@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,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
+ });
@@ -0,0 +1,216 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as fc from "fast-check";
3
+ import { dimAnsiLine, buildMutedMarkdownTheme } from "./theme.js";
4
+ import { stripAnsi } from "../text.js";
5
+
6
+ // ── dimAnsiLine ──────────────────────────────────────────────────────────────
7
+
8
+ describe("dimAnsiLine", () => {
9
+ const makeCache = () => new Map<string, string>();
10
+
11
+ it("dims a single truecolor fg escape", () => {
12
+ const cache = makeCache();
13
+ const input = "\x1b[38;2;255;128;64mhello";
14
+ const result = dimAnsiLine(input, 0.4, 0.5, cache);
15
+ // Output should still be a truecolor fg escape (38;2;...)
16
+ expect(result).toMatch(/\x1b\[38;2;\d+;\d+;\d+mhello/);
17
+ // But the color should be different (dimmed)
18
+ expect(result).not.toBe(input);
19
+ });
20
+
21
+ it("dims multiple fg escapes in one line", () => {
22
+ const cache = makeCache();
23
+ const input = "\x1b[38;2;255;0;0mred\x1b[38;2;0;0;255mblue";
24
+ const result = dimAnsiLine(input, 0.4, 0.5, cache);
25
+ // Both escapes should be rewritten to dimmed versions
26
+ expect(result).not.toContain("\x1b[38;2;255;0;0m");
27
+ expect(result).not.toContain("\x1b[38;2;0;0;255m");
28
+ // Text content preserved
29
+ expect(stripAnsi(result)).toBe("redblue");
30
+ });
31
+
32
+ it("leaves lines with no fg escapes unchanged", () => {
33
+ const cache = makeCache();
34
+ const input = "plain text without escapes";
35
+ const result = dimAnsiLine(input, 0.4, 0.5, cache);
36
+ expect(result).toBe(input);
37
+ });
38
+
39
+ it("preserves non-fg escapes (bold, italic, reset)", () => {
40
+ const cache = makeCache();
41
+ const input = "\x1b[1mbold\x1b[0m \x1b[3mitalic\x1b[23m";
42
+ const result = dimAnsiLine(input, 0.4, 0.5, cache);
43
+ expect(result).toContain("\x1b[1m");
44
+ expect(result).toContain("\x1b[0m");
45
+ expect(result).toContain("\x1b[3m");
46
+ expect(result).toContain("\x1b[23m");
47
+ });
48
+
49
+ it("cache hit returns same result", () => {
50
+ const cache = makeCache();
51
+ const escape = "\x1b[38;2;200;100;50m";
52
+ const input = `${escape}text`;
53
+ const first = dimAnsiLine(input, 0.4, 0.5, cache);
54
+ const second = dimAnsiLine(input, 0.4, 0.5, cache);
55
+ expect(first).toBe(second);
56
+ // Cache should have the entry
57
+ expect(cache.has(escape)).toBe(true);
58
+ });
59
+
60
+ it("passes through unrecognized escapes unchanged", () => {
61
+ const cache = makeCache();
62
+ // A bg escape (48;2;...) should not be matched by FG_COLOR_ESCAPE_RE
63
+ const input = "\x1b[48;2;10;10;10mbg text";
64
+ const result = dimAnsiLine(input, 0.4, 0.5, cache);
65
+ expect(result).toBe(input);
66
+ });
67
+
68
+ it("dims 256-palette fg escapes to truecolor", () => {
69
+ const cache = makeCache();
70
+ const input = "\x1b[38;5;196mred"; // 256-palette red
71
+ const result = dimAnsiLine(input, 0.4, 0.5, cache);
72
+ // Output should be truecolor (38;2;...), not 256-palette
73
+ expect(result).toMatch(/\x1b\[38;2;\d+;\d+;\d+mred/);
74
+ expect(result).not.toContain("\x1b[38;5;");
75
+ });
76
+
77
+ it("handles empty string", () => {
78
+ const cache = makeCache();
79
+ expect(dimAnsiLine("", 0.4, 0.5, cache)).toBe("");
80
+ });
81
+
82
+ it("property: preserves non-ANSI text content", () => {
83
+ fc.assert(
84
+ fc.property(
85
+ fc.string({ maxLength: 200 }),
86
+ (s) => {
87
+ const cache = makeCache();
88
+ const result = dimAnsiLine(s, 0.4, 0.5, cache);
89
+ return stripAnsi(s) === stripAnsi(result);
90
+ },
91
+ ),
92
+ );
93
+ });
94
+
95
+ it("property: never introduces 38;5; escapes (only produces truecolor 38;2;)", () => {
96
+ fc.assert(
97
+ fc.property(
98
+ fc.string({ maxLength: 200 }),
99
+ (s) => {
100
+ const cache = makeCache();
101
+ const result = dimAnsiLine(s, 0.4, 0.5, cache);
102
+ // Any 38;5; in output must have been in the input
103
+ // (dimAnsiLine only produces 38;2; output)
104
+ const inputHas256 = s.includes("38;5;");
105
+ const outputHas256 = result.includes("38;5;");
106
+ // If input didn't have 38;5;, output shouldn't either
107
+ // (because we only dim via truecolor)
108
+ if (!inputHas256) {
109
+ return !outputHas256;
110
+ }
111
+ // If input had 38;5;, they get replaced with 38;2;
112
+ // So output should NOT have 38;5; either
113
+ return !outputHas256;
114
+ },
115
+ ),
116
+ );
117
+ });
118
+ });
119
+
120
+ // ── buildMutedMarkdownTheme ──────────────────────────────────────────────────
121
+
122
+ describe("buildMutedMarkdownTheme", () => {
123
+ const mockTheme = {
124
+ fg: (token: string, text: string) => `\x1b[38;2;180;180;180m${text}\x1b[0m`,
125
+ getFgAnsi: (token: string) => "\x1b[38;2;180;180;180m",
126
+ italic: (text: string) => `\x1b[3m${text}\x1b[23m`,
127
+ };
128
+
129
+ it("returns MarkdownTheme with all required fields", () => {
130
+ const theme = buildMutedMarkdownTheme(mockTheme as any);
131
+ expect(typeof theme.codeBlockIndent).toBe("string");
132
+ expect(typeof theme.heading).toBe("function");
133
+ expect(typeof theme.link).toBe("function");
134
+ expect(typeof theme.linkUrl).toBe("function");
135
+ expect(typeof theme.code).toBe("function");
136
+ expect(typeof theme.codeBlock).toBe("function");
137
+ expect(typeof theme.codeBlockBorder).toBe("function");
138
+ expect(typeof theme.quote).toBe("function");
139
+ expect(typeof theme.quoteBorder).toBe("function");
140
+ expect(typeof theme.hr).toBe("function");
141
+ expect(typeof theme.listBullet).toBe("function");
142
+ expect(typeof theme.bold).toBe("function");
143
+ expect(typeof theme.italic).toBe("function");
144
+ expect(typeof theme.strikethrough).toBe("function");
145
+ expect(typeof theme.underline).toBe("function");
146
+ expect(typeof theme.highlightCode).toBe("function");
147
+ });
148
+
149
+ it("heading uses gold color (#FFD700)", () => {
150
+ const theme = buildMutedMarkdownTheme(mockTheme as any);
151
+ const result = theme.heading("Title");
152
+ // Gold is #FFD700 = rgb(255, 215, 0)
153
+ expect(result).toContain("\x1b[38;2;255;215;0m");
154
+ expect(result).toContain("Title");
155
+ });
156
+
157
+ it("bold uses gold color (#FFD700)", () => {
158
+ const theme = buildMutedMarkdownTheme(mockTheme as any);
159
+ const result = theme.bold("Strong");
160
+ expect(result).toContain("\x1b[38;2;255;215;0m");
161
+ expect(result).toContain("Strong");
162
+ });
163
+
164
+ it("codeBlock uses thinkingText", () => {
165
+ const theme = buildMutedMarkdownTheme(mockTheme as any);
166
+ const result = theme.codeBlock("code here");
167
+ expect(result).toContain("code here");
168
+ });
169
+
170
+ it("italic wraps with ANSI italic codes", () => {
171
+ const theme = buildMutedMarkdownTheme(mockTheme as any);
172
+ const result = theme.italic("slanted");
173
+ expect(result).toContain("\x1b[3m");
174
+ expect(result).toContain("\x1b[23m");
175
+ expect(result).toContain("slanted");
176
+ });
177
+
178
+ it("codeBlockIndent is empty string", () => {
179
+ const theme = buildMutedMarkdownTheme(mockTheme as any);
180
+ expect(theme.codeBlockIndent).toBe("");
181
+ });
182
+
183
+ it("highlightCode returns array of strings", () => {
184
+ const theme = buildMutedMarkdownTheme(mockTheme as any);
185
+ const result = theme.highlightCode!("const x = 1;", "javascript");
186
+ expect(Array.isArray(result)).toBe(true);
187
+ expect(result.length).toBeGreaterThan(0);
188
+ });
189
+
190
+ it("highlightCode dims ANSI escapes in output", () => {
191
+ const theme = buildMutedMarkdownTheme(mockTheme as any);
192
+ const result = theme.highlightCode!("const x = 1;", "javascript");
193
+ // Output should contain dimmed truecolor escapes
194
+ const joined = result.join("\n");
195
+ expect(joined).toMatch(/\x1b\[38;2;\d+;\d+;\d+m/);
196
+ });
197
+
198
+ it("different saturationFactor produces different dimmed output", () => {
199
+ const cache = new Map<string, string>();
200
+ const input = "\x1b[38;2;255;128;64mhello";
201
+ const resultLow = dimAnsiLine(input, 0.4, 0.3, cache);
202
+ const cache2 = new Map<string, string>();
203
+ const resultHigh = dimAnsiLine(input, 0.4, 0.7, cache2);
204
+ // Same input with different saturation factors must produce different escapes
205
+ expect(resultLow).not.toBe(resultHigh);
206
+ // Both must still be valid truecolor fg escapes
207
+ expect(resultLow).toMatch(/\x1b\[38;2;\d+;\d+;\d+mhello/);
208
+ expect(resultHigh).toMatch(/\x1b\[38;2;\d+;\d+;\d+mhello/);
209
+ });
210
+
211
+ it("accepts custom codeDefaultLightness", () => {
212
+ const theme = buildMutedMarkdownTheme(mockTheme as any, { codeDefaultLightness: 0.9 });
213
+ const result = theme.highlightCode!("test", "plaintext");
214
+ expect(Array.isArray(result)).toBe(true);
215
+ });
216
+ });
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { transformThinkingContent } from "./transform.js";
3
+
4
+ describe("transformThinkingContent", () => {
5
+ it("modifies thinking content on assistant messages", () => {
6
+ const message = {
7
+ role: "assistant" as const,
8
+ content: [
9
+ { type: "thinking" as const, thinking: " some thinking " },
10
+ ],
11
+ };
12
+ transformThinkingContent(message);
13
+ expect(message.content[0]!.thinking).toBe("some thinking");
14
+ });
15
+
16
+ it("skips non-assistant messages", () => {
17
+ const message = {
18
+ role: "user" as const,
19
+ content: [
20
+ { type: "thinking" as const, thinking: " should not change " },
21
+ ],
22
+ };
23
+ transformThinkingContent(message);
24
+ expect(message.content[0]!.thinking).toBe(" should not change ");
25
+ });
26
+
27
+ it("skips empty thinking", () => {
28
+ const message = {
29
+ role: "assistant" as const,
30
+ content: [
31
+ { type: "thinking" as const, thinking: " " },
32
+ ],
33
+ };
34
+ transformThinkingContent(message);
35
+ expect(message.content[0]!.thinking).toBe(" ");
36
+ });
37
+
38
+ it("skips thinking with undefined value", () => {
39
+ const message = {
40
+ role: "assistant" as const,
41
+ content: [
42
+ { type: "thinking" as const } as { type: string; thinking?: string },
43
+ ],
44
+ };
45
+ transformThinkingContent(message);
46
+ expect(message.content[0]!.thinking).toBeUndefined();
47
+ });
48
+
49
+ it("calls unindentCodeBlocks on thinking content", () => {
50
+ const message = {
51
+ role: "assistant" as const,
52
+ content: [
53
+ {
54
+ type: "thinking" as const,
55
+ thinking: "```\n indented code\n more code\n```",
56
+ },
57
+ ],
58
+ };
59
+ transformThinkingContent(message);
60
+ expect(message.content[0]!.thinking).toBe("```\nindented code\nmore code\n```");
61
+ });
62
+
63
+ it("handles mixed content types", () => {
64
+ const message = {
65
+ role: "assistant" as const,
66
+ content: [
67
+ { type: "text" as const, text: "some text" },
68
+ { type: "thinking" as const, thinking: " thinking content " },
69
+ { type: "text" as const, text: "more text" },
70
+ ],
71
+ };
72
+ transformThinkingContent(message);
73
+ expect(message.content[0]).toEqual({ type: "text", text: "some text" });
74
+ expect(message.content[1]!.thinking).toBe("thinking content");
75
+ expect(message.content[2]).toEqual({ type: "text", text: "more text" });
76
+ });
77
+
78
+ it("after transform, thinking is trimmed", () => {
79
+ const message = {
80
+ role: "assistant" as const,
81
+ content: [
82
+ { type: "thinking" as const, thinking: " hello world " },
83
+ ],
84
+ };
85
+ transformThinkingContent(message);
86
+ const thinking = message.content[0]!.thinking;
87
+ expect(thinking).toBe(thinking?.trim());
88
+ });
89
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as fc from "fast-check";
3
+ import { unindentCodeBlocks } from "./unindent.js";
4
+
5
+ describe("unindentCodeBlocks", () => {
6
+ it("strips common leading whitespace from fenced code blocks", () => {
7
+ const input = "```\n line1\n line2\n```";
8
+ expect(unindentCodeBlocks(input)).toBe("```\nline1\nline2\n```");
9
+ });
10
+
11
+ it("preserves empty lines structure", () => {
12
+ const input = "```\n line1\n\n line2\n```";
13
+ expect(unindentCodeBlocks(input)).toBe("```\nline1\n\nline2\n```");
14
+ });
15
+
16
+ it("leaves whitespace-only blocks untouched", () => {
17
+ const input = "```\n \n \n```";
18
+ expect(unindentCodeBlocks(input)).toBe("```\n \n \n```");
19
+ });
20
+
21
+ it("handles CRLF → LF normalization", () => {
22
+ const input = "```\r\n line1\r\n line2\r\n```";
23
+ expect(unindentCodeBlocks(input)).toBe("```\nline1\nline2\n```");
24
+ });
25
+
26
+ it("handles blocks with 0 indent on some lines (no stripping)", () => {
27
+ const input = "```\n indented\nnotindented\n indented\n```";
28
+ expect(unindentCodeBlocks(input)).toBe("```\n indented\nnotindented\n indented\n```");
29
+ });
30
+
31
+ it("strips trailing empty lines from code blocks", () => {
32
+ const input = "```\n line1\n line2\n\n\n```";
33
+ expect(unindentCodeBlocks(input)).toBe("```\nline1\nline2\n```");
34
+ });
35
+
36
+ it("handles language tags", () => {
37
+ const input = "```python\n def foo():\n pass\n```";
38
+ expect(unindentCodeBlocks(input)).toBe("```python\ndef foo():\n pass\n```");
39
+ });
40
+
41
+ it("leaves text outside code blocks unchanged", () => {
42
+ const input = "Some text\n```\n code\n```\nMore text";
43
+ expect(unindentCodeBlocks(input)).toBe("Some text\n```\ncode\n```\nMore text");
44
+ });
45
+
46
+ it("handles multiple code blocks", () => {
47
+ const input = "```\n block1\n```\n```\n block2\n```";
48
+ expect(unindentCodeBlocks(input)).toBe("```\nblock1\n```\n```\nblock2\n```");
49
+ });
50
+
51
+ it("handles empty code blocks with content", () => {
52
+ const input = "```\n```";
53
+ expect(unindentCodeBlocks(input)).toBe("```\n```");
54
+ });
55
+
56
+ it("property: idempotence — unindentCodeBlocks(unindentCodeBlocks(x)) === unindentCodeBlocks(x)", () => {
57
+ fc.assert(
58
+ fc.property(fc.string({ maxLength: 500 }), (s) => {
59
+ const once = unindentCodeBlocks(s);
60
+ const twice = unindentCodeBlocks(once);
61
+ return once === twice;
62
+ }),
63
+ );
64
+ });
65
+
66
+ it("property: output never contains CRLF (\\r\\n)", () => {
67
+ fc.assert(
68
+ fc.property(fc.string({ maxLength: 500 }), (s) => {
69
+ const result = unindentCodeBlocks(s);
70
+ return !result.includes("\r\n");
71
+ }),
72
+ );
73
+ });
74
+ });