Files
Agent-System-Frontend/test/workbench-layout.test.ts
weichen 411a79d9e2 Deliver a conversation-first progressive workbench with safe live state
Why: make material, background work, confirmations and files accessible to nontechnical users across desktop and mobile.

Evidence: Dev SELF receipts for Frontend tsc, 190 tests, knip baseline, build, nine fixture browser cases and two real HTTP/browser smoke cases using MockRunner.

Impact: existing API and permissions remain authoritative; stale events/queries and duplicate effects are guarded, while one file preview and reading state are preserved.

Refs: work-companion-ui design r0.2 and plan r1 U2-U5; UI01-UI13.
2026-10-12 16:05:13 +00:00

136 lines
7.0 KiB
TypeScript

import { Children, createElement, isValidElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { TopBar } from '../src/components/TopBar'
import { TaskSidebar } from '../src/components/TaskSidebar'
import { selectWorkbenchLayout, type WorkbenchLayoutOptions } from '../src/workbenchLayout'
import { store } from '../src/store'
vi.mock('../src/store', () => {
const state = {
projects: [{ id: 'prj_1', name: '材料项目' }],
tasks: [{ id: 'cnv_1', projectId: 'prj_1', title: '整理项目材料', updatedAt: '2026-10-12T10:00:00Z' }],
me: null, adminSections: [], artifacts: [], cards: [], projectCards: [], serverWorkspaces: [],
rdmAuditAvailable: false, currentConvId: 'cnv_1', currentProjectId: 'prj_1',
ui: { theme: 'light', showArchived: false, rightCollapsed: true, rightTab: 'work' },
}
return { useStore: (select: (value: typeof state) => unknown) => select(state), store: {
openInbox: vi.fn(), openFiles: vi.fn(), focusPending: vi.fn(), setUI: vi.fn(),
} }
})
const desktop: WorkbenchLayoutOptions = {
width: 1440, contextOpen: false, leftCollapsed: false, rightCollapsed: false,
leftWidth: 260, rightWidth: 360, previewWidth: 700, rightTab: 'work',
previewOpen: false, previewExpanded: false, filesView: 'list',
}
describe('selectWorkbenchLayout', () => {
it.each([320, 390, 736])('shows one conversation pane at %ipx and keeps saved desktop widths unchanged', width => {
const options = { ...desktop, width, leftWidth: 420, rightWidth: 560, rightCollapsed: true }
const layout = selectWorkbenchLayout(options)
expect(layout.compact).toBe(true)
expect([layout.showContext, layout.showConversation, layout.showRight]).toEqual([false, true, false])
expect(options.leftWidth).toBe(420)
expect(options.rightWidth).toBe(560)
})
it('opens context on a narrow screen and returns to the existing conversation', () => {
const options = { ...desktop, width: 390, rightCollapsed: true }
expect(selectWorkbenchLayout({ ...options, contextOpen: true }).pane).toBe('context')
expect(selectWorkbenchLayout({ ...options, contextOpen: false }).pane).toBe('conversation')
})
it('uses the same right pane for work and expanded file reading', () => {
const work = selectWorkbenchLayout({ ...desktop, width: 390 })
expect(work.pane).toBe('work')
expect([work.showContext, work.showConversation, work.showRight]).toEqual([false, false, true])
const files = selectWorkbenchLayout({ ...desktop, width: 390, rightTab: 'files', filesView: 'preview', previewOpen: true, previewExpanded: true })
expect(files.pane).toBe('files')
expect([files.showContext, files.showConversation, files.showRight]).toEqual([false, false, true])
expect(files.previewExpanded).toBe(true)
expect(files.rightWidth).toBe(382)
})
it('keeps the conversation visible after an expanded file pane is closed', () => {
const layout = selectWorkbenchLayout({ ...desktop, rightTab: 'files', filesView: 'preview', previewOpen: true, previewExpanded: true, rightCollapsed: true })
expect(layout.showConversation).toBe(true)
expect(layout.showRight).toBe(false)
expect(layout.previewExpanded).toBe(false)
})
it.each([1024, 1440])('fits stored oversized work panes inside %ipx while reserving conversation space', width => {
const layout = selectWorkbenchLayout({ ...desktop, width, leftWidth: 4000, rightWidth: 4000 })
expect(layout.compact).toBe(false)
expect(layout.leftWidth).toBeGreaterThanOrEqual(220)
expect(layout.leftWidth).toBeLessThanOrEqual(420)
expect(layout.rightWidth).toBeGreaterThanOrEqual(280)
expect(layout.rightWidth).toBeLessThanOrEqual(560)
expect(layout.leftWidth + layout.rightWidth + 360 + 32).toBeLessThanOrEqual(width)
})
it('fits wide file reading at 1024px without discarding the conversation', () => {
const layout = selectWorkbenchLayout({ ...desktop, width: 1024, leftWidth: 420, previewWidth: 1100, rightTab: 'files', filesView: 'preview', previewOpen: true })
expect(layout.showConversation).toBe(true)
expect(layout.rightWidth).toBeGreaterThanOrEqual(420)
expect(layout.leftWidth + layout.rightWidth + 320 + 32).toBeLessThanOrEqual(1024)
})
it('enlarges desktop reading inside the existing right pane and rejects invalid saved widths', () => {
const layout = selectWorkbenchLayout({ ...desktop, leftWidth: NaN, previewWidth: Infinity, rightTab: 'files', filesView: 'preview', previewOpen: true, previewExpanded: true })
expect(layout.showConversation).toBe(false)
expect(layout.showRight).toBe(true)
expect(layout.previewExpanded).toBe(true)
expect(Number.isFinite(layout.leftWidth + layout.rightWidth)).toBe(true)
expect(layout.leftWidth + layout.rightWidth + 20).toBeLessThanOrEqual(1440)
})
})
describe('workbench navigation', () => {
function action(node: ReactNode, label: string): (() => void) | undefined {
for (const child of Children.toArray(node)) {
if (!isValidElement<{ children?: ReactNode; 'aria-label'?: string; onClick?: () => void }>(child)) continue
if (child.props['aria-label'] === label) return child.props.onClick
const found = action(child.props.children, label)
if (found) return found
}
}
it('keeps work and file entrances available when the auxiliary pane is closed', () => {
const html = renderToStaticMarkup(createElement(TopBar))
expect(html).toContain('aria-label="打开工作"')
expect(html).toContain('aria-label="打开文件"')
})
it('offers a clear return to the conversation while reading a file on a narrow screen', () => {
const html = renderToStaticMarkup(createElement<NonNullable<Parameters<typeof TopBar>[0]>>(TopBar, {
compact: true, activePane: 'files', onConversation: () => undefined,
}))
expect(html).toContain('aria-label="返回对话"')
for (const name of ['打开上下文', '打开工作', '打开文件']) expect(html).toContain(`aria-label="${name}"`)
})
it('opens ordinary work with all results reachable instead of only pending confirmations', () => {
action(TopBar(), '打开工作')?.()
expect(store.openInbox).toHaveBeenCalledOnce()
expect(store.setUI).toHaveBeenCalledWith({ inboxFilter: 'all', previewExpanded: false })
})
it('connects both the return action and conversation navigation to the same return path', () => {
const onConversation = vi.fn()
const topbar = TopBar({ compact: true, activePane: 'files', onConversation })
action(topbar, '返回对话')?.()
action(topbar, '打开对话')?.()
expect(onConversation).toHaveBeenCalledTimes(2)
})
it('names conversation creation accurately and exposes conversation selection to the keyboard', () => {
const html = renderToStaticMarkup(createElement(TaskSidebar))
expect(html).toContain('新建对话')
expect(html).not.toContain('新建任务')
expect(html).toMatch(/<button[^>]*class="tsb-task-open"/)
expect(html).toContain('更多对话操作')
expect(html.match(/材料项目/g)).toHaveLength(1)
})
})