'use strict'; // Browser plugin not available. Exercise a local fixture with cached Playwright; // never load the production site's scripts, APIs, or account session. const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const http = require('node:http'); const ROOT = path.resolve(__dirname, '..'); const RECORDS_KEY = 'huayi-qb.records.v1'; const CONFIG_KEY = 'huayi-qb.config.v1'; const DEDUP_BACKUP_KEY = 'huayi-qb.records.before-dedup.v1'; const OPTION_REPAIR_BACKUP_KEY = 'huayi-qb.records.before-duplicate-option-repair.v1'; const IMAGE_UPGRADE_BACKUP_KEY = 'huayi-qb.records.before-image-upgrade.v1'; const JUDGE_EVIDENCE_BACKUP_KEY = 'huayi-qb.records.before-judge-evidence-repair.v1'; const QA_DIR = path.join(os.tmpdir(), 'huayi-qb-qa-v121'); const BUNDLED = path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'); function loadPlaywright() { if (process.env.QB_PLAYWRIGHT_MODULE) return require(process.env.QB_PLAYWRIGHT_MODULE); try { return require('playwright'); } catch { return require(path.join(BUNDLED, 'playwright')); } } test('判断题答案证据:旧默认值隔离、反馈恢复、备份及搜索候选区分', { timeout: 60000 }, async () => { const core = require('../src/core.js'); const source = { kind: 'network', url: 'https://api-phx-ali.yunxuetang.cn/ote/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'old-judge-batch' }; const makeJudge = (questionId, stem, overrides = {}) => core.normalizeRecord({ bankId: 'judge-bank', bankName: '判断验收题库', questionId, type: 'judge', stem, options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }], correctOptionIds: ['0'], answerTexts: ['错误'], answerStatus: 'confirmed', source, ...overrides }); const legacy = makeJudge('old-zero', '精馏操作时,当回流罐液面达到1/3时启动回流泵给塔加入回流液。', { questionIndex: 66 }); const feedback = { ...legacy, correctOptionIds: ['1'], answerTexts: ['正确'], answerEvidence: 'answer-response', answerSource: { kind: 'network', url: 'https://api-phx-ali.yunxuetang.cn/ote/upm/judge-bank/users/test/answer', observedAt: '2026-10-30T00:00:00Z' } }; const otherLegacy = makeJudge('polluted', '被旧默认值污染但存在可信反馈的判断题。'); const contaminated = { ...otherLegacy, answerStatus: 'conflict', conflicts: [ { reason: '同一题目出现不同或不一致的标准答案', stem: otherLegacy.stem, options: otherLegacy.options, correctOptionIds: ['0'], answerTexts: ['错误'], source }, { reason: '同一题目出现不同或不一致的标准答案', stem: otherLegacy.stem, options: otherLegacy.options, correctOptionIds: ['1'], answerTexts: ['正确'], source: feedback.answerSource, answerEvidence: 'answer-response' } ] }; const manualSource = { kind: 'import', observedAt: '2026-10-30T00:00:00Z' }; const numericCandidate = makeJudge('one-half', legacy.stem.replace('1/3', '1/2'), { source: manualSource, answerSource: manualSource, questionIndex: 417 }); const negative = makeJudge('must-not', '电解操作工处理电解槽故障时,必须穿绝缘靴,不准两手同时接触电解槽。', { correctOptionIds: ['1'], answerTexts: ['正确'], source: manualSource, answerSource: manualSource }); const negativeCandidate = makeJudge('may-touch', negative.stem.replace('不准两手', '两手可以'), { source: manualSource, answerSource: manualSource }); const seed = [legacy, contaminated, numericCandidate, negative, negativeCandidate]; const current = { ...legacy, answerStatus: 'missing', correctOptionIds: [], answerTexts: [], source: { kind: 'dom' }, answerSource: undefined }; const bank = { bankId: 'judge-bank', bankName: '判断验收题库', questionCount: 500, loading: false }; const stubs = `const QBAdapter={create(options){window.__qaReceive=options.onQuestions;return {start(){},stop(){},setCopyEnabled(){},getCurrentQuestion(){return window.__qaCurrent||null},getCurrentBank(){return ${JSON.stringify(bank)}}}}}; const QBAuto={create(){return {getState(){return {enabled:false,busy:false,answeredCount:0,skippedCount:0,message:''}},setEnabled(){},answerCurrent(){return Promise.resolve()}}}};`; const appSource = `(function(){${fs.readFileSync(path.join(ROOT, 'src/core.js'), 'utf8')}\n${stubs}\n${fs.readFileSync(path.join(ROOT, 'src/app.js'), 'utf8')}\n})();`; const { chromium } = loadPlaywright(), browser = await chromium.launch(browserOptions()); let context; const createContext = async (initial, blocked = false) => { const result = await browser.newContext({ acceptDownloads: true }); await result.route('**/*', route => route.fulfill({ status: 200, contentType: 'text/html', body: '判断题本地验收' })); await result.addInitScript({ content: `(${installGMShim.toString()})(); GM_addValueChangeListener=(key,callback)=>{window.__qaRemoteChange=callback;return 1}; if(localStorage.getItem(${JSON.stringify(RECORDS_KEY)})===null){GM_setValue(${JSON.stringify(RECORDS_KEY)},${JSON.stringify(initial)});GM_setValue(${JSON.stringify(CONFIG_KEY)},{collecting:false});} window.__qaCurrent=${JSON.stringify(current)}; ${blocked ? `const originalSet=GM_setValue;GM_setValue=(key,value)=>{if(key===${JSON.stringify(JUDGE_EVIDENCE_BACKUP_KEY)}){${blocked === 'silent' ? 'return;' : "throw new Error('fixture judge backup denied');"}}return originalSet(key,value)};` : ''} ${appSource}` }); return result; }; try { context = await createContext(seed); const page = await context.newPage(); await page.goto('https://www.shhuayi-elearning.cn/judge-evidence-fixture'); await page.locator('#huayi-qb-root').waitFor({ state: 'attached' }); await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === legacy.questionId)?.answerStatus === 'missing', '旧列表默认值应撤回为待补'); let stored = await storageRecords(page); assert.equal(stored.find(record => record.questionId === contaminated.questionId).answerStatus, 'confirmed'); assert.deepEqual(stored.find(record => record.questionId === contaminated.questionId).answerTexts, ['正确']); assert.deepEqual((await page.evaluate(key => GM_getValue(key, null), JUDGE_EVIDENCE_BACKUP_KEY)).records, [legacy, contaminated]); await activate(page, 'search'); await page.locator('#qb-search').fill('精馏操作时,当回流罐液面达到'); await page.locator('#qb-search').press('Enter'); const first = page.locator('#qb-results .result').first(); await waitFor(async () => (await first.locator('.stem').textContent()) === legacy.stem, '当前完整题即使部分关键词分数较低也应在前'); assert.equal(await first.locator('.answer strong').textContent(), '标准答案未确认'); const other = page.locator('#qb-results .result').filter({ hasText: numericCandidate.stem }); assert.equal(await other.locator('.answer-label').textContent(), '候选题答案'); assert.match(await other.locator('.mapping').textContent(), /核对题干/); assert.doesNotMatch(await other.locator('.mapping').textContent(), /当前页面对应/); await page.evaluate(record => window.__qaReceive([record]), feedback); await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === legacy.questionId)?.answerStatus === 'confirmed', '可信反馈应恢复被撤回的标准答案'); assert.deepEqual((await storageRecords(page)).find(record => record.questionId === legacy.questionId).answerTexts, ['正确']); assert.equal(await first.locator('.answer-label').textContent(), '本题正确答案'); assert.match(await first.locator('.mapping').textContent(), /当前页面对应:正确/); await page.evaluate(record => { window.__qaCurrent = record; }, { ...negative, answerStatus: 'missing', correctOptionIds: [], answerTexts: [] }); await page.locator('#qb-search').fill('电解操作工处理电解槽故障时'); await page.locator('#qb-search').press('Enter'); const opposite = page.locator('#qb-results .result').filter({ hasText: negativeCandidate.stem }); assert.equal(await opposite.locator('.answer-label').textContent(), '候选题答案'); assert.doesNotMatch(await opposite.locator('.mapping').textContent(), /当前页面对应/); await activate(page, 'settings'); const downloading = page.waitForEvent('download'); await page.locator('[data-action="export-judge-evidence-backup"]').click(); const downloaded = await downloading; assert.deepEqual(JSON.parse(fs.readFileSync(await downloaded.path(), 'utf8')).records, [legacy, contaminated]); assert.ok((await page.evaluate(() => window.__qbMenus.map(menu => menu.label))).some(label => label.includes('判断题答案核验前'))); const imported = makeJudge('old-import', '后来导入的旧列表默认判断答案。'); await importFile(page, 'old-judge.json', JSON.stringify({ schemaVersion: 1, records: [imported] })); assert.equal((await storageRecords(page)).find(record => record.questionId === imported.questionId).answerStatus, 'missing'); const received = makeJudge('old-response', '从旧标签收到的未确认列表判断答案。', { correctOptionIds: ['1'], answerTexts: ['正确'] }); await page.evaluate(record => window.__qaReceive([record]), received); await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === received.questionId)?.answerStatus === 'missing', '新收到的无证据旧列表值也应撤回'); const backup = await page.evaluate(key => GM_getValue(key, null), JUDGE_EVIDENCE_BACKUP_KEY); assert.ok(backup.records.some(record => record.questionId === imported.questionId)); assert.ok(backup.records.some(record => record.questionId === received.questionId)); stored = await storageRecords(page); const stale = stored.map(record => record.questionId === legacy.questionId ? legacy : record); await page.evaluate(({ value, key }) => { GM_setValue(key, value); window.__qaRemoteChange(key, [], value, true); }, { value: stale, key: RECORDS_KEY }); await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === legacy.questionId)?.answerTexts.includes('正确'), '跨标签旧默认值不能污染可信反馈'); assert.equal((await storageRecords(page)).find(record => record.questionId === legacy.questionId).answerStatus, 'confirmed'); const staleWithoutEvent = (await storageRecords(page)).map(record => record.questionId === legacy.questionId ? legacy : record); const trigger = makeJudge('persist-trigger', '触发持久化时整理未收到通知的旧标签记录。', { source: manualSource, answerSource: manualSource }); await page.evaluate(({ value, key, trigger }) => { GM_setValue(key, value); window.__qaReceive([trigger]); }, { value: staleWithoutEvent, key: RECORDS_KEY, trigger }); await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === legacy.questionId)?.answerTexts.includes('正确'), '持久化读到未通知的旧默认值时,也应先隔离再合并'); assert.equal((await storageRecords(page)).find(record => record.questionId === legacy.questionId).answerStatus, 'confirmed'); await context.close(); context = null; for (const failure of [true, 'silent']) { context = await createContext([legacy], failure); const blocked = await context.newPage(); await blocked.goto('https://www.shhuayi-elearning.cn/judge-evidence-fixture'); await blocked.locator('#huayi-qb-root').waitFor({ state: 'attached' }); await blocked.waitForTimeout(1100); assert.deepEqual(await storageRecords(blocked), [legacy], '判断题独立备份抛错或静默失败时均不得覆写原题库'); assert.equal(await blocked.evaluate(key => GM_getValue(key, null), JUDGE_EVIDENCE_BACKUP_KEY), null); await context.close(); context = null; } } finally { if (context) await context.close(); await browser.close(); } }); test('历史误报和图片补全:原始备份、设置导出、旧标签写回与失败保护', { timeout: 60000 }, async () => { // Use the current sources without building dist in a concurrently edited tree. // The adapter fixture supplies explicit snapshot evidence; real storage, UI, // imports, downloads and delayed writes still run in Chromium. const core = require('../src/core.js'); const source = { kind: 'network', url: 'https://example.test/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'old-batch' }; const makeLegacy = (questionId, stem) => { const record = core.normalizeRecord({ bankId: 'repair-bank', bankName: '修复验收题库', questionId, type: 'multiple', stem, options: [{ id: 'a', text: '降低' }, { id: 'b', text: '下降' }, { id: 'c', text: '增加' }, { id: 'd', text: '下降' }], correctOptionIds: ['a', 'b', 'c', 'd'], answerTexts: ['降低', '下降', '增加'], answerStatus: 'confirmed', source }); return { ...record, answerStatus: 'conflict', conflicts: [ { reason: '正确选项ID与正确答案内容不一致', answerTexts: record.answerTexts, correctOptionIds: record.correctOptionIds }, { reason: '同一题目出现不同或不一致的标准答案', stem: record.stem, options: record.options, answerTexts: record.answerTexts, correctOptionIds: record.correctOptionIds, source } ] }; }; const legacy = makeLegacy('old-false-conflict', '旧版重复文字答案误报'); const realConflict = makeLegacy('real-conflict', '两个不同正确选项集合的真实冲突'); realConflict.conflicts[1] = { ...realConflict.conflicts[1], correctOptionIds: ['a', 'b', 'c'] }; const oldImage = core.normalizeRecord({ bankId: 'image-bank', bankName: '图片验收题库', questionId: 'old-image', type: 'single', stem: '根据溶解度曲线,应如何操作?', questionIndex: 0, contentOccurrence: 0, options: [{ id: 'a', text: '降低温度' }, { id: 'b', text: '提高温度' }], correctOptionIds: ['b'], answerStatus: 'confirmed', source }); const oldOther = core.normalizeRecord({ ...oldImage, questionId: 'old-other', questionIndex: 1, stem: '另一道普通题目' }); const newSource = { ...source, observedAt: '2026-10-29T01:00:00Z', batchId: 'new-batch' }; const snapshot = [oldImage, oldOther].map((record, index) => core.normalizeRecord({ ...record, questionId: `new-image-${index}`, stem: record.stem + (index ? '' : '[图片:https://example.test/curve.png]'), source: newSource, answerSource: newSource })); const bank = { bankId: 'image-bank', bankName: '图片验收题库', questionCount: 2, loading: false }; const stubs = `const QBAdapter={create(options){window.__qaReceive=options.onQuestions;return {start(){},stop(){},setCopyEnabled(){},getCurrentQuestion(){return null},getCurrentBank(){return ${JSON.stringify(bank)}}}}}; const QBAuto={create(){return {getState(){return {enabled:false,busy:false,answeredCount:0,skippedCount:0,message:''}},setEnabled(){},answerCurrent(){return Promise.resolve()}}}};`; const appSource = `(function(){${fs.readFileSync(path.join(ROOT, 'src/core.js'), 'utf8')}\n${stubs}\n${fs.readFileSync(path.join(ROOT, 'src/app.js'), 'utf8')}\n})();`; const { chromium } = loadPlaywright(); const browser = await chromium.launch(browserOptions()); const createContext = async (seed, failBackup = false) => { const context = await browser.newContext({ acceptDownloads: true }); await context.route('**/*', route => route.fulfill({ status: 200, contentType: 'text/html', body: '

