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