@pi-archimedes/core 2.0.0 → 2.1.0
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.
- package/README.md +51 -0
- package/package.json +1 -1
- package/src/config.test.ts +86 -0
- package/src/settings-io.test.ts +103 -0
- package/src/startup/logo.test.ts +260 -0
- package/src/startup/sections.test.ts +314 -0
- package/src/startup/version.test.ts +98 -0
- package/src/thinking/patch.test.ts +227 -0
- package/src/thinking/theme.test.ts +216 -0
- package/src/thinking/transform.test.ts +89 -0
- package/src/thinking/unindent.test.ts +74 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ── Mocks ────────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
// Force TRUECOLOR=false so formatColumns output is deterministic across envs
|
|
6
|
+
vi.mock("./logo.js", async (importOriginal) => ({ ...(await importOriginal()), TRUECOLOR: false }));
|
|
7
|
+
|
|
8
|
+
// Mock pi-tui — visibleWidth and truncateToWidth
|
|
9
|
+
vi.mock("@earendil-works/pi-tui", () => ({
|
|
10
|
+
visibleWidth: (s: string) => s.length,
|
|
11
|
+
truncateToWidth: (s: string, w: number) => s.slice(0, w),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// ── Imports ──────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
detectSection,
|
|
18
|
+
parseSectionText,
|
|
19
|
+
parseModelScope,
|
|
20
|
+
extractName,
|
|
21
|
+
formatColumns,
|
|
22
|
+
buildItemWrapper,
|
|
23
|
+
SECTION_KEYS,
|
|
24
|
+
RAMP_FRAMES,
|
|
25
|
+
} from "./sections.js";
|
|
26
|
+
|
|
27
|
+
// ── detectSection ────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe("detectSection", () => {
|
|
30
|
+
it("finds Models section key", () => {
|
|
31
|
+
expect(detectSection("[Models]\nclaude-sonnet")).toBe("Models");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("finds Context section key", () => {
|
|
35
|
+
expect(detectSection("some text [Context] more")).toBe("Context");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("finds Prompts section key", () => {
|
|
39
|
+
expect(detectSection("[Prompts]")).toBe("Prompts");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("finds Skills section key", () => {
|
|
43
|
+
expect(detectSection("Skills: [Skills] list")).toBe("Skills");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("finds Extensions section key", () => {
|
|
47
|
+
expect(detectSection("[Extensions]\nfoo")).toBe("Extensions");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("finds Themes section key", () => {
|
|
51
|
+
expect(detectSection("[Themes]\ndark")).toBe("Themes");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns undefined for non-section text", () => {
|
|
55
|
+
expect(detectSection("just some random text")).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns undefined for empty string", () => {
|
|
59
|
+
expect(detectSection("")).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns undefined for bracketed text that is not a section key", () => {
|
|
63
|
+
expect(detectSection("[NotASection]")).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns the first matching section key", () => {
|
|
67
|
+
const text = "[Models]\n[Context]";
|
|
68
|
+
expect(detectSection(text)).toBe("Models");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ── parseSectionText ─────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
describe("parseSectionText", () => {
|
|
75
|
+
it("extracts items correctly", () => {
|
|
76
|
+
const text = "[Models]\nclaude-sonnet\ngpt-4";
|
|
77
|
+
const result = parseSectionText(text);
|
|
78
|
+
expect(result).not.toBeUndefined();
|
|
79
|
+
expect(result!.name).toBe("Models");
|
|
80
|
+
expect(result!.items).toContain("claude-sonnet");
|
|
81
|
+
expect(result!.items).toContain("gpt-4");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("returns undefined for non-section text", () => {
|
|
85
|
+
expect(parseSectionText("no section here")).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("deduplicates items", () => {
|
|
89
|
+
const text = "[Models]\nclaude-sonnet\nclaude-sonnet\ngpt-4";
|
|
90
|
+
const result = parseSectionText(text);
|
|
91
|
+
expect(result).not.toBeUndefined();
|
|
92
|
+
const sonnetCount = result!.items.filter(i => i === "claude-sonnet").length;
|
|
93
|
+
expect(sonnetCount).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("prefers prefixed over bare names", () => {
|
|
97
|
+
const text = "[Extensions]\nnpm:@foo/bar\n@foo/bar";
|
|
98
|
+
const result = parseSectionText(text);
|
|
99
|
+
expect(result).not.toBeUndefined();
|
|
100
|
+
// Should prefer the prefixed version
|
|
101
|
+
expect(result!.items.some(i => i.startsWith("npm:"))).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("skips empty lines and bracket lines", () => {
|
|
105
|
+
const text = "[Themes]\n\ndark\n[Other]\nlight";
|
|
106
|
+
const result = parseSectionText(text);
|
|
107
|
+
expect(result).not.toBeUndefined();
|
|
108
|
+
expect(result!.items).toContain("dark");
|
|
109
|
+
// "light" is under [Other] which isn't a recognized section,
|
|
110
|
+
// but since we detected [Themes] first, items under [Other] should be skipped
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("handles empty section (no items)", () => {
|
|
114
|
+
const text = "[Models]";
|
|
115
|
+
const result = parseSectionText(text);
|
|
116
|
+
expect(result).not.toBeUndefined();
|
|
117
|
+
expect(result!.items.length).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ── parseModelScope ──────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe("parseModelScope", () => {
|
|
124
|
+
it("extracts model names from 'Model scope:' line", () => {
|
|
125
|
+
const text = "Model scope: claude-sonnet, gpt-4";
|
|
126
|
+
const result = parseModelScope(text);
|
|
127
|
+
expect(result).not.toBeUndefined();
|
|
128
|
+
expect(result!.name).toBe("Models");
|
|
129
|
+
expect(result!.items).toContain("claude-sonnet");
|
|
130
|
+
expect(result!.items).toContain("gpt-4");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("strips keyboard shortcut hints", () => {
|
|
134
|
+
const text = "Model scope: claude-sonnet (Ctrl+1), gpt-4 (Ctrl+2)";
|
|
135
|
+
const result = parseModelScope(text);
|
|
136
|
+
expect(result).not.toBeUndefined();
|
|
137
|
+
expect(result!.items).toContain("claude-sonnet");
|
|
138
|
+
expect(result!.items).not.toContain("(Ctrl+1)");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns undefined when no Model scope line", () => {
|
|
142
|
+
expect(parseModelScope("some random text")).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns undefined for empty items", () => {
|
|
146
|
+
expect(parseModelScope("Model scope: ")).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("handles single model", () => {
|
|
150
|
+
const result = parseModelScope("Model scope: only-one");
|
|
151
|
+
expect(result).not.toBeUndefined();
|
|
152
|
+
expect(result!.items).toEqual(["only-one"]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── extractName ──────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe("extractName", () => {
|
|
159
|
+
it("extracts Models name from path", () => {
|
|
160
|
+
expect(extractName("/path/to/claude-sonnet", "Models")).toBe("claude-sonnet");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("extracts Themes name from path", () => {
|
|
164
|
+
expect(extractName("/path/to/dark-theme", "Themes")).toBe("dark-theme");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("extracts Prompts name (strips extension)", () => {
|
|
168
|
+
expect(extractName("/path/to/my-prompt.ts", "Prompts")).toBe("my-prompt");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("extracts Context name (basename only)", () => {
|
|
172
|
+
expect(extractName("/path/to/context-file.md", "Context")).toBe("context-file.md");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("extracts Skills name from SKILL.md path", () => {
|
|
176
|
+
expect(extractName("/path/to/my-skill/SKILL.md", "Skills")).toBe("my-skill");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("extracts Skills name from SKILL.ts path", () => {
|
|
180
|
+
expect(extractName("/path/to/my-skill/SKILL.ts", "Skills")).toBe("my-skill");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("handles npm: prefix for Extensions", () => {
|
|
184
|
+
expect(extractName("npm:@foo/bar", "Extensions")).toBe("npm:bar");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("handles git: prefix for Extensions", () => {
|
|
188
|
+
expect(extractName("git:github.com/user/repo", "Extensions")).toBe("git:repo");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("strips file extensions for Models", () => {
|
|
192
|
+
expect(extractName("/path/to/model.ts", "Models")).toBe("model");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("handles simple name without path", () => {
|
|
196
|
+
expect(extractName("simple-name", "Models")).toBe("simple-name");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ── formatColumns ────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
describe("formatColumns", () => {
|
|
203
|
+
const mockTheme = {
|
|
204
|
+
fg: (token: string, text: string) => text,
|
|
205
|
+
getFgAnsi: (token: string) => "",
|
|
206
|
+
} as any;
|
|
207
|
+
|
|
208
|
+
const mockRef = {
|
|
209
|
+
frame: 100,
|
|
210
|
+
revealed: true,
|
|
211
|
+
revealedAt: 0,
|
|
212
|
+
scaffoldAt: 0,
|
|
213
|
+
settled: true,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
it("returns empty array for empty sections", () => {
|
|
217
|
+
expect(formatColumns([], mockTheme, 80, mockRef)).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("returns empty array for sections with no items", () => {
|
|
221
|
+
const sections = [{ name: "Models" as const, items: [] }];
|
|
222
|
+
expect(formatColumns(sections, mockTheme, 80, mockRef)).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("formats a single section with items", () => {
|
|
226
|
+
const sections = [{ name: "Models" as const, items: ["claude-sonnet", "gpt-4"] }];
|
|
227
|
+
const result = formatColumns(sections, mockTheme, 80, mockRef);
|
|
228
|
+
expect(result.length).toBeGreaterThan(0);
|
|
229
|
+
// Should contain the section header
|
|
230
|
+
expect(result.some(line => line.includes("[Models]"))).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("formats multiple sections", () => {
|
|
234
|
+
const sections = [
|
|
235
|
+
{ name: "Models" as const, items: ["claude"] },
|
|
236
|
+
{ name: "Themes" as const, items: ["dark"] },
|
|
237
|
+
];
|
|
238
|
+
const result = formatColumns(sections, mockTheme, 80, mockRef);
|
|
239
|
+
expect(result.some(line => line.includes("[Models]"))).toBe(true);
|
|
240
|
+
expect(result.some(line => line.includes("[Themes]"))).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("adds blank line after Version section", () => {
|
|
244
|
+
const sections = [{ name: "Version" as const, items: ["1.0.0"] }];
|
|
245
|
+
const result = formatColumns(sections, mockTheme, 80, mockRef);
|
|
246
|
+
expect(result).toContain("");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("wraps items to new lines when exceeding width", () => {
|
|
250
|
+
const longItems = Array.from({ length: 20 }, (_, i) => `model-${i}`);
|
|
251
|
+
const sections = [{ name: "Models" as const, items: longItems }];
|
|
252
|
+
const result = formatColumns(sections, mockTheme, 40, mockRef);
|
|
253
|
+
// With narrow width, items should wrap to multiple lines
|
|
254
|
+
expect(result.length).toBeGreaterThan(1);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("handles unrevealed state", () => {
|
|
258
|
+
const sections = [{ name: "Models" as const, items: ["test"] }];
|
|
259
|
+
const unrevealedRef = { ...mockRef, revealed: false };
|
|
260
|
+
const result = formatColumns(sections, mockTheme, 80, unrevealedRef);
|
|
261
|
+
expect(result.length).toBeGreaterThan(0);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ── buildItemWrapper ─────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
describe("buildItemWrapper", () => {
|
|
268
|
+
const muted = (t: string) => `\x1b[90m${t}\x1b[0m`;
|
|
269
|
+
|
|
270
|
+
it("returns identity function when not revealed", () => {
|
|
271
|
+
const wrapper = buildItemWrapper(0, false, undefined, undefined, muted);
|
|
272
|
+
expect(wrapper("hello")).toBe("hello");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("returns muted function when no RGB data", () => {
|
|
276
|
+
const wrapper = buildItemWrapper(10, true, undefined, undefined, muted);
|
|
277
|
+
expect(wrapper("hello")).toBe(muted("hello"));
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("returns muted function when ramp is complete", () => {
|
|
281
|
+
const startRgb = [20, 20, 20] as [number, number, number];
|
|
282
|
+
const mutedRgb = [100, 100, 100] as [number, number, number];
|
|
283
|
+
const wrapper = buildItemWrapper(RAMP_FRAMES, true, startRgb, mutedRgb, muted);
|
|
284
|
+
expect(wrapper("hello")).toBe(muted("hello"));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("returns rgb-colored function during ramp", () => {
|
|
288
|
+
const startRgb = [20, 20, 20] as [number, number, number];
|
|
289
|
+
const mutedRgb = [200, 200, 200] as [number, number, number];
|
|
290
|
+
const wrapper = buildItemWrapper(1, true, startRgb, mutedRgb, muted);
|
|
291
|
+
const result = wrapper("hello");
|
|
292
|
+
// Should be a truecolor ANSI escape, not the muted fallback
|
|
293
|
+
expect(result).toMatch(/\x1b\[38;2;\d+;\d+;\d+mhello\x1b\[0m/);
|
|
294
|
+
expect(result).not.toContain("\x1b[90m");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("lerps colors correctly at midpoint", () => {
|
|
298
|
+
const startRgb = [0, 0, 0] as [number, number, number];
|
|
299
|
+
const endRgb = [200, 200, 200] as [number, number, number];
|
|
300
|
+
// At exactly RAMP_FRAMES / 2, t = 0.5, eased = 0.75
|
|
301
|
+
const midAge = Math.floor(RAMP_FRAMES / 2);
|
|
302
|
+
const wrapper = buildItemWrapper(midAge, true, startRgb, endRgb, muted);
|
|
303
|
+
const result = wrapper("x");
|
|
304
|
+
// eased = 1 - (1-0.5)^2 = 0.75
|
|
305
|
+
// lerp(0, 200, 0.75) = 150
|
|
306
|
+
expect(result).toContain("\x1b[38;2;150;150;150mx\x1b[0m");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("returns muted when mutedRgb is undefined", () => {
|
|
310
|
+
const startRgb = [20, 20, 20] as [number, number, number];
|
|
311
|
+
const wrapper = buildItemWrapper(5, true, startRgb, undefined, muted);
|
|
312
|
+
expect(wrapper("hello")).toBe(muted("hello"));
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import * as fc from "fast-check";
|
|
3
|
+
import { compareVersions } from "./version.js";
|
|
4
|
+
|
|
5
|
+
// ── Version generator for property tests ─────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const versionArb = fc.tuple(fc.nat(100), fc.nat(100), fc.nat(100)).map(
|
|
8
|
+
([major, minor, patch]) => `${major}.${minor}.${patch}`,
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
// ── compareVersions ──────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe("compareVersions", () => {
|
|
14
|
+
it('returns 0 for equal versions', () => {
|
|
15
|
+
expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns 1 when first version is greater', () => {
|
|
19
|
+
expect(compareVersions("2.0.0", "1.9.9")).toBe(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns -1 when second version is greater', () => {
|
|
23
|
+
expect(compareVersions("1.0.0", "2.0.0")).toBe(-1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("handles v prefix on first argument", () => {
|
|
27
|
+
expect(compareVersions("v1.2.3", "1.2.3")).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("handles v prefix on second argument", () => {
|
|
31
|
+
expect(compareVersions("1.2.3", "v1.2.3")).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("handles v prefix on both arguments", () => {
|
|
35
|
+
expect(compareVersions("v2.0.0", "v1.0.0")).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("handles partial versions (2 parts vs 3 parts)", () => {
|
|
39
|
+
expect(compareVersions("1.0", "1.0.0")).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("handles partial versions with difference", () => {
|
|
43
|
+
expect(compareVersions("1.1", "1.0.5")).toBe(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("handles zero-padded versions", () => {
|
|
47
|
+
expect(compareVersions("01.02.03", "1.2.3")).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("compares minor version correctly", () => {
|
|
51
|
+
expect(compareVersions("1.2.0", "1.1.9")).toBe(1);
|
|
52
|
+
expect(compareVersions("1.1.0", "1.2.0")).toBe(-1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("compares patch version correctly", () => {
|
|
56
|
+
expect(compareVersions("1.0.5", "1.0.3")).toBe(1);
|
|
57
|
+
expect(compareVersions("1.0.3", "1.0.5")).toBe(-1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("handles all-zero versions", () => {
|
|
61
|
+
expect(compareVersions("0.0.0", "0.0.0")).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── Property tests ───────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
it("property: reflexive — compareVersions(a, a) === 0", () => {
|
|
67
|
+
fc.assert(
|
|
68
|
+
fc.property(versionArb, a => {
|
|
69
|
+
return compareVersions(a, a) === 0;
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("property: antisymmetric — compareVersions(a, b) === -compareVersions(b, a)", () => {
|
|
75
|
+
fc.assert(
|
|
76
|
+
fc.property(versionArb, versionArb, (a, b) => {
|
|
77
|
+
return compareVersions(a, b) === -compareVersions(b, a);
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("property: transitivity — if a<b and b<c then a<c", () => {
|
|
83
|
+
fc.assert(
|
|
84
|
+
fc.property(
|
|
85
|
+
fc.tuple(versionArb, versionArb, versionArb),
|
|
86
|
+
([a, b, c]) => {
|
|
87
|
+
const ab = compareVersions(a, b);
|
|
88
|
+
const bc = compareVersions(b, c);
|
|
89
|
+
const ac = compareVersions(a, c);
|
|
90
|
+
if (ab < 0 && bc < 0) {
|
|
91
|
+
return ac < 0;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
},
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Symbols used by patch.ts to mark patched prototypes
|
|
4
|
+
const PATCHED_KEY = Symbol.for("archimedes:thinkingPatched");
|
|
5
|
+
const PATCH_VERSION_KEY = Symbol.for("archimedes:thinkingPatchVersion");
|
|
6
|
+
|
|
7
|
+
// Dynamic import after mocking the pi-coding-agent module
|
|
8
|
+
async function importPatch() {
|
|
9
|
+
vi.resetModules();
|
|
10
|
+
const mod = await import("./patch.js");
|
|
11
|
+
return mod.patchThinkingRenderer;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("patchThinkingRenderer", () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns early when AssistantMessageComponent is undefined", async () => {
|
|
20
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
21
|
+
AssistantMessageComponent: undefined,
|
|
22
|
+
VERSION: "1.0.0",
|
|
23
|
+
highlightCode: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const patch = await importPatch();
|
|
27
|
+
// Should not throw
|
|
28
|
+
patch(() => ({} as any));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns early when prototype is null", async () => {
|
|
32
|
+
const MockClass = function AssistantMessageComponent() {};
|
|
33
|
+
MockClass.prototype = null;
|
|
34
|
+
|
|
35
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
36
|
+
AssistantMessageComponent: MockClass,
|
|
37
|
+
VERSION: "1.0.0",
|
|
38
|
+
highlightCode: vi.fn(),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const patch = await importPatch();
|
|
42
|
+
patch(() => ({} as any));
|
|
43
|
+
// Prototype must still be null — no patching occurred
|
|
44
|
+
expect(MockClass.prototype).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns early when updateContent is not a function", async () => {
|
|
48
|
+
const MockClass = function AssistantMessageComponent() {};
|
|
49
|
+
MockClass.prototype.updateContent = "not a function";
|
|
50
|
+
|
|
51
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
52
|
+
AssistantMessageComponent: MockClass,
|
|
53
|
+
VERSION: "1.0.0",
|
|
54
|
+
highlightCode: vi.fn(),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const patch = await importPatch();
|
|
58
|
+
patch(() => ({} as any));
|
|
59
|
+
// PATCHED_KEY must NOT be set — early return before patching
|
|
60
|
+
expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns early when class name does not match", async () => {
|
|
64
|
+
const WrongName = function () {};
|
|
65
|
+
WrongName.prototype.updateContent = function () {};
|
|
66
|
+
|
|
67
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
68
|
+
AssistantMessageComponent: WrongName,
|
|
69
|
+
VERSION: "1.0.0",
|
|
70
|
+
highlightCode: vi.fn(),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
const patch = await importPatch();
|
|
74
|
+
patch(() => ({} as any));
|
|
75
|
+
// PATCHED_KEY must NOT be set — name mismatch causes early return
|
|
76
|
+
expect(WrongName.prototype[PATCHED_KEY]).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns early when source lacks thinking check", async () => {
|
|
80
|
+
const MockClass = function AssistantMessageComponent() {};
|
|
81
|
+
MockClass.prototype.updateContent = function updateContent() {
|
|
82
|
+
// Does NOT contain: content.type === "thinking"
|
|
83
|
+
this.doSomething();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
87
|
+
AssistantMessageComponent: MockClass,
|
|
88
|
+
VERSION: "1.0.0",
|
|
89
|
+
highlightCode: vi.fn(),
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
const patch = await importPatch();
|
|
93
|
+
patch(() => ({} as any));
|
|
94
|
+
// PATCHED_KEY must NOT be set — signature mismatch causes early return
|
|
95
|
+
expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns early when source lacks markdownTheme reference", async () => {
|
|
99
|
+
const MockClass = function AssistantMessageComponent() {};
|
|
100
|
+
MockClass.prototype.updateContent = function updateContent() {
|
|
101
|
+
// Has thinking check but no markdownTheme
|
|
102
|
+
if (this.content.type === "thinking") {
|
|
103
|
+
this.render();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
108
|
+
AssistantMessageComponent: MockClass,
|
|
109
|
+
VERSION: "1.0.0",
|
|
110
|
+
highlightCode: vi.fn(),
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
const patch = await importPatch();
|
|
114
|
+
patch(() => ({} as any));
|
|
115
|
+
// PATCHED_KEY must NOT be set — signature mismatch causes early return
|
|
116
|
+
expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("patches successfully when signature matches", async () => {
|
|
120
|
+
const MockClass = function AssistantMessageComponent() {};
|
|
121
|
+
MockClass.prototype.updateContent = function updateContent() {
|
|
122
|
+
if (this.content.type === "thinking") {
|
|
123
|
+
this.markdownTheme.codeBlockIndent = "";
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
128
|
+
AssistantMessageComponent: MockClass,
|
|
129
|
+
VERSION: "1.0.0",
|
|
130
|
+
highlightCode: vi.fn(),
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
const patch = await importPatch();
|
|
134
|
+
patch(() => ({} as any));
|
|
135
|
+
|
|
136
|
+
// The prototype should be marked as patched
|
|
137
|
+
expect(MockClass.prototype[PATCHED_KEY]).toBe(true);
|
|
138
|
+
expect(MockClass.prototype[PATCH_VERSION_KEY]).toBe("1.0.0");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("marks prototype with correct version", async () => {
|
|
142
|
+
const MockClass = function AssistantMessageComponent() {};
|
|
143
|
+
MockClass.prototype.updateContent = function updateContent() {
|
|
144
|
+
if (this.content.type === "thinking") {
|
|
145
|
+
this.markdownTheme.codeBlockIndent = "";
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
150
|
+
AssistantMessageComponent: MockClass,
|
|
151
|
+
VERSION: "2.5.0",
|
|
152
|
+
highlightCode: vi.fn(),
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
const patch = await importPatch();
|
|
156
|
+
patch(() => ({} as any));
|
|
157
|
+
|
|
158
|
+
expect(MockClass.prototype[PATCH_VERSION_KEY]).toBe("2.5.0");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("re-patches when version changes", async () => {
|
|
162
|
+
// First patch with version 1.0.0
|
|
163
|
+
const MockClassV1 = function AssistantMessageComponent() {};
|
|
164
|
+
MockClassV1.prototype.updateContent = function updateContent() {
|
|
165
|
+
if (this.content.type === "thinking") {
|
|
166
|
+
this.markdownTheme.codeBlockIndent = "";
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
171
|
+
AssistantMessageComponent: MockClassV1,
|
|
172
|
+
VERSION: "1.0.0",
|
|
173
|
+
highlightCode: vi.fn(),
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
const patchV1 = await importPatch();
|
|
177
|
+
patchV1(() => ({} as any));
|
|
178
|
+
expect(MockClassV1.prototype[PATCH_VERSION_KEY]).toBe("1.0.0");
|
|
179
|
+
|
|
180
|
+
// Now simulate a version change with a fresh class
|
|
181
|
+
const MockClassV2 = function AssistantMessageComponent() {};
|
|
182
|
+
MockClassV2.prototype.updateContent = function updateContent() {
|
|
183
|
+
if (this.content.type === "thinking") {
|
|
184
|
+
this.markdownTheme.codeBlockIndent = "";
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
189
|
+
AssistantMessageComponent: MockClassV2,
|
|
190
|
+
VERSION: "2.0.0",
|
|
191
|
+
highlightCode: vi.fn(),
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
const patchV2 = await importPatch();
|
|
195
|
+
patchV2(() => ({} as any));
|
|
196
|
+
expect(MockClassV2.prototype[PATCH_VERSION_KEY]).toBe("2.0.0");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("re-patches on same version to update getTheme closure", async () => {
|
|
200
|
+
const MockClass = function AssistantMessageComponent() {};
|
|
201
|
+
MockClass.prototype.updateContent = function updateContent() {
|
|
202
|
+
if (this.content.type === "thinking") {
|
|
203
|
+
this.markdownTheme.codeBlockIndent = "";
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
vi.doMock("@earendil-works/pi-coding-agent", () => ({
|
|
208
|
+
AssistantMessageComponent: MockClass,
|
|
209
|
+
VERSION: "1.0.0",
|
|
210
|
+
highlightCode: vi.fn(),
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
const patch = await importPatch();
|
|
214
|
+
|
|
215
|
+
// First patch
|
|
216
|
+
patch(() => ({} as any));
|
|
217
|
+
expect(MockClass.prototype[PATCHED_KEY]).toBe(true);
|
|
218
|
+
const first = MockClass.prototype.updateContent;
|
|
219
|
+
|
|
220
|
+
// Second patch (same version) — must produce a new function
|
|
221
|
+
patch(() => ({} as any));
|
|
222
|
+
expect(MockClass.prototype[PATCHED_KEY]).toBe(true);
|
|
223
|
+
expect(MockClass.prototype[PATCH_VERSION_KEY]).toBe("1.0.0");
|
|
224
|
+
// The patched function must be a new closure (not the same reference)
|
|
225
|
+
expect(MockClass.prototype.updateContent).not.toBe(first);
|
|
226
|
+
});
|
|
227
|
+
});
|