apply.client.spec.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. /**
  2. * apply wiring on a real cordis Context + SlotRegistry: InputTriggerService mounts
  3. * as ctx.inputTriggers once its sessions dependency is up; the MenuView overlay
  4. * registration follows the slot declaration, resolves the per-session controller from the slot's
  5. * sessionId, and unregisters on fiber teardown.
  6. */
  7. import { Context } from '@deepseek-ai/cordis'
  8. import { describe, expect, it } from 'vitest'
  9. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  10. import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
  11. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  12. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  13. import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
  14. import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
  15. const sid = (k: string): SessionId => k as SessionId
  16. async function bench() {
  17. const ctx = new Context()
  18. await ctx.plugin(SlotRegistry).await()
  19. const slots = ctx.get('slots') as SlotRegistry
  20. // Stand-in for the ui-conversation composer entry: declare the overlay
  21. // slot without providing ConversationController, which is not its lifecycle
  22. // signal.
  23. slots.register(
  24. { name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } } } as never,
  25. () => null,
  26. )
  27. // Sessions face: mint one real scope for session 'a' and resolve it by id.
  28. const scope = createScope(ctx, sid('a'))
  29. const session = { sessionId: sid('a') }
  30. const binding = { sessionId: sid('a'), session, ctx: scope.ctx }
  31. ctx.provide('sessions', {
  32. scope: (id: SessionId) => (id === sid('a') ? scope.ctx : undefined),
  33. scopeOf: (c: Context) => scopeOf(c),
  34. sessionOf: (c: Context) => c === scope.ctx ? session : undefined,
  35. binding: (id: SessionId) => id === sid('a') ? binding : undefined,
  36. } as never)
  37. const locale = new LocaleRuntime(ctx)
  38. // These specs assert the shipped Chinese copy. There is no jsdom `window`
  39. // in this lane, so browser-language detection never runs and the locale
  40. // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly.
  41. locale.setLocale('zh')
  42. ctx.provide('locale', locale)
  43. return { ctx, slots, locale }
  44. }
  45. describe('apply', () => {
  46. it('declares the sessions and locale dependencies (scope tree + localized menu copy)', () => {
  47. expect(inject).toEqual(['sessions', 'locale'])
  48. })
  49. it('registers the bilingual menu dictionaries (group titles by source name + the pending row)', async () => {
  50. const { ctx, locale } = await bench()
  51. await ctx.plugin({ inject: [...inject], apply }).await()
  52. const t = locale.bind('slash.menu')
  53. expect(t('command')).toBe('指令')
  54. locale.setLocale('en')
  55. expect(t('skill')).toBe('Skills')
  56. expect(t('subagent')).toBe('Subagents')
  57. expect(t('loading')).toBe('Loading…')
  58. })
  59. it('mounts ctx.inputTriggers once sessions is up, before any conversation service exists', async () => {
  60. const { ctx } = await bench()
  61. await ctx.plugin({ inject: [...inject], apply }).await()
  62. expect(ctx.get('inputTriggers')).toBeInstanceOf(InputTriggerService)
  63. })
  64. it('registers MenuView into the overlay and resolves the per-session controller by slot sessionId', async () => {
  65. const { ctx, slots } = await bench()
  66. await ctx.plugin({ inject: [...inject], apply }).await()
  67. expect(slots.entries('conversation.input.overlay')).toHaveLength(1)
  68. const entries = slots.entries('conversation.input.overlay')
  69. expect(entries[0]!.options.id).toBe('slash-menu')
  70. // Copy rides the standard locale seat, not the business face.
  71. expect(entries[0]!.locale).toBe('slash.menu')
  72. const inputTriggers = ctx.get('inputTriggers') as InputTriggerService
  73. // StoredEntry.inject is declaration-typed ((...args: never[]) shape);
  74. // the erased registration widens it past a direct cast, so hop unknown.
  75. const injectEntry = entries[0]!.inject as unknown as (sessionId: SessionId) => MenuViewInjected
  76. const injected = injectEntry(sid('a'))
  77. const controller = inputTriggers.sessionOf(
  78. ctx.sessions.scope(sid('a'))!,
  79. )
  80. expect(injected.menu).toBe(controller.menu)
  81. expect(injected.headers).toBe(controller.headers)
  82. // The pick face routes into the controller pipeline (closed menu → no-op).
  83. injected.onPick('command', 0)
  84. expect(controller.menu.getSnapshot().open).toBe(false)
  85. // The crumb face routes into the controller too (closed menu → no-op).
  86. injected.onCrumb('command', 0)
  87. expect(controller.menu.getSnapshot().open).toBe(false)
  88. // The hover face routes into the controller too (closed menu → no-op).
  89. injected.onHover('command', 0)
  90. expect(controller.menu.getSnapshot().open).toBe(false)
  91. // The dismiss face routes into the controller too (closed menu → no-op).
  92. injected.onDismiss()
  93. expect(controller.menu.getSnapshot().open).toBe(false)
  94. // An unknown session id fails loud (no silent scope miss).
  95. expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/)
  96. })
  97. it('fiber teardown removes the overlay entry', async () => {
  98. const { ctx, slots } = await bench()
  99. const fiber = ctx.plugin({ inject: [...inject], apply })
  100. await fiber.await()
  101. expect(slots.entries('conversation.input.overlay')).toHaveLength(1)
  102. await fiber.dispose()
  103. expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
  104. expect(ctx.get('inputTriggers')).toBeUndefined()
  105. })
  106. })