数字我 Digital Me:全量入库(大文件走 Git LFS)
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
// 朝代选拔制度表的单测 —— 这不是「覆盖率」测试,这是【卖点的可执行定义】。
|
||||
//
|
||||
// 这个功能的全部立足点是一句话:「你什么都没变,天花板全变了」。
|
||||
// 如果下面 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, '权重不该出给前端');
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user