feat: collect judgment standards through opt-in normal practice

This commit is contained in:
suanyuit-cyber
2026-10-30 04:32:48 +08:00
parent 6ba4905a7d
commit fcc8390faa
9 changed files with 1153 additions and 24 deletions
+224
View File
@@ -25,6 +25,230 @@ function loadPlaywright() {
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' };