583 lines
36 KiB
JavaScript
583 lines
36 KiB
JavaScript
// 尺度表的单测 —— 与 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);
|
||
});
|