@accounter/client
Version:
Accounter client application
291 lines (221 loc) • 8.59 kB
text/typescript
// @vitest-environment happy-dom
import React from 'react';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { describe, expect, it } from 'vitest';
import {
useChargeMatchQueue,
type UseChargeMatchQueue,
} from '../use-charge-match-queue.js';
type Item = { id: string; label?: string };
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
async function renderQueueHarness(initialItems: Item[]) {
const container = document.createElement('div');
document.body.append(container);
let latest: UseChargeMatchQueue<Item> | null = null;
function Harness({ items }: { items: Item[] }): null {
latest = useChargeMatchQueue(items);
return null;
}
let root: Root | null = null;
await act(async () => {
root = createRoot(container);
root.render(React.createElement(Harness, { items: initialItems }));
await Promise.resolve();
});
const rerender = async (items: Item[]) => {
await act(async () => {
root?.render(React.createElement(Harness, { items }));
await Promise.resolve();
});
};
const cleanup = async () => {
await act(async () => {
root?.unmount();
await Promise.resolve();
});
container.remove();
};
const current = () => {
if (!latest) {
throw new Error('Hook value not captured');
}
return latest;
};
return { current, rerender, cleanup };
}
const ITEMS: Item[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
describe('useChargeMatchQueue', () => {
it('maintains the items array with the first item active and all statuses pending', async () => {
const { current, cleanup } = await renderQueueHarness(ITEMS);
expect(current().items).toEqual(ITEMS);
expect(current().activeIndex).toBe(0);
expect(current().activeItem).toEqual({ id: 'a' });
expect(current().isDone).toBe(false);
expect(current().statusById).toEqual({ a: 'pending', b: 'pending', c: 'pending' });
await cleanup();
});
it('skipItem flags the id as skipped and increments activeIndex', async () => {
const { current, cleanup } = await renderQueueHarness(ITEMS);
await act(async () => {
current().skipItem('a');
});
expect(current().statusById.a).toBe('skipped');
expect(current().activeIndex).toBe(1);
expect(current().activeItem).toEqual({ id: 'b' });
await cleanup();
});
it('acceptItemStatus with success flags the id as matched and increments activeIndex', async () => {
const { current, cleanup } = await renderQueueHarness(ITEMS);
await act(async () => {
current().acceptItemStatus('a', true);
});
expect(current().statusById.a).toBe('matched');
expect(current().activeIndex).toBe(1);
expect(current().activeItem).toEqual({ id: 'b' });
await cleanup();
});
it('acceptItemStatus with failure keeps the active item and status untouched', async () => {
const { current, cleanup } = await renderQueueHarness(ITEMS);
await act(async () => {
current().acceptItemStatus('a', false);
});
expect(current().statusById.a).toBe('pending');
expect(current().activeIndex).toBe(0);
expect(current().activeItem).toEqual({ id: 'a' });
await cleanup();
});
it('marks the queue as done once every item was resolved', async () => {
const { current, cleanup } = await renderQueueHarness(ITEMS);
await act(async () => {
current().skipItem('a');
});
await act(async () => {
current().acceptItemStatus('b', true);
});
await act(async () => {
current().skipItem('c');
});
expect(current().isDone).toBe(true);
expect(current().activeItem).toBeNull();
expect(current().statusById).toEqual({ a: 'skipped', b: 'matched', c: 'skipped' });
await cleanup();
});
it('selectItem pins an upcoming base charge as the active item', async () => {
const { current, cleanup } = await renderQueueHarness(ITEMS);
await act(async () => {
current().selectItem('c');
});
expect(current().activeIndex).toBe(2);
expect(current().activeItem).toEqual({ id: 'c' });
// No item has been resolved, so the queue is not done
expect(current().isDone).toBe(false);
await cleanup();
});
it('selectItem can revisit a skipped base charge', async () => {
const { current, cleanup } = await renderQueueHarness(ITEMS);
await act(async () => {
current().skipItem('a');
});
expect(current().activeItem).toEqual({ id: 'b' });
await act(async () => {
current().selectItem('a');
});
expect(current().activeIndex).toBe(0);
expect(current().activeItem).toEqual({ id: 'a' });
expect(current().statusById.a).toBe('skipped');
await cleanup();
});
it('selectItem is a no-op for a matched charge or an id not in the queue', async () => {
const { current, cleanup } = await renderQueueHarness(ITEMS);
await act(async () => {
current().acceptItemStatus('a', true);
});
// Active is now the first pending charge 'b'
expect(current().activeItem).toEqual({ id: 'b' });
// Selecting the matched charge is ignored
await act(async () => {
current().selectItem('a');
});
expect(current().activeItem).toEqual({ id: 'b' });
// Selecting an unknown id is ignored
await act(async () => {
current().selectItem('zzz');
});
expect(current().activeItem).toEqual({ id: 'b' });
await cleanup();
});
it('resolving a manually selected charge clears the pin and advances to the next pending', async () => {
const { current, cleanup } = await renderQueueHarness(ITEMS);
await act(async () => {
current().selectItem('c');
});
expect(current().activeItem).toEqual({ id: 'c' });
await act(async () => {
current().acceptItemStatus('c', true);
});
// Pin cleared: falls back to the first still-pending charge
expect(current().statusById.c).toBe('matched');
expect(current().activeItem).toEqual({ id: 'a' });
await cleanup();
});
it('selecting a charge that leaves the queue on refetch falls back to first-pending', async () => {
const { current, rerender, cleanup } = await renderQueueHarness(ITEMS);
await act(async () => {
current().selectItem('c');
});
expect(current().activeItem).toEqual({ id: 'c' });
const refetchedItems: Item[] = [{ id: 'a' }, { id: 'b' }];
await rerender(refetchedItems);
expect(current().activeItem).toEqual({ id: 'a' });
await cleanup();
});
it('resets index and statuses when a new items array arrives (e.g. filters changed)', async () => {
const { current, rerender, cleanup } = await renderQueueHarness(ITEMS);
await act(async () => {
current().skipItem('a');
});
expect(current().activeIndex).toBe(1);
const nextItems: Item[] = [{ id: 'x' }, { id: 'y' }];
await rerender(nextItems);
expect(current().items).toEqual(nextItems);
expect(current().activeIndex).toBe(0);
expect(current().activeItem).toEqual({ id: 'x' });
expect(current().statusById).toEqual({ x: 'pending', y: 'pending' });
await cleanup();
});
it('preserves progress when the item set changes, e.g. a merged charge drops out on refetch', async () => {
const { current, rerender, cleanup } = await renderQueueHarness(ITEMS);
await act(async () => {
current().skipItem('a');
});
await act(async () => {
current().acceptItemStatus('b', true);
});
expect(current().activeItem).toEqual({ id: 'c' });
// Refetch after the merge: charge "b" no longer exists in the queue
const refetchedItems: Item[] = [{ id: 'a' }, { id: 'c' }];
await rerender(refetchedItems);
expect(current().statusById).toEqual({ a: 'skipped', c: 'pending' });
expect(current().activeItem).toEqual({ id: 'c' });
expect(current().isDone).toBe(false);
await cleanup();
});
it('does not reset state when a new array reference holds the same items (parent re-render)', async () => {
const { current, rerender, cleanup } = await renderQueueHarness(ITEMS);
await act(async () => {
current().skipItem('a');
});
expect(current().activeIndex).toBe(1);
const sameItemsNewRef: Item[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
await rerender(sameItemsNewRef);
expect(current().activeIndex).toBe(1);
expect(current().activeItem).toEqual({ id: 'b' });
expect(current().statusById.a).toBe('skipped');
await cleanup();
});
});