@pi-archimedes/core 2.0.1 → 2.2.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.
@@ -0,0 +1,260 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as fc from "fast-check";
3
+ import { stripAnsi } from "../text.js";
4
+
5
+ // ── Helpers for dynamic imports with controlled TRUECOLOR ────────────────────
6
+
7
+ async function importLogoWithTruecolor(truecolor: boolean) {
8
+ vi.resetModules();
9
+
10
+ // Set env before the module loads
11
+ const origColorterm = process.env.COLORTERM;
12
+ const origTerm = process.env.TERM;
13
+ const origTermProgram = process.env.TERM_PROGRAM;
14
+ const origWtSession = process.env.WT_SESSION;
15
+
16
+ if (truecolor) {
17
+ process.env.COLORTERM = "truecolor";
18
+ } else {
19
+ delete process.env.COLORTERM;
20
+ process.env.TERM = "xterm";
21
+ delete process.env.TERM_PROGRAM;
22
+ delete process.env.WT_SESSION;
23
+ }
24
+
25
+ const mod = await import("./logo.js");
26
+
27
+ // Restore env
28
+ if (origColorterm === undefined) delete process.env.COLORTERM;
29
+ else process.env.COLORTERM = origColorterm;
30
+ if (origTerm === undefined) delete process.env.TERM;
31
+ else process.env.TERM = origTerm;
32
+ if (origTermProgram === undefined) delete process.env.TERM_PROGRAM;
33
+ else process.env.TERM_PROGRAM = origTermProgram;
34
+ if (origWtSession === undefined) delete process.env.WT_SESSION;
35
+ else process.env.WT_SESSION = origWtSession;
36
+
37
+ return mod;
38
+ }
39
+
40
+ // ── LOGO structure (static, no env dependency) ──────────────────────────────
41
+
42
+ describe("LOGO structure", () => {
43
+ it("has 8 rows", async () => {
44
+ const mod = await importLogoWithTruecolor(false);
45
+ expect(mod.LOGO.length).toBe(8);
46
+ });
47
+
48
+ it("each row has 16 characters", async () => {
49
+ const mod = await importLogoWithTruecolor(false);
50
+ for (const row of mod.LOGO) {
51
+ expect(row.length).toBe(16);
52
+ }
53
+ });
54
+
55
+ it("first row is 12 blocks + 4 spaces", async () => {
56
+ const mod = await importLogoWithTruecolor(false);
57
+ expect(mod.LOGO[0]).toBe("████████████ ");
58
+ });
59
+
60
+ it("last row is 4 blocks + 6 spaces + 4 blocks", async () => {
61
+ const mod = await importLogoWithTruecolor(false);
62
+ expect(mod.LOGO[7]).toBe("████ ████");
63
+ });
64
+
65
+ it("LOGO contains only block chars and spaces", async () => {
66
+ const mod = await importLogoWithTruecolor(false);
67
+ for (const row of mod.LOGO) {
68
+ for (const ch of row) {
69
+ expect(ch === " " || ch === "█").toBe(true);
70
+ }
71
+ }
72
+ });
73
+ });
74
+
75
+ // ── Animation constants ──────────────────────────────────────────────────────
76
+
77
+ describe("animation constants", () => {
78
+ it("CHAR_FADE_FRAMES is 22", async () => {
79
+ const mod = await importLogoWithTruecolor(false);
80
+ expect(mod.CHAR_FADE_FRAMES).toBe(22);
81
+ });
82
+
83
+ it("LOGO_SETTLE_FRAME is 90", async () => {
84
+ const mod = await importLogoWithTruecolor(false);
85
+ expect(mod.LOGO_SETTLE_FRAME).toBe(90);
86
+ });
87
+
88
+ it("LOGO_PAD is 0", async () => {
89
+ const mod = await importLogoWithTruecolor(false);
90
+ expect(mod.LOGO_PAD).toBe(0);
91
+ });
92
+
93
+ it("LOGO_GAP is 4", async () => {
94
+ const mod = await importLogoWithTruecolor(false);
95
+ expect(mod.LOGO_GAP).toBe(4);
96
+ });
97
+ });
98
+
99
+ // ── TRUECOLOR detection ──────────────────────────────────────────────────────
100
+
101
+ describe("TRUECOLOR", () => {
102
+ it("is true when COLORTERM contains truecolor", async () => {
103
+ const mod = await importLogoWithTruecolor(true);
104
+ expect(mod.TRUECOLOR).toBe(true);
105
+ });
106
+
107
+ it("is false when no truecolor env vars set", async () => {
108
+ const mod = await importLogoWithTruecolor(false);
109
+ expect(mod.TRUECOLOR).toBe(false);
110
+ });
111
+ });
112
+
113
+ // ── getShinedLogo — non-truecolor ────────────────────────────────────────────
114
+
115
+ describe("getShinedLogo (non-truecolor)", () => {
116
+ let mod: typeof import("./logo.js");
117
+
118
+ beforeEach(async () => {
119
+ mod = await importLogoWithTruecolor(false);
120
+ });
121
+
122
+ it("returns LOGO unchanged when TRUECOLOR is false", () => {
123
+ const result = mod.getShinedLogo(0, "wave");
124
+ expect(result).toBe(mod.LOGO);
125
+ });
126
+
127
+ it("returns LOGO at any frame when TRUECOLOR is false", () => {
128
+ expect(mod.getShinedLogo(999, "wave")).toBe(mod.LOGO);
129
+ });
130
+
131
+ it("returns 8 rows regardless of frame", () => {
132
+ expect(mod.getShinedLogo(50, "diagonal").length).toBe(8);
133
+ });
134
+ });
135
+
136
+ // ── getShinedLogo — truecolor ────────────────────────────────────────────────
137
+
138
+ describe("getShinedLogo (truecolor)", () => {
139
+ let mod: typeof import("./logo.js");
140
+
141
+ beforeEach(async () => {
142
+ mod = await importLogoWithTruecolor(true);
143
+ });
144
+
145
+ it("returns 8 rows", () => {
146
+ expect(mod.getShinedLogo(0, "wave").length).toBe(8);
147
+ });
148
+
149
+ it("returns different output than LOGO when TRUECOLOR is true", () => {
150
+ const result = mod.getShinedLogo(50, "wave");
151
+ expect(result).not.toBe(mod.LOGO);
152
+ });
153
+
154
+ it("early frames show spaces for not-yet-revealed chars", () => {
155
+ const result = mod.getShinedLogo(0, "vertical");
156
+ // At frame 0, nothing should be revealed yet
157
+ for (const row of result) {
158
+ const stripped = stripAnsi(row);
159
+ expect(stripped).toMatch(/^[\s]*$/);
160
+ }
161
+ });
162
+
163
+ it("late frames show all characters revealed", () => {
164
+ const result = mod.getShinedLogo(200, "vertical");
165
+ for (let i = 0; i < result.length; i++) {
166
+ const stripped = stripAnsi(result[i]!);
167
+ const expected = stripAnsi(mod.LOGO[i]!);
168
+ expect(stripped).toBe(expected);
169
+ }
170
+ });
171
+
172
+ it("output contains ANSI gray escapes", () => {
173
+ const result = mod.getShinedLogo(50, "wave");
174
+ const joined = result.join("\n");
175
+ expect(joined).toMatch(/\x1b\[38;2;\d+;\d+;\d+m/);
176
+ });
177
+
178
+ it("default style is wave", () => {
179
+ const result1 = mod.getShinedLogo(50);
180
+ const result2 = mod.getShinedLogo(50, "wave");
181
+ expect(result1).toEqual(result2);
182
+ });
183
+ });
184
+
185
+ // ── All animation styles ─────────────────────────────────────────────────────
186
+
187
+ describe("animation styles", () => {
188
+ const styles = [
189
+ "diagonal",
190
+ "top-right",
191
+ "bottom-left",
192
+ "bottom-right",
193
+ "center-out",
194
+ "wave",
195
+ "horizontal",
196
+ "vertical",
197
+ "vertical-up",
198
+ ] as const;
199
+
200
+ it.each(styles)("style '%s' produces valid reveal times", async (style) => {
201
+ const mod = await importLogoWithTruecolor(true);
202
+ const result = mod.getShinedLogo(100, style);
203
+ expect(result.length).toBe(8);
204
+ // Each row should be a string
205
+ for (const row of result) {
206
+ expect(typeof row).toBe("string");
207
+ }
208
+ });
209
+
210
+ it.each(styles)("style '%s' fully reveals at high frame count", async (style) => {
211
+ const mod = await importLogoWithTruecolor(true);
212
+ const result = mod.getShinedLogo(500, style);
213
+ for (let i = 0; i < result.length; i++) {
214
+ const stripped = stripAnsi(result[i]!);
215
+ const expected = stripAnsi(mod.LOGO[i]!);
216
+ expect(stripped).toBe(expected);
217
+ }
218
+ });
219
+ });
220
+
221
+ // ── Properties ───────────────────────────────────────────────────────────────
222
+
223
+ describe("properties", () => {
224
+ it("getShinedLogo always returns 8 rows (truecolor)", async () => {
225
+ const mod = await importLogoWithTruecolor(true);
226
+ fc.assert(
227
+ fc.property(fc.nat(1000), n => {
228
+ return mod.getShinedLogo(n, "wave").length === 8;
229
+ }),
230
+ );
231
+ });
232
+
233
+ it("each row visible width equals 16 after stripping ANSI (truecolor)", async () => {
234
+ const mod = await importLogoWithTruecolor(true);
235
+ fc.assert(
236
+ fc.property(fc.nat(500), n => {
237
+ const result = mod.getShinedLogo(n, "diagonal");
238
+ for (const row of result) {
239
+ const stripped = stripAnsi(row);
240
+ // stripAnsi trims, so we check the raw row length instead
241
+ // The actual row length (including ANSI) may vary, but the
242
+ // visible content should always be 16 chars
243
+ // Since stripAnsi trims trailing spaces, check untrimmed
244
+ const raw = row.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
245
+ if (raw.length !== 16) return false;
246
+ }
247
+ return true;
248
+ }),
249
+ );
250
+ });
251
+
252
+ it("getShinedLogo returns 8 rows (non-truecolor)", async () => {
253
+ const mod = await importLogoWithTruecolor(false);
254
+ fc.assert(
255
+ fc.property(fc.nat(1000), n => {
256
+ return mod.getShinedLogo(n, "wave").length === 8;
257
+ }),
258
+ );
259
+ });
260
+ });
@@ -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
+ });