466 lines
27 KiB
JavaScript
466 lines
27 KiB
JavaScript
// 史料表的单测 —— 这不是覆盖率测试,这是【史学纪律的可执行定义】。
|
||
//
|
||
// 这张表的用户是全世界最难骗的那批人,一条错就全毁。所以下面的断言不测「功能能跑」,
|
||
// 测的是「有没有人在这张表里编东西」:
|
||
// · 有引文必须有出处
|
||
// · 原文没写的秩石不许补
|
||
// · 西汉的秩级不许出现在东汉条目里(← 本产品踩过的坑,这是回归测试)
|
||
//
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// 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 用简体「簪袅」');
|
||
});
|