Files

246 lines
17 KiB
JavaScript

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, []);
});