| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110 |
- 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, /<script|已获员工确认|<iframe/);
- assert.deepEqual(input, original);
- assert.equal(renderReportHtml(input), html, '同版输入无需联网即可重复生成相同字节');
- });
- test('来源协议与输入内容不会成为可执行HTML,锚点不使用不可信章节ID', () => {
- const input = task();
- input.title = '<img src=x onerror=alert(1)>';
- input.report.chapters[0].id = '"><script>bad</script>';
- input.report.evidence.e1.quotes[0].text = '</blockquote><script>bad</script>';
- 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, /<script|href="javascript:|onerror="/);
- assert.match(html, /href="#chapter-1"/);
- for (const ref of ['data:text/html,test', 'https://name:secret@example.org/path', 'file:///private']) {
- input.report.evidence.e1.quotes[0].ref = ref;
- assert.ok(!renderReportHtml(input).includes(`href="${ref}"`));
- }
- });
- test('各合法section.kind完整可读,未知结构保留原文而不静默丢失', () => {
- 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]));
- });
|