diff --git a/frontend/src/tests/Banner.test.tsx b/frontend/src/tests/Banner.test.tsx
new file mode 100644
index 0000000..f3d5723
--- /dev/null
+++ b/frontend/src/tests/Banner.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import Banner from '../components/Banner'
+
+describe('Banner', () => {
+ it('shows the UFOund it site title', () => {
+ render()
+
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
+ 'UFOund it',
+ )
+ })
+})
diff --git a/frontend/src/tests/SearchBar.test.tsx b/frontend/src/tests/SearchBar.test.tsx
new file mode 100644
index 0000000..2656b5a
--- /dev/null
+++ b/frontend/src/tests/SearchBar.test.tsx
@@ -0,0 +1,48 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import SearchBar from '../components/SearchBar'
+
+describe('SearchBar', () => {
+ it('renders the current query and sort option', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('searchbox', { name: 'Search sightings' })).toHaveValue(
+ 'Oslo',
+ )
+ expect(screen.getByRole('combobox', { name: 'Sort by' })).toHaveValue(
+ 'city-asc',
+ )
+ })
+
+ it('reports query and sort changes without submitting the form', () => {
+ const onQueryChange = vi.fn()
+ const onSortChange = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ fireEvent.change(screen.getByRole('searchbox'), {
+ target: { value: 'triangle' },
+ })
+ fireEvent.change(screen.getByRole('combobox'), {
+ target: { value: 'shape-asc' },
+ })
+ fireEvent.submit(container.querySelector('form')!)
+
+ expect(onQueryChange).toHaveBeenCalledWith('triangle')
+ expect(onSortChange).toHaveBeenCalledWith('shape-asc')
+ expect(container.querySelector('form')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/tests/SearchPage.test.tsx b/frontend/src/tests/SearchPage.test.tsx
new file mode 100644
index 0000000..bbfc34f
--- /dev/null
+++ b/frontend/src/tests/SearchPage.test.tsx
@@ -0,0 +1,93 @@
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { MemoryRouter } from 'react-router'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import SearchPage from '../pages/SearchPage'
+
+vi.mock('../components/SightingMap', () => ({
+ default: () =>
,
+}))
+
+function renderSearchPage(initialEntry = '/') {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('SearchPage', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('loads a query from the URL and filters after the debounce delay', () => {
+ vi.useFakeTimers()
+ renderSearchPage('/?q=Oslo')
+
+ expect(screen.getByRole('searchbox')).toHaveValue('Oslo')
+ expect(screen.getByText('1 result')).toBeInTheDocument()
+
+ act(() => vi.advanceTimersByTime(300))
+
+ expect(screen.getByText('1 result')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Oslo, NO' })).toBeInTheDocument()
+ })
+
+ it('updates displayed results when the search and sort controls change', () => {
+ vi.useFakeTimers()
+ renderSearchPage()
+
+ expect(screen.getByRole('combobox', { name: 'Sort by' })).toHaveValue('date-desc')
+ expect(screen.getAllByRole('link')[0]).toHaveTextContent('Bergen, NO')
+
+ fireEvent.change(screen.getByRole('searchbox'), {
+ target: { value: 'Phoenix' },
+ })
+ act(() => vi.advanceTimersByTime(300))
+
+ expect(screen.getByRole('link', { name: 'Phoenix, AZ, US' })).toBeInTheDocument()
+ expect(screen.getByText('1 result')).toBeInTheDocument()
+
+ fireEvent.change(screen.getByRole('searchbox'), { target: { value: '' } })
+ act(() => vi.advanceTimersByTime(300))
+ fireEvent.change(screen.getByRole('combobox', { name: 'Sort by' }), {
+ target: { value: 'city-asc' },
+ })
+
+ const locations = screen
+ .getAllByRole('link')
+ .map((link) => link.textContent)
+ expect(locations[0]).toBe('Bergen, NO')
+ expect(locations[1]).toBe('Edna, TX, US')
+ })
+
+ it('sorts results oldest first', () => {
+ vi.useFakeTimers()
+ renderSearchPage()
+
+ fireEvent.change(screen.getByRole('combobox', { name: 'Sort by' }), {
+ target: { value: 'date-asc' },
+ })
+
+ const locations = screen
+ .getAllByRole('link')
+ .map((link) => link.textContent)
+ expect(locations[0]).toBe('Phoenix, AZ, US')
+ expect(locations[1]).toBe('San Marcos, TX, US')
+ })
+
+ it('sorts results newest first', () => {
+ vi.useFakeTimers()
+ renderSearchPage()
+
+ fireEvent.change(screen.getByRole('combobox', { name: 'Sort by' }), {
+ target: { value: 'date-desc' },
+ })
+
+ const locations = screen
+ .getAllByRole('link')
+ .map((link) => link.textContent)
+ expect(locations[0]).toBe('Bergen, NO')
+ expect(locations[1]).toBe('Tromsø, NO')
+ })
+})
diff --git a/frontend/src/tests/SightingDetailPage.test.tsx b/frontend/src/tests/SightingDetailPage.test.tsx
new file mode 100644
index 0000000..43222d6
--- /dev/null
+++ b/frontend/src/tests/SightingDetailPage.test.tsx
@@ -0,0 +1,57 @@
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter, Route, Routes } from 'react-router'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import SightingDetailPage from '../pages/SightingDetailPage'
+
+vi.mock('../components/SightingMap', () => ({
+ default: ({
+ interactive,
+ zoom,
+ }: {
+ interactive?: boolean
+ zoom?: number
+ }) => ,
+}))
+
+function renderDetailPage(path: string, state?: unknown) {
+ return render(
+
+
+ } />
+
+ ,
+ )
+}
+
+describe('SightingDetailPage', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('renders sighting details and preserves the originating search link', () => {
+ vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
+ renderDetailPage('/sightings/1', { fromSearch: '?q=San+Marcos' })
+
+ expect(screen.getByRole('heading', { name: 'San Marcos, TX, US' })).toBeInTheDocument()
+ expect(screen.getByText('45 min')).toBeInTheDocument()
+ expect(screen.getByText(/This event took place/)).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Back to results' })).toHaveAttribute(
+ 'href',
+ '/?q=San+Marcos',
+ )
+ expect(screen.getByTestId('detail-map')).toHaveAttribute('data-interactive', 'true')
+ expect(screen.getByTestId('detail-map')).toHaveAttribute('data-zoom', '11')
+ expect(document.title).toBe('San Marcos, TX, US · UFOund it')
+ })
+
+ it('handles missing sightings and links back to the unfiltered list', () => {
+ vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
+ renderDetailPage('/sightings/missing')
+
+ expect(screen.getByRole('heading', { name: 'Sighting not found' })).toBeInTheDocument()
+ expect(screen.getByText('There is no sighting with id “missing”.')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Back to results' })).toHaveAttribute('href', '/')
+ expect(screen.queryByTestId('detail-map')).not.toBeInTheDocument()
+ expect(document.title).toBe('Sighting not found · UFOund it')
+ })
+})
diff --git a/frontend/src/tests/SightingList.test.tsx b/frontend/src/tests/SightingList.test.tsx
new file mode 100644
index 0000000..8fb197c
--- /dev/null
+++ b/frontend/src/tests/SightingList.test.tsx
@@ -0,0 +1,42 @@
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter } from 'react-router'
+import { describe, expect, it, vi } from 'vitest'
+import SightingList from '../components/SightingList'
+import { mockSightings } from '../data/mock-sightings'
+
+vi.mock('../components/SightingMap', () => ({
+ default: () => ,
+}))
+
+describe('SightingList', () => {
+ it('renders sightings, formatted fields, and detail links', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('1 result')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'San Marcos, TX, US' })).toHaveAttribute(
+ 'href',
+ '/sightings/1',
+ )
+ expect(screen.getByText('cylinder')).toBeInTheDocument()
+ expect(screen.getByText('45 min')).toBeInTheDocument()
+ expect(screen.getByText(mockSightings[0].comments)).toBeInTheDocument()
+ expect(screen.getByTestId('sighting-map')).toBeInTheDocument()
+ })
+
+ it('uses plural result text and explains when no sightings match', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('0 results')).toBeInTheDocument()
+ expect(
+ screen.getByText(/No sightings match your search/),
+ ).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/tests/SightingMap.test.tsx b/frontend/src/tests/SightingMap.test.tsx
new file mode 100644
index 0000000..0c920ad
--- /dev/null
+++ b/frontend/src/tests/SightingMap.test.tsx
@@ -0,0 +1,75 @@
+import type { ReactNode } from 'react'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import SightingMap from '../components/SightingMap'
+import { mockSightings } from '../data/mock-sightings'
+
+type MapProps = {
+ children: ReactNode
+ center: [number, number]
+ zoom: number
+ dragging: boolean
+ zoomControl: boolean
+ doubleClickZoom: boolean
+ touchZoom: boolean
+ boxZoom: boolean
+ keyboard: boolean
+}
+
+vi.mock('react-leaflet', () => ({
+ MapContainer: ({ children, center, zoom, dragging, zoomControl, keyboard }: MapProps) => (
+
+ {children}
+
+ ),
+ TileLayer: ({ url }: { url: string }) => ,
+ Marker: ({ position, children }: { position: [number, number]; children: ReactNode }) => (
+
+ {children}
+
+ ),
+ Popup: ({ children }: { children: ReactNode }) => {children}
,
+}))
+
+describe('SightingMap', () => {
+ it('shows the sighting location on a non-interactive thumbnail by default', () => {
+ const sighting = mockSightings[0]
+ render()
+
+ expect(screen.getByLabelText('Map showing San Marcos')).toBeInTheDocument()
+ expect(screen.getByTestId('map-container')).toHaveAttribute(
+ 'data-center',
+ `${sighting.latitude},${sighting.longitude}`,
+ )
+ expect(screen.getByTestId('map-container')).toHaveAttribute('data-zoom', '10')
+ expect(screen.getByTestId('map-container')).toHaveAttribute('data-dragging', 'false')
+ expect(screen.getByTestId('map-container')).toHaveAttribute(
+ 'data-zoom-control',
+ 'false',
+ )
+ expect(screen.getByTestId('map-marker')).toHaveAttribute(
+ 'data-position',
+ `${sighting.latitude},${sighting.longitude}`,
+ )
+ expect(screen.getByText('San Marcos, TX, US')).toBeInTheDocument()
+ })
+
+ it('enables map controls when interactive', () => {
+ render()
+
+ expect(screen.getByTestId('map-container')).toHaveAttribute('data-zoom', '11')
+ expect(screen.getByTestId('map-container')).toHaveAttribute('data-dragging', 'true')
+ expect(screen.getByTestId('map-container')).toHaveAttribute(
+ 'data-zoom-control',
+ 'true',
+ )
+ expect(screen.getByTestId('map-container')).toHaveAttribute('data-keyboard', 'true')
+ })
+})
diff --git a/frontend/src/tests/filter-sightings.test.ts b/frontend/src/tests/filter-sightings.test.ts
new file mode 100644
index 0000000..0da4ee0
--- /dev/null
+++ b/frontend/src/tests/filter-sightings.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest'
+import type { SightingSortOption, UfoSighting } from '../types/ufo-sighting'
+import {
+ filterSightings,
+ searchSightings,
+ sortSightings,
+} from '../utils/filter-sightings'
+
+const sightings: UfoSighting[] = [
+ {
+ id: 'b',
+ datetime: '2002-01-01T00:00:00',
+ city: 'Oslo',
+ state: '',
+ country: 'no',
+ shape: 'light',
+ durationSeconds: 30,
+ comments: 'A silent glow',
+ datePosted: '2002-01-02',
+ latitude: 59.9,
+ longitude: 10.7,
+ },
+ {
+ id: 'a',
+ datetime: '2001-01-01T00:00:00',
+ city: 'Bergen',
+ state: '',
+ country: 'no',
+ shape: 'triangle',
+ durationSeconds: 60,
+ comments: 'Three lights',
+ datePosted: '2001-01-02',
+ latitude: 60.4,
+ longitude: 5.3,
+ },
+]
+
+describe('sighting filtering and sorting', () => {
+ it('matches city, shape, and comments case-insensitively', () => {
+ expect(filterSightings(sightings, 'OSLO').map(({ id }) => id)).toEqual(['b'])
+ expect(filterSightings(sightings, 'TRIANGLE').map(({ id }) => id)).toEqual(['a'])
+ expect(filterSightings(sightings, 'silent glow').map(({ id }) => id)).toEqual(['b'])
+ })
+
+ it('treats a whitespace-only query as empty', () => {
+ expect(filterSightings(sightings, ' ')).toBe(sightings)
+ })
+
+ it.each([
+ ['date-desc', ['b', 'a']],
+ ['date-asc', ['a', 'b']],
+ ['city-asc', ['a', 'b']],
+ ['shape-asc', ['b', 'a']],
+ ] as [SightingSortOption, string[]][])('sorts by %s without mutating input', (sort, ids) => {
+ const original = [...sightings]
+ expect(sortSightings(sightings, sort).map(({ id }) => id)).toEqual(ids)
+ expect(sightings).toEqual(original)
+ })
+
+ it('filters before sorting in the combined search helper', () => {
+ expect(searchSightings(sightings, 'lights', 'city-asc').map(({ id }) => id)).toEqual([
+ 'a',
+ ])
+ })
+})
\ No newline at end of file
diff --git a/frontend/src/tests/format-sighting.test.ts b/frontend/src/tests/format-sighting.test.ts
new file mode 100644
index 0000000..78b19f5
--- /dev/null
+++ b/frontend/src/tests/format-sighting.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest'
+import type { UfoSighting } from '../types/ufo-sighting'
+import {
+ formatDate,
+ formatDuration,
+ formatLocation,
+} from '../utils/format-sighting'
+
+const sighting: UfoSighting = {
+ id: '1',
+ datetime: '2000-01-02T12:00:00',
+ city: 'Oslo',
+ state: 'os',
+ country: 'no',
+ shape: 'light',
+ durationSeconds: 60,
+ comments: 'A light in the sky',
+ datePosted: '2000-01-03',
+ latitude: 59.9,
+ longitude: 10.7,
+}
+
+describe('sighting formatters', () => {
+ it('formats duration in seconds, whole minutes, and mixed units', () => {
+ expect(formatDuration(42)).toBe('42 sec')
+ expect(formatDuration(120)).toBe('2 min')
+ expect(formatDuration(125)).toBe('2 min 5 sec')
+ })
+
+ it('formats a location with available region codes in uppercase', () => {
+ expect(formatLocation(sighting)).toBe('Oslo, OS, NO')
+ expect(formatLocation({ ...sighting, state: '' })).toBe('Oslo, NO')
+ expect(formatLocation({ ...sighting, state: '', country: '' })).toBe('Oslo')
+ })
+
+ it('formats dates using the English medium date style', () => {
+ expect(formatDate('2000-01-02T12:00:00')).toBe(
+ new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(
+ new Date('2000-01-02T12:00:00'),
+ ),
+ )
+ })
+})
\ No newline at end of file
diff --git a/frontend/src/tests/hooks.test.tsx b/frontend/src/tests/hooks.test.tsx
new file mode 100644
index 0000000..dceda91
--- /dev/null
+++ b/frontend/src/tests/hooks.test.tsx
@@ -0,0 +1,32 @@
+import { act, renderHook } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { mockSightings } from '../data/mock-sightings'
+import { useDebouncedValue } from '../hooks/use-debounced-value'
+import { useSighting } from '../hooks/use-sighting'
+
+describe('sighting hooks', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('returns a value only after it remains unchanged for the delay', () => {
+ vi.useFakeTimers()
+ const { result, rerender } = renderHook(
+ ({ value }) => useDebouncedValue(value, 300),
+ { initialProps: { value: 'first' } },
+ )
+
+ rerender({ value: 'second' })
+ act(() => vi.advanceTimersByTime(299))
+ expect(result.current).toBe('first')
+
+ act(() => vi.advanceTimersByTime(1))
+ expect(result.current).toBe('second')
+ })
+
+ it('looks up an existing sighting and returns undefined for an unknown id', () => {
+ expect(useSighting(mockSightings[0].id)).toBe(mockSightings[0])
+ expect(useSighting('not-a-sighting')).toBeUndefined()
+ expect(useSighting(undefined)).toBeUndefined()
+ })
+})
\ No newline at end of file