shell.client.spec.ts 6.9 KB

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