From afa9d6f8458190e27620799d4e23ab816d279e7b Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Thu, 30 Jul 2026 16:52:30 -0400 Subject: [PATCH 1/6] Add test run to CI script --- .github/workflows/build-check.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml index ddf2d34..35d3122 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-check.yml @@ -19,5 +19,8 @@ jobs: - name: Install deps run: npm ci + - name: Run tests + run: npm run test:run + - name: Verify project builds run: npm run build \ No newline at end of file From 0bd79f6df57caf55d8c2b04bfa6f8f512e6bd9d0 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Thu, 30 Jul 2026 16:52:44 -0400 Subject: [PATCH 2/6] Add basic setup and fetch tests --- src/api/client.ts | 2 +- tests/App.test.tsx | 25 +++- tests/api/client.test.ts | 153 ++++++++++++++++++++++ tests/setup.ts | 6 +- tests/utils/lightcurveDataHelpers.test.ts | 40 ++++++ 5 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 tests/api/client.test.ts create mode 100644 tests/utils/lightcurveDataHelpers.test.ts diff --git a/src/api/client.ts b/src/api/client.ts index 49edfaa..3688552 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -9,7 +9,7 @@ import { SourceSummary, } from '../types'; -class LightcurveApiClient { +export class LightcurveApiClient { private baseUrl: string; private fluxUrlStub: string; diff --git a/tests/App.test.tsx b/tests/App.test.tsx index b39cb87..3600a6d 100644 --- a/tests/App.test.tsx +++ b/tests/App.test.tsx @@ -1,17 +1,34 @@ import { MemoryRouter } from 'react-router'; import { render, screen } from '@testing-library/react'; -import { describe, it } from 'vitest'; +import { describe, it, expect } from 'vitest'; import App from '../src/App'; describe('App', () => { - it('renders App component', () => { + it('renders navigation and footer on unmatched routes', () => { render( - + ); - screen.debug(); + expect( + screen.getByRole('link', { name: /SO Light Curve Viewer/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /the documentation/i }) + ).toBeInTheDocument(); + }); + + it('renders the not-found page for an unmatched route', () => { + render( + + + + ); + + expect( + screen.getByRole('heading', { name: /page not found/i }) + ).toBeInTheDocument(); }); }); diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts new file mode 100644 index 0000000..a1b6a39 --- /dev/null +++ b/tests/api/client.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { LightcurveApiClient } from '../../src/api/client'; + +const BASE_URL = 'http://test.api'; + +function jsonResponse(data: unknown, ok = true, status = 200) { + return { ok, status, json: () => Promise.resolve(data) }; +} + +function blobResponse(ok = true, status = 200) { + return { ok, status, blob: () => Promise.resolve(new Blob(['data'])) }; +} + +describe('LightcurveApiClient', () => { + let client: LightcurveApiClient; + let fetchMock: ReturnType; + let revokeObjectURLMock: ReturnType; + + beforeEach(() => { + client = new LightcurveApiClient(BASE_URL); + fetchMock = vi.fn(); + global.fetch = fetchMock; + URL.createObjectURL = vi.fn().mockReturnValue('blob:mock-url'); + revokeObjectURLMock = vi.fn(); + URL.revokeObjectURL = revokeObjectURLMock; + // jsdom attempts real navigation on anchor clicks, which logs noisy + // "Not implemented" errors; downloads aren't real navigation anyway. + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('GET endpoints', () => { + it('fetches sources from /sources/', async () => { + const sources = [{ source_id: '1' }]; + fetchMock.mockResolvedValueOnce(jsonResponse(sources)); + + const result = await client.getSources(); + + expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/sources/`); + expect(result).toEqual(sources); + }); + + it('fetches a single source by id', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ source_id: '42' })); + + await client.getSourceData('42'); + + expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/sources/42`); + }); + + it('fetches a source summary', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({})); + + await client.getSourceSummary('42'); + + expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/sources/42/summary`); + }); + + it('fetches nearby sources via a cone search query string', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse([])); + + await client.getNearbySources('?ra=1&dec=2&radius=0.5'); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/sources/cone?ra=1&dec=2&radius=0.5` + ); + }); + + it('fetches the sources feed with a start offset', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ items: [] })); + + await client.getSourcesFeed(10); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/sources/feed?start=10` + ); + }); + + it('fetches lightcurve data with a selection strategy', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({})); + + await client.getLightcurveData('42', 'frequency'); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/lightcurves/42/unbinned?selection_strategy=frequency` + ); + }); + + it('throws when a GET request fails', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(null, false, 500)); + + await expect(client.getSourceData('42')).rejects.toThrow( + 'GET /sources/42 failed: 500' + ); + }); + }); + + describe('downloadCutout', () => { + it('fetches the cutout and triggers a download with the expected filename', async () => { + fetchMock.mockResolvedValueOnce(blobResponse()); + const appendChildSpy = vi.spyOn(document.body, 'appendChild'); + + await client.downloadCutout('src-1', 'meas-1', 'png'); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/cutouts/flux/src-1/meas-1?ext=png` + ); + + const anchor = appendChildSpy.mock.calls + .map(([node]) => node) + .find( + (node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement + ); + + expect(anchor?.download).toBe('cutout-src-1-meas-1.png'); + expect(anchor?.href).toBe('blob:mock-url'); + expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('throws when the cutout fetch fails', async () => { + fetchMock.mockResolvedValueOnce(blobResponse(false, 404)); + + await expect( + client.downloadCutout('src-1', 'meas-1', 'png') + ).rejects.toThrow('Failed to get cutout: 404'); + }); + }); + + describe('downloadTableData', () => { + it('fetches table data and triggers a download without a measurement id', async () => { + fetchMock.mockResolvedValueOnce(blobResponse()); + const appendChildSpy = vi.spyOn(document.body, 'appendChild'); + + await client.downloadTableData('src-1', 'csv'); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/lightcurves/src-1/all/download?format=csv` + ); + + const anchor = appendChildSpy.mock.calls + .map(([node]) => node) + .find( + (node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement + ); + + expect(anchor?.download).toBe('source-data-src-1.csv'); + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 5dcf3fb..2f642a5 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,8 +1,6 @@ -import { expect, afterEach, vi, beforeAll } from 'vitest'; +import { afterEach, vi, beforeAll } from 'vitest'; import { cleanup } from '@testing-library/react'; -import * as matchers from '@testing-library/jest-dom/matchers'; - -expect.extend(matchers); +import '@testing-library/jest-dom/vitest'; global.URL.createObjectURL = vi.fn(); diff --git a/tests/utils/lightcurveDataHelpers.test.ts b/tests/utils/lightcurveDataHelpers.test.ts new file mode 100644 index 0000000..22d3c32 --- /dev/null +++ b/tests/utils/lightcurveDataHelpers.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; + +import { generateBaseMarkerConfig } from '../../src/utils/lightcurveDataHelpers'; +import { BaseScatterData } from '../../src/components/Lightcurve'; + +function makeData(flags: number[]) { + return { flags } as unknown as BaseScatterData; +} + +describe('generateBaseMarkerConfig', () => { + it('returns empty marker line arrays for empty data', () => { + const result = generateBaseMarkerConfig(makeData([])); + + expect(result.marker.line.color).toEqual([]); + expect(result.marker.line.width).toEqual([]); + }); + + it('applies default marker styling to unflagged points', () => { + const result = generateBaseMarkerConfig(makeData([0, 0, 0])); + + expect(result.marker.size).toBe(5); + expect(result.marker.line.color).toEqual(['#000', '#000', '#000']); + expect(result.marker.line.width).toEqual([0, 0, 0]); + }); + + it('highlights flagged points in red with a thicker line', () => { + const result = generateBaseMarkerConfig(makeData([0, 1, 0, 1])); + + expect(result.marker.line.color).toEqual(['#000', 'red', '#000', 'red']); + expect(result.marker.line.width).toEqual([0, 1.5, 0, 1.5]); + }); + + it('respects custom marker size, line color, and line width', () => { + const result = generateBaseMarkerConfig(makeData([0, 1]), 10, '#fff', 2); + + expect(result.marker.size).toBe(10); + expect(result.marker.line.color).toEqual(['#fff', 'red']); + expect(result.marker.line.width).toEqual([2, 1.5]); + }); +}); From f871f2f5919663f05f1f7dfd081ec69e3a893457 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Thu, 30 Jul 2026 16:56:16 -0400 Subject: [PATCH 3/6] Add tests for useQuery hook --- tests/hooks/useQuery.test.ts | 115 +++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/hooks/useQuery.test.ts diff --git a/tests/hooks/useQuery.test.ts b/tests/hooks/useQuery.test.ts new file mode 100644 index 0000000..e7d3d5b --- /dev/null +++ b/tests/hooks/useQuery.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; + +import { useQuery } from '../../src/hooks/useQuery'; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +type Props = { queryFn: () => Promise; queryKey: unknown[] }; + +describe('useQuery', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns initialData while the query is pending, then resolves', async () => { + const deferred = createDeferred(); + const queryFn = vi.fn(() => deferred.promise); + + const { result } = renderHook(() => + useQuery({ queryFn, queryKey: ['a'], initialData: 'initial' }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(true)); + expect(result.current.data).toBe('initial'); + expect(result.current.error).toBeNull(); + + deferred.resolve('resolved-value'); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toBe('resolved-value'); + expect(result.current.error).toBeNull(); + }); + + it('sets error and logs it when the query rejects, leaving data unchanged', async () => { + const error = new Error('boom'); + const queryFn = vi.fn(() => Promise.reject(error)); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const { result } = renderHook(() => + useQuery({ queryFn, queryKey: ['a'], initialData: 'initial' }) + ); + + await waitFor(() => expect(result.current.error).toBe(error)); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBe('initial'); + expect(consoleErrorSpy).toHaveBeenCalledWith(String(error)); + }); + + it('refetches when the queryKey changes', async () => { + const queryFnA = vi.fn(() => Promise.resolve('a-value')); + const queryFnB = vi.fn(() => Promise.resolve('b-value')); + + const { result, rerender } = renderHook< + ReturnType>, + Props + >( + ({ queryFn, queryKey }) => + useQuery({ queryFn, queryKey, initialData: 'initial' }), + { + initialProps: { queryFn: queryFnA, queryKey: ['a'] }, + } + ); + + await waitFor(() => expect(result.current.data).toBe('a-value')); + + rerender({ queryFn: queryFnB, queryKey: ['b'] }); + + await waitFor(() => expect(result.current.data).toBe('b-value')); + expect(queryFnA).toHaveBeenCalledTimes(1); + expect(queryFnB).toHaveBeenCalledTimes(1); + }); + + it('ignores a stale response if the query key changes before the previous fetch resolves', async () => { + const deferredA = createDeferred(); + const deferredB = createDeferred(); + const queryFnA = vi.fn(() => deferredA.promise); + const queryFnB = vi.fn(() => deferredB.promise); + + const { result, rerender } = renderHook< + ReturnType>, + Props + >( + ({ queryFn, queryKey }) => + useQuery({ queryFn, queryKey, initialData: 'initial' }), + { + initialProps: { queryFn: queryFnA, queryKey: ['a'] }, + } + ); + + rerender({ queryFn: queryFnB, queryKey: ['b'] }); + + deferredB.resolve('b-value'); + await waitFor(() => expect(result.current.data).toBe('b-value')); + + await act(async () => { + deferredA.resolve('a-value'); + await Promise.resolve(); + }); + + expect(result.current.data).toBe('b-value'); + }); +}); From 420b1672b26a4e928617aa808bffc8d5223b7356 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Thu, 30 Jul 2026 17:05:20 -0400 Subject: [PATCH 4/6] Add some component tests --- tests/components/Badge.test.tsx | 19 +++++ tests/components/RangeInput.test.tsx | 103 +++++++++++++++++++++++++ tests/components/ToggleSwitch.test.tsx | 83 ++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 tests/components/Badge.test.tsx create mode 100644 tests/components/RangeInput.test.tsx create mode 100644 tests/components/ToggleSwitch.test.tsx diff --git a/tests/components/Badge.test.tsx b/tests/components/Badge.test.tsx new file mode 100644 index 0000000..37f09f8 --- /dev/null +++ b/tests/components/Badge.test.tsx @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import { Badge } from '../../src/components/Badge'; + +describe('Badge', () => { + it('renders the label and content', () => { + render(); + + expect(screen.getByRole('heading', { name: 'RA' })).toBeInTheDocument(); + expect(screen.getByText('123.456')).toBeInTheDocument(); + }); + + it('renders ReactNode content, not just strings', () => { + render(Active} />); + + expect(screen.getByText('Active')).toBeInTheDocument(); + }); +}); diff --git a/tests/components/RangeInput.test.tsx b/tests/components/RangeInput.test.tsx new file mode 100644 index 0000000..baba74a --- /dev/null +++ b/tests/components/RangeInput.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { RangeInput } from '../../src/components/RangeInput'; + +describe('RangeInput', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders the label, default value, and units', () => { + render( + {}} + /> + ); + + const paragraph = screen.getByText( + (_, el) => el?.tagName === 'P' && el.textContent === 'Radius 3 deg' + ); + expect(paragraph).toBeInTheDocument(); + }); + + it('updates the displayed value on change without immediately calling onFinalChange', () => { + const onFinalChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByRole('slider'), { target: { value: '7' } }); + + expect(screen.getByRole('slider')).toHaveValue('7'); + expect(onFinalChange).not.toHaveBeenCalled(); + }); + + it('calls onFinalChange with the committed value after the debounce delay', () => { + const onFinalChange = vi.fn(); + + render( + + ); + + const slider = screen.getByRole('slider'); + fireEvent.change(slider, { target: { value: '7' } }); + fireEvent.mouseUp(slider); + + expect(onFinalChange).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + + expect(onFinalChange).toHaveBeenCalledTimes(1); + expect(onFinalChange).toHaveBeenCalledWith(7); + }); + + it('debounces rapid commits into a single call with the latest value', () => { + const onFinalChange = vi.fn(); + + render( + + ); + + const slider = screen.getByRole('slider'); + + fireEvent.change(slider, { target: { value: '3' } }); + fireEvent.mouseUp(slider); + vi.advanceTimersByTime(50); + + fireEvent.change(slider, { target: { value: '9' } }); + fireEvent.mouseUp(slider); + vi.advanceTimersByTime(100); + + expect(onFinalChange).toHaveBeenCalledTimes(1); + expect(onFinalChange).toHaveBeenCalledWith(9); + }); +}); diff --git a/tests/components/ToggleSwitch.test.tsx b/tests/components/ToggleSwitch.test.tsx new file mode 100644 index 0000000..a50a8a1 --- /dev/null +++ b/tests/components/ToggleSwitch.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ToggleSwitch } from '../../src/components/ToggleSwitch'; + +describe('ToggleSwitch', () => { + it('renders the checked/unchecked labels and reflects the checked prop', () => { + render( + {}} + disabled={false} + checkedLabel="On" + uncheckedLabel="Off" + /> + ); + + expect(screen.getByText('On')).toBeInTheDocument(); + expect(screen.getByText('Off')).toBeInTheDocument(); + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + it('calls onChange when toggled', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render( + + ); + + await user.click(screen.getByRole('checkbox')); + + expect(handleChange).toHaveBeenCalledTimes(1); + }); + + it('disables the checkbox and shows the disabled message as a title when disabled', () => { + render( + {}} + disabled={true} + disabledMessage="Not available for this source" + checkedLabel="On" + uncheckedLabel="Off" + /> + ); + + expect(screen.getByRole('checkbox')).toBeDisabled(); + expect( + screen.getByTitle('Not available for this source') + ).toBeInTheDocument(); + }); + + it('does not fire onChange when disabled', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render( + + ); + + await user.click(screen.getByRole('checkbox')); + + expect(handleChange).not.toHaveBeenCalled(); + }); +}); From 35e3242cea52cdad54f8e7777d1cbd0be91ef873 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Thu, 30 Jul 2026 17:16:48 -0400 Subject: [PATCH 5/6] Add tests for table pagination --- .../TablePaginationControls.test.tsx | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/components/TablePaginationControls.test.tsx diff --git a/tests/components/TablePaginationControls.test.tsx b/tests/components/TablePaginationControls.test.tsx new file mode 100644 index 0000000..438e5a6 --- /dev/null +++ b/tests/components/TablePaginationControls.test.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + ColumnDef, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from '@tanstack/react-table'; + +import { TablePaginationControls } from '../../src/components/TablePaginationControls'; +import { TableProps } from '../../src/components/Table'; + +type Row = { id: number }; + +const columns: ColumnDef[] = [{ accessorKey: 'id', header: 'ID' }]; + +function makeRows(count: number): Row[] { + return Array.from({ length: count }, (_, i) => ({ id: i + 1 })); +} + +function TableModeHarness({ + data, + pageSize, +}: { + data: Row[]; + pageSize: number; +}) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { pagination: { pageIndex: 0, pageSize } }, + }); + + return ( + + ); +} + +function CustomModeHarness({ + totalItems, + itemsPerPage, +}: { + totalItems: number; + itemsPerPage: number; +}) { + const [currentPageNumber, setCurrentPageNumber] = useState(1); + const table = useReactTable({ + data: [] as Row[], + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + const customPaginationState: TableProps['customPaginationState'] = { + totalItems, + itemsPerPage, + currentPageNumber, + setCurrentPageNumber, + }; + + return ( + + ); +} + +describe('TablePaginationControls', () => { + describe('table-driven pagination', () => { + it('shows the current page details and disables first/previous on page one', () => { + render(); + + expect(screen.getByText('1 - 2')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByText(/Page 1 of 3/)).toBeInTheDocument(); + expect(screen.getByTitle('Go to first page')).toBeDisabled(); + expect(screen.getByTitle('Go to previous page')).toBeDisabled(); + expect(screen.getByTitle('Go to next page')).toBeEnabled(); + expect(screen.getByTitle('Go to last page')).toBeEnabled(); + }); + + it('navigates forward and backward through pages', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTitle('Go to next page')); + expect(screen.getByText(/Page 2 of 3/)).toBeInTheDocument(); + expect(screen.getByText('3 - 4')).toBeInTheDocument(); + + await user.click(screen.getByTitle('Go to next page')); + expect(screen.getByText(/Page 3 of 3/)).toBeInTheDocument(); + expect(screen.getByText('5 - 5')).toBeInTheDocument(); + expect(screen.getByTitle('Go to next page')).toBeDisabled(); + expect(screen.getByTitle('Go to last page')).toBeDisabled(); + + await user.click(screen.getByTitle('Go to previous page')); + expect(screen.getByText(/Page 2 of 3/)).toBeInTheDocument(); + }); + + it('jumps to the first and last pages', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTitle('Go to last page')); + expect(screen.getByText(/Page 3 of 3/)).toBeInTheDocument(); + + await user.click(screen.getByTitle('Go to first page')); + expect(screen.getByText(/Page 1 of 3/)).toBeInTheDocument(); + }); + }); + + describe('custom pagination state', () => { + it('shows page details computed from the custom pagination state', () => { + render(); + + expect(screen.getByText('1 - 10')).toBeInTheDocument(); + expect(screen.getByText('25')).toBeInTheDocument(); + expect(screen.getByText(/Page 1 of 3/)).toBeInTheDocument(); + expect(screen.getByTitle('Go to first page')).toBeDisabled(); + expect(screen.getByTitle('Go to previous page')).toBeDisabled(); + }); + + it('clamps the displayed range on the final, partial page', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTitle('Go to last page')); + + expect(screen.getByText(/Page 3 of 3/)).toBeInTheDocument(); + expect(screen.getByText('21 - 25')).toBeInTheDocument(); + expect(screen.getByTitle('Go to next page')).toBeDisabled(); + expect(screen.getByTitle('Go to last page')).toBeDisabled(); + }); + + it('advances the custom page number via next/previous', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTitle('Go to next page')); + expect(screen.getByText(/Page 2 of 3/)).toBeInTheDocument(); + expect(screen.getByText('11 - 20')).toBeInTheDocument(); + + await user.click(screen.getByTitle('Go to previous page')); + expect(screen.getByText(/Page 1 of 3/)).toBeInTheDocument(); + }); + }); +}); From 5b7e3979defb6426c94c5f5ea44f4c47f0b4ef55 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Fri, 31 Jul 2026 12:19:48 -0400 Subject: [PATCH 6/6] Resolve CI failures --- package-lock.json | 30 ------------------------------ tests/api/client.test.ts | 34 ++++++++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6df3503..8bb49a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -710,9 +710,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -730,9 +727,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -750,9 +744,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -770,9 +761,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -790,9 +778,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -810,9 +795,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3384,9 +3366,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3408,9 +3387,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3432,9 +3408,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3456,9 +3429,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index a1b6a39..f19e260 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -1,28 +1,46 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; import { LightcurveApiClient } from '../../src/api/client'; const BASE_URL = 'http://test.api'; function jsonResponse(data: unknown, ok = true, status = 200) { - return { ok, status, json: () => Promise.resolve(data) }; + return { + ok, + status, + json: () => Promise.resolve(data), + } as unknown as Response; } function blobResponse(ok = true, status = 200) { - return { ok, status, blob: () => Promise.resolve(new Blob(['data'])) }; + return { + ok, + status, + blob: () => Promise.resolve(new Blob(['data'])), + } as unknown as Response; } describe('LightcurveApiClient', () => { let client: LightcurveApiClient; - let fetchMock: ReturnType; - let revokeObjectURLMock: ReturnType; + let fetchMock: Mock; + let revokeObjectURLMock: Mock<(url: string) => void>; beforeEach(() => { client = new LightcurveApiClient(BASE_URL); - fetchMock = vi.fn(); + fetchMock = vi.fn(); global.fetch = fetchMock; - URL.createObjectURL = vi.fn().mockReturnValue('blob:mock-url'); - revokeObjectURLMock = vi.fn(); + URL.createObjectURL = vi + .fn<() => string>() + .mockReturnValue('blob:mock-url'); + revokeObjectURLMock = vi.fn<(url: string) => void>(); URL.revokeObjectURL = revokeObjectURLMock; // jsdom attempts real navigation on anchor clicks, which logs noisy // "Not implemented" errors; downloads aren't real navigation anyway.