Why: U6 brings generated pages into the work view - a compact task-page section renders the latest conversation surface under the current-task card (SurfaceView), a pin freezes the shown page against new arrivals (unpin follows latest), a jump button opens the full preview, and the project-level aggregate loads on conversation open for cross-session visibility; when the plugin is disabled or no pages exist the section disappears and the work view is untouched. Evidence: tsc clean; vitest 101/101 including the pin/follow test; knip unchanged. Impact: Frontend-only; consumes the U6 backend aggregate endpoint. Refs: proposal r2 U6; Backend #163; Dev task feature/generative-ui-u6
78 lines
3.5 KiB
TypeScript
78 lines
3.5 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import { store } from './store'
|
||
import { previewTabsKey } from './previewtabs'
|
||
import type { StreamEvent, SurfaceSnapshot } from './contracts'
|
||
|
||
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
|
||
// 服务端快照是唯一权威(KTD8 单一状态所有者)。
|
||
|
||
function surfEvent(seq: number, surfaceId: string, revision: number, title: string): StreamEvent<SurfaceSnapshot> {
|
||
return {
|
||
seq, conversationId: 'c1', 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' }] },
|
||
},
|
||
}
|
||
}
|
||
|
||
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(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('自动开标签时右栏切到文件预览(syncFilesEntry 语义)', () => {
|
||
store.applyEvent(surfEvent(101, 'sv1', 1, '可见性页'))
|
||
const ui = store.getState().ui
|
||
expect(ui.rightTab).toBe('files')
|
||
expect(ui.rightCollapsed).toBe(false)
|
||
expect(ui.filesView).toBe('preview')
|
||
const tabs = store.getState().previewTabs[previewTabsKey(store.getState().currentConvId)] ?? []
|
||
expect(tabs.some(t => t.id === 'surface:sv1')).toBe(true)
|
||
})
|
||
|
||
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()
|
||
})
|
||
})
|