#!/usr/bin/env node // 自测:覆盖 novel-art.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 { ANCHOR_RANGE, castNamesOf, gateReport, renderHtml, renderMarkdown, seedFromOutline, slug, PROP_SCALES, validateArt, } from './novel-art.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const FIXTURE = JSON.parse(readFileSync(join(here, '..', 'examples', '渡口-art.json'), 'utf8')); const OUTLINE = JSON.parse( readFileSync(join(here, '..', 'references', 'test-fixtures', 'upstream', '渡口-outline.json'), 'utf8'), ); const CAST = JSON.parse( readFileSync(join(here, '..', 'references', 'test-fixtures', 'upstream', '渡口-cast.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 = (d, id, names = null) => gateReport(d, names).find((g) => g.id === id); /* ---------------- 画风 ---------------- */ // 画风是出图那一刻才定的,整批共用一段风格指令由调用方附加。写进单条提示词, // 换风格就要逐条改,而且跟当时选的那一档正面打架。 { const assets = [...FIXTURE.scenes, ...FIXTURE.props]; for (const k of [/semi-?realistic/i, /painterly/i, /concept art/i, /\bphotoreal/i]) { ok( assets.every((a) => !k.test(`${a.image.sheet} ${a.image.tags.join(' ')}`)), `提示词里不写画风:${k.source}`, ); } // 反向提示词禁的是「假」,不是某一档画风——出图时选的可能正是它 ok( assets.every((a) => !/photorealistic|3d render|\banime\b/i.test(a.image.negativePrompt)), 'negativePrompt 不禁画风词', ); // 表面处理留着,但是两档二选一:它讲的是这个空间被谁在用、保养到什么程度, // 是关于这个对象的事实,不是一句对谁都成立的常量。原来这里钉死了 A 档,于是 // 金銮殿也拿到「漆面剥落、水渍」——跟「每条提示词必须逐字包含同一句画风」 // 是同一个错误,只是换了个词。 ok( assets.every((a) => /Weathered, lived-in materials|Well-kept, actively maintained materials/ .test(a.image.sheet)), '表面处理句整段保留(日常使用 / 持续维护 二选一)', ); } /* ---------------- slug ---------------- */ eq(slug('渡船船舱'), '渡船船舱', '中文场景名保留'); eq(slug('军区总院·中医独立诊室'), '军区总院-中医独立诊室', '间隔号替换'); eq(slug('a/b:c'), 'a-b-c', '危险字符替换'); /* ---------------- seed ---------------- */ const seeded = seedFromOutline(OUTLINE); eq(seeded.source, '渡口', 'seed 带书名'); eq(seeded.scenes.length, 3, 'seed 搬全部场景'); { const s01 = seeded.scenes.find((s) => s.id === 'S01'); eq(s01.usage.episodes.join(','), '1,2,3,4,5,6', 'seed 算出出现集'); eq(s01.usage.beats.join(','), '悬念钩,身份揭破,反转,收束', 'seed 算出承载爽点'); eq(s01.summary, '', '设计字段留给模型填'); const s03 = seeded.scenes.find((s) => s.id === 'S03'); ok(s03.seedNote?.includes('复用方案'), 'outline 的 reusePlan 变成 seedNote 提示做变体'); ok(!seeded.scenes.find((s) => s.id === 'S01').seedNote, '没有复用方案的场景不带 seedNote'); } { // 道具:大纲从 1.1.0 起带 props,seed 要吃到 eq(seeded.props.length, OUTLINE.props.length, 'seed 搬全部道具'); const p01 = seeded.props.find((pr) => pr.id === 'P01'); const src = OUTLINE.props.find((pr) => pr.id === 'P01'); eq(p01.name, src.name, '道具名从大纲搬过来'); // 两边指的是同一件事:这件物件在戏里干什么,不是材质描述 eq(p01.summary, src.function, '大纲的 function 落成这里的 summary'); const epsWithP01 = OUTLINE.episodes.filter((e) => (e.propIds ?? []).includes('P01')).map((e) => e.ep); eq(p01.usage.episodes.join(','), epsWithP01.join(','), 'seed 算出道具的出现集'); // beatIds 是 id,art 这边要的是爽点类型,seed 负责翻译 eq(p01.usage.beats.join(','), src.beatIds.map((id) => OUTLINE.beats.find((b) => b.id === id).type).join(','), 'beatIds 翻译成爽点类型'); // 设计字段留给模型 eq(p01.scale, '', '尺度留空——那是美术层的活'); eq(p01.states.length, 0, '状态变体留空'); eq(p01.image.prompt, '', '出图提示词留空'); eq(p01.carriedBy.length, 0, '跟谁走留空'); } // 旧大纲没有 props 字段:返回空数组,模型照 prop-pass.md 从原文提取,跟以前一样 { const noProps = JSON.parse(JSON.stringify(OUTLINE)); delete noProps.props; const r = seedFromOutline(noProps); eq(r.props.length, 0, '旧大纲 seed 出空道具表,不是 undefined'); ok(Array.isArray(r.props), '空道具表仍然是数组,调用方不用判空'); } ok(seedFromOutline({}).scenes.length === 0, '空大纲不炸'); ok(seedFromOutline({}).props.length === 0, '空大纲的道具表也是空数组'); /* ---------------- castNamesOf ---------------- */ const NAMES = castNamesOf(CAST); ok(NAMES.includes('沈知微') && NAMES.includes('老周'), 'cast 名字提出来了'); ok(NAMES.includes('老伯'), '别名也提出来了'); ok(castNamesOf({ characters: [] }).length === 0, '空 cast 不炸'); /* ---------------- 夹具本身 ---------------- */ eq(validateArt(FIXTURE, NAMES).length, 0, '自带样例通过校验(含角色名检查)'); ok(gateReport(FIXTURE, NAMES).every((g) => g.ok), '样例全部质量门通过'); eq(gateReport(FIXTURE).length, 10, '质量门共 10 道(场景 6 + 道具 4)'); /* ---------------- 质量门逐项击穿 ---------------- */ // 每一道门都要证明它真的会拦——不然就是永远为真的假测试 // G1 锚点 3–5 eq(ANCHOR_RANGE.join('-'), '3-5', '锚点范围 3–5'); { const d = clone(); d.scenes[0].anchors = d.scenes[0].anchors.slice(0, 2); ok(!gate(d, 'anchors').ok, '锚点只有 2 个被拦'); ok(gate(d, 'anchors').detail.includes('渡船船舱'), '报错点名场景'); } { const d = clone(); d.scenes[0].anchors = Array.from({ length: 6 }, (_, i) => ({ name: `锚${i}`, desc: 'x' })); ok(!gate(d, 'anchors').ok, '锚点 6 个也被拦——QC 核对不过来'); } // G3 光照状态 { const d = clone(); d.scenes[2].lighting = []; ok(!gate(d, 'lighting').ok, '没有光照状态被拦——换时段是重新生成不是重新打灯'); } // G4 空景 { const d = clone(); d.scenes[0].image.negativePrompt = 'plastic CG look, text, watermark'; ok(!gate(d, 'no-people').ok, '反向提示词没禁人被拦——环境参考图必须空景'); } // G5 提示词英文 { const d = clone(); d.scenes[0].image.prompt = '一条老木船的客舱'; ok(!gate(d, 'english').ok, '主提示词写中文被拦'); } { const d = clone(); d.scenes[0].lighting[0].prompt = '浓雾平光'; ok(!gate(d, 'english').ok, '光照提示词写中文也被拦'); } // G6 提示词不含角色名 { const d = clone(); d.scenes[0].image.prompt += ' where 老周 stands'; ok(!gate(d, 'no-names', NAMES).ok, '提示词里出现角色名被拦'); ok(gate(d, 'no-names', NAMES).detail.includes('老周'), '报错点名是谁'); ok(gate(d, 'no-names').ok, '没给 cast 时这道门跳过(视为通过)'); ok(gate(d, 'no-names').detail.includes('跳过'), '跳过时明说,不装作查过'); } { const d = clone(); d.scenes[1].lighting[0].prompt += ' with 老伯 in frame'; ok(!gate(d, 'no-names', NAMES).ok, '别名也拦(光照提示词同样查)'); } // G7 变体引用 { const d = clone(); d.scenes[2].variantOf = 'S99'; ok(!gate(d, 'variants').ok, '指向不存在的母场景被拦'); } { const d = clone(); d.scenes[2].variantOf = 'S03'; ok(!gate(d, 'variants').ok, '自己指自己被拦'); } { const d = clone(); delete d.scenes[2].changes; ok(!gate(d, 'variants').ok, '变体缺 changes 被拦——不说改了什么等于没说'); } /* ---------------- validate 结构检查 ---------------- */ ok(validateArt(null).length === 1, 'null 直接报'); ok(validateArt({}).some((x) => x.includes('source')), '缺书名被拦'); { const d = clone(); d.scenes[0].id = 'X1'; ok(validateArt(d).some((x) => x.includes('S01 这种格式')), '场景 id 格式被拦'); } { const d = clone(); d.scenes[1].id = 'S01'; ok(validateArt(d).some((x) => x.includes('重复')), '场景 id 重复被拦'); } { const d = clone(); d.scenes[0].summary = ' '; ok(validateArt(d).some((x) => x.includes('summary')), '缺设计意图被拦'); } { const d = clone(); delete d.scenes[0].primary; ok(validateArt(d).some((x) => x.includes('primary')), '缺主场景标记被拦'); } { const d = clone(); d.scenes[0].anchors[0] = { name: '只有名字' }; ok(validateArt(d).some((x) => x.includes('锚点缺')), '锚点缺描述被拦'); } { const d = clone(); d.scenes[0].lighting[0] = { state: '夜戏' }; ok(validateArt(d).some((x) => x.includes('夜戏')), '光照缺提示词被拦且点名状态'); } { const d = clone(); delete d.scenes[0].image.sheet; ok(validateArt(d).some((x) => x.includes('image.sheet')), '缺设定图提示词被拦'); } { const d = clone(); d.scenes[0].usage = { episodes: 'not-array', beats: [] }; ok(validateArt(d).some((x) => x.includes('usage.episodes')), 'usage 结构错被拦'); } /* ---------------- render markdown ---------------- */ const md = renderMarkdown(FIXTURE); ok(md.startsWith('# 渡口 · 美术设定集'), 'MD 标题'); ok(md.includes('## 场景清单'), 'MD 有场景清单'); ok(md.includes('变体 ← S02'), 'MD 清单标出变体来源'); ok(md.includes('## S01 渡船船舱'), 'MD 每场景一节'); ok(md.includes('一致性锚点'), 'MD 有锚点'); ok(md.includes('**补丁船篷**'), 'MD 锚点带名字'); ok(!md.includes('定机位'), 'MD 不再有定机位库——AI 生产的空间锚在参考图不在文字'); ok(md.includes('光照与时段'), 'MD 有光照状态'); ok(md.includes('```text'), 'MD 提示词进代码块'); /* ---------------- render html ---------------- */ const html = renderHtml(FIXTURE); ok(html.startsWith(''), 'HTML 完整文档'); ok(!/'), '外部脚本检测正则有效'); eq((html.match(/class="kpi[ "]/g) || []).length, 4, 'KPI 带 4 张卡(场景/道具/锚点/光照)'); ok(html.includes('主场景 2 · 变体 1'), 'KPI 场景卡带分类'); ok(html.includes('叙事道具'), 'KPI 有叙事道具卡'); eq((html.match(/class="scene" /g) || []).length, 5, '5 张设定卡(3 场景 + 2 道具)'); ok(html.includes('>场景清单<') && html.includes('>场景设定卡<') && html.includes('>道具清单<') && html.includes('>道具设定卡<') && html.includes('>质量门<'), '五个区块都在'); ok(html.indexOf('>场景设定卡<') < html.indexOf('>道具清单<') && html.indexOf('>道具设定卡<') < html.indexOf('>质量门<'), '道具在场景后、质量门前'); ok(html.indexOf('>场景清单<') < html.indexOf('>场景设定卡<'), '清单在卡片前'); eq((html.match(/class="anchors"/g) || []).length, 5, '每张卡有锚点列表(3 场景 + 2 道具)'); ok(html.includes('变体来源:S02'), '变体卡标母场景'); ok(html.includes('plate-empty'), '没出图时有占位'); ok(html.includes('class="cards"'), '设定卡一排两张的网格'); ok(/\.cards\{[^}]*grid-template-columns:1fr 1fr/.test(html), '两列布局'); // 点图弹层 ok(html.includes('class="lightbox"'), '有图片弹层'); ok(/closeLb\(\)/.test(html), '弹层能关闭'); ok(/e\.key === 'Escape'/.test(html), 'Esc 关闭弹层'); { const d = clone(); d.scenes[0].sheetImage = 'images/x-sheet.png'; const withImg = renderHtml(d); ok(withImg.includes('class="zoom" data-src="images/x-sheet.png"'), '出图后图片可点,弹层拿到地址'); ok(withImg.includes('cursor:zoom-in'), '鼠标提示可放大'); } eq((html.match(/
  • /g) || []).length, 10, '10 道质量门全 ✓'); ok(html.includes('gatepill pass'), '页眉徽章通过态'); ok(html.includes('未提供 cast.json'), '报告如实标注角色名检查被跳过'); // 质量门失败也要渲染 { const d = clone(); d.scenes[0].anchors = []; const bad = renderHtml(d); ok(bad.includes('gatepill fail'), '失败时页眉徽章变红'); ok(bad.includes('class="galert"'), '失败时弹病灶横幅'); ok(bad.includes('
  • '), '未过的门标 ✗'); } // 导出:内嵌的就是 art.json 原样 ok(html.includes('([\s\S]*?)<\/script>/)[1]; ok(!x.includes(' x.includes('P01 这种格式')), '道具 id 格式被拦'); } { const d = clone(); d.props[1].id = 'P01'; ok(validateArt(d).some((x) => x.includes('道具 id P01 重复')), '道具 id 重复被拦'); } { const d = clone(); d.props[0].relatedScenes = ['S99']; ok(validateArt(d).some((x) => x.includes('S99 不存在')), '道具关联不存在的场景被拦'); } { const d = clone(); d.props[0].states[0] = { state: '合上' }; ok(validateArt(d).some((x) => x.includes('合上')), '状态缺提示词被拦且点名'); } // 无道具的文档:道具门放行、报告不渲染道具区块 { const d = clone(); delete d.props; eq(validateArt(d, NAMES).length, 0, '没有道具块也合法——道具是可选层'); ok(gateReport(d).filter((g) => g.id.startsWith('prop-')).every((g) => g.ok), '无道具时四道道具门放行'); ok(!renderHtml(d).includes('>道具清单<'), '无道具时报告不渲染道具区块'); } // 渲染 { ok(md.includes('## 道具清单'), 'MD 有道具清单'); ok(md.includes('## P01 旧皮箱'), 'MD 每件道具一节'); ok(md.includes('状态变体'), 'MD 有状态变体'); ok(html.includes('>P01<') || html.includes('P01'), 'HTML 道具卡有 ID'); ok(html.includes('手持级'), 'HTML 道具卡带尺度徽章'); ok(html.includes('关联场景:S01'), 'HTML 道具卡带关联场景'); } /* ---------------- render 英文界面(--lang en)---------------- */ { const en = renderHtml(FIXTURE, 'en'); ok(en.includes('lang="en"'), '英文报告 '); ok(en.includes('Export JSON'), '英文报告有 Export JSON'); ok(en.includes('>Scene list<'), '英文报告有 Scene list'); ok(en.includes('>Quality gates<'), '英文报告有 Quality gates 区块'); ok(en.includes('Consistency anchors'), '英文报告有 Consistency anchors'); ok(!en.includes('导出 JSON'), '英文报告不含「导出 JSON」'); ok(!en.includes('场景清单'), '英文报告不含「场景清单」'); ok(!en.includes('质量门'), '英文报告不含「质量门」(门的中文 label 属于质量门层,不在界面表里)'); ok(html.includes('lang="zh"'), '默认报告仍是 '); ok(renderMarkdown(FIXTURE, 'en').includes('## Scene list'), 'MD 英文界面有 Scene list'); } { const d = clone(); d.lang = 'en'; ok(renderHtml(d).includes('lang="en"'), 'art.json 顶层 lang 字段生效'); ok(renderHtml(d, 'zh').includes('lang="zh"'), '--lang 优先于 lang 字段'); } { let threw = false; try { renderHtml(FIXTURE, 'jp'); } catch { threw = true; } ok(threw, '非内置语言直接抛错(目前内置 zh / en)'); } // 质量门面板是报告的一部分:英文界面下门标签也要翻译(阈值由门自己算,原样保留) { const gateEn = renderHtml(FIXTURE, 'en'); ok(gateEn.includes('Consistency anchors, 3–5'), 'EN 报告的质量门标签翻译且阈值原样保留'); ok(!gateEn.includes('一致性锚点 3–5 个'), 'EN 报告不再出现中文门标签'); } console.log(`✓ ${passed} 项自测全部通过`);