WorkspaceListState gains archivedSessionIds (ReadonlySet, replaced only on membership change), installed as full snapshots from the list baseline, the unary echo, and the changed frame. Archiving the current session clears the selection into the New Session view state. Test doubles (test-runtime, fake APIs, fixture client) follow the widened IWorkspaces/IApiClient faces.
355 lines
16 KiB
TypeScript
355 lines
16 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* 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 { useSyncExternalStore } 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 { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
|
|
import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
|
|
import type {
|
|
SessionId, SessionListState, WorkspaceListState,
|
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
|
// Session selection controls for the SessionProvider and useSessions stubs.
|
|
const selectedSession = { current: 's-test' as SessionId | undefined }
|
|
const selectedSessionBlank = { current: false }
|
|
const baselinesReady = { current: true }
|
|
|
|
// Render-prop contract stub fed through the standard seat prop (the renderer
|
|
// injects the real one in production): session mode runs children(id), empty
|
|
// mode runs the empty branch — the frame must work against exactly this
|
|
// shape. Typed as the seat's own component type so the branded sessionId
|
|
// parameter stays contract-checked.
|
|
const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) =>
|
|
selectedSession.current === undefined ? <>{empty?.() ?? null}</> : <>{children(selectedSession.current)}</>
|
|
|
|
|
|
/** Observer stub: captures the callback so tests can fire resizes manually. */
|
|
let fireResize: (() => void) | null = null
|
|
class ResizeObserverStub {
|
|
#cb: ResizeObserverCallback
|
|
constructor(cb: ResizeObserverCallback) { this.#cb = cb }
|
|
observe(): void { fireResize = () => { this.#cb([], this) } }
|
|
unobserve(): void {}
|
|
disconnect(): void { fireResize = null }
|
|
}
|
|
|
|
let frameWidth = 1920
|
|
|
|
/** Test-local selector hook over a framework-neutral store instance. */
|
|
function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
|
|
return function useSelector<S>(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) }
|
|
}
|
|
|
|
function mountFrame() {
|
|
window.innerWidth = frameWidth // first-render viewport source before the observer fires
|
|
const instance = createLayoutStore().create()
|
|
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" />
|
|
if (key === 'conversation.empty') return <div data-testid="empty-content" />
|
|
return <div data-testid="other-content" />
|
|
}) as AppFrameProps['renderSlot']
|
|
const useSessions = ((sel: (s: SessionListState) => unknown) => {
|
|
const current = selectedSession.current
|
|
const sessionState = {
|
|
ids: current === undefined ? [] : [current],
|
|
byId: current === undefined
|
|
? {}
|
|
: { [current]: { id: current, displayTitle: 'Test', running: false, blank: selectedSessionBlank.current, updatedAt: 1 } },
|
|
current,
|
|
phase: 'ready',
|
|
} as SessionListState
|
|
return sel(sessionState)
|
|
}) as never
|
|
const workspaceState: WorkspaceListState = {
|
|
items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null,
|
|
baselinesReady: baselinesReady.current, recentWorkspaceId: undefined,
|
|
}
|
|
const element = () => (
|
|
<AppFrame
|
|
useStore={hookOf(instance)}
|
|
actions={instance.actions}
|
|
renderSlot={renderSlot}
|
|
useSessions={useSessions}
|
|
useWorkspaces={((sel: (s: WorkspaceListState) => unknown) => sel(workspaceState)) as never}
|
|
SessionProvider={SessionProviderStub}
|
|
/>
|
|
)
|
|
const utils = render(element())
|
|
const frame = utils.container.firstElementChild as HTMLElement
|
|
return { instance, frame, slotCalls, rerenderFrame: () => { utils.rerender(element()) }, ...utils }
|
|
}
|
|
|
|
function tracks(frame: HTMLElement): number[] {
|
|
const m = /^(\d+)px minmax\(0, 1fr\) (\d+)px$/.exec(frame.style.gridTemplateColumns)
|
|
if (m === null) throw new Error(`unexpected template: ${frame.style.gridTemplateColumns}`)
|
|
return [Number(m[1]), Number(m[2])]
|
|
}
|
|
|
|
function drag(handle: Element, fromX: number, toX: number): void {
|
|
const down = new PointerEvent('pointerdown', { pointerId: 1, clientX: fromX, bubbles: true })
|
|
const move = new PointerEvent('pointermove', { pointerId: 1, clientX: toX, bubbles: true })
|
|
const up = new PointerEvent('pointerup', { pointerId: 1, clientX: toX, bubbles: true })
|
|
act(() => { handle.dispatchEvent(down) })
|
|
act(() => { handle.dispatchEvent(move); vi.advanceTimersByTime(20) })
|
|
act(() => { handle.dispatchEvent(up) })
|
|
}
|
|
|
|
beforeEach(() => {
|
|
frameWidth = 1920
|
|
selectedSession.current = 's-test' as SessionId
|
|
selectedSessionBlank.current = false
|
|
baselinesReady.current = true
|
|
vi.useFakeTimers()
|
|
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number)
|
|
vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) })
|
|
window.innerWidth = frameWidth
|
|
Element.prototype.getBoundingClientRect = function () {
|
|
return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) }
|
|
}
|
|
// jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass.
|
|
const captured = new WeakSet<Element>()
|
|
Element.prototype.setPointerCapture = function () { captured.add(this) }
|
|
Element.prototype.releasePointerCapture = function () { captured.delete(this) }
|
|
Element.prototype.hasPointerCapture = function () { return captured.has(this) }
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.useRealTimers()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
describe('AppFrame', () => {
|
|
it('renders three tracks from store state', () => {
|
|
const { frame } = mountFrame()
|
|
expect(tracks(frame)).toEqual([280, 0])
|
|
})
|
|
|
|
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('keeps the conversation slot mounted while no session is current', () => {
|
|
// No current session: the session-maybe conversation shell owns the New
|
|
// Session view itself — the center column renders it unconditionally.
|
|
selectedSession.current = undefined
|
|
const { slotCalls, getByTestId } = mountFrame()
|
|
expect(getByTestId('center-content')).toBeTruthy()
|
|
expect(slotCalls.map(c => c.key)).toContain('conversation')
|
|
})
|
|
|
|
it('renders both column occupants before baselines settle (no loading gate)', () => {
|
|
// User ruling: the bare loading status looked worse than the shell's own
|
|
// pending rendering — both occupants mount from first paint.
|
|
baselinesReady.current = false
|
|
const { slotCalls } = mountFrame()
|
|
expect(slotCalls.map(c => c.key)).toContain('conversation')
|
|
expect(slotCalls.map(c => c.key)).toContain('details')
|
|
})
|
|
|
|
it('ignores unselected states and closes only when the Session id changes', () => {
|
|
const { frame, instance, rerenderFrame } = mountFrame()
|
|
expect(tracks(frame)).toEqual([280, 0])
|
|
|
|
act(() => { instance.actions.openDetails() })
|
|
expect(tracks(frame)).toEqual([280, 360])
|
|
|
|
selectedSession.current = 's-next' as SessionId
|
|
act(() => { rerenderFrame() })
|
|
expect(tracks(frame)).toEqual([280, 0])
|
|
|
|
act(() => { instance.actions.openDetails() })
|
|
selectedSession.current = 's-blank' as SessionId
|
|
selectedSessionBlank.current = true
|
|
act(() => { rerenderFrame() })
|
|
expect(tracks(frame)).toEqual([280, 0])
|
|
expect(instance.getSnapshot().details).toBe(360)
|
|
|
|
selectedSession.current = 's-next' as SessionId
|
|
selectedSessionBlank.current = false
|
|
act(() => { rerenderFrame() })
|
|
expect(tracks(frame)).toEqual([280, 360])
|
|
|
|
selectedSession.current = undefined
|
|
act(() => { rerenderFrame() })
|
|
expect(tracks(frame)).toEqual([280, 0])
|
|
selectedSession.current = 's-test' as SessionId
|
|
act(() => { rerenderFrame() })
|
|
expect(tracks(frame)).toEqual([280, 0])
|
|
})
|
|
|
|
it('keeps details closed when the first Session materializes', () => {
|
|
selectedSession.current = undefined
|
|
const { frame, instance, rerenderFrame } = mountFrame()
|
|
expect(tracks(frame)).toEqual([280, 0])
|
|
expect(instance.getSnapshot().details).toBe(0)
|
|
|
|
selectedSession.current = 's-first' as SessionId
|
|
act(() => { rerenderFrame() })
|
|
expect(tracks(frame)).toEqual([280, 0])
|
|
})
|
|
|
|
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: 280 })
|
|
})
|
|
|
|
it('sidebar drag widens through rAF-batched pointer moves', () => {
|
|
const { frame } = mountFrame()
|
|
const handles = frame.querySelectorAll('[class*="handle"]')
|
|
drag(handles[0]!, 280, 350)
|
|
expect(tracks(frame)[0]).toBe(350)
|
|
})
|
|
|
|
it('details drag widens leftward (negative dx grows the panel)', () => {
|
|
const { frame, instance } = mountFrame()
|
|
act(() => { instance.actions.openDetails() })
|
|
const handles = frame.querySelectorAll('[class*="handle"]')
|
|
drag(handles[1]!, 1560, 1500)
|
|
expect(tracks(frame)[1]).toBe(420)
|
|
})
|
|
|
|
it('drag base is the rendered (concession-clamped) width, not the preference', () => {
|
|
frameWidth = 1250 // step-2 squeeze: details renders 330 while preference is 360
|
|
const { frame, instance } = mountFrame()
|
|
act(() => { instance.actions.openDetails() })
|
|
expect(tracks(frame)).toEqual([280, 330])
|
|
const handles = frame.querySelectorAll('[class*="handle"]')
|
|
drag(handles[1]!, 920, 930) // shrink by 10 from the rendered width
|
|
expect(instance.getSnapshot().details).toBe(320)
|
|
})
|
|
|
|
it('details column stays mounted at zero width', () => {
|
|
const { frame, getByTestId } = mountFrame()
|
|
expect(tracks(frame)).toEqual([280, 0])
|
|
expect(getByTestId('details-content')).toBeTruthy()
|
|
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
|
|
})
|
|
|
|
it('closed sidebar keeps its compact rail with mounted slot content and collapsed owner props', () => {
|
|
const { frame, instance, slotCalls, getByTestId } = mountFrame()
|
|
act(() => { instance.actions.toggleSidebar() })
|
|
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
|
|
expect(getByTestId('sidebar-content')).toBeTruthy()
|
|
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
|
|
const lastSidebarCall = slotCalls.filter(c => c.key === 'sidebar').at(-1)!
|
|
expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
|
|
})
|
|
|
|
it('viewport shrink triggers the concession chain via ResizeObserver', () => {
|
|
const { frame, instance } = mountFrame()
|
|
act(() => { instance.actions.openDetails() })
|
|
frameWidth = 1250
|
|
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
|
expect(tracks(frame)).toEqual([280, 330])
|
|
frameWidth = 1920
|
|
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
|
expect(tracks(frame)).toEqual([280, 360])
|
|
})
|
|
|
|
it('drag handles disappear for collapsed columns', () => {
|
|
const { frame, instance } = mountFrame()
|
|
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
|
|
act(() => { instance.actions.openDetails() })
|
|
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2)
|
|
act(() => { instance.actions.closeDetails() })
|
|
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
|
|
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, instance } = mountFrame()
|
|
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
|
|
const before = instance.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(instance.getSnapshot().sidebar).toBe(before)
|
|
})
|
|
|
|
it('two moves inside one frame coalesce through the pending rAF', () => {
|
|
const { frame, instance } = mountFrame()
|
|
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
|
|
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) })
|
|
act(() => {
|
|
// Two moves before the frame flushes: the second must ride the pending
|
|
// rAF (frame.current ??= guard), and the flush sees the latest x.
|
|
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 320, bubbles: true }))
|
|
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 340, bubbles: true }))
|
|
vi.advanceTimersByTime(20)
|
|
})
|
|
act(() => { handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 340, bubbles: true })) })
|
|
expect(instance.getSnapshot().sidebar).toBe(340)
|
|
})
|
|
|
|
it('pointerup with a pending rAF cancels it and commits the final position', () => {
|
|
const { frame, instance } = mountFrame()
|
|
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
|
|
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) })
|
|
act(() => {
|
|
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 360, bubbles: true }))
|
|
// No timer advance: the rAF is still pending when pointerup arrives.
|
|
handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 360, bubbles: true }))
|
|
})
|
|
expect(instance.getSnapshot().sidebar).toBe(360)
|
|
})
|
|
|
|
it('zero-width resize reports are ignored (display:none window)', () => {
|
|
const { frame } = mountFrame()
|
|
frameWidth = 0
|
|
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
|
// Track template still reflects the last non-zero viewport.
|
|
expect(tracks(frame)).toEqual([280, 0])
|
|
})
|
|
})
|
|
|
|
describe('AppFrame — unmount with an in-flight resize frame', () => {
|
|
it('cancels the pending rAF on unmount (no post-unmount setState)', () => {
|
|
const { unmount } = mountFrame()
|
|
frameWidth = 800
|
|
act(() => { fireResize?.() }) // rAF scheduled, NOT flushed
|
|
unmount()
|
|
// Flushing after unmount must be a no-op (the frame was cancelled).
|
|
expect(() => { vi.advanceTimersByTime(20) }).not.toThrow()
|
|
})
|
|
|
|
it('double resize inside one frame rides the pending rAF (??= guard)', () => {
|
|
const { frame, instance } = mountFrame()
|
|
act(() => { instance.actions.openDetails() })
|
|
frameWidth = 1250
|
|
act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
|
|
expect(tracks(frame)).toEqual([280, 330])
|
|
})
|
|
})
|