Skip to content
Source
Sign inSign up

composerInlineTokens.test.ts

222 lines7.2 KB

1import { describe, expect, it } from "vite-plus/test";
2​
3import { collectComposerInlineTokens, composerMathRanges } from "./composerInlineTokens.ts";
4import { serializeComposerFileLink } from "./composerTrigger.ts";
5​
6describe("collectComposerInlineTokens", () => {
7 it("collects file links, mentions, and skills with source ranges", () => {
8 const text = "Use $ui and inspect [Chat.tsx](src/Chat.tsx) with @AGENTS.md please";
9​
10 expect(collectComposerInlineTokens(text)).toEqual([
11 {
12 type: "skill",
13 value: "ui",
14 source: "$ui",
15 start: 4,
16 end: 7,
17 },
18 {
19 type: "mention",
20 value: "src/Chat.tsx",
21 source: "[Chat.tsx](src/Chat.tsx)",
22 start: 20,
23 end: 44,
24 },
25 {
26 type: "mention",
27 value: "AGENTS.md",
28 source: "@AGENTS.md",
29 start: 50,
30 end: 60,
31 },
32 ]);
33 });
34​
35 it.each(["$", "€", "𑿝"])("collects %s skill names that begin with a digit", (prefix) => {
36 expect(collectComposerInlineTokens(`Use ${prefix}2spec next`)).toEqual([
37 {
38 type: "skill",
39 value: "2spec",
40 source: `${prefix}2spec`,
41 start: 4,
42 end: 9 + prefix.length,
43 },
44 ]);
45 });
46​
47 it("leaves digits-only dollar amounts and compact monetary expressions as text", () => {
48 expect(collectComposerInlineTokens("I'll pay $20 tomorrow")).toEqual([]);
49 expect(collectComposerInlineTokens("Budget is $1_000 total")).toEqual([]);
50 expect(collectComposerInlineTokens("Budget is $20k tomorrow")).toEqual([]);
51 expect(collectComposerInlineTokens("Cost is $100M total")).toEqual([]);
52 expect(collectComposerInlineTokens("Limit is $1e6 here")).toEqual([]);
53 });
54​
55 it("does not convert incomplete trailing tokens", () => {
56 expect(collectComposerInlineTokens("Use $ui")).toEqual([]);
57 expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]);
58 });
59​
60 it("preserves a confirmed pill when only its trailing delimiter is removed", () => {
61 const withDelimiter = "[package.json](package.json) ";
62 const confirmed = collectComposerInlineTokens(withDelimiter);
63​
64 expect(
65 collectComposerInlineTokens(withDelimiter.trimEnd(), { preserveTrailingFrom: confirmed }),
66 ).toEqual([
67 {
68 type: "mention",
69 value: "package.json",
70 source: "[package.json](package.json)",
71 start: 0,
72 end: 28,
73 },
74 ]);
75 });
76​
77 it("does not preserve a pill after its source is edited", () => {
78 const confirmed = collectComposerInlineTokens("[package.json](package.json) ");
79​
80 expect(
81 collectComposerInlineTokens("[package.json](package-json)", {
82 preserveTrailingFrom: confirmed,
83 }),
84 ).toEqual([]);
85 });
86​
87 it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])(
88 "keeps scoped package reference %s as plain text",
89 (reference) => {
90 expect(collectComposerInlineTokens(`Install ${reference} next`)).toEqual([]);
91 },
92 );
93​
94 it.each(["@namespace.foo(bar)", "@écrire(option)", "@e\u0301crire(option)"])(
95 "keeps decorator call %s as plain text",
96 (reference) => {
97 expect(collectComposerInlineTokens(`${reference}\nclass Example {}`)).toEqual([]);
98 },
99 );
100​
101 it.each(['@"foo(bar)"', "[foo(bar)](foo%28bar%29)"])(
102 "keeps an explicit decorator-shaped file reference as a mention: %s",
103 (reference) => {
104 expect(collectComposerInlineTokens(`${reference} `)).toEqual([
105 {
106 type: "mention",
107 value: "foo(bar)",
108 source: reference,
109 start: 0,
110 end: reference.length,
111 },
112 ]);
113 },
114 );
115​
116 it("keeps bare file mentions before whitespace-separated parentheses", () => {
117 expect(collectComposerInlineTokens("Inspect @README.md (notes)")).toEqual([
118 {
119 type: "mention",
120 value: "README.md",
121 source: "@README.md",
122 start: 8,
123 end: 18,
124 },
125 ]);
126 });
127​
128 it("keeps canonical file links for scoped paths as mentions", () => {
129 expect(collectComposerInlineTokens("Inspect [sub](@scope/pkg/sub) next")).toEqual([
130 {
131 type: "mention",
132 value: "@scope/pkg/sub",
133 source: "[sub](@scope/pkg/sub)",
134 start: 8,
135 end: 29,
136 },
137 ]);
138 });
139​
140 it("allows ambiguous scoped paths through explicit quoted mentions", () => {
141 expect(collectComposerInlineTokens('Inspect @"expo/ui" next')).toEqual([
142 {
143 type: "mention",
144 value: "expo/ui",
145 source: '@"expo/ui"',
146 start: 8,
147 end: 18,
148 },
149 ]);
150 });
151​
152 it("still collects a file link whose label is at the length cap", () => {
153 const label = `${"a".repeat(508)}.tsx`;
154 const tokens = collectComposerInlineTokens(`see [${label}](src/${label}) ok`);
155​
156 expect(tokens).toHaveLength(1);
157 expect(tokens[0]?.value).toBe(`src/${label}`);
158 });
159​
160 it("leaves a file link past the label cap as plain text", () => {
161 const label = `${"a".repeat(509)}.tsx`;
162 expect(collectComposerInlineTokens(`see [${label}](src/${label}) ok`)).toEqual([]);
163 });
164​
165 it("leaves a context reference link alone", () => {
166 expect(
167 collectComposerInlineTokens("see [checkout.png](t3-context://v1/image/ctx_abc) ok"),
168 ).toEqual([]);
169 expect(collectComposerInlineTokens("see ![ctx_abc](t3-context://v1/image/ctx_abc) ok")).toEqual(
170 [],
171 );
172 });
173​
174 it("keeps skill and mention sources inside TeX out of the context chip list", () => {
175 const source = "\\[\\text{use $browser and @README.md here}\\] then $real-skill ";
176 expect(collectComposerInlineTokens(source).map((token) => token.source)).toEqual([
177 "$real-skill",
178 ]);
179 });
180​
181 it.each([
182 "app/routes/posts.$postId.tsx",
183 "src/routes/_chat.$environmentId.$threadId.tsx",
184 "pages/posts/[id].tsx",
185 "app/blog/[...slug].tsx",
186 ])("keeps a file link whose name looks like TeX as one mention: %s", (path) => {
187 const link = serializeComposerFileLink(path);
188 expect(collectComposerInlineTokens(`fix ${link} please`)).toMatchObject([
189 { type: "mention", value: path, source: link },
190 ]);
191 });
192​
193 it.each(["app/routes/posts.$postId.tsx", "pages/posts/[id].tsx"])(
194 "preserves a confirmed TeX-like pill when its trailing delimiter is removed: %s",
195 (path) => {
196 const link = serializeComposerFileLink(path);
197 const confirmed = collectComposerInlineTokens(`fix ${link} `);
198​
199 expect(confirmed).toMatchObject([{ type: "mention", value: path, source: link }]);
200 expect(
201 collectComposerInlineTokens(`fix ${link}`, { preserveTrailingFrom: confirmed }),
202 ).toEqual(confirmed);
203 expect(collectComposerInlineTokens(`fix ${link}`)).toEqual([]);
204 },
205 );
206​
207 it.each([
208 "before $x + @README.md$ after",
209 "before \\(x + @README.md\\) after",
210 "\\[x + @README.md\\] after",
211 "\\[\\text{use @README.md}\\] then go ",
212 ])("keeps a mention that ends a formula inside the formula: %s", (source) => {
213 expect(collectComposerInlineTokens(source)).toEqual([]);
214 });
215​
216 it("still finds a formula after a mention that starts the line", () => {
217 const link = serializeComposerFileLink("app/routes/posts.$postId.tsx");
218 expect(composerMathRanges(`${link} sets $x_i$`).map((formula) => formula.source)).toEqual([
219 "$x_i$",
220 ]);
221 });
222});