theme.client.spec.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  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 {
  6. ThemeSettings,
  7. ThemeSnapshot,
  8. ThemeTokenOverrides,
  9. } from '@deepseek-ai/dsh-client-ui-theme/client'
  10. import { ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
  11. const make = (host = stubSettingsScope<ThemeSettings>()): {
  12. ctx: Context
  13. theme: ThemeRuntime
  14. events: ThemeSnapshot[]
  15. host: StubSettingsScope<ThemeSettings>
  16. } => {
  17. const ctx = new Context()
  18. const events: ThemeSnapshot[] = []
  19. ctx.on('theme/change', (snapshot) => { events.push(snapshot) })
  20. return { ctx, theme: new ThemeRuntime(ctx, host.scope), events, host }
  21. }
  22. describe('ThemeRuntime', () => {
  23. it('defaults to the system preference resolved against prefers-color-scheme', () => {
  24. const { theme } = make()
  25. const snapshot = theme.getTheme()
  26. expect(snapshot.preference).toBe('system')
  27. // jsdom matchMedia is absent; system resolves to light.
  28. expect(snapshot.active.id).toBe('light')
  29. expect(snapshot.active.colorScheme).toBe('light')
  30. expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark'])
  31. })
  32. it('setTheme switches, writes through the scope, republishes, and keeps DOM untouched', () => {
  33. const { theme, events, host } = make()
  34. theme.setTheme('dark')
  35. expect(theme.getTheme().preference).toBe('dark')
  36. expect(theme.getTheme().active.colorScheme).toBe('dark')
  37. expect(host.set).toHaveBeenCalledWith('preference', 'dark')
  38. expect(events).toHaveLength(1)
  39. expect(events[0]).toBe(theme.getTheme())
  40. // The service never touches presentation state.
  41. expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
  42. // Same-value set is a no-op (no extra event).
  43. theme.setTheme('dark')
  44. expect(events).toHaveLength(1)
  45. expect(host.set).toHaveBeenCalledOnce()
  46. })
  47. it('adopts a published Host section without writing it back', () => {
  48. const { theme, events, host } = make()
  49. host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true })
  50. expect(theme.getTheme().preference).toBe('dark')
  51. expect(events).toHaveLength(1)
  52. expect(host.set).not.toHaveBeenCalled()
  53. host.publish({ value: { preference: 'dark' }, revision: 2 })
  54. expect(events).toHaveLength(1)
  55. })
  56. it('adopts a section already standing at construction', () => {
  57. const host = stubSettingsScope<ThemeSettings>()
  58. host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true })
  59. const { theme } = make(host)
  60. expect(theme.getTheme().preference).toBe('dark')
  61. })
  62. it('throws on unknown setTheme ids, duplicate registration, and the system id', () => {
  63. const { theme } = make()
  64. expect(() => { theme.setTheme('sepia') }).toThrow('not registered')
  65. expect(() => theme.register({ id: 'light', colorScheme: 'light', tokens: {} })).toThrow('already registered')
  66. expect(() => theme.register({ id: 'system', colorScheme: 'light', tokens: {} })).toThrow('preference')
  67. })
  68. it('registered themes join the snapshot; disposing the active one resets to default', () => {
  69. const { theme, events, host } = make()
  70. const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } })
  71. expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia'])
  72. theme.setTheme('sepia')
  73. expect(theme.getTheme().active.tokens['--dsw-alias-bg-base']).toBe('red')
  74. dispose()
  75. expect(theme.getTheme().preference).toBe('system')
  76. expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark'])
  77. // Custom ids are in-process extension themes; only the built-in product
  78. // preferences cross the Host settings schema.
  79. expect(host.set).not.toHaveBeenCalled()
  80. // register + set + dispose = three publishes; disposer is idempotent.
  81. expect(events.length).toBe(3)
  82. dispose()
  83. expect(events.length).toBe(3)
  84. })
  85. it('disposing an inactive theme keeps the active preference', () => {
  86. const { theme } = make()
  87. const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: {} })
  88. theme.setTheme('dark')
  89. dispose()
  90. expect(theme.getTheme().preference).toBe('dark')
  91. })
  92. it('revision increases monotonically across every publish', () => {
  93. const { theme, events } = make()
  94. theme.setTheme('dark')
  95. theme.setTheme('light')
  96. const dispose = theme.register({ id: 'sepia', colorScheme: 'dark', tokens: {} })
  97. dispose()
  98. expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4])
  99. })
  100. it('stacks reversible token overrides in call order and selects the active palette value', () => {
  101. const { theme } = make()
  102. const firstTokens: ThemeTokenOverrides = {
  103. '--shared': { light: 'first-light', dark: 'first-dark' },
  104. '--first': { light: 'first-only-light', dark: 'first-only-dark' },
  105. }
  106. const disposeFirst = theme.overrideTokens('first', firstTokens)
  107. firstTokens['--shared']!.light = 'mutated-after-call'
  108. const disposeSecond = theme.overrideTokens('second', {
  109. '--shared': { light: 'second-light', dark: 'second-dark' },
  110. })
  111. expect(theme.getTheme().active.tokens).toMatchObject({
  112. '--first': 'first-only-light',
  113. '--shared': 'second-light',
  114. })
  115. theme.setTheme('dark')
  116. expect(theme.getTheme().active.tokens).toMatchObject({
  117. '--first': 'first-only-dark',
  118. '--shared': 'second-dark',
  119. })
  120. disposeSecond()
  121. expect(theme.getTheme().active.tokens['--shared']).toBe('first-dark')
  122. disposeFirst()
  123. expect(theme.getTheme().active.tokens['--shared']).toBeUndefined()
  124. })
  125. it('replacing one source leaves its stale disposer harmless', () => {
  126. const { theme, events } = make()
  127. const stale = theme.overrideTokens('package', {
  128. '--old': { light: 'old-light', dark: 'old-dark' },
  129. })
  130. const current = theme.overrideTokens('package', {
  131. '--new': { light: 'new-light', dark: 'new-dark' },
  132. })
  133. stale()
  134. expect(theme.getTheme().active.tokens).toEqual({ '--new': 'new-light' })
  135. current()
  136. current()
  137. expect(theme.getTheme().active.tokens).toEqual({})
  138. expect(events).toHaveLength(3)
  139. })
  140. it('exports sorted built-in, registered, and override-only token descriptions as copies', () => {
  141. const { theme } = make()
  142. theme.register({
  143. id: 'custom',
  144. colorScheme: 'light',
  145. tokens: {
  146. '--dsw-alias-bg-base': 'duplicate-built-in',
  147. '--registered': 'registered',
  148. },
  149. })
  150. theme.overrideTokens('package', {
  151. '--registered': { light: 'duplicate-registered', dark: 'duplicate-registered' },
  152. semanticAccent: { light: 'pink', dark: 'red' },
  153. })
  154. const tokens = theme.exportInspectTokens()
  155. expect(tokens.map(token => token.name)).toEqual([...tokens.map(token => token.name)].sort())
  156. expect(tokens.find(token => token.name === '--registered')).toMatchObject({
  157. valueType: 'CSS value',
  158. cssVariable: '--registered',
  159. })
  160. const semantic = tokens.find(token => token.name === 'semanticAccent')
  161. expect(semantic).toMatchObject({ valueType: 'CSS value' })
  162. expect(semantic).not.toHaveProperty('cssVariable')
  163. expect(tokens.filter(token => token.name === '--dsw-alias-bg-base')).toHaveLength(1)
  164. tokens[0]!.description = 'caller mutation'
  165. expect(theme.exportInspectTokens()[0]!.description).not.toBe('caller mutation')
  166. })
  167. it('rejects every malformed token override value with a teaching error', () => {
  168. const { theme } = make()
  169. const override = (value: unknown): void => {
  170. theme.overrideTokens('package', { '--bad': value } as unknown as ThemeTokenOverrides)
  171. }
  172. expect(() => { override('red') }).toThrow(/bare string.*light.*dark/)
  173. for (const value of [1, null, {}, { light: 1, dark: 'dark' }, { light: 'light' }]) {
  174. expect(() => { override(value) }).toThrow(/must map to a \{ light, dark \} pair/)
  175. }
  176. })
  177. it('context dispose releases the scope subscription', async () => {
  178. const { ctx, host } = make()
  179. expect(host.listenerCount()).toBe(1)
  180. await ctx.fiber.dispose()
  181. expect(host.listenerCount()).toBe(0)
  182. })
  183. describe('prefers-color-scheme resolution (stubbed matchMedia)', () => {
  184. type Listener = () => void
  185. const stubMedia = (initialMatches: boolean) => {
  186. const listeners = new Set<Listener>()
  187. const media = {
  188. matches: initialMatches,
  189. addEventListener: (_: 'change', fn: Listener) => { listeners.add(fn) },
  190. removeEventListener: (_: 'change', fn: Listener) => { listeners.delete(fn) },
  191. flip() {
  192. this.matches = !this.matches
  193. for (const fn of listeners) fn()
  194. },
  195. listenerCount: () => listeners.size,
  196. }
  197. vi.stubGlobal('matchMedia', () => media)
  198. return media
  199. }
  200. afterEach(() => { vi.unstubAllGlobals() })
  201. it('system resolves against the media query and follows OS flips', () => {
  202. const media = stubMedia(true)
  203. const { theme, events } = make()
  204. expect(theme.getTheme().preference).toBe('system')
  205. expect(theme.getTheme().active.id).toBe('dark')
  206. media.flip()
  207. expect(theme.getTheme().active.id).toBe('light')
  208. expect(events).toHaveLength(1)
  209. })
  210. it('OS flips do not republish while a concrete preference is set', () => {
  211. const media = stubMedia(false)
  212. const { theme, events } = make()
  213. theme.setTheme('light')
  214. expect(events).toHaveLength(1)
  215. media.flip()
  216. expect(events).toHaveLength(1)
  217. expect(theme.getTheme().active.id).toBe('light')
  218. })
  219. it('context dispose releases the media listener', async () => {
  220. const media = stubMedia(false)
  221. const { ctx } = make()
  222. expect(media.listenerCount()).toBe(1)
  223. await ctx.fiber.dispose()
  224. expect(media.listenerCount()).toBe(0)
  225. })
  226. })
  227. })