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

189 lines
11 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.
// qian.js —— 迁转。★ 规矩写在注释里没用,写成断言才算数。
//
// ⚠ 本文件扫的是【数据与行为】,不是注释。
// (照 test/dynasties-shape.test.js 的先例:「它扫的是【数据】,不是注释」。)
import test from 'node:test';
import assert from 'node:assert/strict';
import {
QIAN_YUANWEN,
ZERO_HITS,
ONE_PERSON,
LAO_ALWAYS_BROKEN,
THE_CLOCK,
STOP_KINDS,
STRATA,
HAN_LADDER_SNAPSHOT,
qianPath,
assertQian,
assertSnapshot,
FINDINGS,
NOT_DONE,
} from '../src/qian.js';
import { computeAxes, DYNASTIES } from '../src/dynasties.js';
import { SUB_REGISTRY } from '../src/talents.js';
// ⚠ SUB_REGISTRY 是【数组】。★ 本刀在穷举驱动上栽过一次:写了 Object.keys() 拿到下标 "0".."15",
// 于是六轴全 40、汉的三条闸全关 —— 【结论没变(0 步),而理由整个是假的】。见 qian.js NOT_DONE ④。
const SUBS = SUB_REGISTRY.map((s) => s.id);
test('★ 体例自检:assertQian() 零违规', () => {
assert.deepEqual(assertQian(), []);
});
test('★ 抄件不许漂:HAN_LADDER_SNAPSHOT 与 dynasties.js 的真梯子逐格一致', () => {
assert.deepEqual(assertSnapshot(), []);
});
test('★ 每一条料都必须标股别(正文/本注/劉昭注/政书)—— 没有股别的料不许存在', () => {
const ids = Object.keys(STRATA);
for (const r of QIAN_YUANWEN) {
assert.ok(ids.includes(r.stratum), `${r.id} 的股别「${r.stratum}」不在 STRATA 里`);
assert.ok(r.source && r.quote, `${r.id} 没有 source/quote`);
assert.ok(r.caveat, `${r.id} 没有 caveat —— 限定语才是注的价值`);
}
});
test('★★ 单证必须自己说出「单证」—— 假单证比假零命中更阴险', () => {
for (const r of QIAN_YUANWEN) {
if (r.witnesses === 1) {
assert.match(String(r.witnessNote || ''), /单证/, `${r.id} 是单证却没说`);
}
}
});
test('★★★ 推演不许带卷次 —— 带卷次的错误不可反驳', () => {
for (const [name, o] of [['ONE_PERSON', ONE_PERSON], ['LAO_ALWAYS_BROKEN', LAO_ALWAYS_BROKEN]]) {
assert.equal(o.kind, 'tuiyan', `${name} 应当标 kind='tuiyan'`);
assert.match(o.noJuanci, /不给.{0,4}安卷次/, `${name} 没明说不给它安卷次`);
}
});
test('★★★★ 头号读数:全表【唯一一条】迁转规则不需要第二个人,而它两头都不在梯子上', () => {
const rules = QIAN_YUANWEN.filter((r) => r.from && r.to);
const solo = rules.filter((r) => r.needsSecondPerson === false);
assert.equal(solo.length, 1, '「一个人就能走」的迁转规则应当正好 1 条');
assert.equal(solo[0].id, 'guan_yezhe');
// ★ 起点 = 梯子 tier2(逐字「郎,比三百石」);终点 = null(四百石,梯子上没有)
assert.equal(solo[0].from.tier, 2, '起点应当是 tier2 —— 它变了,「起点不可达」要重读');
assert.equal(solo[0].to.tier, null, '终点应当是 null —— 它变了,「终点不存在」要重读');
const t2 = HAN_LADDER_SNAPSHOT.ladder.find((l) => l.tier === 2);
assert.equal(t2.grade, '比三百石', '梯子 tier2 的秩石应当与那条迁转的起点逐字相同');
// ★★ 而四百石【真的不在梯子上】—— 这一句是「终点不存在」的实证,不是我的印象
assert.ok(
!HAN_LADDER_SNAPSHOT.ladder.some((l) => l.grade === '四百石'),
'梯子上不该有「四百石」—— 它一旦有了,本表的头号结论要重读'
);
});
test('★★ 零命中必须带【非零的对照词】—— 没有对照的零命中,证明的是检索坏了', () => {
assert.match(ZERO_HITS.notZero, /殿最/);
for (const w of ZERO_HITS.words) assert.equal(w.hits, 0, `${w.w} 不是 0 就不该在这张表上`);
});
test('★★★ 老板红线③:数据里不许出现随机/抽卡这类词,代码里不许有 Math.random', async () => {
const json = JSON.stringify({ QIAN_YUANWEN, ONE_PERSON, THE_CLOCK, ZERO_HITS, STOP_KINDS });
for (const w of ['随机', '抽卡', '概率', '掷骰']) {
assert.ok(!json.includes(w), `数据里出现「${w}」`);
}
// ★ 扫源码:纯函数拿不到自己的源码,故由测试来扫。
//
// ⚠⚠ 【而这一条【必须先把注释剥掉】—— 它当场抓了我一次,记在这里】:
// 我第一版直接扫全文,于是 qian.js 里那句【「本函数没有 Math.random,也永远不许有」】
// —— 一句【禁令】—— 被扫描器判成了违规。
// ★ 这与 lao.js 记的是【同一族】,只是方向反了:那次是【数据里为了禁它而提到它】
// (「⇒ 扫描器是对的,我是错的:它分不出「使用」与「禁止」,而它【本来就不该分】——
// 一个会走到用户眼前的串里出现那个词,不管出于什么用意,都是红线。」);
// ⇒ 而本项目的体例逐字是:【禁令写在注释里(注释不被扫),数据里一个字都不许有】。
// ⇒ ★ 所以【是我的扫描器越界了】:它读了那个【专门用来放禁令的地方】。
// ⇒ 剥注释,再扫。⇒ 数据那一半(上面那个 JSON.stringify)仍然一个字都不许有。
const { readFileSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const raw = readFileSync(fileURLToPath(new URL('../src/qian.js', import.meta.url)), 'utf8');
const code = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
assert.ok(!/Math\.random/.test(code), 'qian.js 的【代码】里出现 Math.random —— 随机 = 抽卡 = 版号 = 死');
});
test('★ qianPath 是纯函数:同一份输入跑两次必须完全相同', () => {
const p = { axes: computeAxes(['rigorous', 'concise']), wang: 60 };
const a = qianPath(p, 'han', { lao: 4000 });
const b = qianPath(p, 'han', { lao: 4000 });
assert.deepEqual(a, b);
});
test('★ 失效方向朝关:问一个不存在的朝代,答 null(不许兜底挑一朝)', () => {
assert.equal(qianPath({ axes: computeAxes([]) }, 'TYPO', { lao: 100 }), null);
});
test('★★ 劳 → 歲 是本表的换算,且它【不进任何一个分】:单位是歲,不是 0–100', () => {
assert.equal(THE_CLOCK.suiOf(0), 0);
assert.equal(THE_CLOCK.suiOf(364), 0);
assert.equal(THE_CLOCK.suiOf(365), 1);
assert.equal(THE_CLOCK.suiOf(36500), 100);
// ★ 劳是天,不许被 clamp 到 100 —— 让单位本身挡住「跟六轴加起来平均」那件事(lao.js 的口径)
const r = qianPath({ axes: computeAxes([]) }, 'han', { lao: 36500 });
assert.equal(r.lao.days, 36500, '劳不许被 clamp 到 100');
assert.equal(r.lao.sui, 100);
});
test('★★ 另外六朝的 0 步,与汉的 0 步【不是一回事】—— stop kind 必须分得开', () => {
for (const d of DYNASTIES) {
const r = qianPath({ axes: computeAxes(['rigorous']), wang: 60 }, d.id, { lao: 4000 });
const kinds = r.stopped.map((s) => s.kind);
if (d.id === 'han') {
assert.ok(!kinds.includes('no-rule'), '汉不该报「本表没有这一朝的原文」—— 本刀抠了它');
assert.ok(kinds.includes('needs-second-person'), '汉必须报【要第二个人】那一条');
} else {
assert.ok(kinds.includes('no-rule'), `${d.id} 的 0 步是【我没干】,必须这么说`);
}
}
});
// ══════════════════════════════════════════════════════════════════════════
// ★★★★★ 实跑 —— 「0 步」不许只是推出来的。
//
// ⚠ 全量是【2^16 × 8 wang × 8 劳 × 7 朝 = 29,360,128 次】(本刀跑过,0 步,零例外)。
// CI 里跑缩版:2^16 画像 × 2 wang × 3 劳(含跨「滿歲」两侧)× 汉 = 393,216 次。
//
// ★★ 而这个测试【必须同时断言一个我知道答案的对照量】——
// 否则它会像本刀第一版那样:【打印出对的结论,而理由整个是假的】。见 qian.js NOT_DONE ④。
// 对照量 = 【汉的起点 tier 必须取到 {0, 3}】(kaoyan.js CONSTANT_PROOF 的解析结论)。
// 它一旦是 {0},就说明画像根本没进去 —— 那这场实验什么都没测。
// ══════════════════════════════════════════════════════════════════════════
test('★★★★★ 穷举实跑:汉 —— 起点 tier 只取到 {0,3}(★ 2 一次都不出现),而迁转恒 0 步', () => {
const startTiers = new Set();
let runs = 0;
let anyStep = 0;
for (let mask = 0; mask < 2 ** SUBS.length; mask++) {
const subs = SUBS.filter((_, i) => mask & (1 << i));
const axes = computeAxes(subs);
for (const wang of [0, 60]) {
for (const lao of [364, 365, 36500]) {
const r = qianPath({ axes, wang }, 'han', { lao });
runs += 1;
startTiers.add(r.start.tier);
if (r.steps.length) anyStep += 1;
}
}
}
// ⚠ 2026-07-17 加经学轴(talents.js 第 17 项 jingxue_tong):副天赋从 16 变 17 ⇒ 穷举底数 2^16 → 2^17。
// ★ 这是【真撞点】:SUBS.length 真的变了。jingxue_tong 对【汉】的六轴全无影响(它只落 jingxue 轴,
// 而汉的三条闸看 jiao/wang/fa/wu),⇒ 每个旧组合分裂成【含/不含 jingxue_tong】两份、汉的结果逐份相同
// ⇒ 起点 tier 仍只取到 {0,3}(下面那条断言未动)。变的只是组合数翻倍。
// ⚠ 2026-07-17 拆墙:穷举底数从引擎派生(2^SUBS.length),不写死 2^17 —— talents.js 加副天赋就自动跟上。
assert.equal(runs, 2 ** SUBS.length * 2 * 3);
// ★ 对照量:画像真的进去了没有 —— 这一条必须先过,否则下面那条断言毫无意义
assert.deepEqual([...startTiers].sort((a, b) => a - b), [0, 3], '汉的起点 tier 应当是 {0,3} —— 若是 {0},说明画像没进去,这场实验作废');
// ★★★ 头号结论:tier2 一次都不出现 ⇒ 那条唯一的迁转,起点对所有人不可达
assert.ok(!startTiers.has(2), '★ 没有一个人落在 tier2 —— 这就是「起点不可达」');
assert.equal(anyStep, 0, '★★ 汉:一步迁转都走不出来,零例外');
});
test('★ FINDINGS / NOT_DONE 必须是【报告】,不是【许愿】:每条都得有落点', () => {
for (const f of FINDINGS) {
assert.ok(f.title && f.where && f.finding && f.proposal, `FINDINGS ${f.id} 不完整`);
assert.equal(f.changed, false, '★ 本刀不许改别人的文件 —— changed 必须全 false');
}
for (const n of NOT_DONE) assert.ok(n.title && n.detail, `NOT_DONE ${n.id} 不完整`);
});