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,55 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* apply wiring on a real cordis Context + SlotsService: tree store built and
|
||||
* subscribed, SidebarRoot registered into the layout-owned sidebar slot with
|
||||
* the inject surface bound off the root binding ctx, effect teardown
|
||||
* unregisters and drops the list subscription. Behavior-level assertions
|
||||
* only — the inject factory's cast shape is due to change with the slot
|
||||
* type-chain redesign.
|
||||
* apply wiring on a real cordis Context + SlotsService (terminal register
|
||||
* form): SidebarRoot registered into the layout-declared sidebar slot, the
|
||||
* thin inject surface (three plain service callbacks closed over the plugin
|
||||
* ctx — no hooks, no store lines), load-order fail-loud, and fiber-teardown
|
||||
* unregistration. Component behavior is covered props-direct in
|
||||
* sidebar-root.spec.tsx; no renderer machinery here.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { scopedSlots, RootBindingProvider } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
// Type-only: ui-layout's SlotMap merge so the sidebar slot key typechecks.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid('a')],
|
||||
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
current: undefined,
|
||||
})
|
||||
const sessions = { list, create: vi.fn(async () => sid('minted')) }
|
||||
const layout = {
|
||||
current: createSnapshotStore<{ sessionId?: SessionId }>({}),
|
||||
open: vi.fn(),
|
||||
toggleSidebar: vi.fn(),
|
||||
}
|
||||
const sessions = { list, create: vi.fn(async () => sid('minted')), open: vi.fn() }
|
||||
const layout = { toggleSidebar: vi.fn() }
|
||||
ctx.provide('sessions', sessions)
|
||||
ctx.provide('layout', layout)
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
slots.define('sidebar', { kind: 'single', scope: 'root' })
|
||||
// Stand-in for ui-layout's root entry: the sidebar slot only exists while
|
||||
// a live entry declares it in children (declaration account: design §2.2).
|
||||
slots.register(
|
||||
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
return { ctx, slots, sessions, layout }
|
||||
}
|
||||
|
||||
function mountSlot(ctx: Context, slots: SlotsService) {
|
||||
const surface = scopedSlots(slots.core, 'sidebar')
|
||||
return render(
|
||||
<RootBindingProvider value={{ ctx }}>
|
||||
{surface.renderSlot('sidebar', {})}
|
||||
</RootBindingProvider>,
|
||||
)
|
||||
/** The sidebar entry's injected share, read off the stored entry. */
|
||||
function injectedOf(slots: SlotsService): SidebarRootInjected {
|
||||
const entries = slots.entries('sidebar')
|
||||
expect(entries).toHaveLength(1)
|
||||
// The typed StoredEntry.inject is declaration-derived ((...args: never[])
|
||||
// shape); the sidebar factory is parameterless, so the call is safe here.
|
||||
const inject = entries[0]!.inject as (() => SidebarRootInjected) | undefined
|
||||
return inject!()
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
@@ -58,101 +56,57 @@ describe('apply', () => {
|
||||
})
|
||||
|
||||
it('fails loud when mounted without the inject declaration', async () => {
|
||||
// ctx.sessions rides the cordis property proxy: reading it from a plugin
|
||||
// ctx.slots rides the cordis property proxy: reading it from a plugin
|
||||
// that never declared the dependency throws instead of yielding undefined.
|
||||
// Await the fiber thenable itself, not a second .await() chain: the test
|
||||
// invariant host wraps plugin() with an eager readiness promise, and only
|
||||
// the thenable settles it (a parallel .await() leaves it unhandled).
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
await expect(ctx.plugin({ apply })).rejects.toThrow(/without inject/)
|
||||
})
|
||||
|
||||
it('registers SidebarRoot which renders from the live list', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
mountSlot(ctx, slots)
|
||||
expect(screen.getByText('proj')).toBeTruthy()
|
||||
expect(screen.getByText('1 session')).toBeTruthy()
|
||||
it('fails loud when no live entry has declared the sidebar slot', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.provide('sessions', {})
|
||||
ctx.provide('layout', {})
|
||||
await expect(ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/slot "sidebar" is not declared/)
|
||||
})
|
||||
|
||||
it('binds actions to layout/sessions off the root binding', async () => {
|
||||
it('registers SidebarRoot with the thin three-callback inject surface', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = injectedOf(slots)
|
||||
// The whole business face: three plain callbacks, no hooks, no store lines.
|
||||
expect(Object.keys(injected).sort()).toEqual(['onCreate', 'onOpen', 'onToggleSidebar'])
|
||||
})
|
||||
|
||||
it('routes the callbacks to the layout/sessions services', async () => {
|
||||
const { ctx, slots, sessions, layout } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
mountSlot(ctx, slots)
|
||||
const injected = injectedOf(slots)
|
||||
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
injected.onToggleSidebar()
|
||||
expect(layout.toggleSidebar).toHaveBeenCalledOnce()
|
||||
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
act(() => { fireEvent.click(screen.getByText('alpha')) })
|
||||
expect(layout.open).toHaveBeenCalledWith('a')
|
||||
injected.onOpen(sid('a'))
|
||||
expect(sessions.open).toHaveBeenCalledWith('a')
|
||||
|
||||
act(() => { fireEvent.click(screen.getByText('New Session')) })
|
||||
injected.onCreate()
|
||||
expect(sessions.create).toHaveBeenCalledWith({})
|
||||
// create-then-open lands after the create promise resolves.
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(layout.open).toHaveBeenCalledWith('minted')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(sessions.open).toHaveBeenCalledWith('minted')
|
||||
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
|
||||
injected.onCreate('/proj')
|
||||
expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' })
|
||||
})
|
||||
|
||||
it('throws from the inject factory when binding ctx lacks the services', async () => {
|
||||
it('teardown unregisters the slot entry', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const bare = new Context()
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const surface = scopedSlots(slots.core, 'sidebar')
|
||||
render(
|
||||
<RootBindingProvider value={{ ctx: bare }}>
|
||||
{surface.renderSlot('sidebar', {})}
|
||||
</RootBindingProvider>,
|
||||
)
|
||||
// The slot error boundary absorbs the throw and logs it.
|
||||
expect(document.querySelector('[data-slot-error="sidebar"]')).toBeTruthy()
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('search input drives the plugin-owned tree store', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
mountSlot(ctx, slots)
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'zzz' } })
|
||||
})
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('expansion toggles route through the injected tree actions', async () => {
|
||||
const { ctx, slots, sessions } = await bench()
|
||||
sessions.list.update((draft) => {
|
||||
draft.ids.push(sid('kid'))
|
||||
draft.byId[sid('kid')] = {
|
||||
id: sid('kid'), title: 'child', cwd: '/proj', parentId: sid('a'), running: false, updatedAt: 2,
|
||||
}
|
||||
})
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
mountSlot(ctx, slots)
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
expect(screen.getByText('alpha')).toBeTruthy()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Expand')) })
|
||||
expect(screen.getByText('child')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('teardown unregisters the slot and drops the list subscription', async () => {
|
||||
const { ctx, slots, sessions } = await bench()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('sidebar')).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('sidebar')).toHaveLength(0)
|
||||
// A post-teardown list change must not reach a disposed store.
|
||||
expect(() => {
|
||||
sessions.list.update((draft) => { draft.ids = [] })
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* SidebarRoot interaction spec on the real framework stack: real tree store
|
||||
* (web-react SnapshotStore) feeding the component through the same selector
|
||||
* hook the inject surface hands out. Covers expand/collapse, subtree unfold,
|
||||
* search filtering, row activation, and the creation entries.
|
||||
* SidebarRoot interaction spec, props-direct (slot-parity test doctrine:
|
||||
* components are fed composed props, no assembly machinery). The standard
|
||||
* useSessions hook is stubbed with a real web-react SnapshotStore selector;
|
||||
* expansion/search live inside the component, so all viewing behavior is
|
||||
* driven through the DOM. Covers expand/collapse, subtree unfold, search
|
||||
* filtering, row activation, and the creation entries.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
// Engine subpath: createSnapshotStore left the public face (wave 3); the
|
||||
// engine remains the sanctioned stub source for the standard hooks in tests.
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSidebarTreeStore, type SidebarTreeStore } from '../src/client/store.ts'
|
||||
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
|
||||
import type { SidebarActions } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
|
||||
@@ -41,29 +43,29 @@ function summary(init: SummaryInit): SessionSummary {
|
||||
function listStateOf(...summaries: SessionSummary[]): SessionListState {
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const s of summaries) byId[s.id] = s
|
||||
return { ids: summaries.map((s) => s.id), byId }
|
||||
return { ids: summaries.map((s) => s.id), byId, current: undefined }
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function mount(...summaries: SessionSummary[]) {
|
||||
const list = createSnapshotStore<SessionListState>(listStateOf(...summaries))
|
||||
const tree: SidebarTreeStore = createSidebarTreeStore({ list })
|
||||
const current = createSnapshotStore<{ id: SessionId | undefined }>({ id: undefined })
|
||||
const actions: SidebarActions = {
|
||||
open: vi.fn((id: SessionId) => { current.update((d) => { d.id = id }) }),
|
||||
create: vi.fn(),
|
||||
toggleSidebar: vi.fn(),
|
||||
}
|
||||
// Real engine store as the useSessions stub: same uSES selector shape the
|
||||
// framework delivers, so list updates re-render exactly like production.
|
||||
const sessions = createSnapshotStore<SessionListState>(listStateOf(...summaries))
|
||||
const onOpen = vi.fn((id: SessionId) => { sessions.update((d) => { d.current = id }) })
|
||||
const onCreate = vi.fn()
|
||||
const onToggleSidebar = vi.fn()
|
||||
const utils = render(
|
||||
<SidebarRoot
|
||||
useTree={tree.store.useSelector}
|
||||
useCurrent={() => current.useSelector((s) => s.id)}
|
||||
actions={actions}
|
||||
tree={tree}
|
||||
collapsed={false}
|
||||
width={300}
|
||||
useSessions={sessions.useSelector}
|
||||
onOpen={onOpen}
|
||||
onCreate={onCreate}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
/>,
|
||||
)
|
||||
return { list, tree, current, actions, ...utils }
|
||||
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
|
||||
}
|
||||
|
||||
const projectData = () => [
|
||||
@@ -72,6 +74,9 @@ const projectData = () => [
|
||||
summary({ id: 'lone', title: 'elsewhere', cwd: '/other', updatedAt: 3 }),
|
||||
]
|
||||
|
||||
/** Flush the store's microtask-batched notification into React. */
|
||||
const flush = async () => { await act(async () => { await Promise.resolve() }) }
|
||||
|
||||
describe('SidebarRoot', () => {
|
||||
it('renders chrome and collapsed project rows', () => {
|
||||
mount(...projectData())
|
||||
@@ -94,11 +99,13 @@ describe('SidebarRoot', () => {
|
||||
expect(screen.queryByText('forked child')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a session on row click and marks it selected', () => {
|
||||
const { actions } = mount(...projectData())
|
||||
it('opens a session on row click and marks it selected', async () => {
|
||||
const { onOpen } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
act(() => { fireEvent.click(screen.getByText('root work')) })
|
||||
expect(actions.open).toHaveBeenCalledWith('root')
|
||||
expect(onOpen).toHaveBeenCalledWith('root')
|
||||
// The mock routed the open into sessions.current — highlight follows.
|
||||
await flush()
|
||||
expect(screen.getByText('root work').closest('[role="treeitem"]')!.getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
@@ -128,20 +135,20 @@ describe('SidebarRoot', () => {
|
||||
})
|
||||
|
||||
it('routes the three creation entries with the right cwd', () => {
|
||||
const { actions } = mount(...projectData())
|
||||
const { onCreate } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('New Session')) })
|
||||
expect(actions.create).toHaveBeenLastCalledWith()
|
||||
expect(onCreate).toHaveBeenLastCalledWith()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('New workspace')) })
|
||||
expect(actions.create).toHaveBeenLastCalledWith()
|
||||
expect(onCreate).toHaveBeenLastCalledWith()
|
||||
// Per-project "+" is hover-revealed by CSS; still clickable in jsdom.
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
|
||||
expect(actions.create).toHaveBeenLastCalledWith('/proj')
|
||||
expect(onCreate).toHaveBeenLastCalledWith('/proj')
|
||||
})
|
||||
|
||||
it('collapse button and group-by menu behave', () => {
|
||||
const { actions } = mount(...projectData())
|
||||
const { onToggleSidebar } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
expect(actions.toggleSidebar).toHaveBeenCalledOnce()
|
||||
expect(onToggleSidebar).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByText('Update')).toBeNull()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
|
||||
expect(screen.getByText('Update')).toBeTruthy()
|
||||
@@ -156,28 +163,27 @@ describe('SidebarRoot', () => {
|
||||
})
|
||||
|
||||
it('re-renders when the sessions list gains a session', async () => {
|
||||
const { list } = mount(...projectData())
|
||||
const { sessions } = mount(...projectData())
|
||||
act(() => {
|
||||
list.update((draft) => {
|
||||
sessions.update((draft) => {
|
||||
draft.ids.push(sid('fresh'))
|
||||
draft.byId[sid('fresh')] = summary({ id: 'fresh', title: 'brand new', cwd: '/fresh', updatedAt: 99 })
|
||||
})
|
||||
})
|
||||
// Store notifications are microtask-batched.
|
||||
await act(async () => { await Promise.resolve() })
|
||||
await flush()
|
||||
expect(screen.getByText('fresh')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('row "More" anchors swallow the click without opening or toggling', () => {
|
||||
const { actions, tree } = mount(...projectData())
|
||||
const { onOpen } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
const before = tree.store.getSnapshot().expandedProjects.length
|
||||
// Project-row anchor: must not collapse the project.
|
||||
// Project-row anchor: must not collapse the project (rows stay visible).
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('More')[0]!) })
|
||||
expect(tree.store.getSnapshot().expandedProjects).toHaveLength(before)
|
||||
expect(screen.getByText('root work')).toBeTruthy()
|
||||
// Session-row anchor: must not open the session.
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('More')[1]!) })
|
||||
expect(actions.open).not.toHaveBeenCalled()
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the running state dot only for running sessions', () => {
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSidebarTreeStore } from '../src/client/store.ts'
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
|
||||
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
|
||||
interface SummaryInit {
|
||||
id: string
|
||||
title?: string
|
||||
cwd?: string
|
||||
parentId?: string
|
||||
running?: boolean
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
function summary(init: SummaryInit): SessionSummary {
|
||||
const s: SessionSummary = {
|
||||
id: sid(init.id),
|
||||
title: init.title ?? init.id,
|
||||
running: init.running ?? false,
|
||||
updatedAt: init.updatedAt ?? 0,
|
||||
}
|
||||
if (init.cwd !== undefined) s.cwd = init.cwd
|
||||
if (init.parentId !== undefined) s.parentId = sid(init.parentId)
|
||||
return s
|
||||
}
|
||||
|
||||
function listStateOf(...summaries: SessionSummary[]): SessionListState {
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const s of summaries) byId[s.id] = s
|
||||
return { ids: summaries.map(s => s.id), byId }
|
||||
}
|
||||
|
||||
function setup(...summaries: SessionSummary[]) {
|
||||
const list = createSnapshotStore<SessionListState>(listStateOf(...summaries))
|
||||
const tree = createSidebarTreeStore({ list })
|
||||
return { list, tree }
|
||||
}
|
||||
|
||||
const flushMicrotasks = () => new Promise<void>((resolve) => { queueMicrotask(resolve) })
|
||||
|
||||
describe('createSidebarTreeStore', () => {
|
||||
it('materializes rows from the initial list snapshot', () => {
|
||||
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
|
||||
expect(tree.store.getSnapshot().rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: '/p', sessionCount: 1 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('re-derives when the sessions list changes', async () => {
|
||||
const { list, tree } = setup(summary({ id: 'a', cwd: '/p' }))
|
||||
list.update((draft) => {
|
||||
draft.ids.push(sid('b'))
|
||||
draft.byId[sid('b')] = summary({ id: 'b', cwd: '/q', updatedAt: 99 })
|
||||
})
|
||||
// Snapshot-store notifications are microtask-batched.
|
||||
await flushMicrotasks()
|
||||
expect(tree.store.getSnapshot().rows.map(r => r.type === 'project' && r.key)).toEqual(['/q', '/p'])
|
||||
})
|
||||
|
||||
it('toggleProject expands and collapses synchronously', () => {
|
||||
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
|
||||
tree.toggleProject('/p')
|
||||
expect(tree.store.getSnapshot().rows).toHaveLength(2)
|
||||
tree.toggleProject('/p')
|
||||
expect(tree.store.getSnapshot().rows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('toggleSession unfolds a subtree', () => {
|
||||
const { tree } = setup(
|
||||
summary({ id: 'root', cwd: '/p', updatedAt: 2 }),
|
||||
summary({ id: 'kid', cwd: '/p', parentId: sid('root'), updatedAt: 1 }),
|
||||
)
|
||||
tree.toggleProject('/p')
|
||||
expect(tree.store.getSnapshot().rows).toHaveLength(2)
|
||||
tree.toggleSession(sid('root'))
|
||||
expect(tree.store.getSnapshot().rows).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('setQuery switches into search mode and back', () => {
|
||||
const { tree } = setup(
|
||||
summary({ id: 'a', title: 'needle', cwd: '/p' }),
|
||||
summary({ id: 'b', title: 'other', cwd: '/q' }),
|
||||
)
|
||||
tree.setQuery('needle')
|
||||
const rows = tree.store.getSnapshot().rows
|
||||
expect(rows.map(r => r.type)).toEqual(['project', 'session'])
|
||||
tree.setQuery('')
|
||||
expect(tree.store.getSnapshot().rows.every(r => r.type === 'project')).toBe(true)
|
||||
})
|
||||
|
||||
it('setGroupBy records the strategy and re-derives', () => {
|
||||
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
|
||||
tree.setGroupBy('workspace')
|
||||
expect(tree.store.getSnapshot().groupBy).toBe('workspace')
|
||||
expect(tree.store.getSnapshot().rows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('dispose stops re-derivation on list changes', async () => {
|
||||
const { list, tree } = setup(summary({ id: 'a', cwd: '/p' }))
|
||||
tree.dispose()
|
||||
list.update((draft) => {
|
||||
draft.ids.push(sid('b'))
|
||||
draft.byId[sid('b')] = summary({ id: 'b', cwd: '/q' })
|
||||
})
|
||||
await flushMicrotasks()
|
||||
expect(tree.store.getSnapshot().rows).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -32,12 +32,12 @@ function summary(init: SummaryInit): SessionSummary {
|
||||
function listOf(...summaries: SessionSummary[]): SessionListState {
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const s of summaries) byId[s.id] = s
|
||||
return { ids: summaries.map(s => s.id), byId }
|
||||
return { ids: summaries.map(s => s.id), byId, current: undefined }
|
||||
}
|
||||
|
||||
const view = (partial: Partial<TreeView> = {}): TreeView => ({
|
||||
expandedProjects: partial.expandedProjects ?? new Set(),
|
||||
expandedSessions: partial.expandedSessions ?? new Set(),
|
||||
expandedProjects: partial.expandedProjects ?? [],
|
||||
expandedSessions: partial.expandedSessions ?? [],
|
||||
query: partial.query ?? '',
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('deriveRows grouping', () => {
|
||||
summary({ id: 'b', cwd: '/p', updatedAt: 2 }),
|
||||
)
|
||||
expect(deriveRows(list, view()).filter(r => r.type === 'session')).toHaveLength(0)
|
||||
const rows = deriveRows(list, view({ expandedProjects: new Set(['/p']) }))
|
||||
const rows = deriveRows(list, view({ expandedProjects: ['/p'] }))
|
||||
expect(rows.slice(1)).toEqual([
|
||||
expect.objectContaining({ type: 'session', id: 'b', depth: 0 }),
|
||||
expect.objectContaining({ type: 'session', id: 'a', depth: 0 }),
|
||||
@@ -112,8 +112,8 @@ describe('deriveRows session tree', () => {
|
||||
|
||||
it('nests children under expanded parents with increasing depth', () => {
|
||||
const rows = deriveRows(treeList, view({
|
||||
expandedProjects: new Set(['/p']),
|
||||
expandedSessions: new Set(['root', 'kid']),
|
||||
expandedProjects: ['/p'],
|
||||
expandedSessions: ['root', 'kid'],
|
||||
}))
|
||||
expect(rows.slice(1)).toEqual([
|
||||
expect.objectContaining({ id: 'other', depth: 0, hasChildren: false }),
|
||||
@@ -124,7 +124,7 @@ describe('deriveRows session tree', () => {
|
||||
})
|
||||
|
||||
it('collapses subtrees at unexpanded sessions', () => {
|
||||
const rows = deriveRows(treeList, view({ expandedProjects: new Set(['/p']) }))
|
||||
const rows = deriveRows(treeList, view({ expandedProjects: ['/p'] }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toEqual(['other', 'root'])
|
||||
})
|
||||
@@ -133,7 +133,7 @@ describe('deriveRows session tree', () => {
|
||||
const rows = deriveRows(listOf(
|
||||
summary({ id: 'p1', cwd: '/a', updatedAt: 2 }),
|
||||
summary({ id: 'stray', cwd: '/b', parentId: sid('p1'), updatedAt: 1 }),
|
||||
), view({ expandedProjects: new Set(['/a', '/b']) }))
|
||||
), view({ expandedProjects: ['/a', '/b'] }))
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: '/a' }),
|
||||
expect.objectContaining({ id: 'p1', depth: 0 }),
|
||||
@@ -147,7 +147,7 @@ describe('deriveRows session tree', () => {
|
||||
summary({ id: 'x', cwd: '/p', parentId: sid('y'), updatedAt: 2 }),
|
||||
summary({ id: 'y', cwd: '/p', parentId: sid('x'), updatedAt: 1 }),
|
||||
summary({ id: 'self', cwd: '/p', parentId: sid('self'), updatedAt: 3 }),
|
||||
), view({ expandedProjects: new Set(['/p']), expandedSessions: new Set(['x', 'y', 'self']) }))
|
||||
), view({ expandedProjects: ['/p'], expandedSessions: ['x', 'y', 'self'] }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toContain('self')
|
||||
expect(ids).toContain('x')
|
||||
@@ -160,7 +160,7 @@ describe('deriveRows session tree', () => {
|
||||
summary({ id: 'b', cwd: '/p', updatedAt: 7 }),
|
||||
summary({ id: 'a', cwd: '/p', updatedAt: 7 }),
|
||||
summary({ id: 'c', cwd: '/p', updatedAt: 7 }),
|
||||
), view({ expandedProjects: new Set(['/p']) }))
|
||||
), view({ expandedProjects: ['/p'] }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
@@ -170,7 +170,7 @@ describe('deriveRows session tree', () => {
|
||||
summary({ id: 'p', cwd: '/p', updatedAt: 9 }),
|
||||
summary({ id: 'old', cwd: '/p', parentId: sid('p'), updatedAt: 1 }),
|
||||
summary({ id: 'new', cwd: '/p', parentId: sid('p'), updatedAt: 5 }),
|
||||
), view({ expandedProjects: new Set(['/p']), expandedSessions: new Set(['p']) }))
|
||||
), view({ expandedProjects: ['/p'], expandedSessions: ['p'] }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toEqual(['p', 'new', 'old'])
|
||||
})
|
||||
@@ -178,7 +178,7 @@ describe('deriveRows session tree', () => {
|
||||
it('carries the running flag onto rows', () => {
|
||||
const rows = deriveRows(
|
||||
listOf(summary({ id: 'a', cwd: '/p', running: true })),
|
||||
view({ expandedProjects: new Set(['/p']) }))
|
||||
view({ expandedProjects: ['/p'] }))
|
||||
expect(rows[1]).toEqual(expect.objectContaining({ id: 'a', running: true }))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user