/* 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; recompute stale loose keys in memory. 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, 1, '不更换稳定存储键或清空旧库'); 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']); 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) => `
[单选] 7. 压力(2 MPa)达到(3)时,应保持Ⅰ型设备。(2分)