Added tests for the opportunities' catch-up and the toasts
This commit is contained in:
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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user