Space/Enter (body-focus only), H/L + arrows skip, J/K + PgDn/PgUp chapter steps, B bookmark capture (note box auto-focuses, Enter saves even when blank, Shift+Enter inserts a newline, Esc cancels), Alt+arrows / Alt+H/L speed steps, up/down volume with a brief OSD, M mute, [ ] skip-duration cycle, ? shortcut dialog, Esc closes the top-most surface (help → capture → note edit → drawers → dropdowns). audio-keys.ts is the ONE declarative table: resolveShortcut() drives the document keydown dispatcher and visibleKeymap() renders the templ dialog (x-for over the exposed keymap — templ cannot import TS). Hardware media keys ride the existing MediaSession handlers; the Media* keydown codes resolve through the same table to the SAME methods, so both layers stay one behavior. Guards mirror the ebook reader: typing in any INPUT/TEXTAREA/contentEditable never triggers shortcuts (bookmark notes included), a focused button keeps Space/Enter for itself, and the focused scrubber keeps its arrows (its own @change seeks — a native arrow nudge moves 0.08s, not a 10s skip). SKIP_SECONDS consolidates to one const beside SPEEDS (the templ skip dropdown now iterates skipChoices; [ ] cycles via cycleSkip()) — the same one-source pattern as the §5 speed-list consolidation. Unit: audio-keys.test.ts (resolution, Alt disambiguation, media keys, keymap integrity and conflict-freedom, dialog visibility) and cycleSkip tests. Live: 39 Playwright checks on the KJV folder book covering every binding, the guards, dialog and OSD; probe user created and deleted.
92 lines
3.9 KiB
TypeScript
92 lines
3.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { KEYMAP, resolveShortcut, visibleKeymap } from "./audio-keys";
|
|
|
|
describe("resolveShortcut", () => {
|
|
it("resolves every in-page binding", () => {
|
|
expect(resolveShortcut(" ", false)).toBe("toggle-play");
|
|
expect(resolveShortcut("Enter", false)).toBe("toggle-play");
|
|
expect(resolveShortcut("h", false)).toBe("skip-back");
|
|
expect(resolveShortcut("H", false)).toBe("skip-back");
|
|
expect(resolveShortcut("ArrowLeft", false)).toBe("skip-back");
|
|
expect(resolveShortcut("l", false)).toBe("skip-forward");
|
|
expect(resolveShortcut("ArrowRight", false)).toBe("skip-forward");
|
|
expect(resolveShortcut("k", false)).toBe("prev-chapter");
|
|
expect(resolveShortcut("PageUp", false)).toBe("prev-chapter");
|
|
expect(resolveShortcut("j", false)).toBe("next-chapter");
|
|
expect(resolveShortcut("PageDown", false)).toBe("next-chapter");
|
|
expect(resolveShortcut("b", false)).toBe("bookmark");
|
|
expect(resolveShortcut("ArrowUp", false)).toBe("volume-up");
|
|
expect(resolveShortcut("ArrowDown", false)).toBe("volume-down");
|
|
expect(resolveShortcut("m", false)).toBe("toggle-mute");
|
|
expect(resolveShortcut("]", false)).toBe("cycle-skip-up");
|
|
expect(resolveShortcut("[", false)).toBe("cycle-skip-down");
|
|
expect(resolveShortcut("?", false)).toBe("toggle-help");
|
|
expect(resolveShortcut("Escape", false)).toBe("escape");
|
|
});
|
|
|
|
it("keeps Alt bindings distinct from their plain counterparts", () => {
|
|
expect(resolveShortcut("l", true)).toBe("speed-up");
|
|
expect(resolveShortcut("l", false)).toBe("skip-forward");
|
|
expect(resolveShortcut("h", true)).toBe("speed-down");
|
|
expect(resolveShortcut("ArrowRight", true)).toBe("speed-up");
|
|
expect(resolveShortcut("ArrowLeft", true)).toBe("speed-down");
|
|
// Alt on an unbound key claims nothing.
|
|
expect(resolveShortcut("ArrowUp", true)).toBeNull();
|
|
expect(resolveShortcut("m", true)).toBeNull();
|
|
});
|
|
|
|
it("resolves in-page media key events", () => {
|
|
expect(resolveShortcut("MediaPlayPause", false)).toBe("media-toggle-play");
|
|
expect(resolveShortcut("MediaPlay", false)).toBe("media-play");
|
|
expect(resolveShortcut("MediaPause", false)).toBe("media-pause");
|
|
expect(resolveShortcut("MediaStop", false)).toBe("media-pause");
|
|
expect(resolveShortcut("MediaTrackNext", false)).toBe("media-next");
|
|
expect(resolveShortcut("MediaTrackPrevious", false)).toBe("media-prev");
|
|
});
|
|
|
|
it("returns null for unknown keys", () => {
|
|
expect(resolveShortcut("x", false)).toBeNull();
|
|
expect(resolveShortcut("F5", false)).toBeNull();
|
|
expect(resolveShortcut("", false)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("KEYMAP integrity", () => {
|
|
it("has exactly one spec per action", () => {
|
|
const actions = KEYMAP.map((spec) => spec.action);
|
|
expect(new Set(actions).size).toBe(actions.length);
|
|
});
|
|
|
|
it("has no conflicting (key, alt) combos across actions", () => {
|
|
const seen = new Map<string, string>();
|
|
for (const spec of KEYMAP) {
|
|
for (const k of spec.keys) {
|
|
const combo = `${k.alt ? "Alt+" : ""}${k.key}`;
|
|
const owner = seen.get(combo);
|
|
expect(owner, `${combo} bound by both ${owner} and ${spec.action}`).toBeUndefined();
|
|
seen.set(combo, spec.action);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("gives every spec at least one key, a label and a description", () => {
|
|
for (const spec of KEYMAP) {
|
|
expect(spec.keys.length, spec.action).toBeGreaterThan(0);
|
|
expect(spec.label.length, spec.action).toBeGreaterThan(0);
|
|
expect(spec.description.length, spec.action).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it("hides only media-key rows from the dialog", () => {
|
|
const visible = new Set(visibleKeymap().map((spec) => spec.action));
|
|
for (const spec of KEYMAP) {
|
|
if (spec.action.startsWith("media-")) {
|
|
expect(visible.has(spec.action), spec.action).toBe(false);
|
|
} else {
|
|
expect(visible.has(spec.action), spec.action).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
});
|