import { describe, expect, it } from "vitest"; import { GROUPS, listStep, resolve, SHORTCUTS } from "../src/lib/keymap"; import type { Scope } from "../src/lib/keymap"; function press( key: string, init: { code?: string; ctrl?: boolean; meta?: boolean; shift?: boolean } = {}, ): KeyboardEvent { return { key, code: init.code ?? "", ctrlKey: init.ctrl ?? false, metaKey: init.meta ?? false, altKey: false, shiftKey: init.shift ?? false, } as KeyboardEvent; } const DAY: Scope[] = ["day", "view"]; const TASK: Scope[] = ["task", "view"]; const TRAY: Scope[] = ["tray", "task", "view"]; const RAIL: Scope[] = ["rail", "view"]; describe("resolve", () => { it("walks days on the grid and tasks inside one", () => { expect(resolve(press("j", { code: "KeyJ" }), DAY)).toBe("dayDown"); expect(resolve(press("j", { code: "KeyJ" }), TASK)).toBe("taskNext"); }); it("leaves the day with h and l from either scope", () => { expect(resolve(press("h", { code: "KeyH" }), DAY)).toBe("dayLeft"); expect(resolve(press("h", { code: "KeyH" }), TASK)).toBe("dayLeft"); }); it("separates a shifted letter from its bare form", () => { expect(resolve(press("H", { code: "KeyH", shift: true }), TASK)).toBe( "taskBack", ); expect(resolve(press("h", { code: "KeyH" }), TASK)).toBe("dayLeft"); }); it("reads the physical key, so a Cyrillic layout still answers", () => { expect(resolve(press("н", { code: "KeyN" }), DAY)).toBe("create"); expect(resolve(press("Т", { code: "KeyT", shift: true }), TASK)).toBe( "taskToday", ); }); it("falls through to the view scope", () => { expect(resolve(press("/", { code: "Slash" }), DAY)).toBe("search"); expect(resolve(press("?", { code: "Slash", shift: true }), DAY)).toBe("help"); }); it("means different things by Enter and Escape per scope", () => { expect(resolve(press("Enter", { code: "Enter" }), DAY)).toBe("dayEnter"); expect(resolve(press("Enter", { code: "Enter" }), TASK)).toBe("taskOpen"); expect(resolve(press("Escape", { code: "Escape" }), TASK)).toBe("taskExit"); expect(resolve(press("Escape", { code: "Escape" }), DAY)).toBe("dismiss"); }); it("ignores a modifier the table never asked for", () => { expect(resolve(press("n", { code: "KeyN", meta: true }), DAY)).toBeNull(); }); it("answers nothing for an unbound key", () => { expect(resolve(press("q", { code: "KeyQ" }), TASK)).toBeNull(); }); it("walks the tray as a grid while task actions still reach through", () => { expect(resolve(press("h", { code: "KeyH" }), TRAY)).toBe("trayLeft"); expect(resolve(press("j", { code: "KeyJ" }), TRAY)).toBe("trayDown"); expect(resolve(press("x", { code: "KeyX" }), TRAY)).toBe("taskToggle"); expect(resolve(press("L", { code: "KeyL", shift: true }), TRAY)).toBe( "taskForward", ); }); it("gives the rail its own reading of the same keys", () => { expect(resolve(press("j", { code: "KeyJ" }), RAIL)).toBe("railNext"); expect(resolve(press("l", { code: "KeyL" }), RAIL)).toBe("railUnfold"); expect(resolve(press("h", { code: "KeyH" }), RAIL)).toBe("railFold"); expect(resolve(press("Enter", { code: "Enter" }), RAIL)).toBe("railChoose"); expect(resolve(press(" ", { code: "Space" }), RAIL)).toBe("railToggleRow"); expect(resolve(press("Escape", { code: "Escape" }), RAIL)).toBe("railLeave"); }); it("opens the quick look on space over a task, and nowhere else", () => { expect(resolve(press(" ", { code: "Space" }), TASK)).toBe("taskPeek"); expect(resolve(press(" ", { code: "Space" }), TRAY)).toBe("taskPeek"); expect(resolve(press(" ", { code: "Space" }), DAY)).toBeNull(); // Without a code, the key alone still reads as space. expect(resolve(press(" "), TASK)).toBe("taskPeek"); }); it("closes the panel it opened with the same key", () => { expect(resolve(press("u", { code: "KeyU" }), TRAY)).toBe("undated"); expect(resolve(press("u", { code: "KeyU" }), DAY)).toBe("undated"); expect(resolve(press("b", { code: "KeyB" }), RAIL)).toBe("toggleRail"); expect(resolve(press("b", { code: "KeyB" }), DAY)).toBe("toggleRail"); }); it("takes undo on either modifier, and redo with shift", () => { expect(resolve(press("z", { code: "KeyZ", meta: true }), DAY)).toBe("undo"); expect(resolve(press("z", { code: "KeyZ", ctrl: true }), DAY)).toBe("undo"); expect( resolve(press("Z", { code: "KeyZ", meta: true, shift: true }), DAY), ).toBe("redo"); expect(resolve(press("z", { code: "KeyZ" }), DAY)).toBeNull(); }); }); describe("listStep", () => { it("takes arrows, the emacs pair and the vim pair", () => { expect(listStep(press("ArrowDown", { code: "ArrowDown" }))).toBe(1); expect(listStep(press("ArrowUp", { code: "ArrowUp" }))).toBe(-1); expect(listStep(press("n", { code: "KeyN", ctrl: true }))).toBe(1); expect(listStep(press("p", { code: "KeyP", ctrl: true }))).toBe(-1); expect(listStep(press("j", { code: "KeyJ", meta: true }))).toBe(1); expect(listStep(press("k", { code: "KeyK", meta: true }))).toBe(-1); }); it("leaves a bare letter alone so it can be typed", () => { expect(listStep(press("n", { code: "KeyN" }))).toBeNull(); expect(listStep(press("j", { code: "KeyJ" }))).toBeNull(); }); }); describe("the sheet and the dispatcher share one table", () => { it("gives every documented binding a group the sheet renders", () => { for (const shortcut of SHORTCUTS) { expect(GROUPS).toContain(shortcut.group); expect(shortcut.keys.length).toBeGreaterThan(0); expect(shortcut.label.length).toBeGreaterThan(0); } }); it("only leaves combos empty on rows the composer handles itself", () => { for (const shortcut of SHORTCUTS) { if (shortcut.combos.length === 0) { expect(shortcut.action).toBeNull(); expect(shortcut.scopes).toEqual(["compose"]); } else { expect(shortcut.action).not.toBeNull(); } } }); it("never binds one combo twice inside a scope", () => { for (const scope of ["day", "task", "tray", "rail", "view"] as Scope[]) { const seen = new Map(); for (const shortcut of SHORTCUTS) { if (!shortcut.scopes.includes(scope)) continue; for (const each of shortcut.combos) { expect( seen.has(each), `${each} is bound twice in ${scope}: ${seen.get(each)} and ${shortcut.label}`, ).toBe(false); seen.set(each, shortcut.label); } } } }); });