import assert from "node:assert/strict"; import test from "node:test"; import { mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearGaugePaintCache, gaugePaintFor, resolveGaugePaint } from "../lib/gauge-theme.ts"; import { paintGauge } from "../lib/shell-gauge.ts"; // Issue #2222: reserved `vars` keys (`gauge.*`) give the gauges their own // roles and glyphs; everything else falls back. `vars` is the only open map // in Pi's theme schema, and these tests run every fixture through the same // validation the theme loader applies. const taggedTheme = { fg(color: string, text: string) { return `<${color}>${text}`; }, }; function withThemeSource(body: unknown): string { const directory = mkdtempSync(join(tmpdir(), "gauge-theme-")); const path = join(directory, "Theme.json"); writeFileSync(path, typeof body === "string" ? body : JSON.stringify(body)); return path; } const BASE_THEME = { name: "Test", colors: { accent: "magenta", warning: "yellow", error: "red", border: "cyan", success: "green", syntaxString: "blue" }, }; test("resolveGaugePaint reads token references and glyphs from the gauge vars", () => { const path = withThemeSource({ ...BASE_THEME, vars: { "gauge.ok": "success", "gauge.warning": "syntaxString", "gauge.error": "error", "gauge.empty": "border", "gauge.filled": "█", "gauge.emptyGlyph": "░" }, }); const paint = resolveGaugePaint(path); assert.deepEqual(paint, { ok: "success", warning: "syntaxString", error: "error", empty: "border", filled: "█", emptyGlyph: "░" }); assert.equal(paintGauge(45, taggedTheme, 8, paint), `████░░░░`); }); test("a theme without gauge vars resolves no paint", () => { assert.equal(resolveGaugePaint(withThemeSource(BASE_THEME)), undefined); assert.equal(resolveGaugePaint(withThemeSource({ ...BASE_THEME, vars: { brand: "#ff0000" } })), undefined); }); test("invalid entries fall back per role and glyphs must be single visible characters", () => { const path = withThemeSource({ ...BASE_THEME, vars: { "gauge.ok": 300, "gauge.warning": "noSuchToken", "gauge.error": "error", "gauge.filled": "██", "gauge.emptyGlyph": "" }, }); assert.deepEqual(resolveGaugePaint(path), { error: "error" }); }); test("gauge colors accept direct hex, okhsl, 256 indexes, and vars references", () => { const path = withThemeSource({ name: "Test", colors: { accent: "brand" }, vars: { brand: "#ff0000", "gauge.ok": "#22c55e", "gauge.warning": "brand", "gauge.error": 196, "gauge.empty": "okhsl(70 0.1 0.5)" }, }); const paint = resolveGaugePaint(path); assert.equal(paint?.ok, "\x1b[38;2;34;197;94m"); assert.equal(paint?.warning, "\x1b[38;2;255;0;0m"); assert.match(paint?.error ?? "", /^\x1b\[38;5;196m$/); assert.match(paint?.empty ?? "", /^\x1b\[38;2;\d+;\d+;\d+m$/); }); test("direct colors use the same color grammar themes use everywhere: 3-digit hex, oklch, and case-insensitive okhsl", () => { const path = withThemeSource({ name: "Test", colors: {}, vars: { "gauge.ok": "#f00", "gauge.warning": "OKHSL(120 50% 50%)", "gauge.error": "oklch(0.7 0.1 200)", "gauge.empty": "okhsl(bogus)" }, }); const paint = resolveGaugePaint(path); assert.equal(paint?.ok, "\x1b[38;2;255;0;0m"); assert.match(paint?.warning ?? "", /^\x1b\[38;2;\d+;\d+;\d+m$/); assert.match(paint?.error ?? "", /^\x1b\[38;2;\d+;\d+;\d+m$/); assert.equal(paint?.empty, undefined); }); test("glyphs must occupy exactly one terminal column", () => { const path = withThemeSource({ ...BASE_THEME, vars: { "gauge.filled": "0", "gauge.emptyGlyph": "\u200b" }, }); assert.deepEqual(resolveGaugePaint(path), {}); }); test("paintGauge paints raw ANSI roles directly and token roles through the theme", () => { const raw = { ok: "\x1b[38;2;34;197;94m" }; assert.equal(paintGauge(45, taggedTheme, 8, raw), "\x1b[38;2;34;197;94m▰▰▰▰\x1b[39m▱▱▱▱"); assert.equal(paintGauge(45, taggedTheme, 8, { ok: "success" }), "▰▰▰▰▱▱▱▱"); }); test("paintGauge tolerates regex metacharacters in theme-authored glyphs", () => { assert.equal(paintGauge(50, taggedTheme, 4, { emptyGlyph: "*" }), "▰▰**"); assert.equal(paintGauge(50, taggedTheme, 4, { emptyGlyph: "." }), "▰▰.."); assert.equal(paintGauge(100, taggedTheme, 4, { filled: "(" }), "(((("); }); test("a malformed theme file throws for the raw resolver and is swallowed by the memoized one", () => { assert.throws(() => resolveGaugePaint(withThemeSource("not json"))); assert.equal(gaugePaintFor(withThemeSource("not json")), undefined); }); test("gaugePaintFor memoizes by stat and picks up file changes", () => { const path = withThemeSource(BASE_THEME); try { clearGaugePaintCache(); assert.equal(gaugePaintFor(path), undefined); assert.equal(gaugePaintFor(undefined), undefined); writeFileSync(path, JSON.stringify({ ...BASE_THEME, vars: { "gauge.ok": "success", "gauge.filled": "█" } })); // Same-second writes are not enough: touch mtime explicitly. utimesSync(path, new Date(), new Date(Date.now() + 5_000)); assert.deepEqual(gaugePaintFor(path), { ok: "success", filled: "█" }); } finally { clearGaugePaintCache(); rmSync(join(path, ".."), { recursive: true, force: true }); } });