import { readFileSync } from 'node:fs'; import { afterEach, describe, expect, it } from 'vitest'; import { applyBuiltInTheme, applyImportedTheme, BUILT_IN_APPEARANCE, BUILT_IN_BACKGROUND, BUILT_IN_EFFECTS, BUILT_IN_TYPOGRAPHY, getActiveColorScheme, getActiveTerminalTheme, getActiveThemeAppearance, isBuiltInTheme, normalizeImportedTheme, onColorSchemeChange, onTerminalThemeChange, resolveBackgroundCss, resolveThemeAppearance, THEME_SCHEMA_VERSION, THEMES, UI_STYLE_PRESETS, UI_THEMES, } from '../frontend/src/theme'; import { SAFE_UI_THEME_PROPERTIES, THEME_MAX_BYTES } from '../src/theme-schema'; function relativeLuminance(hex: string): number { const channels = hex .slice(1) .match(/.{2}/g)! .map((value) => parseInt(value, 16) / 255); return channels .map((value) => (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)) .reduce((sum, value, index) => sum + value * [0.2126, 0.7152, 0.0722][index], 0); } function contrastRatio(foreground: string, background: string): number { const first = relativeLuminance(foreground); const second = relativeLuminance(background); return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05); } describe('Standard 内置主题', () => { afterEach(() => applyBuiltInTheme('cyberpunk')); it('注册 Standard Dark/Light,并保持全部 UI 变量完整', () => { expect(isBuiltInTheme('standard-dark')).toBe(true); expect(isBuiltInTheme('standard-light')).toBe(true); expect(Object.keys(UI_THEMES['standard-dark']).sort()).toEqual( Object.keys(UI_THEMES.cyberpunk).sort() ); expect(Object.keys(UI_THEMES['standard-light']).sort()).toEqual( Object.keys(UI_THEMES.cyberpunk).sort() ); }); it('为浅色和深色终端提供完整 ANSI 16 色', () => { const ansiKeys = [ 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite', ] as const; for (const themeName of ['standard-dark', 'standard-light', 'apple', 'crt', 'glass'] as const) { for (const key of ansiKeys) { expect(THEMES[themeName][key]).toMatch(/^#[0-9a-f]{6}$/i); } } }); it('主要文本、次要文本和强调色达到普通文字 4.5:1 对比度', () => { for (const themeName of ['standard-dark', 'standard-light', 'apple', 'crt', 'glass'] as const) { const ui = UI_THEMES[themeName]; for (const foreground of ['--text', '--text-muted', '--text-dim', '--accent', '--error']) { expect(contrastRatio(ui[foreground], ui['--bg'])).toBeGreaterThanOrEqual(4.5); } } }); it('主题变化会广播给所有订阅终端,新订阅者立即获得当前主题', () => { const received: unknown[] = []; const unsubscribe = onTerminalThemeChange((theme) => received.push(theme)); applyBuiltInTheme('standard-light'); expect(received).toEqual([THEMES.cyberpunk, THEMES['standard-light']]); expect(getActiveTerminalTheme()).toBe(THEMES['standard-light']); unsubscribe(); applyBuiltInTheme('standard-dark'); expect(received).toHaveLength(2); }); it('主题变化会广播明暗模式,供第三方组件同步配色', () => { const received: unknown[] = []; const unsubscribe = onColorSchemeChange((colorScheme) => received.push(colorScheme)); applyBuiltInTheme('standard-light'); expect(received).toEqual(['dark', 'light']); expect(getActiveColorScheme()).toBe('light'); unsubscribe(); applyBuiltInTheme('standard-dark'); expect(received).toHaveLength(2); }); it('旧版浅色自定义主题可以推断模式并继承浅色 ANSI 配色', () => { applyImportedTheme({ ui: { '--bg': '#ffffff', '--text': '#202124', }, }); expect(getActiveTerminalTheme().background).toBe(THEMES['standard-light'].background); expect(getActiveTerminalTheme().yellow).toBe(THEMES['standard-light'].yellow); expect(getActiveThemeAppearance().style).toBe('standard'); }); }); describe('Theme V2 界面风格', () => { afterEach(() => applyBuiltInTheme('cyberpunk')); it('提供版本化外观结构,并让内置主题覆盖四种风格', () => { expect(THEME_SCHEMA_VERSION).toBe(3); expect(BUILT_IN_APPEARANCE).toEqual({ 'standard-dark': { style: 'standard' }, 'standard-light': { style: 'standard' }, cyberpunk: { style: 'cyberpunk' }, apple: { style: 'soft' }, gruvbox: { style: 'dense' }, crt: { style: 'cyberpunk' }, glass: { style: 'soft', blur: 'strong' }, }); expect(Object.keys(UI_STYLE_PRESETS).sort()).toEqual([ 'cyberpunk', 'dense', 'soft', 'standard', ]); }); it('切换内置主题会同步形状、密度、字体、阴影、动效和组件风格', () => { applyBuiltInTheme('apple'); expect(getActiveThemeAppearance()).toEqual({ style: 'soft', shape: 'soft', density: 'comfortable', font: 'system', shadow: 'elevated', motion: 'reduced', blur: 'strong', components: { button: 'soft', input: 'boxed', card: 'elevated', tabs: 'segmented', }, }); applyBuiltInTheme('gruvbox'); expect(getActiveThemeAppearance().style).toBe('dense'); expect(getActiveThemeAppearance().density).toBe('compact'); expect(getActiveThemeAppearance().components.card).toBe('flat'); }); it('自定义主题可以在预设之上安全覆盖外观枚举', () => { applyImportedTheme({ schemaVersion: 2, colorScheme: 'dark', ui: { '--bg': '#101318' }, appearance: { style: 'soft', shape: 'square', density: 'spacious', font: 'mono', shadow: 'none', motion: 'none', components: { button: 'solid', input: 'underline', card: 'flat', tabs: 'underline', }, }, }); expect(getActiveThemeAppearance()).toEqual({ style: 'soft', shape: 'square', density: 'spacious', font: 'mono', shadow: 'none', motion: 'none', blur: 'strong', components: { button: 'solid', input: 'underline', card: 'flat', tabs: 'underline', }, }); }); it('非法外观取值回退到所选预设,不进入页面数据属性', () => { const resolved = resolveThemeAppearance({ style: 'soft', shape: 'invalid' as never, density: 'invalid' as never, components: { button: 'invalid' as never }, }); expect(resolved.shape).toBe(UI_STYLE_PRESETS.soft.shape); expect(resolved.density).toBe(UI_STYLE_PRESETS.soft.density); expect(resolved.components.button).toBe(UI_STYLE_PRESETS.soft.components.button); }); it('终端主题拒绝可触发外部资源请求的 CSS 值', () => { applyImportedTheme({ terminal: { background: 'url(https://example.com/tracker.png)', foreground: '#abcdef', }, }); expect(getActiveTerminalTheme().background).toBe(THEMES.cyberpunk.background); expect(getActiveTerminalTheme().foreground).toBe('#abcdef'); }); it('导入时规范化 Theme V2 并保留合法的基础主题和外观配置', () => { expect( normalizeImportedTheme({ schemaVersion: 999, name: ' My Theme ', baseTheme: 'gruvbox', colorScheme: 'dark', ui: { '--accent': '#abcdef', '--unknown': '#ffffff', }, appearance: { shape: 'soft', motion: 'invalid', components: { button: 'solid', tabs: 'invalid' }, }, }) ).toEqual({ schemaVersion: 3, name: 'My Theme', baseTheme: 'gruvbox', colorScheme: 'dark', ui: { '--accent': '#abcdef' }, appearance: { shape: 'soft', components: { button: 'solid' }, }, }); }); it('旧版 glacier 基础主题优雅降级:字段被丢弃但主题仍可导入', () => { expect( normalizeImportedTheme({ schemaVersion: 2, name: 'Legacy Glacier Custom', baseTheme: 'glacier', colorScheme: 'dark', ui: { '--accent': '#67e8f9' }, appearance: { style: 'soft' }, }) ).toEqual({ schemaVersion: 3, name: 'Legacy Glacier Custom', colorScheme: 'dark', ui: { '--accent': '#67e8f9' }, appearance: { style: 'soft' }, }); }); it('应用与服务端共享 UI 属性白名单,并拒绝 UI 中的外部资源值', () => { expect([...SAFE_UI_THEME_PROPERTIES].sort()).toEqual(Object.keys(UI_THEMES.cyberpunk).sort()); expect( normalizeImportedTheme({ ui: { '--accent': '#abcdef', '--bg': 'url(https://example.com/tracker.png)', }, }) ).toMatchObject({ ui: { '--accent': '#abcdef' }, }); }); }); describe('Standard 主题入口和编辑器', () => { const appHtml = readFileSync(new URL('../frontend/index.html', import.meta.url), 'utf8'); const editorHtml = readFileSync( new URL('../docs/theme-editor/index.html', import.meta.url), 'utf8' ); const terminalSource = readFileSync( new URL('../frontend/src/terminal.ts', import.meta.url), 'utf8' ); const appCss = readFileSync(new URL('../frontend/src/style.css', import.meta.url), 'utf8'); const mainSource = readFileSync(new URL('../frontend/src/main.ts', import.meta.url), 'utf8'); const workerSource = readFileSync(new URL('../src/worker/index.ts', import.meta.url), 'utf8'); const userDbSource = readFileSync(new URL('../src/worker/user-db.ts', import.meta.url), 'utf8'); const presetJson = editorHtml.match( /\/\* THEME_PRESETS_START \*\/ ([\s\S]+?) \/\* THEME_PRESETS_END \*\// )?.[1]; const editorPresets = JSON.parse(presetJson || '{}') as Record< string, { ui: Record; appearance: Record; background?: Record; effects?: Record; } >; it('主项目和在线编辑器都提供两个 Standard 主题', () => { expect(appHtml).toContain(''); expect(appHtml).toContain(''); expect(editorHtml).toContain('