Files
beaver-calendar/tests/keymap.test.ts
T

154 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
});
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<string, string>();
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);
}
}
}
});
});