Files
deepseek-harness/packages/client/ui-conversation/tests/service-stores.spec.ts
imccyu 2431caa9ab fix: ci docs/lint
fix: ci docs/lint

ci: coverage
2026-07-22 17:03:10 +08:00

177 lines
6.4 KiB
TypeScript

// @vitest-environment jsdom
/**
* ConversationService store half: scope-addressed selection/drafts accounts
* (lazy mint, per-scope isolation, root access throws, scope teardown
* collects), view registry (order, duplicate throw, effect-scoped disposal,
* uSES read face). Send/cancel/startSession orchestration live in
* service-orchestration.spec.ts.
*/
import { Context } from 'cordis'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConvViewProps, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string): SessionId => s as SessionId
/**
* The scope tag symbol is module-private to the runtime package; recover it
* through the public seam by recording which symbol scopeOf reads off a
* spying proxy (keeps this bench honest against the real tagging shape
* without dragging the full SessionsService + wire fake in here).
*/
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
get(target, prop, receiver): unknown {
recorded.push(prop)
return Reflect.get(target, prop, receiver)
},
})
void scopeOf(spy)
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
return symbol
})()
/** Scope bench: real cordis scope fibers tagged like SessionsService.resolve mints them. */
interface Bench {
ctx: Context
svc: ConversationService
mint: (id: SessionId) => Context
dispose: (id: SessionId) => Promise<void>
}
function bench(): Bench {
const ctx = new Context()
const fibers = new Map<SessionId, { fiber: ReturnType<Context['plugin']>; ctx: Context }>()
const mint = (id: SessionId): Context => {
let rec = fibers.get(id)
if (rec === undefined) {
const fiber = ctx.plugin(() => {})
const scoped = fiber.ctx.extend({ [SCOPE_TAG]: id })
rec = { fiber, ctx: scoped }
fibers.set(id, rec)
}
return rec.ctx
}
const dispose = async (id: SessionId): Promise<void> => {
const rec = fibers.get(id)
if (rec !== undefined) {
await rec.fiber.dispose()
fibers.delete(id)
}
}
const sessions = { scope: (id: SessionId) => fibers.get(id)?.ctx } as unknown as SessionsService
ctx.provide('sessions', sessions)
const svc = new ConversationService(ctx)
return { ctx, svc, mint, dispose }
}
/** Scoped service view: ctx.get binds the root singleton to the scoped ctx (scope addressing seam). */
function convo(scoped: Context): ConversationService {
const service = scoped.get('conversation')
if (service === undefined) throw new Error('bench: conversation unavailable')
return service
}
const viewComp = (() => null) as unknown as FC<ConvViewProps>
const entry = (id: string, order?: number): ViewEntry =>
({ id, label: id, component: viewComp, ...(order !== undefined ? { order } : {}) }) as unknown as ViewEntry
beforeEach(() => { localStorage.clear() })
describe('scope addressing of stores', () => {
it('root-context selection/drafts access throws with the addressing hint', () => {
const b = bench()
expect(() => b.svc.selection).toThrow(/requires a session scope/)
expect(() => b.svc.drafts).toThrow(/requires a session scope/)
})
it('mints one store per scope and keeps identity per session', () => {
const b = bench()
const c1 = b.mint(sid('s1'))
const c2 = b.mint(sid('s2'))
const sel1 = convo(c1).selection
const sel2 = convo(c2).selection
expect(sel1).not.toBe(sel2)
expect(convo(c1).selection).toBe(sel1)
sel1.set({ turnSeq: 3 })
expect(sel1.getSnapshot()).toEqual({ turnSeq: 3 })
expect(sel2.getSnapshot()).toBeNull()
})
it('persists drafts keyed by session id and evolves independently', async () => {
const b = bench()
const c1 = b.mint(sid('s1'))
convo(c1).drafts.set('hello')
expect(localStorage.getItem('dsh.conversation.draft.s1')).toBe('hello')
const c2 = b.mint(sid('s2'))
expect(convo(c2).drafts.getSnapshot()).toBe('')
// Re-minting after teardown rehydrates from storage; clearing removes the key.
await b.dispose(sid('s1'))
expect(convo(b.mint(sid('s1'))).drafts.getSnapshot()).toBe('hello')
convo(b.mint(sid('s1'))).drafts.set('')
expect(localStorage.getItem('dsh.conversation.draft.s1')).toBeNull()
})
it('scope fiber disposal collects the store account (fresh store on re-mint)', async () => {
const b = bench()
const c1 = b.mint(sid('s1'))
const sel = convo(c1).selection
sel.set({ turnSeq: 1 })
await b.dispose(sid('s1'))
const again = b.mint(sid('s1'))
const sel2 = convo(again).selection
expect(sel2).not.toBe(sel)
expect(sel2.getSnapshot()).toBeNull()
})
})
describe('view registry', () => {
it('orders by order (ties keep registration sequence) with a stable cache reference', () => {
const b = bench()
b.svc.registerView(entry('chat', 0))
b.svc.registerView(entry('waterfall', 2))
b.svc.registerView(entry('trajectory', 1))
const views = b.svc.views()
expect(views.map(v => v.id)).toEqual(['chat', 'trajectory', 'waterfall'])
expect(b.svc.views()).toBe(views)
})
it('duplicate id throws; disposer removes and bumps the version', () => {
const b = bench()
const fn = vi.fn()
b.svc.subscribeViews(fn)
const off = b.svc.registerView(entry('chat'))
expect(() => b.svc.registerView(entry('chat'))).toThrow(/already registered/)
const v1 = b.svc.viewsVersion()
off()
expect(b.svc.viewsVersion()).toBeGreaterThan(v1)
expect(b.svc.views()).toEqual([])
expect(fn).toHaveBeenCalled()
})
it('unsubscribe stops notifications', () => {
const b = bench()
const fn = vi.fn()
const unsub = b.svc.subscribeViews(fn)
unsub()
b.svc.registerView(entry('chat'))
expect(fn).not.toHaveBeenCalled()
})
it('a registering plugin fiber unloading collects its views (effect cascade)', async () => {
const b = bench()
const fiber = b.ctx.plugin((pluginCtx: Context) => {
convo(pluginCtx).registerView(entry('chat'))
})
await fiber.await()
expect(b.svc.views().map(v => v.id)).toEqual(['chat'])
await fiber.dispose()
expect(b.svc.views()).toEqual([])
})
})