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 { createApplication } from '../server/src/core.mjs'; import { attachWorkshopHandlers } from '../server/src/workshop.mjs'; import { runWorkshop } from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs'; import { buildModel } from '../src/core/kfs/build.js'; import { entriesForType, reportCenter, TYPE_FOR_ARTIFACT } from '../src/core/library/reports.js'; import { reportOverview } from '../src/core/report.js'; import { seedMemberGroups } from '../src/core/member-groups.js'; import { workshopReview, workshopOutputRefs } from '../src/core/workshop-outputs.js'; import { createFileAdapter } from '../server/src/files.mjs'; import { CORE_DELIVERABLES, DELIVERY_KINDS, DELIVERY_SCHEMA, reportCategoryOf } from '../skills/ksf-workshop-runtime/scripts/report-contract.mjs'; import { saveAndVerifyReport, prepareHtmlReport } from '../skills/ksf-workshop-runtime/scripts/save-report.mjs'; import { reportAttachments } from '../src/core/report-attachments.js'; import { STATISTICS_COLUMNS, statisticsRows } from '../skills/ksf-workshop-runtime/scripts/report-statistics.mjs'; import { COURSE_ASSESSMENT_COLUMNS, courseAssessmentRows } from '../skills/ksf-workshop-runtime/scripts/course-assessment.mjs'; // Isolated storage: these tests never connect to the production service. const data = JSON.parse(fs.readFileSync(new URL('../server/references/runtime-data.json', import.meta.url))); const course = fs.readFileSync(new URL('../skills/ksf-workshop-runtime/references/workshop-course-flow.md', import.meta.url), 'utf8'); const curriculum = [...course.matchAll(/^\| (D[12]-(?:\d\d|N)) \| \d+ \| [^|]+ \| (S[1-5]) \| ([^\n]+)\|/gm)] .flatMap(([, module, stage, types]) => [...types.matchAll(/`(\w+)`/g)].map(([, type]) => ({ module, stage, type }))); const actor = { id: 'parity-service', kind: 'service', scopes: ['data:read', 'ingest:write'], projectIds: ['*'] }; const agentProfile = { key: 'parity-agent', name: '测试共用智能体', role: 'workshop', runtimePlatform: 'test', channel: 'test' }; function fixture() { let rows = {}, seq = 0; const store = { async transaction(fn) { const prior = structuredClone(rows); try { return await fn(store); } catch (error) { rows = prior; throw error; } }, async lock() {}, async get(name, id) { return structuredClone(rows[name]?.find(row => row.objectId === id) || null); }, async list(name, { where = {}, afterId, limit = 200 } = {}) { return structuredClone((rows[name] || []).filter(row => Object.entries(where).every(([key, value]) => JSON.stringify(row[key] ?? null) === JSON.stringify(value))) .filter(row => !afterId || row.objectId > afterId).sort((a, b) => a.objectId.localeCompare(b.objectId)).slice(0, limit)); }, async create(name, values) { for (const key of Object.keys(values)) assert.ok(data.tables[name][key], `${name}.${key} must exist in the schema`); const row = { ...structuredClone(values), objectId: 'parity' + String(++seq).padStart(8, '0'), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; (rows[name] ||= []).push(row); return structuredClone(row); }, async update(name, id, values) { const row = rows[name]?.find(row => row.objectId === id); assert.ok(row); Object.assign(row, structuredClone(values)); return structuredClone(row); }, }; const settings = { signingSecret: 'parity-test-only', functionsUrl: 'https://kangshifu.fmode.cn/api/functions' }; const files = createFileAdapter({ store, crypto, Parse: {}, data, settings }); const app = createApplication({ store, crypto, Parse: {}, data, settings, files }); attachWorkshopHandlers(app, { store, data, files }); const call = async (name, input, identity = actor) => (await app.dispatch(name, structuredClone(input), identity)).data; return { store, call, files, async start(index, workshopKey = 'parity-topic') { return call('ksfStartWorkshopSession', { requestId: `start-${index}-${workshopKey}`, workshopKey, dataMode: 'sample', groupId: `G${index % 5 + 1}`, participant: { key: `participant-${index}`, name: index < 2 ? '同名组员' : `测试组员${index}`, identityBasis: 'participant_registration' }, agent: { key: agentProfile.key, name: agentProfile.name, role: 'workshop', identityBasis: 'operator_registration', runtimePlatform: 'test', channel: 'test' }, conversation: { key: `conversation-${index}`, channel: 'test' }, }); }, async save(sessionId, item, index, version = 1, chapterId = 'analysis') { const body = `隔离测试正文 ${index},版本 ${version}。不是实际调研成果。`; return runWorkshop('workshop-save', { sessionId, taskKey: `parity-topic:${item.module}:report-${index}`, version, stageCode: item.stage, title: `隔离测试报告${index}`, artifactType: item.type, rawText: body, sourceIds: [], pendingQuestions: ['需要实际研究验证'], report: { executive: [body], chapters: [{ id: chapterId, title: '测试正文', sections: [ { kind: 'para', text: body }, { kind: 'table', data: { cols: ['范围', '结论'], rows: [['隔离测试', body]] } }, ] }] }, }, { requestId: `save-${sessionId}-${index}-v${version}`, agentProfile, call }); }, async publicData() { const raw = Object.fromEntries(['project', 'artifact', 'insight', 'source', 'session', 'opportunity'].map(name => [name, []])); let cursor; do { const page = await call('ksfGetPublicBoard', { limit: 7, ...(cursor ? { cursor } : {}) }, null); for (const name of Object.keys(raw)) raw[name].push(...page[name]); cursor = page.nextCursor; } while (cursor); return raw; }, }; } test('Skill课表13环节的每种报告均经保存入口、精确回读、公开分页、正式分类和全文模型保留正文', async () => { assert.equal(new Set(curriculum.map(item => item.module)).size, 13); assert.equal(new Set(curriculum.map(item => item.type)).size, 7); const f = fixture(), { sessionId } = await f.start(0), saved = []; for (const [index, item] of curriculum.entries()) { const receipt = await f.save(sessionId, item, index); const read = await f.call('ksfGetRecord', { className: 'KfArtifact', objectId: receipt.artifactId }); assert.equal(read.record.artifactType, item.type); assert.equal(read.record.stageCode, item.stage); assert.match(read.record.rawText, new RegExp(`隔离测试正文 ${index}`)); saved.push({ item, receipt, read }); } const db = buildModel(await f.publicData()), view = workshopReview(db, seedMemberGroups(db.members), { mode: 'sample' }); assert.equal(db.artifacts.length, curriculum.length); assert.equal(view.rows.length, curriculum.length); assert.equal(view.hierarchy.flatMap(group => group.members.flatMap(member => member.reports)).length, curriculum.length); for (const { item, receipt, read } of saved) { const record = db.artifactById[receipt.artifactId]; assert.deepEqual(record.report, read.record.report); assert.equal(record.rawText, read.record.rawText); assert.ok(entriesForType(db, TYPE_FOR_ARTIFACT[item.type]).some(entry => entry.kind === 'artifact' && entry.id === receipt.artifactId)); assert.equal(reportOverview(db, receipt.artifactId).chapters[0].sections[1].data.rows.length, 1); } assert.equal(reportCenter(db).entries.filter(entry => entry.kind === 'artifact').length, curriculum.length); assert.equal(workshopReview(db, null).rows.length, 0, '样例按默认真实资料筛选不会显示,不能误报漏存'); }); test('35位组员共用同一智能体,5组各人的同系列报告和版本不混合,报告分类与组员归属对应', async () => { const f = fixture(), expected = new Map(); for (let index = 0; index < 35; index++) { const { sessionId } = await f.start(index), item = curriculum[index % curriculum.length]; await f.save(sessionId, item, 0, 1); const latest = await f.save(sessionId, item, 0, 2); expected.set(sessionId, latest.artifactId); } const db = buildModel(await f.publicData()), groups = seedMemberGroups(db.members); const latest = workshopReview(db, groups, { mode: 'sample' }); assert.equal(db.members.length, 35); assert.equal(groups.groups.length, 5); assert.equal(latest.rows.length, 35); assert.equal(workshopReview(db, groups, { mode: 'sample', history: '1' }).rows.length, 70); for (const group of latest.hierarchy.filter(group => group.memberCount)) { assert.equal(group.memberCount, 7); for (const member of group.members) { assert.equal(member.reports.length, 1); const row = member.reports[0]; assert.equal(row.artifact.memberId, member.id); assert.equal(row.id, expected.get(row.artifact.sessionId)); assert.equal(row.artifact.version, 2); assert.equal(row.reportType, TYPE_FOR_ARTIFACT[row.artifact.type]); } } }); test('七类核心交付物可承载在正式报告类型中,但章节导航与报告分类是两个维度', async () => { const f = fixture(), { sessionId } = await f.start(0); const outputs = [ ['market', 'market-analysis', 'category_report'], ['social', 'social-needs', 'voc_report'], ['evidence', 'evidence-pool', 'voc_report'], ['opportunities', 'opportunity-cards', 'opportunity_card'], ['validation', 'opportunity-validation', 'assessment_report'], ['value', 'buying-points', 'concept'], ['proposal', 'product-proposal', 'concept'], ]; for (const [index, [, anchor, type]] of outputs.entries()) await f.save(sessionId, { module: 'D2-08', stage: 'S5', type }, index, 1, anchor); const db = buildModel(await f.publicData()); for (const [key] of outputs) assert.equal(db.artifacts.filter(artifact => workshopOutputRefs(artifact).refs.some(ref => ref.key === key)).length, 1); assert.equal(new Set(db.artifacts.map(reportCategoryOf)).size, 7, '七类核心交付物现在可分别分类'); }); function deliveryTask(sessionId, key = 'market', overrides = {}) { const output = CORE_DELIVERABLES.find(o => o.key === key), type = DELIVERY_KINDS[key].types[0]; return { sessionId, taskKey: `parity-topic:D2-08:${key}`, version: 1, stageCode: 'S5', title: '隔离测试:' + output.name, artifactType: type, sourceIds: [], pendingQuestions: ['测试内容待实际研究验证'], rawText: '隔离测试的完整正文,不代表实际消费者研究。', report: { executive: ['隔离测试'], delivery: { schema: DELIVERY_SCHEMA, moduleId: 'D2-08', kind: key, contentStatus: 'ready', outputs: [key] }, chapters: [{ id: output.anchor, title: output.name, sections: [{ kind: 'para', text: '该环节的完整测试正文。' }] }] }, ...overrides }; } function verifier(f, override = {}) { return { requestId: 'new-save', invoke: (command, task, requestId) => runWorkshop(command, task, { requestId, agentProfile, call: f.call }), publicRead: p => f.call('ksfGetPublicBoard', p, null), readAttachment: async url => (await f.files.readFile({ query: Object.fromEntries(new URL(url).searchParams) }, null)).bytes, ...override }; } const inlineFile = (name, contentType, bytes) => ({ name, contentType, sha256: crypto.createHash('sha256').update(bytes).digest('hex'), sizeBytes: bytes.length, dataBase64: bytes.toString('base64') }); test('七类新报告经实际Skill保存、精确回读、公开读取校验,个人七项提交状态与分类一致', async () => { const f = fixture(), { sessionId } = await f.start(0); for (const { key } of CORE_DELIVERABLES) { const result = await saveAndVerifyReport(deliveryTask(sessionId, key), verifier(f, { requestId: 'verified-' + key })); assert.equal(result.recordVerified, true);assert.equal(result.publicDataVerified, true);assert.equal(result.browserVerified, false); } const db = buildModel(await f.publicData()), state = seedMemberGroups(db.members), view = workshopReview(db, state, { mode: 'sample' }); const member = view.hierarchy.flatMap(g => g.members).find(m => m.reports.length); assert.equal(member.topics.length, 1);assert.deepEqual(member.topics[0].outputs.map(o => o.state), Array(7).fill('saved')); assert.equal(new Set(member.reports.map(r => r.reportType)).size, 7); for (const row of member.reports) assert.ok(entriesForType(db, row.reportType).some(e => e.id === row.id && e.href)); }); test('默认HTML生成后走实际保存、编号回读、公开投影和文件字节回读;员工确认保持待处理', async t => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-html-parity-')); t.after(() => fs.rmSync(directory, { recursive: true, force: true })); const f = fixture(), { sessionId } = await f.start(0); for (const { key } of CORE_DELIVERABLES) { const { task, htmlPath } = prepareHtmlReport(deliveryTask(sessionId, key), { directory }); const receipt = await saveAndVerifyReport(task, verifier(f, { requestId: 'html-' + key })); assert.equal(receipt.filesVerified, 1);assert.equal(receipt.employeeConfirmation, 'pending'); assert.equal(receipt.htmlFiles.length, 1);assert.equal(receipt.publicDataVerified, true); const file = receipt.htmlFiles[0]; assert.equal(new URL(file.previewUrl).searchParams.get('artifactId'), receipt.artifactId); assert.equal(new URL(file.previewUrl).searchParams.get('preview'), '1'); const actual = await f.files.readFile({ query: Object.fromEntries(new URL(file.url).searchParams) }, null); assert.ok(actual.bytes.equals(fs.readFileSync(htmlPath))); const row = await f.store.get('KfArtifact', receipt.artifactId); assert.deepEqual(row.report.chapters, task.report.chapters); assert.equal(row.meta.workshop.formalConfirmation, false); } assert.equal((await f.store.list('KfArtifact')).length, CORE_DELIVERABLES.length); }); test('全部相关资料去向、正反证统计与代表原话随HTML及正文保存、回读和公开展示,分子不由引文卡数代替', async t => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-statistics-parity-')); t.after(() => fs.rmSync(directory, { recursive: true, force: true })); const f = fixture(), { sessionId } = await f.start(0), task = deliveryTask(sessionId, 'social'); const corpus = [...Array(10)].map((_, i) => ({ id: 'test-' + i, disposition: 'included', stance: i < 4 ? 'support' : i < 6 ? 'oppose' : 'unknown', text: '合成原话' + i })); corpus.push({ id: 'test-10', disposition: 'excluded-irrelevant' }, { id: 'test-11', disposition: 'excluded-duplicate' }, { id: 'test-12', disposition: 'pending-body' }); const source = await f.store.create('KfSource', { title: '隔离合成测试资料', rawText: JSON.stringify(corpus), dataMode: 'sample', meta: { ownerId: actor.id } }); task.sourceIds = [source.objectId];task.report.evidence = { e: { sourceId: source.objectId, claim: '合成测试,原话不等于全量分子', quotes: [{ text: '合成原话0', ref: 'https://example.org/test' }] } }; const included = corpus.filter(r => r.disposition === 'included'); const summary = { id: 'statistics-summary', kind: 'table', data: { cols: [...STATISTICS_COLUMNS], rows: ['support', 'oppose', 'unknown'].map(stance => [stance, included.filter(r => r.stance === stance).length, included.length, '合成测试|13条候选:10纳入、2排除、1待核;非真实消费者资料', '全体已判定记录统计;相反材料也保留', '仅测试数据流,不形成实际研发建议', 'e']) } }; task.report.chapters[0].sections.push({ kind: 'table', title: '全部相关资料处理去向', data: { cols: ['记录', '去向', '判定'], rows: corpus.map(r => [r.id, r.disposition, r.stance || '未判定']) } }, summary); task.rawText = JSON.stringify({ corpus, summary, explanation: '隔离测试;精选一条引文不代表只分析了一条资料' }); const { task: prepared, htmlPath } = prepareHtmlReport(task, { directory, dataMode: 'sample' }); const saved = await saveAndVerifyReport(prepared, verifier(f, { requestId: 'statistics-saved' })); const db = buildModel(await f.publicData()), actual = db.artifactById[saved.artifactId]; assert.deepEqual(actual.report.chapters, task.report.chapters); assert.deepEqual(statisticsRows(actual.report.chapters[0].sections.at(-1)).map(row => row.percent), ['40.0%', '20.0%', '40.0%']); const file = await f.files.readFile({ query: Object.fromEntries(new URL(saved.htmlFiles[0].url).searchParams) }, null); assert.ok(file.bytes.equals(fs.readFileSync(htmlPath)));assert.match(file.bytes.toString(), /2排除、1待核/); assert.equal(actual.report.evidence.e.quotes.length, 1);assert.equal(saved.filesVerified, 1); assert.equal(statisticsRows(summary)[0].numerator, 4); }); test('课程三维与未知项以现有报告表保存,公开数据及HTML字节回读一致,无新增数据库字段', async t => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-course-parity-')); t.after(() => fs.rmSync(directory, { recursive: true, force: true })); const f = fixture(), { sessionId } = await f.start(0), task = deliveryTask(sessionId, 'validation'); task.taskKey = 'parity-topic:D2-05:priority-list'; task.report.chapters[0].sections.push({ kind: 'table', id: 'course-priority', data: { cols: [...COURSE_ASSESSMENT_COLUMNS], rows: [['测试A', 2, 3, 2, '隔离测试依据,商业价值需本组复核', '第1'], ['测试B', 2, null, 2, '测试工艺未知,另表列出补证方式', '先补证']], } }); const prepared = prepareHtmlReport(task, { directory, dataMode: 'sample' }); const saved = await saveAndVerifyReport(prepared.task, verifier(f, { requestId: 'course-score-saved' })); const actual = buildModel(await f.publicData()).artifactById[saved.artifactId]; assert.deepEqual(courseAssessmentRows(actual.report.chapters[0].sections.at(-1)).map(row => row.scores), [[2, 3, 2], [2, null, 2]]); const file = await f.files.readFile({ query: Object.fromEntries(new URL(saved.htmlFiles[0].url).searchParams) }, null); assert.ok(file.bytes.equals(fs.readFileSync(prepared.htmlPath)));assert.match(file.bytes.toString(), /data-course-assessment/); assert.equal(saved.publicDataVerified, true);assert.equal(saved.employeeConfirmation, 'pending'); }); test('HTML与图片随报告原子保存、文件字节回读、公开预览关联;直接匿名取私有文件被拒', async () => { const f = fixture(), { sessionId } = await f.start(0), task = deliveryTask(sessionId, 'proposal'); task.report.attachments = [inlineFile('最终提案.html', 'text/html', Buffer.from('

