Files
digital-me-history/digital-me-backend/test/shiliao.test.js
T
admin 0593ff7e62
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
数字我 Digital Me:全量入库(大文件走 Git LFS)
2026-10-10 16:24:43 +08:00

466 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 史料表的单测 —— 这不是覆盖率测试,这是【史学纪律的可执行定义】。
//
// 这张表的用户是全世界最难骗的那批人,一条错就全毁。所以下面的断言不测「功能能跑」,
// 测的是「有没有人在这张表里编东西」:
// · 有引文必须有出处
// · 原文没写的秩石不许补
// · 西汉的秩级不许出现在东汉条目里(← 本产品踩过的坑,这是回归测试)
//
// ══════════════════════════════════════════════════════════════════════════
// 2026-07-16:本表【已解封】。封条摘除的依据是 curl 生 HTML + 两个独立见证逐字比对。
//
// ⚠ 本轮新增的断言,守的是一类【新的】风险,与「有没有人编东西」同样要命:
// 【把一个见证的读法当成「原文」写死】。
// 本表上一版就栽在这里——它用一个见证读不出「初」字,就断言「原文无『初』字,
// 前一轮误增,已订正」,还把这句话以「已订正」的口气写进了「不许复活」清单。
// 一个错误一旦被写成「已订正」,下一轮就没人敢碰它了。
// → 故新增:凡两个见证读法不同的条目,【必须】有 variant 字段且 confidence='需核实'。
//
// ⚠ 另一类:【假装注不存在】。上一版说「御史大夫中二千石本表无据」「真二千石本表未出现」——
// 两句都不准确:那些字本卷【确实有】,只是在臣瓚注引《茂陵書》里,不在班固正文里。
// 说「无据」比说「注有正文无」危险得多:读者一翻就翻到,本表的可信度当场归零。
// → 故新增:这两条的注里必须【主动交代注的存在】,不许只说「没有」。
// ══════════════════════════════════════════════════════════════════════════
//
// 跑:node --test test/shiliao.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
OFFICES, RANK_NAMES, findOffice, officesByRank, citeOffice, listOffices,
} from '../src/shiliao.js';
// ── 1. 表的完整性:每条都得挂得住 ──
test('每条 OFFICES 都必须有 source 和 era', () => {
for (const o of OFFICES) {
assert.ok(o.source, `「${o.name}」缺 source —— 没出处的条目不许进表`);
assert.ok(o.era, `「${o.name}」缺 era`);
assert.ok(['qin', 'xihan', 'donghan'].includes(o.era), `「${o.name}」的 era 非法:${o.era}`);
}
});
test('有 quote 的必须有 source', () => {
for (const o of OFFICES) {
if (o.quote) assert.ok(o.source, `「${o.name}」有引文却无出处 —— 这正是「编的」的样子`);
if (o.quoteRank) assert.ok(o.source, `「${o.name}」有秩石引文却无出处`);
}
});
test('每条都必须有 confidence,且只能是两个值之一', () => {
for (const o of OFFICES) {
assert.ok(['原文明载', '需核实'].includes(o.confidence), `「${o.name}」的 confidence 非法:${o.confidence}`);
}
});
test('id 不许重复(重复会让 findOffice 静默只返回第一条)', () => {
const ids = OFFICES.map((o) => o.id);
assert.equal(new Set(ids).size, ids.length, `id 有重复:${ids.filter((x, i) => ids.indexOf(x) !== i)}`);
});
// ── 2. ⚠ 回归测试:西汉的县令秩级不许安到东汉头上 ──
// 这是本产品实打实栽过的一次:把《汉书·百官公卿表》的西汉秩级当成了东汉制。
// 东汉县令在《续汉书·百官志五》,作三等定值(千石/四百石/三百石),与本表的区间制不是一回事。
test('回归·本表全部是西汉制,一条 donghan 都不许有', () => {
const bad = OFFICES.filter((o) => o.era === 'donghan');
assert.deepEqual(
bad.map((o) => o.name), [],
'《汉书·百官公卿表上》记的是【西汉】制。这张表里出现 donghan 条目,' +
'说明有人又把西汉秩级安到东汉头上了 —— 这个坑本产品踩过一次'
);
});
test('回归·西汉的县令/县长秩级,绝不许出现在 era=donghan 的条目里', () => {
// 这两个秩级是《汉书·百官公卿表上》的西汉写法,东汉制另有其文,两者不可合并
const XIHAN_XIAN_RANKS = ['千石至六百石', '五百石至三百石'];
for (const o of OFFICES) {
if (o.era !== 'donghan') continue;
assert.ok(
!XIHAN_XIAN_RANKS.includes(o.rank),
`「${o.name}」是 donghan 条目却用了西汉县令/县长的秩级「${o.rank}」—— ` +
'这正是本产品踩过的那个坑:东汉制在《续汉书·百官志五》,作三等定值,不是区间制'
);
}
// 并且这两个秩级必须确实存在于西汉条目里(防止把断言写成空转)
assert.equal(findOffice('xihan', '县令').rank, '千石至六百石');
assert.equal(findOffice('xihan', '县长').rank, '五百石至三百石');
assert.equal(findOffice('donghan', '县令'), null, '本表不该有东汉县令');
});
test('回归·县令/县长条必须自带「这是西汉制」的限定语', () => {
for (const name of ['县令', '县长']) {
const c = citeOffice('xihan', name);
assert.match(c.caveat, /西汉/, `「${name}」的注里必须写明这是西汉制`);
assert.match(c.caveat, /续汉书|續漢書/, `「${name}」的注里必须点出东汉制另有其文`);
}
});
// ── 3. ⚠ 原文没写的,不许补 ──
test('本表不记秩石的官,rank 必须是空 —— 留空是事实,不是漏抠', () => {
// 本表对这几个只记印绶(金印紫綬/銀印青綬),不记秩石。已经掺假核查验证过。
for (const name of ['丞相', '太尉', '御史大夫', '大司马', '大司空']) {
const o = findOffice('xihan', name);
assert.ok(o, `找不到「${name}」`);
assert.equal(o.rank, '', `「${name}」被补了秩石「${o.rank}」—— 本表没写,不许补`);
}
});
test('⚠「御史大夫中二千石」不进 rank —— 但注里必须交代它在臣瓚注里确实有', () => {
const o = findOffice('xihan', '御史大夫');
assert.equal(o.rank, '', '班固正文只记「位上卿、銀印青綬」,注里的秩石不进 rank');
// 注里必须主动把这个常见错误点出来,否则用户还是会自己脑补
assert.match(o.caveat, /中二千石/, '御史大夫的注必须主动处理「中二千石」这个常见说法');
const midRank = officesByRank('xihan', '中二千石');
assert.ok(
!midRank.some((x) => x.id === 'yushi_dafu'),
'御史大夫不在「自太常至執金吾」的范围内(它排在太常之前),不许算进中二千石'
);
// ★ 2026-07-16 curl 目验的新发现:本卷【臣瓚注引《茂陵書》】确有「御史大夫秩中二千石」。
// 上一版说「本表无据」——错。禁令(不进 rank)不变,理由必须换成「注非正文」。
// 注里【不许】只说「没有」:读者一翻本卷就能翻到那四个字,说「无据」等于把本表的
// 可信度赔进去。必须主动交代「注有、正文无、本表只收正文」。
assert.match(
o.caveat, /茂陵書|茂陵书/,
'御史大夫的注必须交代「中二千石」出自臣瓚注引《茂陵書》—— ' +
'不许再说「本表无据」,本卷一翻就能翻到那四个字'
);
assert.match(o.caveat, /正文/, '必须说清楚是「正文无、注有」,而不是笼统的「没有」');
// ⚠ 这里【不能】写「assert 注里不含『本表无据』四字」——本仓的「不许复活」体例要求
// 把错误说法【原样抄下来再打叉】,所以那四个字必然出现在注里,只是出现在禁令里。
// substring 禁令与「不许复活」体例天然冲突:想禁一句话,就得先写下这句话。
// → 故这里只做【正向】断言(必须交代茂陵書、必须区分正文与注),把关靠上面三条。
});
test('⚠「真二千石」不进 rank —— 但它在臣瓚注里出现过一次,不许说成「本表没有」', () => {
// 这两条断言是对的,本轮 curl 目验后仍然成立:班固【正文】确实没有「真二千石」
assert.deepEqual(officesByRank('xihan', '真二千石'), []);
assert.ok(!RANK_NAMES.includes('真二千石'), '本表 rank 只收正文,正文无「真二千石」');
// ★ 但上一版那句「真二千石在本表未出现」是【不准确】的:
// 本卷詹事条下有「臣瓚曰:茂陵書詹事秩真二千石」。只收正文的见证 B 里没有这句,可证是注。
// 本表不为詹事立条,故 rank 里没有它是对的;错的是那个【说法】。
// 这条断言守着「说法」:文件头必须把注里的那次出现交代清楚。
const src = readFileSync(new URL('../src/shiliao.js', import.meta.url), 'utf8');
assert.match(
src, /茂陵書詹事秩真二千石/,
'文件头必须交代「真二千石」在本卷臣瓚注引《茂陵書》里出现过一次 —— ' +
'只说「本表未出现」会让人以为翻遍本卷都没有,而一翻就能翻到'
);
assert.match(
src, /詹事/,
'必须点明那一次出现在【詹事】条下 —— 出处要具体到能被复核,否则又是一句要人相信的话'
);
// ⚠ 同上:不做「文件里不许出现『真二千石在本表未出现』」这种 substring 禁令。
// 「不许复活」体例要求原样抄下错误说法再打叉,禁令自己就会命中自己。
});
// ── 4. ⚠ 四个秩名严格分开:一个「比」字差一级 ──
test('officesByRank 精确匹配:「二千石」不许把「比二千石」「中二千石」捞进来', () => {
const two = officesByRank('xihan', '二千石');
assert.ok(two.length > 0, '「二千石」应当至少匹配到太守');
for (const o of two) {
assert.equal(o.rank, '二千石', `「${o.name}」的秩是「${o.rank}」,被错误地当成「二千石」捞了出来`);
}
assert.ok(two.some((o) => o.id === 'junshou'), '太守应当是二千石');
assert.ok(!two.some((o) => o.id === 'duwei'), '都尉是【比】二千石,不是二千石 —— 差一级');
assert.ok(!two.some((o) => o.id === 'taichang'), '太常是【中】二千石,不是二千石 —— 差一级');
});
test('太守二千石、都尉比二千石 —— 佐官低一级,这是本表内部自洽的一处', () => {
assert.equal(findOffice('xihan', '太守').rank, '二千石');
assert.equal(findOffice('xihan', '都尉').rank, '比二千石');
assert.equal(findOffice('xihan', '丞相司直').rank, '比二千石');
assert.equal(findOffice('xihan', '光禄大夫').rank, '比二千石');
assert.equal(findOffice('xihan', '太中大夫').rank, '比千石');
assert.equal(findOffice('xihan', '谏大夫').rank, '比八百石');
});
test('丞相长史是「千石」不是「比千石」 —— 前一轮抠错过,这是回归', () => {
const o = findOffice('xihan', '丞相长史');
assert.equal(o.rank, '千石');
assert.notEqual(o.rank, '比千石');
assert.ok(!o.quote.includes('比千石'), '引文里不许出现「比千石」—— 原文无「比」字');
});
// ── 5. ⚠ 推导来的秩石必须自曝推导链 ──
test('九卿的中二千石是从总述句推的,必须标 rankDerived 并给出总述句原文', () => {
const nineQing = officesByRank('xihan', '中二千石');
assert.ok(nineQing.length >= 4, '中二千石至少应有太常、郎中令、卫尉、廷尉、执金吾');
for (const o of nineQing) {
assert.equal(o.rankDerived, true, `「${o.name}」的中二千石不是本条直书,必须标 rankDerived`);
assert.ok(o.quoteRank, `「${o.name}」必须给出总述句原文,否则用户无从验证`);
assert.match(o.quoteRank, /自太常至執金吾/, `「${o.name}」的秩石出处应当是那句总述`);
assert.match(o.rankNote, /不在本條|不在本条/, `「${o.name}」必须说明秩石不在本条`);
}
});
test('范围内【未被点名】的诸卿(郎中令/卫尉/廷尉),confidence 必须是「需核实」', () => {
// 太常、执金吾 是总述句点名的两个端点,可靠性高;中间的靠条目次序套进去,是推导
for (const name of ['郎中令', '卫尉', '廷尉']) {
const o = findOffice('xihan', name);
assert.equal(
o.confidence, '需核实',
`「${name}」的秩石靠【总述句+条目次序】推出,不是原文直书 —— 必须标「需核实」`
);
}
});
test('citeOffice 对 rankDerived 的条目,必须把总述句一并吐出来', () => {
const c = citeOffice('xihan', '廷尉');
assert.equal(c.rankDerived, true);
assert.match(c.quote, /自太常至執金吾/, '推导来的秩石,注里必须带上总述句,不许藏');
assert.equal(c.confidence, '需核实');
});
// ── 6. 「需核实」的条目必须有 caveat 说清楚存疑在哪 ──
test('每条「需核实」都必须说清楚为什么存疑 —— 不许只打标不给理由', () => {
for (const o of OFFICES) {
if (o.confidence !== '需核实') continue;
assert.ok(o.caveat && o.caveat.length > 15, `「${o.name}」标了「需核实」却没说存疑在哪`);
}
});
test('卫尉的上下卷矛盾必须留在数据里 —— 不许替两卷调和', () => {
const o = findOffice('xihan', '卫尉');
assert.equal(o.confidence, '需核实');
assert.match(o.caveat, /下卷/, '卫尉的上下卷年份矛盾必须写进注里');
// 上卷作「後元年復為衞尉」,下卷系于中六年 —— 本表只录上卷原文,不采下卷年份
assert.match(o.quote, /後元年復為衛尉/);
assert.ok(!o.quote.includes('中六年'), '卫尉条不许把下卷的年份混进上卷引文');
});
// ── 7. 引文纪律 ──
test('引文标点必须是本项目自己断的 —— 不许出现点校本体例的逗号', () => {
// 本项目体例:只用「。」断句、「、」列举。ctext 与中华书局本用「,」——
// 引文里出现「,」就说明有人照抄了别人的标点(那是受保护的表达)。
for (const o of OFFICES) {
for (const [field, text] of [['quote', o.quote], ['quoteRank', o.quoteRank]]) {
if (!text) continue;
assert.ok(
!text.includes(','),
`「${o.name}」的 ${field} 里有「,」—— 本项目引文体例只用「。」和「、」,` +
'出现逗号说明照抄了点校本/ctext 的标点'
);
}
}
});
test('引文必须短 —— 最小必要引用', () => {
for (const o of OFFICES) {
if (!o.quote) continue;
assert.ok(o.quote.length <= 80, `「${o.name}」的引文 ${o.quote.length} 字,超出最小必要引用`);
}
});
test('引文里不许混进顏師古注 —— 那是唐人对汉制的解释,不是班固原文', () => {
const ZHU = ['師古曰', '师古曰', '應劭曰', '应劭曰', '如淳曰', '臣瓚曰', '文穎曰'];
for (const o of OFFICES) {
const text = `${o.quote || ''}${o.quoteRank || ''}`;
for (const z of ZHU) {
assert.ok(!text.includes(z), `「${o.name}」的引文里混进了注文「${z}」—— 顏師古卒于 645 年,那是唐人说汉制`);
}
}
});
// ── 8. findOffice / citeOffice 的基本行为 ──
test('findOffice:本名、异名、更名后的名都要找得到', () => {
assert.equal(findOffice('xihan', '郎中令').id, 'langzhongling');
assert.equal(findOffice('xihan', '光禄勋').id, 'langzhongling', '更名后的名也要找得到');
assert.equal(findOffice('xihan', '光祿勳').id, 'langzhongling', '繁体也要找得到');
assert.equal(findOffice('xihan', '太守').id, 'junshou');
assert.equal(findOffice('xihan', '郡守').id, 'junshou', '秦官名也要找得到');
assert.equal(findOffice('xihan', '大理').id, 'tingwei', '廷尉更名大理,要找得到');
assert.equal(findOffice('xihan', '执金吾').id, 'zhongwei');
});
test('findOffice:查不到就返回 null —— 不许猜', () => {
assert.equal(findOffice('xihan', '尚书令'), null, '本表没有尚书令,不许猜一个出来');
assert.equal(findOffice('xihan', '功曹'), null, '郡府属吏不在本表(那在《续汉书》且是东汉制)');
assert.equal(findOffice('xihan', '决曹'), null);
assert.equal(findOffice('donghan', '太守'), null, '本表无东汉条目');
assert.equal(findOffice('qin', '太守'), null, '本表 era 全部标 xihan');
assert.equal(findOffice('xihan', ''), null);
assert.equal(findOffice('xihan', null), null);
assert.equal(findOffice('song', '宰相'), null);
});
test('citeOffice:返回 {source, quote, caveat},且 caveat 不许空', () => {
const c = citeOffice('xihan', '太守');
assert.ok(c.source && c.quote && c.caveat);
assert.equal(c.source, '《汉书·卷十九上·百官公卿表上》');
assert.match(c.quote, /郡守秦官/);
assert.equal(citeOffice('xihan', '不存在的官'), null);
});
test('citeOffice:每条都有 caveat —— 注的价值全在限定语,不许有条目裸奔', () => {
for (const o of OFFICES) {
const c = citeOffice(o.era, o.name);
assert.ok(c, `「${o.name}」citeOffice 查不到自己`);
assert.ok(c.caveat && c.caveat.length > 0, `「${o.name}」没有限定语 —— 注只剩原文就成了断言`);
}
});
// ══════════════════════════════════════════════════════════════════════════
// ── 10. ★ 本轮(2026-07-16 curl 解封)新增:守「单见证当定论」这个坑 ──
//
// 上一版栽的就是这个:用一个见证读不出「初」字,就断言「原文无『初』字,已订正」。
// 这类错误比编造更难发现,因为它【长得像一次订正】。
// ══════════════════════════════════════════════════════════════════════════
test('每条都必须标 verified —— 没核过的条目不许混进这张表', () => {
for (const o of OFFICES) {
assert.ok(o.verified, `「${o.name}」没有 verified —— 本表已解封,解封的前提是每条都逐字核过`);
assert.match(
o.verified, /curl/,
`「${o.name}」的 verified 不是 curl 目验 —— ` +
'WebFetch 会把页面交给小模型转述,那不是逐字目验,本表就是因此被封存过'
);
}
});
test('★ 两个见证读法不同的条目,必须有 variant 且 confidence 必须是「需核实」', () => {
// 这是本表被封存过的那个毛病的【可执行版本】:
// 一条引文如果只有一个见证这么读,把它当定论展示出去,就是在拿单见证冒充「原文」。
const withVariant = OFFICES.filter((o) => o.variant);
assert.ok(withVariant.length >= 2, '大司马(「初」字)、大司空(「金印紫綬」四字)两处异文必须留在数据里');
for (const o of withVariant) {
assert.equal(
o.confidence, '需核实',
`「${o.name}」有两本异文却标「原文明载」—— 异文未决就不是「明载」`
);
assert.ok(o.variant.length > 20, `「${o.name}」的 variant 必须说清楚是哪个见证怎么读的`);
}
});
test('★ 大司马的「初」字:不许再断言「原文无初字」—— 那是把单见证当原文', () => {
const o = findOffice('xihan', '大司马');
assert.equal(o.rank, '', '「祿比丞相」是俸禄比拟,不是秩石');
assert.ok(o.variant, '「初」字是两本异文,必须留 variant');
assert.match(o.variant, /初/, 'variant 必须点明争的是「初」字');
assert.equal(o.confidence, '需核实');
// 上一版的原话是「原文【无「初」字】(前一轮曾误增,已订正)」——这句话必须死掉
assert.ok(
!/无「初」字|無「初」字/.test(o.caveat + o.rankNote + o.variant) ||
/异文|異文|未决|未決/.test(o.caveat + o.variant),
'✗ 不许再断言「原文无初字」:见证 A 明明作「綏和元年初賜大司馬金印紫綬」,两本异文未决'
);
});
test('★ 大司空的「金印紫綬」:单见证读法,不许再标「原文明载」', () => {
const o = findOffice('xihan', '大司空');
assert.equal(o.rank, '');
assert.ok(o.variant, '「金印紫綬」四字是两本异文,必须留 variant');
assert.match(o.variant, /金印紫綬/);
assert.equal(o.confidence, '需核实');
// 保守读法:本表 quote 不带这四字(只有一个见证有的字,不写进引文)
assert.ok(
!o.quote.includes('金印紫綬'),
'大司空的引文取保守读法(不带「金印紫綬」)—— 只有一个见证有的字不进引文'
);
});
test('★ citeOffice 必须把 variant 吐出来,且并进 caveat —— 异文不许藏', () => {
const c = citeOffice('xihan', '大司马');
assert.ok(c.variant, 'citeOffice 必须单独给出 variant,前端才能标「两本异文」');
assert.match(
c.caveat, /异文|異文/,
'异文必须同时并进 caveat 串 —— 前端只读 caveat 时也必须漏不掉'
);
// 没有异文的条目,variant 是空串,不是 undefined(前端好判断)
assert.equal(citeOffice('xihan', '太守').variant, '');
});
test('★「自太常至執金吾」覆盖的十条必须全部收齐 —— 范围要能被数出来,不是被相信', () => {
// 本轮 curl 把全卷正文按段列出后确认:总述句「自太常至執金吾」覆盖的正好是本卷正文
// 段序 ⑦–⑯ 这十条。全部收齐,这个范围才可以被【数】;少收一条,它就又变回
// 一句需要读者信任我们的话。次序即本卷段序,不许重排。
const EXPECT = [
'taichang', 'langzhongling', 'weiwei', 'taipu', 'tingwei',
'dianke', 'zongzheng', 'zhisu_neishi', 'shaofu', 'zhongwei',
];
const got = officesByRank('xihan', '中二千石').map((o) => o.id);
assert.deepEqual(
got, EXPECT,
'中二千石的十条必须齐、且按本卷段序排 —— 太常与執金吾是总述句点名的两个端点,中间八条靠次序套进去'
);
// 端点在最前和最后:这正是「自太常至執金吾」这句话的字面结构
assert.equal(got[0], 'taichang', '太常是总述句点名的起点');
assert.equal(got[got.length - 1], 'zhongwei', '執金吾是总述句点名的终点');
});
test('★ 中二千石:端点(太常/執金吾)可标「原文明载」,中间八条必须「需核实」', () => {
// 端点被总述句【点名】,可靠性高于靠条目次序套进去的诸卿 —— 这个区别不许抹平
for (const id of ['taichang', 'zhongwei']) {
const o = OFFICES.find((x) => x.id === id);
assert.equal(o.confidence, '原文明载', `「${o.name}」是总述句点名的端点`);
// ⚠ 但端点【也不是本条直书】:秩石仍在总述句里,故 rankDerived 仍须为 true
assert.equal(o.rankDerived, true, `「${o.name}」的秩石仍在总述句里,不是本条直书`);
}
for (const id of ['langzhongling', 'weiwei', 'taipu', 'tingwei', 'dianke', 'zongzheng', 'zhisu_neishi', 'shaofu']) {
const o = OFFICES.find((x) => x.id === id);
assert.equal(
o.confidence, '需核实',
`「${o.name}」在范围内但【未被总述句点名】,靠条目次序推出 —— 必须标「需核实」`
);
}
});
test('★「三公万石」出顏師古注 —— 不许有任何条目的 rank 是「万石」', () => {
// 「丞相万石」的来历本轮查清了:卷首「師古曰:漢制,三公號稱萬石,其俸月各三百五十斛穀」。
// 那是唐人注,且说的是「號稱」与月俸斛数,本来就不是本表的秩石名目。
for (const o of OFFICES) {
assert.ok(!/万石|萬石/.test(o.rank), `「${o.name}」的 rank 是「${o.rank}」—— 本表无「万石」这个秩名`);
}
assert.ok(!RANK_NAMES.some((r) => /万石|萬石/.test(r)), '本表没有「万石」,出现即是把顏師古注当了正文');
// 丞相条必须主动交代「万石」的来历,否则用户还是会自己脑补
const o = findOffice('xihan', '丞相');
assert.match(o.rankNote + o.caveat, /師古|师古/, '丞相条必须点明「三公号称万石」出自顏師古注,不是班固正文');
});
test('引文的省略必须用「……」标出 —— 不许暗省', () => {
// 暗省是「看起来一字不差、其实中间挖了一句」的错误。上一版有几处暗省(如丞相条跳过
// 「孝惠、高后置左右丞相……有兩長史,秩千石。」)。本轮全部改为显式「……」。
// 这条断言只能守住「有省略号的地方确实是省略」这个体例,真正的把关在 curl 比对脚本。
for (const o of OFFICES) {
if (!o.quote || !o.quote.includes('……')) continue;
assert.ok(
!o.quote.startsWith('……') && !o.quote.endsWith('……'),
`「${o.name}」的引文首尾用了「……」—— 首尾截断不必标省略号,标了反而像原文还有话`
);
}
});
test('officesByRank:非法输入不炸', () => {
assert.deepEqual(officesByRank('xihan', ''), []);
assert.deepEqual(officesByRank('xihan', null), []);
assert.deepEqual(officesByRank('donghan', '二千石'), []);
assert.deepEqual(officesByRank('song', '二千石'), []);
});
test('listOffices 不吐内部字段(quote 该走 citeOffice)', () => {
const list = listOffices('xihan');
assert.ok(list.length > 0);
for (const o of list) {
assert.ok(o.id && o.name);
assert.equal(o.quote, undefined, '引文该走 citeOffice,那里才带 caveat');
assert.equal(o.caveat, undefined);
}
assert.deepEqual(listOffices('donghan'), []);
});
// ── 9. 爵:秦汉是爵—秩二元结构,爵不是秩 ──
test('二十等爵:爵不是秩石,rank 必须留空;二十级不多不少', () => {
const o = findOffice('xihan', '二十等爵');
assert.equal(o.rank, '', '爵不是秩石,不许给它安一个秩');
assert.equal(o.scale.length, 20, '二十等爵必须正好二十级');
assert.equal(o.scale[0], '公士');
assert.equal(o.scale[19], '彻侯');
assert.equal(o.scale[18], '关内侯');
// ★ 2026-07-16:上一版此条标「需核实」,理由是「第三级用字两轮 WebFetch 核查不一致」。
// 本轮 curl 目验:两个独立见证【一致】作「三簪裊」,存疑的理由消失了 → 升为「原文明载」。
// ⚠ 这是本轮唯一一处【调高】confidence 的地方。调高的依据必须是「疑点被解决了」,
// 不是「看着差不多」——「簪褭/簪裊」是异体字(无义别),不是异文(有义别)。
assert.equal(o.confidence, '原文明载');
assert.match(o.caveat, /簪/, '第三级的用字必须在注里交代(异体字,非异文)');
assert.match(o.caveat, /异体|異體/, '必须说清楚簪裊/簪褭 是异体字问题,不是史实问题');
assert.equal(o.scale[2], '簪袅', '两个见证一致作「簪裊」,scale 用简体「簪袅」');
});