返回 DeepSeek-Reasonix
composer-keyboard.test.ts
根目录 / desktop / frontend / src / __tests__ / composer-keyboard.test.ts
1 // Run: tsx src/__tests__/composer-keyboard.test.ts
2 //
3 // Tests for composer prompt-history keyboard gating.
4
5 import {
6 canUsePromptHistory,
7 composerEscapeAction,
8 composerEnterAction,
9 composerMenuKeyAction,
10 insertComposerNewline,
11 isFnKeyEvent,
12 isImeKeyEvent,
13 promptHistoryDirectionFromEvent,
14 type PromptHistoryDirection,
15 } from "../lib/composerKeyboard";
16 import { resetCustomShortcuts, saveCustomShortcut } from "../lib/keyboardShortcuts";
17 import type { ComposerInvocation } from "../lib/invocationDisplay";
18
19 let passed = 0;
20 let failed = 0;
21
22 function eq(a: unknown, b: unknown, label: string) {
23 if (a === b) {
24 process.stdout.write(` PASS ${label}\n`);
25 passed += 1;
26 } else {
27 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
28 failed += 1;
29 }
30 }
31
32 function eligible(
33 direction: PromptHistoryDirection | null,
34 overrides: Partial<Parameters<typeof canUsePromptHistory>[0]> = {},
35 ) {
36 return canUsePromptHistory({
37 direction,
38 menuOpen: false,
39 composing: false,
40 altKey: false,
41 ctrlKey: false,
42 metaKey: false,
43 shiftKey: false,
44 fnKey: false,
45 value: "",
46 selectionStart: 0,
47 selectionEnd: 0,
48 historyIndex: -1,
49 ...overrides,
50 });
51 }
52
53 console.log("\ncomposerKeyboard");
54
55 eq(promptHistoryDirectionFromEvent({ key: "ArrowUp" }), "up", "ArrowUp maps to history up");
56 eq(promptHistoryDirectionFromEvent({ key: "ArrowDown" }), "down", "ArrowDown maps to history down");
57 eq(promptHistoryDirectionFromEvent({ key: "PageUp", code: "PageUp", keyCode: 33 }), null, "PageUp does not map to history");
58 eq(promptHistoryDirectionFromEvent({ key: "Home", code: "Home", keyCode: 36 }), null, "Home does not map to history");
59 eq(promptHistoryDirectionFromEvent({ key: "Unidentified", keyCode: 38 }), "up", "legacy unidentified keyCode 38 maps up");
60
61 eq(isFnKeyEvent({ key: "Fn" }), true, "Fn key is detected");
62 eq(isFnKeyEvent({ key: "ArrowUp", getModifierState: (key) => key === "Fn" }), true, "Fn modifier is detected");
63
64 eq(eligible("up", { value: "", selectionStart: 0, selectionEnd: 0 }), true, "empty input ArrowUp can recall history");
65 eq(eligible("down", { value: "", selectionStart: 0, selectionEnd: 0 }), false, "empty input ArrowDown does not start history");
66 eq(eligible("up", { value: "draft", selectionStart: 5, selectionEnd: 5 }), false, "ArrowUp at draft end keeps native textarea movement");
67 eq(eligible("up", { value: "draft", selectionStart: 0, selectionEnd: 0 }), true, "ArrowUp at first position recalls history");
68 eq(eligible("down", { value: "history", selectionStart: 7, selectionEnd: 7, historyIndex: 0 }), true, "ArrowDown at recalled history end moves newer");
69 eq(eligible("down", { value: "history", selectionStart: 3, selectionEnd: 3, historyIndex: 0 }), false, "ArrowDown inside text keeps native movement");
70 eq(eligible("up", { value: "line1\nline2", selectionStart: 7, selectionEnd: 7 }), false, "ArrowUp inside multiline text keeps native movement");
71 eq(eligible("up", { value: "history", selectionStart: 7, selectionEnd: 7, historyIndex: 0 }), true, "history mode allows repeated ArrowUp");
72 eq(eligible("up", { value: "", selectionStart: 0, selectionEnd: 0, fnKey: true }), false, "Fn-modified arrows are not history shortcuts");
73 eq(eligible("up", { value: "", selectionStart: 0, selectionEnd: 0, shiftKey: true }), false, "Shift+Arrow preserves selection behavior");
74
75 console.log("\ncomposerEnterAction");
76
77 resetCustomShortcuts();
78 eq(composerEnterAction({ key: "Enter" }, "darwin"), "send", "plain Enter sends by default");
79 eq(composerEnterAction({ key: "Enter", shiftKey: true }, "darwin"), "newline-native", "Shift+Enter keeps the native line break by default");
80 eq(composerEnterAction({ key: "Enter", ctrlKey: true }, "windows"), "send", "Ctrl+Enter keeps the legacy default send behavior");
81 eq(composerEnterAction({ key: "Enter", metaKey: true }, "darwin"), "send", "Cmd+Enter keeps the legacy default send behavior");
82 eq(composerEnterAction({ key: "Enter", altKey: true }, "linux"), "send", "Alt+Enter keeps the legacy default send behavior");
83 eq(composerEnterAction({ key: "a" }, "darwin"), null, "non-Enter keys are ignored");
84
85 saveCustomShortcut("composer.newline", { key: "Enter", ctrl: true });
86 eq(composerEnterAction({ key: "Enter", ctrlKey: true }, "windows"), "newline-insert", "custom Ctrl+Enter inserts a newline manually");
87 eq(composerEnterAction({ key: "Enter", shiftKey: true }, "windows"), "none", "Shift+Enter does nothing once the newline chord moved to Ctrl+Enter");
88 eq(composerEnterAction({ key: "Enter" }, "windows"), "send", "plain Enter still sends with a custom newline chord");
89 resetCustomShortcuts();
90
91 saveCustomShortcut("composer.send", { key: "Enter", ctrl: true });
92 eq(composerEnterAction({ key: "Enter", ctrlKey: true }, "linux"), "send", "custom Ctrl+Enter sends (WeChat-style layout)");
93 eq(composerEnterAction({ key: "Enter" }, "linux"), "newline-insert", "plain Enter breaks the line when the send chord moved to Ctrl+Enter");
94 eq(composerEnterAction({ key: "Enter", shiftKey: true }, "linux"), "newline-native", "Shift+Enter still breaks the line in the WeChat-style layout");
95 eq(composerEnterAction({ key: "Enter", altKey: true }, "linux"), "none", "a custom send chord disables legacy modified-Enter aliases");
96
97 resetCustomShortcuts();
98 eq(composerEnterAction({ key: "Enter" }, "darwin"), "send", "reset restores plain-Enter send");
99 eq(composerEnterAction({ key: "Enter", shiftKey: true }, "darwin"), "newline-native", "reset restores the Shift+Enter default");
100
101 console.log("\ncomposerEscapeAction / IME gating");
102
103 eq(composerEscapeAction({ key: "Escape" }, true, true), "pass-through", "Escape passes through while composition is active");
104 eq(composerEscapeAction({ key: "Escape" }, true, false), "cancel", "plain Escape cancels a running turn");
105 eq(composerEscapeAction({ key: "Escape" }, false, false), "pass-through", "Escape does not cancel an idle composer");
106 eq(composerEscapeAction({ key: "Enter" }, true, false), "pass-through", "non-Escape keys do not trigger cancellation");
107
108 console.log("\ncomposerMenuKeyAction / IME gating");
109
110 eq(composerMenuKeyAction({ key: "Escape" }, true), "pass-through", "IME Escape stays with the input");
111 eq(composerMenuKeyAction({ key: "Enter" }, true), "pass-through", "IME Enter stays with the input");
112 eq(composerMenuKeyAction({ key: "ArrowDown" }, true), "pass-through", "IME ArrowDown stays with the input");
113 eq(composerMenuKeyAction({ key: "Escape" }, false), "handle", "plain Escape is handled by the menu");
114 eq(composerMenuKeyAction({ key: "Enter" }, false), "handle", "plain Enter is handled by the menu");
115 eq(composerMenuKeyAction({ key: "a" }, false), "pass-through", "typing keys pass through menu navigation");
116
117 const now = 10_000;
118 eq(
119 isImeKeyEvent({ key: "Escape", isComposing: true }, false, 0, now),
120 true,
121 "native isComposing protects IME Escape",
122 );
123 eq(
124 isImeKeyEvent({ key: "Escape", keyCode: 229 }, false, 0, now),
125 true,
126 "legacy keyCode 229 protects IME Escape",
127 );
128 eq(
129 isImeKeyEvent({ key: "Escape" }, true, 0, now),
130 true,
131 "composition ref protects IME Escape",
132 );
133 eq(
134 isImeKeyEvent({ key: "Escape" }, false, now - 99, now),
135 true,
136 "recent compositionend grace protects IME Escape",
137 );
138 eq(
139 isImeKeyEvent({ key: "Escape" }, false, now - 100, now),
140 false,
141 "compositionend grace expires at its boundary",
142 );
143 eq(
144 isImeKeyEvent({ key: "Escape" }, false, now - 101, now),
145 false,
146 "stale compositionend does not block plain Escape",
147 );
148
149 const boundaryInvocations: ComposerInvocation[] = [
150 { id: "first", offset: 0, command: { name: "first", description: "First skill", kind: "skill" } },
151 { id: "second", offset: 0, command: { name: "second", description: "Second subagent", kind: "subagent" } },
152 ];
153 const newlineAfterFirst = insertComposerNewline("task", boundaryInvocations, {
154 start: 0,
155 end: 0,
156 afterInvocationId: "first",
157 });
158 eq(newlineAfterFirst.text, "\ntask", "custom newline updates the composer text");
159 eq(
160 JSON.stringify(newlineAfterFirst.invocations.map((invocation) => [invocation.id, invocation.offset])),
161 JSON.stringify([["first", 0], ["second", 1]]),
162 "custom newline stays after the invocation at the caret boundary",
163 );
164
165 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
166 if (failed > 0) process.exit(1);
167
167 lines TYPESCRIPT