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,165 +1,290 @@
|
||||
// SlotCore terminal-design behavior: the single register composition API —
|
||||
// a-priori 'root', children declaration/authorization, load-time validation,
|
||||
// one-axis lifecycle cascade, store scope pinning, subscription surface.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { FC } from 'react'
|
||||
import type { RootBinding, SessionBinding, SlotOptions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SlotComponent, StoreHandle } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
// 'root' is NOT merged here: the runtime package owns the built-in row, and
|
||||
// the client aggregate program would see both merges collide.
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'test.single': { kind: 'single'; scope: 'root'; props: { label: string } }
|
||||
'test.list': { kind: 'list'; scope: 'root'; props: { label: string } }
|
||||
'test.keyed': { kind: 'keyed'; scope: 'session'; props: { label: string; useSession: unknown } }
|
||||
'test.single': { kind: 'single'; scope: 'root' }
|
||||
'test.session': { kind: 'single'; scope: 'session' }
|
||||
'test.list': { kind: 'list'; scope: 'root' }
|
||||
'test.keyed': { kind: 'keyed'; scope: 'session' }
|
||||
'test.grandchild': { kind: 'single'; scope: 'root' }
|
||||
}
|
||||
}
|
||||
|
||||
const Comp: FC<{ label: string }> = () => null
|
||||
const SessionComp: FC<{ label: string; useSession: unknown }> = () => null
|
||||
// Wide-accepting fixture: assignable wherever the composed constraint is an
|
||||
// object type (children-declaring fixtures erase via `as never` instead —
|
||||
// RendersCheck would demand a renderSlot consumer).
|
||||
const Comp: SlotComponent<object> = () => null
|
||||
|
||||
/** A minimal structurally-valid store handle (identity is what the ledger tracks). */
|
||||
function fakeHandle(): StoreHandle<{ n: number }, Record<string, (d: { n: number }) => void>> {
|
||||
return {
|
||||
spec: { init: () => ({ n: 0 }), actions: {} },
|
||||
create: () => { throw new Error('not under test') },
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a root-frame entry declaring the four test child slots. */
|
||||
function mountFrame(core: SlotCore) {
|
||||
return core.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'test.single': { kind: 'single', scope: 'root' },
|
||||
'test.session': { kind: 'single', scope: 'session' },
|
||||
'test.list': { kind: 'list', scope: 'root' },
|
||||
'test.keyed': { kind: 'keyed', scope: 'session' },
|
||||
},
|
||||
// Type-level renderSlot presence is proven by the type-chain spec; erasing
|
||||
// here keeps runtime fixtures terse.
|
||||
}, Comp as never)
|
||||
}
|
||||
|
||||
const flushMicrotasks = () => new Promise<void>((resolve) => { queueMicrotask(resolve) })
|
||||
|
||||
describe('SlotCore kind semantics', () => {
|
||||
it('throws on register before define', () => {
|
||||
describe('a-priori root and declaration gate', () => {
|
||||
it('seeds root as single/root at construction', () => {
|
||||
const core = new SlotCore()
|
||||
expect(() => core.register('test.single', Comp)).toThrow('not defined')
|
||||
expect(core.specDynamic('root')).toEqual({ kind: 'single', scope: 'root' })
|
||||
})
|
||||
|
||||
it('throws on duplicate define', () => {
|
||||
it('throws on registering into an undeclared slot', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
expect(() => core.define('test.single', { kind: 'single', scope: 'root' })).toThrow('already defined')
|
||||
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('not declared')
|
||||
})
|
||||
|
||||
it('single: second registration throws, disposer frees the seat', () => {
|
||||
it('root is single: a second frame registration throws', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
const dispose = core.register('test.single', Comp)
|
||||
expect(() => core.register('test.single', Comp)).toThrow('already has a registration')
|
||||
dispose()
|
||||
mountFrame(core)
|
||||
expect(() => core.register({ name: 'root' }, Comp)).toThrow('already has a registration')
|
||||
})
|
||||
|
||||
it('children declaration makes child slots registerable, with specs recorded', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
expect(core.specDynamic('test.session')).toEqual({ kind: 'single', scope: 'session' })
|
||||
expect(() => core.register({ name: 'test.single' }, Comp)).not.toThrow()
|
||||
})
|
||||
|
||||
it('duplicate child declaration throws naming the first declarer', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
core.register({ name: 'test.single', children: { 'test.grandchild': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
expect(() => core.register(
|
||||
{ name: 'test.session', children: { 'test.grandchild': { kind: 'single', scope: 'root' } }, registrant: 'imposter' },
|
||||
Comp as never,
|
||||
)).toThrow(/already declared.*test\.single/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lifecycle cascade (one axis)', () => {
|
||||
it('disposing a declaring entry collapses child slots and their contributions recursively', () => {
|
||||
const core = new SlotCore()
|
||||
const disposeFrame = mountFrame(core)
|
||||
const disposeChild = core.register(
|
||||
{ name: 'test.single', children: { 'test.grandchild': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
core.register({ name: 'test.grandchild' }, Comp)
|
||||
expect(core.entries('test.grandchild')).toHaveLength(1)
|
||||
|
||||
disposeFrame()
|
||||
expect(core.specDynamic('test.single')).toBeUndefined()
|
||||
expect(core.specDynamic('test.grandchild')).toBeUndefined()
|
||||
expect(core.entries('test.single')).toHaveLength(0)
|
||||
expect(() => core.register('test.single', Comp)).not.toThrow()
|
||||
expect(core.entries('test.grandchild')).toHaveLength(0)
|
||||
// Stale disposer of a cascaded-away entry is a no-op.
|
||||
expect(() => { disposeChild() }).not.toThrow()
|
||||
// Slots return to undeclared: contributing again throws until redeclared.
|
||||
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('not declared')
|
||||
})
|
||||
|
||||
it('registration disposers are idempotent', () => {
|
||||
const core = new SlotCore()
|
||||
const dispose = mountFrame(core)
|
||||
dispose()
|
||||
dispose()
|
||||
expect(core.entries('root')).toHaveLength(0)
|
||||
// Redeclare works after collapse.
|
||||
mountFrame(core)
|
||||
expect(core.specDynamic('test.single')).toBeDefined()
|
||||
})
|
||||
|
||||
it('isLive tracks ledger membership across dispose', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
const dispose = core.register({ name: 'test.single' }, Comp)
|
||||
const entry = core.entries('test.single')[0]!
|
||||
expect(core.isLive(entry)).toBe(true)
|
||||
dispose()
|
||||
expect(core.isLive(entry)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kind semantics', () => {
|
||||
it('keyed: duplicate key throws, missing key throws', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('test.keyed', { kind: 'keyed', scope: 'session' })
|
||||
core.register('test.keyed', SessionComp, { key: 'a' })
|
||||
expect(() => core.register('test.keyed', SessionComp, { key: 'a' })).toThrow('key "a"')
|
||||
// Statically rejected since RegisterArgs made keyed options mandatory;
|
||||
// the runtime guard stays for dynamically-composed callers.
|
||||
// @ts-expect-error keyed registration requires options
|
||||
expect(() => core.register('test.keyed', SessionComp)).toThrow('requires options.key')
|
||||
expect(() => core.register('test.keyed', SessionComp, { key: 'b' })).not.toThrow()
|
||||
mountFrame(core)
|
||||
core.register({ name: 'test.keyed', key: 'a' }, Comp)
|
||||
expect(() => core.register({ name: 'test.keyed', key: 'a' }, Comp)).toThrow('key "a"')
|
||||
// Statically rejected (KindOptions); runtime guard stays for dynamic callers.
|
||||
// @ts-expect-error keyed registration requires options.key
|
||||
expect(() => core.register({ name: 'test.keyed' }, Comp)).toThrow('requires options.key')
|
||||
expect(() => core.register({ name: 'test.keyed', key: 'b' }, Comp)).not.toThrow()
|
||||
})
|
||||
|
||||
it('list: duplicate id throws, missing id throws, entries sort by order stably', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('test.list', { kind: 'list', scope: 'root' })
|
||||
core.register('test.list', Comp, { id: 'c', order: 10 })
|
||||
core.register('test.list', Comp, { id: 'a' })
|
||||
core.register('test.list', Comp, { id: 'b' })
|
||||
expect(() => core.register('test.list', Comp, { id: 'a' })).toThrow('id "a"')
|
||||
// @ts-expect-error list registration requires options (static since RegisterArgs)
|
||||
expect(() => core.register('test.list', Comp)).toThrow('requires options.id')
|
||||
const ids = core.entries('test.list').map(e => (e.options as { id: string }).id)
|
||||
expect(ids).toEqual(['a', 'b', 'c'])
|
||||
mountFrame(core)
|
||||
core.register({ name: 'test.list', id: 'c', order: 10 }, Comp)
|
||||
core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
core.register({ name: 'test.list', id: 'b' }, Comp)
|
||||
expect(() => core.register({ name: 'test.list', id: 'a' }, Comp)).toThrow('id "a"')
|
||||
// @ts-expect-error list registration requires options.id
|
||||
expect(() => core.register({ name: 'test.list' }, Comp)).toThrow('requires options.id')
|
||||
expect(core.entries('test.list').map(e => e.options.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('spec() exposes the definition; define disposer clears spec and entries', () => {
|
||||
it('single: second registration throws, disposer frees the seat', () => {
|
||||
const core = new SlotCore()
|
||||
const dispose = core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
core.register('test.single', Comp)
|
||||
expect(core.spec('test.single')).toEqual({ kind: 'single', scope: 'root' })
|
||||
mountFrame(core)
|
||||
const dispose = core.register({ name: 'test.single' }, Comp)
|
||||
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('already has a registration')
|
||||
dispose()
|
||||
expect(core.spec('test.single')).toBeUndefined()
|
||||
expect(core.entries('test.single')).toHaveLength(0)
|
||||
expect(() => core.register('test.single', Comp)).toThrow('not defined')
|
||||
})
|
||||
|
||||
it('disposers are idempotent and stale disposers after redefine are no-ops', () => {
|
||||
const core = new SlotCore()
|
||||
const disposeDef = core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
const disposeReg = core.register('test.single', Comp)
|
||||
disposeReg()
|
||||
disposeReg()
|
||||
disposeDef()
|
||||
disposeDef()
|
||||
core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
core.register('test.single', Comp)
|
||||
disposeDef()
|
||||
disposeReg()
|
||||
expect(core.spec('test.single')).toBeDefined()
|
||||
expect(core.entries('test.single')).toHaveLength(1)
|
||||
expect(() => core.register({ name: 'test.single' }, Comp)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SlotCore subscription surface', () => {
|
||||
describe('store scope pinning', () => {
|
||||
it('one shared handle under two scopes throws at load', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
const handle = fakeHandle()
|
||||
core.register({ name: 'test.session', store: handle }, Comp as never)
|
||||
expect(() => core.register({ name: 'test.single', store: handle }, Comp as never))
|
||||
.toThrow('one handle, one scope')
|
||||
})
|
||||
|
||||
it('same handle under same scope is fine; full unmount releases the pin', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
const handle = fakeHandle()
|
||||
const d1 = core.register({ name: 'test.list', id: 'x', store: handle }, Comp as never)
|
||||
const d2 = core.register({ name: 'test.list', id: 'y', store: handle }, Comp as never)
|
||||
d1()
|
||||
// Still mounted once — scope stays pinned.
|
||||
expect(() => core.register({ name: 'test.session', store: handle }, Comp as never))
|
||||
.toThrow('one handle, one scope')
|
||||
d2()
|
||||
// All mounts gone: the handle may pin a new scope.
|
||||
expect(() => core.register({ name: 'test.session', store: handle }, Comp as never)).not.toThrow()
|
||||
})
|
||||
|
||||
it('factories are exempt from pinning (no shared identity)', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
const factory = () => fakeHandle()
|
||||
core.register({ name: 'test.session', store: factory }, Comp as never)
|
||||
expect(() => core.register({ name: 'test.single', store: factory }, Comp as never)).not.toThrow()
|
||||
})
|
||||
|
||||
it('cascade releases store pins of collapsed child entries', () => {
|
||||
const core = new SlotCore()
|
||||
const disposeFrame = mountFrame(core)
|
||||
const handle = fakeHandle()
|
||||
core.register({ name: 'test.session', store: handle }, Comp as never)
|
||||
disposeFrame()
|
||||
mountFrame(core)
|
||||
expect(() => core.register({ name: 'test.single', store: handle }, Comp as never)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription surface', () => {
|
||||
it('entries() returns a stable cached reference between mutations', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('test.list', { kind: 'list', scope: 'root' })
|
||||
core.register('test.list', Comp, { id: 'a' })
|
||||
mountFrame(core)
|
||||
core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
const first = core.entries('test.list')
|
||||
expect(core.entries('test.list')).toBe(first)
|
||||
core.register('test.list', Comp, { id: 'b' })
|
||||
core.register({ name: 'test.list', id: 'b' }, Comp)
|
||||
expect(core.entries('test.list')).not.toBe(first)
|
||||
})
|
||||
|
||||
it('bumps version synchronously but batches notifications per microtask', async () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
const fn = vi.fn()
|
||||
core.subscribe('test.list', fn)
|
||||
core.define('test.list', { kind: 'list', scope: 'root' })
|
||||
core.register('test.list', Comp, { id: 'a' })
|
||||
core.register('test.list', Comp, { id: 'b' })
|
||||
expect(core.getVersion('test.list')).toBe(3)
|
||||
const before = core.getVersion('test.list')
|
||||
core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
core.register({ name: 'test.list', id: 'b' }, Comp)
|
||||
expect(core.getVersion('test.list')).toBe(before + 2)
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
await flushMicrotasks()
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
core.register('test.list', Comp, { id: 'c' })
|
||||
core.register({ name: 'test.list', id: 'c' }, Comp)
|
||||
await flushMicrotasks()
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('declaration itself notifies child-key subscribers (subscribe-ahead allowed)', async () => {
|
||||
const core = new SlotCore()
|
||||
const fn = vi.fn()
|
||||
core.subscribe('test.single', fn)
|
||||
mountFrame(core)
|
||||
await flushMicrotasks()
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('notifies only subscribers of the touched key; unsubscribe stops delivery', async () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
await flushMicrotasks()
|
||||
const single = vi.fn()
|
||||
const list = vi.fn()
|
||||
core.subscribe('test.single', single)
|
||||
const unsubscribe = core.subscribe('test.list', list)
|
||||
core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
core.register({ name: 'test.single' }, Comp)
|
||||
await flushMicrotasks()
|
||||
expect(single).toHaveBeenCalledTimes(1)
|
||||
expect(list).not.toHaveBeenCalled()
|
||||
unsubscribe()
|
||||
core.define('test.list', { kind: 'list', scope: 'root' })
|
||||
core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
await flushMicrotasks()
|
||||
expect(list).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a mutation from inside a flush re-schedules instead of being lost', async () => {
|
||||
const core = new SlotCore()
|
||||
core.define('test.list', { kind: 'list', scope: 'root' })
|
||||
mountFrame(core)
|
||||
await flushMicrotasks()
|
||||
const seen: number[] = []
|
||||
let reentered = false
|
||||
core.subscribe('test.list', () => {
|
||||
seen.push(core.getVersion('test.list'))
|
||||
if (!reentered) {
|
||||
reentered = true
|
||||
core.register('test.list', Comp, { id: 'reentrant' })
|
||||
core.register({ name: 'test.list', id: 'reentrant' }, Comp)
|
||||
}
|
||||
})
|
||||
core.register('test.list', Comp, { id: 'a' })
|
||||
core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
await flushMicrotasks()
|
||||
await flushMicrotasks()
|
||||
expect(seen).toHaveLength(2)
|
||||
expect(core.entries('test.list')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('getVersion is 0 for untouched keys and monotonic across redefine', () => {
|
||||
it('getVersion is 0 for untouched keys and monotonic across redeclaration', () => {
|
||||
const core = new SlotCore()
|
||||
expect(core.getVersion('test.single')).toBe(0)
|
||||
const dispose = core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
const dispose = mountFrame(core)
|
||||
dispose()
|
||||
const after = core.getVersion('test.single')
|
||||
core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
mountFrame(core)
|
||||
expect(core.getVersion('test.single')).toBeGreaterThan(after)
|
||||
})
|
||||
|
||||
@@ -167,43 +292,14 @@ describe('SlotCore subscription surface', () => {
|
||||
const core = new SlotCore()
|
||||
const keys: string[] = []
|
||||
const off = core.onMutate(key => keys.push(key))
|
||||
core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
core.define('test.list', { kind: 'list', scope: 'root' })
|
||||
core.register('test.list', Comp, { id: 'a' })
|
||||
expect(keys).toEqual(['test.single', 'test.list', 'test.list'])
|
||||
mountFrame(core)
|
||||
// Contribution first, then each declared child key.
|
||||
expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed'])
|
||||
keys.length = 0
|
||||
core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
expect(keys).toEqual(['test.list'])
|
||||
off()
|
||||
core.register('test.list', Comp, { id: 'b' })
|
||||
expect(keys).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SlotOptions typing', () => {
|
||||
it('rejects kind-mismatched options and scope-mismatched inject bindings', () => {
|
||||
// Compile-time negatives only: the body never runs (some rejected shapes
|
||||
// would be legal at runtime, which validates kinds, not props).
|
||||
const typeNegatives = (core: SlotCore) => {
|
||||
// @ts-expect-error single options take no key
|
||||
core.register('test.single', Comp, { key: 'x' })
|
||||
// @ts-expect-error list options require id
|
||||
core.register('test.list', Comp, { order: 1 })
|
||||
// @ts-expect-error keyed options require key
|
||||
core.register('test.keyed', SessionComp, { inject: () => ({}) })
|
||||
// @ts-expect-error component props must match the SlotMap contract
|
||||
core.register('test.single', SessionComp)
|
||||
// @ts-expect-error kind must match the SlotMap declaration
|
||||
core.define('test.single', { kind: 'list', scope: 'root' })
|
||||
const rootInject: SlotOptions<{ kind: 'single'; scope: 'root'; props: { label: string } }> = {
|
||||
// @ts-expect-error root slots bind RootBinding, which has no sessionId
|
||||
inject: (b: RootBinding) => ({ sessionId: b.sessionId }),
|
||||
}
|
||||
return rootInject
|
||||
}
|
||||
expect(typeNegatives).toBeTypeOf('function')
|
||||
|
||||
const sessionInject: SlotOptions<{ kind: 'keyed'; scope: 'session'; props: { label: string } }> = {
|
||||
key: 'k',
|
||||
inject: (b: SessionBinding) => ({ sessionId: b.sessionId }),
|
||||
}
|
||||
expect(sessionInject.key).toBe('k')
|
||||
core.register({ name: 'test.list', id: 'b' }, Comp)
|
||||
expect(keys).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
// Dynamic-key escape hatches and untouched-key behavior of the terminal core.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FC } from 'react'
|
||||
import type { ScopedSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { narrowSlots, SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SlotComponent } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'surface.a': { kind: 'single'; scope: 'root'; props: { label: string } }
|
||||
'surface.b': { kind: 'single'; scope: 'root'; props: { label: string } }
|
||||
'surface.a': { kind: 'single'; scope: 'root' }
|
||||
'surface.b': { kind: 'single'; scope: 'root' }
|
||||
}
|
||||
}
|
||||
|
||||
const Comp: FC<{ label: string }> = () => null
|
||||
const Comp: SlotComponent<object> = () => null
|
||||
|
||||
describe('dynamic-key escape hatch', () => {
|
||||
it('specDynamic reads wide-typed specs for string keys; undefined before define', () => {
|
||||
it('specDynamic reads wide-typed specs for string keys; undefined while undeclared', () => {
|
||||
const core = new SlotCore()
|
||||
expect(core.specDynamic('surface.a')).toBeUndefined()
|
||||
core.define('surface.a', { kind: 'single', scope: 'root' })
|
||||
core.register({ name: 'root', children: { 'surface.a': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
expect(core.specDynamic('surface.a')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(core.specDynamic('never.defined')).toBeUndefined()
|
||||
expect(core.specDynamic('never.declared')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('spec() narrows by SlotMap key', () => {
|
||||
const core = new SlotCore()
|
||||
core.register({ name: 'root', children: { 'surface.a': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
expect(core.spec('surface.a')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(core.spec('surface.b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('entries/getVersion on an untouched key return the frozen empty array and 0', () => {
|
||||
@@ -27,26 +34,9 @@ describe('dynamic-key escape hatch', () => {
|
||||
expect(core.entries('surface.b')).toBe(core.entries('surface.b'))
|
||||
expect(core.getVersion('surface.b')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('narrowSlots', () => {
|
||||
it('returns the same surface narrowed to the subset whitelist', () => {
|
||||
const wide: ScopedSlots<'surface.a' | 'surface.b'> = { renderSlot: () => null }
|
||||
const narrow = narrowSlots<'surface.a', 'surface.a' | 'surface.b'>(wide)
|
||||
expect(narrow).toBe(wide)
|
||||
const rejects = (s: ScopedSlots<'surface.a'>) => {
|
||||
// @ts-expect-error 'surface.b' is outside the narrowed whitelist
|
||||
return () => s.renderSlot('surface.b', {})
|
||||
}
|
||||
expect(rejects(narrow)).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('registration typing', () => {
|
||||
it('keyed/list registrations statically require options (runtime guard retained)', () => {
|
||||
it('isLive is false for entries the core never held', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('surface.a', { kind: 'single', scope: 'root' })
|
||||
// single: options omissible.
|
||||
expect(() => core.register('surface.a', Comp)).not.toThrow()
|
||||
expect(core.isLive({ component: Comp, options: {} })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,140 +1,172 @@
|
||||
// Slot type-chain negative samples (design.md §9 item 2) plus the slots-ring
|
||||
// full-chain positive: register→inject→render composed under the ownership
|
||||
// rule (owner share referenced, injected share locally declared).
|
||||
// Terminal-design compile-time samples (design.md §11 item 2): the four-share
|
||||
// composed register constraint — children spec x SlotMap alignment, renderSlot
|
||||
// key-set containment, store share matching, inject face completeness — plus
|
||||
// the full positive chain. Bodies with @ts-expect-error sites never run.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
OwnerOf, RootBinding, ScopedSlots, SessionBinding, SlotMap, SlotOptions,
|
||||
BoundActions, DefineStore, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
/** Owner share as the slot-owning package's contract would export it. */
|
||||
interface ChainOwnerShare { sessionId: string }
|
||||
/** Registrant's own injected share (locally declared — ownership rule). */
|
||||
interface ChainInjected { useThing: () => number; actions: { open: () => void } }
|
||||
|
||||
// Only package-unique SlotMap keys are merged here. The standard-kit
|
||||
// interfaces (SessionStandardProps/GlobalStandardProps) are NOT re-merged:
|
||||
// the runtime package owns the real members, and in the client aggregate
|
||||
// program a toy merge would collide with them — samples below stay
|
||||
// shape-agnostic about kit member payloads for the same reason.
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'chain.session': { kind: 'single'; scope: 'session'; props: ChainSessionProps; owner: ChainOwnerShare }
|
||||
'chain.root': { kind: 'single'; scope: 'root'; props: ChainRootProps; owner: object }
|
||||
'chain.keyed': { kind: 'keyed'; scope: 'root'; props: ChainRootProps; owner: object }
|
||||
'chain.frame': { kind: 'single'; scope: 'root' }
|
||||
'chain.side': { kind: 'single'; scope: 'root'; owner: { collapsed: boolean; width: number } }
|
||||
'chain.conv': { kind: 'single'; scope: 'session' }
|
||||
'chain.tools': { kind: 'keyed'; scope: 'session' }
|
||||
}
|
||||
}
|
||||
|
||||
/** Full props = owner share (referenced) + standard share + own injected. */
|
||||
type ChainSessionProps = ChainOwnerShare & { useSession: unknown } & ChainInjected
|
||||
type ChainRootProps = ChainInjected
|
||||
declare const defineStore: DefineStore
|
||||
|
||||
const SessionComp: FC<ChainSessionProps> = () => null
|
||||
const RootComp: FC<ChainRootProps> = () => null
|
||||
|
||||
describe('type-chain negatives (compile-time; bodies never run)', () => {
|
||||
it('holds the six negative samples as expect-error sites', () => {
|
||||
const negatives = (core: SlotCore, slots: ScopedSlots<'chain.session'>) => {
|
||||
// 1. Owner passing a registrant-injected key through renderSlot.
|
||||
// OwnerOf<'chain.session'> = ChainOwnerShare — useThing is not in it.
|
||||
slots.renderSlot('chain.session', {
|
||||
sessionId: 's1',
|
||||
// @ts-expect-error injected keys are not owner-suppliable
|
||||
useThing: () => 1,
|
||||
})
|
||||
// 2. Inject factory returning a share that mismatches the registrant's
|
||||
// declared own-injected slice (missing `actions`). The I-typed
|
||||
// options form is where the mismatch surfaces (the full composed
|
||||
// register constraint lands with the phase-2 consumer migration).
|
||||
const mismatched: SlotOptions<SlotMap['chain.session'], ChainInjected> = {
|
||||
// @ts-expect-error inject must supply the full registrant share
|
||||
inject: () => ({ useThing: () => 1 }),
|
||||
}
|
||||
void mismatched
|
||||
// 3. renderSlot on a key outside the whitelist.
|
||||
// @ts-expect-error 'chain.root' is not whitelisted on this surface
|
||||
slots.renderSlot('chain.root', {})
|
||||
// 4. keyed registration without options.
|
||||
// @ts-expect-error keyed kind requires options (RegisterArgs)
|
||||
core.register('chain.keyed', RootComp)
|
||||
// 5. Session-slot inject factory typed against RootBinding's surface.
|
||||
const sessionOpts: SlotOptions<{ kind: 'single'; scope: 'session'; props: ChainSessionProps }, ChainInjected> = {
|
||||
// @ts-expect-error session binding has sessionId; RootBinding-only factories don't type-check
|
||||
inject: (b: RootBinding & { notSession: true }) => ({ useThing: () => 1, actions: { open: () => {} } }),
|
||||
}
|
||||
void sessionOpts
|
||||
// 6. Hand-copied owner share drifting from the contract (wrong value type)
|
||||
// — the composed-reference version right below compiles instead.
|
||||
interface DriftedProps { sessionId: number }
|
||||
const Drifted: FC<DriftedProps & ChainInjected> = () => null
|
||||
// @ts-expect-error drifted hand-copy of the owner share fails at register
|
||||
core.register('chain.session', Drifted)
|
||||
return null as ReactNode
|
||||
}
|
||||
expect(negatives).toBeTypeOf('function')
|
||||
/** Factory form (exclusive seat): module-level export, never a handle. */
|
||||
function createPanelStore() {
|
||||
return defineStore({
|
||||
init: () => ({ sidebar: 280, details: 0 }),
|
||||
persist: 'test.panels',
|
||||
actions: {
|
||||
setSidebar: (d, px: number) => { d.sidebar = px },
|
||||
setDetails: (d, px: number) => { d.details = px },
|
||||
},
|
||||
})
|
||||
|
||||
it('full chain (positive dual): composed props register, inject, and render cleanly', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('chain.session', { kind: 'single', scope: 'session' })
|
||||
const dispose = core.register('chain.session', SessionComp, {
|
||||
inject: (b: SessionBinding): ChainInjected => ({
|
||||
useThing: () => b.sessionId.length,
|
||||
actions: { open: () => {} },
|
||||
}),
|
||||
})
|
||||
const entry = core.entries('chain.session')[0]!
|
||||
// Storage erasure boundary: entries() returns the default-I view; the
|
||||
// registrant share is restored after read-back (the budgeted cast).
|
||||
const injected = (entry.options.inject as unknown as (b: SessionBinding) => ChainInjected)(
|
||||
{ sessionId: 's1', session: { useSelector: undefined }, ctx: undefined })
|
||||
expect(injected.useThing()).toBe(2)
|
||||
// Owner share stays reference-typed at the render surface.
|
||||
const ownerShare: OwnerOf<'chain.session'> = { sessionId: 's1' }
|
||||
expect(ownerShare.sessionId).toBe('s1')
|
||||
dispose()
|
||||
expect(core.entries('chain.session')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── children validation layer (B-b, opt-in per entry) ───────────────────────
|
||||
|
||||
/** Delegating owner share: entry declares children, component carries a slots face. */
|
||||
interface DelegOwnerShare { sessionId: string }
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'chain.deleg': { kind: 'single'; scope: 'root'; props: object; owner: DelegOwnerShare; children: 'chain.child-a' | 'chain.child-b' }
|
||||
'chain.child-a': { kind: 'single'; scope: 'root'; props: object; owner: object }
|
||||
'chain.child-b': { kind: 'single'; scope: 'root'; props: object; owner: object }
|
||||
'chain.outside': { kind: 'single'; scope: 'root'; props: object; owner: object }
|
||||
}
|
||||
}
|
||||
|
||||
describe('children validation layer (compile-time; bodies never run)', () => {
|
||||
it('accepts whitelists inside the authorized union, rejects outside keys', () => {
|
||||
const cases = (core: SlotCore) => {
|
||||
// Positive: slots face ⊆ children union (a strict subset is fine).
|
||||
const InUnion: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a'> }> = () => null
|
||||
core.register('chain.deleg', InUnion)
|
||||
// Positive: the full authorized union.
|
||||
const FullUnion: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a' | 'chain.child-b'> }> = () => null
|
||||
core.register('chain.deleg', FullUnion)
|
||||
// Positive: no slots face at all — delegation is optional.
|
||||
const NoSlots: FC<DelegOwnerShare> = () => null
|
||||
core.register('chain.deleg', NoSlots)
|
||||
// Negative: a key outside the authorized union collapses the slots constraint.
|
||||
const Outside: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.outside'> }> = () => null
|
||||
// @ts-expect-error slots whitelist must stay inside the entry's children union
|
||||
core.register('chain.deleg', Outside)
|
||||
// Negative: smuggling an extra key alongside authorized ones still fails.
|
||||
const Mixed: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a' | 'chain.outside'> }> = () => null
|
||||
// @ts-expect-error a partially-authorized whitelist is still out of union
|
||||
core.register('chain.deleg', Mixed)
|
||||
// Entries WITHOUT children stay unchecked: any slots face registers
|
||||
// freely (I inferred from the inject factory as usual).
|
||||
const FreeFace: FC<ChainOwnerShare & { useSession: unknown } & ChainInjected & { slots: ScopedSlots<'chain.outside'> }> = () => null
|
||||
core.register('chain.session', FreeFace, {
|
||||
inject: (): ChainInjected & { slots: ScopedSlots<'chain.outside'> } =>
|
||||
({ useThing: () => 1, actions: { open: () => {} }, slots: { renderSlot: () => null } }),
|
||||
})
|
||||
const chatStore = () => defineStore({
|
||||
init: () => ({ selection: null as { id: string } | null, draft: '' }),
|
||||
actions: {
|
||||
select: (d, t: { id: string }) => { d.selection = t },
|
||||
setDraft: (d, text: string) => { d.draft = text },
|
||||
clearDraft: (d) => { d.draft = '' },
|
||||
},
|
||||
})
|
||||
type ChatHandle = ReturnType<typeof chatStore>
|
||||
|
||||
type FrameProps =
|
||||
& PropsRuntime<'chain.frame'>
|
||||
& PropsRenderSlots<'chain.side' | 'chain.conv'>
|
||||
& PropsStore<ReturnType<typeof createPanelStore>>
|
||||
& { openSettings: () => void }
|
||||
|
||||
type ConvProps =
|
||||
& PropsRuntime<'chain.conv'>
|
||||
& PropsStore<ChatHandle>
|
||||
& { send: (t: string) => void }
|
||||
|
||||
// Component fixtures (never rendered; the register call sites are the test).
|
||||
declare function Frame(props: FrameProps): ReactNode
|
||||
declare function Conv(props: ConvProps): ReactNode
|
||||
declare function Details(props: PropsRuntime<'chain.conv'> & PropsStore<ChatHandle>): ReactNode
|
||||
declare function Tool(props: PropsRuntime<'chain.tools'>): ReactNode
|
||||
declare function Over(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'chain.side' | 'chain.conv'>): ReactNode
|
||||
declare function NoDecl(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'chain.side'>): ReactNode
|
||||
declare function Blind(props: PropsRuntime<'chain.frame'>): ReactNode
|
||||
declare function WrongStore(props: PropsRuntime<'chain.conv'> & PropsStore<ReturnType<typeof createPanelStore>>): ReactNode
|
||||
declare function Needs(props: PropsRuntime<'chain.conv'> & { send: (t: string) => void }): ReactNode
|
||||
|
||||
describe('terminal-design type chain', () => {
|
||||
it('holds the positive chain and the compile-time negatives', () => {
|
||||
// Everything below is compile-surface only.
|
||||
const samples = (core: SlotCore, chat: ChatHandle, fp: FrameProps, cp: ConvProps, acts: BoundActions<ChatHandle>) => {
|
||||
// ── positive chain ─────────────────────────────────────────────
|
||||
// Frame: children + factory store + inject; actions arrive baked.
|
||||
core.register({
|
||||
name: 'chain.frame',
|
||||
children: {
|
||||
'chain.side': { kind: 'single', scope: 'root' },
|
||||
'chain.conv': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
store: createPanelStore,
|
||||
inject: (actions) => {
|
||||
actions.setSidebar(0)
|
||||
return { openSettings: () => {} }
|
||||
},
|
||||
}, Frame)
|
||||
|
||||
// Conv: shared handle; inject params derive as (sessionId, actions).
|
||||
core.register({
|
||||
name: 'chain.conv',
|
||||
store: chat,
|
||||
inject: (sessionId, actions) => ({
|
||||
send: (text: string) => {
|
||||
const sid: string = sessionId
|
||||
actions.setDraft(text)
|
||||
void sid
|
||||
},
|
||||
}),
|
||||
}, Conv)
|
||||
|
||||
// Pure reader: same handle, no inject.
|
||||
core.register({ name: 'chain.conv', store: chat }, Details)
|
||||
|
||||
// Owner + store shares arrive typed on the component face. Standard-kit
|
||||
// member payloads are the runtime merge's property — not probed here
|
||||
// (the runtime package's own tests cover them).
|
||||
fp.renderSlot('chain.side', { collapsed: false, width: 280 })
|
||||
const draft: string = cp.useStore((s) => s.draft)
|
||||
cp.actions.select({ id: 'm1' })
|
||||
void draft
|
||||
|
||||
// Keyed registration carries key.
|
||||
core.register({ name: 'chain.tools', key: 'bash' }, Tool)
|
||||
|
||||
// ── negatives ──────────────────────────────────────────────────
|
||||
// children spec must match the SlotMap entry.
|
||||
core.register({
|
||||
name: 'chain.frame',
|
||||
// @ts-expect-error chain.conv is session-scoped in SlotMap
|
||||
children: { 'chain.conv': { kind: 'single', scope: 'root' } },
|
||||
}, (() => null) as SlotComponent<never>)
|
||||
|
||||
// renderSlot key set ⊄ children declaration.
|
||||
// @ts-expect-error component renderSlot keys exceed the declaration
|
||||
core.register({ name: 'chain.frame', children: { 'chain.side': { kind: 'single', scope: 'root' } } }, Over)
|
||||
|
||||
// renderSlot consumption without any children declaration.
|
||||
// @ts-expect-error no children declaration authorizes rendering
|
||||
core.register({ name: 'chain.frame' }, NoDecl)
|
||||
|
||||
// children declared but component consumes no renderSlot.
|
||||
// @ts-expect-error children declared, component consumes no renderSlot
|
||||
core.register({ name: 'chain.frame', children: { 'chain.side': { kind: 'single', scope: 'root' } } }, Blind)
|
||||
|
||||
// store share mismatch.
|
||||
// @ts-expect-error component's store share doesn't match the declared handle
|
||||
core.register({ name: 'chain.conv', store: chat }, WrongStore)
|
||||
|
||||
// inject face incomplete for the component's business share.
|
||||
// @ts-expect-error inject face missing `send`
|
||||
core.register({ name: 'chain.conv', inject: () => ({ notSend: 1 }) }, Needs)
|
||||
// @ts-expect-error nothing provides `send` (no inject at all)
|
||||
core.register({ name: 'chain.conv' }, Needs)
|
||||
|
||||
// root-scope inject takes no sessionId.
|
||||
core.register({
|
||||
name: 'chain.side',
|
||||
// @ts-expect-error root-scope inject has no sessionId parameter
|
||||
inject: (sessionId: string) => ({ x: sessionId }),
|
||||
}, ((_p) => null) as SlotComponent<PropsRuntime<'chain.side'> & { x: string }>)
|
||||
|
||||
// keyed registration without key.
|
||||
// @ts-expect-error keyed registration requires options.key
|
||||
core.register({ name: 'chain.tools' }, Tool)
|
||||
|
||||
// renderSlot owner share typed at the call site.
|
||||
// @ts-expect-error owner shape mismatch (width missing)
|
||||
fp.renderSlot('chain.side', { collapsed: false })
|
||||
// @ts-expect-error key not in this render share
|
||||
fp.renderSlot('chain.tools', {})
|
||||
|
||||
// baked actions strip the draft parameter.
|
||||
acts.setDraft('x')
|
||||
// @ts-expect-error wrong payload type
|
||||
acts.setDraft(1)
|
||||
}
|
||||
expect(cases).toBeTypeOf('function')
|
||||
expect(samples).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user