Files

1463 lines
115 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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: 30000 }, async () => {
const question = { bankId: 'collector-bank', bankName: '判断题库1', questionId: 'collector-current', type: 'judge',
stem: '判断题补齐界面验收。', questionIndex: 67, contentOccurrence: 0, answerStatus: 'missing',
options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }], answerTexts: [], correctOptionIds: [] };
const bank = { bankId: question.bankId, bankName: question.bankName, taskId: 'collector-task', questionCount: 500, loading: false };
// The collector engine has its own DOM/action tests. This fixture isolates
// application wiring and records every control action without submitting.
const stubs = `window.__qaControls={events:[],starts:0};window.__qaCurrent=${JSON.stringify(question)};window.__qaBank=${JSON.stringify(bank)};
const QBAdapter={create(options){window.__qaReceive=options.onQuestions;window.__qaBankChange=options.onBankChange;return {
start(){},stop(){},setCopyEnabled(){},getCurrentQuestion(){return window.__qaCurrent},getCurrentBank(){return window.__qaBank}}}};
const QBAuto={create(options){let state={enabled:false,busy:false,answeredCount:0,skippedCount:0,message:''};return {
getState(){return {...state}},setEnabled(value){state.enabled=Boolean(value);window.__qaControls.events.push('auto:'+state.enabled);options.onChange?.()},
answerCurrent(){window.__qaControls.events.push('auto:answer');return Promise.resolve()}}}};
const QBCollector={create(options){let state={active:false,phase:'idle',message:'',answered:0,skipped:0,current:0,total:500};
const emit=patch=>{state={...state,...patch};options.onChange?.({...state})};
const api={getState(){return {...state}},start(){window.__qaControls.starts++;window.__qaControls.events.push('collector:start');emit({active:true,phase:'checking',message:'正在补齐当前判断题库',current:68});if(window.__qaFailCollectorStart)throw new Error('fixture collector failure');return {...state}},
stop(reason){window.__qaControls.events.push('collector:stop');emit({active:false,phase:'stopped',message:reason});return {...state}},destroy(){api.stop('destroy')}};
window.__qaCollector=api;window.__qaCollectorOptions=options;window.__qaCollectorEmit=emit;
options.onChange?.({...state});return api}};`;
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());
const context = await browser.newContext();
const errors = [];
try {
await context.route('**/*', route => route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><html><head><meta charset="utf-8"></head><body><div class="yxtulcdsdk-pc-marking-main"><div class="yxtulcdsdk-form" practice><p>本地判断题练习夹具</p></div></div></body></html>' }));
await context.addInitScript({ content: `(${installGMShim.toString()})();${appSource}` });
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
await page.goto('https://www.shhuayi-elearning.cn/o2o/#/playinfo?taskId=collector-task');
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
assert.equal(await page.locator('#qb-panel').isVisible(), false);
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), false);
assert.equal(await page.evaluate(() => window.__qaControls.starts), 0, '加载和首次onChange不能启动练习');
await activate(page, 'auto');
assert.equal(await page.locator('[data-action="start-collector"]').isEnabled(), true);
assert.equal(await page.locator('[data-action="stop-collector"]').isEnabled(), false);
assert.match(await page.locator('#qb-view-auto').innerText(), /改变练习记录/);
assert.match(await page.locator('#qb-view-auto').innerText(), /隐藏面板后仍会继续/);
assert.equal(await page.locator('#qb-auto-enabled').isChecked(), false);
await page.locator('#qb-auto-enabled').check();
await page.locator('[data-action="start-collector"]').click();
assert.equal(await page.locator('#qb-auto-enabled').isChecked(), false);
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), true);
assert.deepEqual(await page.evaluate(() => window.__qaControls.events.slice(-2)), ['auto:false', 'collector:start']);
assert.equal(await page.locator('[data-action="start-collector"]').isEnabled(), false);
assert.equal(await page.locator('[data-action="stop-collector"]').isEnabled(), true);
const oldCount = await page.locator('#qb-collector-count').textContent();
await page.keyboard.press('Alt+Shift+Q');
await page.evaluate(() => window.__qaCollectorEmit({ answered: 7, current: 75 }));
assert.equal(await page.locator('#qb-collector-count').textContent(), oldCount, '隐藏面板时collector变化不重建计数');
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), true, '隐藏与停止独立');
await activate(page, 'auto');
assert.match(await page.locator('#qb-collector-count').innerText(), /已作答 7 题/);
await activate(page, 'search');
await page.evaluate(() => window.__qaCollectorEmit({ answered: 8 }));
assert.match(await page.locator('#qb-collector-count').textContent(), /已作答 7 题/, '其他页签不重建补齐计数');
await activate(page, 'auto');
assert.match(await page.locator('#qb-collector-count').innerText(), /已作答 8 题/);
await page.locator('#qb-auto-enabled').check();
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), false);
assert.deepEqual(await page.evaluate(() => window.__qaControls.events.slice(-2)), ['collector:stop', 'auto:true']);
await page.locator('[data-action="start-collector"]').click();
await page.locator('[data-action="auto-answer-current"]').click();
assert.deepEqual(await page.evaluate(() => window.__qaControls.events.slice(-2)), ['collector:stop', 'auto:answer']);
await page.locator('[data-action="start-collector"]').click();
await page.locator('[data-action="stop-collector"]').click();
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), false);
assert.match(await page.locator('#qb-collector-status').innerText(), /手动停止/);
await page.evaluate(() => { window.__qaFailCollectorStart = true; });
await page.locator('[data-action="start-collector"]').click();
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), false, '模块异常不能继续作答');
assert.match(await page.locator('#qb-status').innerText(), /练习补齐失败/);
await page.evaluate(() => { window.__qaFailCollectorStart = false; });
await page.locator('[data-action="start-collector"]').click();
await activate(page, 'library');
await page.locator('[data-action="toggle-capture"]').click();
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), false);
assert.equal(await page.evaluate(() => window.__qaCollectorOptions.isCollecting()), false);
await activate(page, 'auto');
assert.equal(await page.locator('[data-action="start-collector"]').isEnabled(), false);
await activate(page, 'library');
await page.locator('[data-action="toggle-capture"]').click();
await activate(page, 'auto');
await page.locator('[data-action="start-collector"]').click();
await page.evaluate(() => { window.__qaBankChange(window.__qaBank); window.__qaBank = { ...window.__qaBank, bankId: 'another-bank', loading: true }; window.__qaBankChange(window.__qaBank); });
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), false, '切库立即停止');
await page.evaluate(value => { window.__qaBank = value; window.__qaBankChange(value); }, bank);
await page.locator('[data-action="start-collector"]').click();
await page.evaluate(key => {
const write = window.GM_setValue;
window.GM_setValue = (name, value) => { if (name === key) throw new Error('fixture storage denied'); return write(name, value); };
const source = { kind: 'network', url: 'https://api.example.test/upm/collector-bank/users/_/answer', observedAt: '2026-10-30T00:00:00Z' };
window.__qaReceive([{ ...window.__qaCurrent, answerStatus: 'confirmed', answerTexts: ['正确'], correctOptionIds: ['1'],
answerEvidence: 'answer-response', source, answerSource: source }]);
}, RECORDS_KEY);
await waitFor(() => page.evaluate(() => !window.__qaCollector.getState().active), '题库持久化失败应立即停止后续练习');
assert.equal(await page.evaluate(() => window.__qaCollectorOptions.isCollecting()), false);
assert.equal(await page.locator('[data-action="start-collector"]').isEnabled(), false);
assert.match(await page.locator('#qb-status').innerText(), /保存失败/);
await page.reload();
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
assert.equal(await page.locator('#qb-panel').isVisible(), false);
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), false);
assert.equal(await page.evaluate(() => window.__qaControls.starts), 0, '刷新不会恢复练习补齐');
const ordinary = await context.newPage();
await ordinary.goto('https://ordinary.example.test/collector-fixture');
await ordinary.locator('#huayi-qb-root').waitFor({ state: 'attached' });
await activate(ordinary, 'auto');
assert.equal(await ordinary.locator('[data-action="start-collector"]').isEnabled(), false, '普通网页不能启动判断题补齐');
await ordinary.locator('[data-action="start-collector"]').evaluate(button => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
assert.equal(await ordinary.evaluate(() => window.__qaControls.starts), 0, '事件边界也核验支持的网站');
assert.deepEqual(errors, []);
} finally {
await context.close();
await browser.close();
}
});
test('实际判断题补齐引擎:正常单选点击、标准反馈及可信入库后换题、停止不交卷', { timeout: 20000 }, async () => {
const core = require('../src/core.js');
const bank = { bankId: 'real-collector-bank', bankName: '本地判断题库2', taskId: 'real-collector-task', questionCount: 232, loading: false };
const source = { kind: 'network', url: 'https://api.example.test/ote/upm/start', observedAt: '2026-10-30T00:00:00Z', batchId: 'complete-fixture-list' };
const seed = Array.from({ length: 232 }, (_, index) => core.normalizeRecord({ bankId: bank.bankId, bankName: bank.bankName,
questionId: `real-collector-${index}`, questionIndex: index, contentOccurrence: 0, type: 'judge', stem: `本地正常判断练习题${index + 1}。`,
options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }],
answerStatus: index < 2 ? 'missing' : 'confirmed', correctOptionIds: index < 2 ? [] : ['0'], answerTexts: index < 2 ? [] : ['错误'],
...(index < 2 ? {} : { answerEvidence: 'submitted-list' }), source }));
const adapter = `const QBAdapter={create(options){window.__smokeReceive=options.onQuestions;return {start(){},stop(){},setCopyEnabled(){},
getCurrentQuestion(){const record=window.__smokeSeed[window.__smokePosition];return QBCore.normalizeRecord({...record,answerStatus:'missing',correctOptionIds:[],answerTexts:[],answerEvidence:undefined})},
getCurrentBank(){return ${JSON.stringify(bank)}}}}};`;
const exposeCollector = `const originalCollectorCreate=QBCollector.create;QBCollector.create=options=>{const api=originalCollectorCreate(options);window.__smokeCollector=api;window.__smokeRecords=options.getRecords;return api};`;
const appSource = `(function(){${fs.readFileSync(path.join(ROOT, 'src/core.js'), 'utf8')}\n${adapter}\n${fs.readFileSync(path.join(ROOT, 'src/autofill.js'), 'utf8')}\n${fs.readFileSync(path.join(ROOT, 'src/collector.js'), 'utf8')}\n${exposeCollector}\n${fs.readFileSync(path.join(ROOT, 'src/app.js'), 'utf8')}\n})();`;
const markup = '<!doctype html><html><head><meta charset="utf-8"><style>body{font:16px sans-serif;margin:20px}.yxtulcdsdk-ques-radio{display:block;padding:12px}button{padding:8px;margin:6px}</style></head><body>' +
'<div class="task-content-active">本地判断题库2 练习 | 必修</div><div class="yxtulcdsdk-answer-card__top">进度 230/232</div>' +
'<div class="yxtulcdsdk-pc-marking-main"><div class="standard-size-14"><span>判断题</span></div><div class="yxtulcdsdk-form" practice></div>' +
'<div id="smoke-feedback"></div><div class="yxtulcdsdk-examing__btns"><button type="button" id="smoke-next">下一题</button>' +
'<button type="button" id="smoke-end">结束</button><button type="button" id="smoke-submit">交卷</button></div></div>' +
'<ul class="yxtulcdsdk-pc-card-list"><li>1</li><li>2</li></ul><button type="button" id="smoke-show-standard">显示网站标准反馈</button>' +
'<button type="button" id="smoke-save-receipt">正常作答回执入库</button></body></html>';
const { chromium } = loadPlaywright(), browser = await chromium.launch(browserOptions());
const context = await browser.newContext();
const errors = [];
try {
await context.route('**/*', route => route.fulfill({ status: 200, contentType: 'text/html', body: markup }));
await context.addInitScript({ content: `(${installGMShim.toString()})();window.__smokeSeed=${JSON.stringify(seed)};window.__smokePosition=0;
if(localStorage.getItem(${JSON.stringify(RECORDS_KEY)})===null)GM_setValue(${JSON.stringify(RECORDS_KEY)},window.__smokeSeed);
${appSource}` });
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
await page.goto('https://www.shhuayi-elearning.cn/o2o/#/playinfo?taskId=real-collector-task');
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
await page.evaluate(() => {
window.__smokeActions = { radio: [], next: [], end: 0, submit: 0 };
const render = () => {
const record = window.__smokeSeed[window.__smokePosition];
const form = document.querySelector('.yxtulcdsdk-form');
form.innerHTML = `<div class="yxtulcdsdk-form__sub">${window.__smokePosition + 1}.</div><div class="yxtulcdsdk-form__content"><p>${record.stem}</p>` +
record.options.map(option => `<label class="yxtulcdsdk-ques-radio"><input type="radio" name="fixture-answer" value="${option.id}"><span class="yxtf-radio__label">${option.text}</span></label>`).join('') + '</div>';
document.querySelector('#smoke-feedback').replaceChildren();
};
document.querySelector('.yxtulcdsdk-form').addEventListener('change', event => {
if (event.target.matches('input[type="radio"]')) window.__smokeActions.radio.push({ index: window.__smokePosition, value: event.target.value });
});
document.querySelector('#smoke-next').addEventListener('click', () => {
window.__smokeActions.next.push(window.__smokePosition); window.__smokePosition++; render();
});
document.querySelector('#smoke-end').addEventListener('click', () => { window.__smokeActions.end++; });
document.querySelector('#smoke-submit').addEventListener('click', () => { window.__smokeActions.submit++; });
document.querySelector('#smoke-show-standard').addEventListener('click', () => {
const record = window.__smokeSeed[window.__smokePosition];
document.querySelector('#smoke-feedback').innerHTML = `<div class="yxtulcdsdk-examing__result-line" data-question-id="${record.questionId}"><span class="yxtulcdsdk-examing__result-line-left">正确答案</span><span class="yxtulcdsdk-examing__result-line-right">错误</span></div>`;
});
document.querySelector('#smoke-save-receipt').addEventListener('click', () => {
const record = window.__smokeSeed[window.__smokePosition];
const source = { kind: 'network', url: 'https://api.example.test/ote/upm/real-collector-bank/users/_/answer', observedAt: '2026-10-30T00:00:01Z' };
window.__smokeReceive([{ ...record, answerStatus: 'confirmed', correctOptionIds: ['0'], answerTexts: ['错误'],
answerEvidence: 'answer-response', source, answerSource: source }]);
});
render();
});
assert.equal(await page.evaluate(() => window.__smokeCollector.getState().active), false);
await activate(page, 'auto');
await page.locator('[data-action="start-collector"]').click();
await waitFor(() => page.evaluate(() => window.__smokeActions.radio.length === 1), '真实引擎应通过label点击一次正常radio');
assert.equal(await page.locator('.yxtulcdsdk-form input[value="1"]').isChecked(), true);
await page.waitForTimeout(700);
assert.deepEqual(await page.evaluate(() => window.__smokeActions), { radio: [{ index: 0, value: '1' }], next: [], end: 0, submit: 0 });
await page.locator('#smoke-show-standard').click();
await page.waitForTimeout(700);
assert.equal(await page.evaluate(() => window.__smokeActions.next.length), 0, '只有网站DOM反馈、未可信入库不能换题');
assert.equal(await page.evaluate(() => window.__smokeActions.radio.length), 1, '等待期间不会重复选择');
assert.equal(await page.evaluate(() => window.__smokeRecords().find(record => record.questionIndex === 0).answerStatus), 'missing');
// Stop through the actual panel button in the second-question transition,
// before the next 300ms tick, so the cancellation boundary is deterministic.
await page.evaluate(() => document.querySelector('#smoke-next').addEventListener('click', () => {
document.querySelector('#huayi-qb-root').shadowRoot.querySelector('[data-action="stop-collector"]').click();
}, { once: true }));
await page.locator('#smoke-save-receipt').click();
await waitFor(() => page.evaluate(() => window.__smokePosition === 1 && !window.__smokeCollector.getState().active), '一致的标准回执入库后才可换到第2题,并可立即停止');
const stored = await page.evaluate(() => window.__smokeRecords().find(record => record.questionIndex === 0));
assert.equal(stored.answerStatus, 'confirmed');
assert.equal(stored.answerEvidence, 'answer-response');
assert.deepEqual(stored.answerTexts, ['错误'], '保存独立网站标准,不能保存引擎刚选择的正确选项');
assert.equal(await page.evaluate(() => window.__smokeRecords().find(record => record.questionIndex === 1).answerStatus), 'missing');
await page.waitForTimeout(1100);
assert.deepEqual(await page.evaluate(() => window.__smokeActions), { radio: [{ index: 0, value: '1' }], next: [0], end: 0, submit: 0 });
assert.equal(await page.locator('.yxtulcdsdk-form__sub').innerText(), '2.');
assert.equal(await page.locator('.yxtulcdsdk-form input:checked').count(), 0);
assert.match(await page.locator('#qb-collector-status').innerText(), /手动停止/);
assert.equal((await storageRecords(page)).length, 232);
assert.deepEqual(errors, []);
} finally {
await context.close();
await browser.close();
}
});
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: '<!doctype html><html><head><meta charset="utf-8"></head><body>判断题本地验收</body></html>' }));
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: '<!doctype html><html><head><meta charset="utf-8"></head><body><p>本地持久化验收</p></body></html>' }));
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: '传热速率=系数&times;推动力,误差&plusmn;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 `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>华谊集团化工总控工(高级)-判断题库1 · 本地验收</title>
<style>body{margin:0;background:#eef2f5;color:#27364a;font:16px/1.7 system-ui,sans-serif}
.fixture-header{padding:22px 30px;background:white;border-bottom:1px solid #d9e1e8}
.fixture-content{margin:32px;max-width:760px;background:white;padding:28px;border-radius:14px}
.fixture-label{font-size:12px;letter-spacing:1px;color:#687888}
.yxtulcdsdk-pc-marking-main.text-no-select{-webkit-user-select:none;user-select:none}
.yxtulcdsdk-form__sub{color:#8995a6;font-size:13px}.yxtulcdsdk-form__content>div[data-rich-text]{font-size:21px;margin:12px 0 25px}
.yxtulcdsdk-ques-radio-group label{display:flex;align-items:center;gap:12px;border:1px solid #dce4eb;padding:12px 16px;margin:12px 0;border-radius:8px}
.yxtf-radio__label{margin-left:8px}.yxtulcdsdk-examing__bottom{margin-top:28px}button{cursor:pointer}
@media(max-width:500px){.fixture-content{margin:14px;padding:18px}.fixture-header{padding:15px}}</style>
</head><body><header class="fixture-header"><div class="fixture-label">本地练习页 · 未连接在线服务</div>
<div class="yxtulcdsdk-exam-header"><span class="yxtf-tooltip">华谊集团化工总控工(高级)-判断题库1</span></div></header>
<main class="fixture-content">${questionMarkup()}</main></body></html>`;
}
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 = `<aside id="qa-sidebar" aria-label="培训大纲">${BANK_FIXTURES.map((bank, index) => `<button type="button" data-qa-bank="${bank.id}" data-task-id="${bank.taskId}" class="task-content${index ? '' : ' task-content-active'}"><span class="group-title${index ? '' : ' task-content-color-active'}">${bank.name}</span><small>练习 | 必修</small></button>`).join('')}</aside>`;
return fixture().replace('干燥过程传质推动力:物料表面水分压P水&gt;热空气中的水分压P水', first.stem)
.replace('</head>', '<style>#qa-sidebar{position:fixed;left:20px;top:110px;bottom:20px;width:260px;display:flex;flex-direction:column;gap:12px;overflow:auto}#qa-sidebar button{border:1px solid #dce3eb;border-radius:8px;background:white;color:#506275;padding:14px;width:100%;text-align:left}#qa-sidebar small{display:block;color:#87929c;margin-top:7px}#qa-sidebar .task-content-active{background:#e4f5f0;border-color:#5fb9a3;color:#08735c}.fixture-content{margin-left:310px;max-width:650px}@media(max-width:500px){#qa-sidebar{display:none}.fixture-content{margin-left:14px}}</style></head>')
.replace('<main class="fixture-content">', `${sidebar}<main class="fixture-content"><div class="yxtulcdsdk-exam-container" task-id="${first.taskId}">`).replace('</main>', '</div></main>');
}
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水&gt;热空气中的水分压P水', duplicateJudgeRecords()[0].stem)
.replace('</main>', '<div class="yxtulcdsdk-answer-card__top">进度27/232</div></main>');
}
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 = '<div data-rich-text="1"></div><div class="mt16"><div class="yxtulcdsdk-ques-radio-group">' + choices.map(([id, text], index) => `<label class="${isMultiple ? 'yxtulcdsdk-ques-checkbox yxtf-checkbox' : 'yxtulcdsdk-ques-radio yxtf-radio'}"><input type="${isMultiple ? 'checkbox' : 'radio'}" value="${id}"><span class="${isMultiple ? 'yxtf-checkbox__label' : 'yxtf-radio__label'}">${bank.type === 'judge' ? '' : String.fromCharCode(65 + index) + '. '}${text}</span></label>`).join('') + '</div></div>';
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 = '<div data-rich-text="1">以下哪项操作可以提高温度?</div><div class="mt16"><div role="radiogroup" class="yxtulcdsdk-ques-radio-group yxtf-radio-group">' +
[['z', '保持不变'], ['x', '降低温度'], ['y', '提高温度']].map(([id, text], i) => '<label class="yxtf-radio yxtulcdsdk-ques-radio"><span class="yxtf-radio__input"><input type="radio" class="yxtf-radio__original" value="' + id + '"></span><span class="yxtf-radio__label">' + String.fromCharCode(65 + i) + '. ' + text + '</span></label>').join('') + '</div></div>';
});
}
function ordinaryFixture() {
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>普通网页自动答题验收</title>
<style>body{margin:30px;background:#f1f5f8;color:#253b50;font:16px/1.7 system-ui,sans-serif}main{max-width:700px;background:white;border-radius:12px;padding:25px}fieldset{border:1px solid #ccd9e4;border-radius:10px;padding:18px}legend{font-weight:600}label{display:block;padding:10px;border-bottom:1px solid #edf0f3}button{margin:12px 8px 0 0;padding:8px 14px}@media(max-width:500px){body{margin:12px}main{padding:14px}}</style>
</head><body><main><h1>普通网页练习</h1><p>使用本地题库,仅选择已确认的选项。</p><form id="ordinary-form"><div id="ordinary-question"></div><button type="submit" id="ordinary-submit">提交答案</button><button type="button" id="ordinary-next">下一题</button></form><button type="button" id="ordinary-unrelated">普通功能按钮</button></main></body></html>`;
}
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 = '<span class="group-title task-content-color-active">尚未载入的空题库</span>';
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=${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));
}
});