#!/usr/bin/env node
// novel-script 自测:不调模型、不花额度,只验确定性逻辑。
// 原则与仓库里其他 skill 一致:每道质量门都要有击穿用例——
// 证明它真的会拦,不是一个永远为真的假测试。
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
DEFAULT_PARAMS,
computeStats,
gateReport,
lineChars,
paramsOf,
renderHtml,
renderMarkdown,
sceneSeconds,
seedFromOutline,
slug,
validateScript,
} from './novel-script.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const FIXTURE = JSON.parse(readFileSync(join(here, '../examples/渡口-script.json'), 'utf8'));
const OUTLINE = JSON.parse(readFileSync(join(here, '../references/test-fixtures/upstream/渡口-outline.json'), 'utf8'));
const ART = JSON.parse(readFileSync(join(here, '../references/test-fixtures/upstream/渡口-art.json'), 'utf8'));
const CAST = JSON.parse(readFileSync(join(here, '../references/test-fixtures/upstream/渡口-cast.json'), 'utf8'));
const CTX = { outline: OUTLINE, art: ART };
let passed = 0;
function ok(cond, label) {
assert.ok(cond, label);
passed += 1;
}
function eq(actual, expected, label) {
assert.equal(actual, expected, `${label} — 期望 ${expected},实际 ${actual}`);
passed += 1;
}
const clone = (x) => structuredClone(x);
const gate = (doc, id, ctx = {}) => gateReport(doc, ctx).find((g) => g.id === id);
/* ---------------- 时长引擎 ---------------- */
eq(lineChars('你好,世界。'), 6, '标点算时间——停顿也是时间');
eq(lineChars(' 你 好 '), 2, '空白不算字符');
eq(lineChars(''), 0, '空串为零');
eq(lineChars(null), 0, 'null 不崩');
const sc = {
flow: [
{ action: '雾漫上来。' },
{ speaker: 'C01', line: '一二三四五六七八九' }, // 9 字 → 2 秒
{ action: '船离岸。' },
],
};
const sec = sceneSeconds(sc);
eq(sec.action, 5, '两个动作节拍 × 2.5 秒');
eq(sec.dialogue, 2, '9 字 ÷ 4.5 字每秒 = 2 秒');
eq(sec.total, 7, '总时长 = 台词 + 动作');
eq(sceneSeconds({ flow: [] }).total, 0, '空节拍流为零秒');
eq(paramsOf({}).charsPerSecond, DEFAULT_PARAMS.charsPerSecond, '默认参数生效');
eq(paramsOf({ params: { charsPerSecond: 6 } }).charsPerSecond, 6, 'params 可覆盖语速');
eq(paramsOf({ params: { charsPerSecond: 6 } }).tolerance, DEFAULT_PARAMS.tolerance, '只覆盖给出的键');
eq(sceneSeconds(sc, { ...DEFAULT_PARAMS, charsPerSecond: 9 }).dialogue, 1, '语速参数参与计算');
/* ---------------- computeStats ---------------- */
const stats = computeStats(FIXTURE);
eq(stats.totals.episodes, 6, '样例全六集');
eq(stats.totals.scenes, 9, '样例九场');
eq(stats.totals.lines, 123, '样例台词句数');
ok(stats.totals.estSeconds > 600 && stats.totals.estSeconds < 750, '预估总时长在目标带附近');
eq(stats.totals.targetSeconds, 720, '目标秒数汇总');
ok(stats.episodes.every((e) => e.est >= 102 && e.est <= 138), '每一集都落在 ±15% 容差带内');
ok(stats.episodes[0].dialogueSeconds > 0 && stats.episodes[0].actionSeconds > 0, '台词与动作分开计秒');
eq(stats.sceneTable.length, 9, '场次总表九行');
eq(stats.sceneTable[0].sceneId, 'S02', '场次总表按出场顺序');
ok(stats.sceneTable[0].lineCount > 0, '场次表带台词句数');
eq(stats.castLines.length, 6, '台词本六个说话人(含画外音,含第 6 集才开口的更夫)');
ok(stats.castLines[0].count >= stats.castLines[stats.castLines.length - 1].count, '台词本按句数降序');
const voEntry = stats.castLines.find((c) => c.id === 'VO');
ok(voEntry, '画外音单独成组');
eq(voEntry.lines[0].ep, 1, '台词条目带集号');
ok(voEntry.lines[0].sceneId === 'S01', '台词条目带场景');
/* ---------------- 质量门:全绿基线 ---------------- */
ok(gateReport(FIXTURE, CTX).every((g) => g.ok), '样例带上游全部门通过');
ok(gateReport(FIXTURE).every((g) => g.ok), '不带上游也全部通过(对账门跳过)');
eq(gateReport(FIXTURE).length, 10, '十道门');
/* ---------------- 质量门:逐门击穿 ---------------- */
// duration — 写超
{
const doc = clone(FIXTURE);
for (let i = 0; i < 30; i++) doc.episodes[0].scenes[0].flow.push({ action: `加戏第 ${i} 拍。` });
const g = gate(doc, 'duration');
ok(!g.ok, '写超时长被拦');
ok(g.detail.includes('超'), '超时报得出秒数');
}
// duration — 写欠
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes = [doc.episodes[0].scenes[0]];
const g = gate(doc, 'duration');
ok(!g.ok, '写欠时长被拦');
ok(g.detail.includes('欠'), '欠时报得出秒数');
}
// duration — 容差可配
{
const doc = clone(FIXTURE);
doc.params = { tolerance: 0.01 };
ok(!gate(doc, 'duration').ok, '容差收紧到 1% 后原样例不再达标');
}
// line-length
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].flow.push({ speaker: 'C03', line: '这句台词故意写得非常非常长,长到一口气根本读不完,纯粹为了击穿单句上限这道门而存在。' });
const g = gate(doc, 'line-length');
ok(!g.ok, '超长台词被拦');
ok(g.detail.includes('字'), '报出字数');
}
// speaker
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].flow.push({ speaker: 'C99', line: '我不在这场里。' });
ok(!gate(doc, 'speaker').ok, '不在本场的说话人被拦');
}
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].flow.push({ speaker: 'VO', line: '画外音不受本场人物限制。' });
ok(gate(doc, 'speaker').ok, 'VO 合法');
}
// hook-cliff
{
const doc = clone(FIXTURE);
doc.episodes[0].hook = ' ';
ok(!gate(doc, 'hook-cliff').ok, '空钩子被拦');
}
{
const doc = clone(FIXTURE);
delete doc.episodes[0].cliff;
ok(!gate(doc, 'hook-cliff').ok, '缺结尾悬念被拦');
}
// hook-open — 钩子必须在全集前 3 拍内兑现(认领机制)
{
const doc = clone(FIXTURE);
delete doc.episodes[0].hookBeat;
const g = gate(doc, 'hook-open');
ok(!g.ok, '缺 hookBeat 被拦');
ok(g.detail.includes('hookBeat'), '报出缺的字段');
}
{
const doc = clone(FIXTURE);
doc.episodes[0].hookBeat = [2, 1]; // 第 2 场第 1 拍 = 全集第 14 拍
const g = gate(doc, 'hook-open');
ok(!g.ok, '钩子落在第 14 拍被拦——冷开场是门不是建议');
ok(g.detail.includes('第 14 拍'), '报出实际位置');
}
{
const doc = clone(FIXTURE);
doc.episodes[0].hookBeat = [9, 9];
ok(gate(doc, 'hook-open').detail.includes('不存在'), '指向不存在的节拍被拦');
}
{
const doc = clone(FIXTURE);
doc.episodes[0].hookBeat = [2, 1];
doc.params = { hookWindow: 20 };
ok(gate(doc, 'hook-open').ok, '钩子窗口可按需放宽');
}
// has-action
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].flow = doc.episodes[0].scenes[0].flow.filter((b) => typeof b.line === 'string');
const g = gate(doc, 'has-action');
ok(!g.ok, '纯对白的场(广播剧)被拦');
ok(g.detail.includes('S02'), '点名到场');
}
// action-prose
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].flow.push({ action: '老周说「坐稳了」,随即撑篙。' });
ok(!gate(doc, 'action-prose').ok, '动作里混台词引号被拦');
}
// beats-claimed
{
const doc = clone(FIXTURE);
doc.episodes[0].beatsClaimed = [];
ok(!gate(doc, 'beats-claimed', CTX).ok, '大纲爽点没认领被拦');
ok(gate(doc, 'beats-claimed').ok, '没给大纲时本门跳过');
ok(gate(doc, 'beats-claimed').detail.includes('跳过'), '跳过要明说,不静默');
}
// refs-characters
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].characters.push('C99');
ok(!gate(doc, 'refs-characters', CTX).ok, '大纲里没有的角色被拦');
ok(gate(doc, 'refs-characters').ok, '没给大纲时本门跳过');
}
// refs-scenes:场景 / 光照 / 道具三个方向都要拦
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].sceneId = 'S99';
ok(!gate(doc, 'refs-scenes', CTX).ok, '不存在的场景被拦');
}
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].lighting = '正午烈日';
const g = gate(doc, 'refs-scenes', CTX);
ok(!g.ok, '没登记过的光照状态被拦');
ok(g.detail.includes('正午烈日'), '光照违规点得出名');
}
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].props.push('P99');
ok(!gate(doc, 'refs-scenes', CTX).ok, '不存在的道具被拦');
ok(gate(doc, 'refs-scenes').ok, '没给美术设定时本门跳过');
}
/* ---------------- validateScript 结构检查 ---------------- */
eq(validateScript(FIXTURE, CTX).length, 0, '样例零违规');
ok(validateScript(null).length > 0, 'null 不崩');
ok(validateScript({}).some((p) => p.includes('source')), '缺 source 报出来');
ok(validateScript({ source: 'x', episodes: [] }).some((p) => p.includes('episodes')), '空 episodes 报出来');
{
const doc = clone(FIXTURE);
doc.episodes.push(clone(doc.episodes[0]));
ok(validateScript(doc).some((p) => p.includes('重复')), '重复集号报出来');
}
{
const doc = clone(FIXTURE);
delete doc.episodes[0].targetSeconds;
ok(validateScript(doc).some((p) => p.includes('targetSeconds')), '缺目标秒数报出来');
}
{
const doc = clone(FIXTURE);
delete doc.episodes[0].beatsClaimed;
ok(validateScript(doc).some((p) => p.includes('beatsClaimed')), '缺爽点认领字段报出来');
}
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].sceneId = '船舱';
ok(validateScript(doc).some((p) => p.includes('S01 这种格式')), 'sceneId 格式报出来');
}
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].flow = [];
ok(validateScript(doc).some((p) => p.includes('节拍流为空')), '空节拍流报出来');
}
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].flow.push({ action: '动作', line: '台词', speaker: 'C01' });
ok(validateScript(doc).some((p) => p.includes('二选一')), '节拍不许既是动作又是台词');
}
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].flow.push({ delivery: '只有语气' });
ok(validateScript(doc).some((p) => p.includes('二选一')), '两头都不是也拦');
}
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].flow.push({ speaker: 'C03', line: ' ' });
ok(validateScript(doc).some((p) => p.includes('空台词')), '空台词报出来');
}
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].flow.push({ line: '没有说话人。' });
ok(validateScript(doc).some((p) => p.includes('speaker')), '台词缺说话人报出来');
}
{
const doc = clone(FIXTURE);
doc.episodes[0].scenes[0].flow.push({ action: ' ' });
ok(validateScript(doc).some((p) => p.includes('空动作')), '空动作节拍报出来');
}
ok(validateScript(clone(FIXTURE)).length === 0, '不带上游校验也通过');
/* ---------------- seed ---------------- */
const seeded = seedFromOutline(OUTLINE);
eq(seeded.source, '渡口', 'seed 带书名');
eq(seeded.episodes.length, 6, 'seed 全六集');
eq(seeded.episodes[0].targetSeconds, 120, '目标秒数 = 每集分钟 × 60');
eq(seeded.episodes[0].hook, OUTLINE.episodes[0].hook, '钩子从大纲搬');
eq(seeded.episodes[0].cliff, OUTLINE.episodes[0].suspense, '悬念从大纲搬');
eq(seeded.episodes[0].beatsClaimed.join(','), '悬念钩', '第 1 集预填悬念钩');
eq(seeded.episodes[2].beatsClaimed.join(','), '身份揭破', '第 3 集预填身份揭破');
eq(seeded.episodes[1].beatsClaimed.length, 0, '没有爽点的集为空数组');
eq(seeded.episodes[0].scenes.length, 0, 'scenes 留空给模型写戏');
ok(seeded.episodes[0].seedNote.includes('S01'), 'seedNote 带候选场景');
ok(seeded.episodes[0].seedNote.includes(OUTLINE.episodes[0].synopsis.slice(0, 10)), 'seedNote 带梗概');
eq(seedFromOutline(OUTLINE, [3, 5]).episodes.map((e) => e.ep).join(','), '3,4,5', '--eps 区间过滤');
eq(seedFromOutline(OUTLINE, [2, 2]).episodes.length, 1, '单集区间');
eq(seedFromOutline({}).episodes.length, 0, '空大纲不崩');
eq(seedFromOutline({ params: {} }).episodes.length, 0, '缺分钟数用默认值不崩');
/* ---------------- slug ---------------- */
eq(slug('渡口'), '渡口', '中文原样');
eq(slug('a b/c'), 'a-b-c', '空格斜杠转短横');
eq(slug(' '), 'script', '空名兜底');
/* ---------------- render markdown ---------------- */
const md = renderMarkdown(FIXTURE, CTX);
ok(md.includes('# 渡口 · 剧本(第 1–6 集)'), 'md 标题带集数区间');
ok(md.includes('第 1 场 · 渡口栈桥(浓雾清晨)'), 'md 场头显示场景名与光照');
ok(md.includes('**老周**'), 'md 说话人显示名字不是 ID');
ok(md.includes('**画外音**'), 'md 里 VO 显示成画外音');
ok(md.includes('场次总表'), 'md 带场次总表');
ok(md.includes('台词本'), 'md 带台词本');
ok(md.includes('第 1 场第 1 拍兑现'), 'md 钩子行带认领位置');
const mdBare = renderMarkdown(FIXTURE);
ok(mdBare.includes('**C03**'), '不给上游时退回裸 ID');
ok(mdBare.includes('S02'), '场景同样退回 ID');
/* ---------------- render html ---------------- */
const html = renderHtml(FIXTURE, CTX);
ok(html.includes(''), 'html 完整文档');
ok(!/src="http|href="http|@import|url\(http/.test(html), '零外部资源');
ok(html.includes('时长仪表'), '01 时长仪表');
ok(html.includes('分集剧本'), '02 分集剧本');
ok(html.includes('场次总表'), '03 场次总表');
ok(html.includes('台词本'), '04 台词本');
ok(html.includes('质量门'), '05 质量门');
ok(html.includes('✓ 质量门 10 / 10'), '页眉徽章全绿');
ok(html.includes('class="band"'), '时长条带目标区间');
ok(html.includes('导出 JSON'), '导出按钮在');
ok(html.includes('id="script-data"'), '数据内嵌');
ok(html.includes('渡口-script.json'), '导出文件名');
ok(html.includes('data-copy'), '复制按钮在');
ok(html.includes('复制全部台词'), '台词本整组复制');
ok(html.includes('@media print'), '打印样式在');
ok(html.includes('prefers-reduced-motion'), '动效可关');
ok(html.includes('class="eps"'), '分集剧本一排两集网格');
ok(html.includes('class="scenes clip"'), '场次区默认最多 300px 截断');
ok(html.includes('展开全部场次'), '每集自带展开与收起按钮');
ok(html.includes('class="casts"'), '台词本一排两个网格');
ok(html.includes('max-height:186px'), '台词列表六行高,纵向滚动');
{
const solo = renderHtml({ source: '渡口', episodes: [clone(FIXTURE.episodes[0])] }, CTX);
ok(solo.includes('eps solo'), '单集单列,不留空半栏');
ok(solo.includes('剧本(第 1 集)'), '单集标题不带区间');
}
ok(html.includes('老周'), 'html 里 ID 换成名字');
ok(html.includes('晨雾'), '光照 chips 在');
ok(html.includes('第 1 场第 1 拍兑现'), '钩子行带认领徽章');
ok(html.includes('act-line hooked'), '认领的节拍在正文里高亮');
ok(!html.includes('音色提示词'), '不给 --cast 就没有音色按钮——不猜');
{
const withCast = renderHtml(FIXTURE, { ...CTX, cast: CAST });
ok(withCast.includes('音色提示词'), '给了 --cast 台词本带音色提示词按钮');
// 从 cast 里取,别硬编样例内容——样例的音色提示词改过形态,
// 写死字符串的断言会跟着挂,而且它验的本来就不是「内容长什么样」
const anyPrompt = CAST.characters.find((c) => c?.voice?.prompt)?.voice?.prompt ?? '';
ok(anyPrompt.length > 0, '样例 cast 里有音色提示词');
// html 里字符是转义过的,取一段不含特殊字符的片段来比对
const frag = anyPrompt.split(',')[0].trim();
ok(frag.length > 5 && withCast.includes(frag), '音色提示词是 cast 的 voice.prompt 原文');
}
ok(html.includes('lang="zh"'), '默认报告 html lang 是 zh');
/* ---------------- render — 英文界面 ---------------- */
{
const en = renderHtml(FIXTURE, { ...CTX, lang: 'en' });
ok(en.includes('lang="en"'), 'en 报告的 html lang 属性正确');
ok(en.includes('Export JSON'), 'en 导出按钮标签');
ok(en.includes('Quality gates 10 / 10'), 'en 页眉徽章全绿');
ok(en.includes('Line book'), 'en 台词本标题');
ok(en.includes('Duration gauge'), 'en 时长仪表标题');
ok(!en.includes('导出 JSON'), 'en 报告不含中文导出标签');
ok(!en.includes('质量门'), 'en 报告不含中文质量门标签');
ok(!en.includes('台词本'), 'en 报告不含中文台词本标签');
}
{
const mdEn = renderMarkdown(FIXTURE, { ...CTX, lang: 'en' });
ok(mdEn.includes('## Episode 1'), 'en md 分集标题是英文');
ok(mdEn.includes('## Line book'), 'en md 台词本标题是英文');
}
{
const doc = clone(FIXTURE);
doc.lang = 'en';
ok(renderMarkdown(doc, CTX).includes('## Episode 1'), 'script.json 顶层 lang 字段生效');
ok(renderMarkdown(doc, { ...CTX, lang: 'zh' }).includes('## 第 1 集'), '--lang 优先于 JSON 的 lang 字段');
}
{
let threw = false;
try { renderHtml(FIXTURE, { ...CTX, lang: 'fr' }); } catch { threw = true; }
ok(threw, '非内置语言直接抛错,不静默回退');
}
// 病灶横幅
{
const doc = clone(FIXTURE);
doc.episodes[1].hook = ''; // 击穿 hook-cliff 门
const h = renderHtml(doc, CTX);
ok(h.includes('class="galert"'), '有门未过时页顶挂病灶横幅');
ok(h.includes('gatepill fail'), '徽章翻红');
}
// XSS:模型数据全部过 esc
{
const doc = clone(FIXTURE);
doc.source = '';
doc.episodes[0].scenes[0].flow.push({ speaker: 'C03', line: '
' });
const h = renderHtml(doc);
ok(!h.includes(''), '标题被转义');
ok(!h.includes(',)