apply.spec.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. /** Settings shell registration: declaration-aware deferral, the ledger projections, and HMR recovery. */
  2. import { Context } from 'cordis'
  3. import { describe, expect, it, vi } from 'vitest'
  4. import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  5. import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client'
  6. import type { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client'
  7. import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
  8. async function bench() {
  9. const ctx = new Context()
  10. await ctx.plugin(SlotsService).await()
  11. return { ctx, slots: ctx.get('slots') as SlotsService }
  12. }
  13. function declare(slots: SlotsService): () => void {
  14. return slots.register(
  15. { name: 'root', children: { 'sidebar.settings': { kind: 'single', scope: 'root' } } } as never,
  16. () => null,
  17. )
  18. }
  19. function injectedOf(slots: SlotsService): SettingsRootInjected {
  20. const entry = slots.entries('sidebar.settings')[0]!
  21. return (entry.inject as () => SettingsRootInjected)()
  22. }
  23. /** The shell's child declarations (chrome, actions, sections, and onboarding overlays). */
  24. const CHILD_SPECS = {
  25. 'settings.trigger': { kind: 'single', scope: 'root' },
  26. 'settings.header': { kind: 'single', scope: 'root' },
  27. 'settings.action': { kind: 'list', scope: 'root' },
  28. 'settings.close': { kind: 'single', scope: 'root' },
  29. 'settings.section': { kind: 'list', scope: 'root' },
  30. 'settings.onboarding': { kind: 'list', scope: 'root' },
  31. } as const
  32. describe('ui-settings apply', () => {
  33. it('declares only the slot registry (a pure composition face, no locale)', () => {
  34. expect(inject).toEqual(['slots'])
  35. })
  36. it('registers the shell and declares every child slot, before or after the declaration', async () => {
  37. const before = await bench()
  38. declare(before.slots)
  39. await before.ctx.plugin({ inject: [...inject], apply }).await()
  40. expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
  41. for (const [name, spec] of Object.entries(CHILD_SPECS)) {
  42. expect(before.slots.spec(name as never)).toEqual(spec)
  43. }
  44. const after = await bench()
  45. await after.ctx.plugin({ inject: [...inject], apply }).await()
  46. expect(after.slots.entries('sidebar.settings')).toHaveLength(0)
  47. declare(after.slots)
  48. await Promise.resolve()
  49. expect(after.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
  50. // The self-inflicted ledger notifications hit the duplicate guard.
  51. expect(after.slots.entries('sidebar.settings')).toHaveLength(1)
  52. })
  53. it('projects the section ledger into ordered nav rows with option defaults', async () => {
  54. const b = await bench()
  55. declare(b.slots)
  56. await b.ctx.plugin({ inject: [...inject], apply }).await()
  57. const { sections } = injectedOf(b.slots).hooks
  58. // The shell ships no sections of its own — registrants fill the ledger.
  59. expect(sections.getSnapshot()).toEqual([])
  60. b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
  61. // No order and no label: both projection defaults apply.
  62. b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
  63. const rows = sections.getSnapshot()
  64. expect(rows).toEqual([
  65. { id: 'a', order: 0, label: '' },
  66. { id: 'z', order: 20, label: 'Z' },
  67. ])
  68. // Snapshot identity is stable until the ledger moves (uSES contract).
  69. expect(sections.getSnapshot()).toBe(rows)
  70. const listener = vi.fn()
  71. const off = sections.subscribe(listener)
  72. b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
  73. await Promise.resolve()
  74. expect(listener).toHaveBeenCalled()
  75. expect(sections.getSnapshot()).not.toBe(rows)
  76. off()
  77. })
  78. it('projects onboarding entries into stable coordinator order', async () => {
  79. const b = await bench()
  80. declare(b.slots)
  81. await b.ctx.plugin({ inject: [...inject], apply }).await()
  82. const { onboardingSteps } = injectedOf(b.slots).hooks
  83. b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null)
  84. b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null)
  85. b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
  86. const steps = onboardingSteps.getSnapshot()
  87. expect(steps).toEqual([
  88. { id: 'welcome', order: -100 },
  89. { id: 'credential', order: 0 },
  90. { id: 'default-order', order: 0 },
  91. ])
  92. expect(onboardingSteps.getSnapshot()).toBe(steps)
  93. const listener = vi.fn()
  94. const off = onboardingSteps.subscribe(listener)
  95. b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null)
  96. await Promise.resolve()
  97. expect(listener).toHaveBeenCalledOnce()
  98. off()
  99. })
  100. it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => {
  101. const b = await bench()
  102. const redeclare = declare(b.slots)
  103. await b.ctx.plugin({ inject: [...inject], apply }).await()
  104. expect(b.slots.entries('sidebar.settings')).toHaveLength(1)
  105. // Declarer unload: the cascade removes our entry and every child
  106. // declaration while our local disposer variable goes stale.
  107. redeclare()
  108. expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
  109. expect(b.slots.spec('settings.trigger')).toBeUndefined()
  110. declare(b.slots)
  111. await Promise.resolve()
  112. expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
  113. for (const [name, spec] of Object.entries(CHILD_SPECS)) {
  114. expect(b.slots.spec(name as never)).toEqual(spec)
  115. }
  116. })
  117. it('unregisters the shell and collapses every child slot on teardown', async () => {
  118. const b = await bench()
  119. declare(b.slots)
  120. const fiber = b.ctx.plugin({ inject: [...inject], apply })
  121. await fiber.await()
  122. await fiber.dispose()
  123. expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
  124. for (const name of Object.keys(CHILD_SPECS)) {
  125. expect(b.slots.spec(name as never)).toBeUndefined()
  126. }
  127. })
  128. })