anvilsign in

collin/browser-terminal-extension

main / extension / lib / theme.test.js
1// Run with: node --test "extension/lib/*.test.js"
2//
3// A light terminal theme is easy to get wrong: the standard ANSI palette is
4// tuned for dark backgrounds, and dropping it onto white produces yellow you
5// cannot read. These tests check every colour against its own background.
6
7const test = require("node:test");
8const assert = require("node:assert");
9const { THEMES, PREFERENCES, resolveTheme } = require("./theme.js");
10
11// --- WCAG relative luminance / contrast ------------------------------------
12
13function srgbToLinear(c) {
14 const v = c / 255;
15 return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
16}
17
18function luminance(hex) {
19 const m = /^#([0-9a-f]{6})$/i.exec(hex);
20 assert.ok(m, `not a 6-digit hex colour: ${hex}`);
21 const n = parseInt(m[1], 16);
22 const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
23 return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
24}
25
26function contrast(a, b) {
27 const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
28 return (x + 0.05) / (y + 0.05);
29}
30
31test("contrast helper matches known values", () => {
32 assert.ok(Math.abs(contrast("#000000", "#ffffff") - 21) < 0.01);
33 assert.strictEqual(contrast("#123456", "#123456"), 1);
34});
35
36// --- structure --------------------------------------------------------------
37
38test("both themes define exactly the same keys", () => {
39 const { light, dark } = THEMES;
40 assert.deepStrictEqual(Object.keys(light.ui).sort(), Object.keys(dark.ui).sort());
41 assert.deepStrictEqual(Object.keys(light.xterm).sort(), Object.keys(dark.xterm).sort());
42});
43
44test("the full 16-colour ANSI palette is present", () => {
45 const ansi = [
46 "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
47 "brightBlack", "brightRed", "brightGreen", "brightYellow",
48 "brightBlue", "brightMagenta", "brightCyan", "brightWhite",
49 ];
50 for (const theme of Object.values(THEMES)) {
51 for (const name of ansi) {
52 assert.ok(theme.xterm[name], `${theme.name} is missing ${name}`);
53 }
54 }
55});
56
57// --- readability ------------------------------------------------------------
58
59test("body text meets WCAG AA (4.5:1)", () => {
60 for (const theme of Object.values(THEMES)) {
61 const c = contrast(theme.xterm.foreground, theme.xterm.background);
62 assert.ok(c >= 4.5, `${theme.name}: foreground on background is ${c.toFixed(2)}:1`);
63 const ui = contrast(theme.ui["--fg"], theme.ui["--bg"]);
64 assert.ok(ui >= 4.5, `${theme.name}: --fg on --bg is ${ui.toFixed(2)}:1`);
65 }
66});
67
68test("muted text still meets AA for large text (3:1)", () => {
69 for (const theme of Object.values(THEMES)) {
70 const c = contrast(theme.ui["--fg-muted"], theme.ui["--bg-panel"]);
71 assert.ok(c >= 3, `${theme.name}: --fg-muted on --bg-panel is ${c.toFixed(2)}:1`);
72 }
73});
74
75test("every ANSI colour is legible on its own background", () => {
76 // 3:1 is the WCAG threshold for non-text/large text. Terminal output is
77 // mostly small, but demanding 4.5 across all 16 slots would force a palette
78 // with no hue separation left.
79 const MIN = 3;
80 for (const theme of Object.values(THEMES)) {
81 const bg = theme.xterm.background;
82 for (const [name, colour] of Object.entries(theme.xterm)) {
83 if (["background", "cursorAccent", "selectionBackground"].includes(name)) continue;
84 // Black is the background-ish slot by definition; it is used as a
85 // foreground only in inverse video.
86 if (name === "black" && theme.name === "dark") continue;
87 const c = contrast(colour, bg);
88 assert.ok(c >= MIN, `${theme.name}: ${name} (${colour}) on ${bg} is ${c.toFixed(2)}:1`);
89 }
90 }
91});
92
93test("accent buttons are readable", () => {
94 for (const theme of Object.values(THEMES)) {
95 const c = contrast(theme.ui["--accent-fg"], theme.ui["--accent"]);
96 assert.ok(c >= 4.5, `${theme.name}: accent text is ${c.toFixed(2)}:1`);
97 }
98});
99
100test("selected text is readable against the selection highlight", () => {
101 for (const theme of Object.values(THEMES)) {
102 const c = contrast(theme.xterm.selectionForeground, theme.xterm.selectionBackground);
103 assert.ok(c >= 4.5, `${theme.name}: selection is ${c.toFixed(2)}:1`);
104 }
105});
106
107test("the two themes are actually different", () => {
108 assert.notStrictEqual(THEMES.light.xterm.background, THEMES.dark.xterm.background);
109 assert.ok(
110 luminance(THEMES.light.xterm.background) > luminance(THEMES.dark.xterm.background),
111 "light should be lighter than dark",
112 );
113});
114
115// --- preference resolution --------------------------------------------------
116
117test("auto follows the system", () => {
118 assert.strictEqual(resolveTheme("auto", true).name, "dark");
119 assert.strictEqual(resolveTheme("auto", false).name, "light");
120});
121
122test("explicit choices override the system", () => {
123 assert.strictEqual(resolveTheme("light", true).name, "light");
124 assert.strictEqual(resolveTheme("dark", false).name, "dark");
125});
126
127test("an unknown or missing preference degrades to auto", () => {
128 assert.strictEqual(resolveTheme(undefined, true).name, "dark");
129 assert.strictEqual(resolveTheme("chartreuse", false).name, "light");
130});
131
132test("every offered preference resolves", () => {
133 for (const p of PREFERENCES) {
134 assert.ok(resolveTheme(p, false).name, `${p} did not resolve`);
135 assert.ok(resolveTheme(p, true).name, `${p} did not resolve`);
136 }
137});