Files
Agent-System-Frontend/src/documentCockpit.test.ts
T
weichen 86770e35fb Add cockpit reader view and evidence panel (I3)
Why: implement the reviewed I3 increment - continuous body reading with
per-section status lines, version summary with collapsed line-diff (backend
versions projection from RDM PR #420), right evidence panel with
sources/history/impacts/conflicts tabs, panorama-reader positioning on the
shared selection, per user+project view preferences (panorama/reader,
density, panel tab), strict-seq history merging and honest restricted states.

Evidence: 10 new pure-logic tests (prefs validation, version summary and
diff marks, strict history merge drops, reader assembly three-state); vitest
324/324 green; tsc and vite build green; tokens-only styles.

Impact: cockpit-only; no fourth column, no standalone graph (relations stay
in the right panel); reader entry is explicit (panorama button / view toggle).

Refs: rollout I3
2026-10-16 12:21:24 +00:00

215 lines
9.4 KiB
TypeScript

// 文档驾驶舱 I2 纯逻辑层测试:能力门控、筛选切换、统计呈现(真 0 vs 不可算)、
// 快照一致性/更新提示、上下文键、选择保留、树徽标。组件取数编排不在此覆盖。
import { describe, expect, it } from 'vitest'
import {
contextKey, nodeSummaryLine, sameSnapshot, selectionSurvives, statDisplay,
STAT_FILTERS, toggleFilter, treeBadges, updateNotice,
type CockpitMeta, type CockpitStats, type CockpitTreeNode,
} from './documentCockpit'
import { cockpitReady, COCKPIT_COMMANDS } from './workspaces'
const meta = (rev: string, seq: number): CockpitMeta => ({
schemaVersion: 'document-cockpit.v1', projectId: 'p1', snapshotRevision: rev,
asOfSeq: seq, generatedAt: '2026-10-16T00:00:00Z', truncated: false,
})
describe('cockpitReady', () => {
const all = COCKPIT_COMMANDS.map(id => ({ id, enabled: true }))
it('七命令齐备且 enabled 才就绪', () => {
expect(cockpitReady(null)).toBe(false)
expect(cockpitReady([])).toBe(false)
expect(cockpitReady(all.map(c => ({ ...c, enabled: false })))).toBe(false)
expect(cockpitReady(all.slice(0, 6))).toBe(false)
expect(cockpitReady(all)).toBe(true)
})
})
describe('统计呈现:真 0 与不可算不得互相冒充', () => {
const stats: CockpitStats = {
documentCount: 0,
completion: { known: 3, total: 5, state: 'known' },
emptyNodeCount: 0,
unverifiedSourceCount: 2,
conflictCount: null as unknown as number,
pendingChangeCount: null,
updatedAt: '',
statReasons: { conflictCount: 'upstream_error', pendingChangeCount: 'not_projected_v1' },
}
it('真 0 显示 0', () => {
expect(statDisplay(stats, 'empty').value).toBe('0')
expect(statDisplay(stats, 'documents').value).toBe('0')
})
it('null 显示 — 并带原因', () => {
const d = statDisplay(stats, 'conflict')
expect(d.value).toBe('—')
expect(d.hint).toBe('upstream_error')
})
it('未纳入投影的统计显式标注原因', () => {
const d = statDisplay(stats, 'pending')
expect(d.value).toBe('—')
expect(d.hint).toBe('not_projected_v1')
})
it('完成度三分态:known/partial/unavailable', () => {
expect(statDisplay(stats, 'completion').value).toBe('3/5')
expect(statDisplay({ ...stats, completion: { known: 1, total: 5, state: 'partial' } }, 'completion').hint).toBe('部分计算')
const u = statDisplay({ ...stats, completion: { known: 0, total: 0, state: 'unavailable' } }, 'completion')
expect(u.value).toBe('—')
})
})
describe('筛选切换', () => {
it('点击切换、再点清除;统计键与筛选值一一对应', () => {
expect(toggleFilter('', 'state:empty')).toBe('state:empty')
expect(toggleFilter('state:empty', 'state:empty')).toBe('')
expect(toggleFilter('state:empty', 'state:conflict')).toBe('state:conflict')
expect(STAT_FILTERS.map(s => s.filter)).toEqual(['state:empty', 'state:unverified', 'state:conflict'])
})
})
describe('快照一致性与更新提示', () => {
it('同 revision 同 seq 视为同快照', () => {
expect(sameSnapshot(meta('wm:5', 5), meta('wm:5', 5))).toBe(true)
expect(sameSnapshot(meta('wm:5', 5), meta('wm:6', 6))).toBe(false)
expect(sameSnapshot(undefined, meta('wm:5', 5))).toBe(false)
})
it('展示快照落后时提示刷新,一致时不提示', () => {
expect(updateNotice(meta('wm:5', 5), meta('wm:6', 6))).toContain('wm:6')
expect(updateNotice(meta('wm:6', 6), meta('wm:6', 6))).toBeNull()
expect(updateNotice(null, meta('wm:6', 6))).toBeNull()
})
})
describe('旧响应丢弃的上下文键', () => {
it('项目与筛选任一变化都改变键', () => {
expect(contextKey('p1', '')).not.toBe(contextKey('p2', ''))
expect(contextKey('p1', '')).not.toBe(contextKey('p1', 'state:empty'))
expect(contextKey('p1', 'state:empty')).toBe(contextKey('p1', 'state:empty'))
})
})
describe('刷新不重置选择', () => {
it('文档仍在根清单则保留', () => {
const kept = selectionSurvives({ kind: 'document', id: 'd1' }, ['d1', 'd2'])
expect(kept).toEqual({ kind: 'document', id: 'd1' })
})
it('文档被筛掉则清空', () => {
expect(selectionSurvives({ kind: 'document', id: 'd9' }, ['d1', 'd2'])).toBeNull()
})
it('章节选择随父文档存活', () => {
expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d1'], 'd1')).toEqual({ kind: 'section', id: 's1' })
expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d2'], 'd1')).toBeNull()
expect(selectionSurvives(null, ['d1'])).toBeNull()
})
})
describe('树徽标与状态行', () => {
const node = (over: Partial<CockpitTreeNode['state']> & { visibility?: string }): CockpitTreeNode => ({
ref: { kind: 'section', id: 's1' }, title: '章节', path: [], childCount: 0,
visibility: over.visibility || 'full',
state: {
content: 'complete', verification: 'not_applicable', sourceCount: 0,
conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true, ...over,
},
} as CockpitTreeNode)
it('诚实徽标:空/受限/未核实/冲突计数', () => {
expect(treeBadges(node({}))).toEqual([])
expect(treeBadges(node({ content: 'empty' }))).toContain('空')
expect(treeBadges(node({ verification: 'unverified' }))).toContain('未核实')
expect(treeBadges(node({ conflictCount: 2 }))).toContain('冲突 2')
expect(treeBadges(node({ visibility: 'metadata_only' }))).toContain('仅元数据')
})
it('结构缺口显式标注(冻结事实:doc→section 两级)', () => {
const line = nodeSummaryLine(node({ content: 'empty' }))
expect(line).toContain('内容为空')
expect(line).toContain('层级止于章节')
})
})
// ---- I3:阅读器与右栏纯逻辑 ----
import {
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
prefsKey, mergePrefs, loadPrefs,
type CockpitHistorySummary, type CockpitVersionSummary,
} from './documentCockpit'
describe('偏好:按用户+项目隔离,枚举外脏值回落默认', () => {
it('prefsKey 区分用户与项目', () => {
expect(prefsKey('a', 'p1')).not.toBe(prefsKey('a', 'p2'))
expect(prefsKey('a', 'p1')).not.toBe(prefsKey('b', 'p1'))
})
it('mergePrefs 逐项校验', () => {
expect(mergePrefs(null)).toEqual({ lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' })
expect(mergePrefs({ lastView: 'reader', density: 'full', rightPanelTab: 'conflicts' })).toEqual({
lastView: 'reader', density: 'full', rightPanelTab: 'conflicts',
})
const junk = mergePrefs({ lastView: 'galaxy', density: 42, rightPanelTab: 'nope' })
expect(junk.lastView).toBe('panorama')
expect(junk.density).toBe('compact')
expect(junk.rightPanelTab).toBe('sources')
})
it('loadPrefs 在无 localStorage 环境安全回落', () => {
expect(loadPrefs('a', 'p1').lastView).toBe('panorama')
})
})
describe('版本摘要与行级差异', () => {
const v = (seq: number, content?: string, redacted?: boolean): CockpitVersionSummary => ({
seq, version: `v${seq}`, at: '2026-10-16T00:00:00Z', basisCount: 0, content, redacted,
})
it('当前+前一版;受限整体降级', () => {
const s = versionSummary([v(3, '三'), v(2, '二'), v(1, '一')])
expect(s.current?.version).toBe('v3')
expect(s.previous?.version).toBe('v2')
expect(s.redacted).toBe(false)
const r = versionSummary([v(3, undefined, true)])
expect(r.redacted).toBe(true)
expect(versionSummary(undefined).current).toBeUndefined()
})
it('diffLines 标记新增/删除/保留', () => {
const d = diffLines('共同\n新增行', '共同\n删除行')
expect(d).toContainEqual({ flag: '=', text: '共同' })
expect(d).toContainEqual({ flag: '+', text: '新增行' })
expect(d).toContainEqual({ flag: '-', text: '删除行' })
})
})
describe('历史严格合并:乱序与重复丢弃(DV-R05)', () => {
const h = (seq: number): CockpitHistorySummary => ({ seq, at: '', op: 'x', summary: `s${seq}` })
it('只接受 seq 更大的条目', () => {
const r = mergeHistoryStrict([h(10), h(9)], [h(11), h(8)])
expect(r.items.map(i => i.seq)).toEqual([10, 9, 11])
expect(r.dropped).toBe(1)
})
it('重复 seq 丢弃且计入 dropped', () => {
const r = mergeHistoryStrict([h(10)], [h(10), h(11)])
expect(r.items.map(i => i.seq)).toEqual([10, 11])
expect(r.dropped).toBe(1)
})
it('空历史接受全部', () => {
expect(mergeHistoryStrict([], [h(1), h(2)]).items.map(i => i.seq)).toEqual([1, 2])
})
it('historyLine 带操作者与本地时间', () => {
expect(historyLine(h(1))).toContain('s1')
})
})
describe('阅读器装配:受限章节给占位不冒充空内容', () => {
const sec = (id: string, content: string): CockpitTreeNode => ({
ref: { kind: 'section', id }, title: id, path: [], childCount: 0,
state: { content, verification: 'not_applicable', sourceCount: 0, conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true },
visibility: 'full',
})
it('正文/受限/未加载三分态', () => {
const out = assembleReader(
[sec('a', 'complete'), sec('b', 'complete'), sec('c', 'empty')],
{
a: { body: [{ kind: 'text', text: '正文A' }] },
b: { body: [{ kind: 'restricted', reason: 'section-duty-or-authorship' }] },
},
)
expect(out[0].blocks).toEqual([{ kind: 'text', text: '正文A' }])
expect(out[1].blocks[0].kind).toBe('restricted')
expect(out[2].blocks[0].reason).toBe('not_loaded')
})
})