feat: collect judgment standards through opt-in normal practice
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const core = require('../src/core.js');
|
||||
const { create } = require('../src/collector.js');
|
||||
|
||||
function fixture({ total = 500, current = 0, missing = [], reply = true, replyDelay = 900 } = {}) {
|
||||
let clock = 0, sequence = 0;
|
||||
const timers = new Map(), events = new EventTarget(), actions = [], changes = [];
|
||||
const base = index => ({ bankId: 'bank-1', bankName: '判断练习题库1', questionId: `q-${index}`, questionIndex: index,
|
||||
type: 'judge', stem: `第${index + 1}项设备检查结论。`, options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }],
|
||||
answerStatus: 'confirmed', correctOptionIds: [index % 2 ? '1' : '0'], answerTexts: [index % 2 ? '正确' : '错误'],
|
||||
answerEvidence: 'submitted-list', source: { kind: 'network', url: 'https://api.yunxuetang.cn/ote/upm/start', observedAt: '2026-10-30T00:00:00Z' } });
|
||||
const state = { current, total, collecting: true, practice: true, heading: '判断题', selected: '判断题库1 练习 | 必修',
|
||||
bank: { bankId: 'bank-1', bankName: '判断练习题库1', taskId: 'task-1', questionCount: total, loading: false },
|
||||
records: Array.from({ length: total }, (_, index) => core.normalizeRecord(missing.includes(index)
|
||||
? { ...base(index), answerStatus: 'missing', answerTexts: [], correctOptionIds: [], answerEvidence: undefined } : base(index))),
|
||||
checked: false, feedback: '', feedbackId: '', questionOverride: null, numberOverride: null, disabled: false, reply, replyDelay };
|
||||
const win = {
|
||||
location: { href: 'https://www.shhuayi-elearning.cn/o2o/#/playinfo?projectid=project-1&taskId=task-1&gwnlCourse=0' },
|
||||
Date: { now: () => clock }, getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
setTimeout(fn, delay) { const id = ++sequence; timers.set(id, { fn, at: clock + delay }); return id; },
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events),
|
||||
fetch() { assert.fail('collector不得调用fetch'); }, XMLHttpRequest: class { constructor() { assert.fail('collector不得调用XHR'); } }
|
||||
};
|
||||
const question = () => ({ ...base(state.current), answerStatus: 'missing', answerTexts: [], correctOptionIds: [], answerEvidence: undefined, ...state.questionOverride });
|
||||
function element(overrides = {}) {
|
||||
return { nodeType: 1, isConnected: true, hidden: false, parentElement: null, disabled: false, textContent: '',
|
||||
getAttribute: () => null, closest: () => null, ...overrides };
|
||||
}
|
||||
function setPosition(index) { state.current = index; state.checked = false; state.feedback = ''; state.feedbackId = ''; state.questionOverride = null; }
|
||||
const labels = [0, 1].map(position => {
|
||||
const input = element({ get value() { return question().options[position].id; }, get checked() { return state.checked && position === 0; },
|
||||
get disabled() { return state.disabled; } });
|
||||
return element({ get textContent() { return question().options[position].text; }, querySelector(selector) {
|
||||
if (selector === 'input[type="radio"]') return input;
|
||||
if (selector === '.yxtf-radio__label') return { textContent: question().options[position].text };
|
||||
return null;
|
||||
}, click() {
|
||||
actions.push({ kind: 'radio', index: state.current, text: this.textContent }); state.checked = true;
|
||||
if (!state.reply) return;
|
||||
const submittedIndex = state.current;
|
||||
win.setTimeout(() => {
|
||||
if (state.current !== submittedIndex) return;
|
||||
const record = core.normalizeRecord({ ...base(submittedIndex), answerEvidence: 'answer-response',
|
||||
answerSource: { kind: 'network', url: 'https://api.yunxuetang.cn/ote/upm/bank-1/users/_/answer', observedAt: '2026-10-30T00:00:01Z' } });
|
||||
state.records = state.records.map(value => value.questionIndex === submittedIndex ? record : value);
|
||||
state.feedback = record.answerTexts[0]; state.feedbackId = record.questionId;
|
||||
}, state.replyDelay);
|
||||
} });
|
||||
});
|
||||
const next = element({ get textContent() { return state.current === total - 1 ? '提交' : '下一题'; }, click() {
|
||||
if (state.current === total - 1) assert.fail('collector不得点击末题提交');
|
||||
actions.push({ kind: 'next', index: state.current }); setPosition(state.current + 1);
|
||||
} });
|
||||
const main = element({ querySelector(selector) { return selector === '.standard-size-14 span' ? { textContent: state.heading } : null; },
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '.yxtulcdsdk-examing__btns button') return [element({ textContent: '上一题', click: () => assert.fail('不应使用上一题') }), next];
|
||||
if (selector === '.yxtulcdsdk-examing__result-line' && state.feedback) return [element({
|
||||
getAttribute: name => name === 'data-question-id' ? state.feedbackId || null : null,
|
||||
querySelector: name => ({ textContent: name === '.yxtulcdsdk-examing__result-line-left' ? '正确答案' : state.feedback })
|
||||
})];
|
||||
return [];
|
||||
} });
|
||||
const form = element({ closest: selector => selector === '.yxtulcdsdk-pc-marking-main' ? main : null,
|
||||
querySelector: selector => selector === '.yxtulcdsdk-form__sub' ? { textContent: `${state.numberOverride ?? state.current + 1}.` } : null,
|
||||
querySelectorAll: selector => selector === '.yxtulcdsdk-ques-radio' ? labels : [] });
|
||||
const cards = Array.from({ length: total }, (_, index) => element({ textContent: String(index + 1), click() { actions.push({ kind: 'card', index }); setPosition(index); } }));
|
||||
const doc = { querySelector(selector) { return selector === '.task-content-active' ? { textContent: state.selected } : null; },
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '.yxtulcdsdk-pc-marking-main .yxtulcdsdk-form[practice]') return state.practice ? [form] : [];
|
||||
if (selector === '.yxtulcdsdk-answer-card__top') return [element({ textContent: `进度 0/${total}` })];
|
||||
if (selector === '.yxtulcdsdk-pc-card-list li') return cards;
|
||||
return [];
|
||||
} };
|
||||
const collector = create({ window: win, document: doc, core, getRecords: () => state.records,
|
||||
getQuestion: question, getBank: () => state.bank, isCollecting: () => state.collecting, onChange: value => changes.push(value) });
|
||||
function advance(milliseconds, maxSteps = 15000) {
|
||||
const end = clock + milliseconds; let steps = 0;
|
||||
while (true) {
|
||||
let chosen;
|
||||
for (const [id, timer] of timers) if (timer.at <= end && (!chosen || timer.at < chosen[1].at)) chosen = [id, timer];
|
||||
if (!chosen) break;
|
||||
if (++steps > maxSteps) assert.fail('polling未能停止');
|
||||
timers.delete(chosen[0]); clock = chosen[1].at; chosen[1].fn();
|
||||
}
|
||||
clock = end;
|
||||
}
|
||||
return { collector, state, actions, changes, timers, win, doc, events, advance, setPosition, base };
|
||||
}
|
||||
|
||||
test('collector is completely off by default and unsupported pages or exams cannot start', () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.collector.getState().active, false); assert.equal(f.timers.size, 0); assert.deepEqual(f.actions, []);
|
||||
for (const href of ['https://ordinary.example/o2o/#/playinfo?taskId=task-1', 'https://www.shhuayi-elearning.cn/o2o/#/exam?taskId=task-1']) {
|
||||
f.win.location.href = href; assert.equal(f.collector.start().phase, 'stopped'); assert.deepEqual(f.actions, []);
|
||||
}
|
||||
const exam = fixture(); exam.state.practice = false;
|
||||
assert.equal(exam.collector.start().active, false);
|
||||
const misleading = fixture(); misleading.state.selected = '化工考试 | 必修';
|
||||
assert.equal(misleading.collector.start().active, false);
|
||||
const wrong = fixture(); wrong.win.location.href += '&wq=1';
|
||||
assert.equal(wrong.collector.start().active, false);
|
||||
});
|
||||
|
||||
test('incomplete identity, nonjudgment type or unconfirmed page count fail without clicks', () => {
|
||||
for (const mutate of [f => { f.state.bank.bankId = ''; }, f => { f.state.bank.questionCount = 499; },
|
||||
f => { f.state.heading = '单选题'; }, f => { f.state.questionOverride = { type: 'single' }; },
|
||||
f => { f.state.numberOverride = 2; }, f => { f.state.collecting = false; }]) {
|
||||
const f = fixture(); mutate(f);
|
||||
assert.equal(f.collector.start().phase, 'stopped'); assert.deepEqual(f.actions, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('only one ordinary radio click is sent and next waits for matching official feedback and trusted storage', () => {
|
||||
const f = fixture({ missing: [0], reply: false });
|
||||
f.collector.start(); f.advance(300);
|
||||
assert.deepEqual(f.actions, [{ kind: 'radio', index: 0, text: '正确' }]);
|
||||
f.advance(1800); assert.equal(f.actions.length, 1, '等待不能重试提交');
|
||||
f.state.feedback = '错误'; f.state.feedbackId = 'q-0';
|
||||
f.advance(600); assert.equal(f.actions.length, 1, '只看到DOM标准、尚未可信入库不能推进');
|
||||
f.state.records = f.state.records.map((record, index) => index === 0 ? core.normalizeRecord({ ...f.base(0), answerEvidence: 'answer-response' }) : record);
|
||||
f.advance(300);
|
||||
assert.equal(f.actions.at(-1).kind, 'next');
|
||||
assert.equal(f.collector.getState().answered, 1);
|
||||
f.collector.stop();
|
||||
});
|
||||
|
||||
test('normal feedback can be incorrect for the chosen radio, and the trusted standard is preserved', () => {
|
||||
const f = fixture({ missing: [0] });
|
||||
f.collector.start(); f.advance(4000);
|
||||
assert.equal(f.state.records[0].answerTexts[0], '错误');
|
||||
assert.equal(f.state.records[0].answerEvidence, 'answer-response');
|
||||
assert.equal(f.actions.filter(action => action.kind === 'radio').length, 1);
|
||||
assert.equal(f.collector.getState().active, true); f.collector.stop();
|
||||
});
|
||||
|
||||
test('a missing canonical, wrong alias, stem or options cannot be treated as an unanswered known question', () => {
|
||||
for (const mutate of [f => { f.state.records = f.state.records.slice(1); },
|
||||
f => { f.state.questionOverride = { questionId: 'unrelated-id' }; },
|
||||
f => { f.state.questionOverride = { stem: '另一道题目' }; },
|
||||
f => { f.state.questionOverride = { options: [{ id: 'new-1', text: '正确', label: '正确' }, { id: 'new-0', text: '错误', label: '错误' }] }; }]) {
|
||||
const f = fixture({ missing: [0] }); mutate(f); f.collector.start(); f.advance(600);
|
||||
assert.equal(f.collector.getState().phase, 'stopped'); assert.deepEqual(f.actions, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('stop cancels queued clicks, duplicate start is single-flight, and destroy cannot restart', () => {
|
||||
const f = fixture({ missing: [0] });
|
||||
f.collector.start(); f.collector.start(); assert.equal(f.timers.size, 1);
|
||||
f.collector.stop('手动停止'); f.advance(2000); assert.deepEqual(f.actions, []);
|
||||
f.collector.start(); f.collector.destroy(); f.advance(2000); assert.deepEqual(f.actions, []);
|
||||
assert.equal(f.collector.start().active, false);
|
||||
});
|
||||
|
||||
test('capture pause, bank change and route change each cancel before another click', () => {
|
||||
for (const mutate of [f => { f.state.collecting = false; }, f => { f.state.bank = { ...f.state.bank, bankId: 'other' }; },
|
||||
f => { f.win.location.href += '&changed=1'; f.events.dispatchEvent(new Event('hashchange')); }]) {
|
||||
const f = fixture({ missing: [0] }); f.collector.start(); mutate(f); f.advance(600);
|
||||
assert.equal(f.collector.getState().active, false); assert.deepEqual(f.actions, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('feedback from another question and an unexpected DOM transition cannot be used for completion', () => {
|
||||
const old = fixture({ missing: [0], reply: false });
|
||||
old.state.feedback = '错误'; old.state.feedbackId = 'q-other'; old.collector.start(); old.advance(600);
|
||||
assert.equal(old.collector.getState().active, false); assert.deepEqual(old.actions, []);
|
||||
const switched = fixture({ missing: [0], reply: false });
|
||||
switched.collector.start(); switched.advance(300); switched.setPosition(1); switched.state.feedback = '正确'; switched.state.feedbackId = 'q-0';
|
||||
switched.advance(300); assert.equal(switched.collector.getState().active, false);
|
||||
assert.deepEqual(switched.actions, [{ kind: 'radio', index: 0, text: '正确' }]);
|
||||
});
|
||||
|
||||
test('feedback timeout and genuine storage conflict stop without retries', () => {
|
||||
const timeout = fixture({ missing: [0], reply: false }); timeout.collector.start(); timeout.advance(16000);
|
||||
assert.equal(timeout.collector.getState().phase, 'stopped'); assert.match(timeout.collector.getState().reason, /15秒/);
|
||||
assert.equal(timeout.actions.length, 1);
|
||||
const conflict = fixture({ missing: [0] }); conflict.state.records[0].answerStatus = 'conflict';
|
||||
conflict.collector.start(); conflict.advance(600); assert.equal(conflict.collector.getState().active, false); assert.deepEqual(conflict.actions, []);
|
||||
});
|
||||
|
||||
test('already-chosen feedback is not resubmitted and waits for trustworthy matching records', () => {
|
||||
const f = fixture({ missing: [0], reply: false }); f.state.checked = true; f.state.feedback = '错误'; f.state.feedbackId = 'q-0';
|
||||
f.collector.start(); f.advance(900); assert.deepEqual(f.actions, []);
|
||||
f.state.records = f.state.records.map((record, index) => index === 0 ? core.normalizeRecord({ ...f.base(0), answerEvidence: 'bound-dom' }) : record);
|
||||
f.advance(300); assert.equal(f.actions[0].kind, 'next'); assert.equal(f.collector.getState().answered, 0); assert.equal(f.collector.getState().skipped, 1);
|
||||
f.collector.stop();
|
||||
});
|
||||
|
||||
test('official feedback conflicting with the newly stored standard stops at that item', () => {
|
||||
const f = fixture({ missing: [0], reply: false }); f.collector.start(); f.advance(300);
|
||||
f.state.feedback = '正确'; f.state.feedbackId = 'q-0';
|
||||
f.state.records = f.state.records.map((record, index) => index === 0 ? core.normalizeRecord({ ...f.base(0), answerEvidence: 'answer-response' }) : record);
|
||||
f.advance(300); assert.equal(f.collector.getState().active, false); assert.match(f.collector.getState().reason, /不一致/);
|
||||
assert.equal(f.actions.length, 1);
|
||||
});
|
||||
|
||||
test('known records are skipped, history without positions is ignored and the final submit button is never clicked', () => {
|
||||
const f = fixture({ current: 66 });
|
||||
f.state.records.push(core.normalizeRecord({ ...f.base(0), questionId: 'old-history-1', questionIndex: undefined,
|
||||
answerStatus: 'missing', correctOptionIds: [], answerTexts: [], answerEvidence: undefined }));
|
||||
f.state.records.push(core.normalizeRecord({ ...f.base(1), questionId: 'old-history-2', questionIndex: undefined,
|
||||
answerStatus: 'missing', correctOptionIds: [], answerTexts: [], answerEvidence: undefined }));
|
||||
f.collector.start(); f.advance(200000);
|
||||
const state = f.collector.getState();
|
||||
assert.equal(state.phase, 'completed'); assert.equal(state.active, false); assert.equal(state.total, 500); assert.equal(state.current, 500);
|
||||
assert.equal(state.skipped, 500); assert.equal(state.answered, 0);
|
||||
assert.equal(f.actions[0].kind, 'card'); assert.equal(f.actions[0].index, 0);
|
||||
assert.equal(f.actions.filter(action => action.kind === 'next').length, 499);
|
||||
assert.equal(f.actions.some(action => action.kind === 'radio'), false); assert.equal(f.timers.size, 0);
|
||||
});
|
||||
|
||||
test('the supported 232-question bank completes only after all canonical positions are verified', () => {
|
||||
const f = fixture({ total: 232, missing: [231] }); f.collector.start(); f.advance(200000);
|
||||
assert.equal(f.collector.getState().phase, 'completed'); assert.equal(f.collector.getState().answered, 1);
|
||||
assert.equal(f.actions.filter(action => action.kind === 'radio')[0].index, 231);
|
||||
assert.equal(f.actions.filter(action => action.kind === 'next').length, 231);
|
||||
});
|
||||
|
||||
test('source evidence is mandatory even if status says confirmed', () => {
|
||||
const f = fixture(); delete f.state.records[0].answerEvidence;
|
||||
f.collector.start(); f.advance(300); assert.equal(f.collector.getState().phase, 'stopped'); assert.deepEqual(f.actions, []);
|
||||
});
|
||||
|
||||
test('navigation stalls and duplicate canonical matches fail closed', () => {
|
||||
const navigation = fixture({ current: 2 });
|
||||
const original = navigation.doc.querySelectorAll;
|
||||
navigation.doc.querySelectorAll = selector => selector === '.yxtulcdsdk-pc-card-list li'
|
||||
? [{ nodeType: 1, isConnected: true, textContent: '1', getAttribute: () => null, closest: () => null, click() { navigation.actions.push({ kind: 'stalled' }); } }]
|
||||
: original(selector);
|
||||
navigation.collector.start(); navigation.advance(16000);
|
||||
assert.equal(navigation.collector.getState().phase, 'stopped'); assert.match(navigation.collector.getState().reason, /15秒/);
|
||||
const duplicate = fixture({ missing: [0] }); duplicate.state.records.push({ ...duplicate.state.records[0] });
|
||||
duplicate.collector.start(); duplicate.advance(300); assert.equal(duplicate.collector.getState().phase, 'stopped'); assert.deepEqual(duplicate.actions, []);
|
||||
});
|
||||
|
||||
test('a shared ID alias across positions and retained conflict snapshots both stop selection', () => {
|
||||
const aliases = fixture({ missing: [0] });
|
||||
aliases.state.records[1].questionIdAliases = ['q-0', 'q-1'];
|
||||
aliases.collector.start(); aliases.advance(300);
|
||||
assert.equal(aliases.collector.getState().phase, 'stopped'); assert.deepEqual(aliases.actions, []);
|
||||
const conflict = fixture(); conflict.state.records[0].conflicts = [{ reason: '尚未解决的标准冲突' }];
|
||||
conflict.collector.start(); conflict.advance(300);
|
||||
assert.equal(conflict.collector.getState().phase, 'stopped'); assert.deepEqual(conflict.actions, []);
|
||||
});
|
||||
@@ -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' };
|
||||
|
||||
Reference in New Issue
Block a user