// 文档驾驶舱 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 & { 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') }) })