// 汉·公卿年表的单测 —— 这不是「覆盖率」测试,这是【史学口径的可执行定义】。 // // 这张表的用户是全世界最难骗的那批人:他们会去翻《汉书》。 // 所以下面每一组断言,守的都不是「代码跑不跑得起来」,是「我们有没有说谎」: // · 引文里混进一个字的顏師古注 → 挂 // · 拿「五年遷」算出一个去职年份 → 挂 // · 给一个只剩一个字的人名安上姓 → 挂 // · 两个见证打架却硬给一个结论 → 挂 // · 把「东市」记到本卷账上 → 挂 // // 跑:node --test test/gongqing.test.js import test from 'node:test'; import assert from 'node:assert/strict'; import { RULERS, GONGQING, COVERAGE, SEATS, EVENTS, BIAO, CALENDAR_NOTE, SHIGU_NOTE, WITNESS_NOTE, PERSON_NOTE, eraAt, yearOfEra, gregorianYearOf, courtAt, fateOf, assertGongqingInvariants, } from '../src/gongqing.js'; // ── 0. 不变量:数据不许违反自己的规矩 ── test('assertGongqingInvariants 必须全绿 —— 规矩写在注释里没用,写成断言才算数', () => { const bad = assertGongqingInvariants(); assert.deepEqual(bad, [], '不变量被破坏:\n' + bad.join('\n')); }); // ── 1. 纯函数性:这是信誉的基础 ── test('确定性:courtAt / fateOf 跑两次必须完全相同', () => { for (const y of [-156, -154, -140, -119, -87]) { assert.deepEqual(courtAt(y), courtAt(y), `courtAt(${y}) 两次不一致`); } for (const n of ['朝錯', '田蚡', '公孫弘', '張湯']) { assert.deepEqual(fateOf(n), fateOf(n), `fateOf(${n}) 两次不一致`); } }); test('无输入 / 坏输入不许瞎编', () => { assert.equal(eraAt(-9999), null); assert.equal(eraAt('景帝三年'), null); assert.equal(courtAt(-9999), null); assert.equal(yearOfEra('乾隆', 3), null); assert.equal(yearOfEra('孝景', 99), null, '景帝没有九十九年,不许算出来'); assert.equal(fateOf(''), null); }); // ══════════════════════════════════════════════════════════════════════════ // 2. 纪年表 —— 「这正是我们缺的那张表」 // ══════════════════════════════════════════════════════════════════════════ test('纪年 → 公历:景帝三年 = 前154', () => { assert.equal(yearOfEra('孝景', 3), -154); const e = eraAt(-154); assert.equal(e.label, '孝景三年'); assert.equal(e.rulerName, '汉景帝'); assert.equal(e.n, 3); }); test('纪年 → 公历:武帝各年号首尾(与 ctext 该表自带的「公元前」栏交叉核对过)', () => { assert.equal(yearOfEra('建元', 1), -140); assert.equal(yearOfEra('元光', 1), -134); assert.equal(yearOfEra('元朔', 1), -128); assert.equal(yearOfEra('元狩', 1), -122); assert.equal(yearOfEra('元鼎', 1), -116); assert.equal(yearOfEra('元封', 1), -110); assert.equal(yearOfEra('太初', 1), -104); assert.equal(yearOfEra('天漢', 1), -100); assert.equal(yearOfEra('太始', 1), -96); assert.equal(yearOfEra('征和', 1), -92); assert.equal(yearOfEra('後元', 1), -88); assert.equal(yearOfEra('後元', 2), -87, '武帝崩于後元二年'); }); test('纪年表必须首尾相接、不重不漏 —— 中间漏一年,后面全错一格', () => { // 往返:每一年换算回去必须还是同一年 for (let y = -179; y <= -87; y++) { const e = eraAt(y); assert.ok(e, `前${-y} 年没有纪年 —— 表断了`); assert.equal(yearOfEra(e.eraKey, e.n), y, `前${-y} 往返不一致`); } }); test('★ 表原文【没有「前元」】—— 「景帝前元三年」是后人的叫法,不许复活', () => { const jing = RULERS.find((r) => r.id === 'jing'); const first = jing.eras[0]; assert.equal(first.name, null, '景帝头一段【无年号】'); assert.equal(first.raw, '孝景元年'); assert.ok(/不许复活/.test(first.nameNote)); // ⚠ 只扫【数据字段】,不扫注 —— 「不许复活「前元」」这句注里必然带「前元」二字。 // 禁烟的牌子上必须写着「吸烟」,禁令的文本不是违禁品(与 decisions.js 的 SERIAL_NOTE 同理)。 const dataFields = RULERS.flatMap((r) => [r.name, r.tableName, ...r.eras.flatMap((e) => [e.key, e.raw, e.name])]); assert.ok(!dataFields.filter(Boolean).some((s) => s.includes('前元')), 'RULERS 的数据字段里冒出了「前元」'); assert.equal(eraAt(-154).label, '孝景三年', '晁错死在孝景三年,不是「景帝前元三年」'); }); // ══════════════════════════════════════════════════════════════════════════ // 3. ★ 十月岁首 —— 「汉纪年与公历一一对应」不许复活 // ══════════════════════════════════════════════════════════════════════════ test('★ 太初改历前,十月/十一月/十二月的事,实际公历年要减一', () => { // 建元二年「十月丞相嬰免」:year 是 -139,可那个十月实际在前 140 年 const e = GONGQING.find((x) => x.era === '孝武建元二年' && x.event === '免' && x.person === '竇嬰'); assert.ok(e, '找不到窦婴免相那一笔'); assert.equal(e.year, -139); assert.equal(e.month, '十月'); const g = gregorianYearOf(e); assert.equal(g.year, -140, '★ 岁首在十月:窦婴免相实际在前 140 年,不是前 139 年'); assert.equal(g.adjusted, true); }); test('★ 同一格里十月排在三月前头 —— 这就是岁首在十月的活证据', () => { const rows = GONGQING.filter((x) => x.era === '孝武建元二年' && x.seat === 'chengxiang'); const shiyue = rows.find((r) => r.month === '十月'); const sanyue = rows.find((r) => r.month === '三月'); assert.ok(shiyue && sanyue); assert.ok( GONGQING.indexOf(shiyue) < GONGQING.indexOf(sanyue), '十月必须排在三月前 —— 表就是这么写的,因为年从十月起' ); }); test('太初改历(前104)之后,十月不再修正', () => { const after = { year: -103, month: '十月', era: '太初二年' }; assert.equal(gregorianYearOf(after).adjusted, false, '太初已改历,十月不再是岁首'); const before = { year: -105, month: '十月', era: '元封六年' }; assert.equal(gregorianYearOf(before).adjusted, true); assert.equal(CALENDAR_NOTE.taichuYear, -104); }); test('正月—九月的条目一律不修正', () => { for (const m of ['正月', '二月', '三月', '五月', '六月', '九月', null]) { assert.equal(gregorianYearOf({ year: -154, month: m }).adjusted, false, `${m} 不该被修正`); } }); test('★ year 的正负口径必须写死:-154 = 公元前154,不是天文学年号', () => { assert.ok(/不是.*天文学年号/.test(CALENDAR_NOTE.yearSign.replace(/【|】/g, ''))); }); // ══════════════════════════════════════════════════════════════════════════ // 4. ★★ 样板年:孝景三年(前154)—— 「同僚图谱是决策点的生成器」 // ══════════════════════════════════════════════════════════════════════════ test('★★ courtAt(-154):那一年,丞相是陶青、太尉是周亚夫、御史大夫是晁错', () => { const c = courtAt(-154); assert.equal(c.era.label, '孝景三年'); assert.equal(c.ruler.name, '汉景帝'); // 丞相陶青 —— 他是【景帝二年】拜的,那一年他没动,所以景帝三年那一行里【没有他】。 // 这一条就是「记事链不是花名册」的证明:查表查不到,只有重放才拿得到。 assert.equal(c.chengxiang.holders.length, 1); assert.equal(c.chengxiang.holders[0].person, '陶青'); assert.equal(c.chengxiang.holders[0].since, -155, '陶青是景帝二年八月丁未拜的'); // 太尉周亚夫 —— 空了二十三年的位子,七国反的这一年才重开 assert.equal(c.taiwei.holders[0].person, '周亞夫'); assert.equal(c.taiwei.holders[0].since, -154); // ★★ 御史大夫晁错 —— 哪怕他正月壬子就要斩了,那一年他【在场】。 // 这正是 holders 的口径:给的是「那一年在过这个位子上的人」,不是「年底还剩谁」。 // 按年底算,景帝三年的御史大夫就成了「无」—— 那这个功能最想说的那句话就没了。 assert.equal(c.yushi.holders[0].person, '朝錯'); assert.equal(c.yushi.holders[0].since, -155); assert.equal(c.yushi.holders[0].left.event, '要斬', '他就是在这一年没的'); assert.equal(c.yushi.holders[0].left.month, '正月'); }); test('★★ 而那一年正月壬子,晁错要斩 —— 而你在场', () => { const f = fateOf('朝錯'); assert.equal(f.found, true); assert.equal(f.end.word, '要斬'); assert.equal(f.end.year, -154); assert.equal(f.end.era, '孝景三年'); assert.equal(f.end.month, '正月'); assert.equal(f.end.day, '壬子'); assert.equal(f.end.office, '御史大夫'); assert.equal(f.end.quote, '正月壬子,錯有罪要斬。'); assert.equal(f.end.yearReliable, true, '这一笔年份是硬的:表自己给了年月日'); }); test('★ 晁错三种写法都认得(朝錯/晁錯/鼂錯)—— 一个本子里就有三种', () => { const a = fateOf('朝錯'), b = fateOf('晁錯'), c = fateOf('鼂錯'); for (const f of [a, b, c]) { assert.equal(f.found, true); assert.equal(f.end.word, '要斬'); } assert.equal(a.person, '朝錯'); assert.ok(a.variants.includes('晁錯') && a.variants.includes('鼂錯')); }); test('★★ 景帝三年那一行,同僚自己就把关口生出来了', () => { const c = courtAt(-154); const names = [ ...c.chengxiang.holders, ...c.taiwei.holders, ...c.yushi.holders, ...c.jiangjun, ...c.others, ].map((x) => x.person).filter(Boolean); // 削藩的、腰斩的、劝斩他的、领兵平叛的、后来做到丞相的 —— 全在这一年 for (const who of ['陶青', '周亞夫', '朝錯', '竇嬰', '袁盎', '衞綰']) { assert.ok(names.includes(who), `景帝三年少了 ${who} —— 这一年的图谱不全,关口就生不出来`); } }); test('★ 故吴相袁盎 —— 吴反的那一年,他从反王的相变成朝廷的奉常', () => { const e = GONGQING.find((x) => x.person === '袁盎'); assert.equal(e.year, -154); assert.equal(e.office, '奉常'); assert.equal(e.from, '故吳相'); assert.equal(e.quote, '故吳相袁盎為奉常。'); }); test('★ 窦婴那一笔挂着卷四十九的旁证:表记谁升了,传记为什么升', () => { const e = GONGQING.find((x) => x.person === '竇嬰' && x.event === '拜' && x.year === -154); assert.equal(e.office, '大將軍'); assert.ok(e.corroboration, '缺旁证'); assert.ok(/卷四十九/.test(e.corroboration.source)); assert.ok(/獨急斬錯以謝吳/.test(e.corroboration.quote), '这一句才是关口的由来'); assert.ok(/旁证不是本表|属卷四十九/.test(e.corroboration.caveat), '旁证必须标明它不是本卷'); }); // ══════════════════════════════════════════════════════════════════════════ // 5. ★★ 史学铁律 —— 这几组挂了,这个功能就得下线 // ══════════════════════════════════════════════════════════════════════════ test('★★【东市不在这一卷】:本表关于晁错之死只有九个字', () => { const e = GONGQING.find((x) => x.event === '要斬' && x.year === -154); assert.equal(e.quote, '正月壬子,錯有罪要斬。'); assert.ok(!e.quote.includes('東'), '「东市」出卷四十九,不许记到本卷账上'); assert.ok(!e.quote.includes('朝衣')); assert.ok(!e.quote.includes('七國'), '「七国反」也不在这一卷'); assert.ok(/卷四十九/.test(e.caveat), 'caveat 必须说清楚东市出自哪一卷'); }); test('★★【表作「要斬」不作「腰斬」】—— 照录原字,不许径改', () => { const yao = GONGQING.filter((x) => x.event === '要斬'); assert.equal(yao.length, 2, '本表只有两个人拿到这两个字:晁错、刘屈氂'); for (const e of yao) { assert.ok(e.quote.includes('要斬'), '照录原字'); assert.ok(!e.quote.includes('腰斬'), '不许把「要」改成「腰」'); } assert.deepEqual(yao.map((e) => e.person), ['朝錯', '劉屈氂']); assert.equal(yao[1].year, -90, '刘屈氂:征和三年'); }); test('★★【引文里一个字的后人注都不许有】—— 顏師古是唐人,班固是东汉人', () => { const notes = ['師古曰', '應劭曰', '如淳曰', '晉灼曰', '臣瓚曰', '張晏曰', '文穎曰', '鄧展曰', '孟康曰']; for (const e of GONGQING) { for (const w of notes) { assert.ok(!String(e.quote).includes(w), `${e.era}/${e.office} 的 quote 混进了「${w}」`); if (e.corroboration) { assert.ok(!String(e.corroboration.quote).includes(w), `${e.era} 的旁证 quote 混进了「${w}」`); } } } }); test('★★【「N年遷」不许算出年份】—— 实测三个样本,两种读法互斥', () => { // 周亚夫:前154 拜太尉,「五年遷」,实际前150 迁丞相 → 含拜年才对 const ya = GONGQING.find((x) => x.person === '周亞夫' && x.office === '太尉' && x.event === '拜'); assert.equal(ya.year, -154); assert.ok(ya.quote.includes('五年遷')); const yaCheng = GONGQING.find((x) => x.person === '周亞夫' && x.office === '丞相' && x.event === '拜'); assert.equal(yaCheng.year, -150, '前154 算第 1 年 → 前150 是第 5 年(含拜年)'); // 陶青:前162 拜御史大夫,「七年遷」,实际前155 迁丞相 → 不含拜年才对 const tao = GONGQING.find((x) => x.person === '陶青' && x.office === '御史大夫'); assert.equal(tao.year, -162); assert.ok(tao.quote.includes('七年遷')); const taoCheng = GONGQING.find((x) => x.person === '陶青' && x.office === '丞相' && x.event === '拜'); assert.equal(taoCheng.year, -155, '前162 + 7 = 前155(不含拜年)'); // ★ 两条互斥 —— 所以一律不算 assert.notEqual( -154 + 5, -150, '若「N年」= 拜年 + N,周亚夫该在前149 迁 —— 可他在前150。这就是不能算的理由' ); assert.equal(-162 + 7, -155, '而陶青偏偏就是拜年 + N。两种读法互斥。'); }); test('★★ 张汤:下场写在拜官句尾 → fateOf 给结局,但 year 必须是 null', () => { const f = fateOf('張湯'); assert.equal(f.found, true); assert.equal(f.end.word, '六年有罪自殺'); assert.equal(f.end.year, null, '★「六年」是那个不可计算的记法 —— 算出一个年份就是撒谎'); assert.equal(f.end.yearReliable, false); assert.ok(/算不出来/.test(f.end.yearNote)); // 拜官那一笔的年份是硬的 const bai = f.entries.find((e) => e.event === '拜' && e.office === '御史大夫'); assert.equal(bai.year, -120, '元狩三年三月壬辰,廷尉张汤为御史大夫'); }); test('★★ 直不疑:两个见证打架(三年死/三年免)→ 不裁,也不给 fate', () => { const f = fateOf('直不疑'); assert.equal(f.found, true); assert.equal(f.end, null, '★ 两个见证给的是两回事,硬给一个结局就是撒谎'); assert.equal(f.unresolved.length, 1); assert.equal(f.unresolved[0].field, 'fate'); assert.ok(/草庐/.test(f.unresolved[0].witnessA) && /ctext/.test(f.unresolved[0].witnessB)); // quote 必须截在两边一致的地方 const e = GONGQING.find((x) => x.person === '直不疑'); assert.equal(e.quote, '八月壬辰,衞尉直不疑為御史大夫。'); assert.ok(!e.quote.includes('三年'), '有分歧的四个字不许进 quote'); }); test('★★ 霍光:月份异文(三月/二月)→ month 必须是 null,两说并陈', () => { const e = GONGQING.find((x) => x.person === '霍光'); assert.equal(e.year, -87); assert.equal(e.month, null, '★ 两个见证一个三月一个二月 —— 不许替读者选'); assert.equal(e.day, '丁卯'); assert.equal(e.variantUnresolved.field, 'month'); assert.ok(/本项目的推算/.test(e.caveat), '推算必须标明是我们的推算,不是史料的裁断'); }); test('★★【姓名不具的一律 null】—— 顏師古自己说这是史之阙文', () => { for (const e of GONGQING) { if (e.name && e.name.length === 1 && !e.from) { // 单字且没有「记事链」把他串起来的 if (['殷', '勝', '介'].includes(e.name)) { assert.equal(e.person, null, `「${e.office}${e.name}」只剩一个字,不许替它猜姓`); } } } // 接晁错位子的那个人,表只留下一个字 const jie = GONGQING.find((x) => x.name === '介'); assert.equal(jie.office, '御史大夫'); assert.equal(jie.year, -153); assert.equal(jie.person, null); assert.ok(/姓名不具/.test(jie.caveat)); assert.ok(/闕文|阙文/.test(SHIGU_NOTE.quote + SHIGU_NOTE.why)); }); test('★★【同名不并人】:元光四年同一年两个「張敺」→ person 必须置 null', () => { const e = GONGQING.find((x) => x.name === '張敺'); assert.equal(e.year, -131); assert.equal(e.office, '御史大夫'); assert.equal(e.person, null, '★ 同一年另有「宣平侯張敺為太常」,封邑不同,显非一人'); assert.equal(e.personUncertain, true); assert.ok(/宣平侯/.test(e.caveat)); assert.ok(/同名不并人/.test(PERSON_NOTE.homonym)); }); test('★ 记事链解出来的名字是可以给 person 的 —— 那不是猜', () => { // 「六月丞相嘉薨」的「嘉」= 申屠嘉,由同表孝文後元二年那一笔解出 const e = GONGQING.find((x) => x.name === '嘉' && x.event === '薨'); assert.equal(e.person, '申屠嘉'); const seed = GONGQING.find((x) => x.person === '申屠嘉' && x.event === '拜'); assert.equal(seed.year, -162); assert.equal(seed.seed, true); assert.ok(/申屠嘉/.test(e.caveat), '必须说清楚这个名字是哪儿解出来的'); }); test('★★ 引文标点必须声明为本项目自断 —— 见证一致恰恰是不能照抄的理由', () => { assert.ok(/标点为本项目自断/.test(WITNESS_NOTE.punctuation)); assert.ok(/受著作权保护/.test(WITNESS_NOTE.punctuation)); assert.ok(/两个见证的标点【完全一致】/.test(WITNESS_NOTE.punctuation)); }); test('★ ctext 的坏字判定必须有内证,不许「我觉得草庐对」', () => { assert.ok(/简体/.test(WITNESS_NOTE.ctextFlaws), '判 ctext 之误的依据是【坏字是简体】这个指纹'); const xue = GONGQING.find((x) => x.person === '薛澤' && x.event === '拜'); assert.ok(/平【觉】侯|平觉侯/.test(xue.caveat)); assert.ok(xue.quote.includes('平棘侯')); }); // ══════════════════════════════════════════════════════════════════════════ // 6. ★ 太尉:「太尉是谁(或:省)」—— 官制的开合就是战事的影子 // ══════════════════════════════════════════════════════════════════════════ test('★★ 景帝元年:太尉是【省】—— 这个官那时候根本不存在', () => { const c = courtAt(-156); assert.equal(c.taiwei.status, '省'); assert.equal(c.taiwei.holders.length, 0); assert.equal(c.taiwei.abolished[0].office, '太尉'); assert.equal(c.chengxiang.holders[0].person, '申屠嘉', '而丞相是文帝朝拜的 —— 接续记事就是为这个'); assert.equal(c.yushi.holders[0].person, '陶青'); }); test('★★ 七国反的那一年,空了二十三年的太尉重开', () => { assert.equal(courtAt(-155).taiwei.status, '省', '景帝二年还是省'); assert.equal(courtAt(-154).taiwei.holders[0].person, '周亞夫', '景帝三年重开'); assert.equal(courtAt(-151).taiwei.holders[0].person, '周亞夫', '中间几年他还在'); assert.equal(-154 - -177, 23, '文帝三年官省 → 景帝三年重开,二十三年'); }); test('★ 周亚夫迁丞相 → 太尉又省;田蚡开一年 → 又省', () => { // ★ 景帝七年的丞相【是两个人】:陶青(六月乙巳免)和周亚夫(继任)。 // holders 给的是「那一年在过这个位子上的人」,所以两个都在 —— 这才是实情。 const c150 = courtAt(-150); assert.deepEqual(c150.chengxiang.holders.map((h) => h.person), ['陶青', '周亞夫']); assert.equal(c150.chengxiang.holders[0].left.event, '免'); assert.equal(c150.chengxiang.holders[1].left, null, '周亚夫这一年拜相,还没走'); assert.equal( c150.taiwei.holders[0].person, '周亞夫', '★ 他迁丞相的那一年,太尉这个位子上【也还是他】—— 年内先后不可知,两边都算他在' ); assert.equal(courtAt(-149).taiwei.status, '省', '次年就省了'); assert.equal(courtAt(-140).taiwei.holders[0].person, '田蚡', '建元元年为田蚡一个人又开了一次'); assert.equal(courtAt(-139).taiwei.holders[0].person, '田蚡', '免于建元二年 —— 那一年他还在'); assert.equal(courtAt(-138).taiwei.status, '省', '此后太尉这个官就没再有过'); }); // ★★ 这一条是先写测试才逼出来的:原先把「官省」记在【栏】上, // 于是卫青一拜大司马,太尉的「省」就被顶掉了 —— courtAt(前110) 会说「太尉栏:不载」, // 可太尉明明是【裁了】,而那一栏坐着大司马。裁的是官,不是栏。 test('★★【官省是「那个官」裁了,不是「那一栏」空了】—— 太尉裁着,大司马照样坐在同一栏里', () => { const later = GONGQING.filter((x) => x.office === '太尉' && x.event === '拜' && x.year > -139); assert.equal(later.length, 0, '★ 太尉这个官,建元二年之后本表一笔都没有了'); const c = courtAt(-110); assert.equal(c.taiwei.holders[0].person, '衞青'); assert.equal(c.taiwei.holders[0].office, '大司馬大將軍'); assert.equal(c.taiwei.status, 'held'); assert.equal(c.taiwei.abolished[0].office, '太尉', '★ 太尉照样是裁着的 —— 裁的是官,不是栏'); assert.equal(c.taiwei.abolished[0].since, -139); }); test('★ 卫青死后(前105—前88),那一栏是真的空了', () => { for (const y of [-105, -100, -95, -90, -88]) { assert.equal(courtAt(y).taiwei.status, '省', `前${-y} 年那一栏该是空的:太尉裁着,大司马也没人`); assert.equal(courtAt(y).taiwei.holders.length, 0); } assert.equal(courtAt(-87).taiwei.holders[0].person, '霍光', '到武帝断气那一年才又坐上人'); assert.equal(-87 - -139, 52, '建元二年太尉官省 → 後元二年霍光拜大司马大将军,五十二年'); }); test('★ 推断出来的年份必须标出来,不许冒充原文', () => { const inferred = GONGQING.filter((x) => x.yearInferred); assert.equal(inferred.length, 2, '灌婴、周亚夫那两条「官省」的年份是推断的'); for (const e of inferred) { assert.equal(e.event, '官省'); assert.ok(/推断/.test(e.caveat), '推断必须说是推断'); } // 建元二年那条「官省」原文自带年份 → 不许标 inferred const tian = GONGQING.find((x) => x.event === '官省' && x.year === -139); assert.ok(!tian.yearInferred, '这一条原文就写在建元二年那一格里,不是推断'); // courtAt 必须把 inferred 透出去 —— 推断出来的东西不许在下游变成事实 assert.equal(courtAt(-156).taiwei.abolished[0].inferred, true, '景帝元年太尉之省,年份是推断的'); assert.equal(courtAt(-138).taiwei.abolished[0].inferred, false, '建元二年那条原文自带年份'); }); test('★ 元狩四年:太尉那一栏头一回同时坐了两个人(卫青、霍去病)', () => { const two = GONGQING.filter((x) => x.year === -119 && x.seat === 'taiwei'); assert.equal(two.length, 2); assert.deepEqual(two.map((x) => x.person), ['衞青', '霍去病']); assert.deepEqual(two.map((x) => x.office), ['大司馬大將軍', '大司馬票騎將軍']); }); test('★ 大司马放在太尉栏是【原表的编排】,本文件不表态', () => { const seat = SEATS.find((s) => s.id === 'taiwei'); assert.ok(/原表的编排/.test(seat.note)); assert.ok(/不是本文件对二者关系的判断/.test(seat.note)); }); // ══════════════════════════════════════════════════════════════════════════ // 7. ★ 覆盖范围:不许自吹 // ══════════════════════════════════════════════════════════════════════════ test('★★ 三公全覆盖 —— 前156—前87 每一年都得有丞相(或明说不载)', () => { for (let y = -156; y <= -87; y++) { const c = courtAt(y); assert.ok( c.chengxiang.holders.length > 0 || c.chengxiang.status !== 'held', `前${-y} 年丞相栏是空的 —— 既没人也没说不载` ); assert.equal(c.coverage.sangong, true, `前${-y} 年该在覆盖范围内`); } }); test('★★ 列将军【不许当名单】—— 表自己就阙:卫青拜大将军这一笔根本没有', () => { assert.equal(COVERAGE.jiangjun.complete, false); const bai = GONGQING.find((x) => x.person === '衞青' && x.office === '大將軍' && x.event === '拜'); assert.equal(bai, undefined, '★ 表里真的没有这一笔 —— 一个大将军凭空出现在他升迁的那一格里'); const sima = GONGQING.find((x) => x.person === '衞青' && x.event === '拜'); assert.equal(sima.year, -119); assert.ok(sima.quote.includes('大將軍衞青'), '这一格管他叫大将军,可他是哪年当上的,表没说'); assert.ok(/表阙/.test(sima.caveat)); assert.equal(courtAt(-119).coverage.jiangjun, false); }); test('★★ 九卿【不许当全名单】—— courtAt 必须自己说这话', () => { assert.equal(COVERAGE.jiuqing.complete, false); const c = courtAt(-120); assert.equal(c.coverage.others, false); assert.ok(/绝不许/.test(c.othersNote), 'courtAt 必须把「others 不是全名单」透给调用方'); assert.ok(/只抠了两处/.test(COVERAGE.jiuqing.note)); }); test('★ 覆盖范围外的年份,courtAt 必须自己承认', () => { const c = courtAt(-170); // 文帝朝 assert.equal(c.coverage.inRange, false); assert.equal(c.coverage.sangong, false); assert.ok(/不在覆盖范围/.test(c.coverage.note)); assert.ok(/不许当数/.test(c.coverage.note)); }); test('★ 覆盖范围外的条目必须标 seed,不许混进来冒充', () => { for (const e of GONGQING) { const inRange = e.year >= COVERAGE.years.from && e.year <= COVERAGE.years.to; if (!inRange) assert.equal(e.seed, true, `${e.era}/${e.office} 落在范围外却没标 seed`); } assert.equal(GONGQING.filter((x) => x.seed).length, 5, '接续记事:灌婴×3、申屠嘉、陶青'); }); test('★ 年内先后不可知,courtAt 必须说这话 —— 不许拿它排「谁先谁后」', () => { const c = courtAt(-154); assert.ok(/不给月份/.test(c.withinYearNote)); assert.ok(/不许拿 holders 的顺序排先后/.test(c.withinYearNote)); }); // ★★ 这一条守的是 courtAt 最要紧的那个前提:重放需要链是完整的。 // 链不完整的栏若也照着接,courtAt 会一本正经地编 —— 那比没有数据坏得多。 test('★★ 链不完整的栏【不许接】—— 否则 courtAt(前100) 会说「大将军是窦婴」', () => { // 窦婴景帝三年拜大将军,表【从没记过】他何时去此职 const dou = GONGQING.find((x) => x.seat === 'jiangjun' && x.person === '竇嬰'); assert.equal(dou.year, -154); const left = GONGQING.find((x) => x.seat === 'jiangjun' && x.person === '竇嬰' && x.event !== '拜'); assert.equal(left, undefined, '表真的没记他何时不当大将军了'); // 若照三公那样接下来,他会一直「是」大将军 —— 必须不接 assert.deepEqual(courtAt(-154).jiangjun.map((x) => x.person), ['竇嬰'], '那一年表记了他,报'); assert.deepEqual(courtAt(-153).jiangjun, [], '★ 次年表没记,就不报 —— 不许接'); assert.deepEqual(courtAt(-100).jiangjun, [], '★★ 接下来就成了「前100年大将军是窦婴」—— 那是编的'); // 九卿同理 assert.deepEqual(courtAt(-154).others.map((x) => x.person).filter(Boolean), ['袁盎', '劉通', '衞綰']); assert.deepEqual(courtAt(-153).others, [], '★ 九卿只抠了两处,接出来的是假的'); assert.ok(/链不完整的栏,接出来的是编的/.test(courtAt(-154).coverage.carryForward)); }); // ══════════════════════════════════════════════════════════════════════════ // 8. ★ fateOf:「这个人后来怎么了」 // ══════════════════════════════════════════════════════════════════════════ test('★ 表给的字分得清清楚楚,不许合并成「离任」', () => { const cases = [ ['田蚡', '薨', -131], // 死在丞相任上 ['公孫弘', '薨', -121], // 死在丞相任上 ['李蔡', '自殺', -118], // 有罪自杀 ['嚴青翟', '自殺', -115], // 有罪自杀 ['趙周', '下獄死', -112], // 下狱死 ['公孫賀', '下獄死', -91], // 下狱死 ['劉屈氂', '要斬', -90], // 腰斩 ['霍去病', '薨', -117], ['衞青', '薨', -106], ]; for (const [who, word, year] of cases) { const f = fateOf(who); assert.equal(f.found, true, `${who} 不在表里`); assert.equal(f.end.word, word, `${who} 的下场该是「${word}」`); assert.equal(f.end.year, year, `${who} 的年份不对`); } }); test('★★ 窦婴:免相之后表里一个字都没有 —— 不载 ≠ 善终', () => { const f = fateOf('竇嬰'); assert.equal(f.found, true); assert.equal(f.end.word, '免', '★ 表只记到「免」为止'); assert.equal(f.end.year, -139); // 建元二年之后他再没进过这张表 const after = f.entries.filter((e) => e.year > -139); assert.equal(after.length, 0, '★ 公卿年表只记公卿:你不在那个位子上,你的死不归它管'); const tian = GONGQING.find((x) => x.person === '田蚡' && x.event === '薨'); assert.ok(/窦婴怎么完的.*一个字都没有/.test(tian.caveat.replace(/【|】/g, ''))); }); test('★ 周亚夫:表只记到「免」—— 别替表往下写', () => { const f = fateOf('周亞夫'); assert.equal(f.end.word, '免'); assert.equal(f.end.year, -147); assert.ok(/别替表往下写/.test(f.entries.find((e) => e.event === '免').caveat)); }); test('★ 公孙弘:博士 → 左内史 → 御史大夫 → 丞相,一条完整的线', () => { const f = fateOf('公孫弘'); const line = f.entries.filter((e) => e.event === '拜').map((e) => [e.year, e.office]); assert.deepEqual(line, [[-130, '左內史'], [-126, '御史大夫'], [-124, '丞相']]); assert.equal(f.entries.find((e) => e.event === '拜' && e.office === '左內史').from, '博士'); assert.equal(f.end.word, '薨', '死在丞相任上'); }); test('★ 表里没有的人,fateOf 必须说「表里没有」,不许编', () => { const f = fateOf('司馬遷'); assert.equal(f.found, false); assert.deepEqual(f.entries, []); assert.ok(/不载 ≠ 没这个人/.test(f.note)); }); // ══════════════════════════════════════════════════════════════════════════ // 9. 结构:出处、见证 // ══════════════════════════════════════════════════════════════════════════ test('每一条都必须有引文和出处 —— 正文是推演,注是原文', () => { for (const e of GONGQING) { assert.ok(e.quote && e.quote.length > 0, `${e.era}/${e.office} 缺引文`); assert.equal(e.source, BIAO, `${e.era}/${e.office} 的 source 不是本卷`); assert.ok([1, 2].includes(e.witnesses)); } }); test('只有一个见证的必须自己说出来', () => { const single = GONGQING.filter((e) => e.witnesses === 1); for (const e of single) { assert.ok(e.caveat, `${e.era}/${e.office} 只有一个见证却没有 caveat`); assert.ok(/见证|异文|一致/.test(e.caveat), `${e.era} 的 caveat 得交代见证情况`); } assert.equal(single.length, 1, '只有晁错起家那一格两个见证的用字不一致'); }); test('顏師古注是【注】,必须标明,且是单证 —— 不装作有两个见证', () => { assert.ok(/是注不是正文/.test(SHIGU_NOTE.caveat)); assert.ok(/单证/.test(SHIGU_NOTE.caveat)); assert.ok(/唐人/.test(SHIGU_NOTE.caveat)); }); test('event 只许用表自己的字', () => { const words = new Set(EVENTS.map((e) => e.word)); for (const e of GONGQING) assert.ok(words.has(e.event), `${e.era} 用了表外的词「${e.event}」`); for (const bad of ['离任', '任命', '去世', '解雇']) { assert.ok(!words.has(bad), `「${bad}」是现代词,不许进 EVENTS`); } }); test('GONGQING 必须按年升序 —— courtAt 靠它重放', () => { for (let i = 1; i < GONGQING.length; i++) { assert.ok(GONGQING[i].year >= GONGQING[i - 1].year, `第 ${i} 条乱序`); } });