selection-survival.spec.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. // @vitest-environment jsdom
  2. /**
  3. * Exercises selection persistence through the real SlotsService store axis;
  4. * component stubs cannot prove per-session identity or disposal.
  5. */
  6. import { Context } from 'cordis'
  7. import { beforeEach, describe, expect, it } from 'vitest'
  8. import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  9. import type { SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
  10. import { createChatStore } from '../src/client/stores.ts'
  11. const sid = (s: string): SessionId => s as SessionId
  12. interface Bench {
  13. slots: SlotsService
  14. chat: ReturnType<typeof createChatStore>
  15. }
  16. function bench(): Bench {
  17. const ctx = new Context()
  18. ctx.provide('sessions', {
  19. list: createSnapshotStore<SessionListState>({
  20. ids: [], byId: {}, current: undefined, phase: 'ready',
  21. }),
  22. provideInfo: () => undefined,
  23. maybeProvideInfo: () => ({ hooks: {}, props: {} }),
  24. provide: () => () => {},
  25. })
  26. ctx.provide('workspaces', {
  27. list: createSnapshotStore<WorkspaceListState>({
  28. items: [], state: 'idle', phase: 'ready', error: null,
  29. baselinesReady: true, recentWorkspaceId: undefined,
  30. }),
  31. })
  32. // Service self-registers as ctx 'slots' (cordis Service constructor).
  33. const slots = new SlotsService(ctx)
  34. const chat = createChatStore()
  35. // The apply.ts shape: one shared handle across both session-slot
  36. // registrations. 'conversation'/'details' must first exist in the ledger —
  37. // register a root occupant declaring them (the AppFrame role; the stand-in
  38. // consumes renderSlot to satisfy the declare-means-render check).
  39. slots.register({
  40. name: 'root',
  41. children: {
  42. 'conversation': { kind: 'single', scope: 'session-maybe' },
  43. 'conversation.session': { kind: 'single', scope: 'session' },
  44. 'details': { kind: 'single', scope: 'session' },
  45. },
  46. }, (_p: { renderSlot?: unknown }) => null)
  47. // apply.ts mounts the shared chat handle only under session-scope slots
  48. // (the session-maybe 'conversation' shell carries no store).
  49. slots.register({ name: 'conversation.session', store: chat }, () => null)
  50. slots.register({ name: 'details', store: chat }, () => null)
  51. return { slots, chat }
  52. }
  53. /** Resolve the store instance the renderer would hand a slot's component for a session. */
  54. function storeFor(b: Bench, slot: 'conversation.session' | 'details', sessionId: SessionId) {
  55. const host = renderHost(b)
  56. const entry = host.entriesOf(slot)[0]!
  57. return host.storeOf(entry, sessionId)! as ReturnType<ReturnType<typeof createChatStore>['create']>
  58. }
  59. /** The host face is only built at renderSlot time; install a stub renderer once to reach it. */
  60. function renderHost(b: Bench): import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost {
  61. const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost })
  62. if (captured._host === undefined) {
  63. b.slots.install({
  64. renderRoot: (host) => {
  65. captured._host = host
  66. return null
  67. },
  68. })
  69. b.slots.renderSlot('root', {})
  70. }
  71. return captured._host!
  72. }
  73. beforeEach(() => {
  74. localStorage.clear()
  75. })
  76. describe('selection survives on the store seat', () => {
  77. it('one session, two slots: conversation writes, details reads the SAME instance', () => {
  78. const b = bench()
  79. const conv = storeFor(b, 'conversation.session', sid('s1'))
  80. const details = storeFor(b, 'details', sid('s1'))
  81. conv.actions.select({ turnSeq: 3, callId: 'c1' })
  82. expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
  83. // Identity, not just value: the shared handle resolves one instance per scope key.
  84. expect(details).toBe(conv)
  85. })
  86. it('sessions are isolated: s2 selection never bleeds into s1', () => {
  87. const b = bench()
  88. const one = storeFor(b, 'conversation.session', sid('s1'))
  89. const two = storeFor(b, 'conversation.session', sid('s2'))
  90. expect(two).not.toBe(one)
  91. one.actions.select({ turnSeq: 1, callId: 'a' })
  92. two.actions.select({ turnSeq: 9, callId: 'z' })
  93. expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
  94. expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
  95. })
  96. it('a list-projection update keeps instance identity and the selection value', () => {
  97. const b = bench()
  98. const id = sid('s1')
  99. const projection = createSnapshotStore({ displayTitle: 's1' })
  100. const store = storeFor(b, 'conversation.session', id)
  101. store.actions.select({ turnSeq: 3, callId: 'c1' })
  102. store.actions.setDraft('half-typed')
  103. projection.set({ displayTitle: 'proj-a' })
  104. expect(projection.getSnapshot().displayTitle).toBe('proj-a')
  105. const after = storeFor(b, 'conversation.session', id)
  106. expect(after).toBe(store)
  107. expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
  108. expect(after.store.getSnapshot().draft).toBe('half-typed')
  109. })
  110. it('session death buries the instance and its persisted draft', () => {
  111. const b = bench()
  112. const doomed = storeFor(b, 'conversation.session', sid('s1'))
  113. doomed.actions.setDraft('to be buried')
  114. doomed.actions.select({ turnSeq: 1 })
  115. expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
  116. // SessionsService calls this public slot lifecycle seam when the scope dies.
  117. b.slots.pruneStoreScope(sid('s1'))
  118. // Persisted residue is gone with the session...
  119. expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
  120. // ...and a re-created same-id session starts from a FRESH instance.
  121. const reborn = storeFor(b, 'conversation.session', sid('s1'))
  122. expect(reborn).not.toBe(doomed)
  123. expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
  124. })
  125. })