@xynogen/pix-pretty 1.19.0 → 1.20.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/src/utils.test.ts DELETED
@@ -1,576 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
-
3
- import { MAX_PREVIEW_LINES } from "./config.js";
4
- import type { FgTheme } from "./types.js";
5
- import {
6
- dotJoin,
7
- fillToolBackground,
8
- formatCollapsedToolRow,
9
- formatJson,
10
- frameToolResult,
11
- hideCollapsedToolCall,
12
- padIcon,
13
- pluralize,
14
- renderCollapsedToolRow,
15
- renderDimPreview,
16
- ruleFrame,
17
- sectionRule,
18
- setResultDetails,
19
- termW,
20
- unframeToolResult,
21
- viewportText,
22
- } from "./utils.js";
23
-
24
- class MockTextComponent {
25
- private text = "";
26
- invalidations = 0;
27
-
28
- setText(value: string): void {
29
- this.text = value;
30
- }
31
-
32
- render(): string[] {
33
- return this.text.split("\n");
34
- }
35
-
36
- invalidate(): void {
37
- this.invalidations++;
38
- }
39
- }
40
-
41
- // Counting variant: records how often the inner component is re-fitted, so a
42
- // regression that recomputes every frame (the pre-memo CPU bug) fails loudly.
43
- class CountingTextComponent {
44
- static setCalls = 0;
45
- static renderCalls = 0;
46
- private text = "";
47
- setText(value: string): void {
48
- CountingTextComponent.setCalls++;
49
- this.text = value;
50
- }
51
- render(): string[] {
52
- CountingTextComponent.renderCalls++;
53
- return this.text.split("\n");
54
- }
55
- invalidate(): void {}
56
- }
57
-
58
- describe("viewportText", () => {
59
- it("trims a pre-filled row to Pi's narrower fullscreen viewport", () => {
60
- const text = viewportText(MockTextComponent);
61
- text.setText(fillToolBackground("tool", "", 10));
62
-
63
- expect(plain(text.render(9)[0]!)).toBe("tool".padEnd(9));
64
- expect(plain(text.render(10)[0]!)).toBe("tool".padEnd(10));
65
- });
66
-
67
- it("memoizes: repeat render at same width does not re-fit the inner component", () => {
68
- CountingTextComponent.setCalls = 0;
69
- const text = viewportText(CountingTextComponent);
70
- text.setText("line one\nline two");
71
-
72
- text.render(20);
73
- text.render(20);
74
- text.render(20);
75
- // One fit for the width; repeats hit the cache. Pre-memo this was 3.
76
- expect(CountingTextComponent.setCalls).toBe(1);
77
- });
78
-
79
- it("re-fits when width changes, and again when it returns", () => {
80
- CountingTextComponent.setCalls = 0;
81
- const text = viewportText(CountingTextComponent);
82
- text.setText("abcdefghij");
83
-
84
- text.render(20);
85
- text.render(5); // width changed → re-fit
86
- text.render(5); // same → cached
87
- text.render(20); // changed back → re-fit
88
- expect(CountingTextComponent.setCalls).toBe(3);
89
- });
90
-
91
- it("re-fits after setText changes the content", () => {
92
- CountingTextComponent.setCalls = 0;
93
- const text = viewportText(CountingTextComponent);
94
- text.setText("first");
95
- text.render(20);
96
- text.render(20); // cached
97
- text.setText("second"); // invalidates memo
98
- text.render(20); // re-fit
99
- expect(CountingTextComponent.setCalls).toBe(2);
100
- });
101
-
102
- it("setText with identical value is a no-op (no memo invalidation)", () => {
103
- CountingTextComponent.setCalls = 0;
104
- const text = viewportText(CountingTextComponent);
105
- text.setText("same");
106
- text.render(20);
107
- text.setText("same"); // identical → must not invalidate
108
- text.render(20); // still cached
109
- expect(CountingTextComponent.setCalls).toBe(1);
110
- });
111
-
112
- it("idle frames (same text+width) do not re-call the inner render", () => {
113
- CountingTextComponent.renderCalls = 0;
114
- const text = viewportText(CountingTextComponent);
115
- text.setText("line one\nline two");
116
- text.render(20);
117
- text.render(20);
118
- text.render(20);
119
- // One inner render for the width; spinner ticks reuse the memoized output.
120
- expect(CountingTextComponent.renderCalls).toBe(1);
121
- });
122
-
123
- it("re-calls inner render after content changes", () => {
124
- CountingTextComponent.renderCalls = 0;
125
- const text = viewportText(CountingTextComponent);
126
- text.setText("first");
127
- text.render(20);
128
- text.setText("second"); // content changed → render output stale
129
- text.render(20);
130
- expect(CountingTextComponent.renderCalls).toBe(2);
131
- });
132
-
133
- it("invalidate() drops the render memo so a same-width frame recomputes (theme change)", () => {
134
- CountingTextComponent.renderCalls = 0;
135
- const text = viewportText(CountingTextComponent);
136
- text.setText("body");
137
- text.render(20);
138
- text.render(20); // memo hit
139
- expect(CountingTextComponent.renderCalls).toBe(1);
140
- text.invalidate(); // theme/style changed → output stale even at same width
141
- text.render(20); // must recompute, not serve pre-invalidate output
142
- expect(CountingTextComponent.renderCalls).toBe(2);
143
- });
144
-
145
- it("streaming append reuses already-fitted lines and fits only the new tail", () => {
146
- const text = viewportText(MockTextComponent);
147
- text.setText("aaaaaaaaaa\nbbbbbbbbbb");
148
- expect(text.render(5).map(plain)).toEqual(["aaaaa", "bbbbb"]);
149
- // Append (streaming). Old lines fit identically; new line appears.
150
- text.setText("aaaaaaaaaa\nbbbbbbbbbb\ncccccccccc");
151
- expect(text.render(5).map(plain)).toEqual(["aaaaa", "bbbbb", "ccccc"]);
152
- });
153
-
154
- it("width change re-fits all lines (line cache is width-scoped, no stale serve)", () => {
155
- const text = viewportText(MockTextComponent);
156
- text.setText("abcdefghij");
157
- expect(text.render(5).map(plain)).toEqual(["abcde"]);
158
- // Must NOT serve the stale 5-wide fit for the same raw line at a new width.
159
- expect(text.render(8).map(plain)).toEqual(["abcdefgh"]);
160
- });
161
- });
162
-
163
- // Strip ANSI escapes so assertions test content, not color codes.
164
- const ANSI = /\x1b\[[0-9;]*m/g;
165
- function plain(text: string): string {
166
- return text.replace(ANSI, "");
167
- }
168
-
169
- describe("termW", () => {
170
- // termW() caches and invalidates on a stdout 'resize' event. Set columns then
171
- // emit resize so the next call re-reads.
172
- function setCols(cols: number): void {
173
- (process.stdout as { columns?: number }).columns = cols;
174
- process.stdout.emit("resize");
175
- }
176
-
177
- it("returns the true terminal width with no upper clamp (ultrawide)", () => {
178
- const orig = process.stdout.columns;
179
- try {
180
- setCols(384); // wider than the old 210 cap
181
- expect(termW()).toBe(384);
182
- } finally {
183
- setCols(orig ?? 80);
184
- }
185
- });
186
-
187
- it("floors width at 1 for a degenerate column count", () => {
188
- const orig = process.stdout.columns;
189
- try {
190
- setCols(0); // falsy — falls through resolution chain, never < 1
191
- expect(termW()).toBeGreaterThanOrEqual(1);
192
- } finally {
193
- setCols(orig ?? 80);
194
- }
195
- });
196
- });
197
-
198
- describe("frameToolResult", () => {
199
- it("wraps a component at render width and forwards invalidation", () => {
200
- const child = new MockTextComponent();
201
- child.setText("result");
202
- const theme: FgTheme = { fg: (key, text) => `[${key}]${text}[/${key}]` };
203
- const framed = frameToolResult(child, theme, false);
204
-
205
- expect(framed.render(8)).toEqual([
206
- "[success]────────[/success]",
207
- "result",
208
- "[success]────────[/success]",
209
- ]);
210
- framed.setText("updated");
211
- expect(framed.render(8)[1]).toBe("updated");
212
- framed.invalidate();
213
- expect(child.invalidations).toBe(1);
214
- });
215
-
216
- it("unwraps a framed component when a result collapses", () => {
217
- const child = new MockTextComponent();
218
- child.setText("expanded");
219
- const theme: FgTheme = { fg: (key, text) => `[${key}]${text}[/${key}]` };
220
- const framed = frameToolResult(child, theme, false);
221
- framed.setText("collapsed");
222
-
223
- expect(unframeToolResult(framed)).toBe(child);
224
- expect(unframeToolResult(framed).render(20)).toEqual(["collapsed"]);
225
- });
226
-
227
- it("uses error rules for failed results", () => {
228
- const child = new MockTextComponent();
229
- child.setText("failed");
230
- const theme: FgTheme = { fg: (key, text) => `[${key}]${text}[/${key}]` };
231
-
232
- expect(frameToolResult(child, theme, true).render(4)).toEqual([
233
- "[error]────[/error]",
234
- "failed",
235
- "[error]────[/error]",
236
- ]);
237
- });
238
-
239
- it("reuses an existing frame instead of nesting rules on rerender", () => {
240
- const child = new MockTextComponent();
241
- child.setText("result");
242
- const theme: FgTheme = { fg: (key, text) => `[${key}]${text}[/${key}]` };
243
- const first = frameToolResult(child, theme, false);
244
- const nextTheme: FgTheme = { fg: (key, text) => `<${key}>${text}</${key}>` };
245
- const rerendered = frameToolResult(first, nextTheme, true);
246
-
247
- expect(rerendered).toBe(first);
248
- expect(rerendered.render(4)).toEqual(["<error>────</error>", "result", "<error>────</error>"]);
249
- });
250
- });
251
-
252
- describe("ruleFrame", () => {
253
- it("wraps body with a rule top and bottom, then footer below the close", () => {
254
- const out = ruleFrame(["a", "b"], ["… +3 more"], 10);
255
- expect(out).toHaveLength(5);
256
- expect(plain(out[0]!)).toBe("─".repeat(10)); // top rule
257
- expect(out.slice(1, 3)).toEqual(["a", "b"]); // body
258
- expect(plain(out[3]!)).toBe("─".repeat(10)); // bottom rule closes the block
259
- expect(plain(out[4]!)).toBe("… +3 more"); // footer after the close
260
- });
261
-
262
- it("closes the block even with no footer", () => {
263
- const out = ruleFrame(["only"], [], 4);
264
- expect(plain(out[0]!)).toBe("────");
265
- expect(plain(out.at(-1)!)).toBe("────");
266
- });
267
-
268
- it("paints both rules via the supplied paint fn, neutral by default", () => {
269
- const green = (s: string) => `<G>${s}</G>`;
270
- const ok = ruleFrame(["x"], [], 4, green);
271
- expect(ok[0]).toBe("<G>────</G>");
272
- expect(ok.at(-1)).toBe("<G>────</G>"); // both top and bottom rule painted
273
- const neutral = ruleFrame(["x"], [], 4);
274
- expect(neutral[0]).toContain("50;50;50"); // FG_RULE default tint
275
- });
276
- });
277
-
278
- describe("dotJoin", () => {
279
- it("joins non-empty parts with a middot, dropping falsy pieces", () => {
280
- expect(dotJoin(["a", "b", "c"])).toBe("a · b · c");
281
- expect(dotJoin(["a", "", null, undefined, false, "b"])).toBe("a · b");
282
- expect(dotJoin(["only"])).toBe("only");
283
- expect(dotJoin([])).toBe("");
284
- });
285
-
286
- it("paints the separator when a paint fn is supplied", () => {
287
- expect(dotJoin(["a", "b"], (s) => `<${s}>`)).toBe("a< · >b");
288
- });
289
- });
290
-
291
- // Minimal theme: fg() passes text through untouched.
292
- const theme: FgTheme = { fg: (_key, text) => text };
293
-
294
- describe("pluralize", () => {
295
- it("uses singular for count of 1", () => {
296
- expect(pluralize(1, "match", "matches")).toBe("1 match");
297
- });
298
-
299
- it("uses plural for count != 1", () => {
300
- expect(pluralize(0, "match", "matches")).toBe("0 matches");
301
- expect(pluralize(2, "match", "matches")).toBe("2 matches");
302
- });
303
-
304
- it("defaults plural to noun + s", () => {
305
- expect(pluralize(1, "line")).toBe("1 line");
306
- expect(pluralize(3, "line")).toBe("3 lines");
307
- });
308
- });
309
-
310
- describe("formatJson", () => {
311
- it("reindents a JSON string into a multiline block", () => {
312
- expect(formatJson('{"a":1,"b":2}')).toBe('{\n "a": 1,\n "b": 2\n}');
313
- });
314
-
315
- it("reindents an object value", () => {
316
- expect(formatJson({ a: 1 })).toBe('{\n "a": 1\n}');
317
- });
318
-
319
- it("falls back to the raw string for non-JSON input", () => {
320
- expect(formatJson("not json")).toBe("not json");
321
- });
322
-
323
- it("reindenting a mega JSON one-liner breaks it into short, still-valid lines", () => {
324
- // A JSON one-liner is the pathological render case. Reindenting alone splits
325
- // it into short lines; it must NOT be hard-wrapped (that would split string
326
- // values mid-token) so the block stays valid JSON for syntax highlighting.
327
- const obj = { results: Array.from({ length: 300 }, (_, i) => ({ i, name: `item-${i}` })) };
328
- const mega = JSON.stringify(obj); // one long line
329
- const out = formatJson(mega, { wrapWidth: 80, maxLines: 9999 });
330
- expect(out.split("\n").length).toBeGreaterThan(300); // broken into many lines
331
- expect(() => JSON.parse(out)).not.toThrow(); // still valid JSON → highlightable
332
- });
333
-
334
- it("hard-wraps a NON-JSON mega-line but leaves JSON untouched", () => {
335
- // A genuine non-JSON one-liner (multi-KB plain string) still gets wrapped so
336
- // the TUI never measures a single huge line.
337
- const plain = "x".repeat(7806); // not JSON
338
- const wrapped = formatJson(plain, { wrapWidth: 80, maxLines: 9999 });
339
- const lines = wrapped.split("\n");
340
- expect(Math.max(...lines.map((l) => l.length))).toBeLessThanOrEqual(80);
341
- expect(wrapped.replace(/\n/g, "").length).toBe(plain.length); // lossless
342
- });
343
-
344
- it("caps line count with a `+N more` footer", () => {
345
- const obj = Object.fromEntries(Array.from({ length: 200 }, (_, i) => [`k${i}`, i]));
346
- const out = formatJson(obj, { maxLines: 10 });
347
- const lines = out.split("\n");
348
- expect(lines.length).toBe(11); // 10 + footer
349
- expect(lines.at(-1)).toMatch(/^… \+\d+ more$/);
350
- });
351
-
352
- it("applies a hard char ceiling as a last-resort guard", () => {
353
- const out = formatJson({ blob: "y".repeat(5000) }, { maxChars: 100, maxLines: 999 });
354
- expect(out.length).toBeLessThanOrEqual(100);
355
- expect(out.endsWith("…")).toBe(true);
356
- });
357
- });
358
-
359
- describe("collapsed tool rows", () => {
360
- const rowTheme = { fg: (_key: string, text: string) => text, bold: (text: string) => text };
361
-
362
- it("uses dim for the target and muted for tertiary metadata", () => {
363
- const taggedTheme = {
364
- fg: (key: string, text: string) => `<${key}>${text}</${key}>`,
365
- bold: (text: string) => text,
366
- };
367
- expect(formatCollapsedToolRow(taggedTheme, "read", "src/a.ts", "12 lines")).toContain(
368
- "<dim>src/a.ts</dim> <muted>·</muted> <muted>12 lines</muted>",
369
- );
370
- });
371
-
372
- it("renders a consistent status, tool, target, and metadata row", () => {
373
- // The status marker is width-normalized to 2 cells (padIcon) so wide glyphs
374
- // align with narrow ones; a 1-cell `✓` therefore carries one pad space.
375
- expect(formatCollapsedToolRow(rowTheme, "read", "src/a.ts", "12 lines")).toBe(
376
- "✓ read src/a.ts · 12 lines",
377
- );
378
- const rendered = plain(renderCollapsedToolRow(rowTheme, "read", "src/a.ts", "12 lines"));
379
- expect(rendered).toStartWith("✓ read src/a.ts · 12 lines");
380
- });
381
-
382
- it("padIcon normalizes markers to a fixed cell width (per pi-tui visibleWidth)", () => {
383
- // pi-tui's width table drives the actual TUI column math, so padIcon trusts
384
- // it: `✓`/`✗`/`⚠` measure 1 cell and gain a pad space; `⚡` measures 2 and
385
- // is left as-is. All markers then occupy the same 2-cell column.
386
- expect(padIcon("✓")).toBe("✓ ");
387
- expect(padIcon("✗")).toBe("✗ ");
388
- expect(padIcon("⚠")).toBe("⚠ ");
389
- expect(padIcon("⚡")).toBe("⚡"); // already 2 cells — unchanged
390
- expect(padIcon("x", 4)).toBe("x "); // explicit width
391
- expect(padIcon("⚡", 1)).toBe("⚡"); // never truncated below its own width
392
- });
393
-
394
- it("hides only collapsed, non-expanded call rows", () => {
395
- let value = "unchanged";
396
- expect(hideCollapsedToolCall({ collapsed: true }, false, (text) => (value = text))).toBe(true);
397
- expect(value).toBe("");
398
- expect(hideCollapsedToolCall({ collapsed: true }, true, () => {})).toBe(false);
399
- });
400
- });
401
-
402
- describe("setResultDetails", () => {
403
- it("preserves upstream metadata while adding renderer details", () => {
404
- const result = {
405
- content: [{ type: "text" as const, text: "output" }],
406
- details: {
407
- truncation: { truncated: true, totalLines: 500 },
408
- fullOutputPath: "/tmp/full.log",
409
- },
410
- };
411
-
412
- setResultDetails(result, { _type: "bashResult", exitCode: 0 });
413
-
414
- expect(result.details as Record<string, unknown>).toEqual({
415
- truncation: { truncated: true, totalLines: 500 },
416
- fullOutputPath: "/tmp/full.log",
417
- _type: "bashResult",
418
- exitCode: 0,
419
- });
420
- });
421
- });
422
-
423
- describe("renderDimPreview", () => {
424
- it("renders 'done' for empty input", () => {
425
- expect(plain(renderDimPreview("", theme))).toContain("done");
426
- });
427
-
428
- it("shows every line when under the cap", () => {
429
- const out = plain(renderDimPreview("a\nb\nc", theme));
430
- expect(out).toContain("a");
431
- expect(out).toContain("b");
432
- expect(out).toContain("c");
433
- expect(out).not.toContain("more line");
434
- });
435
-
436
- it("does not add overflow marker at exactly the cap", () => {
437
- const body = Array.from({ length: MAX_PREVIEW_LINES }, (_, i) => `L${i}`);
438
- const out = plain(renderDimPreview(body.join("\n"), theme));
439
- expect(out).not.toContain("more line");
440
- });
441
-
442
- it("adds singular overflow marker for 1 extra line", () => {
443
- const body = Array.from({ length: MAX_PREVIEW_LINES + 1 }, (_, i) => `L${i}`);
444
- const out = plain(renderDimPreview(body.join("\n"), theme));
445
- expect(out).toContain("… 1 more line");
446
- expect(out).not.toContain("more lines");
447
- });
448
-
449
- it("adds plural overflow marker for many extra lines", () => {
450
- const body = Array.from({ length: MAX_PREVIEW_LINES + 3 }, (_, i) => `L${i}`);
451
- const out = plain(renderDimPreview(body.join("\n"), theme));
452
- expect(out).toContain("… 3 more lines");
453
- });
454
-
455
- it("respects a custom maxLines", () => {
456
- const out = plain(renderDimPreview("a\nb\nc\nd", theme, { maxLines: 2 }));
457
- expect(out).toContain("… 2 more lines");
458
- });
459
-
460
- it("prepends a header line when given", () => {
461
- const out = plain(renderDimPreview("body", theme, { header: "5 matches" }));
462
- expect(out).toContain("5 matches");
463
- expect(out).toContain("body");
464
- });
465
-
466
- it("frames the body with a rule top and bottom, dropping the redundant header", () => {
467
- const out = plain(renderDimPreview("a\nb", theme, { frame: true, header: "2 files" }));
468
- const lines = out.split("\n");
469
- // No floating header in framed mode — the collapsed row carries the count.
470
- expect(out).not.toContain("2 files");
471
- expect(lines[0]).toMatch(/^─+$/); // top rule is the first line
472
- expect(lines.at(-1)).toMatch(/^─+$/); // bottom rule closes the block
473
- });
474
-
475
- it("paints the frame rules when a paint fn is given", () => {
476
- const tag: FgTheme = { fg: (k, v) => `<${k}>${v}` };
477
- const out = renderDimPreview("a\nb", tag, {
478
- frame: true,
479
- paint: (s: string) => tag.fg("success", s),
480
- });
481
- expect(out).toContain("<success>─");
482
- });
483
-
484
- it("frames overflow footer below the bottom rule", () => {
485
- const body = Array.from({ length: MAX_PREVIEW_LINES + 2 }, (_, i) => `L${i}`);
486
- const out = plain(renderDimPreview(body.join("\n"), theme, { frame: true }));
487
- const lines = out.split("\n");
488
- // overflow marker is the LAST line, below the closing rule
489
- expect(lines.at(-1)).toContain("… 2 more lines");
490
- expect(lines.at(-2)).toMatch(/^─+$/); // bottom rule sits above the footer
491
- });
492
-
493
- it("highlights matched keyword with non-dim styling", () => {
494
- const raw = renderDimPreview("foo bar foo", theme, { highlight: "foo" });
495
- // matched 'foo' wrapped in yellow/bold ANSI (not produced by stub fg)
496
- expect(raw).toContain("\x1b[");
497
- expect(plain(raw)).toContain("foo bar foo");
498
- });
499
-
500
- it("treats regex metacharacters as literal highlight text", () => {
501
- const raw = renderDimPreview("call(foo)", theme, { highlight: "(" });
502
- expect(plain(raw)).toContain("call(foo)");
503
- expect(raw).toContain("\x1b[");
504
- });
505
-
506
- it("highlights every regex match, not just a literal substring", () => {
507
- // /te.t/ must light up both 'test' and 'text' — a literal indexOf can't.
508
- const raw = renderDimPreview("test text", theme, { highlight: /te.t/g });
509
- // Two bold-open codes = two highlighted hits.
510
- expect(raw.split("\x1b[1m").length - 1).toBe(2);
511
- expect(plain(raw)).toContain("test text");
512
- });
513
-
514
- it("does not loop on a zero-width regex match", () => {
515
- // /x*/ matches empty everywhere — must terminate and keep content intact.
516
- const raw = renderDimPreview("abc", theme, { highlight: /x*/g });
517
- expect(plain(raw)).toContain("abc");
518
- });
519
-
520
- it("skips highlighting a line that already carries ANSI (no escape corruption)", () => {
521
- // Pre-colored input: a match inside an escape would corrupt it, so the
522
- // whole line is dimmed instead — no BOLD hit is injected.
523
- const preColored = "\x1b[31mtest\x1b[0m done";
524
- const raw = renderDimPreview(preColored, theme, { highlight: "test" });
525
- expect(raw).not.toContain("\x1b[1m"); // no bold hit
526
- expect(raw).toContain("\x1b[31m"); // original ANSI preserved
527
- });
528
-
529
- it("renders an === label === separator as a section divider", () => {
530
- const raw = renderDimPreview("=== branch ===\nmain", theme, {});
531
- // Label survives; the raw === markers are gone (replaced by a rule).
532
- expect(plain(raw)).toContain("branch");
533
- expect(plain(raw)).not.toContain("===");
534
- expect(plain(raw)).toContain("─"); // divider glyph
535
- expect(plain(raw)).toContain("main"); // ordinary lines untouched
536
- });
537
- });
538
-
539
- describe("sectionRule", () => {
540
- // Tagging theme so we can assert which role each fragment uses.
541
- const tag: FgTheme = { fg: (key, text) => `<${key}>${text}</${key}>` };
542
-
543
- it("left-aligns the label after a short lead rule, all muted", () => {
544
- const out = sectionRule("=== versions ===", tag, 40) ?? "";
545
- expect(out).not.toBeNull();
546
- // One muted span wrapping the whole divider; label starts after 4 dashes.
547
- expect(out).toBe("<muted>──── versions ──────────────────────────</muted>");
548
- });
549
-
550
- it("wraps an over-long label snugly with 2 dashes each side", () => {
551
- const out = sectionRule("=== a very long section label here ===", tag, 20) ?? "";
552
- expect(out).toBe("<muted>── a very long section label here ──</muted>");
553
- });
554
-
555
- it("accepts extra whitespace and 2+ equals signs", () => {
556
- expect(sectionRule("== dirty? ==", tag, 40)).toContain("──── dirty? ");
557
- expect(sectionRule("===== a b c =====", tag, 40)).toContain("──── a b c ");
558
- });
559
-
560
- it("returns null for a non-separator line", () => {
561
- expect(sectionRule("just a normal line", tag, 20)).toBeNull();
562
- expect(sectionRule("=== no closing", tag, 20)).toBeNull();
563
- // Must span the whole line — leading text before the === disqualifies it.
564
- expect(sectionRule("plain === middle === text", tag, 20)).toBeNull();
565
- });
566
-
567
- it("returns null when the line already carries ANSI", () => {
568
- expect(sectionRule("\x1b[31m=== x ===\x1b[0m", tag, 20)).toBeNull();
569
- });
570
-
571
- it("fills the full requested width so it aligns with the tool frame", () => {
572
- const out = sectionRule("=== x ===", tag, 400) ?? "";
573
- const visible = out.replace(/<\/?[a-z]+>/g, "");
574
- expect([...visible].length).toBe(400);
575
- });
576
- });