import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG } from '../card/config'; import { renderCard, type CardAssets, type Ctx } from '../card/render'; import { applyTheme, currentTheme, initialTheme, initTheme, toggleTheme } from './theme'; const KEY = 'aircard.theme'; const html = document.documentElement; function mockMatchMedia(prefersDark: boolean) { window.matchMedia = vi.fn((query: string) => ({ matches: query === '(prefers-color-scheme: dark)' ? prefersDark : false, media: query, addEventListener: () => {}, removeEventListener: () => {}, })) as unknown as typeof window.matchMedia; } beforeEach(() => { localStorage.clear(); html.removeAttribute('data-theme'); document.head.innerHTML = ''; mockMatchMedia(true); }); afterEach(() => { vi.restoreAllMocks(); }); describe('initialTheme', () => { it('ohne gespeicherten Wert gilt das System', () => { expect(initialTheme(null, true)).toBe('dark'); expect(initialTheme(null, false)).toBe('light'); }); it('ein gespeicherter Wert schlägt das System', () => { expect(initialTheme('light', true)).toBe('light'); expect(initialTheme('dark', false)).toBe('dark'); }); it('ein ungültiger Wert wird ignoriert (System)', () => { expect(initialTheme('blau', true)).toBe('dark'); expect(initialTheme('blau', false)).toBe('light'); expect(initialTheme('', false)).toBe('light'); expect(initialTheme('LIGHT', true)).toBe('dark'); }); }); describe('applyTheme', () => { it('setzt data-theme auf und die Browserfarbe, speichert aber nichts', () => { applyTheme('light'); expect(html.getAttribute('data-theme')).toBe('light'); expect(document.querySelector('meta[name="theme-color"]')?.getAttribute('content')).toBe('#F4F6FB'); applyTheme('dark'); expect(html.getAttribute('data-theme')).toBe('dark'); expect(document.querySelector('meta[name="theme-color"]')?.getAttribute('content')).toBe('#0F1828'); expect(localStorage.getItem(KEY)).toBeNull(); }); it('kommt auch ohne theme-color-Tag klar', () => { document.head.innerHTML = ''; expect(() => applyTheme('light')).not.toThrow(); expect(html.getAttribute('data-theme')).toBe('light'); }); }); describe('initTheme', () => { it('nimmt den gespeicherten Wert', () => { localStorage.setItem(KEY, 'light'); expect(initTheme()).toBe('light'); expect(html.getAttribute('data-theme')).toBe('light'); }); it('ohne gespeicherten Wert das System (dunkel bzw. hell)', () => { expect(initTheme()).toBe('dark'); mockMatchMedia(false); expect(initTheme()).toBe('light'); }); it('wirft localStorage beim Lesen, gilt das System', () => { vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new DOMException('blockiert', 'SecurityError'); }); mockMatchMedia(false); expect(initTheme()).toBe('light'); expect(html.getAttribute('data-theme')).toBe('light'); }); it('fehlt matchMedia, bleibt es dunkel', () => { window.matchMedia = undefined as unknown as typeof window.matchMedia; expect(initTheme()).toBe('dark'); }); }); describe('toggleTheme', () => { it('wechselt zwischen dunkel und hell, setzt data-theme und speichert', () => { applyTheme('dark'); expect(toggleTheme()).toBe('light'); expect(html.getAttribute('data-theme')).toBe('light'); expect(localStorage.getItem(KEY)).toBe('light'); expect(toggleTheme()).toBe('dark'); expect(html.getAttribute('data-theme')).toBe('dark'); expect(localStorage.getItem(KEY)).toBe('dark'); }); it('ohne data-theme geht es vom Systemwert aus', () => { mockMatchMedia(false); // System hell → Wechsel auf dunkel expect(toggleTheme()).toBe('dark'); }); it('wirft localStorage beim Schreiben, gibt es keinen Fehler und der Wechsel gilt trotzdem', () => { vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new DOMException('voll', 'QuotaExceededError'); }); applyTheme('dark'); expect(() => toggleTheme()).not.toThrow(); expect(html.getAttribute('data-theme')).toBe('light'); expect(currentTheme()).toBe('light'); }); }); describe('Theme und Kartenausgabe', () => { // Umschalten verändert nur UI-Tokens: der Renderer liest weder data-theme noch CSS-Variablen. function drawLog(): string[] { const log: string[] = []; const state: Record = {}; const ctx = new Proxy(state, { get(target, prop: string) { if (prop in target) return target[prop]; if (prop === 'measureText') return (text: string) => ({ width: text.length * 20 }); if (prop === 'createLinearGradient') { return (...args: number[]) => { log.push(`gradient(${args.join(',')})`); return { addColorStop: (offset: number, color: string) => log.push(`stop(${offset},${color})`) }; }; } return (...args: unknown[]) => void log.push(`${prop}(${args.map((a) => (typeof a === 'object' ? 'obj' : String(a))).join(',')})`); }, set(target, prop: string, value) { target[prop] = value; log.push(`${prop}=${String(value)}`); return true; }, }); const assets: CardAssets = { image: null, logo: { source: {} as CanvasImageSource, w: 200, h: 100 }, brand: {} as CanvasImageSource, }; renderCard(ctx as unknown as Ctx, { ...DEFAULT_CONFIG, template: 'classic' }, assets, () => log.push('strokePath')); return log; } it('hell und dunkel zeichnen dieselbe Karte', () => { applyTheme('dark'); const dark = drawLog(); applyTheme('light'); const light = drawLog(); expect(dark.length).toBeGreaterThan(10); expect(light).toEqual(dark); expect(dark.join('\n')).toContain('fillStyle=#1B2738'); }); });