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