Files

124 lines
7.9 KiB
JavaScript
Raw Permalink 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.
/* 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; });