Files
yianxue-assistant/tests/bank-engine.cjs
T
2026-10-10 03:39:43 +08:00

180 lines
13 KiB
JavaScript
Raw 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.
/* Pure storage/matching checks, including the user-supplied ZIP. */
'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 bankSource = fs.readFileSync(path.join(rootDir, 'bank-store.js'), 'utf8');
const matcherSource = fs.readFileSync(path.join(rootDir, 'answer-matcher.js'), 'utf8');
const JSZip = require(path.join(rootDir, 'vendor', 'jszip.min.js'));
function runtime(kind = 'legacy', memory = new Map()) {
const environment = { TextDecoder, URL, URLSearchParams, JSZip, console };
if (kind === 'legacy') {
environment.GM_getValue = (key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback;
environment.GM_setValue = (key, value) => { memory.set(key, structuredClone(value)); };
} else if (kind === 'modern') {
environment.GM = {
getValue: async (key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback,
setValue: async (key, value) => { memory.set(key, structuredClone(value)); }
};
} else {
environment.localStorage = { getItem: key => memory.get(key) ?? null, setItem: (key, value) => memory.set(key, value) };
}
vm.createContext(environment);
vm.runInContext(bankSource, environment);
vm.runInContext(matcherSource, environment);
return { store: environment.YianBankStore, matcher: environment.YianAnswerMatcher, environment, memory };
}
function file(name, text) {
const bytes = Buffer.isBuffer(text) ? text : Buffer.from(text);
return { name, size: bytes.length, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) };
}
function record(overrides = {}) {
return {
role: '主操', course: '风险管理', sourceUrl: 'http://old-host/clientcn/exam/risk/questions?title=x&flag=exercise', number: 1,
type: '单选', question: '温度低于 -50.5 ℃ 时,不应进行的作业?',
options: [{ label: 'A', text: '作业甲' }, { label: 'B', text: '作业乙' }, { label: 'C', text: '作业丙' }],
answer: 'B', explanation: '', ...overrides
};
}
function csv(rows) {
const headers = ['题号', '题型', '题目', '选项A', '选项B', '选项C', '参考答案', '题目解析', '来源网址', '题库名称'];
const cell = value => '"' + String(value ?? '').replace(/"/g, '""') + '"';
return '\uFEFF' + [headers, ...rows.map(row => [row.number, row.type, row.question, ...['A', 'B', 'C'].map(label => row.options.find(option => option.label === label)?.text || ''), row.answer, row.explanation, row.sourceUrl, row.course])].map(row => row.map(cell).join(',')).join('\r\n') + '\r\n';
}
(async () => {
const { store, matcher, memory } = runtime();
assert.equal((await store.load()).storage, 'userscript');
assert.equal((await store.load()).total, 0);
assert.deepEqual(Array.from(store.roles), ['主操', '班长', '副操']);
const parsed = store.parseCSV('\uFEFF"题目","选项A"\r\n"逗号,引号""和\n换行","甲"\r\n');
assert.equal(parsed[1][0], '逗号,引号"和\n换行');
assert.throws(() => store.parseCSV('a,b\n"未闭合'), /未闭合/);
assert.throws(() => store.parseCSV('a,b\n"关闭"坏,b'), /非法字符/);
assert.throws(() => store.parseCSV('a,b\n未"转义,b'), /未转义/);
assert.notEqual(matcher.normalize('不应在 -50.5℃ 作业'), matcher.normalize('应在 50.5℃ 作业'));
assert.notEqual(matcher.normalize('50.5'), matcher.normalize('505'));
assert.equal(matcher.normalize('−50.5 ℃'), '-50.5 °c');
const escaped = record({ options: [{ label: 'A', text: "'-50" }, { label: 'B', text: "'引用" }, { label: 'C', text: "''-50" }] });
let state = await store.importFiles([file('风险管理_题库_1题.csv', csv([escaped]))], { role: '主操' });
assert.equal(state.records[0].options[0].text, '-50');
assert.equal(state.records[0].options[1].text, "'引用");
assert.equal(state.records[0].options[2].text, "''-50");
assert.equal(state.records[0].correctTexts[0], "'引用");
state = await store.importFiles([file('风险管理.json', JSON.stringify({ title: '风险管理', sourceUrl: record().sourceUrl, questions: [{ ...record(), role: undefined }] }))], { role: '主操' });
assert.equal(state.total, 1, '相同岗位/课程/来源的导入应替换旧数据');
assert.equal(state.records[0].questionKey, matcher.questionKey(state.records[0]));
assert.equal(state.records[0].optionKey, matcher.optionKey(state.records[0].options));
state = await store.importFiles([file('班长.csv', csv([record({ course: '班长课程', sourceUrl: 'http://host/exam/leader/questions' })]))], { role: '班长' });
assert.equal(state.total, 2, '导入一个岗位不丢失其他岗位');
assert.equal(state.counts.主操, 1); assert.equal(state.counts.班长, 1);
const revision = state.revision;
await assert.rejects(store.importFiles([file('坏.csv', csv([record({ answer: 'H' })]))], { role: '主操' }), /不存在的选项/);
assert.equal((await store.load()).revision, revision, '无效导入应保持原题库不变');
await assert.rejects(store.importFiles([file('没有岗位.csv', csv([record()]))]), /选择主操/);
await assert.rejects(store.importFiles([file('坏.json', JSON.stringify([record({ correctTexts: ['作业甲'] })]))]), /不一致/);
await assert.rejects(store.importFiles([file('空.json', '{"records":[]}')]), /没有题目/);
await store.setSetting('role', '班长');
assert.equal(await store.getSetting('role', ''), '班长');
assert.equal(await store.getSetting('missing', false), false);
const reloaded = runtime('modern', memory);
assert.equal((await reloaded.store.load()).total, 2);
assert.equal(await reloaded.store.getSetting('role'), '班长');
const origin = runtime('origin');
assert.equal((await origin.store.load()).storage, 'origin');
assert.equal((await origin.store.importFiles([file('风险管理.csv', csv([record()]))], { role: '主操' })).storage, 'origin');
const q = record({ options: [{ label: 'A', text: '作业乙' }, { label: 'B', text: '作业丙' }, { label: 'C', text: '作业甲' }] });
let answer = matcher.match(q, { role: '主操', course: '风险管理' }, state);
assert.equal(answer.status, 'matched'); assert.deepEqual(Array.from(answer.correctLabels), ['A']);
assert.equal(matcher.match({ ...q, question: q.question.replace('不应', '应') }, { role: '主操' }, state).status, 'missing');
assert.equal(matcher.match({ ...q, options: q.options.slice(0, 2) }, { role: '主操' }, state).status, 'missing');
assert.equal(matcher.match({ ...q, type: '多选' }, { role: '主操' }, state).status, 'missing');
assert.equal(matcher.match(q, {}, state).status, 'unsupported');
assert.equal(matcher.match({ ...q, questionImages: ['image.png'] }, { role: '主操' }, state).status, 'unsupported');
assert.equal(matcher.match({ ...q, options: [q.options[0], { label: 'B', text: q.options[0].text }] }, { role: '主操' }, state).status, 'unsupported');
assert.deepEqual(Array.from(matcher.match({ ...q, options: q.options.map(option => ({ ...option, label: option.label + '.' })) }, { role: '主操' }, state).correctLabels), ['A']);
assert.equal(matcher.sourceIdentity('http://one:8080/clientcn/exam/risk/questions?flag=exercise'), matcher.sourceIdentity('https://two/#/clientcn/exam/risk/questions?title=风险管理&flag=exercise'));
const conflict = store.validateRecord(record({ course: '重大危险源', sourceUrl: 'http://other/exam/danger/questions', answer: 'A' }));
const sameAnswer = store.validateRecord(record({ number: 2, course: '课程二', sourceUrl: 'http://other/exam/another/questions' }));
const groupedState = { records: [state.records[0], conflict, sameAnswer, state.records[1]], revision: 99 };
assert.equal(matcher.match(q, { role: '主操' }, groupedState).status, 'conflict');
assert.equal(matcher.match(q, { role: '主操', course: '风险管理' }, groupedState).status, 'matched');
answer = matcher.match(q, { role: '主操', sourceUrl: 'http://new-ip:1234/#/clientcn/exam/risk/questions?flag=exercise' }, groupedState);
assert.equal(answer.status, 'matched'); assert.equal(answer.records.length, 1);
answer = matcher.match(q, { role: '主操', course: '重大危险源' }, groupedState);
assert.equal(answer.status, 'matched'); assert.deepEqual(Array.from(answer.correctLabels), ['C']);
assert.equal(matcher.search('温度', {}, groupedState).total, 0);
assert.equal(matcher.search('', { role: '主操' }, groupedState).total, 0);
let found = matcher.search('温度 -50.5 作业乙', { role: '主操' }, groupedState);
assert.equal(found.total, 2, '同题同选项同答案合并,冲突答案分别展示');
assert(found.items.every(item => item.conflict));
assert(found.items.some(item => item.sources.length === 2));
assert.equal(matcher.search('温度', { role: '班长' }, groupedState).total, 1);
assert.equal(matcher.search('温度', { role: '主操', course: '风险管理' }, groupedState).items[0].conflict, false);
assert.equal(matcher.search('温度不存在', { role: '主操' }, groupedState).total, 0);
found = matcher.search('温度', { role: '主操', limit: 1, offset: 0 }, groupedState);
assert.equal(found.items.length, 1); assert.equal(found.hasMore, true);
assert.equal(matcher.search('温度', { role: '主操', limit: 1, offset: 1 }, groupedState).hasMore, false);
const multiple = store.validateRecord(record({ type: '多选', answer: 'AC' }));
const multipleResult = matcher.match({ ...q, type: '多选' }, { role: '主操' }, { records: [multiple], revision: 1 });
assert.equal(multipleResult.status, 'matched');
assert.deepEqual(Array.from(multipleResult.correctLabels), ['B', 'C']);
const quotaRuntime = runtime();
await quotaRuntime.store.importFiles([file('风险管理.csv', csv([record()]))], { role: '主操' });
quotaRuntime.environment.GM_setValue = () => { throw new Error('quota exceeded'); };
await assert.rejects(quotaRuntime.store.importFiles([file('班长.csv', csv([record({ course: '班长课程' })]))], { role: '班长' }), /quota/);
assert.equal((await quotaRuntime.store.load()).total, 1, '保存失败应保持可用的旧题库');
// Calls are serialized so independently imported roles cannot overwrite each other.
const parallelRuntime = runtime();
await Promise.all([
parallelRuntime.store.importFiles([file('主操.csv', csv([record()]))], { role: '主操' }),
parallelRuntime.store.importFiles([file('班长.csv', csv([record()]))], { role: '班长' })
]);
assert.equal((await parallelRuntime.store.load()).total, 2);
// ZIP all-or-nothing replacement; lock files and unrelated metadata are ignored.
const zip = new JSZip();
zip.file('题库/副操/风险管理.csv', csv([record({ sourceUrl: 'http://host/exam/a/questions' })]));
zip.file('题库/副操/~$风险管理.csv', 'broken');
zip.file('__MACOSX/._file.csv', 'broken');
state = await store.importFiles([file('替换.zip', await zip.generateAsync({ type: 'nodebuffer' }))]);
assert.equal(state.total, 1); assert.equal(state.counts.副操, 1); assert.equal(state.counts.主操, 0);
const invalidZip = new JSZip();
invalidZip.file('题库/主操/正常.csv', csv([record()]));
invalidZip.file('题库/班长/损坏.csv', csv([record({ answer: 'H' })]));
await assert.rejects(store.importFiles([file('损坏.zip', await invalidZip.generateAsync({ type: 'nodebuffer' }))]), /不存在的选项/);
assert.equal((await store.load()).total, 1);
await assert.rejects(store.importFiles([file('题库.zip', await zip.generateAsync({ type: 'nodebuffer' })), file('一份.csv', csv([record()]))]), /只选择一个/);
console.log('PASS 本地存储、原子导入、CSV引号/换行/转义、岗位隔离、精确换序匹配、答案冲突、来源消歧与搜索分页');
const realZip = path.join(rootDir, '题库.zip');
if (fs.existsSync(realZip)) {
const started = performance.now();
const imported = await store.importFiles([file('题库.zip', fs.readFileSync(realZip))]);
assert.equal(imported.total, 5520);
assert.equal(imported.counts.主操 + imported.counts.班长 + imported.counts.副操, 5520);
assert(imported.records.every(row => row.correctTexts.length > 0));
const courseConflicts = matcher.search('常压罐组收料作业中', { role: '主操', limit: 100 }, imported);
assert(courseConflicts.items.some(item => item.conflict), '真实跨课程冲突应显式显示');
const noSource = matcher.match(courseConflicts.items.find(item => item.conflict), { role: '主操' }, imported);
assert.equal(noSource.status, 'conflict');
const exact = imported.records[0];
const matched = matcher.match(exact, { role: exact.role, course: exact.course, sourceUrl: exact.sourceUrl }, imported);
assert.equal(matched.status, 'matched');
assert.equal(new Set(imported.records.map(item => item.sourceFile)).size, 28);
console.log('PASS 实际题库 ZIP:28 CSV,5520 题,岗位数量 ' + JSON.stringify(imported.counts) + ';导入耗时 ' + Math.round(performance.now() - started) + ' ms');
}
})().catch(error => { console.error(error); process.exitCode = 1; });