164 lines
6.5 KiB
TypeScript
164 lines
6.5 KiB
TypeScript
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<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);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|