fix: deduplicate practice snapshots and compact white panel
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user