tests/shortcuts.test.ts
Rama main · réplica de lectura del repositorio original.
// THE LAYOUT MATRIX. Every documented control-modified shortcut, resolved from
// the keydown a real keyboard sends on Arabic, Persian, Russian, Greek, Hebrew,
// AZERTY, Dvorak and US QWERTY.
//
// This is the regression the product shipped with: `client/App.tsx` compared
// `e.key.toLowerCase()` to Latin letters, and `e.key` is whatever the LAYOUT
// produced. On the owner's Arabic keyboard the physical P key reports "ح", so
// Ctrl+P opened nothing — in an app that ships a complete Arabic translation
// and mirrors its entire layout for it. Five of seven bindings were dead, and
// no test noticed, because every test typed Latin letters.
//
// Two halves, and the second is what keeps the fix from over-correcting:
// - NON-LATIN layouts must reach the binding through the PHYSICAL key.
// - LATIN layouts that MOVE letters (AZERTY, Dvorak) must keep reaching it
// through the letter, and must NOT fire from the physical position. On
// Dvorak `b` is under physical KeyN and physical KeyB types `x`; a reader
// who learned "Ctrl+B is bold" learned it about the b finger.
//
// The browser-level companion is scripts/check-layouts.mjs, which drives the
// real app through the DevTools Protocol. This file is where the cases that
// harness CANNOT deliver live — chiefly Arabic's lam-alef, a TWO-code-point
// `key` that Chromium's Input.dispatchKeyEvent flattens to "".
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { isKey, layoutKey, physicalKey, shortcutKey, type ShortcutEvent } from "../client/keys.ts";
/** A layout, as a physical-key → produced-character table. Only the keys that
* differ from US QWERTY are listed. */
interface Layout {
id: string;
/** The layout produces Latin letters, so `e.key` answers on its own. */
latin?: boolean;
produces: Record<string, string>;
/** Characters this layout reaches only WITH Shift, where that differs from
* "the same character uppercased" — ЙЦУКЕН's slash is Shift+Backslash. */
shifted?: Record<string, string>;
}
const PUNCT_CODES: Record<string, string> = {
Backquote: "`", Minus: "-", Equal: "=", BracketLeft: "[", BracketRight: "]",
Backslash: "\\", Semicolon: ";", Quote: "'", Comma: ",", Period: ".", Slash: "/",
};
function usChar(code: string): string {
const letter = /^Key([A-Z])$/.exec(code)?.[1];
if (letter) return letter.toLowerCase();
return PUNCT_CODES[code] ?? "";
}
const ARABIC: Layout = {
id: "ar",
produces: {
KeyA: "ش", KeyB: "لا", KeyC: "ؤ", KeyD: "ي", KeyE: "ث", KeyF: "ب",
KeyG: "ل", KeyH: "ا", KeyI: "ه", KeyJ: "ت", KeyK: "ن", KeyL: "م",
KeyM: "ة", KeyN: "ى", KeyO: "خ", KeyP: "ح", KeyQ: "ض", KeyR: "ق",
KeyS: "س", KeyT: "ف", KeyU: "ع", KeyV: "ر", KeyW: "ص", KeyX: "ء",
KeyY: "غ", KeyZ: "ئ", Slash: "ظ",
},
};
const PERSIAN: Layout = {
id: "fa",
produces: {
KeyA: "ش", KeyB: "ذ", KeyC: "ز", KeyD: "ی", KeyE: "ث", KeyF: "ب",
KeyG: "ل", KeyH: "ا", KeyI: "ه", KeyJ: "ت", KeyK: "ن", KeyL: "م",
KeyM: "ئ", KeyN: "د", KeyO: "خ", KeyP: "ح", KeyQ: "ض", KeyR: "ق",
KeyS: "س", KeyT: "ف", KeyU: "ع", KeyV: "ر", KeyW: "ص", KeyX: "ط",
KeyY: "غ", KeyZ: "ظ", Slash: "/",
},
};
const RUSSIAN: Layout = {
id: "ru",
produces: {
KeyA: "ф", KeyB: "и", KeyC: "с", KeyD: "в", KeyE: "у", KeyF: "а",
KeyG: "п", KeyH: "р", KeyI: "ш", KeyJ: "о", KeyK: "л", KeyL: "д",
KeyM: "ь", KeyN: "т", KeyO: "щ", KeyP: "з", KeyQ: "й", KeyR: "к",
KeyS: "ы", KeyT: "е", KeyU: "г", KeyV: "м", KeyW: "ц", KeyX: "ч",
KeyY: "н", KeyZ: "я", Slash: ".", Backslash: "\\",
},
// ЙЦУКЕН has no slash on the slash key — it types "." there. The Russian
// reader's "/" is Shift+Backslash, and it arrives as key="/", so the LAYOUT
// answers Ctrl+/ and the physical fallback correctly stays out of it.
shifted: { Backslash: "/", Slash: "," },
};
const GREEK: Layout = {
id: "el",
produces: {
KeyA: "α", KeyB: "β", KeyC: "ψ", KeyD: "δ", KeyE: "ε", KeyF: "φ",
KeyG: "γ", KeyH: "η", KeyI: "ι", KeyJ: "ξ", KeyK: "κ", KeyL: "λ",
KeyM: "μ", KeyN: "ν", KeyO: "ο", KeyP: "π", KeyQ: ";", KeyR: "ρ",
KeyS: "σ", KeyT: "τ", KeyU: "θ", KeyV: "ω", KeyW: "ς", KeyX: "χ",
KeyY: "υ", KeyZ: "ζ",
},
};
const HEBREW: Layout = {
id: "he",
produces: {
KeyA: "ש", KeyB: "נ", KeyC: "ב", KeyD: "ג", KeyE: "ק", KeyF: "כ",
KeyG: "ע", KeyH: "י", KeyI: "ן", KeyJ: "ח", KeyK: "ל", KeyL: "ך",
KeyM: "צ", KeyN: "מ", KeyO: "ם", KeyP: "פ", KeyQ: "/", KeyR: "ר",
KeyS: "ד", KeyT: "א", KeyU: "ו", KeyV: "ה", KeyW: "'", KeyX: "ס",
KeyY: "ט", KeyZ: "ז", Slash: ".",
},
};
const US: Layout = { id: "us", latin: true, produces: {} };
const AZERTY: Layout = {
id: "azerty",
latin: true,
produces: { KeyA: "q", KeyQ: "a", KeyW: "z", KeyZ: "w", KeyM: ",", Semicolon: "m" },
};
const DVORAK: Layout = {
id: "dvorak",
latin: true,
produces: {
KeyQ: "'", KeyW: ",", KeyE: ".", KeyR: "p", KeyT: "y", KeyY: "f",
KeyU: "g", KeyI: "c", KeyO: "r", KeyP: "l", BracketLeft: "/", BracketRight: "=",
KeyA: "a", KeyS: "o", KeyD: "e", KeyF: "u", KeyG: "i", KeyH: "d",
KeyJ: "h", KeyK: "t", KeyL: "n", Semicolon: "s", Quote: "-",
KeyZ: ";", KeyX: "q", KeyC: "j", KeyV: "k", KeyB: "x", KeyN: "b",
KeyM: "m", Comma: "w", Period: "v", Slash: "z", Minus: "[", Equal: "]",
},
};
const NON_LATIN = [ARABIC, PERSIAN, RUSSIAN, GREEK, HEBREW];
const LATIN = [US, AZERTY, DVORAK];
const ALL_CODES = [
...Array.from({ length: 26 }, (_, i) => `Key${String.fromCharCode(65 + i)}`),
...Object.keys(PUNCT_CODES),
];
/** What this layout produces on a physical key, unshifted and shifted. */
function produces(layout: Layout, code: string, shift: boolean): string {
if (shift) {
const explicit = layout.shifted?.[code];
if (explicit !== undefined) return explicit;
const base = layout.produces[code] ?? usChar(code);
return /^[a-z]$/.test(base) ? base.toUpperCase() : base;
}
return layout.produces[code] ?? usChar(code);
}
/** The keystroke a reader of `layout` performs to type `char` — the physical
* key AND whether Shift is part of reaching it. Latin layouts have such a
* key; a non-Latin layout has no Latin letters at all, and its reader presses
* the US position — what the second alphabet on their keycaps says. */
function strokeFor(layout: Layout, char: string): { code: string; shift: boolean } {
for (const code of ALL_CODES) if (produces(layout, code, false) === char) return { code, shift: false };
for (const code of ALL_CODES) if (produces(layout, code, true) === char) return { code, shift: true };
for (const code of ALL_CODES) if (usChar(code) === char) return { code, shift: false };
throw new Error(`no physical key for "${char}" on ${layout.id}`);
}
interface Mods {
shift?: boolean;
alt?: boolean;
meta?: boolean;
altGraph?: boolean;
}
/** The keydown a browser delivers when a reader of `layout` presses the key
* that types `char`, with Ctrl (or Cmd) held. */
function keydown(layout: Layout, char: string, mods: Mods = {}): ShortcutEvent {
const stroke = strokeFor(layout, char);
const code = stroke.code;
const shift = !!mods.shift || stroke.shift;
const base = produces(layout, code, false);
const key = produces(layout, code, shift);
// Windows virtual-key codes follow the letter on a Latin layout and the US
// position on a non-Latin one — which is what browsers report.
const vkChar = layout.latin ? base : usChar(code);
const keyCode = /^[a-z]$/.test(vkChar)
? vkChar.toUpperCase().charCodeAt(0)
: ({ ";": 186, "=": 187, ",": 188, "-": 189, ".": 190, "/": 191, "`": 192, "[": 219, "\\": 220, "]": 221, "'": 222 } as Record<string, number>)[vkChar] ?? 0;
return {
key,
code,
keyCode,
ctrlKey: !mods.meta,
metaKey: !!mods.meta,
shiftKey: shift,
altKey: !!mods.alt || !!mods.altGraph,
getModifierState: (name: string) => name === "AltGraph" && !!mods.altGraph,
};
}
/** Every binding the product documents in the Ctrl/Cmd+/ sheet that is matched
* on a single character. If a row is added to ShortcutsHelp and not to this
* list, the new row is untested under every non-Latin keyboard on earth. */
const BINDINGS: { name: string; char: string; mods?: Mods }[] = [
{ name: "command palette (Ctrl+P)", char: "p" },
{ name: "publish note (Ctrl+Shift+P)", char: "p", mods: { shift: true } },
{ name: "search (Ctrl+K)", char: "k" },
{ name: "graph view (Ctrl+G)", char: "g" },
{ name: "reading view (Ctrl+E)", char: "e" },
{ name: "daily note (Ctrl+D)", char: "d" },
{ name: "new note (Ctrl+N)", char: "n" },
{ name: "zen (Ctrl+Shift+Z)", char: "z", mods: { shift: true } },
{ name: "shortcut sheet (Ctrl+/)", char: "/" },
{ name: "notes pane (Ctrl+Alt+B)", char: "b", mods: { alt: true } },
{ name: "outline pane (Ctrl+Alt+Shift+B)", char: "b", mods: { alt: true, shift: true } },
{ name: "insert template (Ctrl+Alt+T)", char: "t", mods: { alt: true } },
{ name: "new from template (Ctrl+Alt+Shift+T)", char: "t", mods: { alt: true, shift: true } },
// The editor's own keymap resolves through the same two signals.
{ name: "save (Ctrl+S)", char: "s" },
{ name: "bold (Ctrl+B)", char: "b" },
{ name: "italic (Ctrl+I)", char: "i" },
{ name: "underline (Ctrl+U)", char: "u" },
{ name: "strikethrough (Ctrl+Shift+X)", char: "x", mods: { shift: true } },
{ name: "highlight (Ctrl+Shift+H)", char: "h", mods: { shift: true } },
{ name: "focus section (Ctrl+Alt+F)", char: "f", mods: { alt: true } },
{ name: "find (Ctrl+F)", char: "f" },
{ name: "undo (Ctrl+Z)", char: "z" },
];
describe("every documented shortcut, on every layout", () => {
for (const layout of [...LATIN, ...NON_LATIN]) {
it(`resolves all ${BINDINGS.length} bindings on ${layout.id}`, () => {
for (const binding of BINDINGS) {
const event = keydown(layout, binding.char, binding.mods);
assert.equal(
shortcutKey(event),
binding.char,
`${binding.name} on ${layout.id}: key=${JSON.stringify(event.key)} code=${event.code}`,
);
}
});
}
// The bug as the owner met it, spelled out on its own so a regression names
// itself rather than hiding inside a loop of twenty-two.
it("Ctrl+P opens the palette on an Arabic keyboard", () => {
const event = keydown(ARABIC, "p");
assert.equal(event.key, "ح", "the Arabic layout puts ح on the physical P key");
assert.equal(event.key.toLowerCase() === "p", false, "the OLD comparison — this is the bug");
assert.equal(shortcutKey(event), "p");
});
it("Ctrl+Shift+P still means publish, not the palette, on Arabic", () => {
const event = keydown(ARABIC, "p", { shift: true });
assert.equal(shortcutKey(event), "p");
assert.equal(event.shiftKey, true);
});
});
describe("the physical fallback does not over-reach", () => {
it("Dvorak's Ctrl+B is the key that TYPES b, not the physical B key", () => {
// Physical KeyN types "b" on Dvorak — that is the reader's bold key.
assert.equal(shortcutKey({ key: "b", code: "KeyN", keyCode: 66, ctrlKey: true }), "b");
// Physical KeyB types "x" there, and must stay "x".
assert.equal(shortcutKey({ key: "x", code: "KeyB", keyCode: 88, ctrlKey: true }), "x");
});
it("AZERTY's Ctrl+Shift+Z is the key that TYPES z (physical W)", () => {
assert.equal(shortcutKey({ key: "Z", code: "KeyW", keyCode: 90, ctrlKey: true, shiftKey: true }), "z");
// Physical KeyZ types "w" on AZERTY — and Ctrl+Shift+W closes the window,
// which is precisely why nothing of ours may answer to it.
assert.equal(shortcutKey({ key: "W", code: "KeyZ", keyCode: 87, ctrlKey: true, shiftKey: true }), "w");
});
it("a Latin letter from the layout always beats the physical position", () => {
for (const layout of LATIN) {
for (const binding of BINDINGS) {
const event = keydown(layout, binding.char, binding.mods);
assert.equal(layoutKey(event), binding.char, `${layout.id} ${binding.name}`);
}
}
});
it("AltGr is typing, not commanding", () => {
// Polish AltGr+E is "ę". Resolving that to the physical E would toggle the
// reading view every time a Polish reader typed a word with ę in it.
const polish: ShortcutEvent = {
key: "ę",
code: "KeyE",
keyCode: 69,
ctrlKey: true,
altKey: true,
getModifierState: (name) => name === "AltGraph",
};
assert.equal(shortcutKey(polish), null);
// The same physical key with a plain left Ctrl+Alt is still a command.
const plain: ShortcutEvent = { ...polish, getModifierState: () => false, key: "ε" };
assert.equal(shortcutKey(plain), "e");
});
});
describe("the keys a layout mangles in other ways", () => {
it("Arabic's lam-alef ligature is TWO code points, and still resolves", () => {
// The Arabic 101 layout puts "لا" on the physical B key. It is the case
// CodeMirror's own keyCode fallback misses (its `isChar` test requires a
// single code point), and the one Chromium's DevTools Protocol cannot even
// deliver — so it lives here rather than in scripts/check-layouts.mjs.
const event: ShortcutEvent = { key: "لا", code: "KeyB", keyCode: 66, ctrlKey: true };
assert.equal(event.key.length, 2);
assert.equal(layoutKey(event), null);
assert.equal(shortcutKey(event), "b");
});
it("an empty key (Chromium's answer to the same ligature) resolves", () => {
assert.equal(shortcutKey({ key: "", code: "KeyB", keyCode: 66, ctrlKey: true }), "b");
});
it("macOS Option+B is '∫', and still folds the pane", () => {
assert.equal(shortcutKey({ key: "∫", code: "KeyB", keyCode: 66, metaKey: true, altKey: true }), "b");
assert.equal(shortcutKey({ key: "†", code: "KeyT", keyCode: 84, metaKey: true, altKey: true }), "t");
});
it("a dead key resolves through the physical position", () => {
assert.equal(shortcutKey({ key: "Dead", code: "KeyP", keyCode: 80, ctrlKey: true }), "p");
assert.equal(shortcutKey({ key: "Unidentified", code: "KeyK", keyCode: 75, ctrlKey: true }), "k");
});
it("an IME that sends no code at all falls back to the legacy keyCode", () => {
assert.equal(shortcutKey({ key: "ح", code: "", keyCode: 80, ctrlKey: true }), "p");
assert.equal(shortcutKey({ key: "ظ", keyCode: 191, ctrlKey: true }), "/");
});
it("an event with nothing usable resolves to null rather than guessing", () => {
assert.equal(shortcutKey({ key: "ح", code: "", keyCode: 0, ctrlKey: true }), null);
assert.equal(shortcutKey({ key: "Escape", code: "Escape", keyCode: 27 }), null);
assert.equal(shortcutKey({ key: "ArrowUp", code: "ArrowUp", keyCode: 38, ctrlKey: true }), null);
});
});
describe("Ctrl+/ — the one binding that is punctuation", () => {
it("answers to the layout's own slash wherever it lives", () => {
// US: Slash. German: Shift+7. Dvorak: the physical [ key. All arrive as "/".
assert.equal(isKey({ key: "/", code: "Slash", keyCode: 191, ctrlKey: true }, "/"), true);
assert.equal(isKey({ key: "/", code: "Digit7", keyCode: 55, ctrlKey: true, shiftKey: true }, "/"), true);
assert.equal(isKey({ key: "/", code: "BracketLeft", keyCode: 219, ctrlKey: true }, "/"), true);
});
it("answers to Ctrl+? — Shift+/ on a US keyboard", () => {
assert.equal(isKey({ key: "?", code: "Slash", keyCode: 191, ctrlKey: true, shiftKey: true }, "/"), true);
});
it("answers to the physical slash key on Arabic, which types ظ", () => {
assert.equal(isKey({ key: "ظ", code: "Slash", keyCode: 191, ctrlKey: true }, "/"), true);
});
it("does NOT answer to Dvorak's physical slash key, which types z", () => {
// Ctrl+z there is undo, and undo it must stay.
assert.equal(isKey({ key: "z", code: "Slash", keyCode: 90, ctrlKey: true }, "/"), false);
assert.equal(shortcutKey({ key: "z", code: "Slash", keyCode: 90, ctrlKey: true }), "z");
});
it("does NOT answer to the Russian physical slash key, which types a period", () => {
// ЙЦУКЕН has no slash there at all; the Russian reader's "/" is
// Shift+Backslash, and it arrives as "/" — the layout answers.
assert.equal(isKey({ key: ".", code: "Slash", keyCode: 190, ctrlKey: true }, "/"), false);
assert.equal(isKey({ key: "/", code: "Backslash", keyCode: 220, ctrlKey: true, shiftKey: true }, "/"), true);
});
});
describe("physicalKey", () => {
it("names every letter, digit and bound punctuation key by position", () => {
assert.equal(physicalKey({ key: "", code: "KeyQ" }), "q");
assert.equal(physicalKey({ key: "", code: "Digit4" }), "4");
assert.equal(physicalKey({ key: "", code: "Numpad4" }), "4");
assert.equal(physicalKey({ key: "", code: "BracketRight" }), "]");
assert.equal(physicalKey({ key: "", code: "Backslash" }), "\\");
});
it("names nothing for the keys that are not characters", () => {
for (const code of ["Escape", "F1", "ArrowLeft", "Enter", "Tab", "Space", "ShiftLeft"]) {
assert.equal(physicalKey({ key: "", code }), null, code);
}
});
});