Files
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

485 lines
32 KiB
JavaScript
Raw Permalink 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.
// 朝代选拔制度表的单测 —— 这不是「覆盖率」测试,这是【卖点的可执行定义】。
//
// 这个功能的全部立足点是一句话:「你什么都没变,天花板全变了」。
// 如果下面 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, '权重不该出给前端');
}
}
});