Files
digital-me-history/digital-me-backend/test/dynasties-tracks.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

547 lines
40 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.
// 多轨化(tracks)的单测 —— 第七朝(辽)那一刀立的。
//
// 这个文件守两件事:
// ① 【跨梯不可比】:这一条必须是数据/函数上成立,不能只写在注释里。
// ② 【宋的武举回来了】:上一轮把它删了,代价是一个史实错误(宋=排斥武人)。
//
// ⚠⚠ 曾经还守着第三件事:【迁移锁】(五个单轨朝代,多轨化前后 1128 个 profile 的输出
// 逐字相同)。★ 2026-07-16 第二刀(删加权求和)把它【退休】了,凭据写在下面 ——
// 退休一条锁是件大事,不许悄悄删,所以理由留在原地。
//
// 跑:node --test test/dynasties-tracks.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DYNASTIES, computeRank, compareDynasties, getDynasty, listDynasties,
profileFromSubs, sameScale, tracksOf,
} from '../src/dynasties.js';
// ══════════════════════════════════════════════════════════════════════════
// ── 【迁移锁已退休】2026-07-16 第二刀(删加权求和)──────────────────────
//
// 【它原来是什么】两条测试:拿 1128 个 profile 跑五个单轨朝代,指纹必须与
// dynasties-pre-multitrack.json(旧代码采的)逐字相同。老板当时的原话:「迁完输出必须一字不变」。
//
// 【为什么退休,不是「挂了就删」】那把尺子是用来证【输出没变】的 —— 它是【多轨化】那一刀的
// 验收:那一刀改的是 schema(ladder → tracks),输出理应一字不变,所以「不变」就是对的。
// ★ 而这一刀(删加权求和)的任务【就是让输出变】:七朝原文里没有加权求和的原型
// (chiduo.js SHAPE_DIFF.weightedSumOccurrences = 0),旧的位阶【本来就是错的】。
// ⇒ 拿一把证「没变」的尺子来验一刀「必须变」的活,尺子本身就用错了。
// 留着它,只有两条路:要么不敢改引擎,要么重新生成 fixture 把它弄绿 ——
// 而后者正是本项目铁律里的【假订正】(「它长得像一次订正」)。两条都比退休它坏。
//
// 【凭什么敢退休 —— 覆盖面只增不减,这一条是硬要求】
// 接它的是 test/dynasties-shape.test.js 的【变化审计】,它在每一个方向上都更严:
// · 朝代:五朝 → 【七朝】(宋、辽当年不在锁里)
// · 粒度:五个 sha256 指纹 →【7896 格逐格对照】(1128 profile × 7 朝)
// · 参照:pre-multitrack →【dynasties-weighted-sum.json】,而它的真伪【不靠自述】:
// shape 测试头一条就拿 pre-multitrack 里那 6×5 份【完整】输出交叉验证它(双证)。
// ⇒ 旧引擎的产出【一格都没丢】,它只是从「必须相等」变成了「不等就必须说出依据」。
// · 判词:指纹只会说「不一样」;审计要求【每一处不一样都归进一个说得出原文的类】,
// 且「我说不清为什么变的」那一类【必须为空】。
// ★ 一句话:锁证的是「我没动它」;审计证的是「我动的每一格都对」。后者严格强于前者。
// ══════════════════════════════════════════════════════════════════════════
// ── ② 跨梯不可比:必须在数据/函数上成立 ──────────────────────────────────
test('★★ 跨梯不可比:两条梯子的 unit 必须不同 —— 这是判别式,不是注释', () => {
// 辽:北面 vs 南面。这两个的 tier 摆到一根轴上,跟拿汉的秩石比南齐的官品是同一个错。
assert.equal(sameScale('liao', 'beimian', 'nanmian'), false, '辽的北面与南面【绝不可比】');
// 宋:文选(差遣) vs 武选(使臣阶)。上一轮就是因为「不共一把尺」才把武举删了 —— 尺确实不共。
assert.equal(sameScale('song', 'wenxuan', 'wuxuan'), false, '宋的文选与武选【绝不可比】');
// 自己跟自己:可比。这是唯一为真的情形。
assert.equal(sameScale('liao', 'beimian', 'beimian'), true);
assert.equal(sameScale('han', 'main', 'main'), true);
// ★★ 2026-07-17 第四刀:南朝入列。三轨 —— 士族清途 / 中书通事舍人 / 军功。
// 「士族的侍中(qingtu tier4) 与 中书通事舍人(sheren tier4),谁高?」——【这个问题本身是错的】。
// ★ 而这正是南朝这一朝要说的那句话:士庶天隔【不是高低之隔,是两把尺之隔】。
assert.equal(sameScale('nanchao', 'qingtu', 'sheren'), false, '★ 士族的清途与舍人那一户【绝不可比】');
assert.equal(sameScale('nanchao', 'qingtu', 'junhao'), false, '清途与将军号不共一把尺');
assert.equal(sameScale('nanchao', 'sheren', 'junhao'), false);
assert.equal(sameScale('nanchao', 'sheren', 'sheren'), true);
// 而且必须是【数据】上不同,不是靠 sameScale 里写死 if:逐条查 unit 真的两两不等。
for (const id of ['liao', 'song', 'nanchao']) {
const ts = tracksOf(id);
assert.ok(ts.length > 1, `${id} 必须是多轨`);
const units = new Set(ts.map((t) => t.unit));
assert.equal(units.size, ts.length, `${id} 的每条梯子必须有各自的 unit —— 同 unit 就意味着可比`);
}
});
// ══════════════════════════════════════════════════════════════════════════
// ★★★ 南朝三轨(2026-07-17 第四刀)。这一组守的是【这一刀的病根】,不是它的症状。
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 南朝:中书通事舍人【不许】出现在士族清途的梯子上 —— 那等于说江斅当了纪僧真', () => {
const d = getDynasty('nanchao');
const qingtu = d.tracks.find((t) => t.id === 'qingtu');
assert.ok(qingtu, '南朝必须有士族清途这条梯子');
for (const l of qingtu.ladder) {
assert.ok(
!/舍人/.test(l.title),
`★ 士族清途的 tier${l.tier} 是「${l.title}」—— 舍人爬到士族的梯子上来了。` +
'本表的 stigmaNote 写着「清流不与同席」,而梯子让他们共用一格。'
);
}
// ★ 病根不是「有人落在那一格」,是【那一格在士族的路上】:
// 旧梯子 tier4=中书通事舍人,而士族的天花板是 tier5(尚书令)——
// 即:本表结构性地断言【士族要当尚书令,得先经过中书通事舍人】。
// ⇒ 就算没有一个 profile 落在 tier4,那句话仍然写在数据里。★ 没人落在坑里,不等于坑填了。
const shizu = d.channels.find((c) => c.id === 'shizu');
const sheren = d.channels.find((c) => c.id === 'sheren');
assert.notEqual(shizu.track, sheren.track, '★ 士族与舍人【绝不许共用一条梯子】');
assert.equal(sameScale('nanchao', shizu.track, sheren.track), false, '两条梯子的 tier 不可比');
});
test('★★ 南朝:舍人的梯子【只有它自己】—— 位子只有一个,没有升法', () => {
const d = getDynasty('nanchao');
const t = d.tracks.find((x) => x.id === 'sheren');
const tiers = t.ladder.map((l) => l.tier);
assert.deepEqual(tiers, [0, 4], '★ tier0(不在这一户)+ tier4(中书通事舍人)。就这两格');
assert.equal(t.ladder.find((l) => l.tier === 4).title, '中书通事舍人');
// ★ 上面没有一格 —— 这是【原文正面否认升法】:茹法亮固辞大司农,垂涕而出。
// ✗ 谁想补一个 tier5,先拿一条说【舍人怎么升】的原文来。
assert.equal(t.ladder.find((l) => l.tier === 5), undefined, '★ 舍人之上没有一格 —— 升官就是离开这条路');
// 而 seat = ceiling = 4:走进来的人就在那一格,没有升法,也没有考试。
const c = d.channels.find((x) => x.id === 'sheren');
assert.equal(c.xuan.seat, 4);
assert.equal(c.ceiling.tier, 4);
assert.deepEqual(c.xuan.kemu, [], '这条路没有量表(久勞、親信 —— 一个时钟 + 一段关系)');
});
test('★★★ 南朝:不许给齐代安品数 —— 《南齐书·百官志》不系品秩(双证逐字符复核)', () => {
// ★ 这一条守的是本刀的头号发现,也是【两个文件曾经打架】的那一条:
// decisions.js 早有铁律「一律只写「品卑」、【绝不给齐代品数】」,
// 而 dynasties.js 的旧梯子给了四格品数(七品/六品/五品/三品)——【上线的是给品数的那个】。
// (与「御史大夫」那次同种:shiliao.js 先查出来了,dynasties.js 照错上线。)
const d = getDynasty('nanchao');
for (const t of d.tracks) {
for (const l of t.ladder) {
assert.ok(
!/^[一二三四五六七八九]品$/.test(l.grade) && !/^正[一二三四五六七八九]品/.test(l.grade),
`${t.id}/tier${l.tier}「${l.title}」的 grade 是「${l.grade}」—— ` +
'★ 《南齐书·百官志》全卷不系一个正官的品:「秩」零命中,「品」7 见全是国子学与诸陵令的' +
'属吏(「典學二人,三品」「用二品三品勳」)。品数不在这部书上,本表不许自己安一个。'
);
}
}
// ✗ 而【绝不许】反过来写成「南齐无官品」—— 与「辽无官品」同种的错。
// 志序自己说了品在哪:本之晉令。
assert.match(d.unitNote, /不系品秩/, '准确表述:志不系品秩');
assert.match(d.unitNote, /晉令|晋令/, '★ 必须同时说清楚官品是有的,它在晋令里 —— 志不载 ≠ 本来就没有');
assert.ok(!/南齐无官品|南朝无官品/.test(d.unitNote), '✗ 「南齐无官品」是「反过来说本表无据」');
});
test('★★ 南朝:士族清途的每一级都必须挂得住 —— tier4 是【侍中】,且那句话是「齐世」的', () => {
const d = getDynasty('nanchao');
const qingtu = d.tracks.find((t) => t.id === 'qingtu');
const t4 = qingtu.ladder.find((l) => l.tier === 4);
assert.equal(t4.title, '侍中', '★ 士族的第四阶是侍中,不是中书通事舍人');
assert.match(t4.grade, /美姿容者兼官/, '★ 原文比本表编的狠:齐世朝会,多以美姿容者兼官');
// 天花板:尚书令/仆射 并作一格,依据是志自己的话「無令,左僕射為臺主,與令同」。
const t5 = qingtu.ladder.find((l) => l.tier === 5);
assert.match(t5.title, /尚书令/);
assert.match(t5.grade, /與令同|与令同/, '★ 把令与仆射并作一格,是志说的,不是本表凑的');
});
test('★★ 判别式的【失效方向】必须朝关:问错名字要答「不」,不能答「行」', () => {
// ⚠ 2026-07-16 验收抓出来的真 bug:sameScale 原本用带回落的 trackOf,
// 于是拼错的梯子名会悄悄回落到 tracks[0],两边撞成同一条 → 返回 true。
// 一个用来【禁止】跨梯画轴的函数,被问到不认识的名字时答「可以画」,是最坏的一种坏。
assert.equal(sameScale('liao', 'beimian', 'TYPO'), false, '拼错的梯子名不许被判成「可比」');
assert.equal(sameScale('liao', 'TYPO1', 'TYPO2'), false, '两个都不存在,更不许说可比');
assert.equal(sameScale('song', 'wenxuan', 'wujuan'), false, '差一个字母也不行(wuxuan 才对)');
assert.equal(sameScale('liao', undefined, undefined), false, '没给名字 = 没问清楚,答「不」');
assert.equal(sameScale('liao', 'beimian', null), false);
assert.equal(sameScale('nope', 'main', 'main'), false, '朝代都不存在');
// 单轨朝代也一样:'main' 之外的名字一律不认。
assert.equal(sameScale('han', 'main', 'TYPO'), false);
// 而正当的问法必须仍然答得对 —— 别为了堵洞把函数堵死。
assert.equal(sameScale('han', 'main', 'main'), true);
assert.equal(sameScale('liao', 'beimian', 'beimian'), true);
assert.equal(sameScale('liao', 'beimian', 'nanmian'), false);
});
test('★ 多轨朝代不许再报「朝代的单位」—— 那是个假问题', () => {
const byId = Object.fromEntries(listDynasties().map((d) => [d.id, d]));
// 单轨:unit 就是那条梯子的,与多轨化之前一样。
assert.equal(byId.han.unit, 'shi');
assert.equal(byId.han.unitName, '秩石');
assert.equal(byId.han.multiTrack, undefined, '单轨朝代不该有 multiTrack 这个东西');
// 多轨:不报「一个」单位,报全部。
for (const id of ['liao', 'song']) {
assert.equal(byId[id].unit, 'multi', `${id} 没有「一个」单位可报`);
assert.equal(byId[id].multiTrack, true);
assert.ok(byId[id].tracks.length > 1);
assert.match(byId[id].unitName, ///, '多轨朝代的 unitName 必须是合并显示名,不是某一条的');
}
assert.equal(byId.liao.unitName, '北面官/南面官');
assert.equal(byId.song.unitName, '差遣/使臣阶');
});
test('每条通道都必须挂在一条真实存在的梯子上(写错 track 名会静默走到 tracks[0] 去)', () => {
for (const d of DYNASTIES) {
assert.ok(Array.isArray(d.tracks) && d.tracks.length >= 1, `${d.name} 没有 tracks`);
const ids = new Set(d.tracks.map((t) => t.id));
assert.equal(ids.size, d.tracks.length, `${d.name} 的 track id 有重复`);
// ══════════════════════════════════════════════════════════════════════
// ★★★ 2026-07-17 第四刀:这条断言【改了】。原来是:
// assert.deepEqual(t.ladder.map((l) => l.tier), [0,1,2,3,4,5], '阶梯必须是 0..5');
//
// 【为什么它错了 —— 它和「一朝一把尺」是同一句话,只是往下了一层】
// 多轨化杀掉的假设是【「一朝一把尺」】。而这条断言藏着它的孪生兄弟:
// ★【「每把尺都有六格」】。前六朝碰巧成立,所以它和它哥哥一样活了好几轮。
// ⇒ 南齐的中书通事舍人把它撞穿了:那条路【只有一个位子,没有升法】——
// · 上面没有:舍人往上一步就是【出】。「出法亮為大司農,中書勢利之職,法亮不樂去,
// 固辭不受,既而代人已致,法亮垂涕而出」(《南齐书》卷56,双证)——
// 大司农是九卿,比舍人高得多,而他【哭着不肯去】:升官就是离开这条路。
// ⇒ 编一个 tier5 出来,就是发明一条【原文正面否认的升法】。
// · 下面也没有:「其下有主書令史」是【职司的高下】,不是【仕途的一级】;
// 而纪僧真的路子是「出自本縣武吏……稍歷軍校」,他不是从主书令史升上来的。
// · 中间更没有:这条路【没有量表】(「齊初亦用久勞,及以親信」)。
//
// 【为什么不是「给它编四格填满」】★ 这是这条改动的全部要害:
// 编出来的四格,在数据里【长得和有原文的那两格一模一样】。⇒ 空操作比缺席更坏。
// (本轮刚因为同一条道理删掉了 penaltyIfNoWang —— 不许前脚删一个,后脚造四个。)
//
// 【新断言守什么 —— 只增不减,比旧的更严】
// ① 从 0 起(tier0 = 「你不在这条路上」,每条梯子都得有)
// ② 严格递增、无重复、⊆ 0..5(★ 旧断言隐含的「不许乱序/不许重复/不许越界」全部保留)
// ③ ★ 新增:【每一级要么挂原文,要么明标推演】—— 旧断言【完全没管这件事】,
// 于是南朝那条梯子上「七品/六品/五品/三品」四个查不实的品数,在它眼皮底下躺了好几轮。
// ⇒ 放宽的只有「必须正好六格」这一条,而它是错的;其余全部收紧。
// ══════════════════════════════════════════════════════════════════════
for (const t of d.tracks) {
assert.ok(t.unit && t.unitName, `${d.name}/${t.id} 缺 unit/unitName`);
const tiers = t.ladder.map((l) => l.tier);
assert.equal(tiers[0], 0, `${d.name}/${t.id}: 梯子必须从 tier0 起(「你不在这条路上」那一格)`);
for (let i = 1; i < tiers.length; i++) {
assert.ok(
tiers[i] > tiers[i - 1],
`${d.name}/${t.id}: 阶梯必须严格递增、不许重复 —— 得到 ${JSON.stringify(tiers)}`
);
}
// ⚠⚠ 2026-07-17 接 25 朝:上界【不再写死 5】。★ 这是「一朝一把六格尺」那个隐含假设的最后一处 ——
// 多轨化摘掉了「一朝一把尺」、上一刀摘掉了「每把尺都有六格」(南齐舍人只一格),
// 而【上界正好是 5】还剩在这里。二十等爵(0–20)、九品(0–18)、九命(0–17)原文就不止六格:
// ✗ 把它们压成 0–5,就是拿【本表的编号】盖掉【原文的编号】(秦那把尺,二十个号是班固写的,不是本表排的)。
// ⇒ 不变量【保留】:非负整数、从 0 起(上面 L219)、严格递增(上面 L220-224);放宽的只有「≤5」这个上界。
assert.ok(
tiers.every((x) => Number.isInteger(x) && x >= 0),
`${d.name}/${t.id}: 阶梯必须是从 0 起、严格递增的非负整数 —— 得到 ${JSON.stringify(tiers)}`
);
// ★ 每一级都要交代自己的依据:grade 里要么挂原文(「志作/序作」),要么明标推演。
//
// ⚠⚠ 【这条只对南朝生效,而这个范围本身要说清楚,否则它就是一次假订正】:
// 本刀【只回原书逐字核过南朝】(《南齐书》卷16,双证 wikisource + 国学导航,
// 15 句逐句比对、「秩」与「品」逐字符复核)。汉/魏晋/唐/宋/明/辽的梯子是上几刀留下的,
// ★ 本刀【没有核过它们】—— 而「我没核过」不等于「它们没有依据」。
// ⇒ 拿一条我没核过的梯子去红一条测试,正是本项目铁律里的【假订正】:
// 「写『已订正』前先确认手上有两个见证」。我手上没有那六朝的见证。
// ⇒ 所以这里【只收紧南朝】,并把「其余六朝没核」这件事记在 dynasties.js 的 not_done 里,
// 由老板决定下一刀要不要逐级核。
// ✗ 【不许】为了让这条断言「看起来更全面」而把它扩到七朝 —— 那会逼着下一个人去给
// 他也没核过的梯子编依据。★ 一条断言的范围,不许大于写它的人手上的见证。
if (d.id === 'nanchao') {
for (const l of t.ladder) {
assert.match(
l.grade, /志作|序作|推演|本表的编号|非原文/,
`${d.name}/${t.id}/tier${l.tier}「${l.title}」: grade 既没挂原文,也没标推演 —— ` +
'★ 梯子的每一级要么有原文,要么照实说它是本表编的。' +
'(南朝那条旧梯子的「七品/六品/五品/三品」就是这么混进来的:' +
'它们是从魏晋的梯子上抄的,而《南齐书·百官志》全卷不系一个正官的品 ——' +
'「秩」零命中,「品」7 见全是属吏与勳品,双证逐字符复核。)'
);
}
}
}
for (const c of d.channels) {
assert.ok(c.track, `${d.name}/${c.name} 没写 track —— 会静默掉到第一条梯子上去`);
assert.ok(ids.has(c.track), `${d.name}/${c.name} 的 track「${c.track}」不存在`);
}
}
});
test('★ rank.unit 必须来自【你走的那条梯子】,不是朝代', () => {
// 纯果决在宋只能走武举 → 使臣阶;吏能在宋走科举 → 差遣。同一朝,两个单位。
const wu = computeRank(profileFromSubs(['blunt', 'motivate'], { wang: 0 }), 'song');
const wen = computeRank(profileFromSubs(['rigorous', 'datadriven', 'concise', 'shortfirst', 'patient', 'calm'], { wang: 0 }), 'song');
assert.equal(wu.channel.id, 'wuju');
assert.equal(wu.rank.unit, 'shichen', '走武举的人,位阶要以使臣阶计');
assert.equal(wu.dynasty.unitName, '使臣阶');
assert.equal(wen.channel.id, 'keju_song');
assert.equal(wen.rank.unit, 'chaiqian', '走科举的人,位阶要以差遣计');
assert.equal(wen.dynasty.unitName, '差遣');
// ★ 而这两个人【同在宋】,位阶不可互比 —— 这正是多轨化要表达的那件事。
assert.equal(sameScale('song', 'wenxuan', 'wuxuan'), false);
});
// ── ③ 宋:武举回来了 ────────────────────────────────────────────────────
test('★★ 宋·武举:上一轮删掉它造成的【史实错误】必须已经修好', () => {
// 纯果决:一个只会打仗的人。上一轮他在宋算出 tier0「白身、无路可走」——
// 那等于本表断言熙宁没有武人的路,而《宋史·选举志三》「武举、武选」一整节就在那儿。
const wuren = profileFromSubs(['blunt', 'motivate'], { wang: 0 });
const song = computeRank(wuren, 'song');
assert.ok(song.channel, '纯武人在熙宁【必须有路】—— 没路是上一轮删武举删出来的错');
assert.equal(song.channel.id, 'wuju');
assert.ok(song.rank.tier > 0, '不许再把武人算成白身');
assert.equal(song.ceiling.tier, 5, '武选这条路通到环卫班,不是封顶的偏门');
});
// ⚠ 2026-07-16 第二刀:本条同样只改了【字段名】—— weights 被删了(详见 dynasties.test.js
// 同名断言上的那段注)。意思没改,覆盖面从 weights 一处扩到 key + kemu 两处。
test('★ 宋·武举:门第进不了武举 —— 与科举同一个道理(熙宁八年同日锁试于贡院)', () => {
const wuju = getDynasty('song').channels.find((c) => c.id === 'wuju');
assert.equal(wuju.gate.wang, undefined, '武举的 gate 里不许有 wang');
assert.ok(!wuju.xuan.key.includes('wang'), '武举的排序键里不许有 wang');
assert.ok(!wuju.xuan.kemu.some((k) => k.axis === 'wang'), '武举的可数科目里不许有 wang');
// 于是宋【两条正经轨】都不看门第,门第只剩恩荫一条 —— 宋的立意因武举而更硬,不是更软。
const keju = getDynasty('song').channels.find((c) => c.id === 'keju_song');
assert.equal(keju.gate.wang, undefined);
const yin = getDynasty('song').channels.find((c) => c.id === 'yinbu');
assert.ok(yin.gate.wang > 0, '门第没有消失,它还在恩荫那条路上');
});
test('★ 宋·武举:有门第的武人该去考武举,不该被判去荫补(与「同 tier 按天花板择路」同一条规矩)', () => {
// 恩荫 ceiling=3 且带 stigma;武举 ceiling=5 无 stigma。一个能打又有门第的人,
// 若被判去荫补,就白背一个「非进士出身」、天花板还掉两级。
const p = profileFromSubs(['blunt', 'motivate'], { wang: 80 });
const r = computeRank(p, 'song');
assert.equal(r.channel.id, 'wuju', '能走武举的武人不许被判去恩荫');
assert.equal(r.stigma, false, '他不该平白背上「非进士出身」');
// 前提校验:恩荫对他确实开着,引擎是【挑】了武举,不是只能走武举。
const yin = getDynasty('song').channels.find((c) => c.id === 'yinbu');
assert.ok(p.wang >= yin.gate.wang, '前提:恩荫对他开着,否则这条测试没在测东西');
});
test('★ 宋:「这是表的缺」那句话必须已经删掉(缺补上了,话就不能留)', () => {
const song = getDynasty('song');
assert.ok(!/这张表的缺|表的缺|暂未列入/.test(song.systemNote), 'systemNote 里不许再写武举是表的缺 —— 它已经列进来了');
assert.match(song.systemNote, /武举|武选/, '而武选这条路必须写进用户读得到的那段');
});
test('★ 宋·武举:时代错置不许复活 —— 「以策为去留」是天圣八年(1030),不是熙宁', () => {
const wuju = getDynasty('song').channels.find((c) => c.id === 'wuju');
const all = JSON.stringify({ t: wuju.sourceText, g: wuju.gateNote });
// 这句话漂亮、好用、到处被引作「宋武举重策」的证据 —— 正因为好用才危险。
// 它系于天圣八年,早于本朝 span 三十八年;熙宁的口径相反(武艺末等者三班差使)。
assert.ok(!/以策为去留/.test(all), '「以策为去留,弓马为高下」是天圣八年的记法,不许放进熙宁的引文');
assert.ok(!/天圣/.test(wuju.sourceText), '引文里不许出现天圣年间的事');
assert.ok(!/元丰|崇宁|绍兴|建炎/.test(wuju.sourceText), '引文不许越出熙宁 span');
// 而该在的必须在,且必须双证。
assert.match(wuju.sourceText, /熙宁五年/);
assert.match(wuju.sourceVerified, /双证/);
assert.ok(wuju.sourceCaveat.length > 10);
// 王安石那段是奏议(政论) —— 本文件的铁律:引政论必须连着引结果。
assert.match(wuju.sourceText, /于是悉从中书所定/, '引王安石的奏议,必须把「被采纳了」这个结果一起引');
});
// ── 辽 ──────────────────────────────────────────────────────────────────
test('★★ 辽:两梯都必须列 —— 不许再用「删掉破坏它的那根」绕过去', () => {
const liao = getDynasty('liao');
const ids = liao.tracks.map((t) => t.id).sort();
assert.deepEqual(ids, ['beimian', 'nanmian'], '辽必须两梯都在');
// 两梯各自都得有人走得到 —— 光挂着一条没有任何通道通向它的梯子,等于没列。
for (const t of liao.tracks) {
assert.ok(liao.channels.some((c) => c.track === t.id), `辽的「${t.name}」没有任何通道通向它,等于没列`);
}
});
test('★★ 辽·最毒的坑:契丹南枢密院是【北面官】,不许挪到南面那条梯子上', () => {
const liao = getDynasty('liao');
// 志自己警告过:「宰相、枢密、宣徽、林牙……皆分北、南,其实所治皆北面之事。」
// 南院枢密使是北面官;南面另有一个「汉人枢密院」。两个都叫枢密院,一北一南,绝不可混。
const wenquan = liao.channels.find((c) => c.id === 'wenquan_liao');
assert.equal(wenquan.track, 'beimian', '文铨(契丹南枢密院)是北面官 —— 挪到南面就是踩坑');
assert.match(wenquan.ceiling.title, /北面官/, '天花板写「南院枢密使」时必须当场注明它是北面官');
assert.match(wenquan.sourceCaveat, /其实所治皆北面之事/, 'caveat 必须把志自己的警告引出来');
// 南面那条梯子上不许出现「南院枢密使」;它只能有「汉人枢密使」。
const nanmian = liao.tracks.find((t) => t.id === 'nanmian');
const nanTitles = nanmian.ladder.map((l) => l.title).join('|');
assert.ok(!/南院枢密使/.test(nanTitles), '南面梯子上不许出现南院枢密使 —— 那是北面官');
assert.match(nanTitles, /汉人枢密使/, '南面的顶是汉人枢密使');
});
test('★★ 辽:志不系品秩 —— 但【不许】反过来写成「辽无官品」', () => {
const liao = getDynasty('liao');
const all = JSON.stringify(liao);
// 《辽史·百官志》四卷不系一个品、一石(本项目双证实测)。所以两梯的 unit 都不是刻度。
for (const t of liao.tracks) {
assert.ok(['beimian', 'nanmian'].includes(t.unit), '辽的梯子不许挂一个假的刻度上去');
}
// ★ 而这是本文件栽过的那个错的反面:不许把「志不载」说成「本来就没有」。
// ⚠ 这条断言自己踩过一次坑,记在这里:起初写的是 /辽没有官品/ 一律禁 —— 结果它把
// unitNote 里【正确的那句】「但这不等于辽没有官品」也判成了违规。
// 禁的是【断言】,不是【否定这个断言】。所以只准以「不等于/不是」打头出现。
for (const m of all.matchAll(/.{0,4}(辽没有官品|辽无官品|辽不设品|辽本无品秩)/g)) {
assert.match(
m[0], /(不等于|不是|≠)/,
`✗ 不许断言「${m[1]}」—— 志不载 ≠ 辽没有。(要说它,只能写成「不等于…」。)`
);
}
assert.match(liao.unitNote, /志不系品秩|不系品秩/, '必须写成【志不系品秩】');
assert.match(liao.unitNote, /无征者不书|无徵者不书/, '并且要说明志自己承认「无征者不书」');
});
test('★ 辽:《辽史》没有选举志 —— 这一点必须让用户读得到,不是只写在注释里', () => {
const liao = getDynasty('liao');
// 本表整张表的分析单位就是选拔制度,而辽这一朝没有一卷志是讲「你怎么进去」的。
// 瞒着用户等于让他以为这一格跟前六朝一样准。
assert.match(liao.systemNote, /选举志/, 'systemNote 必须说明《辽史》无选举志');
assert.match(liao.systemNote, /元/, '并且要说明它是元人仓促修的');
// 南面那条通道故意不挂 source(志上真没有)—— 照汉「察举孝廉」的先例。
const nan = liao.channels.find((c) => c.id === 'nanmian_hanguan');
assert.equal(nan.source, undefined, '南面通道不许挂出处 —— 志上没有,挂了就是编');
assert.match(nan.gateNote, /没有一卷选举志|连一卷选举志都没有/, '而且要当着用户的面说清楚这条路没有出处');
});
// ══════════════════════════════════════════════════════════════════════════
// ★★★ 2026-07-17 第三刀:这条测试【被重写了】。原来那条的标题是
// 「★★ 辽:择路取决于出身,而本表没有出身这一维 —— 这个失真必须写给用户看」,
// 它断言【每条辽的 gateNote 都必须自陈「这份画像里没有出身这一项」】。
//
// ★ 为什么重写而不是「报冲突、别自己裁」:
// 那条测试守的是【一个失真的自陈】。老板这一刀的原话是
// 「辽的择路【必须由出身决定,不再由能力替代】(这是这一维存在的全部理由,要有断言钉死)」
// —— 即:它要求那个失真【消失】。而那条测试要求那个失真的自陈【存在】。
// ⇒ 两者不可能同时绿。这不是「我裁了一个冲突」,是【老板的指令直接指向这条测试】。
// (对照:decisions.test.js 那两条红着的,老板【没有】指令要动它们 —— 所以我一个字没碰。)
//
// ⚠ 而重写它的时候,把它守的东西【只增不减】:
// 旧的守「必须自陈失真」(1 条断言 × 3 条通道);
// 新的守「出身必须真的决定择路」+「不许再拿能力替出身」+「文案不许指向用户本人」。
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 辽:择路【由出身决定】,不再由能力替代 —— 这一维存在的全部理由', () => {
const liao = getDynasty('liao');
assert.match(liao.systemNote, /你是谁家的人|哪一族/, 'systemNote 必须点明辽先问出身');
// ★★ 同一个人(能力一个字都没变),只换出身 —— 走的路必须不同。
// 这就是「你什么都没变,天花板全变了」在出身这一维上的样子。
const clerk = profileFromSubs(['rigorous', 'datadriven', 'concise', 'shortfirst'], { wang: 0 });
const qidan = computeRank({ ...clerk, chushen: 'qidan' }, 'liao');
const han = computeRank({ ...clerk, chushen: 'han' }, 'liao');
assert.equal(qidan.channel.id, 'wenquan_liao', '生在契丹的纯吏能人 → 北面文铨(「凡契丹人民皆属焉」)');
assert.equal(han.channel.id, 'nanmian_hanguan', '生在汉的【同一个人】→ 南面汉官(「南面治汉人州县」)');
assert.notEqual(
qidan.channel.id, han.channel.id,
'★★★ 能力完全相同,出身不同 ⇒ 择路必须不同。若这条挂了,说明引擎又在拿能力替出身做决定'
);
// ★★ 反过来钉死:出身【不给】的时候,引擎【不许替他答】。
// 旧引擎在这里会拿六轴挑一条北面的路出来 —— 那正是「拿你的性子替你的出身答的」。
const unset = computeRank(clerk, 'liao');
assert.equal(unset.channel, null, '★ 没定出身 ⇒ 引擎不许挑一条路出来替他答');
assert.ok(unset.chushen && unset.chushen.missing === true, '必须显式报「这一部传还没定出身」');
assert.ok(
unset.reasons.some((s) => /还没定/.test(s)),
'而且必须当着用户的面说出来 —— 不是静悄悄地算成白身'
);
// ★★ 出身关的路,理由必须是【出身的理由】,不是「你哪一项不够」——
// 那条路对他关着,跟他会什么一点关系都没有。
const blockedBySchen = han.blocked.filter((b) => b.byChushen);
assert.equal(blockedBySchen.length, 2, '生在汉 ⇒ 北面两条路(武铨/文铨)都该由出身关着');
for (const b of blockedBySchen) {
assert.deepEqual(b.missing, [], '★ 出身不是一个差多少的缺口 —— ✗「你差 55 分的契丹」是这功能能犯的最蠢的错');
assert.match(b.note, /你生在/, '关路的理由必须是出身的话');
}
// ★★★ 文案红线:出身是【这一部传的设定】,不是用户的属性。
// ✗ 不许出现「你的民族」「你的出身是」这种指向用户本人的措辞(PIPL 第28条:民族是敏感个人信息)。
// ✓ 只许「这一部传里,你生在……」。
const allCopy = [
...qidan.reasons, ...han.reasons, ...unset.reasons,
...han.blocked.map((b) => b.note),
qidan.chushen.ask, qidan.chushen.askNote,
].join('\n');
for (const bad of ['你的民族', '你的出身是', '您的民族', '你的种族']) {
assert.ok(!allCopy.includes(bad), `★ 文案红线:出现了「${bad}」—— 出身是这部书的设定,不是用户的属性`);
}
assert.match(qidan.chushen.ask, /这一部传里/, '问句必须是「这一部传里,你生在……」的句式');
});
test('★★ 辽:出身【所属 ≠ 任职】—— 不许写「北面官只有契丹人能当」', () => {
// 《辽史》卷47「詔四方館副使止以契丹人充」——四方館是【南面朝官】。
// 一个南面官要下诏才变成契丹专任 ⇒ 南面本来不是按族分的;志把族属限官当【例外】写,不是当通则。
// 又:志自己有反例「太祖初有漢兒司,韓知古總知漢兒司事」。
// ⇒ 本表的闸只回答【谁的铨管你】(所属),不回答【你的族能当什么官】(任职)。
const liao = getDynasty('liao');
for (const c of liao.channels) {
for (const bad of ['只对契丹人开', '北面官是契丹人的官', '这条路是给汉人的']) {
assert.ok(
!c.gateNote.includes(bad),
`辽/${c.name} 的 gateNote 出现「${bad}」—— 那是【任职】的说法,志不支持(见 chushen.js CONFLICTS #1)`
);
}
}
// ★ 而【所属】那一句必须在:它是这个闸的全部依据,也是它一直躺在 sourceText 里没被读的那句。
const wu = liao.channels.find((c) => c.id === 'wuquan_liao');
assert.match(wu.sourceText, /凡契丹军马皆属焉/, '武铨的所属句必须在原文里');
const wen = liao.channels.find((c) => c.id === 'wenquan_liao');
assert.match(wen.sourceText, /凡契丹人民皆属焉/, '文铨的所属句必须在原文里');
});
test('★ 辽:北面 tier4 必须写「北院大王」,不许写「夷离堇」(会同元年即改,与御史大夫同种错)', () => {
const bei = getDynasty('liao').tracks.find((t) => t.id === 'beimian');
const t4 = bei.ladder.find((l) => l.tier === 4);
assert.match(t4.title, /北院大王/, '正名是北院大王');
// 「本名夷离堇」可以写在括号里(志自己就是这么记的),但不许拿旧称当正名。
assert.ok(!/^夷离堇/.test(t4.title), '✗ 不许拿 938 年就改掉的旧称当正名 —— 那是东汉写御史大夫的同一种错');
});
test('★ 辽:于越不许进梯子(无职掌、终辽之世三人)', () => {
const bei = getDynasty('liao').tracks.find((t) => t.id === 'beimian');
const titles = bei.ladder.map((l) => l.title).join('|');
// 「大于越府。无职掌,班百僚之上,非有大功德者不授……终辽之世,以于越得重名者三人。」
// 班百僚之上不假,但它是尊号不是仕途的一级。写进 ladder = 告诉用户你这条路的终点是那三个人之一。
assert.ok(!/于越/.test(titles), '于越无职掌、终辽之世仅三人,是尊号不是一级仕途');
});
test('★ 辽:挂了 source 的通道必须双证 + 带限定语(新朝代,别开先例)', () => {
for (const c of getDynasty('liao').channels) {
if (!c.source) continue;
assert.ok(c.sourceVerified, `辽/${c.name} 挂了出处却没说什么时候、怎么核的`);
assert.match(c.sourceVerified, /双证/, `辽/${c.name} 必须双证`);
assert.ok(c.sourceCaveat && c.sourceCaveat.length > 10, `辽/${c.name} 有出处必须有限定语`);
assert.match(c.source, /辽史/);
}
});
// ── 内核:加了辽之后,那句话必须仍然成立 ──────────────────────────────────
test('★★ 内核:同一份 profile 在不同朝代,必须仍然给出不同的位阶(八朝)', () => {
const clerk = profileFromSubs(['rigorous', 'datadriven', 'concise', 'shortfirst', 'patient', 'calm'], { wang: 0 });
const ranks = DYNASTIES.map((d) => computeRank(clerk, d.id));
// ⚠ 2026-07-17 拆墙:朝数从引擎派生,不写死 —— 接朝只增不减,卖点断言(tier/官名多样)照旧。
assert.equal(ranks.length, DYNASTIES.length);
assert.ok(new Set(ranks.map((r) => r.rank.tier)).size >= 2, '所有朝代算出同一个 tier —— 卖点不成立');
assert.ok(new Set(ranks.map((r) => r.rank.title)).size >= 3, '官名太少,对照没有说服力');
// 确定性:加了辽也不许破。
for (const d of DYNASTIES) assert.deepEqual(computeRank(clerk, d.id), computeRank(clerk, d.id));
});
test('★ compareDynasties:八朝混合量纲,且仍然必须声明不可横着比', () => {
const clerk = profileFromSubs(['rigorous', 'datadriven', 'concise', 'shortfirst', 'patient', 'calm'], { wang: 0 });
const r = compareDynasties(clerk);
assert.equal(r.items.length, DYNASTIES.length, '★ 2026-07-17 拆墙:朝数从引擎派生');
assert.equal(r.mixedUnits, true);
assert.match(r.comparabilityNote, /可比附而不等值/);
});
test('★ 用户读得到的字段里,不许漏出工程术语(七朝全扫,含辽)', () => {
const leaks = /not_done|schema|ladder|weights|gate\b|字段|前端|TODO|FIXME|track/i;
for (const d of DYNASTIES) {
for (const [k, v] of Object.entries({ systemNote: d.systemNote, unitNote: d.unitNote })) {
assert.ok(!leaks.test(String(v || '')), `${d.name}.${k} 漏了工程术语`);
}
for (const c of d.channels) {
assert.ok(!leaks.test(String(c.gateNote || '')), `${d.name}/${c.name}.gateNote 漏了工程术语`);
}
}
});