数字我 Digital Me:全量入库(大文件走 Git LFS)
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s

This commit is contained in:
2026-10-10 16:24:43 +08:00
commit 0593ff7e62
1893 changed files with 361723 additions and 0 deletions
+558
View File
@@ -0,0 +1,558 @@
// 一部传 —— 路由 + 持久化的回归测试。
//
// ★ 这不是「覆盖率」测试。它钉的是【老板拍过的那五条结构,以及那条逃生口】——
// 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), '★ 想换活法的唯一出口,必须一起给');
});