| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151 |
- // @vitest-environment jsdom
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
- import { Context } from 'cordis'
- import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
- import { STORAGE_KEY, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
- const make = (): { ctx: Context; theme: ThemeService; events: ThemeSnapshot[] } => {
- const ctx = new Context()
- const events: ThemeSnapshot[] = []
- ctx.on('theme/change', (snapshot) => { events.push(snapshot) })
- return { ctx, theme: new ThemeService(ctx), events }
- }
- describe('ThemeService', () => {
- beforeEach(() => {
- localStorage.clear()
- })
- it('defaults to the system preference resolved against prefers-color-scheme', () => {
- const { theme } = make()
- const snapshot = theme.getTheme()
- expect(snapshot.preference).toBe('system')
- // jsdom matchMedia is absent; system resolves to light.
- expect(snapshot.active.id).toBe('light')
- expect(snapshot.active.colorScheme).toBe('light')
- expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark'])
- })
- it('setTheme switches, persists, republishes, and keeps DOM untouched', () => {
- const { theme, events } = make()
- theme.setTheme('dark')
- expect(theme.getTheme().preference).toBe('dark')
- expect(theme.getTheme().active.colorScheme).toBe('dark')
- expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
- expect(events).toHaveLength(1)
- expect(events[0]).toBe(theme.getTheme())
- // The service never touches presentation state.
- expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
- // Same-value set is a no-op (no extra event).
- theme.setTheme('dark')
- expect(events).toHaveLength(1)
- })
- it('restores a persisted preference and falls back on garbage', () => {
- localStorage.setItem(STORAGE_KEY, 'dark')
- expect(make().theme.getTheme().preference).toBe('dark')
- localStorage.setItem(STORAGE_KEY, 'sepia')
- expect(make().theme.getTheme().preference).toBe('system')
- })
- it('throws on unknown setTheme ids, duplicate registration, and the system id', () => {
- const { theme } = make()
- expect(() => { theme.setTheme('sepia') }).toThrow('not registered')
- expect(() => theme.register({ id: 'light', colorScheme: 'light', tokens: {} })).toThrow('already registered')
- expect(() => theme.register({ id: 'system', colorScheme: 'light', tokens: {} })).toThrow('preference')
- })
- it('registered themes join the snapshot; disposing the active one resets to default', () => {
- const { theme, events } = make()
- const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } })
- expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia'])
- theme.setTheme('sepia')
- expect(theme.getTheme().active.tokens['--dsw-alias-bg-base']).toBe('red')
- dispose()
- expect(theme.getTheme().preference).toBe('system')
- expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark'])
- expect(localStorage.getItem(STORAGE_KEY)).toBe('system')
- // register + set + dispose = three publishes; disposer is idempotent.
- expect(events.length).toBe(3)
- dispose()
- expect(events.length).toBe(3)
- })
- it('disposing an inactive theme keeps the active preference', () => {
- const { theme } = make()
- const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: {} })
- theme.setTheme('dark')
- dispose()
- expect(theme.getTheme().preference).toBe('dark')
- })
- it('revision increases monotonically across every publish', () => {
- const { theme, events } = make()
- theme.setTheme('dark')
- theme.setTheme('light')
- const dispose = theme.register({ id: 'sepia', colorScheme: 'dark', tokens: {} })
- dispose()
- expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4])
- })
- it('runs without localStorage (node boots): defaults on read, no-op on write', () => {
- vi.stubGlobal('localStorage', undefined)
- try {
- const { theme } = make()
- expect(theme.getTheme().preference).toBe('system')
- theme.setTheme('dark')
- expect(theme.getTheme().preference).toBe('dark')
- } finally {
- vi.unstubAllGlobals()
- }
- })
- describe('prefers-color-scheme resolution (stubbed matchMedia)', () => {
- type Listener = () => void
- const stubMedia = (initialMatches: boolean) => {
- const listeners = new Set<Listener>()
- const media = {
- matches: initialMatches,
- addEventListener: (_: 'change', fn: Listener) => { listeners.add(fn) },
- removeEventListener: (_: 'change', fn: Listener) => { listeners.delete(fn) },
- flip() {
- this.matches = !this.matches
- for (const fn of listeners) fn()
- },
- listenerCount: () => listeners.size,
- }
- vi.stubGlobal('matchMedia', () => media)
- return media
- }
- afterEach(() => { vi.unstubAllGlobals() })
- it('system resolves against the media query and follows OS flips', () => {
- const media = stubMedia(true)
- const { theme, events } = make()
- expect(theme.getTheme().preference).toBe('system')
- expect(theme.getTheme().active.id).toBe('dark')
- media.flip()
- expect(theme.getTheme().active.id).toBe('light')
- expect(events).toHaveLength(1)
- })
- it('OS flips do not republish while a concrete preference is set', () => {
- const media = stubMedia(false)
- const { theme, events } = make()
- theme.setTheme('light')
- expect(events).toHaveLength(1)
- media.flip()
- expect(events).toHaveLength(1)
- expect(theme.getTheme().active.id).toBe('light')
- })
- it('context dispose releases the media listener', async () => {
- const media = stubMedia(false)
- const { ctx } = make()
- expect(media.listenerCount()).toBe(1)
- await ctx.fiber.dispose()
- expect(media.listenerCount()).toBe(0)
- })
- })
- })
|