feat(gui): chain slot kind with select routing and renderSlotChain
This commit is contained in:
@@ -13,6 +13,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
'test.session': { kind: 'single'; scope: 'session' }
|
||||
'test.list': { kind: 'list'; scope: 'root' }
|
||||
'test.keyed': { kind: 'keyed'; scope: 'session' }
|
||||
'test.chain': { kind: 'chain'; scope: 'session'; owner: { tags: string[] } }
|
||||
'test.grandchild': { kind: 'single'; scope: 'root' }
|
||||
}
|
||||
}
|
||||
@@ -39,6 +40,7 @@ function mountFrame(core: SlotCore) {
|
||||
'test.session': { kind: 'single', scope: 'session' },
|
||||
'test.list': { kind: 'list', scope: 'root' },
|
||||
'test.keyed': { kind: 'keyed', scope: 'session' },
|
||||
'test.chain': { kind: 'chain', scope: 'session' },
|
||||
},
|
||||
// Type-level renderSlot presence is proven by the type-chain spec; erasing
|
||||
// here keeps runtime fixtures terse.
|
||||
@@ -148,6 +150,31 @@ describe('kind semantics', () => {
|
||||
expect(core.entries('test.list').map(e => e.options.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('chain: missing select throws; select and priority land on the stored entry', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
// Statically rejected (KindOptions); runtime guard stays for dynamic callers.
|
||||
// @ts-expect-error chain registration requires options.select
|
||||
expect(() => core.register({ name: 'test.chain' }, Comp)).toThrow('requires options.select')
|
||||
const select = ({ tags }: { tags: string[] }) => tags[0] ?? null
|
||||
core.register({ name: 'test.chain', select, priority: 5 }, Comp as never)
|
||||
const entry = core.entries('test.chain')[0]!
|
||||
expect(entry.select).toBe(select)
|
||||
expect(entry.options.priority).toBe(5)
|
||||
})
|
||||
|
||||
it('chain: entries sort by priority ascending, ties keep registration order', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
const sel = () => null
|
||||
core.register({ name: 'test.chain', select: sel, priority: 10, registrant: 'late' }, Comp as never)
|
||||
core.register({ name: 'test.chain', select: sel, registrant: 'default-a' }, Comp as never)
|
||||
core.register({ name: 'test.chain', select: sel, registrant: 'default-b' }, Comp as never)
|
||||
core.register({ name: 'test.chain', select: sel, priority: -1, registrant: 'first' }, Comp as never)
|
||||
expect(core.entries('test.chain').map(e => e.registrant))
|
||||
.toEqual(['first', 'default-a', 'default-b', 'late'])
|
||||
})
|
||||
|
||||
it('single: second registration throws, disposer frees the seat', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
@@ -294,7 +321,7 @@ describe('subscription surface', () => {
|
||||
const off = core.onMutate(key => keys.push(key))
|
||||
mountFrame(core)
|
||||
// Contribution first, then each declared child key.
|
||||
expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed'])
|
||||
expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed', 'test.chain'])
|
||||
keys.length = 0
|
||||
core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
expect(keys).toEqual(['test.list'])
|
||||
|
||||
@@ -20,9 +20,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
'chain.side': { kind: 'single'; scope: 'root'; owner: { collapsed: boolean; width: number } }
|
||||
'chain.conv': { kind: 'single'; scope: 'session' }
|
||||
'chain.tools': { kind: 'keyed'; scope: 'session' }
|
||||
'chain.takeover': { kind: 'chain'; scope: 'session'; owner: { items: readonly Item[] } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Chain-currency fixture: the owner share carries a union the selectors narrow. */
|
||||
interface Item { kind: 'q' | 'a'; id: string }
|
||||
|
||||
declare const defineStore: DefineStore
|
||||
|
||||
/** Factory form (exclusive seat): module-level export, never a handle. */
|
||||
@@ -68,6 +72,9 @@ declare function NoDecl(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'c
|
||||
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
|
||||
declare function Takeover(props: PropsRuntime<'chain.takeover'> & { matched: Item }): ReactNode
|
||||
declare function WideTakeover(props: PropsRuntime<'chain.takeover'> & { matched: Item | string }): ReactNode
|
||||
declare function NarrowTakeover(props: PropsRuntime<'chain.takeover'> & { matched: { kind: 'q'; id: string; extra: number } }): ReactNode
|
||||
|
||||
describe('terminal-design type chain', () => {
|
||||
it('holds the positive chain and the compile-time negatives', () => {
|
||||
@@ -115,6 +122,28 @@ describe('terminal-design type chain', () => {
|
||||
// Keyed registration carries key.
|
||||
core.register({ name: 'chain.tools', key: 'bash' }, Tool)
|
||||
|
||||
// Chain registration: select is mandatory, M infers from its return,
|
||||
// matched joins the component constraint; priority is the explicit
|
||||
// chain position.
|
||||
core.register({
|
||||
name: 'chain.takeover',
|
||||
select: ({ items }) => items.find((i) => i.kind === 'q') ?? null,
|
||||
priority: 1,
|
||||
}, Takeover)
|
||||
|
||||
// A component accepting a wider matched than the selector supplies
|
||||
// checks through parameter contravariance.
|
||||
core.register({
|
||||
name: 'chain.takeover',
|
||||
select: ({ items }) => items.find((i) => i.kind === 'q') ?? null,
|
||||
}, WideTakeover)
|
||||
|
||||
// renderSlotChain share: chain keys dispatch with the fallback bag;
|
||||
// non-chain keys stay on renderSlot.
|
||||
const chainSlots: PropsRenderSlots<'chain.takeover' | 'chain.conv'> = null as never
|
||||
chainSlots.renderSlotChain('chain.takeover', { items: [] }, { fallback: null })
|
||||
chainSlots.renderSlot('chain.conv', {})
|
||||
|
||||
// ── negatives ──────────────────────────────────────────────────
|
||||
// children spec must match the SlotMap entry.
|
||||
core.register({
|
||||
@@ -156,6 +185,34 @@ describe('terminal-design type chain', () => {
|
||||
// @ts-expect-error keyed registration requires options.key
|
||||
core.register({ name: 'chain.tools' }, Tool)
|
||||
|
||||
// chain registration without select.
|
||||
// @ts-expect-error chain registration requires options.select
|
||||
core.register({ name: 'chain.takeover' }, Takeover)
|
||||
|
||||
// Drifted chain component: demands a matched shape the selector cannot
|
||||
// supply (NoInfer pins M to the select return — the component position
|
||||
// must not widen it).
|
||||
// @ts-expect-error component matched prop drifts from the select return
|
||||
core.register({
|
||||
name: 'chain.takeover',
|
||||
select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q') ?? null,
|
||||
}, NarrowTakeover)
|
||||
|
||||
// select must return M | null, not undefined (find() must be coalesced).
|
||||
// @ts-expect-error select may not return undefined
|
||||
core.register({
|
||||
name: 'chain.takeover',
|
||||
select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q'),
|
||||
}, Takeover)
|
||||
|
||||
// Chain keys are not renderSlot-dispatchable (and vice versa).
|
||||
// @ts-expect-error chain keys dispatch through renderSlotChain only
|
||||
chainSlots.renderSlot('chain.takeover', { items: [] })
|
||||
// @ts-expect-error non-chain keys have no renderSlotChain dispatch
|
||||
chainSlots.renderSlotChain('chain.conv', {})
|
||||
// @ts-expect-error a children set without chain keys provides no renderSlotChain
|
||||
fp.renderSlotChain
|
||||
|
||||
// renderSlot owner share typed at the call site.
|
||||
// @ts-expect-error owner shape mismatch (width missing)
|
||||
fp.renderSlot('chain.side', { collapsed: false })
|
||||
|
||||
Reference in New Issue
Block a user