apply.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. /** Ownerless-copy registrations: the six seats, dictionaries, thunked labels, and HMR recovery. */
  2. import { Context } from 'cordis'
  3. import { describe, expect, it, vi } from 'vitest'
  4. import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
  5. import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  6. import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
  7. import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
  8. import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
  9. import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
  10. import { GeneralSection } from '../src/client/GeneralSection.tsx'
  11. import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
  12. import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx'
  13. import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
  14. import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx'
  15. import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
  16. // The service reads its initial locale from the browser; these specs assert
  17. // the shipped Chinese copy, so they state the browser they assume.
  18. usePinnedBrowserLanguages('zh-CN')
  19. /** The seats this plugin fills for a loopback browser (slot name → expected component). */
  20. const SEATS = [
  21. ['settings.trigger', TriggerContent],
  22. ['settings.header', HeaderContent],
  23. ['settings.action', SettingsDocumentAction],
  24. ['settings.close', CloseLabel],
  25. ['settings.section', GeneralSection],
  26. ['settings.onboarding', WelcomeNotice],
  27. ] as const
  28. async function bench(isLoopback = true) {
  29. const ctx = new Context()
  30. await ctx.plugin(SlotsService).await()
  31. const locale = new LocaleService(ctx)
  32. ctx.provide('locale', locale)
  33. const settingsDescribe = vi.fn(() => Promise.resolve({
  34. rpcId: 'settings-general' as never,
  35. result: {
  36. ok: true as const,
  37. value: {
  38. writable: true,
  39. hasDocument: true,
  40. namespaces: [{
  41. ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
  42. schema: {},
  43. value: {},
  44. applies: 'live' as const,
  45. secrets: [],
  46. revision: 0,
  47. }],
  48. },
  49. },
  50. }))
  51. const settingsOpenDocument = vi.fn(() => Promise.resolve({
  52. rpcId: 'settings-open' as never,
  53. result: { ok: true as const, value: { opened: true as const } },
  54. }))
  55. ctx.provide('connection', {
  56. api: { settings: { describe: settingsDescribe, openDocument: settingsOpenDocument } },
  57. isLoopback,
  58. } as never)
  59. return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe, settingsOpenDocument }
  60. }
  61. /** Declare the shell's six child slots the way ui-settings' entry does. */
  62. function declare(slots: SlotsService): () => void {
  63. return slots.register(
  64. {
  65. name: 'root',
  66. children: {
  67. 'settings.trigger': { kind: 'single', scope: 'root' },
  68. 'settings.header': { kind: 'single', scope: 'root' },
  69. 'settings.action': { kind: 'list', scope: 'root' },
  70. 'settings.close': { kind: 'single', scope: 'root' },
  71. 'settings.section': { kind: 'list', scope: 'root' },
  72. 'settings.onboarding': { kind: 'list', scope: 'root' },
  73. },
  74. } as never,
  75. () => null,
  76. )
  77. }
  78. function generalEntry(slots: SlotsService) {
  79. return slots.entries('settings.section').find(e => e.component === GeneralSection)
  80. }
  81. describe('ui-settings-general apply', () => {
  82. it('declares the services it uses', () => {
  83. expect(inject).toEqual(['slots', 'locale', 'connection'])
  84. })
  85. it('fills all six seats for declarations before or after apply', async () => {
  86. const before = await bench()
  87. declare(before.slots)
  88. await before.ctx.plugin({ inject: [...inject], apply }).await()
  89. for (const [name, component] of SEATS) {
  90. expect(before.slots.entries(name)[0]!.component).toBe(component)
  91. }
  92. const entry = generalEntry(before.slots)!
  93. expect(entry.options).toMatchObject({ id: 'general', order: 0 })
  94. // The nav label is a locale-following thunk; owners resolve at read time.
  95. expect(resolveSlotLabel(entry.options.label)).toBe('通用设置')
  96. expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
  97. expect(before.slots.entries('settings.general.item')).toEqual([])
  98. const welcome = before.slots.entries('settings.onboarding')[0]!
  99. expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 })
  100. const action = before.slots.entries('settings.action')[0]!
  101. const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)()
  102. expect(actionInjected.controller.store.getSnapshot().status).toBe('idle')
  103. expect(actionInjected.useSnapshot).toEqual(expect.any(Function))
  104. // Copy rides the standard locale seat: every seat declares the namespace.
  105. for (const [name] of SEATS) {
  106. expect(before.slots.entries(name)[0]!.locale).toBe('settings')
  107. }
  108. const after = await bench()
  109. await after.ctx.plugin({ inject: [...inject], apply }).await()
  110. for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0)
  111. declare(after.slots)
  112. await Promise.resolve()
  113. for (const [name, component] of SEATS) {
  114. expect(after.slots.entries(name)[0]!.component).toBe(component)
  115. // The self-inflicted ledger notifications hit the duplicate guard.
  116. expect(after.slots.entries(name)).toHaveLength(1)
  117. }
  118. await vi.waitFor(() => {
  119. expect(after.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
  120. })
  121. })
  122. it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => {
  123. const b = await bench()
  124. declare(b.slots)
  125. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  126. await fiber.await()
  127. expect(b.locale.bind('settings')('title')).toBe('设置')
  128. b.locale.setLocale('en')
  129. expect(b.locale.bind('settings')('close')).toBe('Close')
  130. b.locale.setLocale('zh')
  131. await fiber.dispose()
  132. // The (ns, locale) seats are free again — the dictionary disposer ran.
  133. expect(() => b.locale.register('settings', 'zh', {})).not.toThrow()
  134. expect(() => b.locale.register('settings', 'en', {})).not.toThrow()
  135. })
  136. it('the nav label thunk follows the active locale without re-registration', async () => {
  137. const b = await bench()
  138. declare(b.slots)
  139. await b.ctx.plugin({ inject: [...inject], apply }).await()
  140. const zhVersions = SEATS.map(([name]) => b.slots.getVersion(name))
  141. b.locale.setLocale('en')
  142. // No ledger churn: freshness rides the thunk (and the renderer's locale
  143. // subscription), not re-registration.
  144. SEATS.forEach(([name], i) => {
  145. expect(b.slots.getVersion(name)).toBe(zhVersions[i]!)
  146. expect(b.slots.entries(name)).toHaveLength(1)
  147. })
  148. expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General')
  149. b.locale.setLocale('zh')
  150. expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
  151. })
  152. it('refreshes loaded welcome state only for its settings namespace or a reconnect', async () => {
  153. const b = await bench()
  154. declare(b.slots)
  155. await b.ctx.plugin({ inject: [...inject], apply }).await()
  156. const entry = b.slots.entries('settings.onboarding')[0]!
  157. const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
  158. await controller.load()
  159. expect(b.settingsDescribe).toHaveBeenCalledOnce()
  160. b.ctx.emit('settings/changed', 'unrelated')
  161. expect(b.settingsDescribe).toHaveBeenCalledOnce()
  162. b.ctx.emit('settings/changed', WELCOME_NOTICE_SETTINGS_NAMESPACE)
  163. await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
  164. b.ctx.emit('connection/reset')
  165. await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
  166. })
  167. it('refreshes loaded document availability on reconnect without reading it eagerly', async () => {
  168. const b = await bench()
  169. declare(b.slots)
  170. await b.ctx.plugin({ inject: [...inject], apply }).await()
  171. const entry = b.slots.entries('settings.action')[0]!
  172. const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)()
  173. b.ctx.emit('connection/reset')
  174. expect(b.settingsDescribe).not.toHaveBeenCalled()
  175. await controller.load()
  176. expect(b.settingsDescribe).toHaveBeenCalledOnce()
  177. b.ctx.emit('connection/reset')
  178. await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
  179. })
  180. it('keeps remote welcome acknowledgement process-local', async () => {
  181. const b = await bench(false)
  182. declare(b.slots)
  183. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  184. await fiber.await()
  185. const entry = b.slots.entries('settings.onboarding')[0]!
  186. const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
  187. await controller.load()
  188. await expect(controller.acknowledge()).resolves.toBe(true)
  189. expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
  190. expect(b.settingsDescribe).not.toHaveBeenCalled()
  191. expect(b.slots.entries('settings.action')).toEqual([])
  192. await fiber.dispose()
  193. for (const [name] of SEATS) expect(b.slots.entries(name)).toEqual([])
  194. })
  195. it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
  196. const b = await bench()
  197. const redeclare = declare(b.slots)
  198. await b.ctx.plugin({ inject: [...inject], apply }).await()
  199. // Declarer unload: the cascade removes every seat entry and the item
  200. // declaration while our local disposers go stale.
  201. redeclare()
  202. for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
  203. expect(b.slots.spec('settings.general.item')).toBeUndefined()
  204. declare(b.slots)
  205. await Promise.resolve()
  206. for (const [name, component] of SEATS) {
  207. expect(b.slots.entries(name)[0]!.component).toBe(component)
  208. }
  209. expect(b.slots.entries('settings.general.item')).toEqual([])
  210. expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
  211. // The recovered registrations still ride the locale path.
  212. b.locale.setLocale('en')
  213. expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General')
  214. b.locale.setLocale('zh')
  215. })
  216. it('removes every seat and the item declaration on teardown', async () => {
  217. const b = await bench()
  218. declare(b.slots)
  219. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  220. await fiber.await()
  221. expect(b.slots.spec('settings.general.item')).toBeDefined()
  222. await fiber.dispose()
  223. for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
  224. expect(b.slots.spec('settings.general.item')).toBeUndefined()
  225. })
  226. })