495 lines
36 KiB
JavaScript
495 lines
36 KiB
JavaScript
// 出身的单测 —— 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, '★ 给一个没有出身维的朝代传出身,不许因此关掉任何路');
|
||
});
|