@gusnips/react 0.9.25 → 0.9.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,444 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement, useLayoutEffect, type ChangeEvent } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { useSlashCommands, type SlashCommand } from "./commands.ts";
6
+
7
+ interface Action extends SlashCommand {
8
+ action: string;
9
+ }
10
+
11
+ const commands: readonly Action[] = [
12
+ {
13
+ name: "help",
14
+ description: "See commands",
15
+ aliases: ["commands"],
16
+ tags: ["keyboard"],
17
+ action: "help",
18
+ },
19
+ {
20
+ name: "new",
21
+ description: "Start a chat",
22
+ aliases: ["fresh"],
23
+ tags: ["conversation"],
24
+ action: "new",
25
+ },
26
+ {
27
+ name: "stop",
28
+ description: "Stop the reply",
29
+ aliases: ["cancel"],
30
+ tags: ["reply"],
31
+ action: "stop",
32
+ },
33
+ {
34
+ name: "model",
35
+ description: "Choose a model",
36
+ aliases: ["llm"],
37
+ tags: ["engine"],
38
+ takesArg: true,
39
+ action: "model",
40
+ },
41
+ ];
42
+
43
+ const roots = new Set<Root>();
44
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
45
+
46
+ afterEach(() => {
47
+ for (const root of roots) act(() => root.unmount());
48
+ roots.clear();
49
+ document.body.replaceChildren();
50
+ });
51
+
52
+ function composer(initialText = "/", initialCommands = commands) {
53
+ const element = document.createElement("div");
54
+ document.body.append(element);
55
+ const root = createRoot(element);
56
+ roots.add(root);
57
+ let text = initialText;
58
+ let registry = initialCommands;
59
+ let current: ReturnType<typeof useSlashCommands<Action>> | undefined;
60
+ const execute = vi.fn();
61
+ const error = vi.fn();
62
+ const send = vi.fn();
63
+ const changed = vi.fn((next: string) => {
64
+ text = next;
65
+ render();
66
+ });
67
+
68
+ function Harness() {
69
+ const slash = useSlashCommands({
70
+ text,
71
+ commands: registry,
72
+ onChange: changed,
73
+ onExecute: execute,
74
+ onError: error,
75
+ });
76
+ useLayoutEffect(() => {
77
+ current = slash;
78
+ }, [slash]);
79
+ return createElement(
80
+ "div",
81
+ null,
82
+ createElement("textarea", {
83
+ value: text,
84
+ onChange: (event: ChangeEvent<HTMLTextAreaElement>) => changed(event.currentTarget.value),
85
+ "aria-label": "Message",
86
+ role: "combobox",
87
+ "aria-autocomplete": "list",
88
+ "aria-expanded": slash.open,
89
+ "aria-controls": slash.open ? slash.listId : undefined,
90
+ "aria-activedescendant": slash.activeId,
91
+ onKeyDown: (event) => {
92
+ if (slash.handleKeyDown(event)) return;
93
+ if (event.key === "Enter" && !event.shiftKey) {
94
+ event.preventDefault();
95
+ send(text);
96
+ }
97
+ },
98
+ }),
99
+ createElement(
100
+ "button",
101
+ {
102
+ type: "button",
103
+ onClick: () => {
104
+ if (!slash.submit()) send(text);
105
+ },
106
+ },
107
+ "Send",
108
+ ),
109
+ slash.open
110
+ ? createElement(
111
+ "div",
112
+ { id: slash.listId, role: "listbox", "aria-label": "Commands" },
113
+ ...slash.items.map((command, index) =>
114
+ createElement(
115
+ "button",
116
+ {
117
+ key: command.name,
118
+ type: "button",
119
+ tabIndex: -1,
120
+ id: slash.itemId(command),
121
+ role: "option",
122
+ "aria-selected": slash.index === index,
123
+ onMouseEnter: () => slash.highlight(index),
124
+ onMouseDown: (event) => event.preventDefault(),
125
+ onClick: (event) => slash.pick(command, event.altKey),
126
+ },
127
+ `/${command.name}`,
128
+ ),
129
+ ),
130
+ )
131
+ : null,
132
+ );
133
+ }
134
+
135
+ function render() {
136
+ root.render(createElement(Harness));
137
+ }
138
+ act(render);
139
+ const input = element.querySelector("textarea");
140
+ const sendButton = element.querySelector("button");
141
+ if (!input || !sendButton) throw new Error("Composer did not render");
142
+ input.focus();
143
+
144
+ return {
145
+ execute,
146
+ error,
147
+ send,
148
+ changed,
149
+ input,
150
+ get text() {
151
+ return text;
152
+ },
153
+ get menu() {
154
+ if (!current) throw new Error("Hook did not render");
155
+ return current;
156
+ },
157
+ change(next: string) {
158
+ act(() => {
159
+ text = next;
160
+ render();
161
+ });
162
+ },
163
+ register(next: readonly Action[]) {
164
+ act(() => {
165
+ registry = next;
166
+ render();
167
+ });
168
+ },
169
+ press(key: string, options: KeyboardEventInit = {}) {
170
+ const event = new KeyboardEvent("keydown", {
171
+ key,
172
+ bubbles: true,
173
+ cancelable: true,
174
+ ...options,
175
+ });
176
+ act(() => input.dispatchEvent(event));
177
+ return event;
178
+ },
179
+ clickSend() {
180
+ act(() => sendButton.click());
181
+ },
182
+ clickOption(name: string, options: MouseEventInit = {}) {
183
+ const option = Array.from(element.querySelectorAll("button[role=option]")).find(
184
+ (button) => button.textContent === `/${name}`,
185
+ );
186
+ if (!option) throw new Error(`Command not rendered: ${name}`);
187
+ const down = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
188
+ act(() => {
189
+ option.dispatchEvent(down);
190
+ option.dispatchEvent(new MouseEvent("click", { bubbles: true, ...options }));
191
+ });
192
+ return down;
193
+ },
194
+ };
195
+ }
196
+
197
+ describe("useSlashCommands", () => {
198
+ it("wraps arrow selection and links the active row to the input", () => {
199
+ const chat = composer();
200
+ expect(chat.menu.index).toBe(0);
201
+ expect(chat.input.getAttribute("aria-activedescendant")).toBe(chat.menu.itemId(commands[0]!));
202
+ expect(chat.press("ArrowUp").defaultPrevented).toBe(true);
203
+ expect(chat.menu.index).toBe(3);
204
+ chat.press("ArrowDown");
205
+ expect(chat.menu.index).toBe(0);
206
+ expect(chat.input.getAttribute("aria-controls")).toBe(chat.menu.listId);
207
+ });
208
+
209
+ it("executes a selected local action once without sending or clearing the draft", () => {
210
+ const chat = composer();
211
+ chat.press("ArrowDown");
212
+ chat.press("Enter");
213
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[1], undefined, false);
214
+ expect(chat.send).not.toHaveBeenCalled();
215
+ expect(chat.changed).not.toHaveBeenCalled();
216
+ expect(chat.text).toBe("/");
217
+ });
218
+
219
+ it("Tab completes a name without running its action", () => {
220
+ const chat = composer("/he");
221
+ expect(chat.press("Tab").defaultPrevented).toBe(true);
222
+ expect(chat.text).toBe("/help");
223
+ expect(chat.execute).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it("retains arguments when keyboard selection completes a partial name", () => {
227
+ const chat = composer("/mo large model");
228
+ chat.press("Enter");
229
+ expect(chat.text).toBe("/model large model");
230
+ expect(chat.execute).not.toHaveBeenCalled();
231
+ chat.press("Enter", { altKey: true });
232
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[3], "large model", true);
233
+ });
234
+
235
+ it("completes an argument-taking alias before keyboard or button submission", () => {
236
+ const keyboard = composer("/llm");
237
+ keyboard.press("Enter");
238
+ expect(keyboard.text).toBe("/model ");
239
+ expect(keyboard.execute).not.toHaveBeenCalled();
240
+ const button = composer("/llm");
241
+ button.clickSend();
242
+ expect(button.text).toBe("/model ");
243
+ expect(button.execute).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it("button submission uses an exact alias without entering the message path", () => {
247
+ const chat = composer("/cancel");
248
+ chat.clickSend();
249
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[2], undefined, false);
250
+ expect(chat.send).not.toHaveBeenCalled();
251
+ });
252
+
253
+ it("Escape stays closed until the query changes", () => {
254
+ const chat = composer("/he");
255
+ expect(chat.press("Escape").defaultPrevented).toBe(true);
256
+ expect(chat.menu.open).toBe(false);
257
+ chat.register([commands[1]!]);
258
+ expect(chat.menu.open).toBe(false);
259
+ expect(chat.menu.activeId).toBeUndefined();
260
+ chat.register(commands);
261
+ chat.change("/hel");
262
+ expect(chat.menu.open).toBe(true);
263
+ expect(chat.menu.index).toBe(0);
264
+ chat.change("/he");
265
+ expect(chat.menu.open).toBe(true);
266
+ });
267
+
268
+ it("reopens the full list even when Escape dismissed the same bare slash", () => {
269
+ const chat = composer();
270
+ chat.press("ArrowDown");
271
+ chat.press("Escape");
272
+ expect(chat.menu.open).toBe(false);
273
+ act(() => chat.menu.showAll());
274
+ expect(chat.menu.open).toBe(true);
275
+ expect(chat.menu.index).toBe(0);
276
+ expect(chat.menu.items).toEqual(commands);
277
+ expect(chat.text).toBe("/");
278
+ expect(chat.execute).not.toHaveBeenCalled();
279
+ expect(chat.send).not.toHaveBeenCalled();
280
+ });
281
+
282
+ it("returns an unknown draft to the full list for retry", () => {
283
+ const chat = composer("/missing arg");
284
+ chat.clickSend();
285
+ act(() => chat.menu.showAll());
286
+ expect(chat.text).toBe("/");
287
+ expect(chat.menu.open).toBe(true);
288
+ expect(chat.menu.items).toEqual(commands);
289
+ expect(chat.execute).not.toHaveBeenCalled();
290
+ });
291
+
292
+ it("protects IME when given a native keyboard event directly", () => {
293
+ const chat = composer("/help");
294
+ const event = new KeyboardEvent("keydown", {
295
+ key: "Enter",
296
+ isComposing: true,
297
+ cancelable: true,
298
+ });
299
+ act(() => expect(chat.menu.handleKeyDown(event)).toBe(true));
300
+ expect(event.defaultPrevented).toBe(false);
301
+ expect(chat.execute).not.toHaveBeenCalled();
302
+ });
303
+
304
+ it("leaves Shift+Enter and modified arrow/Tab keys to the text field", () => {
305
+ const chat = composer();
306
+ for (const [key, options] of [
307
+ ["Enter", { shiftKey: true }],
308
+ ["ArrowDown", { shiftKey: true }],
309
+ ["ArrowUp", { metaKey: true }],
310
+ ["Tab", { shiftKey: true }],
311
+ ] satisfies Array<[string, KeyboardEventInit]>) {
312
+ expect(chat.press(key, options).defaultPrevented).toBe(false);
313
+ }
314
+ expect(chat.execute).not.toHaveBeenCalled();
315
+ expect(chat.send).not.toHaveBeenCalled();
316
+ });
317
+
318
+ it("lets IME accept text without executing a command or sending a message", () => {
319
+ const chat = composer("/help");
320
+ expect(chat.press("Enter", { isComposing: true }).defaultPrevented).toBe(false);
321
+ expect(chat.execute).not.toHaveBeenCalled();
322
+ expect(chat.send).not.toHaveBeenCalled();
323
+ chat.change("plain text");
324
+ chat.press("Enter", { isComposing: true });
325
+ expect(chat.send).not.toHaveBeenCalled();
326
+ });
327
+
328
+ it("also protects the final IME key reported only as keyCode 229", () => {
329
+ const chat = composer("/help");
330
+ const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
331
+ Object.defineProperty(event, "keyCode", { value: 229 });
332
+ act(() => chat.input.dispatchEvent(event));
333
+ expect(event.defaultPrevented).toBe(false);
334
+ expect(chat.execute).not.toHaveBeenCalled();
335
+ expect(chat.send).not.toHaveBeenCalled();
336
+ });
337
+
338
+ it("exposes empty results and reports unknown commands without sending them", () => {
339
+ const chat = composer("/missing");
340
+ expect(chat.menu.open).toBe(true);
341
+ expect(chat.menu.items).toEqual([]);
342
+ expect(chat.menu.activeId).toBeUndefined();
343
+ chat.press("Enter");
344
+ expect(chat.error).toHaveBeenCalledExactlyOnceWith({ kind: "unknown", name: "missing" });
345
+ expect(chat.send).not.toHaveBeenCalled();
346
+ expect(chat.text).toBe("/missing");
347
+ });
348
+
349
+ it("tags find a row but cannot execute through direct submission", () => {
350
+ const chat = composer("/conversation");
351
+ expect(chat.menu.items.map((command) => command.name)).toEqual(["new"]);
352
+ chat.clickSend();
353
+ expect(chat.error).toHaveBeenCalledWith({ kind: "unknown", name: "conversation" });
354
+ expect(chat.execute).not.toHaveBeenCalled();
355
+ chat.press("Tab");
356
+ expect(chat.text).toBe("/new");
357
+ chat.clickSend();
358
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[1], undefined, false);
359
+ });
360
+
361
+ it("does not auto-dispatch an ambiguous alias on Enter or button submission", () => {
362
+ const registry = commands.slice(0, 2).map((command) => ({ ...command, aliases: ["shared"] }));
363
+ const chat = composer("/shared", registry);
364
+ chat.press("Enter");
365
+ chat.clickSend();
366
+ expect(chat.error).toHaveBeenCalledTimes(2);
367
+ expect(chat.error).toHaveBeenLastCalledWith({
368
+ kind: "ambiguous",
369
+ name: "shared",
370
+ commands: registry,
371
+ });
372
+ expect(chat.execute).not.toHaveBeenCalled();
373
+ expect(chat.send).not.toHaveBeenCalled();
374
+ });
375
+
376
+ it("lets an explicit arrow selection choose between ambiguous aliases", () => {
377
+ const registry = commands.slice(0, 2).map((command) => ({ ...command, aliases: ["shared"] }));
378
+ const chat = composer("/shared", registry);
379
+ chat.press("ArrowDown");
380
+ chat.press("Enter");
381
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(registry[1], undefined, false);
382
+ expect(chat.error).not.toHaveBeenCalled();
383
+ });
384
+
385
+ it("keeps focus on the text field when a row is clicked", () => {
386
+ const chat = composer();
387
+ expect(chat.clickOption("stop", { altKey: true }).defaultPrevented).toBe(true);
388
+ expect(document.activeElement).toBe(chat.input);
389
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[2], undefined, true);
390
+ expect(chat.send).not.toHaveBeenCalled();
391
+ });
392
+
393
+ it("refreshes matching and resets selection when capabilities change under a fixed draft", () => {
394
+ const chat = composer();
395
+ chat.press("ArrowDown");
396
+ expect(chat.menu.active?.name).toBe("new");
397
+ chat.register([commands[2]!]);
398
+ expect(chat.text).toBe("/");
399
+ expect(chat.menu.index).toBe(0);
400
+ expect(chat.menu.active?.name).toBe("stop");
401
+ chat.press("Enter");
402
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[2], undefined, false);
403
+ });
404
+
405
+ it("uses current translated search words even when the draft has not changed", () => {
406
+ const chat = composer("/comecar");
407
+ expect(chat.menu.items).toEqual([]);
408
+ const translated = commands.map((command) =>
409
+ command.name === "new"
410
+ ? { ...command, description: "Comece uma conversa", tags: ["começar", "conversa"] }
411
+ : command,
412
+ );
413
+ chat.register(translated);
414
+ expect(chat.menu.items.map((command) => command.name)).toEqual(["new"]);
415
+ expect(chat.text).toBe("/comecar");
416
+ });
417
+
418
+ it("re-resolves a retained row against current capabilities", () => {
419
+ const chat = composer();
420
+ const oldRow = chat.menu.items[2]!;
421
+ chat.register(commands.slice(0, 2));
422
+ act(() => chat.menu.pick(oldRow));
423
+ expect(chat.error).toHaveBeenCalledWith({ kind: "unknown", name: "stop" });
424
+ expect(chat.execute).not.toHaveBeenCalled();
425
+ });
426
+
427
+ it("direct submission does not depend on menu state and forwards Alt", () => {
428
+ const chat = composer("/cancel");
429
+ chat.press("Escape");
430
+ act(() => expect(chat.menu.submit(true)).toBe(true));
431
+ expect(chat.execute).toHaveBeenCalledExactlyOnceWith(commands[2], undefined, true);
432
+ });
433
+
434
+ it("leaves ordinary and multiline messages to the existing sender", () => {
435
+ const chat = composer("hi");
436
+ expect(chat.menu.open).toBe(false);
437
+ chat.clickSend();
438
+ expect(chat.send).toHaveBeenLastCalledWith("hi");
439
+ chat.change("/help\nquoted text");
440
+ chat.clickSend();
441
+ expect(chat.send).toHaveBeenLastCalledWith("/help\nquoted text");
442
+ expect(chat.execute).not.toHaveBeenCalled();
443
+ });
444
+ });
@@ -0,0 +1,245 @@
1
+ // @vitest-environment node
2
+ import { createElement } from "react";
3
+ import { renderToString } from "react-dom/server";
4
+ import { describe, expect, expectTypeOf, it, vi } from "vitest";
5
+ import {
6
+ completeCommand,
7
+ matchCommands,
8
+ parseCommand,
9
+ resolveCommand,
10
+ useSlashCommands,
11
+ } from "./commands.ts";
12
+
13
+ const help = {
14
+ name: "help",
15
+ description: "Show available commands",
16
+ aliases: ["assist", "GUIDE"],
17
+ tags: ["manual"],
18
+ };
19
+ const schedule = {
20
+ name: "schedule",
21
+ description: "Run a task later",
22
+ aliases: ["later", "book"],
23
+ tags: ["timer"],
24
+ takesArg: true,
25
+ };
26
+ const commands = [help, schedule];
27
+
28
+ describe("parseCommand", () => {
29
+ it.each([
30
+ { text: "/", expected: { name: "" } },
31
+ { text: "/ ", expected: { name: "", arg: "" } },
32
+ { text: "/HELP", expected: { name: "help" } },
33
+ { text: "/help ", expected: { name: "help", arg: "" } },
34
+ { text: " \t/HeLp", expected: { name: "help" } },
35
+ {
36
+ text: "/SCHEDULE Keep these spaces\tand case ",
37
+ expected: { name: "schedule", arg: "Keep these spaces\tand case " },
38
+ },
39
+ ])("parses a single line: $text", ({ text, expected }) => {
40
+ expect(parseCommand(text)).toEqual(expected);
41
+ });
42
+
43
+ it.each(["/tmp/file", "/help/topic", "//help"])(
44
+ "keeps a slash path as an unknown command token: %s",
45
+ (text) => {
46
+ const name = text.slice(1);
47
+ expect(parseCommand(text)).toEqual({ name });
48
+ expect(resolveCommand(commands, name)).toEqual({ kind: "unknown", name });
49
+ },
50
+ );
51
+
52
+ it.each(["", " \t", "help", "Please /help", "https://example.test/help", "Hello /help there"])(
53
+ "ignores non-command prose: %s",
54
+ (text) => {
55
+ expect(parseCommand(text)).toBeNull();
56
+ },
57
+ );
58
+
59
+ it.each(["\n", "\r", "\r\n", "\u{2028}", "\u{2029}"])(
60
+ "rejects a line terminator anywhere: %j",
61
+ (separator) => {
62
+ for (const text of [`${separator}/help`, `/help${separator}`, `/help ${separator}topic`]) {
63
+ expect(parseCommand(text)).toBeNull();
64
+ }
65
+ },
66
+ );
67
+ });
68
+
69
+ describe("resolveCommand", () => {
70
+ it("prefers an exact canonical name over an earlier alias", () => {
71
+ const alias = { ...schedule, aliases: ["help"] };
72
+ expect(resolveCommand([alias, help], "HELP")).toEqual({ kind: "matched", command: help });
73
+ });
74
+
75
+ it.each([
76
+ { token: "guide", command: help },
77
+ { token: "LATER", command: schedule },
78
+ ])("matches an alias without case sensitivity: $token", ({ token, command }) => {
79
+ expect(resolveCommand(commands, token)).toEqual({ kind: "matched", command });
80
+ });
81
+
82
+ it("reports aliases shared by different commands in registry order", () => {
83
+ const first = { ...schedule, aliases: ["shared", "SHARED"] };
84
+ const second = { ...help, aliases: ["shared"] };
85
+ expect(resolveCommand([first, second], "SHARED")).toEqual({
86
+ kind: "ambiguous",
87
+ name: "SHARED",
88
+ commands: [first, second],
89
+ });
90
+ expect(resolveCommand([first], "shared")).toEqual({ kind: "matched", command: first });
91
+ });
92
+
93
+ it("reports duplicate canonical names even when an alias also matches", () => {
94
+ const duplicate = { ...schedule, name: "HELP" };
95
+ const alias = { ...schedule, aliases: ["help"] };
96
+ expect(resolveCommand([help, alias, duplicate], "help")).toEqual({
97
+ kind: "ambiguous",
98
+ name: "help",
99
+ commands: [help, duplicate],
100
+ });
101
+ });
102
+
103
+ it.each(["manual", "timer", "Show available commands", "hel", "lat", "missing"])(
104
+ "does not execute a tag, description, prefix or unknown token: %s",
105
+ (name) => {
106
+ expect(resolveCommand(commands, name)).toEqual({ kind: "unknown", name });
107
+ },
108
+ );
109
+
110
+ it("never executes an empty token, even if the registry contains one", () => {
111
+ expect(resolveCommand([{ ...help, name: "", aliases: [""] }], "")).toEqual({
112
+ kind: "unknown",
113
+ name: "",
114
+ });
115
+ });
116
+ });
117
+
118
+ describe("matchCommands", () => {
119
+ it("ranks names, aliases, prefixes, tags and descriptions with stable ties", () => {
120
+ const registry = [
121
+ { name: "description", description: "Find a task", aliases: [], tags: [] },
122
+ { name: "tag", description: "", aliases: [], tags: ["finder"] },
123
+ { name: "shortcut", description: "", aliases: ["finder"], tags: [] },
124
+ { name: "finding", description: "", aliases: [], tags: [] },
125
+ { name: "alias", description: "", aliases: ["find"], tags: [] },
126
+ { name: "find", description: "", aliases: [], tags: [] },
127
+ { name: "second-tag", description: "", aliases: [], tags: ["find"] },
128
+ { name: "second-description", description: "Find another task", aliases: [], tags: [] },
129
+ { name: "unrelated", description: "", aliases: [], tags: [] },
130
+ ];
131
+ expect(matchCommands(registry, "find").map((command) => command.name)).toEqual([
132
+ "find",
133
+ "alias",
134
+ "finding",
135
+ "shortcut",
136
+ "tag",
137
+ "second-tag",
138
+ "description",
139
+ "second-description",
140
+ ]);
141
+ });
142
+
143
+ const translated = [
144
+ {
145
+ name: "ação",
146
+ description: "A próxima revisão",
147
+ aliases: ["café"],
148
+ tags: ["rápido"],
149
+ },
150
+ {
151
+ name: "mañana",
152
+ description: "Resumen de la reunión",
153
+ aliases: ["después"],
154
+ tags: ["configuración"],
155
+ },
156
+ ];
157
+ it.each([
158
+ { query: " ACAO ", name: "ação" },
159
+ { query: "ac\u{0327}a\u{0303}o", name: "ação" },
160
+ { query: "CAFE", name: "ação" },
161
+ { query: "RAPIDO", name: "ação" },
162
+ { query: "PROXIMA REVISAO", name: "ação" },
163
+ { query: "MANANA", name: "mañana" },
164
+ { query: "DESPUES", name: "mañana" },
165
+ { query: "CONFIGURACION", name: "mañana" },
166
+ { query: "REUNION", name: "mañana" },
167
+ ])("matches case and accents across search fields: $query", ({ query, name }) => {
168
+ expect(matchCommands(translated, query).map((command) => command.name)).toEqual([name]);
169
+ });
170
+
171
+ it("returns only the first descriptor for each canonical name", () => {
172
+ const duplicate = { ...help, name: "HELP" };
173
+ const registry = [help, schedule, duplicate];
174
+ expect(matchCommands(registry, "help")).toEqual([help]);
175
+ expect(matchCommands(registry, "")).toEqual(commands);
176
+ });
177
+
178
+ it("does not turn repeated or shared aliases into duplicate rows", () => {
179
+ const first = { ...help, aliases: ["shared", "shared", "SHARED"] };
180
+ const second = { ...schedule, aliases: ["shared"] };
181
+ expect(matchCommands([first, second], "shared")).toEqual([first, second]);
182
+ });
183
+
184
+ it("returns original descriptors and preserves their extra fields in the type", () => {
185
+ const custom = { ...help, execute: () => "done" };
186
+ const [matched] = matchCommands([custom], "assist");
187
+ expectTypeOf(matched).toEqualTypeOf<typeof custom | undefined>();
188
+ expect(matched).toBe(custom);
189
+ expect(matched?.execute()).toBe("done");
190
+ });
191
+
192
+ it.each(["", " \t "])("keeps registry order for an empty query: %j", (query) => {
193
+ expect(matchCommands([schedule, help], query)).toEqual([schedule, help]);
194
+ });
195
+
196
+ it("returns no rows for an unrelated query", () => {
197
+ expect(matchCommands(commands, "missing")).toEqual([]);
198
+ });
199
+ });
200
+
201
+ describe("completeCommand", () => {
202
+ it.each([
203
+ ["/later", "/schedule "],
204
+ ["/sche", "/schedule "],
205
+ ["/SCHEDULE", "/schedule "],
206
+ ["/", "/schedule "],
207
+ ["/later ", "/schedule "],
208
+ [" \t/later Keep these spaces\tand case ", "/schedule Keep these spaces\tand case "],
209
+ ])("canonicalizes an argument-taking command: %s", (text, expected) => {
210
+ expect(completeCommand(schedule, text)).toBe(expected);
211
+ });
212
+
213
+ it.each([
214
+ ["/assi", "/help"],
215
+ ["/GUIDE", "/help"],
216
+ ["/", "/help"],
217
+ ["/assist topic with spaces ", "/help topic with spaces "],
218
+ ])("canonicalizes a plain command without dropping its arguments: %s", (text, expected) => {
219
+ expect(completeCommand(help, text)).toBe(expected);
220
+ });
221
+ });
222
+
223
+ describe("useSlashCommands on the server", () => {
224
+ it("imports and renders in Node without browser globals or running callbacks", () => {
225
+ expect(typeof window).toBe("undefined");
226
+ expect(typeof document).toBe("undefined");
227
+ const onChange = vi.fn();
228
+ const onExecute = vi.fn();
229
+ const onError = vi.fn();
230
+
231
+ function Menu() {
232
+ const menu = useSlashCommands({ text: "/he", commands, onChange, onExecute, onError });
233
+ return createElement(
234
+ "output",
235
+ { id: menu.listId },
236
+ `${menu.open}/${menu.active?.name}/${menu.items.length}`,
237
+ );
238
+ }
239
+
240
+ expect(renderToString(createElement(Menu))).toContain("true/help/1");
241
+ expect(onChange).not.toHaveBeenCalled();
242
+ expect(onExecute).not.toHaveBeenCalled();
243
+ expect(onError).not.toHaveBeenCalled();
244
+ });
245
+ });