refactor(gui): slot system standard — single register, four props shares, framework store seat
The definitive slot model for the web client, replacing the first-generation
define/register two-step, ScopedSlots whitelist faces, and binding handles:
- 'root' is the only a-priori slot (SlotsService built-in); the shell renders
exactly ctx.slots.renderSlot('root', {}).
- register is the single API: children = slot declaration + render
authorization + runtime spec in one options object; misconfiguration fails
loud at load (duplicate declaration, undeclared contribution, one store
handle under two scopes).
- Component props arrive in four auto-derived shares: PropsRuntime<K>
(owner params + session/global standard kits via declare-merge),
PropsRenderSlots<S>, PropsStore<H>, and the inject business face.
sessionId is framework-supplied; hooks are framework-made only.
- Framework store seat: defineStore factories declare schema/actions/persist;
read = useStore, write = baked actions only; store scope derives from the
mounting entry; per-session persist keys and clearPersisted lifecycle.
- inject factories read the apply closure's own ctx (binding handles retired;
root-ctx back door closed); SessionProvider is self-wired render-prop.
- Rendering sits behind the SlotRenderer install seam; runtime stays
React-free; ownership ledger keyed to the single entry axis closes the
stale-authority window (StaleAuthorizationError probes).
Docs: the slot type-chain note is refreshed in place as the slot system
standard RFC (bilingual pair re-recorded); the web client architecture RFC
defers its slot sections there; packages/client/AGENTS.md gains the slot and
props discipline; gui-testing/web-styling notes drop missions/ references.
Tests: suites rewritten to the standard (props fed directly, real store
engines via createXXXStore().create(), no render machinery); load-time
negative samples for declaration/authorization/store conflicts; verified by
real-host playwright run (three columns, empty state, collapse, keyed session
remount, cross-slot selection sharing).
docs(ui-sidebar): point contract reference at the committed slot standard RFC
missions/ is workspace-local and never committed; the README must not cite it.
This commit is contained in:
@@ -1,16 +1,35 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AppFrame interaction spec: drag sequences (pointer capture + rAF flush),
|
||||
* concession response to viewport change, details stays mounted at zero
|
||||
* width. jsdom has no layout engine, so the frame width comes from a mocked
|
||||
* getBoundingClientRect and resizes are driven through the ResizeObserver
|
||||
* stub; assertions read the inline grid template.
|
||||
* AppFrame interaction spec under the four-share props form: real layout
|
||||
* store instance (createLayoutStore().create() — the test-sanctioned engine
|
||||
* path), a recording renderSlot stub, and a render-prop SessionProvider stub
|
||||
* (the real one is framework-wired to the renderer host; its own behavior is
|
||||
* web-react's spec territory). Drag sequences (pointer capture + rAF flush),
|
||||
* concession response to viewport change, and details staying mounted at
|
||||
* zero width are the preserved behavior assertions. jsdom has no layout
|
||||
* engine, so the frame width comes from a mocked getBoundingClientRect and
|
||||
* resizes are driven through the ResizeObserver stub.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { AppFrame, CenterColumn, DetailsColumn, type PanelState } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import { clampWidth } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
|
||||
import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
|
||||
import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
|
||||
|
||||
// Session-mode switch for the SessionProvider stub (hoisted above the mock).
|
||||
const sessionMode = vi.hoisted(() => ({ current: true }))
|
||||
|
||||
vi.mock('@deepseek-ai/dsh-client-web-react', async (importOriginal) => {
|
||||
const mod = await importOriginal<object>()
|
||||
return {
|
||||
...mod,
|
||||
// Render-prop contract stub: session mode runs children(id), empty mode
|
||||
// runs the empty branch — the frame must work against exactly this shape.
|
||||
SessionProvider: ({ children, empty }: { children: (id: string) => ReactNode; empty?: () => ReactNode }) =>
|
||||
sessionMode.current ? <>{children('s-test')}</> : <>{empty?.() ?? null}</>,
|
||||
}
|
||||
})
|
||||
|
||||
/** Observer stub: captures the callback so tests can fire resizes manually. */
|
||||
let fireResize: (() => void) | null = null
|
||||
@@ -26,22 +45,27 @@ let frameWidth = 1920
|
||||
|
||||
function mountFrame() {
|
||||
window.innerWidth = frameWidth // first-render viewport source before the observer fires
|
||||
const sidebar = createSnapshotStore<PanelState>({ open: true, width: 300 })
|
||||
const details = createSnapshotStore<PanelState>({ open: true, width: 360 })
|
||||
const instance = createLayoutStore().create()
|
||||
instance.actions.openDetails() // seed: sidebar at default 300, details open at default 360
|
||||
const slotCalls: { key: string; props: unknown }[] = []
|
||||
const renderSlot = ((key: string, owner: object) => {
|
||||
slotCalls.push({ key, props: owner })
|
||||
if (key === 'sidebar') return <div data-testid="sidebar-content" />
|
||||
if (key === 'conversation') return <div data-testid="center-content" />
|
||||
if (key === 'details') return <div data-testid="details-content" />
|
||||
return <div data-testid="empty-content" />
|
||||
}) as AppFrameProps['renderSlot']
|
||||
const useSessions = ((sel: (s: unknown) => unknown) => sel({ ids: [], byId: {} })) as never
|
||||
const utils = render(
|
||||
<AppFrame
|
||||
useSidebar={sidebar.useSelector}
|
||||
useDetails={details.useSelector}
|
||||
setSidebarWidth={(px) => { sidebar.update((d) => { d.width = clampWidth(px, 240, 420) }) }}
|
||||
setDetailsWidth={(px) => { details.update((d) => { d.width = clampWidth(px, 300, 520) }) }}
|
||||
sidebar={<div data-testid="sidebar-content" />}
|
||||
>
|
||||
<CenterColumn><div data-testid="center-content" /></CenterColumn>
|
||||
<DetailsColumn><div data-testid="details-content" /></DetailsColumn>
|
||||
</AppFrame>,
|
||||
useStore={instance.useSelector}
|
||||
actions={instance.actions}
|
||||
renderSlot={renderSlot}
|
||||
useSessions={useSessions}
|
||||
/>,
|
||||
)
|
||||
const frame = utils.container.firstElementChild as HTMLElement
|
||||
return { sidebar, details, frame, ...utils }
|
||||
return { instance, frame, slotCalls, ...utils }
|
||||
}
|
||||
|
||||
function tracks(frame: HTMLElement): number[] {
|
||||
@@ -61,6 +85,8 @@ function drag(handle: Element, fromX: number, toX: number): void {
|
||||
|
||||
beforeEach(() => {
|
||||
frameWidth = 1920
|
||||
sessionMode.current = true
|
||||
localStorage.clear() // the layout store persists; instances must not bleed across tests
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number)
|
||||
@@ -83,11 +109,37 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AppFrame', () => {
|
||||
it('renders three tracks from panel state', () => {
|
||||
it('renders three tracks from store state', () => {
|
||||
const { frame } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([300, 360])
|
||||
})
|
||||
|
||||
it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => {
|
||||
const { slotCalls, getByTestId } = mountFrame()
|
||||
expect(getByTestId('center-content')).toBeTruthy()
|
||||
expect(getByTestId('details-content')).toBeTruthy()
|
||||
const keys = slotCalls.map((c) => c.key)
|
||||
expect(keys).toContain('conversation')
|
||||
expect(keys).toContain('details')
|
||||
expect(keys).not.toContain('conversation.empty')
|
||||
expect(slotCalls.find((c) => c.key === 'conversation')!.props).toEqual({})
|
||||
expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({})
|
||||
})
|
||||
|
||||
it('renders the empty branch through conversation.empty when no session is current', () => {
|
||||
sessionMode.current = false
|
||||
const { slotCalls, getByTestId, queryByTestId } = mountFrame()
|
||||
expect(getByTestId('empty-content')).toBeTruthy()
|
||||
expect(queryByTestId('center-content')).toBeNull()
|
||||
expect(slotCalls.map((c) => c.key)).toContain('conversation.empty')
|
||||
expect(slotCalls.map((c) => c.key)).not.toContain('conversation')
|
||||
})
|
||||
|
||||
it('sidebar slot receives live concession output as owner props', () => {
|
||||
const { slotCalls } = mountFrame()
|
||||
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 300 })
|
||||
})
|
||||
|
||||
it('sidebar drag widens through rAF-batched pointer moves', () => {
|
||||
const { frame } = mountFrame()
|
||||
const handles = frame.querySelectorAll('[class*="handle"]')
|
||||
@@ -104,16 +156,16 @@ describe('AppFrame', () => {
|
||||
|
||||
it('drag base is the rendered (concession-clamped) width, not the preference', () => {
|
||||
frameWidth = 1250 // step-2 squeeze: details renders 310 while preference is 360
|
||||
const { frame, details } = mountFrame()
|
||||
const { frame, instance } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([300, 310])
|
||||
const handles = frame.querySelectorAll('[class*="handle"]')
|
||||
drag(handles[1]!, 940, 950) // shrink by 10 from the rendered width
|
||||
expect(details.getSnapshot().width).toBe(300)
|
||||
expect(instance.store.getSnapshot().details).toBe(300)
|
||||
})
|
||||
|
||||
it('details column stays mounted at zero width', () => {
|
||||
const { frame, details, getByTestId } = mountFrame()
|
||||
act(() => { details.update((d) => { d.open = false }) })
|
||||
const { frame, instance, getByTestId } = mountFrame()
|
||||
act(() => { instance.actions.closeDetails() })
|
||||
expect(tracks(frame)).toEqual([300, 0])
|
||||
expect(getByTestId('details-content')).toBeTruthy()
|
||||
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
|
||||
@@ -130,31 +182,31 @@ describe('AppFrame', () => {
|
||||
})
|
||||
|
||||
it('drag handles disappear for collapsed columns', () => {
|
||||
const { frame, details, sidebar } = mountFrame()
|
||||
const { frame, instance } = mountFrame()
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2)
|
||||
act(() => { details.update((d) => { d.open = false }) })
|
||||
act(() => { instance.actions.closeDetails() })
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
|
||||
act(() => { sidebar.update((d) => { d.open = false }) })
|
||||
act(() => { instance.actions.toggleSidebar() })
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppFrame — guard branches', () => {
|
||||
it('pointer moves without capture are ignored (no width write)', () => {
|
||||
const { frame, sidebar } = mountFrame()
|
||||
const { frame, instance } = mountFrame()
|
||||
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
|
||||
const before = sidebar.getSnapshot().width
|
||||
const before = instance.store.getSnapshot().sidebar
|
||||
// Move + up without a preceding pointerdown: hasPointerCapture is false.
|
||||
act(() => {
|
||||
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 9, clientX: 500, bubbles: true }))
|
||||
vi.advanceTimersByTime(20)
|
||||
handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 9, clientX: 500, bubbles: true }))
|
||||
})
|
||||
expect(sidebar.getSnapshot().width).toBe(before)
|
||||
expect(instance.store.getSnapshot().sidebar).toBe(before)
|
||||
})
|
||||
|
||||
it('two moves inside one frame coalesce through the pending rAF', () => {
|
||||
const { frame, sidebar } = mountFrame()
|
||||
const { frame, instance } = mountFrame()
|
||||
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
|
||||
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
|
||||
act(() => {
|
||||
@@ -165,11 +217,11 @@ describe('AppFrame — guard branches', () => {
|
||||
vi.advanceTimersByTime(20)
|
||||
})
|
||||
act(() => { handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 340, bubbles: true })) })
|
||||
expect(sidebar.getSnapshot().width).toBe(340)
|
||||
expect(instance.store.getSnapshot().sidebar).toBe(340)
|
||||
})
|
||||
|
||||
it('pointerup with a pending rAF cancels it and commits the final position', () => {
|
||||
const { frame, sidebar } = mountFrame()
|
||||
const { frame, instance } = mountFrame()
|
||||
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
|
||||
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
|
||||
act(() => {
|
||||
@@ -177,7 +229,7 @@ describe('AppFrame — guard branches', () => {
|
||||
// No timer advance: the rAF is still pending when pointerup arrives.
|
||||
handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 360, bubbles: true }))
|
||||
})
|
||||
expect(sidebar.getSnapshot().width).toBe(360)
|
||||
expect(instance.store.getSnapshot().sidebar).toBe(360)
|
||||
})
|
||||
|
||||
it('zero-width resize reports are ignored (display:none window)', () => {
|
||||
@@ -199,7 +251,7 @@ describe('AppFrame — unmount with an in-flight resize frame', () => {
|
||||
expect(() => { vi.advanceTimersByTime(20) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('double resize inside one frame rides the pending rAF (?"?= guard)', () => {
|
||||
it('double resize inside one frame rides the pending rAF (??= guard)', () => {
|
||||
const { frame } = mountFrame()
|
||||
frameWidth = 1250
|
||||
act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
// @vitest-environment jsdom
|
||||
// Client apply wiring: ctx.layout provided, the four layout-owned slots
|
||||
// defined, teardown cascades (service unprovided + slot specs removed + list
|
||||
// subscription dropped). Node half and the invariant companion ride along —
|
||||
// they are one-line surfaces the aggregate coverage gate still requires
|
||||
// exercised.
|
||||
// Client apply wiring under the terminal register form: ctx.layout provided,
|
||||
// ONE register() call declares the four child slots + seats the store factory
|
||||
// + wires the panel actions through the inject hook; teardown cascades
|
||||
// (service unprovided + declarations gone + registration cleared). Node half
|
||||
// and the invariant companion ride along — one-line surfaces the aggregate
|
||||
// coverage gate still requires exercised.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout'
|
||||
import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant'
|
||||
@@ -18,8 +17,6 @@ async function bench() {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
|
||||
ctx.provide('sessions', { list })
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService }
|
||||
}
|
||||
|
||||
@@ -28,28 +25,31 @@ describe('ui-layout client apply', () => {
|
||||
expect(inject).toContain('slots')
|
||||
})
|
||||
|
||||
it('provides ctx.layout and defines the four layout-owned slots', async () => {
|
||||
it('provides ctx.layout and registers AppFrame into root with the four child declarations', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
const fiber = ctx.plugin({ inject: ['slots'], apply })
|
||||
await fiber.await()
|
||||
expect(ctx.get('layout')).toBeInstanceOf(LayoutService)
|
||||
// The one register() call occupied 'root'…
|
||||
expect(slots.entries('root')).toHaveLength(1)
|
||||
// …and declared the four children in the ledger.
|
||||
expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(slots.spec('conversation')).toEqual({ kind: 'single', scope: 'session' })
|
||||
expect(slots.spec('details')).toEqual({ kind: 'single', scope: 'session' })
|
||||
expect(slots.spec('conversation.empty')).toEqual({ kind: 'single', scope: 'root' })
|
||||
})
|
||||
|
||||
it('teardown unwinds service, slot specs, and the prune subscription', async () => {
|
||||
it('teardown unwinds the service, the root registration, and the child declarations', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
const fiber = ctx.plugin({ inject: ['slots'], apply })
|
||||
await fiber.await()
|
||||
const layout = ctx.get('layout') as LayoutService
|
||||
const disposeSpy = vi.spyOn(layout, 'dispose')
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('layout')).toBeUndefined()
|
||||
expect(slots.entries('root')).toHaveLength(0)
|
||||
expect(slots.spec('sidebar')).toBeUndefined()
|
||||
expect(slots.spec('conversation.empty')).toBeUndefined()
|
||||
expect(disposeSpy).toHaveBeenCalledTimes(1)
|
||||
// The built-in root declaration survives entry teardown (runtime-owned).
|
||||
expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import {
|
||||
DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MIN,
|
||||
} from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
|
||||
|
||||
const open = (width: number) => ({ open: true, width })
|
||||
const closed = (width: number) => ({ open: false, width })
|
||||
// Numeric preference form (0 = closed); helpers keep the scenario names readable.
|
||||
const open = (width: number) => width
|
||||
const closed = (_width: number) => 0
|
||||
|
||||
describe('clampWidth', () => {
|
||||
it('clamps into the range and rounds', () => {
|
||||
|
||||
73
packages/client/ui-layout/tests/layout-store.spec.ts
Normal file
73
packages/client/ui-layout/tests/layout-store.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* createLayoutStore unit account: init shape, the action write set (clamp
|
||||
* inside actions), and the persist key round-trip over jsdom localStorage.
|
||||
* Uses the test-sanctioned path: factory self-call + .create() gives the
|
||||
* real engine instance (same create path as production).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
|
||||
import {
|
||||
DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
|
||||
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
|
||||
} from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
|
||||
|
||||
const PERSIST_KEY = 'dsh.layout.panels'
|
||||
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
describe('createLayoutStore', () => {
|
||||
it('initializes with sidebar open at default and details closed', () => {
|
||||
const { store } = createLayoutStore().create()
|
||||
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 })
|
||||
})
|
||||
|
||||
it('each create() is an independent instance (factory is not a singleton)', () => {
|
||||
const a = createLayoutStore().create()
|
||||
const b = createLayoutStore().create()
|
||||
a.actions.setSidebar(400)
|
||||
expect(b.store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
|
||||
})
|
||||
|
||||
it('setSidebar/setDetails clamp into the contract ranges', () => {
|
||||
const { store, actions } = createLayoutStore().create()
|
||||
actions.setSidebar(1)
|
||||
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_MIN)
|
||||
actions.setSidebar(9999)
|
||||
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_MAX)
|
||||
actions.setDetails(1)
|
||||
expect(store.getSnapshot().details).toBe(DETAILS_MIN)
|
||||
actions.setDetails(9999)
|
||||
expect(store.getSnapshot().details).toBe(DETAILS_MAX)
|
||||
})
|
||||
|
||||
it('toggleSidebar flips closed <-> contract default (drag width forgotten)', () => {
|
||||
const { store, actions } = createLayoutStore().create()
|
||||
actions.setSidebar(400)
|
||||
actions.toggleSidebar()
|
||||
expect(store.getSnapshot().sidebar).toBe(0)
|
||||
actions.toggleSidebar()
|
||||
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
|
||||
})
|
||||
|
||||
it('openDetails is a no-op when already open; closeDetails zeroes', () => {
|
||||
const { store, actions } = createLayoutStore().create()
|
||||
actions.openDetails()
|
||||
expect(store.getSnapshot().details).toBe(DETAILS_DEFAULT)
|
||||
actions.setDetails(500)
|
||||
actions.openDetails()
|
||||
expect(store.getSnapshot().details).toBe(500)
|
||||
actions.closeDetails()
|
||||
expect(store.getSnapshot().details).toBe(0)
|
||||
})
|
||||
|
||||
it('persists under dsh.layout.panels and rehydrates on the next create', () => {
|
||||
const first = createLayoutStore().create()
|
||||
first.actions.setSidebar(320)
|
||||
first.actions.openDetails()
|
||||
expect(JSON.parse(localStorage.getItem(PERSIST_KEY) ?? '{}')).toEqual({ sidebar: 320, details: DETAILS_DEFAULT })
|
||||
|
||||
const second = createLayoutStore().create()
|
||||
expect(second.store.getSnapshot()).toEqual({ sidebar: 320, details: DETAILS_DEFAULT })
|
||||
})
|
||||
})
|
||||
@@ -1,139 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* LayoutService over the real snapshot-store engine (persist rides jsdom
|
||||
* localStorage). ctx is faked down to the one surface the service reads:
|
||||
* ctx.sessions.list as a real store, so prune subscriptions are exercised
|
||||
* for real.
|
||||
* LayoutService behavior: the cross-plugin panel-action face. Geometry
|
||||
* lives in the entry store (layout-store.spec.ts) — here we assert the
|
||||
* delegation seam: attachPanels wiring, the three actions forwarding, the
|
||||
* unwired fail-loud, and re-attach overwriting a stale action set.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import { DETAILS_DEFAULT, SIDEBAR_DEFAULT } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/src/client/service.ts'
|
||||
import type { PanelActions } from '@deepseek-ai/dsh-client-ui-layout/src/client/service.ts'
|
||||
|
||||
function makeCtx() {
|
||||
const list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
|
||||
// The service resolves sessions via ctx.get (typed merge suspended, see service).
|
||||
const ctx = { get: (name: string) => (name === 'sessions' ? { list } : undefined) } as unknown as Context
|
||||
return { ctx, list }
|
||||
function fakePanels(): PanelActions {
|
||||
return {
|
||||
setSidebar: vi.fn(),
|
||||
setDetails: vi.fn(),
|
||||
toggleSidebar: vi.fn(),
|
||||
openDetails: vi.fn(),
|
||||
closeDetails: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-side brand: specs mint ids the wire would normally brand. */
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
const summary = (id: SessionId) => ({ id, title: id as string, running: false, updatedAt: 1 })
|
||||
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
describe('LayoutService', () => {
|
||||
it('defaults: sidebar open 300, details closed 360, empty nav', () => {
|
||||
const svc = new LayoutService(makeCtx().ctx)
|
||||
expect(svc.sidebar.getSnapshot()).toEqual({ open: true, width: SIDEBAR_DEFAULT })
|
||||
expect(svc.details.getSnapshot()).toEqual({ open: false, width: DETAILS_DEFAULT })
|
||||
expect(svc.current.getSnapshot()).toEqual({ viewFor: {} })
|
||||
svc.dispose()
|
||||
it('forwards the three panel actions to the attached set', () => {
|
||||
const service = new LayoutService()
|
||||
const panels = fakePanels()
|
||||
service.attachPanels(panels)
|
||||
|
||||
service.toggleSidebar()
|
||||
service.openDetails()
|
||||
service.closeDetails()
|
||||
|
||||
expect(panels.toggleSidebar).toHaveBeenCalledTimes(1)
|
||||
expect(panels.openDetails).toHaveBeenCalledTimes(1)
|
||||
expect(panels.closeDetails).toHaveBeenCalledTimes(1)
|
||||
expect(panels.setSidebar).not.toHaveBeenCalled()
|
||||
expect(panels.setDetails).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('open validates against sessions.list and selects', () => {
|
||||
const { ctx, list } = makeCtx()
|
||||
const svc = new LayoutService(ctx)
|
||||
expect(() => { svc.open(sid('nope')) }).toThrow(/unknown session/)
|
||||
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
|
||||
svc.open(sid('s1'))
|
||||
expect(svc.current.getSnapshot().sessionId).toBe('s1')
|
||||
svc.dispose()
|
||||
it('fails loud before the root entry wired its actions', () => {
|
||||
const service = new LayoutService()
|
||||
expect(() => { service.toggleSidebar() }).toThrow(/panel actions not wired/)
|
||||
expect(() => { service.openDetails() }).toThrow(/panel actions not wired/)
|
||||
expect(() => { service.closeDetails() }).toThrow(/panel actions not wired/)
|
||||
})
|
||||
|
||||
it('width setters clamp into contract ranges', () => {
|
||||
const svc = new LayoutService(makeCtx().ctx)
|
||||
svc.setSidebarWidth(10)
|
||||
expect(svc.sidebar.getSnapshot().width).toBe(240)
|
||||
svc.setSidebarWidth(10_000)
|
||||
expect(svc.sidebar.getSnapshot().width).toBe(420)
|
||||
svc.setDetailsWidth(10)
|
||||
expect(svc.details.getSnapshot().width).toBe(300)
|
||||
svc.setDetailsWidth(10_000)
|
||||
expect(svc.details.getSnapshot().width).toBe(520)
|
||||
svc.dispose()
|
||||
})
|
||||
it('re-attach overwrites the stale action set (entry re-register)', () => {
|
||||
const service = new LayoutService()
|
||||
const stale = fakePanels()
|
||||
const fresh = fakePanels()
|
||||
service.attachPanels(stale)
|
||||
service.attachPanels(fresh)
|
||||
|
||||
it('toggle and open/close flip flags without touching widths', () => {
|
||||
const svc = new LayoutService(makeCtx().ctx)
|
||||
svc.toggleSidebar()
|
||||
expect(svc.sidebar.getSnapshot()).toEqual({ open: false, width: SIDEBAR_DEFAULT })
|
||||
svc.openDetails()
|
||||
expect(svc.details.getSnapshot().open).toBe(true)
|
||||
svc.closeDetails()
|
||||
expect(svc.details.getSnapshot().open).toBe(false)
|
||||
svc.dispose()
|
||||
})
|
||||
service.toggleSidebar()
|
||||
|
||||
it('prune clears viewFor entries and the current selection of removed sessions', () => {
|
||||
const { ctx, list } = makeCtx()
|
||||
const svc = new LayoutService(ctx)
|
||||
list.update((d) => {
|
||||
d.ids.push(sid('s1'), sid('s2'))
|
||||
d.byId[sid('s1')] = summary(sid('s1'))
|
||||
d.byId[sid('s2')] = summary(sid('s2'))
|
||||
})
|
||||
svc.open(sid('s1'))
|
||||
svc.openView(sid('s1'), 'chat')
|
||||
svc.openView(sid('s2'), 'chat')
|
||||
list.update((d) => { d.ids = [sid('s2')]; d.byId = { [sid('s2')]: d.byId[sid('s2')]! } })
|
||||
expect(svc.current.getSnapshot().sessionId).toBeUndefined()
|
||||
expect(svc.current.getSnapshot().viewFor).toEqual({ s2: 'chat' })
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it('prune leaves untouched state alone (no gratuitous store writes)', () => {
|
||||
const { ctx, list } = makeCtx()
|
||||
const svc = new LayoutService(ctx)
|
||||
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
|
||||
svc.open(sid('s1'))
|
||||
const before = svc.current.getSnapshot()
|
||||
list.update((d) => { d.byId[sid('s1')] = { ...d.byId[sid('s1')]!, title: 'renamed' } })
|
||||
expect(svc.current.getSnapshot()).toBe(before)
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it('persists panel state and nav across instances (fresh service, same storage)', () => {
|
||||
const first = new LayoutService(makeCtx().ctx)
|
||||
first.setSidebarWidth(320)
|
||||
first.openDetails()
|
||||
first.dispose()
|
||||
const second = new LayoutService(makeCtx().ctx)
|
||||
expect(second.sidebar.getSnapshot().width).toBe(320)
|
||||
expect(second.details.getSnapshot().open).toBe(true)
|
||||
second.dispose()
|
||||
})
|
||||
|
||||
it('dispose stops pruning', () => {
|
||||
const { ctx, list } = makeCtx()
|
||||
const svc = new LayoutService(ctx)
|
||||
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
|
||||
svc.open(sid('s1'))
|
||||
svc.dispose()
|
||||
list.update((d) => { d.ids = []; d.byId = {} })
|
||||
expect(svc.current.getSnapshot().sessionId).toBe('s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LayoutService — construction and prune edge branches', () => {
|
||||
it('throws loud when the sessions service is absent', () => {
|
||||
const bare = { get: () => undefined } as unknown as Context
|
||||
expect(() => new LayoutService(bare)).toThrow(/sessions service unavailable/)
|
||||
})
|
||||
|
||||
it('prunes stale viewFor while the current selection stays valid', () => {
|
||||
// Covers the prune branch where staleView holds but staleCurrent does not.
|
||||
const { ctx, list } = makeCtx()
|
||||
const svc = new LayoutService(ctx)
|
||||
list.update((d) => { d.ids.push(sid('s1'), sid('s2')); d.byId[sid('s1')] = summary(sid('s1')); d.byId[sid('s2')] = summary(sid('s2')) })
|
||||
svc.open(sid('s1'))
|
||||
svc.openView(sid('s2'), 'chat')
|
||||
list.update((d) => { d.ids = [sid('s1')]; d.byId = { [sid('s1')]: d.byId[sid('s1')]! } })
|
||||
expect(svc.current.getSnapshot().sessionId).toBe('s1')
|
||||
expect(svc.current.getSnapshot().viewFor).toEqual({})
|
||||
svc.dispose()
|
||||
expect(stale.toggleSidebar).not.toHaveBeenCalled()
|
||||
expect(fresh.toggleSidebar).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user