shell.client.spec.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. /** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { describe, expect, it, vi } from 'vitest'
  4. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  5. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  6. import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
  7. import { apply, inject } from '../src/client/index.ts'
  8. import type { SettingsRootInjected } from '../src/client/shell-contract.ts'
  9. import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
  10. async function bench() {
  11. const ctx = new Context()
  12. await ctx.plugin(SlotRegistry).await()
  13. // Copy machinery the shell only reads a revision from; the real locale
  14. // plugin would drag its own settings-row dependencies into this bench.
  15. ctx.provide('locale', {
  16. register: () => () => {},
  17. bind: () => (key: string) => key,
  18. getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }),
  19. subscribe: () => () => {},
  20. } as never)
  21. // The shell mounts ui-settings, which injects `remote.settings`; without the
  22. // namespace provided its fiber parks and no slot is ever declared.
  23. const settings = {
  24. describe: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'no settings', {}) }),
  25. }
  26. const reconnect = vi.fn()
  27. const connectionState = {
  28. getSnapshot: () => 'connected' as const,
  29. subscribe: () => () => {},
  30. }
  31. ctx.provide('connection', { state: connectionState, reconnect } as never)
  32. ctx.provide('remote', {
  33. $on: () => () => {},
  34. $host: { home: undefined, isLoopback: false },
  35. settings,
  36. } as never)
  37. ctx.provide('remote.settings', settings as never)
  38. await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
  39. return { ctx, slots: ctx.get('slots') as SlotRegistry, connectionState, reconnect }
  40. }
  41. function declare(slots: SlotRegistry): () => void {
  42. return slots.register(
  43. { name: 'root', children: { 'sidebar.settings': { kind: 'single', scope: 'root' } } } as never,
  44. () => null,
  45. )
  46. }
  47. function injectedOf(slots: SlotRegistry): SettingsRootInjected {
  48. const entry = slots.entries('sidebar.settings')[0]!
  49. return (entry.inject as () => SettingsRootInjected)()
  50. }
  51. /** The shell's child declarations (chrome, actions, sections, and onboarding overlays). */
  52. const CHILD_SPECS = {
  53. 'settings.trigger': { kind: 'single', scope: 'root' },
  54. 'settings.header': { kind: 'single', scope: 'root' },
  55. 'settings.action': { kind: 'list', scope: 'root' },
  56. 'settings.close': { kind: 'single', scope: 'root' },
  57. 'settings.section': { kind: 'list', scope: 'root' },
  58. 'settings.onboarding': { kind: 'list', scope: 'root' },
  59. } as const
  60. describe('ui-settings apply', () => {
  61. it('declares only the slot registry (a pure composition face, no locale)', () => {
  62. expect(inject).toEqual([
  63. 'slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope',
  64. ])
  65. })
  66. it('registers the shell and declares every child slot, before or after the declaration', async () => {
  67. const before = await bench()
  68. declare(before.slots)
  69. await before.ctx.plugin({ inject: [...inject], apply }).await()
  70. expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
  71. for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
  72. expect(before.slots.spec(name)).toEqual(CHILD_SPECS[name])
  73. }
  74. const after = await bench()
  75. await after.ctx.plugin({ inject: [...inject], apply }).await()
  76. expect(after.slots.entries('sidebar.settings')).toHaveLength(0)
  77. declare(after.slots)
  78. await Promise.resolve()
  79. expect(after.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
  80. // The self-inflicted ledger notifications hit the duplicate guard.
  81. expect(after.slots.entries('sidebar.settings')).toHaveLength(1)
  82. })
  83. it('projects the section ledger into ordered nav rows with option defaults', async () => {
  84. const b = await bench()
  85. declare(b.slots)
  86. await b.ctx.plugin({ inject: [...inject], apply }).await()
  87. const { sections } = injectedOf(b.slots).hooks
  88. // This package registers the General section itself; every other section
  89. // arrives from a feature registrant.
  90. const GENERAL = { id: 'general', order: 0, label: 'general.nav' }
  91. expect(sections.getSnapshot()).toEqual([GENERAL])
  92. b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
  93. // No order and no label: both projection defaults apply.
  94. b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
  95. const rows = sections.getSnapshot()
  96. expect(rows).toEqual([
  97. GENERAL,
  98. { id: 'a', order: 0, label: '' },
  99. { id: 'z', order: 20, label: 'Z' },
  100. ])
  101. // Snapshot identity is stable until the ledger moves (uSES contract).
  102. expect(sections.getSnapshot()).toBe(rows)
  103. const listener = vi.fn()
  104. const off = sections.subscribe(listener)
  105. b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
  106. await Promise.resolve()
  107. expect(listener).toHaveBeenCalled()
  108. expect(sections.getSnapshot()).not.toBe(rows)
  109. off()
  110. })
  111. it('projects the Gateway connection control without copying its state', async () => {
  112. const b = await bench()
  113. declare(b.slots)
  114. await b.ctx.plugin({ inject: [...inject], apply }).await()
  115. const injected = injectedOf(b.slots)
  116. expect(injected.hooks.connectionState).toBe(b.connectionState)
  117. injected.reconnect()
  118. expect(b.reconnect).toHaveBeenCalledOnce()
  119. })
  120. it('projects onboarding entries into stable coordinator order', async () => {
  121. const b = await bench()
  122. declare(b.slots)
  123. await b.ctx.plugin({ inject: [...inject], apply }).await()
  124. const { onboardingSteps } = injectedOf(b.slots).hooks
  125. b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null)
  126. b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null)
  127. b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
  128. const steps = onboardingSteps.getSnapshot()
  129. expect(steps).toEqual([
  130. { id: 'welcome', order: -100 },
  131. { id: 'credential', order: 0 },
  132. { id: 'default-order', order: 0 },
  133. ])
  134. expect(onboardingSteps.getSnapshot()).toBe(steps)
  135. const listener = vi.fn()
  136. const off = onboardingSteps.subscribe(listener)
  137. b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null)
  138. await Promise.resolve()
  139. expect(listener).toHaveBeenCalledOnce()
  140. off()
  141. })
  142. it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => {
  143. const b = await bench()
  144. const redeclare = declare(b.slots)
  145. await b.ctx.plugin({ inject: [...inject], apply }).await()
  146. expect(b.slots.entries('sidebar.settings')).toHaveLength(1)
  147. // Declarer unload: the cascade removes our entry and every child
  148. // declaration while our local disposer variable goes stale.
  149. redeclare()
  150. expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
  151. expect(b.slots.spec('settings.trigger')).toBeUndefined()
  152. declare(b.slots)
  153. await Promise.resolve()
  154. expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
  155. for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
  156. expect(b.slots.spec(name)).toEqual(CHILD_SPECS[name])
  157. }
  158. })
  159. it('unregisters the shell and collapses every child slot on teardown', async () => {
  160. const b = await bench()
  161. declare(b.slots)
  162. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  163. await fiber.await()
  164. await fiber.dispose()
  165. expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
  166. for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
  167. expect(b.slots.spec(name)).toBeUndefined()
  168. }
  169. })
  170. })