theme.spec.ts 6.5 KB

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