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