Files
Toolbox/dev_test_scripts/unit/test_travel_citytour_export.js
T

179 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
* 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(含内联样式)')