/* Unified-bank identity, provenance, topic boundaries and durable v2 migration. */ 'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const rootDir = path.resolve(__dirname, '..'); const JSZip = require(path.join(rootDir, 'vendor', 'jszip.min.js')); const V2 = 'yianxue.assistant.bank.v2', V3 = 'yianxue.assistant.bank.v3'; function runtime(memory = new Map(), failWrites = false) { const environment = { TextDecoder, URL, URLSearchParams, JSZip, GM_getValue: (key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback, GM_setValue: (key, value) => { if (failWrites) throw new Error('quota exhausted'); memory.set(key, structuredClone(value)); } }; vm.createContext(environment); for (const name of ['bank-store.js', 'answer-matcher.js']) vm.runInContext(fs.readFileSync(path.join(rootDir, name), 'utf8'), environment); return { store: environment.YianBankStore, matcher: environment.YianAnswerMatcher, memory }; } function file(name, content) { const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content); return { name, size: bytes.length, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) }; } function row(overrides = {}) { return { role: '主操', course: '安全领导力-1', sourceUrl: 'http://old/clientcn/exam/leadership/questions', sourceFile: '题库/主操/领导力.csv', number: 1, type: '单选', question: '技术措施应当怎样执行?', options: [{ label: 'A', text: '先核对要求' }, { label: 'B', text: '任意操作' }], answer: 'A', ...overrides }; } (async () => { const { store, matcher } = runtime(); const origins = [row(), row({ role: '副操', sourceFile: '题库/副操/领导力.csv' }), row({ role: '班长', sourceFile: '题库/班长/领导力.csv' }), row({ role: '', sourceFile: '独立题库.json' })]; let state = await store.importFiles([file('统一.json', JSON.stringify({ records: origins }))]); assert.equal(state.schemaVersion, 3); assert.equal(state.total, 4, '保留四份来源记录'); assert.equal(state.uniqueTotal, 1, '跨岗位同题同答案仅聚合为一项'); assert.equal(state.questionTotal, 1); assert.equal(state.conflictTotal, 0); assert.equal(state.counts.未标明, 1); for (const role of ['', '主操', '班长', '副操', '旧版遗留值']) { const found = matcher.search('技术措施', { role }, state); assert.equal(found.total, 1); assert.equal(found.items[0].sources.length, 4, '聚合显示全部原岗位和来源文件'); assert.equal(matcher.match(row(), { role }, state).status, 'matched'); } const reordered = { ...row(), options: [{ label: 'A', text: '任意操作' }, { label: 'B', text: '先核对要求' }] }; const mapped = matcher.match(reordered, {}, state); assert.deepEqual(Array.from(mapped.correctLabels), ['B']); assert.deepEqual(Array.from(mapped.correctTexts), ['先核对要求']); state = await store.importFiles([file('统一.json', JSON.stringify({ records: origins }))]); assert.equal(state.total, 4, '重复导入不增加记录'); const conflict = row({ role: '副操', course: '另一主题', sourceUrl: 'http://old/clientcn/exam/other/questions', sourceFile: '另一主题.csv', answer: 'B' }); state = await store.importFiles([file('另一主题.json', JSON.stringify({ records: [conflict] }))]); assert.equal(state.total, 5); assert.equal(state.uniqueTotal, 2); assert.equal(state.questionTotal, 1); assert.equal(state.conflictTotal, 1); assert.equal(matcher.match(reordered, {}, state).status, 'conflict', '四比一也不得多数决'); assert(matcher.search('技术措施', {}, state).items.every(item => item.conflict)); assert.equal(matcher.match(reordered, { course: '安全领导力-1' }, state).status, 'matched'); assert.equal(matcher.match(reordered, { sourceUrl: 'http://new/clientcn/exam/other/questions' }, state).correctLabels[0], 'A'); assert.equal(matcher.match(reordered, { courses: ['安全领导力-1'], course: '1-1安全生产方针和目标' }, state).correctLabels[0], 'B'); assert.equal(matcher.match(reordered, { courses: ['另一主题'] }, state).correctLabels[0], 'A'); assert.equal(matcher.match(reordered, { courses: ['不存在'], sourceUrl: origins[0].sourceUrl }, state).status, 'missing', '主题名单没有匹配记录不得退回其他来源'); assert.equal(matcher.match(reordered, { courses: [] }, state).status, 'missing'); assert.equal(matcher.match(reordered, { courses: ['安全领导力'] }, state).status, 'missing', '课程名单必须为全名,关键词不能授权作答'); assert.equal(matcher.match(reordered, { courses: ['安全领导力-1', '另一主题'] }, state).status, 'conflict'); const legacyValue = { schemaVersion: 2, records: origins.concat(origins[0]), revision: 8, updatedAt: '2026-10-17T00:00:00.000Z' }; const oldMemory = new Map([[V2, JSON.stringify(legacyValue)]]); const oldBackup = oldMemory.get(V2); const migrated = await runtime(oldMemory).store.load(); assert.equal(migrated.schemaVersion, 3); assert.equal(migrated.total, 4, '升级过程去掉完全重复的来源记录'); assert.equal(migrated.uniqueTotal, 1); assert.equal(migrated.revision, 8); assert.equal(migrated.updatedAt, legacyValue.updatedAt); assert.equal(migrated.migration.status, 'complete'); assert.equal(oldMemory.get(V2), oldBackup, '升级成功依然保留旧库原值'); assert.equal((await runtime(oldMemory).store.load()).total, 4, '重复加载不会再次追加 v2'); const migratedTwice = await runtime(oldMemory).store.importFiles([file('统一.json', JSON.stringify({ records: origins }))]); assert.equal(migratedTwice.total, 4); const quotaMemory = new Map([[V2, structuredClone(legacyValue)]]); const quotaBefore = JSON.stringify(quotaMemory.get(V2)); const quota = await runtime(quotaMemory, true).store.load(); assert.equal(quota.total, 4, '迁移保存失败时仍可读取已经校验的旧库'); assert.equal(quota.migration.status, 'pending'); assert.match(quota.migration.error, /quota/); assert.equal(JSON.stringify(quotaMemory.get(V2)), quotaBefore); assert.equal(quotaMemory.has(V3), false, '失败不得写入半份新库'); for (const damaged of ['not json', { records: [row({ answer: 'H' })] }, { records: 'bad' }]) { const memory = new Map([[V2, damaged]]); const before = JSON.stringify(memory.get(V2)); await assert.rejects(runtime(memory).store.load(), /损坏|无效/); assert.equal(JSON.stringify(memory.get(V2)), before); assert.equal(memory.has(V3), false); } const corruptedV3 = new Map([[V2, legacyValue], [V3, { schemaVersion: 3, records: [row({ correctTexts: ['任意操作'] })] }]]); await assert.rejects(runtime(corruptedV3).store.load(), /无效/, 'v3 损坏时不得静默恢复较旧题库并覆盖新数据'); assert.deepEqual(corruptedV3.get(V2), legacyValue); const unifiedPath = path.join(rootDir, '题库', '统一题库.csv'); if (fs.existsSync(unifiedPath)) { const real = runtime(); const unifiedFile = file('统一题库.csv', fs.readFileSync(unifiedPath)); const once = await real.store.importFiles([unifiedFile]); const twice = await real.store.importFiles([unifiedFile]); assert.equal(once.total, 5520); assert.equal(twice.total, once.total); assert.equal(twice.uniqueTotal, once.uniqueTotal); assert.equal(new Set(twice.records.map(record => record.sourceFile)).size, 28); assert(twice.conflictTotal > 0, '真实跨来源冲突必须保留'); console.log('PASS 统一 CSV:5520 条来源 / ' + twice.uniqueTotal + ' 个答案组合 / ' + twice.questionTotal + ' 道题 / ' + twice.conflictTotal + ' 道冲突题,重复导入记录数不变'); } console.log('PASS 统一题库跨岗位与无岗位、文字映射、来源聚合、冲突不多数决、准确主题名单、v2 安全迁移及损坏/配额保护'); })().catch(error => { console.error(error); process.exitCode = 1; });