import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; import { renderReportHtml } from '../skills/ksf-workshop-runtime/scripts/render-report-html.mjs'; import { prepareHtmlReport } from '../skills/ksf-workshop-runtime/scripts/save-report.mjs'; const task = () => ({ title: '隔离测试:面体与酱体的取舍', taskKey: 'test:proposal', version: 1, stageCode: 'S5', artifactType: 'concept', rawText: '仅用于测试,不是真实调研。', sourceIds: [], pendingQuestions: ['哪项实测会改变建议?'], report: { executive: ['这是样本内的待验证判断,不代表总体需求。'], delivery: { schema: 'ksf.workshop-delivery.v1', moduleId: 'D2-08', kind: 'proposal', contentStatus: 'ready', outputs: ['proposal'] }, chapters: [{ id: 'product-proposal', title: '本轮比较问题', sections: [{ kind: 'findings', data: [{ conclusion: '改动与代价须一起验证', attitude: '解释不能超出依据。', evidence: 'e1' }] }] }], evidence: { e1: { claim: '单条原话仅作线索', sourceId: 'test-source', quotes: [{ text: '测试原话\n第二段内容。', author: '测试作者', ref: '测试平台|观察日2026-11-11; https://example.org/review/1' }] } } } }); test('HTML保留真实输入的摘要、章节、解释、逐字证言、来源及待核问题,不编造统计或确认', () => { const input = task(), original = structuredClone(input), html = renderReportHtml(input); for (const text of [input.title, input.report.executive[0], input.report.chapters[0].title, '解释不能超出依据。', '测试原话\n第二段内容。', input.pendingQuestions[0], '测试平台|观察日2026-11-11']) assert.ok(html.includes(text), text); assert.match(html, /href="https:\/\/example.org\/review\/1"/); assert.match(html, /课堂过程成果 · 待员工核对/); assert.doesNotMatch(html, / { const input = task(); input.title = ''; input.report.chapters[0].id = '">'; input.report.evidence.e1.quotes[0].text = ''; input.report.evidence.e1.quotes[0].ref = 'javascript:alert(1)'; const html = renderReportHtml(input); assert.ok(html.includes('<img src=x onerror=alert(1)>')); assert.doesNotMatch(html, / { const input = task(); input.report.pipeline = [{ label: '资格核验', value: 7 }]; input.report.chapters[0].sections = [ { kind: 'para', text: '段落内容' }, { kind: 'longpara', text: '长文第一段\n\n长文第二段' }, { kind: 'table', data: { cols: ['方案', '依据'], rows: [['真实产品', 'e1']] } }, { kind: 'matrix', data: { cols: ['维度', '差异'], rows: [['测试维度', ['保留体验', '新增代价']]] } }, { kind: 'two', data: { can: ['可做项'], cannot: ['红线项'] } }, { kind: 'fit', data: { transferCols: ['路线'], transfer: [['可迁移条件']], redlineCols: ['限制'], redline: [['不得迁移条件']] } }, { kind: 'score', data: [['测试机会', '待评', '待评', '待评', '待评', '不能排序']] }, { kind: 'ladder', data: { ladder: [[1, '产品变量', '拟改设定']], statement: '本题定位', for_who: '本题场景' } }, { kind: 'statement', data: { alt: ['测试表达'] } }, { kind: 'milestones', data: [['M1', '对照试验', ['记录项'], '结果决定去向']] }, { kind: 'funnel', data: [{ stage: 1, name: '去重', value: 3, unit: '条', scope: '隔离测试' }] }, { kind: 'future', data: { original: '未知结构保留值' } }, ]; const html = renderReportHtml(input); for (const value of ['段落内容','长文第二段','真实产品','新增代价','可做项','红线项','不得迁移条件','不能排序','拟改设定','本题定位','测试表达','结果决定去向','隔离测试','未知结构保留值','资格核验']) assert.ok(html.includes(value), value); assert.doesNotMatch(html, /NaN|undefined|\[object Object\]/); }); test('默认生成实际HTML附件,保留输入及其他附件,相同版本可复用但不能覆盖不同正文', t => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-html-test-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); const input = task(), original = structuredClone(input), textFile = path.join(dir, '说明.txt'); fs.writeFileSync(textFile, '隔离测试附件'); const first = prepareHtmlReport(input, { directory: dir, localFiles: [{ path: textFile, contentType: 'text/plain' }] }); assert.equal(first.task.report.attachments.length, 2); const html = first.task.report.attachments[0], bytes = fs.readFileSync(first.htmlPath); assert.equal(html.contentType, 'text/html');assert.equal(bytes.length, html.sizeBytes); assert.equal(bytes.toString('base64'), html.dataBase64); assert.equal(crypto.createHash('sha256').update(bytes).digest('hex'), html.sha256); assert.deepEqual(prepareHtmlReport(first.task, { directory: dir }).task, first.task); input.report.executive[0] = '实质修改了判断'; assert.throws(() => prepareHtmlReport(input, { directory: dir }), { code: 'REPORT_HTML_VERSION_CONFLICT' }); input.version = 2; assert.notEqual(prepareHtmlReport(input, { directory: dir }).htmlPath, first.htmlPath); assert.deepEqual(original.report.executive, ['这是样本内的待验证判断,不代表总体需求。']); assert.equal(original.report.attachments, undefined); }); test('超限HTML在任何写入前失败,既有交付合同仍检查空核心章节', t => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-html-limit-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); const input = task();input.report.chapters[0].sections = [{ kind: 'para', text: '字'.repeat(400000) }]; assert.throws(() => prepareHtmlReport(input, { directory: dir }), { code: 'REPORT_HTML_TOO_LARGE' }); assert.equal(fs.readdirSync(dir).length, 0); input.report.chapters[0].sections = []; assert.throws(() => prepareHtmlReport(input, { directory: dir }), { code: 'VALIDATION_FAILED' }); }); test('样例标识由真实会话模式传入渲染,不向业务任务增加未允许字段', t => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-html-mode-')); t.after(() => fs.rmSync(directory, { recursive: true, force: true })); const prepared = prepareHtmlReport(task(), { directory, dataMode: 'sample' }); assert.match(fs.readFileSync(prepared.htmlPath, 'utf8'), /样例资料/); assert.equal(Object.hasOwn(prepared.task, 'dataMode'), false); }); test('共用数字人的不同员工即使课题键和版本相同,本地HTML文件也按真实会话隔离', t => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-html-people-')); t.after(() => fs.rmSync(directory, { recursive: true, force: true })); const a = task(), b = task(); a.sessionId = 'person-a'; b.sessionId = 'person-b'; b.report.executive[0] = '另一位员工自己的报告内容'; const first = prepareHtmlReport(a, { directory }), second = prepareHtmlReport(b, { directory }); assert.notEqual(first.htmlPath, second.htmlPath); assert.ok(!fs.readFileSync(first.htmlPath, 'utf8').includes(b.report.executive[0])); assert.ok(fs.readFileSync(second.htmlPath, 'utf8').includes(b.report.executive[0])); });