878 lines
51 KiB
JavaScript
878 lines
51 KiB
JavaScript
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: '<p>升温时应选择?</p>', 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: '<p>升温时应选择?</p>',
|
|
choiceItems: [
|
|
{ id: 'a2', itemContent: '<p>提高温度</p>', content: '旧选项正文', selected: 0 },
|
|
{ id: 'a1', itemContent: '<p>降低温度</p>', 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: '<p>可采用哪些措施?</p>',
|
|
choiceItems: [
|
|
{ id: 'a3', itemContent: '<p>保持压力</p>' },
|
|
{ id: 'a1', itemContent: '<p>降低温度</p>', selected: 1 },
|
|
{ id: 'a2', itemContent: ' ', content: '<p>提高温度</p>' }
|
|
],
|
|
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({ questionList: [{ 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: '<b>标准解释</b>' }];
|
|
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 = '<p>温度<100℃,压力>2MPa时不正确的是?</p>';
|
|
assert.equal(extractQuestions(raw)[0].stem, '温度<100℃,压力>2MPa时不正确的是?');
|
|
});
|
|
|
|
test('rich text preserves each safe image URL, entity decoding and image order', () => {
|
|
const html = '<p>曲线&结构式<img alt="src=https://wrong.test/ignored.png >" data-src="https://wrong.test/lazy.png" SRC="HTTPS://IMAGES.EXAMPLE/a.png?x=1&y=2"></p>' +
|
|
"<br><img src='http://images.example/b.png'><img src=https://images.example/c.png>";
|
|
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('<img src=https://images.example/a/>'), '[图片:https://images.example/a/]');
|
|
assert.equal(richText('<img src=https://images.example/a.png?x=1&y=2>'), '[图片:https://images.example/a.png?x=1&y=2]');
|
|
assert.equal(richText('<img src="https://images.example/a.png">©×'), '[图片:https://images.example/a.png]©×');
|
|
assert.equal(richText('<img src="https://images.example/[a].png">'), '[图片:https://images.example/%5Ba%5D.png]');
|
|
assert.equal(richText('A&amp;B<img src="https://images.example/a.png?literal=&amp;">'), 'A&B[图片:https://images.example/a.png?literal=&]');
|
|
assert.equal(richText('<img src="https://images.example/literal.png">'), '<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(`<img src="${src}" alt="option">`), '');
|
|
}
|
|
assert.equal(richText('<img data-src="https://images.example/lazy.png" alt="src=https://images.example/alt.png">'), '');
|
|
assert.equal(richText('<img alt=foo=src=https://images.example/fake.png>'), '');
|
|
assert.equal(richText('<img-not-really src="https://images.example/fake.png"></img-not-really><img:fake src="https://images.example/fake.png">'), '');
|
|
assert.equal(richText('A<br-not-a-break>B</br-not-a-break>C'), 'ABC');
|
|
assert.equal(richText('<img src="javascript:alert(1)" src="https://images.example/second.png">'), '');
|
|
assert.equal(richText('<script><img src="https://images.example/script.png"></script><!--<img src="https://images.example/comment.png">--><style>x{}</style>文字'), '文字');
|
|
});
|
|
|
|
test('pure image choices retain their correct IDs and CSV roundtrip without conflating URLs', () => {
|
|
const core = require('../src/core.js');
|
|
const [raw] = extractQuestions({ listQues: [{ id: 'image-question', quesType: 0,
|
|
content: '<p>下列结构式与曲线相符的是?<img src="https://images.example/curve.jpg"></p>',
|
|
choiceItems: [{ id: 'a', itemContent: '<img src="https://images.example/chemical-a.png">' },
|
|
{ id: 'b', itemContent: '<img src="https://images.example/chemical-b.png?x=1&y=2">' }],
|
|
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: '<img src="https://images.example/old.png">' },
|
|
{ id: 'a2', content: '<img src="https://images.example/new.png">', 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: selector => selector === '.yxtulcdsdk-examing__result-line' ? (state.feedback || []).map(value => ({
|
|
getAttribute: name => name === 'data-question-id' ? value.questionId || null : null,
|
|
querySelector: selector => selector === '.yxtulcdsdk-examing__result-line-left' ? { textContent: value.heading } : { textContent: value.answer },
|
|
closest: () => null
|
|
})) : [] };
|
|
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: state.optionIds?.[index] || `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 = '<img src="https://images.example/stem-a.jpg">';
|
|
page.state.optionHTML = ['<img src="https://images.example/a.png">', '<img src="https://images.example/b.png?x=1&y=2">'];
|
|
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 = '<img src="https://images.example/stem-b.jpg">';
|
|
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: '<p>图片题<img src="https://images.example/curve.jpg"></p>' }] };
|
|
const page = bankPage(async () => new Response(JSON.stringify(payload), { headers: { 'content-type': 'application/json' } }));
|
|
page.adapter.start();
|
|
const before = page.received.length;
|
|
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
|
|
assert.equal(page.received.length, before, 'full image snapshot remains deferred until a visible total exists');
|
|
page.state.stem = '图片题'; page.state.stemHTML = payload.listQues[0].content;
|
|
assert.equal(page.adapter.getCurrentQuestion().questionId, 'q1', 'known network identity is usable before persistence');
|
|
page.adapter.captureCurrent();
|
|
assert.equal(page.received.length, before, 'DOM observation cannot preempt the image upgrade migration');
|
|
page.state.progress = '0/1';
|
|
page.adapter.getCurrentBank();
|
|
assert.equal(page.received.length, before + 1);
|
|
assert.equal(page.contexts.at(-1).fullSnapshot, true);
|
|
assert.equal(page.contexts.at(-1).pageQuestionCount, 1);
|
|
assert.equal(page.contexts.at(-1).currentBankId, 'bank-a');
|
|
page.adapter.getCurrentBank();
|
|
assert.equal(page.received.length, before + 1, 'the same deferred batch is delivered once');
|
|
page.adapter.stop();
|
|
});
|
|
|
|
test('current bank follows actual active sidebar instead of stale document title, even without questions', () => {
|
|
const { adapter, state, navigate, doc, changes } = bankPage();
|
|
adapter.start();
|
|
assert.equal(adapter.getCurrentBank().bankName, '判断题库1');
|
|
navigate('单选题库1', 'task-b');
|
|
assert.equal(doc.title, '不会随切换更新的旧标题');
|
|
assert.equal(adapter.getCurrentBank().bankName, '单选题库1');
|
|
assert.equal(adapter.getCurrentBank().loading, true);
|
|
assert.equal(adapter.getCurrentQuestion(), null, '旧判断题DOM仍在时不能采到新的单选库');
|
|
state.player = state.exam = '单选题库1'; state.containerTask = 'task-b';
|
|
assert.equal(adapter.getCurrentQuestion(), null, '只更新标题和task-id也不能把旧正文移入新库');
|
|
state.stem = '新的单选题目?';
|
|
const question = adapter.getCurrentQuestion();
|
|
assert.equal(question.bankName, '单选题库1');
|
|
assert.equal(question.stem, '新的单选题目?');
|
|
assert.equal(adapter.getCurrentBank().loading, false);
|
|
state.form = false;
|
|
assert.equal(adapter.getCurrentBank().bankName, '单选题库1', '未出现题目时仍能识别库名用于导出');
|
|
assert.ok(changes.some(bank => bank.bankName === '单选题库1'));
|
|
adapter.stop();
|
|
});
|
|
|
|
test('late old-bank response retains source name and cannot change the new active bank', async () => {
|
|
const pending = new Map();
|
|
const page = bankPage(url => new Promise(resolve => pending.set(url, resolve)));
|
|
page.adapter.start();
|
|
const first = page.win.fetch('/upm/start?praId=bank-a');
|
|
page.navigate('单选题库1', 'task-b');
|
|
page.state.player = page.state.exam = '单选题库1'; page.state.containerTask = 'task-b'; page.state.stem = '新的单选题目?';
|
|
const second = page.win.fetch('/upm/start?praId=bank-b');
|
|
const response = raw => new Response(JSON.stringify({ listQues: [raw] }), { headers: { 'content-type': 'application/json' } });
|
|
pending.get('/upm/start?praId=bank-b')(response({ ...single(), id: 'q-b', content: '新的单选题目?' }));
|
|
await second; await flush(); await flush();
|
|
assert.equal(page.adapter.getCurrentBank().bankId, 'bank-b');
|
|
pending.get('/upm/start?praId=bank-a')(response(single()));
|
|
await first; await flush(); await flush();
|
|
assert.equal(page.adapter.getCurrentBank().bankName, '单选题库1');
|
|
assert.equal(page.adapter.getCurrentBank().bankId, 'bank-b');
|
|
assert.equal(page.received.find(record => record.bankId === 'bank-a').bankName, '判断题库1');
|
|
page.adapter.stop();
|
|
});
|
|
|
|
test('returning A to B to A does not adopt late B results or stale document title', async () => {
|
|
let releaseB;
|
|
const page = bankPage(url => String(url).includes('bank-b') ? new Promise(resolve => { releaseB = resolve; }) : Promise.resolve(new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } })));
|
|
page.adapter.start();
|
|
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
|
|
page.navigate('单选题库1', 'task-b');
|
|
page.state.player = page.state.exam = '单选题库1'; page.state.containerTask = 'task-b'; page.state.stem = '新的单选题目?';
|
|
const pending = page.win.fetch('/upm/start?praId=bank-b');
|
|
page.navigate('判断题库1', 'task-a');
|
|
page.state.player = page.state.exam = '判断题库1'; page.state.containerTask = 'task-a'; page.state.stem = '升温时应选择?';
|
|
releaseB(new Response(JSON.stringify({ listQues: [{ ...single(), content: '新的单选题目?' }] }), { headers: { 'content-type': 'application/json' } }));
|
|
await pending; await flush(); await flush();
|
|
assert.equal(page.adapter.getCurrentBank().bankName, '判断题库1');
|
|
assert.equal(page.adapter.getCurrentBank().bankId, 'bank-a');
|
|
assert.equal(page.received.find(record => record.bankId === 'bank-b').bankName, '单选题库1');
|
|
page.adapter.stop();
|
|
});
|
|
|
|
test('different named response in same task never changes selected bank name or id', async () => {
|
|
let payload = { praName: '判断题库1', listQues: [single()] };
|
|
const page = bankPage(async () => new Response(JSON.stringify(payload), { headers: { 'content-type': 'application/json' } }));
|
|
page.adapter.start();
|
|
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
|
|
assert.equal(page.adapter.getCurrentBank().bankId, 'bank-a');
|
|
payload = { praName: '接口验收库', listQues: [single()] };
|
|
await page.win.fetch('/upm/start?praId=unrelated-bank'); await flush(); await flush();
|
|
assert.equal(page.adapter.getCurrentBank().bankName, '判断题库1');
|
|
assert.equal(page.adapter.getCurrentBank().bankId, 'bank-a');
|
|
page.adapter.stop();
|
|
});
|
|
|
|
test('bank identity keeps updating while capture and copy are both paused', () => {
|
|
const page = bankPage();
|
|
page.adapter.start(); page.adapter.stop(); page.adapter.setCopyEnabled(false);
|
|
const before = page.received.length;
|
|
page.navigate('多选题库', 'task-c');
|
|
assert.equal(page.changes.at(-1).bankName, '多选题库');
|
|
assert.equal(page.received.length, before);
|
|
});
|
|
|
|
test('initial HTML parsing and atomic full SPA render do not block a new form', () => {
|
|
const page = bankPage();
|
|
page.state.sidebar = page.state.player = page.state.exam = ''; page.state.form = false;
|
|
page.adapter.start();
|
|
page.state.sidebar = page.state.player = page.state.exam = '判断题库1'; page.state.form = true;
|
|
assert.ok(page.adapter.getCurrentQuestion(), '首次解析出正文不能当成上一题残留');
|
|
page.state.sidebar = page.state.player = page.state.exam = '单选题库1';
|
|
page.state.taskId = page.state.containerTask = 'task-b'; page.state.stem = '同步渲染的新题目?';
|
|
page.win.location.href = 'https://www.shhuayi-elearning.cn/o2o/#/playinfo?taskId=task-b';
|
|
const question = page.adapter.getCurrentQuestion();
|
|
assert.equal(question.bankName, '单选题库1');
|
|
assert.equal(question.stem, '同步渲染的新题目?');
|
|
page.adapter.stop();
|
|
});
|
|
|
|
test('ordinary pages do not observe requests, capture questions or intercept copy events', async () => {
|
|
const page = fakePage(async () => new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } }));
|
|
page.win.location.href = 'https://ordinary.example/account';
|
|
page.doc.title = '普通网页标题不应成为题库';
|
|
const originalFetch = page.win.fetch;
|
|
const originalOpen = page.win.XMLHttpRequest.prototype.open;
|
|
const originalSend = page.win.XMLHttpRequest.prototype.send;
|
|
const listeners = [];
|
|
const observers = [];
|
|
page.win.addEventListener = type => listeners.push(type);
|
|
page.win.removeEventListener = () => {};
|
|
page.win.MutationObserver = class {
|
|
constructor(callback) { observers.push(callback); }
|
|
observe() {}
|
|
};
|
|
page.doc.createElement = () => assert.fail('普通网页不能注入复制恢复样式');
|
|
const changes = [];
|
|
const adapter = create({ window: page.win, document: page.doc, observeNetwork: false,
|
|
onQuestions: values => page.received.push(...values), onBankChange: bank => changes.push(bank) });
|
|
adapter.start();
|
|
adapter.setCopyEnabled(true);
|
|
assert.equal(adapter.isSupportedPage(), false);
|
|
assert.equal(adapter.getCurrentQuestion(), null);
|
|
assert.equal(adapter.captureCurrent(), null);
|
|
assert.deepEqual(adapter.getCurrentBank(), { bankId: '', bankName: '', taskId: '', loading: false, questionCount: null });
|
|
await page.win.fetch('/upm/start?praId=not-a-bank');
|
|
const xhr = new page.win.XMLHttpRequest();
|
|
xhr.open('GET', '/upm/start?praId=not-a-bank'); xhr.send(); xhr.respond({ listQues: [single()] });
|
|
await flush();
|
|
adapter.stop();
|
|
assert.equal(page.win.fetch, originalFetch);
|
|
assert.equal(page.win.XMLHttpRequest.prototype.open, originalOpen);
|
|
assert.equal(page.win.XMLHttpRequest.prototype.send, originalSend);
|
|
assert.deepEqual(page.received, [], '普通页响应即使碰巧形似练习数据也不能写入题库');
|
|
assert.deepEqual(listeners, [], '普通页无需安装copy、selection或导航监听');
|
|
assert.deepEqual(observers, [], '普通页不需要观察整个DOM');
|
|
assert.ok(changes.every(bank => bank.bankName === '' && bank.bankId === ''));
|
|
});
|
|
|
|
test('network observation can be disabled independently on a supported exercise page', async () => {
|
|
const page = bankPage();
|
|
const originalFetch = page.win.fetch;
|
|
const originalOpen = page.win.XMLHttpRequest.prototype.open;
|
|
const adapter = create({ window: page.win, document: page.doc, observeNetwork: false,
|
|
onQuestions: values => page.received.push(...values) });
|
|
adapter.start();
|
|
assert.equal(adapter.isSupportedPage(), true);
|
|
assert.equal(page.received.length, 1, '已知站点的页面采集不依赖网络钩子');
|
|
assert.equal(page.received[0].stem, '升温时应选择?');
|
|
assert.equal(page.received[0].bankName, '判断题库1');
|
|
assert.equal(page.received[0].answerStatus, 'missing');
|
|
assert.equal(page.win.fetch, originalFetch);
|
|
assert.equal(page.win.XMLHttpRequest.prototype.open, originalOpen);
|
|
adapter.stop();
|
|
});
|
|
|
|
test('a known root appearing later is detectable without automatically enabling network observation', () => {
|
|
const page = fakePage();
|
|
page.win.location.href = 'https://ordinary.example/demo';
|
|
page.doc.title = '显式题目结构演示';
|
|
let exerciseRoot = null;
|
|
page.doc.querySelector = selector => selector === ROOT_SELECTOR_FOR_TEST ? exerciseRoot : null;
|
|
const originalFetch = page.win.fetch;
|
|
const originalOpen = page.win.XMLHttpRequest.prototype.open;
|
|
const adapter = create({ window: page.win, document: page.doc, observeNetwork: false,
|
|
onQuestions: values => page.received.push(...values) });
|
|
adapter.start();
|
|
assert.equal(adapter.isSupportedPage(), false);
|
|
assert.equal(adapter.getCurrentBank().bankName, '');
|
|
exerciseRoot = {};
|
|
assert.equal(adapter.isSupportedPage(), true);
|
|
assert.equal(adapter.getCurrentBank().bankName, '显式题目结构演示');
|
|
assert.equal(page.win.fetch, originalFetch);
|
|
assert.equal(page.win.XMLHttpRequest.prototype.open, originalOpen);
|
|
assert.deepEqual(page.received, []);
|
|
adapter.stop();
|
|
});
|
|
|
|
test('a lookalike hostname is not treated as the supported learning platform', () => {
|
|
const page = fakePage();
|
|
page.win.location.href = 'https://www.shhuayi-elearning.cn.ordinary.example/';
|
|
page.doc.title = '页面标题不是题库';
|
|
const adapter = create({ window: page.win, document: page.doc, observeNetwork: false });
|
|
adapter.start();
|
|
assert.equal(adapter.isSupportedPage(), false);
|
|
assert.equal(adapter.getCurrentBank().bankName, '');
|
|
assert.equal(adapter.getCurrentQuestion(), null);
|
|
adapter.stop();
|
|
});
|
|
|
|
test('complete snapshots retain repeated content positions and share one batch timestamp', () => {
|
|
const raw = { praId: 'p1', listQues: [single(), { ...single(), id: 'q2' }, { id: 'j1', quesType: 2, content: '判断示例', judgeAnswer: 0 }] };
|
|
const records = extractQuestions(raw, { batchId: 'batch-a', observedAt: '2026-10-29T09:52:46.000Z' });
|
|
assert.equal(records.length, 3, '同批次的两道相同题干是合法两题,不能按内容折成一题');
|
|
assert.deepEqual(records.map(q => q.questionIndex), [0, 1, 2]);
|
|
assert.deepEqual(records.map(q => q.contentOccurrence), [0, 1, 0]);
|
|
assert.equal(new Set(records.map(q => q.source.observedAt)).size, 1);
|
|
assert.equal(new Set(records.map(q => q.source.batchId)).size, 1);
|
|
});
|
|
|
|
test('listQues uses the ID submitted by the real practice client, not wrapper identities', () => {
|
|
const [record] = extractQuestions({ id: 'list-wrapper', quesType: 2, content: '容器说明', listQues: [{ ...single(), id: 'practice-row-id', quesId: 'bank-item-id', questionId: 'other-wrapper-id' }] });
|
|
assert.equal(record.questionId, 'practice-row-id');
|
|
assert.equal(record.stem, '升温时应选择?');
|
|
});
|
|
|
|
test('single answer receipts inherit request identity and snapshot occurrence metadata', () => {
|
|
const [known] = extractQuestions({ praId: 'p1', listQues: [single()] }, { batchId: 'first-snapshot', observedAt: '2026-10-29T09:52:46.000Z' });
|
|
const [answer] = extractQuestions({ id: 'answer-receipt-uuid', answers: ['a2'], explainText: '标准解析' }, {
|
|
answerResponse: true, questionId: 'q1', bankId: 'p1', knownQuestions: [known], batchId: 'answer-response'
|
|
});
|
|
assert.equal(answer.questionId, 'q1');
|
|
assert.equal(answer.questionIndex, 0);
|
|
assert.equal(answer.contentOccurrence, 0);
|
|
assert.equal(answer.source.batchId, 'first-snapshot');
|
|
assert.equal(answer.answerStatus, 'confirmed');
|
|
assert.deepEqual(extractQuestions({ id: 'receipt', quesType: 0, content: '完全不同的题干', choiceItems: single().choiceItems }, {
|
|
answerResponse: true, questionId: 'q1', bankId: 'p1', knownQuestions: [known]
|
|
}), [], '不能将另一题正文拼到请求中的题目ID');
|
|
});
|
|
|
|
test('the displayed progress total is available and never carried through a bank transition', () => {
|
|
const page = bankPage();
|
|
page.state.progress = '进度27/232';
|
|
assert.equal(page.adapter.getCurrentBank().questionCount, 232);
|
|
page.navigate('单选题库1', 'task-b');
|
|
assert.equal(page.adapter.getCurrentBank().questionCount, null, '切库途中旧卡片232不能归入新库');
|
|
page.state.player = page.state.exam = '单选题库1'; page.state.containerTask = 'task-b'; page.state.stem = '新题?'; page.state.progress = '进度0/500';
|
|
assert.equal(page.adapter.getCurrentBank().questionCount, 500);
|
|
page.state.progress = '';
|
|
assert.equal(page.adapter.getCurrentBank().questionCount, null);
|
|
page.state.progress = '进度233/232';
|
|
assert.equal(page.adapter.getCurrentBank().questionCount, null, '畸形进度不作为总题数');
|
|
});
|
|
|
|
test('network snapshots know 232 questions, and late old-bank totals cannot replace the current count', async () => {
|
|
const pending = new Map();
|
|
const page = bankPage(url => new Promise(resolve => pending.set(url, resolve)));
|
|
page.adapter.start();
|
|
const first = page.win.fetch('/upm/start?praId=bank-a');
|
|
page.navigate('单选题库1', 'task-b');
|
|
page.state.player = page.state.exam = '单选题库1'; page.state.containerTask = 'task-b'; page.state.stem = '新题?';
|
|
const second = page.win.fetch('/upm/start?praId=bank-b');
|
|
const response = (size, prefix) => new Response(JSON.stringify({ listQues: Array.from({ length: size }, (_, i) => ({ ...single(), id: `${prefix}-${i}`, content: `合成题目${i}` })) }), { headers: { 'content-type': 'application/json' } });
|
|
pending.get('/upm/start?praId=bank-b')(response(232, 'new'));
|
|
await second; await flush(); await flush();
|
|
assert.equal(page.adapter.getCurrentBank().questionCount, 232);
|
|
pending.get('/upm/start?praId=bank-a')(response(500, 'old'));
|
|
await first; await flush(); await flush();
|
|
assert.equal(page.adapter.getCurrentBank().questionCount, 232);
|
|
const batch = page.received.filter(q => q.bankId === 'bank-b');
|
|
assert.equal(batch.length, 232);
|
|
assert.equal(new Set(batch.map(q => q.source.batchId)).size, 1);
|
|
assert.equal(new Set(batch.map(q => q.source.observedAt)).size, 1);
|
|
page.adapter.stop();
|
|
});
|
|
|
|
test('DOM question number distinguishes legitimate identical items in the latest snapshot', async () => {
|
|
let prefix = 'old';
|
|
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [
|
|
{ ...single(), id: `${prefix}-first` }, { ...single(), id: `${prefix}-second` }
|
|
] }), { headers: { 'content-type': 'application/json' } }));
|
|
page.adapter.start();
|
|
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
|
|
page.state.questionNumber = 2;
|
|
const first = page.adapter.getCurrentQuestion();
|
|
assert.equal(first.questionId, 'old-second');
|
|
assert.equal(first.questionIndex, 1);
|
|
assert.equal(first.contentOccurrence, 1);
|
|
prefix = 'new';
|
|
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
|
|
const second = page.adapter.getCurrentQuestion();
|
|
assert.equal(second.questionId, 'new-second', '刷新完整列表后不再与旧批次的UUID混合匹配');
|
|
assert.equal(second.contentOccurrence, 1);
|
|
assert.notEqual(second.source.batchId, first.source.batchId);
|
|
page.state.questionNumber = 3;
|
|
assert.equal(page.adapter.getCurrentQuestion(), null, '题号与已加载完整列表不一致时应等待页面同步');
|
|
page.adapter.stop();
|
|
});
|
|
|
|
test('hidden parent answer permission cannot be bypassed by nested resultInfo', () => {
|
|
const raw = single();
|
|
raw.allowViewSingleQuesAnswer = false;
|
|
raw.resultInfo = { answers: ['a2'], judgeAnswer: 1 };
|
|
assert.equal(extractQuestions(raw)[0].answerStatus, 'missing');
|
|
});
|
|
|
|
test('old unbound feedback letters cannot become the next question standard answer', async () => {
|
|
const next = { ...single(), id: 'q2', content: '另一道题目?' };
|
|
next.choiceItems.forEach(option => delete option.answer);
|
|
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [single(), next] }), { headers: { 'content-type': 'application/json' } }));
|
|
page.adapter.start();
|
|
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
|
|
page.state.stem = '另一道题目?'; page.state.questionNumber = 2;
|
|
page.state.feedback = [{ heading: '正确答案', answer: 'A' }, { heading: '解析', answer: '上一题遗留解析' }];
|
|
const unbound = page.adapter.getCurrentQuestion();
|
|
assert.equal(unbound.questionId, 'q2');
|
|
assert.equal(unbound.answerStatus, 'missing');
|
|
assert.equal(unbound.explanation, '');
|
|
page.state.feedback = [{ heading: '正确答案', answer: 'A', questionId: 'q1' }];
|
|
assert.equal(page.adapter.getCurrentQuestion().answerStatus, 'missing', '明确属于上一题的反馈也不能套在新题');
|
|
page.state.feedback = [{ heading: '正确答案', answer: 'B', questionId: 'q2' }];
|
|
assert.deepEqual(page.adapter.getCurrentQuestion().answerTexts, ['提高温度']);
|
|
page.adapter.stop();
|
|
});
|
|
|
|
test('new stem with stale radio option ids waits until DOM values match its network identity', async () => {
|
|
const raw = { ...single(), id: 'q-new', choiceItems: [
|
|
{ id: 'new-a', content: '降低温度', answer: 0 }, { id: 'new-b', content: '提高温度', answer: 1 }
|
|
] };
|
|
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [raw] }), { headers: { 'content-type': 'application/json' } }));
|
|
page.adapter.start();
|
|
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
|
|
assert.equal(page.adapter.getCurrentQuestion(), null, '显示文字一致也不能给旧radio值绑定新题ID');
|
|
page.state.optionIds = ['new-a', 'new-b'];
|
|
const question = page.adapter.getCurrentQuestion();
|
|
assert.equal(question.questionId, 'q-new');
|
|
assert.deepEqual(question.options.map(option => option.id), ['new-a', 'new-b']);
|
|
page.adapter.stop();
|
|
});
|
|
|
|
test('observer ignores unrelated and selection mutations and batches relevant work before scanning', () => {
|
|
const page = bankPage();
|
|
let callback, reads = 0, sequence = 0;
|
|
const pending = new Map();
|
|
page.win.MutationObserver = class { constructor(value) { callback = value; } observe() {} };
|
|
page.win.setTimeout = fn => { const key = ++sequence; pending.set(key, fn); return key; };
|
|
page.win.clearTimeout = key => pending.delete(key);
|
|
const originalOne = page.doc.querySelector;
|
|
const originalAll = page.doc.querySelectorAll;
|
|
page.doc.querySelector = selector => { reads++; return originalOne(selector); };
|
|
page.doc.querySelectorAll = selector => { reads++; return originalAll(selector); };
|
|
function element(selectors, parentElement = null, id = '') {
|
|
const result = { nodeType: 1, id, parentElement,
|
|
matches: selector => selector.split(',').some(part => selectors.includes(part.trim()) || part.trim() === `#${id}`),
|
|
querySelector: () => null,
|
|
closest(selector) { return this.matches(selector) ? this : parentElement?.closest(selector) || null; }
|
|
};
|
|
return result;
|
|
}
|
|
page.adapter.start();
|
|
const baseline = reads;
|
|
const plain = element(['.unrelated-timer']);
|
|
const own = element([], null, 'huayi-qb-root');
|
|
const main = element([ROOT_SELECTOR_FOR_TEST]);
|
|
const radio = element(['.yxtulcdsdk-ques-radio'], main);
|
|
const card = element(['.yxtulcdsdk-answer-card__item']);
|
|
for (let i = 0; i < 1000; i++) callback([
|
|
{ type: 'characterData', target: { nodeType: 3, parentElement: plain } },
|
|
{ type: 'childList', target: own, addedNodes: [], removedNodes: [] },
|
|
{ type: 'attributes', attributeName: 'class', target: radio },
|
|
{ type: 'attributes', attributeName: 'class', target: card }
|
|
]);
|
|
assert.equal(reads, baseline, '无关页面变化不进行任何全页查询');
|
|
assert.equal(pending.size, 0, '选中/答题卡/助手自己的变化不调度扫描');
|
|
const stem = element(['.yxtulcdsdk-form__content'], main);
|
|
for (let i = 0; i < 100; i++) callback([{ type: 'characterData', target: { nodeType: 3, parentElement: stem } }]);
|
|
assert.equal(reads, baseline, '相关变化也不在observer回调里同步重扫');
|
|
assert.equal(pending.size, 1, '连续100次题目变化只调度一次检查');
|
|
const task = [...pending.values()][0]; pending.clear(); task();
|
|
assert.ok(reads > baseline, '合批后仍检查真实题目更新');
|
|
page.adapter.stop();
|
|
});
|
|
|
|
test('unsubmitted listQues judgment placeholders 0 and 1 remain missing without evidence', () => {
|
|
for (const judgeAnswer of [0, '0', 1, '1']) {
|
|
for (const submitQuesAnswers of [undefined, []]) {
|
|
const [record] = extractQuestions({ listQues: [{ id: 'j-unanswered', quesType: 2,
|
|
content: '未作答题的占位字段不是标准答案', judgeAnswer, submitQuesAnswers, isAnswered: 1 }] });
|
|
assert.equal(record.answerStatus, 'missing');
|
|
assert.deepEqual(record.answerTexts, []);
|
|
assert.deepEqual(record.correctOptionIds, []);
|
|
assert.equal(record.answerEvidence, undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('submitted practice judgments use judgeAnswer rather than the student submitted choice', () => {
|
|
for (const [judgeAnswer, submitted, expected] of [[0, '1', '错误'], [1, '0', '正确'], ['0', '1', '错误'], ['1', '0', '正确']]) {
|
|
const [record] = extractQuestions({ listQues: [{ id: 'j-answered', quesType: 2,
|
|
content: '已提交后可用标准反馈', judgeAnswer, submitQuesAnswers: [submitted] }] });
|
|
assert.equal(record.answerStatus, 'confirmed');
|
|
assert.deepEqual(record.answerTexts, [expected]);
|
|
assert.equal(record.answerEvidence, 'submitted-list');
|
|
}
|
|
});
|
|
|
|
test('an explicit answered resultInfo can confirm a practice judgment independently of placeholders', () => {
|
|
const [record] = extractQuestions({ listQues: [{ id: 'j-result', quesType: 2, content: '明确答后结果',
|
|
judgeAnswer: 0, submitQuesAnswers: [], resultInfo: { judgeAnswer: 1 } }] });
|
|
assert.deepEqual(record.answerTexts, ['正确']);
|
|
assert.equal(record.answerEvidence, 'submitted-list');
|
|
const [empty] = extractQuestions({ listQues: [{ id: 'j-empty', quesType: 2, content: '空结果对象也不是标准答案',
|
|
judgeAnswer: 0, resultInfo: {} }] });
|
|
assert.equal(empty.answerStatus, 'missing');
|
|
assert.equal(empty.answerEvidence, undefined);
|
|
});
|
|
|
|
test('explicit questionList standards retain 0/1 semantics while parent hidden flags win', () => {
|
|
const raw = { id: 'j-standard', quesType: 2, content: '显式题库标准答案', judgeAnswer: 0 };
|
|
const [standard] = extractQuestions({ questionList: [raw] }, { url: 'https://api.yunxuetang.cn/ote/pra/p1/questions' });
|
|
assert.deepEqual(standard.answerTexts, ['错误']);
|
|
assert.equal(standard.answerEvidence, 'explicit-standard');
|
|
for (const parent of [{ allowViewSingleQuesAnswer: false }, { info: { allowViewSingleQuesAnswer: 0 } }]) {
|
|
const [hidden] = extractQuestions({ ...parent, data: { questionList: [{ ...raw, allowViewSingleQuesAnswer: true }] } });
|
|
assert.equal(hidden.answerStatus, 'missing');
|
|
assert.equal(hidden.answerEvidence, undefined);
|
|
}
|
|
});
|
|
|
|
test('a direct upm start payload cannot bypass the practice-list evidence gate', async () => {
|
|
const page = fakePage(async () => new Response(JSON.stringify({ id: 'j-direct', quesType: 2,
|
|
content: '起始接口直接对象仍需已答证据', judgeAnswer: 1 }), { headers: { 'content-type': 'application/json' } }));
|
|
page.adapter.start();
|
|
await page.win.fetch('/upm/wrong/start?praId=p1'); await flush(); await flush();
|
|
assert.equal(page.received.at(-1).answerStatus, 'missing');
|
|
assert.equal(page.received.at(-1).answerEvidence, undefined);
|
|
page.adapter.stop();
|
|
});
|
|
|
|
test('same-question judgment answer receipts confirm standards and anonymize user URL segments', () => {
|
|
const page = fakePage();
|
|
page.adapter.start();
|
|
const initial = new page.win.XMLHttpRequest();
|
|
initial.open('GET', '/upm/start?praId=p1'); initial.send();
|
|
initial.respond({ listQues: [{ id: 'j1', quesType: 2, content: '断续噪声的判断示例', judgeAnswer: 0, submitQuesAnswers: [] }] });
|
|
assert.equal(page.received.at(-1).answerStatus, 'missing');
|
|
const answer = new page.win.XMLHttpRequest();
|
|
answer.open('PUT', '/upm/p1/users/private-user/wrong/answer');
|
|
answer.send(JSON.stringify({ answers: [{ quesId: 'j1', answer: ['0'] }] }));
|
|
answer.respond([{ id: 'receipt-id', judgeAnswer: 1, corrected: false }]);
|
|
const record = page.received.at(-1);
|
|
assert.equal(record.questionId, 'j1');
|
|
assert.deepEqual(record.answerTexts, ['正确']);
|
|
assert.equal(record.answerEvidence, 'answer-response');
|
|
assert.equal(record.source.url, 'https://www.shhuayi-elearning.cn/upm/p1/users/_/wrong/answer');
|
|
page.adapter.stop();
|
|
});
|
|
|
|
test('DOM standard feedback carries bound or known trusted answer evidence', async () => {
|
|
const raw = single();
|
|
raw.choiceItems.forEach(option => delete option.answer);
|
|
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [raw] }), { headers: { 'content-type': 'application/json' } }));
|
|
page.adapter.start();
|
|
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
|
|
page.state.feedback = [{ heading: '正确答案', answer: 'B' }];
|
|
assert.equal(page.adapter.getCurrentQuestion().answerStatus, 'missing', '未验证网络占位题不能替裸DOM反馈提供证据');
|
|
page.state.feedback = [{ heading: '正确答案', answer: 'B', questionId: 'q1' }];
|
|
assert.equal(page.adapter.getCurrentQuestion().answerEvidence, 'bound-dom');
|
|
const answer = new page.win.XMLHttpRequest();
|
|
answer.open('PUT', '/upm/bank-a/users/u1/answer');
|
|
answer.send(JSON.stringify({ answers: [{ quesId: 'q1', answer: ['a1'] }] }));
|
|
answer.respond([{ answers: ['a2'] }]);
|
|
page.state.feedback = [{ heading: '正确答案', answer: 'B' }];
|
|
assert.equal(page.adapter.getCurrentQuestion().answerEvidence, 'answer-response', '与可信同题反馈一致时保留原始证据');
|
|
page.adapter.stop();
|
|
});
|
|
|
|
test('an older answer receipt cannot attach its answer to a newer same-id full snapshot', () => {
|
|
const page = fakePage();
|
|
page.adapter.start();
|
|
const load = content => {
|
|
const request = new page.win.XMLHttpRequest();
|
|
request.open('GET', '/upm/start?praId=p1'); request.send();
|
|
request.respond({ listQues: [{ id: 'same-id', quesType: 2, content, judgeAnswer: 0, submitQuesAnswers: [] }] });
|
|
};
|
|
load('旧版本题干');
|
|
const answer = new page.win.XMLHttpRequest();
|
|
answer.open('PUT', '/upm/p1/users/u1/answer');
|
|
answer.send(JSON.stringify({ answers: [{ quesId: 'same-id', answer: ['0'] }] }));
|
|
load('重新加载后的新版本题干');
|
|
const count = page.received.length;
|
|
answer.respond([{ judgeAnswer: 1 }]);
|
|
assert.equal(page.received.length, count, '旧回执不能用较新缓存题干拼接成已确认答案');
|
|
assert.equal(page.received.at(-1).stem, '重新加载后的新版本题干');
|
|
assert.equal(page.received.at(-1).answerStatus, 'missing');
|
|
page.adapter.stop();
|
|
});
|
|
|
|
test('a newer snapshot of a different bank does not discard an independent answer receipt', () => {
|
|
const page = fakePage();
|
|
page.adapter.start();
|
|
const first = new page.win.XMLHttpRequest();
|
|
first.open('GET', '/upm/start?praId=p1'); first.send();
|
|
first.respond({ listQues: [{ id: 'q1', quesType: 2, content: '第一题库题目', judgeAnswer: 0, submitQuesAnswers: [] }] });
|
|
const answer = new page.win.XMLHttpRequest();
|
|
answer.open('PUT', '/upm/p1/users/u1/answer');
|
|
answer.send(JSON.stringify({ answers: [{ quesId: 'q1', answer: ['0'] }] }));
|
|
page.doc.title = '第二题库';
|
|
const second = new page.win.XMLHttpRequest();
|
|
second.open('GET', '/upm/start?praId=p2'); second.send();
|
|
second.respond({ listQues: [{ id: 'q1', quesType: 2, content: '第二题库题目', judgeAnswer: 0, submitQuesAnswers: [] }] });
|
|
answer.respond([{ judgeAnswer: 1 }]);
|
|
const received = page.received.at(-1);
|
|
assert.equal(received.bankId, 'p1');
|
|
assert.equal(received.stem, '第一题库题目');
|
|
assert.equal(received.answerEvidence, 'answer-response');
|
|
assert.deepEqual(received.answerTexts, ['正确']);
|
|
page.adapter.stop();
|
|
});
|