#!/usr/bin/env node
// 自测:覆盖 novel-outline.mjs 里所有确定性逻辑。
// 不调用任何模型,不花额度,跑一次 < 1 秒。
// node scripts/selftest.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
ADAPT_MODES,
DEFAULT_PER_VOLUME,
DEFAULT_THRESHOLDS,
MAX_VOLUMES,
RISK_PATTERNS,
STAGES,
chunkVolumes,
computeAssets,
detectChapters,
fmtEps,
gateReport,
primarySceneCap,
renderHtml,
renderMarkdown,
slug,
validateOutline,
} from './novel-outline.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const FIXTURE = JSON.parse(readFileSync(join(here, '..', 'examples', '渡口-outline.json'), 'utf8'));
let passed = 0;
function ok(cond, msg) {
assert.ok(cond, msg);
passed++;
}
function eq(actual, expected, msg) {
assert.strictEqual(actual, expected, `${msg} — 期望 ${expected},实际 ${actual}`);
passed++;
}
const clone = () => JSON.parse(JSON.stringify(FIXTURE));
const gate = (o, id) => gateReport(o).find((g) => g.id === id);
/* ---------------- chunk ---------------- */
const book = Array.from({ length: 30 }, (_, i) => `第${i + 1}章 标题\n\n正文${'内容'.repeat(50)}`).join('\n\n');
eq(detectChapters(book).length, 30, '识别出 30 个章节标题');
ok(detectChapters('楔子 雾\n\n正文\n\n第一章 渡口\n\n正文').length === 2, '楔子也算章节标题');
ok(detectChapters('Chapter 12 The Ferry\n\ntext').length === 1, '英文 Chapter 也认');
const byChapter = chunkVolumes(book, 10);
eq(byChapter.mode, 'chapter', '有章节就按章分卷');
eq(byChapter.volumes.length, 3, '30 章 ÷ 每卷 10 章 = 3 卷');
eq(byChapter.chapters, 30, '章数报对');
eq(byChapter.truncated, false, '没超限不报截断');
ok(byChapter.volumes[0].includes('第1章') && byChapter.volumes[0].includes('第10章'), '第一卷装前 10 章');
ok(byChapter.volumes[2].includes('第30章'), '最后一卷装到尾');
const intro = '开篇引子没有章节号\n\n第一章 渡口\n\n正文\n\n第二章 雾\n\n正文';
ok(chunkVolumes(intro, 10).volumes[0].startsWith('开篇引子'), '章前引子归进第一卷');
const plain = 'X'.repeat(45_000);
const bySize = chunkVolumes(plain, 10);
eq(bySize.mode, 'size', '识别不出章节就按字数切');
ok(bySize.volumes.length >= 2, '长文本切成多块');
eq(chunkVolumes('', 10).volumes.length, 0, '空文本零卷');
const huge = Array.from({ length: MAX_VOLUMES + 5 }, (_, i) => `第${i + 1}章 x\n\n正文`).join('\n\n');
const capped = chunkVolumes(huge, 1);
eq(capped.volumes.length, MAX_VOLUMES, `超限截到 ${MAX_VOLUMES} 卷`);
eq(capped.truncated, true, '超限必须明确报 truncated');
/* ---------------- slug ---------------- */
eq(slug('渡口'), '渡口', '中文书名保留');
eq(slug('a/b:c'), 'a-b-c', '危险字符替换');
/* ---------------- 夹具本身 ---------------- */
eq(validateOutline(FIXTURE).length, 0, '自带样例通过 full 校验');
eq(validateOutline(FIXTURE, 'skeleton').length, 0, '样例通过 skeleton 校验');
eq(validateOutline(FIXTURE, 'beats').length, 0, '样例通过 beats 校验');
ok(gateReport(FIXTURE).every((g) => g.ok), '样例全部质量门通过');
eq(gateReport(FIXTURE).length, 14, '质量门共 14 项');
/* ---------------- props 叙事道具 ---------------- */
{
const clone = () => JSON.parse(JSON.stringify(FIXTURE));
// 自带样例带 props,两道相关的门都该过
ok(Array.isArray(FIXTURE.props) && FIXTURE.props.length > 0, '自带样例带上了叙事道具');
ok(gate(FIXTURE, 'prop-cap').ok, '样例的道具数在上限内');
ok(gate(FIXTURE, 'refs').ok, '样例的道具引用完整');
ok(gate(FIXTURE, 'refs').label.includes('道具'), '有 props 时 refs 门的措辞点出道具');
// --- 向后兼容:没有 props 字段的旧大纲必须照常通过,不是报错 ---
// 这条是这次改动最要紧的一条断言。存量 outline.json 一份都没有 props,
// 如果这两道门判失败,等于所有旧大纲一升级就全红。
const old = clone(); delete old.props;
for (const e of old.episodes) delete e.propIds;
eq(validateOutline(old).length, 0, '旧大纲没有 props 照常通过 validate');
ok(gate(old, 'prop-cap').ok, '旧大纲的道具上限门跳过而不是失败');
ok(gate(old, 'prop-cap').detail.includes('跳过'), '跳过要明说,不静默');
ok(gate(old, 'refs').ok, '旧大纲的引用门照常通过');
ok(!gate(old, 'refs').label.includes('道具'), '没有 props 时 refs 门的措辞不提道具');
eq(gateReport(old).length, 14, '旧大纲的门数一样是 14,跳过不等于少一道门');
// --- 击穿:propIds 指向不存在的道具 ---
const a = clone(); a.episodes[0].propIds = ['P09'];
ok(!gate(a, 'refs').ok, 'propIds 指向不存在的道具被拦下');
ok(gate(a, 'refs').detail.includes('P09'), '点名是哪个 id');
// --- 击穿:登记了但从没在任何一集出现 ---
const b = clone(); b.props.push({ id: 'P03', name: '油纸伞', function: '没人用它' });
ok(!gate(b, 'refs').ok, '零集使用的道具被拦下——跟失业角色同一个判据');
ok(gate(b, 'refs').detail.includes('P03'), '点名是哪件道具');
// --- 击穿:beatIds 指向不存在的爽点 ---
const c = clone(); c.props[0].beatIds = ['B99'];
ok(!gate(c, 'refs').ok, 'beatIds 指错被拦下——指错等于这件道具没有戏剧理由');
// --- 击穿:超过上限 ---
const d = clone();
for (let i = 3; i <= 10; i += 1) {
const id = `P${String(i).padStart(2, '0')}`;
d.props.push({ id, name: `道具${i}`, function: '凑数' });
d.episodes[0].propIds.push(id);
}
ok(!gate(d, 'prop-cap').ok, '道具超过 8 件被拦下');
ok(gate(d, 'prop-cap').detail.includes('10'), '报出实际件数');
// --- 击穿:结构层 ---
const e1 = clone(); delete e1.props[0].function;
ok(validateOutline(e1).some((x) => x.includes('function')), '缺 function 被拦——填不出来的不是道具是背景');
const e2 = clone(); e2.props[0].id = 'X01';
ok(validateOutline(e2).some((x) => x.includes('P01 这种格式')), '道具 id 格式被拦');
const e3 = clone(); e3.props[1].id = 'P01';
ok(validateOutline(e3).some((x) => x.includes('重复')), '道具 id 重复被拦');
const e4 = clone(); e4.props[0].beatIds = 'B01';
ok(validateOutline(e4).some((x) => x.includes('beatIds')), 'beatIds 不是数组被拦');
// --- 资产清单是算出来的,不是模型写的 ---
const assets = computeAssets(FIXTURE);
eq(assets.props.length, FIXTURE.props.length, '资产清单汇总了全部道具');
const p01 = assets.props.find((x) => x.id === 'P01');
const epsWithP01 = FIXTURE.episodes.filter((x) => (x.propIds ?? []).includes('P01')).map((x) => x.ep);
assert.deepEqual(p01.episodes, epsWithP01, '出现集是从 episodes[].propIds 反查出来的');
eq(p01.uses, epsWithP01.length, '次数跟出现集对得上');
eq(computeAssets(old).props.length, 0, '旧大纲的道具清单是空数组,不是 undefined');
}
eq(STAGES.join(','), 'skeleton,beats,full', '三档 stage');
ok(ADAPT_MODES.includes('抽核'), '改编幅度枚举');
/* ---------------- 质量门逐项击穿 ---------------- */
// 每一道门都要证明它真的会拦——不然就是永远为真的假测试
// G1a–G1c 角色分档上限
{
const o = clone();
for (let i = 6; i <= 9; i++) {
o.characters.push({ id: `C0${i}`, name: `主角${i}`, tier: 'lead', role: '主', arc: '有弧', from: ['原创'] });
o.episodes[0].characterIds.push(`C0${i}`);
}
ok(!gate(o, 'lead-cap').ok, '6 个主角被拦(上限 5)');
ok(gate(o, 'support-cap').ok, '配角档不受主角档超限影响');
ok(validateOutline(o).some((x) => x.includes('主角组') && x.includes('超过上限')), 'validate 报主角组超限');
}
{
const o = clone();
for (let i = 6; i <= 16; i++) {
o.characters.push({ id: `C${String(i).padStart(2, '0')}`, name: `伙计${i}`, tier: 'functional', role: '功能', from: ['原创'] });
o.episodes[0].characterIds.push(`C${String(i).padStart(2, '0')}`);
}
ok(!gate(o, 'functional-cap').ok, '11 个功能性角色被拦(上限 10)');
}
{
const o = clone();
o.characters.forEach((c) => { if (c.tier === 'lead') c.tier = 'support'; });
ok(!gate(o, 'lead-cap').ok, '一个主角都没有也被拦——没有主角的剧不成立');
ok(validateOutline(o, 'skeleton').some((x) => x.includes('没有主角组')), 'skeleton 档就报缺主角');
}
// 阈值可覆盖
{
const o = clone();
o.params.thresholds = { maxLeads: 1 };
ok(!gate(o, 'lead-cap').ok, '阈值收紧到 1,2 个主角就超');
eq(DEFAULT_THRESHOLDS.maxLeads, 5, '主角组默认上限 5');
eq(DEFAULT_THRESHOLDS.maxSupport, 10, '重要配角默认上限 10');
eq(DEFAULT_THRESHOLDS.maxFunctional, 10, '功能性角色默认上限 10');
}
// 分档的结构规则
{
const o = clone();
o.characters[0].tier = 'boss';
ok(validateOutline(o, 'skeleton').some((x) => x.includes('tier')), '未知 tier 被拦');
}
{
const o = clone();
delete o.characters[0].arc; // 沈知微是 lead
ok(validateOutline(o, 'skeleton').some((x) => x.includes('arc')), '主角缺人物弧被拦');
}
{
const o = clone();
delete o.characters[4].arc; // 更夫是 functional,本来就没有 arc 字段
eq(validateOutline(o, 'skeleton').length, 0, '功能性角色不要求人物弧——医生就是来缝针的');
}
// G2 主场景上限——随集数动态:clamp(4 + ⌈集数/10⌉, 5, 15)
// 这是 AI 短剧的数:场景是生成的没有搭景钱,上限守的是一致性资产和空间认知
eq(primarySceneCap(6), 5, '6 集微型剧给 5 个主场景');
eq(primarySceneCap(20), 6, '20 集给 6');
eq(primarySceneCap(30), 7, '30 集给 7');
eq(primarySceneCap(60), 10, '60 集给 10');
eq(primarySceneCap(100), 14, '100 集给 14');
eq(primarySceneCap(200), 15, '再长也封顶 15');
eq(primarySceneCap(undefined), 8, '没有集数信息给居中值 8');
{
const o = clone(); // 夹具 6 集 → 上限 5
for (let i = 4; i <= 9; i++) o.scenes.push({ id: `S0${i}`, name: `景${i}`, primary: true });
o.episodes[0].sceneIds.push('S04', 'S05', 'S06', 'S07', 'S08', 'S09');
ok(!gate(o, 'scene-cap').ok, '6 集的剧开 8 个主场景被拦');
ok(gate(o, 'scene-cap').label.includes('≤ 5'), '门的标签显示动态算出的上限');
o.params.episodes = 60; // 只为验证上限跟着集数走——集数变了其他门会另行报错
ok(gate(o, 'scene-cap').ok, '同样 8 个主场景,60 集就放行');
o.params.episodes = 6;
o.params.thresholds = { maxPrimaryScenes: 9 };
ok(gate(o, 'scene-cap').ok, '显式覆盖优先于动态值——放宽');
o.params.thresholds = { maxPrimaryScenes: 3 };
o.params.episodes = 60;
ok(!gate(o, 'scene-cap').ok, '显式覆盖优先于动态值——收紧也一样');
}
// G3 一次性场景没有规避方案
{
const o = clone();
delete o.scenes[2].reusePlan; // S03 只用了一次
ok(!gate(o, 'once-scene').ok, '一次性场景缺规避方案被拦');
ok(gate(o, 'once-scene').detail.includes('芦苇'), '报错点名是哪个场景');
}
// G4 爽点间隔
{
const o = clone();
o.beats = o.beats.filter((b) => b.id !== 'B02'); // 1 → 5 之间断档
ok(!gate(o, 'beat-gap').ok, '第 1–5 集断档被拦');
ok(gate(o, 'beat-gap').detail.includes('断档'), '报的是断档');
}
{
const o = clone();
o.beats.forEach((b) => (b.episode = Math.min(b.episode + 3, 6)));
o.beats[0].episode = 4; // 开头真空
ok(!gate(o, 'beat-gap').ok, '开头 3 集真空被拦');
}
{
const o = clone();
o.beats = o.beats.filter((b) => b.episode <= 3);
ok(!gate(o, 'beat-gap').ok, '结尾真空被拦');
// beats 档就要拦住间隔问题,不能等写完分集才发现
ok(validateOutline(o, 'beats').some((x) => x.includes('爽点间隔')), 'beats 档就报间隔');
}
// G5 第 1 集钩子
{
const o = clone();
o.episodes[0].hook = ' ';
ok(!gate(o, 'ep1-hook').ok, '第 1 集没钩子被拦');
}
// G6 大爆点时机
{
const o = clone();
o.beats.forEach((b) => (b.weight = 'minor'));
o.beats[3].weight = 'major'; // 唯一 major 在第 6 集(最后一集)
ok(!gate(o, 'major-early').ok, 'major 只在最后一集被拦');
ok(validateOutline(o, 'beats').some((x) => x.includes('大爆点')), 'beats 档就报大爆点');
}
{
const o = clone();
o.beats.forEach((b) => (b.weight = 'minor'));
ok(!gate(o, 'major-early').ok, '一个 major 都没有也被拦');
}
// G7 三栏齐全
{
const o = clone();
o.episodes[3].suspense = '';
ok(!gate(o, 'ep-fields').ok, '缺悬念栏被拦');
ok(gate(o, 'ep-fields').detail.includes('4'), '报错点名第 4 集');
}
// G8 同框拆解
{
const o = clone();
delete o.episodes[0].crowdPlan; // 第 1 集 4 人同框
ok(!gate(o, 'crowd-plan').ok, '三人以上没有拆解方案被拦');
}
{
const o = clone();
o.episodes[1].characterIds = ['C01', 'C04']; // 两个人不需要
delete o.episodes[1].crowdPlan;
ok(gate(o, 'crowd-plan').ok, '两人同框不强制拆解方案');
}
// G9 生成难点预警
{
const o = clone();
o.episodes[2].synopsis += '雨点砸在船篷上。';
ok(!gate(o, 'risk-flag').ok, '梗概出现雨戏没进预警被拦');
o.episodes[2].warnings = ['雨戏'];
ok(gate(o, 'risk-flag').ok, '标了预警就放行');
}
ok(Object.keys(RISK_PATTERNS).length === 4, '四类生成难点');
ok(RISK_PATTERNS['肢体接触'].test('两人拥抱'), '拥抱触发肢体接触');
ok(RISK_PATTERNS['人群'].test('集市上'), '集市触发人群');
// G10 引用完整
{
const o = clone();
o.episodes[0].sceneIds.push('S99');
ok(!gate(o, 'refs').ok, '引用不存在的场景被拦');
}
{
const o = clone();
o.episodes.forEach((e) => (e.characterIds = e.characterIds.filter((id) => id !== 'C04')));
o.episodes[0].characterIds = ['C01', 'C02', 'C03'];
ok(!gate(o, 'refs').ok, '失业角色被拦');
ok(gate(o, 'refs').detail.includes('C04'), '报错点名失业的是谁');
}
{
const o = clone();
o.episodes.forEach((e) => (e.sceneIds = e.sceneIds.filter((id) => id !== 'S03')));
ok(!gate(o, 'refs').ok, '空转场景被拦');
}
{
const o = clone();
o.beats[0].episode = 99;
ok(!gate(o, 'refs').ok, '爽点落在不存在的集被拦');
}
// G11 叙述体
{
const o = clone();
o.episodes[1].synopsis = '胡二爷说:「你这箱子里是金条吧。」';
ok(!gate(o, 'no-dialogue').ok, '梗概里写对白被拦');
}
{
const o = clone();
o.episodes[1].hook = '他说“跟我走”算不算威胁';
ok(!gate(o, 'no-dialogue').ok, '弯引号也被拦');
}
/* ---------------- validate 结构检查 ---------------- */
ok(validateOutline(null).length === 1, 'null 直接报');
{
const o = clone();
o.params.adaptMode = '魔改';
ok(validateOutline(o, 'skeleton').some((x) => x.includes('adaptMode')), '未知改编幅度被拦');
}
{
const o = clone();
o.params.genre = '';
ok(validateOutline(o, 'skeleton').some((x) => x.includes('genre')), '题材缺失被拦——它决定爽点类型');
}
{
const o = clone();
o.adaptation.cut = [];
ok(validateOutline(o, 'skeleton').some((x) => x.includes('没砍')), '抽核却一条没砍被拦');
o.params.adaptMode = '忠实';
ok(!validateOutline(o, 'skeleton').some((x) => x.includes('没砍')), '忠实改编允许不砍');
}
{
const o = clone();
o.characters[0].id = 'X1';
ok(validateOutline(o, 'skeleton').some((x) => x.includes('C01 这种格式')), '角色 id 格式被拦');
}
{
const o = clone();
o.characters[1].id = 'C01';
ok(validateOutline(o, 'skeleton').some((x) => x.includes('重复')), '角色 id 重复被拦');
}
{
const o = clone();
o.characters[0].from = [];
ok(validateOutline(o, 'skeleton').some((x) => x.includes('改动记录')), '缺 ← 改动记录被拦');
}
{
const o = clone();
delete o.scenes[0].primary;
ok(validateOutline(o, 'skeleton').some((x) => x.includes('primary')), '场景缺 primary 被拦');
}
{
const o = clone();
o.beats[0].weight = 'huge';
ok(validateOutline(o, 'beats').some((x) => x.includes('weight')), '未知 weight 被拦');
}
{
const o = clone();
o.beats[0].setup = '';
ok(validateOutline(o, 'beats').some((x) => x.includes('setup')), '爽点缺铺垫被拦');
}
{
const o = clone();
o.episodes.pop();
ok(validateOutline(o).some((x) => x.includes('说好 6 集')), '集数对不上被拦');
}
{
const o = clone();
o.episodes[2].ep = 9;
ok(validateOutline(o).some((x) => x.includes('编号必须从 1 连续')), '集号断裂被拦');
}
// stage 分档:skeleton 不看 beats/episodes
{
const o = clone();
delete o.beats;
delete o.episodes;
eq(validateOutline(o, 'skeleton').length, 0, 'skeleton 档不要求 beats/episodes');
ok(validateOutline(o, 'beats').some((x) => x.includes('beats 为空')), 'beats 档要求爽点表');
ok(validateOutline(o, 'full').length > 0, 'full 档要求全部');
}
/* ---------------- 资产清单 ---------------- */
const assets = computeAssets(FIXTURE);
eq(assets.scenes.length, 3, '资产清单收全部场景');
{
const s01 = assets.scenes.find((s) => s.id === 'S01');
eq(s01.uses, 6, 'S01 每集都用');
eq(s01.episodes.join(','), '1,2,3,4,5,6', 'S01 出现集列表');
const s03 = assets.scenes.find((s) => s.id === 'S03');
eq(s03.uses, 1, 'S03 只用一次');
ok(s03.reusePlan, '一次性场景带着复用方案');
}
{
const c04 = assets.characters.find((c) => c.id === 'C04');
eq(c04.uses, 6, '胡二爷每集都在');
const c02 = assets.characters.find((c) => c.id === 'C02');
eq(c02.episodes.join(','), '1,2,3,4,6', '陆行远第 5 集缺席');
}
// 角色资产量折算:按档算出来的,不让模型写
{
const plan = Object.fromEntries(assets.castPlan.map((t) => [t.tier, t]));
eq(plan.lead.count, 2, '主角组 2 人');
eq(plan.support.count, 2, '重要配角 2 人');
eq(plan.functional.count, 1, '功能性角色 1 人');
ok(plan.lead.spec.includes('设定图'), '主角组折算成全套设定图');
ok(plan.functional.spec.includes('提示词'), '功能性角色折算成提示词直出');
ok(plan.functional.names.includes('岸上挑灯的更夫'), '折算表带名单');
}
eq(assets.warnings['人群'].join(','), '2', '预警清单按类型汇总');
eq(assets.beatsByType['身份揭破'].join(','), '3', '爽点按类型汇总落点');
/* ---------------- render markdown ---------------- */
const md = renderMarkdown(FIXTURE);
ok(md.startsWith('# 渡口 · 短剧改编大纲'), 'MD 标题');
ok(md.includes('6 集 × 2 分钟'), 'MD 带参数行');
for (const sec of ['一、改编说明', '二、人物表', '三、爽点表', '四、分集梗概', '五、资产清单']) {
ok(md.includes(sec), `MD 有${sec}`);
}
ok(md.includes('(由分集数据自动汇总)'), 'MD 标明资产清单是算出来的');
ok(md.includes('【钩子】'), 'MD 钩子栏');
ok(md.includes('【悬念】'), 'MD 悬念栏');
ok(md.includes('✅'), 'MD 带质量门结果');
ok(md.includes('合并:岸边问路的路人甲乙'), 'MD 人物表带 ← 改动记录');
ok(md.includes('主角组'), 'MD 人物表带层级');
ok(md.includes('角色资产量折算'), 'MD 资产清单带按档折算');
// 人物表按档排序:主角组在前
ok(md.indexOf('| C01 |') < md.indexOf('| C05 |'), '主角排在功能性角色前面');
/* ---------------- render html ---------------- */
const html = renderHtml(FIXTURE);
ok(html.startsWith(''), 'HTML 完整文档');
ok(!/了吗';
const x = renderHtml(o).match(/id="outline-data">([\s\S]*?)<\/script>/)[1];
ok(!x.includes(''), '默认中文界面,lang="zh"');
const enHtml = renderHtml(FIXTURE, 'en');
ok(enHtml.includes(''), '英文界面 lang="en"');
ok(enHtml.includes('Export JSON'), 'EN 报告有 Export JSON 按钮');
ok(enHtml.includes('Key decisions'), 'EN 报告有 Key decisions 区块');
ok(enHtml.includes('Dispatch matrix'), 'EN 报告有 Dispatch matrix 区块');
ok(enHtml.includes('Beat rhythm') && enHtml.includes('Scene overview'), 'EN 报告有 Beat rhythm 和 Scene overview');
ok(enHtml.includes('Asset conversion') && enHtml.includes('Quality gates'), 'EN 报告有 Asset conversion 和 Quality gates');
ok(!enHtml.includes('导出 JSON'), 'EN 报告不含中文导出按钮');
ok(!enHtml.includes('关键决策'), 'EN 报告不含中文关键决策标题');
ok(!enHtml.includes('每集调度矩阵') && !enHtml.includes('资产量折算'), 'EN 报告不含中文区块标题');
ok(enHtml.includes('悬念钩'), 'EN 报告里爽点类型是数据,保持原样');
ok(enHtml.includes('Leads 1–5'), 'EN 报告的质量门标签翻译且阈值原样保留');
ok(!enHtml.includes('主角组 1–5 人'), 'EN 报告不再出现中文门标签');
ok(enHtml.includes('Episode 1'), 'EN 分集卡标题');
// outline.json 顶层 lang 字段生效,显式 --lang 优先
{
const o = clone();
o.lang = 'en';
ok(renderHtml(o).includes(''), 'outline.lang=en 时默认出英文界面');
ok(renderHtml(o, 'zh').includes(''), '显式 lang 参数优先于 outline.lang');
}
const enMd = renderMarkdown(FIXTURE, 'en');
ok(enMd.startsWith('# 渡口 · Short-Drama Adaptation Outline'), 'EN MD 标题');
ok(enMd.includes('1. Adaptation notes') && enMd.includes('5. Asset list'), 'EN MD 章节标题');
// 质量门 label(含【钩子】字样)是数据不翻译,只查界面上的栏目标签
ok(enMd.includes('**[Hook]**') && !enMd.includes('**【钩子】**'), 'EN MD 钩子栏用英文方括号');
// 非法语言直接抛错,不静默回退
{
let threw = '';
try {
renderHtml(FIXTURE, 'fr');
} catch (e) {
threw = e.message;
}
ok(threw.includes('zh / en'), '非法语言抛错——界面只内置 zh / en');
}
eq(DEFAULT_PER_VOLUME, 15, '默认每卷 15 章');
console.log(`✓ ${passed} 项自测全部通过`);