shell.client.spec.ts 7.2 KB

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