Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions apps/ui/src/components/CoachTip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,21 @@ describe("CoachTip (RIG-2530)", () => {

test("label-only: a command with no keymap row renders the label and no chip", async () => {
setPlatform("other");
// Guard the premise: view.backlog has no keymap row on this base.
expect(shortcutFor(cmd("view.backlog"), "other")).toBeUndefined();
// Guard the premise: board.openCardCrossLink has no keymap row (it is
// board-nav dispatched, never a global chord — keymap.test.ts pins this).
expect(
shortcutFor(cmd("board.openCardCrossLink"), "other"),
).toBeUndefined();

const { getByRole, baseElement } = render(() => (
<CoachTip>
<CoachTipTrigger as="button" type="button">
Backlog
Open cross-link
</CoachTipTrigger>
<CoachTipContent label="Backlog" command={cmd("view.backlog")} />
<CoachTipContent
label="Open cross-link"
command={cmd("board.openCardCrossLink")}
/>
</CoachTip>
));

Expand All @@ -121,7 +127,7 @@ describe("CoachTip (RIG-2530)", () => {

const tooltip = tooltipOf(baseElement);
expect(tooltip).not.toBeNull();
expect(tooltip?.textContent).toContain("Backlog");
expect(tooltip?.textContent).toContain("Open cross-link");
expect(tooltip?.querySelector(".cx-palette-shortcut")).toBeNull();
expect(tooltip?.querySelector("kbd")).toBeNull();
});
Expand Down
10 changes: 10 additions & 0 deletions apps/ui/src/components/Palette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,12 +245,22 @@ describe("Palette (RIG-2483)", () => {
);
const bridge = links.find((b) => b.textContent?.includes("Bridge"));
const settings = links.find((b) => b.textContent?.includes("Settings"));
const backlog = links.find((b) => b.textContent?.includes("Backlog"));
const done = links.find((b) => b.textContent?.includes("Done"));
// view.bridge → Mod+B, view.settings → Mod+, — aria uses the WAI-ARIA
// Control token. The display chord no longer rides a native title (the
// RIG-2530 sweep coaches it via CoachTip); a native title would
// double-tooltip, so it must be absent.
expect(bridge?.getAttribute("aria-keyshortcuts")).toBe("Control+B");
expect(bridge?.getAttribute("title")).toBeNull();
expect(settings?.getAttribute("aria-keyshortcuts")).toBe("Control+,");
expect(settings?.getAttribute("title")).toBeNull();
// view.backlog / view.done are sequence-only (G L / G D): shortcutForAria
// skips the sequence so NO aria-keyshortcuts is emitted, and the RIG-2530
// sweep moved coaching to a CoachTip, so there is no native title either.
expect(backlog?.getAttribute("aria-keyshortcuts")).toBeNull();
expect(backlog?.getAttribute("title")).toBeNull();
expect(done?.getAttribute("aria-keyshortcuts")).toBeNull();
expect(done?.getAttribute("title")).toBeNull();
});
});
7 changes: 5 additions & 2 deletions apps/ui/src/components/ShortcutsOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ describe("ShortcutsOverlay (RIG-2482)", () => {
fireEvent.input(input, { target: { value: "bridge" } });
await flush();
const rows = container.querySelectorAll(".cx-shortcuts-row");
expect(rows.length).toBe(1);
expect(rows[0]?.textContent).toContain("Bridge");
// "bridge" now matches both Mod+B and the G B leader sequence (RIG-2484).
expect(rows.length).toBe(2);
const text = [...rows].map((r) => r.textContent ?? "");
expect(text.every((t) => t.includes("Bridge"))).toBe(true);
expect(text.some((t) => t.includes("G then B"))).toBe(true);
});

test("a no-match query shows the dim empty row and no rows", async () => {
Expand Down
29 changes: 29 additions & 0 deletions apps/ui/src/keyboard-e2e.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,35 @@ describe("App-root keyboard spine (RIG-2456)", () => {

expect(store.view()).toBe("settings");
});

test("G then S lands on Settings (leader sequence, real App wiring)", async () => {
setPlatform("other");
const { store } = mountApp("/");
expect(store.view()).toBe("bridge");

// The `G S` sequence (keymap.ts) resolves through the same tier-3 path as
// `Mod+,` once the T3 runtime arms the leader. Registers nothing — the
// spine already registered view.settings.
press({ key: "g" });
press({ key: "s" });
await flush();

expect(store.view()).toBe("settings");
});

test("G then L lands on Backlog (sequence-only command, real App wiring)", async () => {
setPlatform("other");
const { store } = mountApp("/");
expect(store.view()).toBe("bridge");

// view.backlog's ONLY keyboard binding is the `G L` sequence; this proves
// the leader runtime resolves it end to end with no App-specific setup.
press({ key: "g" });
press({ key: "l" });
await flush();

expect(store.view()).toBe("backlog");
});
});

describe("shortcuts overlay (RIG-2482)", () => {
Expand Down
225 changes: 223 additions & 2 deletions apps/ui/src/keyboard/dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test";
import { afterEach, describe, expect, jest, test } from "bun:test";
import type { Command, CommandId, CommandScope } from "./commands";
import { detectPlatform, eventToChord, installKeymap } from "./dispatch";
import {
detectPlatform,
eventToChord,
installKeymap,
LEADER_TIMEOUT_MS,
} from "./dispatch";
import type { Platform } from "./keymap";
import { createCommandRegistry } from "./registry";
import type { RovingGroupHandle } from "./roving";
Expand Down Expand Up @@ -445,3 +450,219 @@ describe("installKeymap", () => {
expect(ran).toBe(0);
});
});

// The leader/mnemonic runtime (RIG-2484 T3): "press G, then <key>" sequences
// armed inside the ONE keydown handler, with a timeout, the editable guard
// ahead of arming, and dead-sequence fall-through to single-chord resolution.
describe("installKeymap — leader sequences", () => {
let uninstall: (() => void) | null = null;

afterEach(() => {
uninstall?.();
uninstall = null;
jest.useRealTimers();
setPlatform("other");
});

test("g then b runs view.bridge (G B); the arming g is defaultPrevented", () => {
const registry = createCommandRegistry();
let ran = 0;
registry.register(makeCommand("view.bridge", () => ran++));
uninstall = installKeymap(registry, () => null);

const armed = keydown({ key: "g" });
expect(armed.defaultPrevented).toBe(true);
expect(ran).toBe(0);

keydown({ key: "b" });
expect(ran).toBe(1);
// The completion disarmed the leader: a second bare b is a no-op, not a
// stuck-pending double-complete.
keydown({ key: "b" });
expect(ran).toBe(1);
});

test("timeout: after LEADER_TIMEOUT_MS the leader disarms, so b does not complete", () => {
jest.useFakeTimers();
const registry = createCommandRegistry();
let ran = 0;
registry.register(makeCommand("view.bridge", () => ran++));
uninstall = installKeymap(registry, () => null);

keydown({ key: "g" });
jest.advanceTimersByTime(LEADER_TIMEOUT_MS + 1);
keydown({ key: "b" });

expect(ran).toBe(0);
});

test("editable-guard: g then b in an input arms nothing, runs nothing, prevents nothing", () => {
const registry = createCommandRegistry();
let ran = 0;
registry.register(makeCommand("view.bridge", () => ran++));
uninstall = installKeymap(registry, () => null);

const input = document.createElement("input");
document.body.appendChild(input);
const armed = keydown({ key: "g" }, input);
const completed = keydown({ key: "b" }, input);

expect(armed.defaultPrevented).toBe(false);
expect(completed.defaultPrevented).toBe(false);
expect(ran).toBe(0);
input.remove();
});

test("<select> non-regression: g on a focused select does not arm (native typeahead intact)", () => {
const registry = createCommandRegistry();
uninstall = installKeymap(registry, () => null);

const select = document.createElement("select");
document.body.appendChild(select);
const event = keydown({ key: "g" }, select);

expect(event.defaultPrevented).toBe(false);
select.remove();
});

test("ARIA-widget non-regression: g inside a role=listbox does not arm", () => {
const registry = createCommandRegistry();
uninstall = installKeymap(registry, () => null);

const listbox = document.createElement("div");
listbox.setAttribute("role", "listbox");
const option = document.createElement("div");
listbox.appendChild(option);
document.body.appendChild(listbox);
const event = keydown({ key: "g" }, option);

expect(event.defaultPrevented).toBe(false);
listbox.remove();
});

test("dead-sequence fall-through: g then ArrowDown routes to the active group", () => {
const registry = createCommandRegistry();
const { handle, routed } = stubGroup(() => true);
uninstall = installKeymap(registry, () => handle);

keydown({ key: "g" });
const event = keydown({ key: "ArrowDown" });

// "G ArrowDown" matches no row → falls through to the single-chord path,
// where the active group claims list.moveNext.
expect(routed).toEqual([id("list.moveNext")]);
expect(event.defaultPrevented).toBe(true);
});

test("re-arm: g g then b runs view.bridge (the second g re-arms)", () => {
const registry = createCommandRegistry();
let ran = 0;
registry.register(makeCommand("view.bridge", () => ran++));
uninstall = installKeymap(registry, () => null);

keydown({ key: "g" });
const rearmed = keydown({ key: "g" });
expect(rearmed.defaultPrevented).toBe(true);
keydown({ key: "b" });

expect(ran).toBe(1);
});

test("arm-then-refocus: g on window, then b in a composer input, does not complete", () => {
const registry = createCommandRegistry();
let ran = 0;
registry.register(makeCommand("view.bridge", () => ran++));
uninstall = installKeymap(registry, () => null);

keydown({ key: "g" });
const input = document.createElement("input");
document.body.appendChild(input);
const event = keydown({ key: "b" }, input);

expect(ran).toBe(0); // the editable guard swallowed the completion key
expect(event.defaultPrevented).toBe(false);
input.remove();
});

test("Escape disarms: g, Escape, then b does not complete", () => {
const registry = createCommandRegistry();
let ran = 0;
registry.register(makeCommand("view.bridge", () => ran++));
uninstall = installKeymap(registry, () => null);

keydown({ key: "g" });
const esc = keydown({ key: "Escape" });
expect(esc.defaultPrevented).toBe(true);
keydown({ key: "b" });

expect(ran).toBe(0);
});

test.each([
["Shift", { shiftKey: true }],
["Control", { ctrlKey: true }],
["Alt", { altKey: true }],
["Meta", { metaKey: true }],
] as const)(
"a lone %s keydown mid-sequence does NOT disarm: g, %s, b completes",
(key, flag) => {
const registry = createCommandRegistry();
let ran = 0;
registry.register(makeCommand("view.bridge", () => ran++));
uninstall = installKeymap(registry, () => null);

keydown({ key: "g" });
keydown({ key, ...flag });
keydown({ key: "b" });

expect(ran).toBe(1);
},
);

test("Mod+B mid-sequence disarms AND runs view.bridge in the same keydown", () => {
const registry = createCommandRegistry();
let ran = 0;
registry.register(makeCommand("view.bridge", () => ran++));
uninstall = installKeymap(registry, () => null);

keydown({ key: "g" });
// "G Ctrl+B" matches no row → falls through; the leader disarmed, and the
// single-chord Ctrl+B resolves view.bridge.
keydown({ key: "b", ctrlKey: true });

expect(ran).toBe(1);
// The leader disarmed (not left pending): a bare b now does nothing.
keydown({ key: "b" });
expect(ran).toBe(1);
});

test("held g (repeat) does not arm", () => {
const registry = createCommandRegistry();
let ran = 0;
registry.register(makeCommand("view.bridge", () => ran++));
uninstall = installKeymap(registry, () => null);

const event = keydown({ key: "g", repeat: true });
expect(event.defaultPrevented).toBe(false);

// Nothing armed, so a following b never completes.
keydown({ key: "b" });
expect(ran).toBe(0);
});

test("uninstall while a leader is pending leaves no timer firing", () => {
jest.useFakeTimers();
const registry = createCommandRegistry();
let ran = 0;
registry.register(makeCommand("view.bridge", () => ran++));
const stop = installKeymap(registry, () => null);

keydown({ key: "g" });
stop(); // clears the live pending.timer
uninstall = null;

expect(() => jest.advanceTimersByTime(LEADER_TIMEOUT_MS + 1)).not.toThrow();
keydown({ key: "b" });
expect(ran).toBe(0); // listener gone; nothing runs
});
});
Loading
Loading