Merge remote-tracking branch 'origin/main' into feature/fe-u7-browser-e2e

This commit is contained in:
2026-10-12 14:34:44 +00:00
10 changed files with 280 additions and 30 deletions
+60
View File
@@ -0,0 +1,60 @@
import { expect, test } from '@playwright/test'
import type { Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'requires a served frontend bundle')
const conversation: Conversation = {
id: 'surface-reload', projectId: 'surface-project', title: '页面恢复回归',
createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z',
}
const snapshots: SurfaceSnapshot[] = ['page-1', 'page-2'].map((surfaceId, i) => ({
identity: { surfaceId, owner: 'fixture', pluginId: 'generative-ui', conversationId: conversation.id },
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1,
spec: { title: `恢复页面${i + 1}`, nodes: [{ id: 'body', type: 'markdown', markdown: `恢复正文${i + 1}` }] },
}))
const metadata: SurfaceMeta[] = snapshots.map(s => ({
surfaceId: s.identity.surfaceId, title: s.spec.title, pluginId: s.identity.pluginId,
revision: s.revision, conversationId: conversation.id, updatedAt: conversation.updatedAt,
}))
test('saved-page reload keeps the app and restores both full snapshots', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', e => errors.push(e.message))
await page.addInitScript(({ id }) => {
localStorage.setItem('as.conv', id)
localStorage.setItem('as.ui', JSON.stringify({ rightTab: 'files', filesView: 'preview', rightCollapsed: false }))
}, { id: conversation.id })
// Replay the two distinct server contracts through the real app. Intercept all
// API requests so this regression can run against a live bundle without writes.
await page.route('**/api/**', async route => {
const request = route.request()
const path = new URL(request.url()).pathname
if (request.method() !== 'GET') return route.fulfill({ status: 405, body: 'read-only regression' })
if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' })
let body: unknown = []
if (path === '/api/health') body = { status: 'ok' }
else if (path === '/api/me') body = { owner: 'fixture', name: 'fixture', auth: true, authVia: 'session' }
else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '恢复项目', createdAt: '', updatedAt: '' }]
else if (path === '/api/conversations' || path === `/api/projects/${conversation.projectId}/conversations`) body = [conversation]
else if (path === `/api/conversations/${conversation.id}`) body = { conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }
else if (path === `/api/conversations/${conversation.id}/surfaces` || path === `/api/projects/${conversation.projectId}/surfaces`) body = metadata
else if (path.startsWith('/api/surfaces/')) {
body = snapshots.find(s => s.identity.surfaceId === path.split('/').at(-1))
} else if (path.startsWith('/api/settings')) body = { schema: [], defaults: {}, user: {}, project: {} }
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
await page.goto('/')
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面1')
await expect(page.locator('.preview-pane')).toContainText('恢复正文1')
await page.reload()
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面1')
await page.getByRole('button', { name: '返回文件列表' }).click()
await expect(page.locator('[data-surface-row]')).toHaveCount(2)
await page.locator('[data-surface-row="page-2"]').click()
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面2')
await expect(page.locator('.preview-pane')).toContainText('恢复正文2')
expect(errors).toEqual([])
})
+6 -4
View File
@@ -4,7 +4,7 @@ import { auth } from './auth'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
ActivityCard,
ActivityCard, SurfaceMeta, SurfaceSnapshot,
} from './contracts'
const BASE = ''
@@ -35,11 +35,13 @@ export const api = {
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
/** 会话内页面目录:扁平 SurfaceMeta(无 identity/spec),不可直接写入 surfaces 投影。 */
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<any[]>(r)),
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
+9 -5
View File
@@ -7,6 +7,7 @@ import { Collapsible, Icon } from '@ui'
import { artifactIcon, humanSize } from '../messageparts'
import type { Artifact } from '../contracts'
import { groupArtifactsByName } from '../artifactGrouping'
import { surfaceKey } from '../surfaceKey'
export function ArtifactsPanel() {
const artifacts = useStore(s => s.artifacts)
@@ -42,16 +43,19 @@ export function ArtifactsPanel() {
{surfaces.length > 0 && (
<div className="ap-surfaces" data-ap-surfaces>
<div className="ap-section-title">生成页面</div>
{surfaces.map(sv => (
<div key={String(sv.identity.surfaceId)} className="artifact-row" data-surface-row={String(sv.identity.surfaceId)}
onClick={() => store.openSurface(String(sv.identity.surfaceId))} title={`打开页面 ${sv.spec?.title ?? ''}`}>
{surfaces.filter(sv => surfaceKey(sv) != null).map(sv => {
const id = surfaceKey(sv)!
return (
<div key={id} className="artifact-row" data-surface-row={id}
onClick={() => store.openSurface(id)} title={`打开页面 ${sv.spec?.title ?? ''}`}>
<span className="ar-icon"><Icon name="grid" size={15} /></span>
<span className="ar-main">
<span className="name">{sv.spec?.title || '(未命名页面)'}</span>
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity.pluginId}</span>
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity?.pluginId}</span>
</span>
</div>
))}
)
})}
</div>
)}
{artifacts.length === 0
+6 -2
View File
@@ -7,6 +7,7 @@ import type { Artifact } from '../contracts'
import { humanSize } from '../messageparts'
import { previewTabsKey } from '../previewtabs'
import { store, useStore } from '../store'
import { surfaceKey } from '../surfaceKey'
export function PreviewPane() {
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
@@ -17,7 +18,10 @@ export function PreviewPane() {
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
const surfaces = useStore(s => s.surfaces)
const surfaceSnap = active?.id.startsWith('surface:')
? surfaces.find(x => 'surface:' + String(x.identity.surfaceId) === active.id)
? surfaces.find(x => {
const id = surfaceKey(x)
return id != null && 'surface:' + id === active.id
})
: undefined
const art = active ? artifacts.find(a => a.id === active.id) : undefined
const textKind = !!art && isTextKind(art)
@@ -56,7 +60,7 @@ export function PreviewPane() {
</div>
<div className="preview-pane-body">
{surfaceSnap
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), id, {})} docBridge={{ onOpenDoc: docId => {
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(surfaceKey(surfaceSnap) ?? '', id, {})} docBridge={{ onOpenDoc: docId => {
const art = artifacts.find(a => a.id === docId)
if (art) store.requestPreview(art)
} }} />
+14 -5
View File
@@ -6,6 +6,7 @@ import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
import { Icon, SurfaceView } from '@ui'
import { previewTabsKey } from '../previewtabs'
import { surfaceKey } from '../surfaceKey'
import { ArtifactsPanel } from './ArtifactsPanel'
import { PreviewPane } from './PreviewPane'
@@ -91,6 +92,11 @@ function FilesTab() {
return <div className="tab-body"><ArtifactsPanel /></div>
}
/** U1:同会话存在后台 progress 卡(进行中/待确认)时,progress 为权威对象,隐藏 CurrentTaskCard 避免双标题。 */
export function hasActiveProgressCard(cards: ActivityCard[]): boolean {
return cards.some(c => c.kind === 'progress' && (c.status === 'active' || c.status === 'action_required'))
}
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
@@ -99,10 +105,11 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
projectCards: ActivityCard[]
cards: ActivityCard[]
}) {
const showCurrentTask = !hasActiveProgressCard(cards)
return (
<>
<div className="tab-body" data-scroll-sync="inbox">
<CurrentTaskCard plan={plan} todos={todos} />
{showCurrentTask ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
@@ -180,9 +187,11 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
function WorkspaceSurfaces() {
const surfaces = useStore(s => s.surfaces)
const pin = useStore(s => s.ui.workspaceSurfacePin)
if (surfaces.length === 0) return null
const shown = pin ? surfaces.find(x => String(x.identity.surfaceId) === pin) : surfaces[surfaces.length - 1]
const usable = surfaces.filter(x => surfaceKey(x) != null)
if (usable.length === 0) return null
const shown = pin ? usable.find(x => surfaceKey(x) === pin) : usable[usable.length - 1]
if (!shown) return null
const shownId = surfaceKey(shown)!
return (
<div className="workspace-surfaces" data-workspace-surfaces>
<div className="ws-head">
@@ -190,10 +199,10 @@ function WorkspaceSurfaces() {
<span className="ws-meta">{shown.spec?.title || '(未命名)'} · 修订 {shown.revision}</span>
<span style={{ flex: 1 }} />
<button className="icon-btn" title={pin ? '取消固定(跟随最新)' : '固定此页(新页面不抢占)'}
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : String(shown.identity.surfaceId) })}>
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : shownId })}>
{pin ? '📍' : '📌'}
</button>
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(String(shown.identity.surfaceId))}>
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(shownId)}>
↗
</button>
</div>
+22 -12
View File
@@ -8,6 +8,7 @@ import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessag
import { canFlushOutbox, deriveShellPhase } from './runstate'
import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
import { rdmAuditReady } from './workspaces'
import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement'
@@ -404,23 +405,31 @@ export const store = {
})
}
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
// surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
api.surfaceList(convId).then(list => {
api.surfaceList(convId).then(async list => {
if (state.currentConvId !== convId) return
set({ surfaces: list ?? [] })
const metas = list ?? []
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。标签只用 Meta。
const conv = previewTabsKey(state.currentConvId)
const tabs = state.previewTabs[conv] ?? []
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
if (!hasSurfaceTab && (list?.length ?? 0) > 0) {
const latest = list[0]
if (!hasSurfaceTab && metas.length > 0) {
const latest = metas[0]
const opened = openPreviewTab(tabs, { id: 'surface:' + String(latest.surfaceId), name: latest.title || '页面' })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
}
const fetched = await Promise.all(metas.map(m =>
api.surfaceGet(String(m.surfaceId)).catch(() => null),
))
if (state.currentConvId !== convId) return
const hydrated = fetched.filter((s): s is SurfaceSnapshot => surfaceKey(s) != null)
// 慢水合不得覆盖并发 surface.updated 已写入的较新 revision。
set({ surfaces: mergeSurfacesByRevision(state.surfaces, hydrated) })
}).catch(() => {})
const resumeSeq = await store.reconcile()
store.connectLive(resumeSeq ?? state.lastSeq)
@@ -547,10 +556,10 @@ export const store = {
// KTD8:单一状态所有者——服务端快照为权威,本地按 revision 守卫:
// 旧修订/重复事件不覆盖新修订;切会话由 selectConversation 清空。
const p = e.payload as SurfaceSnapshot
if (!p?.identity?.surfaceId) break
const id = String(p.identity.surfaceId)
const surfaces = state.surfaces.filter(x => String(x.identity.surfaceId) !== id)
const existing = state.surfaces.find(x => String(x.identity.surfaceId) === id)
const id = surfaceKey(p)
if (!id) break
const surfaces = state.surfaces.filter(x => surfaceKey(x) !== id)
const existing = state.surfaces.find(x => surfaceKey(x) === id)
if (!existing || p.revision > existing.revision) {
surfaces.push(p)
set({ surfaces })
@@ -906,7 +915,8 @@ export const store = {
},
async tidyCards() {
const done = state.cards.filter(c => c.status === 'done')
// U1:不收纳失败语义卡(failed,或旧 done+danger),避免把失败当完成清掉。
const done = state.cards.filter(c => c.status === 'done' && c.tone !== 'danger')
for (const c of done) {
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
}
@@ -947,7 +957,7 @@ export const store = {
},
openSurface(surfaceId: string) {
// generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
const snap = state.surfaces.find(x => surfaceKey(x) === surfaceId)
const conv = previewTabsKey(state.currentConvId)
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: 'surface:' + surfaceId, name: snap?.spec?.title || '页面' })
set({
@@ -961,7 +971,7 @@ export const store = {
async handleSurfaceAction(surfaceId: string, actionId: string, data: Record<string, unknown>) {
const inflight = surfaceActionsInflight[surfaceId + ':' + actionId]
if (inflight) return
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
const snap = state.surfaces.find(x => surfaceKey(x) === surfaceId)
if (!snap) { store.toast('页面快照不可用,无法提交'); return }
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2))
surfaceActionsInflight[surfaceId + ':' + actionId] = true
+78 -2
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'
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, SurfaceSnapshot } from './contracts'
import type { StreamEvent, SurfaceMeta, SurfaceSnapshot } from './contracts'
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
// 服务端快照是唯一权威(KTD8 单一状态所有者)。
@@ -75,3 +77,77 @@ describe('U6 工作区任务页', () => {
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'))
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([])
})
})
+19
View File
@@ -0,0 +1,19 @@
// surface 投影可能短暂混入无 identity 的条目;读 id 一律经此助手,避免渲染崩溃。
import type { SurfaceSnapshot } from './contracts'
export function surfaceKey(x: { identity?: { surfaceId?: unknown } } | null | undefined): string | null {
const id = x?.identity?.surfaceId
return id == null || id === '' ? null : String(id)
}
/** 按 surfaceId 合并;同 id 取 revision 较大者。跳过无 identity 的脏项。 */
export function mergeSurfacesByRevision(a: SurfaceSnapshot[], b: SurfaceSnapshot[]): SurfaceSnapshot[] {
const byId = new Map<string, SurfaceSnapshot>()
for (const s of [...a, ...b]) {
const id = surfaceKey(s)
if (!id) continue
const existing = byId.get(id)
if (!existing || (s.revision ?? 0) >= (existing.revision ?? 0)) byId.set(id, s)
}
return [...byId.values()]
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import type { SurfaceSnapshot } from '../src/contracts'
import { mergeSurfacesByRevision, surfaceKey } from '../src/surfaceKey'
function snap(id: string, revision: number): SurfaceSnapshot {
return {
identity: {
surfaceId: id,
owner: 'o',
pluginId: 'p',
},
compat: { schemaVersion: 1, catalogVersion: '1' },
revision,
activationEpoch: 0,
spec: { title: id, nodes: [] },
}
}
describe('surfaceKey', () => {
it('reads nested identity and skips missing', () => {
expect(surfaceKey(snap('s1', 1))).toBe('s1')
expect(surfaceKey(undefined)).toBeNull()
expect(surfaceKey({} as SurfaceSnapshot)).toBeNull()
expect(surfaceKey({ identity: {} } as SurfaceSnapshot)).toBeNull()
})
})
describe('mergeSurfacesByRevision', () => {
it('keeps higher revision and drops dirty entries', () => {
const dirty = { revision: 99 } as SurfaceSnapshot
const merged = mergeSurfacesByRevision(
[snap('a', 1), dirty],
[snap('a', 3), snap('b', 1)],
)
expect(merged.map(s => [surfaceKey(s), s.revision])).toEqual([
['a', 3],
['b', 1],
])
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { hasActiveProgressCard } from '../src/components/Workbench'
import type { ActivityCard } from '@ui'
function card(over: Partial<ActivityCard>): ActivityCard {
return {
id: 'card_1', projectId: 'prj_1', conversationId: 'cnv_1',
kind: 'progress', title: '材料整理', status: 'active', tone: 'info',
createdAt: '2026-10-12T10:00:00Z', updatedAt: '2026-10-12T10:00:00Z',
...over,
}
}
describe('hasActiveProgressCard', () => {
it('active/action_required progress 卡视为占用当前任务位', () => {
expect(hasActiveProgressCard([card({ status: 'active' })])).toBe(true)
expect(hasActiveProgressCard([card({ status: 'action_required' })])).toBe(true)
})
it('完成/失败 progress 或非 progress 不隐藏 CurrentTaskCard', () => {
expect(hasActiveProgressCard([card({ status: 'done', tone: 'ok' })])).toBe(false)
expect(hasActiveProgressCard([card({ status: 'failed', tone: 'danger' })])).toBe(false)
expect(hasActiveProgressCard([card({ kind: 'confirm', status: 'action_required' })])).toBe(false)
expect(hasActiveProgressCard([])).toBe(false)
})
})