test(client): support panel selection in slot runtime

This commit is contained in:
imccyu
2026-09-09 00:26:29 +08:00
parent 581803bf57
commit fe460f7fd1
5 changed files with 67 additions and 10 deletions
@@ -54,6 +54,7 @@
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-ui-chat": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
@@ -29,8 +29,10 @@ import {
apply as applyUiSession, inject as uiSessionInject,
} from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client'
import type {
ChildrenDecl, ComposedProps, HostObservable, OwnerOf, SlotComponent, SlotMap, SlotRenderer,
ChildrenDecl, ComposedProps, HostObservable, OwnerOf, RenderOpts, SlotComponent, SlotMap, SlotRenderer,
SlotRendererHost, SnapshotSelectorHook, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
import { registerDomSnapshotSerializer } from './snapshot.ts'
@@ -125,7 +127,7 @@ export interface TestFileUpload {
* {@link SlotView.update} drive React through the standard uSES boundary.
*/
class OwnerPropsCell {
private readonly owners = new Map<string, object>()
private readonly owners = new Map<string, { owner: object; opts: RenderOpts | undefined }>()
private readonly listeners = new Set<() => void>()
private version = 0
@@ -147,15 +149,16 @@ class OwnerPropsCell {
* caller wraps in act).
* @param key - slot key.
* @param owner - owner props share.
* @param opts - explicit keyed or list selection for the render site.
*/
set(key: string, owner: object): void {
this.owners.set(key, owner)
set(key: string, owner: object, opts?: RenderOpts): void {
this.owners.set(key, { owner, opts })
this.version += 1
for (const fn of [...this.listeners]) fn()
}
/** Keys with supplied owner props, in first-supply order. */
entries(): readonly (readonly [string, object])[] {
entries(): readonly (readonly [string, { owner: object; opts: RenderOpts | undefined }])[] {
return [...this.owners.entries()]
}
}
@@ -217,6 +220,8 @@ export class SlotTestRuntime {
readonly sessions: TestSessions
/** Workspaces double (list observable, recorded intent actions). */
readonly workspaces: TestWorkspaces
/** Test-owned panel selection used by the framework's usePanelInfo hook. */
readonly panelInfo = createSnapshotStore<PanelInfo>({ activePanelId: null })
/** Mutable file-upload stub; replace `upload` in suites that exercise the capability. */
readonly fileUpload: TestFileUpload
@@ -233,6 +238,7 @@ export class SlotTestRuntime {
private readonly autoDeclared = new Set<string>()
private autoRootView: RenderResult | undefined
private readonly disposeWorkspaceSource: () => void
private readonly disposePanelInfoSource: () => void
private constructor(ctx: Context, slots: SlotRegistry) {
this.ctx = ctx
@@ -248,6 +254,7 @@ export class SlotTestRuntime {
ctx.provide('workspaces', this.workspaces)
ctx.provide('fileUpload', this.fileUpload as never)
this.disposeWorkspaceSource = slots.provideRoot({ hooks: { workspaces: this.workspaces.list } })
this.disposePanelInfoSource = slots.provideRoot({ hooks: { panelInfo: this.panelInfo } })
// Capturing install: the production renderer does the rendering; the
// wrapper only takes the host face for storeOf (no machinery copied).
const renderer = createSlotRenderer()
@@ -309,6 +316,11 @@ export class SlotTestRuntime {
this.disposeWorkspaceSource()
}
/** Release the default panel hook before mounting the production Layout owner. */
releasePanelInfoSource(): void {
this.disposePanelInfoSource()
}
/**
* Render the root slot tree through the ctx-level entry (the shell's own
* entry point): `ctx.slots.renderSlot('root', {})` under Testing Library.
@@ -333,13 +345,13 @@ export class SlotTestRuntime {
async declare(children: ChildrenDecl): Promise<void> {
for (const key of Object.keys(children)) this.autoDeclared.add(key)
const cell = this.ownerCell
const AutoFrame = (props: { renderSlot: (key: string, owner: object) => ReactNode }) => {
const AutoFrame = (props: { renderSlot: (key: string, owner: object, opts?: RenderOpts) => ReactNode }) => {
useSyncExternalStore(cell.subscribe, cell.getVersion)
// Keyed Fragments only: the renderer's outlet anchor is the one
// `[data-slot]` element — the frame adding its own would nest
// duplicate anchors under the same key.
return createElement(Fragment, null, cell.entries().map(([key, owner]) =>
createElement(Fragment, { key }, props.renderSlot(key, owner))))
return createElement(Fragment, null, cell.entries().map(([key, { owner, opts }]) =>
createElement(Fragment, { key }, props.renderSlot(key, owner, opts))))
}
await this.root.declare(children as never, AutoFrame as never)
}
@@ -352,16 +364,17 @@ export class SlotTestRuntime {
* slot of the same tree.
* @param key - a key declared through {@link SlotTestRuntime.declare}.
* @param owner - owner props share for the render site.
* @param opts - explicit keyed or list selection; retained by view updates.
* @returns the slot-local view (snapshot container, scoped queries, owner updates).
*/
renderSlot<K extends keyof SlotMap & string>(key: K, owner: OwnerOf<K>): SlotView<K> {
renderSlot<K extends keyof SlotMap & string>(key: K, owner: OwnerOf<K>, opts?: RenderOpts): SlotView<K> {
if (!this.autoDeclared.has(key)) {
throw new Error(`renderSlot('${key}') without declare() — declare the key first (or use root.declare for a custom frame)`)
}
const install = (next: object): void => {
// Synchronous cell write inside act: the frame re-renders through uSES.
act(() => {
this.ownerCell.set(key, next)
this.ownerCell.set(key, next, opts)
})
}
install(owner)
@@ -421,6 +434,7 @@ export class SlotTestRuntime {
for (const view of this.views.splice(0)) view.unmount()
for (const handle of this.handles.splice(0)) await handle.dispose()
this.root.release()
this.disposePanelInfoSource()
await this.sessions.disposeScopes()
localStorage.clear()
}
@@ -5,6 +5,8 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-chat/client'
import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { MainPanelId, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
import {
bindSnapshotSelector,
@@ -17,6 +19,12 @@ import {
const originalLanguages = [...navigator.languages]
const originalLanguage = navigator.language
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'trt.panel-info': { kind: 'keyed'; scope: 'root'; owner: { label: string } }
}
}
usePinnedBrowserLanguages('zh-CN', 'en-US')
afterEach(cleanup)
afterAll(() => {
@@ -38,6 +46,34 @@ function entry(seq: number): SessionLiveEventEntry {
}
describe('fixture helpers', () => {
it('drives panel hooks, retains keyed selection on owner updates, and releases the default source', async () => {
const runtime = await SlotTestRuntime.create()
try {
await runtime.declare({ 'trt.panel-info': { kind: 'keyed', scope: 'root' } })
runtime.slots.register({ name: 'trt.panel-info', key: 'probe' },
({ usePanelInfo, label }: PropsRuntime<'trt.panel-info'>) => (
<span>{label}:{usePanelInfo(info => info.activePanelId) ?? 'conversation'}</span>
))
const view = runtime.renderSlot('trt.panel-info', { label: 'first' }, { entryKey: 'probe' })
expect(view.container.textContent).toBe('first:conversation')
act(() => { runtime.panelInfo.set({ activePanelId: 'custom' as MainPanelId }) })
expect(view.container.textContent).toBe('first:custom')
view.update({ label: 'next' })
expect(view.container.textContent).toBe('next:custom')
const replacement = createSnapshotStore<PanelInfo>({ activePanelId: null })
await act(async () => {
runtime.releasePanelInfoSource()
await runtime.mount({
inject: ['slots'],
apply(ctx) { ctx.slots.provideRoot({ hooks: { panelInfo: replacement } }) },
})
})
expect(view.container.textContent).toBe('next:conversation')
} finally {
await runtime.dispose()
}
})
it('rejects an upload until a suite replaces the default stub', async () => {
const runtime = await SlotTestRuntime.create()
expect(runtime.fileUpload.available).toBe(false)
@@ -38,6 +38,9 @@
{
"path": "../../client/ui-conversation"
},
{
"path": "../../client/ui-layout"
},
{
"path": "../../client/ui-slots"
},
+3
View File
@@ -9900,6 +9900,9 @@ importers:
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:^
version: link:../../client/ui-conversation
'@deepseek-ai/dsh-client-ui-layout':
specifier: workspace:^
version: link:../../client/ui-layout
'@deepseek-ai/dsh-client-ui-renderer':
specifier: workspace:^
version: link:../../client/ui-renderer