chat-apply.spec.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. // @vitest-environment jsdom
  2. // apply wiring: the conversation service provided, the chat view registered
  3. // as the first 'conversation.view' ring entry declaring the keyed toolview
  4. // hole, the three slot registrations land against a root entry's children
  5. // declarations (the AppFrame role), the shared store handle rides all session
  6. // entries, and the bash sample mounts through the load-order seam as a keyed
  7. // entry. Full-chain rendering belongs to the machinery spec
  8. // (chat-toolview-slot.spec.tsx) and the shell e2e; this spec stops at the
  9. // assembly surface.
  10. import { Context } from 'cordis'
  11. import { describe, expect, it, vi } from 'vitest'
  12. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  13. import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  14. import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
  15. import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
  16. const ROOT = 'root-1' as SessionId
  17. const CHILD = 'child-1' as SessionId
  18. async function bench() {
  19. const ctx = new Context()
  20. const slotsFiber = ctx.plugin(SlotsService)
  21. await slotsFiber.await()
  22. const listStore = createSnapshotStore<SessionListState>({
  23. ids: [ROOT, CHILD],
  24. byId: {
  25. [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, blank: false, updatedAt: 1 },
  26. [CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, blank: false, updatedAt: 2 },
  27. },
  28. current: undefined,
  29. phase: 'ready',
  30. } as SessionListState)
  31. const sessionsFake = {
  32. list: listStore,
  33. binding: vi.fn(),
  34. scope: () => undefined,
  35. provideInfo: () => undefined,
  36. maybeProvideInfo: () => ({ hooks: {}, props: {} }),
  37. provide: vi.fn(() => () => {}),
  38. create: vi.fn(),
  39. open: vi.fn(),
  40. updateIntent: vi.fn(),
  41. }
  42. ctx.provide('sessions', sessionsFake)
  43. ctx.provide('workspaces', {
  44. startSession: vi.fn(),
  45. sendSession: vi.fn(),
  46. })
  47. ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
  48. ctx.provide('locale', { bind: () => (key: string) => key })
  49. // Declared by ui-layout's root entry in production; a stand-in root
  50. // occupant declares them here so the contributions land (it consumes
  51. // renderSlot to satisfy the declare-means-render check).
  52. const slots = ctx.get('slots') as SlotsService
  53. slots.register({
  54. name: 'root',
  55. children: {
  56. 'conversation': { kind: 'single', scope: 'session-maybe' },
  57. 'details': { kind: 'single', scope: 'session' },
  58. },
  59. }, (_p: { renderSlot?: unknown }) => null)
  60. const fiber = ctx.plugin({ inject: [...inject], apply })
  61. return { ctx, fiber, slots }
  62. }
  63. /** First stored entry for a key (inject/store live directly on StoredEntry). */
  64. function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
  65. return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
  66. }
  67. describe('apply wiring', () => {
  68. it('provides the conversation service', async () => {
  69. const b = await bench()
  70. await b.fiber.await()
  71. expect(b.ctx.get('conversation')).toBeDefined()
  72. })
  73. it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => {
  74. const b = await bench()
  75. await b.fiber.await()
  76. const entries = b.slots.entries('conversation.view')
  77. expect(entries.map((e) => e.options.id)).toEqual(['chat'])
  78. expect(entries[0]?.options.label).toBe('Chat')
  79. expect(entries[0]?.options.order).toBe(0)
  80. // Declaring is claiming: the chat entry's registration put the hole on
  81. // the ledger with the contract's kind/scope.
  82. expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
  83. })
  84. it('occupies the slots + the ring; session entries share one store handle', async () => {
  85. const b = await bench()
  86. await b.fiber.await()
  87. const conversation = renderEntryOf(b.slots, 'conversation')
  88. const conversationSession = renderEntryOf(b.slots, 'conversation.session')
  89. const chatView = renderEntryOf(b.slots, 'conversation.view')
  90. const details = renderEntryOf(b.slots, 'details')
  91. expect(conversation?.inject).toBeTypeOf('function')
  92. expect(chatView?.inject).toBeTypeOf('function')
  93. expect(details?.inject).toBeTypeOf('function')
  94. // The shared handle: one apply-built store value on ALL session entries
  95. // (the session-maybe 'conversation' shell carries no store by design).
  96. expect(conversationSession?.store).toBeDefined()
  97. expect(details?.store).toBe(conversationSession?.store)
  98. expect(chatView?.store).toBe(conversationSession?.store)
  99. // The hero workspace picker hole rides the conversation entry's children
  100. // declaration (the empty-state occupant is gone).
  101. expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
  102. })
  103. it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => {
  104. const b = await bench()
  105. await b.fiber.await()
  106. // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
  107. // service being present implies the chat entry declared the hole first.
  108. const entries = b.slots.entries('conversation.chat.toolview')
  109. expect(entries.map((e) => e.options.key)).toEqual(['bash', 'todo_write'])
  110. })
  111. it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
  112. const b = await bench()
  113. await b.fiber.await()
  114. await b.fiber.dispose()
  115. expect(b.slots.entries('conversation')).toHaveLength(0)
  116. // The declared ring collapses with its declaring entry, and the chat
  117. // entry's keyed hole (with the sample's registration) collapses with it.
  118. expect(b.slots.entries('conversation.view')).toHaveLength(0)
  119. expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
  120. expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
  121. expect(b.slots.entries('details')).toHaveLength(0)
  122. expect(b.ctx.get('conversation')).toBeUndefined()
  123. })
  124. })