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, []);
|
||||
});
|
||||
Reference in New Issue
Block a user