559 lines
34 KiB
JavaScript
559 lines
34 KiB
JavaScript
// 一部传 —— 路由 + 持久化的回归测试。
|
||
//
|
||
// ★ 这不是「覆盖率」测试。它钉的是【老板拍过的那五条结构,以及那条逃生口】——
|
||
// 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), '★ 想换活法的唯一出口,必须一起给');
|
||
});
|