apply.client.spec.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. /** locale apply wiring: service + dictionaries provision, declaration-aware
  2. * Language row registration, snapshot projection into the row store, and
  3. * recovery after an HMR collapse of the declaring entry. */
  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 { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
  8. import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
  9. import {
  10. apply, inject, SETTINGS_NS,
  11. } from '@deepseek-ai/dsh-client-locale/client'
  12. import type { LanguageRowInjected, LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  13. import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '../src/locale-settings.ts'
  14. import { LanguageRow } from '../src/client/LanguageRow.tsx'
  15. import type { createLanguageRowStore } from '../src/client/settings-store.ts'
  16. const SLOT = 'settings.general.item'
  17. async function bench() {
  18. const ctx = new Context()
  19. await ctx.plugin(SlotRegistry).await()
  20. let preference: string | undefined
  21. let revision = 0
  22. const namespace = () => ({
  23. ns: LOCALE_SETTINGS_NAMESPACE,
  24. schema: LocaleSettingsSchema.toJSON(),
  25. value: preference === undefined ? {} : { preference },
  26. applies: 'live' as const,
  27. secrets: [],
  28. revision,
  29. })
  30. const describe = vi.fn(async () => ({
  31. rpcId: 'locale-describe' as never,
  32. result: {
  33. ok: true as const,
  34. value: { writable: true, hasDocument: true, namespaces: [namespace()] },
  35. },
  36. }))
  37. const mutate = vi.fn(async (request: { ops: { value: string }[] }) => {
  38. preference = request.ops[0]!.value
  39. revision += 1
  40. return {
  41. rpcId: 'locale-mutate' as never,
  42. result: { ok: true as const, value: namespace() },
  43. }
  44. })
  45. ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never)
  46. const events = new TestRemote(ctx)
  47. await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
  48. return {
  49. ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate, events,
  50. setHostPreference: (next: string | undefined) => { preference = next; revision += 1 },
  51. }
  52. }
  53. /** Stand in for the settings shell: declare the General item slot from root. */
  54. function declareItems(slots: SlotRegistry): () => void {
  55. return slots.register(
  56. { name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never,
  57. () => null,
  58. )
  59. }
  60. /** Mirror the framework's inject choreography: bake a real instance from the
  61. * declared handle and hand its actions to the entry's inject factory. */
  62. function faceOf(slots: SlotRegistry) {
  63. const entry = slots.entries(SLOT).find(e => e.component === LanguageRow)!
  64. const handle = entry.store as ReturnType<typeof createLanguageRowStore>
  65. const instance = handle.create()
  66. const face = (entry.inject as unknown as (a: typeof instance.actions) => LanguageRowInjected)(instance.actions)
  67. return { entry, instance, face }
  68. }
  69. describe('locale apply', () => {
  70. // These are wiring specs, not default-language specs. A fresh LocaleRuntime
  71. // with no jsdom `window` skips browser detection and opens on FALLBACK_LOCALE
  72. // (en); each test that reads localized copy stages its locale explicitly via
  73. // setLocale/Host preference instead of leaning on a dead browser pin.
  74. it('declares the slot service', () => {
  75. expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope'])
  76. })
  77. it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => {
  78. const before = await bench()
  79. declareItems(before.slots)
  80. await before.ctx.plugin({ inject: [...inject], apply }).await()
  81. const locale = before.ctx.get('locale') as LocaleRuntime
  82. // Base dictionaries are registered: the (ns, locale) seats are occupied.
  83. expect(() => locale.register('common', 'zh', {})).toThrow('already has locale')
  84. expect(() => locale.register('common', 'en', {})).toThrow('already has locale')
  85. // The lane has no jsdom `window`, so detection never runs and a fresh
  86. // service opens on FALLBACK_LOCALE (en); read the zh side explicitly.
  87. locale.setLocale('zh')
  88. expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
  89. const entry = before.slots.entries(SLOT).find(e => e.component === LanguageRow)!
  90. expect(entry.options).toMatchObject({ id: 'language', order: 0 })
  91. const after = await bench()
  92. const fiber = after.ctx.plugin({ inject: [...inject], apply })
  93. await fiber.await()
  94. expect(after.slots.entries(SLOT)).toHaveLength(0)
  95. declareItems(after.slots)
  96. await Promise.resolve()
  97. expect(after.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true)
  98. })
  99. it('projects service snapshots into the row store and routes face writes back', async () => {
  100. const b = await bench()
  101. declareItems(b.slots)
  102. await b.ctx.plugin({ inject: [...inject], apply }).await()
  103. const locale = b.ctx.get('locale') as LocaleRuntime
  104. // An event ahead of any inject hits the unbound-actions arm.
  105. locale.setLocale('en')
  106. const { entry, instance, face } = faceOf(b.slots)
  107. // The inject-time re-sync sealed the init window: the mirror is current.
  108. expect(instance.getSnapshot().active).toBe('en')
  109. expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en'])
  110. // Copy rides the standard locale seat: the entry declares the namespace.
  111. expect(entry.locale).toBe(SETTINGS_NS)
  112. expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language')
  113. face.setLocale('zh')
  114. expect(locale.getLocale().active).toBe('zh')
  115. expect(instance.getSnapshot().active).toBe('zh')
  116. expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
  117. await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) })
  118. })
  119. it('loads and refreshes the explicit Host preference after nonblocking activation', async () => {
  120. const b = await bench()
  121. // The shared mirror read once at bench time; a Host-side change reaches it
  122. // through the document invalidation, exactly as production announces one.
  123. // Preference must differ from the provisional locale (FALLBACK_LOCALE = en
  124. // with no window), or clearing it below would be unobservable.
  125. b.setHostPreference('zh')
  126. b.events.emit('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
  127. declareItems(b.slots)
  128. await b.ctx.plugin({ inject: [...inject], apply }).await()
  129. const locale = b.ctx.get('locale') as LocaleRuntime
  130. await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
  131. // Cleared preference falls back to the provisional locale.
  132. b.setHostPreference(undefined)
  133. b.events.emit('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
  134. await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
  135. // Re-selecting zh after the clear is an explicit pick of the provisional
  136. // value and must persist as a written preference.
  137. b.setHostPreference('zh')
  138. b.events.emit('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
  139. await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
  140. expect(b.describe).toHaveBeenCalledTimes(4)
  141. })
  142. it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
  143. const b = await bench()
  144. const host = declareItems(b.slots)
  145. await b.ctx.plugin({ inject: [...inject], apply }).await()
  146. expect(b.slots.entries(SLOT)).toHaveLength(1)
  147. // Collapse: the declarer dies, the cascade removes our entry while the
  148. // apply closure still holds its (now stale) disposer.
  149. host()
  150. expect(b.slots.entries(SLOT)).toHaveLength(0)
  151. declareItems(b.slots)
  152. await Promise.resolve()
  153. expect(b.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true)
  154. })
  155. it('teardown removes the row; teardown without a declaration is quiet', async () => {
  156. const b = await bench()
  157. declareItems(b.slots)
  158. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  159. await fiber.await()
  160. expect(b.slots.entries(SLOT)).toHaveLength(1)
  161. await fiber.dispose()
  162. expect(b.slots.entries(SLOT)).toHaveLength(0)
  163. // Never-declared bench: the effect disposer's dispose arm stays undefined.
  164. const quiet = await bench()
  165. const f2 = quiet.ctx.plugin({ inject: [...inject], apply })
  166. await f2.await()
  167. await f2.dispose()
  168. expect(quiet.slots.entries(SLOT)).toHaveLength(0)
  169. })
  170. })