const test = require('node:test'); const assert = require('node:assert/strict'); const { extractQuestions, create, richText } = require('../src/adapter.js'); const single = () => ({ id: 'q1', quesType: 0, content: '
升温时应选择?
', choiceItems: [ { id: 'a1', content: '降低温度', selected: 1, answer: 0 }, { id: 'a2', content: '提高温度', selected: 0, answer: 1 } ] }); test('extracts confirmed choices atomically and ignores student selection', () => { const [q] = extractQuestions({ data: { praId: 'p1', praName: '单选题库', listQues: [single()] } }); assert.equal(q.bankId, 'p1'); assert.equal(q.bankName, '单选题库'); assert.equal(q.stem, '升温时应选择?'); assert.deepEqual(q.correctOptionIds, ['a2']); assert.deepEqual(q.answerTexts, ['提高温度']); }); test('practice single-choice itemContent preserves standard answers by option id', () => { const raw = { id: 'practice-single', quesType: 0, content: '升温时应选择?
', choiceItems: [ { id: 'a2', itemContent: '提高温度
', content: '旧选项正文', selected: 0 }, { id: 'a1', itemContent: '降低温度
', selected: 1 } ], correctAnswers: ['a2'] }; const [q] = extractQuestions({ data: { praId: 'p1', listQues: [raw] } }); assert.ok(q); assert.equal(q.answerStatus, 'confirmed'); assert.deepEqual(q.options.map(option => option.text), ['提高温度', '降低温度']); assert.deepEqual(q.correctOptionIds, ['a2']); assert.deepEqual(q.answerTexts, ['提高温度']); assert.equal(q.options[0].label, 'A'); }); test('practice multiple-choice itemContent keeps shuffled answers and content fallback', () => { const raw = { id: 'practice-multiple', quesType: 1, content: '可采用哪些措施?
', choiceItems: [ { id: 'a3', itemContent: '保持压力
' }, { id: 'a1', itemContent: '降低温度
', selected: 1 }, { id: 'a2', itemContent: ' ', content: '提高温度
' } ], correctAnswers: ['a2', 'a3'] }; const [q] = extractQuestions({ data: { praId: 'p2', listQues: [raw] } }); assert.ok(q); assert.equal(q.answerStatus, 'confirmed'); assert.deepEqual(q.options.map(option => option.text), ['保持压力', '降低温度', '提高温度']); assert.deepEqual(q.correctOptionIds, ['a2', 'a3']); assert.deepEqual(q.answerTexts, ['提高温度', '保持压力']); assert.equal(q.options.find(option => option.id === 'a2').label, 'C'); }); test('selected, submitContent, userAnswer and top-level answer/answers are not standard answers', () => { const raw = single(); raw.choiceItems.forEach(o => delete o.answer); Object.assign(raw, { answer: 'a1', answers: ['a1'], userAnswer: 'a1', submitQuesAnswers: ['a1'], corrected: true }); const [q] = extractQuestions({ questionList: [raw] }); assert.equal(q.answerStatus, 'missing'); assert.deepEqual(q.answerTexts, []); }); test('judge zero and string zero mean incorrect, never truthy correct', () => { for (const judgeAnswer of [0, '0', 1, '1', null, '-1']) { const [q] = extractQuestions({ listQues: [{ id: 'j1', quesType: 2, content: '压力越高越安全。', judgeAnswer }] }); const valid = judgeAnswer === 0 || judgeAnswer === '0' || judgeAnswer === 1 || judgeAnswer === '1'; assert.equal(q.answerStatus, valid ? 'confirmed' : 'missing'); assert.deepEqual(q.answerTexts, valid ? [String(judgeAnswer) === '1' ? '正确' : '错误'] : []); } }); test('multiple answers use ids and follow each response option order without letter assumptions', () => { const raw = single(); raw.quesType = 1; raw.choiceItems.push({ id: 'a3', content: '保持压力', answer: 1 }); raw.choiceItems.reverse(); const [q] = extractQuestions([raw]); assert.deepEqual(q.correctOptionIds, ['a3', 'a2']); assert.deepEqual(q.answerTexts, ['保持压力', '提高温度']); assert.equal(q.options.find(o => o.id === 'a2').label, 'B'); }); test('answer-only responses require an identified known question', () => { const raw = single(); raw.choiceItems.forEach(o => delete o.answer); const [base] = extractQuestions(raw, { bankId: 'p1' }); const response = [{ answers: ['a2'], corrected: false, explainText: '标准解释' }]; assert.deepEqual(extractQuestions(response, { answerResponse: true, questionId: 'other', knownQuestions: [base], bankId: 'p1' }), []); const [q] = extractQuestions(response, { answerResponse: true, questionId: 'q1', knownQuestions: [base], bankId: 'p1' }); assert.equal(q.questionId, 'q1'); assert.deepEqual(q.answerTexts, ['提高温度']); assert.equal(q.explanation, '标准解释'); assert.deepEqual(extractQuestions([response[0], response[0]], { answerResponse: true, questionId: 'q1', knownQuestions: [base], bankId: 'p1' }), []); }); test('cross-bank id collisions cannot attach an answer to another bank', () => { const [base] = extractQuestions(single(), { bankId: 'p1' }); assert.deepEqual(extractQuestions([{ answers: ['a2'] }], { answerResponse: true, questionId: 'q1', knownQuestions: [base], bankId: 'p2' }), []); }); test('known questions support explicit correctAnswers but not invalid option ids', () => { const raw = single(); raw.choiceItems.forEach(o => delete o.answer); raw.correctAnswers = ['a2']; assert.deepEqual(extractQuestions(raw)[0].answerTexts, ['提高温度']); raw.correctAnswers = ['a1', 'a2']; assert.equal(extractQuestions(raw)[0].answerStatus, 'missing'); raw.correctAnswers = ['unknown']; assert.equal(extractQuestions(raw)[0].answerStatus, 'missing'); }); test('hidden standard answers and unrelated business objects stay unconfirmed', () => { const raw = single(); raw.allowViewSingleQuesAnswer = 0; assert.equal(extractQuestions(raw)[0].answerStatus, 'missing'); assert.deepEqual(extractQuestions({ id: 'x', type: 0, content: '普通通知', selected: true }), []); }); test('resultInfo.answers is explicit standard-answer context on a full question', () => { const raw = single(); raw.choiceItems.forEach(o => delete o.answer); raw.answers = ['a1']; raw.resultInfo = { answers: ['a2'], explainText: '说明' }; assert.deepEqual(extractQuestions(raw)[0].answerTexts, ['提高温度']); }); test('markup and entities preserve comparisons, units and negations', () => { const raw = single(); raw.content = '温度<100℃,压力>2MPa时不正确的是?
'; assert.equal(extractQuestions(raw)[0].stem, '温度<100℃,压力>2MPa时不正确的是?'); }); test('rich text preserves each safe image URL, entity decoding and image order', () => { const html = '曲线&结构式

";
assert.equal(richText(html), '曲线&结构式[图片:https://images.example/a.png?x=1&y=2]\n[图片:http://images.example/b.png][图片:https://images.example/c.png]');
assert.equal(richText({ innerHTML: html, textContent: '曲线&结构式' }), richText(html));
assert.equal(richText('
'), '[图片:https://images.example/a.png?x=1&y=2]');
assert.equal(richText('
©×'), '[图片:https://images.example/a.png]©×');
assert.equal(richText('
'), '[图片:https://images.example/%5Ba%5D.png]');
assert.equal(richText('A&B
'), 'A&B[图片:https://images.example/a.png?literal=&]');
assert.equal(richText('<img src="https://images.example/literal.png">'), '
');
});
test('image markers reject unsafe schemes and do not invent a shared placeholder', () => {
for (const src of ['javascript:alert(1)', 'javascript:alert(1)', 'data:image/png;base64,AAAA', 'file:///C:/secret.png', '//images.example/a.png', '/relative.png', '']) {
assert.equal(richText(`下列结构式与曲线相符的是?
' },
{ id: 'b', itemContent: '
' }],
correctAnswers: ['b'] }] });
assert.ok(raw);
assert.equal(raw.stem, '下列结构式与曲线相符的是?[图片:https://images.example/curve.jpg]');
assert.deepEqual(raw.options.map(option => option.text), ['[图片:https://images.example/chemical-a.png]', '[图片:https://images.example/chemical-b.png?x=1&y=2]']);
assert.deepEqual(raw.correctOptionIds, ['b']);
assert.deepEqual(raw.answerTexts, ['[图片:https://images.example/chemical-b.png?x=1&y=2]']);
const record = core.normalizeRecord(raw);
assert.equal(record.answerStatus, 'confirmed');
assert.deepEqual(core.fromCSV(core.toCSV([record])), [record]);
const changed = { ...record, options: record.options.map(option => ({ ...option, text: option.text.replace('chemical-b', 'chemical-c') })) };
assert.equal(core.mapAnswers(record, changed).status, 'unmapped');
const fallback = { ...single(), choiceItems: [{ id: 'a1', content: '
' },
{ id: 'a2', content: '
', answer: 1 }] };
assert.equal(extractQuestions(fallback)[0].answerTexts[0], '[图片:https://images.example/new.png]');
});
test('malformed/unsupported types, circular and deeply nested payloads are bounded', () => {
const circular = { data: null }; circular.data = circular;
assert.deepEqual(extractQuestions(circular), []);
assert.deepEqual(extractQuestions({ quesType: 3, id: 'x', content: '填空' }), []);
assert.deepEqual(extractQuestions(null), []);
assert.deepEqual(extractQuestions('not json'), []);
assert.deepEqual(extractQuestions({ ...single(), choiceItems: [null, {}] }), []);
const deeply = {}; let at = deeply; for (let i = 0; i < 30; i++) at = at.data = {};
at.questionList = [single()];
assert.deepEqual(extractQuestions(deeply), []);
});
function fakePage(fetchImpl = async () => new Response('{}', { headers: { 'content-type': 'application/json' } })) {
class XHR extends EventTarget {
open(method, url) { this.method = method; this.url = url; }
send(body) { this.sentBody = body; }
getResponseHeader(name) { return name === 'content-type' ? 'application/json' : null; }
respond(value) { this.status = 200; this.responseType = ''; this.responseText = JSON.stringify(value); this.dispatchEvent(new Event('load')); }
}
const doc = { title: '题库一', querySelector: () => null, querySelectorAll: () => [] };
const win = { fetch: fetchImpl, XMLHttpRequest: XHR, location: { href: 'https://www.shhuayi-elearning.cn/o2o/' }, setTimeout, clearTimeout };
const received = [];
const adapter = create({ window: win, document: doc, onQuestions: values => received.push(...values) });
return { win, doc, adapter, received };
}
const flush = () => new Promise(resolve => setImmediate(resolve));
test('fetch observation keeps original response, snapshots bank name and restores original fetch', async () => {
let release;
const original = () => new Promise(resolve => { release = resolve; });
const { win, doc, adapter, received } = fakePage(original);
adapter.start();
const promise = win.fetch('https://api-ulcd.yunxuetang.cn/v2/upm/start?praId=p1&token=secret');
doc.title = '题库二';
const response = new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } });
release(response);
assert.equal(await promise, response);
await flush(); await flush();
assert.equal(received.length, 1);
assert.equal(received[0].bankName, '题库一');
assert.equal(received[0].bankId, 'p1');
assert.equal(received[0].source.url, 'https://api-ulcd.yunxuetang.cn/v2/upm/start');
assert.equal(response.bodyUsed, false);
adapter.stop();
assert.equal(win.fetch, original);
});
test('responses from a previous capture session are ignored after pause and resume', async () => {
let release;
const { win, adapter, received } = fakePage(() => new Promise(resolve => { release = resolve; }));
adapter.start();
const promise = win.fetch('/upm/start?praId=p1');
adapter.stop(); adapter.start();
release(new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } }));
await promise; await flush(); await flush();
assert.equal(received.length, 0);
adapter.stop();
});
test('XHR observes standard result by request question id even after another question loads', () => {
const { win, adapter, received } = fakePage();
const originalOpen = win.XMLHttpRequest.prototype.open;
adapter.start();
const initial = new win.XMLHttpRequest();
initial.open('GET', '/upm/start?praId=p1'); initial.send();
const raw = single(); raw.choiceItems.forEach(o => delete o.answer);
initial.respond({ listQues: [raw, { id: 'q2', quesType: 2, content: '另一题' }] });
const answer = new win.XMLHttpRequest();
answer.open('PUT', '/upm/p1/users/u1/answer');
answer.send(JSON.stringify({ answers: [{ quesId: 'q1', answer: ['a1'] }] }));
answer.respond([{ answers: ['a2'], corrected: false }]);
const result = received.at(-1);
assert.equal(result.questionId, 'q1');
assert.equal(result.type, 'single');
assert.deepEqual(result.answerTexts, ['提高温度']);
adapter.stop();
assert.equal(win.XMLHttpRequest.prototype.open, originalOpen);
});
test('unrelated API paths and unrelated hosts do not get captured', async () => {
const { win, adapter, received } = fakePage(async () => new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } }));
adapter.start();
await win.fetch('https://unknown.example/upm/start?praId=p1');
await win.fetch('/ordinary/notifications');
await flush();
assert.equal(received.length, 0);
adapter.stop();
});
function bankPage(fetchImpl) {
const page = fakePage(fetchImpl);
const events = new EventTarget();
page.win.addEventListener = events.addEventListener.bind(events);
page.win.removeEventListener = events.removeEventListener.bind(events);
page.win.dispatchEvent = events.dispatchEvent.bind(events);
const state = { sidebar: '判断题库1', player: '判断题库1', exam: '判断题库1', taskId: 'task-a', containerTask: 'task-a', stem: '升温时应选择?', form: true, questionNumber: 1, progress: '' };
page.win.location.href = 'https://www.shhuayi-elearning.cn/o2o/#/playinfo?taskId=task-a';
page.doc.title = '不会随切换更新的旧标题';
const item = { getAttribute: key => key === 'data-task-id' ? state.taskId : null };
const sidebar = { get textContent() { return state.sidebar; }, closest: () => item };
const player = { get textContent() { return state.player; } };
const exam = { get textContent() { return state.exam; } };
const container = { getAttribute: key => key === 'task-id' ? state.containerTask : null };
const main = { querySelectorAll: () => [] };
const stem = { get textContent() { return state.stem; }, get innerHTML() { return state.stemHTML; }, matches: selector => selector === '[data-rich-text]' };
const options = ['降低温度', '提高温度'].map((value, index) => ({
querySelector: selector => selector === 'input' ? { value: `a${index + 1}` } : selector === '.yxtf-radio__label, .yxtf-checkbox__label' ? { textContent: `${String.fromCharCode(65 + index)}. ${value}`, querySelector: () => state.optionHTML ? { innerHTML: state.optionHTML[index], textContent: '' } : null } : null,
textContent: value
}));
const form = {
get textContent() { return state.stem + options.map(option => option.textContent).join(''); },
get innerHTML() { return state.stemHTML || state.optionHTML ? (state.stemHTML ?? state.stem) + (state.optionHTML ?? options.map(option => option.textContent)).join('') : undefined; },
closest: selector => selector === ROOT_SELECTOR_FOR_TEST ? main : null,
getAttribute: () => null, style: {},
querySelector: selector => selector === '.yxtulcdsdk-form__content' ? { children: [stem] } : selector === '.yxtulcdsdk-form__sub' ? { textContent: `${state.questionNumber}.` } : null,
querySelectorAll: () => options
};
page.doc.querySelector = selector => {
if (selector === '.task-content-active .group-title, .task-content-active .task-content-color-active') return state.sidebar ? sidebar : null;
if (selector === 'header.o2oPlayFrame-right .title .yxtf-tooltip') return state.player ? player : null;
if (selector === '.yxtulcdsdk-exam-header .yxtf-tooltip') return state.exam ? exam : null;
if (selector === '.yxtulcdsdk-exam-container[task-id]') return container;
if (selector === '.yxtulcdsdk-answer-card__top') return state.progress ? { textContent: state.progress } : null;
return null;
};
page.doc.querySelectorAll = selector => {
if (selector === '.yxtulcdsdk-pc-marking-main .yxtulcdsdk-form' && state.form) return [form];
if (selector === '.yxtulcdsdk-uexam-preview-content__base-row-flex--practice .info-left .info-text') return (state.preview || []).map(value => typeof value === 'string' ? { textContent: value } : value);
return [];
};
const changes = [];
const contexts = [];
page.adapter = create({ window: page.win, document: page.doc, onQuestions: (values, context) => { page.received.push(...values); contexts.push(context); }, onBankChange: bank => changes.push(bank) });
return { ...page, state, changes, contexts, navigate(name, taskId) {
state.sidebar = name; state.taskId = taskId;
page.win.location.href = `https://www.shhuayi-elearning.cn/o2o/#/playinfo?taskId=${taskId}`;
page.win.dispatchEvent(new Event('hashchange'));
} };
}
const ROOT_SELECTOR_FOR_TEST = '.yxtulcdsdk-pc-marking-main';
test('DOM image questions match response images and a pure-image SPA replacement unblocks', () => {
const page = bankPage();
page.state.stem = '';
page.state.stemHTML = '
';
page.state.optionHTML = ['
', '
'];
page.adapter.start();
const question = page.adapter.getCurrentQuestion();
assert.equal(question.stem, '[图片:https://images.example/stem-a.jpg]');
assert.deepEqual(question.options.map(option => option.text), ['[图片:https://images.example/a.png]', '[图片:https://images.example/b.png?x=1&y=2]']);
const [network] = extractQuestions({ ...single(), content: page.state.stemHTML,
choiceItems: page.state.optionHTML.map((itemContent, index) => ({ id: `a${index + 1}`, itemContent })), correctAnswers: ['a2'] });
assert.equal(network.stem, question.stem);
assert.deepEqual(network.options, question.options);
page.navigate('图片题库2', 'task-b');
page.state.player = page.state.exam = '图片题库2'; page.state.containerTask = 'task-b';
assert.equal(page.adapter.getCurrentQuestion(), null);
page.state.stemHTML = '
';
assert.equal(page.adapter.getCurrentQuestion().stem, '[图片:https://images.example/stem-b.jpg]');
assert.equal(page.adapter.getCurrentBank().loading, false);
page.adapter.stop();
});
test('snapshot context reports a single visible preview total rather than API question count', async () => {
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } }));
page.state.preview = ['试题总数:401题', '错题数量:2题'];
page.adapter.start();
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
const metadata = page.contexts.find(context => context.fullSnapshot);
assert.equal(metadata.bankId, 'bank-a');
assert.equal(metadata.currentBankId, 'bank-a');
assert.equal(metadata.pageQuestionCount, 401);
assert.equal(metadata.questionCount, 1);
page.state.preview = ['试题总数:401题', '试题总数:401题'];
assert.equal(page.adapter.getCurrentBank().questionCount, 1, 'ambiguous preview cannot supply a DOM total; remembered API count remains display-only');
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
assert.equal(page.contexts.filter(context => context.fullSnapshot).at(-1).pageQuestionCount, null);
page.adapter.stop();
});
test('image snapshots wait for matching DOM total and suppress intermediate DOM upserts', async () => {
const payload = { listQues: [{ ...single(), content: '图片题
'), '');
assert.equal(richText('