隔离测试提案

')), inlineFile('示意图.png', 'image/png', Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a5YkAAAAASUVORK5CYII=', 'base64'))]; const result = await saveAndVerifyReport(task, verifier(f));assert.equal(result.filesVerified, 2); const replay = await saveAndVerifyReport(task, verifier(f));assert.equal(replay.artifactId, result.artifactId); assert.equal((await f.store.list('KsfFileObject')).length, 2, '同请求重放不产生重复文件'); const row = await f.store.get('KfArtifact', result.artifactId); assert.equal(JSON.stringify(row.report).includes('dataBase64'), false); const files = row.report.attachments;assert.equal(files.length, 2); assert.equal(reportAttachments({ id: row.objectId, report: row.report }).length, 2); await assert.rejects(f.files.readFile({ query: { fileId: files[0].id } }, null), { code: 'AUTH_REQUIRED' }); const publicFile = await f.files.readFile({ query: { artifactId: row.objectId, fileId: files[0].id } }, null); assert.equal(publicFile.name, '最终提案.html');assert.match(publicFile.bytes.toString(), /隔离测试提案/); await assert.rejects(f.files.readFile({ query: { artifactId: 'another-report', fileId: files[0].id } }, null), { code: 'REFERENCE_NOT_FOUND' }); await f.store.update('KfArtifact', row.objectId, { recordState: 'archived' }); await assert.rejects(f.files.readFile({ query: { artifactId: row.objectId, fileId: files[0].id } }, null), { code: 'REFERENCE_NOT_FOUND' }); }); test('附件内容损坏、伪造回执、跨身份取文件、空交付物均拒绝,失败不遗留报告或文件', async () => { const f = fixture(), { sessionId } = await f.start(0); const bad = deliveryTask(sessionId);bad.report.chapters[0].sections = [{ kind: 'table', data: { cols: ['标题'], rows: [[' ']] } }]; await assert.rejects(f.call('ksfSaveWorkshopTask', { ...bad, requestId: 'empty-report' }), { code: 'VALIDATION_FAILED' }); const attachment = inlineFile('bad.png', 'image/png', Buffer.from('not an image')); bad.report = deliveryTask(sessionId).report;bad.report.attachments = [attachment]; await assert.rejects(f.call('ksfSaveWorkshopTask', { ...bad, requestId: 'bad-image' }), { code: 'VALIDATION_FAILED' }); bad.report.attachments = [inlineFile('ok.txt', 'text/plain', Buffer.from('保存后也须回滚')), { ...inlineFile('bad.txt', 'text/plain', Buffer.from('正文')), sha256: 'a'.repeat(64) }]; await assert.rejects(f.call('ksfSaveWorkshopTask', { ...bad, requestId: 'bad-hash' }), { code: 'UPLOAD_INCOMPLETE' }); bad.report.attachments = [{ name: 'fake.txt', contentType: 'text/plain', sizeBytes: 1, sha256: 'a'.repeat(64), receipt: { fileId: 'fake' } }]; await assert.rejects(f.call('ksfSaveWorkshopTask', { ...bad, requestId: 'fake-file' }), { code: 'UPLOAD_INCOMPLETE' }); assert.equal((await f.store.list('KfArtifact')).length, 0);assert.equal((await f.store.list('KsfFileObject')).length, 0); assert.equal((await f.store.list('KfSource')).length, 0); const good = deliveryTask(sessionId);good.report.attachments = [inlineFile('private.txt', 'text/plain', Buffer.from('私有原件'))]; const saved = await saveAndVerifyReport(good, verifier(f)); const [file] = await f.store.list('KsfFileObject'); await assert.rejects(f.files.readFile({ query: { fileId: file.objectId } }, { id: 'other', kind: 'service', scopes: ['data:read'] }), { code: 'FORBIDDEN' }); await f.store.update('KfArtifact', saved.artifactId, { meta: {}, dashboardState: 'draft' }); await assert.rejects(f.files.readFile({ query: { artifactId: saved.artifactId, fileId: file.objectId } }, null), { code: 'REFERENCE_NOT_FOUND' }); }); test('仅有boardVisible回执不算验证完成;回读不符保留编号且不重试保存,旧服务在写前停止', async () => { const f = fixture(), { sessionId } = await f.start(0), task = deliveryTask(sessionId); let saves = 0; const base = verifier(f); await assert.rejects(saveAndVerifyReport(task, { ...base, invoke: async (command, input, id) => { if (command === 'workshop-save') saves++; return base.invoke(command, input, id); }, publicRead: async () => ({ artifact: [], nextCursor: null }) }), error => { assert.equal(error.code, 'REPORT_PUBLIC_READBACK_MISMATCH');assert.ok(error.partial.receipt.artifactId);assert.equal(error.partial.automaticRetry, false);return true; }); assert.equal(saves, 1);assert.equal((await f.store.list('KfArtifact')).length, 1); await assert.rejects(saveAndVerifyReport(task, { ...base, invoke: async command => { assert.equal(command, 'workshop-contract');return { workshop: { version: 'ksf.workshop.v1' } }; } }), { code: 'REPORT_DELIVERY_CAPABILITY_UNAVAILABLE' }); }); test('个人交付核对按课题、资料模式、最新版本隔离;草稿不计已交,缺正文、旧成果待定位均明示', async () => { const f = fixture(), first = await f.start(0), other = await f.start(0, 'another-topic'); await saveAndVerifyReport(deliveryTask(first.sessionId), verifier(f)); const draft = deliveryTask(first.sessionId);draft.version = 2;draft.report.delivery.contentStatus = 'draft'; await saveAndVerifyReport(draft, verifier(f, { requestId: 'save-draft' })); await saveAndVerifyReport(deliveryTask(other.sessionId, 'proposal', { taskKey: 'another-topic:D2-08:proposal' }), verifier(f, { requestId: 'other-topic' })); const raw = await f.publicData(); const db = buildModel(raw), view = workshopReview(db, seedMemberGroups(db.members), { mode: 'all', history: '1', reportType: 'value' }); const topics = view.hierarchy.flatMap(g => g.members.flatMap(m => m.topics)); const a = topics.find(t => t.workshopKey === 'parity-topic'), b = topics.find(t => t.workshopKey === 'another-topic'); assert.equal(a.outputs.find(o => o.key === 'market').state, 'draft'); assert.equal(a.outputs.find(o => o.key === 'proposal').state, 'missing'); assert.equal(b.outputs.find(o => o.key === 'proposal').state, 'saved'); assert.equal(b.outputs.find(o => o.key === 'market').state, 'missing'); const legacy = structuredClone(raw.artifact[0]);legacy.objectId = 'legacy-empty';legacy.reportSeriesKey = 'legacy';legacy.version = 1; legacy.report = { chapters: [{ id: 'evidence-pool', sections: [] }] };legacy.rawText = ''; raw.artifact.push(legacy); const db2 = buildModel(raw), v2 = workshopReview(db2, seedMemberGroups(db2.members), { mode: 'sample' }); assert.equal(v2.hierarchy.flatMap(g => g.members.flatMap(m => m.topics)).find(t => t.workshopKey === 'parity-topic').outputs.find(o => o.key === 'evidence').state, 'missing-body'); }); test('报告中心仅计成果报告;旧原文可读、无正文不伪造,独立机会及资料分开统计', () => { const db = buildModel({ artifact: [{ objectId: 'text', title: '旧报告', artifactType: 'voc_report', rawText: '已保存的真实原文。' }, { objectId: 'empty', title: '缺正文', artifactType: 'concept' }], opportunity: [{ objectId: 'opp', title: '独立机会' }], source: [{ objectId: 'category-source', sourceType: 'category' }] }); const center = reportCenter(db);assert.equal(center.total, 2);assert.equal(center.separateObjects.opportunities, 1); assert.ok(center.entries.every(e => e.kind === 'artifact')); assert.equal(center.entries.find(e => e.id === 'text').href, '/report/text'); const read = reportOverview(db, 'text');assert.equal(read.bodyStatus, 'text');assert.match(read.report.subtitle, /尚非结构化/); assert.equal(read.chapters[0].sections[0].text, '已保存的真实原文。');assert.equal(db.artifactById.text.report, null); assert.equal(reportOverview(db, 'empty'), null); }); test('已有上传回执可关联报告;跨身份回执及超限附件在写入前拒绝', async () => { const f = fixture(), { sessionId } = await f.start(0), bytes = Buffer.from('已通过文件通道的正文。'); const input = inlineFile('来源文件.txt', 'text/plain', bytes); const prepared = await f.call('ksfPrepareFileUpload', { requestId: 'prepare-report-file', fileName: input.name, contentType: input.contentType, sizeBytes: input.sizeBytes, sha256: input.sha256 }, { ...actor, scopes: [...actor.scopes,'source:write'] }); const receipt = await f.files.upload({ query: Object.fromEntries(new URL(prepared.upload.url).searchParams), headers: { 'x-ksf-upload-token': prepared.upload.headers['X-Ksf-Upload-Token'] }, body: bytes }); const attachment = { ...input, receipt };delete attachment.dataBase64; await assert.rejects(f.store.transaction(s => f.files.saveReportAttachments(s, [attachment], { ...actor, id: 'other-service' })), { code: 'UPLOAD_INCOMPLETE' }); const task = deliveryTask(sessionId);task.report.attachments = [attachment]; const saved = await saveAndVerifyReport(task, verifier(f));assert.equal(saved.filesVerified, 1); const large = { ...attachment, sizeBytes: 1024 * 1024 + 1 }; await assert.rejects(f.store.transaction(s => f.files.saveReportAttachments(s, [large], actor)), { code: 'VALIDATION_FAILED' }); });