Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ jobs:
with:
files: ./coverage/lcov.info
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# UrbanAnalyst

[![codecov](https://codecov.io/gh/mpadge/UrbanAnalyst/branch/main/graph/badge.svg)](https://app.codecov.io/gh/mpadge/UrbanAnalyst)


Source code for [Urban Analyst](https://urbananalyst.city) site. All associated
code is in [the UrbanAnalyst organisation](https://github.com/UrbanAnalyst).
This repo is under my private account just so it can be deployed directly
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "UrbanAnalyst",
"version": "0.2.1",
"version": "0.2.2",
"private": true,
"license": "MIT",
"scripts": {
Expand Down
93 changes: 57 additions & 36 deletions src/components/appBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ vi.mock('@mui/material/AppBar', () => ({
}))

vi.mock('@mui/material/Box', () => ({
default: ({ children, sx, ...props }: any) => (
<div {...props} style={sx} data-testid="mui-box">
{children}
</div>
)
default: (props: any) => {
const { children, sx, ...rest } = props
const { key, ...cleanRest } = rest
return (
<div style={sx} data-testid="mui-box" {...cleanRest}>
{children}
</div>
)
}
}))

vi.mock('@mui/material/Toolbar', () => ({
Expand All @@ -35,54 +39,71 @@ vi.mock('@mui/material/Toolbar', () => ({
}))

vi.mock('@mui/material/Typography', () => ({
default: ({ children, textAlign, ...props }: any) => (
<div {...props} data-text-align={textAlign}>
{children}
</div>
)
default: (props: any) => {
const { children, textAlign, ...rest } = props
const { key, ...cleanRest } = rest
return (
<div data-text-align={textAlign} {...cleanRest}>
{children}
</div>
)
}
}))

vi.mock('@mui/material/Button', () => ({
default: ({ children, color, href, key }: any) => (
<div key={key} data-color={color} data-href={href} role="button">
{children}
</div>
)
default: (props: any) => {
const { children, color, href, ...rest } = props
const { key, ...cleanRest } = rest
return (
<div data-color={color} data-href={href} role="button" {...cleanRest}>
{children}
</div>
)
}
}))

vi.mock('@mui/material/IconButton', () => ({
default: ({ children, size, edge, color, onClick, sx, 'aria-label': ariaLabel }: any) => (
<button
data-size={size}
data-edge={edge}
data-color={color}
onClick={onClick}
aria-label={ariaLabel}
style={sx}
>
{children}
</button>
)
default: (props: any) => {
const { children, size, edge, color, onClick, sx, 'aria-label': ariaLabel, ...rest } = props
const { key, ...cleanRest } = rest
return (
<button
data-size={size}
data-edge={edge}
data-color={color}
onClick={onClick}
aria-label={ariaLabel}
style={sx}
{...cleanRest}
>
{children}
</button>
)
}
}))

vi.mock('@mui/material/Menu', () => ({
default: ({ children, anchorEl, open, onClose, ...props }: any) => {
// Filter out React-specific props that shouldn't go to DOM
const { anchorOrigin, keepMounted, transformOrigin, ...domProps } = props
default: (props: any) => {
const { children, anchorEl, open, onClose, ...domProps } = props
const { anchorOrigin, keepMounted, transformOrigin, key, ...rest } = domProps
return (
<div {...domProps} data-open={open} data-testid="menu">
<div data-open={open} data-testid="menu" {...rest}>
{open ? children : null}
</div>
)
}
}))

vi.mock('@mui/material/MenuItem', () => ({
default: ({ children, onClick, component, href, key }: any) => (
<div key={key} onClick={onClick} data-href={href} data-testid="menu-item">
{children}
</div>
)
default: (props: any) => {
const { children, onClick, component, href, ...rest } = props
const { key, ...cleanRest } = rest
return (
<div onClick={onClick} data-href={href} data-testid="menu-item" {...cleanRest}>
{children}
</div>
)
}
}))

vi.mock('@mui/material/SvgIcon', () => ({
Expand Down
110 changes: 110 additions & 0 deletions src/components/utils/errorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ErrorBoundary, ErrorSkeleton, ComponentErrorSkeleton } from '@/components/utils/errorBoundary'

const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => {
if (shouldThrow) {
throw new Error('Test error')
}
return <div>Content rendered successfully</div>
}

/*
* Note: When testing ErrorBoundary components that intentionally throw errors,
* React's development mode logs verbose error stacks to stderr. These are suppressed
* in vitest.setup.ts via process.stderr.write interception.
*/

describe('ErrorBoundary', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})

afterEach(() => {
vi.restoreAllMocks()
})

it('renders children when no error occurs', () => {
render(
<ErrorBoundary>
<div>Child content</div>
</ErrorBoundary>
)

expect(screen.getByText('Child content')).toBeInTheDocument()
})

it('renders fallback when error occurs', () => {
render(
<ErrorBoundary fallback={<div>Custom fallback</div>}>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)

expect(screen.getByText('Custom fallback')).toBeInTheDocument()
})

it('shows default error UI when error occurs and no fallback provided', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)

expect(screen.getByText('Something went wrong')).toBeInTheDocument()
expect(screen.getByText('Test error')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
})

it('has retry button', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)

const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})

it('shows error message when error occurs', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)

expect(screen.getByText('Something went wrong')).toBeInTheDocument()
expect(screen.getByText('Test error')).toBeInTheDocument()
})
})

describe('ErrorSkeleton', () => {
it('renders correctly', () => {
render(<ErrorSkeleton />)

expect(screen.getByText('Failed to load component')).toBeInTheDocument()
})

it('has correct styling structure', () => {
const { container } = render(<ErrorSkeleton />)
const box = container.querySelector('[class*="MuiBox"]')

expect(box).toBeInTheDocument()
})
})

describe('ComponentErrorSkeleton', () => {
it('renders correctly', () => {
render(<ComponentErrorSkeleton />)

expect(screen.getByText('Failed to load map')).toBeInTheDocument()
})

it('has full viewport width and height', () => {
const { container } = render(<ComponentErrorSkeleton />)
const box = container.querySelector('[class*="MuiBox"]')

expect(box).toBeInTheDocument()
})
})
126 changes: 126 additions & 0 deletions src/components/utils/layerUtils.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest'
import { calculateLayerRanges } from '@/components/utils/layerUtils'
import { DataRangeKeys, Data2RangeKeys, CityDataProps } from '@/data/interfaces'

describe('calculateLayerRanges', () => {
const mockCitiesArray: CityDataProps[] = [
{
city: 'City1',
dataRanges: {
social_index: [0, 0.2, 0.8, 1],
transport: [0, 0.1, 0.9, 1],
bike_index: [0, 0.3, 0.7, 1]
} as Record<DataRangeKeys, [number, number, number, number]>,
dataRangesPaired: {
transport_bike: [0, 0.15, 0.85, 1]
} as Record<Data2RangeKeys, [number, number, number, number]>
}
]

it('returns correct values for single layer mode', () => {
const result = calculateLayerRanges(
0,
'social_index',
'',
'Single',
mockCitiesArray
)

expect(result.this_layer).toBe('social_index')
expect(result.layer_start).toBe(0)
expect(result.layer_min).toBe(0.2)
expect(result.layer_max).toBe(0.8)
expect(result.layer_stop).toBe(1)
expect(result.dual_layers).toBe(false)
})

it('returns correct values for paired layer mode with dual layers', () => {
const result = calculateLayerRanges(
0,
'transport',
'bike_index',
'Paired',
mockCitiesArray
)

expect(result.this_layer).toBe('transport_bike')
expect(result.layer_start).toBe(0)
expect(result.layer_min).toBe(0.15)
expect(result.layer_max).toBe(0.85)
expect(result.layer_stop).toBe(1)
expect(result.dual_layers).toBe(true)
expect(result.these_layers).toBe('transport_bike')
})

it('handles layer name replacement correctly', () => {
const result = calculateLayerRanges(
0,
'social_index',
'',
'Single',
mockCitiesArray
)

expect(result.this_layer).toBe('social_index')
})

it('returns fallback for paired mode when no paired keys exist', () => {
const mockCitiesArrayNoPaired: CityDataProps[] = [
{
city: 'City1',
dataRanges: {
social_index: [0, 0.2, 0.8, 1]
} as Record<DataRangeKeys, [number, number, number, number]>,
dataRangesPaired: {} as Record<Data2RangeKeys, [number, number, number, number]>
}
]

const result = calculateLayerRanges(
0,
'social_index',
'',
'Paired',
mockCitiesArrayNoPaired
)

expect(result.dual_layers).toBe(false)
expect(result.this_layer).toBe('social_index')
})

it('handles different layer order in paired keys', () => {
const mockCitiesArrayReversed: CityDataProps[] = [
{
city: 'City1',
dataRanges: {
transport: [0, 0.1, 0.9, 1]
} as Record<DataRangeKeys, [number, number, number, number]>,
dataRangesPaired: {
bike_transport: [0, 0.15, 0.85, 1]
} as Record<Data2RangeKeys, [number, number, number, number]>
}
]

const result = calculateLayerRanges(
0,
'transport',
'bike_index',
'Paired',
mockCitiesArrayReversed
)

expect(result.these_layers).toBe('bike_transport')
expect(result.dual_layers).toBe(true)
})

it('handles layer name replacement correctly', () => {
const result = calculateLayerRanges(
0,
'social_index',
'',
'Single',
mockCitiesArray
)

expect(result.this_layer).toBe('social_index')
})
})
Loading