fix: retain question images and repair evidenced legacy records

This commit is contained in:
suanyuit-cyber
2026-10-29 22:40:38 +08:00
parent 15fa009f9e
commit c3edf3975e
45 changed files with 2778 additions and 103 deletions
+120 -6
View File
@@ -1,6 +1,6 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { extractQuestions, create } = require('../src/adapter.js');
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 },
@@ -125,6 +125,53 @@ test('markup and entities preserve comparisons, units and negations', () => {
assert.equal(extractQuestions(raw)[0].stem, '温度<100℃,压力>2MPa时不正确的是?');
});
test('rich text preserves each safe image URL, entity decoding and image order', () => {
const html = '<p>曲线&amp;结构&#x5f0f;<img alt="src=https://wrong.test/ignored.png >" data-src="https://wrong.test/lazy.png" SRC="HTTPS://IMAGES.EXAMPLE/a.png?x=1&amp;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&amp;y=2>'), '[图片:https://images.example/a.png?x=1&y=2]');
assert.equal(richText('<img src="https&colon;//images.example/a.png">&copy;&times;'), '[图片: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;amp;B<img src="https://images.example/a.png?literal=&amp;amp;">'), 'A&amp;B[图片:https://images.example/a.png?literal=&amp;]');
assert.equal(richText('&lt;img src="https://images.example/literal.png"&gt;'), '<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)', 'java&#x73;cript: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&amp;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), []);
@@ -229,13 +276,14 @@ function bankPage(fetchImpl) {
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; }, matches: selector => selector === '[data-rich-text]' };
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: () => null } : null,
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,
@@ -249,10 +297,15 @@ function bankPage(fetchImpl) {
if (selector === '.yxtulcdsdk-answer-card__top') return state.progress ? { textContent: state.progress } : null;
return null;
};
page.doc.querySelectorAll = selector => selector === '.yxtulcdsdk-pc-marking-main .yxtulcdsdk-form' && state.form ? [form] : [];
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 = [];
page.adapter = create({ window: page.win, document: page.doc, onQuestions: values => page.received.push(...values), onBankChange: bank => changes.push(bank) });
return { ...page, state, changes, navigate(name, taskId) {
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'));
@@ -260,6 +313,67 @@ function bankPage(fetchImpl) {
}
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&amp;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();