fix: retain question images and repair evidenced legacy records
This commit is contained in:
+120
-6
@@ -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>曲线&结构式<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), []);
|
||||
@@ -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&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
@@ -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&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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user