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.
136 lines
7.0 KiB
TypeScript
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)
|
|
})
|
|
})
|