req-ledger.test.mjs 5.7 KB
// 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-需求清单/<mod>/{_module.md,index.md,<req>.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'), '<h1>首页</h1>')
    writeFileSync(join(protoDir, 'login.html'), '<form>登录</form>')
    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, '<div>订单页 v1</div>')
    writeLedger(root, computeUnits(root)) // 建基线(含原型快照)

    // 只改原型内容,docs/08 §三 FE 行文字一字不动
    writeFileSync(proto, '<div>订单页 v2 改了布局</div>')
    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 }) }
})