// 朝代选拔制度表的单测 —— 这不是「覆盖率」测试,这是【卖点的可执行定义】。 // // 这个功能的全部立足点是一句话:「你什么都没变,天花板全变了」。 // 如果下面 selling-point 那组断言挂了,说明同一个人在不同朝代结果差不多 —— // 那这个功能不用做了,这组测试就是最便宜的证伪。 // // 跑:node --test test/dynasties.test.js import test from 'node:test'; import assert from 'node:assert/strict'; import { AXES, DYNASTIES, computeAxes, computeRank, compareDynasties, listDynasties, getDynasty, profileFromSubs, } from '../src/dynasties.js'; import { SUB_REGISTRY } from '../src/talents.js'; // 样板人:吏能满格 + 沉毅,不善交游,无声望。 // 选他做样板,因为他正好卡在汉与魏晋的分水岭上: // 汉的辟召只问本事(四科「明达法令」),魏晋的中正只问门第与乡论。 const CLERK = ['rigorous', 'datadriven', 'concise', 'shortfirst', 'patient', 'calm']; const clerk = () => profileFromSubs(CLERK, { wang: 0 }); // ── 1. 纯函数性:这是整个功能的信誉基础 ── test('确定性:同一 profile 跑两次,结果必须完全相同', () => { for (const d of DYNASTIES) { const a = computeRank(clerk(), d.id); const b = computeRank(clerk(), d.id); assert.deepEqual(a, b, `${d.name} 两次结果不一致 —— 用户刷新一次就发现是瞎编的`); } }); test('computeRank 不接受未知朝代', () => { // ⚠ 这里原来用的是 'song' —— 2026-07-16 宋真的加进来了,这条断言当场失效。 // 换成一个【不打算做的】朝代 id。别再挑「下一个要做的朝代」当哨兵, // 否则每加一朝就要来改一次,而且改的时候很容易顺手把这条测试删了。 assert.equal(computeRank(clerk(), '__not_a_dynasty__'), null); assert.equal(getDynasty('__not_a_dynasty__'), null); }); // ── 2. 卖点:同一个人,天花板全变了 ── test('卖点:同一份 profile 在不同朝代,必须给出不同的位阶', () => { const ranks = DYNASTIES.map((d) => computeRank(clerk(), d.id)); const tiers = new Set(ranks.map((r) => r.rank.tier)); assert.ok(tiers.size >= 2, '所有朝代算出同一个 tier —— 卖点不成立,这个功能可以毙了'); const titles = new Set(ranks.map((r) => r.rank.title)); assert.ok(titles.size >= 3, `只有 ${titles.size} 种官名,对照没有说服力`); }); test('卖点·具体案例:吏能满格但不善交游、无声望的人', () => { const han = computeRank(clerk(), 'han'); const weijin = computeRank(clerk(), 'weijin'); const nanchao = computeRank(clerk(), 'nanchao'); // 汉:察举重乡论,他上不去;但辟召只问本事,他上得去。这是汉的关键。 assert.equal(han.channel.id, 'bizhao', '汉应当走辟召(四科·明达法令)'); assert.equal(han.rank.title, '县令'); assert.equal(han.rank.grade, '六百石'); assert.ok( han.blocked.some((b) => b.channel === '察举孝廉'), '察举那条路必须对他明确关着 —— 他没有乡誉' ); // 魏晋:中正问门第、清谈问机锋、军功问果决 —— 三条路一条都不对他开。 assert.equal(weijin.channel, null, '魏晋应当无路可走'); assert.equal(weijin.rank.tier, 0); assert.equal(weijin.rank.title, '不入流'); assert.equal(weijin.blocked.length, 3, '魏晋三条路必须全部标记为关着,并说明差在哪'); // 南朝:同一套本事,掌了天下机要 —— 而史书把他归进《倖臣传》。 // 「权力有了,身份没有」:这是整个功能最想让人看见的一格。 assert.equal(nanchao.channel.id, 'sheren'); assert.equal(nanchao.rank.title, '中书通事舍人'); // ⚠ 2026-07-17 第四刀:grade 由 '品卑而权重' 改为 '品卑而权重(志作「通事舍人無員」…)'。 // ★ 改的是【后面挂了原文】,不是改口径:「品卑」这两个字一个都没动。 // 照辽的体例办(辽的 grade 逐字就是「南面·属县(志作「分以刺史、县令」)」)。 // ⇒ 断言改为前缀匹配 + 单独验那句原文,比原来【更严】:原来只验了本表的话,现在连出处一起验。 assert.match(nanchao.rank.grade, /^品卑而权重/, '「品卑」的口径一个字都不许动'); assert.match( nanchao.rank.grade, /通事舍人無員/, '★ 「品卑」不是本表的判断,是志【不系品】:卷16 只作「通事舍人無員」(双证)' ); assert.equal(nanchao.rank.unit, 'sheren', '★ 舍人走的是他自己那把尺,不是士族的官品'); assert.equal(nanchao.category, '倖臣'); assert.equal(nanchao.stigma, true); // 同一个人:南朝掌机要,魏晋不入流。这就是产品。 assert.ok(nanchao.rank.tier > weijin.rank.tier + 2, '南朝与魏晋的落差必须足够大才有冲击力'); }); test('headline 必须说出那句话', () => { const r = compareDynasties(clerk()); assert.match(r.headline, /你什么都没变/); // ★ 高位那一格是「寒人/恩幸掌机要」主题(clerk 吏能满格、门第为零):随朝代排序的平局 tie-break, // 可能是南朝·中书通事舍人,也可能是刘宋·恩幸侍中(二者 tierNorm 并列;2026-07-30 编年排序后刘宋先排、赢平局)。 // ⚠ 不写死是哪一朝/哪个官 —— 那是稳定排序的产物,会随接朝/排序漂移;只钉「高位是寒人掌机要之职」这个不变量。 assert.match(r.headline, /舍人|侍中|尚书|散騎|散骑/, '★ 高位那一格应是寒人/恩幸掌机要之职(舍人/侍中/尚书之属)'); // ⚠ 2026-07-17 接 25 朝:headline 的【最低那一格】是一个 tier0 —— 而【哪一朝占最低】随接朝而变 // (加西汉时是「西汉·斗食」;接了曹魏/东晋后,稳定排序里更靠后的东晋「不入流」占了最低位)。 // ★ 两者都是 tier0:clerk 吏能满格,而这些朝的尺子一条都不量他会的(西汉六轴瞎、魏晋看门第)。 // ⇒ 不变量 =【最低那一格是一个 tier0 白身(不入流 / 斗食小吏)】,这正是「你什么都没变,天花板全变了」。 // ✗ 不写死是哪一朝 —— 那是稳定排序的产物,会随接朝漂移;写死它就又是一道墙。 // ⚠ 2026-07-30 归一化 tierNorm + 编年排序后:最低那一格仍是 tier0 白身,但【哪一朝】随排序漂移 // (旧稳定序是东晋「不入流」/西汉「斗食」;新序可能是别朝的 tier0 白身,如隋/北齐「流外」)。 // ⇒ 正则覆盖全表各朝的 tier0 白身之名(不入流/斗食/流外/白身/无品/未入流),只钉「最低=白身」这个不变量。 assert.match(r.headline, /不入流|斗食|流外|白身|無品|无品|未入流/, '★ 最低那一格必须是一个 tier0 白身(六轴瞎 / 看门第,吏能满格也上不去)'); }); // ── 3. 通道即杠杆:择路必须按天花板,不按原始分 ── test('明代:能过科举就别去当吏 —— 择路按最终位阶,不按原始分', () => { const ming = computeRank(clerk(), 'ming'); // 吏员出职的原始分更高(吏能权重 0.55),但它的天花板封在正七品。 // 若按原始分择路,算法会让一个考得上科举的人去当吏,一辈子锁死 —— 这是回归测试。 assert.equal(ming.channel.id, 'keju', '应当走科举正途(天花板高),而不是吏员出职(原始分高但封顶)'); assert.ok(ming.assimilation > 0, '走正途必须带同化代价'); }); // ── 3b. 宋(熙宁)—— 加第六朝时新立的断言 ── // // 这一组的用意不是「覆盖宋」,是钉死宋【凭什么值得加】: // 门第这个变量,在魏晋是通往三公的钥匙,在熙宁是一条封着顶的岔路。同一个 wang,两头相反。 // 如果哪天有人把这组改绿了,多半是他动了 keju_song 的 gate —— 那就等于把宋加进来的理由删了。 test('★ 宋的立意:同一个门第,在西晋通向三公,在熙宁通向一条封着顶的路', () => { // 只有门第,没点任何副天赋 —— 一个「什么都不会,但投了个好胎」的人。 const noble = profileFromSubs([], { wang: 80 }); const wj = computeRank(noble, 'weijin'); assert.equal(wj.channel.id, 'zhongzheng', '西晋:门第直接把中正一途打开'); assert.equal(wj.ceiling.tier, 5, '西晋:门第这条路本身通到顶(尚书/中书监)'); assert.equal(wj.stigma, false, '西晋:门第是清望,不是污点'); const song = computeRank(noble, 'song'); assert.equal(song.channel.id, 'yinbu', '熙宁:门第进不了科举,只能走恩荫'); assert.equal(song.ceiling.tier, 3, '熙宁:恩荫封在知州 —— 门第买得到门,买不到顶'); assert.equal(song.stigma, true, '熙宁:非进士出身是终身的那四个字'); // 这才是这一朝加进来的全部理由:同一个变量,天花板差了两级、且一边带污名。 assert.ok( wj.ceiling.tier > song.ceiling.tier, '门第的天花板在西晋必须【高于】北宋 —— 否则宋这一朝在本表里没有意义' ); }); // ⚠ 2026-07-16 第二刀(删加权求和)改了这条测试的【一处字段名】,改动的口径记在这里: // 本条原文是 `assert.equal(keju.weights.wang, undefined)` —— 而 weights 这个字段 // 整个被删了(七朝原文里没有加权求和的原型),于是 keju.weights 是 undefined, // 这条断言【报的是 TypeError,不是「wang 混进来了」】。它不是「结论冲突」,是字段没了。 // ★ 断言的【意思一个字没改】,覆盖面反而变大:wang 不许出现在这条通道的 // 【任何一处判定口径】里 —— gate(开不开)、key(排序键)、kemu(可数科目),三处全扫。 // ✗ 而「结论冲突」那一类(新引擎算出来的位阶与旧测试对不上)本刀【一个都没改测试】, // 因为一个都没发生:卖点那几条全绿。 test('★ 宋是全表唯一一朝:wang 不进任何一条通道的 gate 与【选】(糊名誊录的后果)', () => { const song = getDynasty('song'); const keju = song.channels.find((c) => c.id === 'keju_song'); // 正途里绝不许出现 wang —— 考官不知道你是谁,这是糊名誊录的直接后果。 assert.equal(keju.gate.wang, undefined, '科举正途的 gate 里不许有 wang —— 糊名之下考官不知道你是谁'); assert.ok(!keju.xuan.key.includes('wang'), '科举正途的排序键里不许有 wang'); assert.ok(!keju.xuan.kemu.some((k) => k.axis === 'wang'), '科举正途的可数科目里不许有 wang'); // 而门第没有消失,它被赶到了恩荫那条路上 —— 「不看门第」不等于「门第不存在」。 const yin = song.channels.find((c) => c.id === 'yinbu'); assert.ok(yin.gate.wang > 0, '门第没有消失,它被赶到恩荫那条路上去了'); assert.equal(yin.ceiling.tier, 3, '而那条路封着顶'); }); test('★ 熙宁与盛唐的分水岭:一个纯诗人,唐进得去,熙宁进不去', () => { // 纯文采:唐的进士科 gate{wen:50} 开着;熙宁殿试「专以策」,gate{fa:55,heng:50} 关着。 // 这不是我们编的对照 —— 是神宗自己把尺子换了(「愈于以诗赋取人尔」)。 const poet = profileFromSubs(['poetic', 'story', 'vivid'], { wang: 0 }); const tang = computeRank(poet, 'tang'); assert.equal(tang.channel.id, 'jinshi', '盛唐:进士重诗赋,纯诗人正该由此进'); const song = computeRank(poet, 'song'); assert.equal(song.channel, null, '熙宁:殿试专以策,纯诗人无路 —— 一个字都没变,尺子换了'); assert.ok( song.blocked.some((b) => b.channel.includes('科举')), '而且必须明说科举那条路对他关着,差在哪一项' ); }); test('★ 回归:同 tier 时必须按天花板择路,不许按原始分(宋恩荫捅出来的洞)', () => { // 有门第、又能考的人:科举(ceiling 5) 与 恩荫(ceiling 3) 算出来【同为 tier 3】, // 而恩荫的原始分更高(wang 权重 0.55)。若按原始分择路,引擎会把一个考得上的人判去荫补, // 平白给他一个 stigma、天花板从宰执掉到知州。 // 这与「明代能过科举就别去当吏」是同一条规矩 —— 那条靠 tier 就分出来了,这条不行。 const nobleClerk = profileFromSubs(CLERK, { wang: 80 }); const r = computeRank(nobleClerk, 'song'); assert.equal(r.channel.id, 'keju_song', '考得上的人必须去考 —— 不许因为恩荫原始分高就判他去荫补'); assert.equal(r.ceiling.tier, 5, '他的天花板必须是宰执,不是知州'); assert.equal(r.stigma, false, '他不该平白背上「非进士出身」'); // 且恩荫那条路对他其实是【开着】的 —— 引擎是「挑」了科举,不是「只能」走科举。 const yin = getDynasty('song').channels.find((c) => c.id === 'yinbu'); assert.ok(nobleClerk.wang >= yin.gate.wang, '前提校验:恩荫对他确实开着,否则这条测试没在测东西'); }); test('通道的 ceiling 必须真的封顶', () => { // 造一个吏能爆表但只配走吏员出职的人:明代吏员 ceiling.tier=2 const p = profileFromSubs(['rigorous', 'datadriven', 'concise', 'shortfirst'], { wang: 0 }); for (const d of DYNASTIES) { const r = computeRank(p, d.id); if (r.channel) { const ch = getDynasty(d.id).channels.find((c) => c.id === r.channel.id); assert.ok(r.rank.tier <= (ch.ceiling?.tier ?? 5), `${d.name}/${ch.name} 越过了自己的天花板`); } } }); // ── 4. 声望改变命运:制度不同,同一个变量的分量不同 ── test('给他一个门第:魏晋从不入流变成有路可走,而汉基本不动', () => { const poor = profileFromSubs(CLERK, { wang: 0 }); const noble = profileFromSubs(CLERK, { wang: 80 }); const wjPoor = computeRank(poor, 'weijin'); const wjNoble = computeRank(noble, 'weijin'); assert.equal(wjPoor.channel, null); assert.ok(wjNoble.channel, '有门第之后,中正一途必须开'); assert.ok(wjNoble.rank.tier > wjPoor.rank.tier, '门第在魏晋必须显著改变结果'); const hanPoor = computeRank(poor, 'han'); const hanNoble = computeRank(noble, 'han'); assert.equal(hanPoor.channel.id, hanNoble.channel.id, '门第不该改变汉的辟召一途 —— 它只问本事'); assert.equal(hanPoor.rank.tier, hanNoble.rank.tier); // ↑ 这两条合起来,才是「性格 × 制度 = 命运」:同一个变量,在两朝的分量完全不同。 }); // ══════════════════════════════════════════════════════════════════════════ // ★★★★★ 2026-07-17 加西汉 + 经学轴:【兒寬 gate 证明】 // 这是这一刀要坐实的那件事:西汉「六轴瞎」,而补了第七轴「经学」之后,博士弟子那条路当场能跑。 // 兒寬画像「善著書、口不能發明」——renwu.js 给他 subs=[warm, calm],前六轴给他 wen=40(说话瞎)。 // ⇒ 构造两个 profile:一个有 jingxue_tong(通经)、一个没有。跑西汉博士弟子那条路: // 通经的 gate 开(tier↑)、不通经的 gate 关(tier0)。 // ⇒ 这就是「六轴瞎(wen=40)、经学不瞎(63 开路)」的证明。 // ══════════════════════════════════════════════════════════════════════════ test('★★★★★ 西汉·兒寬 gate 证明:六轴瞎(wen=40)而经学不瞎(63 开路)—— 通经 gate 开、不通经 gate 关', () => { const buTong = profileFromSubs(['warm', 'calm'], { wang: 0 }); // 不通经 const tong = profileFromSubs(['warm', 'calm', 'jingxue_tong'], { wang: 0 }); // 通经 // ① 前提:两人的六轴【完全一样】,尤其 wen=40(说话瞎)—— 只有 jingxue 一维不同(正交)。 assert.equal(buTong.axes.wen, 40, '兒寬说话瞎:wen=40(renwu.js FINDINGS ③:文采轴不量写作)'); assert.equal(tong.axes.wen, 40, '★ 加了 jingxue_tong,wen 一格没动 —— 经学与六轴正交'); assert.equal(buTong.axes.jingxue, 40, '不通经:jingxue=40'); assert.equal(tong.axes.jingxue, 63, '通经:jingxue=63(binary,只此两值)'); // 六轴逐条相同 —— 除 jingxue 外,两个 profile 完全一样。 for (const ax of ['wen', 'fa', 'wu', 'jiao', 'heng', 'tan']) { assert.equal(buTong.axes[ax], tong.axes[ax], `${ax} 必须相同 —— 只有 jingxue 该变`); } const rBu = computeRank(buTong, 'xihan'); const rTong = computeRank(tong, 'xihan'); // ② 不通经:博士弟子的闸【关着】(jingxue 40 < 55)⇒ 落到盲路 ⇒ tier 0(斗食)。 assert.notEqual(rBu.channel.id, 'boshi_dizi', '不通经 ⇒ 走不了博士弟子'); assert.equal(rBu.rank.tier, 0, '★ 不通经 ⇒ gate 关 ⇒ tier 0(斗食、佐史)'); const boshiBlocked = rBu.blocked.find((b) => b.channel.includes('博士弟子')); assert.ok(boshiBlocked, '★★ 博士弟子对不通经的人【关着】,且在 blocked 里'); assert.deepEqual( boshiBlocked.missing, [{ axis: 'jingxue', axisName: '经学', need: 55, got: 40 }], '★ 关的理由是【经学差(55 vs 40)】—— 而不是六轴的任何一维' ); // ③ 通经:同一个说话瞎的人(wen 还是 40),博士弟子的闸【开了】⇒ tier 从 0 升到 1,天花板 tier5。 assert.equal(rTong.channel.id, 'boshi_dizi', '★★★ 通经 ⇒ 走博士弟子(gate 开)'); assert.equal(rTong.rank.tier, 1, '★ 通经 ⇒ tier 从 0 升到 1(通一藝)'); assert.equal(rTong.ceiling.tier, 5, '★★ 而这条路的天花板是御史大夫(tier5)—— 兒寬走到这的实证'); // ⇒ ★★★★★ 同一个说话瞎(wen=40)的人,只因「通经」这一维不同,博士弟子那条路从关(tier0·斗食) // 变成开(tier1·郎,天花板御史大夫)。⇒ 六轴瞎,经学不瞎。这就是那条要补的轴。 assert.ok(rTong.rank.tier > rBu.rank.tier, '★ 通经的人比不通经的人高 —— 而两人说话一样瞎'); }); // ══════════════════════════════════════════════════════════════════════════ // ★★ 2026-07-17 第四刀:这条测试【改名了】,而改名的过程本身值得记下来。 // // 【它原来叫】「魏晋的门第是成本不是禁令:寒素走中正一途【要挨罚】,但不封死」 // ★★★ 而它的函数体【从来没有验过「挨罚」这件事】—— 它只验了「清谈那条路开着」。 // ⇒ 于是 penaltyIfNoWang 这个死代码,在一条【名字里写着它、身体里不碰它】的测试底下 // 躺了很久。★ 测试是绿的,名字是假的,代码是死的 —— 三样凑齐,没人会发现。 // ⚠ 这正是「空操作比缺席更坏」的第二层:连它的测试都是空操作。 // 一条测试的【名字】不是断言,只有 assert 才是。名字骗得过人,骗不过 penalty 那一行 —— // 可惜从来没人去读那一行。 // ⇒ 新名字只说这条测试【真的验了什么】。「挨罚」那件事本表现在【不做了】(penalty 已删), // 而门第的成本由 gate{wang:55} 表达:不够的人这条路【关着】,blocked 会逐条告诉他。 // ⚠ 已知的失真(not_done ④ 记着):gate 只有【关/开】两档, // 表达不了「可以走,只是更难」——而本文件的铁律要的正是那一档。✗ 但不许再拿一个不跑的 // penalty 冒充「我们表达了」。 // ══════════════════════════════════════════════════════════════════════════ test('魏晋的门第是成本不是禁令:无门第但有机锋者,清谈一途必须开 —— 不许写死「寒门锁死」', () => { const p = profileFromSubs(['socratic', 'humor', 'poetic', 'story', 'vivid'], { wang: 60 }); const r = computeRank(p, 'weijin'); assert.ok(r.channel, '有清谈之才者,魏晋必须有路'); // 不许出现「寒门=县令」的硬编码:门第低的人只要有别的本事,仍应高于 tier 1 const noWang = computeRank(profileFromSubs(['socratic', 'humor', 'poetic', 'story', 'vivid'], { wang: 0 }), 'weijin'); assert.ok(noWang.channel, '无门第但有机锋者,清谈一途必须开 —— 不许写死寒门锁死'); // ★ 门第的成本【在 gate 上】,不在一个 penalty 上:无门第 ⇒ 中正那条路对他关着,且必须说出来。 assert.ok( noWang.blocked.some((b) => b.channel === '中正定品'), '★ 门第不够 ⇒ 中正那条路【关着】—— 这就是本表现在表达「成本」的全部方式' ); }); test('★★★ penaltyIfNoWang 不许复活 —— 它是本表关于「空操作」的标本', () => { // ★ 这条测试是【第四刀删掉那个死代码之后补的】,它守的是「不许有人把它加回来」。 // 为什么需要它:那个 penalty 在文件里躺了很久,两个文件(dynasties.js 文件头、lao.js // LEX_NOOP)都引它当【反面教材】,而它本身还在数据里活着。删了它,就得有人守着门。 for (const d of DYNASTIES) { for (const c of d.channels) { assert.equal( c.penaltyIfNoWang, undefined, `${d.name}/${c.name}: penaltyIfNoWang 回来了 —— ` + '★ 它是【可证明的空操作】:触发条件是 wang < gate.wang,而 wang 不够那条路就已经关了,' + 'tier 只对开着的路算。⇒ 它永远不会跑,却在数据里长得和一条生效的规则一模一样。' ); assert.equal(c.penaltyNote, undefined, `${d.name}/${c.name}: penaltyNote 是那个空操作的文案,一起走`); } } // ★ 而「门第是成本不是禁令」这条史学判断【没有跟着走】—— 它由 gate 表达,必须还在。 const zz = getDynasty('weijin').channels.find((c) => c.id === 'zhongzheng'); assert.equal(zz.gate.wang, 55, '★ 删的是空操作,不是那条史学判断:门第的成本仍在 gate 上'); }); // ── 5. 合规:不得过度迎合(《拟人化互动办法》第八条(五))── test('合规:结果里必须包含「哪条路对你关着」,不能只有好话', () => { for (const d of DYNASTIES) { const r = computeRank(clerk(), d.id); assert.ok(Array.isArray(r.blocked), `${d.name} 缺 blocked 字段`); if (r.blocked.length) { // 关着的路必须说清楚【为什么】关着 —— 这是「约束」,不是「奉承」。 // ★★ 2026-07-17:这里【分成两种关法】,因为出身进来之后,「关着」不再只有一种: // · 差多少(missing):你差 55 分的吏能 —— ★ 这个你补得上,所以必须告诉你差多少。 // · 出身(byChushen):你生在汉 —— ★ 这个【补不上】,所以它【没有】「差多少」。 // ✗ 硬给它编一个数(「你差 55 分的契丹」)是这个功能能犯的最蠢的错。 // ⇒ 合规要的是【约束看得见】(第八条(五)),不是【每条约束都是一个数】。 // 两种关法都得说清楚,但说清楚的方式不同。 for (const b of r.blocked) { if (b.byChushen) { assert.deepEqual(b.missing, [], `${d.name}/${b.channel}:出身不是一个差多少的缺口,不许给它编一个数`); assert.match(b.note, /你生在|还没定/, `${d.name}/${b.channel} 说出身关着,却没说是哪一部传的哪个设定`); } else { assert.ok(b.missing.length > 0, `${d.name}/${b.channel} 说关着却没说差在哪`); for (const m of b.missing) { assert.ok(m.axisName && typeof m.need === 'number' && typeof m.got === 'number'); } } } assert.ok( r.reasons.some((s) => s.includes('对你关着')), `${d.name} 的 reasons 必须把关着的路说出来(第八条(五):不得过度迎合用户)` ); } } }); // ── 6. 史料:注必须挂得住 ── test('每条带 source 的通道,注里必须同时给出处、原文和限定语', () => { for (const d of DYNASTIES) { for (const c of d.channels) { if (!c.source) continue; assert.ok(c.sourceText, `${d.name}/${c.name} 有出处却无原文`); // 有争议的史料必须带 caveat —— 「上品无寒门」是政论不是制度,这类必须标注 if (c.sourceCaveat) assert.ok(c.sourceCaveat.length > 10); } } }); test('compareDynasties 必须声明秩石与官品可比附而不等值', () => { const r = compareDynasties(clerk()); assert.equal(r.mixedUnits, true, '汉用秩石、宋用差遣、其余用官品,必须标记为混合量纲'); assert.match(r.comparabilityNote, /可比附而不等值/); assert.equal(r.items.length, DYNASTIES.length); }); // ── 6b. 史料纪律:宋这一朝在核实中挡下的坑,钉住,别让人「补全」回来 ── test('★ 宋:三个查不实的说法,不许被「补全」回来', () => { const song = getDynasty('song'); const all = JSON.stringify(song); // (1) 「殿试遂为常制」的年份:两个见证作「八年」/「是年」,真异文,判不了 → 不许安年份。 assert.ok( !/殿试遂为常制[^」]{0,12}(八年|开宝)/.test(all), '不许给「殿试遂为常制」安年份 —— 紧邻句两证作「八年」/「是年」,判不了' ); // (2) 糊名冠「先是」,原文自己把它推到淳化三年之前 → 不许写「淳化三年始糊名」。 assert.ok(!/淳化三年[^。]{0,10}糊名/.test(all), '不许写「淳化三年始糊名」—— 原文作「先是」'); // (3) 「熙宁四年罢诗赋」不在《宋史·选举志》(卷155/156 零命中) → 一个字都不许出现在引文里。 assert.ok( !/熙宁四年[^。」]{0,8}(罢诗赋|专试经义)/.test(all), '「熙宁四年罢诗赋」不在宋史选举志 —— 通行说法≠我目验过。本表只写「熙宁三年,殿试始专以策」' ); // 而该写的那句必须在,且必须是【殿试】不是省试。 const keju = song.channels.find((c) => c.id === 'keju_song'); assert.match(keju.sourceText, /熙宁三年,亲试进士,始专以策/); assert.match(keju.sourceCaveat, /殿试/, 'caveat 必须点明「专以策」说的是殿试,不是省试'); }); test('★ 东汉:御史大夫不许复活 —— 时代错置 + 秩石无据(2026-07-16 curl 核实后删)', () => { const han = getDynasty('han'); assert.equal(han.era, '东汉'); // 前提:本条断言只在 era=东汉 时成立 const all = JSON.stringify(han); // ① 时代错置:御史大夫绥和元年(前8)即罢,东汉三公是太尉/司徒/司空; // 本朝仅建安十三年(208)曹操罢三公时复置一次,那不是辟召能指望的常规天花板。 // ✗ 不许出现在任何 ceiling.title 里。 for (const c of han.channels) { assert.ok( !/御史大夫/.test(c.ceiling?.title || ''), `东汉通道「${c.name}」的天花板不许写御史大夫 —— 那是西汉官(前8 罢),东汉作司空` ); } // ② 连带:本朝 ladder 也不许把它当一级。 // ⚠ 2026-07-16 多轨化:ladder 已从 d.ladder 下沉到 d.tracks[].ladder。 // 这里【扫全部 track】而不是只扫一条 —— 断言的意思一个字没改,覆盖面反而变大了: // 将来东汉若真长出第二条梯子,这条断言自动跟着守。 for (const t of han.tracks) { for (const s of t.ladder) { assert.ok(!/御史大夫/.test(s.title || ''), '东汉 ladder 不许出现御史大夫'); } } // ③ 若哪天有人为了「讲清楚」而在注释外的数据里提它,必须同时讲明它不是东汉常制。 // 这里只守数据:整个东汉条目的可读字段里一个「御史大夫」都不该有。 assert.ok(!/御史大夫/.test(all), '东汉条目的数据里不许出现御史大夫(注释不受此限)'); // 而该写的那个必须在,且秩石必须对:《续汉书·百官志二》正文「廷尉,卿一人,中二千石。」 const bizhao = han.channels.find((c) => c.id === 'bizhao'); assert.equal(bizhao.ceiling.title, '廷尉', '辟召的天花板是廷尉——九卿里管法的那个,与「明达法令」对得上'); assert.equal(bizhao.ceiling.grade, '中二千石'); // ✗ 不许「改成 廷尉 / 司空」来凑一对:司空是三公,不是中二千石,grade 会跟着错。 assert.ok(!/司空/.test(bizhao.ceiling.title), '不许拿司空来凑 —— 它不是中二千石'); }); test('★ 用户读得到的字段里,不许漏出工程术语(systemNote 是 reasons[0],不是给我们看的)', () => { // 加宋时真的漏过一次:systemNote 里写了「原因见 not_done」,直接进了用户看到的第一句。 // 表的内部限制该不该告诉用户 → 该(「这是表的缺,不是宋的缺」); // 但「为什么装不下」是我们的事,用户不该读到 schema/ladder/not_done 这种词。 const leaks = /not_done|schema|ladder|weights|gate\b|字段|前端|TODO|FIXME/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 漏了工程术语`); } } }); test('★ 宋:每条挂了 source 的通道都必须标明双证核实过(这一朝是新加的,别开先例)', () => { for (const c of getDynasty('song').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} 有出处必须有限定语`); } }); // ── 7. 回归守卫:talents.js 加了新副天赋而这里没跟上 ── test('AXES 必须覆盖 talents.js 的全部副天赋,一个都不许漏', () => { const inAxes = new Set(AXES.flatMap((a) => a.subs)); const missing = SUB_REGISTRY.map((s) => s.id).filter((id) => !inAxes.has(id)); assert.deepEqual( missing, [], `这些副天赋没有归到任何取向轴,会被静默忽略:${missing.join(', ')}` + ' —— talents.js 加了新项就必须回来更新 AXES' ); const unknown = [...inAxes].filter((id) => !SUB_REGISTRY.some((s) => s.id === id)); assert.deepEqual(unknown, [], `AXES 引用了 talents.js 里不存在的副天赋:${unknown.join(', ')}`); }); test('computeAxes:没点过的轴是基线 40,不是 0', () => { const a = computeAxes([]); for (const ax of AXES) assert.equal(a[ax.id], 40, '空 profile 的每条轴都该是基线'); const b = computeAxes(['poetic']); assert.ok(b.wen > 40 && b.fa === 40); }); test('listDynasties 只吐展示字段,不吐 gate/weights(那是内部评分口径)', () => { for (const d of listDynasties()) { assert.ok(d.id && d.name && d.system && d.unitName); for (const c of d.channels) { assert.equal(c.gate, undefined, '闸门阈值不该出给前端'); assert.equal(c.weights, undefined, '权重不该出给前端'); } } });