本地持久化验收

' })); await context.addInitScript({ content: `(${installGMShim.toString()})(); GM_addValueChangeListener=(key,callback)=>{window.__qaRemoteChange=callback;return 1}; if(localStorage.getItem(${JSON.stringify(RECORDS_KEY)})===null){GM_setValue(${JSON.stringify(RECORDS_KEY)},${JSON.stringify(seed)});GM_setValue(${JSON.stringify(CONFIG_KEY)},{collecting:false});} ${failBackup ? `const originalSet=GM_setValue;GM_setValue=(key,value)=>{if(key===${JSON.stringify(OPTION_REPAIR_BACKUP_KEY)})throw new Error('fixture backup denied');return originalSet(key,value)};` : ''} ${appSource}` }); return context; }; let context; try { context = await createContext([legacy, realConflict, oldImage, oldOther]); const page = await context.newPage(); await page.goto('https://www.shhuayi-elearning.cn/repair-fixture'); await page.locator('#huayi-qb-root').waitFor({ state: 'attached' }); assert.equal(await page.locator('#qb-panel').isVisible(), false); await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === legacy.questionId)?.answerStatus === 'confirmed', '加载时应修复误报并写入'); assert.equal((await storageRecords(page)).find(record => record.questionId === realConflict.questionId).answerStatus, 'conflict'); const backup = await page.evaluate(key => GM_getValue(key, null), OPTION_REPAIR_BACKUP_KEY); assert.deepEqual(backup.records, [legacy], '真实冲突不应被修复或混入误报备份'); await activate(page, 'settings'); const backupDownloadWaiting = page.waitForEvent('download'); await page.locator('[data-action="export-option-repair-backup"]').click(); const backupDownload = await backupDownloadWaiting; const backupPath = await backupDownload.path(); assert.deepEqual(JSON.parse(fs.readFileSync(backupPath, 'utf8')).records, [legacy]); const imported = makeLegacy('later-import', '后来导入的另一道误报'); await importFile(page, 'legacy-import.json', JSON.stringify({ schemaVersion: 1, records: [imported] })); assert.equal((await storageRecords(page)).find(record => record.questionId === imported.questionId).answerStatus, 'confirmed'); assert.deepEqual((await page.evaluate(key => GM_getValue(key, null), OPTION_REPAIR_BACKUP_KEY)).records, [legacy, imported], '后续导入修复必须追加原始版本'); await page.evaluate(({ snapshot, bankId }) => window.__qaReceive(snapshot, { fullSnapshot: true, pageQuestionCount: 2, currentBankId: bankId }), { snapshot, bankId: bank.bankId }); await waitFor(async () => !(await storageRecords(page)).some(record => record.questionId === oldImage.questionId), '完整图片快照应移除已备份旧纯文字记录'); const afterImage = await storageRecords(page); assert.equal(afterImage.filter(record => record.bankId === bank.bankId).length, 2); assert.ok(afterImage.find(record => record.questionId === snapshot[0].questionId).questionIdAliases.includes(oldImage.questionId)); const imageBackup = await page.evaluate(key => GM_getValue(key, null), IMAGE_UPGRADE_BACKUP_KEY); assert.deepEqual(imageBackup.records, [oldImage]); assert.equal(imageBackup.migrations.length, 1); await activate(page, 'settings'); const imageDownloadWaiting = page.waitForEvent('download'); await page.locator('[data-action="export-image-upgrade-backup"]').click(); const imageDownload = await imageDownloadWaiting; assert.deepEqual(JSON.parse(fs.readFileSync(await imageDownload.path(), 'utf8')).records, [oldImage]); await page.evaluate(({ stale, key }) => { GM_setValue(key, stale); window.__qaRemoteChange(key, [], stale, true); }, { stale: [...afterImage, oldImage], key: RECORDS_KEY }); await waitFor(async () => (await storageRecords(page)).filter(record => record.bankId === bank.bankId).length === 2, '旧标签写回不能复活已补全的原始版本'); await page.reload(); await page.locator('#huayi-qb-root').waitFor({ state: 'attached' }); assert.equal((await storageRecords(page)).filter(record => record.bankId === bank.bankId).length, 2); assert.ok((await storageRecords(page)).find(record => record.questionId === snapshot[0].questionId).stem.includes('[图片:')); const changedOld = { ...oldImage, correctOptionIds: ['a'], answerTexts: ['降低温度'] }; const current = await storageRecords(page); await page.evaluate(({ stale, key }) => { GM_setValue(key, stale); window.__qaRemoteChange(key, [], stale, true); }, { stale: [...current, changedOld], key: RECORDS_KEY }); await page.waitForTimeout(350); assert.ok((await storageRecords(page)).some(record => record.questionId === oldImage.questionId && record.answerTexts.includes('降低温度')), '相同旧key但答案已变化的记录不能按退休证据删除'); await context.close(); context = null; const rawImage = { ...oldImage }, rawOther = { ...oldOther }; delete rawImage.contentOccurrence; delete rawOther.contentOccurrence; context = await createContext([rawImage, rawOther]); const rawPage = await context.newPage(); await rawPage.goto('https://www.shhuayi-elearning.cn/repair-fixture'); await rawPage.locator('#huayi-qb-root').waitFor({ state: 'attached' }); await rawPage.evaluate(({ snapshot, bankId }) => window.__qaReceive(snapshot, { fullSnapshot: true, pageQuestionCount: 2, currentBankId: bankId }), { snapshot, bankId: bank.bankId }); await waitFor(async () => (await storageRecords(rawPage)).length === 2 && !(await storageRecords(rawPage)).some(record => record.questionId === rawImage.questionId), '内存已补occ但GM原记录未补时,也不能复活旧纯文字题'); const rawBackup = await rawPage.evaluate(key => GM_getValue(key, null), IMAGE_UPGRADE_BACKUP_KEY); assert.ok(rawBackup.records.some(record => JSON.stringify(record) === JSON.stringify(rawImage)), '必须原样保留真实GM中的旧对象'); assert.equal(rawBackup.migrations.length, 2, '内存和GM中的两个原始元数据版本均有明确退休证据'); await context.close(); context = null; const oldMath = { ...oldImage, questionId: 'old-math', stem: '传热速率=系数×推动力,误差±2。' }; const newMath = core.normalizeRecord({ ...snapshot[0], questionId: 'new-math', stem: '传热速率=系数×推动力,误差±2。' }); const existingModern = core.normalizeRecord({ ...newMath, questionId: 'existing-modern-math', questionIdAliases: ['older-modern-math'], source: { ...newSource, batchId: 'earlier-modern-batch' } }); context = await createContext([oldMath, existingModern, oldOther]); const mathPage = await context.newPage(); await mathPage.goto('https://www.shhuayi-elearning.cn/repair-fixture'); await mathPage.locator('#huayi-qb-root').waitFor({ state: 'attached' }); await mathPage.evaluate(({ snapshot, bankId }) => window.__qaReceive(snapshot, { fullSnapshot: true, pageQuestionCount: 2, currentBankId: bankId }), { snapshot: [newMath, snapshot[1]], bankId: bank.bankId }); await waitFor(async () => (await storageRecords(mathPage)).length === 2, '实体补全应修复已经同时保存新旧两种文字的题库'); const mathRecords = await storageRecords(mathPage); const canonicalMath = mathRecords.find(record => record.stem === newMath.stem); assert.equal(canonicalMath.questionId, existingModern.questionId, '合并可保留已有别名记录的canonical ID'); assert.ok(canonicalMath.questionIdAliases.includes(newMath.questionId)); assert.ok(canonicalMath.questionIdAliases.includes(oldMath.questionId)); const mathBackup = await mathPage.evaluate(key => GM_getValue(key, null), IMAGE_UPGRADE_BACKUP_KEY); assert.ok(mathBackup.records.some(record => record.questionId === oldMath.questionId && record.stem === oldMath.stem)); assert.ok(mathBackup.migrations.every(item => item.kind === 'math-entity')); await mathPage.evaluate(({ stale, key }) => { GM_setValue(key, stale); window.__qaRemoteChange(key, [], stale, true); }, { stale: [...mathRecords, oldMath], key: RECORDS_KEY }); await waitFor(async () => (await storageRecords(mathPage)).length === 2, '别名合并改变replacement key后,旧实体版本仍不得复活'); await mathPage.reload(); await mathPage.locator('#huayi-qb-root').waitFor({ state: 'attached' }); assert.equal((await storageRecords(mathPage)).length, 2); await context.close(); context = null; context = await createContext([legacy], true); const blocked = await context.newPage(); await blocked.goto('https://www.shhuayi-elearning.cn/repair-fixture'); await blocked.locator('#huayi-qb-root').waitFor({ state: 'attached' }); await blocked.waitForTimeout(350); assert.deepEqual(await storageRecords(blocked), [legacy], '独立备份写入失败时必须保持原存储'); assert.equal(await blocked.evaluate(key => GM_getValue(key, null), OPTION_REPAIR_BACKUP_KEY), null); } finally { if (context) await context.close(); await browser.close(); } }); function browserOptions() { const executablePath = process.env.QB_CHROMIUM_EXECUTABLE || process.env.QB_CHROMIUM_PATH; if (executablePath) return { headless: true, executablePath }; const cached = path.join(os.homedir(), 'AppData/Local/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-win64/chrome-headless-shell.exe'); return fs.existsSync(cached) ? { headless: true, executablePath: cached } : { headless: true }; } function questionMarkup() { // Keep the selector structure needed by the adapter without depending on an // account-specific browser save or any production page resources. return fs.readFileSync(path.join(__dirname, 'fixtures/question.html'), 'utf8'); } function fixture() { return ` 华谊集团化工总控工(高级)-判断题库1 · 本地验收
本地练习页 · 未连接在线服务
华谊集团化工总控工(高级)-判断题库1
${questionMarkup()}
`; } const BANK_FIXTURES = [ { id: 'judge-1', name: '华谊集团化工总控工(高级)-判断题库1', taskId: 'qa-task-judge-1', type: 'judge', stem: '判断库一的设备检查结论正确吗?', answer: '正确' }, { id: 'judge-2', name: '华谊集团化工总控工(高级)-判断题库2', taskId: 'qa-task-judge-2', type: 'judge', stem: '判断库二的维护措施正确吗?', answer: '错误' }, { id: 'single-1', name: '华谊集团化工总控工(高级)-单选题库1', taskId: 'qa-task-single-1', type: 'single', stem: '单选库一应该采用哪一种换热操作?', answer: '提高温度' }, { id: 'single-2', name: '华谊集团化工总控工(高级)-单选题库2', taskId: 'qa-task-single-2', type: 'single', stem: '单选库二应该采用哪一种压力操作?', answer: '降低压力' }, { id: 'multiple', name: '华谊集团化工总控工(高级)-多选题库', taskId: 'qa-task-multiple', type: 'multiple', stem: '多选库有哪些需要同时执行的巡检操作?', answer: '检查温度' } ]; function multiBankFixture() { const first = BANK_FIXTURES[0]; const sidebar = ``; return fixture().replace('干燥过程传质推动力:物料表面水分压P水>热空气中的水分压P水', first.stem) .replace('', '') .replace('
', `${sidebar}
`).replace('
', '
'); } function bankResponse(bank) { const choiceItems = bank.type === 'judge' ? undefined : bank.type === 'multiple' ? [{ id: 'check-t', content: '检查温度', answer: 1 }, { id: 'check-p', content: '检查压力', answer: 1 }, { id: 'skip', content: '跳过巡检', answer: 0 }] : [{ id: 'yes', content: bank.answer, answer: 1 }, { id: 'hold', content: '保持不变', answer: 0 }, { id: 'no', content: '停止运行', answer: 0 }]; return { data: { praId: bank.id, praName: bank.name, listQues: [{ id: `question-${bank.id}`, quesType: { single: 0, multiple: 1, judge: 2 }[bank.type], content: bank.stem, ...(choiceItems ? { choiceItems } : { judgeAnswer: bank.answer === '正确' ? 1 : 0, submitQuesAnswers: [bank.answer === '正确' ? '0' : '1'] }) }] } }; } const DEDUP_BANK = { id: 'dedup-judge-2', name: '华谊集团化工总控工(高级)-判断题库2' }; function duplicateJudgeRecords() { // The real 232-question bank contains three pairs with the same wording. // Across three fetches, IDs change, but each original occurrence must remain. // This dedup fixture has submitted-list proof; the separate judge migration // test covers old start placeholders that have no standard-answer evidence. return Array.from({ length: 3 }, (_, copy) => Array.from({ length: 232 }, (_, index) => ({ bankId: DEDUP_BANK.id, bankName: DEDUP_BANK.name, questionId: `legacy-${index}-${copy}`, type: 'judge', stem: `去重验收第${(index < 229 ? index : index - 229) + 1}项:设备巡检要求已经确认。`, options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }], correctOptionIds: ['1'], answerTexts: ['正确'], answerStatus: 'confirmed', answerEvidence: 'submitted-list', source: { kind: 'network', url: 'https://api-ulcd.yunxuetang.cn/v2/upm/start', observedAt: `2026-10-29T01:0${copy}:00.000Z` } }))).flat(); } function duplicateJudgeFixture() { return fixture().replaceAll('华谊集团化工总控工(高级)-判断题库1', DEDUP_BANK.name) .replace('干燥过程传质推动力:物料表面水分压P水>热空气中的水分压P水', duplicateJudgeRecords()[0].stem) .replace('', '
进度27/232
'); } async function setupBankSwitches(page) { await page.evaluate(banks => { window.__qaBanks = banks; const installQuestion = bank => { const container = document.querySelector('.yxtulcdsdk-exam-container'); container.setAttribute('task-id', bank.taskId); document.querySelector('.yxtulcdsdk-exam-header .yxtf-tooltip').textContent = bank.name; const main = container.querySelector('.yxtulcdsdk-pc-marking-main'); main.querySelector('.standard-size-14 span').textContent = { judge: '判断题', single: '单选题', multiple: '多选题' }[bank.type]; const choices = bank.type === 'judge' ? [['1', '正确'], ['0', '错误']] : bank.type === 'multiple' ? [['check-t', '检查温度'], ['check-p', '检查压力'], ['skip', '跳过巡检']] : [['hold', '保持不变'], ['no', '停止运行'], ['yes', bank.answer]]; const isMultiple = bank.type === 'multiple'; main.querySelector('.yxtulcdsdk-form__content').innerHTML = '
' + choices.map(([id, text], index) => ``).join('') + '
'; main.querySelector('.yxtulcdsdk-form__content > [data-rich-text]').textContent = bank.stem; }; installQuestion(banks[0]); document.querySelectorAll('[data-qa-bank]').forEach(button => button.addEventListener('click', () => { const bank = banks.find(item => item.id === button.dataset.qaBank); document.querySelectorAll('[data-qa-bank]').forEach(item => { item.classList.toggle('task-content-active', item === button); item.querySelector('.group-title').classList.toggle('task-content-color-active', item === button); }); location.hash = `/playinfo?taskId=${bank.taskId}`; window.setTimeout(async () => { installQuestion(bank); await fetch(`/upm/start?praId=${bank.id}&item=bank-${bank.id}`).then(response => response.json()); }, 90); })); }, BANK_FIXTURES); await page.evaluate(bank => fetch(`/upm/start?praId=${bank.id}&item=bank-${bank.id}`).then(response => response.json()), BANK_FIXTURES[0]); } async function downloadedCSV(page, filename) { const waiting = page.waitForEvent('download'); await page.locator('[data-action="export-csv"]').click(); const download = await waiting; const location = path.join(QA_DIR, filename); await download.saveAs(location); return { name: download.suggestedFilename(), text: fs.readFileSync(location, 'utf8') }; } function installGMShim() { window.unsafeWindow = window; window.__qbMenus = []; window.GM_getValue = (key, fallback) => { const value = localStorage.getItem(key); return value === null ? fallback : JSON.parse(value); }; window.GM_setValue = (key, value) => localStorage.setItem(key, JSON.stringify(value)); window.GM_registerMenuCommand = (label, callback) => { window.__qbMenus.push({ label, callback }); return window.__qbMenus.length; }; window.GM_addValueChangeListener = () => 1; window.GM_removeValueChangeListener = () => {}; window.__qbOriginalFetch = window.fetch; window.__qbOriginalXHROpen = XMLHttpRequest.prototype.open; window.__qbOriginalXHRSend = XMLHttpRequest.prototype.send; } async function waitFor(predicate, message, timeout = 5000) { const expires = Date.now() + timeout; do { const value = await predicate(); if (value) return value; await new Promise(resolve => setTimeout(resolve, 40)); } while (Date.now() < expires); assert.fail(message); } async function storageRecords(page) { return page.evaluate(key => window.GM_getValue(key, []), RECORDS_KEY); } async function activate(page, tab) { if (!await page.locator('#qb-panel').isVisible()) await page.keyboard.press('Alt+Shift+Q'); const exact = page.locator(`#huayi-qb-root [data-tab="${tab}"]`); if (await exact.count()) { await exact.click(); return; } const names = { search: /检索|查询/, library: /题库|导出/, settings: /设置/, collect: /采集/ }; const candidates = page.getByRole('tab', { name: names[tab] }); if (await candidates.count()) await candidates.first().click(); } async function importFile(page, name, text) { await activate(page, 'library'); await page.locator('#qb-import-file').setInputFiles({ name, mimeType: name.endsWith('.json') ? 'application/json' : 'text/csv', buffer: Buffer.from(text) }); await waitFor(async () => await page.locator('#qb-import-file').evaluate(input => input.files.length === 0), '等待异步文件解析和界面刷新完成'); } async function selectQuestion(page) { await page.evaluate(() => { const content = document.querySelector('.yxtulcdsdk-form__content > [data-rich-text]'); const range = document.createRange(); range.selectNodeContents(content); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); content.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); document.dispatchEvent(new Event('selectionchange')); }); } async function setSingleQuestion(page) { await page.evaluate(() => { const container = document.querySelector('.yxtulcdsdk-pc-marking-main'); container.querySelector('.standard-size-14 span').textContent = '单选题'; container.querySelector('.yxtulcdsdk-form__sub').textContent = '7.'; container.querySelector('.yxtulcdsdk-form__content').innerHTML = '
以下哪项操作可以提高温度?
' + [['z', '保持不变'], ['x', '降低温度'], ['y', '提高温度']].map(([id, text], i) => '').join('') + '
'; }); } function ordinaryFixture() { return `普通网页自动答题验收

