179 lines
9.5 KiB
JavaScript
179 lines
9.5 KiB
JavaScript
/* ============================================================
|
||
* test_travel_citytour_export.js —— 游山玩水 · 单文件 HTML 导出器单元测试
|
||
*
|
||
* 测什么:tc_export.js 的 build() 是纯字符串拼装(不碰 DOM),
|
||
* 所以可以在 Node 里直接跑,把导出的 HTML 当成字符串做结构断言。
|
||
*
|
||
* 覆盖:
|
||
* 1. report_spec 为空时必须抛错(否则又会出现「静默留白」)
|
||
* 2. 12 个区块、hero、目录、卡片数、表格 pick_id、清单、地图 SVG、页脚 都要在
|
||
* 3. 不得出现 undefined / [object Object] / <script(导出文档是静态的)
|
||
* 4. 标签配平(section / table / ol 等)
|
||
* 5. 有配图的样例(如东)不能崩,未内嵌时降级为占位块
|
||
* 6. tc_export.css 必须覆盖导出文档用到的关键类名
|
||
*
|
||
* 运行: node dev_test_scripts/unit/test_travel_citytour_export.js
|
||
* ============================================================ */
|
||
const fs = require('fs')
|
||
const path = require('path')
|
||
|
||
const ROOT = path.join(__dirname, '..', '..')
|
||
// 技能全栈隔离:导出器与其样式、样例、照片、生成结果都在 skills/<skill_id>/ 内
|
||
const SKILL_ID = 'sh_2d_car_hike'
|
||
const SKILL_PUBLIC = path.join(ROOT, 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour', 'skills', SKILL_ID)
|
||
const SKILL_SERVER = path.join(ROOT, 'src', 'server', 'thought_lab', 'labs', 'travel_citytour', 'skills', SKILL_ID)
|
||
|
||
const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||
|
||
const exporter = require(path.join(SKILL_PUBLIC, 'tc_export.js'))
|
||
|
||
let pass = 0
|
||
const fails = []
|
||
function ok(name, cond, extra) {
|
||
if (cond) { pass++; console.log(' ✔ ' + name) }
|
||
else { fails.push(name + (extra ? ' → ' + extra : '')); console.log(' ✘ ' + name + (extra ? ' → ' + extra : '')) }
|
||
}
|
||
function count(hay, needle) {
|
||
let n = 0, i = 0
|
||
while (true) {
|
||
const j = hay.indexOf(needle, i)
|
||
if (j < 0) break
|
||
n++; i = j + needle.length
|
||
}
|
||
return n
|
||
}
|
||
|
||
const CSS = fs.readFileSync(path.join(SKILL_PUBLIC, 'tc_export.css'), 'utf-8')
|
||
|
||
/* ---------- 1. 空 spec 必须抛错 ---------- */
|
||
console.log('\n[1] 空 report_spec')
|
||
let threw = false
|
||
try { exporter.build({ summary: { title: 'x' } }, {}, { css: CSS }) } catch (e) { threw = true }
|
||
ok('sections 缺失时主动抛错(不静默留白)', threw)
|
||
|
||
/* ---------- 2. 真实结果:结构完整性 ---------- */
|
||
console.log('\n[2] 真实生成结果(last_result.json)')
|
||
const env = readJson(path.join(SKILL_SERVER, 'data', 'last_result.json'))
|
||
const spec = readJson(path.join(SKILL_SERVER, 'report_spec.json'))
|
||
const payload = env.payload
|
||
|
||
const html = exporter.build(payload, spec, {
|
||
css: CSS,
|
||
meta: env.meta,
|
||
exportedAt: '2026-09-29 15:30:00'
|
||
})
|
||
|
||
ok('产出完整 HTML 文档', html.startsWith('<!DOCTYPE html>') && html.trim().endsWith('</html>'))
|
||
ok('lang=zh-CN', html.indexOf('<html lang="zh-CN">') > 0)
|
||
ok('标题取自 payload.summary.title', html.indexOf('<title>' + payload.summary.title + ' · 行程报告</title>') > 0)
|
||
ok('样式已内联(<style> 含 .hero)', html.indexOf('<style>') > 0 && html.indexOf('.hero{') > 0)
|
||
|
||
const specSections = spec.sections
|
||
ok('区块数 = report_spec.sections 数(' + specSections.length + ')', count(html, '<section class="section"') === specSections.length - 1,
|
||
'实际 ' + count(html, '<section class="section"'))
|
||
let allIds = true
|
||
const missIds = []
|
||
specSections.forEach((s) => {
|
||
const id = s.id
|
||
if (html.indexOf('id="' + id + '"') < 0) { allIds = false; missIds.push(id) }
|
||
})
|
||
ok('每个区块 id 都落地', allIds, missIds.join(','))
|
||
|
||
ok('overview 渲染成 hero 头图', html.indexOf('<header class="hero" id="sec_overview">') > 0)
|
||
ok('hero 里有标题/一句话/标签/统计', html.indexOf('<h1>' + payload.summary.title + '</h1>') > 0 &&
|
||
html.indexOf('class="lede"') > 0 && html.indexOf('class="hero-tags"') > 0 && html.indexOf('class="hero-stats"') > 0)
|
||
ok('hero meta 徽标(模型/耗时/校验)', html.indexOf('模型 ' + env.meta.model) > 0 && html.indexOf('耗时 ') > 0 &&
|
||
html.indexOf('结构校验通过') > 0)
|
||
|
||
ok('目录锚点数 = 有标题的区块数', count(html, 'class="index"') === 1 &&
|
||
count(html, '<div class="index"') === 0 && count(html, '<a href="#sec_') === specSections.filter((s) => s.title).length,
|
||
'实际 ' + count(html, '<a href="#sec_') + ' / 期望 ' + specSections.filter((s) => s.title).length)
|
||
|
||
ok('卡片数 = payload.cards 数(' + payload.cards.length + ')', count(html, '<article class="card"') === payload.cards.length,
|
||
'实际 ' + count(html, '<article class="card"'))
|
||
|
||
const pickIds = specSections.filter((s) => s.pick_id).map((s) => s.pick_id)
|
||
const missTables = pickIds.filter((id) => html.indexOf('缺少表格:' + id) >= 0)
|
||
ok('4 张按 pick_id 引用的表格全部命中', missTables.length === 0, missTables.join(','))
|
||
|
||
ok('待核实清单条数 = payload.checklist 数(' + payload.checklist.length + ')',
|
||
count(html, '<li class="check-item">') === payload.checklist.length,
|
||
'实际 ' + count(html, '<li class="check-item">'))
|
||
|
||
ok('地图内联 SVG(不依赖 Leaflet)', html.indexOf('<svg class="map-svg"') > 0 && html.indexOf('leaflet') < 0)
|
||
ok('地图点位数 = payload.map.markers 数(' + payload.map.markers.length + ')',
|
||
count(html, '<circle ') === payload.map.markers.length, '实际 ' + count(html, '<circle '))
|
||
ok('地图线条数 > 0(路线已连线)', count(html, '<line ') > 0)
|
||
ok('地图带图例与免责说明', html.indexOf('class="legend"') > 0 &&
|
||
html.indexOf(payload.map.disclaimer || spec.map_route_disclaimer_default) > 0)
|
||
ok('地图点位清单(.points)条数与点位数一致', count(html, '<div class="point">') === payload.map.markers.length)
|
||
|
||
ok('费用合计行渲染', html.indexOf('<tfoot>') > 0 && html.indexOf('合计') > 0)
|
||
ok('页脚含导出时间与免责', html.indexOf('2026-09-29 15:30:00') > 0 && html.indexOf('出行前请自行核实') > 0)
|
||
|
||
/* ---------- 3. 静态 & 干净 ---------- */
|
||
console.log('\n[3] 导出文档是静态的、无脏数据')
|
||
ok('不含 <script>', html.indexOf('<script') < 0)
|
||
ok('不含 <link rel="stylesheet">(样式必须内联)', html.indexOf('<link') < 0)
|
||
ok('不含 undefined', html.indexOf('undefined') < 0)
|
||
ok('不含 [object Object]', html.indexOf('[object Object]') < 0)
|
||
ok('不含 null 字面量渲染', html.indexOf('>null<') < 0)
|
||
|
||
const pairs = [
|
||
['<section class="section"', '</section>'],
|
||
['<article class="card"', '</article>'],
|
||
['<table class="tbl">', '</table>'],
|
||
['<div class="map-wrap">', '</div>']
|
||
]
|
||
pairs.forEach(([open, close]) => {
|
||
ok('标签配平:' + open.replace(/[<>=]/g, '').slice(0, 24), count(html, open) <= count(html, close))
|
||
})
|
||
|
||
/* ---------- 4. 有配图的样例(如东):不能崩,且降级为占位块 ---------- */
|
||
console.log('\n[4] 带配图的离线样例(如东)')
|
||
const sample = readJson(path.join(SKILL_PUBLIC, 'sample', 'rudong_2026-09-19.json'))
|
||
const sampleSpec = readJson(path.join(SKILL_PUBLIC, 'sample', 'report_spec.json'))
|
||
const sampleHtml = exporter.build(sample, sampleSpec, { css: CSS, meta: sample.meta })
|
||
|
||
ok('样例可正常导出', sampleHtml.startsWith('<!DOCTYPE html>') && sampleHtml.length > 5000)
|
||
const sampleImgs = exporter.collectImageUrls(sample)
|
||
ok('识别出样例配图(' + sampleImgs.length + ' 张)', sampleImgs.length > 0, '实际 ' + sampleImgs.length)
|
||
ok('未内嵌时降级为占位块(不出现断图 <img>)',
|
||
sampleHtml.indexOf('配图未内嵌') > 0 && sampleHtml.indexOf('<img src="../assets') < 0)
|
||
ok('图片署名/免责说明仍在', sampleHtml.indexOf('AI 示意图,非实景') > 0)
|
||
|
||
const withEmbedded = exporter.build(sample, sampleSpec, {
|
||
css: CSS, images: { '../assets/photos/rudong_cover.jpg': 'data:image/jpeg;base64,AAAA' }
|
||
})
|
||
ok('内嵌成功后使用 data URI', withEmbedded.indexOf('src="data:image/jpeg;base64,AAAA"') > 0)
|
||
|
||
/* ---------- 5. 文件名 ---------- */
|
||
console.log('\n[5] 导出文件名')
|
||
ok('文件名 = 标题 + 行程报告.html', exporter.defaultFilename(payload) === payload.summary.title + '-行程报告.html',
|
||
exporter.defaultFilename(payload))
|
||
ok('非法字符被清掉', exporter.defaultFilename({ summary: { title: 'a/b:c*?"<>|d' } }) === 'a b c d-行程报告.html',
|
||
exporter.defaultFilename({ summary: { title: 'a/b:c*?"<>|d' } }))
|
||
ok('无标题时兜底', exporter.defaultFilename({}) === '行程报告-行程报告.html', exporter.defaultFilename({}))
|
||
|
||
/* ---------- 6. 样式覆盖 ---------- */
|
||
console.log('\n[6] tc_export.css 覆盖导出用到的关键类名')
|
||
const needClasses = [
|
||
'.hero', '.kicker', '.hero-stats', '.index', '.section', '.sec-title', '.note-warn', '.note-info',
|
||
'.note-danger', '.tablebox', 'table.tbl', '.row-hard', '.row-hl', '.col-time', '.col-kind', '.kind-act',
|
||
'.kind-hard', '.badge-high', '.badge-medium', '.badge-low', '.badge-info', '.day', '.group', '.card-grid',
|
||
'.card-title', '.card-sub', '.card-badges', '.field', '.field-label', '.field-value', '.verify', '.bullets',
|
||
'.figcap', '.img-missing', '.map-wrap', '.map-svg', '.legend', '.swatch', '.map-note', '.points', '.point',
|
||
'.checklist', '.check-item', '.doc-foot'
|
||
]
|
||
const missCss = needClasses.filter((c) => CSS.indexOf(c) < 0)
|
||
ok('关键类名全部有样式', missCss.length === 0, missCss.join(','))
|
||
|
||
/* ---------- 汇总 ---------- */
|
||
console.log('\n============================')
|
||
console.log('通过 ' + pass + ' 项,失败 ' + fails.length + ' 项')
|
||
if (fails.length) {
|
||
console.log('\n失败明细:')
|
||
fails.forEach((f) => console.log(' - ' + f))
|
||
process.exit(1)
|
||
}
|
||
console.log('导出的 HTML 体积约 ' + Math.round(html.length / 1024) + ' KB(含内联样式)') |