apply.client.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. /** ui-theme apply wiring: service provision, settings dictionaries riding the
  2. * locale service, declaration-aware Appearance row registration, snapshot
  3. * projection into the row store, and HMR collapse recovery. */
  4. import { Context } from '@deepseek-ai/cordis'
  5. import { describe, expect, it, vi } from 'vitest'
  6. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  7. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  8. import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
  9. import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
  10. import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client'
  11. import type { AppearanceRowInjected, FontSizeRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
  12. import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts'
  13. import { AppearanceRow } from '../src/client/AppearanceRow.tsx'
  14. import { FontSizeRow } from '../src/client/FontSizeRow.tsx'
  15. import type { createAppearanceRowStore, createFontSizeRowStore } from '../src/client/settings-store.ts'
  16. // These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
  17. // so browser-language detection never runs and a fresh LocaleRuntime opens on
  18. // FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead.
  19. const SLOT = 'settings.general.item'
  20. function deferred<T>() {
  21. let resolve!: (value: T) => void
  22. const promise = new Promise<T>((done) => { resolve = done })
  23. return { promise, resolve }
  24. }
  25. async function bench(isLoopback = true) {
  26. const ctx = new Context()
  27. await ctx.plugin(SlotRegistry).await()
  28. const locale = new LocaleRuntime(ctx)
  29. locale.setLocale('zh')
  30. ctx.provide('locale', locale)
  31. const section: Record<string, unknown> = { preference: 'system', fontSize: 14 }
  32. const namespace = () => ({
  33. ns: THEME_SETTINGS_NAMESPACE,
  34. schema: ThemeSettingsSchema.toJSON(),
  35. value: { ...section },
  36. applies: 'live' as const,
  37. secrets: [],
  38. revision: 0,
  39. })
  40. const describe = vi.fn(() => Promise.resolve({
  41. ok: true as const,
  42. value: { writable: true, hasDocument: true, namespaces: [namespace()] },
  43. }))
  44. const mutate = vi.fn((_ns: string, ops: { path: string[]; value: unknown }[]) => {
  45. const op = ops[0]!
  46. section[op.path[0]!] = op.value
  47. return Promise.resolve({ ok: true as const, value: namespace() })
  48. })
  49. const events = new TestRemote(ctx, { settings: { describe, mutate } })
  50. events.$host = { home: undefined, isLoopback }
  51. await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
  52. return {
  53. ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, events,
  54. setHostSection: (next: Record<string, unknown>) => { Object.assign(section, next) },
  55. }
  56. }
  57. /** Stand in for the settings shell: declare the General item slot from root. */
  58. function declareItems(slots: SlotRegistry): () => void {
  59. return slots.register(
  60. { name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never,
  61. () => null,
  62. )
  63. }
  64. /** Mirror the framework's inject choreography: bake a real instance from the
  65. * declared handle and hand its actions to the entry's inject factory. */
  66. function faceOf(slots: SlotRegistry) {
  67. const entry = slots.entries(SLOT).find(e => e.component === AppearanceRow)!
  68. const handle = entry.store as ReturnType<typeof createAppearanceRowStore>
  69. const instance = handle.create()
  70. const face = (entry.inject as unknown as (a: typeof instance.actions) => AppearanceRowInjected)(instance.actions)
  71. return { entry, instance, face }
  72. }
  73. /** The same choreography for the font-size row entry. */
  74. function fontSizeFaceOf(slots: SlotRegistry) {
  75. const entry = slots.entries(SLOT).find(e => e.component === FontSizeRow)!
  76. const handle = entry.store as ReturnType<typeof createFontSizeRowStore>
  77. const instance = handle.create()
  78. const face = (entry.inject as unknown as (a: typeof instance.actions) => FontSizeRowInjected)(instance.actions)
  79. return { entry, instance, face }
  80. }
  81. describe('ui-theme apply', () => {
  82. it('declares the slot and locale services', () => {
  83. expect(inject).toEqual(['slots', 'locale', 'remote', 'settingsScope'])
  84. })
  85. it('provides the service, registers localized copy, and registers both rows (declaration before or after apply)', async () => {
  86. const before = await bench()
  87. declareItems(before.slots)
  88. await before.ctx.plugin({ inject: [...inject], apply }).await()
  89. expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('外观')
  90. expect(before.locale.bind(SETTINGS_NS)('fontSize.title')).toBe('字号大小')
  91. before.locale.setLocale('en')
  92. expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('Appearance')
  93. const entry = before.slots.entries(SLOT).find(e => e.component === AppearanceRow)!
  94. expect(entry.options).toMatchObject({ id: 'appearance', order: 10 })
  95. const fontEntry = before.slots.entries(SLOT).find(e => e.component === FontSizeRow)!
  96. expect(fontEntry.options).toMatchObject({ id: 'font-size', order: 11 })
  97. expect(fontEntry.locale).toBe(SETTINGS_NS)
  98. const after = await bench()
  99. const fiber = after.ctx.plugin({ inject: [...inject], apply })
  100. await fiber.await()
  101. expect(after.slots.entries(SLOT)).toHaveLength(0)
  102. declareItems(after.slots)
  103. await Promise.resolve()
  104. expect(after.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true)
  105. expect(after.slots.entries(SLOT).some(e => e.component === FontSizeRow)).toBe(true)
  106. })
  107. it('projects service snapshots into the row store and routes face writes back', async () => {
  108. const b = await bench()
  109. declareItems(b.slots)
  110. await b.ctx.plugin({ inject: [...inject], apply }).await()
  111. const theme = b.ctx.get('theme') as ThemeRuntime
  112. // An event ahead of any inject hits the unbound-actions arm.
  113. theme.setTheme('dark')
  114. const { instance, face } = faceOf(b.slots)
  115. // The inject-time re-sync sealed the init window: the mirror is current.
  116. expect(instance.getSnapshot().preference).toBe('dark')
  117. // Copy rides the standard locale seat: the entry declares the namespace.
  118. expect(b.slots.entries(SLOT).find(e => e.component === AppearanceRow)!.locale).toBe(SETTINGS_NS)
  119. face.setTheme('system')
  120. expect(theme.getTheme().preference).toBe('system')
  121. expect(instance.getSnapshot().preference).toBe('system')
  122. await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) })
  123. })
  124. it('projects font-size snapshots into its row store and routes face writes back', async () => {
  125. const b = await bench()
  126. declareItems(b.slots)
  127. await b.ctx.plugin({ inject: [...inject], apply }).await()
  128. const theme = b.ctx.get('theme') as ThemeRuntime
  129. // An event ahead of any inject hits the unbound-actions arm.
  130. theme.setFontSize(16)
  131. const { instance, face } = fontSizeFaceOf(b.slots)
  132. // The inject-time re-sync sealed the init window: the mirror is current.
  133. expect(instance.getSnapshot().fontSize).toBe(16)
  134. face.setFontSize(12)
  135. expect(theme.getTheme().fontSize).toBe(12)
  136. expect(instance.getSnapshot().fontSize).toBe(12)
  137. await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) })
  138. })
  139. it('loads Host settings at boot, refreshes its namespace, and keeps remote browsers process-local', async () => {
  140. const b = await bench()
  141. // The shared mirror read once at bench time; a Host-side change reaches it
  142. // through the document invalidation, exactly as production announces one.
  143. b.setHostSection({ preference: 'dark', fontSize: 17 })
  144. b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
  145. declareItems(b.slots)
  146. await b.ctx.plugin({ inject: [...inject], apply }).await()
  147. const theme = b.ctx.get('theme') as ThemeRuntime
  148. await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
  149. expect(theme.getTheme().fontSize).toBe(17)
  150. // The mirror refreshes on every document commit (ns-agnostic); the scope's
  151. // derived value only moves when its own namespace changed.
  152. b.events.emit('settings/document-updated', ['unrelated', 0])
  153. await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(3) })
  154. expect(theme.getTheme().preference).toBe('dark')
  155. b.setHostSection({ preference: 'light' })
  156. b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
  157. await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') })
  158. b.setHostSection({ preference: 'dark' })
  159. b.ctx.emit('connection/reset')
  160. await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
  161. const remote = await bench(false)
  162. declareItems(remote.slots)
  163. await remote.ctx.plugin({ inject: [...inject], apply }).await()
  164. const remoteTheme = remote.ctx.get('theme') as ThemeRuntime
  165. remoteTheme.setTheme('dark')
  166. await Promise.resolve()
  167. expect(remote.describe).not.toHaveBeenCalled()
  168. expect(remote.mutate).not.toHaveBeenCalled()
  169. })
  170. it('activates before a slow settings refresh and converges when it settles', async () => {
  171. const b = await bench()
  172. b.setHostSection({ preference: 'dark' })
  173. const describe = b.describe.getMockImplementation()!
  174. const pending = deferred<Awaited<ReturnType<typeof describe>>>()
  175. b.describe.mockImplementationOnce(() => pending.promise)
  176. // The refresh hangs on the wire; the mirror keeps serving the last good
  177. // answer, so activation never blocks on the settings transport.
  178. b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
  179. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  180. await fiber.await()
  181. const theme = b.ctx.get('theme') as ThemeRuntime
  182. expect(theme.getTheme().preference).toBe('system')
  183. pending.resolve(await describe())
  184. await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
  185. await fiber.dispose()
  186. })
  187. it('ignores an invalid preference crossing the settings wire', async () => {
  188. const b = await bench()
  189. b.setHostSection({ preference: 'sepia' })
  190. b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
  191. await b.ctx.plugin({ inject: [...inject], apply }).await()
  192. const theme = b.ctx.get('theme') as ThemeRuntime
  193. await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(2) })
  194. expect(theme.getTheme().preference).toBe('system')
  195. })
  196. it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
  197. const b = await bench()
  198. const host = declareItems(b.slots)
  199. await b.ctx.plugin({ inject: [...inject], apply }).await()
  200. expect(b.slots.entries(SLOT)).toHaveLength(2)
  201. // Collapse: the declarer dies, the cascade removes our entries while the
  202. // apply closure still holds its (now stale) disposers.
  203. host()
  204. expect(b.slots.entries(SLOT)).toHaveLength(0)
  205. declareItems(b.slots)
  206. await Promise.resolve()
  207. expect(b.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true)
  208. expect(b.slots.entries(SLOT).some(e => e.component === FontSizeRow)).toBe(true)
  209. })
  210. it('teardown removes the rows and the dictionaries; teardown without a declaration is quiet', async () => {
  211. const b = await bench()
  212. declareItems(b.slots)
  213. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  214. await fiber.await()
  215. expect(b.slots.entries(SLOT)).toHaveLength(2)
  216. await fiber.dispose()
  217. expect(b.slots.entries(SLOT)).toHaveLength(0)
  218. // Dictionary disposal: translation falls back to the bare key.
  219. expect(b.locale.bind(SETTINGS_NS)('appearance.title')).toBe('appearance.title')
  220. // Never-declared bench: the effect disposer's dispose arm stays undefined.
  221. const quiet = await bench()
  222. const f2 = quiet.ctx.plugin({ inject: [...inject], apply })
  223. await f2.await()
  224. await f2.dispose()
  225. expect(quiet.slots.entries(SLOT)).toHaveLength(0)
  226. })
  227. })