547 lines
40 KiB
JavaScript
547 lines
40 KiB
JavaScript
// 多轨化(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 漏了工程术语`);
|
||
}
|
||
}
|
||
});
|