UNPKG

@accounter/client

Version:
291 lines (221 loc) • 8.59 kB
// @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(); }); });