'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 QA_DIR = path.join(os.tmpdir(), 'huayi-qb-qa-v110'); 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')); } } 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 }) }] } }; } 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 = () => {}; } 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) { 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) }); } 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('') + '
'; }); } 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.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('/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('#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(), true); await page.reload({ waitUntil: 'domcontentloaded' }); await page.locator('#qb-launcher').waitFor({ state: 'visible' }); 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-launcher.png') }); await page.locator('[data-action="restore"]').click(); 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'); await page.locator('#qb-keep-launcher').uncheck(); 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.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(); const menuFound = await page.evaluate(() => { const menu = window.__qbMenus.find(item => /显示|唤出|恢复|打开/.test(item.label)); if (!menu) return false; menu.callback(); return true; }); assert.equal(menuFound, true, '应提供油猴菜单恢复入口'); assert.equal(await page.locator('#qb-panel').isVisible(), true); await activate(page, 'settings'); await page.locator('#qb-keep-launcher').check(); }); await t.test('JSON导入与当前页面选项乱序:正确内容映射为C', async () => { const initial = (await storageRecords(page))[0]; 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('#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('文字搜索可手输题干、选项与标准答案,支持当前库和全部库', 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 () => { 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)); } });