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): StreamEvent { 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() }) }) 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(r => { resolve = r })) await store.openConversation('race') store.applyEvent(surfEvent(301, 'racing', 2, 'live revision')) resolve(snapshot('racing', 1)) await new Promise(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(r => { resolve = r })) await store.openConversation('previous-conv') await store.openConversation('current-conv') resolve(snapshot('previous')) await new Promise(r => setTimeout(r, 0)) expect(store.getState().currentConvId).toBe('current-conv') expect(store.getState().surfaces).toEqual([]) }) })