import { describe, expect, it } from "vite-plus/test"; import { getThemeColorsForMode, themeColorToHex, THEME_FILE_VERSION } from "./themePalette"; import { isVsCodeThemeFile, pairVsCodeThemes, parseVsCodeThemeFile, resolveThemeLabelCollisions, } from "./vscodeThemeImport"; function asHex(value: string): string { const hex = themeColorToHex(value); if (!hex) throw new Error(`Expected a theme color, received ${value}`); return hex; } function contrastRatio(first: string, second: string): number { const toChannels = (value: string) => { const hex = asHex(value).slice(1); return [0, 1, 2].map( (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255, ); }; const luminance = (value: string) => toChannels(value) .map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)) .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0); const lighter = Math.max(luminance(first), luminance(second)); const darker = Math.min(luminance(first), luminance(second)); return (lighter + 0.05) / (darker + 0.05); } // Shaped like a real workbench theme: dotted keys, alpha overlays, and a lot // of roles simply left out. const VSCODE_DARK = { name: "pierre-dark-soft", type: "dark", colors: { "editor.background": "#171717", "editor.foreground": "#d4d4d4", foreground: "#d4d4d4", "sideBar.background": "#101010", "sideBar.foreground": "#8a8a8a", "sideBar.border": "#1d1d1d", focusBorder: "#69b1ff", "button.background": "#69b1ff", "button.foreground": "#171717", "input.border": "#2c2c2c", "input.placeholderForeground": "#525252", "terminal.background": "#101010", "terminal.foreground": "#8a8a8a", "list.hoverBackground": "#1f3e5e59", "list.activeSelectionBackground": "#1f3e5e99", }, tokenColors: [], }; describe("VS Code theme import", () => { it("recognises workbench themes and rejects our own files", () => { expect(isVsCodeThemeFile(VSCODE_DARK)).toBe(true); expect(isVsCodeThemeFile({ type: "dark", tokenColors: [] })).toBe(true); expect( isVsCodeThemeFile({ version: THEME_FILE_VERSION, name: "Aurora", appearance: "light", colors: { canvas: "#ffffff" }, }), ).toBe(false); expect(isVsCodeThemeFile("nope")).toBe(false); }); it("carries the editor surfaces and accent across", () => { const theme = parseVsCodeThemeFile(VSCODE_DARK); // The slug name is read as words; a displayName would win verbatim. expect(theme.label).toBe("Pierre Dark Soft"); expect(theme.appearance).toBe("dark"); expect(asHex(theme.colors.canvas)).toBe("#171717"); expect(asHex(theme.colors.text)).toBe("#d4d4d4"); expect(asHex(theme.colors.accent)).toBe("#69b1ff"); expect(asHex(theme.colors.sidebar)).toBe("#101010"); expect(asHex(theme.colors.terminalBackground)).toBe("#101010"); }); it("flattens alpha overlays onto the surface they sit on", () => { const theme = parseVsCodeThemeFile(VSCODE_DARK); // #1f3e5e59 over the #101010 sidebar, not left semi-transparent. expect(theme.colors.sidebarRowHover).toMatch(/^oklch\(/); expect(asHex(theme.colors.sidebarRowHover)).not.toBe("#1f3e5e59"); expect(theme.colors.sidebarRowSelected).not.toBe(theme.colors.sidebar); }); it("keeps a fallback placeholder dimmer than entered text", () => { const theme = parseVsCodeThemeFile({ name: "Dark placeholder fallback", type: "dark", colors: { "editor.background": "#1e1e2e", "editor.foreground": "#cdd6f4", "input.placeholderForeground": "#cdd6f473", }, }); expect( contrastRatio(theme.colors.placeholder, theme.colors.surfaceRaised), ).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(theme.colors.placeholder, theme.colors.canvas)).toBeGreaterThanOrEqual( 4.5, ); expect(contrastRatio(theme.colors.placeholder, theme.colors.canvas)).toBeLessThan( contrastRatio(theme.colors.text, theme.colors.canvas), ); }); it("fills every role the file omits with a readable derived value", () => { const theme = parseVsCodeThemeFile(VSCODE_DARK); const colors = getThemeColorsForMode(theme, "dark")!; for (const value of Object.values(colors)) { expect(value).toMatch(/^oklch\(/); } expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5); expect( contrastRatio(colors.messageActionForeground, colors.messageAction), ).toBeGreaterThanOrEqual(4.5); }); it("falls back to the editor background when the type is missing or odd", () => { const untyped = parseVsCodeThemeFile({ name: "Untyped", colors: { "editor.background": "#fdfdfd", "editor.foreground": "#202020" }, }); expect(untyped.appearance).toBe("light"); const hc = parseVsCodeThemeFile({ name: "High contrast", type: "hc-light", colors: { "editor.background": "#ffffff" }, }); expect(hc.appearance).toBe("light"); }); it("keeps an unreadable foreground out of the palette", () => { const theme = parseVsCodeThemeFile({ name: "Unreadable", type: "dark", colors: { "editor.background": "#101010", "editor.foreground": "#111111" }, }); expect(asHex(theme.colors.text)).not.toBe("#111111"); expect(contrastRatio(theme.colors.text, theme.colors.canvas)).toBeGreaterThanOrEqual(4.5); }); it("stays readable when the file replaces a surface but not its foreground", () => { // A dark theme with a light sidebar: the derived sidebar foreground was // solved for a dark surface and would vanish on this one. const theme = parseVsCodeThemeFile({ name: "Split", type: "dark", colors: { "editor.background": "#101010", "editor.foreground": "#f5f5f5", "sideBar.background": "#fafafa", "terminal.background": "#fbfbfb", }, }); expect(asHex(theme.colors.sidebar)).toBe("#fafafa"); expect( contrastRatio(theme.colors.sidebarForeground, theme.colors.sidebar), ).toBeGreaterThanOrEqual(4.5); expect( contrastRatio(theme.colors.terminalForeground, theme.colors.terminalBackground), ).toBeGreaterThanOrEqual(4.5); }); it("reads wide-gamut color() notation", () => { // Themes authored for P3 displays (the shipped Pierre "vibrant" pair) use // this instead of hex. const theme = parseVsCodeThemeFile({ name: "Vibrant", type: "dark", colors: { "editor.background": "color(display-p3 0.039216 0.039216 0.039216)", "editor.foreground": "color(display-p3 0.980392 0.980392 0.980392)", focusBorder: "color(display-p3 0.308664 0.645271 1.000000)", "editor.selectionBackground": "color(display-p3 0.308664 0.645271 1.000000 / 0.300000)", }, }); expect(asHex(theme.colors.canvas)).toMatch(/^#0[89ab]/); expect(asHex(theme.colors.text)).toMatch(/^#f[a-f0-9]/); // The P3 blue lands in sRGB blue, not black or a clipped grey. const accent = asHex(theme.colors.accent); const [red, green, blue] = [1, 3, 5].map((index) => Number.parseInt(accent.slice(index, index + 2), 16), ) as [number, number, number]; expect(blue).toBeGreaterThan(200); expect(blue).toBeGreaterThan(red); expect(green).toBeGreaterThan(red); }); it("pairs light and dark files from one family into dual-mode themes", () => { const make = (name: string, type: "light" | "dark") => parseVsCodeThemeFile({ name, type, colors: { "editor.background": type === "dark" ? "#101014" : "#fdfdfd", "editor.foreground": type === "dark" ? "#e6e6e6" : "#1f1f1f", focusBorder: "#69b1ff", }, }); const themes = pairVsCodeThemes([ make("github-dark", "dark"), make("github-light", "light"), make("github-dark-colorblind", "dark"), make("github-light-colorblind", "light"), make("github-dark-dimmed", "dark"), ]); const labels = themes.map((theme) => theme.label); expect(labels).toEqual(["Github", "Github Colorblind", "Github Dark Dimmed"]); const github = themes[0]!; expect(github.appearance).toBe("light"); expect(getThemeColorsForMode(github, "dark")).not.toBeNull(); expect(asHex(getThemeColorsForMode(github, "dark")!.canvas)).toBe("#101014"); expect(asHex(github.colors.canvas)).toBe("#fdfdfd"); // The unpaired dimmed variant stays a single dark theme. expect(getThemeColorsForMode(themes[2]!, "light")).toBeNull(); }); it("does not guess when a family is ambiguous", () => { const make = (name: string, type: "light" | "dark") => parseVsCodeThemeFile({ name, type, colors: { "editor.background": type === "dark" ? "#101014" : "#fdfdfd" }, }); const themes = pairVsCodeThemes([ make("solar-dark", "dark"), make("solar-dark-soft", "dark"), make("solar-light", "light"), ]); // "solar-dark-soft" groups under its own key with no light partner, so // it keeps its full name; the remaining pair merges. expect(themes.map((theme) => theme.label).sort()).toEqual(["Solar", "Solar Dark Soft"]); }); it("keeps derived surfaces neutral instead of washing them with the accent", () => { // A gray theme with a blue focusBorder: roles the file omits (code // surface, plain surfaces) must stay near the canvas, not turn blue. const theme = parseVsCodeThemeFile(VSCODE_DARK); const spread = (value: string) => { const hex = asHex(value); const channels = [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16)); return Math.max(...channels) - Math.min(...channels); }; expect(spread(theme.colors.codeBackground)).toBeLessThanOrEqual(8); expect(spread(theme.colors.surface)).toBeLessThanOrEqual(8); expect(spread(theme.colors.text)).toBeLessThanOrEqual(12); // The accent itself keeps the file's color. expect(asHex(theme.colors.accent)).toBe("#69b1ff"); }); it("tells same-named variants apart by their file names", () => { // Dracula ships dracula.json and dracula-soft.json that both say // "Dracula" inside. const dracula = (bg: string) => parseVsCodeThemeFile({ name: "Dracula", type: "dark", colors: { "editor.background": bg, "editor.foreground": "#f8f8f2" }, }); const themes = resolveThemeLabelCollisions([ { theme: dracula("#282a36"), sourceName: "dracula.json" }, { theme: dracula("#22232e"), sourceName: "dracula-soft.json" }, ]); expect(themes.map((theme) => theme.label)).toEqual(["Dracula", "Dracula Soft"]); expect(themes.map((theme) => theme.id)).toEqual(["dracula", "dracula-soft"]); // Without file names the second falls back to numbering. const numbered = resolveThemeLabelCollisions([ { theme: dracula("#282a36") }, { theme: dracula("#22232e") }, ]); expect(numbered.map((theme) => theme.label)).toEqual(["Dracula", "Dracula 2"]); }); it("falls back to the name when the displayName humanizes to nothing", () => { const theme = parseVsCodeThemeFile({ displayName: "---", name: "night-owl", type: "dark", colors: { "editor.background": "#011627" }, }); expect(theme.label).toBe("Night Owl"); }); it("keeps a pair whose stripped name is reserved as two single themes", () => { const make = (name: string, type: "light" | "dark") => parseVsCodeThemeFile({ name, type, colors: { "editor.background": type === "dark" ? "#101014" : "#fdfdfd" }, }); const themes = pairVsCodeThemes([make("grove-light", "light"), make("grove-dark", "dark")]); expect(themes.map((theme) => theme.label).sort()).toEqual(["Grove Dark", "Grove Light"]); }); it("explains a file with no editor background", () => { expect(() => parseVsCodeThemeFile({ name: "Empty", type: "dark", colors: {} })).toThrow( /editor\.background/, ); }); });