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();
+88 -6
View File
@@ -22,6 +22,36 @@ test('multiple answer plan preserves the complete shuffled correct set', () => {
const plan = planAnswer([record], current({ type: 'multiple' }), core);
assert.equal(plan.status, 'ready'); assert.deepEqual(new Set(plan.labels), new Set(['B', 'C']));
});
function repeatedMultiple(extra = {}) {
return single({ type: 'multiple',
options: [{ id: 'a', text: '降低', label: 'A' }, { id: 'b', text: '下降', label: 'B' },
{ id: 'c', text: '增加', label: 'C' }, { id: 'd', text: '下降', label: 'D' }],
correctOptionIds: ['a', 'b', 'c', 'd'], answerTexts: ['降低', '下降', '增加'], ...extra });
}
test('multiple answer plan accepts every explicitly correct occurrence of repeated text', () => {
const record = repeatedMultiple();
const question = repeatedMultiple({ answerStatus: 'missing', correctOptionIds: [], answerTexts: [] });
question.options = [question.options[3], question.options[2], question.options[0], question.options[1]]
.map((option, index) => ({ ...option, id: `new-${index}`, label: 'ABCD'[index] }));
const plan = planAnswer([record], question, core);
assert.equal(plan.status, 'ready');
assert.deepEqual([...plan.labels].sort(), ['A', 'B', 'C', 'D']);
const subset = repeatedMultiple({ correctOptionIds: ['b', 'd'], answerTexts: ['下降'] });
assert.deepEqual(planAnswer([subset], question, core).labels, ['A', 'D']);
});
test('repeated text still skips partial answers, text-only answers, ID contradictions and duplicate labels', () => {
const question = repeatedMultiple({ answerStatus: 'missing', correctOptionIds: [], answerTexts: [] });
assert.equal(planAnswer([repeatedMultiple({ correctOptionIds: ['b'], answerTexts: ['下降'] })], question, core).status, 'ambiguous');
assert.equal(planAnswer([repeatedMultiple({ correctOptionIds: [], answerTexts: ['下降'] })], question, core).status, 'ambiguous');
const changed = repeatedMultiple();
[changed.options[0].id, changed.options[1].id] = [changed.options[1].id, changed.options[0].id];
assert.equal(planAnswer([repeatedMultiple()], changed, core).status, 'ambiguous');
question.options[3].label = 'B';
assert.equal(planAnswer([repeatedMultiple()], question, core).status, 'ambiguous');
});
test('judgement uses explicit correct and incorrect content, including zero ID', () => {
const record = { bankId: 'j', type: 'judge', stem: '这项说法正确吗?', options: [{ id: '1', text: '正确' }, { id: '0', text: '错误' }], correctOptionIds: ['0'], answerStatus: 'confirmed' };
const question = { ...record, options: [{ id: '0', text: '错误', label: '错误' }, { id: '1', text: '正确', label: '正确' }] };
@@ -74,14 +104,15 @@ test('continuous selection starts disabled and can be destroyed without scanning
assert.equal(engine.isEnabled(), false); assert.deepEqual(calls, []);
});
function checkboxPage({ disabled = false, ariaDisabled = false, duringClick } = {}) {
function checkboxPage({ disabled = false, ariaDisabled = false, duringClick, texts = ['检查温度', '检查压力', '跳过巡检'], htmls, stemHTML, richLabels = false } = {}) {
const clicks = [];
const baseNode = () => ({ nodeType: 1, isConnected: true, parentElement: null, hidden: false, getAttribute: () => null, closest: () => null });
const container = { ...baseNode(), matches: () => false, contains: () => true };
const title = { ...baseNode(), textContent: '选择全部正确的巡检操作。', querySelector: () => null };
const inputs = ['检查温度', '检查压力', '跳过巡检'].map((text, index) => {
const label = { ...baseNode(), textContent: text, querySelector: () => null,
cloneNode: () => ({ textContent: text, querySelectorAll: () => [] }) };
const title = { ...baseNode(), textContent: '选择全部正确的巡检操作。', innerHTML: stemHTML, querySelector: () => null };
const inputs = texts.map((text, index) => {
const label = { ...baseNode(), textContent: text,
querySelector: selector => richLabels && selector.includes('[data-rich-text]') ? { innerHTML: htmls?.[index], textContent: text } : null,
cloneNode: () => ({ textContent: text, innerHTML: htmls?.[index], querySelectorAll: () => [] }) };
const input = { ...baseNode(), type: 'checkbox', value: String(index), name: 'checks', id: `check-${index}`, checked: false, disabled: false, labels: [label],
matches: selector => selector === ':disabled' && disabled,
closest: selector => selector === '[aria-disabled="true"]' ? (ariaDisabled ? container : null) : selector === 'label' ? label : selector.startsWith('fieldset') ? container : null,
@@ -92,10 +123,61 @@ function checkboxPage({ disabled = false, ariaDisabled = false, duringClick } =
container.querySelectorAll = selector => selector.startsWith('input') ? inputs : [title];
const doc = { querySelectorAll: selector => selector.startsWith('.yxtulcdsdk') ? [] : [container] };
const win = { getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), clearTimeout() {}, clearInterval() {}, setTimeout() {}, setInterval() {} };
const record = { type: 'multiple', stem: title.textContent, options: inputs.map((input, index) => ({ id: input.value, text: ['检查温度', '检查压力', '跳过巡检'][index] })), answerTexts: ['检查温度', '检查压力'], correctOptionIds: ['0', '1'], answerStatus: 'confirmed' };
const record = { type: 'multiple', stem: title.textContent, options: inputs.map((input, index) => ({ id: input.value, text: texts[index] })), answerTexts: texts.slice(0, 2), correctOptionIds: ['0', '1'], answerStatus: 'confirmed' };
return { win, doc, record, inputs, clicks, title };
}
test('automatic selection chooses all correct repeated checkboxes and clears an incorrect choice', async () => {
const page = checkboxPage({ texts: ['下降', '增加', '下降'] });
page.record.correctOptionIds = ['0', '2']; page.record.answerTexts = ['下降'];
page.inputs[1].checked = true;
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
const result = await engine.answerCurrent();
assert.equal(result.status, 'answered');
assert.deepEqual(page.inputs.map(input => input.checked), [true, false, true]);
assert.deepEqual(page.clicks, [0, 1, 2]);
engine.destroy();
});
test('automatic selection never clicks an ambiguous repeated-text subset', async () => {
const page = checkboxPage({ texts: ['下降', '增加', '下降'] });
page.record.correctOptionIds = ['0']; page.record.answerTexts = ['下降'];
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
const result = await engine.answerCurrent();
assert.equal(result.status, 'skipped'); assert.deepEqual(page.clicks, []);
engine.destroy();
});
test('automatic selection reads image stems and choices consistently with network records', async () => {
const { extractQuestions } = require('../src/adapter.js');
for (const richLabels of [false, true]) {
const stemHTML = '<p>选择对应结构<img src="https://images.example/curve.jpg"></p>';
const htmls = ['<img src="https://images.example/a.png">', '<img src="https://images.example/b.png?x=1&amp;y=2">'];
const [record] = extractQuestions({ id: 'image-single', quesType: 0, content: stemHTML,
choiceItems: htmls.map((itemContent, index) => ({ id: String(index), itemContent })), correctAnswers: ['1'] });
const page = checkboxPage({ texts: ['', ''], htmls, stemHTML, richLabels });
page.inputs.forEach(input => { input.type = 'radio'; });
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [record] });
const result = await engine.answerCurrent();
assert.equal(result.status, 'answered');
assert.deepEqual(result.labels, ['B']);
assert.deepEqual(result.answerTexts, ['[图片:https://images.example/b.png?x=1&y=2]']);
assert.deepEqual(page.clicks, [1]);
engine.destroy();
}
});
test('a different image URL cannot reuse a known image answer', async () => {
const page = checkboxPage({ texts: ['', ''], htmls: ['<img src="https://images.example/a.png">', '<img src="https://images.example/c.png">'] });
const record = single({ type: 'multiple', stem: page.title.textContent,
options: [{ id: '0', text: '[图片:https://images.example/a.png]' }, { id: '1', text: '[图片:https://images.example/b.png]' }],
correctOptionIds: ['1'], answerTexts: ['[图片:https://images.example/b.png]'] });
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [record] });
const result = await engine.answerCurrent();
assert.equal(result.status, 'skipped'); assert.deepEqual(page.clicks, []);
engine.destroy();
});
test('disabled fieldsets and aria-disabled ancestors are never clicked', async () => {
for (const config of [{ disabled: true }, { ariaDisabled: true }]) {
const page = checkboxPage(config);
+201
View File
@@ -39,6 +39,207 @@ test('duplicate option content is ambiguous even when ID exists', () => {
assert.equal(core.mapAnswers(record, record).status, 'ambiguous');
});
const repeatedMultiple = (overrides = {}) => fixture({
type: 'multiple',
options: [
{ id: 'a', text: '降低', label: 'A' }, { id: 'b', text: '下降', label: 'B' },
{ id: 'c', text: '增加', label: 'C' }, { id: 'd', text: '下降', label: 'D' }
],
correctOptionIds: ['a', 'b', 'c', 'd'], answerTexts: ['降低', '下降', '增加'],
...overrides
});
test('multiple correct IDs with repeated option text normalize without a false conflict', () => {
const record = core.normalizeRecord(repeatedMultiple());
assert.equal(record.answerStatus, 'confirmed');
assert.deepEqual(record.correctOptionIds, ['a', 'b', 'c', 'd']);
assert.deepEqual(record.answerTexts, ['降低', '下降', '增加']);
assert.deepEqual(record.conflicts, []);
assert.deepEqual(core.normalizeRecord(record), record);
assert.deepEqual(core.fromCSV(core.toCSV([record])), [record]);
});
test('all correct occurrences of repeated multiple-choice text map after IDs and order change', () => {
const record = core.normalizeRecord(repeatedMultiple());
const current = repeatedMultiple({ answerStatus: 'missing', correctOptionIds: [], answerTexts: [] });
current.options = [current.options[3], current.options[2], current.options[0], current.options[1]]
.map((option, index) => ({ ...option, id: `new-${index}`, label: 'ABCD'[index] }));
const mapped = core.mapAnswers(record, current);
assert.equal(mapped.status, 'matched');
assert.deepEqual([...mapped.labels].sort(), ['A', 'B', 'C', 'D']);
const subset = core.normalizeRecord(repeatedMultiple({ correctOptionIds: ['b', 'd'], answerTexts: ['下降'] }));
assert.equal(subset.answerStatus, 'confirmed');
assert.deepEqual(core.mapAnswers(subset, current).labels, ['A', 'D']);
});
test('partly correct or text-only repeated options remain ambiguous', () => {
const partial = core.normalizeRecord(repeatedMultiple({ correctOptionIds: ['b'], answerTexts: ['下降'] }));
assert.equal(partial.answerStatus, 'confirmed');
assert.equal(core.mapAnswers(partial, partial).status, 'ambiguous');
const textOnly = core.normalizeRecord(repeatedMultiple({ correctOptionIds: [], answerTexts: ['下降'] }));
assert.equal(core.mapAnswers(textOnly, textOnly).status, 'ambiguous');
});
test('repeated text does not override contradictory IDs, answer content or saved conflicts', () => {
const record = core.normalizeRecord(repeatedMultiple());
const changed = repeatedMultiple();
[changed.options[0].id, changed.options[1].id] = [changed.options[1].id, changed.options[0].id];
assert.equal(core.mapAnswers(record, changed).status, 'unmapped');
assert.equal(core.normalizeRecord(repeatedMultiple({ answerTexts: ['降低', '下降'] })).answerStatus, 'conflict');
const conflicts = [{ reason: '正确选项ID与正确答案内容不一致', answerTexts: record.answerTexts, correctOptionIds: record.correctOptionIds }];
const legacy = core.normalizeRecord({ ...record, answerStatus: 'conflict', conflicts });
assert.equal(legacy.answerStatus, 'conflict');
assert.deepEqual(legacy.conflicts, conflicts);
assert.equal(core.mergeRecord(legacy, record).answerStatus, 'conflict');
});
test('different selections among repeated options remain real merge conflicts', () => {
const all = repeatedMultiple();
const partial = repeatedMultiple({ correctOptionIds: ['a', 'b', 'c'], answerTexts: ['降低', '下降', '增加'] });
assert.equal(core.mergeRecord(all, partial).answerStatus, 'conflict');
const first = repeatedMultiple({ correctOptionIds: ['b'], answerTexts: ['下降'] });
const second = repeatedMultiple({ correctOptionIds: ['d'], answerTexts: ['下降'] });
assert.equal(core.mergeRecord(first, second).answerStatus, 'conflict');
});
function legacyRepeatedMultiple(overrides = {}) {
const record = core.normalizeRecord(repeatedMultiple({
source: { kind: 'network', url: 'https://example.test/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'batch-one' }, ...overrides
}));
return { ...record, answerStatus: 'conflict', conflicts: [
{ reason: '正确选项ID与正确答案内容不一致', answerTexts: [...record.answerTexts], correctOptionIds: [...record.correctOptionIds] },
{ reason: '同一题目出现不同或不一致的标准答案', stem: record.stem, options: record.options.map(option => ({ ...option })),
answerTexts: [...record.answerTexts], correctOptionIds: [...record.correctOptionIds], source: { ...record.answerSource } }
] };
}
test('explicit legacy duplicate-text repair preserves the original and is idempotent', () => {
const legacy = legacyRepeatedMultiple();
const original = JSON.stringify(legacy);
assert.equal(core.normalizeRecord(legacy).answerStatus, 'conflict', 'normalization alone must remain conservative');
const result = core.repairDuplicateOptionConflicts([legacy]);
assert.equal(result.repaired.length, 1);
assert.equal(result.repaired[0].original, legacy, 'caller receives the exact original input for backup');
assert.equal(JSON.stringify(legacy), original, 'pure repair must not mutate the input or snapshots');
assert.equal(result.records[0].answerStatus, 'confirmed');
assert.deepEqual(result.records[0].conflicts, []);
assert.deepEqual(result.records[0].correctOptionIds, ['a', 'b', 'c', 'd']);
assert.deepEqual([...core.mapAnswers(result.records[0], result.records[0]).labels].sort(), ['A', 'B', 'C', 'D']);
assert.deepEqual(core.fromCSV(core.toCSV(result.records)), result.records);
const again = core.repairDuplicateOptionConflicts(result.records);
assert.deepEqual(again.records, result.records);
assert.deepEqual(again.repaired, []);
});
test('legacy repair requires complete agreeing evidence and never clears actual conflicts', async t => {
const cases = [
['different correct IDs despite the same text set', value => { value.conflicts[1].correctOptionIds = ['a', 'b', 'c']; }],
['different answer content', value => { value.conflicts[1].answerTexts = ['下降']; }],
['changed option content', value => { value.conflicts[1].options[0].text = '升高'; }],
['changed option identities', value => { value.conflicts[1].options[0].id = 'replacement'; }],
['partial options snapshot', value => { value.conflicts[1].options.pop(); }],
['stem without its full options snapshot', value => { delete value.conflicts[1].options; }],
['options without their full stem snapshot', value => { delete value.conflicts[1].stem; }],
['changed stem', value => { value.conflicts[1].stem += '另一个条件'; }],
['different question identity', value => { value.conflicts[1].questionId = 'other'; }],
['different source batch', value => { value.conflicts[1].source.batchId = 'another-batch'; }],
['source without the recorded batch', value => { delete value.conflicts[1].source.batchId; }],
['different observation in source', value => { value.conflicts[1].source.observedAt = '2026-10-29T01:00:00Z'; }],
['explicit different batch', value => { value.conflicts[1].batchId = 'another-batch'; }],
['unknown conflict reason', value => { value.conflicts.push({ ...value.conflicts[0], reason: '人工标记答案有疑问' }); }],
['malformed conflict evidence', value => { value.conflicts.push(null); }],
['missing answer evidence', value => { delete value.conflicts[1].correctOptionIds; }],
['missing old mismatch reason', value => { value.conflicts.shift(); }],
['duplicate option ID', value => { value.options[3].id = 'b'; }],
['duplicate correct ID', value => { value.correctOptionIds.push('b'); }],
['unknown correct ID', value => { value.correctOptionIds.push('unknown'); }],
['no duplicate correct option text', value => {
value.correctOptionIds = ['a', 'b', 'c'];
value.conflicts.forEach(snapshot => { snapshot.correctOptionIds = ['a', 'b', 'c']; });
}],
['non-multiple conflict', value => { value.type = 'single'; }]
];
for (const [name, mutate] of cases) await t.test(name, () => {
const value = legacyRepeatedMultiple();
mutate(value);
const result = core.repairDuplicateOptionConflicts([value]);
assert.equal(result.repaired.length, 0);
assert.equal(result.records[0].answerStatus, 'conflict');
});
});
function imageUpgradeFixture() {
const source = { kind: 'network', url: 'https://example.test/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'old-image-batch' };
const existing = [0, 1].map(index => core.normalizeRecord(fixture({ questionId: `old-image-${index}`,
stem: index ? '另一道无需图片的题目' : '根据溶解度曲线,应如何操作?', questionIndex: index, source })));
const incoming = existing.map((record, index) => core.normalizeRecord({ ...record, questionId: `new-image-${index}`,
stem: record.stem + (index ? '' : '[图片:https://example.test/curve.png]'),
source: { ...source, observedAt: '2026-10-29T01:00:00Z', batchId: 'new-image-batch' },
answerSource: { ...source, observedAt: '2026-10-29T01:00:00Z', batchId: 'new-image-batch' } }));
return { existing, incoming, context: { bankId: 'bank-1', expectedCount: 2, fullSnapshot: true } };
}
test('complete image snapshot upgrades only the unique old text record and retains its aliases', () => {
const { existing, incoming, context } = imageUpgradeFixture();
const before = JSON.stringify([existing, incoming]);
const result = core.upgradeLegacyImageRecords(existing, incoming, context);
assert.equal(result.upgraded.length, 1);
assert.equal(result.upgraded[0].original, existing[0]);
assert.equal(result.existing.length, 1);
assert.equal(result.incoming.length, 2);
assert.ok(result.incoming[0].stem.includes('[图片:https://example.test/curve.png]'));
assert.deepEqual(result.incoming[0].questionIdAliases, ['old-image-0', 'new-image-0']);
assert.equal(JSON.stringify([existing, incoming]), before, 'upgrade must not mutate original inputs');
const merged = core.compactRecords(core.upsert(result.existing, result.incoming)).records;
assert.equal(merged.length, 2, 'the image addition must not turn a two-question bank into three');
assert.equal(core.upgradeLegacyImageRecords(merged, incoming, context).upgraded.length, 0);
});
test('image completion also upgrades an unchanged question ID before normal merge can mark a conflict', () => {
const { existing, incoming, context } = imageUpgradeFixture();
incoming[0].questionId = existing[0].questionId;
const result = core.upgradeLegacyImageRecords(existing, incoming, context);
assert.equal(result.upgraded.length, 1);
const merged = core.compactRecords(core.upsert(result.existing, result.incoming)).records;
assert.equal(merged.length, 2);
assert.ok(merged.every(record => record.answerStatus === 'confirmed'));
});
test('image upgrade refuses partial, ambiguous or semantically changed evidence', async t => {
const cases = [
['not a full snapshot', value => { value.context.fullSnapshot = false; }],
['page count mismatch', value => { value.context.expectedCount = 3; }],
['wrong current bank', value => { value.context.bankId = 'other-bank'; }],
['incomplete snapshot', value => { value.incoming.pop(); }],
['missing position', value => { delete value.incoming[0].questionIndex; }],
['duplicate snapshot position', value => { value.incoming[1].questionIndex = 0; }],
['old position differs', value => { value.existing[0].questionIndex = 1; }],
['old position missing', value => { delete value.existing[0].questionIndex; }],
['old source is not a list response', value => { value.existing[0].source.url = 'https://example.test/answer'; }],
['new observation is only DOM', value => { value.incoming[0].source.kind = 'dom'; }],
['new snapshot has mixed batches', value => { value.incoming[1].source.batchId = 'another-batch'; }],
['old record already has a different image', value => { value.existing[0].stem += '[图片:https://example.test/other-curve.png]'; }],
['new text has changed', value => { value.incoming[0].stem = '根据溶解度曲线,不应如何操作?[图片:https://example.test/curve.png]'; }],
['new options have changed', value => { value.incoming[0].options[0].text += '且提高压力'; }],
['answer differs', value => { value.incoming[0].correctOptionIds = ['x']; value.incoming[0].answerTexts = ['降低温度']; }],
['old answer is conflicted', value => { value.existing[0].answerStatus = 'conflict'; }],
['two old candidates with the same complete text', value => { value.existing.push({ ...value.existing[0], questionId: 'another-old', questionIndex: 7 }); }],
['new snapshot has two equal text candidates', value => { value.incoming[1] = { ...value.incoming[0], questionId: 'new-image-1', questionIndex: 1 }; }],
['duplicate option content cannot be matched one-to-one', value => {
value.existing[0].options[2].text = value.existing[0].options[0].text;
value.incoming[0].options[2].text = value.incoming[0].options[0].text;
}]
];
for (const [name, mutate] of cases) await t.test(name, () => {
const value = imageUpgradeFixture(); mutate(value);
const result = core.upgradeLegacyImageRecords(value.existing, value.incoming, value.context);
assert.equal(result.upgraded.length, 0);
assert.equal(result.existing.length, value.existing.length);
assert.ok(result.incoming.every(record => !record.questionIdAliases?.includes('old-image-0')));
});
});
test('same stem but different option sets never auto-map', () => {
const record = core.normalizeRecord(fixture());
const current = reorder(record);
+124
View File
@@ -13,6 +13,8 @@ const ROOT = path.resolve(__dirname, '..');
const RECORDS_KEY = 'huayi-qb.records.v1';
const CONFIG_KEY = 'huayi-qb.config.v1';
const DEDUP_BACKUP_KEY = 'huayi-qb.records.before-dedup.v1';
const OPTION_REPAIR_BACKUP_KEY = 'huayi-qb.records.before-duplicate-option-repair.v1';
const IMAGE_UPGRADE_BACKUP_KEY = 'huayi-qb.records.before-image-upgrade.v1';
const QA_DIR = path.join(os.tmpdir(), 'huayi-qb-qa-v121');
const BUNDLED = path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules');
@@ -22,6 +24,128 @@ function loadPlaywright() {
catch { return require(path.join(BUNDLED, 'playwright')); }
}
test('历史误报和图片补全:原始备份、设置导出、旧标签写回与失败保护', { timeout: 60000 }, async () => {
// Use the current sources without building dist in a concurrently edited tree.
// The adapter fixture supplies explicit snapshot evidence; real storage, UI,
// imports, downloads and delayed writes still run in Chromium.
const core = require('../src/core.js');
const source = { kind: 'network', url: 'https://example.test/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'old-batch' };
const makeLegacy = (questionId, stem) => {
const record = core.normalizeRecord({ bankId: 'repair-bank', bankName: '修复验收题库', questionId, type: 'multiple', stem,
options: [{ id: 'a', text: '降低' }, { id: 'b', text: '下降' }, { id: 'c', text: '增加' }, { id: 'd', text: '下降' }],
correctOptionIds: ['a', 'b', 'c', 'd'], answerTexts: ['降低', '下降', '增加'], answerStatus: 'confirmed', source });
return { ...record, answerStatus: 'conflict', conflicts: [
{ reason: '正确选项ID与正确答案内容不一致', answerTexts: record.answerTexts, correctOptionIds: record.correctOptionIds },
{ reason: '同一题目出现不同或不一致的标准答案', stem: record.stem, options: record.options,
answerTexts: record.answerTexts, correctOptionIds: record.correctOptionIds, source }
] };
};
const legacy = makeLegacy('old-false-conflict', '旧版重复文字答案误报');
const realConflict = makeLegacy('real-conflict', '两个不同正确选项集合的真实冲突');
realConflict.conflicts[1] = { ...realConflict.conflicts[1], correctOptionIds: ['a', 'b', 'c'] };
const oldImage = core.normalizeRecord({ bankId: 'image-bank', bankName: '图片验收题库', questionId: 'old-image', type: 'single',
stem: '根据溶解度曲线,应如何操作?', questionIndex: 0, contentOccurrence: 0,
options: [{ id: 'a', text: '降低温度' }, { id: 'b', text: '提高温度' }], correctOptionIds: ['b'], answerStatus: 'confirmed', source });
const oldOther = core.normalizeRecord({ ...oldImage, questionId: 'old-other', questionIndex: 1, stem: '另一道普通题目' });
const newSource = { ...source, observedAt: '2026-10-29T01:00:00Z', batchId: 'new-batch' };
const snapshot = [oldImage, oldOther].map((record, index) => core.normalizeRecord({ ...record,
questionId: `new-image-${index}`, stem: record.stem + (index ? '' : '[图片:https://example.test/curve.png]'),
source: newSource, answerSource: newSource }));
const bank = { bankId: 'image-bank', bankName: '图片验收题库', questionCount: 2, loading: false };
const stubs = `const QBAdapter={create(options){window.__qaReceive=options.onQuestions;return {start(){},stop(){},setCopyEnabled(){},getCurrentQuestion(){return null},getCurrentBank(){return ${JSON.stringify(bank)}}}}};
const QBAuto={create(){return {getState(){return {enabled:false,busy:false,answeredCount:0,skippedCount:0,message:''}},setEnabled(){},answerCurrent(){return Promise.resolve()}}}};`;
const appSource = `(function(){${fs.readFileSync(path.join(ROOT, 'src/core.js'), 'utf8')}\n${stubs}\n${fs.readFileSync(path.join(ROOT, 'src/app.js'), 'utf8')}\n})();`;
const { chromium } = loadPlaywright();
const browser = await chromium.launch(browserOptions());
const createContext = async (seed, failBackup = false) => {
const context = await browser.newContext({ acceptDownloads: true });
await context.route('**/*', route => route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><html><head><meta charset="utf-8"></head><body><p>本地持久化验收</p></body></html>' }));
await context.addInitScript({ content: `(${installGMShim.toString()})();
GM_addValueChangeListener=(key,callback)=>{window.__qaRemoteChange=callback;return 1};
if(localStorage.getItem(${JSON.stringify(RECORDS_KEY)})===null){GM_setValue(${JSON.stringify(RECORDS_KEY)},${JSON.stringify(seed)});GM_setValue(${JSON.stringify(CONFIG_KEY)},{collecting:false});}
${failBackup ? `const originalSet=GM_setValue;GM_setValue=(key,value)=>{if(key===${JSON.stringify(OPTION_REPAIR_BACKUP_KEY)})throw new Error('fixture backup denied');return originalSet(key,value)};` : ''}
${appSource}` });
return context;
};
let context;
try {
context = await createContext([legacy, realConflict, oldImage, oldOther]);
const page = await context.newPage();
await page.goto('https://www.shhuayi-elearning.cn/repair-fixture');
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
assert.equal(await page.locator('#qb-panel').isVisible(), false);
await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === legacy.questionId)?.answerStatus === 'confirmed', '加载时应修复误报并写入');
assert.equal((await storageRecords(page)).find(record => record.questionId === realConflict.questionId).answerStatus, 'conflict');
const backup = await page.evaluate(key => GM_getValue(key, null), OPTION_REPAIR_BACKUP_KEY);
assert.deepEqual(backup.records, [legacy], '真实冲突不应被修复或混入误报备份');
await activate(page, 'settings');
const backupDownloadWaiting = page.waitForEvent('download');
await page.locator('[data-action="export-option-repair-backup"]').click();
const backupDownload = await backupDownloadWaiting;
const backupPath = await backupDownload.path();
assert.deepEqual(JSON.parse(fs.readFileSync(backupPath, 'utf8')).records, [legacy]);
const imported = makeLegacy('later-import', '后来导入的另一道误报');
await importFile(page, 'legacy-import.json', JSON.stringify({ schemaVersion: 1, records: [imported] }));
assert.equal((await storageRecords(page)).find(record => record.questionId === imported.questionId).answerStatus, 'confirmed');
assert.deepEqual((await page.evaluate(key => GM_getValue(key, null), OPTION_REPAIR_BACKUP_KEY)).records, [legacy, imported], '后续导入修复必须追加原始版本');
await page.evaluate(({ snapshot, bankId }) => window.__qaReceive(snapshot, { fullSnapshot: true, pageQuestionCount: 2, currentBankId: bankId }), { snapshot, bankId: bank.bankId });
await waitFor(async () => !(await storageRecords(page)).some(record => record.questionId === oldImage.questionId), '完整图片快照应移除已备份旧纯文字记录');
const afterImage = await storageRecords(page);
assert.equal(afterImage.filter(record => record.bankId === bank.bankId).length, 2);
assert.ok(afterImage.find(record => record.questionId === snapshot[0].questionId).questionIdAliases.includes(oldImage.questionId));
const imageBackup = await page.evaluate(key => GM_getValue(key, null), IMAGE_UPGRADE_BACKUP_KEY);
assert.deepEqual(imageBackup.records, [oldImage]);
assert.equal(imageBackup.migrations.length, 1);
await activate(page, 'settings');
const imageDownloadWaiting = page.waitForEvent('download');
await page.locator('[data-action="export-image-upgrade-backup"]').click();
const imageDownload = await imageDownloadWaiting;
assert.deepEqual(JSON.parse(fs.readFileSync(await imageDownload.path(), 'utf8')).records, [oldImage]);
await page.evaluate(({ stale, key }) => { GM_setValue(key, stale); window.__qaRemoteChange(key, [], stale, true); }, { stale: [...afterImage, oldImage], key: RECORDS_KEY });
await waitFor(async () => (await storageRecords(page)).filter(record => record.bankId === bank.bankId).length === 2, '旧标签写回不能复活已补全的原始版本');
await page.reload();
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
assert.equal((await storageRecords(page)).filter(record => record.bankId === bank.bankId).length, 2);
assert.ok((await storageRecords(page)).find(record => record.questionId === snapshot[0].questionId).stem.includes('[图片:'));
const changedOld = { ...oldImage, correctOptionIds: ['a'], answerTexts: ['降低温度'] };
const current = await storageRecords(page);
await page.evaluate(({ stale, key }) => { GM_setValue(key, stale); window.__qaRemoteChange(key, [], stale, true); }, { stale: [...current, changedOld], key: RECORDS_KEY });
await page.waitForTimeout(350);
assert.ok((await storageRecords(page)).some(record => record.questionId === oldImage.questionId && record.answerTexts.includes('降低温度')),
'相同旧key但答案已变化的记录不能按退休证据删除');
await context.close(); context = null;
const rawImage = { ...oldImage }, rawOther = { ...oldOther };
delete rawImage.contentOccurrence; delete rawOther.contentOccurrence;
context = await createContext([rawImage, rawOther]);
const rawPage = await context.newPage();
await rawPage.goto('https://www.shhuayi-elearning.cn/repair-fixture');
await rawPage.locator('#huayi-qb-root').waitFor({ state: 'attached' });
await rawPage.evaluate(({ snapshot, bankId }) => window.__qaReceive(snapshot, { fullSnapshot: true, pageQuestionCount: 2, currentBankId: bankId }), { snapshot, bankId: bank.bankId });
await waitFor(async () => (await storageRecords(rawPage)).length === 2 && !(await storageRecords(rawPage)).some(record => record.questionId === rawImage.questionId),
'内存已补occ但GM原记录未补时,也不能复活旧纯文字题');
const rawBackup = await rawPage.evaluate(key => GM_getValue(key, null), IMAGE_UPGRADE_BACKUP_KEY);
assert.ok(rawBackup.records.some(record => JSON.stringify(record) === JSON.stringify(rawImage)), '必须原样保留真实GM中的旧对象');
assert.equal(rawBackup.migrations.length, 2, '内存和GM中的两个原始元数据版本均有明确退休证据');
await context.close(); context = null;
context = await createContext([legacy], true);
const blocked = await context.newPage();
await blocked.goto('https://www.shhuayi-elearning.cn/repair-fixture');
await blocked.locator('#huayi-qb-root').waitFor({ state: 'attached' });
await blocked.waitForTimeout(350);
assert.deepEqual(await storageRecords(blocked), [legacy], '独立备份写入失败时必须保持原存储');
assert.equal(await blocked.evaluate(key => GM_getValue(key, null), OPTION_REPAIR_BACKUP_KEY), null);
} finally {
if (context) await context.close();
await browser.close();
}
});
function browserOptions() {
const executablePath = process.env.QB_CHROMIUM_EXECUTABLE || process.env.QB_CHROMIUM_PATH;
if (executablePath) return { headless: true, executablePath };