From 61a8cce9a27d451ade1ceb3a433dec0c0618346e Mon Sep 17 00:00:00 2001 From: Vitaliy Bravikov Date: Mon, 5 Oct 2026 10:47:37 +0200 Subject: [PATCH] Unit tests are created for the existing components, and current available functionality --- frontend/src/tests/Banner.test.tsx | 13 +++ frontend/src/tests/SearchBar.test.tsx | 48 ++++++++++ frontend/src/tests/SearchPage.test.tsx | 93 +++++++++++++++++++ .../src/tests/SightingDetailPage.test.tsx | 57 ++++++++++++ frontend/src/tests/SightingList.test.tsx | 42 +++++++++ frontend/src/tests/SightingMap.test.tsx | 75 +++++++++++++++ frontend/src/tests/filter-sightings.test.ts | 65 +++++++++++++ frontend/src/tests/format-sighting.test.ts | 43 +++++++++ frontend/src/tests/hooks.test.tsx | 32 +++++++ 9 files changed, 468 insertions(+) create mode 100644 frontend/src/tests/Banner.test.tsx create mode 100644 frontend/src/tests/SearchBar.test.tsx create mode 100644 frontend/src/tests/SearchPage.test.tsx create mode 100644 frontend/src/tests/SightingDetailPage.test.tsx create mode 100644 frontend/src/tests/SightingList.test.tsx create mode 100644 frontend/src/tests/SightingMap.test.tsx create mode 100644 frontend/src/tests/filter-sightings.test.ts create mode 100644 frontend/src/tests/format-sighting.test.ts create mode 100644 frontend/src/tests/hooks.test.tsx 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