// lib/req-ledger.test.mjs — 单测:需求台账 哈希 / 提取 / diff / 读写 import { test } from 'node:test' import assert from 'node:assert/strict' import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { hashContent, collectReqUnits, collectFeUnits, collectProtoUnit, computeUnits, loadLedger, writeLedger, diffLedger, PROTO_UNIT_ID, } from './req-ledger.mjs' // 搭一个最小项目目录:docs/01-需求清单//{_module.md,index.md,.md} + docs/08 function scaffold() { const root = mkdtempSync(join(tmpdir(), 'req-ledger-')) const reqDir = join(root, 'docs', '01-需求清单', 'USR-UserInfo') mkdirSync(reqDir, { recursive: true }) writeFileSync(join(root, 'docs', '01-需求清单', 'index.md'), '# 索引\n| a | b |\n') writeFileSync(join(reqDir, '_module.md'), '# 模块头\n') writeFileSync(join(reqDir, 'USR-UserInfo-Login.md'), '# 登录\n目标: 用户登录\n') writeFileSync(join(reqDir, 'USR-UserInfo-Logout.md'), '# 登出\n目标: 用户登出\n') writeFileSync(join(root, 'docs', '08-模块任务管理.md'), '## 一、Plan\n- [x] A0\n\n## 三、前端\n- 整体里程碑: —\n- 功能:\n - [ ] FE-01 登录页\n - [ ] FE-02 用户列表\n\n## 四、其它\n- [ ] FE-99 不该被收\n') return root } test('hashContent: CRLF 与行尾空白归一后哈希一致', () => { assert.equal(hashContent('a\nb\n'), hashContent('a\r\nb \r\n')) assert.equal(hashContent('a\nb'), hashContent(' a\nb \n\n')) assert.notEqual(hashContent('a\nb'), hashContent('a\nc')) }) test('collectReqUnits: 排除 _module.md 与 index.md,其余 == req_id', () => { const root = scaffold() try { const u = collectReqUnits(root) assert.deepEqual([...u.keys()].sort(), ['USR-UserInfo-Login', 'USR-UserInfo-Logout']) assert.equal(u.get('USR-UserInfo-Login').kind, 'req') } finally { rmSync(root, { recursive: true, force: true }) } }) test('collectFeUnits: 只收 §三 的 FE 行,遇下一个 ## 即止', () => { const root = scaffold() try { const u = collectFeUnits(root) assert.deepEqual([...u.keys()].sort(), ['FE-01', 'FE-02']) // FE-99 在 §四,不收 assert.equal(u.get('FE-01').kind, 'fe') } finally { rmSync(root, { recursive: true, force: true }) } }) test('computeUnits: req + fe 合并', () => { const root = scaffold() try { const u = computeUnits(root) assert.equal(u.size, 4) // 2 req + 2 fe } finally { rmSync(root, { recursive: true, force: true }) } }) test('collectProtoUnit: 无 prototype/ → 空;有 .html → 单一 __prototype__ 单元', () => { const root = scaffold() try { assert.equal(collectProtoUnit(root).size, 0) // scaffold 无 prototype/ const protoDir = join(root, 'prototype', 'pages') mkdirSync(protoDir, { recursive: true }) writeFileSync(join(root, 'prototype', 'index.html'), '

首页

') writeFileSync(join(protoDir, 'login.html'), '
登录
') const u = collectProtoUnit(root) assert.deepEqual([...u.keys()], [PROTO_UNIT_ID]) assert.equal(u.get(PROTO_UNIT_ID).kind, 'proto') } finally { rmSync(root, { recursive: true, force: true }) } }) test('原型内容改动被 diff 识别为 changed(FE 行文字不变也能检测到)', () => { const root = scaffold() try { writeFileSync(join(root, 'docs', '01-需求清单', 'index.md'), '# 索引\n') // 占位,无关 mkdirSync(join(root, 'prototype'), { recursive: true }) const proto = join(root, 'prototype', 'order.html') writeFileSync(proto, '
订单页 v1
') writeLedger(root, computeUnits(root)) // 建基线(含原型快照) // 只改原型内容,docs/08 §三 FE 行文字一字不动 writeFileSync(proto, '
订单页 v2 改了布局
') const diff = diffLedger(loadLedger(root), computeUnits(root)) assert.deepEqual(diff.changed.map((x) => x.id), [PROTO_UNIT_ID]) assert.equal(diff.changed[0].kind, 'proto') // 删除原型文件 → removed rmSync(proto) const diff2 = diffLedger(loadLedger(root), computeUnits(root)) assert.deepEqual(diff2.removed.map((x) => x.id), [PROTO_UNIT_ID]) } finally { rmSync(root, { recursive: true, force: true }) } }) test('diffLedger: 台账缺失 → 全部算 new', () => { const root = scaffold() try { const diff = diffLedger(null, computeUnits(root)) assert.equal(diff.new.length, 4) assert.equal(diff.changed.length, 0) } finally { rmSync(root, { recursive: true, force: true }) } }) test('writeLedger/loadLedger 往返 + diff 识别 新增/变更/删除/未变', () => { const root = scaffold() try { // 建基线 writeLedger(root, computeUnits(root)) let diff = diffLedger(loadLedger(root), computeUnits(root)) assert.equal(diff.new.length, 0) assert.equal(diff.changed.length, 0) assert.equal(diff.unchanged.length, 4) // 改一张卡 → changed;加一张卡 → new;删 FE-02 一行靠改 docs/08 → removed const reqDir = join(root, 'docs', '01-需求清单', 'USR-UserInfo') writeFileSync(join(reqDir, 'USR-UserInfo-Login.md'), '# 登录\n目标: 用户登录(改了规则)\n') writeFileSync(join(reqDir, 'USR-UserInfo-PwdReset.md'), '# 改密\n目标: 重置密码\n') writeFileSync(join(root, 'docs', '08-模块任务管理.md'), '## 一、Plan\n## 三、前端\n- 功能:\n - [ ] FE-01 登录页\n\n## 四、其它\n') diff = diffLedger(loadLedger(root), computeUnits(root)) assert.deepEqual(diff.new.map((x) => x.id), ['USR-UserInfo-PwdReset']) assert.deepEqual(diff.changed.map((x) => x.id), ['USR-UserInfo-Login']) assert.deepEqual(diff.removed.map((x) => x.id), ['FE-02']) } finally { rmSync(root, { recursive: true, force: true }) } })