Files

284 lines
23 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.
/* 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 ℃');
assert.equal(matcher.normalize(' M\u200bPa\t\n 10\u00a0℃\uFEFF '), 'MPa 10 ℃');
for (const [left, right] of [['MPa', 'mPa'], ['m²', 'm2'], ['Ⅰ', 'I'], ['不是', '是'], ['−50', '-50'], ['1.0', '10'], ['A,B', 'A,B']]) {
assert.notEqual(matcher.normalize(left), matcher.normalize(right), '自动匹配必须区分 ' + left + ' / ' + right);
assert.equal(store.normalize(left), matcher.normalize(left), '存储与匹配的严格规范化必须一致');
}
assert.equal(matcher.searchNormalize('−50.5 ℃'), '-50.5 °c');
assert.equal(matcher.searchNormalize('MPA'), matcher.searchNormalize('MPa'));
// Loose search may find similar units, but cannot merge or authorize a different unit.
const semanticRecords = [
store.validateRecord(record({ question: '压力单位 MPa 应怎样使用?', options: [{ label: 'A', text: 'MPa' }, { label: 'B', text: 'mPa' }], answer: 'A' })),
store.validateRecord(record({ question: '压力单位 mPa 应怎样使用?', options: [{ label: 'A', text: 'MPa' }, { label: 'B', text: 'mPa' }], answer: 'B' }))
];
const semanticState = { records: semanticRecords, revision: 1 };
assert.equal(matcher.search('mpa', { role: '主操' }, semanticState).total, 2);
const pressurePage = { ...semanticRecords[0], options: [{ label: 'A', text: 'mPa' }, { label: 'B', text: 'MPa' }] };
const pressureAnswer = matcher.match(pressurePage, { role: '主操' }, semanticState);
assert.equal(pressureAnswer.status, 'matched');
assert.deepEqual(Array.from(pressureAnswer.correctLabels), ['B']);
assert.deepEqual(Array.from(pressureAnswer.correctTexts), ['MPa']);
assert.throws(() => store.validateRecord({ ...semanticRecords[0], correctTexts: ['mPa'] }), /不一致/, '答案校验不能混淆单位大小写');
for (const [bankText, pageText] of [['MPa', 'mPa'], ['m²', 'm2'], ['Ⅰ', 'I'], ['不应', '应'], ['50.5', '505']]) {
const evidence = store.validateRecord(record({ question: '语义不能被自动改写。', options: [{ label: 'A', text: bankText }, { label: 'B', text: '其他选项' }], answer: 'A' }));
const modifiedPage = { ...evidence, options: [{ label: 'A', text: pageText }, { label: 'B', text: '其他选项' }] };
assert.equal(matcher.match(modifiedPage, { role: '主操' }, { records: [evidence], revision: 1 }).status, 'missing', bankText + ' 不得误匹配 ' + pageText);
assert.notEqual(store.optionKey(evidence.options), store.optionKey(modifiedPage.options));
}
const evidenceRecord = store.validateRecord(record({ options: [{ label: 'A', text: 'MPa 10\n设备' }, { label: 'B', text: '备用设备' }], answer: 'A' }));
const evidenceAnswer = matcher.match({ ...evidenceRecord, options: [{ label: 'A', text: '备用设备' }, { label: 'B', text: 'MPa 10 设备' }] }, { role: '主操' }, { records: [evidenceRecord], revision: 1 });
assert.equal(evidenceAnswer.status, 'matched');
assert.deepEqual(Array.from(evidenceAnswer.correctLabels), ['B']);
assert.deepEqual(Array.from(evidenceAnswer.correctTexts), ['MPa 10\n设备'], '返回正确文字须保留题库证据原文');
evidenceAnswer.correctTexts[0] = '不能改写题库';
assert.equal(evidenceRecord.correctTexts[0], 'MPa 10\n设备', '结果数组不应直接暴露题库答案数组');
const multiEvidence = store.validateRecord(record({ type: '多选', options: [{ label: 'A', text: 'MPa 10\n设备' }, { label: 'B', text: '备用\n设备' }, { label: 'C', text: '无关设备' }], answer: 'AB' }));
const multiEvidenceAnswer = matcher.match({ ...multiEvidence, options: [{ label: 'A', text: '备用 设备' }, { label: 'B', text: '无关设备' }, { label: 'C', text: 'MPa 10 设备' }] }, { role: '主操' }, { records: [multiEvidence], revision: 1 });
assert.equal(multiEvidenceAnswer.status, 'matched');
assert.deepEqual(Array.from(multiEvidenceAnswer.correctLabels), ['A', 'C']);
assert.deepEqual(Array.from(multiEvidenceAnswer.correctTexts), ['备用\n设备', 'MPa 10\n设备'], '多选映射后逐项对应字母,保留每个选项的题库原文空白');
const sameStemDifferentAnswers = [semanticRecords[0], store.validateRecord({ ...semanticRecords[0], course: '另一课程', answer: 'B', correctTexts: undefined })];
assert.equal(matcher.match(pressurePage, { role: '主操' }, { records: sameStemDifferentAnswers, revision: 1 }).status, 'conflict', '不同大小写的正确文本应保留为答案冲突');
// Existing v2 data retains its source texts and backup while upgrading to v3.
const oldMemory = new Map([['yianxue.assistant.bank.v2', {
records: semanticRecords.map(item => ({ ...item, questionKey: 'old-loose-key', optionKey: 'old-loose-options', id: 'old-id' })),
revision: 7, updatedAt: '2026-10-10T00:00:00.000Z'
}]]);
const upgraded = await runtime('modern', oldMemory).store.load();
assert.equal(upgraded.total, 2, '回读旧库不丢失语义不同的记录');
assert.equal(upgraded.revision, 7);
assert.equal(upgraded.updatedAt, '2026-10-10T00:00:00.000Z');
upgraded.records.forEach(item => {
assert.equal(item.questionKey, matcher.questionKey(item));
assert.equal(item.optionKey, matcher.optionKey(item.options));
assert.notEqual(item.id, 'old-id');
});
assert.notEqual(upgraded.records[0].questionKey, upgraded.records[1].questionKey);
assert.equal(oldMemory.size, 2, '写入新的 v3 键并保留原始 v2 备份');
assert.equal(oldMemory.get('yianxue.assistant.bank.v2').records[0].id, 'old-id', '升级不应改写原始 v2 数据');
assert.equal(upgraded.schemaVersion, 3);
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, '无效导入应保持原题库不变');
const roleFree = await runtime().store.importFiles([file('没有岗位.csv', csv([record()]))]);
assert.equal(roleFree.total, 1, '无岗位的旧导出可直接导入统一题库');
assert.equal(roleFree.counts.未标明, 1);
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, 'matched');
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, 2);
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 === 3));
assert.equal(matcher.search('温度', { role: '班长' }, groupedState).total, 2, '遗留岗位设置不应过滤统一搜索');
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']);
assert.deepEqual(Array.from(multipleResult.correctTexts), ['作业丙', '作业甲'], '多选字母与题库原答案文字按当前网页顺序逐项对应');
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 严格语义匹配与宽松搜索分离、原答案证据、旧库安全升级、本地存储和原子导入、统一题库、换序/冲突/搜索分页');
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');
}
// Parse real DOM instead of stripping bracketed numbers/difficulty with a regex.
const { chromium } = require('playwright');
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
try {
const page = await browser.newPage();
const requests = [];
page.on('request', request => requests.push(request.url()));
const optionHTML = (label, text) => `<div class="pc_examtest_content_list_des"><span class="examtest_content_list_des_optionsort">${label}</span>${text}</div>`;
const questionHTML = (stem, options, answer) => `<div class="pc_examtest_content"><div class="pc_examtest_content_question">${stem}</div>${options.map((text, index) => optionHTML(String.fromCharCode(65 + index), text)).join('')}<div class="examtest_content_answer_left">参考答案:<span class="answer">${answer}</span></div></div>`;
await page.setContent('<div class="pc_examtest_title">DOM 元数据验证</div>' +
questionHTML('<p>[单选] 7. 压力(2 MPa)达到(3)时,应保持Ⅰ型设备。<span class="pc_examtest_content_question_value">(2分)</span></p><div class="pc_examtest_content_question_difficulty">难度低</div>', ['MPa', 'mPa'], 'A') +
questionHTML('[判断] 8. 设备(2分)仍须检查难度系数(3)。<span class="pc_examtest_content_question_value">(3分)</span><div class="pc_examtest_content_question_difficulty">难度中</div>', ['对', '错'], 'B') +
questionHTML('[多选] 9. m² 和 m2 是不同写法。', ['m²', 'm2'], 'AB'));
await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, 'question-bank-core.js'), 'utf8') });
const extracted = await page.evaluate(() => QuestionBankCore.extract(document));
assert.deepEqual(extracted.questions.map(item => item.number), [7, 8, 9]);
assert.deepEqual(extracted.questions.map(item => item.type), ['单选', '判断', '多选']);
assert.equal(extracted.questions[0].question, '压力(2 MPa)达到(3)时,应保持Ⅰ型设备。');
assert.equal(extracted.questions[1].question, '设备(2分)仍须检查难度系数(3)。', '真正的题干括号数字及难度字样须保留');
assert.equal(extracted.questions[2].question, 'm² 和 m2 是不同写法。', '旧版无 p 和无元数据的结构仍可解析');
assert.equal(extracted.questions[0].options[0].text, 'MPa');
assert.equal(extracted.questions[0].options[1].text, 'mPa');
assert(!extracted.questions[0].questionHTML.includes('pc_examtest_content_question_value'));
assert.equal(await page.locator('.pc_examtest_content_question_value').count(), 2, '提取只处理克隆,不能更改网页原 DOM');
const sampleDirectory = path.join(rootDir, '网页样本', '谊安学考试');
const sampleHTML = path.join(sampleDirectory, '谊安学.html');
const structurePath = path.join(sampleDirectory, '题目结构.json');
if (fs.existsSync(sampleHTML) && fs.existsSync(structurePath)) {
const expected = JSON.parse(fs.readFileSync(structurePath, 'utf8'));
const actual = await page.evaluate(html => QuestionBankCore.extract(QuestionBankCore.parseHTML(html)), fs.readFileSync(sampleHTML, 'utf8'));
assert.equal(actual.total, expected.total);
assert.equal(actual.answered, 0);
assert.equal(actual.title, expected.title);
assert.deepEqual(actual.questions.map(item => ({ number: item.number, type: item.type, question: item.question, options: item.options.map(option => ({ label: option.label, text: option.text })) })),
expected.questions.map(item => ({ number: item.number, type: item.type, question: item.question, options: item.options.map(option => ({ label: option.label, text: option.text })) })));
console.log('PASS 新考试样本 40 题:类型/题号/题干/选项与人工结构一致,分数和难度按 DOM 排除');
}
assert.equal(requests.length, 0, '核心提取和保存网页解析不得请求原网站');
console.log('PASS DOM 元数据清理、括号数字保留、旧无 p 结构与原页面不变');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });