fix: deduplicate practice snapshots and compact white panel
This commit is contained in:
+94
-3
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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水>热空气中的水分压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}`).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 {
|
||||
|
||||
Reference in New Issue
Block a user