Переглянути джерело

test(client): support panel selection in slot runtime

imccyu 3 днів тому
батько
коміт
fe460f7fd1

+ 1 - 0
packages/test-support/client-runtime/package.json

@@ -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:^",

+ 24 - 10
packages/test-support/client-runtime/src/index.ts

@@ -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()
   }

+ 36 - 0
packages/test-support/client-runtime/tests/helpers.client.spec.tsx

@@ -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)

+ 3 - 0
packages/test-support/client-runtime/tsconfig.json

@@ -38,6 +38,9 @@
     {
       "path": "../../client/ui-conversation"
     },
+    {
+      "path": "../../client/ui-layout"
+    },
     {
       "path": "../../client/ui-slots"
     },

+ 3 - 0
pnpm-lock.yaml

@@ -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