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[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(/]*class="tsb-task-open"/) expect(html).toContain('更多对话操作') expect(html.match(/材料项目/g)).toHaveLength(1) }) })