普通网页练习

使用本地题库,仅选择已确认的选项。

`; } const AUTO_FIXTURES = { single: { type: 'single', stem: '通用单选:哪项操作有助于提高温度?', options: [['hold', '保持不变'], ['cool', '降低温度'], ['heat', '提高温度']], answer: ['提高温度'] }, judge: { type: 'judge', stem: '通用判断:停车检修时可以跳过确认步骤。', options: [['1', '正确'], ['0', '错误']], answer: ['错误'] }, multiple: { type: 'multiple', stem: '通用多选:巡检时需要检查哪些参数?', options: [['skip', '跳过检查'], ['pressure', '压力'], ['temperature', '温度']], answer: ['压力', '温度'] }, continuous: { type: 'single', stem: '连续模式:换题后应选择哪个已确认的项目?', options: [['hold', '保持状态'], ['check', '完成检查']], answer: ['完成检查'] }, stopped: { type: 'single', stem: '关闭模式:这一题应由用户自行选择。', options: [['stop-a', '选项甲'], ['stop-b', '选项乙']], answer: ['选项乙'] }, ambiguous: { type: 'single', stem: '歧义题:同题干两个来源给出不同答案。', options: [['a', '做法甲'], ['b', '做法乙']], answer: ['做法甲'] }, missing: { type: 'single', stem: '尚无标准答案的通用题目。', options: [['a', '选项一'], ['b', '选项二']], answer: [] } }; function autoRecords() { const records = Object.entries(AUTO_FIXTURES).filter(([key]) => key !== 'missing').map(([key, item]) => ({ bankId: 'generic-seed-bank', bankName: '通用已确认题库', questionId: `generic-${key}`, type: item.type, stem: item.stem, options: item.options.map(([id, text], index) => ({ id, text, label: item.type === 'judge' ? text : String.fromCharCode(65 + index) })), answerTexts: item.answer, correctOptionIds: item.options.filter(([, text]) => item.answer.includes(text)).map(([id]) => id), answerStatus: 'confirmed' })); const ambiguous = records.find(record => record.questionId === 'generic-ambiguous'); records.push({ ...ambiguous, bankId: 'other-generic-bank', bankName: '另一来源题库', questionId: 'generic-ambiguous-other', answerTexts: ['做法乙'], correctOptionIds: ['b'] }); return records; } async function installOrdinaryQuestion(page, key, wrongChecked = false) { await page.evaluate(({ question, wrongChecked }) => { const root = document.querySelector('#ordinary-question'); root.replaceChildren(); const fieldset = document.createElement('fieldset'); fieldset.id = 'generic-question'; const legend = document.createElement('legend'); legend.textContent = question.stem; fieldset.append(legend); question.options.forEach(([id, text], index) => { const label = document.createElement('label'); const input = document.createElement('input'); input.type = question.type === 'multiple' ? 'checkbox' : 'radio'; input.name = 'ordinary-answer'; input.value = id; input.id = `ordinary-${id}`; if (wrongChecked && index === 0) input.checked = true; label.append(input, document.createTextNode(` ${text}`)); fieldset.append(label); }); root.append(fieldset); }, { question: AUTO_FIXTURES[key], wrongChecked }); } test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢复', { timeout: 150000 }, async t => { const { chromium } = loadPlaywright(); const builtPath = path.join(ROOT, 'dist/huayi-question-bank.user.js'); assert.ok(fs.existsSync(builtPath), '先运行 node build.cjs 生成安装版脚本'); const script = fs.readFileSync(builtPath, 'utf8'); fs.mkdirSync(QA_DIR, { recursive: true }); const markup = fixture(); const server = http.createServer((req, res) => { if (req.url === '/favicon.ico') { res.writeHead(204); res.end(); return; } if (req.url.startsWith('/upm/start') || req.url.startsWith('/unrelated/report')) { const item = new URL(req.url, 'http://127.0.0.1').searchParams.get('item') || '1'; res.writeHead(200, { 'Content-Type': 'application/json' }); if (item === 'legacy-duplicates') { const copy = Math.max(0, Math.min(2, Number(new URL(req.url, 'http://127.0.0.1').searchParams.get('copy')) || 0)); res.end(JSON.stringify({ data: { praId: DEDUP_BANK.id, praName: DEDUP_BANK.name, listQues: duplicateJudgeRecords().filter(record => record.questionId.endsWith(`-${copy}`)).map(record => ({ id: record.questionId, quesType: 2, content: record.stem, judgeAnswer: 1, submitQuesAnswers: ['0'] })) } })); return; } if (item.startsWith('bank-')) { const bank = BANK_FIXTURES.find(candidate => item === `bank-${candidate.id}`); const delay = Math.min(1000, Number(new URL(req.url, 'http://127.0.0.1').searchParams.get('delay')) || 0); const finish = () => res.end(JSON.stringify(bank ? bankResponse(bank) : { data: {} })); if (delay) setTimeout(finish, delay); else finish(); return; } if (item === 'scope-current') { res.end(JSON.stringify({ data: { praId: 'scope-bank', praName: '范围验收题库', listQues: [{ id: 'scope-current', quesType: 0, content: '当前题库范围验收题', choiceItems: [{ id: 'x', content: '降低温度' }, { id: 'y', content: '提高温度' }, { id: 'z', content: '保持不变' }] }] } })); return; } res.end(JSON.stringify({ data: { praId: 'qa-network', praName: '接口验收题库', listQues: [{ id: `network-${item}`, quesType: 0, content: `接口加载时尚未返回答案的题目 ${item}`, choiceItems: [{ id: 'n-a', content: '我的错误作答' }, { id: 'n-b', content: '平台标准选项' }] }] } })); return; } if (req.url === '/upm/qa-network/users/qa-user/answer') { req.resume(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ data: { correctAnswers: ['n-b'], explainText: '来自正常作答结果的标准答案。' } })); return; } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(req.url.startsWith('/dedup-bank') ? duplicateJudgeFixture() : req.url.startsWith('/multi-bank') ? multiBankFixture() : markup); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const origin = `http://127.0.0.1:${server.address().port}`; const browser = await chromium.launch(browserOptions()); const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true }); await context.addInitScript(installGMShim); await context.addInitScript({ content: script }); const page = await context.newPage(); const errors = []; page.on('pageerror', error => errors.push(error.message)); page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 204, body: '' })); try { await page.goto(`${origin}/o2o/#/playinfo?taskId=qa-task-initial`, { waitUntil: 'domcontentloaded' }); await page.locator('#huayi-qb-root').waitFor({ state: 'attached' }); assert.equal(await page.locator('#qb-panel').isVisible(), false, '首次加载默认完全隐藏'); assert.equal(await page.locator('#qb-launcher').isVisible(), false, '默认不保留悬浮按钮'); await page.keyboard.press('Alt+Shift+Q'); await page.locator('#qb-panel').waitFor({ state: 'visible' }); await t.test('页面非空、脱敏DOM夹具采集缺答案题目并恢复文字选择', async () => { assert.match(await page.title(), /判断题库1/); assert.equal(new URL(page.url()).origin, origin); assert.match(await page.locator('.fixture-content').innerText(), /干燥过程传质推动力/); const records = await waitFor(async () => (await storageRecords(page)).find(record => record.stem.includes('干燥过程传质推动力')), '真实题干应写入本地题库'); assert.equal(records.answerStatus, 'missing'); assert.equal(records.answerTexts.length, 0); assert.equal(await page.locator('.yxtulcdsdk-pc-marking-main').evaluate(element => getComputedStyle(element).userSelect), 'text'); assert.equal(await page.locator('nextjs-portal, vite-error-overlay, #webpack-dev-server-client-overlay').count(), 0); }); await t.test('真实fetch与XHR响应观察:标准答案按同题ID补齐,暂停后停止采集', async () => { await page.evaluate(() => fetch('/upm/start?praId=qa-network').then(response => response.json())); const loaded = await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === 'network-1'), 'fetch返回的完整题目应被采集'); assert.equal(loaded.answerStatus, 'missing'); await page.evaluate(() => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', '/upm/qa-network/users/qa-user/answer'); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onload = () => resolve(xhr.status); xhr.onerror = reject; xhr.send(JSON.stringify({ answers: [{ quesId: 'network-1', answers: ['n-a'] }] })); })); const completed = await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === 'network-1' && record.answerStatus === 'confirmed'), 'XHR标准答案应按请求题ID补齐'); assert.deepEqual(completed.answerTexts, ['平台标准选项']); assert.ok(!completed.answerTexts.includes('我的错误作答'), '不能将学生选择作为标准答案'); await page.evaluate(() => fetch('/unrelated/report?item=noise').then(response => response.text())); await page.waitForTimeout(300); assert.equal((await storageRecords(page)).some(record => record.questionId === 'network-noise'), false, '无关接口不能采集'); await activate(page, 'library'); await page.locator('[data-action="toggle-capture"]').click(); await page.evaluate(() => fetch('/upm/start?praId=qa-network&item=paused').then(response => response.json())); await page.waitForTimeout(300); assert.equal((await storageRecords(page)).some(record => record.questionId === 'network-paused'), false); await page.locator('[data-action="toggle-capture"]').click(); await page.evaluate(() => fetch('/upm/start?praId=qa-network&item=resumed').then(response => response.json())); await waitFor(async () => (await storageRecords(page)).some(record => record.questionId === 'network-resumed'), '恢复采集后应重新观察fetch'); await activate(page, 'search'); }); await t.test('关闭复制恢复开关后原禁选样式生效,再开启恢复文字选择', async () => { await activate(page, 'settings'); await page.locator('#qb-copy-enabled').uncheck(); assert.equal(await page.locator('.yxtulcdsdk-pc-marking-main').evaluate(element => getComputedStyle(element).userSelect), 'none'); await page.locator('#qb-copy-enabled').check(); assert.equal(await page.locator('.yxtulcdsdk-pc-marking-main').evaluate(element => getComputedStyle(element).userSelect), 'text'); await activate(page, 'search'); }); await t.test('折叠后可展开;隐藏无悬浮入口且刷新后仍完全隐藏', async () => { const fullHeight = (await page.locator('#qb-panel').boundingBox()).height; await page.locator('[data-action="collapse"]').click(); const collapsedHeight = (await page.locator('#qb-panel').boundingBox()).height; assert.ok(collapsedHeight < fullHeight, '折叠应缩小面板'); await page.locator('[data-action="collapse"]').click(); await page.locator('.header [data-action="hide"]').click(); assert.equal(await page.locator('#qb-panel').isVisible(), false); assert.equal(await page.locator('#qb-launcher').isVisible(), false); await page.reload({ waitUntil: 'domcontentloaded' }); await page.locator('#huayi-qb-root').waitFor({ state: 'attached' }); assert.equal(await page.locator('#qb-panel').isVisible(), false); await selectQuestion(page); await page.waitForTimeout(300); assert.equal(await page.locator('#qb-panel').isVisible(), false, '隐藏后划词不能擅自弹出面板'); await page.screenshot({ path: path.join(QA_DIR, 'hidden-default.png') }); await page.keyboard.press('Alt+Shift+Q'); assert.equal(await page.locator('#qb-panel').isVisible(), true); assert.match(await page.locator('#qb-search').inputValue(), /干燥过程传质推动力/, '恢复面板后应保留隐藏时划词检索的内容'); }); await t.test('快捷键切换显隐;即使旧配置为打开和悬浮按钮刷新也保持隐藏', async () => { await activate(page, 'settings'); assert.equal(await page.locator('#qb-keep-launcher').count(), 0, '默认隐藏不再提供悬浮按钮偏好'); await page.locator('.header [data-action="hide"]').click(); assert.equal(await page.locator('#qb-panel').isVisible(), false); assert.equal(await page.locator('#qb-launcher').isVisible(), false); await page.evaluate(key => window.GM_setValue(key, { ...window.GM_getValue(key, {}), mode: 'open', keepLauncher: true }), CONFIG_KEY); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('#huayi-qb-root', { state: 'attached' }); assert.equal(await page.locator('#qb-launcher').isVisible(), false); await page.keyboard.press('Alt+Shift+Q'); await page.locator('#qb-panel').waitFor({ state: 'visible' }); await page.locator('.header [data-action="hide"]').click(); await page.keyboard.press('Alt+Shift+Q'); assert.equal(await page.locator('#qb-panel').isVisible(), true); }); await t.test('JSON导入与当前页面选项乱序:正确内容映射为C', async () => { const initial = (await storageRecords(page)).find(record => record.stem.includes('干燥过程传质推动力')); assert.ok(initial, '使用当前页面原始题库作为导入归属,不能依赖记录数组顺序'); const record = { bankId: initial.bankId, bankName: initial.bankName, questionId: 'e2e-single-7', type: 'single', stem: '以下哪项操作可以提高温度?', options: [{ id: 'x', text: '降低温度', label: 'A' }, { id: 'y', text: '提高温度', label: 'B' }, { id: 'z', text: '保持不变', label: 'C' }], correctOptionIds: ['y'], answerTexts: ['提高温度'], answerStatus: 'confirmed', explanation: '用于验证答案内容与页面当前顺序对应。', source: { kind: 'fixture', url: origin, observedAt: new Date().toISOString() } }; await importFile(page, 'verified-question.json', JSON.stringify({ schemaVersion: 1, records: [record] })); await waitFor(async () => (await storageRecords(page)).some(item => item.questionId === record.questionId), 'JSON导入应持久化'); await setSingleQuestion(page); await activate(page, 'search'); await page.locator('#qb-search').fill(record.stem); await waitFor(async () => /提高温度/.test(await page.locator('#qb-panel').innerText()), '检索应显示正确答案内容'); await waitFor(async () => /当前[^\n]{0,20}C|对应[^\n]{0,20}C/.test(await page.locator('#qb-panel').innerText()), '原B答案应按页面乱序映射到C'); await waitFor(async () => (await storageRecords(page)).filter(item => item.stem === record.stem).length === 1, '无ID的DOM题目应与唯一同题标准答案合并'); assert.equal(await page.locator('#qb-results article').count(), 1, '同一题不能同时出现已确认和待补两张卡'); await page.screenshot({ path: path.join(QA_DIR, 'desktop-answer-mapping.png') }); }); let exportedCSV = ''; await t.test('CSV真实下载、BOM与答案内容及重新导入', async () => { await activate(page, 'library'); await page.locator('#qb-scope').selectOption(''); const downloaded = page.waitForEvent('download'); await page.locator('[data-action="export-csv"]').click(); const download = await downloaded; const csvPath = path.join(QA_DIR, 'exported-question-bank.csv'); await download.saveAs(csvPath); const bytes = fs.readFileSync(csvPath); assert.equal(bytes.subarray(0, 3).toString('hex'), 'efbbbf'); exportedCSV = bytes.toString('utf8'); assert.match(exportedCSV, /提高温度/); const before = (await storageRecords(page)).length; await importFile(page, 'round-trip.csv', exportedCSV); await page.waitForTimeout(350); assert.equal((await storageRecords(page)).length, before, 'CSV重复导入应去重'); }); await t.test('390px视口面板可见且不超出视口', async () => { await page.setViewportSize({ width: 390, height: 844 }); await activate(page, 'search'); await page.locator('#qb-search').fill('以下哪项操作可以提高温度?'); await page.waitForTimeout(200); const rect = await page.locator('#qb-panel').boundingBox(); assert.ok(rect.x >= 0 && rect.x + rect.width <= 391, '面板横向不应溢出'); assert.ok(rect.y >= 0 && rect.y + rect.height <= 845, '面板纵向应适配可用高度'); await page.screenshot({ path: path.join(QA_DIR, 'mobile-answer-mapping.png') }); }); await t.test('格式错误导入保持已有题库并给出失败提示', async () => { const before = (await storageRecords(page)).length; await importFile(page, 'broken.json', '{"schemaVersion":1,"records":[{"stem":"缺少题型"}]}'); await waitFor(async () => /导入失败/.test(await page.locator('#qb-status').innerText()), '错误文件应显示导入失败'); assert.equal((await storageRecords(page)).length, before); }); await t.test('当前题库范围包含唯一同名无bankId旧题;同名多bankId时不盲并', async () => { await setSingleQuestion(page); await page.evaluate(() => { document.querySelector('.yxtulcdsdk-exam-header .yxtf-tooltip').textContent = '范围验收题库'; document.querySelector('.yxtulcdsdk-form__content > [data-rich-text]').textContent = '当前题库范围验收题'; }); await page.evaluate(() => fetch('/upm/start?praId=scope-bank&item=scope-current').then(response => response.json())); await waitFor(async () => (await storageRecords(page)).some(record => record.questionId === 'scope-current' && record.bankId === 'scope-bank'), '当前题应从网络数据取得稳定题库ID'); const shared = { bankName: '范围验收题库', type: 'single', options: [{ id: 'x', text: '降低温度', label: 'A' }, { id: 'y', text: '提高温度', label: 'B' }, { id: 'z', text: '保持不变', label: 'C' }], correctOptionIds: ['y'], answerTexts: ['提高温度'], answerStatus: 'confirmed', source: { kind: 'import', url: '', observedAt: new Date().toISOString() } }; await importFile(page, 'current-scope.json', JSON.stringify({ schemaVersion: 1, records: [ { ...shared, bankId: 'scope-bank', questionId: 'scope-current', stem: '当前题库范围验收题' }, { ...shared, bankId: '', questionId: 'scope-legacy', stem: '只有题库名称的旧题也应导出' } ] })); await waitFor(async () => (await storageRecords(page)).some(record => record.questionId === 'scope-legacy'), '同名无bankId旧题应已导入'); await page.locator('#qb-scope').selectOption('__current__'); const downloadCSV = async filename => { const waiting = page.waitForEvent('download'); await page.locator('[data-action="export-csv"]').click(); const download = await waiting; const location = path.join(QA_DIR, filename); await download.saveAs(location); return fs.readFileSync(location, 'utf8'); }; const uniqueScope = await downloadCSV('current-scope-unique-name.csv'); assert.match(uniqueScope, /scope-current/); assert.match(uniqueScope, /scope-legacy/, '唯一同名旧题不能从当前题库导出中遗漏'); await importFile(page, 'ambiguous-scope.json', JSON.stringify({ schemaVersion: 1, records: [ { ...shared, bankId: 'scope-other-bank', questionId: 'scope-other', stem: '同名但不同题库ID的题目' } ] })); await waitFor(async () => (await storageRecords(page)).some(record => record.questionId === 'scope-other'), '第二个同名题库应已导入'); const ambiguousScope = await downloadCSV('current-scope-ambiguous-name.csv'); assert.match(ambiguousScope, /scope-current/); assert.doesNotMatch(ambiguousScope, /scope-legacy/, '存在同名多个题库ID时不能猜测无ID旧题归属'); assert.doesNotMatch(ambiguousScope, /scope-other/, '其他题库ID不能混入当前题库范围'); await page.locator('#qb-scope').selectOption(''); }); await t.test('JSON批次第二题非法时整批保持未导入', async () => { const before = (await storageRecords(page)).length; const valid = { bankId: 'batch-bank', bankName: '批次验收题库', questionId: 'atomic-first-must-not-save', type: 'single', stem: '批次第一条有效题目', options: [{ id: 'a', text: '选项一', label: 'A' }, { id: 'b', text: '选项二', label: 'B' }], correctOptionIds: ['a'], answerTexts: ['选项一'], answerStatus: 'confirmed' }; await importFile(page, 'invalid-second-record.json', JSON.stringify({ schemaVersion: 1, records: [ valid, { ...valid, questionId: 'atomic-invalid-second', type: 'unsupported', stem: '批次第二条非法题目' } ] })); await waitFor(async () => /导入失败/.test(await page.locator('#qb-status').innerText()), '第二题非法应显示整批导入失败'); const records = await storageRecords(page); assert.equal(records.length, before); assert.equal(records.some(record => record.questionId === 'atomic-first-must-not-save'), false, '不能先导入第一条再失败'); }); await t.test('已确认答案不属于选项时展示冲突卡且不给出答案字母', async () => { const stem = '答案不在选项冲突验收题'; await importFile(page, 'invalid-confirmed-answer.json', JSON.stringify({ schemaVersion: 1, records: [{ bankId: 'conflict-bank', bankName: '冲突验收题库', questionId: 'answer-outside-options', type: 'single', stem, options: [{ id: 'a', text: '选项一', label: 'A' }, { id: 'b', text: '选项二', label: 'B' }], answerTexts: ['不存在的正确选项'], answerStatus: 'confirmed' }] })); const record = await waitFor(async () => (await storageRecords(page)).find(item => item.questionId === 'answer-outside-options'), '不一致答案应保留供核验'); assert.equal(record.answerStatus, 'conflict'); await activate(page, 'search'); await page.locator('#qb-search-scope').selectOption(''); await page.locator('#qb-search').fill(stem); await waitFor(async () => (await page.locator('#qb-results .badge.conflict').count()) === 1, '不一致答案应显示冲突状态'); const card = page.locator('#qb-results article').first(); assert.match(await card.innerText(), /存在不同标准答案,需要核验/); assert.doesNotMatch(await card.innerText(), /当前页面对应[::]/, '冲突答案不能显示已确认字母'); }); await t.test('多题库:文字搜索、当前库统计与命名、多文件导入', async group => { const bankContext = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true }); await bankContext.addInitScript(installGMShim); await bankContext.addInitScript({ content: script }); await bankContext.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 204, body: '' })); const bankPage = await bankContext.newPage(); bankPage.on('pageerror', error => errors.push(error.message)); bankPage.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); try { await bankPage.goto(`${origin}/multi-bank#/playinfo?taskId=${BANK_FIXTURES[0].taskId}`, { waitUntil: 'domcontentloaded' }); await bankPage.locator('#huayi-qb-root').waitFor({ state: 'attached' }); assert.equal(await bankPage.locator('#qb-panel').isVisible(), false); await bankPage.keyboard.press('Alt+Shift+Q'); await bankPage.locator('#qb-panel').waitFor({ state: 'visible' }); await setupBankSwitches(bankPage); await waitFor(async () => (await storageRecords(bankPage)).some(record => record.bankId === 'judge-1'), '初始判断题库应采集到稳定身份'); await group.test('五个题库侧栏切换只统计和导出当前库,文件名随实际名称变化', async () => { for (const bank of BANK_FIXTURES) { if (bank.id !== BANK_FIXTURES[0].id) await bankPage.locator(`[data-qa-bank="${bank.id}"]`).click(); await waitFor(async () => (await storageRecords(bankPage)).some(record => record.bankId === bank.id), `${bank.name}应采集成功`); await waitFor(async () => (await bankPage.locator('#qb-context-name').innerText()).includes(bank.name), '当前题库名称应跟随红色侧栏项'); await activate(bankPage, 'library'); assert.equal(await bankPage.locator('#qb-scope').inputValue(), '__current__', '切换后默认导出当前题库'); assert.equal((await bankPage.locator('#qb-stat-total').innerText()).replace(/,/g, ''), '1', '切换后主统计不能累积其他题库'); assert.ok((await bankPage.locator('#qb-export-name').innerText()).includes(bank.name), '导出说明应包含实际题库名称'); const exported = await downloadedCSV(bankPage, `current-${bank.id}.csv`); assert.ok(exported.name.includes(bank.name), `文件名应使用实际题库名称:${exported.name}`); assert.ok(exported.text.includes(bank.stem)); for (const other of BANK_FIXTURES.filter(item => item.id !== bank.id)) assert.ok(!exported.text.includes(other.stem), '当前库CSV不能混入已保存的其他题库'); } assert.match(await bankPage.title(), /判断题库1/, '页面title保持旧值,验证侧栏优先识别'); const stored = await storageRecords(bankPage); for (const bank of BANK_FIXTURES) { const exact = stored.filter(record => record.stem === bank.stem); assert.equal(exact.length, 1, '切换过渡期间旧题不得采为新题库副本'); assert.equal(exact[0].bankId, bank.id); assert.equal(exact[0].bankName, bank.name); } await bankPage.screenshot({ path: path.join(QA_DIR, 'current-bank-export.png') }); }); await group.test('华谊题目DOM可自动选择多选和乱序单选,保持提交与下一题由用户操作', async () => { await bankPage.evaluate(() => { window.__qaSiteButtonClicks = 0; document.querySelector('.fixture-content').addEventListener('click', event => { if (event.target.closest('button')) window.__qaSiteButtonClicks++; }); }); await activate(bankPage, 'auto'); assert.equal(await bankPage.locator('#qb-auto-enabled').isChecked(), false); await bankPage.locator('[data-action="auto-answer-current"]').click(); await waitFor(async () => (await bankPage.locator('.yxtulcdsdk-form input:checked').count()) === 2, '华谊多选应同时选择两个标准答案'); assert.equal(await bankPage.locator('.yxtulcdsdk-form input[value="check-t"]').isChecked(), true); assert.equal(await bankPage.locator('.yxtulcdsdk-form input[value="check-p"]').isChecked(), true); assert.equal(await bankPage.locator('.yxtulcdsdk-form input[value="skip"]').isChecked(), false); await bankPage.locator('[data-qa-bank="single-1"]').click(); await waitFor(async () => (await bankPage.locator('#qb-context-name').innerText()).includes('单选题库1'), '切到单选库等待题库身份更新'); await waitFor(async () => (await bankPage.locator('.yxtulcdsdk-form__content > [data-rich-text]').innerText()).includes('单选库一'), '等待新题目正文加载'); await bankPage.locator('[data-action="auto-answer-current"]').click(); await waitFor(async () => await bankPage.locator('.yxtulcdsdk-form input[value="yes"]').isChecked(), '华谊单选应按当前C位置选中标准内容'); assert.equal(await bankPage.locator('.yxtulcdsdk-form input:checked').count(), 1); assert.equal(await bankPage.evaluate(() => window.__qaSiteButtonClicks), 0, '自动答题不能点击题目区的上一题、下一题或提交按钮'); await bankPage.screenshot({ path: path.join(QA_DIR, 'huayi-auto-answer.png') }); }); await group.test('文字搜索可手输题干、选项与标准答案,支持当前库和全部库', async () => { await bankPage.evaluate(() => { window.__qaLateResponse = fetch('/upm/start?praId=multiple&item=bank-multiple&delay=500').then(response => response.json()); }); await bankPage.locator('[data-qa-bank="single-1"]').click(); await waitFor(async () => (await bankPage.locator('#qb-context-name').innerText()).includes('单选题库1'), '应切至单选库1'); await bankPage.evaluate(() => window.__qaLateResponse); assert.ok((await bankPage.locator('#qb-context-name').innerText()).includes('单选题库1'), '旧题库迟到响应不能把当前名称改回去'); await activate(bankPage, 'search'); await bankPage.locator('#qb-search-field').selectOption('stem'); await bankPage.locator('#qb-search').fill('单选库一'); await bankPage.locator('#qb-search').press('Enter'); await waitFor(async () => (await bankPage.locator('#qb-results article').count()) === 1, '手输题干应得到唯一候选'); assert.match(await bankPage.locator('#qb-results').innerText(), /提高温度/); await bankPage.locator('#qb-search-field').selectOption('options'); await bankPage.locator('#qb-search').fill('提高温度'); await bankPage.locator('[data-action="search"]').click(); await waitFor(async () => (await bankPage.locator('#qb-results article').count()) === 1, '选项文字应可检索'); await bankPage.locator('#qb-search-field').selectOption('answer'); await bankPage.locator('#qb-search').fill('降低压力'); await bankPage.locator('#qb-search').press('Enter'); await waitFor(async () => (await bankPage.locator('#qb-results article').count()) === 0, '当前单选库1不应查出单选库2答案'); await bankPage.locator('#qb-search-scope').selectOption(''); await waitFor(async () => (await bankPage.locator('#qb-results article').count()) === 1, '全部库应检索到另一题库的标准答案'); assert.match(await bankPage.locator('#qb-results').innerText(), /单选题库2/); await bankPage.locator('[data-action="clear-search"]').click(); assert.equal(await bankPage.locator('#qb-search').inputValue(), ''); assert.equal(await bankPage.locator('#qb-results article').count(), 0); await bankPage.locator('#qb-search-field').selectOption('all'); await bankPage.locator('#qb-search').fill('巡检'); await bankPage.locator('#qb-search').press('Enter'); await waitFor(async () => (await bankPage.locator('#qb-results article').count()) > 0, '全部字段关键词应可查询'); await bankPage.screenshot({ path: path.join(QA_DIR, 'text-search-all-banks.png') }); }); await group.test('一次导入多个CSV和JSON题库并保留各自名称,重复导入不增加题数', async () => { const jsonRecord = { bankId: 'import-chemistry', bankName: '导入化学题库', questionId: 'import-chemical-one', type: 'single', stem: '化学导入样题的答案是什么?', options: [{ id: 'a', text: '甲', label: 'A' }, { id: 'b', text: '乙', label: 'B' }], answerTexts: ['乙'], correctOptionIds: ['b'], answerStatus: 'confirmed' }; const files = [ { name: '化学.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ schemaVersion: 1, records: [jsonRecord] })) }, { name: '设备.csv', mimeType: 'text/csv', buffer: Buffer.from('来源题库,题型,题干,选项A,选项B,正确答案内容,答案状态\n导入设备题库,单选题,设备导入样题如何操作?,开阀,关阀,开阀,已确认\n') }, { name: '文件名备用题库.csv', mimeType: 'text/csv', buffer: Buffer.from('题型,题干,选项A,选项B,正确答案内容,答案状态\n单选题,没有来源名称的题目如何归库?,一,二,二,已确认\n') } ]; await activate(bankPage, 'library'); assert.equal(await bankPage.locator('#qb-import-file').getAttribute('multiple') !== null, true); await bankPage.locator('#qb-import-file').setInputFiles(files); await waitFor(async () => (await storageRecords(bankPage)).some(record => record.questionId === jsonRecord.questionId), '多个文件应一批导入'); const records = await storageRecords(bankPage); assert.ok(records.some(record => record.bankName === '导入化学题库' && record.stem === jsonRecord.stem)); assert.ok(records.some(record => record.bankName === '导入设备题库' && record.stem.startsWith('设备导入'))); assert.ok(records.some(record => record.bankName === '文件名备用题库' && record.stem.startsWith('没有来源名称'))); const total = records.length; await bankPage.locator('#qb-import-file').setInputFiles(files); await waitFor(async () => /导入/.test(await bankPage.locator('#qb-status').innerText()), '重复多文件导入应完成'); await bankPage.waitForTimeout(350); assert.equal((await storageRecords(bankPage)).length, total); assert.equal((await bankPage.locator('#qb-stat-total').innerText()).replace(/,/g, ''), '1', '导入多个题库不改变当前题库统计'); }); await group.test('多个文件中一个格式错误时整批不导入', async () => { const before = (await storageRecords(bankPage)).length; await bankPage.locator('#qb-import-file').setInputFiles([ { name: '这一库不能部分写入.csv', mimeType: 'text/csv', buffer: Buffer.from('来源题库,题型,题干,选项A,选项B,正确答案内容\n原子文件题库,单选题,多文件失败前有效题目,一,二,一\n') }, { name: '坏文件.json', mimeType: 'application/json', buffer: Buffer.from('{"schemaVersion":1,"records":[{"stem":"缺少题型"}]}') } ]); await waitFor(async () => /导入失败/.test(await bankPage.locator('#qb-status').innerText()), '应提示多文件批次失败'); assert.equal((await storageRecords(bankPage)).length, before); assert.equal((await storageRecords(bankPage)).some(record => record.stem === '多文件失败前有效题目'), false); }); await group.test('导出指定题库使用实际名称并清理Windows文件名非法字符', async () => { const bankName = '自定义:专项/题库*?'; await importFile(bankPage, 'special.json', JSON.stringify({ schemaVersion: 1, records: [{ bankId: 'special-bank', bankName, questionId: 'special-one', type: 'judge', stem: '特殊名称题库的判断题', options: [{ id: '1', text: '正确' }, { id: '0', text: '错误' }], answerTexts: ['正确'], correctOptionIds: ['1'], answerStatus: 'confirmed' }] })); const value = await bankPage.locator('#qb-scope option').evaluateAll((options, name) => options.find(option => option.textContent.includes(name))?.value, bankName); assert.ok(value, '应可选择刚导入的指定题库'); await bankPage.locator('#qb-scope').selectOption(value); const exported = await downloadedCSV(bankPage, 'special-name.csv'); assert.match(exported.name, /自定义.*专项.*题库/); assert.doesNotMatch(exported.name, /[<>:"/\\|?*]/, '文件名不能包含Windows非法字符'); assert.match(exported.text, /特殊名称题库的判断题/); assert.ok(exported.text.includes(bankName), 'CSV内部来源名称应保留原文'); }); await group.test('新空题库切换后不继续显示或导出上一库数据,窄屏布局正常', async () => { await bankPage.evaluate(() => { document.querySelectorAll('.task-content-active').forEach(item => item.classList.remove('task-content-active')); const button = document.createElement('button'); button.className = 'task-content task-content-active'; button.dataset.taskId = 'qa-task-empty'; button.innerHTML = '尚未载入的空题库'; document.querySelector('#qa-sidebar').append(button); location.hash = '/playinfo?taskId=qa-task-empty'; document.querySelector('.yxtulcdsdk-exam-container').setAttribute('task-id', 'qa-task-empty'); document.querySelector('.yxtulcdsdk-exam-header .yxtf-tooltip').textContent = '尚未载入的空题库'; document.querySelector('.yxtulcdsdk-pc-marking-main').replaceChildren(); }); await waitFor(async () => (await bankPage.locator('#qb-context-name').innerText()).includes('尚未载入的空题库'), '空库名称也应及时更新'); await activate(bankPage, 'library'); assert.equal(await bankPage.locator('#qb-scope').inputValue(), '__current__'); await waitFor(async () => (await bankPage.locator('#qb-stat-total').innerText()).trim() === '0', '空库主计数应为0'); let downloaded = false; const captureDownload = () => { downloaded = true; }; bankPage.on('download', captureDownload); await bankPage.locator('[data-action="export-csv"]').click(); await bankPage.waitForTimeout(250); bankPage.off('download', captureDownload); assert.equal(downloaded, false, '空库不能导出上一题库的数据'); assert.match(await bankPage.locator('#qb-status').innerText(), /没有题目|暂无|尚未/); await bankPage.setViewportSize({ width: 390, height: 844 }); await activate(bankPage, 'search'); const rect = await bankPage.locator('#qb-panel').boundingBox(); assert.ok(rect.x >= 0 && rect.x + rect.width <= 391); assert.ok(rect.y >= 0 && rect.y + rect.height <= 845); await bankPage.screenshot({ path: path.join(QA_DIR, 'mobile-current-bank.png') }); }); } finally { await bankContext.close(); } }); await t.test('全局网页隐藏唤出与自动答题', async group => { assert.ok(/\/\/ @match\s+\*:\/\/\*\/\*/.test(script) || (/\/\/ @match\s+http:\/\/\*\/\*/.test(script) && /\/\/ @match\s+https:\/\/\*\/\*/.test(script)), '安装元数据应覆盖普通HTTP/HTTPS网页'); const genericContext = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true }); const seed = JSON.stringify(autoRecords()); const initialization = `(${installGMShim.toString()})();\nif(!localStorage.getItem(${JSON.stringify(RECORDS_KEY)}))localStorage.setItem(${JSON.stringify(RECORDS_KEY)},${JSON.stringify(seed)});\nlocalStorage.setItem(${JSON.stringify(CONFIG_KEY)},JSON.stringify({mode:'open',keepLauncher:true,copyEnabled:true,collecting:true,tab:'search'}));\n${script}`; await genericContext.addInitScript({ content: initialization }); await genericContext.route('**/*', route => { if (route.request().url().endsWith('/favicon.ico')) return route.fulfill({ status: 204, body: '' }); if (new URL(route.request().url()).pathname === '/upm/start') return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(bankResponse(BANK_FIXTURES[0])) }); return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: ordinaryFixture() }); }); const genericPage = await genericContext.newPage(); genericPage.on('pageerror', error => errors.push(error.message)); genericPage.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); try { await genericPage.goto('http://ordinary.example.test/exercise', { waitUntil: 'domcontentloaded' }); await genericPage.locator('#huayi-qb-root').waitFor({ state: 'attached' }); await genericPage.evaluate(() => { window.__qaSubmitCount = 0; window.__qaNextCount = 0; window.__qaUnrelatedCount = 0; document.querySelector('#ordinary-form').addEventListener('submit', event => { event.preventDefault(); window.__qaSubmitCount++; }); document.querySelector('#ordinary-next').addEventListener('click', () => window.__qaNextCount++); document.querySelector('#ordinary-unrelated').addEventListener('click', () => window.__qaUnrelatedCount++); }); await installOrdinaryQuestion(genericPage, 'single'); await group.test('普通域名每次默认隐藏,快捷键显示/隐藏,不拦截网页网络和采集普通表单', async () => { assert.equal(await genericPage.locator('#qb-panel').isVisible(), false); assert.equal(await genericPage.locator('#qb-launcher').isVisible(), false); assert.equal(await genericPage.evaluate(() => window.fetch === window.__qbOriginalFetch && XMLHttpRequest.prototype.open === window.__qbOriginalXHROpen && XMLHttpRequest.prototype.send === window.__qbOriginalXHRSend), true, '普通网页网络函数应保持原样'); await genericPage.evaluate(() => fetch('/upm/start?praId=judge-1').then(response => response.json())); await genericPage.waitForTimeout(300); assert.equal((await storageRecords(genericPage)).length, autoRecords().length, '普通页面不得把任意业务响应采集进题库'); await genericPage.keyboard.press('Alt+Shift+Q'); await genericPage.locator('#qb-panel').waitFor({ state: 'visible' }); assert.equal(await genericPage.locator('#qb-search-scope').inputValue(), '', '没有当前题库的普通页默认搜索全部库'); await genericPage.locator('#qb-search').fill('提高温度'); await genericPage.locator('#qb-search').press('Enter'); await waitFor(async () => (await genericPage.locator('#qb-results article').count()) === 1, '普通网页也可查已导入题库'); await genericPage.keyboard.press('Alt+Shift+Q'); assert.equal(await genericPage.locator('#qb-panel').isVisible(), false); await genericPage.keyboard.press('Alt+Shift+Q'); assert.equal(await genericPage.locator('#qb-panel').isVisible(), true); await activate(genericPage, 'auto'); assert.equal(await genericPage.locator('#qb-auto-enabled').isChecked(), false, '自动答题默认关闭'); assert.equal(await genericPage.locator('#generic-question input:checked').count(), 0); }); await group.test('单选乱序按内容选择当前答案且不提交或点击下一题', async () => { await genericPage.locator('[data-action="auto-answer-current"]').click(); await waitFor(async () => await genericPage.locator('#ordinary-heat').isChecked(), '应选择当前顺序中的提高温度'); assert.equal(await genericPage.locator('#generic-question input:checked').count(), 1); assert.equal(await genericPage.evaluate(() => window.__qaSubmitCount + window.__qaNextCount), 0); await genericPage.screenshot({ path: path.join(QA_DIR, 'generic-auto-single.png') }); }); await group.test('判断与多选完整集合正确,取消原来选错的多选项', async () => { await installOrdinaryQuestion(genericPage, 'judge'); await genericPage.locator('[data-action="auto-answer-current"]').click(); await waitFor(async () => await genericPage.locator('#ordinary-0').isChecked(), '判断题应选择错误,不能将0当真'); assert.equal(await genericPage.locator('#generic-question input:checked').count(), 1); await installOrdinaryQuestion(genericPage, 'multiple', true); assert.equal(await genericPage.locator('#ordinary-skip').isChecked(), true); await genericPage.locator('[data-action="auto-answer-current"]').click(); await waitFor(async () => await genericPage.locator('#ordinary-pressure').isChecked() && await genericPage.locator('#ordinary-temperature').isChecked() && !await genericPage.locator('#ordinary-skip').isChecked(), '多选必须选择全部正确项并取消错误项'); assert.equal(await genericPage.locator('#generic-question input:checked').count(), 2); assert.equal(await genericPage.evaluate(() => window.__qaSubmitCount + window.__qaNextCount), 0); }); await group.test('缺答案与不同来源冲突题跳过,不误点普通功能按钮', async () => { for (const key of ['missing', 'ambiguous']) { await installOrdinaryQuestion(genericPage, key); await genericPage.locator('[data-action="auto-answer-current"]').click(); await genericPage.waitForTimeout(200); assert.equal(await genericPage.locator('#generic-question input:checked').count(), 0, `${key}题目不应猜测答案`); assert.match(await genericPage.locator('#qb-auto-status').innerText(), /未|没有|跳过|冲突|歧义|不明确|无法|多个/); } assert.equal(await genericPage.evaluate(() => window.__qaUnrelatedCount), 0); }); await group.test('连续模式选择动态新题,关闭后立即停止,刷新后关闭并隐藏', async () => { await installOrdinaryQuestion(genericPage, 'continuous'); await genericPage.locator('#qb-auto-enabled').check(); await waitFor(async () => await genericPage.locator('#ordinary-check').isChecked(), '连续模式开启后选择已确认答案'); await installOrdinaryQuestion(genericPage, 'single'); await waitFor(async () => await genericPage.locator('#ordinary-heat').isChecked(), '连续模式应处理动态换题'); await genericPage.locator('#qb-auto-enabled').uncheck(); await installOrdinaryQuestion(genericPage, 'stopped'); await genericPage.waitForTimeout(600); assert.equal(await genericPage.locator('#generic-question input:checked').count(), 0, '关闭后不得继续操作新题'); await genericPage.locator('#qb-auto-enabled').check(); await waitFor(async () => await genericPage.locator('#ordinary-stop-b').isChecked(), '重新开启后可选择当前题'); await genericPage.reload({ waitUntil: 'domcontentloaded' }); await genericPage.locator('#huayi-qb-root').waitFor({ state: 'attached' }); assert.equal(await genericPage.locator('#qb-panel').isVisible(), false, '刷新不应记住打开状态'); assert.equal(await genericPage.locator('#qb-launcher').isVisible(), false); await genericPage.keyboard.press('Alt+Shift+Q'); await activate(genericPage, 'auto'); assert.equal(await genericPage.locator('#qb-auto-enabled').isChecked(), false, '连续模式不能跨页面自动开启'); await installOrdinaryQuestion(genericPage, 'single'); await genericPage.waitForTimeout(300); assert.equal(await genericPage.locator('#generic-question input:checked').count(), 0); }); await group.test('另一个HTTPS普通域名也默认隐藏,普通无题页面和窄屏不误操作', async () => { await genericPage.goto('https://another.example.test/ordinary', { waitUntil: 'domcontentloaded' }); await genericPage.locator('#huayi-qb-root').waitFor({ state: 'attached' }); assert.equal(await genericPage.locator('#qb-panel').isVisible(), false); await genericPage.keyboard.press('Alt+Shift+Q'); await activate(genericPage, 'auto'); await genericPage.evaluate(() => { document.querySelector('#ordinary-form').replaceChildren(); const option = document.createElement('input'); option.type = 'checkbox'; option.id = 'ordinary-unrelated-checkbox'; document.querySelector('main').append(option); }); await genericPage.locator('[data-action="auto-answer-current"]').click(); await genericPage.waitForTimeout(200); assert.equal(await genericPage.locator('#ordinary-unrelated-checkbox').isChecked(), false); await genericPage.setViewportSize({ width: 390, height: 844 }); const rect = await genericPage.locator('#qb-panel').boundingBox(); assert.ok(rect.x >= 0 && rect.x + rect.width <= 391 && rect.y >= 0 && rect.y + rect.height <= 845); await genericPage.screenshot({ path: path.join(QA_DIR, 'generic-auto-mobile.png') }); }); } finally { await genericContext.close(); } }); await t.test('紧凑纯白面板与历史三份重复记录修复', async group => { const dedupContext = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true }); const legacyRecords = duplicateJudgeRecords(); await dedupContext.addInitScript({ content: `(${installGMShim.toString()})();\nif(localStorage.getItem(${JSON.stringify(RECORDS_KEY)})===null){GM_setValue(${JSON.stringify(RECORDS_KEY)},${JSON.stringify(legacyRecords)});}\n${script}` }); await dedupContext.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 204, body: '' })); const dedupPage = await dedupContext.newPage(); dedupPage.on('pageerror', error => errors.push(error.message)); dedupPage.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); try { await dedupPage.goto(`${origin}/dedup-bank#/playinfo?taskId=qa-dedup-task`, { waitUntil: 'domcontentloaded' }); await dedupPage.locator('#huayi-qb-root').waitFor({ state: 'attached' }); assert.equal(await dedupPage.locator('#qb-panel').isVisible(), false, '数据迁移不能擅自显示面板'); await activate(dedupPage, 'library'); await group.test('696条历史记录归一为232题,保留三份ID与原始备份', async () => { await waitFor(async () => (await storageRecords(dedupPage)).length === 232, '696条三倍数据应迁移为232道唯一题目'); const records = await storageRecords(dedupPage); assert.equal((await dedupPage.locator('#qb-stat-total').innerText()).replace(/,/g, ''), '232'); assert.equal((await dedupPage.locator('#qb-stat-confirmed').innerText()).replace(/,/g, ''), '232'); assert.match(await dedupPage.locator('#qb-page-total').innerText(), /232/, '页面总数应与平台进度一致'); assert.ok(records.every(record => record.questionIdAliases?.length === 3), '每道题应保留三个原始题目ID'); assert.equal(new Set(records.map(record => record.stem)).size, 229, '原库同批三对同内容题应保留为232个原始位置,不能压成229'); const backup = await dedupPage.evaluate(key => window.GM_getValue(key, null), DEDUP_BACKUP_KEY); const original = Array.isArray(backup) ? backup : backup?.records; assert.equal(original?.length, 696, '首次修复前应保留原696条完整记录'); assert.equal(new Set(original.map(record => record.questionId)).size, 696); }); await group.test('CSV与JSON按唯一题导出,别名保留,旧三份响应不会再次累积', async () => { const exported = await downloadedCSV(dedupPage, 'dedup-232.csv'); const parsed = require('../src/core.js').fromCSV(exported.text); assert.equal(parsed.length, 232, 'CSV应只包含232道唯一题,而不是696条记录'); assert.ok(parsed.every(record => record.questionIdAliases?.length === 3), 'CSV完整记录JSON应保留全部别名'); assert.match(exported.name, /232题/); const jsonWaiting = dedupPage.waitForEvent('download'); await dedupPage.locator('[data-action="export-json"]').click(); const jsonDownload = await jsonWaiting; const jsonPath = path.join(QA_DIR, 'dedup-232.json'); await jsonDownload.saveAs(jsonPath); const json = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); assert.equal(json.records.length, 232); assert.ok(json.records.every(record => record.questionIdAliases?.length === 3)); await dedupPage.evaluate(async bankId => { for (const copy of [0, 1, 2]) await fetch(`/upm/start?praId=${bankId}&item=legacy-duplicates©=${copy}`).then(response => response.json()); }, DEDUP_BANK.id); await dedupPage.waitForTimeout(550); assert.equal((await storageRecords(dedupPage)).length, 232, '旧的不同ID响应不能让重复题再次长出来'); assert.equal((await dedupPage.locator('#qb-stat-total').innerText()).trim(), '232'); assert.match(await dedupPage.locator('#qb-page-total').innerText(), /232/, '696条响应不能覆盖DOM中平台总数232'); await dedupPage.reload({ waitUntil: 'domcontentloaded' }); await dedupPage.locator('#huayi-qb-root').waitFor({ state: 'attached' }); assert.equal(await dedupPage.locator('#qb-panel').isVisible(), false); await activate(dedupPage, 'library'); assert.equal((await storageRecords(dedupPage)).length, 232, '刷新后仍应保持唯一题目数'); await dedupPage.screenshot({ path: path.join(QA_DIR, 'white-compact-232.png') }); }); await group.test('面板至多300×440,标题按钮与面板均为白色,窄屏无溢出', async () => { await dedupPage.mouse.move(0, 0); const desktop = await dedupPage.locator('#qb-panel').boundingBox(); assert.ok(desktop.width <= 301 && desktop.height <= 441, `紧凑面板应至多300×440,实际${desktop.width}×${desktop.height}`); const colors = await dedupPage.locator('#qb-panel').evaluate(panel => ({ panel: getComputedStyle(panel).backgroundColor, header: getComputedStyle(panel.querySelector('.header')).backgroundColor, button: getComputedStyle(panel.querySelector('.btn.primary')).backgroundColor })); assert.equal(colors.panel, 'rgb(255, 255, 255)'); assert.equal(colors.header, 'rgb(255, 255, 255)'); assert.equal(colors.button, 'rgb(255, 255, 255)'); await dedupPage.setViewportSize({ width: 390, height: 844 }); const mobile = await dedupPage.locator('#qb-panel').boundingBox(); assert.ok(mobile.width <= 301 && mobile.height <= 441 && mobile.x >= 0 && mobile.x + mobile.width <= 391); assert.ok(mobile.y >= 0 && mobile.y + mobile.height <= 845); await activate(dedupPage, 'auto'); assert.equal(await dedupPage.locator('#qb-auto-enabled').isChecked(), false, '迁移和样式更新不能开启自动答题'); await activate(dedupPage, 'library'); await dedupPage.screenshot({ path: path.join(QA_DIR, 'white-compact-mobile.png') }); await dedupPage.setViewportSize({ width: 1440, height: 1000 }); }); await group.test('冲突重复保留为冲突,不同题库和选项内容不会误合并', async () => { const base = { bankId: DEDUP_BANK.id, bankName: DEDUP_BANK.name, type: 'single', stem: '内容去重边界:应选择哪种操作?', options: [{ id: 'a', text: '甲', label: 'A' }, { id: 'b', text: '乙', label: 'B' }], answerTexts: ['甲'], correctOptionIds: ['a'], answerStatus: 'confirmed' }; await importFile(dedupPage, 'dedup-boundaries.json', JSON.stringify({ schemaVersion: 1, records: [ { ...base, questionId: 'conflict-a', source: { kind: 'network', url: 'https://api-ulcd.yunxuetang.cn/v2/upm/start', observedAt: '2026-10-29T02:00:00.000Z' } }, { ...base, questionId: 'conflict-b', answerTexts: ['乙'], correctOptionIds: ['b'], source: { kind: 'network', url: 'https://api-ulcd.yunxuetang.cn/v2/upm/start', observedAt: '2026-10-29T02:01:00.000Z' } }, { ...base, bankId: 'dedup-other-bank', bankName: '另一题库', questionId: 'different-bank' }, { ...base, questionId: 'different-options', options: [{ id: 'a', text: '甲', label: 'A' }, { id: 'c', text: '丙', label: 'B' }] } ] })); const records = await storageRecords(dedupPage); const boundary = records.filter(record => record.stem === base.stem); assert.equal(boundary.length, 3, '相同库同内容归一,不同库和不同选项保持独立'); const conflicted = boundary.find(record => record.bankId === DEDUP_BANK.id && record.options.some(option => option.text === '乙')); assert.equal(conflicted.answerStatus, 'conflict'); assert.ok(conflicted.conflicts?.length >= 2, '两个标准答案应保留供核验'); assert.ok(boundary.some(record => record.bankId === 'dedup-other-bank' && record.answerStatus === 'confirmed')); assert.ok(boundary.some(record => record.options.some(option => option.text === '丙'))); }); } finally { await dedupContext.close(); } }); await t.test('控制台无脚本错误', async () => { assert.deepEqual(errors, []); }); t.diagnostic(`Browser plugin not available; cached Chromium via Playwright. Local origin: ${origin}; viewports 1440x1000 and 390x844; evidence: ${QA_DIR}`); } finally { await context.close(); await browser.close(); await new Promise(resolve => server.close(resolve)); } });