apply.spec.tsx 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. /**
  2. * apply wiring on a real cordis Context + SlotsService (terminal register
  3. * form): SidebarRoot registered into the layout-declared sidebar slot, the
  4. * thin inject surface (three plain service callbacks closed over the plugin
  5. * ctx — no hooks, no store lines), load-order fail-loud, and fiber-teardown
  6. * unregistration. Component behavior is covered props-direct in
  7. * sidebar-root.spec.tsx; no renderer machinery here.
  8. */
  9. import { Context } from 'cordis'
  10. import { describe, expect, it, vi } from 'vitest'
  11. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  12. import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  13. import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
  14. import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
  15. import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
  16. // Type-only: ui-layout's SlotMap merge so the sidebar slot key typechecks.
  17. import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
  18. const sid = (s: string) => s as SessionId
  19. async function bench() {
  20. const ctx = new Context()
  21. await ctx.plugin(SlotsService).await()
  22. const list = createSnapshotStore<SessionListState>({
  23. ids: [sid('a')],
  24. byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
  25. current: undefined,
  26. })
  27. const sessions = {
  28. list,
  29. create: vi.fn(async () => sid('minted')),
  30. open: vi.fn(),
  31. clear: vi.fn(),
  32. }
  33. const layout = { toggleSidebar: vi.fn() }
  34. ctx.provide('sessions', sessions)
  35. ctx.provide('layout', layout)
  36. const slots = ctx.get('slots') as SlotsService
  37. // Stand-in for ui-layout's root entry: the sidebar slot only exists while
  38. // a live entry declares it in children (declaration account: design §2.2).
  39. slots.register(
  40. { name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
  41. () => null,
  42. )
  43. return { ctx, slots, sessions, layout }
  44. }
  45. /** The sidebar entry's injected share, read off the stored entry. */
  46. function injectedOf(slots: SlotsService): SidebarRootInjected {
  47. const entries = slots.entries('sidebar')
  48. expect(entries).toHaveLength(1)
  49. // The typed StoredEntry.inject is declaration-derived ((...args: never[])
  50. // shape); the sidebar factory is parameterless, so the call is safe here.
  51. const inject = entries[0]!.inject as (() => SidebarRootInjected) | undefined
  52. return inject!()
  53. }
  54. describe('apply', () => {
  55. it('declares the services it binds', () => {
  56. expect(inject).toEqual(['slots', 'layout', 'sessions'])
  57. })
  58. it('fails loud when mounted without the inject declaration', async () => {
  59. // ctx.slots rides the cordis property proxy: reading it from a plugin
  60. // that never declared the dependency throws instead of yielding undefined.
  61. const ctx = new Context()
  62. await ctx.plugin(SlotsService).await()
  63. await expect(ctx.plugin({ apply })).rejects.toThrow(/without inject/)
  64. })
  65. it('fails loud when no live entry has declared the sidebar slot', async () => {
  66. const ctx = new Context()
  67. await ctx.plugin(SlotsService).await()
  68. ctx.provide('sessions', {})
  69. ctx.provide('layout', {})
  70. await expect(ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/slot "sidebar" is not declared/)
  71. })
  72. it('registers SidebarRoot with the thin three-callback inject surface', async () => {
  73. const { ctx, slots } = await bench()
  74. await ctx.plugin({ inject: [...inject], apply }).await()
  75. const injected = injectedOf(slots)
  76. // The whole business face: three plain callbacks, no hooks, no store lines.
  77. expect(Object.keys(injected).sort()).toEqual(['onCreate', 'onOpen', 'onToggleSidebar'])
  78. })
  79. it('routes the callbacks to the layout/sessions services', async () => {
  80. const { ctx, slots, sessions, layout } = await bench()
  81. await ctx.plugin({ inject: [...inject], apply }).await()
  82. const injected = injectedOf(slots)
  83. injected.onToggleSidebar()
  84. expect(layout.toggleSidebar).toHaveBeenCalledOnce()
  85. injected.onOpen(sid('a'))
  86. expect(sessions.open).toHaveBeenCalledWith('a')
  87. injected.onCreate()
  88. expect(sessions.clear).toHaveBeenCalledOnce()
  89. expect(sessions.create).not.toHaveBeenCalled()
  90. injected.onCreate('/proj')
  91. expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' })
  92. // create-then-open lands after the create promise resolves.
  93. await Promise.resolve()
  94. await Promise.resolve()
  95. expect(sessions.open).toHaveBeenCalledWith('minted')
  96. })
  97. it('teardown unregisters the slot entry', async () => {
  98. const { ctx, slots } = await bench()
  99. const fiber = ctx.plugin({ inject: [...inject], apply })
  100. await fiber.await()
  101. expect(slots.entries('sidebar')).toHaveLength(1)
  102. await fiber.dispose()
  103. expect(slots.entries('sidebar')).toHaveLength(0)
  104. })
  105. })