'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 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')); }
}
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 · 本地验收
${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 }) }] } };
}
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.
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',
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 = '';
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 = '以下哪项操作可以提高温度?
';
});
}
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 })) } }));
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));
}
});