1
0

apply.client.spec.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. /** Ownerless-copy registrations: the five seats, dictionaries, thunked labels, and HMR recovery. */
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { describe, expect, it, vi } from 'vitest'
  4. import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
  5. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  6. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  7. import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
  8. import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
  9. import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
  10. import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
  11. import { GeneralSection } from '../src/client/GeneralSection.tsx'
  12. import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
  13. import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx'
  14. // These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
  15. // so browser-language detection never runs and a fresh LocaleRuntime opens on
  16. // FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead.
  17. /** The seats this plugin fills for a loopback browser (slot name → expected component). */
  18. const SEATS = [
  19. ['settings.trigger', TriggerContent],
  20. ['settings.header', HeaderContent],
  21. ['settings.action', SettingsDocumentAction],
  22. ['settings.close', CloseLabel],
  23. ['settings.section', GeneralSection],
  24. ] as const
  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 settingsDescribe = vi.fn(() => Promise.resolve({
  32. rpcId: 'settings-general' as never,
  33. result: {
  34. ok: true as const,
  35. value: {
  36. writable: true,
  37. hasDocument: true,
  38. namespaces: [],
  39. },
  40. },
  41. }))
  42. const settingsOpenDocument = vi.fn(() => Promise.resolve({
  43. rpcId: 'settings-open' as never,
  44. result: { ok: true as const, value: { opened: true as const } },
  45. }))
  46. ctx.provide('connection', {
  47. api: { settings: { describe: settingsDescribe, openDocument: settingsOpenDocument } },
  48. isLoopback,
  49. } as never)
  50. new TestRemote(ctx)
  51. await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
  52. return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument }
  53. }
  54. /** Declare the shell's six child slots the way ui-settings' entry does. */
  55. function declare(slots: SlotRegistry): () => void {
  56. return slots.register(
  57. {
  58. name: 'root',
  59. children: {
  60. 'settings.trigger': { kind: 'single', scope: 'root' },
  61. 'settings.header': { kind: 'single', scope: 'root' },
  62. 'settings.action': { kind: 'list', scope: 'root' },
  63. 'settings.close': { kind: 'single', scope: 'root' },
  64. 'settings.section': { kind: 'list', scope: 'root' },
  65. 'settings.onboarding': { kind: 'list', scope: 'root' },
  66. },
  67. } as never,
  68. () => null,
  69. )
  70. }
  71. function generalEntry(slots: SlotRegistry) {
  72. return slots.entries('settings.section').find(e => e.component === GeneralSection)
  73. }
  74. describe('ui-settings-general apply', () => {
  75. it('declares the services it uses', () => {
  76. expect(inject).toEqual(['slots', 'locale', 'connection', 'settingsScope'])
  77. })
  78. it('fills all five seats for declarations before or after apply', async () => {
  79. const before = await bench()
  80. declare(before.slots)
  81. await before.ctx.plugin({ inject: [...inject], apply }).await()
  82. for (const [name, component] of SEATS) {
  83. expect(before.slots.entries(name)[0]!.component).toBe(component)
  84. }
  85. const entry = generalEntry(before.slots)!
  86. expect(entry.options).toMatchObject({ id: 'general', order: 0 })
  87. // The nav label is a locale-following thunk; owners resolve at read time.
  88. expect(resolveSlotLabel(entry.options.label)).toBe('通用设置')
  89. expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
  90. expect(before.slots.entries('settings.general.item')).toEqual([])
  91. // The onboarding hole stays declared for feature-owned steps; this plugin
  92. // no longer seats one.
  93. expect(before.slots.entries('settings.onboarding')).toEqual([])
  94. const action = before.slots.entries('settings.action')[0]!
  95. const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)()
  96. expect(actionInjected.controller.store.getSnapshot().status).toBe('idle')
  97. expect(actionInjected.hooks.snapshot).toBe(actionInjected.controller.store)
  98. // Copy rides the standard locale seat: every seat declares the namespace.
  99. for (const [name] of SEATS) {
  100. expect(before.slots.entries(name)[0]!.locale).toBe('settings')
  101. }
  102. const after = await bench()
  103. await after.ctx.plugin({ inject: [...inject], apply }).await()
  104. for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0)
  105. declare(after.slots)
  106. await Promise.resolve()
  107. for (const [name, component] of SEATS) {
  108. expect(after.slots.entries(name)[0]!.component).toBe(component)
  109. // The self-inflicted ledger notifications hit the duplicate guard.
  110. expect(after.slots.entries(name)).toHaveLength(1)
  111. }
  112. await vi.waitFor(() => {
  113. expect(after.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
  114. })
  115. })
  116. it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => {
  117. const b = await bench()
  118. declare(b.slots)
  119. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  120. await fiber.await()
  121. expect(b.locale.bind('settings')('title')).toBe('设置')
  122. b.locale.setLocale('en')
  123. expect(b.locale.bind('settings')('close')).toBe('Close')
  124. b.locale.setLocale('zh')
  125. await fiber.dispose()
  126. // The (ns, locale) seats are free again — the dictionary disposer ran.
  127. expect(() => b.locale.register('settings', 'zh', {})).not.toThrow()
  128. expect(() => b.locale.register('settings', 'en', {})).not.toThrow()
  129. })
  130. it('the nav label thunk follows the active locale without re-registration', async () => {
  131. const b = await bench()
  132. declare(b.slots)
  133. await b.ctx.plugin({ inject: [...inject], apply }).await()
  134. const zhVersions = SEATS.map(([name]) => b.slots.getVersion(name))
  135. b.locale.setLocale('en')
  136. // No ledger churn: freshness rides the thunk (and the renderer's locale
  137. // subscription), not re-registration.
  138. SEATS.forEach(([name], i) => {
  139. expect(b.slots.getVersion(name)).toBe(zhVersions[i]!)
  140. expect(b.slots.entries(name)).toHaveLength(1)
  141. })
  142. expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General')
  143. b.locale.setLocale('zh')
  144. expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
  145. })
  146. it('reads availability from the shared mirror and follows its reconnect refresh', async () => {
  147. const b = await bench()
  148. declare(b.slots)
  149. await b.ctx.plugin({ inject: [...inject], apply }).await()
  150. const entry = b.slots.entries('settings.action')[0]!
  151. const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)()
  152. // The mirror read once at its own boot; the action's load adds no read.
  153. await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledOnce() })
  154. await controller.load()
  155. expect(b.settingsDescribe).toHaveBeenCalledOnce()
  156. expect(controller.store.getSnapshot().status).toBe('ready')
  157. b.ctx.emit('connection/reset')
  158. await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
  159. })
  160. it('withholds the loopback-only document action off-loopback', async () => {
  161. const b = await bench(false)
  162. declare(b.slots)
  163. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  164. await fiber.await()
  165. expect(b.slots.entries('settings.action')).toEqual([])
  166. expect(b.settingsDescribe).not.toHaveBeenCalled()
  167. await fiber.dispose()
  168. for (const [name] of SEATS) expect(b.slots.entries(name)).toEqual([])
  169. })
  170. it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
  171. const b = await bench()
  172. const redeclare = declare(b.slots)
  173. await b.ctx.plugin({ inject: [...inject], apply }).await()
  174. // Declarer unload: the cascade removes every seat entry and the item
  175. // declaration while our local disposers go stale.
  176. redeclare()
  177. for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
  178. expect(b.slots.spec('settings.general.item')).toBeUndefined()
  179. declare(b.slots)
  180. await Promise.resolve()
  181. for (const [name, component] of SEATS) {
  182. expect(b.slots.entries(name)[0]!.component).toBe(component)
  183. }
  184. expect(b.slots.entries('settings.general.item')).toEqual([])
  185. expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
  186. // The recovered registrations still ride the locale path.
  187. b.locale.setLocale('en')
  188. expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General')
  189. b.locale.setLocale('zh')
  190. })
  191. it('removes every seat and the item declaration on teardown', async () => {
  192. const b = await bench()
  193. declare(b.slots)
  194. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  195. await fiber.await()
  196. expect(b.slots.spec('settings.general.item')).toBeDefined()
  197. await fiber.dispose()
  198. for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
  199. expect(b.slots.spec('settings.general.item')).toBeUndefined()
  200. })
  201. })