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

495 lines
36 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.
// 出身的单测 —— 2026-07-17 第三刀立的。
//
// 这个文件守四件事,重要性从高到低:
// ①【辽的择路必须由出身决定,不再由能力替代】—— 老板的原话:「这是这一维存在的全部理由,
// 要有断言钉死」。它在 test/dynasties-tracks.test.js 里也钉了一遍(那边是从引擎那头钉的),
// 这边从数据与合规那头钉。
// ②【合规】出身是【这部书的设定】,不是用户的属性:不许指向用户本人的措辞、不许上 twins。
// ③【引用完整性】chushen.js 点名的每条通道,必须在 dynasties.js 里真的存在。
// ④【不许做成跨朝通用枚举】—— 这条是数据形状的断言,不是口号。
//
// 跑:node --test test/chushen.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
CHUSHEN, ABSENT, SHAPES, ABSENCE_KINDS,
hasChushen, chushenOf, getChushenValue, citeChushen, absenceOf, closedBy,
} from '../src/chushen.js';
import { DYNASTIES, getDynasty, computeRank, compareDynasties, profileFromSubs } from '../src/dynasties.js';
import { openLineage, rankSnapshot, assertNoGameMechanics } from '../src/decisions.js';
const chIds = (dynId) => new Set(getDynasty(dynId).channels.map((c) => c.id));
// ══════════════════════════════════════════════════════════════════════════
// ① ★★★ 这一维存在的全部理由
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 辽:同一个人,只换出身 → 择路必须变(这一维存在的全部理由)', () => {
// 能力一个字都没变:纯吏能。
const clerk = profileFromSubs(['rigorous', 'datadriven', 'concise', 'shortfirst'], { wang: 0 });
const q = computeRank({ ...clerk, chushen: 'qidan' }, 'liao');
const h = computeRank({ ...clerk, chushen: 'han' }, 'liao');
assert.equal(q.channel.id, 'wenquan_liao');
assert.equal(h.channel.id, 'nanmian_hanguan');
assert.notEqual(q.channel.id, h.channel.id, '★ 能力相同、出身不同 ⇒ 路必须不同');
// ★★ 而且【武人也一样】:出身在能力之前先问。
const warrior = profileFromSubs(['blunt', 'motivate'], { wang: 0 });
assert.equal(computeRank({ ...warrior, chushen: 'qidan' }, 'liao').channel.id, 'wuquan_liao');
// 生在汉的武人:北面武铨的铨管的是【契丹军马】,不覆盖他;而南面那条路本表 gate 的是吏能。
// ⇒ 他没有路。★ 这是本表的缺(见 NOT_DONE),不是史料的缺 —— 但它【绝不许】再由能力替出身答。
assert.equal(
computeRank({ ...warrior, chushen: 'han' }, 'liao').channel, null,
'★ 生在汉的武人不许被塞进北面武铨 —— 那正是「拿能力替出身做决定」'
);
});
test('★★★ 辽:出身没定 ⇒ 引擎不许替他答', () => {
const clerk = profileFromSubs(['rigorous', 'datadriven', 'concise', 'shortfirst'], { wang: 0 });
const r = computeRank(clerk, 'liao');
assert.equal(r.channel, null, '没定出身 ⇒ 不许挑一条路出来');
assert.equal(r.chushen.missing, true);
assert.ok(r.reasons.some((s) => /还没定/.test(s)), '必须当着用户的面说这一问没答');
const shut = closedBy('liao', null);
assert.equal(
getDynasty('liao').channels.every((c) => shut.closed.has(c.id)), true,
'没定出身 ⇒ 辽的每一条【属】的路都必须关着(三条通道全在某个值的 shu 上)'
);
// ★★ 每一条关着的路,理由都必须是【出身的理由】,而不是「你哪一项不够」。
// 一个没定出身的人,被告知「你差 55 分的吏能」,那是答非所问:本表还没问他那一问呢。
for (const b of r.blocked) {
assert.equal(b.byChushen, true, `${b.channel} 该由出身关着`);
assert.deepEqual(b.missing, [], '出身不是一个差多少的缺口');
assert.match(b.note, /还没定/, '而且要说清楚是哪一问没答');
}
});
// ══════════════════════════════════════════════════════════════════════════
// ★★★ 2026-07-17 验收抓到的一处,钉在这里。
//
// 【dynasties.js 的原话,逐字】:「出身关的路【永远不算「最近的一条」】…
// ⚠ 若不排掉它,会出现一个很难看的错:一个出身关掉的路 missing.length=0,
// 排序时反而排在最前面 —— 引擎会告诉一个汉人「你最接近的是北面武铨」。」
//
// ★ 那句注释是对的,而它下面那行代码【没有做到它】:旧写法把 shenClosed 放在
// 【排序键的第一级】—— 那只是【排在后面】,不是【不参选】。
// ⇒ 排序是相对的,它只在【还有别的路可比】时才管用。而辽·没答出身时【三条路全关】,
// 那一级键全程是 0(空操作)⇒ 掉到 missing.length ⇒ 北面武铨的 gate{wu:55} 本来就过
// ⇒ 它排第一 ⇒ 引擎对一个【还没答出身的人】说「你最接近北面武铨」。
// ★★ 与本刀已修的两处【同形,第三例】:seat 4→3(「没人落在坑里≠坑填了」)、
// t0 的 unit(「他没进任何一条路,我们却先替他站了队」)—— nearest 就在那一格上面 20 行。
//
// ⇒ 这条断言钉的是【不参选】,不是【排在后面】。两者的差别只在「全关」这一格上显出来 ——
// 而那一格【正是这一维存在的理由】。
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 辽:没答出身 ⇒ 连「你最接近哪条路」都不许答 —— 排在后面 ≠ 不参选', () => {
const wu = profileFromSubs(['blunt', 'motivate'], { wang: 0 }); // wu=73 ⇒ 武铨的 gate 本来就过
const r = computeRank(wu, 'liao');
assert.equal(r.chushen.missing, true, '前提:这一问没答');
assert.equal(r.channel, null, '前提:一条路都不开');
// ★ 前提:这个人【恰恰过得了】武铨的 gate —— 否则这条测试是空操作(旧写法也会绿)。
const wuquan = r.blocked.find((b) => b.channel === '北面 · 武铨');
assert.ok(wuquan && wuquan.byChushen, '前提:武铨是被【出身】关的,不是被 gate 关的');
assert.deepEqual(wuquan.missing, [], '前提:他一项都不差 —— 这正是旧写法让它排第一的原因');
// ★★ 结论:三条路全被出身关着 ⇒ 没有一条是「最近的」⇒ null。
// ✗ 不许兜底挑一条:兜底就是替他站队,而站队正是这一维要删的那个失真。
assert.equal(
r.nearest, null,
'★ 没答出身的人,「你最接近哪条路」这个问题问不出口 —— 不许给他挑一条出身关着的路'
);
});
test('★★ 而【答了】之后,nearest 必须照常答得出来 —— 别为了堵洞把它堵死', () => {
const wu = profileFromSubs(['blunt', 'motivate'], { wang: 0 });
// 生在汉:北面两条路的铨不覆盖他(出身关);南面·汉官是他这一路,但 gate{fa:55} 他过不了。
const h = computeRank({ ...wu, chushen: 'han' }, 'liao');
assert.equal(h.channel, null, '前提:生在汉的武人,本表接不住他(已知的缺,不是史料的缺)');
assert.equal(
h.nearest, '南面 · 汉官',
'★ 他最接近的必须是【他这一路】上那条差分数的,不是出身关着的北面武铨'
);
// ⚠ 这一条才是上面那句注释原本要防的那个错,它现在真的被防住了。
const closed = closedBy('liao', 'han');
assert.equal(closed.closed.has('wuquan_liao'), true, '北面武铨对他是出身关着的');
assert.notEqual(h.nearest, '北面 · 武铨', '★ 引擎绝不许告诉一个汉人「你最接近北面武铨」');
});
test('★★ 明:生在倡優之家 ⇒ 正途关着,偏路还开着(原文只禁「入試」)', () => {
// 能过明科举的人:heng >= 55, fa >= 50
const scholar = profileFromSubs(['rigorous', 'datadriven', 'patient', 'calm'], { wang: 0 });
const normal = computeRank(scholar, 'ming');
const cy = computeRank({ ...scholar, chushen: 'changyou' }, 'ming');
assert.equal(normal.channel.id, 'keju', '没说生在倡優之家 ⇒ 科举正途开着');
assert.equal(normal.ceiling.tier, 5, '正途通到内阁大学士');
assert.equal(cy.channel.id, 'lisi', '★ 生在倡優之家 ⇒ 只剩吏员出职');
assert.equal(cy.ceiling.tier, 2, '★★ 天花板从内阁大学士(5) 掉到知县(2) —— 你什么都没变');
assert.ok(
cy.blocked.some((b) => b.byChushen && /不許入試/.test(b.note)),
'关科举的理由必须是原文那句「倡優之家……俱不許入試」'
);
// ★★★ 而【吏员出职必须还开着】—— 原文禁的是「入試」,吏员出职不是考试。
// 若哪天有人把 changyou 的 jin 加上 lisi,就是替原文多禁了一条它没禁的路。
assert.ok(!cy.blocked.some((b) => b.byChushen && b.channel.includes('吏员')), '原文没禁吏员出职');
});
test('★★★ 「没定出身」在【属】与【禁】里不是一回事 —— 混了,明会被报成「这一问还没答」', () => {
// ★ 这一条是盯输出盯出来的,不是想出来的:
// · 属(辽):没定 ⇒【这一问真的没答】,引擎给不出判词(路全关着)。
// · 禁(明):没定 ⇒【不是没答】,是「你不在被禁的那一类里」——原文只禁了「倡優之家」,
// 【没给另一头起过名字】,所以「没declare」本身就是一个有效状态,判词照出。
// ⚠ 第一版两种都报 missing=true,于是明明算出了判词的明被报成「还没答」,
// 还被 headline 整个摘出去 —— 那是拿【原文没给这一头起名字】冒充【用户没回答】。
const clerk = profileFromSubs(['rigorous', 'datadriven', 'concise', 'shortfirst'], { wang: 0 });
const scholar = profileFromSubs(['rigorous', 'datadriven', 'patient', 'calm'], { wang: 0 });
assert.equal(closedBy('liao', null).missing, true, '属:没定 = 这一问没答');
assert.equal(closedBy('ming', null).missing, false, '★ 禁:没定 = 你不在被禁那一类里,不是没答');
assert.equal(closedBy('ming', null).closed.size, 0, '禁:没定 ⇒ 一条路都不关');
// 明没定出身 ⇒ 照样有判词,而且【不许】说「这一部传里,你生在……」——原文没给这一头名字。
const m = computeRank(scholar, 'ming');
assert.equal(m.channel.id, 'keju', '明没定出身 ⇒ 判词照出');
assert.equal(m.chushen.missing, false);
assert.equal(m.chushen.value, null);
assert.ok(
!m.reasons.some((s) => /你生在/.test(s)),
'★ 原文没给「不是倡優之家」起过名字 ⇒ 本表也不给它起,这里【什么都不说】。' +
'✗ 编一句「你生在民户」就是替原文补一个它没有的类'
);
// 而辽没定出身 ⇒ 必须说这一问没答。
assert.equal(computeRank(clerk, 'liao').chushen.missing, true);
// ★★ headline 不许拿一个【没答的问题】当论据 —— 但【禁】那种没定的,是有效判词,不许被摘。
const cmp = compareDynasties(profileFromSubs(['rigorous', 'datadriven', 'patient', 'calm'], { wang: 0 }));
const judged = cmp.items.filter((i) => !(i.chushen && i.chushen.missing)).map((i) => i.dynasty.id);
assert.ok(judged.includes('ming'), '★ 明必须留在 headline 的论据里(它有判词)');
assert.ok(!judged.includes('liao'), '★ 而没答出身的辽必须被摘出去 —— 拿「我们没问」冒充「他不行」是错的');
});
// ══════════════════════════════════════════════════════════════════════════
// ② ★★ 合规 —— 出身是【这部书的设定】,不是用户的属性
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 合规:文案不许指向用户本人(PIPL 第28条:民族是敏感个人信息)', () => {
// ✓ 只许「这一部传里,你生在……」 ✗ 不许「你的民族」「你的出身是」
const BAD = ['你的民族', '您的民族', '你的出身是', '你的种族', '你是什么族', '您的种族'];
const walk = (node, path) => {
if (typeof node === 'string') {
for (const b of BAD) {
assert.ok(!node.includes(b), `${path} 出现「${b}」—— 出身是这部书的设定,不是用户的属性`);
}
return;
}
if (node && typeof node === 'object') for (const [k, v] of Object.entries(node)) walk(v, `${path}.${k}`);
};
walk(CHUSHEN, 'CHUSHEN');
walk(ABSENT, 'ABSENT');
// 而问句【必须】是那个句式 —— 这是正面断言,不是只防坏词。
for (const c of CHUSHEN) {
assert.match(c.ask, /这一部传里/, `${c.dynastyId} 的问句必须是「这一部传里,你生在……」的句式`);
}
});
test('★★★ 合规:出身【绝不许】上 twins —— 它落在这一部传上', () => {
// openLineage 收它、冻它。★ 同一个 subjectId 开两部传,可以一部契丹、一部汉。
const clerk = profileFromSubs(['rigorous', 'datadriven', 'concise', 'shortfirst'], { wang: 0 });
const asQidan = computeRank({ ...clerk, chushen: 'qidan' }, 'liao');
const asHan = computeRank({ ...clerk, chushen: 'han' }, 'liao');
const L1 = openLineage({ subjectId: 'u', rank: asQidan });
assert.deepEqual(L1.chushen, { id: 'qidan', name: '契丹' }, '出身必须冻在这一部传上');
assert.ok(Object.isFrozen(L1), '一部传是冻的');
// ★★ 同一个分身的另一部传,可以是另一个出身 —— 那不是同一个人有两个民族,是两本书有两个设定。
const L2 = openLineage({ subjectId: 'u', rank: asHan, existing: [L1], edition: 2, precededBy: L1.id });
assert.deepEqual(L2.chushen, { id: 'han', name: '汉' });
assert.equal(L1.chushen.id, 'qidan', '★ 开第二部传,第一部的设定一个字都不许动');
assert.notEqual(L1.id, L2.id);
// ★★★ 出身与位阶【同源】:快照里的出身,必须就是算出这个位阶的那个出身。
// (若它们能对不上,这部书会写着「你生在汉」而位阶是按契丹算的。)
assert.equal(L1.rank.chushen.id, 'qidan');
assert.equal(L1.rank.channel.id, 'wenquan_liao', '契丹 ⇒ 北面文铨');
assert.equal(L2.rank.channel.id, 'nanmian_hanguan', '汉 ⇒ 南面汉官');
});
test('★★ rankSnapshot 对快照幂等 —— 否则 anotherEdition 会把出身悄悄丢掉', () => {
const clerk = profileFromSubs(['rigorous', 'datadriven', 'concise', 'shortfirst'], { wang: 0 });
const r = computeRank({ ...clerk, chushen: 'qidan' }, 'liao');
const s1 = rankSnapshot(r); // computeRank 的形状:chushen:{value:{id,name},…}
const s2 = rankSnapshot(s1); // 已是快照的形状:chushen:{id,name}
assert.deepEqual(s1.chushen, { id: 'qidan', name: '契丹' });
assert.deepEqual(
s2.chushen, s1.chushen,
'★ 再快照一次,出身必须还在 —— anotherEdition(L) 不给新 rank 时走的就是这条路。' +
'丢了它,续写的那一版会变成「没定出身」,而用户什么都没改'
);
});
test('★★ 红线:出身与劳的结构里不许有手游机制', () => {
assert.deepEqual(assertNoGameMechanics(CHUSHEN), [], '出身表里有闸门字段/文案');
const clerk = profileFromSubs(['rigorous'], { wang: 0 });
const r = computeRank({ ...clerk, chushen: 'qidan', lao: 400 }, 'liao');
assert.deepEqual(assertNoGameMechanics(r), [], 'computeRank 的产出里有闸门字段/文案');
assert.deepEqual(assertNoGameMechanics(openLineage({ subjectId: 'u', rank: r })), []);
});
// ══════════════════════════════════════════════════════════════════════════
// ③ ★ 引用完整性 —— chushen.js 点名的通道必须真的存在
// ══════════════════════════════════════════════════════════════════════════
test('★★ 出身点名的每条通道,必须在 dynasties.js 里真的存在', () => {
// ⚠ 这一条是跨文件的:出身表拿字符串点名通道,拼错一个字母,闸就【静悄悄地不生效】——
// 而它长得和生效的一模一样。(同 dynasties.js 的 sameScale 为什么不许用带回落的 trackOf。)
for (const c of CHUSHEN) {
const ids = chIds(c.dynastyId);
for (const v of c.values) {
for (const s of v.shu || []) {
assert.ok(ids.has(s.channel), `${c.dynastyId}/${v.id} 的【属】点名了不存在的通道:${s.channel}`);
assert.ok(s.quote, `${c.dynastyId}/${v.id}→${s.channel} 必须自带那一句原文,不许由模板拼`);
}
for (const j of v.jin || []) {
assert.ok(ids.has(j.channel), `${c.dynastyId}/${v.id} 的【禁】点名了不存在的通道:${j.channel}`);
assert.ok(j.quote, `${c.dynastyId}/${v.id}→${j.channel} 必须自带那一句原文`);
}
}
}
});
test('★★★ 引文必须逐条挂,不许由模板拼 —— 武铨是【軍馬】,文铨是【人民】', () => {
// 这一条是本轮当场踩出来的:第一版把理由写成模板「凡${name}人民皆屬焉」,
// 于是【武铨被安上了一句它没有的话】(武铨原文作「凡契丹軍馬皆屬焉」)。
// ★ 一个模板凭空伪造了一句带书名的引文 —— 正是「带卷次的错误比没出处的错误危害大十倍」的那一种。
const qidan = getChushenValue('liao', 'qidan');
const byCh = Object.fromEntries(qidan.shu.map((s) => [s.channel, s.quote]));
assert.equal(byCh.wuquan_liao, '凡契丹軍馬皆屬焉', '武铨是军马');
assert.equal(byCh.wenquan_liao, '凡契丹人民皆屬焉', '文铨是人民');
assert.notEqual(byCh.wuquan_liao, byCh.wenquan_liao, '★ 这两句不是一句话 —— 别拼模板');
// 而引擎给用户的理由里,两条必须各引各的。
const clerk = profileFromSubs(['rigorous', 'datadriven', 'concise', 'shortfirst'], { wang: 0 });
const h = computeRank({ ...clerk, chushen: 'han' }, 'liao');
const wu = h.blocked.find((b) => b.channel.includes('武铨'));
const wen = h.blocked.find((b) => b.channel.includes('文铨'));
assert.match(wu.note, /凡契丹軍馬皆屬焉/, '关武铨的理由必须引武铨那一句');
assert.ok(!wu.note.includes('凡契丹人民皆屬焉'), '✗ 武铨不许被安上文铨那句话');
assert.match(wen.note, /凡契丹人民皆屬焉/, '关文铨的理由必须引文铨那一句');
});
// ══════════════════════════════════════════════════════════════════════════
// ★★★ 2026-07-17 验收补:模板不许拼引文 —— 【也不许对引文的字面下断言】
//
// 上一条(「武铨是軍馬,文铨是人民」)挡住的是【模板伪造一句引文】。
// ★ 但它没挡住【模板伪造一句「关于引文」的话】—— 而后者一样会假,验收当场抓到一例:
// 模板原写「志的原话是「${s.quote}」,那个【「属」字】说的是……」,
// 而汉那一条的引文是「南面治漢人州縣、租賦、軍馬之事」——【全句没有一个「屬」字,作「治」】。
// ⇒ 引擎在对用户说「那个『属』字」,而它指着的那句话里一个「屬」都没有。
// ★★ 判别式:一条 why 若声称引文里有某个字,那个字就必须【真的在它引的那句话里】。
// ⇒ 这是可证伪的:谁再往模板里塞一句「关于引文字面」的断言,这条就红。
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 模板不许对引文的字面下断言 —— 汉那句里没有「屬」字', () => {
// ① 数据层:本表现有的三条【属】引文里,只有两条真的有「屬」字。
// ★ 这一格摆出来,是为了让下一个人看见:这三句【不是同一种句子】。
const shape = { 凡契丹軍馬皆屬焉: true, 凡契丹人民皆屬焉: true, '南面治漢人州縣、租賦、軍馬之事': false };
for (const [q, hasShu] of Object.entries(shape)) {
assert.equal(q.includes('屬'), hasShu, `「${q}」有没有「屬」字,本表记的与实际不符`);
}
// ② 引擎层:每一条 why,若出现「「属」字」这种【关于字面的断言】,
// 它引的那句话里就必须真有那个字。★ 三个出身值全跑一遍,一条都不许漏。
for (const [dyn, val] of [['liao', 'qidan'], ['liao', 'han'], ['ming', 'changyou']]) {
const { why } = closedBy(dyn, val);
for (const [ch, note] of Object.entries(why)) {
if (!/「属」字|「屬」字/.test(note)) continue;
const quoted = note.match(/「([^「」]{6,})」/g) || [];
assert.ok(
quoted.some((q) => q.includes('屬') || q.includes('属')),
`${dyn}/${val}/${ch}:这条理由声称「那个『属』字」,而它引的话里【没有「屬」】——\n` +
` ${note}\n` +
' ★ 模板不许拼引文,也不许对引文的字面下断言。只许说【本表自己的读法】(所属 ≠ 任职)。'
);
}
}
});
// ══════════════════════════════════════════════════════════════════════════
// ④ ★★ 不许做成跨朝通用枚举 —— 这是数据形状的断言,不是口号
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 出身【不是一个跨朝通用枚举】—— 两朝连形状都不一样', () => {
const liao = chushenOf('liao');
const ming = chushenOf('ming');
// ★ 形状不同:辽是【属】(白名单:这个铨管谁),明是【禁】(黑名单:这类人不许进)。
assert.equal(liao.shape, 'shu');
assert.equal(ming.shape, 'jin');
assert.notEqual(liao.shape, ming.shape, '★ 两朝的出身连形状都不一样 —— 这就是不许合并的证据');
// ★★ 取值不同,而且【连「有几个值」都不一样】:
// 辽的序两头都点了名(「以國制治契丹,以漢制待漢人」)⇒ 真二元;
// 明只点了被禁的那一头(「倡優之家」)⇒ 原文没给另一头起过名字,本表也不给它起。
assert.deepEqual(liao.values.map((v) => v.id), ['qidan', 'han']);
assert.deepEqual(ming.values.map((v) => v.id), ['changyou'], '★ 明只有一个值 —— 这是结论不是漏填');
// ★★★ 而两朝的取值【没有一个是共享的】:谁想把出身做成一个通用枚举,先解释
// 「契丹」和「倡優之家」怎么会是同一个字段的两个值。
const overlap = liao.values.map((v) => v.id).filter((id) => ming.values.some((m) => m.id === id));
assert.deepEqual(overlap, [], '★ 两朝的出身取值不许有交集 —— 出身在七朝里根本不是同一个东西');
// 别的朝代【没有出身维】—— 而且那不是漏了,是每一朝都有一条说得出的理由。
// ⚠ 2026-07-17 接 25 朝:chushen.js 是给【原有八朝】建的(liao 的属、ming 的禁…)。
// 新接的 14 朝,出身维【尚未建模】(如金的女直进士科、陈的诸王起家都是出身,但没接进 chushen.js)——
// 这是【已知的未做项,报老板】,不是「漏了没声明」。⇒ 本条只覆盖 chushen.js 建过模的那八朝;
// 即:chushenOf 或 absenceOf 有一个说得上话的朝。查不到的(新朝)不进这条 —— 那是另一件事。
const CHUSHEN_MODELED = new Set(['han', 'weijin', 'nanchao', 'tang', 'song', 'ming', 'liao', 'xihan']);
for (const d of DYNASTIES) {
if (!CHUSHEN_MODELED.has(d.id)) continue; // 新接的朝:出身维未建模(报老板),不在本条范围
if (hasChushen(d.id)) continue;
const a = absenceOf(d.id);
assert.ok(a, `${d.name} 既没有出身维,也没说为什么 —— 那才是漏了`);
assert.ok(ABSENCE_KINDS[a.kind], `${d.name} 的缺席种类不认识:${a.kind}`);
assert.ok(a.reason && a.reason.length > 40, `${d.name} 的理由太短,说不清`);
}
assert.deepEqual(
// ⚠ 顺序 = DYNASTIES 编年序(2026-07-30「朝代按正常顺序排」后):辽(916)→元(1271)→明(1368)→清(1636)。
DYNASTIES.filter((d) => hasChushen(d.id)).map((d) => d.id), ['liao', 'yuan', 'ming', 'qing'],
'★ 有出身维的:辽(属·shu)、元(四等人·shu,2026-07-30 双证接入)、明(禁·jin)、清(旗籍·shu,2026-07-30 双证接入)。' +
'多一朝,先拿出带卷次的双证;少一朝,说明有人把闸删了'
);
});
test('★★ 唐的出身:有原文,但在引擎没做的那一段 —— 这是结构性缺席,不是史料缺席', () => {
const a = absenceOf('tang');
assert.equal(a.kind, 'out-of-segment');
assert.equal(a.whoIsBlind, '本表的模型', '★ 看不见它的是本表,不是史料,更不是唐');
assert.match(a.quote, /刑家之子/, '唐的出身科条是有原文的(《新唐书》卷45 铨选)');
assert.ok(a.witnesses.length >= 2, '而且是双证');
// ★ 而「本轮未查全」的那两朝,判词必须是【我们】—— 不许读成「那一朝没有」。
for (const id of ['han', 'song']) {
assert.equal(absenceOf(id).kind, 'not-scanned');
assert.equal(absenceOf(id).whoIsBlind, '我们', '★ 宁可说「我没查」,不可说「它没有」');
}
});
// ══════════════════════════════════════════════════════════════════════════
// ⑤ 每一个取值都必须挂原文 —— 没有原文的出身,不许存在
// ══════════════════════════════════════════════════════════════════════════
test('★★ 每个出身取值都必须挂【正文】的原文与双证,且类与闸分层', () => {
for (const c of CHUSHEN) {
assert.ok(SHAPES[c.shape], `${c.dynastyId} 的形状不认识:${c.shape}`);
for (const v of c.values) {
const cite = citeChushen(c.dynastyId, v.id);
assert.ok(cite, `${c.dynastyId}/${v.id} 取不到注`);
// ★★ 闸的出处必须带卷次、必须双证 —— 闸是「制度如何运作」,料的层级不够就不许造闸。
assert.match(cite.zha.source, /卷/, `${c.dynastyId}/${v.id} 的闸号称有出处,那就必须带卷次`);
assert.ok(cite.zha.quote && cite.zha.quote.length > 10, `${c.dynastyId}/${v.id} 的闸缺原文`);
assert.ok(cite.zha.witnesses.length >= 2, `${c.dynastyId}/${v.id} 的闸必须双证`);
assert.ok(cite.zha.caveat && cite.zha.caveat.length > 40, `${c.dynastyId}/${v.id} 的闸缺限定语 —— 限定语才是注的诚实`);
assert.match(cite.zha.level, /正文/, '★ 闸只许取正文');
}
// ★★★ 辽:类出自【志序】、闸出自【正文】—— 这个分层是这张表的地基。
// 志序按 chiduo.js 的规矩只能证「领域怎么切」(=出身把人分成几类),
// 不能证「制度如何运作」(=闸)。两层必须分开给。
if (c.dynastyId === 'liao') {
const cite = citeChushen('liao', 'qidan');
assert.match(cite.lei.level, /志序/);
assert.match(cite.lei.quote, /以國制治契丹,以漢制待漢人/, '类的依据是序里那一句');
assert.match(cite.lei.caveat, /historianFrame|志序/, '而且必须自陈它只能用来切类');
assert.match(cite.zha.quote, /凡契丹軍馬皆屬焉/, '闸的依据是正文里那一句');
}
}
});
// ══════════════════════════════════════════════════════════════════════════
// ⑥ ★★★ 2026-07-17 第五刀:【问句必须带着它的选项】
//
// 【这条断言钉的是一个在生产上活了一整刀的 bug,过程记在这里,比结论值钱】
// 第三刀把出身这一维做完了:ask 在、闸在、原文在、双证在,没答时 reasons 还明写着
// 「这一部传还没定」。★ 每一条单测都绿。
// ⚠ 而 computeRank 的 chushen 块【只报答案,不报选项】——【「契丹」与「汉」这两个名字
// 从来没有出过后端】。⇒ 前端没有东西可渲染 ⇒ 这一问【问不出口】。
// ⇒ 而 closedBy 的失效方向朝【关】(那是对的):没答 ⇒ 属的路全关 ⇒ 算出「白身」。
// ⇒ 两件对的事合起来是一件错的事:【辽这一列,对没答过的人整列塌成「白身」,
// 而用户根本没有地方回答。】
//
// ★★ 它的形状是本项目的老坑:【拿「我们没问」冒充「他不行」】——
// 与 buildHeadline 里那一条(没答出身的朝代不许当论据)【一模一样】,只是换了个位置。
// ⚠⚠ 而那一条【治不了这一条】:headline 把辽摘出去了,页面上那一列还写着「白身」。
// ★ 摘出去只是「不拿它当论据」,不是「让他答得上」。
//
// ★★★ 这一次它为什么躲过了所有断言:【它落在前后端的缝上】——
// 后端:我报了 ask,我没替他答,我说了这一问没答 ⇒ 一条纪律都没破。
// 前端:我如实渲染了后端给我的 rank.title ⇒ 一条纪律都没破。
// 两边各自看都没错,错在【谁也没负责让这一问能被答】。
// ⇒ 【缝上的错,得有一条骑在缝上的断言】:本条断言的不是「引擎算得对」,
// 是【引擎交出去的那份东西,够不够让人答这一问】。
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 有 ask 就必须有 values —— 一个没有选项的问句,是问不出口的', () => {
const clerk = profileFromSubs(['rigorous', 'datadriven', 'concise', 'shortfirst'], { wang: 0 });
for (const d of DYNASTIES) {
const r = computeRank(clerk, d.id);
if (!hasChushen(d.id)) {
// 没有出身维 ⇒ null。★ 不许给前端一个空格子 —— 那会让用户以为这一朝漏问了他什么。
assert.equal(r.chushen, null, `${d.name} 没有出身维,就不该给出身这一格`);
continue;
}
const c = r.chushen;
assert.ok(c.ask, `${d.name} 缺问句`);
// ★★ 这一行就是那个 bug 的判别式:问句在,而选项不在。
//
// ⚠⚠ 【失败信息必须分形状写 —— 这一条是本条断言自己踩出来的,记在这里当标本】:
// 第一版的失败信息一句话通吃,写着「没答 ⇒ 属的路全关 ⇒ 这一列塌成「白身」」。
// ★ 而把 bug 种回去实测,它【先在「明」上响】—— 于是这条断言当着我的面对「明」说了一句
// 【假话】:明是【禁】,没答【一条路都不关】(closedBy('ming', null).closed.size === 0,
// 上面那条断言正钉着这件事),判词照出,根本不塌成白身。
// ⇒ 这与本文件反复抓到的是【同一个家族】:模板对着一个它没核过的案子下断言
// (武铨被安上「人民」/「那个『属』字」指着一句没有「屬」的话)——
// ★ 这次犯在【断言自己的失败信息】上,而失败信息恰恰是【只有出事时才有人读】的那段话。
// ⇒ 所以两种形状各说各的后果,一个字都不许通用。
const consequence = c.shape === 'shu'
? '没答 ⇒ 属的路全关 ⇒ 这一列塌成「白身」,而用户根本没有地方回答'
: '用户永远没法说「我生在被点名的那一类里」⇒ 这一朝只剩没被禁的那一头,' +
'而「同一个人,答一句话,天花板换一个」那一格【根本发生不了】';
assert.ok(
Array.isArray(c.values) && c.values.length > 0,
`${d.name} 报了问句「${c.ask}」,却【没有报选项】——\n` +
` ⇒ 前端拿不到取值的名字 ⇒ 这一问问不出口 ⇒ ${consequence}。\n` +
' ★ 一个问句没有选项,它就不是一问,是一句自言自语。'
);
// 选项必须与闭合词表【逐字一致】:前端不许自己造名字。
// ⚠ 尤其【不许补齐成二元】:明只有一个值(原文没给另一头起过名字),
// 这里报几个,前端就只能摆几个。deepEqual 把这件事钉死。
assert.deepEqual(
c.values,
chushenOf(d.id).values.map((v) => ({ id: v.id, name: v.name })),
`${d.name} 报给前端的选项与 chushen.js 的闭合词表对不上`
);
// ★ 报出去的每一个选项,都必须真的能被引擎收回来 —— 否则用户点了它,
// validateChushen 认不出 ⇒ 当没给(失效方向朝关)⇒ 点了等于没点,而且看不出为什么。
for (const v of c.values) {
assert.ok(getChushenValue(d.id, v.id), `${d.name} 报了一个引擎自己不认的选项:${v.id}`);
}
}
});
test('★ 查不到一律返回 null —— 不许猜(猜一个出身出来,就是替用户的书编设定)', () => {
assert.equal(chushenOf('han'), null, '汉没有出身维');
assert.equal(chushenOf('__nope__'), null);
assert.equal(getChushenValue('liao', '__nope__'), null);
assert.equal(getChushenValue('ming', 'qidan'), null, '★ 明没有「契丹」这个出身 —— 跨朝的值不许串门');
assert.equal(citeChushen('liao', '__nope__'), null);
assert.equal(hasChushen('__nope__'), false);
// ★ 失效方向朝【关】:认不出的出身 = 没给。
// ✗ 绝不许「认不出就放行」—— 那等于用户随便传个字符串就能打开北面的路。
const bogus = closedBy('liao', '__nope__');
assert.equal(bogus.missing, true, '认不出的出身必须当成没给');
assert.equal(getDynasty('liao').channels.every((c) => bogus.closed.has(c.id)), true, '★ 而没给 ⇒ 属的路全关');
// 没有出身维的朝代:出身关不了它的任何一条路(出身在那一朝不是个问题)。
const han = closedBy('han', 'qidan');
assert.equal(han.dimension, false);
assert.equal(han.closed.size, 0, '★ 给一个没有出身维的朝代传出身,不许因此关掉任何路');
});