Files
digital-me-history/digital-me-backend/test/chiduo.test.js
T
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

583 lines
36 KiB
JavaScript
Raw 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.
// 尺度表的单测 —— 与 shiliao.test.js 同一个定位:【史学纪律的可执行定义】,不是覆盖率测试。
//
// 下面的断言不测「功能能跑」,测的是三件事:
// 1. 有没有人在这张表里编东西 —— 每条轴必须有 source/quote/caveat/见证
// 2. 推演有没有伪装成史料 —— 每条映射必须 inferred:true,且必须标距离
// 3. 「缺席」有没有被偷偷读成「零分」—— noRulerFor 必须给 undefined,不许给 0
//
// ══════════════════════════════════════════════════════════════════════════
// ★★★ 最要紧的三条,写在最前面,因为它们是这个文件存在的理由:
//
// · COMMON_AXES 必须是【空的】。七朝凑不出一条共有轴,这是【结论】不是缺。
// 谁哪天想往里塞一条,这条测试会拦住他,并要求他先拿出【七朝各一条原文】。
//
// · noRulerFor 必须返回 undefined,【不许返回 0】。
// 零分 = 制度量了你判你不行;维度缺席 = 制度根本看不见你这一项。
// 混了,「怀才不遇」就变成了「你不行」——那正好是这个产品要说的反面。
//
// · PROFESSION_MAP 里【不许出现 strained(牵强级)】。牵强的宁可不映射,全部进 REJECTED。
// ══════════════════════════════════════════════════════════════════════════
//
// 跑:node --test test/chiduo.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
AXES, MECHANISMS, SHAPES, EVIDENCE, DISTANCES, ABSENCE, VERDICTS,
COMMON_AXES, COMMON_AXES_NOTE, RECURRENCES, FIELD_SPLIT, SKEW,
PROFESSION_MAP, REJECTED, ENGINE_AXIS_DIFF, SOURCE_AXES_MISSING_FROM_ENGINE,
SHAPE_DIFF, CONFLICTS, FORBIDDEN, DYNASTY_IDS,
axesOf, getAxis, citeAxis, commonAxes, recurrences,
mapProfession, noRulerFor, professionChannels, usableAsInstitution,
} from '../src/chiduo.js';
import { DYNASTIES as ENGINE_DYNASTIES } from '../src/dynasties.js';
import { PROFESSIONS } from '../src/professions.js';
// ── 1. 表的完整性:没有原文的轴,不许存在 ──
test('★ 每条轴都必须有 source —— 没有原文的轴,不许存在', () => {
for (const a of AXES) {
assert.ok(a.source, `「${a.name}」缺 source —— 没出处的轴不许进表`);
assert.ok(a.quote, `「${a.name}」缺 quote —— 挂了出处却不给原文,等于没出处`);
assert.ok(a.caveat, `「${a.name}」缺 caveat —— 限定语才是注的诚实`);
assert.ok(Array.isArray(a.witnesses) && a.witnesses.length >= 1, `「${a.name}」缺 witnesses`);
assert.ok(a.verified, `「${a.name}」缺 verified —— 必须说清什么时候、用什么方法核的`);
assert.ok(a.span, `「${a.name}」缺 span —— 一条科目不说它管哪一段,就是在等着被时代错置`);
}
});
test('每条轴的 id 唯一、dynastyId 合法、shape/evidenceKind 在词表里', () => {
const seen = new Set();
for (const a of AXES) {
assert.ok(!seen.has(a.id), `轴 id 重复:${a.id}`);
seen.add(a.id);
assert.ok(DYNASTY_IDS.includes(a.dynastyId), `「${a.name}」的 dynastyId 非法:${a.dynastyId}`);
assert.ok(SHAPES[a.shape], `「${a.name}」的 shape 不在 SHAPES 里:${a.shape}`);
assert.ok(EVIDENCE[a.evidenceKind], `「${a.name}」的 evidenceKind 不在 EVIDENCE 里:${a.evidenceKind}`);
}
});
test('★ 本表的朝代 id 必须与 dynasties.js 对齐 —— 对不上就是两张表在说两回事', () => {
const engineIds = ENGINE_DYNASTIES.map((d) => d.id);
for (const id of DYNASTY_IDS) {
assert.ok(engineIds.includes(id), `chiduo 的朝代「${id}」在 dynasties.js 里不存在`);
}
assert.equal(DYNASTY_IDS.length, 7, '七朝,不多不少');
});
test('★ 七朝每一朝都必须有量项 —— 一朝都不许空着(空着就该说清是哪一种缺席,而不是没写)', () => {
for (const id of DYNASTY_IDS) {
assert.ok(axesOf(id).length >= 1, `「${id}」一条量项都没有`);
}
});
// ── 2. ★★★ 共有轴:空的,而且必须是空的 ──
test('★★★ COMMON_AXES 必须是空的 —— 七朝凑不出一条共有轴,这是结论不是缺', () => {
assert.deepEqual(COMMON_AXES, [], [
'有人往 COMMON_AXES 里塞东西了。',
'塞之前请先回答:这条轴在【七朝各自】的原文里,分别是哪一句?带卷次。',
'原文给的是每朝各自的科目(汉四科 ≠ 唐四才 ≠ 明四书义三道),它们互不通约。',
'硬凑一条共有轴,等于替七朝的史官发明一个他们没有的共识。',
].join('\n'));
assert.ok(COMMON_AXES_NOTE.length > 100, 'COMMON_AXES 是空的,那就必须有一段话解释为什么空');
assert.deepEqual(commonAxes().axes, []);
});
test('★★ 跨朝复现的字,一条都不许被标成轴(isAxis 必须为 false,且必须写明为什么不是)', () => {
assert.ok(RECURRENCES.length >= 1, '至少要记下「勞」这一条');
for (const r of RECURRENCES) {
assert.equal(r.isAxis, false, `「${r.word}」被标成了轴 —— 一个字的复现不构成一条轴`);
assert.ok(r.notAxisReason && r.notAxisReason.length > 50, `「${r.word}」没写明为什么它不是一条轴`);
assert.ok(r.occurrences.length >= 2, `「${r.word}」复现不足两朝,不该进 RECURRENCES`);
for (const o of r.occurrences) {
assert.ok(getAxis(o.axisId), `「${r.word}」在 ${o.dynastyId} 指向的 axisId 不存在:${o.axisId}`);
}
}
const lao = RECURRENCES.find((r) => r.id === 'lao');
assert.ok(lao, '「勞」是七朝里唯一跨三朝复现的字,必须记着');
assert.equal(lao.dynasties.length, 3, '勞 复现于南朝/唐/宋,三朝');
});
// ── 3. ★★★ 映射:推演不许伪装成史料 ──
test('★★★ 每条映射都必须标 inferred:true —— 映射是推演,不是史料', () => {
for (const m of PROFESSION_MAP) {
assert.equal(m.inferred, true, `${m.professionId}→${m.dynastyId} 没标 inferred`);
assert.ok(m.note && m.note.length > 30, `${m.professionId}→${m.dynastyId} 没写映射的理由与失真处`);
}
});
test('★★★ mapProfession 的返回值必须处处标着 inferred —— 前端要能把「原文」和「推演」分开', () => {
const r = mapProfession('lawyer', 'han');
assert.equal(r.inferred, true, '映射层整体必须标 inferred');
for (const m of r.mapped) {
assert.equal(m.inferred, true, '每条映射必须标 inferred');
assert.ok(m.cite, '映射必须把原文一起给出 —— 不许只给推演的话');
assert.ok(m.cite.caveat, '原文必须带限定语 —— caveat 不许省');
assert.ok(m.cite.source && m.cite.quote, '原文必须带出处与引文');
}
assert.ok(/推演/.test(r.caveat), 'mapProfession 的 caveat 必须明说这一层是推演');
});
test('★★ PROFESSION_MAP 里不许出现 strained —— 牵强的宁可不映射', () => {
for (const m of PROFESSION_MAP) {
assert.ok(DISTANCES[m.distance], `${m.professionId}→${m.dynastyId} 的 distance 非法:${m.distance}`);
assert.notEqual(m.distance, 'strained',
`${m.professionId}→${m.dynastyId} 是牵强级却进了映射表 —— 牵强的应该进 REJECTED,并写明为什么拒`);
}
});
test('★ REJECTED 里必须全是 strained,且每条都要写明拒的理由', () => {
assert.ok(REJECTED.length >= 5, '一张只有映射没有拒绝的表,读者无从判断它的门槛在哪');
for (const r of REJECTED) {
assert.equal(r.distance, 'strained', `REJECTED 里混进了非牵强级:${r.professionId}→${r.dynastyId}`);
assert.ok(r.reason && r.reason.length > 40, `${r.professionId}→${r.dynastyId} 没写拒的理由`);
}
});
test('★ 映射与拒绝里的 professionId 必须来自 professions.js 的闭合词表', () => {
// ★ 这条测试守的是「profession 可以直接进 gate」的整个前提:
// 它可以进 gate,【当且仅当】它真的是闭合的。词表外的 id 一旦混进来,就跟 personaTags 一样了。
const ids = new Set(PROFESSIONS.map((p) => p.id));
for (const m of [...PROFESSION_MAP, ...REJECTED]) {
assert.ok(ids.has(m.professionId), `professionId「${m.professionId}」不在 professions.js 的闭合词表里`);
assert.ok(DYNASTY_IDS.includes(m.dynastyId), `dynastyId「${m.dynastyId}」非法`);
}
});
test('★ 映射指向的 axisId 必须真实存在;REJECTED 的 axisId 可以为 null(拒的时候本来就可能无处可挂)', () => {
for (const m of PROFESSION_MAP) {
assert.ok(getAxis(m.axisId), `${m.professionId}→${m.dynastyId} 指向的轴不存在:${m.axisId}`);
const axis = getAxis(m.axisId);
assert.equal(axis.dynastyId, m.dynastyId,
`${m.professionId}→${m.dynastyId} 映射到了别朝的轴(${m.axisId} 属于 ${axis.dynastyId})`);
}
for (const r of REJECTED) {
if (r.axisId !== null) assert.ok(getAxis(r.axisId), `REJECTED 指向的轴不存在:${r.axisId}`);
}
});
test('★★ 映射挂的 channelId 必须在 dynasties.js 里真实存在 —— 这是「可被采纳」的唯一硬约束', () => {
for (const m of PROFESSION_MAP) {
if (!m.channelId) continue; // null 是常态,那是 diff 不是 bug
const d = ENGINE_DYNASTIES.find((x) => x.id === m.dynastyId);
assert.ok(d, `dynasties.js 里没有朝代 ${m.dynastyId}`);
assert.ok(d.channels.some((c) => c.id === m.channelId),
`${m.professionId}→${m.dynastyId} 挂的通道「${m.channelId}」在 dynasties.js 里不存在`);
}
});
test('★★ 几乎所有映射的 channelId 都是 null —— 这个「几乎恒空」本身就是交付物,不是待修的 bug', () => {
const withChannel = PROFESSION_MAP.filter((m) => m.channelId);
assert.ok(withChannel.length <= 2, [
'channelId 非空的映射变多了。这不一定是好事 —— 先回答:',
'引擎的 channels 全是【入仕】,而原文把选拔分成入仕/服官/任事三段(FIELD_SPLIT,两朝独立印证)。',
'「身言书判」是铨选的尺子,引擎里根本没有那一段。硬挂通道 = 把铨选的料塞进科举的格子。',
].join('\n'));
assert.ok(withChannel.length >= 1, '至少 lawyer→han→bizhao 该挂得上 —— 一条都挂不上说明 id 写错了');
});
// ── 4. ★★★ 缺席:不许被读成零分 ──
test('★★★ noRulerFor 返回的是【缺席】,不是 0', () => {
const r = noRulerFor('doctor', 'han');
assert.equal(r.absent, true, '汉四科里没有一科在量医术');
// ★★ 三态断言:键在、值是 undefined、且【绝不是 0】
assert.ok('value' in r, 'value 这个键必须在 —— 前端要能看出「我们表达了这一项」');
assert.equal(r.value, undefined, 'value 必须是 undefined');
assert.notEqual(r.value, 0, '★ undefined 不是 0。零分是「量了你判你不行」,缺席是「根本没量你这一项」');
assert.equal(r.isZero, false, 'isZero 必须恒 false —— 本函数永远不产生零分');
assert.ok(r.absence, 'absence 必须是显式字段 —— 前端不许靠 value 判断');
assert.ok(ABSENCE[r.absence], `absence 非法:${r.absence}`);
});
test('★★ JSON.stringify 会吃掉 value 键 —— 所以 absent/absence 必须是显式字段(这条守的是前端)', () => {
const r = noRulerFor('doctor', 'han');
const round = JSON.parse(JSON.stringify(r));
assert.ok(!('value' in round), 'undefined 过 JSON 会整个键消失 —— 这在语义上是对的');
// ★ 但正因为它会消失,前端如果写 `value || 0` 就把「缺席」变成了「零分」。故:
assert.equal(round.absent, true, 'absent 必须活过序列化');
assert.ok(round.absence, 'absence 必须活过序列化');
assert.equal(round.isZero, false, 'isZero 必须活过序列化');
});
test('★★★ 三种缺席必须分开 —— 混了就是「反过来说本表无据」', () => {
// 汉:有制度级科目,可以数给用户看 → 维度缺席(【制度】看不见你)
assert.equal(noRulerFor('doctor', 'han').absence, 'no-ruler');
assert.equal(noRulerFor('doctor', 'han').whoIsBlind, '制度');
// 辽:《辽史》无选举志,「科舉」二字零命中 → 史料缺席(【我们】看不见制度)
const liao = noRulerFor('writer', 'liao');
assert.equal(liao.absence, 'unrecorded', '辽的「无」是志阙,不是制度无 —— 这是 dynasties.js 反复栽过的坑');
assert.equal(liao.whoIsBlind, '我们');
// 魏晋/南朝:手上的料全是政论/逸事/传序,够不着「制度这样量你」
assert.equal(noRulerFor('lawyer', 'weijin').absence, 'non-institutional');
assert.equal(noRulerFor('lawyer', 'weijin').whoIsBlind, '我们');
assert.equal(noRulerFor('lawyer', 'nanchao').absence, 'non-institutional');
});
test('★★ 「可数」必须真的可数 —— counted 要带原文,用户点开自己数', () => {
const r = noRulerFor('doctor', 'han');
assert.equal(r.counted.length, axesOf('han').length, 'counted 必须是这一朝的【全部】量项,不许挑');
assert.ok(r.countedCount >= 1);
for (const c of r.counted) {
assert.ok(c.source && c.quote, `「${c.name}」没给原文 —— 那用户拿什么数`);
assert.ok('measures' in c, 'measures 键必须在(可能是 null,那是「它什么都不量」)');
}
// 这是本文件唯一能同时做到「是推演」和「有原文」的地方:
// 映射是推演;而「这一朝的科目里没有一条在量它」是【数出来的】。
assert.ok(/可数/.test(r.caveat), 'no-ruler 必须告诉用户这是数出来的');
assert.ok(/本表所收/.test(r.caveat), '★ 必须同时说清「本表所收 ≠ 该朝全部」—— 少这一句就是全称断言');
});
// ── 4b. ★★★ 数不全的时候,判词必须降级(本轮验收抓出来的缺陷,这几条是回归断言)──
//
// 【缺陷原样,留档,因为它是本文件第二刀自己的漏网】:
// noRulerFor('developer','tang') 曾返回 whoIsBlind='制度'(=「唐制度看不见程序员」),
// 而同一张表在同一朝【自己承认】:明字、明算【只有科名,考什么没有原文】(contentUnknown)。
// ★ 这两句不能同时成立 —— 你读不出明算考什么,就不能断言它没在量算学;
// 何况本表把 accountant→tang_mingsuan 标成【直译级】,正说明这一格装得下人。
// → 这就是「把【我们读不出】说成【制度没量】」,即本文件反复警告的【反过来说本表无据】。
test('★★★ 读不出内容的科目存在时,「制度看不见你」这个判词必须降级 —— 你不能数你读不出的东西', () => {
const r = noRulerFor('developer', 'tang');
assert.equal(r.absent, true);
assert.equal(r.absence, 'no-ruler', '种类仍是 no-ruler —— 唐确实有制度级科目');
assert.equal(r.countIncomplete, true, [
'唐有 contentUnknown 的科目(明字/明算),所以这份「数一遍」数不全。',
'countIncomplete 必须是【返回值】—— 注释挡不住任何人,返回值可以。',
].join('\n'));
assert.notEqual(r.whoIsBlind, '制度', [
'★★ 数不全却仍然判「制度看不见你」—— 这就是拿「我们读不出明算考什么」',
'冒充「唐没有量它的尺子」,正是 dynasties.js「不许复活」清单里的那个错。',
].join('\n'));
assert.ok(/存疑|我们/.test(r.whoIsBlind), '降级后的判词必须说清「那一部分是我们看不见」');
assert.ok(r.unreadableCount >= 2, '明字、明算两条至少');
assert.ok(r.unreadable.every((u) => u.axisId && u.name && u.why), 'unreadable 每条要说清是哪一条、为什么读不出');
// ★ 而且 caveat 必须把话说死
assert.ok(/数不全/.test(r.caveat), '数不全就必须当场说数不全');
assert.ok(/不许说/.test(r.caveat), '必须明写「不许说该朝没有量它的尺子」');
assert.ok(/本表所收/.test(r.caveat), '★ 全称断言的那句限定语照样不许掉');
});
test('★★ 数得全的时候,判词才可以是「制度」—— 汉是这样一朝(四科读得出,一条不缺)', () => {
const r = noRulerFor('doctor', 'han');
assert.equal(r.countIncomplete, false, '汉九条量项全部读得出 measures —— 这一朝数得全');
assert.equal(r.unreadableCount, 0);
assert.equal(r.whoIsBlind, '制度', '数得全,才说得起「制度看不见你」');
assert.equal(r.readableCount, r.countedCount);
});
test('★★ counted 每条都必须自陈 readable —— 否则「你自己数一遍」就是句空话', () => {
for (const dyn of DYNASTY_IDS) {
const r = noRulerFor('doctor', dyn);
for (const c of r.counted) {
assert.ok('readable' in c, `${dyn} 的「${c.name}」没自陈 readable —— 用户拿什么数它?`);
// readable === false 的,必须要么是 contentUnknown(读不出),要么 measures===null(不量)
if (!c.readable) {
assert.ok(c.contentUnknown || c.measures === null,
`${dyn} 的「${c.name}」标了不可数,却既不是 contentUnknown 也不是 measures===null`);
}
if (c.readable) assert.notEqual(c.measures, null, `「${c.name}」标了可数却没有 measures`);
}
assert.equal(r.readableCount, r.counted.filter((c) => c.readable).length);
}
});
test('★ countIncomplete 只认 contentUnknown —— 孙楚的状/王安石的发言不掀翻判词(我们知道它们什么都不量)', () => {
// ★ 这条守的是一个【区别】:
// 「读不出它量什么」(明字/明算) —— 会掀翻「制度没量」这个判词
// 「知道它什么都不量」(状是品题语、发言不是科目) —— 不掀翻判词,但也不该被当成可数的科目
const song = noRulerFor('doctor', 'song');
const wang = song.counted.find((c) => c.axisId === 'song_wanganshi');
assert.ok(wang, '王安石那条在 song 的 counted 里');
assert.equal(wang.readable, false, '一句发言不是一条可数的科目');
assert.equal(wang.contentUnknown, false, '★ 但它不是「读不出」—— 我们很清楚它什么都不量');
assert.equal(song.countIncomplete, false, '宋没有 contentUnknown 的科目 → 判词不该被这一条降级');
});
test('★ 有映射的时候,absent 必须为 false', () => {
const r = noRulerFor('lawyer', 'han');
assert.equal(r.absent, false);
assert.equal(r.absence, null);
assert.equal(r.isZero, false, '★ 有尺子的时候也不许冒出一个 0 —— 本函数从不产生分数');
});
test('非法 id 一律返回 null —— 不许猜(同 shiliao.js findOffice 的口径)', () => {
assert.equal(mapProfession('nope', 'han'), null);
assert.equal(mapProfession('lawyer', 'nope'), null);
assert.equal(noRulerFor('nope', 'han'), null);
assert.equal(noRulerFor('lawyer', 'nope'), null);
assert.equal(getAxis('nope'), null);
assert.equal(citeAxis('nope'), null);
assert.deepEqual(axesOf('nope'), []);
});
// ── 5. 见证学纪律 ──
test('★ measures === null 的轴,必须在 caveat 里说明「它什么都不量」不是漏填', () => {
const nulls = AXES.filter((a) => a.measures === null && !a.contentUnknown);
assert.ok(nulls.length >= 1, '魏晋的状就是这样一条 —— 它什么都不量,这是本表最重要的一条');
for (const a of nulls) {
assert.ok(/不量|null|没有|不是漏填|发言|判词|批判/.test(a.caveat),
`「${a.name}」的 measures 是 null,但 caveat 没解释这不是漏填`);
}
const zhuang = getAxis('wj_zhuang');
assert.equal(zhuang.measures, null, '★ 状什么都不量 —— 量表让判断不依赖判断者,而状明确依赖判断者是谁');
});
test('★ singleWitness 的轴必须在 caveat 里自陈「单证」—— 不许让人以为它是双证', () => {
const singles = AXES.filter((a) => a.singleWitness);
assert.ok(singles.length >= 1);
for (const a of singles) {
assert.equal(a.witnesses.length, 1, `「${a.name}」标了 singleWitness 却列了多个见证`);
assert.ok(/单证/.test(a.caveat), `「${a.name}」是单证,caveat 必须说`);
assert.ok(/单证/.test(citeAxis(a.id).caveat), `「${a.name}」的 citeAxis 必须把「单证」透出去给前端`);
}
});
test('★ contentUnknown 的轴:科名在原文,但考什么没有原文 → measures 必须是 null', () => {
const unknowns = AXES.filter((a) => a.contentUnknown);
assert.ok(unknowns.length >= 1, '唐的明字/明算就是这样 —— 有格子,没内容');
for (const a of unknowns) {
assert.equal(a.measures, null,
`「${a.name}」标了 contentUnknown(不知道它考什么),却又填了 measures(说得出它量什么)—— 自相矛盾`);
assert.ok(citeAxis(a.id).contentUnknown, 'contentUnknown 必须透给前端');
}
});
test('★★ spanMismatch 的轴必须透给前端 —— 时代错置是本项目最贵的一类错', () => {
const mism = AXES.filter((a) => a.spanMismatch);
assert.ok(mism.length >= 1);
for (const a of mism) {
assert.ok(/时段不合/.test(citeAxis(a.id).caveat), `「${a.name}」的 spanMismatch 没透给前端`);
}
// ★ 宋:本表所收的宋科目【全是宋初的】,而 dynasties.js 的宋锁死在熙宁。
const songInstitution = axesOf('song').filter((a) => usableAsInstitution(a));
assert.ok(songInstitution.length >= 1);
for (const a of songInstitution) {
assert.ok(a.spanMismatch, [
`宋的制度级量项「${a.name}」没标 spanMismatch。`,
'本表所收的宋科目全是【宋初】的,而 dynasties.js 的宋锁死在【熙宁 1068–1077】。',
'哪天有人拿到了熙宁的科目原文,可以摘掉这个标 —— 但得先拿到。',
].join('\n'));
}
});
test('★★ usableAsInstitution:失效方向必须朝【关】 —— 问错了必须答「不」', () => {
// 同 dynasties.js sameScale 的口径:一个用来【拦住】某件事的函数,问错了不许放行。
assert.equal(usableAsInstitution(null), false);
assert.equal(usableAsInstitution({}), false);
assert.equal(usableAsInstitution({ evidenceKind: 'typo' }), false);
assert.equal(usableAsInstitution({ evidenceKind: 'zhengwen' }), true);
assert.equal(usableAsInstitution({ evidenceKind: 'zhengwen', institution: false }), false,
'条目自己可以再收紧 —— 收紧必须生效');
});
test('★★★ 注:只有【注引书】能当制度陈述,【注家自己的话】不能 —— 判别式必须朝【关】失效', () => {
// 【本轮验收抓出来的第二个洞,留档】:
// 在此之前,任何标了 evidenceKind:'zhu' 的条目都能过 usableAsInstitution()。
// 而本项目的铁律逐字写着:「師古曰/應劭曰/如淳曰/臣瓚曰/裴松之注」开头的是后人注,【不是史料】。
// → 判别式在这里是【朝开】失效的,与它自己声明的口径(同 sameScale:问错必须答「不」)相反。
//
// ★ 而「注」里确实有两样东西,不能一刀切:
// 注家自己的话(意见)✗ vs 注引书(转录的文献)✓ —— 汉四科属后者,它是本项目关于汉最硬的料。
const shigu = { evidenceKind: 'zhu', source: '《汉书》顏師古注', quote: '師古曰:「……」' };
assert.equal(usableAsInstitution(shigu), false, [
'★ 一条「師古曰」(注家自己的意见)被当成制度陈述放行了。',
'本项目铁律:師古曰/應劭曰/如淳曰/臣瓚曰/裴松之注 —— 是后人注,不是史料。',
'注要能用,必须交代它【引的是哪一部书】(quotesWhat)—— 没交代的一律当注家自己的话。',
].join('\n'));
const zhuNoQuotes = { evidenceKind: 'zhu' };
assert.equal(usableAsInstitution(zhuNoQuotes), false, '没填 quotesWhat 的注,一律不许放行');
// ★ 汉四科:注【引书】—— 刘昭注引应劭《汉官仪》载世祖诏,是东汉当代的官制手册,可以用
for (const id of ['han_dexing', 'han_xuetong', 'han_falv', 'han_gangyi']) {
const a = getAxis(id);
assert.equal(a.evidenceKind, 'zhu');
assert.ok(a.quotesWhat, `「${a.name}」是注,必须交代它引的是谁 —— 这是本文件原本就写着的口径`);
assert.equal(usableAsInstitution(a), true,
`★ 汉四科必须仍然可用 —— 它整段就在刘昭注引应劭《汉官仪》里,是本项目关于汉最硬的一条料`);
}
assert.equal(EVIDENCE.zhu.requiresQuotesWhat, true, '注这一类必须强制交代引的是谁');
});
test('★★★ 政论/发言/逸事/传序/本纪,一条都不许被当成制度陈述', () => {
const notInstitution = ['polemic', 'speech', 'instance', 'historianFrame', 'annal'];
for (const k of notInstitution) {
assert.equal(EVIDENCE[k].institution, false, `EVIDENCE.${k} 被标成了可作制度陈述`);
}
// 「上品无寒门」是请废九品的奏疏 —— 本项目在这上面栽过,这是回归断言
assert.equal(usableAsInstitution(getAxis('wj_menshi')), false,
'「上品無寒門」是政论,不是制度描述 —— dynasties.js 早有铁律,这里守着它');
// 而且引它必须连着引刘毅自己承认的例外
assert.ok(getAxis('wj_menshi').quote.includes('暨時有之,皆曲有故'),
'★ 引「上品無寒門」必须连着引「暨時有之,皆曲有故」—— 删掉后半句就是把它删成名言的那个手法');
// 文彦博那条的教训:引政论必须连着引结果
assert.ok(getAxis('han_jiafa').quote.includes('帝從之'),
'★ 左雄这条是【被采纳的】奏 —— 「帝從之,於是班下郡國」必须在引文里,否则它会被自家铁律误伤');
});
test('★★ 辽:全部条目 institution=false —— 志阙 ≠ 制度无', () => {
const liao = axesOf('liao');
assert.ok(liao.length >= 3);
for (const a of liao) {
assert.equal(a.evidenceKind, 'annal', `辽的「${a.name}」不是本纪散记?《辽史》没有选举志`);
assert.equal(usableAsInstitution(a), false,
`辽的「${a.name}」被当成了制度陈述 —— 那是几个事件,不是一条制度`);
assert.ok(/不許|不许/.test(a.caveat), `辽的「${a.name}」没写明不许由它推出通制陈述`);
}
});
// ── 6. 与引擎的 diff:这个 diff 本身就是交付物 ──
test('★ ENGINE_AXIS_DIFF 必须盖住引擎的六轴 + wang,且 verdict 合法', () => {
const covered = ENGINE_AXIS_DIFF.map((d) => d.axis);
for (const a of ['wen', 'fa', 'wu', 'jiao', 'heng', 'tan', 'wang']) {
assert.ok(covered.includes(a), `六轴里的「${a}」没有 diff —— 这个 diff 是交付物,不许漏`);
}
for (const d of ENGINE_AXIS_DIFF) {
assert.ok(VERDICTS[d.verdict], `「${d.name}」的 verdict 非法:${d.verdict}`);
assert.ok(d.note && d.note.length > 40, `「${d.name}」的 diff 没写清楚`);
}
});
test('★★ 「本轮所据原文中未见」不许被写成「原文里没有」—— 这是本项目栽过两次的坑', () => {
const noSource = ENGINE_AXIS_DIFF.filter((d) => d.verdict === 'noSource');
assert.ok(noSource.length >= 1, 'jiao/tan 两条就是');
for (const d of noSource) {
assert.ok(/未见|没找到|我没|不存在/.test(d.note),
`「${d.name}」标了 noSource,但没把「我没找到 ≠ 它不存在」写出来`);
}
assert.ok(/未见|≠ 不存在/.test(VERDICTS.noSource.name), 'noSource 这个 verdict 的名字本身就得带着限定语');
});
test('★★★ 加权求和在本轮所据原文里零次出现 —— 而这句话必须带着它的限定语', () => {
assert.equal(SHAPE_DIFF.weightedSumOccurrences, 0);
assert.ok(/措辞|不是对七朝制度的全称断言|这批引文/.test(SHAPE_DIFF.verdict),
'★ 「零次出现」是对【手上这批引文】的陈述,不是对七朝制度的全称断言 —— 这句限定语不许掉');
assert.ok(/gate(合取门槛)在形状上是对的/.test(SHAPE_DIFF.verdict),
'★ 结论的另一半也不许掉:阈值站得住,发明得最狠的是权重');
for (const s of SHAPE_DIFF.foundInSources) assert.ok(SHAPES[s], `SHAPE_DIFF 里的形状不在 SHAPES 里:${s}`);
});
test('★ 每一种 SHAPE 都必须在 AXES 里有实例 —— 不许有只存在于注释里的形状', () => {
const used = new Set(AXES.map((a) => a.shape));
for (const id of Object.keys(SHAPES)) {
assert.ok(used.has(id), `形状「${SHAPES[id].name}」在 AXES 里没有任何实例 —— 那它就是编的`);
}
});
// ══════════════════════════════════════════════════════════════════════════
// ★★ 2026-07-17 第四刀:这条断言【放宽了一格,而红线一寸没让】。改的理由记在这里。
//
// 【它原来是】`assert.equal(c.changed, false)` —— 标题是「一条都不许自作主张改 —— changed 必须恒 false」。
// ★ 而它的【理由】(写在断言的失败信息里,逐字)是:
// 「本刀的红线:若归纳出的轴与现有轴冲突,【不许自作主张替换】,写进 notes 报老板。
// 而且 dynasties.js 正被另一条线并发改(辽·多轨化)—— 抢同一块就是找冲突。」
// ⇒ 红线是【不许自作主张】+【别抢并发改的文件】。而断言写的是【changed 恒 false】。
// ★★ 这两句不是一回事:老板【拍了】的那一条,改它【不是自作主张】。
// 而并发那条线(辽·多轨化)早就落地了,抢文件的顾虑也不在了。
//
// 【为什么必须放宽 —— 不放宽,这张表会朝另一个方向说谎】
// 老板这一刀的原话:「跨 channel 的 tier 仍不可比而【没守】(chiduo #9)→ 加守卫或加断言。」
// ⇒ #9 已经落地(comparableTier + canReach 守卫 + assertInvariants 断言)。
// ⇒ 若还逼着它写 `changed: false`,这张表就会一直说「这条还没改,待老板拍」——
// ★【而那是假的,且它会让下一个人再去改一遍一件已经做完的事】。
// ★★ 先例就在本项目里,而且是同一个判据:第三刀删掉辽的 gateNote 自陈时写着 ——
// 「★ 判据很干净:那句话是【一个失真的自陈】。失真没了,自陈就得跟着没。
// ⇒ 而「诚实地报告一个已经修好的失真」,本身就是不诚实。」
// `changed: false` 就是那种自陈。冲突修好了,自陈得跟着改。
//
// 【新断言守什么 —— 只增不减】
// · changed === false ⇒ 照旧:where/finding/proposal 必须写全(一个字没让)。
// · changed === true ⇒ ★【必须交代是谁拍的、改在哪】(changedNote),否则就是自作主张。
// ⇒ 红线从「不许改」收紧成了【「改了必须留下判词」】—— 那才是红线本来要的东西:
// 它防的从来不是「改」,是【无人知晓地改】。
// ══════════════════════════════════════════════════════════════════════════
test('★ CONFLICTS:不许自作主张改 —— 改了的必须留下判词(谁拍的、改在哪)', () => {
assert.ok(CONFLICTS.length >= 5, '冲突要报全');
for (const c of CONFLICTS) {
assert.ok(c.where && c.finding && c.proposal, `冲突 #${c.id} 没写全(where/finding/proposal)`);
assert.equal(typeof c.changed, 'boolean', `冲突 #${c.id} 的 changed 必须是布尔 —— 不许含糊`);
if (c.changed === true) {
// ★ 改了 ⇒ 必须说清楚是谁拍的、改成了什么。没有判词的「已改」=自作主张。
assert.ok(
c.changedNote && c.changedNote.length > 40,
[
`冲突 #${c.id} 标了 changed: true,却没写 changedNote。`,
'★ 本刀的红线是【不许自作主张替换】——它防的不是「改」,是【无人知晓地改】。',
'改了就得留下判词:老板哪一句话拍的、落在哪个函数上、原文还在不在。',
].join('\n')
);
}
}
// ★ 而【绝大多数仍然必须是没改的】—— 这张表的性质是【提案表】,不是变更日志。
// 若哪天多数条目都成了 changed: true,那它就已经不是这张表了,得重新想它是什么。
const changed = CONFLICTS.filter((c) => c.changed === true);
assert.ok(
changed.length <= Math.floor(CONFLICTS.length / 2),
`${changed.length}/${CONFLICTS.length} 条被标成已改 —— 提案表变成了变更日志,该重新想它是什么了`
);
});
test('★ FORBIDDEN / FIELD_SPLIT / SKEW 得是活的,不是摆设', () => {
assert.ok(FORBIDDEN.length >= 5);
for (const f of FORBIDDEN) {
assert.ok(f.claim && f.verdict && f.detail, '「不许复活」清单每条都要有 claim/verdict/detail');
assert.ok(f.detail.length > 50, `「${f.claim}」的理由太短 —— 禁令不写清楚,下一轮就会被复活`);
}
assert.equal(FIELD_SPLIT.stages.length, 3, '入仕/服官/任事,三段');
assert.equal(FIELD_SPLIT.stages.filter((s) => s.inEngine).length, 1,
'★ 引擎只做了三段里的一段(入仕)—— 这是本文件对引擎最大的一条结构性发现');
assert.ok(SKEW.gaps.length >= 3, 'professions.js 对七朝的三处系统性偏斜:武/出身/经学');
assert.ok(SOURCE_AXES_MISSING_FROM_ENGINE.length >= 5, '原文有而六轴没有的,一条都不许漏');
const shen = SOURCE_AXES_MISSING_FROM_ENGINE.find((x) => x.axisId === 'tang_shen');
assert.ok(shen, '★ 唐擇人四才的第一条是「身:體貌豐偉」—— 长相。六轴里没有它,这条最刺眼,不许漏');
});
// ── 7. 纯函数 / 红线 ──
test('★ 纯函数:同一组输入跑两次,结果必须完全相同', () => {
assert.deepEqual(mapProfession('lawyer', 'han'), mapProfession('lawyer', 'han'));
assert.deepEqual(noRulerFor('doctor', 'han'), noRulerFor('doctor', 'han'));
assert.deepEqual(recurrences(), recurrences());
});
test('★ axesOf 返回的是副本 —— 调用方改不动这张表', () => {
const a = axesOf('han');
a.push({ id: 'injected' });
assert.notEqual(axesOf('han').length, a.length, 'axesOf 返回了内部数组的引用');
});
test('★★ 红线回归:本文件不许出现体力/次数/冷却/每日上限/洗点这一类字段与文案', () => {
const src = readFileSync(new URL('../src/chiduo.js', import.meta.url), 'utf8');
for (const bad of ['体力', '冷却', '每日上限', '洗点', '抽卡']) {
assert.ok(!src.includes(bad), `chiduo.js 里出现了「${bad}」—— 这是产品红线`);
}
});
test('★★ 法务回归:引文的省略必须用「……」标出,不许暗省(shiliao.js 有同款断言)', () => {
// ★ 这条断言的由来:dynasties.js 的汉四科 sourceText 在「察茂才尤異孝廉之吏。」处直接结句,
// 把「務授試以職」「并正舉者」两条机制省掉了,且没有「……」标记 —— 那是暗省。
// 本文件把这两条捡回来放进了 MECHANISMS,并在 CONFLICTS #3 报了老板。
for (const a of [...AXES, ...MECHANISMS]) {
if (a.quote.includes('..') && !a.quote.includes('……')) {
assert.fail(`「${a.name}」的引文用了「..」而不是「……」`);
}
}
const shouzhi = MECHANISMS.find((m) => m.id === 'han_shouzhi');
assert.ok(shouzhi && shouzhi.quote.includes('務授試以職') && shouzhi.quote.includes('并正舉者'),
'★ 四科能运转的两条机制(试职、举主连坐)必须在表里 —— dynasties.js 把它们省掉了');
});
test('★ MECHANISMS:不量人,但必须挂原文(它们不是轴,但同样不许编)', () => {
assert.ok(MECHANISMS.length >= 3);
for (const m of MECHANISMS) {
assert.ok(m.source && m.quote && m.caveat, `机制「${m.name}」缺 source/quote/caveat`);
assert.ok(DYNASTY_IDS.includes(m.dynastyId), `机制「${m.name}」的 dynastyId 非法`);
}
});
test('professionChannels 是提案,必须自陈未接线', () => {
const r = professionChannels('lawyer', 'han');
assert.deepEqual(r.channelIds, ['bizhao']);
assert.equal(r.proposalOnly, true, '★ 本函数不被 computeRank 调用,本轮也不接线');
assert.equal(r.inferred, true);
assert.equal(professionChannels('nope', 'han'), null);
});