1import { describe, expect, it } from "vite-plus/test";
2
3import { collectComposerInlineTokens } from "./composerInlineTokens.ts";
4
5describe("collectComposerInlineTokens", () => {
6 it("collects file links, mentions, and skills with source ranges", () => {
7 const text = "Use $ui and inspect [Chat.tsx](src/Chat.tsx) with @AGENTS.md please";
8
9 expect(collectComposerInlineTokens(text)).toEqual([
10 {
11 type: "skill",
12 value: "ui",
13 source: "$ui",
14 start: 4,
15 end: 7,
16 },
17 {
18 type: "mention",
19 value: "src/Chat.tsx",
20 source: "[Chat.tsx](src/Chat.tsx)",
21 start: 20,
22 end: 44,
23 },
24 {
25 type: "mention",
26 value: "AGENTS.md",
27 source: "@AGENTS.md",
28 start: 50,
29 end: 60,
30 },
31 ]);
32 });
33
34 it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
35 "collects %s skill names that begin with a digit",
36 (prefix) => {
37 expect(collectComposerInlineTokens(`Use ${prefix}2spec next`)).toEqual([
38 {
39 type: "skill",
40 value: "2spec",
41 source: `${prefix}2spec`,
42 start: 4,
43 end: 9 + prefix.length,
44 },
45 ]);
46 },
47 );
48
49 it.each(["€", "£", "¥", "₹", "₩", "₿", "𑿝"])("leaves %s currency amounts as text", (prefix) => {
50 expect(
51 collectComposerInlineTokens(
52 `${prefix}20 ${prefix}1_000 ${prefix}20k ${prefix}100M ${prefix}1e6 `,
53 ),
54 ).toEqual([]);
55 });
56
57 it("leaves digits-only dollar amounts and compact monetary expressions as text", () => {
58 expect(collectComposerInlineTokens("I'll pay $20 tomorrow")).toEqual([]);
59 expect(collectComposerInlineTokens("Budget is $1_000 total")).toEqual([]);
60 expect(collectComposerInlineTokens("Budget is $20k tomorrow")).toEqual([]);
61 expect(collectComposerInlineTokens("Cost is $100M total")).toEqual([]);
62 expect(collectComposerInlineTokens("Limit is $1e6 here")).toEqual([]);
63 });
64
65 it("does not convert incomplete trailing tokens", () => {
66 expect(collectComposerInlineTokens("Use $ui")).toEqual([]);
67 expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]);
68 });
69
70 it("keeps the delimiter after a token outside its source range", () => {
71 const text = "Inspect [package.json](package.json) next";
72
73 expect(collectComposerInlineTokens(text)).toEqual([
74 {
75 type: "mention",
76 value: "package.json",
77 source: "[package.json](package.json)",
78 start: 8,
79 end: 36,
80 },
81 ]);
82 expect(text.slice(36)).toBe(" next");
83 });
84
85 it("preserves a confirmed pill when only its trailing delimiter is removed", () => {
86 const withDelimiter = "[package.json](package.json) ";
87 const confirmed = collectComposerInlineTokens(withDelimiter);
88
89 expect(
90 collectComposerInlineTokens(withDelimiter.trimEnd(), { preserveTrailingFrom: confirmed }),
91 ).toEqual([
92 {
93 type: "mention",
94 value: "package.json",
95 source: "[package.json](package.json)",
96 start: 0,
97 end: 28,
98 },
99 ]);
100 });
101
102 it("does not preserve a pill after its source is edited", () => {
103 const confirmed = collectComposerInlineTokens("[package.json](package.json) ");
104
105 expect(
106 collectComposerInlineTokens("[package.json](package-json)", {
107 preserveTrailingFrom: confirmed,
108 }),
109 ).toEqual([]);
110 });
111
112 it("ignores normal web links", () => {
113 expect(collectComposerInlineTokens("Read [docs](https://example.com) first")).toEqual([]);
114 });
115
116 it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])(
117 "keeps scoped package reference %s as plain text",
118 (reference) => {
119 expect(collectComposerInlineTokens(`Install ${reference} next`)).toEqual([]);
120 },
121 );
122
123 it("keeps scoped package references plain across incomplete input and IME whitespace", () => {
124 expect(collectComposerInlineTokens("Install @expo/ui")).toEqual([]);
125 expect(collectComposerInlineTokens("入力 @expo/ui を追加")).toEqual([]);
126 });
127
128 it("keeps bare non-scoped file paths as mentions", () => {
129 expect(collectComposerInlineTokens("Inspect @README.md next")).toEqual([
130 {
131 type: "mention",
132 value: "README.md",
133 source: "@README.md",
134 start: 8,
135 end: 18,
136 },
137 ]);
138 });
139
140 it("keeps canonical file links for scoped paths as mentions", () => {
141 expect(collectComposerInlineTokens("Inspect [sub](@scope/pkg/sub) next")).toEqual([
142 {
143 type: "mention",
144 value: "@scope/pkg/sub",
145 source: "[sub](@scope/pkg/sub)",
146 start: 8,
147 end: 29,
148 },
149 ]);
150 });
151
152 it("allows ambiguous scoped paths through explicit quoted mentions", () => {
153 expect(collectComposerInlineTokens('Inspect @"expo/ui" next')).toEqual([
154 {
155 type: "mention",
156 value: "expo/ui",
157 source: '@"expo/ui"',
158 start: 8,
159 end: 18,
160 },
161 ]);
162 });
163
164 it("still collects a file link whose label is at the length cap", () => {
165 const label = `${"a".repeat(508)}.tsx`;
166 const tokens = collectComposerInlineTokens(`see [${label}](src/${label}) ok`);
167
168 expect(tokens).toHaveLength(1);
169 expect(tokens[0]?.value).toBe(`src/${label}`);
170 });
171
172 it("leaves a file link past the label cap as plain text", () => {
173 const label = `${"a".repeat(509)}.tsx`;
174 expect(collectComposerInlineTokens(`see [${label}](src/${label}) ok`)).toEqual([]);
175 });
176
177 it("leaves a context reference link alone", () => {
178 expect(
179 collectComposerInlineTokens("see [checkout.png](t3-context://v1/image/ctx_abc) ok"),
180 ).toEqual([]);
181 expect(collectComposerInlineTokens("see ![ctx_abc](t3-context://v1/image/ctx_abc) ok")).toEqual(
182 [],
183 );
184 });
185
186 it("stays fast on unterminated bracket runs", () => {
187 // Unbounded, the label body rescanned the rest of the text from every
188 // whitespace: this input took seconds.
189 const started = performance.now();
190 expect(collectComposerInlineTokens(" [[".repeat(40_000))).toEqual([]);
191 expect(performance.now() - started).toBeLessThan(1_000);
192 });
193});