Skip to content
Source
Sign inSign up

dispatcher.browser.test.ts

147 lines4.9 KB

1import type { KeybindingCommand, ResolvedKeybindingsConfig } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3​
4import { dispatchMenuAction, registerCommands, registerMenuAction } from "./dispatcher";
5​
6function bindings(entries: ReadonlyArray<[string, KeybindingCommand]>): ResolvedKeybindingsConfig {
7 return entries.map(([key, command]) => ({
8 command,
9 shortcut: {
10 key,
11 metaKey: false,
12 ctrlKey: false,
13 shiftKey: false,
14 altKey: false,
15 modKey: false,
16 },
17 }));
19​
20const keybindings = () =>
21 bindings([
22 ["k", "chat.new"],
23 ["j", "chat.newLocal"],
24 ]);
25​
26function press(key: string): KeyboardEvent {
27 const event = new KeyboardEvent("keydown", { key, cancelable: true, bubbles: true });
28 window.dispatchEvent(event);
29 return event;
31​
32describe("command dispatcher", () => {
33 it("runs the newest scope's handler and nothing below it", () => {
34 const global = vi.fn();
35 const outer = vi.fn();
36 const inner = vi.fn();
37 using stack = new DisposableStack();
38 stack.defer(registerCommands({ "chat.new": global }, { keybindings }));
39 stack.defer(registerCommands({ "chat.new": outer }, { keybindings }));
40 stack.defer(registerCommands({ "chat.new": inner }, { keybindings }));
41​
42 press("k");
43​
44 expect(inner).toHaveBeenCalledOnce();
45 expect(outer).not.toHaveBeenCalled();
46 expect(global).not.toHaveBeenCalled();
47 });
48​
49 it("falls through scopes that return false, skip the command, or fail their guard", () => {
50 const global = vi.fn();
51 const declined = vi.fn(() => false);
52 const guarded = vi.fn();
53 using stack = new DisposableStack();
54 stack.defer(registerCommands({ "chat.new": global }, { keybindings }));
55 stack.defer(registerCommands({ "chat.new": declined }, { keybindings }));
56 stack.defer(registerCommands({ "chat.newLocal": vi.fn() }, { keybindings }));
57 stack.defer(registerCommands({ "chat.new": guarded }, { keybindings, when: () => false }));
58​
59 press("k");
60​
61 expect(guarded).not.toHaveBeenCalled();
62 expect(declined).toHaveBeenCalledOnce();
63 expect(global).toHaveBeenCalledOnce();
64 });
65​
66 it("stops once a handler prevents default, and unregisters on cleanup", () => {
67 const lower = vi.fn();
68 const upper = vi.fn((event: KeyboardEvent) => {
69 event.preventDefault();
70 return false;
71 });
72 using stack = new DisposableStack();
73 stack.defer(registerCommands({ "chat.new": lower }, { keybindings }));
74 const unregisterUpper = registerCommands({ "chat.new": upper }, { keybindings });
75​
76 press("k");
77 expect(lower).not.toHaveBeenCalled();
78​
79 unregisterUpper();
80 press("k");
81 expect(upper).toHaveBeenCalledOnce();
82 expect(lower).toHaveBeenCalledOnce();
83 });
84​
85 it("lets a scope claim raw keys before command lookup", () => {
86 const onEscape = vi.fn((event: KeyboardEvent) => (event.key === "Escape" ? undefined : false));
87 const command = vi.fn();
88 using stack = new DisposableStack();
89 stack.defer(registerCommands({ "chat.new": command }, { keybindings, onKeyDown: onEscape }));
90​
91 press("Escape");
92 press("k");
93​
94 expect(onEscape).toHaveBeenCalledTimes(2);
95 expect(command).toHaveBeenCalledOnce();
96 });
97​
98 it("tells observers whether a scope consumed the key, after the scopes ran", () => {
99 const calls: Array<string> = [];
100 using stack = new DisposableStack();
101 stack.defer(
102 registerCommands({}, { observe: (_event, consumed) => calls.push(`observe:${consumed}`) }),
103 );
104 stack.defer(
105 registerCommands({ "chat.new": () => void calls.push("command") }, { keybindings }),
106 );
107​
108 press("k");
109 press("x");
110​
111 expect(calls).toEqual(["command", "observe:true", "observe:false"]);
112 });
113​
114 it("consumes a command chord pressed inside a confirmation without running its handler", () => {
115 const command = vi.fn();
116 const dialog = document.createElement("div");
117 dialog.setAttribute("role", "alertdialog");
118 const cancel = document.createElement("button");
119 dialog.append(cancel);
120 document.body.append(dialog);
121 using stack = new DisposableStack();
122 stack.defer(() => dialog.remove());
123 stack.defer(registerCommands({ "chat.new": command }, { keybindings }));
124 cancel.focus();
125​
126 const event = new KeyboardEvent("keydown", { key: "k", cancelable: true, bubbles: true });
127 cancel.dispatchEvent(event);
128​
129 expect(command).not.toHaveBeenCalled();
130 expect(event.defaultPrevented).toBe(true);
131 });
132​
133 it("routes a menu action to its newest handler", () => {
134 const older = vi.fn();
135 const newer = vi.fn();
136 using stack = new DisposableStack();
137 stack.defer(registerMenuAction("test-action", older));
138 const unregisterNewer = registerMenuAction("test-action", newer);
139​
140 dispatchMenuAction("test-action");
141 unregisterNewer();
142 dispatchMenuAction("test-action");
143​
144 expect(newer).toHaveBeenCalledOnce();
145 expect(older).toHaveBeenCalledOnce();
146 });
147});