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

559 lines
34 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.
// 一部传 —— 路由 + 持久化的回归测试。
//
// ★ 这不是「覆盖率」测试。它钉的是【老板拍过的那五条结构,以及那条逃生口】——
// decisions.js 的纯函数部分已经有 decisions.test.js 钉着了;本组钉的是
// 【引擎接到 HTTP 上之后,那些结构还在不在】。
//
// 1. ★ 不可逆:答过的题不许再答(409)、已成的传不许再写(409)→ 没有读档
// 2. ★ 一朝成传:还有没写完的,不许开新的(409);另写一版要求上一部先 closed(409)
// 3. ★★ 逃生口:自由文本【记进传里,但一票不参与判定】—— 这一条是全案最狠的一句
// 4. ★★ 快照冻结:rankSnapshot 冻在开传那一刻
// 5. ★★ 级联删:删分身必须删 shishu_lineages(那个 Promise.all 已经漏过四次)
// 6. ★ 归属:别人的分身、别人的传,一律拿不到(IDOR)
//
// 需要本地 MongoDB;连不上则整组 skip(不阻塞无 DB 的 CI)。
process.env.NODE_ENV = 'test';
process.env.MONGO_DB = process.env.MONGO_DB_TEST || 'digital_me_test';
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import http from 'node:http';
// ★ 红线的可执行形式 —— 拿本项目【自己那一份】清单去扫接口的响应体,不另写一份(两处必分叉)。
import { assertNoGameMechanics } from '../src/decisions.js';
let server, base, db, closeDb, ObjectId, signToken, mongoUp = true;
let deleteTwin;
const OWNER = { id: null, email: 'lineage@test.local' };
const OTHER = { id: null, email: 'other@test.local' };
let twinId, otherTwinId;
// 样张那个人:严谨/有理有据/简洁/结论先行/耐心/沉稳,无声望。
// ★ 与 decisions.test.js / dynasties.test.js 的 CLERK 是同一个人 —— 三刀共用一个样板人,
// 对照才立得住。他在南朝是【中书通事舍人,tier 4】,两个关口都只发给他。
const CLERK = ['rigorous', 'datadriven', 'concise', 'shortfirst', 'patient', 'calm'];
before(async () => {
const dbm = await import('../src/db.js');
closeDb = dbm.closeDb;
try { db = await dbm.connectDb(); }
catch { mongoUp = false; return; }
({ ObjectId } = await import('mongodb'));
({ signToken } = await import('../src/middleware/auth.js'));
({ deleteTwin } = await import('../src/services/twin.js'));
const { shishuRouter } = await import('../src/routes/shishu.js');
OWNER.id = new ObjectId();
OTHER.id = new ObjectId();
await db.collection('users').insertMany([
{ _id: OWNER.id, email: OWNER.email },
{ _id: OTHER.id, email: OTHER.email },
]);
const t = await db.collection('twins').insertOne({
ownerId: OWNER.id, displayName: '某', talents: { sub_talents: CLERK }, isPublic: false,
});
twinId = t.insertedId;
const t2 = await db.collection('twins').insertOne({
ownerId: OTHER.id, displayName: '他', talents: { sub_talents: CLERK }, isPublic: false,
});
otherTwinId = t2.insertedId;
const app = express();
app.use(express.json());
app.use('/api/shishu', shishuRouter);
server = http.createServer(app).listen(0);
await new Promise((ok) => server.once('listening', ok));
base = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
if (server) server.close();
if (mongoUp) {
await db.collection('shishu_lineages').deleteMany({ twinId: { $in: [twinId, otherTwinId] } });
await db.collection('twins').deleteMany({ _id: { $in: [twinId, otherTwinId] } });
await db.collection('users').deleteMany({ _id: { $in: [OWNER.id, OTHER.id] } });
if (closeDb) await closeDb();
}
});
const auth = (u = OWNER) => ({ authorization: `Bearer ${signToken({ id: u.id.toString(), email: u.email })}` });
const get = (p, u) => fetch(base + p, { headers: auth(u) });
const post = (p, body, u) =>
fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json', ...auth(u) }, body: JSON.stringify(body || {}) });
const wipe = () => db.collection('shishu_lineages').deleteMany({ twinId });
const D1 = 'nanqi-sheren-simen';
const D2 = 'nanqi-qi-shidafu';
// 开一部南朝的传,返回 detail。★ 开完之后手上只有 D0(永明八年那一趟江)。
async function openNanchao() {
const r = await post(`/api/shishu/lineage/${twinId}`, { dynasty: 'nanchao' });
assert.equal(r.status, 200, '开传应当成功');
return r.json();
}
// ══════════════════════════════════════════════════════════════════════════
// ★★ 2026-07-17 第六刀:南齐的传现在【从永明八年那一趟江起】,不再从建武的四户起。
// D0(宣旨慰劳,永明 490)→ D1(四户,建武)→ D2(乞作士大夫,建武末)
//
// ⇒ 下面几条集成测试要的是【一部正站在关口一面前的传】—— 那件事一个字没变,
// 变的只是「怎么走到关口一」。故加一个 helper,把它们各自的断言原样留着。
// ⚠ 用 'bu-ken-wang'(历史答案)走那一步,是因为它的 effects 里 quan 是 0
// ⇒ 关口二的 state:{quan:0} 那一关不受它影响
// ⇒ 下面所有「守住那一户才开得了第二道口」的断言,测的仍然是【关口一的那个选择】。
// ══════════════════════════════════════════════════════════════════════════
const D0 = 'nanqi-xuanzhi';
async function openAtD1() {
const d = await openNanchao();
const id = d.lineage.id;
const r = await post(`/api/shishu/lineage/${twinId}/${id}/decide`, {
decisionId: D0, optionId: 'bu-ken-wang',
});
assert.equal(r.status, 200, '永明八年那一趟江应当答得掉');
const b = await r.json();
assert.deepEqual(b.decisions.map((x) => x.id), [D1], '过了永明,手上应当正好是关口一');
return b;
}
test('★ 代位:开一部传 —— 位阶快照 + 开着的关口', async (t) => {
if (!mongoUp) return t.skip('无本地 MongoDB,跳过 DB 集成测试');
await wipe();
const d = await openNanchao();
assert.equal(d.lineage.dynasty, 'nanchao');
assert.equal(d.lineage.status, 'open');
// ★★ 位阶【冻在开传那一刻】—— 这一格是样张里那个人:中书通事舍人,tier 4。
assert.equal(d.lineage.rank.channel.id, 'sheren');
assert.equal(d.lineage.rank.rank.tier, 4);
// ★★ 第六刀:开局手上只有【关口三·宣旨慰劳】(永明八年 490)——
// 关口一(四户,建武)的 afterDecisions 要求先过它,关口二再要求先过关口一。
// ⇒ 一部南齐的传现在是三格,从永明起,照时序走。
assert.deepEqual(d.decisions.map((x) => x.id), [D0]);
// ★ 性格锁:只列「你这种人会考虑的念头」,且【必须带 persona】—— 没有它就成了灰按钮。
for (const o of d.decisions[0].options) assert.ok(o.persona, '每个选项必须带 persona(性格锁)');
// ✗ 选完之后才该露面的东西,一个都不许提前吐出来。
const raw = JSON.stringify(d.decisions[0]);
assert.ok(!raw.includes('historical'), 'historical 不许在选之前吐给前端');
assert.ok(!/"fate"|"inherited"|"effects"/.test(raw), 'fate/inherited/effects 不许在选之前吐给前端');
});
test('★★ 一朝成传:还有没写完的,不许开新的(409)', async (t) => {
if (!mongoUp) return t.skip('无本地 MongoDB');
await wipe();
await openNanchao();
const r = await post(`/api/shishu/lineage/${twinId}`, { dynasty: 'tang' });
assert.equal(r.status, 409, '不同时开二十四个坑');
assert.equal((await r.json()).error, 'has-open-lineage');
});
test('★★★ 不可逆:答过的题不许再答;已成的传不许再写 —— 一律 409,不静默', async (t) => {
if (!mongoUp) return t.skip('无本地 MongoDB');
await wipe();
const d = await openAtD1(); // ★ 第六刀:先过永明八年那一趟江
const id = d.lineage.id;
// 选「守住这一户」→ 关口二打开(state{quan:0} 过、afterDecisions 过、closedByOptions 没撞)
const r1 = await post(`/api/shishu/lineage/${twinId}/${id}/decide`, { decisionId: D1, optionId: 'shou-hu' });
assert.equal(r1.status, 200);
const d1 = await r1.json();
assert.deepEqual(d1.decisions.map((x) => x.id), [D2], '关口二应当打开 —— 这就是「关口串联」');
assert.equal(d1.state.values.li, 3, '守住这一户 → 利 +3(状态量由链推出)');
assert.equal(d1.lineage.status, 'open');
// ★ 同一道题,再答一次 → 409。【选了就是选了,没有读档。】
const again = await post(`/api/shishu/lineage/${twinId}/${id}/decide`, { decisionId: D1, optionId: 'zuo-shi' });
assert.equal(again.status, 409, '答过的题不许再答');
assert.equal((await again.json()).error, 'decision-taken');
// 走完关口二 → 一朝成传
const r2 = await post(`/api/shishu/lineage/${twinId}/${id}/decide`, { decisionId: D2, optionId: 'bu-qi' });
assert.equal(r2.status, 200);
const d2 = await r2.json();
assert.equal(d2.lineage.status, 'closed', '链走完 → 自动成传');
assert.equal(d2.decisions.length, 0);
// ★ 已成的传不许再写 → 409
const after = await post(`/api/shishu/lineage/${twinId}/${id}/decide`, { decisionId: D1, optionId: 'zuo-shi' });
assert.equal(after.status, 409, '传已成,不可再书');
assert.equal((await after.json()).error, 'lineage-closed');
});
test('★ 位阶够不到 / 前序没过的关口:根本碰不到(409),且不告诉他差在哪', async (t) => {
if (!mongoUp) return t.skip('无本地 MongoDB');
await wipe();
const d = await openNanchao();
const id = d.lineage.id;
// 开局直接答关口二 —— afterDecisions 没过。★ 不是「你没解锁」,是这道题没发到你手上。
const r = await post(`/api/shishu/lineage/${twinId}/${id}/decide`, { decisionId: D2, optionId: 'bu-qi' });
assert.equal(r.status, 409);
const b = await r.json();
assert.equal(b.error, 'decision-not-open');
// ⚠ 故意不说「你差在哪」:不在场的人不该看见这道题的存在,看见了就变成灰按钮。
assert.ok(!/差|需要|解锁|quan|tier/.test(b.message), `不许告诉他差在哪:${b.message}`);
});
test('★★ 永久关闭:伸手碰过令文的人,士林那边的门就关死了 —— 关口二一次都不出现', async (t) => {
if (!mongoUp) return t.skip('无本地 MongoDB');
await wipe();
const d = await openAtD1(); // ★ 第六刀:先过永明八年那一趟江
const id = d.lineage.id;
// zuo-shi 的 quan 是 +2,state{quan:0} 那一关它过得去 —— 关它的是 closedByOptions。
const r = await post(`/api/shishu/lineage/${twinId}/${id}/decide`, { decisionId: D1, optionId: 'zuo-shi' });
const b = await r.json();
assert.equal(b.state.values.quan, 2, 'zuo-shi → 权 +2:state 那一关它过得去');
assert.equal(b.decisions.length, 0, '★ 关口二被 closedByOptions 永久关闭 → 链走完');
assert.equal(b.lineage.status, 'closed', '★ 一朝成传:他这一朝只有一道题。这不是失败态。');
});
test('★★★ 逃生口:你自己写的那个念头 —— 记进传里,但【一票不参与判定】', async (t) => {
if (!mongoUp) return t.skip('无本地 MongoDB');
await wipe();
const d = await openAtD1(); // ★ 第六刀:先过永明八年那一趟江
const id = d.lineage.id;
const aside = '南迁。带着这一户的文簿板籍,去江北另立一个中书省。';
const r = await post(`/api/shishu/lineage/${twinId}/${id}/decide`, {
decisionId: D1, optionId: 'shou-hu', aside,
});
assert.equal(r.status, 200);
const b = await r.json();
// ① 它被记进了传里
// ⚠ 第六刀:chain[0] 现在是永明那一趟江,写着 aside 的是 chain[1](关口一那一步)。
// ★ 故按 decisionId 找,不按下标 —— 下标会在加关口的那天悄悄指向错的那一步,
// 而这条测试要钉的是「aside 记在【它被写下的那一步】上」。
const step = b.lineage.chain.find((c) => c.decisionId === D1);
assert.ok(step, '关口一那一步应当在链上');
assert.equal(step.aside, aside, '★ 逃生口必须记进传里');
assert.ok(
!b.lineage.chain.find((c) => c.decisionId === D0).aside,
'★ 逃生口只记在你写它的那一步上 —— 不许溢到别的关口'
);
// ② ★★ 它【没有改变引擎的判定】—— 与不写它的那一部,逐字相同。
await wipe();
const d2 = await openAtD1();
const r2 = await post(`/api/shishu/lineage/${twinId}/${d2.lineage.id}/decide`, {
decisionId: D1, optionId: 'shou-hu',
});
const b2 = await r2.json();
assert.deepEqual(b.state.values, b2.state.values, '★ 逃生口不许动状态量');
assert.deepEqual(
b.decisions.map((x) => x.id), b2.decisions.map((x) => x.id),
'★ 逃生口不许开/关任何一个关口'
);
assert.equal(b.lineage.status, b2.lineage.status, '★ 逃生口不许改变这本书写完了没有');
});
test('★★ 成传:正文 + 注 + 史臣曰 + 赞曰;逃生口成了传里的一句旁注', async (t) => {
if (!mongoUp) return t.skip('无本地 MongoDB');
await wipe();
const d = await openAtD1(); // ★ 第六刀:先过永明八年那一趟江
const id = d.lineage.id;
// 没写完 → 409:这不是错误,是「书还没写到那儿」。
const early = await get(`/api/shishu/lineage/${twinId}/${id}/zhuan`);
assert.equal(early.status, 409);
assert.equal((await early.json()).error, 'lineage-open');
await post(`/api/shishu/lineage/${twinId}/${id}/decide`, {
decisionId: D1, optionId: 'zuo-shi', aside: '南迁',
});
const r = await get(`/api/shishu/lineage/${twinId}/${id}/zhuan`);
assert.equal(r.status, 200);
const z = await r.json();
// ★ 体例自检必须全绿
assert.deepEqual(z.violations, [], `正文体例违规:${JSON.stringify(z.violations)}`);
assert.deepEqual(z.shizan.violations, [], '赞的体例必须合《南齐书》之例(四言、八句、偶句押韵、无重韵)');
// ★ 正文 —— 与样张对得上(docs/样传-南齐-中书通事舍人.md 第二节)
assert.ok(z.zhengwenText.includes('不知何许人也'), '★ 拒绝给传主安籍贯');
assert.ok(z.zhengwenText.includes('议格不行'), '★ zuo-shi 的下场');
assert.ok(z.zhengwenText.includes('尝有意于「南迁」,然终不果'), '★★ 逃生口:一句旁注,进正文');
// ★★★ 而它没有变成一条通道:这本书的结局仍然由他【真正选的那一步】决定。
assert.ok(z.zhengwenText.includes('子孙无闻'), '★ 逃生口不是自定义结局');
// ★ 史臣曰 + 赞曰 —— 传末该给用户看的是这一篇(一卷一篇),不是关口级的
assert.ok(z.shizan.shichen.includes('史臣曰'));
// ══════════════════════════════════════════════════════════════════════
// ★★ 2026-07-17 第六刀:【起联变了,而这是对的】—— 记原因,因为它牵着一份文档。
//
// shizan.js 的取法逐字:「起 ← 链首那一格 —— 这个人是从哪儿起的」。
// ⇒ 加了「宣旨慰劳」(永明八年)之后,南齐的传【从永明起】,不再从建武的四户起
// ⇒ 起联从「省内四户,机要所因」变成「巴东杀僚,衔命及津」。
// ★ 这不是回归,这正是那条取法在起作用:他确实不是从四户起的了。
//
// ⚠⚠ 而它同时说明一件事,本刀已在 docs/样传-南齐-中书通事舍人.md 顶上挂了过期声明:
// 那份样张自称「以下为 shizanForLineage(lineage) 的【实际输出,原样粘贴】」——
// ★ 加一道题,这句话当场变成假话。⇒ 【一份自称「原样粘贴」的文档,是有保质期的】,
// 而没有任何东西看着它过期。⇒ 这条测试是唯一发现它过期的东西(因为它钉着样张)。
// ★ 故这一行【不许改成宽松匹配】:它钉得越死,样张过期的那天就越早被发现。
// ══════════════════════════════════════════════════════════════════════
assert.deepEqual(z.shizan.zan, ['巴东杀僚', '衔命及津', '欲著为令', '不恃恩亲', '念之愈深', '去死愈邻', '后有需者', '故历梁陈'],
'★ 起联 ← 链首那一格:南齐的传现在从永明八年那一趟江起,故起联是「巴东杀僚,衔命及津」');
// ★★ 注:每一条都必须有 caveat —— 注的价值就是它。
for (const zh of z.zhu) assert.ok(zh.caveat && zh.caveat.trim(), `注〔${zh.n}〕缺 caveat`);
// ★★★ 推演绝不许带卷次:带卷次的错误比没出处的错误危害大十倍,因为它不可反驳。
for (const zh of z.zhu) if (zh.kind === 'tuiyan') assert.ok(!zh.source, `注〔${zh.n}〕推演却安了卷次`);
// ★ 逃生口那一条注:它连推演都不是,它是你的字。
const aside = z.zhu.find((x) => x.what.includes('逃生口'));
assert.ok(aside && !aside.source, '逃生口的注不许安卷次');
assert.ok(/不改变引擎的判定/.test(aside.caveat));
// ★ 影响档:三档,且是【档位标签,不是测算值】
// ⚠ 第六刀:steps[0] 现在是永明那一趟江(事层 ⇒ 0 年),著为令那一步成了 steps[1]。
// ★ 故按 decisionId 找,【不按下标】—— 下标会在加关口的那天悄悄指向另一步,
// 而这条测试要钉的是「zuo-shi 这一念传两百年」,不是「第一步传两百年」。
const zuoStep = z.steps.find((s) => s.decisionId === D1);
assert.ok(zuoStep, '著为令那一步应当在传里');
assert.equal(zuoStep.impact.years, 200);
assert.ok(/档位标签/.test(zuoStep.impact.precisionNote), '★ 前端不许拿它画曲线,这句话必须给');
// ★★ 而同一部传里,永明那一趟江是【事】层 ⇒ 恒 0 年(弥合)——
// 这两行摆在一起,正是本产品的那句话:同一个人,同一部传,两念,两个下场。
const jiangStep = z.steps.find((s) => s.decisionId === D0);
assert.equal(jiangStep.impact.years, 0, '★ 事层恒被弥合:一支军队真的破了,而五十年内没留下一个字');
});
// ══════════════════════════════════════════════════════════════════════════
// ★★ 六条走得通的链,【逐条】都必须合体例 —— 这一条是 2026-07-17 验收补的,记原因:
//
// 上面那条测试【只走了 zuo-shi 一条链】,然后断言 violations 为空,绿了。
// ⇒ 而 zuo-shi 恰好是【fate.cites 为空】的那一种 ⇒ 它恰好走的是 NO_JUANCI 那个分支
// ⇒ 于是「有旁证时不说『不给它安卷次』」这个洞,从 401 条测试底下【整个躲过去了】。
// ★ 实测:六条链里有两条真的违规(qiu-ming 单步;shou-hu → qi-zuo),而 qi-zuo 正是
// 关口二的【历史答案】—— 即:最像史书的那条路,跑出来的传自己不合体例。
// ★★ 判别式(这才是这条测试的价值):【测了一条链 ≠ 测了这个函数】。
// 句库是按【决策点 × 选项】分的,那么断言就必须按【决策点 × 选项】遍历 ——
// 挑一条好看的链去断言,等于让数据自己挑考题。
// ⚠ 纯函数,不吃 DB:它是 buildZhuan 的体例断言,不是路由的回归。故不 skip。
// ══════════════════════════════════════════════════════════════════════════
test('★ 六条走得通的链,逐条都必须合体例(不许只挑一条好看的去断言)', async () => {
const { buildZhuan } = await import('../src/zhuan.js');
const { openLineage, appendDecision, availableDecisions, stateOf } = await import('../src/decisions.js');
const { computeRank, computeAxes } = await import('../src/dynasties.js');
// 样张那个人(与本文件 CLERK 同一个)→ 南朝【中书通事舍人,tier 4】。
const rank = computeRank({ axes: computeAxes(CLERK), wang: 0, lao: 0, chushen: null }, 'nanchao');
assert.equal(rank.channel.id, 'sheren', '样板人必须站在那一户里,否则这条测试测的是别人');
// ★ 穷举:从空链起,把每一条走得通的链都走到 closed 为止。
// ⚠ 不写死那六条 —— 写死就会在加第三个关口的那天悄悄漏掉新的那几条。
const walk = (L, acc) => {
if (L.closed) { acc.push(L); return acc; }
for (const d of availableDecisions(stateOf(L))) {
for (const o of d.options) acc = walk(appendDecision(L, d.id, o.id), acc);
}
return acc;
};
const all = walk(openLineage({ subjectId: 'x', rank, existing: [], edition: 1 }), []);
assert.ok(all.length >= 6, `走得通的链只有 ${all.length} 条 —— 少于 6 条,说明穷举没跑对`);
for (const L of all) {
const path = L.chain.map((s) => s.optionId).join(' → ');
const z = buildZhuan(L, { person: { name: '某' } });
assert.ok(z, `${path}:成传返回 null`);
// ★★ 体例自检:这是 zhuan.js 对用户唯一的承诺,一条链都不许违。
assert.deepEqual(z.violations, [], `【${path}】正文体例违规:${JSON.stringify(z.violations)}`);
assert.deepEqual(z.shizan.violations, [], `【${path}】赞的体例违规`);
// ★★★ 推演绝不许带卷次 —— 逐条链再钉一遍(上面那条只钉了 zuo-shi 那一条链)。
for (const zh of z.zhu) {
assert.ok(zh.caveat && zh.caveat.trim(), `【${path}】注〔${zh.n}〕缺 caveat`);
if (zh.kind === 'tuiyan') assert.ok(!zh.source, `【${path}】注〔${zh.n}〕推演却安了卷次`);
}
// ★ 下场那一条注:它永远是推演 ⇒ 它【永远】要自己说出「不给它安卷次」,
// ⚠ 有没有旁证都一样 —— 有旁证时【尤其】要说,那一刻它底下正躺着一段带卷次的原文。
const mo = z.zhu.find((x) => x.what.startsWith('下场'));
assert.ok(mo, `【${path}】没有下场那一条注`);
assert.equal(mo.kind, 'tuiyan', `【${path}】下场永远是推演:这个人不存在,他的下场当然无史料`);
assert.ok(
/不给.{0,4}安卷次|无史料/.test(mo.caveat),
`【${path}】下场的注没说「无史料/不给它安卷次」—— ` +
'★ 这一条曾经真的漏过:NO_JUANCI 挂在 fateCites 的 else 支上,' +
'于是【有旁证时反而不说】,而那正是最该说的时候。'
);
}
});
// ══════════════════════════════════════════════════════════════════════════
// ★★★ 2026-07-17 验收补的这一条:【测了一朝 ≠ 测了这个函数】
//
// 上面那条测试自己写着判别式:「测了一条链 ≠ 测了这个函数 …… 挑一条好看的链去断言,
// 等于让数据自己挑考题」。★ 而它自己【只跑南朝的舍人那一格】——
// 于是同一个陷阱在下一个尺度上原样复发:【它挑了一朝好看的】。
//
// 【它漏掉的那个 bug(验收当场量出来的)】
// 汉·tier5(孝廉 → 九卿/三公),一道题都不用答,buildZhuan 直接吐:
// 「注〔4〕标了【原文】却没有 source/quote —— ✗ 没有出处的不许标成原文」
// ⇒ ★ assertZhuan 【一直是对的、一直在响】:没有人跑过汉的传,所以没有人听见。
// ⇒ ★★ 而它【不看链】:0 步的传就坏了 —— 比任何一条链都靠前。
// 上面那条测试从「走得通的链」出发,而【汉一条链都走不通(HAN_DECISIONS 没上线)】,
// 所以汉在它眼里【根本不存在】。★ 用「有没有题」当入口,就永远测不到「没有题的那些人」——
// 而那正是老板这一刀点名要的那些人。
//
// ⇒ 故这一条【从 rank 出发,不从链出发】:每一朝 × 每一条 track × 每一格,
// 开一部空传就断言体例。⚠ 不写死朝代名与格数 —— 写死就会在加朝的那天重新漏掉。
// ⚠ 纯函数,不吃 DB。
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 每一朝 × 每一格,空链的传都必须合体例(汉/唐曾经在这里坏了很久)', async () => {
const { buildZhuan } = await import('../src/zhuan.js');
const { openLineage } = await import('../src/decisions.js');
const { DYNASTIES } = await import('../src/dynasties.js');
let n = 0;
for (const d of DYNASTIES) {
for (const t of (d.tracks || [])) {
for (const step of (t.ladder || [])) {
// 这一格上,每一条【属于这条 track】的通道都试一遍;tier0 再加一个「一条路都不开」。
const chans = d.channels.filter((c) => c.track === t.id);
const cases = chans.map((c) => ({ id: c.id, name: c.name }));
if (step.tier === 0) cases.push(null);
for (const ch of cases) {
const rank = {
dynasty: { id: d.id, name: d.name, era: d.era, span: d.span },
channel: ch,
rank: { title: step.title, grade: step.grade, unit: t.unit, tier: step.tier },
};
const L = { ...openLineage({ subjectId: 'sweep', rank }), closed: true };
const z = buildZhuan(L, { person: { name: '某' } });
assert.ok(z, `${d.id}/${t.id}/tier${step.tier}/${ch?.id ?? '(无通道)'}:成传返回 null`);
n += 1;
assert.deepEqual(
z.violations, [],
`【${d.id}·${t.id}·tier${step.tier}·${ch?.id ?? '(无通道)'}】体例违规:` +
`${JSON.stringify(z.violations)}\n正文:${z.zhengwenText}`
);
}
}
}
}
assert.ok(n >= 40, `只扫了 ${n} 格 —— 太少,说明遍历没跑对`);
});
test('★★ 另写一版:要求上一部先写完(409);写完了才给开,且旧的那部仍然在、仍然可读', async (t) => {
if (!mongoUp) return t.skip('无本地 MongoDB');
await wipe();
const d = await openAtD1(); // ★ 第六刀:先过永明八年那一趟江
const id = d.lineage.id;
// ★ 未完结就另开 = 在你后悔的那一步弃书重来 = 读档换了个名字。→ 409
const early = await post(`/api/shishu/lineage/${twinId}/another`, {});
assert.equal(early.status, 409);
assert.equal((await early.json()).error, 'previous-not-closed');
await post(`/api/shishu/lineage/${twinId}/${id}/decide`, { decisionId: D1, optionId: 'zuo-shi' });
const r = await post(`/api/shishu/lineage/${twinId}/another`, {});
assert.equal(r.status, 200);
const d2 = await r.json();
assert.equal(d2.lineage.edition, 2);
assert.equal(d2.lineage.precededBy, d.lineage.lineageId, '★ precededBy 不是父节点,是「同一个人的上一部书」');
assert.deepEqual(d2.lineage.chain, [], '★ 新的一版从空链起:不继承旧链的任何一步(从中途分叉就是读档)');
// ★★★ 这一条是「另写一版不是读档」的全部内容:旧的那部【仍然在、仍然可读】。
const list = await (await get(`/api/shishu/lineage/${twinId}`)).json();
assert.equal(list.items.length, 2, '两部都成立,两部都在');
const old = list.items.find((x) => x.lineageId === d.lineage.lineageId);
assert.equal(old.status, 'closed');
// ⚠ 第六刀:链长 1 → 2(永明那一趟江 + 建武的著为令)。
// ★ 断言的是【旧的那部没被新的一版动过】—— 故钉【那一步还在不在】,不钉链长:
// 链长会随着加关口一直变,而「旧的那部没被动过」这件事不会变。
assert.equal(old.chain.length, 2, '旧的那部一个字都没动');
assert.equal(old.chain.at(-1).optionId, 'zuo-shi', '★ 旧的那部最后那一步仍是他当初选的那个');
const oldZhuan = await get(`/api/shishu/lineage/${twinId}/${old.id}/zhuan`);
assert.equal(oldZhuan.status, 200, '★ 旧的那部仍然读得出一部完整的传');
});
test('★ 归属:别人的分身、别人的传,一律拿不到(IDOR)', async (t) => {
if (!mongoUp) return t.skip('无本地 MongoDB');
await wipe();
const d = await openNanchao();
// 别人的分身 → 404(loadTwin / getTwin 是全站唯一入口)
assert.equal((await get(`/api/shishu/lineage/${otherTwinId}`, OTHER)).status, 200);
assert.equal((await get(`/api/shishu/lineage/${twinId}`, OTHER)).status, 404, '别人的分身拿不到');
// ★★ 自己的分身 + 别人的传 id → 404。getLineage 把 twinId 钉进 filter,防的就是这一手。
const mine = await post(`/api/shishu/lineage/${otherTwinId}`, { dynasty: 'nanchao' }, OTHER);
const otherId = (await mine.json()).lineage.id;
const cross = await get(`/api/shishu/lineage/one/${twinId}/${otherId}`);
assert.equal(cross.status, 404, '★ 用自己的分身去读别人的传,必须拿不到');
await db.collection('shishu_lineages').deleteMany({ twinId: otherTwinId });
// 没 token → 401(★ 401 是基线)
assert.equal((await fetch(`${base}/api/shishu/lineage/${twinId}`)).status, 401);
// 乱七八糟的 id 不许把请求炸掉(getTwin 内部有 ObjectId.isValid 守卫)
assert.equal((await get(`/api/shishu/lineage/one/${twinId}/not-an-id`)).status, 404);
assert.equal((await get('/api/shishu/lineage/zzz')).status, 404);
void d;
});
test('★★★ 级联删:删分身必须删 shishu_lineages —— 那个 Promise.all 已经漏过四次', async (t) => {
if (!mongoUp) return t.skip('无本地 MongoDB');
// 单独造一个分身来删,别把上面几组共用的那个删了。
const tmp = await db.collection('twins').insertOne({
ownerId: OWNER.id, displayName: '某', talents: { sub_talents: CLERK }, isPublic: false,
});
const tid = tmp.insertedId;
const d = await (await post(`/api/shishu/lineage/${tid}`, { dynasty: 'nanchao' })).json();
// ★ 里面存着用户【自己写的字】(逃生口)—— 删分身不删它,就是用户以为删干净了而它还在。
await post(`/api/shishu/lineage/${tid}/${d.lineage.id}/decide`, {
decisionId: D1, optionId: 'zuo-shi', aside: '南迁',
});
assert.equal(await db.collection('shishu_lineages').countDocuments({ twinId: tid }), 1);
assert.equal(await deleteTwin(String(tid), String(OWNER.id)), true);
assert.equal(
await db.collection('shishu_lineages').countDocuments({ twinId: tid }), 0,
'★★★ 删分身必须级联删 shishu_lineages(出身/位阶快照/整条链/★用户自己写的那句念头)'
);
});
test('🚫 红线:这一层【不许】有体力/次数/冷却/每日上限/连续天数/签到', async (t) => {
if (!mongoUp) return t.skip('无本地 MongoDB');
await wipe();
const d = await openNanchao();
const id = d.lineage.id;
const detail = await (await get(`/api/shishu/lineage/one/${twinId}/${id}`)).json();
const list = await (await get(`/api/shishu/lineage/${twinId}`)).json();
await post(`/api/shishu/lineage/${twinId}/${id}/decide`, { decisionId: D1, optionId: 'zuo-shi' });
const zh = await (await get(`/api/shishu/lineage/${twinId}/${id}/zhuan`)).json();
// ★★ 拿【本项目自己的那条可执行红线】去扫接口的响应体,不另写一份清单:
// assertNoGameMechanics 同时扫【字段名】(结构性风险:有人哪天加一个 dailyLimit)
// 与【文案】(话术性风险:有人哪天写一句「今天没次数了」)。
// ⚠ 另写一份清单 = 两处规则,迟早分叉 —— 而这一条是【版号级】的,不许分叉。
for (const [name, payload] of [['开传', d], ['全貌', detail], ['列表', list], ['成传', zh]]) {
assert.deepEqual(
assertNoGameMechanics(payload), [],
`★ 红线(${name}):一个字段名就能把产品推回「网络游戏」的认定(版号)。这是红线,不是偏好。`
);
}
// ★★★ 读档那一族:【只扫字段名,不扫文案】—— 这个区别是本组最要紧的一条,写在这里:
// 本产品最核心的一句文案【就是】「选了就是选了,没有读档」——
// ⇒ 禁令的文本必须写着那两个字。★【禁止吸烟的牌子上必须有「吸烟」二字,牌子不是违禁品。】
// (decisions.js 给 SERIAL_NOTE 开的正是同一个例外,理由逐字相同。)
// ⇒ 所以这里扫的是【键】:一个 undo / rollback 的【接口】才是复活读档,
// 一句「没有读档」的【话】恰恰是在杀它。混着扫,就会逼着人把那句话删掉 ——
// ★ 那才是这条红线真正会输的方式:断言把禁令本身当成了违禁品。
const badKeys = [];
const seen = new Set();
const walkKeys = (node, path) => {
if (!node || typeof node !== 'object' || seen.has(node)) return;
seen.add(node);
for (const [k, v] of Object.entries(node)) {
if (/^(undo|revert|rollback|reopen|respec|reset|replay|restore|snapshot)/i.test(k)) {
badKeys.push(`${path}.${k}`);
}
walkKeys(v, `${path}.${k}`);
}
};
walkKeys([d, detail, list, zh], 'root');
assert.deepEqual(badKeys, [], '★ 红线:接口吐出了读档类的字段 —— 选了就是选了,没有读档');
// 而那句【话】必须在 —— 它是老板拍过的那条结构,用户必须读得到。
assert.ok(/没有读档/.test(detail.irreversibleNote), '★「选了就是选了,没有读档」这句话必须给用户看');
assert.ok(/另写一版/.test(detail.irreversibleNote), '★ 想换活法的唯一出口,必须一起给');
});