feat(frontend): j/k row nav + ? cheatsheet on Acks and Remittances

This commit is contained in:
Tyler
2026-06-20 16:57:15 -06:00
parent b980e77cf2
commit 8f419cdeba
6 changed files with 1347 additions and 290 deletions
+362 -1
View File
@@ -51,6 +51,57 @@ async function waitForText(text: string, timeoutMs = 2000): Promise<void> {
}
}
/**
* Poll a predicate until it holds or we time out. react-query's fetch
* resolves asynchronously, and Radix portals content into
* `document.body`, so the DOM doesn't reflect new state synchronously
* after a keypress — we have to await a tick before asserting.
*/
async function settle(
predicate: () => boolean,
timeoutMs = 2000
): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error("settle: predicate did not hold within timeout");
}
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
}
/**
* Dispatch a synthetic keydown on `window`. Matches the dispatching
* pattern from `useDrawerKeyboard.test.ts` and `Claims.test.tsx` —
* `bubbles: true` so the event propagates like a real keypress, and
* `cancelable: true` so the hook's `preventDefault` has something to
* act on.
*/
function pressKey(key: string): void {
act(() => {
window.dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
);
});
}
/** The row at `idx` (per `data-row-index={idx}` on the row). */
function rowAt(idx: number): HTMLTableRowElement | null {
return document.querySelector(
`tbody tr[data-row-index="${idx}"]`
) as HTMLTableRowElement | null;
}
/** True iff exactly one row carries `data-state="selected"`. */
function hasExactlyOneSelectedRow(): boolean {
const selected = document.querySelectorAll(
'tbody tr[data-state="selected"]'
);
return selected.length === 1;
}
describe("Acks", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -87,4 +138,314 @@ describe("Acks", () => {
expect(document.body.textContent).toContain("b-uuid-1");
unmount();
});
});
it("test_initial_render_shows_no_selection_highlight", async () => {
// Three rows in the fixture so there's plenty to select; the test
// asserts the *initial* state has no row marked selected.
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 1,
sourceBatchId: "b-1",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-06-20T12:00:00Z",
},
{
id: 2,
sourceBatchId: "b-2",
acceptedCount: 2,
rejectedCount: 0,
receivedCount: 2,
ackCode: "A",
parsedAt: "2026-06-20T12:01:00Z",
},
{
id: 3,
sourceBatchId: "b-3",
acceptedCount: 3,
rejectedCount: 0,
receivedCount: 3,
ackCode: "A",
parsedAt: "2026-06-20T12:02:00Z",
},
],
total: 3,
returned: 3,
has_more: false,
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-3");
// None of the three rows should be marked selected — the spec
// requires the initial render shows no selection highlight.
expect(
document.querySelectorAll('tbody tr[data-state="selected"]').length
).toBe(0);
unmount();
});
it("test_pressing_j_increments_selection", async () => {
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 1,
sourceBatchId: "b-1",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-06-20T12:00:00Z",
},
{
id: 2,
sourceBatchId: "b-2",
acceptedCount: 2,
rejectedCount: 0,
receivedCount: 2,
ackCode: "A",
parsedAt: "2026-06-20T12:01:00Z",
},
{
id: 3,
sourceBatchId: "b-3",
acceptedCount: 3,
rejectedCount: 0,
receivedCount: 3,
ackCode: "A",
parsedAt: "2026-06-20T12:02:00Z",
},
],
total: 3,
returned: 3,
has_more: false,
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-3");
// From null, j lands on row 0.
pressKey("j");
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
expect(rowAt(1)?.getAttribute("data-state")).not.toBe("selected");
expect(rowAt(2)?.getAttribute("data-state")).not.toBe("selected");
// j again → row 1.
pressKey("j");
await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
expect(rowAt(1)?.getAttribute("data-state")).toBe("selected");
expect(rowAt(0)?.getAttribute("data-state")).not.toBe("selected");
// j again → row 2.
pressKey("j");
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
// j from last row wraps to row 0 (per the wrap-or-clamp policy
// documented in Acks.tsx).
pressKey("j");
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
unmount();
});
it("test_pressing_k_decrements_selection_with_wrap", async () => {
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 1,
sourceBatchId: "b-1",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-06-20T12:00:00Z",
},
{
id: 2,
sourceBatchId: "b-2",
acceptedCount: 2,
rejectedCount: 0,
receivedCount: 2,
ackCode: "A",
parsedAt: "2026-06-20T12:01:00Z",
},
{
id: 3,
sourceBatchId: "b-3",
acceptedCount: 3,
rejectedCount: 0,
receivedCount: 3,
ackCode: "A",
parsedAt: "2026-06-20T12:02:00Z",
},
],
total: 3,
returned: 3,
has_more: false,
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-3");
// From null, k jumps to the last row — a one-keystroke "bottom
// of list" gesture.
pressKey("k");
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
// k again → row 1.
pressKey("k");
await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
expect(rowAt(1)?.getAttribute("data-state")).toBe("selected");
expect(rowAt(2)?.getAttribute("data-state")).not.toBe("selected");
// k twice more lands on row 0.
pressKey("k");
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
// k from row 0 wraps to row 2 (last).
pressKey("k");
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
unmount();
});
it("test_question_mark_toggles_cheatsheet_and_escape_closes", async () => {
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 1,
sourceBatchId: "b-1",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-06-20T12:00:00Z",
},
],
total: 1,
returned: 1,
has_more: false,
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-1");
// No cheatsheet initially.
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).toBeNull();
// `?` opens it (Radix portals into document.body — query there).
pressKey("?");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
null
);
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).not.toBeNull();
// Escape closes it. The page wires onClose via useRowKeyboard
// (Escape → onClose → setHelpOpen(false)).
pressKey("Escape");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
null
);
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).toBeNull();
// `?` again re-opens (proving the toggle works in both directions).
pressKey("?");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
null
);
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).not.toBeNull();
unmount();
});
it("test_j_does_not_navigate_while_cheatsheet_is_open", async () => {
// `enabled: !helpOpen` is the recommended pairing with
// useRowKeyboard: j/k nav yields to the cheatsheet while it's
// open. The cheatsheet's own dismiss listener still closes it on
// any non-`?` keypress (per KeyboardCheatsheet.tsx), so pressing
// j will close the cheatsheet but the row should NOT have moved.
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 1,
sourceBatchId: "b-1",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-06-20T12:00:00Z",
},
{
id: 2,
sourceBatchId: "b-2",
acceptedCount: 2,
rejectedCount: 0,
receivedCount: 2,
ackCode: "A",
parsedAt: "2026-06-20T12:01:00Z",
},
],
total: 2,
returned: 2,
has_more: false,
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-2");
// Move selection to row 0 first (so we can later verify it didn't
// jump to row 1 while the cheatsheet is open).
pressKey("j");
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
// Open cheatsheet.
pressKey("?");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
null
);
// Press j while cheatsheet is open. Cheatsheet closes (its own
// listener handles any non-? key), but selection should NOT have
// moved — row 0 is still selected.
pressKey("j");
await settle(
() =>
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
null
);
expect(
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).toBeNull();
// Row 0 is still the selected row.
expect(hasExactlyOneSelectedRow()).toBe(true);
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
unmount();
});
});