fix: deduplicate practice snapshots and compact white panel

This commit is contained in:
suanyuit-cyber
2026-10-29 18:49:42 +08:00
parent 83ca85cc17
commit 762408a347
14 changed files with 1996 additions and 90 deletions
+94 -3
View File
@@ -183,7 +183,7 @@ function bankPage(fetchImpl) {
page.win.addEventListener = events.addEventListener.bind(events);
page.win.removeEventListener = events.removeEventListener.bind(events);
page.win.dispatchEvent = events.dispatchEvent.bind(events);
const state = { sidebar: '判断题库1', player: '判断题库1', exam: '判断题库1', taskId: 'task-a', containerTask: 'task-a', stem: '升温时应选择?', form: true };
const state = { sidebar: '判断题库1', player: '判断题库1', exam: '判断题库1', taskId: 'task-a', containerTask: 'task-a', stem: '升温时应选择?', form: true, questionNumber: 1, progress: '' };
page.win.location.href = 'https://www.shhuayi-elearning.cn/o2o/#/playinfo?taskId=task-a';
page.doc.title = '不会随切换更新的旧标题';
const item = { getAttribute: key => key === 'data-task-id' ? state.taskId : null };
@@ -201,7 +201,7 @@ function bankPage(fetchImpl) {
get textContent() { return state.stem + options.map(option => option.textContent).join(''); },
closest: selector => selector === ROOT_SELECTOR_FOR_TEST ? main : null,
getAttribute: () => null, style: {},
querySelector: selector => selector === '.yxtulcdsdk-form__content' ? { children: [stem] } : null,
querySelector: selector => selector === '.yxtulcdsdk-form__content' ? { children: [stem] } : selector === '.yxtulcdsdk-form__sub' ? { textContent: `${state.questionNumber}.` } : null,
querySelectorAll: () => options
};
page.doc.querySelector = selector => {
@@ -209,6 +209,7 @@ function bankPage(fetchImpl) {
if (selector === 'header.o2oPlayFrame-right .title .yxtf-tooltip') return state.player ? player : null;
if (selector === '.yxtulcdsdk-exam-header .yxtf-tooltip') return state.exam ? exam : null;
if (selector === '.yxtulcdsdk-exam-container[task-id]') return container;
if (selector === '.yxtulcdsdk-answer-card__top') return state.progress ? { textContent: state.progress } : null;
return null;
};
page.doc.querySelectorAll = selector => selector === '.yxtulcdsdk-pc-marking-main .yxtulcdsdk-form' && state.form ? [form] : [];
@@ -343,7 +344,7 @@ test('ordinary pages do not observe requests, capture questions or intercept cop
assert.equal(adapter.isSupportedPage(), false);
assert.equal(adapter.getCurrentQuestion(), null);
assert.equal(adapter.captureCurrent(), null);
assert.deepEqual(adapter.getCurrentBank(), { bankId: '', bankName: '', taskId: '', loading: false });
assert.deepEqual(adapter.getCurrentBank(), { bankId: '', bankName: '', taskId: '', loading: false, questionCount: null });
await page.win.fetch('/upm/start?praId=not-a-bank');
const xhr = new page.win.XMLHttpRequest();
xhr.open('GET', '/upm/start?praId=not-a-bank'); xhr.send(); xhr.respond({ listQues: [single()] });
@@ -408,3 +409,93 @@ test('a lookalike hostname is not treated as the supported learning platform', (
assert.equal(adapter.getCurrentQuestion(), null);
adapter.stop();
});
test('complete snapshots retain repeated content positions and share one batch timestamp', () => {
const raw = { praId: 'p1', listQues: [single(), { ...single(), id: 'q2' }, { id: 'j1', quesType: 2, content: '判断示例', judgeAnswer: 0 }] };
const records = extractQuestions(raw, { batchId: 'batch-a', observedAt: '2026-10-29T09:52:46.000Z' });
assert.equal(records.length, 3, '同批次的两道相同题干是合法两题,不能按内容折成一题');
assert.deepEqual(records.map(q => q.questionIndex), [0, 1, 2]);
assert.deepEqual(records.map(q => q.contentOccurrence), [0, 1, 0]);
assert.equal(new Set(records.map(q => q.source.observedAt)).size, 1);
assert.equal(new Set(records.map(q => q.source.batchId)).size, 1);
});
test('listQues uses the ID submitted by the real practice client, not wrapper identities', () => {
const [record] = extractQuestions({ id: 'list-wrapper', quesType: 2, content: '容器说明', listQues: [{ ...single(), id: 'practice-row-id', quesId: 'bank-item-id', questionId: 'other-wrapper-id' }] });
assert.equal(record.questionId, 'practice-row-id');
assert.equal(record.stem, '升温时应选择?');
});
test('single answer receipts inherit request identity and snapshot occurrence metadata', () => {
const [known] = extractQuestions({ praId: 'p1', listQues: [single()] }, { batchId: 'first-snapshot', observedAt: '2026-10-29T09:52:46.000Z' });
const [answer] = extractQuestions({ id: 'answer-receipt-uuid', answers: ['a2'], explainText: '标准解析' }, {
answerResponse: true, questionId: 'q1', bankId: 'p1', knownQuestions: [known], batchId: 'answer-response'
});
assert.equal(answer.questionId, 'q1');
assert.equal(answer.questionIndex, 0);
assert.equal(answer.contentOccurrence, 0);
assert.equal(answer.source.batchId, 'first-snapshot');
assert.equal(answer.answerStatus, 'confirmed');
assert.deepEqual(extractQuestions({ id: 'receipt', quesType: 0, content: '完全不同的题干', choiceItems: single().choiceItems }, {
answerResponse: true, questionId: 'q1', bankId: 'p1', knownQuestions: [known]
}), [], '不能将另一题正文拼到请求中的题目ID');
});
test('the displayed progress total is available and never carried through a bank transition', () => {
const page = bankPage();
page.state.progress = '进度27/232';
assert.equal(page.adapter.getCurrentBank().questionCount, 232);
page.navigate('单选题库1', 'task-b');
assert.equal(page.adapter.getCurrentBank().questionCount, null, '切库途中旧卡片232不能归入新库');
page.state.player = page.state.exam = '单选题库1'; page.state.containerTask = 'task-b'; page.state.stem = '新题?'; page.state.progress = '进度0/500';
assert.equal(page.adapter.getCurrentBank().questionCount, 500);
page.state.progress = '';
assert.equal(page.adapter.getCurrentBank().questionCount, null);
page.state.progress = '进度233/232';
assert.equal(page.adapter.getCurrentBank().questionCount, null, '畸形进度不作为总题数');
});
test('network snapshots know 232 questions, and late old-bank totals cannot replace the current count', async () => {
const pending = new Map();
const page = bankPage(url => new Promise(resolve => pending.set(url, resolve)));
page.adapter.start();
const first = page.win.fetch('/upm/start?praId=bank-a');
page.navigate('单选题库1', 'task-b');
page.state.player = page.state.exam = '单选题库1'; page.state.containerTask = 'task-b'; page.state.stem = '新题?';
const second = page.win.fetch('/upm/start?praId=bank-b');
const response = (size, prefix) => new Response(JSON.stringify({ listQues: Array.from({ length: size }, (_, i) => ({ ...single(), id: `${prefix}-${i}`, content: `合成题目${i}` })) }), { headers: { 'content-type': 'application/json' } });
pending.get('/upm/start?praId=bank-b')(response(232, 'new'));
await second; await flush(); await flush();
assert.equal(page.adapter.getCurrentBank().questionCount, 232);
pending.get('/upm/start?praId=bank-a')(response(500, 'old'));
await first; await flush(); await flush();
assert.equal(page.adapter.getCurrentBank().questionCount, 232);
const batch = page.received.filter(q => q.bankId === 'bank-b');
assert.equal(batch.length, 232);
assert.equal(new Set(batch.map(q => q.source.batchId)).size, 1);
assert.equal(new Set(batch.map(q => q.source.observedAt)).size, 1);
page.adapter.stop();
});
test('DOM question number distinguishes legitimate identical items in the latest snapshot', async () => {
let prefix = 'old';
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [
{ ...single(), id: `${prefix}-first` }, { ...single(), id: `${prefix}-second` }
] }), { headers: { 'content-type': 'application/json' } }));
page.adapter.start();
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
page.state.questionNumber = 2;
const first = page.adapter.getCurrentQuestion();
assert.equal(first.questionId, 'old-second');
assert.equal(first.questionIndex, 1);
assert.equal(first.contentOccurrence, 1);
prefix = 'new';
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
const second = page.adapter.getCurrentQuestion();
assert.equal(second.questionId, 'new-second', '刷新完整列表后不再与旧批次的UUID混合匹配');
assert.equal(second.contentOccurrence, 1);
assert.notEqual(second.source.batchId, first.source.batchId);
page.state.questionNumber = 3;
assert.equal(page.adapter.getCurrentQuestion(), null, '题号与已加载完整列表不一致时应等待页面同步');
page.adapter.stop();
});
+137
View File
@@ -298,3 +298,140 @@ test('search limit fifty and offset give stable pages independent of input order
assert.equal(new Set([...first, ...second].map((item) => item.record.questionId)).size, 60);
assert.equal(core.search(records, '完全一致的检索题干', { limit: 0 }).length, 0);
});
function repeatedListSnapshots(size, duplicateCount = 3, batches = 3) {
const result = [];
for (let batch = 0; batch < batches; batch++) for (let index = 0; index < size; index++) {
const contentIndex = index >= size - duplicateCount ? index - (size - duplicateCount) : index;
const answer = contentIndex % 2 ? '错误' : '正确';
result.push({
bankId: 'bank-repeated', bankName: '判断题库', questionId: `session-${batch}-question-${index}`, type: 'judge', stem: `第${contentIndex}项设备检查结论。`,
options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }],
answerTexts: [answer], correctOptionIds: [answer === '正确' ? '1' : '0'], answerStatus: 'confirmed',
source: { kind: 'network', url: 'https://api.example.test/ote/upm/start', observedAt: new Date(Date.UTC(2026, 9, 29, 1, batch, 0, index)).toISOString() }
});
}
return result;
}
test('legacy 696 rows compact to 232 while retaining legitimate repeated questions in each snapshot', () => {
const raw = repeatedListSnapshots(232);
const repaired = core.compactRecords(raw);
assert.equal(repaired.records.length, 232);
assert.equal(repaired.removed, 464);
assert.equal(repaired.groups, 232);
assert.equal(repaired.conflictCount, 0);
assert.ok(repaired.records.every(record => record.questionIdAliases.length === 3));
assert.equal(new Set(repaired.records.map(record => record.stem)).size, 229);
const repeated = repaired.records.filter(record => record.stem === raw[0].stem);
assert.deepEqual(repeated.map(record => record.contentOccurrence).sort(), [0, 1]);
assert.equal(core.upsert(raw, repaired.records).length, 232);
assert.equal(core.upsert(repaired.records, raw).length, 232);
});
test('three 500-row snapshots plus anonymous missing DOM observations repair to 500', () => {
const raw = repeatedListSnapshots(500, 2);
const dom = { ...raw[0], questionId: '', bankId: '', answerStatus: 'missing', answerTexts: [], correctOptionIds: [], source: { kind: 'dom', url: 'https://practice.example.test/' } };
raw.splice(500, 0, dom);
raw.push({ ...dom, bankId: 'bank-repeated' });
const repaired = core.compactRecords(raw);
assert.equal(repaired.records.length, 500);
assert.equal(repaired.removed, 1002);
assert.equal(core.upsert(raw, repaired.records).length, 500);
assert.equal(core.compactRecords(core.upsert(repaired.records, raw)).records.length, 500);
});
test('explicit batch and occurrence metadata preserves multiplicity without relying on timestamps', () => {
const raw = repeatedListSnapshots(5, 2);
const counters = new Map();
raw.forEach((record, index) => {
const batch = Math.floor(index / 5);
const key = `${batch}:${record.stem}`;
record.questionIndex = index % 5;
record.contentOccurrence = counters.get(key) || 0;
counters.set(key, record.contentOccurrence + 1);
record.source.batchId = `batch-${batch}`;
record.source.observedAt = '2026-10-29T05:00:00Z';
});
const repaired = core.compactRecords(raw);
assert.equal(repaired.records.length, 5);
assert.ok(repaired.records.every(record => Number.isSafeInteger(record.questionIndex) && Number.isSafeInteger(record.contentOccurrence)));
const csvRecords = core.fromCSV(core.toCSV(repaired.records));
assert.deepEqual(csvRecords, repaired.records);
assert.equal(core.compactRecords(csvRecords).records.length, 5);
assert.ok(csvRecords.every(record => record.source.batchId));
});
test('without snapshot or occurrence evidence, different identified questions stay separate', () => {
const first = fixture({ questionId: 'first' });
const second = fixture({ questionId: 'second' });
assert.equal(core.compactRecords([first, second]).records.length, 2);
const incomplete = [first, second].map(record => ({ ...record, contentOccurrence: 0, options: [], correctOptionIds: [], answerTexts: [], answerStatus: 'missing' }));
assert.equal(core.compactRecords(incomplete).records.length, 2);
});
test('compaction never crosses different known bank IDs or guesses an ambiguous bank name', () => {
const row = fixture({ questionId: 'a', contentOccurrence: 0 });
const other = { ...row, questionId: 'b', bankId: 'bank-2' };
const unresolved = { ...row, questionId: 'c', bankId: '' };
assert.equal(core.compactRecords([row, other, unresolved]).records.length, 3);
const uniqueName = core.compactRecords([row, unresolved]);
assert.equal(uniqueName.records.length, 1);
assert.equal(uniqueName.records[0].bankId, row.bankId);
assert.deepEqual(new Set(uniqueName.records[0].questionIdAliases), new Set(['a', 'c']));
});
test('compaction preserves differing answers as a conflict and keeps original ID aliases', () => {
const first = fixture({ questionId: 'first', contentOccurrence: 0 });
const other = fixture({ questionId: 'second', contentOccurrence: 0, correctOptionIds: ['x'] });
const repaired = core.compactRecords([first, other]);
assert.equal(repaired.records.length, 1);
assert.equal(repaired.conflictCount, 1);
assert.equal(repaired.records[0].answerStatus, 'conflict');
assert.equal(repaired.records[0].conflicts.length, 2);
assert.deepEqual(new Set(repaired.records[0].questionIdAliases), new Set(['first', 'second']));
assert.equal(core.mapAnswers(repaired.records[0], first).status, 'ambiguous');
assert.equal(core.upsert(repaired.records, [first]).length, 1);
assert.equal(core.upsert(repaired.records, [first])[0].answerStatus, 'conflict');
});
test('compaction retains option IDs and their correct answers atomically when IDs change', () => {
const first = fixture({ questionId: 'first', contentOccurrence: 0 });
const second = reorder(fixture({ questionId: 'second', contentOccurrence: 0 }));
second.options = second.options.map((option, index) => ({ ...option, id: `new-${index}` }));
second.correctOptionIds = ['new-2'];
const repaired = core.compactRecords([first, second]).records[0];
assert.equal(repaired.answerStatus, 'confirmed');
assert.deepEqual(repaired.answerTexts, ['提高温度']);
assert.equal(repaired.options.find(option => option.id === repaired.correctOptionIds[0]).text, repaired.answerTexts[0]);
assert.equal(core.mapAnswers(repaired, second).status, 'matched');
});
test('compaction distinguishes different option sets, negation and mathematical units', () => {
const rows = [
fixture({ questionId: 'a', contentOccurrence: 0, stem: 'P水 > P气,面积2m²' }),
fixture({ questionId: 'b', contentOccurrence: 0, stem: 'P水 < P气,面积2m²' }),
fixture({ questionId: 'c', contentOccurrence: 0, stem: 'P水 > P气,面积2m2' }),
fixture({ questionId: 'd', contentOccurrence: 0, stem: '不正确的操作' }),
fixture({ questionId: 'e', contentOccurrence: 0, stem: '正确的操作' }),
fixture({ questionId: 'f', contentOccurrence: 0, options: [{ id: 'x', text: '新选项' }, { id: 'y', text: '提高温度' }] })
];
assert.equal(core.compactRecords(rows).records.length, rows.length);
});
test('anonymous snapshot duplicates with explicit occurrence have distinct keys', () => {
const first = fixture({ questionId: '', contentOccurrence: 0 });
const second = fixture({ questionId: '', contentOccurrence: 1 });
assert.notEqual(core.normalizeRecord(first).key, core.normalizeRecord(second).key);
assert.equal(core.upsert([], [first, second]).length, 2);
assert.equal(core.compactRecords([first, second]).records.length, 2);
});
test('metadata validation refuses corrupt occurrences and preserves explicit aliases in JSON', () => {
assert.throws(() => core.normalizeRecord(fixture({ contentOccurrence: -1 })), /非负整数/);
assert.throws(() => core.normalizeRecord(fixture({ questionIndex: '1' })), /非负整数/);
assert.throws(() => core.normalizeRecord(fixture({ questionIdAliases: [Number.MAX_SAFE_INTEGER + 1] })), /不安全/);
const record = core.normalizeRecord(fixture({ contentOccurrence: 0, questionIndex: 5, questionIdAliases: ['legacy-id'], source: { kind: 'network', batchId: 'batch-one', observedAt: '2026-10-29T00:00:00Z' } }));
assert.deepEqual(core.normalizeRecord(JSON.parse(JSON.stringify(record))), record);
assert.deepEqual(core.fromCSV(core.toCSV([record])), [record]);
});
+134 -3
View File
@@ -12,7 +12,8 @@ const http = require('node:http');
const ROOT = path.resolve(__dirname, '..');
const RECORDS_KEY = 'huayi-qb.records.v1';
const CONFIG_KEY = 'huayi-qb.config.v1';
const QA_DIR = path.join(os.tmpdir(), 'huayi-qb-qa-v120');
const DEDUP_BACKUP_KEY = 'huayi-qb.records.before-dedup.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');
function loadPlaywright() {
@@ -75,6 +76,26 @@ function bankResponse(bank) {
return { data: { praId: bank.id, praName: bank.name, listQues: [{ id: `question-${bank.id}`, quesType: { single: 0, multiple: 1, judge: 2 }[bank.type], content: bank.stem, ...(choiceItems ? { choiceItems } : { judgeAnswer: bank.answer === '正确' ? 1 : 0 }) }] } };
}
const DEDUP_BANK = { id: 'dedup-judge-2', name: '华谊集团化工总控工(高级)-判断题库2' };
function duplicateJudgeRecords() {
// The real 232-question bank contains three pairs with the same wording.
// Across three fetches, IDs change, but each original occurrence must remain.
return Array.from({ length: 3 }, (_, copy) => Array.from({ length: 232 }, (_, index) => ({
bankId: DEDUP_BANK.id, bankName: DEDUP_BANK.name, questionId: `legacy-${index}-${copy}`,
type: 'judge', stem: `去重验收第${(index < 229 ? index : index - 229) + 1}项:设备巡检要求已经确认。`,
options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }],
correctOptionIds: ['1'], answerTexts: ['正确'], answerStatus: 'confirmed',
source: { kind: 'network', url: 'https://api-ulcd.yunxuetang.cn/v2/upm/start', observedAt: `2026-10-29T01:0${copy}:00.000Z` }
}))).flat();
}
function duplicateJudgeFixture() {
return fixture().replaceAll('华谊集团化工总控工(高级)-判断题库1', DEDUP_BANK.name)
.replace('干燥过程传质推动力:物料表面水分压P水&gt;热空气中的水分压P水', duplicateJudgeRecords()[0].stem)
.replace('</main>', '<div class="yxtulcdsdk-answer-card__top">进度27/232</div></main>');
}
async function setupBankSwitches(page) {
await page.evaluate(banks => {
window.__qaBanks = banks;
@@ -244,6 +265,12 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
if (req.url.startsWith('/upm/start') || req.url.startsWith('/unrelated/report')) {
const item = new URL(req.url, 'http://127.0.0.1').searchParams.get('item') || '1';
res.writeHead(200, { 'Content-Type': 'application/json' });
if (item === 'legacy-duplicates') {
const copy = Math.max(0, Math.min(2, Number(new URL(req.url, 'http://127.0.0.1').searchParams.get('copy')) || 0));
res.end(JSON.stringify({ data: { praId: DEDUP_BANK.id, praName: DEDUP_BANK.name,
listQues: duplicateJudgeRecords().filter(record => record.questionId.endsWith(`-${copy}`)).map(record => ({ id: record.questionId, quesType: 2, content: record.stem, judgeAnswer: 1 })) } }));
return;
}
if (item.startsWith('bank-')) {
const bank = BANK_FIXTURES.find(candidate => item === `bank-${candidate.id}`);
const delay = Math.min(1000, Number(new URL(req.url, 'http://127.0.0.1').searchParams.get('delay')) || 0);
@@ -271,7 +298,7 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(req.url.startsWith('/multi-bank') ? multiBankFixture() : markup);
res.end(req.url.startsWith('/dedup-bank') ? duplicateJudgeFixture() : req.url.startsWith('/multi-bank') ? multiBankFixture() : markup);
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
@@ -380,7 +407,8 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
});
await t.test('JSON导入与当前页面选项乱序:正确内容映射为C', async () => {
const initial = (await storageRecords(page))[0];
const initial = (await storageRecords(page)).find(record => record.stem.includes('干燥过程传质推动力'));
assert.ok(initial, '使用当前页面原始题库作为导入归属,不能依赖记录数组顺序');
const record = {
bankId: initial.bankId, bankName: initial.bankName, questionId: 'e2e-single-7',
type: 'single', stem: '以下哪项操作可以提高温度?',
@@ -824,6 +852,109 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
} finally { await genericContext.close(); }
});
await t.test('紧凑纯白面板与历史三份重复记录修复', async group => {
const dedupContext = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true });
const legacyRecords = duplicateJudgeRecords();
await dedupContext.addInitScript({ content: `(${installGMShim.toString()})();\nif(localStorage.getItem(${JSON.stringify(RECORDS_KEY)})===null){GM_setValue(${JSON.stringify(RECORDS_KEY)},${JSON.stringify(legacyRecords)});}\n${script}` });
await dedupContext.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 204, body: '' }));
const dedupPage = await dedupContext.newPage();
dedupPage.on('pageerror', error => errors.push(error.message));
dedupPage.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
try {
await dedupPage.goto(`${origin}/dedup-bank#/playinfo?taskId=qa-dedup-task`, { waitUntil: 'domcontentloaded' });
await dedupPage.locator('#huayi-qb-root').waitFor({ state: 'attached' });
assert.equal(await dedupPage.locator('#qb-panel').isVisible(), false, '数据迁移不能擅自显示面板');
await activate(dedupPage, 'library');
await group.test('696条历史记录归一为232题,保留三份ID与原始备份', async () => {
await waitFor(async () => (await storageRecords(dedupPage)).length === 232, '696条三倍数据应迁移为232道唯一题目');
const records = await storageRecords(dedupPage);
assert.equal((await dedupPage.locator('#qb-stat-total').innerText()).replace(/,/g, ''), '232');
assert.equal((await dedupPage.locator('#qb-stat-confirmed').innerText()).replace(/,/g, ''), '232');
assert.match(await dedupPage.locator('#qb-page-total').innerText(), /232/, '页面总数应与平台进度一致');
assert.ok(records.every(record => record.questionIdAliases?.length === 3), '每道题应保留三个原始题目ID');
assert.equal(new Set(records.map(record => record.stem)).size, 229, '原库同批三对同内容题应保留为232个原始位置,不能压成229');
const backup = await dedupPage.evaluate(key => window.GM_getValue(key, null), DEDUP_BACKUP_KEY);
const original = Array.isArray(backup) ? backup : backup?.records;
assert.equal(original?.length, 696, '首次修复前应保留原696条完整记录');
assert.equal(new Set(original.map(record => record.questionId)).size, 696);
});
await group.test('CSV与JSON按唯一题导出,别名保留,旧三份响应不会再次累积', async () => {
const exported = await downloadedCSV(dedupPage, 'dedup-232.csv');
const parsed = require('../src/core.js').fromCSV(exported.text);
assert.equal(parsed.length, 232, 'CSV应只包含232道唯一题,而不是696条记录');
assert.ok(parsed.every(record => record.questionIdAliases?.length === 3), 'CSV完整记录JSON应保留全部别名');
assert.match(exported.name, /232题/);
const jsonWaiting = dedupPage.waitForEvent('download');
await dedupPage.locator('[data-action="export-json"]').click();
const jsonDownload = await jsonWaiting;
const jsonPath = path.join(QA_DIR, 'dedup-232.json');
await jsonDownload.saveAs(jsonPath);
const json = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
assert.equal(json.records.length, 232);
assert.ok(json.records.every(record => record.questionIdAliases?.length === 3));
await dedupPage.evaluate(async bankId => {
for (const copy of [0, 1, 2]) await fetch(`/upm/start?praId=${bankId}&item=legacy-duplicates&copy=${copy}`).then(response => response.json());
}, DEDUP_BANK.id);
await dedupPage.waitForTimeout(550);
assert.equal((await storageRecords(dedupPage)).length, 232, '旧的不同ID响应不能让重复题再次长出来');
assert.equal((await dedupPage.locator('#qb-stat-total').innerText()).trim(), '232');
assert.match(await dedupPage.locator('#qb-page-total').innerText(), /232/, '696条响应不能覆盖DOM中平台总数232');
await dedupPage.reload({ waitUntil: 'domcontentloaded' });
await dedupPage.locator('#huayi-qb-root').waitFor({ state: 'attached' });
assert.equal(await dedupPage.locator('#qb-panel').isVisible(), false);
await activate(dedupPage, 'library');
assert.equal((await storageRecords(dedupPage)).length, 232, '刷新后仍应保持唯一题目数');
await dedupPage.screenshot({ path: path.join(QA_DIR, 'white-compact-232.png') });
});
await group.test('面板至多300×440,标题按钮与面板均为白色,窄屏无溢出', async () => {
await dedupPage.mouse.move(0, 0);
const desktop = await dedupPage.locator('#qb-panel').boundingBox();
assert.ok(desktop.width <= 301 && desktop.height <= 441, `紧凑面板应至多300×440,实际${desktop.width}×${desktop.height}`);
const colors = await dedupPage.locator('#qb-panel').evaluate(panel => ({
panel: getComputedStyle(panel).backgroundColor,
header: getComputedStyle(panel.querySelector('.header')).backgroundColor,
button: getComputedStyle(panel.querySelector('.btn.primary')).backgroundColor
}));
assert.equal(colors.panel, 'rgb(255, 255, 255)');
assert.equal(colors.header, 'rgb(255, 255, 255)');
assert.equal(colors.button, 'rgb(255, 255, 255)');
await dedupPage.setViewportSize({ width: 390, height: 844 });
const mobile = await dedupPage.locator('#qb-panel').boundingBox();
assert.ok(mobile.width <= 301 && mobile.height <= 441 && mobile.x >= 0 && mobile.x + mobile.width <= 391);
assert.ok(mobile.y >= 0 && mobile.y + mobile.height <= 845);
await activate(dedupPage, 'auto');
assert.equal(await dedupPage.locator('#qb-auto-enabled').isChecked(), false, '迁移和样式更新不能开启自动答题');
await activate(dedupPage, 'library');
await dedupPage.screenshot({ path: path.join(QA_DIR, 'white-compact-mobile.png') });
await dedupPage.setViewportSize({ width: 1440, height: 1000 });
});
await group.test('冲突重复保留为冲突,不同题库和选项内容不会误合并', async () => {
const base = {
bankId: DEDUP_BANK.id, bankName: DEDUP_BANK.name, type: 'single', stem: '内容去重边界:应选择哪种操作?',
options: [{ id: 'a', text: '甲', label: 'A' }, { id: 'b', text: '乙', label: 'B' }], answerTexts: ['甲'], correctOptionIds: ['a'], answerStatus: 'confirmed'
};
await importFile(dedupPage, 'dedup-boundaries.json', JSON.stringify({ schemaVersion: 1, records: [
{ ...base, questionId: 'conflict-a', source: { kind: 'network', url: 'https://api-ulcd.yunxuetang.cn/v2/upm/start', observedAt: '2026-10-29T02:00:00.000Z' } },
{ ...base, questionId: 'conflict-b', answerTexts: ['乙'], correctOptionIds: ['b'], source: { kind: 'network', url: 'https://api-ulcd.yunxuetang.cn/v2/upm/start', observedAt: '2026-10-29T02:01:00.000Z' } },
{ ...base, bankId: 'dedup-other-bank', bankName: '另一题库', questionId: 'different-bank' },
{ ...base, questionId: 'different-options', options: [{ id: 'a', text: '甲', label: 'A' }, { id: 'c', text: '丙', label: 'B' }] }
] }));
const records = await storageRecords(dedupPage);
const boundary = records.filter(record => record.stem === base.stem);
assert.equal(boundary.length, 3, '相同库同内容归一,不同库和不同选项保持独立');
const conflicted = boundary.find(record => record.bankId === DEDUP_BANK.id && record.options.some(option => option.text === '乙'));
assert.equal(conflicted.answerStatus, 'conflict');
assert.ok(conflicted.conflicts?.length >= 2, '两个标准答案应保留供核验');
assert.ok(boundary.some(record => record.bankId === 'dedup-other-bank' && record.answerStatus === 'confirmed'));
assert.ok(boundary.some(record => record.options.some(option => option.text === '丙')));
});
} finally { await dedupContext.close(); }
});
await t.test('控制台无脚本错误', async () => { assert.deepEqual(errors, []); });
t.diagnostic(`Browser plugin not available; cached Chromium via Playwright. Local origin: ${origin}; viewports 1440x1000 and 390x844; evidence: ${QA_DIR}`);
} finally {