From 13232bb15c5c06e19718d075d17765edd537b85c Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Fri, 16 Oct 2026 16:41:10 +0100 Subject: [PATCH] Added tests for the opportunities' catch-up and the toasts --- .../primitives/createOpportunities.test.ts | 172 ++++++++++++++++++ web/src/primitives/createToasts.test.ts | 59 ++++++ 2 files changed, 231 insertions(+) create mode 100644 web/src/primitives/createOpportunities.test.ts create mode 100644 web/src/primitives/createToasts.test.ts diff --git a/web/src/primitives/createOpportunities.test.ts b/web/src/primitives/createOpportunities.test.ts new file mode 100644 index 0000000..888765d --- /dev/null +++ b/web/src/primitives/createOpportunities.test.ts @@ -0,0 +1,172 @@ +import { createRoot } from 'solid-js'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +import type * as clientModule from '@/api/client'; +import type { OpportunityChanges } from '@/api/client'; +import { ApiError } from '@/api/client'; +import { testOpportunity } from '@/lib/testOpportunity'; +import type { OpportunityList } from './createOpportunities'; +import { createOpportunities } from './createOpportunities'; + +// The two requests createOpportunities makes; each test answers them. +const requests = vi.hoisted(() => ({ + listOpportunities: vi.fn<(signal?: AbortSignal) => Promise>(), + listChangedOpportunities: + vi.fn<(batch: string, signal?: AbortSignal) => Promise>(), +})); + +vi.mock('@/api/client', async (importOriginal) => ({ + ...(await importOriginal()), + api: requests, +})); + +/** A promise the test settles when it chooses. */ +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (err: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (err: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** Lets pending promises and Solid's effects run. */ +const settle = async (): Promise => { + for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +const changes = (batch: string, ...uuids: string[]): OpportunityChanges => ({ + opportunities: uuids.map((uuid) => testOpportunity(uuid, { title: `${uuid} ${batch}` })), + batch, +}); + +let loads: Deferred[]; +let catchUps: { batch: string; result: Deferred }[]; +let list: OpportunityList; +let dispose: () => void; + +const titles = (): string[] => list.items().map((opportunity) => opportunity.title); + +beforeEach(() => { + loads = []; + catchUps = []; + requests.listOpportunities.mockImplementation(() => { + const load = deferred(); + loads.push(load); + return load.promise; + }); + requests.listChangedOpportunities.mockImplementation((batch) => { + const result = deferred(); + catchUps.push({ batch, result }); + return result.promise; + }); + createRoot((disposeRoot) => { + dispose = disposeRoot; + list = createOpportunities(); + }); +}); + +afterEach(() => { + dispose(); + vi.clearAllMocks(); +}); + +/** Finishes the first load with ocds-1 and ocds-2, at batch b1. */ +async function loaded(): Promise { + loads[0]?.resolve(changes('b1', 'ocds-1', 'ocds-2')); + await settle(); +} + +test('catches up from the loaded batch, then from the newest it got', async () => { + await loaded(); + + list.catchUp(); + await settle(); + expect(catchUps.map((c) => c.batch)).toEqual(['b1']); + catchUps[0]?.result.resolve(changes('b2', 'ocds-2', 'ocds-3')); + await settle(); + expect(titles()).toEqual(['ocds-1 b1', 'ocds-2 b2', 'ocds-3 b2']); + + list.catchUp(); + await settle(); + expect(catchUps.map((c) => c.batch)).toEqual(['b1', 'b2']); + expect(loads).toHaveLength(1); +}); + +test('reloads when there is no batch to catch up from', async () => { + loads[0]?.resolve(changes('', 'ocds-1')); + await settle(); + + list.catchUp(); + await settle(); + + expect(catchUps).toHaveLength(0); + expect(loads).toHaveLength(2); +}); + +test.each([ + { name: 'an unknown batch', err: new ApiError('/opportunities', 410, undefined) }, + { name: 'a failure', err: new Error('offline') }, +])('reloads after $name', async ({ err }) => { + await loaded(); + + list.catchUp(); + await settle(); + catchUps[0]?.result.reject(err); + await settle(); + + expect(loads).toHaveLength(2); +}); + +test('waits for a load before catching up from its batch', async () => { + list.catchUp(); + await settle(); + expect(catchUps).toHaveLength(0); + + await loaded(); + expect(catchUps.map((c) => c.batch)).toEqual(['b1']); +}); + +test('runs one catch-up at a time, and one more for calls meanwhile', async () => { + await loaded(); + + list.catchUp(); + await settle(); + list.catchUp(); + list.catchUp(); + await settle(); + expect(catchUps).toHaveLength(1); + + catchUps[0]?.result.resolve(changes('b2')); + await settle(); + expect(catchUps.map((c) => c.batch)).toEqual(['b1', 'b2']); + catchUps[1]?.result.resolve(changes('b3')); + await settle(); + expect(catchUps).toHaveLength(2); +}); + +test('drops a catch-up that a reload overtook', async () => { + await loaded(); + + list.catchUp(); + await settle(); + list.reload(); + await settle(); + // The reload finishes first; the catch-up's older changes then arrive. + loads[1]?.resolve(changes('b5', 'ocds-1')); + await settle(); + catchUps[0]?.result.resolve(changes('b2', 'ocds-9')); + await settle(); + + expect(titles()).toEqual(['ocds-1 b5']); + list.catchUp(); + await settle(); + expect(catchUps.at(-1)?.batch).toBe('b5'); +}); diff --git a/web/src/primitives/createToasts.test.ts b/web/src/primitives/createToasts.test.ts new file mode 100644 index 0000000..5182400 --- /dev/null +++ b/web/src/primitives/createToasts.test.ts @@ -0,0 +1,59 @@ +import { createRoot } from 'solid-js'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +import type { Toasts } from './createToasts'; +import { createToasts } from './createToasts'; + +let toasts: Toasts; +let dispose: () => void; + +const messages = (): string[] => toasts.items().map((toast) => toast.message); + +beforeEach(() => { + vi.useFakeTimers(); + createRoot((disposeRoot) => { + dispose = disposeRoot; + toasts = createToasts(); + }); +}); + +afterEach(() => { + dispose(); + vi.useRealTimers(); +}); + +test('shows toasts oldest first, at most five', () => { + for (let i = 1; i <= 6; i++) toasts.show(`message ${i}`); + expect(messages()).toEqual(['message 2', 'message 3', 'message 4', 'message 5', 'message 6']); +}); + +test('each toast goes after 15 seconds', () => { + toasts.show('first'); + vi.advanceTimersByTime(5_000); + toasts.show('second'); + + vi.advanceTimersByTime(9_999); + expect(messages()).toEqual(['first', 'second']); + vi.advanceTimersByTime(1); + expect(messages()).toEqual(['second']); + vi.advanceTimersByTime(5_000); + expect(messages()).toEqual([]); +}); + +test('dismissing a toast removes it and its timer', () => { + toasts.show('first'); + toasts.show('second'); + const first = toasts.items()[0]; + if (!first) throw new Error('no toast'); + + toasts.dismiss(first.id); + + expect(messages()).toEqual(['second']); + expect(vi.getTimerCount()).toBe(1); +}); + +test('cleaning up clears the timers', () => { + toasts.show('first'); + dispose(); + expect(vi.getTimerCount()).toBe(0); +});