Added tests for the opportunities' catch-up and the toasts

This commit is contained in:
bwilliams committed 2026-10-16 16:41:10 +01:00
1 parent f9064511b0
commit 13232bb15c
2 files changed
+231

No files matched your search

@@ -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<OpportunityChanges>>(),
listChangedOpportunities:
vi.fn<(batch: string, signal?: AbortSignal) => Promise<OpportunityChanges>>(),
}));
vi.mock('@/api/client', async (importOriginal) => ({
...(await importOriginal<typeof clientModule>()),
api: requests,
}));
/** A promise the test settles when it chooses. */
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (err: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (err: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
/** Lets pending promises and Solid's effects run. */
const settle = async (): Promise<void> => {
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<OpportunityChanges>[];
let catchUps: { batch: string; result: Deferred<OpportunityChanges> }[];
let list: OpportunityList;
let dispose: () => void;
const titles = (): string[] => list.items().map((opportunity) => opportunity.title);
beforeEach(() => {
loads = [];
catchUps = [];
requests.listOpportunities.mockImplementation(() => {
const load = deferred<OpportunityChanges>();
loads.push(load);
return load.promise;
});
requests.listChangedOpportunities.mockImplementation((batch) => {
const result = deferred<OpportunityChanges>();
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<void> {
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');
});
+59
View File
@@ -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);
});