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
215 lines
9.4 KiB
TypeScript
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')
|
|
})
|
|
})
|