apply.spec.ts 3.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. /**
  2. * apply wiring on a real cordis Context + SlotsService: SlashService mounts
  3. * as ctx.slash once its sessions dependency is up; the MenuView overlay
  4. * registration waits on the conversation seam (ctx.inject scope), lands once
  5. * the declarer is up, resolves the per-session controller from the slot's
  6. * sessionId, and unregisters on fiber teardown.
  7. */
  8. import { Context } from 'cordis'
  9. import { describe, expect, it, vi } from 'vitest'
  10. import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  11. import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
  12. import { apply, inject, SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
  13. import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-slash/client'
  14. const sid = (k: string): SessionId => k as SessionId
  15. async function bench() {
  16. const ctx = new Context()
  17. await ctx.plugin(SlotsService).await()
  18. const slots = ctx.get('slots') as SlotsService
  19. // Stand-in for the ui-conversation composer entry: declare the overlay
  20. // slot, then provide the conversation service (declaration precedes the
  21. // service exactly as the real apply orders them).
  22. slots.register(
  23. { name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } } } as never,
  24. () => null,
  25. )
  26. // Sessions face: mint one real scope for session 'a' and resolve it by id.
  27. const scope = createScope(ctx, sid('a'))
  28. ctx.provide('sessions', {
  29. scope: (id: SessionId) => (id === sid('a') ? scope.ctx : undefined),
  30. scopeOf: (c: Context) => scopeOf(c),
  31. })
  32. return { ctx, slots }
  33. }
  34. describe('apply', () => {
  35. it('declares the sessions dependency (controller resolution reads the scope tree)', () => {
  36. expect(inject).toEqual(['sessions'])
  37. })
  38. it('mounts ctx.slash once sessions is up, before any conversation service exists', async () => {
  39. const { ctx } = await bench()
  40. await ctx.plugin({ inject: [...inject], apply }).await()
  41. expect(ctx.get('slash')).toBeInstanceOf(SlashService)
  42. })
  43. it('registers MenuView into the overlay and resolves the per-session controller by slot sessionId', async () => {
  44. const { ctx, slots } = await bench()
  45. await ctx.plugin({ inject: [...inject], apply }).await()
  46. expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
  47. ctx.provide('conversation', {})
  48. // The inject scope activates asynchronously on the service arrival.
  49. await vi.waitFor(() => { expect(slots.entries('conversation.input.overlay')).toHaveLength(1) })
  50. const entries = slots.entries('conversation.input.overlay')
  51. expect(entries[0]!.options.id).toBe('slash-menu')
  52. const slash = ctx.get('slash') as SlashService
  53. // StoredEntry.inject is declaration-typed ((...args: never[]) shape);
  54. // the erased registration widens it past a direct cast, so hop unknown.
  55. const injectEntry = entries[0]!.inject as unknown as (sessionId: SessionId) => MenuViewInjected
  56. const injected = injectEntry(sid('a'))
  57. const controller = slash.sessionOf(
  58. (ctx.get('sessions') as { scope(id: SessionId): Context }).scope(sid('a')),
  59. )
  60. expect(injected.menu).toBe(controller.menu)
  61. // The pick face routes into the controller pipeline (closed menu → no-op).
  62. injected.onPick('command', 0)
  63. expect(controller.menu.getSnapshot().open).toBe(false)
  64. // An unknown session id fails loud (no silent scope miss).
  65. expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/)
  66. })
  67. it('fiber teardown removes the overlay entry', async () => {
  68. const { ctx, slots } = await bench()
  69. const fiber = ctx.plugin({ inject: [...inject], apply })
  70. await fiber.await()
  71. ctx.provide('conversation', {})
  72. await vi.waitFor(() => { expect(slots.entries('conversation.input.overlay')).toHaveLength(1) })
  73. await fiber.dispose()
  74. expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
  75. expect(ctx.get('slash')).toBeUndefined()
  76. })
  77. })