theme.spec.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. // @vitest-environment jsdom
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { Context } from 'cordis'
  4. import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
  5. import { STORAGE_KEY, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
  6. const make = (): { ctx: Context; theme: ThemeService; events: ThemeSnapshot[] } => {
  7. const ctx = new Context()
  8. const events: ThemeSnapshot[] = []
  9. ctx.on('theme/change', (snapshot) => { events.push(snapshot) })
  10. return { ctx, theme: new ThemeService(ctx), events }
  11. }
  12. describe('ThemeService', () => {
  13. beforeEach(() => {
  14. localStorage.clear()
  15. })
  16. it('defaults to the system preference resolved against prefers-color-scheme', () => {
  17. const { theme } = make()
  18. const snapshot = theme.getTheme()
  19. expect(snapshot.preference).toBe('system')
  20. // jsdom matchMedia is absent; system resolves to light.
  21. expect(snapshot.active.id).toBe('light')
  22. expect(snapshot.active.colorScheme).toBe('light')
  23. expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark'])
  24. })
  25. it('setTheme switches, persists, republishes, and keeps DOM untouched', () => {
  26. const { theme, events } = make()
  27. theme.setTheme('dark')
  28. expect(theme.getTheme().preference).toBe('dark')
  29. expect(theme.getTheme().active.colorScheme).toBe('dark')
  30. expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
  31. expect(events).toHaveLength(1)
  32. expect(events[0]).toBe(theme.getTheme())
  33. // The service never touches presentation state.
  34. expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
  35. // Same-value set is a no-op (no extra event).
  36. theme.setTheme('dark')
  37. expect(events).toHaveLength(1)
  38. })
  39. it('restores a persisted preference and falls back on garbage', () => {
  40. localStorage.setItem(STORAGE_KEY, 'dark')
  41. expect(make().theme.getTheme().preference).toBe('dark')
  42. localStorage.setItem(STORAGE_KEY, 'sepia')
  43. expect(make().theme.getTheme().preference).toBe('system')
  44. })
  45. it('throws on unknown setTheme ids, duplicate registration, and the system id', () => {
  46. const { theme } = make()
  47. expect(() => { theme.setTheme('sepia') }).toThrow('not registered')
  48. expect(() => theme.register({ id: 'light', colorScheme: 'light', tokens: {} })).toThrow('already registered')
  49. expect(() => theme.register({ id: 'system', colorScheme: 'light', tokens: {} })).toThrow('preference')
  50. })
  51. it('registered themes join the snapshot; disposing the active one resets to default', () => {
  52. const { theme, events } = make()
  53. const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } })
  54. expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia'])
  55. theme.setTheme('sepia')
  56. expect(theme.getTheme().active.tokens['--dsw-alias-bg-base']).toBe('red')
  57. dispose()
  58. expect(theme.getTheme().preference).toBe('system')
  59. expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark'])
  60. expect(localStorage.getItem(STORAGE_KEY)).toBe('system')
  61. // register + set + dispose = three publishes; disposer is idempotent.
  62. expect(events.length).toBe(3)
  63. dispose()
  64. expect(events.length).toBe(3)
  65. })
  66. it('disposing an inactive theme keeps the active preference', () => {
  67. const { theme } = make()
  68. const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: {} })
  69. theme.setTheme('dark')
  70. dispose()
  71. expect(theme.getTheme().preference).toBe('dark')
  72. })
  73. it('revision increases monotonically across every publish', () => {
  74. const { theme, events } = make()
  75. theme.setTheme('dark')
  76. theme.setTheme('light')
  77. const dispose = theme.register({ id: 'sepia', colorScheme: 'dark', tokens: {} })
  78. dispose()
  79. expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4])
  80. })
  81. it('runs without localStorage (node boots): defaults on read, no-op on write', () => {
  82. vi.stubGlobal('localStorage', undefined)
  83. try {
  84. const { theme } = make()
  85. expect(theme.getTheme().preference).toBe('system')
  86. theme.setTheme('dark')
  87. expect(theme.getTheme().preference).toBe('dark')
  88. } finally {
  89. vi.unstubAllGlobals()
  90. }
  91. })
  92. describe('prefers-color-scheme resolution (stubbed matchMedia)', () => {
  93. type Listener = () => void
  94. const stubMedia = (initialMatches: boolean) => {
  95. const listeners = new Set<Listener>()
  96. const media = {
  97. matches: initialMatches,
  98. addEventListener: (_: 'change', fn: Listener) => { listeners.add(fn) },
  99. removeEventListener: (_: 'change', fn: Listener) => { listeners.delete(fn) },
  100. flip() {
  101. this.matches = !this.matches
  102. for (const fn of listeners) fn()
  103. },
  104. listenerCount: () => listeners.size,
  105. }
  106. vi.stubGlobal('matchMedia', () => media)
  107. return media
  108. }
  109. afterEach(() => { vi.unstubAllGlobals() })
  110. it('system resolves against the media query and follows OS flips', () => {
  111. const media = stubMedia(true)
  112. const { theme, events } = make()
  113. expect(theme.getTheme().preference).toBe('system')
  114. expect(theme.getTheme().active.id).toBe('dark')
  115. media.flip()
  116. expect(theme.getTheme().active.id).toBe('light')
  117. expect(events).toHaveLength(1)
  118. })
  119. it('OS flips do not republish while a concrete preference is set', () => {
  120. const media = stubMedia(false)
  121. const { theme, events } = make()
  122. theme.setTheme('light')
  123. expect(events).toHaveLength(1)
  124. media.flip()
  125. expect(events).toHaveLength(1)
  126. expect(theme.getTheme().active.id).toBe('light')
  127. })
  128. it('context dispose releases the media listener', async () => {
  129. const media = stubMedia(false)
  130. const { ctx } = make()
  131. expect(media.listenerCount()).toBe(1)
  132. await ctx.fiber.dispose()
  133. expect(media.listenerCount()).toBe(0)
  134. })
  135. })
  136. })