apply.spec.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  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 { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
  11. import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
  12. import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  13. import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
  14. import { apply, inject, SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
  15. import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-slash/client'
  16. // The service reads its initial locale from the browser; these specs assert
  17. // the shipped Chinese copy, so they state the browser they assume.
  18. usePinnedBrowserLanguages('zh-CN')
  19. const sid = (k: string): SessionId => k as SessionId
  20. async function bench() {
  21. const ctx = new Context()
  22. await ctx.plugin(SlotsService).await()
  23. const slots = ctx.get('slots') as SlotsService
  24. // Stand-in for the ui-conversation composer entry: declare the overlay
  25. // slot, then provide the conversation service (declaration precedes the
  26. // service exactly as the real apply orders them).
  27. slots.register(
  28. { name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } } } as never,
  29. () => null,
  30. )
  31. // Sessions face: mint one real scope for session 'a' and resolve it by id.
  32. const scope = createScope(ctx, sid('a'))
  33. ctx.provide('sessions', {
  34. scope: (id: SessionId) => (id === sid('a') ? scope.ctx : undefined),
  35. scopeOf: (c: Context) => scopeOf(c),
  36. })
  37. const locale = new LocaleService(ctx)
  38. ctx.provide('locale', locale)
  39. return { ctx, slots, locale }
  40. }
  41. describe('apply', () => {
  42. it('declares the sessions and locale dependencies (scope tree + localized menu copy)', () => {
  43. expect(inject).toEqual(['sessions', 'locale'])
  44. })
  45. it('registers the bilingual menu dictionaries (group titles by source name + the pending row)', async () => {
  46. const { ctx, locale } = await bench()
  47. await ctx.plugin({ inject: [...inject], apply }).await()
  48. const t = locale.bind('slash.menu')
  49. expect(t('command')).toBe('命令')
  50. locale.setLocale('en')
  51. expect(t('skill')).toBe('Skills')
  52. expect(t('subagent')).toBe('Subagents')
  53. expect(t('loading')).toBe('Loading…')
  54. })
  55. it('mounts ctx.slash once sessions is up, before any conversation service exists', async () => {
  56. const { ctx } = await bench()
  57. await ctx.plugin({ inject: [...inject], apply }).await()
  58. expect(ctx.get('slash')).toBeInstanceOf(SlashService)
  59. })
  60. it('registers MenuView into the overlay and resolves the per-session controller by slot sessionId', async () => {
  61. const { ctx, slots } = await bench()
  62. await ctx.plugin({ inject: [...inject], apply }).await()
  63. expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
  64. ctx.provide('conversation', {})
  65. // The inject scope activates asynchronously on the service arrival.
  66. await vi.waitFor(() => { expect(slots.entries('conversation.input.overlay')).toHaveLength(1) })
  67. const entries = slots.entries('conversation.input.overlay')
  68. expect(entries[0]!.options.id).toBe('slash-menu')
  69. // Copy rides the standard locale seat, not the business face.
  70. expect(entries[0]!.locale).toBe('slash.menu')
  71. const slash = ctx.get('slash') as SlashService
  72. // StoredEntry.inject is declaration-typed ((...args: never[]) shape);
  73. // the erased registration widens it past a direct cast, so hop unknown.
  74. const injectEntry = entries[0]!.inject as unknown as (sessionId: SessionId) => MenuViewInjected
  75. const injected = injectEntry(sid('a'))
  76. const controller = slash.sessionOf(
  77. (ctx.get('sessions') as { scope(id: SessionId): Context }).scope(sid('a')),
  78. )
  79. expect(injected.menu).toBe(controller.menu)
  80. // The pick face routes into the controller pipeline (closed menu → no-op).
  81. injected.onPick('command', 0)
  82. expect(controller.menu.getSnapshot().open).toBe(false)
  83. // The dismiss face routes into the controller too (closed menu → no-op).
  84. injected.onDismiss()
  85. expect(controller.menu.getSnapshot().open).toBe(false)
  86. // An unknown session id fails loud (no silent scope miss).
  87. expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/)
  88. })
  89. it('fiber teardown removes the overlay entry', async () => {
  90. const { ctx, slots } = await bench()
  91. const fiber = ctx.plugin({ inject: [...inject], apply })
  92. await fiber.await()
  93. ctx.provide('conversation', {})
  94. await vi.waitFor(() => { expect(slots.entries('conversation.input.overlay')).toHaveLength(1) })
  95. await fiber.dispose()
  96. expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
  97. expect(ctx.get('slash')).toBeUndefined()
  98. })
  99. })