返回 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 composerEnterAction,
8 insertComposerNewline,
9 isFnKeyEvent,
10 promptHistoryDirectionFromEvent,
11 type PromptHistoryDirection,
12 } from "../lib/composerKeyboard";
13 import { resetCustomShortcuts, saveCustomShortcut } from "../lib/keyboardShortcuts";
14 import type { ComposerInvocation } from "../lib/invocationDisplay";
15
16 let passed = 0;
17 let failed = 0;
18
19 function eq(a: unknown, b: unknown, label: string) {
20 if (a === b) {
21 process.stdout.write(` PASS ${label}\n`);
22 passed += 1;
23 } else {
24 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
25 failed += 1;
26 }
27 }
28
29 function eligible(
30 direction: PromptHistoryDirection | null,
31 overrides: Partial<Parameters<typeof canUsePromptHistory>[0]> = {},
32 ) {
33 return canUsePromptHistory({
34 direction,
35 menuOpen: false,
36 composing: false,
37 altKey: false,
38 ctrlKey: false,
39 metaKey: false,
40 shiftKey: false,
41 fnKey: false,
42 value: "",
43 selectionStart: 0,
44 selectionEnd: 0,
45 historyIndex: -1,
46 ...overrides,
47 });
48 }
49
50 console.log("\ncomposerKeyboard");
51
52 eq(promptHistoryDirectionFromEvent({ key: "ArrowUp" }), "up", "ArrowUp maps to history up");
53 eq(promptHistoryDirectionFromEvent({ key: "ArrowDown" }), "down", "ArrowDown maps to history down");
54 eq(promptHistoryDirectionFromEvent({ key: "PageUp", code: "PageUp", keyCode: 33 }), null, "PageUp does not map to history");
55 eq(promptHistoryDirectionFromEvent({ key: "Home", code: "Home", keyCode: 36 }), null, "Home does not map to history");
56 eq(promptHistoryDirectionFromEvent({ key: "Unidentified", keyCode: 38 }), "up", "legacy unidentified keyCode 38 maps up");
57
58 eq(isFnKeyEvent({ key: "Fn" }), true, "Fn key is detected");
59 eq(isFnKeyEvent({ key: "ArrowUp", getModifierState: (key) => key === "Fn" }), true, "Fn modifier is detected");
60
61 eq(eligible("up", { value: "", selectionStart: 0, selectionEnd: 0 }), true, "empty input ArrowUp can recall history");
62 eq(eligible("down", { value: "", selectionStart: 0, selectionEnd: 0 }), false, "empty input ArrowDown does not start history");
63 eq(eligible("up", { value: "draft", selectionStart: 5, selectionEnd: 5 }), false, "ArrowUp at draft end keeps native textarea movement");
64 eq(eligible("up", { value: "draft", selectionStart: 0, selectionEnd: 0 }), true, "ArrowUp at first position recalls history");
65 eq(eligible("down", { value: "history", selectionStart: 7, selectionEnd: 7, historyIndex: 0 }), true, "ArrowDown at recalled history end moves newer");
66 eq(eligible("down", { value: "history", selectionStart: 3, selectionEnd: 3, historyIndex: 0 }), false, "ArrowDown inside text keeps native movement");
67 eq(eligible("up", { value: "line1\nline2", selectionStart: 7, selectionEnd: 7 }), false, "ArrowUp inside multiline text keeps native movement");
68 eq(eligible("up", { value: "history", selectionStart: 7, selectionEnd: 7, historyIndex: 0 }), true, "history mode allows repeated ArrowUp");
69 eq(eligible("up", { value: "", selectionStart: 0, selectionEnd: 0, fnKey: true }), false, "Fn-modified arrows are not history shortcuts");
70 eq(eligible("up", { value: "", selectionStart: 0, selectionEnd: 0, shiftKey: true }), false, "Shift+Arrow preserves selection behavior");
71
72 console.log("\ncomposerEnterAction");
73
74 resetCustomShortcuts();
75 eq(composerEnterAction({ key: "Enter" }, "darwin"), "send", "plain Enter sends by default");
76 eq(composerEnterAction({ key: "Enter", shiftKey: true }, "darwin"), "newline-native", "Shift+Enter keeps the native line break by default");
77 eq(composerEnterAction({ key: "Enter", ctrlKey: true }, "windows"), "send", "Ctrl+Enter keeps the legacy default send behavior");
78 eq(composerEnterAction({ key: "Enter", metaKey: true }, "darwin"), "send", "Cmd+Enter keeps the legacy default send behavior");
79 eq(composerEnterAction({ key: "Enter", altKey: true }, "linux"), "send", "Alt+Enter keeps the legacy default send behavior");
80 eq(composerEnterAction({ key: "a" }, "darwin"), null, "non-Enter keys are ignored");
81
82 saveCustomShortcut("composer.newline", { key: "Enter", ctrl: true });
83 eq(composerEnterAction({ key: "Enter", ctrlKey: true }, "windows"), "newline-insert", "custom Ctrl+Enter inserts a newline manually");
84 eq(composerEnterAction({ key: "Enter", shiftKey: true }, "windows"), "none", "Shift+Enter does nothing once the newline chord moved to Ctrl+Enter");
85 eq(composerEnterAction({ key: "Enter" }, "windows"), "send", "plain Enter still sends with a custom newline chord");
86 resetCustomShortcuts();
87
88 saveCustomShortcut("composer.send", { key: "Enter", ctrl: true });
89 eq(composerEnterAction({ key: "Enter", ctrlKey: true }, "linux"), "send", "custom Ctrl+Enter sends (WeChat-style layout)");
90 eq(composerEnterAction({ key: "Enter" }, "linux"), "newline-insert", "plain Enter breaks the line when the send chord moved to Ctrl+Enter");
91 eq(composerEnterAction({ key: "Enter", shiftKey: true }, "linux"), "newline-native", "Shift+Enter still breaks the line in the WeChat-style layout");
92 eq(composerEnterAction({ key: "Enter", altKey: true }, "linux"), "none", "a custom send chord disables legacy modified-Enter aliases");
93
94 resetCustomShortcuts();
95 eq(composerEnterAction({ key: "Enter" }, "darwin"), "send", "reset restores plain-Enter send");
96 eq(composerEnterAction({ key: "Enter", shiftKey: true }, "darwin"), "newline-native", "reset restores the Shift+Enter default");
97
98 const boundaryInvocations: ComposerInvocation[] = [
99 { id: "first", offset: 0, command: { name: "first", description: "First skill", kind: "skill" } },
100 { id: "second", offset: 0, command: { name: "second", description: "Second subagent", kind: "subagent" } },
101 ];
102 const newlineAfterFirst = insertComposerNewline("task", boundaryInvocations, {
103 start: 0,
104 end: 0,
105 afterInvocationId: "first",
106 });
107 eq(newlineAfterFirst.text, "\ntask", "custom newline updates the composer text");
108 eq(
109 JSON.stringify(newlineAfterFirst.invocations.map((invocation) => [invocation.id, invocation.offset])),
110 JSON.stringify([["first", 0], ["second", 1]]),
111 "custom newline stays after the invocation at the caret boundary",
112 );
113
114 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
115 if (failed > 0) process.exit(1);
116
116 lines TYPESCRIPT