|
3 | 3 | * at once — it never records-and-waits-for-relaunch. Pins resolve exactly as |
4 | 4 | * startup does; explicit pins are deterministic regardless of terminal state, |
5 | 5 | * so these tests never touch `process.env` or spawn an OS probe. |
| 6 | + * |
| 7 | + * Every assertion below reads painted content or the live style registry — |
| 8 | + * never a repaint counter. The pin cycle runs with no cost movement at all, |
| 9 | + * so a meter-gated repaint (or a stale cached SyntaxStyle) leaves the old |
| 10 | + * palette on screen and fails. |
6 | 11 | */ |
7 | 12 |
|
8 | 13 | import { afterEach, describe, expect, test } from "bun:test"; |
| 14 | +import { RGBA, type CapturedSpan } from "@opentui/core"; |
9 | 15 |
|
10 | | -import { setTheme, UI } from "../theme.js"; |
11 | | -import { applyThemePinLive } from "./settings.js"; |
| 16 | +import { corbitsDark, corbitsLight, setTheme, UI } from "../theme.js"; |
| 17 | +import { transcriptSyntaxStyle } from "../stream.js"; |
| 18 | +import { withTestRenderer, type Harness } from "../harness.js"; |
| 19 | +import { appendStreamRow } from "../shell/chrome.js"; |
| 20 | +import { createAppShell } from "../shell/index.js"; |
| 21 | +import { applyThemePinLive, repaintShellForTheme } from "./settings.js"; |
12 | 22 |
|
13 | 23 | afterEach(() => { |
14 | 24 | setTheme("corbits-dark"); |
15 | 25 | }); |
16 | 26 |
|
| 27 | +/** Live heading colour in the transcript registry. */ |
| 28 | +function registryHeadingFg(): RGBA | undefined { |
| 29 | + return transcriptSyntaxStyle().getAllStyles().get("markup.heading.1")?.fg; |
| 30 | +} |
| 31 | + |
| 32 | +function painted(spans: CapturedSpan[], text: string, fg: RGBA): boolean { |
| 33 | + return spans.some((s) => s.text.includes(text) && s.fg.equals(fg)); |
| 34 | +} |
| 35 | + |
| 36 | +async function settleSpans( |
| 37 | + h: Harness, |
| 38 | + ready: (spans: CapturedSpan[]) => boolean, |
| 39 | +): Promise<CapturedSpan[]> { |
| 40 | + let spans = h.captureSpans().lines.flatMap((line) => line.spans); |
| 41 | + for (let i = 0; i < 400 && !ready(spans); i++) { |
| 42 | + await h.renderOnce(); |
| 43 | + await new Promise((resolve) => setTimeout(resolve, 25)); |
| 44 | + spans = h.captureSpans().lines.flatMap((line) => line.spans); |
| 45 | + } |
| 46 | + return spans; |
| 47 | +} |
| 48 | + |
17 | 49 | describe("applyThemePinLive", () => { |
18 | | - test("a dark pin swaps the shared UI binding and repaints", () => { |
19 | | - let repaints = 0; |
20 | | - const applied = applyThemePinLive("dark", () => { |
21 | | - repaints += 1; |
22 | | - }); |
23 | | - expect(applied).toBe("corbits-dark"); |
24 | | - expect(UI.name).toBe("corbits-dark"); |
25 | | - expect(repaints).toBe(1); |
26 | | - }); |
| 50 | + test("a light pin swaps the binding, rebuilds the registry, and repaints", async () => { |
| 51 | + await withTestRenderer( |
| 52 | + async (h) => { |
| 53 | + setTheme("corbits-dark"); |
| 54 | + const shell = createAppShell(h.renderer, { |
| 55 | + terminal: { columns: 80, rows: 40 }, |
| 56 | + wireKeys: false, |
| 57 | + }); |
| 58 | + try { |
| 59 | + appendStreamRow(shell, { role: "assistant", text: "# pin cycle" }); |
| 60 | + const darkHeading = RGBA.fromHex(corbitsDark.heading); |
| 61 | + const lightHeading = RGBA.fromHex(corbitsLight.heading); |
| 62 | + const darkGround = UI.ground; |
| 63 | + const before = await settleSpans(h, (spans) => |
| 64 | + painted(spans, "pin cycle", darkHeading), |
| 65 | + ); |
| 66 | + expect(painted(before, "pin cycle", darkHeading)).toBe(true); |
| 67 | + expect(registryHeadingFg()?.equals(darkHeading)).toBe(true); |
27 | 68 |
|
28 | | - test("a light pin swaps the shared UI binding and repaints", () => { |
29 | | - setTheme("corbits-dark"); |
30 | | - const darkGround = UI.ground; |
31 | | - let repaints = 0; |
32 | | - const applied = applyThemePinLive("light", () => { |
33 | | - repaints += 1; |
34 | | - }); |
35 | | - expect(applied).toBe("corbits-light"); |
36 | | - expect(UI.name).toBe("corbits-light"); |
37 | | - expect(UI.ground).not.toBe(darkGround); |
38 | | - expect(repaints).toBe(1); |
39 | | - }); |
| 69 | + // No cost movement anywhere in this cycle: the meter never moves, |
| 70 | + // so only an unconditional repaint repaints anything. |
| 71 | + const applied = applyThemePinLive("light", () => |
| 72 | + repaintShellForTheme(shell), |
| 73 | + ); |
| 74 | + expect(applied).toBe("corbits-light"); |
| 75 | + expect(UI.name).toBe("corbits-light"); |
| 76 | + expect(UI.ground).not.toBe(darkGround); |
| 77 | + expect(registryHeadingFg()?.equals(lightHeading)).toBe(true); |
40 | 78 |
|
41 | | - test("auto re-resolves through the startup path and repaints", () => { |
42 | | - let repaints = 0; |
43 | | - const applied = applyThemePinLive("auto", () => { |
44 | | - repaints += 1; |
45 | | - }); |
| 79 | + const after = await settleSpans(h, (spans) => |
| 80 | + painted(spans, "pin cycle", lightHeading), |
| 81 | + ); |
| 82 | + expect(painted(after, "pin cycle", lightHeading)).toBe(true); |
| 83 | + expect(painted(after, "pin cycle", darkHeading)).toBe(false); |
| 84 | + } finally { |
| 85 | + shell.dispose(); |
| 86 | + setTheme("corbits-dark"); |
| 87 | + } |
| 88 | + }, |
| 89 | + { width: 80, height: 40 }, |
| 90 | + ); |
| 91 | + }, 30000); |
| 92 | + |
| 93 | + test("pinning back to dark restores dark content", async () => { |
| 94 | + await withTestRenderer( |
| 95 | + async (h) => { |
| 96 | + setTheme("corbits-light"); |
| 97 | + const shell = createAppShell(h.renderer, { |
| 98 | + terminal: { columns: 80, rows: 40 }, |
| 99 | + wireKeys: false, |
| 100 | + }); |
| 101 | + try { |
| 102 | + appendStreamRow(shell, { role: "assistant", text: "# pin cycle" }); |
| 103 | + const darkHeading = RGBA.fromHex(corbitsDark.heading); |
| 104 | + const lightHeading = RGBA.fromHex(corbitsLight.heading); |
| 105 | + const before = await settleSpans(h, (spans) => |
| 106 | + painted(spans, "pin cycle", lightHeading), |
| 107 | + ); |
| 108 | + expect(painted(before, "pin cycle", lightHeading)).toBe(true); |
| 109 | + expect(registryHeadingFg()?.equals(lightHeading)).toBe(true); |
| 110 | + |
| 111 | + const applied = applyThemePinLive("dark", () => |
| 112 | + repaintShellForTheme(shell), |
| 113 | + ); |
| 114 | + expect(applied).toBe("corbits-dark"); |
| 115 | + expect(UI.name).toBe("corbits-dark"); |
| 116 | + expect(registryHeadingFg()?.equals(darkHeading)).toBe(true); |
| 117 | + |
| 118 | + const after = await settleSpans(h, (spans) => |
| 119 | + painted(spans, "pin cycle", darkHeading), |
| 120 | + ); |
| 121 | + expect(painted(after, "pin cycle", darkHeading)).toBe(true); |
| 122 | + expect(painted(after, "pin cycle", lightHeading)).toBe(false); |
| 123 | + } finally { |
| 124 | + shell.dispose(); |
| 125 | + setTheme("corbits-dark"); |
| 126 | + } |
| 127 | + }, |
| 128 | + { width: 80, height: 40 }, |
| 129 | + ); |
| 130 | + }, 30000); |
| 131 | + |
| 132 | + test("auto re-resolves through the startup path and rebuilds the registry", () => { |
| 133 | + setTheme("corbits-dark"); |
| 134 | + const applied = applyThemePinLive("auto", () => undefined); |
46 | 135 | expect(["corbits-dark", "corbits-light"]).toContain(applied); |
47 | 136 | expect(UI.name).toBe(applied); |
48 | | - expect(repaints).toBe(1); |
| 137 | + expect(registryHeadingFg()?.equals(RGBA.fromHex(UI.heading))).toBe(true); |
49 | 138 | }); |
50 | 139 | }); |
0 commit comments