feat(frontend): j/k row nav + ? cheatsheet on Acks and Remittances
This commit is contained in:
+362
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user