Files
Agent-System-Frontend/src/surface.test.ts
T
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

176 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as apiModule from './api'
import { api } from './api'
import { store } from './store'
import { previewTabsKey } from './previewtabs'
import type { StreamEvent, SurfaceMeta, SurfaceSnapshot } from './contracts'
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
// 服务端快照是唯一权威(KTD8 单一状态所有者)。
function surfEvent(seq: number, surfaceId: string, revision: number, title: string, conversationId = 'c1'): StreamEvent<SurfaceSnapshot> {
return {
seq, conversationId, ts: new Date().toISOString(), v: 1, type: 'surface.updated',
payload: {
identity: { surfaceId, owner: 'alice', pluginId: 'generative-ui', conversationId: 'c1' },
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' },
revision, activationEpoch: 1,
spec: { title, nodes: [{ type: 'markdown', id: 'n1', markdown: 'x' }] },
},
}
}
beforeEach(async () => {
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => ({
conversation: { id, projectId: 'p1', title: id, createdAt: '', updatedAt: '' },
messages: [], runs: [], activeHitl: [], lastSeq: 0,
}))
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
vi.spyOn(api, 'listCards').mockResolvedValue([])
vi.spyOn(api, 'listTasks').mockResolvedValue([])
vi.spyOn(apiModule, 'connectEvents').mockReturnValue({ close: () => {} })
await store.openConversation('c1')
for (const tab of store.getState().previewTabs.c1 ?? []) store.closePreview(tab.id)
store.setUI({ rightTab: 'work', rightCollapsed: true, filesView: 'list' })
})
afterEach(() => vi.restoreAllMocks())
describe('surface.updated projection', () => {
it('新事件入库;旧修订与重复事件不覆盖', () => {
store.applyEvent(surfEvent(1, 's1', 1, '初版'))
store.applyEvent(surfEvent(2, 's1', 2, '修订二'))
store.applyEvent(surfEvent(3, 's1', 1, '迟到的旧修订'))
store.applyEvent(surfEvent(4, 's1', 2, '重复事件'))
const list = store.getState().surfaces
expect(list).toHaveLength(1)
expect(list[0].revision).toBe(2)
expect(list[0].spec.title).toBe('修订二')
})
it('多页面并存,互不覆盖', () => {
store.applyEvent(surfEvent(1, 's1', 1, '页面一'))
store.applyEvent(surfEvent(5, 's2', 1, '页面二'))
const list = store.getState().surfaces
expect(list.filter(x => String(x.identity.surfaceId) === 's1')).toHaveLength(1)
expect(list.filter(x => String(x.identity.surfaceId) === 's2')).toHaveLength(1)
})
})
describe('surface.updated 可见性联动', () => {
it('keeps new background pages available without opening the file pane', () => {
store.applyEvent(surfEvent(101, 'sv1', 1, '可见性页'))
const ui = store.getState().ui
expect(ui.rightTab).toBe('work')
expect(ui.rightCollapsed).toBe(true)
expect(ui.filesView).toBe('list')
const tabs = store.getState().previewTabs[previewTabsKey(store.getState().currentConvId)] ?? []
expect(tabs.some(t => t.id === 'surface:sv1')).toBe(false)
store.openSurface('sv1')
expect(store.getState().ui.rightTab).toBe('files')
expect(store.getState().previewActive.c1).toBe('surface:sv1')
})
it('抑制开标签时留 toast 痕迹', () => {
// 预置:已有 surface 标签但 active 是别的工件标签 → 新页面不开标签
store.applyEvent(surfEvent(102, 'sv2', 1, '第二页'))
// sv2 已入 surfaces 数组(事件投影),标签未强开——至少状态一致
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true)
})
})
describe('U6 工作区任务页', () => {
it('pin 固定后新页面事件不改变固定对象;取消后跟随最新', () => {
store.setUI({ workspaceSurfacePin: null })
store.applyEvent(surfEvent(201, 'w1', 1, '第一页'))
const first = store.getState().ui.workspaceSurfacePin
expect(first).toBeNull() // 默认跟随(不自动 pin)
// 用户固定 w1
store.setUI({ workspaceSurfacePin: 'w1' })
store.applyEvent(surfEvent(202, 'w2', 1, '第二页'))
// pin 不变,且 surfaces 数组两页并存(禁用回退时容器随空数组自然消失)
expect(store.getState().ui.workspaceSurfacePin).toBe('w1')
const w2 = store.getState().surfaces.find(x => String(x.identity.surfaceId) === 'w2')
expect(w2?.spec.title).toBe('第二页')
store.setUI({ workspaceSurfacePin: null })
expect(store.getState().ui.workspaceSurfacePin).toBeNull()
})
})
describe('saved surfaces on conversation reload', () => {
const snapshot = (id: string, revision = 1) => surfEvent(1, id, revision, id).payload!
const meta = (id: string): SurfaceMeta => ({
surfaceId: id, title: id, pluginId: 'generative-ui', revision: 1,
conversationId: 'c1', updatedAt: '2026-10-12T00:00:00Z',
})
beforeEach(() => {
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
vi.spyOn(api, 'surfaceGet').mockImplementation(async id => snapshot(id))
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => ({
conversation: { id, projectId: 'p1', title: id, createdAt: '', updatedAt: '' },
messages: [], runs: [], activeHitl: [], lastSeq: 0,
}))
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
vi.spyOn(api, 'listCards').mockResolvedValue([])
vi.spyOn(api, 'listTasks').mockResolvedValue([])
vi.spyOn(apiModule, 'connectEvents').mockReturnValue({ close: () => {} })
store.setUI({ workspaceSurfacePin: null })
const key = previewTabsKey(store.getState().currentConvId)
for (const tab of store.getState().previewTabs[key] ?? []) store.closePreview(tab.id)
})
afterEach(() => vi.restoreAllMocks())
it('hydrates the metadata directory before exposing two renderable snapshots', async () => {
vi.mocked(api.surfaceList).mockResolvedValue([meta('reload-1'), meta('reload-2')])
await store.openConversation('reload')
await vi.waitFor(() => {
expect(store.getState().surfaces).toEqual([snapshot('reload-1'), snapshot('reload-2')])
})
expect(api.surfaceGet).toHaveBeenCalledWith('reload-1')
expect(api.surfaceGet).toHaveBeenCalledWith('reload-2')
expect(store.getState().previewActive.reload).toBe('surface:reload-1')
})
it('isolates one unavailable page while restoring the remaining page', async () => {
vi.mocked(api.surfaceList).mockResolvedValue([meta('missing'), meta('available')])
vi.mocked(api.surfaceGet).mockImplementation(async id => {
if (id === 'missing') throw new Error('404: removed page')
return snapshot(id)
})
await store.openConversation('partial')
await vi.waitFor(() => expect(store.getState().surfaces).toEqual([snapshot('available')]))
})
it('keeps a newer live revision when an older snapshot arrives late', async () => {
let resolve!: (value: SurfaceSnapshot) => void
vi.mocked(api.surfaceList).mockResolvedValue([meta('racing')])
vi.mocked(api.surfaceGet).mockReturnValue(new Promise<SurfaceSnapshot>(r => { resolve = r }))
await store.openConversation('race')
store.applyEvent(surfEvent(301, 'racing', 2, 'live revision', 'race'))
resolve(snapshot('racing', 1))
await new Promise<void>(r => setTimeout(r, 0))
expect(store.getState().surfaces).toHaveLength(1)
expect(store.getState().surfaces[0].revision).toBe(2)
expect(store.getState().surfaces[0].spec.title).toBe('live revision')
})
it('discards hydration from the previous conversation after switching', async () => {
let resolve!: (value: SurfaceSnapshot) => void
vi.mocked(api.surfaceList).mockResolvedValueOnce([meta('previous')]).mockResolvedValue([])
vi.mocked(api.surfaceGet).mockReturnValue(new Promise<SurfaceSnapshot>(r => { resolve = r }))
await store.openConversation('previous-conv')
await store.openConversation('current-conv')
resolve(snapshot('previous'))
await new Promise<void>(r => setTimeout(r, 0))
expect(store.getState().currentConvId).toBe('current-conv')
expect(store.getState().surfaces).toEqual([])
})
})