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
+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 };