/g) || []).length, 200);
+ assert.strictEqual(getWordsCalls, 1);
+
+ hooks.state.ui.listBrowserPage = 2;
+ hooks.renderListBrowser();
+ assert.match(elements.listBody.innerHTML, /201<\/td>/);
+ elements.listSearch.dispatchEvent({ type: 'input', target: { value: 'word-401' } });
+ assert.strictEqual(hooks.state.ui.listBrowserPage, 1);
+ await new Promise((resolve) => setTimeout(resolve, 220));
+ assert.match(elements.listBody.innerHTML, /word-401/);
+ });
+
+ await record('filtered list export remains a complete mergeable word list', async () => {
+ const store = createMockStore([
+ { word: 'alpha', meaning: 'A', example: 'First', freq: 0.8, correctCount: 2 },
+ { word: 'beta', meaning: 'B', note: 'private note', questionId: 'internal-id' }
+ ]);
+ hooks.setStore(store);
+ hooks.state.ui.listBrowserQuery = 'alpha';
+ hooks.state.ui.listBrowserLearnedOnly = true;
+ windowStub.URL.created.length = 0;
+ const anchor = createElementStub('a');
+ vocabContext.document.createElement = () => anchor;
+
+ hooks.exportCurrentList();
+
+ const download = windowStub.URL.created.at(-1);
+ assert.ok(download, 'Expected an exported blob');
+ const payload = JSON.parse(await download.blob.text());
+ assert.strictEqual(payload.type, 'wordlist');
+ assert.strictEqual(payload.category, 'external');
+ assert.strictEqual(payload.entries.length, 2);
+ assert.deepStrictEqual(Array.from(payload.entries, (entry) => entry.word), ['alpha', 'beta']);
+ assert.ok(!Object.prototype.hasOwnProperty.call(payload, 'version'));
+ assert.ok(!Object.prototype.hasOwnProperty.call(payload, 'words'));
+ assert.ok(!Object.prototype.hasOwnProperty.call(payload.entries[0], 'correctCount'));
+ assert.ok(!Object.prototype.hasOwnProperty.call(payload.entries[1], 'questionId'));
+ assert.match(windowStub.messages.at(-1).text, /可分享词表/);
+ });
+
+ await record('card actions are ignored while list modal is open', () => {
+ hooks.state.session.stage = 'recognition';
+ elements.listModal.dataset.open = 'true';
+ let prevented = false;
+ hooks.handleCardAction({
+ target: {
+ closest() {
+ return { dataset: { action: 'reveal-meaning' } };
+ }
+ },
+ preventDefault() {
+ prevented = true;
+ }
+ });
+ assert.strictEqual(hooks.state.session.stage, 'recognition');
+ assert.strictEqual(prevented, false);
+ elements.listModal.dataset.open = 'false';
+ });
+
await record('import request triggers input', () => {
const store = createMockStore();
hooks.setStore(store);
@@ -1018,11 +1390,11 @@ async function run() {
windowStub.VocabDataIO = {
importWordList: async () => ({
type: 'progress',
- entries: [{ word: 'theta', meaning: 'T' }],
+ entries: [{ word: 'theta', meaning: 'T', nextReview: '2026-07-25T00:00:00.000Z' }],
meta: {
category: 'user',
- config: { dailyNew: 5, reviewLimit: 10, masteryCount: 2, notify: false },
- reviewQueue: ['x']
+ listId: 'spelling-errors-p1',
+ config: { dailyNew: 5, reviewLimit: 10, masteryCount: 2, notify: false }
}
})
};
@@ -1030,7 +1402,9 @@ async function run() {
await hooks.performImport({ name: 'progress.json' });
assert.strictEqual(store.words.length, 1);
assert.strictEqual(store.config.dailyNew, 5);
- assert.strictEqual(store.reviewQueue.length, 1);
+ assert.strictEqual(store.config.activeListId, 'spelling-errors-p1');
+ assert.strictEqual(store.replaceProgressCalls[0].listId, 'spelling-errors-p1');
+ assert.strictEqual(store.replaceProgressCalls[0].words[0].nextReview, '2026-07-25T00:00:00.000Z');
});
await record('export progress triggers download', async () => {
diff --git a/developer/tests/js/legacyMigrationBrickRegression.test.js b/developer/tests/js/legacyMigrationBrickRegression.test.js
new file mode 100644
index 00000000..2829700f
--- /dev/null
+++ b/developer/tests/js/legacyMigrationBrickRegression.test.js
@@ -0,0 +1,113 @@
+#!/usr/bin/env node
+// Regression: a single malformed v1 practice record (e.g. negative duration) must NOT
+// reject AppData's module-level `ready` promise. Before the fix, migrateLegacyData ran
+// unguarded inside the ready chain, so one bad record threw VALIDATION -> ready rejected
+// as INITIALIZATION_BLOCKED -> every browse read awaiting ready failed -> #browse-view
+// loading overlay never cleared (browse "won't open / freezes"), and with zero summaries
+// written the idempotency guard never tripped, so reload re-hit the same record forever.
+import assert from 'assert';
+import fs from 'fs';
+import path from 'path';
+import vm from 'vm';
+import { fileURLToPath } from 'url';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
+const appDataSource = fs.readFileSync(path.join(root, 'js/data/v2/appData.js'), 'utf8');
+const catalogSource = fs.readFileSync(path.join(root, 'js/data/v2/dataCatalog.js'), 'utf8');
+const recordSource = fs.readFileSync(path.join(root, 'js/data/practiceRecordSource.js'), 'utf8');
+const clone = (value) => value === undefined ? undefined : structuredClone(value);
+function stable(value) { if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`; if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`; return JSON.stringify(value); }
+function checksum(value) { let hash = 0x811c9dc5; for (const char of stable(value)) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 0x01000193); } return `fnv1a-${(hash >>> 0).toString(16)}`; }
+class AppDataError extends Error { constructor(code, message) { super(message); this.code = code; } }
+
+function harness(legacyValues, options = {}) {
+ const catalogSandbox = { structuredClone }; catalogSandbox.globalThis = catalogSandbox;
+ vm.runInContext(catalogSource, vm.createContext(catalogSandbox), { filename: 'dataCatalog.js' });
+ const catalog = catalogSandbox.__AppDataV2Catalog;
+ const shared = options.shared || { docs: new Map(), entities: new Map([['practiceSummaries', new Map()], ['practiceDetails', new Map()], ['practiceAnnotations', new Map()]]), counter: 0, legacyReads: 0, externalReads: 0 };
+ const envelope = (key, data, state = 'present', revision = 1, operationId = 'seed') => ({ schemaVersion: 2, revision, operationId, updatedAt: new Date().toISOString(), state, data: state === 'cleared' ? null : clone(data), checksum: checksum(state === 'cleared' ? null : data) });
+ class Kernel {
+ async initialize() { this.state = 'ready'; this.backend = 'memory'; return this; }
+ async getEnvelope(key) { return shared.docs.get(key) || null; }
+ async read(key, options = {}) { const entry = catalog.get(key); const value = shared.docs.get(key) || null; const data = !value || value.state === 'cleared' ? entry.defaultValue() : value.data; return options.withMeta ? { data: clone(data), envelope: clone(value) } : clone(data); }
+ async mutate(changes, options = {}) { const op = String(options.operationId || `doc-${++shared.counter}`); const revisions = {}; for (const change of changes) { const old = shared.docs.get(change.logicalKey); if (change.expectedRevision !== undefined && Number(change.expectedRevision) !== Number(old && old.revision || 0)) throw new AppDataError('CONFLICT', 'document revision'); const revision = Number(old && old.revision || 0) + 1; shared.docs.set(change.logicalKey, envelope(change.logicalKey, change.data, change.state, revision, op)); revisions[change.logicalKey] = revision; } return { committed: true, operationId: op, revisions, derived: { status: 'ready', pending: [] }, warnings: [] }; }
+ async readEntity(store, recordId) { const row = shared.entities.get(store).get(String(recordId)); return row ? clone(row.data) : null; }
+ async listEntities(store) { if (store !== 'practiceSummaries') throw new AppDataError('VALIDATION', 'details are not listable'); return Array.from(shared.entities.get(store).values()).map((row) => clone(row.data)); }
+ async mutateEntities(operations, options = {}) { const op = String(options.operationId || `entity-${++shared.counter}`); for (const item of operations) { const rows = shared.entities.get(item.store); const old = rows.get(String(item.recordId)); rows.set(String(item.recordId), { recordId: String(item.recordId), revision: Number(old && old.revision || 0) + 1, operationId: op, data: clone(item.data) }); } return { committed: true, operationId: op, revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] }; }
+ status() { return { state: this.state, backend: this.backend, failure: null }; }
+ }
+ const internals = { DataKernel: Kernel, AppDataError, catalog, clone, checksum, randomId: (prefix) => `${prefix}-${++shared.counter}`, nowIso: () => new Date().toISOString(), makeEnvelope: (entry, data, options = {}) => envelope(entry.logicalKey, data, options.state, options.revision, options.operationId), validateEnvelope: (entry, value) => Boolean(value && value.schemaVersion === 2 && value.checksum === checksum(value.data)), readLegacyValues: async () => { shared.legacyReads += 1; return clone(legacyValues); }, readLegacyExternalBackup: async () => { shared.externalReads += 1; return clone(options.externalBackup || null); } };
+ const sandbox = { console: { log() {}, warn() {}, error() {} }, Date, JSON, Math, Map, Set, Promise, structuredClone, __AppDataV2Internals: internals, sessionStorage: { getItem() { return null; }, setItem() {}, removeItem() {} } }; sandbox.window = sandbox; sandbox.globalThis = sandbox;
+ const context = vm.createContext(sandbox); vm.runInContext(recordSource, context, { filename: 'practiceRecordSource.js' }); vm.runInContext(appDataSource, context, { filename: 'appData.js' }); return { app: sandbox.AppData, shared };
+}
+
+async function run() {
+ // One valid record + one malformed (negative duration -> canonicalizeRecord throws VALIDATION).
+ const legacy = { practice_records: [
+ { id: 'good-1', type: 'reading', duration: 120, totalQuestions: 10, correctAnswers: 8, accuracy: 0.8 },
+ { id: 'bad-1', type: 'reading', duration: -5, totalQuestions: 10, correctAnswers: 8, accuracy: 0.8 }
+ ] };
+ const { app } = harness(legacy);
+
+ // Core regression: ready must resolve; browse's data read must not throw INITIALIZATION_BLOCKED.
+ const summaries = await app.practice.list({ projection: 'light' });
+
+ // Good record migrated; malformed record skipped rather than bricking the whole migration.
+ assert.strictEqual(summaries.length, 1, 'exactly the one valid record should migrate');
+ assert.strictEqual(summaries[0].id, 'good-1', 'the valid record must survive');
+ assert.deepStrictEqual(await app.settings.getAll(), {}, 'settings read after migration must not reject');
+
+ // Empty legacy set is also a clean resolve (no records path).
+ const empty = harness({});
+ assert.deepStrictEqual(await empty.app.practice.list({ projection: 'light' }), [], 'empty legacy migrates to empty list');
+
+ const merged = harness({ practice_records: [
+ { id: 'idb-only', type: 'reading', title: 'IDB only', totalQuestions: 1, correctAnswers: 1 },
+ { id: 'shared', type: 'reading', title: 'IDB wins', totalQuestions: 1, correctAnswers: 1 }
+ ] }, { externalBackup: { practiceRecords: [
+ { id: 'external-only', type: 'reading', title: 'External only', totalQuestions: 1, correctAnswers: 1 },
+ { id: 'shared', type: 'reading', title: 'External loses', totalQuestions: 1, correctAnswers: 0 }
+ ] } });
+ const mergedSummaries = await merged.app.practice.list({ projection: 'light' });
+ assert.deepStrictEqual(mergedSummaries.map((record) => record.id).sort(), ['external-only', 'idb-only', 'shared']);
+ assert.strictEqual(mergedSummaries.find((record) => record.id === 'shared').title, 'IDB wins');
+ assert.strictEqual(merged.shared.docs.get('system.migrations').data.externalBackupV1.status, 'consumed');
+
+ const secondBoot = harness({ practice_records: [
+ { id: 'must-not-resurrect', type: 'reading', totalQuestions: 1, correctAnswers: 1 }
+ ] }, { shared: merged.shared, externalBackup: { practiceRecords: [
+ { id: 'must-not-reimport', type: 'reading', totalQuestions: 1, correctAnswers: 1 }
+ ] } });
+ await secondBoot.app.ready;
+ assert.strictEqual(merged.shared.legacyReads, 1, 'completed migration must not rescan v1');
+ assert.strictEqual(merged.shared.externalReads, 1, 'consumed external JSON must not be read again');
+ assert.strictEqual((await secondBoot.app.practice.list({ projection: 'light' })).length, 3);
+
+ const partialShared = { docs: new Map(), entities: new Map([['practiceSummaries', new Map()], ['practiceDetails', new Map()], ['practiceAnnotations', new Map()]]), counter: 0, legacyReads: 0, externalReads: 0 };
+ partialShared.entities.get('practiceSummaries').set('partial', {
+ recordId: 'partial', revision: 1, operationId: 'seed', data: { id: 'partial', type: 'reading', title: 'Keep v2 summary' }
+ });
+ const partial = harness({ practice_records: [{
+ id: 'partial', type: 'reading', title: 'Legacy summary', totalQuestions: 1, correctAnswers: 1,
+ answers: { 1: 'A' }, notes: { 1: 'Recovered note' }
+ }] }, { shared: partialShared });
+ await partial.app.ready;
+ assert.strictEqual(partial.shared.entities.get('practiceSummaries').get('partial').data.title, 'Keep v2 summary');
+ assert.strictEqual(partial.shared.entities.get('practiceDetails').get('partial').data.answers[1], 'A');
+ assert.strictEqual(partial.shared.entities.get('practiceAnnotations').get('partial').data.notes[1], 'Recovered note');
+
+ const customLibrary = harness({
+ active_exam_index_key: 'exam_index_1700000000000',
+ exam_index_configurations: [{ id: 'exam_index_1700000000000', name: 'Legacy custom' }],
+ exam_index_1700000000000: [{ id: 'legacy-exam', type: 'reading' }]
+ });
+ await customLibrary.app.ready;
+ const activeId = await customLibrary.app.library.getActive();
+ assert.match(activeId, /^legacy-library-/);
+ assert.strictEqual((await customLibrary.app.library.getIndex(activeId))[0].id, 'legacy-exam');
+ assert.strictEqual((await customLibrary.app.library.listConfigurations())[0].id, activeId);
+
+ console.log('PASS legacyMigrationBrickRegression');
+}
+
+run().catch((error) => { console.error('FAIL legacyMigrationBrickRegression'); console.error(error); process.exit(1); });
diff --git a/developer/tests/js/legacyViewReadStatus.test.js b/developer/tests/js/legacyViewReadStatus.test.js
index 60c7f155..9cdeeab1 100644
--- a/developer/tests/js/legacyViewReadStatus.test.js
+++ b/developer/tests/js/legacyViewReadStatus.test.js
@@ -89,7 +89,7 @@ describe('LegacyExamListView._getCompletionStatus', () => {
title: 'Passage 2',
path: 'Reading/P2/passage-2.html'
};
- windowStub.getPracticeRecordsState = () => ([
+ const records = [
{
id: 'suite-record-1',
examId: 'suite-suite-record-1',
@@ -104,7 +104,8 @@ describe('LegacyExamListView._getCompletionStatus', () => {
}
]
}
- ]);
+ ];
+ windowStub.rebuildBrowseCompletionIndex(records);
const status = view._getCompletionStatus(exam);
@@ -113,37 +114,37 @@ describe('LegacyExamListView._getCompletionStatus', () => {
assert.strictEqual(status.date, '2026-07-01T09:58:00.000Z', '应优先读取 suiteEntries 子条目的时间');
});
- it('uses suite child scoreInfo and parent timestamp fallback for lightweight summaries', () => {
+ it('uses suiteEntrySummaries score and parent timestamp fallback for light records', () => {
const { windowStub, LegacyExamListView } = loadLegacyExamListView();
const view = new LegacyExamListView();
const exam = {
id: 'reading-p3',
title: 'Passage 3'
};
- windowStub.getPracticeRecordsState = () => ([
+ const records = [
{
id: 'suite-record-2',
examId: 'suite-suite-record-2',
title: '2026-07-02 套题',
date: '2026-07-02T12:30:00.000Z',
- suiteEntries: [
+ suiteEntrySummaries: [
{
examId: 'reading-p3',
title: 'Passage 3',
- scoreInfo: {
- correct: 9,
- total: 10,
- percentage: 90
- }
+ correctAnswers: 9,
+ totalQuestions: 10,
+ accuracy: 0.9,
+ percentage: 90
}
]
}
- ]);
+ ];
+ windowStub.rebuildBrowseCompletionIndex(records);
const status = view._getCompletionStatus(exam);
- assert(status, '轻量 suiteEntries 子条目也应产生完成状态');
- assert.strictEqual(status.percentage, 90, '应从 suiteEntries.scoreInfo 读取分数');
+ assert(status, 'light.suiteEntrySummaries 子条目也应产生完成状态');
+ assert.strictEqual(status.percentage, 90, '应从 suiteEntrySummaries 读取分数');
assert.strictEqual(status.date, '2026-07-02T12:30:00.000Z', '子条目缺失时间时应回退到父记录时间');
});
});
diff --git a/developer/tests/js/libraryManagerImportConfig.test.js b/developer/tests/js/libraryManagerImportConfig.test.js
index 93b719a1..870575f2 100644
--- a/developer/tests/js/libraryManagerImportConfig.test.js
+++ b/developer/tests/js/libraryManagerImportConfig.test.js
@@ -22,42 +22,63 @@ function clone(value) {
}
function createHarness(seed = {}) {
- const storageState = new Map();
- const localStorageState = new Map();
- const appState = { examIndex: [] };
+ const librarySeed = seed.library && typeof seed.library === 'object' ? seed.library : {};
+ const indexes = new Map(Object.entries(librarySeed.importedIndexes || {}).map(([id, value]) => [String(id), clone(value)]));
+ let activeId = typeof librarySeed.activeConfigurationId === 'string' && librarySeed.activeConfigurationId.trim()
+ ? librarySeed.activeConfigurationId.trim()
+ : null;
+ let configurations = (Array.isArray(librarySeed.configurations) ? librarySeed.configurations : [])
+ .filter((config) => config && (config.id || config.key))
+ .map((config) => Object.assign({}, clone(config), { id: config.id || config.key }));
+ const practiceRecords = clone(seed.records || []);
const defaultReadingIndex = clone(seed.readingExamIndex || []);
- Object.entries(seed.storage || {}).forEach(([key, value]) => {
- storageState.set(key, clone(value));
- });
-
- const localStorage = {
- getItem(key) {
- return localStorageState.has(key) ? localStorageState.get(key) : null;
+ let resourceBasePrefix = '';
+
+ const AppData = {
+ ready: Promise.resolve(),
+ library: {
+ async getActive() { return activeId; },
+ async activate(id) {
+ activeId = typeof id === 'string' && id.trim() ? id.trim() : null;
+ return { committed: true };
+ },
+ async listConfigurations() { return clone(configurations); },
+ async updateConfiguration(config) {
+ const id = String(config?.id || config?.key || '').trim();
+ if (!id) throw new Error('configuration id required');
+ const next = Object.assign({}, clone(config), { id, key: id });
+ const index = configurations.findIndex((item) => item && (item.id === id || item.key === id));
+ if (index >= 0) configurations[index] = next;
+ else configurations.push(next);
+ return { committed: true };
+ },
+ async getIndex(id) { return clone(indexes.get(String(id || '')) || []); },
+ async resolveIndex() { return activeId ? clone(indexes.get(activeId) || []) : []; },
+ async import({ id, configuration, index }) {
+ const normalizedId = String(id || '').trim();
+ indexes.set(normalizedId, clone(Array.isArray(index) ? index : []));
+ await this.updateConfiguration(Object.assign({}, configuration || {}, { id: normalizedId, key: normalizedId }));
+ return { committed: true };
+ },
+ async remove(id) {
+ const normalizedId = String(id || '').trim();
+ indexes.delete(normalizedId);
+ configurations = configurations.filter((item) => item && item.id !== normalizedId && item.key !== normalizedId);
+ return { committed: true };
+ }
},
- setItem(key, value) {
- localStorageState.set(key, String(value));
+ practice: {
+ async list() { return clone(practiceRecords); }
},
- removeItem(key) {
- localStorageState.delete(key);
+ preferences: {
+ async getResourceBasePrefix() { return resourceBasePrefix; },
+ async setResourceBasePrefix(value) { resourceBasePrefix = String(value || ''); }
}
};
const windowStub = {
console: { log() {}, warn() {}, error() {}, info() {} },
- localStorage,
- storage: {
- async get(key, fallback = null) {
- return storageState.has(key) ? clone(storageState.get(key)) : clone(fallback);
- },
- async set(key, value) {
- storageState.set(key, clone(value));
- return true;
- },
- async remove(key) {
- storageState.delete(key);
- return true;
- }
- },
+ AppData,
__READING_EXAM_INDEX__: clone(defaultReadingIndex),
getReadingExamIndex() {
return clone(defaultReadingIndex);
@@ -67,13 +88,6 @@ function createHarness(seed = {}) {
__defaultListeningLibraryAvailable: typeof seed.defaultListeningAvailable === 'boolean'
? seed.defaultListeningAvailable
: undefined,
- getExamIndexState() {
- return clone(appState.examIndex);
- },
- setExamIndexState(next) {
- appState.examIndex = clone(Array.isArray(next) ? next : []);
- return clone(appState.examIndex);
- },
assignExamSequenceNumbers(list) {
(Array.isArray(list) ? list : []).forEach((exam, index) => {
if (exam && typeof exam === 'object') {
@@ -100,7 +114,6 @@ function createHarness(seed = {}) {
window: windowStub,
globalThis: windowStub,
console: windowStub.console,
- localStorage,
CustomEvent: class CustomEvent {
constructor(type, init = {}) {
this.type = type;
@@ -127,7 +140,7 @@ function createHarness(seed = {}) {
loadScript('js/core/resourceCore.js', context);
loadScript('js/services/libraryDiscovery.js', context);
loadScript('js/services/libraryManager.js', context);
- return { window: windowStub, storageState, appState };
+ return { window: windowStub, indexes, getActiveId: () => activeId, getConfigurations: () => clone(configurations) };
}
function baseSeed() {
@@ -161,16 +174,14 @@ function baseSeed() {
readingA,
listeningOld,
records,
- storage: {
- active_exam_index_key: 'custom_active',
- custom_active: [readingA, listeningOld],
- exam_index: [readingA, listeningOld],
- exam_index_configurations: [
- { name: '当前题库', key: 'custom_active', examCount: 2, timestamp: 1 },
- { name: '默认题库', key: 'exam_index', examCount: 2, timestamp: 1 }
- ],
- practice_records: records
+ library: {
+ activeConfigurationId: 'custom_active',
+ importedIndexes: { custom_active: [readingA, listeningOld] },
+ configurations: [
+ { id: 'custom_active', name: '当前题库', key: 'custom_active', examCount: 2, timestamp: 1 }
+ ]
},
+ records,
readingExamIndex: [readingA],
listeningExamIndex: [listeningOld]
};
@@ -205,14 +216,14 @@ async function testFullListeningCreatesSnapshotAndKeepsReading() {
});
assert.notStrictEqual(created.key, 'custom_active', '导入必须创建新配置,不能污染当前配置');
- assert.strictEqual(await window.storage.get('active_exam_index_key'), created.key, '新配置应成为活动配置');
- const oldConfig = await window.storage.get('custom_active');
+ assert.strictEqual(await window.AppData.library.getActive(), created.key, '新配置应成为活动配置');
+ const oldConfig = await window.AppData.library.getIndex('custom_active');
assert.deepStrictEqual(oldConfig, [seed.readingA, seed.listeningOld], '旧配置索引必须原样保留');
- const next = await window.storage.get(created.key);
+ const next = await window.AppData.library.getIndex(created.key);
assert(next.some((exam) => exam.id === 'reading-a'), '听力全量导入必须继承阅读索引');
assert(next.some((exam) => exam.id === 'listening-new'), '新听力题必须进入新配置');
assert(!next.some((exam) => exam.id === 'listening-old'), '听力全量导入应替换旧听力索引');
- assert.deepStrictEqual(await window.storage.get('practice_records'), seed.records, '导入配置不能修改练习记录');
+ assert.deepStrictEqual(await window.AppData.practice.list(), seed.records, '导入配置不能修改练习记录');
assert.strictEqual(created.counts.reading, 1);
assert.strictEqual(created.counts.listening, 1);
@@ -241,12 +252,12 @@ async function testFullReadingCreatesSnapshotAndKeepsListening() {
activate: true
});
- const next = await window.storage.get(created.key);
+ const next = await window.AppData.library.getIndex(created.key);
assert(next.some((exam) => exam.id === 'reading-new'), '阅读全量导入必须进入新配置');
assert(!next.some((exam) => exam.id === 'reading-a'), '阅读全量导入应替换旧阅读索引');
assert(next.some((exam) => exam.id === 'listening-old'), '阅读全量导入必须继承听力索引');
- assert.deepStrictEqual(await window.storage.get('custom_active'), [seed.readingA, seed.listeningOld], '旧配置不能被阅读全量改写');
- assert.deepStrictEqual(await window.storage.get('practice_records'), seed.records, '阅读导入不能修改练习记录');
+ assert.deepStrictEqual(await window.AppData.library.getIndex('custom_active'), [seed.readingA, seed.listeningOld], '旧配置不能被阅读全量改写');
+ assert.deepStrictEqual(await window.AppData.practice.list(), seed.records, '阅读导入不能修改练习记录');
recordResult('阅读全量导入创建新配置并继承听力', { key: created.key, counts: created.counts });
}
@@ -280,20 +291,20 @@ async function testIncrementalCreatesSnapshotAndDedupes() {
assert.notStrictEqual(created.key, 'custom_active', '增量导入也必须创建新配置');
assert.strictEqual(created.merge.updated, 1, '同 importKey 的题源应更新');
assert.strictEqual(created.merge.added, 1, '新 importKey 的题源应追加');
- const next = await window.storage.get(created.key);
+ const next = await window.AppData.library.getIndex(created.key);
assert(next.some((exam) => exam.id === 'reading-a'), '增量导入必须保留阅读索引');
assert(next.some((exam) => exam.title === 'P2 Listening Updated'), '增量导入应更新旧题');
assert(next.some((exam) => exam.id === 'listening-extra'), '增量导入应追加新题');
assert(!next.some((exam) => exam.id === 'listening-old'), '同 importKey 旧题不应重复保留');
- assert.deepStrictEqual(await window.storage.get('custom_active'), [seed.readingA, seed.listeningOld], '增量导入不能改写原活动配置');
- assert.deepStrictEqual(await window.storage.get('practice_records'), seed.records, '增量导入不能修改练习记录');
+ assert.deepStrictEqual(await window.AppData.library.getIndex('custom_active'), [seed.readingA, seed.listeningOld], '增量导入不能改写原活动配置');
+ assert.deepStrictEqual(await window.AppData.practice.list(), seed.records, '增量导入不能修改练习记录');
recordResult('增量导入创建新配置并按 importKey 去重更新', { key: created.key, merge: created.merge });
}
async function testSwitchConfigurationDoesNotTouchPracticeRecords() {
const seed = baseSeed();
- seed.storage.alt_config = [{
+ seed.library.importedIndexes.alt_config = [{
id: 'listening-alt',
examId: 'listening-alt',
type: 'listening',
@@ -302,23 +313,23 @@ async function testSwitchConfigurationDoesNotTouchPracticeRecords() {
path: 'Alt/',
filename: 'alt.html'
}];
- seed.storage.exam_index_configurations.push({ name: 'Alt', key: 'alt_config', examCount: 1, timestamp: 2 });
+ seed.library.configurations.push({ id: 'alt_config', name: 'Alt', key: 'alt_config', examCount: 1, timestamp: 2 });
const { window } = createHarness(seed);
const manager = window.LibraryManager.getInstance();
- const before = await window.storage.get('practice_records');
+ const before = await window.AppData.practice.list();
const applied = await manager.applyLibraryConfiguration('alt_config');
assert.strictEqual(applied, true, '配置切换应该成功');
- assert.strictEqual(await window.storage.get('active_exam_index_key'), 'alt_config', '活动配置应切换到目标配置');
- assert.deepStrictEqual(await window.storage.get('practice_records'), before, '配置切换不能触碰练习记录');
+ assert.strictEqual(await window.AppData.library.getActive(), 'alt_config', '活动配置应切换到目标配置');
+ assert.deepStrictEqual(await window.AppData.practice.list(), before, '配置切换不能触碰练习记录');
recordResult('切换题库配置不触碰练习记录', { activeKey: 'alt_config' });
}
async function testDeleteInactiveConfigurationCleansDatasetAndPathMap() {
const seed = baseSeed();
- seed.storage.delete_me = [{
+ seed.library.importedIndexes.delete_me = [{
id: 'delete-me-listening',
examId: 'delete-me-listening',
type: 'listening',
@@ -327,24 +338,19 @@ async function testDeleteInactiveConfigurationCleansDatasetAndPathMap() {
path: 'DeleteMe/',
filename: 'delete.html'
}];
- seed.storage['exam_path_map__delete_me'] = {
- reading: { root: 'ReadingCustom/', exceptions: {} },
- listening: { root: 'DeleteMe/', exceptions: {} }
- };
- seed.storage.exam_index_configurations.push({ name: 'Delete Me', key: 'delete_me', examCount: 1, timestamp: 3 });
+ seed.library.configurations.push({ id: 'delete_me', name: 'Delete Me', key: 'delete_me', examCount: 1, timestamp: 3 });
const { window } = createHarness(seed);
const manager = window.LibraryManager.getInstance();
- const before = await window.storage.get('practice_records');
+ const before = await window.AppData.practice.list();
const result = await manager.deleteLibraryConfiguration('delete_me');
assert.strictEqual(result.deleted, true, '非活动自定义配置应允许删除');
- assert.strictEqual(await window.storage.get('delete_me', null), null, '删除配置时必须删除对应题库数据集');
- assert.strictEqual(await window.storage.get('exam_path_map__delete_me', null), null, '删除配置时必须清理对应 path map');
- const configs = await window.storage.get('exam_index_configurations', []);
+ assert.deepStrictEqual(await window.AppData.library.getIndex('delete_me'), [], '删除配置时必须删除对应题库数据集');
+ const configs = await window.AppData.library.listConfigurations();
assert(!configs.some((config) => config && config.key === 'delete_me'), '配置列表中不应残留已删除配置');
- assert.deepStrictEqual(await window.storage.get('practice_records'), before, '删除题库配置不能删除练习记录');
- assert.strictEqual(await window.storage.get('active_exam_index_key'), 'custom_active', '删除非活动配置不能改变当前活动配置');
+ assert.deepStrictEqual(await window.AppData.practice.list(), before, '删除题库配置不能删除练习记录');
+ assert.strictEqual(await window.AppData.library.getActive(), 'custom_active', '删除非活动配置不能改变当前活动配置');
recordResult('删除非活动配置会清理数据集和 path map 且保留练习记录', { key: 'delete_me' });
}
@@ -353,18 +359,18 @@ async function testDeleteConfigurationGuardsDefaultAndActive() {
const seed = baseSeed();
const { window } = createHarness(seed);
const manager = window.LibraryManager.getInstance();
- const beforeConfigs = await window.storage.get('exam_index_configurations');
- const beforeRecords = await window.storage.get('practice_records');
+ const beforeConfigs = await window.AppData.library.listConfigurations();
+ const beforeRecords = await window.AppData.practice.list();
- const defaultResult = await manager.deleteLibraryConfiguration('exam_index');
+ const defaultResult = await manager.deleteLibraryConfiguration('');
const activeResult = await manager.deleteLibraryConfiguration('custom_active');
assert.strictEqual(defaultResult.deleted, false, '默认配置不能删除');
- assert.strictEqual(defaultResult.reason, 'default-config', '默认配置删除应返回明确原因');
+ assert.strictEqual(defaultResult.reason, 'invalid-key', 'nullable 默认配置没有可删除实体,应返回 invalid-key');
assert.strictEqual(activeResult.deleted, false, '当前活动配置不能删除');
assert.strictEqual(activeResult.reason, 'active-config', '活动配置删除应返回明确原因');
- assert.deepStrictEqual(await window.storage.get('exam_index_configurations'), beforeConfigs, '受保护配置删除不应改写配置列表');
- assert.deepStrictEqual(await window.storage.get('practice_records'), beforeRecords, '受保护配置删除不应改写练习记录');
+ assert.deepStrictEqual(await window.AppData.library.listConfigurations(), beforeConfigs, '受保护配置删除不应改写配置列表');
+ assert.deepStrictEqual(await window.AppData.practice.list(), beforeRecords, '受保护配置删除不应改写练习记录');
recordResult('删除配置保护默认和当前活动配置', {
defaultReason: defaultResult.reason,
@@ -392,7 +398,6 @@ async function testDefaultLibrarySkipsListeningWithoutManifest() {
filename: 'listening.html'
};
const { window } = createHarness({
- storage: { active_exam_index_key: 'exam_index' },
readingExamIndex: [readingDefault],
listeningExamIndex: [listeningDefault],
defaultListeningAvailable: false
@@ -427,7 +432,6 @@ async function testDefaultLibraryKeepsListeningWithManifest() {
filename: 'listening.html'
};
const { window } = createHarness({
- storage: { active_exam_index_key: 'exam_index' },
readingExamIndex: [readingDefault],
listeningExamIndex: [listeningDefault],
listeningManifest: { 'default-listening': { examId: 'default-listening' } },
@@ -443,6 +447,58 @@ async function testDefaultLibraryKeepsListeningWithManifest() {
recordResult('manifest 存在时默认题库加载内置听力', { loadedCount: loaded.length });
}
+async function testBrokenLegacyActiveLibraryFallsBackToReadingManifest() {
+ const readingDefault = {
+ id: 'default-reading-after-repair',
+ examId: 'default-reading-after-repair',
+ type: 'reading',
+ title: 'Default Reading After Repair',
+ category: 'P1',
+ path: 'Reading/default-repair/',
+ filename: 'reading.html'
+ };
+ const { window, getActiveId } = createHarness({
+ library: {
+ activeConfigurationId: 'exam_index',
+ configurations: [{ id: 'exam_index', key: 'exam_index', name: '错误迁移的默认题库' }],
+ importedIndexes: {}
+ },
+ readingExamIndex: [readingDefault],
+ defaultListeningAvailable: false
+ });
+ const manager = window.LibraryManager.getInstance();
+ const loaded = await manager.loadActiveLibrary(true);
+
+ assert.deepStrictEqual(loaded.map((exam) => exam.id), ['default-reading-after-repair'], 'a broken v1 active key must not hide the generated Reading manifest');
+ assert.strictEqual(getActiveId(), 'exam_index', 'display fallback must not rewrite persistent selection state');
+ recordResult('错误迁移的 v1 活动题库回退 Reading manifest', { loadedCount: loaded.length });
+}
+
+async function testForceReloadKeepsHealthyCustomLibraryActive() {
+ const customExam = {
+ id: 'healthy-custom-reading',
+ examId: 'healthy-custom-reading',
+ type: 'reading',
+ title: 'Healthy Custom Reading',
+ category: 'P1',
+ path: 'Imported/healthy/',
+ filename: 'reading.html'
+ };
+ const { window, getActiveId } = createHarness({
+ library: {
+ activeConfigurationId: 'healthy-custom',
+ configurations: [{ id: 'healthy-custom', key: 'healthy-custom', name: '健康自定义题库' }],
+ importedIndexes: { 'healthy-custom': [customExam] }
+ },
+ readingExamIndex: [{ id: 'default-must-not-replace-custom', type: 'reading' }]
+ });
+ const loaded = await window.LibraryManager.getInstance().loadActiveLibrary(true);
+
+ assert.deepStrictEqual(loaded.map((exam) => exam.id), ['healthy-custom-reading']);
+ assert.strictEqual(getActiveId(), 'healthy-custom', 'force reload must not switch a healthy custom library to default');
+ recordResult('强制刷新保留健康自定义题库', { loadedCount: loaded.length });
+}
+
async function testFullReadingDoesNotReAddDefaultListeningWhenManifestMissing() {
const readingNew = {
id: 'reading-new-default',
@@ -464,7 +520,6 @@ async function testFullReadingDoesNotReAddDefaultListeningWhenManifestMissing()
filename: 'hidden.html'
};
const { window } = createHarness({
- storage: { active_exam_index_key: 'exam_index', exam_index: [] },
readingExamIndex: [],
listeningExamIndex: [defaultListening],
defaultListeningAvailable: false
@@ -476,7 +531,7 @@ async function testFullReadingDoesNotReAddDefaultListeningWhenManifestMissing()
additions: [readingNew],
activate: true
});
- const next = await window.storage.get(created.key);
+ const next = await window.AppData.library.getIndex(created.key);
assert(next.some((exam) => exam.id === 'reading-new-default'), '阅读导入应保存新阅读');
assert(!next.some((exam) => exam.id === 'default-listening-hidden'), 'manifest 缺失时阅读全量导入不能补回默认听力');
@@ -485,6 +540,78 @@ async function testFullReadingDoesNotReAddDefaultListeningWhenManifestMissing()
recordResult('阅读全量导入不会补回缺 manifest 的默认听力', { key: created.key, counts: created.counts });
}
+async function testRecordResolverUsesStoredLibraryProvenance() {
+ const defaultExam = {
+ id: 'shared-exam-id',
+ examId: 'shared-exam-id',
+ type: 'reading',
+ title: 'Default source',
+ category: 'P1',
+ path: 'Reading/default-source/'
+ };
+ const importedExam = Object.assign({}, defaultExam, {
+ title: 'Imported source',
+ category: 'P4',
+ path: 'Reading/imported-source/'
+ });
+ const { window } = createHarness({
+ library: {
+ activeConfigurationId: 'library_imported_source',
+ configurations: [{ id: 'library_imported_source', key: 'library_imported_source', name: 'Imported source' }],
+ importedIndexes: { library_imported_source: [importedExam] }
+ },
+ readingExamIndex: [defaultExam]
+ });
+
+ const defaultResolved = await window.resolveExamForPracticeRecord({
+ examId: 'shared-exam-id',
+ metadata: { libraryConfigurationId: null }
+ });
+ const importedResolved = await window.resolveExamForPracticeRecord({
+ examId: 'shared-exam-id',
+ metadata: { libraryConfigurationId: 'library_imported_source' }
+ });
+ const unknownResolved = await window.resolveExamForPracticeRecord({ examId: 'shared-exam-id' });
+
+ assert.strictEqual(defaultResolved.title, 'Default source', 'default provenance must not use the active imported library');
+ assert.strictEqual(importedResolved.title, 'Imported source', 'imported provenance must resolve its own authoritative index');
+ assert.strictEqual(unknownResolved, null, 'with custom libraries present, a provenance-less legacy record must not guess the active library and risk resolving a shared examId to the wrong exam');
+ recordResult('历史记录按保存的题库 provenance 解析', {
+ defaultTitle: defaultResolved.title,
+ importedTitle: importedResolved.title
+ });
+}
+
+// Conditional-downgrade contract: a v1-migrated record that never got a
+// libraryConfigurationId must still be replayable when the user has NO custom
+// libraries — the examId can only mean the one default-library exam, so falling
+// back to the active index is safe and restores v1 behavior. This guards against
+// the v2 hard gate that made every provenance-less legacy record fail to open.
+async function testRecordResolverFallsBackForLegacyRecordsWhenNoCustomLibraries() {
+ const defaultExam = {
+ id: 'legacy-exam-id',
+ examId: 'legacy-exam-id',
+ type: 'reading',
+ title: 'Default source',
+ category: 'P1',
+ path: 'Reading/default-source/'
+ };
+ const { window } = createHarness({
+ library: {
+ activeConfigurationId: null,
+ configurations: [],
+ importedIndexes: {}
+ },
+ readingExamIndex: [defaultExam]
+ });
+
+ const resolved = await window.resolveExamForPracticeRecord({ examId: 'legacy-exam-id' });
+
+ assert.ok(resolved, 'a provenance-less legacy record must still resolve when no custom library can cause ambiguity');
+ assert.strictEqual(resolved.title, 'Default source', 'the fallback must resolve against the active (default) library');
+ recordResult('无自定义题库时旧记录回退当前题库解析', { title: resolved.title });
+}
+
async function main() {
try {
await testFullListeningCreatesSnapshotAndKeepsReading();
@@ -495,7 +622,11 @@ async function main() {
await testDeleteConfigurationGuardsDefaultAndActive();
await testDefaultLibrarySkipsListeningWithoutManifest();
await testDefaultLibraryKeepsListeningWithManifest();
+ await testBrokenLegacyActiveLibraryFallsBackToReadingManifest();
+ await testForceReloadKeepsHealthyCustomLibraryActive();
await testFullReadingDoesNotReAddDefaultListeningWhenManifestMissing();
+ await testRecordResolverUsesStoredLibraryProvenance();
+ await testRecordResolverFallsBackForLegacyRecordsWhenNoCustomLibraries();
console.log(JSON.stringify({
status: 'pass',
detail: `${results.length}/${results.length} 测试通过`,
diff --git a/developer/tests/js/listeningRecordBridgeParser.test.js b/developer/tests/js/listeningRecordBridgeParser.test.js
new file mode 100644
index 00000000..f05ccd4f
--- /dev/null
+++ b/developer/tests/js/listeningRecordBridgeParser.test.js
@@ -0,0 +1,115 @@
+import assert from 'assert';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import '../../../js/utils/safeObjectLiteralParser.js';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const parser = globalThis.SafeObjectLiteralParser;
+
+function assertRejected(source, pattern) {
+ assert.throws(
+ () => parser.parse(source),
+ pattern || /SafeObjectLiteralParseError/,
+ `应拒绝: ${source}`
+ );
+}
+
+function testLegacyDataSyntax() {
+ const value = parser.parse(`
+ {
+ answerKey: {
+ text: { q1: 'accommodation', 2: "library", },
+ matching: { q2: 'B' }
+ },
+ sections: [true, false, null, -1.5e2,],
+ // 兼容题库行注释
+ title: 'Listening \\\\ Practice',
+ /* 兼容块注释 */
+ }
+ `);
+
+ assert.strictEqual(Object.getPrototypeOf(value), null);
+ assert.strictEqual(Object.getPrototypeOf(value.answerKey), null);
+ assert.strictEqual(value.answerKey.text.q1, 'accommodation');
+ assert.strictEqual(value.answerKey.text['2'], 'library');
+ assert.deepStrictEqual(value.sections, [true, false, null, -150]);
+ assert.strictEqual(value.title, 'Listening \\ Practice');
+}
+
+function testParseAtStopsAfterLiteral() {
+ const source = " /* config */ { answerKey: { text: { q31: 'accommodation' } } }; runLater()";
+ const result = parser.parseAt(source, 0);
+ assert.strictEqual(result.value.answerKey.text.q31, 'accommodation');
+ assert.strictEqual(source.slice(result.endIndex).trim(), '; runLater()');
+}
+
+function testExecutableSyntaxIsRejected() {
+ global.__listeningParserSentinel = 0;
+ const attacks = [
+ "{ value: (global.__listeningParserSentinel = 1) }",
+ "{ value: global.__listeningParserSentinel }",
+ "{ value: (() => 1)() }",
+ "{ get value() { global.__listeningParserSentinel = 1; } }",
+ "{ ...global.__payload }",
+ "{ [global.__key]: 1 }",
+ "{ value: `template` }",
+ "{ value: /regex/ }",
+ "{ value: undefined }",
+ "{ value: NaN }",
+ "{ value: Infinity }",
+ "{ shorthand }"
+ ];
+ attacks.forEach((source) => assertRejected(source));
+ assert.strictEqual(global.__listeningParserSentinel, 0, '恶意输入不得执行');
+ delete global.__listeningParserSentinel;
+}
+
+function testPrototypePollutionKeysAreRejected() {
+ assertRejected("{ __proto__: { polluted: true } }", /forbidden object key/);
+ assertRejected("{ constructor: { prototype: { polluted: true } } }", /forbidden object key/);
+ assertRejected("{ 'prototype': {} }", /forbidden object key/);
+ assert.strictEqual({}.polluted, undefined);
+}
+
+function testLimitsAndMalformedInput() {
+ assert.throws(
+ () => parser.parse('{ a: { b: { c: 1 } } }', { maxDepth: 2 }),
+ /maximum nesting depth/
+ );
+ assertRejected("{ value: 'unterminated }", /unterminated string/);
+ assertRejected('{ value: 1 /* unterminated }', /unterminated block comment/);
+ assert.throws(
+ () => parser.parse('{ a: 1, b: 2 }', { maxProperties: 1 }),
+ /maximum property count/
+ );
+}
+
+function testBridgeHasNoDynamicCodeExecution() {
+ const bridgePath = path.join(__dirname, '../../../js/listeningRecordBridge.js');
+ const source = fs.readFileSync(bridgePath, 'utf8');
+ assert.ok(!/\bnew\s+Function\s*\(/.test(source), 'bridge 不得使用 new Function');
+ assert.ok(!/\beval\s*\(/.test(source), 'bridge 不得使用 eval');
+ assert.ok(source.includes('SafeObjectLiteralParser.parseAt'), 'bridge 应使用纯数据解析器');
+}
+
+function run() {
+ testLegacyDataSyntax();
+ testParseAtStopsAfterLiteral();
+ testExecutableSyntaxIsRejected();
+ testPrototypePollutionKeysAreRejected();
+ testLimitsAndMalformedInput();
+ testBridgeHasNoDynamicCodeExecution();
+ console.log(JSON.stringify({
+ status: 'pass',
+ detail: 'listening bridge safe object-literal parser regression checks passed'
+ }));
+}
+
+try {
+ run();
+} catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+}
diff --git a/developer/tests/js/listeningRecordBridgeProtocol.test.js b/developer/tests/js/listeningRecordBridgeProtocol.test.js
new file mode 100644
index 00000000..a4f82216
--- /dev/null
+++ b/developer/tests/js/listeningRecordBridgeProtocol.test.js
@@ -0,0 +1,208 @@
+import assert from 'assert';
+import fs from 'fs';
+import path from 'path';
+import vm from 'vm';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const repoRoot = path.resolve(__dirname, '../../..');
+const bridgeSource = fs.readFileSync(path.join(repoRoot, 'js/listeningRecordBridge.js'), 'utf8');
+
+function createHarness() {
+ const posted = [];
+ const messageListeners = [];
+ const timers = [];
+ let nextTimerId = 1;
+
+ const parentWindow = {
+ postMessage(message, targetOrigin) {
+ posted.push({ message, targetOrigin });
+ }
+ };
+ const answerInput = { value: 'accommodation' };
+ const document = {
+ readyState: 'complete',
+ title: 'Listening protocol test',
+ referrer: '',
+ body: null,
+ documentElement: {},
+ activeElement: null,
+ addEventListener() {},
+ querySelector(selector) {
+ return selector === '[name="q1"]' ? answerInput : null;
+ },
+ querySelectorAll() {
+ return [];
+ },
+ createElement() {
+ return {
+ innerHTML: '',
+ querySelector() { return null; },
+ querySelectorAll() { return []; }
+ };
+ }
+ };
+ const window = {
+ document,
+ opener: parentWindow,
+ parent: null,
+ location: {
+ protocol: 'file:',
+ href: 'file:///fixtures/listening.html?examId=listening-protocol',
+ pathname: '/fixtures/listening.html'
+ },
+ crypto: {
+ randomUUID() {
+ return 'fixed-submission';
+ }
+ },
+ App: {
+ state: { isReviewing: true },
+ config: {
+ questionList: [1],
+ answerKey: {
+ text: { q1: 'accommodation' }
+ }
+ }
+ },
+ addEventListener(type, listener) {
+ if (type === 'message') messageListeners.push(listener);
+ }
+ };
+ window.parent = window;
+
+ const sandbox = {
+ window,
+ document,
+ URL,
+ Uint8Array,
+ console: {
+ log() {},
+ warn() {},
+ error() {}
+ },
+ setInterval(callback, delay) {
+ const timer = { id: nextTimerId++, callback, delay, interval: true, cancelled: false };
+ timers.push(timer);
+ return timer.id;
+ },
+ clearInterval(id) {
+ const timer = timers.find((item) => item.id === id);
+ if (timer) timer.cancelled = true;
+ },
+ setTimeout(callback, delay) {
+ const timer = { id: nextTimerId++, callback, delay, interval: false, cancelled: false };
+ timers.push(timer);
+ return timer.id;
+ },
+ clearTimeout(id) {
+ const timer = timers.find((item) => item.id === id);
+ if (timer) timer.cancelled = true;
+ }
+ };
+ sandbox.globalThis = sandbox;
+ vm.runInContext(bridgeSource, vm.createContext(sandbox), {
+ filename: 'js/listeningRecordBridge.js'
+ });
+
+ assert.strictEqual(messageListeners.length, 1, 'bridge must register one host message listener');
+ return {
+ window,
+ parentWindow,
+ posted,
+ timers,
+ dispatch(type, data, overrides = {}) {
+ messageListeners[0]({
+ source: overrides.source || parentWindow,
+ origin: overrides.origin === undefined ? 'null' : overrides.origin,
+ data: {
+ type,
+ source: overrides.messageSource || 'exam_host',
+ data
+ }
+ });
+ }
+ };
+}
+
+function messagesOf(harness, type) {
+ return harness.posted.filter((entry) => entry.message && entry.message.type === type);
+}
+
+function run() {
+ const harness = createHarness();
+ const state = harness.window.__listeningBridgeGetState();
+
+ assert.strictEqual(harness.window.__listeningBridgeComplete(), true);
+ assert(state.pendingCompletion, 'pre-INIT completion must be retained');
+ assert.strictEqual(state.pendingCompletion.submissionId, 'listening-submit-fixed-submission');
+ assert.strictEqual(state.completed, false, 'completion cannot settle before persistence ACK');
+ assert.strictEqual(messagesOf(harness, 'PRACTICE_COMPLETE').length, 0, 'pre-INIT must not emit completion');
+ assert(messagesOf(harness, 'REQUEST_INIT').length >= 1, 'pre-INIT completion must request initialization');
+ assert(harness.posted.every((entry) => entry.targetOrigin === '*'), 'file bridge sends must use wildcard targetOrigin');
+
+ harness.dispatch('INIT_SESSION', {
+ examId: 'listening-protocol',
+ sessionId: 'host-session',
+ windowSessionToken: 'host-token',
+ parentOrigin: 'null',
+ startTime: 1000
+ });
+
+ const firstCompletion = messagesOf(harness, 'PRACTICE_COMPLETE').at(-1);
+ assert(firstCompletion, 'trusted INIT must flush pending completion');
+ assert.strictEqual(firstCompletion.message.data.submissionId, 'listening-submit-fixed-submission');
+ assert.strictEqual(firstCompletion.message.data.sessionId, 'host-session');
+ assert.strictEqual(firstCompletion.message.data.windowSessionToken, 'host-token');
+ assert.strictEqual(state.completed, false, 'emission alone must not mark completion');
+
+ const retryTimer = harness.timers.find((timer) => !timer.interval && timer.delay === 400 && !timer.cancelled);
+ assert(retryTimer, 'completion must schedule a persistence retry');
+ retryTimer.callback();
+ const completionMessages = messagesOf(harness, 'PRACTICE_COMPLETE');
+ assert.strictEqual(completionMessages.length, 2, 'timeout must resend completion');
+ assert.strictEqual(
+ completionMessages[0].message.data.submissionId,
+ completionMessages[1].message.data.submissionId,
+ 'retry must reuse the same submissionId'
+ );
+
+ harness.dispatch('PRACTICE_SUBMIT_ACK', {
+ submissionId: 'forged-submission',
+ sessionId: 'host-session',
+ windowSessionToken: 'host-token'
+ });
+ assert.strictEqual(state.completed, false, 'mismatched submission ACK must be ignored');
+
+ harness.dispatch('PRACTICE_SUBMIT_ACK', {
+ submissionId: 'listening-submit-fixed-submission',
+ sessionId: 'host-session',
+ windowSessionToken: 'host-token'
+ }, { origin: 'https://forged.example' });
+ assert.strictEqual(state.completed, false, 'wrong-origin ACK must be ignored');
+
+ harness.dispatch('PRACTICE_SUBMIT_ACK', {
+ submissionId: 'listening-submit-fixed-submission',
+ sessionId: 'host-session',
+ windowSessionToken: 'host-token'
+ });
+ assert.strictEqual(state.completed, true, 'trusted persisted ACK must settle completion');
+ assert.strictEqual(state.pendingCompletion, null, 'settled completion must release pending payload');
+ assert(
+ harness.timers.filter((timer) => !timer.interval).every((timer) => timer.cancelled),
+ 'trusted ACK must cancel every completion retry'
+ );
+
+ console.log(JSON.stringify({
+ status: 'pass',
+ detail: 'listening pre-INIT completion, same-submission retry and persisted ACK checks passed'
+ }));
+}
+
+try {
+ run();
+} catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+}
diff --git a/developer/tests/js/markdownExporter.test.js b/developer/tests/js/markdownExporter.test.js
index 4f556dc6..18312db0 100644
--- a/developer/tests/js/markdownExporter.test.js
+++ b/developer/tests/js/markdownExporter.test.js
@@ -12,8 +12,11 @@ const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '../../..');
const results = [];
-function loadExporter() {
+function loadExporter(options = {}) {
const windowStub = {};
+ if (typeof options.resolveExamForPracticeRecord === 'function') {
+ windowStub.resolveExamForPracticeRecord = options.resolveExamForPracticeRecord;
+ }
const sandbox = {
window: windowStub,
document: {
@@ -143,11 +146,44 @@ async function testCanonicalCorrectAnswerMapWins() {
});
}
+async function testHistoricalMetadataWinsDuringExport() {
+ const exporter = loadExporter({
+ async resolveExamForPracticeRecord() {
+ return {
+ id: 'shared-id',
+ title: 'Current library title',
+ category: 'P4',
+ frequency: 'current'
+ };
+ }
+ });
+ const grouped = await exporter.groupRecordsByDateAsync([{
+ id: 'history-1',
+ examId: 'shared-id',
+ date: '2026-07-25T10:00:00.000Z',
+ title: 'Saved title',
+ metadata: {
+ libraryConfigurationId: 'library_original',
+ category: 'P1',
+ frequency: 'saved'
+ }
+ }]);
+ const exported = grouped['2026-07-25'][0];
+ assert.strictEqual(exported.title, 'Saved title', '导出不得用解析出的题目覆盖历史标题');
+ assert.strictEqual(exported.category, 'P1', '导出不得用解析出的题目覆盖历史分类');
+ assert.strictEqual(exported.frequency, 'saved', '导出不得用解析出的题目覆盖历史频次');
+ recordResult('historical metadata wins over resolved library metadata during export', true, {
+ title: exported.title,
+ category: exported.category
+ });
+}
+
async function runAllTests() {
const tests = [
testNumericCorrectAnswersAreNotAnswerMaps,
testPlainCorrectAnswerMapsStillWork,
- testCanonicalCorrectAnswerMapWins
+ testCanonicalCorrectAnswerMapWins,
+ testHistoricalMetadataWinsDuringExport
];
for (const testFn of tests) {
try {
diff --git a/developer/tests/js/onDemandEntrypoints.test.js b/developer/tests/js/onDemandEntrypoints.test.js
index b5546758..92e5c2d9 100644
--- a/developer/tests/js/onDemandEntrypoints.test.js
+++ b/developer/tests/js/onDemandEntrypoints.test.js
@@ -37,6 +37,9 @@ function createDocumentStub(inputState, buttonState) {
}
return null;
},
+ querySelectorAll() {
+ return [];
+ },
getElementById(id) {
if (id === 'exam-search-input') {
return inputState;
@@ -68,11 +71,18 @@ function createHarness() {
showMessage(message, type) {
messages.push({ message, type });
},
- getExamIndexState() {
+ async resolveActiveLibraryIndex() {
return [
{ id: 'reading-1', title: 'Ocean Passage', type: 'reading', hasHtml: true, path: 'Reading/set-1' }
];
},
+ AppData: {
+ ready: Promise.resolve(),
+ preferences: {
+ async getCandidateCode() { return null; },
+ async setCandidateCode(value) { return value; }
+ }
+ },
AppLazyLoader: {
ensureGroup(name) {
ensureCalls.push(name);
@@ -118,9 +128,7 @@ async function testRandomPracticeEnsuresBrowseRuntime(harness) {
loadScript('js/app/main-entry.js', harness.context);
loadScript('js/presentation/app-actions.js', harness.context);
- harness.windowStub.AppActions.startRandomPractice('all', 'reading');
- await Promise.resolve();
- await Promise.resolve();
+ await harness.windowStub.AppActions.startRandomPractice('all', 'reading');
assert(harness.ensureCalls.includes('browse-runtime'), '随机练习应主动确保 browse-runtime 已加载');
assert.strictEqual(harness.windowStub.__openedExamId, 'reading-1', '随机练习应在严格按需模式下仍能打开题目');
diff --git a/developer/tests/js/performanceBaseline.js b/developer/tests/js/performanceBaseline.js
deleted file mode 100644
index 45effbb2..00000000
--- a/developer/tests/js/performanceBaseline.js
+++ /dev/null
@@ -1,597 +0,0 @@
-/**
- * 性能基线测量工具
- * 测量FPS、内存使用、交互延迟等性能指标
- */
-
-class PerformanceBaseline {
- constructor() {
- this.metrics = {
- fps: [],
- memory: [],
- interactionDelay: [],
- renderTime: [],
- loadTime: null
- };
- this.isMonitoring = false;
- this.frameCount = 0;
- this.lastFrameTime = performance.now();
- this.monitoringInterval = null;
- this.baselineData = null;
- }
-
- // 开始性能监控
- startMonitoring(duration = 10000) {
- console.log('📊 开始性能基线测量...');
- this.isMonitoring = true;
- this.frameCount = 0;
- this.lastFrameTime = performance.now();
-
- // 清空之前的测量数据
- this.metrics = {
- fps: [],
- memory: [],
- interactionDelay: [],
- renderTime: [],
- loadTime: performance.now()
- };
-
- // 开始FPS监控
- this.startFPSMonitoring();
-
- // 开始内存监控
- this.startMemoryMonitoring();
-
- // 设置监控持续时间
- setTimeout(() => {
- this.stopMonitoring();
- }, duration);
-
- console.log(`⏱️ 性能监控已启动,将持续 ${duration}ms`);
- }
-
- // 停止性能监控
- stopMonitoring() {
- if (!this.isMonitoring) return;
-
- this.isMonitoring = false;
-
- if (this.monitoringInterval) {
- cancelAnimationFrame(this.monitoringInterval);
- this.monitoringInterval = null;
- }
-
- // 生成基线报告
- this.generateBaselineReport();
-
- console.log('📊 性能基线测量完成');
- }
-
- // 开始FPS监控
- startFPSMonitoring() {
- const measureFPS = () => {
- if (!this.isMonitoring) return;
-
- const currentTime = performance.now();
- const deltaTime = currentTime - this.lastFrameTime;
- const currentFPS = 1000 / deltaTime;
-
- this.metrics.fps.push(currentFPS);
- this.frameCount++;
- this.lastFrameTime = currentTime;
-
- this.monitoringInterval = requestAnimationFrame(measureFPS);
- };
-
- this.monitoringInterval = requestAnimationFrame(measureFPS);
- }
-
- // 开始内存监控
- startMemoryMonitoring() {
- const memoryInterval = setInterval(() => {
- if (!this.isMonitoring) {
- clearInterval(memoryInterval);
- return;
- }
-
- // 使用performance.memory API(Chrome支持)
- if (performance.memory) {
- const memoryInfo = {
- used: performance.memory.usedJSHeapSize,
- total: performance.memory.totalJSHeapSize,
- limit: performance.memory.jsHeapSizeLimit,
- timestamp: performance.now()
- };
- this.metrics.memory.push(memoryInfo);
- } else {
- // 降级方案:使用估算
- const estimatedMemory = this.estimateMemoryUsage();
- this.metrics.memory.push({
- used: estimatedMemory,
- total: estimatedMemory * 1.5,
- limit: estimatedMemory * 4,
- timestamp: performance.now(),
- estimated: true
- });
- }
- }, 1000); // 每秒测量一次
- }
-
- // 估算内存使用(降级方案)
- estimateMemoryUsage() {
- // 简单的内存估算:基于localStorage使用量 + DOM元素数量
- let storageSize = 0;
- for (let key in localStorage) {
- if (localStorage.hasOwnProperty(key)) {
- storageSize += localStorage[key].length + key.length;
- }
- }
-
- const domElements = document.getElementsByTagName('*').length;
- const estimatedDOMMemory = domElements * 200; // 每个DOM元素估算200字节
-
- return storageSize + estimatedDOMMemory + 1024 * 1024; // 基础1MB
- }
-
- // 测量交互延迟
- async measureInteractionDelay(element, action) {
- const startTime = performance.now();
-
- // 执行交互
- if (typeof action === 'function') {
- await action();
- } else if (element && typeof element[action] === 'function') {
- element[action]();
- }
-
- // 等待下一个渲染帧
- await new Promise(resolve => {
- requestAnimationFrame(resolve);
- });
-
- const endTime = performance.now();
- const delay = endTime - startTime;
-
- this.metrics.interactionDelay.push({
- action: action,
- delay,
- timestamp: performance.now()
- });
-
- return delay;
- }
-
- // 测量渲染时间
- measureRenderTime(element, content) {
- const startTime = performance.now();
-
- // 修改内容
- if (typeof content === 'string') {
- element.innerHTML = content;
- } else if (typeof content === 'function') {
- content(element);
- }
-
- // 等待渲染完成
- return new Promise(resolve => {
- requestAnimationFrame(() => {
- const endTime = performance.now();
- const renderTime = endTime - startTime;
-
- this.metrics.renderTime.push({
- elementType: element.tagName,
- renderTime,
- timestamp: performance.now()
- });
-
- resolve(renderTime);
- });
- });
- }
-
- // 测量大量数据渲染性能
- async measureLargeDataRendering(dataArray, containerElement) {
- console.log(`📏 测量渲染 ${dataArray.length} 条数据的性能...`);
-
- const startTime = performance.now();
-
- // 清空容器
- containerElement.innerHTML = '';
-
- // 分批渲染以避免阻塞
- const batchSize = 100;
- for (let i = 0; i < dataArray.length; i += batchSize) {
- const batch = dataArray.slice(i, i + batchSize);
- const fragment = document.createDocumentFragment();
-
- batch.forEach(item => {
- const div = document.createElement('div');
- div.className = 'test-item';
- div.textContent = item.title || JSON.stringify(item);
- fragment.appendChild(div);
- });
-
- containerElement.appendChild(fragment);
-
- // 让出控制权
- if (i % (batchSize * 10) === 0) {
- await new Promise(resolve => setTimeout(resolve, 0));
- }
- }
-
- const endTime = performance.now();
- const totalTime = endTime - startTime;
- const itemsPerSecond = dataArray.length / (totalTime / 1000);
-
- return {
- totalTime,
- itemsPerSecond,
- itemCount: dataArray.length
- };
- }
-
- // 生成基线报告
- generateBaselineReport() {
- if (this.metrics.fps.length === 0) {
- console.warn('⚠️ 没有足够的性能数据生成报告');
- return;
- }
-
- // 计算FPS统计
- const fpsStats = this.calculateStats(this.metrics.fps);
-
- // 计算内存统计
- const memoryStats = this.calculateMemoryStats();
-
- // 计算交互延迟统计
- const interactionStats = this.calculateInteractionStats();
-
- // 计算渲染时间统计
- const renderStats = this.calculateRenderStats();
-
- this.baselineData = {
- timestamp: new Date().toISOString(),
- measurementDuration: performance.now() - this.metrics.loadTime,
- fps: fpsStats,
- memory: memoryStats,
- interaction: interactionStats,
- render: renderStats,
- systemInfo: this.getSystemInfo()
- };
-
- console.log('📊 性能基线报告:', this.baselineData);
- this.displayBaselineReport();
- }
-
- // 计算统计数据
- calculateStats(values) {
- if (values.length === 0) return null;
-
- const sorted = [...values].sort((a, b) => a - b);
- const sum = values.reduce((acc, val) => acc + val, 0);
-
- return {
- min: sorted[0],
- max: sorted[sorted.length - 1],
- average: sum / values.length,
- median: sorted[Math.floor(sorted.length / 2)],
- p95: sorted[Math.floor(sorted.length * 0.95)],
- p99: sorted[Math.floor(sorted.length * 0.99)],
- sampleCount: values.length
- };
- }
-
- // 计算内存统计
- calculateMemoryStats() {
- if (this.metrics.memory.length === 0) return null;
-
- const usedMemory = this.metrics.memory.map(m => m.used);
- const totalMemory = this.metrics.memory.map(m => m.total);
- const limitMemory = this.metrics.memory.map(m => m.limit);
-
- return {
- used: this.calculateStats(usedMemory),
- total: this.calculateStats(totalMemory),
- limit: this.calculateStats(limitMemory),
- utilizationRate: {
- average: (usedMemory.reduce((a, b) => a + b, 0) / totalMemory.reduce((a, b) => a + b, 0)) * 100,
- peak: (Math.max(...usedMemory) / Math.max(...limitMemory)) * 100
- },
- estimated: this.metrics.memory.some(m => m.estimated)
- };
- }
-
- // 计算交互延迟统计
- calculateInteractionStats() {
- if (this.metrics.interactionDelay.length === 0) return null;
-
- const delays = this.metrics.interactionDelay.map(i => i.delay);
- const stats = this.calculateStats(delays);
-
- return {
- ...stats,
- samples: this.metrics.interactionDelay.map(i => ({
- action: i.action,
- delay: i.delay,
- timestamp: i.timestamp
- }))
- };
- }
-
- // 计算渲染时间统计
- calculateRenderStats() {
- if (this.metrics.renderTime.length === 0) return null;
-
- const renderTimes = this.metrics.renderTime.map(r => r.renderTime);
- const stats = this.calculateStats(renderTimes);
-
- return {
- ...stats,
- samples: this.metrics.renderTime.map(r => ({
- elementType: r.elementType,
- renderTime: r.renderTime,
- timestamp: r.timestamp
- }))
- };
- }
-
- // 获取系统信息
- getSystemInfo() {
- return {
- userAgent: navigator.userAgent,
- platform: navigator.platform,
- language: navigator.language,
- cookieEnabled: navigator.cookieEnabled,
- onLine: navigator.onLine,
- screen: {
- width: screen.width,
- height: screen.height,
- colorDepth: screen.colorDepth,
- pixelDepth: screen.pixelDepth
- },
- viewport: {
- width: window.innerWidth,
- height: window.innerHeight
- },
- devicePixelRatio: window.devicePixelRatio || 1,
- hardwareConcurrency: navigator.hardwareConcurrency || 'unknown'
- };
- }
-
- // 显示基线报告
- displayBaselineReport() {
- const report = this.baselineData;
- if (!report) return;
-
- console.log('\n🎯 性能基线测量结果');
- console.log('='.repeat(50));
-
- // FPS报告
- if (report.fps) {
- console.log('\n📺 FPS (帧率):');
- console.log(` 平均: ${report.fps.average.toFixed(2)} FPS`);
- console.log(` 最低: ${report.fps.min.toFixed(2)} FPS`);
- console.log(` 最高: ${report.fps.max.toFixed(2)} FPS`);
- console.log(` P95: ${report.fps.p95.toFixed(2)} FPS`);
- console.log(` P99: ${report.fps.p99.toFixed(2)} FPS`);
- }
-
- // 内存报告
- if (report.memory) {
- console.log('\n💾 内存使用:');
- console.log(` 平均使用: ${(report.memory.used.average / 1024 / 1024).toFixed(2)} MB`);
- console.log(` 峰值使用: ${(report.memory.used.max / 1024 / 1024).toFixed(2)} MB`);
- console.log(` 利用率: ${report.memory.utilizationRate.average.toFixed(2)}% (峰值: ${report.memory.utilizationRate.peak.toFixed(2)}%)`);
- if (report.memory.estimated) {
- console.log(' ⚠️ 基于估算,不是精确测量');
- }
- }
-
- // 交互延迟报告
- if (report.interaction) {
- console.log('\n⚡ 交互延迟:');
- console.log(` 平均: ${report.interaction.average.toFixed(2)} ms`);
- console.log(` 最快: ${report.interaction.min.toFixed(2)} ms`);
- console.log(` 最慢: ${report.interaction.max.toFixed(2)} ms`);
- console.log(` P95: ${report.interaction.p95.toFixed(2)} ms`);
- }
-
- // 渲染时间报告
- if (report.render) {
- console.log('\n🎨 渲染时间:');
- console.log(` 平均: ${report.render.average.toFixed(2)} ms`);
- console.log(` 最快: ${report.render.min.toFixed(2)} ms`);
- console.log(` 最慢: ${report.render.max.toFixed(2)} ms`);
- console.log(` P95: ${report.render.p95.toFixed(2)} ms`);
- }
-
- console.log('\n⏱️ 测量时长: ' + (report.measurementDuration / 1000).toFixed(2) + ' 秒');
- console.log('🕐 测量时间: ' + report.timestamp);
- console.log('='.repeat(50));
- }
-
- // 运行完整的性能基准测试
- async runFullBenchmark() {
- console.log('🚀 开始完整性能基准测试...');
-
- // 1. 基础性能监控
- this.startMonitoring(5000); // 5秒基础监控
-
- // 2. 等待基础监控完成
- await new Promise(resolve => setTimeout(resolve, 6000));
-
- // 3. DOM操作性能测试
- await this.testDOMPerformance();
-
- // 4. 数据处理性能测试
- await this testDataProcessingPerformance();
-
- // 5. UI交互性能测试
- await this.testUIInteractionPerformance();
-
- console.log('✅ 完整性能基准测试完成');
- return this.baselineData;
- }
-
- // DOM操作性能测试
- async testDOMPerformance() {
- console.log('🏗️ 测试DOM操作性能...');
-
- const container = document.createElement('div');
- container.id = 'performance-test-container';
- container.style.cssText = 'position: absolute; left: -9999px; top: -9999px; visibility: hidden;';
- document.body.appendChild(container);
-
- // 测试大量元素创建
- const elementCount = 1000;
- const elements = Array.from({ length: elementCount }, (_, i) => ({
- title: `测试元素 ${i}`,
- content: `内容 ${i}`
- }));
-
- const domResult = await this.measureLargeDataRendering(elements, container);
-
- console.log(` DOM渲染: ${elementCount} 个元素用时 ${domResult.totalTime.toFixed(2)}ms`);
- console.log(` 渲染速度: ${domResult.itemsPerSecond.toFixed(2)} 元素/秒`);
-
- // 清理测试元素
- document.body.removeChild(container);
- }
-
- // 数据处理性能测试
- async testDataProcessingPerformance() {
- console.log('📊 测试数据处理性能...');
-
- // 创建大量测试数据
- const largeDataSet = Array.from({ length: 10000 }, (_, i) => ({
- id: `item_${i}`,
- title: `测试数据项 ${i}`,
- category: ['reading', 'listening', 'writing', 'speaking'][i % 4],
- score: Math.floor(Math.random() * 100),
- date: new Date(Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
- metadata: {
- tags: Array.from({ length: 5 }, (_, j) => `tag_${i}_${j}`),
- priority: Math.floor(Math.random() * 10),
- status: ['active', 'completed', 'pending'][i % 3]
- }
- }));
-
- // 测试排序性能
- const sortStart = performance.now();
- const sortedData = [...largeDataSet].sort((a, b) => b.score - a.score);
- const sortTime = performance.now() - sortStart;
-
- // 测试过滤性能
- const filterStart = performance.now();
- const filteredData = largeDataSet.filter(item => item.category === 'reading' && item.score > 70);
- const filterTime = performance.now() - filterStart;
-
- // 测试搜索性能
- const searchStart = performance.now();
- const searchResults = largeDataSet.filter(item =>
- item.title.includes('测试数据项') && item.metadata.tags.some(tag => tag.includes('tag_'))
- );
- const searchTime = performance.now() - searchStart;
-
- console.log(` 数据排序: ${largeDataSet.length} 项用时 ${sortTime.toFixed(2)}ms`);
- console.log(` 数据过滤: 找到 ${filteredData.length} 项用时 ${filterTime.toFixed(2)}ms`);
- console.log(` 数据搜索: 找到 ${searchResults.length} 项用时 ${searchTime.toFixed(2)}ms`);
- }
-
- // UI交互性能测试
- async testUIInteractionPerformance() {
- console.log('🖱️ 测试UI交互性能...');
-
- // 创建测试按钮
- const testButton = document.createElement('button');
- testButton.id = 'performance-test-button';
- testButton.textContent = '性能测试按钮';
- testButton.style.cssText = 'position: absolute; left: -9999px; top: -9999px; visibility: hidden;';
- document.body.appendChild(testButton);
-
- // 测试点击延迟
- const clickDelays = [];
- for (let i = 0; i < 10; i++) {
- const delay = await this.measureInteractionDelay(testButton, 'click');
- clickDelays.push(delay);
- }
-
- // 测试输入延迟
- const testInput = document.createElement('input');
- testInput.id = 'performance-test-input';
- testInput.style.cssText = 'position: absolute; left: -9999px; top: -9999px; visibility: hidden;';
- document.body.appendChild(testInput);
-
- const inputDelays = [];
- for (let i = 0; i < 10; i++) {
- const delay = await this.measureInteractionDelay(testInput, () => {
- testInput.value = `测试输入 ${i}`;
- });
- inputDelays.push(delay);
- }
-
- const avgClickDelay = clickDelays.reduce((a, b) => a + b, 0) / clickDelays.length;
- const avgInputDelay = inputDelays.reduce((a, b) => a + b, 0) / inputDelays.length;
-
- console.log(` 点击延迟: 平均 ${avgClickDelay.toFixed(2)}ms`);
- console.log(` 输入延迟: 平均 ${avgInputDelay.toFixed(2)}ms`);
-
- // 清理测试元素
- document.body.removeChild(testButton);
- document.body.removeChild(testInput);
- }
-
- // 获取性能建议
- getPerformanceRecommendations() {
- if (!this.baselineData) return null;
-
- const recommendations = [];
- const report = this.baselineData;
-
- // FPS建议
- if (report.fps && report.fps.average < 30) {
- recommendations.push({
- category: 'FPS',
- severity: 'high',
- message: `平均FPS只有 ${report.fps.average.toFixed(1)},建议优化动画和渲染`,
- suggestions: ['减少DOM操作频率', '使用CSS动画代替JavaScript动画', '优化重绘和回流']
- });
- } else if (report.fps && report.fps.p95 < 45) {
- recommendations.push({
- category: 'FPS',
- severity: 'medium',
- message: `P95 FPS只有 ${report.fps.p95.toFixed(1)},存在性能波动`,
- suggestions: ['检查是否有阻塞主线程的操作', '优化复杂计算', '使用Web Workers处理重任务']
- });
- }
-
- // 内存建议
- if (report.memory && report.memory.utilizationRate.peak > 80) {
- recommendations.push({
- category: '内存',
- severity: 'high',
- message: `内存利用率峰值达到 ${report.memory.utilizationRate.peak.toFixed(1)}%`,
- suggestions: ['检查内存泄漏', '优化数据结构', '及时清理不需要的对象']
- });
- }
-
- // 交互延迟建议
- if (report.interaction && report.interaction.p95 > 100) {
- recommendations.push({
- category: '交互延迟',
- severity: 'medium',
- message: `P95交互延迟达到 ${report.interaction.p95.toFixed(1)}ms`,
- suggestions: ['减少事件处理器的复杂度', '使用防抖和节流', '优化DOM操作']
- });
- }
-
- return recommendations;
- }
-}
-
-// 创建全局实例
-window.performanceBaseline = new PerformanceBaseline();
-
-// 导出供使用
-if (typeof module !== 'undefined' && module.exports) {
- module.exports = PerformanceBaseline;
-}
\ No newline at end of file
diff --git a/developer/tests/js/practiceCompletionFlow.test.js b/developer/tests/js/practiceCompletionFlow.test.js
index d584f01c..981604b3 100644
--- a/developer/tests/js/practiceCompletionFlow.test.js
+++ b/developer/tests/js/practiceCompletionFlow.test.js
@@ -32,6 +32,7 @@ function createAppHarness(options = {}) {
const savedCompletions = [];
const statusCalls = [];
const cleanupCalls = [];
+ const completionClaims = new Map();
const examIndex = [{
id: 'reading-p1',
title: 'Passage 1',
@@ -41,17 +42,6 @@ function createAppHarness(options = {}) {
}];
let releaseSync = null;
- const storage = {
- async get(key, fallback = null) {
- if (key === 'exam_index' || key === 'active_exam_index_key') {
- return key === 'exam_index' ? examIndex : 'exam_index';
- }
- return fallback;
- },
- async set() {
- return true;
- }
- };
const quietConsole = {
log() {},
warn() {},
@@ -61,7 +51,6 @@ function createAppHarness(options = {}) {
};
const sandboxWindow = {
console: quietConsole,
- storage,
location: { href: 'http://localhost/' },
document: { addEventListener() {}, removeEventListener() {} },
addEventListener() {},
@@ -76,22 +65,42 @@ function createAppHarness(options = {}) {
}
: async (syncOptions = {}) => {
syncCalls.push(syncOptions);
+ if (syncCalls.length > 1) {
+ return true;
+ }
await new Promise((resolve) => {
releaseSync = resolve;
});
return true;
},
- PracticeRecordAPI: {
- async saveCompletion(payload, context) {
+ resolveActiveLibraryIndex: async () => examIndex.map((entry) => ({ ...entry })),
+ AppData: {
+ ready: Promise.resolve(),
+ practice: {
+ async completeAttempt(command) {
if (options.saveRejects) {
throw new Error('save failed');
}
- savedCompletions.push({ payload, context });
- return {
+ if (completionClaims.has(command.operationId)) {
+ return completionClaims.get(command.operationId);
+ }
+ const record = {
+ ...command.record,
id: 'saved-1',
- examId: context.examId,
- sessionId: context.sessionId
+ examId: command.record.examId,
+ sessionId: command.record.sessionId
};
+ savedCompletions.push({ command: JSON.parse(JSON.stringify(command)), record });
+ const receipt = { committed: true, revision: 1, operationId: command.operationId || 'test-operation', record };
+ completionClaims.set(command.operationId, receipt);
+ return receipt;
+ },
+ async get(recordId) {
+ return savedCompletions.find((entry) => entry.record.id === recordId)?.record || null;
+ },
+ async list() {
+ return savedCompletions.map((entry) => ({ ...entry.record }));
+ }
}
},
AchievementManager: {
@@ -104,7 +113,6 @@ function createAppHarness(options = {}) {
const sandbox = {
window: sandboxWindow,
document: sandboxWindow.document,
- storage,
console: quietConsole,
setTimeout,
clearTimeout,
@@ -118,10 +126,23 @@ function createAppHarness(options = {}) {
const context = vm.createContext(sandbox);
loadScript('js/app/examSessionMixin.js', context);
+ const examWindow = {
+ closed: false,
+ postMessage() {}
+ };
const app = {
components: {},
examWindows: new Map([
- ['reading-p1', { expectedSessionId: 'session-p1', sessionId: 'session-p1' }]
+ ['reading-p1', {
+ window: examWindow,
+ expectedSessionId: 'session-p1',
+ sessionId: 'session-p1',
+ windowSessionToken: 'token-p1',
+ windowSessionTokenSessionId: 'session-p1',
+ sessionGeneration: 1,
+ suiteSessionId: null,
+ expectedOrigin: 'http://localhost'
+ }]
]),
suiteExamMap: new Map(),
currentSuiteSession: null,
@@ -151,14 +172,16 @@ function createAppHarness(options = {}) {
async function testCompletionWaitsForSyncAndDedupes() {
const harness = createAppHarness();
const completion = harness.app.handlePracticeComplete('reading-p1', {
+ sessionId: 'session-p1',
scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 },
duration: 120,
+ endTime: '2026-07-28T00:00:00.000Z',
answers: { q1: 'A' }
});
await waitForCondition(
- () => harness.savedCompletions.length === 1,
- '第一次完成应保存一次'
+ () => harness.savedCompletions.length === 1 && harness.syncCalls.length === 1,
+ '第一次完成应保存并进入同步阶段'
);
assert.strictEqual(harness.savedCompletions.length, 1, '第一次完成应保存一次');
assert.strictEqual(harness.statusCalls.length, 0, 'syncPracticeRecords 未完成前不应先标记完成');
@@ -173,7 +196,8 @@ async function testCompletionWaitsForSyncAndDedupes() {
await harness.app.handlePracticeComplete('reading-p1', {
sessionId: 'session-p1',
scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 },
- duration: 120
+ duration: 120,
+ endTime: '2026-07-28T00:00:00.000Z'
});
assert.strictEqual(harness.savedCompletions.length, 1, '相同 sessionId 的重复完成事件不应重复保存');
}
diff --git a/developer/tests/js/practiceCore.guard.test.js b/developer/tests/js/practiceCore.guard.test.js
index e7e2006e..5cb54d04 100644
--- a/developer/tests/js/practiceCore.guard.test.js
+++ b/developer/tests/js/practiceCore.guard.test.js
@@ -1,840 +1,62 @@
#!/usr/bin/env node
+import assert from 'assert';
import fs from 'fs';
import path from 'path';
+import vm from 'vm';
import { fileURLToPath } from 'url';
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-const repoRoot = path.resolve(__dirname, '..', '..', '..');
-const jsRoot = path.join(repoRoot, 'js');
-
-const DIRECT_WRITE_ALLOWLIST = new Set([
- path.join('js', 'utils', 'storage.js')
-]);
-
-const PROTOCOL_ALIAS_ALLOWLIST = new Set([
- path.join('js', 'core', 'practiceCore.js')
-]);
-
-const PATH_CORE_ALLOWLIST = new Set([
- path.join('js', 'core', 'resourceCore.js')
-]);
-
-const STATE_CORE_ALLOWLIST = new Set([
- path.join('js', 'app', 'state-service.js')
-]);
-
-const directWritePatterns = [
- /(?:^|[^\w.])storage\.set\s*\(\s*['"]practice_records['"]/g,
- /(?:^|[^\w.])window\.storage\.set\s*\(\s*['"]practice_records['"]/g,
- /(?:^|[^\w.])store\.set\s*\(\s*['"]practice_records['"]/g,
- /(?:^|[^\w.])storage\.remove\s*\(\s*['"]practice_records['"]/g,
- /(?:^|[^\w.])window\.storage\.remove\s*\(\s*['"]practice_records['"]/g,
- /(?:^|[^\w.])store\.remove\s*\(\s*['"]practice_records['"]/g,
- /(?:^|[^\w.])this\.set\s*\(\s*['"]practice_records['"]/g,
- /['"]practice\.records['"]\s*:\s*['"]practice_records['"]/g
-];
-
-const practiceRecordsShadowPatterns = [
- /(?:window|global|globalThis|globalRef)\.practiceRecords\s*=/g,
- /practiceRecords\s*:\s*cloneArray\s*\(\s*(?:window|global|globalThis|globalRef)\.practiceRecords\s*\)/g
-];
-
-const protocolAliasPatterns = [
- /practice_complete'\s*:\s*'PRACTICE_COMPLETE'/g,
- /practice_completed'\s*:\s*'PRACTICE_COMPLETE'/g,
- /REQUEST_SESSION_INIT/g,
- /PRACTICE_COMPLETE_TYPES/g
-];
-
-const pathDefinitionPatterns = [
- /(?:^|\n)\s*function\s+buildResourcePath\s*\(/g,
- /(?:^|\n)\s*function\s+derivePathMapFromIndex\s*\(/g,
- /(?:^|\n)\s*function\s+getResourceAttempts\s*\(/g,
- /(?:^|\n)\s*function\s+resolveResource\s*\(/g,
- /(?:^|\n)\s*(?:async\s+)?buildResourcePath\s*\(/g,
- /(?:^|\n)\s*(?:async\s+)?derivePathMapFromIndex\s*\(/g,
- /(?:^|\n)\s*(?:async\s+)?getResourceAttempts\s*\(/g,
- /(?:^|\n)\s*(?:async\s+)?resolveResource\s*\(/g,
- /(?:const|let|var)\s+buildResourcePath\s*=/g,
- /(?:const|let|var)\s+derivePathMapFromIndex\s*=/g,
- /(?:const|let|var)\s+getResourceAttempts\s*=/g,
- /(?:const|let|var)\s+resolveResource\s*=/g
-];
-
-const stateDefinitionPatterns = [
- /function\s+getExamIndexState\s*\(/g,
- /function\s+setExamIndexState\s*\(/g,
- /function\s+getPracticeRecordsState\s*\(/g,
- /function\s+setPracticeRecordsState\s*\(/g,
- /function\s+getFilteredExamsState\s*\(/g,
- /function\s+setFilteredExamsState\s*\(/g,
- /function\s+setBrowseFilterState\s*\(/g,
- /function\s+getSelectedRecordsState\s*\(/g,
- /function\s+assignExamSequenceNumbers\s*\(/g,
- /Object\.defineProperty\(\s*window\s*,\s*['"]fallbackExamSessions['"]/g,
- /Object\.defineProperty\(\s*window\s*,\s*['"]processedSessions['"]/g
-];
-
-const legacyMarkers = [
- /LegacyStateBridge/g,
- /LegacyStateAdapter/g,
- /LegacyStateAdapterBridge/g,
- /LegacyStorageFacade/g,
- /legacy-storage/g,
- /deprecated persistent write via storage facade/g
-];
-
-const bannedRecordFallbackChecks = [
- {
- file: path.join('js', 'core', 'practiceRecordAPI.js'),
- patterns: [
- /getScoreStorageInstance/g,
- /scoreStorage/g,
- /recalculateUserStats/g,
- /getDefaultUserStats/g,
- /getUserStats/g,
- /global\.dataRepositories/g,
- /readPersistentValue/g,
- /writePersistentValue/g,
- /createStorageInternalAccessOptions/g
- ]
- },
- {
- file: path.join('js', 'core', 'scoreStorage.js'),
- patterns: [
- /this\.storage\.get\s*\(\s*this\.storageKeys\.practiceRecords/g,
- /this\.storage\.set\s*\(\s*this\.storageKeys\.practiceRecords/g,
- /this\.storage\.get\s*\(\s*this\.storageKeys\.userStats/g,
- /this\.storage\.set\s*\(\s*this\.storageKeys\.userStats/g,
- /(?:const|let|var)\s+practiceRepo\s*=/g,
- /practiceRepo\.(?:list|overwrite|clear)\s*\(/g,
- /metaRepo\.(?:get|set|remove)\s*\(\s*['"]user_stats['"]/g,
- /compatSchema(?:Read|Write)/g,
- /window\.PracticeStore\.(?:list|replace|save)/g,
- /window\.simpleStorageWrapper\.(?:savePracticeRecords|addPracticeRecord|getPracticeRecords)/g,
- /PracticeCore\.store\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g,
- /practiceCoreStore\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g,
- /继续使用底层兼容/g,
- /继续使用底层/g
- ]
- },
- {
- file: path.join('js', 'core', 'practiceStore.js'),
- patterns: [
- /PRACTICE_RECORDS_KEY/g,
- /getCoreStore/g,
- /getLegacyWrapper/g,
- /getStorage/g,
- /PracticeCore\.store/g,
- /simpleStorageWrapper/g,
- /storage\.(?:get|set)\s*\(/g
- ]
- },
- {
- file: path.join('js', 'core', 'practiceRecorder.js'),
- patterns: [
- /fallbackSavePracticeRecord/g,
- /standardizeRecordForFallback/g,
- /savedBy\s*:\s*['"]fallback['"]/g,
- /fallbackReason/g,
- /suiteFallback/g,
- /scoreStorage\.savePracticeRecord/g,
- /scoreStorage\.getPracticeRecords/g,
- /window\.PracticeStore\.(?:list|replace|save)/g,
- /window\.simpleStorageWrapper\.(?:savePracticeRecords|addPracticeRecord|getPracticeRecords)/g,
- /PracticeCore\.store\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g,
- /practiceCoreStore\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g,
- /practiceRepo\.list\s*\(/g,
- /继续使用底层兼容/g,
- /继续使用底层/g
- ]
- },
- {
- file: path.join('js', 'utils', 'simpleStorageWrapper.js'),
- patterns: [
- /practiceRepo/g,
- /repos\.practice/g,
- /api\.(?:replace|saveRecord|deleteById|deleteMany|clear|writeStats|resetStats)\s*\(/g,
- /PracticeCore\.store\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g,
- /practiceCoreStore\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g
- ]
- },
- {
- file: path.join('js', 'utils', 'storage.js'),
- patterns: [
- /window\.PracticeStore\.(?:list|replace|save)/g,
- /window\.simpleStorageWrapper\.(?:savePracticeRecords|addPracticeRecord|getPracticeRecords)/g,
- /PracticeCore\.store\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g,
- /practiceCoreStore\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g,
- /继续使用底层兼容/g,
- /继续使用底层/g
- ]
- },
- {
- file: path.join('js', 'main.js'),
- patterns: [
- /savePracticeRecordFallback/g,
- /\[Fallback\]\s*收到练习完成/g,
- /\[Fallback\]\s*真实数据/g,
- /\[Fallback\]\s*保存练习记录失败/g,
- /PracticeRecordAPI 保存失败,继续/g,
- /window\.PracticeStore\.(?:list|replace|save|clear)/g,
- /window\.simpleStorageWrapper\.(?:savePracticeRecords|addPracticeRecord|getPracticeRecords)/g,
- /PracticeCore\.store\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g,
- /practiceCoreStore\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g,
- /storage\.get\s*\(\s*\[['"]practice['"]\s*,\s*['"]records['"]\]\.join/g,
- /storage\.set\s*\(\s*\[['"]practice['"]\s*,\s*['"]records['"]\]\.join/g
- ]
- },
- {
- file: path.join('js', 'app', 'suitePracticeMixin.js'),
- patterns: [
- /_listPracticeRecordsWithFallback/g,
- /_replacePracticeRecordsWithFallback/g,
- /_saveSinglePracticeRecordWithFallback/g,
- /_saveSuitePracticeRecordFallback/g,
- /includeRecorder/g
- ]
- },
- {
- file: path.join('js', 'app', 'examSessionMixin.js'),
- patterns: [
- /suiteFallback/g
- ]
- },
- {
- file: path.join('js', 'components', 'onboardingTour.js'),
- patterns: [
- /dataRepositories\.practice/g,
- /repositories\.practice/g,
- /repos\.practice/g
- ]
- }
-];
-
-const bannedStatsBypassChecks = [
- {
- file: path.join('js', 'app.js'),
- patterns: [
- /(?:^|[^\w.])storage\.get\s*\(\s*['"]user_stats['"]/g,
- /(?:^|[^\w.])storage\.set\s*\(\s*['"]user_stats['"]/g,
- /window\.storage\.(?:get|set)\s*\(\s*['"]user_stats['"]/g,
- /PracticeCore\.store\.writeMeta\s*\(\s*['"]user_stats['"]/g,
- /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats)/g
- ]
- },
- {
- file: path.join('js', 'main.js'),
- patterns: [
- /(?:^|[^\w.])storage\.get\s*\(\s*['"]user_stats['"]/g,
- /(?:^|[^\w.])storage\.set\s*\(\s*['"]user_stats['"]/g,
- /window\.storage\.(?:get|set)\s*\(\s*['"]user_stats['"]/g,
- /PracticeCore\.store\.writeMeta\s*\(\s*['"]user_stats['"]/g,
- /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats)/g
- ]
- },
- {
- file: path.join('js', 'app', 'suitePracticeMixin.js'),
- patterns: [
- /(?:^|[^\w.])storage\.get\s*\(\s*['"]user_stats['"]/g,
- /(?:^|[^\w.])storage\.set\s*\(\s*['"]user_stats['"]/g,
- /window\.storage\.(?:get|set)\s*\(\s*['"]user_stats['"]/g,
- /PracticeCore\.store\.writeMeta\s*\(\s*['"]user_stats['"]/g,
- /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats)/g
- ]
- },
- {
- file: path.join('js', 'components', 'DataIntegrityManager.js'),
- patterns: [
- /(?:^|[^\w.])storage\.get\s*\(\s*['"]user_stats['"]/g,
- /(?:^|[^\w.])storage\.set\s*\(\s*['"]user_stats['"]/g,
- /window\.storage\.(?:get|set)\s*\(\s*['"]user_stats['"]/g,
- /PracticeCore\.store\.writeMeta\s*\(\s*['"]user_stats['"]/g,
- /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats)/g
- ]
- },
- {
- file: path.join('js', 'utils', 'dataBackupManager.js'),
- patterns: [
- /(?:^|[^\w.])storage\.get\s*\(\s*['"]user_stats['"]/g,
- /(?:^|[^\w.])storage\.set\s*\(\s*['"]user_stats['"]/g,
- /window\.storage\.(?:get|set)\s*\(\s*['"]user_stats['"]/g,
- /PracticeCore\.store\.writeMeta\s*\(\s*['"]user_stats['"]/g,
- /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats|createBackup|restoreBackup)/g,
- /practiceRecorder\.(?:getUserStats|createBackup|restoreBackup)/g
- ]
- },
- {
- file: path.join('js', 'core', 'practiceRecorder.js'),
- patterns: [
- /(?:^|[^\w.])metaRepo\.(?:get|set)\s*\(\s*['"]user_stats['"]/g,
- /(?:^|[^\w.])this\.metaRepo\.(?:get|set)\s*\(\s*['"]user_stats['"]/g,
- /PracticeCore\.store\.writeMeta\s*\(\s*['"]user_stats['"]/g,
- /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats|exportData|importData|createBackup|restoreBackup)/g,
- /this\.scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats|exportData|importData|createBackup|restoreBackup)/g
- ]
- },
- {
- file: path.join('js', 'components', 'practiceHistoryEnhancer.js'),
- patterns: [
- /window\.practiceStats/g,
- /(?:^|[^\w.])storage\.get\s*\(\s*['"]user_stats['"]/g,
- /(?:^|[^\w.])storage\.set\s*\(\s*['"]user_stats['"]/g,
- /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats)/g
- ]
- },
- {
- file: path.join('js', 'services', 'achievementManager.js'),
- patterns: [
- /(?:^|[^\w.])storage\.get\s*\(\s*['"]user_stats['"]/g,
- /(?:^|[^\w.])storage\.set\s*\(\s*['"]user_stats['"]/g,
- /window\.storage\.(?:get|set)\s*\(\s*['"]user_stats['"]/g,
- /PracticeCore\.store\.writeMeta\s*\(\s*['"]user_stats['"]/g,
- /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats)/g
- ]
- }
-];
-
-const bannedImportNormalizerForkChecks = [
- {
- file: path.join('js', 'components', 'DataIntegrityManager.js'),
- patterns: [
- /_normalizePracticeRecord/g,
- /_standardizePracticeRecord/g,
- /_mergeAnswerMaps/g,
- /_detailsToCorrectMap/g,
- /_comparisonToCorrectMap/g,
- /_resolveCorrectAnswerMap/g,
- /_pickNumber/g,
- /_pickInteger/g,
- /_pickDuration/g,
- /_normalizeDate/g,
- /_stringify/g
- ]
- },
- {
- file: path.join('js', 'utils', 'dataBackupManager.js'),
- patterns: [
- /validateNormalizedRecords/g,
- /looksLikePracticeRecord/g,
- /isPracticeRecordPath/g,
- /extractRecordsFromCommonShapes/g,
- /parseNumber/g,
- /parseInteger/g,
- /firstParsedInteger/g,
- /payload\.records/g,
- /record\.percentage/g,
- /record\.accuracy/g
- ]
- }
-];
-
-const productionContractBypassPatterns = [
- /compatSchema(?:Read|Write)/g,
- /(?:^|\n)\s*(?:async\s+)?createInternalAccessOptions\s*\(/g,
- /(?:^|\n)\s*(?:async\s+)?hasInternalAccess\s*\(/g,
- /persistentStore\.createInternalAccessOptions/g,
- /persistentStore\.hasInternalAccess/g,
- /__recordStore/g
-];
-
-const replayCorrectAnswerFallbackChecks = [
- {
- file: path.join('js', 'runtime', 'unifiedReadingPage.js'),
- functionNames: ['buildReplayResults'],
- patterns: [
- /(?:entry|realData|rawData|rawRealData)\.correctAnswers/g,
- /(?:entry|realData|rawData|rawRealData)\.answerComparison/g,
- /scoreInfo\.details/g,
- /detailSources/g
- ]
- },
- {
- file: path.join('js', 'practice-page-enhancer.js'),
- functionNames: ['buildReplayResultsFromEntry'],
- patterns: [
- /(?:entry|realData|rawData|rawRealData)\.correctAnswers/g,
- /(?:entry|realData|rawData|rawRealData)\.answerComparison/g,
- /scoreInfo\.details/g,
- /detailSources/g
- ]
- },
- {
- file: path.join('js', 'app', 'examSessionMixin.js'),
- functionNames: ['_resolveReplayCorrectAnswerMap'],
- patterns: [
- /(?:entry|realData|rawData|rawRealData|source)\.correctAnswers/g,
- /(?:entry|realData|rawData|rawRealData|source)\.answerComparison/g,
- /scoreInfo\.details/g,
- /detailSources/g
- ]
- },
- {
- file: path.join('js', 'components', 'practiceRecordModal.js'),
- functionNames: ['getLegacyCorrectAnswers', 'mergeComparisonWithCorrections'],
- patterns: [
- /(?:record|realData)\.correctAnswers/g,
- /scoreInfo\.details/g,
- /detailSources/g
- ]
- },
- {
- file: path.join('js', 'utils', 'storage.js'),
- functionNames: ['compressRealData'],
- patterns: [
- /correctAnswers\s*:\s*realData\.correctAnswers/g,
- /correctAnswer\s*:\s*comparison\.correctAnswer/g
- ]
- },
- {
- file: path.join('templates', 'exam-placeholder.html'),
- functionNames: ['buildReplaySnapshot'],
- patterns: [
- /(?:source|entry|realData|rawData)\.correctAnswers/g,
- /deriveCorrectMapFromDetails/g
- ]
- }
-];
-
-const replayCorrectAnswerGlobalPatterns = [
- /extractReplayCorrectAnswersFrom(?:Comparison|Details)/g,
- /_hydrateReplayCorrectAnswersFromDetails/g,
- /fallbackCorrectSources/g
-];
-
-function walk(dir, bucket = []) {
- const entries = fs.readdirSync(dir, { withFileTypes: true });
- entries.forEach((entry) => {
- const fullPath = path.join(dir, entry.name);
- const relativePath = path.relative(repoRoot, fullPath);
- if (entry.isDirectory()) {
- if (relativePath === path.join('js', 'bundles')) {
- return;
- }
- walk(fullPath, bucket);
- return;
- }
- if (entry.isFile() && entry.name.endsWith('.js')) {
- bucket.push(fullPath);
- }
- });
- return bucket;
-}
-
-function formatLine(source, index) {
- return source.slice(0, index).split('\n').length;
-}
-
-function collectMatches(files, patterns, allowlist) {
- const findings = [];
- files.forEach((fullPath) => {
- const relativePath = path.relative(repoRoot, fullPath);
- if (allowlist.has(relativePath)) {
- return;
- }
- const source = fs.readFileSync(fullPath, 'utf8');
- patterns.forEach((pattern) => {
- pattern.lastIndex = 0;
- let match = pattern.exec(source);
- while (match) {
- findings.push({
- file: relativePath,
- line: formatLine(source, match.index),
- snippet: match[0].trim()
- });
- match = pattern.exec(source);
- }
- });
- });
- return findings;
-}
-
-function collectTargetedFallbackMatches() {
- const findings = [];
- bannedRecordFallbackChecks.forEach((check) => {
- const fullPath = path.join(repoRoot, check.file);
- if (!fs.existsSync(fullPath)) {
- return;
- }
- const source = fs.readFileSync(fullPath, 'utf8');
- check.patterns.forEach((pattern) => {
- pattern.lastIndex = 0;
- let match = pattern.exec(source);
- while (match) {
- findings.push({
- file: check.file,
- line: formatLine(source, match.index),
- snippet: match[0].trim()
- });
- match = pattern.exec(source);
- }
- });
- });
- return findings;
-}
-
-function collectTargetedStatsBypassMatches() {
- const findings = [];
- bannedStatsBypassChecks.forEach((check) => {
- const fullPath = path.join(repoRoot, check.file);
- if (!fs.existsSync(fullPath)) {
- return;
- }
- const source = fs.readFileSync(fullPath, 'utf8');
- check.patterns.forEach((pattern) => {
- pattern.lastIndex = 0;
- let match = pattern.exec(source);
- while (match) {
- findings.push({
- file: check.file,
- line: formatLine(source, match.index),
- snippet: match[0].trim()
- });
- match = pattern.exec(source);
- }
- });
- });
- return findings;
-}
-
-function collectImportNormalizerForkMatches() {
- const findings = [];
- bannedImportNormalizerForkChecks.forEach((check) => {
- const fullPath = path.join(repoRoot, check.file);
- if (!fs.existsSync(fullPath)) {
- return;
- }
- const source = fs.readFileSync(fullPath, 'utf8');
- check.patterns.forEach((pattern) => {
- pattern.lastIndex = 0;
- let match = pattern.exec(source);
- while (match) {
- findings.push({
- file: check.file,
- line: formatLine(source, match.index),
- snippet: match[0].trim()
- });
- match = pattern.exec(source);
- }
- });
- });
- return findings;
-}
-
-function collectProductionContractBypassMatches(files) {
- return collectMatches(files, productionContractBypassPatterns, new Set());
-}
-
-function collectPracticeDataPublicSurfaceMatches() {
- const findings = [];
- const corePath = path.join('js', 'core', 'practiceCore.js');
- const coreFullPath = path.join(repoRoot, corePath);
- if (fs.existsSync(coreFullPath)) {
- const source = fs.readFileSync(coreFullPath, 'utf8');
- const publicStoreMatch = /const\s+publicStore\s*=\s*Object\.freeze\(\{([\s\S]*?)\}\);/m.exec(source);
- if (!publicStoreMatch) {
- findings.push({
- file: corePath,
- line: 0,
- snippet: 'publicStore missing'
- });
- } else {
- const banned = [
- 'replacePracticeRecords',
- 'savePracticeRecord',
- 'routeStorageSet',
- 'routeStorageRemove',
- 'writeMeta',
- 'removeMeta'
- ];
- banned.forEach((name) => {
- const index = publicStoreMatch[1].indexOf(name);
- if (index >= 0) {
- findings.push({
- file: corePath,
- line: formatLine(source, publicStoreMatch.index + index),
- snippet: `publicStore exposes ${name}`
- });
- }
- });
- }
- }
-
- const dataIndexPath = path.join('js', 'data', 'index.js');
- const dataIndexFullPath = path.join(repoRoot, dataIndexPath);
- if (fs.existsSync(dataIndexFullPath)) {
- const source = fs.readFileSync(dataIndexFullPath, 'utf8');
- const apiStart = source.indexOf('const api = {', source.indexOf('const metaFacade'));
- const apiEnd = apiStart >= 0 ? source.indexOf('};', apiStart) : -1;
- if (apiStart < 0 || apiEnd < 0) {
- findings.push({
- file: dataIndexPath,
- line: 0,
- snippet: 'public dataRepositories api missing'
- });
- } else {
- const publicApiBody = source.slice(apiStart, apiEnd);
- const patterns = [
- /get\s+practice\s*\(/g,
- /practiceRepo/g,
- /user_stats[\s\S]{0,80}(?:metaRepo\.set|metaRepo\.get)/g
- ];
- patterns.forEach((pattern) => {
- pattern.lastIndex = 0;
- let match = pattern.exec(publicApiBody);
- while (match) {
- findings.push({
- file: dataIndexPath,
- line: formatLine(source, apiStart + match.index),
- snippet: match[0].trim()
- });
- match = pattern.exec(publicApiBody);
- }
- });
- }
- }
- return findings;
-}
-
-function collectReplayCorrectAnswerFallbackMatches(files) {
- const findings = [];
- replayCorrectAnswerFallbackChecks.forEach((check) => {
- const fullPath = path.join(repoRoot, check.file);
- if (!fs.existsSync(fullPath)) {
- return;
- }
- const source = fs.readFileSync(fullPath, 'utf8');
- check.functionNames.forEach((functionName) => {
- const extracted = extractFunctionBody(source, functionName);
- if (!extracted) {
- findings.push({
- file: check.file,
- line: 0,
- snippet: `${functionName} missing`
- });
- return;
- }
- check.patterns.forEach((pattern) => {
- pattern.lastIndex = 0;
- let match = pattern.exec(extracted.body);
- while (match) {
- findings.push({
- file: check.file,
- line: formatLine(source, extracted.startIndex + match.index),
- snippet: `${functionName}: ${match[0].trim()}`
- });
- match = pattern.exec(extracted.body);
- }
- });
- });
- });
-
- const targetFiles = new Set(replayCorrectAnswerFallbackChecks.map((check) => check.file));
- files.forEach((fullPath) => {
- const relativePath = path.relative(repoRoot, fullPath);
- if (!targetFiles.has(relativePath)) {
- return;
- }
- const source = fs.readFileSync(fullPath, 'utf8');
- replayCorrectAnswerGlobalPatterns.forEach((pattern) => {
- pattern.lastIndex = 0;
- let match = pattern.exec(source);
- while (match) {
- findings.push({
- file: relativePath,
- line: formatLine(source, match.index),
- snippet: match[0].trim()
- });
- match = pattern.exec(source);
- }
- });
- });
- return findings;
-}
-
-function collectUnsafeCompatSchemaWrites() {
- const findings = [];
- [
- path.join('js', 'core', 'scoreStorage.js'),
- path.join('js', 'utils', 'storage.js')
- ].forEach((relativePath) => {
- const fullPath = path.join(repoRoot, relativePath);
- if (!fs.existsSync(fullPath)) {
- return;
- }
- const source = fs.readFileSync(fullPath, 'utf8');
- const rawWritePattern = /(?:this\.storage\.set\s*\(\s*this\.storageKeys\.practiceRecords|this\.set\s*\(\s*['"]practice_records['"])/g;
- rawWritePattern.lastIndex = 0;
- let match = rawWritePattern.exec(source);
- while (match) {
- const windowStart = Math.max(0, match.index - 450);
- const windowEnd = Math.min(source.length, match.index + 220);
- const localContext = source.slice(windowStart, windowEnd);
- if (!/compatSchemaWrite/.test(localContext)) {
- findings.push({
- file: relativePath,
- line: formatLine(source, match.index),
- snippet: match[0].trim()
- });
- }
- match = rawWritePattern.exec(source);
- }
- });
- return findings;
-}
-
-function extractFunctionBody(source, functionName) {
- const signature = new RegExp(`(?:async\\s+)?${functionName}\\s*\\(`, 'g');
- const match = signature.exec(source);
- if (!match) {
- return null;
- }
- const openBrace = source.indexOf('{', match.index);
- if (openBrace < 0) {
- return null;
- }
-
- let depth = 0;
- for (let index = openBrace; index < source.length; index += 1) {
- const char = source[index];
- if (char === '{') {
- depth += 1;
- } else if (char === '}') {
- depth -= 1;
- if (depth === 0) {
- return {
- body: source.slice(openBrace, index + 1),
- startIndex: openBrace
- };
- }
- }
- }
- return null;
-}
-
-function collectRuntimeCompatSchemaWrites() {
- const findings = [];
- const relativePath = path.join('js', 'utils', 'storage.js');
- const fullPath = path.join(repoRoot, relativePath);
- if (!fs.existsSync(fullPath)) {
- return findings;
- }
- const source = fs.readFileSync(fullPath, 'utf8');
- ['restoreFromBackup', 'importData'].forEach((functionName) => {
- const extracted = extractFunctionBody(source, functionName);
- if (!extracted) {
- findings.push({
- file: relativePath,
- line: 0,
- snippet: `${functionName} missing`
- });
- return;
- }
- const pattern = /compatSchemaWrite\s*:\s*true/g;
- pattern.lastIndex = 0;
- let match = pattern.exec(extracted.body);
- while (match) {
- findings.push({
- file: relativePath,
- line: formatLine(source, extracted.startIndex + match.index),
- snippet: `${functionName}: ${match[0]}`
- });
- match = pattern.exec(extracted.body);
- }
- });
- return findings;
-}
-
-function collectE2ERawPracticeRecordReads() {
- const findings = [];
- const relativePath = path.join('developer', 'tests', 'e2e', 'suite_practice_flow.py');
- const fullPath = path.join(repoRoot, relativePath);
- if (!fs.existsSync(fullPath)) {
- return findings;
- }
- const source = fs.readFileSync(fullPath, 'utf8');
- const patterns = [
- /window\.storage\.get\(['"]practice_records['"]/g,
- /storage\.get\(['"]practice_records['"]/g,
- /window\.storage\.set\(['"]practice_records['"]/g,
- /storage\.set\(['"]practice_records['"]/g,
- /window\.storage\.remove\(['"]practice_records['"]/g,
- /storage\.remove\(['"]practice_records['"]/g
- ];
- patterns.forEach((pattern) => {
- pattern.lastIndex = 0;
- let match = pattern.exec(source);
- while (match) {
- findings.push({
- file: relativePath,
- line: formatLine(source, match.index),
- snippet: match[0].trim()
- });
- match = pattern.exec(source);
- }
- });
- return findings;
-}
-
-function main() {
- const files = walk(jsRoot);
- const directWrites = collectMatches(files, directWritePatterns, DIRECT_WRITE_ALLOWLIST);
- const practiceRecordShadows = collectMatches(files, practiceRecordsShadowPatterns, new Set());
- const recordFallbacks = collectTargetedFallbackMatches();
- const statsBypasses = collectTargetedStatsBypassMatches();
- const importNormalizerForks = collectImportNormalizerForkMatches();
- const productionContractBypasses = collectProductionContractBypassMatches(files);
- const practiceDataPublicSurfaceBypasses = collectPracticeDataPublicSurfaceMatches();
- const replayCorrectAnswerFallbacks = collectReplayCorrectAnswerFallbackMatches(files);
- const unsafeCompatSchemaWrites = collectUnsafeCompatSchemaWrites();
- const runtimeCompatSchemaWrites = collectRuntimeCompatSchemaWrites();
- const e2eRawPracticeRecordReads = collectE2ERawPracticeRecordReads();
- const aliasCopies = collectMatches(files, protocolAliasPatterns, PROTOCOL_ALIAS_ALLOWLIST);
- const pathCopies = collectMatches(files, pathDefinitionPatterns, PATH_CORE_ALLOWLIST);
- const stateCopies = collectMatches(files, stateDefinitionPatterns, STATE_CORE_ALLOWLIST);
- const legacyCopies = collectMatches(files, legacyMarkers, new Set());
- const passed = directWrites.length === 0
- && practiceRecordShadows.length === 0
- && recordFallbacks.length === 0
- && statsBypasses.length === 0
- && importNormalizerForks.length === 0
- && productionContractBypasses.length === 0
- && practiceDataPublicSurfaceBypasses.length === 0
- && replayCorrectAnswerFallbacks.length === 0
- && unsafeCompatSchemaWrites.length === 0
- && runtimeCompatSchemaWrites.length === 0
- && e2eRawPracticeRecordReads.length === 0
- && aliasCopies.length === 0
- && pathCopies.length === 0
- && stateCopies.length === 0
- && legacyCopies.length === 0;
-
- console.log(JSON.stringify({
- status: passed ? 'pass' : 'fail',
- detail: passed
- ? '无多余 practice_records 直写、记录旧 fallback、导入层 record normalizer 分叉、公开 internal token、回放正确答案兜底、运行期 compat schema 写入、E2E raw records 读取、协议别名复制、路径实现复制、状态实现复制或 legacy bridge 残留'
- : `发现 ${directWrites.length} 处直写、${practiceRecordShadows.length} 处 practiceRecords 影子事实源、${recordFallbacks.length} 处记录旧 fallback、${statsBypasses.length} 处统计旧入口绕行、${importNormalizerForks.length} 处导入层 record normalizer 分叉、${productionContractBypasses.length} 处生产契约绕行、${practiceDataPublicSurfaceBypasses.length} 处练习数据公开写面、${replayCorrectAnswerFallbacks.length} 处回放正确答案兜底、${unsafeCompatSchemaWrites.length} 处非显式 compat schema 写入、${runtimeCompatSchemaWrites.length} 处运行期 compat schema 写入、${e2eRawPracticeRecordReads.length} 处 E2E raw records 读取、${aliasCopies.length} 处协议别名复制、${pathCopies.length} 处路径实现复制、${stateCopies.length} 处状态实现复制、${legacyCopies.length} 处 legacy 残留`,
- directWrites,
- practiceRecordShadows,
- recordFallbacks,
- statsBypasses,
- importNormalizerForks,
- productionContractBypasses,
- practiceDataPublicSurfaceBypasses,
- replayCorrectAnswerFallbacks,
- unsafeCompatSchemaWrites,
- runtimeCompatSchemaWrites,
- e2eRawPracticeRecordReads,
- aliasCopies,
- pathCopies,
- stateCopies,
- legacyCopies
- }, null, 2));
-
- if (!passed) {
- process.exit(1);
- }
-}
-
-main();
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
+const sourcePath = path.join(repoRoot, 'js', 'core', 'practiceCore.js');
+const source = fs.readFileSync(sourcePath, 'utf8');
+
+const forbidden = [
+ 'STORAGE_KEYS',
+ 'persistentStore',
+ 'global.storage',
+ 'dataRepositories',
+ 'repository',
+ '__installRecordAPI',
+ '__installInternalRepositories',
+ 'readMeta(',
+ 'writeMeta(',
+ 'routeStorageSet',
+ 'routeStorageRemove'
+];
+
+for (const marker of forbidden) {
+ assert.strictEqual(source.includes(marker), false, `PracticeCore must not contain v1 marker: ${marker}`);
+}
+
+const windowStub = { console };
+const context = vm.createContext({ window: windowStub, globalThis: windowStub, console, Date, Math, JSON });
+vm.runInContext(source, context, { filename: 'js/core/practiceCore.js' });
+
+const core = windowStub.PracticeCore;
+assert.ok(core && core.__stable === true, 'PracticeCore must initialize');
+assert.deepStrictEqual(
+ Object.keys(core).sort(),
+ ['__stable', 'contracts', 'ingestor', 'protocol', 'version'].sort(),
+ 'PracticeCore public surface must stay persistence-free'
+);
+assert.strictEqual(Object.isFrozen(core), true, 'PracticeCore public surface must be frozen');
+assert.strictEqual(Object.prototype.hasOwnProperty.call(core, 'store'), false, 'PracticeCore.store must not exist');
+
+const suiteEntry = JSON.parse(JSON.stringify(core.contracts.standardizeSuiteEntries([{
+ examId: 'suite-annotation-fallback',
+ markedQuestions: [],
+ metadata: { markedQuestions: ['q2'] }
+}])[0]));
+assert.deepStrictEqual(suiteEntry.markedQuestions, ['q2'],
+ 'suite normalization must skip an empty root annotation array when metadata has saved values');
+assert.deepStrictEqual(suiteEntry.metadata.markedQuestions, ['q2']);
+const explicitEmpty = JSON.parse(JSON.stringify(core.contracts.resolveAnnotationState(
+ { markedQuestions: [] },
+ [{ markedQuestions: ['stale'] }]
+)));
+assert.deepStrictEqual(explicitEmpty.markedQuestions, [],
+ 'normal record annotation edits must retain explicit-empty semantics');
+
+console.log(JSON.stringify({
+ status: 'pass',
+ detail: 'PracticeCore exposes only contracts, protocol and ingestor'
+}, null, 2));
diff --git a/developer/tests/js/practiceCore.test.js b/developer/tests/js/practiceCore.test.js
deleted file mode 100644
index 8f643bbd..00000000
--- a/developer/tests/js/practiceCore.test.js
+++ /dev/null
@@ -1,692 +0,0 @@
-#!/usr/bin/env node
-import fs from 'fs';
-import path from 'path';
-import vm from 'vm';
-import assert from 'assert';
-import { fileURLToPath } from 'url';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-const repoRoot = path.resolve(__dirname, '..', '..', '..');
-
-function loadScript(relativePath, context) {
- const fullPath = path.join(repoRoot, relativePath);
- const source = fs.readFileSync(fullPath, 'utf8');
- vm.runInContext(source, context, { filename: relativePath });
-}
-
-function createRepositoryHarness() {
- const practiceState = [];
- const metaState = new Map();
-
- return {
- repositories: {
- practice: {
- async list() {
- return practiceState.map((record) => JSON.parse(JSON.stringify(record)));
- },
- async overwrite(records) {
- practiceState.splice(0, practiceState.length, ...records.map((record) => JSON.parse(JSON.stringify(record))));
- return true;
- },
- async upsert(record) {
- const clone = JSON.parse(JSON.stringify(record));
- const index = practiceState.findIndex((entry) => entry && String(entry.id) === String(clone.id));
- if (index >= 0) {
- practiceState[index] = clone;
- } else {
- practiceState.unshift(clone);
- }
- return clone;
- }
- },
- meta: {
- async get(key, fallback = null) {
- return metaState.has(key)
- ? JSON.parse(JSON.stringify(metaState.get(key)))
- : JSON.parse(JSON.stringify(fallback));
- },
- async set(key, value) {
- metaState.set(key, JSON.parse(JSON.stringify(value)));
- return true;
- },
- async remove(key) {
- metaState.delete(key);
- return true;
- }
- }
- },
- practiceState,
- metaState
- };
-}
-
-const results = [];
-
-function recordResult(name, passed, detail) {
- results.push({ name, passed, detail, timestamp: new Date().toISOString() });
-}
-
-async function testProtocolNormalization(PracticeCore) {
- const normalized = PracticeCore.protocol.normalizeMessage({
- type: 'practice_completed',
- data: { examId: 'reading-p1' },
- source: 'practice_page'
- });
-
- assert(normalized, '消息应被正确解析');
- assert.strictEqual(normalized.type, 'PRACTICE_COMPLETE', '消息类型应折叠为 PRACTICE_COMPLETE');
- assert.strictEqual(PracticeCore.protocol.normalizeMessageType('request_init'), 'REQUEST_INIT', 'REQUEST_INIT 别名应归一');
- recordResult('PracticeCore 协议归一化', true, normalized);
-}
-
-async function testCompletionIngestion(PracticeCore) {
- const record = PracticeCore.ingestor.fromCompletion({
- type: 'practice_complete',
- data: {
- examId: 'reading-p1',
- sessionId: 'session-reading-p1',
- title: 'Passage 1',
- duration: 1800,
- answers: { 1: 'A', 2: 'B' },
- correctAnswers: { 1: 'A', 2: 'C' },
- scoreInfo: { correct: 1, total: 2, accuracy: 0.5, percentage: 50 },
- metadata: { category: 'P1', frequency: 'high', type: 'reading' }
- }
- }, {
- examId: 'reading-p1',
- metadata: { examTitle: 'Passage 1', category: 'P1', frequency: 'high', type: 'reading' }
- }, {
- id: 'reading-p1',
- title: 'Passage 1',
- category: 'P1',
- frequency: 'high',
- type: 'reading'
- });
-
- assert(record, '完成负载应成功转换为记录');
- assert.strictEqual(record.examId, 'reading-p1');
- assert.strictEqual(record.type, 'reading');
- assert.strictEqual(record.totalQuestions, 2);
- assert.strictEqual(record.correctAnswers, 1);
- assert.strictEqual(record.answers.length, 2);
- assert.strictEqual(record.correctAnswerMap.q1, 'A');
- assert.strictEqual(record.metadata.category, 'P1');
- recordResult('PracticeCore 完成负载入站', true, { id: record.id, metadata: record.metadata });
-}
-
-async function testPracticeRecordApiWritePath(sandbox, PracticeCore, practiceState, metaState) {
- assert.strictEqual(typeof PracticeCore.store.savePracticeRecord, 'undefined', '公开 PracticeCore.store 不能暴露记录写入口');
- assert.strictEqual(typeof PracticeCore.store.replacePracticeRecords, 'undefined', '公开 PracticeCore.store 不能暴露批量替换入口');
- assert.strictEqual(typeof PracticeCore.store.writeMeta, 'undefined', '公开 PracticeCore.store 不能暴露 meta 写入口');
- assert.strictEqual(typeof PracticeCore.store.routeStorageSet, 'undefined', '公开 PracticeCore.store 不能暴露 storage 写路由');
- assert.strictEqual(typeof PracticeCore.__installRecordAPI, 'undefined', 'PracticeRecordAPI 初始化后必须删除内部 store 安装钩子');
- const api = sandbox.window.PracticeRecordAPI;
-
- const first = await api.saveRecord({
- id: 'record-reading-p1',
- examId: 'reading-p1',
- sessionId: 'session-reading-p1',
- title: 'Passage 1',
- type: 'reading',
- date: new Date().toISOString(),
- score: 1,
- totalQuestions: 2,
- correctAnswers: 1,
- accuracy: 0.5,
- answers: { q1: 'A', q2: 'B' },
- correctAnswerMap: { q1: 'A', q2: 'C' },
- metadata: { examTitle: 'Passage 1', category: 'P1', frequency: 'high', type: 'reading' }
- }, { maxRecords: 1000 });
-
- const second = await api.saveRecord({
- id: 'record-reading-p1-retry',
- examId: 'reading-p1',
- sessionId: 'session-reading-p1',
- title: 'Passage 1 retry',
- type: 'reading',
- date: new Date().toISOString(),
- score: 2,
- totalQuestions: 2,
- correctAnswers: 2,
- accuracy: 1,
- answers: { q1: 'A', q2: 'C' },
- correctAnswerMap: { q1: 'A', q2: 'C' },
- metadata: { examTitle: 'Passage 1 retry', category: 'P1', frequency: 'high', type: 'reading' }
- }, { maxRecords: 1000 });
-
- assert(first, '第一次保存应成功');
- assert(second, '第二次保存应成功');
- assert.strictEqual(practiceState.length, 1, '相同 sessionId 的记录应只保留一条');
- assert.strictEqual(practiceState[0].id, 'record-reading-p1-retry', '应保留最新记录');
- assert.strictEqual(metaState.get('user_stats').totalPractices, 1, 'saveRecord 默认应重算 user_stats');
-
- await api.writeStats({ totalPractices: 1 });
- assert.strictEqual(metaState.get('user_stats').totalPractices, 1, 'user_stats 应走统一 PracticeRecordAPI 写路径');
-
- await assert.rejects(
- () => api.saveRecord({
- id: 'record-without-exam-id',
- sessionId: 'session-without-exam-id',
- title: 'Legacy title only',
- type: 'reading',
- date: new Date().toISOString(),
- score: 1,
- totalQuestions: 1,
- correctAnswers: 1,
- accuracy: 1
- }, { maxRecords: 1000 }),
- /canonical examId/,
- '缺少 canonical examId 的记录不能用 title/sessionId 兜底保存'
- );
-
- recordResult('PracticeRecordAPI 统一写路径', true, {
- savedRecordId: practiceState[0].id,
- totalRecords: practiceState.length
- });
-}
-
-async function testPracticeRecordApiContract(sandbox, PracticeCore, practiceState, metaState) {
- const api = sandbox.window.PracticeRecordAPI;
- assert(api, 'PracticeRecordAPI 应挂载到 window');
- assert.strictEqual(typeof api.saveCompletion, 'function', 'PracticeRecordAPI 应提供 saveCompletion');
- assert.strictEqual(typeof api.getById, 'function', 'PracticeRecordAPI 应提供 getById');
- assert.strictEqual(typeof api.toSummaryMetrics, 'function', 'PracticeRecordAPI 应提供 toSummaryMetrics');
-
- const saved = await api.saveCompletion({
- type: 'PRACTICE_COMPLETE',
- data: {
- examId: 'reading-p2',
- sessionId: 'session-reading-p2',
- title: 'Passage 2',
- duration: 900,
- answers: { q1: 'TRUE', q2: 'FALSE' },
- correctAnswerMap: { q1: 'TRUE', q2: 'NOT GIVEN' },
- answerComparison: {
- q1: { userAnswer: 'TRUE', correctAnswer: 'TRUE', isCorrect: true },
- q2: { userAnswer: 'FALSE', correctAnswer: 'NOT GIVEN', isCorrect: false }
- },
- scoreInfo: { correct: 1, total: 2, accuracy: 0.5, percentage: 50 },
- metadata: { category: 'P2', frequency: 'high', type: 'reading' }
- }
- }, {
- examId: 'reading-p2',
- metadata: { examTitle: 'Passage 2', category: 'P2', frequency: 'high', type: 'reading' }
- }, {
- id: 'reading-p2',
- title: 'Passage 2',
- category: 'P2',
- frequency: 'high',
- type: 'reading'
- }, { currentVersion: '0.6.2-fix', maxRecords: 1000 });
-
- assert(saved, 'saveCompletion 应返回已保存记录');
- assert.strictEqual(saved.examId, 'reading-p2');
- assert.strictEqual(saved.correctAnswerMap.q2, 'NOT GIVEN');
- assert.strictEqual(saved.answerComparison.q2.isCorrect, false);
- assert.strictEqual(practiceState[0].id, saved.id, 'saveCompletion 应写入统一 PracticeCore store');
-
- const hit = await api.getById(saved.id);
- assert(hit, 'getById 应能按 record id 找回记录');
- assert.strictEqual(hit.sessionId, 'session-reading-p2');
-
- const sessionHit = await api.getById('session-reading-p2');
- assert(sessionHit, 'getById 应能按 sessionId 找回记录');
- assert.strictEqual(sessionHit.id, saved.id);
-
- const metrics = api.toSummaryMetrics({ accuracy: 0.75, totalQuestions: 4, correctAnswers: 3 });
- assert.strictEqual(metrics.percentage, 75, 'summary metrics 应从 0..1 accuracy 推导 percentage');
- const importedMetrics = api.toSummaryMetrics({ accuracy: 85, totalQuestions: 20, correctAnswers: 17 });
- assert.strictEqual(importedMetrics.accuracy, 0.85, 'summary metrics 应把 0..100 accuracy 归一到 0..1');
-
- await api.replace([
- {
- id: 'record-delete-a',
- examId: 'reading-delete-a',
- sessionId: 'session-delete-a',
- title: 'Delete A',
- type: 'reading',
- score: 1,
- totalQuestions: 1,
- correctAnswers: 1,
- accuracy: 1
- },
- {
- id: 'record-delete-b',
- examId: 'reading-delete-b',
- sessionId: 'session-delete-b',
- title: 'Delete B',
- type: 'reading',
- score: 0,
- totalQuestions: 1,
- correctAnswers: 0,
- accuracy: 0
- }
- ], { maxRecords: 1000 });
- assert.strictEqual(practiceState.length, 2, 'replace 应写入统一 PracticeCore store');
-
- const deletedBySession = await api.deleteMany(['session-delete-a'], { maxRecords: 1000, updateStats: true, matchBy: 'sessionId' });
- assert.strictEqual(deletedBySession.deletedCount, 1, 'deleteMany 应支持按 sessionId 删除 (matchBy: sessionId)');
- assert.deepStrictEqual(
- practiceState.map((record) => record.id),
- ['record-delete-b'],
- 'deleteMany 应通过统一 replace 更新 canonical store'
- );
- assert.strictEqual(metaState.get('user_stats').totalPractices, 1, 'deleteMany(updateStats) 后统计应按剩余 canonical 记录重算');
-
- const deletedById = await api.deleteById('record-delete-b', { maxRecords: 1000, updateStats: true });
- assert.strictEqual(deletedById.deleted, true, 'deleteById 应支持按 record id 删除');
- assert.strictEqual(practiceState.length, 0, 'deleteById 删除后 canonical store 应为空');
- assert.strictEqual(metaState.get('user_stats').totalPractices, 0, 'deleteById(updateStats) 删除最后一条后统计应归零');
-
- await api.saveRecord({
- id: 'record-clear-a',
- examId: 'reading-clear-a',
- sessionId: 'session-clear-a',
- title: 'Clear A',
- type: 'reading',
- score: 1,
- totalQuestions: 1,
- correctAnswers: 1,
- accuracy: 1
- }, { maxRecords: 1000 });
- assert.strictEqual(practiceState.length, 1, 'saveRecord 应在 clear 前写入记录');
- await api.clear({ maxRecords: 1000, updateStats: true });
- assert.strictEqual(practiceState.length, 0, 'clear 应清空 canonical store');
- assert.strictEqual(metaState.get('user_stats').totalPractices, 0, 'clear(updateStats) 后统计应归零');
-
- recordResult('PracticeRecordAPI 统一记录门面', true, {
- savedRecordId: saved.id,
- metrics
- });
-}
-
-async function testPracticeRecordApiStatsIdempotency(sandbox, practiceState, metaState) {
- const api = sandbox.window.PracticeRecordAPI;
- let recalculateCalls = 0;
- sandbox.window.scoreStorage = {
- get currentVersion() {
- throw new Error('PracticeRecordAPI must not read scoreStorage.currentVersion');
- },
- get maxRecords() {
- throw new Error('PracticeRecordAPI must not read scoreStorage.maxRecords');
- },
- async recalculateUserStats() {
- recalculateCalls += 1;
- throw new Error('PracticeRecordAPI must not delegate stats to scoreStorage');
- }
- };
-
- await api.clear({ maxRecords: 1000 });
- await api.writeStats({
- totalPractices: 0,
- totalTimeSpent: 0,
- averageScore: 0
- });
-
- const payload = {
- type: 'PRACTICE_COMPLETE',
- data: {
- examId: 'reading-idempotent',
- sessionId: 'session-idempotent',
- title: 'Idempotent Passage',
- duration: 300,
- answers: { q1: 'A', q2: 'B' },
- correctAnswerMap: { q1: 'A', q2: 'C' },
- scoreInfo: { correct: 1, total: 2, accuracy: 0.5, percentage: 50 },
- metadata: { category: 'P1', frequency: 'high', type: 'reading' }
- }
- };
- const context = {
- examId: 'reading-idempotent',
- sessionId: 'session-idempotent',
- metadata: { examTitle: 'Idempotent Passage', category: 'P1', frequency: 'high', type: 'reading' }
- };
- const examEntry = {
- id: 'reading-idempotent',
- title: 'Idempotent Passage',
- category: 'P1',
- frequency: 'high',
- type: 'reading'
- };
-
- await api.saveCompletion(payload, context, examEntry, { currentVersion: '0.6.2-fix', maxRecords: 1000, updateStats: true });
- await api.saveCompletion(payload, context, examEntry, { currentVersion: '0.6.2-fix', maxRecords: 1000, updateStats: true });
-
- assert.strictEqual(practiceState.length, 1, '重复 completion 只能保留一条 canonical 记录');
- const stats = metaState.get('user_stats');
- assert(stats, '重复 completion 后应写入 user_stats');
- assert.strictEqual(stats.totalPractices, 1, '重复 completion 不能让 user_stats.totalPractices 翻倍');
- assert.strictEqual(stats.totalTimeSpent, 300, '重复 completion 不能让 user_stats.totalTimeSpent 翻倍');
- assert.strictEqual(stats.averageScore, 0.5, '重复 completion 后平均分应基于唯一 canonical 记录');
- assert.strictEqual(recalculateCalls, 0, 'PracticeRecordAPI 不应调用 scoreStorage.recalculateUserStats');
-
- delete sandbox.window.scoreStorage;
- recordResult('PracticeRecordAPI 统计幂等', true, {
- totalRecords: practiceState.length,
- totalPractices: stats.totalPractices
- });
-}
-
-async function testNumericCorrectAnswersRemainScoreCount(PracticeCore) {
- const standardized = PracticeCore.contracts.standardizeRecord({
- id: 'record-numeric-correct-answers',
- examId: 'reading-numeric-correct-answers',
- sessionId: 'session-numeric-correct-answers',
- title: 'Numeric Correct Answers',
- type: 'reading',
- date: '2026-05-24T00:00:00.000Z',
- score: 7,
- totalQuestions: 10,
- correctAnswers: 7,
- answers: { q1: 'A', q2: 'B' },
- realData: {
- answers: { q1: 'A', q2: 'B' },
- correctAnswers: 7
- }
- }, {
- currentVersion: '0.6.2-fix',
- generateRecordId: () => 'record-generated-should-not-be-used'
- });
-
- assert.strictEqual(standardized.correctAnswers, 7, 'top-level correctAnswers 数字应表示答对数量');
- assert(standardized.realData, 'standardizeRecord 应保留 realData');
- assert.strictEqual(typeof standardized.realData.correctAnswers, 'object', 'realData.correctAnswers 只能是答案表对象');
- assert(!Array.isArray(standardized.realData.correctAnswers), 'realData.correctAnswers 不能是数组');
- assert.strictEqual(
- Object.keys(standardized.realData.correctAnswers).length,
- 0,
- 'numeric realData.correctAnswers 不能被伪造成正确答案表'
- );
-
- recordResult('PracticeCore numeric correctAnswers 契约', true, {
- correctAnswers: standardized.correctAnswers,
- realDataCorrectAnswerKeys: Object.keys(standardized.realData.correctAnswers)
- });
-}
-
-async function testObjectCorrectAnswersBecomeCorrectAnswerMap(PracticeCore) {
- const standardized = PracticeCore.contracts.standardizeRecord({
- id: 'record-object-correct-answers',
- examId: 'reading-object-correct-answers',
- sessionId: 'session-object-correct-answers',
- title: 'Object Correct Answers',
- type: 'reading',
- date: '2026-05-24T00:00:00.000Z',
- totalQuestions: 2,
- answers: { 1: 'A', 2: 'B' },
- correctAnswers: { 1: 'A', 2: 'C' },
- metadata: { examTitle: 'Object Correct Answers', category: 'P1', frequency: 'low', type: 'reading' }
- }, {
- currentVersion: '0.6.2-fix',
- generateRecordId: () => 'record-generated-should-not-be-used'
- });
-
- assert.strictEqual(standardized.correctAnswers, 1, '对象型 correctAnswers 应作为答案表推导答对数量');
- assert.strictEqual(standardized.correctAnswerMap.q1, 'A', '对象型 correctAnswers 应迁移到 correctAnswerMap');
- assert.strictEqual(standardized.correctAnswerMap.q2, 'C', '对象型 correctAnswers 不应丢失后续题目');
- assert.strictEqual(standardized.answers.length, 2, '答案列表应带正确答案表一起归一化');
- assert.strictEqual(standardized.answers[1].correctAnswer, 'C', 'answerList 应保留正确答案');
-
- recordResult('PracticeCore object correctAnswers 兼容答案表', true, {
- correctAnswers: standardized.correctAnswers,
- correctAnswerMap: standardized.correctAnswerMap
- });
-}
-
-async function testSuiteEntryCorrectAnswerMapSurvives(PracticeCore) {
- const standardized = PracticeCore.contracts.standardizeRecord({
- id: 'record-suite-entry-correct-map',
- examId: 'suite-parent',
- sessionId: 'session-suite-entry-correct-map',
- title: 'Suite Parent',
- type: 'reading',
- date: '2026-05-24T00:00:00.000Z',
- suiteMode: true,
- suiteSessionId: 'suite-correct-map',
- suiteEntries: [
- {
- examId: 'reading-p1',
- title: 'Passage 1',
- answers: { 1: 'A', 2: 'B' },
- correctAnswers: { 1: 'A', 2: 'C' },
- scoreInfo: { correct: 1, total: 2, accuracy: 0.5 }
- }
- ],
- metadata: { examTitle: 'Suite Parent', category: 'suite', frequency: 'suite', type: 'reading' }
- }, {
- currentVersion: '0.6.2-fix',
- generateRecordId: () => 'record-generated-should-not-be-used'
- });
-
- const entry = standardized.suiteEntries[0];
- assert(entry, 'suite entry 应存在');
- assert.strictEqual(entry.correctAnswerMap.q1, 'A', 'suite entry 应保留对象型 correctAnswers');
- assert.strictEqual(entry.correctAnswerMap.q2, 'C', 'suite entry correctAnswerMap 不应丢失');
-
- recordResult('PracticeCore suite entry correctAnswerMap 保留', true, {
- correctAnswerMap: entry.correctAnswerMap
- });
-}
-
-async function testCanonicalCorrectAnswerMapWinsOverLegacyObjects(PracticeCore) {
- const standardized = PracticeCore.contracts.standardizeRecord({
- id: 'record-canonical-correct-map',
- examId: 'reading-canonical-correct-map',
- sessionId: 'session-canonical-correct-map',
- title: 'Canonical Correct Map',
- type: 'reading',
- date: '2026-05-24T00:00:00.000Z',
- totalQuestions: 2,
- answers: { q1: 'A', q2: 'B' },
- correctAnswerMap: { q1: 'A', q2: 'D' },
- correctAnswers: { q1: 'B', q2: 'C' },
- realData: {
- answers: { q1: 'A', q2: 'B' },
- correctAnswerMap: { q2: 'D' },
- correctAnswers: { q1: 'B', q2: 'C' }
- },
- metadata: { examTitle: 'Canonical Correct Map', category: 'P1', frequency: 'low', type: 'reading' }
- }, {
- currentVersion: '0.6.2-fix',
- generateRecordId: () => 'record-generated-should-not-be-used'
- });
-
- assert.strictEqual(standardized.correctAnswerMap.q1, 'A', 'canonical correctAnswerMap 不能被 legacy correctAnswers 覆盖');
- assert.strictEqual(standardized.correctAnswerMap.q2, 'D', 'canonical correctAnswerMap 后续题目也不能被 legacy correctAnswers 覆盖');
- assert.strictEqual(standardized.realData.correctAnswers.q1, 'A', 'realData.correctAnswers 应镜像 canonical correctAnswerMap');
- assert.strictEqual(standardized.realData.correctAnswerMap.q2, 'D', 'realData.correctAnswerMap 应镜像 canonical correctAnswerMap');
- assert.strictEqual(standardized.correctAnswers, 1, '答对数量应基于 canonical correctAnswerMap 计算');
-
- recordResult('PracticeCore canonical correctAnswerMap 优先级', true, {
- correctAnswerMap: standardized.correctAnswerMap,
- correctAnswers: standardized.correctAnswers
- });
-}
-
-async function testSuiteEntryDerivesCorrectAnswerMapFromComparison(PracticeCore) {
- const standardized = PracticeCore.contracts.standardizeRecord({
- id: 'record-suite-entry-derived-map',
- examId: 'suite-parent-derived',
- sessionId: 'session-suite-entry-derived-map',
- title: 'Suite Parent Derived',
- type: 'reading',
- date: '2026-05-24T00:00:00.000Z',
- suiteMode: true,
- suiteSessionId: 'suite-derived-map',
- suiteEntries: [
- {
- examId: 'reading-p2',
- title: 'Passage 2',
- answers: { q1: 'A', q2: 'B' },
- answerComparison: {
- q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true },
- q2: { userAnswer: 'B', correctAnswer: 'C', isCorrect: false }
- }
- },
- {
- examId: 'reading-p3',
- title: 'Passage 3',
- answers: { q1: 'TRUE' },
- realData: {
- correctAnswerMap: { q1: 'FALSE' }
- }
- }
- ],
- metadata: { examTitle: 'Suite Parent Derived', category: 'suite', frequency: 'suite', type: 'reading' }
- }, {
- currentVersion: '0.6.2-fix',
- generateRecordId: () => 'record-generated-should-not-be-used'
- });
-
- assert.strictEqual(standardized.suiteEntries[0].correctAnswerMap.q1, 'A', 'suite entry 应能从 answerComparison 派生正确答案');
- assert.strictEqual(standardized.suiteEntries[0].correctAnswerMap.q2, 'C', 'suite entry 派生 correctAnswerMap 不应漏后续题目');
- assert.strictEqual(standardized.suiteEntries[1].correctAnswerMap.q1, 'FALSE', 'suite entry 应读取 realData.correctAnswerMap');
-
- recordResult('PracticeCore suite entry 派生 correctAnswerMap', true, {
- firstEntry: standardized.suiteEntries[0].correctAnswerMap,
- secondEntry: standardized.suiteEntries[1].correctAnswerMap
- });
-}
-
-async function testReplayResultSnapshotCanonicalOnly(PracticeCore) {
- const replay = PracticeCore.contracts.buildReplayResultSnapshot({
- answers: { q1: 'A', q2: 'D' },
- correctAnswerMap: { q1: 'A', q2: 'D' },
- correctAnswers: { q1: 'B', q2: 'C' },
- answerComparison: {
- q1: { userAnswer: 'A', correctAnswer: 'B', isCorrect: false },
- q2: { userAnswer: 'D', correctAnswer: 'C', isCorrect: false }
- },
- scoreInfo: { correct: 0, total: 2, accuracy: 0, percentage: 0 },
- realData: {
- correctAnswerMap: { q2: 'D' },
- correctAnswers: { q1: 'B', q2: 'C' }
- }
- });
-
- assert.strictEqual(replay.correctAnswers.q1, 'A', 'replay 应以 canonical correctAnswerMap 为唯一正确答案表');
- assert.strictEqual(replay.correctAnswers.q2, 'D', 'realData canonical map 只能补缺,不能被 legacy 覆盖');
- assert.strictEqual(replay.correctAnswerMap.q2, 'D', 'replay 应同时输出 canonical correctAnswerMap 别名');
- assert.strictEqual(replay.answerComparison.q1.correctAnswer, 'A', 'comparison 正确答案必须按 canonical map 重建');
- assert.strictEqual(replay.answerComparison.q2.correctAnswer, 'D', 'comparison 后续题目也必须按 canonical map 重建');
- assert.strictEqual(replay.answerComparison.q1.isCorrect, true, 'isCorrect 必须按 canonical map 重算');
- assert.strictEqual(replay.scoreInfo.correct, 2, '完整 canonical map 时必须重算正确数');
- assert.strictEqual(replay.scoreInfo.total, 2, '完整 canonical map 时必须重算总题数');
- assert.strictEqual(replay.scoreInfo.percentage, 100, '完整 canonical map 时必须重算百分比');
-
- recordResult('PracticeCore replay canonical correctAnswerMap 契约', true, {
- correctAnswerMap: replay.correctAnswerMap,
- scoreInfo: replay.scoreInfo
- });
-}
-
-async function testReplayResultSnapshotRefusesLegacyCorrectAnswerFallback(PracticeCore) {
- const replay = PracticeCore.contracts.buildReplayResultSnapshot({
- answers: { q1: 'A' },
- correctAnswers: 7,
- answerComparison: {
- q1: { userAnswer: 'A', correctAnswer: 'B', isCorrect: false }
- },
- scoreInfo: { correct: 5, total: 7, accuracy: 5 / 7, percentage: 71 }
- });
-
- assert.strictEqual(Object.keys(replay.correctAnswers).length, 0, '数字型 correctAnswers 不能被当成 replay 答案表');
- assert.strictEqual(replay.answerComparison.q1.correctAnswer, '', 'comparison.correctAnswer 不能作为 replay 正确答案 fallback');
- assert.strictEqual(replay.answerComparison.q1.isCorrect, null, '缺 canonical map 时不能猜测 isCorrect');
- assert.strictEqual(replay.scoreInfo.correct, 5, '缺 canonical map 时保留来源分数');
- assert.strictEqual(replay.scoreInfo.total, 7, '缺 canonical map 时保留来源总题数');
-
- recordResult('PracticeCore replay 拒绝 legacy 正确答案兜底', true, {
- correctAnswers: replay.correctAnswers,
- scoreInfo: replay.scoreInfo
- });
-}
-
-async function testReplayResultSnapshotSuitePrefixedKeys(PracticeCore) {
- const replay = PracticeCore.contracts.buildReplayResultSnapshot({
- answers: {
- q1: 'A',
- 'reading-p1::q17': 'B'
- },
- correctAnswerMap: {
- q1: 'A',
- 'reading-p1::q17': 'C'
- },
- allQuestionIds: ['q1', 'reading-p1::q17']
- });
-
- assert.strictEqual(replay.answers.q1, 'A', '普通 q1 应保留');
- assert.strictEqual(replay.answers.q17, 'B', 'suite 前缀题号应只取 :: 后的问题段');
- assert.strictEqual(Object.keys(replay.answers).length, 2, 'suite 前缀题号不能和 q1 撞键');
- assert.strictEqual(replay.answerComparison.q17.correctAnswer, 'C', 'suite 前缀正确答案应归一到 q17');
- assert.strictEqual(replay.scoreInfo.total, 2, 'suite 前缀题号应计入总题数');
-
- recordResult('PracticeCore replay suite 前缀题号归一', true, {
- answers: replay.answers,
- correctAnswerMap: replay.correctAnswerMap
- });
-}
-
-async function main() {
- const { repositories, practiceState, metaState } = createRepositoryHarness();
-
- const windowStub = {
- console,
- practiceRecords: []
- };
-
- const sandbox = {
- window: windowStub,
- console,
- setTimeout,
- clearTimeout,
- setInterval,
- clearInterval,
- Date,
- Math,
- JSON
- };
- sandbox.globalThis = sandbox.window;
-
- const context = vm.createContext(sandbox);
- loadScript('js/core/practiceCore.js', context);
- sandbox.window.PracticeCore.__installInternalRepositories(repositories);
- loadScript('js/core/practiceRecordAPI.js', context);
- loadScript('js/core/practiceStore.js', context);
- const PracticeCore = sandbox.window.PracticeCore;
-
- try {
- await testProtocolNormalization(PracticeCore);
- await testCompletionIngestion(PracticeCore);
- await testPracticeRecordApiWritePath(sandbox, PracticeCore, practiceState, metaState);
- await testPracticeRecordApiContract(sandbox, PracticeCore, practiceState, metaState);
- await testPracticeRecordApiStatsIdempotency(sandbox, practiceState, metaState);
- await testNumericCorrectAnswersRemainScoreCount(PracticeCore);
- await testObjectCorrectAnswersBecomeCorrectAnswerMap(PracticeCore);
- await testSuiteEntryCorrectAnswerMapSurvives(PracticeCore);
- await testCanonicalCorrectAnswerMapWinsOverLegacyObjects(PracticeCore);
- await testSuiteEntryDerivesCorrectAnswerMapFromComparison(PracticeCore);
- await testReplayResultSnapshotCanonicalOnly(PracticeCore);
- await testReplayResultSnapshotRefusesLegacyCorrectAnswerFallback(PracticeCore);
- await testReplayResultSnapshotSuitePrefixedKeys(PracticeCore);
-
- const summary = {
- status: 'pass',
- detail: `${results.length}/${results.length} 测试通过`,
- passed: results.length,
- total: results.length
- };
- console.log(JSON.stringify(summary, null, 2));
- } catch (error) {
- recordResult('PracticeCore 测试执行失败', false, { error: error.message });
- console.log(JSON.stringify({
- status: 'fail',
- detail: error.message,
- results
- }, null, 2));
- process.exit(1);
- }
-}
-
-main();
diff --git a/developer/tests/js/practiceCoreAppStateSync.test.js b/developer/tests/js/practiceCoreAppStateSync.test.js
deleted file mode 100644
index f8ada1d4..00000000
--- a/developer/tests/js/practiceCoreAppStateSync.test.js
+++ /dev/null
@@ -1,256 +0,0 @@
-#!/usr/bin/env node
-import fs from 'fs';
-import path from 'path';
-import vm from 'vm';
-import assert from 'assert';
-import { fileURLToPath } from 'url';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-const repoRoot = path.resolve(__dirname, '..', '..', '..');
-
-function loadScript(relativePath, context) {
- const fullPath = path.join(repoRoot, relativePath);
- const source = fs.readFileSync(fullPath, 'utf8');
- vm.runInContext(source, context, { filename: relativePath });
-}
-
-function createHarness() {
- const practiceState = [];
- const stateServiceRecords = [];
- const quietConsole = {
- log() {},
- warn() {},
- error() {},
- info() {},
- debug() {}
- };
-
- const repositories = {
- practice: {
- async list() {
- return practiceState.map((record) => ({ ...record }));
- },
- async overwrite(records) {
- practiceState.splice(0, practiceState.length, ...(Array.isArray(records) ? records.map((record) => ({ ...record })) : []));
- return true;
- }
- },
- meta: {
- async get(_key, fallback = null) {
- return fallback;
- },
- async set() {
- return true;
- },
- async remove() {
- return true;
- }
- }
- };
-
- const sandbox = {
- console: quietConsole,
- Date,
- Math,
- JSON,
- app: {
- state: {
- practice: {
- records: []
- }
- }
- },
- practiceRecords: [{ id: 'legacy-shadow' }],
- setPracticeRecordsState(records) {
- stateServiceRecords.splice(0, stateServiceRecords.length, ...(Array.isArray(records) ? records.map((record) => ({ ...record })) : []));
- return stateServiceRecords.map((record) => ({ ...record }));
- }
- };
-
- sandbox.window = sandbox;
- sandbox.globalThis = sandbox;
- const context = vm.createContext(sandbox);
- loadScript('js/core/practiceCore.js', context);
- sandbox.PracticeCore.__installInternalRepositories(repositories);
- loadScript('js/core/practiceRecordAPI.js', context);
-
- return {
- sandbox,
- practiceState,
- stateServiceRecords
- };
-}
-
-function createStateServiceHarness() {
- let enrichCalls = 0;
- const quietConsole = {
- log() {},
- warn() {},
- error() {},
- info() {},
- debug() {}
- };
-
- const sandbox = {
- console: quietConsole,
- Date,
- Math,
- JSON,
- Set,
- Map,
- Array,
- Object,
- String,
- Number,
- Boolean,
- structuredClone,
- app: {
- state: {
- exam: {},
- practice: { records: [] },
- ui: {},
- system: {}
- }
- },
- DataConsistencyManager: class DataConsistencyManager {
- enrichRecordData() {
- enrichCalls += 1;
- throw new Error('state-service must not create display projections');
- }
-
- ensureConsistency() {
- enrichCalls += 1;
- throw new Error('state-service must not create display projections');
- }
- }
- };
-
- sandbox.window = sandbox;
- sandbox.globalThis = sandbox;
- const context = vm.createContext(sandbox);
- loadScript('js/app/state-service.js', context);
- sandbox.appStateService.connectApp(sandbox.app);
-
- return {
- sandbox,
- getEnrichCalls() {
- return enrichCalls;
- }
- };
-}
-
-async function testPracticeCoreSyncsAppState() {
- const harness = createHarness();
- const record = {
- id: 'record-1',
- sessionId: 'session-1',
- type: 'reading',
- score: 8,
- correctAnswers: 8,
- totalQuestions: 10,
- accuracy: 0.8,
- percentage: 80,
- duration: 100,
- date: '2026-03-09T10:00:00.000Z',
- startTime: '2026-03-09T09:58:20.000Z',
- endTime: '2026-03-09T10:00:00.000Z',
- title: 'Record 1',
- metadata: {
- examTitle: 'Record 1',
- category: 'P1',
- frequency: 'high',
- type: 'reading',
- examType: 'reading'
- }
- };
-
- assert.strictEqual(
- typeof harness.sandbox.PracticeCore.store.replacePracticeRecords,
- 'undefined',
- '公开 PracticeCore.store 不能暴露批量写入口'
- );
-
- await harness.sandbox.PracticeRecordAPI.replace([record]);
-
- assert.deepStrictEqual(
- harness.stateServiceRecords.map((item) => item.id),
- ['record-1'],
- 'replacePracticeRecords 后应同步 state-service 练习记录状态'
- );
- assert.deepStrictEqual(
- harness.sandbox.app.state.practice.records.map((item) => item.id),
- ['record-1'],
- 'replacePracticeRecords 后应同步 app.state.practice.records'
- );
-
- assert.deepStrictEqual(
- harness.sandbox.practiceRecords.map((item) => item.id),
- ['legacy-shadow'],
- 'replacePracticeRecords 不应写回 legacy global.practiceRecords 影子事实源'
- );
-
- await harness.sandbox.PracticeRecordAPI.replace([]);
-
- assert.strictEqual(harness.practiceState.length, 0, 'replacePracticeRecords([]) 应清空 canonical store');
- assert.strictEqual(harness.stateServiceRecords.length, 0, 'replacePracticeRecords([]) 应清空 state-service 练习记录状态');
- assert.strictEqual(harness.sandbox.app.state.practice.records.length, 0, 'replacePracticeRecords([]) 应同步清空 app.state.practice.records');
-}
-
-async function testStateServiceKeepsCanonicalPracticeRecordsOnly() {
- const harness = createStateServiceHarness();
- const record = {
- id: 'canonical-record',
- correctAnswers: 7,
- totalQuestions: 10,
- score: 7,
- realData: {
- answers: { q1: 'A' }
- }
- };
-
- const returned = harness.sandbox.appStateService.setPracticeRecords([record]);
- assert.strictEqual(harness.getEnrichCalls(), 0, 'setPracticeRecords 不应调用 DataConsistencyManager 生成显示投影');
- assert.strictEqual(returned[0].correctAnswers, 7, '返回值应保留数字型 correctAnswers');
- assert.strictEqual(returned[0].answerComparison, undefined, 'canonical state 不应补 answerComparison 显示字段');
- assert.strictEqual(returned[0].realData.correctAnswers, undefined, 'canonical state 不应补 realData.correctAnswers 显示字段');
-
- const appRecord = harness.sandbox.app.state.practice.records[0];
- assert.strictEqual(appRecord.correctAnswers, 7, 'app.state.practice.records 应保留 canonical 数字型 correctAnswers');
- assert.strictEqual(appRecord.answerComparison, undefined, 'app.state.practice.records 不应存显示投影字段');
- assert.strictEqual(appRecord.realData.correctAnswers, undefined, 'app.state.practice.records 不应存 realData.correctAnswers 投影');
-
- const getterRecord = harness.sandbox.getPracticeRecordsState()[0];
- getterRecord.correctAnswers = { q1: 'A' };
- getterRecord.realData.answers.q1 = 'MUTATED';
- const afterGetterMutation = harness.sandbox.appStateService.getPracticeRecords()[0];
- assert.strictEqual(afterGetterMutation.correctAnswers, 7, '全局 getter 返回 clone,不能污染内部 correctAnswers');
- assert.strictEqual(afterGetterMutation.realData.answers.q1, 'A', '全局 getter 返回 clone,不能污染内部 nested realData');
-
- returned[0].realData.answers.q1 = 'RETURNED_MUTATION';
- const afterReturnedMutation = harness.sandbox.appStateService.getPracticeRecords()[0];
- assert.strictEqual(afterReturnedMutation.realData.answers.q1, 'A', 'setPracticeRecords 返回值也必须是 clone');
-
- record.realData.answers.q1 = 'INPUT_MUTATION';
- const afterInputMutation = harness.sandbox.appStateService.getPracticeRecords()[0];
- assert.strictEqual(afterInputMutation.realData.answers.q1, 'A', '输入 record 后续变更不能污染 state-service');
-}
-
-async function main() {
- try {
- await testPracticeCoreSyncsAppState();
- await testStateServiceKeepsCanonicalPracticeRecordsOnly();
- console.log(JSON.stringify({
- status: 'pass',
- detail: 'PracticeCore replacePracticeRecords 与 AppStateService canonical 记录同步契约通过'
- }, null, 2));
- } catch (error) {
- console.log(JSON.stringify({
- status: 'fail',
- detail: error.message
- }, null, 2));
- process.exit(1);
- }
-}
-
-main();
diff --git a/developer/tests/js/practiceCustomCard.test.js b/developer/tests/js/practiceCustomCard.test.js
index 42f9b923..f77bcb65 100644
--- a/developer/tests/js/practiceCustomCard.test.js
+++ b/developer/tests/js/practiceCustomCard.test.js
@@ -33,6 +33,7 @@ function loadCustomCardCalculators(source) {
[
'global.__testCalculateReadingRadarData = calculateReadingRadarData;',
'global.__testCalculatePracticeHeatmapData = calculatePracticeHeatmapData;',
+ 'global.__testFilterByExamType = filterByExamType;',
'})(window);'
].join('\n')
);
@@ -54,7 +55,8 @@ function loadCustomCardCalculators(source) {
vm.runInContext(injected, context, { filename: 'legacyViewBundle.js' });
return {
calculateReadingRadarData: sandboxWindow.__testCalculateReadingRadarData,
- calculatePracticeHeatmapData: sandboxWindow.__testCalculatePracticeHeatmapData
+ calculatePracticeHeatmapData: sandboxWindow.__testCalculatePracticeHeatmapData,
+ filterByExamType: sandboxWindow.__testFilterByExamType
};
}
@@ -106,7 +108,9 @@ try {
assertContains(source, 'function loadPersistedPracticeWidget()', '自定义卡片应提供读取持久化组件的函数');
assertContains(source, 'function persistPracticeWidget(widget)', '自定义卡片应提供写入持久化组件的函数');
assertContains(source, "persistPracticeWidget(widget);", '切换组件时应写回持久化,刷新后才能保持选中');
- assertContains(source, "var PRACTICE_WIDGET_PREFERENCE_KEY = 'practice_custom_widget';", '持久化组件应使用固定的 localStorage 键');
+ assertContains(source, 'window.AppData.preferences.getPracticeWidget()', '自定义卡片应通过 AppData.preferences 读取组件偏好');
+ assertContains(source, 'window.AppData.preferences.setPracticeWidget(widget)', '自定义卡片应通过 AppData.preferences 保存组件偏好');
+ assertNotContains(source, 'practice_custom_widget', '运行期组件偏好不能继续持有 v1 物理 key');
assertContains(source, 'function calculatePracticeHeatmapData(records, monthDate)', '热力图数据聚合函数应存在');
assertContains(source, 'aggregatePracticeHeatmapSets(records, monthStart)', '热力图应按套数聚合练习记录');
assertContains(source, "event.target.closest('[data-practice-heatmap-month]')", '月份按钮事件应单独绑定');
@@ -127,7 +131,7 @@ try {
assertContains(source, 'event.stopPropagation();', '整卡其他区域点击应阻止冒泡且不翻转');
record('自定义卡片业务逻辑守卫');
- const { calculateReadingRadarData, calculatePracticeHeatmapData } = loadCustomCardCalculators(source);
+ const { calculateReadingRadarData, calculatePracticeHeatmapData, filterByExamType } = loadCustomCardCalculators(source);
assert.strictEqual(typeof calculatePracticeHeatmapData, 'function', '热力图聚合函数应可被测试提取');
const heatmapData = calculatePracticeHeatmapData([
{ id: 'h1', date: '2026-05-01T08:00:00', totalQuestions: 13 },
@@ -207,12 +211,69 @@ try {
assert.strictEqual(radarData.totalErrors, 2, '雷达应只统计两道错题');
record('雷达题型映射回归守卫');
+ const lightRadarData = calculateReadingRadarData([{
+ id: 'light-reading',
+ type: 'reading',
+ date: '2026-05-25T00:00:00.000Z',
+ questionTypeErrorCounts: {
+ true_false_not_given: 2,
+ sentence_completion: 1
+ }
+ }, {
+ id: 'light-suite',
+ type: 'suite',
+ date: '2026-05-26T00:00:00.000Z',
+ suiteEntrySummaries: [{
+ examId: 'suite-reading-child',
+ type: 'reading',
+ date: '2026-05-26T00:00:00.000Z',
+ questionTypeErrorCounts: { short_answer: 3 }
+ }, {
+ examId: 'suite-listening-child',
+ type: 'listening',
+ date: '2026-05-26T00:00:00.000Z',
+ questionTypeErrorCounts: { other: 9 }
+ }]
+ }]);
+ const lightRadarCounts = Object.fromEntries(lightRadarData.dataPoints.map((point) => [point.label, point.value]));
+ assert.strictEqual(lightRadarCounts['判断题'], 2, 'light summary 的判断题错题应进入雷达');
+ assert.strictEqual(lightRadarCounts['句子填空'], 1, 'light summary 的句子填空错题应进入雷达');
+ assert.strictEqual(lightRadarCounts['简答题'], 3, '套题 reading entry 的轻量错题应进入雷达');
+ assert.strictEqual(lightRadarData.totalErrors, 6, 'listening entry 不得污染阅读雷达');
+ record('雷达消费 v2 轻量错题投影回归守卫');
+
+ const suiteFilterRecords = [{
+ id: 'reading-only-suite',
+ type: 'suite',
+ suiteEntrySummaries: [{ type: 'reading' }]
+ }, {
+ id: 'listening-only-suite',
+ type: 'suite',
+ suiteEntrySummaries: [{ type: 'listening' }]
+ }, {
+ id: 'mixed-suite',
+ type: 'suite',
+ suiteEntrySummaries: [{ type: 'reading' }, { type: 'listening' }]
+ }];
+ assert.deepStrictEqual(
+ filterByExamType(suiteFilterRecords, [], 'reading').map((record) => record.id),
+ ['reading-only-suite', 'mixed-suite'],
+ '类型筛选必须直接消费 suiteEntrySummaries 的 reading 类型'
+ );
+ assert.deepStrictEqual(
+ filterByExamType(suiteFilterRecords, [], 'listening').map((record) => record.id),
+ ['listening-only-suite', 'mixed-suite'],
+ '类型筛选必须直接消费 suiteEntrySummaries 的 listening 类型'
+ );
+ record('套题类型筛选消费 v2 轻量 entry 投影');
+
[
"this.activeWidget = loadPersistedPracticeWidget() || options.defaultWidget || 'heatmap'",
'function loadPersistedPracticeWidget()',
'function persistPracticeWidget(widget)',
'persistPracticeWidget(widget);',
- "var PRACTICE_WIDGET_PREFERENCE_KEY = 'practice_custom_widget';",
+ 'window.AppData.preferences.getPracticeWidget()',
+ 'window.AppData.preferences.setPracticeWidget(widget)',
'function calculatePracticeHeatmapData(records, monthDate)',
'aggregatePracticeHeatmapSets(records, monthStart)',
'averageSetsPerActiveDay',
diff --git a/developer/tests/js/practiceLightProjectionRenderContract.test.js b/developer/tests/js/practiceLightProjectionRenderContract.test.js
new file mode 100644
index 00000000..1b16a3cd
--- /dev/null
+++ b/developer/tests/js/practiceLightProjectionRenderContract.test.js
@@ -0,0 +1,1317 @@
+#!/usr/bin/env node
+/**
+ * 练习记录 light 投影 <-> 渲染过滤 的跨文件契约测试。
+ *
+ * 复现的线上 bug:做完题后记录已写入,控制台打印「已从 AppData 加载 1 条练习摘要」,
+ * 但练习记录页面一条都不显示。
+ *
+ * - js/data/v2/appData.js `lightFromCanonical` 曾把缺失的 dataSource 回退成 `null`;
+ * - js/main.js `updatePracticeView` 渲染前按 `dataSource === 'real' || === undefined` 过滤;
+ * - `null` 两个都不匹配 => 记录被整条过滤掉 => 界面空白。
+ *
+ * 本文件用三层防线锁住这个语义:
+ * 1. 投影层契约:light 投影产出的 dataSource 必须能通过"最严格的历史过滤条件"。
+ * 断言写成"能否通过渲染过滤",而不是硬编码 'real' 还是 undefined,
+ * 这样投影侧无论选哪个合法值都算通过,只有 null 之类的哨兵值会失败。
+ * 2. 跨文件端到端:真实 AppData 写入 -> 真实 light 投影读出 -> 真实
+ * js/main.js `updatePracticeView` 渲染,断言记录真的到达了 renderer。
+ * 无论未来 light 投影或过滤条件怎么改,只要存进去的记录显示不出来就失败。
+ * 3. 同类隐患静态守卫:light 投影里其他 `|| null` 回退字段,不允许出现
+ * 只认 `=== undefined` 的下游消费方(反向同理)。
+ */
+
+import assert from 'assert';
+import fs from 'fs';
+import path from 'path';
+import vm from 'vm';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const repoRoot = path.resolve(__dirname, '..', '..', '..');
+
+const APP_DATA_SOURCE = 'js/data/v2/appData.js';
+const MAIN_SOURCE = 'js/main.js';
+const RECORD_SOURCE_MODULE = 'js/data/practiceRecordSource.js';
+const ONBOARDING_SOURCE = 'js/components/onboardingTour.js';
+
+/**
+ * 历史上最严格的渲染过滤条件(js/main.js updatePracticeView 引爆此 bug 时的原文)。
+ *
+ * 故意保留这个"窄"版本作为投影层的契约:light 投影不允许产出任何需要下游放宽
+ * 判断才能显示的哨兵值。放宽 main.js 的过滤只是补救,投影侧本身必须是干净的。
+ */
+const passesRenderFilter = (record) => Boolean(record)
+ && (record.dataSource === 'real' || record.dataSource === undefined);
+
+function readSource(relativePath) {
+ return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
+}
+
+function createMemoryStorage() {
+ const values = new Map();
+ return {
+ get length() { return values.size; },
+ key(index) { return Array.from(values.keys())[index] ?? null; },
+ getItem(key) { return values.has(String(key)) ? values.get(String(key)) : null; },
+ setItem(key, value) { values.set(String(key), String(value)); },
+ removeItem(key) { values.delete(String(key)); }
+ };
+}
+
+/**
+ * 加载真实 AppData 领域代码 + 内存 FakeKernel。
+ * IDB-only 内核在 Node 没有 IndexedDB;FakeKernel 只替换持久层,light 投影 / completeAttempt
+ * 仍走生产 appData.js,保证投影契约可在 CI 无浏览器时验证。
+ */
+function loadRealAppData() {
+ const catalogSource = readSource('js/data/v2/dataCatalog.js');
+ const appDataSource = readSource(APP_DATA_SOURCE);
+ const recordSource = readSource(RECORD_SOURCE_MODULE);
+ const clone = (value) => (value === undefined ? undefined : structuredClone(value));
+ function stable(value) {
+ if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
+ if (value && typeof value === 'object') {
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`;
+ }
+ return JSON.stringify(value);
+ }
+ function checksum(value) {
+ let hash = 0x811c9dc5;
+ for (const char of stable(value)) {
+ hash ^= char.charCodeAt(0);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ return `fnv1a-${(hash >>> 0).toString(16)}`;
+ }
+ class AppDataError extends Error {
+ constructor(code, message) {
+ super(message);
+ this.code = code;
+ }
+ }
+
+ const catalogSandbox = { structuredClone };
+ catalogSandbox.globalThis = catalogSandbox;
+ vm.runInContext(catalogSource, vm.createContext(catalogSandbox), { filename: 'dataCatalog.js' });
+ const catalog = catalogSandbox.__AppDataV2Catalog;
+ const shared = {
+ docs: new Map(),
+ entities: new Map([
+ ['practiceSummaries', new Map()],
+ ['practiceDetails', new Map()],
+ ['practiceAnnotations', new Map()]
+ ]),
+ counter: 0
+ };
+ const envelope = (key, data, state = 'present', revision = 1, operationId = 'seed') => ({
+ schemaVersion: 2,
+ revision,
+ operationId,
+ updatedAt: new Date().toISOString(),
+ state,
+ data: state === 'cleared' ? null : clone(data),
+ checksum: checksum(state === 'cleared' ? null : data)
+ });
+
+ class Kernel {
+ async initialize() {
+ this.state = 'ready';
+ this.backend = 'memory';
+ return this;
+ }
+ async read(key, options = {}) {
+ const entry = catalog.get(key);
+ const value = shared.docs.get(key) || null;
+ const data = !value || value.state === 'cleared' ? entry.defaultValue() : value.data;
+ return options.withMeta ? { data: clone(data), envelope: clone(value) } : clone(data);
+ }
+ async mutate(changes, options = {}) {
+ const op = String(options.operationId || `doc-${++shared.counter}`);
+ const revisions = {};
+ for (const change of changes) {
+ const old = shared.docs.get(change.logicalKey);
+ if (change.expectedRevision !== undefined
+ && Number(change.expectedRevision) !== Number(old && old.revision || 0)) {
+ throw new AppDataError('CONFLICT', 'document revision');
+ }
+ const revision = Number(old && old.revision || 0) + 1;
+ shared.docs.set(change.logicalKey, envelope(change.logicalKey, change.data, change.state, revision, op));
+ revisions[change.logicalKey] = revision;
+ }
+ return { committed: true, operationId: op, revisions, derived: { status: 'ready', pending: [] }, warnings: [] };
+ }
+ async journalNoop(options = {}) {
+ return {
+ committed: true,
+ operationId: options.operationId || `noop-${++shared.counter}`,
+ revisions: {},
+ derived: { status: 'ready', pending: [] },
+ warnings: []
+ };
+ }
+ async readEntity(store, recordId, options = {}) {
+ const row = shared.entities.get(store).get(String(recordId)) || null;
+ return options.withMeta ? clone(row) : row && clone(row.data);
+ }
+ async listEntities(store, options = {}) {
+ if (store !== 'practiceSummaries') throw new AppDataError('VALIDATION', 'details are not listable');
+ const rows = Array.from(shared.entities.get(store).values());
+ return options.withMeta ? clone(rows) : rows.map((row) => clone(row.data));
+ }
+ async readPracticeSnapshot(recordIds = null, options = {}) {
+ const ids = recordIds == null ? null : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]).map(String));
+ const stores = options.stores || ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'];
+ return Object.fromEntries(stores.map((store) => [store, Array.from(shared.entities.get(store).values())
+ .filter((row) => !ids || ids.has(String(row.recordId)))
+ .map((row) => options.withMeta ? clone(row) : clone(row.data))]));
+ }
+ async mutateEntities(operations, options = {}) {
+ const op = String(options.operationId || `entity-${++shared.counter}`);
+ const revisions = {};
+ for (const item of operations) {
+ const rows = shared.entities.get(item.store);
+ if (item.type === 'clear') {
+ rows.clear();
+ revisions[`${item.store}/*`] = 0;
+ continue;
+ }
+ const old = rows.get(String(item.recordId));
+ if (item.expectedRevision !== undefined && item.expectedRevision !== null
+ && Number(item.expectedRevision) !== Number(old && old.revision || 0)) {
+ throw new AppDataError('CONFLICT', 'entity revision');
+ }
+ if (item.type === 'delete') {
+ rows.delete(String(item.recordId));
+ revisions[`${item.store}/${item.recordId}`] = Number(old && old.revision || 0) + 1;
+ } else {
+ const row = {
+ recordId: String(item.recordId),
+ revision: Number(old && old.revision || 0) + 1,
+ operationId: op,
+ updatedAt: new Date().toISOString(),
+ data: clone(item.data),
+ checksum: checksum(item.data)
+ };
+ rows.set(row.recordId, row);
+ revisions[`${item.store}/${item.recordId}`] = row.revision;
+ }
+ }
+ return { committed: true, operationId: op, revisions, derived: { status: 'ready', pending: [] }, warnings: [] };
+ }
+ async exportSnapshot() {
+ const envelopes = {};
+ for (const [key, value] of shared.docs) {
+ if (catalog.get(key).export === true) envelopes[key] = clone(value);
+ }
+ const entities = Object.fromEntries(
+ Array.from(shared.entities, ([store, rows]) => [store, Array.from(rows.values()).map(clone)])
+ );
+ const snapshot = {
+ format: 'ielts-atlas-data-v2',
+ schemaVersion: 2,
+ scope: 'full',
+ envelopes,
+ entities
+ };
+ snapshot.checksum = checksum({ envelopes, entities });
+ return snapshot;
+ }
+ async installSnapshot(snapshot, options = {}) {
+ for (const [key, value] of Object.entries(snapshot.envelopes || {})) shared.docs.set(key, clone(value));
+ for (const [store, rows] of Object.entries(snapshot.entities || {})) {
+ shared.entities.set(store, new Map(rows.map((row) => [String(row.recordId), clone(row)])));
+ }
+ return {
+ committed: true,
+ operationId: options.operationId || `install-${++shared.counter}`,
+ revisions: {},
+ derived: { status: 'ready', pending: [] },
+ warnings: []
+ };
+ }
+ onCommitted() { return () => {}; }
+ status() { return { state: this.state, backend: this.backend, failure: null }; }
+ }
+
+ const internals = {
+ DataKernel: Kernel,
+ AppDataError,
+ catalog,
+ clone,
+ checksum,
+ randomId: (prefix) => `${prefix}-${++shared.counter}`,
+ nowIso: () => new Date().toISOString(),
+ makeEnvelope: (entry, data, options = {}) => envelope(entry.logicalKey, data, options.state, options.revision, options.operationId),
+ validateEnvelope: (_entry, value) => Boolean(value && value.schemaVersion === 2 && value.checksum === checksum(value.data))
+ };
+ const sandbox = {
+ console: { log() {}, warn() {}, error() {} },
+ Date,
+ JSON,
+ Math,
+ Map,
+ Set,
+ Promise,
+ structuredClone,
+ setTimeout,
+ clearTimeout,
+ __AppDataV2Internals: internals,
+ localStorage: createMemoryStorage(),
+ sessionStorage: createMemoryStorage()
+ };
+ sandbox.window = sandbox;
+ sandbox.globalThis = sandbox;
+ const context = vm.createContext(sandbox);
+ vm.runInContext(recordSource, context, { filename: RECORD_SOURCE_MODULE });
+ vm.runInContext(appDataSource, context, { filename: APP_DATA_SOURCE });
+ return sandbox.AppData;
+}
+
+/**
+ * 在 VM 里加载真实的 js/main.js 并暴露真实的 updatePracticeView。
+ * 只 stub 渲染出口(PracticeHistoryRenderer)和最小 DOM,过滤逻辑本身是生产代码。
+ *
+ * 同时加载 js/data/practiceRecordSource.js —— 这是"什么算真实练习记录"的唯一权威判定,
+ * 线上由 browse.bundle.js 与 main.js 同批提供(core-foundation 也内联同一份供投影器使用)。
+ * 不加载它,main.js 会走"分类器缺失"的保底分支并放行全部记录,演示记录过滤将测不到。
+ */
+function loadRealPracticeView() {
+ const renderedBatches = [];
+ const summaries = [];
+ const historyContainer = {
+ id: 'history-list',
+ innerHTML: '',
+ addEventListener() {},
+ contains() { return false; }
+ };
+ const quietConsole = { log() {}, warn() {}, error() {}, info() {}, debug() {} };
+ const sandbox = {
+ console: quietConsole,
+ setTimeout,
+ clearTimeout,
+ setInterval,
+ clearInterval,
+ Date,
+ Math,
+ JSON,
+ // updatePracticeView 用到的少量顶层协作函数(真实实现在其他 bundle 成员里)。
+ getBulkDeleteModeState: () => false,
+ getSelectedRecordsState: () => new Set(),
+ document: {
+ addEventListener() {},
+ getElementById(id) {
+ return id === 'history-list' ? historyContainer : null;
+ },
+ querySelector() { return null; },
+ querySelectorAll() { return []; },
+ createElement() {
+ return {
+ style: {},
+ classList: { add() {}, remove() {} },
+ appendChild() {},
+ setAttribute() {}
+ };
+ }
+ }
+ };
+ sandbox.window = sandbox;
+ sandbox.globalThis = sandbox;
+ sandbox.window.location = { origin: 'http://localhost' };
+ sandbox.window.addEventListener = () => {};
+ sandbox.window.PracticeHistoryRenderer = {
+ renderView(options) {
+ // Array.from:records 来自 VM realm,跨 realm 数组的原型不同,
+ // 直接 slice() 会让 deepStrictEqual 因原型不一致而误报。
+ renderedBatches.push(Array.from(options && Array.isArray(options.records) ? options.records : []));
+ return { scroller: null };
+ }
+ };
+ sandbox.window.PracticeDashboardView = null;
+ // 汇总卡片(已练题数等)也读同一份过滤结果,一起观测。
+ sandbox.window.PracticeStats = {
+ calculateSummary(records) {
+ summaries.push(Array.from(records));
+ return { totalPracticed: records.length, averageScore: 0, totalStudyMinutes: 0, streak: 0 };
+ },
+ sortByDateDesc(records) {
+ return records.slice().sort((left, right) => new Date(right.date) - new Date(left.date));
+ }
+ };
+
+ const context = vm.createContext(sandbox);
+ vm.runInContext(readSource(RECORD_SOURCE_MODULE), context, { filename: RECORD_SOURCE_MODULE });
+ vm.runInContext(readSource(MAIN_SOURCE), context, { filename: MAIN_SOURCE });
+ assert.strictEqual(typeof sandbox.updatePracticeView, 'function',
+ 'js/main.js 必须暴露顶层 updatePracticeView,否则本测试的观测点已失效');
+ assert.strictEqual(typeof sandbox.PracticeRecordSource?.isRealPracticeRecord, 'function',
+ 'PracticeRecordSource 必须已安装,否则 main.js 会走保底分支放行全部记录,过滤断言形同虚设');
+
+ return {
+ updatePracticeView: sandbox.updatePracticeView,
+ recordSource: sandbox.PracticeRecordSource,
+ renderedBatches,
+ summaries,
+ lastRenderedIds() {
+ const last = renderedBatches.at(-1) || [];
+ return Array.from(last, (record) => String(record && record.id || '')).sort();
+ },
+ lastSummaryIds() {
+ const last = summaries.at(-1) || [];
+ return Array.from(last, (record) => String(record && record.id || '')).sort();
+ }
+ };
+}
+
+function createDeferred() {
+ let resolve;
+ let reject;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+function createManualClock() {
+ let now = 0;
+ let nextId = 1;
+ const timers = new Map();
+ return {
+ setTimeout(callback, delay = 0) {
+ const id = nextId++;
+ timers.set(id, { callback, dueAt: now + Number(delay || 0) });
+ return id;
+ },
+ clearTimeout(id) { timers.delete(id); },
+ advance(milliseconds) {
+ const target = now + milliseconds;
+ while (true) {
+ const due = Array.from(timers.entries())
+ .filter(([, timer]) => timer.dueAt <= target)
+ .sort((left, right) => left[1].dueAt - right[1].dueAt || left[0] - right[0])[0];
+ if (!due) break;
+ const [id, timer] = due;
+ timers.delete(id);
+ now = timer.dueAt;
+ timer.callback();
+ }
+ now = target;
+ },
+ pendingDelay(delay) {
+ return Array.from(timers.values()).filter((timer) => timer.dueAt - now === delay).length;
+ }
+ };
+}
+
+function createOnboardingSandbox({ resolveIndex, completeAttempt, clock = null }) {
+ const previewIds = new Set();
+ const deletedIds = [];
+ let refreshCount = 0;
+ let queryCount = 0;
+ const elementsById = new Map();
+ const makeClassList = () => {
+ const values = new Set();
+ return {
+ add(...names) { names.forEach((name) => values.add(name)); },
+ remove(...names) { names.forEach((name) => values.delete(name)); },
+ contains(name) { return values.has(name); }
+ };
+ };
+ const makeElement = (tagName = 'div') => {
+ const element = {
+ tagName: String(tagName).toUpperCase(),
+ style: {},
+ dataset: {},
+ classList: makeClassList(),
+ children: [],
+ innerHTML: '',
+ offsetWidth: 320,
+ offsetHeight: 160,
+ appendChild(child) {
+ this.children.push(child);
+ child.parentNode = this;
+ if (child.id) elementsById.set(child.id, child);
+ return child;
+ },
+ remove() {
+ if (this.parentNode) {
+ const index = this.parentNode.children.indexOf(this);
+ if (index >= 0) this.parentNode.children.splice(index, 1);
+ this.parentNode = null;
+ }
+ if (this.id) elementsById.delete(this.id);
+ },
+ addEventListener() {},
+ removeEventListener() {},
+ querySelector() { return null; },
+ querySelectorAll() { return []; },
+ getBoundingClientRect() {
+ return { top: 100, left: 100, right: 200, bottom: 140, width: 100, height: 40 };
+ },
+ scrollIntoView() {}
+ };
+ return element;
+ };
+ const body = makeElement('body');
+ const documentElement = makeElement('html');
+ documentElement.scrollTop = 0;
+ const document = {
+ body,
+ documentElement,
+ createElement: makeElement,
+ getElementById(id) { return elementsById.get(id) || null; },
+ querySelector() { queryCount += 1; return null; },
+ querySelectorAll() { return []; },
+ addEventListener() {},
+ removeEventListener() {}
+ };
+ const sandbox = {
+ console: { log() {}, warn() {}, error() {} },
+ document,
+ setTimeout: clock ? clock.setTimeout : setTimeout,
+ clearTimeout: clock ? clock.clearTimeout : clearTimeout,
+ requestAnimationFrame(callback) { callback(); },
+ CustomEvent: class CustomEvent { constructor(type, options) { this.type = type; this.detail = options?.detail; } },
+ innerWidth: 1280,
+ innerHeight: 800,
+ scrollY: 0,
+ scrollTo() {},
+ addEventListener() {},
+ removeEventListener() {},
+ dispatchEvent() {},
+ resolveActiveLibraryIndex: resolveIndex,
+ syncPracticeRecords: async () => { refreshCount += 1; },
+ PracticeRecordSource: {
+ allowPreviewRecordId(id) { previewIds.add(String(id)); },
+ clearPreviewRecordId(id) { previewIds.delete(String(id)); }
+ },
+ AppData: {
+ ready: Promise.resolve(),
+ preferences: {
+ async getOnboarding() { return {}; },
+ async setOnboarding() {}
+ },
+ practice: {
+ completeAttempt,
+ async delete({ recordId }) { deletedIds.push(String(recordId)); }
+ }
+ }
+ };
+ sandbox.window = sandbox;
+ sandbox.globalThis = sandbox;
+ const context = vm.createContext(sandbox);
+ vm.runInContext(readSource(ONBOARDING_SOURCE), context, { filename: ONBOARDING_SOURCE });
+ sandbox.OnboardingTour.registerSteps([{
+ id: 'review',
+ activateView: null,
+ subSteps: [{ id: 'inject', action: 'injectDemoRecord', target: null }]
+ }]);
+ return {
+ api: sandbox.OnboardingTour,
+ previewIds,
+ deletedIds,
+ body,
+ getRefreshCount: () => refreshCount,
+ getQueryCount: () => queryCount
+ };
+}
+
+async function flushAsyncWork(turns = 4) {
+ for (let index = 0; index < turns; index += 1) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+}
+
+async function flushPromiseWork(turns = 16) {
+ for (let index = 0; index < turns; index += 1) await Promise.resolve();
+}
+
+/** 提取 appData.js 里 lightFromCanonical 的函数体,用于静态字段分析。 */
+function extractLightProjectionBody(source) {
+ const match = source.match(/function lightFromCanonical\(source\)\s*\{[\s\S]*?\n \}/);
+ assert(match, 'appData.js 中必须存在 lightFromCanonical,静态守卫依赖它定位投影字段');
+ return match[0];
+}
+
+/** 找出 light 投影里"取不到值就落到某个哨兵"的字段。 */
+function classifyProjectionFallbacks(body) {
+ const nullFallback = [];
+ const undefinedFallback = [];
+ for (const line of body.split('\n')) {
+ const field = line.match(/^\s*([A-Za-z0-9_]+):\s*(.*)$/);
+ if (!field) continue;
+ const [, name, expression] = field;
+ if (/(\|\||\?\?)\s*null\b/.test(expression) || /==\s*null\s*\?\s*null\b/.test(expression)) {
+ nullFallback.push(name);
+ }
+ if (/(\|\||\?\?)\s*undefined\b/.test(expression)) {
+ undefinedFallback.push(name);
+ }
+ }
+ return { nullFallback, undefinedFallback };
+}
+
+function collectSourceFiles(directory, files = []) {
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+ const fullPath = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ // bundles 是构建产物,源码守住即可,避免同一处问题重复报告。
+ if (entry.name === 'node_modules' || entry.name === 'bundles') continue;
+ collectSourceFiles(fullPath, files);
+ continue;
+ }
+ if (entry.name.endsWith('.js')) files.push(fullPath);
+ }
+ return files;
+}
+
+// ---------------------------------------------------------------------------
+// 1. 投影层契约:dataSource 语义
+// ---------------------------------------------------------------------------
+
+async function testLightProjectionDataSourcePassesRenderFilter() {
+ const app = loadRealAppData();
+ await app.ready;
+
+ // 三种真实形态:显式 dataSource / 完全缺失 / 只在 metadata 里。
+ await app.practice.completeAttempt({
+ operationId: 'light-ds-explicit',
+ record: {
+ id: 'ds-explicit', sessionId: 'ds-explicit', examId: 'reading-explicit', type: 'reading',
+ dataSource: 'real', totalQuestions: 4, correctAnswers: 3, duration: 90,
+ date: '2026-07-20T10:00:00.000Z'
+ }
+ });
+ await app.practice.completeAttempt({
+ operationId: 'light-ds-absent',
+ record: {
+ id: 'ds-absent', sessionId: 'ds-absent', examId: 'reading-absent', type: 'reading',
+ totalQuestions: 4, correctAnswers: 2, duration: 80,
+ date: '2026-07-20T11:00:00.000Z'
+ }
+ });
+ await app.practice.completeAttempt({
+ operationId: 'light-ds-metadata',
+ record: {
+ id: 'ds-metadata', sessionId: 'ds-metadata', examId: 'reading-metadata', type: 'reading',
+ metadata: { dataSource: 'real', examTitle: 'metadata only' },
+ totalQuestions: 4, correctAnswers: 1, duration: 70,
+ date: '2026-07-20T12:00:00.000Z'
+ }
+ });
+
+ const listed = await app.practice.list({ projection: 'light' });
+ const byId = new Map(listed.map((record) => [String(record.id), record]));
+
+ const explicit = byId.get('ds-explicit');
+ assert(explicit, 'practice.list light 必须包含显式带 dataSource 的记录');
+ assert.strictEqual(explicit.dataSource, 'real',
+ 'light 投影必须原样保留记录自带的 dataSource');
+ assert(passesRenderFilter(explicit),
+ '带 dataSource: "real" 的记录必须能通过练习记录渲染过滤');
+
+ const metadataOnly = byId.get('ds-metadata');
+ assert(metadataOnly, 'practice.list light 必须包含仅 metadata 带 dataSource 的记录');
+ assert.strictEqual(metadataOnly.dataSource, 'real',
+ 'light 投影必须从 metadata.dataSource 取出 dataSource');
+ assert(passesRenderFilter(metadataOnly),
+ '仅 metadata.dataSource 带值的记录必须能通过练习记录渲染过滤');
+
+ const absent = byId.get('ds-absent');
+ assert(absent, 'practice.list light 必须包含不带 dataSource 的记录');
+ // 这是本 bug 的核心断言:不硬编码期望值,只要求"能被渲染"。
+ // `|| null` 回退会在这里失败(null 既不是 'real' 也不是 undefined)。
+ assert(passesRenderFilter(absent),
+ 'dataSource 缺失时 light 投影的回退值必须能通过渲染过滤'
+ + `('real' 或 undefined 均可,禁止 null 等哨兵值),实际得到 ${JSON.stringify(absent.dataSource)}`);
+ assert.notStrictEqual(absent.dataSource, null,
+ 'light 投影禁止把缺失的 dataSource 写成 null:消费方按 === undefined 判缺失,null 会让记录整条消失');
+
+ // practice.get 与 detail 投影共用同一条回退链,必须同样安全。
+ const fetchedLight = await app.practice.get('ds-absent', { projection: 'light' });
+ assert(passesRenderFilter(fetchedLight),
+ 'practice.get light 投影的 dataSource 回退必须与 practice.list 一致且可渲染');
+ const fetchedDetail = await app.practice.get('ds-absent', { projection: 'detail' });
+ assert(passesRenderFilter(fetchedDetail),
+ 'detail 投影复用 lightFromCanonical,dataSource 回退同样不得阻断渲染');
+
+ // 全量投影不做回退,保持原样(缺失即缺失),确认没有反向污染权威数据。
+ const canonical = await app.practice.get('ds-absent');
+ assert(passesRenderFilter(canonical),
+ '全量 canonical 记录本来就没有 dataSource,必须仍然可渲染');
+}
+
+async function testFalsyDataSourcesPreserveProjectionAndJudgement() {
+ const app = loadRealAppData();
+ await app.ready;
+ const classifier = loadRealPracticeView().recordSource;
+ const fixtures = [
+ { id: 'top-false', record: { dataSource: false }, expected: false },
+ { id: 'top-zero', record: { dataSource: 0 }, expected: false },
+ { id: 'top-empty', record: { dataSource: '' }, expected: true },
+ { id: 'top-null', record: { dataSource: null }, expected: true },
+ { id: 'metadata-false', record: { metadata: { dataSource: false } }, expected: false },
+ { id: 'metadata-zero', record: { metadata: { dataSource: 0 } }, expected: false },
+ { id: 'metadata-empty', record: { metadata: { dataSource: '' } }, expected: true },
+ { id: 'metadata-null', record: { metadata: { dataSource: null } }, expected: true },
+ { id: 'top-demo', record: { dataSource: 'demo' }, expected: false },
+ { id: 'top-e2e', record: { dataSource: 'e2e-seed' }, expected: false },
+ { id: 'metadata-onboarding', record: { metadata: { source: 'onboarding-demo' } }, expected: false },
+ {
+ id: 'top-null-wins',
+ record: { dataSource: null, metadata: { dataSource: 'demo' } },
+ expected: true
+ }
+ ];
+
+ for (const [index, fixture] of fixtures.entries()) {
+ await app.practice.completeAttempt({
+ operationId: `falsy-source-${fixture.id}`,
+ record: buildFixtureRecord(fixture, index)
+ });
+ }
+
+ for (const fixture of fixtures) {
+ const projections = await Promise.all(['full', 'light', 'detail'].map((projection) => (
+ app.practice.get(fixture.id, { projection })
+ )));
+ const hasTopDataSource = Object.prototype.hasOwnProperty.call(fixture.record, 'dataSource');
+ const metadataHasDataSource = Object.prototype.hasOwnProperty.call(fixture.record.metadata || {}, 'dataSource');
+ const expectedValue = hasTopDataSource
+ ? fixture.record.dataSource
+ : (metadataHasDataSource ? fixture.record.metadata.dataSource : undefined);
+
+ for (const [index, projection] of projections.entries()) {
+ const projectionName = ['full', 'light', 'detail'][index];
+ assert.strictEqual(classifier.isRealPracticeRecord(projection), fixture.expected,
+ `${fixture.id} 的 ${projectionName} isReal 结果必须一致`);
+ if (hasTopDataSource || (metadataHasDataSource && projectionName !== 'full')) {
+ assert(Object.prototype.hasOwnProperty.call(projection, 'dataSource'),
+ `${fixture.id} 的 ${projectionName} 投影必须保留显式 dataSource 属性`);
+ assert.strictEqual(projection.dataSource, expectedValue,
+ `${fixture.id} 的 ${projectionName} 投影不得吞掉或改写 falsy dataSource`);
+ }
+ if (metadataHasDataSource) {
+ assert.strictEqual(projection.metadata.dataSource, fixture.record.metadata.dataSource,
+ `${fixture.id} 的 ${projectionName} metadata.dataSource 必须原样保留`);
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// 2. 跨文件端到端:存进去的记录必须显示出来
+// ---------------------------------------------------------------------------
+
+async function testStoredRecordsReachRenderedHistoryList() {
+ const app = loadRealAppData();
+ await app.ready;
+
+ // 一条真实形态的练习记录:走 examSessionMixin / practiceRecorder 的字段布局。
+ const realShapedRecord = {
+ id: 'session-real-shape',
+ sessionId: 'session-real-shape',
+ examId: 'reading-p1-real',
+ title: 'Reading P1 Real Shape',
+ type: 'reading',
+ category: 'P1',
+ startTime: '2026-07-21T09:00:00.000Z',
+ endTime: '2026-07-21T09:20:00.000Z',
+ date: '2026-07-21T09:20:00.000Z',
+ duration: 1200,
+ totalQuestions: 13,
+ correctAnswers: 11,
+ accuracy: 11 / 13,
+ percentage: 84.6,
+ dataSource: 'real',
+ isRealData: true,
+ scoreInfo: { correct: 11, total: 13, accuracy: 11 / 13, percentage: 84.6, source: 'data_collector' },
+ realData: { isRealData: true, answers: { q1: 'A' }, scoreInfo: { correct: 11, total: 13 } },
+ metadata: { examTitle: 'Reading P1 Real Shape', category: 'P1' }
+ };
+ // 同批再放一条历史形态:没人写过 dataSource(迁移记录 / 老版本落库)。
+ const unlabelledRecord = {
+ id: 'session-unlabelled',
+ sessionId: 'session-unlabelled',
+ examId: 'reading-p2-unlabelled',
+ title: 'Reading P2 Unlabelled',
+ type: 'reading',
+ date: '2026-07-21T10:00:00.000Z',
+ duration: 900,
+ totalQuestions: 13,
+ correctAnswers: 7,
+ percentage: 53.8
+ };
+
+ await app.practice.completeAttempt({ operationId: 'e2e-real-shape', record: realShapedRecord });
+ await app.practice.completeAttempt({ operationId: 'e2e-unlabelled', record: unlabelledRecord });
+
+ const summaries = await app.practice.list({ projection: 'light' });
+ assert.strictEqual(summaries.length, 2,
+ '两条记录都必须落进 practice.summaries light 投影');
+
+ // 2a. 投影结果本身必须能通过渲染过滤(与 main.js 解耦的独立断言)。
+ for (const summary of summaries) {
+ assert(passesRenderFilter(summary),
+ `存入的记录 ${summary.id} 的 light 投影必须能通过渲染过滤,`
+ + `实际 dataSource=${JSON.stringify(summary.dataSource)}`);
+ }
+
+ // 2b. 真正跑一遍生产渲染入口:AppData 存进去的东西必须出现在历史列表里。
+ // 这一层不复刻任何过滤条件,是"存了就必须能看见"的本质保护。
+ const view = loadRealPracticeView();
+ view.updatePracticeView(summaries, [
+ { id: 'reading-p1-real', title: 'Reading P1 Real Shape', type: 'reading', category: 'P1' },
+ { id: 'reading-p2-unlabelled', title: 'Reading P2 Unlabelled', type: 'reading', category: 'P2' }
+ ]);
+
+ assert.strictEqual(view.renderedBatches.length, 1,
+ 'updatePracticeView 必须把过滤后的记录交给 PracticeHistoryRenderer 渲染一次');
+ assert.deepStrictEqual(
+ view.lastRenderedIds(),
+ ['session-real-shape', 'session-unlabelled'],
+ '经 AppData.practice.completeAttempt 存入并以 light 投影读出的记录,必须全部出现在渲染列表中'
+ + '(一条都不显示 = 用户线上遇到的空白练习记录页)'
+ );
+ assert.deepStrictEqual(
+ view.lastSummaryIds(),
+ ['session-real-shape', 'session-unlabelled'],
+ '汇总卡片(已练题数/正确率)必须与历史列表看到同一批记录,不能被同一个过滤条件吃掉'
+ );
+}
+
+async function testRenderFilterKeepsUnlabelledRecordsAfterProjectionChange() {
+ // 防止过滤条件被再次收窄:直接把 light 投影可能产出的每种 dataSource 形态
+ // 喂给真实 updatePracticeView,只允许"明确的非真实来源"被排除。
+ const view = loadRealPracticeView();
+ const app = loadRealAppData();
+ await app.ready;
+
+ await app.practice.completeAttempt({
+ operationId: 'filter-shape-probe',
+ record: { id: 'probe', sessionId: 'probe', examId: 'reading-probe', type: 'reading', date: '2026-07-22T09:00:00.000Z' }
+ });
+ const [projected] = await app.practice.list({ projection: 'light' });
+ assert(projected, 'light 投影必须产出探针记录');
+
+ // 用真实投影产出的形态克隆出多条记录,逐一确认它们都能显示。
+ const shapes = [
+ { id: 'shape-projected', record: { ...projected, id: 'shape-projected' } },
+ { id: 'shape-real', record: { ...projected, id: 'shape-real', dataSource: 'real' } },
+ { id: 'shape-absent', record: (() => { const next = { ...projected, id: 'shape-absent' }; delete next.dataSource; return next; })() }
+ ];
+ view.updatePracticeView(shapes.map((shape) => shape.record), []);
+ assert.deepStrictEqual(
+ view.lastRenderedIds(),
+ ['shape-absent', 'shape-projected', 'shape-real'],
+ 'light 投影实际产出的 dataSource 形态(含缺失)必须全部通过 updatePracticeView 的渲染过滤'
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 2b. 演示/种子记录:UI、stats、achievements 三处判定必须一致
+//
+// 修复的 bug:判定被复制成两套语义不同的实现 ——
+// - js/main.js 只看顶层 dataSource(排除 'demo' / 'e2e-seed');
+// - appData.js 投影器只看 metadata.source === 'onboarding-demo'。
+// 于是 demo / e2e-seed 记录"在练习记录页看不见,却计入成绩统计和成就解锁",
+// 用户会看到自己没做过的题影响了正确率与成就。
+//
+// 本节的核心断言不是"某个具体值被排除",而是**三处结论逐条一致**:
+// 只要 UI 与两个投影器对同一条记录给出不同结论,测试就失败。
+// ---------------------------------------------------------------------------
+
+/** 演示/种子记录的各种真实形态(含两个维度的组合)。 */
+const DEMO_RECORD_FIXTURES = Object.freeze([
+ {
+ id: 'demo-datasource',
+ why: '顶层 dataSource: "demo"',
+ record: { dataSource: 'demo' }
+ },
+ {
+ id: 'demo-e2e-seed',
+ why: '顶层 dataSource: "e2e-seed"(测试/夹具种子数据)',
+ record: { dataSource: 'e2e-seed' }
+ },
+ {
+ id: 'demo-onboarding-metadata',
+ why: 'metadata.source: "onboarding-demo"(js/components/onboardingTour.js 注入的引导演示记录)',
+ record: { metadata: { source: 'onboarding-demo', examTitle: '示例练习' } }
+ },
+ {
+ id: 'demo-metadata-datasource',
+ why: 'metadata.dataSource: "demo"(light 投影会把它提到顶层)',
+ record: { metadata: { dataSource: 'demo' } }
+ },
+ {
+ id: 'demo-both-dimensions',
+ why: '两个维度同时标注为演示数据',
+ record: { dataSource: 'demo', metadata: { source: 'onboarding-demo' } }
+ }
+]);
+
+/** 必须被当作真实练习的形态 —— 尤其包含"dataSource 缺失"(曾被收窄导致整页空白)。 */
+const REAL_RECORD_FIXTURES = Object.freeze([
+ {
+ id: 'real-explicit',
+ why: '显式 dataSource: "real"',
+ record: { dataSource: 'real' }
+ },
+ {
+ id: 'real-absent',
+ why: 'dataSource 完全缺失(迁移记录 / 套题聚合 / 听力桥接从不写该字段)',
+ record: {}
+ },
+ {
+ id: 'real-metadata-only',
+ why: '仅 metadata.dataSource: "real"',
+ record: { metadata: { dataSource: 'real' } }
+ },
+ {
+ id: 'real-suite-source-label',
+ why: 'metadata.source: "listening" —— 该字段被复用为内容类型标签,不得当成演示标记',
+ record: { metadata: { source: 'listening' } }
+ },
+ {
+ id: 'real-collector-source-label',
+ why: 'metadata.source: "practice_page" —— 采集方式标签,不得当成演示标记',
+ record: { metadata: { source: 'practice_page' } }
+ },
+ {
+ id: 'real-empty-datasource',
+ why: 'dataSource 为空串(历史脏数据),按缺失处理',
+ record: { dataSource: '' }
+ }
+]);
+
+function buildFixtureRecord(fixture, index) {
+ const base = {
+ id: fixture.id,
+ sessionId: fixture.id,
+ examId: `reading-${fixture.id}`,
+ title: fixture.id,
+ type: 'reading',
+ // 每条给足 1 题 1 对:这样"是否计入"在 stats 上体现为可观测的数值差异。
+ totalQuestions: 1,
+ correctAnswers: 1,
+ accuracy: 1,
+ duration: 60,
+ // 时间各不相同,保证成就解锁时间戳有确定顺序。
+ date: `2026-07-2${index % 9}T09:00:00.000Z`,
+ completedAt: `2026-07-2${index % 9}T09:00:00.000Z`
+ };
+ const merged = Object.assign(base, fixture.record);
+ if (fixture.record.metadata) {
+ merged.metadata = Object.assign({}, fixture.record.metadata);
+ }
+ return merged;
+}
+
+async function testDemoRecordsAreExcludedFromViewStatsAndAchievements() {
+ const app = loadRealAppData();
+ await app.ready;
+ const view = loadRealPracticeView();
+
+ const fixtures = [...REAL_RECORD_FIXTURES, ...DEMO_RECORD_FIXTURES];
+ for (const [index, fixture] of fixtures.entries()) {
+ await app.practice.completeAttempt({
+ operationId: `demo-contract-${fixture.id}`,
+ record: buildFixtureRecord(fixture, index)
+ });
+ }
+
+ const realIds = REAL_RECORD_FIXTURES.map((fixture) => fixture.id).sort();
+ const summaries = await app.practice.list({ projection: 'light' });
+ assert.strictEqual(summaries.length, fixtures.length,
+ '演示记录同样是权威数据,必须完整落库;排除只发生在展示与派生统计层');
+
+ // --- 1) UI:练习记录列表与汇总卡片都不能出现演示记录 ---
+ view.updatePracticeView(summaries, []);
+ assert.deepStrictEqual(view.lastRenderedIds(), realIds,
+ '练习记录列表必须只渲染真实记录:演示/种子记录要被排除,而所有真实形态'
+ + '(含 dataSource 缺失)必须保留');
+ assert.deepStrictEqual(view.lastSummaryIds(), realIds,
+ '汇总卡片必须与历史列表看到同一批记录');
+
+ // --- 2) stats 投影器:演示记录不得计入成绩统计 ---
+ const stats = await app.practice.getStats();
+ assert.strictEqual(stats.totalPractices, REAL_RECORD_FIXTURES.length,
+ `practice.stats 只能统计真实记录:期望 ${REAL_RECORD_FIXTURES.length} 条,`
+ + `实际 ${stats.totalPractices} 条(多出来的就是被计入的演示/种子记录 —— `
+ + '用户会看到"我没做这些题,为什么成绩变了")');
+ assert.strictEqual(stats.totalQuestions, REAL_RECORD_FIXTURES.length,
+ 'practice.stats 的题目数同样不得包含演示记录的题目');
+ assert.strictEqual(stats.correctAnswers, REAL_RECORD_FIXTURES.length,
+ 'practice.stats 的正确数同样不得包含演示记录的作答');
+
+ // --- 3) achievements 投影器:演示记录不得推进成就解锁 ---
+ // 6 条真实记录只够解锁 first_step;若把 5 条演示记录也算进去就是 11 条,
+ // 会额外解锁 practice_bronze(累计 10 次练习)。用它当"是否混入"的探针。
+ const achievements = await app.achievements.getAll();
+ assert(achievements.first_step && achievements.first_step.unlockedAt,
+ '真实记录必须能正常解锁成就(first_step)');
+ assert.strictEqual(Object.prototype.hasOwnProperty.call(achievements, 'practice_bronze'), false,
+ 'achievements.progress 不得把演示/种子记录计入练习次数:'
+ + `只有 ${REAL_RECORD_FIXTURES.length} 条真实记录时 practice_bronze(10 次)必须仍未解锁`);
+}
+
+async function testDemoJudgementIsIdenticalAcrossViewStatsAndAchievements() {
+ // 本次修复的核心契约:三处判定必须逐条一致。
+ // 做法是对每条 fixture 单独观测三个消费方的结论,再互相比对 —— 不比对某个硬编码期望值,
+ // 这样无论未来判定规则怎么变,只要三处出现分歧就立刻失败(正是当前 bug 的形态)。
+ const view = loadRealPracticeView();
+ const fixtures = [...REAL_RECORD_FIXTURES, ...DEMO_RECORD_FIXTURES];
+ const disagreements = [];
+
+ for (const [index, fixture] of fixtures.entries()) {
+ // 每条 fixture 用独立的 AppData 实例,避免相互影响统计阈值。
+ const app = loadRealAppData();
+ await app.ready;
+ await app.practice.completeAttempt({
+ operationId: `judgement-${fixture.id}`,
+ record: buildFixtureRecord(fixture, index)
+ });
+
+ const summaries = await app.practice.list({ projection: 'light' });
+ assert.strictEqual(summaries.length, 1, `${fixture.id} 必须落库为唯一一条权威记录`);
+
+ view.updatePracticeView(summaries, []);
+ const inView = view.lastRenderedIds().includes(fixture.id);
+
+ const stats = await app.practice.getStats();
+ const inStats = stats.totalPractices === 1;
+
+ const achievements = await app.achievements.getAll();
+ const inAchievements = Boolean(achievements.first_step && achievements.first_step.unlockedAt);
+
+ if (!(inView === inStats && inStats === inAchievements)) {
+ disagreements.push(
+ `${fixture.id}(${fixture.why}): 列表=${inView} stats=${inStats} achievements=${inAchievements}`
+ );
+ }
+ }
+
+ assert.deepStrictEqual(disagreements, [],
+ '“什么算真实练习记录”必须只有一份判定:练习记录列表、practice.stats、'
+ + 'achievements.progress 对同一条记录的结论必须完全相同。\n'
+ + '出现分歧意味着判定又被复制成了多套实现(这正是"记录看不见却计入统计"的成因):\n'
+ + disagreements.join('\n'));
+}
+
+async function testDemoJudgementHasSingleImplementation() {
+ // 静态守卫:防止任何一方悄悄写回本地副本。
+ // 判定实现必须只存在于 js/data/practiceRecordSource.js。
+ const classifierSource = readSource(RECORD_SOURCE_MODULE);
+ assert(classifierSource.includes('function isRealPracticeRecord'),
+ `${RECORD_SOURCE_MODULE} 必须是判定的唯一实现处`);
+
+ for (const [relativePath, mustReference] of [[MAIN_SOURCE, 'PracticeRecordSource'], [APP_DATA_SOURCE, 'PracticeRecordSource']]) {
+ assert(readSource(relativePath).includes(mustReference),
+ `${relativePath} 必须通过 ${mustReference} 复用统一判定,不得自建副本`);
+ }
+
+ // 旧的本地实现名不得复活(appData.js 的 isDemoRecord 只看 metadata.source)。
+ assert(!readSource(APP_DATA_SOURCE).includes('function isDemoRecord'),
+ 'appData.js 不得恢复本地 isDemoRecord:它只看 metadata.source,与 UI 侧语义不一致');
+
+ // main.js 不得再内联 dataSource 的逐值比较(这是旧 UI 判定的形态)。
+ const mainSource = readSource(MAIN_SOURCE);
+ const inlinedDataSourceComparison = mainSource
+ .split('\n')
+ .map((line, index) => ({ line: line.trim(), lineNumber: index + 1 }))
+ .filter(({ line }) => !line.startsWith('//') && !line.startsWith('*'))
+ .filter(({ line }) => /dataSource\s*===\s*['"]real['"]/.test(line));
+ assert.deepStrictEqual(inlinedDataSourceComparison, [],
+ 'js/main.js 不得内联 `dataSource === "real"` 判定:必须走 PracticeRecordSource,'
+ + '否则会与 stats/achievements 投影器再次分叉:\n'
+ + inlinedDataSourceComparison.map((item) => ` ${MAIN_SOURCE}:${item.lineNumber} ${item.line}`).join('\n'));
+}
+
+async function testOnboardingPreviewIsViewOnly() {
+ // 引导演示记录是唯一需要"看得见但不计入"的例外:
+ // 它必须能在列表里渲染(引导要教用户认识那一行),但绝不能进 stats/achievements。
+ // 例外只存在于视图层白名单,投影器读不到 —— 判定本身仍然只有一份。
+ const app = loadRealAppData();
+ await app.ready;
+ const view = loadRealPracticeView();
+ const DEMO_ID = 'demo-onboarding-record';
+
+ await app.practice.completeAttempt({
+ operationId: 'onboarding-preview-real',
+ record: buildFixtureRecord({ id: 'real-alongside-demo', record: {} }, 1)
+ });
+ await app.practice.completeAttempt({
+ operationId: 'onboarding-preview-demo',
+ record: buildFixtureRecord({
+ id: DEMO_ID,
+ record: { metadata: { source: 'onboarding-demo', examTitle: '示例练习' } }
+ }, 2)
+ });
+
+ const summaries = await app.practice.list({ projection: 'light' });
+
+ // 未登记预览:演示记录必须不可见。
+ view.updatePracticeView(summaries, []);
+ assert.deepStrictEqual(view.lastRenderedIds(), ['real-alongside-demo'],
+ '未登记预览许可时,引导演示记录必须与其他演示记录一样被排除');
+
+ // 登记预览后:仅该 id 可见,其它演示记录仍被排除。
+ view.recordSource.allowPreviewRecordId(DEMO_ID);
+ assert.strictEqual(view.recordSource.isPreviewRecord({
+ id: DEMO_ID,
+ dataSource: 'demo',
+ metadata: { source: 'demo' }
+ }), false, '仅 id 匹配但没有 onboarding marker 的演示记录不得借用引导预览许可');
+ assert.strictEqual(view.recordSource.isPreviewRecord({
+ id: 'different-record',
+ sessionId: DEMO_ID,
+ metadata: { source: 'onboarding-demo' }
+ }), false, '仅 sessionId 命中不得越权放行其它非真实记录');
+ assert.strictEqual(view.recordSource.isPreviewRecord({
+ id: DEMO_ID,
+ metadata: { source: 'onboarding-demo' }
+ }), true, 'preview 必须只放行 id 与 onboarding marker 同时匹配的记录');
+ view.updatePracticeView(summaries, []);
+ assert.deepStrictEqual(view.lastRenderedIds(), [DEMO_ID, 'real-alongside-demo'].sort(),
+ '登记预览许可后,引导演示记录必须能在练习记录列表中渲染(引导步骤依赖这一行)');
+
+ // 关键:预览许可绝不能泄漏到 stats / achievements。
+ const stats = await app.practice.getStats();
+ assert.strictEqual(stats.totalPractices, 1,
+ '引导预览许可只影响渲染:practice.stats 必须仍然只统计那 1 条真实记录');
+ assert.strictEqual(view.recordSource.isRealPracticeRecord(
+ summaries.find((record) => String(record.id) === DEMO_ID)
+ ), false, '预览许可不得改变"是否真实记录"的判定本身,否则投影器也会被污染');
+
+ // 撤销许可后立即恢复排除(引导结束/跳过时调用)。
+ view.recordSource.clearPreviewRecordId(DEMO_ID);
+ view.updatePracticeView(summaries, []);
+ assert.deepStrictEqual(view.lastRenderedIds(), ['real-alongside-demo'],
+ '撤销预览许可后,引导演示记录必须立刻从练习记录列表消失');
+}
+
+async function testOnboardingStopCancelsPendingInjectionLifecycle() {
+ const indexDeferred = createDeferred();
+ let completeCalls = 0;
+ const harness = createOnboardingSandbox({
+ resolveIndex: () => indexDeferred.promise,
+ completeAttempt: async () => { completeCalls += 1; }
+ });
+
+ harness.api.start(true);
+ harness.api.stop();
+ indexDeferred.resolve([]);
+ await flushAsyncWork();
+
+ assert.strictEqual(completeCalls, 0,
+ 'stop 后尚未开始的异步注入不得继续落库');
+ assert.strictEqual(harness.previewIds.size, 0,
+ 'stop 必须立即撤销 onboarding preview 许可');
+ assert(harness.deletedIds.includes('demo-onboarding-record'),
+ 'stop 必须走演示记录清理路径');
+}
+
+async function testOnboardingStopCompensatesInFlightWriteWithoutInjectionRefresh() {
+ const writeDeferred = createDeferred();
+ let completeCalls = 0;
+ const harness = createOnboardingSandbox({
+ resolveIndex: async () => [],
+ completeAttempt: async () => {
+ completeCalls += 1;
+ return writeDeferred.promise;
+ }
+ });
+
+ harness.api.start(true);
+ await flushAsyncWork();
+ assert.strictEqual(completeCalls, 1, '测试前置条件:注入写入必须已经在途');
+
+ harness.api.stop();
+ await flushAsyncWork();
+ const refreshCountAfterStopCleanup = harness.getRefreshCount();
+ writeDeferred.resolve();
+ await flushAsyncWork(8);
+
+ assert.strictEqual(harness.previewIds.size, 0,
+ '在途写入完成后也不得恢复 preview 许可');
+ assert(harness.deletedIds.filter((id) => id === 'demo-onboarding-record').length >= 2,
+ 'stop 清理早于在途写入完成时,写入链必须再做一次删除补偿');
+ assert.strictEqual(harness.getRefreshCount(), refreshCountAfterStopCleanup,
+ 'tour 停止后,旧注入链不得继续刷新练习历史');
+}
+
+async function testOnboardingRapidRestartIsolatesRendererAndCancelsOldPolling() {
+ const clock = createManualClock();
+ const restartedIndex = createDeferred();
+ let indexCalls = 0;
+ const harness = createOnboardingSandbox({
+ clock,
+ resolveIndex: () => {
+ indexCalls += 1;
+ return indexCalls === 1 ? Promise.resolve([]) : restartedIndex.promise;
+ },
+ completeAttempt: async () => {}
+ });
+
+ harness.api.start(true);
+ await flushPromiseWork();
+ assert.strictEqual(clock.pendingDelay(120), 1,
+ '测试前置条件:旧 lifecycle 必须已经进入 selector 轮询');
+
+ const oldOverlay = harness.body.children.find((element) => element.className === 'onboarding-overlay');
+ const oldTooltip = harness.body.children.find((element) => element.className === 'onboarding-tooltip');
+ harness.api.stop();
+ await flushPromiseWork();
+ harness.api.start(true);
+ await flushPromiseWork();
+
+ const newOverlay = harness.body.children.find((element) =>
+ element.className === 'onboarding-overlay' && element !== oldOverlay);
+ const newTooltip = harness.body.children.find((element) =>
+ element.className === 'onboarding-tooltip' && element !== oldTooltip);
+ assert(oldOverlay && oldTooltip && newOverlay && newTooltip,
+ 'rapid restart 必须同时保留可区分的新旧 renderer 节点直到退出动画结束');
+ assert.strictEqual(clock.pendingDelay(120), 0,
+ 'stop 必须立即取消旧 lifecycle 的 selector 定时器');
+
+ const queriesAfterRestart = harness.getQueryCount();
+ const refreshesAfterRestart = harness.getRefreshCount();
+ clock.advance(300);
+ await flushPromiseWork();
+
+ assert(!harness.body.children.includes(oldOverlay) && !harness.body.children.includes(oldTooltip),
+ '退出动画结束后必须删除旧 renderer 节点');
+ assert(harness.body.children.includes(newOverlay) && harness.body.children.includes(newTooltip),
+ '旧 destroy timer 绝不能删除 restart 创建的新 renderer 节点');
+
+ clock.advance(5000);
+ await flushPromiseWork();
+ assert.strictEqual(harness.getQueryCount(), queriesAfterRestart,
+ '旧 selector 轮询在 rapid restart 后不得继续查询 DOM');
+ assert.strictEqual(harness.getRefreshCount(), refreshesAfterRestart,
+ '旧注入链在 rapid restart 后不得继续刷新练习历史');
+}
+
+// ---------------------------------------------------------------------------
+// 3. 同类隐患:其他 `|| null` 回退字段
+// ---------------------------------------------------------------------------
+
+async function testLightProjectionNullFallbacksHaveNoStrictUndefinedConsumers() {
+ const body = extractLightProjectionBody(readSource(APP_DATA_SOURCE));
+ const { nullFallback, undefinedFallback } = classifyProjectionFallbacks(body);
+
+ assert(nullFallback.length > 0,
+ '静态守卫必须至少识别出一个 null 回退字段,否则解析规则已与 appData.js 脱节');
+ assert(
+ !nullFallback.includes('dataSource'),
+ 'dataSource 不得回退为 null:消费方按 `=== undefined` 判缺失,null 会让记录整条不显示'
+ );
+
+ const files = collectSourceFiles(path.join(repoRoot, 'js'));
+ const mismatches = [];
+ for (const file of files) {
+ const relative = path.relative(repoRoot, file).split(path.sep).join('/');
+ const lines = readSource(relative).split('\n');
+ lines.forEach((line, index) => {
+ // null 回退字段 + 只认 undefined 的消费方 = 本 bug 的同构形态。
+ for (const field of nullFallback) {
+ if (new RegExp(`\\.${field}\\s*(===|!==)\\s*undefined`).test(line)) {
+ mismatches.push(`${relative}:${index + 1} [${field} 回退 null,但此处只判 undefined] ${line.trim()}`);
+ }
+ }
+ // 反向形态:undefined 回退字段遇到只认 null 的消费方。
+ for (const field of undefinedFallback) {
+ if (new RegExp(`\\.${field}\\s*(===|!==)\\s*null`).test(line)) {
+ mismatches.push(`${relative}:${index + 1} [${field} 回退 undefined,但此处只判 null] ${line.trim()}`);
+ }
+ }
+ });
+ }
+
+ assert.deepStrictEqual(mismatches, [],
+ 'light 投影的缺省哨兵与下游严格判等不一致,会重演"记录存进去但不显示":\n'
+ + mismatches.join('\n'));
+}
+
+// ---------------------------------------------------------------------------
+// 4. bundle 同步:线上跑的是 bundle,不是源码
+// ---------------------------------------------------------------------------
+
+async function testBundledCopiesMatchSourceContract() {
+ const sourceProjection = extractLightProjectionBody(readSource(APP_DATA_SOURCE));
+ const bundleDirectory = path.join(repoRoot, 'js', 'bundles');
+ const bundles = fs.readdirSync(bundleDirectory).filter((name) => name.endsWith('.bundle.js'));
+
+ const projectionDrift = [];
+ for (const name of bundles) {
+ const source = fs.readFileSync(path.join(bundleDirectory, name), 'utf8');
+ if (!source.includes('function lightFromCanonical')) continue;
+ const bundled = extractLightProjectionBody(source);
+ // bundle 内缩进不同,比较去掉行首空白后的语义文本。
+ const strip = (text) => text.split('\n').map((line) => line.trim()).join('\n');
+ if (strip(bundled) !== strip(sourceProjection)) {
+ projectionDrift.push(`js/bundles/${name} 的 lightFromCanonical 与 ${APP_DATA_SOURCE} 不一致`);
+ }
+ }
+ assert(bundles.some((name) => fs.readFileSync(path.join(bundleDirectory, name), 'utf8').includes('function lightFromCanonical')),
+ '至少一个 bundle 必须内联 lightFromCanonical,否则本检查形同虚设');
+ assert.deepStrictEqual(projectionDrift, [],
+ 'bundle 未重建:应用运行的是 bundle,源码修好但 bundle 仍是旧投影会继续丢记录:\n'
+ + projectionDrift.join('\n'));
+
+ // updatePracticeView 的过滤块同样必须与 main.js 同步。
+ const mainFilter = readSource(MAIN_SOURCE)
+ .match(/function updatePracticeView\([\s\S]*?\n const stats = window\.PracticeStats;/);
+ assert(mainFilter, 'js/main.js 中必须能定位 updatePracticeView 的过滤段落');
+ const normalizedMainFilter = mainFilter[0].split('\n').map((line) => line.trim()).join('\n');
+ const filterDrift = [];
+ for (const name of bundles) {
+ const source = fs.readFileSync(path.join(bundleDirectory, name), 'utf8');
+ if (!source.includes('function updatePracticeView')) continue;
+ const bundled = source.match(/function updatePracticeView\([\s\S]*?\n const stats = window\.PracticeStats;/);
+ if (!bundled) {
+ filterDrift.push(`js/bundles/${name} 的 updatePracticeView 结构与 ${MAIN_SOURCE} 不同,无法比对`);
+ continue;
+ }
+ if (bundled[0].split('\n').map((line) => line.trim()).join('\n') !== normalizedMainFilter) {
+ filterDrift.push(`js/bundles/${name} 的 updatePracticeView 渲染过滤与 ${MAIN_SOURCE} 不一致`);
+ }
+ }
+ assert.deepStrictEqual(filterDrift, [],
+ 'bundle 未重建:练习记录渲染过滤与源码不一致:\n' + filterDrift.join('\n'));
+}
+
+const tests = [
+ ['light 投影的 dataSource 必须能通过渲染过滤', testLightProjectionDataSourcePassesRenderFilter],
+ ['falsy dataSource 在 full/light/detail 中必须保值且 isReal 一致', testFalsyDataSourcesPreserveProjectionAndJudgement],
+ ['存入的练习记录必须出现在渲染后的历史列表', testStoredRecordsReachRenderedHistoryList],
+ ['light 投影产出的所有 dataSource 形态都不被渲染过滤吃掉', testRenderFilterKeepsUnlabelledRecordsAfterProjectionChange],
+ ['演示/种子记录必须同时不显示、不计入统计、不计入成就', testDemoRecordsAreExcludedFromViewStatsAndAchievements],
+ ['列表/统计/成就对每条记录的来源判定必须完全一致', testDemoJudgementIsIdenticalAcrossViewStatsAndAchievements],
+ ['来源判定只允许有一份实现', testDemoJudgementHasSingleImplementation],
+ ['引导演示记录的预览例外只影响渲染,不影响统计与成就', testOnboardingPreviewIsViewOnly],
+ ['stop 必须取消尚未落库的 onboarding 异步注入', testOnboardingStopCancelsPendingInjectionLifecycle],
+ ['stop 必须补偿在途写入且旧注入链不得继续刷新', testOnboardingStopCompensatesInFlightWriteWithoutInjectionRefresh],
+ ['rapid stop/start 必须隔离 renderer 并取消旧 selector 轮询', testOnboardingRapidRestartIsolatesRendererAndCancelsOldPolling],
+ ['light 投影的 null 回退字段不得遇到只认 undefined 的消费方', testLightProjectionNullFallbacksHaveNoStrictUndefinedConsumers],
+ ['bundle 内联的投影与渲染过滤必须与源码同步', testBundledCopiesMatchSourceContract]
+];
+
+const results = [];
+for (const [name, test] of tests) {
+ try {
+ await test();
+ results.push({ name, status: 'pass' });
+ } catch (error) {
+ results.push({ name, status: 'fail', error: error.stack || error.message });
+ console.log(JSON.stringify({ status: 'fail', detail: `${name} 失败`, results }, null, 2));
+ process.exit(1);
+ }
+}
+
+console.log(JSON.stringify({
+ status: 'pass',
+ detail: `${results.length}/${results.length} tests passed`,
+ results
+}, null, 2));
diff --git a/developer/tests/js/practicePageEnhancerReplay.test.js b/developer/tests/js/practicePageEnhancerReplay.test.js
index 352a7071..269d2c94 100644
--- a/developer/tests/js/practicePageEnhancerReplay.test.js
+++ b/developer/tests/js/practicePageEnhancerReplay.test.js
@@ -196,6 +196,39 @@ async function testPracticeEnhancerSubmissionPayloadCarriesCorrectAnswerMap() {
});
}
+async function testPracticeEnhancerCompletionAddsSubmissionContract() {
+ const enhancer = loadEnhancer();
+ const messages = [];
+ enhancer.parentWindow = {
+ postMessage(message, targetOrigin) {
+ messages.push({ message, targetOrigin });
+ }
+ };
+ enhancer.parentOrigin = 'https://host.example';
+ enhancer.sessionId = 'enhancer-session';
+ enhancer.examId = 'enhancer-exam';
+ enhancer.windowSessionToken = 'enhancer-token';
+
+ const payload = { answers: { q1: 'A' } };
+ assert.strictEqual(enhancer.sendMessage('PRACTICE_COMPLETE', payload), true);
+ assert.strictEqual(messages.length, 1);
+ assert.strictEqual(messages[0].targetOrigin, 'https://host.example');
+ assert.strictEqual(messages[0].message.data.sessionId, 'enhancer-session');
+ assert.strictEqual(messages[0].message.data.windowSessionToken, 'enhancer-token');
+ assert.match(messages[0].message.data.submissionId, /^practice-submit-/);
+ assert.strictEqual(payload.submissionId, messages[0].message.data.submissionId);
+
+ enhancer.sendMessage('PRACTICE_COMPLETE', payload);
+ assert.strictEqual(
+ messages[1].message.data.submissionId,
+ messages[0].message.data.submissionId,
+ 'retrying the same payload must reuse its submissionId'
+ );
+ recordResult('practice enhancer completion adds submission correlation', true, {
+ submissionId: payload.submissionId
+ });
+}
+
async function testUnifiedReadingReplayRefusesComparisonCorrectAnswerFallback() {
const windowStub = {
__IELTS_READING_PAGE_TEST_HOOKS__: true,
@@ -205,7 +238,6 @@ async function testUnifiedReadingReplayRefusesComparisonCorrectAnswerFallback()
CSS: { escape(value) { return String(value); } },
addEventListener() {},
removeEventListener() {},
- localStorage: { getItem() { return null; }, setItem() {}, removeItem() {} },
AnswerMatchCore: {
compareAnswers(userAnswer, correctAnswer) {
return String(userAnswer == null ? '' : userAnswer).trim().toLowerCase()
@@ -266,7 +298,6 @@ async function testUnifiedReadingReplayCanonicalMapWins() {
CSS: { escape(value) { return String(value); } },
addEventListener() {},
removeEventListener() {},
- localStorage: { getItem() { return null; }, setItem() {}, removeItem() {} },
AnswerMatchCore: {
compareAnswers(userAnswer, correctAnswer) {
return String(userAnswer == null ? '' : userAnswer).trim().toLowerCase()
@@ -332,6 +363,7 @@ async function runAllTests() {
testPracticeEnhancerReplayCanonicalMapWins,
testPracticeEnhancerReplayIgnoresNumericCorrectAnswersAsMap,
testPracticeEnhancerSubmissionPayloadCarriesCorrectAnswerMap,
+ testPracticeEnhancerCompletionAddsSubmissionContract,
testUnifiedReadingReplayCanonicalMapWins,
testUnifiedReadingReplayRefusesComparisonCorrectAnswerFallback
];
diff --git a/developer/tests/js/practiceRecordPersistence.test.js b/developer/tests/js/practiceRecordPersistence.test.js
index 455c20da..dd00b6a6 100644
--- a/developer/tests/js/practiceRecordPersistence.test.js
+++ b/developer/tests/js/practiceRecordPersistence.test.js
@@ -15,26 +15,11 @@ function loadScript(relativePath, context) {
vm.runInContext(source, context, { filename: relativePath });
}
-function createWebStorage() {
- const map = new Map();
- return {
- getItem(key) {
- return map.has(key) ? map.get(key) : null;
- },
- setItem(key, value) {
- map.set(key, String(value));
- },
- removeItem(key) {
- map.delete(key);
- },
- dump() {
- return new Map(map);
- }
- };
-}
-
function createHarness(options = {}) {
- const { syncUiOnReplace = false, saveCompletionImpl = null, forceCompletionNotice = false } = options;
+ const {
+ saveCompletionImpl = null,
+ forceCompletionNotice = false
+ } = options;
const practiceState = [
{
id: 'record-1',
@@ -51,11 +36,18 @@ function createHarness(options = {}) {
duration: 90
}
];
- const uiState = practiceState.map((record) => ({ ...record }));
- const localStorage = createWebStorage();
- const sessionStorage = createWebStorage();
const listeners = new Map();
const savedSpellingErrors = [];
+ const renderedSnapshots = [];
+ const browseSnapshots = [];
+ const examIndex = [{
+ id: 'listening-p1-fallback',
+ title: 'Listening P1 Fallback',
+ category: 'P1',
+ path: 'assets/generated/listening-exams/listening-p1-fallback.html',
+ frequency: 'fallback',
+ type: 'listening'
+ }];
const quietConsole = {
log() {},
warn() {},
@@ -64,33 +56,14 @@ function createHarness(options = {}) {
debug() {}
};
- const storageStub = {
- mode: 'indexeddb',
- getKey(key) {
- return `exam_system_${key}`;
- },
- async get() {
- return practiceState.map((record) => ({ ...record }));
- },
- async set(key, value) {
- if (key === 'practice_records') {
- practiceState.splice(0, practiceState.length, ...(Array.isArray(value) ? value.map((record) => ({ ...record })) : []));
- }
- return true;
- },
- async writePersistentValue(key, value) {
- return this.set(key, value);
- }
- };
-
const messageLog = [];
- let renderCount = 0;
+ const deleteCommands = [];
+ const deleteManyCommands = [];
+ const clearCommands = [];
+ const completionCommands = [];
const sandbox = {
console: quietConsole,
- localStorage,
- sessionStorage,
- storage: storageStub,
confirm: () => true,
showMessage: (message, type) => {
messageLog.push({ message, type });
@@ -105,35 +78,23 @@ function createHarness(options = {}) {
processedSessions: {
clear() {}
},
- getPracticeRecordsState() {
- return uiState.map((record) => ({ ...record }));
- },
- setPracticeRecordsState(records) {
- uiState.splice(0, uiState.length, ...(Array.isArray(records) ? records.map((record) => ({ ...record })) : []));
- return uiState.map((record) => ({ ...record }));
- },
getSelectedRecordsState() {
return new Set();
},
clearSelectedRecordsState() {},
setBulkDeleteModeState() {},
refreshBulkDeleteButton() {},
- refreshBrowseProgressFromRecords() {},
- updatePracticeView() {},
+ refreshBrowseProgressFromRecords(records, index) {
+ browseSnapshots.push({ records: structuredClone(records), index: structuredClone(index) });
+ },
+ updatePracticeView(records, index) {
+ renderedSnapshots.push({ records: structuredClone(records), index: structuredClone(index) });
+ },
normalizeRecordId(id) {
return id == null ? '' : String(id);
},
- getExamIndexState() {
- return [
- {
- id: 'listening-p1-fallback',
- title: 'Listening P1 Fallback',
- category: 'P1',
- path: 'assets/generated/listening-exams/listening-p1-fallback.html',
- frequency: 'fallback',
- type: 'listening'
- }
- ];
+ async resolveActiveLibraryIndex() {
+ return structuredClone(examIndex);
},
document: {
addEventListener() {},
@@ -149,9 +110,9 @@ function createHarness(options = {}) {
},
window: {
console: quietConsole,
- storage: storageStub,
- location: {
- origin: 'http://localhost'
+ location: options.location || {
+ origin: 'http://localhost',
+ protocol: 'http:'
},
addEventListener(type, handler) {
if (!listeners.has(type)) {
@@ -175,67 +136,97 @@ function createHarness(options = {}) {
return true;
}
},
- app: {
- state: {
- practice: {
- records: uiState.map((record) => ({ ...record }))
- }
- }
- },
- PracticeRecordAPI: {
- async list() {
- return practiceState.map((record) => ({ ...record }));
- },
- async getById(recordId) {
- const targetId = recordId == null ? '' : String(recordId);
- return practiceState.find((record) => (
- String(record.id) === targetId || String(record.sessionId || '') === targetId
- )) || null;
- },
- async saveCompletion(realData = {}, context = {}, exam = {}) {
- if (typeof saveCompletionImpl === 'function') {
- return await saveCompletionImpl(realData, context, exam);
+ AppData: {
+ ready: Promise.resolve(),
+ practice: {
+ async list() {
+ return structuredClone(practiceState);
+ },
+ async listInsights() {
+ return structuredClone(practiceState);
+ },
+ async get(recordId) {
+ const target = String(recordId || '');
+ const record = practiceState.find((item) => item && (
+ String(item.id || '') === target || String(item.sessionId || '') === target
+ ));
+ return structuredClone(record || null);
+ },
+ async delete(command = {}) {
+ deleteCommands.push(structuredClone(command));
+ const index = practiceState.findIndex((record) => String(record.id) === String(command.recordId));
+ if (index >= 0) practiceState.splice(index, 1);
+ return {
+ committed: true,
+ revision: deleteCommands.length,
+ operationId: command.operationId || `delete-${deleteCommands.length}`,
+ derived: { status: 'ready', pending: [] },
+ warnings: []
+ };
+ },
+ async deleteMany(command = {}) {
+ deleteManyCommands.push(structuredClone(command));
+ const ids = new Set((command.recordIds || []).map(String));
+ for (let index = practiceState.length - 1; index >= 0; index -= 1) {
+ if (ids.has(String(practiceState[index].id))) practiceState.splice(index, 1);
+ }
+ return {
+ committed: true,
+ revision: deleteManyCommands.length,
+ operationId: command.operationId || `delete-many-${deleteManyCommands.length}`,
+ derived: { status: 'ready', pending: [] },
+ warnings: []
+ };
+ },
+ async clear(command = {}) {
+ clearCommands.push(structuredClone(command));
+ practiceState.splice(0, practiceState.length);
+ return {
+ committed: true,
+ revision: clearCommands.length,
+ operationId: command.operationId || `clear-${clearCommands.length}`,
+ derived: { status: 'ready', pending: [] },
+ warnings: []
+ };
+ },
+ async completeAttempt(command = {}) {
+ completionCommands.push(structuredClone(command));
+ if (typeof saveCompletionImpl === 'function') {
+ return await saveCompletionImpl(command);
+ }
+ const input = command.record && typeof command.record === 'object'
+ ? structuredClone(command.record)
+ : {};
+ const record = {
+ ...input,
+ id: input.id || `record-${practiceState.length + 1}`,
+ examId: input.examId || '',
+ sessionId: input.sessionId || '',
+ title: input.title || '',
+ endTime: input.endTime || input.date || '2026-03-09T12:00:00.000Z',
+ date: input.endTime || input.date || '2026-03-09T12:00:00.000Z'
+ };
+ practiceState.unshift(structuredClone(record));
+ return {
+ committed: true,
+ revision: completionCommands.length,
+ operationId: command.operationId || `completion-${completionCommands.length}`,
+ derived: { status: 'ready', pending: [] },
+ warnings: [],
+ record: structuredClone(record)
+ };
}
- const record = {
- id: `record-${practiceState.length + 1}`,
- examId: context && context.examId ? context.examId : (exam.id || realData.examId || ''),
- sessionId: realData.sessionId || '',
- title: exam.title || realData.title || '',
- date: realData.endTime || '2026-03-09T12:00:00.000Z',
- percentage: Number(realData?.scoreInfo?.percentage) || 0,
- duration: Number(realData?.duration ?? realData?.scoreInfo?.duration) || 0
- };
- practiceState.unshift({ ...record });
- return { ...record };
},
- async replace(records) {
- practiceState.splice(0, practiceState.length, ...(Array.isArray(records) ? records.map((record) => ({ ...record })) : []));
- if (syncUiOnReplace) {
- uiState.splice(0, uiState.length, ...(Array.isArray(records) ? records.map((record) => ({ ...record })) : []));
+ settings: {
+ async reset() {
+ return {
+ committed: true,
+ revision: 1,
+ operationId: 'settings-reset',
+ derived: { status: 'ready', pending: [] },
+ warnings: []
+ };
}
- return practiceState.map((record) => ({ ...record }));
- },
- async deleteById(recordId) {
- const targetId = recordId == null ? '' : String(recordId);
- const next = [];
- let deleted = null;
- practiceState.forEach((record) => {
- if (!deleted && (String(record.id) === targetId || String(record.sessionId || '') === targetId)) {
- deleted = { ...record };
- return;
- }
- next.push(record);
- });
- await this.replace(next);
- return {
- deleted: Boolean(deleted),
- record: deleted,
- records: practiceState.map((record) => ({ ...record }))
- };
- },
- async clear() {
- await this.replace([]);
- return true;
}
}
}
@@ -243,37 +234,39 @@ function createHarness(options = {}) {
sandbox.globalThis = sandbox.window;
sandbox.fallbackExamSessions = new Map();
- sandbox.window.localStorage = localStorage;
- sandbox.window.sessionStorage = sessionStorage;
sandbox.window.fallbackExamSessions = sandbox.fallbackExamSessions;
+ sandbox.window.resolveActiveLibraryIndex = sandbox.resolveActiveLibraryIndex;
loadScript('js/main.js', vm.createContext(sandbox));
if (forceCompletionNotice) {
sandbox.shouldAnnounceCompletion = () => true;
}
- sandbox.updatePracticeView = function updatePracticeViewSpy() {
- renderCount += 1;
+ sandbox.updatePracticeView = function updatePracticeViewSpy(records, index) {
+ renderedSnapshots.push({ records: structuredClone(records), index: structuredClone(index) });
+ };
+ sandbox.refreshBrowseProgressFromRecords = function refreshBrowseProgressFromRecordsSpy(records, index) {
+ browseSnapshots.push({ records: structuredClone(records), index: structuredClone(index) });
};
sandbox.window.updatePracticeView = sandbox.updatePracticeView;
+ sandbox.window.refreshBrowseProgressFromRecords = sandbox.refreshBrowseProgressFromRecords;
return {
sandbox,
practiceState,
- localStorage,
- sessionStorage,
+ examIndex,
+ renderedSnapshots,
+ browseSnapshots,
+ deleteCommands,
+ deleteManyCommands,
+ clearCommands,
+ completionCommands,
messageLog,
- savedSpellingErrors,
- getRenderCount() {
- return renderCount;
- }
+ savedSpellingErrors
};
}
-async function testDeleteRecordPersistsAndCleansLegacyKeys() {
+async function testDeleteRecordCommitsAndRefreshesCanonicalSnapshot() {
const harness = createHarness();
- harness.localStorage.setItem('practice_records', JSON.stringify([{ id: 'legacy-record' }]));
- harness.localStorage.setItem('old_prefix_practice_records', JSON.stringify([{ id: 'legacy-old' }]));
- harness.localStorage.setItem('exam_system_practice_records', 'stale-shadow');
await harness.sandbox.deleteRecord('record-1');
@@ -282,51 +275,55 @@ async function testDeleteRecordPersistsAndCleansLegacyKeys() {
['record-2'],
'deleteRecord 应删除 canonical store 中的目标记录'
);
- assert.strictEqual(
- harness.localStorage.getItem('practice_records'),
- null,
- 'deleteRecord 后应清理 legacy practice_records,避免删除记录被影子键回灌'
+ assert.deepStrictEqual(
+ harness.deleteCommands[0],
+ { recordId: 'record-1' },
+ 'deleteRecord 应通过 AppData.practice.delete 提交目标 identity'
+ );
+ assert.deepStrictEqual(
+ harness.renderedSnapshots.at(-1).records.map((record) => record.id),
+ ['record-2'],
+ 'deleteRecord 后应以 AppData 回读结果刷新练习视图'
);
assert.deepStrictEqual(
- harness.sandbox.window.app.state.practice.records.map((record) => record.id),
+ harness.browseSnapshots.at(-1).records.map((record) => record.id),
['record-2'],
- 'deleteRecord 后应同步 app.state.practice.records,避免页面销毁时把旧记录写回去'
+ 'deleteRecord 后应以同一 AppData 快照重建浏览完成索引'
);
- assert.strictEqual(harness.localStorage.getItem('old_prefix_practice_records'), null, 'deleteRecord 后应清理 old_prefix 影子键');
- assert.strictEqual(harness.localStorage.getItem('exam_system_practice_records'), null, 'deleteRecord 后应清理 indexeddb shadow key');
+ assert.deepStrictEqual(harness.renderedSnapshots.at(-1).index, harness.examIndex, '练习视图应接收活动题库快照');
+ assert.deepStrictEqual(harness.browseSnapshots.at(-1).index, harness.examIndex, '浏览索引应接收同一活动题库快照');
}
-async function testClearPracticeDataPersistsAndClearsLegacyKeys() {
+async function testClearPracticeDataCommitsAndRefreshesCanonicalSnapshot() {
const harness = createHarness();
- harness.localStorage.setItem('practice_records', JSON.stringify([{ id: 'legacy-record' }]));
- harness.sessionStorage.setItem('practice_records', JSON.stringify([{ id: 'legacy-record' }]));
await harness.sandbox.clearPracticeData();
assert.strictEqual(harness.practiceState.length, 0, 'clearPracticeData 应清空 canonical store');
- assert.strictEqual(harness.sandbox.window.app.state.practice.records.length, 0, 'clearPracticeData 后应同步清空 app.state.practice.records');
- assert.strictEqual(harness.localStorage.getItem('practice_records'), null, 'clearPracticeData 后应删除 localStorage legacy 键');
- assert.strictEqual(harness.sessionStorage.getItem('practice_records'), null, 'clearPracticeData 后应删除 sessionStorage legacy 键');
-}
-
-async function testDeleteRecordForcesUiRefreshWhenPracticeCoreAlreadySyncedState() {
- const harness = createHarness({ syncUiOnReplace: true });
-
- await harness.sandbox.deleteRecord('record-1');
-
- assert.ok(
- harness.getRenderCount() > 0,
- 'deleteRecord 后即使全局状态已被 PracticeCore 提前同步,仍应强制刷新 UI,避免残留旧卡片'
- );
+ assert.strictEqual(harness.clearCommands.length, 1, 'clearPracticeData 应通过 AppData.practice.clear 写入 tombstone');
+ assert.deepStrictEqual(harness.renderedSnapshots.at(-1).records, [], 'clearPracticeData 后练习视图应接收空快照');
+ assert.deepStrictEqual(harness.browseSnapshots.at(-1).records, [], 'clearPracticeData 后浏览完成索引应接收空快照');
}
async function testFallbackCompletionPersistsNormalizedSpellingErrors() {
- const harness = createHarness({ forceCompletionNotice: true });
- const childWindow = { closed: false, postMessage() {} };
+ const harness = createHarness({
+ forceCompletionNotice: true,
+ location: { origin: 'file://', protocol: 'file:' }
+ });
+ const outcomes = [];
+ const targetOrigins = [];
+ const childWindow = {
+ closed: false,
+ postMessage(message, targetOrigin) {
+ outcomes.push(message);
+ targetOrigins.push(targetOrigin);
+ }
+ };
harness.sandbox.window.fallbackExamSessions.set('parent-session-1', {
examId: 'listening-p1-fallback',
sessionId: 'parent-session-1',
win: childWindow,
+ windowSessionToken: 'fallback-token-1',
initPayload: {
examId: 'listening-p1-fallback',
sessionId: 'parent-session-1'
@@ -336,13 +333,33 @@ async function testFallbackCompletionPersistsNormalizedSpellingErrors() {
harness.sandbox.setupMessageListener();
await harness.sandbox.window.__dispatchWindowEvent('message', {
- origin: 'http://localhost',
+ origin: 'null',
+ source: childWindow,
+ data: {
+ type: 'REQUEST_INIT',
+ source: 'listening_record_bridge',
+ data: {
+ examId: 'listening-p1-fallback',
+ sessionId: 'child-temp-session'
+ }
+ }
+ });
+ const initMessage = outcomes.find((message) => message && message.type === 'INIT_SESSION');
+ assert(initMessage, 'file fallback REQUEST_INIT must receive INIT_SESSION');
+ assert.strictEqual(initMessage.data.parentOrigin, 'null', 'file fallback must declare opaque null origin');
+ assert(targetOrigins.every((origin) => origin === '*'), 'file fallback INIT must use wildcard targetOrigin');
+
+ await harness.sandbox.window.__dispatchWindowEvent('message', {
+ origin: 'null',
source: childWindow,
data: {
type: 'PRACTICE_COMPLETE',
+ source: 'listening_record_bridge',
+ windowSessionToken: 'fallback-token-1',
realData: {
examId: 'listening-unknown',
sessionId: 'child-temp-session',
+ submissionId: 'listening-submit-fallback-1',
type: 'listening',
practiceType: 'listening',
answers: { q1: 'acommodatio' },
@@ -380,6 +397,12 @@ async function testFallbackCompletionPersistsNormalizedSpellingErrors() {
const savedRecord = harness.practiceState.find((record) => record && record.examId === 'listening-p1-fallback');
assert(savedRecord, 'fallback PRACTICE_COMPLETE 应保存父页题源练习记录');
assert.strictEqual(savedRecord.sessionId, 'parent-session-1', 'fallback 记录 sessionId 应归一到父页会话');
+ const acknowledgement = outcomes.find((message) => message && message.type === 'PRACTICE_SUBMIT_ACK');
+ assert(acknowledgement, 'fallback 持久化成功后必须回传 PRACTICE_SUBMIT_ACK');
+ assert.strictEqual(acknowledgement.data.submissionId, 'listening-submit-fallback-1');
+ assert.strictEqual(acknowledgement.data.sessionId, 'parent-session-1');
+ assert.strictEqual(acknowledgement.data.windowSessionToken, 'fallback-token-1');
+ assert(targetOrigins.every((origin) => origin === '*'), 'file fallback ACK must use wildcard targetOrigin');
assert.strictEqual(harness.savedSpellingErrors.length, 1, 'fallback 应保存 bridge 带回的 spellingErrors');
assert.deepStrictEqual(
@@ -435,13 +458,36 @@ async function testCanonicalCompletionSaveRejectsWhenPersistenceFails() {
);
}
+async function testSubmissionIdProducesStableCompletionOperationId() {
+ const harness = createHarness();
+ const payload = {
+ examId: 'listening-p1-fallback',
+ sessionId: 'session-stable-submit',
+ submissionId: 'submission-stable-submit',
+ type: 'listening',
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ };
+ await harness.sandbox.savePracticeCompletionRecord(payload.examId, payload);
+ await harness.sandbox.savePracticeCompletionRecord(payload.examId, payload);
+ assert.strictEqual(harness.completionCommands.length, 2);
+ assert.strictEqual(
+ harness.completionCommands[0].operationId,
+ 'practice-complete:listening-p1-fallback:session-stable-submit:submission-stable-submit'
+ );
+ assert.strictEqual(
+ harness.completionCommands[1].operationId,
+ harness.completionCommands[0].operationId,
+ 'retrying the same submission must reuse the canonical idempotency operationId'
+ );
+}
+
async function main() {
try {
- await testDeleteRecordPersistsAndCleansLegacyKeys();
- await testClearPracticeDataPersistsAndClearsLegacyKeys();
- await testDeleteRecordForcesUiRefreshWhenPracticeCoreAlreadySyncedState();
+ await testDeleteRecordCommitsAndRefreshesCanonicalSnapshot();
+ await testClearPracticeDataCommitsAndRefreshesCanonicalSnapshot();
await testFallbackCompletionPersistsNormalizedSpellingErrors();
await testCanonicalCompletionSaveRejectsWhenPersistenceFails();
+ await testSubmissionIdProducesStableCompletionOperationId();
console.log(JSON.stringify({
status: 'pass',
detail: 'practice record persistence and fallback completion regressions are covered'
diff --git a/developer/tests/js/practiceRecordStress.test.js b/developer/tests/js/practiceRecordStress.test.js
new file mode 100644
index 00000000..a9c776aa
--- /dev/null
+++ b/developer/tests/js/practiceRecordStress.test.js
@@ -0,0 +1,345 @@
+#!/usr/bin/env node
+import assert from 'assert';
+import fs from 'fs';
+import path from 'path';
+import vm from 'vm';
+import { performance } from 'perf_hooks';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const repoRoot = path.resolve(__dirname, '..', '..', '..');
+const ciProfile = process.argv.includes('--ci');
+const profile = ciProfile
+ ? { concurrentRecords: 36, heavyRecords: 32, highlights: 60, notes: 30, outlines: 8 }
+ : { concurrentRecords: 72, heavyRecords: 64, highlights: 180, notes: 80, outlines: 16 };
+
+const clone = (value) => value === undefined ? undefined : structuredClone(value);
+const source = (relativePath) => fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
+const stable = (value) => {
+ if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
+ if (value && typeof value === 'object') {
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`;
+ }
+ return JSON.stringify(value);
+};
+const checksum = (value) => {
+ let hash = 0x811c9dc5;
+ for (const char of stable(value)) {
+ hash ^= char.charCodeAt(0);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ return `fnv1a-${(hash >>> 0).toString(16)}`;
+};
+
+function createHarness(seed = null) {
+ const catalogSandbox = { structuredClone };
+ catalogSandbox.globalThis = catalogSandbox;
+ vm.runInContext(source('js/data/v2/dataCatalog.js'), vm.createContext(catalogSandbox), { filename: 'dataCatalog.js' });
+ const catalog = catalogSandbox.__AppDataV2Catalog;
+ const shared = {
+ docs: new Map(),
+ entities: new Map([
+ ['practiceSummaries', new Map()],
+ ['practiceDetails', new Map()],
+ ['practiceAnnotations', new Map()]
+ ]),
+ snapshotReads: 0,
+ mutations: 0,
+ counter: 0
+ };
+ if (seed && seed.entities) {
+ for (const [store, rows] of Object.entries(seed.entities)) {
+ if (!shared.entities.has(store)) shared.entities.set(store, new Map());
+ for (const row of rows || []) shared.entities.get(store).set(String(row.recordId), clone(row));
+ }
+ }
+
+ const envelope = (key, data, state = 'present', revision = 1, operationId = 'seed') => ({
+ schemaVersion: catalog.version,
+ revision,
+ operationId,
+ updatedAt: new Date().toISOString(),
+ state,
+ data: state === 'cleared' ? null : clone(data),
+ checksum: checksum(state === 'cleared' ? null : data)
+ });
+ const defaultValue = (key) => {
+ const entry = catalog.get(key);
+ return entry && typeof entry.defaultValue === 'function' ? entry.defaultValue() : null;
+ };
+ class AppDataError extends Error {
+ constructor(code, message) {
+ super(message);
+ this.code = code;
+ this.committed = false;
+ }
+ }
+ class Kernel {
+ async initialize() { this.state = 'ready'; this.backend = 'memory'; return this; }
+ async read(key, options = {}) {
+ const row = shared.docs.get(key) || null;
+ const data = row && row.state !== 'cleared' ? row.data : defaultValue(key);
+ return options.withMeta ? { data: clone(data), envelope: clone(row) } : clone(data);
+ }
+ async mutate(changes, options = {}) {
+ const operationId = String(options.operationId || `document-${++shared.counter}`);
+ for (const change of changes) {
+ const current = shared.docs.get(change.logicalKey) || null;
+ const currentRevision = Number(current && current.revision || 0);
+ if (change.expectedRevision !== undefined && Number(change.expectedRevision) !== currentRevision) {
+ throw new AppDataError('CONFLICT', 'document revision conflict');
+ }
+ shared.docs.set(change.logicalKey, envelope(
+ change.logicalKey,
+ change.data,
+ change.state,
+ currentRevision + 1,
+ operationId
+ ));
+ }
+ return { committed: true, operationId, revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] };
+ }
+ async journalNoop(options = {}) {
+ return { committed: true, operationId: options.operationId || `noop-${++shared.counter}`, revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] };
+ }
+ async readEntity(store, recordId, options = {}) {
+ const row = shared.entities.get(store).get(String(recordId)) || null;
+ return options.withMeta ? clone(row) : row && clone(row.data);
+ }
+ async listEntities(store, options = {}) {
+ if (store !== 'practiceSummaries') throw new AppDataError('VALIDATION', 'only practice summaries are listable');
+ const rows = Array.from(shared.entities.get(store).values());
+ return options.withMeta ? clone(rows) : rows.map((row) => clone(row.data));
+ }
+ async readPracticeSnapshot(recordIds = null, options = {}) {
+ shared.snapshotReads += 1;
+ const requested = recordIds === null || recordIds === undefined
+ ? null
+ : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]).map(String));
+ const stores = Array.isArray(options.stores) && options.stores.length
+ ? options.stores
+ : ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'];
+ const result = {};
+ for (const store of stores) {
+ const rows = Array.from(shared.entities.get(store).values())
+ .filter((row) => !requested || requested.has(String(row.recordId)));
+ result[store] = options.withMeta ? clone(rows) : rows.map((row) => clone(row.data));
+ }
+ return result;
+ }
+ async mutateEntities(operations, options = {}) {
+ const operationId = String(options.operationId || `entity-${++shared.counter}`);
+ const next = new Map(Array.from(shared.entities, ([store, rows]) => [store, new Map(rows)]));
+ for (const item of operations) {
+ const rows = next.get(item.store);
+ if (item.type === 'clear') {
+ rows.clear();
+ continue;
+ }
+ const id = String(item.recordId);
+ const current = rows.get(id) || null;
+ const currentRevision = Number(current && current.revision || 0);
+ if (item.expectedRevision !== undefined && item.expectedRevision !== null
+ && Number(item.expectedRevision) !== currentRevision) {
+ throw new AppDataError('CONFLICT', `entity revision conflict: ${item.store}/${id}`);
+ }
+ if (item.type === 'delete') {
+ rows.delete(id);
+ continue;
+ }
+ rows.set(id, {
+ recordId: id,
+ revision: currentRevision + 1,
+ operationId,
+ updatedAt: new Date().toISOString(),
+ data: clone(item.data),
+ checksum: checksum(item.data)
+ });
+ }
+ shared.entities = next;
+ shared.mutations += 1;
+ return { committed: true, operationId, revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] };
+ }
+ async exportSnapshot() {
+ return {
+ format: 'ielts-atlas-data-v2',
+ schemaVersion: catalog.version,
+ scope: 'full',
+ envelopes: {},
+ entities: Object.fromEntries(Array.from(shared.entities, ([store, rows]) => [store, Array.from(rows.values()).map(clone)]))
+ };
+ }
+ async installSnapshot() { return { committed: true, operationId: 'install', revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] }; }
+ onCommitted() { return () => {}; }
+ status() { return { state: this.state, backend: this.backend, failure: null }; }
+ }
+
+ const internals = {
+ DataKernel: Kernel,
+ AppDataError,
+ catalog,
+ clone,
+ checksum,
+ randomId: (prefix) => `${prefix}-${++shared.counter}`,
+ nowIso: () => new Date().toISOString(),
+ makeEnvelope: (entry, data, options = {}) => envelope(entry.logicalKey, data, options.state, options.revision, options.operationId)
+ };
+ const sandbox = {
+ console: { log() {}, warn() {}, error() {}, info() {}, debug() {} },
+ Date, JSON, Math, Map, Set, Promise, structuredClone, Reflect,
+ Object, Array, Number, String, Boolean, RegExp, Error, TypeError,
+ __AppDataV2Internals: internals
+ };
+ sandbox.window = sandbox;
+ sandbox.globalThis = sandbox;
+ const context = vm.createContext(sandbox);
+ vm.runInContext(source('js/data/practiceRecordSource.js'), context, { filename: 'practiceRecordSource.js' });
+ vm.runInContext(source('js/data/v2/appData.js'), context, { filename: 'appData.js' });
+ return {
+ app: sandbox.AppData,
+ shared,
+ snapshot() {
+ return {
+ entities: Object.fromEntries(Array.from(shared.entities, ([store, rows]) => [store, Array.from(rows.values()).map(clone)]))
+ };
+ }
+ };
+}
+
+function makeRecord(index, options = {}) {
+ const prefix = options.prefix || 'stress';
+ const highlights = Array.from({ length: options.highlightCount || 0 }, (_, highlightIndex) => ({
+ id: `${prefix}-highlight-${index}-${highlightIndex}`,
+ noteId: options.noteCount ? `${prefix}-note-${index}-${highlightIndex % options.noteCount}` : '',
+ text: `highlight ${index}/${highlightIndex} ${'h'.repeat(48)}`,
+ start: highlightIndex * 5,
+ end: highlightIndex * 5 + 12
+ }));
+ const notes = Array.from({ length: options.noteCount || 0 }, (_, noteIndex) => ({
+ id: `${prefix}-note-${index}-${noteIndex}`,
+ body: `note ${index}/${noteIndex} ${'n'.repeat(64)}`,
+ quote: `quote ${index}/${noteIndex}`
+ }));
+ const noteOutlines = Array.from({ length: options.outlineCount || 0 }, (_, outlineIndex) => ({
+ id: `${prefix}-outline-${index}-${outlineIndex}`,
+ title: `Outline ${index}/${outlineIndex}`,
+ order: outlineIndex
+ }));
+ const timestamp = new Date(Date.UTC(2026, 0, 1, 0, 0, index % 60)).toISOString();
+ return {
+ id: `${prefix}-record-${index}`,
+ sessionId: `${prefix}-session-${index}`,
+ examId: `${prefix}-exam-${index}`,
+ title: `Stress record ${index}`,
+ type: 'reading',
+ date: timestamp,
+ startTime: timestamp,
+ endTime: timestamp,
+ duration: 1800 + index,
+ totalQuestions: 40,
+ correctAnswers: 36,
+ accuracy: 0.9,
+ answers: { q1: 'A', q2: 'B' },
+ correctAnswerMap: { q1: 'A', q2: 'C' },
+ highlights,
+ notes,
+ noteOutlines,
+ metadata: { examTitle: `Stress record ${index}`, category: 'P3', frequency: 'high', type: 'reading' }
+ };
+}
+
+function verifyRecord(record, expected) {
+ assert(record, 'record must exist after v2 persistence round-trip');
+ assert.strictEqual(record.highlights.length, expected.highlights, `${record.id}: highlights changed`);
+ assert.strictEqual(record.notes.length, expected.notes, `${record.id}: notes changed`);
+ assert.strictEqual(record.noteOutlines.length, expected.outlines, `${record.id}: outlines changed`);
+ if (expected.highlights && expected.notes) {
+ assert.strictEqual(record.highlights[0].noteId, record.notes[0].id, `${record.id}: annotation link changed`);
+ }
+}
+
+async function main() {
+ const metrics = {};
+ const harness = createHarness();
+ await harness.app.ready;
+ const concurrent = Array.from({ length: profile.concurrentRecords }, (_, index) => makeRecord(index, {
+ prefix: 'concurrent', highlightCount: 12, noteCount: 6, outlineCount: 3
+ }));
+ let started = performance.now();
+ await Promise.all(concurrent.map((record) => harness.app.practice.completeAttempt({
+ operationId: `stress-concurrent-${record.id}`,
+ record
+ })));
+ metrics.concurrentSaveMs = Math.round((performance.now() - started) * 100) / 100;
+ const concurrentFull = await harness.app.practice.list({ projection: 'full' });
+ assert.strictEqual(concurrentFull.length, concurrent.length, 'concurrent v2 saves lost records');
+ concurrentFull.forEach((record) => verifyRecord(record, { highlights: 12, notes: 6, outlines: 3 }));
+
+ const readsBeforeSnapshotCheck = harness.shared.snapshotReads;
+ const snapshotChecked = await harness.app.practice.get('concurrent-record-0', { projection: 'full' });
+ assert.strictEqual(harness.shared.snapshotReads, readsBeforeSnapshotCheck + 1, 'full get must use one projection snapshot');
+ verifyRecord(snapshotChecked, { highlights: 12, notes: 6, outlines: 3 });
+ const light = await harness.app.practice.list({ projection: 'light' });
+ assert(light.every((record) => !Object.prototype.hasOwnProperty.call(record, 'highlights')), 'light projection leaked annotations');
+
+ const heavy = Array.from({ length: profile.heavyRecords }, (_, index) => makeRecord(index, {
+ prefix: 'heavy', highlightCount: profile.highlights, noteCount: profile.notes, outlineCount: profile.outlines
+ }));
+ started = performance.now();
+ await Promise.all(heavy.map((record) => harness.app.practice.completeAttempt({
+ operationId: `stress-heavy-${record.id}`,
+ record
+ })));
+ metrics.heavySaveMs = Math.round((performance.now() - started) * 100) / 100;
+ started = performance.now();
+ const heavyFull = await harness.app.practice.list({ projection: 'full' });
+ metrics.fullListMs = Math.round((performance.now() - started) * 100) / 100;
+ assert.strictEqual(heavyFull.length, concurrent.length + heavy.length, 'full v2 list changed record count');
+ heavyFull.filter((record) => record.id.startsWith('heavy-')).forEach((record) => verifyRecord(record, {
+ highlights: profile.highlights, notes: profile.notes, outlines: profile.outlines
+ }));
+
+ await harness.app.practice.updateAnnotations({
+ recordId: 'heavy-record-0',
+ examId: 'heavy-exam-0',
+ patch: { reviewed: true },
+ operationId: 'stress-annotation-update'
+ });
+ const updated = await harness.app.practice.get('heavy-record-0', { projection: 'full' });
+ assert.strictEqual(updated.reviewed, true, 'annotation update was not visible in full projection');
+
+ await harness.app.practice.delete({ recordId: 'heavy-record-1', operationId: 'stress-delete' });
+ const afterDelete = await harness.app.practice.list({ projection: 'full' });
+ assert(afterDelete.every(Boolean), 'full list must not contain null rows after deletion');
+ assert(!afterDelete.some((record) => record.id === 'heavy-record-1'), 'deleted record survived full list');
+
+ const reloaded = createHarness(harness.snapshot());
+ await reloaded.app.ready;
+ const reloadedRecords = await reloaded.app.practice.list({ projection: 'full' });
+ assert.strictEqual(reloadedRecords.length, afterDelete.length, 'reload changed v2 record count');
+ metrics.serializedBytes = JSON.stringify(harness.snapshot()).length;
+
+ const generousLimitMs = ciProfile ? 10000 : 30000;
+ for (const [name, value] of Object.entries(metrics)) {
+ if (name.endsWith('Ms')) assert(value < generousLimitMs, `${name} exceeded ${generousLimitMs}ms`);
+ }
+ process.stdout.write(JSON.stringify({
+ status: 'pass',
+ detail: {
+ profile: ciProfile ? 'ci' : 'full',
+ concurrentRecords: concurrent.length,
+ heavyRecords: heavy.length,
+ highlightsPerHeavyRecord: profile.highlights,
+ notesPerHeavyRecord: profile.notes,
+ outlinesPerHeavyRecord: profile.outlines,
+ snapshotReads: harness.shared.snapshotReads,
+ ...metrics
+ }
+ }));
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/developer/tests/js/practiceRecordTest.js b/developer/tests/js/practiceRecordTest.js
deleted file mode 100644
index bb98b429..00000000
--- a/developer/tests/js/practiceRecordTest.js
+++ /dev/null
@@ -1,667 +0,0 @@
-/**
- * Practice记录增删测试
- * 验证练习记录管理功能的完整性
- */
-
-class PracticeRecordTest {
- constructor() {
- this.testResults = [];
- this.testRecords = [];
- this.originalRecords = [];
- this.simpleStorage = window.simpleStorageWrapper;
- this.practiceRecordAPI = window.PracticeRecordAPI;
- }
-
- getPracticeRecordAPI(requiredMethods = []) {
- const api = this.practiceRecordAPI || window.PracticeRecordAPI;
- if (!api) {
- throw new Error('PracticeRecordAPI不可用');
- }
- requiredMethods.forEach(method => {
- if (typeof api[method] !== 'function') {
- throw new Error(`PracticeRecordAPI.${method}不可用`);
- }
- });
- return api;
- }
-
- async listPracticeRecords() {
- const api = this.getPracticeRecordAPI(['list']);
- return await api.list();
- }
-
- async getPracticeRecordById(id) {
- const api = this.getPracticeRecordAPI(['getById']);
- return await api.getById(id);
- }
-
- async savePracticeRecord(record) {
- const api = this.getPracticeRecordAPI(['saveRecord']);
- await api.saveRecord(record, { updateStats: true });
- return true;
- }
-
- async replacePracticeRecords(records) {
- const api = this.getPracticeRecordAPI(['replace']);
- await api.replace(Array.isArray(records) ? records : [], { updateStats: true });
- return true;
- }
-
- async deletePracticeRecord(id) {
- const api = this.getPracticeRecordAPI(['deleteById']);
- const result = await api.deleteById(id, { updateStats: true });
- return Boolean(result && result.deleted);
- }
-
- async deletePracticeRecords(ids) {
- const api = this.getPracticeRecordAPI(['deleteMany']);
- const result = await api.deleteMany(ids, { updateStats: true });
- return Number(result && result.deletedCount) || 0;
- }
-
- // 运行所有Practice记录测试
- async runAllTests() {
- console.log('📝 开始Practice记录增删测试...');
-
- this.testResults = [];
-
- // 1. 备份原始数据
- await this.backupOriginalData();
-
- // 2. 测试存储连接
- this.testStorageConnection();
-
- // 3. 测试创建记录
- await this.testCreateRecord();
-
- // 4. 测试获取记录
- await this.testGetRecords();
-
- // 5. 测试更新记录
- await this.testUpdateRecord();
-
- // 6. 测试删除记录
- await this.testDeleteRecord();
-
- // 7. 测试批量操作
- await this.testBatchOperations();
-
- // 8. 测试数据验证
- await this.testDataValidation();
-
- // 9. 测试边界情况
- await this.testEdgeCases();
-
- // 10. 恢复原始数据
- await this.restoreOriginalData();
-
- this.printResults();
- return this.testResults;
- }
-
- // 备份原始数据
- async backupOriginalData() {
- const testName = '备份原始数据';
-
- try {
- if (this.practiceRecordAPI) {
- this.originalRecords = await this.listPracticeRecords();
- console.log(`[PracticeRecordTest] 备份了 ${this.originalRecords.length} 条原始记录`);
- this.recordTest(testName, true, {
- backupCount: this.originalRecords.length,
- timestamp: new Date().toISOString()
- });
- } else {
- this.recordTest(testName, false, { error: 'PracticeRecordAPI不可用' });
- }
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 测试存储连接
- testStorageConnection() {
- const testName = '存储连接测试';
-
- try {
- const checks = [
- { name: 'simpleStorageWrapper只读兼容存在', exists: !!this.simpleStorage },
- { name: 'getPracticeRecords只读方法存在', exists: typeof this.simpleStorage?.getPracticeRecords === 'function' },
- { name: 'PracticeRecordAPI存在', exists: !!this.practiceRecordAPI },
- { name: 'PracticeRecordAPI.saveRecord方法存在', exists: typeof this.practiceRecordAPI?.saveRecord === 'function' },
- { name: 'PracticeRecordAPI.deleteById方法存在', exists: typeof this.practiceRecordAPI?.deleteById === 'function' }
- ];
-
- const allConnected = checks.every(check => check.exists);
-
- this.recordTest(testName, allConnected, {
- checks,
- totalChecks: checks.length,
- passedChecks: checks.filter(c => c.exists).length
- });
-
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 测试创建记录
- async testCreateRecord() {
- const testName = '创建记录测试';
-
- try {
- const newRecords = [
- {
- id: 'test_record_1',
- title: '雅思阅读测试1',
- type: 'reading',
- score: 85,
- totalQuestions: 40,
- correctAnswers: 34,
- date: '2024-01-01',
- timeSpent: 1800, // 30分钟
- difficulty: 'medium'
- },
- {
- id: 'test_record_2',
- title: '雅思听力测试1',
- type: 'listening',
- score: 92,
- totalQuestions: 40,
- correctAnswers: 37,
- date: '2024-01-02',
- timeSpent: 1500, // 25分钟
- difficulty: 'hard'
- },
- {
- id: 'test_record_3',
- title: '雅思阅读测试2',
- type: 'reading',
- score: 78,
- totalQuestions: 40,
- correctAnswers: 31,
- date: '2024-01-03',
- timeSpent: 2000, // 33分钟
- difficulty: 'easy'
- }
- ];
-
- const createResults = [];
-
- for (const record of newRecords) {
- try {
- const success = await this.savePracticeRecord(record);
- createResults.push({
- recordId: record.id,
- success,
- record: record
- });
-
- if (success) {
- this.testRecords.push(record);
- }
- } catch (error) {
- createResults.push({
- recordId: record.id,
- success: false,
- error: error.message
- });
- }
- }
-
- const allCreated = createResults.every(r => r.success);
-
- this.recordTest(testName, allCreated, {
- createResults,
- totalRecords: newRecords.length,
- successfulCreates: createResults.filter(r => r.success).length
- });
-
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 测试获取记录
- async testGetRecords() {
- const testName = '获取记录测试';
-
- try {
- // 获取所有记录
- const allRecords = await this.listPracticeRecords();
-
- // 验证测试记录是否被正确保存
- const foundTestRecords = this.testRecords.filter(testRecord =>
- allRecords.some(savedRecord => savedRecord.id === testRecord.id)
- );
-
- // 测试单个记录获取
- const singleRecordResults = [];
- for (const testRecord of this.testRecords) {
- try {
- const foundRecord = await this.getPracticeRecordById(testRecord.id);
- singleRecordResults.push({
- recordId: testRecord.id,
- success: foundRecord !== null && foundRecord.id === testRecord.id
- });
- } catch (error) {
- singleRecordResults.push({
- recordId: testRecord.id,
- success: false,
- error: error.message
- });
- }
- }
-
- const allFound = foundTestRecords.length === this.testRecords.length;
- const allSinglesFound = singleRecordResults.every(r => r.success);
-
- this.recordTest(testName, allFound && allSinglesFound, {
- totalRecordsInStorage: allRecords.length,
- testRecordsCreated: this.testRecords.length,
- foundTestRecords: foundTestRecords.length,
- singleRecordResults,
- getAllSuccess: allFound,
- getByIdSuccess: allSinglesFound
- });
-
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 测试更新记录
- async testUpdateRecord() {
- const testName = '更新记录测试';
-
- try {
- if (this.testRecords.length === 0) {
- this.recordTest(testName, false, { error: '没有测试记录可供更新' });
- return;
- }
-
- const updateRecord = this.testRecords[0];
- const originalScore = updateRecord.score;
-
- // 更新数据
- const updates = {
- score: 90,
- timeSpent: 1600,
- notes: '更新后的笔记'
- };
-
- const updateSuccess = await this.savePracticeRecord({ ...updateRecord, ...updates });
-
- // 验证更新
- const updatedRecord = await this.getPracticeRecordById(updateRecord.id);
- const updateVerified = updatedRecord !== null &&
- updatedRecord.score === updates.score &&
- updatedRecord.timeSpent === updates.timeSpent &&
- updatedRecord.notes === updates.notes;
-
- // 恢复原始数据(用于其他测试)
- if (updateSuccess) {
- await this.savePracticeRecord({ ...updatedRecord, score: originalScore });
- }
-
- this.recordTest(testName, updateSuccess && updateVerified, {
- recordId: updateRecord.id,
- originalScore,
- updates,
- updateSuccess,
- updateVerified,
- updatedScore: updatedRecord?.score
- });
-
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 测试删除记录
- async testDeleteRecord() {
- const testName = '删除记录测试';
-
- try {
- if (this.testRecords.length < 2) {
- this.recordTest(testName, false, { error: '测试记录不足,无法进行删除测试' });
- return;
- }
-
- const recordToDelete = this.testRecords.pop(); // 删除最后一个测试记录
- const recordsBeforeDelete = (await this.listPracticeRecords()).length;
-
- // 执行删除
- const deleteSuccess = await this.deletePracticeRecord(recordToDelete.id);
-
- // 验证删除
- const recordsAfterDelete = (await this.listPracticeRecords()).length;
- const deleteVerified = recordsAfterDelete === recordsBeforeDelete - 1;
-
- // 确认记录确实被删除
- const deletedRecordExists = (await this.getPracticeRecordById(recordToDelete.id)) !== null;
-
- const fullyDeleted = deleteSuccess && deleteVerified && !deletedRecordExists;
-
- this.recordTest(testName, fullyDeleted, {
- deletedRecordId: recordToDelete.id,
- recordsBeforeDelete,
- recordsAfterDelete,
- deleteSuccess,
- deleteVerified,
- recordStillExists: deletedRecordExists
- });
-
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 测试批量操作
- async testBatchOperations() {
- const testName = '批量操作测试';
-
- try {
- // 创建更多测试记录用于批量操作
- const batchRecords = [
- {
- id: 'batch_test_1',
- title: '批量测试1',
- type: 'reading',
- score: 75,
- date: '2024-01-04'
- },
- {
- id: 'batch_test_2',
- title: '批量测试2',
- type: 'listening',
- score: 88,
- date: '2024-01-05'
- }
- ];
-
- // 批量添加
- let batchAddSuccess = true;
- const addedIds = [];
- for (const record of batchRecords) {
- const success = await this.savePracticeRecord(record);
- if (success) {
- addedIds.push(record.id);
- } else {
- batchAddSuccess = false;
- }
- }
-
- // 批量删除
- const recordsBeforeBatchDelete = (await this.listPracticeRecords()).length;
- const deletedCount = await this.deletePracticeRecords(addedIds);
- const batchDeleteSuccess = deletedCount === addedIds.length;
- const recordsAfterBatchDelete = (await this.listPracticeRecords()).length;
-
- const batchDeleteVerified = recordsAfterBatchDelete === recordsBeforeBatchDelete - addedIds.length;
-
- this.recordTest(testName, batchAddSuccess && batchDeleteSuccess && batchDeleteVerified, {
- batchRecordsCount: batchRecords.length,
- batchAddSuccess,
- addedIds: addedIds.length,
- batchDeleteSuccess,
- recordsBeforeBatchDelete,
- recordsAfterBatchDelete,
- batchDeleteVerified
- });
-
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 测试数据验证
- async testDataValidation() {
- const testName = '数据验证测试';
-
- try {
- const validationResults = [];
-
- // 测试有效记录
- const validRecord = {
- id: 'validation_test_valid',
- title: '有效记录测试',
- type: 'reading',
- score: 85,
- date: '2024-01-06'
- };
-
- const validValidation = this.simpleStorage.validatePracticeRecord(validRecord);
- validationResults.push({
- type: 'valid_record',
- success: validValidation.isValid,
- details: validValidation
- });
-
- // 测试无效记录(缺少必需字段)
- const invalidRecords = [
- {
- // 缺少id
- title: '无效记录测试1',
- type: 'reading',
- score: 85,
- date: '2024-01-06'
- },
- {
- id: 'invalid_test_2',
- // 缺少type
- title: '无效记录测试2',
- score: 85,
- date: '2024-01-06'
- },
- {
- id: 'invalid_test_3',
- title: '无效记录测试3',
- type: 'reading',
- // 缺少score
- date: '2024-01-06'
- },
- {
- id: 'invalid_test_4',
- title: '无效记录测试4',
- type: 'reading',
- score: 85
- // 缺少date
- }
- ];
-
- invalidRecords.forEach((record, index) => {
- const validation = this.simpleStorage.validatePracticeRecord(record);
- validationResults.push({
- type: `invalid_record_${index + 1}`,
- success: !validation.isValid, // 应该验证失败
- details: validation
- });
- });
-
- const allValidationsCorrect = validationResults.every(r => r.success);
-
- this.recordTest(testName, allValidationsCorrect, {
- validationResults,
- totalValidations: validationResults.length,
- correctValidations: validationResults.filter(r => r.success).length
- });
-
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 测试边界情况
- async testEdgeCases() {
- const testName = '边界情况测试';
-
- try {
- const edgeCaseResults = [];
-
- // 测试空记录
- const emptyRecord = { id: 'empty_test', title: '', type: '', score: 0, date: '' };
- const emptyValidation = this.simpleStorage.validatePracticeRecord(emptyRecord);
- edgeCaseResults.push({
- case: 'empty_record',
- success: !emptyValidation.isValid,
- details: emptyValidation
- });
-
- // 测试极值分数
- const extremeScoreRecord = {
- id: 'extreme_score_test',
- title: '极值分数测试',
- type: 'reading',
- score: 150, // 超过100
- date: '2024-01-07'
- };
- const extremeScoreValidation = this.simpleStorage.validatePracticeRecord(extremeScoreRecord);
- edgeCaseResults.push({
- case: 'extreme_score',
- success: extremeScoreValidation.isValid, // 结构有效,值是否合理由业务逻辑处理
- details: extremeScoreValidation
- });
-
- // 测试特殊字符
- const specialCharRecord = {
- id: 'special_char_test',
- title: '特殊字符测试 🚀',
- type: 'reading',
- score: 85,
- date: '2024-01-08'
- };
- const specialCharValidation = this.simpleStorage.validatePracticeRecord(specialCharRecord);
- edgeCaseResults.push({
- case: 'special_characters',
- success: specialCharValidation.isValid,
- details: specialCharValidation
- });
-
- // 测试超长标题
- const longTitleRecord = {
- id: 'long_title_test',
- title: '这是一个非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常长的标题测试',
- type: 'reading',
- score: 85,
- date: '2024-01-09'
- };
- const longTitleValidation = this.simpleStorage.validatePracticeRecord(longTitleRecord);
- edgeCaseResults.push({
- case: 'long_title',
- success: longTitleValidation.isValid,
- details: longTitleValidation
- });
-
- const allEdgeCasesPassed = edgeCaseResults.every(r => r.success);
-
- this.recordTest(testName, allEdgeCasesPassed, {
- edgeCaseResults,
- totalEdgeCases: edgeCaseResults.length,
- passedEdgeCases: edgeCaseResults.filter(r => r.success).length
- });
-
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 恢复原始数据
- async restoreOriginalData() {
- const testName = '恢复原始数据';
-
- try {
- // 清理所有测试记录
- await this.replacePracticeRecords(this.originalRecords);
-
- // 验证恢复
- const finalRecords = await this.listPracticeRecords();
- const expectedCount = this.originalRecords.length;
- const restoreSuccess = finalRecords.length === expectedCount;
-
- this.recordTest(testName, restoreSuccess, {
- originalCount: this.originalRecords.length,
- finalCount: finalRecords.length,
- expectedCount,
- restoreSuccess,
- timestamp: new Date().toISOString()
- });
-
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 记录测试结果
- recordTest(testName, passed, details) {
- this.testResults.push({
- name: testName,
- passed,
- details,
- timestamp: new Date().toISOString()
- });
-
- const status = passed ? '✅' : '❌';
- console.log(`${status} ${testName}`);
- if (!passed && details.error) {
- console.error(' 错误:', details.error);
- }
- }
-
- // 打印测试结果
- printResults() {
- const totalTests = this.testResults.length;
- const passedTests = this.testResults.filter(r => r.passed).length;
- const failedTests = totalTests - passedTests;
-
- console.log('\n📊 Practice记录测试结果汇总:');
- console.log(`总测试数: ${totalTests}`);
- console.log(`通过: ${passedTests} ✅`);
- console.log(`失败: ${failedTests} ❌`);
- console.log(`成功率: ${((passedTests / totalTests) * 100).toFixed(1)}%`);
-
- if (failedTests > 0) {
- console.log('\n❌ 失败的测试:');
- this.testResults
- .filter(r => !r.passed)
- .forEach(r => {
- console.log(` - ${r.name}: ${r.details.error || '测试条件不满足'}`);
- });
- }
-
- console.log('\n📈 操作统计:');
- console.log(`测试创建记录数: ${this.testRecords.length}`);
- console.log(`原始记录备份数: ${this.originalRecords.length}`);
- }
-
- // 生成测试报告
- generateReport() {
- const totalTests = this.testResults.length;
- const passedTests = this.testResults.filter(r => r.passed).length;
- const successRate = ((passedTests / totalTests) * 100).toFixed(1);
-
- return {
- summary: {
- totalTests,
- passedTests,
- failedTests: totalTests - passedTests,
- successRate: `${successRate}%`,
- timestamp: new Date().toISOString()
- },
- operationStats: {
- testRecordsCreated: this.testRecords.length,
- originalRecordsBackedUp: this.originalRecords.length
- },
- failedTests: this.testResults.filter(r => !r.passed).map(r => ({
- name: r.name,
- error: r.details.error || '测试条件不满足',
- details: r.details
- }))
- };
- }
-}
-
-// 导出供使用
-if (typeof module !== 'undefined' && module.exports) {
- module.exports = PracticeRecordTest;
-}
diff --git a/developer/tests/js/practiceRecorder.test.js b/developer/tests/js/practiceRecorder.test.js
index 57264fb2..608c7573 100644
--- a/developer/tests/js/practiceRecorder.test.js
+++ b/developer/tests/js/practiceRecorder.test.js
@@ -1,158 +1,181 @@
#!/usr/bin/env node
+import assert from 'assert';
import fs from 'fs';
import path from 'path';
import vm from 'vm';
-import assert from 'assert';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
-const repoRoot = path.resolve(__dirname, '..', '..', '..');
+const repoRoot = path.resolve(__dirname, '../../..');
-function loadScript(relativePath, context) {
- const fullPath = path.join(repoRoot, relativePath);
- const source = fs.readFileSync(fullPath, 'utf8');
- vm.runInContext(source, context, { filename: relativePath });
+function clone(value) {
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
-const results = [];
-
-function recordResult(name, detail) {
- results.push({ name, detail, timestamp: new Date().toISOString() });
-}
-
-function createPrototypeRecorder(PracticeRecorder) {
- const recorder = Object.create(PracticeRecorder.prototype);
- recorder.scoreStorage = null;
- recorder.practiceTypeCache = new Map();
- recorder.lookupExamIndexEntry = () => null;
- recorder.generateRecordId = () => 'record-generated';
- return recorder;
+function loadScript(relativePath, context) {
+ vm.runInContext(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'), context, { filename: relativePath });
}
-async function testCanonicalCorrectAnswerMapWins(PracticeRecorder) {
- const recorder = createPrototypeRecorder(PracticeRecorder);
- const record = {
- id: 'record-canonical-correct-map',
- examId: 'reading-canonical',
- sessionId: 'session-canonical',
- title: 'Canonical correct map',
- type: 'reading',
- date: '2026-05-10T00:00:00.000Z',
- startTime: '2026-05-10T00:00:00.000Z',
- endTime: '2026-05-10T00:10:00.000Z',
- duration: 600,
- score: 2,
- totalQuestions: 2,
- correctAnswers: { q1: 'B', q2: 'C' },
- correctAnswersCount: 2,
- accuracy: 1,
- answers: { q1: 'A', q2: 'D' },
- correctAnswerMap: { q1: 'A', q2: 'D' },
- realData: {
- correctAnswerMap: { q2: 'D', q3: 'E' },
- correctAnswers: { q1: 'B', q2: 'C', q3: 'F' },
- answers: { q1: 'A', q2: 'D', q3: 'E' }
- },
- scoreInfo: {
- correct: 2,
- details: {
- q1: { userAnswer: 'A', correctAnswer: 'B' },
- q3: { userAnswer: 'E', correctAnswer: 'G' }
- }
- },
- metadata: { examTitle: 'Canonical', category: 'P1', frequency: 'high', type: 'reading' }
+function createHarness() {
+ const state = {
+ records: [],
+ drafts: [{
+ id: 'reading-draft:reading-p2',
+ kind: 'reading_draft',
+ examId: 'reading-p2',
+ sessionId: 'reading-session',
+ answers: { q1: 'A' },
+ updatedAt: '2026-07-26T00:00:00.000Z'
+ }],
+ commands: [],
+ backupCalls: [],
+ discardedActiveSessions: [],
+ failCompleteAttempts: 0
};
-
- const storageReady = recorder.prepareRecordForStorage(record);
- assert.strictEqual(storageReady.correctAnswerMap.q1, 'A', 'canonical correctAnswerMap 应压过对象型 correctAnswers');
- assert.strictEqual(storageReady.correctAnswerMap.q2, 'D', 'realData legacy 不应覆盖顶层 canonical');
- assert.strictEqual(storageReady.correctAnswerMap.q3, 'E', 'realData.correctAnswerMap 应先于 legacy correctAnswers 补缺');
- assert.strictEqual(storageReady.realData.correctAnswers.q1, 'A', 'realData.correctAnswers 应镜像 canonical map');
- assert.strictEqual(storageReady.realData.correctAnswerMap.q3, 'E', 'realData.correctAnswerMap 应镜像 canonical map');
-
- const standardized = recorder.normalizeRecordForPracticeRecordApi(record);
- assert.strictEqual(standardized.correctAnswers, 2, '对象型 correctAnswers 不能污染数字答对数');
- assert.strictEqual(standardized.correctAnswerMap.q1, 'A', '重试标准化也必须 canonical 优先');
- assert.strictEqual(standardized.correctAnswerMap.q3, 'E', '重试标准化应保留 realData canonical 补缺');
- assert.strictEqual(standardized.realData.correctAnswers.q1, 'A', '重试标准化 realData.correctAnswers 应镜像 canonical');
- assert.strictEqual(standardized.realData.correctAnswerMap.q2, 'D', '重试标准化 realData.correctAnswerMap 应存在');
- recordResult('PracticeRecorder 正确答案表 canonical 优先', { correctAnswerMap: standardized.correctAnswerMap });
-}
-
-async function testCompletionPayloadCanonicalCorrectAnswerMapWins(PracticeRecorder) {
- const recorder = createPrototypeRecorder(PracticeRecorder);
- const shaped = recorder.normalizePracticeCompletePayload({
- examId: 'reading-completion-canonical',
- sessionId: 'session-completion-canonical',
- answers: { q1: 'A', q2: 'D' },
- correctAnswerMap: { q1: 'A', q2: 'D' },
- correctAnswers: { q1: 'B', q2: 'C' },
- correctAnswersCount: 2,
- scoreInfo: {
- correct: 2,
- total: 2,
- details: {
- q1: { userAnswer: 'A', correctAnswer: 'B' }
+ const quietConsole = { log() {}, warn() {}, error() {}, info() {}, debug() {} };
+ const appData = {
+ ready: Promise.resolve(),
+ practice: {
+ async completeAttempt(command) {
+ state.commands.push(clone(command));
+ if (state.failCompleteAttempts > 0) {
+ state.failCompleteAttempts -= 1;
+ const error = new Error('transient write failure');
+ error.code = 'IO';
+ throw error;
+ }
+ const existing = state.records.find((record) => record.id === command.record.id);
+ if (existing) return { committed: true, operationId: command.operationId, revision: 1, record: clone(existing) };
+ const record = clone(command.record);
+ state.records.unshift(record);
+ return { committed: true, operationId: command.operationId, revision: 1, record: clone(record) };
+ },
+ async get(id) {
+ return clone(state.records.find((record) => record.id === id) || null);
+ },
+ async list() {
+ return clone(state.records);
+ },
+ async getStats() {
+ return { totalPractices: state.records.length, totalTimeSpent: 600, averageScore: 0.5 };
}
},
- realData: {
- correctAnswerMap: { q2: 'D' },
- correctAnswers: { q1: 'B', q2: 'C' }
- }
- });
-
- assert(shaped, 'completion payload 应能标准化');
- assert.strictEqual(shaped.results.correctAnswers, 2, 'completion 数字 correctAnswers 应来自 count 字段');
- assert.strictEqual(shaped.results.correctAnswerMap.q1, 'A', 'completion canonical map 应压过 legacy map');
- assert.strictEqual(shaped.results.correctAnswerMap.q2, 'D', 'completion canonical map 应保留 q2');
- assert.strictEqual(shaped.results.realData.correctAnswers.q1, 'A', 'completion realData.correctAnswers 应镜像 canonical');
- assert.strictEqual(shaped.results.realData.correctAnswerMap.q2, 'D', 'completion realData.correctAnswerMap 应镜像 canonical');
- recordResult('completion payload 正确答案表 canonical 优先', { correctAnswerMap: shaped.results.correctAnswerMap });
-}
-
-async function testRetrySaveUsesPracticeRecordApi(PracticeRecorder, windowStub) {
- const savedRecords = [];
- const tempRecords = [];
- const saveOptions = [];
- const recorder = Object.create(PracticeRecorder.prototype);
-
- recorder.scoreStorage = null;
- recorder.practiceTypeCache = new Map();
- recorder.metaRepo = {
- async get(key, fallback) {
- if (key === 'temp_practice_records') {
- return tempRecords.map((record) => JSON.parse(JSON.stringify(record)));
+ recovery: {
+ async listDrafts() {
+ return clone(state.drafts);
+ },
+ async saveDraft(value) {
+ const draft = { ...clone(value), updatedAt: '2026-07-26T01:00:00.000Z' };
+ const index = state.drafts.findIndex((entry) => entry.id === draft.id);
+ if (index >= 0) state.drafts[index] = draft;
+ else state.drafts.push(draft);
+ return { committed: true, item: clone(draft) };
+ },
+ async discardDraft(id) {
+ state.drafts = state.drafts.filter((entry) => entry.id !== id);
+ return { committed: true };
+ },
+ async discardActiveSession(id) {
+ state.discardedActiveSessions.push(String(id));
+ return { committed: true };
}
- return fallback;
},
- async set(key, value) {
- if (key === 'temp_practice_records') {
- tempRecords.splice(0, tempRecords.length, ...value.map((record) => JSON.parse(JSON.stringify(record))));
+ backups: {
+ async export(options) {
+ state.backupCalls.push({ method: 'export', options: clone(options) });
+ return {
+ format: 'ielts-atlas-data-v2',
+ schemaVersion: 2,
+ scope: 'partial',
+ envelopes: {},
+ entities: {
+ practiceSummaries: state.records.map((record) => ({
+ recordId: record.id,
+ revision: 1,
+ operationId: 'export',
+ updatedAt: '2026-07-26T00:00:00.000Z',
+ data: clone(record),
+ checksum: `sum-${record.id}`
+ })),
+ practiceDetails: [],
+ practiceAnnotations: []
+ },
+ checksum: 'snapshot-checksum'
+ };
+ },
+ async create(options) {
+ state.backupCalls.push({ method: 'create', options: clone(options) });
+ return { id: options.id || 'backup-before-import' };
+ },
+ async previewImport(payload, options) {
+ state.backupCalls.push({ method: 'previewImport', payload: clone(payload), options: clone(options) });
+ const format = payload && payload.format === 'ielts-atlas-data-v2' ? 'v2' : 'v1';
+ return {
+ id: 'import-plan-1',
+ format,
+ keys: [],
+ practice: { accepted: 1, importedCount: 1, skippedCount: 0 }
+ };
+ },
+ async commitImport(id, options) {
+ state.backupCalls.push({ method: 'commitImport', id, options: clone(options) });
+ return {
+ committed: true,
+ operationId: options.operationId || 'import-operation',
+ revisions: { 'practiceSummaries/legacy-import': 1 },
+ importedCount: 1,
+ practice: { accepted: 1, importedCount: 1, skippedCount: 0 }
+ };
+ },
+ async recordImport(entry) {
+ state.backupCalls.push({ method: 'recordImport', entry: clone(entry) });
+ return { committed: true };
+ },
+ async restore(id) {
+ state.backupCalls.push({ method: 'restore', id });
+ return { committed: true, operationId: `restore:${id}` };
+ },
+ async list() {
+ return [{ id: 'backup-before-import' }];
}
- return true;
}
};
- recorder.updateUserStats = async () => {
- throw new Error('retrySaveWithStandardizedRecord should let PracticeRecordAPI own stats');
+ const windowStub = {
+ console: quietConsole,
+ AppData: appData,
+ resolveActiveLibraryIndex: async () => [{ id: 'reading-p1', title: 'Passage 1', type: 'reading', category: 'P1', frequency: 'high' }]
};
- windowStub.PracticeRecordAPI = {
- async saveRecord(record, options = {}) {
- savedRecords.unshift(JSON.parse(JSON.stringify(record)));
- saveOptions.push(JSON.parse(JSON.stringify(options)));
- return record;
- }
+ const sandbox = {
+ window: windowStub,
+ console: quietConsole,
+ setTimeout,
+ clearTimeout,
+ setInterval,
+ clearInterval,
+ Date,
+ Math,
+ JSON
};
+ sandbox.globalThis = windowStub;
+ const context = vm.createContext(sandbox);
+ loadScript('js/core/practiceCore.js', context);
+ loadScript('js/core/practiceRecorder.js', context);
+ const recorder = Object.create(windowStub.PracticeRecorder.prototype);
+ recorder.wait = async () => {};
+ return { recorder, state, windowStub };
+}
- const saved = await recorder.retrySaveWithStandardizedRecord({
- id: 'record-api-retry',
+function makeRecord(id = 'record-v2') {
+ return {
+ id,
examId: 'reading-p1',
- sessionId: 'session-api-retry',
- title: 'Retry should use PracticeRecordAPI',
+ sessionId: `session-${id}`,
+ title: 'Passage 1',
type: 'reading',
- date: '2026-05-07T00:00:00.000Z',
- startTime: '2026-05-07T00:00:00.000Z',
- endTime: '2026-05-07T00:10:00.000Z',
+ date: '2026-07-26',
+ startTime: '2026-07-26T00:00:00.000Z',
+ endTime: '2026-07-26T00:10:00.000Z',
duration: 600,
score: 1,
totalQuestions: 2,
@@ -160,264 +183,358 @@ async function testRetrySaveUsesPracticeRecordApi(PracticeRecorder, windowStub)
accuracy: 0.5,
answers: { q1: 'A', q2: 'B' },
correctAnswerMap: { q1: 'A', q2: 'C' },
- metadata: { examTitle: 'Passage 1', category: 'P1', frequency: 'high', type: 'reading' }
- });
-
- assert.strictEqual(saved.id, 'record-api-retry', '标准化重试保存应返回已保存记录');
- assert.strictEqual(savedRecords.length, 1, '主记录应通过 PracticeRecordAPI 落库');
- assert.strictEqual(saveOptions[0].updateStats, true, '标准化重试保存应让统一 API 负责统计更新');
- assert.strictEqual(tempRecords.length, 0, 'API 保存成功不应写入临时恢复队列');
- assert.strictEqual(Object.prototype.hasOwnProperty.call(savedRecords[0], 'savedBy'), false, '标准化重试不应写入 fallback 标记');
- assert.strictEqual(Object.prototype.hasOwnProperty.call(savedRecords[0], 'fallbackReason'), false, '标准化重试不应写入 fallback 原因');
- recordResult('标准化重试保存走统一 PracticeRecordAPI', { savedRecordId: saved.id });
-}
-
-async function testPrimarySaveUsesPracticeRecordApi(PracticeRecorder, windowStub) {
- const savedRecords = [];
- const saveOptions = [];
- const recorder = Object.create(PracticeRecorder.prototype);
-
- recorder.scoreStorage = {};
- Object.defineProperties(recorder.scoreStorage, {
- currentVersion: {
- get() {
- throw new Error('savePracticeRecord must not read ScoreStorage.currentVersion');
- }
- },
- maxRecords: {
- get() {
- throw new Error('savePracticeRecord must not read ScoreStorage.maxRecords');
- }
- },
- savePracticeRecord: {
- value: async () => {
- throw new Error('savePracticeRecord must not call ScoreStorage directly');
- }
- }
- });
- recorder.practiceTypeCache = new Map();
- recorder.wait = async () => {};
- recorder.isCriticalError = PracticeRecorder.prototype.isCriticalError;
- recorder.prepareRecordForStorage = PracticeRecorder.prototype.prepareRecordForStorage;
- recorder.restoreRecordAnswerState = PracticeRecorder.prototype.restoreRecordAnswerState;
- recorder.buildRecordLogSummary = PracticeRecorder.prototype.buildRecordLogSummary;
- recorder.inferExamId = PracticeRecorder.prototype.inferExamId;
- recorder.lookupExamIndexEntry = () => null;
- recorder.normalizePracticeType = PracticeRecorder.prototype.normalizePracticeType;
- recorder.normalizeAnswerMap = PracticeRecorder.prototype.normalizeAnswerMap;
- recorder.normalizeAnswerComparison = PracticeRecorder.prototype.normalizeAnswerComparison;
- recorder.convertAnswerArrayToMap = PracticeRecorder.prototype.convertAnswerArrayToMap;
- recorder.extractCorrectAnswerMap = PracticeRecorder.prototype.extractCorrectAnswerMap;
- recorder.buildAnswerDetails = PracticeRecorder.prototype.buildAnswerDetails;
- recorder.saveToTemporaryStorage = async () => {
- throw new Error('API primary save success should not write temp queue');
- };
-
- windowStub.PracticeRecordAPI = {
- async saveRecord(record, options = {}) {
- savedRecords.unshift(JSON.parse(JSON.stringify(record)));
- saveOptions.push(JSON.parse(JSON.stringify(options)));
- return Object.assign({}, record, { savedBy: 'api-primary' });
+ realData: {
+ questionTypeMap: { q1: 'true-false-not-given' },
+ interactions: [{ type: 'answer', questionId: 'q1' }]
},
- async getById(recordId) {
- return savedRecords.find((record) => record.id === recordId || record.sessionId === recordId) || null;
- }
+ metadata: { examId: 'reading-p1', examTitle: 'Passage 1', category: 'P1', frequency: 'high', type: 'reading' }
};
-
- const saved = await recorder.savePracticeRecord({
- id: 'record-api-primary',
- examId: 'reading-p2',
- sessionId: 'session-api-primary',
- title: 'Primary should use PracticeRecordAPI',
- type: 'reading',
- date: '2026-05-08T00:00:00.000Z',
- startTime: '2026-05-08T00:00:00.000Z',
- endTime: '2026-05-08T00:12:00.000Z',
- duration: 720,
- score: 2,
- totalQuestions: 3,
- correctAnswers: 2,
- accuracy: 2 / 3,
- answers: { q1: 'A', q2: 'B', q3: 'C' },
- correctAnswerMap: { q1: 'A', q2: 'B', q3: 'D' },
- metadata: { examTitle: 'Passage 2', category: 'P2', frequency: 'medium', type: 'reading' }
- });
-
- assert.strictEqual(saved.id, 'record-api-primary', '正常保存应返回 API 保存记录');
- assert.strictEqual(saved.savedBy, 'api-primary', '正常保存应使用 PracticeRecordAPI 返回值');
- assert.strictEqual(savedRecords.length, 1, '正常保存应通过 PracticeRecordAPI 落库');
- assert.strictEqual(saveOptions[0].updateStats, true, '正常保存应让统一 API 负责统计更新');
- assert.strictEqual(Object.prototype.hasOwnProperty.call(saveOptions[0], 'currentVersion'), false, '正常保存不应从 ScoreStorage 透传版本');
- assert.strictEqual(Object.prototype.hasOwnProperty.call(saveOptions[0], 'maxRecords'), false, '正常保存不应从 ScoreStorage 透传容量');
- recordResult('正常保存走统一 PracticeRecordAPI', { savedRecordId: saved.id });
}
-async function testLegacyDataMethodsUseUnifiedAdapters(PracticeRecorder, windowStub) {
- const recorder = Object.create(PracticeRecorder.prototype);
- const calls = [];
- const records = [
- {
- id: 'record-export-a',
- examId: 'reading-export-a',
- sessionId: 'session-export-a',
- title: 'Export Passage',
- type: 'reading',
- date: '2026-05-09T00:00:00.000Z',
- startTime: '2026-05-09T00:00:00.000Z',
- endTime: '2026-05-09T00:05:00.000Z',
- duration: 300,
- score: 1,
- totalQuestions: 2,
- correctAnswers: 1,
- accuracy: 0.5,
- metadata: { category: 'P1', frequency: 'high', examTitle: 'Export Passage' }
- }
- ];
- const stats = {
- totalPractices: 1,
- totalTimeSpent: 300,
- averageScore: 0.5,
- categoryStats: {},
- questionTypeStats: {},
- streakDays: 1,
- practiceDays: ['2026-05-09'],
- lastPracticeDate: '2026-05-09',
- achievements: []
- };
-
- recorder.scoreStorage = {
- async savePracticeRecord() {
- throw new Error('legacy data methods must not save through ScoreStorage');
- },
- async getUserStats() {
- throw new Error('getUserStats must use PracticeRecordAPI.readStats');
- },
- exportData() {
- throw new Error('exportData must use PracticeRecordAPI');
- },
- importData() {
- throw new Error('importData must use DataBackupManager');
- },
- createBackup() {
- throw new Error('createBackup must use DataBackupManager');
- },
- restoreBackup() {
- throw new Error('restoreBackup must use DataBackupManager');
- }
+async function main() {
+ const results = [];
+ const record = async (name, test) => {
+ await test();
+ results.push({ name, status: 'pass' });
};
- windowStub.PracticeRecordAPI = {
- async list() {
- calls.push({ method: 'list' });
- return JSON.parse(JSON.stringify(records));
- },
- async readStats(options = {}) {
- calls.push({ method: 'readStats', fallback: options.fallback });
- return JSON.parse(JSON.stringify(stats));
- },
- getDefaultStats() {
- return {
- totalPractices: 0,
- totalTimeSpent: 0,
- averageScore: 0,
- categoryStats: {},
- questionTypeStats: {},
- streakDays: 0,
- practiceDays: [],
- lastPracticeDate: null,
- achievements: []
+ try {
+ await record('save separates business id from per-call operation id', async () => {
+ const { recorder, state } = createHarness();
+ const saved = await recorder.savePracticeRecord(makeRecord());
+ assert.strictEqual(saved.id, 'record-v2');
+ assert.strictEqual(state.records.length, 1);
+ assert.strictEqual(state.commands.length, 1);
+ assert.notStrictEqual(state.commands[0].operationId, 'record-v2');
+ assert(String(state.commands[0].operationId).startsWith('practice-complete_'));
+ assert.deepStrictEqual(clone(state.commands[0].record.answers), { q1: 'A', q2: 'B' });
+ assert(Array.isArray(state.commands[0].record.answerList));
+ assert.deepStrictEqual(clone(state.commands[0].record.answerList.map((item) => item.questionId)), ['q1', 'q2']);
+ assert.deepStrictEqual(clone(state.commands[0].record.questionTypeMap), { q1: 'true-false-not-given' });
+ assert.deepStrictEqual(clone(state.commands[0].record.interactions), [{ type: 'answer', questionId: 'q1' }]);
+ assert.deepStrictEqual(clone(saved.correctAnswerMap), { q1: 'A', q2: 'C' });
+ await recorder.savePracticeRecord({ ...makeRecord(), title: 'Updated title' });
+ assert.notStrictEqual(
+ state.commands[1].operationId,
+ state.commands[0].operationId,
+ 'a second logical save of the same record must receive a new operation id'
+ );
+ });
+
+ await record('internal retries reuse one operation id', async () => {
+ const { recorder, state } = createHarness();
+ state.failCompleteAttempts = 1;
+ const saved = await recorder.savePracticeRecord(makeRecord('record-retry'));
+ assert.strictEqual(saved.id, 'record-retry');
+ assert.strictEqual(state.commands.length, 2);
+ assert.strictEqual(state.commands[0].operationId, state.commands[1].operationId);
+ });
+
+ await record('global message listener never persists PRACTICE_COMPLETE alongside the host', async () => {
+ const { recorder } = createHarness();
+ let completionCalls = 0;
+ recorder.handleSessionCompleted = async () => { completionCalls += 1; };
+ recorder.handleExamMessage({
+ data: {
+ type: 'PRACTICE_COMPLETE',
+ data: { examId: 'reading-p1', results: { scoreInfo: { correct: 1, total: 1 } } }
+ }
+ });
+ assert.strictEqual(completionCalls, 0, 'host-owned completion must not be saved through the recorder global listener');
+ });
+
+ await record('restore and autosave isolate ordinary sessions from suite recovery snapshots', async () => {
+ const { recorder, windowStub } = createHarness();
+ const savedSessions = [];
+ const restoreLogs = [];
+ windowStub.console.log = (...args) => restoreLogs.push(args.join(' '));
+ windowStub.AppData.recovery.listActiveSessions = async () => clone([
+ null,
+ ['not-an-active-session'],
+ { id: 'missing-exam', sessionId: 'missing-exam' },
+ { examId: 'reading-missing-identity' },
+ {
+ id: 'active-session:mismatched-owner-a',
+ sessionId: 'mismatched-owner-b',
+ examId: 'reading-mismatched-identity'
+ },
+ {
+ schema: 'suite-session-v2',
+ version: 2,
+ id: 'suite-owner-restore',
+ sessionId: 'suite-owner-restore',
+ examId: 'reading-suite-host',
+ revision: 4,
+ status: 'active',
+ sequence: [{ examId: 'reading-suite-p1' }],
+ _recoveryExclusiveGroup: 'suite-practice',
+ updatedAt: '2026-08-09T00:02:00.000Z'
+ },
+ {
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: 'multi-owner-restore',
+ sessionId: 'multi-owner-restore',
+ examId: 'listening-suite-host',
+ revision: 2,
+ sessions: [{ id: 'multi-owner-restore', baseExamId: 'listening-base' }],
+ updatedAt: '2026-08-09T00:03:00.000Z'
+ },
+ {
+ id: 'active-session:ordinary-session',
+ sessionId: 'ordinary-session',
+ examId: ' reading-p1 ',
+ status: 'active',
+ updatedAt: '2026-08-09T00:01:00.000Z'
+ },
+ {
+ // Legacy ordinary recovery entries may have a sessionId but no
+ // AppData-prefixed entity id yet.
+ sessionId: 'legacy-ordinary-session',
+ examId: ' listening-p1 ',
+ status: 'paused',
+ lastActivity: '2026-08-09T00:00:00.000Z'
+ }
+ ]);
+ windowStub.AppData.recovery.saveActiveSession = async (value) => {
+ savedSessions.push(clone(value));
+ return { committed: true };
+ };
+ recorder.activeSessions = new Map();
+
+ await recorder.restoreActiveSessions();
+
+ assert.deepStrictEqual(
+ Array.from(recorder.activeSessions.keys()).sort(),
+ ['listening-p1', 'reading-p1'],
+ 'only normalized ordinary exam ids should enter the recorder map'
+ );
+ assert.strictEqual(
+ recorder.activeSessions.get('reading-p1').id,
+ 'active-session:ordinary-session'
+ );
+ assert.strictEqual(
+ recorder.activeSessions.get('listening-p1').id,
+ 'active-session:legacy-ordinary-session',
+ 'legacy ordinary sessions must retain compatibility via sessionId'
+ );
+ assert(
+ restoreLogs.includes('Restored 2 active sessions'),
+ 'restore diagnostics must count only accepted ordinary sessions'
+ );
+
+ await recorder.saveActiveSessions();
+
+ assert.deepStrictEqual(
+ savedSessions.map((session) => session.id).sort(),
+ ['active-session:legacy-ordinary-session', 'active-session:ordinary-session']
+ );
+ assert(savedSessions.every((session) => (
+ session.schema !== 'suite-session-v2'
+ && session.schema !== 'multi-suite-sessions-v2'
+ )), 'autosave must never rewrite suite recovery snapshots as recorder sessions');
+ assert.strictEqual(
+ savedSessions.some((session) => (
+ session.id === 'active-session:suite-owner-restore'
+ || session.id === 'active-session:multi-owner-restore'
+ )),
+ false,
+ 'autosave must never generate active-session: clones'
+ );
+ });
+
+ await record('completion keeps its exact session across same-exam ABA replacement', async () => {
+ const { recorder, state, windowStub } = createHarness();
+ let releaseExamIndex;
+ let markExamIndexEntered;
+ const examIndexEntered = new Promise((resolve) => { markExamIndexEntered = resolve; });
+ const examIndexGate = new Promise((resolve) => { releaseExamIndex = resolve; });
+ windowStub.resolveActiveLibraryIndex = async () => {
+ markExamIndexEntered();
+ await examIndexGate;
+ return [{ id: 'reading-p1', title: 'Passage 1', type: 'reading', category: 'P1', frequency: 'high' }];
};
- }
- };
- windowStub.DataBackupManager = class FakeDataBackupManager {
- importPracticeData(data, options = {}) {
- calls.push({ method: 'importPracticeData', data, options });
- return { imported: true, options };
- }
-
- createBackup(backupName, type) {
- calls.push({ method: 'createBackup', backupName, type });
- return 'backup-created';
- }
-
- restoreBackup(backupId) {
- calls.push({ method: 'restoreBackup', backupId });
- return { restored: backupId };
- }
- };
-
- const loadedStats = await recorder.getUserStats();
- assert.deepStrictEqual(loadedStats, stats, 'getUserStats 应通过 PracticeRecordAPI.readStats');
-
- const exported = JSON.parse(await recorder.exportData('json'));
- assert.strictEqual(exported.version, '0.6.2-fix', 'JSON 导出应使用稳定导出版本');
- assert.deepStrictEqual(exported.practiceRecords, records, 'JSON 导出应通过 PracticeRecordAPI.list');
- assert.deepStrictEqual(exported.userStats, stats, 'JSON 导出应通过 PracticeRecordAPI.readStats');
-
- const csv = await recorder.exportData('csv');
- assert(csv.includes('record-export-a'), 'CSV 导出应序列化 PracticeRecordAPI 记录');
-
- const importResult = await recorder.importData({ practice_records: records }, { merge: false });
- assert.strictEqual(importResult.imported, true, 'importData 应委托 DataBackupManager');
- assert.strictEqual(importResult.options.mergeMode, 'replace', 'merge=false 应转换为 replace mergeMode');
-
- const backupId = recorder.createBackup('legacy-backup');
- assert.strictEqual(backupId, 'backup-created', 'createBackup 应委托 DataBackupManager');
- const restored = recorder.restoreBackup('backup-created');
- assert.deepStrictEqual(restored, { restored: 'backup-created' }, 'restoreBackup 应委托 DataBackupManager');
- assert(calls.some((call) => call.method === 'createBackup' && call.type === 'practice_recorder'), 'createBackup 应标记 practice_recorder 来源');
- recordResult('公开数据方法走统一适配器', { calls: calls.map((call) => call.method) });
-}
-
-async function main() {
- const quietConsole = {
- log() {},
- warn() {},
- error() {},
- info() {},
- debug() {}
- };
- const windowStub = {
- console: quietConsole,
- dataRepositories: {}
- };
- const sandbox = {
- window: windowStub,
- console: quietConsole,
- setTimeout,
- clearTimeout,
- setInterval,
- clearInterval,
- Date,
- Math,
- JSON,
- ScoreStorage: function ScoreStorage() {}
- };
- sandbox.globalThis = sandbox.window;
- const context = vm.createContext(sandbox);
- loadScript('js/core/practiceCore.js', context);
- loadScript('js/core/practiceRecorder.js', context);
- const PracticeRecorder = sandbox.window.PracticeRecorder;
+ const sessionA = {
+ id: 'active-session:session-a',
+ sessionId: 'session-a',
+ examId: 'reading-p1',
+ status: 'active',
+ startTime: '2026-08-09T00:00:00.000Z',
+ lastActivity: '2026-08-09T00:01:00.000Z',
+ progress: { totalQuestions: 1 },
+ answers: { q1: 'A' },
+ metadata: { examTitle: 'Passage 1', type: 'reading' }
+ };
+ recorder.activeSessions = new Map([['reading-p1', sessionA]]);
+ recorder.sessionListeners = new Map();
+ recorder.practiceTypeCache = new Map();
+ recorder.dispatchSessionEvent = () => {};
+
+ const completion = recorder.handleSessionCompleted({
+ examId: 'reading-p1',
+ sessionId: 'session-a',
+ results: {
+ duration: 60,
+ endTime: '2026-08-09T00:01:00.000Z',
+ answers: { q1: 'A' },
+ correctAnswerMap: { q1: 'A' },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1 }
+ }
+ });
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(
+ () => reject(new Error('completion did not reach the gated library-index await')),
+ 1000
+ );
+ examIndexEntered.then(() => {
+ clearTimeout(timeout);
+ resolve();
+ }, reject);
+ });
+ Object.assign(sessionA, {
+ id: 'active-session:session-b',
+ sessionId: 'session-b',
+ startTime: '2026-08-09T00:02:00.000Z',
+ lastActivity: '2026-08-09T00:03:00.000Z',
+ answers: { q1: 'B' },
+ metadata: { examTitle: 'Passage 1 replacement', type: 'reading' }
+ });
+ releaseExamIndex();
+
+ const saved = await completion;
+ assert.strictEqual(saved.sessionId, 'session-a', 'completion must save the session captured before the await');
+ assert.strictEqual(state.commands.length, 1);
+ assert.strictEqual(state.commands[0].record.sessionId, 'session-a');
+ assert.strictEqual(recorder.activeSessions.get('reading-p1'), sessionA, 'completion must retain the in-place replacement session');
+ assert.strictEqual(sessionA.sessionId, 'session-b', 'completion payload must not rewrite the replacement identity');
+ assert.deepStrictEqual(state.discardedActiveSessions, [], 'completion must not discard either entity after losing exact map ownership');
+ });
+
+ await record('explicit ordinary ownership beats a same-exam active suite', async () => {
+ const completeWithOwnership = async (ownership = {}) => {
+ const { recorder, state, windowStub } = createHarness();
+ const sessionId = `ordinary-${Object.keys(ownership).join('-') || 'legacy'}`;
+ recorder.activeSessions = new Map([['reading-p1', {
+ id: `active-session:${sessionId}`,
+ sessionId,
+ examId: 'reading-p1',
+ status: 'active',
+ startTime: '2026-08-09T01:00:00.000Z',
+ lastActivity: '2026-08-09T01:01:00.000Z',
+ progress: { totalQuestions: 1 },
+ answers: { q1: 'A' },
+ metadata: { examTitle: 'Passage 1', type: 'reading' }
+ }]]);
+ recorder.sessionListeners = new Map();
+ recorder.practiceTypeCache = new Map();
+ recorder.dispatchSessionEvent = () => {};
+ windowStub.app = {
+ suiteExamMap: new Map([['reading-p1', 'active-suite']]),
+ currentSuiteSession: {
+ id: 'active-suite',
+ status: 'active',
+ sequence: [{ examId: 'reading-p1' }]
+ }
+ };
+ let fallbackCalls = 0;
+ const resolveSuiteSessionFromApp = recorder.resolveSuiteSessionFromApp.bind(recorder);
+ recorder.resolveSuiteSessionFromApp = (...args) => {
+ fallbackCalls += 1;
+ return resolveSuiteSessionFromApp(...args);
+ };
+
+ const payload = Object.assign({
+ examId: 'reading-p1',
+ sessionId,
+ results: {
+ duration: 60,
+ endTime: '2026-08-09T01:01:00.000Z',
+ answers: { q1: 'A' },
+ correctAnswerMap: { q1: 'A' },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1 }
+ }
+ }, ownership);
+ const saved = await recorder.handleSessionCompleted(payload);
+ return { fallbackCalls, saved, state };
+ };
- try {
- await testCanonicalCorrectAnswerMapWins(PracticeRecorder);
- await testCompletionPayloadCanonicalCorrectAnswerMapWins(PracticeRecorder);
- await testPrimarySaveUsesPracticeRecordApi(PracticeRecorder, windowStub);
- await testRetrySaveUsesPracticeRecordApi(PracticeRecorder, windowStub);
- await testLegacyDataMethodsUseUnifiedAdapters(PracticeRecorder, windowStub);
- console.log(JSON.stringify({
- status: 'pass',
- detail: `${results.length}/${results.length} 测试通过`,
- passed: results.length,
- total: results.length
- }, null, 2));
+ const managedOrdinary = await completeWithOwnership({ suiteSessionId: null });
+ assert.strictEqual(managedOrdinary.fallbackCalls, 0, 'managed ordinary ownership must not consult global suite state');
+ assert.strictEqual(managedOrdinary.state.commands.length, 1, 'managed ordinary completion must save a single record');
+ assert.strictEqual(managedOrdinary.saved.suiteSessionId, null);
+
+ const explicitSingle = await completeWithOwnership({ practiceMode: 'single' });
+ assert.strictEqual(explicitSingle.fallbackCalls, 0, 'explicit single mode must not consult global suite state');
+ assert.strictEqual(explicitSingle.state.commands.length, 1, 'explicit single completion must save a single record');
+ assert.strictEqual(explicitSingle.saved.suiteSessionId, null);
+
+ const legacy = await completeWithOwnership();
+ assert.strictEqual(legacy.fallbackCalls, 1, 'ownership-free legacy payloads must retain global suite inference');
+ assert.strictEqual(legacy.state.commands.length, 0, 'legacy inferred suite entries must still skip standalone persistence');
+ assert.strictEqual(legacy.saved.suiteSessionId, 'active-suite');
+ });
+
+ await record('temporary recovery draft does not overwrite reading drafts', async () => {
+ const { recorder, state } = createHarness();
+ await recorder.saveToTemporaryStorage(makeRecord('record-recovery'));
+ assert(state.drafts.some((draft) => draft.id === 'reading-draft:reading-p2'));
+ assert(state.drafts.some((draft) => draft.id === 'practice-record:record-recovery' && draft.kind === 'practice_record_recovery'));
+
+ const recovered = [];
+ recorder.savePracticeRecord = async (value) => {
+ recovered.push(clone(value));
+ return value;
+ };
+ await recorder.recoverTemporaryRecords();
+ assert.strictEqual(recovered.length, 1);
+ assert.strictEqual(recovered[0].id, 'record-recovery');
+ assert.deepStrictEqual(state.drafts.map((draft) => draft.id), ['reading-draft:reading-p2']);
+ });
+
+ await record('JSON export is a catalog-governed v2 practice snapshot', async () => {
+ const { recorder, state } = createHarness();
+ state.records.push(makeRecord('record-export'));
+ const exported = JSON.parse(await recorder.exportData('json'));
+ assert.strictEqual(exported.format, 'ielts-atlas-data-v2');
+ assert.strictEqual(exported.schemaVersion, 2);
+ assert(Array.isArray(exported.entities.practiceSummaries));
+ assert.strictEqual(Object.prototype.hasOwnProperty.call(exported, 'practiceRecords'), false);
+ assert.strictEqual(Object.prototype.hasOwnProperty.call(exported, 'userStats'), false);
+ assert.deepStrictEqual(state.backupCalls[0], { method: 'export', options: { domains: ['practice'] } });
+ });
+
+ await record('import preview and commit stay inside AppData.backups', async () => {
+ const { recorder, state } = createHarness();
+ const result = await recorder.importData({ practice_records: [makeRecord('legacy-import')] }, {
+ merge: false,
+ operationId: 'import-practice-v2'
+ });
+ assert.strictEqual(result.committed, true);
+ assert.strictEqual(result.backupId, 'backup-before-import');
+ const previewCall = state.backupCalls.find((call) => call.method === 'previewImport');
+ assert(previewCall);
+ assert.strictEqual(previewCall.options.practiceMode, 'replace');
+ assert.strictEqual(previewCall.payload.practice_records[0].id, 'legacy-import');
+ const commitCall = state.backupCalls.find((call) => call.method === 'commitImport' && call.id === 'import-plan-1');
+ assert(commitCall);
+ assert.strictEqual(commitCall.options.confirmDestructive, true);
+ assert(state.backupCalls.some((call) => call.method === 'recordImport'));
+ });
+
+ await record('backup create and restore delegate to the backups domain', async () => {
+ const { recorder, state } = createHarness();
+ const backup = await recorder.createBackup('practice-backup');
+ const restored = await recorder.restoreBackup(backup.id);
+ assert.strictEqual(restored.committed, true);
+ assert(state.backupCalls.some((call) => call.method === 'create' && call.options.type === 'practice-recorder'));
+ assert(state.backupCalls.some((call) => call.method === 'restore' && call.id === 'practice-backup'));
+ });
+
+ console.log(JSON.stringify({ status: 'pass', detail: `${results.length}/${results.length} tests passed`, results }, null, 2));
} catch (error) {
- console.log(JSON.stringify({
- status: 'fail',
- detail: error.message,
- results
- }, null, 2));
+ results.push({ name: 'test execution', status: 'fail', error: error.stack || error.message });
+ console.log(JSON.stringify({ status: 'fail', results }, null, 2));
process.exit(1);
}
}
diff --git a/developer/tests/js/practiceTimerPreferences.test.js b/developer/tests/js/practiceTimerPreferences.test.js
index a2cde853..49e45206 100644
--- a/developer/tests/js/practiceTimerPreferences.test.js
+++ b/developer/tests/js/practiceTimerPreferences.test.js
@@ -11,14 +11,22 @@ const repoRoot = path.resolve(__dirname, '..', '..', '..');
const source = fs.readFileSync(path.join(repoRoot, 'js/utils/practiceTimerPreferences.js'), 'utf8');
function loadPreferences() {
- const store = new Map();
+ const persisted = { reading: null, listening: null };
+ const calls = [];
+ let writeFailure = null;
const window = {
- localStorage: {
- getItem(key) {
- return store.has(key) ? store.get(key) : null;
- },
- setItem(key, value) {
- store.set(key, String(value));
+ AppData: {
+ ready: Promise.resolve(true),
+ preferences: {
+ async getTimer() {
+ return JSON.parse(JSON.stringify(persisted));
+ },
+ async setTimer(scope, value) {
+ if (writeFailure) throw writeFailure;
+ const normalized = JSON.parse(JSON.stringify(value));
+ calls.push({ scope, value: normalized });
+ persisted[scope] = normalized;
+ }
}
}
};
@@ -30,18 +38,54 @@ function loadPreferences() {
Math,
JSON,
String,
- Boolean
+ Boolean,
+ console
};
vm.runInNewContext(source, context, { filename: 'practiceTimerPreferences.js' });
- return { manager: window.PracticeTimerPreferences, store };
+ return {
+ manager: window.PracticeTimerPreferences,
+ calls,
+ persisted,
+ failWrites(error) { writeFailure = error; }
+ };
}
-const { manager, store } = loadPreferences();
+async function testHydrationRetriesAfterAppDataInstall() {
+ const window = {};
+ const context = {
+ window,
+ globalThis: window,
+ Object,
+ Number,
+ Math,
+ JSON,
+ String,
+ Boolean,
+ console
+ };
+ vm.runInNewContext(source, context, { filename: 'practiceTimerPreferences-before-appdata.js' });
+ assert.equal(await window.PracticeTimerPreferences.ready, false);
+ window.AppData = {
+ ready: Promise.resolve(true),
+ preferences: {
+ async getTimer() {
+ return { reading: { mode: 'countdown', countdownMinutes: 12 }, listening: { expiryAction: 'lock' } };
+ }
+ }
+ };
+ assert.equal(await window.PracticeTimerPreferences.ready, true);
+ assert.equal(window.PracticeTimerPreferences.read('reading').countdownMinutes, 12);
+ assert.equal(window.PracticeTimerPreferences.read('listening').expiryAction, 'lock');
+}
+
+await testHydrationRetriesAfterAppDataInstall();
-assert.equal(manager.READING_KEY, 'ielts_reading_timer_preferences_v2');
-assert.equal(manager.LISTENING_KEY, 'ielts_listening_timer_preferences_v1');
+const { manager, calls, persisted, failWrites } = loadPreferences();
+const plain = (value) => JSON.parse(JSON.stringify(value));
-assert.deepEqual(manager.read('reading'), {
+assert.equal(await manager.ready, true);
+
+assert.deepEqual(plain(manager.read('reading')), {
version: 1,
mode: 'elapsed',
countdownMinutes: 60,
@@ -50,7 +94,7 @@ assert.deepEqual(manager.read('reading'), {
expiryAction: 'warn'
});
-const reading = manager.save('reading', {
+const reading = await manager.save('reading', {
mode: 'countdown',
countdownMinutes: 999,
limitEnabled: true,
@@ -63,7 +107,7 @@ assert.equal(reading.limitEnabled, true);
assert.equal(reading.limitMinutes, 1);
assert.equal(reading.expiryAction, 'auto-submit');
-const listening = manager.save('listening', {
+const listening = await manager.save('listening', {
mode: 'invalid',
countdownMinutes: '30',
limitEnabled: false,
@@ -76,13 +120,20 @@ assert.equal(listening.limitEnabled, false);
assert.equal(listening.limitMinutes, 60);
assert.equal(listening.expiryAction, 'lock');
-assert.notEqual(manager.keyFor('reading'), manager.keyFor('listening'));
-assert(store.has(manager.READING_KEY), 'reading preferences should be persisted');
-assert(store.has(manager.LISTENING_KEY), 'listening preferences should be persisted');
+assert.deepEqual(calls.map((entry) => entry.scope), ['reading', 'listening']);
+assert.deepEqual(persisted.reading, plain(reading));
+assert.deepEqual(persisted.listening, plain(listening));
assert.equal(manager.read('reading').expiryAction, 'auto-submit');
assert.equal(manager.read('listening').expiryAction, 'lock');
+failWrites(new Error('timer persistence unavailable'));
+await assert.rejects(
+ manager.save('reading', { mode: 'elapsed', expiryAction: 'warn' }),
+ /timer persistence unavailable/
+);
+assert.equal(manager.read('reading').expiryAction, 'auto-submit', 'failed writes must not change the cached preference');
+
process.stdout.write(JSON.stringify({
status: 'pass',
- detail: 'practice timer preferences sanitize and persist independently'
+ detail: 'practice timer preferences sanitize and persist through AppData independently'
}));
diff --git a/developer/tests/js/readingAnnotationHostProtocol.test.js b/developer/tests/js/readingAnnotationHostProtocol.test.js
new file mode 100644
index 00000000..22124e03
--- /dev/null
+++ b/developer/tests/js/readingAnnotationHostProtocol.test.js
@@ -0,0 +1,813 @@
+#!/usr/bin/env node
+import assert from 'assert';
+import fs from 'fs';
+import path from 'path';
+import vm from 'vm';
+import { fileURLToPath } from 'url';
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
+
+function loadScript(relativePath, context) {
+ vm.runInContext(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'), context, { filename: relativePath });
+}
+
+function clone(value) {
+ return value == null ? value : JSON.parse(JSON.stringify(value));
+}
+
+function createHarness(initialRecord, options = {}) {
+ const records = new Map([[String(initialRecord.id), clone(initialRecord)]]);
+ const saveCalls = [];
+ const conflicts = [];
+ let revision = 1;
+ const listeners = new Map();
+ let drafts = [];
+ const documentStub = {
+ addEventListener() {},
+ removeEventListener() {},
+ querySelector() { return null; },
+ querySelectorAll() { return []; }
+ };
+ const windowStub = {
+ document: documentStub,
+ location: { origin: 'http://localhost', href: 'http://localhost/' },
+ resolveActiveLibraryIndex: async () => [],
+ addEventListener(type, handler) { listeners.set(type, handler); },
+ removeEventListener(type, handler) {
+ if (listeners.get(type) === handler) listeners.delete(type);
+ },
+ AppData: {
+ ready: Promise.resolve(),
+ practice: {
+ async get(id) { return clone(records.get(String(id)) || null); },
+ async list() { return clone(Array.from(records.values())); },
+ async updateAnnotations(command) {
+ const expectedRevision = revision;
+ const current = clone(records.get(String(command.recordId)) || null);
+ if (!current) throw new Error(`Unknown record: ${command.recordId}`);
+ const patch = clone(command.patch);
+ current.annotations = { ...(current.annotations || {}), [command.examId]: patch };
+ if (Array.isArray(current.suiteEntries) && current.suiteEntries.length) {
+ current.suiteEntries = current.suiteEntries.map((entry) => (
+ String(entry.examId) === String(command.examId)
+ ? { ...entry, ...patch, realData: { ...(entry.realData || {}), ...patch } }
+ : entry
+ ));
+ } else {
+ Object.assign(current, patch);
+ current.realData = { ...(current.realData || {}), ...patch };
+ }
+ saveCalls.push({ command: clone(command), record: clone(current) });
+ if (typeof options?.beforeSave === 'function') {
+ await options.beforeSave(current, saveCalls.length);
+ }
+ if (revision !== expectedRevision) {
+ const error = new Error('annotation revision conflict');
+ error.code = 'CONFLICT';
+ conflicts.push(clone(command));
+ throw error;
+ }
+ records.set(String(current.id), clone(current));
+ revision += 1;
+ return { committed: true };
+ }
+ },
+ recovery: {
+ async listActiveSessions() { return []; },
+ async discardActiveSession() { return { committed: true }; },
+ async listDrafts() { return clone(drafts); },
+ async saveDraft(value, saveOptions = {}) {
+ if (typeof options.beforeDraftCommit === 'function') {
+ await options.beforeDraftCommit(value, saveOptions);
+ }
+ if (typeof saveOptions.commitGuard === 'function') {
+ let allowed = false;
+ try { allowed = saveOptions.commitGuard() === true; } catch (_) {}
+ if (!allowed) {
+ return { committed: false, stale: true, code: 'STALE_RECOVERY_WRITE' };
+ }
+ }
+ const item = { ...clone(value), updatedAt: new Date().toISOString() };
+ const index = drafts.findIndex((draft) => draft.id === item.id);
+ if (index >= 0) drafts[index] = item;
+ else drafts.push(item);
+ return { committed: true, item: clone(item) };
+ },
+ async discardDraft(id) {
+ drafts = drafts.filter((draft) => draft.id !== id);
+ return { committed: true };
+ }
+ }
+ }
+ };
+ const sandbox = {
+ window: windowStub,
+ document: documentStub,
+ console,
+ setTimeout,
+ clearTimeout,
+ setInterval,
+ clearInterval,
+ Date,
+ Math,
+ JSON,
+ Map,
+ Set,
+ URL,
+ URLSearchParams
+ };
+ sandbox.globalThis = windowStub;
+ const context = vm.createContext(sandbox);
+ loadScript('js/app/examSessionMixin.js', context);
+ loadScript('js/app/suitePracticeMixin.js', context);
+ const app = { components: {}, setState() {}, getState() { return null; } };
+ Object.assign(app, windowStub.ExamSystemAppMixins.examSession, windowStub.ExamSystemAppMixins.suitePractice);
+ return { app, windowStub, records, saveCalls, conflicts, listeners, getDrafts: () => clone(drafts) };
+}
+
+function bindReviewProtocol(harness, record, examId) {
+ const examWindow = {
+ name: `review-${examId}`,
+ closed: false,
+ location: { href: `http://localhost/${examId}.html` },
+ _messages: [],
+ postMessage(message) { this._messages.push(clone(message)); }
+ };
+ const review = harness.app._buildReviewSession(record);
+ assert(review, 'review session should be created');
+ harness.app._ensureReviewReplayStore().set(review.sessionId, review);
+ const info = {
+ window: examWindow,
+ expectedSessionId: `session-${examId}`,
+ windowSessionToken: `token-${examId}`,
+ windowSessionTokenSessionId: `session-${examId}`,
+ expectedUrl: `http://localhost/${examId}.html`,
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false,
+ reviewMode: true,
+ reviewSessionId: review.sessionId,
+ reviewEntryIndex: review.entries.findIndex(entry => entry.examId === examId),
+ readOnly: true
+ };
+ harness.app.examWindows = new Map([[examId, info]]);
+ harness.app.setupExamWindowCommunication(examWindow, examId);
+ const handler = harness.app.messageHandlers.get(examId);
+ assert.strictEqual(typeof handler, 'function', 'message handler should be registered');
+ return { examWindow, review, info, handler };
+}
+
+async function send(handler, examWindow, data, type = 'READING_ANNOTATION_SYNC', overrides = {}) {
+ await handler({
+ origin: overrides.origin || 'http://localhost',
+ source: overrides.source || examWindow,
+ data: { type, source: overrides.envelopeSource || 'practice_page', data }
+ });
+}
+
+function bindLiveProtocol(harness, examId, libraryConfigurationId = null) {
+ const examWindow = {
+ name: `practice-${examId}`,
+ closed: false,
+ location: { href: `http://localhost/${examId}.html` },
+ postMessage() {}
+ };
+ const info = {
+ window: examWindow,
+ expectedSessionId: `session-${examId}`,
+ windowSessionToken: `token-${examId}`,
+ windowSessionTokenSessionId: `session-${examId}`,
+ expectedUrl: `http://localhost/${examId}.html`,
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false,
+ reviewMode: false,
+ practiceMode: 'single',
+ readOnly: false,
+ ...(libraryConfigurationId ? { libraryConfigurationId } : {})
+ };
+ harness.app.examWindows = new Map([[examId, info]]);
+ harness.app.setupExamWindowCommunication(examWindow, examId);
+ const handler = harness.app.messageHandlers.get(examId);
+ assert.strictEqual(typeof handler, 'function', 'live message handler should be registered');
+ return { examWindow, info, handler };
+}
+
+// 单篇阅读 final-submit 落库后,结果页的 windowInfo 不在 review 回放态,
+// 而是持有宿主回传的 submittedRecordId。该 helper 模拟该场景。
+function bindSubmittedProtocol(harness, examId, recordId) {
+ const examWindow = {
+ name: `submitted-${examId}`,
+ closed: false,
+ location: { href: `http://localhost/${examId}.html` },
+ _messages: [],
+ postMessage(message) { this._messages.push(clone(message)); }
+ };
+ const info = {
+ window: examWindow,
+ expectedSessionId: `session-${examId}`,
+ windowSessionToken: `token-${examId}`,
+ windowSessionTokenSessionId: `session-${examId}`,
+ expectedUrl: `http://localhost/${examId}.html`,
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false,
+ reviewMode: false,
+ reviewSessionId: null,
+ practiceMode: 'single',
+ readOnly: false,
+ submittedRecordId: String(recordId)
+ };
+ harness.app.examWindows = new Map([[examId, info]]);
+ harness.app.setupExamWindowCommunication(examWindow, examId);
+ const handler = harness.app.messageHandlers.get(examId);
+ assert.strictEqual(typeof handler, 'function', 'submitted message handler should be registered');
+ return { examWindow, info, handler };
+}
+
+async function testSingleRecordTokenGateAndMerge() {
+ const original = {
+ id: 'record-single',
+ examId: 'reading-p1',
+ status: 'completed',
+ scoreInfo: { correct: 8, total: 10, percentage: 80 },
+ duration: 612,
+ date: '2026-07-01T00:00:00.000Z',
+ realData: { source: 'unified-reading', noteText: 'old' }
+ };
+ const harness = createHarness(original);
+ const { examWindow, review, info, handler } = bindReviewProtocol(harness, original, 'reading-p1');
+ const base = {
+ examId: 'reading-p1',
+ recordId: original.id,
+ reviewSessionId: review.sessionId,
+ sessionId: info.expectedSessionId,
+ annotations: {
+ noteText: 'updated',
+ notes: [{ id: 'n1', body: 'body', outlineId: 'o1' }],
+ noteOutlines: [{ id: 'o1', title: 'Outline' }],
+ highlights: [{ id: 'h1', text: 'quote', noteId: 'n1' }],
+ markedQuestions: ['q2'],
+ scrollY: 245
+ }
+ };
+
+ await send(handler, examWindow, { ...base, windowSessionToken: 'forged-token' });
+ assert.strictEqual(harness.saveCalls.length, 0, 'forged window token must be rejected');
+ await send(handler, examWindow, { ...base, sessionId: 'stale-session', windowSessionToken: info.windowSessionToken });
+ assert.strictEqual(harness.saveCalls.length, 0, 'stale session id must be rejected');
+ await send(handler, examWindow, { ...base, reviewSessionId: 'review-forged', windowSessionToken: info.windowSessionToken });
+ assert.strictEqual(harness.saveCalls.length, 0, 'foreign review session must be rejected');
+ const { recordId: _omittedRecordId, ...withoutRecordId } = base;
+ await send(handler, examWindow, { ...withoutRecordId, windowSessionToken: info.windowSessionToken });
+ assert.strictEqual(harness.saveCalls.length, 0, 'missing record binding must be rejected');
+ await send(
+ handler,
+ examWindow,
+ { ...base, windowSessionToken: info.windowSessionToken },
+ 'READING_ANNOTATION_SYNC',
+ { origin: 'https://attacker.invalid' }
+ );
+ assert.strictEqual(harness.saveCalls.length, 0, 'wrong origin must be rejected');
+ await send(
+ handler,
+ examWindow,
+ { ...base, windowSessionToken: info.windowSessionToken },
+ 'READING_ANNOTATION_SYNC',
+ { source: { name: examWindow.name, location: examWindow.location } }
+ );
+ assert.strictEqual(harness.saveCalls.length, 0, 'lookalike WindowProxy must be rejected');
+ await send(
+ handler,
+ examWindow,
+ { ...base, windowSessionToken: info.windowSessionToken },
+ 'READING_ANNOTATION_SYNC',
+ { envelopeSource: 'exam_host' }
+ );
+ assert.strictEqual(harness.saveCalls.length, 0, 'wrong source tag must be rejected');
+
+ await send(handler, examWindow, { ...base, windowSessionToken: info.windowSessionToken });
+ assert.strictEqual(harness.saveCalls.length, 1, 'valid annotation sync should save once');
+ const call = harness.saveCalls[0];
+ assert.strictEqual(Object.prototype.hasOwnProperty.call(call.command, 'updateStats'), false, 'annotation command must not expose a stats toggle');
+ assert.strictEqual(call.command.recordId, original.id);
+ assert.strictEqual(call.command.examId, 'reading-p1');
+ assert.deepStrictEqual(call.record.scoreInfo, original.scoreInfo, 'score must be preserved');
+ assert.strictEqual(call.record.duration, original.duration, 'duration must be preserved');
+ assert.strictEqual(call.record.status, original.status, 'completion status must be preserved');
+ assert.strictEqual(call.record.date, original.date, 'completion date must be preserved');
+ assert.strictEqual(call.record.highlights[0].noteId, 'n1', 'highlight noteId link must survive host merge');
+ assert.strictEqual(call.record.realData.notes[0].id, 'n1', 'canonical replay data should receive notes');
+}
+
+async function testSuiteEntryScopedMerge() {
+ const original = {
+ id: 'record-suite',
+ examId: 'suite-record-suite',
+ status: 'completed',
+ scoreInfo: { correct: 20, total: 30 },
+ duration: 3200,
+ suiteEntries: [
+ { examId: 'reading-p1', scoreInfo: { correct: 7, total: 10 }, notes: [{ id: 'old-p1' }] },
+ { examId: 'reading-p2', scoreInfo: { correct: 6, total: 10 }, notes: [{ id: 'keep-p2' }] }
+ ]
+ };
+ const harness = createHarness(original);
+ const { examWindow, review, info, handler } = bindReviewProtocol(harness, original, 'reading-p1');
+ await send(handler, examWindow, {
+ examId: 'reading-p1',
+ recordId: original.id,
+ reviewSessionId: review.sessionId,
+ sessionId: info.expectedSessionId,
+ windowSessionToken: info.windowSessionToken,
+ notes: [{ id: 'new-p1' }],
+ noteOutlines: []
+ });
+ assert.strictEqual(harness.saveCalls.length, 1, 'suite annotation should save aggregate record once');
+ const saved = harness.saveCalls[0].record;
+ assert.strictEqual(saved.suiteEntries[0].notes[0].id, 'new-p1', 'matching suite entry should be updated');
+ assert.strictEqual(saved.suiteEntries[1].notes[0].id, 'keep-p2', 'other suite entry must remain unchanged');
+ assert.deepStrictEqual(saved.scoreInfo, original.scoreInfo, 'aggregate score must remain unchanged');
+}
+
+async function testAnnotationWritesAreSerialized() {
+ const original = {
+ id: 'record-race',
+ examId: 'reading-p1',
+ scoreInfo: { correct: 8, total: 10 },
+ notes: [{ id: 'old' }]
+ };
+ let releaseFirstSave;
+ let markFirstSaveStarted;
+ const firstSaveStarted = new Promise((resolve) => { markFirstSaveStarted = resolve; });
+ const harness = createHarness(original, {
+ async beforeSave(_record, saveNumber) {
+ if (saveNumber !== 1) return;
+ markFirstSaveStarted();
+ await new Promise((resolve) => { releaseFirstSave = resolve; });
+ }
+ });
+ const { examWindow, review, info, handler } = bindReviewProtocol(harness, original, 'reading-p1');
+ const base = {
+ examId: 'reading-p1',
+ recordId: original.id,
+ reviewSessionId: review.sessionId,
+ sessionId: info.expectedSessionId,
+ windowSessionToken: info.windowSessionToken
+ };
+
+ const first = send(handler, examWindow, { ...base, notes: [{ id: 'first' }] });
+ await firstSaveStarted;
+ const second = send(handler, examWindow, { ...base, notes: [{ id: 'second' }] });
+ releaseFirstSave();
+ const settled = await Promise.allSettled([first, second]);
+
+ assert.strictEqual(harness.saveCalls.length, 2, 'both valid annotation snapshots should reach the domain boundary');
+ assert.strictEqual(harness.conflicts.length, 1, 'a stale concurrent annotation must produce a deterministic conflict');
+ assert.strictEqual(settled.filter((result) => result.status === 'rejected').length, 1);
+ assert.strictEqual(
+ harness.records.get(original.id).notes[0].id,
+ 'second',
+ 'later annotation snapshot must win even when the prior write is slow'
+ );
+}
+
+function testSuiteDraftCarriesStructuredNotes() {
+ const harness = createHarness({ id: 'unused', examId: 'unused' });
+ const draft = harness.app._buildSuiteDraftSnapshot({
+ draft: {
+ answers: { q1: 'A' },
+ notes: [{ id: 'n1', body: 'draft' }],
+ noteOutlines: [{ id: 'o1', title: 'Draft outline' }],
+ highlights: [{ id: 'h1', noteId: 'n1' }]
+ },
+ draftUpdatedAt: 1234
+ });
+ assert.strictEqual(draft.notes[0].id, 'n1', 'suite draft should retain structured notes');
+ assert.strictEqual(draft.noteOutlines[0].id, 'o1', 'suite draft should retain outlines');
+ assert.strictEqual(draft.highlights[0].noteId, 'n1', 'suite draft should retain highlight noteId');
+ assert.strictEqual(draft.updatedAt, 1234, 'suite draft ordering timestamp should be retained');
+}
+
+async function testLiveDraftUsesIsolatedTokenGatedStore() {
+ const harness = createHarness({ id: 'unused-live', examId: 'reading-live' });
+ const { examWindow, info, handler } = bindLiveProtocol(harness, 'reading-live');
+ const base = {
+ examId: 'reading-live',
+ sessionId: info.expectedSessionId,
+ windowSessionToken: info.windowSessionToken,
+ draftUpdatedAt: 2000,
+ draft: {
+ answers: { q1: 'A' },
+ highlights: [{ id: 'h-live', noteId: 'n-live' }],
+ notes: [{ id: 'n-live', body: 'draft note' }],
+ noteOutlines: [{ id: 'o-live', title: 'Draft' }],
+ markedQuestions: ['q1'],
+ noteText: 'draft note',
+ scrollY: 88,
+ updatedAt: 2000
+ }
+ };
+
+ await send(handler, examWindow, { ...base, windowSessionToken: 'forged' }, 'READING_DRAFT_SYNC');
+ assert.strictEqual(harness.getDrafts().length, 0, 'forged draft token must not write recovery data');
+
+ await send(handler, examWindow, base, 'READING_DRAFT_SYNC');
+ assert.strictEqual(harness.saveCalls.length, 0, 'in-progress drafts must not enter practice records');
+ const stored = harness.getDrafts().find((draft) => draft.id === 'reading-draft:reading-live');
+ assert.strictEqual(stored.notes[0].id, 'n-live', 'structured notes should persist in recovery drafts');
+ assert.strictEqual(stored.highlights[0].noteId, 'n-live', 'draft highlight noteId should persist');
+
+ await send(handler, examWindow, {
+ ...base,
+ draftUpdatedAt: 1000,
+ draft: { ...base.draft, notes: [{ id: 'stale' }], updatedAt: 1000 }
+ }, 'READING_DRAFT_SYNC');
+ assert.strictEqual(
+ harness.getDrafts().find((draft) => draft.id === 'reading-draft:reading-live').notes[0].id,
+ 'n-live',
+ 'older draft snapshots must not replace newer state'
+ );
+ assert.strictEqual(
+ await harness.app.clearReadingDraftForExam('reading-live', { sessionId: info.expectedSessionId }),
+ true,
+ 'matching submitted draft should be discarded by entity id'
+ );
+ assert.strictEqual(harness.getDrafts().length, 0);
+
+ const isolatedHarness = createHarness({ id: 'unused-library-live', examId: 'reading-library-live' });
+ const isolated = bindLiveProtocol(isolatedHarness, 'reading-library-live', 'library-a');
+ const isolatedBase = {
+ examId: 'reading-library-live',
+ sessionId: isolated.info.expectedSessionId,
+ windowSessionToken: isolated.info.windowSessionToken,
+ draftUpdatedAt: 3000,
+ draft: { notes: [{ id: 'note-a' }], updatedAt: 3000 }
+ };
+ await send(isolated.handler, isolated.examWindow, isolatedBase, 'READING_DRAFT_SYNC');
+ isolated.info.libraryConfigurationId = 'library-b';
+ await send(isolated.handler, isolated.examWindow, {
+ ...isolatedBase,
+ draftUpdatedAt: 4000,
+ draft: { notes: [{ id: 'note-b' }], updatedAt: 4000 }
+ }, 'READING_DRAFT_SYNC');
+ assert.deepStrictEqual(
+ isolatedHarness.getDrafts().map((draft) => draft.id).sort(),
+ ['reading-draft:reading-library-live:library-a', 'reading-draft:reading-library-live:library-b']
+ );
+ assert.strictEqual(
+ (await isolatedHarness.app.getReadingDraftForExam('reading-library-live', { libraryConfigurationId: 'library-a' })).notes[0].id,
+ 'note-a'
+ );
+ assert.strictEqual(
+ (await isolatedHarness.app.getReadingDraftForExam('reading-library-live', { libraryConfigurationId: 'library-b' })).notes[0].id,
+ 'note-b'
+ );
+ await isolatedHarness.app.clearReadingDraftForExam('reading-library-live', { libraryConfigurationId: 'library-a' });
+ assert.deepStrictEqual(isolatedHarness.getDrafts().map((draft) => draft.id), ['reading-draft:reading-library-live:library-b']);
+}
+
+async function testInFlightDraftCommitUsesRegistrationGuard() {
+ let markCommitEntered;
+ let releaseCommit;
+ const commitEntered = new Promise((resolve) => { markCommitEntered = resolve; });
+ const commitGate = new Promise((resolve) => { releaseCommit = resolve; });
+ const harness = createHarness({ id: 'unused-inflight', examId: 'reading-inflight' }, {
+ async beforeDraftCommit() {
+ markCommitEntered();
+ await commitGate;
+ }
+ });
+ const { info } = bindLiveProtocol(harness, 'reading-inflight');
+ info.sessionGeneration = 1;
+ const pending = harness.app.handleReadingDraftSync('reading-inflight', {
+ examId: 'reading-inflight',
+ sessionId: info.expectedSessionId,
+ windowSessionGeneration: info.sessionGeneration,
+ draftUpdatedAt: 5000,
+ draft: { answers: { q1: 'stale-inflight' }, updatedAt: 5000 }
+ }, info);
+
+ await commitEntered;
+ const replacementWindow = {
+ name: 'practice-reading-inflight-replacement',
+ closed: false,
+ location: { href: 'http://localhost/reading-inflight.html' },
+ postMessage() {}
+ };
+ const replacementInfo = {
+ ...info,
+ window: replacementWindow,
+ expectedSessionId: 'session-reading-inflight-replacement',
+ windowSessionToken: 'token-reading-inflight-replacement',
+ windowSessionTokenSessionId: 'session-reading-inflight-replacement',
+ sessionGeneration: 2
+ };
+ harness.app.examWindows.set('reading-inflight', replacementInfo);
+ releaseCommit();
+
+ assert.strictEqual(await pending, false, 'a replaced registration must reject its in-flight draft commit');
+ assert.deepStrictEqual(harness.getDrafts(), [], 'the rejected generation must not reach recovery storage');
+ assert.strictEqual(harness.app.examWindows.get('reading-inflight'), replacementInfo);
+}
+
+async function testInFlightDraftRejectsInPlaceRegistrationMutation() {
+ let markCommitEntered;
+ let releaseCommit;
+ const commitEntered = new Promise((resolve) => { markCommitEntered = resolve; });
+ const commitGate = new Promise((resolve) => { releaseCommit = resolve; });
+ const harness = createHarness({ id: 'unused-inplace', examId: 'reading-inplace' }, {
+ async beforeDraftCommit() {
+ markCommitEntered();
+ await commitGate;
+ }
+ });
+ const { info } = bindLiveProtocol(harness, 'reading-inplace');
+ const pending = harness.app.handleReadingDraftSync('reading-inplace', {
+ examId: 'reading-inplace',
+ sessionId: info.expectedSessionId,
+ draftUpdatedAt: 5100,
+ draft: { answers: { q1: 'stale-inplace' }, updatedAt: 5100 }
+ }, info);
+
+ await commitEntered;
+ // Reset/rebind paths can mutate the existing object rather than replacing the Map value.
+ info.expectedSessionId = 'session-reading-inplace-rebound';
+ info.windowSessionToken = 'token-reading-inplace-rebound';
+ info.windowSessionTokenSessionId = info.expectedSessionId;
+ info.sessionGeneration = 2;
+ releaseCommit();
+
+ assert.strictEqual(await pending, false, 'an in-place registration mutation must reject the old draft');
+ assert.deepStrictEqual(harness.getDrafts(), [], 'legacy payloads without generation must still be ownership-gated');
+}
+
+async function testInFlightFinalDraftSurvivesWindowClose() {
+ let markCommitEntered;
+ let releaseCommit;
+ const commitEntered = new Promise((resolve) => { markCommitEntered = resolve; });
+ const commitGate = new Promise((resolve) => { releaseCommit = resolve; });
+ const harness = createHarness({ id: 'unused-pagehide', examId: 'reading-pagehide' }, {
+ async beforeDraftCommit() {
+ markCommitEntered();
+ await commitGate;
+ }
+ });
+ const { examWindow, info } = bindLiveProtocol(harness, 'reading-pagehide');
+ info.sessionGeneration = 1;
+ const pending = harness.app._queueReadingDraftSync('reading-pagehide', {
+ examId: 'reading-pagehide',
+ sessionId: info.expectedSessionId,
+ windowSessionGeneration: info.sessionGeneration,
+ draftUpdatedAt: 5200,
+ draft: { answers: { q1: 'final-pagehide' }, updatedAt: 5200 }
+ }, info);
+
+ await commitEntered;
+ // pagehide is accepted while live; the WindowProxy may close before the IDB put.
+ examWindow.closed = true;
+ harness.app.updateExamStatus = () => {};
+ let closeSettled = false;
+ const closePromise = harness.app.handleExamWindowClosed('reading-pagehide', examWindow)
+ .then((value) => { closeSettled = true; return value; });
+ await Promise.resolve();
+ assert.strictEqual(closeSettled, false, 'close cleanup must wait for the accepted draft queue');
+ releaseCommit();
+
+ assert.strictEqual(await pending, true, 'closing an otherwise exact registration must not lose its final draft');
+ assert.strictEqual(await closePromise, true);
+ assert.strictEqual(harness.getDrafts().length, 1);
+ assert.strictEqual(harness.getDrafts()[0].answers.q1, 'final-pagehide');
+ assert.strictEqual(harness.app.examWindows.has('reading-pagehide'), false, 'cleanup may remove the old registration after commit');
+}
+
+async function testWindowCloseCleanupIsRegistrationScoped() {
+ const harness = createHarness({ id: 'unused-close-scope', examId: 'reading-close-scope' });
+
+ // A late close callback with no registration must not wait on an unrelated
+ // draft tail and later broad-delete a registration created during that wait.
+ let releaseUnrelatedQueue;
+ harness.app.examWindows = new Map();
+ harness.app._readingDraftStoreQueue = new Promise((resolve) => { releaseUnrelatedQueue = resolve; });
+ const missingClose = harness.app.handleExamWindowClosed('reading-close-missing');
+ const replacementWithoutPriorOwner = { window: { closed: false }, expectedSessionId: 'new-missing-owner' };
+ harness.app.examWindows.set('reading-close-missing', replacementWithoutPriorOwner);
+ assert.strictEqual(await missingClose, false);
+ assert.strictEqual(harness.app.examWindows.get('reading-close-missing'), replacementWithoutPriorOwner);
+ releaseUnrelatedQueue();
+
+ const { examWindow, info } = bindLiveProtocol(harness, 'reading-close-replaced');
+ info.sessionGeneration = 1;
+ let releaseAcceptedQueue;
+ harness.app._readingDraftStoreQueue = new Promise((resolve) => { releaseAcceptedQueue = resolve; });
+ const closing = harness.app.handleExamWindowClosed('reading-close-replaced', examWindow);
+ await Promise.resolve();
+
+ const replacementWindow = { closed: false };
+ const replacementInfo = {
+ window: replacementWindow,
+ expectedSessionId: 'session-reading-close-replaced-new',
+ windowSessionToken: 'token-reading-close-replaced-new',
+ windowSessionTokenSessionId: 'session-reading-close-replaced-new',
+ sessionGeneration: 2,
+ suiteSessionId: null
+ };
+ const replacementHandler = () => {};
+ harness.app.examWindows.set('reading-close-replaced', replacementInfo);
+ harness.app.messageHandlers.set('reading-close-replaced', replacementHandler);
+ releaseAcceptedQueue();
+
+ assert.strictEqual(await closing, false, 'a close callback must stop owning the map after replacement');
+ assert.strictEqual(harness.app.examWindows.get('reading-close-replaced'), replacementInfo);
+ assert.strictEqual(harness.app.messageHandlers.get('reading-close-replaced'), replacementHandler);
+}
+
+async function testCompletionPersistenceVerification() {
+ const persisted = {
+ id: 'record-persisted',
+ examId: 'reading-persisted',
+ sessionId: 'session-persisted',
+ endTime: '2026-07-22T10:00:00.000Z'
+ };
+ const harness = createHarness(persisted);
+ assert.strictEqual(
+ await harness.app._isPracticeCompletionPersisted(persisted),
+ true,
+ 'an exact canonical record round-trip should authorize draft cleanup'
+ );
+ assert.strictEqual(
+ await harness.app._isPracticeCompletionPersisted({ ...persisted, endTime: '2026-07-22T10:01:00.000Z' }),
+ false,
+ 'an older record with the same id must not authorize cleanup for a newer completion'
+ );
+ assert.strictEqual(
+ await harness.app._isPracticeCompletionPersisted({ ...persisted, id: 'record-missing' }),
+ false,
+ 'a completion absent from the canonical store must retain its resumable draft'
+ );
+ for (const field of ['examId', 'sessionId', 'endTime']) {
+ const incomplete = { ...persisted };
+ delete incomplete[field];
+ assert.strictEqual(
+ await harness.app._isPracticeCompletionPersisted(incomplete),
+ false,
+ `a completion without ${field} must not authorize cleanup`
+ );
+ }
+}
+
+async function testSubmittedReadingAnnotationSync() {
+ // 单篇阅读 final-submit 后,结果页通过宿主回传的 submittedRecordId 发送标注
+ // 同步;路由守卫与 handleReadingAnnotationSync 必须按该 id 直连已存档记录,
+ // 不再要求 review 回放态。同时校验仍拒绝伪造的 token/session/recordId。
+ const original = {
+ id: 'record-submitted',
+ examId: 'reading-submitted',
+ status: 'completed',
+ scoreInfo: { correct: 8, total: 10, percentage: 80 },
+ duration: 612,
+ date: '2026-07-01T00:00:00.000Z',
+ realData: { source: 'unified-reading', noteText: 'old' }
+ };
+ const harness = createHarness(original);
+ const { examWindow, info, handler } = bindSubmittedProtocol(harness, 'reading-submitted', original.id);
+ const base = {
+ examId: 'reading-submitted',
+ recordId: original.id,
+ reviewSessionId: null,
+ sessionId: info.expectedSessionId,
+ annotations: {
+ noteText: 'updated-after-submit',
+ notes: [{ id: 'n1', body: 'post-submit note', outlineId: 'o1' }],
+ noteOutlines: [{ id: 'o1', title: 'Outline' }],
+ highlights: [{ id: 'h1', text: 'quote', noteId: 'n1' }],
+ markedQuestions: ['q5'],
+ scrollY: 305
+ }
+ };
+
+ await send(handler, examWindow, { ...base, windowSessionToken: 'forged-token' });
+ assert.strictEqual(harness.saveCalls.length, 0, 'submitted sync: forged window token must be rejected');
+ await send(handler, examWindow, { ...base, sessionId: 'stale-session', windowSessionToken: info.windowSessionToken });
+ assert.strictEqual(harness.saveCalls.length, 0, 'submitted sync: stale session id must be rejected');
+ await send(handler, examWindow, { ...base, recordId: 'record-forged', windowSessionToken: info.windowSessionToken });
+ assert.strictEqual(harness.saveCalls.length, 0, 'submitted sync: foreign recordId must be rejected');
+
+ await send(handler, examWindow, { ...base, windowSessionToken: info.windowSessionToken });
+ assert.strictEqual(harness.saveCalls.length, 1, 'submitted sync: valid payload should save once');
+ const call = harness.saveCalls[0];
+ assert.strictEqual(Object.prototype.hasOwnProperty.call(call.command, 'updateStats'), false, 'submitted annotation command must not expose a stats toggle');
+ assert.deepStrictEqual(call.record.scoreInfo, original.scoreInfo, 'submitted sync: score must be preserved');
+ assert.strictEqual(call.record.status, original.status, 'submitted sync: completion status must be preserved');
+ assert.strictEqual(call.record.realData.noteText, 'updated-after-submit', 'submitted sync: noteText must land on realData');
+ assert.strictEqual(call.record.realData.notes[0].id, 'n1', 'submitted sync: notes must land on realData');
+ assert.strictEqual(call.record.realData.markedQuestions[0], 'q5', 'submitted sync: markedQuestions must land on realData');
+ assert.strictEqual(call.record.highlights[0].noteId, 'n1', 'submitted sync: highlight link must survive host merge');
+}
+
+async function testSubmittedRecordAnnouncementRequiresCanonicalPersistence() {
+ const candidate = {
+ id: 'record-completion',
+ examId: 'reading-completion',
+ sessionId: 'session-completion',
+ endTime: '2026-07-22T11:00:00.000Z'
+ };
+ const harness = createHarness(candidate);
+ harness.records.delete(candidate.id);
+ const announcements = [];
+ const clearedDrafts = [];
+ let cleanupCalls = 0;
+ harness.app.components.practiceRecorder = {
+ async handleSessionCompleted() {
+ return clone(candidate);
+ }
+ };
+ harness.app._normalizeListeningSpellingErrors = () => {};
+ harness.app._announceSubmittedReadingRecord = (...args) => announcements.push(args);
+ harness.app.clearReadingDraftForExam = async (...args) => clearedDrafts.push(args);
+ harness.app.updateExamStatus = () => {};
+ harness.app.showRealCompletionNotification = async () => {};
+ harness.app.cleanupExamSession = () => { cleanupCalls += 1; };
+ harness.app._isResetCapableUnifiedReadingCompletion = () => false;
+ const submitWindow = {
+ closed: false,
+ location: { href: `http://localhost/${candidate.examId}.html` },
+ _messages: [],
+ postMessage(message) { this._messages.push(clone(message)); }
+ };
+ harness.app.examWindows = new Map([[candidate.examId, {
+ window: submitWindow,
+ expectedSessionId: candidate.sessionId,
+ sessionId: candidate.sessionId,
+ windowSessionToken: 'token-completion',
+ windowSessionTokenSessionId: candidate.sessionId,
+ expectedUrl: submitWindow.location.href,
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false
+ }]]);
+ const submissionId = 'submission-completion';
+
+ await harness.app.handlePracticeComplete(candidate.examId, {
+ examId: candidate.examId,
+ sessionId: candidate.sessionId,
+ submissionId,
+ endTime: candidate.endTime
+ }, submitWindow);
+ assert.strictEqual(announcements.length, 0, 'temporary-only completion must not announce an unsaved record id');
+ assert.strictEqual(clearedDrafts.length, 0, 'temporary-only completion must retain its resumable draft');
+ assert.strictEqual(cleanupCalls, 0, 'unverified completion must retain the live session for retry');
+ assert.strictEqual(submitWindow._messages.at(-1).type, 'PRACTICE_SUBMIT_FAILED', 'failed persistence must NACK the child');
+ assert.deepStrictEqual(
+ clone({
+ submissionId: submitWindow._messages.at(-1).data.submissionId,
+ sessionId: submitWindow._messages.at(-1).data.sessionId,
+ examId: submitWindow._messages.at(-1).data.examId,
+ suiteSessionId: submitWindow._messages.at(-1).data.suiteSessionId
+ }),
+ { submissionId, sessionId: candidate.sessionId, examId: candidate.examId, suiteSessionId: null },
+ 'NACK must carry every submission correlation field'
+ );
+
+ harness.records.set(candidate.id, clone(candidate));
+ await harness.app.handlePracticeComplete(candidate.examId, {
+ examId: candidate.examId,
+ sessionId: candidate.sessionId,
+ submissionId,
+ endTime: candidate.endTime
+ }, submitWindow);
+ assert.strictEqual(announcements.length, 1, 'canonical completion should announce its saved record id once');
+ assert.strictEqual(clearedDrafts.length, 1, 'canonical completion should clear its resumable draft');
+ assert.strictEqual(cleanupCalls, 1, 'verified canonical completion may clean up its live session');
+ assert.strictEqual(submitWindow._messages.at(-1).type, 'PRACTICE_SUBMIT_ACK', 'verified persistence must ACK the child');
+ assert.deepStrictEqual(
+ clone({
+ submissionId: submitWindow._messages.at(-1).data.submissionId,
+ sessionId: submitWindow._messages.at(-1).data.sessionId,
+ examId: submitWindow._messages.at(-1).data.examId,
+ suiteSessionId: submitWindow._messages.at(-1).data.suiteSessionId
+ }),
+ { submissionId, sessionId: candidate.sessionId, examId: candidate.examId, suiteSessionId: null },
+ 'ACK must carry every submission correlation field'
+ );
+}
+
+async function main() {
+ await testSingleRecordTokenGateAndMerge();
+ await testSuiteEntryScopedMerge();
+ await testAnnotationWritesAreSerialized();
+ await testSubmittedReadingAnnotationSync();
+ await testLiveDraftUsesIsolatedTokenGatedStore();
+ await testInFlightDraftCommitUsesRegistrationGuard();
+ await testInFlightDraftRejectsInPlaceRegistrationMutation();
+ await testInFlightFinalDraftSurvivesWindowClose();
+ await testWindowCloseCleanupIsRegistrationScoped();
+ await testCompletionPersistenceVerification();
+ await testSubmittedRecordAnnouncementRequiresCanonicalPersistence();
+ testSuiteDraftCarriesStructuredNotes();
+ process.stdout.write(JSON.stringify({
+ status: 'pass',
+ detail: 'reading draft/annotation protocols reject forged tokens, isolate recovery entities, surface CAS conflicts, persist post-submit notes and preserve stats'
+ }));
+}
+
+main().catch((error) => {
+ process.stdout.write(JSON.stringify({ status: 'fail', detail: error && error.stack ? error.stack : String(error) }));
+ process.exit(1);
+});
diff --git a/developer/tests/js/resourceCore.test.js b/developer/tests/js/resourceCore.test.js
index e354ca95..db4cb3a9 100644
--- a/developer/tests/js/resourceCore.test.js
+++ b/developer/tests/js/resourceCore.test.js
@@ -22,37 +22,20 @@ function recordResult(name, passed, detail) {
}
function createResourceCoreHarness() {
- const storageState = new Map();
- const localStorageState = new Map();
-
- const localStorage = {
- getItem(key) {
- return localStorageState.has(key) ? localStorageState.get(key) : null;
- },
- setItem(key, value) {
- localStorageState.set(key, String(value));
- },
- removeItem(key) {
- localStorageState.delete(key);
- }
- };
+ const libraryIndexes = new Map();
const windowStub = {
console,
- localStorage,
- storage: {
- async get(key, fallback = null) {
- return storageState.has(key) ? storageState.get(key) : fallback;
- },
- async set(key, value) {
- storageState.set(key, JSON.parse(JSON.stringify(value)));
- return true;
- },
- async remove(key) {
- storageState.delete(key);
- return true;
+ AppData: {
+ ready: Promise.resolve(),
+ library: {
+ async getIndex(configurationId) {
+ return JSON.parse(JSON.stringify(libraryIndexes.get(String(configurationId)) || []));
+ },
+ async getActive() { return null; }
}
},
+ __libraryIndexes: libraryIndexes,
location: {
href: 'file:///Users/test/index.html'
},
@@ -62,7 +45,6 @@ function createResourceCoreHarness() {
const sandbox = {
window: windowStub,
console,
- localStorage,
location: windowStub.location,
fetch: async () => ({ ok: true, status: 200 }),
setTimeout,
@@ -242,19 +224,20 @@ function testRuntimeResourceTakesPrecedence(context, ResourceCore) {
});
}
-async function testDeletePathMapForConfiguration(context, ResourceCore) {
- const key = ResourceCore.getPathMapStorageKey('custom_config');
- await context.window.storage.set(key, {
- reading: { root: 'ReadingCustom/', exceptions: {} },
- listening: { root: 'ListeningCustom/', exceptions: {} }
- });
-
- const deleted = await ResourceCore.deletePathMapForConfiguration('custom_config');
+async function testPathMapDerivedFromLibraryIndex(context, ResourceCore) {
+ const index = [
+ { id: 'custom-reading', type: 'reading', path: 'ReadingCustom/set-a/', filename: 'index.html' },
+ { id: 'custom-listening', type: 'listening', path: 'ListeningCustom/set-b/', filename: 'index.html' }
+ ];
+ context.window.__libraryIndexes.set('custom_config', index);
- assert.strictEqual(deleted, true, '删除 path map 应返回 true');
- assert.strictEqual(await context.window.storage.get(key, null), null, '删除配置时 path map 存储键必须被移除');
+ const pathMap = await ResourceCore.loadPathMapForConfiguration('custom_config');
+ assert.strictEqual(pathMap.reading.root, 'ReadingCustom/set-a/');
+ assert.strictEqual(pathMap.listening.root, 'ListeningCustom/set-b/');
+ assert.strictEqual(await ResourceCore.deletePathMapForConfiguration('custom_config'), true, '删除派生缓存应是无状态操作');
+ assert.deepStrictEqual(await context.window.AppData.library.getIndex('custom_config'), index, '派生 path map 操作不得修改题库权威 index');
- recordResult('ResourceCore 删除指定配置 path map', true, { key });
+ recordResult('ResourceCore 从 AppData.library index 派生 path map', true, { pathMap });
}
async function main() {
@@ -268,7 +251,7 @@ async function main() {
testDefaultRootStillWorks(ResourceCore);
testExplicitEmptyRootDoesNotFallback(ResourceCore);
testRuntimeResourceTakesPrecedence(context, ResourceCore);
- await testDeletePathMapForConfiguration(context, ResourceCore);
+ await testPathMapDerivedFromLibraryIndex(context, ResourceCore);
console.log(JSON.stringify({
status: 'pass',
diff --git a/developer/tests/js/reviewHighlightDictionaryProtocol.test.js b/developer/tests/js/reviewHighlightDictionaryProtocol.test.js
new file mode 100644
index 00000000..d0d679a2
--- /dev/null
+++ b/developer/tests/js/reviewHighlightDictionaryProtocol.test.js
@@ -0,0 +1,143 @@
+import assert from 'assert';
+import fs from 'fs';
+import path from 'path';
+import vm from 'vm';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const repoRoot = path.resolve(__dirname, '../../..');
+const sourcePath = path.join(repoRoot, 'js/runtime/reviewHighlightDictionary.js');
+const source = fs.readFileSync(sourcePath, 'utf8');
+const instrumentedSource = source.replace(
+ ' const api = {',
+ ` global.__reviewHighlightDictionaryTestHooks = {
+ configure(options) {
+ currentOptions = { ...currentOptions, ...(options || {}) };
+ },
+ setActiveLookup(lookup, selectedText) {
+ activeLookup = lookup || null;
+ activeHighlight = { textContent: selectedText || '' };
+ },
+ saveActiveLookup
+ };
+
+ const api = {`
+);
+assert.notStrictEqual(instrumentedSource, source, 'test instrumentation marker must match production source');
+
+class HTMLElement {}
+class HTMLButtonElement extends HTMLElement {
+ constructor() {
+ super();
+ this.textContent = '加入生词';
+ this.disabled = false;
+ }
+}
+
+function createHarness() {
+ let uuidSequence = 0;
+ const sandbox = {
+ window: null,
+ document: {},
+ HTMLElement,
+ HTMLButtonElement,
+ Node: class Node {},
+ crypto: {
+ randomUUID() {
+ uuidSequence += 1;
+ return `request-${uuidSequence}`;
+ }
+ },
+ console,
+ setTimeout,
+ clearTimeout
+ };
+ sandbox.window = sandbox;
+ sandbox.globalThis = sandbox;
+ vm.runInContext(instrumentedSource, vm.createContext(sandbox), {
+ filename: 'js/runtime/reviewHighlightDictionary.js'
+ });
+ return sandbox;
+}
+
+async function run() {
+ const harness = createHarness();
+ const hooks = harness.__reviewHighlightDictionaryTestHooks;
+ const dictionary = harness.ReviewHighlightDictionary;
+ const posted = [];
+
+ hooks.configure({
+ postMessage(type, payload) {
+ posted.push({ type, payload });
+ return true;
+ }
+ });
+ hooks.setActiveLookup({
+ term: 'resilient',
+ zh: '有韧性的',
+ en: 'able to recover quickly',
+ source: 'local'
+ }, 'resilient');
+
+ const failedButton = new HTMLButtonElement();
+ const failedSave = hooks.saveActiveLookup(failedButton);
+ assert.strictEqual(posted.length, 1);
+ assert.strictEqual(posted[0].type, 'VOCAB_HIGHLIGHT_SAVE');
+ assert.strictEqual(posted[0].payload.requestId, 'vocab-highlight-request-1');
+ assert.strictEqual(failedButton.textContent, '加入生词', 'bare postMessage delivery must not show success');
+ assert.strictEqual(
+ dictionary.handleSaveOutcome({ requestId: 'unknown-request' }, true),
+ false,
+ 'unknown ACK must not settle another request'
+ );
+ assert.strictEqual(failedButton.textContent, '加入生词');
+ assert.strictEqual(
+ dictionary.handleSaveOutcome({ requestId: posted[0].payload.requestId }, false),
+ true,
+ 'matching FAILED must settle the pending request'
+ );
+ await failedSave;
+ assert.strictEqual(failedButton.textContent, '保存失败');
+ assert.strictEqual(failedButton.disabled, false);
+
+ const ackButton = new HTMLButtonElement();
+ const ackSave = hooks.saveActiveLookup(ackButton);
+ assert.strictEqual(posted[1].payload.requestId, 'vocab-highlight-request-2');
+ dictionary.handleSaveOutcome({ requestId: posted[1].payload.requestId }, true);
+ await ackSave;
+ assert.strictEqual(ackButton.textContent, '已加入');
+ assert.strictEqual(ackButton.disabled, true);
+
+ const directWrites = [];
+ harness.AppData = {
+ ready: Promise.resolve(),
+ vocab: {
+ async upsertCollectionWord(collectionId, word) {
+ directWrites.push({ collectionId, word });
+ }
+ }
+ };
+ hooks.configure({
+ postMessage() {
+ return false;
+ }
+ });
+ hooks.setActiveLookup({ term: 'durable', zh: '持久的' }, 'durable');
+ const directButton = new HTMLButtonElement();
+ await hooks.saveActiveLookup(directButton);
+ assert.strictEqual(directWrites.length, 1, 'unavailable host route must commit through direct AppData');
+ assert.strictEqual(directWrites[0].collectionId, 'reading-highlights');
+ assert.strictEqual(directButton.textContent, '已加入');
+ assert.strictEqual(directButton.disabled, true);
+
+ console.log(JSON.stringify({
+ status: 'pass',
+ detail: 'vocab requestId ACK/FAILED and direct-commit UI checks passed'
+ }));
+}
+
+run().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/developer/tests/js/scoreStorageCorrectAnswers.test.js b/developer/tests/js/scoreStorageCorrectAnswers.test.js
deleted file mode 100644
index 3f6d7b18..00000000
--- a/developer/tests/js/scoreStorageCorrectAnswers.test.js
+++ /dev/null
@@ -1,294 +0,0 @@
-#!/usr/bin/env node
-'use strict';
-
-import assert from 'assert';
-import fs from 'fs';
-import path from 'path';
-import vm from 'vm';
-import { fileURLToPath } from 'url';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-const repoRoot = path.resolve(__dirname, '../../..');
-const results = [];
-
-function loadScoreStorageHarness() {
- const windowStub = {
- AnswerMatchCore: {
- compareAnswers(userAnswer, correctAnswer) {
- return String(userAnswer || '').trim().toLowerCase() === String(correctAnswer || '').trim().toLowerCase();
- }
- }
- };
- const sandbox = {
- window: windowStub,
- console: {
- log() {},
- warn() {},
- error() {},
- info() {},
- debug() {}
- },
- Date,
- Math,
- JSON,
- Object,
- Array,
- String,
- Number
- };
- sandbox.globalThis = sandbox.window;
- vm.createContext(sandbox);
- const source = fs.readFileSync(path.join(repoRoot, 'js/core/scoreStorage.js'), 'utf8');
- vm.runInContext(source, sandbox, { filename: 'js/core/scoreStorage.js' });
-
- const scoreStorage = Object.create(windowStub.ScoreStorage.prototype);
- scoreStorage.currentVersion = 'test-version';
- scoreStorage.maxRecords = 1000;
- scoreStorage.windowStub = windowStub;
- return scoreStorage;
-}
-
-function recordResult(name, passed, detail) {
- results.push({ name, passed, detail, timestamp: new Date().toISOString() });
-}
-
-function createConflictRecord() {
- return {
- id: 'score-storage-correct-map',
- examId: 'reading-score-storage',
- type: 'reading',
- answers: { q1: 'A', q2: 'D', q3: 'E' },
- correctAnswerMap: { q1: 'A' },
- correctAnswers: { q1: 'B', q2: 'C', q3: 'F' },
- totalQuestions: 3,
- realData: {
- correctAnswerMap: { q2: 'D' },
- correctAnswers: { q3: 'E' }
- },
- startTime: '2026-05-24T10:00:00.000Z',
- endTime: '2026-05-24T10:05:00.000Z'
- };
-}
-
-async function testFallbackStandardizeRecordCanonicalCorrectMapWins() {
- const scoreStorage = loadScoreStorageHarness();
- const standardized = scoreStorage.standardizeRecord(createConflictRecord());
-
- assert.strictEqual(standardized.correctAnswers, 2, '对象型 correctAnswers 不能污染 standardizeRecord 数字答对数');
- assert.strictEqual(standardized.score, 2, 'score 应从 canonical 正确答案表和用户答案推导');
- assert.strictEqual(standardized.correctAnswerMap.q1, 'A', 'standardizeRecord 应优先使用 canonical correctAnswerMap');
- assert.strictEqual(standardized.correctAnswerMap.q2, 'D', 'realData.correctAnswerMap 应先于 legacy correctAnswers 补缺');
- assert.strictEqual(standardized.correctAnswerMap.q3, 'F', 'legacy correctAnswers 只能作为缺失题目的补缺来源');
- assert.strictEqual(standardized.realData.correctAnswers.q1, 'A', 'realData.correctAnswers 应镜像 canonical map');
- assert.strictEqual(standardized.realData.correctAnswerMap.q2, 'D', 'realData.correctAnswerMap 应镜像 canonical map');
-
- recordResult('ScoreStorage fallback standardizeRecord canonical correctAnswerMap wins', true, {
- correctAnswerMap: standardized.correctAnswerMap,
- correctAnswers: standardized.correctAnswers
- });
-}
-
-async function testNormalizeLegacyRecordCanonicalCorrectMapWins() {
- const scoreStorage = loadScoreStorageHarness();
- const normalized = scoreStorage.normalizeLegacyRecord(createConflictRecord());
-
- assert.strictEqual(normalized.correctAnswers, 2, 'legacy normalize 不能保留对象型 correctAnswers 到数字字段');
- assert.strictEqual(normalized.correctAnswerMap.q1, 'A', 'legacy normalize 应优先使用 canonical correctAnswerMap');
- assert.strictEqual(normalized.correctAnswerMap.q2, 'D', 'legacy normalize 应使用 realData.correctAnswerMap 补缺');
- assert.strictEqual(normalized.realData.correctAnswers.q1, 'A', 'legacy realData.correctAnswers 应镜像 canonical map');
-
- recordResult('ScoreStorage normalizeLegacyRecord canonical correctAnswerMap wins', true, {
- correctAnswerMap: normalized.correctAnswerMap,
- correctAnswers: normalized.correctAnswers
- });
-}
-
-async function testNormalizeRecordFieldsCanonicalCorrectMapWins() {
- const scoreStorage = loadScoreStorageHarness();
- const normalized = scoreStorage.normalizeRecordFields(createConflictRecord());
-
- assert.strictEqual(normalized.correctAnswers, 2, 'normalizeRecordFields 不能保留对象型 correctAnswers 到数字字段');
- assert.strictEqual(normalized.correctAnswerMap.q1, 'A', 'normalizeRecordFields 应优先使用 canonical correctAnswerMap');
- assert.strictEqual(normalized.correctAnswerMap.q2, 'D', 'normalizeRecordFields 应使用 realData.correctAnswerMap 补缺');
- assert.strictEqual(normalized.realData.correctAnswers.q1, 'A', 'normalizeRecordFields realData.correctAnswers 应镜像 canonical map');
- assert.strictEqual(normalized.realData.correctAnswerMap.q2, 'D', 'normalizeRecordFields realData.correctAnswerMap 应镜像 canonical map');
-
- recordResult('ScoreStorage normalizeRecordFields canonical correctAnswerMap wins', true, {
- correctAnswerMap: normalized.correctAnswerMap,
- correctAnswers: normalized.correctAnswers
- });
-}
-
-async function testStorageAdapterRecordsAndStatsReadsUsePracticeRecordApiAndRejectWrites() {
- const scoreStorage = loadScoreStorageHarness();
- const calls = [];
- const records = [{ id: 'api-record-a', examId: 'reading-api-a' }];
- const stats = { totalPractices: 1 };
-
- scoreStorage.repositories = {
- practice: {
- async list() {
- throw new Error('ScoreStorage storage adapter must not raw-list practice records');
- },
- async overwrite() {
- throw new Error('ScoreStorage storage adapter must not raw-overwrite practice records');
- },
- async clear() {
- throw new Error('ScoreStorage storage adapter must not raw-clear practice records');
- }
- },
- meta: {
- async get(key, fallback = null) {
- if (key === 'user_stats') {
- throw new Error('ScoreStorage storage adapter must not raw-read user_stats');
- }
- calls.push({ type: 'meta.get', key });
- return fallback;
- },
- async set(key, value) {
- if (key === 'user_stats') {
- throw new Error('ScoreStorage storage adapter must not raw-write user_stats');
- }
- calls.push({ type: 'meta.set', key, value });
- return true;
- },
- async remove(key) {
- if (key === 'user_stats') {
- throw new Error('ScoreStorage storage adapter must not raw-remove user_stats');
- }
- calls.push({ type: 'meta.remove', key });
- return true;
- }
- },
- backups: {
- async list() {
- calls.push({ type: 'backups.list' });
- return [];
- },
- async saveAll(value) {
- calls.push({ type: 'backups.saveAll', value });
- return true;
- },
- async clear() {
- calls.push({ type: 'backups.clear' });
- return true;
- }
- }
- };
-
- scoreStorage.storageKeys = {
- practiceRecords: 'practice_records',
- userStats: 'user_stats',
- storageVersion: 'storage_version',
- backupData: 'manual_backups'
- };
-
- const apiRecords = records.map(record => Object.assign({}, record));
- scoreStorage.windowStub.PracticeRecordAPI = {
- async list() {
- calls.push({ type: 'api.list' });
- return apiRecords.map(record => Object.assign({}, record));
- },
- async replace(nextRecords, options = {}) {
- calls.push({ type: 'api.replace', records: nextRecords.map(record => Object.assign({}, record)), options });
- throw new Error('ScoreStorage adapter must not call PracticeRecordAPI.replace');
- },
- async clear(options = {}) {
- calls.push({ type: 'api.clear', options });
- throw new Error('ScoreStorage adapter must not call PracticeRecordAPI.clear');
- },
- async readStats(options = {}) {
- calls.push({ type: 'api.readStats', fallback: options.fallback });
- return Object.assign({}, stats);
- },
- async writeStats(nextStats) {
- calls.push({ type: 'api.writeStats', stats: Object.assign({}, nextStats) });
- throw new Error('ScoreStorage adapter must not call PracticeRecordAPI.writeStats');
- },
- async resetStats(nextStats = null) {
- calls.push({ type: 'api.resetStats', stats: nextStats });
- throw new Error('ScoreStorage adapter must not call PracticeRecordAPI.resetStats');
- }
- };
-
- const adapter = scoreStorage.createStorageAdapter();
- const listed = await adapter.get('practice_records', []);
- assert.strictEqual(listed.length, 1, 'adapter.get(practice_records) 应委托 PracticeRecordAPI.list');
-
- await assert.rejects(
- () => adapter.set('practice_records', [{ id: 'api-record-b', examId: 'reading-api-b' }]),
- /ScoreStorage\.storage\.set\(practice_records\) is disabled/,
- 'adapter.set(practice_records) 必须禁用'
- );
- assert.strictEqual(apiRecords[0].id, 'api-record-a', 'adapter.set(practice_records) 不能改 canonical records');
-
- await assert.rejects(
- () => adapter.remove('practice_records'),
- /ScoreStorage\.storage\.remove\(practice_records\) is disabled/,
- 'adapter.remove(practice_records) 必须禁用'
- );
- assert.strictEqual(apiRecords.length, 1, 'adapter.remove(practice_records) 不能清空 canonical records');
-
- const readStats = await adapter.get('user_stats', { totalPractices: 0 });
- assert.strictEqual(readStats.totalPractices, 1, 'adapter.get(user_stats) 应委托 PracticeRecordAPI.readStats');
-
- await assert.rejects(
- () => adapter.set('user_stats', { totalPractices: 3 }),
- /ScoreStorage\.storage\.set\(user_stats\) is disabled/,
- 'adapter.set(user_stats) 必须禁用'
- );
- assert.strictEqual(stats.totalPractices, 1, 'adapter.set(user_stats) 不能改 canonical stats');
-
- await assert.rejects(
- () => adapter.remove('user_stats'),
- /ScoreStorage\.storage\.remove\(user_stats\) is disabled/,
- 'adapter.remove(user_stats) 必须禁用'
- );
- assert.strictEqual(stats.totalPractices, 1, 'adapter.remove(user_stats) 不能重置 canonical stats');
-
- assert(!calls.some(call => call.type === 'api.replace'), 'adapter.set(practice_records) 不应调用 PracticeRecordAPI.replace');
- assert(!calls.some(call => call.type === 'api.clear'), 'adapter.remove(practice_records) 不应调用 PracticeRecordAPI.clear');
- assert(!calls.some(call => call.type === 'api.writeStats'), 'adapter.set(user_stats) 不应调用 PracticeRecordAPI.writeStats');
- assert(!calls.some(call => call.type === 'api.resetStats'), 'adapter.remove(user_stats) 不应调用 PracticeRecordAPI.resetStats');
- assert(!calls.some(call => call.type.startsWith('meta.') && call.key === 'user_stats'), 'adapter 不应直接读写 raw user_stats');
- recordResult('ScoreStorage storage adapter records/stats reads use PracticeRecordAPI and writes fail-fast', true, {
- calls: calls.map(call => call.type)
- });
-}
-
-async function runAllTests() {
- const tests = [
- testFallbackStandardizeRecordCanonicalCorrectMapWins,
- testNormalizeLegacyRecordCanonicalCorrectMapWins,
- testNormalizeRecordFieldsCanonicalCorrectMapWins,
- testStorageAdapterRecordsAndStatsReadsUsePracticeRecordApiAndRejectWrites
- ];
- for (const testFn of tests) {
- try {
- await testFn();
- } catch (error) {
- recordResult(testFn.name, false, { error: error.message, stack: error.stack });
- }
- }
-}
-
-function printJsonReport() {
- const totalTests = results.length;
- const passedTests = results.filter(result => result.passed).length;
- const failedTests = totalTests - passedTests;
- const report = {
- status: failedTests === 0 ? 'pass' : 'fail',
- detail: `${passedTests}/${totalTests} 测试通过`,
- summary: { totalTests, passedTests, failedTests },
- failedTests: results.filter(result => !result.passed)
- };
- console.log(JSON.stringify(report, null, 2));
- return report;
-}
-
-(async function main() {
- await runAllTests();
- const report = printJsonReport();
- process.exit(report.status === 'pass' ? 0 : 1);
-})();
diff --git a/developer/tests/js/serviceFacade.test.js b/developer/tests/js/serviceFacade.test.js
deleted file mode 100644
index 15a5741e..00000000
--- a/developer/tests/js/serviceFacade.test.js
+++ /dev/null
@@ -1,251 +0,0 @@
-#!/usr/bin/env node
-import fs from 'fs';
-import path from 'path';
-import vm from 'vm';
-import assert from 'assert';
-import { fileURLToPath } from 'url';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-const repoRoot = path.resolve(__dirname, '..', '..', '..');
-
-function loadScript(relativePath, context) {
- const source = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
- vm.runInContext(source, context, { filename: relativePath });
-}
-
-function stripComments(source) {
- return String(source || '')
- .replace(/\/\*[\s\S]*?\*\//g, '')
- .replace(/\/\/.*$/gm, '');
-}
-
-function isForwardOnlyFeatureSource(source) {
- const code = stripComments(source);
- const normalized = code.replace(/\s+/g, ' ').trim();
- if (!normalized) {
- return true;
- }
-
- const reExportOnly = /^export\s+(\*\s+from|\{[^}]+\}\s+from)\s+['"][^'"]+['"]\s*;?\s*$/;
- if (reExportOnly.test(normalized)) {
- return true;
- }
-
- const commonJsProxy = /^module\.exports\s*=\s*require\(['"][^'"]+['"]\)\s*;?\s*$/;
- if (commonJsProxy.test(normalized)) {
- return true;
- }
-
- const functionCount = (code.match(/\bfunction\b/g) || []).length;
- const directAliasPattern = /(window|globalThis|global)\.[A-Za-z_$][\w$]*\s*=\s*(window|globalThis|global)\.[A-Za-z_$][\w$]*\s*;?/;
- const objectAssignAliasPattern = /(window|globalThis|global)\.[A-Za-z_$][\w$]*\s*=\s*Object\.assign\(\{\}\s*,\s*(window|globalThis|global)\.[A-Za-z_$][\w$]*\s*\|\|\s*\{\}\s*\)\s*;?/;
-
- return functionCount <= 1 && (directAliasPattern.test(code) || objectAssignAliasPattern.test(code));
-}
-
-async function testPracticeStore() {
- const records = [];
- const windowStub = {
- PracticeRecordAPI: {
- async list() { return records.slice(); },
- async replace(next) {
- records.splice(0, records.length, ...next);
- return true;
- },
- async saveRecord(record) {
- records.unshift(record);
- return record;
- }
- }
- };
- const context = vm.createContext({ window: windowStub, globalThis: windowStub, console });
- loadScript('js/core/practiceStore.js', context);
-
- assert.strictEqual(typeof windowStub.PracticeStore.list, 'function');
- await windowStub.PracticeStore.save({ id: 'r1' });
- assert.strictEqual((await windowStub.PracticeStore.list()).length, 1);
- await windowStub.PracticeStore.clear();
- assert.strictEqual((await windowStub.PracticeStore.list()).length, 0);
-}
-
-function testFeaturesNoForwardOnlyFiles() {
- const featureRoot = path.join(repoRoot, 'js', 'features');
- if (!fs.existsSync(featureRoot)) {
- return;
- }
- const queue = [featureRoot];
- const files = [];
-
- while (queue.length > 0) {
- const current = queue.shift();
- const entries = fs.readdirSync(current, { withFileTypes: true });
- entries.forEach((entry) => {
- const fullPath = path.join(current, entry.name);
- if (entry.isDirectory()) {
- queue.push(fullPath);
- return;
- }
- if (entry.isFile() && fullPath.endsWith('.js')) {
- files.push(fullPath);
- }
- });
- }
-
- const forwardOnlyFiles = files
- .filter((fullPath) => isForwardOnlyFeatureSource(fs.readFileSync(fullPath, 'utf8')))
- .map((fullPath) => path.relative(repoRoot, fullPath).replace(/\\/g, '/'))
- .sort();
-
- assert.strictEqual(
- forwardOnlyFiles.length,
- 0,
- `js/features 禁止新增转发-only 文件: ${forwardOnlyFiles.join(', ')}`
- );
-}
-
-function testIndexCssConvergence() {
- const source = fs.readFileSync(path.join(repoRoot, 'index.html'), 'utf8');
- const cssHrefs = [...source.matchAll(/ ]*\brel\s*=\s*["']stylesheet["'][^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>/gi)]
- .map((match) => match[1].trim());
-
- const allowedCss = new Set([
- 'css/main.css',
- 'css/heroui-bridge.css',
- 'css/onboarding.css'
- ]);
- const unexpectedCss = cssHrefs.filter((href) => !allowedCss.has(href));
-
- assert(
- cssHrefs.includes('css/main.css'),
- 'index.html 必须保留 css/main.css 作为主样式入口'
- );
- assert.strictEqual(
- unexpectedCss.length,
- 0,
- `index.html 样式链接出现拆分迹象(新增小 CSS): ${unexpectedCss.join(', ')}`
- );
-}
-
-function testBuildBundlesNoDeletedScriptRefs() {
- const removedScripts = [
- 'js/features/session/examSessionService.js',
- 'js/features/session/sessionFeature.js',
- 'js/features/app/app-init.js',
- 'js/features/practice/practice-sync.js',
- 'js/features/overview/overview-runtime.js',
- 'js/runtime/mainRuntime.js',
- 'js/runtime/legacyPublicAPI.js'
- ];
- const buildSource = fs.readFileSync(path.join(repoRoot, 'scripts', 'build-bundles.mjs'), 'utf8');
- const staleRefs = removedScripts.filter((relativePath) => buildSource.includes(relativePath));
-
- assert.strictEqual(
- staleRefs.length,
- 0,
- `build-bundles.mjs 仍引用已删除脚本: ${staleRefs.join(', ')}`
- );
-}
-
-function testCompatPatchRegistry() {
- const windowStub = {};
- const context = vm.createContext({ window: windowStub, globalThis: windowStub, console });
- loadScript('js/patches/runtime-fixes.js', context);
-
- assert.strictEqual(typeof windowStub.CompatPatch.register, 'function');
- windowStub.CompatPatch.register('patch-a', {
- owner: 'runtime',
- reason: 'test patch',
- removeAfter: 'test'
- });
- const patches = windowStub.CompatPatch.list();
- assert.strictEqual(Array.isArray(patches), true);
- assert.strictEqual(patches.some((item) => item && item.name === 'patch-a'), true);
-}
-
-function testReadingLaunchHost() {
- const windowStub = {
- __READING_EXAM_MANIFEST__: {
- 'p1-reading': { dataKey: 'p1-reading-data', script: 'dummy.js' }
- },
- buildResourcePath(exam, kind) {
- return `${kind}/${exam.pdfFilename || ''}`;
- }
- };
- const context = vm.createContext({ window: windowStub, globalThis: windowStub, console, URLSearchParams });
- loadScript('js/app/examSessionMixin.js', context);
-
- const mixin = windowStub.ExamSystemAppMixins && windowStub.ExamSystemAppMixins.examSession;
- assert(mixin && typeof mixin.resolveReadingLaunchDescriptor === 'function', 'examSessionMixin 应暴露 resolveReadingLaunchDescriptor');
- const host = {
- _ensureAbsoluteUrl(url) {
- return url;
- },
- _isReadingLibraryExam: mixin._isReadingLibraryExam,
- _getUnifiedReadingManifestEntry: mixin._getUnifiedReadingManifestEntry,
- _isUnifiedReadingExam: mixin._isUnifiedReadingExam,
- _buildUnifiedReadingUrl: mixin._buildUnifiedReadingUrl,
- _buildReadingPdfUrl: mixin._buildReadingPdfUrl,
- resolveReadingLaunchDescriptor: mixin.resolveReadingLaunchDescriptor
- };
-
- const unified = host.resolveReadingLaunchDescriptor({
- id: 'p1-reading',
- type: 'reading',
- pdfFilename: 'reading.pdf'
- });
- assert.strictEqual(unified.mode, 'unified_html');
- assert.strictEqual(unified.dataKey, 'p1-reading-data');
- assert(unified.url.includes('reading-practice-unified.html?'));
-
- const pdf = host.resolveReadingLaunchDescriptor({
- id: 'p2-reading',
- type: 'reading',
- pdfFilename: 'fallback.pdf'
- });
- assert.strictEqual(pdf.mode, 'pdf_manual');
- assert.strictEqual(pdf.pdfUrl, 'pdf/fallback.pdf');
-
- const listening = host.resolveReadingLaunchDescriptor({
- id: 'listening-p1',
- type: 'listening',
- pdfFilename: 'listening.pdf'
- });
- assert.strictEqual(listening, null);
-}
-
-function testMainOpenExamDoesNotFallbackToRawHtml() {
- const source = fs.readFileSync(path.join(repoRoot, 'js/main.js'), 'utf8').replace(/\r\n/g, '\n');
- const match = source.match(/function openExam\s*\([^)]*\)\s*\{([\s\S]*?)\n\}\n\nfunction viewPDF/);
- assert(match, 'main.js 应保留 openExam 函数,并位于 viewPDF 之前');
-
- const openExamBody = match[1];
- assert(openExamBody.includes('window.app.openExam'), 'openExam 必须只委托统一 App 练习入口');
- assert(openExamBody.includes('统一练习入口未就绪'), 'openExam 在统一入口不可用时必须提示明确错误点');
- assert(!openExamBody.includes("buildResourcePath(exam, 'html')"), 'openExam 禁止拼接原始 HTML 题源路径');
- assert(!openExamBody.includes('window.open('), 'openExam 禁止直接打开原始题源窗口');
- assert(!openExamBody.includes('startHandshakeFallback'), 'openExam 禁止启动旧 HTML 握手兜底');
- assert(!source.includes('function startHandshakeFallback('), 'main.js 禁止保留旧 HTML 握手兜底函数');
-}
-
-async function main() {
- await testPracticeStore();
- testFeaturesNoForwardOnlyFiles();
- testIndexCssConvergence();
- testBuildBundlesNoDeletedScriptRefs();
- testReadingLaunchHost();
- testMainOpenExamDoesNotFallbackToRawHtml();
- testCompatPatchRegistry();
- console.log(JSON.stringify({
- status: 'pass',
- detail: 'convergence facade guard tests passed'
- }, null, 2));
-}
-
-main().catch((error) => {
- console.log(JSON.stringify({
- status: 'fail',
- detail: error.message
- }, null, 2));
- process.exit(1);
-});
diff --git a/developer/tests/js/simpleStorageWrapperPracticeData.test.js b/developer/tests/js/simpleStorageWrapperPracticeData.test.js
deleted file mode 100644
index 41464d4b..00000000
--- a/developer/tests/js/simpleStorageWrapperPracticeData.test.js
+++ /dev/null
@@ -1,205 +0,0 @@
-#!/usr/bin/env node
-import assert from 'assert';
-import fs from 'fs';
-import path from 'path';
-import vm from 'vm';
-import { fileURLToPath } from 'url';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-const repoRoot = path.resolve(__dirname, '..', '..', '..');
-
-function createHarness() {
- const calls = [];
- const records = [{ id: 'record-1', examId: 'reading-1', type: 'reading', score: 1 }];
- const stats = { totalPractices: 1 };
- const meta = new Map();
-
- const windowStub = {
- PracticeRecordAPI: {
- async list() {
- calls.push({ type: 'api.list' });
- return records.map(record => Object.assign({}, record));
- },
- async getById(id) {
- calls.push({ type: 'api.getById', id });
- return records.find(record => record.id === id) || null;
- },
- async readStats(options = {}) {
- calls.push({ type: 'api.readStats', fallback: options.fallback });
- return Object.assign({}, options.fallback || {}, stats);
- },
- async replace() {
- calls.push({ type: 'api.replace' });
- throw new Error('simpleStorageWrapper must not call PracticeRecordAPI.replace');
- },
- async saveRecord() {
- calls.push({ type: 'api.saveRecord' });
- throw new Error('simpleStorageWrapper must not call PracticeRecordAPI.saveRecord');
- },
- async deleteById() {
- calls.push({ type: 'api.deleteById' });
- throw new Error('simpleStorageWrapper must not call PracticeRecordAPI.deleteById');
- },
- async deleteMany() {
- calls.push({ type: 'api.deleteMany' });
- throw new Error('simpleStorageWrapper must not call PracticeRecordAPI.deleteMany');
- },
- async clear() {
- calls.push({ type: 'api.clear' });
- throw new Error('simpleStorageWrapper must not call PracticeRecordAPI.clear');
- },
- async writeStats() {
- calls.push({ type: 'api.writeStats' });
- throw new Error('simpleStorageWrapper must not call PracticeRecordAPI.writeStats');
- },
- async resetStats() {
- calls.push({ type: 'api.resetStats' });
- throw new Error('simpleStorageWrapper must not call PracticeRecordAPI.resetStats');
- }
- },
- dataRepositories: {
- settings: {
- async getAll() { return {}; },
- async saveAll() { return true; },
- async get() { return null; },
- async set() { return true; }
- },
- backups: {
- async list() { return []; },
- async saveAll() { return true; },
- async add() { return true; },
- async delete() { return true; },
- async clear() { return true; }
- },
- meta: {
- async get(key, fallback = null) {
- calls.push({ type: 'meta.get', key });
- return meta.has(key) ? meta.get(key) : fallback;
- },
- async set(key, value) {
- calls.push({ type: 'meta.set', key, value });
- meta.set(key, value);
- return true;
- },
- async remove(key) {
- calls.push({ type: 'meta.remove', key });
- meta.delete(key);
- return true;
- }
- }
- }
- };
-
- const sandbox = {
- window: windowStub,
- globalThis: windowStub,
- console: {
- log() {},
- warn() {},
- error() {}
- }
- };
- vm.createContext(sandbox);
- const source = fs.readFileSync(path.join(repoRoot, 'js/utils/simpleStorageWrapper.js'), 'utf8');
- vm.runInContext(source, sandbox, { filename: 'js/utils/simpleStorageWrapper.js' });
-
- return {
- calls,
- records,
- stats,
- meta,
- wrapper: windowStub.simpleStorageWrapper
- };
-}
-
-async function assertRejectsWrite(label, operation, expectedPattern) {
- await assert.rejects(operation, expectedPattern, `${label} 必须 fail-fast`);
-}
-
-async function main() {
- const harness = createHarness();
- const { wrapper, calls, records, stats, meta } = harness;
-
- assert(wrapper, 'simpleStorageWrapper 应自动连接 dataRepositories');
-
- const listed = await wrapper.getPracticeRecords();
- assert.strictEqual(listed.length, 1, 'getPracticeRecords 应保留 PracticeRecordAPI.list 只读兼容');
- assert.strictEqual((await wrapper.get('practice_records', [])).length, 1, 'get(practice_records) 应保留只读兼容');
- assert.strictEqual((await wrapper.getById('record-1')).id, 'record-1', 'getById 应保留只读兼容');
- assert.strictEqual((await wrapper.get('user_stats', { totalPractices: 0 })).totalPractices, 1, 'get(user_stats) 应保留只读兼容');
-
- await assertRejectsWrite(
- 'savePracticeRecords',
- () => wrapper.savePracticeRecords([{ id: 'next' }]),
- /SimpleStorageWrapper\.savePracticeRecords is disabled/
- );
- await assertRejectsWrite(
- 'addPracticeRecord',
- () => wrapper.addPracticeRecord({ id: 'next' }),
- /SimpleStorageWrapper\.addPracticeRecord is disabled/
- );
- await assertRejectsWrite(
- 'update',
- () => wrapper.update('record-1', { score: 2 }),
- /SimpleStorageWrapper\.update is disabled/
- );
- await assertRejectsWrite(
- 'delete',
- () => wrapper.delete('record-1'),
- /SimpleStorageWrapper\.delete is disabled/
- );
- await assertRejectsWrite(
- 'deletePracticeRecord',
- () => wrapper.deletePracticeRecord('record-1'),
- /SimpleStorageWrapper\.deletePracticeRecord is disabled/
- );
- await assertRejectsWrite(
- 'deletePracticeRecords',
- () => wrapper.deletePracticeRecords(['record-1']),
- /SimpleStorageWrapper\.deletePracticeRecords is disabled/
- );
- await assertRejectsWrite(
- 'set(practice_records)',
- () => wrapper.set('practice_records', []),
- /SimpleStorageWrapper\.set\(practice_records\) is disabled/
- );
- await assertRejectsWrite(
- 'set(user_stats)',
- () => wrapper.set('user_stats', { totalPractices: 0 }),
- /SimpleStorageWrapper\.set\(user_stats\) is disabled/
- );
- await assertRejectsWrite(
- 'remove(practice_records)',
- () => wrapper.remove('practice_records'),
- /SimpleStorageWrapper\.remove\(practice_records\) is disabled/
- );
- await assertRejectsWrite(
- 'remove(user_stats)',
- () => wrapper.remove('user_stats'),
- /SimpleStorageWrapper\.remove\(user_stats\) is disabled/
- );
-
- await wrapper.set('unrelated_meta', { ok: true });
- assert.deepStrictEqual(meta.get('unrelated_meta'), { ok: true }, '非练习数据仍可走 meta repo 写入');
- await wrapper.remove('unrelated_meta');
- assert.strictEqual(meta.has('unrelated_meta'), false, '非练习数据仍可走 meta repo 删除');
-
- assert.strictEqual(records.length, 1, 'wrapper 练习数据写入口不能改变 records');
- assert.strictEqual(stats.totalPractices, 1, 'wrapper stats 写入口不能改变 stats');
- const writeCalls = calls.filter(call => /^api\.(replace|saveRecord|deleteById|deleteMany|clear|writeStats|resetStats)$/.test(call.type));
- assert.strictEqual(writeCalls.length, 0, 'wrapper 写入口不能调用 PracticeRecordAPI 写方法');
-
- process.stdout.write(JSON.stringify({
- status: 'pass',
- detail: 'SimpleStorageWrapper 练习数据只读兼容,公开写入口 fail-fast'
- }));
-}
-
-main().catch((error) => {
- process.stdout.write(JSON.stringify({
- status: 'fail',
- detail: error && error.message ? error.message : String(error)
- }));
- process.exit(1);
-});
diff --git a/developer/tests/js/siteDataReset.test.js b/developer/tests/js/siteDataReset.test.js
new file mode 100644
index 00000000..0c028d44
--- /dev/null
+++ b/developer/tests/js/siteDataReset.test.js
@@ -0,0 +1,196 @@
+#!/usr/bin/env node
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import vm from 'node:vm';
+import { fileURLToPath } from 'node:url';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
+const source = fs.readFileSync(path.join(root, 'js/core/siteDataReset.js'), 'utf8');
+
+function createStorage(seed = {}) {
+ const values = new Map(Object.entries(seed));
+ return {
+ values,
+ clearCalls: 0,
+ clear() {
+ this.clearCalls += 1;
+ values.clear();
+ }
+ };
+}
+
+function createHarness(options = {}) {
+ const events = [];
+ const messages = [];
+ const requests = [];
+ const deleteModes = Object.assign({}, options.deleteModes);
+ const localStorage = createStorage({ consent: 'yes' });
+ const sessionStorage = createStorage({ recovery: 'active' });
+ const indexedDB = {
+ deleteDatabase(name) {
+ events.push(`delete:${name}`);
+ const request = { name, completed: false };
+ requests.push(request);
+ request.complete = () => {
+ if (request.completed) return;
+ request.completed = true;
+ events.push(`deleted:${name}`);
+ request.onsuccess?.({ target: request });
+ };
+ queueMicrotask(() => {
+ const mode = deleteModes[name] || 'success';
+ if (mode === 'error') {
+ request.error = new Error(`delete failed: ${name}`);
+ request.onerror?.({ target: request });
+ return;
+ }
+ if (mode === 'blocked' || mode === 'blocked-success') {
+ request.onblocked?.({ target: request });
+ if (mode === 'blocked') return;
+ }
+ queueMicrotask(request.complete);
+ });
+ return request;
+ }
+ };
+ const externalBackup = {
+ calls: 0,
+ async prepareForFullReset() {
+ this.calls += 1;
+ events.push('external:prepare');
+ if (options.externalError) throw new Error('external backup busy');
+ }
+ };
+ const windowStub = {
+ indexedDB,
+ localStorage,
+ sessionStorage,
+ ExternalBackupService: externalBackup,
+ confirm: () => options.confirmed !== false,
+ showMessage(message, type) { messages.push({ message, type }); },
+ console: Object.assign({}, console, { error() {} }),
+ location: {
+ reloadCalls: 0,
+ reload() { this.reloadCalls += 1; events.push('reload'); }
+ }
+ };
+ const context = vm.createContext({
+ window: windowStub,
+ globalThis: windowStub,
+ console: windowStub.console,
+ Promise,
+ Object,
+ Error
+ });
+ vm.runInContext(source, context, { filename: 'siteDataReset.js' });
+ return {
+ windowStub,
+ events,
+ messages,
+ requests,
+ deleteModes,
+ localStorage,
+ sessionStorage,
+ externalBackup,
+ complete(name) {
+ const request = requests.find((item) => item.name === name && !item.completed);
+ assert.ok(request, `missing pending request for ${name}`);
+ request.complete();
+ }
+ };
+}
+
+async function flush() {
+ for (let index = 0; index < 6; index += 1) await Promise.resolve();
+}
+
+async function testCancelledReset() {
+ const harness = createHarness({ confirmed: false });
+ const result = await harness.windowStub.clearCache();
+ assert.equal(result.reason, 'cancelled');
+ assert.deepEqual(harness.events, []);
+ assert.equal(harness.localStorage.clearCalls, 0);
+}
+
+async function testSuccessfulReset() {
+ const harness = createHarness();
+ const result = await harness.windowStub.clearCache();
+ assert.equal(result.success, true);
+ assert.deepEqual(JSON.parse(JSON.stringify(result.databases)), [
+ 'IELTSAtlasDataV2',
+ 'ExamSystemDB',
+ 'IELTSAtlasExternalBackupV2'
+ ]);
+ assert.equal(result.databases.includes('ExamSystemExternalBackup'), false,
+ 'legacy external handle database stays untouched for this release');
+ assert.equal(harness.events[0], 'external:prepare');
+ assert.equal(harness.localStorage.values.size, 0);
+ assert.equal(harness.sessionStorage.values.size, 0);
+ assert.equal(harness.windowStub.location.reloadCalls, 1);
+ assert.equal(result.externalBackupFilesPreserved, true);
+}
+
+async function testBlockedDeletionKeepsWaiting() {
+ const harness = createHarness({ deleteModes: { IELTSAtlasDataV2: 'blocked' } });
+ const pending = harness.windowStub.SiteDataReset.perform({ reload: false });
+ let settled = false;
+ pending.finally(() => { settled = true; });
+ await flush();
+ assert.equal(settled, false);
+ assert.equal(harness.localStorage.clearCalls, 0, 'storage clears only after every database is deleted');
+ assert.ok(harness.messages.some((entry) => entry.type === 'warning' && /关闭其他标签页/.test(entry.message)));
+ harness.complete('IELTSAtlasDataV2');
+ const result = await pending;
+ assert.equal(result.success, true);
+ assert.equal(harness.localStorage.values.size, 0);
+}
+
+async function testDeletionFailureIsVisible() {
+ const harness = createHarness({ deleteModes: { ExamSystemDB: 'error' } });
+ const result = await harness.windowStub.clearCache();
+ assert.equal(result.success, false);
+ assert.equal(result.reason, 'partial_reset');
+ assert.equal(result.terminal, false);
+ assert.equal(harness.windowStub.location.reloadCalls, 0);
+ assert.equal(harness.localStorage.clearCalls, 1);
+ assert.ok(harness.messages.some((entry) => entry.type === 'error'));
+}
+
+async function testExternalFailureStopsBeforeDeletion() {
+ const harness = createHarness({ externalError: true });
+ const result = await harness.windowStub.clearCache();
+ assert.equal(result.reason, 'external_backup_busy');
+ assert.equal(harness.events.some((entry) => entry.startsWith('delete:')), false);
+ assert.equal(harness.localStorage.clearCalls, 0);
+ assert.equal(harness.windowStub.location.reloadCalls, 0);
+}
+
+async function testConcurrentCallsShareOneRun() {
+ const harness = createHarness({ deleteModes: { IELTSAtlasDataV2: 'blocked' } });
+ const first = harness.windowStub.SiteDataReset.perform({ reload: false });
+ const second = harness.windowStub.SiteDataReset.perform({ reload: false });
+ await flush();
+ assert.equal(harness.events.filter((entry) => entry.startsWith('delete:')).length, 3);
+ assert.equal(harness.externalBackup.calls, 1);
+ harness.complete('IELTSAtlasDataV2');
+ const [left, right] = await Promise.all([first, second]);
+ assert.equal(left, right);
+}
+
+async function testFinishedNonTerminalRunCanRepeat() {
+ const harness = createHarness();
+ assert.equal((await harness.windowStub.SiteDataReset.perform({ reload: false })).success, true);
+ assert.equal((await harness.windowStub.SiteDataReset.perform({ reload: false })).success, true);
+ assert.equal(harness.events.filter((entry) => entry.startsWith('delete:')).length, 6);
+ assert.equal(harness.localStorage.clearCalls, 2);
+}
+
+await testCancelledReset();
+await testSuccessfulReset();
+await testBlockedDeletionKeepsWaiting();
+await testDeletionFailureIsVisible();
+await testExternalFailureStopsBeforeDeletion();
+await testConcurrentCallsShareOneRun();
+await testFinishedNonTerminalRunCanRepeat();
+console.log('SiteDataReset tests passed');
diff --git a/developer/tests/js/spellingErrorCollector.test.js b/developer/tests/js/spellingErrorCollector.test.js
index 32abadbd..cf600617 100644
--- a/developer/tests/js/spellingErrorCollector.test.js
+++ b/developer/tests/js/spellingErrorCollector.test.js
@@ -22,7 +22,6 @@ const __dirname = path.dirname(__filename);
// ============================================================================
global.window = {
- storage: null,
spellingErrorCollector: null
};
@@ -36,26 +35,32 @@ global.console = {
info: () => {}
};
-// 模拟存储系统
-class MockStorage {
- constructor() {
- this.data = new Map();
- this.ready = Promise.resolve();
- }
-
- async get(key) {
- return this.data.get(key) || null;
- }
-
- async set(key, value) {
- this.data.set(key, value);
- return true;
- }
-
- setNamespace() {}
+function clone(value) {
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
-global.window.storage = new MockStorage();
+const vocabCollections = {};
+function resetCollections() {
+ Object.keys(vocabCollections).forEach((id) => delete vocabCollections[id]);
+}
+global.window.AppData = {
+ ready: Promise.resolve(),
+ vocab: {
+ async listCollections() {
+ return clone(vocabCollections);
+ },
+ async saveCollection(id, value) {
+ vocabCollections[id] = clone(value);
+ return { committed: true };
+ },
+ async saveCollections(values) {
+ Object.entries(values).forEach(([id, value]) => {
+ vocabCollections[id] = clone(value);
+ });
+ return { committed: true };
+ }
+ }
+};
// ============================================================================
// 加载 SpellingErrorCollector
@@ -414,6 +419,7 @@ function testSpellingFalsePositiveFilters() {
*/
async function testVocabListSaveAndLoad() {
console.log('测试: 词表保存和加载');
+ resetCollections();
const collector = new window.SpellingErrorCollector();
await collector.ensureInitialized();
@@ -452,6 +458,7 @@ async function testVocabListSaveAndLoad() {
assert.strictEqual(loadedList.id, 'p1', '词表ID应该正确');
assert.strictEqual(loadedList.words.length, 1, '应该有1个单词');
assert.strictEqual(loadedList.words[0].word, 'accommodation', '单词应该正确');
+ assert.ok(vocabCollections['spelling-errors-p1'], '必须写入 canonical collection');
console.log(' ✓ 词表保存和加载正确');
}
@@ -529,6 +536,7 @@ async function testMergeErrorsToList() {
*/
async function testSaveErrors() {
console.log('测试: 保存错误到词表');
+ resetCollections();
const collector = new window.SpellingErrorCollector();
await collector.ensureInitialized();
@@ -579,6 +587,7 @@ async function testSaveErrors() {
*/
async function testRemoveWord() {
console.log('测试: 移除单词');
+ resetCollections();
const collector = new window.SpellingErrorCollector();
await collector.ensureInitialized();
@@ -619,6 +628,7 @@ async function testRemoveWord() {
*/
async function testClearList() {
console.log('测试: 清空词表');
+ resetCollections();
const collector = new window.SpellingErrorCollector();
await collector.ensureInitialized();
diff --git a/developer/tests/js/stateSerializerTest.js b/developer/tests/js/stateSerializerTest.js
deleted file mode 100644
index 6e396879..00000000
--- a/developer/tests/js/stateSerializerTest.js
+++ /dev/null
@@ -1,483 +0,0 @@
-/**
- * 状态序列化器测试套件
- * 验证Set/Map对象的序列化/反序列化一致性
- */
-
-class StateSerializerTest {
- constructor() {
- this.testResults = [];
- this.testData = {
- setExample: new Set(['item1', 'item2', 'item3']),
- mapExample: new Map([
- ['key1', 'value1'],
- ['key2', 'value2'],
- ['key3', { complex: 'object' }]
- ]),
- nestedObject: {
- level1: {
- level2: {
- setInside: new Set(['nested', 'set']),
- mapInside: new Map([['nested', 'map']])
- }
- },
- array: [
- new Set(['set in array']),
- new Map([['map', 'in array']])
- ]
- },
- mixedState: {
- exam: {
- filteredExams: ['exam1', 'exam2'],
- configurations: new Map([['config1', { enabled: true }]])
- },
- practice: {
- selectedRecords: new Set(['record1', 'record2', 'record3']),
- bulkDeleteMode: false
- },
- system: {
- processedSessions: new Set(['session1', 'session2']),
- fallbackExamSessions: new Map([['fallback1', { data: 'test' }]])
- }
- }
- };
- }
-
- // 运行所有测试
- async runAllTests() {
- console.log('🧪 开始状态序列化测试...');
-
- this.testResults = [];
-
- // 基础类型测试
- this.testSetSerialization();
- this.testMapSerialization();
- this.testDateSerialization();
- this.testNestedObjectSerialization();
-
- // 实际应用场景测试
- this.testActualAppState();
- this.testLocalStorageRoundTrip();
- this.testDataConsistency();
-
- this.printResults();
- return this.testResults;
- }
-
- // 测试Set序列化
- testSetSerialization() {
- const testName = 'Set序列化/反序列化';
- const original = this.testData.setExample;
-
- try {
- const serialized = StateSerializer.serialize(original);
- const deserialized = StateSerializer.deserialize(serialized);
-
- const isValid = this.validateSetEquality(original, deserialized);
- this.recordTest(testName, isValid, {
- original: Array.from(original),
- serialized,
- deserialized: Array.from(deserialized)
- });
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 测试Map序列化
- testMapSerialization() {
- const testName = 'Map序列化/反序列化';
- const original = this.testData.mapExample;
-
- try {
- const serialized = StateSerializer.serialize(original);
- const deserialized = StateSerializer.deserialize(serialized);
-
- const isValid = this.validateMapEquality(original, deserialized);
- this.recordTest(testName, isValid, {
- original: Array.from(original.entries()),
- serialized,
- deserialized: Array.from(deserialized.entries())
- });
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 测试Date序列化
- testDateSerialization() {
- const testName = 'Date序列化/反序列化';
- const original = new Date('2024-01-01T00:00:00.000Z');
-
- try {
- const serialized = StateSerializer.serialize(original);
- const deserialized = StateSerializer.deserialize(serialized);
-
- const isValid = deserialized instanceof Date && deserialized.getTime() === original.getTime();
- this.recordTest(testName, isValid, {
- original: original.toISOString(),
- serialized,
- deserialized: deserialized.toISOString()
- });
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 测试嵌套对象序列化
- testNestedObjectSerialization() {
- const testName = '嵌套对象序列化/反序列化';
- const original = this.testData.nestedObject;
-
- try {
- const serialized = StateSerializer.serialize(original);
- const deserialized = StateSerializer.deserialize(serialized);
-
- const isValid = this.validateNestedObjectEquality(original, deserialized);
- this.recordTest(testName, isValid, {
- hasSetInside: deserialized.level1.level2.setInside instanceof Set,
- hasMapInside: deserialized.level1.level2.mapInside instanceof Map,
- setInsideSize: deserialized.level1.level2.setInside.size,
- mapInsideSize: deserialized.level1.level2.mapInside.size
- });
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 测试实际应用状态
- testActualAppState() {
- const testName = '实际应用状态序列化';
- const original = this.testData.mixedState;
-
- try {
- const serialized = StateSerializer.serialize(original);
- const deserialized = StateSerializer.deserialize(serialized);
-
- const isValid = this.validateAppStateEquality(original, deserialized);
- this.recordTest(testName, isValid, {
- selectedRecordsCount: deserialized.practice.selectedRecords.size,
- processedSessionsCount: deserialized.system.processedSessions.size,
- fallbackSessionsCount: deserialized.system.fallbackExamSessions.size
- });
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 测试LocalStorage往返
- async testLocalStorageRoundTrip() {
- const testName = 'LocalStorage往返测试';
- const original = this.testData.mixedState;
-
- try {
- // 序列化并存储
- const serialized = StateSerializer.serialize(original);
- const testKey = 'test_state_roundtrip';
- await storage.set(testKey, serialized);
-
- // 从存储读取并反序列化
- const stored = await storage.get(testKey);
- const deserialized = StateSerializer.deserialize(stored);
-
- // 清理测试数据
- await storage.remove(testKey);
-
- const isValid = this.validateAppStateEquality(original, deserialized);
- this.recordTest(testName, isValid, {
- storageSuccess: true,
- dataIntegrity: isValid
- });
- } catch (error) {
- this.recordTest(testName, false, { error: error.message });
- }
- }
-
- // 测试数据一致性
- testDataConsistency() {
- const testName = '数据一致性验证';
- const testCases = [
- new Set([]),
- new Set(['single']),
- new Set(['a', 'b', 'c', 'd', 'e']),
- new Map([]),
- new Map([['key', 'value']]),
- new Map([['a', 1], ['b', 2], ['c', 3]])
- ];
-
- let allPassed = true;
- const results = [];
-
- testCases.forEach((testCase, index) => {
- const isValid = StateSerializer.validate(testCase);
- results.push({ index, type: testCase.constructor.name, isValid });
- if (!isValid) allPassed = false;
- });
-
- this.recordTest(testName, allPassed, { results });
- }
-
- // 验证Set相等性
- validateSetEquality(set1, set2) {
- if (!(set2 instanceof Set)) return false;
- if (set1.size !== set2.size) return false;
-
- const arr1 = Array.from(set1).sort();
- const arr2 = Array.from(set2).sort();
-
- return JSON.stringify(arr1) === JSON.stringify(arr2);
- }
-
- // 验证Map相等性
- validateMapEquality(map1, map2) {
- if (!(map2 instanceof Map)) return false;
- if (map1.size !== map2.size) return false;
-
- const arr1 = Array.from(map1.entries()).sort();
- const arr2 = Array.from(map2.entries()).sort();
-
- return JSON.stringify(arr1) === JSON.stringify(arr2);
- }
-
- // 验证嵌套对象相等性
- validateNestedObjectEquality(obj1, obj2) {
- // 检查Set
- if (!this.validateSetEquality(obj1.level1.level2.setInside, obj2.level1.level2.setInside)) {
- return false;
- }
-
- // 检查Map
- if (!this.validateMapEquality(obj1.level1.level2.mapInside, obj2.level1.level2.mapInside)) {
- return false;
- }
-
- // 检查数组中的Set和Map
- if (!this.validateSetEquality(obj1.array[0], obj2.array[0])) {
- return false;
- }
-
- if (!this.validateMapEquality(obj1.array[1], obj2.array[1])) {
- return false;
- }
-
- return true;
- }
-
- // 验证应用状态相等性
- validateAppStateEquality(state1, state2) {
- // 检查practice.selectedRecords
- if (!this.validateSetEquality(state1.practice.selectedRecords, state2.practice.selectedRecords)) {
- return false;
- }
-
- // 检查system.processedSessions
- if (!this.validateSetEquality(state1.system.processedSessions, state2.system.processedSessions)) {
- return false;
- }
-
- // 检查system.fallbackExamSessions
- if (!this.validateMapEquality(state1.system.fallbackExamSessions, state2.system.fallbackExamSessions)) {
- return false;
- }
-
- return true;
- }
-
- // 记录测试结果
- recordTest(testName, passed, details) {
- this.testResults.push({
- name: testName,
- passed,
- details,
- timestamp: new Date().toISOString()
- });
-
- const status = passed ? '✅' : '❌';
- console.log(`${status} ${testName}`);
- if (!passed) {
- console.error(' 详情:', details);
- }
- }
-
- // 打印测试结果
- printResults() {
- const totalTests = this.testResults.length;
- const passedTests = this.testResults.filter(r => r.passed).length;
- const failedTests = totalTests - passedTests;
-
- console.log('\n📊 测试结果汇总:');
- console.log(`总测试数: ${totalTests}`);
- console.log(`通过: ${passedTests} ✅`);
- console.log(`失败: ${failedTests} ❌`);
- console.log(`成功率: ${((passedTests / totalTests) * 100).toFixed(1)}%`);
-
- if (failedTests > 0) {
- console.log('\n❌ 失败的测试:');
- this.testResults
- .filter(r => !r.passed)
- .forEach(r => console.log(` - ${r.name}: ${r.details.error || '数据不匹配'}`));
- }
- }
-
- // 创建刷新测试页面
- static createRefreshTestPage() {
- const testPage = `
-
-
-
-
-
- 状态序列化刷新测试
-
-
-
- 🔄 状态序列化刷新测试
-
-
-
测试步骤:
-
- 点击"创建测试数据"创建Set/Map状态
- 点击"保存状态到存储"
- 刷新页面 (F5)
- 点击"验证恢复的状态"
-
-
-
创建测试数据
-
保存状态到存储
-
加载状态
-
验证恢复的状态
-
运行完整测试
-
-
-
-
-
-
-
-
-
-
-
-`;
-
- return testPage;
- }
-}
-
-// 导出供使用
-if (typeof module !== 'undefined' && module.exports) {
- module.exports = StateSerializerTest;
-}
\ No newline at end of file
diff --git a/developer/tests/js/storageManagerRecords.test.js b/developer/tests/js/storageManagerRecords.test.js
deleted file mode 100644
index e2caaad4..00000000
--- a/developer/tests/js/storageManagerRecords.test.js
+++ /dev/null
@@ -1,629 +0,0 @@
-#!/usr/bin/env node
-import fs from 'fs';
-import path from 'path';
-import vm from 'vm';
-import assert from 'assert';
-import { fileURLToPath } from 'url';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-const repoRoot = path.resolve(__dirname, '..', '..', '..');
-
-function createMemoryStorage() {
- const data = new Map();
- return {
- get length() {
- return data.size;
- },
- key(index) {
- return Array.from(data.keys())[index] || null;
- },
- getItem(key) {
- return data.has(String(key)) ? data.get(String(key)) : null;
- },
- setItem(key, value) {
- data.set(String(key), String(value));
- },
- removeItem(key) {
- data.delete(String(key));
- },
- clear() {
- data.clear();
- },
- _dump() {
- return Object.fromEntries(data.entries());
- }
- };
-}
-
-function createFetch(responseData = null) {
- return async () => ({
- ok: true,
- async json() {
- return responseData || { practice_records: [{ id: 'backup-record', examId: 'reading-backup' }] };
- }
- });
-}
-
-async function createHarness(options = {}) {
- const localStorage = createMemoryStorage();
- const sessionStorage = createMemoryStorage();
- const intervals = [];
- const timeouts = [];
- const listeners = [];
- const calls = [];
- const quietConsole = {
- log() {},
- info() {},
- warn() {},
- error() {}
- };
-
- const windowStub = {
- location: { protocol: options.protocol || 'http:' },
- localStorage,
- sessionStorage,
- indexedDB: null,
- dispatchEvent() {},
- addEventListener(type, handler) {
- listeners.push({ type, handler });
- },
- showMessage() {},
- fetch: createFetch(options.backupData),
- setTimeout(callback, delay) {
- const id = { callback, delay };
- timeouts.push(id);
- return id;
- },
- clearTimeout(id) {
- const index = timeouts.indexOf(id);
- if (index >= 0) {
- timeouts.splice(index, 1);
- }
- }
- };
- if (options.practiceRecordAPI) {
- windowStub.PracticeRecordAPI = options.practiceRecordAPI(calls);
- }
-
- const sandbox = {
- window: windowStub,
- globalThis: windowStub,
- localStorage,
- sessionStorage,
- document: {
- dispatchEvent() {}
- },
- CustomEvent: class CustomEvent {
- constructor(type, init = {}) {
- this.type = type;
- this.detail = init.detail || null;
- }
- },
- console: quietConsole,
- JSON,
- Date,
- Math,
- setInterval(callback, delay) {
- const id = { callback, delay };
- intervals.push(id);
- return id;
- },
- clearInterval(id) {
- const index = intervals.indexOf(id);
- if (index >= 0) {
- intervals.splice(index, 1);
- }
- },
- setTimeout(callback, delay) {
- return windowStub.setTimeout(callback, delay);
- },
- clearTimeout(id) {
- return windowStub.clearTimeout(id);
- },
- fetch: createFetch(options.backupData)
- };
- const context = vm.createContext(sandbox);
- const source = fs.readFileSync(path.join(repoRoot, 'js/utils/storage.js'), 'utf8');
- vm.runInContext(source, context, { filename: 'js/utils/storage.js' });
- await windowStub.persistentStore.ready;
- return {
- window: windowStub,
- persistentStore: windowStub.persistentStore,
- localStorage,
- sessionStorage,
- calls,
- intervals,
- timeouts,
- context,
- listeners
- };
-}
-
-function loadScript(relativePath, context) {
- const source = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
- vm.runInContext(source, context, { filename: relativePath });
-}
-
-function flushWindowTimeouts(harness) {
- while (harness.timeouts.length > 0) {
- const pending = harness.timeouts.splice(0, harness.timeouts.length);
- pending.forEach((entry) => {
- if (typeof entry.callback === 'function') {
- entry.callback();
- }
- });
- }
-}
-
-function readEnvelope(storage, key) {
- const raw = storage.getItem(key);
- return raw ? JSON.parse(raw).data : null;
-}
-
-function readRawPracticeRecords(harness) {
- const records = readEnvelope(harness.localStorage, 'exam_system_practice_records');
- return Array.isArray(records) ? records : [];
-}
-
-async function writeRawPracticeRecords(harness, records) {
- harness.localStorage.setItem('exam_system_practice_records', JSON.stringify({
- data: records,
- timestamp: Date.now(),
- version: '0.6.2-fix',
- compressed: false
- }));
-}
-
-async function testStorageDataSourceReadBypassesPublicPracticeRecordRedirect() {
- const calls = [];
- const windowStub = { ExamData: {} };
- const sandbox = {
- window: windowStub,
- console: {
- log() {},
- info() {},
- warn() {},
- error() {}
- }
- };
- const context = vm.createContext(sandbox);
- const source = fs.readFileSync(path.join(repoRoot, 'js/data/dataSources/storageDataSource.js'), 'utf8');
- vm.runInContext(source, context, { filename: 'js/data/dataSources/storageDataSource.js' });
-
- const storageManager = {
- async get(key, defaultValue, options = {}) {
- calls.push({ type: 'get', key, options });
- if (key === 'practice_records' && !options.skipPracticeCoreRedirect) {
- throw new Error('public practice record redirect would recurse');
- }
- return key === 'practice_records'
- ? [{ id: `raw-${calls.length}`, examId: 'reading-raw' }]
- : defaultValue;
- },
- async set() {
- return true;
- },
- async remove() {
- return true;
- }
- };
-
- const externalDataSource = new windowStub.ExamData.StorageDataSource(storageManager);
- await assert.rejects(
- () => externalDataSource.read('practice_records', []),
- /protected key practice_records/,
- '外部 new StorageDataSource 不能读取 protected key'
- );
-
- const dataSource = new windowStub.ExamData.StorageDataSource(storageManager, {
- createInternalOptions() {
- return { skipPracticeCoreRedirect: true, internalAccessToken: Symbol('test-internal') };
- }
- });
- const records = await dataSource.read('practice_records', []);
- assert.strictEqual(records[0].id, 'raw-1', 'StorageDataSource.read 应直接读底层 raw store');
-
- const txRecords = await dataSource.runTransaction(async (transaction) => {
- return transaction.get('practice_records', []);
- });
- assert.strictEqual(txRecords[0].id, 'raw-2', 'StorageTransactionContext.get 应直接读底层 raw store');
-
- assert.strictEqual(calls.length, 2, 'read 和 transaction.get 应各触发一次底层读取');
- assert(calls.every((call) => call.options && call.options.skipPracticeCoreRedirect === true),
- 'StorageDataSource 底层读取必须跳过 PracticeRecordAPI/public storage redirect');
-}
-
-async function testRuntimeImportFailsWithoutPracticeRecordAPI() {
- const harness = await createHarness();
- await writeRawPracticeRecords(harness, [{ id: 'existing-record', examId: 'reading-existing' }]);
-
- const result = await harness.persistentStore.importData({
- data: {
- practice_records: [{ id: 'import-record', examId: 'reading-import' }]
- }
- });
-
- assert.strictEqual(result.success, false, '运行期导入缺少 PracticeRecordAPI 时必须失败');
- assert.strictEqual(
- readRawPracticeRecords(harness).some((record) => record && record.id === 'import-record'),
- false,
- '运行期导入失败不能把导入记录写入 raw practice_records'
- );
- assert.strictEqual(
- readRawPracticeRecords(harness).some((record) => record && record.id === 'existing-record'),
- true,
- '统一 API 缺失时导入必须 fail-fast,不能先清空已有练习记录'
- );
-}
-
-async function testInternalStorageAccessIsNotWindowPublic() {
- const harness = await createHarness();
- flushWindowTimeouts(harness);
-
- assert.strictEqual(
- Object.prototype.hasOwnProperty.call(harness.window, 'createStorageInternalAccessOptions'),
- false,
- 'internal storage access 生成器不能挂到 window'
- );
- assert.strictEqual(
- Object.prototype.hasOwnProperty.call(harness.window, 'hasStorageInternalAccess'),
- false,
- 'internal storage access 校验器不能挂到 window'
- );
- assert.strictEqual(
- Object.prototype.hasOwnProperty.call(harness.window, '__installStorageInternalAccess'),
- true,
- 'storage internal access installer 在被消费前应保持可用'
- );
-}
-
-async function testFullDataBootstrapHidesInternalPracticeRepositories() {
- const harness = await createHarness();
- const scripts = [
- 'js/core/storageProviderRegistry.js',
- 'js/data/dataSources/storageDataSource.js',
- 'js/data/repositories/baseRepository.js',
- 'js/data/repositories/dataRepositoryRegistry.js',
- 'js/data/repositories/practiceRepository.js',
- 'js/data/repositories/settingsRepository.js',
- 'js/data/repositories/backupRepository.js',
- 'js/data/repositories/metaRepository.js',
- 'js/core/practiceCore.js',
- 'js/data/index.js',
- 'js/core/practiceRecordAPI.js'
- ];
- scripts.forEach((script) => loadScript(script, harness.context));
- flushWindowTimeouts(harness);
-
- assert(harness.window.PracticeRecordAPI, 'PracticeRecordAPI 应完成初始化');
- assert.strictEqual(
- Object.prototype.hasOwnProperty.call(harness.window.ExamData, 'internalRepositories'),
- false,
- 'ExamData.internalRepositories 不能暴露底层 practiceRepo'
- );
- assert.strictEqual(
- Boolean(harness.window.dataRepositories && harness.window.dataRepositories.practice),
- false,
- 'public dataRepositories 不能暴露 practice 仓库'
- );
- assert.strictEqual(
- typeof harness.window.PracticeCore.__installInternalRepositories,
- 'undefined',
- 'PracticeCore 内部仓库 installer 必须在数据层注入后删除'
- );
- assert.strictEqual(
- typeof harness.window.PracticeCore.__installRecordAPI,
- 'undefined',
- 'PracticeCore RecordAPI installer 必须在 PracticeRecordAPI 初始化后删除'
- );
- assert.strictEqual(
- typeof harness.window.__installStorageInternalAccess,
- 'undefined',
- 'storage internal access installer 必须在数据层注入后删除'
- );
-
- await harness.window.PracticeRecordAPI.saveRecord({
- id: 'bootstrap-record',
- examId: 'reading-bootstrap',
- type: 'reading',
- date: '2026-05-25T00:00:00.000Z',
- score: 1,
- totalQuestions: 1,
- correctAnswers: 1,
- accuracy: 1
- });
- const records = await harness.window.PracticeRecordAPI.list();
- assert.strictEqual(records.length, 1, '隐藏内部仓库后 PracticeRecordAPI 仍应能落库');
- assert.strictEqual(records[0].id, 'bootstrap-record');
-}
-
-async function testRuntimeImportUsesPracticeRecordAPI() {
- const savedRecords = [];
- const harness = await createHarness({
- practiceRecordAPI: (calls) => ({
- async replace(records) {
- calls.push({ type: 'api.replace', records });
- savedRecords.splice(0, savedRecords.length, ...(Array.isArray(records) ? records : []));
- return savedRecords.slice();
- },
- async list() {
- calls.push({ type: 'api.list' });
- return savedRecords.slice();
- }
- })
- });
-
- const result = await harness.persistentStore.importData({
- data: {
- practice_records: [{ id: 'import-record', examId: 'reading-import' }]
- }
- });
-
- assert.strictEqual(result.success, true, 'PracticeRecordAPI 可用时运行期导入应成功');
- assert(harness.calls.some((call) => call.type === 'api.replace'), '运行期导入必须调用 PracticeRecordAPI.replace');
- assert.strictEqual(savedRecords.length, 1, 'PracticeRecordAPI 应收到导入记录');
- assert.strictEqual(
- readRawPracticeRecords(harness).some((record) => record && record.id === 'import-record'),
- false,
- '运行期导入成功也不能把导入记录落到 raw practice_records 影子键'
- );
-}
-
-async function testPublicStorageFacadeReadsWithPracticeRecordAPIAndRejectsWrites() {
- const savedRecords = [{ id: 'api-existing', examId: 'reading-existing' }];
- let stats = { totalPractices: 1 };
- const harness = await createHarness({
- practiceRecordAPI: (calls) => ({
- async list() {
- calls.push({ type: 'api.list' });
- return savedRecords.slice();
- },
- async replace(records, options) {
- calls.push({ type: 'api.replace', records, options });
- throw new Error('public storage facade must not call PracticeRecordAPI.replace');
- },
- async clear(options) {
- calls.push({ type: 'api.clear', options });
- throw new Error('public storage facade must not call PracticeRecordAPI.clear');
- },
- async readStats(options = {}) {
- calls.push({ type: 'api.readStats', options });
- return Object.assign({}, options.fallback || {}, stats);
- },
- async writeStats(nextStats) {
- calls.push({ type: 'api.writeStats', stats: nextStats });
- throw new Error('public storage facade must not call PracticeRecordAPI.writeStats');
- },
- async resetStats() {
- calls.push({ type: 'api.resetStats' });
- throw new Error('public storage facade must not call PracticeRecordAPI.resetStats');
- }
- })
- });
-
- const listed = await harness.persistentStore.get('practice_records', []);
- assert.strictEqual(listed.length, 1, 'public storage.get(practice_records) 应委托 PracticeRecordAPI.list');
-
- await assert.rejects(
- () => harness.persistentStore.set('practice_records', [{ id: 'api-next', examId: 'reading-next' }]),
- /Storage\.set\(practice_records\) is disabled/,
- 'public storage.set(practice_records) 必须禁用'
- );
- assert.strictEqual(savedRecords[0].id, 'api-existing', 'public storage.set(practice_records) 不能改 canonical records');
- assert.strictEqual(readRawPracticeRecords(harness).some((record) => record && record.id === 'api-next'), false, 'public storage.set(practice_records) 不能写 raw shadow key');
-
- await assert.rejects(
- () => harness.persistentStore.remove('practice_records'),
- /Storage\.remove\(practice_records\) is disabled/,
- 'public storage.remove(practice_records) 必须禁用'
- );
- assert.strictEqual(savedRecords.length, 1, 'public storage.remove(practice_records) 不能清空 canonical records');
-
- const readStats = await harness.persistentStore.get('user_stats', { totalPractices: 0 });
- assert.strictEqual(readStats.totalPractices, 1, 'public storage.get(user_stats) 应委托 PracticeRecordAPI.readStats');
-
- await assert.rejects(
- () => harness.persistentStore.set('user_stats', { totalPractices: 3 }),
- /Storage\.set\(user_stats\) is disabled/,
- 'public storage.set(user_stats) 必须禁用'
- );
- assert.strictEqual(stats.totalPractices, 1, 'public storage.set(user_stats) 不能改 canonical stats');
-
- await assert.rejects(
- () => harness.persistentStore.remove('user_stats'),
- /Storage\.remove\(user_stats\) is disabled/,
- 'public storage.remove(user_stats) 必须禁用'
- );
- assert.strictEqual(stats.totalPractices, 1, 'public storage.remove(user_stats) 不能重置 canonical stats');
-
- const callTypes = harness.calls.map((call) => call.type);
- assert(callTypes.includes('api.list'), 'PracticeRecordAPI.list must be called');
- assert(callTypes.includes('api.readStats'), 'PracticeRecordAPI.readStats must be called');
- assert(!callTypes.includes('api.replace'), 'public storage.set(practice_records) must not call PracticeRecordAPI.replace');
- assert(!callTypes.includes('api.clear'), 'public storage.remove(practice_records) must not call PracticeRecordAPI.clear');
- assert(!callTypes.includes('api.writeStats'), 'public storage.set(user_stats) must not call PracticeRecordAPI.writeStats');
- assert(!callTypes.includes('api.resetStats'), 'public storage.remove(user_stats) must not call PracticeRecordAPI.resetStats');
-}
-
-async function testPublicBypassOptionsCannotWritePracticeData() {
- const savedRecords = [{ id: 'api-existing', examId: 'reading-existing' }];
- let stats = { totalPractices: 1 };
- const harness = await createHarness({
- practiceRecordAPI: (calls) => ({
- async list() {
- calls.push({ type: 'api.list' });
- return savedRecords.slice();
- },
- async replace(records, options) {
- calls.push({ type: 'api.replace', records, options });
- throw new Error('public bypass options must not call PracticeRecordAPI.replace');
- },
- async clear(options) {
- calls.push({ type: 'api.clear', options });
- throw new Error('public bypass options must not call PracticeRecordAPI.clear');
- },
- async readStats(options = {}) {
- calls.push({ type: 'api.readStats', options });
- return Object.assign({}, options.fallback || {}, stats);
- },
- async writeStats(nextStats) {
- calls.push({ type: 'api.writeStats', stats: nextStats });
- throw new Error('public bypass options must not call PracticeRecordAPI.writeStats');
- },
- async resetStats() {
- calls.push({ type: 'api.resetStats' });
- throw new Error('public bypass options must not call PracticeRecordAPI.resetStats');
- }
- })
- });
- await writeRawPracticeRecords(harness, [{ id: 'raw-shadow', examId: 'reading-shadow' }]);
-
- const listed = await harness.persistentStore.get('practice_records', [], { skipPracticeCoreRedirect: true });
- assert.strictEqual(listed[0].id, 'api-existing', 'skipPracticeCoreRedirect 不能让 public get 读取 raw practice_records');
-
- await assert.rejects(
- () => harness.persistentStore.set('practice_records', [{ id: 'api-skip', examId: 'reading-skip' }], { skipPracticeCoreRedirect: true }),
- /Storage\.set\(practice_records\) is disabled/,
- 'skipPracticeCoreRedirect 不能让 public set 写 practice_records'
- );
- assert.strictEqual(savedRecords[0].id, 'api-existing', 'skipPracticeCoreRedirect public set 不能改 canonical records');
- assert.strictEqual(readRawPracticeRecords(harness).some((record) => record && record.id === 'api-skip'), false,
- 'skipPracticeCoreRedirect public set 不能写 raw practice_records');
-
- await assert.rejects(
- () => harness.persistentStore.set('user_stats', { totalPractices: 5 }, { skipReady: true }),
- /Storage\.set\(user_stats\) is disabled/,
- 'skipReady 不能让 public set(user_stats) 写 stats'
- );
- assert.strictEqual(stats.totalPractices, 1, 'skipReady public set(user_stats) 不能改 canonical stats');
-
- await assert.rejects(
- () => harness.persistentStore.append('practice_records', { id: 'api-append', examId: 'reading-append' }),
- /Storage\.append\(practice_records\) is disabled/,
- 'public append(practice_records) 必须禁用'
- );
-
- await assert.rejects(
- () => harness.persistentStore.remove('practice_records', { skipReady: true }),
- /Storage\.remove\(practice_records\) is disabled/,
- 'skipReady 不能让 public remove(practice_records) 清空 records'
- );
- assert.strictEqual(savedRecords.length, 1, 'skipReady public remove(practice_records) 不能清空 canonical records');
-
- const callTypes = harness.calls.map((call) => call.type);
- assert(!callTypes.includes('api.replace'), 'public bypass options must not call PracticeRecordAPI.replace');
- assert(!callTypes.includes('api.clear'), 'public bypass options must not call PracticeRecordAPI.clear');
- assert(!callTypes.includes('api.writeStats'), 'public bypass options must not call PracticeRecordAPI.writeStats');
- assert(!callTypes.includes('api.resetStats'), 'public bypass options must not call PracticeRecordAPI.resetStats');
-}
-
-async function testCompressedRealDataKeepsOnlyCanonicalCorrectAnswerMap() {
- const harness = await createHarness();
- const compressed = harness.persistentStore.compressRealData({
- score: 1,
- totalQuestions: 2,
- accuracy: 0.5,
- percentage: 50,
- duration: 30,
- answers: { q1: 'A' },
- correctAnswerMap: { q1: 'A' },
- correctAnswers: { q1: 'B' },
- answerComparison: {
- q1: { userAnswer: 'A', correctAnswer: 'B', isCorrect: false }
- },
- isRealData: true,
- source: 'test'
- });
-
- assert.deepStrictEqual(compressed.correctAnswerMap, { q1: 'A' }, '压缩 realData 只能保留 canonical correctAnswerMap');
- assert.strictEqual(
- Object.prototype.hasOwnProperty.call(compressed, 'correctAnswers'),
- false,
- '压缩 realData 不能保留 legacy correctAnswers 对象'
- );
- assert.strictEqual(
- Object.prototype.hasOwnProperty.call(compressed.answerComparison.q1, 'correctAnswer'),
- false,
- '压缩 answerComparison 不能保留 correctAnswer 作为第二事实源'
- );
- assert.strictEqual(compressed.answerComparison.q1.isCorrect, false, '压缩 comparison 应保留已有正误显示结果');
-}
-
-async function testPublicStorageFacadeDoesNotFallbackToPracticeCore() {
- const harness = await createHarness();
- harness.window.PracticeCore = {
- store: {
- handlesStorageKey(key) {
- return key === 'practice_records' || key === 'user_stats';
- },
- async routeStorageSet() {
- throw new Error('public storage facade must not fallback to PracticeCore.store routeStorageSet');
- },
- async routeStorageRemove() {
- throw new Error('public storage facade must not fallback to PracticeCore.store routeStorageRemove');
- }
- }
- };
-
- await assert.rejects(
- () => harness.persistentStore.get('practice_records', []),
- /Storage\.get\(practice_records\): PracticeRecordAPI\.list not ready/,
- 'API 缺失时 public storage.get(practice_records) 必须失败,不能返回默认值或读 raw store'
- );
-
- await assert.rejects(
- () => harness.persistentStore.set('practice_records', [{ id: 'must-not-write' }]),
- /Storage\.set\(practice_records\) is disabled/,
- 'API 缺失时 public storage.set(practice_records) 必须失败'
- );
- assert.strictEqual(
- readRawPracticeRecords(harness).some((record) => record && record.id === 'must-not-write'),
- false,
- 'API 缺失时 public storage.set(practice_records) 不能写 raw store'
- );
-
- await assert.rejects(
- () => harness.persistentStore.remove('practice_records'),
- /Storage\.remove\(practice_records\) is disabled/,
- 'API 缺失时 public storage.remove(practice_records) 必须失败'
- );
-}
-
-async function testRestoreFromBackupFailsWithoutPracticeRecordAPI() {
- const harness = await createHarness();
- const result = await harness.persistentStore.restoreFromBackup();
-
- assert.strictEqual(result, false, '内置备份恢复缺少 PracticeRecordAPI 时应跳过失败');
- assert.strictEqual(
- readRawPracticeRecords(harness).some((record) => record && record.id === 'backup-record'),
- false,
- '内置备份恢复失败不能把备份记录写入 raw practice_records'
- );
-}
-
-async function main() {
- await testStorageDataSourceReadBypassesPublicPracticeRecordRedirect();
- await testRuntimeImportFailsWithoutPracticeRecordAPI();
- await testInternalStorageAccessIsNotWindowPublic();
- await testFullDataBootstrapHidesInternalPracticeRepositories();
- await testRuntimeImportUsesPracticeRecordAPI();
- await testPublicStorageFacadeReadsWithPracticeRecordAPIAndRejectsWrites();
- await testPublicBypassOptionsCannotWritePracticeData();
- await testCompressedRealDataKeepsOnlyCanonicalCorrectAnswerMap();
- await testPublicStorageFacadeDoesNotFallbackToPracticeCore();
- await testRestoreFromBackupFailsWithoutPracticeRecordAPI();
-
- process.stdout.write(JSON.stringify({
- status: 'pass',
- detail: 'StorageManager 运行期 records/stats 公开读只通过 PracticeRecordAPI,公开写入口 fail-fast'
- }));
-}
-
-main().catch((error) => {
- process.stdout.write(JSON.stringify({
- status: 'fail',
- detail: error && error.message ? error.message : String(error)
- }));
- process.exit(1);
-});
diff --git a/developer/tests/js/suiteInlineFallback.test.js b/developer/tests/js/suiteInlineFallback.test.js
index e2fdf51b..7bde504b 100644
--- a/developer/tests/js/suiteInlineFallback.test.js
+++ b/developer/tests/js/suiteInlineFallback.test.js
@@ -4,6 +4,7 @@ import fs from 'fs';
import vm from 'vm';
import assert from 'assert';
import { fileURLToPath } from 'url';
+import { webcrypto } from 'node:crypto';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -41,6 +42,7 @@ function createExamWindow(parentWindow) {
Date,
Array,
JSON,
+ URL,
});
scriptContext.globalThis = examWindow;
examWindow.window = examWindow;
@@ -78,6 +80,7 @@ function createExamWindow(parentWindow) {
querySelectorAll() {
return [buttonStub];
},
+ referrer: 'http://localhost/index.html',
readyState: 'complete',
defaultView: null
};
@@ -87,7 +90,7 @@ function createExamWindow(parentWindow) {
document: doc,
opener: parentWindow,
parent: parentWindow,
- location: { href: 'http://localhost/p1.html' },
+ location: { href: 'http://localhost/p1.html', protocol: 'http:' },
closed: false,
_messageListeners: messageListeners,
_messages: [],
@@ -101,7 +104,7 @@ function createExamWindow(parentWindow) {
postMessage(message) {
this._messages.push(message);
messageListeners.slice().forEach(listener => {
- listener({ data: message, source: parentWindow });
+ listener({ data: message, source: parentWindow, origin: 'http://localhost' });
});
},
focus() {},
@@ -135,6 +138,7 @@ async function main() {
location: { origin: 'http://localhost', href: 'http://localhost/index.html' },
screen: { availWidth: 1920, availHeight: 1080 },
document: { title: 'IELTS Practice' },
+ crypto: webcrypto,
postMessage(message) {
this._messages.push(message);
}
@@ -151,6 +155,8 @@ async function main() {
Date,
JSON,
Array,
+ URL,
+ Uint8Array,
};
sandbox.globalThis = sandbox.window;
@@ -191,6 +197,13 @@ async function main() {
app.suiteExamMap = new Map([[examId, suiteSessionId]]);
const examWindow = createExamWindow(parentWindow);
+ app.examWindows = new Map([[examId, {
+ window: examWindow,
+ expectedSessionId: `session-${examId}`,
+ expectedUrl: examWindow.location.href,
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false
+ }]]);
app.injectInlineScript(examWindow, examId);
const initMessage = examWindow._messages.find(msg => msg && msg.type === 'INIT_SESSION');
@@ -209,12 +222,32 @@ async function main() {
assert.strictEqual(examWindow._nativeCloseCalled, false, '窗口不应真正关闭');
assert.strictEqual(examWindow.closed, false, '窗口状态应保持开启');
- const navigateMessage = { type: 'SUITE_NAVIGATE', data: { url: 'http://localhost/p2.html', examId: 'reading-inline-2' } };
- examWindow._messageListeners.forEach(listener => listener({ data: navigateMessage }));
+ const navigateMessage = {
+ type: 'SUITE_NAVIGATE',
+ source: 'exam_host',
+ data: {
+ url: 'http://localhost/p2.html',
+ examId: 'reading-inline-2',
+ windowSessionToken: initMessage.data.windowSessionToken
+ }
+ };
+ examWindow._messageListeners.forEach(listener => listener({
+ data: navigateMessage,
+ source: parentWindow,
+ origin: 'http://localhost'
+ }));
assert.strictEqual(examWindow.location.href, 'http://localhost/p2.html', '应在标签页内导航至下一篇');
- const forceCloseMessage = { type: 'SUITE_FORCE_CLOSE', data: { suiteSessionId } };
- examWindow._messageListeners.forEach(listener => listener({ data: forceCloseMessage }));
+ const forceCloseMessage = {
+ type: 'SUITE_FORCE_CLOSE',
+ source: 'exam_host',
+ data: { suiteSessionId, windowSessionToken: initMessage.data.windowSessionToken }
+ };
+ examWindow._messageListeners.forEach(listener => listener({
+ data: forceCloseMessage,
+ source: parentWindow,
+ origin: 'http://localhost'
+ }));
assert.strictEqual(examWindow._nativeCloseCalled, true, '强制关闭应调用原生 close');
assert.strictEqual(examWindow.closed, true, '强制关闭后窗口应标记为关闭');
diff --git a/developer/tests/js/suiteModeFlow.test.js b/developer/tests/js/suiteModeFlow.test.js
index 3a9b70c1..6606fd4c 100755
--- a/developer/tests/js/suiteModeFlow.test.js
+++ b/developer/tests/js/suiteModeFlow.test.js
@@ -4,6 +4,7 @@ import fs from 'fs';
import vm from 'vm';
import assert from 'assert';
import { fileURLToPath } from 'url';
+import { webcrypto } from 'crypto';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -23,7 +24,7 @@ function createStubWindow(name) {
const stub = {
name,
closed: false,
- location: { href: 'about:blank' },
+ location: { href: 'http://localhost/exam.html' },
document: { title: '', addEventListener() {}, removeEventListener() {} },
focus() { this._focused = true; },
close() { this.closed = true; },
@@ -51,19 +52,8 @@ function createStubWindow(name) {
}
async function main() {
- const storageState = new Map();
const practiceRecords = [];
- const storage = {
- async get(key, fallback = undefined) {
- if (storageState.has(key)) {
- return deepClone(storageState.get(key));
- }
- return deepClone(fallback);
- },
- async set(key, value) {
- storageState.set(key, deepClone(value));
- }
- };
+ const windowSessions = new Map();
const documentStub = {
title: '',
@@ -74,6 +64,15 @@ async function main() {
dispatchEvent() { return true; },
createElement() { return { className: '', style: {} }; }
};
+ const navigatorStub = {
+ locks: {
+ request(name, options = {}, callback) {
+ assert.strictEqual(options.mode, 'exclusive');
+ assert.strictEqual(options.ifAvailable, true);
+ return Promise.resolve(callback({ name: String(name || ''), mode: 'exclusive' }));
+ }
+ }
+ };
const windowStub = {
_messages: [],
@@ -82,7 +81,9 @@ async function main() {
},
addEventListener() {},
removeEventListener() {},
- location: { href: 'http://localhost/' },
+ location: { href: 'http://localhost/', origin: 'http://localhost' },
+ navigator: navigatorStub,
+ crypto: webcrypto,
screen: { availWidth: 1920, availHeight: 1080 },
document: documentStub,
practicePageManager: {
@@ -97,59 +98,62 @@ async function main() {
}
};
- windowStub.storage = storage;
- windowStub.PracticeRecordAPI = {
- async list() {
- return deepClone(practiceRecords);
- },
- async saveRecord(record) {
- practiceRecords.unshift(deepClone(record));
- return deepClone(record);
- },
- async deleteMany(ids) {
- const targets = new Set((Array.isArray(ids) ? ids : []).map((id) => String(id)));
- let deleted = 0;
- for (let index = practiceRecords.length - 1; index >= 0; index -= 1) {
- const record = practiceRecords[index];
- if (record && targets.has(String(record.id || record.sessionId || ''))) {
- practiceRecords.splice(index, 1);
- deleted += 1;
+ windowStub.resolveActiveLibraryIndex = async () => deepClone(examIndex);
+ windowStub.AppData = {
+ ready: Promise.resolve(),
+ practice: {
+ async list() { return deepClone(practiceRecords); },
+ async getStats() { return { totalPractices: practiceRecords.length }; },
+ async finalizeSuite({ record, childSessionIds = [] }) {
+ const targets = new Set(childSessionIds.map(String));
+ for (let index = practiceRecords.length - 1; index >= 0; index -= 1) {
+ const current = practiceRecords[index];
+ if (targets.has(String(current && (current.id || current.sessionId) || ''))) {
+ practiceRecords.splice(index, 1);
+ }
}
+ const identity = String(record && (record.id || record.sessionId) || '');
+ const existing = practiceRecords.findIndex((item) => String(item && (item.id || item.sessionId) || '') === identity);
+ if (existing >= 0) practiceRecords[existing] = deepClone(record);
+ else practiceRecords.unshift(deepClone(record));
+ return { committed: true, operationId: `suite-${identity}`, record: deepClone(record), derived: { status: 'ready', pending: [] }, warnings: [] };
}
- return { deleted };
},
- async recalculateStats() {
- return { totalPractices: practiceRecords.length };
+ recovery: {
+ windowSession: {
+ save(name, value) { windowSessions.set(String(name), deepClone(value)); return true; },
+ get(name) { return deepClone(windowSessions.get(String(name)) || null); },
+ discard(name) { windowSessions.delete(String(name)); return true; }
+ },
+ async listDrafts() { return []; },
+ async listActiveSessions() { return []; },
+ async saveActiveSession() { return { committed: true }; },
+ async discardActiveSession() { return { committed: true }; },
+ async cleanupForRetry() { return { committed: true, removedCount: 0, removedByKind: {} }; }
}
};
windowStub.CustomEvent = function CustomEvent(type, init = {}) {
return { type, detail: init.detail || null };
};
- const sessionStorageStub = new Map();
- const sessionStorageObj = {
- getItem(key) { return sessionStorageStub.get(key) || null; },
- setItem(key, value) { sessionStorageStub.set(key, String(value)); },
- removeItem(key) { sessionStorageStub.delete(key); },
- clear() { sessionStorageStub.clear(); }
- };
-
const sandbox = {
window: windowStub,
- storage,
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
Math,
+ crypto: webcrypto,
+ URL,
document: documentStub,
CustomEvent: windowStub.CustomEvent
};
+ sandbox.navigator = navigatorStub;
sandbox.globalThis = sandbox.window;
- sandbox.window.sessionStorage = sessionStorageObj;
const context = vm.createContext(sandbox);
+ const createVmMap = vm.runInContext('() => new Map()', context);
loadScript('js/app/examSessionMixin.js', context);
loadScript('js/app/suitePracticeMixin.js', context);
@@ -184,10 +188,6 @@ async function main() {
}
];
- await storage.set('exam_index', examIndex);
- await storage.set('active_exam_index_key', 'exam_index');
- await storage.set('active_sessions', []);
-
const mixins = windowStub.ExamSystemAppMixins;
if (!mixins || !mixins.examSession || !mixins.suitePractice) {
throw new Error('未能加载所需的 mixin');
@@ -205,6 +205,7 @@ async function main() {
};
Object.assign(app, mixins.examSession, mixins.suitePractice);
+ app.examWindows = createVmMap();
app.ensureExamWindowSession = function ensureExamWindowSession(examId, win) {
if (!this.examWindows) {
@@ -223,9 +224,42 @@ async function main() {
};
app.setupExamWindowManagement = function setupExamWindowManagement() {};
+ let registrationGeneration = 0;
+ const installManagedTestWindow = (targetApp, examId, targetWindow, options = {}) => {
+ registrationGeneration += 1;
+ const expectedSessionId = targetApp.generateSessionId(examId);
+ const windowInfo = {
+ examId,
+ window: targetWindow,
+ navigationOwnership: options.launchOwnership || null,
+ suiteSessionId: options.suiteSessionId || targetApp.currentSuiteSession && targetApp.currentSuiteSession.id || null,
+ expectedSessionId,
+ windowSessionToken: `flow-token-${registrationGeneration}`,
+ windowSessionTokenSessionId: expectedSessionId,
+ sessionGeneration: registrationGeneration,
+ status: 'active'
+ };
+ targetApp.examWindows.set(examId, windowInfo);
+ if (options.launchOwnership && typeof targetApp._recordExamLaunchRegistrationReceipt === 'function') {
+ targetApp._recordExamLaunchRegistrationReceipt(
+ examId,
+ options.launchOwnership,
+ targetApp._captureExamSessionRegistration(examId, windowInfo)
+ );
+ }
+ if (options.launchOwnership && typeof targetApp._commitExamLaunchOwnership === 'function') {
+ assert.strictEqual(targetApp._commitExamLaunchOwnership(options.launchOwnership), true);
+ }
+ return targetWindow;
+ };
+
const windowsMap = new Map();
const openCalls = [];
let openAttempt = 0;
+ app._postExamMessage = (examId, targetWindow, type, data = {}) => {
+ targetWindow.postMessage({ type, data: { ...data, examId } }, 'http://localhost');
+ return true;
+ };
app.openExam = async function openExamStub(examId, options = {}) {
openAttempt += 1;
@@ -242,6 +276,10 @@ async function main() {
targetWindow = createStubWindow(name);
windowsMap.set(name, targetWindow);
}
+ if (options.launchOwnership
+ && !this._claimExamLaunchWindowOwnership(options.launchOwnership, targetWindow)) {
+ return null;
+ }
targetWindow.lastExamId = examId;
if (typeof this.startPracticeSession === 'function') {
@@ -254,11 +292,15 @@ async function main() {
this.setupExamWindowManagement(targetWindow, examId);
}
- return targetWindow;
+ return installManagedTestWindow(this, examId, targetWindow, options);
}
if (openAttempt === 2) {
assert(reuseWindow, '第二次调用应提供复用窗口');
+ if (options.launchOwnership
+ && !this._claimExamLaunchWindowOwnership(options.launchOwnership, reuseWindow)) {
+ return null;
+ }
reuseWindow.lastExamId = examId;
if (typeof this.startPracticeSession === 'function') {
@@ -271,7 +313,7 @@ async function main() {
this.setupExamWindowManagement(reuseWindow, examId);
}
- return reuseWindow;
+ return installManagedTestWindow(this, examId, reuseWindow, options);
}
if (openAttempt === 3) {
@@ -283,6 +325,10 @@ async function main() {
const newWindow = createStubWindow(name);
newWindow.lastExamId = examId;
windowsMap.set(name, newWindow);
+ if (options.launchOwnership
+ && !this._claimExamLaunchWindowOwnership(options.launchOwnership, newWindow)) {
+ return null;
+ }
if (typeof this.startPracticeSession === 'function') {
await this.startPracticeSession(examId);
@@ -294,7 +340,7 @@ async function main() {
this.setupExamWindowManagement(newWindow, examId);
}
- return newWindow;
+ return installManagedTestWindow(this, examId, newWindow, options);
}
return null;
@@ -346,7 +392,7 @@ async function main() {
assert.strictEqual(handledP3, true, 'P3 完成后应顺利收尾');
assert.strictEqual(app.currentSuiteSession, null, '套题会话应在完成后被清理');
- const savedPracticeRecords = await windowStub.PracticeRecordAPI.list();
+ const savedPracticeRecords = await windowStub.AppData.practice.list();
assert.strictEqual(savedPracticeRecords.length, 1, '应只生成一条套题练习记录');
assert.strictEqual(savedPracticeRecords[0].suiteEntries.length, 3, '套题记录应包含三篇文章');
@@ -365,6 +411,7 @@ async function main() {
_updatePracticeRecordsState: async () => {}
};
Object.assign(appSim, mixins.examSession, mixins.suitePractice);
+ appSim.examWindows = createVmMap();
appSim.ensureExamWindowSession = app.ensureExamWindowSession;
appSim.injectDataCollectionScript = app.injectDataCollectionScript;
appSim.setupExamWindowManagement = app.setupExamWindowManagement;
@@ -373,12 +420,18 @@ async function main() {
appSim.openExam = async function(examId, options = {}) {
simOpenCalls.push({ examId, options: { ...options } });
const name = options.windowName && options.windowName.trim() ? options.windowName.trim() : '_blank';
- const win = createStubWindow(name);
+ const win = options.reuseWindow && !options.reuseWindow.closed
+ ? options.reuseWindow
+ : createStubWindow(name);
+ if (options.launchOwnership
+ && !this._claimExamLaunchWindowOwnership(options.launchOwnership, win)) {
+ return null;
+ }
win.lastExamId = examId;
- return win;
+ return installManagedTestWindow(this, examId, win, options);
};
- sessionStorageStub.clear();
+ windowStub.AppData.recovery.windowSession.discard('simulation');
await appSim.startSuitePractice({ flowMode: 'simulation' });
const simSession = appSim.currentSuiteSession;
assert(simSession, '模拟会话应被创建');
@@ -393,10 +446,9 @@ async function main() {
assert.strictEqual(simSession.currentIndex, 1, '应前进到第二篇');
assert(simSession.draftsByExam[simP1.examId], 'P1 draft 应被保存');
- // 验证 sessionStorage 镜像
- const stored = sessionStorageStub.get('ielts_sim_session');
- assert(stored, 'sessionStorage 应包含会话镜像');
- const snapshot = JSON.parse(stored);
+ // 验证窗口级 recovery 领域镜像
+ const snapshot = windowStub.AppData.recovery.windowSession.get('simulation');
+ assert(snapshot, 'recovery.windowSession 应包含会话镜像');
assert.strictEqual(snapshot.id, simSession.id, '镜像 id 应匹配');
assert.strictEqual(snapshot.currentIndex, 1, '镜像 currentIndex 应为 1');
@@ -410,7 +462,7 @@ async function main() {
const simNavOob = await appSim._handleSimulationNavigate(simP1.examId, { direction: 'prev' }, simSession.windowRef);
assert.strictEqual(simNavOob, false, 'P1 向前导航应失败');
- process.stdout.write(JSON.stringify({ status: 'pass', detail: '模拟模式按顺序串联三篇题目并生成单条记录,导航与 sessionStorage 镜像正常' }));
+ process.stdout.write(JSON.stringify({ status: 'pass', detail: '模拟模式按顺序串联三篇题目并生成单条记录,导航与 recovery.windowSession 镜像正常' }));
}
main().catch(error => {
diff --git a/developer/tests/js/suiteModeRegression.test.js b/developer/tests/js/suiteModeRegression.test.js
index ae532678..c3128f13 100644
--- a/developer/tests/js/suiteModeRegression.test.js
+++ b/developer/tests/js/suiteModeRegression.test.js
@@ -4,6 +4,7 @@ import fs from 'fs';
import vm from 'vm';
import assert from 'assert';
import { fileURLToPath } from 'url';
+import { webcrypto } from 'node:crypto';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -19,7 +20,7 @@ function createStubWindow(name) {
return {
name,
closed: false,
- location: { href: 'about:blank' },
+ location: { href: 'http://localhost/exam.html' },
_messages: [],
postMessage(payload) {
this._messages.push(payload);
@@ -28,24 +29,27 @@ function createStubWindow(name) {
};
}
-function createSandbox() {
- const storageStub = {
- _data: new Map(),
- async get(key, fallback = null) {
- return this._data.has(key) ? this._data.get(key) : fallback;
- },
- async set(key, value) {
- this._data.set(key, value);
- return true;
- }
+function createSandbox(options = {}) {
+ const cloneValue = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
+ const windowSessionStore = new Map();
+ let activeSessions = [];
+ let practiceRecords = [];
+ const recoveryControl = {
+ saveQueue: [],
+ cleanupQueue: [],
+ events: []
};
-
- const sessionStorageStub = new Map();
- const sessionStorageObj = {
- getItem(key) { return sessionStorageStub.get(key) || null; },
- setItem(key, value) { sessionStorageStub.set(key, String(value)); },
- removeItem(key) { sessionStorageStub.delete(key); },
- clear() { sessionStorageStub.clear(); }
+ const navigatorStub = {
+ locks: {
+ async request(name, lockOptions = {}, callback) {
+ assert.strictEqual(lockOptions.mode, 'exclusive');
+ assert.strictEqual(lockOptions.ifAvailable, true);
+ const normalizedName = String(name || '');
+ // Each createApp fixture represents the sole realm under test. The callback's
+ // returned promise controls how long the exclusive lease remains held.
+ return await callback({ name: normalizedName, mode: 'exclusive' });
+ }
+ }
};
const documentStub = {
@@ -82,17 +86,100 @@ function createSandbox() {
track(listenerStats.removed, type);
},
showMessage() {},
+ AppData: {
+ ready: Promise.resolve(),
+ recovery: {
+ async listActiveSessions() {
+ return cloneValue(activeSessions);
+ },
+ async saveActiveSession(value, options = {}) {
+ recoveryControl.events.push({ type: 'save', value: cloneValue(value), options: cloneValue(options) });
+ const behavior = recoveryControl.saveQueue.length ? recoveryControl.saveQueue.shift() : true;
+ if (behavior instanceof Error) throw behavior;
+ if (typeof behavior === 'function') return behavior(value, options);
+ if (behavior === false) return { committed: false };
+ const id = String(value && value.id || '');
+ const index = activeSessions.findIndex((item) => String(item && item.id || '') === id);
+ if (index >= 0) activeSessions[index] = cloneValue(value);
+ else activeSessions.push(cloneValue(value));
+ return { committed: true, item: cloneValue(value) };
+ },
+ async discardActiveSession(id) {
+ recoveryControl.events.push({ type: 'discard', id: String(id) });
+ activeSessions = activeSessions.filter((item) => String(item && item.id || '') !== String(id));
+ return { committed: true };
+ },
+ async cleanupForRetry(options = {}) {
+ recoveryControl.events.push({ type: 'cleanup', options: cloneValue(options) });
+ const behavior = recoveryControl.cleanupQueue.length ? recoveryControl.cleanupQueue.shift() : true;
+ if (behavior instanceof Error) throw behavior;
+ return { committed: behavior !== false, removedCount: behavior === false ? 0 : 1, removedByKind: {} };
+ },
+ windowSession: {
+ save(kind, value) {
+ windowSessionStore.set(String(kind), cloneValue(value));
+ return true;
+ },
+ get(kind) {
+ return cloneValue(windowSessionStore.get(String(kind)) || null);
+ },
+ discard(kind) {
+ windowSessionStore.delete(String(kind));
+ return true;
+ }
+ }
+ },
+ practice: {
+ async list() {
+ return cloneValue(practiceRecords);
+ },
+ async get(recordId) {
+ const target = String(recordId || '');
+ const record = practiceRecords.find((item) => item && (
+ String(item.id || '') === target || String(item.sessionId || '') === target
+ ));
+ return cloneValue(record || null);
+ },
+ async getStats() {
+ return {};
+ },
+ async completeAttempt(command = {}) {
+ const record = cloneValue(command.record || {});
+ practiceRecords.push(record);
+ return { committed: true, record };
+ },
+ async finalizeSuite(command = {}) {
+ const record = cloneValue(command.record || {});
+ practiceRecords = [record];
+ return { committed: true, record };
+ }
+ },
+ preferences: {
+ async patchSuite() {
+ return { committed: true };
+ }
+ }
+ },
+ async resolveExamForPracticeRecord(record) {
+ const examId = String(record && record.examId || '');
+ return examId ? { id: examId, title: record.title || examId, type: 'reading', path: 'Reading/' + examId + '/' } : null;
+ },
+ async resolveActiveLibraryIndex() {
+ return ['reading-p1', 'reading-p2', 'reading-p3'].map((id) => ({
+ id,
+ title: id,
+ type: 'reading',
+ path: 'Reading/' + id + '/'
+ }));
+ },
CustomEvent: function CustomEvent(type, init = {}) {
return { type, detail: init.detail || null };
},
- location: { origin: 'http://localhost', href: 'http://localhost/' },
- localStorage: {
- _data: new Map(),
- getItem(key) { return this._data.has(key) ? this._data.get(key) : null; },
- setItem(key, value) { this._data.set(key, String(value)); },
- removeItem(key) { this._data.delete(key); }
- },
- sessionStorage: sessionStorageObj,
+ location: options.protocol === 'file:'
+ ? { protocol: 'file:', origin: 'null', href: 'file:///index.html' }
+ : { protocol: 'http:', origin: 'http://localhost', href: 'http://localhost/' },
+ crypto: webcrypto,
+ navigator: navigatorStub,
practiceConfig: { suite: {} },
__listenerCount(type) {
if (!listenerRegistry.has(type)) return 0;
@@ -100,11 +187,14 @@ function createSandbox() {
},
__listenerStats: listenerStats
};
+ windowStub.__dispatchEvent = (type, event) => {
+ const listeners = listenerRegistry.has(type) ? Array.from(listenerRegistry.get(type)) : [];
+ listeners.forEach((listener) => listener(event));
+ };
const sandbox = {
window: windowStub,
document: documentStub,
- storage: storageStub,
console,
setTimeout,
clearTimeout,
@@ -113,15 +203,15 @@ function createSandbox() {
Math,
CustomEvent: windowStub.CustomEvent,
URL,
- URLSearchParams
+ URLSearchParams,
+ Uint8Array
};
- windowStub.storage = storageStub;
+ sandbox.navigator = navigatorStub;
sandbox.globalThis = sandbox.window;
- sandbox.window.storage = storageStub;
- return { sandbox, windowStub, sessionStorageStub };
+ return { sandbox, windowStub, windowSessionStore, recoveryControl };
}
-function createApp(windowStub) {
+function createApp(windowStub, options = {}) {
const app = {
components: {},
setState() {},
@@ -133,9 +223,84 @@ function createApp(windowStub) {
saveRealPracticeData: async () => {}
};
Object.assign(app, windowStub.ExamSystemAppMixins.examSession, windowStub.ExamSystemAppMixins.suitePractice);
+ app._createSuiteTestMap = typeof windowStub.__createSuiteTestMap === 'function'
+ ? windowStub.__createSuiteTestMap
+ : () => new Map();
+ if (options.suiteModeReady !== false) {
+ app._suiteModeReady = true;
+ app.currentSuiteSession = null;
+ app.suiteExamMap = app._createSuiteTestMap();
+ app.multiSuiteSessionsMap = app._createSuiteTestMap();
+ app._multiSuiteCompletionTails = app._createSuiteTestMap();
+ app._suiteSessionGeneration = 0;
+ app._suiteRecoveryReady = Promise.resolve(null);
+ }
return app;
}
+function installManagedTestWindow(app, examId, targetWindow, options = {}) {
+ if (!targetWindow || targetWindow.closed) return targetWindow;
+ const managedMap = typeof app._createSuiteTestMap === 'function'
+ ? app._createSuiteTestMap()
+ : new Map();
+ if (!app.examWindows || app.examWindows.constructor !== managedMap.constructor) {
+ if (app.examWindows && typeof app.examWindows.entries === 'function') {
+ for (const [key, value] of app.examWindows.entries()) managedMap.set(key, value);
+ }
+ app.examWindows = managedMap;
+ }
+ const previous = app.examWindows.get(examId);
+ const generation = Math.max(0, Number(previous && previous.sessionGeneration) || 0) + 1;
+ const expectedSessionId = `test-session:${String(examId)}:${generation}`;
+ const windowSessionToken = `test-token:${String(examId)}:${generation}`;
+ const info = {
+ window: targetWindow,
+ navigationOwnership: { examId: String(examId), generation },
+ suiteSessionId: options.suiteSessionId || (app.currentSuiteSession && app.currentSuiteSession.id) || null,
+ expectedSessionId,
+ windowSessionToken,
+ windowSessionTokenSessionId: expectedSessionId,
+ expectedUrl: 'http://localhost/exam.html',
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false,
+ sessionGeneration: generation,
+ status: 'active'
+ };
+ app.examWindows.set(examId, info);
+ if (options.launchOwnership && typeof app._recordExamLaunchRegistrationReceipt === 'function') {
+ app._recordExamLaunchRegistrationReceipt(
+ examId,
+ options.launchOwnership,
+ app._captureExamSessionRegistration(examId, info)
+ );
+ }
+ if (options.launchOwnership && typeof app._commitExamLaunchOwnership === 'function') {
+ assert.strictEqual(app._commitExamLaunchOwnership(options.launchOwnership), true);
+ }
+ return targetWindow;
+}
+
+function buildOwnedStartResult(app, examId, value = true, launchOwnership = null) {
+ const windowInfo = app.examWindows && app.examWindows.get(examId);
+ return app._buildPracticeSessionOwnedSuccess(
+ examId,
+ 'test',
+ windowInfo && windowInfo.expectedSessionId,
+ value,
+ windowInfo,
+ launchOwnership
+ );
+}
+
+async function restoreOwnedMultiSuiteItems(app, windowSession, items) {
+ assert.strictEqual(
+ await app._acquireMultiSuiteRecoveryOwnership(windowSession),
+ true,
+ 'multi-suite restore fixture must own the base and exact recovery leases'
+ );
+ return await app._restorePersistentMultiSuiteSessions(items, [windowSession]);
+}
+
function plain(value) {
return JSON.parse(JSON.stringify(value));
}
@@ -165,16 +330,520 @@ function makeSession(sessionId = 'suite_test_1') {
}
async function run() {
- const { sandbox, windowStub, sessionStorageStub } = createSandbox();
+ const { sandbox, windowStub, windowSessionStore, recoveryControl } = createSandbox();
const context = vm.createContext(sandbox);
loadScript('js/app/examSessionMixin.js', context);
loadScript('js/app/suitePracticeMixin.js', context);
+ windowStub.__createSuiteTestMap = vm.runInContext('() => new Map()', context);
if (!windowStub.ExamSystemAppMixins || !windowStub.ExamSystemAppMixins.suitePractice) {
throw new Error('mixin 加载失败');
}
+ // Case 0.0: 套题必须先提交 durable v2 recovery;配额错误只清 recovery 后重试一次。
+ {
+ const app = createApp(windowStub);
+ const quotaError = new Error('quota');
+ quotaError.code = 'QUOTA_EXCEEDED';
+ recoveryControl.saveQueue.push(quotaError, true);
+ recoveryControl.events.length = 0;
+ let openCount = 0;
+ app.openExam = async (examId, openOptions = {}) => {
+ openCount += 1;
+ assert.deepStrictEqual(
+ recoveryControl.events.map((event) => event.type),
+ ['save', 'cleanup', 'save'],
+ '首题窗口只能在清理并确认 durable recovery 后打开'
+ );
+ const targetWindow = installManagedTestWindow(app, examId, createStubWindow('suite-window'), openOptions);
+ const targetRegistration = app._captureSuiteNavigationRegistration(
+ examId,
+ targetWindow,
+ openOptions.suiteSessionId,
+ openOptions.launchOwnership
+ );
+ assert(targetRegistration, 'successful open fixture must install an exact managed registration');
+ assert.strictEqual(
+ app._isSuiteNavigationRegistrationCurrent(examId, targetRegistration, openOptions.suiteSessionId),
+ true
+ );
+ assert.strictEqual(
+ app._isSuiteExamLaunchOwnershipCurrent(examId, openOptions.launchOwnership, targetWindow),
+ false,
+ 'open commit must release the reservation before returning to the suite caller'
+ );
+ return targetWindow;
+ };
+ assert.strictEqual(
+ await app._launchSuiteSessionFromSequence(makeSession('suite_start_retry').sequence, { flowMode: 'simulation' }),
+ true,
+ '配额清理后的单次重试成功时应启动套题'
+ );
+ assert.strictEqual(openCount, 1, 'durable commit 成功后只能打开一次首题');
+ const cleanupEvent = recoveryControl.events.find((event) => event.type === 'cleanup');
+ assert.deepStrictEqual(
+ plain(cleanupEvent.options.preserve.activeSession),
+ [app.currentSuiteSession.id],
+ '清理必须保护当前套题 recovery'
+ );
+ }
+
+ // Case 0.0.1: cleanup 后仍失败或存储被拒绝时,套题不得 fail-open。
+ for (const failureMode of ['quota', 'backend']) {
+ const app = createApp(windowStub);
+ const firstError = new Error(failureMode);
+ firstError.code = failureMode === 'quota' ? 'QUOTA_EXCEEDED' : 'BACKEND_UNAVAILABLE';
+ recoveryControl.events.length = 0;
+ recoveryControl.saveQueue.push(firstError);
+ if (failureMode === 'quota') {
+ const retryError = new Error('quota-retry');
+ retryError.code = 'QUOTA_EXCEEDED';
+ recoveryControl.saveQueue.push(retryError);
+ }
+ let openCount = 0;
+ app.openExam = async () => {
+ openCount += 1;
+ return createStubWindow('must-not-open');
+ };
+ assert.strictEqual(
+ await app._launchSuiteSessionFromSequence(makeSession(`suite_start_${failureMode}`).sequence, { flowMode: 'simulation' }),
+ false,
+ `${failureMode} 持久化失败必须阻止启动`
+ );
+ assert.strictEqual(openCount, 0, '未确认 recovery 时不得打开首题');
+ assert.strictEqual(app.currentSuiteSession, null, '未启动会话不得留在内存中伪装成可恢复状态');
+ assert.strictEqual(
+ recoveryControl.events.filter((event) => event.type === 'cleanup').length,
+ failureMode === 'quota' ? 1 : 0,
+ '只有 quota 错误允许触发 recovery cleanup'
+ );
+ }
+
+ // Case 0.0.1a: 刷新后的同名存活题页必须旋转 token 重新绑定,不得导航或重载。
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_live_rebind');
+ session.windowRef = null;
+ session.windowBinding = {
+ examId: 'reading-p1',
+ expectedSessionId: 'reading-p1-session',
+ windowSessionToken: 'old-window-token',
+ sessionGeneration: 4,
+ expectedUrl: 'http://localhost/exam.html?examId=reading-p1',
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false
+ };
+ session.currentIndex = 1;
+ session.activeExamId = 'reading-p2';
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id]));
+ assert.strictEqual(
+ app._buildSuiteWindowBinding(session).examId,
+ 'reading-p1',
+ 'proof 前的 fallback binding 必须保留凭据来源篇章'
+ );
+ const liveChild = createStubWindow('ielts-suite-mode-tab');
+ liveChild.location.href = session.windowBinding.expectedUrl;
+ liveChild.addEventListener = () => {};
+ const originalPostMessage = liveChild.postMessage.bind(liveChild);
+ liveChild.postMessage = (payload, targetOrigin) => {
+ originalPostMessage(payload, targetOrigin);
+ if (payload && payload.type === 'SUITE_REBIND_CHALLENGE') {
+ windowStub.__dispatchEvent('message', {
+ source: liveChild,
+ origin: 'http://localhost',
+ data: {
+ type: 'SUITE_REBIND_PROOF',
+ source: 'practice_page',
+ data: {
+ challenge: payload.data.challenge,
+ suiteSessionId: session.id,
+ examId: 'reading-p2',
+ sessionId: 'reading-p1-session',
+ windowSessionToken: 'old-window-token',
+ windowSessionGeneration: 4
+ }
+ }
+ });
+ }
+ };
+ const originalOpen = windowStub.open;
+ const observedOpenUrls = [];
+ windowStub.open = (url, name) => {
+ observedOpenUrls.push({ url, name });
+ return liveChild;
+ };
+ const oldNavigationOwnership = app._recordExamWindowNavigation(liveChild, 'reading-p2');
+ const oldSameSuiteInfo = {
+ examId: 'reading-p2',
+ window: liveChild,
+ navigationOwnership: oldNavigationOwnership,
+ suiteSessionId: session.id,
+ expectedSessionId: 'reading-p1-session',
+ windowSessionToken: 'old-window-token',
+ windowSessionTokenSessionId: 'reading-p1-session',
+ sessionGeneration: 4,
+ expectedUrl: session.windowBinding.expectedUrl,
+ expectedOrigin: session.windowBinding.expectedOrigin,
+ allowOpaqueOrigin: false,
+ status: 'active'
+ };
+ app.examWindows = app._createSuiteTestMap();
+ app.examWindows.set('reading-p2', oldSameSuiteInfo);
+ assert.strictEqual(
+ app._buildSuiteWindowBinding(session).windowSessionToken,
+ 'old-window-token',
+ 'the fixture must expose the stale same-suite registration that used to overwrite nextBinding'
+ );
+ let durableBindingBeforeSetup = null;
+ let durableWindowNameBeforeSetup = null;
+ const originalSetup = app.setupExamWindowManagement.bind(app);
+ app.setupExamWindowManagement = (...args) => {
+ const saves = recoveryControl.events.filter((event) => event.type === 'save'
+ && event.value && String(event.value.id) === String(session.id));
+ const latestSave = saves[saves.length - 1];
+ durableBindingBeforeSetup = latestSave && plain(latestSave.value.windowBinding);
+ durableWindowNameBeforeSetup = latestSave && latestSave.value.windowName;
+ return originalSetup(...args);
+ };
+ try {
+ const rebound = await app._tryRebindSuiteWindow(session, session.sequence[1]);
+ assert.strictEqual(rebound.window, liveChild, '应复用同一 WindowProxy');
+ assert.deepStrictEqual(observedOpenUrls, [{ url: '', name: 'ielts-suite-mode-tab' }]);
+ assert.strictEqual(liveChild.location.href, session.windowBinding.expectedUrl, '重绑定不得改写题页 URL');
+ const info = app.examWindows.get('reading-p2');
+ assert.strictEqual(info.expectedSessionId, 'reading-p1-session', '重绑定必须保留练习 session 身份');
+ assert.strictEqual(info.sessionGeneration, 5, '重绑定 generation 必须严格递增');
+ assert.notStrictEqual(info.windowSessionToken, 'old-window-token', '重绑定必须旋转 token');
+ assert.strictEqual(session.windowBinding.examId, 'reading-p2', 'proof 成功后才能接管实际活动篇章');
+ assert.deepStrictEqual(durableBindingBeforeSetup, plain(session.windowBinding));
+ assert.strictEqual(durableBindingBeforeSetup.examId, 'reading-p2');
+ assert.strictEqual(durableBindingBeforeSetup.expectedSessionId, 'reading-p1-session');
+ assert.strictEqual(durableBindingBeforeSetup.sessionGeneration, 5);
+ assert.notStrictEqual(durableBindingBeforeSetup.windowSessionToken, 'old-window-token');
+ assert.strictEqual(durableWindowNameBeforeSetup, session.windowName);
+ } finally {
+ windowStub.open = originalOpen;
+ const info = app.examWindows && app.examWindows.get('reading-p2');
+ if (info && info.closeMonitor) clearInterval(info.closeMonitor);
+ if (app._handshakeTimers) {
+ for (const timer of app._handshakeTimers.values()) clearInterval(timer);
+ app._handshakeTimers.clear();
+ }
+ }
+ }
+
+ // Case 0.0.1aa: 首个 INIT 之前必须已有包含 window binding 的 durable v2 snapshot。
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_pre_init_binding');
+ session.windowRef = null;
+ session._suiteGeneration = 1;
+ session._lastDurableRecoveryRevision = 0;
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id]));
+ assert.strictEqual(await app._commitSuiteRecovery(session, { reason: 'pre-init-base' }), true);
+ const child = createStubWindow('ielts-suite-mode-tab');
+ child.addEventListener = () => {};
+ let firstInitDurableSnapshot = null;
+ const originalPostMessage = child.postMessage.bind(child);
+ child.postMessage = (payload) => {
+ if (!firstInitDurableSnapshot && payload && String(payload.type || '').toUpperCase() === 'INIT_SESSION') {
+ const saves = recoveryControl.events.filter((event) => event.type === 'save');
+ firstInitDurableSnapshot = saves.length ? plain(saves[saves.length - 1].value) : null;
+ }
+ originalPostMessage(payload);
+ };
+ app.resolveReadingLaunchDescriptor = () => ({
+ mode: 'unified_html',
+ url: 'http://localhost/exam.html?examId=reading-p1'
+ });
+ app.openExamWindow = () => child;
+ app._guardExamWindowContent = (targetWindow) => targetWindow;
+ app._captureLaunchLibraryConfigurationId = async () => null;
+ app.startPracticeSession = async (examId, startOptions = {}) => buildOwnedStartResult(
+ app,
+ examId,
+ 'reading-p1-session',
+ startOptions.launchOwnership
+ );
+ app.injectDataCollectionScript = () => {};
+ const opened = await app.openExam('reading-p1', {
+ examDefinition: session.sequence[0].exam,
+ target: 'tab',
+ windowName: session.windowName,
+ suiteSessionId: session.id,
+ suiteFlowMode: 'simulation',
+ suiteTimerMode: 'countdown',
+ suiteTimerLimitSeconds: 3600,
+ sequenceIndex: 0,
+ sequenceTotal: session.sequence.length
+ });
+ assert.strictEqual(opened, child);
+ assert(firstInitDurableSnapshot && firstInitDurableSnapshot.windowBinding, 'first INIT must observe a durable window binding');
+ const firstInit = child._messages.find((message) => message && String(message.type || '').toUpperCase() === 'INIT_SESSION');
+ assert(firstInit, 'suite window must receive INIT after the checkpoint');
+ assert.strictEqual(firstInitDurableSnapshot.windowBinding.expectedSessionId, firstInit.data.sessionId);
+ assert.strictEqual(firstInitDurableSnapshot.windowBinding.windowSessionToken, firstInit.data.windowSessionToken);
+ const info = app.examWindows && app.examWindows.get('reading-p1');
+ if (info && info.closeMonitor) clearInterval(info.closeMonitor);
+ if (app._handshakeTimers) {
+ for (const timer of app._handshakeTimers.values()) clearInterval(timer);
+ app._handshakeTimers.clear();
+ }
+ }
+
+ // Case 0.0.1ab: an async sender from a replaced registration must not restore the old map entry.
+ {
+ const app = createApp(windowStub);
+ const oldWindow = createStubWindow('old-registration');
+ const newWindow = createStubWindow('new-registration');
+ const oldInfo = app.ensureExamWindowSession('reading-p1', oldWindow);
+ let releaseOldDraft;
+ app.getReadingDraftForExam = async () => new Promise((resolve) => { releaseOldDraft = resolve; });
+ const staleSend = app._sendExamInitEnvelope('reading-p1', oldWindow);
+ await Promise.resolve();
+ const newInfo = {
+ ...oldInfo,
+ window: newWindow,
+ expectedSessionId: 'reading-p1-new-session',
+ windowSessionToken: 'reading-p1-new-token',
+ windowSessionTokenSessionId: 'reading-p1-new-session',
+ sessionGeneration: Number(oldInfo.sessionGeneration) + 1,
+ initEnvelopeEpoch: 0
+ };
+ app.examWindows.set('reading-p1', newInfo);
+ releaseOldDraft({ sessionId: oldInfo.expectedSessionId, updatedAt: 1, answers: { q1: 'OLD' } });
+ assert.strictEqual(await staleSend, null);
+ assert.strictEqual(app.examWindows.get('reading-p1'), newInfo, 'stale sender must not write its old registration back');
+ assert.strictEqual(oldWindow._messages.length, 0, 'stale registration must not receive INIT');
+ }
+
+ // Case 0.0.1ac: within one registration, only the latest async draft sender may emit INIT.
+ {
+ const app = createApp(windowStub);
+ const child = createStubWindow('same-registration');
+ const info = app.ensureExamWindowSession('reading-p1', child);
+ let callCount = 0;
+ let releaseSlowDraft;
+ app.getReadingDraftForExam = async () => {
+ callCount += 1;
+ if (callCount === 1) return new Promise((resolve) => { releaseSlowDraft = resolve; });
+ return { sessionId: info.expectedSessionId, updatedAt: 20, answers: { q1: 'NEW' } };
+ };
+ const slowSend = app._sendExamInitEnvelope('reading-p1', child);
+ await Promise.resolve();
+ const fastPayload = await app._sendExamInitEnvelope('reading-p1', child);
+ assert.strictEqual(fastPayload.draft.answers.q1, 'NEW');
+ releaseSlowDraft({ sessionId: info.expectedSessionId, updatedAt: 10, answers: { q1: 'OLD' } });
+ assert.strictEqual(await slowSend, null);
+ assert.strictEqual(child._messages.length, 2, 'only the latest sender should emit the two INIT aliases');
+ assert(child._messages.every((message) => message.data.draft.answers.q1 === 'NEW'));
+ }
+
+ // Case 0.0.1b: file:// 无法读取子窗口 URL 时,完整持久 binding 仍应允许无导航重绑。
+ {
+ const fileHarness = createSandbox({ protocol: 'file:' });
+ const fileContext = vm.createContext(fileHarness.sandbox);
+ loadScript('js/app/examSessionMixin.js', fileContext);
+ loadScript('js/app/suitePracticeMixin.js', fileContext);
+ const fileApp = createApp(fileHarness.windowStub);
+ const session = makeSession('suite_file_live_rebind');
+ session.windowRef = null;
+ session.windowBinding = {
+ examId: 'reading-p1',
+ expectedSessionId: 'reading-p1-file-session',
+ windowSessionToken: 'old-file-token',
+ sessionGeneration: 7,
+ expectedUrl: 'file:///reading-practice-unified.html?examId=reading-p1',
+ expectedOrigin: 'file://',
+ allowOpaqueOrigin: true
+ };
+ fileApp.currentSuiteSession = session;
+ fileApp.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id]));
+ const liveChild = createStubWindow('ielts-suite-mode-tab');
+ Object.defineProperty(liveChild, 'location', {
+ configurable: true,
+ get() {
+ const error = new Error('opaque file origin');
+ error.name = 'SecurityError';
+ throw error;
+ }
+ });
+ const observedOpenUrls = [];
+ const originalPostMessage = liveChild.postMessage.bind(liveChild);
+ liveChild.postMessage = (payload, targetOrigin) => {
+ originalPostMessage(payload, targetOrigin);
+ if (payload && payload.type === 'SUITE_REBIND_CHALLENGE') {
+ fileHarness.windowStub.__dispatchEvent('message', {
+ source: liveChild,
+ origin: 'null',
+ data: {
+ type: 'SUITE_REBIND_PROOF',
+ source: 'practice_page',
+ data: {
+ challenge: payload.data.challenge,
+ suiteSessionId: session.id,
+ examId: 'reading-p1',
+ sessionId: 'reading-p1-file-session',
+ windowSessionToken: 'old-file-token',
+ windowSessionGeneration: 7
+ }
+ }
+ });
+ }
+ };
+ fileHarness.windowStub.open = (url, name) => {
+ observedOpenUrls.push({ url, name });
+ return liveChild;
+ };
+ const rebound = await fileApp._tryRebindSuiteWindow(session, session.sequence[0]);
+ assert.strictEqual(rebound.window, liveChild, 'file:// must reuse the surviving named WindowProxy');
+ assert.deepStrictEqual(observedOpenUrls, [{ url: '', name: 'ielts-suite-mode-tab' }]);
+ const info = fileApp.examWindows.get('reading-p1');
+ assert.strictEqual(info.expectedSessionId, 'reading-p1-file-session');
+ assert.strictEqual(info.sessionGeneration, 8);
+ assert.notStrictEqual(info.windowSessionToken, 'old-file-token');
+ if (info && info.closeMonitor) clearInterval(info.closeMonitor);
+ if (fileApp._handshakeTimers) {
+ for (const timer of fileApp._handshakeTimers.values()) clearInterval(timer);
+ fileApp._handshakeTimers.clear();
+ }
+ }
+
+ // Case 0.0.2: 每题 recovery 未提交时必须 NACK,且保持当前篇可重试。
+ {
+ const fileHarness = createSandbox({ protocol: 'file:' });
+ const fileContext = vm.createContext(fileHarness.sandbox);
+ loadScript('js/app/examSessionMixin.js', fileContext);
+ loadScript('js/app/suitePracticeMixin.js', fileContext);
+ const fileApp = createApp(fileHarness.windowStub);
+ const session = makeSession('suite_file_blank_window');
+ session.windowRef = null;
+ session.windowBinding = {
+ examId: 'reading-p1',
+ expectedSessionId: 'reading-p1-file-session',
+ windowSessionToken: 'old-file-token',
+ sessionGeneration: 2,
+ expectedUrl: 'file:///reading-practice-unified.html?examId=reading-p1',
+ expectedOrigin: 'file://',
+ allowOpaqueOrigin: true
+ };
+ fileApp.currentSuiteSession = session;
+ const blankChild = createStubWindow('ielts-suite-mode-tab');
+ blankChild.location.href = 'about:blank';
+ blankChild.close = function close() { this.closed = true; };
+ fileHarness.windowStub.open = () => blankChild;
+ assert.strictEqual(await fileApp._tryRebindSuiteWindow(session, session.sequence[0]), null);
+ assert.strictEqual(blankChild.closed, true, 'new file:// about:blank window must be closed instead of treated as a rebound child');
+ assert.strictEqual(Boolean(fileApp.examWindows && fileApp.examWindows.has('reading-p1')), false);
+ }
+
+ // Case 0.0.1c: completed recovery 清理失败时不得并发创建新套题。
+ {
+ const app = createApp(windowStub);
+ const completed = makeSession('suite_completed_cleanup_failure');
+ completed.status = 'completed';
+ app.currentSuiteSession = completed;
+ app._teardownSuiteSession = async () => false;
+ let openCount = 0;
+ app.openExam = async () => {
+ openCount += 1;
+ return createStubWindow('must-not-open');
+ };
+ assert.strictEqual(
+ await app._launchSuiteSessionFromSequence(makeSession('replacement').sequence, { flowMode: 'simulation' }),
+ false
+ );
+ assert.strictEqual(app.currentSuiteSession, completed);
+ assert.strictEqual(openCount, 0);
+ }
+
+ // Case 0.0.2: 每题 recovery 未提交时必须 NACK,且保持当前篇可重试。
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_passage_nack');
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id]));
+ let openCount = 0;
+ app.openExam = async () => {
+ openCount += 1;
+ return createStubWindow('suite-window');
+ };
+ recoveryControl.saveQueue.push(false);
+ const payload = {
+ suiteSessionId: session.id,
+ submissionId: 'submission-p1',
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 },
+ draft: { answers: { q1: 'A' }, updatedAt: 100 },
+ draftUpdatedAt: 100
+ };
+ const failed = await app.handleSuitePracticeComplete('reading-p1', payload, session.windowRef);
+ assert.strictEqual(failed.handled, true);
+ assert.strictEqual(failed.committed, false, '未持久化 passage 不得 ACK committed');
+ assert.strictEqual(failed.errorCode, 'suite_recovery_save_failed');
+ assert.strictEqual(session.currentIndex, 0, '失败后必须停留在 P1');
+ assert.strictEqual(session.activeExamId, 'reading-p1');
+ assert.strictEqual(openCount, 0, '失败后不得清题或切到 P2');
+
+ const retried = await app.handleSuitePracticeComplete('reading-p1', payload, session.windowRef);
+ assert.strictEqual(retried.committed, true, '相同 submission 重试提交成功后才 ACK');
+ assert.strictEqual(openCount, 1, '成功重试后只前进一次');
+ assert.strictEqual(session.currentIndex, 1);
+ }
+
// Case 0: 单题历史回顾必须从记录根层或 realData 回灌高亮
+ // A passage CAS receipt remains authoritative even if this continuation loses its
+ // recovery claim before saveActiveSession returns. Keep the receipt-aligned tuple,
+ // ACK the submission, and suppress every later window side effect.
+ for (const autoAdvance of [false, true]) {
+ const app = createApp(windowStub);
+ const session = makeSession(`suite_passage_post_receipt_${autoAdvance ? 'advance' : 'manual'}`);
+ if (!autoAdvance) {
+ session.flowMode = 'stationary';
+ session.autoAdvanceAfterSubmit = false;
+ }
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id]));
+ let openCount = 0;
+ app.openExam = async () => {
+ openCount += 1;
+ return createStubWindow('must-not-open-after-post-receipt-loss');
+ };
+ let claimRelease = null;
+ recoveryControl.saveQueue.push((value) => {
+ claimRelease = app._releaseSuiteRecoveryClaim('single', session);
+ return { committed: true, item: plain(value) };
+ });
+ const outcome = await app.handleSuitePracticeComplete('reading-p1', {
+ suiteSessionId: session.id,
+ submissionId: `post-receipt-${autoAdvance ? 'advance' : 'manual'}`,
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ }, session.windowRef);
+ if (claimRelease) await claimRelease;
+ assert.strictEqual(outcome.handled, true);
+ assert.strictEqual(outcome.committed, true, 'a confirmed per-call durable receipt must still ACK');
+ assert.strictEqual(outcome.errorCode, 'suite_advance_superseded');
+ assert.strictEqual(openCount, 0, 'post-receipt ownership loss must suppress window navigation');
+ assert.strictEqual(session.currentIndex, autoAdvance ? 1 : 0);
+ assert.strictEqual(session.activeExamId, autoAdvance ? 'reading-p2' : 'reading-p1');
+ if (autoAdvance) {
+ assert.strictEqual(session.pendingAdvance, null);
+ } else {
+ assert.strictEqual(session.pendingAdvance.completedExamId, 'reading-p1');
+ }
+ const durableEvent = recoveryControl.events.filter((event) => (
+ event.type === 'save' && event.value && event.value.id === session.id
+ )).at(-1);
+ assert(durableEvent, 'the post-owner-loss outcome must be backed by this invocation durable receipt');
+ assert.strictEqual(durableEvent.value.currentIndex, session.currentIndex);
+ assert.strictEqual(durableEvent.value.activeExamId, session.activeExamId);
+ }
+
{
const app = createApp(windowStub);
const rootHighlights = [{ id: 'hl-root', scope: 'left', text: 'root highlight' }];
@@ -221,11 +890,22 @@ async function run() {
app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
const openCalls = [];
+ recoveryControl.events.length = 0;
app.openExam = async (examId, options = {}) => {
+ const latestSave = recoveryControl.events
+ .filter((event) => event.type === 'save' && event.value && event.value.id === session.id)
+ .at(-1);
+ assert(latestSave, '导航前必须存在当前套题的 durable recovery commit');
+ assert.strictEqual(latestSave.value.activeExamId, examId, '导航前 recovery 必须已指向目标篇章');
+ assert.strictEqual(
+ latestSave.value.currentIndex,
+ session.sequence.findIndex((entry) => entry.examId === examId),
+ '导航前 recovery 索引必须已切到目标篇章'
+ );
openCalls.push({ examId, options });
const win = createStubWindow('suite-window');
win.lastExamId = examId;
- return win;
+ return installManagedTestWindow(app, examId, win, options);
};
const originalWindow = session.windowRef;
@@ -250,7 +930,6 @@ async function run() {
// Case 1.1: 手动回看模式下提交后不应自动跳篇
{
- windowStub.localStorage.setItem('suite_auto_advance_after_submit', 'false');
const app = createApp(windowStub);
const session = makeSession('suite_manual');
session.flowMode = 'classic';
@@ -281,7 +960,6 @@ async function run() {
assert.strictEqual(reviewStateCount, 1, '手动模式应下发回看上下文');
assert.strictEqual(session.currentIndex, 0, '手动模式应停留在当前篇');
assert.strictEqual(session.pendingAdvance.completedExamId, 'reading-p1', '应记录待切题状态');
- windowStub.localStorage.removeItem('suite_auto_advance_after_submit');
}
// Case 2: SIMULATION_NAVIGATE 前后切换并保存 draft
@@ -298,7 +976,7 @@ async function run() {
openCalls.push({ examId, options });
const win = createStubWindow('suite-window');
win.lastExamId = examId;
- return win;
+ return installManagedTestWindow(app, examId, win, options);
};
// Navigate prev from P2 to P1
@@ -342,10 +1020,10 @@ async function run() {
assert.strictEqual(session.currentIndex, 1, '应回到第二篇');
assert.deepStrictEqual(session.draftsByExam['reading-p1'].answers, { q1: 'A' }, 'P1 draft 应被保存');
assert.strictEqual(session.results.length, 2, '应记录两个篇章快照结果');
- const mirroredSession = JSON.parse(sessionStorageStub.get('ielts_sim_session'));
+ const mirroredSession = windowSessionStore.get('simulation');
const mirroredP2Result = mirroredSession.results.find(entry => entry.examId === 'reading-p2');
- assert.strictEqual(Object.prototype.hasOwnProperty.call(mirroredP2Result, 'highlights'), false, 'sessionStorage results 不应重复写高亮');
- assert.deepStrictEqual(mirroredSession.draftsByExam['reading-p2'].highlights, p2Highlights, 'sessionStorage 应只在 draft 中保存 P2 高亮');
+ assert.strictEqual(Object.prototype.hasOwnProperty.call(mirroredP2Result, 'highlights'), false, 'window session results 不应重复写高亮');
+ assert.deepStrictEqual(mirroredSession.draftsByExam['reading-p2'].highlights, p2Highlights, 'window session 应只在 draft 中保存 P2 高亮');
const p2Replay = app._buildSuiteReplayEntry(session, 'reading-p2');
assert.deepStrictEqual(p2Replay.highlights, p2Highlights, '套题中途回看必须能恢复 P2 高亮');
@@ -366,15 +1044,26 @@ async function run() {
session.flowMode = 'stationary';
session.autoAdvanceAfterSubmit = false;
session.results = [
+ {
+ examId: 'reading-p1', title: 'Passage 1', duration: 10,
+ answers: { q1: 'A' }, answerComparison: {},
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {}
+ },
{
examId: 'reading-p2',
title: 'Passage 2',
+ duration: 10,
answers: { q1: 'B' },
answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } },
scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 },
highlights: [],
scrollY: 0,
rawData: {}
+ },
+ {
+ examId: 'reading-p3', title: 'Passage 3', duration: 10,
+ answers: { q1: 'C' }, answerComparison: {},
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {}
}
];
session.draftsByExam['reading-p2'] = {
@@ -412,9 +1101,9 @@ async function run() {
const session = makeSession('suite_final_highlight');
const p2Highlights = [{ scope: 'groups', text: 'P2 answer evidence', color: 'green' }];
session.results = [
- { examId: 'reading-p1', title: 'Passage 1', answers: { q1: 'A' }, answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} },
- { examId: 'reading-p2', title: 'Passage 2', answers: { q1: 'B' }, answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, highlights: p2Highlights, scrollY: 240, rawData: { highlights: p2Highlights, scrollY: 240 } },
- { examId: 'reading-p3', title: 'Passage 3', answers: { q1: 'C' }, answerComparison: { q1: { userAnswer: 'C', correctAnswer: 'C', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} }
+ { examId: 'reading-p1', title: 'Passage 1', duration: 10, answers: { q1: 'A' }, answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} },
+ { examId: 'reading-p2', title: 'Passage 2', duration: 10, answers: { q1: 'B' }, answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, highlights: p2Highlights, scrollY: 240, rawData: { highlights: p2Highlights, scrollY: 240 } },
+ { examId: 'reading-p3', title: 'Passage 3', duration: 10, answers: { q1: 'C' }, answerComparison: { q1: { userAnswer: 'C', correctAnswer: 'C', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} }
];
app.currentSuiteSession = session;
app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
@@ -477,6 +1166,8 @@ async function run() {
});
app.openExam = async (examId, options = {}) => {
assert.strictEqual(examId, 'reading-p2', '下一题应打开 P2');
+ assert.strictEqual(options.examDefinition.id, 'reading-p2', '跨题回放必须传入按记录来源解析的题目定义');
+ assert.strictEqual(options.requireRecordProvenance, true, '跨题回放不得回落到当前活动题库');
app.examWindows.set(examId, {
window: secondWindow,
reviewMode: Boolean(options.reviewMode),
@@ -508,10 +1199,10 @@ async function run() {
app.currentSuiteSession = session;
app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
- app.openExam = async (examId) => {
+ app.openExam = async (examId, options = {}) => {
const win = createStubWindow('suite-window');
win.lastExamId = examId;
- return win;
+ return installManagedTestWindow(app, examId, win, options);
};
const hops = [
@@ -550,10 +1241,15 @@ async function run() {
let resolveOpen = null;
let openCallCount = 0;
- app.openExam = async () => {
+ app.openExam = async (examId, options = {}) => {
openCallCount += 1;
return await new Promise((resolve) => {
- resolveOpen = () => resolve(createStubWindow('suite-window'));
+ resolveOpen = () => resolve(installManagedTestWindow(
+ app,
+ examId,
+ createStubWindow('suite-window'),
+ options
+ ));
});
};
@@ -566,8 +1262,8 @@ async function run() {
scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
}
}, session.windowRef);
- const secondNavigate = await app._handleSimulationNavigate('reading-p1', { direction: 'next' }, session.windowRef);
- assert.strictEqual(secondNavigate, false, '并发切题应被锁拒绝,避免重复导航');
+ const secondNavigate = app._handleSimulationNavigate('reading-p1', { direction: 'next' }, session.windowRef);
+ await new Promise((resolve) => setTimeout(resolve, 0));
assert.strictEqual(openCallCount, 1, '并发切题期间只允许一次窗口切换');
if (typeof resolveOpen === 'function') {
@@ -575,12 +1271,55 @@ async function run() {
}
const firstNavigateOk = await firstNavigate;
assert.strictEqual(firstNavigateOk, true, '首个切题请求应成功');
+ assert.strictEqual(await secondNavigate, false, '重复的旧篇请求应在串行等待后按 stale 消息忽略');
session.activeExamId = 'reading-p2';
const staleNavigate = await app._handleSimulationNavigate('reading-p1', { direction: 'next' }, session.windowRef);
assert.strictEqual(staleNavigate, false, '非活动篇章消息必须忽略');
}
+ // Case 2.2.1: 新篇提交若撞上上一跳的 ready 等待,必须排队而不能丢失
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_nav_queue_next');
+ session.currentIndex = 0;
+ session.activeExamId = 'reading-p1';
+ app.currentSuiteSession = session;
+
+ let releaseFirstOpen;
+ const opened = [];
+ app.openExam = async (examId, options = {}) => {
+ opened.push(examId);
+ if (opened.length === 1) {
+ await new Promise((resolve) => { releaseFirstOpen = resolve; });
+ }
+ return installManagedTestWindow(app, examId, createStubWindow('suite-window'), options);
+ };
+
+ const firstNavigate = app._handleSimulationNavigate(
+ 'reading-p1',
+ { direction: 'next' },
+ session.windowRef
+ );
+ for (let attempt = 0; attempt < 50 && typeof releaseFirstOpen !== 'function'; attempt += 1) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+ assert.strictEqual(typeof releaseFirstOpen, 'function', '第一跳必须在 claim 与 durable save 后进入 openExam');
+ const queuedNavigate = app._handleSimulationNavigate(
+ 'reading-p2',
+ { direction: 'next' },
+ session.windowRef
+ );
+ assert.deepStrictEqual(opened, ['reading-p2'], '锁内只应启动第一跳');
+
+ releaseFirstOpen();
+ assert.strictEqual(await firstNavigate, true, '第一跳应成功');
+ assert.strictEqual(await queuedNavigate, true, '下一篇提交应在第一跳完成后继续处理');
+ assert.deepStrictEqual(opened, ['reading-p2', 'reading-p3'], '排队提交应继续切到 P3');
+ assert.strictEqual(session.currentIndex, 2, '串行导航后索引应到达 P3');
+ assert.strictEqual(session.activeExamId, 'reading-p3', '串行导航后活动篇章应到达 P3');
+ }
+
// Case 2.3: 重复绑定同一 exam 消息通道时必须替换旧监听器
{
const app = createApp(windowStub);
@@ -626,6 +1365,7 @@ async function run() {
const info = app.ensureExamWindowSession('reading-p2', examWindow);
info.expectedSessionId = 'expected_session';
+ app._refreshExamWindowToken('reading-p2', info);
info.suiteSessionId = session.id;
app.examWindows.set('reading-p2', info);
@@ -649,6 +1389,7 @@ async function run() {
examId: 'reading-p2',
suiteSessionId: session.id,
sessionId: 'stale_session',
+ windowSessionToken: info.windowSessionToken,
direction: 'prev',
source: 'practice_page'
},
@@ -680,6 +1421,7 @@ async function run() {
const info = app.ensureExamWindowSession('reading-p1', examWindow);
info.expectedSessionId = 'expected_inline_session';
+ app._refreshExamWindowToken('reading-p1', info);
info.suiteSessionId = session.id;
app.examWindows.set('reading-p1', info);
@@ -693,6 +1435,7 @@ async function run() {
examId: 'reading-p2',
suiteSessionId: session.id,
sessionId: 'stale_inline_session',
+ windowSessionToken: info.windowSessionToken,
draft: {
answers: { q1: 'P2 answer' },
highlights: [{ scope: 'left', text: 'P2 highlight' }],
@@ -712,7 +1455,51 @@ async function run() {
assert.strictEqual(session.draftsByExam['reading-p2'].noteText, 'P2 note', 'P2 noteText 应保存');
}
- // Case 2.4.2: inline simulation 草稿同步必须按篇拆分 elapsed,并镜像回 sessionStorage
+ // Case 2.4.1a: a queued classic draft cannot write through a replaced registration.
+ {
+ const app = createApp(windowStub);
+ const oldWindow = createStubWindow('old-reading-window');
+ const newWindow = createStubWindow('new-reading-window');
+ const oldInfo = {
+ window: oldWindow,
+ expectedSessionId: 'reading-session',
+ sessionGeneration: 1,
+ practiceMode: 'classic',
+ suiteSessionId: null,
+ reviewMode: false
+ };
+ const newInfo = { ...oldInfo, window: newWindow, sessionGeneration: 2 };
+ app.examWindows = new Map([['reading-p1', oldInfo]]);
+ let releaseQueue;
+ app._readingDraftStoreQueue = new Promise((resolve) => { releaseQueue = resolve; });
+ const pending = app._queueReadingDraftSync('reading-p1', {
+ sessionId: 'reading-session',
+ draft: { answers: { q1: 'stale' }, updatedAt: 100 },
+ draftUpdatedAt: 100
+ }, oldInfo);
+ app.examWindows.set('reading-p1', newInfo);
+ releaseQueue();
+ assert.strictEqual(await pending, false, '窗口重注册后排队中的旧草稿必须被拒绝');
+ }
+
+ // Case 2.4.1b: suite handler failure must not fall back to a standalone v2 attempt.
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_no_standalone_fallback');
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id]));
+ app.handleSuitePracticeComplete = async () => { throw new Error('suite handler failure'); };
+ let standaloneWrites = 0;
+ app.saveRealPracticeData = async () => { standaloneWrites += 1; return { id: 'unexpected' }; };
+ assert.strictEqual(await app.handlePracticeComplete('reading-p1', {
+ suiteSessionId: session.id,
+ answers: { q1: 'A' },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ }, session.windowRef), false);
+ assert.strictEqual(standaloneWrites, 0, '套题处理失败不得写入单篇 fallback');
+ }
+
+ // Case 2.4.2: inline simulation 草稿同步必须按篇拆分 elapsed,并镜像回窗口会话域
{
const app = createApp(windowStub);
const session = makeSession('suite_inline_elapsed_route');
@@ -732,6 +1519,7 @@ async function run() {
const info = app.ensureExamWindowSession('reading-p1', examWindow);
info.expectedSessionId = 'expected_inline_elapsed_session';
+ app._refreshExamWindowToken('reading-p1', info);
info.suiteSessionId = session.id;
app.examWindows.set('reading-p1', info);
@@ -745,6 +1533,7 @@ async function run() {
examId: 'reading-p2',
suiteSessionId: session.id,
sessionId: 'stale_inline_elapsed_session',
+ windowSessionToken: info.windowSessionToken,
draft: {
answers: { q1: 'P2 answer' },
highlights: [],
@@ -761,8 +1550,8 @@ async function run() {
});
assert.strictEqual(session.elapsedByExam['reading-p2'], 60, 'P2 elapsed 必须按整套累计时间拆分为单篇时长');
- const mirrored = JSON.parse(sessionStorageStub.get('ielts_sim_session'));
- assert.strictEqual(mirrored.elapsedByExam['reading-p2'], 60, 'sessionStorage 镜像也必须保存拆分后的 P2 elapsed');
+ const mirrored = windowSessionStore.get('simulation');
+ assert.strictEqual(mirrored.elapsedByExam['reading-p2'], 60, '窗口会话镜像也必须保存拆分后的 P2 elapsed');
}
// Case 2.5: activeExamId 漂移但 currentIndex 正确时,导航应自愈继续
@@ -775,11 +1564,11 @@ async function run() {
app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
let openCount = 0;
- app.openExam = async (examId) => {
+ app.openExam = async (examId, options = {}) => {
openCount += 1;
const win = createStubWindow('suite-window');
win.lastExamId = examId;
- return win;
+ return installManagedTestWindow(app, examId, win, options);
};
const healed = await app._handleSimulationNavigate('reading-p1', {
@@ -892,772 +1681,6100 @@ async function run() {
assert.deepStrictEqual(plain(session.draftsByExam['reading-p2'].highlights), [{ scope: 'groups', text: 'P2 highlight' }], 'P2 高亮应隔离保存');
}
- // Case 3.1: 如果最后一篇已有导航快照,最终提交仍应覆盖并 finalize
+ // Case 3.0.2: inline simulation 提交必须在落库后 ACK,并可按同一 submissionId 重放
{
const app = createApp(windowStub);
- const session = makeSession('suite_finalize_upsert');
- session.results = [
- { examId: 'reading-p1', title: 'Passage 1', answers: { q1: 'A' }, answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} },
- { examId: 'reading-p2', title: 'Passage 2', answers: { q1: 'B' }, answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} },
- { examId: 'reading-p3', title: 'Passage 3', answers: { q1: 'OLD' }, answerComparison: { q1: { userAnswer: 'OLD', correctAnswer: 'C', isCorrect: false } }, scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0 }, rawData: {} }
- ];
- session.currentIndex = 2;
- session.activeExamId = 'reading-p3';
+ const session = makeSession('suite_inline_submit_ack');
+ const sourceWindow = session.windowRef;
+ const examId = 'reading-p1';
+ const sessionId = 'session-inline-submit-ack';
+ const submissionId = 'submission-inline-submit-ack';
+ sourceWindow.location.href = `http://localhost/${examId}.html`;
app.currentSuiteSession = session;
app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+ app.examWindows = new Map([[examId, {
+ window: sourceWindow,
+ expectedSessionId: sessionId,
+ sessionId,
+ windowSessionToken: 'token-inline-submit-ack',
+ windowSessionTokenSessionId: sessionId,
+ expectedUrl: sourceWindow.location.href,
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false,
+ suiteSessionId: session.id
+ }]]);
+ const payload = {
+ examId,
+ sessionId,
+ submissionId,
+ suiteSessionId: session.id,
+ suiteSubmission: true,
+ duration: 3600,
+ suiteEntries: session.sequence.map((entry, index) => ({
+ examId: entry.examId,
+ title: entry.exam.title,
+ category: entry.exam.category,
+ duration: 1200,
+ answers: { q1: String.fromCharCode(65 + index) },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ }))
+ };
- let finalizeCount = 0;
- app.finalizeSuiteRecord = async () => {
- finalizeCount += 1;
+ assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true);
+ let ack = sourceWindow._messages.filter(message => message && message.type === 'PRACTICE_SUBMIT_ACK').at(-1);
+ assert(ack, 'inline simulation persistence must ACK the child');
+ assert.deepStrictEqual(plain({
+ submissionId: ack.data.submissionId,
+ sessionId: ack.data.sessionId,
+ examId: ack.data.examId,
+ suiteSessionId: ack.data.suiteSessionId
+ }), { submissionId, sessionId, examId, suiteSessionId: session.id });
+ assert.strictEqual((await windowStub.AppData.practice.list()).length, 1, 'first submit must persist one suite record');
+
+ assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true);
+ ack = sourceWindow._messages.filter(message => message && message.type === 'PRACTICE_SUBMIT_ACK').at(-1);
+ assert(ack, 'retry must replay the persisted ACK');
+ assert.strictEqual((await windowStub.AppData.practice.list()).length, 1, 'retry must not persist a second suite record');
+ app._announcePracticeSubmitOutcome(examId, { ...payload, suiteId: 'set-1' }, sourceWindow, true);
+ assert.strictEqual(app._replayPracticeSubmitReceipt(examId, { ...payload, suiteId: 'set-1' }, sourceWindow), true);
+ assert.strictEqual(app._replayPracticeSubmitReceipt(examId, { ...payload, suiteId: 'set-2' }, sourceWindow), false, 'ACK receipt 必须包含 suiteId');
+ clearTimeout(session.submitReceiptTeardownTimer);
+ session.submitReceiptTeardownTimer = null;
+
+ let guardedCloseTeardownCount = 0;
+ app._teardownSuiteSession = async () => { guardedCloseTeardownCount += 1; return true; };
+ session.status = 'active';
+ app._ensureSuiteWindowGuard(session, sourceWindow);
+ sourceWindow.close();
+ await Promise.resolve();
+ assert.strictEqual(guardedCloseTeardownCount, 0, '进行中的套题必须继续拦截 guarded close');
+ session.status = 'completed';
+ sourceWindow.close();
+ await Promise.resolve();
+ assert.strictEqual(guardedCloseTeardownCount, 1, '最终 ACK 的 guarded close 必须立即 teardown 已完成套题');
+ app._releaseSuiteWindowGuard(sourceWindow);
+
+ let completedCloseTeardownCount = 0;
+ app._teardownSuiteSession = async (targetSession) => {
+ assert.strictEqual(targetSession, session);
+ completedCloseTeardownCount += 1;
+ return true;
+ };
+ app.setupExamWindowCommunication(sourceWindow, examId);
+ const closeAttemptHandler = app.messageHandlers.get(examId);
+ const closeAttemptEvent = {
+ source: sourceWindow,
+ origin: 'http://localhost',
+ data: {
+ type: 'SUITE_CLOSE_ATTEMPT',
+ source: 'practice_page',
+ data: {
+ examId,
+ suiteSessionId: session.id,
+ windowSessionToken: 'token-inline-submit-ack'
+ }
+ }
};
+ await closeAttemptHandler(closeAttemptEvent);
+ assert.strictEqual(completedCloseTeardownCount, 1, 'final ACK 后子页退出应立即清理已完成套题');
- const handled = await app.handleSuitePracticeComplete('reading-p3', {
- suiteSessionId: session.id,
- answers: { q1: 'C' },
- answerComparison: { q1: { userAnswer: 'C', correctAnswer: 'C', isCorrect: true } },
- scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
- }, session.windowRef);
+ session.status = 'active';
+ await closeAttemptHandler(closeAttemptEvent);
+ assert.strictEqual(completedCloseTeardownCount, 1, '进行中的套题仍必须拦截子页关闭');
+ }
- assert.strictEqual(handled, true, '最后一篇覆盖提交应成功');
- assert.strictEqual(finalizeCount, 1, '最后一篇覆盖提交后仍应 finalize');
- const p3 = session.results.find(item => item.examId === 'reading-p3');
- assert.deepStrictEqual(p3.answers, { q1: 'C' }, '最终提交应覆盖旧快照答案');
- }
-
- // Case 4: 套题意外关闭后,已作答篇应按单篇普通流程保存
+ // Case 3.0.3: multi-suite 保存失败必须 NACK,同键重试成功后才 ACK
{
const app = createApp(windowStub);
- const session = makeSession('suite_abort');
- session.results = [
- {
- examId: 'reading-p1',
- rawData: {
- answers: { q1: 'A' },
- scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
- }
- },
- {
- examId: 'reading-p2',
- rawData: {
- answers: { q1: 'B' },
- scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
- }
- }
- ];
- app.currentSuiteSession = session;
- app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
-
- const savedExamIds = [];
- app.saveRealPracticeData = async (examId) => {
- savedExamIds.push(examId);
+ const examId = 'listening-multi-suite';
+ const sessionId = 'session-multi-submit';
+ const submissionId = 'submission-multi-submit';
+ const suiteSessionId = 'suite-session-multi-submit';
+ const sourceWindow = createStubWindow('multi-suite-submit');
+ sourceWindow.location.href = `http://localhost/${examId}.html`;
+ app.examWindows = new Map([[examId, {
+ window: sourceWindow,
+ expectedSessionId: sessionId,
+ sessionId,
+ windowSessionToken: 'token-multi-submit',
+ windowSessionTokenSessionId: sessionId,
+ expectedUrl: sourceWindow.location.href,
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false
+ }]]);
+ let saveAttempts = 0;
+ app._saveSuitePracticeRecord = async () => {
+ saveAttempts += 1;
+ if (saveAttempts === 1) throw new Error('expected multi-suite save failure');
};
- app._teardownSuiteSession = async () => {
- app.currentSuiteSession = null;
+ const payload = {
+ examId,
+ sessionId,
+ submissionId,
+ suiteSessionId,
+ suiteId: 'set-1',
+ totalSuites: 1,
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
};
- await app._abortSuiteSession(session, {});
- assert.deepStrictEqual(savedExamIds.sort(), ['reading-p1', 'reading-p2'], '中断后应保存所有已作答篇章');
+ assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), false);
+ let outcome = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type)).at(-1);
+ assert.strictEqual(outcome.type, 'PRACTICE_SUBMIT_FAILED');
+ assert.deepStrictEqual(plain({
+ submissionId: outcome.data.submissionId,
+ sessionId: outcome.data.sessionId,
+ examId: outcome.data.examId,
+ suiteSessionId: outcome.data.suiteSessionId
+ }), { submissionId, sessionId, examId, suiteSessionId });
+
+ const oldV2Session = app.multiSuiteSessionsMap.get(examId);
+ delete oldV2Session.suiteResults[0].metadata.submissionId;
+ delete oldV2Session.finalizeRecord.suiteEntries[0].metadata;
+ assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true);
+ outcome = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type)).at(-1);
+ assert.strictEqual(outcome.type, 'PRACTICE_SUBMIT_ACK');
+ assert.strictEqual(saveAttempts, 2, 'retry must re-attempt the failed aggregate save exactly once');
+ assert.strictEqual(app.multiSuiteSessionsMap.get(examId), oldV2Session, '旧 v2 frozen 必须保留 recovery 作为 durable receipt');
}
- // Case 5: sessionStorage 镜像在 teardown 后应被清理
+ // Case 3.0.3a: legacy payload 的 finalizing 重试只能收敛原 frozen aggregate
{
const app = createApp(windowStub);
- const session = makeSession('suite_storage_cleanup');
- app.currentSuiteSession = session;
- app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
- app._mirrorSessionToStorage(session);
- assert(sessionStorageStub.has('ielts_sim_session'), '镜像应存在');
- app._clearSessionStorage();
- assert(!sessionStorageStub.has('ielts_sim_session'), '清理后镜像应删除');
+ let saveAttempts = 0;
+ app._saveSuitePracticeRecord = async () => {
+ saveAttempts += 1;
+ if (saveAttempts === 1) throw new Error('expected legacy multi-suite save failure');
+ };
+ const payload = {
+ suiteId: 'set-1',
+ totalSuites: 1,
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ };
+ assert.strictEqual(await app.handleMultiSuitePracticeComplete('listening-multi-legacy-retry', payload), false);
+ assert.strictEqual(await app.handleMultiSuitePracticeComplete('listening-multi-legacy-retry', payload), true);
+ assert.strictEqual(saveAttempts, 2, 'legacy retry 只能重放一次原 frozen aggregate,不能创建新 session 再聚合');
}
- // Case 6: _sendSimulationContext 应发送正确的上下文
+ // Case 3.0.4: canonical receipt 未确认 committed 时必须 NACK
{
const app = createApp(windowStub);
- const session = makeSession('suite_context');
- const p1Highlights = [{ scope: 'left', text: 'P1 context highlight', color: 'yellow' }];
- session.draftsByExam['reading-p1'] = { answers: { q1: 'A' }, highlights: p1Highlights, scrollY: 0 };
- session.elapsedByExam['reading-p1'] = 45;
- app.currentSuiteSession = session;
- const targetWindow = createStubWindow('ctx-window');
- const sent = app._sendSimulationContext(session, 'reading-p1', targetWindow);
- assert.strictEqual(sent, true, '应成功发送上下文');
- const ctxMsg = targetWindow._messages.find(m => m && m.type === 'SIMULATION_CONTEXT');
- assert(ctxMsg, '应收到 SIMULATION_CONTEXT');
- assert.strictEqual(ctxMsg.data.currentIndex, 0, 'currentIndex 应为 0');
- assert.strictEqual(ctxMsg.data.total, 3, 'total 应为 3');
- assert.strictEqual(Array.isArray(ctxMsg.data.suiteSequence), true, 'SIMULATION_CONTEXT 应包含 suiteSequence');
- assert.deepStrictEqual(ctxMsg.data.suiteSequence.map(item => item.examId), ['reading-p1', 'reading-p2', 'reading-p3'], 'suiteSequence 应包含三篇 examId');
- assert.strictEqual(ctxMsg.data.isLast, false, 'P1 不是最后一篇');
- assert.strictEqual(ctxMsg.data.canPrev, false, 'P1 不能向前');
- assert.strictEqual(ctxMsg.data.canNext, true, 'P1 可以向后');
- assert.deepStrictEqual(ctxMsg.data.draft.answers, { q1: 'A' }, 'draft 应回传');
- assert.deepStrictEqual(ctxMsg.data.draft.highlights, p1Highlights, 'draft highlights 应随上下文回传,避免切题后丢失高亮');
- assert.strictEqual(ctxMsg.data.elapsed, 45, 'elapsed 应回传');
-
- const sentP3 = app._sendSimulationContext(session, 'reading-p3', targetWindow);
- assert.strictEqual(sentP3, true, 'P3 上下文应成功');
- const ctxP3 = targetWindow._messages.filter(m => m && m.type === 'SIMULATION_CONTEXT')[1];
- assert.strictEqual(ctxP3.data.isLast, true, 'P3 应标记为最后一篇');
- assert.strictEqual(ctxP3.data.canNext, false, 'P3 不能向后导航');
+ const examId = 'listening-multi-uncommitted-receipt';
+ const sessionId = 'session-multi-uncommitted-receipt';
+ const submissionId = 'submission-multi-uncommitted-receipt';
+ const sourceWindow = createStubWindow('multi-uncommitted-receipt');
+ sourceWindow.location.href = `http://localhost/${examId}.html`;
+ app.examWindows = new Map([[examId, {
+ window: sourceWindow,
+ expectedSessionId: sessionId,
+ sessionId,
+ windowSessionToken: 'token-multi-uncommitted-receipt',
+ windowSessionTokenSessionId: sessionId,
+ expectedUrl: sourceWindow.location.href,
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false
+ }]]);
+ const originalFinalizeSuite = windowStub.AppData.practice.finalizeSuite;
+ windowStub.AppData.practice.finalizeSuite = async () => ({ committed: false });
+ try {
+ const committed = await app.handlePracticeComplete(examId, {
+ examId,
+ sessionId,
+ submissionId,
+ suiteSessionId: 'suite-multi-uncommitted-receipt',
+ suiteId: 'set-1',
+ totalSuites: 1,
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ }, sourceWindow);
+ assert.strictEqual(committed, false, 'uncommitted canonical receipt must not be treated as success');
+ } finally {
+ windowStub.AppData.practice.finalizeSuite = originalFinalizeSuite;
+ }
+ const outcome = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type)).at(-1);
+ assert(outcome && outcome.type === 'PRACTICE_SUBMIT_FAILED', 'uncommitted canonical receipt must NACK');
}
- // Case 6.1: INIT_SESSION payload 应携带三篇 suiteSequence
+ // Case 3.0.4a: suiteId multi-suite partial results must restore into a new app instance
{
+ const examId = 'listening-100-p1_set1';
const app = createApp(windowStub);
- const session = makeSession('suite_init_sequence');
- app.currentSuiteSession = session;
- app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
- const initWindow = createStubWindow('init-window');
- const windowInfo = app.ensureExamWindowSession('reading-p1', initWindow);
- windowInfo.suiteSessionId = session.id;
- windowInfo.suiteFlowMode = 'simulation';
- const payload = app._buildExamInitPayload('reading-p1', windowInfo);
- assert.strictEqual(Array.isArray(payload.suiteSequence), true, 'INIT_SESSION 应包含 suiteSequence');
- assert.deepStrictEqual(payload.suiteSequence.map(item => item.examId), ['reading-p1', 'reading-p2', 'reading-p3'], 'INIT suiteSequence 应覆盖三篇');
- assert.deepStrictEqual(payload.suiteSequence.map(item => item.category), ['P1', 'P2', 'P3'], 'INIT suiteSequence 应带 category');
- }
+ app.initializeSuiteMode();
- // Case 8: handleSessionReady 应触发首篇模拟上下文下发
- {
- const app = createApp(windowStub);
- const session = makeSession('suite_session_ready');
- app.currentSuiteSession = session;
- app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
- app.examWindows = new Map();
- const readyWindow = createStubWindow('ready-window');
- app.examWindows.set('reading-p1', {
- examId: 'reading-p1',
- window: readyWindow,
- expectedSessionId: 'session-reading-p1',
- suiteSessionId: session.id
+ const handled = await app.handleSuitePracticeComplete(examId, {
+ suiteId: 'set-1',
+ totalSuites: 2,
+ sessionId: 'multi-suite-child-session-1',
+ answers: { q1: 'A' },
+ correctAnswers: { q1: 'A' },
+ answerComparison: {
+ q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true }
+ },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 },
+ duration: 42
});
- app.handleSessionReady('reading-p1', { sessionId: 'session-reading-p1', pageType: 'unified-reading' });
- const msg = readyWindow._messages.find(item => item && item.type === 'SIMULATION_CONTEXT');
- assert(msg, 'SESSION_READY 后应下发 SIMULATION_CONTEXT');
- assert.strictEqual(msg.data.examId, 'reading-p1', 'SESSION_READY 下发应匹配 examId');
+ assert.strictEqual(handled, true, 'multi-suite 部分结果应成功处理');
+ const mirrored = windowSessionStore.get('multi-suite-practice');
+ assert.strictEqual(mirrored.schema, 'multi-suite-sessions-v2', 'multi-suite 应写入 v2 恢复快照');
+ assert.strictEqual(mirrored.version, 2, 'multi-suite 恢复快照版本必须为 v2');
+ const mirroredSession = mirrored.sessions.find((session) => session.baseExamId === 'listening-100-p1');
+ assert(mirroredSession, '部分结果应保留目标 multi-suite 会话');
+ assert.strictEqual(mirroredSession.suiteResults.length, 1, '部分结果应写入 suiteResults');
+ assert.strictEqual(mirroredSession.suiteResults[0].suiteId, 'set-1', '恢复快照应保留 suiteId');
+ const durableSession = recoveryControl.events
+ .filter((event) => event.type === 'save' && event.value.schema === 'multi-suite-sessions-v2')
+ .at(-1)?.value;
+ assert(durableSession, '部分结果必须写入 AppData v2 activeSession');
+ assert.strictEqual(durableSession.sessions[0].suiteResults.length, 1);
+
+ const restoredApp = createApp(windowStub, { suiteModeReady: false });
+ restoredApp.initializeSuiteMode();
+ await restoredApp._ensureSuiteRecoveryReady();
+ const restoredSession = restoredApp.multiSuiteSessionsMap.get('listening-100-p1');
+ assert(restoredSession, '匹配当前标签页窗口镜像时应从 v2 恢复 multi-suite 会话');
+ assert.strictEqual(restoredSession.status, 'active', '部分恢复会话应保持 active');
+ assert.strictEqual(restoredSession.expectedSuiteCount, 2, '恢复会话应保留 expectedSuiteCount');
+ assert.strictEqual(restoredSession.suiteResults.length, 1, '恢复会话应保留部分结果');
+ assert.deepStrictEqual(
+ plain(restoredSession.suiteResults[0].answerComparison),
+ { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ '恢复会话应保留结果比较数据'
+ );
+
+ assert.strictEqual(
+ await restoredApp._releaseSuiteRecoveryClaim('multi', restoredSession),
+ true
+ );
+ const originalLocks = windowStub.navigator.locks;
+ const durableTakeoverLocks = {
+ held: new Map(),
+ calls: [],
+ async request(name, lockOptions = {}, callback) {
+ assert.strictEqual(lockOptions.mode, 'exclusive');
+ assert.strictEqual(lockOptions.ifAvailable, true);
+ const normalizedName = String(name || '');
+ this.calls.push(normalizedName);
+ if (this.held.has(normalizedName)) return callback(null);
+ const lock = { name: normalizedName, mode: 'exclusive' };
+ this.held.set(normalizedName, lock);
+ try {
+ return await callback(lock);
+ } finally {
+ if (this.held.get(normalizedName) === lock) this.held.delete(normalizedName);
+ }
+ }
+ };
+ windowStub.navigator.locks = durableTakeoverLocks;
+ const durableOwnerApp = createApp(windowStub);
+ const durableOwnerSession = plain(restoredSession);
+ assert.strictEqual(
+ await durableOwnerApp._acquireMultiSuiteRecoveryOwnership(durableOwnerSession),
+ true,
+ 'active tab fixture must hold the base and exact durable recovery leases'
+ );
+ windowSessionStore.delete('multi-suite-practice');
+ const foreignTabApp = createApp(windowStub, { suiteModeReady: false });
+ foreignTabApp.initializeSuiteMode();
+ await foreignTabApp._ensureSuiteRecoveryReady();
+ assert.strictEqual(
+ foreignTabApp.multiSuiteSessionsMap.has('listening-100-p1'),
+ false,
+ 'fresh HTTP tab must not bypass an active durable owner lease'
+ );
+ assert.strictEqual(
+ await durableOwnerApp._releaseSuiteRecoveryClaim('multi', durableOwnerSession),
+ true
+ );
+ const takeoverFallback = foreignTabApp.getOrCreateMultiSuiteSession(
+ 'listening-100-p1_set1',
+ { install: false }
+ );
+ const takeover = await foreignTabApp._refreshPersistentMultiSuiteBase(
+ 'listening-100-p1',
+ takeoverFallback
+ );
+ assert.strictEqual(takeover.blocked, false);
+ const takenOverSession = takeover.session;
+ assert(takenOverSession, 'the same fresh tab must recover durable state after owner release/crash');
+ assert.strictEqual(takenOverSession.id, durableSession.id);
+ assert.strictEqual(
+ await foreignTabApp._releaseSuiteRecoveryClaim('multi', takenOverSession),
+ true
+ );
+ for (const recoveredSession of Array.from(foreignTabApp.multiSuiteSessionsMap.values())) {
+ if (foreignTabApp._ownsMultiSuiteRecoveryOwnership(recoveredSession)) {
+ assert.strictEqual(
+ await foreignTabApp._releaseSuiteRecoveryClaim('multi', recoveredSession),
+ true
+ );
+ }
+ }
+ if (foreignTabApp.currentSuiteSession
+ && foreignTabApp._ownsSuiteRecoveryClaim('single', foreignTabApp.currentSuiteSession)) {
+ assert.strictEqual(
+ await foreignTabApp._releaseSuiteRecoveryClaim('single', foreignTabApp.currentSuiteSession),
+ true
+ );
+ }
+ assert.strictEqual(
+ durableTakeoverLocks.held.has(foreignTabApp._suiteRecoveryClaimName(durableSession.id)),
+ false,
+ 'the exact durable takeover lease must be released by fixture cleanup'
+ );
+ windowStub.navigator.locks = originalLocks;
+
+ const retryApp = createApp(windowStub);
+ const retryPayload = {
+ suiteId: 'set-1',
+ totalSuites: 2,
+ sessionId: 'multi-suite-retry-child',
+ answers: { q1: 'B' },
+ answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ };
+ const saveEventStart = recoveryControl.events.length;
+ recoveryControl.saveQueue.push(false);
+ assert.strictEqual(
+ await retryApp.handleMultiSuitePracticeComplete('listening-retry-p1_set1', retryPayload),
+ false,
+ 'durable receipt 未确认时部分结果必须 NACK'
+ );
+ const refreshedAfterNack = createApp(windowStub, { suiteModeReady: false });
+ refreshedAfterNack.initializeSuiteMode();
+ await refreshedAfterNack._ensureSuiteRecoveryReady();
+ assert.strictEqual(
+ refreshedAfterNack.multiSuiteSessionsMap.has('listening-retry-p1'),
+ false,
+ 'v2 枚举成功后不得从 window WAL 恢复未提交结果'
+ );
+ assert.strictEqual(
+ await retryApp.handleMultiSuitePracticeComplete('listening-retry-p1_set1', retryPayload),
+ true,
+ '相同结果重试必须再次尝试 durable save'
+ );
+ assert.strictEqual(
+ recoveryControl.events.slice(saveEventStart).filter((event) => (
+ event.type === 'save'
+ && event.value.schema === 'multi-suite-sessions-v2'
+ && event.value.sessions[0].baseExamId === 'listening-retry-p1'
+ )).length,
+ 2,
+ '内存中已存在结果不能绕过未完成的 v2 写入'
+ );
}
- // Case 8.1: 迟到 SESSION_READY 若窗口 URL 已切到其他篇,必须忽略
- {
+ // Case 3.0.5: reading suite 聚合提交后的 UI/清理故障不得触发单篇 fallback 或假 NACK
+ for (const failingStep of ['sync', 'overview', 'message', 'teardown-schedule']) {
const app = createApp(windowStub);
- const session = makeSession('suite_stale_ready');
- session.currentIndex = 0;
- session.activeExamId = 'reading-p1';
+ const session = makeSession(`suite_post_commit_${failingStep}`);
+ const sourceWindow = session.windowRef;
+ const examId = 'reading-p1';
+ const sessionId = `session-post-commit-${failingStep}`;
+ const submissionId = `submission-post-commit-${failingStep}`;
+ sourceWindow.location.href = `http://localhost/${examId}.html`;
app.currentSuiteSession = session;
app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
- app.examWindows = new Map();
+ app.examWindows = new Map([[examId, {
+ window: sourceWindow,
+ expectedSessionId: sessionId,
+ sessionId,
+ windowSessionToken: `token-post-commit-${failingStep}`,
+ windowSessionTokenSessionId: sessionId,
+ expectedUrl: sourceWindow.location.href,
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false,
+ suiteSessionId: session.id
+ }]]);
- const staleWindow = createStubWindow('ready-window');
- staleWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p1';
- app.examWindows.set('reading-p2', {
- examId: 'reading-p2',
- window: staleWindow,
- expectedSessionId: 'session-reading-p2',
+ const aggregateRecords = [];
+ let partialFallbacks = 0;
+ let standaloneFallbacks = 0;
+ app._saveSuitePracticeRecord = async (record) => {
+ aggregateRecords.push(record);
+ };
+ app._savePartialSuiteAsIndividual = async () => {
+ partialFallbacks += 1;
+ };
+ app.saveRealPracticeData = async () => {
+ standaloneFallbacks += 1;
+ return { id: `unexpected-standalone-${failingStep}` };
+ };
+ if (failingStep === 'sync') {
+ app._updatePracticeRecordsState = async () => { throw new Error('expected sync failure'); };
+ } else if (failingStep === 'overview') {
+ app.refreshOverviewData = () => { throw new Error('expected overview failure'); };
+ } else if (failingStep === 'teardown-schedule') {
+ app._scheduleSuiteSubmitTeardown = () => { throw new Error('expected teardown scheduling failure'); };
+ }
+ const originalShowMessage = windowStub.showMessage;
+ if (failingStep === 'message') {
+ windowStub.showMessage = () => { throw new Error('expected completion message failure'); };
+ }
+
+ const payload = {
+ examId,
+ sessionId,
+ submissionId,
suiteSessionId: session.id,
- pageType: 'unified-reading'
- });
+ suiteSubmission: true,
+ duration: 3600,
+ suiteEntries: session.sequence.map((entry, index) => ({
+ examId: entry.examId,
+ title: entry.exam.title,
+ category: entry.exam.category,
+ duration: 1200,
+ answers: { q1: String.fromCharCode(65 + index) },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ }))
+ };
- app.handleSessionReady('reading-p2', {
- sessionId: 'session-reading-p2',
- pageType: 'unified-reading'
- });
+ try {
+ assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true, `${failingStep}: committed suite must return success`);
+ app.examWindows.get(examId).practiceSubmitReceipts = {};
+ assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true, `${failingStep}: completed-session retry must return success without receipt cache`);
+ } finally {
+ windowStub.showMessage = originalShowMessage;
+ if (session.submitReceiptTeardownTimer) {
+ clearTimeout(session.submitReceiptTeardownTimer);
+ session.submitReceiptTeardownTimer = null;
+ }
+ }
+ const outcomes = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type));
+ assert(outcomes.length >= 2 && outcomes.every(message => message.type === 'PRACTICE_SUBMIT_ACK'), `${failingStep}: commit and completed-session retry must only ACK`);
+ assert.strictEqual(aggregateRecords.length, 1, `${failingStep}: aggregate record must be written exactly once`);
+ assert.strictEqual(partialFallbacks, 0, `${failingStep}: individual suite fallback must not run after commit`);
+ assert.strictEqual(standaloneFallbacks, 0, `${failingStep}: outer standalone fallback must not run after commit`);
+ assert.strictEqual(session.status, 'completed', `${failingStep}: committed session must stay completed`);
+ }
- assert.strictEqual(session.activeExamId, 'reading-p1', '迟到 SESSION_READY 不得覆写 activeExamId');
- assert.strictEqual(session.currentIndex, 0, '迟到 SESSION_READY 不得覆写 currentIndex');
- const staleCtx = staleWindow._messages.find(item => item && item.type === 'SIMULATION_CONTEXT');
- assert.strictEqual(staleCtx, undefined, '迟到 SESSION_READY 不应下发模拟上下文');
+ // Case 3.0.6: multi-suite 聚合提交后的各后置步骤故障仍须 ACK 且保持单次聚合写入
+ for (const failingStep of ['spelling', 'sync', 'overview', 'session-cleanup', 'message']) {
+ const app = createApp(windowStub);
+ const examId = `listening-multi-post-commit-${failingStep}`;
+ const sessionId = `session-multi-post-commit-${failingStep}`;
+ const submissionId = `submission-multi-post-commit-${failingStep}`;
+ const suiteSessionId = `suite-multi-post-commit-${failingStep}`;
+ const sourceWindow = createStubWindow(`multi-post-commit-${failingStep}`);
+ sourceWindow.location.href = `http://localhost/${examId}.html`;
+ app.examWindows = new Map([[examId, {
+ window: sourceWindow,
+ expectedSessionId: sessionId,
+ sessionId,
+ windowSessionToken: `token-multi-post-commit-${failingStep}`,
+ windowSessionTokenSessionId: sessionId,
+ expectedUrl: sourceWindow.location.href,
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false
+ }]]);
+
+ const aggregateRecords = [];
+ let standaloneFallbacks = 0;
+ app._saveSuitePracticeRecord = async (record) => {
+ aggregateRecords.push(record);
+ };
+ app.saveRealPracticeData = async () => {
+ standaloneFallbacks += 1;
+ return { id: `unexpected-multi-standalone-${failingStep}` };
+ };
+ if (failingStep === 'sync') {
+ app._updatePracticeRecordsState = async () => { throw new Error('expected multi sync failure'); };
+ } else if (failingStep === 'overview') {
+ app.refreshOverviewData = () => { throw new Error('expected multi overview failure'); };
+ } else if (failingStep === 'session-cleanup') {
+ app.multiSuiteSessionsMap = new class extends Map {
+ delete() { throw new Error('expected multi session cleanup failure'); }
+ }();
+ }
+ const originalShowMessage = windowStub.showMessage;
+ const originalCollector = windowStub.spellingErrorCollector;
+ windowStub.spellingErrorCollector = {
+ async saveErrors() {
+ if (failingStep === 'spelling') throw new Error('expected spelling sync failure');
+ }
+ };
+ if (failingStep === 'message') {
+ windowStub.showMessage = () => { throw new Error('expected multi completion message failure'); };
+ }
+ const payload = {
+ examId,
+ sessionId,
+ submissionId,
+ suiteSessionId,
+ suiteId: 'set-1',
+ totalSuites: 1,
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 },
+ spellingErrors: [{ word: 'practice', answer: 'practise' }]
+ };
+
+ try {
+ assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true, `${failingStep}: committed multi-suite must return success`);
+ assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true, `${failingStep}: multi-suite receipt replay must return success`);
+ } finally {
+ windowStub.showMessage = originalShowMessage;
+ windowStub.spellingErrorCollector = originalCollector;
+ }
+ const outcomes = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type));
+ assert(outcomes.length >= 2 && outcomes.every(message => message.type === 'PRACTICE_SUBMIT_ACK'), `${failingStep}: multi-suite commit and replay must only ACK`);
+ assert.strictEqual(aggregateRecords.length, 1, `${failingStep}: multi-suite aggregate must be written exactly once`);
+ assert.strictEqual(standaloneFallbacks, 0, `${failingStep}: multi-suite must not enter standalone fallback after commit`);
}
- // Case 8.2: waitForSuiteWindowExamReady 不得把调用前的旧 ready 当成当前切题成功
+ // Case 3.0.6.1: durable recovery 清理后,v2 聚合记录仍是精确提交的幂等收据
{
const app = createApp(windowStub);
- const session = makeSession('suite_ready_timestamp_guard');
- const targetWindow = createStubWindow('ready-window');
- targetWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p2';
- app.examWindows = new Map([
- ['reading-p2', {
- examId: 'reading-p2',
- window: targetWindow,
- suiteSessionId: session.id,
- pageType: 'unified-reading',
- lastMessageType: 'SESSION_READY',
- lastMessageAt: Date.now() - 5000
- }]
- ]);
+ await app._ensureSuiteRecoveryReady();
+ const examId = 'listening-multi-canonical-receipt_set1';
+ const payload = {
+ suiteId: 'set-1',
+ totalSuites: 1,
+ sessionId: 'multi-canonical-child',
+ submissionId: 'multi-canonical-submission',
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ };
- const ready = await app._waitForSuiteWindowExamReady(session, 'reading-p2', targetWindow, 120);
- assert.strictEqual(ready, false, '调用前的旧 SESSION_READY 不能被误判为当前窗口已就绪');
+ assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, payload), true);
+ assert.strictEqual(app.multiSuiteSessionsMap.has('listening-multi-canonical-receipt'), false, 'cleanup 成功后 runtime recovery 应释放');
+ const committed = await windowStub.AppData.practice.list({ projection: 'detail' });
+ assert.strictEqual(committed.length, 1);
+ assert.strictEqual(committed[0].suiteEntries[0].metadata.sessionId, payload.sessionId);
+ assert.strictEqual(committed[0].suiteEntries[0].metadata.submissionId, payload.submissionId);
+
+ const refreshed = createApp(windowStub, { suiteModeReady: false });
+ refreshed.initializeSuiteMode();
+ await refreshed._ensureSuiteRecoveryReady();
+ assert.strictEqual(await refreshed.handleMultiSuitePracticeComplete(examId, payload), true, '刷新后的精确重放必须由 canonical 记录 ACK');
+ assert.strictEqual((await windowStub.AppData.practice.list({ projection: 'detail' })).length, 1, 'canonical 重放不能生成第二条聚合记录');
+
+ const concurrentApp = createApp(windowStub);
+ const originalFinalizeSuite = windowStub.AppData.practice.finalizeSuite;
+ let finalizeCalls = 0;
+ windowStub.AppData.practice.finalizeSuite = async (...args) => {
+ finalizeCalls += 1;
+ return originalFinalizeSuite(...args);
+ };
+ try {
+ assert.deepStrictEqual(
+ await Promise.all([
+ concurrentApp.handleMultiSuitePracticeComplete('listening-multi-concurrent_set1', payload),
+ concurrentApp.handleMultiSuitePracticeComplete('listening-multi-concurrent_set1', payload)
+ ]),
+ [true, true],
+ '完整 triple 的并发重放必须得到相同 ACK'
+ );
+ } finally {
+ windowStub.AppData.practice.finalizeSuite = originalFinalizeSuite;
+ }
+ assert.strictEqual(finalizeCalls, 1, '并发精确重放只能生成一次 canonical aggregate');
}
- // Case 8.3: 复用窗口切题若未等到 fresh ready,不得提前把目标篇高亮推送到旧页
+ // Case 3.0.6.2: completed multi-suite 只拥有原提交重放,不能吞掉同 base 的下一次运行
{
const app = createApp(windowStub);
- const session = makeSession('suite_reuse_window_highlight_guard');
- session.currentIndex = 0;
- session.activeExamId = 'reading-p1';
- app.currentSuiteSession = session;
- app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
-
- const reusedWindow = createStubWindow('suite-window');
- reusedWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p1';
- session.windowRef = reusedWindow;
- app.openExam = async (_examId, options = {}) => options.reuseWindow || reusedWindow;
- app._waitForSuiteWindowExamReady = async () => false;
+ await app._ensureSuiteRecoveryReady();
+ const examId = 'listening-multi-owner_set1';
+ const aggregateRecords = new Map();
+ const saveAttempts = [];
+ app._saveSuitePracticeRecord = async (record) => {
+ saveAttempts.push(plain(record));
+ aggregateRecords.set(record.operationId, plain(record));
+ };
+ const originalDiscard = windowStub.AppData.recovery.discardActiveSession;
+ const discardCalls = [];
+ windowStub.AppData.recovery.discardActiveSession = async (id, options) => {
+ discardCalls.push({ id: String(id), options: plain(options || {}) });
+ return { committed: false };
+ };
+ const firstPayload = {
+ suiteId: 'set-1',
+ totalSuites: 1,
+ sessionId: 'multi-owner-child',
+ submissionId: 'multi-owner-submission-1',
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 },
+ spellingErrors: [{ word: 'practice', userInput: 'practise' }]
+ };
+ const completedSession = app.getOrCreateMultiSuiteSession(examId);
- const ok = await app._handleSimulationNavigate('reading-p1', {
- direction: 'next',
- draft: {
- answers: { q1: 'A' },
- highlights: [{ scope: 'left', text: 'P1 highlight before switch' }],
- scrollY: 123,
- updatedAt: Date.now()
- },
- resultSnapshot: {
- answers: { q1: 'A' },
- answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
- scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ try {
+ assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, firstPayload), true);
+ assert.strictEqual(aggregateRecords.size, 1, '首次运行只能生成一条聚合记录');
+ assert.strictEqual(saveAttempts.length, 1, '首次运行只能提交一次 aggregate');
+ assert.strictEqual(discardCalls.length, 1, 'aggregate 成功后必须尝试清理 durable recovery');
+ assert.strictEqual(discardCalls[0].id, completedSession.id, 'durable cleanup 必须针对已完成会话');
+ assert.strictEqual(app.multiSuiteSessionsMap.get(completedSession.baseExamId), completedSession, 'durable cleanup 失败时应保留完成态作为重放收据');
+
+ // durable recovery 在 aggregate commit 与 cleanup 之间只可能恢复到 finalizing;
+ // 必须先用原 operationId 收敛旧记录,再解释当前 submission。
+ completedSession.status = 'finalizing';
+ app.multiSuiteSessionsMap.set(completedSession.baseExamId, completedSession);
+ assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, firstPayload), true, 'finalizing 原 submission 必须幂等收敛');
+ assert.strictEqual(aggregateRecords.size, 1, 'finalizing 重放不能创建第二条聚合记录');
+ assert.strictEqual(saveAttempts.length, 2, 'finalizing 重放必须再次提交原 frozen aggregate');
+ assert.deepStrictEqual(saveAttempts[1], saveAttempts[0], 'finalizing 重放的 aggregate 必须保持字节语义稳定');
+
+ const oldV2Snapshot = plain(completedSession);
+ oldV2Snapshot.status = 'finalizing';
+ delete oldV2Snapshot.suiteResults[0].metadata.submissionId;
+ delete oldV2Snapshot.finalizeRecord.suiteEntries[0].metadata;
+ oldV2Snapshot.finalizeRecord.spellingErrors[0].timestamp += 1;
+ assert.strictEqual(app._isValidMultiSuiteRecoverySnapshot({
+ schema: 'multi-suite-sessions-v2', version: 2, sessions: [oldV2Snapshot]
+ }), true, '升级前缺少 entry metadata 的 v2 frozen snapshot 仍应可恢复');
+
+ // 模拟旧实现中 cleanup 失败后残留的 completed runtime session。
+ app.multiSuiteSessionsMap.set(completedSession.baseExamId, completedSession);
+ assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, firstPayload), true, '原 submission 重放必须幂等成功');
+ assert.strictEqual(aggregateRecords.size, 1, '原 submission 重放不能重复聚合');
+ assert.strictEqual(saveAttempts.length, 2, 'completed 原 submission 重放不能再次调用 aggregate 保存');
+
+ const nextPayload = {
+ ...firstPayload,
+ submissionId: 'multi-owner-submission-2',
+ answers: { q1: 'B' },
+ answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } }
+ };
+ assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, nextPayload), true, '新 submission 必须创建新运行');
+ assert.strictEqual(aggregateRecords.size, 2, '同 base 的下一次运行必须生成独立聚合记录');
+ assert.strictEqual(saveAttempts.length, 3, '下一次运行必须只新增一次 aggregate 保存');
+ const records = Array.from(aggregateRecords.values());
+ assert.notStrictEqual(records[0].id, records[1].id, '两次运行必须使用不同 multi-suite session id');
+ assert.strictEqual(records[1].answers['set-1::q1'], 'B', '新聚合必须包含下一次运行的答案');
+ assert.strictEqual(records[1].suiteEntries.length, 1, '新聚合不能混入旧运行结果');
+ assert.strictEqual(records[1].scoreInfo.total, 1, '新聚合分数只能来自当前运行');
+ assert.strictEqual(records[1].suiteEntries[0].rawData.submissionId, nextPayload.submissionId, '新聚合必须归属当前 submission');
+
+ const nextChildPayload = {
+ ...nextPayload,
+ sessionId: 'multi-owner-child-2',
+ answers: { q1: 'C' },
+ answerComparison: { q1: { userAnswer: 'C', correctAnswer: 'C', isCorrect: true } }
+ };
+ assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, nextChildPayload), true, '不同 child session 必须创建新运行');
+ assert.strictEqual(aggregateRecords.size, 3, '不同 child session 不能被误判为 completed 重放');
+ assert.strictEqual(saveAttempts.length, 4, '不同 child session 必须只新增一次 aggregate 保存');
+ assert.strictEqual(Array.from(aggregateRecords.values())[2].answers['set-1::q1'], 'C');
+
+ completedSession.status = 'finalizing';
+ completedSession.finalizeOperationId = 'wrong-operation-id';
+ app.multiSuiteSessionsMap.set(completedSession.baseExamId, completedSession);
+ assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, firstPayload), false, '错配 operationId 的 frozen aggregate 必须 fail closed');
+ assert.strictEqual(saveAttempts.length, 4, '错配 operationId 不能触发 aggregate 保存');
+
+ completedSession.finalizeOperationId = completedSession.finalizeRecord.operationId;
+ completedSession.finalizeRecord.spellingErrors = [];
+ assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, firstPayload), false, '损坏的顶层拼写汇总必须 fail closed');
+ assert.strictEqual(saveAttempts.length, 4, '损坏的拼写汇总不能调用保存');
+
+ completedSession.finalizeRecord = plain(saveAttempts[0]);
+ completedSession.finalizeOperationId = completedSession.finalizeRecord.operationId;
+ completedSession.suiteResults[0].answers.q1 = 'tampered';
+ app.multiSuiteSessionsMap.set(completedSession.baseExamId, completedSession);
+ assert.strictEqual(
+ await app.handleMultiSuitePracticeComplete(examId, firstPayload),
+ false,
+ '不一致的 frozen aggregate 必须 fail closed,不能复用 operationId 重建'
+ );
+ assert.strictEqual(aggregateRecords.size, 3, '损坏 frozen aggregate 不能写入新记录');
+ assert.strictEqual(saveAttempts.length, 4, '损坏 frozen aggregate 不能调用保存');
+ app.multiSuiteSessionsMap.delete(completedSession.baseExamId);
+ } finally {
+ windowStub.AppData.recovery.discardActiveSession = originalDiscard;
+ for (const record of aggregateRecords.values()) {
+ await originalDiscard(record.id);
}
- }, reusedWindow);
-
- assert.strictEqual(ok, true, 'ready 超时时切题流程仍应继续,由后续 SESSION_READY 兜底');
- assert.strictEqual(session.activeExamId, 'reading-p2', 'activeExamId 应先对齐到目标篇');
- assert.strictEqual(
- reusedWindow._messages.some(message => message && message.type === 'SIMULATION_CONTEXT'),
- false,
- '未拿到 fresh ready 前不得向复用窗口提前发送 SIMULATION_CONTEXT,避免旧页误吃目标篇高亮'
- );
- assert.deepStrictEqual(
- session.draftsByExam['reading-p1'].highlights,
- [{ scope: 'left', text: 'P1 highlight before switch' }],
- '切题前当前篇高亮仍应保存在 draft 中'
- );
+ }
}
- // Case 8.4: 复用窗口若已落到目标篇 URL,即使 fresh ready 缺失也应兜底下发上下文
+ // Case 3.0.6.3: finalizing 必须携带 frozen pair;同一 active suite 的不同提交不能误 ACK
{
const app = createApp(windowStub);
- const session = makeSession('suite_reuse_window_target_url_fallback');
- session.currentIndex = 0;
- session.activeExamId = 'reading-p1';
- const p2Highlights = [{ scope: 'left', text: 'P2 highlight after switch', color: 'green' }];
- session.draftsByExam['reading-p2'] = { answers: { q5: 'B' }, highlights: p2Highlights, scrollY: 66 };
- app.currentSuiteSession = session;
- app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
-
- const reusedWindow = createStubWindow('suite-window');
- reusedWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p2';
- session.windowRef = reusedWindow;
- app.examWindows = new Map([
- ['reading-p2', { window: reusedWindow }]
- ]);
- app.openExam = async (_examId, options = {}) => options.reuseWindow || reusedWindow;
- app._waitForSuiteWindowExamReady = async () => false;
-
- const ok = await app._handleSimulationNavigate('reading-p1', {
- direction: 'next',
- draft: {
- answers: { q1: 'A' },
- highlights: [{ scope: 'left', text: 'P1 highlight before switch' }],
- scrollY: 123,
- updatedAt: Date.now()
- }
- }, reusedWindow);
-
- assert.strictEqual(ok, true, '目标窗口 URL 已切到新篇时,切题流程应允许兜底恢复');
- const ctxMsg = reusedWindow._messages.find(message => message && message.type === 'SIMULATION_CONTEXT');
- assert(ctxMsg, '目标窗口 URL 已切到新篇时,应继续下发 SIMULATION_CONTEXT');
- assert.strictEqual(ctxMsg.data.examId, 'reading-p2', '兜底上下文必须指向目标篇');
- assert.deepStrictEqual(ctxMsg.data.draft.highlights, p2Highlights, '目标篇高亮应随兜底上下文一起恢复');
+ await app._ensureSuiteRecoveryReady();
+ const examId = 'listening-multi-active-conflict_set1';
+ const firstPayload = {
+ suiteId: 'set-1',
+ totalSuites: 2,
+ sessionId: 'multi-active-child',
+ submissionId: 'multi-active-submission-1',
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ };
+ assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, firstPayload), true);
+ const session = app.multiSuiteSessionsMap.get('listening-multi-active-conflict');
+ assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, {
+ ...firstPayload,
+ submissionId: 'multi-active-submission-2',
+ answers: { q1: 'B' }
+ }), false, '同一 active suite 的不同 submission 不能 ACK 未持久化答案');
+ assert.strictEqual(session.suiteResults.length, 1);
+ assert.strictEqual(session.suiteResults[0].answers.q1, 'A');
+
+ const missingFrozen = plain(session);
+ missingFrozen.status = 'finalizing';
+ missingFrozen.finalizeOperationId = null;
+ missingFrozen.finalizeRecord = null;
+ assert.strictEqual(await app.finalizeMultiSuiteRecord(missingFrozen), false, 'finalizing 缺少 frozen pair 必须 fail closed');
+ assert.strictEqual(app._isValidMultiSuiteRecoverySnapshot({
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ sessions: [missingFrozen]
+ }), false, '缺少 frozen pair 的 finalizing recovery 不能进入运行态');
+ await windowStub.AppData.recovery.discardActiveSession(session.id);
+ app.multiSuiteSessionsMap.delete(session.baseExamId);
}
- // Case 7: 错篇 PRACTICE_COMPLETE 必须被忽略,不能污染结果
+ // Case 3.0.6.4: 恢复的 active-complete 会话(finalize 前崩溃窗口)须先幂等收敛,
+ // 再让同 base 新一轮继续,而不是被已记录的同 suiteId 阻塞 NACK。
{
const app = createApp(windowStub);
- const session = makeSession('suite_wrong_exam_complete');
- session.activeExamId = 'reading-p2';
- app.currentSuiteSession = session;
- app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
-
- const handled = await app.handleSuitePracticeComplete('reading-p1', {
- suiteSessionId: session.id,
+ await app._ensureSuiteRecoveryReady();
+ const examId = 'listening-multi-active-complete_set1';
+
+ // 直接构造 active-complete 会话(结果已齐、无 frozen record)。
+ // 真实崩溃窗口:最后一次 _commitMultiSuiteRecovery 已把 active 状态写入 durable,
+ // finalize 尚未执行。因此 durable 与 WAL 都存在,durable 为权威。
+ const result = {
+ suiteId: 'set-1',
+ examId: examId,
answers: { q1: 'A' },
+ correctAnswers: { q1: 'A' },
answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 },
+ spellingErrors: [],
+ timestamp: Date.now(),
+ duration: 10,
+ metadata: { sessionId: 'multi-active-complete-child', submissionId: 'multi-active-complete-sub-1' },
+ rawData: null
+ };
+ const activeCompleteSession = {
+ id: 'multi_listening-multi-active-complete_crash_1',
+ baseExamId: 'listening-multi-active-complete',
+ status: 'active',
+ startTime: Date.now(),
+ suiteResults: [result],
+ expectedSuiteCount: 1,
+ metadata: { source: 'p1', createdAt: new Date().toISOString() },
+ lastUpdate: Date.now(),
+ revision: 1
+ };
+ const durableSnap = {
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: activeCompleteSession.id,
+ revision: 1,
+ sessions: [activeCompleteSession],
+ updatedAt: Date.now()
+ };
+ await windowStub.AppData.recovery.saveActiveSession(durableSnap);
+ windowSessionStore.set('multi-suite-practice', durableSnap);
+
+ const restoredApp = createApp(windowStub, { suiteModeReady: false });
+ restoredApp.initializeSuiteMode();
+ await restoredApp._ensureSuiteRecoveryReady();
+ const restored = restoredApp.multiSuiteSessionsMap.get('listening-multi-active-complete');
+ assert(restored, 'active-complete durable 快照应可恢复');
+ assert.strictEqual(restored.status, 'active');
+
+ // 新一轮同 suiteId、新身份:应触发收敛 finalize 旧会话,再接纳新结果。
+ const aggregateRecords = [];
+ restoredApp._saveSuitePracticeRecord = async (record) => { aggregateRecords.push(record); };
+ const newPayload = {
+ suiteId: 'set-1',
+ totalSuites: 1,
+ sessionId: 'multi-active-complete-new-child',
+ submissionId: 'multi-active-complete-sub-2',
+ answers: { q1: 'B' },
+ answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } },
scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
- }, session.windowRef);
-
- assert.strictEqual(handled, true, '错篇提交应被处理为忽略');
- assert.strictEqual(session.results.length, 0, '错篇提交不得写入 session.results');
+ };
+ assert.strictEqual(
+ await restoredApp.handleMultiSuitePracticeComplete(examId, newPayload),
+ true,
+ 'active-complete 恢复后同 suiteId 新提交不得被 NACK'
+ );
+ // 旧 active-complete 会话先收敛为一条聚合记录;随后新一轮完成自己的一条。
+ assert.strictEqual(aggregateRecords.length, 2, '旧会话收敛 + 新一轮完成各生成一条记录');
+ const firstRecord = aggregateRecords[0];
+ assert.strictEqual(firstRecord.suiteEntries.length, 1, '收敛记录应包含旧结果');
+ const secondRecord = aggregateRecords[1];
+ assert.strictEqual(secondRecord.suiteEntries[0].answers.q1, 'B', '新一轮结果应聚合进第二条记录');
+ if (restored && restored.id) {
+ await windowStub.AppData.recovery.discardActiveSession(restored.id);
+ }
+ restoredApp.multiSuiteSessionsMap.delete('listening-multi-active-complete');
}
- // Case 9: 听力桥的临时 examId/sessionId 不得阻断父应用当前题源落库
+ // Case 3.0.6.5: 损坏 durable + 有效 window-WAL 同时存在时,恢复须保留 WAL 回退;
+ // 而 durable 完全不存在(save 从未成功)时仍丢弃 WAL(未提交结果不恢复)。
{
const app = createApp(windowStub);
- const examWindow = createStubWindow('custom-listening-window');
- const examId = 'custom-listening-teacher-pack';
- const expectedSessionId = 'custom-listening-teacher-pack_expected';
- let captured = null;
-
- app.handlePracticeComplete = async (handledExamId, data) => {
- captured = { examId: handledExamId, data };
+ await app._ensureSuiteRecoveryReady();
+ const examId = 'listening-multi-corrupt-durable_set1';
+ const payload = {
+ suiteId: 'set-1',
+ totalSuites: 2,
+ sessionId: 'multi-corrupt-child',
+ submissionId: 'multi-corrupt-sub-1',
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
};
-
- app.setupExamWindowCommunication(examWindow, examId, {
- id: examId,
- title: 'Teacher Pack Listening',
- type: 'listening'
+ assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, payload), true);
+ const session = app.multiSuiteSessionsMap.get('listening-multi-corrupt-durable');
+
+ // 保留 WAL,同时写入一个损坏的 durable(结构合法但校验失败:finalizeOperationId 错配)。
+ const corruptDurable = plain(session);
+ corruptDurable.finalizeOperationId = 'practice-multisuite:wrong:id:finalize';
+ corruptDurable.finalizeRecord = { id: 'stale-record' };
+ const corruptDurableRevision = Number(session.revision) + 7;
+ await windowStub.AppData.recovery.saveActiveSession({
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: session.id,
+ revision: corruptDurableRevision,
+ sessions: [corruptDurable],
+ updatedAt: Date.now()
});
- const info = app.ensureExamWindowSession(examId, examWindow);
- info.expectedSessionId = expectedSessionId;
- app.examWindows.set(examId, info);
-
- const handler = app.messageHandlers.get(examId);
- assert.strictEqual(typeof handler, 'function', '听力题源应注册 message handler');
+ const restoredApp = createApp(windowStub, { suiteModeReady: false });
+ restoredApp.initializeSuiteMode();
+ await restoredApp._ensureSuiteRecoveryReady();
+ assert(
+ restoredApp.multiSuiteSessionsMap.has('listening-multi-corrupt-durable'),
+ '损坏 durable 存在时应保留 window-WAL 回退,不能把有效草稿抹掉'
+ );
+ const restoredSession = restoredApp.multiSuiteSessionsMap.get('listening-multi-corrupt-durable');
+ assert.strictEqual(
+ restoredSession._lastDurableRecoveryRevision,
+ corruptDurableRevision,
+ '匹配的损坏 durable 必须把实体 revision 交给保留的 window-WAL'
+ );
+ assert.strictEqual(
+ restoredSession.revision,
+ corruptDurableRevision,
+ '保留的 window-WAL 必须追平 durable revision,确保下一次变更可以前进'
+ );
+ restoredSession.revision += 1;
+ recoveryControl.saveQueue.push(async (value, options) => {
+ const currentDurable = (await windowStub.AppData.recovery.listActiveSessions())
+ .find((item) => String(item && item.id || '') === String(value.id));
+ assert.strictEqual(Number(currentDurable && currentDurable.revision), corruptDurableRevision);
+ assert.strictEqual(
+ options.expectedEntityRevision,
+ corruptDurableRevision,
+ 'WAL 恢复后的下一次保存必须从匹配的 durable revision 做 CAS'
+ );
+ assert.strictEqual(value.revision, corruptDurableRevision + 1);
+ return { committed: true, item: plain(value) };
+ });
+ assert.strictEqual(
+ await restoredApp._commitMultiSuiteRecovery(restoredSession),
+ true,
+ '继承 durable revision 后的 window-WAL 必须仍可继续保存'
+ );
+ assert.notStrictEqual(restoredSession._suiteRecoveryWritesBlocked, true);
+
+ for (const [label, invalidRevision] of [['fractional', 1.5], ['infinite', Infinity]]) {
+ const invalidRevisionWal = {
+ ...plain(restoredSession),
+ id: `multi-invalid-revision-${label}`,
+ baseExamId: `listening-multi-invalid-revision-${label}`,
+ revision: 2,
+ _restoredFromWindowSession: true
+ };
+ delete invalidRevisionWal._lastDurableRecoveryRevision;
+ restoredApp.multiSuiteSessionsMap.set(invalidRevisionWal.baseExamId, invalidRevisionWal);
+ const invalidRevisionDurable = plain(invalidRevisionWal);
+ delete invalidRevisionDurable._restoredFromWindowSession;
+ invalidRevisionDurable.finalizeOperationId = 'practice-multisuite:wrong:id:finalize';
+ invalidRevisionDurable.finalizeRecord = { id: 'stale-record' };
+ await restoreOwnedMultiSuiteItems(restoredApp, invalidRevisionWal, [{
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: invalidRevisionWal.id,
+ revision: invalidRevision,
+ sessions: [invalidRevisionDurable]
+ }]);
+ const retainedWal = restoredApp.multiSuiteSessionsMap.get(invalidRevisionWal.baseExamId);
+ assert.strictEqual(retainedWal, invalidRevisionWal);
+ assert.strictEqual(retainedWal._lastDurableRecoveryRevision, 0);
+ assert.strictEqual(retainedWal.revision, 2);
+ retainedWal.revision += 1;
+ recoveryControl.saveQueue.push((value, options) => {
+ assert.strictEqual(options.expectedEntityRevision, 0);
+ assert.strictEqual(value.revision, 3);
+ assert.strictEqual(value.sessions[0].revision, 3);
+ return { committed: true, item: plain(value) };
+ });
+ assert.strictEqual(
+ await restoredApp._commitMultiSuiteRecovery(retainedWal),
+ true,
+ `${label} durable revision 必须按 0 修复且不得锁死有效 WAL`
+ );
+ assert.notStrictEqual(retainedWal._suiteRecoveryWritesBlocked, true);
+ restoredApp.multiSuiteSessionsMap.delete(invalidRevisionWal.baseExamId);
+ }
- await handler({
- source: examWindow,
- origin: 'http://localhost',
- data: {
- type: 'PRACTICE_COMPLETE',
- source: 'listening_record_bridge',
- data: {
- source: 'listening_record_bridge',
- examId: 'listening-unknown',
- sessionId: 'listening-unknown_123',
- practiceType: 'listening',
- pageType: 'listening',
- answers: { q1: 'acommodation' },
- correctAnswers: { q1: 'accommodation' },
- answerComparison: {
- q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false }
- },
- scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' }
+ const duplicateIdWal = {
+ ...plain(restoredSession),
+ id: 'multi-duplicate-id',
+ baseExamId: 'listening-multi-duplicate-id',
+ revision: 1,
+ _restoredFromWindowSession: true
+ };
+ delete duplicateIdWal._lastDurableRecoveryRevision;
+ restoredApp.multiSuiteSessionsMap.set(duplicateIdWal.baseExamId, duplicateIdWal);
+ await restoreOwnedMultiSuiteItems(restoredApp, duplicateIdWal, [3, 9].map((revision, index) => {
+ const candidateSession = {
+ ...plain(duplicateIdWal),
+ revision,
+ lastUpdate: 1000 + index
+ };
+ delete candidateSession._restoredFromWindowSession;
+ return {
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: duplicateIdWal.id,
+ revision,
+ sessions: [candidateSession],
+ updatedAt: 1000 + index
+ };
+ }));
+ const restoredDuplicate = restoredApp.multiSuiteSessionsMap.get(duplicateIdWal.baseExamId);
+ assert.strictEqual(
+ restoredDuplicate._lastDurableRecoveryRevision,
+ 3,
+ '重复 active-session id 必须继承 AppData CAS 实际使用的首项 revision'
+ );
+ assert.strictEqual(restoredDuplicate.revision, 3);
+ restoredApp.multiSuiteSessionsMap.delete(duplicateIdWal.baseExamId);
+
+ const durableOwnerId = 'multi-valid-durable-owner';
+ const conflictingWal = {
+ ...plain(restoredDuplicate),
+ id: durableOwnerId,
+ baseExamId: 'listening-valid-owner-window-wal',
+ revision: 2,
+ _restoredFromWindowSession: true
+ };
+ delete conflictingWal._lastDurableRecoveryRevision;
+ restoredApp.multiSuiteSessionsMap.set(conflictingWal.baseExamId, conflictingWal);
+ const durableOwnedSession = {
+ ...plain(restoredDuplicate),
+ id: durableOwnerId,
+ baseExamId: 'listening-valid-owner-durable',
+ revision: 6,
+ lastUpdate: Date.now() + 100
+ };
+ delete durableOwnedSession._restoredFromWindowSession;
+ delete durableOwnedSession._lastDurableRecoveryRevision;
+ await restoreOwnedMultiSuiteItems(restoredApp, conflictingWal, [{
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: durableOwnerId,
+ revision: 6,
+ sessions: [durableOwnedSession],
+ updatedAt: Date.now() + 100
+ }]);
+ assert.strictEqual(
+ restoredApp.multiSuiteSessionsMap.has(conflictingWal.baseExamId),
+ false,
+ 'a valid durable entity must evict a different-base WAL that reuses its exact CAS id'
+ );
+ const restoredDurableOwner = restoredApp.multiSuiteSessionsMap.get(durableOwnedSession.baseExamId);
+ assert(restoredDurableOwner, 'the valid durable owner must still be restored');
+ assert.strictEqual(restoredDurableOwner.id, durableOwnerId);
+ assert.strictEqual(restoredDurableOwner._lastDurableRecoveryRevision, 6);
+ assert.strictEqual(
+ Array.from(restoredApp.multiSuiteSessionsMap.values())
+ .filter((item) => item && String(item.id || '') === durableOwnerId)
+ .length,
+ 1,
+ 'one AppData identity must produce only one live multi-suite session'
+ );
+ restoredApp.multiSuiteSessionsMap.delete(durableOwnedSession.baseExamId);
+
+ const canonicalOwnerId = 'multi-valid-durable-canonical-owner';
+ const canonicalBaseExamId = 'listening-valid-owner-canonical';
+ const rawWalBaseExamId = ` ${canonicalBaseExamId} `;
+ const nonCanonicalWal = {
+ ...plain(restoredDurableOwner),
+ id: canonicalOwnerId,
+ baseExamId: rawWalBaseExamId,
+ revision: 2,
+ _restoredFromWindowSession: true
+ };
+ delete nonCanonicalWal._lastDurableRecoveryRevision;
+ restoredApp.multiSuiteSessionsMap.set(rawWalBaseExamId, nonCanonicalWal);
+ const canonicalDurableSession = {
+ ...plain(restoredDurableOwner),
+ id: canonicalOwnerId,
+ baseExamId: rawWalBaseExamId,
+ revision: 7,
+ lastUpdate: Date.now() + 200
+ };
+ delete canonicalDurableSession._restoredFromWindowSession;
+ delete canonicalDurableSession._lastDurableRecoveryRevision;
+ await restoreOwnedMultiSuiteItems(restoredApp, nonCanonicalWal, [{
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: canonicalOwnerId,
+ revision: 7,
+ sessions: [canonicalDurableSession],
+ updatedAt: Date.now() + 200
+ }]);
+ assert.strictEqual(
+ restoredApp.multiSuiteSessionsMap.has(rawWalBaseExamId),
+ false,
+ 'a non-canonical WAL key must not coexist with the canonical durable base'
+ );
+ assert(restoredApp.multiSuiteSessionsMap.has(canonicalBaseExamId));
+ assert.strictEqual(
+ restoredApp.multiSuiteSessionsMap.get(canonicalBaseExamId).baseExamId,
+ canonicalBaseExamId,
+ 'durable restore must canonicalize both the Map key and the session property'
+ );
+ assert.strictEqual(
+ Array.from(restoredApp.multiSuiteSessionsMap.values())
+ .filter((item) => item && String(item.id || '') === canonicalOwnerId)
+ .length,
+ 1,
+ 'canonicalization differences must not duplicate one CAS identity'
+ );
+ restoredApp.multiSuiteSessionsMap.delete(canonicalBaseExamId);
+
+ const competingBaseExamId = 'listening-valid-owner-competing-id';
+ const competingWal = {
+ ...plain(restoredDurableOwner),
+ id: 'multi-window-owner-competing-id',
+ baseExamId: ` ${competingBaseExamId} `,
+ revision: 2,
+ _restoredFromWindowSession: true
+ };
+ delete competingWal._lastDurableRecoveryRevision;
+ const competingDurable = {
+ ...plain(restoredDurableOwner),
+ id: 'multi-durable-owner-competing-id',
+ baseExamId: competingBaseExamId,
+ revision: 8,
+ lastUpdate: Date.now() + 300
+ };
+ delete competingDurable._restoredFromWindowSession;
+ delete competingDurable._lastDurableRecoveryRevision;
+ const competingDurableItem = {
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: competingDurable.id,
+ revision: 8,
+ sessions: [competingDurable],
+ updatedAt: Date.now() + 300
+ };
+ const originalCompetingLocks = windowStub.navigator.locks;
+ const previousMultiWindowWal = plain(windowSessionStore.get('multi-suite-practice') || null);
+ const competingLocks = {
+ held: new Map(),
+ async request(name, lockOptions = {}, callback) {
+ assert.strictEqual(lockOptions.mode, 'exclusive');
+ assert.strictEqual(lockOptions.ifAvailable, true);
+ const normalizedName = String(name || '');
+ if (this.held.has(normalizedName)) return callback(null);
+ const lock = { name: normalizedName, mode: 'exclusive' };
+ this.held.set(normalizedName, lock);
+ try {
+ return await callback(lock);
+ } finally {
+ if (this.held.get(normalizedName) === lock) this.held.delete(normalizedName);
}
}
+ };
+ windowStub.navigator.locks = competingLocks;
+ const competingOwnerApp = createApp(windowStub);
+ const liveCompetingDurable = plain(competingDurable);
+ assert.strictEqual(
+ await competingOwnerApp._acquireSuiteRecoveryClaim('multi', liveCompetingDurable),
+ true,
+ 'foreign authoritative durable fixture must hold its exact lease'
+ );
+ await windowStub.AppData.recovery.saveActiveSession(plain(competingDurableItem));
+ windowSessionStore.set('multi-suite-practice', {
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ sessions: [plain(competingWal)],
+ updatedAt: Date.now()
});
+ const contendedWalApp = createApp(windowStub, { suiteModeReady: false });
+ contendedWalApp.initializeSuiteMode();
+ await contendedWalApp._ensureSuiteRecoveryReady();
+ assert.strictEqual(
+ contendedWalApp.multiSuiteSessionsMap.has(competingBaseExamId),
+ false,
+ 'a same-base WAL must remain quarantined while the authoritative durable lease is active'
+ );
+ assert.strictEqual(
+ windowSessionStore.get('multi-suite-practice').sessions[0].id,
+ competingWal.id,
+ 'contention must preserve the copied WAL bytes for a later crash takeover retry'
+ );
+ assert.strictEqual(
+ await competingOwnerApp._releaseSuiteRecoveryClaim('multi', liveCompetingDurable),
+ true
+ );
- assert(captured, '听力桥 PRACTICE_COMPLETE 不应被临时 examId/sessionId 静默丢弃');
- assert.strictEqual(captured.examId, examId, '父应用应使用当前打开的题源 examId');
- assert.strictEqual(captured.data.examId, examId, 'payload examId 应被纠正为父应用题源');
- assert.strictEqual(captured.data.sessionId, expectedSessionId, 'payload sessionId 应被纠正为父应用会话');
- }
-
- // Case 10: 任意目录听力完成后必须进入 PracticeRecorder,并保存错词
- {
- const app = createApp(windowStub);
- const examId = 'custom-listening-arbitrary-folder';
- const savedCompletions = [];
- const savedErrors = [];
- let status = null;
-
- app.components.practiceRecorder = {
- handleSessionCompleted: async (payload) => {
- savedCompletions.push(payload);
- return { id: 'record-custom-listening', examId };
+ const takeoverApp = createApp(windowStub, { suiteModeReady: false });
+ takeoverApp.initializeSuiteMode();
+ await takeoverApp._ensureSuiteRecoveryReady();
+ const authoritativeTakeover = takeoverApp.multiSuiteSessionsMap.get(competingBaseExamId);
+ assert(authoritativeTakeover, 'released/crashed durable owner must be recoverable by a fresh app');
+ assert.strictEqual(
+ authoritativeTakeover.id,
+ competingDurable.id,
+ 'the authoritative durable identity must replace the stale same-base WAL identity'
+ );
+ assert.strictEqual(
+ Array.from(takeoverApp.multiSuiteSessionsMap.values())
+ .some((item) => item && item.id === competingWal.id),
+ false
+ );
+ assert.strictEqual(await takeoverApp._releaseSuiteRecoveryClaim('multi', authoritativeTakeover), true);
+ for (const app of [contendedWalApp, takeoverApp]) {
+ for (const recoveredSession of Array.from(app.multiSuiteSessionsMap.values())) {
+ if (app._ownsSuiteRecoveryClaim('multi', recoveredSession)) {
+ await app._releaseSuiteRecoveryClaim('multi', recoveredSession);
+ }
+ }
+ if (app.currentSuiteSession && app._ownsSuiteRecoveryClaim('single', app.currentSuiteSession)) {
+ await app._releaseSuiteRecoveryClaim('single', app.currentSuiteSession);
}
+ }
+ await windowStub.AppData.recovery.discardActiveSession(competingDurable.id);
+ if (previousMultiWindowWal) windowSessionStore.set('multi-suite-practice', previousMultiWindowWal);
+ else windowSessionStore.delete('multi-suite-practice');
+ windowStub.navigator.locks = originalCompetingLocks;
+
+ const crossSchemaWal = {
+ id: 'cross-schema-duplicate-id',
+ baseExamId: 'listening-cross-schema-duplicate',
+ revision: 1,
+ _restoredFromWindowSession: true
};
- app.updateExamStatus = (handledExamId, nextStatus) => {
- status = { examId: handledExamId, status: nextStatus };
+ restoredApp.multiSuiteSessionsMap.set(crossSchemaWal.baseExamId, crossSchemaWal);
+ const laterMultiSuiteItem = {
+ ...plain(restoredDuplicate),
+ id: crossSchemaWal.id,
+ baseExamId: crossSchemaWal.baseExamId,
+ revision: 9
};
- app.showRealCompletionNotification = () => {};
- app.cleanupExamSession = async () => {};
- app.setState = () => {};
+ await restoreOwnedMultiSuiteItems(restoredApp, crossSchemaWal, [{
+ schema: 'suite-session-v2',
+ version: 2,
+ id: crossSchemaWal.id,
+ revision: 4
+ }, {
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: crossSchemaWal.id,
+ revision: 9,
+ sessions: [laterMultiSuiteItem]
+ }]);
+ assert.strictEqual(
+ restoredApp.multiSuiteSessionsMap.has(crossSchemaWal.baseExamId),
+ false,
+ '同 ID 的 AppData 首项属于其他 schema 时,后项 multi-suite 不得声明 CAS 所有权'
+ );
- const previousCollector = windowStub.spellingErrorCollector;
- windowStub.spellingErrorCollector = {
- detectSource: () => 'other',
- detectErrors: () => [{
- word: 'accommodation',
- userInput: 'acommodation',
- questionId: 'q1',
- suiteId: null,
- examId,
- timestamp: 1710000000000,
- errorCount: 1,
- source: 'other'
- }],
- saveErrors: async (errors) => {
- savedErrors.push(...errors);
- return true;
+ const shadowedMarkerId = 'cross-schema-shadowed-marker';
+ const shadowedMarkerBase = 'listening-cross-schema-shadowed-marker';
+ const safeFallbackWal = {
+ ...plain(restoredSession),
+ id: 'safe-fallback-wal-id',
+ baseExamId: ` ${shadowedMarkerBase} `,
+ revision: 2,
+ lastUpdate: Date.now(),
+ _suiteRecoveryTimestampKnown: true,
+ _restoredFromWindowSession: true
+ };
+ delete safeFallbackWal._lastDurableRecoveryRevision;
+ restoredApp.multiSuiteSessionsMap.set(safeFallbackWal.baseExamId, safeFallbackWal);
+ const corruptShadowedMarker = {
+ ...plain(restoredSession),
+ id: shadowedMarkerId,
+ baseExamId: shadowedMarkerBase,
+ revision: 9,
+ finalizeOperationId: 'practice-multisuite:wrong:id:finalize',
+ finalizeRecord: { id: 'stale-record' }
+ };
+ delete corruptShadowedMarker._restoredFromWindowSession;
+ delete corruptShadowedMarker._lastDurableRecoveryRevision;
+ const originalShadowedMarkerLocks = windowStub.navigator.locks;
+ const originalShadowedMarkerFence = windowStub.AppData.recovery.getActiveSessionFence;
+ const shadowedMarkerLocks = {
+ held: new Map(),
+ calls: [],
+ async request(name, lockOptions = {}, callback) {
+ assert.strictEqual(lockOptions.mode, 'exclusive');
+ assert.strictEqual(lockOptions.ifAvailable, true);
+ const normalizedName = String(name || '');
+ this.calls.push(normalizedName);
+ if (this.held.has(normalizedName)) return callback(null);
+ const lock = { name: normalizedName, mode: 'exclusive' };
+ this.held.set(normalizedName, lock);
+ try {
+ return await callback(lock);
+ } finally {
+ if (this.held.get(normalizedName) === lock) this.held.delete(normalizedName);
+ }
}
};
-
- await app.handlePracticeComplete(examId, {
- examId,
- sessionId: `${examId}_session`,
- practiceType: 'listening',
- pageType: 'listening',
- answers: { q1: 'acommodation' },
- correctAnswers: { q1: 'accommodation' },
- answerComparison: {
- q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false }
- },
- scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' }
+ windowStub.navigator.locks = shadowedMarkerLocks;
+ windowStub.AppData.recovery.getActiveSessionFence = async (id) => ({
+ id: String(id),
+ exists: false,
+ tombstoned: false,
+ revision: 0
});
+ const shadowedMarkerSaveStart = recoveryControl.events.length;
+ try {
+ await restoreOwnedMultiSuiteItems(restoredApp, safeFallbackWal, [{
+ schema: 'suite-session-v2',
+ version: 2,
+ id: shadowedMarkerId,
+ revision: 4
+ }, {
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: shadowedMarkerId,
+ revision: 9,
+ sessions: [corruptShadowedMarker]
+ }]);
+ assert.strictEqual(
+ restoredApp.multiSuiteSessionsMap.get(shadowedMarkerBase),
+ safeFallbackWal,
+ 'a shadowed corrupt base marker must not delete a different exact-id WAL'
+ );
+ assert.strictEqual(safeFallbackWal.baseExamId, shadowedMarkerBase);
+ assert.notStrictEqual(
+ safeFallbackWal._lastDurableRecoveryRevision,
+ 9,
+ 'a later shadowed marker must not lend its revision to a different WAL identity'
+ );
+ const safeFallbackSaves = recoveryControl.events
+ .slice(shadowedMarkerSaveStart)
+ .filter((event) => event.type === 'save');
+ assert.strictEqual(safeFallbackSaves.length, 1);
+ assert.strictEqual(safeFallbackSaves[0].value.id, safeFallbackWal.id);
+ assert.strictEqual(safeFallbackSaves[0].options.expectedEntityRevision, 0);
+ assert.deepStrictEqual(
+ shadowedMarkerLocks.calls,
+ [
+ restoredApp._multiSuiteBaseClaimName(safeFallbackWal.baseExamId),
+ restoredApp._suiteRecoveryClaimName(safeFallbackWal.id)
+ ],
+ 'only the WAL base and exact identity may be claimed; a shadowed corrupt marker is not authoritative'
+ );
+ assert.strictEqual(
+ await restoredApp._releaseSuiteRecoveryClaim('multi', safeFallbackWal),
+ true
+ );
+ assert.strictEqual(shadowedMarkerLocks.held.size, 0);
+ } finally {
+ await windowStub.AppData.recovery.discardActiveSession(safeFallbackWal.id);
+ if (originalShadowedMarkerFence) {
+ windowStub.AppData.recovery.getActiveSessionFence = originalShadowedMarkerFence;
+ } else {
+ delete windowStub.AppData.recovery.getActiveSessionFence;
+ }
+ windowStub.navigator.locks = originalShadowedMarkerLocks;
+ }
+ restoredApp.multiSuiteSessionsMap.delete(shadowedMarkerBase);
+
+ const exactIdWal = {
+ id: 'multi-exact-id',
+ baseExamId: 'listening-multi-exact-id',
+ revision: 2,
+ lastUpdate: Date.now(),
+ _suiteRecoveryTimestampKnown: true,
+ _restoredFromWindowSession: true
+ };
+ restoredApp.multiSuiteSessionsMap.set(exactIdWal.baseExamId, exactIdWal);
+ const originalExactIdFence = windowStub.AppData.recovery.getActiveSessionFence;
+ windowStub.AppData.recovery.getActiveSessionFence = async (id) => ({
+ id: String(id),
+ exists: false,
+ tombstoned: false,
+ revision: 0
+ });
+ const exactIdSaveStart = recoveryControl.events.length;
+ try {
+ await restoreOwnedMultiSuiteItems(restoredApp, exactIdWal, [{
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: `${exactIdWal.id} `,
+ revision: 8,
+ sessions: [{ baseExamId: exactIdWal.baseExamId }]
+ }]);
+ assert.notStrictEqual(
+ exactIdWal._lastDurableRecoveryRevision,
+ 8,
+ 'active-session id 必须按 AppData 原始字符串精确匹配,不能 trim 后借用 revision'
+ );
+ assert.strictEqual(
+ restoredApp.multiSuiteSessionsMap.get(exactIdWal.baseExamId),
+ exactIdWal,
+ 'the exact-id WAL may establish its own expected=0 entity without borrowing a whitespace alias'
+ );
+ const exactIdSaves = recoveryControl.events
+ .slice(exactIdSaveStart)
+ .filter((event) => event.type === 'save');
+ assert.strictEqual(exactIdSaves.length, 1);
+ assert.strictEqual(exactIdSaves[0].value.id, exactIdWal.id);
+ assert.strictEqual(exactIdSaves[0].options.expectedEntityRevision, 0);
+ assert.strictEqual(await restoredApp._releaseSuiteRecoveryClaim('multi', exactIdWal), true);
+ } finally {
+ await windowStub.AppData.recovery.discardActiveSession(exactIdWal.id);
+ if (originalExactIdFence) {
+ windowStub.AppData.recovery.getActiveSessionFence = originalExactIdFence;
+ } else {
+ delete windowStub.AppData.recovery.getActiveSessionFence;
+ }
+ }
+ restoredApp.multiSuiteSessionsMap.delete(exactIdWal.baseExamId);
+
+ const mismatchedOwnerWal = {
+ ...plain(restoredSession),
+ id: 'multi-nested-owner',
+ baseExamId: 'listening-multi-mismatched-owner',
+ revision: 2,
+ _restoredFromWindowSession: true
+ };
+ delete mismatchedOwnerWal._lastDurableRecoveryRevision;
+ restoredApp.multiSuiteSessionsMap.set(mismatchedOwnerWal.baseExamId, mismatchedOwnerWal);
+ const mismatchedDurableSession = {
+ ...plain(mismatchedOwnerWal),
+ id: 'multi-corrupt-nested-owner'
+ };
+ delete mismatchedDurableSession._restoredFromWindowSession;
+ const laterValidDuplicate = plain(mismatchedOwnerWal);
+ delete laterValidDuplicate._restoredFromWindowSession;
+ await restoreOwnedMultiSuiteItems(restoredApp, mismatchedOwnerWal, [{
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: mismatchedOwnerWal.id,
+ revision: 11,
+ sessions: [mismatchedDurableSession]
+ }, {
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: mismatchedOwnerWal.id,
+ revision: 12,
+ sessions: [laterValidDuplicate]
+ }]);
+ assert.strictEqual(
+ restoredApp.multiSuiteSessionsMap.get(mismatchedOwnerWal.baseExamId),
+ mismatchedOwnerWal,
+ 'a corrupt first exact-id owner must block a later duplicate from replacing the window WAL'
+ );
+ assert.strictEqual(mismatchedOwnerWal._lastDurableRecoveryRevision, 11);
+ assert.strictEqual(mismatchedOwnerWal.revision, 11, 'the retained WAL must inherit the actual first CAS owner revision');
+ restoredApp.multiSuiteSessionsMap.delete(mismatchedOwnerWal.baseExamId);
- windowStub.spellingErrorCollector = previousCollector;
-
- assert.strictEqual(savedCompletions.length, 1, '听力完成应调用 PracticeRecorder 落库');
- assert.strictEqual(savedCompletions[0].examId, examId, '落库 payload 应保留当前听力 examId');
- assert.strictEqual(savedErrors.length, 1, '任意目录听力错词应保存到词表链路');
- assert.strictEqual(savedErrors[0].word, 'accommodation', '错词应来自 answerComparison');
- assert.deepStrictEqual(status, { examId, status: 'completed' }, '完成后应更新题源状态');
+ await windowStub.AppData.recovery.discardActiveSession(session.id);
+ restoredApp.multiSuiteSessionsMap.delete('listening-multi-corrupt-durable');
}
- // Case 10b: 听力桥自带错词也必须归一到父页面当前题源,不能写入临时 listening-unknown
+ // Case 3.0.6.6: multi-suite recovery 收到 stale receipt 后必须置 write-block,
+ // 后续提交短路,避免用同一旧 revision 无限重试并反复 NACK 合法提交(F3)。
{
const app = createApp(windowStub);
- const examId = 'listening-p1-normalized-errors';
- const savedErrors = [];
-
- app.components.practiceRecorder = {
- handleSessionCompleted: async () => ({ id: 'record-normalized-errors', examId })
- };
- app.updateExamStatus = () => {};
- app.showRealCompletionNotification = () => {};
- app.cleanupExamSession = async () => {};
- app.setState = () => {};
-
- const previousCollector = windowStub.spellingErrorCollector;
- windowStub.spellingErrorCollector = {
- detectSource: () => 'p1',
- detectErrors: () => [],
- saveErrors: async (errors) => {
- savedErrors.push(...errors);
- return true;
- }
+ await app._ensureSuiteRecoveryReady();
+ const session = {
+ id: 'multi-writeblock-session',
+ baseExamId: 'listening-multi-writeblock',
+ status: 'active',
+ startTime: Date.now(),
+ suiteResults: [],
+ expectedSuiteCount: 1,
+ metadata: { source: 'p1' },
+ lastUpdate: Date.now(),
+ revision: 2,
+ _lastDurableRecoveryRevision: 1
};
+ app.multiSuiteSessionsMap.set(session.baseExamId, session);
- await app.handlePracticeComplete(examId, {
- examId,
- sessionId: `${examId}_session`,
- practiceType: 'listening',
- pageType: 'listening',
- answers: { q1: 'acommodation' },
- correctAnswers: { q1: 'accommodation' },
- answerComparison: {
- q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false }
- },
- scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' },
- spellingErrors: [{
- word: 'accommodation',
- userInput: 'acommodation',
- questionId: 'q1',
- suiteId: null,
- examId: 'listening-unknown',
- timestamp: 1710000000000,
- errorCount: 1,
- source: 'other'
- }]
- });
+ const saveEventsBefore = recoveryControl.events.filter((event) => event.type === 'save').length;
+ recoveryControl.saveQueue.push(() => ({ committed: false, code: 'STALE_RECOVERY_WRITE' }));
+ assert.strictEqual(
+ await app._commitMultiSuiteRecovery(session),
+ false,
+ 'stale receipt 时 multi-suite recovery 提交必须失败'
+ );
+ assert.strictEqual(
+ session._suiteRecoveryWritesBlocked,
+ true,
+ 'stale receipt 后 multi-suite session 必须置 write-block'
+ );
- windowStub.spellingErrorCollector = previousCollector;
+ // write-block 后不应再向 AppData 发起任何持久化提交。
+ const saveEventsMid = recoveryControl.events.filter((event) => event.type === 'save').length;
+ assert.strictEqual(
+ await app._commitMultiSuiteRecovery(session),
+ false,
+ 'write-block 后提交必须短路返回 false'
+ );
+ const saveEventsAfter = recoveryControl.events.filter((event) => event.type === 'save').length;
+ assert.strictEqual(
+ saveEventsAfter,
+ saveEventsMid,
+ 'write-block 后不得再调用 saveActiveSession'
+ );
+ assert(saveEventsAfter > saveEventsBefore, 'stale receipt 必须确实触发过一次持久化提交');
- assert.strictEqual(savedErrors.length, 1, '听力桥自带错词应继续保存');
- assert.strictEqual(savedErrors[0].examId, examId, '错词 examId 必须归一到父页面题源');
- assert.strictEqual(savedErrors[0].source, 'p1', 'P1 听力错词 source 必须归一,避免写到 other 词表');
+ app.multiSuiteSessionsMap.delete(session.baseExamId);
+ await windowStub.AppData.recovery.discardActiveSession(session.id);
}
- // Case 11: 听力桥 bootstrap ready 不得提前结束父子握手
+ // Case 3.0.6.7: 已完成的套题会话关闭末篇子页时,不得把末篇标成 interrupted(H1)。
{
const app = createApp(windowStub);
- const examWindow = createStubWindow('custom-listening-handshake-window');
- const examId = 'custom-listening-handshake';
- const expectedSessionId = 'custom-listening-handshake_expected';
-
- app.setupExamWindowCommunication(examWindow, examId, {
- id: examId,
- title: 'Handshake Listening',
- type: 'listening'
- });
-
- const info = app.ensureExamWindowSession(examId, examWindow);
- info.expectedSessionId = expectedSessionId;
- app.examWindows.set(examId, info);
- examWindow._messages.length = 0;
-
- const timer = setInterval(() => {}, 10000);
- app._handshakeTimers = new Map([[examId, timer]]);
+ const suite = makeSession('suite_completed_close');
+ suite.status = 'completed';
+ const child = suite.windowRef;
+ app.currentSuiteSession = suite;
+ app.suiteExamMap = new Map(suite.sequence.map((item) => [item.examId, suite.id]));
+ app.examWindows = new Map([['reading-p1', { window: child, suiteSessionId: suite.id }]]);
+ const statusUpdates = [];
+ app.updateExamStatus = (examId, status) => { statusUpdates.push({ examId, status }); };
+ const cleanupCalls = [];
+ app.cleanupExamSession = async (examId) => { cleanupCalls.push(examId); };
+
+ assert.strictEqual(await app.handleExamWindowClosed('reading-p1', child), true);
+ assert.strictEqual(
+ statusUpdates.some((update) => update.examId === 'reading-p1' && update.status === 'completed'),
+ true,
+ 'completed 套题关闭末篇必须标记 completed 而非 interrupted'
+ );
+ assert.strictEqual(
+ statusUpdates.some((update) => update.status === 'interrupted'),
+ false,
+ 'completed 套题关闭末篇不得落为 interrupted'
+ );
+ assert.strictEqual(cleanupCalls.includes('reading-p1'), true, 'completed 套题关闭后必须清理 exam session');
+ app.examWindows.delete('reading-p1');
+ }
- const handler = app.messageHandlers.get(examId);
- assert.strictEqual(typeof handler, 'function', '听力题源应注册 message handler');
+ // Case 3.0.7: stale completed-session teardown must not own a newer session
+ {
+ const app = createApp(windowStub);
+ const staleSession = makeSession('suite_stale_completed');
+ staleSession.status = 'completed';
+ staleSession._suiteGeneration = 1;
+ const freshSession = makeSession('suite_fresh_started');
+ freshSession.status = 'active';
+ freshSession._suiteGeneration = 2;
+ freshSession.windowRef = createStubWindow('suite-fresh-window');
+
+ app.currentSuiteSession = freshSession;
+ app.suiteExamMap = new Map(freshSession.sequence.map(item => [item.examId, freshSession.id]));
+ assert.strictEqual(await app._ensureSuiteRecoveryClaim('single', freshSession), true);
+ app._mirrorSessionToStorage(freshSession);
+ const freshSnapshot = plain(windowSessionStore.get('simulation'));
+ const freshWindow = freshSession.windowRef;
+ let scheduledTeardown = null;
+ let discardCalls = 0;
+ const originalSetTimeout = sandbox.setTimeout;
+ const originalClearTimeout = sandbox.clearTimeout;
+ const originalDiscard = windowStub.AppData.recovery.windowSession.discard;
+
+ sandbox.setTimeout = (callback) => {
+ scheduledTeardown = callback;
+ return { unref() {} };
+ };
+ sandbox.clearTimeout = () => {};
+ windowStub.AppData.recovery.windowSession.discard = (...args) => {
+ discardCalls += 1;
+ return originalDiscard(...args);
+ };
try {
- await handler({
- source: examWindow,
- origin: 'http://localhost',
- data: {
- type: 'SESSION_READY',
- source: 'listening_record_bridge',
- data: {
- source: 'listening_record_bridge',
- examId: 'listening-unknown',
- sessionId: 'listening-unknown_123',
- pageType: 'listening',
- type: 'listening',
- initialized: false
- }
- }
- });
-
- const preInitInfo = app.examWindows.get(examId);
- assert.strictEqual(app._handshakeTimers.has(examId), true, 'pre-init ready 不得停止 INIT 重试');
- assert.strictEqual(preInitInfo.dataCollectorReady, undefined, 'pre-init ready 不得标记 collector ready');
- assert(examWindow._messages.some(message => message && message.type === 'INIT_SESSION'), 'pre-init ready 后应补发 INIT_SESSION');
-
- await handler({
- source: examWindow,
- origin: 'http://localhost',
- data: {
- type: 'SESSION_READY',
- source: 'listening_record_bridge',
- data: {
- source: 'listening_record_bridge',
- examId,
- sessionId: expectedSessionId,
- pageType: 'listening',
- type: 'listening',
- initialized: true
- }
- }
- });
-
- assert.strictEqual(app._handshakeTimers.has(examId), false, 'initialized ready 才能停止 INIT 重试');
- assert.strictEqual(app.examWindows.get(examId).dataCollectorReady, true, 'initialized ready 应标记 collector ready');
+ assert.strictEqual(app._scheduleSuiteSubmitTeardown(staleSession), true, '旧 session 应成功注册延迟清理');
+ assert.strictEqual(typeof scheduledTeardown, 'function', '应捕获延迟清理回调');
+ scheduledTeardown();
+ await Promise.resolve();
} finally {
- clearInterval(timer);
+ sandbox.setTimeout = originalSetTimeout;
+ sandbox.clearTimeout = originalClearTimeout;
+ windowStub.AppData.recovery.windowSession.discard = originalDiscard;
}
+
+ assert.strictEqual(app.currentSuiteSession, freshSession, '旧 session teardown 不得替换新 session');
+ assert.strictEqual(freshWindow.closed, false, '旧 session teardown 不得关闭新 session 窗口');
+ assert.strictEqual(app.suiteExamMap.get('reading-p1'), freshSession.id, '旧 session teardown 不得清理新 suiteExamMap');
+ assert.deepStrictEqual(plain(windowSessionStore.get('simulation')), freshSnapshot, '旧 session teardown 不得清理新 snapshot');
+ assert.strictEqual(discardCalls, 0, '旧 session teardown 不得 discard 新 session snapshot');
}
- // Case 12: 听力完成早于 initialized ready 时,也必须先补建 recorder session 再落库
+ // Case 3.0.8: timer 与进行中的 teardown 重叠时,失败后必须保留一次自动重试。
{
const app = createApp(windowStub);
- const examWindow = createStubWindow('custom-listening-complete-first-window');
- const examId = 'custom-listening-complete-first';
- const expectedSessionId = 'custom-listening-complete-first_expected';
- const calls = [];
- let status = null;
+ const session = makeSession('suite_teardown_timer_overlap');
+ session.status = 'completed';
+ session.windowRef.close = function close() { this.closed = true; };
+ const suiteWindow = session.windowRef;
+ const overlapExamId = session.activeExamId;
+ const overlapInfo = {
+ window: suiteWindow,
+ suiteSessionId: session.id,
+ expectedSessionId: 'teardown-overlap-attempt',
+ windowSessionToken: 'teardown-overlap-token',
+ windowSessionTokenSessionId: 'teardown-overlap-attempt',
+ sessionGeneration: 3
+ };
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+ app.examWindows = new Map([[overlapExamId, overlapInfo]]);
+ app.messageHandlers = new Map([[overlapExamId, () => {}]]);
+ session.windowBinding = {
+ examId: overlapExamId,
+ expectedSessionId: overlapInfo.expectedSessionId,
+ windowSessionToken: overlapInfo.windowSessionToken,
+ sessionGeneration: overlapInfo.sessionGeneration
+ };
- app.components.practiceRecorder = {
- activeSessions: new Map(),
- startPracticeSession(handledExamId, examData) {
- calls.push({ type: 'startPracticeSession', examId: handledExamId, examData });
- this.activeSessions.set(handledExamId, {
- examId: handledExamId,
- sessionId: `${handledExamId}_generated`,
- metadata: {},
- progress: { totalQuestions: examData.totalQuestions || 0 },
- answers: {}
- });
- return this.activeSessions.get(handledExamId);
- },
- handleSessionStarted(payload) {
- calls.push({ type: 'handleSessionStarted', payload });
- assert(this.activeSessions.has(payload.examId), '补建 session 必须先于 handleSessionStarted');
- const session = this.activeSessions.get(payload.examId);
- session.sessionId = payload.sessionId;
- session.metadata = { ...session.metadata, ...payload.metadata };
- this.activeSessions.set(payload.examId, session);
- },
- async handleSessionCompleted(payload) {
- calls.push({ type: 'handleSessionCompleted', payload });
- assert(this.activeSessions.has(payload.examId), '真实 recorder 没有 active session 会拒绝落库');
- const session = this.activeSessions.get(payload.examId);
- assert.strictEqual(session.sessionId, payload.sessionId, '完成 payload 必须使用父页面 expectedSessionId');
- return { id: `record_${payload.sessionId}`, examId: payload.examId, sessionId: payload.sessionId };
- }
+ const scheduledTimers = [];
+ const originalSetTimeout = sandbox.setTimeout;
+ const originalClearTimeout = sandbox.clearTimeout;
+ sandbox.setTimeout = (callback) => {
+ const timer = { run: callback, unref() {} };
+ scheduledTimers.push(timer);
+ return timer;
};
- app.updateExamStatus = (handledExamId, nextStatus) => {
- status = { examId: handledExamId, status: nextStatus };
+ sandbox.clearTimeout = () => {};
+
+ let discardAttempts = 0;
+ let markDiscardStarted;
+ let releaseDiscard;
+ const discardStarted = new Promise((resolve) => { markDiscardStarted = resolve; });
+ const discardGate = new Promise((resolve) => { releaseDiscard = resolve; });
+ app._discardPersistentSuiteRecovery = async () => {
+ discardAttempts += 1;
+ if (discardAttempts === 1) {
+ markDiscardStarted();
+ return discardGate;
+ }
+ return true;
};
- app.showRealCompletionNotification = () => {};
- app.cleanupExamSession = async () => {};
- app.setState = () => {};
- app.setupExamWindowCommunication(examWindow, examId, {
- id: examId,
- title: 'Complete First Listening',
- type: 'listening'
- });
+ try {
+ assert.strictEqual(app._scheduleSuiteSubmitTeardown(session), true);
+ session.activeExamId = session.sequence[1].examId;
+ const firstTeardown = app._teardownSuiteSession(session);
+ await discardStarted;
+ const frozenRegistrations = session._suiteTeardownRegistrations;
+ assert(frozenRegistrations && typeof frozenRegistrations.get === 'function' && frozenRegistrations.size === 1);
+ const overlappingTimer = scheduledTimers[0].run();
+ releaseDiscard(false);
+ assert.strictEqual(await firstTeardown, false);
+ await overlappingTimer;
+ assert.strictEqual(
+ session._suiteTeardownRegistrations,
+ frozenRegistrations,
+ 'completed teardown retry must retain the original exact registration snapshot'
+ );
+ assert.strictEqual(scheduledTimers.length, 2, '失败的重叠 teardown 必须重新挂起 fallback timer');
+ assert.strictEqual(session.submitReceiptTeardownTimer, scheduledTimers[1]);
+ await scheduledTimers[1].run();
+ } finally {
+ sandbox.setTimeout = originalSetTimeout;
+ sandbox.clearTimeout = originalClearTimeout;
+ }
- const info = app.ensureExamWindowSession(examId, examWindow);
- info.expectedSessionId = expectedSessionId;
- app.examWindows.set(examId, info);
+ assert.strictEqual(discardAttempts, 2, 'fallback timer 必须在瞬时失败后重新尝试 discard');
+ assert.strictEqual(app.currentSuiteSession, null, '重试成功后必须完成 teardown');
+ assert.strictEqual(suiteWindow.closed, true, '重试成功后必须关闭已完成题页');
+ assert.strictEqual(
+ app.examWindows.has(session.sequence[1].examId),
+ false,
+ 'late activeExamId mutation must not create a ghost registration during force-close'
+ );
+ assert.strictEqual(session._suiteTeardownRegistrations, undefined);
+ }
- const handler = app.messageHandlers.get(examId);
- assert.strictEqual(typeof handler, 'function', '听力题源应注册 message handler');
+ // Case 3.1: 如果最后一篇已有导航快照,最终提交仍应覆盖并 finalize
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_finalize_upsert');
+ session.results = [
+ { examId: 'reading-p1', title: 'Passage 1', answers: { q1: 'A' }, answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} },
+ { examId: 'reading-p2', title: 'Passage 2', answers: { q1: 'B' }, answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} },
+ { examId: 'reading-p3', title: 'Passage 3', answers: { q1: 'OLD' }, answerComparison: { q1: { userAnswer: 'OLD', correctAnswer: 'C', isCorrect: false } }, scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0 }, rawData: {} }
+ ];
+ session.currentIndex = 2;
+ session.activeExamId = 'reading-p3';
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
- await handler({
- source: examWindow,
- origin: 'http://localhost',
- data: {
- type: 'PRACTICE_COMPLETE',
- source: 'listening_record_bridge',
- data: {
- source: 'listening_record_bridge',
- examId: 'listening-unknown',
- sessionId: 'listening-unknown_early',
- practiceType: 'listening',
- pageType: 'listening',
- title: 'Complete First Listening',
- answers: { q1: 'acommodation' },
- correctAnswers: { q1: 'accommodation' },
- answerComparison: {
- q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false }
- },
- scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' }
- }
- }
- });
+ let finalizeCount = 0;
+ app.finalizeSuiteRecord = async () => {
+ finalizeCount += 1;
+ };
- assert.deepStrictEqual(
- calls.map(call => call.type),
- ['startPracticeSession', 'handleSessionStarted', 'handleSessionCompleted'],
- 'complete-before-ready 必须先建会话、再同步 sessionId、最后落库'
- );
- assert.strictEqual(calls[2].payload.examId, examId, '完成 payload examId 应被纠正为父页面当前题源');
- assert.strictEqual(calls[2].payload.sessionId, expectedSessionId, '完成 payload sessionId 应被纠正为父页面会话');
- assert.deepStrictEqual(status, { examId, status: 'completed' }, '完成后应更新题源状态');
+ const handled = await app.handleSuitePracticeComplete('reading-p3', {
+ suiteSessionId: session.id,
+ answers: { q1: 'C' },
+ answerComparison: { q1: { userAnswer: 'C', correctAnswer: 'C', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ }, session.windowRef);
+
+ assert.strictEqual(handled, true, '最后一篇覆盖提交应成功');
+ assert.strictEqual(finalizeCount, 1, '最后一篇覆盖提交后仍应 finalize');
+ const p3 = session.results.find(item => item.examId === 'reading-p3');
+ assert.deepStrictEqual(p3.answers, { q1: 'C' }, '最终提交应覆盖旧快照答案');
}
- // Case 13: 统一阅读提交后 reset 必须复用父页通信链路并重建 recorder session
+ // Case 4: 显式中断只清理 v2 套题会话,不拆分写入 v1 单篇记录
{
const app = createApp(windowStub);
- const examWindow = createStubWindow('unified-reading-retake-window');
- const examId = 'reading-retake-unified';
- const firstSessionId = 'reading-retake-first-session';
- const resetSessionId = 'reading-retake-reset-session';
- const completions = [];
- const recorderStarts = [];
- const resetStarts = [];
- const statuses = [];
+ const session = makeSession('suite_abort');
+ session.results = [
+ {
+ examId: 'reading-p1',
+ rawData: {
+ answers: { q1: 'A' },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ }
+ },
+ {
+ examId: 'reading-p2',
+ rawData: {
+ answers: { q1: 'B' },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ }
+ }
+ ];
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+
+ const savedExamIds = [];
+ app.saveRealPracticeData = async (examId) => {
+ savedExamIds.push(examId);
+ };
+ app._teardownSuiteSession = async () => {
+ app.currentSuiteSession = null;
+ };
+
+ await app._abortSuiteSession(session, {});
+ assert.deepStrictEqual(savedExamIds, [], '中断后不得通过 v1 单篇路径写入记录');
+ }
+
+ // Case 4.2: 下一篇打开失败不得删除已提交结果,且可重试继续。
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_open_next_retry');
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id]));
+ app.openExam = async () => { throw new Error('expected next-window failure'); };
+ const discardCount = recoveryControl.events.filter((event) => event.type === 'discard').length;
+ const outcome = await app.handleSuitePracticeComplete('reading-p1', {
+ suiteSessionId: session.id,
+ submissionId: 'submit-open-next-retry',
+ duration: 30,
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ }, session.windowRef);
+ assert.strictEqual(outcome.handled, true);
+ assert.strictEqual(outcome.committed, true, '当前篇 durable 后必须 ACK');
+ assert.strictEqual(outcome.errorCode, 'suite_advance_failed');
+ assert.strictEqual(session.activeExamId, 'reading-p2', 'recovery 必须指向待打开的下一篇');
+ assert.strictEqual(session.results.length, 1, '已提交结果必须留在 suite session');
+ assert.strictEqual(
+ recoveryControl.events.filter((event) => event.type === 'discard').length,
+ discardCount,
+ '自动切题失败不得 discard recovery'
+ );
+ const retriedWindow = createStubWindow('open-next-retried');
+ app.openExam = async (examId, options = {}) => (
+ installManagedTestWindow(app, examId, retriedWindow, options)
+ );
+ assert.strictEqual(await app.continueSuitePractice(), true, '现有继续入口应能重试打开下一篇');
+ }
+
+ // Case 4.3: 关闭活动子页必须暂停计时并写入 durable recovery。
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_close_pauses_timer');
+ const child = session.windowRef;
+ session.suiteTimerRunning = true;
+ session.suiteTimerPausedAtMs = null;
+ session.suiteTimerPausedOffsetMs = 0;
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id]));
+ app.examWindows = new Map([['reading-p1', { window: child, suiteSessionId: session.id }]]);
+ const saveStart = recoveryControl.events.length;
+ assert.strictEqual(await app.handleExamWindowClosed('reading-p1', child), true);
+ assert.strictEqual(session.suiteTimerRunning, false);
+ assert(Number.isFinite(Number(session.suiteTimerPausedAtMs)));
+ const pausedElapsed = app._computeSuiteElapsedSeconds(session, session.suiteTimerPausedAtMs);
+ assert.strictEqual(
+ app._computeSuiteElapsedSeconds(session, session.suiteTimerPausedAtMs + 60000),
+ pausedElapsed,
+ '窗口关闭后套题总计时不得继续增长'
+ );
+ const saved = recoveryControl.events.slice(saveStart).filter((event) => event.type === 'save').at(-1)?.value;
+ assert(saved && saved.suiteTimerRunning === false, '暂停字段必须写入 AppData v2 recovery');
+ assert.strictEqual(saved.suiteTimerPausedAtMs, session.suiteTimerPausedAtMs);
+ }
+
+ // Case 4.4: 同名入口必须按 suite session 隔离窗口所有权。
+ {
+ const originalOpen = windowStub.open;
+ const windowsByName = new Map();
+ windowStub.open = (_url, name) => {
+ if (!windowsByName.has(name)) {
+ const child = createStubWindow(name);
+ child.close = () => { child.closed = true; };
+ windowsByName.set(name, child);
+ }
+ return windowsByName.get(name);
+ };
+ try {
+ const firstApp = createApp(windowStub);
+ const secondApp = createApp(windowStub);
+ firstApp._generateSuiteSessionId = () => 'suite-window-owner-a';
+ secondApp._generateSuiteSessionId = () => 'suite-window-owner-b';
+ firstApp.openExam = async (examId, options = {}) => installManagedTestWindow(
+ firstApp,
+ examId,
+ windowStub.open('', options.windowName),
+ options
+ );
+ secondApp.openExam = async (examId, options = {}) => installManagedTestWindow(
+ secondApp,
+ examId,
+ windowStub.open('', options.windowName),
+ options
+ );
+ const sequence = makeSession().sequence;
+ assert.strictEqual(await firstApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true);
+ assert.strictEqual(await secondApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true);
+ assert.notStrictEqual(firstApp.currentSuiteSession.windowName, secondApp.currentSuiteSession.windowName);
+ const secondWindow = secondApp.currentSuiteSession.windowRef;
+ assert.strictEqual(await firstApp._teardownSuiteSession(firstApp.currentSuiteSession), true);
+ assert.strictEqual(secondWindow.closed, false, '旧主页面 teardown 不得关闭新 session 的子页');
+ } finally {
+ windowStub.open = originalOpen;
+ }
+ }
+
+ // Case 5: AppData window-session mirror must be cleared after teardown
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_storage_cleanup');
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+ assert.strictEqual(await app._ensureSuiteRecoveryClaim('single', session), true);
+ app._mirrorSessionToStorage(session);
+ assert(windowSessionStore.has('simulation'), '镜像应存在');
+ app._clearSessionStorage();
+ assert(!windowSessionStore.has('simulation'), '清理后镜像应删除');
+ }
+
+ // Case 6: _sendSimulationContext 应发送正确的上下文
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_context');
+ const p1Highlights = [{ scope: 'left', text: 'P1 context highlight', color: 'yellow' }];
+ session.draftsByExam['reading-p1'] = { answers: { q1: 'A' }, highlights: p1Highlights, scrollY: 0 };
+ session.draftsByExam['reading-p2'] = { answers: { q2: 'B' }, noteText: 'P2 draft' };
+ session.elapsedByExam['reading-p1'] = 45;
+ app.currentSuiteSession = session;
+ const targetWindow = createStubWindow('ctx-window');
+ const sent = app._sendSimulationContext(session, 'reading-p1', targetWindow);
+ assert.strictEqual(sent, true, '应成功发送上下文');
+ const ctxMsg = targetWindow._messages.find(m => m && m.type === 'SIMULATION_CONTEXT');
+ assert(ctxMsg, '应收到 SIMULATION_CONTEXT');
+ assert.strictEqual(ctxMsg.data.currentIndex, 0, 'currentIndex 应为 0');
+ assert.strictEqual(ctxMsg.data.total, 3, 'total 应为 3');
+ assert.strictEqual(Array.isArray(ctxMsg.data.suiteSequence), true, 'SIMULATION_CONTEXT 应包含 suiteSequence');
+ assert.deepStrictEqual(ctxMsg.data.suiteSequence.map(item => item.examId), ['reading-p1', 'reading-p2', 'reading-p3'], 'suiteSequence 应包含三篇 examId');
+ assert.strictEqual(ctxMsg.data.isLast, false, 'P1 不是最后一篇');
+ assert.strictEqual(ctxMsg.data.canPrev, false, 'P1 不能向前');
+ assert.strictEqual(ctxMsg.data.canNext, true, 'P1 可以向后');
+ assert.deepStrictEqual(ctxMsg.data.draft.answers, { q1: 'A' }, 'draft 应回传');
+ assert.deepStrictEqual(ctxMsg.data.draft.highlights, p1Highlights, 'draft highlights 应随上下文回传,避免切题后丢失高亮');
+ assert.deepStrictEqual(
+ plain(ctxMsg.data.draftsByExam),
+ plain(session.draftsByExam),
+ 'inline context 必须同时携带所有篇章草稿'
+ );
+ assert.notStrictEqual(ctxMsg.data.draftsByExam, session.draftsByExam, '草稿集合必须以克隆值发送');
+ assert.strictEqual(ctxMsg.data.elapsed, 45, 'elapsed 应回传');
+
+ const sentP3 = app._sendSimulationContext(session, 'reading-p3', targetWindow);
+ assert.strictEqual(sentP3, true, 'P3 上下文应成功');
+ const ctxP3 = targetWindow._messages.filter(m => m && m.type === 'SIMULATION_CONTEXT')[1];
+ assert.strictEqual(ctxP3.data.isLast, true, 'P3 应标记为最后一篇');
+ assert.strictEqual(ctxP3.data.canNext, false, 'P3 不能向后导航');
+ }
+
+ // Case 6.1: INIT_SESSION payload 应携带三篇 suiteSequence
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_init_sequence');
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+ const initWindow = createStubWindow('init-window');
+ const windowInfo = app.ensureExamWindowSession('reading-p1', initWindow);
+ windowInfo.suiteSessionId = session.id;
+ windowInfo.suiteFlowMode = 'simulation';
+ const payload = app._buildExamInitPayload('reading-p1', windowInfo);
+ assert.strictEqual(Array.isArray(payload.suiteSequence), true, 'INIT_SESSION 应包含 suiteSequence');
+ assert.deepStrictEqual(payload.suiteSequence.map(item => item.examId), ['reading-p1', 'reading-p2', 'reading-p3'], 'INIT suiteSequence 应覆盖三篇');
+ assert.deepStrictEqual(payload.suiteSequence.map(item => item.category), ['P1', 'P2', 'P3'], 'INIT suiteSequence 应带 category');
+ }
+
+ // Case 6.2: 占位页 URL 必须显式传播窄范围 suite 测试标志
+ {
+ const app = createApp(windowStub);
+ const placeholderUrl = app._buildExamPlaceholderUrl(
+ {
+ id: 'reading-p1',
+ title: 'Passage 1 & 特殊字符',
+ category: 'P1'
+ },
+ {
+ suiteSessionId: 'suite placeholder session',
+ sequenceIndex: 0
+ }
+ );
+ const parsed = new URL(placeholderUrl);
+ assert.strictEqual(parsed.pathname.endsWith('/templates/exam-placeholder.html'), true, '应使用套题占位页');
+ assert.strictEqual(parsed.searchParams.get('suite_test'), '1', '占位页必须收到 suite_test=1');
+ assert.strictEqual(parsed.searchParams.get('suiteSessionId'), 'suite placeholder session', '套题会话 ID 应 round-trip');
+ assert.strictEqual(parsed.searchParams.get('title'), 'Passage 1 & 特殊字符', '标题特殊字符应由 URLSearchParams 安全编码');
+ assert.strictEqual(parsed.searchParams.get('index'), '0', '首篇 index=0 不应被省略');
+ }
+
+ // Case 8: handleSessionReady 应触发首篇模拟上下文下发
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_session_ready');
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+ app.examWindows = new Map();
+ const readyWindow = createStubWindow('ready-window');
+ app.examWindows.set('reading-p1', {
+ examId: 'reading-p1',
+ window: readyWindow,
+ expectedSessionId: 'session-reading-p1',
+ suiteSessionId: session.id
+ });
+
+ app.handleSessionReady('reading-p1', {
+ sessionId: 'session-reading-p1',
+ suiteSessionId: session.id,
+ pageType: 'unified-reading'
+ });
+ const msg = readyWindow._messages.find(item => item && item.type === 'SIMULATION_CONTEXT');
+ assert(msg, 'SESSION_READY 后应下发 SIMULATION_CONTEXT');
+ assert.strictEqual(msg.data.examId, 'reading-p1', 'SESSION_READY 下发应匹配 examId');
+ }
+
+ // Case 8.1: 迟到 SESSION_READY 若窗口 URL 已切到其他篇,必须忽略
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_stale_ready');
+ session.currentIndex = 0;
+ session.activeExamId = 'reading-p1';
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+ app.examWindows = new Map();
+
+ const staleWindow = createStubWindow('ready-window');
+ staleWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p1';
+ app.examWindows.set('reading-p2', {
+ examId: 'reading-p2',
+ window: staleWindow,
+ expectedSessionId: 'session-reading-p2',
+ suiteSessionId: session.id,
+ pageType: 'unified-reading'
+ });
+
+ app.handleSessionReady('reading-p2', {
+ sessionId: 'session-reading-p2',
+ pageType: 'unified-reading'
+ });
+
+ assert.strictEqual(session.activeExamId, 'reading-p1', '迟到 SESSION_READY 不得覆写 activeExamId');
+ assert.strictEqual(session.currentIndex, 0, '迟到 SESSION_READY 不得覆写 currentIndex');
+ const staleCtx = staleWindow._messages.find(item => item && item.type === 'SIMULATION_CONTEXT');
+ assert.strictEqual(staleCtx, undefined, '迟到 SESSION_READY 不应下发模拟上下文');
+ }
+
+ // Case 8.2: waitForSuiteWindowExamReady 不得把调用前的旧 ready 当成当前切题成功
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_ready_timestamp_guard');
+ const targetWindow = createStubWindow('ready-window');
+ targetWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p2';
+ app.examWindows = new Map([
+ ['reading-p2', {
+ examId: 'reading-p2',
+ window: targetWindow,
+ suiteSessionId: session.id,
+ pageType: 'unified-reading',
+ lastMessageType: 'SESSION_READY',
+ lastMessageAt: Date.now() - 5000
+ }]
+ ]);
+
+ const ready = await app._waitForSuiteWindowExamReady(session, 'reading-p2', targetWindow, 120);
+ assert.strictEqual(ready, false, '调用前的旧 SESSION_READY 不能被误判为当前窗口已就绪');
+ }
+
+ // Case 8.3: 复用窗口切题若未等到 fresh ready,不得提前把目标篇高亮推送到旧页
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_reuse_window_highlight_guard');
+ session.currentIndex = 0;
+ session.activeExamId = 'reading-p1';
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+
+ const reusedWindow = createStubWindow('suite-window');
+ reusedWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p1';
+ session.windowRef = reusedWindow;
+ app.openExam = async (examId, options = {}) => installManagedTestWindow(
+ app,
+ examId,
+ options.reuseWindow || reusedWindow,
+ options
+ );
+ app._waitForSuiteWindowExamReady = async () => false;
+
+ const ok = await app._handleSimulationNavigate('reading-p1', {
+ direction: 'next',
+ draft: {
+ answers: { q1: 'A' },
+ highlights: [{ scope: 'left', text: 'P1 highlight before switch' }],
+ scrollY: 123,
+ updatedAt: Date.now()
+ },
+ resultSnapshot: {
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ }
+ }, reusedWindow);
+
+ assert.strictEqual(ok, true, 'ready 超时时切题流程仍应继续,由后续 SESSION_READY 兜底');
+ assert.strictEqual(session.activeExamId, 'reading-p2', 'activeExamId 应先对齐到目标篇');
+ assert.strictEqual(
+ reusedWindow._messages.some(message => message && message.type === 'SIMULATION_CONTEXT'),
+ false,
+ '未拿到 fresh ready 前不得向复用窗口提前发送 SIMULATION_CONTEXT,避免旧页误吃目标篇高亮'
+ );
+ assert.deepStrictEqual(
+ session.draftsByExam['reading-p1'].highlights,
+ [{ scope: 'left', text: 'P1 highlight before switch' }],
+ '切题前当前篇高亮仍应保存在 draft 中'
+ );
+ }
+
+ // Case 8.4: 复用窗口若已落到目标篇 URL,即使 fresh ready 缺失也应兜底下发上下文
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_reuse_window_target_url_fallback');
+ session.currentIndex = 0;
+ session.activeExamId = 'reading-p1';
+ const p2Highlights = [{ scope: 'left', text: 'P2 highlight after switch', color: 'green' }];
+ session.draftsByExam['reading-p2'] = { answers: { q5: 'B' }, highlights: p2Highlights, scrollY: 66 };
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+
+ const reusedWindow = createStubWindow('suite-window');
+ reusedWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p2';
+ session.windowRef = reusedWindow;
+ app.examWindows = new Map([
+ ['reading-p2', { window: reusedWindow }]
+ ]);
+ app.openExam = async (examId, options = {}) => installManagedTestWindow(
+ app,
+ examId,
+ options.reuseWindow || reusedWindow,
+ options
+ );
+ app._waitForSuiteWindowExamReady = async () => false;
+
+ const ok = await app._handleSimulationNavigate('reading-p1', {
+ direction: 'next',
+ draft: {
+ answers: { q1: 'A' },
+ highlights: [{ scope: 'left', text: 'P1 highlight before switch' }],
+ scrollY: 123,
+ updatedAt: Date.now()
+ }
+ }, reusedWindow);
+
+ assert.strictEqual(ok, true, '目标窗口 URL 已切到新篇时,切题流程应允许兜底恢复');
+ const ctxMsg = reusedWindow._messages.find(message => message && message.type === 'SIMULATION_CONTEXT');
+ assert(ctxMsg, '目标窗口 URL 已切到新篇时,应继续下发 SIMULATION_CONTEXT');
+ assert.strictEqual(ctxMsg.data.examId, 'reading-p2', '兜底上下文必须指向目标篇');
+ assert.deepStrictEqual(ctxMsg.data.draft.highlights, p2Highlights, '目标篇高亮应随兜底上下文一起恢复');
+ }
+
+ // Case 7: 错篇 PRACTICE_COMPLETE 必须被忽略,不能污染结果
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_wrong_exam_complete');
+ session.activeExamId = 'reading-p2';
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+
+ const handled = await app.handleSuitePracticeComplete('reading-p1', {
+ suiteSessionId: session.id,
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ }, session.windowRef);
+
+ assert.strictEqual(handled, true, '错篇提交应被处理为忽略');
+ assert.strictEqual(session.results.length, 0, '错篇提交不得写入 session.results');
+ }
+
+ // Case 9: 听力桥的临时 examId/sessionId 不得阻断父应用当前题源落库
+ {
+ const app = createApp(windowStub);
+ const examWindow = createStubWindow('custom-listening-window');
+ const examId = 'custom-listening-teacher-pack';
+ const expectedSessionId = 'custom-listening-teacher-pack_expected';
+ let captured = null;
+
+ app.handlePracticeComplete = async (handledExamId, data) => {
+ captured = { examId: handledExamId, data };
+ };
+
+ app.setupExamWindowCommunication(examWindow, examId, {
+ id: examId,
+ title: 'Teacher Pack Listening',
+ type: 'listening'
+ });
+
+ const info = app.ensureExamWindowSession(examId, examWindow);
+ info.expectedSessionId = expectedSessionId;
+ app._refreshExamWindowToken(examId, info);
+ app.examWindows.set(examId, info);
+
+ const handler = app.messageHandlers.get(examId);
+ assert.strictEqual(typeof handler, 'function', '听力题源应注册 message handler');
+
+ await handler({
+ source: examWindow,
+ origin: 'http://localhost',
+ data: {
+ type: 'PRACTICE_COMPLETE',
+ source: 'listening_record_bridge',
+ data: {
+ source: 'listening_record_bridge',
+ examId: 'listening-unknown',
+ sessionId: 'listening-unknown_123',
+ submissionId: 'listening-submit-teacher-pack',
+ windowSessionToken: info.windowSessionToken,
+ practiceType: 'listening',
+ pageType: 'listening',
+ answers: { q1: 'acommodation' },
+ correctAnswers: { q1: 'accommodation' },
+ answerComparison: {
+ q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false }
+ },
+ scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' }
+ }
+ }
+ });
+
+ assert(captured, '听力桥 PRACTICE_COMPLETE 不应被临时 examId/sessionId 静默丢弃');
+ assert.strictEqual(captured.examId, examId, '父应用应使用当前打开的题源 examId');
+ assert.strictEqual(captured.data.examId, examId, 'payload examId 应被纠正为父应用题源');
+ assert.strictEqual(captured.data.sessionId, expectedSessionId, 'payload sessionId 应被纠正为父应用会话');
+ }
+
+ // Case 10: 任意目录听力完成后必须进入 PracticeRecorder,并保存错词
+ {
+ const app = createApp(windowStub);
+ const examId = 'custom-listening-arbitrary-folder';
+ const savedCompletions = [];
+ const savedErrors = [];
+ let status = null;
+
+ app.components.practiceRecorder = {
+ handleSessionCompleted: async (payload) => {
+ savedCompletions.push(payload);
+ const record = {
+ id: 'record-custom-listening',
+ examId,
+ sessionId: `${examId}_session`,
+ endTime: '2026-07-26T00:00:00.000Z'
+ };
+ return (await windowStub.AppData.practice.completeAttempt({ record })).record;
+ }
+ };
+ app.updateExamStatus = (handledExamId, nextStatus) => {
+ status = { examId: handledExamId, status: nextStatus };
+ };
+ app.showRealCompletionNotification = () => {};
+ app.cleanupExamSession = async () => {};
+ app.setState = () => {};
+ const completionWindow = createStubWindow('custom-listening-completion-window');
+ const completionInfo = app.ensureExamWindowSession(examId, completionWindow);
+ completionInfo.expectedSessionId = `${examId}_session`;
+ app.examWindows.set(examId, completionInfo);
+ const completionRegistration = app._captureExamSessionRegistration(examId, completionInfo);
+
+ const previousCollector = windowStub.spellingErrorCollector;
+ windowStub.spellingErrorCollector = {
+ detectSource: () => 'other',
+ detectErrors: () => [{
+ word: 'accommodation',
+ userInput: 'acommodation',
+ questionId: 'q1',
+ suiteId: null,
+ examId,
+ timestamp: 1710000000000,
+ errorCount: 1,
+ source: 'other'
+ }],
+ saveErrors: async (errors) => {
+ savedErrors.push(...errors);
+ return true;
+ }
+ };
+
+ await app.handlePracticeComplete(examId, {
+ examId,
+ sessionId: `${examId}_session`,
+ practiceType: 'listening',
+ pageType: 'listening',
+ answers: { q1: 'acommodation' },
+ correctAnswers: { q1: 'accommodation' },
+ answerComparison: {
+ q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false }
+ },
+ scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' }
+ }, completionWindow, { expectedRegistration: completionRegistration });
+
+ windowStub.spellingErrorCollector = previousCollector;
+
+ assert.strictEqual(savedCompletions.length, 1, '听力完成应调用 PracticeRecorder 落库');
+ assert.strictEqual(savedCompletions[0].examId, examId, '落库 payload 应保留当前听力 examId');
+ assert.strictEqual(savedErrors.length, 1, '任意目录听力错词应保存到词表链路');
+ assert.strictEqual(savedErrors[0].word, 'accommodation', '错词应来自 answerComparison');
+ assert.deepStrictEqual(status, { examId, status: 'completed' }, '完成后应更新题源状态');
+ }
+
+ // Case 10b: 听力桥自带错词也必须归一到父页面当前题源,不能写入临时 listening-unknown
+ {
+ const app = createApp(windowStub);
+ const examId = 'listening-p1-normalized-errors';
+ const savedErrors = [];
+
+ app.components.practiceRecorder = {
+ handleSessionCompleted: async () => {
+ const record = {
+ id: 'record-normalized-errors',
+ examId,
+ sessionId: `${examId}_session`,
+ endTime: '2026-07-26T00:00:00.000Z'
+ };
+ return (await windowStub.AppData.practice.completeAttempt({ record })).record;
+ }
+ };
+ app.updateExamStatus = () => {};
+ app.showRealCompletionNotification = () => {};
+ app.cleanupExamSession = async () => {};
+ app.setState = () => {};
+ const completionWindow = createStubWindow('normalized-listening-completion-window');
+ const completionInfo = app.ensureExamWindowSession(examId, completionWindow);
+ completionInfo.expectedSessionId = `${examId}_session`;
+ app.examWindows.set(examId, completionInfo);
+ const completionRegistration = app._captureExamSessionRegistration(examId, completionInfo);
+
+ const previousCollector = windowStub.spellingErrorCollector;
+ windowStub.spellingErrorCollector = {
+ detectSource: () => 'p1',
+ detectErrors: () => [],
+ saveErrors: async (errors) => {
+ savedErrors.push(...errors);
+ return true;
+ }
+ };
+
+ await app.handlePracticeComplete(examId, {
+ examId,
+ sessionId: `${examId}_session`,
+ practiceType: 'listening',
+ pageType: 'listening',
+ answers: { q1: 'acommodation' },
+ correctAnswers: { q1: 'accommodation' },
+ answerComparison: {
+ q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false }
+ },
+ scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' },
+ spellingErrors: [{
+ word: 'accommodation',
+ userInput: 'acommodation',
+ questionId: 'q1',
+ suiteId: null,
+ examId: 'listening-unknown',
+ timestamp: 1710000000000,
+ errorCount: 1,
+ source: 'other'
+ }]
+ }, completionWindow, { expectedRegistration: completionRegistration });
+
+ windowStub.spellingErrorCollector = previousCollector;
+
+ assert.strictEqual(savedErrors.length, 1, '听力桥自带错词应继续保存');
+ assert.strictEqual(savedErrors[0].examId, examId, '错词 examId 必须归一到父页面题源');
+ assert.strictEqual(savedErrors[0].source, 'p1', 'P1 听力错词 source 必须归一,避免写到 other 词表');
+ }
+
+ // Case 11: 听力桥 bootstrap ready 不得提前结束父子握手
+ {
+ const app = createApp(windowStub);
+ const examWindow = createStubWindow('custom-listening-handshake-window');
+ const examId = 'custom-listening-handshake';
+ const expectedSessionId = 'custom-listening-handshake_expected';
+
+ app.setupExamWindowCommunication(examWindow, examId, {
+ id: examId,
+ title: 'Handshake Listening',
+ type: 'listening'
+ });
+
+ const info = app.ensureExamWindowSession(examId, examWindow);
+ info.expectedSessionId = expectedSessionId;
+ app._refreshExamWindowToken(examId, info);
+ app.examWindows.set(examId, info);
+ examWindow._messages.length = 0;
+
+ const timer = setInterval(() => {}, 10000);
+ app._handshakeTimers = new Map([[examId, timer]]);
+
+ const handler = app.messageHandlers.get(examId);
+ assert.strictEqual(typeof handler, 'function', '听力题源应注册 message handler');
+
+ try {
+ await handler({
+ source: examWindow,
+ origin: 'http://localhost',
+ data: {
+ type: 'SESSION_READY',
+ source: 'listening_record_bridge',
+ data: {
+ source: 'listening_record_bridge',
+ examId: 'listening-unknown',
+ sessionId: 'listening-unknown_123',
+ pageType: 'listening',
+ type: 'listening',
+ initialized: false
+ }
+ }
+ });
+
+ const preInitInfo = app.examWindows.get(examId);
+ assert.strictEqual(app._handshakeTimers.has(examId), true, 'pre-init ready 不得停止 INIT 重试');
+ assert.strictEqual(preInitInfo.dataCollectorReady, undefined, 'pre-init ready 不得标记 collector ready');
+ assert(examWindow._messages.some(message => message && message.type === 'INIT_SESSION'), 'pre-init ready 后应补发 INIT_SESSION');
+
+ await handler({
+ source: examWindow,
+ origin: 'http://localhost',
+ data: {
+ type: 'SESSION_READY',
+ source: 'listening_record_bridge',
+ data: {
+ source: 'listening_record_bridge',
+ examId,
+ sessionId: expectedSessionId,
+ windowSessionToken: info.windowSessionToken,
+ pageType: 'listening',
+ type: 'listening',
+ initialized: true
+ }
+ }
+ });
+
+ assert.strictEqual(app._handshakeTimers.has(examId), false, 'initialized ready 才能停止 INIT 重试');
+ assert.strictEqual(app.examWindows.get(examId).dataCollectorReady, true, 'initialized ready 应标记 collector ready');
+ } finally {
+ clearInterval(timer);
+ }
+ }
+
+ // Case 11.1: 占位页无 token 的 bootstrap ready 也只能触发 INIT,不能结束握手
+ {
+ const app = createApp(windowStub);
+ const examWindow = createStubWindow('suite-placeholder-handshake-window');
+ const examId = 'suite-placeholder-handshake';
+ app.setupExamWindowCommunication(examWindow, examId, { id: examId, type: 'reading' });
+ const info = app.ensureExamWindowSession(examId, examWindow);
+ info.expectedOrigin = 'null';
+ info.allowOpaqueOrigin = true;
+ examWindow._messages.length = 0;
+
+ const timer = setInterval(() => {}, 10000);
+ app._handshakeTimers = new Map([[examId, timer]]);
+ try {
+ await app.messageHandlers.get(examId)({
+ source: examWindow,
+ origin: 'file://',
+ data: {
+ type: 'SESSION_READY',
+ source: 'suite_placeholder',
+ data: {
+ source: 'suite_placeholder',
+ examId,
+ sessionId: null,
+ windowSessionToken: null,
+ pageType: 'suite-placeholder'
+ }
+ }
+ });
+
+ assert.strictEqual(app._handshakeTimers.has(examId), true, '占位页 bootstrap ready 不得停止 INIT 重试');
+ assert.strictEqual(app.examWindows.get(examId).dataCollectorReady, undefined, '无 token ready 不得标记 collector ready');
+ assert(examWindow._messages.some(message => message && message.type === 'INIT_SESSION'), '无 token ready 后应立即补发 INIT_SESSION');
+ } finally {
+ clearInterval(timer);
+ }
+ }
+
+ // Case 12: 听力完成早于 initialized ready 时,也必须先补建 recorder session 再落库
+ {
+ const app = createApp(windowStub);
+ const examWindow = createStubWindow('custom-listening-complete-first-window');
+ const examId = 'custom-listening-complete-first';
+ const expectedSessionId = 'custom-listening-complete-first_expected';
+ const calls = [];
+ let status = null;
+
+ app.components.practiceRecorder = {
+ activeSessions: new Map(),
+ startPracticeSession(handledExamId, examData) {
+ calls.push({ type: 'startPracticeSession', examId: handledExamId, examData });
+ this.activeSessions.set(handledExamId, {
+ examId: handledExamId,
+ sessionId: `${handledExamId}_generated`,
+ metadata: {},
+ progress: { totalQuestions: examData.totalQuestions || 0 },
+ answers: {}
+ });
+ return this.activeSessions.get(handledExamId);
+ },
+ handleSessionStarted(payload) {
+ calls.push({ type: 'handleSessionStarted', payload });
+ assert(this.activeSessions.has(payload.examId), '补建 session 必须先于 handleSessionStarted');
+ const session = this.activeSessions.get(payload.examId);
+ session.sessionId = payload.sessionId;
+ session.metadata = { ...session.metadata, ...payload.metadata };
+ this.activeSessions.set(payload.examId, session);
+ },
+ async handleSessionCompleted(payload) {
+ calls.push({ type: 'handleSessionCompleted', payload });
+ assert(this.activeSessions.has(payload.examId), '真实 recorder 没有 active session 会拒绝落库');
+ const session = this.activeSessions.get(payload.examId);
+ assert.strictEqual(session.sessionId, payload.sessionId, '完成 payload 必须使用父页面 expectedSessionId');
+ const record = {
+ id: `record_${payload.sessionId}`,
+ examId: payload.examId,
+ sessionId: payload.sessionId,
+ endTime: payload.endTime || '2026-07-26T00:00:00.000Z'
+ };
+ return (await windowStub.AppData.practice.completeAttempt({ record })).record;
+ }
+ };
+ app.updateExamStatus = (handledExamId, nextStatus) => {
+ status = { examId: handledExamId, status: nextStatus };
+ };
+ app.showRealCompletionNotification = () => {};
+ app.cleanupExamSession = async () => {};
+ app.setState = () => {};
+
+ app.setupExamWindowCommunication(examWindow, examId, {
+ id: examId,
+ title: 'Complete First Listening',
+ type: 'listening'
+ });
+
+ const info = app.ensureExamWindowSession(examId, examWindow);
+ info.expectedSessionId = expectedSessionId;
+ app._refreshExamWindowToken(examId, info);
+ app.examWindows.set(examId, info);
+
+ const handler = app.messageHandlers.get(examId);
+ assert.strictEqual(typeof handler, 'function', '听力题源应注册 message handler');
+
+ await handler({
+ source: examWindow,
+ origin: 'http://localhost',
+ data: {
+ type: 'PRACTICE_COMPLETE',
+ source: 'listening_record_bridge',
+ data: {
+ source: 'listening_record_bridge',
+ examId: 'listening-unknown',
+ sessionId: 'listening-unknown_early',
+ submissionId: 'listening-submit-complete-first',
+ windowSessionToken: info.windowSessionToken,
+ practiceType: 'listening',
+ pageType: 'listening',
+ title: 'Complete First Listening',
+ answers: { q1: 'acommodation' },
+ correctAnswers: { q1: 'accommodation' },
+ answerComparison: {
+ q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false }
+ },
+ scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' }
+ }
+ }
+ });
+
+ assert.deepStrictEqual(
+ calls.map(call => call.type),
+ ['startPracticeSession', 'handleSessionStarted', 'handleSessionCompleted'],
+ 'complete-before-ready 必须先建会话、再同步 sessionId、最后落库'
+ );
+ assert.strictEqual(calls[2].payload.examId, examId, '完成 payload examId 应被纠正为父页面当前题源');
+ assert.strictEqual(calls[2].payload.sessionId, expectedSessionId, '完成 payload sessionId 应被纠正为父页面会话');
+ assert.deepStrictEqual(status, { examId, status: 'completed' }, '完成后应更新题源状态');
+ }
+
+ // Case 13: 统一阅读提交后 reset 必须复用父页通信链路并重建 recorder session
+ {
+ const app = createApp(windowStub);
+ const examWindow = createStubWindow('unified-reading-retake-window');
+ const examId = 'reading-retake-unified';
+ const firstSessionId = 'reading-retake-first-session';
+ const resetSessionId = 'reading-retake-reset-session';
+ const completions = [];
+ const recorderStarts = [];
+ const resetStarts = [];
+ const statuses = [];
let cleanupCount = 0;
let restartCount = 0;
- app.generateSessionId = () => resetSessionId;
+ app.generateSessionId = () => resetSessionId;
+ app.components.practiceRecorder = {
+ activeSessions: new Map(),
+ async handleSessionCompleted(payload) {
+ completions.push(payload);
+ this.activeSessions.delete(payload.examId);
+ const record = {
+ id: `record_${payload.sessionId}`,
+ examId: payload.examId,
+ sessionId: payload.sessionId,
+ endTime: payload.endTime || '2026-07-26T00:00:00.000Z'
+ };
+ return (await windowStub.AppData.practice.completeAttempt({ record })).record;
+ },
+ handleSessionStarted(payload) {
+ recorderStarts.push(payload);
+ const session = this.activeSessions.get(payload.examId) || {
+ examId: payload.examId,
+ metadata: {},
+ progress: {},
+ answers: {}
+ };
+ session.sessionId = payload.sessionId;
+ session.metadata = { ...session.metadata, ...payload.metadata };
+ this.activeSessions.set(payload.examId, session);
+ }
+ };
+ app.startPracticeSession = async (handledExamId, startOptions = {}) => {
+ resetStarts.push(handledExamId);
+ app.components.practiceRecorder.activeSessions.set(handledExamId, {
+ examId: handledExamId,
+ sessionId: 'temporary-reset-session',
+ metadata: {},
+ progress: {},
+ answers: {}
+ });
+ return buildOwnedStartResult(
+ app,
+ handledExamId,
+ true,
+ startOptions.launchOwnership
+ );
+ };
+ app.cleanupExamSession = async () => {
+ cleanupCount += 1;
+ };
+ app.restartExamHandshake = (targetWindow, handledExamId) => {
+ restartCount += 1;
+ assert.strictEqual(targetWindow, examWindow, 'reset 应复用当前统一阅读窗口');
+ assert.strictEqual(handledExamId, examId, 'reset 握手应使用当前题源');
+ };
+ app.updateExamStatus = (handledExamId, status) => {
+ statuses.push({ examId: handledExamId, status });
+ };
+ app.showRealCompletionNotification = () => {};
+ app.setState = () => {};
+
+ app.setupExamWindowCommunication(examWindow, examId, {
+ id: examId,
+ title: 'Unified Retake Reading',
+ type: 'reading'
+ });
+
+ const info = app.ensureExamWindowSession(examId, examWindow);
+ info.expectedSessionId = firstSessionId;
+ app._refreshExamWindowToken(examId, info);
+ app.examWindows.set(examId, info);
+ const initialGeneration = info.sessionGeneration;
+
+ const handler = app.messageHandlers.get(examId);
+ assert.strictEqual(typeof handler, 'function', '统一阅读题源应注册 message handler');
+
+ await handler({
+ source: examWindow,
+ origin: 'http://localhost',
+ data: {
+ type: 'PRACTICE_COMPLETE',
+ source: 'practice_page',
+ data: {
+ examId,
+ sessionId: firstSessionId,
+ submissionId: 'reading-submit-first-session',
+ windowSessionToken: info.windowSessionToken,
+ answers: { q1: 'A' },
+ answerComparison: {
+ q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true }
+ },
+ scoreInfo: { correct: 1, total: 1, totalQuestions: 1, accuracy: 1, percentage: 100 },
+ metadata: {
+ type: 'reading',
+ examType: 'reading',
+ practiceMode: 'single',
+ renderMode: 'unified-reading'
+ }
+ }
+ }
+ });
+
+ assert.strictEqual(cleanupCount, 0, '统一阅读提交后不得清掉父页消息 handler');
+ assert.strictEqual(app.messageHandlers.has(examId), true, '统一阅读完成后应保留 message handler 等待 reset');
+ assert.strictEqual(app.examWindows.get(examId).status, 'completed', '统一阅读完成后应标记窗口完成态');
+ assert.strictEqual(completions.length, 1, '统一阅读完成应正常进入 recorder');
+
+ examWindow._messages.length = 0;
+ recorderStarts.length = 0;
+ await handler({
+ source: examWindow,
+ origin: 'http://localhost',
+ data: {
+ type: 'PRACTICE_RESET_REQUEST',
+ source: 'practice_page',
+ data: {
+ examId,
+ sessionId: firstSessionId,
+ windowSessionToken: info.windowSessionToken,
+ reason: 'retake-after-submit',
+ fromPracticeMode: 'single',
+ targetPracticeMode: 'single',
+ normalUrl: 'file:///reading-practice-unified.html?examId=reading-retake-unified'
+ }
+ }
+ });
+
+ const resetInfo = app.examWindows.get(examId);
+ assert.strictEqual(resetInfo.expectedSessionId, resetSessionId, 'reset 必须生成新的 expectedSessionId');
+ assert(resetInfo.sessionGeneration > initialGeneration, 'reset 旋转 token 时必须推进 generation');
+ assert.strictEqual(resetInfo.status, 'active', 'reset 后窗口应回到 active');
+ assert.deepStrictEqual(resetStarts, [examId], 'reset 后必须补建练习会话');
+ assert.strictEqual(recorderStarts.length, 1, 'reset 后必须同步 recorder sessionId');
+ assert.strictEqual(recorderStarts[0].sessionId, resetSessionId, 'recorder sessionId 必须使用 reset 后的新 session');
+ assert.strictEqual(
+ app.components.practiceRecorder.activeSessions.get(examId).sessionId,
+ resetSessionId,
+ 'reset 后 active recorder session 必须可被下一次提交使用'
+ );
+ assert(
+ examWindow._messages.some(message => message && message.type === 'INIT_SESSION' && message.data && message.data.sessionId === resetSessionId),
+ 'reset 后必须向子页发送新的 INIT_SESSION'
+ );
+ assert.strictEqual(restartCount, 1, 'reset 后必须重启握手');
+ assert(statuses.some(item => item.examId === examId && item.status === 'in-progress'), 'reset 后题源状态应回到 in-progress');
+ }
+
+ // Finalization must freeze teardown ownership before its first durable await.
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_finalize_freezes_owner');
+ const examId = session.activeExamId;
+ const suiteWindow = session.windowRef;
+ suiteWindow.close = function close() { this.closed = true; };
+ session._suiteGeneration = 1;
+ session.results = session.sequence.map((entry, index) => ({
+ examId: entry.examId,
+ title: entry.exam.title,
+ category: entry.exam.category,
+ duration: 10,
+ answers: { q1: String.fromCharCode(65 + index) },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 },
+ rawData: {}
+ }));
+ const suiteInfo = {
+ window: suiteWindow,
+ suiteSessionId: session.id,
+ expectedSessionId: 'suite-finalize-owner-attempt',
+ windowSessionToken: 'suite-finalize-owner-token',
+ windowSessionTokenSessionId: 'suite-finalize-owner-attempt',
+ sessionGeneration: 7
+ };
+ session.windowBinding = {
+ examId,
+ expectedSessionId: suiteInfo.expectedSessionId,
+ windowSessionToken: suiteInfo.windowSessionToken,
+ sessionGeneration: suiteInfo.sessionGeneration
+ };
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+ app.examWindows = new Map([[examId, suiteInfo]]);
+ app.messageHandlers = new Map([[examId, () => {}]]);
+
+ let markFinalizeWriteEntered;
+ let releaseFinalizeWrite;
+ const finalizeWriteEntered = new Promise((resolve) => { markFinalizeWriteEntered = resolve; });
+ const finalizeWriteGate = new Promise((resolve) => { releaseFinalizeWrite = resolve; });
+ let recoveryWrites = 0;
+ app._commitSuiteRecovery = async () => {
+ recoveryWrites += 1;
+ if (recoveryWrites === 1) {
+ markFinalizeWriteEntered();
+ await finalizeWriteGate;
+ }
+ return true;
+ };
+ app._saveSuitePracticeRecord = async () => {};
+ app._updatePracticeRecordsState = async () => {};
+ app._discardPersistentSuiteRecovery = async () => true;
+
+ const finalizing = app.finalizeSuiteRecord(session, { deferTeardown: true });
+ await finalizeWriteEntered;
+ const frozenRegistrations = session._suiteTeardownRegistrations;
+ assert(frozenRegistrations && typeof frozenRegistrations.get === 'function');
+ assert.strictEqual(frozenRegistrations.get(examId).windowInfo, suiteInfo);
+
+ const normalWindow = createStubWindow('normal-after-finalize-start');
+ normalWindow.close = function close() { this.closed = true; };
+ const normalInfo = {
+ window: normalWindow,
+ suiteSessionId: null,
+ expectedSessionId: 'normal-after-finalize-attempt',
+ windowSessionToken: 'normal-after-finalize-token',
+ windowSessionTokenSessionId: 'normal-after-finalize-attempt',
+ sessionGeneration: 8
+ };
+ // Simulate a late binding mutation while the first finalize write is in flight.
+ session.windowRef = normalWindow;
+ session.windowBinding = {
+ examId,
+ expectedSessionId: normalInfo.expectedSessionId,
+ windowSessionToken: normalInfo.windowSessionToken,
+ sessionGeneration: normalInfo.sessionGeneration
+ };
+ app.examWindows.set(examId, normalInfo);
+ const normalHandler = () => {};
+ app.messageHandlers.set(examId, normalHandler);
+ releaseFinalizeWrite();
+
+ assert.strictEqual(await finalizing, true);
+ assert.strictEqual(session._suiteTeardownRegistrations, frozenRegistrations);
+ let scheduledTeardown = null;
+ const originalSetTimeout = sandbox.setTimeout;
+ const originalClearTimeout = sandbox.clearTimeout;
+ sandbox.setTimeout = (callback) => {
+ scheduledTeardown = callback;
+ return { unref() {} };
+ };
+ sandbox.clearTimeout = () => {};
+ try {
+ assert.strictEqual(app._scheduleSuiteSubmitTeardown(session), true);
+ assert.strictEqual(session._suiteTeardownRegistrations, frozenRegistrations, 'delayed scheduling must reuse the pre-finalize snapshot');
+ await scheduledTeardown();
+ } finally {
+ sandbox.setTimeout = originalSetTimeout;
+ sandbox.clearTimeout = originalClearTimeout;
+ }
+ assert.strictEqual(suiteWindow.closed, true, 'teardown must close the originally frozen suite window');
+ assert.strictEqual(normalWindow.closed, false, 'teardown must not close a late normal replacement');
+ assert.strictEqual(app.examWindows.get(examId), normalInfo);
+ assert.strictEqual(app.messageHandlers.get(examId), normalHandler);
+ }
+
+ // Reusing a suite WindowProxy must invalidate its old owner before openExam's first await.
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_reuse_navigation_gap');
+ const examId = session.activeExamId;
+ const sharedWindow = session.windowRef;
+ sharedWindow.close = function close() { this.closed = true; };
+ session.status = 'completed';
+ session._suiteGeneration = 1;
+ const suiteInfo = {
+ window: sharedWindow,
+ suiteSessionId: session.id,
+ expectedSessionId: 'suite-reuse-gap-attempt',
+ windowSessionToken: 'suite-reuse-gap-token',
+ windowSessionTokenSessionId: 'suite-reuse-gap-attempt',
+ expectedUrl: 'http://localhost/suite-old.html',
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false,
+ sessionGeneration: 5
+ };
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+ app.examWindows = new Map([[examId, suiteInfo]]);
+ app.setupExamWindowCommunication(sharedWindow, examId, {
+ id: examId,
+ title: 'Suite reuse gap',
+ type: 'reading'
+ });
+ const oldMessageHandler = app.messageHandlers.get(examId);
+ const oldHandshakeTimer = setInterval(() => {}, 60000);
+ app._handshakeTimers = new Map([[examId, oldHandshakeTimer]]);
+ session.windowBinding = {
+ examId,
+ expectedSessionId: suiteInfo.expectedSessionId,
+ windowSessionToken: suiteInfo.windowSessionToken,
+ sessionGeneration: suiteInfo.sessionGeneration
+ };
+ app._ensureSuiteWindowGuard(session, sharedWindow);
+ await windowStub.AppData.recovery.saveActiveSession({ id: session.id, status: 'completed' });
+ await windowStub.AppData.recovery.saveActiveSession({
+ id: `active-session:${suiteInfo.expectedSessionId}`,
+ examId,
+ sessionId: suiteInfo.expectedSessionId,
+ status: 'started'
+ });
+
+ let scheduledTeardown = null;
+ let markReuseCleanupEntered;
+ let releaseReuseCleanup;
+ const reuseCleanupEntered = new Promise((resolve) => { markReuseCleanupEntered = resolve; });
+ const reuseCleanupGate = new Promise((resolve) => { releaseReuseCleanup = resolve; });
+ const originalSetTimeout = sandbox.setTimeout;
+ const originalClearTimeout = sandbox.clearTimeout;
+ const originalCleanupReused = app._cleanupReusedWindowSessions;
+ const originalCaptureLibrary = app._captureLaunchLibraryConfigurationId;
+ const originalStartPractice = app.startPracticeSession;
+ const originalInject = app.injectDataCollectionScript;
+ const originalGuard = app._guardExamWindowContent;
+ const originalResolveReading = app.resolveReadingLaunchDescriptor;
+ sandbox.setTimeout = (callback) => {
+ if (!scheduledTeardown) scheduledTeardown = callback;
+ return { unref() {} };
+ };
+ sandbox.clearTimeout = () => {};
+ app._cleanupReusedWindowSessions = async () => {
+ markReuseCleanupEntered();
+ await reuseCleanupGate;
+ return [];
+ };
+ app._captureLaunchLibraryConfigurationId = async () => null;
+ app.startPracticeSession = async (handledExamId, startOptions = {}) => buildOwnedStartResult(
+ app,
+ handledExamId,
+ true,
+ startOptions.launchOwnership
+ );
+ app.injectDataCollectionScript = () => {};
+ app._guardExamWindowContent = (targetWindow) => targetWindow;
+ app.resolveReadingLaunchDescriptor = () => ({
+ mode: 'unified_html',
+ url: 'http://localhost/normal-reuse-gap.html'
+ });
+
+ let normalInfo = null;
+ try {
+ assert.strictEqual(app._scheduleSuiteSubmitTeardown(session), true);
+ const opening = app.openExam(examId, {
+ examDefinition: { id: examId, title: 'Normal reuse gap', type: 'reading', hasHtml: true },
+ target: 'tab',
+ reuseWindow: sharedWindow,
+ practiceMode: 'single'
+ });
+ await reuseCleanupEntered;
+ const pendingInfo = app.examWindows.get(examId);
+ assert.notStrictEqual(pendingInfo, suiteInfo, 'navigation must synchronously replace the old registration');
+ assert.strictEqual(pendingInfo.window, sharedWindow);
+ assert.strictEqual(pendingInfo.suiteSessionId, null);
+ assert(pendingInfo.sessionGeneration > suiteInfo.sessionGeneration);
+ assert.strictEqual(pendingInfo.handshakeDeferred, true);
+ assert.strictEqual(app.messageHandlers.has(examId), false, 'the navigated-away document handler must be detached synchronously');
+ assert.strictEqual(app._handshakeTimers.has(examId), false, 'the old handshake retry must be stopped synchronously');
+ const messageCountBeforePendingRequest = sharedWindow._messages.length;
+ await oldMessageHandler({
+ source: sharedWindow,
+ origin: 'http://localhost',
+ data: { type: 'REQUEST_INIT', source: 'practice_page', data: { examId } }
+ });
+ assert.strictEqual(
+ sharedWindow._messages.length,
+ messageCountBeforePendingRequest,
+ 'a detached handler must fail closed while reassignment is pending'
+ );
+
+ await scheduledTeardown();
+ assert.strictEqual(sharedWindow.closed, false, 'teardown must not close the already navigated normal page');
+ assert.strictEqual(
+ sharedWindow._messages.some(message => message && message.type === 'SUITE_FORCE_CLOSE'),
+ false,
+ 'the pending normal reuse must not receive a suite force-close envelope'
+ );
+
+ releaseReuseCleanup();
+ assert.strictEqual(await opening, sharedWindow);
+ normalInfo = app.examWindows.get(examId);
+ assert.strictEqual(normalInfo.window, sharedWindow);
+ assert.strictEqual(normalInfo.suiteSessionId, null);
+ assert.strictEqual(normalInfo.status, 'active');
+ } finally {
+ releaseReuseCleanup && releaseReuseCleanup();
+ sandbox.setTimeout = originalSetTimeout;
+ sandbox.clearTimeout = originalClearTimeout;
+ app._cleanupReusedWindowSessions = originalCleanupReused;
+ app._captureLaunchLibraryConfigurationId = originalCaptureLibrary;
+ app.startPracticeSession = originalStartPractice;
+ app.injectDataCollectionScript = originalInject;
+ app._guardExamWindowContent = originalGuard;
+ app.resolveReadingLaunchDescriptor = originalResolveReading;
+ }
+
+ const activeAfterTeardown = await windowStub.AppData.recovery.listActiveSessions();
+ assert.strictEqual(app.currentSuiteSession, null);
+ assert.strictEqual(
+ activeAfterTeardown.some(item => item && item.sessionId === suiteInfo.expectedSessionId),
+ false,
+ 'the old suite attempt recovery must still be removed during the navigation gap'
+ );
+ if (normalInfo && normalInfo.closeMonitor) clearInterval(normalInfo.closeMonitor);
+ if (app._handshakeTimers) {
+ for (const timer of app._handshakeTimers.values()) clearInterval(timer);
+ app._handshakeTimers.clear();
+ }
+ app.examWindows.delete(examId);
+ app.messageHandlers.delete(examId);
+ }
+
+ // A raw PDF reuse has no later managed handshake; it must fully detach the old owner.
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_pdf_reuse_gap');
+ const examId = session.activeExamId;
+ const sharedWindow = session.windowRef;
+ sharedWindow.close = function close() { this.closed = true; };
+ session.status = 'completed';
+ session._suiteGeneration = 1;
+ const closeMonitorToken = setInterval(() => {}, 60000);
+ const handshakeTimerToken = setInterval(() => {}, 60000);
+ const suiteInfo = {
+ window: sharedWindow,
+ suiteSessionId: session.id,
+ expectedSessionId: 'suite-pdf-owner-attempt',
+ windowSessionToken: 'suite-pdf-owner-token',
+ windowSessionTokenSessionId: 'suite-pdf-owner-attempt',
+ sessionGeneration: 3,
+ closeMonitor: closeMonitorToken
+ };
+ session.windowBinding = {
+ examId,
+ expectedSessionId: suiteInfo.expectedSessionId,
+ windowSessionToken: suiteInfo.windowSessionToken,
+ sessionGeneration: suiteInfo.sessionGeneration
+ };
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+ app.examWindows = new Map([[examId, suiteInfo]]);
+ const listenerCountBeforeHandler = windowStub.__listenerCount('message');
+ app.setupExamWindowCommunication(sharedWindow, examId, {
+ id: examId,
+ title: 'Suite PDF reuse',
+ type: 'reading'
+ });
+ assert.strictEqual(windowStub.__listenerCount('message'), listenerCountBeforeHandler + 1);
+ app._handshakeTimers = new Map([[examId, handshakeTimerToken]]);
+ app._discardPersistentSuiteRecovery = async () => true;
+
+ let scheduledTeardown = null;
+ const originalSetTimeout = sandbox.setTimeout;
+ const originalClearTimeout = sandbox.clearTimeout;
+ const originalClearInterval = sandbox.clearInterval;
+ const clearedIntervals = new Set();
+ sandbox.setTimeout = (callback) => {
+ scheduledTeardown = callback;
+ return { unref() {} };
+ };
+ sandbox.clearTimeout = () => {};
+ sandbox.clearInterval = (timer) => {
+ clearedIntervals.add(timer);
+ originalClearInterval(timer);
+ };
+ try {
+ assert.strictEqual(app._scheduleSuiteSubmitTeardown(session), true);
+ assert.strictEqual(
+ app._openPdfWindow({ id: examId, title: 'Normal PDF' }, 'http://localhost/normal.pdf', {
+ reuseWindow: sharedWindow,
+ target: 'tab'
+ }),
+ sharedWindow
+ );
+ assert.strictEqual(sharedWindow.location.href, 'http://localhost/normal.pdf');
+ assert.strictEqual(app.examWindows.has(examId), false, 'unmanaged PDF reuse must not leave a provisional registration');
+ assert.strictEqual(app.messageHandlers.has(examId), false);
+ assert.strictEqual(app._handshakeTimers.has(examId), false);
+ assert.strictEqual(windowStub.__listenerCount('message'), listenerCountBeforeHandler, 'PDF reuse must detach the real old message listener');
+ assert(clearedIntervals.has(closeMonitorToken), 'PDF reuse must stop the old close monitor');
+ assert(clearedIntervals.has(handshakeTimerToken), 'PDF reuse must stop the old handshake timer');
+
+ // A later managed launch may occupy the same exam id on another window.
+ // The invalidated PDF WindowProxy must remain remembered independently
+ // of the current examWindows entry until the frozen teardown completes.
+ const normalWindow = createStubWindow('normal-after-pdf-reuse');
+ normalWindow.close = function close() { this.closed = true; };
+ const normalInfo = {
+ window: normalWindow,
+ suiteSessionId: null,
+ expectedSessionId: 'normal-after-pdf-attempt',
+ windowSessionToken: 'normal-after-pdf-token',
+ windowSessionTokenSessionId: 'normal-after-pdf-attempt',
+ sessionGeneration: 4
+ };
+ const normalHandler = () => {};
+ app.examWindows.set(examId, normalInfo);
+ app.messageHandlers.set(examId, normalHandler);
+
+ await scheduledTeardown();
+ assert.strictEqual(sharedWindow.closed, false, 'delayed teardown must not close the already navigated PDF');
+ assert.strictEqual(normalWindow.closed, false);
+ assert.strictEqual(app.examWindows.get(examId), normalInfo);
+ assert.strictEqual(app.messageHandlers.get(examId), normalHandler);
+ assert.strictEqual(
+ sharedWindow._messages.some(message => message && message.type === 'SUITE_FORCE_CLOSE'),
+ false,
+ 'the PDF replacement must not receive a suite force-close envelope'
+ );
+ assert.strictEqual(app.currentSuiteSession, null);
+ } finally {
+ sandbox.setTimeout = originalSetTimeout;
+ sandbox.clearTimeout = originalClearTimeout;
+ sandbox.clearInterval = originalClearInterval;
+ if (suiteInfo.closeMonitor) clearInterval(suiteInfo.closeMonitor);
+ if (app._handshakeTimers) {
+ for (const timer of app._handshakeTimers.values()) clearInterval(timer);
+ app._handshakeTimers.clear();
+ }
+ }
+ }
+
+ // Delayed completed-suite teardown only owns its exact registration and recovery.
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_delayed_exact_owner');
+ const examId = session.activeExamId;
+ const sharedWindow = session.windowRef;
+ sharedWindow.close = function close() { this.closed = true; };
+ session.status = 'completed';
+ session._suiteGeneration = 1;
+ const suiteInfo = {
+ window: sharedWindow,
+ suiteSessionId: session.id,
+ expectedSessionId: 'suite-owned-attempt',
+ windowSessionToken: 'suite-owned-token',
+ sessionGeneration: 4
+ };
+ const suiteHandler = () => {};
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+ app.examWindows = new Map([[examId, suiteInfo]]);
+ app.messageHandlers = new Map([[examId, suiteHandler]]);
+ session.windowBinding = {
+ examId,
+ expectedSessionId: suiteInfo.expectedSessionId,
+ windowSessionToken: suiteInfo.windowSessionToken,
+ sessionGeneration: suiteInfo.sessionGeneration
+ };
+ app._ensureSuiteWindowGuard(session, sharedWindow);
+ assert.strictEqual(sharedWindow.__IELTS_SUITE_PARENT_GUARD__.sessionId, session.id);
+ assert.strictEqual(await app._ensureSuiteRecoveryClaim('single', session), true);
+ app._mirrorSessionToStorage(session);
+ await windowStub.AppData.recovery.saveActiveSession({ id: session.id, status: 'completed' });
+ await windowStub.AppData.recovery.saveActiveSession({
+ id: `active-session:${suiteInfo.expectedSessionId}`,
+ examId,
+ sessionId: suiteInfo.expectedSessionId,
+ status: 'started'
+ });
+
+ let scheduledTeardown = null;
+ const originalSetTimeout = sandbox.setTimeout;
+ const originalClearTimeout = sandbox.clearTimeout;
+ sandbox.setTimeout = (callback) => {
+ scheduledTeardown = callback;
+ return { unref() {} };
+ };
+ sandbox.clearTimeout = () => {};
+
+ const normalInfo = {
+ window: sharedWindow,
+ // A completed suite may still cause normal initialization to inherit this tag.
+ suiteSessionId: session.id,
+ expectedSessionId: 'normal-reused-attempt',
+ windowSessionToken: 'normal-reused-token',
+ sessionGeneration: suiteInfo.sessionGeneration + 1
+ };
+ const lateWindowRef = createStubWindow('late-normal-window-ref');
+ lateWindowRef.close = function close() { this.closed = true; };
+ const normalHandler = () => {};
+ try {
+ assert.strictEqual(app._scheduleSuiteSubmitTeardown(session), true);
+ assert.strictEqual(typeof scheduledTeardown, 'function');
+
+ // The normal practice reuses the same exam id and WindowProxy before the delay expires.
+ app.examWindows.set(examId, normalInfo);
+ app.messageHandlers.set(examId, normalHandler);
+ // Even if a late protocol message mutates the live binding, the scheduled
+ // teardown must retain the completed suite's pre-delay ownership snapshot.
+ session.windowBinding = {
+ examId,
+ expectedSessionId: normalInfo.expectedSessionId,
+ windowSessionToken: normalInfo.windowSessionToken,
+ sessionGeneration: normalInfo.sessionGeneration
+ };
+ session.windowRef = lateWindowRef;
+ await windowStub.AppData.recovery.saveActiveSession({
+ id: `active-session:${normalInfo.expectedSessionId}`,
+ examId,
+ sessionId: normalInfo.expectedSessionId,
+ status: 'started'
+ });
+
+ await scheduledTeardown();
+ } finally {
+ sandbox.setTimeout = originalSetTimeout;
+ sandbox.clearTimeout = originalClearTimeout;
+ }
+
+ const activeAfterTeardown = await windowStub.AppData.recovery.listActiveSessions();
+ assert.strictEqual(app.currentSuiteSession, null, 'completed suite teardown should still finish');
+ assert.strictEqual(app.suiteExamMap.has(examId), false, 'completed suite routing should be cleared');
+ assert.strictEqual(windowSessionStore.has('simulation'), false, 'completed suite snapshot should be cleared');
+ assert.strictEqual(sharedWindow.closed, false, 'reused normal-practice window must stay open');
+ assert.strictEqual(lateWindowRef.closed, false, 'live windowRef changes must not redirect frozen teardown at another window');
+ assert.strictEqual(
+ sharedWindow.__IELTS_SUITE_PARENT_GUARD__,
+ undefined,
+ 'the completed suite guard should be released from the reused window'
+ );
+ assert.strictEqual(
+ sharedWindow._messages.some((message) => message && message.type === 'SUITE_FORCE_CLOSE'),
+ false,
+ 'reused normal-practice window must not receive SUITE_FORCE_CLOSE'
+ );
+ assert.strictEqual(app.examWindows.get(examId), normalInfo, 'new registration must survive stale teardown');
+ assert.strictEqual(app.messageHandlers.get(examId), normalHandler, 'new message handler must survive stale teardown');
+ assert.strictEqual(
+ activeAfterTeardown.some((item) => item && item.sessionId === suiteInfo.expectedSessionId),
+ false,
+ 'the completed suite attempt recovery should be removed by exact session id'
+ );
+ assert.strictEqual(
+ activeAfterTeardown.some((item) => item && item.sessionId === normalInfo.expectedSessionId),
+ true,
+ 'the reused normal-practice recovery must survive stale teardown'
+ );
+
+ app.examWindows.delete(examId);
+ app.messageHandlers.delete(examId);
+ await windowStub.AppData.recovery.discardActiveSession(`active-session:${normalInfo.expectedSessionId}`);
+ }
+
+ // A different-window normal registration must survive while the frozen suite window closes.
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_delayed_distinct_window');
+ const examId = session.activeExamId;
+ const suiteWindow = session.windowRef;
+ suiteWindow.close = function close() { this.closed = true; };
+ session.status = 'completed';
+ session._suiteGeneration = 1;
+ const suiteInfo = {
+ window: suiteWindow,
+ suiteSessionId: session.id,
+ expectedSessionId: 'suite-distinct-attempt',
+ windowSessionToken: 'suite-distinct-token',
+ windowSessionTokenSessionId: 'suite-distinct-attempt',
+ sessionGeneration: 7
+ };
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+ app.examWindows = new Map([[examId, suiteInfo]]);
+ app.messageHandlers = new Map([[examId, () => {}]]);
+ session.windowBinding = {
+ examId,
+ expectedSessionId: suiteInfo.expectedSessionId,
+ windowSessionToken: suiteInfo.windowSessionToken,
+ sessionGeneration: suiteInfo.sessionGeneration
+ };
+ app._ensureSuiteWindowGuard(session, suiteWindow);
+ await windowStub.AppData.recovery.saveActiveSession({ id: session.id, status: 'completed' });
+ await windowStub.AppData.recovery.saveActiveSession({
+ id: `active-session:${suiteInfo.expectedSessionId}`,
+ examId,
+ sessionId: suiteInfo.expectedSessionId,
+ status: 'started'
+ });
+
+ let scheduledTeardown = null;
+ const originalSetTimeout = sandbox.setTimeout;
+ const originalClearTimeout = sandbox.clearTimeout;
+ sandbox.setTimeout = (callback) => {
+ scheduledTeardown = callback;
+ return { unref() {} };
+ };
+ sandbox.clearTimeout = () => {};
+
+ const normalWindow = createStubWindow('distinct-normal-window');
+ normalWindow.close = function close() { this.closed = true; };
+ const normalInfo = {
+ window: normalWindow,
+ suiteSessionId: null,
+ expectedSessionId: 'normal-distinct-attempt',
+ windowSessionToken: 'normal-distinct-token',
+ windowSessionTokenSessionId: 'normal-distinct-attempt',
+ sessionGeneration: suiteInfo.sessionGeneration + 1
+ };
+ const normalHandler = () => {};
+ try {
+ assert.strictEqual(app._scheduleSuiteSubmitTeardown(session), true);
+ app.examWindows.set(examId, normalInfo);
+ app.messageHandlers.set(examId, normalHandler);
+ session.windowRef = normalWindow;
+ session.windowBinding = {
+ examId,
+ expectedSessionId: normalInfo.expectedSessionId,
+ windowSessionToken: normalInfo.windowSessionToken,
+ sessionGeneration: normalInfo.sessionGeneration
+ };
+ await windowStub.AppData.recovery.saveActiveSession({
+ id: `active-session:${normalInfo.expectedSessionId}`,
+ examId,
+ sessionId: normalInfo.expectedSessionId,
+ status: 'started'
+ });
+ await scheduledTeardown();
+ } finally {
+ sandbox.setTimeout = originalSetTimeout;
+ sandbox.clearTimeout = originalClearTimeout;
+ }
+
+ const activeAfterTeardown = await windowStub.AppData.recovery.listActiveSessions();
+ assert.strictEqual(suiteWindow.closed, true, 'the frozen suite window must be closed');
+ assert.strictEqual(normalWindow.closed, false, 'the distinct normal window must stay open');
+ assert.strictEqual(
+ suiteWindow._messages.some(message => message && message.type === 'SUITE_FORCE_CLOSE'),
+ false,
+ 'teardown must not route a force-close envelope through the replacement registration'
+ );
+ assert.strictEqual(app.examWindows.get(examId), normalInfo, 'the distinct normal registration must survive');
+ assert.strictEqual(normalInfo.window, normalWindow, 'teardown must not rebind the normal registration to the old suite window');
+ assert.strictEqual(normalInfo.windowSessionToken, 'normal-distinct-token', 'teardown must not rotate the normal token');
+ assert.strictEqual(app.messageHandlers.get(examId), normalHandler, 'the distinct normal handler must survive');
+ assert.strictEqual(
+ activeAfterTeardown.some(item => item && item.sessionId === suiteInfo.expectedSessionId),
+ false,
+ 'the old suite attempt recovery must be removed'
+ );
+ assert.strictEqual(
+ activeAfterTeardown.some(item => item && item.sessionId === normalInfo.expectedSessionId),
+ true,
+ 'the distinct normal recovery must survive'
+ );
+
+ app.examWindows.delete(examId);
+ app.messageHandlers.delete(examId);
+ await windowStub.AppData.recovery.discardActiveSession(`active-session:${normalInfo.expectedSessionId}`);
+ }
+
+ // A failed active-suite abort must recapture ownership after navigation.
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite_abort_recaptures_registration');
+ const firstExamId = session.sequence[0].examId;
+ const firstWindow = session.windowRef;
+ firstWindow.close = function close() { this.closed = true; };
+ const firstInfo = {
+ window: firstWindow,
+ suiteSessionId: session.id,
+ expectedSessionId: 'abort-first-attempt',
+ windowSessionToken: 'abort-first-token',
+ windowSessionTokenSessionId: 'abort-first-attempt',
+ sessionGeneration: 1
+ };
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+ app.examWindows = new Map([[firstExamId, firstInfo]]);
+ app.messageHandlers = new Map([[firstExamId, () => {}]]);
+ session.windowBinding = {
+ examId: firstExamId,
+ expectedSessionId: firstInfo.expectedSessionId,
+ windowSessionToken: firstInfo.windowSessionToken,
+ sessionGeneration: firstInfo.sessionGeneration
+ };
+
+ let discardAttempts = 0;
+ app._discardPersistentSuiteRecovery = async () => {
+ discardAttempts += 1;
+ return discardAttempts > 1;
+ };
+
+ assert.strictEqual(
+ await app._abortSuiteSession(session, { reason: 'user_discard' }),
+ false,
+ 'the first abort should surface the durable discard failure'
+ );
+ assert.strictEqual(session.status, 'active', 'a failed abort must leave the suite active');
+ assert.strictEqual(app.currentSuiteSession, session, 'a failed abort must retain the active owner');
+ assert.strictEqual(
+ session._suiteTeardownRegistrations,
+ undefined,
+ 'an active abort failure must discard its frozen teardown registration'
+ );
+ assert.strictEqual(firstWindow.closed, false, 'a failed abort must not close the current window');
+
+ const secondExamId = session.sequence[1].examId;
+ const secondWindow = createStubWindow('suite-window-after-abort-retry');
+ secondWindow.close = function close() { this.closed = true; };
+ const secondInfo = {
+ window: secondWindow,
+ suiteSessionId: session.id,
+ expectedSessionId: 'abort-second-attempt',
+ windowSessionToken: 'abort-second-token',
+ windowSessionTokenSessionId: 'abort-second-attempt',
+ sessionGeneration: 2
+ };
+ session.currentIndex = 1;
+ session.activeExamId = secondExamId;
+ session.windowRef = secondWindow;
+ session.windowBinding = {
+ examId: secondExamId,
+ expectedSessionId: secondInfo.expectedSessionId,
+ windowSessionToken: secondInfo.windowSessionToken,
+ sessionGeneration: secondInfo.sessionGeneration
+ };
+ app.examWindows.delete(firstExamId);
+ app.messageHandlers.delete(firstExamId);
+ app.examWindows.set(secondExamId, secondInfo);
+ app.messageHandlers.set(secondExamId, () => {});
+
+ const recaptured = app._captureSuiteTeardownRegistrations(session);
+ assert.strictEqual(
+ recaptured.get(secondExamId).windowInfo,
+ secondInfo,
+ 'the retry precondition must expose the newly bound exact registration'
+ );
+ const cleanupResults = [];
+ const cleanupExamSession = app.cleanupExamSession.bind(app);
+ app.cleanupExamSession = async (...args) => {
+ const result = await cleanupExamSession(...args);
+ cleanupResults.push({ examId: args[0], result });
+ return result;
+ };
+
+ assert.strictEqual(
+ await app._abortSuiteSession(session, { reason: 'user_discard' }),
+ true,
+ 'the retry should teardown the registration captured after navigation'
+ );
+ assert.strictEqual(discardAttempts, 2, 'the retry must attempt durable discard again');
+ assert.strictEqual(secondWindow.closed, true, 'the retry must close the newly bound suite window');
+ assert.strictEqual(firstWindow.closed, false, 'the retry must not reuse and close the stale window snapshot');
+ assert.deepStrictEqual(
+ cleanupResults,
+ [{ examId: secondExamId, result: true }],
+ 'the retry must run exact cleanup for the newly captured registration'
+ );
+ assert.strictEqual(app.currentSuiteSession, null, 'the successful retry must finish teardown');
+ assert.strictEqual(app.examWindows.has(secondExamId), false, 'the newly captured registration must be cleaned');
+ }
+
+ // A failed primary recovery save must retry with the frozen registration id.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-fallback-session-alignment';
+ const ownerSessionId = 'fallback-owner-session';
+ const unrelatedSessionId = 'fallback-unrelated-session';
+ const examWindow = createStubWindow('fallback-session-alignment-window');
+ const windowInfo = {
+ window: examWindow,
+ expectedSessionId: ownerSessionId,
+ windowSessionToken: 'fallback-session-token',
+ windowSessionTokenSessionId: ownerSessionId,
+ sessionGeneration: 1,
+ suiteSessionId: null
+ };
+ const originalResolveActiveLibraryIndex = windowStub.resolveActiveLibraryIndex;
+ const hadOpen = Object.prototype.hasOwnProperty.call(windowStub, 'open');
+ const originalOpen = windowStub.open;
+ const fallbackOpenCalls = [];
+ let generatedSessionIdCount = 0;
+
+ app.examWindows = new Map([[examId, windowInfo]]);
+ app.messageHandlers = new Map([[examId, () => {}]]);
+ app.generateSessionId = () => {
+ generatedSessionIdCount += 1;
+ return 'unexpected-generated-fallback-session';
+ };
+ windowStub.resolveActiveLibraryIndex = async () => [{
+ id: examId,
+ title: 'Fallback Session Alignment',
+ type: 'reading'
+ }];
+ windowStub.open = (url, name) => {
+ fallbackOpenCalls.push({ url, name });
+ return createStubWindow(name);
+ };
+
+ try {
+ await windowStub.AppData.recovery.saveActiveSession({
+ id: `active-session:${unrelatedSessionId}`,
+ examId,
+ sessionId: unrelatedSessionId,
+ status: 'started'
+ });
+ const recoveryEventStart = recoveryControl.events.length;
+ recoveryControl.saveQueue.push(new Error('transient primary recovery failure'), true);
+
+ await app.startPracticeSession(examId);
+
+ const attemptedSaves = recoveryControl.events
+ .slice(recoveryEventStart)
+ .filter((event) => event.type === 'save' && event.value && event.value.examId === examId);
+ assert.strictEqual(attemptedSaves.length, 2, 'primary failure must make one fallback save attempt');
+ attemptedSaves.forEach((event) => {
+ assert.strictEqual(event.value.sessionId, ownerSessionId);
+ assert.strictEqual(event.value.id, `active-session:${ownerSessionId}`);
+ });
+ const activeAfterFallback = await windowStub.AppData.recovery.listActiveSessions();
+ assert(
+ activeAfterFallback.some((item) => item && item.id === `active-session:${ownerSessionId}`),
+ 'fallback recovery must use the exact registration id'
+ );
+ assert.strictEqual(windowInfo.expectedSessionId, ownerSessionId);
+ assert.strictEqual(generatedSessionIdCount, 0, 'fallback must not mint another session id');
+ assert.strictEqual(fallbackOpenCalls.length, 1);
+ assert.strictEqual(fallbackOpenCalls[0].name, `practice_${ownerSessionId}`);
+
+ const expectedRegistration = app._captureExamSessionRegistration(examId, windowInfo);
+ assert.strictEqual(
+ await app.cleanupExamSession(examId, { expectedRegistration }),
+ true,
+ 'the exact current registration should be cleaned'
+ );
+ const activeAfterCleanup = await windowStub.AppData.recovery.listActiveSessions();
+ assert.strictEqual(
+ activeAfterCleanup.some((item) => item && item.id === `active-session:${ownerSessionId}`),
+ false,
+ 'exact cleanup must delete the fallback recovery it owns'
+ );
+ assert.strictEqual(
+ activeAfterCleanup.some((item) => item && item.id === `active-session:${unrelatedSessionId}`),
+ true,
+ 'exact cleanup must preserve another session for the same exam'
+ );
+ } finally {
+ await windowStub.AppData.recovery.discardActiveSession(`active-session:${ownerSessionId}`);
+ await windowStub.AppData.recovery.discardActiveSession(`active-session:${unrelatedSessionId}`);
+ await windowStub.AppData.recovery.discardActiveSession('active-session:unexpected-generated-fallback-session');
+ windowStub.resolveActiveLibraryIndex = originalResolveActiveLibraryIndex;
+ if (hadOpen) {
+ windowStub.open = originalOpen;
+ } else {
+ delete windowStub.open;
+ }
+ }
+ }
+
+ // A fallback save that loses its exact registration immediately after commit must clean its ghost.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-fallback-inflight-replacement';
+ const ownerSessionId = 'fallback-inflight-owner';
+ const replacementSessionId = 'fallback-inflight-replacement';
+ const ownerInfo = {
+ window: createStubWindow('fallback-inflight-owner-window'),
+ expectedSessionId: ownerSessionId,
+ windowSessionToken: 'fallback-inflight-owner-token',
+ windowSessionTokenSessionId: ownerSessionId,
+ sessionGeneration: 1,
+ suiteSessionId: null
+ };
+ const replacementInfo = {
+ window: createStubWindow('fallback-inflight-replacement-window'),
+ expectedSessionId: replacementSessionId,
+ windowSessionToken: 'fallback-inflight-replacement-token',
+ windowSessionTokenSessionId: replacementSessionId,
+ sessionGeneration: 2,
+ suiteSessionId: null
+ };
+ const replacementHandler = () => {};
+ const recovery = windowStub.AppData.recovery;
+ const originalSaveActiveSession = recovery.saveActiveSession;
+ const originalResolveActiveLibraryIndex = windowStub.resolveActiveLibraryIndex;
+ const hadOpen = Object.prototype.hasOwnProperty.call(windowStub, 'open');
+ const originalOpen = windowStub.open;
+ let fallbackOpenCount = 0;
+ let fallbackSaveCount = 0;
+
+ app.examWindows = new Map([[examId, ownerInfo]]);
+ app.messageHandlers = new Map([[examId, () => {}]]);
+ app.components.practiceRecorder = {
+ startPracticeSession() {
+ throw new Error('force final fallback');
+ }
+ };
+ windowStub.resolveActiveLibraryIndex = async () => [{
+ id: examId,
+ title: 'Fallback In-flight Replacement',
+ type: 'reading'
+ }];
+ windowStub.open = () => {
+ fallbackOpenCount += 1;
+ return createStubWindow('unexpected-fallback-window');
+ };
+ recovery.saveActiveSession = async (value, options = {}) => {
+ fallbackSaveCount += 1;
+ const allowed = typeof options.commitGuard === 'function' && options.commitGuard() === true;
+ if (!allowed) {
+ return { committed: false, stale: true, code: 'STALE_RECOVERY_WRITE' };
+ }
+ const receipt = await originalSaveActiveSession(value, options);
+ app.examWindows.set(examId, replacementInfo);
+ app.messageHandlers.set(examId, replacementHandler);
+ return receipt;
+ };
+
+ try {
+ await originalSaveActiveSession({
+ id: `active-session:${replacementSessionId}`,
+ examId,
+ sessionId: replacementSessionId,
+ status: 'started'
+ });
+ assert.strictEqual(
+ await app.startPracticeSessionFallback(examId, {}, { sessionId: 'unowned-fallback-session' }),
+ null,
+ 'fallback without an immutable registration tuple must fail closed'
+ );
+ assert.strictEqual(fallbackSaveCount, 0, 'an unowned fallback must not reach recovery storage');
+ assert.strictEqual(await app.startPracticeSession(examId), null);
+ assert.strictEqual(fallbackSaveCount, 1, 'the fallback must make only its owner-bound save attempt');
+ assert.strictEqual(fallbackOpenCount, 0, 'a stale fallback must not open another practice window');
+ assert.strictEqual(app.examWindows.get(examId), replacementInfo, 'the replacement registration must survive');
+ assert.strictEqual(app.messageHandlers.get(examId), replacementHandler, 'the replacement handler must survive');
+ const active = await recovery.listActiveSessions();
+ assert.strictEqual(
+ active.some((item) => item && item.id === `active-session:${ownerSessionId}`),
+ false,
+ 'post-commit ownership loss must discard the just-written fallback recovery'
+ );
+ assert.strictEqual(
+ active.some((item) => item && item.id === `active-session:${replacementSessionId}`),
+ true,
+ 'post-commit cleanup must preserve the replacement recovery'
+ );
+ } finally {
+ recovery.saveActiveSession = originalSaveActiveSession;
+ await recovery.discardActiveSession(`active-session:${ownerSessionId}`);
+ await recovery.discardActiveSession(`active-session:${replacementSessionId}`);
+ await recovery.discardActiveSession('active-session:unowned-fallback-session');
+ windowStub.resolveActiveLibraryIndex = originalResolveActiveLibraryIndex;
+ if (hadOpen) {
+ windowStub.open = originalOpen;
+ } else {
+ delete windowStub.open;
+ }
+ }
+ }
+
+ // The async practice page manager must receive the host id and may realign only its exact owner.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-manager-session-alignment';
+ const hostSessionId = 'manager-host-session';
+ const managerSessionId = 'manager-actual-session';
+ const windowInfo = {
+ window: createStubWindow('manager-session-alignment-window'),
+ expectedSessionId: hostSessionId,
+ windowSessionToken: 'manager-host-token',
+ windowSessionTokenSessionId: hostSessionId,
+ sessionGeneration: 1,
+ suiteSessionId: null
+ };
+ const initialToken = windowInfo.windowSessionToken;
+ const originalResolveActiveLibraryIndex = windowStub.resolveActiveLibraryIndex;
+ const hadManager = Object.prototype.hasOwnProperty.call(windowStub, 'practicePageManager');
+ const originalManager = windowStub.practicePageManager;
+
+ app.examWindows = new Map([[examId, windowInfo]]);
+ app.messageHandlers = new Map([[examId, () => {}]]);
+ windowStub.resolveActiveLibraryIndex = async () => [{
+ id: examId,
+ title: 'Manager Session Alignment',
+ type: 'reading'
+ }];
+ windowStub.practicePageManager = {
+ async startPracticeSession(handledExamId, examData) {
+ assert.strictEqual(handledExamId, examId);
+ assert.strictEqual(examData.sessionId, hostSessionId, 'manager must receive the host owner id');
+ await windowStub.AppData.recovery.saveActiveSession({
+ id: `active-session:${managerSessionId}`,
+ examId,
+ sessionId: managerSessionId,
+ status: 'started'
+ });
+ return managerSessionId;
+ }
+ };
+
+ try {
+ const startResult = await app.startPracticeSession(examId);
+ assert.strictEqual(app._isPracticeSessionOwnedSuccess(startResult), true);
+ assert.strictEqual(startResult.sessionId, managerSessionId);
+ assert.strictEqual(startResult.value, managerSessionId);
+ assert.strictEqual(windowInfo.expectedSessionId, managerSessionId);
+ assert.strictEqual(windowInfo.windowSessionTokenSessionId, managerSessionId);
+ assert.notStrictEqual(windowInfo.windowSessionToken, initialToken, 'manager id realignment must rotate the token');
+
+ windowStub.practicePageManager.startPracticeSession = async (handledExamId, examData) => {
+ assert.strictEqual(handledExamId, examId);
+ assert.strictEqual(examData.sessionId, managerSessionId);
+ return true;
+ };
+ const booleanResult = await app.startPracticeSession(examId, {
+ examDefinition: { id: examId, title: 'Manager Boolean Success', type: 'reading' }
+ });
+ assert.strictEqual(app._isPracticeSessionOwnedSuccess(booleanResult), true);
+ assert.strictEqual(booleanResult.sessionId, managerSessionId);
+ assert.notStrictEqual(windowInfo.expectedSessionId, 'true', 'boolean success must not become a session id');
+
+ const expectedRegistration = app._captureExamSessionRegistration(examId, windowInfo);
+ assert.strictEqual(await app.cleanupExamSession(examId, { expectedRegistration }), true);
+ const active = await windowStub.AppData.recovery.listActiveSessions();
+ assert.strictEqual(
+ active.some((item) => item && item.id === `active-session:${managerSessionId}`),
+ false,
+ 'cleanup must own the manager-aligned recovery id'
+ );
+ } finally {
+ await windowStub.AppData.recovery.discardActiveSession(`active-session:${managerSessionId}`);
+ windowStub.resolveActiveLibraryIndex = originalResolveActiveLibraryIndex;
+ if (hadManager) {
+ windowStub.practicePageManager = originalManager;
+ } else {
+ delete windowStub.practicePageManager;
+ }
+ }
+ }
+
+ // A manager result that arrives after replacement may clean its own ghost, never the new owner.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-manager-inflight-replacement';
+ const hostSessionId = 'manager-inflight-host';
+ const managerSessionId = 'manager-inflight-actual';
+ const replacementSessionId = 'manager-inflight-replacement';
+ const ownerInfo = {
+ window: createStubWindow('manager-inflight-owner-window'),
+ expectedSessionId: hostSessionId,
+ windowSessionToken: 'manager-inflight-owner-token',
+ windowSessionTokenSessionId: hostSessionId,
+ sessionGeneration: 1,
+ suiteSessionId: null
+ };
+ const replacementInfo = {
+ window: createStubWindow('manager-inflight-replacement-window'),
+ expectedSessionId: replacementSessionId,
+ windowSessionToken: 'manager-inflight-replacement-token',
+ windowSessionTokenSessionId: replacementSessionId,
+ sessionGeneration: 2,
+ suiteSessionId: null
+ };
+ const replacementHandler = () => {};
+ const originalResolveActiveLibraryIndex = windowStub.resolveActiveLibraryIndex;
+ const hadManager = Object.prototype.hasOwnProperty.call(windowStub, 'practicePageManager');
+ const originalManager = windowStub.practicePageManager;
+
+ app.examWindows = new Map([[examId, ownerInfo]]);
+ app.messageHandlers = new Map([[examId, () => {}]]);
+ windowStub.resolveActiveLibraryIndex = async () => [{
+ id: examId,
+ title: 'Manager In-flight Replacement',
+ type: 'reading'
+ }];
+ windowStub.practicePageManager = {
+ async startPracticeSession(handledExamId, examData) {
+ assert.strictEqual(handledExamId, examId);
+ assert.strictEqual(examData.sessionId, hostSessionId);
+ await windowStub.AppData.recovery.saveActiveSession({
+ id: `active-session:${managerSessionId}`,
+ examId,
+ sessionId: managerSessionId,
+ status: 'started'
+ });
+ app.examWindows.set(examId, replacementInfo);
+ app.messageHandlers.set(examId, replacementHandler);
+ return managerSessionId;
+ }
+ };
+
+ try {
+ await windowStub.AppData.recovery.saveActiveSession({
+ id: `active-session:${replacementSessionId}`,
+ examId,
+ sessionId: replacementSessionId,
+ status: 'started'
+ });
+ assert.strictEqual(await app.startPracticeSession(examId), null);
+ assert.strictEqual(app.examWindows.get(examId), replacementInfo);
+ assert.strictEqual(app.messageHandlers.get(examId), replacementHandler);
+ assert.strictEqual(replacementInfo.expectedSessionId, replacementSessionId);
+ assert.strictEqual(replacementInfo.windowSessionToken, 'manager-inflight-replacement-token');
+ const active = await windowStub.AppData.recovery.listActiveSessions();
+ assert.strictEqual(
+ active.some((item) => item && item.id === `active-session:${managerSessionId}`),
+ false,
+ 'the displaced manager recovery must be discarded by its actual id'
+ );
+ assert.strictEqual(
+ active.some((item) => item && item.id === `active-session:${replacementSessionId}`),
+ true,
+ 'the replacement recovery must survive targeted ghost cleanup'
+ );
+ } finally {
+ await windowStub.AppData.recovery.discardActiveSession(`active-session:${managerSessionId}`);
+ await windowStub.AppData.recovery.discardActiveSession(`active-session:${replacementSessionId}`);
+ windowStub.resolveActiveLibraryIndex = originalResolveActiveLibraryIndex;
+ if (hadManager) {
+ windowStub.practicePageManager = originalManager;
+ } else {
+ delete windowStub.practicePageManager;
+ }
+ }
+ }
+
+ // A registration replaced while findExamDefinition is pending must not be adopted by the old start.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-start-definition-owner-gate';
+ const ownerWindow = createStubWindow('definition-owner-window');
+ const replacementWindow = createStubWindow('definition-replacement-window');
+ const ownerInfo = {
+ window: ownerWindow,
+ expectedSessionId: 'definition-owner-session',
+ windowSessionToken: 'definition-owner-token',
+ windowSessionTokenSessionId: 'definition-owner-session',
+ sessionGeneration: 1,
+ suiteSessionId: null
+ };
+ const replacementInfo = {
+ window: replacementWindow,
+ expectedSessionId: 'definition-replacement-session',
+ windowSessionToken: 'definition-replacement-token',
+ windowSessionTokenSessionId: 'definition-replacement-session',
+ sessionGeneration: 2,
+ suiteSessionId: null
+ };
+ const replacementHandler = () => {};
+ const originalResolveActiveLibraryIndex = windowStub.resolveActiveLibraryIndex;
+ let markLookupEntered;
+ let releaseLookup;
+ const lookupEntered = new Promise((resolve) => { markLookupEntered = resolve; });
+ const lookupGate = new Promise((resolve) => { releaseLookup = resolve; });
+ windowStub.resolveActiveLibraryIndex = async () => {
+ markLookupEntered();
+ await lookupGate;
+ return [{ id: examId, title: 'Definition owner gate', type: 'reading' }];
+ };
+ app.examWindows = new Map([[examId, ownerInfo]]);
+ app.messageHandlers = new Map([[examId, () => {}]]);
+
+ try {
+ const starting = app.startPracticeSession(examId);
+ await lookupEntered;
+ app.examWindows.set(examId, replacementInfo);
+ app.messageHandlers.set(examId, replacementHandler);
+ releaseLookup();
+
+ assert.strictEqual(await starting, null);
+ assert.strictEqual(app.examWindows.get(examId), replacementInfo);
+ assert.strictEqual(app.messageHandlers.get(examId), replacementHandler);
+ assert.strictEqual(replacementInfo.expectedSessionId, 'definition-replacement-session');
+ assert.strictEqual(replacementInfo.windowSessionToken, 'definition-replacement-token');
+ } finally {
+ releaseLookup && releaseLookup();
+ windowStub.resolveActiveLibraryIndex = originalResolveActiveLibraryIndex;
+ }
+ }
+
+ // A stale openExam index lookup must stop before it navigates or overwrites a replacement.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-open-index-owner-gate';
+ const ownerInfo = {
+ window: createStubWindow('open-index-owner-window'),
+ expectedSessionId: 'open-index-owner-session',
+ windowSessionToken: 'open-index-owner-token',
+ windowSessionTokenSessionId: 'open-index-owner-session',
+ sessionGeneration: 1,
+ suiteSessionId: null
+ };
+ const replacementInfo = {
+ window: createStubWindow('open-index-replacement-window'),
+ expectedSessionId: 'open-index-replacement-session',
+ windowSessionToken: 'open-index-replacement-token',
+ windowSessionTokenSessionId: 'open-index-replacement-session',
+ sessionGeneration: 2,
+ suiteSessionId: null
+ };
+ const replacementHandler = () => {};
+ const originalResolveActiveLibraryIndex = windowStub.resolveActiveLibraryIndex;
+ const originalOpenExamWindow = app.openExamWindow;
+ const originalInject = app.injectDataCollectionScript;
+ let markIndexEntered;
+ let releaseIndex;
+ const indexEntered = new Promise((resolve) => { markIndexEntered = resolve; });
+ const indexGate = new Promise((resolve) => { releaseIndex = resolve; });
+ let openCount = 0;
+ let injectCount = 0;
+ windowStub.resolveActiveLibraryIndex = async () => {
+ markIndexEntered();
+ await indexGate;
+ return [{ id: examId, title: 'Open index owner gate', type: 'reading', hasHtml: true }];
+ };
+ app.openExamWindow = () => {
+ openCount += 1;
+ return ownerInfo.window;
+ };
+ app.injectDataCollectionScript = () => {
+ injectCount += 1;
+ };
+ app.examWindows = new Map([[examId, ownerInfo]]);
+ app.messageHandlers = new Map([[examId, () => {}]]);
+
+ try {
+ const opening = app.openExam(examId, { target: 'tab' });
+ await indexEntered;
+ app.examWindows.set(examId, replacementInfo);
+ app.messageHandlers.set(examId, replacementHandler);
+ releaseIndex();
+
+ assert.strictEqual(await opening, null);
+ assert.strictEqual(openCount, 0, 'a stale index lookup must stop before navigation');
+ assert.strictEqual(injectCount, 0);
+ assert.strictEqual(app.examWindows.get(examId), replacementInfo);
+ assert.strictEqual(app.messageHandlers.get(examId), replacementHandler);
+ } finally {
+ releaseIndex && releaseIndex();
+ windowStub.resolveActiveLibraryIndex = originalResolveActiveLibraryIndex;
+ app.openExamWindow = originalOpenExamWindow;
+ app.injectDataCollectionScript = originalInject;
+ }
+ }
+
+ // Removing the frozen predecessor during the index await is neutral; only a different tuple supersedes launch.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-open-index-predecessor-closed';
+ const oldWindow = createStubWindow('open-index-predecessor-window');
+ const newWindow = createStubWindow('_blank');
+ newWindow.addEventListener = () => {};
+ const oldInfo = {
+ window: oldWindow,
+ expectedSessionId: 'open-index-predecessor-session',
+ windowSessionToken: 'open-index-predecessor-token',
+ windowSessionTokenSessionId: 'open-index-predecessor-session',
+ sessionGeneration: 1,
+ suiteSessionId: null
+ };
+ const originalResolveActiveLibraryIndex = windowStub.resolveActiveLibraryIndex;
+ let markIndexEntered;
+ let releaseIndex;
+ const indexEntered = new Promise(resolve => { markIndexEntered = resolve; });
+ const indexGate = new Promise(resolve => { releaseIndex = resolve; });
+ windowStub.resolveActiveLibraryIndex = async () => {
+ markIndexEntered();
+ await indexGate;
+ return [{ id: examId, title: 'Closed predecessor launch', type: 'reading', hasHtml: true }];
+ };
+ app.examWindows = new Map([[examId, oldInfo]]);
+ app.messageHandlers = new Map([[examId, () => {}]]);
+ app.resolveReadingLaunchDescriptor = () => ({
+ mode: 'unified_html',
+ url: `http://localhost/${examId}.html`
+ });
+ app.openExamWindow = () => newWindow;
+ app._guardExamWindowContent = targetWindow => targetWindow;
+ app._captureLaunchLibraryConfigurationId = async () => null;
+ app.startPracticeSession = async (handledExamId, startOptions = {}) => buildOwnedStartResult(
+ app,
+ handledExamId,
+ true,
+ startOptions.launchOwnership
+ );
+ app.injectDataCollectionScript = () => {};
+ try {
+ const opening = app.openExam(examId, { target: 'tab', windowName: '_blank' });
+ await indexEntered;
+ const predecessorRegistration = app._captureExamSessionRegistration(examId, oldInfo);
+ assert.strictEqual(await app.cleanupExamSession(examId, {
+ expectedRegistration: predecessorRegistration,
+ recoverySessionId: predecessorRegistration.expectedSessionId
+ }), true);
+ releaseIndex();
+ assert.strictEqual(await opening, newWindow);
+ assert.strictEqual(app.examWindows.get(examId).window, newWindow);
+ assert.notStrictEqual(app.examWindows.get(examId), oldInfo);
+ } finally {
+ releaseIndex && releaseIndex();
+ windowStub.resolveActiveLibraryIndex = originalResolveActiveLibraryIndex;
+ const current = app.examWindows && app.examWindows.get(examId);
+ if (current && current.closeMonitor) clearInterval(current.closeMonitor);
+ if (app._handshakeTimers) {
+ for (const timer of app._handshakeTimers.values()) clearInterval(timer);
+ app._handshakeTimers.clear();
+ }
+ }
+ }
+
+ // An openExam manager await that loses its exact owner must not inject, INIT, checkpoint, or rebind.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-open-manager-owner-gate';
+ const exam = { id: examId, title: 'Open manager owner gate', type: 'reading', hasHtml: true };
+ const oldWindow = createStubWindow('open-manager-old-window');
+ const replacementWindow = createStubWindow('open-manager-replacement-window');
+ const oldLoadHandlers = [];
+ const replacementLoadHandlers = [];
+ oldWindow.addEventListener = (type, handler) => {
+ if (type === 'load' && typeof handler === 'function') oldLoadHandlers.push(handler);
+ };
+ replacementWindow.addEventListener = (type, handler) => {
+ if (type === 'load' && typeof handler === 'function') replacementLoadHandlers.push(handler);
+ };
+ const originalManager = windowStub.practicePageManager;
+ const hadManager = Object.prototype.hasOwnProperty.call(windowStub, 'practicePageManager');
+ const originalResolveReading = app.resolveReadingLaunchDescriptor;
+ const originalOpenExamWindow = app.openExamWindow;
+ const originalGuard = app._guardExamWindowContent;
+ const originalCaptureLibrary = app._captureLaunchLibraryConfigurationId;
+ const originalInject = app.injectDataCollectionScript;
+ let markManagerEntered;
+ let releaseManager;
+ const managerEntered = new Promise((resolve) => { markManagerEntered = resolve; });
+ const managerGate = new Promise((resolve) => { releaseManager = resolve; });
+ let injectCount = 0;
+ let checkpointCount = 0;
+ app.resolveReadingLaunchDescriptor = () => ({
+ mode: 'unified_html',
+ url: `http://localhost/${examId}.html`
+ });
+ app.openExamWindow = () => oldWindow;
+ app._guardExamWindowContent = (targetWindow) => targetWindow;
+ app._captureLaunchLibraryConfigurationId = async () => null;
+ app.injectDataCollectionScript = () => {
+ injectCount += 1;
+ };
+ windowStub.practicePageManager = {
+ async startPracticeSession(handledExamId, examData) {
+ assert.strictEqual(handledExamId, examId);
+ assert.strictEqual(typeof examData.sessionId, 'string');
+ assert(examData.sessionId.length > 0);
+ markManagerEntered();
+ await managerGate;
+ return examData.sessionId;
+ }
+ };
+
+ let replacementRegistration = null;
+ let replacementHandler = null;
+ try {
+ const opening = app.openExam(examId, {
+ examDefinition: exam,
+ target: 'tab',
+ windowName: 'open-manager-owner-gate-tab',
+ suiteSessionId: 'suite-open-manager-owner-gate',
+ beforeSuiteHandshake: async () => {
+ checkpointCount += 1;
+ return true;
+ }
+ });
+ await managerEntered;
+ const oldRegistration = app._captureExamSessionRegistration(examId);
+ assert(oldRegistration && oldRegistration.window === oldWindow);
+
+ replacementRegistration = app.setupExamWindowManagement(
+ replacementWindow,
+ examId,
+ exam,
+ {
+ expectedUrl: `http://localhost/${examId}-replacement.html`,
+ deferInitialHandshake: true
+ }
+ );
+ replacementHandler = app.messageHandlers.get(examId);
+ assert(replacementRegistration && replacementRegistration.window === replacementWindow);
+ releaseManager();
+
+ assert.strictEqual(await opening, null);
+ assert.strictEqual(injectCount, 0, 'a displaced launch must never inject into its old WindowProxy');
+ assert.strictEqual(checkpointCount, 0, 'a displaced launch must not mutate the suite binding');
+ assert.strictEqual(
+ oldWindow._messages.some(message => message && String(message.type || '').toUpperCase() === 'INIT_SESSION'),
+ false,
+ 'the displaced WindowProxy must not receive INIT'
+ );
+
+ await Promise.all(oldLoadHandlers.map(handler => Promise.resolve(handler())));
+ assert.strictEqual(app.examWindows.get(examId), replacementRegistration.windowInfo);
+ assert.strictEqual(app.messageHandlers.get(examId), replacementHandler);
+ assert.strictEqual(replacementRegistration.windowInfo.window, replacementWindow);
+ assert.strictEqual(
+ oldWindow._messages.some(message => message && String(message.type || '').toUpperCase() === 'INIT_SESSION'),
+ false,
+ 'stale load callbacks must remain owner-gated'
+ );
+ } finally {
+ releaseManager && releaseManager();
+ app.resolveReadingLaunchDescriptor = originalResolveReading;
+ app.openExamWindow = originalOpenExamWindow;
+ app._guardExamWindowContent = originalGuard;
+ app._captureLaunchLibraryConfigurationId = originalCaptureLibrary;
+ app.injectDataCollectionScript = originalInject;
+ if (hadManager) {
+ windowStub.practicePageManager = originalManager;
+ } else {
+ delete windowStub.practicePageManager;
+ }
+ const current = app.examWindows && app.examWindows.get(examId);
+ if (current && current.closeMonitor) clearInterval(current.closeMonitor);
+ if (app._handshakeTimers) {
+ for (const timer of app._handshakeTimers.values()) clearInterval(timer);
+ app._handshakeTimers.clear();
+ }
+ replacementLoadHandlers.length = 0;
+ }
+ }
+
+ // Failed deferred starts must tear down only their exact owned popup registration.
+ for (const failureMode of [
+ 'manager-false',
+ 'recovery-uncommitted',
+ 'checkpoint-false',
+ 'config-window-close',
+ 'manager-window-close'
+ ]) {
+ const app = createApp(windowStub);
+ const examId = `reading-open-start-failure-${failureMode}`;
+ const exam = { id: examId, title: failureMode, type: 'reading', hasHtml: true };
+ const examWindow = createStubWindow(`start-failure-${failureMode}`);
+ examWindow.addEventListener = () => {};
+ examWindow.close = function close() { this.closed = true; };
+ const hadManager = Object.prototype.hasOwnProperty.call(windowStub, 'practicePageManager');
+ const originalManager = windowStub.practicePageManager;
+ let injectCount = 0;
+ app.resolveReadingLaunchDescriptor = () => ({
+ mode: 'unified_html',
+ url: `http://localhost/${examId}.html`
+ });
+ app.openExamWindow = () => examWindow;
+ app._guardExamWindowContent = targetWindow => targetWindow;
+ app._captureLaunchLibraryConfigurationId = async () => {
+ if (failureMode === 'config-window-close') examWindow.close();
+ return null;
+ };
+ app.injectDataCollectionScript = () => { injectCount += 1; };
+ if (failureMode === 'manager-false' || failureMode === 'manager-window-close') {
+ windowStub.practicePageManager = {
+ async startPracticeSession() {
+ if (failureMode === 'manager-window-close') {
+ examWindow.close();
+ return true;
+ }
+ return false;
+ }
+ };
+ } else {
+ delete windowStub.practicePageManager;
+ }
+ if (failureMode === 'recovery-uncommitted') {
+ recoveryControl.saveQueue.push(false);
+ }
+ if (failureMode === 'checkpoint-false') {
+ app.startPracticeSession = async (handledExamId, startOptions = {}) => buildOwnedStartResult(
+ app,
+ handledExamId,
+ true,
+ startOptions.launchOwnership
+ );
+ }
+ try {
+ const opened = await app.openExam(examId, {
+ examDefinition: exam,
+ target: 'tab',
+ windowName: examWindow.name,
+ ...(failureMode === 'checkpoint-false' ? {
+ suiteSessionId: `suite-${examId}`,
+ beforeSuiteHandshake: async () => false
+ } : {})
+ });
+ assert.strictEqual(opened, null);
+ assert.strictEqual(Boolean(app.examWindows && app.examWindows.has(examId)), false);
+ assert.strictEqual(Boolean(app.messageHandlers && app.messageHandlers.has(examId)), false);
+ assert.strictEqual(examWindow.closed, true);
+ assert.strictEqual(injectCount, 0);
+ } finally {
+ if (hadManager) {
+ windowStub.practicePageManager = originalManager;
+ } else {
+ delete windowStub.practicePageManager;
+ }
+ const current = app.examWindows && app.examWindows.get(examId);
+ if (current && current.closeMonitor) clearInterval(current.closeMonitor);
+ if (app._handshakeTimers) {
+ for (const timer of app._handshakeTimers.values()) clearInterval(timer);
+ app._handshakeTimers.clear();
+ }
+ }
+ }
+
+ // A completed first navigation is owned by its exact provisional registration. A newer
+ // same-exam launch that fails before navigation must not strand the first launch mid-config.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-post-navigation-registration-owner';
+ const exam = { id: examId, title: 'Post-navigation owner', type: 'reading', hasHtml: true };
+ const examWindow = createStubWindow('_blank');
+ examWindow.addEventListener = () => {};
+ examWindow.close = function close() { this.closed = true; };
+ const hadOpen = Object.prototype.hasOwnProperty.call(windowStub, 'open');
+ const originalOpen = windowStub.open;
+ let markConfigurationEntered;
+ let releaseConfiguration;
+ const configurationEntered = new Promise(resolve => { markConfigurationEntered = resolve; });
+ const configurationGate = new Promise(resolve => { releaseConfiguration = resolve; });
+ let configurationCommitGuard = null;
+ let injectCount = 0;
+ let restartCount = 0;
+ windowStub.open = () => examWindow;
+ app.resolveReadingLaunchDescriptor = () => ({
+ mode: 'unified_html',
+ url: `http://localhost/${examId}.html`
+ });
+ app._guardExamWindowContent = targetWindow => targetWindow;
+ app._captureLaunchLibraryConfigurationId = async (_handledExamId, captureOptions = {}) => {
+ configurationCommitGuard = captureOptions.commitGuard;
+ assert.strictEqual(configurationCommitGuard(), true);
+ markConfigurationEntered();
+ await configurationGate;
+ assert.strictEqual(configurationCommitGuard(), true);
+ return null;
+ };
+ app.startPracticeSession = async (handledExamId, startOptions = {}) => {
+ assert.strictEqual(
+ app._isExamSessionRegistrationCurrent(handledExamId, startOptions.expectedRegistration),
+ true
+ );
+ return buildOwnedStartResult(app, handledExamId, true);
+ };
+ app.restartExamHandshake = () => { restartCount += 1; };
+ app.injectDataCollectionScript = () => { injectCount += 1; };
+ let provisionalRegistration = null;
+ try {
+ const opening = app.openExam(examId, {
+ examDefinition: exam,
+ target: 'tab',
+ windowName: '_blank'
+ });
+ await configurationEntered;
+ provisionalRegistration = app._captureExamSessionRegistration(examId);
+ assert(provisionalRegistration);
+ assert.strictEqual(provisionalRegistration.window, examWindow);
+ assert.strictEqual(provisionalRegistration.windowInfo.launchProvisional, true);
+ assert.strictEqual(
+ app._isOpenExamRegistrationCurrent(examId, provisionalRegistration, examWindow),
+ true
+ );
+
+ await assert.rejects(
+ app.openExam(examId, { requireRecordProvenance: true }),
+ /./
+ );
+ assert.strictEqual(configurationCommitGuard(), true);
+ assert.strictEqual(
+ app._isExamSessionRegistrationCurrent(examId, provisionalRegistration),
+ true,
+ 'a failed pre-navigation reservation must not supersede the navigated provisional tuple'
+ );
+ releaseConfiguration();
+
+ assert.strictEqual(await opening, examWindow);
+ const finalRegistration = app._captureExamSessionRegistration(examId);
+ assert(finalRegistration);
+ assert.notStrictEqual(finalRegistration.windowInfo, provisionalRegistration.windowInfo);
+ assert.strictEqual(finalRegistration.windowInfo.launchProvisional, undefined);
+ assert.strictEqual(finalRegistration.windowInfo.handshakeDeferred, false);
+ assert.strictEqual(app.messageHandlers.has(examId), true);
+ assert.strictEqual(restartCount, 1);
+ assert.strictEqual(injectCount, 1);
+ } finally {
+ releaseConfiguration && releaseConfiguration();
+ if (hadOpen) windowStub.open = originalOpen;
+ else delete windowStub.open;
+ const current = app.examWindows && app.examWindows.get(examId);
+ if (current && current.closeMonitor) clearInterval(current.closeMonitor);
+ if (app._handshakeTimers) {
+ for (const timer of app._handshakeTimers.values()) clearInterval(timer);
+ app._handshakeTimers.clear();
+ }
+ }
+ }
+
+ // A supplied launch token publishes the exact final registration it created. A same-suite,
+ // same-WindowProxy successor installed after openExam resolves must not be re-captured as the
+ // older launch's result by reading the global examWindows map.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-launch-registration-receipt';
+ const suiteSessionId = 'suite-launch-registration-receipt';
+ const exam = { id: examId, title: 'Launch receipt', type: 'reading', hasHtml: true };
+ const examWindow = createStubWindow('launch-registration-receipt-window');
+ examWindow.addEventListener = () => {};
+ app.resolveReadingLaunchDescriptor = () => ({
+ mode: 'unified_html',
+ url: `http://localhost/${examId}.html`
+ });
+ app._guardExamWindowContent = targetWindow => targetWindow;
+ app._captureLaunchLibraryConfigurationId = async () => null;
+ app.restartExamHandshake = () => {};
+ app.injectDataCollectionScript = () => {};
+ app.startPracticeSession = async (handledExamId, startOptions = {}) => {
+ const expectedRegistration = startOptions.expectedRegistration;
+ assert.strictEqual(
+ app._isExamSessionRegistrationCurrent(handledExamId, expectedRegistration),
+ true
+ );
+ const previousInfo = expectedRegistration.windowInfo;
+ const realignedInfo = {
+ ...previousInfo,
+ expectedSessionId: 'launch-receipt-manager-session',
+ sessionId: null,
+ sessionGeneration: Number(previousInfo.sessionGeneration || 0) + 1,
+ windowSessionToken: 'launch-receipt-manager-token',
+ windowSessionTokenSessionId: 'launch-receipt-manager-session'
+ };
+ app.examWindows.set(handledExamId, realignedInfo);
+ const registration = app._captureExamSessionRegistration(handledExamId, realignedInfo);
+ return Object.freeze({
+ owned: true,
+ status: 'owned-success',
+ examId: handledExamId,
+ source: 'manager',
+ sessionId: realignedInfo.expectedSessionId,
+ value: null,
+ registration
+ });
+ };
+
+ const launchOwnership = app._beginExamLaunchOwnership(examId, {
+ reuseWindow: examWindow,
+ windowName: examWindow.name
+ });
+ const opened = await app.openExam(examId, {
+ examDefinition: exam,
+ target: 'tab',
+ reuseWindow: examWindow,
+ windowName: examWindow.name,
+ launchOwnership,
+ suiteSessionId,
+ beforeSuiteHandshake: async (context = {}) => {
+ assert.strictEqual(context.commitGuard(), true);
+ return true;
+ }
+ });
+ assert.strictEqual(opened, examWindow);
+ const launchReceipt = app._captureExamLaunchRegistrationReceipt(
+ examId,
+ launchOwnership,
+ examWindow
+ );
+ assert(launchReceipt, 'successful supplied launch must publish an exact receipt');
+ assert.strictEqual(launchReceipt.expectedSessionId, 'launch-receipt-manager-session');
+ assert.strictEqual(launchReceipt.suiteSessionId, suiteSessionId);
+ assert.strictEqual(
+ app._isExamSessionRegistrationCurrent(examId, launchReceipt),
+ true
+ );
+
+ const newerRegistration = app.setupExamWindowManagement(
+ examWindow,
+ examId,
+ exam,
+ {
+ expectedRegistration: launchReceipt,
+ expectedUrl: `http://localhost/${examId}.html`,
+ suiteSessionId,
+ skipContentGuard: true,
+ deferInitialHandshake: true
+ }
+ );
+ assert(newerRegistration);
+ assert.strictEqual(newerRegistration.window, examWindow);
+ assert.strictEqual(newerRegistration.suiteSessionId, suiteSessionId);
+ assert.notStrictEqual(newerRegistration.windowInfo, launchReceipt.windowInfo);
+ assert.notStrictEqual(newerRegistration.expectedSessionId, launchReceipt.expectedSessionId);
+ assert.strictEqual(
+ app._captureExamLaunchRegistrationReceipt(examId, launchOwnership, examWindow),
+ null,
+ 'a newer same-suite tuple must invalidate, not replace, the older launch receipt'
+ );
+ assert.strictEqual(app.examWindows.get(examId), newerRegistration.windowInfo);
+
+ const current = app.examWindows.get(examId);
+ if (current && current.closeMonitor) clearInterval(current.closeMonitor);
+ if (app._handshakeTimers && app._handshakeTimers.has(examId)) {
+ clearInterval(app._handshakeTimers.get(examId));
+ app._handshakeTimers.delete(examId);
+ }
+ const currentHandler = app.messageHandlers && app.messageHandlers.get(examId);
+ if (currentHandler) {
+ windowStub.removeEventListener('message', currentHandler);
+ app.messageHandlers.delete(examId);
+ }
+ }
+
+ // Direct location assignment does not prove that options.windowName resolves to the reused
+ // WindowProxy. Only window.open(requestedName) may establish that named-context proof.
+ for (const launchKind of ['html', 'pdf']) {
+ const app = createApp(windowStub);
+ const examId = `reading-direct-reuse-name-proof-${launchKind}`;
+ const exam = { id: examId, title: `Direct reuse ${launchKind}`, type: 'reading' };
+ const actualName = `actual-direct-reuse-${launchKind}`;
+ const unrelatedName = `unrelated-direct-reuse-${launchKind}`;
+ const examWindow = createStubWindow(actualName);
+ const ownership = app._beginExamLaunchOwnership(examId, {
+ reuseWindow: examWindow,
+ windowName: unrelatedName
+ });
+ const launchOptions = {
+ examId,
+ reuseWindow: examWindow,
+ windowName: unrelatedName,
+ launchOwnership: ownership
+ };
+ const opened = launchKind === 'html'
+ ? app.openExamWindow(`http://localhost/${examId}.html`, exam, launchOptions)
+ : app._openPdfWindow(exam, `http://localhost/${examId}.pdf`, launchOptions);
+ assert.strictEqual(opened, examWindow);
+ assert.notStrictEqual(
+ app._resolveExamLaunchProvenWindow(`window-name:${unrelatedName}`),
+ examWindow,
+ `${launchKind} direct reuse must not treat options.windowName as resolved proof`
+ );
+ assert.strictEqual(
+ app._resolveExamLaunchProvenWindow(`window-name:${actualName}`),
+ examWindow,
+ `${launchKind} direct reuse may retain the safely read actual WindowProxy name`
+ );
+
+ const namedExamId = `reading-unrelated-named-continuation-${launchKind}`;
+ const namedContinuation = app._beginExamLaunchOwnership(namedExamId, {
+ windowName: unrelatedName
+ });
+ Object.defineProperty(examWindow, 'name', {
+ configurable: true,
+ get() { throw new Error('cross-origin name'); }
+ });
+ const opaqueOwnership = app._beginExamLaunchOwnership(
+ `reading-opaque-direct-reuse-${launchKind}`,
+ { reuseWindow: examWindow }
+ );
+ assert.strictEqual(
+ app._isExamLaunchOwnershipCurrent(namedExamId, namedContinuation),
+ true,
+ `${launchKind} opaque reuse must not steal an unrelated unproven named continuation`
+ );
+ assert.strictEqual(
+ app._examLaunchOwnershipTargetLeaseKeys.get(opaqueOwnership)
+ .has(`window-name:${unrelatedName}`),
+ false
+ );
+ }
+
+ // An abort may await durable recovery cleanup after removing its exact HTML tuple. A raw PDF
+ // navigation on the same WindowProxy has no managed map entry, but its committed navigation
+ // token must still prevent the older abort from closing the newly owned document.
+ {
+ const app = createApp(windowStub);
+ const htmlExamId = 'reading-html-abort-before-pdf-reuse';
+ const pdfExamId = 'reading-pdf-reuses-aborting-window';
+ const examWindow = createStubWindow('html-abort-pdf-reuse-window');
+ let closeCount = 0;
+ examWindow.close = function close() {
+ closeCount += 1;
+ this.closed = true;
+ };
+ const htmlOwnership = app._beginExamLaunchOwnership(htmlExamId, {
+ reuseWindow: examWindow
+ });
+ assert.strictEqual(app.openExamWindow(
+ `http://localhost/${htmlExamId}.html`,
+ { id: htmlExamId, title: 'HTML abort owner', type: 'reading' },
+ {
+ examId: htmlExamId,
+ reuseWindow: examWindow,
+ launchOwnership: htmlOwnership
+ }
+ ), examWindow);
+ const htmlRegistration = app._captureExamSessionRegistration(htmlExamId);
+ assert(htmlRegistration && htmlRegistration.navigationOwnership);
+
+ let markDiscardEntered;
+ let releaseDiscard;
+ const discardEntered = new Promise(resolve => { markDiscardEntered = resolve; });
+ const discardGate = new Promise(resolve => { releaseDiscard = resolve; });
+ app._discardActiveSessionsForExam = async () => {
+ markDiscardEntered();
+ await discardGate;
+ return 0;
+ };
+ const aborting = app._abortOwnedExamLaunch(
+ htmlExamId,
+ examWindow,
+ htmlOwnership,
+ htmlRegistration
+ );
+ await discardEntered;
+ assert.strictEqual(app.examWindows.has(htmlExamId), false);
+
+ const pdfOwnership = app._beginExamLaunchOwnership(pdfExamId, {
+ reuseWindow: examWindow
+ });
+ assert.strictEqual(app._openPdfWindow(
+ { id: pdfExamId, title: 'PDF replacement', type: 'reading' },
+ `http://localhost/${pdfExamId}.pdf`,
+ { reuseWindow: examWindow, launchOwnership: pdfOwnership }
+ ), examWindow);
+ assert.notStrictEqual(
+ app._examWindowCommittedNavigationOwners.get(examWindow),
+ htmlRegistration.navigationOwnership
+ );
+ assert.strictEqual(app.examWindows.has(pdfExamId), false, 'raw PDF must remain unmanaged');
+
+ releaseDiscard();
+ assert.strictEqual(await aborting, true);
+ assert.strictEqual(closeCount, 0, 'the stale HTML abort must not close the newer raw PDF');
+ assert.strictEqual(examWindow.closed, false);
+ assert.strictEqual(examWindow.location.href, `http://localhost/${pdfExamId}.pdf`);
+ }
+
+ // Popup fallback may register the host window, but startup abort must never close the app itself.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-host-window-start-abort';
+ const hadClose = Object.prototype.hasOwnProperty.call(windowStub, 'close');
+ const originalClose = windowStub.close;
+ let hostCloseCount = 0;
+ windowStub.close = () => { hostCloseCount += 1; };
+ const info = {
+ window: windowStub,
+ expectedSessionId: 'host-window-abort-session',
+ windowSessionToken: 'host-window-abort-token',
+ windowSessionTokenSessionId: 'host-window-abort-session',
+ sessionGeneration: 1,
+ suiteSessionId: null,
+ expectedOrigin: 'http://localhost'
+ };
+ app.examWindows = new Map([[examId, info]]);
+ app.messageHandlers = new Map([[examId, () => {}]]);
+ const launchOwnership = app._beginExamLaunchOwnership(examId, { reuseWindow: windowStub });
+ const registration = app._captureExamSessionRegistration(examId, info);
+ try {
+ assert.strictEqual(
+ await app._abortOwnedExamLaunch(examId, windowStub, launchOwnership, registration),
+ true
+ );
+ assert.strictEqual(hostCloseCount, 0);
+ assert.strictEqual(app.examWindows.has(examId), false);
+ } finally {
+ if (hadClose) windowStub.close = originalClose;
+ else delete windowStub.close;
+ }
+ }
+
+ // Launch ownership reserves implicit names, cannot be widened by a caller, and gates guard retries.
+ {
+ const app = createApp(windowStub);
+ const implicitExamId = 'reading-implicit-lease';
+ const implicitOwnership = app._beginExamLaunchOwnership(implicitExamId, {});
+ assert(implicitOwnership.targetLeaseKeys.includes(`window-name:exam_${implicitExamId}`));
+ assert(implicitOwnership.targetLeaseKeys.includes(`window-name:pdf_${implicitExamId}`));
+
+ let staleOpenCount = 0;
+ const originalOpenExamWindow = app.openExamWindow;
+ app.openExamWindow = () => {
+ staleOpenCount += 1;
+ return createStubWindow('unexpected-expanded-target');
+ };
+ assert.strictEqual(await app.openExam(implicitExamId, {
+ examDefinition: { id: implicitExamId, title: 'Implicit lease', type: 'reading', hasHtml: true },
+ launchOwnership: implicitOwnership,
+ windowName: 'caller-added-target'
+ }), null);
+ assert.strictEqual(staleOpenCount, 0, 'an adopted launch token must not gain a new named target');
+ app.openExamWindow = originalOpenExamWindow;
+
+ const sharedWindow = createStubWindow(`exam_${implicitExamId}`);
+ app._beginExamLaunchOwnership('reading-window-holder', { reuseWindow: sharedWindow });
+ const staleImplicit = app._beginExamLaunchOwnership(implicitExamId, {});
+ Object.defineProperty(sharedWindow, 'name', {
+ configurable: true,
+ get() { throw new Error('cross-origin name'); }
+ });
+ const opaqueWinner = app._beginExamLaunchOwnership('reading-window-winner', { reuseWindow: sharedWindow });
+ assert.strictEqual(
+ app._isExamLaunchOwnershipCurrent(implicitExamId, staleImplicit),
+ false,
+ 'claiming an opaque WindowProxy must transfer its previously proven named lease'
+ );
+ const inheritedTargetKey = `window-name:exam_${implicitExamId}`;
+ const interveningNamedLaunch = app._beginExamLaunchOwnership('reading-window-name-intervening', {
+ windowName: `exam_${implicitExamId}`
+ });
+ assert.strictEqual(
+ app._isExamLaunchOwnershipCurrent('reading-window-winner', opaqueWinner),
+ false,
+ 'an inherited effective target key must remain part of the opaque owner lease'
+ );
+ const secondOpaqueWinner = app._beginExamLaunchOwnership('reading-window-second-winner', {
+ reuseWindow: sharedWindow
+ });
+ assert.strictEqual(
+ app._isExamLaunchOwnershipCurrent('reading-window-name-intervening', interveningNamedLaunch),
+ false,
+ 'a later opaque reuse must transfer the proven target key across multiple generations'
+ );
+ assert(
+ app._examLaunchOwnershipTargetLeaseKeys.get(secondOpaqueWinner).has(inheritedTargetKey),
+ 'the multi-generation owner must retain the inherited effective target key'
+ );
+ const secondNamedWindow = createStubWindow(`exam_${implicitExamId}`);
+ const secondNamedOwner = app._beginExamLaunchOwnership('reading-window-second-context', {
+ windowName: `exam_${implicitExamId}`
+ });
+ assert.strictEqual(
+ app._claimExamLaunchWindowOwnership(
+ secondNamedOwner,
+ secondNamedWindow,
+ `exam_${implicitExamId}`
+ ),
+ true
+ );
+ const opaqueFirstContextReuse = app._beginExamLaunchOwnership('reading-window-first-context-reuse', {
+ reuseWindow: sharedWindow
+ });
+ assert.strictEqual(
+ app._isExamLaunchOwnershipCurrent('reading-window-second-context', secondNamedOwner),
+ true,
+ 'an opaque reuse of P1 must not steal a target name now proven to resolve to P2'
+ );
+ assert.strictEqual(
+ app._examLaunchOwnershipTargetLeaseKeys.get(opaqueFirstContextReuse).has(inheritedTargetKey),
+ false
+ );
+
+ const renamedWindow = createStubWindow('proof-name-foo');
+ app._beginExamLaunchOwnership('reading-proof-name-holder', { reuseWindow: renamedWindow });
+ assert.strictEqual(
+ app._resolveExamLaunchProvenWindow('window-name:proof-name-foo'),
+ renamedWindow
+ );
+ renamedWindow.name = 'proof-name-bar';
+ const pendingFooOwner = app._beginExamLaunchOwnership('reading-proof-name-pending-foo', {
+ windowName: 'proof-name-foo'
+ });
+ app._beginExamLaunchOwnership('reading-proof-name-bar-reuse', {
+ reuseWindow: renamedWindow
+ });
+ assert.strictEqual(
+ app._isExamLaunchOwnershipCurrent('reading-proof-name-pending-foo', pendingFooOwner),
+ true,
+ 'a readable rename must revoke the old proof instead of stealing the pending old name'
+ );
+ assert.strictEqual(app._resolveExamLaunchProvenWindow('window-name:proof-name-foo'), null);
+ assert.strictEqual(
+ app._resolveExamLaunchProvenWindow('window-name:proof-name-bar'),
+ renamedWindow
+ );
+
+ const closedProofWindow = createStubWindow('proof-name-closed');
+ app._beginExamLaunchOwnership('reading-proof-name-closed-holder', {
+ reuseWindow: closedProofWindow
+ });
+ const closedProofKey = 'window-name:proof-name-closed';
+ const storedClosedProof = app._examLaunchProvenWindowByTargetKey.get(closedProofKey);
+ if (typeof WeakRef === 'function') {
+ assert.notStrictEqual(storedClosedProof, closedProofWindow, 'proof map must not strongly retain WindowProxy');
+ assert.strictEqual(typeof storedClosedProof.deref, 'function');
+ }
+ closedProofWindow.closed = true;
+ assert.strictEqual(app._resolveExamLaunchProvenWindow(closedProofKey), null);
+ assert.strictEqual(app._examLaunchProvenWindowByTargetKey.has(closedProofKey), false);
+
+ const committedReservationApp = createApp(windowStub);
+ for (let index = 0; index < 4; index += 1) {
+ const committedExamId = `reading-committed-reservation-${index}`;
+ const committedWindow = createStubWindow(`committed-actual-name-${index}`);
+ const customWindowName = `committed-custom-name-${index}`;
+ const committedOwnership = committedReservationApp._beginExamLaunchOwnership(
+ committedExamId,
+ {
+ reuseWindow: committedWindow,
+ windowName: customWindowName
+ }
+ );
+ assert.strictEqual(
+ committedReservationApp._examLaunchOwnershipExplicitWindows.get(committedOwnership),
+ committedWindow
+ );
+ const launchOptions = {
+ examId: committedExamId,
+ reuseWindow: committedWindow,
+ windowName: customWindowName,
+ launchOwnership: committedOwnership
+ };
+ assert.strictEqual(
+ committedReservationApp.openExamWindow(
+ `http://localhost/${committedExamId}.html`,
+ { id: committedExamId, title: 'Committed reservation', type: 'reading' },
+ launchOptions
+ ),
+ committedWindow
+ );
+ assert(
+ launchOptions.navigationRegistration
+ && committedReservationApp._isExamSessionRegistrationCurrent(
+ committedExamId,
+ launchOptions.navigationRegistration
+ ),
+ 'navigation must install an exact registration before releasing its reservation'
+ );
+ assert.strictEqual(
+ committedReservationApp._commitExamLaunchOwnership(committedOwnership),
+ true
+ );
+ assert.strictEqual(committedReservationApp._examLaunchOwnerships.has(committedExamId), false);
+ assert.strictEqual(
+ committedReservationApp._examLaunchTargetOwnerships.size,
+ 0,
+ 'successful unique named launches must not accumulate target reservations'
+ );
+ assert.strictEqual(
+ committedReservationApp._examLaunchWindowOwnerships.get(committedWindow),
+ undefined
+ );
+ assert.strictEqual(
+ committedReservationApp._examLaunchOwnershipExplicitWindows.has(committedOwnership),
+ false,
+ 'commit must release the token side table strong WindowProxy reference'
+ );
+ assert.strictEqual(
+ committedReservationApp._claimExamLaunchWindowOwnership(
+ committedOwnership,
+ committedWindow,
+ customWindowName
+ ),
+ false,
+ 'a committed continuation must never reacquire its released reservation'
+ );
+ assert.strictEqual(
+ committedReservationApp._isExamLaunchOwnershipCurrent(
+ committedExamId,
+ committedOwnership,
+ null,
+ committedWindow
+ ),
+ false
+ );
+ if (index === 0) {
+ const provenName = `committed-actual-name-${index}`;
+ const pendingNamedOwnership = committedReservationApp._beginExamLaunchOwnership(
+ 'reading-committed-proof-pending',
+ { windowName: provenName }
+ );
+ Object.defineProperty(committedWindow, 'name', {
+ configurable: true,
+ get() { throw new Error('cross-origin name'); }
+ });
+ const opaqueReuseOwnership = committedReservationApp._beginExamLaunchOwnership(
+ 'reading-committed-proof-opaque-reuse',
+ { reuseWindow: committedWindow }
+ );
+ assert.strictEqual(
+ committedReservationApp._isExamLaunchOwnershipCurrent(
+ 'reading-committed-proof-pending',
+ pendingNamedOwnership
+ ),
+ false,
+ 'weak name proof must survive reservation release and protect a later opaque reuse'
+ );
+ assert.strictEqual(
+ committedReservationApp._rollbackExamLaunchOwnership(opaqueReuseOwnership),
+ true
+ );
+ assert.strictEqual(
+ committedReservationApp._rollbackExamLaunchOwnership(pendingNamedOwnership),
+ true
+ );
+ assert.strictEqual(committedReservationApp._examLaunchTargetOwnerships.size, 0);
+ }
+ committedWindow.closed = true;
+ assert.strictEqual(
+ committedReservationApp._resolveExamLaunchProvenWindow(
+ `window-name:committed-actual-name-${index}`
+ ),
+ null,
+ 'closed committed windows must not retain a proven-name resolution'
+ );
+ }
+
+ const rollbackApp = createApp(windowStub);
+ const rollbackWindow = createStubWindow('rollback-nested-target');
+ const rollbackExamId = 'reading-rollback-nested';
+ rollbackApp.setupExamWindowCommunication(
+ rollbackWindow,
+ rollbackExamId,
+ { id: rollbackExamId, title: 'Installed launch owner', type: 'reading' },
+ { expectedUrl: 'http://localhost/exam.html' }
+ );
+ const installedInfo = rollbackApp.examWindows.get(rollbackExamId);
+ const installedHandler = rollbackApp.messageHandlers.get(rollbackExamId);
+ const installedRegistration = rollbackApp._captureExamSessionRegistration(
+ rollbackExamId,
+ installedInfo
+ );
+ const predecessor = rollbackApp._beginExamLaunchOwnership(rollbackExamId, {
+ reuseWindow: rollbackWindow,
+ windowName: rollbackWindow.name
+ });
+ const staleReservation = rollbackApp._beginExamLaunchOwnership(rollbackExamId, {
+ reuseWindow: rollbackWindow,
+ windowName: rollbackWindow.name
+ });
+ const newestReservation = rollbackApp._beginExamLaunchOwnership(rollbackExamId, {
+ reuseWindow: rollbackWindow,
+ windowName: rollbackWindow.name
+ });
+ assert.strictEqual(rollbackApp._rollbackExamLaunchOwnership(staleReservation), false);
+ assert.strictEqual(rollbackApp._rollbackExamLaunchOwnership(newestReservation), true);
+ assert.strictEqual(
+ rollbackApp._isExamLaunchOwnershipCurrent(
+ rollbackExamId,
+ predecessor,
+ null,
+ rollbackWindow
+ ),
+ false,
+ 'a failed nested reservation must not resurrect an older open continuation'
+ );
+ assert.strictEqual(
+ rollbackApp._isExamSessionRegistrationCurrent(
+ rollbackExamId,
+ installedRegistration
+ ),
+ true,
+ 'pre-navigation reservations must not invalidate the installed page registration'
+ );
+ assert.strictEqual(
+ rollbackApp.messageHandlers.get(rollbackExamId),
+ installedHandler
+ );
+ const messagesBeforeInstalledRequest = rollbackWindow._messages.length;
+ await installedHandler({
+ source: rollbackWindow,
+ origin: 'http://localhost',
+ data: {
+ type: 'REQUEST_INIT',
+ source: 'practice_page',
+ data: { examId: rollbackExamId }
+ }
+ });
+ assert(
+ rollbackWindow._messages.length > messagesBeforeInstalledRequest,
+ 'a failed pre-navigation launch must not disable the installed page protocol'
+ );
+ const nextReservation = rollbackApp._beginExamLaunchOwnership(rollbackExamId, {
+ reuseWindow: rollbackWindow,
+ windowName: rollbackWindow.name
+ });
+ assert.strictEqual(
+ rollbackApp._isExamLaunchOwnershipCurrent(
+ rollbackExamId,
+ nextReservation,
+ null,
+ rollbackWindow
+ ),
+ true,
+ 'a later launch must replace stale reservations without traversing predecessor chains'
+ );
+ const rollbackInfo = rollbackApp.examWindows.get(rollbackExamId);
+ if (rollbackInfo && rollbackInfo.closeMonitor) clearInterval(rollbackInfo.closeMonitor);
+
+ for (const takeoverMode of ['pre-navigation-failure', 'committed-navigation']) {
+ const guardApp = createApp(windowStub);
+ const guardExamId = `reading-guard-retry-owner-${takeoverMode}`;
+ const guardWindow = createStubWindow(`guard-retry-window-${takeoverMode}`);
+ let replaceCount = 0;
+ guardWindow.location = {
+ href: 'about:blank',
+ replace(url) {
+ replaceCount += 1;
+ this.href = url;
+ }
+ };
+ const originalSetTimeout = sandbox.setTimeout;
+ const originalShouldUsePlaceholder = guardApp._shouldUsePlaceholderPage;
+ let scheduledRetry = null;
+ sandbox.setTimeout = (callback) => {
+ scheduledRetry = callback;
+ return 1;
+ };
+ guardApp._shouldUsePlaceholderPage = () => true;
+ try {
+ const guardOwnership = guardApp._beginExamLaunchOwnership(guardExamId, {
+ reuseWindow: guardWindow
+ });
+ const navigationOwnership = guardApp._recordExamWindowNavigation(
+ guardWindow,
+ guardExamId
+ );
+ const navigationRegistration = guardApp._installExamNavigationProvisionalRegistration(
+ guardExamId,
+ guardWindow,
+ { expectedUrl: 'about:blank' }
+ );
+ guardApp._guardExamWindowContent(
+ guardWindow,
+ { id: guardExamId, title: 'Guard retry' },
+ {
+ examId: guardExamId,
+ launchOwnership: guardOwnership,
+ navigationOwnership,
+ navigationRegistration,
+ guardRetryCount: 3
+ }
+ );
+ assert.strictEqual(typeof scheduledRetry, 'function');
+
+ const replacementOwnership = guardApp._beginExamLaunchOwnership(guardExamId, {
+ reuseWindow: guardWindow
+ });
+ if (takeoverMode === 'pre-navigation-failure') {
+ assert.strictEqual(guardApp._rollbackExamLaunchOwnership(replacementOwnership), true);
+ scheduledRetry();
+ assert.strictEqual(
+ replaceCount,
+ 1,
+ 'a failed pre-navigation reservation must not suppress the installed page retry'
+ );
+ assert.notStrictEqual(guardWindow.location.href, 'about:blank');
+ } else {
+ const replacementUrl = `http://localhost/${guardExamId}-replacement.html`;
+ assert.strictEqual(guardApp.openExamWindow(
+ replacementUrl,
+ { id: guardExamId, title: 'Committed replacement', type: 'reading' },
+ {
+ examId: guardExamId,
+ reuseWindow: guardWindow,
+ launchOwnership: replacementOwnership
+ }
+ ), guardWindow);
+ scheduledRetry();
+ assert.strictEqual(
+ replaceCount,
+ 0,
+ 'a real navigation must invalidate the older installed-page retry'
+ );
+ assert.strictEqual(guardWindow.location.href, replacementUrl);
+ }
+ } finally {
+ sandbox.setTimeout = originalSetTimeout;
+ guardApp._shouldUsePlaceholderPage = originalShouldUsePlaceholder;
+ }
+ }
+
+ for (const successorMode of ['same-navigation', 'new-navigation']) {
+ const guardApp = createApp(windowStub);
+ const guardExamId = `reading-guard-managed-successor-${successorMode}`;
+ const guardWindow = createStubWindow(`guard-managed-successor-${successorMode}`);
+ guardWindow.addEventListener = () => {};
+ let replaceCount = 0;
+ guardWindow.location = {
+ href: 'about:blank',
+ replace(url) {
+ replaceCount += 1;
+ this.href = url;
+ }
+ };
+ const originalSetTimeout = sandbox.setTimeout;
+ const originalShouldUsePlaceholder = guardApp._shouldUsePlaceholderPage;
+ let scheduledRetry = null;
+ sandbox.setTimeout = (callback) => {
+ scheduledRetry = callback;
+ return 1;
+ };
+ guardApp._shouldUsePlaceholderPage = () => true;
+ try {
+ const navigationOwnership = guardApp._recordExamWindowNavigation(
+ guardWindow,
+ guardExamId
+ );
+ const provisionalRegistration = guardApp._installExamNavigationProvisionalRegistration(
+ guardExamId,
+ guardWindow,
+ { expectedUrl: 'about:blank' }
+ );
+ guardApp._guardExamWindowContent(
+ guardWindow,
+ { id: guardExamId, title: 'Guard managed successor' },
+ {
+ examId: guardExamId,
+ navigationOwnership,
+ navigationRegistration: provisionalRegistration,
+ guardRetryCount: 3
+ }
+ );
+ assert.strictEqual(typeof scheduledRetry, 'function');
+ const guardRetry = scheduledRetry;
+
+ const successorNavigationOwnership = successorMode === 'new-navigation'
+ ? guardApp._recordExamWindowNavigation(guardWindow, guardExamId)
+ : navigationOwnership;
+ const managedRegistration = guardApp.setupExamWindowManagement(
+ guardWindow,
+ guardExamId,
+ { id: guardExamId, title: 'Managed successor', type: 'reading' },
+ {
+ expectedRegistration: provisionalRegistration,
+ expectedUrl: 'about:blank',
+ skipContentGuard: true,
+ deferInitialHandshake: true
+ }
+ );
+ assert(managedRegistration, 'setup must install the managed successor registration');
+ assert.notStrictEqual(managedRegistration.windowInfo, provisionalRegistration.windowInfo);
+ assert.strictEqual(
+ managedRegistration.navigationOwnership,
+ successorNavigationOwnership
+ );
+
+ guardRetry();
+ assert.strictEqual(
+ replaceCount,
+ successorMode === 'same-navigation' ? 1 : 0,
+ successorMode === 'same-navigation'
+ ? 'a managed successor on the same navigation must inherit the pending retry'
+ : 'a managed successor after a new navigation must reject the stale retry'
+ );
+ const managedInfo = guardApp.examWindows.get(guardExamId);
+ if (managedInfo && managedInfo.closeMonitor) clearInterval(managedInfo.closeMonitor);
+ if (guardApp._handshakeTimers && guardApp._handshakeTimers.has(guardExamId)) {
+ clearInterval(guardApp._handshakeTimers.get(guardExamId));
+ }
+ const managedHandler = guardApp.messageHandlers
+ && guardApp.messageHandlers.get(guardExamId);
+ if (managedHandler) {
+ windowStub.removeEventListener('message', managedHandler);
+ guardApp.messageHandlers.delete(guardExamId);
+ }
+ } finally {
+ sandbox.setTimeout = originalSetTimeout;
+ guardApp._shouldUsePlaceholderPage = originalShouldUsePlaceholder;
+ }
+ }
+ }
+
+ // Managed suiteSessionId:null is an ordinary owner even while the same exam is active in a suite.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-managed-ordinary-owner';
+ const examWindow = createStubWindow('managed-ordinary-window');
+ const windowInfo = {
+ window: examWindow,
+ expectedSessionId: 'managed-ordinary-session',
+ windowSessionToken: 'managed-ordinary-token',
+ windowSessionTokenSessionId: 'managed-ordinary-session',
+ sessionGeneration: 1,
+ suiteSessionId: null,
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false,
+ status: 'active'
+ };
+ app.examWindows = new Map([[examId, windowInfo]]);
+ app.currentSuiteSession = {
+ id: 'suite-managed-ordinary',
+ status: 'active',
+ activeExamId: examId,
+ currentIndex: 0,
+ sequence: [{ examId }],
+ results: []
+ };
+ app.suiteExamMap.set(examId, app.currentSuiteSession.id);
+ let resolverCalls = 0;
+ app._resolveSuiteSessionId = () => {
+ resolverCalls += 1;
+ return app.currentSuiteSession.id;
+ };
+ const initPayload = app._buildExamInitPayload(examId, windowInfo);
+ assert.strictEqual(initPayload.suiteSessionId, null);
+ assert.strictEqual(resolverCalls, 0, 'explicit null must bypass global suite inference');
+
+ const registration = app._captureExamSessionRegistration(examId, windowInfo);
+ let suiteReadyCalls = 0;
+ let suiteReviewNavigateCalls = 0;
+ let ordinaryReviewNavigateCalls = 0;
+ let simulationNavigateCalls = 0;
+ app._handleSuiteSessionReady = () => { suiteReadyCalls += 1; };
+ app.handleSuiteReviewNavigate = async () => {
+ suiteReviewNavigateCalls += 1;
+ return true;
+ };
+ app.handleReviewReplayNavigate = async () => {
+ ordinaryReviewNavigateCalls += 1;
+ return true;
+ };
+ app._handleSimulationNavigate = async () => { simulationNavigateCalls += 1; };
+ assert.strictEqual(app.handleSessionReady(examId, {
+ examId,
+ sessionId: 'forged-ready-session',
+ windowSessionToken: windowInfo.windowSessionToken,
+ initialized: true
+ }, { expectedRegistration: registration }), false);
+ assert.strictEqual(windowInfo.expectedSessionId, registration.expectedSessionId);
+ assert.strictEqual(app.handleSessionReady(examId, {
+ examId,
+ sessionId: registration.expectedSessionId,
+ suiteSessionId: app.currentSuiteSession.id,
+ windowSessionToken: windowInfo.windowSessionToken,
+ initialized: true
+ }, { expectedRegistration: registration }), false);
+ assert.strictEqual(windowInfo.suiteSessionId, null);
+ assert.strictEqual(suiteReadyCalls, 0);
+
+ examWindow.addEventListener = () => {};
+ app.setupExamWindowCommunication(examWindow, examId, null, {
+ expectedRegistration: registration
+ });
+ const dispatchForgedSuiteMessage = async (type, extra = {}) => {
+ windowStub.__dispatchEvent('message', {
+ source: examWindow,
+ origin: 'http://localhost',
+ data: {
+ type,
+ source: 'practice_page',
+ data: {
+ examId,
+ sessionId: registration.expectedSessionId,
+ suiteSessionId: app.currentSuiteSession.id,
+ windowSessionToken: registration.windowSessionToken,
+ ...extra
+ }
+ }
+ });
+ await Promise.resolve();
+ };
+ windowStub.__dispatchEvent('message', {
+ source: examWindow,
+ origin: 'http://localhost',
+ data: {
+ type: 'SESSION_READY',
+ source: 'practice_page',
+ data: {
+ examId,
+ sessionId: registration.expectedSessionId
+ }
+ }
+ });
+ await Promise.resolve();
+ assert.strictEqual(windowInfo.dataCollectorReady, undefined, 'tokenless ordinary READY must stay bootstrap-ineligible');
+ await dispatchForgedSuiteMessage('SESSION_READY', { initialized: true });
+ await dispatchForgedSuiteMessage('REVIEW_NAVIGATE', {
+ direction: 'next',
+ suiteReviewMode: true
+ });
+ await dispatchForgedSuiteMessage('SIMULATION_NAVIGATE', { direction: 'next' });
+ await dispatchForgedSuiteMessage('SIMULATION_ACTIVE_EXAM_CHANGE');
+ assert.strictEqual(windowInfo.suiteSessionId, null);
+ assert.strictEqual(suiteReadyCalls, 0);
+ assert.strictEqual(suiteReviewNavigateCalls, 0);
+ assert.strictEqual(ordinaryReviewNavigateCalls, 0);
+ assert.strictEqual(simulationNavigateCalls, 0);
+ assert.strictEqual(app.currentSuiteSession.activeExamId, examId);
+
+ let suiteCompletionCalls = 0;
+ let recorderPayload = null;
+ app.handleSuitePracticeComplete = async () => {
+ suiteCompletionCalls += 1;
+ return true;
+ };
app.components.practiceRecorder = {
- activeSessions: new Map(),
async handleSessionCompleted(payload) {
- completions.push(payload);
- this.activeSessions.delete(payload.examId);
- return { id: `record_${payload.sessionId}`, examId: payload.examId, sessionId: payload.sessionId };
- },
- handleSessionStarted(payload) {
- recorderStarts.push(payload);
- const session = this.activeSessions.get(payload.examId) || {
- examId: payload.examId,
- metadata: {},
- progress: {},
- answers: {}
+ recorderPayload = { ...payload };
+ return {
+ id: 'managed-ordinary-record',
+ examId,
+ sessionId: payload.sessionId,
+ endTime: payload.endTime
};
- session.sessionId = payload.sessionId;
- session.metadata = { ...session.metadata, ...payload.metadata };
- this.activeSessions.set(payload.examId, session);
}
};
- app.startPracticeSession = async (handledExamId) => {
- resetStarts.push(handledExamId);
- app.components.practiceRecorder.activeSessions.set(handledExamId, {
- examId: handledExamId,
- sessionId: 'temporary-reset-session',
- metadata: {},
- progress: {},
- answers: {}
- });
+ app._isPracticeCompletionPersisted = async () => true;
+ app._announceSubmittedReadingRecord = () => false;
+ app._announcePracticeSubmitOutcome = () => false;
+ app.clearReadingDraftForExam = async () => false;
+ app.showRealCompletionNotification = async () => true;
+ app.cleanupExamSession = async () => true;
+ app.updateExamStatus = () => {};
+ assert.strictEqual(await app.handlePracticeComplete(examId, {
+ sessionId: windowInfo.expectedSessionId,
+ submissionId: 'managed-ordinary-submit',
+ endTime: '2026-08-09T00:00:00.000Z'
+ }, examWindow, { expectedRegistration: registration }), true);
+ assert.strictEqual(suiteCompletionCalls, 0);
+ assert.strictEqual(Object.prototype.hasOwnProperty.call(recorderPayload, 'suiteSessionId'), true);
+ assert.strictEqual(recorderPayload.suiteSessionId, null);
+ assert.strictEqual(recorderPayload.practiceMode, 'single');
+ assert.strictEqual(windowInfo.suiteSessionId, null);
+ assert.deepStrictEqual(plain(app.currentSuiteSession.results), []);
+
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ assert.strictEqual(await app.handlePracticeComplete(examId, {
+ sessionId: windowInfo.expectedSessionId,
+ submissionId: `forged-suite-submit-${attempt}`,
+ suiteSessionId: app.currentSuiteSession.id,
+ suiteId: 'forged-entry',
+ endTime: '2026-08-09T00:00:00.000Z'
+ }, examWindow, { expectedRegistration: registration }), false);
+ }
+ assert.strictEqual(suiteCompletionCalls, 0);
+ assert.strictEqual(windowInfo.suiteSessionId, null);
+ assert.deepStrictEqual(plain(app.currentSuiteSession.results), []);
+ const installedHandler = app.messageHandlers && app.messageHandlers.get(examId);
+ if (installedHandler) windowStub.removeEventListener('message', installedHandler);
+ }
+
+ // A durable suite completion still acknowledges the exact E1 registration after E2 reserves the shared target.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-suite-ack-owner';
+ const suiteSessionId = 'suite-ack-owner';
+ const examWindow = createStubWindow('suite-ack-shared-target');
+ const windowInfo = {
+ window: examWindow,
+ expectedSessionId: 'suite-ack-session',
+ windowSessionToken: 'suite-ack-token',
+ windowSessionTokenSessionId: 'suite-ack-session',
+ sessionGeneration: 1,
+ suiteSessionId,
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false,
+ status: 'active'
};
- app.cleanupExamSession = async () => {
- cleanupCount += 1;
+ app.examWindows = new Map([[examId, windowInfo]]);
+ app.currentSuiteSession = {
+ id: suiteSessionId,
+ status: 'active',
+ activeExamId: examId,
+ currentIndex: 0,
+ sequence: [{ examId }],
+ results: []
};
- app.restartExamHandshake = (targetWindow, handledExamId) => {
- restartCount += 1;
- assert.strictEqual(targetWindow, examWindow, 'reset 应复用当前统一阅读窗口');
- assert.strictEqual(handledExamId, examId, 'reset 握手应使用当前题源');
+ const launchOwnership = app._beginExamLaunchOwnership(examId, {
+ reuseWindow: examWindow,
+ windowName: examWindow.name
+ });
+ const registration = app._captureExamSessionRegistration(examId, windowInfo);
+ app.handleSuitePracticeComplete = async () => {
+ app._beginExamLaunchOwnership('reading-suite-ack-next', {
+ windowName: examWindow.name
+ });
+ assert.strictEqual(app._isExamLaunchOwnershipCurrent(examId, launchOwnership), false);
+ return {
+ handled: true,
+ committed: true,
+ errorCode: 'suite_advance_superseded'
+ };
};
- app.updateExamStatus = (handledExamId, status) => {
- statuses.push({ examId: handledExamId, status });
+ assert.strictEqual(await app.handlePracticeComplete(examId, {
+ examId,
+ sessionId: registration.expectedSessionId,
+ suiteSessionId,
+ submissionId: 'suite-ack-submission',
+ endTime: '2026-08-09T00:00:00.000Z'
+ }, examWindow, {
+ expectedRegistration: registration,
+ launchOwnership
+ }), true);
+ assert(
+ examWindow._messages.some(message => message && message.type === 'PRACTICE_SUBMIT_ACK'),
+ 'durable suite outcome must ACK through the frozen exact registration after launch lease handoff'
+ );
+ assert.strictEqual(
+ examWindow._messages.some(message => message && message.type === 'PRACTICE_SUBMIT_FAILED'),
+ false
+ );
+ assert.strictEqual(app.examWindows.get(examId), windowInfo);
+ }
+
+ // A memorize reset must not mutate its old registration before the replacement open owns it.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-reset-launch-owner';
+ const examWindow = createStubWindow('reset-owner-window');
+ const windowInfo = {
+ window: examWindow,
+ expectedSessionId: 'reset-owner-session',
+ windowSessionToken: 'reset-owner-token',
+ windowSessionTokenSessionId: 'reset-owner-session',
+ sessionGeneration: 1,
+ suiteSessionId: null,
+ practiceMode: 'memorize',
+ reviewMode: true,
+ readOnly: true,
+ status: 'completed',
+ submittedRecordId: 'old-record'
};
- app.showRealCompletionNotification = () => {};
- app.setState = () => {};
+ app.examWindows = new Map([[examId, windowInfo]]);
+ const launchOwnership = app._beginExamLaunchOwnership(examId, { reuseWindow: examWindow });
+ const registration = app._captureExamSessionRegistration(examId, windowInfo);
+ let markOpenEntered;
+ let releaseOpen;
+ const openEntered = new Promise(resolve => { markOpenEntered = resolve; });
+ const openGate = new Promise(resolve => { releaseOpen = resolve; });
+ app.openExam = async () => {
+ markOpenEntered();
+ await openGate;
+ return null;
+ };
+ const resetting = app.handlePracticeResetRequest(examId, {
+ reason: 'memorize-start-test'
+ }, examWindow, { expectedRegistration: registration, launchOwnership });
+ await openEntered;
+ assert.strictEqual(windowInfo.practiceMode, 'memorize');
+ assert.strictEqual(windowInfo.reviewMode, true);
+ assert.strictEqual(windowInfo.readOnly, true);
+ assert.strictEqual(windowInfo.status, 'completed');
+ assert.strictEqual(windowInfo.submittedRecordId, 'old-record');
+ app._beginExamLaunchOwnership(examId, { reuseWindow: examWindow });
+ releaseOpen();
+ assert.strictEqual(await resetting, null);
+ assert.strictEqual(windowInfo.practiceMode, 'memorize');
+ assert.strictEqual(windowInfo.status, 'completed');
+ }
- app.setupExamWindowCommunication(examWindow, examId, {
- id: examId,
- title: 'Unified Retake Reading',
- type: 'reading'
+ // A completion that loses ownership may finish A's durable write, but cannot rebind or delete B.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-completion-owner-overlap';
+ const oldWindow = createStubWindow('completion-old-window');
+ const newWindow = createStubWindow('completion-new-window');
+ const oldInfo = {
+ window: oldWindow,
+ expectedSessionId: 'completion-old-session',
+ windowSessionToken: 'completion-old-token',
+ windowSessionTokenSessionId: 'completion-old-session',
+ sessionGeneration: 1,
+ suiteSessionId: null,
+ expectedOrigin: 'http://localhost'
+ };
+ const newInfo = {
+ window: newWindow,
+ expectedSessionId: 'completion-new-session',
+ windowSessionToken: 'completion-new-token',
+ windowSessionTokenSessionId: 'completion-new-session',
+ sessionGeneration: 2,
+ suiteSessionId: null,
+ expectedOrigin: 'http://localhost'
+ };
+ app.examWindows = new Map([[examId, oldInfo]]);
+ const oldHandler = () => {};
+ const newHandler = () => {};
+ app.messageHandlers = new Map([[examId, oldHandler]]);
+ const launchOwnership = app._beginExamLaunchOwnership(examId, { reuseWindow: oldWindow });
+ const oldRegistration = app._captureExamSessionRegistration(examId, oldInfo);
+ await windowStub.AppData.recovery.saveActiveSession({
+ id: 'active-session:completion-old-session', examId, sessionId: 'completion-old-session'
});
+ await windowStub.AppData.recovery.saveActiveSession({
+ id: 'active-session:completion-new-session', examId, sessionId: 'completion-new-session'
+ });
+ let markRecorderEntered;
+ let releaseRecorder;
+ const recorderEntered = new Promise(resolve => { markRecorderEntered = resolve; });
+ const recorderGate = new Promise(resolve => { releaseRecorder = resolve; });
+ app.components.practiceRecorder = {
+ async handleSessionCompleted(payload) {
+ markRecorderEntered();
+ await recorderGate;
+ return {
+ id: 'completion-old-record',
+ examId,
+ sessionId: payload.sessionId,
+ endTime: payload.endTime
+ };
+ }
+ };
+ app._isPracticeCompletionPersisted = async () => true;
+ let staleAnnouncementCount = 0;
+ app._announceSubmittedReadingRecord = () => { staleAnnouncementCount += 1; return true; };
+ app._announcePracticeSubmitOutcome = () => { staleAnnouncementCount += 1; return true; };
+ const completing = app.handlePracticeComplete(examId, {
+ sessionId: oldInfo.expectedSessionId,
+ submissionId: 'completion-old-submit',
+ endTime: '2026-08-09T00:00:00.000Z'
+ }, oldWindow, { expectedRegistration: oldRegistration, launchOwnership });
+ await recorderEntered;
+ app._beginExamLaunchOwnership(examId, { reuseWindow: newWindow });
+ app.examWindows.set(examId, newInfo);
+ app.messageHandlers.set(examId, newHandler);
+ releaseRecorder();
+ assert.strictEqual(await completing, true);
+ assert.strictEqual(staleAnnouncementCount, 0);
+ assert.strictEqual(app.examWindows.get(examId), newInfo);
+ assert.strictEqual(app.messageHandlers.get(examId), newHandler);
+ const remaining = (await windowStub.AppData.recovery.listActiveSessions())
+ .filter(item => item && item.examId === examId);
+ assert.strictEqual(remaining.some(item => item.sessionId === 'completion-old-session'), false);
+ assert.strictEqual(remaining.some(item => item.sessionId === 'completion-new-session'), true);
+ }
- const info = app.ensureExamWindowSession(examId, examWindow);
- info.expectedSessionId = firstSessionId;
- app.examWindows.set(examId, info);
+ // Stable implicit window names must drive reuse cleanup even without options.reuseWindow.
+ for (const reuseScope of ['same-exam', 'cross-exam']) {
+ const app = createApp(windowStub);
+ app.calculateWindowFeatures = () => '';
+ const oldExamId = `reading-implicit-reuse-old-${reuseScope}`;
+ const newExamId = reuseScope === 'same-exam'
+ ? oldExamId
+ : `reading-implicit-reuse-new-${reuseScope}`;
+ const sharedWindow = createStubWindow(`exam_${newExamId}`);
+ const registeredWindow = reuseScope === 'same-exam'
+ ? createStubWindow(`exam_${newExamId}`)
+ : sharedWindow;
+ const oldSessionId = `implicit-reuse-session-${reuseScope}`;
+ const oldInfo = {
+ window: registeredWindow,
+ expectedSessionId: oldSessionId,
+ windowSessionToken: `implicit-reuse-token-${reuseScope}`,
+ windowSessionTokenSessionId: oldSessionId,
+ sessionGeneration: 1,
+ suiteSessionId: null,
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false
+ };
+ app.examWindows = new Map([[oldExamId, oldInfo]]);
+ app.messageHandlers = new Map([[oldExamId, () => {}]]);
+ await windowStub.AppData.recovery.saveActiveSession({
+ id: `active-session:${oldSessionId}`,
+ examId: oldExamId,
+ sessionId: oldSessionId,
+ status: 'started'
+ });
+ const hadOpen = Object.prototype.hasOwnProperty.call(windowStub, 'open');
+ const originalOpen = windowStub.open;
+ windowStub.open = () => sharedWindow;
+ try {
+ const launchOptions = {
+ examId: newExamId,
+ launchOwnership: app._beginExamLaunchOwnership(newExamId, {})
+ };
+ assert.strictEqual(
+ app.openExamWindow(
+ `http://localhost/${newExamId}.html`,
+ { id: newExamId, title: newExamId, type: 'reading' },
+ launchOptions
+ ),
+ sharedWindow
+ );
+ assert.strictEqual(launchOptions.windowReuseDetected, true);
+ await app._cleanupReusedWindowSessions(sharedWindow, newExamId);
+ const active = await windowStub.AppData.recovery.listActiveSessions();
+ assert.strictEqual(active.some(item => item && item.sessionId === oldSessionId), false);
+ if (reuseScope === 'same-exam') {
+ assert.strictEqual(app.examWindows.has(oldExamId), true, 'same-exam provisional tuple must survive until setup');
+ assert.strictEqual(app.examWindows.get(oldExamId).window, sharedWindow);
+ assert.notStrictEqual(
+ app.examWindows.get(oldExamId),
+ oldInfo,
+ 'the first navigation of a replacement WindowProxy must invalidate the installed tuple synchronously'
+ );
+ assert.strictEqual(app.messageHandlers.has(oldExamId), false);
+ } else {
+ assert.strictEqual(app.examWindows.has(oldExamId), false, 'cross-exam provisional tuple must be removed');
+ }
+ } finally {
+ if (hadOpen) windowStub.open = originalOpen;
+ else delete windowStub.open;
+ await windowStub.AppData.recovery.discardActiveSession(`active-session:${oldSessionId}`);
+ }
+ }
- const handler = app.messageHandlers.get(examId);
- assert.strictEqual(typeof handler, 'function', '统一阅读题源应注册 message handler');
+ // Managed same-exam reuse retains the pending map tuple while deleting the frozen old recovery id.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-managed-reuse-recovery';
+ const examWindow = createStubWindow('managed-reuse-window');
+ const oldInfo = {
+ window: examWindow,
+ expectedSessionId: 'managed-reuse-old-session',
+ windowSessionToken: 'managed-reuse-old-token',
+ windowSessionTokenSessionId: 'managed-reuse-old-session',
+ sessionGeneration: 1,
+ suiteSessionId: null
+ };
+ app.examWindows = new Map([[examId, oldInfo]]);
+ app.messageHandlers = new Map([[examId, () => {}]]);
+ await windowStub.AppData.recovery.saveActiveSession({
+ id: 'active-session:managed-reuse-old-session', examId, sessionId: 'managed-reuse-old-session'
+ });
+ assert.strictEqual(app._markExamWindowReusePending(examWindow), 1);
+ const pendingInfo = app.examWindows.get(examId);
+ assert.notStrictEqual(pendingInfo, oldInfo);
+ assert.strictEqual(pendingInfo.reassignedFromExpectedSessionId, 'managed-reuse-old-session');
+ const pendingRegistration = app._captureExamSessionRegistration(examId, pendingInfo);
+ await app._cleanupReusedWindowSessions(examWindow, examId);
+ assert.strictEqual(app._isExamSessionRegistrationCurrent(examId, pendingRegistration), true);
+ const remaining = (await windowStub.AppData.recovery.listActiveSessions())
+ .filter(item => item && item.examId === examId);
+ assert.strictEqual(remaining.some(item => item.sessionId === 'managed-reuse-old-session'), false);
+ }
- await handler({
- source: examWindow,
+ // A replay resolver failure is pre-navigation: keep the installed source tuple and index usable.
+ {
+ const app = createApp(windowStub);
+ const examId = 'reading-review-resolver-source';
+ const nextExamId = 'reading-review-resolver-target';
+ const reviewSessionId = 'review-resolver-session';
+ const reviewWindow = createStubWindow('review-resolver-window');
+ app.setupExamWindowCommunication(
+ reviewWindow,
+ examId,
+ { id: examId, title: 'Review source', type: 'reading' },
+ { expectedUrl: 'http://localhost/exam.html' }
+ );
+ const sourceInfo = app.examWindows.get(examId);
+ sourceInfo.reviewMode = true;
+ sourceInfo.readOnly = true;
+ sourceInfo.reviewSessionId = reviewSessionId;
+ sourceInfo.reviewEntryIndex = 0;
+ app.examWindows.set(examId, sourceInfo);
+ const sourceRegistration = app._captureExamSessionRegistration(examId, sourceInfo);
+ const sourceHandler = app.messageHandlers.get(examId);
+ const reviewSession = {
+ sessionId: reviewSessionId,
+ recordId: 'review-resolver-record',
+ currentIndex: 0,
+ readOnly: true,
+ windowRef: reviewWindow,
+ entries: [
+ { examId, title: 'Review source' },
+ { examId: nextExamId, title: 'Review target' }
+ ]
+ };
+ app._ensureReviewReplayStore().set(reviewSessionId, reviewSession);
+ app._resolveReviewExamDefinition = async () => {
+ throw new Error('expected resolver failure');
+ };
+ assert.strictEqual(await app.handleReviewReplayNavigate(
+ examId,
+ { direction: 'next', reviewSessionId },
+ reviewWindow,
+ { expectedRegistration: sourceRegistration }
+ ), null);
+ assert.strictEqual(reviewSession.currentIndex, 0);
+ assert.strictEqual(app._isExamSessionRegistrationCurrent(examId, sourceRegistration), true);
+ assert.strictEqual(app.messageHandlers.get(examId), sourceHandler);
+ const messageCount = reviewWindow._messages.length;
+ await sourceHandler({
+ source: reviewWindow,
origin: 'http://localhost',
data: {
- type: 'PRACTICE_COMPLETE',
+ type: 'REQUEST_INIT',
source: 'practice_page',
- data: {
- examId,
- sessionId: firstSessionId,
- answers: { q1: 'A' },
- answerComparison: {
- q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true }
- },
- scoreInfo: { correct: 1, total: 1, totalQuestions: 1, accuracy: 1, percentage: 100 },
- metadata: {
- type: 'reading',
- examType: 'reading',
- practiceMode: 'single',
- renderMode: 'unified-reading'
- }
- }
+ data: { examId }
}
});
+ assert(reviewWindow._messages.length > messageCount);
+ if (sourceInfo.closeMonitor) clearInterval(sourceInfo.closeMonitor);
+ }
- assert.strictEqual(cleanupCount, 0, '统一阅读提交后不得清掉父页消息 handler');
- assert.strictEqual(app.messageHandlers.has(examId), true, '统一阅读完成后应保留 message handler 等待 reset');
- assert.strictEqual(app.examWindows.get(examId).status, 'completed', '统一阅读完成后应标记窗口完成态');
- assert.strictEqual(completions.length, 1, '统一阅读完成应正常进入 recorder');
+ // Suite review handoff must not treat a failed/absent exact source cleanup as release,
+ // and must continue checking the exact target tuple after asynchronous context delivery.
+ for (const cleanupMode of ['false', 'throw', 'missing']) {
+ const app = createApp(windowStub);
+ const session = makeSession(`suite-review-cleanup-${cleanupMode}`);
+ session.flowMode = 'stationary';
+ session.autoAdvanceAfterSubmit = false;
+ session.results = [{ examId: 'reading-p1', title: 'Passage 1' }];
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+ const sourceWindow = session.windowRef;
+ const sourceInfo = {
+ examId: 'reading-p1',
+ window: sourceWindow,
+ expectedSessionId: `review-cleanup-${cleanupMode}-source`,
+ windowSessionToken: `review-cleanup-${cleanupMode}-token`,
+ sessionGeneration: 1,
+ suiteSessionId: session.id
+ };
+ app.examWindows = new Map([['reading-p1', sourceInfo]]);
+ const sourceRegistration = app._captureExamSessionRegistration('reading-p1', sourceInfo);
+ app._commitSuiteRecovery = async (_targetSession, commitOptions = {}) => {
+ if (commitOptions.commitGuard && commitOptions.commitGuard() !== true) return false;
+ if (typeof commitOptions.onDurableReceipt === 'function') {
+ commitOptions.onDurableReceipt({ committed: true });
+ }
+ return true;
+ };
+ const cleanupCalls = [];
+ if (cleanupMode === 'false') {
+ app.cleanupExamSession = async (examId, cleanupOptions) => {
+ cleanupCalls.push({ examId, cleanupOptions });
+ return false;
+ };
+ } else if (cleanupMode === 'throw') {
+ app.cleanupExamSession = async (examId, cleanupOptions) => {
+ cleanupCalls.push({ examId, cleanupOptions });
+ throw new Error('expected exact cleanup failure');
+ };
+ } else {
+ app.cleanupExamSession = null;
+ }
+ let openCalls = 0;
+ app.openExam = async () => {
+ openCalls += 1;
+ return createStubWindow('unexpected-review-target');
+ };
+ assert.strictEqual(await app.handleSuiteReviewNavigate(
+ 'reading-p1',
+ { direction: 'next', suiteSessionId: session.id },
+ sourceWindow,
+ {
+ expectedRegistration: sourceRegistration,
+ commitGuard: () => app._isExamSessionRegistrationCurrent('reading-p1', sourceRegistration)
+ }
+ ), false);
+ assert.strictEqual(openCalls, 0, `${cleanupMode} cleanup must stop before target navigation`);
+ assert.strictEqual(cleanupCalls.length, cleanupMode === 'missing' ? 0 : 1);
+ if (cleanupCalls.length) {
+ assert.strictEqual(cleanupCalls[0].examId, 'reading-p1');
+ assert.strictEqual(cleanupCalls[0].cleanupOptions.expectedRegistration, sourceRegistration);
+ }
+ assert.strictEqual(
+ app._isExamSessionRegistrationCurrent('reading-p1', sourceRegistration),
+ true,
+ `${cleanupMode} cleanup must keep the frozen source registration authoritative`
+ );
+ }
- examWindow._messages.length = 0;
- await handler({
- source: examWindow,
- origin: 'http://localhost',
- data: {
- type: 'PRACTICE_RESET_REQUEST',
- source: 'practice_page',
- data: {
- examId,
- sessionId: firstSessionId,
- reason: 'retake-after-submit',
- fromPracticeMode: 'single',
- targetPracticeMode: 'single',
- normalUrl: 'file:///reading-practice-unified.html?examId=reading-retake-unified'
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite-review-target-tuple-guard');
+ session.flowMode = 'stationary';
+ session.autoAdvanceAfterSubmit = false;
+ session.results = [{ examId: 'reading-p1', title: 'Passage 1' }];
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id]));
+ const sourceWindow = session.windowRef;
+ const sourceInfo = {
+ examId: 'reading-p1',
+ window: sourceWindow,
+ expectedSessionId: 'review-target-guard-source',
+ windowSessionToken: 'review-target-guard-source-token',
+ sessionGeneration: 1,
+ suiteSessionId: session.id
+ };
+ app.examWindows = new Map([['reading-p1', sourceInfo]]);
+ const sourceRegistration = app._captureExamSessionRegistration('reading-p1', sourceInfo);
+ app._commitSuiteRecovery = async (_targetSession, commitOptions = {}) => {
+ if (commitOptions.commitGuard && commitOptions.commitGuard() !== true) return false;
+ if (typeof commitOptions.onDurableReceipt === 'function') {
+ commitOptions.onDurableReceipt({ committed: true });
+ }
+ return true;
+ };
+ app.cleanupExamSession = async (examId, cleanupOptions = {}) => {
+ assert.strictEqual(examId, 'reading-p1');
+ assert.strictEqual(cleanupOptions.expectedRegistration, sourceRegistration);
+ if (!app._isExamSessionRegistrationCurrent(examId, sourceRegistration)) return false;
+ app.examWindows.delete(examId);
+ return true;
+ };
+ const targetWindow = sourceWindow;
+ let targetOpenCalls = 0;
+ app.openExam = async (examId, openOptions = {}) => {
+ targetOpenCalls += 1;
+ return installManagedTestWindow(app, examId, targetWindow, openOptions);
+ };
+ app._waitForSuiteWindowExamReady = async () => true;
+ let targetSendCalls = 0;
+ let markTargetSendEntered;
+ let releaseTargetSend;
+ const targetSendEntered = new Promise((resolve) => { markTargetSendEntered = resolve; });
+ const targetSendGate = new Promise((resolve) => { releaseTargetSend = resolve; });
+ app._sendSuiteReviewState = async () => {
+ targetSendCalls += 1;
+ markTargetSendEntered();
+ await targetSendGate;
+ return true;
+ };
+ const targetNavigation = app.handleSuiteReviewNavigate(
+ 'reading-p1',
+ { direction: 'next', suiteSessionId: session.id },
+ sourceWindow,
+ {
+ expectedRegistration: sourceRegistration,
+ commitGuard: () => app._isExamSessionRegistrationCurrent('reading-p1', sourceRegistration)
+ }
+ );
+ await targetSendEntered;
+ const targetInfoBeforeReplacement = app.examWindows.get('reading-p2');
+ const replacementInfo = {
+ ...targetInfoBeforeReplacement,
+ expectedSessionId: 'review-target-reassigned-session',
+ windowSessionToken: 'review-target-reassigned-token',
+ sessionGeneration: Number(targetInfoBeforeReplacement.sessionGeneration || 0) + 1
+ };
+ app.examWindows.set('reading-p2', replacementInfo);
+ releaseTargetSend();
+ assert.strictEqual(
+ await targetNavigation,
+ false,
+ 'an async target tuple replacement must invalidate the review continuation'
+ );
+ assert.strictEqual(targetOpenCalls, 1, 'confirmed exact cleanup must proceed to target setup');
+ assert.strictEqual(targetSendCalls, 1, 'the target tuple must be checked again after async delivery');
+ assert.strictEqual(app.examWindows.get('reading-p2'), replacementInfo);
+ assert.strictEqual(session.windowRef, targetWindow);
+ }
+
+ // The suite continuation must consume openExam's exact receipt. Re-reading the
+ // global map would adopt either an ordinary tuple or a newer tuple with the same
+ // suite id on the same exam and WindowProxy.
+ for (const replacementMode of ['ordinary', 'same-suite']) {
+ const app = createApp(windowStub);
+ const suiteSessionId = `suite-post-open-${replacementMode}-replacement`;
+ app._generateSuiteSessionId = () => suiteSessionId;
+ const targetWindow = createStubWindow(`suite-post-open-${replacementMode}-window`);
+ let replacementInfo = null;
+ app.openExam = async (examId, options = {}) => {
+ installManagedTestWindow(app, examId, targetWindow, options);
+ const suiteInfo = app.examWindows.get(examId);
+ queueMicrotask(() => {
+ replacementInfo = {
+ ...suiteInfo,
+ suiteSessionId: replacementMode === 'ordinary' ? null : suiteSessionId,
+ expectedSessionId: `${replacementMode}-post-open-session`,
+ windowSessionToken: `${replacementMode}-post-open-token`,
+ windowSessionTokenSessionId: `${replacementMode}-post-open-session`,
+ sessionGeneration: Number(suiteInfo.sessionGeneration || 0) + 1
+ };
+ app.examWindows.set(examId, replacementInfo);
+ });
+ return targetWindow;
+ };
+
+ assert.strictEqual(
+ await app._launchSuiteSessionFromSequence(
+ makeSession(suiteSessionId).sequence,
+ { flowMode: 'simulation' }
+ ),
+ false,
+ `a suite launch must not adopt a queued ${replacementMode} replacement registration`
+ );
+ assert(app.currentSuiteSession && app.currentSuiteSession.id === suiteSessionId);
+ assert.strictEqual(app.currentSuiteSession.windowRef, null);
+ assert.strictEqual(app.examWindows.get('reading-p1'), replacementInfo);
+ assert.strictEqual(
+ String(replacementInfo.suiteSessionId || ''),
+ replacementMode === 'ordinary' ? '' : suiteSessionId
+ );
+ }
+
+ // Rebind reserves the target name before the proof await, while leaving the
+ // candidate's installed registration untouched. Every independently frozen
+ // identity and the reservation itself must survive before WindowProxy claim,
+ // durable mutation, or setup.
+ for (const replacementMode of [
+ 'ordinary-tuple',
+ 'committed-navigation',
+ 'window-binding',
+ 'launch-reservation',
+ 'commit-throw',
+ 'setup-throw'
+ ]) {
+ const app = createApp(windowStub);
+ const session = makeSession(`suite-rebind-proof-${replacementMode}`);
+ session.windowRef = null;
+ session.windowBinding = {
+ examId: 'reading-p1',
+ expectedSessionId: 'rebind-proof-old-session',
+ windowSessionToken: 'rebind-proof-old-token',
+ sessionGeneration: 3,
+ expectedUrl: 'http://localhost/exam.html?examId=reading-p1',
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false
+ };
+ const challengedBinding = session.windowBinding;
+ session.currentIndex = 1;
+ session.activeExamId = 'reading-p2';
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id]));
+ app.examWindows = app._createSuiteTestMap();
+
+ const candidate = createStubWindow('ielts-suite-mode-tab');
+ candidate.location.href = challengedBinding.expectedUrl;
+ candidate.addEventListener = () => {};
+ let challengedInfo = null;
+ if (replacementMode === 'ordinary-tuple') {
+ const stableNavigation = app._recordExamWindowNavigation(candidate, 'reading-p2');
+ challengedInfo = {
+ examId: 'reading-p2',
+ window: candidate,
+ navigationOwnership: stableNavigation,
+ suiteSessionId: session.id,
+ expectedSessionId: 'challenged-rebind-session',
+ windowSessionToken: 'challenged-rebind-token',
+ windowSessionTokenSessionId: 'challenged-rebind-session',
+ sessionGeneration: 1,
+ status: 'active'
+ };
+ app.examWindows.set('reading-p2', challengedInfo);
+ }
+ const originalPostMessage = candidate.postMessage.bind(candidate);
+ let ordinaryInfo = null;
+ let replacementNavigation = null;
+ let replacementBinding = null;
+ let replacementOwnership = null;
+ candidate.postMessage = (payload, targetOrigin) => {
+ originalPostMessage(payload, targetOrigin);
+ if (!payload || payload.type !== 'SUITE_REBIND_CHALLENGE') return;
+ queueMicrotask(() => {
+ if (replacementMode === 'ordinary-tuple') {
+ ordinaryInfo = {
+ ...challengedInfo,
+ suiteSessionId: null,
+ expectedSessionId: 'ordinary-rebind-session',
+ windowSessionToken: 'ordinary-rebind-token',
+ windowSessionTokenSessionId: 'ordinary-rebind-session',
+ sessionGeneration: challengedInfo.sessionGeneration + 1,
+ status: 'active'
+ };
+ app.examWindows.set('reading-p2', ordinaryInfo);
+ } else if (replacementMode === 'committed-navigation') {
+ replacementNavigation = app._recordExamWindowNavigation(candidate, 'reading-p2');
+ } else if (replacementMode === 'launch-reservation') {
+ replacementOwnership = app._beginExamLaunchOwnership('reading-p2', {
+ windowName: 'ielts-suite-mode-tab',
+ reuseWindow: candidate
+ });
+ } else if (replacementMode === 'window-binding') {
+ replacementBinding = {
+ ...challengedBinding,
+ windowSessionToken: 'newer-rebind-binding-token',
+ sessionGeneration: challengedBinding.sessionGeneration + 1
+ };
+ session.windowBinding = replacementBinding;
}
+ windowStub.__dispatchEvent('message', {
+ source: candidate,
+ origin: 'http://localhost',
+ data: {
+ type: 'SUITE_REBIND_PROOF',
+ source: 'practice_page',
+ data: {
+ challenge: payload.data.challenge,
+ suiteSessionId: session.id,
+ examId: 'reading-p2',
+ sessionId: challengedBinding.expectedSessionId,
+ windowSessionToken: challengedBinding.windowSessionToken,
+ windowSessionGeneration: challengedBinding.sessionGeneration
+ }
+ }
+ });
+ });
+ };
+ const originalOpen = windowStub.open;
+ windowStub.open = () => candidate;
+ let launchBeginCalls = 0;
+ let challengedOwnership = null;
+ const originalBegin = app._beginSuiteExamLaunchOwnership.bind(app);
+ app._beginSuiteExamLaunchOwnership = (...args) => {
+ launchBeginCalls += 1;
+ challengedOwnership = originalBegin(...args);
+ return challengedOwnership;
+ };
+ let launchRollbackCalls = 0;
+ const originalRollback = app._rollbackExamLaunchOwnership.bind(app);
+ app._rollbackExamLaunchOwnership = (ownership) => {
+ launchRollbackCalls += 1;
+ return originalRollback(ownership);
+ };
+ let launchClaimCalls = 0;
+ const originalClaim = app._claimSuiteExamLaunchWindow.bind(app);
+ app._claimSuiteExamLaunchWindow = (...args) => {
+ launchClaimCalls += 1;
+ return originalClaim(...args);
+ };
+ let setupCalls = 0;
+ const originalSetup = app.setupExamWindowManagement.bind(app);
+ app.setupExamWindowManagement = (...args) => {
+ setupCalls += 1;
+ if (replacementMode === 'setup-throw') {
+ throw new Error('expected rebind setup failure');
+ }
+ return originalSetup(...args);
+ };
+ if (replacementMode === 'commit-throw') {
+ app._commitSuiteRecovery = async () => {
+ throw new Error('expected rebind pre-receipt commit failure');
+ };
+ }
+ try {
+ if (replacementMode === 'commit-throw' || replacementMode === 'setup-throw') {
+ await assert.rejects(
+ () => app._tryRebindSuiteWindow(session, session.sequence[1]),
+ new RegExp(replacementMode === 'commit-throw'
+ ? 'expected rebind pre-receipt commit failure'
+ : 'expected rebind setup failure'),
+ `${replacementMode} must propagate its injected failure after releasing the reservation`
+ );
+ } else {
+ assert.strictEqual(
+ await app._tryRebindSuiteWindow(session, session.sequence[1]),
+ null,
+ `${replacementMode} replacement during proof await must invalidate rebind`
+ );
}
+ assert.strictEqual(launchBeginCalls, 1, 'rebind must reserve the target before awaiting proof');
+ assert.strictEqual(launchRollbackCalls, 1, 'stale proof must roll back only its exact reservation');
+ assert.strictEqual(
+ launchClaimCalls,
+ replacementMode === 'commit-throw' || replacementMode === 'setup-throw' ? 1 : 0,
+ 'only a current successful proof may claim the WindowProxy'
+ );
+ assert.strictEqual(
+ setupCalls,
+ replacementMode === 'setup-throw' ? 1 : 0,
+ 'managed setup must run only after proof and durable commit succeed'
+ );
+ assert(challengedOwnership, 'the challenged reservation must be observable');
+ assert.strictEqual(
+ app._isExamLaunchOwnershipCurrent('reading-p2', challengedOwnership),
+ false,
+ 'the stale challenged reservation must no longer own the target'
+ );
+ if (replacementMode === 'ordinary-tuple') {
+ assert.strictEqual(session.windowBinding, challengedBinding);
+ assert.strictEqual(app.examWindows.get('reading-p2'), ordinaryInfo);
+ assert.strictEqual(String(ordinaryInfo.suiteSessionId || ''), '');
+ } else if (replacementMode === 'committed-navigation') {
+ assert.strictEqual(session.windowBinding, challengedBinding);
+ assert.strictEqual(app._isExamWindowNavigationCurrent(candidate, replacementNavigation), true);
+ assert.strictEqual(app.examWindows.has('reading-p2'), false);
+ } else if (replacementMode === 'launch-reservation') {
+ assert.strictEqual(session.windowBinding, challengedBinding);
+ assert.strictEqual(app.examWindows.has('reading-p2'), false);
+ assert(replacementOwnership, 'the newer ordinary reservation must be created during proof');
+ assert.strictEqual(
+ app._isExamLaunchOwnershipCurrent('reading-p2', replacementOwnership, null, candidate),
+ true,
+ 'rolling back the stale rebind must preserve the newer ordinary reservation'
+ );
+ } else if (replacementMode === 'commit-throw') {
+ assert.deepStrictEqual(
+ plain(session.windowBinding),
+ plain(challengedBinding),
+ 'a pre-receipt commit exception must restore only its tentative binding'
+ );
+ assert.strictEqual(app.examWindows.has('reading-p2'), false);
+ } else if (replacementMode === 'setup-throw') {
+ assert.notDeepStrictEqual(
+ plain(session.windowBinding),
+ plain(challengedBinding),
+ 'a post-receipt setup exception must keep the durable-aligned binding'
+ );
+ assert.strictEqual(session.windowBinding.sessionGeneration, challengedBinding.sessionGeneration + 1);
+ } else {
+ assert.strictEqual(session.windowBinding, replacementBinding);
+ assert.strictEqual(app.examWindows.has('reading-p2'), false);
+ }
+ } finally {
+ windowStub.open = originalOpen;
+ }
+ }
+
+ // Resume must own the target before either recovery-ready or claim awaits.
+ // A newer ordinary reservation during either gap remains authoritative and
+ // the stale resume must not reach proof, WindowProxy claim, setup, or fallback open.
+ for (const gateMode of ['recovery-ready', 'ensure-claim']) {
+ const app = createApp(windowStub);
+ const session = makeSession(`suite-resume-preawait-${gateMode}`);
+ const candidate = createStubWindow('ielts-suite-mode-tab');
+ candidate.location.href = 'http://localhost/exam.html?examId=reading-p1';
+ session.windowRef = candidate;
+ session.windowBinding = {
+ examId: 'reading-p1',
+ expectedSessionId: `resume-${gateMode}-session`,
+ windowSessionToken: `resume-${gateMode}-token`,
+ sessionGeneration: 2,
+ expectedUrl: candidate.location.href,
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false
+ };
+ app._suiteModeReady = true;
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map((entry) => [entry.examId, session.id]));
+
+ let releaseGate;
+ let markGateEntered;
+ const gateEntered = new Promise((resolve) => { markGateEntered = resolve; });
+ const gate = new Promise((resolve) => { releaseGate = resolve; });
+ if (gateMode === 'recovery-ready') {
+ app._suiteRecoveryReady = gate;
+ } else {
+ app._suiteRecoveryReady = Promise.resolve();
+ app._ensureSuiteRecoveryClaim = async () => {
+ markGateEntered();
+ return gate;
+ };
+ }
+ app._commitSuiteRecovery = async (_owner, options = {}) => (
+ typeof options.commitGuard !== 'function' || options.commitGuard() !== false
+ );
+ let challengedOwnership = null;
+ const originalBegin = app._beginSuiteExamLaunchOwnership.bind(app);
+ app._beginSuiteExamLaunchOwnership = (...args) => {
+ challengedOwnership = originalBegin(...args);
+ return challengedOwnership;
+ };
+ let rebindCalls = 0;
+ const originalRebind = app._tryRebindSuiteWindow.bind(app);
+ app._tryRebindSuiteWindow = (...args) => {
+ rebindCalls += 1;
+ return originalRebind(...args);
+ };
+ let reacquireCalls = 0;
+ app._reacquireSuiteWindow = () => {
+ reacquireCalls += 1;
+ return candidate;
+ };
+ let setupCalls = 0;
+ app.setupExamWindowManagement = () => {
+ setupCalls += 1;
+ return null;
+ };
+ let openCalls = 0;
+ app.openExam = async () => {
+ openCalls += 1;
+ return candidate;
+ };
+
+ const resume = app.resumeSuitePractice(session.id);
+ if (gateMode === 'ensure-claim') await gateEntered;
+ else await Promise.resolve();
+ assert(challengedOwnership, `${gateMode} must begin the resume reservation synchronously`);
+ assert.strictEqual(
+ app._isExamLaunchOwnershipCurrent('reading-p1', challengedOwnership, null, candidate),
+ true
+ );
+ const ordinaryOwnership = app._beginExamLaunchOwnership('reading-p1', {
+ windowName: session.windowName,
+ reuseWindow: candidate
});
+ releaseGate(true);
+ assert.strictEqual(await resume, false, `${gateMode} stale resume must fail closed`);
+ assert.strictEqual(rebindCalls, gateMode === 'ensure-claim' ? 1 : 0);
+ assert.strictEqual(reacquireCalls, 0, 'stale resume must stop before named-window proof');
+ assert.strictEqual(setupCalls, 0, 'stale resume must stop before managed setup');
+ assert.strictEqual(openCalls, 0, 'stale resume must never create a later fallback launch');
+ assert.strictEqual(
+ app._isExamLaunchOwnershipCurrent('reading-p1', ordinaryOwnership, null, candidate),
+ true,
+ 'stale resume cleanup must preserve the newer ordinary owner'
+ );
+ assert.strictEqual(app._isExamLaunchOwnershipCurrent('reading-p1', challengedOwnership), false);
+ app._rollbackExamLaunchOwnership(ordinaryOwnership);
+ }
- const resetInfo = app.examWindows.get(examId);
- assert.strictEqual(resetInfo.expectedSessionId, resetSessionId, 'reset 必须生成新的 expectedSessionId');
- assert.strictEqual(resetInfo.status, 'active', 'reset 后窗口应回到 active');
- assert.deepStrictEqual(resetStarts, [examId], 'reset 后必须补建练习会话');
- assert.strictEqual(recorderStarts.length, 1, 'reset 后必须同步 recorder sessionId');
- assert.strictEqual(recorderStarts[0].sessionId, resetSessionId, 'recorder sessionId 必须使用 reset 后的新 session');
+ // Concurrent resume clicks before recovery-ready must coalesce before either
+ // call can create a launch reservation. Otherwise the second token supersedes
+ // the first and the stale first continuation can publish a failed promise that
+ // also causes the second call to roll back its only valid owner.
+ {
+ const app = createApp(windowStub);
+ const sessionId = 'suite-resume-entry-coalesced';
+ const storedSession = makeSession(sessionId);
+ const session = makeSession(sessionId);
+ storedSession.windowRef = null;
+ storedSession.windowBinding = null;
+ session.windowRef = null;
+ session.windowBinding = null;
+ app._suiteModeReady = true;
+ app.currentSuiteSession = null;
+ app._restoreSessionFromStorage = () => storedSession;
+ let releaseRecovery;
+ app._suiteRecoveryReady = new Promise((resolve) => { releaseRecovery = resolve; });
+ app._commitSuiteRecovery = async (_owner, options = {}) => (
+ typeof options.commitGuard !== 'function' || options.commitGuard() !== false
+ );
+ let beginCalls = 0;
+ const originalBegin = app._beginSuiteExamLaunchOwnership.bind(app);
+ app._beginSuiteExamLaunchOwnership = (...args) => {
+ beginCalls += 1;
+ return originalBegin(...args);
+ };
+ const targetWindow = createStubWindow('suite-resume-entry-target');
+ let openCalls = 0;
+ app.openExam = async (examId, options = {}) => {
+ openCalls += 1;
+ return installManagedTestWindow(app, examId, targetWindow, options);
+ };
+
+ const firstResume = app.resumeSuitePractice(session.id);
+ const secondResume = app.resumeSuitePractice(session.id);
+ assert.strictEqual(beginCalls, 1, 'the second entry must join before creating another reservation');
+ assert.strictEqual(app._suiteResumeEntryPromises.has(session.id), true);
+ // Recovery promotes a different authoritative object with the same durable
+ // identity; the app-level gate and frozen launch token must survive the swap.
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map((entry) => [entry.examId, session.id]));
+ releaseRecovery();
+ assert.deepStrictEqual(await Promise.all([firstResume, secondResume]), [true, true]);
+ assert.strictEqual(beginCalls, 1);
+ assert.strictEqual(openCalls, 1, 'coalesced resume callers must share one navigation');
+ assert.strictEqual(session.windowRef, targetWindow);
+ assert.strictEqual(app._suiteResumeEntryPromises.has(session.id), false);
+ }
+
+ // A preflight begin can synchronously lose its reservation before resume
+ // records the token. That rejected token must be rolled back immediately;
+ // the outer finally cannot clean up a token that was never installed in
+ // resumeLaunch, and it must not disturb the newer owner that superseded it.
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite-resume-preflight-rejected-token');
+ const candidate = createStubWindow('ielts-suite-mode-tab');
+ session.windowRef = candidate;
+ app._suiteModeReady = true;
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map((entry) => [entry.examId, session.id]));
+ let releaseRecovery;
+ app._suiteRecoveryReady = new Promise((resolve) => { releaseRecovery = resolve; });
+ let rejectedOwnership = null;
+ let newerOwnership = null;
+ const originalBegin = app._beginSuiteExamLaunchOwnership.bind(app);
+ app._beginSuiteExamLaunchOwnership = (...args) => {
+ rejectedOwnership = originalBegin(...args);
+ newerOwnership = app._beginExamLaunchOwnership('reading-p1', {
+ windowName: session.windowName,
+ reuseWindow: candidate
+ });
+ return rejectedOwnership;
+ };
+
+ const resume = app.resumeSuitePractice(session.id);
+ assert(rejectedOwnership, 'resume preflight must create the rejected reservation');
+ assert(newerOwnership, 'fixture must synchronously supersede the preflight reservation');
+ assert.strictEqual(app._examLaunchOwnershipRollbackStates.has(rejectedOwnership), false);
+ assert.strictEqual(app._examLaunchOwnershipExplicitWindows.has(rejectedOwnership), false);
assert.strictEqual(
- app.components.practiceRecorder.activeSessions.get(examId).sessionId,
- resetSessionId,
- 'reset 后 active recorder session 必须可被下一次提交使用'
+ app._isExamLaunchOwnershipCurrent('reading-p1', newerOwnership, null, candidate),
+ true,
+ 'rejected-token cleanup must preserve the synchronous replacement owner'
);
- assert(
- examWindow._messages.some(message => message && message.type === 'INIT_SESSION' && message.data && message.data.sessionId === resetSessionId),
- 'reset 后必须向子页发送新的 INIT_SESSION'
+
+ session.status = 'completed';
+ releaseRecovery();
+ assert.strictEqual(await resume, false);
+ assert.strictEqual(app._suiteResumeEntryPromises.has(session.id), false);
+ assert.strictEqual(
+ app._isExamLaunchOwnershipCurrent('reading-p1', newerOwnership, null, candidate),
+ true
);
- assert.strictEqual(restartCount, 1, 'reset 后必须重启握手');
- assert(statuses.some(item => item.examId === examId && item.status === 'in-progress'), 'reset 后题源状态应回到 in-progress');
+ app._rollbackExamLaunchOwnership(newerOwnership);
+ }
+
+ // When recovery has not exposed the target yet, freeze the entry ownership
+ // epoch. A launch begun while recovery-ready is pending must prevent a later
+ // resume from manufacturing a higher-sequence reservation after the await.
+ {
+ const app = createApp(windowStub);
+ const session = makeSession('suite-resume-unknown-target-epoch');
+ app._suiteModeReady = true;
+ app.currentSuiteSession = null;
+ app._restoreSessionFromStorage = () => null;
+ let releaseRecovery;
+ app._suiteRecoveryReady = new Promise((resolve) => { releaseRecovery = resolve; });
+ let suiteBeginCalls = 0;
+ const originalBegin = app._beginSuiteExamLaunchOwnership.bind(app);
+ app._beginSuiteExamLaunchOwnership = (...args) => {
+ suiteBeginCalls += 1;
+ return originalBegin(...args);
+ };
+ let openCalls = 0;
+ app.openExam = async () => {
+ openCalls += 1;
+ return createStubWindow('must-not-open-after-epoch-change');
+ };
+ const resume = app.resumeSuitePractice(session.id);
+ await Promise.resolve();
+ assert.strictEqual(suiteBeginCalls, 0, 'unknown target must not reserve before it can be identified');
+ const ordinaryOwnership = app._beginExamLaunchOwnership('reading-p1', {
+ windowName: session.windowName
+ });
+ app.currentSuiteSession = session;
+ app.suiteExamMap = new Map(session.sequence.map((entry) => [entry.examId, session.id]));
+ releaseRecovery();
+ assert.strictEqual(await resume, false);
+ assert.strictEqual(suiteBeginCalls, 0, 'changed entry epoch must block any post-await reservation');
+ assert.strictEqual(openCalls, 0);
+ assert.strictEqual(
+ app._isExamLaunchOwnershipCurrent('reading-p1', ordinaryOwnership),
+ true
+ );
+ app._rollbackExamLaunchOwnership(ordinaryOwnership);
}
process.stdout.write(JSON.stringify({ status: 'pass', detail: 'simulation mode regression cases passed' }));
diff --git a/developer/tests/js/suitePracticeStorageFallback.test.js b/developer/tests/js/suitePracticeStorageFallback.test.js
index 69c607df..6c4b2c94 100644
--- a/developer/tests/js/suitePracticeStorageFallback.test.js
+++ b/developer/tests/js/suitePracticeStorageFallback.test.js
@@ -20,44 +20,30 @@ function deepClone(value) {
}
async function main() {
- const state = new Map();
- state.set('practice_records', [{ id: 'legacy_1', examId: 'legacy-a' }]);
- const storage = {
- async get(key, fallback = undefined) {
- if (state.has(key)) {
- return deepClone(state.get(key));
- }
- return deepClone(fallback);
- },
- async set(key, value) {
- state.set(key, deepClone(value));
- }
- };
-
+ const practiceListCalls = [];
const sandboxWindow = {
location: { href: 'http://localhost/' },
showMessage() {},
addEventListener() {},
removeEventListener() {},
document: { addEventListener() {}, removeEventListener() {} },
- // 统一入口:PracticeRecordAPI 是套题练习记录的唯一读取通道
- PracticeRecordAPI: {
- async list() {
+ AppData: {
+ ready: Promise.resolve(),
+ practice: {
+ async list(options) {
+ practiceListCalls.push(deepClone(options));
return [{ id: 'api_1', examId: 'api-a' }];
},
- async saveRecord() {
- throw new Error('saveRecord should not be called in this read test');
- },
- async recalculateStats() {
+ async getStats() {
return { totalPractices: 1 };
}
+ }
}
};
const sandbox = {
window: sandboxWindow,
document: sandboxWindow.document,
- storage,
console,
setTimeout,
clearTimeout,
@@ -86,22 +72,26 @@ async function main() {
Object.assign(app, mixins.examSession, mixins.suitePractice);
- // 统一入口验证:_loadSuitePracticeRecordsForFiltering 应通过 PracticeRecordAPI.list 读取
+ // 统一入口验证:过滤和聚合只读 AppData.practice
const fromFiltering = await app._loadSuitePracticeRecordsForFiltering();
assert.ok(Array.isArray(fromFiltering) && fromFiltering.length > 0, '过滤读取应返回记录');
- assert.strictEqual(fromFiltering[0].id, 'api_1', '过滤读取应通过 PracticeRecordAPI.list 获取');
+ assert.strictEqual(fromFiltering[0].id, 'api_1', '过滤读取应通过 AppData.practice.list 获取');
- // _listPracticeRecordsViaAPI 也应直接走 PracticeRecordAPI.list
+ // 兼容命名的方法内部也必须直达领域 API
const viaAPI = await app._listPracticeRecordsViaAPI();
assert.ok(Array.isArray(viaAPI) && viaAPI.length > 0, 'API 读取应返回记录');
- assert.strictEqual(viaAPI[0].id, 'api_1', 'API 读取应通过 PracticeRecordAPI.list 获取');
+ assert.strictEqual(viaAPI[0].id, 'api_1', 'API 读取应通过 AppData.practice.list 获取');
+ assert.deepStrictEqual(practiceListCalls, [
+ { projection: 'detail' },
+ { projection: 'detail' }
+ ], '套题去重只应读取详情层,不得加载高亮和笔记层');
+ assert.strictEqual(await app._recalculatePracticeStatsFromRecords(), true);
- // 无 PracticeRecordAPI 时应返回空数组,不崩溃
- delete sandboxWindow.PracticeRecordAPI;
- const emptyResult = await app._listPracticeRecordsViaAPI();
- assert.ok(Array.isArray(emptyResult) && emptyResult.length === 0, '无 API 时应安全返回空数组');
+ // 缺少事实层必须明确失败,不能伪装成空记录
+ delete sandboxWindow.AppData;
+ await assert.rejects(() => app._listPracticeRecordsViaAPI(), /AppData|practice/);
- process.stdout.write(JSON.stringify({ status: 'pass', detail: 'suitePractice 统一通过 PracticeRecordAPI 读取记录' }));
+ process.stdout.write(JSON.stringify({ status: 'pass', detail: 'suitePractice only reads AppData.practice and does not fake empty data when unavailable' }));
}
main().catch((error) => {
diff --git a/developer/tests/js/suitePreference.test.js b/developer/tests/js/suitePreference.test.js
new file mode 100644
index 00000000..b5cc7f5b
--- /dev/null
+++ b/developer/tests/js/suitePreference.test.js
@@ -0,0 +1,201 @@
+#!/usr/bin/env node
+import assert from 'assert';
+import fs from 'fs';
+import path from 'path';
+import vm from 'vm';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const repoRoot = path.resolve(__dirname, '..', '..', '..');
+const source = fs.readFileSync(path.join(repoRoot, 'js/utils/suitePreference.js'), 'utf8');
+
+// Mirrors practiceTimerPreferences.test.js: each call spins up a fresh vm
+// context so the IIFE-captured hydrationPromise cache is reset between
+// cases. tenanceAppData.preferences.getSuite is stubbed per scenario so the
+// contract "reload must surface stored suite preference on first read" can
+// be asserted without racing eager hydration.
+function loadSuitePreference(stored) {
+ const persisted = JSON.parse(JSON.stringify(stored == null ? {} : stored));
+ const patchCalls = [];
+ const window = {
+ AppData: {
+ ready: Promise.resolve(true),
+ preferences: {
+ async getSuite() {
+ return JSON.parse(JSON.stringify(persisted));
+ },
+ async patchSuite(patch) {
+ patchCalls.push(JSON.parse(JSON.stringify(patch)));
+ }
+ }
+ }
+ };
+ const context = {
+ window,
+ globalThis: window,
+ Object,
+ Number,
+ Math,
+ JSON,
+ String,
+ Boolean,
+ Promise,
+ console: { log() {}, warn() {}, error() {} }
+ };
+ vm.runInNewContext(source, context, { filename: 'suitePreference.js' });
+ return {
+ utils: window.SuitePreferenceUtils,
+ patchCalls,
+ persisted
+ };
+}
+
+function loadSuitePreferenceBeforeAppData(stored) {
+ const persisted = JSON.parse(JSON.stringify(stored == null ? {} : stored));
+ const patchCalls = [];
+ const window = {};
+ const context = {
+ window,
+ globalThis: window,
+ Object,
+ Number,
+ Math,
+ JSON,
+ String,
+ Boolean,
+ Promise,
+ console: { log() {}, warn() {}, error() {} }
+ };
+ vm.runInNewContext(source, context, { filename: 'suitePreference.js' });
+ window.AppData = {
+ ready: Promise.resolve(true),
+ preferences: {
+ async getSuite() { return JSON.parse(JSON.stringify(persisted)); },
+ async patchSuite(patch) { patchCalls.push(JSON.parse(JSON.stringify(patch))); }
+ }
+ };
+ return { utils: window.SuitePreferenceUtils, patchCalls };
+}
+
+const plain = (value) => JSON.parse(JSON.stringify(value));
+
+// Behaviour contract for Fix 6: after a reload the first call to
+// resolveSuitePreference() must surface the persisted suite preference
+// (e.g. flowMode='simulation'), not the classic default, because hydration
+// is awaited before any suiteConfig read.
+{
+ const stored = { flowMode: 'simulation', frequencyScope: 'high', autoAdvanceAfterSubmit: false };
+ const { utils, patchCalls } = loadSuitePreference(stored);
+
+ // eagerly-kicked hydration has a chance to settle before we await, but
+ // resolveSuitePreference awaits it anyway; the first read must reflect
+ // the stored value regardless of timing.
+ const first = await utils.resolveSuitePreference();
+ assert.equal(first.flowMode, 'simulation',
+ 'first resolve after reload must surface the stored flowMode, not the classic default');
+ assert.equal(first.frequencyScope, 'high',
+ 'first resolve after reload must surface the stored frequencyScope');
+ assert.equal(first.autoAdvanceAfterSubmit, false,
+ 'first resolve after reload must surface the stored autoAdvanceAfterSubmit');
+ assert.deepEqual(plain(first), {
+ flowMode: 'simulation',
+ frequencyScope: 'high',
+ autoAdvanceAfterSubmit: false
+ });
+ // The promise-based read must not mutate persistence on its own.
+ assert.equal(patchCalls.length, 0, 'resolveSuitePreference must not patch preferences');
+}
+
+// Reverse assertion: once hydration has been awaited, a subsequent call
+// must keep returning the stored preference (the cached hydrationPromise
+// short-circuits, so the stored value must still answer). ready is the
+// hydrateSuitePreference function reference, so it must be invoked, not
+// awaited as a bare value.
+{
+ const stored = { flowMode: 'simulation', frequencyScope: 'high', autoAdvanceAfterSubmit: false };
+ const { utils } = loadSuitePreference(stored);
+
+ await utils.ready();
+ const result = await utils.resolveSuitePreference();
+ assert.equal(result.flowMode, 'simulation');
+ assert.equal(result.frequencyScope, 'high');
+ const second = await utils.resolveSuitePreference();
+ assert.equal(second.flowMode, 'simulation',
+ 'a second resolve after hydration must still report the stored preference');
+ assert.equal(second.frequencyScope, 'high');
+}
+
+// Contract default when nothing is persisted: resolve must fall back to the
+// canonical classic/all defaults even after hydration runs to completion.
+{
+ const { utils } = loadSuitePreference({});
+ const result = await utils.resolveSuitePreference();
+ assert.equal(result.flowMode, 'classic',
+ 'empty persisted suite must resolve to the classic flowMode default');
+ assert.equal(result.frequencyScope, 'all',
+ 'empty persisted suite must resolve to the all frequencyScope default');
+ assert.equal(result.autoAdvanceAfterSubmit, true,
+ 'classic fallback must auto-advance after submit');
+}
+
+// persistSuitePreference is synchronous and reads config.suite inline: after
+// hydration surfaces simulation, a persist without an explicit flowMode must
+// keep the stored flowMode (Fix 6 contract: persist no longer defers to
+// resolveSuitePreference, but the value was already hydrated into config.suite).
+{
+ const stored = { flowMode: 'simulation', frequencyScope: 'high', autoAdvanceAfterSubmit: false };
+ const { utils, patchCalls } = loadSuitePreference(stored);
+
+ await utils.ready();
+ const persisted = utils.persistSuitePreference({ autoAdvanceAfterSubmit: true });
+ assert.equal(persisted.flowMode, 'simulation',
+ 'persist must reuse the hydrated flowMode when the caller omits it');
+ assert.equal(persisted.frequencyScope, 'high',
+ 'persist must reuse the hydrated frequencyScope when the caller omits it');
+ assert.equal(persisted.autoAdvanceAfterSubmit, true,
+ 'persist must honour the explicit autoAdvanceAfterSubmit override');
+ // persist fires patchSuite asynchronously after hydration settles.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ assert.equal(patchCalls.length, 1, 'persist must persist the resolved preference once');
+ assert.deepEqual(patchCalls[0], {
+ flowMode: 'simulation',
+ frequencyScope: 'high',
+ autoAdvanceAfterSubmit: true
+ });
+}
+
+// Race regression for the suitePracticeMixin synchronous reader: even when
+// resolveSuitePreference is never awaited, the eagerly-kicked hydration must
+// populate config.suite before ensurePracticeConfig().suite is read. We
+// emulate the mixin by awaiting utils.ready() (which is hydrateSuitePreference)
+// then synchronously inspecting ensurePracticeConfig().suite.
+{
+ const stored = { flowMode: 'stationary', frequencyScope: 'custom', autoAdvanceAfterSubmit: true };
+ const { utils } = loadSuitePreference(stored);
+ await utils.ready();
+ const suiteConfig = utils.ensurePracticeConfig().suite;
+ assert.equal(suiteConfig.flowMode, 'stationary',
+ 'eager hydration must populate config.suite.flowMode for synchronous readers');
+ assert.equal(suiteConfig.frequencyScope, 'custom',
+ 'eager hydration must populate config.suite.frequencyScope for synchronous readers');
+ assert.equal(suiteConfig.autoAdvanceAfterSubmit, true,
+ 'eager hydration must populate config.suite.autoAdvanceAfterSubmit for synchronous readers');
+}
+
+// runtime-entry loads before core-foundation, so the eager call can legitimately
+// run before AppData exists. That miss must not be cached as a permanent false.
+{
+ const stored = { flowMode: 'simulation', frequencyScope: 'high', autoAdvanceAfterSubmit: false };
+ const { utils } = loadSuitePreferenceBeforeAppData(stored);
+ const result = await utils.resolveSuitePreference();
+ assert.equal(result.flowMode, 'simulation',
+ 'late AppData installation must retry suite hydration after the eager early miss');
+ assert.equal(result.frequencyScope, 'high');
+ assert.equal(result.autoAdvanceAfterSubmit, false);
+}
+
+process.stdout.write(JSON.stringify({
+ status: 'pass',
+ detail: ' suitePreference resolves the hydrated suite preference on first read instead of the classic default'
+}));
diff --git a/developer/tests/js/suiteSessionRecoveryV2.test.js b/developer/tests/js/suiteSessionRecoveryV2.test.js
new file mode 100644
index 00000000..8bd51226
--- /dev/null
+++ b/developer/tests/js/suiteSessionRecoveryV2.test.js
@@ -0,0 +1,2607 @@
+#!/usr/bin/env node
+
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import vm from 'node:vm';
+import { fileURLToPath } from 'node:url';
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
+const source = fs.readFileSync(path.join(repoRoot, 'js/app/suitePracticeMixin.js'), 'utf8');
+
+function createSessionStore() {
+ const values = new Map();
+ const calls = { save: 0, get: 0, discard: 0 };
+ return {
+ save(name, value) { calls.save += 1; values.set(String(name), structuredClone(value)); return true; },
+ get(name) { calls.get += 1; return values.has(String(name)) ? structuredClone(values.get(String(name))) : null; },
+ discard(name) { calls.discard += 1; values.delete(String(name)); return true; },
+ peek(name) { return values.get(String(name)) || null; },
+ calls
+ };
+}
+
+function createPassthroughLockManager() {
+ return {
+ async request(name, options, callback) {
+ return callback({ name, mode: options && options.mode || 'exclusive' });
+ }
+ };
+}
+
+function createExclusiveLockManager() {
+ const held = new Map();
+ const calls = [];
+ let nextError = null;
+ return {
+ held,
+ calls,
+ failNext(error) { nextError = error; },
+ async request(name, options, callback) {
+ calls.push({ name: String(name), options: structuredClone(options || {}) });
+ if (nextError) {
+ const error = nextError;
+ nextError = null;
+ throw error;
+ }
+ assert.equal(options && options.mode, 'exclusive');
+ assert.equal(options && options.ifAvailable, true, 'claim contention must never queue a stale WAL');
+ if (held.has(String(name))) return callback(null);
+ const lock = { name: String(name), mode: 'exclusive' };
+ held.set(String(name), lock);
+ try {
+ return await callback(lock);
+ } finally {
+ if (held.get(String(name)) === lock) held.delete(String(name));
+ }
+ }
+ };
+}
+
+function createHarness(options = {}) {
+ const sessionStore = options.sessionStore || createSessionStore();
+ const activeSessionStore = options.activeSessionStore || new Map();
+ const recoveryFenceStore = options.recoveryFenceStore || new Map();
+ const realisticRecoveryStore = options.realisticRecoveryStore === true;
+ const recoveryRevision = (item) => {
+ const revision = Number(item && item.revision);
+ return Number.isSafeInteger(revision) && revision >= 0 ? revision : 0;
+ };
+ const recoveryGroup = (item) => {
+ const explicit = String(item && item._recoveryExclusiveGroup || '').trim();
+ if (explicit) return explicit;
+ return item && item.schema === 'suite-session-v2' && Number(item.version) === 2
+ ? 'suite-practice'
+ : '';
+ };
+ const cloneRecoveryOptions = (value = {}) => Object.fromEntries(Object.entries(value)
+ .filter(([, entry]) => typeof entry !== 'function')
+ .map(([key, entry]) => [key, structuredClone(entry)]));
+ const recoveryCalls = {
+ save: 0,
+ discard: 0,
+ cleanup: 0,
+ saveQueue: [],
+ discardQueue: [],
+ discardOptions: [],
+ listedItems: null,
+ listQueue: [],
+ list: 0
+ };
+ const messages = [];
+ const practiceFinalizes = [];
+ const rawStorageTrap = new Proxy({}, {
+ get() {
+ throw new Error('suite recovery must use AppData v2, not raw Web Storage');
+ },
+ set() {
+ throw new Error('suite recovery must use AppData v2, not raw Web Storage');
+ }
+ });
+ const protocol = options.protocol === 'file:' ? 'file:' : 'http:';
+ const windowStub = {
+ location: {
+ protocol,
+ href: protocol === 'file:' ? 'file:///index.html' : 'http://localhost/'
+ },
+ localStorage: rawStorageTrap,
+ sessionStorage: rawStorageTrap,
+ showMessage(text, type) { messages.push({ text, type }); },
+ addEventListener() {},
+ removeEventListener() {}
+ };
+ windowStub.navigator = options.locks === null
+ ? {}
+ : { locks: options.locks || createPassthroughLockManager() };
+ const sandbox = {
+ window: windowStub,
+ console,
+ Date,
+ Math,
+ JSON,
+ Array,
+ Object,
+ Map,
+ Set,
+ URL,
+ structuredClone,
+ setTimeout,
+ clearTimeout,
+ setInterval,
+ clearInterval,
+ AppData: {
+ ready: Promise.resolve(),
+ recovery: {
+ windowSession: sessionStore,
+ async listActiveSessions() {
+ recoveryCalls.list += 1;
+ if (options.listActiveSessionsError) throw options.listActiveSessionsError;
+ const items = recoveryCalls.listQueue.length
+ ? recoveryCalls.listQueue.shift()
+ : Array.isArray(recoveryCalls.listedItems)
+ ? recoveryCalls.listedItems
+ : Array.from(activeSessionStore.values());
+ return items
+ .filter((value) => !realisticRecoveryStore || value?._recoveryTombstone !== true)
+ .map((value) => structuredClone(value));
+ },
+ async getActiveSessionFence(id) {
+ const normalizedId = String(id);
+ if (recoveryFenceStore.has(normalizedId)) {
+ return structuredClone(recoveryFenceStore.get(normalizedId));
+ }
+ const active = activeSessionStore.get(normalizedId);
+ return active
+ ? {
+ id: normalizedId,
+ exists: true,
+ tombstoned: active._recoveryTombstone === true,
+ revision: recoveryRevision(active)
+ }
+ : { id: normalizedId, exists: false, tombstoned: false, revision: 0 };
+ },
+ async saveActiveSession(value, options = {}) {
+ recoveryCalls.save += 1;
+ const behavior = recoveryCalls.saveQueue.length ? recoveryCalls.saveQueue.shift() : true;
+ const outcome = typeof behavior === 'function'
+ ? await behavior(structuredClone(value), cloneRecoveryOptions(options))
+ : behavior;
+ if (outcome instanceof Error) throw outcome;
+ if (outcome === false || (outcome && outcome.committed === false)) {
+ return outcome === false ? { committed: false } : structuredClone(outcome);
+ }
+ if (typeof options.commitGuard === 'function' && options.commitGuard() === false) {
+ return { committed: false, stale: true, reason: 'COMMIT_GUARD_REJECTED' };
+ }
+ const normalizedId = String(value.id);
+ if (realisticRecoveryStore
+ && Object.prototype.hasOwnProperty.call(options, 'expectedEntityRevision')) {
+ const expectedRevision = Number(options.expectedEntityRevision);
+ const active = activeSessionStore.get(normalizedId);
+ const actualRevision = active ? recoveryRevision(active) : 0;
+ if (actualRevision !== expectedRevision) {
+ return {
+ committed: false,
+ stale: true,
+ code: 'STALE_RECOVERY_WRITE',
+ expectedEntityRevision: expectedRevision,
+ actualEntityRevision: actualRevision
+ };
+ }
+ }
+ const exclusiveGroup = String(options.exclusiveGroup || '').trim();
+ if (realisticRecoveryStore && exclusiveGroup) {
+ const conflicting = Array.from(activeSessionStore.entries()).find(([id, item]) => (
+ String(id) !== normalizedId
+ && item?._recoveryTombstone !== true
+ && recoveryGroup(item) === exclusiveGroup
+ ));
+ if (conflicting) {
+ return {
+ committed: false,
+ stale: true,
+ code: 'RECOVERY_GROUP_CONFLICT',
+ conflictingEntityId: String(conflicting[0])
+ };
+ }
+ }
+ const storedValue = structuredClone(value);
+ if (exclusiveGroup) storedValue._recoveryExclusiveGroup = exclusiveGroup;
+ activeSessionStore.set(normalizedId, storedValue);
+ recoveryFenceStore.set(String(value.id), {
+ id: String(value.id),
+ exists: true,
+ tombstoned: false,
+ revision: recoveryRevision(value)
+ });
+ return outcome && typeof outcome === 'object'
+ ? { ...structuredClone(outcome), committed: true, item: structuredClone(value) }
+ : { committed: true, item: structuredClone(value) };
+ },
+ async discardActiveSession(id, options = {}) {
+ recoveryCalls.discard += 1;
+ recoveryCalls.discardOptions.push(cloneRecoveryOptions(options));
+ const behavior = recoveryCalls.discardQueue.length ? recoveryCalls.discardQueue.shift() : true;
+ const outcome = typeof behavior === 'function'
+ ? await behavior(String(id), cloneRecoveryOptions(options))
+ : behavior;
+ if (outcome instanceof Error) throw outcome;
+ if (outcome === false || (outcome && outcome.committed === false)) {
+ return outcome === false ? { committed: false } : structuredClone(outcome);
+ }
+ if (typeof options.commitGuard === 'function' && options.commitGuard() === false) {
+ return { committed: false, stale: true, reason: 'COMMIT_GUARD_REJECTED' };
+ }
+ const normalizedId = String(id);
+ if (realisticRecoveryStore
+ && Object.prototype.hasOwnProperty.call(options, 'expectedEntityRevision')) {
+ const expectedRevision = Number(options.expectedEntityRevision);
+ const active = activeSessionStore.get(normalizedId);
+ const actualRevision = active ? recoveryRevision(active) : 0;
+ if (actualRevision !== expectedRevision) {
+ return {
+ committed: false,
+ stale: true,
+ code: 'STALE_RECOVERY_WRITE',
+ expectedEntityRevision: expectedRevision,
+ actualEntityRevision: actualRevision
+ };
+ }
+ activeSessionStore.set(normalizedId, {
+ id: normalizedId,
+ revision: Math.min(Number.MAX_SAFE_INTEGER, actualRevision + 1),
+ _recoveryTombstone: true,
+ discardedAt: Date.now()
+ });
+ } else {
+ activeSessionStore.delete(normalizedId);
+ }
+ if (Object.prototype.hasOwnProperty.call(options, 'expectedEntityRevision')) {
+ const expectedRevision = Number(options.expectedEntityRevision);
+ recoveryFenceStore.set(normalizedId, {
+ id: normalizedId,
+ exists: true,
+ tombstoned: true,
+ revision: Number.isSafeInteger(expectedRevision) && expectedRevision >= 0
+ ? Math.min(Number.MAX_SAFE_INTEGER, expectedRevision + 1)
+ : 1
+ });
+ } else {
+ recoveryFenceStore.delete(String(id));
+ }
+ return outcome && typeof outcome === 'object'
+ ? { ...structuredClone(outcome), committed: true }
+ : { committed: true };
+ },
+ async cleanupForRetry() {
+ recoveryCalls.cleanup += 1;
+ return { committed: true, removedCount: 0, removedByKind: {} };
+ }
+ },
+ practice: {
+ async finalizeSuite(command = {}) {
+ practiceFinalizes.push(structuredClone(command));
+ return { committed: true, record: structuredClone(command.record) };
+ }
+ }
+ }
+ };
+ if (options.listActiveSessionsUnavailable === true) {
+ delete sandbox.AppData.recovery.listActiveSessions;
+ }
+ windowStub.AppData = sandbox.AppData;
+ windowStub.ExamSystemAppMixins = {};
+ sandbox.globalThis = windowStub;
+ const vmContext = vm.createContext(sandbox);
+ vm.runInContext(source, vmContext, { filename: 'js/app/suitePracticeMixin.js' });
+ const createVmMap = vm.runInContext('() => new Map()', vmContext);
+ const mixin = windowStub.ExamSystemAppMixins.suitePractice;
+ const sequence = ['p1', 'p2', 'p3'].map((examId, index) => ({
+ examId,
+ exam: { id: examId, title: `Passage ${index + 1}`, category: `P${index + 1}` },
+ category: `P${index + 1}`
+ }));
+ const makeApp = () => {
+ const app = {
+ components: {},
+ currentSuiteSession: null,
+ suiteExamMap: new Map(),
+ examWindows: createVmMap(),
+ messages
+ };
+ Object.assign(app, mixin);
+ let registrationGeneration = 0;
+ app._installManagedTestWindow = (examId, targetWindow) => {
+ registrationGeneration += 1;
+ app.examWindows.set(examId, {
+ window: targetWindow,
+ suiteSessionId: app.currentSuiteSession && app.currentSuiteSession.id || null,
+ registrationToken: `test-registration-${registrationGeneration}`,
+ sessionGeneration: registrationGeneration
+ });
+ return targetWindow;
+ };
+ app._captureExamSessionRegistration = (examId, windowInfo) => Object.freeze({
+ examId,
+ window: windowInfo.window,
+ suiteSessionId: String(windowInfo.suiteSessionId || ''),
+ registrationToken: windowInfo.registrationToken,
+ sessionGeneration: windowInfo.sessionGeneration
+ });
+ app._isExamSessionRegistrationCurrent = (examId, registration) => {
+ const current = app.examWindows.get(examId);
+ return !!current
+ && current.window === registration.window
+ && String(current.suiteSessionId || '') === String(registration.suiteSessionId || '')
+ && current.registrationToken === registration.registrationToken
+ && current.sessionGeneration === registration.sessionGeneration;
+ };
+ let launchSequence = 0;
+ const launchReceipts = new WeakMap();
+ app._beginExamLaunchOwnership = (examId) => Object.freeze({
+ examId: String(examId || ''),
+ sequence: ++launchSequence,
+ initialState: null,
+ targetLeaseKeys: Object.freeze([])
+ });
+ app._isExamLaunchOwnershipCurrent = (examId, ownership) => Boolean(
+ ownership && String(ownership.examId || '') === String(examId || '')
+ );
+ app._claimExamLaunchWindowOwnership = () => true;
+ app._commitExamLaunchOwnership = () => true;
+ app._rollbackExamLaunchOwnership = () => true;
+ app._recordExamLaunchRegistrationReceipt = (examId, ownership, registration) => {
+ if (!ownership || !registration) return false;
+ launchReceipts.set(ownership, { examId: String(examId || ''), registration });
+ return true;
+ };
+ app._captureExamLaunchRegistrationReceipt = (examId, ownership, targetWindow = null) => {
+ const receipt = ownership && launchReceipts.get(ownership);
+ if (!receipt
+ || receipt.examId !== String(examId || '')
+ || (targetWindow && receipt.registration.window !== targetWindow)
+ || !app._isExamSessionRegistrationCurrent(examId, receipt.registration)) return null;
+ return receipt.registration;
+ };
+ let openExamImplementation = null;
+ Object.defineProperty(app, 'openExam', {
+ configurable: true,
+ get() {
+ return openExamImplementation;
+ },
+ set(implementation) {
+ openExamImplementation = async (...args) => {
+ const targetWindow = await implementation.apply(app, args);
+ if (!targetWindow || targetWindow.closed) return targetWindow;
+ app._installManagedTestWindow(args[0], targetWindow);
+ const launchOwnership = args[1] && args[1].launchOwnership;
+ if (launchOwnership) {
+ app._recordExamLaunchRegistrationReceipt(
+ args[0],
+ launchOwnership,
+ app._captureExamSessionRegistration(args[0], app.examWindows.get(args[0]))
+ );
+ }
+ return targetWindow;
+ };
+ }
+ });
+ app._clearSuiteHandshakes = () => {};
+ app._ensureSuiteWindowGuard = () => {};
+ app._releaseSuiteWindowGuard = () => {};
+ app._focusSuiteWindow = () => {};
+ app._sendSimulationContext = () => true;
+ app.updateExamStatus = () => {};
+ app.cleanupExamSession = async () => {};
+ return app;
+ };
+ sandbox.localStorage = rawStorageTrap;
+ sandbox.sessionStorage = rawStorageTrap;
+ return {
+ sessionStore,
+ activeSessionStore,
+ recoveryFenceStore,
+ recoveryCalls,
+ messages,
+ practiceFinalizes,
+ makeApp,
+ sequence
+ };
+}
+
+async function main() {
+ const fixtureTimeBase = Date.now() - 60_000;
+ const { sessionStore, activeSessionStore, messages, practiceFinalizes, makeApp, sequence } = createHarness();
+ const firstApp = makeApp();
+ let firstWindow;
+ firstApp.openExam = async () => {
+ const snapshot = sessionStore.peek('simulation');
+ assert.equal(snapshot.status, 'active');
+ assert.equal(snapshot.currentIndex, 0);
+ assert.equal(snapshot.sequence.length, 3);
+ firstWindow = { closed: false, name: 'suite-window' };
+ return firstWindow;
+ };
+ assert.equal(await firstApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true);
+ assert.equal(sessionStore.peek('simulation').status, 'active');
+
+ const tabOwnedWal = structuredClone(sessionStore.peek('simulation'));
+ const tabOwnedDurable = structuredClone(activeSessionStore.get(tabOwnedWal.id));
+ assert(tabOwnedDurable, 'fixture must persist the tab-owned suite recovery');
+
+ const durableOnlySingleLocks = createExclusiveLockManager();
+ const olderDurableSingle = {
+ ...structuredClone(tabOwnedDurable),
+ id: 'suite-durable-only-older',
+ lastUpdate: fixtureTimeBase + 100
+ };
+ const newestDurableSingle = {
+ ...structuredClone(tabOwnedDurable),
+ id: 'suite-durable-only-newest',
+ lastUpdate: fixtureTimeBase + 200
+ };
+ const durableOnlySingleStore = new Map([
+ [olderDurableSingle.id, olderDurableSingle],
+ [newestDurableSingle.id, newestDurableSingle]
+ ]);
+ const durableSingleOwnerHarness = createHarness({
+ locks: durableOnlySingleLocks,
+ activeSessionStore: durableOnlySingleStore,
+ realisticRecoveryStore: true
+ });
+ const durableSingleOwnerApp = durableSingleOwnerHarness.makeApp();
+ const heldNewestSingle = durableSingleOwnerApp._restoreSessionFromStorage(newestDurableSingle);
+ assert.equal(await durableSingleOwnerApp._acquireSuiteRecoveryClaim('single', heldNewestSingle), true);
+
+ const emptyHttpTabHarness = createHarness({
+ locks: durableOnlySingleLocks,
+ activeSessionStore: durableOnlySingleStore,
+ realisticRecoveryStore: true
+ });
+ const emptyHttpTabApp = emptyHttpTabHarness.makeApp();
+ emptyHttpTabApp.initializeSuiteMode();
+ await emptyHttpTabApp._ensureSuiteRecoveryReady();
+ assert.equal(emptyHttpTabApp.currentSuiteSession, null, 'a fresh HTTP tab must not bypass the newest durable owner lease');
+ assert.equal(emptyHttpTabHarness.recoveryCalls.discard, 0, 'lease contention must not clean durable recovery');
+ assert.equal(
+ durableOnlySingleLocks.calls.filter((call) => call.name === emptyHttpTabApp._suiteRecoveryClaimName(olderDurableSingle.id)).length,
+ 0,
+ 'newest single contention must not fall back to an older durable id'
+ );
+ assert.equal(await durableSingleOwnerApp._releaseSuiteRecoveryClaim('single', heldNewestSingle), true);
+
+ const heldOlderSingle = durableSingleOwnerApp._restoreSessionFromStorage(olderDurableSingle);
+ assert.equal(await durableSingleOwnerApp._acquireSuiteRecoveryClaim('single', heldOlderSingle), true);
+ assert.equal(await emptyHttpTabApp._refreshSuiteRecoveryCandidates(), null);
+ assert.equal(emptyHttpTabApp.currentSuiteSession, null, 'an active older singleton must block coordinated takeover of the whole group');
+ assert.equal(emptyHttpTabHarness.recoveryCalls.discard, 0, 'older-owner contention must not partially clean the singleton group');
+ assert.equal(durableOnlySingleStore.get(olderDurableSingle.id)._recoveryTombstone, undefined);
+ assert.equal(durableOnlySingleStore.get(newestDurableSingle.id)._recoveryTombstone, undefined);
+ assert.equal(
+ durableOnlySingleLocks.held.has(emptyHttpTabApp._suiteRecoveryClaimName(newestDurableSingle.id)),
+ false,
+ 'a failed group claim must release the provisional authoritative lock'
+ );
+ assert.equal(await durableSingleOwnerApp._releaseSuiteRecoveryClaim('single', heldOlderSingle), true);
+
+ assert.equal(await emptyHttpTabApp._refreshSuiteRecoveryCandidates(), emptyHttpTabApp.currentSuiteSession);
+ assert.equal(emptyHttpTabApp.currentSuiteSession.id, newestDurableSingle.id, 'the same tab must take over the newest durable after owner release');
+ assert.equal(emptyHttpTabHarness.recoveryCalls.discard, 1, 'takeover must tombstone the older singleton before installing the newest');
+ assert.equal(emptyHttpTabHarness.recoveryCalls.discardOptions[0].expectedEntityRevision, Number(olderDurableSingle.revision) || 0);
+ assert.equal(durableOnlySingleStore.get(olderDurableSingle.id)._recoveryTombstone, true);
+ assert.equal(
+ durableOnlySingleStore.get(olderDurableSingle.id).revision,
+ (Number(olderDurableSingle.revision) || 0) + 1
+ );
+ assert.equal(durableOnlySingleLocks.held.size, 1);
+ emptyHttpTabApp.currentSuiteSession.revision += 1;
+ emptyHttpTabApp.currentSuiteSession.lastUpdate = Date.now();
+ assert.equal(
+ await emptyHttpTabApp._commitSuiteRecovery(emptyHttpTabApp.currentSuiteSession, { notify: false }),
+ true,
+ 'the newest singleton must commit after coordinated cleanup removes the legacy group conflict'
+ );
+ assert.equal(durableOnlySingleStore.get(newestDurableSingle.id)._recoveryExclusiveGroup, 'suite-practice');
+ assert.equal(await emptyHttpTabApp._releaseSuiteRecoveryClaim('single', emptyHttpTabApp.currentSuiteSession), true);
+ assert.equal(durableOnlySingleLocks.held.size, 0);
+
+ const matchingGroupHttpLocks = createExclusiveLockManager();
+ const matchingGroupHttpStore = new Map([
+ [olderDurableSingle.id, structuredClone(olderDurableSingle)],
+ [newestDurableSingle.id, structuredClone(newestDurableSingle)]
+ ]);
+ const matchingGroupOlderOwnerHarness = createHarness({
+ locks: matchingGroupHttpLocks,
+ activeSessionStore: matchingGroupHttpStore,
+ realisticRecoveryStore: true
+ });
+ const matchingGroupOlderOwnerApp = matchingGroupOlderOwnerHarness.makeApp();
+ const matchingGroupHeldOlder = matchingGroupOlderOwnerApp._restoreSessionFromStorage(olderDurableSingle);
+ assert.equal(await matchingGroupOlderOwnerApp._acquireSuiteRecoveryClaim('single', matchingGroupHeldOlder), true);
+ const matchingGroupHttpHarness = createHarness({
+ locks: matchingGroupHttpLocks,
+ activeSessionStore: matchingGroupHttpStore,
+ realisticRecoveryStore: true
+ });
+ matchingGroupHttpHarness.sessionStore.save('simulation', structuredClone(newestDurableSingle));
+ const matchingGroupHttpApp = matchingGroupHttpHarness.makeApp();
+ matchingGroupHttpApp.initializeSuiteMode();
+ await matchingGroupHttpApp._ensureSuiteRecoveryReady();
+ assert.equal(matchingGroupHttpApp.currentSuiteSession, null, 'a matching WAL must remain quarantined while another singleton id is live');
+ assert.equal(matchingGroupHttpHarness.recoveryCalls.discard, 0);
+ assert.equal(matchingGroupHttpHarness.sessionStore.peek('simulation').id, newestDurableSingle.id);
+ assert.equal(await matchingGroupOlderOwnerApp._releaseSuiteRecoveryClaim('single', matchingGroupHeldOlder), true);
+ assert.equal(await matchingGroupHttpApp._refreshSuiteRecoveryCandidates(), matchingGroupHttpApp.currentSuiteSession);
+ assert.equal(matchingGroupHttpApp.currentSuiteSession.id, newestDurableSingle.id);
+ assert.equal(matchingGroupHttpStore.get(olderDurableSingle.id)._recoveryTombstone, true);
+ matchingGroupHttpApp.currentSuiteSession.revision += 1;
+ matchingGroupHttpApp.currentSuiteSession.lastUpdate = Date.now();
+ assert.equal(await matchingGroupHttpApp._commitSuiteRecovery(
+ matchingGroupHttpApp.currentSuiteSession,
+ { notify: false }
+ ), true, 'a matching HTTP WAL must commit after coordinated legacy-id cleanup');
+ assert.equal(await matchingGroupHttpApp._releaseSuiteRecoveryClaim('single', matchingGroupHttpApp.currentSuiteSession), true);
+
+ const splitWalGroupLocks = createExclusiveLockManager();
+ const splitWalGroupStore = new Map([
+ [olderDurableSingle.id, structuredClone(olderDurableSingle)],
+ [newestDurableSingle.id, structuredClone(newestDurableSingle)]
+ ]);
+ const splitWalGroupTabA = createHarness({
+ locks: splitWalGroupLocks,
+ activeSessionStore: splitWalGroupStore,
+ realisticRecoveryStore: true
+ });
+ const splitWalGroupTabB = createHarness({
+ locks: splitWalGroupLocks,
+ activeSessionStore: splitWalGroupStore,
+ realisticRecoveryStore: true
+ });
+ splitWalGroupTabA.sessionStore.save('simulation', structuredClone(olderDurableSingle));
+ splitWalGroupTabB.sessionStore.save('simulation', structuredClone(newestDurableSingle));
+ const splitWalGroupAppA = splitWalGroupTabA.makeApp();
+ const splitWalGroupAppB = splitWalGroupTabB.makeApp();
+ splitWalGroupAppA.initializeSuiteMode();
+ splitWalGroupAppB.initializeSuiteMode();
+ await Promise.all([
+ splitWalGroupAppA._ensureSuiteRecoveryReady(),
+ splitWalGroupAppB._ensureSuiteRecoveryReady()
+ ]);
+ const splitWalOwners = [splitWalGroupAppA, splitWalGroupAppB]
+ .filter((app) => app.currentSuiteSession);
+ assert.equal(splitWalOwners.length, 1, 'different-id WAL tabs must elect one singleton owner');
+ assert.equal(splitWalOwners[0].currentSuiteSession.id, newestDurableSingle.id);
+ assert.equal(
+ splitWalGroupTabA.recoveryCalls.discard + splitWalGroupTabB.recoveryCalls.discard,
+ 1,
+ 'the group winner must tombstone exactly the older durable identity'
+ );
+ assert.equal(splitWalGroupStore.get(olderDurableSingle.id)._recoveryTombstone, true);
+ assert.equal(splitWalGroupStore.get(newestDurableSingle.id)._recoveryTombstone, undefined);
+ const splitWalGroupName = splitWalGroupAppA._singleSuiteRecoveryGroupClaimName();
+ assert.equal(
+ splitWalGroupLocks.calls.filter((call) => call.name === splitWalGroupName).length,
+ 2,
+ 'both tabs must contend on the origin-wide singleton group lock first'
+ );
+ assert.deepEqual(
+ splitWalGroupLocks.calls
+ .map((call) => call.name)
+ .filter((name) => name.startsWith('ielts-atlas:suite-recovery:'))
+ .sort(),
+ [
+ splitWalGroupAppA._suiteRecoveryClaimName(olderDurableSingle.id),
+ splitWalGroupAppA._suiteRecoveryClaimName(newestDurableSingle.id)
+ ].sort(),
+ 'only the group winner may request the two coordinated exact identities'
+ );
+ assert.equal(splitWalGroupLocks.held.has(splitWalGroupName), false, 'the short-lived group lock must be released after install');
+ assert.deepEqual(
+ Array.from(splitWalGroupLocks.held.keys()),
+ [splitWalGroupAppA._suiteRecoveryClaimName(newestDurableSingle.id)],
+ 'only the authoritative exact lease may survive recovery'
+ );
+ const splitWalLoserHarness = splitWalOwners[0] === splitWalGroupAppA
+ ? splitWalGroupTabB
+ : splitWalGroupTabA;
+ assert.equal(
+ splitWalLoserHarness.sessionStore.peek('simulation').recoveryLeaseContended,
+ true,
+ 'group contention must preserve and mark the losing tab WAL for retry'
+ );
+ assert.equal(await splitWalOwners[0]._releaseSuiteRecoveryClaim(
+ 'single',
+ splitWalOwners[0].currentSuiteSession
+ ), true);
+ assert.equal(splitWalGroupLocks.held.size, 0);
+ const splitWalLoserApp = splitWalOwners[0] === splitWalGroupAppA
+ ? splitWalGroupAppB
+ : splitWalGroupAppA;
+ assert.equal(
+ await splitWalLoserApp._refreshSuiteRecoveryCandidates(),
+ splitWalLoserApp.currentSuiteSession,
+ 'the marked group-contention WAL must remain retryable after owner release'
+ );
+ assert.equal(splitWalLoserApp.currentSuiteSession.id, newestDurableSingle.id);
+ assert.equal(await splitWalLoserApp._releaseSuiteRecoveryClaim(
+ 'single',
+ splitWalLoserApp.currentSuiteSession
+ ), true);
+ assert.equal(splitWalGroupLocks.held.size, 0);
+
+ const matchingGroupFileStore = new Map([
+ [olderDurableSingle.id, structuredClone(olderDurableSingle)],
+ [newestDurableSingle.id, structuredClone(newestDurableSingle)]
+ ]);
+ const matchingGroupFileHarness = createHarness({
+ protocol: 'file:',
+ activeSessionStore: matchingGroupFileStore,
+ realisticRecoveryStore: true
+ });
+ matchingGroupFileHarness.sessionStore.save('simulation', structuredClone(newestDurableSingle));
+ const matchingGroupFileApp = matchingGroupFileHarness.makeApp();
+ matchingGroupFileApp.initializeSuiteMode();
+ await matchingGroupFileApp._ensureSuiteRecoveryReady();
+ assert.equal(matchingGroupFileApp.currentSuiteSession.id, newestDurableSingle.id);
+ assert.equal(matchingGroupFileStore.get(olderDurableSingle.id)._recoveryTombstone, true);
+ matchingGroupFileApp.currentSuiteSession.revision += 1;
+ matchingGroupFileApp.currentSuiteSession.lastUpdate = Date.now();
+ assert.equal(await matchingGroupFileApp._commitSuiteRecovery(
+ matchingGroupFileApp.currentSuiteSession,
+ { notify: false }
+ ), true, 'a matching file WAL must commit after coordinated legacy-id cleanup');
+ assert.equal(await matchingGroupFileApp._releaseSuiteRecoveryClaim('single', matchingGroupFileApp.currentSuiteSession), true);
+
+ const staleOlderWalLocks = createExclusiveLockManager();
+ const staleOlderDurable = {
+ ...structuredClone(olderDurableSingle),
+ elapsedByExam: { p1: 111 }
+ };
+ const authoritativeNewerDurable = {
+ ...structuredClone(newestDurableSingle),
+ elapsedByExam: { p1: 999 }
+ };
+ const staleOlderWalStore = new Map([
+ [staleOlderDurable.id, structuredClone(staleOlderDurable)],
+ [authoritativeNewerDurable.id, structuredClone(authoritativeNewerDurable)]
+ ]);
+ const staleOlderWalHarness = createHarness({
+ locks: staleOlderWalLocks,
+ activeSessionStore: staleOlderWalStore,
+ realisticRecoveryStore: true
+ });
+ staleOlderWalHarness.sessionStore.save('simulation', structuredClone(staleOlderDurable));
+ const staleOlderWalApp = staleOlderWalHarness.makeApp();
+ staleOlderWalApp.initializeSuiteMode();
+ await staleOlderWalApp._ensureSuiteRecoveryReady();
+ assert.equal(staleOlderWalApp.currentSuiteSession.id, authoritativeNewerDurable.id, 'a matching stale WAL must not override the newer singleton owner');
+ assert.equal(staleOlderWalApp.currentSuiteSession.elapsedByExam.p1, 999, 'newer durable progress must survive group reconciliation');
+ assert.equal(staleOlderWalStore.get(staleOlderDurable.id)._recoveryTombstone, true);
+ staleOlderWalApp.currentSuiteSession.revision += 1;
+ assert.equal(await staleOlderWalApp._commitSuiteRecovery(
+ staleOlderWalApp.currentSuiteSession,
+ { notify: false }
+ ), true);
+ assert.equal(await staleOlderWalApp._releaseSuiteRecoveryClaim('single', staleOlderWalApp.currentSuiteSession), true);
+
+ const durableOnlyFileStore = new Map([
+ [olderDurableSingle.id, structuredClone(olderDurableSingle)],
+ [newestDurableSingle.id, structuredClone(newestDurableSingle)]
+ ]);
+ const durableOnlyFileHarness = createHarness({
+ protocol: 'file:',
+ activeSessionStore: durableOnlyFileStore,
+ realisticRecoveryStore: true
+ });
+ const durableOnlyFileApp = durableOnlyFileHarness.makeApp();
+ durableOnlyFileApp.initializeSuiteMode();
+ await durableOnlyFileApp._ensureSuiteRecoveryReady();
+ assert.equal(durableOnlyFileApp.currentSuiteSession.id, newestDurableSingle.id, 'file durable-only recovery must select the newest singleton');
+ assert.equal(durableOnlyFileStore.get(olderDurableSingle.id)._recoveryTombstone, true, 'file durable-only recovery must clean the older group member first');
+ durableOnlyFileApp.currentSuiteSession.revision += 1;
+ assert.equal(await durableOnlyFileApp._commitSuiteRecovery(
+ durableOnlyFileApp.currentSuiteSession,
+ { notify: false }
+ ), true, 'file durable-only recovery must commit after coordinated group cleanup');
+ assert.equal(await durableOnlyFileApp._releaseSuiteRecoveryClaim('single', durableOnlyFileApp.currentSuiteSession), true);
+
+ const vanishedSingleLocks = createExclusiveLockManager();
+ const vanishedSingleHarness = createHarness({ locks: vanishedSingleLocks });
+ vanishedSingleHarness.recoveryCalls.listQueue.push(
+ [structuredClone(newestDurableSingle)],
+ []
+ );
+ const vanishedSingleApp = vanishedSingleHarness.makeApp();
+ vanishedSingleApp.initializeSuiteMode();
+ await vanishedSingleApp._ensureSuiteRecoveryReady();
+ assert.equal(vanishedSingleApp.currentSuiteSession, null, 'a durable clone that vanishes under its lease must not be exposed');
+ assert.equal(vanishedSingleHarness.recoveryCalls.save, 0, 'a vanished durable clone must never expected=0 resurrect itself');
+ assert.equal(vanishedSingleHarness.recoveryCalls.list, 2);
+ assert.equal(vanishedSingleLocks.held.size, 0);
+
+ for (const enumerationFailure of ['missing', 'throw']) {
+ const unavailableSingleLocks = createExclusiveLockManager();
+ const unavailableSingleHarness = createHarness({
+ locks: unavailableSingleLocks,
+ ...(enumerationFailure === 'missing'
+ ? { listActiveSessionsUnavailable: true }
+ : { listActiveSessionsError: new Error('active recovery enumeration failed') })
+ });
+ unavailableSingleHarness.sessionStore.save('simulation', structuredClone(tabOwnedWal));
+ unavailableSingleHarness.recoveryFenceStore.set(String(tabOwnedWal.id), {
+ id: String(tabOwnedWal.id),
+ exists: true,
+ tombstoned: true,
+ revision: (Number(tabOwnedWal.revision) || 0) + 1
+ });
+ const unavailableSingleApp = unavailableSingleHarness.makeApp();
+ unavailableSingleApp.initializeSuiteMode();
+ await unavailableSingleApp._ensureSuiteRecoveryReady();
+ assert.equal(
+ unavailableSingleApp.currentSuiteSession,
+ null,
+ `HTTP single WAL must remain quarantined when durable enumeration is ${enumerationFailure}`
+ );
+ assert.equal(unavailableSingleHarness.recoveryCalls.save, 0);
+ assert.equal(unavailableSingleHarness.sessionStore.peek('simulation').id, tabOwnedWal.id);
+ assert.equal(unavailableSingleLocks.held.size, 0);
+ assert.equal(
+ unavailableSingleLocks.calls.filter((call) => (
+ call.name === unavailableSingleApp._singleSuiteRecoveryGroupClaimName()
+ )).length,
+ 1,
+ `HTTP ${enumerationFailure} enumeration must release its acquired group coordination lock`
+ );
+ }
+
+ const mismatchedWalLocks = createExclusiveLockManager();
+ const mismatchedWalStore = new Map([
+ [tabOwnedDurable.id, structuredClone(tabOwnedDurable)]
+ ]);
+ const mismatchedWalOwnerHarness = createHarness({
+ locks: mismatchedWalLocks,
+ activeSessionStore: mismatchedWalStore,
+ realisticRecoveryStore: true
+ });
+ const mismatchedWalOwnerApp = mismatchedWalOwnerHarness.makeApp();
+ const mismatchedForeignOwner = mismatchedWalOwnerApp._restoreSessionFromStorage(tabOwnedDurable);
+ assert.equal(await mismatchedWalOwnerApp._acquireSuiteRecoveryClaim('single', mismatchedForeignOwner), true);
+ const mismatchedWalHarness = createHarness({
+ locks: mismatchedWalLocks,
+ activeSessionStore: mismatchedWalStore,
+ realisticRecoveryStore: true
+ });
+ const localWalId = 'suite-local-tab-owner';
+ mismatchedWalHarness.sessionStore.save('simulation', {
+ ...structuredClone(tabOwnedWal),
+ id: localWalId,
+ revision: 1,
+ lastUpdate: Number(tabOwnedWal.lastUpdate) + 1
+ });
+ const mismatchedWalApp = mismatchedWalHarness.makeApp();
+ mismatchedWalApp.initializeSuiteMode();
+ await mismatchedWalApp._ensureSuiteRecoveryReady();
+ assert.equal(mismatchedWalApp.currentSuiteSession, null, 'a mismatched pre-first-save WAL must remain quarantined while the durable singleton is live');
+ assert.equal(mismatchedWalHarness.sessionStore.peek('simulation').id, localWalId, 'fail-closed coordination must preserve the local WAL bytes');
+ assert.equal(mismatchedWalHarness.recoveryCalls.save, 0, 'the mismatched WAL must not attempt expected=0 against a foreign exclusive group');
+ assert.equal(mismatchedWalHarness.activeSessionStore.has(tabOwnedDurable.id), true, 'mismatched foreign durable recovery must not be discarded');
+ assert.equal(mismatchedWalHarness.recoveryCalls.discard, 0);
+ assert.equal(await mismatchedWalOwnerApp._releaseSuiteRecoveryClaim('single', mismatchedForeignOwner), true);
+ assert.equal(await mismatchedWalApp._refreshSuiteRecoveryCandidates(), mismatchedWalApp.currentSuiteSession);
+ assert.equal(mismatchedWalApp.currentSuiteSession.id, tabOwnedDurable.id, 'after release the authoritative durable singleton must replace the mismatched WAL owner');
+ assert.equal(await mismatchedWalApp._releaseSuiteRecoveryClaim('single', mismatchedWalApp.currentSuiteSession), true);
+
+ const matchedWalHarness = createHarness();
+ const matchedLocalWal = {
+ ...structuredClone(tabOwnedWal),
+ currentIndex: 0,
+ activeExamId: 'p1',
+ revision: 10,
+ lastUpdate: fixtureTimeBase + 1000
+ };
+ const matchedDurable = {
+ ...structuredClone(tabOwnedDurable),
+ currentIndex: 1,
+ activeExamId: 'p2',
+ revision: 11,
+ lastUpdate: fixtureTimeBase + 2000
+ };
+ matchedWalHarness.sessionStore.save('simulation', matchedLocalWal);
+ matchedWalHarness.activeSessionStore.set(matchedDurable.id, matchedDurable);
+ const matchedWalApp = matchedWalHarness.makeApp();
+ matchedWalApp.initializeSuiteMode();
+ await matchedWalApp._ensureSuiteRecoveryReady();
+ assert.equal(matchedWalApp.currentSuiteSession.id, matchedLocalWal.id);
+ assert.equal(matchedWalApp.currentSuiteSession.currentIndex, 1, 'matching HTTP WAL evidence must allow the newer durable snapshot');
+ assert.equal(matchedWalApp.currentSuiteSession.activeExamId, 'p2');
+
+ const bindingOwnerHarness = createHarness();
+ const bindingOwnerApp = bindingOwnerHarness.makeApp();
+ const priorSuiteBinding = {
+ examId: 'p1',
+ expectedSessionId: 'suite-binding-child',
+ windowSessionToken: 'suite-binding-token',
+ sessionGeneration: 7,
+ expectedUrl: 'http://localhost/exam.html?examId=p1',
+ expectedOrigin: 'http://localhost',
+ allowOpaqueOrigin: false
+ };
+ const bindingOwnerSession = bindingOwnerApp._restoreSessionFromStorage({
+ ...structuredClone(tabOwnedWal),
+ id: 'suite-binding-owner',
+ activeExamId: 'p1',
+ currentIndex: 0,
+ revision: 0,
+ windowBinding: structuredClone(priorSuiteBinding)
+ });
+ assert(bindingOwnerSession);
+ assert.equal(await bindingOwnerApp._acquireSuiteRecoveryClaim('single', bindingOwnerSession), true);
+ bindingOwnerApp.currentSuiteSession = bindingOwnerSession;
+ bindingOwnerApp.suiteExamMap = new Map(bindingOwnerSession.sequence.map((entry) => [entry.examId, bindingOwnerSession.id]));
+ bindingOwnerApp.examWindows = new Map([['p1', {
+ window: { closed: false },
+ suiteSessionId: null,
+ expectedSessionId: 'ordinary-child-session',
+ windowSessionToken: 'ordinary-window-token',
+ sessionGeneration: 12
+ }]]);
+ assert.deepEqual(
+ structuredClone(bindingOwnerApp._buildSuiteWindowBinding(bindingOwnerSession)),
+ priorSuiteBinding,
+ 'a managed ordinary same-exam registration must not replace the persisted suite binding'
+ );
+ assert.equal(await bindingOwnerApp._commitSuiteRecovery(bindingOwnerSession, { notify: false }), true);
+ assert.deepEqual(
+ bindingOwnerHarness.activeSessionStore.get(bindingOwnerSession.id).windowBinding,
+ priorSuiteBinding,
+ 'the durable suite commit must retain the exact prior suite-owned binding'
+ );
+ assert.equal(await bindingOwnerApp._releaseSuiteRecoveryClaim('single', bindingOwnerSession), true);
+
+ const sharedSingleLocks = createExclusiveLockManager();
+ const sharedSingleDurable = new Map([[matchedDurable.id, structuredClone(matchedDurable)]]);
+ const sharedSingleFences = new Map();
+ const copiedSingleTabA = createHarness({
+ locks: sharedSingleLocks,
+ activeSessionStore: sharedSingleDurable,
+ recoveryFenceStore: sharedSingleFences
+ });
+ const copiedSingleTabB = createHarness({
+ locks: sharedSingleLocks,
+ activeSessionStore: sharedSingleDurable,
+ recoveryFenceStore: sharedSingleFences
+ });
+ copiedSingleTabA.sessionStore.save('simulation', structuredClone(matchedLocalWal));
+ copiedSingleTabB.sessionStore.save('simulation', structuredClone(matchedLocalWal));
+ const copiedSingleAppA = copiedSingleTabA.makeApp();
+ const copiedSingleAppB = copiedSingleTabB.makeApp();
+ const staleSingleA = copiedSingleAppA._restoreSessionFromStorage();
+ const staleSingleB = copiedSingleAppB._restoreSessionFromStorage();
+ copiedSingleAppA.initializeSuiteMode();
+ copiedSingleAppB.initializeSuiteMode();
+ assert.equal(copiedSingleAppA.currentSuiteSession, null, 'copyable WAL must remain quarantined before lease acquisition');
+ assert.equal(copiedSingleAppB.currentSuiteSession, null, 'both copied tabs must quarantine WAL synchronously');
+ await Promise.all([
+ copiedSingleAppA._ensureSuiteRecoveryReady(),
+ copiedSingleAppB._ensureSuiteRecoveryReady()
+ ]);
+ const singleWinner = copiedSingleAppA.currentSuiteSession ? copiedSingleAppA : copiedSingleAppB;
+ const singleLoser = singleWinner === copiedSingleAppA ? copiedSingleAppB : copiedSingleAppA;
+ const singleWinnerHarness = singleWinner === copiedSingleAppA ? copiedSingleTabA : copiedSingleTabB;
+ const singleLoserHarness = singleWinner === copiedSingleAppA ? copiedSingleTabB : copiedSingleTabA;
+ const staleSingleLoser = singleWinner === copiedSingleAppA ? staleSingleB : staleSingleA;
+ assert(singleWinner.currentSuiteSession, 'exactly one copied tab must own the single-suite recovery');
+ assert.equal(singleLoser.currentSuiteSession, null);
+ assert.equal(sharedSingleLocks.held.size, 1, 'the winner must hold its lease for the live recovery lifetime');
+ assert.equal(sharedSingleLocks.calls[0].name, sharedSingleLocks.calls[1].name, 'single copied WALs must contend on one exact-id lock');
+ assert.equal(
+ singleLoserHarness.sessionStore.peek('simulation').recoveryLeaseContended,
+ true,
+ 'the loser must retain only a non-secret contention marker for safe crash takeover'
+ );
+ const singleSaveCountBefore = copiedSingleTabA.recoveryCalls.save + copiedSingleTabB.recoveryCalls.save;
+ assert.equal(await singleLoser._commitSuiteRecovery(staleSingleLoser, { reason: 'copied-tab-stale-ref' }), false);
+ assert.equal(await singleWinner._commitSuiteRecovery(singleWinner.currentSuiteSession, { reason: 'lease-owner' }), true);
+ assert.equal(
+ copiedSingleTabA.recoveryCalls.save + copiedSingleTabB.recoveryCalls.save,
+ singleSaveCountBefore + 1,
+ 'only the object bound to the held claim may commit'
+ );
+ singleWinnerHarness.recoveryCalls.discardQueue.push(false);
+ assert.equal(await singleWinner._discardStoredSuiteSession(singleWinner.currentSuiteSession), false);
+ assert.equal(sharedSingleLocks.held.size, 1, 'failed durable discard must retain the lease for retry');
+ assert.equal(await singleWinner._discardStoredSuiteSession(singleWinner.currentSuiteSession), true);
+ assert.equal(sharedSingleLocks.held.size, 0, 'successful local cleanup must release the lease');
+
+ const staleSingleRetry = createHarness({
+ locks: sharedSingleLocks,
+ activeSessionStore: sharedSingleDurable,
+ recoveryFenceStore: sharedSingleFences
+ });
+ staleSingleRetry.sessionStore.save('simulation', structuredClone(singleLoserHarness.sessionStore.peek('simulation')));
+ const staleSingleRetryApp = staleSingleRetry.makeApp();
+ staleSingleRetryApp.initializeSuiteMode();
+ await staleSingleRetryApp._ensureSuiteRecoveryReady();
+ assert.equal(staleSingleRetryApp.currentSuiteSession, null, 'a contended WAL must not resurrect after the owner discarded durable state');
+ assert.equal(staleSingleRetry.sessionStore.peek('simulation'), null);
+ assert.equal(staleSingleRetry.recoveryCalls.save, 0);
+ assert.equal(sharedSingleLocks.held.size, 0);
+
+ const uncontendedPreSaveHarness = createHarness();
+ const uncontendedPreSaveWal = {
+ ...structuredClone(tabOwnedWal),
+ id: 'suite-uncontended-pre-first-save',
+ revision: 0,
+ lastUpdate: fixtureTimeBase + 2500
+ };
+ uncontendedPreSaveHarness.sessionStore.save('simulation', structuredClone(uncontendedPreSaveWal));
+ const uncontendedPreSaveApp = uncontendedPreSaveHarness.makeApp();
+ uncontendedPreSaveApp.initializeSuiteMode();
+ await uncontendedPreSaveApp._ensureSuiteRecoveryReady();
+ assert.equal(uncontendedPreSaveApp.currentSuiteSession.id, uncontendedPreSaveWal.id);
+ assert.equal(uncontendedPreSaveHarness.recoveryCalls.save, 1, 'an uncontended missing fence must migrate the first WAL');
+ assert.equal(
+ await uncontendedPreSaveApp._releaseSuiteRecoveryClaim('single', uncontendedPreSaveApp.currentSuiteSession),
+ true
+ );
+
+ const preSaveCrashLocks = createExclusiveLockManager();
+ const preSaveCrashDurable = new Map();
+ const preSaveCrashFences = new Map();
+ const preSaveCrashWal = {
+ ...structuredClone(tabOwnedWal),
+ id: 'suite-pre-first-save-crash',
+ revision: 0,
+ lastUpdate: fixtureTimeBase + 3000
+ };
+ const preSaveCrashTabA = createHarness({
+ locks: preSaveCrashLocks,
+ activeSessionStore: preSaveCrashDurable,
+ recoveryFenceStore: preSaveCrashFences
+ });
+ const preSaveCrashTabB = createHarness({
+ locks: preSaveCrashLocks,
+ activeSessionStore: preSaveCrashDurable,
+ recoveryFenceStore: preSaveCrashFences
+ });
+ preSaveCrashTabA.sessionStore.save('simulation', structuredClone(preSaveCrashWal));
+ preSaveCrashTabB.sessionStore.save('simulation', structuredClone(preSaveCrashWal));
+ preSaveCrashTabA.recoveryCalls.saveQueue.push(new Error('owner crashed before first durable save'));
+ preSaveCrashTabB.recoveryCalls.saveQueue.push(new Error('owner crashed before first durable save'));
+ const preSaveCrashAppA = preSaveCrashTabA.makeApp();
+ const preSaveCrashAppB = preSaveCrashTabB.makeApp();
+ preSaveCrashAppA.initializeSuiteMode();
+ preSaveCrashAppB.initializeSuiteMode();
+ await Promise.all([
+ preSaveCrashAppA._ensureSuiteRecoveryReady(),
+ preSaveCrashAppB._ensureSuiteRecoveryReady()
+ ]);
+ const preSaveCrashWinner = preSaveCrashAppA.currentSuiteSession ? preSaveCrashAppA : preSaveCrashAppB;
+ const preSaveCrashLoserHarness = preSaveCrashWinner === preSaveCrashAppA ? preSaveCrashTabB : preSaveCrashTabA;
+ assert(preSaveCrashWinner.currentSuiteSession, 'the first claimant keeps its WAL after a transient first-save failure');
+ assert.equal(preSaveCrashDurable.has(preSaveCrashWal.id), false);
+ assert.equal(preSaveCrashLoserHarness.sessionStore.peek('simulation').recoveryLeaseContended, true);
+ assert.equal(
+ await preSaveCrashWinner._releaseSuiteRecoveryClaim('single', preSaveCrashWinner.currentSuiteSession),
+ true,
+ 'simulated owner crash must release the browser-held lease'
+ );
+ assert.equal(preSaveCrashLocks.held.size, 0);
+
+ const preSaveCrashTakeover = createHarness({
+ locks: preSaveCrashLocks,
+ activeSessionStore: preSaveCrashDurable,
+ recoveryFenceStore: preSaveCrashFences
+ });
+ preSaveCrashTakeover.sessionStore.save(
+ 'simulation',
+ structuredClone(preSaveCrashLoserHarness.sessionStore.peek('simulation'))
+ );
+ const preSaveCrashTakeoverApp = preSaveCrashTakeover.makeApp();
+ preSaveCrashTakeoverApp.initializeSuiteMode();
+ await preSaveCrashTakeoverApp._ensureSuiteRecoveryReady();
+ assert(preSaveCrashTakeoverApp.currentSuiteSession, 'a missing fence must preserve and migrate the only crash WAL');
+ assert.equal(preSaveCrashTakeoverApp.currentSuiteSession.id, preSaveCrashWal.id);
+ assert.equal(preSaveCrashTakeover.recoveryCalls.save, 1);
+ assert.equal(preSaveCrashDurable.has(preSaveCrashWal.id), true);
+ assert.equal(
+ await preSaveCrashTakeoverApp._releaseSuiteRecoveryClaim(
+ 'single',
+ preSaveCrashTakeoverApp.currentSuiteSession
+ ),
+ true
+ );
+ assert.equal(preSaveCrashLocks.held.size, 0);
+
+ const expiredWalHarness = createHarness();
+ expiredWalHarness.sessionStore.save('simulation', {
+ ...structuredClone(tabOwnedWal),
+ id: 'suite-expired-copied-wal',
+ revision: 0,
+ lastUpdate: Date.now() - (31 * 24 * 60 * 60 * 1000)
+ });
+ const expiredWalApp = expiredWalHarness.makeApp();
+ expiredWalApp.initializeSuiteMode();
+ await expiredWalApp._ensureSuiteRecoveryReady();
+ assert.equal(expiredWalApp.currentSuiteSession, null, 'a copied WAL older than the durable recovery TTL must not revive');
+ assert.equal(expiredWalHarness.sessionStore.peek('simulation'), null);
+ assert.equal(expiredWalHarness.recoveryCalls.save, 0);
+
+ const fileFallbackHarness = createHarness({ protocol: 'file:' });
+ fileFallbackHarness.activeSessionStore.set(tabOwnedDurable.id, structuredClone(tabOwnedDurable));
+ const fileFallbackApp = fileFallbackHarness.makeApp();
+ fileFallbackApp.initializeSuiteMode();
+ await fileFallbackApp._ensureSuiteRecoveryReady();
+ assert.equal(fileFallbackApp.currentSuiteSession.id, tabOwnedDurable.id, 'file: must retain durable recovery fallback without a window WAL');
+
+ const shadowedSuiteOwnerHarness = createHarness();
+ shadowedSuiteOwnerHarness.sessionStore.save('simulation', structuredClone(tabOwnedWal));
+ shadowedSuiteOwnerHarness.recoveryCalls.listedItems = [{
+ schema: 'foreign-recovery-schema',
+ version: 1,
+ id: tabOwnedWal.id,
+ revision: 0,
+ lastUpdate: fixtureTimeBase + 3000
+ }, {
+ ...structuredClone(tabOwnedDurable),
+ revision: 9,
+ lastUpdate: fixtureTimeBase + 4000
+ }];
+ const shadowedSuiteOwnerApp = shadowedSuiteOwnerHarness.makeApp();
+ shadowedSuiteOwnerApp.initializeSuiteMode();
+ await shadowedSuiteOwnerApp._ensureSuiteRecoveryReady();
+ assert.equal(shadowedSuiteOwnerApp.currentSuiteSession, null, 'a later suite candidate must not bypass another-schema first ownership of the same AppData id');
+ assert.equal(shadowedSuiteOwnerHarness.sessionStore.peek('simulation'), null, 'unsafe same-id WAL must be cleared instead of migrated over the first owner');
+ assert.equal(shadowedSuiteOwnerHarness.recoveryCalls.save, 0);
+ assert.equal(shadowedSuiteOwnerHarness.recoveryCalls.discard, 0);
+
+ const corruptFirstOwnerHarness = createHarness();
+ corruptFirstOwnerHarness.sessionStore.save('simulation', structuredClone(tabOwnedWal));
+ const corruptFirstOwner = {
+ ...structuredClone(tabOwnedDurable),
+ status: 'invalid',
+ revision: 4,
+ lastUpdate: fixtureTimeBase + 3000
+ };
+ const laterValidDuplicate = {
+ ...structuredClone(tabOwnedDurable),
+ currentIndex: 1,
+ activeExamId: 'p2',
+ revision: 9,
+ lastUpdate: fixtureTimeBase + 4000
+ };
+ corruptFirstOwnerHarness.recoveryCalls.listedItems = [corruptFirstOwner, laterValidDuplicate];
+ corruptFirstOwnerHarness.recoveryCalls.saveQueue.push((value, options) => {
+ assert.equal(options.expectedEntityRevision, 4, 'repair must CAS against the actual corrupt first owner');
+ assert(value.revision >= 5);
+ assert.equal(value.activeExamId, tabOwnedWal.activeExamId, 'repair must use this tab WAL, not the later duplicate payload');
+ return { committed: true };
+ });
+ const corruptFirstOwnerApp = corruptFirstOwnerHarness.makeApp();
+ corruptFirstOwnerApp.initializeSuiteMode();
+ await corruptFirstOwnerApp._ensureSuiteRecoveryReady();
+ assert.equal(corruptFirstOwnerApp.currentSuiteSession.id, tabOwnedWal.id);
+ assert.equal(corruptFirstOwnerApp.currentSuiteSession.activeExamId, tabOwnedWal.activeExamId);
+ assert.notEqual(corruptFirstOwnerApp.currentSuiteSession._suiteRecoveryWritesBlocked, true);
+ assert.equal(corruptFirstOwnerHarness.recoveryCalls.discard, 0, 'matching corrupt durable must be repaired without a tombstone');
+
+ const multiSuiteSession = {
+ id: 'multi-suite-tab-owner',
+ baseExamId: 'listening-multi-tab-owner',
+ status: 'active',
+ startTime: 1000,
+ suiteResults: [],
+ expectedSuiteCount: 2,
+ metadata: {},
+ lastUpdate: fixtureTimeBase + 1000,
+ revision: 3,
+ finalizeOperationId: null,
+ finalizeRecord: null
+ };
+ const multiSuiteWal = {
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ sessions: [structuredClone(multiSuiteSession)],
+ updatedAt: fixtureTimeBase + 1000
+ };
+ const multiSuiteDurable = {
+ ...structuredClone(multiSuiteWal),
+ id: multiSuiteSession.id,
+ revision: multiSuiteSession.revision
+ };
+
+ for (const enumerationFailure of ['missing', 'throw']) {
+ const unavailableMultiLocks = createExclusiveLockManager();
+ const unavailableMultiHarness = createHarness({
+ locks: unavailableMultiLocks,
+ ...(enumerationFailure === 'missing'
+ ? { listActiveSessionsUnavailable: true }
+ : { listActiveSessionsError: new Error('multi recovery enumeration failed') })
+ });
+ unavailableMultiHarness.sessionStore.save('multi-suite-practice', structuredClone(multiSuiteWal));
+ unavailableMultiHarness.recoveryFenceStore.set(multiSuiteSession.id, {
+ id: multiSuiteSession.id,
+ exists: true,
+ tombstoned: true,
+ revision: multiSuiteSession.revision + 1
+ });
+ const unavailableMultiApp = unavailableMultiHarness.makeApp();
+ unavailableMultiApp.initializeSuiteMode();
+ await unavailableMultiApp._ensureSuiteRecoveryReady();
+ assert.equal(
+ unavailableMultiApp.multiSuiteSessionsMap.has(multiSuiteSession.baseExamId),
+ false,
+ `HTTP multi WAL must remain quarantined when durable enumeration is ${enumerationFailure}`
+ );
+ assert.equal(unavailableMultiHarness.recoveryCalls.save, 0);
+ assert.equal(
+ unavailableMultiHarness.sessionStore.peek('multi-suite-practice').sessions[0].id,
+ multiSuiteSession.id
+ );
+ assert.equal(unavailableMultiLocks.held.size, 0);
+ }
+
+ const crossKindLocks = createExclusiveLockManager();
+ const crossKindHarness = createHarness({ locks: crossKindLocks });
+ const crossKindId = tabOwnedWal.id;
+ const crossKindMultiSession = {
+ ...structuredClone(multiSuiteSession),
+ id: crossKindId,
+ baseExamId: 'listening-cross-kind-owner'
+ };
+ const crossKindMultiWal = {
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ sessions: [structuredClone(crossKindMultiSession)],
+ updatedAt: fixtureTimeBase + 2000
+ };
+ const crossKindMultiDurable = {
+ ...structuredClone(crossKindMultiWal),
+ id: crossKindId,
+ revision: crossKindMultiSession.revision
+ };
+ crossKindHarness.sessionStore.save('simulation', structuredClone(tabOwnedWal));
+ crossKindHarness.sessionStore.save('multi-suite-practice', structuredClone(crossKindMultiWal));
+ crossKindHarness.recoveryCalls.listedItems = [
+ structuredClone(crossKindMultiDurable),
+ structuredClone(tabOwnedDurable)
+ ];
+ const crossKindApp = crossKindHarness.makeApp();
+ const crossKindStaleSingle = crossKindApp._restoreSessionFromStorage();
+ crossKindApp.initializeSuiteMode();
+ await crossKindApp._ensureSuiteRecoveryReady();
+ const crossKindRestoredMulti = crossKindApp.multiSuiteSessionsMap.get(crossKindMultiSession.baseExamId);
+ assert.equal(crossKindApp.currentSuiteSession, null, 'a multi-suite first owner must reject the same-id single WAL');
+ assert(crossKindRestoredMulti, 'the authoritative same-id multi WAL must retry its claim after single releases it');
+ assert.equal(crossKindRestoredMulti.id, crossKindId);
+ assert.equal(crossKindApp._ownsMultiSuiteRecoveryOwnership(crossKindRestoredMulti), true);
+ assert.equal(crossKindLocks.held.size, 2);
+ assert(crossKindLocks.held.has(crossKindApp._suiteRecoveryClaimName(crossKindId)));
+ assert(crossKindLocks.held.has(crossKindApp._multiSuiteBaseClaimName(crossKindMultiSession.baseExamId)));
+ assert.equal(crossKindHarness.recoveryCalls.save, 0);
+ assert.equal(crossKindHarness.recoveryCalls.discard, 0);
+ assert.equal(
+ await crossKindApp._commitSuiteRecovery(crossKindStaleSingle, { notify: false }),
+ false,
+ 'the stale single object must not borrow the multi-suite claim'
+ );
+ const crossKindSaveCount = crossKindHarness.recoveryCalls.save;
+ crossKindRestoredMulti.revision += 1;
+ assert.equal(await crossKindApp._commitMultiSuiteRecovery(crossKindRestoredMulti), true);
+ assert.equal(crossKindHarness.recoveryCalls.save, crossKindSaveCount + 1);
+ assert.equal(crossKindHarness.activeSessionStore.get(crossKindId).schema, 'multi-suite-sessions-v2');
+ assert.equal(await crossKindApp._releaseSuiteRecoveryClaim('single', crossKindStaleSingle), false);
+ assert.equal(crossKindLocks.held.size, 2, 'a stale cross-kind release must not drop either multi ownership lease');
+ assert.equal(await crossKindApp._releaseSuiteRecoveryClaim('multi', crossKindRestoredMulti), true);
+ assert.equal(crossKindLocks.held.size, 0);
+
+ const reverseCrossKindLocks = createExclusiveLockManager();
+ const reverseCrossKindHarness = createHarness({ locks: reverseCrossKindLocks });
+ const reverseCrossKindId = 'suite-reverse-cross-kind-owner';
+ const reverseCrossKindSingle = {
+ ...structuredClone(tabOwnedDurable),
+ id: reverseCrossKindId,
+ lastUpdate: fixtureTimeBase + 3000
+ };
+ const reverseCrossKindMultiSession = {
+ ...structuredClone(multiSuiteSession),
+ id: reverseCrossKindId,
+ baseExamId: 'listening-reverse-cross-kind-owner'
+ };
+ const reverseCrossKindMultiWal = {
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ sessions: [structuredClone(reverseCrossKindMultiSession)],
+ updatedAt: fixtureTimeBase + 3000
+ };
+ reverseCrossKindHarness.sessionStore.save('multi-suite-practice', reverseCrossKindMultiWal);
+ reverseCrossKindHarness.recoveryCalls.listedItems = [structuredClone(reverseCrossKindSingle)];
+ const reverseCrossKindApp = reverseCrossKindHarness.makeApp();
+ const reverseCrossKindStaleMulti = reverseCrossKindApp
+ ._restoreMultiSuiteSessionsFromStorage({ install: false })[0];
+ reverseCrossKindApp.initializeSuiteMode();
+ await reverseCrossKindApp._ensureSuiteRecoveryReady();
+ assert(reverseCrossKindApp.currentSuiteSession, 'durable single must retry after the wrong-kind multi WAL releases the shared id');
+ assert.equal(reverseCrossKindApp.currentSuiteSession.id, reverseCrossKindId);
+ assert.equal(reverseCrossKindApp.multiSuiteSessionsMap.has(reverseCrossKindMultiSession.baseExamId), false);
+ assert.equal(reverseCrossKindApp._ownsSuiteRecoveryClaim('single', reverseCrossKindApp.currentSuiteSession), true);
+ assert.equal(reverseCrossKindLocks.held.size, 1);
+ assert.equal(
+ await reverseCrossKindApp._commitMultiSuiteRecovery(reverseCrossKindStaleMulti),
+ false,
+ 'the rejected wrong-kind WAL object must not reacquire the durable single identity'
+ );
+ assert.equal(await reverseCrossKindApp._releaseSuiteRecoveryClaim(
+ 'single',
+ reverseCrossKindApp.currentSuiteSession
+ ), true);
+ assert.equal(reverseCrossKindLocks.held.size, 0);
+
+ const aliasClaimLocks = createExclusiveLockManager();
+ const aliasClaimHarness = createHarness({ locks: aliasClaimLocks });
+ const aliasClaimApp = aliasClaimHarness.makeApp();
+ aliasClaimApp.multiSuiteSessionsMap = new Map();
+ const aliasCanonicalBase = 'listening-alias-claim-owner';
+ const aliasLosingSession = {
+ ...structuredClone(multiSuiteSession),
+ id: 'multi-alias-losing-object',
+ baseExamId: ` ${aliasCanonicalBase} `,
+ _restoredFromWindowSession: true,
+ _suiteRecoveryTimestampKnown: true
+ };
+ const aliasWinningSession = {
+ ...structuredClone(multiSuiteSession),
+ id: 'multi-alias-winning-object',
+ baseExamId: aliasCanonicalBase,
+ _restoredFromWindowSession: true,
+ _suiteRecoveryTimestampKnown: true
+ };
+ assert.equal(await aliasClaimApp._acquireSuiteRecoveryClaim('multi', aliasLosingSession), true);
+ assert.equal(await aliasClaimApp._acquireSuiteRecoveryClaim('multi', aliasWinningSession), true);
+ aliasClaimApp.multiSuiteSessionsMap.set(aliasLosingSession.baseExamId, aliasLosingSession);
+ aliasClaimApp.multiSuiteSessionsMap.set(aliasCanonicalBase, aliasWinningSession);
+ const aliasDurableMarker = {
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: 'multi-alias-foreign-marker',
+ revision: 1,
+ sessions: [{ baseExamId: aliasCanonicalBase }]
+ };
+ await aliasClaimApp._restorePersistentMultiSuiteSessions([aliasDurableMarker], []);
+ assert.equal(aliasClaimApp.multiSuiteSessionsMap.get(aliasCanonicalBase), aliasWinningSession);
+ assert.equal(aliasLosingSession._suiteRecoveryClaimRejected, true);
+ assert.equal(aliasLosingSession._suiteRecoveryWritesBlocked, true);
+ const aliasSaveCount = aliasClaimHarness.recoveryCalls.save;
+ assert.equal(await aliasClaimApp._commitMultiSuiteRecovery(aliasLosingSession), false);
+ assert.equal(aliasClaimHarness.recoveryCalls.save, aliasSaveCount, 'an evicted alias object must not reacquire after release');
+ assert.equal(aliasClaimApp._ownsSuiteRecoveryClaim('multi', aliasWinningSession), true);
+ assert.equal(await aliasClaimApp._releaseSuiteRecoveryClaim('multi', aliasWinningSession), true);
+
+ const sameObjectAliasSession = {
+ ...structuredClone(multiSuiteSession),
+ id: 'multi-alias-same-object',
+ baseExamId: ` ${aliasCanonicalBase}-same `,
+ _restoredFromWindowSession: true,
+ _suiteRecoveryTimestampKnown: true
+ };
+ const sameObjectCanonicalBase = `${aliasCanonicalBase}-same`;
+ assert.equal(await aliasClaimApp._acquireSuiteRecoveryClaim('multi', sameObjectAliasSession), true);
+ aliasClaimApp.multiSuiteSessionsMap.set(sameObjectAliasSession.baseExamId, sameObjectAliasSession);
+ aliasClaimApp.multiSuiteSessionsMap.set(sameObjectCanonicalBase, sameObjectAliasSession);
+ await aliasClaimApp._restorePersistentMultiSuiteSessions([{
+ ...aliasDurableMarker,
+ id: 'multi-alias-same-object-marker',
+ sessions: [{ baseExamId: sameObjectCanonicalBase }]
+ }], []);
+ assert.equal(aliasClaimApp.multiSuiteSessionsMap.get(sameObjectCanonicalBase), sameObjectAliasSession);
+ assert.equal(aliasClaimApp._ownsSuiteRecoveryClaim('multi', sameObjectAliasSession), true);
+ assert.notEqual(sameObjectAliasSession._suiteRecoveryClaimRejected, true);
+ assert.equal(await aliasClaimApp._releaseSuiteRecoveryClaim('multi', sameObjectAliasSession), true);
+ assert.equal(aliasClaimLocks.held.size, 0);
+
+ const durableOnlyMultiLocks = createExclusiveLockManager();
+ const durableOnlyMultiBase = 'listening-durable-only-owner';
+ const olderDurableMultiSession = {
+ ...structuredClone(multiSuiteSession),
+ id: 'multi-durable-only-older',
+ baseExamId: durableOnlyMultiBase,
+ lastUpdate: fixtureTimeBase + 500
+ };
+ const newestDurableMultiSession = {
+ ...structuredClone(multiSuiteSession),
+ id: 'multi-durable-only-newest',
+ baseExamId: durableOnlyMultiBase,
+ lastUpdate: fixtureTimeBase + 600
+ };
+ const durableMultiWrapper = (session) => ({
+ schema: 'multi-suite-sessions-v2',
+ version: 2,
+ id: session.id,
+ revision: session.revision,
+ sessions: [structuredClone(session)],
+ updatedAt: session.lastUpdate
+ });
+ const olderDurableMulti = durableMultiWrapper(olderDurableMultiSession);
+ const newestDurableMulti = durableMultiWrapper(newestDurableMultiSession);
+ const durableOnlyMultiStore = new Map([
+ [olderDurableMulti.id, olderDurableMulti],
+ [newestDurableMulti.id, newestDurableMulti]
+ ]);
+ const durableMultiOwnerHarness = createHarness({
+ locks: durableOnlyMultiLocks,
+ activeSessionStore: durableOnlyMultiStore
+ });
+ const durableMultiOwnerApp = durableMultiOwnerHarness.makeApp();
+ const heldNewestMulti = structuredClone(newestDurableMultiSession);
+ assert.equal(await durableMultiOwnerApp._acquireMultiSuiteRecoveryOwnership(heldNewestMulti), true);
+
+ const emptyMultiHttpHarness = createHarness({
+ locks: durableOnlyMultiLocks,
+ activeSessionStore: durableOnlyMultiStore
+ });
+ const emptyMultiHttpApp = emptyMultiHttpHarness.makeApp();
+ emptyMultiHttpApp.initializeSuiteMode();
+ await emptyMultiHttpApp._ensureSuiteRecoveryReady();
+ assert.equal(emptyMultiHttpApp.multiSuiteSessionsMap.has(durableOnlyMultiBase), false, 'a fresh HTTP tab must not bypass the newest per-base lease');
+ assert.equal(
+ durableOnlyMultiLocks.calls.filter((call) => call.name === emptyMultiHttpApp._suiteRecoveryClaimName(olderDurableMulti.id)).length,
+ 0,
+ 'newest per-base contention must not fall back to an older multi id'
+ );
+ const durableOnlyMultiPayload = {
+ suiteId: 'set-1',
+ totalSuites: 2,
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ };
+ assert.equal(
+ await emptyMultiHttpApp.handleMultiSuitePracticeComplete(
+ `${durableOnlyMultiBase}_set1`,
+ durableOnlyMultiPayload
+ ),
+ false,
+ 'the targeted handler must respect the live authoritative entity lock'
+ );
+ assert.equal(await durableMultiOwnerApp._releaseSuiteRecoveryClaim('multi', heldNewestMulti), true);
+ assert.equal(
+ await emptyMultiHttpApp.handleMultiSuitePracticeComplete(
+ `${durableOnlyMultiBase}_set1`,
+ durableOnlyMultiPayload
+ ),
+ true,
+ 'the same handler must take over the durable base after the entity owner releases'
+ );
+ const durableOnlyMultiRestored = emptyMultiHttpApp.multiSuiteSessionsMap.get(durableOnlyMultiBase);
+ assert.equal(durableOnlyMultiRestored.id, newestDurableMulti.id, 'the same tab must take over the newest per-base durable after release');
+ assert.equal(durableOnlyMultiLocks.held.size, 2);
+ assert.equal(await emptyMultiHttpApp._releaseSuiteRecoveryClaim('multi', durableOnlyMultiRestored), true);
+ assert.equal(durableOnlyMultiLocks.held.size, 0, 'older durable ids must never leave unused claims behind');
+
+ const handlerTakeoverLocks = createExclusiveLockManager();
+ const handlerTakeoverSession = {
+ ...structuredClone(multiSuiteSession),
+ id: 'multi-handler-crash-takeover',
+ baseExamId: 'listening-multi-handler-crash-takeover',
+ expectedSuiteCount: 2,
+ suiteResults: [],
+ lastUpdate: fixtureTimeBase + 700
+ };
+ const handlerTakeoverDurable = durableMultiWrapper(handlerTakeoverSession);
+ const handlerTakeoverStore = new Map([
+ [handlerTakeoverDurable.id, structuredClone(handlerTakeoverDurable)]
+ ]);
+ const handlerOwnerHarness = createHarness({
+ locks: handlerTakeoverLocks,
+ activeSessionStore: handlerTakeoverStore
+ });
+ const handlerOwnerApp = handlerOwnerHarness.makeApp();
+ const heldHandlerOwner = structuredClone(handlerTakeoverSession);
+ assert.equal(await handlerOwnerApp._acquireMultiSuiteRecoveryOwnership(heldHandlerOwner), true);
+
+ const handlerRetryHarness = createHarness({
+ locks: handlerTakeoverLocks,
+ activeSessionStore: handlerTakeoverStore
+ });
+ const handlerRetryApp = handlerRetryHarness.makeApp();
+ handlerRetryApp.initializeSuiteMode();
+ await handlerRetryApp._ensureSuiteRecoveryReady();
+ assert.equal(
+ handlerRetryApp.multiSuiteSessionsMap.has(handlerTakeoverSession.baseExamId),
+ false,
+ 'a live owner must keep the durable base quarantined in the retrying tab'
+ );
+ const handlerPayload = {
+ suiteId: 'set-1',
+ totalSuites: 2,
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ };
+ const canonicalReplayLocks = createExclusiveLockManager();
+ const canonicalReplayHarness = createHarness({
+ locks: canonicalReplayLocks,
+ activeSessionStore: new Map(),
+ realisticRecoveryStore: true
+ });
+ const canonicalReplayApp = canonicalReplayHarness.makeApp();
+ canonicalReplayApp.initializeSuiteMode();
+ await canonicalReplayApp._ensureSuiteRecoveryReady();
+ const canonicalReplayBase = 'listening-multi-canonical-replay';
+ const canonicalReplayPayload = {
+ ...handlerPayload,
+ sessionId: 'canonical-child-session',
+ submissionId: 'canonical-child-submission'
+ };
+ canonicalReplayApp._generateMultiSuiteSessionId = () => 'multi-canonical-replay-empty-owner';
+ canonicalReplayApp._listPracticeRecordsViaAPI = async () => [{
+ multiSuite: true,
+ examId: canonicalReplayBase,
+ suiteEntries: [{
+ suiteId: canonicalReplayPayload.suiteId,
+ metadata: {
+ sessionId: canonicalReplayPayload.sessionId,
+ submissionId: canonicalReplayPayload.submissionId
+ }
+ }]
+ }];
+ assert.equal(
+ await canonicalReplayApp.handleMultiSuitePracticeComplete(
+ `${canonicalReplayBase}_set1`,
+ canonicalReplayPayload
+ ),
+ true,
+ 'a canonical durable receipt must ACK a stale child without starting a new base owner'
+ );
+ assert.equal(canonicalReplayApp.multiSuiteSessionsMap.has(canonicalReplayBase), false);
+ assert.equal(canonicalReplayHarness.recoveryCalls.save, 0);
+ assert.equal(canonicalReplayHarness.sessionStore.peek('multi-suite-practice'), null);
+ assert.equal(canonicalReplayLocks.held.size, 0, 'canonical replay must release the provisional base and exact leases');
+
+ assert.equal(
+ await handlerRetryApp.handleMultiSuitePracticeComplete(
+ `${handlerTakeoverSession.baseExamId}_set1`,
+ handlerPayload
+ ),
+ false,
+ 'the production completion path must not create a new id while the durable owner is alive'
+ );
+ assert.equal(handlerRetryHarness.recoveryCalls.save, 0);
+ assert.equal(handlerRetryApp.multiSuiteSessionsMap.has(handlerTakeoverSession.baseExamId), false);
+
+ assert.equal(await handlerOwnerApp._releaseSuiteRecoveryClaim('multi', heldHandlerOwner), true);
+ const idleOtherBaseSession = {
+ ...structuredClone(handlerTakeoverSession),
+ id: 'multi-idle-other-base',
+ baseExamId: 'listening-multi-idle-other-base',
+ lastUpdate: fixtureTimeBase + 800
+ };
+ const idleOtherBaseDurable = durableMultiWrapper(idleOtherBaseSession);
+ handlerTakeoverStore.set(idleOtherBaseDurable.id, structuredClone(idleOtherBaseDurable));
+ const callsBeforeIdleInitialize = handlerTakeoverLocks.calls.length;
+ const idleMultiHarness = createHarness({
+ locks: handlerTakeoverLocks,
+ activeSessionStore: handlerTakeoverStore
+ });
+ const idleMultiApp = idleMultiHarness.makeApp();
+ idleMultiApp.initializeSuiteMode();
+ await idleMultiApp._ensureSuiteRecoveryReady();
+ assert.equal(idleMultiApp.multiSuiteSessionsMap.size, 0, 'an idle HTTP tab must not install durable-only multi bases');
+ assert.equal(handlerTakeoverLocks.calls.length, callsBeforeIdleInitialize, 'idle startup must not claim any durable-only base or entity');
+ assert.equal(handlerTakeoverLocks.held.size, 0);
+ assert.equal(
+ await handlerRetryApp.handleMultiSuitePracticeComplete(
+ `${handlerTakeoverSession.baseExamId}_set1`,
+ handlerPayload
+ ),
+ true,
+ 'the same app instance must refresh and merge into the released durable owner'
+ );
+ const handlerTakeoverRestored = handlerRetryApp.multiSuiteSessionsMap.get(handlerTakeoverSession.baseExamId);
+ assert(handlerTakeoverRestored);
+ assert.equal(handlerTakeoverRestored.id, handlerTakeoverSession.id, 'retry must preserve the durable entity identity');
+ assert.equal(handlerTakeoverRestored.suiteResults.length, 1);
+ assert.equal(handlerTakeoverRestored.suiteResults[0].suiteId, handlerPayload.suiteId);
+ assert.equal(handlerRetryHarness.recoveryCalls.save, 1);
+ assert.equal(handlerTakeoverStore.get(handlerTakeoverSession.id).id, handlerTakeoverSession.id);
+ assert.equal(await handlerRetryApp._releaseSuiteRecoveryClaim('multi', handlerTakeoverRestored), true);
+ assert.equal(handlerTakeoverLocks.held.size, 0);
+
+ const emptySameBaseLocks = createExclusiveLockManager();
+ const emptySameBaseStore = new Map();
+ const emptySameBaseHarnessA = createHarness({
+ locks: emptySameBaseLocks,
+ activeSessionStore: emptySameBaseStore,
+ realisticRecoveryStore: true
+ });
+ const emptySameBaseHarnessB = createHarness({
+ locks: emptySameBaseLocks,
+ activeSessionStore: emptySameBaseStore,
+ realisticRecoveryStore: true
+ });
+ const emptySameBaseAppA = emptySameBaseHarnessA.makeApp();
+ const emptySameBaseAppB = emptySameBaseHarnessB.makeApp();
+ emptySameBaseAppA.initializeSuiteMode();
+ emptySameBaseAppB.initializeSuiteMode();
+ await Promise.all([
+ emptySameBaseAppA._ensureSuiteRecoveryReady(),
+ emptySameBaseAppB._ensureSuiteRecoveryReady()
+ ]);
+ emptySameBaseHarnessA.recoveryCalls.listQueue.push([]);
+ emptySameBaseHarnessB.recoveryCalls.listQueue.push([]);
+ emptySameBaseAppA._generateMultiSuiteSessionId = () => 'multi-empty-same-base-a';
+ emptySameBaseAppB._generateMultiSuiteSessionId = () => 'multi-empty-same-base-b';
+ const emptySameBase = 'listening-multi-empty-base-race';
+ const emptySameBaseOutcomes = await Promise.all([
+ emptySameBaseAppA.handleMultiSuitePracticeComplete(`${emptySameBase}_set1`, handlerPayload),
+ emptySameBaseAppB.handleMultiSuitePracticeComplete(`${emptySameBase}_set1`, handlerPayload)
+ ]);
+ assert.deepEqual([...emptySameBaseOutcomes].sort(), [false, true], 'only one empty tab may create the first entity for a canonical base');
+ assert.equal(
+ emptySameBaseAppA.multiSuiteSessionsMap.size + emptySameBaseAppB.multiSuiteSessionsMap.size,
+ 1
+ );
+ assert.equal(emptySameBaseStore.size, 1, 'same-base first submit must persist exactly one entity id');
+ assert.equal(
+ emptySameBaseHarnessA.recoveryCalls.save + emptySameBaseHarnessB.recoveryCalls.save,
+ 1
+ );
+ const emptySameBaseWinner = emptySameBaseAppA.multiSuiteSessionsMap.has(emptySameBase)
+ ? emptySameBaseAppA
+ : emptySameBaseAppB;
+ assert.equal(
+ await emptySameBaseWinner._releaseSuiteRecoveryClaim(
+ 'multi',
+ emptySameBaseWinner.multiSuiteSessionsMap.get(emptySameBase)
+ ),
+ true
+ );
+ assert.equal(emptySameBaseLocks.held.size, 0);
+
+ const parallelBaseLocks = createExclusiveLockManager();
+ const parallelBaseStore = new Map();
+ const parallelBaseHarnessA = createHarness({
+ locks: parallelBaseLocks,
+ activeSessionStore: parallelBaseStore,
+ realisticRecoveryStore: true
+ });
+ const parallelBaseHarnessB = createHarness({
+ locks: parallelBaseLocks,
+ activeSessionStore: parallelBaseStore,
+ realisticRecoveryStore: true
+ });
+ const parallelBaseAppA = parallelBaseHarnessA.makeApp();
+ const parallelBaseAppB = parallelBaseHarnessB.makeApp();
+ parallelBaseAppA.initializeSuiteMode();
+ parallelBaseAppB.initializeSuiteMode();
+ await Promise.all([
+ parallelBaseAppA._ensureSuiteRecoveryReady(),
+ parallelBaseAppB._ensureSuiteRecoveryReady()
+ ]);
+ parallelBaseHarnessA.recoveryCalls.listQueue.push([]);
+ parallelBaseHarnessB.recoveryCalls.listQueue.push([]);
+ parallelBaseAppA._generateMultiSuiteSessionId = () => 'multi-parallel-base-a';
+ parallelBaseAppB._generateMultiSuiteSessionId = () => 'multi-parallel-base-b';
+ const parallelBaseA = 'listening-multi-parallel-base-a';
+ const parallelBaseB = 'listening-multi-parallel-base-b';
+ assert.deepEqual(await Promise.all([
+ parallelBaseAppA.handleMultiSuitePracticeComplete(`${parallelBaseA}_set1`, handlerPayload),
+ parallelBaseAppB.handleMultiSuitePracticeComplete(`${parallelBaseB}_set1`, handlerPayload)
+ ]), [true, true], 'different canonical bases must remain independently writable');
+ assert.equal(parallelBaseStore.size, 2);
+ assert.equal(parallelBaseLocks.held.size, 4, 'each active base must retain one base lease and one exact entity lease');
+ assert(parallelBaseLocks.held.has(parallelBaseAppA._multiSuiteBaseClaimName(parallelBaseA)));
+ assert(parallelBaseLocks.held.has(parallelBaseAppB._multiSuiteBaseClaimName(parallelBaseB)));
+ assert.equal(await parallelBaseAppA._releaseSuiteRecoveryClaim(
+ 'multi',
+ parallelBaseAppA.multiSuiteSessionsMap.get(parallelBaseA)
+ ), true);
+ assert.equal(await parallelBaseAppB._releaseSuiteRecoveryClaim(
+ 'multi',
+ parallelBaseAppB.multiSuiteSessionsMap.get(parallelBaseB)
+ ), true);
+ assert.equal(parallelBaseLocks.held.size, 0);
+
+ const vanishedMultiLocks = createExclusiveLockManager();
+ const vanishedMultiHarness = createHarness({ locks: vanishedMultiLocks });
+ const vanishedMultiApp = vanishedMultiHarness.makeApp();
+ vanishedMultiApp.initializeSuiteMode();
+ await vanishedMultiApp._ensureSuiteRecoveryReady();
+ vanishedMultiHarness.recoveryCalls.listQueue.push(
+ [structuredClone(newestDurableMulti)],
+ []
+ );
+ assert.equal(
+ await vanishedMultiApp.handleMultiSuitePracticeComplete(
+ `${durableOnlyMultiBase}_set1`,
+ durableOnlyMultiPayload
+ ),
+ false,
+ 'a targeted durable clone that vanishes under its leases must not be exposed'
+ );
+ assert.equal(vanishedMultiApp.multiSuiteSessionsMap.has(durableOnlyMultiBase), false);
+ assert.equal(vanishedMultiHarness.recoveryCalls.save, 0, 'a vanished durable multi clone must not expected=0 resurrect itself');
+ assert.equal(vanishedMultiHarness.recoveryCalls.list, 3);
+ assert.equal(vanishedMultiLocks.held.size, 0);
+
+ const matchedMultiHttpHarness = createHarness();
+ matchedMultiHttpHarness.sessionStore.save('multi-suite-practice', structuredClone(multiSuiteWal));
+ matchedMultiHttpHarness.activeSessionStore.set(multiSuiteDurable.id, structuredClone(multiSuiteDurable));
+ const matchedMultiHttpApp = matchedMultiHttpHarness.makeApp();
+ matchedMultiHttpApp.initializeSuiteMode();
+ await matchedMultiHttpApp._ensureSuiteRecoveryReady();
+ const matchedMultiSession = matchedMultiHttpApp.multiSuiteSessionsMap.get(multiSuiteSession.baseExamId);
+ assert(matchedMultiSession, 'matching HTTP multi-suite WAL identity must allow durable recovery');
+ assert.equal(matchedMultiSession.id, multiSuiteSession.id);
+ assert.equal(matchedMultiSession._lastDurableRecoveryRevision, multiSuiteSession.revision);
+
+ const replacedWalLocks = createExclusiveLockManager();
+ const replacedWalSession = {
+ ...structuredClone(multiSuiteSession),
+ id: 'multi-suite-replaced-window-wal',
+ lastUpdate: fixtureTimeBase
+ };
+ const replacedWalHarness = createHarness({
+ locks: replacedWalLocks,
+ activeSessionStore: new Map([[multiSuiteDurable.id, structuredClone(multiSuiteDurable)]])
+ });
+ replacedWalHarness.sessionStore.save('multi-suite-practice', {
+ ...structuredClone(multiSuiteWal),
+ sessions: [replacedWalSession]
+ });
+ const replacedWalApp = replacedWalHarness.makeApp();
+ replacedWalApp.initializeSuiteMode();
+ await replacedWalApp._ensureSuiteRecoveryReady();
+ const replacedWalOwner = replacedWalApp.multiSuiteSessionsMap.get(multiSuiteSession.baseExamId);
+ assert.equal(replacedWalOwner.id, multiSuiteSession.id, 'the newest durable identity must replace a stale same-base WAL id');
+ assert.equal(replacedWalLocks.held.size, 2, 'only the installed base and exact durable leases may remain held');
+ assert(replacedWalLocks.held.has(replacedWalApp._multiSuiteBaseClaimName(multiSuiteSession.baseExamId)));
+ assert(replacedWalLocks.held.has(replacedWalApp._suiteRecoveryClaimName(multiSuiteSession.id)));
+ assert.equal(
+ replacedWalLocks.held.has(replacedWalApp._suiteRecoveryClaimName(replacedWalSession.id)),
+ false,
+ 'the displaced WAL exact-id lease must be released after the base transfers'
+ );
+ assert.equal(await replacedWalApp._releaseSuiteRecoveryClaim('multi', replacedWalOwner), true);
+ assert.equal(replacedWalLocks.held.size, 0);
+
+ let signalExactRequest;
+ const exactRequestStarted = new Promise((resolve) => { signalExactRequest = resolve; });
+ const halfLockManager = {
+ held: new Map(),
+ async request(name, lockOptions, callback) {
+ assert.equal(lockOptions.mode, 'exclusive');
+ assert.equal(lockOptions.ifAvailable, true);
+ const normalizedName = String(name);
+ const lock = { name: normalizedName, mode: 'exclusive' };
+ if (normalizedName.includes(':multi-suite-base:')) {
+ this.held.set(normalizedName, lock);
+ void callback(lock);
+ await exactRequestStarted;
+ this.held.delete(normalizedName);
+ throw new Error('simulated base lock loss while exact acquisition is pending');
+ }
+ signalExactRequest();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ this.held.set(normalizedName, lock);
+ try {
+ return await callback(lock);
+ } finally {
+ if (this.held.get(normalizedName) === lock) this.held.delete(normalizedName);
+ }
+ }
+ };
+ const halfLockHarness = createHarness({ locks: halfLockManager });
+ const halfLockApp = halfLockHarness.makeApp();
+ const halfLockSession = {
+ ...structuredClone(multiSuiteSession),
+ id: 'multi-half-lock-race',
+ baseExamId: 'listening-multi-half-lock-race'
+ };
+ assert.equal(
+ await halfLockApp._acquireMultiSuiteRecoveryOwnership(halfLockSession),
+ false,
+ 'an exact lease acquired after unexpected base loss must not become an owner'
+ );
+ assert.equal(halfLockManager.held.size, 0, 'late exact acquisition must be released instead of leaking a half-lock');
+ assert.equal(halfLockApp._ownsSuiteRecoveryClaim('multi', halfLockSession), false);
+
+ const sharedMultiLocks = createExclusiveLockManager();
+ const sharedMultiDurable = new Map([[multiSuiteDurable.id, structuredClone(multiSuiteDurable)]]);
+ const copiedMultiTabA = createHarness({ locks: sharedMultiLocks, activeSessionStore: sharedMultiDurable });
+ const copiedMultiTabB = createHarness({ locks: sharedMultiLocks, activeSessionStore: sharedMultiDurable });
+ copiedMultiTabA.sessionStore.save('multi-suite-practice', structuredClone(multiSuiteWal));
+ copiedMultiTabB.sessionStore.save('multi-suite-practice', structuredClone(multiSuiteWal));
+ const copiedMultiAppA = copiedMultiTabA.makeApp();
+ const copiedMultiAppB = copiedMultiTabB.makeApp();
+ const staleMultiA = copiedMultiAppA._restoreMultiSuiteSessionsFromStorage({ install: false })[0];
+ const staleMultiB = copiedMultiAppB._restoreMultiSuiteSessionsFromStorage({ install: false })[0];
+ copiedMultiAppA.initializeSuiteMode();
+ copiedMultiAppB.initializeSuiteMode();
+ assert.equal(copiedMultiAppA.multiSuiteSessionsMap.size, 0, 'multi WAL must remain quarantined before claim acquisition');
+ assert.equal(copiedMultiAppB.multiSuiteSessionsMap.size, 0);
+ await Promise.all([
+ copiedMultiAppA._ensureSuiteRecoveryReady(),
+ copiedMultiAppB._ensureSuiteRecoveryReady()
+ ]);
+ const multiWinner = copiedMultiAppA.multiSuiteSessionsMap.has(multiSuiteSession.baseExamId)
+ ? copiedMultiAppA
+ : copiedMultiAppB;
+ const multiLoser = multiWinner === copiedMultiAppA ? copiedMultiAppB : copiedMultiAppA;
+ const multiWinnerHarness = multiWinner === copiedMultiAppA ? copiedMultiTabA : copiedMultiTabB;
+ const multiLoserHarness = multiWinner === copiedMultiAppA ? copiedMultiTabB : copiedMultiTabA;
+ const staleMultiLoser = multiWinner === copiedMultiAppA ? staleMultiB : staleMultiA;
+ const multiWinnerSession = multiWinner.multiSuiteSessionsMap.get(multiSuiteSession.baseExamId);
+ assert(multiWinnerSession);
+ assert.equal(multiLoser.multiSuiteSessionsMap.size, 0, 'the copied multi-suite loser must not become a runtime owner');
+ assert.equal(sharedMultiLocks.held.size, 2);
+ assert.equal(sharedMultiLocks.calls[0].name, sharedMultiLocks.calls[1].name);
+ assert.equal(
+ multiLoserHarness.sessionStore.peek('multi-suite-practice').sessions[0].recoveryLeaseContended,
+ true
+ );
+ multiWinnerSession.revision += 1;
+ staleMultiLoser.revision += 1;
+ const multiSaveCountBefore = copiedMultiTabA.recoveryCalls.save + copiedMultiTabB.recoveryCalls.save;
+ const [multiWinnerCommit, multiLoserCommit] = await Promise.all([
+ multiWinner._commitMultiSuiteRecovery(multiWinnerSession),
+ multiLoser._commitMultiSuiteRecovery(staleMultiLoser)
+ ]);
+ assert.deepEqual([multiWinnerCommit, multiLoserCommit], [true, false]);
+ assert.equal(
+ copiedMultiTabA.recoveryCalls.save + copiedMultiTabB.recoveryCalls.save,
+ multiSaveCountBefore + 1,
+ 'only the multi-suite object bound to the held claim may commit'
+ );
+ assert.equal(await multiWinner._releaseSuiteRecoveryClaim('multi', multiWinnerSession), true);
+ assert.equal(sharedMultiLocks.held.size, 0);
+
+ const uncontendedMultiHarness = createHarness();
+ const uncontendedMultiSession = {
+ ...structuredClone(multiSuiteSession),
+ id: 'multi-uncontended-pre-first-save',
+ baseExamId: 'listening-multi-uncontended-pre-first-save',
+ revision: 0
+ };
+ uncontendedMultiHarness.sessionStore.save('multi-suite-practice', {
+ ...structuredClone(multiSuiteWal),
+ sessions: [structuredClone(uncontendedMultiSession)]
+ });
+ const uncontendedMultiApp = uncontendedMultiHarness.makeApp();
+ uncontendedMultiApp.initializeSuiteMode();
+ await uncontendedMultiApp._ensureSuiteRecoveryReady();
+ const uncontendedMultiRestored = uncontendedMultiApp.multiSuiteSessionsMap.get(uncontendedMultiSession.baseExamId);
+ assert(uncontendedMultiRestored, 'an uncontended multi WAL with no fence must be migrated, not discarded');
+ assert.equal(uncontendedMultiHarness.recoveryCalls.save, 1);
+ assert.equal(await uncontendedMultiApp._releaseSuiteRecoveryClaim('multi', uncontendedMultiRestored), true);
+
+ const preSaveMultiLocks = createExclusiveLockManager();
+ const preSaveMultiDurable = new Map();
+ const preSaveMultiFences = new Map();
+ const preSaveMultiOwnerHarness = createHarness({
+ locks: preSaveMultiLocks,
+ activeSessionStore: preSaveMultiDurable,
+ recoveryFenceStore: preSaveMultiFences
+ });
+ const preSaveMultiOwner = preSaveMultiOwnerHarness.makeApp();
+ preSaveMultiOwner.multiSuiteSessionsMap = new Map();
+ const preSaveMultiSession = {
+ ...structuredClone(multiSuiteSession),
+ id: 'multi-pre-first-save-crash',
+ baseExamId: 'listening-multi-pre-first-save-crash',
+ revision: 0
+ };
+ assert.equal(await preSaveMultiOwner._acquireMultiSuiteRecoveryOwnership(preSaveMultiSession), true);
+ preSaveMultiOwner.multiSuiteSessionsMap.set(preSaveMultiSession.baseExamId, preSaveMultiSession);
+ assert.equal(preSaveMultiOwner._mirrorMultiSuiteSessionsToStorage(), true);
+ const preSaveMultiWal = structuredClone(
+ preSaveMultiOwnerHarness.sessionStore.peek('multi-suite-practice')
+ );
+
+ const preSaveMultiLoserHarness = createHarness({
+ locks: preSaveMultiLocks,
+ activeSessionStore: preSaveMultiDurable,
+ recoveryFenceStore: preSaveMultiFences
+ });
+ preSaveMultiLoserHarness.sessionStore.save('multi-suite-practice', structuredClone(preSaveMultiWal));
+ const preSaveMultiLoser = preSaveMultiLoserHarness.makeApp();
+ preSaveMultiLoser.initializeSuiteMode();
+ await preSaveMultiLoser._ensureSuiteRecoveryReady();
+ assert.equal(preSaveMultiLoser.multiSuiteSessionsMap.size, 0);
+ assert.equal(
+ preSaveMultiLoserHarness.sessionStore.peek('multi-suite-practice').sessions[0].recoveryLeaseContended,
+ true
+ );
+ assert.equal(await preSaveMultiOwner._releaseSuiteRecoveryClaim('multi', preSaveMultiSession), true);
+ assert.equal(preSaveMultiLocks.held.size, 0);
+
+ const preSaveMultiTakeoverHarness = createHarness({
+ locks: preSaveMultiLocks,
+ activeSessionStore: preSaveMultiDurable,
+ recoveryFenceStore: preSaveMultiFences
+ });
+ preSaveMultiTakeoverHarness.sessionStore.save(
+ 'multi-suite-practice',
+ structuredClone(preSaveMultiLoserHarness.sessionStore.peek('multi-suite-practice'))
+ );
+ const preSaveMultiTakeover = preSaveMultiTakeoverHarness.makeApp();
+ preSaveMultiTakeover.initializeSuiteMode();
+ await preSaveMultiTakeover._ensureSuiteRecoveryReady();
+ const migratedPreSaveMulti = preSaveMultiTakeover.multiSuiteSessionsMap.get(preSaveMultiSession.baseExamId);
+ assert(migratedPreSaveMulti, 'a missing fence must migrate the contended multi WAL after owner crash');
+ assert.equal(preSaveMultiTakeoverHarness.recoveryCalls.save, 1);
+ assert.equal(preSaveMultiDurable.get(preSaveMultiSession.id).schema, 'multi-suite-sessions-v2');
+ assert.equal(await preSaveMultiTakeover._releaseSuiteRecoveryClaim('multi', migratedPreSaveMulti), true);
+ assert.equal(preSaveMultiLocks.held.size, 0);
+
+ const expiredMultiHarness = createHarness();
+ const expiredMultiSession = {
+ ...structuredClone(multiSuiteSession),
+ id: 'multi-expired-copied-wal',
+ baseExamId: 'listening-multi-expired-copied-wal',
+ revision: 0,
+ lastUpdate: Date.now() - (31 * 24 * 60 * 60 * 1000)
+ };
+ expiredMultiHarness.sessionStore.save('multi-suite-practice', {
+ ...structuredClone(multiSuiteWal),
+ sessions: [expiredMultiSession],
+ updatedAt: Date.now()
+ });
+ const expiredMultiApp = expiredMultiHarness.makeApp();
+ expiredMultiApp.initializeSuiteMode();
+ await expiredMultiApp._ensureSuiteRecoveryReady();
+ assert.equal(expiredMultiApp.multiSuiteSessionsMap.size, 0);
+ assert.equal(expiredMultiHarness.sessionStore.peek('multi-suite-practice'), null);
+ assert.equal(expiredMultiHarness.recoveryCalls.save, 0);
+
+ const throwingFileLocks = {
+ calls: 0,
+ request() { this.calls += 1; throw new Error('file: must bypass Web Locks'); }
+ };
+ const multiFileFallbackHarness = createHarness({ protocol: 'file:', locks: throwingFileLocks });
+ multiFileFallbackHarness.activeSessionStore.set(multiSuiteDurable.id, structuredClone(multiSuiteDurable));
+ const multiFileFallbackApp = multiFileFallbackHarness.makeApp();
+ multiFileFallbackApp.initializeSuiteMode();
+ await multiFileFallbackApp._ensureSuiteRecoveryReady();
+ assert.equal(
+ multiFileFallbackApp.multiSuiteSessionsMap.get(multiSuiteSession.baseExamId).id,
+ multiSuiteSession.id,
+ 'file: must retain durable-only multi-suite recovery fallback'
+ );
+ assert.equal(throwingFileLocks.calls, 0);
+
+ const fileWindowOnlyHarness = createHarness({ protocol: 'file:', locks: throwingFileLocks });
+ const fileWindowOnlySession = {
+ ...structuredClone(multiSuiteSession),
+ id: 'multi-file-window-only',
+ baseExamId: 'listening-multi-file-window-only',
+ revision: 0
+ };
+ fileWindowOnlyHarness.sessionStore.save('multi-suite-practice', {
+ ...structuredClone(multiSuiteWal),
+ sessions: [fileWindowOnlySession]
+ });
+ const fileWindowOnlyApp = fileWindowOnlyHarness.makeApp();
+ fileWindowOnlyApp.initializeSuiteMode();
+ await fileWindowOnlyApp._ensureSuiteRecoveryReady();
+ assert(fileWindowOnlyApp.multiSuiteSessionsMap.has(fileWindowOnlySession.baseExamId));
+ assert.equal(fileWindowOnlyHarness.recoveryCalls.save, 1, 'file: window-only multi WAL must establish expected=0 durable CAS');
+ assert.equal(throwingFileLocks.calls, 0);
+
+ const noLocksHarness = createHarness({ locks: null });
+ noLocksHarness.sessionStore.save('simulation', structuredClone(matchedLocalWal));
+ noLocksHarness.activeSessionStore.set(matchedDurable.id, structuredClone(matchedDurable));
+ const noLocksApp = noLocksHarness.makeApp();
+ noLocksApp.initializeSuiteMode();
+ await noLocksApp._ensureSuiteRecoveryReady();
+ assert.equal(noLocksApp.currentSuiteSession, null, 'HTTP recovery must fail closed without navigator.locks');
+ assert.equal(noLocksHarness.recoveryCalls.save, 0);
+ assert.equal(noLocksHarness.recoveryCalls.discard, 0);
+ assert.equal(
+ noLocksHarness.sessionStore.peek('simulation').recoveryLeaseContended,
+ true,
+ 'an unavailable group lock must preserve and mark the WAL for a later retry'
+ );
+ assert.equal(noLocksHarness.sessionStore.peek('simulation').id, matchedLocalWal.id);
+ assert.equal(noLocksHarness.sessionStore.peek('simulation').revision, matchedLocalWal.revision);
+
+ const throwingLocks = createExclusiveLockManager();
+ throwingLocks.failNext(new Error('locks backend unavailable'));
+ const throwingLocksHarness = createHarness({ locks: throwingLocks });
+ throwingLocksHarness.sessionStore.save('simulation', structuredClone(matchedLocalWal));
+ throwingLocksHarness.activeSessionStore.set(matchedDurable.id, structuredClone(matchedDurable));
+ const throwingLocksApp = throwingLocksHarness.makeApp();
+ throwingLocksApp.initializeSuiteMode();
+ await throwingLocksApp._ensureSuiteRecoveryReady();
+ assert.equal(throwingLocksApp.currentSuiteSession, null, 'request failure must fail closed without hanging');
+ assert.equal(throwingLocksHarness.sessionStore.peek('simulation').recoveryLeaseContended, true);
+ assert.equal(throwingLocksHarness.sessionStore.peek('simulation').id, matchedLocalWal.id);
+ assert.equal(throwingLocksHarness.sessionStore.peek('simulation').revision, matchedLocalWal.revision);
+ assert.equal(throwingLocksHarness.recoveryCalls.save, 0);
+ assert.equal(throwingLocksHarness.recoveryCalls.discard, 0);
+ assert.equal(throwingLocks.held.size, 0, 'a failed group request must not leak a hold');
+ assert.deepEqual(
+ throwingLocks.calls.map((call) => call.name),
+ [throwingLocksApp._singleSuiteRecoveryGroupClaimName()],
+ 'a failed group request must stop before the WAL exact-id request'
+ );
+ assert.equal(
+ await throwingLocksApp._refreshSuiteRecoveryCandidates(),
+ throwingLocksApp.currentSuiteSession,
+ 'group request failure must not terminalize the serialized WAL retry'
+ );
+ assert.equal(throwingLocksApp.currentSuiteSession.id, matchedDurable.id);
+ assert.equal(await throwingLocksApp._releaseSuiteRecoveryClaim(
+ 'single',
+ throwingLocksApp.currentSuiteSession
+ ), true);
+ assert.equal(throwingLocks.held.size, 0);
+
+ // Drafts may arrive after the child Window exists but before openExam() resolves.
+ // The initializing snapshot must bind that exact source and persist the draft.
+ const earlyHarness = createHarness();
+ const initializingApp = earlyHarness.makeApp();
+ let initializingWindow;
+ initializingApp.openExam = async () => {
+ initializingWindow = { closed: false, name: 'initializing-window' };
+ initializingApp._installManagedTestWindow('p1', initializingWindow);
+ const initializingSession = initializingApp.currentSuiteSession;
+ assert.equal(initializingSession.status, 'active');
+ const accepted = await initializingApp._handleSuiteDraftSync('p1', {
+ suiteSessionId: initializingSession.id,
+ draft: { answers: { q1: 'early' }, updatedAt: 120 },
+ draftUpdatedAt: 120,
+ elapsed: 4
+ }, initializingApp.examWindows.get('p1'), initializingWindow);
+ assert.equal(accepted, true);
+ assert.equal(initializingSession.windowRef, initializingWindow);
+ return initializingWindow;
+ };
+ assert.equal(await initializingApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true);
+
+ const choiceHarness = createHarness();
+ const choiceSourceApp = choiceHarness.makeApp();
+ choiceSourceApp.openExam = async () => ({ closed: false, name: 'choice-window', close() { this.closed = true; } });
+ assert.equal(await choiceSourceApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true);
+ const choiceApp = choiceHarness.makeApp();
+ choiceApp.initializeSuiteMode();
+ await choiceApp._ensureSuiteRecoveryReady();
+ const choiceCandidate = await choiceApp.getSuiteRecoveryCandidate();
+ assert.equal(choiceCandidate.id, choiceSourceApp.currentSuiteSession.id);
+ let implicitResumeCount = 0;
+ choiceApp.resumeSuitePractice = async () => { implicitResumeCount += 1; return true; };
+ assert.equal(await choiceApp.startSuitePractice(), false, '未明确选择时不得自动继续恢复套题');
+ assert.equal(implicitResumeCount, 0, '再次点击套题入口不能隐式等同于继续');
+ assert.equal(await choiceApp.abandonSuiteRecovery(), false, '放弃 recovery 必须携带用户看到的 session id');
+ assert.equal(await choiceApp.startSuitePractice({ recoveryAction: 'continue' }), false, '继续 recovery 必须携带用户看到的 session id');
+ assert.equal(await choiceApp.abandonSuiteRecovery('another-suite'), false, '放弃操作必须绑定用户确认的 recovery identity');
+ assert.equal(choiceApp.currentSuiteSession.id, choiceCandidate.id);
+ assert.equal(await choiceApp.abandonSuiteRecovery(choiceCandidate.id), true, '用户明确放弃后应完整 teardown');
+ assert.equal(choiceApp.currentSuiteSession, null);
+ assert.equal(choiceHarness.activeSessionStore.size, 0, '放弃必须清除 durable active-session recovery');
+ assert.equal(choiceHarness.practiceFinalizes.length, 0, '放弃未完成套题不得生成单篇或聚合记录');
+
+ const discardFailureHarness = createHarness();
+ const discardFailureApp = discardFailureHarness.makeApp();
+ const discardFailureWindow = { closed: false, name: 'discard-failure', close() { this.closed = true; } };
+ discardFailureApp.openExam = async () => discardFailureWindow;
+ discardFailureApp.initializeSuiteMode();
+ await discardFailureApp._ensureSuiteRecoveryReady();
+ assert.equal(await discardFailureApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true);
+ const discardFailureSession = discardFailureApp.currentSuiteSession;
+ const discardFallbackTimer = setTimeout(() => {}, 60000);
+ discardFallbackTimer.unref?.();
+ discardFailureSession.submitReceiptTeardownTimer = discardFallbackTimer;
+ discardFailureHarness.recoveryCalls.discardQueue.push(false);
+ assert.equal(await discardFailureApp.abandonSuiteRecovery(discardFailureSession.id), false);
+ assert.equal(discardFailureApp.currentSuiteSession, discardFailureSession, 'discard failure must retain the in-memory suite');
+ assert.equal(discardFailureSession.status, 'active', 'discard failure must not mark the suite aborted');
+ assert.equal(discardFailureWindow.closed, false, 'discard failure must not close the active question window');
+ assert.equal(discardFailureSession.submitReceiptTeardownTimer, discardFallbackTimer, 'discard failure must preserve the fallback teardown timer');
+ assert(discardFailureHarness.activeSessionStore.has(discardFailureSession.id), 'discard failure must retain durable recovery');
+ assert.equal(await discardFailureApp.abandonSuiteRecovery(discardFailureSession.id), true, 'discard should remain retryable');
+
+ const queuedWriteHarness = createHarness();
+ const queuedWriteApp = queuedWriteHarness.makeApp();
+ const queuedWriteWindow = { closed: false, name: 'queued-write', close() { this.closed = true; } };
+ queuedWriteApp.openExam = async () => queuedWriteWindow;
+ queuedWriteApp.initializeSuiteMode();
+ await queuedWriteApp._ensureSuiteRecoveryReady();
+ assert.equal(await queuedWriteApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true);
+ const queuedWriteSession = queuedWriteApp.currentSuiteSession;
+ let releaseQueuedSave;
+ let markQueuedSaveStarted;
+ const queuedSaveStarted = new Promise((resolve) => { markQueuedSaveStarted = resolve; });
+ queuedWriteHarness.recoveryCalls.saveQueue.push(async () => {
+ markQueuedSaveStarted();
+ return new Promise((resolve) => { releaseQueuedSave = resolve; });
+ });
+ const pendingRecoveryWrite = queuedWriteApp._commitSuiteRecovery(queuedWriteSession, { reason: 'queued-before-discard' });
+ await queuedSaveStarted;
+ const queuedAbandon = queuedWriteApp.abandonSuiteRecovery(queuedWriteSession.id);
+ await Promise.resolve();
+ assert.equal(queuedWriteWindow.closed, false, 'teardown must not close the window before the queued write settles');
+ releaseQueuedSave({ committed: true });
+ assert.equal(await pendingRecoveryWrite, true);
+ assert.equal(await queuedAbandon, true);
+ assert.equal(queuedWriteHarness.activeSessionStore.has(queuedWriteSession.id), false, 'discard must run after queued save and prevent resurrection');
+
+ const submitAbandonHarness = createHarness();
+ const submitAbandonApp = submitAbandonHarness.makeApp();
+ let submitAbandonOpenCount = 0;
+ const submitAbandonWindow = { closed: false, name: 'submit-abandon', close() { this.closed = true; } };
+ submitAbandonApp.openExam = async () => {
+ submitAbandonOpenCount += 1;
+ return submitAbandonWindow;
+ };
+ submitAbandonApp.initializeSuiteMode();
+ await submitAbandonApp._ensureSuiteRecoveryReady();
+ assert.equal(await submitAbandonApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true);
+ const submitAbandonSession = submitAbandonApp.currentSuiteSession;
+ let releaseSubmitSave;
+ let markSubmitSaveStarted;
+ const submitSaveStarted = new Promise((resolve) => { markSubmitSaveStarted = resolve; });
+ submitAbandonHarness.recoveryCalls.saveQueue.push(async () => {
+ markSubmitSaveStarted();
+ return new Promise((resolve) => { releaseSubmitSave = resolve; });
+ });
+ const submitOutcomePromise = submitAbandonApp.handleSuitePracticeComplete('p1', {
+ suiteSessionId: submitAbandonSession.id,
+ submissionId: 'submit-abandon-p1',
+ duration: 10,
+ answers: { q1: 'A' },
+ answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } },
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }
+ }, submitAbandonWindow);
+ await submitSaveStarted;
+ const submitAbandonPromise = submitAbandonApp.abandonSuiteRecovery(submitAbandonSession.id);
+ await Promise.resolve();
+ releaseSubmitSave({ committed: true });
+ const submitOutcome = await submitOutcomePromise;
+ assert.equal(submitOutcome.handled, true);
+ assert.equal(submitOutcome.committed, false, 'abandon must invalidate the in-flight submit continuation before ACK');
+ assert.equal(submitOutcome.errorCode, 'suite_teardown_in_progress');
+ assert.equal(await submitAbandonPromise, true);
+ assert.equal(submitAbandonOpenCount, 1, 'an abandoned submit must not open the next passage');
+
+ const walPreservationHarness = createHarness();
+ const walSourceApp = walPreservationHarness.makeApp();
+ walSourceApp.openExam = async () => ({ closed: false, name: 'wal-source' });
+ walSourceApp.initializeSuiteMode();
+ await walSourceApp._ensureSuiteRecoveryReady();
+ assert.equal(await walSourceApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true);
+ const validWal = walPreservationHarness.sessionStore.peek('simulation');
+ walPreservationHarness.activeSessionStore.clear();
+ walPreservationHarness.activeSessionStore.set(validWal.id, {
+ schema: 'suite-session-v2',
+ version: 2,
+ id: validWal.id,
+ status: 'invalid',
+ revision: 4,
+ lastUpdate: Number(validWal.lastUpdate) + 1000
+ });
+ walPreservationHarness.recoveryCalls.saveQueue.push((value, options) => {
+ assert.equal(options.expectedEntityRevision, 4, 'invalid durable repair must CAS against the listed entity revision');
+ assert(value.revision > 4, 'the repaired snapshot must advance beyond the invalid durable revision');
+ return { committed: true };
+ });
+ const walRestoredApp = walPreservationHarness.makeApp();
+ walRestoredApp.initializeSuiteMode();
+ await walRestoredApp._ensureSuiteRecoveryReady();
+ assert.equal(walRestoredApp.currentSuiteSession.id, validWal.id, 'invalid durable candidates must not erase a valid window WAL');
+ assert.equal(walPreservationHarness.sessionStore.peek('simulation').id, validWal.id);
+ assert.equal(walPreservationHarness.recoveryCalls.discard, 0, 'matching invalid durable state must be repaired without writing a tombstone');
+ assert.equal(walRestoredApp.currentSuiteSession._suiteRecoveryWritesBlocked, undefined);
+
+ const unsafeRevisionHarness = createHarness();
+ const unsafeRevisionSource = unsafeRevisionHarness.makeApp();
+ unsafeRevisionSource.openExam = async () => ({ closed: false, name: 'unsafe-revision-source' });
+ unsafeRevisionSource.initializeSuiteMode();
+ await unsafeRevisionSource._ensureSuiteRecoveryReady();
+ assert.equal(await unsafeRevisionSource._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true);
+ const unsafeRevisionWal = unsafeRevisionHarness.sessionStore.peek('simulation');
+ unsafeRevisionHarness.activeSessionStore.clear();
+ unsafeRevisionHarness.activeSessionStore.set(unsafeRevisionWal.id, {
+ schema: 'suite-session-v2',
+ version: 2,
+ id: unsafeRevisionWal.id,
+ status: 'invalid',
+ revision: 1.5,
+ lastUpdate: Number(unsafeRevisionWal.lastUpdate) + 1000
+ });
+ unsafeRevisionHarness.recoveryCalls.saveQueue.push((value, options) => {
+ assert.equal(options.expectedEntityRevision, 0, 'unsafe durable revisions must normalize to the initial CAS revision');
+ assert(value.revision > 0);
+ return { committed: true };
+ });
+ const unsafeRevisionRestoredApp = unsafeRevisionHarness.makeApp();
+ unsafeRevisionRestoredApp.initializeSuiteMode();
+ await unsafeRevisionRestoredApp._ensureSuiteRecoveryReady();
+ assert.equal(unsafeRevisionRestoredApp.currentSuiteSession.id, unsafeRevisionWal.id);
+ assert.equal(unsafeRevisionHarness.recoveryCalls.discard, 0);
+ assert.notEqual(unsafeRevisionRestoredApp.currentSuiteSession._suiteRecoveryWritesBlocked, true);
+
+ // A structurally valid single-suite entity with an unsafe imported revision must
+ // be offered as revision 0, then remain both writable and abandonable under the
+ // same safe-integer CAS contract used by AppData.
+ for (const [label, unsafeRevision] of [
+ ['fractional', 1.5],
+ ['infinite', Number.POSITIVE_INFINITY],
+ ['unsafe-integer', Number.MAX_SAFE_INTEGER + 1]
+ ]) {
+ const validUnsafeHarness = createHarness();
+ const validUnsafeId = `suite-valid-unsafe-${label}`;
+ const validUnsafeSnapshot = {
+ ...structuredClone(tabOwnedDurable),
+ id: validUnsafeId,
+ revision: unsafeRevision,
+ lastUpdate: fixtureTimeBase + 7000
+ };
+ validUnsafeHarness.sessionStore.save('simulation', {
+ ...structuredClone(validUnsafeSnapshot),
+ lastUpdate: fixtureTimeBase + 6000
+ });
+ validUnsafeHarness.activeSessionStore.set(validUnsafeId, structuredClone(validUnsafeSnapshot));
+ const validUnsafeApp = validUnsafeHarness.makeApp();
+ validUnsafeApp.initializeSuiteMode();
+ await validUnsafeApp._ensureSuiteRecoveryReady();
+ const restoredUnsafeSession = validUnsafeApp.currentSuiteSession;
+ assert.equal(restoredUnsafeSession.id, validUnsafeId);
+ assert.equal(restoredUnsafeSession.revision, 0, `${label} runtime revision must normalize to zero`);
+ assert.equal(restoredUnsafeSession._lastDurableRecoveryRevision, 0, `${label} durable CAS base must normalize to zero`);
+
+ const observedWrites = [];
+ validUnsafeHarness.recoveryCalls.saveQueue.push((value, options) => {
+ observedWrites.push({ value, options });
+ return { committed: true };
+ });
+ validUnsafeHarness.recoveryCalls.saveQueue.push((value, options) => {
+ observedWrites.push({ value, options });
+ return { committed: true };
+ });
+ assert.equal(await validUnsafeApp._commitSuiteRecovery(restoredUnsafeSession, { notify: false }), true);
+ assert.equal(await validUnsafeApp._commitSuiteRecovery(restoredUnsafeSession, { notify: false }), true);
+ assert.equal(observedWrites[0].options.expectedEntityRevision, 0);
+ assert.equal(observedWrites[0].value.revision, 1);
+ assert.equal(observedWrites[1].options.expectedEntityRevision, 1);
+ assert.equal(observedWrites[1].value.revision, 2);
+ assert.equal(restoredUnsafeSession._lastDurableRecoveryRevision, 2);
+
+ const abandonHarness = createHarness();
+ abandonHarness.sessionStore.save('simulation', structuredClone(validUnsafeSnapshot));
+ abandonHarness.activeSessionStore.set(validUnsafeId, structuredClone(validUnsafeSnapshot));
+ const abandonApp = abandonHarness.makeApp();
+ abandonApp.initializeSuiteMode();
+ await abandonApp._ensureSuiteRecoveryReady();
+ assert.equal(await abandonApp.abandonSuiteRecovery(validUnsafeId), true, `${label} recovery must remain abandonable`);
+ assert.equal(abandonHarness.recoveryCalls.discardOptions.at(-1).expectedEntityRevision, 0);
+ assert.equal(abandonApp.currentSuiteSession, null);
+ }
+
+ const invalidUnsafeFileHarness = createHarness({ protocol: 'file:' });
+ invalidUnsafeFileHarness.activeSessionStore.set('suite-invalid-unsafe-file', {
+ schema: 'suite-session-v2',
+ version: 2,
+ id: 'suite-invalid-unsafe-file',
+ status: 'invalid',
+ revision: Number.POSITIVE_INFINITY,
+ lastUpdate: fixtureTimeBase + 8000
+ });
+ const invalidUnsafeFileApp = invalidUnsafeFileHarness.makeApp();
+ invalidUnsafeFileApp.initializeSuiteMode();
+ await invalidUnsafeFileApp._ensureSuiteRecoveryReady();
+ assert.equal(invalidUnsafeFileApp.currentSuiteSession, null);
+ assert.equal(invalidUnsafeFileHarness.recoveryCalls.discardOptions[0].expectedEntityRevision, 0);
+
+ const walOrderingHarness = createHarness();
+ const walOrderingSource = walOrderingHarness.makeApp();
+ walOrderingSource.openExam = async () => ({ closed: false, name: 'wal-ordering-source' });
+ walOrderingSource.initializeSuiteMode();
+ await walOrderingSource._ensureSuiteRecoveryReady();
+ assert.equal(await walOrderingSource._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true);
+ const orderingBase = walOrderingHarness.sessionStore.peek('simulation');
+ const laterTimestampSameRevision = {
+ ...structuredClone(orderingBase),
+ revision: 10,
+ lastUpdate: fixtureTimeBase + 9000,
+ currentIndex: 0,
+ activeExamId: 'p1'
+ };
+ const higherDurableRevision = {
+ ...structuredClone(orderingBase),
+ revision: 10,
+ lastUpdate: fixtureTimeBase + 1000,
+ currentIndex: 1,
+ activeExamId: 'p2'
+ };
+ walOrderingHarness.sessionStore.save('simulation', laterTimestampSameRevision);
+ walOrderingHarness.activeSessionStore.set(orderingBase.id, higherDurableRevision);
+ const savesBeforeEqualRevisionRestore = walOrderingHarness.recoveryCalls.save;
+ const walOrderingApp = walOrderingHarness.makeApp();
+ walOrderingApp.initializeSuiteMode();
+ await walOrderingApp._ensureSuiteRecoveryReady();
+ assert.equal(walOrderingApp.currentSuiteSession.currentIndex, 1, 'durable recovery must beat a later same-revision WAL branch');
+ assert.equal(walOrderingApp.currentSuiteSession.activeExamId, 'p2');
+ assert.equal(walOrderingHarness.recoveryCalls.save, savesBeforeEqualRevisionRestore, 'same-revision WAL must not be promoted');
+
+ walOrderingHarness.sessionStore.save('simulation', {
+ ...laterTimestampSameRevision,
+ revision: 11
+ });
+ walOrderingHarness.activeSessionStore.set(orderingBase.id, higherDurableRevision);
+ walOrderingHarness.recoveryCalls.saveQueue.push((_value, options) => {
+ assert.equal(options.expectedEntityRevision, 10);
+ walOrderingHarness.activeSessionStore.set(orderingBase.id, {
+ ...higherDurableRevision,
+ revision: 15
+ });
+ return { committed: false, code: 'STALE_RECOVERY_WRITE', actualEntityRevision: 15 };
+ });
+ const failedPromotionApp = walOrderingHarness.makeApp();
+ failedPromotionApp.initializeSuiteMode();
+ assert.equal(failedPromotionApp.currentSuiteSession, null, 'WAL promotion must remain quarantined until its claim and durable merge settle');
+ await failedPromotionApp._ensureSuiteRecoveryReady();
+ assert.equal(failedPromotionApp.currentSuiteSession.currentIndex, 1, 'failed promotion must fall back to durable progress');
+ assert.equal(failedPromotionApp.currentSuiteSession.activeExamId, 'p2');
+ assert.equal(failedPromotionApp.currentSuiteSession._lastDurableRecoveryRevision, 10, 'stale receipt must not adopt another tab revision');
+ assert.equal(walOrderingHarness.sessionStore.peek('simulation').revision, 10, 'losing WAL must be replaced by the durable snapshot');
+ const savesBeforeDurableReload = walOrderingHarness.recoveryCalls.save;
+ const durableReloadApp = walOrderingHarness.makeApp();
+ durableReloadApp.initializeSuiteMode();
+ await durableReloadApp._ensureSuiteRecoveryReady();
+ assert.equal(durableReloadApp.currentSuiteSession.activeExamId, 'p2');
+ assert.equal(walOrderingHarness.recoveryCalls.save, savesBeforeDurableReload, 'reload must not retry the losing WAL');
+
+ const resumedApp = makeApp();
+ resumedApp.initializeSuiteMode();
+ await resumedApp._ensureSuiteRecoveryReady();
+ assert.equal(resumedApp.currentSuiteSession.id, firstApp.currentSuiteSession.id);
+ assert.equal(resumedApp.currentSuiteSession.activeExamId, 'p1');
+ assert.equal(resumedApp.currentSuiteSession.globalTimerAnchorMs, firstApp.currentSuiteSession.globalTimerAnchorMs);
+
+ const suiteWindow = { closed: false, name: 'suite-window' };
+ const session = resumedApp.currentSuiteSession;
+ session.status = 'active';
+ session.windowRef = suiteWindow;
+ session.activeExamId = 'p2';
+ session.currentIndex = 1;
+ resumedApp._installManagedTestWindow('p2', suiteWindow);
+ const windowInfo = resumedApp.examWindows.get('p2');
+ assert.equal(await resumedApp._handleSuiteDraftSync('p2', {
+ suiteSessionId: session.id,
+ draft: { answers: { q2: 'new' }, updatedAt: 100 },
+ draftUpdatedAt: 100,
+ elapsed: 12
+ }, windowInfo, suiteWindow), true);
+ assert.deepEqual(session.draftsByExam.p2.answers, { q2: 'new' });
+ assert.equal(await resumedApp._handleSuiteDraftSync('p2', {
+ suiteSessionId: session.id,
+ draft: { answers: { q2: 'equal-must-reject' }, updatedAt: 100 },
+ draftUpdatedAt: 100
+ }, windowInfo, suiteWindow), false);
+ assert.deepEqual(session.draftsByExam.p2.answers, { q2: 'new' });
+ assert.equal(await resumedApp._handleSuiteDraftSync('p2', {
+ suiteSessionId: session.id,
+ draft: { answers: { q2: 'missing-time-must-reject' } }
+ }, windowInfo, suiteWindow), false);
+ assert.equal(await resumedApp._handleSuiteDraftSync('p1', {
+ suiteSessionId: session.id,
+ draft: { answers: { q1: 'late-p1' }, updatedAt: 200 },
+ draftUpdatedAt: 200
+ }, windowInfo, suiteWindow), true);
+ assert.equal(session.activeExamId, 'p2', '迟到的旧篇草稿不得回滚活动篇章');
+ assert.equal(session.currentIndex, 1);
+
+ // A paused suite must retain its pause state when a draft omits timer fields.
+ const pausedSession = {
+ ...session,
+ suiteTimerRunning: false,
+ suiteTimerPausedAtMs: 5000,
+ suiteTimerPausedOffsetMs: 3000
+ };
+ resumedApp._syncSuiteTimerFromPayload(pausedSession, {
+ draft: { answers: { q1: 'paused' }, updatedAt: 300 }
+ });
+ assert.equal(pausedSession.suiteTimerRunning, false, '普通草稿同步不得恢复暂停套题计时');
+ assert.equal(pausedSession.suiteTimerPausedAtMs, 5000, '普通草稿同步不得清除暂停时间');
+
+ let openedOnResume = false;
+ resumedApp.openExam = async () => {
+ openedOnResume = true;
+ return { closed: false, name: 'replacement' };
+ };
+ session.windowRef = null;
+ session._restoredFromStorage = true;
+ resumedApp._fetchSuiteExamIndex = async () => sequence.map((entry) => entry.exam);
+ assert.equal(await resumedApp.resumeSuitePractice(session.id), true);
+ assert.equal(openedOnResume, true);
+ assert.equal(sessionStore.peek('simulation').draftsByExam.p2.answers.q2, 'new');
+
+ const missingApp = makeApp();
+ missingApp.initializeSuiteMode();
+ await missingApp._ensureSuiteRecoveryReady();
+ missingApp._fetchSuiteExamIndex = async () => [sequence[0].exam];
+ missingApp.openExam = async () => { throw new Error('must not open missing exam'); };
+ const missingRecoveryId = missingApp.currentSuiteSession.id;
+ const discardCallsBeforeMismatch = sessionStore.calls.discard;
+ assert.equal(await missingApp.resumeSuitePractice(missingApp.currentSuiteSession.id), false);
+ assert.equal(sessionStore.peek('simulation').id, missingRecoveryId, '题库不一致不得擅自放弃 recovery');
+ assert.equal(sessionStore.calls.discard, discardCallsBeforeMismatch, '题库不一致不得清除窗口恢复镜像');
+ assert.equal(
+ messages.some((entry) => entry.type === 'warning' && entry.text.includes('恢复数据仍会保留')),
+ true,
+ '题库不一致必须明确告知用户 recovery 已保留'
+ );
+
+ const invalidTerminalSnapshot = {
+ schema: 'suite-session-v2',
+ version: 2,
+ id: 'suite_invalid_terminal',
+ status: 'finalizing',
+ sequence,
+ suiteSequence: sequence,
+ currentIndex: sequence.length,
+ activeExamId: null,
+ results: [{
+ examId: 'p1',
+ title: 'Passage 1',
+ category: 'P1',
+ scoreInfo: { correct: 1, total: 1 }
+ }],
+ draftsByExam: {},
+ elapsedByExam: {},
+ suiteTimerMode: 'elapsed',
+ suiteTimerLimitSeconds: 3600,
+ startTime: 1000,
+ globalTimerAnchorMs: 1000,
+ suiteTimerAnchorMs: 1000,
+ lastUpdate: fixtureTimeBase + 1000
+ };
+ sessionStore.save('simulation', invalidTerminalSnapshot);
+ const corruptApp = makeApp();
+ corruptApp.initializeSuiteMode();
+ assert.equal(corruptApp.currentSuiteSession, null, '不完整终态快照必须被丢弃');
+ assert.equal(sessionStore.peek('simulation'), null, '不完整终态快照不得永久卡在 finalizing');
+
+ const validTerminalResults = sequence.map((entry) => ({
+ examId: entry.examId,
+ title: entry.exam.title,
+ category: entry.category,
+ duration: 10,
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 },
+ answers: { [`q-${entry.examId}`]: 'A' },
+ answerComparison: {}
+ }));
+ const terminalActiveId = 'suite_terminal_active_id';
+ const terminalActiveHarness = createHarness();
+ terminalActiveHarness.sessionStore.save('simulation', {
+ ...invalidTerminalSnapshot,
+ id: terminalActiveId,
+ status: 'active',
+ activeExamId: 'p3',
+ results: validTerminalResults,
+ finalizeOperationId: null,
+ finalizeRecord: null
+ });
+ const terminalActiveApp = terminalActiveHarness.makeApp();
+ terminalActiveApp.initializeSuiteMode();
+ await terminalActiveApp._ensureSuiteRecoveryReady();
+ assert.equal(terminalActiveApp.currentSuiteSession.status, 'finalizing', '已完成索引的活动篇章快照必须转入终态恢复');
+ assert.equal(terminalActiveApp.currentSuiteSession.activeExamId, null, '终态恢复不得重新打开最后一篇');
+
+ const malformedRecordId = 'suite_malformed_record';
+ sessionStore.save('simulation', {
+ ...invalidTerminalSnapshot,
+ id: malformedRecordId,
+ results: validTerminalResults,
+ finalizeOperationId: `practice-suite:${malformedRecordId}:finalize`,
+ finalizeRecord: {
+ id: malformedRecordId,
+ sessionId: malformedRecordId,
+ operationId: `practice-suite:${malformedRecordId}:finalize`,
+ suiteEntries: sequence.map((entry) => ({ examId: entry.examId }))
+ }
+ });
+ const malformedRecordApp = makeApp();
+ malformedRecordApp.initializeSuiteMode();
+ assert.equal(malformedRecordApp.currentSuiteSession, null, '缺少聚合字段的终态记录不得重放');
+ assert.equal(sessionStore.peek('simulation'), null);
+
+ const incompleteFinalizeApp = makeApp();
+ const incompleteFinalizeSession = {
+ id: 'suite_incomplete_finalize',
+ status: 'active',
+ startTime: 1000,
+ sequence,
+ currentIndex: 2,
+ activeExamId: 'p3',
+ results: validTerminalResults.filter((entry) => entry.examId !== 'p2'),
+ draftsByExam: {},
+ elapsedByExam: {},
+ flowMode: 'stationary',
+ windowRef: null
+ };
+ incompleteFinalizeApp.currentSuiteSession = incompleteFinalizeSession;
+ incompleteFinalizeApp._saveSuitePracticeRecord = async () => {
+ throw new Error('incomplete suite must not be persisted');
+ };
+ assert.equal(await incompleteFinalizeApp._finalizeSuiteRecordWithGate(incompleteFinalizeSession), false);
+ assert.equal(incompleteFinalizeSession.status, 'active');
+ assert.equal(incompleteFinalizeSession.currentIndex, 1);
+ assert.equal(incompleteFinalizeSession.activeExamId, 'p2');
+
+ const finalApp = makeApp();
+ let committedRecord = null;
+ const finalSession = {
+ id: 'suite_terminal',
+ status: 'active',
+ startTime: 1000,
+ globalTimerAnchorMs: 1000,
+ suiteTimerAnchorMs: 1000,
+ sequence,
+ currentIndex: sequence.length,
+ activeExamId: null,
+ results: sequence.map((entry) => ({
+ examId: entry.examId,
+ title: entry.exam.title,
+ category: entry.category,
+ duration: 10,
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 },
+ answers: { [`q-${entry.examId}`]: 'A' },
+ answerComparison: {}
+ })),
+ draftsByExam: {},
+ elapsedByExam: {},
+ windowRef: null,
+ windowBinding: {
+ examId: 'p3',
+ expectedSessionId: 'terminal-window-session',
+ windowSessionToken: 'terminal-window-token',
+ sessionGeneration: 2
+ }
+ };
+ const finalChild = {
+ closed: false,
+ postMessage() {},
+ close() { this.closed = true; }
+ };
+ finalApp.currentSuiteSession = finalSession;
+ finalApp._resolveSuiteSequenceNumber = async () => 1;
+ finalApp._formatSuiteDateLabel = () => '2026-08-07';
+ finalApp._updatePracticeRecordsState = async () => {};
+ finalApp._postExamMessage = () => true;
+ finalApp.refreshOverviewData = () => {};
+ let reboundTerminalExamId = '';
+ finalApp._tryRebindSuiteWindow = async (_session, entry) => {
+ reboundTerminalExamId = entry.examId;
+ finalApp.examWindows = new Map([[entry.examId, {
+ window: finalChild,
+ suiteSessionId: finalSession.id,
+ expectedSessionId: finalSession.windowBinding.expectedSessionId,
+ windowSessionToken: finalSession.windowBinding.windowSessionToken,
+ windowSessionTokenSessionId: finalSession.windowBinding.expectedSessionId,
+ sessionGeneration: finalSession.windowBinding.sessionGeneration
+ }]]);
+ return { window: finalChild };
+ };
+ assert.equal(await finalApp.resumeSuitePractice(finalSession.id), true);
+ committedRecord = practiceFinalizes[0] && practiceFinalizes[0].record;
+ assert.equal(practiceFinalizes.length, 1, '终态恢复必须调用 v2 AppData.practice.finalizeSuite');
+ assert.equal(practiceFinalizes[0].operationId, 'practice-suite:suite_terminal:finalize');
+ assert.equal(practiceFinalizes[0].record.operationId, practiceFinalizes[0].operationId);
+ assert.equal(sessionStore.peek('simulation'), null);
+ assert.equal(finalSession.status, 'completed');
+ assert.equal(reboundTerminalExamId, 'p3', '终态恢复必须按持久 binding 重新绑定存活题页');
+ assert.equal(finalChild.closed, true, '终态恢复完成后必须关闭重新绑定的题页');
+ assert.equal(messages.some((entry) => entry.type === 'error'), false);
+
+ const divergentId = 'suite_divergent_record';
+ sessionStore.save('simulation', {
+ ...invalidTerminalSnapshot,
+ id: divergentId,
+ results: validTerminalResults,
+ finalizeOperationId: `practice-suite:${divergentId}:finalize`,
+ finalizeRecord: {
+ ...committedRecord,
+ id: divergentId,
+ sessionId: divergentId,
+ operationId: `practice-suite:${divergentId}:finalize`,
+ correctAnswers: 999,
+ scoreInfo: { ...committedRecord.scoreInfo, correct: 999 }
+ }
+ });
+ const divergentApp = makeApp();
+ divergentApp.initializeSuiteMode();
+ assert.equal(divergentApp.currentSuiteSession, null, '分数与结果不一致的终态聚合记录必须被丢弃');
+ assert.equal(sessionStore.peek('simulation'), null);
+
+ const concurrentApp = makeApp();
+ const concurrentWindow = { closed: false, name: 'concurrent-window' };
+ const concurrentSession = {
+ ...finalSession,
+ id: 'suite_concurrent_finalize',
+ status: 'active',
+ currentIndex: 0,
+ activeExamId: 'p1',
+ results: [],
+ draftsByExam: {},
+ elapsedByExam: {},
+ flowMode: 'simulation',
+ windowRef: concurrentWindow,
+ finalizeRecord: null,
+ finalizeOperationId: null,
+ _lastDurableRecoveryRevision: 0,
+ _suiteRecoveryWritesBlocked: false,
+ _suiteTeardownInProgress: false,
+ _teardownPromise: null
+ };
+ concurrentApp.currentSuiteSession = concurrentSession;
+ concurrentApp._resolveSuiteSequenceNumber = async () => 1;
+ concurrentApp._formatSuiteDateLabel = () => '2026-08-07';
+ concurrentApp._updatePracticeRecordsState = async () => {};
+ concurrentApp.refreshOverviewData = () => {};
+ concurrentApp._suiteModeReady = true;
+ let finalizeCalls = 0;
+ let releaseFinalizeSave;
+ let markFinalizeSaveStarted;
+ const finalizeSaveStarted = new Promise((resolve) => { markFinalizeSaveStarted = resolve; });
+ const finalizeSaveGate = new Promise((resolve) => { releaseFinalizeSave = resolve; });
+ concurrentApp._saveSuitePracticeRecord = async (record) => {
+ finalizeCalls += 1;
+ markFinalizeSaveStarted();
+ await finalizeSaveGate;
+ return record;
+ };
+ const concurrentPayload = {
+ suiteSessionId: concurrentSession.id,
+ suiteSubmission: true,
+ submissionId: 'submission-concurrent',
+ suiteEntries: sequence.map((entry) => ({
+ examId: entry.examId,
+ duration: 10,
+ scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 },
+ answers: { [`q-${entry.examId}`]: 'A' },
+ answerComparison: {}
+ }))
+ };
+ const concurrentSubmits = [
+ concurrentApp._handleInlineSimulationSuiteSubmit('p1', concurrentPayload, concurrentWindow),
+ concurrentApp._handleInlineSimulationSuiteSubmit('p1', concurrentPayload, concurrentWindow)
+ ];
+ await finalizeSaveStarted;
+ assert.equal(await concurrentApp.abandonSuiteRecovery(concurrentSession.id), false, 'live finalize 期间不得承诺放弃成功');
+ assert.equal(concurrentApp.currentSuiteSession, concurrentSession, '拒绝放弃时必须保留当前 finalizing session');
+ releaseFinalizeSave();
+ const concurrentOutcomes = await Promise.all(concurrentSubmits);
+ assert.equal(finalizeCalls, 1, '并发 inline submit 必须复用同一个 finalize promise');
+ assert.equal(concurrentOutcomes.every((outcome) => outcome && outcome.committed === true), true);
+
+ const mirrorHarness = createHarness();
+ const mirrorApp = mirrorHarness.makeApp();
+ const mirrorOwner = { id: 'suite-mirror-denied' };
+ assert.equal(await mirrorApp._acquireSuiteRecoveryClaim('single', mirrorOwner), true);
+ mirrorHarness.sessionStore.save = () => {
+ const error = new Error('session storage denied');
+ error.name = 'SecurityError';
+ throw error;
+ };
+ assert.equal(mirrorApp._mirrorSuiteRecoverySnapshot({ id: 'suite-mirror-denied' }, mirrorOwner), false);
+ assert.equal(
+ mirrorHarness.messages.filter((entry) => entry.type === 'warning' && entry.text.includes('临时恢复存储')).length,
+ 1,
+ 'sessionStorage 拒绝必须产生可见降级提示'
+ );
+ assert.equal(mirrorApp._mirrorSuiteRecoverySnapshot({ id: 'suite-mirror-denied' }, mirrorOwner), false);
+ assert.equal(
+ mirrorHarness.messages.filter((entry) => entry.type === 'warning' && entry.text.includes('临时恢复存储')).length,
+ 1,
+ '连续镜像失败提示必须节流'
+ );
+
+ process.stdout.write(JSON.stringify({ status: 'pass', detail: 'v2 suite recovery state machine passed' }));
+}
+
+main().catch((error) => {
+ process.stdout.write(JSON.stringify({ status: 'fail', detail: error.stack || String(error) }));
+ process.exit(1);
+});
diff --git a/developer/tests/js/unifiedReadingCoreRegression.test.js b/developer/tests/js/unifiedReadingCoreRegression.test.js
index 25a1bb82..c94da775 100644
--- a/developer/tests/js/unifiedReadingCoreRegression.test.js
+++ b/developer/tests/js/unifiedReadingCoreRegression.test.js
@@ -15,22 +15,6 @@ function loadScript(relativePath, context) {
vm.runInContext(code, context, { filename: relativePath });
}
-function createSessionStorageStub() {
- const store = new Map();
- return {
- store,
- getItem(key) {
- return store.has(key) ? store.get(key) : null;
- },
- setItem(key, value) {
- store.set(key, String(value));
- },
- removeItem(key) {
- store.delete(key);
- }
- };
-}
-
function createClassList() {
return {
add() {},
@@ -51,7 +35,6 @@ function createContext() {
HTMLSelectElement.prototype = Object.create(HTMLElement.prototype);
HTMLSelectElement.prototype.constructor = HTMLSelectElement;
- const sessionStorage = createSessionStorageStub();
const timer = {
textContent: '',
style: {},
@@ -125,7 +108,6 @@ function createContext() {
},
history: { replaceState() {} },
document,
- sessionStorage,
opener: null,
parent: null,
addEventListener() {},
@@ -195,7 +177,6 @@ function createContext() {
HTMLSelectElement,
CustomEvent: window.CustomEvent,
CSS: window.CSS,
- sessionStorage,
location: window.location
};
sandbox.globalThis = window;
@@ -254,19 +235,76 @@ async function testSubmitPostsBeforeExplanationRenderFinishes() {
releaseExplanation = resolve;
}));
- const submitPromise = hooks.handleSubmit();
+ let submitError = null;
+ const submitPromise = hooks.handleSubmit().catch((error) => {
+ submitError = error;
+ });
await Promise.resolve();
+ assert.ifError(submitError);
assert.strictEqual(messages.length, 1, 'submit should notify host before explanation rendering completes');
assert.strictEqual(messages[0].type, 'PRACTICE_COMPLETE', 'submit should post a practice completion message');
assert.strictEqual(messages[0].data?.answers?.q1, 'A', 'posted submission should include the current answer');
releaseExplanation();
await submitPromise;
+ assert.ifError(submitError);
hooks.setTestOverride('renderExplanations', null);
assert.strictEqual(window.__UNIFIED_READING_SIMULATION_MODE__, false, 'submit regression harness should remain in non-simulation mode');
}
+function testDraftBearingInitIsNotSuppressed() {
+ const { hooks } = loadHooks();
+ const baseData = {
+ examId: 'reading-p1',
+ sessionId: 'session-init',
+ windowSessionToken: 'token-init',
+ messageIssuedAtMs: 1000
+ };
+ const noDraftSignature = hooks.buildInitSignature(baseData);
+ const draftData = {
+ ...baseData,
+ draft: { answers: { q1: 'A' }, updatedAt: 2000 }
+ };
+ const draftSignature = hooks.buildInitSignature(draftData);
+ assert.notStrictEqual(
+ draftSignature,
+ noDraftSignature,
+ 'a later draft-bearing INIT must not be suppressed by an earlier no-draft INIT'
+ );
+ assert.strictEqual(
+ hooks.buildInitSignature(draftData),
+ draftSignature,
+ 'repeated INITs carrying the same draft should still be deduplicated'
+ );
+}
+
+function testSuiteReviewAnnotationsUseDraftChannel() {
+ const { hooks } = loadHooks();
+ const messages = [];
+ const hostWindow = {
+ postMessage(payload) {
+ messages.push(payload);
+ }
+ };
+ hooks.setTestState({
+ examId: 'reading-suite-review',
+ sessionId: 'session-suite-review',
+ suiteSessionId: 'suite-review',
+ simulationMode: true,
+ suiteReviewMode: true,
+ reviewMode: true,
+ readOnly: true,
+ parentWindow: hostWindow
+ });
+
+ hooks.syncReadingAnnotation('note-edit');
+
+ assert.strictEqual(messages.length, 1, 'suite review annotation should emit one persistence message');
+ assert.strictEqual(messages[0].type, 'SIMULATION_DRAFT_SYNC', 'suite review annotation must use the suite draft channel');
+ assert.strictEqual(messages[0].data?.examId, 'reading-suite-review', 'suite draft sync must retain the active exam id');
+}
+
function testGroupedCheckboxSplitKeysScorePartially() {
const { hooks } = loadHooks();
const results = hooks.buildResultsFromAnswers({
@@ -358,6 +396,8 @@ function testSuiteTimerIgnoresEmptyLimitValues() {
async function main() {
await testSubmitPostsBeforeExplanationRenderFinishes();
+ testDraftBearingInitIsNotSuppressed();
+ testSuiteReviewAnnotationsUseDraftChannel();
testGroupedCheckboxSplitKeysScorePartially();
testGroupedCheckboxSingleKeyArrayScoresPartially();
testAcceptedAnswerArraysStaySinglePoint();
diff --git a/developer/tests/js/unifiedReadingLockRegression.test.js b/developer/tests/js/unifiedReadingLockRegression.test.js
index 51853362..8d5b3117 100644
--- a/developer/tests/js/unifiedReadingLockRegression.test.js
+++ b/developer/tests/js/unifiedReadingLockRegression.test.js
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
+import vm from 'vm';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
@@ -15,7 +16,101 @@ function ok(cond, label, failed) {
if (!cond) failed.push(label);
}
-function run() {
+async function testEndlessLifecycle(failed) {
+ const appActionsSource = read('js/presentation/app-actions.js');
+ const intervalCallbacks = new Map();
+ let nextIntervalId = 0;
+ let messageHandler = null;
+ const practiceWindow = {
+ closed: false,
+ focus() {},
+ postMessage() {},
+ location: { href: 'about:blank' }
+ };
+ const openCalls = [];
+ const examWindows = new Map();
+ const exams = [{
+ id: 'reading-endless',
+ type: 'reading',
+ hasHtml: true,
+ title: 'Endless Reading'
+ }];
+ const windowStub = {
+ location: { href: 'https://example.test/index.html' },
+ document: {
+ readyState: 'loading',
+ addEventListener() {},
+ querySelector() { return null; }
+ },
+ resolveActiveLibraryIndex: async () => exams,
+ showMessage() {},
+ addEventListener(type, listener) {
+ if (type === 'message') messageHandler = listener;
+ },
+ removeEventListener(type, listener) {
+ if (type === 'message' && messageHandler === listener) messageHandler = null;
+ },
+ app: {
+ examWindows,
+ async openExam(examId, options) {
+ openCalls.push({ examId, options });
+ examWindows.set(examId, {
+ expectedOrigin: 'https://example.test',
+ allowOpaqueOrigin: false,
+ windowSessionToken: 'endless-token'
+ });
+ return practiceWindow;
+ },
+ _postExamMessage() { return true; }
+ }
+ };
+ const context = vm.createContext({
+ window: windowStub,
+ document: windowStub.document,
+ console,
+ URL,
+ Promise,
+ Math,
+ Date,
+ setInterval(callback) {
+ const id = ++nextIntervalId;
+ intervalCallbacks.set(id, callback);
+ return id;
+ },
+ clearInterval(id) {
+ intervalCallbacks.delete(id);
+ }
+ });
+ vm.runInContext(appActionsSource, context, { filename: 'app-actions.js' });
+
+ await windowStub.AppActions.startEndlessPractice();
+ ok(openCalls.length === 1, 'endless_first_open_not_called', failed);
+ ok(openCalls[0]?.options?.endlessMode === true, 'endless_first_open_missing_mode', failed);
+ ok(openCalls[0]?.options?.windowName === 'ielts-endless-mode-tab', 'endless_first_open_missing_stable_window_name', failed);
+ ok(typeof messageHandler === 'function', 'endless_message_handler_not_installed', failed);
+
+ messageHandler?.({
+ source: practiceWindow,
+ origin: 'https://example.test',
+ data: {
+ type: 'PRACTICE_COMPLETE',
+ source: 'practice_page',
+ data: { windowSessionToken: 'endless-token' }
+ }
+ });
+ const countdownId = Math.max(...intervalCallbacks.keys());
+ for (let tick = 0; tick < 5; tick += 1) {
+ intervalCallbacks.get(countdownId)?.();
+ }
+ await Promise.resolve();
+ await Promise.resolve();
+ ok(openCalls.length === 2, 'endless_next_exam_did_not_use_openExam', failed);
+ ok(openCalls[1]?.options?.reuseWindow === practiceWindow, 'endless_next_exam_did_not_reuse_window', failed);
+ ok(openCalls[1]?.options?.endlessMode === true, 'endless_next_exam_missing_mode', failed);
+ windowStub.AppActions.stopEndlessPractice({ silent: true });
+}
+
+async function run() {
const failed = [];
const unifiedHtml = read('assets/generated/reading-exams/reading-practice-unified.html');
const unifiedPage = read('js/runtime/unifiedReadingPage.js');
@@ -24,6 +119,11 @@ function run() {
ok(!/practice-page-ui\.js/.test(unifiedHtml), 'unified_html_loads_practice_page_ui', failed);
ok(!/leftHtmlWithHighlights/.test(unifiedPage), 'unified_page_contains_leftHtmlWithHighlights', failed);
ok(/function enterSubmittedReadOnlyState\s*\(/.test(unifiedPage), 'missing_enterSubmittedReadOnlyState', failed);
+ ok(/function setTimerLockMode\s*\([\s\S]*data-note-outline-add[\s\S]*disabled/.test(unifiedPage), 'timer_lock_does_not_disable_note_controls', failed);
+ ok(/function canEditReadingNotes\s*\(\)\s*\{\s*if \(state\.timerLocked\) return false;/.test(unifiedPage), 'can_edit_notes_allows_timer_lock', failed);
+ ok(/function upsertNote\s*\([\s\S]*if \(!canEditReadingNotes\(\)\) return null;/.test(unifiedPage), 'note_upsert_not_guarded_by_timer_lock', failed);
+ ok(/function syncReadingAnnotation\s*\([\s\S]*if \(!canEditReadingNotes\(\)\) return;/.test(unifiedPage), 'annotation_sync_not_guarded_by_timer_lock', failed);
+ ok(/function canSyncReadingDraft\s*\([\s\S]*!state\.timerLocked/.test(unifiedPage), 'draft_sync_not_guarded_by_timer_lock', failed);
ok(/dom\.exitBtn\?\.addEventListener\('click',\s*handleExitClick\)/.test(unifiedPage), 'missing_exit_btn_binding', failed);
ok(/ENDLESS_USER_EXIT/.test(unifiedPage), 'missing_endless_exit_message', failed);
ok(/stopEndlessPractice/.test(unifiedPage), 'missing_endless_stop_function', failed);
@@ -33,6 +133,7 @@ function run() {
ok(/displayAnswerValue\(entry\.userAnswer\)/.test(unifiedPage), 'review_results_user_answer_not_normalized', failed);
ok(/displayAnswerValue\(entry\.correctAnswer,\s*''\)/.test(unifiedPage), 'review_results_correct_answer_not_normalized', failed);
ok(/setDropzoneAnswer\(dropzone,\s*value,\s*label\)/.test(unifiedPage), 'dropzone_replay_label_not_preserved', failed);
+ ok(/value:\s*item\.dataset\.heading\s*\|\|\s*item\.dataset\.option\s*\|\|\s*item\.dataset\.key/.test(unifiedPage), 'drag_payload_ignores_data_key', failed);
ok(/const valueList = splitAnswerTokens\(rawValue\);/.test(unifiedPage), 'replay_field_value_list_not_normalized', failed);
ok(!/String\(rawValue == null \? '' : rawValue\)\.split/.test(unifiedPage), 'replay_raw_object_string_split_regressed', failed);
ok(/--reading-left-pane-width/.test(unifiedHtml), 'missing_resizable_reading_pane_width_var', failed);
@@ -52,6 +153,7 @@ function run() {
ok(/#right \.tfng-item > p\s*\{[\s\S]*margin:\s*0 0 6px/.test(unifiedHtml), 'tfng_stem_option_spacing_not_scoped', failed);
ok(/\.tfng-options\s*\{[\s\S]*gap:\s*4px 12px/.test(unifiedHtml), 'tfng_option_row_spacing_missing', failed);
ok(/function restoreHighlights\s*\([\s\S]*?return restoredCount;/.test(highlightShared), 'restoreHighlights_no_restore_count', failed);
+ await testEndlessLifecycle(failed);
if (failed.length) {
process.stdout.write(JSON.stringify({
@@ -68,4 +170,4 @@ function run() {
}));
}
-run();
+await run();
diff --git a/developer/tests/js/unifiedReadingNotesMigration.test.js b/developer/tests/js/unifiedReadingNotesMigration.test.js
new file mode 100644
index 00000000..dbe38998
--- /dev/null
+++ b/developer/tests/js/unifiedReadingNotesMigration.test.js
@@ -0,0 +1,43 @@
+#!/usr/bin/env node
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(here, '..', '..', '..');
+const page = fs.readFileSync(path.join(root, 'js/runtime/unifiedReadingPage.js'), 'utf8');
+const highlights = fs.readFileSync(path.join(root, 'js/runtime/readingHighlightShared.js'), 'utf8');
+
+assert.match(highlights, /noteId:\s*node\.dataset/);
+assert.match(highlights, /offsetSpan\.dataset\.noteId\s*=\s*String\(record\.noteId\)/);
+assert.match(highlights, /span\.dataset\.noteId\s*=\s*String\(record\.noteId\)/);
+
+for (const [field, collector] of [['notes', 'collectNotes'], ['noteOutlines', 'collectNoteOutlines'], ['markedQuestions', 'getCurrentMarkedQuestions']]) {
+ assert.match(page, new RegExp(`${field}: ${collector}`, 'm'), `${field} must be collected into drafts/submissions`);
+ assert.match(page, new RegExp(`${field}: normalize`, 'm'), `${field} must be normalized at payload boundaries`);
+}
+
+assert.match(page, /if \(state\.reviewMode\) \{[\s\S]*postMessage\('READING_ANNOTATION_SYNC'/);
+assert.match(page, /postMessage\('READING_DRAFT_SYNC'/);
+assert.match(page, /function canSyncReadingDraft\(\)/);
+assert.match(page, /attachReadingDraftLifecycleHooks/);
+assert.match(page, /recordId:\s*state\.reviewRecordId/);
+assert.match(page, /annotations:\s*\{[\s\S]*highlights:[\s\S]*noteText:[\s\S]*notes:[\s\S]*noteOutlines:[\s\S]*markedQuestions:[\s\S]*scrollY:/);
+assert.match(page, /state\.reviewRecordId\s*=\s*String\(data\.recordId \|\| entry\.id/);
+assert.match(page, /function canEditReadingNotes\(\)[\s\S]*!state\.readOnly[\s\S]*!state\.timerLocked[\s\S]*!state\.memorizeMode[\s\S]*!state\.submitted/);
+assert.match(page, /const canEditNotes = canEditReadingNotes\(\)/);
+assert.match(page, /control\.closest\('#reading-note-editor, #reading-note-drawer'\)/);
+assert.match(page, /syncReadingAnnotation\('highlight'\)/);
+assert.match(page, /function clearStructuredNotesForReset\(\)[\s\S]*\.hl\[data-note-id\], \.hl\[data-hl-type="note"\]/);
+assert.match(page, /#reading-note-drawer\{[^}]*z-index:3600/);
+assert.match(page, /#reading-note-editor\{[^}]*z-index:3700/);
+assert.match(page, /data-result-question-id/);
+assert.match(page, /displayUserAnswer:\s*selectedTokens\.length/);
+
+assert.doesNotMatch(page, /saveLocalReadingRecord|ExamSystemDB|exam_system_practice_records|indexedDB/i);
+
+console.log(JSON.stringify({
+ status: 'pass',
+ detail: 'structured notes, note anchors, review sync, display controls and safe storage boundaries covered'
+}));
diff --git a/developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js b/developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js
index 68c0a87a..862652e6 100644
--- a/developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js
+++ b/developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js
@@ -15,18 +15,20 @@ function loadScript(relativePath, context) {
vm.runInContext(code, context, { filename: relativePath });
}
-function createSessionStorageStub() {
+function createWindowSessionStub() {
const store = new Map();
return {
- store,
- getItem(key) {
- return store.has(key) ? store.get(key) : null;
+ save(name, value) {
+ store.set(String(name), JSON.parse(JSON.stringify(value)));
+ return true;
},
- setItem(key, value) {
- store.set(key, String(value));
+ get(name) {
+ const value = store.get(String(name));
+ return value == null ? null : JSON.parse(JSON.stringify(value));
},
- removeItem(key) {
- store.delete(key);
+ discard(name) {
+ store.delete(String(name));
+ return true;
}
};
}
@@ -34,7 +36,25 @@ function createSessionStorageStub() {
function createDocumentStub() {
const radio = { checked: true, value: 'A' };
const notes = { value: 'fresh note' };
+ const timerClasses = new Set();
+ const pendingScripts = [];
+ const timer = {
+ textContent: '',
+ dataset: {},
+ style: {},
+ classList: {
+ add(...names) { names.forEach((name) => timerClasses.add(name)); },
+ remove(...names) { names.forEach((name) => timerClasses.delete(name)); },
+ toggle(name, enabled) {
+ if (enabled) timerClasses.add(name);
+ else timerClasses.delete(name);
+ },
+ contains(name) { return timerClasses.has(name); }
+ }
+ };
return {
+ __timer: timer,
+ __pendingScripts: pendingScripts,
body: {
dataset: {},
classList: {
@@ -56,32 +76,73 @@ function createDocumentStub() {
}
return [];
},
- getElementById() {
- return null;
+ getElementById(id) {
+ return id === 'timer' ? timer : null;
+ },
+ createElement(tagName) {
+ const normalizedTag = String(tagName || '').toLowerCase();
+ if (normalizedTag === 'script') {
+ return { src: '', defer: false, onload: null, onerror: null };
+ }
+ if (normalizedTag === 'template') {
+ return {
+ innerHTML: '',
+ content: { querySelectorAll() { return []; } }
+ };
+ }
+ return {
+ dataset: {},
+ style: {},
+ classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
+ setAttribute() {},
+ addEventListener() {},
+ appendChild() {}
+ };
+ },
+ head: {
+ appendChild(script) {
+ pendingScripts.push(script);
+ return script;
+ }
},
addEventListener() {},
removeEventListener() {}
};
}
+function hostEvent(sourceWindow, type, data, overrides = {}) {
+ return {
+ source: overrides.source || sourceWindow,
+ origin: overrides.origin || 'http://localhost',
+ data: { type, source: overrides.envelopeSource || 'exam_host', data }
+ };
+}
+
function createContext() {
- const sessionStorage = createSessionStorageStub();
+ const windowSession = createWindowSessionStub();
const document = createDocumentStub();
+ const messages = [];
+ let closeCount = 0;
const window = {
location: {
href: 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p1',
- search: '?examId=reading-p1'
+ search: '?examId=reading-p1',
+ protocol: 'http:'
},
history: { replaceState() {} },
document,
- sessionStorage,
+ AppData: {
+ ready: Promise.resolve(true),
+ recovery: { windowSession }
+ },
opener: null,
parent: null,
addEventListener() {},
removeEventListener() {},
scrollTo() {},
scrollY: 0,
- close() {},
+ close() { closeCount += 1; },
+ showMessage(text, type) { messages.push({ text, type }); },
console,
setTimeout,
clearTimeout,
@@ -132,22 +193,21 @@ function createContext() {
HTMLInputElement: window.HTMLInputElement,
HTMLTextAreaElement: window.HTMLTextAreaElement,
HTMLSelectElement: window.HTMLSelectElement,
- sessionStorage,
location: window.location
};
sandbox.globalThis = window;
- return { context: vm.createContext(sandbox), window, document, sessionStorage };
+ return { context: vm.createContext(sandbox), window, document, windowSession, messages, getCloseCount: () => closeCount };
}
function loadHooks() {
- const { context, window, sessionStorage } = createContext();
+ const { context, window, document, windowSession, messages, getCloseCount } = createContext();
window.__IELTS_READING_PAGE_TEST_HOOKS__ = true;
window.__READING_EXAM_MANIFEST__ = {};
window.__READING_EXAM_DATA__ = new Map();
loadScript('js/runtime/unifiedReadingPage.js', context);
const hooks = window.__IELTS_UNIFIED_READING_PAGE_TEST__;
assert(hooks, 'should expose unified reading page test hooks');
- return { hooks, window, sessionStorage };
+ return { hooks, window, document, windowSession, messages, getCloseCount };
}
function plain(value) {
@@ -178,6 +238,31 @@ async function testDraftArbitration() {
assert.strictEqual(fresh.updatedAt, 3000, 'newer draft must win updatedAt');
}
+async function testSuiteTimerModePrecedence() {
+ const { hooks, document } = loadHooks();
+ const pausedAtMs = Date.now();
+ hooks.setTestState({
+ suiteSessionId: 'suite-timer',
+ suiteTimerMode: 'elapsed',
+ suiteTimerLimitSeconds: 60,
+ suiteTimerAnchorMs: pausedAtMs - 120000,
+ pagePausedAtMs: pausedAtMs,
+ pagePausedOffsetMs: 0
+ });
+ hooks.renderTimer();
+ assert.strictEqual(document.__timer.textContent, '02:00', 'elapsed 套题必须显示正计时');
+ assert.strictEqual(document.__timer.dataset.timerMode, 'elapsed');
+ assert.strictEqual(document.__timer.classList.contains('timer-expired'), false, 'elapsed 套题不得按 limit 触发倒计时过期');
+
+ hooks.setTestState({
+ suiteTimerMode: 'countdown',
+ suiteTimerLimitSeconds: 3600
+ });
+ hooks.renderTimer();
+ assert.strictEqual(document.__timer.textContent, '58 minutes remaining');
+ assert.strictEqual(document.__timer.dataset.timerMode, 'countdown');
+}
+
async function testInlineEnvelopeGuard() {
const { hooks } = loadHooks();
@@ -231,7 +316,7 @@ async function testInlineEnvelopeGuard() {
}
async function testInlineReinitSnapshot() {
- const { hooks, sessionStorage, window } = loadHooks();
+ const { hooks, windowSession, window } = loadHooks();
hooks.setTestState({
examId: 'reading-p1',
@@ -284,9 +369,8 @@ async function testInlineReinitSnapshot() {
assert.deepStrictEqual(plain(slotEntry[1].draft.answers), { q1: 'A' }, 'slot draft must be updated before reinit');
assert.strictEqual(slotEntry[1].draft.noteText, 'fresh note', 'slot draft noteText must be updated before reinit');
- const storageKey = 'ielts_sim_draft::suite-1::reading-p1';
- assert(sessionStorage.store.has(storageKey), 'reinit snapshot must persist the local mirror');
- const stored = JSON.parse(sessionStorage.store.get(storageKey));
+ const stored = windowSession.get('simulation-draft:suite-1:reading-p1');
+ assert(stored, 'reinit snapshot must persist the window-session draft');
assert.deepStrictEqual(plain(stored.draft.answers), { q1: 'A' }, 'persisted mirror must use the captured draft');
}
@@ -299,8 +383,12 @@ async function testWindowSessionMessageGuard() {
sessionId: 'session-new',
suiteSessionId: 'suite-new',
parentWindow: sourceWindow,
+ expectedParentOrigin: 'http://localhost',
+ parentOrigin: 'http://localhost',
+ parentOriginIsOpaque: false,
windowSessionToken: 'token-new',
windowSessionIssuedAtMs: 5000,
+ windowSessionGeneration: 2,
lastInitSignature: '',
simulationCtx: { examId: 'reading-p2', flowMode: 'simulation', currentIndex: 1 },
suite: {
@@ -316,29 +404,21 @@ async function testWindowSessionMessageGuard() {
}
});
- await hooks.handleIncoming({
- source: sourceWindow,
- data: {
- type: 'INIT_SESSION',
- data: {
+ await hooks.handleIncoming(hostEvent(sourceWindow, 'INIT_SESSION', {
examId: 'reading-p2',
sessionId: 'session-new',
suiteSessionId: 'suite-new',
windowSessionToken: 'token-old',
- messageIssuedAtMs: 4000
- }
- }
- });
+ windowSessionGeneration: 1,
+ messageIssuedAtMs: 4000,
+ parentOrigin: 'http://localhost'
+ }));
let state = hooks.getTestState();
assert.strictEqual(state.lastInitSignature, '', 'stale INIT_SESSION must not overwrite current inline session');
assert.strictEqual(state.windowSessionToken, 'token-new', 'stale INIT_SESSION must not replace window token');
- await hooks.handleIncoming({
- source: sourceWindow,
- data: {
- type: 'SIMULATION_CONTEXT',
- data: {
+ await hooks.handleIncoming(hostEvent(sourceWindow, 'SIMULATION_CONTEXT', {
examId: 'reading-p2',
sessionId: 'session-new',
suiteSessionId: 'suite-new',
@@ -346,47 +426,580 @@ async function testWindowSessionMessageGuard() {
currentIndex: 0,
total: 3,
windowSessionToken: 'token-old',
+ windowSessionGeneration: 1,
messageIssuedAtMs: 4000,
suiteSequence: [
{ examId: 'reading-p1' },
{ examId: 'reading-p2' },
{ examId: 'reading-p3' }
]
- }
- }
- });
+ }));
state = hooks.getTestState();
assert.strictEqual(state.simulationCtx.currentIndex, 1, 'stale SIMULATION_CONTEXT must not replace active simulation context');
- await hooks.handleIncoming({
- source: sourceWindow,
- data: {
- type: 'INIT_SESSION',
- data: {
+ await hooks.handleIncoming(hostEvent(sourceWindow, 'INIT_SESSION', {
examId: 'reading-p2',
sessionId: 'session-newer',
suiteSessionId: 'suite-new',
windowSessionToken: 'token-newer',
- messageIssuedAtMs: 6000
- }
- }
- });
+ windowSessionGeneration: 3,
+ messageIssuedAtMs: 6000,
+ parentOrigin: 'http://localhost'
+ }));
state = hooks.getTestState();
assert.strictEqual(state.sessionId, 'session-newer', 'newer INIT_SESSION must still be accepted');
assert.strictEqual(state.windowSessionToken, 'token-newer', 'newer INIT_SESSION must adopt the latest window token');
+
+ await hooks.handleIncoming(hostEvent(sourceWindow, 'INIT_SESSION', {
+ examId: 'reading-p2',
+ sessionId: 'session-newer',
+ suiteSessionId: 'suite-new',
+ windowSessionToken: 'token-rebound',
+ windowSessionGeneration: 4,
+ messageIssuedAtMs: 7000,
+ parentOrigin: 'http://localhost'
+ }));
+
+ state = hooks.getTestState();
+ assert.strictEqual(
+ state.windowSessionToken,
+ 'token-rebound',
+ 'same-session rebind INIT must not be suppressed when token/generation rotates'
+ );
+
+ assert.strictEqual(
+ hooks.shouldAcceptWindowSessionMessage({
+ windowSessionToken: 'token-same-ms-old',
+ windowSessionGeneration: 4,
+ messageIssuedAtMs: 7000
+ }, sourceWindow),
+ false,
+ '同一注册代际的旧 token 即使时间戳相同也必须拒绝'
+ );
+ assert.strictEqual(
+ hooks.shouldAcceptWindowSessionMessage({
+ windowSessionToken: 'token-next-generation',
+ windowSessionGeneration: 5,
+ messageIssuedAtMs: 7000
+ }, sourceWindow),
+ true,
+ '更高注册代际必须覆盖旧 token'
+ );
+ hooks.setTestState({
+ windowSessionToken: 'token-current-no-generation',
+ windowSessionIssuedAtMs: 6000,
+ windowSessionGeneration: 0
+ });
+ assert.strictEqual(
+ hooks.shouldAcceptWindowSessionMessage({
+ windowSessionToken: 'token-equal-ms-no-generation',
+ messageIssuedAtMs: 6000
+ }, sourceWindow),
+ false,
+ '缺少注册代际且时间戳相等的不同 token 必须拒绝'
+ );
+ hooks.stopReadingDraftSync();
+ hooks.stopSimulationDraftSync();
+}
+
+async function testReferrerlessInitBindsOnlyTrustedHost() {
+ const { hooks } = loadHooks();
+ const parentWindow = { postMessage() {} };
+ hooks.setTestState({
+ examId: 'reading-p1',
+ sessionId: null,
+ suiteSessionId: null,
+ parentWindow,
+ expectedParentOrigin: '',
+ parentOrigin: '',
+ parentOriginIsOpaque: false,
+ windowSessionToken: '',
+ lastInitSignature: ''
+ });
+ const initData = {
+ examId: 'reading-p1',
+ sessionId: 'referrerless-session',
+ parentOrigin: 'https://host.example',
+ windowSessionToken: 'referrerless-token'
+ };
+ await hooks.handleIncoming(hostEvent({ postMessage() {} }, 'INIT_SESSION', initData, { origin: 'https://host.example' }));
+ assert.strictEqual(hooks.getTestState().parentOrigin, '', 'a forged source must not bind a referrerless child');
+ await hooks.handleIncoming(hostEvent(parentWindow, 'INIT_SESSION', initData, { origin: 'https://attacker.invalid' }));
+ assert.strictEqual(hooks.getTestState().parentOrigin, '', 'a mismatched origin must not bind a referrerless child');
+ await hooks.handleIncoming(hostEvent(parentWindow, 'INIT_SESSION', initData, { origin: 'https://host.example' }));
+ assert.strictEqual(hooks.getTestState().parentOrigin, 'https://host.example', 'trusted non-opaque INIT must bind the missing referrer origin');
+ assert.strictEqual(hooks.getTestState().windowSessionToken, 'referrerless-token');
+
+ const fileHarness = loadHooks();
+ const fileParent = { postMessage() {} };
+ fileHarness.window.location.protocol = 'file:';
+ fileHarness.hooks.setTestState({
+ examId: 'reading-p1',
+ sessionId: null,
+ suiteSessionId: null,
+ parentWindow: fileParent,
+ expectedParentOrigin: '',
+ parentOrigin: '',
+ parentOriginIsOpaque: false,
+ windowSessionToken: '',
+ lastInitSignature: ''
+ });
+ await fileHarness.hooks.handleIncoming(hostEvent(fileParent, 'INIT_SESSION', {
+ examId: 'reading-p1',
+ sessionId: 'file-session',
+ parentOrigin: 'file://',
+ windowSessionToken: 'file-token'
+ }, { origin: 'file://' }));
+ assert.strictEqual(fileHarness.hooks.getTestState().parentOrigin, 'null',
+ 'file:// opener INIT must bind the opaque origin');
+ assert.strictEqual(fileHarness.hooks.getTestState().parentOriginIsOpaque, true);
+ assert.strictEqual(fileHarness.hooks.getTestState().windowSessionToken, 'file-token');
+}
+
+async function testSavedRecordAcknowledgementSessionGate() {
+ const { hooks } = loadHooks();
+ const sourceWindow = { name: 'saved-record-host' };
+ hooks.setTestState({
+ examId: 'reading-p1',
+ sessionId: 'session-current',
+ submittedRecordId: 'record-existing',
+ parentWindow: sourceWindow,
+ expectedParentOrigin: 'http://localhost',
+ parentOrigin: 'http://localhost',
+ parentOriginIsOpaque: false,
+ windowSessionToken: 'token-current',
+ suite: {
+ inline: false,
+ slotsByExamId: new Map()
+ }
+ });
+
+ await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', {
+ examId: 'reading-p1',
+ sessionId: 'session-stale',
+ recordId: 'record-stale',
+ windowSessionToken: 'token-current'
+ }));
+ assert.strictEqual(
+ hooks.getTestState().submittedRecordId,
+ 'record-existing',
+ 'a late acknowledgement from an older session must be ignored'
+ );
+
+ await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', {
+ examId: 'reading-p1',
+ recordId: 'record-without-session',
+ windowSessionToken: 'token-current'
+ }));
+ assert.strictEqual(
+ hooks.getTestState().submittedRecordId,
+ 'record-existing',
+ 'an acknowledgement without a session binding must be ignored'
+ );
+
+ const validAcknowledgement = {
+ examId: 'reading-p1',
+ sessionId: 'session-current',
+ recordId: 'record-current',
+ windowSessionToken: 'token-current'
+ };
+ await hooks.handleIncoming(hostEvent(
+ sourceWindow,
+ 'PRACTICE_RECORD_SAVED',
+ validAcknowledgement,
+ { origin: 'https://attacker.invalid' }
+ ));
+ await hooks.handleIncoming(hostEvent(
+ sourceWindow,
+ 'PRACTICE_RECORD_SAVED',
+ validAcknowledgement,
+ { source: { name: sourceWindow.name } }
+ ));
+ await hooks.handleIncoming(hostEvent(
+ sourceWindow,
+ 'PRACTICE_RECORD_SAVED',
+ validAcknowledgement,
+ { envelopeSource: 'practice_page' }
+ ));
+ await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', {
+ ...validAcknowledgement,
+ windowSessionToken: 'token-forged'
+ }));
+ assert.strictEqual(
+ hooks.getTestState().submittedRecordId,
+ 'record-existing',
+ 'wrong origin/window/source/token must not overwrite the saved record binding'
+ );
+
+ await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', {
+ ...validAcknowledgement
+ }));
+ assert.strictEqual(
+ hooks.getTestState().submittedRecordId,
+ 'record-current',
+ 'the current session acknowledgement should bind its saved record id'
+ );
+
+ hooks.setTestState({ sessionId: null });
+ await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', {
+ examId: 'reading-p1',
+ sessionId: 'session-current',
+ recordId: 'record-during-restart',
+ windowSessionToken: 'token-current'
+ }));
+ assert.strictEqual(
+ hooks.getTestState().submittedRecordId,
+ 'record-current',
+ 'an acknowledgement received during session restart must be ignored'
+ );
+}
+
+async function testSubmitAcknowledgementStateMachine() {
+ const falseHarness = loadHooks();
+ falseHarness.hooks.setTestState({
+ examId: 'reading-p1',
+ sessionId: 'session-submit-false',
+ parentWindow: { postMessage() { return false; } },
+ expectedParentOrigin: 'http://localhost',
+ parentOrigin: 'http://localhost',
+ parentOriginIsOpaque: false,
+ windowSessionToken: 'token-submit-false',
+ submissionStatus: 'draft',
+ submissionId: ''
+ });
+ assert.strictEqual(falseHarness.hooks.beginSubmission('PRACTICE_COMPLETE', {}), false);
+ assert.strictEqual(falseHarness.hooks.getTestState().submissionStatus, 'draft');
+ assert.strictEqual(falseHarness.hooks.getTestState().readOnly, false);
+
+ const failedHarness = loadHooks();
+ const throwingParent = {
+ postMessage() {
+ throw new Error('delivery failed');
+ }
+ };
+ failedHarness.hooks.setTestState({
+ examId: 'reading-p1',
+ sessionId: 'session-submit-failed',
+ parentWindow: throwingParent,
+ expectedParentOrigin: 'http://localhost',
+ parentOrigin: 'http://localhost',
+ parentOriginIsOpaque: false,
+ windowSessionToken: 'token-submit-failed',
+ submissionStatus: 'draft',
+ submissionId: ''
+ });
+ assert.strictEqual(
+ failedHarness.hooks.beginSubmission('PRACTICE_COMPLETE', { answers: { q1: 'A' } }),
+ false,
+ 'a synchronous postMessage failure must reject the submission attempt'
+ );
+ assert.strictEqual(failedHarness.hooks.getTestState().submissionStatus, 'draft');
+ assert.strictEqual(failedHarness.hooks.getTestState().readOnly, false);
+
+ const delivered = [];
+ const sourceWindow = {
+ postMessage(message) {
+ delivered.push(message);
+ }
+ };
+ const { hooks, messages, getCloseCount } = loadHooks();
+ hooks.setTestState({
+ examId: 'reading-p1',
+ sessionId: 'session-submit-current',
+ suiteSessionId: null,
+ parentWindow: sourceWindow,
+ expectedParentOrigin: 'http://localhost',
+ parentOrigin: 'http://localhost',
+ parentOriginIsOpaque: false,
+ windowSessionToken: 'token-submit-current',
+ submissionStatus: 'draft',
+ submissionId: ''
+ });
+
+ assert.strictEqual(hooks.beginSubmission('PRACTICE_COMPLETE', { answers: { q1: 'A' } }), true);
+ let state = hooks.getTestState();
+ const submissionId = state.submissionId;
+ assert.strictEqual(state.submissionStatus, 'submitting');
+ assert.strictEqual(state.submitted, false, 'delivery alone must not mark the page submitted');
+ assert.strictEqual(state.readOnly, false, 'delivery alone must not lock the page');
+ assert.strictEqual(delivered.length, 1);
+ assert.strictEqual(delivered[0].data.submissionId, submissionId);
+ assert.strictEqual(
+ hooks.beginSubmission('PRACTICE_COMPLETE', { answers: { q1: 'A' } }),
+ false,
+ 'a duplicate click while submitting must be ignored'
+ );
+ assert.strictEqual(delivered.length, 1, 'duplicate clicks must not emit a second message');
+
+ await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_SUBMIT_ACK', {
+ sessionId: 'session-submit-current',
+ submissionId,
+ windowSessionToken: 'token-submit-current'
+ }));
+ assert.strictEqual(hooks.getTestState().submissionStatus, 'submitting', 'ACK without examId must be ignored');
+
+ await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_SUBMIT_FAILED', {
+ examId: 'reading-p1',
+ sessionId: 'session-submit-current',
+ submissionId,
+ windowSessionToken: 'token-submit-current',
+ errorCode: 'suite_recovery_save_failed'
+ }));
+ state = hooks.getTestState();
+ assert.strictEqual(state.submissionStatus, 'draft');
+ assert.strictEqual(state.readOnly, false);
+ assert.strictEqual(messages.length, 1, 'a persistence NACK must be visible in the active question page');
+ assert.strictEqual(messages[0].type, 'error');
+ assert.match(messages[0].text, /未能安全保存/);
+ await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_SUBMIT_ACK', {
+ examId: 'reading-p1',
+ sessionId: 'session-submit-current',
+ submissionId,
+ windowSessionToken: 'token-submit-current'
+ }));
+ assert.strictEqual(hooks.getTestState().submissionStatus, 'draft', 'late ACK after NACK must not submit the page');
+ assert.strictEqual(hooks.getTestState().readOnly, false);
+
+ assert.strictEqual(hooks.beginSubmission('PRACTICE_COMPLETE', { answers: { q1: 'A' } }), true);
+ assert.strictEqual(delivered.length, 2);
+ assert.strictEqual(delivered[1].data.submissionId, submissionId, 'retry must reuse the idempotency key');
+
+ await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_SUBMIT_ACK', {
+ examId: 'reading-p1',
+ sessionId: 'session-submit-current',
+ submissionId,
+ windowSessionToken: 'token-submit-current'
+ }));
+ state = hooks.getTestState();
+ assert.strictEqual(state.submissionStatus, 'submitted');
+ assert.strictEqual(state.submitted, true);
+ assert.strictEqual(state.readOnly, true, 'only a valid ACK may lock the page');
+ assert.strictEqual(getCloseCount(), 0, 'single-passage ACK must keep its result page open');
+
+ const suiteHarness = loadHooks();
+ const suiteParent = { postMessage() {} };
+ suiteHarness.hooks.setTestState({
+ examId: 'reading-p3',
+ sessionId: 'session-suite-final',
+ suiteSessionId: 'suite-final',
+ parentWindow: suiteParent,
+ expectedParentOrigin: 'http://localhost',
+ parentOrigin: 'http://localhost',
+ parentOriginIsOpaque: false,
+ windowSessionToken: 'token-suite-final',
+ simulationMode: true,
+ simulationCtx: { isLast: true },
+ submissionStatus: 'draft',
+ submissionId: ''
+ });
+ assert.strictEqual(suiteHarness.hooks.beginSubmission('SIMULATION_SUBMIT', {
+ suiteSessionId: 'suite-final'
+ }), true);
+ const suiteSubmissionId = suiteHarness.hooks.getTestState().submissionId;
+ await suiteHarness.hooks.handleIncoming(hostEvent(suiteParent, 'PRACTICE_SUBMIT_ACK', {
+ examId: 'reading-p3',
+ sessionId: 'session-suite-final',
+ suiteSessionId: 'suite-final',
+ submissionId: suiteSubmissionId,
+ windowSessionToken: 'token-suite-final'
+ }));
+ assert.strictEqual(suiteHarness.getCloseCount(), 1, 'final simulation-suite ACK must close the child page');
+
+ const timeoutHarness = loadHooks();
+ const timeoutParent = { postMessage() {} };
+ timeoutHarness.hooks.setTestState({
+ examId: 'reading-p1',
+ sessionId: 'session-submit-timeout',
+ parentWindow: timeoutParent,
+ expectedParentOrigin: 'http://localhost',
+ parentOrigin: 'http://localhost',
+ parentOriginIsOpaque: false,
+ windowSessionToken: 'token-submit-timeout',
+ submissionStatus: 'draft',
+ submissionId: ''
+ });
+ assert.strictEqual(timeoutHarness.hooks.beginSubmission('PRACTICE_COMPLETE', {}), true);
+ const timeoutSubmissionId = timeoutHarness.hooks.getTestState().submissionId;
+ assert.strictEqual(timeoutHarness.hooks.expirePendingSubmission(timeoutSubmissionId), true);
+ assert.strictEqual(timeoutHarness.hooks.getTestState().submissionStatus, 'draft');
+ assert.strictEqual(timeoutHarness.hooks.getTestState().readOnly, false);
+ await timeoutHarness.hooks.handleIncoming(hostEvent(timeoutParent, 'PRACTICE_SUBMIT_ACK', {
+ examId: 'reading-p1',
+ sessionId: 'session-submit-timeout',
+ submissionId: timeoutSubmissionId,
+ windowSessionToken: 'token-submit-timeout'
+ }));
+ assert.strictEqual(timeoutHarness.hooks.getTestState().submissionStatus, 'draft', 'late ACK after timeout must be ignored');
+ assert.strictEqual(timeoutHarness.hooks.getTestState().readOnly, false);
+}
+
+async function testInteractionDraftWritesParentWalImmediately() {
+ const { hooks, window } = loadHooks();
+ const received = [];
+ const parentWindow = {
+ closed: false,
+ app: {
+ receiveSuiteDraftSnapshotFromChild(examId, payload, sourceWindow) {
+ received.push({ examId, payload: plain(payload), sourceWindow });
+ return Promise.resolve(true);
+ }
+ },
+ postMessage() {}
+ };
+ hooks.setTestState({
+ examId: 'reading-p1',
+ sessionId: 'session-wal',
+ suiteSessionId: 'suite-wal',
+ parentWindow,
+ simulationMode: true,
+ readOnly: false,
+ timerLocked: false,
+ windowSessionToken: 'token-wal',
+ windowSessionGeneration: 7
+ });
+ hooks.scheduleInteractionDraftSync('input');
+ await Promise.resolve();
+ assert.strictEqual(received.length, 1, 'input microtask must write parent WAL without waiting for periodic timer');
+ assert.strictEqual(received[0].examId, 'reading-p1');
+ assert.strictEqual(received[0].payload.suiteSessionId, 'suite-wal');
+ assert.strictEqual(received[0].payload.windowSessionToken, 'token-wal');
+ assert.strictEqual(received[0].payload.windowSessionGeneration, 7);
+ assert.strictEqual(received[0].sourceWindow, window, 'direct WAL must identify the exact child WindowProxy');
+}
+
+async function testStaleInitCannotInstallDatasetsAfterNewerGeneration() {
+ const { hooks, window, document } = loadHooks();
+ const sourceWindow = { postMessage() {} };
+ window.__READING_EXAM_MANIFEST__ = {
+ 'reading-slow': {
+ dataKey: 'reading-slow',
+ script: 'slow-reading-dataset.js',
+ title: 'Slow dataset'
+ }
+ };
+ window.__READING_EXAM_DATA__ = new Map();
+ hooks.setTestState({
+ examId: 'reading-slow',
+ sessionId: null,
+ suiteSessionId: null,
+ sessionReadySent: false,
+ parentWindow: sourceWindow,
+ expectedParentOrigin: 'http://localhost',
+ parentOrigin: 'http://localhost',
+ parentOriginIsOpaque: false,
+ windowSessionToken: '',
+ windowSessionGeneration: 0,
+ hostInitEpoch: 0,
+ lastInitSignature: '',
+ suite: {
+ inline: false,
+ activeExamId: null,
+ currentIndex: 0,
+ sequence: [],
+ slotsByExamId: new Map()
+ }
+ });
+
+ const staleInit = hooks.handleIncoming(hostEvent(sourceWindow, 'INIT_SESSION', {
+ examId: 'reading-slow',
+ sessionId: 'session-stale',
+ suiteSessionId: 'suite-race',
+ suiteFlowMode: 'simulation',
+ suiteSequenceIndex: 0,
+ suiteSequenceTotal: 1,
+ suiteSequence: [{ examId: 'reading-slow', dataKey: 'reading-slow' }],
+ windowSessionToken: 'token-stale',
+ windowSessionGeneration: 1,
+ messageIssuedAtMs: 1000,
+ parentOrigin: 'http://localhost'
+ }));
+ await Promise.resolve();
+ await Promise.resolve();
+ assert.strictEqual(document.__pendingScripts.length, 1, 'stale INIT should be waiting on its dataset script');
+
+ await hooks.handleIncoming(hostEvent(sourceWindow, 'INIT_SESSION', {
+ examId: 'reading-slow',
+ sessionId: 'session-current',
+ suiteSessionId: 'suite-race',
+ windowSessionToken: 'token-current',
+ windowSessionGeneration: 2,
+ messageIssuedAtMs: 2000,
+ parentOrigin: 'http://localhost'
+ }));
+ const currentSignature = hooks.getTestState().lastInitSignature;
+ assert(currentSignature, 'newer INIT should complete before the stale dataset load');
+
+ window.__READING_EXAM_DATA__.set('reading-slow', {
+ meta: { title: 'Slow dataset' },
+ passage: { blocks: [] },
+ questionGroups: [],
+ questionOrder: []
+ });
+ document.__pendingScripts[0].onload();
+ await staleInit;
+
+ const state = hooks.getTestState();
+ assert.strictEqual(state.windowSessionToken, 'token-current', 'stale INIT must not replace the newer token');
+ assert.strictEqual(state.lastInitSignature, currentSignature, 'stale INIT must not replace the newer INIT signature');
+ assert.strictEqual(state.suiteInline, false, 'stale INIT must not install inline suite state after a newer generation');
+ assert.deepStrictEqual(plain(state.suiteSequence), [], 'stale INIT must not overwrite the newer suite sequence');
+}
+
+async function testFileRebindChallengeProvesExistingWindowIdentity() {
+ const { hooks, window } = loadHooks();
+ const proofs = [];
+ const parentWindow = { postMessage(message, origin) { proofs.push({ message: plain(message), origin }); } };
+ window.location.protocol = 'file:';
+ hooks.setTestState({
+ examId: 'reading-p1',
+ sessionId: 'reading-p1-session',
+ suiteSessionId: 'suite-file-proof',
+ parentWindow,
+ parentOrigin: 'null',
+ parentOriginIsOpaque: true,
+ windowSessionToken: 'file-proof-token',
+ windowSessionGeneration: 9
+ });
+ await hooks.handleIncoming(hostEvent(parentWindow, 'SUITE_REBIND_CHALLENGE', {
+ challenge: 'challenge-1',
+ suiteSessionId: 'suite-file-proof',
+ examId: 'reading-p1',
+ windowSessionToken: 'file-proof-token'
+ }, { origin: 'null' }));
+ assert.strictEqual(proofs.length, 1, 'the surviving file child must answer its original parent');
+ assert.strictEqual(proofs[0].origin, '*');
+ assert.strictEqual(proofs[0].message.type, 'SUITE_REBIND_PROOF');
+ assert.strictEqual(proofs[0].message.data.windowSessionToken, 'file-proof-token');
+ assert.strictEqual(proofs[0].message.data.windowSessionGeneration, 9);
+
+ await hooks.handleIncoming(hostEvent({ postMessage() {} }, 'SUITE_REBIND_CHALLENGE', {
+ challenge: 'challenge-forged',
+ suiteSessionId: 'suite-file-proof',
+ examId: 'reading-p1',
+ windowSessionToken: 'file-proof-token'
+ }, { origin: 'null' }));
+ assert.strictEqual(proofs.length, 1, 'a different WindowProxy must not obtain the proof token');
}
async function main() {
await testDraftArbitration();
+ await testSuiteTimerModePrecedence();
await testInlineEnvelopeGuard();
await testInlineReinitSnapshot();
await testWindowSessionMessageGuard();
+ await testReferrerlessInitBindsOnlyTrustedHost();
+ await testSavedRecordAcknowledgementSessionGate();
+ await testSubmitAcknowledgementStateMachine();
+ await testInteractionDraftWritesParentWalImmediately();
+ await testStaleInitCannotInstallDatasetsAfterNewerGeneration();
+ await testFileRebindChallengeProvesExistingWindowIdentity();
process.stdout.write(JSON.stringify({
status: 'pass',
detail: 'unified reading inline suite regressions covered'
}));
+ process.exit(0);
}
main().catch((error) => {
diff --git a/developer/tests/js/vocabDataIO.test.js b/developer/tests/js/vocabDataIO.test.js
new file mode 100644
index 00000000..888fedd9
--- /dev/null
+++ b/developer/tests/js/vocabDataIO.test.js
@@ -0,0 +1,95 @@
+#!/usr/bin/env node
+import assert from 'assert';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+global.window = global;
+global.AppData = {
+ ready: Promise.resolve(),
+ vocab: {
+ async getConfig() {
+ return { activeListId: 'spelling-errors-p1', dailyNew: 8 };
+ },
+ async readList() {
+ throw new Error('进度导出不应读取原始 collection 词条');
+ }
+ }
+};
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const repoRoot = path.resolve(__dirname, '../../..');
+eval(fs.readFileSync(path.join(repoRoot, 'js/utils/vocabDataIO.js'), 'utf8'));
+
+function makeJsonFile(payload, name = 'data.json') {
+ const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
+ Object.defineProperty(blob, 'name', { value: name });
+ return blob;
+}
+
+const vendorWords = await window.VocabDataIO.importWordList(makeJsonFile({
+ version: 'vendor-1',
+ words: [{ id: 'vendor-1', word: 'alpha', meaning: 'A', correctCount: 2 }]
+}, 'vendor.json'));
+assert.strictEqual(vendorWords.type, 'wordlist');
+assert.strictEqual(vendorWords.entries.length, 1);
+
+const importBlob = new Blob([JSON.stringify({
+ version: '2.0',
+ listId: 'spelling-errors-p1',
+ config: { activeListId: 'spelling-errors-p1', dailyNew: 8 },
+ words: [{ id: 'word-1', word: 'garden', meaning: '花园', nextReview: '2026-07-25T00:00:00.000Z' }]
+})], { type: 'application/json' });
+Object.defineProperty(importBlob, 'name', { value: 'progress.json' });
+
+const imported = await window.VocabDataIO.importWordList(importBlob);
+assert.strictEqual(imported.type, 'progress');
+assert.strictEqual(imported.meta.listId, 'spelling-errors-p1');
+assert.strictEqual(imported.entries[0].nextReview, '2026-07-25T00:00:00.000Z');
+
+await assert.rejects(
+ window.VocabDataIO.importWordList(makeJsonFile({
+ type: 'progress',
+ version: '2.0',
+ listId: 'spelling-errors-p1',
+ config: { activeListId: 'spelling-errors-p1' },
+ words: [{ word: 123, meaning: { value: 'bad' } }]
+ }, 'bad-progress.json')),
+ /invalid|无效/i
+);
+
+await assert.rejects(
+ window.VocabDataIO.importWordList(makeJsonFile({
+ type: 'progress',
+ words: [{ word: 'alpha', meaning: 'A' }]
+ }, 'incomplete-progress.json')),
+ /v2|配置|词表/i
+);
+
+await assert.rejects(
+ window.VocabDataIO.importWordList(makeJsonFile({
+ version: '0.6.2-fix',
+ config: { activeListId: 'default' },
+ words: [{ word: 'alpha', meaning: 'A', correctCount: 2 }],
+ reviewQueue: ['alpha']
+ }, 'v1-progress.json')),
+ /不支持 v1 进度备份/
+);
+
+const exportBlob = await window.VocabDataIO.exportProgress([
+ { id: 'word-1', word: 'garden', meaning: '花园', userInput: 'gardon' }
+]);
+const exported = JSON.parse(await exportBlob.text());
+assert.strictEqual(exported.listId, 'spelling-errors-p1');
+assert.strictEqual(exported.type, 'progress');
+assert.strictEqual(exported.words[0].word, 'garden');
+assert.strictEqual(Object.prototype.hasOwnProperty.call(exported, 'reviewQueue'), false);
+const roundTrip = await window.VocabDataIO.importWordList(makeJsonFile(exported, 'round-trip.json'));
+assert.strictEqual(roundTrip.type, 'progress');
+assert.strictEqual(roundTrip.entries[0].meaning, '花园');
+
+console.log(JSON.stringify({
+ status: 'pass',
+ detail: 'vocab progress import/export preserves canonical list identity and excludes derived queue'
+}, null, 2));
diff --git a/developer/tests/js/vocabStore.test.js b/developer/tests/js/vocabStore.test.js
index cb80d4fa..1a82c308 100644
--- a/developer/tests/js/vocabStore.test.js
+++ b/developer/tests/js/vocabStore.test.js
@@ -9,42 +9,100 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..', '..', '..');
-function createLocalStorage(seed = {}) {
- const store = new Map(Object.entries(seed));
- return {
- getItem(key) {
- return store.has(key) ? store.get(key) : null;
+function clone(value) {
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
+}
+
+function createVocabFacade(seed = {}) {
+ const state = {
+ words: clone(seed.words || []),
+ collections: clone(seed.collections || {}),
+ config: { activeListId: 'default', ...(clone(seed.config) || {}) }
+ };
+ const vocab = {
+ async getConfig() {
+ return clone(state.config);
+ },
+ async listWords() {
+ return clone(state.words);
+ },
+ async listCollections() {
+ return clone(state.collections);
},
- setItem(key, value) {
- store.set(key, String(value));
+ async replaceListWords({ listId = 'default', words = [] }) {
+ if (seed.failReplace) throw new Error('backend write failed');
+ if (listId === 'default') {
+ state.words = clone(words);
+ } else {
+ state.collections[listId] = {
+ ...(state.collections[listId] || {}),
+ id: listId,
+ words: clone(words)
+ };
+ }
+ return { committed: true };
},
- removeItem(key) {
- store.delete(key);
+ async mergeListWords({ listId = 'default', words = [] }) {
+ const target = listId === 'default'
+ ? state.words
+ : (state.collections[listId]?.words || []);
+ const merged = clone(target);
+ let addedCount = 0;
+ let updatedCount = 0;
+ for (const incoming of words) {
+ const identity = String(incoming.word || incoming.id || '').trim().toLowerCase();
+ const index = merged.findIndex((word) => String(word.word || word.id || '').trim().toLowerCase() === identity);
+ if (index >= 0) {
+ merged[index] = { ...merged[index], ...clone(incoming) };
+ updatedCount += 1;
+ } else {
+ merged.push(clone(incoming));
+ addedCount += 1;
+ }
+ }
+ await this.replaceListWords({ listId, words: merged });
+ return { committed: true, words: clone(merged), addedCount, updatedCount };
},
- clear() {
- store.clear();
+ async patchConfig(patch = {}) {
+ state.config = { ...state.config, ...clone(patch) };
+ return { committed: true };
+ },
+ async activateList(listId) {
+ state.config = { ...state.config, activeListId: listId };
+ return { committed: true };
+ },
+ async patchWord({ listId = 'default', wordId, patch = {} }) {
+ const source = listId === 'default'
+ ? state.words
+ : (state.collections[listId]?.words || []);
+ const index = source.findIndex((word) => (word.id || word.word) === wordId);
+ if (index < 0) throw new Error(`Unknown word: ${wordId}`);
+ source[index] = { ...source[index], ...clone(patch) };
+ return { committed: true, word: clone(source[index]) };
}
};
+ return { state, vocab };
}
-function loadVocabStore({ embeddedWords, storageSeed }) {
+function loadVocabStore({ embeddedWords, dataSeed }) {
const quietConsole = {
log() {},
warn() {},
error() {},
info() {}
};
+ const { state: appDataState, vocab } = createVocabFacade(dataSeed);
const windowStub = {
console: quietConsole,
__EMBEDDED_WORDLISTS__: {
ielts_core: embeddedWords || []
},
- location: { protocol: 'file:' }
+ location: { protocol: 'file:' },
+ AppData: { ready: Promise.resolve(), vocab }
};
const sandbox = {
window: windowStub,
console: quietConsole,
- localStorage: createLocalStorage(storageSeed),
Date,
Math,
JSON,
@@ -52,7 +110,6 @@ function loadVocabStore({ embeddedWords, storageSeed }) {
clearTimeout
};
sandbox.globalThis = sandbox.window;
- sandbox.window.localStorage = sandbox.localStorage;
sandbox.window.Date = Date;
sandbox.window.Math = Math;
sandbox.window.JSON = JSON;
@@ -62,6 +119,7 @@ function loadVocabStore({ embeddedWords, storageSeed }) {
const context = vm.createContext(sandbox);
const source = fs.readFileSync(path.join(repoRoot, 'js/core/vocabStore.js'), 'utf8');
vm.runInContext(source, context, { filename: 'js/core/vocabStore.js' });
+ sandbox.window.VocabStore.__appDataState = appDataState;
return sandbox.window.VocabStore;
}
@@ -72,9 +130,11 @@ async function testSpellingErrorUsesEmbeddedLexiconMeaning() {
meaning: 'n. 住宿',
example: 'The hotel provides comfortable accommodation.'
}],
- storageSeed: {
- vocab_list_p1_errors: JSON.stringify({
- id: 'p1',
+ dataSeed: {
+ words: [{ id: 'default-seed', word: 'unrelated', meaning: 'seed' }],
+ collections: {
+ 'spelling-errors-p1': {
+ id: 'spelling-errors-p1',
words: [{
word: 'accommodation',
userInput: 'accomodation',
@@ -84,7 +144,8 @@ async function testSpellingErrorUsesEmbeddedLexiconMeaning() {
errorCount: 2,
source: 'p1'
}]
- })
+ }
+ }
}
});
@@ -102,9 +163,11 @@ async function testSpellingErrorUsesEmbeddedLexiconMeaning() {
async function testSpellingErrorFallsBackWhenLexiconMissing() {
const vocabStore = loadVocabStore({
embeddedWords: [],
- storageSeed: {
- vocab_list_p4_errors: JSON.stringify({
- id: 'p4',
+ dataSeed: {
+ words: [{ id: 'default-seed', word: 'unrelated', meaning: 'seed' }],
+ collections: {
+ 'spelling-errors-p4': {
+ id: 'spelling-errors-p4',
words: [{
word: 'specialised',
userInput: 'specializedd',
@@ -114,7 +177,8 @@ async function testSpellingErrorFallsBackWhenLexiconMissing() {
errorCount: 1,
source: 'p4'
}]
- })
+ }
+ }
}
});
@@ -130,8 +194,12 @@ async function testSpellingErrorFallsBackWhenLexiconMissing() {
async function testSpellingErrorPreservesStoredMeaningAndMetadata() {
const vocabStore = loadVocabStore({
embeddedWords: [],
- storageSeed: {
- vocab_list_master_errors: JSON.stringify([{
+ dataSeed: {
+ words: [{ id: 'default-seed', word: 'unrelated', meaning: 'seed' }],
+ collections: {
+ 'spelling-errors-master': {
+ id: 'spelling-errors-master',
+ words: [{
id: 'spelling-all-garden',
word: 'garden',
meaning: 'n. 花园;庭院',
@@ -145,7 +213,9 @@ async function testSpellingErrorPreservesStoredMeaningAndMetadata() {
acceptedAnswers: ['green garden', 'green gardens'],
canonicalAnswer: 'green garden',
reasonCode: 'edit'
- }])
+ }]
+ }
+ }
}
});
@@ -168,8 +238,12 @@ async function testSpellingErrorPreservesStoredMeaningAndMetadata() {
async function testSpellingErrorMetadataSurvivesStudyUpdates() {
const vocabStore = loadVocabStore({
embeddedWords: [],
- storageSeed: {
- vocab_list_master_errors: JSON.stringify([{
+ dataSeed: {
+ words: [{ id: 'default-seed', word: 'unrelated', meaning: 'seed' }],
+ collections: {
+ 'spelling-errors-master': {
+ id: 'spelling-errors-master',
+ words: [{
id: 'spelling-all-garden',
word: 'garden',
meaning: 'n. 花园;庭院',
@@ -181,7 +255,9 @@ async function testSpellingErrorMetadataSurvivesStudyUpdates() {
source: 'p1',
acceptedAnswers: ['green garden'],
canonicalAnswer: 'green garden'
- }])
+ }]
+ }
+ }
}
});
@@ -199,6 +275,83 @@ async function testSpellingErrorMetadataSurvivesStudyUpdates() {
assert.strictEqual(updated.errorCount, 3, '背诵更新不应该洗掉错误次数');
assert.deepStrictEqual(updated.acceptedAnswers, ['green garden']);
assert.strictEqual(updated.canonicalAnswer, 'green garden');
+ assert.strictEqual(vocabStore.__appDataState.config.activeListId, 'spelling-errors-master');
+ assert.strictEqual(
+ vocabStore.__appDataState.collections['spelling-errors-master'].words[0].note,
+ 'new memory note',
+ '学习更新必须通过 AppData.vocab.patchWord 提交'
+ );
+}
+
+async function testDefaultLexiconWriteFailureRejectsInitialization() {
+ const vocabStore = loadVocabStore({
+ embeddedWords: [{ word: 'alpha', meaning: 'A' }],
+ dataSeed: { failReplace: true }
+ });
+
+ await assert.rejects(vocabStore.init(), /backend write failed/);
+ assert.strictEqual(vocabStore.state.ready, false, '持久化失败时不得把词汇域标记为 ready');
+}
+
+async function testConfigUsesCentralBoundsAndTypes() {
+ const vocabStore = loadVocabStore({
+ embeddedWords: [],
+ dataSeed: {
+ words: [{ id: 'word-1', word: 'alpha', meaning: 'A' }]
+ }
+ });
+ await vocabStore.init();
+
+ await vocabStore.setConfig({
+ dailyNew: -10,
+ reviewLimit: 999,
+ masteryCount: 2.9,
+ notify: 'yes',
+ theme: 'neon'
+ });
+ let config = vocabStore.getConfig();
+ assert.strictEqual(config.dailyNew, 0);
+ assert.strictEqual(config.reviewLimit, 300);
+ assert.strictEqual(config.masteryCount, 2);
+ assert.strictEqual(config.notify, true);
+ assert.strictEqual(config.theme, 'auto');
+
+ await vocabStore.setConfig({
+ dailyNew: '10',
+ reviewLimit: Number.NaN,
+ masteryCount: Number.POSITIVE_INFINITY,
+ notify: false,
+ theme: 'dark'
+ });
+ config = vocabStore.getConfig();
+ assert.strictEqual(config.dailyNew, 20);
+ assert.strictEqual(config.reviewLimit, 100);
+ assert.strictEqual(config.masteryCount, 4);
+ assert.strictEqual(config.notify, false);
+ assert.strictEqual(config.theme, 'dark');
+}
+
+async function testProgressRestoreRequiresCompleteV2Identity() {
+ const vocabStore = loadVocabStore({
+ embeddedWords: [],
+ dataSeed: {
+ words: [{ id: 'word-1', word: 'alpha', meaning: 'A' }]
+ }
+ });
+ await vocabStore.init();
+
+ await assert.rejects(
+ vocabStore.replaceProgress([{ word: 'beta', meaning: 'B' }], { dailyNew: 10 }, null),
+ /未知词表/
+ );
+ await assert.rejects(
+ vocabStore.replaceProgress([{ word: 'beta', meaning: 'B' }], null, 'custom'),
+ /有效配置/
+ );
+ await assert.rejects(
+ vocabStore.replaceProgress([{ word: 'beta', meaning: 'B' }], { dailyNew: 10 }, 'other-list'),
+ /未知词表/
+ );
}
async function main() {
@@ -212,6 +365,12 @@ async function main() {
results.push({ name: '错词保留已补全释义和元数据', status: 'pass' });
await testSpellingErrorMetadataSurvivesStudyUpdates();
results.push({ name: '背诵更新保留错词业务元数据', status: 'pass' });
+ await testDefaultLexiconWriteFailureRejectsInitialization();
+ results.push({ name: '默认词库持久化失败会阻断 ready', status: 'pass' });
+ await testConfigUsesCentralBoundsAndTypes();
+ results.push({ name: '配置写入遵守统一范围和类型', status: 'pass' });
+ await testProgressRestoreRequiresCompleteV2Identity();
+ results.push({ name: '进度恢复要求完整 v2 词表身份', status: 'pass' });
console.log(JSON.stringify({
status: 'pass',
detail: `${results.length}/${results.length} 测试通过`,
diff --git a/developer/tests/performance-test.html b/developer/tests/performance-test.html
deleted file mode 100644
index 22706abe..00000000
--- a/developer/tests/performance-test.html
+++ /dev/null
@@ -1,725 +0,0 @@
-
-
-
-
-
- 性能基线测量工具
-
-
-
-
-
📊 性能基线测量工具
-
测量系统性能指标,建立性能基线,识别优化机会
-
-
-
🎯 测试控制面板
-
-
-
-
- ⚡ 快速测试 (10秒)
-
-
- 🚀 完整基准测试
-
-
- 📈 实时监控
-
-
- ⏹️ 停止监控
-
-
- 🗑️ 清除结果
-
-
-
-
-
准备就绪
-
-
- 实时指标:
- FPS: --
- 内存: --
- 延迟: --
-
-
-
-
-
-
-
-
-
📊 详细报告
-
运行测试后将显示详细的性能报告...
-
-
-
-
🔧 高级测试
-
-
-
- 🏗️ DOM性能测试
-
-
- 📊 数据处理测试
-
-
- 🖱️ 交互性能测试
-
-
- 📜 虚拟滚动测试
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/developer/tests/refresh-test.html b/developer/tests/refresh-test.html
deleted file mode 100644
index f42fb82f..00000000
--- a/developer/tests/refresh-test.html
+++ /dev/null
@@ -1,408 +0,0 @@
-
-
-
-
-
- 状态序列化刷新测试
-
-
-
-
-
🔄 状态序列化刷新测试
-
测试Set/Map对象在页面刷新后的持久化能力
-
-
-
📋 测试步骤:
-
- 点击"创建测试数据" - 创建包含Set/Map的测试状态
- 点击"保存状态到存储" - 序列化并保存到localStorage
- 刷新页面 (F5) - 模拟用户刷新行为
- 点击"验证恢复的状态" - 检查数据完整性
-
-
-
- 🔧 创建测试数据
- 💾 保存状态到存储
- 📂 加载状态
- ✅ 验证恢复的状态
- 🧪 运行完整测试
- 🔍 运行完整性验证
- 🗑️ 清除测试数据
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/developer/tests/regression-test.html b/developer/tests/regression-test.html
deleted file mode 100644
index b30c4b80..00000000
--- a/developer/tests/regression-test.html
+++ /dev/null
@@ -1,625 +0,0 @@
-
-
-
-
-
- 本地回归测试套件
-
-
-
-
-
🧪 本地回归测试套件
-
自动化测试核心功能,确保系统稳定性和数据完整性
-
-
-
🎯 测试控制面板
-
-
-
- 🚀 运行所有测试
-
-
- 📚 Exam加载测试
-
-
- 📝 Practice记录测试
-
-
- 💾 备份恢复测试
-
-
- 🔄 状态序列化测试
-
-
- 🗑️ 清除结果
-
-
-
-
-
准备就绪
-
-
-
-
-
-
📋 详细测试报告
-
点击"运行所有测试"开始测试...
-
-
-
-
-
Exam加载测试
-
验证考试数据加载、分类、搜索和UI渲染功能
-
运行测试
-
-
-
-
-
Practice记录测试
-
验证练习记录的创建、读取、更新、删除和批量操作
-
运行测试
-
-
-
-
-
备份恢复测试
-
验证数据备份创建、恢复、删除和完整性验证
-
运行测试
-
-
-
-
-
状态序列化测试
-
验证Set/Map对象的序列化/反序列化和数据完整性
-
运行测试
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/developer/tests/run-integration-tests.bat b/developer/tests/run-integration-tests.bat
index 053d624b..764bfaef 100644
--- a/developer/tests/run-integration-tests.bat
+++ b/developer/tests/run-integration-tests.bat
@@ -2,15 +2,7 @@
echo Running Integration Tests...
echo.
-echo Test 1: Multi-Suite Submission Flow
-node developer\tests\js\integration\multiSuiteSubmission.test.js
-if %ERRORLEVEL% NEQ 0 (
- echo FAILED: Multi-Suite Submission Test
- exit /b 1
-)
-echo.
-
-echo Test 2: Spelling Error Collection Flow
+echo Test 1: Spelling Error Collection Flow
node developer\tests\js\integration\spellingErrorCollection.test.js
if %ERRORLEVEL% NEQ 0 (
echo FAILED: Spelling Error Collection Test
@@ -18,7 +10,7 @@ if %ERRORLEVEL% NEQ 0 (
)
echo.
-echo Test 3: Vocab List Switching Flow
+echo Test 2: Vocab List Switching Flow
node developer\tests\js\integration\vocabListSwitching.test.js
if %ERRORLEVEL% NEQ 0 (
echo FAILED: Vocab List Switching Test
@@ -26,7 +18,7 @@ if %ERRORLEVEL% NEQ 0 (
)
echo.
-echo Test 4: Vocab Session View Flow
+echo Test 3: Vocab Session View Flow
node developer\tests\js\integration\vocabSessionView.test.js
if %ERRORLEVEL% NEQ 0 (
echo FAILED: Vocab Session View Test
@@ -35,16 +27,4 @@ if %ERRORLEVEL% NEQ 0 (
echo.
echo All integration tests passed!
-echo.
-
-echo Running Performance Benchmarks...
-echo.
-node developer\tests\js\integration\performance.benchmark.js
-if %ERRORLEVEL% NEQ 0 (
- echo WARNING: Performance benchmark failed
- echo Continuing anyway...
-)
-echo.
-
-echo All tests completed!
exit /b 0
diff --git a/developer/tests/run_all_tests.py b/developer/tests/run_all_tests.py
index dc72d71d..004cd5c7 100644
--- a/developer/tests/run_all_tests.py
+++ b/developer/tests/run_all_tests.py
@@ -163,62 +163,72 @@ def run_ci_tests(self) -> bool:
return False
def run_e2e_tests(self) -> bool:
- """运行 E2E 测试"""
+ """运行统一 E2E 套件(file:// 兼容,含提交/结算与导出导入)"""
self.log("=" * 80)
- self.log("运行 E2E 套题练习流程测试")
+ self.log("运行统一 E2E 套件 (e2e_runner.py)")
self.log("=" * 80)
-
- test_script = REPO_ROOT / "developer" / "tests" / "e2e" / "suite_practice_flow.py"
-
+
+ test_script = REPO_ROOT / "developer" / "tests" / "e2e" / "e2e_runner.py"
+
if not test_script.exists():
self.log(f"E2E 测试脚本不存在: {test_script}", "ERROR")
return False
-
+
try:
result = subprocess.run(
[sys.executable, str(test_script)],
capture_output=True,
text=True,
- timeout=180
+ timeout=900
)
-
+
print(result.stdout)
if result.stderr:
print(result.stderr)
-
+
passed = result.returncode == 0
-
- # 尝试解析 JSON 报告
- report_path = REPO_ROOT / "developer" / "tests" / "e2e" / "reports" / "suite-practice-flow-report.json"
+
+ report_path = REPO_ROOT / "developer" / "tests" / "e2e" / "reports" / "e2e-unified-report.json"
if report_path.exists():
try:
report = json.loads(report_path.read_text(encoding="utf-8"))
self.results.append({
- "name": "E2E 套题练习流程",
+ "name": "E2E 统一套件",
"status": report.get("status", "unknown"),
- "duration": report.get("duration"),
- "consoleLogs": len(report.get("consoleLogs", []))
+ "duration": report.get("durationSeconds"),
+ "cases": [
+ {
+ "name": item.get("name"),
+ "status": item.get("status"),
+ "exitCode": item.get("exitCode"),
+ }
+ for item in report.get("cases", [])
+ ],
})
except Exception:
- pass
+ self.results.append({
+ "name": "E2E 统一套件",
+ "status": "pass" if passed else "fail",
+ "returnCode": result.returncode
+ })
else:
self.results.append({
- "name": "E2E 套题练习流程",
+ "name": "E2E 统一套件",
"status": "pass" if passed else "fail",
"returnCode": result.returncode
})
-
+
if passed:
self.log("E2E 测试通过", "SUCCESS")
else:
self.log(f"E2E 测试失败 (返回码: {result.returncode})", "ERROR")
-
+
return passed
-
+
except subprocess.TimeoutExpired:
- self.log("E2E 测试超时 (180秒)", "ERROR")
+ self.log("E2E 测试超时 (900秒)", "ERROR")
self.results.append({
- "name": "E2E 套题练习流程",
+ "name": "E2E 统一套件",
"status": "fail",
"error": "超时"
})
@@ -226,7 +236,7 @@ def run_e2e_tests(self) -> bool:
except Exception as e:
self.log(f"运行 E2E 测试时出错: {e}", "ERROR")
self.results.append({
- "name": "E2E 套题练习流程",
+ "name": "E2E 统一套件",
"status": "fail",
"error": str(e)
})
diff --git a/developer/tests/storage-vocab-test.html b/developer/tests/storage-vocab-test.html
deleted file mode 100644
index 0e5cae55..00000000
--- a/developer/tests/storage-vocab-test.html
+++ /dev/null
@@ -1,386 +0,0 @@
-
-
-
-
-
- Storage Vocabulary Test
-
-
-
- Storage Vocabulary Test
- 测试词表存储、同步和导出功能
-
-
-
1. 词表存储测试
-
保存测试词表
-
加载测试词表
-
添加单词
-
移除单词
-
-
-
-
-
2. 数据同步测试
-
同步词表
-
测试冲突合并
-
测试页面关闭持久化
-
-
-
-
-
3. 降级存储测试
-
检查存储健康
-
测试降级保存
-
-
-
-
-
4. 数据导出测试
-
导出词表
-
导出练习记录
-
导出完整数据
-
下载词表
-
-
-
-
-
-
-
diff --git a/developer/tests/tools/reading-json/export_generated_reading_dataset.node.js b/developer/tests/tools/reading-json/export_generated_reading_dataset.node.js
index 50c04e1f..8d281211 100644
--- a/developer/tests/tools/reading-json/export_generated_reading_dataset.node.js
+++ b/developer/tests/tools/reading-json/export_generated_reading_dataset.node.js
@@ -21,13 +21,17 @@ function readText(filePath) {
}
function parseArgs(argv) {
- const args = { examId: '', list: false };
+ const args = { examId: '', list: false, all: false };
for (let i = 2; i < argv.length; i += 1) {
const token = argv[i];
if (token === '--list') {
args.list = true;
continue;
}
+ if (token === '--all') {
+ args.all = true;
+ continue;
+ }
if (token === '--exam-id') {
args.examId = (argv[i + 1] || '').trim();
i += 1;
@@ -120,6 +124,23 @@ function pickManifestEntry(manifest, examId) {
return null;
}
+function buildPayload(dataset, entry, fallbackExamId = '') {
+ return {
+ examId: dataset.examId || entry.examId || fallbackExamId,
+ questionOrder: Array.isArray(dataset.questionOrder) ? dataset.questionOrder : [],
+ answerKey: dataset.answerKey && typeof dataset.answerKey === 'object' ? dataset.answerKey : {},
+ questionGroups: Array.isArray(dataset.questionGroups) ? dataset.questionGroups : [],
+ questionDisplayMap: dataset.questionDisplayMap && typeof dataset.questionDisplayMap === 'object'
+ ? dataset.questionDisplayMap
+ : {},
+ meta: dataset.meta && typeof dataset.meta === 'object' ? dataset.meta : {},
+ metaQuestionIntroHtml: dataset.meta && typeof dataset.meta.questionIntroHtml === 'string'
+ ? dataset.meta.questionIntroHtml
+ : '',
+ script: entry.script
+ };
+}
+
function main() {
if (!fs.existsSync(MANIFEST_PATH)) {
fail('reading_manifest_not_found');
@@ -134,6 +155,17 @@ function main() {
return;
}
+ if (args.all) {
+ const entries = buildEntryList(manifest);
+ const datasets = Object.fromEntries(entries.map((entry) => {
+ const dataset = loadDataset(context, registry, entry);
+ const payload = buildPayload(dataset, entry, entry.examId);
+ return [payload.examId, payload];
+ }));
+ process.stdout.write(`${JSON.stringify({ entries, datasets })}\n`);
+ return;
+ }
+
if (!args.examId) {
fail('missing_required_arg:--exam-id');
}
@@ -144,20 +176,7 @@ function main() {
}
const dataset = loadDataset(context, registry, entry);
- const payload = {
- examId: dataset.examId || entry.examId || args.examId,
- questionOrder: Array.isArray(dataset.questionOrder) ? dataset.questionOrder : [],
- answerKey: dataset.answerKey && typeof dataset.answerKey === 'object' ? dataset.answerKey : {},
- questionGroups: Array.isArray(dataset.questionGroups) ? dataset.questionGroups : [],
- questionDisplayMap: dataset.questionDisplayMap && typeof dataset.questionDisplayMap === 'object'
- ? dataset.questionDisplayMap
- : {},
- meta: dataset.meta && typeof dataset.meta === 'object' ? dataset.meta : {},
- metaQuestionIntroHtml: dataset.meta && typeof dataset.meta.questionIntroHtml === 'string'
- ? dataset.meta.questionIntroHtml
- : '',
- script: entry.script
- };
+ const payload = buildPayload(dataset, entry, args.examId);
process.stdout.write(`${JSON.stringify(payload)}\n`);
}
diff --git a/developer/tests/vocabListSwitcher.test.html b/developer/tests/vocabListSwitcher.test.html
deleted file mode 100644
index b2bcc900..00000000
--- a/developer/tests/vocabListSwitcher.test.html
+++ /dev/null
@@ -1,614 +0,0 @@
-
-
-
-
-
- VocabListSwitcher 单元测试
-
-
-
- 🧪 VocabListSwitcher 单元测试
- 测试词表切换器组件的核心功能
-
-
-
测试摘要
-
总测试数: 0
-
通过: 0
-
失败: 0
-
成功率: 0%
-
-
-
-
控制面板
- 🚀 运行所有测试
- 🗑️ 清除结果
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/findings.md b/findings.md
new file mode 100644
index 00000000..20b88cf0
--- /dev/null
+++ b/findings.md
@@ -0,0 +1,224 @@
+# Findings
+
+## Baseline
+
+- Branch: `codex/audit-tmp-migration`
+- HEAD: `2a1801cd6f8ddc721a6971e9e07c39623083cc2d`
+- Divergence from `origin/opensource`: 7 upstream-only / 25 branch-only commits.
+- Existing dirty files are user-owned accuracy UI and generated bundle changes; preserve them.
+
+## Verified defects from the audit
+
+- Full restore writes only present document envelopes and listed entity stores.
+- Partial practice replace can leave detail/annotation orphans.
+- Operation journal survives restore and business IDs are reused as operation IDs.
+- v1 nested annotations and `scoreInfo.correct/total` are lost from light/full projection.
+- Concurrent vocab writes conflict and one update is lost.
+- `file://` fallback uses `"file://"` as target origin; Chromium silently drops the message.
+- Listening marks completion before INIT/ACK.
+- `examSessionMixin.createFallbackRecorder` overwrites the safer app-level fallback.
+- DataKernel listeners are realm-local; file-page BroadcastChannel was proven available.
+- Row checksum errors currently latch the entire backend.
+
+## Prior live probes
+
+- Full restore left a post-backup vocab word in place.
+- Nested v1 import produced `correctAnswers=0`, `totalQuestions=0`, and null annotations.
+- Two concurrent vocab upserts produced one fulfillment, one `CONFLICT`, and one saved word.
+- Reusing a record ID as operation ID rejected the changed second save.
+- A commit in a second file-page realm was readable by the first realm but absent from its committed listener.
+
+## Implementation decisions
+
+- Full-user-data mirror semantics will be prepared in `createImportPlan`; DataKernel stays a validated atomic installer and receives explicit cleared envelopes/all entity layers.
+- `resetJournal` is applied inside the same install transaction after checking replay against the old journal; the new journal contains only the restore receipt.
+- Partial practice imports may update a subset of stores only when the resulting three recordId sets remain identical; incomplete replace is rejected.
+- Cross-realm commit broadcast must not depend on local listeners being registered, otherwise a writer-only child realm would never notify the parent.
+- User-owned accuracy changes are source-backed and will be preserved by rebuilding bundles from the modified source files.
+
+## Implemented worktree checkpoint
+
+- `appData.js` now normalizes accuracy values above 1 to ratios, derives `correctAnswers/totalQuestions` from both `scoreInfo.correctAnswers/totalQuestions` and legacy `scoreInfo.correct/total`, and emits percentage separately.
+- `splitPracticeRecord` now fills top-level annotation fields from `realData`, then `rawData`, without persisting either legacy mirror.
+- Practice light projection now emits sanitized `suiteEntrySummaries`; Browse consumption and regression coverage remain pending.
+- `dataKernel.js` has a partial implementation for `CORRUPT_RECORD`, cross-realm commit broadcast, and restore journal reset; exact transaction/error boundaries still need focused source review.
+
+## Remaining data-layer gaps after source review
+
+- `createImportPlan` still has the old contract: full replace only iterates present envelopes, and practice replace only clears/rebuilds stores present in the payload. It must synthesize cleared envelopes for every importable/exportable user-data catalog entry and require all three practice stores for replace.
+- Practice merge currently snapshots only source stores and can still produce unequal recordId sets. The resulting three-store sets need validation before `installSnapshot`.
+- `backups.restore` does not pass `resetJournal: true`; DataKernel supports it but the public restore path currently preserves the old journal.
+- `vocab.saveCollection`, `saveCollections`, `upsertCollectionWord`, `mergeListWords`, and related collection read-modify-write paths are still unqueued bare CAS operations.
+- `operationId()` already generates a fresh ID by default; the remaining idempotency bug is at callers that explicitly pass `record.id/sessionId`. Same-request retries must preserve an explicit request/submission operation ID.
+- Full-mirror document scope is exactly catalog entries with `export:true` and an import policy other than `ignore`; this includes `backups.settings` but excludes `backups.entries`, backup history, `recovery.windowSession`, and the journal.
+- `DataKernel.listEntities` must skip corrupt summary rows so stats/list rendering continue; direct `readEntity` remains the diagnostic path that returns `CORRUPT_RECORD`.
+- Vocab RMW coverage is broader than `upsertCollectionWord`: collection/config/word merge, patch, and progress methods all read revisions before CAS. A single realm-local queue plus bounded re-read/retry is the simplest consistent contract.
+
+## Messaging review checkpoint
+
+- Fallback `main.js` still computes `targetOrigin` by accepting any truthy origin before testing `protocol === 'file:'`; therefore a Chromium value of `"file://"` still reaches `postMessage`. The file check must take precedence and the declared parent origin must be normalized to `"null"`.
+- Listening still sets `state.completed = true` before INIT/ACK and only retries while `!state.completed`; it has no persisted submission receipt handler.
+- Reading highlight UI still treats successful `postMessage` invocation as persistence success. It needs a generated `requestId`, pending request map, ACK/FAILED listener, and direct-AppData fallback only when no host route is available.
+- `app.js` defines the richer fallback recorder, while `examSessionMixin.js` still defines another `createFallbackRecorder`; the later mixin assignment can overwrite the richer implementation.
+- The main exam mixin already has the correct opaque-origin endpoint (`expectedOrigin:"null"`, wildcard send) and a persisted `PRACTICE_SUBMIT_ACK/FAILED` receipt cache. Listening can join that contract without inventing a second host protocol.
+- Listening bootstrap creates a provisional sessionId before INIT; therefore a pre-INIT completion must cache extracted details/submissionId, then build the final payload only after host INIT replaces the provisional sessionId.
+- Completion retry timers must resend the cached payload directly. Calling `onComplete()` and rescheduling all timers from inside each timer would create an endless timer-reset loop.
+- Business-id reuse existed in `PracticeRecorder.savePracticeRecord`, suite finalize, and the host completion fallback. Recorder now owns one new op per call and reuses it only for its internal retries; submission-correlated host/suite saves derive the op from submissionId.
+- `handlePracticeComplete` called the general recorder-session rebind and then called the listening wrapper that delegates to the same general rebind, producing duplicate `handleSessionStarted`; the second call is redundant.
+- The remaining suite reset failure was fixture accounting, not a second reset call: the first reading completion correctly invokes the general recorder rebind, leaving one entry in `recorderStarts`; reset invokes `_syncRecorderSessionStarted` once. Reset the probe before the reset request so the assertion measures only reset behavior.
+- The Listening bridge already has the intended pending state machine in source: `onComplete` creates one submission, `sendPendingCompletion` refuses to emit before trusted INIT, and retry timers resend the cached payload. The existing parser test is not visibly read by ordinary `Get-Content`, so inspect its encoding before extending it.
+- `listeningRecordBridgeParser.test.js` is normal UTF-8 and only covers safe literal parsing; protocol coverage belongs in a separate VM harness. The bridge deliberately exposes `__listeningBridgeComplete` and `__listeningBridgeGetState`, which makes pre-INIT/retry/ACK assertions possible without production-only test hooks.
+- The static runner has an explicit `security_regression_tests` list, so the new Listening protocol VM regression must be registered there rather than merely added to the filesystem.
+- The vocab dictionary's public API exposes ACK settlement but not the save initiator. A VM-only source injection can expose closure hooks without changing production code, allowing exact assertions that `postMessage` delivery leaves the button pending, FAILED shows `保存失败` when direct AppData is unavailable, ACK shows `已加入`, and direct AppData commit is also accepted.
+- Browse completion is not owned by `browseController.js`; that module only controls modes and filters. Existing Browse record tests exercise `BrowsePreferencesUtils`, so the child completion consumer must be located in the legacy view/presentation layer before choosing the regression harness.
+- The actual Browse completion index is `rebuildBrowseCompletionIndex` in `legacyViewBundle.js`; both its indexed path and path/file fallback enumerate only `record.suiteEntries`. The existing `legacyViewReadStatus.test.js` is the exact regression surface and currently labels a full `suiteEntries` object as “lightweight”.
+- The cross-realm chain is correctly connected: DataKernel remote BroadcastChannel events are dispatched with `remote:true`; `AppData.backups.onDataCommitted` directly subscribes to that kernel listener; ExternalBackupService subscribes once and calls `markDirty` without filtering remote events. The backup regression should emit an explicit remote event so this contract cannot regress silently.
+- `practiceRecordPersistence.test.js` already drives the app-level fallback handshake/submit path, but its harness hardcodes an HTTP origin and discards `postMessage` targetOrigin. Extending this existing test to run the fallback completion under `location.protocol='file:'`, `origin='file://'`, event origin `null`, and asserting every reply target is `*` is the smallest realistic origin regression.
+- The production protocol scan found completion senders in the inline suite placeholder, Practice Enhancer, unified reading, Listening bridge, and shipped templates, plus an E2E inline fixture. The host now rejects missing correlation metadata, so each sender path must be checked for enrichment rather than assuming literal payloads contain the fields.
+
+## Current Production Reports
+
+- Built-in Reading exams must come strictly from `assets/generated/reading-exams/manifest.js`; imported-library state or AppData readiness must not replace an available built-in index with an empty list.
+- `assets/generated/listening-exams/manifest.js` may be absent in distributed packages. Its loader is optional and must not gate Reading browse startup or practice submission.
+- The reported save failure reaches `AppData.practice.completeAttempt` through `ExamSystemApp.saveRealPracticeData`; `canonicalizeRecord` rejects `correctAnswers` because the upstream completion normalization produced a negative or non-finite number. The upstream computation must be fixed rather than weakening the non-negative persistence invariant.
+
+## Confirmed Root Causes
+
+- `js/data/v2/dataKernel.js:143-160` reads legacy IndexedDB rows as the value itself. The v1 store actually persisted `{ key, value, timestamp }`, so `practice_records`, library configuration, and active-key values are currently parsed one level too high. The practice migration therefore sees no array and imports zero records.
+- v1 used exact `exam_index` as the built-in/default-library sentinel. `migrateLegacyData` copies that value to `library.activeConfigurationId`, while `importedLibraryId` rejects every `exam_index`/`exam_index_*` ID. `LibraryManager.loadActiveLibrary` then treats the invalid sentinel as a non-default library, receives an empty index, dispatches `examIndexLoaded` with `[]`, and never reads the generated Reading manifest.
+- Existing poisoned v2 envelopes survive a source fix because document migration skips any existing envelope and practice migration returns as soon as one summary exists. The repair must be idempotent and must merge missing legacy records instead of using collection non-emptiness as completion.
+- Reading, Listening, and the generic practice enhancer use object-valued `correctAnswers` for the answer-key map and place the numeric score in `scoreInfo.correct`. Suite aggregates use numeric top-level `correctAnswers`. `canonicalizeRecord` currently validates the overloaded object field before adapting it, so a valid completion fails persistence. The canonical non-negative invariant is correct; the compatibility boundary must select the first valid scalar score candidate and preserve the map separately.
+- `LibraryManager.loadActiveLibrary` already treats the Listening manifest as optional for a default library, but an invalid/non-default active ID returns an empty custom index before reaching that code. Empty or invalid active custom state must reset to the default and continue through the manifest path.
+
+## Chosen Hotfix Contract
+
+- Unwrap legacy IDB rows strictly through `.value`; no historical production writer supports raw business values in that object store.
+- Translate the v1 exact default sentinel to v2 `null`; never persist the generated Reading `exam_index` cache as user library data.
+- Remap valid v1 custom `exam_index_*` libraries to accepted deterministic IDs, but only when their index is a non-empty array.
+- Repair poisoned active-library state on startup and make browse startup fall back to the generated Reading manifest when a selected custom library is missing or empty.
+- Merge missing legacy records by stable ID, skip already-migrated IDs, and use a versioned repair operation ID.
+- Normalize overloaded completion score fields before canonical validation; preserve `correctAnswerMap`, keep legal zeroes, and never accept a negative/non-finite candidate when a later valid candidate exists.
+
+## Supplied Backup: Confirmed Semantics
+
+- `ielts-atlas-backup-2026-07-28T15-14-09-096Z.json` is checksum-valid (`fnv1a-88bc05b5`) but semantically poisoned: all three practice entity stores are empty, `library.activeConfigurationId` is `"[object Object]"`, `library.importedIndexes` is missing, and settings/vocab/achievements contain old `{key,value,timestamp}` rows rather than business values.
+- The exporter faithfully captured an already-corrupted v2 database. The product defect is that it labeled a sparse physical snapshot as `scope:"full"`, generated a valid checksum, and provided no semantic validation or completeness manifest.
+- Old opensource main UI exports practice records and stats and defaults to merge; it does not import library configuration/active state, so its normal merge/replace path cannot clear the built-in library. The dangerous old `StorageManager.importData` full clear path existed but was not the normal DataBackupManager UI.
+- The v2 migration row-wrapper bug is a new regression. Old `StorageManager.getFromIndexedDB` correctly read `request.result.value`; v2 migration read the entire row.
+- The supplied file cannot reconstruct missing practice records or lost library configurations by itself. It can safely recover inner settings/vocab/achievement values. Practice/library recovery additionally needs the original `ExamSystemDB` or another older backup.
+- The built-in Reading manifest is code, not user data. Only user custom-library configurations/indexes/active selection belong to snapshot state; `null` active always means load the generated manifest.
+- A checksum proves byte-level integrity, not business correctness. Import preview must distinguish `trusted-full`, `degraded-partial`, and `invalid` inputs.
+- New exports now materialize every exportable catalog key as an explicit `present` or `cleared` envelope. Missing keys in older sparse snapshots are preserved rather than inferred as deletion requests.
+- v2 import canonicalization repairs only exact legacy row aliases and rejects cross-domain wrappers. Full snapshots require a coherent library bundle; valid partial library updates remain importable.
+- Destructive import preview reports existing/incoming/final/removed practice counts. Commit requires explicit `confirmDestructive:true` after user confirmation.
+- Startup uses one versioned `v1ToV2` state. It migrates only an empty v2 database or repairs an exact known poison fingerprint; marker absence alone never replays a frozen v1 database.
+- The supplied JSON itself still cannot yield missing practice records: its three entity arrays are genuinely empty. Recovery succeeds only if the user's old IndexedDB or another older backup still contains those records.
+
+## Raw-Data-First Re-audit (Final)
+
+- Historical production has one IndexedDB row contract: `{key,value,timestamp}`. Its `value` is the serialized storage envelope `{data,timestamp,version[,compressed]}`. The old reader returned `request.result.value`; the bad v2 migration uniquely passed the whole row to the legacy parser.
+- The whole corruption chain comes from that one wrong boundary. Object documents retained the wrapper, array documents normalized to `[]`, nullable strings became `"[object Object]"`, and practice extraction found no record array. Checksums later certified those already-wrong bytes.
+- Raw unprefixed compatibility exists only in Web Storage for `practice_records`, `vocab_user_config`, and `user_achievements`. The reader now accepts exactly those evidenced variants and no speculative raw-IDB shape.
+- Initial migration runs only when v2 has no user envelopes and no practice summaries. Exact wrapper/library poison may trigger a narrow repair. A healthy existing v2 database is marked `existing-v2` without reading or replaying frozen v1 data.
+- Wrapper repair requires the expected legacy alias and an object payload. It preserves fields added to the outer v2 document after the bad migration.
+- Library poison repair restores only exact wrapped legacy index IDs. A poisoned active ID consults the old active ID only when v2 has no usable current index; otherwise it becomes the built-in/default selection and existing v2 custom libraries remain untouched.
+- Practice recovery from old storage is limited to initial migration or an exact poisoned state with an empty summary store. This is the only unavoidable ambiguity: after a bad migration, an intentionally cleared empty practice store is indistinguishable from the original collapse while the poison fingerprint remains.
+- The built-in Reading index is never user data. `null`/invalid/empty custom selection displays `assets/generated/reading-exams/manifest.js`; this fallback does not mutate the persisted selection. A healthy custom library remains active even during forced reload.
+- Sparse or poisoned old backups are degraded to partial imports. Missing keys do not imply deletion, and the supplied file's merge path preserves current practice/library data. Explicit destructive replace requires `confirmDestructive:true`.
+- The supplied backup cannot recover practice records or custom indexes because those arrays/envelopes are already empty or absent. Recovery requires the user's surviving old `ExamSystemDB` or an older intact backup; no code fallback can reconstruct data that is absent from both.
+
+## Persistent v1 Reconciliation Decision
+
+- The user explicitly prefers recovery completeness over preventing old v1 records from reappearing after a later v2 deletion.
+- Required startup behavior: if canonical v1 data is readable, merge every valid v1 practice record and user library into v2 on every startup; retain valid v2-only additions; replace known wrapper/`"[object Object]"` migration poison with decoded v1 values.
+- Repeated startup must be idempotent by stable record/library IDs and checksum/revision comparisons, not by skipping legacy reads through a completion marker.
+- The completion marker is diagnostic only. It must never suppress a legacy read or reconciliation, and it must not be rewritten when no business data changed.
+- Practice reconciliation is record-based: a complete healthy v2 three-layer record wins on an ID collision; a missing v1 ID is added; a partially present v2 record is replaced atomically from v1 to avoid mixed summary/detail/annotation provenance.
+- Library reconciliation is a deterministic union by remapped legacy ID. Healthy v2-only libraries and healthy active selections survive; missing v1 libraries are added; poisoned or dangling active selection is repaired from the v1 active key.
+- A failed or incomplete legacy read produces no v2 writes and no marker update; the application continues on existing v2 and retries next startup.
+- Exact current gates to remove are `migrateLegacyData`'s completed-marker return and healthy-v2 `existing-v2` return. Library, document, and practice reconciliation must no longer depend on `freshMigration`, `poisonDetected`, or an empty summary collection.
+- Existing `practiceLayers(..., true)` exposes all three revisions and `practiceUpserts` already emits a single atomic three-store mutation, so a partial record can be replaced coherently without adding a new kernel repair API.
+- `migrateLegacyLibraryData` already compares configuration/index checksums before writing; changing it to an unconditional deterministic union keeps repeated startup diff-only.
+- The VM regression now proves that an old completion marker cannot suppress reconciliation, v1-only and v2-only records coexist, complete healthy same-ID v2 records win, partial three-layer records are atomically rebuilt from v1, and a second boot rereads v1 without incrementing any business revision.
+- The real IndexedDB regression now updates `ExamSystemDB` after a completed v4 reconciliation, reloads into a new realm, verifies the newly appended v1 record is migrated beside a v2-only record, then proves a third unchanged boot has identical document/entity revisions and checksums.
+- Exact wrappers are not the only historical bad output: array/object legacy documents written by the faulty migration can be recognized by their `legacy-documents-*` operation ID. Those documents should be refreshed from the live v1 alias when values differ; later normal v2 writes have a different operation ID and remain authoritative.
+- `AppData.practice.delete` removes all three layers without a tombstone. Under the user-selected persistent-union policy, deleting a record that still exists in v1 must therefore be temporary: the next startup restores it.
+- Catalog policies provide the general document merge contract needed for “all v1 data”: `patch` objects should include v1-only keys while healthy v2 values win conflicts; `merge-by-id` arrays should include v1-only items while healthy v2 items win the same identity. Exact bad-migration operation IDs remain a full v1 replacement.
+- The existing `mergeImportValue`/`mergeCollection` helpers already implement those policies. Calling them as `(legacyValue, currentV2Value)` produces the desired union with healthy v2 winning collisions and avoids a second merge implementation.
+- Persistent document reconciliation now applies those catalog policies on every startup: the test proves legacy-only settings and vocabulary are added, current v2 values win shared keys/IDs, and exact bad-migration writes are still replaced rather than merged.
+# 2026-07-30 Review Fixes, v2 Insights, And Endless Mode
+
+## Confirmed review regressions
+
+- `js/data/v2/appData.js`: persistent legacy reconciliation currently re-merges `active_sessions`, `temp_practice_records`, `interrupted_records`, and `rejected_completion_payloads`, resurrecting v2-deleted recovery rows.
+- `js/utils/BrowsePreferencesUtils.js`: first synchronous preference read returns/caches defaults while async AppData hydration finishes without reapplying the initial filter/scroll state.
+- `developer/tests/ci/run_static_suite.py`: the exam app method-contract collector scans only `js/app/*Mixin.js`, while `createFallbackRecorder` now exists only in `js/app.js`.
+- `js/data/v2/appData.js`: achievement projection supports an existing unlocked state, but `getAll()` always passes `{}` and no durable v2 progress document currently owns new unlocks.
+- `developer/tests/e2e/suite_practice_flow.py`: the E2E predicate reads properties from the Promise returned by async `resolveSuitePreference()`.
+- `js/boot-fallbacks.js`: pre-import backup creation happens before semantic preview and user confirmation.
+
+## Lightweight practice insight gap
+
+- `js/main.js` loads `AppData.practice.list({ projection: 'light' })`, but `PracticePriorityRenderer.calculateReadingRadarData()` reads `questionTypePerformance`, `answerDetails`, and `scoreInfo.details`, all of which live only in v2 detail records.
+- The production radar therefore receives records with no classifiable wrong-answer data and reports zero errors.
+- Suite child records are deleted after finalization; `suiteEntrySummaries` currently preserve score metadata but no compact question-type error counts.
+- The appropriate contract is a compact derived `questionTypeErrorCounts` field on summaries and suite-entry summaries, not a fallback to loading every detail record.
+- `filterByExamType()` also ignores existing `suiteEntrySummaries`; it can consume those without a new API.
+
+## Endless mode
+
+- `js/presentation/app-actions.js` initializes `endlessState` as `null`, writes `endlessState.examIndex` before constructing the object, and deterministically throws on the first start.
+- The generated runtime-entry bundle contains the same defect and is what `index.html` executes.
+- The unified reading page only emits `ENDLESS_USER_EXIT` when an endless marker is present, but current first/subsequent exam opens do not add that marker.
+- Subsequent endless exams manually navigate/register/start a session instead of using the normal `app.openExam()` lifecycle, risking stale window/session state.
+- Focused regressions must execute the lifecycle; the existing endless test only scans source strings.
+
+## Source-contract decisions after main-agent read
+
+- The root README confirms bundles are the only production runtime and must be rebuilt from source; `file://` remains a required execution mode.
+- Recovery documents are cataloged as authoritative/exportable `merge-by-id` data. Their backup/import semantics should remain intact; only startup legacy reconciliation needs a one-shot policy.
+- `lightFromCanonical()` is the canonical summary constructor and `lightSuiteEntry()` is the canonical compact suite-entry constructor, so derived error counts belong in those two functions and will naturally persist in `practiceSummaries`.
+- `filterByExamType()` currently consults the entire exam index before record metadata. It should first honor `suiteEntrySummaries`, then the summary's own type, and only use the exam index as a legacy fallback.
+- The current import fallback has a clean sequencing boundary: preview and optional confirmation end immediately before `commitImport()`, making backup creation safe to move to that point without changing payload validation.
+- `openExam()` already owns reused-window cleanup, launch-library provenance, session registration, recorder start, and injection. Endless follow-up navigation should call this path instead of duplicating those responsibilities.
+- Achievement tests currently assert `getAll()` performs one `practiceSummaries` list and no document reads; adding durable progress intentionally changes that contract to one `achievements.progress` read and requires updating the focused harness/catalog expectations.
+- Existing Browse preference coverage is concentrated in `developer/tests/js/browsePreferencesRecords.test.js`; it already models AppData preference failures and is the right place for delayed-hydration ordering coverage.
+- The static method contract has a single collector in `run_static_suite.py`; scanning `app.js` alongside mixins fixes the source-of-truth mismatch without duplicating a method.
+- The recovery facade exposes discard/complete methods that write a cleared/current v2 envelope, so the transient reconciliation regression can model the real user path and reboot the shared kernel.
+- `unifiedReadingLockRegression.test.js` is already registered by the static suite and can host an executable VM lifecycle check for first open and countdown-driven window reuse without adding another runner block.
+
+## Final implementation and residual gates
+
+- New practice writes persist answer-free `questionTypeErrorCounts` in summaries and suite-entry summaries. `practice.listInsights({limit:10})` supplies the same contract for historical rows by reading only the bounded missing details; annotations and all-history detail scans remain excluded.
+- Browse activation now awaits preference hydration before reading the persisted filter, and initial preference UI/scroll restoration use the same readiness promise.
+- Achievement unlock facts are stored in exportable/importable `achievements.progress`; deleting source practice rows no longer relocks them.
+- Endless mode now constructs state atomically, marks the unified URL, opens first and later exams through `app.openExam()`, reuses the stable tab, and cleans up/report failures.
+- The final full static report passed every gate changed by this work. Its remaining failures are outside this scope: the pre-existing v2 migration allowlist mismatch, noisy suite-regression JSON parsing, four NB replay content fixtures, and the 480-second Reading quick audit timeout.
+- The suite E2E reached lazy loading, persisted preference setup, and window launch after its two runner API fixes, then stopped at the existing first-passage readiness timeout caused by unavailable local exercise resources.
+
+## Residual gate triage
+
+- The v2 unique-entry failure is a guard allowlist drift: `run_static_suite.py` still anchors the allowed AppData legacy-import region at the removed `findDeclaredValue` symbol. The legacy reads are confined to the intended v1 compatibility/migration boundaries; repair the semantic allowlist and keep a negative guard case.
+- `suiteModeRegression.test.js` exits successfully and prints pass JSON on its final stdout line. The static runner incorrectly parses the entire noisy stdout as one JSON document even though it already has a last-line JSON helper.
+- All four NB replay failures are stale tests. The runtime now requires a trusted `INIT_SESSION`, `source: exam_host`, and a matching window token; with that protocol the four resources restore answers, answered state, highlights, text, and mirror data correctly. `p2-high-201` also needs its test selector scoped to the clone group.
+- Reading quick is not merely a slow-machine timeout. It spends about 218 seconds launching Node/VM once per 232 static datasets, then twelve UI cases each exhaust a 30-second wait for an obsolete or premature `#results` contract. Fix the page-ready/result contract and batch the static exporter before revisiting the 480-second outer bound.
+- Suite E2E does not fail because the optional listening manifest is absent. `_buildExamPlaceholderUrl()` drops the parent's test mode, so `exam-placeholder.html` identifies itself as non-test, sets `examState=blocked`, and never enables completion. Propagate the narrow `suite_test=1` flag and rerun the full suite chain.
+- Release recommendation: repair the three test-infrastructure failures promptly to restore a trustworthy green gate; keep Reading UI and suite end-to-end paths release-blocking until their real chains run successfully.
+
+## Residual gate repair design
+
+- Reading quick currently opens the unified page as a top-level `file://` document. Even after the click handler binds, submission intentionally cannot post to itself; results render only after a correlated `PRACTICE_SUBMIT_ACK`. The audit must host the page in an iframe, perform the existing `REQUEST_INIT`/`INIT_SESSION` handshake, wait for `SESSION_READY`, and ACK `PRACTICE_COMPLETE`.
+- No new Reading runtime-ready sentinel is needed: `SESSION_READY` is emitted only after action and message listeners have been attached.
+- The Reading exporter can add an `--all` mode that loads all 232 registered datasets into one VM/context. Python should consume that bundle once instead of launching Node once per exam.
+- Suite placeholder propagation should use the already-supported narrow `suite_test=1` query flag. The template and environment detector need no behavior change.
+- The NB replay fixture must retain the production trusted-message gate and instead send a valid INIT plus matching token/source. Its selectors must be scoped to the same clone-enabled group so `p2-high-201` covers the intended case.
+
+## Final verification (2026-07-31)
+
+- Placeholder reuse can render before the next `INIT_SESSION`; URL-level `suiteFlowMode` recovery now makes simulation/stationary behavior deterministic, while late contexts preserve submitted-final navigation.
+- The unified static suite passes all checks. The only fixture-level correction was adding `practice.listInsights()` to the practice-persistence AppData stub.
+- Bundle drift is green for all 14 outputs; eight historical symbol-collision warnings remain explicitly non-blocking.
+- The legacy migration fixture should derive its fresh timestamp from `Date.now()` and include a 31-day stale row to keep TTL cleanup explicitly covered.
+- Main-agent marker verification confirmed bounded semantic regions: DataKernel legacy constants end before `function clone`; AppData import recognition ends before `entityRowFromLayer`; AppData document migration ends before kernel initialization; migration fixtures are bounded by their harness/main functions rather than whole-file exemptions.
+- The existing reliable-submit E2E already contains a compact file-compatible iframe host and correlation helpers. Reading audit can embed a smaller auto-ACK variant, operate on the named frame, and retain page-level screenshots/console collection.
+- `suiteModeRegression.test.js` already exposes native `URL`/`URLSearchParams` in its VM sandbox, so the placeholder URL query and special-character round-trip can be covered without new harness dependencies.
diff --git a/index.html b/index.html
index b8d457f4..7a775908 100644
--- a/index.html
+++ b/index.html
@@ -784,10 +784,11 @@ 🔧 系统管理
系统工具和设置选项
- 🗑️ 清除缓存
+ 🗑️ 清理数据
🔧 系统管理
💾 数据管理
- 本地磁盘备份(推荐)、应用内快照、导入导出。清缓存不会删除你绑定文件夹里的备份文件。
+ 本地磁盘备份(推荐)、应用内快照、导入与导出。清理浏览器站点数据不会删除已写入磁盘的备份文件。
- this.persistState(path, storageKey)
- );
- try {
- await Promise.all(promises);
- } catch (error) {
- console.error('[App] 批量持久化状态失败:', error);
- }
- },
- async loadState(path, storageKey = null) {
- const key = storageKey || path.replace('.', '_');
- try {
- const value = await storage.get(key, null);
- if (value !== null) {
- const deserializedValue = StateSerializer.deserialize(value);
- this.setState(path, deserializedValue);
- return deserializedValue;
- }
- } catch (error) {
- console.error(`[App] 加载状态失败 ${path}:`, error);
- }
- return null;
- },
- async loadPersistedState() {
- const stateMappings = {
- exam: 'app_exam_state',
- practice: 'app_practice_state',
- ui: 'app_ui_state',
- system: 'app_system_state'
- };
- for (const [path, storageKey] of Object.entries(stateMappings)) {
- await this.loadState(path, storageKey);
- }
- console.log('[App] 持久化状态加载完成');
- },
- async saveAllState() {
- const stateMappings = {
- exam: 'app_exam_state',
- practice: 'app_practice_state',
- ui: 'app_ui_state',
- system: 'app_system_state'
- };
- await this.persistMultipleState(stateMappings);
- console.log('[App] 所有状态已保存');
- },
async checkComponents() {
console.log('=== 组件加载检查 ===');
try {
@@ -173,12 +113,9 @@ class ExamSystemApp {
console.log(`${name}: ${status}`);
});
console.log('\n=== 数据检查 ===');
- const practiceRecordsCount = this.getState('practice.records')?.length || 0;
- console.log(`practiceRecords: ${practiceRecordsCount} 条记录`);
try {
- const records = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function'
- ? await window.PracticeRecordAPI.list()
- : [];
+ // 只统计条数,light 投影即可,避免为诊断日志拉取全量答题详情。
+ const records = await window.AppData.practice.list({ projection: 'light' });
const count = Array.isArray(records) ? records.length : 0;
console.log(`canonical practice records: ${count} 条记录`);
} catch (_) {
@@ -205,11 +142,6 @@ class ExamSystemApp {
console.warn('[App] AppStateService connect failed:', error);
}
}
- Object.defineProperty(window, 'dataIntegrityManager', {
- get: () => this.state.components.dataIntegrityManager,
- set: (value) => this.setState('components.dataIntegrityManager', value),
- configurable: true
- });
Object.defineProperty(window, 'pdfHandler', {
get: () => this.state.components.pdfHandler,
set: (value) => this.setState('components.pdfHandler', value),
@@ -226,33 +158,22 @@ class ExamSystemApp {
const integratedBootstrapMixin = {
checkDependencies() {
- const requiredGlobals = ['storage'];
+ const requiredGlobals = ['AppData'];
const missing = requiredGlobals.filter((name) => !window[name]);
if (missing.length > 0) {
throw new Error(`Missing required dependencies: ${missing.join(', ')}`);
}
},
async initializeComponents() {
- const optionalComponents = [];
- try {
- await this.initializeCoreComponents();
- if (optionalComponents.length > 0) {
- try {
- await this.waitForComponents(optionalComponents, 5000);
- await this.initializeOptionalComponents();
- } catch (_) {
- await this.initializeAvailableOptionalComponents();
- }
- } else {
- await this.initializeOptionalComponents();
- }
- } catch (error) {
- console.error('[App] 核心组件加载失败:', error);
- throw error;
- }
+ await this.initializeCoreComponents();
},
async initializeCoreComponents() {
if (this.instantiatePracticeRecorder()) {
+ // PracticeRecorder restores durable sessions asynchronously. The
+ // hot-upgrade rebind must run after that restore has completed;
+ // otherwise the recovery snapshot can overwrite the host session
+ // that we are about to seed.
+ await this._practiceRecorderRebindPromise;
return;
}
console.warn('[App] PracticeRecorder类不可用,使用降级记录器');
@@ -265,14 +186,119 @@ class ExamSystemApp {
return false;
}
try {
- this.components.practiceRecorder = new PracticeRecorder();
+ const previous = this.components && this.components.practiceRecorder
+ ? this.components.practiceRecorder
+ : null;
+ if (previous && previous.constructor === window.PracticeRecorder && previous.isFallback !== true) {
+ return true;
+ }
+ const recorder = new PracticeRecorder();
+ this.components.practiceRecorder = recorder;
this.ensurePracticeRecorderEvents();
+ // Hot-upgrade from the bootstrap fallback must re-seed live host sessions;
+ // otherwise PRACTICE_COMPLETE finds no activeSessions and production rejects
+ // synthetic saves, so the child never receives PRACTICE_SUBMIT_ACK / results.
+ const recorderReady = recorder.ready && typeof recorder.ready.then === 'function'
+ ? recorder.ready
+ : Promise.resolve();
+ this._practiceRecorderRebindPromise = Promise.resolve(recorderReady)
+ .then(() => this._rebindPracticeRecorderSessions(recorder, previous))
+ .catch((rebindError) => {
+ console.warn('[App] PracticeRecorder ready 后重建活动会话失败:', rebindError);
+ });
return true;
} catch (error) {
console.error('[App] PracticeRecorder初始化失败:', error);
return false;
}
},
+ _rebindPracticeRecorderSessions(recorder, previousRecorder = null) {
+ if (!recorder || typeof recorder.startPracticeSession !== 'function') {
+ return;
+ }
+ const seeded = new Set();
+ try {
+ if (this.examWindows && typeof this.examWindows.forEach === 'function') {
+ this.examWindows.forEach((info, examId) => {
+ if (!info || !examId) {
+ return;
+ }
+ if (info.reviewMode || String(info.practiceMode || '').toLowerCase() === 'memorize') {
+ return;
+ }
+ if (info.status === 'completed' || info.status === 'closed') {
+ return;
+ }
+ const sessionId = info.expectedSessionId || info.sessionId || null;
+ if (!sessionId) {
+ return;
+ }
+ try {
+ recorder.startPracticeSession(examId, {
+ sessionId: String(sessionId),
+ title: info.title || info.examTitle || '',
+ category: info.category || info.pageType || '',
+ frequency: info.frequency || '',
+ libraryConfigurationId: Object.prototype.hasOwnProperty.call(info, 'libraryConfigurationId')
+ ? info.libraryConfigurationId
+ : (typeof this._readLaunchLibraryConfigurationId === 'function'
+ ? this._readLaunchLibraryConfigurationId(examId, null, info)
+ : null)
+ });
+ if (typeof recorder.handleSessionStarted === 'function') {
+ recorder.handleSessionStarted({
+ examId,
+ sessionId: String(sessionId),
+ metadata: {
+ pageType: info.pageType || null,
+ suiteSessionId: info.suiteSessionId || null,
+ source: 'recorder-hot-upgrade',
+ libraryConfigurationId: Object.prototype.hasOwnProperty.call(info, 'libraryConfigurationId')
+ ? info.libraryConfigurationId
+ : null
+ }
+ });
+ }
+ seeded.add(String(examId));
+ } catch (seedError) {
+ console.warn('[App] 升级 PracticeRecorder 时重建活动会话失败:', examId, seedError);
+ }
+ });
+ }
+ } catch (error) {
+ console.warn('[App] 升级 PracticeRecorder 时扫描 examWindows 失败:', error);
+ }
+
+ // Carry over any sessions the fallback stub tracked in-memory before the class loaded.
+ try {
+ const priorSessions = previousRecorder && previousRecorder.activeSessions;
+ if (priorSessions && typeof priorSessions.forEach === 'function') {
+ priorSessions.forEach((session, examId) => {
+ if (!examId || seeded.has(String(examId)) || !session) {
+ return;
+ }
+ const sessionId = session.sessionId || session.id || null;
+ if (!sessionId) {
+ return;
+ }
+ try {
+ recorder.startPracticeSession(examId, Object.assign({}, session.metadata || {}, {
+ sessionId: String(sessionId),
+ title: session.metadata && (session.metadata.examTitle || session.metadata.title) || '',
+ totalQuestions: session.progress && session.progress.totalQuestions || 0,
+ libraryConfigurationId: session.metadata && session.metadata.libraryConfigurationId != null
+ ? session.metadata.libraryConfigurationId
+ : null
+ }));
+ } catch (seedError) {
+ console.warn('[App] 升级 PracticeRecorder 时迁移降级会话失败:', examId, seedError);
+ }
+ });
+ }
+ } catch (error) {
+ console.warn('[App] 升级 PracticeRecorder 时读取降级会话失败:', error);
+ }
+ },
ensurePracticeRecorderEvents() {
if (this._practiceRecorderEventsBound) {
return;
@@ -282,36 +308,61 @@ class ExamSystemApp {
}
},
createFallbackRecorder() {
- function normalizeRecords(records) {
- return Array.isArray(records) ? records : [];
- }
+ const activeSessions = new Map();
+ const start = (examId, examData = {}) => {
+ const sessionId = (examData && examData.sessionId)
+ || `fallback_${examId || 'exam'}_${Date.now()}`;
+ const session = {
+ examId: examId || '',
+ startTime: new Date().toISOString(),
+ sessionId,
+ status: 'started',
+ progress: {
+ totalQuestions: examData && examData.totalQuestions || 0
+ },
+ metadata: {
+ examTitle: examData && examData.title || '',
+ category: examData && examData.category || '',
+ frequency: examData && examData.frequency || '',
+ libraryConfigurationId: examData && examData.libraryConfigurationId != null
+ ? examData.libraryConfigurationId
+ : null
+ }
+ };
+ if (examId) {
+ activeSessions.set(examId, session);
+ }
+ return session;
+ };
return {
- startPracticeSession: (examId) => ({ examId: examId || '', startTime: Date.now(), sessionId: `fallback_${Date.now()}`, status: 'started' }),
- startSession: (examId) => ({ examId: examId || '', startTime: Date.now(), sessionId: `fallback_${Date.now()}`, status: 'started' }),
- handleRealPracticeData: async () => null,
- savePracticeRecord: async (record) => {
- try {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.saveRecord === 'function') {
- await window.PracticeRecordAPI.saveRecord(record);
- } else {
- throw new Error('统一练习记录存储未就绪');
- }
- } catch (error) {
- console.warn('[App] 降级记录器保存失败:', error);
+ activeSessions,
+ isFallback: true,
+ startPracticeSession: start,
+ startSession: start,
+ handleSessionStarted: (data) => {
+ if (!data || !data.examId || !data.sessionId) {
+ return;
}
- return record || null;
- },
- getPracticeRecords: async () => {
- try {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- return normalizeRecords(await window.PracticeRecordAPI.list());
- }
- return [];
- } catch (error) {
- console.warn('[App] 降级记录器读取失败:', error);
- return [];
+ const existing = activeSessions.get(data.examId) || {
+ examId: data.examId,
+ startTime: new Date().toISOString(),
+ status: 'started',
+ metadata: {}
+ };
+ existing.sessionId = data.sessionId;
+ existing.status = 'active';
+ if (data.metadata) {
+ existing.metadata = Object.assign({}, existing.metadata || {}, data.metadata);
}
- }
+ activeSessions.set(data.examId, existing);
+ },
+ savePracticeRecord: async (record) => {
+ const receipt = await window.AppData.practice.completeAttempt({ record });
+ return receipt && receipt.record ? receipt.record : null;
+ },
+ // 兼容用的记录列表读取:调用方只做列表/统计展示,light 投影已覆盖,
+ // 不需要拉取答题详情、笔记与高亮等重负载字段。
+ getPracticeRecords: async () => window.AppData.practice.list({ projection: 'light' })
};
},
schedulePracticeRecorderUpgrade(maxAttempts = 20, interval = 500) {
@@ -336,43 +387,6 @@ class ExamSystemApp {
this._practiceRecorderUpgradeTimer = setInterval(tryUpgrade, interval);
tryUpgrade();
},
- async initializeOptionalComponents() {},
- async initializeAvailableOptionalComponents() {
- const availableComponents = [].filter((name) => window[name]);
- if (availableComponents.length > 0) {
- await this.initializeOptionalComponents();
- } else {
- console.warn('[App] 没有发现可用的可选组件');
- }
- },
- async waitForComponents(requiredClasses = ['ExamBrowser'], timeout = 3000) {
- const startTime = Date.now();
- const checkInterval = 100;
- while (Date.now() - startTime < timeout) {
- const loadingStatus = requiredClasses.map((className) => {
- const isLoaded = window[className] && typeof window[className] === 'function';
- if (!isLoaded) {
- console.debug(`[App] 等待组件: ${className}`);
- }
- return { className, isLoaded };
- });
- const allLoaded = loadingStatus.every((status) => status.isLoaded);
- if (allLoaded) {
- return true;
- }
- await new Promise((resolve) => setTimeout(resolve, checkInterval));
- }
- const missingClasses = requiredClasses.filter((className) => !window[className] || typeof window[className] !== 'function');
- const loadedClasses = requiredClasses.filter((className) => window[className] && typeof window[className] === 'function');
- const errorMessage = [
- `组件加载超时 (${timeout}ms)`,
- `已加载: ${loadedClasses.join(', ') || '无'}`,
- `缺失: ${missingClasses.join(', ')}`,
- '请检查组件文件是否正确加载'
- ].join('\n');
- console.error('[App] 组件加载失败:', errorMessage);
- throw new Error(errorMessage);
- }
};
const integratedFallbackMixin = {
@@ -637,11 +651,17 @@ class ExamSystemApp {
case 'browse':
if (window.__pendingBrowseFilter && typeof window.applyBrowseFilter === 'function') {
const { category, type, filterMode, path } = window.__pendingBrowseFilter;
- try {
- window.applyBrowseFilter(category, type, filterMode, path);
- } finally {
- delete window.__pendingBrowseFilter;
- }
+ Promise.resolve(
+ typeof window.initializeBrowseView === 'function'
+ ? window.initializeBrowseView({ skipLoad: true })
+ : null
+ ).then(() => window.applyBrowseFilter(category, type, filterMode, path))
+ .catch((error) => {
+ console.warn('[App] 应用待处理题库筛选失败:', error);
+ })
+ .finally(() => {
+ delete window.__pendingBrowseFilter;
+ });
} else if (typeof window.initializeBrowseView === 'function') {
window.initializeBrowseView();
}
@@ -652,6 +672,9 @@ class ExamSystemApp {
.then(() => (typeof window.ensureBrowseGroup === 'function' ? window.ensureBrowseGroup() : null))
.then(() => (typeof window.ensurePracticeSuiteReady === 'function' ? window.ensurePracticeSuiteReady() : null))
.then(() => {
+ if (typeof window.ensurePracticeRecordsSync === 'function') {
+ return window.ensurePracticeRecordsSync('practice-view');
+ }
if (typeof window.syncPracticeRecords === 'function') {
return window.syncPracticeRecords();
}
@@ -681,6 +704,7 @@ class ExamSystemApp {
}
},
browseCategory(category, type = null, filterMode = null, path = null) {
+ const wasAlreadyInBrowse = this.currentView === 'browse';
try {
window.__pendingBrowseFilter = { category, type, filterMode, path };
const descriptor = Object.getOwnPropertyDescriptor(window, '__browseFilter');
@@ -695,14 +719,16 @@ class ExamSystemApp {
} catch (_) {}
this.navigateToView('browse');
try {
- if (typeof window.applyBrowseFilter === 'function' && document.getElementById('browse-view')?.classList.contains('active')) {
+ // 非 browse → browse 时,onViewActivated 已经消费 pending filter;
+ // 只有原本就在 browse 页时才需要补一次应用,避免双重加载。
+ if (wasAlreadyInBrowse && typeof window.applyBrowseFilter === 'function' && document.getElementById('browse-view')?.classList.contains('active')) {
window.applyBrowseFilter(category, type, filterMode, path);
delete window.__pendingBrowseFilter;
}
} catch (_) {}
},
async startCategoryPractice(category) {
- const examIndex = await storage.get('exam_index', []);
+ const examIndex = await window.resolveActiveLibraryIndex();
const categoryExams = examIndex.filter((exam) => exam.category === category);
if (categoryExams.length === 0) {
window.showMessage(`${category} 分类暂无可用题目`, 'warning');
@@ -726,8 +752,6 @@ class ExamSystemApp {
this.checkDependencies();
this.updateLoadingMessage('正在初始化状态管理...');
this.initializeGlobalCompatibility();
- this.updateLoadingMessage('正在加载持久化状态...');
- await this.loadPersistedState();
this.updateLoadingMessage('正在初始化响应式功能...');
this.initializeResponsiveFeatures();
this.updateLoadingMessage('正在加载系统组件...');
@@ -928,20 +952,13 @@ class ExamSystemApp {
},
async loadInitialData() {
try {
- const examIndex = await storage.get('exam_index', []);
- if (Array.isArray(examIndex)) {
- this.setState('exam.index', examIndex);
- }
- const practiceRecords = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function'
- ? await window.PracticeRecordAPI.list()
- : [];
- if (Array.isArray(practiceRecords)) {
- this.setState('practice.records', practiceRecords);
- }
- const browseFilter = await storage.get('browse_filter', { category: 'all', type: 'all' });
+ const browsePreference = await window.AppData.preferences.getBrowse();
+ const browseFilter = browsePreference && browsePreference.filter
+ ? browsePreference.filter
+ : { category: 'all', type: 'all' };
this.setState('ui.browseFilter', browseFilter);
await this.loadUserStats();
- this.updateOverviewStats();
+ await this.updateOverviewStats();
} catch (error) {
console.error('Failed to load initial data:', error);
}
@@ -957,15 +974,15 @@ class ExamSystemApp {
lastPracticeDate: null,
achievements: []
};
- const stats = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function'
- ? await window.PracticeRecordAPI.readStats({ fallback })
- : fallback;
+ const stats = Object.assign({}, fallback, await window.AppData.practice.getStats());
this.userStats = stats;
return stats;
},
async updateOverviewStats() {
- const examIndex = this.getState('exam.index') || [];
- const practiceRecords = this.getState('practice.records') || [];
+ const [examIndex, practiceRecords] = await Promise.all([
+ window.resolveActiveLibraryIndex(),
+ window.AppData.practice.list({ projection: 'light' })
+ ]);
if (!Array.isArray(examIndex) || !Array.isArray(practiceRecords)) {
console.warn('[App] 状态管理中的数据格式异常');
return;
@@ -1003,7 +1020,7 @@ class ExamSystemApp {
},
updateCategoryStats(examIndex, practiceRecords) {
const categories = ['P1', 'P2', 'P3'];
- const list = Array.isArray(examIndex) ? examIndex : (Array.isArray(window.examIndex) ? window.examIndex : []);
+ const list = Array.isArray(examIndex) ? examIndex : [];
categories.forEach((category) => {
const categoryExams = list.filter((exam) => exam.category === category);
const categoryRecords = practiceRecords.filter((record) => {
@@ -1091,7 +1108,12 @@ class ExamSystemApp {
},
onStartEndless() {
if (window.AppActions && typeof window.AppActions.startEndlessPractice === 'function') {
- window.AppActions.startEndlessPractice();
+ Promise.resolve(window.AppActions.startEndlessPractice()).catch((error) => {
+ console.error('[App] 无尽模式启动失败:', error);
+ if (typeof window.showMessage === 'function') {
+ window.showMessage('无尽模式启动失败,请稍后重试', 'error');
+ }
+ });
return;
}
if (typeof window.showMessage === 'function') {
@@ -1127,12 +1149,6 @@ class ExamSystemApp {
}
},
destroy() {
- this.persistMultipleState({
- 'exam.index': 'exam_index',
- 'ui.browseFilter': 'browse_filter',
- 'exam.currentCategory': 'current_category',
- 'exam.currentExamType': 'current_exam_type'
- });
window.removeEventListener('resize', this.handleResize);
if (this.sessionMonitorInterval) {
clearInterval(this.sessionMonitorInterval);
diff --git a/js/app/browseController.js b/js/app/browseController.js
index add439a1..5fca8a25 100644
--- a/js/app/browseController.js
+++ b/js/app/browseController.js
@@ -72,29 +72,15 @@
}
};
- function getActiveExamIndex() {
- try {
- if (typeof global.getExamIndexState === 'function') {
- const state = global.getExamIndexState();
- return Array.isArray(state) ? state : [];
- }
- } catch (_) { }
- return Array.isArray(global.examIndex) ? global.examIndex : null;
- }
-
function hasListeningEntries(index) {
return (Array.isArray(index) ? index : []).some((exam) => {
return exam && exam.type === 'listening';
});
}
- function hasActiveListeningLibrary() {
+ function hasActiveListeningLibrary(index) {
if (typeof global.hasActiveListeningLibrary === 'function') {
- return global.hasActiveListeningLibrary();
- }
- const index = getActiveExamIndex();
- if (index === null) {
- return true;
+ return global.hasActiveListeningLibrary(index);
}
return hasListeningEntries(index);
}
@@ -124,7 +110,7 @@
* 初始化控制器
* @param {string} containerId - 按钮容器的DOM ID
*/
- initialize(containerId = 'type-filter-buttons') {
+ initialize(containerId = 'type-filter-buttons', examIndex = []) {
this.buttonContainer = document.getElementById(containerId);
if (!this.buttonContainer) {
console.warn('[BrowseController] 按钮容器未找到:', containerId);
@@ -132,10 +118,10 @@
}
// 从全局状态恢复模式
- this.restoreMode();
+ this.restoreMode(examIndex);
// 渲染初始按钮
- this.renderFilterButtons();
+ this.renderFilterButtons(examIndex);
return true;
}
@@ -144,7 +130,7 @@
* 设置浏览模式
* @param {string} mode - 模式ID (default | frequency-p1 | frequency-p4)
*/
- setMode(mode) {
+ setMode(mode, examIndex = []) {
if (isReadingMemorizeBrowseMode()) {
mode = 'default';
}
@@ -153,7 +139,7 @@
return;
}
- const nextMode = isListeningMode(mode) && !hasActiveListeningLibrary()
+ const nextMode = isListeningMode(mode) && !hasActiveListeningLibrary(examIndex)
? 'default'
: mode;
this.currentMode = nextMode;
@@ -163,10 +149,10 @@
this.saveMode();
// 重新渲染按钮
- this.renderFilterButtons();
+ this.renderFilterButtons(examIndex);
// 应用筛选
- this.applyFilter(this.activeFilter);
+ this.applyFilter(this.activeFilter, examIndex);
}
/**
@@ -180,13 +166,13 @@
/**
* 渲染筛选按钮
*/
- renderFilterButtons() {
+ renderFilterButtons(examIndex = []) {
if (!this.buttonContainer) {
return;
}
const config = this.getCurrentModeConfig();
- const filters = this.getVisibleFilters(config);
+ const filters = this.getVisibleFilters(config, examIndex);
if (!filters.some((filter) => filter.id === this.activeFilter)) {
this.activeFilter = filters.length ? filters[0].id : 'all';
}
@@ -218,8 +204,13 @@
button.setAttribute('aria-pressed', filter.id === this.activeFilter ? 'true' : 'false');
// 绑定点击事件
- button.addEventListener('click', () => {
- this.handleFilterClick(filter.id);
+ button.addEventListener('click', async () => {
+ try {
+ const index = await global.resolveActiveLibraryIndex();
+ this.handleFilterClick(filter.id, index);
+ } catch (error) {
+ console.error('[BrowseController] 读取活动题库失败:', error);
+ }
});
this.buttonContainer.appendChild(button);
@@ -231,16 +222,16 @@
}
}
- getVisibleFilters(config) {
+ getVisibleFilters(config, examIndex = []) {
const normalized = config || this.getCurrentModeConfig();
const filters = Array.isArray(normalized.filters) ? normalized.filters : [];
if (isReadingMemorizeBrowseMode()) {
return BROWSE_MODES.default.filters.filter((filter) => filter.type === 'reading');
}
- if (normalized.id === 'default' && !hasActiveListeningLibrary()) {
+ if (normalized.id === 'default' && !hasActiveListeningLibrary(examIndex)) {
return filters.filter((filter) => filter.type !== 'listening');
}
- if (isListeningMode(normalized.id) && !hasActiveListeningLibrary()) {
+ if (isListeningMode(normalized.id) && !hasActiveListeningLibrary(examIndex)) {
return BROWSE_MODES.default.filters.filter((filter) => filter.type !== 'listening');
}
return filters.slice();
@@ -250,14 +241,14 @@
* 处理筛选按钮点击
* @param {string} filterId - 筛选器ID
*/
- handleFilterClick(filterId) {
+ handleFilterClick(filterId, examIndex = []) {
this.activeFilter = filterId;
// 更新按钮激活状态
this.updateButtonStates();
// 应用筛选
- this.applyFilter(filterId);
+ this.applyFilter(filterId, examIndex);
}
/**
@@ -285,15 +276,15 @@
* 应用筛选
* @param {string} filterId - 筛选器ID
*/
- applyFilter(filterId) {
+ applyFilter(filterId, examIndex = []) {
const config = this.getCurrentModeConfig();
if (config.filterLogic === 'type-based') {
// 默认模式:按类型筛选
- this.filterByType(filterId);
+ this.filterByType(filterId, examIndex);
} else if (config.filterLogic === 'folder-based') {
// 频率模式:按文件夹筛选
- this.filterByFolder(filterId);
+ this.filterByFolder(filterId, examIndex);
}
}
@@ -301,10 +292,10 @@
* 按类型筛选(默认模式)
* @param {string} type - 类型 (all | reading | listening)
*/
- filterByType(type) {
+ filterByType(type, examIndex = []) {
// 调用全局的 filterByType 函数
if (typeof global.filterByType === 'function') {
- global.filterByType(type);
+ global.filterByType(type, examIndex);
} else {
console.warn('[BrowseController] filterByType 函数未定义');
}
@@ -314,7 +305,7 @@
* 按文件夹筛选(频率模式)
* @param {string} filterId - 筛选器ID
*/
- filterByFolder(filterId) {
+ filterByFolder(filterId, examIndex = []) {
const config = this.getCurrentModeConfig();
const basePath = global.__browsePath || config.basePath || null;
const folders = config.folderMap[filterId];
@@ -326,11 +317,8 @@
return;
}
- // 获取题库索引
- const examIndex = this.getExamIndex();
-
// 筛选题目
- const filtered = examIndex.filter(exam => {
+ const filtered = (Array.isArray(examIndex) ? examIndex : []).filter(exam => {
if (!exam || !exam.path) {
return false;
}
@@ -353,23 +341,6 @@
// 显示筛选结果
this.displayFilteredExams(filtered);
}
-
-
-
- /**
- * 获取题库索引
- * @returns {Array} 题库数组
- */
- getExamIndex() {
- // 优先使用全局状态服务
- if (typeof global.getExamIndexState === 'function') {
- return global.getExamIndexState();
- }
-
- // 回退到全局变量
- return Array.isArray(global.examIndex) ? global.examIndex : [];
- }
-
/**
* 显示筛选后的题目
* @param {Array} exams - 题目数组
@@ -407,11 +378,11 @@
/**
* 从全局状态恢复模式
*/
- restoreMode() {
+ restoreMode(examIndex = []) {
try {
const savedMode = global.__browseFilterMode;
if (savedMode && BROWSE_MODES[savedMode]) {
- this.currentMode = isListeningMode(savedMode) && !hasActiveListeningLibrary()
+ this.currentMode = isListeningMode(savedMode) && !hasActiveListeningLibrary(examIndex)
? 'default'
: savedMode;
}
@@ -423,8 +394,8 @@
/**
* 重置为默认模式
*/
- resetToDefault() {
- this.setMode('default');
+ resetToDefault(examIndex = []) {
+ this.setMode('default', examIndex);
}
// ============================================================================
@@ -547,10 +518,8 @@
// 4. 调用 ExamActions.loadExamList 来执行真正的筛选和渲染
// 这确保了所有逻辑(包括频率模式、置顶等)都由 ExamActions 统一处理
- if (global.ExamActions && typeof global.ExamActions.loadExamList === 'function') {
- global.ExamActions.loadExamList();
- } else if (typeof global.loadExamList === 'function') {
- global.loadExamList();
+ if (typeof global.loadExamList === 'function') {
+ global.loadExamList(normalizedOptions.examIndex || null);
} else {
console.warn('[BrowseController] 无法加载题库列表: loadExamList 未定义');
}
@@ -575,9 +544,8 @@
global.BrowseController = BrowseController;
global.BROWSE_MODES = BROWSE_MODES;
global.refreshListeningAvailabilityUI = function refreshListeningAvailabilityUI(index) {
- const listeningAvailable = Array.isArray(index)
- ? hasListeningEntries(index)
- : hasActiveListeningLibrary();
+ const examIndex = Array.isArray(index) ? index : [];
+ const listeningAvailable = hasListeningEntries(examIndex);
const controller = global.browseController || null;
if (controller && isListeningMode(controller.currentMode) && !listeningAvailable) {
@@ -598,7 +566,7 @@
}
if (controller && controller.buttonContainer) {
- controller.renderFilterButtons();
+ controller.renderFilterButtons(examIndex);
} else {
const container = global.document && global.document.getElementById('type-filter-buttons');
const listeningButtons = container
diff --git a/js/app/examActions.js b/js/app/examActions.js
index 8c6fc821..a7c666ce 100644
--- a/js/app/examActions.js
+++ b/js/app/examActions.js
@@ -352,15 +352,9 @@
return categories[Math.max(0, stageIndex)] || null;
}
- function findExamById(examId) {
- const list = Array.isArray(global.examIndex)
- ? global.examIndex
- : (global.appStateService && typeof global.appStateService.getExamIndex === 'function'
- ? global.appStateService.getExamIndex()
- : []);
- return Array.isArray(list)
- ? list.find((item) => item && String(item.id) === String(examId))
- : null;
+ function findExamById(examId, examIndex) {
+ const list = Array.isArray(examIndex) ? examIndex : [];
+ return list.find((item) => item && String(item.id) === String(examId)) || null;
}
function isReadingMemorizeBrowseMode() {
@@ -409,8 +403,11 @@
return (Array.isArray(exams) ? exams : []).filter(isReadingMemorizeExam);
}
- function launchReadingMemorizeExam(examId) {
- const exam = findExamById(examId);
+ async function launchReadingMemorizeExam(examId, examIndex = null) {
+ const list = Array.isArray(examIndex)
+ ? examIndex
+ : await global.resolveActiveLibraryIndex();
+ const exam = findExamById(examId, list);
if (!isReadingMemorizeExam(exam)) {
if (typeof global.showMessage === 'function') {
global.showMessage('该题目无法使用统一阅读页背题,请选择有 HTML 数据的阅读题。', 'warning');
@@ -701,13 +698,16 @@
}
}
- function handleCustomSuiteSelect(examId) {
+ async function handleCustomSuiteSelect(examId, examIndex = null) {
const draft = getCustomSuiteDraft();
if (!draft || draft.status === 'ready') {
return false;
}
- const exam = findExamById(examId);
+ const list = Array.isArray(examIndex)
+ ? examIndex
+ : await global.resolveActiveLibraryIndex();
+ const exam = findExamById(examId, list);
if (!exam) {
return false;
}
@@ -808,7 +808,7 @@
/**
* 加载并渲染题库列表
*/
- function loadExamList() {
+ function loadExamList(examIndex = []) {
console.log('[ExamActions] loadExamList called');
if (typeof global.setupBrowseControls === 'function') {
@@ -828,13 +828,13 @@
if (!memorizeSelectionActive && global.__browseFilterMode && global.__browseFilterMode !== 'default' && global.browseController) {
try {
if (!global.browseController.buttonContainer) {
- global.browseController.initialize('type-filter-buttons');
+ global.browseController.initialize('type-filter-buttons', examIndex);
}
if (global.browseController.currentMode !== global.__browseFilterMode) {
- global.browseController.setMode(global.__browseFilterMode);
+ global.browseController.setMode(global.__browseFilterMode, examIndex);
} else {
const activeFilter = global.browseController.activeFilter || 'all';
- global.browseController.applyFilter(activeFilter);
+ global.browseController.applyFilter(activeFilter, examIndex);
}
return;
} catch (error) {
@@ -842,15 +842,8 @@
}
}
- // 2. 获取题库快照
- let examIndexSnapshot = [];
- if (global.appStateService) {
- examIndexSnapshot = global.appStateService.getExamIndex();
- } else if (typeof global.getExamIndexState === 'function') {
- examIndexSnapshot = global.getExamIndexState();
- } else {
- examIndexSnapshot = Array.isArray(global.examIndex) ? global.examIndex : [];
- }
+ // 2. 使用控制器边界传入的本次题库快照。
+ const examIndexSnapshot = Array.isArray(examIndex) ? examIndex : [];
// 3. 获取筛选条件
let activeCategory = 'all';
@@ -1320,46 +1313,12 @@
return Promise.resolve();
}
- function ensureSettingsToolsReady() {
- if (global.AppLazyLoader && typeof global.AppLazyLoader.ensureGroup === 'function') {
- return global.AppLazyLoader.ensureGroup('settings-tools');
- }
- return ensureBrowseGroupReady();
- }
-
- async function ensureDataIntegrityManagerReady() {
- try {
- await ensureSettingsToolsReady();
- } catch (error) {
- console.warn('[ExamActions] 设置工具预加载失败,继续尝试导出:', error);
- }
-
- if (!global.dataIntegrityManager && global.DataIntegrityManager) {
- try {
- global.dataIntegrityManager = new global.DataIntegrityManager();
- } catch (error) {
- console.warn('[ExamActions] 初始化 DataIntegrityManager 失败:', error);
- }
- }
-
- return global.dataIntegrityManager || null;
- }
-
async function exportPracticeData() {
try {
- if (global.dataIntegrityManager && typeof global.dataIntegrityManager.exportData === 'function') {
- global.dataIntegrityManager.exportData();
- try { global.showMessage && global.showMessage('导出完成', 'success'); } catch (_) { }
- return;
- }
- } catch (_) { }
- try {
- var records = global.PracticeRecordAPI && typeof global.PracticeRecordAPI.list === 'function'
- ? await global.PracticeRecordAPI.list()
- : (global.getPracticeRecordsState ? global.getPracticeRecordsState() : []);
- var blob = new Blob([JSON.stringify(records, null, 2)], { type: 'application/json; charset=utf-8' });
+ var snapshot = await global.AppData.backups.export({ domains: ['practice'] });
+ var blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json; charset=utf-8' });
var url = URL.createObjectURL(blob);
- var a = document.createElement('a'); a.href = url; a.download = 'practice-records.json';
+ var a = document.createElement('a'); a.href = url; a.download = 'ielts-atlas-practice-v2.json';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
try { global.showMessage && global.showMessage('导出完成', 'success'); } catch (_) { }
@@ -1370,14 +1329,17 @@
}
async function exportAllData() {
- var manager = null;
try {
- manager = await ensureDataIntegrityManagerReady();
- if (manager && typeof manager.exportData === 'function') {
- await manager.exportData();
- try { global.showMessage && global.showMessage('数据导出成功', 'success'); } catch (_) { }
- return;
- }
+ var snapshot = await global.AppData.backups.export();
+ var blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json; charset=utf-8' });
+ var url = URL.createObjectURL(blob);
+ var anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = 'ielts-atlas-backup-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json';
+ document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(url);
+ try { await global.AppData.backups.recordExport({ type: 'full-v2', checksum: snapshot.checksum }); } catch (historyError) { console.warn('[ExamActions] 导出历史记录失败:', historyError); }
+ try { global.showMessage && global.showMessage('数据导出成功', 'success'); } catch (_) { }
+ return snapshot;
} catch (error) {
console.error('[ExamActions] 数据导出失败:', error);
if (typeof global.showMessage === 'function') {
@@ -1386,9 +1348,7 @@
return;
}
- if (typeof global.exportPracticeData === 'function') {
- return global.exportPracticeData();
- }
+ return null;
if (typeof global.showMessage === 'function') {
global.showMessage('Data manager module is unavailable.', 'warning');
}
@@ -1449,7 +1409,8 @@
isReadingMemorizeExam
};
- global.loadExamList = loadExamList;
+ // 全局 loadExamList 由 main.js 的适配器持有(无参时自解析题库索引);
+ // 此处仅通过 global.ExamActions.loadExamList 暴露,避免覆盖后无参调用拿到空数组。
global.resetBrowseViewToAll = resetBrowseViewToAll;
global.displayExams = displayExams;
global.setupExamActionHandlers = setupExamActionHandlers;
diff --git a/js/app/examSessionMixin.js b/js/app/examSessionMixin.js
index 803ae383..6274569e 100644
--- a/js/app/examSessionMixin.js
+++ b/js/app/examSessionMixin.js
@@ -6,59 +6,11 @@
const PRACTICE_ENHANCER_BUILD_ID = '20250105';
async function getActiveExamIndexSnapshot() {
- const stateGetters = [
- () => (typeof global.getExamIndexState === 'function') ? global.getExamIndexState() : null,
- () => (typeof getExamIndexState === 'function') ? getExamIndexState : null
- ];
-
- for (const getterFactory of stateGetters) {
- try {
- const getter = getterFactory();
- if (typeof getter === 'function') {
- const state = getter();
- if (Array.isArray(state) && state.length) {
- return state.slice();
- }
- }
- } catch (_) { }
- }
-
- let activeKey = 'exam_index';
- try {
- if (typeof global.getActiveLibraryConfigurationKey === 'function') {
- const resolved = await global.getActiveLibraryConfigurationKey();
- if (resolved && typeof resolved === 'string' && resolved.trim()) {
- activeKey = resolved.trim();
- }
- } else {
- const storedKey = await storage.get('active_exam_index_key', 'exam_index');
- if (storedKey && typeof storedKey === 'string' && storedKey.trim()) {
- activeKey = storedKey.trim();
- }
- }
- } catch (_) {
- try {
- const storedKey = await storage.get('active_exam_index_key', 'exam_index');
- if (storedKey && typeof storedKey === 'string' && storedKey.trim()) {
- activeKey = storedKey.trim();
- }
- } catch (_) { }
- }
-
- let dataset = await storage.get(activeKey, []) || [];
- if ((!Array.isArray(dataset) || dataset.length === 0) && activeKey !== 'exam_index') {
- dataset = await storage.get('exam_index', []) || [];
+ if (typeof global.resolveActiveLibraryIndex !== 'function') {
+ throw new Error('LibraryManager.resolveActiveIndex is unavailable');
}
- if (!Array.isArray(dataset) || dataset.length === 0) {
- if (Array.isArray(global.examIndex) && global.examIndex.length) {
- dataset = global.examIndex.slice();
- } else if (typeof global.getReadingExamIndex === 'function') {
- dataset = global.getReadingExamIndex();
- } else if (Array.isArray(global.__READING_EXAM_INDEX__) && global.__READING_EXAM_INDEX__.length) {
- dataset = global.__READING_EXAM_INDEX__.slice();
- }
- }
- return Array.isArray(dataset) ? dataset : [];
+ const dataset = await global.resolveActiveLibraryIndex();
+ return Array.isArray(dataset) ? dataset.slice() : [];
}
async function findExamDefinition(examId) {
@@ -71,20 +23,6 @@
return match;
}
- const fallbacks = [
- Array.isArray(global.examIndex) ? global.examIndex : null,
- typeof global.getReadingExamIndex === 'function' ? global.getReadingExamIndex() : null,
- Array.isArray(global.__READING_EXAM_INDEX__) ? global.__READING_EXAM_INDEX__ : null,
- Array.isArray(global.listeningExamIndex) ? global.listeningExamIndex : null
- ];
- for (const fallback of fallbacks) {
- if (!Array.isArray(fallback)) continue;
- const found = fallback.find(entry => entry && entry.id === examId);
- if (found) {
- return found;
- }
- }
-
return null;
}
@@ -364,21 +302,598 @@
};
},
+ _captureExamLaunchRegistrationState(examId) {
+ const windowInfo = this.examWindows && this.examWindows.get(examId);
+ return Object.freeze({
+ hasRegistration: Boolean(windowInfo),
+ registrationEpoch: Number(this._examRegistrationEpochs
+ && this._examRegistrationEpochs.get(String(examId || '')) || 0),
+ registration: windowInfo
+ ? this._captureExamSessionRegistration(examId, windowInfo)
+ : null
+ });
+ },
+
+ _resolveExamLaunchTargetLeaseKeys(examId, options = {}) {
+ // Reserve every named browsing context this launch may navigate before
+ // the first await. A later explicit reuse of one of these names must
+ // invalidate the older launch before window.open() can navigate it.
+ const normalizedExamId = String(examId || '').trim();
+ const names = normalizedExamId
+ ? [`exam_${normalizedExamId}`, `pdf_${normalizedExamId}`]
+ : [];
+ if (options && typeof options.windowName === 'string') {
+ names.push(options.windowName.trim());
+ }
+ if (options && options.reuseWindow) {
+ try {
+ if (typeof options.reuseWindow.name === 'string') {
+ names.push(options.reuseWindow.name.trim());
+ }
+ } catch (_) {}
+ }
+ return Object.freeze(Array.from(new Set(names
+ .filter(name => name && !name.startsWith('_'))
+ .map(name => `window-name:${name}`))));
+ },
+
+ _recordExamWindowNavigation(targetWindow, examId = '') {
+ if (!targetWindow
+ || (typeof targetWindow !== 'object' && typeof targetWindow !== 'function')) {
+ return null;
+ }
+ if (!this._examWindowCommittedNavigationOwners) {
+ this._examWindowCommittedNavigationOwners = new WeakMap();
+ }
+ this._examWindowCommittedNavigationSequence = Math.max(
+ 0,
+ Number(this._examWindowCommittedNavigationSequence) || 0
+ ) + 1;
+ const navigationOwnership = Object.freeze({
+ examId: String(examId || ''),
+ sequence: this._examWindowCommittedNavigationSequence
+ });
+ this._examWindowCommittedNavigationOwners.set(targetWindow, navigationOwnership);
+ return navigationOwnership;
+ },
+
+ _isExamWindowNavigationCurrent(targetWindow, expectedNavigationOwnership) {
+ return Boolean(
+ targetWindow
+ && expectedNavigationOwnership
+ && this._examWindowCommittedNavigationOwners
+ && this._examWindowCommittedNavigationOwners.get(targetWindow) === expectedNavigationOwnership
+ );
+ },
+
+ _resolveExamLaunchProvenWindow(targetLeaseKey) {
+ if (!this._examLaunchProvenWindowByTargetKey) return null;
+ const stored = this._examLaunchProvenWindowByTargetKey.get(targetLeaseKey);
+ const targetWindow = stored && typeof stored.deref === 'function'
+ ? stored.deref()
+ : stored;
+ if (!targetWindow) {
+ this._examLaunchProvenWindowByTargetKey.delete(targetLeaseKey);
+ return null;
+ }
+ try {
+ if (targetWindow.closed === true) {
+ this._examLaunchProvenWindowByTargetKey.delete(targetLeaseKey);
+ const targetKeys = this._examLaunchProvenTargetKeysByWindow
+ && this._examLaunchProvenTargetKeysByWindow.get(targetWindow);
+ if (targetKeys) targetKeys.delete(targetLeaseKey);
+ return null;
+ }
+ } catch (_) {
+ // A cross-origin WindowProxy may reject property access while alive.
+ }
+ return targetWindow;
+ },
+
+ _storeExamLaunchProvenWindow(targetLeaseKey, targetWindow) {
+ if (!this._examLaunchProvenWindowByTargetKey) {
+ this._examLaunchProvenWindowByTargetKey = new Map();
+ }
+ const stored = typeof WeakRef === 'function'
+ ? new WeakRef(targetWindow)
+ : targetWindow;
+ this._examLaunchProvenWindowByTargetKey.set(targetLeaseKey, stored);
+ return targetWindow;
+ },
+
+ _claimExamLaunchWindowOwnership(ownership, targetWindow, provenTargetLeaseKeys = []) {
+ if (!ownership || !targetWindow || (typeof targetWindow !== 'object' && typeof targetWindow !== 'function')) {
+ return false;
+ }
+ const rollbackState = this._examLaunchOwnershipRollbackStates
+ && this._examLaunchOwnershipRollbackStates.get(ownership);
+ if (!rollbackState
+ || (this._committedExamLaunchOwnerships
+ && this._committedExamLaunchOwnerships.has(ownership))) {
+ // A launch token is only a pre-navigation reservation. Once it has
+ // committed (or rolled back), callers must use the installed exact
+ // registration instead of resurrecting its released name/window slots.
+ return false;
+ }
+ try {
+ if (targetWindow.closed) {
+ return false;
+ }
+ } catch (_) {
+ return false;
+ }
+ if (!this._examLaunchWindowOwnerships) {
+ this._examLaunchWindowOwnerships = new WeakMap();
+ }
+ if (!this._examLaunchOwnershipTargetLeaseKeys) {
+ this._examLaunchOwnershipTargetLeaseKeys = new WeakMap();
+ }
+ if (!this._examLaunchProvenTargetKeysByWindow) {
+ this._examLaunchProvenTargetKeysByWindow = new WeakMap();
+ }
+ if (!this._examLaunchProvenWindowByTargetKey) {
+ this._examLaunchProvenWindowByTargetKey = new Map();
+ }
+ const current = this._examLaunchWindowOwnerships.get(targetWindow);
+ if (current && Number(current.sequence) > Number(ownership.sequence)) {
+ return false;
+ }
+ const effectiveTargetLeaseKeys = new Set(
+ this._examLaunchOwnershipTargetLeaseKeys.get(ownership)
+ || ownership.targetLeaseKeys
+ || []
+ );
+ const newlyProvenKeys = new Set(
+ (Array.isArray(provenTargetLeaseKeys) ? provenTargetLeaseKeys : [provenTargetLeaseKeys])
+ .map(key => String(key || '').trim())
+ .filter(Boolean)
+ .map(key => key.startsWith('window-name:') ? key : `window-name:${key}`)
+ .filter(key => !key.slice('window-name:'.length).startsWith('_'))
+ );
+ let targetNameWasReadable = false;
+ try {
+ const targetName = typeof targetWindow.name === 'string'
+ ? targetWindow.name.trim()
+ : '';
+ targetNameWasReadable = true;
+ if (targetName && !targetName.startsWith('_')) {
+ newlyProvenKeys.add(`window-name:${targetName}`);
+ }
+ } catch (_) {}
+ if (targetNameWasReadable) {
+ const priorTargetKeys = this._examLaunchProvenTargetKeysByWindow.get(targetWindow);
+ if (priorTargetKeys) {
+ for (const targetLeaseKey of Array.from(priorTargetKeys)) {
+ if (newlyProvenKeys.has(targetLeaseKey)) continue;
+ if (this._resolveExamLaunchProvenWindow(targetLeaseKey) === targetWindow) {
+ this._examLaunchProvenWindowByTargetKey.delete(targetLeaseKey);
+ }
+ priorTargetKeys.delete(targetLeaseKey);
+ }
+ }
+ }
+ for (const targetLeaseKey of newlyProvenKeys) {
+ const previousWindow = this._resolveExamLaunchProvenWindow(targetLeaseKey);
+ if (previousWindow && previousWindow !== targetWindow) {
+ const previousKeys = this._examLaunchProvenTargetKeysByWindow.get(previousWindow);
+ if (previousKeys) previousKeys.delete(targetLeaseKey);
+ }
+ this._storeExamLaunchProvenWindow(targetLeaseKey, targetWindow);
+ const targetKeys = this._examLaunchProvenTargetKeysByWindow.get(targetWindow) || new Set();
+ targetKeys.add(targetLeaseKey);
+ this._examLaunchProvenTargetKeysByWindow.set(targetWindow, targetKeys);
+ effectiveTargetLeaseKeys.add(targetLeaseKey);
+ }
+ if (current !== ownership && this._examLaunchTargetOwnerships) {
+ // Cross-origin/PDF WindowProxy objects may throw when reading .name.
+ // Transfer only names still proven to resolve to this proxy. A mere
+ // reservation for the same name is not browsing-context proof. The
+ // previous launch reservation may already be committed/released; the
+ // weak browsing-context proof intentionally survives that release.
+ const inheritedTargetLeaseKeys = this._examLaunchProvenTargetKeysByWindow.get(targetWindow)
+ || [];
+ for (const targetLeaseKey of inheritedTargetLeaseKeys) {
+ if (this._resolveExamLaunchProvenWindow(targetLeaseKey) === targetWindow) {
+ effectiveTargetLeaseKeys.add(targetLeaseKey);
+ }
+ }
+ }
+ this._examLaunchOwnershipTargetLeaseKeys.set(ownership, effectiveTargetLeaseKeys);
+ if (this._examLaunchTargetOwnerships) {
+ for (const targetLeaseKey of effectiveTargetLeaseKeys) {
+ const mappedOwnership = this._examLaunchTargetOwnerships.get(targetLeaseKey);
+ if (!mappedOwnership
+ || Number(mappedOwnership.sequence) < Number(ownership.sequence)) {
+ if (rollbackState) rollbackState.targetKeys.add(targetLeaseKey);
+ this._examLaunchTargetOwnerships.set(targetLeaseKey, ownership);
+ }
+ }
+ }
+ if (rollbackState) rollbackState.windows.add(targetWindow);
+ this._examLaunchWindowOwnerships.set(targetWindow, ownership);
+ return true;
+ },
+
+ _beginExamLaunchOwnership(examId, options = {}) {
+ if (!this._examLaunchOwnerships) {
+ this._examLaunchOwnerships = new Map();
+ }
+ if (!this._examLaunchTargetOwnerships) {
+ this._examLaunchTargetOwnerships = new Map();
+ }
+ if (!this._examLaunchOwnershipTargetLeaseKeys) {
+ this._examLaunchOwnershipTargetLeaseKeys = new WeakMap();
+ }
+ if (!this._examLaunchOwnershipRollbackStates) {
+ this._examLaunchOwnershipRollbackStates = new WeakMap();
+ }
+ if (!this._examLaunchOwnershipExplicitWindows) {
+ this._examLaunchOwnershipExplicitWindows = new WeakMap();
+ }
+ if (!this._committedExamLaunchOwnerships) {
+ this._committedExamLaunchOwnerships = new WeakSet();
+ }
+ this._examLaunchOwnershipSequence = Math.max(
+ 0,
+ Number(this._examLaunchOwnershipSequence) || 0
+ ) + 1;
+ const targetLeaseKeys = this._resolveExamLaunchTargetLeaseKeys(examId, options);
+ const explicitWindow = options && options.reuseWindow && !options.reuseWindow.closed
+ ? options.reuseWindow
+ : null;
+ const ownership = Object.freeze({
+ examId: String(examId || ''),
+ initialState: this._captureExamLaunchRegistrationState(examId),
+ sequence: this._examLaunchOwnershipSequence,
+ targetLeaseKeys
+ });
+ if (explicitWindow) {
+ // Keep the WindowProxy outside the immutable token so commit/rollback
+ // can release the final strong reference deterministically.
+ this._examLaunchOwnershipExplicitWindows.set(ownership, explicitWindow);
+ }
+ const normalizedExamId = String(examId || '');
+ const rollbackState = {
+ examKey: normalizedExamId,
+ targetKeys: new Set(targetLeaseKeys),
+ windows: new Set()
+ };
+ this._examLaunchOwnershipRollbackStates.set(ownership, rollbackState);
+ this._examLaunchOwnershipTargetLeaseKeys.set(ownership, new Set(targetLeaseKeys));
+ this._examLaunchOwnerships.set(normalizedExamId, ownership);
+ for (const targetLeaseKey of targetLeaseKeys) {
+ this._examLaunchTargetOwnerships.set(targetLeaseKey, ownership);
+ }
+ if (explicitWindow) {
+ this._claimExamLaunchWindowOwnership(ownership, explicitWindow);
+ }
+ return ownership;
+ },
+
+ _releaseExamLaunchOwnershipReservation(ownership) {
+ const rollbackState = ownership
+ && this._examLaunchOwnershipRollbackStates
+ && this._examLaunchOwnershipRollbackStates.get(ownership);
+ if (!rollbackState) {
+ return { found: false, released: false };
+ }
+ let released = false;
+ if (this._examLaunchOwnerships
+ && this._examLaunchOwnerships.get(rollbackState.examKey) === ownership) {
+ this._examLaunchOwnerships.delete(rollbackState.examKey);
+ released = true;
+ }
+ if (this._examLaunchTargetOwnerships) {
+ for (const targetLeaseKey of rollbackState.targetKeys) {
+ if (this._examLaunchTargetOwnerships.get(targetLeaseKey) !== ownership) {
+ continue;
+ }
+ this._examLaunchTargetOwnerships.delete(targetLeaseKey);
+ released = true;
+ }
+ }
+ if (this._examLaunchWindowOwnerships) {
+ for (const targetWindow of rollbackState.windows) {
+ if (this._examLaunchWindowOwnerships.get(targetWindow) !== ownership) {
+ continue;
+ }
+ this._examLaunchWindowOwnerships.delete(targetWindow);
+ released = true;
+ }
+ }
+ this._examLaunchOwnershipRollbackStates.delete(ownership);
+ if (this._examLaunchOwnershipTargetLeaseKeys) {
+ this._examLaunchOwnershipTargetLeaseKeys.delete(ownership);
+ }
+ if (this._examLaunchOwnershipExplicitWindows) {
+ this._examLaunchOwnershipExplicitWindows.delete(ownership);
+ }
+ return { found: true, released };
+ },
+
+ _rollbackExamLaunchOwnership(ownership) {
+ // Launch ownership is a reservation for an unfinished open continuation,
+ // not the authority of an already installed page. On failure, release
+ // only slots that still point at this reservation. Restoring predecessor
+ // tokens can resurrect an older continuation after an A -> B -> A race;
+ // installed pages remain authoritative through their exact registration.
+ return this._releaseExamLaunchOwnershipReservation(ownership).released;
+ },
+
+ _commitExamLaunchOwnership(ownership) {
+ const release = this._releaseExamLaunchOwnershipReservation(ownership);
+ if (!release.found) {
+ return false;
+ }
+ if (!this._committedExamLaunchOwnerships) {
+ this._committedExamLaunchOwnerships = new WeakSet();
+ }
+ this._committedExamLaunchOwnerships.add(ownership);
+ return true;
+ },
+
+ _isExamLaunchRegistrationStateCurrent(examId, state) {
+ if (!state) {
+ return false;
+ }
+ if (state.hasRegistration) {
+ // Closing the frozen predecessor may legitimately remove it while a
+ // newer launch is waiting on the index/library. Absence is neutral;
+ // any different replacement tuple still invalidates this launch.
+ if (!this.examWindows || !this.examWindows.has(examId)) {
+ const currentEpoch = Number(this._examRegistrationEpochs
+ && this._examRegistrationEpochs.get(String(examId || '')) || 0);
+ return currentEpoch === Number(state.registrationEpoch || 0);
+ }
+ return Boolean(state.registration)
+ && this._isExamSessionRegistrationCurrent(examId, state.registration);
+ }
+ return !(this.examWindows && this.examWindows.has(examId));
+ },
+
+ _isExamLaunchOwnershipCurrent(examId, ownership, registrationState = null, targetWindow = null) {
+ if (!ownership
+ || (this._committedExamLaunchOwnerships
+ && this._committedExamLaunchOwnerships.has(ownership))
+ || !this._examLaunchOwnerships
+ || this._examLaunchOwnerships.get(String(examId || '')) !== ownership) {
+ return false;
+ }
+ const effectiveTargetLeaseKeys = this._examLaunchOwnershipTargetLeaseKeys
+ && this._examLaunchOwnershipTargetLeaseKeys.get(ownership)
+ || ownership.targetLeaseKeys
+ || [];
+ for (const targetLeaseKey of effectiveTargetLeaseKeys) {
+ if (!this._examLaunchTargetOwnerships
+ || this._examLaunchTargetOwnerships.get(targetLeaseKey) !== ownership) {
+ return false;
+ }
+ }
+ const hasExplicitWindow = Boolean(
+ this._examLaunchOwnershipExplicitWindows
+ && this._examLaunchOwnershipExplicitWindows.has(ownership)
+ );
+ if (hasExplicitWindow) {
+ const explicitWindow = this._examLaunchOwnershipExplicitWindows.get(ownership);
+ if (!explicitWindow
+ || !this._examLaunchWindowOwnerships
+ || this._examLaunchWindowOwnerships.get(explicitWindow) !== ownership) {
+ return false;
+ }
+ }
+ const ownedWindow = targetWindow || null;
+ if (ownedWindow) {
+ try {
+ if (ownedWindow.closed) {
+ return false;
+ }
+ } catch (_) {
+ return false;
+ }
+ if (!this._examLaunchWindowOwnerships
+ || this._examLaunchWindowOwnerships.get(ownedWindow) !== ownership) {
+ return false;
+ }
+ }
+ return registrationState
+ ? this._isExamLaunchRegistrationStateCurrent(examId, registrationState)
+ : true;
+ },
+
+ _isOwnedExamLaunchRegistrationCurrent(examId, ownership, expectedRegistration) {
+ return this._isExamLaunchOwnershipCurrent(
+ examId,
+ ownership,
+ null,
+ expectedRegistration && expectedRegistration.window
+ )
+ && Boolean(expectedRegistration)
+ && this._isExamSessionRegistrationCurrent(examId, expectedRegistration);
+ },
+
+ _isOpenExamRegistrationCurrent(examId, expectedRegistration, targetWindow = null) {
+ const ownedWindow = targetWindow || (expectedRegistration && expectedRegistration.window) || null;
+ if (!ownedWindow
+ || !expectedRegistration
+ || expectedRegistration.window !== ownedWindow
+ || !this._isExamSessionRegistrationCurrent(examId, expectedRegistration)) {
+ return false;
+ }
+ try {
+ return ownedWindow.closed !== true;
+ } catch (_) {
+ return false;
+ }
+ },
+
+ _recordExamLaunchRegistrationReceipt(examId, launchOwnership, registration) {
+ if (!launchOwnership
+ || (typeof launchOwnership !== 'object' && typeof launchOwnership !== 'function')) {
+ return false;
+ }
+ if (!this._examLaunchRegistrationReceipts) {
+ this._examLaunchRegistrationReceipts = new WeakMap();
+ }
+ // A receipt identifies the exact result of this open continuation; it
+ // must never fall back to whichever tuple later occupies examWindows.
+ this._examLaunchRegistrationReceipts.delete(launchOwnership);
+ const normalizedExamId = String(examId || '').trim();
+ const targetWindow = registration && registration.window || null;
+ if (!normalizedExamId
+ || String(launchOwnership.examId || '').trim() !== normalizedExamId
+ || !registration
+ || !this._isOpenExamRegistrationCurrent(
+ normalizedExamId,
+ registration,
+ targetWindow
+ )) {
+ return false;
+ }
+ this._examLaunchRegistrationReceipts.set(launchOwnership, Object.freeze({
+ examId: normalizedExamId,
+ window: targetWindow,
+ registration
+ }));
+ return true;
+ },
+
+ _captureExamLaunchRegistrationReceipt(examId, launchOwnership, targetWindow = null) {
+ if (!launchOwnership
+ || !this._examLaunchRegistrationReceipts
+ || (typeof launchOwnership !== 'object' && typeof launchOwnership !== 'function')) {
+ return null;
+ }
+ const receipt = this._examLaunchRegistrationReceipts.get(launchOwnership);
+ const normalizedExamId = String(examId || '').trim();
+ const expectedWindow = targetWindow || (receipt && receipt.window) || null;
+ if (!receipt
+ || !normalizedExamId
+ || String(launchOwnership.examId || '').trim() !== normalizedExamId
+ || receipt.examId !== normalizedExamId
+ || !expectedWindow
+ || receipt.window !== expectedWindow
+ || !this._isOpenExamRegistrationCurrent(
+ normalizedExamId,
+ receipt.registration,
+ expectedWindow
+ )) {
+ return null;
+ }
+ return receipt.registration;
+ },
+
+ async _abortOwnedExamLaunch(examId, targetWindow, launchOwnership, expectedRegistration) {
+ if (!targetWindow
+ || !expectedRegistration
+ || expectedRegistration.window !== targetWindow
+ || !this._isExamSessionRegistrationCurrent(examId, expectedRegistration)) {
+ return false;
+ }
+ const expectedNavigationOwnership = expectedRegistration.navigationOwnership
+ || (expectedRegistration.windowInfo && expectedRegistration.windowInfo.navigationOwnership)
+ || null;
+ // Once navigation has installed an exact provisional registration, that
+ // tuple owns rollback. A newer pre-navigation reservation must not strand
+ // this page in handshakeDeferred, nor prevent exact cleanup on failure.
+ await this.cleanupExamSession(examId, {
+ expectedRegistration,
+ recoverySessionId: expectedRegistration.expectedSessionId
+ });
+ const targetWasReassigned = Boolean(this.examWindows && Array.from(this.examWindows.values())
+ .some(info => info && info.window === targetWindow));
+ if (!targetWasReassigned
+ && this._isExamWindowNavigationCurrent(targetWindow, expectedNavigationOwnership)
+ && targetWindow !== window
+ && (() => {
+ try { return targetWindow.closed !== true; } catch (_) { return false; }
+ })()) {
+ try {
+ if (typeof this._releaseSuiteWindowGuard === 'function'
+ && expectedRegistration.suiteSessionId) {
+ this._releaseSuiteWindowGuard(targetWindow, expectedRegistration.suiteSessionId);
+ }
+ } catch (_) {}
+ try {
+ if (typeof targetWindow.close === 'function') {
+ targetWindow.close();
+ }
+ } catch (_) {}
+ }
+ window.showMessage && window.showMessage('练习会话启动失败,请重试。', 'error');
+ return true;
+ },
+
/**
* 打开指定题目进行练习
*/
async openExam(examId, options = {}) {
- const examIndex = await getActiveExamIndexSnapshot();
- const list = Array.isArray(examIndex) ? examIndex : (Array.isArray(window.examIndex) ? window.examIndex : []);
- const exam = list.find(e => e.id === examId);
+ const hasSuppliedLaunchOwnership = Boolean(
+ options
+ && Object.prototype.hasOwnProperty.call(options, 'launchOwnership')
+ );
+ const suppliedLaunchOwnership = hasSuppliedLaunchOwnership
+ ? options.launchOwnership
+ : null;
+ if (hasSuppliedLaunchOwnership) {
+ const suppliedTargetKeys = new Set(
+ suppliedLaunchOwnership && this._examLaunchOwnershipTargetLeaseKeys
+ && this._examLaunchOwnershipTargetLeaseKeys.get(suppliedLaunchOwnership)
+ || suppliedLaunchOwnership && suppliedLaunchOwnership.targetLeaseKeys
+ || []
+ );
+ const requestedTargetKeys = this._resolveExamLaunchTargetLeaseKeys(examId, options);
+ const expandsOwnership = requestedTargetKeys.some(key => !suppliedTargetKeys.has(key));
+ if (expandsOwnership || !this._isExamLaunchOwnershipCurrent(
+ examId,
+ suppliedLaunchOwnership,
+ null,
+ options && options.reuseWindow || null
+ )) {
+ return null;
+ }
+ }
+ const launchOwnership = hasSuppliedLaunchOwnership
+ ? suppliedLaunchOwnership
+ : this._beginExamLaunchOwnership(examId, options);
+ const rollbackUncommittedLaunch = () => !hasSuppliedLaunchOwnership
+ && this._rollbackExamLaunchOwnership(launchOwnership);
+ const initialLaunchState = launchOwnership.initialState;
const reviewMode = Boolean(options && options.reviewMode);
+ let exam = options && options.examDefinition && typeof options.examDefinition === 'object'
+ ? options.examDefinition
+ : null;
+ if (!exam) {
+ if (options && options.requireRecordProvenance) {
+ rollbackUncommittedLaunch();
+ throw new Error('历史记录的题库来源不可用');
+ }
+ let examIndex;
+ try {
+ examIndex = await getActiveExamIndexSnapshot();
+ } catch (indexError) {
+ rollbackUncommittedLaunch();
+ throw indexError;
+ }
+ if (!this._isExamLaunchOwnershipCurrent(examId, launchOwnership, initialLaunchState)) {
+ rollbackUncommittedLaunch();
+ return null;
+ }
+ const list = Array.isArray(examIndex) ? examIndex : [];
+ exam = list.find(e => e.id === examId);
+ }
const practiceMode = options && typeof options.practiceMode === 'string'
? options.practiceMode.trim().toLowerCase()
: '';
const memorizeMode = practiceMode === 'memorize';
+ if (!this._isExamLaunchOwnershipCurrent(examId, launchOwnership, initialLaunchState)) {
+ rollbackUncommittedLaunch();
+ return null;
+ }
+
if (!exam) {
window.showMessage('题目不存在', 'error');
+ rollbackUncommittedLaunch();
return;
}
@@ -388,7 +903,10 @@
: null;
if (readingLaunch && readingLaunch.mode === 'pdf_manual' && readingLaunch.pdfUrl) {
- return this._openPdfWindow(exam, readingLaunch.pdfUrl, options);
+ return this._openPdfWindow(exam, readingLaunch.pdfUrl, {
+ ...options,
+ launchOwnership
+ });
}
// 若无HTML,直接打开PDF
@@ -397,10 +915,13 @@
? window.buildResourcePath(exam, 'pdf')
: ((exam.path || '').replace(/\\/g, '/').replace(/\/+\//g, '/') + (exam.pdfFilename || ''));
const resolvedPdfUrl = this._ensureAbsoluteUrl(pdfUrl);
- return this._openPdfWindow(exam, resolvedPdfUrl, options);
+ return this._openPdfWindow(exam, resolvedPdfUrl, {
+ ...options,
+ launchOwnership
+ });
}
- const guardOptions = { ...options, examId };
+ const guardOptions = { ...options, examId, launchOwnership };
// 测试环境的套题练习统一使用占位页,避免因题目资源差异导致 E2E 不稳定
let examUrl = (readingLaunch && readingLaunch.mode === 'unified_html' && readingLaunch.url)
? readingLaunch.url
@@ -414,29 +935,227 @@
if (guardOptions.suiteSessionId && readingLaunch && readingLaunch.mode === 'unified_html') {
examUrl = this._appendSuiteContextToExamUrl(examUrl, guardOptions);
}
+ if (guardOptions.endlessMode) {
+ examUrl = this._appendEndlessContextToExamUrl(examUrl);
+ }
let examWindow = this.openExamWindow(examUrl, exam, guardOptions);
+ if (!examWindow
+ || !this._claimExamLaunchWindowOwnership(launchOwnership, examWindow)
+ || !this._isExamLaunchOwnershipCurrent(
+ examId,
+ launchOwnership,
+ null,
+ examWindow
+ )) {
+ return null;
+ }
+ if (!guardOptions.navigationOwnership) {
+ guardOptions.navigationOwnership = this._recordExamWindowNavigation(examWindow, examId);
+ }
+ let navigationRegistration = guardOptions.navigationRegistration;
+ if (!navigationRegistration || navigationRegistration.window !== examWindow) {
+ navigationRegistration = this._installExamNavigationProvisionalRegistration(
+ examId,
+ examWindow,
+ { ...guardOptions, expectedUrl: this._ensureAbsoluteUrl(examUrl) }
+ );
+ }
+ if (!this._isOpenExamRegistrationCurrent(examId, navigationRegistration, examWindow)) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ navigationRegistration
+ );
+ return null;
+ }
+ this._commitExamLaunchOwnership(launchOwnership);
try {
const guardedWindow = this._guardExamWindowContent(examWindow, exam, guardOptions);
if (guardedWindow) {
- examWindow = guardedWindow;
+ if (guardedWindow !== examWindow) {
+ examWindow = guardedWindow;
+ const marked = Number(this._markExamWindowReusePending(examWindow, { examId })) || 0;
+ if (marked > 0) guardOptions.windowReuseDetected = true;
+ }
+ navigationRegistration = this._installExamNavigationProvisionalRegistration(
+ examId,
+ examWindow,
+ { ...guardOptions, expectedUrl: this._ensureAbsoluteUrl(examUrl) }
+ );
}
} catch (guardError) {
console.warn('[App] 题目窗口占位页守护失败:', guardError);
}
- if (guardOptions.reuseWindow && examWindow && !examWindow.closed && typeof this._cleanupReusedWindowSessions === 'function') {
+ if (!this._isOpenExamRegistrationCurrent(examId, navigationRegistration, examWindow)) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ navigationRegistration
+ );
+ return null;
+ }
+ if (guardOptions.windowReuseDetected === true
+ && examWindow
+ && !examWindow.closed
+ && typeof this._cleanupReusedWindowSessions === 'function') {
await this._cleanupReusedWindowSessions(examWindow, examId);
+ if (!this._isOpenExamRegistrationCurrent(
+ examId,
+ navigationRegistration,
+ examWindow
+ )) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ navigationRegistration
+ );
+ return null;
+ }
}
- // 再进行会话记录与脚本注入
- if (!reviewMode && !memorizeMode) {
- await this.startPracticeSession(examId);
+ // 在启动窗口前捕获激活的题库配置 ID,确保后续练习记录 metadata 来源
+ // 一律按"启动时"的题库写入,避免用户在考试过程中切换题库导致提交时来源不一致。
+ if (!reviewMode) {
+ try {
+ await this._captureLaunchLibraryConfigurationId(examId, {
+ commitGuard: () => this._isOpenExamRegistrationCurrent(
+ examId,
+ navigationRegistration,
+ examWindow
+ )
+ });
+ } catch (captureError) {
+ console.warn('[App] 捕获启动题库配置 ID 失败:', captureError);
+ }
+ if (!this._isOpenExamRegistrationCurrent(
+ examId,
+ navigationRegistration,
+ examWindow
+ )) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ navigationRegistration
+ );
+ return null;
+ }
}
- this.injectDataCollectionScript(examWindow, examId, exam);
- this.setupExamWindowManagement(examWindow, examId, exam, options);
+ // Register the window first so the host expectedSessionId exists, then start the
+ // recorder with that same id. Starting the recorder before window setup used
+ // to mint a second session id that never matched INIT/COMPLETE.
+ const suiteBindingCheckpoint = typeof guardOptions.beforeSuiteHandshake === 'function'
+ ? guardOptions.beforeSuiteHandshake
+ : (guardOptions.suiteSessionId && typeof this._commitSuiteWindowBindingBeforeHandshake === 'function'
+ ? (context) => this._commitSuiteWindowBindingBeforeHandshake(
+ guardOptions.suiteSessionId,
+ context.examId,
+ context.examWindow,
+ context.windowInfo,
+ { commitGuard: context.commitGuard }
+ )
+ : null);
+ const deferSuiteHandshake = Boolean(guardOptions.suiteSessionId && suiteBindingCheckpoint);
+ const deferPracticeHandshake = !reviewMode && !memorizeMode;
+ const deferLaunchHandshake = deferSuiteHandshake || deferPracticeHandshake;
+ if (!this._isOpenExamRegistrationCurrent(
+ examId,
+ navigationRegistration,
+ examWindow
+ )) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ navigationRegistration
+ );
+ return null;
+ }
+ const setupRegistration = this.setupExamWindowManagement(examWindow, examId, exam, {
+ ...guardOptions,
+ expectedRegistration: navigationRegistration,
+ launchOwnership: null,
+ skipContentGuard: true,
+ deferInitialHandshake: deferLaunchHandshake,
+ expectedUrl: this._ensureAbsoluteUrl(examUrl)
+ });
+ if (!this._isOpenExamRegistrationCurrent(examId, setupRegistration, examWindow)) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ navigationRegistration
+ );
+ return null;
+ }
+ const registeredInfo = setupRegistration.windowInfo;
+ let launchRegistration = setupRegistration;
+ if (deferLaunchHandshake) {
+ this._buildExamInitPayload(examId, registeredInfo);
+ this.examWindows && this.examWindows.set(examId, registeredInfo);
+ launchRegistration = this._captureExamSessionRegistration(examId, registeredInfo);
+ if (!this._isOpenExamRegistrationCurrent(examId, launchRegistration, examWindow)) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ launchRegistration
+ );
+ return null;
+ }
+ }
+ if (!reviewMode && !memorizeMode) {
+ let startResult;
+ try {
+ startResult = await this.startPracticeSession(examId, {
+ examDefinition: exam,
+ expectedRegistration: launchRegistration
+ });
+ } catch (startError) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ launchRegistration
+ );
+ throw startError;
+ }
+ if (!this._isPracticeSessionOwnedSuccess(startResult)
+ || !this._isOpenExamRegistrationCurrent(
+ examId,
+ startResult.registration,
+ examWindow
+ )) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ launchRegistration
+ );
+ return null;
+ }
+ launchRegistration = startResult.registration;
+ if (!deferSuiteHandshake
+ && launchRegistration.windowInfo.handshakeDeferred === true) {
+ launchRegistration.windowInfo.handshakeDeferred = false;
+ this.restartExamHandshake(examWindow, examId, {
+ expectedRegistration: launchRegistration
+ });
+ }
+ }
+ if (!this._isOpenExamRegistrationCurrent(examId, launchRegistration, examWindow)) {
+ return null;
+ }
if (options && options.suiteSessionId) {
- const sessionInfo = this.ensureExamWindowSession(examId, examWindow);
+ if (!this._isOpenExamRegistrationCurrent(examId, launchRegistration, examWindow)) {
+ return null;
+ }
+ const sessionInfo = launchRegistration.windowInfo;
sessionInfo.suiteSessionId = options.suiteSessionId;
if (options.suiteFlowMode) {
sessionInfo.suiteFlowMode = options.suiteFlowMode;
@@ -465,8 +1184,74 @@
sessionInfo.suiteSequenceTotal = options.sequenceTotal;
}
this.examWindows && this.examWindows.set(examId, sessionInfo);
+ launchRegistration = this._captureExamSessionRegistration(examId, sessionInfo);
+ if (!this._isOpenExamRegistrationCurrent(examId, launchRegistration, examWindow)) {
+ return null;
+ }
+ if (deferSuiteHandshake) {
+ const checkpointRegistration = launchRegistration;
+ const checkpointCommitGuard = () => this._isOpenExamRegistrationCurrent(
+ examId,
+ checkpointRegistration,
+ examWindow
+ );
+ let checkpointCommitted;
+ try {
+ checkpointCommitted = await suiteBindingCheckpoint({
+ examId,
+ examWindow,
+ windowInfo: sessionInfo,
+ expectedRegistration: checkpointRegistration,
+ launchOwnership: null,
+ commitGuard: checkpointCommitGuard
+ });
+ } catch (checkpointError) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ checkpointRegistration
+ );
+ throw checkpointError;
+ }
+ if (!this._isOpenExamRegistrationCurrent(
+ examId,
+ checkpointRegistration,
+ examWindow
+ )) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ checkpointRegistration
+ );
+ return null;
+ }
+ if (checkpointCommitted !== true) {
+ const checkpointError = new Error('Suite window binding was not durably committed before INIT');
+ checkpointError.code = 'RECOVERY_COMMIT_NOT_CONFIRMED';
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ checkpointRegistration
+ );
+ throw checkpointError;
+ }
+ sessionInfo.handshakeDeferred = false;
+ this.restartExamHandshake(examWindow, examId, {
+ expectedRegistration: checkpointRegistration
+ });
+ launchRegistration = this._captureExamSessionRegistration(examId, sessionInfo);
+ }
}
+ if (!this._isOpenExamRegistrationCurrent(examId, launchRegistration, examWindow)) {
+ return null;
+ }
+ this.injectDataCollectionScript(examWindow, examId, exam, {
+ expectedRegistration: launchRegistration
+ });
if (reviewMode && typeof this._bindReviewWindowRef === 'function') {
this._bindReviewWindowRef(options.reviewSessionId, examWindow);
}
@@ -476,21 +1261,63 @@
'info'
);
+ if (hasSuppliedLaunchOwnership
+ && !this._recordExamLaunchRegistrationReceipt(
+ examId,
+ launchOwnership,
+ launchRegistration
+ )) {
+ return null;
+ }
+
return examWindow;
} catch (error) {
console.error('Failed to open exam:', error);
window.showMessage('打开题目失败,请重试', 'error');
+ return null;
}
},
_openPdfWindow(exam, resolvedPdfUrl, options = {}) {
let pdfWin = null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const claimWindow = (candidateWindow, targetName = '') => !launchOwnership
+ || this._claimExamLaunchWindowOwnership(launchOwnership, candidateWindow, targetName);
+ const recordNavigation = (candidateWindow) => {
+ const navigationOwnership = this._recordExamWindowNavigation(
+ candidateWindow,
+ exam && exam.id
+ );
+ if (navigationOwnership) options.navigationOwnership = navigationOwnership;
+ return navigationOwnership;
+ };
if (options.reuseWindow && !options.reuseWindow.closed) {
try {
+ if (launchOwnership && !this._isExamLaunchOwnershipCurrent(
+ exam && exam.id,
+ launchOwnership,
+ null,
+ options.reuseWindow
+ )) {
+ return null;
+ }
options.reuseWindow.location.href = resolvedPdfUrl;
- options.reuseWindow.focus();
+ recordNavigation(options.reuseWindow);
+ // Direct assignment proves only the reused WindowProxy (and the
+ // actual readable .name). options.windowName was never resolved
+ // by window.open(), so it must not become browsing-context proof.
+ if (!claimWindow(options.reuseWindow)) {
+ return null;
+ }
+ this._markExamWindowReusePending(options.reuseWindow, {
+ unmanaged: true,
+ examId: exam && exam.id
+ });
+ try {
+ if (typeof options.reuseWindow.focus === 'function') options.reuseWindow.focus();
+ } catch (_) {}
pdfWin = options.reuseWindow;
} catch (reuseError) {
console.warn('[App] 无法复用已打开的标签,尝试重新打开:', reuseError);
@@ -501,23 +1328,57 @@
if (options.target === 'tab') {
try {
pdfWin = window.open(resolvedPdfUrl, '_blank');
+ recordNavigation(pdfWin);
+ if (!claimWindow(pdfWin)) {
+ if (pdfWin && launchOwnership) {
+ return null;
+ }
+ pdfWin = null;
+ } else {
+ this._markExamWindowReusePending(pdfWin, {
+ unmanaged: true,
+ examId: exam && exam.id
+ });
+ }
} catch (_) { }
} else {
try {
pdfWin = window.open(resolvedPdfUrl, `pdf_${exam.id}`, 'width=1000,height=800,scrollbars=yes,resizable=yes,status=yes,toolbar=yes');
+ recordNavigation(pdfWin);
+ if (!claimWindow(pdfWin, `pdf_${exam.id}`)) {
+ if (pdfWin && launchOwnership) {
+ return null;
+ }
+ pdfWin = null;
+ } else {
+ this._markExamWindowReusePending(pdfWin, {
+ unmanaged: true,
+ examId: exam && exam.id
+ });
+ }
} catch (_) { }
}
}
if (!pdfWin) {
try {
+ if (!claimWindow(window)) {
+ return null;
+ }
window.location.href = resolvedPdfUrl;
+ recordNavigation(window);
+ this._markExamWindowReusePending(window, {
+ unmanaged: true,
+ examId: exam && exam.id
+ });
+ this._commitExamLaunchOwnership(launchOwnership);
return window;
} catch (error) {
throw new Error('无法打开PDF窗口,请检查弹窗设置');
}
}
+ this._commitExamLaunchOwnership(launchOwnership);
window.showMessage(`正在打开PDF: ${exam.title}`, 'info');
return pdfWin;
},
@@ -557,13 +1418,310 @@
/**
* 在新窗口中打开题目
*/
+ _buildExamWindowRegistrationMarker(examId, registration) {
+ const info = registration || {};
+ const numericGeneration = Number(info.sessionGeneration);
+ return JSON.stringify([
+ String(examId || ''),
+ String(info.suiteSessionId || ''),
+ String(info.expectedSessionId || ''),
+ String(info.windowSessionToken || ''),
+ Number.isInteger(numericGeneration) ? numericGeneration : null
+ ]);
+ },
+
+ _rememberExamWindowReassignment(targetWindow, examId, registration) {
+ if (!targetWindow || !registration) return false;
+ if (!this._reassignedExamWindowRegistrations) {
+ this._reassignedExamWindowRegistrations = new WeakMap();
+ }
+ let markers = this._reassignedExamWindowRegistrations.get(targetWindow);
+ if (!markers) {
+ markers = new Set();
+ this._reassignedExamWindowRegistrations.set(targetWindow, markers);
+ }
+ markers.add(this._buildExamWindowRegistrationMarker(examId, registration));
+ return true;
+ },
+
+ _installExamNavigationProvisionalRegistration(examId, targetWindow, options = {}) {
+ const normalizedExamId = String(examId || '').trim();
+ if (!normalizedExamId || !targetWindow) return null;
+ try {
+ if (targetWindow.closed) return null;
+ } catch (_) {
+ return null;
+ }
+ if (!this.examWindows) this.examWindows = new Map();
+ const current = this.examWindows.get(normalizedExamId) || null;
+ if (current && current.window === targetWindow && current.launchProvisional === true) {
+ current.navigationOwnership = this._examWindowCommittedNavigationOwners
+ && this._examWindowCommittedNavigationOwners.get(targetWindow)
+ || current.navigationOwnership
+ || null;
+ return this._captureExamSessionRegistration(normalizedExamId, current);
+ }
+
+ const numericGeneration = Number(current && current.sessionGeneration);
+ const nextGeneration = Number.isSafeInteger(numericGeneration)
+ && numericGeneration >= 0
+ && numericGeneration < Number.MAX_SAFE_INTEGER
+ ? numericGeneration + 1
+ : 1;
+ let expectedSessionId = current && current.window === targetWindow
+ ? String(current.expectedSessionId || '').trim()
+ : '';
+ if (!expectedSessionId) {
+ expectedSessionId = String(this.generateSessionId(normalizedExamId) || '').trim();
+ }
+ const endpoint = this._resolveExamMessageEndpoint(options && options.expectedUrl || '');
+ const provisional = current && current.window === targetWindow
+ ? current
+ : {
+ window: targetWindow,
+ startTime: Date.now(),
+ status: 'reassigning',
+ expectedSessionId,
+ windowSessionToken: null,
+ windowSessionTokenSessionId: null,
+ sessionGeneration: nextGeneration,
+ closeMonitor: null
+ };
+ provisional.window = targetWindow;
+ provisional.status = 'reassigning';
+ provisional.handshakeDeferred = true;
+ provisional.windowReusePending = true;
+ provisional.launchProvisional = true;
+ provisional.navigationOwnership = this._examWindowCommittedNavigationOwners
+ && this._examWindowCommittedNavigationOwners.get(targetWindow)
+ || null;
+ provisional.suiteSessionId = options && options.suiteSessionId
+ ? String(options.suiteSessionId)
+ : null;
+ provisional.expectedUrl = endpoint.expectedUrl;
+ provisional.expectedOrigin = endpoint.expectedOrigin;
+ provisional.allowOpaqueOrigin = endpoint.allowOpaqueOrigin;
+ provisional.observedOrigin = '';
+ provisional.expectedSessionId = expectedSessionId;
+ if (!provisional.windowSessionToken
+ || String(provisional.windowSessionTokenSessionId || '') !== expectedSessionId) {
+ provisional.windowSessionToken = this.generateWindowSessionToken(normalizedExamId);
+ provisional.windowSessionTokenSessionId = expectedSessionId;
+ }
+ if (!Number.isInteger(provisional.sessionGeneration)) {
+ provisional.sessionGeneration = nextGeneration;
+ }
+ if (!this._examRegistrationEpochs) this._examRegistrationEpochs = new Map();
+ if (!current || current !== provisional) {
+ this._examRegistrationEpochs.set(
+ normalizedExamId,
+ Number(this._examRegistrationEpochs.get(normalizedExamId) || 0) + 1
+ );
+ }
+ this.examWindows.set(normalizedExamId, provisional);
+ return this._captureExamSessionRegistration(normalizedExamId, provisional);
+ },
+
+ _markExamWindowReusePending(targetWindow, options = {}) {
+ if (!targetWindow || !this.examWindows) {
+ return 0;
+ }
+ const unmanagedTarget = options && options.unmanaged === true;
+ const launchExamId = options && options.examId != null
+ ? String(options.examId).trim()
+ : '';
+ let marked = 0;
+ for (const [candidateExamId, current] of Array.from(this.examWindows.entries())) {
+ const normalizedCandidateExamId = String(candidateExamId || '').trim();
+ const reusesSameWindow = Boolean(current && current.window === targetWindow);
+ const supersedesSameExam = Boolean(
+ current
+ && launchExamId
+ && normalizedCandidateExamId === launchExamId
+ );
+ if (!reusesSameWindow && !supersedesSameExam) {
+ continue;
+ }
+ const previousSessionId = String(current.expectedSessionId || '').trim();
+ const previousSuiteSessionId = String(current.suiteSessionId || '').trim();
+ if (previousSuiteSessionId && reusesSameWindow) {
+ this._rememberExamWindowReassignment(targetWindow, candidateExamId, current);
+ }
+ const numericGeneration = Number(current.sessionGeneration);
+ const nextGeneration = Number.isSafeInteger(numericGeneration)
+ && numericGeneration >= 0
+ && numericGeneration < Number.MAX_SAFE_INTEGER
+ ? numericGeneration + 1
+ : 1;
+ let pendingSessionId = '';
+ let pendingToken = null;
+ try {
+ pendingSessionId = typeof this.generateSessionId === 'function'
+ ? String(this.generateSessionId(candidateExamId) || '')
+ : '';
+ pendingToken = typeof this.generateWindowSessionToken === 'function'
+ ? this.generateWindowSessionToken(candidateExamId)
+ : null;
+ } catch (_) {}
+ if (!pendingSessionId) {
+ pendingSessionId = `reuse-pending:${String(candidateExamId)}:${Date.now()}:${nextGeneration}`;
+ }
+ const activeSuite = this.currentSuiteSession;
+ const activeSuiteBinding = activeSuite
+ && activeSuite.windowBinding
+ && typeof activeSuite.windowBinding === 'object'
+ ? activeSuite.windowBinding
+ : null;
+ const recoveryOwnedBySuiteTeardown = Boolean(
+ previousSuiteSessionId
+ && activeSuite
+ && String(activeSuite.id || '') === previousSuiteSessionId
+ && String(activeSuiteBinding && activeSuiteBinding.examId || '') === String(candidateExamId)
+ && String(activeSuiteBinding && activeSuiteBinding.expectedSessionId || '') === previousSessionId
+ && String(activeSuiteBinding && activeSuiteBinding.windowSessionToken || '') === String(current.windowSessionToken || '')
+ && Number(activeSuiteBinding && activeSuiteBinding.sessionGeneration) === Number(current.sessionGeneration)
+ );
+ // Replace (rather than mutate) the registration synchronously after
+ // navigation and before openExam's first await. Delayed suite teardown
+ // and queued draft writes can no longer mistake the reused WindowProxy
+ // for the old suite attempt during that gap.
+ const pendingInfo = {
+ ...current,
+ window: targetWindow,
+ status: 'reassigning',
+ navigationOwnership: this._examWindowCommittedNavigationOwners
+ && this._examWindowCommittedNavigationOwners.get(targetWindow)
+ || null,
+ suiteSessionId: null,
+ expectedSessionId: pendingSessionId,
+ sessionId: null,
+ sessionGeneration: nextGeneration,
+ windowSessionToken: pendingToken,
+ windowSessionTokenSessionId: pendingToken ? pendingSessionId : null,
+ handshakeDeferred: true,
+ windowReusePending: true,
+ reassignedFromExpectedSessionId: previousSessionId || null,
+ reassignedFromSuiteTeardownOwner: recoveryOwnedBySuiteTeardown,
+ closeMonitor: null
+ };
+
+ // The listener and retry timer belong to the document that was just
+ // navigated away. Leaving either alive would let the replacement page
+ // complete a provisional handshake before openExam installs its final
+ // registration.
+ if (this.messageHandlers && this.messageHandlers.has(candidateExamId)) {
+ const previousHandler = this.messageHandlers.get(candidateExamId);
+ try {
+ if (previousHandler) window.removeEventListener('message', previousHandler);
+ } catch (_) {}
+ this.messageHandlers.delete(candidateExamId);
+ }
+ if (this._handshakeTimers && this._handshakeTimers.has(candidateExamId)) {
+ try { clearInterval(this._handshakeTimers.get(candidateExamId)); } catch (_) {}
+ this._handshakeTimers.delete(candidateExamId);
+ }
+ if (current.closeMonitor) {
+ try { clearInterval(current.closeMonitor); } catch (_) {}
+ }
+
+ if (unmanagedTarget) {
+ // A raw PDF has no enhanced-page handshake and therefore no later
+ // setupExamWindowManagement call to replace this provisional entry.
+ // Remove it now, while retaining the old suite recovery for the
+ // already-frozen delayed teardown.
+ this.examWindows.delete(candidateExamId);
+ const suite = this.currentSuiteSession;
+ const binding = suite && suite.windowBinding && typeof suite.windowBinding === 'object'
+ ? suite.windowBinding
+ : null;
+ const isExactSuiteOwner = Boolean(
+ previousSuiteSessionId
+ && suite
+ && String(suite.id || '') === previousSuiteSessionId
+ && String(binding && binding.examId || '') === String(candidateExamId)
+ && String(binding && binding.expectedSessionId || '') === previousSessionId
+ && String(binding && binding.windowSessionToken || '') === String(current.windowSessionToken || '')
+ && Number(binding && binding.sessionGeneration) === Number(current.sessionGeneration)
+ );
+ if (!isExactSuiteOwner
+ && previousSessionId
+ && typeof this._discardActiveSessionsForExam === 'function') {
+ const recoveryCleanupGuard = () => {
+ const replacement = this.examWindows && this.examWindows.get(candidateExamId);
+ return !replacement
+ || String(replacement.expectedSessionId || '').trim() !== previousSessionId;
+ };
+ Promise.resolve(this._discardActiveSessionsForExam(candidateExamId, {
+ expectedSessionId: previousSessionId,
+ commitGuard: recoveryCleanupGuard
+ })).catch((error) => {
+ console.warn('[App] 清理 PDF 复用窗口旧恢复会话失败:', candidateExamId, error);
+ });
+ }
+ } else {
+ if (!this._examRegistrationEpochs) this._examRegistrationEpochs = new Map();
+ const registrationEpochKey = String(candidateExamId || '');
+ this._examRegistrationEpochs.set(
+ registrationEpochKey,
+ Number(this._examRegistrationEpochs.get(registrationEpochKey) || 0) + 1
+ );
+ this.examWindows.set(candidateExamId, pendingInfo);
+ }
+ marked += 1;
+ }
+ return marked;
+ },
+
openExamWindow(examUrl, exam, options = {}) {
const reuseWindow = options.reuseWindow;
const finalUrl = this._ensureAbsoluteUrl(examUrl);
+ const launchOwnership = options && options.launchOwnership || null;
+ const claimWindow = (candidateWindow, targetName = '') => !launchOwnership
+ || this._claimExamLaunchWindowOwnership(launchOwnership, candidateWindow, targetName);
+ const recordNavigation = (candidateWindow) => {
+ const navigationOwnership = this._recordExamWindowNavigation(
+ candidateWindow,
+ options.examId
+ );
+ if (navigationOwnership) options.navigationOwnership = navigationOwnership;
+ return navigationOwnership;
+ };
+ const markWindowReuse = (candidateWindow) => {
+ const marked = Number(this._markExamWindowReusePending(candidateWindow, {
+ examId: options.examId
+ })) || 0;
+ const navigationRegistration = this._installExamNavigationProvisionalRegistration(
+ options.examId,
+ candidateWindow,
+ { ...options, expectedUrl: finalUrl }
+ );
+ if (navigationRegistration) {
+ options.navigationRegistration = navigationRegistration;
+ }
+ if (marked > 0) {
+ options.windowReuseDetected = true;
+ }
+ return marked;
+ };
if (reuseWindow && !reuseWindow.closed) {
try {
+ if (launchOwnership && !this._isExamLaunchOwnershipCurrent(
+ options.examId,
+ launchOwnership,
+ null,
+ reuseWindow
+ )) {
+ return null;
+ }
reuseWindow.location.href = finalUrl;
- reuseWindow.focus();
+ recordNavigation(reuseWindow);
+ if (!claimWindow(reuseWindow)) {
+ return null;
+ }
+ markWindowReuse(reuseWindow);
+ try {
+ if (typeof reuseWindow.focus === 'function') reuseWindow.focus();
+ } catch (_) {}
return reuseWindow;
} catch (error) {
console.warn('[App] 复用窗口失败,尝试重新打开:', error);
@@ -577,6 +1735,15 @@
: '_blank';
try {
tabWindow = window.open(finalUrl, requestedName);
+ recordNavigation(tabWindow);
+ if (!claimWindow(tabWindow, requestedName)) {
+ if (tabWindow && launchOwnership) {
+ return null;
+ }
+ tabWindow = null;
+ } else {
+ markWindowReuse(tabWindow);
+ }
if (tabWindow && typeof tabWindow.focus === 'function') {
tabWindow.focus();
}
@@ -598,12 +1765,26 @@
`exam_${exam.id}`,
windowFeatures
);
+ recordNavigation(examWindow);
+ if (!claimWindow(examWindow, `exam_${exam.id}`)) {
+ if (examWindow && launchOwnership) {
+ return null;
+ }
+ examWindow = null;
+ } else {
+ markWindowReuse(examWindow);
+ }
} catch (_) { }
// 弹窗被拦截时,降级为当前窗口打开,确保用户可进入练习页
if (!examWindow) {
try {
+ if (!claimWindow(window)) {
+ return null;
+ }
window.location.href = finalUrl;
+ recordNavigation(window);
+ markWindowReuse(window);
return window; // 以当前窗口作为返回引用
} catch (e) {
throw new Error('无法打开题目页面,请检查弹窗/文件路径设置');
@@ -634,6 +1815,104 @@
}
},
+ _resolveExamMessageEndpoint(rawUrl) {
+ const href = this._ensureAbsoluteUrl(rawUrl);
+ if (!href) {
+ return { expectedUrl: '', expectedOrigin: '', allowOpaqueOrigin: false };
+ }
+ try {
+ const parsed = new URL(href, window.location.href);
+ // Chromium reports URL.origin as "file://" while postMessage events
+ // between file pages use the opaque origin "null".
+ if (parsed.protocol === 'file:') {
+ return {
+ expectedUrl: parsed.href,
+ expectedOrigin: 'null',
+ allowOpaqueOrigin: true
+ };
+ }
+ if (parsed.origin && parsed.origin !== 'null') {
+ return {
+ expectedUrl: parsed.href,
+ expectedOrigin: parsed.origin,
+ allowOpaqueOrigin: false
+ };
+ }
+ } catch (_) {
+ // An unparseable launch URL must never degrade to wildcard messaging.
+ }
+ return { expectedUrl: '', expectedOrigin: '', allowOpaqueOrigin: false };
+ },
+
+ _reportExamMessageRejected(examId, type, reason, event = null) {
+ if (!this._examMessageRejectionCounts) this._examMessageRejectionCounts = new Map();
+ const key = `${String(reason || 'unknown')}:${String(type || 'unknown')}`;
+ const count = Number(this._examMessageRejectionCounts.get(key) || 0) + 1;
+ this._examMessageRejectionCounts.set(key, count);
+ const incomingOrigin = event && typeof event.origin === 'string' ? event.origin : '';
+ const originClass = incomingOrigin === 'null'
+ ? 'opaque'
+ : (incomingOrigin && window.location && incomingOrigin === window.location.origin ? 'same-origin' : (incomingOrigin ? 'cross-origin' : 'missing'));
+ const detail = {
+ reason: String(reason || 'unknown'),
+ messageType: String(type || 'unknown'),
+ examId: String(examId || ''),
+ originClass,
+ count
+ };
+ if (count === 1 || count % 10 === 0) {
+ console.debug('[ExamMessage] rejected', detail);
+ }
+ try {
+ window.dispatchEvent(new CustomEvent('ielts-atlas:message-rejected', { detail }));
+ } catch (_) {
+ // Telemetry must never affect the security decision.
+ }
+ return false;
+ },
+
+ _postExamMessage(examId, targetWindow, type, data = {}, options = {}) {
+ if (!targetWindow || targetWindow.closed || typeof targetWindow.postMessage !== 'function') {
+ return false;
+ }
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ if (expectedRegistration && (
+ expectedRegistration.window !== targetWindow
+ || (launchOwnership
+ ? !this._isOwnedExamLaunchRegistrationCurrent(examId, launchOwnership, expectedRegistration)
+ : !this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ )) {
+ return false;
+ }
+ const windowInfo = expectedRegistration
+ ? expectedRegistration.windowInfo
+ : this.ensureExamWindowSession(examId, targetWindow);
+ const normalizedType = String(type || '').trim().toUpperCase();
+ if ((normalizedType === 'INIT_SESSION' || normalizedType === 'INIT_EXAM_SESSION')
+ && windowInfo.handshakeDeferred === true) {
+ return false;
+ }
+ const targetOrigin = windowInfo.expectedOrigin && windowInfo.expectedOrigin !== 'null'
+ ? windowInfo.expectedOrigin
+ : (windowInfo.allowOpaqueOrigin ? '*' : '');
+ if (!targetOrigin) {
+ console.warn('[App] 拒绝向未绑定可信 origin 的题目窗口发送消息:', type, examId);
+ return false;
+ }
+ const payload = Object.assign({}, data || {}, {
+ examId: data && data.examId != null ? data.examId : examId,
+ windowSessionToken: windowInfo.windowSessionToken
+ });
+ targetWindow.postMessage({
+ type,
+ data: payload,
+ source: 'exam_host',
+ timestamp: Date.now()
+ }, targetOrigin);
+ return true;
+ },
+
_appendSuiteContextToExamUrl(rawUrl, options = {}) {
if (!rawUrl) {
return rawUrl;
@@ -671,6 +1950,19 @@
}
},
+ _appendEndlessContextToExamUrl(rawUrl) {
+ if (!rawUrl) {
+ return rawUrl;
+ }
+ try {
+ const parsed = new URL(rawUrl, (window && window.location && window.location.href) ? window.location.href : undefined);
+ parsed.searchParams.set('endless', '1');
+ return parsed.toString();
+ } catch (_) {
+ return rawUrl;
+ }
+ },
+
_normalizeSuiteTimerAnchor(value) {
if (value == null || value === '') {
return null;
@@ -835,6 +2127,58 @@
if (!examWindow || examWindow.closed) {
return examWindow;
}
+ const retryOptions = options && typeof options === 'object' ? options : {};
+ const launchOwnership = retryOptions.launchOwnership || null;
+ const examId = retryOptions.examId;
+ const navigationRegistration = retryOptions.navigationRegistration || null;
+ const expectedNavigationOwnership = navigationRegistration
+ && navigationRegistration.navigationOwnership
+ || retryOptions.navigationOwnership
+ || null;
+ const recordNavigation = (targetWindow) => {
+ const navigationOwnership = this._recordExamWindowNavigation(targetWindow, examId);
+ if (navigationOwnership) retryOptions.navigationOwnership = navigationOwnership;
+ return navigationOwnership;
+ };
+ const ownsGuardWindow = (targetWindow = examWindow) => {
+ if (navigationRegistration) {
+ const currentWindowInfo = examId && this.examWindows
+ ? this.examWindows.get(examId)
+ : null;
+ return Boolean(
+ examId
+ && targetWindow === navigationRegistration.window
+ && currentWindowInfo
+ && currentWindowInfo.window === targetWindow
+ && currentWindowInfo.navigationOwnership === expectedNavigationOwnership
+ && this._isExamWindowNavigationCurrent(
+ targetWindow,
+ expectedNavigationOwnership
+ )
+ );
+ }
+ return !launchOwnership || Boolean(
+ examId
+ && targetWindow
+ && this._isExamLaunchOwnershipCurrent(
+ examId,
+ launchOwnership,
+ null,
+ targetWindow
+ )
+ );
+ };
+ if (!ownsGuardWindow()) {
+ return examWindow;
+ }
+ // Separate file:// documents have opaque origins. Reading a child
+ // window's location is forbidden even when both files are local,
+ // and the launch URL has already been resolved by openExam().
+ if (typeof window !== 'undefined'
+ && window.location
+ && window.location.protocol === 'file:') {
+ return examWindow;
+ }
const resolveHref = (targetWindow) => {
try {
@@ -854,9 +2198,7 @@
const currentHref = resolveHref(examWindow);
const normalizedHref = (currentHref || '').toLowerCase();
- const retryOptions = options && typeof options === 'object' ? options : {};
const retryCount = Number.isFinite(retryOptions.guardRetryCount) ? retryOptions.guardRetryCount : 0;
- const examId = retryOptions.examId;
if (examId && this.examWindows && this.examWindows.has(examId)) {
const windowInfo = this.examWindows.get(examId);
@@ -877,11 +2219,15 @@
const placeholderUrl = this._buildExamPlaceholderUrl(exam, retryOptions);
if (placeholderUrl) {
try {
+ if (!ownsGuardWindow()) {
+ return examWindow;
+ }
if (examWindow.location && typeof examWindow.location.replace === 'function') {
examWindow.location.replace(placeholderUrl);
} else {
examWindow.location.href = placeholderUrl;
}
+ recordNavigation(examWindow);
return examWindow;
} catch (forceError) {
console.warn('[App] 套题模式强制跳转占位页失败,继续使用原窗口:', forceError);
@@ -897,6 +2243,9 @@
try {
setTimeout(() => {
try {
+ if (!ownsGuardWindow()) {
+ return;
+ }
this._guardExamWindowContent(examWindow, exam, {
...retryOptions,
guardRetryCount: nextCount
@@ -935,20 +2284,32 @@
}
try {
+ if (!ownsGuardWindow()) {
+ return examWindow;
+ }
if (examWindow.location && typeof examWindow.location.replace === 'function') {
examWindow.location.replace(placeholderUrl);
+ recordNavigation(examWindow);
return examWindow;
}
examWindow.location.href = placeholderUrl;
+ recordNavigation(examWindow);
return examWindow;
} catch (navigationError) {
console.warn('[App] 题目窗口导航占位页失败,尝试重新打开:', navigationError);
try {
+ if (!ownsGuardWindow()) {
+ return examWindow;
+ }
const windowName = (options && options.windowName)
? String(options.windowName)
: (examWindow.name || '_blank');
const reopened = window.open(placeholderUrl, windowName);
- if (reopened) {
+ recordNavigation(reopened);
+ if (reopened
+ && (!launchOwnership
+ || (this._claimExamLaunchWindowOwnership(launchOwnership, reopened)
+ && ownsGuardWindow(reopened)))) {
return reopened;
}
} catch (openError) {
@@ -962,6 +2323,7 @@
_buildExamPlaceholderUrl(exam = null, options = {}) {
const basePath = 'templates/exam-placeholder.html';
const params = new URLSearchParams();
+ params.set('suite_test', '1');
const safeSet = (key, value) => {
if (value == null) {
@@ -1038,9 +2400,29 @@
/**
* 注入数据采集脚本到练习页面
*/
- injectDataCollectionScript(examWindow, examId, exam = null) {
+ injectDataCollectionScript(examWindow, examId, exam = null, options = {}) {
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsInjection = () => {
+ if (!expectedRegistration) {
+ return true;
+ }
+ if (expectedRegistration.window !== examWindow) {
+ return false;
+ }
+ return launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ expectedRegistration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration);
+ };
+ if (!ownsInjection()) {
+ return false;
+ }
if (this._isUnifiedReadingExam(exam)) {
- return;
+ return false;
}
const isListeningExam = typeof this._isListeningLibraryExam === 'function'
@@ -1067,7 +2449,7 @@
};
const injectScript = () => {
try {
- if (!examWindow || examWindow.closed) {
+ if (!ownsInjection() || !examWindow || examWindow.closed) {
console.warn('[DataInjection] 目标窗口已关闭');
return;
}
@@ -1076,7 +2458,7 @@
? (examWindow.__listeningBridgeGetState || examWindow.__listeningBridgeComplete)
: (examWindow.practicePageEnhancer && typeof examWindow.practicePageEnhancer.initialize === 'function');
if (bridgeReady) {
- this.initializePracticeSession(examWindow, examId);
+ this.initializePracticeSession(examWindow, examId, options);
return;
}
@@ -1093,6 +2475,11 @@
return;
}
+ if (isListeningExam && doc.documentElement
+ && doc.documentElement.dataset.listeningWrapper === 'true') {
+ return;
+ }
+
// 套题占位页自带消息协议与按钮,不需要再注入增强器(避免重复发送 PRACTICE_COMPLETE)
try {
if (doc.getElementById('complete-exam-btn') && doc.getElementById('force-ready-btn')) {
@@ -1108,12 +2495,15 @@
: (host && typeof host.querySelector === 'function' ? host.querySelector(existingSelector) : null);
if (existingEnhancerScript) {
if (isListeningExam && (examWindow.__listeningBridgeGetState || examWindow.__listeningBridgeComplete)) {
- this.initializePracticeSession(examWindow, examId);
+ this.initializePracticeSession(examWindow, examId, options);
}
return;
}
let enhancerInjected = false;
const appendEnhancer = () => {
+ if (!ownsInjection()) {
+ return;
+ }
const alreadyReady = isListeningExam
? (examWindow.__listeningBridgeGetState || examWindow.__listeningBridgeComplete)
: (examWindow.practicePageEnhancer && typeof examWindow.practicePageEnhancer.initialize === 'function');
@@ -1130,7 +2520,9 @@
scriptEl.onload = () => {
setTimeout(() => {
try {
- this.initializePracticeSession(examWindow, examId);
+ if (ownsInjection()) {
+ this.initializePracticeSession(examWindow, examId, options);
+ }
} catch (sessionError) {
console.warn('[DataInjection] 初始化练习会话失败:', sessionError);
}
@@ -1138,28 +2530,33 @@
};
scriptEl.onerror = (loadError) => {
+ if (!ownsInjection()) {
+ return;
+ }
console.warn('[DataInjection] 加载增强器失败:', loadError);
scriptEl.remove();
if (!isListeningExam) {
- this.injectInlineScript(examWindow, examId);
+ this.injectInlineScript(examWindow, examId, options);
}
};
- host.appendChild(scriptEl);
+ if (ownsInjection()) {
+ host.appendChild(scriptEl);
+ }
};
appendEnhancer();
} catch (error) {
console.error('[DataInjection] 注入增强器脚本时出错:', error);
- if (!isListeningExam) {
- this.injectInlineScript(examWindow, examId);
+ if (ownsInjection() && !isListeningExam) {
+ this.injectInlineScript(examWindow, examId, options);
}
}
};
const checkAndInject = () => {
try {
- if (!examWindow || examWindow.closed) {
+ if (!ownsInjection() || !examWindow || examWindow.closed) {
return;
}
@@ -1170,23 +2567,43 @@
setTimeout(checkAndInject, 200);
}
} catch (error) {
- console.warn('[DataInjection] 检测题目页面就绪状态失败:', error);
+ if (ownsInjection()) {
+ console.warn('[DataInjection] 检测题目页面就绪状态失败:', error);
+ }
}
};
setTimeout(checkAndInject, 300);
+ return true;
},
/**
* 内联脚本注入(备用方案)
*/
- injectInlineScript(examWindow, examId) {
+ injectInlineScript(examWindow, examId, options = {}) {
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsInjection = () => !expectedRegistration || (
+ expectedRegistration.window === examWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ expectedRegistration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ );
try {
+ if (!ownsInjection()) {
+ return false;
+ }
if (!examWindow || !examWindow.document || !examWindow.document.head) {
throw new Error('inline_target_unavailable');
}
const sessionToken = `${examId}_${Date.now()}`;
+ // 备用方案注入时同步读取 host 端启动时捕获的题库配置 ID,确保 enhancer 也能拿到来源。
+ const launchLibraryConfigurationId = this._readLaunchLibraryConfigurationId(examId);
const inlineScript = examWindow.document.createElement('script');
inlineScript.type = 'text/javascript';
inlineScript.textContent = `
@@ -1202,7 +2619,26 @@
examId: ${JSON.stringify(examId)},
startTime: Date.now(),
answers: {},
- suite: {
+ // 启动时 host 端捕获的题库配置 ID;每条 INIT_SESSION 还会再次以
+ // initData.libraryConfigurationId 同步更新,确保即使延迟加载也能拿到正确来源。
+ libraryConfigurationId: ${JSON.stringify(launchLibraryConfigurationId || null)},
+ expectedParentOrigin: (function() {
+ try {
+ if (!document.referrer) return '';
+ var parsed = new URL(document.referrer, window.location.href);
+ // Chromium: file URL.origin is "file://", postMessage event.origin is "null".
+ if (parsed.protocol === 'file:') return '';
+ if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return '';
+ return parsed.origin;
+ } catch (_) {
+ return '';
+ }
+ })(),
+ parentOrigin: '',
+ parentOriginIsOpaque: false,
+ windowSessionToken: '',
+ submissionId: '',
+ suite: {
active: false,
sessionId: null,
guarded: false,
@@ -1211,12 +2647,41 @@
}
};
+ function createSubmissionId() {
+ try {
+ if (window.crypto && typeof window.crypto.randomUUID === 'function') {
+ return 'inline-submit-' + window.crypto.randomUUID();
+ }
+ } catch (_) {}
+ return 'inline-submit-' + (state.sessionId || state.examId || 'session') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2);
+ }
+
function sendMessage(type, data) {
if (!parentWindow || typeof parentWindow.postMessage !== 'function') {
return;
}
try {
- parentWindow.postMessage({ type: type, data: data || {} }, '*');
+ var targetOrigin = state.parentOrigin && state.parentOrigin !== 'null'
+ ? state.parentOrigin
+ : (state.expectedParentOrigin || (window.location.protocol === 'file:' ? '*' : ''));
+ if (!targetOrigin) return;
+ var payload = Object.assign({}, data || {});
+ if (type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT') {
+ if (!state.submissionId) {
+ state.submissionId = payload.submissionId || createSubmissionId();
+ }
+ payload.sessionId = payload.sessionId || state.sessionId || null;
+ payload.submissionId = payload.submissionId || state.submissionId;
+ }
+ parentWindow.postMessage({
+ type: type,
+ data: Object.assign(payload, {
+ suiteSessionId: state.suite.sessionId || null,
+ windowSessionToken: state.windowSessionToken || null
+ }),
+ source: 'inline_collector',
+ timestamp: Date.now()
+ }, targetOrigin);
} catch (error) {
console.warn('[InlineEnhancer] 无法发送消息:', error);
}
@@ -1333,11 +2798,21 @@
function handleInitSession(message) {
var initData = message && message.data ? message.data : {};
if (initData.sessionId) {
+ if (state.sessionId && String(state.sessionId) !== String(initData.sessionId)) {
+ state.submissionId = '';
+ }
state.sessionId = initData.sessionId;
}
if (initData.examId) {
state.examId = initData.examId;
}
+ // host 启动时捕获并随 INIT_SESSION 携带的题库配置 ID;这里同步更新 state,
+ // 在 enhancer 回传完成结果时一并透传,避免后续提交再读当前激活题库。
+ if (typeof initData.libraryConfigurationId !== 'undefined'
+ && initData.libraryConfigurationId !== null
+ && initData.libraryConfigurationId !== '') {
+ state.libraryConfigurationId = initData.libraryConfigurationId;
+ }
if (initData.suiteSessionId) {
state.suite.active = true;
state.suite.sessionId = initData.suiteSessionId;
@@ -1359,10 +2834,55 @@
}
if (message.type === 'INIT_SESSION') {
+ var initData = message.data || {};
+ var incomingOrigin = event && typeof event.origin === 'string' ? event.origin : '';
+ var declaredOrigin = typeof initData.parentOrigin === 'string' ? initData.parentOrigin : '';
+ var incomingToken = typeof initData.windowSessionToken === 'string'
+ ? initData.windowSessionToken.trim()
+ : '';
+ if (!event || event.source !== parentWindow || message.source !== 'exam_host' || !incomingToken) return;
+ var expectedParentOrigin = state.expectedParentOrigin
+ && state.expectedParentOrigin !== 'file://'
+ && String(state.expectedParentOrigin).indexOf('file:') !== 0
+ ? state.expectedParentOrigin
+ : '';
+ if (expectedParentOrigin) {
+ if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) return;
+ state.parentOrigin = expectedParentOrigin;
+ state.parentOriginIsOpaque = false;
+ } else if (window.location.protocol === 'file:') {
+ var trustedFileOrigin = (incomingOrigin === 'null' || incomingOrigin === 'file://')
+ && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://');
+ if (!trustedFileOrigin) return;
+ state.parentOrigin = 'null';
+ state.parentOriginIsOpaque = true;
+ } else {
+ var trustedWebOrigin = !!incomingOrigin
+ && incomingOrigin !== 'null'
+ && incomingOrigin !== 'file://'
+ && declaredOrigin === incomingOrigin;
+ if (!trustedWebOrigin) return;
+ state.parentOrigin = incomingOrigin;
+ state.parentOriginIsOpaque = false;
+ }
+ state.windowSessionToken = incomingToken;
handleInitSession(message);
return;
}
+ var messageData = message.data || {};
+ var messageToken = typeof messageData.windowSessionToken === 'string'
+ ? messageData.windowSessionToken.trim()
+ : '';
+ var messageOrigin = event && typeof event.origin === 'string' ? event.origin : '';
+ var originMatches = state.parentOriginIsOpaque
+ ? (messageOrigin === 'null' || messageOrigin === 'file://')
+ : Boolean(state.parentOrigin && messageOrigin === state.parentOrigin);
+ if (!event || event.source !== parentWindow || message.source !== 'exam_host'
+ || !originMatches || !state.windowSessionToken || messageToken !== state.windowSessionToken) {
+ return;
+ }
+
if (!state.suite.active) {
return;
}
@@ -1424,7 +2944,9 @@
examId: state.examId,
duration: Math.round((Date.now() - state.startTime) / 1000),
answers: state.answers,
- source: 'inline_collector'
+ source: 'inline_collector',
+ // 透传启动时捕获的题库配置 ID,便于 host 端 completeAttempt 写入 metadata 来源。
+ libraryConfigurationId: state.libraryConfigurationId || null
});
}
};
@@ -1441,35 +2963,65 @@
})();
`;
+ if (!ownsInjection()) {
+ return false;
+ }
examWindow.document.head.appendChild(inlineScript);
setTimeout(() => {
- this.initializePracticeSession(examWindow, examId);
+ if (ownsInjection()) {
+ this.initializePracticeSession(examWindow, examId, options);
+ }
}, 300);
-
+ return true;
} catch (error) {
+ if (!ownsInjection()) {
+ return false;
+ }
console.error('[DataInjection] 内联脚本注入失败:', error);
this.handleInjectionError(examId, error);
+ return false;
}
},
/**
* 初始化练习会话
*/
- initializePracticeSession(examWindow, examId) {
+ initializePracticeSession(examWindow, examId, options = {}) {
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsInitialization = () => !expectedRegistration || (
+ expectedRegistration.window === examWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ expectedRegistration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ );
try {
+ if (!ownsInitialization()) {
+ return false;
+ }
const now = Date.now();
- let existingInfo = null;
- if (this.examWindows && this.examWindows.has(examId)) {
+ let existingInfo = expectedRegistration
+ ? expectedRegistration.windowInfo
+ : null;
+ if (!existingInfo && this.examWindows && this.examWindows.has(examId)) {
existingInfo = this.examWindows.get(examId) || null;
}
- let suiteSessionId = existingInfo && existingInfo.suiteSessionId
+ const hasExplicitSuiteOwnership = Boolean(
+ existingInfo
+ && Object.prototype.hasOwnProperty.call(existingInfo, 'suiteSessionId')
+ );
+ let suiteSessionId = hasExplicitSuiteOwnership && existingInfo.suiteSessionId
? existingInfo.suiteSessionId
: null;
- if (!suiteSessionId && this.currentSuiteSession) {
+ if (!hasExplicitSuiteOwnership && !suiteSessionId && this.currentSuiteSession) {
const activeMatch = this.currentSuiteSession.activeExamId === examId;
const sequenceIndex = Number.isInteger(this.currentSuiteSession.currentIndex)
? this.currentSuiteSession.currentIndex
@@ -1484,7 +3036,12 @@
}
}
- const windowInfo = this.ensureExamWindowSession(examId, examWindow);
+ const windowInfo = expectedRegistration
+ ? expectedRegistration.windowInfo
+ : this.ensureExamWindowSession(examId, examWindow);
+ if (!ownsInitialization()) {
+ return false;
+ }
if (suiteSessionId && !windowInfo.suiteSessionId) {
windowInfo.suiteSessionId = suiteSessionId;
}
@@ -1502,10 +3059,10 @@
const initPayload = this._buildExamInitPayload(examId, windowInfo, { timestamp: now });
// 发送会话初始化消息
- examWindow.postMessage({
- type: 'INIT_SESSION',
- data: initPayload
- }, '*');
+ if (!ownsInitialization()) {
+ return false;
+ }
+ this._postExamMessage(examId, examWindow, 'INIT_SESSION', initPayload);
// 存储会话信息
if (!this.examWindows) {
@@ -1533,9 +3090,10 @@
suiteSessionId: suiteSessionId || null
}));
}
-
+ return true;
} catch (error) {
console.error('[DataInjection] 会话初始化失败:', error);
+ return false;
}
},
@@ -1553,13 +3111,7 @@
type: 'script_injection_error'
};
- // 保存错误日志到本地存储
- const errorLogs = await storage.get('injection_errors', []);
- errorLogs.push(errorInfo);
- if (errorLogs.length > 50) {
- errorLogs.splice(0, errorLogs.length - 50); // 保留最近50条错误
- }
- await storage.set('injection_errors', errorLogs);
+ console.warn('[DataInjection] 诊断信息:', errorInfo);
// 不显示错误给用户,静默处理
console.warn('[DataInjection] 将使用模拟数据模式');
@@ -1569,18 +3121,29 @@
* 设置题目窗口管理
*/
setupExamWindowManagement(examWindow, examId, exam = null, options = {}) {
- if (!examWindow) {
+ if (!examWindow || examWindow.closed) {
console.warn('[App] 缺少题目窗口引用,无法完成窗口管理');
return;
}
- try {
- const guardedWindow = this._guardExamWindowContent(examWindow, exam, { ...options, examId });
- if (guardedWindow) {
- examWindow = guardedWindow;
+ const expectedRegistration = options && options.expectedRegistration || null;
+ if (expectedRegistration && !this._isOpenExamRegistrationCurrent(
+ examId,
+ expectedRegistration,
+ examWindow
+ )) {
+ return null;
+ }
+
+ if (!(options && options.skipContentGuard === true)) {
+ try {
+ const guardedWindow = this._guardExamWindowContent(examWindow, exam, { ...options, examId });
+ if (guardedWindow) {
+ examWindow = guardedWindow;
+ }
+ } catch (guardError) {
+ console.warn('[App] 守护题目窗口内容失败:', guardError);
}
- } catch (guardError) {
- console.warn('[App] 守护题目窗口内容失败:', guardError);
}
// 存储窗口引用
@@ -1588,12 +3151,49 @@
this.examWindows = new Map();
}
- this.examWindows.set(examId, {
+ const previousWindowInfo = this.examWindows.get(examId);
+ if (previousWindowInfo && previousWindowInfo.closeMonitor) {
+ try {
+ clearInterval(previousWindowInfo.closeMonitor);
+ } catch (_) {}
+ }
+
+ const endpoint = this._resolveExamMessageEndpoint(
+ options && options.expectedUrl
+ ? options.expectedUrl
+ : (exam ? this.buildExamUrl(exam) : '')
+ );
+ const adoptedBinding = options && options.adoptWindowBinding && typeof options.adoptWindowBinding === 'object'
+ ? options.adoptWindowBinding
+ : null;
+ const adoptedSessionId = adoptedBinding && typeof adoptedBinding.expectedSessionId === 'string'
+ ? adoptedBinding.expectedSessionId.trim()
+ : '';
+ const adoptedToken = adoptedBinding && typeof adoptedBinding.windowSessionToken === 'string'
+ ? adoptedBinding.windowSessionToken.trim()
+ : '';
+ const adoptedGeneration = Number(adoptedBinding && adoptedBinding.sessionGeneration);
+ const canAdoptBinding = Boolean(
+ adoptedSessionId
+ && adoptedToken
+ && Number.isInteger(adoptedGeneration)
+ && adoptedGeneration > 0
+ );
+ const windowInfo = {
window: examWindow,
+ navigationOwnership: this._examWindowCommittedNavigationOwners
+ && this._examWindowCommittedNavigationOwners.get(examWindow)
+ || (previousWindowInfo && previousWindowInfo.navigationOwnership)
+ || null,
startTime: Date.now(),
status: 'active',
- expectedSessionId: null,
- origin: (typeof window !== 'undefined' && window.location) ? window.location.origin : '',
+ expectedSessionId: canAdoptBinding ? adoptedSessionId : null,
+ windowSessionToken: canAdoptBinding ? adoptedToken : null,
+ windowSessionTokenSessionId: canAdoptBinding ? adoptedSessionId : null,
+ expectedUrl: endpoint.expectedUrl,
+ expectedOrigin: endpoint.expectedOrigin,
+ allowOpaqueOrigin: endpoint.allowOpaqueOrigin,
+ observedOrigin: '',
suiteSessionId: (options && options.suiteSessionId) ? options.suiteSessionId : null,
suiteFlowMode: (options && options.suiteFlowMode) ? String(options.suiteFlowMode) : null,
suiteSequenceIndex: Number.isInteger(options && options.sequenceIndex) ? options.sequenceIndex : null,
@@ -1606,8 +3206,27 @@
: null,
readOnly: options && Object.prototype.hasOwnProperty.call(options, 'readOnly')
? Boolean(options.readOnly)
- : Boolean(options && options.reviewMode)
- });
+ : Boolean(options && options.reviewMode),
+ handshakeDeferred: Boolean(options && options.deferInitialHandshake),
+ // Async INIT/draft work must be tied to this exact registration.
+ // Reusing an exam ID replaces the map entry even when the browser
+ // keeps the same WindowProxy alive.
+ sessionGeneration: canAdoptBinding
+ ? adoptedGeneration
+ : (previousWindowInfo && Number.isFinite(previousWindowInfo.sessionGeneration)
+ ? previousWindowInfo.sessionGeneration + 1
+ : 1),
+ closeMonitor: null
+ };
+ if (!this._examRegistrationEpochs) this._examRegistrationEpochs = new Map();
+ const registrationEpochKey = String(examId || '');
+ this._examRegistrationEpochs.set(
+ registrationEpochKey,
+ Number(this._examRegistrationEpochs.get(registrationEpochKey) || 0) + 1
+ );
+ this.examWindows.set(examId, windowInfo);
+ this.ensureExamWindowSession(examId, examWindow);
+ const setupRegistration = this._captureExamSessionRegistration(examId, windowInfo);
// 监听窗口关闭事件
let checkClosed = null;
@@ -1616,41 +3235,52 @@
try {
if (examWindow.closed) {
clearInterval(checkClosed);
- this.handleExamWindowClosed(examId);
+ if (windowInfo.closeMonitor === checkClosed) {
+ windowInfo.closeMonitor = null;
+ }
+ this.handleExamWindowClosed(examId, examWindow);
}
} catch (monitorError) {
clearInterval(checkClosed);
console.warn('[App] 无法检测题目窗口状态:', monitorError);
}
}, 1000);
+ windowInfo.closeMonitor = checkClosed;
} catch (error) {
console.warn('[App] 启动窗口关闭监控失败:', error);
}
// 设置窗口通信
try {
- this.setupExamWindowCommunication(examWindow, examId, exam, options);
+ this.setupExamWindowCommunication(examWindow, examId, exam, {
+ ...options,
+ expectedRegistration: setupRegistration
+ });
} catch (error) {
console.warn('[App] 初始化题目窗口通信失败:', error);
}
// 启动与练习页的会话握手(file:// 下更可靠)
- try {
- this.startExamHandshake(examWindow, examId);
- } catch (e) {
- console.warn('[App] 启动握手失败:', e);
+ if (!windowInfo.handshakeDeferred) {
+ try {
+ this.startExamHandshake(examWindow, examId, {
+ expectedRegistration: setupRegistration,
+ launchOwnership: options && options.launchOwnership || null
+ });
+ } catch (e) {
+ console.warn('[App] 启动握手失败:', e);
+ }
}
- const emitInitEnvelope = () => {
- const windowInfo = this.ensureExamWindowSession(examId, examWindow);
- const initPayload = this._buildExamInitPayload(examId, windowInfo);
- try {
- examWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*');
- examWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*');
- } catch (postError) {
- console.warn('[App] 跨源初始化题目窗口失败:', postError);
+ const emitInitEnvelope = () => this._sendExamInitEnvelope(
+ examId,
+ examWindow,
+ {},
+ {
+ expectedRegistration: setupRegistration,
+ launchOwnership: options && options.launchOwnership || null
}
- };
+ );
if (!isFileProtocol) {
try {
@@ -1667,6 +3297,7 @@
if (!(options && options.reviewMode)) {
this.updateExamStatus(examId, 'in-progress');
}
+ return setupRegistration;
},
/**
@@ -1713,6 +3344,9 @@
'SUITE_CONFIG_UPDATE',
'VOCAB_HIGHLIGHT_SAVE',
'SIMULATION_DRAFT_SYNC',
+ 'READING_DRAFT_SYNC',
+ 'READING_ANNOTATION_SYNC',
+ 'PRACTICE_RECORD_SAVED',
'SIMULATION_NAVIGATE',
'SIMULATION_ACTIVE_EXAM_CHANGE',
'SIMULATION_SUBMIT'
@@ -1836,35 +3470,69 @@
const messageHandler = async (event) => {
// 取得当前题目窗口引用(可能在 handshake 期间被更新)
- const storedInfo = (this.examWindows && this.examWindows.get(examId)) || {};
+ const storedInfo = this.examWindows && this.examWindows.get(examId);
+ if (!storedInfo) {
+ this._reportExamMessageRejected(examId, '', 'missing-registration', event);
+ return;
+ }
+ const entryRegistration = this._captureExamSessionRegistration(examId, storedInfo);
+ // An uncommitted newer launch reservation must invalidate stale open
+ // continuations, but the currently registered page remains entitled to
+ // finish its protocol until navigation replaces this exact tuple.
+ const ownsEntryRegistration = entryRegistration
+ && this._isExamSessionRegistrationCurrent(examId, entryRegistration);
+ if (!ownsEntryRegistration) {
+ this._reportExamMessageRejected(examId, '', 'stale-registration', event);
+ return;
+ }
const expectedWindow = storedInfo.window || examWindow;
const sourceWindow = event ? (event.source || null) : null;
// 缺少来源窗口直接拒绝
if (!sourceWindow || !expectedWindow) {
+ this._reportExamMessageRejected(examId, '', 'missing-window', event);
return;
}
- // 校验来源域,允许 file:// (origin 为 null) 与同源页面
- if (event.origin && event.origin !== 'null') {
- const allowedOrigin = window.location && window.location.origin;
- if (allowedOrigin && event.origin !== allowedOrigin) {
- return;
- }
- }
-
const normalized = normalizeMessage(event.data);
if (!normalized) {
+ this._reportExamMessageRejected(examId, '', 'invalid-envelope', event);
+ return;
+ }
+
+ if (storedInfo.windowReusePending === true) {
+ this._reportExamMessageRejected(examId, normalized.type, 'window-reassignment-pending', event);
return;
}
- const windowInfo = this.ensureExamWindowSession(examId, expectedWindow);
+ const windowInfo = storedInfo;
const expectedSessionId = windowInfo.expectedSessionId || '';
+ // Most messages must still come from the exact exam window. A small
+ // suite/listening compatibility path below can prove an equivalent
+ // source with the window token and full session scope; do not reject
+ // before those constraints have been evaluated.
+ const sourceMatched = sourceWindow === expectedWindow;
+ const incomingOrigin = event && typeof event.origin === 'string' ? event.origin : '';
+ if (windowInfo.expectedOrigin && windowInfo.expectedOrigin !== 'null') {
+ if (incomingOrigin !== windowInfo.expectedOrigin) {
+ this._reportExamMessageRejected(examId, normalized.type, 'origin-mismatch', event);
+ return;
+ }
+ } else if (windowInfo.allowOpaqueOrigin) {
+ if (incomingOrigin !== 'null' && incomingOrigin !== 'file://') {
+ this._reportExamMessageRejected(examId, normalized.type, 'opaque-origin-mismatch', event);
+ return;
+ }
+ } else {
+ this._reportExamMessageRejected(examId, normalized.type, 'origin-unbound', event);
+ return;
+ }
// 放宽消息源过滤,兼容 inline_collector 与 practice_page
const src = normalized.sourceTag || '';
const allowedSources = new Set(['practice_page', 'inline_collector', 'suite_placeholder', 'listening_record_bridge']);
- if (src && !allowedSources.has(src)) {
+ if (!src || !allowedSources.has(src)) {
+ this._reportExamMessageRejected(examId, normalized.type, 'source-tag-mismatch', event);
return; // 非预期来源的消息忽略
}
@@ -1890,6 +3558,21 @@
const activeSuiteSequence = this.currentSuiteSession && Array.isArray(this.currentSuiteSession.sequence)
? this.currentSuiteSession.sequence
: [];
+ const registeredSuiteSessionId = windowInfo
+ && Object.prototype.hasOwnProperty.call(windowInfo, 'suiteSessionId')
+ && typeof windowInfo.suiteSessionId === 'string'
+ ? windowInfo.suiteSessionId.trim()
+ : '';
+ const ownsCurrentSuiteRegistration = Boolean(
+ registeredSuiteSessionId
+ && activeSuiteSessionId
+ && registeredSuiteSessionId === activeSuiteSessionId
+ );
+ const ownsPayloadSuiteProtocol = Boolean(
+ ownsCurrentSuiteRegistration
+ && payloadSuiteSessionId
+ && payloadSuiteSessionId === registeredSuiteSessionId
+ );
const isExamInActiveSuite = Boolean(
this.currentSuiteSession
&& activeSuiteSequence.some(item => item && String(item.examId) === expectedExamId)
@@ -1914,14 +3597,155 @@
const expectedWindowSessionToken = windowInfo && typeof windowInfo.windowSessionToken === 'string'
? windowInfo.windowSessionToken.trim()
: '';
+ const isTokenlessListeningBootstrap = Boolean(
+ type === 'SESSION_READY'
+ && !payloadWindowSessionToken
+ && sourceMatched
+ && src === 'listening_record_bridge'
+ && data.initialized === false
+ && (data.pageType === 'listening' || data.type === 'listening')
+ );
+ const isTokenlessSuiteBootstrap = Boolean(
+ type === 'SESSION_READY'
+ && !payloadWindowSessionToken
+ && sourceMatched
+ && src === 'suite_placeholder'
+ && data.pageType === 'suite-placeholder'
+ && (!payloadExamId || payloadExamId === expectedExamId)
+ && (!payloadSuiteSessionId || payloadSuiteSessionId === registeredSuiteSessionId)
+ );
+ const isTokenlessReadyBootstrap = isTokenlessListeningBootstrap || isTokenlessSuiteBootstrap;
+ const permitsPreInitWithoutToken = type === 'REQUEST_INIT' || isTokenlessReadyBootstrap;
+ if (!permitsPreInitWithoutToken && (
+ !expectedWindowSessionToken
+ || !payloadWindowSessionToken
+ || payloadWindowSessionToken !== expectedWindowSessionToken
+ )) {
+ this._reportExamMessageRejected(examId, type, 'token-mismatch', event);
+ return;
+ }
+ const requestsSuiteOwnedProtocol = isSimulationSuiteMessage
+ || type === 'SUITE_CLOSE_ATTEMPT'
+ || type === 'SUITE_CONFIG_UPDATE'
+ || (type === 'REVIEW_NAVIGATE' && (
+ data.suiteReviewMode === true
+ || Boolean(payloadSuiteSessionId)
+ || Boolean(registeredSuiteSessionId)
+ ))
+ || (type === 'SESSION_READY'
+ && !isTokenlessReadyBootstrap
+ && Boolean(payloadSuiteSessionId || registeredSuiteSessionId))
+ || ((type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT')
+ && Boolean(payloadSuiteSessionId || registeredSuiteSessionId));
+ if (requestsSuiteOwnedProtocol && !ownsPayloadSuiteProtocol) {
+ this._reportExamMessageRejected(examId, type, 'suite-registration-mismatch', event);
+ return;
+ }
const canRoutePayloadExamInActiveSuite = Boolean(
suiteRoutableMessageTypes.has(type)
&& isPayloadExamInActiveSuite
- && activeSuiteSessionId
- && payloadSuiteSessionId
- && payloadSuiteSessionId === activeSuiteSessionId
+ && ownsPayloadSuiteProtocol
);
- const sourceMatched = isLikelySameWindowContext(sourceWindow, expectedWindow);
+ const isReadingAnnotationSync = type === 'READING_ANNOTATION_SYNC';
+ const isReadingDraftSync = type === 'READING_DRAFT_SYNC';
+ if (isReadingAnnotationSync) {
+ const expectedReviewSessionId = windowInfo && windowInfo.reviewSessionId
+ ? String(windowInfo.reviewSessionId)
+ : '';
+ const payloadReviewSessionId = data && data.reviewSessionId != null
+ ? String(data.reviewSessionId)
+ : '';
+ const payloadRecordId = data && data.recordId != null ? String(data.recordId) : '';
+ const hasStrictSessionBinding = Boolean(
+ expectedSessionId
+ && payloadSessionId
+ && payloadSessionId === expectedSessionId
+ );
+ const hasStrictWindowToken = Boolean(
+ expectedWindowSessionToken
+ && payloadWindowSessionToken
+ && payloadWindowSessionToken === expectedWindowSessionToken
+ );
+ const hasStrictReviewBinding = Boolean(
+ windowInfo
+ && windowInfo.reviewMode
+ && expectedReviewSessionId
+ && payloadReviewSessionId === expectedReviewSessionId
+ );
+ // 单篇阅读 final-submit 后,结果页以已存档 recordId 发送标注同步:
+ // 不在 review 回放态,但 windowInfo.submittedRecordId 必须与 payload
+ // recordId 严格匹配,并仍受 source/会话/窗口 token/题号约束。
+ const hasSubmittedRecordBinding = Boolean(
+ windowInfo
+ && !windowInfo.reviewMode
+ && windowInfo.submittedRecordId
+ && payloadRecordId
+ && payloadRecordId === String(windowInfo.submittedRecordId)
+ );
+ if (
+ !sourceMatched
+ || !hasStrictSessionBinding
+ || !hasStrictWindowToken
+ || (!hasStrictReviewBinding && !hasSubmittedRecordBinding)
+ || !payloadExamId
+ || payloadExamId !== expectedExamId
+ ) {
+ return;
+ }
+ }
+ if (isReadingDraftSync) {
+ const hasStrictSessionBinding = Boolean(
+ expectedSessionId
+ && payloadSessionId
+ && payloadSessionId === expectedSessionId
+ );
+ const hasStrictWindowToken = Boolean(
+ expectedWindowSessionToken
+ && payloadWindowSessionToken
+ && payloadWindowSessionToken === expectedWindowSessionToken
+ );
+ const isLivePracticeWindow = Boolean(
+ windowInfo
+ && !windowInfo.reviewMode
+ && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize'
+ );
+ if (
+ !sourceMatched
+ || !hasStrictSessionBinding
+ || !hasStrictWindowToken
+ || !isLivePracticeWindow
+ || !payloadExamId
+ || payloadExamId !== expectedExamId
+ ) {
+ return;
+ }
+ }
+ if (type === 'SIMULATION_DRAFT_SYNC' && isExamInActiveSuite) {
+ const incomingUpdatedAt = Number(data && (data.draftUpdatedAt
+ ?? (data.draft && data.draft.updatedAt)
+ ?? data.updatedAt));
+ const suiteWindowBound = Boolean(
+ windowInfo
+ && windowInfo.suiteSessionId
+ && String(windowInfo.suiteSessionId) === activeSuiteSessionId
+ );
+ const exactSuiteDraftBinding = Boolean(
+ sourceMatched
+ && suiteWindowBound
+ && payloadSuiteSessionId === activeSuiteSessionId
+ && isPayloadExamInActiveSuite
+ && expectedWindowSessionToken
+ && payloadWindowSessionToken === expectedWindowSessionToken
+ && Number.isFinite(incomingUpdatedAt)
+ && incomingUpdatedAt > 0
+ && this.currentSuiteSession
+ && ['active', 'initializing'].includes(this.currentSuiteSession.status)
+ );
+ if (!exactSuiteDraftBinding) {
+ this._reportExamMessageRejected(examId, type, 'suite-draft-binding-mismatch', event);
+ return;
+ }
+ }
const payloadWindowInfo = payloadExamId && payloadExamId !== expectedExamId && this.examWindows
? this.examWindows.get(payloadExamId)
: null;
@@ -1970,22 +3794,26 @@
const allowSuiteSourceFallback = Boolean(
!sourceMatched
&& payloadExamId
+ && payloadSessionId
+ && expectedSessionId
+ && payloadSessionId === expectedSessionId
&& payloadTokenMatchesExpectedWindow
&& (payloadExamId === expectedExamId || isPayloadExamInActiveSuite)
- && (
- (payloadSuiteSessionId && activeSuiteSessionId && payloadSuiteSessionId === activeSuiteSessionId)
- || isExamInActiveSuite
- )
+ && ownsPayloadSuiteProtocol
);
const allowListeningSourceFallback = Boolean(
!sourceMatched
&& isListeningBridgeProtocolMessage
- && (
- (payloadExamId && payloadExamId === expectedExamId)
- || (payloadSessionId && expectedSessionId && payloadSessionId === expectedSessionId)
- )
+ && payloadTokenMatchesExpectedWindow
+ && payloadExamId
+ && payloadExamId === expectedExamId
+ && payloadSessionId
+ && expectedSessionId
+ && payloadSessionId === expectedSessionId
+ && (!payloadSuiteSessionId || !activeSuiteSessionId || payloadSuiteSessionId === activeSuiteSessionId)
);
if (!sourceMatched && !allowSuiteSourceFallback && !allowListeningSourceFallback) {
+ this._reportExamMessageRejected(examId, type, 'window-mismatch', event);
return;
}
if (windowInfo && sourceWindow && (sourceMatched || !expectedWindow || expectedWindow.closed)) {
@@ -2000,9 +3828,7 @@
|| type === 'SIMULATION_ACTIVE_EXAM_CHANGE'
|| type === 'SIMULATION_SUBMIT'
|| type === 'SESSION_READY')
- && payloadSuiteSessionId
- && activeSuiteSessionId
- && payloadSuiteSessionId === activeSuiteSessionId
+ && ownsPayloadSuiteProtocol
&& payloadExamId
&& (payloadExamId === expectedExamId || isPayloadExamInActiveSuite)
&& (
@@ -2040,9 +3866,6 @@
data.sessionId = expectedSessionId;
} else {
windowInfo.sessionId = payloadSessionId;
- if (!windowInfo.expectedSessionId) {
- windowInfo.expectedSessionId = payloadSessionId;
- }
}
} else if (type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT') {
if (!expectedSessionId) {
@@ -2068,8 +3891,19 @@
if (!data.sessionId && expectedSessionId) {
data.sessionId = expectedSessionId;
}
+ if (
+ (type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT')
+ && (
+ !String(data.submissionId || '').trim()
+ || !String(data.sessionId || '').trim()
+ || !String(payloadWindowSessionToken || '').trim()
+ )
+ ) {
+ this._reportExamMessageRejected(examId, type, 'missing-submission-contract', event);
+ return;
+ }
- windowInfo.origin = event.origin;
+ windowInfo.observedOrigin = event.origin;
windowInfo.lastMessageAt = Date.now();
windowInfo.lastMessageType = type;
if (payloadWindowSessionToken) {
@@ -2077,6 +3911,14 @@
}
this.examWindows.set(examId, windowInfo);
+ const eventRegistration = this._captureExamSessionRegistration(examId, windowInfo);
+ const ownsEventRegistration = eventRegistration
+ && this._isExamSessionRegistrationCurrent(examId, eventRegistration);
+ if (!ownsEventRegistration) {
+ this._reportExamMessageRejected(examId, type, 'stale-registration', event);
+ return;
+ }
+
switch (type) {
case 'exam_completed':
this.handleExamCompleted(examId, data);
@@ -2088,14 +3930,41 @@
this.handleExamError(examId, data);
break;
// 新增:处理数据采集器的消息
- case 'SESSION_READY':
- this.handleSessionReady(examId, data);
- if (typeof this._maybeRestoreSuiteReviewState === 'function') {
- this._maybeRestoreSuiteReviewState(examId, sourceWindow || expectedWindow, windowInfo).catch((restoreError) => {
+ case 'SESSION_READY': {
+ if (isTokenlessReadyBootstrap) {
+ this._sendExamInitEnvelope(examId, sourceWindow || examWindow, {}, {
+ expectedRegistration: eventRegistration
+ });
+ break;
+ }
+ const sessionReadyAccepted = this.handleSessionReady(examId, data, {
+ expectedRegistration: eventRegistration
+ });
+ const stillOwnsReadyRegistration = this._isExamSessionRegistrationCurrent(
+ examId,
+ eventRegistration
+ );
+ if (sessionReadyAccepted !== false
+ && stillOwnsReadyRegistration
+ && ownsPayloadSuiteProtocol
+ && typeof this._maybeRestoreSuiteReviewState === 'function') {
+ this._maybeRestoreSuiteReviewState(
+ examId,
+ sourceWindow || expectedWindow,
+ windowInfo,
+ {
+ expectedRegistration: eventRegistration,
+ commitGuard: () => this._isExamSessionRegistrationCurrent(
+ examId,
+ eventRegistration
+ )
+ }
+ ).catch((restoreError) => {
console.warn('[SuitePractice] 恢复回看态失败:', restoreError);
});
}
break;
+ }
case 'PROGRESS_UPDATE':
this.handleProgressUpdate(examId, data);
break;
@@ -2112,25 +3981,52 @@
console.info('[ReadingMemorize] 背题模式结果仅在统一阅读页内展示,跳过练习记录:', examId);
break;
}
- if (data && data.suiteSessionId && windowInfo) {
- windowInfo.suiteSessionId = data.suiteSessionId;
- this.examWindows && this.examWindows.set(examId, windowInfo);
- }
- await this.handlePracticeComplete(examId, data, sourceWindow || expectedWindow);
+ await this.handlePracticeComplete(
+ examId,
+ data,
+ sourceWindow || expectedWindow,
+ {
+ expectedRegistration: eventRegistration
+ }
+ );
break;
case 'ERROR_OCCURRED':
this.handleDataCollectionError(examId, data);
break;
case 'REQUEST_INIT':
- sendInitEnvelope(sourceWindow || examWindow);
+ this._sendExamInitEnvelope(examId, sourceWindow || examWindow, {}, {
+ expectedRegistration: eventRegistration
+ });
break;
case 'PRACTICE_RESET_REQUEST':
- await this.handlePracticeResetRequest(examId, data, sourceWindow || expectedWindow);
+ await this.handlePracticeResetRequest(
+ examId,
+ data,
+ sourceWindow || expectedWindow,
+ {
+ expectedRegistration: eventRegistration
+ }
+ );
break;
- case 'SUITE_CLOSE_ATTEMPT':
- console.warn('[SuitePractice] 练习页尝试关闭套题窗口:', data);
+ case 'SUITE_CLOSE_ATTEMPT': {
+ const suiteSession = this.currentSuiteSession;
+ const requestedSuiteSessionId = String(data && data.suiteSessionId || '').trim();
+ if (ownsPayloadSuiteProtocol
+ && sourceMatched
+ && suiteSession
+ && suiteSession.status === 'completed'
+ && requestedSuiteSessionId === String(suiteSession.id || '')
+ && typeof this._teardownSuiteSession === 'function') {
+ await this._teardownSuiteSession(suiteSession);
+ } else {
+ console.warn('[SuitePractice] 练习页尝试关闭进行中的套题窗口:', data);
+ }
break;
+ }
case 'SUITE_CONFIG_UPDATE': {
+ if (!ownsPayloadSuiteProtocol) {
+ break;
+ }
const autoAdvance = typeof data.autoAdvanceAfterSubmit === 'boolean'
? data.autoAdvanceAfterSubmit
: true;
@@ -2141,94 +4037,116 @@
window.practiceConfig.suite = {};
}
window.practiceConfig.suite.autoAdvanceAfterSubmit = autoAdvance;
- try {
- if (window.localStorage) {
- window.localStorage.setItem('suite_auto_advance_after_submit', String(autoAdvance));
- }
- } catch (_) {
- // ignore storage write failures
- }
+ await window.AppData.preferences.patchSuite({ autoAdvanceAfterSubmit: autoAdvance });
break;
}
case 'VOCAB_HIGHLIGHT_SAVE':
- if (typeof window.saveReadingHighlightVocab === 'function') {
- await window.saveReadingHighlightVocab(data);
+ if (!data || !String(data.requestId || '').trim()) {
+ this._reportExamMessageRejected(examId, type, 'missing-request-id', event);
+ break;
+ }
+ try {
+ const saved = typeof window.saveReadingHighlightVocab === 'function'
+ ? await window.saveReadingHighlightVocab(data)
+ : null;
+ this._announceVocabHighlightOutcome(
+ examId,
+ data,
+ sourceWindow || expectedWindow,
+ Boolean(saved),
+ saved ? '' : 'save_failed'
+ );
+ } catch (saveError) {
+ console.warn('[VocabStore] 阅读高亮生词保存异常:', saveError);
+ this._announceVocabHighlightOutcome(
+ examId,
+ data,
+ sourceWindow || expectedWindow,
+ false,
+ 'save_failed'
+ );
}
break;
case 'REVIEW_NAVIGATE':
- if (data && typeof this.handleSuiteReviewNavigate === 'function') {
- const activeSuiteId = this.currentSuiteSession && this.currentSuiteSession.id
- ? String(this.currentSuiteSession.id)
- : '';
- const windowSuiteId = windowInfo && windowInfo.suiteSessionId
- ? String(windowInfo.suiteSessionId)
- : '';
- const isExplicitSuiteNavigate = data.suiteReviewMode === true;
- const isActiveSuiteWindow = Boolean(windowSuiteId && activeSuiteId && windowSuiteId === activeSuiteId);
- if (isExplicitSuiteNavigate || isActiveSuiteWindow) {
- const payloadExamId = data.examId != null ? String(data.examId).trim() : '';
+ if (ownsPayloadSuiteProtocol) {
+ if (data && typeof this.handleSuiteReviewNavigate === 'function') {
+ const suiteReviewExamId = data.examId != null ? String(data.examId).trim() : '';
const hasPayloadExamInActiveSuite = Boolean(
- payloadExamId
+ suiteReviewExamId
&& this.currentSuiteSession
&& Array.isArray(this.currentSuiteSession.sequence)
- && this.currentSuiteSession.sequence.some(item => item && item.examId === payloadExamId)
+ && this.currentSuiteSession.sequence.some(item => item && item.examId === suiteReviewExamId)
+ );
+ const suiteReviewRoutedExamId = hasPayloadExamInActiveSuite ? suiteReviewExamId : examId;
+ await this.handleSuiteReviewNavigate(
+ suiteReviewRoutedExamId,
+ data,
+ sourceWindow || expectedWindow,
+ {
+ expectedRegistration: eventRegistration,
+ commitGuard: () => this._isExamSessionRegistrationCurrent(
+ examId,
+ eventRegistration
+ )
+ }
);
- const routedExamId = hasPayloadExamInActiveSuite ? payloadExamId : examId;
- const handledSuiteReview = await this.handleSuiteReviewNavigate(routedExamId, data, sourceWindow || expectedWindow);
- if (handledSuiteReview) {
- break;
- }
}
+ break;
}
- await this.handleReviewReplayNavigate(examId, data, sourceWindow || expectedWindow);
+ await this.handleReviewReplayNavigate(
+ examId,
+ data,
+ sourceWindow || expectedWindow,
+ {
+ expectedRegistration: eventRegistration
+ }
+ );
break;
case 'SIMULATION_DRAFT_SYNC':
- if (this.currentSuiteSession && data && data.draft) {
- const incomingUpdatedAt = Number(data.draftUpdatedAt ?? data.draft.updatedAt);
- const previousDraft = this.currentSuiteSession.draftsByExam[routedExamId] || null;
- const previousUpdatedAt = Number(previousDraft && previousDraft.updatedAt);
- const shouldAcceptDraft = !(
- previousDraft
- && Number.isFinite(previousUpdatedAt)
- && Number.isFinite(incomingUpdatedAt)
- && incomingUpdatedAt < previousUpdatedAt
- );
- if (shouldAcceptDraft) {
- this.currentSuiteSession.draftsByExam[routedExamId] = {
- ...data.draft,
- updatedAt: Number.isFinite(incomingUpdatedAt) ? incomingUpdatedAt : Date.now()
- };
- }
- if (Number.isFinite(Number(data.elapsed))) {
- if (typeof this._deriveSuiteExamElapsedSeconds === 'function') {
- this.currentSuiteSession.elapsedByExam[routedExamId] = this._deriveSuiteExamElapsedSeconds(
- this.currentSuiteSession,
- routedExamId,
- Number(data.elapsed)
- );
- } else {
- this.currentSuiteSession.elapsedByExam[routedExamId] = Math.max(0, Number(data.elapsed));
+ if (ownsPayloadSuiteProtocol && typeof this._handleSuiteDraftSync === 'function') {
+ await this._handleSuiteDraftSync(
+ routedExamId,
+ data,
+ windowInfo,
+ sourceWindow || expectedWindow,
+ {
+ expectedRegistration: eventRegistration,
+ commitGuard: () => this._isExamSessionRegistrationCurrent(
+ examId,
+ eventRegistration
+ )
}
- }
- if (typeof this._mirrorSessionToStorage === 'function') {
- this._mirrorSessionToStorage(this.currentSuiteSession);
- }
+ );
}
break;
+ case 'READING_DRAFT_SYNC':
+ await this._queueReadingDraftSync(routedExamId, data, windowInfo);
+ break;
+ case 'READING_ANNOTATION_SYNC':
+ await this._queueReadingAnnotationSync(routedExamId, data, windowInfo);
+ break;
case 'SIMULATION_NAVIGATE':
- if (typeof this._handleSimulationNavigate === 'function') {
- await this._handleSimulationNavigate(routedExamId, data, sourceWindow || expectedWindow);
+ if (ownsPayloadSuiteProtocol && typeof this._handleSimulationNavigate === 'function') {
+ await this._handleSimulationNavigate(
+ routedExamId,
+ data,
+ sourceWindow || expectedWindow,
+ {
+ expectedRegistration: eventRegistration,
+ commitGuard: () => this._isExamSessionRegistrationCurrent(
+ examId,
+ eventRegistration
+ )
+ }
+ );
}
break;
case 'SIMULATION_ACTIVE_EXAM_CHANGE':
if (
this.currentSuiteSession
+ && this.currentSuiteSession.status === 'active'
+ && ownsPayloadSuiteProtocol
&& isPayloadExamInActiveSuite
- && (
- !payloadSuiteSessionId
- || !activeSuiteSessionId
- || payloadSuiteSessionId === activeSuiteSessionId
- )
) {
const activeIndex = activeSuiteSequence.findIndex(item => item && String(item.examId) === routedExamId);
this.currentSuiteSession.activeExamId = routedExamId;
@@ -2239,6 +4157,10 @@
if (sourceWindow && !sourceWindow.closed) {
this.currentSuiteSession.windowRef = sourceWindow;
}
+ if (typeof this._buildSuiteWindowBinding === 'function') {
+ const binding = this._buildSuiteWindowBinding(this.currentSuiteSession);
+ if (binding) this.currentSuiteSession.windowBinding = binding;
+ }
if (Number.isFinite(Number(data.elapsed))) {
if (typeof this._deriveSuiteExamElapsedSeconds === 'function') {
this.currentSuiteSession.elapsedByExam[routedExamId] = this._deriveSuiteExamElapsedSeconds(
@@ -2256,10 +4178,20 @@
}
break;
case 'SIMULATION_SUBMIT':
- if (windowInfo && windowInfo.reviewMode) {
+ if (!ownsPayloadSuiteProtocol || (windowInfo && windowInfo.reviewMode)) {
break;
}
- await this.handlePracticeComplete(routedExamId, data, sourceWindow || expectedWindow);
+ await this.handlePracticeComplete(
+ routedExamId,
+ data,
+ sourceWindow || expectedWindow,
+ {
+ expectedRegistration: routedExamId === examId
+ ? eventRegistration
+ : this._captureExamSessionRegistration(routedExamId),
+ launchOwnership: null
+ }
+ );
break;
default:
}
@@ -2284,17 +4216,14 @@
}
this.messageHandlers.set(examId, messageHandler);
- // 向题目窗口发送初始化消息(兼容 0.2 增强器监听的 INIT_SESSION)
- const sendInitEnvelope = (targetWindow) => {
- try {
- const windowInfo = this.ensureExamWindowSession(examId, targetWindow);
- const initPayload = this._buildExamInitPayload(examId, windowInfo);
- targetWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*');
- targetWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*');
- } catch (initError) {
- console.warn('[App] 发送初始化消息失败:', initError);
- }
- };
+ const setupRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const sendInitEnvelope = (targetWindow, registration = setupRegistration) => this._sendExamInitEnvelope(
+ examId,
+ targetWindow,
+ {},
+ registration ? { expectedRegistration: registration, launchOwnership } : {}
+ );
const tryAttachInitHandler = (targetWindow) => {
if (!targetWindow || isFileProtocol) {
@@ -2333,120 +4262,62 @@
/**
* 与练习页建立握手(重复发送 INIT_SESSION,直到收到 SESSION_READY)
*/
- startExamHandshake(examWindow, examId) {
+ startExamHandshake(examWindow, examId, options = {}) {
if (!this._handshakeTimers) this._handshakeTimers = new Map();
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsHandshake = () => !expectedRegistration || (
+ launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ expectedRegistration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ );
- // 避免重复握手
- if (this._handshakeTimers.has(examId)) return;
-
- let attempts = 0;
- const maxAttempts = 30; // ~9s
- const tick = () => {
- if (examWindow && !examWindow.closed) {
- try {
- const windowInfo = this.ensureExamWindowSession(examId, examWindow);
- const initPayload = this._buildExamInitPayload(examId, windowInfo);
- windowInfo.handshakeAttempts = attempts + 1;
- windowInfo.lastHandshakeAt = Date.now();
- this.examWindows && this.examWindows.set(examId, windowInfo);
- // 直接发送两种事件名,确保增强器任何实现都能收到
- examWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*');
- examWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*');
- } catch (_) { /* 忽略 */ }
- }
- attempts++;
- if (attempts >= maxAttempts) {
- clearInterval(timer);
- this._handshakeTimers.delete(examId);
- console.warn('[App] 握手超时,练习页可能未加载增强器');
- }
- };
- const timer = setInterval(tick, 300);
- this._handshakeTimers.set(examId, timer);
- // 立即发送一次
- tick();
- },
-
- /**
- * 创建降级记录器
- */
- createFallbackRecorder() {
- return {
- handleRealPracticeData: async (examId, realData) => {
- try {
- // 获取题目信息
- const exam = await findExamDefinition(examId);
-
- if (!exam) {
- console.error('[FallbackRecorder] 无法找到题目信息:', examId);
- return null;
- }
-
- const api = window.PracticeRecordAPI;
- if (!api || typeof api.saveCompletion !== 'function') {
- throw new Error('统一练习记录 API 未就绪');
- }
- const practiceRecord = await api.saveCompletion(realData, {
- examId,
- sessionId: realData && realData.sessionId ? realData.sessionId : null,
- examEntry: exam,
- metadata: {
- examId,
- examTitle: exam.title || realData?.title || '',
- category: exam.category || realData?.category || 'unknown',
- frequency: exam.frequency || realData?.frequency || 'unknown',
- type: exam.type || realData?.type || null
- }
- });
-
- // 检查成就
- if (window.AchievementManager) {
- window.AchievementManager.check(practiceRecord).catch(console.warn);
- }
-
- return practiceRecord;
- } catch (error) {
- console.error('[FallbackRecorder] 保存失败:', error);
- return null;
- }
- },
-
- startSession: (examId) => {
- // 简单的会话管理
- return {
- examId: examId,
- startTime: new Date().toISOString(),
- sessionId: this.generateSessionId(examId),
- status: 'started'
- };
- },
-
- getPracticeRecords: async (filters = {}) => {
- try {
- const records = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function'
- ? await window.PracticeRecordAPI.list()
- : [];
-
- if (Object.keys(filters).length === 0) {
- return records;
- }
-
- return records.filter(record => {
- if (filters.examId && record.examId !== filters.examId) return false;
- if (filters.category && record.category !== filters.category) return false;
- if (filters.startDate && new Date(record.startTime) < new Date(filters.startDate)) return false;
- if (filters.endDate && new Date(record.startTime) > new Date(filters.endDate)) return false;
- if (filters.minAccuracy && record.accuracy < filters.minAccuracy) return false;
- if (filters.maxAccuracy && record.accuracy > filters.maxAccuracy) return false;
+ // 避免重复握手
+ if (this._handshakeTimers.has(examId)) return;
- return true;
- });
- } catch (error) {
- console.error('[FallbackRecorder] 获取记录失败:', error);
- return [];
- }
+ let attempts = 0;
+ const maxAttempts = 30; // ~9s
+ const stopTimer = () => {
+ clearInterval(timer);
+ if (this._handshakeTimers.get(examId) === timer) {
+ this._handshakeTimers.delete(examId);
+ }
+ };
+ const tick = async () => {
+ if (!ownsHandshake()) {
+ stopTimer();
+ return;
+ }
+ if (examWindow && !examWindow.closed) {
+ try {
+ const windowInfo = expectedRegistration
+ ? expectedRegistration.windowInfo
+ : this.ensureExamWindowSession(examId, examWindow);
+ windowInfo.handshakeAttempts = attempts + 1;
+ windowInfo.lastHandshakeAt = Date.now();
+ this.examWindows && this.examWindows.set(examId, windowInfo);
+ await this._sendExamInitEnvelope(
+ examId,
+ examWindow,
+ {},
+ expectedRegistration ? { expectedRegistration, launchOwnership } : {}
+ );
+ } catch (_) { /* 忽略 */ }
+ }
+ attempts++;
+ if (attempts >= maxAttempts) {
+ stopTimer();
+ console.warn('[App] 握手超时,练习页可能未加载增强器');
}
};
+ const timer = setInterval(() => { tick(); }, 300);
+ this._handshakeTimers.set(examId, timer);
+ // 立即发送一次
+ tick();
},
// ExamBrowser组件已移除,使用内置的题目列表功能
@@ -2568,7 +4439,15 @@
},
generateWindowSessionToken(examId) {
- const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
+ const cryptoApi = global.crypto;
+ if (!cryptoApi || typeof cryptoApi.getRandomValues !== 'function') {
+ throw new Error('Secure random generator is required for window session tokens');
+ }
+ const bytes = new Uint8Array(24);
+ cryptoApi.getRandomValues(bytes);
+ const suffix = Array.from(bytes)
+ .map(byte => byte.toString(16).padStart(2, '0'))
+ .join('');
const normalizedExamId = typeof examId === 'string'
? examId.trim().replace(/\s+/g, '-')
: (examId != null ? String(examId).trim().replace(/\s+/g, '-') : '');
@@ -2971,6 +4850,27 @@
: (Array.isArray(entry.realData?.highlights)
? entry.realData.highlights.slice()
: (Array.isArray(record.realData?.highlights) ? record.realData.highlights.slice() : [])));
+ const noteText = typeof entry.noteText === 'string'
+ ? entry.noteText
+ : (typeof entry.rawData?.noteText === 'string'
+ ? entry.rawData.noteText
+ : (typeof entry.realData?.noteText === 'string'
+ ? entry.realData.noteText
+ : (typeof record.realData?.noteText === 'string' ? record.realData.noteText : '')));
+ const notes = Array.isArray(entry.notes)
+ ? this._cloneReviewData(entry.notes)
+ : (Array.isArray(entry.rawData?.notes)
+ ? this._cloneReviewData(entry.rawData.notes)
+ : (Array.isArray(entry.realData?.notes)
+ ? this._cloneReviewData(entry.realData.notes)
+ : (Array.isArray(record.realData?.notes) ? this._cloneReviewData(record.realData.notes) : [])));
+ const noteOutlines = Array.isArray(entry.noteOutlines)
+ ? this._cloneReviewData(entry.noteOutlines)
+ : (Array.isArray(entry.rawData?.noteOutlines)
+ ? this._cloneReviewData(entry.rawData.noteOutlines)
+ : (Array.isArray(entry.realData?.noteOutlines)
+ ? this._cloneReviewData(entry.realData.noteOutlines)
+ : (Array.isArray(record.realData?.noteOutlines) ? this._cloneReviewData(record.realData.noteOutlines) : [])));
const scrollY = Number.isFinite(Number(entry.scrollY))
? Number(entry.scrollY)
: (Number.isFinite(Number(entry.rawData?.scrollY))
@@ -3004,6 +4904,9 @@
? entryMetadata.markedQuestions.slice()
: (Array.isArray(recordMetadata.markedQuestions) ? recordMetadata.markedQuestions.slice() : [])),
highlights,
+ noteText,
+ notes,
+ noteOutlines,
scrollY,
metadata: mergedMetadata
};
@@ -3020,19 +4923,638 @@
return this.reviewReplaySessions;
},
+ async _resolveReviewExamDefinition(entry) {
+ if (!entry || typeof entry !== 'object' || !entry.examId) {
+ throw new Error('历史记录缺少题目标识');
+ }
+ if (typeof window.resolveExamForPracticeRecord !== 'function') {
+ throw new Error('历史记录题库解析器不可用');
+ }
+ const exam = await window.resolveExamForPracticeRecord(entry);
+ if (exam) return exam;
+ // resolveExamForPracticeRecord 在记录缺 provenance 时已回退到当前活动题库解析
+ // (见 libraryManager.resolveIndexForRecord)。走到这里说明 examId 在可解析的题库中
+ // 确实不存在——统一按“题目不可用”处理,不再因缺少 libraryConfigurationId 而拒绝回放,
+ // 那会误伤所有 v1 迁移来、迁移时无法唯一判定来源的旧记录。
+ throw new Error('该记录对应的题目在当前题库中不存在,可能题库已被删除或切换');
+ },
+
_buildReviewSession(record) {
const entries = this._buildReviewReplayEntriesFromRecord(record);
const validEntries = entries.filter((entry) => entry && entry.examId);
if (validEntries.length === 0) {
return null;
}
- return {
- sessionId: `review_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
- entries: validEntries,
- currentIndex: 0,
- windowRef: null,
- readOnly: true
- };
+ return {
+ sessionId: `review_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
+ recordId: record && record.id != null ? String(record.id) : '',
+ entries: validEntries,
+ currentIndex: 0,
+ windowRef: null,
+ readOnly: true
+ };
+ },
+
+ _cloneReadingDraftValue(value) {
+ if (value == null) {
+ return value;
+ }
+ try {
+ return JSON.parse(JSON.stringify(value));
+ } catch (_) {
+ if (Array.isArray(value)) {
+ return value.slice();
+ }
+ if (value && typeof value === 'object') {
+ return Object.assign({}, value);
+ }
+ return value;
+ }
+ },
+
+ _readingDraftId(examId, libraryConfigurationId = null) {
+ const normalizedExamId = String(examId || '').trim();
+ const normalizedConfigurationId = libraryConfigurationId == null
+ ? ''
+ : String(libraryConfigurationId).trim();
+ return normalizedConfigurationId
+ ? `reading-draft:${normalizedExamId}:${normalizedConfigurationId}`
+ : `reading-draft:${normalizedExamId}`;
+ },
+
+ _buildReadingDraftSnapshot(examId, data = {}, windowInfo = null) {
+ const source = data && data.draft && typeof data.draft === 'object' && !Array.isArray(data.draft)
+ ? data.draft
+ : (data && typeof data === 'object' ? data : {});
+ const answers = source.answers && typeof source.answers === 'object' && !Array.isArray(source.answers)
+ ? this._cloneReadingDraftValue(source.answers)
+ : {};
+ const highlights = Array.isArray(source.highlights) ? this._cloneReadingDraftValue(source.highlights) : [];
+ const notes = Array.isArray(source.notes) ? this._cloneReadingDraftValue(source.notes) : [];
+ const noteOutlines = Array.isArray(source.noteOutlines) ? this._cloneReadingDraftValue(source.noteOutlines) : [];
+ const markedQuestions = Array.isArray(source.markedQuestions)
+ ? this._cloneReadingDraftValue(source.markedQuestions)
+ : [];
+ const noteText = typeof source.noteText === 'string' ? source.noteText : '';
+ const scrollY = Number.isFinite(Number(source.scrollY)) ? Math.max(0, Number(source.scrollY)) : 0;
+ const updatedAt = Number(data.draftUpdatedAt ?? source.updatedAt);
+ const sessionId = data.sessionId != null
+ ? String(data.sessionId)
+ : (windowInfo && windowInfo.expectedSessionId ? String(windowInfo.expectedSessionId) : '');
+ const libraryConfigurationId = this._readLaunchLibraryConfigurationId(examId, windowInfo);
+ return {
+ id: this._readingDraftId(examId, libraryConfigurationId),
+ examId: String(examId),
+ libraryConfigurationId: libraryConfigurationId == null ? null : String(libraryConfigurationId),
+ sessionId,
+ answers,
+ highlights,
+ notes,
+ noteOutlines,
+ markedQuestions,
+ noteText,
+ scrollY,
+ updatedAt: Number.isFinite(updatedAt) ? updatedAt : Date.now(),
+ status: 'in_progress',
+ kind: 'reading_draft'
+ };
+ },
+
+ async _readReadingDraftStore() {
+ const drafts = await window.AppData.recovery.listDrafts();
+ const store = {};
+ (Array.isArray(drafts) ? drafts : []).forEach((draft) => {
+ if (draft && draft.kind === 'reading_draft' && draft.examId) {
+ const id = draft.id || this._readingDraftId(draft.examId, draft.libraryConfigurationId);
+ store[String(id)] = draft;
+ }
+ });
+ return store;
+ },
+
+ async _writeReadingDraftStore(store, changedDraft = null, options = {}) {
+ try {
+ if (changedDraft) {
+ const saveOptions = typeof options.commitGuard === 'function'
+ ? { commitGuard: options.commitGuard }
+ : {};
+ const receipt = await window.AppData.recovery.saveDraft(changedDraft, saveOptions);
+ if (!receipt || receipt.committed !== true) {
+ return false;
+ }
+ }
+ const drafts = await window.AppData.recovery.listDrafts();
+ const cutoff = Date.now() - (7 * 24 * 60 * 60 * 1000);
+ for (const draft of Array.isArray(drafts) ? drafts : []) {
+ const numericUpdatedAt = Number(draft && draft.updatedAt);
+ const draftUpdatedAt = Number.isFinite(numericUpdatedAt)
+ ? numericUpdatedAt
+ : Date.parse(draft && draft.updatedAt);
+ if (
+ draft
+ && draft.kind === 'reading_draft'
+ && draft.id !== changedDraft?.id
+ && (!Number.isFinite(draftUpdatedAt) || draftUpdatedAt < cutoff)
+ ) {
+ await window.AppData.recovery.discardDraft(draft.id);
+ }
+ }
+ return true;
+ } catch (error) {
+ console.warn('[ReadingDraftGateway] 写入草稿失败:', error);
+ return false;
+ }
+ },
+
+ async handleReadingDraftSync(examId, data = {}, windowInfo = null) {
+ const info = windowInfo || (this.examWindows && this.examWindows.get(examId));
+ if (!info || info.reviewMode) {
+ return false;
+ }
+ if (windowInfo && this.examWindows && this.examWindows.get(examId) !== info) {
+ return false;
+ }
+ if (String(info.practiceMode || '').toLowerCase() === 'memorize') {
+ return false;
+ }
+ // 用“本窗口的 suite 绑定”判断是否套题草稿,而不是看全局 currentSuiteSession:
+ // 否则当任意套题会话仍活跃时,普通独立阅读窗口(windowInfo.suiteSessionId 为空)
+ // 的草稿也会被拒绝,关闭该窗口会丢失该题的在做答案/笔记。
+ if (info.suiteSessionId) {
+ return typeof this._handleSuiteDraftSync === 'function'
+ ? this._handleSuiteDraftSync(examId, data, info, info.window)
+ : false;
+ }
+ const expectedSessionId = info.expectedSessionId ? String(info.expectedSessionId) : '';
+ const payloadSessionId = data && data.sessionId != null ? String(data.sessionId) : '';
+ if (!expectedSessionId || !payloadSessionId || payloadSessionId !== expectedSessionId) {
+ return false;
+ }
+ const expectedRegistration = this._captureExamSessionRegistration(examId, info);
+ if (!expectedRegistration) {
+ return false;
+ }
+ const payloadGeneration = Number(data && data.windowSessionGeneration);
+ if (Number.isInteger(payloadGeneration)
+ && payloadGeneration !== expectedRegistration.sessionGeneration) {
+ return false;
+ }
+ const isExactRegistration = () => (
+ this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ );
+ const isLiveRegistration = () => (
+ isExactRegistration()
+ && (!expectedRegistration.window || !expectedRegistration.window.closed)
+ );
+ // Reject already-closed/stale sources at the gateway. Once an accepted
+ // pagehide draft reaches the transaction, ownership (not liveness) is the
+ // commit condition so closing the page cannot discard its final answers.
+ if (!isLiveRegistration()) {
+ return false;
+ }
+ const draft = this._buildReadingDraftSnapshot(examId, data, info);
+ if (!draft.sessionId) {
+ return false;
+ }
+ // 必须在写队列里重新读取最新 store 再合并,否则并发不同 exam 的 write 会互相覆盖、
+ // 后写者会丢掉前者的草稿(整个 map 是同一个存储 key,read-modify-write 非原子)。
+ const store = await this._readReadingDraftStore();
+ if (!isExactRegistration()) {
+ return false;
+ }
+ const previous = store[String(draft.id)] || null;
+ const previousNumericUpdatedAt = Number(previous && previous.updatedAt);
+ const previousUpdatedAt = Number.isFinite(previousNumericUpdatedAt)
+ ? previousNumericUpdatedAt
+ : Date.parse(previous && previous.updatedAt);
+ const nextNumericUpdatedAt = Number(draft.updatedAt);
+ const nextUpdatedAt = Number.isFinite(nextNumericUpdatedAt)
+ ? nextNumericUpdatedAt
+ : Date.parse(draft.updatedAt);
+ if (
+ previous
+ && previous.sessionId === draft.sessionId
+ && Number.isFinite(previousUpdatedAt)
+ && Number.isFinite(nextUpdatedAt)
+ && nextUpdatedAt < previousUpdatedAt
+ ) {
+ return false;
+ }
+ store[String(draft.id)] = draft;
+ if (!isExactRegistration()) {
+ return false;
+ }
+ if (!await this._writeReadingDraftStore(store, draft, { commitGuard: isExactRegistration })) {
+ return false;
+ }
+ if (!isExactRegistration()) {
+ return false;
+ }
+ info.lastReadingDraft = draft;
+ info.lastReadingDraftAt = Date.now();
+ if (this.examWindows) {
+ this.examWindows.set(examId, info);
+ }
+ return true;
+ },
+
+ async _queueReadingDraftSync(examId, data = {}, windowInfo = null) {
+ // 同一宿主窗口内保持事件顺序;跨标签并发由 AppData/kernel CAS 处理。
+ if (!this._readingDraftStoreQueue || typeof this._readingDraftStoreQueue.then !== 'function') {
+ this._readingDraftStoreQueue = Promise.resolve();
+ }
+ const queuedRegistration = windowInfo
+ ? this._captureExamSessionRegistration(examId, windowInfo)
+ : null;
+ const queued = this._readingDraftStoreQueue
+ .catch(() => undefined)
+ .then(() => {
+ if (queuedRegistration
+ && !this._isExamSessionRegistrationCurrent(examId, queuedRegistration)) {
+ return false;
+ }
+ return this.handleReadingDraftSync(examId, data, windowInfo);
+ });
+ this._readingDraftStoreQueue = queued.catch(() => undefined).then(() => {
+ if (this._readingDraftStoreQueue === queued) {
+ this._readingDraftStoreQueue = Promise.resolve();
+ }
+ });
+ return queued;
+ },
+
+ async getReadingDraftForExam(examId, options = {}) {
+ const normalizedExamId = examId != null ? String(examId).trim() : '';
+ if (!normalizedExamId) {
+ return null;
+ }
+ const libraryConfigurationId = Object.prototype.hasOwnProperty.call(options, 'libraryConfigurationId')
+ ? options.libraryConfigurationId
+ : this._readLaunchLibraryConfigurationId(normalizedExamId, options.windowInfo);
+ const store = await this._readReadingDraftStore();
+ const draft = store[this._readingDraftId(normalizedExamId, libraryConfigurationId)] || null;
+ if (!draft || typeof draft !== 'object') {
+ return null;
+ }
+ // 仅用于“恢复未完成草稿”:跨开窗/重启时 expectedSessionId 会重新生成,
+ // 旧 draft 的 sessionId 必然与之不同;读取不写入任何数据,无跨会话覆盖风险,
+ // 因此这里不再用 sessionId 拦截,把旧草稿透传给调用方,由其在新 session 里继续答题。
+ // 写/清路径仍保留严格校验,避免跨会话误覆盖或误删。
+ const cloned = this._cloneReadingDraftValue(draft);
+ const expectedSessionId = options.sessionId != null ? String(options.sessionId) : '';
+ if (expectedSessionId && String(cloned.sessionId || '') !== expectedSessionId) {
+ cloned.sessionId = expectedSessionId;
+ }
+ return cloned;
+ },
+
+ async clearReadingDraftForExam(examId, options = {}) {
+ const normalizedExamId = examId != null ? String(examId).trim() : '';
+ if (!normalizedExamId) {
+ return false;
+ }
+ const commitGuard = options && typeof options.commitGuard === 'function'
+ ? options.commitGuard
+ : null;
+ if (commitGuard && commitGuard() !== true) {
+ return false;
+ }
+ const libraryConfigurationId = Object.prototype.hasOwnProperty.call(options, 'libraryConfigurationId')
+ ? options.libraryConfigurationId
+ : this._readLaunchLibraryConfigurationId(normalizedExamId, options.windowInfo);
+ const run = async () => {
+ const store = await this._readReadingDraftStore();
+ if (commitGuard && commitGuard() !== true) {
+ return false;
+ }
+ const existing = store[this._readingDraftId(normalizedExamId, libraryConfigurationId)] || null;
+ if (!existing) {
+ return false;
+ }
+ const expectedSessionId = options.sessionId != null ? String(options.sessionId) : '';
+ // completion 路径用 acceptResumeSessionId=true 调用:若用户是在恢复的草稿上继续答题,
+ // 存档里仍是恢复前的旧 sessionId,而完成事件带的是新 session id;
+ // 这里已由完成事件本身做过严格的 message/session 校验,可直接删除该题草稿,
+ // 避免已提交的答案在重开 SAME 题时被旧草稿复活。
+ if (expectedSessionId && String(existing.sessionId || '') !== expectedSessionId && !options.acceptResumeSessionId) {
+ return false;
+ }
+ if (commitGuard && commitGuard() !== true) {
+ return false;
+ }
+ const discardOptions = commitGuard ? { commitGuard } : {};
+ const revision = Number(existing && existing.revision);
+ if (Number.isSafeInteger(revision) && revision >= 0) {
+ discardOptions.expectedEntityRevision = revision;
+ }
+ const receipt = await window.AppData.recovery.discardDraft(existing.id, discardOptions);
+ return !receipt || receipt.committed !== false;
+ };
+ // 与当前窗口的 draft sync 顺序一致,物理并发控制仍由 kernel 负责。
+ if (!this._readingDraftStoreQueue || typeof this._readingDraftStoreQueue.then !== 'function') {
+ this._readingDraftStoreQueue = Promise.resolve();
+ }
+ const queued = this._readingDraftStoreQueue
+ .catch(() => undefined)
+ .then(run);
+ this._readingDraftStoreQueue = queued.catch(() => undefined).then(() => {
+ if (this._readingDraftStoreQueue === queued) {
+ this._readingDraftStoreQueue = Promise.resolve();
+ }
+ });
+ return queued;
+ },
+
+ async _isPracticeCompletionPersisted(record) {
+ const identityFields = ['id', 'examId', 'sessionId'];
+ const completionTime = (value) => value && (
+ value.endTime || value.completedAt || value.timestamp || value.date
+ );
+ if (!record || typeof record !== 'object'
+ || identityFields.some((key) => record[key] == null || String(record[key]).trim() === '')
+ || !completionTime(record)) {
+ return false;
+ }
+ try {
+ const persisted = await window.AppData.practice.get(String(record.id), { projection: 'light' });
+ if (!persisted || typeof persisted !== 'object') {
+ return false;
+ }
+ return identityFields.every((key) => String(persisted[key] ?? '') === String(record[key]))
+ && String(completionTime(persisted) || '') === String(completionTime(record));
+ } catch (error) {
+ console.warn('[ReadingDraftGateway] 无法确认完成记录已落库,保留草稿:', error);
+ return false;
+ }
+ },
+
+ async handleReadingAnnotationSync(examId, data = {}, windowInfo = null) {
+ const info = windowInfo || (this.examWindows && this.examWindows.get(examId));
+ if (!info) {
+ return false;
+ }
+ // 两条来源均可落库标注:①review 回放态,按 reviewSessionId 解析 recordId;
+ // ②单篇阅读 final-submit 后的结果页,按 windowInfo.submittedRecordId 直连
+ // 已存档的练习记录。两者都需要 payload.recordId 与解析出的 recordId 严格匹配。
+ let recordId = '';
+ if (info.reviewMode && info.reviewSessionId) {
+ const reviewSessionId = String(info.reviewSessionId);
+ const sessions = this._ensureReviewReplayStore();
+ const reviewSession = sessions.get(reviewSessionId);
+ if (!reviewSession || !reviewSession.recordId) {
+ return false;
+ }
+ recordId = String(reviewSession.recordId);
+ } else if (info.submittedRecordId) {
+ recordId = String(info.submittedRecordId);
+ } else {
+ return false;
+ }
+ if (data.recordId == null || String(data.recordId) !== recordId) {
+ return false;
+ }
+
+ const source = data.annotations && typeof data.annotations === 'object' && !Array.isArray(data.annotations)
+ ? data.annotations
+ : data;
+ const annotationPatch = {};
+ ['highlights', 'notes', 'noteOutlines', 'markedQuestions'].forEach((key) => {
+ if (Object.prototype.hasOwnProperty.call(source, key) && Array.isArray(source[key])) {
+ annotationPatch[key] = this._cloneReviewData(source[key]);
+ }
+ });
+ if (Object.prototype.hasOwnProperty.call(source, 'noteText') && typeof source.noteText === 'string') {
+ annotationPatch.noteText = source.noteText;
+ }
+ if (Object.prototype.hasOwnProperty.call(source, 'scrollY')) {
+ const scrollY = Number(source.scrollY);
+ if (Number.isFinite(scrollY)) {
+ annotationPatch.scrollY = Math.max(0, scrollY);
+ }
+ }
+ if (Object.keys(annotationPatch).length === 0) {
+ return false;
+ }
+
+ const normalizedExamId = String(examId);
+ await window.AppData.practice.updateAnnotations({
+ recordId,
+ examId: normalizedExamId,
+ patch: annotationPatch,
+ operationId: data.operationId || data.messageId || undefined
+ });
+
+ // 只有 review 回放分支需要同时更新内存中的 reviewSession.entries;
+ // 单篇 submitted 直连已存档记录的分支不持有 reviewSession,跳过。
+ if (info.reviewMode && info.reviewSessionId) {
+ const reviewSessionId = String(info.reviewSessionId);
+ const sessions = this._ensureReviewReplayStore();
+ const reviewSession = sessions.get(reviewSessionId);
+ if (reviewSession && Array.isArray(reviewSession.entries)) {
+ reviewSession.entries = reviewSession.entries.map((entry) => (
+ entry && String(entry.examId) === normalizedExamId
+ ? Object.assign({}, entry, annotationPatch)
+ : entry
+ ));
+ sessions.set(reviewSessionId, reviewSession);
+ }
+ }
+ return true;
+ },
+
+ async _queueReadingAnnotationSync(examId, data = {}, windowInfo = null) {
+ return this.handleReadingAnnotationSync(examId, data, windowInfo);
+ },
+
+ // 单篇阅读 final-submit 落库成功后,把已存档 recordId 写入 windowInfo 并
+ // postMessage 回结果页,使结果页笔记改动能以 READING_ANNOTATION_SYNC
+ // 持久化回该练习记录。套题流程不会走到这里(已在 handleSuitePracticeComplete 早退)。
+ _announceSubmittedReadingRecord(examId, savedRecord, completionData, sourceWindow, options = {}) {
+ try {
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsAnnouncement = () => !expectedRegistration || (
+ expectedRegistration.window === sourceWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(examId, launchOwnership, expectedRegistration)
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ );
+ if (!ownsAnnouncement()) {
+ return false;
+ }
+ const recordId = savedRecord && savedRecord.id != null ? String(savedRecord.id).trim() : '';
+ if (!recordId) {
+ return false;
+ }
+ const sessionId = completionData && completionData.sessionId != null
+ ? String(completionData.sessionId)
+ : '';
+ const targetWindow = (sourceWindow && !sourceWindow.closed) ? sourceWindow : null;
+ if (!targetWindow) {
+ return false;
+ }
+ if (!ownsAnnouncement()) {
+ return false;
+ }
+ const windowInfo = expectedRegistration
+ ? expectedRegistration.windowInfo
+ : this.ensureExamWindowSession(examId, targetWindow);
+ if (windowInfo) {
+ windowInfo.submittedRecordId = recordId;
+ windowInfo.window = targetWindow;
+ windowInfo.status = 'completed';
+ windowInfo.completedAt = windowInfo.completedAt || Date.now();
+ this.examWindows && this.examWindows.set(examId, windowInfo);
+ }
+ if (!ownsAnnouncement()) {
+ return false;
+ }
+ this._postExamMessage(examId, targetWindow, 'PRACTICE_RECORD_SAVED', {
+ examId,
+ recordId,
+ sessionId: sessionId || null
+ }, options);
+ return true;
+ } catch (_) {
+ // annotation persistence hint is best-effort
+ return false;
+ }
+ },
+
+ _announcePracticeSubmitOutcome(examId, completionData, sourceWindow, succeeded, details = {}, options = {}) {
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsAnnouncement = () => !expectedRegistration || (
+ expectedRegistration.window === sourceWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(examId, launchOwnership, expectedRegistration)
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ );
+ if (!ownsAnnouncement()) {
+ return false;
+ }
+ const submissionId = completionData && completionData.submissionId != null
+ ? String(completionData.submissionId).trim()
+ : '';
+ const sessionId = completionData && completionData.sessionId != null
+ ? String(completionData.sessionId).trim()
+ : '';
+ const targetWindow = sourceWindow && !sourceWindow.closed ? sourceWindow : null;
+ if (!submissionId || !sessionId || !targetWindow) {
+ return false;
+ }
+ try {
+ const type = succeeded ? 'PRACTICE_SUBMIT_ACK' : 'PRACTICE_SUBMIT_FAILED';
+ const payload = {
+ examId,
+ submissionId,
+ sessionId,
+ suiteSessionId: completionData && completionData.suiteSessionId
+ ? String(completionData.suiteSessionId)
+ : null,
+ errorCode: succeeded ? null : String(details.errorCode || 'save_failed')
+ };
+ if (!ownsAnnouncement()) {
+ return false;
+ }
+ const delivered = this._postExamMessage(examId, targetWindow, type, payload, options);
+ if (succeeded) {
+ if (!ownsAnnouncement()) {
+ return false;
+ }
+ const windowInfo = expectedRegistration
+ ? expectedRegistration.windowInfo
+ : this.ensureExamWindowSession(examId, targetWindow);
+ const receiptKey = `${sessionId}:${String(completionData?.suiteId || '').trim()}:${submissionId}`;
+ const receipts = windowInfo.practiceSubmitReceipts && typeof windowInfo.practiceSubmitReceipts === 'object'
+ ? windowInfo.practiceSubmitReceipts
+ : {};
+ receipts[receiptKey] = Object.assign({}, payload, { examId, succeeded: true });
+ const keys = Object.keys(receipts);
+ keys.slice(0, Math.max(0, keys.length - 8)).forEach((key) => delete receipts[key]);
+ windowInfo.practiceSubmitReceipts = receipts;
+ this.examWindows && this.examWindows.set(examId, windowInfo);
+ }
+ return delivered;
+ } catch (error) {
+ console.warn('[DataCollection] 提交结果回执发送失败:', error);
+ return false;
+ }
+ },
+
+ _announceVocabHighlightOutcome(examId, requestData, sourceWindow, succeeded, errorCode = '') {
+ const requestId = requestData && requestData.requestId != null
+ ? String(requestData.requestId).trim()
+ : '';
+ const sessionId = requestData && requestData.sessionId != null
+ ? String(requestData.sessionId).trim()
+ : '';
+ const targetWindow = sourceWindow && !sourceWindow.closed ? sourceWindow : null;
+ if (!requestId || !sessionId || !targetWindow) {
+ return false;
+ }
+ return this._postExamMessage(
+ examId,
+ targetWindow,
+ succeeded ? 'VOCAB_HIGHLIGHT_SAVE_ACK' : 'VOCAB_HIGHLIGHT_SAVE_FAILED',
+ {
+ examId,
+ sessionId,
+ requestId,
+ errorCode: succeeded ? null : String(errorCode || 'save_failed')
+ }
+ );
+ },
+
+ _replayPracticeSubmitReceipt(examId, completionData, sourceWindow) {
+ const submissionId = completionData && completionData.submissionId != null
+ ? String(completionData.submissionId).trim()
+ : '';
+ const sessionId = completionData && completionData.sessionId != null
+ ? String(completionData.sessionId).trim()
+ : '';
+ if (!submissionId || !sessionId || !sourceWindow || sourceWindow.closed) {
+ return false;
+ }
+ const windowInfo = this.ensureExamWindowSession(examId, sourceWindow);
+ const receipt = windowInfo.practiceSubmitReceipts
+ && windowInfo.practiceSubmitReceipts[`${sessionId}:${String(completionData?.suiteId || '').trim()}:${submissionId}`];
+ if (!receipt || receipt.succeeded !== true) {
+ return false;
+ }
+ this._announcePracticeSubmitOutcome(examId, completionData, sourceWindow, true);
+ return true;
+ },
+
+ _scheduleSuiteSubmitTeardown(session) {
+ if (!session || typeof this._teardownSuiteSession !== 'function') {
+ return false;
+ }
+ if (!(session._suiteTeardownRegistrations instanceof Map)
+ && typeof this._captureSuiteTeardownRegistrations === 'function') {
+ // Freeze the completed suite's exact binding before the receipt replay delay.
+ session._suiteTeardownRegistrations = this._captureSuiteTeardownRegistrations(session);
+ }
+ if (session.submitReceiptTeardownTimer) {
+ clearTimeout(session.submitReceiptTeardownTimer);
+ }
+ const timer = setTimeout(async () => {
+ let tornDown = false;
+ try {
+ tornDown = await this._teardownSuiteSession(session);
+ } catch (teardownError) {
+ console.warn('[SuitePractice] 提交回执重放窗口结束后清理套题会话失败:', teardownError);
+ }
+ if (!tornDown && session.submitReceiptTeardownTimer === timer) {
+ session.submitReceiptTeardownTimer = null;
+ if (this._isSuiteSessionCurrentOwner(session)) this._scheduleSuiteSubmitTeardown(session);
+ }
+ }, 30000);
+ session.submitReceiptTeardownTimer = timer;
+ if (timer && typeof timer.unref === 'function') {
+ timer.unref();
+ }
+ return true;
},
_bindReviewWindowRef(reviewSessionId, windowRef) {
@@ -3076,14 +5598,15 @@
}
const replayPayload = {
reviewSessionId: session.sessionId,
+ recordId: session.recordId || null,
reviewEntryIndex: safeIndex,
readOnly: session.readOnly !== false,
entry: this._cloneReviewData(entry)
};
const contextPayload = this._buildReviewContextPayload(session, safeIndex);
try {
- targetWindow.postMessage({ type: 'REPLAY_PRACTICE_RECORD', data: replayPayload }, '*');
- targetWindow.postMessage({ type: 'REVIEW_CONTEXT', data: contextPayload }, '*');
+ this._postExamMessage(examId, targetWindow, 'REPLAY_PRACTICE_RECORD', replayPayload);
+ this._postExamMessage(examId, targetWindow, 'REVIEW_CONTEXT', contextPayload);
return true;
} catch (error) {
console.warn('[ReviewReplay] 向题目页发送回放数据失败:', error);
@@ -3121,11 +5644,21 @@
return sent;
},
- async handleReviewReplayNavigate(examId, data = {}, sourceWindow = null) {
+ async handleReviewReplayNavigate(examId, data = {}, sourceWindow = null, options = {}) {
const windowInfo = this.examWindows && this.examWindows.get(examId);
if (!windowInfo || !windowInfo.reviewMode) {
return;
}
+ const expectedRegistration = options && options.expectedRegistration
+ ? options.expectedRegistration
+ : this._captureExamSessionRegistration(examId, windowInfo);
+ const ownsSourceReview = () => Boolean(expectedRegistration) && (
+ expectedRegistration.window === (sourceWindow || windowInfo.window)
+ && this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ );
+ if (!ownsSourceReview()) {
+ return null;
+ }
const sessionId = data.reviewSessionId
? String(data.reviewSessionId)
: (windowInfo.reviewSessionId ? String(windowInfo.reviewSessionId) : '');
@@ -3161,30 +5694,68 @@
return;
}
- session.currentIndex = nextIndex;
- session.windowRef = sourceWindow || windowInfo.window || session.windowRef || null;
- store.set(sessionId, session);
-
if (String(nextEntry.examId) === String(examId)) {
+ if (!ownsSourceReview()) {
+ return null;
+ }
+ session.currentIndex = nextIndex;
+ session.windowRef = sourceWindow || windowInfo.window || session.windowRef || null;
+ store.set(sessionId, session);
windowInfo.reviewEntryIndex = nextIndex;
this.examWindows && this.examWindows.set(examId, windowInfo);
this._sendReviewReplayMessages(examId, session.windowRef, session, nextIndex);
return;
}
- try {
- await this.cleanupExamSession(examId);
- } catch (error) {
- console.warn('[ReviewReplay] 清理旧题目会话失败:', error);
- }
-
- await this.openExam(nextEntry.examId, {
+ const reuseWindow = sourceWindow || windowInfo.window || session.windowRef || null;
+ const launchOptions = {
reviewMode: true,
readOnly: true,
reviewSessionId: sessionId,
reviewEntryIndex: nextIndex,
- reuseWindow: session.windowRef || null
+ reuseWindow,
+ requireRecordProvenance: true
+ };
+ // Resolve record provenance while reserving only the target exam/name. Do
+ // not claim the installed source WindowProxy until the resolver succeeds;
+ // a failed replay navigation must leave the current review page usable.
+ const targetLaunchOwnership = this._beginExamLaunchOwnership(nextEntry.examId, {
+ ...launchOptions,
+ reuseWindow: null
+ });
+ let examDefinition;
+ try {
+ examDefinition = await this._resolveReviewExamDefinition(nextEntry);
+ } catch (error) {
+ this._rollbackExamLaunchOwnership(targetLaunchOwnership);
+ console.warn('[ReviewReplay] 无法解析下一题,保留当前回顾页:', error);
+ return null;
+ }
+ if (!this._isExamLaunchOwnershipCurrent(
+ nextEntry.examId,
+ targetLaunchOwnership,
+ targetLaunchOwnership.initialState
+ ) || !ownsSourceReview()) {
+ return null;
+ }
+ if (reuseWindow && !this._claimExamLaunchWindowOwnership(
+ targetLaunchOwnership,
+ reuseWindow
+ )) {
+ return null;
+ }
+ const openedWindow = await this.openExam(nextEntry.examId, {
+ ...launchOptions,
+ examDefinition,
+ launchOwnership: targetLaunchOwnership
});
+ if (!openedWindow) {
+ return null;
+ }
+ session.currentIndex = nextIndex;
+ session.windowRef = openedWindow;
+ store.set(sessionId, session);
+ return openedWindow;
},
async openPracticeRecordReplay(record) {
@@ -3201,14 +5772,40 @@
throw new Error('无法解析首题题目标识');
}
- const openedWindow = await this.openExam(firstEntry.examId, {
+ const launchOptions = {
reviewMode: true,
readOnly: true,
reviewSessionId: session.sessionId,
- reviewEntryIndex: 0
+ reviewEntryIndex: 0,
+ requireRecordProvenance: true
+ };
+ const launchOwnership = this._beginExamLaunchOwnership(firstEntry.examId, launchOptions);
+ let examDefinition;
+ try {
+ examDefinition = await this._resolveReviewExamDefinition(firstEntry);
+ } catch (error) {
+ this._rollbackExamLaunchOwnership(launchOwnership);
+ store.delete(session.sessionId);
+ throw error;
+ }
+ if (!this._isExamLaunchOwnershipCurrent(
+ firstEntry.examId,
+ launchOwnership,
+ launchOwnership.initialState
+ )) {
+ store.delete(session.sessionId);
+ return null;
+ }
+ const openedWindow = await this.openExam(firstEntry.examId, {
+ ...launchOptions,
+ examDefinition,
+ launchOwnership
});
if (!openedWindow) {
store.delete(session.sessionId);
+ if (!this._isExamLaunchOwnershipCurrent(firstEntry.examId, launchOwnership)) {
+ return null;
+ }
throw new Error('无法打开回顾页面');
}
this._bindReviewWindowRef(session.sessionId, openedWindow);
@@ -3221,9 +5818,23 @@
info.expectedSessionId = this.generateSessionId(examId);
}
this._refreshExamWindowToken(examId, info);
- const suiteSessionId = typeof this._resolveSuiteSessionId === 'function'
- ? this._resolveSuiteSessionId(examId, info)
- : (info.suiteSessionId || null);
+ // A managed registration always carries suiteSessionId, including an
+ // explicit null for an ordinary launch. Only legacy payloads that do
+ // not have the field may infer suite ownership from global state.
+ const hasExplicitSuiteOwnership = Object.prototype.hasOwnProperty.call(info, 'suiteSessionId');
+ const suiteSessionId = hasExplicitSuiteOwnership
+ ? (info.suiteSessionId || null)
+ : (typeof this._resolveSuiteSessionId === 'function'
+ ? this._resolveSuiteSessionId(examId, info)
+ : null);
+ const activeSuite = suiteSessionId
+ && this.currentSuiteSession
+ && String(this.currentSuiteSession.id || '') === String(suiteSessionId)
+ ? this.currentSuiteSession
+ : null;
+ const autoAdvanceAfterSubmit = activeSuite && typeof activeSuite.autoAdvanceAfterSubmit === 'boolean'
+ ? activeSuite.autoAdvanceAfterSubmit
+ : (typeof info.autoAdvanceAfterSubmit === 'boolean' ? info.autoAdvanceAfterSubmit : null);
const timerContext = typeof this._resolveSuiteTimerContext === 'function'
? this._resolveSuiteTimerContext({}, info)
: {
@@ -3235,14 +5846,21 @@
? Math.floor(Number(extras.messageIssuedAtMs ?? extras.timestamp))
: Date.now();
info.lastInitMessageAt = messageIssuedAtMs;
+ // 启动时捕获的题库配置 ID:优先用 windowInfo 上预存值(启动时埋下),
+ // 否则从 mixin 私有 Map 兜底读,确保随 INIT_SESSION 携带到考试窗口。
+ const launchLibraryConfigurationId = Object.prototype.hasOwnProperty.call(info, 'libraryConfigurationId')
+ ? info.libraryConfigurationId
+ : this._readLaunchLibraryConfigurationId(examId);
const payload = {
examId: examId,
- parentOrigin: window.location.origin,
+ parentOrigin: info.allowOpaqueOrigin ? 'null' : window.location.origin,
sessionId: info.expectedSessionId,
windowSessionToken: info.windowSessionToken || null,
+ windowSessionGeneration: Number.isInteger(info.sessionGeneration) ? info.sessionGeneration : 0,
messageIssuedAtMs,
suiteSessionId: suiteSessionId || null,
suiteFlowMode: info.suiteFlowMode || null,
+ autoAdvanceAfterSubmit,
suiteTimerAnchorMs: timerContext.suiteTimerAnchorMs || null,
globalTimerAnchorMs: timerContext.globalTimerAnchorMs || null,
suiteTimerMode: timerContext.suiteTimerMode || null,
@@ -3262,23 +5880,99 @@
reviewEntryIndex: Number.isInteger(info.reviewEntryIndex) ? info.reviewEntryIndex : 0,
readOnly: Object.prototype.hasOwnProperty.call(info, 'readOnly')
? Boolean(info.readOnly)
- : Boolean(info.reviewMode)
+ : Boolean(info.reviewMode),
+ libraryConfigurationId: launchLibraryConfigurationId
};
+ if (
+ !payload.reviewMode
+ && !suiteSessionId
+ && !payload.suiteFlowMode
+ && info.lastReadingDraft
+ && typeof info.lastReadingDraft === 'object'
+ && String(info.lastReadingDraft.sessionId || '') === String(info.expectedSessionId || '')
+ ) {
+ payload.draft = this._cloneReadingDraftValue(info.lastReadingDraft);
+ }
if (extras && typeof extras === 'object') {
Object.assign(payload, extras);
}
+ // extras 显式提供 libraryConfigurationId 时不被覆盖;若 extras 显式带
+ // undefined/null(不应出现),保留启动捕获值以免丢失题库来源。
+ if (extras && typeof extras === 'object'
+ && Object.prototype.hasOwnProperty.call(extras, 'libraryConfigurationId')) {
+ payload.libraryConfigurationId = extras.libraryConfigurationId;
+ } else if (payload.libraryConfigurationId === undefined) {
+ payload.libraryConfigurationId = launchLibraryConfigurationId;
+ }
return payload;
},
- _sendExamInitEnvelope(examId, targetWindow, extras = {}) {
+ async _sendExamInitEnvelope(examId, targetWindow, extras = {}, options = {}) {
if (!targetWindow || targetWindow.closed) {
return null;
}
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsEnvelope = () => !expectedRegistration || (
+ expectedRegistration.window === targetWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ expectedRegistration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ );
+ if (!ownsEnvelope()) {
+ return null;
+ }
try {
- const windowInfo = this.ensureExamWindowSession(examId, targetWindow);
+ const windowInfo = expectedRegistration
+ ? expectedRegistration.windowInfo
+ : this.ensureExamWindowSession(examId, targetWindow);
+ if (windowInfo && windowInfo.handshakeDeferred === true) {
+ return null;
+ }
+ const registrationGeneration = Number(windowInfo && windowInfo.sessionGeneration) || 0;
+ const expectedSessionId = String(windowInfo && windowInfo.expectedSessionId || '');
+ const expectedToken = String(windowInfo && windowInfo.windowSessionToken || '');
+ const initEnvelopeEpoch = Math.max(0, Number(windowInfo && windowInfo.initEnvelopeEpoch) || 0) + 1;
+ windowInfo.initEnvelopeEpoch = initEnvelopeEpoch;
+ let restoredDraft = null;
+ if (
+ windowInfo
+ && !windowInfo.reviewMode
+ && !windowInfo.suiteSessionId
+ && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize'
+ && typeof this.getReadingDraftForExam === 'function'
+ && !(extras && Object.prototype.hasOwnProperty.call(extras, 'draft'))
+ ) {
+ try {
+ restoredDraft = await this.getReadingDraftForExam(examId, {
+ sessionId: windowInfo.expectedSessionId
+ });
+ } catch (_) {
+ // draft restore is best-effort
+ }
+ }
+ const currentWindowInfo = this.examWindows && this.examWindows.get(examId);
+ if (!windowInfo
+ || !ownsEnvelope()
+ || currentWindowInfo !== windowInfo
+ || windowInfo.window !== targetWindow
+ || Number(windowInfo.sessionGeneration) !== registrationGeneration
+ || String(windowInfo.expectedSessionId || '') !== expectedSessionId
+ || String(windowInfo.windowSessionToken || '') !== expectedToken
+ || Number(windowInfo.initEnvelopeEpoch) !== initEnvelopeEpoch
+ || windowInfo.handshakeDeferred === true) {
+ return null;
+ }
+ if (restoredDraft) {
+ windowInfo.lastReadingDraft = restoredDraft;
+ }
const initPayload = this._buildExamInitPayload(examId, windowInfo, extras);
- targetWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*');
- targetWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*');
+ this._postExamMessage(examId, targetWindow, 'INIT_SESSION', initPayload);
+ this._postExamMessage(examId, targetWindow, 'init_exam_session', initPayload);
return initPayload;
} catch (initError) {
console.warn('[App] 发送初始化消息失败:', initError);
@@ -3286,7 +5980,7 @@
}
},
- restartExamHandshake(examWindow, examId) {
+ restartExamHandshake(examWindow, examId, options = {}) {
if (this._handshakeTimers && this._handshakeTimers.has(examId)) {
try {
clearInterval(this._handshakeTimers.get(examId));
@@ -3295,7 +5989,7 @@
}
this._handshakeTimers.delete(examId);
}
- this.startExamHandshake(examWindow, examId);
+ this.startExamHandshake(examWindow, examId, options);
},
ensureExamWindowSession(examId, examWindow = null) {
@@ -3311,7 +6005,10 @@
expectedSessionId: this.generateSessionId(examId),
windowSessionToken: null,
windowSessionTokenSessionId: null,
- origin: (typeof window !== 'undefined' && window.location) ? window.location.origin : '',
+ expectedUrl: '',
+ expectedOrigin: '',
+ allowOpaqueOrigin: false,
+ observedOrigin: '',
suiteTimerAnchorMs: null,
globalTimerAnchorMs: null,
suiteTimerMode: null,
@@ -3323,7 +6020,9 @@
reviewMode: false,
reviewSessionId: null,
reviewEntryIndex: 0,
- readOnly: false
+ readOnly: false,
+ sessionGeneration: 1,
+ submittedRecordId: ''
});
}
@@ -3333,6 +6032,28 @@
windowInfo.window = examWindow;
}
+ if (!windowInfo.expectedOrigin && examWindow) {
+ try {
+ const currentHref = examWindow.location && typeof examWindow.location.href === 'string'
+ ? examWindow.location.href
+ : '';
+ const endpoint = this._resolveExamMessageEndpoint(currentHref);
+ const hostOrigin = window.location && window.location.origin;
+ const isTrustedSameOrigin = endpoint.expectedOrigin
+ && endpoint.expectedOrigin !== 'null'
+ && hostOrigin
+ && endpoint.expectedOrigin === hostOrigin;
+ const isTrustedLocalFile = endpoint.allowOpaqueOrigin && isFileProtocol;
+ if (isTrustedSameOrigin || isTrustedLocalFile) {
+ windowInfo.expectedUrl = endpoint.expectedUrl;
+ windowInfo.expectedOrigin = endpoint.expectedOrigin;
+ windowInfo.allowOpaqueOrigin = endpoint.allowOpaqueOrigin;
+ }
+ } catch (_) {
+ // Cross-origin WindowProxy locations are intentionally not probed further.
+ }
+ }
+
if (!windowInfo.expectedSessionId) {
windowInfo.expectedSessionId = this.generateSessionId(examId);
}
@@ -3356,17 +6077,100 @@
return windowInfo;
},
+ /**
+ * 在考试启动时捕获当前激活的题库配置 ID,写入 windowInfo 与 mixin 私有 Map,
+ * 供后续 INIT_SESSION payload 以及 completeAttempt 路径使用,避免提交时再读取
+ * 当前激活题库而拿到不一致的来源。
+ * 该方法为 async:必要时调用方需 await。
+ */
+ async _captureLaunchLibraryConfigurationId(examId, options = {}) {
+ if (!examId) return null;
+ const commitGuard = options && typeof options.commitGuard === 'function'
+ ? options.commitGuard
+ : null;
+ if (!this._launchLibraryConfigurationIds) {
+ this._launchLibraryConfigurationIds = new Map();
+ }
+ let configurationId = null;
+ try {
+ if (window.AppData && window.AppData.library
+ && typeof window.AppData.library.getActive === 'function') {
+ configurationId = await window.AppData.library.getActive();
+ }
+ } catch (captureError) {
+ console.warn('[ExamSession] 捕获启动题库配置 ID 失败:', captureError);
+ configurationId = null;
+ }
+ const normalized = (configurationId === undefined || configurationId === null)
+ ? null
+ : configurationId;
+ if (commitGuard && commitGuard() !== true) {
+ return null;
+ }
+ this._launchLibraryConfigurationIds.set(String(examId), normalized);
+ // 同步作用中 windowInfo:避免后续 _buildExamInitPayload 等同步路径漏读
+ try {
+ if (this.examWindows && this.examWindows.has(examId)) {
+ const windowInfo = this.examWindows.get(examId);
+ if (windowInfo && typeof windowInfo === 'object'
+ && !Object.prototype.hasOwnProperty.call(windowInfo, 'libraryConfigurationId')) {
+ windowInfo.libraryConfigurationId = normalized;
+ }
+ }
+ } catch (_) { /* 忽略:windowInfo 不存在不影响捕获 */ }
+ return normalized;
+ },
+
+ /**
+ * 同步读取指定 examId 启动时捕获的题库配置 ID;若无捕获返回 null。
+ * 优先取实时注入(realData.metadata / payload 显式传入)的值,再回退到启动时捕获值。
+ */
+ _readLaunchLibraryConfigurationId(examId, ...fromSources) {
+ for (const source of fromSources) {
+ if (source !== undefined && source !== null && typeof source === 'object') {
+ const metadata = source.metadata;
+ const direct = Object.prototype.hasOwnProperty.call(source, 'libraryConfigurationId')
+ ? source.libraryConfigurationId
+ : (metadata && Object.prototype.hasOwnProperty.call(metadata, 'libraryConfigurationId'))
+ ? metadata.libraryConfigurationId
+ : undefined;
+ if (direct !== undefined && direct !== null) {
+ return direct;
+ }
+ }
+ }
+ if (!this._launchLibraryConfigurationIds) {
+ return null;
+ }
+ return this._launchLibraryConfigurationIds.get(String(examId)) || null;
+ },
+
+ /**
+ * 清除指定 examId 启动时捕获的题库配置 ID(窗口关闭后调用)。
+ */
+ _discardLaunchLibraryConfigurationId(examId) {
+ if (this._launchLibraryConfigurationIds && examId) {
+ this._launchLibraryConfigurationIds.delete(String(examId));
+ }
+ },
+
_syncRecorderSessionStarted(examId, windowInfo, metadata = {}) {
const recorder = this.components && this.components.practiceRecorder;
if (!recorder || typeof recorder.handleSessionStarted !== 'function') {
return;
}
const sessionId = (windowInfo && windowInfo.expectedSessionId) || this.generateSessionId(examId);
+ // 注入启动时捕获的题库配置 ID,确保 recorder 会话上携带来源。
+ const mergedMetadata = Object.assign({}, metadata);
+ if (!Object.prototype.hasOwnProperty.call(mergedMetadata, 'libraryConfigurationId')) {
+ mergedMetadata.libraryConfigurationId =
+ this._readLaunchLibraryConfigurationId(examId, windowInfo, metadata);
+ }
try {
recorder.handleSessionStarted({
examId,
sessionId,
- metadata
+ metadata: mergedMetadata
});
} catch (recorderError) {
console.warn('[PracticeRecorder] 重置后同步会话状态失败:', recorderError);
@@ -3375,16 +6179,115 @@
async _removeActiveExamSessionMetadata(examId) {
try {
- const activeSessions = await storage.get('active_sessions', []);
- const updatedSessions = Array.isArray(activeSessions)
- ? activeSessions.filter(session => session && session.examId !== examId)
- : [];
- await storage.set('active_sessions', updatedSessions);
+ await this._discardActiveSessionsForExam(examId);
} catch (error) {
console.warn('[App] 清理活动会话元数据失败:', error);
}
},
+ _captureExamSessionRegistration(examId, windowInfo = null) {
+ const info = windowInfo || (this.examWindows && this.examWindows.get(examId));
+ if (!info) {
+ return null;
+ }
+ const numericGeneration = Number(info.sessionGeneration);
+ return Object.freeze({
+ windowInfo: info,
+ window: info.window || null,
+ navigationOwnership: info.navigationOwnership || null,
+ suiteSessionId: String(info.suiteSessionId || ''),
+ expectedSessionId: String(info.expectedSessionId || ''),
+ windowSessionToken: String(info.windowSessionToken || ''),
+ sessionGeneration: Number.isInteger(numericGeneration) ? numericGeneration : null
+ });
+ },
+
+ _isExamSessionRegistrationCurrent(examId, expectedRegistration) {
+ if (!expectedRegistration || !expectedRegistration.windowInfo || !this.examWindows) {
+ return false;
+ }
+ const current = this.examWindows.get(examId);
+ if (!current || current !== expectedRegistration.windowInfo) {
+ return false;
+ }
+ const numericGeneration = Number(current.sessionGeneration);
+ const currentGeneration = Number.isInteger(numericGeneration) ? numericGeneration : null;
+ return current.window === expectedRegistration.window
+ && String(current.suiteSessionId || '') === String(expectedRegistration.suiteSessionId || '')
+ && String(current.expectedSessionId || '') === String(expectedRegistration.expectedSessionId || '')
+ && String(current.windowSessionToken || '') === String(expectedRegistration.windowSessionToken || '')
+ && currentGeneration === expectedRegistration.sessionGeneration;
+ },
+
+ async _discardActiveSessionsForExam(examId, options = {}) {
+ const hasExpectedSessionId = Object.prototype.hasOwnProperty.call(options, 'expectedSessionId');
+ const commitGuard = options && typeof options.commitGuard === 'function'
+ ? options.commitGuard
+ : null;
+ const expectedSessionId = hasExpectedSessionId
+ ? String(options.expectedSessionId || '').trim()
+ : '';
+ // Ownership-gated cleanup must fail closed when no exact recovery id is known.
+ if (hasExpectedSessionId && !expectedSessionId) {
+ return 0;
+ }
+ if (commitGuard && commitGuard() !== true) {
+ return 0;
+ }
+ const activeSessions = await window.AppData.recovery.listActiveSessions();
+ if (commitGuard && commitGuard() !== true) {
+ return 0;
+ }
+ const matches = (Array.isArray(activeSessions) ? activeSessions : [])
+ .filter((session) => {
+ if (!session || String(session.examId || '') !== String(examId || '')) {
+ return false;
+ }
+ if (!hasExpectedSessionId) {
+ return true;
+ }
+ const entityId = String(session.id || session.recordId || '').trim();
+ const entitySessionId = entityId.startsWith('active-session:')
+ ? entityId.slice('active-session:'.length)
+ : entityId;
+ return String(session.sessionId || '').trim() === expectedSessionId
+ || entitySessionId === expectedSessionId;
+ });
+ let removedCount = 0;
+ for (const session of matches) {
+ if (commitGuard && commitGuard() !== true) {
+ return removedCount;
+ }
+ const entityId = session.id || session.sessionId || session.recordId;
+ if (entityId) {
+ const discardOptions = commitGuard ? { commitGuard } : {};
+ if (typeof window.AppData.recovery.getActiveSessionFence === 'function') {
+ const fence = await window.AppData.recovery.getActiveSessionFence(entityId);
+ if (commitGuard && commitGuard() !== true) {
+ return removedCount;
+ }
+ if (!fence || fence.exists !== true || fence.tombstoned === true) {
+ continue;
+ }
+ const revision = Number(fence.revision);
+ if (Number.isSafeInteger(revision) && revision >= 0) {
+ discardOptions.expectedEntityRevision = revision;
+ }
+ } else {
+ const revision = Number(session && session.revision);
+ if (Number.isSafeInteger(revision) && revision >= 0) {
+ discardOptions.expectedEntityRevision = revision;
+ }
+ }
+ const receipt = await window.AppData.recovery.discardActiveSession(entityId, discardOptions);
+ if (!receipt || receipt.committed !== false) {
+ removedCount += 1;
+ }
+ }
+ }
+ return removedCount;
+ },
+
_isResetCapableUnifiedReadingCompletion(data, sourceWindow = null) {
if (!sourceWindow || sourceWindow.closed) {
return false;
@@ -3404,8 +6307,19 @@
return renderMode === 'unified-reading' || pageType === 'unified-reading';
},
- async retainExamWindowAfterCompletion(examId, sourceWindow, data = {}) {
- const windowInfo = this.ensureExamWindowSession(examId, sourceWindow);
+ async retainExamWindowAfterCompletion(examId, sourceWindow, data = {}, options = {}) {
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsCompletion = () => Boolean(expectedRegistration) && (
+ expectedRegistration.window === sourceWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(examId, launchOwnership, expectedRegistration)
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ );
+ if (!ownsCompletion()) {
+ return false;
+ }
+ const windowInfo = expectedRegistration.windowInfo;
windowInfo.window = sourceWindow || windowInfo.window || null;
windowInfo.status = 'completed';
windowInfo.completedAt = Date.now();
@@ -3414,43 +6328,68 @@
windowInfo.reviewMode = false;
windowInfo.readOnly = false;
this.examWindows && this.examWindows.set(examId, windowInfo);
- await this._removeActiveExamSessionMetadata(examId);
+ await this._discardActiveSessionsForExam(examId, {
+ expectedSessionId: expectedRegistration.expectedSessionId,
+ commitGuard: ownsCompletion
+ });
+ return ownsCompletion();
},
- async handlePracticeResetRequest(examId, data = {}, sourceWindow = null) {
+ async handlePracticeResetRequest(examId, data = {}, sourceWindow = null, options = {}) {
+ const launchOwnership = options && options.launchOwnership || null;
+ const expectedRegistration = options && options.expectedRegistration
+ ? options.expectedRegistration
+ : this._captureExamSessionRegistration(examId);
const targetWindow = sourceWindow
- || (this.examWindows && this.examWindows.has(examId) ? this.examWindows.get(examId).window : null);
+ || (expectedRegistration && expectedRegistration.window)
+ || null;
if (!targetWindow || targetWindow.closed) {
window.showMessage && window.showMessage('题目窗口已关闭,无法重置测试', 'warning');
return;
}
+ const ownsResetRegistration = (registration = expectedRegistration) => Boolean(
+ registration
+ && registration.window === targetWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ registration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, registration))
+ );
+ if (!ownsResetRegistration()) {
+ return null;
+ }
const payload = data && typeof data === 'object' ? data : {};
const reason = String(payload.reason || '').trim().toLowerCase();
const fromPracticeMode = String(payload.fromPracticeMode || payload.practiceMode || '').trim().toLowerCase();
- const windowInfo = this.ensureExamWindowSession(examId, targetWindow);
+ const windowInfo = expectedRegistration.windowInfo;
const shouldReopenAsNormal = reason === 'memorize-start-test'
|| fromPracticeMode === 'memorize'
|| windowInfo.practiceMode === 'memorize';
if (shouldReopenAsNormal) {
- windowInfo.practiceMode = null;
- windowInfo.reviewMode = false;
- windowInfo.readOnly = false;
- windowInfo.status = 'active';
- this.examWindows && this.examWindows.set(examId, windowInfo);
- await this.openExam(examId, {
+ if (!ownsResetRegistration()) {
+ return null;
+ }
+ const reopenedWindow = await this.openExam(examId, {
target: 'tab',
windowName: 'ielts-reading-practice',
reuseWindow: targetWindow
});
- return;
+ return reopenedWindow ? true : null;
}
+ if (!ownsResetRegistration()) {
+ return null;
+ }
windowInfo.window = targetWindow;
windowInfo.status = 'active';
windowInfo.startTime = Date.now();
windowInfo.completedAt = null;
+ windowInfo.sessionGeneration = Math.max(0, Number(windowInfo.sessionGeneration) || 0) + 1;
windowInfo.expectedSessionId = this.generateSessionId(examId);
windowInfo.sessionId = null;
windowInfo.practiceMode = null;
@@ -3458,107 +6397,401 @@
windowInfo.reviewSessionId = null;
windowInfo.reviewEntryIndex = 0;
windowInfo.readOnly = false;
+ windowInfo.submittedRecordId = '';
windowInfo.dataCollectorReady = false;
windowInfo.lastResetAt = Date.now();
windowInfo.lastResetReason = reason || 'reset';
+ this._refreshExamWindowToken(examId, windowInfo);
this.examWindows && this.examWindows.set(examId, windowInfo);
- await this.startPracticeSession(examId);
- this._syncRecorderSessionStarted(examId, windowInfo, {
+ const resetRegistration = this._captureExamSessionRegistration(examId, windowInfo);
+ const startResult = await this.startPracticeSession(examId, {
+ expectedRegistration: resetRegistration,
+ launchOwnership
+ });
+ if (!this._isPracticeSessionOwnedSuccess(startResult)
+ || !ownsResetRegistration(startResult.registration)) {
+ return null;
+ }
+ const activeWindowInfo = startResult.registration.windowInfo;
+ if (!ownsResetRegistration(startResult.registration)) {
+ return null;
+ }
+ this._syncRecorderSessionStarted(examId, activeWindowInfo, {
pageType: 'unified-reading',
url: payload.normalUrl || payload.url || null,
title: payload.title || null,
resetReason: reason || 'reset'
});
- this._sendExamInitEnvelope(examId, targetWindow, {
+ await this._sendExamInitEnvelope(examId, targetWindow, {
practiceMode: null,
reviewMode: false,
readOnly: false
+ }, {
+ expectedRegistration: startResult.registration,
+ launchOwnership
+ });
+ if (!ownsResetRegistration(startResult.registration)) {
+ return null;
+ }
+ this.restartExamHandshake(targetWindow, examId, {
+ expectedRegistration: startResult.registration,
+ launchOwnership
});
- this.restartExamHandshake(targetWindow, examId);
+ if (!ownsResetRegistration(startResult.registration)) {
+ return null;
+ }
this.updateExamStatus(examId, 'in-progress');
+ return true;
},
/**
* 开始练习会话
*/
- async startPracticeSession(examId) {
- const exam = await findExamDefinition(examId);
- if (!exam) {
- console.error('Exam not found:', examId);
- window.showMessage && window.showMessage('题目索引未加载,请重试或重新导入题库。', 'error');
- return;
+ _isPracticeSessionOwnedSuccess(result) {
+ return Boolean(
+ result
+ && result.owned === true
+ && result.status === 'owned-success'
+ && result.registration
+ && result.sessionId
+ );
+ },
+
+ _buildPracticeSessionOwnedSuccess(
+ examId,
+ source,
+ sessionId,
+ value,
+ windowInfo,
+ launchOwnership = null
+ ) {
+ const registration = this._captureExamSessionRegistration(examId, windowInfo);
+ const ownsRegistration = launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(examId, launchOwnership, registration)
+ : this._isExamSessionRegistrationCurrent(examId, registration);
+ const normalizedSessionId = String(
+ sessionId || (registration && registration.expectedSessionId) || ''
+ ).trim();
+ if (!registration || !ownsRegistration || !normalizedSessionId) {
+ return null;
+ }
+ return Object.freeze({
+ owned: true,
+ status: 'owned-success',
+ examId: String(examId || ''),
+ source: String(source || ''),
+ sessionId: normalizedSessionId,
+ value: value == null ? null : value,
+ registration
+ });
+ },
+
+ async _saveOwnedPracticeSessionRecovery(examId, sessionId, expectedRegistration, options = {}) {
+ const normalizedSessionId = String(sessionId || '').trim();
+ const additionalGuard = options && typeof options.commitGuard === 'function'
+ ? options.commitGuard
+ : null;
+ const ownsRegistration = () => this._isExamSessionRegistrationCurrent(
+ examId,
+ expectedRegistration
+ ) && (!additionalGuard || additionalGuard() === true);
+ // openExam registers the WindowProxy before starting a session. Calls without that
+ // immutable owner tuple are legacy/stale and must not create unowned recovery data.
+ if (!normalizedSessionId || !expectedRegistration || !ownsRegistration()) {
+ return null;
+ }
+
+ const sessionData = {
+ id: `active-session:${normalizedSessionId}`,
+ examId: examId,
+ startTime: new Date().toISOString(),
+ status: 'started',
+ sessionId: normalizedSessionId
+ };
+ const receipt = await window.AppData.recovery.saveActiveSession(sessionData, {
+ commitGuard: ownsRegistration
+ });
+ if (!receipt || receipt.committed !== true) {
+ return null;
+ }
+ if (!ownsRegistration()) {
+ const cleanupGuard = () => {
+ const current = this.examWindows && this.examWindows.get(examId);
+ return this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ || !current
+ || String(current.expectedSessionId || '').trim() !== normalizedSessionId;
+ };
+ if (cleanupGuard()) {
+ await this._discardActiveSessionsForExam(examId, {
+ expectedSessionId: normalizedSessionId,
+ commitGuard: cleanupGuard
+ });
+ }
+ return null;
+ }
+ return sessionData;
+ },
+
+ async startPracticeSession(examId, options = {}) {
+ const launchOwnership = options && options.launchOwnership || null;
+ let expectedRegistration = options && options.expectedRegistration
+ ? options.expectedRegistration
+ : this._captureExamSessionRegistration(examId);
+ let windowInfo = expectedRegistration && expectedRegistration.windowInfo || null;
+ const ownsExpectedRegistration = () => Boolean(expectedRegistration) && (
+ launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ expectedRegistration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ );
+ if (!ownsExpectedRegistration()) {
+ return null;
+ }
+ const pendingCompletion = this._practiceCompletionGates
+ && this._practiceCompletionGates.get(String(examId || ''));
+ if (pendingCompletion && pendingCompletion.promise) {
+ try { await pendingCompletion.promise; } catch (_) {}
+ if (!ownsExpectedRegistration()) {
+ return null;
+ }
}
+ let hostSessionId = String(expectedRegistration.expectedSessionId || '').trim()
+ || String(this.generateSessionId(examId) || '').trim();
+ if (!hostSessionId) {
+ return null;
+ }
+ if (String(windowInfo.expectedSessionId || '').trim() !== hostSessionId) {
+ windowInfo.expectedSessionId = hostSessionId;
+ this._refreshExamWindowToken(examId, windowInfo);
+ this.examWindows.set(examId, windowInfo);
+ expectedRegistration = this._captureExamSessionRegistration(examId, windowInfo);
+ if (!ownsExpectedRegistration()) {
+ return null;
+ }
+ }
+
+ let exam = options && options.examDefinition && typeof options.examDefinition === 'object'
+ ? options.examDefinition
+ : null;
try {
+ if (!exam) {
+ exam = await findExamDefinition(examId);
+ if (!ownsExpectedRegistration()) {
+ return null;
+ }
+ }
+ if (!exam) {
+ console.error('Exam not found:', examId);
+ window.showMessage && window.showMessage('题目索引未加载,请重试或重新导入题库。', 'error');
+ return null;
+ }
+
// 优先使用新的练习页面管理器
if (window.practicePageManager) {
- const sessionId = await window.practicePageManager.startPracticeSession(examId, exam);
+ const managerResult = await window.practicePageManager.startPracticeSession(
+ examId,
+ Object.assign({}, exam, { sessionId: hostSessionId })
+ );
+ const managerSessionId = String(
+ typeof managerResult === 'string'
+ ? managerResult
+ : (managerResult && typeof managerResult === 'object'
+ ? (managerResult.sessionId || '')
+ : '')
+ ).trim();
+ if (!ownsExpectedRegistration()) {
+ const staleSessionId = managerSessionId || hostSessionId;
+ const staleRegistration = expectedRegistration;
+ const cleanupGuard = () => {
+ const current = this.examWindows && this.examWindows.get(examId);
+ return this._isExamSessionRegistrationCurrent(examId, staleRegistration)
+ || !current
+ || String(current.expectedSessionId || '').trim() !== staleSessionId;
+ };
+ if (staleSessionId && cleanupGuard()) {
+ await this._discardActiveSessionsForExam(examId, {
+ expectedSessionId: staleSessionId,
+ commitGuard: cleanupGuard
+ });
+ }
+ return null;
+ }
+ if (managerResult === false) {
+ return null;
+ }
+ if (managerSessionId && managerSessionId !== hostSessionId) {
+ windowInfo.expectedSessionId = managerSessionId;
+ this._refreshExamWindowToken(examId, windowInfo);
+ this.examWindows.set(examId, windowInfo);
+ expectedRegistration = this._captureExamSessionRegistration(examId, windowInfo);
+ if (!ownsExpectedRegistration()) {
+ return null;
+ }
+ hostSessionId = managerSessionId;
+ if (!windowInfo.handshakeDeferred && windowInfo.window) {
+ this.restartExamHandshake(windowInfo.window, examId, {
+ expectedRegistration,
+ launchOwnership
+ });
+ }
+ }
- // 更新题目状态
this.updateExamStatus(examId, 'in-progress');
- return sessionId;
+ return this._buildPracticeSessionOwnedSuccess(
+ examId,
+ 'manager',
+ hostSessionId,
+ managerResult,
+ windowInfo,
+ launchOwnership
+ );
}
// 使用练习记录器开始会话
if (this.components.practiceRecorder) {
+ const launchLibraryConfigurationId = this._readLaunchLibraryConfigurationId(examId);
+ const startPayload = Object.assign({}, exam, {
+ sessionId: hostSessionId,
+ libraryConfigurationId: launchLibraryConfigurationId
+ });
let sessionData;
if (typeof this.components.practiceRecorder.startPracticeSession === 'function') {
- sessionData = this.components.practiceRecorder.startPracticeSession(examId, exam);
+ sessionData = this.components.practiceRecorder.startPracticeSession(
+ examId,
+ startPayload
+ );
} else if (typeof this.components.practiceRecorder.startSession === 'function') {
- sessionData = this.components.practiceRecorder.startSession(examId, exam);
+ sessionData = this.components.practiceRecorder.startSession(
+ examId,
+ startPayload
+ );
} else {
console.warn('[App] PracticeRecorder没有可用的启动方法');
sessionData = null;
}
- } else {
- // 降级处理
- const sessionData = {
- examId: examId,
- startTime: new Date().toISOString(),
- status: 'started',
- sessionId: this.generateSessionId(examId)
- };
-
- const activeSessions = await storage.get('active_sessions', []);
- activeSessions.push(sessionData);
- await storage.set('active_sessions', activeSessions);
+ if (!ownsExpectedRegistration()) {
+ return null;
+ }
+ const recorderSessionId = String(
+ sessionData && sessionData.sessionId || hostSessionId
+ ).trim();
+ if (recorderSessionId && recorderSessionId !== hostSessionId) {
+ windowInfo.expectedSessionId = recorderSessionId;
+ this._refreshExamWindowToken(examId, windowInfo);
+ this.examWindows.set(examId, windowInfo);
+ expectedRegistration = this._captureExamSessionRegistration(examId, windowInfo);
+ if (!ownsExpectedRegistration()) {
+ return null;
+ }
+ hostSessionId = recorderSessionId;
+ if (!windowInfo.handshakeDeferred && windowInfo.window) {
+ this.restartExamHandshake(windowInfo.window, examId, {
+ expectedRegistration,
+ launchOwnership
+ });
+ }
+ }
+ this.updateExamStatus(examId, 'in-progress');
+ return this._buildPracticeSessionOwnedSuccess(
+ examId,
+ 'recorder',
+ hostSessionId,
+ sessionData,
+ windowInfo,
+ launchOwnership
+ );
}
- // 更新题目状态
+ const sessionData = await this._saveOwnedPracticeSessionRecovery(
+ examId,
+ hostSessionId,
+ expectedRegistration,
+ {
+ commitGuard: () => !launchOwnership
+ || this._isExamLaunchOwnershipCurrent(
+ examId,
+ launchOwnership,
+ null,
+ expectedRegistration.window
+ )
+ }
+ );
+ if (!sessionData || !ownsExpectedRegistration()) {
+ return null;
+ }
this.updateExamStatus(examId, 'in-progress');
-
+ return this._buildPracticeSessionOwnedSuccess(
+ examId,
+ 'recovery',
+ hostSessionId,
+ sessionData,
+ windowInfo,
+ launchOwnership
+ );
} catch (error) {
console.error('[App] 启动练习会话失败:', error);
-
- // 最终降级方案
- this.startPracticeSessionFallback(examId, exam);
+ return await this.startPracticeSessionFallback(examId, exam, {
+ sessionId: hostSessionId,
+ expectedRegistration,
+ launchOwnership
+ });
}
},
/**
* 降级启动练习会话
*/
- async startPracticeSessionFallback(examId, exam) {
-
- const sessionData = {
- examId: examId,
- startTime: new Date().toISOString(),
- status: 'started',
- sessionId: this.generateSessionId(examId)
- };
-
- const activeSessions = await storage.get('active_sessions', []);
- activeSessions.push(sessionData);
- await storage.set('active_sessions', activeSessions);
+ async startPracticeSessionFallback(examId, exam, options = {}) {
+ const sessionId = String(options && options.sessionId || '').trim();
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsExpectedRegistration = () => Boolean(expectedRegistration) && (
+ launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ expectedRegistration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ );
+ if (!sessionId || !ownsExpectedRegistration()) {
+ return null;
+ }
+ const sessionData = await this._saveOwnedPracticeSessionRecovery(
+ examId,
+ sessionId,
+ expectedRegistration,
+ {
+ commitGuard: () => !launchOwnership
+ || this._isExamLaunchOwnershipCurrent(
+ examId,
+ launchOwnership,
+ null,
+ expectedRegistration.window
+ )
+ }
+ );
+ if (!sessionData || !ownsExpectedRegistration()) {
+ return null;
+ }
- // 更新题目状态
this.updateExamStatus(examId, 'in-progress');
-
- // 尝试打开练习页面
const practiceUrl = `templates/ielts-exam-template.html?examId=${examId}`;
window.open(practiceUrl, `practice_${sessionData.sessionId}`, 'width=1200,height=800');
+ return this._buildPracticeSessionOwnedSuccess(
+ examId,
+ 'fallback',
+ sessionData.sessionId,
+ sessionData,
+ expectedRegistration.windowInfo,
+ launchOwnership
+ );
},
/**
@@ -3601,42 +6834,80 @@
/**
* 处理数据采集器会话就绪
*/
- handleSessionReady(examId, data) {
+ handleSessionReady(examId, data, options = {}) {
const payload = data && typeof data === 'object' ? data : {};
+ const expectedRegistration = options && options.expectedRegistration
+ || this._captureExamSessionRegistration(examId);
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsReadyRegistration = () => Boolean(expectedRegistration) && (
+ launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(examId, launchOwnership, expectedRegistration)
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ );
+ if (!ownsReadyRegistration()) {
+ return false;
+ }
+
+ const windowInfo = expectedRegistration.windowInfo;
+ const expectedSessionId = String(expectedRegistration.expectedSessionId || '').trim();
+ const payloadSessionId = typeof payload.sessionId === 'string'
+ ? payload.sessionId.trim()
+ : '';
+ // SESSION_READY is an acknowledgement of the host-issued identity. The
+ // page may never rotate that identity itself; manager/recorder alignment
+ // updates the registration on the host before READY is accepted.
+ if (payloadSessionId && expectedSessionId && payloadSessionId !== expectedSessionId) {
+ return false;
+ }
+
+ const hasManagedSuiteOwnership = Object.prototype.hasOwnProperty.call(windowInfo, 'suiteSessionId');
+ const registeredSuiteSessionId = hasManagedSuiteOwnership
+ ? String(windowInfo.suiteSessionId || '').trim()
+ : '';
+ const payloadSuiteSessionId = typeof payload.suiteSessionId === 'string'
+ ? payload.suiteSessionId.trim()
+ : '';
+ const activeSuite = this.currentSuiteSession;
+ const activeSuiteSessionId = String(activeSuite && activeSuite.id || '').trim();
+ const ownsCurrentSuiteRegistration = Boolean(
+ registeredSuiteSessionId
+ && activeSuiteSessionId
+ && registeredSuiteSessionId === activeSuiteSessionId
+ );
+ const ownsPayloadSuiteProtocol = Boolean(
+ ownsCurrentSuiteRegistration
+ && payloadSuiteSessionId
+ && payloadSuiteSessionId === registeredSuiteSessionId
+ );
+ // A payload may confirm an existing suite owner, but it must never
+ // promote an ordinary (explicit-null) registration into the suite.
+ if (payloadSuiteSessionId && payloadSuiteSessionId !== registeredSuiteSessionId) {
+ return false;
+ }
+ if (registeredSuiteSessionId && !ownsCurrentSuiteRegistration) {
+ return false;
+ }
+
const isListeningBridgeReady = payload.source === 'listening_record_bridge'
|| payload.metadata?.source === 'listening_record_bridge'
|| payload.pageType === 'listening'
|| payload.type === 'listening';
- const isPreInitListeningReady = Boolean(
- isListeningBridgeReady
- && payload.initialized === false
+ const isPreInitReady = (isListeningBridgeReady && payload.initialized === false) || (
+ !String(payload.windowSessionToken || '').trim()
+ && payload.pageType === 'suite-placeholder'
);
- // 更新会话状态
- let windowInfo = null;
- if (this.examWindows && this.examWindows.has(examId)) {
- windowInfo = this.examWindows.get(examId);
- } else {
- windowInfo = this.ensureExamWindowSession(examId);
+ if (isListeningBridgeReady) {
+ windowInfo.listeningBridgeSeen = true;
+ windowInfo.listeningBridgeInitialized = !isPreInitReady;
}
-
- if (windowInfo) {
- if (isListeningBridgeReady) {
- windowInfo.listeningBridgeSeen = true;
- windowInfo.listeningBridgeInitialized = !isPreInitListeningReady;
- }
- if (!isPreInitListeningReady) {
- windowInfo.dataCollectorReady = true;
- }
- if (payload.pageType) {
- windowInfo.pageType = payload.pageType;
- }
- if (!isPreInitListeningReady && payload.sessionId && windowInfo.expectedSessionId !== payload.sessionId) {
- windowInfo.expectedSessionId = payload.sessionId;
- }
- if (payload.suiteSessionId && !windowInfo.suiteSessionId) {
- windowInfo.suiteSessionId = payload.suiteSessionId;
- }
+ if (!isPreInitReady) {
+ windowInfo.dataCollectorReady = true;
+ }
+ if (payload.pageType) {
+ windowInfo.pageType = payload.pageType;
+ }
+ if (ownsPayloadSuiteProtocol) {
if (payload.suiteFlowMode && !windowInfo.suiteFlowMode) {
windowInfo.suiteFlowMode = payload.suiteFlowMode;
}
@@ -3649,27 +6920,34 @@
if (Array.isArray(payload.suiteSequence) && payload.suiteSequence.length) {
windowInfo.suiteSequence = payload.suiteSequence;
}
- if (payload.url) {
- windowInfo.latestUrl = payload.url;
- }
- this.examWindows && this.examWindows.set(examId, windowInfo);
}
+ if (payload.url) {
+ windowInfo.latestUrl = payload.url;
+ }
+ if (!ownsReadyRegistration()) {
+ return false;
+ }
+ this.examWindows && this.examWindows.set(examId, windowInfo);
- if (isPreInitListeningReady) {
+ if (isPreInitReady) {
try {
- const targetWindow = (windowInfo && windowInfo.window) || null;
+ const targetWindow = windowInfo.window || null;
if (targetWindow && typeof targetWindow.postMessage === 'function') {
- const initPayload = this._buildExamInitPayload(examId, windowInfo || {});
- targetWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*');
- targetWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*');
+ const initPayload = this._buildExamInitPayload(examId, windowInfo);
+ const sendOptions = { expectedRegistration, launchOwnership };
+ this._postExamMessage(examId, targetWindow, 'INIT_SESSION', initPayload, sendOptions);
+ this._postExamMessage(examId, targetWindow, 'init_exam_session', initPayload, sendOptions);
}
} catch (initError) {
- console.warn('[App] 听力桥预初始化 ready 后补发 INIT_SESSION 失败:', initError);
+ console.warn('[App] 预初始化 ready 后补发 INIT_SESSION 失败:', initError);
}
- return;
+ return ownsReadyRegistration();
}
- if (this.suiteExamMap && this.suiteExamMap.has(examId) && typeof this._handleSuiteSessionReady === 'function') {
+ if (ownsPayloadSuiteProtocol
+ && this.suiteExamMap
+ && this.suiteExamMap.has(examId)
+ && typeof this._handleSuiteSessionReady === 'function') {
try {
this._handleSuiteSessionReady(examId);
} catch (suiteReadyError) {
@@ -3677,11 +6955,27 @@
}
}
- if (!(windowInfo && windowInfo.reviewMode)
+ const stationarySuiteExam = Boolean(
+ ownsPayloadSuiteProtocol
+ && activeSuite
+ && activeSuite.status === 'active'
+ && activeSuite.flowMode === 'stationary'
+ && Array.isArray(activeSuite.sequence)
+ && activeSuite.sequence.some(item => item && String(item.examId) === String(examId))
+ );
+ if (stationarySuiteExam && typeof this._sendSuiteReviewState === 'function') {
+ try {
+ this._sendSuiteReviewState(activeSuite, examId, windowInfo.window || null);
+ } catch (suiteContextError) {
+ console.warn('[SuitePractice] 手动回看页面 ready 后补发上下文失败:', suiteContextError);
+ }
+ }
+
+ if (!windowInfo.reviewMode
&& this.components
&& this.components.practiceRecorder
&& typeof this.components.practiceRecorder.handleSessionStarted === 'function') {
- const recorderSessionId = (windowInfo && windowInfo.expectedSessionId) || payload.sessionId || this.generateSessionId(examId);
+ const recorderSessionId = expectedSessionId || this.generateSessionId(examId);
try {
this.components.practiceRecorder.handleSessionStarted({
examId,
@@ -3690,7 +6984,9 @@
pageType: payload.pageType || null,
url: payload.url || null,
title: payload.title || null,
- suiteSessionId: payload.suiteSessionId || null
+ suiteSessionId: registeredSuiteSessionId || null,
+ // 此处是练习页 SESSION_READY 后同步会话状态的时刻,注入启动时捕获的题库配置 ID。
+ libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, payload, windowInfo)
}
});
} catch (recorderError) {
@@ -3698,7 +6994,9 @@
}
}
- // 停止握手重试
+ if (!ownsReadyRegistration()) {
+ return false;
+ }
try {
if (this._handshakeTimers && this._handshakeTimers.has(examId)) {
clearInterval(this._handshakeTimers.get(examId));
@@ -3706,12 +7004,10 @@
}
} catch (_) { }
- if (windowInfo && windowInfo.reviewMode) {
+ if (windowInfo.reviewMode) {
this._dispatchReviewReplayForExam(examId, windowInfo.window || null);
}
-
- // 可以在这里发送额外的配置信息给数据采集器
- // 例如题目信息、特殊设置等
+ return true;
},
/**
@@ -3753,11 +7049,7 @@
return signals.includes('listening_record_bridge') || signals.includes('listening');
},
- _ensureRecorderSessionForListeningCompletion(examId, data) {
- if (!this._isListeningBridgeCompletionPayload(data)) {
- return;
- }
-
+ _ensureRecorderSessionForPracticeCompletion(examId, data, sourceWindow = null, defaults = {}) {
const recorder = this.components && this.components.practiceRecorder;
if (!recorder) {
return;
@@ -3783,18 +7075,35 @@
&& typeof recorder.activeSessions.has === 'function'
&& recorder.activeSessions.has(examId)
);
+ const pageType = defaults.pageType
+ || data?.pageType
+ || data?.metadata?.pageType
+ || data?.metadata?.type
+ || data?.type
+ || 'practice';
+ const practiceType = defaults.type
+ || data?.type
+ || data?.metadata?.type
+ || data?.metadata?.examType
+ || pageType;
+ const source = defaults.source
+ || data?.source
+ || data?.metadata?.source
+ || 'practice_page';
if (!hasActiveSession && typeof recorder.startPracticeSession === 'function') {
try {
recorder.startPracticeSession(examId, {
+ sessionId,
title: data?.title || data?.metadata?.examTitle || '',
category: data?.category || data?.pageType || data?.metadata?.category || '',
frequency: data?.frequency || data?.metadata?.frequency || '',
- type: 'listening',
- totalQuestions: data?.scoreInfo?.total || data?.totalQuestions || 0
+ type: practiceType,
+ totalQuestions: data?.scoreInfo?.total || data?.totalQuestions || 0,
+ libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, data, windowInfo)
});
} catch (startError) {
- console.warn('[PracticeRecorder] 听力完成前补建会话失败:', startError);
+ console.warn('[PracticeRecorder] 完成前补建会话失败:', startError);
}
}
@@ -3804,17 +7113,18 @@
examId,
sessionId,
metadata: {
- pageType: data?.pageType || 'listening',
- type: 'listening',
- examType: 'listening',
+ pageType,
+ type: practiceType,
+ examType: defaults.examType || practiceType,
url: data?.url || data?.metadata?.url || null,
title: data?.title || data?.metadata?.examTitle || null,
suiteSessionId: data?.suiteSessionId || data?.metadata?.suiteSessionId || null,
- source: data?.source || data?.metadata?.source || 'listening_record_bridge'
+ source,
+ libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, data, windowInfo)
}
});
} catch (startedError) {
- console.warn('[PracticeRecorder] 听力完成前同步会话状态失败:', startedError);
+ console.warn('[PracticeRecorder] 完成前同步会话状态失败:', startedError);
}
}
},
@@ -3848,14 +7158,66 @@
/**
* 处理练习完成(真实数据)
*/
- async handlePracticeComplete(examId, data, sourceWindow = null) {
+ async handlePracticeComplete(examId, data, sourceWindow = null, options = {}) {
+ const launchOwnership = options && options.launchOwnership || null;
+ const expectedRegistration = options && options.expectedRegistration
+ ? options.expectedRegistration
+ : this._captureExamSessionRegistration(examId);
+ sourceWindow = sourceWindow || (expectedRegistration && expectedRegistration.window) || null;
+ const ownsCompletionRegistration = () => Boolean(expectedRegistration) && (
+ expectedRegistration.window === sourceWindow
+ && this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ );
+ const ownsCompletion = () => Boolean(expectedRegistration) && (
+ expectedRegistration.window === sourceWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(examId, launchOwnership, expectedRegistration)
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ );
+ if (!ownsCompletion()) {
+ return false;
+ }
if (data && !data.sessionId) {
- data.sessionId = `${examId}_${Date.now()}`;
+ data.sessionId = String(expectedRegistration.expectedSessionId || '').trim()
+ || `${examId}_${Date.now()}`;
+ }
+ const completionSessionId = String(data && data.sessionId || '').trim();
+ if (!completionSessionId
+ || completionSessionId !== String(expectedRegistration.expectedSessionId || '').trim()) {
+ return false;
+ }
+ const registrationInfoAtCompletion = expectedRegistration.windowInfo;
+ const hasManagedSuiteOwnershipAtCompletion = Object.prototype.hasOwnProperty.call(
+ registrationInfoAtCompletion,
+ 'suiteSessionId'
+ );
+ const registeredSuiteSessionId = String(expectedRegistration.suiteSessionId || '').trim();
+ const submittedSuiteSessionId = String(
+ data && (
+ data.suiteSessionId
+ || (data.metadata && data.metadata.suiteSessionId)
+ ) || ''
+ ).trim();
+ if (hasManagedSuiteOwnershipAtCompletion) {
+ if ((registeredSuiteSessionId && submittedSuiteSessionId !== registeredSuiteSessionId)
+ || (!registeredSuiteSessionId && submittedSuiteSessionId)) {
+ return false;
+ }
+ data = Object.assign({}, data, {
+ suiteSessionId: registeredSuiteSessionId || null
+ });
+ if (!registeredSuiteSessionId
+ && !String(data.practiceMode || data.metadata && data.metadata.practiceMode || '').trim()) {
+ data.practiceMode = 'single';
+ }
}
if (String(data?.practiceMode || data?.metadata?.practiceMode || '').toLowerCase() === 'memorize') {
console.info('[ReadingMemorize] 背题模式完成事件不保存为正式练习记录:', examId);
return;
}
+ if (this._replayPracticeSubmitReceipt(examId, data, sourceWindow)) {
+ return true;
+ }
// 听力桥返回的填空答案直接按 answerComparison 检测,不能依赖题源目录名必须包含 P1/P4。
try {
@@ -3897,73 +7259,243 @@
console.warn('[DataCollection] 拼写错误检测失败,已忽略:', error);
}
this._normalizeListeningSpellingErrors(examId, data);
+ // Reading/placeholder completions need the same active-session rebind that
+ // listening already performed: hot-upgraded PracticeRecorder instances otherwise
+ // reject production saves when activeSessions was empty.
+ this._ensureRecorderSessionForPracticeCompletion(examId, data, sourceWindow);
let suiteHandlerDeclined = false;
const payloadSuiteSessionId = (
data
&& typeof data === 'object'
- && typeof data.suiteSessionId === 'string'
- ) ? data.suiteSessionId.trim() : '';
- const hasMappedSuiteExam = Boolean(this.suiteExamMap && this.suiteExamMap.has(examId));
- const hasActiveSuiteSession = Boolean(
- this.currentSuiteSession
- && this.currentSuiteSession.status === 'active'
- && (!payloadSuiteSessionId || this.currentSuiteSession.id === payloadSuiteSessionId)
+ ) ? String(
+ data.suiteSessionId
+ || (data.metadata && data.metadata.suiteSessionId)
+ || ''
+ ).trim() : '';
+ const registrationInfo = expectedRegistration.windowInfo;
+ const hasManagedSuiteOwnership = Object.prototype.hasOwnProperty.call(
+ registrationInfo,
+ 'suiteSessionId'
);
- const shouldDelegateToSuiteHandler = Boolean(
+ const registrationSuiteSessionId = String(expectedRegistration.suiteSessionId || '').trim();
+ const hasPayloadSuiteEntry = Boolean(
data
&& typeof data === 'object'
&& typeof data.suiteId === 'string'
&& data.suiteId.trim()
- ) || hasMappedSuiteExam || Boolean(payloadSuiteSessionId) || hasActiveSuiteSession;
+ );
+ let shouldDelegateToSuiteHandler = false;
+ if (hasManagedSuiteOwnership) {
+ if (registrationSuiteSessionId) {
+ if (payloadSuiteSessionId !== registrationSuiteSessionId) {
+ return false;
+ }
+ shouldDelegateToSuiteHandler = true;
+ } else if (payloadSuiteSessionId || hasPayloadSuiteEntry) {
+ // A managed ordinary registration can never acquire suite ownership
+ // from payload/global state after launch.
+ return false;
+ }
+ } else {
+ // Compatibility for legacy registrations that predate the explicit
+ // suiteSessionId field. New managed windows never enter this branch.
+ const hasMappedSuiteExam = Boolean(this.suiteExamMap && this.suiteExamMap.has(examId));
+ const hasActiveSuiteSession = Boolean(
+ this.currentSuiteSession
+ && this.currentSuiteSession.status === 'active'
+ && (!payloadSuiteSessionId || this.currentSuiteSession.id === payloadSuiteSessionId)
+ );
+ shouldDelegateToSuiteHandler = hasPayloadSuiteEntry
+ || hasMappedSuiteExam
+ || Boolean(payloadSuiteSessionId)
+ || hasActiveSuiteSession;
+ }
if (shouldDelegateToSuiteHandler && typeof this.handleSuitePracticeComplete === 'function') {
try {
- const handled = await this.handleSuitePracticeComplete(examId, data, sourceWindow);
+ if (!ownsCompletion()) {
+ return false;
+ }
+ const suiteOutcome = await this.handleSuitePracticeComplete(examId, data, sourceWindow);
+ const handled = suiteOutcome === true || Boolean(suiteOutcome && suiteOutcome.handled);
if (handled) {
- return;
+ const committed = !suiteOutcome || typeof suiteOutcome !== 'object' || suiteOutcome.committed !== false;
+ const suiteErrorCode = String(suiteOutcome && suiteOutcome.errorCode || '').trim();
+ const acknowledgeDurableOutcome = committed || suiteErrorCode === 'suite_advance_superseded';
+ if (acknowledgeDurableOutcome ? !ownsCompletionRegistration() : !ownsCompletion()) {
+ return acknowledgeDurableOutcome;
+ }
+ this._announcePracticeSubmitOutcome(examId, data, sourceWindow, acknowledgeDurableOutcome, {
+ errorCode: suiteErrorCode
+ }, {
+ expectedRegistration,
+ ...(acknowledgeDurableOutcome ? {} : { launchOwnership })
+ });
+ if (committed && suiteOutcome && suiteOutcome.teardownSession && typeof this._teardownSuiteSession === 'function') {
+ try {
+ this._scheduleSuiteSubmitTeardown(suiteOutcome.teardownSession);
+ } catch (teardownError) {
+ console.warn('[SuitePractice] 套题已提交,但延迟清理调度失败:', teardownError);
+ }
+ }
+ return acknowledgeDurableOutcome;
}
suiteHandlerDeclined = true;
} catch (suiteError) {
- console.error('[SuitePractice] 处理套题结果失败,回退至普通流程:', suiteError);
- window.showMessage && window.showMessage('套题模式出现异常,记录将以单篇形式保存。', 'warning');
+ console.error('[SuitePractice] 处理套题结果失败,保留 v2 恢复快照:', suiteError);
+ window.showMessage && window.showMessage('套题模式出现异常,恢复快照已保留,请稍后重试。', 'error');
suiteHandlerDeclined = true;
}
}
+ if (suiteHandlerDeclined && shouldDelegateToSuiteHandler) {
+ return false;
+ }
+ if (shouldDelegateToSuiteHandler && typeof this.handleSuitePracticeComplete !== 'function') {
+ return false;
+ }
+
+ if (!ownsCompletion()) {
+ return false;
+ }
+ if (!this._practiceCompletionGates) {
+ this._practiceCompletionGates = new Map();
+ }
+ const priorCompletionGate = this._practiceCompletionGates.get(String(examId || ''));
+ if (priorCompletionGate && priorCompletionGate.promise) {
+ try { await priorCompletionGate.promise; } catch (_) {}
+ if (!ownsCompletion()) {
+ return false;
+ }
+ }
+ let releaseCompletionGate = null;
+ const completionGate = {
+ registration: expectedRegistration,
+ sessionId: completionSessionId,
+ promise: new Promise((resolve) => { releaseCompletionGate = resolve; })
+ };
+ this._practiceCompletionGates.set(String(examId || ''), completionGate);
+
const recorder = this.components && this.components.practiceRecorder;
- const completionData = suiteHandlerDeclined
- ? Object.assign({}, data, {
- allowStandaloneSave: true,
- metadata: Object.assign({}, data?.metadata || {}, { allowStandaloneSave: true, suiteRecovery: true })
- })
- : data;
- this._ensureRecorderSessionForListeningCompletion(examId, completionData);
+ const completionData = data;
+ // The generic completion rebind above already covers listening payloads.
+ let completionCommitted = false;
+ let completedViaFallback = false;
try {
+ let persistedRecord = null;
if (recorder && typeof recorder.handleSessionCompleted === 'function') {
try {
- await recorder.handleSessionCompleted(completionData);
+ persistedRecord = await recorder.handleSessionCompleted(completionData);
} catch (recErr) {
console.warn('[DataCollection] PracticeRecorder 完成事件处理失败,改用降级存储:', recErr);
- await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true });
+ persistedRecord = await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true });
+ completedViaFallback = true;
}
} else {
- await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true });
+ persistedRecord = await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true });
+ completedViaFallback = true;
+ }
+
+ if (!persistedRecord || typeof persistedRecord !== 'object' || !String(persistedRecord.id || '').trim()) {
+ throw new Error('Practice completion returned without a committed record');
+ }
+
+ let completionReadable = false;
+ if (typeof this._isPracticeCompletionPersisted === 'function') {
+ try {
+ completionReadable = await this._isPracticeCompletionPersisted(persistedRecord);
+ } catch (verificationError) {
+ console.warn('[DataCollection] 练习记录提交后回读失败,不影响已提交结果:', verificationError);
+ }
+ }
+ if (!completionReadable) {
+ throw new Error('Practice completion could not be verified in canonical storage');
+ }
+ completionCommitted = true;
+
+ if (completedViaFallback && recorder && typeof recorder.endPracticeSession === 'function') {
+ const activeRecorderSession = recorder.activeSessions
+ && typeof recorder.activeSessions.get === 'function'
+ ? recorder.activeSessions.get(examId)
+ : null;
+ if ((activeRecorderSession
+ && String(activeRecorderSession.sessionId || '').trim() === completionSessionId)
+ || (!activeRecorderSession && ownsCompletion())) {
+ recorder.endPracticeSession(examId);
+ }
+ }
+
+ if (!ownsCompletion()) {
+ return true;
+ }
+
+ // 单篇阅读 final-submit 落库成功后,把已存档 recordId 回传给结果页,
+ // 使其可以在只读提交态编辑笔记并以 READING_ANNOTATION_SYNC 持久化回该记录。
+ // 套题流程在上方的 handleSuitePracticeComplete 分支已 return,不会走到这里。
+ const completionOwnershipOptions = { expectedRegistration, launchOwnership };
+ this._announceSubmittedReadingRecord(
+ examId,
+ persistedRecord,
+ completionData,
+ sourceWindow,
+ completionOwnershipOptions
+ );
+ this._announcePracticeSubmitOutcome(
+ examId,
+ completionData,
+ sourceWindow,
+ true,
+ {},
+ completionOwnershipOptions
+ );
+
+ if (typeof this.clearReadingDraftForExam === 'function') {
+ try {
+ await this.clearReadingDraftForExam(examId, {
+ sessionId: completionData && completionData.sessionId
+ ? String(completionData.sessionId)
+ : null,
+ // 完成事件已通过严格的 message/session 校验,删除该题草稿时
+ // 允许命中“恢复前的旧 session id”的存档,避免已提交答案被复活。
+ acceptResumeSessionId: true,
+ commitGuard: ownsCompletion
+ });
+ } catch (_) {
+ // draft cleanup is best-effort
+ }
+ }
+ if (!ownsCompletion()) {
+ return true;
}
// 刷新内存中的练习记录,确保无需手动刷新即可看到
// 注意:数据已落库,UI 同步失败不应传播为"保存失败",否则会误导用户并可能诱发重复提交。
try {
+ if (!ownsCompletion()) {
+ return true;
+ }
if (typeof window.syncPracticeRecords === 'function') {
await window.syncPracticeRecords({ forceRender: true });
- } else if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- const latest = await window.PracticeRecordAPI.list();
- this.setState('practice.records', Array.isArray(latest) ? latest : []);
+ } else {
+ const [latest, index] = await Promise.all([
+ window.AppData.practice.list({ projection: 'light' }),
+ window.resolveActiveLibraryIndex()
+ ]);
+ if (typeof window.refreshBrowseProgressFromRecords === 'function') {
+ window.refreshBrowseProgressFromRecords(latest, index);
+ }
+ if (typeof window.updatePracticeView === 'function') {
+ window.updatePracticeView(latest, index);
+ }
}
} catch (syncErr) {
console.error('[DataCollection] 刷新练习记录失败(数据已保存,不影响落库结果):', syncErr);
}
+ if (!ownsCompletion()) {
+ return true;
+ }
// P1/P4:落库后同步保存错词到词表(multi-suite 在 finalizeMultiSuiteRecord 内处理)
if (Array.isArray(data?.spellingErrors) && data.spellingErrors.length > 0
@@ -3975,33 +7507,83 @@
console.warn('[DataCollection] 保存拼写错误词表失败(不影响主流程):', saveError);
}
}
+ if (!ownsCompletion()) {
+ return true;
+ }
// 更新UI状态
this.updateExamStatus(examId, 'completed');
// 显示完成通知(使用真实数据)
- await this.showRealCompletionNotification(examId, data);
-
- // 检查成就
- if (window.AchievementManager) {
- window.AchievementManager.check(data?.realData).catch(console.warn);
+ await this.showRealCompletionNotification(examId, data, {
+ commitGuard: ownsCompletion
+ });
+ if (!ownsCompletion()) {
+ return true;
}
- // 刷新练习记录显示
- if (typeof updatePracticeView === 'function') {
- updatePracticeView();
+ // 检查成就(解锁判定由 achievements.progress projector 负责,这里只读取差异并提示)
+ if (window.AchievementManager) {
+ window.AchievementManager.check().catch(console.warn);
}
} catch (error) {
console.error('[DataCollection] 处理练习完成数据失败:', error);
- window.showMessage && window.showMessage('练习记录保存失败,请稍后重试', 'error');
+ if (ownsCompletion()) {
+ window.showMessage && window.showMessage('练习记录保存失败,请稍后重试', 'error');
+ this._announcePracticeSubmitOutcome(examId, completionData, sourceWindow, false, {
+ errorCode: 'save_failed'
+ }, {
+ expectedRegistration,
+ launchOwnership
+ });
+ }
} finally {
- if (this._isResetCapableUnifiedReadingCompletion(completionData, sourceWindow)) {
- await this.retainExamWindowAfterCompletion(examId, sourceWindow, completionData);
- } else {
- this.cleanupExamSession(examId);
+ try {
+ if (completionCommitted) {
+ try {
+ if (ownsCompletion()) {
+ if (this._isResetCapableUnifiedReadingCompletion(completionData, sourceWindow)) {
+ await this.retainExamWindowAfterCompletion(
+ examId,
+ sourceWindow,
+ completionData,
+ { expectedRegistration, launchOwnership }
+ );
+ } else {
+ await this.cleanupExamSession(examId, {
+ expectedRegistration,
+ recoverySessionId: completionSessionId
+ });
+ }
+ } else {
+ const staleCleanupGuard = () => {
+ const current = this.examWindows && this.examWindows.get(examId);
+ return !current
+ || String(current.expectedSessionId || '').trim() !== completionSessionId;
+ };
+ if (staleCleanupGuard()) {
+ await this._discardActiveSessionsForExam(examId, {
+ expectedSessionId: completionSessionId,
+ commitGuard: staleCleanupGuard
+ });
+ }
+ }
+ } catch (cleanupError) {
+ console.warn('[DataCollection] 练习已提交,但会话清理失败:', cleanupError);
+ }
+ }
+ } finally {
+ if (this._practiceCompletionGates
+ && this._practiceCompletionGates.get(String(examId || '')) === completionGate) {
+ this._practiceCompletionGates.delete(String(examId || ''));
+ }
+ if (typeof releaseCompletionGate === 'function') {
+ releaseCompletionGate();
+ }
}
}
+ return completionCommitted;
},
/**
@@ -4018,12 +7600,7 @@
type: 'data_collection_error'
};
- const errorLogs = await storage.get('collection_errors', []);
- errorLogs.push(errorInfo);
- if (errorLogs.length > 50) {
- errorLogs.splice(0, errorLogs.length - 50);
- }
- await storage.set('collection_errors', errorLogs);
+ console.warn('[DataCollection] 诊断信息:', errorInfo);
// 标记该会话使用模拟数据
if (this.examWindows && this.examWindows.has(examId)) {
@@ -4102,17 +7679,16 @@
throw new Error(`无法找到题目信息: ${examId}`);
}
- const api = window.PracticeRecordAPI;
- if (!api || typeof api.saveCompletion !== 'function') {
- throw new Error('统一练习记录 API 未就绪');
- }
-
const metadata = Object.assign({}, realData?.metadata || {}, {
examId,
examTitle: exam.title || realData?.title || '',
category: exam.category || realData?.category || realData?.metadata?.category || 'unknown',
frequency: exam.frequency || realData?.frequency || realData?.metadata?.frequency || 'unknown',
- type: exam.type || realData?.type || realData?.practiceType || null
+ type: exam.type || realData?.type || realData?.practiceType || null,
+ // 启动时捕获的题库配置 ID;优先取 realData.metadata 显式值,再回退到启动时
+ // 在 openExam 捕获的 mixin 私有 Map 值,最后显式随 metadata 写入为 null,
+ // 让记录来源稳定不受到提交时当前激活题库的影响。
+ libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, realData)
});
const payload = Object.assign({}, realData, {
@@ -4124,19 +7700,17 @@
metadata
});
- const savedRecord = await api.saveCompletion(payload, {
- examId,
- sessionId: payload.sessionId || realData?.sessionId || null,
- examEntry: exam,
- metadata
- }, exam, {
- currentVersion: (window.scoreStorage && window.scoreStorage.currentVersion) || '1.0.0',
- maxRecords: (window.scoreStorage && window.scoreStorage.maxRecords) || 1000,
- updateStats: true
+ const receipt = await window.AppData.practice.completeAttempt({
+ record: payload,
+ operationId: payload.operationId
+ || payload.messageId
+ || (payload.submissionId
+ ? `practice-complete:${String(payload.examId || examId)}:${String(payload.sessionId || 'session')}:${String(payload.submissionId)}`
+ : undefined)
});
console.log('[DataCollection] 练习完成数据已保存到 canonical store');
- return savedRecord;
+ return receipt.record;
} catch (error) {
console.error('[DataCollection] 保存真实数据失败:', error);
throw error;
@@ -4146,12 +7720,21 @@
/**
* 显示真实完成通知
*/
- async showRealCompletionNotification(examId, realData) {
+ async showRealCompletionNotification(examId, realData, options = {}) {
+ const commitGuard = options && typeof options.commitGuard === 'function'
+ ? options.commitGuard
+ : null;
+ if (commitGuard && commitGuard() !== true) {
+ return false;
+ }
const examIndex = await getActiveExamIndexSnapshot();
+ if (commitGuard && commitGuard() !== true) {
+ return false;
+ }
const list = Array.isArray(examIndex) ? examIndex : [];
const exam = list.find(e => e.id === examId);
- if (!exam) return;
+ if (!exam) return false;
const scoreInfo = realData.scoreInfo;
if (scoreInfo) {
@@ -4184,27 +7767,108 @@
: 0;
window.showMessage(`练习完成!\n${exam.title}\n用时: ${duration} 分钟`, 'success');
}
+ return true;
},
/**
* 处理题目窗口关闭
*/
- handleExamWindowClosed(examId) {
+ async handleExamWindowClosed(examId, closedWindow = null) {
+ const info = this.examWindows && this.examWindows.get(examId);
+ if (!info) {
+ return false;
+ }
+ const closedRegistration = info
+ ? this._captureExamSessionRegistration(examId, info)
+ : null;
+ const expectedWindow = info && info.window ? info.window : null;
+ if (closedWindow && expectedWindow && closedWindow !== expectedWindow) {
+ return false;
+ }
+ if (info && info.closeMonitor) {
+ try { clearInterval(info.closeMonitor); } catch (_) {}
+ info.closeMonitor = null;
+ }
- if (this.suiteExamMap && this.suiteExamMap.has(examId) && this.currentSuiteSession && this.currentSuiteSession.status === 'active' && this.suiteExamMap.get(examId) === this.currentSuiteSession.id) {
- window.showMessage && window.showMessage('套题练习窗口已关闭,套题模式将被中断并回退到普通模式。', 'warning');
- if (typeof this._abortSuiteSession === 'function') {
- this._abortSuiteSession(this.currentSuiteSession, {}).catch(error => {
- console.error('[SuitePractice] 中断套题失败:', error);
- });
+ // A pagehide draft can already be accepted while its IDB transaction is
+ // still reading. Drain the queue before removing the registration that its
+ // commit guard owns; if a new registration appears while waiting, fail
+ // closed and leave that replacement untouched.
+ const pendingDraftWrites = !closedRegistration.suiteSessionId
+ ? this._readingDraftStoreQueue
+ : null;
+ if (pendingDraftWrites && typeof pendingDraftWrites.then === 'function') {
+ try { await pendingDraftWrites; } catch (_) {}
+ }
+ if (closedRegistration
+ && !this._isExamSessionRegistrationCurrent(examId, closedRegistration)) {
+ if (typeof this.cleanupExamSession === 'function') {
+ await this.cleanupExamSession(examId, { expectedRegistration: closedRegistration });
}
+ return false;
}
- // 更新题目状态
- this.updateExamStatus(examId, 'interrupted');
+ const suite = this.currentSuiteSession;
+ const isSuiteExam = Boolean(
+ suite
+ && this.suiteExamMap
+ && this.suiteExamMap.get(examId) === suite.id
+ && suite.status === 'active'
+ );
+ const isCompletedSuiteExam = Boolean(
+ suite
+ && this.suiteExamMap
+ && this.suiteExamMap.get(examId) === suite.id
+ && suite.status === 'completed'
+ );
+ if (isCompletedSuiteExam) {
+ // 记录已提交,子页完成后的关闭不应把末篇标成 interrupted;
+ // 会话清理由 30s teardown / 下次 launch 负责。
+ this.updateExamStatus(examId, 'completed');
+ if (typeof this.cleanupExamSession === 'function') {
+ await this.cleanupExamSession(examId, closedRegistration
+ ? { expectedRegistration: closedRegistration }
+ : {});
+ }
+ return true;
+ }
+ if (isSuiteExam) {
+ if (String(suite.activeExamId || '') !== String(examId)) {
+ return false;
+ }
+ if (closedWindow && suite.windowRef && closedWindow !== suite.windowRef) {
+ return false;
+ }
+ const pausedAtMs = Date.now();
+ if (suite.suiteTimerRunning !== false
+ || !Number.isFinite(Number(suite.suiteTimerPausedAtMs))) {
+ suite.suiteTimerPausedAtMs = pausedAtMs;
+ }
+ suite.suiteTimerRunning = false;
+ suite.windowRef = null;
+ suite.status = 'active';
+ suite.lastUpdate = pausedAtMs;
+ let persisted = false;
+ if (typeof this._commitSuiteRecovery === 'function') {
+ persisted = await this._commitSuiteRecovery(suite, { reason: 'window-close' });
+ }
+ if (!persisted && typeof this._mirrorSessionToStorage === 'function') {
+ this._mirrorSessionToStorage(suite);
+ }
+ if (persisted) {
+ window.showMessage && window.showMessage('套题练习窗口已关闭,当前进度已暂停并保留,可从套题模式继续。', 'warning');
+ } else {
+ window.showMessage && window.showMessage('套题窗口已关闭,但恢复快照保存失败,请勿关闭主页面。', 'error');
+ }
+ }
- // 清理会话
- this.cleanupExamSession(examId);
+ this.updateExamStatus(examId, 'interrupted');
+ if (typeof this.cleanupExamSession === 'function') {
+ await this.cleanupExamSession(examId, closedRegistration
+ ? { expectedRegistration: closedRegistration }
+ : {});
+ }
+ return true;
},
/**
@@ -4355,27 +8019,105 @@
return [];
}
const normalizedKeepExamId = keepExamId != null ? String(keepExamId).trim() : '';
- const staleExamIds = [];
+ const staleRegistrations = [];
+ const retainedRecoveryCleanups = [];
this.examWindows.forEach((windowInfo, candidateExamId) => {
const normalizedCandidateExamId = candidateExamId != null ? String(candidateExamId).trim() : '';
- if (!normalizedCandidateExamId || (normalizedKeepExamId && normalizedCandidateExamId === normalizedKeepExamId)) {
+ if (!normalizedCandidateExamId) {
return;
}
if (windowInfo && windowInfo.window === targetWindow) {
- staleExamIds.push(normalizedCandidateExamId);
+ const registration = this._captureExamSessionRegistration(
+ normalizedCandidateExamId,
+ windowInfo
+ );
+ if (registration) {
+ const recoverySessionId = windowInfo.reassignedFromSuiteTeardownOwner === true
+ ? ''
+ : String(
+ windowInfo.reassignedFromExpectedSessionId
+ || registration.expectedSessionId
+ || ''
+ ).trim();
+ const cleanup = {
+ examId: normalizedCandidateExamId,
+ registration,
+ recoverySessionId
+ };
+ if (normalizedKeepExamId && normalizedCandidateExamId === normalizedKeepExamId) {
+ if (recoverySessionId
+ && recoverySessionId !== String(registration.expectedSessionId || '').trim()) {
+ retainedRecoveryCleanups.push(cleanup);
+ }
+ } else {
+ staleRegistrations.push(cleanup);
+ }
+ }
}
});
- for (const staleExamId of staleExamIds) {
+ for (const retained of retainedRecoveryCleanups) {
+ const commitGuard = () => {
+ const current = this.examWindows && this.examWindows.get(retained.examId);
+ return this._isExamSessionRegistrationCurrent(
+ retained.examId,
+ retained.registration
+ ) && String(current && current.expectedSessionId || '').trim() !== retained.recoverySessionId;
+ };
+ try {
+ await this._discardActiveSessionsForExam(retained.examId, {
+ expectedSessionId: retained.recoverySessionId,
+ commitGuard
+ });
+ } catch (error) {
+ console.warn('[App] 清理复用窗口旧恢复会话失败:', retained.examId, error);
+ }
+ }
+ for (const stale of staleRegistrations) {
try {
- await this.cleanupExamSession(staleExamId);
+ await this.cleanupExamSession(stale.examId, {
+ expectedRegistration: stale.registration,
+ recoverySessionId: stale.recoverySessionId
+ });
} catch (error) {
- console.warn('[App] 清理复用窗口旧题目会话失败:', staleExamId, error);
+ console.warn('[App] 清理复用窗口旧题目会话失败:', stale.examId, error);
}
}
- return staleExamIds;
+ return staleRegistrations.map(stale => stale.examId);
},
- async cleanupExamSession(examId) {
+ async cleanupExamSession(examId, options = {}) {
+ const hasExpectedRegistration = Object.prototype.hasOwnProperty.call(options, 'expectedRegistration');
+ const expectedRegistration = hasExpectedRegistration ? options.expectedRegistration : null;
+ const requestedRecoverySessionId = Object.prototype.hasOwnProperty.call(options, 'recoverySessionId')
+ ? String(options.recoverySessionId || '').trim()
+ : String(expectedRegistration && expectedRegistration.expectedSessionId || '').trim();
+ const recoveryCleanupGuard = requestedRecoverySessionId
+ ? () => {
+ const current = this.examWindows && this.examWindows.get(examId);
+ return !current
+ || String(current.expectedSessionId || '').trim() !== requestedRecoverySessionId;
+ }
+ : null;
+ if (hasExpectedRegistration && !this._isExamSessionRegistrationCurrent(examId, expectedRegistration)) {
+ const current = this.examWindows && this.examWindows.get(examId);
+ // The map/handler now belong to another registration. The old recovery may
+ // still be removed by its exact session id, unless that id has been reused.
+ if (requestedRecoverySessionId
+ && (!current || String(current.expectedSessionId || '').trim() !== requestedRecoverySessionId)) {
+ await this._discardActiveSessionsForExam(examId, {
+ expectedSessionId: requestedRecoverySessionId,
+ commitGuard: recoveryCleanupGuard
+ });
+ }
+ return false;
+ }
+
+ const windowInfo = this.examWindows && this.examWindows.get(examId);
+ if (windowInfo && windowInfo.closeMonitor) {
+ try { clearInterval(windowInfo.closeMonitor); } catch (_) {}
+ windowInfo.closeMonitor = null;
+ }
+
// 清理窗口引用
if (this.examWindows && this.examWindows.has(examId)) {
this.examWindows.delete(examId);
@@ -4389,9 +8131,15 @@
}
// 清理活动会话
- const activeSessions = await storage.get('active_sessions', []);
- const updatedSessions = activeSessions.filter(session => session.examId !== examId);
- await storage.set('active_sessions', updatedSessions);
+ if (hasExpectedRegistration) {
+ await this._discardActiveSessionsForExam(examId, {
+ expectedSessionId: requestedRecoverySessionId,
+ commitGuard: recoveryCleanupGuard
+ });
+ } else {
+ await this._discardActiveSessionsForExam(examId);
+ }
+ return true;
},
/**
@@ -4573,7 +8321,7 @@
* 显示活动会话详情
*/
async showActiveSessionsDetails() {
- const activeSessions = await storage.get('active_sessions', []);
+ const activeSessions = await window.AppData.recovery.listActiveSessions();
const examIndex = await getActiveExamIndexSnapshot();
if (activeSessions.length === 0) {
@@ -4664,7 +8412,7 @@
* 关闭所有题目会话
*/
async closeAllExamSessions() {
- const activeSessions = await storage.get('active_sessions', []);
+ const activeSessions = await window.AppData.recovery.listActiveSessions();
activeSessions.forEach(session => {
this.closeExamSession(session.examId);
diff --git a/js/app/main-entry.js b/js/app/main-entry.js
index cd3a420c..163b8bb6 100644
--- a/js/app/main-entry.js
+++ b/js/app/main-entry.js
@@ -7,8 +7,9 @@
var SESSION_GROUP = 'session-suite';
var STATE_CORE_GROUP = 'state-core';
var SETTINGS_GROUP = 'settings-tools';
- var READING_CANDIDATE_CODE_PREF_KEY = 'ielts_reading_candidate_code_preferences_v1';
var READING_CANDIDATE_CODE_PATTERN = /^\d{6}$/;
+ var readingCandidateCodeCache = { mode: 'auto', customCode: '' };
+ var readingCandidateCodeReady = null;
function ensureLazyGroup(name) {
if (!name || !global.AppLazyLoader || typeof global.AppLazyLoader.ensureGroup !== 'function') {
@@ -46,34 +47,32 @@
}
function readReadingCandidateCodePreferences() {
- try {
- var raw = global.localStorage && global.localStorage.getItem(READING_CANDIDATE_CODE_PREF_KEY);
- var parsed = raw ? JSON.parse(raw) : null;
- var mode = parsed && parsed.mode === 'custom' ? 'custom' : 'auto';
- var customCode = parsed && typeof parsed.customCode === 'string'
- ? parsed.customCode.replace(/\D/g, '').slice(0, 6)
- : '';
- return {
- mode: mode,
- customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : ''
- };
- } catch (_) {
- return { mode: 'auto', customCode: '' };
- }
- }
-
- function saveReadingCandidateCodePreferences(preferences) {
+ return Object.assign({}, readingCandidateCodeCache);
+ }
+
+ function loadReadingCandidateCodePreferences() {
+ if (readingCandidateCodeReady) return readingCandidateCodeReady;
+ readingCandidateCodeReady = Promise.resolve().then(async function loadCandidateCode() {
+ await global.AppData.ready;
+ var stored = await global.AppData.preferences.getCandidateCode();
+ var mode = stored && stored.mode === 'custom' ? 'custom' : 'auto';
+ var customCode = stored && typeof stored.customCode === 'string' ? stored.customCode.replace(/\D/g, '').slice(0, 6) : '';
+ readingCandidateCodeCache = { mode: mode, customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' };
+ return readingCandidateCodeCache;
+ });
+ return readingCandidateCodeReady;
+ }
+
+ async function saveReadingCandidateCodePreferences(preferences) {
+ await loadReadingCandidateCodePreferences();
var next = {
mode: preferences && preferences.mode === 'custom' ? 'custom' : 'auto',
customCode: preferences && typeof preferences.customCode === 'string'
? preferences.customCode.replace(/\D/g, '').slice(0, 6)
: ''
};
- try {
- if (global.localStorage) {
- global.localStorage.setItem(READING_CANDIDATE_CODE_PREF_KEY, JSON.stringify(next));
- }
- } catch (_) { }
+ await global.AppData.preferences.setCandidateCode(next);
+ readingCandidateCodeCache = next;
return next;
}
@@ -89,7 +88,8 @@
}
}
- function setupReadingCandidateCodeSettings() {
+ async function setupReadingCandidateCodeSettings() {
+ await loadReadingCandidateCodePreferences();
var input = document.getElementById('reading-candidate-code-input');
var saveButton = document.getElementById('reading-candidate-code-save-btn');
var randomButton = document.getElementById('reading-candidate-code-random-btn');
@@ -144,7 +144,7 @@
setReadingCandidateCodeStatus(status, '', '');
});
- saveButton.addEventListener('click', function saveCandidateCodeSettings() {
+ saveButton.addEventListener('click', async function saveCandidateCodeSettings() {
var mode = getSelectedMode();
var code = input.value.replace(/\D/g, '').slice(0, 6);
if (mode === 'custom' && !READING_CANDIDATE_CODE_PATTERN.test(code)) {
@@ -152,7 +152,7 @@
input.focus();
return;
}
- saveReadingCandidateCodePreferences({ mode: mode, customCode: code });
+ await saveReadingCandidateCodePreferences({ mode: mode, customCode: code });
setReadingCandidateCodeStatus(
status,
mode === 'custom' ? '已保存自定义编码:' + code : '已保存:自动生成。',
@@ -160,11 +160,11 @@
);
});
- randomButton.addEventListener('click', function generateCandidateCode() {
+ randomButton.addEventListener('click', async function generateCandidateCode() {
var code = hashReadingCandidateCode(createReadingCandidateCodeSeed());
setSelectedMode('custom');
input.value = code;
- saveReadingCandidateCodePreferences({ mode: 'custom', customCode: code });
+ await saveReadingCandidateCodePreferences({ mode: 'custom', customCode: code });
setReadingCandidateCodeStatus(status, '已随机生成并保存:' + code, 'success');
});
@@ -183,12 +183,13 @@
}
}
- function setupPracticeTimerSettings() {
+ async function setupPracticeTimerSettings() {
var manager = global.PracticeTimerPreferences;
if (!manager || typeof manager.read !== 'function' || typeof manager.save !== 'function') {
return;
}
+ if (manager.ready) await manager.ready;
Array.prototype.slice.call(document.querySelectorAll('.practice-timer-card[data-timer-scope]'))
.forEach(function bindTimerCard(card) {
var scope = String(card.dataset.timerScope || '').toLowerCase() === 'listening'
@@ -244,10 +245,14 @@
setPracticeTimerStatus(status, '', '');
});
});
- saveButton.addEventListener('click', function saveTimerPreferences() {
- var saved = manager.save(scope, collect());
- apply(saved);
- setPracticeTimerStatus(status, '已保存', 'success');
+ saveButton.addEventListener('click', async function saveTimerPreferences() {
+ try {
+ var saved = await manager.save(scope, collect());
+ apply(saved);
+ setPracticeTimerStatus(status, '已保存', 'success');
+ } catch (error) {
+ setPracticeTimerStatus(status, '保存失败', 'error');
+ }
});
apply(manager.read(scope));
@@ -343,20 +348,6 @@
return ensureLazyGroup(SETTINGS_GROUP);
}
- function setStorageNamespace() {
- if (!global.storage || !global.storage.ready || typeof global.storage.setNamespace !== 'function') {
- return;
- }
- global.storage.ready.then(function applyNamespace() {
- global.storage.setNamespace('exam_system');
- try {
- console.log('[MainEntry] 已设置存储命名空间: exam_system');
- } catch (_) { }
- }).catch(function handleNamespaceError(error) {
- console.error('[MainEntry] 设置命名空间失败', error);
- });
- }
-
function initializeNavigationShell() {
try {
if (global.NavigationController && typeof global.NavigationController.ensure === 'function') {
@@ -594,35 +585,29 @@
return active.id.replace(/-view$/, '');
}
- function syncOverviewAfterIndexLoad() {
- if (!global.app || typeof global.app.setState !== 'function') {
- return;
- }
- if (typeof global.getExamIndexState !== 'function') {
- return;
- }
- var list = global.getExamIndexState();
+ function syncOverviewAfterIndexLoad(index) {
+ var list = Array.isArray(index) ? index : [];
if (!Array.isArray(list)) {
return;
}
try {
- global.app.setState('exam.index', list.slice());
- if (typeof global.app.refreshOverviewData === 'function') {
- global.app.refreshOverviewData();
+ if (typeof global.updateOverview === 'function') {
+ global.updateOverview(list);
}
} catch (error) {
console.warn('[MainEntry] 同步总览数据失败:', error);
}
}
- function handleExamIndexLoaded() {
- syncOverviewAfterIndexLoad();
+ function handleExamIndexLoaded(index) {
+ var snapshot = Array.isArray(index) ? index : [];
+ syncOverviewAfterIndexLoad(snapshot);
var activeView = getActiveViewName();
if (activeView === 'browse') {
ensureBrowseGroup().then(function afterBrowseReady() {
if (typeof global.loadExamList === 'function') {
- try { global.loadExamList(); } catch (_) { }
+ try { global.loadExamList(snapshot); } catch (_) { }
}
var loading = document.querySelector('#browse-view .loading');
if (loading) {
@@ -636,8 +621,8 @@
if (activeView === 'practice') {
Promise.all([ensureBrowseGroup(), ensurePracticeSuiteGroup()]).then(function onPracticeReady() {
- if (typeof global.updatePracticeView === 'function') {
- try { global.updatePracticeView(); } catch (_) { }
+ if (typeof global.startPracticeRecordsSyncInBackground === 'function') {
+ global.startPracticeRecordsSyncInBackground('exam-index-loaded', { forceRender: true });
}
}).catch(function handlePracticeLoadError(error) {
console.error('[MainEntry] practice 视图模块加载失败:', error);
@@ -645,8 +630,8 @@
}
}
- global.addEventListener('examIndexLoaded', function onExamIndexLoaded() {
- handleExamIndexLoaded();
+ global.addEventListener('examIndexLoaded', function onExamIndexLoaded(event) {
+ handleExamIndexLoaded(event && event.detail ? event.detail.index : []);
});
global.addEventListener('appCoreReady', function onAppCoreReady() {
@@ -677,7 +662,6 @@
}
function init() {
- setStorageNamespace();
initializeNavigationShell();
setupReadingCandidateCodeSettings();
setupPracticeTimerSettings();
diff --git a/js/app/spellingErrorCollector.js b/js/app/spellingErrorCollector.js
index dbc0d610..0237471e 100644
--- a/js/app/spellingErrorCollector.js
+++ b/js/app/spellingErrorCollector.js
@@ -61,12 +61,11 @@
// 错误缓存,用于临时存储检测到的错误
this.errorCache = new Map();
- // 词表存储键配置
- this.storageKeys = {
- p1: 'vocab_list_p1_errors',
- p4: 'vocab_list_p4_errors',
- master: 'vocab_list_master_errors',
- custom: 'vocab_list_custom'
+ this.collectionIds = {
+ p1: 'spelling-errors-p1',
+ p4: 'spelling-errors-p4',
+ master: 'spelling-errors-master',
+ custom: 'custom'
};
this.lexiconCache = null;
@@ -84,17 +83,8 @@
*/
async init() {
try {
- // 等待存储系统就绪
- if (window.storage && window.storage.ready) {
- await window.storage.ready;
- }
-
- // 设置命名空间
- if (window.storage && typeof window.storage.setNamespace === 'function') {
- window.storage.setNamespace('exam_system');
- console.log('[SpellingErrorCollector] 存储命名空间已设置');
- }
-
+ if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab is unavailable');
+ await window.AppData.ready;
this.initialized = true;
console.log('[SpellingErrorCollector] 初始化完成');
} catch (error) {
@@ -462,14 +452,9 @@
try {
await this.ensureInitialized();
- const storageKey = this.storageKeys[listId] || listId;
-
- if (!window.storage) {
- console.warn('[SpellingErrorCollector] 存储系统不可用');
- return null;
- }
-
- const list = await window.storage.get(storageKey);
+ const collectionId = this.collectionIds[listId] || listId;
+ const collections = await window.AppData.vocab.listCollections();
+ const list = collections[collectionId];
const normalizedList = this.normalizeVocabListShape(list, listId, listId);
if (normalizedList) {
@@ -481,7 +466,7 @@
return null;
} catch (error) {
console.error(`[SpellingErrorCollector] 加载词表失败: ${listId}`, error);
- return null;
+ throw error;
}
}
@@ -493,31 +478,10 @@
async saveVocabList(vocabList) {
try {
await this.ensureInitialized();
-
- if (!vocabList || !vocabList.id) {
- console.error('[SpellingErrorCollector] 无效的词表对象');
- return false;
- }
-
- if (!Array.isArray(vocabList.words)) {
- vocabList.words = [];
- }
-
- vocabList = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList;
-
- // 更新统计信息
- vocabList.stats = vocabList.stats || {};
- vocabList.stats.totalWords = vocabList.words.length;
- vocabList.updatedAt = Date.now();
-
- const storageKey = this.storageKeys[vocabList.id] || vocabList.id;
-
- if (!window.storage) {
- console.warn('[SpellingErrorCollector] 存储系统不可用');
- return false;
- }
-
- await window.storage.set(storageKey, vocabList);
+ vocabList = this.prepareVocabList(vocabList);
+ if (!vocabList) return false;
+ const collectionId = this.collectionIds[vocabList.id] || vocabList.id;
+ await window.AppData.vocab.saveCollection(collectionId, vocabList);
console.log(`[SpellingErrorCollector] 保存词表成功: ${vocabList.id}, 单词数: ${vocabList.words.length}`);
return true;
@@ -527,6 +491,19 @@
}
}
+ prepareVocabList(vocabList) {
+ if (!vocabList || !vocabList.id) {
+ console.error('[SpellingErrorCollector] 无效的词表对象');
+ return null;
+ }
+ if (!Array.isArray(vocabList.words)) vocabList.words = [];
+ const normalized = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList;
+ normalized.stats = normalized.stats || {};
+ normalized.stats.totalWords = normalized.words.length;
+ normalized.updatedAt = Date.now();
+ return normalized;
+ }
+
/**
* 获取词表单词数量
* @param {string} listId - 词表ID
@@ -538,7 +515,7 @@
return list ? list.words.length : 0;
} catch (error) {
console.error(`[SpellingErrorCollector] 获取词表单词数失败: ${listId}`, error);
- return 0;
+ throw error;
}
}
@@ -1108,17 +1085,25 @@
try {
await this.ensureInitialized();
await this.ensureCoreLexicon();
-
- // 按来源分组错误
const errorsBySource = this.groupErrorsBySource(errors);
-
- // 保存到各个来源的词表
+ const pendingCollections = {};
for (const [source, sourceErrors] of Object.entries(errorsBySource)) {
- await this.saveErrorsToList(source, sourceErrors);
+ let vocabList = await this.loadVocabList(source);
+ if (!vocabList) vocabList = this.createEmptyList(source, source);
+ this.mergeErrorsToList(vocabList, sourceErrors);
+ const prepared = this.prepareVocabList(vocabList);
+ if (!prepared) throw new Error(`生成 ${source} 错词词表失败`);
+ pendingCollections[this.collectionIds[source] || source] = prepared;
}
-
- // 同步到综合词表
- await this.syncToMasterList(errors);
+
+ let masterList = await this.loadVocabList('master');
+ if (!masterList) masterList = this.createEmptyList('master', 'all');
+ this.mergeErrorsToList(masterList, errors);
+ const preparedMaster = this.prepareVocabList(masterList);
+ if (!preparedMaster) throw new Error('生成综合错词词表失败');
+ pendingCollections[this.collectionIds.master] = preparedMaster;
+
+ await window.AppData.vocab.saveCollections(pendingCollections);
console.log(`[SpellingErrorCollector] 保存完成,共保存 ${errors.length} 个错误`);
return true;
@@ -1269,7 +1254,9 @@
);
if (vocabList.words.length < originalLength) {
- await this.saveVocabList(vocabList);
+ if (!await this.saveVocabList(vocabList)) {
+ return false;
+ }
console.log(`[SpellingErrorCollector] 从词表 ${listId} 移除单词: ${word}`);
return true;
} else {
@@ -1299,7 +1286,9 @@
vocabList.words = [];
vocabList.updatedAt = Date.now();
- await this.saveVocabList(vocabList);
+ if (!await this.saveVocabList(vocabList)) {
+ return false;
+ }
console.log(`[SpellingErrorCollector] 清空词表: ${listId}`);
return true;
diff --git a/js/app/state-service.js b/js/app/state-service.js
index 56524814..87a35dfc 100644
--- a/js/app/state-service.js
+++ b/js/app/state-service.js
@@ -5,32 +5,6 @@
return Array.isArray(value) ? value.slice() : [];
}
- function cloneValue(value) {
- if (value === null || value === undefined) {
- return value;
- }
- if (typeof global.structuredClone === 'function') {
- try {
- return global.structuredClone(value);
- } catch (_) { }
- }
- try {
- return JSON.parse(JSON.stringify(value));
- } catch (_) {
- if (Array.isArray(value)) {
- return value.map((item) => cloneValue(item));
- }
- if (value && typeof value === 'object') {
- return Object.assign({}, value);
- }
- return value;
- }
- }
-
- function clonePracticeRecords(records) {
- return Array.isArray(records) ? records.map((record) => cloneValue(record)) : [];
- }
-
function cloneSet(value) {
if (value instanceof Set) {
return new Set(value);
@@ -171,8 +145,6 @@
this.globalBindingsInstalled = false;
this.state = {
- examIndex: cloneArray(global.examIndex),
- practiceRecords: [],
filteredExams: Array.isArray(global.filteredExams) ? global.filteredExams : [],
browseFilter: normalizeFilter(global.__browseFilter),
bulkDeleteMode: !!global.bulkDeleteMode,
@@ -183,8 +155,6 @@
};
this.listeners = {
- examIndex: new Set(),
- practiceRecords: new Set(),
filteredExams: new Set(),
browseFilter: new Set(),
bulkDeleteMode: new Set(),
@@ -240,13 +210,11 @@
try {
if (app.state.exam) {
- app.state.exam.index = this.state.examIndex;
app.state.exam.currentCategory = this.state.browseFilter.category;
app.state.exam.currentExamType = this.state.browseFilter.type;
app.state.exam.filteredExams = this.state.filteredExams;
}
if (app.state.practice) {
- app.state.practice.records = clonePracticeRecords(this.state.practiceRecords);
app.state.practice.selectedRecords = this.state.selectedRecords;
app.state.practice.bulkDeleteMode = this.state.bulkDeleteMode;
}
@@ -266,12 +234,6 @@
syncFromAppPath(path, value) {
switch (path) {
- case 'exam.index':
- this.setExamIndex(value, { syncApp: false });
- break;
- case 'practice.records':
- this.setPracticeRecords(value, { syncApp: false });
- break;
case 'exam.filteredExams':
this.setFilteredExams(value, { syncApp: false });
break;
@@ -311,41 +273,6 @@
}
}
- getExamIndex() {
- return this.state.examIndex;
- }
-
- setExamIndex(list, options = {}) {
- const normalized = assignExamSequenceNumbers(cloneArray(list));
- this.state.examIndex = normalized;
- if (options.syncApp !== false) {
- this.applyToApp();
- }
- emit(this.listeners, 'examIndex', this.state.examIndex);
- return this.state.examIndex;
- }
-
- getPracticeRecords() {
- return clonePracticeRecords(this.state.practiceRecords);
- }
-
- setPracticeRecords(records, options = {}) {
- const normalized = clonePracticeRecords(records);
- this.state.practiceRecords = normalized;
- if (options.syncApp !== false) {
- this.applyToApp();
- }
- emit(this.listeners, 'practiceRecords', clonePracticeRecords(this.state.practiceRecords));
- if (typeof global.updateBrowseAnchorsFromRecords === 'function') {
- try {
- global.updateBrowseAnchorsFromRecords(clonePracticeRecords(this.state.practiceRecords));
- } catch (error) {
- console.warn('[AppStateService] updateBrowseAnchorsFromRecords failed:', error);
- }
- }
- return clonePracticeRecords(this.state.practiceRecords);
- }
-
getFilteredExams() {
return this.state.filteredExams;
}
@@ -600,18 +527,6 @@
const service = this;
- globalRef.getExamIndexState = function getExamIndexState() {
- return service.getExamIndex();
- };
- globalRef.setExamIndexState = function setExamIndexState(list) {
- return service.setExamIndex(list);
- };
- globalRef.getPracticeRecordsState = function getPracticeRecordsState() {
- return service.getPracticeRecords();
- };
- globalRef.setPracticeRecordsState = function setPracticeRecordsState(records) {
- return service.setPracticeRecords(records);
- };
globalRef.getFilteredExamsState = function getFilteredExamsState() {
return service.getFilteredExams();
};
@@ -671,14 +586,6 @@
};
globalRef.assignExamSequenceNumbers = assignExamSequenceNumbers;
- defineGlobalProperty(globalRef, 'examIndex', {
- get: () => service.getExamIndex(),
- set: (value) => service.setExamIndex(value)
- });
- defineGlobalProperty(globalRef, 'practiceRecords', {
- get: () => service.getPracticeRecords(),
- set: (value) => service.setPracticeRecords(value)
- });
defineGlobalProperty(globalRef, 'filteredExams', {
get: () => service.getFilteredExams(),
set: (value) => service.setFilteredExams(value)
diff --git a/js/app/suitePracticeMixin.js b/js/app/suitePracticeMixin.js
index 9c7a5660..84edf582 100644
--- a/js/app/suitePracticeMixin.js
+++ b/js/app/suitePracticeMixin.js
@@ -1,5 +1,26 @@
(function(global) {
const isFileProtocol = !!(global && global.location && global.location.protocol === 'file:');
+ const multiSuiteRecoveryName = 'multi-suite-practice';
+ const multiSuiteRecoverySchema = 'multi-suite-sessions-v2';
+ const suiteRecoveryTtlMs = 30 * 24 * 60 * 60 * 1000;
+
+ function normalizeRecoveryEntityRevision(value) {
+ const revision = Number(value);
+ return Number.isSafeInteger(revision) && revision >= 0 ? revision : 0;
+ }
+
+ function suiteRecoveryTimestamp(value) {
+ for (const field of ['updatedAt', 'lastUpdate', 'timestamp', 'createdAt']) {
+ const raw = value && value[field];
+ const numeric = Number(raw);
+ if (Number.isFinite(numeric) && numeric >= Date.UTC(2000, 0, 1)) return numeric;
+ if (typeof raw === 'string' && raw.trim() && !Number.isFinite(numeric)) {
+ const parsed = Date.parse(raw);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ }
+ return null;
+ }
function getSuitePreferenceUtils() {
return global.SuitePreferenceUtils || null;
@@ -8,7 +29,7 @@
function resolveSuitePreferenceForMixin(options = {}) {
const suitePreferenceUtils = getSuitePreferenceUtils();
if (suitePreferenceUtils && typeof suitePreferenceUtils.resolveSuitePreference === 'function') {
- return suitePreferenceUtils.resolveSuitePreference(options);
+ return suitePreferenceUtils.ensurePracticeConfig().suite || {};
}
let flowMode = String(options && options.flowMode || '').trim().toLowerCase();
if (!['classic', 'simulation', 'stationary'].includes(flowMode)) {
@@ -50,13 +71,1717 @@
return;
}
- this._suiteModeReady = true;
- this.currentSuiteSession = null;
- this.suiteExamMap = new Map();
- this.multiSuiteSessionsMap = new Map(); // 新增:存储多套题会话
- if (typeof this._clearSuiteHandshakes === 'function') {
- this._clearSuiteHandshakes();
+ this._suiteModeReady = true;
+ this.currentSuiteSession = null;
+ this.suiteExamMap = new Map();
+ this.multiSuiteSessionsMap = new Map(); // 新增:存储多套题会话
+ this._multiSuiteCompletionTails = new Map();
+ this._suiteSessionGeneration = Math.max(0, Number(this._suiteSessionGeneration) || 0);
+ const restoredMultiSuiteSessions = this._restoreMultiSuiteSessionsFromStorage({ install: false });
+ if (typeof this._clearSuiteHandshakes === 'function') {
+ this._clearSuiteHandshakes();
+ }
+
+ const restored = this._restoreSessionFromStorage();
+ // Window-session WAL is copyable when a browser tab is duplicated. Keep it
+ // quarantined until this document holds the non-serializable Web Lock for
+ // the exact AppData entity id; only then may it become a live runtime owner.
+ this._suiteRecoveryReady = this._restorePersistentSuiteSession(
+ restored,
+ Array.isArray(restoredMultiSuiteSessions) ? restoredMultiSuiteSessions : []
+ );
+ },
+
+ _suiteRecoveryClaimName(sessionOrId) {
+ const id = sessionOrId && typeof sessionOrId === 'object'
+ ? sessionOrId.id
+ : sessionOrId;
+ const normalizedId = String(id ?? '');
+ return normalizedId ? `ielts-atlas:suite-recovery:${normalizedId}` : '';
+ },
+
+ _singleSuiteRecoveryGroupClaimName() {
+ return 'ielts-atlas:suite-recovery-group:suite-practice';
+ },
+
+ async _acquireSingleSuiteRecoveryGroupClaim() {
+ if (isFileProtocol) {
+ return { state: 'held', fileProtocol: true };
+ }
+ const locks = global.navigator && global.navigator.locks;
+ if (!locks || typeof locks.request !== 'function') return null;
+
+ const lockName = this._singleSuiteRecoveryGroupClaimName();
+ let settleAcquisition;
+ let acquisitionSettled = false;
+ const acquiredPromise = new Promise((resolve) => {
+ settleAcquisition = (claim) => {
+ if (acquisitionSettled) return;
+ acquisitionSettled = true;
+ resolve(claim || null);
+ };
+ });
+ let releaseHold;
+ const holdPromise = new Promise((resolve) => { releaseHold = resolve; });
+ const claim = {
+ lockName,
+ state: 'pending',
+ releaseRequested: false,
+ releaseHold,
+ requestPromise: null
+ };
+
+ claim.requestPromise = Promise.resolve().then(() => locks.request(lockName, {
+ mode: 'exclusive',
+ ifAvailable: true
+ }, async (lock) => {
+ if (!lock) {
+ settleAcquisition(null);
+ return false;
+ }
+ claim.state = 'held';
+ settleAcquisition(claim);
+ await holdPromise;
+ return true;
+ })).catch(() => {
+ settleAcquisition(null);
+ return false;
+ }).finally(() => {
+ claim.state = 'released';
+ settleAcquisition(null);
+ });
+
+ const acquiredClaim = await acquiredPromise;
+ if (!acquiredClaim) {
+ try {
+ await claim.requestPromise;
+ } catch (_) {}
+ return null;
+ }
+ return acquiredClaim;
+ },
+
+ async _releaseSingleSuiteRecoveryGroupClaim(claim) {
+ if (!claim || claim.state !== 'held') return false;
+ if (claim.fileProtocol === true) {
+ claim.state = 'released';
+ return true;
+ }
+ claim.releaseRequested = true;
+ claim.state = 'releasing';
+ claim.releaseHold();
+ try {
+ await claim.requestPromise;
+ } catch (_) {}
+ return true;
+ },
+
+ _getSuiteRecoveryClaimState() {
+ if (!(this._suiteRecoveryClaimsById instanceof Map)) {
+ this._suiteRecoveryClaimsById = new Map();
+ }
+ if (!(this._suiteRecoveryClaimsBySession instanceof WeakMap)) {
+ this._suiteRecoveryClaimsBySession = new WeakMap();
+ }
+ return {
+ byId: this._suiteRecoveryClaimsById,
+ bySession: this._suiteRecoveryClaimsBySession
+ };
+ },
+
+ _multiSuiteBaseClaimName(baseExamId) {
+ const normalizedBaseExamId = String(baseExamId || '').trim();
+ return normalizedBaseExamId
+ ? `ielts-atlas:multi-suite-base:${normalizedBaseExamId}`
+ : '';
+ },
+
+ _getMultiSuiteBaseClaimState() {
+ if (!(this._multiSuiteBaseClaimsByBase instanceof Map)) {
+ this._multiSuiteBaseClaimsByBase = new Map();
+ }
+ if (!(this._multiSuiteBaseClaimsBySession instanceof WeakMap)) {
+ this._multiSuiteBaseClaimsBySession = new WeakMap();
+ }
+ return {
+ byBase: this._multiSuiteBaseClaimsByBase,
+ bySession: this._multiSuiteBaseClaimsBySession
+ };
+ },
+
+ _rejectMultiSuiteBaseClaimSession(session) {
+ if (!session || typeof session !== 'object') return false;
+ try {
+ Object.defineProperty(session, '_multiSuiteBaseClaimRejected', {
+ value: true,
+ writable: true,
+ configurable: true,
+ enumerable: false
+ });
+ } catch (_) {
+ session._multiSuiteBaseClaimRejected = true;
+ }
+ return true;
+ },
+
+ _ownsMultiSuiteBaseClaim(session) {
+ if (isFileProtocol) return Boolean(session && String(session.baseExamId || '').trim());
+ if (!session || !String(session.baseExamId || '').trim()) return false;
+ const baseExamId = String(session.baseExamId).trim();
+ const { byBase, bySession } = this._getMultiSuiteBaseClaimState();
+ const claim = bySession.get(session);
+ return Boolean(claim
+ && claim.baseExamId === baseExamId
+ && claim.ownerSession === session
+ && claim.state === 'held'
+ && byBase.get(baseExamId) === claim);
+ },
+
+ async _acquireMultiSuiteBaseClaim(session) {
+ const baseExamId = String(session && session.baseExamId || '').trim();
+ if (!session || !baseExamId || session._multiSuiteBaseClaimRejected === true) return false;
+ session.baseExamId = baseExamId;
+ if (isFileProtocol) return true;
+ const lockName = this._multiSuiteBaseClaimName(baseExamId);
+ const locks = global.navigator && global.navigator.locks;
+ if (!lockName || !locks || typeof locks.request !== 'function') {
+ this._rejectMultiSuiteBaseClaimSession(session);
+ return false;
+ }
+ const { byBase, bySession } = this._getMultiSuiteBaseClaimState();
+ const boundClaim = bySession.get(session);
+ if (boundClaim) {
+ if (boundClaim.ownerSession !== session || boundClaim.baseExamId !== baseExamId) return false;
+ if (boundClaim.state === 'held') return this._ownsMultiSuiteBaseClaim(session);
+ if (boundClaim.state === 'pending' && boundClaim.acquiredPromise) {
+ return boundClaim.acquiredPromise;
+ }
+ return false;
+ }
+ const existing = byBase.get(baseExamId);
+ if (existing) {
+ if (existing.ownerSession !== session) return false;
+ if (existing.state === 'held') return true;
+ if (existing.state === 'pending' && existing.acquiredPromise) {
+ return existing.acquiredPromise;
+ }
+ return false;
+ }
+
+ let settleAcquisition;
+ let acquisitionSettled = false;
+ const acquiredPromise = new Promise((resolve) => {
+ settleAcquisition = (owned) => {
+ if (acquisitionSettled) return;
+ acquisitionSettled = true;
+ resolve(Boolean(owned));
+ };
+ });
+ let releaseHold;
+ const holdPromise = new Promise((resolve) => { releaseHold = resolve; });
+ const claim = {
+ baseExamId,
+ lockName,
+ ownerSession: session,
+ state: 'pending',
+ acquiredPromise,
+ releaseHold,
+ releaseRequested: false,
+ contention: false,
+ requestPromise: null
+ };
+ byBase.set(baseExamId, claim);
+ bySession.set(session, claim);
+ claim.requestPromise = Promise.resolve().then(() => locks.request(lockName, {
+ mode: 'exclusive',
+ ifAvailable: true
+ }, async (lock) => {
+ if (!lock || byBase.get(baseExamId) !== claim || bySession.get(session) !== claim) {
+ if (!lock) claim.contention = true;
+ settleAcquisition(false);
+ return false;
+ }
+ claim.state = 'held';
+ settleAcquisition(true);
+ await holdPromise;
+ return true;
+ })).catch(() => {
+ settleAcquisition(false);
+ return false;
+ }).finally(() => {
+ const endedUnexpectedly = claim.state === 'held' && claim.releaseRequested !== true;
+ const ownerSession = claim.ownerSession;
+ if (byBase.get(baseExamId) === claim) byBase.delete(baseExamId);
+ if (bySession.get(ownerSession) === claim) bySession.delete(ownerSession);
+ claim.state = 'released';
+ if (endedUnexpectedly && ownerSession) {
+ this._terminalizeSuiteRecoverySession(ownerSession);
+ this._rejectMultiSuiteBaseClaimSession(ownerSession);
+ Promise.resolve().then(() => this._releaseSuiteRecoveryClaim('multi', ownerSession)).catch(() => {});
+ }
+ settleAcquisition(false);
+ });
+ const acquired = await acquiredPromise;
+ if (!acquired) {
+ if (byBase.get(baseExamId) === claim) byBase.delete(baseExamId);
+ if (bySession.get(session) === claim) bySession.delete(session);
+ this._rejectMultiSuiteBaseClaimSession(session);
+ if (claim.contention === true) this._markSuiteRecoveryLeaseContended('multi', session);
+ }
+ return acquired;
+ },
+
+ _transferMultiSuiteBaseClaim(fromSession, toSession) {
+ const fromBaseExamId = String(fromSession && fromSession.baseExamId || '').trim();
+ const toBaseExamId = String(toSession && toSession.baseExamId || '').trim();
+ if (!fromSession || !toSession || !fromBaseExamId || fromBaseExamId !== toBaseExamId) return false;
+ if (isFileProtocol) {
+ toSession._multiSuiteBaseClaimRejected = false;
+ this._rejectMultiSuiteBaseClaimSession(fromSession);
+ return true;
+ }
+ const { byBase, bySession } = this._getMultiSuiteBaseClaimState();
+ const claim = bySession.get(fromSession);
+ if (!claim || claim.ownerSession !== fromSession || claim.baseExamId !== fromBaseExamId
+ || claim.state !== 'held' || byBase.get(fromBaseExamId) !== claim) {
+ return false;
+ }
+ bySession.delete(fromSession);
+ claim.ownerSession = toSession;
+ bySession.set(toSession, claim);
+ toSession._multiSuiteBaseClaimRejected = false;
+ this._rejectMultiSuiteBaseClaimSession(fromSession);
+ return true;
+ },
+
+ async _releaseMultiSuiteBaseClaim(session) {
+ const baseExamId = String(session && session.baseExamId || '').trim();
+ if (!session || !baseExamId) return false;
+ if (isFileProtocol) {
+ this._rejectMultiSuiteBaseClaimSession(session);
+ return true;
+ }
+ const { byBase, bySession } = this._getMultiSuiteBaseClaimState();
+ const claim = bySession.get(session);
+ if (!claim || claim.ownerSession !== session || claim.baseExamId !== baseExamId
+ || claim.state !== 'held' || byBase.get(baseExamId) !== claim) {
+ return false;
+ }
+ this._rejectMultiSuiteBaseClaimSession(session);
+ claim.releaseRequested = true;
+ claim.state = 'releasing';
+ byBase.delete(baseExamId);
+ bySession.delete(session);
+ claim.releaseHold();
+ try {
+ await claim.requestPromise;
+ } catch (_) {}
+ return true;
+ },
+
+ _terminalizeSuiteRecoverySession(session) {
+ if (!session || typeof session !== 'object') return false;
+ try {
+ Object.defineProperties(session, {
+ _suiteRecoveryClaimRejected: {
+ value: true,
+ writable: true,
+ configurable: true,
+ enumerable: false
+ },
+ _suiteRecoveryWritesBlocked: {
+ value: true,
+ writable: true,
+ configurable: true,
+ enumerable: false
+ }
+ });
+ } catch (_) {
+ session._suiteRecoveryClaimRejected = true;
+ session._suiteRecoveryWritesBlocked = true;
+ }
+ return true;
+ },
+
+ _ownsSuiteRecoveryClaim(kind, session) {
+ if (isFileProtocol) return Boolean(session && session.id);
+ if (!session || !session.id) return false;
+ const normalizedKind = kind === 'multi' ? 'multi' : 'single';
+ const { byId, bySession } = this._getSuiteRecoveryClaimState();
+ const id = String(session.id);
+ const claim = bySession.get(session);
+ return Boolean(claim
+ && claim.kind === normalizedKind
+ && claim.id === id
+ && claim.state === 'held'
+ && claim.ownerSession === session
+ && byId.get(id) === claim);
+ },
+
+ _suiteRecoveryClaimOwner(kind, sessionOrId) {
+ if (isFileProtocol) return null;
+ const id = String(sessionOrId && typeof sessionOrId === 'object'
+ ? sessionOrId.id ?? ''
+ : sessionOrId ?? '');
+ if (!id) return null;
+ const normalizedKind = kind === 'multi' ? 'multi' : 'single';
+ const { byId } = this._getSuiteRecoveryClaimState();
+ const claim = byId.get(id);
+ return claim && claim.kind === normalizedKind && claim.state === 'held'
+ ? claim.ownerSession
+ : null;
+ },
+
+ _markSuiteRecoveryLeaseContended(kind, session) {
+ if (isFileProtocol || !session || !session.id) return false;
+ const windowSession = global.AppData?.recovery?.windowSession;
+ if (!windowSession || typeof windowSession.get !== 'function'
+ || typeof windowSession.save !== 'function') return false;
+ const id = String(session.id);
+ try {
+ if (kind !== 'multi') {
+ const snapshot = windowSession.get('simulation');
+ if (!snapshot || String(snapshot.id ?? '') !== id) return false;
+ return windowSession.save('simulation', {
+ ...snapshot,
+ recoveryLeaseContended: true
+ }) !== false;
+ }
+ const snapshot = windowSession.get(multiSuiteRecoveryName);
+ if (!snapshot || !Array.isArray(snapshot.sessions)) return false;
+ let matched = false;
+ const sessions = snapshot.sessions.map((storedSession) => {
+ if (!storedSession || String(storedSession.id ?? '') !== id) return storedSession;
+ matched = true;
+ return { ...storedSession, recoveryLeaseContended: true };
+ });
+ if (!matched) return false;
+ return windowSession.save(multiSuiteRecoveryName, { ...snapshot, sessions }) !== false;
+ } catch (_) {
+ return false;
+ }
+ },
+
+ _removeSuiteRecoveryWindowWal(kind, session) {
+ if (!session || !session.id) return false;
+ const windowSession = global.AppData?.recovery?.windowSession;
+ if (!windowSession || typeof windowSession.get !== 'function') return false;
+ const id = String(session.id);
+ try {
+ if (kind !== 'multi') {
+ const snapshot = windowSession.get('simulation');
+ if (!snapshot || String(snapshot.id ?? '') !== id) return false;
+ return typeof windowSession.discard === 'function'
+ ? windowSession.discard('simulation') !== false
+ : false;
+ }
+ const snapshot = windowSession.get(multiSuiteRecoveryName);
+ if (!snapshot || !Array.isArray(snapshot.sessions)) return false;
+ const sessions = snapshot.sessions.filter((storedSession) => (
+ !storedSession || String(storedSession.id ?? '') !== id
+ ));
+ if (sessions.length === snapshot.sessions.length) return false;
+ if (!sessions.length) {
+ return typeof windowSession.discard === 'function'
+ ? windowSession.discard(multiSuiteRecoveryName) !== false
+ : false;
+ }
+ return typeof windowSession.save === 'function'
+ ? windowSession.save(multiSuiteRecoveryName, { ...snapshot, sessions }) !== false
+ : false;
+ } catch (_) {
+ return false;
+ }
+ },
+
+ async _readSuiteRecoveryFence(session) {
+ const recovery = global.AppData && global.AppData.recovery;
+ if (!session || session.id == null || !recovery
+ || typeof recovery.getActiveSessionFence !== 'function') {
+ return { supported: false, exists: false, tombstoned: false, revision: 0 };
+ }
+ try {
+ const fence = await recovery.getActiveSessionFence(String(session.id));
+ if (!fence || typeof fence !== 'object'
+ || String(fence.id ?? '') !== String(session.id)) {
+ return { supported: false, exists: false, tombstoned: false, revision: 0 };
+ }
+ return {
+ supported: true,
+ exists: fence.exists === true,
+ tombstoned: fence.exists === true && fence.tombstoned === true,
+ revision: normalizeRecoveryEntityRevision(fence.revision)
+ };
+ } catch (error) {
+ console.warn('[SuitePractice] 读取恢复实体 fence 失败,保留窗口 WAL 供重试:', error);
+ return { supported: false, exists: false, tombstoned: false, revision: 0 };
+ }
+ },
+
+ async _acquireSuiteRecoveryClaim(kind, session) {
+ if (isFileProtocol) return Boolean(session && session.id);
+ if (!session || !session.id || session._suiteRecoveryClaimRejected === true) return false;
+ const normalizedKind = kind === 'multi' ? 'multi' : 'single';
+ const id = String(session.id);
+ const lockName = this._suiteRecoveryClaimName(id);
+ if (!lockName) return false;
+ const { byId, bySession } = this._getSuiteRecoveryClaimState();
+ const boundClaim = bySession.get(session);
+ if (boundClaim) {
+ if (boundClaim.kind !== normalizedKind || boundClaim.ownerSession !== session) return false;
+ if (boundClaim.state === 'held') return this._ownsSuiteRecoveryClaim(normalizedKind, session);
+ if (boundClaim.state === 'pending' && boundClaim.acquiredPromise) {
+ return boundClaim.acquiredPromise;
+ }
+ return false;
+ }
+ const existing = byId.get(id);
+ if (existing) {
+ if (existing.ownerSession !== session || existing.kind !== normalizedKind) return false;
+ if (existing.state === 'held') return true;
+ if (existing.state === 'pending' && existing.acquiredPromise) {
+ return existing.acquiredPromise;
+ }
+ return false;
+ }
+
+ const locks = global.navigator && global.navigator.locks;
+ if (!locks || typeof locks.request !== 'function') {
+ this._terminalizeSuiteRecoverySession(session);
+ return false;
+ }
+
+ let settleAcquisition;
+ let acquisitionSettled = false;
+ const acquiredPromise = new Promise((resolve) => {
+ settleAcquisition = (owned) => {
+ if (acquisitionSettled) return;
+ acquisitionSettled = true;
+ resolve(Boolean(owned));
+ };
+ });
+ let releaseHold;
+ const holdPromise = new Promise((resolve) => { releaseHold = resolve; });
+ const claim = {
+ id,
+ kind: normalizedKind,
+ lockName,
+ ownerSession: session,
+ state: 'pending',
+ acquiredPromise,
+ releaseHold,
+ releaseRequested: false,
+ contention: false,
+ requestPromise: null
+ };
+ byId.set(id, claim);
+ bySession.set(session, claim);
+
+ claim.requestPromise = Promise.resolve().then(() => locks.request(lockName, {
+ mode: 'exclusive',
+ ifAvailable: true
+ }, async (lock) => {
+ if (!lock || byId.get(id) !== claim || bySession.get(session) !== claim) {
+ if (!lock) claim.contention = true;
+ settleAcquisition(false);
+ return false;
+ }
+ claim.state = 'held';
+ settleAcquisition(true);
+ await holdPromise;
+ return true;
+ })).catch((error) => {
+ claim.error = error;
+ settleAcquisition(false);
+ return false;
+ }).finally(() => {
+ const endedUnexpectedly = claim.state === 'held' && claim.releaseRequested !== true;
+ if (byId.get(id) === claim) byId.delete(id);
+ if (bySession.get(claim.ownerSession) === claim) {
+ bySession.delete(claim.ownerSession);
+ }
+ claim.state = 'released';
+ if (endedUnexpectedly && claim.ownerSession) {
+ this._terminalizeSuiteRecoverySession(claim.ownerSession);
+ if (normalizedKind === 'multi' && this._ownsMultiSuiteBaseClaim(claim.ownerSession)) {
+ Promise.resolve().then(() => this._releaseMultiSuiteBaseClaim(claim.ownerSession)).catch(() => {});
+ }
+ }
+ settleAcquisition(false);
+ });
+
+ const acquired = await acquiredPromise;
+ if (!acquired) {
+ if (byId.get(id) === claim) byId.delete(id);
+ if (bySession.get(session) === claim) bySession.delete(session);
+ this._terminalizeSuiteRecoverySession(session);
+ if (claim.contention === true) {
+ this._markSuiteRecoveryLeaseContended(normalizedKind, session);
+ }
+ }
+ return acquired;
+ },
+
+ async _ensureSuiteRecoveryClaim(kind, session) {
+ return this._ownsSuiteRecoveryClaim(kind, session)
+ || await this._acquireSuiteRecoveryClaim(kind, session);
+ },
+
+ _ownsMultiSuiteRecoveryOwnership(session) {
+ return this._ownsMultiSuiteBaseClaim(session)
+ && this._ownsSuiteRecoveryClaim('multi', session);
+ },
+
+ async _acquireMultiSuiteRecoveryOwnership(session) {
+ if (!session || !session.id || !String(session.baseExamId || '').trim()) return false;
+ if (!this._ownsMultiSuiteBaseClaim(session)
+ && !await this._acquireMultiSuiteBaseClaim(session)) {
+ return false;
+ }
+ if (this._ownsSuiteRecoveryClaim('multi', session)
+ || await this._acquireSuiteRecoveryClaim('multi', session)) {
+ const ownsCombined = this._ownsMultiSuiteRecoveryOwnership(session);
+ if (!ownsCombined && this._ownsSuiteRecoveryClaim('multi', session)) {
+ // The base request may end unexpectedly while the exact request is
+ // pending. Never leave the late exact acquisition held on its own.
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ }
+ return ownsCombined;
+ }
+ await this._releaseMultiSuiteBaseClaim(session);
+ return false;
+ },
+
+ _transferSuiteRecoveryClaim(kind, fromSession, toSession) {
+ const normalizedKind = kind === 'multi' ? 'multi' : 'single';
+ if (isFileProtocol) {
+ if (!fromSession || !toSession
+ || String(fromSession.id ?? '') !== String(toSession.id ?? '')) {
+ return false;
+ }
+ if (normalizedKind === 'multi') {
+ const fromBaseExamId = String(fromSession.baseExamId || '').trim();
+ const toBaseExamId = String(toSession.baseExamId || '').trim();
+ if (!fromBaseExamId || !toBaseExamId) return false;
+ if (fromBaseExamId === toBaseExamId) {
+ if (!this._transferMultiSuiteBaseClaim(fromSession, toSession)) return false;
+ } else {
+ toSession._multiSuiteBaseClaimRejected = false;
+ this._rejectMultiSuiteBaseClaimSession(fromSession);
+ }
+ }
+ toSession._suiteRecoveryClaimRejected = false;
+ delete toSession._suiteRecoveryWritesBlocked;
+ this._terminalizeSuiteRecoverySession(fromSession);
+ return true;
+ }
+ if (!fromSession || !toSession || String(fromSession.id ?? '') !== String(toSession.id ?? '')) {
+ return false;
+ }
+ const { byId, bySession } = this._getSuiteRecoveryClaimState();
+ const claim = bySession.get(fromSession);
+ const id = String(fromSession.id);
+ if (!claim || claim.kind !== normalizedKind || claim.state !== 'held'
+ || claim.ownerSession !== fromSession || byId.get(id) !== claim) {
+ return false;
+ }
+ if (normalizedKind === 'multi') {
+ const fromBaseExamId = String(fromSession.baseExamId || '').trim();
+ const toBaseExamId = String(toSession.baseExamId || '').trim();
+ if (!fromBaseExamId || !toBaseExamId || !this._ownsMultiSuiteBaseClaim(fromSession)) {
+ return false;
+ }
+ if (fromBaseExamId === toBaseExamId) {
+ if (!this._transferMultiSuiteBaseClaim(fromSession, toSession)) return false;
+ } else if (!this._ownsMultiSuiteBaseClaim(toSession)) {
+ // A same-entity durable snapshot can correct a stale WAL base, but
+ // the authoritative base must be reserved before its exact-id claim
+ // moves. The caller releases the superseded base after the transfer.
+ return false;
+ }
+ }
+ bySession.delete(fromSession);
+ claim.ownerSession = toSession;
+ bySession.set(toSession, claim);
+ toSession._suiteRecoveryClaimRejected = false;
+ delete toSession._suiteRecoveryWritesBlocked;
+ this._terminalizeSuiteRecoverySession(fromSession);
+ return true;
+ },
+
+ async _releaseSuiteRecoveryClaim(kind, session) {
+ const normalizedKind = kind === 'multi' ? 'multi' : 'single';
+ if (isFileProtocol) {
+ if (!session || !session.id) return false;
+ if (normalizedKind === 'multi') await this._releaseMultiSuiteBaseClaim(session);
+ this._terminalizeSuiteRecoverySession(session);
+ return true;
+ }
+ if (!session || !session.id) return false;
+ const { byId, bySession } = this._getSuiteRecoveryClaimState();
+ const id = String(session.id);
+ const claim = bySession.get(session);
+ if (!claim || claim.kind !== normalizedKind || claim.ownerSession !== session
+ || byId.get(id) !== claim || claim.state !== 'held') {
+ return normalizedKind === 'multi'
+ ? await this._releaseMultiSuiteBaseClaim(session)
+ : false;
+ }
+ // Releasing a runtime claim is terminal for that exact object. A stale
+ // continuation must never reacquire the same id after reconciliation,
+ // canonical alias eviction, or successful teardown (ABA protection).
+ this._terminalizeSuiteRecoverySession(session);
+ claim.releaseRequested = true;
+ claim.state = 'releasing';
+ byId.delete(id);
+ bySession.delete(session);
+ claim.releaseHold();
+ try {
+ await claim.requestPromise;
+ } catch (_) {}
+ if (normalizedKind === 'multi' && this._ownsMultiSuiteBaseClaim(session)) {
+ await this._releaseMultiSuiteBaseClaim(session);
+ }
+ return true;
+ },
+
+ _installRestoredSuiteSession(restored) {
+ if (!restored) return null;
+ const restoredGeneration = Math.max(0, Number(restored._suiteGeneration) || 0);
+ this._suiteSessionGeneration = Math.max(this._suiteSessionGeneration, restoredGeneration);
+ restored._suiteGeneration = restoredGeneration || ++this._suiteSessionGeneration;
+ this.currentSuiteSession = restored;
+ this._registerSuiteSequence(restored);
+ this._suiteResumeNoticeShown = false;
+ return restored;
+ },
+
+ async _claimDurableSingleRecoveryGroup(recovery, rawItems, durableEntityId, preferredSession = null) {
+ const firstItemsById = new Map();
+ (Array.isArray(rawItems) ? rawItems : []).forEach((item) => {
+ const id = durableEntityId(item);
+ if (id && !firstItemsById.has(id)) firstItemsById.set(id, item);
+ });
+ const singleItems = Array.from(firstItemsById.values())
+ .filter((item) => item
+ && item.schema === 'suite-session-v2'
+ && Number(item.version) === 2)
+ .sort((left, right) => {
+ const leftTime = Number(left.lastUpdate) || Date.parse(left.updatedAt || '') || 0;
+ const rightTime = Number(right.lastUpdate) || Date.parse(right.updatedAt || '') || 0;
+ return rightTime - leftTime;
+ });
+ const preferredId = preferredSession && preferredSession.id != null
+ ? String(preferredSession.id)
+ : '';
+ const preferredItem = preferredId ? firstItemsById.get(preferredId) : null;
+ const preferredOwnsSingleItem = Boolean(preferredItem
+ && preferredItem.schema === 'suite-session-v2'
+ && Number(preferredItem.version) === 2);
+ const newestValidItem = singleItems.find((item) => Boolean(this._restoreSessionFromStorage(item)));
+ // A matching WAL is evidence for repairing that exact durable identity, not
+ // authority to roll the singleton group back. A newer valid durable owner must
+ // win even when this tab still carries an older matching WAL.
+ const authoritativeItem = newestValidItem
+ || (preferredOwnsSingleItem ? preferredItem : null);
+ if (!authoritativeItem) {
+ if (preferredId && singleItems.length) {
+ if (this._ownsSuiteRecoveryClaim('single', preferredSession)) {
+ await this._releaseSuiteRecoveryClaim('single', preferredSession);
+ } else {
+ this._terminalizeSuiteRecoverySession(preferredSession);
+ }
+ return { session: null, items: rawItems, acquired: false, attempted: true };
+ }
+ return { session: null, items: rawItems, acquired: false, attempted: false };
+ }
+
+ const authoritativeId = durableEntityId(authoritativeItem);
+ const authoritativeUsesPreferredSession = Boolean(preferredSession
+ && preferredId === authoritativeId);
+ const authoritativeSession = authoritativeUsesPreferredSession
+ ? preferredSession
+ : this._restoreSessionFromStorage(authoritativeItem);
+ const authoritativeNeedsWalRepair = !newestValidItem && authoritativeUsesPreferredSession;
+ const initialSingleIds = new Set(singleItems.map((item) => durableEntityId(item)));
+ const claimedSessions = [];
+ if (preferredSession && !authoritativeUsesPreferredSession
+ && this._ownsSuiteRecoveryClaim('single', preferredSession)) {
+ // The copied/pre-first-save WAL has its own exact lock. Keep it through
+ // reconciliation so it cannot race an expected=0 write, then terminalize
+ // it when the durable singleton owner is selected (or coordination fails).
+ claimedSessions.push(preferredSession);
+ }
+ const releaseClaims = async (keepAuthoritative = false) => {
+ for (const session of claimedSessions) {
+ if (keepAuthoritative && session === authoritativeSession) continue;
+ if (this._ownsSuiteRecoveryClaim('single', session)) {
+ await this._releaseSuiteRecoveryClaim('single', session);
+ } else {
+ this._terminalizeSuiteRecoverySession(session);
+ }
+ }
+ };
+ const claimsStillOwned = () => claimedSessions.every((session) => (
+ this._ownsSuiteRecoveryClaim('single', session)
+ ));
+
+ try {
+ // Claim the authoritative identity first. If it is live elsewhere, never
+ // touch an older singleton or fall back to it.
+ if (!this._ownsSuiteRecoveryClaim('single', authoritativeSession)
+ && !await this._acquireSuiteRecoveryClaim('single', authoritativeSession)) {
+ await releaseClaims();
+ return { session: null, items: rawItems, acquired: false, attempted: true };
+ }
+ if (!claimedSessions.includes(authoritativeSession)) {
+ claimedSessions.push(authoritativeSession);
+ }
+ for (const item of singleItems) {
+ const id = durableEntityId(item);
+ if (!id || id === authoritativeId) continue;
+ const groupSession = preferredSession && id === preferredId
+ ? preferredSession
+ : (this._restoreSessionFromStorage(item) || { id });
+ if (!await this._acquireSuiteRecoveryClaim('single', groupSession)) {
+ await releaseClaims();
+ return { session: null, items: rawItems, acquired: false, attempted: true };
+ }
+ if (!claimedSessions.includes(groupSession)) claimedSessions.push(groupSession);
+ }
+
+ // The first list may race with completion, TTL pruning, or an older
+ // client. Re-read only after every exact identity is locked, and require
+ // the complete singleton set and its newest valid owner to be unchanged.
+ let refreshedItems = await recovery.listActiveSessions();
+ const refreshedFirstItemsById = new Map();
+ (Array.isArray(refreshedItems) ? refreshedItems : []).forEach((item) => {
+ const id = durableEntityId(item);
+ if (id && !refreshedFirstItemsById.has(id)) refreshedFirstItemsById.set(id, item);
+ });
+ const refreshedSingleItems = Array.from(refreshedFirstItemsById.values())
+ .filter((item) => item
+ && item.schema === 'suite-session-v2'
+ && Number(item.version) === 2)
+ .sort((left, right) => {
+ const leftTime = Number(left.lastUpdate) || Date.parse(left.updatedAt || '') || 0;
+ const rightTime = Number(right.lastUpdate) || Date.parse(right.updatedAt || '') || 0;
+ return rightTime - leftTime;
+ });
+ const refreshedNewestValid = refreshedSingleItems.find((item) => (
+ Boolean(this._restoreSessionFromStorage(item))
+ ));
+ const refreshedRepairItem = authoritativeNeedsWalRepair
+ ? refreshedFirstItemsById.get(authoritativeId)
+ : null;
+ const refreshedAuthoritative = refreshedNewestValid
+ || (refreshedRepairItem
+ && refreshedRepairItem.schema === 'suite-session-v2'
+ && Number(refreshedRepairItem.version) === 2
+ ? refreshedRepairItem
+ : null);
+ const claimedIds = new Set(claimedSessions
+ .map((session) => String(session.id))
+ .filter((id) => initialSingleIds.has(id)));
+ const refreshedIds = new Set(refreshedSingleItems.map((item) => durableEntityId(item)));
+ if (!refreshedAuthoritative
+ || durableEntityId(refreshedAuthoritative) !== authoritativeId
+ || claimedIds.size !== refreshedIds.size
+ || Array.from(claimedIds).some((id) => !refreshedIds.has(id))
+ || !claimsStillOwned()) {
+ await releaseClaims();
+ return { session: null, items: refreshedItems, acquired: false, attempted: true };
+ }
+
+ if (refreshedSingleItems.length > 1
+ && typeof recovery.discardActiveSession !== 'function') {
+ await releaseClaims();
+ return { session: null, items: refreshedItems, acquired: false, attempted: true };
+ }
+ for (const item of refreshedSingleItems) {
+ const id = durableEntityId(item);
+ if (id === authoritativeId) continue;
+ const discardReceipt = await recovery.discardActiveSession(id, {
+ expectedEntityRevision: normalizeRecoveryEntityRevision(item.revision),
+ commitGuard: claimsStillOwned
+ });
+ if (!discardReceipt || discardReceipt.committed !== true || !claimsStillOwned()) {
+ await releaseClaims();
+ return { session: null, items: refreshedItems, acquired: false, attempted: true };
+ }
+ }
+
+ refreshedItems = await recovery.listActiveSessions();
+ const remainingSingleIds = [];
+ const finalFirstItemsById = new Map();
+ (Array.isArray(refreshedItems) ? refreshedItems : []).forEach((item) => {
+ const id = durableEntityId(item);
+ if (id && !finalFirstItemsById.has(id)) finalFirstItemsById.set(id, item);
+ });
+ for (const item of finalFirstItemsById.values()) {
+ if (item && item.schema === 'suite-session-v2' && Number(item.version) === 2) {
+ remainingSingleIds.push(durableEntityId(item));
+ }
+ }
+ const finalAuthoritative = finalFirstItemsById.get(authoritativeId);
+ if (remainingSingleIds.length !== 1
+ || remainingSingleIds[0] !== authoritativeId
+ || !finalAuthoritative
+ || (!authoritativeNeedsWalRepair && !this._restoreSessionFromStorage(finalAuthoritative))
+ || !this._ownsSuiteRecoveryClaim('single', authoritativeSession)) {
+ await releaseClaims();
+ return { session: null, items: refreshedItems, acquired: false, attempted: true };
+ }
+
+ await releaseClaims(true);
+ if (!authoritativeUsesPreferredSession) {
+ authoritativeSession._restoredFromDurableClaim = true;
+ }
+ return {
+ session: authoritativeSession,
+ items: refreshedItems,
+ acquired: true,
+ attempted: true
+ };
+ } catch (error) {
+ await releaseClaims();
+ throw error;
+ }
+ },
+
+ async _restorePersistentSuiteSession(fastSnapshotSession = null, multiWindowSessions = []) {
+ const pendingMultiWindowSessions = Array.isArray(multiWindowSessions)
+ ? multiWindowSessions
+ : [];
+ let singleGroupClaim = null;
+ const ensureSingleGroupClaim = async (windowWalSession = null) => {
+ if (isFileProtocol) return true;
+ if (singleGroupClaim && singleGroupClaim.state === 'held') return true;
+ singleGroupClaim = await this._acquireSingleSuiteRecoveryGroupClaim();
+ if (singleGroupClaim) return true;
+ if (windowWalSession) {
+ // Group contention is retryable. Preserve the serialized WAL and mark
+ // it without binding/terminalizing this runtime object to an exact id.
+ this._markSuiteRecoveryLeaseContended('single', windowWalSession);
+ }
+ return false;
+ };
+ try {
+ const claimedMultiWindowSessions = [];
+ let singleWindowClaimUnavailable = false;
+ if (fastSnapshotSession) {
+ // Every HTTP singleton recovery contender must enter through the same
+ // short-lived group lock before it may hold a WAL-specific exact lock.
+ // Otherwise two tabs carrying different legacy ids can each pre-hold one
+ // exact lock and make the authoritative-first group repair abandon both.
+ if (!await ensureSingleGroupClaim(fastSnapshotSession)) {
+ singleWindowClaimUnavailable = true;
+ fastSnapshotSession = null;
+ } else if (!await this._acquireSuiteRecoveryClaim('single', fastSnapshotSession)) {
+ singleWindowClaimUnavailable = true;
+ fastSnapshotSession = null;
+ }
+ }
+ for (const session of pendingMultiWindowSessions) {
+ if (session && await this._acquireMultiSuiteRecoveryOwnership(session)) {
+ claimedMultiWindowSessions.push(session);
+ }
+ }
+ const recovery = global.AppData && global.AppData.recovery;
+ if (!recovery || typeof recovery.listActiveSessions !== 'function') {
+ if (!isFileProtocol) {
+ // HTTP(S) WAL is copyable across duplicated tabs. Without an
+ // authoritative durable enumeration we cannot distinguish a
+ // pre-first-save crash from a completed owner whose tombstone is
+ // temporarily unreadable, so keep the serialized WAL quarantined.
+ for (const session of claimedMultiWindowSessions) {
+ if (this._ownsSuiteRecoveryClaim('multi', session)) {
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ } else if (this._ownsMultiSuiteBaseClaim(session)) {
+ await this._releaseMultiSuiteBaseClaim(session);
+ }
+ }
+ if (fastSnapshotSession && this._ownsSuiteRecoveryClaim('single', fastSnapshotSession)) {
+ await this._releaseSuiteRecoveryClaim('single', fastSnapshotSession);
+ }
+ return null;
+ }
+ for (const session of claimedMultiWindowSessions) {
+ if (!this._ownsMultiSuiteRecoveryOwnership(session)) continue;
+ if (session._suiteRecoveryLeaseContended === true) {
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ continue;
+ }
+ this.multiSuiteSessionsMap.set(String(session.baseExamId || '').trim(), session);
+ }
+ if (fastSnapshotSession && this._ownsSuiteRecoveryClaim('single', fastSnapshotSession)) {
+ if (fastSnapshotSession._suiteRecoveryLeaseContended === true) {
+ await this._releaseSuiteRecoveryClaim('single', fastSnapshotSession);
+ fastSnapshotSession = null;
+ } else {
+ this._installRestoredSuiteSession(fastSnapshotSession);
+ this._notifySuiteResumeAvailable(fastSnapshotSession);
+ }
+ }
+ return fastSnapshotSession;
+ }
+ try {
+ if (global.AppData.ready && typeof global.AppData.ready.then === 'function') {
+ await global.AppData.ready;
+ }
+ let items = await recovery.listActiveSessions();
+ const durableEntityId = (item) => {
+ for (const field of ['id', 'sessionId', 'recordId']) {
+ if (item && item[field] !== undefined && item[field] !== null && item[field] !== '') {
+ return String(item[field]);
+ }
+ }
+ return '';
+ };
+ let acquiredDurableOnlyClaim = false;
+ let retryDurableSingleAfterMulti = false;
+ if (fastSnapshotSession && !singleWindowClaimUnavailable) {
+ const coordinatedWindowSingle = await this._claimDurableSingleRecoveryGroup(
+ recovery,
+ items,
+ durableEntityId,
+ fastSnapshotSession
+ );
+ if (coordinatedWindowSingle.attempted) {
+ items = coordinatedWindowSingle.items;
+ if (coordinatedWindowSingle.acquired) {
+ fastSnapshotSession = coordinatedWindowSingle.session;
+ acquiredDurableOnlyClaim = true;
+ } else {
+ // The exact WAL remains serialized, but it must not be exposed
+ // while another singleton identity is live or group cleanup
+ // cannot be confirmed.
+ fastSnapshotSession = null;
+ singleWindowClaimUnavailable = true;
+ }
+ }
+ }
+ if (!fastSnapshotSession && !singleWindowClaimUnavailable) {
+ const firstRawItemsById = new Map();
+ (Array.isArray(items) ? items : []).forEach((item) => {
+ const id = durableEntityId(item);
+ if (id && !firstRawItemsById.has(id)) firstRawItemsById.set(id, item);
+ });
+ const hasDurableSingleCandidate = Array.from(firstRawItemsById.values()).some((item) => (
+ item && item.schema === 'suite-session-v2' && Number(item.version) === 2
+ ));
+ if (hasDurableSingleCandidate && !await ensureSingleGroupClaim()) {
+ singleWindowClaimUnavailable = true;
+ }
+ }
+ if (!fastSnapshotSession && !singleWindowClaimUnavailable) {
+ // Durable-only singleton recovery must coordinate the complete group on
+ // file: as well. Web Locks are bypassed there, but AppData still enforces
+ // the same exclusive group and would otherwise reject the first resumed
+ // commit while an older id remained active.
+ const coordinatedSingle = await this._claimDurableSingleRecoveryGroup(
+ recovery,
+ items,
+ durableEntityId
+ );
+ items = coordinatedSingle.items;
+ if (coordinatedSingle.acquired) {
+ fastSnapshotSession = coordinatedSingle.session;
+ acquiredDurableOnlyClaim = true;
+ } else if (!isFileProtocol && coordinatedSingle.attempted) {
+ // A same-id multi WAL may have won this page's shared exact lock
+ // before raw durable ownership proved the id is single. Reconcile
+ // multi first, then retry the authoritative kind.
+ retryDurableSingleAfterMulti = true;
+ }
+ }
+ if (!isFileProtocol) {
+ const firstRawItemsById = new Map();
+ (Array.isArray(items) ? items : []).forEach((item) => {
+ const id = durableEntityId(item);
+ if (id && !firstRawItemsById.has(id)) firstRawItemsById.set(id, item);
+ });
+ const claimedMultiIds = new Set(claimedMultiWindowSessions
+ .filter(Boolean)
+ .map((session) => String(session.id ?? '')));
+ const claimedMultiBases = new Map();
+ for (const session of claimedMultiWindowSessions) {
+ const baseExamId = String(session && session.baseExamId || '').trim();
+ if (baseExamId && this._ownsMultiSuiteBaseClaim(session)
+ && !claimedMultiBases.has(baseExamId)) {
+ claimedMultiBases.set(baseExamId, session);
+ }
+ }
+ const authoritativeMultiByBase = new Map();
+ for (const candidate of firstRawItemsById.values()) {
+ if (!candidate
+ || candidate.schema !== multiSuiteRecoverySchema
+ || Number(candidate.version) !== 2
+ || !this._isValidMultiSuiteRecoverySnapshot(candidate)
+ || candidate.sessions.length !== 1
+ || durableEntityId(candidate) !== String(candidate.sessions[0].id ?? '')) {
+ continue;
+ }
+ const baseExamId = String(candidate.sessions[0].baseExamId || '').trim();
+ if (!baseExamId) continue;
+ const candidateTime = Number(candidate.sessions[0].lastUpdate)
+ || Date.parse(candidate.updatedAt || '') || 0;
+ const existing = authoritativeMultiByBase.get(baseExamId);
+ if (!existing || candidateTime > existing.time) {
+ authoritativeMultiByBase.set(baseExamId, { candidate, time: candidateTime });
+ }
+ }
+ // Multi-suite is singleton per canonical base, not globally. Claim
+ // exactly the newest valid raw-first identity for each base. A held
+ // newest lease suppresses that base; never fall back to an older id.
+ for (const { candidate } of authoritativeMultiByBase.values()) {
+ const candidateId = durableEntityId(candidate);
+ const baseExamId = String(candidate.sessions[0].baseExamId || '').trim();
+ const baseOwner = claimedMultiBases.get(baseExamId);
+ // HTTP startup may reconcile durable state only for bases proven by
+ // this tab's window WAL. Durable-only bases are claimed lazily by an
+ // explicit completion/restore request so an idle tab cannot starve
+ // the active submitter for every origin-wide recovery entity.
+ if (!baseOwner) continue;
+ if (claimedMultiIds.has(candidateId)) continue;
+ const durableSession = this._cloneSuitePlainObject(candidate.sessions[0]);
+ if (await this._acquireSuiteRecoveryClaim('multi', durableSession)
+ && this._transferMultiSuiteBaseClaim(baseOwner, durableSession)) {
+ // The base now belongs to the authoritative durable identity;
+ // retire the displaced WAL's otherwise-orphaned exact-id lock.
+ if (this._ownsSuiteRecoveryClaim('multi', baseOwner)) {
+ await this._releaseSuiteRecoveryClaim('multi', baseOwner);
+ }
+ durableSession._restoredFromDurableClaim = true;
+ claimedMultiWindowSessions.push(durableSession);
+ claimedMultiIds.add(String(durableSession.id));
+ claimedMultiBases.set(baseExamId, durableSession);
+ acquiredDurableOnlyClaim = true;
+ } else {
+ if (this._ownsSuiteRecoveryClaim('multi', durableSession)) {
+ await this._releaseSuiteRecoveryClaim('multi', durableSession);
+ }
+ // A same-base WAL is not authoritative while the newest durable
+ // identity is live elsewhere. Quarantine it without migration;
+ // a later refresh will retry the durable claim or, if it vanished,
+ // resume the WAL/fence path.
+ for (const windowSession of claimedMultiWindowSessions) {
+ if (!windowSession
+ || windowSession._restoredFromDurableClaim === true
+ || String(windowSession.baseExamId || '').trim() !== baseExamId) continue;
+ try {
+ Object.defineProperty(windowSession, '_suiteRecoveryAuthoritativeClaimDeferred', {
+ value: true,
+ writable: true,
+ configurable: true,
+ enumerable: false
+ });
+ } catch (_) {
+ windowSession._suiteRecoveryAuthoritativeClaimDeferred = true;
+ }
+ }
+ }
+ }
+ // Acquiring may have waited behind a page that finalized/replaced the
+ // entity. Re-read under the exact locks before reconciling or installing.
+ if (acquiredDurableOnlyClaim) items = await recovery.listActiveSessions();
+ }
+ await this._restorePersistentMultiSuiteSessions(items, claimedMultiWindowSessions);
+ if (!isFileProtocol && !fastSnapshotSession && retryDurableSingleAfterMulti) {
+ const retriedSingle = await this._claimDurableSingleRecoveryGroup(
+ recovery,
+ items,
+ durableEntityId
+ );
+ items = retriedSingle.items;
+ if (retriedSingle.acquired) {
+ fastSnapshotSession = retriedSingle.session;
+ acquiredDurableOnlyClaim = true;
+ }
+ }
+ const fastSnapshotSessionId = fastSnapshotSession && fastSnapshotSession.id != null
+ ? String(fastSnapshotSession.id)
+ : '';
+ const scopedItems = singleWindowClaimUnavailable
+ ? []
+ : (Array.isArray(items) ? items : []).filter((item) => (
+ isFileProtocol || (fastSnapshotSessionId
+ && durableEntityId(item) === fastSnapshotSessionId)
+ ));
+ const firstDurableItemsById = new Map();
+ scopedItems.forEach((item) => {
+ const id = durableEntityId(item);
+ if (id && !firstDurableItemsById.has(id)) firstDurableItemsById.set(id, item);
+ });
+ const candidates = Array.from(firstDurableItemsById.values())
+ .filter((item) => item
+ && item.schema === 'suite-session-v2'
+ && Number(item.version) === 2
+ && item.id)
+ .sort((left, right) => {
+ const leftTime = Number(left.lastUpdate) || Date.parse(left.updatedAt || '') || 0;
+ const rightTime = Number(right.lastUpdate) || Date.parse(right.updatedAt || '') || 0;
+ return rightTime - leftTime;
+ });
+ for (const candidate of candidates) {
+ const restored = this._restoreSessionFromStorage(candidate);
+ if (restored) {
+ restored._lastDurableRecoveryRevision = normalizeRecoveryEntityRevision(candidate.revision);
+ let selected = restored;
+ if (fastSnapshotSession
+ && String(fastSnapshotSession.id) === String(restored.id)
+ && normalizeRecoveryEntityRevision(fastSnapshotSession.revision)
+ > normalizeRecoveryEntityRevision(restored.revision)) {
+ selected = fastSnapshotSession;
+ fastSnapshotSession._lastDurableRecoveryRevision = restored._lastDurableRecoveryRevision;
+ const promoted = await this._commitSuiteRecovery(fastSnapshotSession, {
+ notify: false,
+ reason: 'window-wal-promotion'
+ });
+ if (!promoted) {
+ if (fastSnapshotSession._suiteRecoveryWritesBlocked === true) {
+ selected = restored;
+ this._mirrorSuiteRecoverySnapshot(candidate, fastSnapshotSession);
+ } else {
+ console.warn('[SuitePractice] 最新窗口 WAL 暂未提升到持久 v2 recovery,恢复前将再次重试。');
+ }
+ }
+ }
+ if (fastSnapshotSession && selected !== fastSnapshotSession
+ && !this._transferSuiteRecoveryClaim('single', fastSnapshotSession, selected)) {
+ this._terminalizeSuiteRecoverySession(selected);
+ await this._releaseSuiteRecoveryClaim('single', fastSnapshotSession);
+ return null;
+ }
+ this._installRestoredSuiteSession(selected);
+ this._notifySuiteResumeAvailable(selected);
+ return selected;
+ }
+ if (fastSnapshotSession
+ && durableEntityId(candidate) === fastSnapshotSessionId) {
+ if (fastSnapshotSession._restoredFromDurableClaim === true) {
+ // This clone was valid only in the pre-claim read. If the
+ // refreshed raw first owner is now corrupt, it is not a WAL and
+ // must never repair/expected=0-resurrect itself.
+ await this._releaseSuiteRecoveryClaim('single', fastSnapshotSession);
+ return null;
+ }
+ // The WAL proves this tab owns the exact CAS identity. Repair an
+ // invalid durable payload in place instead of tombstoning it:
+ // AppData discard writes a higher-revision tombstone, so a later
+ // expected=0 migration could never safely restore this WAL.
+ const durableRevision = normalizeRecoveryEntityRevision(candidate.revision);
+ fastSnapshotSession._lastDurableRecoveryRevision = durableRevision;
+ fastSnapshotSession.revision = Math.max(
+ normalizeRecoveryEntityRevision(fastSnapshotSession.revision),
+ durableRevision
+ );
+ const repaired = await this._commitSuiteRecovery(fastSnapshotSession, {
+ notify: false,
+ reason: 'window-wal-repair'
+ });
+ if (!repaired) {
+ if (fastSnapshotSession._suiteRecoveryWritesBlocked === true) {
+ this._clearSessionStorage(fastSnapshotSession);
+ if (this.currentSuiteSession === fastSnapshotSession) {
+ const ownedId = String(fastSnapshotSession.id);
+ if (this.suiteExamMap instanceof Map) {
+ for (const [examId, suiteId] of this.suiteExamMap) {
+ if (String(suiteId) === ownedId) this.suiteExamMap.delete(examId);
+ }
+ }
+ this.currentSuiteSession = null;
+ }
+ await this._releaseSuiteRecoveryClaim('single', fastSnapshotSession);
+ return null;
+ }
+ console.warn('[SuitePractice] 匹配窗口 WAL 的损坏 durable recovery 暂未修复,保留 WAL 供重试。');
+ }
+ this._installRestoredSuiteSession(fastSnapshotSession);
+ this._notifySuiteResumeAvailable(fastSnapshotSession);
+ return fastSnapshotSession;
+ }
+ if (typeof recovery.discardActiveSession === 'function') {
+ try {
+ const discardReceipt = await recovery.discardActiveSession(candidate.id, {
+ expectedEntityRevision: normalizeRecoveryEntityRevision(candidate.revision)
+ });
+ if (!discardReceipt || discardReceipt.committed !== true) {
+ console.warn('[SuitePractice] 无效 recovery 已被并发更新,跳过清理:', candidate.id);
+ }
+ } catch (discardError) {
+ console.warn('[SuitePractice] 无法清理无效的 v2 套题恢复实体:', discardError);
+ }
+ }
+ }
+ if (fastSnapshotSession) {
+ const firstOwner = firstDurableItemsById.get(fastSnapshotSessionId);
+ if (firstOwner) {
+ // Another schema owns the AppData findIndex slot for this exact
+ // identity. Never let legacy migration overwrite that first item.
+ const firstOwnerIsMultiSuite = firstOwner.schema === multiSuiteRecoverySchema
+ && Number(firstOwner.version) === 2;
+ const matchingMultiWindowSessions = firstOwnerIsMultiSuite
+ ? pendingMultiWindowSessions.filter((session) => (
+ session && String(session.id ?? '') === fastSnapshotSessionId
+ ))
+ : [];
+ fastSnapshotSession._suiteRecoveryWritesBlocked = true;
+ this._clearSessionStorage(fastSnapshotSession);
+ if (this.currentSuiteSession === fastSnapshotSession) {
+ if (this.suiteExamMap instanceof Map) {
+ for (const [examId, suiteId] of this.suiteExamMap) {
+ if (String(suiteId) === fastSnapshotSessionId) this.suiteExamMap.delete(examId);
+ }
+ }
+ this.currentSuiteSession = null;
+ }
+ await this._releaseSuiteRecoveryClaim('single', fastSnapshotSession);
+ // A duplicated tab can carry both single- and multi-suite WALs with
+ // the same AppData identity. The single WAL is examined first, so it
+ // may temporarily hold the shared lock. Once the durable first owner
+ // proves that identity belongs to multi-suite, retry the quarantined
+ // multi WAL after releasing the wrong-schema claim. Reconcile against
+ // the already refreshed durable list so the authoritative entity is
+ // restored without a stale-read or expected=0 migration window.
+ if (!isFileProtocol && matchingMultiWindowSessions.length) {
+ const retriedMultiWindowSessions = [];
+ for (const session of matchingMultiWindowSessions) {
+ const retrySession = this._cloneSuitePlainObject(session);
+ if (await this._acquireMultiSuiteRecoveryOwnership(retrySession)) {
+ retriedMultiWindowSessions.push(retrySession);
+ }
+ }
+ if (retriedMultiWindowSessions.length) {
+ await this._restorePersistentMultiSuiteSessions(items, retriedMultiWindowSessions);
+ }
+ }
+ return null;
+ }
+ if (fastSnapshotSession._restoredFromDurableClaim === true) {
+ // The entity vanished after the lock was acquired and the list was
+ // refreshed. Never reinterpret a stale durable clone as an expected=0 WAL.
+ await this._releaseSuiteRecoveryClaim('single', fastSnapshotSession);
+ return null;
+ }
+ if (fastSnapshotSession._suiteRecoveryTimestampKnown !== true) {
+ // Unknown-age WAL cannot outlive the durable tombstone horizon.
+ // Keep the bytes quarantined for manual recovery, but never expose
+ // or expected=0-promote it without a trustworthy timestamp.
+ await this._releaseSuiteRecoveryClaim('single', fastSnapshotSession);
+ return null;
+ }
+ const fence = await this._readSuiteRecoveryFence(fastSnapshotSession);
+ if (fence.supported && fence.tombstoned) {
+ // A confirmed CAS tombstone proves the prior owner completed
+ // cleanup. Clear the copied WAL instead of resurrecting it.
+ this._removeSuiteRecoveryWindowWal('single', fastSnapshotSession);
+ fastSnapshotSession._suiteRecoveryWritesBlocked = true;
+ await this._releaseSuiteRecoveryClaim('single', fastSnapshotSession);
+ return null;
+ }
+ if ((fence.supported && fence.exists)
+ || (!fence.supported && !isFileProtocol)) {
+ // Without a definitive fence result, keep the WAL quarantined.
+ // This avoids both unsafe resurrection and destructive cleanup.
+ await this._releaseSuiteRecoveryClaim('single', fastSnapshotSession);
+ return null;
+ }
+ // No durable owner has ever existed for this exact id. This tab now
+ // holds the lock, so expected=0 migration preserves either an
+ // ordinary same-tab crash WAL or a copied-tab pre-first-save WAL.
+ fastSnapshotSession._lastDurableRecoveryRevision = 0;
+ const migrated = await this._commitSuiteRecovery(fastSnapshotSession, {
+ notify: false,
+ reason: 'legacy-window-migration'
+ });
+ if (!migrated) {
+ if (fastSnapshotSession._suiteRecoveryWritesBlocked === true) {
+ this._clearSessionStorage(fastSnapshotSession);
+ await this._releaseSuiteRecoveryClaim('single', fastSnapshotSession);
+ return null;
+ }
+ console.warn('[SuitePractice] 未能将窗口恢复快照迁移到持久 v2 recovery。');
+ }
+ this._installRestoredSuiteSession(fastSnapshotSession);
+ this._notifySuiteResumeAvailable(fastSnapshotSession);
+ return fastSnapshotSession;
+ }
+ if (this.currentSuiteSession && this.currentSuiteSession._restoredFromStorage === true) {
+ this.currentSuiteSession = null;
+ this._clearSessionStorage();
+ }
+ return null;
+ } catch (error) {
+ console.warn('[SuitePractice] 读取持久 v2 套题恢复实体失败:', error);
+ if (!isFileProtocol) {
+ // A failed HTTP(S) enumeration is not evidence that durable state is
+ // absent. Fail closed and preserve window WAL bytes for a later retry;
+ // never install or expected=0-migrate a potentially copied snapshot.
+ for (const session of claimedMultiWindowSessions) {
+ const baseExamId = String(session && session.baseExamId || '').trim();
+ if (baseExamId && this.multiSuiteSessionsMap instanceof Map
+ && this.multiSuiteSessionsMap.get(baseExamId) === session) {
+ this.multiSuiteSessionsMap.delete(baseExamId);
+ }
+ if (this._ownsSuiteRecoveryClaim('multi', session)) {
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ } else if (this._ownsMultiSuiteBaseClaim(session)) {
+ await this._releaseMultiSuiteBaseClaim(session);
+ }
+ }
+ if (fastSnapshotSession) {
+ if (this.currentSuiteSession === fastSnapshotSession) {
+ this.currentSuiteSession = null;
+ }
+ if (this._ownsSuiteRecoveryClaim('single', fastSnapshotSession)) {
+ await this._releaseSuiteRecoveryClaim('single', fastSnapshotSession);
+ }
+ }
+ return null;
+ }
+ for (const session of claimedMultiWindowSessions) {
+ if (!this._ownsMultiSuiteRecoveryOwnership(session)) continue;
+ if (session._suiteRecoveryLeaseContended === true
+ || session._restoredFromDurableClaim === true) {
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ continue;
+ }
+ this.multiSuiteSessionsMap.set(String(session.baseExamId || '').trim(), session);
+ }
+ if (fastSnapshotSession && this._ownsSuiteRecoveryClaim('single', fastSnapshotSession)) {
+ if (fastSnapshotSession._suiteRecoveryLeaseContended === true
+ || fastSnapshotSession._restoredFromDurableClaim === true) {
+ await this._releaseSuiteRecoveryClaim('single', fastSnapshotSession);
+ fastSnapshotSession = null;
+ } else {
+ this._installRestoredSuiteSession(fastSnapshotSession);
+ this._notifySuiteResumeAvailable(fastSnapshotSession);
+ }
+ }
+ return fastSnapshotSession;
+ }
+ } finally {
+ if (singleGroupClaim) {
+ await this._releaseSingleSuiteRecoveryGroupClaim(singleGroupClaim);
+ }
+ }
+ },
+
+ async _restorePersistentMultiSuiteSessions(items, windowSessions = []) {
+ const rawItems = Array.isArray(items) ? items : [];
+ const durableEntityId = (item) => {
+ for (const field of ['id', 'sessionId', 'recordId']) {
+ if (item && item[field] !== undefined && item[field] !== null && item[field] !== '') {
+ return String(item[field]);
+ }
+ }
+ return '';
+ };
+ const tabOwnedWindowSessionIds = new Set();
+ const windowSessionsByBase = new Map();
+ for (const session of Array.isArray(windowSessions) ? windowSessions : []) {
+ if (!session || session.id == null || !this._ownsMultiSuiteRecoveryOwnership(session)) continue;
+ const baseExamId = String(session.baseExamId || '').trim();
+ if (!baseExamId) {
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ continue;
+ }
+ session.baseExamId = baseExamId;
+ session._restoredFromWindowSession = true;
+ if (!windowSessionsByBase.has(baseExamId)) windowSessionsByBase.set(baseExamId, []);
+ windowSessionsByBase.get(baseExamId).push(session);
+ const existingBaseSession = this.multiSuiteSessionsMap.get(baseExamId);
+ if (!existingBaseSession
+ || (existingBaseSession._restoredFromDurableClaim === true
+ && session._restoredFromDurableClaim !== true)) {
+ // Keep the real window WAL visible until a separately claimed durable
+ // candidate survives the under-lock re-read. A stale durable clone must
+ // not displace the only crash fallback before that confirmation.
+ this.multiSuiteSessionsMap.set(baseExamId, session);
+ }
+ tabOwnedWindowSessionIds.add(String(session.id));
+ }
+ const hasWindowOwnerEvidence = (item) => (
+ isFileProtocol || tabOwnedWindowSessionIds.has(durableEntityId(item))
+ );
+ const firstDurableItemsById = new Map();
+ // AppData CAS 对整个 active-session 集合使用 findIndex,必须先锁定原始顺序中的首项再筛 schema。
+ rawItems.forEach((item) => {
+ const id = durableEntityId(item);
+ if (id && !firstDurableItemsById.has(id)) firstDurableItemsById.set(id, item);
+ });
+ const allMultiSuiteItems = rawItems.filter((item) => item
+ && item.schema === multiSuiteRecoverySchema
+ && Number(item.version) === 2);
+ const multiSuiteItems = Array.from(firstDurableItemsById.values())
+ .filter((item) => item
+ && item.schema === multiSuiteRecoverySchema
+ && Number(item.version) === 2);
+ // 所有 schema/version 匹配 multi-suite 的 durable 条目,无论有效与否,
+ // 都代表该 base 曾有持久恢复;有效者覆盖 WAL,损坏者保留 WAL 回退。
+ const durableBaseIds = new Set();
+ const durableRevisionById = new Map();
+ allMultiSuiteItems.forEach((item) => {
+ const baseExamId = String(item.sessions && item.sessions[0] && item.sessions[0].baseExamId || '').trim();
+ if (baseExamId) durableBaseIds.add(baseExamId);
+ });
+ multiSuiteItems.forEach((item) => {
+ // CAS 所有权采用 AppData 的精确 active-session identity;base 相同并不代表是同一实体。
+ const id = durableEntityId(item);
+ if (!hasWindowOwnerEvidence(item)) return;
+ durableRevisionById.set(id, normalizeRecoveryEntityRevision(item.revision));
+ });
+ const candidates = multiSuiteItems
+ .filter((item) => hasWindowOwnerEvidence(item))
+ .filter((item) => this._isValidMultiSuiteRecoverySnapshot(item) && item.sessions.length === 1)
+ .filter((item) => durableEntityId(item) === String(item.sessions[0].id ?? ''))
+ .sort((left, right) => {
+ const leftTime = Number(left.sessions[0].lastUpdate) || Date.parse(left.updatedAt || '') || 0;
+ const rightTime = Number(right.sessions[0].lastUpdate) || Date.parse(right.updatedAt || '') || 0;
+ return rightTime - leftTime;
+ });
+ const validDurableIds = new Set(candidates.map((candidate) => durableEntityId(candidate)));
+ for (const [baseExamId, baseSessions] of windowSessionsByBase) {
+ for (const session of baseSessions) {
+ if (!session || session._restoredFromDurableClaim !== true
+ || validDurableIds.has(String(session.id ?? ''))) continue;
+ if (this.multiSuiteSessionsMap.get(baseExamId) === session) {
+ this.multiSuiteSessionsMap.delete(baseExamId);
+ }
+ if (this._ownsSuiteRecoveryClaim('multi', session)) {
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ }
+ }
+ if (!this.multiSuiteSessionsMap.has(baseExamId)) {
+ const fallbackWal = baseSessions.find((session) => session
+ && session._restoredFromDurableClaim !== true
+ && this._ownsMultiSuiteRecoveryOwnership(session));
+ if (fallbackWal) this.multiSuiteSessionsMap.set(baseExamId, fallbackWal);
+ }
+ }
+ // durable 完全不存在(v2 枚举确认无此 base 的恢复)时丢弃 window-WAL;
+ // durable 存在(无论有效损坏)时保留 WAL 回退:有效者随后覆盖,损坏者避免草稿丢失。
+ if (this.multiSuiteSessionsMap instanceof Map) {
+ // Older window snapshots may contain whitespace aliases. Canonicalize
+ // both key and value before merging so one logical base cannot occupy
+ // two Map entries or miss a corrupt-durable preservation marker.
+ for (const [storedBaseExamId, session] of Array.from(this.multiSuiteSessionsMap.entries())) {
+ if (!session || session._restoredFromWindowSession !== true) continue;
+ const canonicalBaseExamId = String(session.baseExamId || storedBaseExamId || '').trim();
+ if (!canonicalBaseExamId) {
+ this.multiSuiteSessionsMap.delete(storedBaseExamId);
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ continue;
+ }
+ session.baseExamId = canonicalBaseExamId;
+ if (storedBaseExamId !== canonicalBaseExamId) {
+ this.multiSuiteSessionsMap.delete(storedBaseExamId);
+ if (!this.multiSuiteSessionsMap.has(canonicalBaseExamId)) {
+ this.multiSuiteSessionsMap.set(canonicalBaseExamId, session);
+ } else if (this.multiSuiteSessionsMap.get(canonicalBaseExamId) !== session) {
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ }
+ }
+ }
+ for (const [baseExamId, session] of this.multiSuiteSessionsMap) {
+ if (!session || session._restoredFromWindowSession !== true) continue;
+ const sessionId = String(session.id ?? '');
+ const firstOwner = firstDurableItemsById.get(sessionId);
+ const firstOwnerIsMultiSuite = Boolean(firstOwner
+ && firstOwner.schema === multiSuiteRecoverySchema
+ && Number(firstOwner.version) === 2);
+ if (session._suiteRecoveryAuthoritativeClaimDeferred === true) {
+ // The newest valid durable identity for this base is actively
+ // leased elsewhere (or Locks failed). Keep the serialized WAL for
+ // retry, but do not expose or expected=0-migrate a competing id.
+ this.multiSuiteSessionsMap.delete(baseExamId);
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ continue;
+ }
+ if (!firstOwner) {
+ if (session._restoredFromDurableClaim === true) {
+ this.multiSuiteSessionsMap.delete(baseExamId);
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ continue;
+ }
+ if (session._suiteRecoveryTimestampKnown !== true) {
+ this.multiSuiteSessionsMap.delete(baseExamId);
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ continue;
+ }
+ const fence = await this._readSuiteRecoveryFence(session);
+ if (fence.supported && fence.tombstoned) {
+ this.multiSuiteSessionsMap.delete(baseExamId);
+ this._removeSuiteRecoveryWindowWal('multi', session);
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ continue;
+ }
+ if ((fence.supported && fence.exists)
+ || (!fence.supported && !isFileProtocol)) {
+ this.multiSuiteSessionsMap.delete(baseExamId);
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ continue;
+ }
+ // The exact lock is now held and no durable owner or tombstone has
+ // ever existed. Preserve any pre-first-save crash WAL by establishing
+ // its initial CAS entity before exposing it as the runtime owner;
+ // a contention marker is not ownership evidence and is not required.
+ session._lastDurableRecoveryRevision = 0;
+ session.revision = Math.max(1, normalizeRecoveryEntityRevision(session.revision));
+ const migrated = await this._commitMultiSuiteRecovery(session);
+ if (!migrated && session._suiteRecoveryWritesBlocked === true) {
+ this.multiSuiteSessionsMap.delete(baseExamId);
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ }
+ continue;
+ }
+ if (session._restoredFromDurableClaim === true
+ && !validDurableIds.has(sessionId)) {
+ // The initial read supplied this clone, but the raw first owner
+ // changed or became invalid after its lock was acquired. It has no
+ // window WAL provenance and therefore cannot be repaired or exposed.
+ this.multiSuiteSessionsMap.delete(baseExamId);
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ continue;
+ }
+ // An AppData identity owned by another schema cannot safely be reused.
+ if (firstOwner && !firstOwnerIsMultiSuite) {
+ this.multiSuiteSessionsMap.delete(baseExamId);
+ session._suiteRecoveryWritesBlocked = true;
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ continue;
+ }
+ // A valid durable candidate is authoritative for its exact CAS id and
+ // will be installed below; never retain a second WAL entry for that id.
+ if (validDurableIds.has(sessionId)) {
+ this.multiSuiteSessionsMap.delete(baseExamId);
+ // file: bypasses Web Locks, but the replaced WAL object must still
+ // become terminal. Otherwise an old in-memory reference can commit
+ // after the durable clone has become authoritative.
+ if (isFileProtocol) {
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ }
+ continue;
+ }
+ const durableRevision = durableRevisionById.get(sessionId);
+ if (durableRevision != null) {
+ session._lastDurableRecoveryRevision = durableRevision;
+ session.revision = Math.max(durableRevision, normalizeRecoveryEntityRevision(session.revision));
+ }
+ const canonicalBaseExamId = String(session.baseExamId || baseExamId || '').trim();
+ if (durableRevision == null && !durableBaseIds.has(canonicalBaseExamId)) {
+ this.multiSuiteSessionsMap.delete(baseExamId);
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ }
+ }
+ }
+ const restoredBaseIds = new Set();
+ for (const candidate of candidates) {
+ const session = this._cloneSuitePlainObject(candidate.sessions[0]);
+ const baseExamId = String(session.baseExamId || '').trim();
+ if (!baseExamId || restoredBaseIds.has(baseExamId)) continue;
+ const previousOwner = this._suiteRecoveryClaimOwner('multi', candidate.id);
+ const previousBaseExamId = String(previousOwner && previousOwner.baseExamId || '').trim();
+ let reservedAuthoritativeBase = false;
+ if (!isFileProtocol && previousOwner && previousBaseExamId !== baseExamId) {
+ reservedAuthoritativeBase = await this._acquireMultiSuiteBaseClaim(session);
+ if (!reservedAuthoritativeBase) {
+ session._suiteRecoveryWritesBlocked = true;
+ await this._releaseSuiteRecoveryClaim('multi', previousOwner);
+ continue;
+ }
+ }
+ if (!isFileProtocol && (!previousOwner
+ || !this._transferSuiteRecoveryClaim('multi', previousOwner, session))) {
+ session._suiteRecoveryWritesBlocked = true;
+ if (reservedAuthoritativeBase && this._ownsMultiSuiteBaseClaim(session)) {
+ await this._releaseMultiSuiteBaseClaim(session);
+ }
+ if (previousOwner) await this._releaseSuiteRecoveryClaim('multi', previousOwner);
+ continue;
+ }
+ if (!isFileProtocol && reservedAuthoritativeBase
+ && this._ownsMultiSuiteBaseClaim(previousOwner)) {
+ await this._releaseMultiSuiteBaseClaim(previousOwner);
+ }
+ const displacedSessions = new Set(windowSessionsByBase.get(baseExamId) || []);
+ const currentBaseOwner = this.multiSuiteSessionsMap.get(baseExamId);
+ if (currentBaseOwner) displacedSessions.add(currentBaseOwner);
+ for (const displaced of displacedSessions) {
+ if (!displaced || displaced === previousOwner || displaced === session) continue;
+ if (isFileProtocol || this._ownsSuiteRecoveryClaim('multi', displaced)) {
+ await this._releaseSuiteRecoveryClaim('multi', displaced);
+ }
+ }
+ session.baseExamId = baseExamId;
+ session._restoredFromWindowSession = true;
+ session._lastDurableRecoveryRevision = normalizeRecoveryEntityRevision(candidate.revision);
+ session.revision = Math.max(
+ session._lastDurableRecoveryRevision,
+ normalizeRecoveryEntityRevision(session.revision)
+ );
+ this.multiSuiteSessionsMap.set(baseExamId, session);
+ restoredBaseIds.add(baseExamId);
+ }
+ },
+
+ async _ensureSuiteRecoveryReady() {
+ if (!this._suiteModeReady) this.initializeSuiteMode();
+ if (this._suiteRecoveryReady && typeof this._suiteRecoveryReady.then === 'function') {
+ await this._suiteRecoveryReady;
+ }
+ return this.currentSuiteSession;
+ },
+
+ async _refreshSuiteRecoveryCandidates() {
+ if (!this._suiteModeReady) this.initializeSuiteMode();
+ if (this._suiteRecoveryReady && typeof this._suiteRecoveryReady.then === 'function') {
+ await this._suiteRecoveryReady;
+ }
+ if (this.currentSuiteSession) return this.currentSuiteSession;
+ const restored = this._restoreSessionFromStorage();
+ const restoredMulti = this._restoreMultiSuiteSessionsFromStorage({ install: false });
+ const refresh = this._restorePersistentSuiteSession(
+ restored,
+ Array.isArray(restoredMulti) ? restoredMulti : []
+ );
+ this._suiteRecoveryReady = refresh;
+ await refresh;
+ return this.currentSuiteSession;
+ },
+
+ async getSuiteRecoveryCandidate() {
+ await this._ensureSuiteRecoveryReady();
+ const session = this.currentSuiteSession;
+ if (!session || !['active', 'initializing', 'finalizing'].includes(session.status)) return null;
+ const sequence = Array.isArray(session.sequence) ? session.sequence : [];
+ const index = Math.min(Math.max(0, Number(session.currentIndex) || 0), Math.max(0, sequence.length - 1));
+ const entry = sequence[index] || null;
+ return {
+ id: session.id,
+ status: session.status,
+ currentIndex: index,
+ total: sequence.length,
+ title: entry && entry.exam && entry.exam.title ? entry.exam.title : (entry && entry.examId) || '未完成套题',
+ completedCount: Array.isArray(session.results) ? session.results.length : 0
+ };
+ },
+
+ async abandonSuiteRecovery(expectedSessionId = '') {
+ await this._ensureSuiteRecoveryReady();
+ const session = this.currentSuiteSession;
+ const expectedId = String(expectedSessionId || '').trim();
+ if (!session) return expectedId ? false : true;
+ if (!expectedId) {
+ window.showMessage && window.showMessage('请重新确认要放弃的未完成套题。', 'warning');
+ return false;
+ }
+ if (expectedId && String(session.id) !== expectedId) {
+ window.showMessage && window.showMessage('未完成套题已发生变化,请重新确认要放弃的套题。', 'warning');
+ return false;
}
+ return this._abortSuiteSession(session, { reason: 'user_discard' });
},
async startSuitePractice(options = {}) {
@@ -66,13 +1791,43 @@
if (!this._suiteModeReady) {
this.initializeSuiteMode();
}
+ await this._ensureSuiteRecoveryReady();
+ if (!this.currentSuiteSession) {
+ // A previous startup scan may have observed an active owner holding
+ // the authoritative durable lease. An explicit start is also a retry:
+ // rescan so a crashed/closed owner can be taken over instead of
+ // immediately creating a conflicting new singleton id.
+ await this._refreshSuiteRecoveryCandidates();
+ }
+ const recoveryAction = String(options.recoveryAction || '').trim().toLowerCase();
+ const recoverySessionId = String(options.recoverySessionId || '').trim();
const suitePreference = this._resolveSuitePreference(options);
const flowMode = suitePreference.flowMode;
const frequencyScope = suitePreference.frequencyScope;
- if (this.currentSuiteSession && this.currentSuiteSession.status === 'active') {
- window.showMessage && window.showMessage('套题练习正在进行中,请先完成当前套题。', 'warning');
- return;
+ if (this.currentSuiteSession && ['active', 'initializing', 'finalizing'].includes(this.currentSuiteSession.status)) {
+ if ((recoveryAction === 'restart' || recoveryAction === 'discard'
+ || recoveryAction === 'continue' || recoveryAction === 'resume')
+ && !recoverySessionId) {
+ window.showMessage && window.showMessage('未完成套题已发生变化,请重新选择。', 'warning');
+ return false;
+ }
+ if (recoverySessionId && String(this.currentSuiteSession.id) !== recoverySessionId) {
+ window.showMessage && window.showMessage('未完成套题已发生变化,请重新选择。', 'warning');
+ return false;
+ }
+ if (recoveryAction === 'restart' || recoveryAction === 'discard') {
+ const abandoned = await this._abortSuiteSession(this.currentSuiteSession, { reason: 'user_discard' });
+ if (!abandoned || this.currentSuiteSession) {
+ window.showMessage && window.showMessage('未能安全清除上次套题,暂未创建新套题。', 'error');
+ return false;
+ }
+ } else if (recoveryAction === 'continue' || recoveryAction === 'resume') {
+ return this.resumeSuitePractice(recoverySessionId);
+ } else {
+ window.showMessage && window.showMessage('检测到未完成套题,请先选择继续或放弃并新建。', 'warning');
+ return false;
+ }
}
if (frequencyScope === 'custom') {
@@ -159,28 +1914,90 @@
? '驻足模式'
: (flowMode === 'simulation' ? '模拟模式' : '经典模式')
});
- if (!started && this.currentSuiteSession) {
- await this._abortSuiteSession(this.currentSuiteSession, { reason: 'startup_failed' });
- }
+ return started;
} catch (error) {
console.error('[SuitePractice] 启动失败:', error);
window.showMessage && window.showMessage('套题练习启动失败,请稍后重试。', 'error');
- if (this.currentSuiteSession) {
- await this._abortSuiteSession(this.currentSuiteSession, { reason: 'startup_failed' });
+ if (this.currentSuiteSession && ['active', 'initializing'].includes(this.currentSuiteSession.status)) {
+ this.currentSuiteSession.windowRef = null;
+ this.currentSuiteSession._restoredFromStorage = true;
+ this.currentSuiteSession.lastUpdate = Date.now();
+ await this._commitSuiteRecovery(this.currentSuiteSession, { notify: false, reason: 'startup-error' });
+ window.showMessage && window.showMessage('首篇窗口未能打开,套题恢复快照已保留,可稍后重试。', 'warning');
}
+ return false;
}
},
async handleSuitePracticeComplete(examId, data, sourceWindow = null) {
+ const withSubmitOutcome = (handled, committed = handled, errorCode = '', extra = null) => (
+ data && data.submissionId
+ ? Object.assign({
+ handled: Boolean(handled),
+ committed: Boolean(committed),
+ errorCode: errorCode || null
+ }, extra || {})
+ : Boolean(handled)
+ );
+ const completionWindowInfo = this.examWindows && this.examWindows.get(examId);
+ const completionRegistration = completionWindowInfo
+ && (!sourceWindow || completionWindowInfo.window === sourceWindow)
+ && typeof this._captureExamSessionRegistration === 'function'
+ ? this._captureExamSessionRegistration(examId, completionWindowInfo)
+ : null;
+ // Freeze the target launch before this event yields. Otherwise an ordinary
+ // launch can win the shared named tab while persistence is pending, only for
+ // this older completion continuation to begin a newer launch and steal it back.
+ let preflightLaunch = null;
+ const preflightSession = this.currentSuiteSession;
+ if (preflightSession
+ && preflightSession.status === 'active'
+ && !(data && data.suiteId)
+ && !(data && data.suiteSubmission === true)
+ && Array.isArray(preflightSession.sequence)) {
+ const preflightExamId = String(examId || '');
+ const preflightActiveExamId = String(preflightSession.activeExamId || '');
+ const preflightIndex = preflightSession.sequence.findIndex((entry) => (
+ entry && String(entry.examId) === preflightExamId
+ ));
+ const autoAdvance = this._shouldAutoAdvanceAfterSubmit();
+ const targetIndex = preflightIndex + 1;
+ const targetEntry = autoAdvance && preflightIndex >= 0
+ ? preflightSession.sequence[targetIndex]
+ : null;
+ const reuseWindow = sourceWindow && !sourceWindow.closed
+ ? sourceWindow
+ : (preflightSession.windowRef && !preflightSession.windowRef.closed
+ ? preflightSession.windowRef
+ : null);
+ if ((!preflightActiveExamId || preflightActiveExamId === preflightExamId)
+ && targetEntry && targetEntry.examId) {
+ const windowName = preflightSession.windowName || 'ielts-suite-mode-tab';
+ preflightLaunch = {
+ session: preflightSession,
+ targetExamId: String(targetEntry.examId),
+ targetIndex,
+ windowName,
+ reuseWindow,
+ ownership: this._beginSuiteExamLaunchOwnership(targetEntry.examId, { windowName })
+ };
+ }
+ }
+ await this._ensureSuiteRecoveryReady();
// First check whether this is multi-suite mode (detected via suiteId).
if (data && data.suiteId) {
- return await this.handleMultiSuitePracticeComplete(examId, data);
+ const committed = await this.handleMultiSuitePracticeComplete(examId, data);
+ return withSubmitOutcome(true, committed, committed ? '' : 'suite_save_failed');
}
if (data && data.suiteSubmission === true && typeof this._handleInlineSimulationSuiteSubmit === 'function') {
return await this._handleInlineSimulationSuiteSubmit(examId, data, sourceWindow);
}
const session = this.currentSuiteSession;
- if (!session || session.status !== 'active') {
+ if (!session || !await this._ensureSuiteRecoveryClaim('single', session)) {
+ return false;
+ }
+ if (this.currentSuiteSession !== session
+ || !this._ownsSuiteRecoveryClaim('single', session)) {
return false;
}
@@ -190,6 +2007,29 @@
if (payloadSuiteSessionId && payloadSuiteSessionId !== session.id) {
return false;
}
+ if (session.status === 'finalizing') {
+ session._finalizeSubmissionId = data && data.submissionId
+ ? String(data.submissionId)
+ : (session._finalizeSubmissionId || null);
+ const committed = await this._finalizeSuiteRecordWithGate(session, {
+ deferTeardown: Boolean(
+ session._finalizeSubmissionId
+ && sourceWindow
+ && !sourceWindow.closed
+ )
+ });
+ return withSubmitOutcome(true, committed, committed ? '' : 'suite_save_failed', data && data.submissionId ? {
+ teardownSession: committed ? session : null
+ } : null);
+ }
+ if (session.status === 'completed') {
+ return withSubmitOutcome(true, true, '', data && data.submissionId ? {
+ teardownSession: session
+ } : null);
+ }
+ if (session.status !== 'active') {
+ return withSubmitOutcome(true, false, 'suite_finalizing');
+ }
const mappingMissing = !this.suiteExamMap || !this.suiteExamMap.has(examId);
if (mappingMissing && typeof this._registerSuiteSequence === 'function') {
@@ -219,7 +2059,7 @@
submittedExamId: examId,
sessionId: session.id
});
- return true;
+ return withSubmitOutcome(true, false, 'inactive_suite_exam');
}
const derivedDuration = this._deriveSuiteExamElapsedSeconds(session, examId, data && data.duration);
@@ -229,7 +2069,6 @@
this._upsertSuiteResult(session, examId, normalized);
this._syncSuiteTimerFromPayload(session, data);
session.lastUpdate = Date.now();
- this.updateExamStatus && this.updateExamStatus(examId, 'completed');
this._persistSuiteDraftSnapshot(session, examId, data);
if (Number.isFinite(Number(data && data.duration))) {
@@ -241,45 +2080,156 @@
await this._abortSuiteSession(session, { reason: 'missing_sequence_index' });
return false;
}
+ const previousIndex = session.currentIndex;
+ const previousActiveExamId = session.activeExamId;
+ const previousPendingAdvance = session.pendingAdvance;
const shouldAutoAdvance = this._shouldAutoAdvanceAfterSubmit();
if (!shouldAutoAdvance) {
session.currentIndex = currentIndex;
session.activeExamId = examId;
- session.pendingAdvance = {
+ const tentativePendingAdvance = {
completedExamId: examId,
finalReview: currentIndex >= session.sequence.length - 1,
updatedAt: Date.now()
};
- this._mirrorSessionToStorage(session);
+ session.pendingAdvance = tentativePendingAdvance;
+ let passageDurableReceiptConfirmed = false;
+ const committed = await this._commitSuiteRecovery(session, {
+ reason: 'passage-submit',
+ onDurableReceipt: () => { passageDurableReceiptConfirmed = true; }
+ });
+ if (!committed) {
+ if (!passageDurableReceiptConfirmed
+ && this.currentSuiteSession === session
+ && session.currentIndex === currentIndex
+ && String(session.activeExamId || '') === String(examId)
+ && session.pendingAdvance === tentativePendingAdvance) {
+ session.currentIndex = previousIndex;
+ session.activeExamId = previousActiveExamId;
+ session.pendingAdvance = previousPendingAdvance;
+ }
+ return passageDurableReceiptConfirmed
+ ? withSubmitOutcome(true, true, 'suite_advance_superseded')
+ : withSubmitOutcome(true, false, 'suite_recovery_save_failed');
+ }
+ if (!this._canContinueSuiteOperation(session)) {
+ return withSubmitOutcome(true, false, 'suite_teardown_in_progress');
+ }
+ if (preflightLaunch && (preflightLaunch.session !== session
+ || preflightLaunch.targetIndex !== currentIndex
+ || preflightLaunch.targetExamId !== String(examId)
+ || !this._isSuiteExamLaunchOwnershipCurrent(
+ examId,
+ preflightLaunch.ownership,
+ preflightLaunch.reuseWindow
+ ))) {
+ return withSubmitOutcome(true, false, 'suite_advance_superseded');
+ }
+ this.updateExamStatus && this.updateExamStatus(examId, 'completed');
const replayWindow = sourceWindow && !sourceWindow.closed
? sourceWindow
: (session.windowRef && !session.windowRef.closed ? session.windowRef : null);
if (replayWindow) {
await this._sendSuiteReviewState(session, examId, replayWindow);
}
- return true;
+ if (!this._canContinueSuiteOperation(session)) {
+ return withSubmitOutcome(true, false, 'suite_teardown_in_progress');
+ }
+ return withSubmitOutcome(true, true);
}
session.currentIndex = currentIndex + 1;
+ session.activeExamId = session.currentIndex < session.sequence.length
+ ? session.sequence[session.currentIndex].examId
+ : null;
session.pendingAdvance = null;
- this._mirrorSessionToStorage(session);
+ let passageDurableReceiptConfirmed = false;
+ const committed = await this._commitSuiteRecovery(session, {
+ reason: 'passage-submit',
+ onDurableReceipt: () => { passageDurableReceiptConfirmed = true; }
+ });
+ if (!committed) {
+ const tentativeNext = session.sequence[currentIndex + 1];
+ if (!passageDurableReceiptConfirmed
+ && this.currentSuiteSession === session
+ && session.currentIndex === currentIndex + 1
+ && String(session.activeExamId || '') === String(tentativeNext && tentativeNext.examId || '')
+ && session.pendingAdvance === null) {
+ session.currentIndex = previousIndex;
+ session.activeExamId = previousActiveExamId;
+ session.pendingAdvance = previousPendingAdvance;
+ }
+ return passageDurableReceiptConfirmed
+ ? withSubmitOutcome(true, true, 'suite_advance_superseded')
+ : withSubmitOutcome(true, false, 'suite_recovery_save_failed');
+ }
+ if (!this._canContinueSuiteOperation(session)) {
+ return withSubmitOutcome(true, false, 'suite_teardown_in_progress');
+ }
+ const nextEntry = session.currentIndex < session.sequence.length
+ ? session.sequence[session.currentIndex]
+ : null;
+ if (nextEntry && preflightLaunch && (preflightLaunch.session !== session
+ || preflightLaunch.targetIndex !== session.currentIndex
+ || preflightLaunch.targetExamId !== String(nextEntry.examId)
+ || !this._isSuiteExamLaunchOwnershipCurrent(
+ nextEntry.examId,
+ preflightLaunch.ownership
+ ))) {
+ return withSubmitOutcome(true, true, 'suite_advance_superseded');
+ }
+ if (nextEntry && preflightLaunch && preflightLaunch.reuseWindow
+ && (!this._claimSuiteExamLaunchWindow(
+ preflightLaunch.ownership,
+ preflightLaunch.reuseWindow
+ ) || !this._isSuiteExamLaunchOwnershipCurrent(
+ nextEntry.examId,
+ preflightLaunch.ownership,
+ preflightLaunch.reuseWindow
+ ))) {
+ return withSubmitOutcome(true, true, 'suite_advance_superseded');
+ }
+ this.updateExamStatus && this.updateExamStatus(examId, 'completed');
// Last passage -> finalize the entire simulation
if (session.currentIndex >= session.sequence.length) {
- await this.finalizeSuiteRecord(session);
- return true;
+ const deferTeardown = Boolean(data && data.submissionId && sourceWindow && !sourceWindow.closed);
+ session._finalizeSubmissionId = data && data.submissionId ? String(data.submissionId) : null;
+ const committed = await this._finalizeSuiteRecordWithGate(session, { deferTeardown });
+ return withSubmitOutcome(true, committed, committed ? '' : 'suite_save_failed', deferTeardown ? {
+ teardownSession: session
+ } : null);
}
// Not last -> advance to next passage
if (typeof this.cleanupExamSession === 'function') {
try {
- await this.cleanupExamSession(examId);
+ if (completionRegistration) {
+ await this.cleanupExamSession(examId, {
+ expectedRegistration: completionRegistration,
+ recoverySessionId: completionRegistration.expectedSessionId
+ });
+ } else if (!(this.examWindows instanceof Map)) {
+ // Compatibility for lightweight hosts without managed registrations.
+ await this.cleanupExamSession(examId);
+ }
} catch (cleanupError) {
console.warn('[SuitePractice] 清理上一篇会话失败:', cleanupError);
}
}
+ if (!this._canContinueSuiteOperation(session)) {
+ return withSubmitOutcome(true, false, 'suite_teardown_in_progress');
+ }
- return this._advanceSuiteToNext(session, sequenceEntry.exam.title, examId);
+ const advanced = await this._advanceSuiteToNext(session, sequenceEntry.exam.title, examId, preflightLaunch ? {
+ launchOwnership: preflightLaunch.ownership,
+ windowName: preflightLaunch.windowName,
+ reuseWindow: preflightLaunch.reuseWindow
+ } : {});
+ if (!this._canContinueSuiteOperation(session)) {
+ return withSubmitOutcome(true, false, 'suite_teardown_in_progress');
+ }
+ return withSubmitOutcome(true, true, advanced ? '' : 'suite_advance_failed');
},
async continueSuitePractice() {
@@ -294,9 +2244,439 @@
return this._advanceSuiteToNext(session, 'previous section', null);
},
+ async resumeSuitePractice(expectedSessionId = '') {
+ const expectedId = String(expectedSessionId || '').trim();
+ if (!this._suiteResumeEntryPromises) this._suiteResumeEntryPromises = new Map();
+ const existingEntry = this._suiteResumeEntryPromises.get(expectedId);
+ if (existingEntry && existingEntry.promise
+ && typeof existingEntry.promise.then === 'function') {
+ return existingEntry.promise;
+ }
+ let resolveEntryPromise;
+ let rejectEntryPromise;
+ const entryPromise = new Promise((resolve, reject) => {
+ resolveEntryPromise = resolve;
+ rejectEntryPromise = reject;
+ });
+ const entryRecord = { promise: entryPromise };
+ // Publish the per-entity entry gate before initialization, recovery, or
+ // launch reservation can yield. A second resume must join this exact
+ // continuation instead of superseding its pre-await ownership token.
+ this._suiteResumeEntryPromises.set(expectedId, entryRecord);
+ const runResumeEntry = async () => {
+ if (!this._suiteModeReady && !this.currentSuiteSession) this.initializeSuiteMode();
+ const currentPreflightSession = this.currentSuiteSession;
+ if (currentPreflightSession && currentPreflightSession._resumePromise
+ && typeof currentPreflightSession._resumePromise.then === 'function'
+ && (!expectedId || String(currentPreflightSession.id) === expectedId)) {
+ return currentPreflightSession._resumePromise;
+ }
+ const storedPreflightSession = !currentPreflightSession && expectedId
+ ? this._restoreSessionFromStorage()
+ : null;
+ const preflightSession = currentPreflightSession
+ && expectedId
+ && String(currentPreflightSession.id) === expectedId
+ ? currentPreflightSession
+ : (storedPreflightSession && String(storedPreflightSession.id) === expectedId
+ ? storedPreflightSession
+ : null);
+ const preflightSequence = preflightSession && Array.isArray(preflightSession.sequence)
+ ? preflightSession.sequence
+ : [];
+ const preflightTarget = preflightSession
+ && (preflightSession.status === 'finalizing'
+ || Number(preflightSession.currentIndex) >= preflightSequence.length)
+ ? preflightSequence.find((entry) => entry && preflightSession.windowBinding
+ && String(entry.examId) === String(preflightSession.windowBinding.examId || ''))
+ : (preflightSession ? preflightSequence[preflightSession.currentIndex] : null);
+ let resumeLaunch = null;
+ if (preflightSession && preflightTarget && preflightTarget.examId) {
+ const baseWindowName = preflightSession.windowName || 'ielts-suite-mode-tab';
+ const windowName = preflightSession._suiteWindowNameConflict
+ ? `${baseWindowName}-${preflightSession.id}`
+ : baseWindowName;
+ const reuseWindow = preflightSession.windowRef && !preflightSession.windowRef.closed
+ ? preflightSession.windowRef
+ : null;
+ const ownership = this._beginSuiteExamLaunchOwnership(preflightTarget.examId, {
+ windowName,
+ reuseWindow
+ });
+ let launchAccepted = false;
+ try {
+ if (ownership && this._isSuiteExamLaunchOwnershipCurrent(
+ preflightTarget.examId,
+ ownership,
+ reuseWindow
+ )) {
+ resumeLaunch = {
+ session: preflightSession,
+ targetExamId: String(preflightTarget.examId),
+ windowName,
+ reuseWindow,
+ ownership
+ };
+ launchAccepted = true;
+ }
+ } finally {
+ // This validation runs before the outer resume try/finally. If
+ // begin succeeded but the target changed synchronously, do not
+ // strand an untracked exam/name/WindowProxy reservation.
+ if (ownership && !launchAccepted
+ && typeof this._rollbackExamLaunchOwnership === 'function') {
+ this._rollbackExamLaunchOwnership(ownership);
+ }
+ }
+ }
+ const snapshotOwnershipMap = (ownershipMap) => new Map(
+ ownershipMap && typeof ownershipMap.entries === 'function'
+ ? Array.from(ownershipMap.entries())
+ : []
+ );
+ const resumeEntryOwnershipEpoch = {
+ sequence: Number(this._examLaunchOwnershipSequence) || 0,
+ examOwners: snapshotOwnershipMap(this._examLaunchOwnerships),
+ targetOwners: snapshotOwnershipMap(this._examLaunchTargetOwnerships)
+ };
+ const ownershipEpochStillCurrent = (targetEntry, windowName, reuseWindow = null) => {
+ if (!targetEntry || !targetEntry.examId
+ || Number(this._examLaunchOwnershipSequence || 0) !== resumeEntryOwnershipEpoch.sequence) {
+ return false;
+ }
+ const targetExamId = String(targetEntry.examId);
+ const currentExamOwner = this._examLaunchOwnerships
+ && this._examLaunchOwnerships.get(targetExamId) || null;
+ const frozenExamOwner = resumeEntryOwnershipEpoch.examOwners.get(targetExamId) || null;
+ if (currentExamOwner !== frozenExamOwner) return false;
+ const targetLeaseKeys = typeof this._resolveExamLaunchTargetLeaseKeys === 'function'
+ ? this._resolveExamLaunchTargetLeaseKeys(targetExamId, { windowName, reuseWindow })
+ : [`window-name:${String(windowName || '').trim()}`];
+ return Array.from(targetLeaseKeys || []).every((targetLeaseKey) => {
+ const currentTargetOwner = this._examLaunchTargetOwnerships
+ && this._examLaunchTargetOwnerships.get(targetLeaseKey) || null;
+ const frozenTargetOwner = resumeEntryOwnershipEpoch.targetOwners.get(targetLeaseKey) || null;
+ return currentTargetOwner === frozenTargetOwner;
+ });
+ };
+ const rollbackResumeLaunch = () => {
+ const ownership = resumeLaunch && resumeLaunch.ownership;
+ if (!ownership || typeof this._rollbackExamLaunchOwnership !== 'function') return false;
+ return this._rollbackExamLaunchOwnership(ownership) === true;
+ };
+
+ try {
+ if (this._suiteRecoveryReady && typeof this._suiteRecoveryReady.then === 'function') {
+ await this._suiteRecoveryReady;
+ }
+ const session = this.currentSuiteSession;
+ if (!session || !['active', 'initializing', 'finalizing'].includes(session.status)) {
+ return false;
+ }
+ if (!expectedId) {
+ window.showMessage && window.showMessage('请重新选择要继续的未完成套题。', 'warning');
+ return false;
+ }
+ if (String(session.id) !== expectedId) {
+ window.showMessage && window.showMessage('未完成套题已发生变化,请重新选择。', 'warning');
+ return false;
+ }
+ if (session._resumePromise && typeof session._resumePromise.then === 'function') {
+ return session._resumePromise;
+ }
+
+ const resolveResumeLaunch = (targetEntry) => {
+ if (!targetEntry || !targetEntry.examId) return null;
+ const baseWindowName = session.windowName || 'ielts-suite-mode-tab';
+ const windowName = session._suiteWindowNameConflict
+ ? `${baseWindowName}-${session.id}`
+ : baseWindowName;
+ const reuseWindow = session.windowRef && !session.windowRef.closed
+ ? session.windowRef
+ : null;
+ if (resumeLaunch) {
+ if (String(resumeLaunch.session && resumeLaunch.session.id || '') !== String(session.id)
+ || resumeLaunch.targetExamId !== String(targetEntry.examId)
+ || resumeLaunch.windowName !== windowName
+ || resumeLaunch.reuseWindow !== reuseWindow
+ || !resumeLaunch.ownership
+ || !this._isSuiteExamLaunchOwnershipCurrent(
+ targetEntry.examId,
+ resumeLaunch.ownership,
+ resumeLaunch.reuseWindow
+ )) {
+ return null;
+ }
+ resumeLaunch.session = session;
+ return resumeLaunch;
+ }
+ if (!ownershipEpochStillCurrent(targetEntry, windowName, reuseWindow)) return null;
+ const ownership = this._beginSuiteExamLaunchOwnership(targetEntry.examId, {
+ windowName,
+ reuseWindow
+ });
+ if (!ownership || !this._isSuiteExamLaunchOwnershipCurrent(
+ targetEntry.examId,
+ ownership,
+ reuseWindow
+ )) {
+ if (ownership && typeof this._rollbackExamLaunchOwnership === 'function') {
+ this._rollbackExamLaunchOwnership(ownership);
+ }
+ return null;
+ }
+ resumeLaunch = {
+ session,
+ targetExamId: String(targetEntry.examId),
+ windowName,
+ reuseWindow,
+ ownership
+ };
+ return resumeLaunch;
+ };
+
+ const resumePromise = (async () => {
+ const sequence = Array.isArray(session.sequence) ? session.sequence : [];
+ if (!sequence.length) {
+ window.showMessage && window.showMessage('未完成套题缺少可恢复的题序,恢复数据仍会保留;如需重新开始,请选择“放弃并新建”。', 'warning');
+ return false;
+ }
+
+ // A terminal snapshot is deliberately never clamped back to P3. The
+ // aggregate record and operation id are replayed until v2 confirms it.
+ if (session.status === 'finalizing' || session.currentIndex >= sequence.length) {
+ const boundEntry = session.windowBinding && sequence.find((entry) => (
+ entry && String(entry.examId) === String(session.windowBinding.examId || '')
+ ));
+ const finalizingLaunch = boundEntry ? resolveResumeLaunch(boundEntry) : null;
+ if (boundEntry && !finalizingLaunch) return false;
+ session.status = 'finalizing';
+ session.currentIndex = sequence.length;
+ session.activeExamId = null;
+ if (!await this._commitSuiteRecovery(session, { reason: 'finalize-resume' })) {
+ return false;
+ }
+ if (!session.windowRef && boundEntry) {
+ const rebound = finalizingLaunch
+ ? await this._tryRebindSuiteWindow(session, boundEntry, finalizingLaunch)
+ : null;
+ if (rebound && rebound.window && !rebound.window.closed) {
+ finalizingLaunch.ownership = rebound.ownership || finalizingLaunch.ownership;
+ session.windowRef = rebound.window;
+ }
+ }
+ return this._finalizeSuiteRecordWithGate(session, { fromRecovery: true });
+ }
+ if (typeof this.openExam !== 'function') return false;
+
+ const initialTargetEntry = sequence[session.currentIndex];
+ const activeLaunch = resolveResumeLaunch(initialTargetEntry);
+ if (!activeLaunch) return false;
+ let currentExamIndex = null;
+ if (session._restoredFromStorage === true && typeof this._fetchSuiteExamIndex === 'function') {
+ try {
+ currentExamIndex = await this._fetchSuiteExamIndex();
+ } catch (validationError) {
+ console.warn('[SuitePractice] 无法验证恢复目标,保留快照供稍后重试:', validationError);
+ window.showMessage && window.showMessage('暂时无法读取当前题库,未完成套题仍会保留。', 'warning');
+ return false;
+ }
+ if (!this._isSuiteExamLaunchOwnershipCurrent(
+ activeLaunch.targetExamId,
+ activeLaunch.ownership,
+ activeLaunch.reuseWindow
+ )) return false;
+ if (Array.isArray(currentExamIndex)) {
+ const byId = new Map(currentExamIndex.map((entry) => {
+ const id = entry && (entry.id ?? entry.examId);
+ return id == null ? null : [String(id), entry];
+ }).filter(Boolean));
+ const targetId = String(sequence[session.currentIndex].examId);
+ const missingSequenceEntry = sequence.some((entry) => !byId.has(String(entry.examId)));
+ if (missingSequenceEntry || !byId.has(targetId)) {
+ window.showMessage && window.showMessage('未完成套题与当前题库不一致,恢复数据仍会保留;如需重新开始,请选择“放弃并新建”。', 'warning');
+ return false;
+ }
+ session.sequence = sequence.map((entry) => {
+ const indexed = byId.get(String(entry.examId));
+ return indexed
+ ? { ...entry, exam: indexed, title: entry.title || indexed.title, category: entry.category || indexed.category }
+ : entry;
+ });
+ }
+ }
+
+ const targetEntry = session.sequence[session.currentIndex];
+ if (!targetEntry || !targetEntry.examId) return false;
+ if (String(targetEntry.examId) !== activeLaunch.targetExamId
+ || !this._isSuiteExamLaunchOwnershipCurrent(
+ targetEntry.examId,
+ activeLaunch.ownership,
+ activeLaunch.reuseWindow
+ )) return false;
+ session.status = 'active';
+ session.activeExamId = targetEntry.examId;
+ session.windowRef = null;
+ session.lastUpdate = Date.now();
+ const resumeStillOwned = () => this.currentSuiteSession === session
+ && session.status === 'active'
+ && String(session.activeExamId || '') === String(targetEntry.examId)
+ && this._isSuiteExamLaunchOwnershipCurrent(
+ targetEntry.examId,
+ activeLaunch.ownership,
+ activeLaunch.reuseWindow
+ );
+ if (!await this._commitSuiteRecovery(session, {
+ reason: 'suite-resume',
+ commitGuard: resumeStillOwned
+ }) || !resumeStillOwned()) {
+ return false;
+ }
+
+ let examWindow = null;
+ let reboundExistingWindow = false;
+ let targetRegistration = null;
+ const installedRegistrationStillCurrent = (targetWindow = null) => Boolean(
+ targetRegistration
+ && (!targetWindow || targetRegistration.window === targetWindow)
+ && !targetRegistration.window.closed
+ && this._isSuiteNavigationRegistrationCurrent(
+ targetEntry.examId,
+ targetRegistration,
+ session
+ )
+ );
+ const hadWindowNameConflict = session._suiteWindowNameConflict === true;
+ const rebound = session.windowBinding
+ ? await this._tryRebindSuiteWindow(session, targetEntry, activeLaunch)
+ : {
+ window: null,
+ ownership: activeLaunch.ownership,
+ fallbackAllowed: true
+ };
+ let rebindFallbackAllowed = false;
+ if (rebound && rebound.window && !rebound.window.closed) {
+ activeLaunch.ownership = rebound.ownership || activeLaunch.ownership;
+ examWindow = rebound.window;
+ targetRegistration = rebound.registration || null;
+ if (!installedRegistrationStillCurrent(examWindow)) return false;
+ reboundExistingWindow = true;
+ } else if (rebound
+ && rebound.fallbackAllowed === true
+ && rebound.ownership === activeLaunch.ownership
+ && resumeStillOwned()) {
+ rebindFallbackAllowed = true;
+ } else {
+ return false;
+ }
+ if (!examWindow && !hadWindowNameConflict && session._suiteWindowNameConflict === true) {
+ // The named window proved it belongs to another page. This launch only
+ // reserved the old name, so abort and let an explicit retry reserve the
+ // conflict-safe suffix before opening anything.
+ return false;
+ }
+ try {
+ if (!examWindow && rebindFallbackAllowed && resumeStillOwned()) {
+ const openOptions = {
+ target: 'tab',
+ examDefinition: targetEntry.exam,
+ windowName: activeLaunch.windowName,
+ suiteSessionId: session.id,
+ suiteFlowMode: session.flowMode || 'simulation',
+ suiteTimerMode: session.suiteTimerMode || 'countdown',
+ suiteTimerLimitSeconds: Number.isFinite(Number(session.suiteTimerLimitSeconds))
+ ? Number(session.suiteTimerLimitSeconds)
+ : 3600,
+ sequenceIndex: session.currentIndex,
+ sequenceTotal: session.sequence.length
+ };
+ if (activeLaunch.ownership) openOptions.launchOwnership = activeLaunch.ownership;
+ examWindow = await this.openExam(targetEntry.examId, openOptions);
+ if (examWindow && !examWindow.closed) {
+ targetRegistration = this._captureSuiteNavigationRegistration(
+ targetEntry.examId,
+ examWindow,
+ session,
+ activeLaunch.ownership
+ );
+ }
+ }
+ } catch (error) {
+ console.warn('[SuitePractice] 恢复套题窗口失败:', error);
+ }
+ if (!examWindow || examWindow.closed
+ || !installedRegistrationStillCurrent(examWindow)) {
+ session.windowRef = null;
+ session._restoredFromStorage = true;
+ window.showMessage && window.showMessage('未能打开未完成套题,请检查弹窗权限后再次点击套题模式。', 'warning');
+ return false;
+ }
+
+ session.windowRef = examWindow;
+ session._restoredFromStorage = false;
+ this._ensureSuiteWindowGuard(session, examWindow);
+ this._focusSuiteWindow(examWindow);
+ if (reboundExistingWindow) {
+ const reboundReady = await this._waitForSuiteWindowExamReady(session, targetEntry.examId, examWindow);
+ if (!installedRegistrationStillCurrent(examWindow)) return false;
+ if (!reboundReady) {
+ session._restoredFromStorage = true;
+ window.showMessage && window.showMessage('题目页仍在,但重新绑定尚未完成;页面未被重载,请稍后重试。', 'warning');
+ return false;
+ }
+ }
+ if (!installedRegistrationStillCurrent(examWindow)) return false;
+ if (session.flowMode === 'simulation') {
+ this._sendSimulationContext(session, targetEntry.examId, examWindow);
+ } else if (session.pendingAdvance || (session.results || []).some((entry) => entry && entry.examId === targetEntry.examId)) {
+ await this._sendSuiteReviewState(session, targetEntry.examId, examWindow).catch((error) => {
+ console.warn('[SuitePractice] 恢复套题回看状态失败:', error);
+ });
+ }
+ if (!installedRegistrationStillCurrent(examWindow)) return false;
+ window.showMessage && window.showMessage(`已恢复未完成套题:${targetEntry.exam?.title || targetEntry.examId}`, 'success');
+ return true;
+ })();
+ session._resumePromise = resumePromise;
+ try {
+ return await resumePromise;
+ } finally {
+ if (session._resumePromise === resumePromise) delete session._resumePromise;
+ }
+ } finally {
+ rollbackResumeLaunch();
+ }
+ };
+ runResumeEntry().then((value) => {
+ if (this._suiteResumeEntryPromises.get(expectedId) === entryRecord) {
+ this._suiteResumeEntryPromises.delete(expectedId);
+ }
+ resolveEntryPromise(value);
+ }, (error) => {
+ if (this._suiteResumeEntryPromises.get(expectedId) === entryRecord) {
+ this._suiteResumeEntryPromises.delete(expectedId);
+ }
+ rejectEntryPromise(error);
+ });
+ return await entryPromise;
+ },
+
async _handleInlineSimulationSuiteSubmit(examId, data, sourceWindow = null) {
+ const withSubmitOutcome = (handled, committed = handled, errorCode = '', extra = null) => (
+ data && data.submissionId
+ ? Object.assign({
+ handled: Boolean(handled),
+ committed: Boolean(committed),
+ errorCode: errorCode || null
+ }, extra || {})
+ : Boolean(handled)
+ );
+ await this._ensureSuiteRecoveryReady();
const session = this.currentSuiteSession;
- if (!session || session.status !== 'active' || session.flowMode !== 'simulation') {
+ if (!session || !await this._ensureSuiteRecoveryClaim('single', session)) return false;
+ if (this.currentSuiteSession !== session
+ || !this._ownsSuiteRecoveryClaim('single', session)) return false;
+ if (!session || session.flowMode !== 'simulation') {
return false;
}
const payloadSuiteSessionId = data && typeof data.suiteSessionId === 'string'
@@ -305,9 +2685,32 @@
if (payloadSuiteSessionId && payloadSuiteSessionId !== session.id) {
return false;
}
+ if (session.status === 'finalizing') {
+ session._finalizeSubmissionId = data && data.submissionId
+ ? String(data.submissionId)
+ : (session._finalizeSubmissionId || null);
+ const committed = await this._finalizeSuiteRecordWithGate(session, {
+ deferTeardown: Boolean(
+ session._finalizeSubmissionId
+ && sourceWindow
+ && !sourceWindow.closed
+ )
+ });
+ return withSubmitOutcome(true, committed, committed ? '' : 'suite_save_failed', session._finalizeSubmissionId ? {
+ teardownSession: committed ? session : null
+ } : null);
+ }
+ if (session.status === 'completed') {
+ return withSubmitOutcome(true, true, '', data && data.submissionId ? {
+ teardownSession: session
+ } : null);
+ }
+ if (session.status !== 'active') {
+ return false;
+ }
const suiteEntries = Array.isArray(data && data.suiteEntries) ? data.suiteEntries : [];
if (!suiteEntries.length) {
- return false;
+ return withSubmitOutcome(true, false, 'suite_entries_missing');
}
const entriesByExam = new Map();
suiteEntries.forEach((entry) => {
@@ -317,7 +2720,7 @@
}
});
if (!entriesByExam.size) {
- return false;
+ return withSubmitOutcome(true, false, 'suite_entries_missing');
}
const hasEverySequenceEntry = Array.isArray(session.sequence)
&& session.sequence.length > 0
@@ -331,7 +2734,7 @@
expected: session.sequence.map(item => item && item.examId).filter(Boolean),
received: Array.from(entriesByExam.keys())
});
- return false;
+ return withSubmitOutcome(true, false, 'suite_entries_incomplete');
}
session.results = [];
@@ -355,6 +2758,8 @@
answers: entryPayload.answers || {},
highlights: Array.isArray(entryPayload.highlights) ? entryPayload.highlights.slice() : [],
noteText: typeof entryPayload.noteText === 'string' ? entryPayload.noteText : '',
+ notes: Array.isArray(entryPayload.notes) ? entryPayload.notes.slice() : [],
+ noteOutlines: Array.isArray(entryPayload.noteOutlines) ? entryPayload.noteOutlines.slice() : [],
scrollY: Number.isFinite(Number(entryPayload.scrollY)) ? Number(entryPayload.scrollY) : 0,
markedQuestions: Array.isArray(entryPayload.markedQuestions) ? entryPayload.markedQuestions.slice() : []
},
@@ -366,7 +2771,6 @@
if (Number.isFinite(entryDuration)) {
session.elapsedByExam[entryExamId] = Math.max(0, entryDuration);
}
- this.updateExamStatus && this.updateExamStatus(entryExamId, 'completed');
});
this._syncSuiteTimerFromPayload(session, data);
@@ -377,9 +2781,24 @@
if (sourceWindow && !sourceWindow.closed) {
session.windowRef = sourceWindow;
}
- this._mirrorSessionToStorage(session);
- await this.finalizeSuiteRecord(session);
- return true;
+ const recoveryCommitted = await this._commitSuiteRecovery(session, {
+ reason: 'inline-suite-submit'
+ });
+ if (!recoveryCommitted) {
+ return withSubmitOutcome(true, false, 'suite_recovery_save_failed');
+ }
+ if (!this._isSuiteOperationOwner(session)) {
+ return withSubmitOutcome(true, false, 'suite_teardown_in_progress');
+ }
+ session.sequence.forEach((entry) => {
+ if (entry && entry.examId) this.updateExamStatus && this.updateExamStatus(entry.examId, 'completed');
+ });
+ const deferTeardown = Boolean(data && data.submissionId && sourceWindow && !sourceWindow.closed);
+ session._finalizeSubmissionId = data && data.submissionId ? String(data.submissionId) : null;
+ const committed = await this._finalizeSuiteRecordWithGate(session, { deferTeardown });
+ return withSubmitOutcome(true, committed, committed ? '' : 'suite_save_failed', deferTeardown ? {
+ teardownSession: session
+ } : null);
},
_resolveSuitePreference(options = {}) {
@@ -435,7 +2854,7 @@
if (data.draft && typeof data.draft === 'object' && !Array.isArray(data.draft)) {
return true;
}
- return ['answers', 'highlights', 'noteText', 'scrollY', 'markedQuestions', 'draftUpdatedAt'].some((key) => (
+ return ['answers', 'highlights', 'noteText', 'notes', 'noteOutlines', 'scrollY', 'markedQuestions', 'draftUpdatedAt'].some((key) => (
Object.prototype.hasOwnProperty.call(data, key)
));
},
@@ -467,6 +2886,12 @@
const noteTextSource = typeof draftSource.noteText === 'string'
? draftSource.noteText
: (data && typeof data.noteText === 'string' ? data.noteText : '');
+ const notesSource = Array.isArray(draftSource.notes)
+ ? draftSource.notes
+ : (Array.isArray(data && data.notes) ? data.notes : []);
+ const noteOutlinesSource = Array.isArray(draftSource.noteOutlines)
+ ? draftSource.noteOutlines
+ : (Array.isArray(data && data.noteOutlines) ? data.noteOutlines : []);
const scrollSource = Number.isFinite(Number(draftSource.scrollY))
? Number(draftSource.scrollY)
: (Number.isFinite(Number(data && data.scrollY)) ? Number(data.scrollY) : 0);
@@ -480,6 +2905,8 @@
answers: this._cloneSuiteDraftPlainObject(answerSource),
highlights: highlightSource.slice(),
noteText: noteTextSource,
+ notes: this._cloneSuitePlainObject(notesSource),
+ noteOutlines: this._cloneSuitePlainObject(noteOutlinesSource),
scrollY: scrollSource,
markedQuestions: markedQuestionsSource.slice(),
updatedAt: Number.isFinite(updatedAt) ? updatedAt : Date.now()
@@ -501,18 +2928,126 @@
const previousDraft = session.draftsByExam[normalizedExamId] || null;
const previousUpdatedAt = Number(previousDraft && previousDraft.updatedAt);
const nextUpdatedAt = Number(nextDraft.updatedAt);
+ const suppliedUpdatedAt = Number(data && (data.draftUpdatedAt
+ ?? (data.draft && data.draft.updatedAt)
+ ?? data.updatedAt));
+ if (previousDraft && !Number.isFinite(suppliedUpdatedAt)) {
+ return false;
+ }
if (
previousDraft
&& Number.isFinite(previousUpdatedAt)
&& Number.isFinite(nextUpdatedAt)
- && nextUpdatedAt < previousUpdatedAt
+ && nextUpdatedAt <= previousUpdatedAt
) {
return false;
}
+ nextDraft.updatedAt = Number.isFinite(suppliedUpdatedAt) ? suppliedUpdatedAt : nextDraft.updatedAt;
session.draftsByExam[normalizedExamId] = nextDraft;
+ session.draftRevision = Math.max(0, Number(session.draftRevision) || 0) + 1;
+ const previousUpdate = Number(session.lastUpdate);
+ session.lastUpdate = Math.max(
+ Number.isFinite(previousUpdate) ? previousUpdate : 0,
+ Number.isFinite(suppliedUpdatedAt) ? suppliedUpdatedAt : Date.now(),
+ Date.now()
+ );
return true;
},
+ async _handleSuiteDraftSync(examId, data = {}, windowInfo = null, sourceWindow = null) {
+ const session = this.currentSuiteSession;
+ const normalizedExamId = examId != null ? String(examId).trim() : '';
+ const registeredWindowInfo = !this.examWindows
+ || (typeof this.examWindows.values === 'function'
+ ? Array.from(this.examWindows.values()).includes(windowInfo)
+ : Object.values(this.examWindows).includes(windowInfo));
+ const payloadSuiteId = data && data.suiteSessionId != null ? String(data.suiteSessionId).trim() : '';
+ const expectedSuiteId = windowInfo && windowInfo.suiteSessionId != null
+ ? String(windowInfo.suiteSessionId).trim()
+ : '';
+ const incomingUpdatedAt = Number(data && (data.draftUpdatedAt
+ ?? (data.draft && data.draft.updatedAt)
+ ?? data.updatedAt));
+ if (
+ !session
+ || session._suiteRecoveryWritesBlocked === true
+ || !['active', 'initializing'].includes(session.status)
+ || !normalizedExamId
+ || !payloadSuiteId
+ || payloadSuiteId !== String(session.id)
+ || (expectedSuiteId && expectedSuiteId !== String(session.id))
+ || !windowInfo
+ || (this.examWindows && !registeredWindowInfo)
+ || !windowInfo.window
+ || windowInfo.window.closed
+ || (sourceWindow && sourceWindow !== windowInfo.window)
+ || (session.windowRef && session.windowRef.closed)
+ || (session.flowMode !== 'simulation' && session.windowRef && session.windowRef !== windowInfo.window)
+ || (session.flowMode !== 'simulation' && !session.windowRef && session.status !== 'initializing')
+ || !Number.isFinite(incomingUpdatedAt)
+ || incomingUpdatedAt <= 0
+ || !Array.isArray(session.sequence)
+ || !session.sequence.some((entry) => entry && String(entry.examId) === normalizedExamId)
+ || !data.draft
+ || typeof data.draft !== 'object'
+ ) {
+ return false;
+ }
+ if (!await this._ensureSuiteRecoveryClaim('single', session)) {
+ return false;
+ }
+ const stillRegisteredWindowInfo = !this.examWindows
+ || (typeof this.examWindows.values === 'function'
+ ? Array.from(this.examWindows.values()).includes(windowInfo)
+ : Object.values(this.examWindows).includes(windowInfo));
+ if (this.currentSuiteSession !== session
+ || !this._ownsSuiteRecoveryClaim('single', session)
+ || !stillRegisteredWindowInfo
+ || (sourceWindow && sourceWindow !== windowInfo.window)) {
+ return false;
+ }
+ if (!this._persistSuiteDraftSnapshot(session, normalizedExamId, data)) {
+ return false;
+ }
+ if (Number.isFinite(Number(data.elapsed))) {
+ session.elapsedByExam[normalizedExamId] = typeof this._deriveSuiteExamElapsedSeconds === 'function'
+ ? this._deriveSuiteExamElapsedSeconds(session, normalizedExamId, Number(data.elapsed))
+ : Math.max(0, Number(data.elapsed));
+ }
+ this._syncSuiteTimerFromPayload(session, data);
+ session.windowRef = windowInfo.window;
+ const indexedEntry = Number.isInteger(session.currentIndex)
+ ? session.sequence[session.currentIndex]
+ : null;
+ if (indexedEntry && String(indexedEntry.examId) === normalizedExamId) {
+ session.activeExamId = normalizedExamId;
+ }
+ this._mirrorSessionToStorage(session);
+ return this._commitSuiteRecovery(session, {
+ reason: 'draft-sync'
+ });
+ },
+
+ receiveSuiteDraftSnapshotFromChild(examId, data = {}, sourceWindow = null) {
+ const normalizedExamId = examId != null ? String(examId).trim() : '';
+ const windowInfo = normalizedExamId && this.examWindows && this.examWindows.get(normalizedExamId);
+ if (!windowInfo || !sourceWindow || windowInfo.window !== sourceWindow || sourceWindow.closed) {
+ return false;
+ }
+ const payloadSuiteSessionId = data && data.suiteSessionId != null ? String(data.suiteSessionId).trim() : '';
+ const payloadToken = data && data.windowSessionToken != null ? String(data.windowSessionToken).trim() : '';
+ const payloadGeneration = Number(data && data.windowSessionGeneration);
+ if (!payloadSuiteSessionId
+ || payloadSuiteSessionId !== String(windowInfo.suiteSessionId || '')
+ || !payloadToken
+ || payloadToken !== String(windowInfo.windowSessionToken || '')
+ || !Number.isInteger(payloadGeneration)
+ || payloadGeneration !== Number(windowInfo.sessionGeneration)) {
+ return false;
+ }
+ return this._handleSuiteDraftSync(normalizedExamId, data, windowInfo, sourceWindow);
+ },
+
_buildSuiteSequencePayload(session) {
const sequence = session && Array.isArray(session.sequence) ? session.sequence : [];
return sequence
@@ -547,6 +3082,28 @@
}
},
+ _suiteComparableValue(value) {
+ if (Array.isArray(value)) {
+ return value.map((item) => this._suiteComparableValue(item));
+ }
+ if (value && typeof value === 'object') {
+ return Object.keys(value).sort().reduce((result, key) => {
+ result[key] = this._suiteComparableValue(value[key]);
+ return result;
+ }, {});
+ }
+ return value;
+ },
+
+ _suiteValuesEqual(left, right) {
+ try {
+ return JSON.stringify(this._suiteComparableValue(left))
+ === JSON.stringify(this._suiteComparableValue(right));
+ } catch (_) {
+ return false;
+ }
+ },
+
_sanitizeSuiteRawData(rawData) {
const cloned = this._cloneSuitePlainObject(rawData || {});
if (!cloned || typeof cloned !== 'object') {
@@ -555,6 +3112,8 @@
delete cloned.highlights;
delete cloned.scrollY;
delete cloned.noteText;
+ delete cloned.notes;
+ delete cloned.noteOutlines;
return cloned;
},
@@ -573,6 +3132,14 @@
if (noteText) {
rawData.noteText = noteText;
}
+ const notes = this._resolveSuiteEntryNotes(entry, draft);
+ if (notes.length > 0) {
+ rawData.notes = notes;
+ }
+ const noteOutlines = this._resolveSuiteEntryNoteOutlines(entry, draft);
+ if (noteOutlines.length > 0) {
+ rawData.noteOutlines = noteOutlines;
+ }
return rawData;
},
@@ -582,8 +3149,11 @@
entry && entry.highlights,
entry && entry.rawData && entry.rawData.highlights
];
+ // 把“存在数组”视为权威来源(即使为空也可代表用户已清空高亮),
+ // 与 _buildSuiteDraftSnapshot 的写路径语义保持一致;避免显式 highlights: []
+ // 被跳过而回落到旧 entry.rawData.highlights,复活已删除的高亮。
for (const source of sources) {
- if (Array.isArray(source) && source.length > 0) {
+ if (source != null && Array.isArray(source)) {
return source.slice();
}
}
@@ -631,6 +3201,40 @@
return '';
},
+ _resolveSuiteEntryNotes(entry, draft = null) {
+ const sources = [
+ draft && draft.notes,
+ entry && entry.notes,
+ entry && entry.rawData && entry.rawData.notes
+ ];
+ // 把“存在数组”视为权威来源(即使为空也可代表用户已删除最后一条结构笔记),
+ // 与 _buildSuiteDraftSnapshot 的写路径语义保持一致;避免显式 notes: []
+ // 被跳过而回落到旧 entry.rawData.notes,复活已删除的笔记。
+ for (const source of sources) {
+ if (source != null && Array.isArray(source)) {
+ return this._cloneSuitePlainObject(source);
+ }
+ }
+ return [];
+ },
+
+ _resolveSuiteEntryNoteOutlines(entry, draft = null) {
+ const sources = [
+ draft && draft.noteOutlines,
+ entry && entry.noteOutlines,
+ entry && entry.rawData && entry.rawData.noteOutlines
+ ];
+ // 把“存在数组”视为权威来源(即使为空也可代表用户已删除最后一条笔记大纲),
+ // 与 _buildSuiteDraftSnapshot 的写路径语义保持一致;避免显式 noteOutlines: []
+ // 被跳过而回落到旧 entry.rawData.noteOutlines,复活已删除的大纲。
+ for (const source of sources) {
+ if (source != null && Array.isArray(source)) {
+ return this._cloneSuitePlainObject(source);
+ }
+ }
+ return [];
+ },
+
_buildSuiteReplayEntry(session, examId) {
if (!session || !Array.isArray(session.results)) {
return null;
@@ -695,6 +3299,8 @@
const highlights = this._resolveSuiteEntryHighlights(result, draft);
const noteText = this._resolveSuiteEntryNoteText(result, draft);
+ const notes = this._resolveSuiteEntryNotes(result, draft);
+ const noteOutlines = this._resolveSuiteEntryNoteOutlines(result, draft);
const scrollY = this._resolveSuiteEntryScrollY(result, draft);
const markedQuestions = result && Array.isArray(result.markedQuestions)
? result.markedQuestions.slice()
@@ -705,6 +3311,8 @@
|| markedQuestions.length
|| highlights.length
|| noteText
+ || notes.length
+ || noteOutlines.length
|| (Number.isFinite(Number(scrollY)) && Number(scrollY) > 0)
);
if (!hasReplayData) {
@@ -719,6 +3327,8 @@
markedQuestions,
highlights,
noteText,
+ notes,
+ noteOutlines,
scrollY
};
},
@@ -755,7 +3365,7 @@
currentIndex: sequenceIndex,
total: session.sequence.length,
canPrev: sequenceIndex > 0,
- canNext: sequenceIndex < session.sequence.length - 1 || allowFinalizeFromNav,
+ canNext: viewMode === 'review' && (sequenceIndex < session.sequence.length - 1 || allowFinalizeFromNav),
finalizeOnNext: allowFinalizeFromNav,
title: (sequenceEntry.exam && sequenceEntry.exam.title) || sequenceEntry.examId || '',
examId: examId,
@@ -780,18 +3390,15 @@
}
try {
if (replayEntry) {
- resolvedWindow.postMessage({
- type: 'REPLAY_PRACTICE_RECORD',
- data: {
- suiteSessionId: session.id,
- reviewEntryIndex: contextPayload.currentIndex,
- readOnly: contextPayload.readOnly !== false,
- markedQuestions: Array.isArray(replayEntry.markedQuestions) ? replayEntry.markedQuestions : [],
- entry: replayEntry
- }
- }, '*');
+ this._postExamMessage(examId, resolvedWindow, 'REPLAY_PRACTICE_RECORD', {
+ suiteSessionId: session.id,
+ reviewEntryIndex: contextPayload.currentIndex,
+ readOnly: contextPayload.readOnly !== false,
+ markedQuestions: Array.isArray(replayEntry.markedQuestions) ? replayEntry.markedQuestions : [],
+ entry: replayEntry
+ });
}
- resolvedWindow.postMessage({ type: 'REVIEW_CONTEXT', data: contextPayload }, '*');
+ this._postExamMessage(examId, resolvedWindow, 'REVIEW_CONTEXT', contextPayload);
return true;
} catch (error) {
console.warn('[SuitePractice] 发送套题回看上下文失败:', error);
@@ -826,7 +3433,7 @@
&& Number(windowInfo.lastMessageAt) >= startedAt
&& (!windowInfo.suiteSessionId || windowInfo.suiteSessionId === session.id)
&& (!windowInfo.windowSessionToken || !windowInfo.lastWindowSessionToken || windowInfo.windowSessionToken === windowInfo.lastWindowSessionToken)
- && (!windowInfo.pageType || /unified-reading|suite-placeholder/i.test(String(windowInfo.pageType)))
+ && (!windowInfo.pageType || /unified-reading|suite-placeholder|^p[1-4]$|^practice$/i.test(String(windowInfo.pageType)))
);
if (readyMatches) {
return true;
@@ -870,13 +3477,13 @@
const pageType = windowInfo && typeof windowInfo.pageType === 'string'
? windowInfo.pageType.toLowerCase()
: '';
- if (pageType && !pageType.includes('unified-reading') && !pageType.includes('suite-placeholder')) {
+ if (pageType && !/unified-reading|suite-placeholder|^p[1-4]$|^practice$/i.test(pageType)) {
return false;
}
return true;
},
- async _maybeRestoreSuiteReviewState(examId, targetWindow = null, windowInfo = null) {
+ async _maybeRestoreSuiteReviewState(examId, targetWindow = null, windowInfo = null, options = {}) {
if (!examId || this._shouldAutoAdvanceAfterSubmit()) {
return false;
}
@@ -909,16 +3516,151 @@
return false;
}
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const externalCommitGuard = options && typeof options.commitGuard === 'function'
+ ? options.commitGuard
+ : null;
+ const ownsReviewRegistration = () => {
+ if (externalCommitGuard && externalCommitGuard() !== true) return false;
+ if (!expectedRegistration) return true;
+ if (expectedRegistration.window !== resolvedWindow) return false;
+ return typeof this._isExamSessionRegistrationCurrent !== 'function'
+ || this._isExamSessionRegistrationCurrent(examId, expectedRegistration) === true;
+ };
+ if (!ownsReviewRegistration()) return false;
+
+ const previousActiveExamId = session.activeExamId;
+ const previousIndex = session.currentIndex;
session.activeExamId = examId;
const sessionIndex = session.sequence.findIndex(item => item && item.examId === examId);
if (sessionIndex >= 0) {
session.currentIndex = sessionIndex;
- this._mirrorSessionToStorage(session);
+ let durableReceiptConfirmed = false;
+ const restoreStillOwned = () => this.currentSuiteSession === session
+ && session.status === 'active'
+ && session.currentIndex === sessionIndex
+ && String(session.activeExamId || '') === String(examId)
+ && ownsReviewRegistration();
+ if (!await this._commitSuiteRecovery(session, {
+ reason: 'review-restore',
+ commitGuard: restoreStillOwned,
+ onDurableReceipt: () => { durableReceiptConfirmed = true; }
+ })) {
+ if (!durableReceiptConfirmed
+ && this.currentSuiteSession === session
+ && session.currentIndex === sessionIndex
+ && String(session.activeExamId || '') === String(examId)) {
+ session.currentIndex = previousIndex;
+ session.activeExamId = previousActiveExamId;
+ }
+ return false;
+ }
+ if (!this._canContinueSuiteOperation(session) || !restoreStillOwned()) {
+ return false;
+ }
+ }
+ if (!ownsReviewRegistration()) return false;
+ return this._sendSuiteReviewState(session, examId, resolvedWindow);
+ },
+
+ _beginSuiteExamLaunchOwnership(examId, options = {}) {
+ if (!examId || typeof this._beginExamLaunchOwnership !== 'function') return null;
+ const launchOptions = {};
+ if (typeof options.windowName === 'string' && options.windowName.trim()) {
+ launchOptions.windowName = options.windowName;
+ }
+ if (options.reuseWindow && !options.reuseWindow.closed) {
+ launchOptions.reuseWindow = options.reuseWindow;
+ }
+ return this._beginExamLaunchOwnership(examId, launchOptions);
+ },
+
+ _claimSuiteExamLaunchWindow(ownership, targetWindow) {
+ if (!ownership) return true;
+ if (!targetWindow || targetWindow.closed
+ || typeof this._claimExamLaunchWindowOwnership !== 'function') {
+ return false;
}
- return this._sendSuiteReviewState(session, examId, resolvedWindow);
+ return this._claimExamLaunchWindowOwnership(ownership, targetWindow) === true;
+ },
+
+ _isSuiteExamLaunchOwnershipCurrent(examId, ownership, targetWindow = null) {
+ if (!ownership) return true;
+ if (typeof this._isExamLaunchOwnershipCurrent !== 'function') return false;
+ return this._isExamLaunchOwnershipCurrent(examId, ownership, null, targetWindow) === true;
+ },
+
+ _isSuiteCallerRegistrationCurrent(examId, sourceWindow, options = {}) {
+ const commitGuard = options && typeof options.commitGuard === 'function'
+ ? options.commitGuard
+ : null;
+ if (commitGuard && commitGuard() !== true) return false;
+ const expectedRegistration = options && options.expectedRegistration || null;
+ if (!expectedRegistration) return true;
+ if (sourceWindow && expectedRegistration.window !== sourceWindow) return false;
+ return commitGuard
+ ? true
+ : (typeof this._isExamSessionRegistrationCurrent !== 'function'
+ || this._isExamSessionRegistrationCurrent(examId, expectedRegistration) === true);
+ },
+
+ _captureSuiteNavigationRegistration(
+ examId,
+ targetWindow,
+ expectedSuiteSessionId = '',
+ launchOwnership = null
+ ) {
+ const expectedSuiteSession = expectedSuiteSessionId
+ && typeof expectedSuiteSessionId === 'object'
+ ? expectedSuiteSessionId
+ : null;
+ const normalizedSuiteSessionId = String(
+ expectedSuiteSession
+ ? expectedSuiteSession.id || ''
+ : expectedSuiteSessionId || ''
+ ).trim();
+ if (!examId
+ || !normalizedSuiteSessionId
+ || (expectedSuiteSession && this.currentSuiteSession !== expectedSuiteSession)
+ || !targetWindow
+ || targetWindow.closed
+ || !launchOwnership
+ || typeof this._captureExamLaunchRegistrationReceipt !== 'function'
+ || typeof this._isExamSessionRegistrationCurrent !== 'function') return null;
+ const registration = this._captureExamLaunchRegistrationReceipt(
+ examId,
+ launchOwnership,
+ targetWindow
+ );
+ if (!registration
+ || registration.window !== targetWindow
+ || String(registration.suiteSessionId || '').trim() !== normalizedSuiteSessionId) return null;
+ // Consume the frozen registration produced by this exact openExam launch.
+ // Re-reading examWindows here could adopt a newer same-window registration.
+ return this._isExamSessionRegistrationCurrent(examId, registration) === true
+ ? registration
+ : null;
+ },
+
+ _isSuiteNavigationRegistrationCurrent(examId, registration, expectedSuiteSessionId = '') {
+ const expectedSuiteSession = expectedSuiteSessionId
+ && typeof expectedSuiteSessionId === 'object'
+ ? expectedSuiteSessionId
+ : null;
+ const normalizedSuiteSessionId = String(
+ expectedSuiteSession
+ ? expectedSuiteSession.id || ''
+ : expectedSuiteSessionId || ''
+ ).trim();
+ if (!registration
+ || !normalizedSuiteSessionId
+ || (expectedSuiteSession && this.currentSuiteSession !== expectedSuiteSession)
+ || String(registration.suiteSessionId || '').trim() !== normalizedSuiteSessionId) return false;
+ return typeof this._isExamSessionRegistrationCurrent === 'function'
+ && this._isExamSessionRegistrationCurrent(examId, registration) === true;
},
- async handleSuiteReviewNavigate(examId, data = {}, sourceWindow = null) {
+ async handleSuiteReviewNavigate(examId, data = {}, sourceWindow = null, options = {}) {
if (this._shouldAutoAdvanceAfterSubmit()) {
return false;
}
@@ -926,6 +3668,22 @@
if (!session || session.status !== 'active' || !Array.isArray(session.sequence) || !session.sequence.length) {
return false;
}
+ const sourceRegistrationStillOwned = () => this._isSuiteCallerRegistrationCurrent(
+ examId,
+ sourceWindow,
+ options
+ );
+ if (!sourceRegistrationStillOwned()) return false;
+ const reviewWindowInfo = this.examWindows && this.examWindows.get(examId);
+ const suppliedSourceRegistration = options && options.expectedRegistration || null;
+ const reviewSourceRegistration = suppliedSourceRegistration
+ && (!sourceWindow || suppliedSourceRegistration.window === sourceWindow)
+ ? suppliedSourceRegistration
+ : (reviewWindowInfo
+ && (!sourceWindow || reviewWindowInfo.window === sourceWindow)
+ && typeof this._captureExamSessionRegistration === 'function'
+ ? this._captureExamSessionRegistration(examId, reviewWindowInfo)
+ : null);
const payloadSuiteSessionId = data && typeof data.suiteSessionId === 'string'
? data.suiteSessionId.trim()
@@ -938,15 +3696,6 @@
if (currentIndex < 0) {
return false;
}
- session.currentIndex = currentIndex;
- session.activeExamId = examId;
- this._persistSuiteDraftSnapshot(session, examId, data);
- if (Number.isFinite(Number(data && data.elapsed))) {
- session.elapsedByExam[examId] = this._deriveSuiteExamElapsedSeconds(session, examId, Number(data.elapsed));
- }
- this._syncSuiteTimerFromPayload(session, data);
- this._mirrorSessionToStorage(session);
-
const direction = String(data.direction || '').trim().toLowerCase();
let targetIndex = currentIndex;
if (direction === 'next') {
@@ -956,10 +3705,12 @@
} else {
return false;
}
-
const hasCurrentResult = Array.isArray(session.results)
? session.results.some(item => item && item.examId === examId)
: false;
+ if (direction === 'next' && !hasCurrentResult) {
+ return false;
+ }
const requestedFinalizeOnNext = Boolean(
direction === 'next'
&& currentIndex === session.sequence.length - 1
@@ -967,9 +3718,45 @@
&& data.finalizeOnNext === true
&& hasCurrentResult
);
+ const targetEntry = targetIndex >= 0 && targetIndex < session.sequence.length
+ ? session.sequence[targetIndex]
+ : null;
+ const initialTargetWindow = sourceWindow && !sourceWindow.closed
+ ? sourceWindow
+ : (session.windowRef && !session.windowRef.closed ? session.windowRef : null);
+ const launchWindowName = session.windowName || 'ielts-suite-mode-tab';
+ const launchOwnership = targetEntry && targetEntry.examId
+ ? this._beginSuiteExamLaunchOwnership(targetEntry.examId, {
+ windowName: launchWindowName
+ })
+ : null;
+ session.currentIndex = currentIndex;
+ session.activeExamId = examId;
+ this._persistSuiteDraftSnapshot(session, examId, data);
+ if (Number.isFinite(Number(data && data.elapsed))) {
+ session.elapsedByExam[examId] = this._deriveSuiteExamElapsedSeconds(session, examId, Number(data.elapsed));
+ }
+ this._syncSuiteTimerFromPayload(session, data);
+ const reviewDraftStillOwned = () => this.currentSuiteSession === session
+ && session.status === 'active'
+ && sourceRegistrationStillOwned();
+ if (!await this._commitSuiteRecovery(session, {
+ reason: 'review-draft',
+ commitGuard: reviewDraftStillOwned
+ })) {
+ return false;
+ }
+ if (!this._canContinueSuiteOperation(session) || !reviewDraftStillOwned()) {
+ return false;
+ }
+ if (targetEntry && !this._isSuiteExamLaunchOwnershipCurrent(
+ targetEntry.examId,
+ launchOwnership
+ )) return false;
if (requestedFinalizeOnNext) {
+ if (!this._canContinueSuiteOperation(session)) return false;
session.pendingAdvance = null;
- await this.finalizeSuiteRecord(session);
+ await this._finalizeSuiteRecordWithGate(session);
return true;
}
@@ -982,32 +3769,101 @@
&& session.pendingAdvance.finalReview === true
);
if (canFinalize) {
+ if (!this._canContinueSuiteOperation(session)) return false;
session.pendingAdvance = null;
- await this.finalizeSuiteRecord(session);
+ await this._finalizeSuiteRecordWithGate(session);
return true;
}
return true;
}
- const targetEntry = session.sequence[targetIndex];
if (!targetEntry || !targetEntry.examId) {
return false;
}
- let targetWindow = sourceWindow && !sourceWindow.closed ? sourceWindow : (session.windowRef && !session.windowRef.closed ? session.windowRef : null);
+ const previousIndex = session.currentIndex;
+ const previousActiveExamId = session.activeExamId;
+ let sourceRegistrationReleased = false;
+ let targetRegistration = null;
+ const navigationRegistrationStillOwned = (targetWindow = null) => targetRegistration
+ ? ((!targetWindow || targetRegistration.window === targetWindow)
+ && !targetRegistration.window.closed
+ && this._isSuiteNavigationRegistrationCurrent(
+ targetEntry.examId,
+ targetRegistration,
+ session
+ ))
+ : (sourceRegistrationReleased || sourceRegistrationStillOwned());
+ session.currentIndex = targetIndex;
+ session.activeExamId = targetEntry.examId;
+ session.lastUpdate = Date.now();
+ let reviewDurableReceiptConfirmed = false;
+ const reviewLaunchStillOwned = (targetWindow = null) => (
+ this.currentSuiteSession === session
+ && session.status === 'active'
+ && session.currentIndex === targetIndex
+ && String(session.activeExamId || '') === String(targetEntry.examId)
+ && navigationRegistrationStillOwned(targetWindow)
+ && (targetRegistration
+ ? true
+ : this._isSuiteExamLaunchOwnershipCurrent(
+ targetEntry.examId,
+ launchOwnership,
+ targetWindow
+ ))
+ );
+ const reviewCommitted = await this._commitSuiteRecovery(session, {
+ reason: 'review-navigate',
+ commitGuard: reviewLaunchStillOwned,
+ onDurableReceipt: () => { reviewDurableReceiptConfirmed = true; }
+ });
+ if (!reviewCommitted || !reviewLaunchStillOwned()) {
+ if (!reviewDurableReceiptConfirmed
+ && this.currentSuiteSession === session
+ && session.currentIndex === targetIndex
+ && String(session.activeExamId || '') === String(targetEntry.examId)) {
+ session.currentIndex = previousIndex;
+ session.activeExamId = previousActiveExamId;
+ }
+ return false;
+ }
+ if (!this._canContinueSuiteOperation(session)) {
+ return false;
+ }
+
+ let targetWindow = initialTargetWindow;
+ if (targetWindow && (!this._claimSuiteExamLaunchWindow(launchOwnership, targetWindow)
+ || !reviewLaunchStillOwned(targetWindow))) return false;
const isCrossExamNavigation = targetEntry.examId !== examId;
- if (isCrossExamNavigation && typeof this.cleanupExamSession === 'function') {
+ if (isCrossExamNavigation) {
+ if (typeof this.cleanupExamSession !== 'function' || !reviewSourceRegistration) {
+ return false;
+ }
+ let sourceCleanupConfirmed = false;
try {
- await this.cleanupExamSession(examId);
+ sourceCleanupConfirmed = await this.cleanupExamSession(examId, {
+ expectedRegistration: reviewSourceRegistration,
+ recoverySessionId: reviewSourceRegistration.expectedSessionId
+ }) === true;
} catch (cleanupError) {
console.warn('[SuitePractice] review 跨篇切换清理旧会话失败:', cleanupError);
+ return false;
}
+ if (!sourceCleanupConfirmed) return false;
+ if (!this._canContinueSuiteOperation(session)
+ || !this._isSuiteExamLaunchOwnershipCurrent(
+ targetEntry.examId,
+ launchOwnership,
+ targetWindow
+ )) return false;
+ sourceRegistrationReleased = true;
}
if (isCrossExamNavigation || !targetWindow) {
- targetWindow = await this.openExam(targetEntry.examId, {
+ const openOptions = {
+ examDefinition: targetEntry.exam,
target: 'tab',
- windowName: session.windowName || 'ielts-suite-mode-tab',
+ windowName: launchWindowName,
suiteSessionId: session.id,
suiteFlowMode: session.flowMode || 'simulation',
suiteTimerMode: session.suiteTimerMode || 'countdown',
@@ -1015,21 +3871,35 @@
sequenceIndex: targetIndex,
sequenceTotal: session.sequence.length,
reuseWindow: targetWindow || undefined
- });
+ };
+ if (!targetWindow) delete openOptions.reuseWindow;
+ if (launchOwnership) openOptions.launchOwnership = launchOwnership;
+ targetWindow = await this.openExam(targetEntry.examId, openOptions);
+ if (targetWindow && !targetWindow.closed) {
+ targetRegistration = this._captureSuiteNavigationRegistration(
+ targetEntry.examId,
+ targetWindow,
+ session,
+ launchOwnership
+ );
+ if (!targetRegistration) return false;
+ }
+ if (!this._canContinueSuiteOperation(session)
+ || !targetWindow
+ || targetWindow.closed
+ || !reviewLaunchStillOwned(targetWindow)) return false;
}
if (!targetWindow || targetWindow.closed) {
return false;
}
+ if (!reviewLaunchStillOwned(targetWindow)) return false;
session.windowRef = targetWindow;
- session.currentIndex = targetIndex;
- session.activeExamId = targetEntry.examId;
- session.lastUpdate = Date.now();
- this._mirrorSessionToStorage(session);
this._focusSuiteWindow(targetWindow);
if (isCrossExamNavigation) {
const ready = await this._waitForSuiteWindowExamReady(session, targetEntry.examId, targetWindow);
+ if (!this._canContinueSuiteOperation(session) || !reviewLaunchStillOwned(targetWindow)) return false;
if (!ready) {
if (!this._canFallbackSendSuiteContext(targetEntry.examId, targetWindow)) {
console.warn('[SuitePractice] 套题切换等待 ready 超时,延后上下文下发,等待 SESSION_READY 兜底');
@@ -1038,6 +3908,7 @@
console.warn('[SuitePractice] 套题切换未收到 fresh ready,但窗口已切到目标篇,继续下发上下文');
}
}
+ if (!reviewLaunchStillOwned(targetWindow)) return false;
if (session.flowMode === 'simulation') {
session._contextSentExamId = targetEntry.examId;
session._contextSentAt = Date.now();
@@ -1045,13 +3916,16 @@
} else {
await this._sendSuiteReviewState(session, targetEntry.examId, targetWindow);
}
+ if (!reviewLaunchStillOwned(targetWindow)) return false;
return true;
},
- async _advanceSuiteToNext(session, completedTitle, skipExamIdForAbort) {
+ async _advanceSuiteToNext(session, completedTitle, skipExamIdForAbort, options = {}) {
+ if (!this._canContinueSuiteOperation(session)) {
+ return false;
+ }
if (typeof this.openExam !== 'function') {
- window.showMessage && window.showMessage('无法继续套题练习,已回退到普通模式。', 'warning');
- await this._abortSuiteSession(session, { reason: 'missing_open_exam', skipExamId: skipExamIdForAbort || null });
+ window.showMessage && window.showMessage('当前篇已保存,但下一篇未能打开;可从套题模式继续。', 'warning');
return false;
}
@@ -1061,12 +3935,41 @@
return false;
}
+ const frozenIndex = session.currentIndex;
+ const windowName = typeof options.windowName === 'string' && options.windowName.trim()
+ ? options.windowName
+ : (session.windowName || 'ielts-suite-mode-tab');
+ const reuseWindow = Object.prototype.hasOwnProperty.call(options, 'reuseWindow')
+ ? (options.reuseWindow && !options.reuseWindow.closed ? options.reuseWindow : null)
+ : (session.windowRef && !session.windowRef.closed ? session.windowRef : null);
+ const launchOwnership = options.launchOwnership
+ || this._beginSuiteExamLaunchOwnership(nextEntry.examId, { windowName, reuseWindow });
+ let targetRegistration = null;
+ const launchStillCurrent = (targetWindow = null) => (
+ this._canContinueSuiteOperation(session)
+ && session.currentIndex === frozenIndex
+ && String(session.activeExamId || nextEntry.examId) === String(nextEntry.examId)
+ && (targetRegistration
+ ? ((!targetWindow || targetRegistration.window === targetWindow)
+ && !targetRegistration.window.closed
+ && this._isSuiteNavigationRegistrationCurrent(
+ nextEntry.examId,
+ targetRegistration,
+ session
+ ))
+ : this._isSuiteExamLaunchOwnershipCurrent(nextEntry.examId, launchOwnership, targetWindow))
+ );
+ if (!this._isSuiteExamLaunchOwnershipCurrent(nextEntry.examId, launchOwnership, reuseWindow)) {
+ return false;
+ }
session.activeExamId = nextEntry.examId;
- const windowName = session.windowName || 'ielts-suite-mode-tab';
- const reuseWindow = session.windowRef && !session.windowRef.closed ? session.windowRef : null;
let openError = null;
const attemptOpen = async (candidateWindow = null) => {
+ if (candidateWindow && !this._claimSuiteExamLaunchWindow(launchOwnership, candidateWindow)) {
+ return null;
+ }
+ if (!launchStillCurrent(candidateWindow)) return null;
const options = {
target: 'tab',
windowName,
@@ -1081,9 +3984,23 @@
if (candidateWindow && !candidateWindow.closed) {
options.reuseWindow = candidateWindow;
}
+ if (launchOwnership) options.launchOwnership = launchOwnership;
try {
- const opened = await this.openExam(nextEntry.examId, options);
+ const opened = await this.openExam(nextEntry.examId, {
+ ...options,
+ examDefinition: nextEntry.exam
+ });
+ if (opened && !opened.closed) {
+ targetRegistration = this._captureSuiteNavigationRegistration(
+ nextEntry.examId,
+ opened,
+ session,
+ launchOwnership
+ );
+ if (!targetRegistration) return null;
+ }
+ if (!launchStillCurrent(opened && !opened.closed ? opened : candidateWindow)) return null;
if (opened && !opened.closed) {
return opened;
}
@@ -1101,6 +4018,7 @@
}
if ((!nextWindow || nextWindow.closed) && windowName) {
+ if (!launchStillCurrent(reuseWindow)) return false;
const fallbackWindow = typeof this._reacquireSuiteWindow === 'function'
? this._reacquireSuiteWindow(windowName, session)
: this._openNamedSuiteWindow(windowName, session);
@@ -1110,21 +4028,22 @@
}
if (!nextWindow || nextWindow.closed) {
+ if (!launchStillCurrent()) return false;
if (openError) {
console.warn('[SuitePractice] 套题无法打开下一篇:', openError);
}
- window.showMessage && window.showMessage('无法继续套题练习,已回退到普通模式。', 'warning');
- await this._abortSuiteSession(session, { reason: 'open_next_failed', skipExamId: skipExamIdForAbort || null });
+ window.showMessage && window.showMessage('当前篇已保存,但下一篇未能打开;可从套题模式继续。', 'warning');
return false;
}
+ if (!launchStillCurrent(nextWindow)) return false;
session.windowRef = nextWindow;
this._ensureSuiteWindowGuard(session, session.windowRef);
this._focusSuiteWindow(session.windowRef);
- this._mirrorSessionToStorage(session);
const reusedNextWindow = Boolean(reuseWindow && nextWindow === reuseWindow);
if (reusedNextWindow) {
const ready = await this._waitForSuiteWindowExamReady(session, nextEntry.examId, session.windowRef);
+ if (!launchStillCurrent(session.windowRef)) return false;
if (!ready) {
if (!this._canFallbackSendSuiteContext(nextEntry.examId, session.windowRef)) {
window.showMessage && window.showMessage('已完成' + (completedTitle || '上一篇') + ',正在继续:' + nextEntry.exam.title + '。', 'success');
@@ -1134,28 +4053,60 @@
console.warn('[SuitePractice] 自动切题未收到 fresh ready,但窗口已切到目标篇,继续下发上下文');
}
}
+ if (!launchStillCurrent(session.windowRef)) return false;
session._contextSentExamId = nextEntry.examId;
session._contextSentAt = Date.now();
this._sendSimulationContext(session, nextEntry.examId, session.windowRef);
+ if (!launchStillCurrent(session.windowRef)) return false;
window.showMessage && window.showMessage('已完成' + (completedTitle || '上一篇') + ',正在继续:' + nextEntry.exam.title + '。', 'success');
return true;
},
- _mirrorSessionToStorage(session) {
- if (!session) return;
+ _buildSuiteRecoverySnapshot(session, options = {}) {
+ if (!session) return null;
try {
+ const now = Date.now();
+ const previousUpdate = Number(session.lastUpdate);
+ session.lastUpdate = Number.isFinite(previousUpdate)
+ ? Math.max(now, previousUpdate + 1)
+ : now;
+ const currentRevision = normalizeRecoveryEntityRevision(session.revision);
+ session.revision = options.bumpRevision !== false
+ ? Math.min(Number.MAX_SAFE_INTEGER, currentRevision + 1)
+ : currentRevision;
+ const hasWindowBindingOverride = Object.prototype.hasOwnProperty.call(
+ options,
+ 'windowBindingSnapshotOverride'
+ );
+ const windowBinding = this._buildSuiteWindowBinding(session, hasWindowBindingOverride ? {
+ override: options.windowBindingSnapshotOverride,
+ strict: true
+ } : {});
+ if (hasWindowBindingOverride && !windowBinding) {
+ throw new Error('Explicit suite window binding snapshot is invalid');
+ }
const snapshot = {
+ schema: 'suite-session-v2',
+ version: 2,
id: session.id,
- sequence: session.sequence,
+ generation: Math.max(0, Number(session._suiteGeneration) || 0),
+ status: session.status || 'active',
+ sequence: this._cloneSuitePlainObject(session.sequence || []),
suiteSequence: this._buildSuiteSequencePayload(session),
- currentIndex: session.currentIndex,
- draftsByExam: session.draftsByExam || {},
- elapsedByExam: session.elapsedByExam || {},
- globalTimerAnchorMs: session.globalTimerAnchorMs,
+ currentIndex: Number.isInteger(session.currentIndex) ? session.currentIndex : 0,
+ draftsByExam: this._cloneSuitePlainObject(session.draftsByExam || {}),
+ elapsedByExam: this._cloneSuitePlainObject(session.elapsedByExam || {}),
+ globalTimerAnchorMs: Number(session.globalTimerAnchorMs) || Number(session.startTime) || now,
+ suiteTimerAnchorMs: Number(session.suiteTimerAnchorMs) || Number(session.globalTimerAnchorMs) || Number(session.startTime) || now,
+ suiteTimerMode: session.suiteTimerMode || 'countdown',
+ suiteTimerLimitSeconds: Number.isFinite(Number(session.suiteTimerLimitSeconds))
+ ? Number(session.suiteTimerLimitSeconds)
+ : 3600,
suiteTimerPausedOffsetMs: Math.max(0, Number(session.suiteTimerPausedOffsetMs) || 0),
suiteTimerPausedAtMs: Number.isFinite(Number(session.suiteTimerPausedAtMs)) ? Number(session.suiteTimerPausedAtMs) : null,
suiteTimerRunning: session.suiteTimerRunning !== false,
flowMode: session.flowMode || 'simulation',
+ frequencyScope: session.frequencyScope || 'all',
autoAdvanceAfterSubmit: typeof session.autoAdvanceAfterSubmit === 'boolean'
? session.autoAdvanceAfterSubmit
: true,
@@ -1166,32 +4117,641 @@
markedQuestions: Array.isArray(r.markedQuestions) ? r.markedQuestions.slice() : [],
rawData: this._sanitizeSuiteRawData(r.rawData)
})),
- startTime: session.startTime,
- activeExamId: session.activeExamId
+ startTime: Number(session.startTime) || now,
+ activeExamId: session.activeExamId || null,
+ pendingAdvance: session.pendingAdvance && typeof session.pendingAdvance === 'object'
+ ? this._cloneSuitePlainObject(session.pendingAdvance)
+ : null,
+ windowBinding,
+ windowName: session.windowName || 'ielts-suite-mode-tab',
+ lastUpdate: session.lastUpdate,
+ revision: normalizeRecoveryEntityRevision(session.revision),
+ draftRevision: Math.max(0, Number(session.draftRevision) || 0),
+ finalizeOperationId: session.finalizeOperationId || null,
+ finalizeRecord: session.finalizeRecord
+ ? this._cloneSuitePlainObject(session.finalizeRecord)
+ : null
};
- if (global.sessionStorage) {
- global.sessionStorage.setItem('ielts_sim_session', JSON.stringify(snapshot));
+ return snapshot;
+ } catch (error) {
+ console.warn('[SuitePractice] 无法构建套题恢复快照:', error);
+ return null;
+ }
+ },
+
+ _mirrorSuiteRecoverySnapshot(snapshot, ownerSession = null) {
+ if (!snapshot || !global.AppData?.recovery?.windowSession) return false;
+ if (!isFileProtocol && (!ownerSession
+ || String(snapshot.id ?? '') !== String(ownerSession.id ?? '')
+ || !this._ownsSuiteRecoveryClaim('single', ownerSession))) {
+ return false;
+ }
+ try {
+ const saved = global.AppData.recovery.windowSession.save('simulation', snapshot) !== false;
+ if (!saved) {
+ this._showSuiteRecoveryMirrorFailure();
}
- } catch (_) { /* file:// may not support */ }
+ return saved;
+ } catch (error) {
+ this._showSuiteRecoveryMirrorFailure(error);
+ return false;
+ }
},
- _restoreSessionFromStorage() {
+ _showSuiteRecoveryMirrorFailure(error = null) {
+ if (error) {
+ console.warn('[SuitePractice] 窗口级套题恢复镜像写入失败,持久 v2 恢复仍会继续尝试:', error);
+ }
+ const now = Date.now();
+ if (now - Number(this._lastSuiteRecoveryMirrorFailureAt || 0) < 30000) {
+ return;
+ }
+ this._lastSuiteRecoveryMirrorFailureAt = now;
try {
- if (!global.sessionStorage) return null;
- const raw = global.sessionStorage.getItem('ielts_sim_session');
- if (!raw) return null;
- const snapshot = JSON.parse(raw);
- if (!snapshot || !snapshot.id || !Array.isArray(snapshot.sequence)) return null;
- return snapshot;
- } catch (_) { return null; }
+ window.showMessage && window.showMessage(
+ '浏览器已拒绝临时恢复存储。系统仍会尝试保存到主数据层;若再次提示保存失败,本次操作会暂停,请先处理存储权限或空间。',
+ 'warning'
+ );
+ } catch (_) { /* the v2 recovery path must not depend on presentation helpers */ }
+ },
+
+ _mirrorSessionToStorage(session) {
+ if (!session || session._suiteRecoveryWritesBlocked === true
+ || !this._ownsSuiteRecoveryClaim('single', session)) return false;
+ const snapshot = this._buildSuiteRecoverySnapshot(session);
+ return this._mirrorSuiteRecoverySnapshot(snapshot, session);
+ },
+
+ _buildSuiteWindowBinding(session, options = {}) {
+ const hasOverride = Object.prototype.hasOwnProperty.call(options, 'override');
+ const fallbackSource = hasOverride ? options.override : session && session.windowBinding;
+ const fallback = fallbackSource && typeof fallbackSource === 'object'
+ ? fallbackSource
+ : null;
+ const validatedFallback = () => {
+ const fallbackGeneration = Number(fallback && fallback.sessionGeneration);
+ const fallbackExamId = String(fallback && fallback.examId || '').trim();
+ if (!fallback
+ || !fallbackExamId
+ || !Array.isArray(session.sequence)
+ || !session.sequence.some((entry) => entry && String(entry.examId) === fallbackExamId)
+ || !String(fallback.expectedSessionId || '').trim()
+ || !String(fallback.windowSessionToken || '').trim()
+ || !Number.isInteger(fallbackGeneration)
+ || fallbackGeneration <= 0) {
+ return null;
+ }
+ return { ...this._cloneSuitePlainObject(fallback), examId: fallbackExamId };
+ };
+ if (hasOverride && options.strict === true) {
+ return validatedFallback();
+ }
+ const examId = session && session.activeExamId != null
+ ? String(session.activeExamId)
+ : String(fallback && fallback.examId || '');
+ const info = examId && this.examWindows && this.examWindows.get(examId);
+ if (!info || !info.window || info.window.closed) {
+ return validatedFallback();
+ }
+ const expectedSessionId = typeof info.expectedSessionId === 'string' ? info.expectedSessionId.trim() : '';
+ const windowSessionToken = typeof info.windowSessionToken === 'string' ? info.windowSessionToken.trim() : '';
+ const generation = Number(info.sessionGeneration);
+ if (!expectedSessionId || !windowSessionToken || !Number.isInteger(generation) || generation <= 0) {
+ return validatedFallback();
+ }
+ if (Object.prototype.hasOwnProperty.call(info, 'suiteSessionId')) {
+ if (String(info.suiteSessionId ?? '') !== String(session.id)) {
+ return validatedFallback();
+ }
+ } else {
+ // A truly legacy registration has no suiteSessionId field. It may only
+ // refresh a binding whose exact non-secret credentials were already
+ // persisted; never infer suite ownership from the global current session.
+ const legacyFallback = validatedFallback();
+ if (!legacyFallback
+ || String(legacyFallback.examId) !== String(examId)
+ || String(legacyFallback.expectedSessionId) !== expectedSessionId
+ || String(legacyFallback.windowSessionToken) !== windowSessionToken
+ || Number(legacyFallback.sessionGeneration) !== generation) {
+ return legacyFallback;
+ }
+ }
+ return {
+ examId,
+ expectedSessionId,
+ windowSessionToken,
+ sessionGeneration: generation,
+ expectedUrl: info.expectedUrl || '',
+ expectedOrigin: info.expectedOrigin || '',
+ allowOpaqueOrigin: info.allowOpaqueOrigin === true
+ };
+ },
+
+ async _commitSuiteWindowBindingBeforeHandshake(suiteSessionId, examId, examWindow, windowInfo = null, options = {}) {
+ const session = this.currentSuiteSession;
+ const normalizedSuiteId = String(suiteSessionId || '').trim();
+ const normalizedExamId = String(examId || '').trim();
+ if (!session
+ || !normalizedSuiteId
+ || String(session.id) !== normalizedSuiteId
+ || !this._isSuiteSessionCurrentOwner(session)
+ || !normalizedExamId
+ || String(session.activeExamId || '') !== normalizedExamId
+ || !examWindow
+ || examWindow.closed) {
+ return false;
+ }
+ const registeredInfo = windowInfo || (this.examWindows && this.examWindows.get(normalizedExamId));
+ if (!registeredInfo
+ || registeredInfo.window !== examWindow
+ || String(registeredInfo.suiteSessionId || '') !== normalizedSuiteId) {
+ return false;
+ }
+ const previousWindowRef = session.windowRef || null;
+ const previousBinding = session.windowBinding
+ ? this._cloneSuitePlainObject(session.windowBinding)
+ : null;
+ const expectedStatus = session.status;
+ session.windowRef = examWindow;
+ const binding = this._buildSuiteWindowBinding(session);
+ if (!binding) {
+ session.windowRef = previousWindowRef;
+ return false;
+ }
+ session.windowBinding = binding;
+ session.lastUpdate = Date.now();
+ const suppliedGuard = typeof options.commitGuard === 'function' ? options.commitGuard : null;
+ const bindingStillOwned = () => {
+ let suppliedAllows = true;
+ try {
+ suppliedAllows = !suppliedGuard || suppliedGuard() !== false;
+ } catch (_) {
+ suppliedAllows = false;
+ }
+ const liveRegistration = this.examWindows && this.examWindows.get(normalizedExamId);
+ return suppliedAllows
+ && this.currentSuiteSession === session
+ && this._isSuiteSessionCurrentOwner(session)
+ && this._ownsSuiteRecoveryClaim('single', session)
+ && session.status === expectedStatus
+ && String(session.activeExamId || '') === normalizedExamId
+ && session.windowRef === examWindow
+ && session.windowBinding === binding
+ && liveRegistration === registeredInfo
+ && registeredInfo.window === examWindow
+ && String(registeredInfo.suiteSessionId || '') === normalizedSuiteId;
+ };
+ let bindingDurableReceiptConfirmed = false;
+ const committed = await this._commitSuiteRecovery(session, {
+ reason: 'window-binding',
+ commitGuard: bindingStillOwned,
+ onDurableReceipt: () => { bindingDurableReceiptConfirmed = true; }
+ });
+ if (!committed || !bindingStillOwned()) {
+ if (bindingDurableReceiptConfirmed) return false;
+ if (this.currentSuiteSession !== session
+ || !this._isSuiteSessionCurrentOwner(session)
+ || session.windowRef !== examWindow
+ || session.windowBinding !== binding) {
+ return false;
+ }
+ session.windowRef = previousWindowRef;
+ session.windowBinding = previousBinding;
+ return false;
+ }
+ return true;
+ },
+
+ _isSuiteRecoveryQuotaError(error) {
+ const code = String(error && (error.code || error.name) || '').toUpperCase();
+ return code === 'QUOTA_EXCEEDED'
+ || code === 'QUOTAEXCEEDEDERROR'
+ || code === 'NS_ERROR_DOM_QUOTA_REACHED';
+ },
+
+ _showSuiteRecoveryPersistenceFailure(error, phase = 'update') {
+ const quota = this._isSuiteRecoveryQuotaError(error);
+ const recoveryCode = String(error && error.code || '').toUpperCase();
+ const stale = recoveryCode === 'STALE_RECOVERY_WRITE'
+ || recoveryCode === 'RECOVERY_GROUP_CONFLICT';
+ const message = stale
+ ? '另一页面已更新这套练习。为避免旧进度覆盖新进度,本页面的操作已暂停;请返回最新页面继续。'
+ : (quota
+ ? '浏览器存储空间不足。系统已尝试清理过期恢复数据,但仍无法安全保存套题;本次操作已暂停,练习记录不会被删除。'
+ : '浏览器拒绝或无法使用持久存储。为避免套题进度丢失,本次操作已暂停;请允许站点存储,file:// 下也可改用本地静态服务器后重试。');
+ const key = (stale ? 'stale:' : (quota ? 'quota:' : 'backend:')) + String(phase || 'update');
+ const now = Date.now();
+ if (this._lastSuiteRecoveryFailureKey === key
+ && now - Number(this._lastSuiteRecoveryFailureAt || 0) < 5000) {
+ return;
+ }
+ this._lastSuiteRecoveryFailureKey = key;
+ this._lastSuiteRecoveryFailureAt = now;
+ window.showMessage && window.showMessage(message, 'error');
+ },
+
+ async _commitSuiteRecovery(session, options = {}) {
+ if (!session || !session.id) return false;
+ if (session._suiteRecoveryWritesBlocked === true) return false;
+ const hasWindowBindingOverride = Object.prototype.hasOwnProperty.call(
+ options,
+ 'windowBindingSnapshotOverride'
+ );
+ const windowBindingOverrideRef = hasWindowBindingOverride
+ ? options.windowBindingSnapshotOverride
+ : null;
+ let windowBindingSnapshotOverride = null;
+ if (hasWindowBindingOverride) {
+ try {
+ windowBindingSnapshotOverride = this._cloneSuitePlainObject(windowBindingOverrideRef);
+ } catch (_) {
+ return false;
+ }
+ }
+ const previous = session._suiteRecoveryCommitTail && typeof session._suiteRecoveryCommitTail.then === 'function'
+ ? session._suiteRecoveryCommitTail
+ : Promise.resolve();
+ const commit = previous.catch(() => undefined).then(async () => {
+ if (session._suiteRecoveryWritesBlocked === true) return false;
+ if (!this._ownsSuiteRecoveryClaim('single', session)
+ && !await this._acquireSuiteRecoveryClaim('single', session)) {
+ return false;
+ }
+ const commitGuard = typeof options.commitGuard === 'function'
+ ? options.commitGuard
+ : null;
+ const guardAllowsCommit = () => {
+ if (hasWindowBindingOverride && session.windowBinding !== windowBindingOverrideRef) {
+ return false;
+ }
+ if (!commitGuard) return true;
+ try {
+ return commitGuard() !== false;
+ } catch (_) {
+ return false;
+ }
+ };
+ if (!guardAllowsCommit() || !this._ownsSuiteRecoveryClaim('single', session)) return false;
+ const recovery = global.AppData && global.AppData.recovery;
+ if (!recovery || typeof recovery.saveActiveSession !== 'function') {
+ const unavailable = new Error('AppData v2 recovery.saveActiveSession is unavailable');
+ unavailable.code = 'BACKEND_UNAVAILABLE';
+ throw unavailable;
+ }
+ const snapshot = this._buildSuiteRecoverySnapshot(session, hasWindowBindingOverride ? {
+ windowBindingSnapshotOverride
+ } : {});
+ if (!snapshot) {
+ const invalid = new Error('Suite recovery snapshot could not be built');
+ invalid.code = 'VALIDATION';
+ throw invalid;
+ }
+ const snapshotRevision = normalizeRecoveryEntityRevision(snapshot.revision);
+ const operationId = `suite-recovery:${String(session.id)}:${snapshotRevision}`;
+ const expectedEntityRevision = normalizeRecoveryEntityRevision(session._lastDurableRecoveryRevision);
+ const save = async () => {
+ if (!guardAllowsCommit() || !this._ownsSuiteRecoveryClaim('single', session)) {
+ return { committed: false, code: 'COMMIT_GUARD_REJECTED' };
+ }
+ const receipt = await recovery.saveActiveSession(snapshot, {
+ operationId,
+ expectedEntityRevision,
+ exclusiveGroup: 'suite-practice',
+ ...(commitGuard ? { commitGuard } : {})
+ });
+ if (!receipt || receipt.committed !== true) {
+ const notCommitted = new Error('Suite recovery commit was not confirmed');
+ notCommitted.code = receipt && receipt.reason === 'COMMIT_GUARD_REJECTED'
+ ? 'COMMIT_GUARD_REJECTED'
+ : (receipt && receipt.code
+ ? String(receipt.code)
+ : 'RECOVERY_COMMIT_NOT_CONFIRMED');
+ if (notCommitted.code === 'STALE_RECOVERY_WRITE'
+ || notCommitted.code === 'RECOVERY_GROUP_CONFLICT') {
+ session._suiteRecoveryWritesBlocked = true;
+ }
+ throw notCommitted;
+ }
+ return receipt;
+ };
+ try {
+ await save();
+ } catch (error) {
+ if (!this._isSuiteRecoveryQuotaError(error)) throw error;
+ if (typeof recovery.cleanupForRetry === 'function') {
+ try {
+ await recovery.cleanupForRetry({
+ preserve: { activeSession: [String(session.id)] }
+ });
+ } catch (cleanupError) {
+ console.warn('[SuitePractice] 清理过期 v2 recovery 后重试失败:', cleanupError);
+ }
+ }
+ await save();
+ }
+ // A confirmed receipt has already advanced the AppData CAS owner even
+ // when a caller-level launch guard is lost before this continuation
+ // resumes. Adopt that durable revision (and mirror the authoritative
+ // snapshot while the recovery claim is still ours) before reporting the
+ // business operation as stale, so a rollback can retry from the new CAS
+ // base instead of becoming permanently write-blocked.
+ session._lastDurableRecoveryRevision = snapshotRevision;
+ this._mirrorSuiteRecoverySnapshot(snapshot, session);
+ if (typeof options.onDurableReceipt === 'function') {
+ try {
+ options.onDurableReceipt({
+ revision: snapshotRevision,
+ snapshot: this._cloneSuitePlainObject(snapshot)
+ });
+ } catch (_) {
+ // Receipt bookkeeping must not turn a confirmed durable commit
+ // into an application-level persistence failure.
+ }
+ }
+ if (!guardAllowsCommit() || !this._ownsSuiteRecoveryClaim('single', session)) return false;
+ return true;
+ });
+ session._suiteRecoveryCommitTail = commit;
+ try {
+ return await commit;
+ } catch (error) {
+ if (String(error && error.code || '').startsWith('COMMIT_GUARD')) {
+ return false;
+ }
+ console.warn('[SuitePractice] 持久 v2 套题恢复写入失败:', error);
+ if (options.notify !== false) {
+ this._showSuiteRecoveryPersistenceFailure(error, options.reason || 'update');
+ }
+ return false;
+ } finally {
+ if (session._suiteRecoveryCommitTail === commit) {
+ delete session._suiteRecoveryCommitTail;
+ }
+ }
+ },
+
+ _restoreSessionFromStorage(providedSnapshot = null) {
+ const clearInvalidWindowSnapshot = () => {
+ if (providedSnapshot == null) this._clearSessionStorage();
+ };
+ try {
+ const snapshot = providedSnapshot || global.AppData.recovery.windowSession.get('simulation');
+ if (!snapshot || typeof snapshot !== 'object' || !snapshot.id) return null;
+ const recoveryTime = suiteRecoveryTimestamp(snapshot);
+ if (recoveryTime !== null && recoveryTime <= Date.now() - suiteRecoveryTtlMs) {
+ clearInvalidWindowSnapshot();
+ return null;
+ }
+ if (snapshot.schema !== 'suite-session-v2' || Number(snapshot.version) !== 2) {
+ clearInvalidWindowSnapshot();
+ return null;
+ }
+ const statusValue = String(snapshot.status || 'active').trim().toLowerCase();
+ if (!['initializing', 'active', 'finalizing'].includes(statusValue)) {
+ clearInvalidWindowSnapshot();
+ return null;
+ }
+ const rawSequence = Array.isArray(snapshot.sequence)
+ ? snapshot.sequence
+ : (Array.isArray(snapshot.suiteSequence) ? snapshot.suiteSequence : []);
+ const sequence = rawSequence.map((entry) => {
+ if (!entry || typeof entry !== 'object') return null;
+ const exam = entry.exam && typeof entry.exam === 'object' ? entry.exam : entry;
+ const examId = String(entry.examId ?? exam.id ?? '').trim();
+ if (!examId) return null;
+ return {
+ ...this._cloneSuitePlainObject(entry),
+ examId,
+ exam: { ...this._cloneSuitePlainObject(exam), id: exam.id || examId }
+ };
+ }).filter(Boolean);
+ const sequenceIds = sequence.map((entry) => String(entry.examId));
+ const flowMode = String(snapshot.flowMode || 'simulation').trim().toLowerCase();
+ const timerMode = String(snapshot.suiteTimerMode || 'countdown').trim().toLowerCase();
+ const timerLimit = Number(snapshot.suiteTimerLimitSeconds);
+ if (
+ !sequence.length
+ || new Set(sequenceIds).size !== sequenceIds.length
+ || !['classic', 'simulation', 'stationary'].includes(flowMode)
+ || !['countdown', 'elapsed'].includes(timerMode)
+ || !Number.isFinite(timerLimit)
+ || timerLimit <= 0
+ ) {
+ clearInvalidWindowSnapshot();
+ return null;
+ }
+ const rawIndex = Number(snapshot.currentIndex);
+ if (!Number.isInteger(rawIndex) || rawIndex < 0 || rawIndex > sequence.length) {
+ clearInvalidWindowSnapshot();
+ return null;
+ }
+ const results = Array.isArray(snapshot.results)
+ ? this._cloneSuitePlainObject(snapshot.results)
+ : [];
+ if (results.some((entry) => !this._isValidSuiteRecoveryResult(entry, sequenceIds))) {
+ clearInvalidWindowSnapshot();
+ return null;
+ }
+ const expectedOperationId = `practice-suite:${String(snapshot.id)}:finalize`;
+ if (snapshot.finalizeOperationId && snapshot.finalizeOperationId !== expectedOperationId) {
+ clearInvalidWindowSnapshot();
+ return null;
+ }
+ if (snapshot.finalizeRecord) {
+ const finalizeRecord = snapshot.finalizeRecord;
+ if (!this._isValidSuiteFinalizeRecord({
+ id: snapshot.id,
+ sequence,
+ results
+ }, finalizeRecord)) {
+ clearInvalidWindowSnapshot();
+ return null;
+ }
+ }
+ const autoAdvance = snapshot.autoAdvanceAfterSubmit !== false;
+ const activeId = snapshot.activeExamId != null ? String(snapshot.activeExamId).trim() : '';
+ const activeIndex = activeId
+ ? sequence.findIndex((entry) => String(entry.examId) === activeId)
+ : -1;
+ const resultIds = results
+ .map((entry) => entry && String(entry.examId || '').trim())
+ .filter(Boolean);
+ const terminalSnapshot = statusValue === 'finalizing' || rawIndex === sequence.length;
+ if (
+ new Set(resultIds).size !== resultIds.length
+ || resultIds.some((examId) => !sequenceIds.includes(examId))
+ || (terminalSnapshot && (
+ resultIds.length !== sequenceIds.length
+ || sequenceIds.some((examId) => !resultIds.includes(examId))
+ ))
+ || (statusValue !== 'finalizing' && activeId && activeIndex < 0)
+ || (terminalSnapshot && rawIndex !== sequence.length)
+ ) {
+ clearInvalidWindowSnapshot();
+ return null;
+ }
+ if (
+ statusValue !== 'finalizing'
+ && activeIndex >= 0
+ && activeIndex !== rawIndex
+ && !(autoAdvance && rawIndex === activeIndex + 1 && results.some((entry) => entry && String(entry.examId) === activeId))
+ ) {
+ clearInvalidWindowSnapshot();
+ return null;
+ }
+ let currentIndex = rawIndex;
+ let status = statusValue;
+ if (status === 'finalizing' || currentIndex === sequence.length) {
+ status = 'finalizing';
+ currentIndex = sequence.length;
+ } else if (
+ autoAdvance
+ && activeIndex >= 0
+ && currentIndex === activeIndex
+ && results.some((entry) => entry && String(entry.examId) === activeId)
+ && currentIndex + 1 < sequence.length
+ ) {
+ currentIndex += 1;
+ }
+ const activeExamId = status === 'finalizing'
+ ? null
+ : (sequence[currentIndex] && sequence[currentIndex].examId) || activeId || sequence[0].examId;
+ const now = Date.now();
+ return {
+ id: String(snapshot.id),
+ status,
+ startTime: Number(snapshot.startTime) || now,
+ sequence,
+ currentIndex,
+ results,
+ draftsByExam: snapshot.draftsByExam && typeof snapshot.draftsByExam === 'object'
+ ? this._cloneSuitePlainObject(snapshot.draftsByExam)
+ : {},
+ elapsedByExam: snapshot.elapsedByExam && typeof snapshot.elapsedByExam === 'object'
+ ? this._cloneSuitePlainObject(snapshot.elapsedByExam)
+ : {},
+ globalTimerAnchorMs: Number(snapshot.globalTimerAnchorMs) || Number(snapshot.startTime) || now,
+ suiteTimerAnchorMs: Number(snapshot.suiteTimerAnchorMs) || Number(snapshot.globalTimerAnchorMs) || Number(snapshot.startTime) || now,
+ suiteTimerMode: timerMode,
+ suiteTimerLimitSeconds: timerLimit,
+ suiteTimerPausedOffsetMs: Math.max(0, Number(snapshot.suiteTimerPausedOffsetMs) || 0),
+ suiteTimerPausedAtMs: Number.isFinite(Number(snapshot.suiteTimerPausedAtMs)) ? Number(snapshot.suiteTimerPausedAtMs) : null,
+ suiteTimerRunning: snapshot.suiteTimerRunning !== false,
+ flowMode,
+ frequencyScope: snapshot.frequencyScope || 'all',
+ autoAdvanceAfterSubmit: autoAdvance,
+ pendingAdvance: snapshot.pendingAdvance && typeof snapshot.pendingAdvance === 'object'
+ ? this._cloneSuitePlainObject(snapshot.pendingAdvance)
+ : null,
+ activeExamId,
+ windowRef: null,
+ windowBinding: snapshot.windowBinding && typeof snapshot.windowBinding === 'object'
+ ? this._cloneSuitePlainObject(snapshot.windowBinding)
+ : null,
+ windowName: this._resolveSuiteWindowName(snapshot.id, snapshot.windowName),
+ lastUpdate: Number.isFinite(Number(snapshot.lastUpdate)) ? Number(snapshot.lastUpdate) : now,
+ revision: normalizeRecoveryEntityRevision(snapshot.revision),
+ draftRevision: Math.max(0, Number(snapshot.draftRevision) || 0),
+ finalizeOperationId: snapshot.finalizeOperationId || (snapshot.finalizeRecord ? expectedOperationId : null),
+ finalizeRecord: snapshot.finalizeRecord && typeof snapshot.finalizeRecord === 'object'
+ ? this._cloneSuitePlainObject(snapshot.finalizeRecord)
+ : null,
+ _suiteGeneration: Math.max(0, Number(snapshot.generation) || 0),
+ _restoredFromStorage: true,
+ _suiteRecoveryTimestampKnown: recoveryTime !== null,
+ _suiteRecoveryLeaseContended: snapshot.recoveryLeaseContended === true
+ };
+ } catch (error) {
+ console.warn('[SuitePractice] 套题恢复快照读取失败:', error);
+ clearInvalidWindowSnapshot();
+ return null;
+ }
},
- _clearSessionStorage() {
+ _clearSessionStorage(session = null) {
try {
- if (global.sessionStorage) {
- global.sessionStorage.removeItem('ielts_sim_session');
+ if (session && global.AppData?.recovery?.windowSession
+ && typeof global.AppData.recovery.windowSession.get === 'function') {
+ const snapshot = global.AppData.recovery.windowSession.get('simulation');
+ if (snapshot && typeof snapshot === 'object' && snapshot.id
+ && String(snapshot.id) !== String(session.id)) {
+ return false;
+ }
+ const snapshotGeneration = Number(snapshot && snapshot.generation);
+ const sessionGeneration = Number(session._suiteGeneration);
+ if (Number.isFinite(snapshotGeneration) && snapshotGeneration > 0
+ && Number.isFinite(sessionGeneration) && sessionGeneration > 0
+ && snapshotGeneration !== sessionGeneration) {
+ return false;
+ }
}
+ global.AppData.recovery.windowSession.discard('simulation');
+ return true;
} catch (_) { /* ignore */ }
+ return false;
+ },
+
+ async _discardPersistentSuiteRecovery(session) {
+ if (!session || !session.id) return false;
+ if (!this._ownsSuiteRecoveryClaim('single', session)
+ && !await this._acquireSuiteRecoveryClaim('single', session)) return false;
+ const recovery = global.AppData && global.AppData.recovery;
+ if (!recovery || typeof recovery.discardActiveSession !== 'function') return false;
+ try {
+ const receipt = await recovery.discardActiveSession(String(session.id), {
+ expectedEntityRevision: normalizeRecoveryEntityRevision(session._lastDurableRecoveryRevision)
+ });
+ if (receipt && receipt.committed === true) return true;
+ const notCommitted = new Error('Suite recovery discard was not confirmed');
+ notCommitted.code = receipt && receipt.code
+ ? String(receipt.code)
+ : 'RECOVERY_DISCARD_NOT_CONFIRMED';
+ throw notCommitted;
+ } catch (error) {
+ console.warn('[SuitePractice] 无法清除持久 v2 套题恢复实体:', error);
+ this._showSuiteRecoveryPersistenceFailure(error, 'discard');
+ return false;
+ }
+ },
+
+ async _discardStoredSuiteSession(session) {
+ if (!session) return false;
+ if (this.currentSuiteSession && !this._isSuiteSessionCurrentOwner(session)) {
+ return false;
+ }
+ const writesWereBlocked = session._suiteRecoveryWritesBlocked === true;
+ session._suiteTeardownInProgress = true;
+ await this._freezeSuiteRecoveryWrites(session);
+ if (!await this._discardPersistentSuiteRecovery(session)) {
+ session._suiteRecoveryWritesBlocked = writesWereBlocked;
+ session._suiteTeardownInProgress = false;
+ return false;
+ }
+ if (this.suiteExamMap && Array.isArray(session.sequence)) {
+ session.sequence.forEach((entry) => {
+ if (entry && entry.examId != null
+ && this.suiteExamMap.get(String(entry.examId)) === session.id) {
+ this.suiteExamMap.delete(String(entry.examId));
+ }
+ });
+ }
+ if (this.currentSuiteSession === session) this.currentSuiteSession = null;
+ this._clearSessionStorage(session);
+ session._suiteTeardownInProgress = false;
+ await this._releaseSuiteRecoveryClaim('single', session);
+ return true;
+ },
+
+ _notifySuiteResumeAvailable(session) {
+ if (!session || this._suiteResumeNoticeShown) return;
+ this._suiteResumeNoticeShown = true;
+ const activeEntry = Array.isArray(session.sequence)
+ ? session.sequence.find((entry) => entry && String(entry.examId) === String(session.activeExamId))
+ : null;
+ const title = activeEntry && activeEntry.exam && activeEntry.exam.title
+ ? activeEntry.exam.title
+ : (session.activeExamId || '当前篇章');
+ window.showMessage && window.showMessage(`检测到未完成套题:${title}。再次点击“套题模式”可选择继续或放弃并新建。`, 'info');
},
_syncSuiteTimerFromPayload(session, data = {}) {
@@ -1217,18 +4777,31 @@
const existingOffsetMs = Math.max(0, Number(session.suiteTimerPausedOffsetMs) || 0);
session.suiteTimerPausedOffsetMs = Math.max(existingOffsetMs, Math.max(0, pausedOffsetMs));
}
- const running = timerSnapshot ? timerSnapshot.running : data.suiteTimerRunning;
- session.suiteTimerRunning = running !== false;
- const pausedAtMs = Number(
- (timerSnapshot && timerSnapshot.pausedAtMs)
- ?? data.suiteTimerPausedAtMs
- ?? data.pausedAtMs
+ const hasExplicitRunning = Boolean(
+ (timerSnapshot && Object.prototype.hasOwnProperty.call(timerSnapshot, 'running'))
+ || Object.prototype.hasOwnProperty.call(data, 'suiteTimerRunning')
+ );
+ const hasExplicitPausedAt = Boolean(
+ (timerSnapshot && Object.prototype.hasOwnProperty.call(timerSnapshot, 'pausedAtMs'))
+ || Object.prototype.hasOwnProperty.call(data, 'suiteTimerPausedAtMs')
+ || Object.prototype.hasOwnProperty.call(data, 'pausedAtMs')
);
- session.suiteTimerPausedAtMs = (
- session.suiteTimerRunning === false
- && Number.isFinite(pausedAtMs)
- && pausedAtMs > 0
- ) ? Math.floor(pausedAtMs) : null;
+ if (hasExplicitRunning || hasExplicitPausedAt) {
+ const pausedAtMs = Number(
+ (timerSnapshot && timerSnapshot.pausedAtMs)
+ ?? data.suiteTimerPausedAtMs
+ ?? data.pausedAtMs
+ );
+ const running = hasExplicitRunning
+ ? (timerSnapshot ? timerSnapshot.running : data.suiteTimerRunning)
+ : !(Number.isFinite(pausedAtMs) && pausedAtMs > 0);
+ session.suiteTimerRunning = running !== false;
+ session.suiteTimerPausedAtMs = (
+ session.suiteTimerRunning === false
+ && Number.isFinite(pausedAtMs)
+ && pausedAtMs > 0
+ ) ? Math.floor(pausedAtMs) : null;
+ }
},
_computeSuiteElapsedSeconds(session, referenceNow = Date.now()) {
@@ -1300,13 +4873,14 @@
const pausedAtMs = Number.isFinite(Number(session.suiteTimerPausedAtMs)) ? Number(session.suiteTimerPausedAtMs) : null;
const suiteTimerRunning = session.suiteTimerRunning !== false;
const payload = {
- type: 'SIMULATION_CONTEXT',
- data: {
suiteSessionId: session.id,
flowMode: session.flowMode || 'simulation',
examId,
sessionId: windowInfo && windowInfo.expectedSessionId ? windowInfo.expectedSessionId : null,
windowSessionToken: windowInfo && windowInfo.windowSessionToken ? windowInfo.windowSessionToken : null,
+ windowSessionGeneration: windowInfo && Number.isInteger(windowInfo.sessionGeneration)
+ ? windowInfo.sessionGeneration
+ : 0,
messageIssuedAtMs,
suiteSequence: this._buildSuiteSequencePayload(session),
currentIndex: idx,
@@ -1315,6 +4889,7 @@
canPrev: idx > 0,
canNext: idx < session.sequence.length - 1,
draft,
+ draftsByExam: this._cloneSuitePlainObject(session.draftsByExam || {}),
elapsed,
globalTimerAnchorMs: timerAnchorMs,
suiteTimerAnchorMs: timerAnchorMs,
@@ -1330,10 +4905,9 @@
pausedAtMs,
running: suiteTimerRunning
}
- }
};
try {
- targetWindow.postMessage(payload, '*');
+ this._postExamMessage(examId, targetWindow, 'SIMULATION_CONTEXT', payload);
return true;
} catch (e) {
console.warn('[SuitePractice] 发送模拟上下文失败:', e);
@@ -1341,26 +4915,71 @@
}
},
- async _handleSimulationNavigate(examId, data, sourceWindow) {
+ async _handleSimulationNavigate(examId, data, sourceWindow, options = {}) {
const session = this.currentSuiteSession;
if (!session || session.status !== 'active') return false;
if (session.flowMode !== 'simulation') return false;
- if (session.simulationNavigateLocked === true) return false;
+ const sourceRegistrationStillOwned = () => this._isSuiteCallerRegistrationCurrent(
+ examId,
+ sourceWindow,
+ options
+ );
+ if (!sourceRegistrationStillOwned()) return false;
+ if (session.simulationNavigateLocked === true) {
+ const inFlight = this._simulationNavigateInFlight;
+ if (!inFlight || typeof inFlight.then !== 'function') return false;
+ try {
+ await inFlight;
+ } catch (_) {
+ // The queued request still gets its own validation and error path.
+ }
+ return this._handleSimulationNavigate(examId, data, sourceWindow, options);
+ }
const normalizedExamId = examId != null ? String(examId).trim() : '';
const activeExamId = session.activeExamId != null ? String(session.activeExamId).trim() : '';
if (!normalizedExamId) return false;
const currentIdx = session.sequence.findIndex(e => e && e.examId === normalizedExamId);
if (currentIdx < 0) return false;
+ const direction = String(data && data.direction || '').toLowerCase();
+ if (direction !== 'next' && direction !== 'prev' && direction !== 'previous') return false;
+ const targetIdx = direction === 'next' ? currentIdx + 1 : currentIdx - 1;
+ if (targetIdx < 0 || targetIdx >= session.sequence.length) return false;
+ const targetEntry = session.sequence[targetIdx];
+ if (!targetEntry || !targetEntry.examId) return false;
// Self-heal when activeExamId drifts but the index still points to the current page.
+ let shouldSelfHealActiveExamId = false;
if (activeExamId && normalizedExamId !== activeExamId) {
const allowSelfHeal = Number.isInteger(session.currentIndex) && session.currentIndex === currentIdx;
if (!allowSelfHeal) {
return false;
}
- session.activeExamId = normalizedExamId;
+ shouldSelfHealActiveExamId = true;
}
+ let releaseNavigation;
+ const navigationInFlight = new Promise((resolve) => {
+ releaseNavigation = resolve;
+ });
+ this._simulationNavigateInFlight = navigationInFlight;
session.simulationNavigateLocked = true;
+ const launchWindowName = session.windowName || 'ielts-suite-mode-tab';
+ const initialSourceWindow = sourceWindow && !sourceWindow.closed ? sourceWindow : null;
+ // Reserve the target exam/name synchronously, but do not claim the current
+ // WindowProxy until the durable navigation commit succeeds; claiming it here
+ // would invalidate the message handler that still owes the completion ACK.
+ const launchOwnership = this._beginSuiteExamLaunchOwnership(targetEntry.examId, {
+ windowName: launchWindowName
+ });
try {
+ await this._ensureSuiteRecoveryReady();
+ if (this.currentSuiteSession !== session
+ || !await this._ensureSuiteRecoveryClaim('single', session)
+ || !this._ownsSuiteRecoveryClaim('single', session)
+ || session.status !== 'active'
+ || !sourceRegistrationStillOwned()
+ || !this._isSuiteExamLaunchOwnershipCurrent(targetEntry.examId, launchOwnership)) {
+ return false;
+ }
+ if (shouldSelfHealActiveExamId) session.activeExamId = normalizedExamId;
this._persistSuiteDraftSnapshot(session, normalizedExamId, data);
if (data && typeof data.elapsed === 'number') {
@@ -1390,33 +5009,85 @@
this._upsertSuiteResult(session, normalizedExamId, normalizedSnapshot);
}
- const direction = String(data && data.direction || '').toLowerCase();
- if (direction !== 'next' && direction !== 'prev' && direction !== 'previous') return false;
- const targetIdx = direction === 'next' ? currentIdx + 1 : currentIdx - 1;
- if (targetIdx < 0 || targetIdx >= session.sequence.length) return false;
-
- const targetEntry = session.sequence[targetIdx];
- if (!targetEntry || !targetEntry.examId) return false;
-
+ const previousIndex = session.currentIndex;
+ const previousActiveExamId = session.activeExamId;
+ let simulationDurableReceiptConfirmed = false;
+ let targetRegistration = null;
+ const navigationRegistrationStillOwned = (targetWindow = null) => targetRegistration
+ ? ((!targetWindow || targetRegistration.window === targetWindow)
+ && !targetRegistration.window.closed
+ && this._isSuiteNavigationRegistrationCurrent(
+ targetEntry.examId,
+ targetRegistration,
+ session
+ ))
+ : sourceRegistrationStillOwned();
session.currentIndex = targetIdx;
session.activeExamId = targetEntry.examId;
+ const simulationLaunchStillOwned = (targetWindow = null) => (
+ this.currentSuiteSession === session
+ && session.status === 'active'
+ && session.currentIndex === targetIdx
+ && String(session.activeExamId || '') === String(targetEntry.examId)
+ && navigationRegistrationStillOwned(targetWindow)
+ && (targetRegistration
+ ? true
+ : this._isSuiteExamLaunchOwnershipCurrent(
+ targetEntry.examId,
+ launchOwnership,
+ targetWindow
+ ))
+ );
+ const recoveryCommitted = await this._commitSuiteRecovery(session, {
+ reason: 'simulation-navigate',
+ commitGuard: simulationLaunchStillOwned,
+ onDurableReceipt: () => { simulationDurableReceiptConfirmed = true; }
+ });
+ if (!recoveryCommitted) {
+ if (!simulationDurableReceiptConfirmed
+ && this.currentSuiteSession === session
+ && session.currentIndex === targetIdx
+ && String(session.activeExamId || '') === String(targetEntry.examId)) {
+ session.currentIndex = previousIndex;
+ session.activeExamId = previousActiveExamId;
+ }
+ return false;
+ }
+ if (!simulationLaunchStillOwned()) return false;
+ if (initialSourceWindow
+ && (!this._claimSuiteExamLaunchWindow(launchOwnership, initialSourceWindow)
+ || !simulationLaunchStillOwned(initialSourceWindow))) {
+ return false;
+ }
- const targetWindow = await this.openExam(targetEntry.examId, {
+ const openOptions = {
+ examDefinition: targetEntry.exam,
target: 'tab',
- windowName: session.windowName || 'ielts-suite-mode-tab',
+ windowName: launchWindowName,
suiteSessionId: session.id,
suiteFlowMode: session.flowMode || 'simulation',
suiteTimerMode: session.suiteTimerMode || 'countdown',
suiteTimerLimitSeconds: Number.isFinite(Number(session.suiteTimerLimitSeconds)) ? Number(session.suiteTimerLimitSeconds) : 3600,
sequenceIndex: targetIdx,
sequenceTotal: session.sequence.length,
- reuseWindow: sourceWindow && !sourceWindow.closed ? sourceWindow : undefined
- });
+ reuseWindow: initialSourceWindow || undefined
+ };
+ if (!initialSourceWindow) delete openOptions.reuseWindow;
+ if (launchOwnership) openOptions.launchOwnership = launchOwnership;
+ const targetWindow = await this.openExam(targetEntry.examId, openOptions);
+ if (targetWindow && !targetWindow.closed) {
+ targetRegistration = this._captureSuiteNavigationRegistration(
+ targetEntry.examId,
+ targetWindow,
+ session,
+ launchOwnership
+ );
+ if (!targetRegistration) return false;
+ }
- if (!targetWindow || targetWindow.closed) return false;
+ if (!targetWindow || targetWindow.closed || !simulationLaunchStillOwned(targetWindow)) return false;
session.windowRef = targetWindow;
- this._mirrorSessionToStorage(session);
const reusedSourceWindow = Boolean(
sourceWindow
&& !sourceWindow.closed
@@ -1425,6 +5096,7 @@
);
if (reusedSourceWindow) {
const ready = await this._waitForSuiteWindowExamReady(session, targetEntry.examId, targetWindow);
+ if (!simulationLaunchStillOwned(targetWindow)) return false;
if (!ready) {
if (!this._canFallbackSendSuiteContext(targetEntry.examId, targetWindow)) {
console.warn('[SuitePractice] 模拟模式切题等待 ready 超时,延后上下文下发,等待 SESSION_READY 兜底');
@@ -1434,13 +5106,19 @@
console.warn('[SuitePractice] 模拟模式切题未收到 fresh ready,但窗口已切到目标篇,继续下发上下文');
}
}
+ if (!simulationLaunchStillOwned(targetWindow)) return false;
session._contextSentExamId = targetEntry.examId;
session._contextSentAt = Date.now();
this._sendSimulationContext(session, targetEntry.examId, targetWindow);
+ if (!simulationLaunchStillOwned(targetWindow)) return false;
this._focusSuiteWindow(targetWindow);
return true;
} finally {
session.simulationNavigateLocked = false;
+ if (this._simulationNavigateInFlight === navigationInFlight) {
+ this._simulationNavigateInFlight = null;
+ }
+ releaseNavigation();
}
},
@@ -1461,7 +5139,7 @@
if (!session || (session.status !== 'active' && session.status !== 'initializing') || !examId) {
return false;
}
- if (session.flowMode !== 'simulation') {
+ if (session.flowMode !== 'simulation' && session.flowMode !== 'stationary') {
return false;
}
if (!Array.isArray(session.sequence) || !session.sequence.length) {
@@ -1483,7 +5161,7 @@
const pageType = windowInfo && typeof windowInfo.pageType === 'string'
? windowInfo.pageType.toLowerCase()
: '';
- if (pageType && !pageType.includes('unified-reading') && !pageType.includes('suite-placeholder')) {
+ if (pageType && !/unified-reading|suite-placeholder|^p[1-4]$|^practice$/i.test(pageType)) {
return false;
}
let targetWindow = session.windowRef && !session.windowRef.closed ? session.windowRef : null;
@@ -1527,46 +5205,573 @@
session.currentIndex = idx;
session.activeExamId = examId;
session.windowRef = targetWindow;
- this._mirrorSessionToStorage(session);
- if (session._contextSentExamId === examId
+ if (session.flowMode === 'simulation' && session._contextSentExamId === examId
&& Number.isFinite(Number(session._contextSentAt))
&& Date.now() - session._contextSentAt < 3000) {
return true;
}
+ if (session.flowMode === 'stationary') {
+ return this._sendSuiteReviewState(session, examId, targetWindow);
+ }
return this._sendSimulationContext(session, examId, targetWindow);
},
+ _serializeMultiSuiteSession(session) {
+ return {
+ id: String(session.id),
+ baseExamId: String(session.baseExamId),
+ status: session.status || 'active',
+ startTime: Number(session.startTime) || Date.now(),
+ suiteResults: (Array.isArray(session.suiteResults) ? session.suiteResults : []).map((result) => ({
+ suiteId: String(result.suiteId),
+ examId: String(result.examId),
+ answers: this._cloneSuitePlainObject(result.answers || {}),
+ correctAnswers: this._cloneSuitePlainObject(result.correctAnswers || {}),
+ answerComparison: this._cloneSuitePlainObject(result.answerComparison || {}),
+ scoreInfo: this._cloneSuitePlainObject(result.scoreInfo || {}),
+ spellingErrors: this._cloneSuitePlainObject(Array.isArray(result.spellingErrors) ? result.spellingErrors : []),
+ timestamp: Number(result.timestamp) || 0,
+ duration: Number(result.duration) || 0,
+ metadata: this._cloneSuitePlainObject(result.metadata || {}),
+ rawData: this._cloneSuitePlainObject(result.rawData || null)
+ })),
+ expectedSuiteCount: session.expectedSuiteCount == null
+ ? null
+ : Number(session.expectedSuiteCount),
+ metadata: this._cloneSuitePlainObject(session.metadata || {}),
+ lastUpdate: Number(session.lastUpdate) || Date.now(),
+ revision: normalizeRecoveryEntityRevision(session.revision),
+ finalizeOperationId: session.finalizeOperationId || null,
+ finalizeRecord: session.finalizeRecord
+ ? this._cloneSuitePlainObject(session.finalizeRecord)
+ : null
+ };
+ },
+
+ _mirrorMultiSuiteSessionsToStorage() {
+ const windowSession = global.AppData?.recovery?.windowSession;
+ if (!windowSession || typeof windowSession.save !== 'function') return false;
+ try {
+ const sessions = Array.from(this.multiSuiteSessionsMap instanceof Map
+ ? this.multiSuiteSessionsMap.values()
+ : [])
+ .filter((session) => Boolean(session)
+ && (isFileProtocol || this._ownsMultiSuiteRecoveryOwnership(session)))
+ .sort((left, right) => String(left.baseExamId || '').localeCompare(String(right.baseExamId || '')))
+ .map((session) => this._serializeMultiSuiteSession(session));
+ if (!sessions.length) {
+ return typeof windowSession.discard === 'function'
+ ? windowSession.discard(multiSuiteRecoveryName) !== false
+ : false;
+ }
+ return windowSession.save(multiSuiteRecoveryName, {
+ schema: multiSuiteRecoverySchema,
+ version: 2,
+ sessions,
+ updatedAt: Date.now()
+ }) !== false;
+ } catch (error) {
+ console.warn('[MultiSuite] 多套题恢复快照写入失败:', error);
+ return false;
+ }
+ },
+
+ async _commitMultiSuiteRecovery(session) {
+ const recovery = global.AppData && global.AppData.recovery;
+ if (!session || !session.id || session._suiteRecoveryWritesBlocked === true
+ || !recovery || typeof recovery.saveActiveSession !== 'function') {
+ return false;
+ }
+ if (!this._ownsMultiSuiteRecoveryOwnership(session)
+ && !await this._acquireMultiSuiteRecoveryOwnership(session)) {
+ return false;
+ }
+ const revision = normalizeRecoveryEntityRevision(session.revision);
+ const snapshot = {
+ schema: multiSuiteRecoverySchema,
+ version: 2,
+ id: String(session.id),
+ revision,
+ sessions: [this._serializeMultiSuiteSession(session)],
+ updatedAt: Date.now()
+ };
+ try {
+ if (!this._ownsMultiSuiteRecoveryOwnership(session)) return false;
+ const receipt = await recovery.saveActiveSession(snapshot, {
+ operationId: `multi-suite-recovery:${String(session.id)}:${revision}`,
+ expectedEntityRevision: normalizeRecoveryEntityRevision(session._lastDurableRecoveryRevision),
+ commitGuard: () => this._ownsMultiSuiteRecoveryOwnership(session)
+ });
+ if (!receipt || receipt.committed !== true) {
+ const error = new Error('Multi-suite recovery commit was not confirmed');
+ error.code = receipt && receipt.code ? String(receipt.code) : 'RECOVERY_COMMIT_NOT_CONFIRMED';
+ if (error.code === 'STALE_RECOVERY_WRITE' || error.code === 'RECOVERY_GROUP_CONFLICT') {
+ session._suiteRecoveryWritesBlocked = true;
+ }
+ throw error;
+ }
+ session._lastDurableRecoveryRevision = revision;
+ if (!this._ownsMultiSuiteRecoveryOwnership(session)) return false;
+ this._mirrorMultiSuiteSessionsToStorage();
+ return true;
+ } catch (error) {
+ console.warn('[MultiSuite] 持久 v2 恢复写入失败:', error);
+ this._showSuiteRecoveryPersistenceFailure(error, 'multi-suite');
+ return false;
+ }
+ },
+
+ _restoreMultiSuiteSessionsFromStorage(options = {}) {
+ const windowSession = global.AppData?.recovery?.windowSession;
+ if (!windowSession || typeof windowSession.get !== 'function') {
+ return options.install === false ? [] : false;
+ }
+ try {
+ const snapshot = windowSession.get(multiSuiteRecoveryName);
+ if (!snapshot) return options.install === false ? [] : false;
+ if (!this._isValidMultiSuiteRecoverySnapshot(snapshot)) {
+ if (typeof windowSession.discard === 'function') windowSession.discard(multiSuiteRecoveryName);
+ return options.install === false ? [] : false;
+ }
+ const snapshotTime = suiteRecoveryTimestamp(snapshot);
+ const cutoff = Date.now() - suiteRecoveryTtlMs;
+ const retainedStoredSessions = snapshot.sessions.filter((storedSession) => {
+ const sessionTime = suiteRecoveryTimestamp(storedSession);
+ const recoveryTime = sessionTime === null ? snapshotTime : sessionTime;
+ return recoveryTime === null || recoveryTime > cutoff;
+ });
+ if (retainedStoredSessions.length !== snapshot.sessions.length) {
+ if (!retainedStoredSessions.length) {
+ if (typeof windowSession.discard === 'function') {
+ windowSession.discard(multiSuiteRecoveryName);
+ }
+ return options.install === false ? [] : false;
+ }
+ if (typeof windowSession.save === 'function') {
+ windowSession.save(multiSuiteRecoveryName, {
+ ...snapshot,
+ sessions: retainedStoredSessions,
+ updatedAt: Date.now()
+ });
+ }
+ }
+ const restoredSessions = retainedStoredSessions.map((storedSession) => {
+ const session = this._cloneSuitePlainObject(storedSession);
+ const sessionTime = suiteRecoveryTimestamp(storedSession);
+ const recoveryTime = sessionTime === null ? snapshotTime : sessionTime;
+ session.baseExamId = String(session.baseExamId || '').trim();
+ session.revision = normalizeRecoveryEntityRevision(session.revision);
+ session._restoredFromWindowSession = true;
+ session._suiteRecoveryTimestampKnown = recoveryTime !== null;
+ session._suiteRecoveryLeaseContended = storedSession.recoveryLeaseContended === true;
+ if (options.install !== false) {
+ this.multiSuiteSessionsMap.set(session.baseExamId, session);
+ }
+ return session;
+ });
+ return options.install === false ? restoredSessions : true;
+ } catch (error) {
+ console.warn('[MultiSuite] 多套题恢复快照读取失败:', error);
+ try {
+ if (typeof windowSession.discard === 'function') windowSession.discard(multiSuiteRecoveryName);
+ } catch (_) {}
+ return options.install === false ? [] : false;
+ }
+ },
+
+ _isValidMultiSuiteRecoverySnapshot(snapshot) {
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)
+ || snapshot.schema !== multiSuiteRecoverySchema
+ || Number(snapshot.version) !== 2
+ || !Array.isArray(snapshot.sessions)) {
+ return false;
+ }
+ const sessionIds = new Set();
+ const baseExamIds = new Set();
+ return snapshot.sessions.every((session) => {
+ if (!session || typeof session !== 'object' || Array.isArray(session)) return false;
+ const id = String(session.id || '').trim();
+ const baseExamId = String(session.baseExamId || '').trim();
+ const status = String(session.status || '').trim().toLowerCase();
+ const expectedCount = session.expectedSuiteCount == null ? null : Number(session.expectedSuiteCount);
+ if (!id || !baseExamId || sessionIds.has(id) || baseExamIds.has(baseExamId)
+ || !['active', 'finalizing', 'completed'].includes(status)
+ || !Number.isFinite(Number(session.startTime))
+ || !Array.isArray(session.suiteResults)
+ || (expectedCount != null && (!Number.isInteger(expectedCount) || expectedCount <= 0))) {
+ return false;
+ }
+ const suiteIds = new Set();
+ if (!session.suiteResults.every((result) => {
+ const suiteId = String(result && result.suiteId || '').trim();
+ if (suiteIds.has(suiteId)) return false;
+ suiteIds.add(suiteId);
+ return this._isValidMultiSuiteRecoveryResult(result);
+ })) return false;
+ const operationId = `practice-multisuite:${id}:finalize`;
+ if (session.finalizeOperationId && session.finalizeOperationId !== operationId) return false;
+ if (Boolean(session.finalizeOperationId) !== Boolean(session.finalizeRecord)) return false;
+ if (session.finalizeRecord && (!session.finalizeOperationId
+ || !this._isValidMultiSuiteFinalizeRecord(session, session.finalizeRecord))) return false;
+ if (status === 'finalizing' && (!session.finalizeOperationId || !session.finalizeRecord)) return false;
+ if (status === 'completed' && (!expectedCount || session.suiteResults.length < expectedCount
+ || !session.finalizeOperationId || !session.finalizeRecord)) return false;
+ sessionIds.add(id);
+ baseExamIds.add(baseExamId);
+ return true;
+ });
+ },
+
+ _isValidMultiSuiteRecoveryResult(result) {
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return false;
+ const suiteId = String(result.suiteId || '').trim();
+ const examId = String(result.examId || '').trim();
+ const answers = result.answers;
+ const comparison = result.answerComparison;
+ return Boolean(
+ suiteId
+ && examId
+ && this._isValidSuiteScoreInfo(result.scoreInfo)
+ && answers && typeof answers === 'object' && !Array.isArray(answers)
+ && comparison && typeof comparison === 'object' && !Array.isArray(comparison)
+ && (!result.correctAnswers || (typeof result.correctAnswers === 'object' && !Array.isArray(result.correctAnswers)))
+ && (!result.spellingErrors || Array.isArray(result.spellingErrors))
+ && Number.isFinite(Number(result.timestamp)) && Number(result.timestamp) >= 0
+ && Number.isFinite(Number(result.duration)) && Number(result.duration) >= 0
+ && (!result.metadata || (typeof result.metadata === 'object' && !Array.isArray(result.metadata)))
+ && (result.rawData == null || (typeof result.rawData === 'object' && !Array.isArray(result.rawData)))
+ );
+ },
+
+ _isValidMultiSuiteFinalizeRecord(session, record) {
+ if (!session || !record || typeof record !== 'object' || Array.isArray(record)) return false;
+ const results = Array.isArray(session.suiteResults) ? session.suiteResults : [];
+ const expectedScores = this.aggregateScores(results);
+ const expectedAnswers = this.aggregateAnswers(results);
+ const expectedComparison = this.aggregateAnswerComparisons(results);
+ const expectedSpellingErrors = this.aggregateSpellingErrors(results);
+ const expectedDuration = results.reduce((sum, result) => sum + (Number(result.duration) || 0), 0);
+ const operationId = `practice-multisuite:${String(session.id)}:finalize`;
+ const numericMatches = (left, right) => Number.isFinite(Number(left)) && Number(left) === Number(right);
+ const spellingContent = (errors) => (Array.isArray(errors) ? errors : []).map(({ timestamp, ...error }) => error);
+ const entries = Array.isArray(record.suiteEntries) ? record.suiteEntries : [];
+ return Boolean(
+ String(record.id || '') === String(session.id)
+ && String(record.examId || '') === String(session.baseExamId)
+ && record.type === 'listening'
+ && record.multiSuite === true
+ && typeof record.title === 'string'
+ && typeof record.date === 'string'
+ && typeof record.startTime === 'string'
+ && typeof record.endTime === 'string'
+ && numericMatches(record.duration, expectedDuration)
+ && this._isValidSuiteScoreInfo(record.scoreInfo)
+ && numericMatches(record.totalQuestions, expectedScores.total)
+ && numericMatches(record.correctAnswers, expectedScores.correct)
+ && Math.abs(Number(record.accuracy) - Number(expectedScores.accuracy)) < 1e-9
+ && Number(record.percentage) === Number(expectedScores.percentage)
+ && numericMatches(record.scoreInfo.correct, expectedScores.correct)
+ && numericMatches(record.scoreInfo.total, expectedScores.total)
+ && Math.abs(Number(record.scoreInfo.accuracy) - Number(expectedScores.accuracy)) < 1e-9
+ && Number(record.scoreInfo.percentage) === Number(expectedScores.percentage)
+ && this._suiteValuesEqual(record.answers, expectedAnswers)
+ && this._suiteValuesEqual(record.answerComparison, expectedComparison)
+ && Array.isArray(record.spellingErrors)
+ && this._suiteValuesEqual(spellingContent(record.spellingErrors), spellingContent(expectedSpellingErrors))
+ && entries.length === results.length
+ && entries.every((entry, index) => {
+ const result = results[index];
+ return entry && String(entry.suiteId || '') === String(result.suiteId)
+ && String(entry.examId || '') === String(result.examId)
+ && numericMatches(entry.duration, result.duration)
+ && this._suiteValuesEqual(entry.scoreInfo, result.scoreInfo)
+ && this._suiteValuesEqual(entry.answers, result.answers)
+ && this._suiteValuesEqual(entry.answerComparison, result.answerComparison)
+ && this._suiteValuesEqual(entry.spellingErrors, result.spellingErrors || [])
+ && (entry.metadata
+ ? this._suiteValuesEqual(entry.metadata, result.metadata || {})
+ : !result.metadata?.submissionId)
+ && numericMatches(entry.timestamp, result.timestamp)
+ && this._suiteValuesEqual(entry.rawData, result.rawData || null);
+ })
+ && record.metadata && typeof record.metadata === 'object'
+ && String(record.metadata.sessionId || '') === String(session.id)
+ && record.metadata.frequency === 'multi-suite'
+ && (!session.metadata || !session.metadata.source || record.metadata.source === session.metadata.source)
+ && Number(record.metadata.suiteCount) === results.length
+ && Number(record.metadata.expectedSuiteCount) === Number(session.expectedSuiteCount)
+ && record.realData && typeof record.realData === 'object'
+ && record.realData.source === 'multi_suite_mode'
+ && numericMatches(record.realData.correct, expectedScores.correct)
+ && numericMatches(record.realData.total, expectedScores.total)
+ && Math.abs(Number(record.realData.accuracy) - Number(expectedScores.accuracy)) < 1e-9
+ && Number(record.realData.percentage) === Number(expectedScores.percentage)
+ && numericMatches(record.realData.duration, expectedDuration)
+ && Number(record.realData.suiteCount) === results.length
+ && record.operationId === operationId
+ );
+ },
+
/**
* 处理多套题练习完成(用于100 P1/P4等包含多套题的HTML页面)
* @param {string} examId - 考试ID(可能包含套题后缀)
* @param {object} suiteData - 套题数据
* @returns {boolean} 是否成功处理
*/
+ async _refreshPersistentMultiSuiteBase(baseExamId, fallbackSession = null) {
+ const normalizedBaseExamId = String(baseExamId || '').trim();
+ const recovery = global.AppData && global.AppData.recovery;
+ if (!normalizedBaseExamId || !recovery
+ || typeof recovery.listActiveSessions !== 'function') {
+ return { session: null, blocked: true };
+ }
+ const baseOwner = fallbackSession && typeof fallbackSession === 'object'
+ ? fallbackSession
+ : { baseExamId: normalizedBaseExamId };
+ baseOwner.baseExamId = normalizedBaseExamId;
+ if (!await this._acquireMultiSuiteBaseClaim(baseOwner)) {
+ return { session: null, blocked: true };
+ }
+ let claimedSession = null;
+ let restoredSession = null;
+ const releaseOwnership = async () => {
+ for (const session of [restoredSession, claimedSession, baseOwner]) {
+ if (!session) continue;
+ if (this._ownsSuiteRecoveryClaim('multi', session)) {
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ } else if (this._ownsMultiSuiteBaseClaim(session)) {
+ await this._releaseMultiSuiteBaseClaim(session);
+ }
+ }
+ };
+ const selectAuthoritative = (rawItems) => {
+ const firstItemsById = new Map();
+ (Array.isArray(rawItems) ? rawItems : []).forEach((item) => {
+ const id = String(item && (item.id ?? item.sessionId ?? item.recordId) || '');
+ if (id && !firstItemsById.has(id)) firstItemsById.set(id, item);
+ });
+ let authoritative = null;
+ let authoritativeTime = -1;
+ for (const item of firstItemsById.values()) {
+ if (!item
+ || item.schema !== multiSuiteRecoverySchema
+ || Number(item.version) !== 2
+ || !this._isValidMultiSuiteRecoverySnapshot(item)
+ || item.sessions.length !== 1
+ || String(item.id ?? '') !== String(item.sessions[0].id ?? '')
+ || String(item.sessions[0].baseExamId || '').trim() !== normalizedBaseExamId) {
+ continue;
+ }
+ const candidateTime = Number(item.sessions[0].lastUpdate)
+ || Date.parse(item.updatedAt || '') || 0;
+ if (!authoritative || candidateTime > authoritativeTime) {
+ authoritative = item;
+ authoritativeTime = candidateTime;
+ }
+ }
+ return authoritative;
+ };
+ try {
+ if (global.AppData.ready && typeof global.AppData.ready.then === 'function') {
+ await global.AppData.ready;
+ }
+ const initialItems = await recovery.listActiveSessions();
+ const authoritative = selectAuthoritative(initialItems);
+ if (!authoritative) {
+ if (!fallbackSession || !fallbackSession.id
+ || !await this._acquireSuiteRecoveryClaim('multi', fallbackSession)) {
+ await releaseOwnership();
+ return { session: null, blocked: !fallbackSession };
+ }
+ return { session: fallbackSession, blocked: false, created: true };
+ }
+
+ claimedSession = this._cloneSuitePlainObject(authoritative.sessions[0]);
+ if (!await this._acquireSuiteRecoveryClaim('multi', claimedSession)
+ || !this._transferMultiSuiteBaseClaim(baseOwner, claimedSession)) {
+ await releaseOwnership();
+ return { session: null, blocked: true };
+ }
+ claimedSession._restoredFromDurableClaim = true;
+ const refreshedItems = await recovery.listActiveSessions();
+ const refreshedAuthoritative = selectAuthoritative(refreshedItems);
+ if (!refreshedAuthoritative
+ || String(refreshedAuthoritative.id ?? '') !== String(authoritative.id ?? '')) {
+ await releaseOwnership();
+ return { session: null, blocked: true };
+ }
+ await this._restorePersistentMultiSuiteSessions(refreshedItems, [claimedSession]);
+ restoredSession = this.multiSuiteSessionsMap instanceof Map
+ ? this.multiSuiteSessionsMap.get(normalizedBaseExamId)
+ : null;
+ if (restoredSession
+ && String(restoredSession.id ?? '') === String(authoritative.id ?? '')
+ && this._ownsMultiSuiteRecoveryOwnership(restoredSession)) {
+ return { session: restoredSession, blocked: false, created: false };
+ }
+ await releaseOwnership();
+ return { session: null, blocked: true };
+ } catch (error) {
+ console.warn('[MultiSuite] 恢复当前 base 的持久会话失败:', error);
+ await releaseOwnership();
+ return { session: null, blocked: true };
+ }
+ },
+
async handleMultiSuitePracticeComplete(examId, suiteData) {
if (!suiteData || !suiteData.suiteId) {
console.warn('[MultiSuite] 缺少suiteId,无法处理多套题完成');
return false;
}
+ await this._ensureSuiteRecoveryReady();
+ const baseExamId = String(this._extractBaseExamId(examId) || '').trim();
+ if (!baseExamId) return false;
+
+ const previous = this._multiSuiteCompletionTails.get(baseExamId) || Promise.resolve();
+ const task = previous.catch(() => false)
+ .then(() => this._handleMultiSuitePracticeCompleteInternal(examId, suiteData, baseExamId));
+ this._multiSuiteCompletionTails.set(baseExamId, task);
+ try {
+ return await task;
+ } finally {
+ if (this._multiSuiteCompletionTails.get(baseExamId) === task) {
+ this._multiSuiteCompletionTails.delete(baseExamId);
+ }
+ }
+ },
+
+ async _handleMultiSuitePracticeCompleteInternal(examId, suiteData, baseExamId) {
+
console.log('[MultiSuite] 处理套题完成:', examId, '套题ID:', suiteData.suiteId);
- // 获取或创建多套题会话
- const session = this.getOrCreateMultiSuiteSession(examId);
+ const normalizedSuiteId = String(suiteData.suiteId).trim();
+ const childSessionId = String(suiteData.sessionId || '').trim();
+ const submissionId = String(suiteData.submissionId || '').trim();
+ const isSubmissionReplay = (result) => Boolean(
+ childSessionId
+ && submissionId
+ && String(result?.suiteId || '').trim() === normalizedSuiteId
+ && String(result?.metadata?.sessionId || result?.rawData?.sessionId || '').trim() === childSessionId
+ && String(result?.metadata?.submissionId || result?.rawData?.submissionId || '').trim() === submissionId
+ );
+
+ let session = this.multiSuiteSessionsMap.get(baseExamId);
+ let pendingFreshSession = false;
+ if (!session) {
+ const fallbackSession = this.getOrCreateMultiSuiteSession(examId, { install: false });
+ if (!fallbackSession) return false;
+ const refreshed = await this._refreshPersistentMultiSuiteBase(baseExamId, fallbackSession);
+ if (refreshed.blocked || !refreshed.session) return false;
+ session = refreshed.session;
+ if (refreshed.created === true) {
+ // Do not publish a brand-new empty owner until canonical receipt
+ // replay has been checked. A stale child replay must not strand an
+ // empty WAL plus the base and exact leases for the page lifetime.
+ pendingFreshSession = true;
+ }
+ }
+ if (!this._ownsMultiSuiteRecoveryOwnership(session)
+ && !await this._acquireMultiSuiteRecoveryOwnership(session)) return false;
+ const currentSuiteResult = session && session.suiteResults.find(
+ result => String(result?.suiteId || '').trim() === normalizedSuiteId
+ );
+ const replaysCurrentSession = Boolean(currentSuiteResult
+ && ((!childSessionId || !submissionId) || isSubmissionReplay(currentSuiteResult)));
+
+ // v2 聚合记录是 durable submission receipt。恢复实体已清理或新流程已开始时,
+ // 旧窗口的精确重放仍由 canonical 记录识别,不能创建第二条聚合记录。
+ if (!replaysCurrentSession && childSessionId && submissionId) {
+ let records;
+ try {
+ records = await this._listPracticeRecordsViaAPI();
+ } catch (_) {
+ if (pendingFreshSession) await this._releaseSuiteRecoveryClaim('multi', session);
+ return false;
+ }
+ const alreadyCommitted = records.some((record) => record && record.multiSuite === true
+ && String(record.examId || '').trim() === baseExamId
+ && Array.isArray(record.suiteEntries)
+ && record.suiteEntries.some(isSubmissionReplay));
+ if (alreadyCommitted) {
+ if (pendingFreshSession) await this._releaseSuiteRecoveryClaim('multi', session);
+ return true;
+ }
+ }
+ if (pendingFreshSession) {
+ this.multiSuiteSessionsMap.set(session.baseExamId, session);
+ this._mirrorMultiSuiteSessionsToStorage();
+ }
+
+ // baseExamId 只负责定位当前流程,不能把已经完成的流程变成下一次练习的业务身份。
+ // 恢复的 active 会话若结果已齐但尚未聚合(finalize 前崩溃窗口),先幂等收敛,
+ // 避免同 base 新一轮被已记录的同 suiteId 阻塞。
+ if (session.status === 'active' && this.isMultiSuiteComplete(session)) {
+ const converged = await this.finalizeMultiSuiteRecord(session);
+ if (!converged) return false;
+ }
+
+ if (session.status === 'finalizing'
+ && !await this.finalizeMultiSuiteRecord(session)) {
+ return false;
+ }
+
+ if (session.status === 'completed') {
+ if (replaysCurrentSession) {
+ console.warn('[MultiSuite] 已完成套题的原提交重放,跳过:', suiteData.suiteId);
+ return true;
+ }
+ if (this.multiSuiteSessionsMap.get(session.baseExamId)?.id === session.id) {
+ this.multiSuiteSessionsMap.delete(session.baseExamId);
+ }
+ const previousSession = session;
+ const nextSession = this.getOrCreateMultiSuiteSession(examId, { install: false });
+ if (!nextSession) return false;
+ if (this._ownsMultiSuiteBaseClaim(previousSession)) {
+ if (!this._transferMultiSuiteBaseClaim(previousSession, nextSession)) return false;
+ if (this._ownsSuiteRecoveryClaim('multi', previousSession)) {
+ await this._releaseSuiteRecoveryClaim('multi', previousSession);
+ } else {
+ this._terminalizeSuiteRecoverySession(previousSession);
+ }
+ if (!await this._acquireSuiteRecoveryClaim('multi', nextSession)) {
+ await this._releaseMultiSuiteBaseClaim(nextSession);
+ return false;
+ }
+ } else if (!await this._acquireMultiSuiteRecoveryOwnership(nextSession)) {
+ return false;
+ }
+ session = nextSession;
+ this.multiSuiteSessionsMap.set(session.baseExamId, session);
+ this._mirrorMultiSuiteSessionsToStorage();
+ }
// 检查是否已经记录过这个套题
const alreadyRecorded = session.suiteResults.some(
- result => result.suiteId === suiteData.suiteId
+ result => String(result.suiteId) === normalizedSuiteId
);
if (alreadyRecorded) {
+ if (childSessionId && submissionId && !replaysCurrentSession) {
+ console.warn('[MultiSuite] 同一套题收到不同提交,拒绝误 ACK:', suiteData.suiteId);
+ return false;
+ }
console.warn('[MultiSuite] 套题已记录,跳过:', suiteData.suiteId);
+ if (normalizeRecoveryEntityRevision(session.revision)
+ > normalizeRecoveryEntityRevision(session._lastDurableRecoveryRevision)
+ && !await this._commitMultiSuiteRecovery(session)) {
+ return false;
+ }
+ if (session.status !== 'completed' && this.isMultiSuiteComplete(session)) {
+ return await this.finalizeMultiSuiteRecord(session);
+ }
return true;
}
// 添加套题结果到会话
const suiteResult = {
- suiteId: suiteData.suiteId,
- examId: examId,
+ suiteId: normalizedSuiteId,
+ examId: String(examId),
answers: suiteData.answers || {},
correctAnswers: suiteData.correctAnswers || {},
answerComparison: suiteData.answerComparison || {},
@@ -1576,6 +5781,7 @@
duration: suiteData.duration || 0,
metadata: {
sessionId: suiteData.sessionId,
+ submissionId: suiteData.submissionId,
completedAt: new Date().toISOString()
},
rawData: (() => {
@@ -1598,12 +5804,16 @@
session.expectedSuiteCount = this._detectExpectedSuiteCount(examId, suiteData);
console.log('[MultiSuite] 检测到预期套题数量:', session.expectedSuiteCount);
}
+ session.revision = normalizeRecoveryEntityRevision(session.revision) + 1;
+ this._mirrorMultiSuiteSessionsToStorage();
+ if (!await this._commitMultiSuiteRecovery(session)) {
+ return false;
+ }
// 检查是否所有套题都已完成
if (this.isMultiSuiteComplete(session)) {
console.log('[MultiSuite] all suite entries completed, finalizing consolidated record.');
- await this.finalizeMultiSuiteRecord(session);
- return true;
+ return await this.finalizeMultiSuiteRecord(session);
}
// 还有套题未完成,保存当前进度
@@ -1621,15 +5831,15 @@
*/
_detectExpectedSuiteCount(examId, suiteData) {
// 尝试从suiteData中获取总套题数
- if (suiteData.totalSuites && Number.isFinite(suiteData.totalSuites)) {
- return suiteData.totalSuites;
+ if (suiteData.totalSuites && Number.isFinite(Number(suiteData.totalSuites))) {
+ return Math.max(1, Math.floor(Number(suiteData.totalSuites)));
}
// 尝试从metadata中获取
if (suiteData.metadata && suiteData.metadata.totalSuites) {
const count = Number(suiteData.metadata.totalSuites);
if (Number.isFinite(count) && count > 0) {
- return count;
+ return Math.max(1, Math.floor(count));
}
}
@@ -1650,12 +5860,48 @@
async finalizeMultiSuiteRecord(session) {
if (!session || !Array.isArray(session.suiteResults) || session.suiteResults.length === 0) {
console.warn('[MultiSuite] 无效的会话或无结果,跳过聚合');
- return;
+ return false;
+ }
+ if (!this._ownsMultiSuiteRecoveryOwnership(session)
+ && !await this._acquireMultiSuiteRecoveryOwnership(session)) return false;
+ if (session._finalizePromise && typeof session._finalizePromise.then === 'function') {
+ return session._finalizePromise;
+ }
+ const finalizePromise = this._finalizeMultiSuiteRecordInternal(session);
+ session._finalizePromise = finalizePromise;
+ try {
+ return await finalizePromise;
+ } finally {
+ if (session._finalizePromise === finalizePromise) {
+ session._finalizePromise = null;
+ }
+ }
+ },
+
+ async _finalizeMultiSuiteRecordInternal(session) {
+ if (!session || !Array.isArray(session.suiteResults) || session.suiteResults.length === 0) {
+ console.warn('[MultiSuite] 无效的会话或无结果,跳过聚合');
+ return false;
+ }
+ if (!this._ownsMultiSuiteRecoveryOwnership(session)
+ && !await this._acquireMultiSuiteRecoveryOwnership(session)) return false;
+
+ const operationId = `practice-multisuite:${String(session.id)}:finalize`;
+ const hasFinalizeState = Boolean(session.finalizeRecord || session.finalizeOperationId);
+ const hasFrozenRecord = Boolean(session.finalizeRecord
+ && session.finalizeOperationId === operationId
+ && this._isValidMultiSuiteFinalizeRecord(session, session.finalizeRecord));
+ if ((session.status === 'finalizing' || hasFinalizeState) && !hasFrozenRecord) {
+ console.warn('[MultiSuite] 聚合快照与当前会话不一致,拒绝使用相同 operationId 重建');
+ return false;
}
session.status = 'finalizing';
+ session.lastUpdate = Date.now();
+ this._mirrorMultiSuiteSessionsToStorage();
console.log('[MultiSuite] 开始聚合多套题记录:', session.id);
+ let record = null;
try {
const completionTime = Date.now();
const startTime = session.startTime || completionTime;
@@ -1685,8 +5931,9 @@
const displayTitle = dateLabel + ' ' + sourceLabel + ' multi-suite practice';
// 构建聚合记录
- const record = {
+ record = {
id: session.id,
+ operationId: `practice-multisuite:${String(session.id)}:finalize`,
examId: session.baseExamId,
title: displayTitle,
type: 'listening',
@@ -1715,6 +5962,7 @@
answers: result.answers,
answerComparison: result.answerComparison,
spellingErrors: result.spellingErrors || [],
+ metadata: this._cloneSuitePlainObject(result.metadata || {}),
duration: result.duration || 0,
timestamp: result.timestamp,
rawData: result.rawData || null
@@ -1748,38 +5996,96 @@
}
};
- // 保存聚合记录
- await this._saveSuitePracticeRecord(record);
-
- // 保存拼写错误到词表
- if (aggregatedSpellingErrors.length > 0 && window.spellingErrorCollector) {
- try {
- await window.spellingErrorCollector.saveErrors(aggregatedSpellingErrors);
- console.log('[MultiSuite] 已保存拼写错误到词表:', aggregatedSpellingErrors.length);
- } catch (error) {
- console.warn('[MultiSuite] 保存拼写错误失败:', error);
- }
+ const frozenRecord = hasFrozenRecord
+ ? this._cloneSuitePlainObject(session.finalizeRecord)
+ : record;
+ frozenRecord.operationId = operationId;
+ session.finalizeOperationId = operationId;
+ session.finalizeRecord = this._cloneSuitePlainObject(frozenRecord);
+ session.lastUpdate = Date.now();
+ if (!hasFrozenRecord) {
+ session.revision = normalizeRecoveryEntityRevision(session.revision) + 1;
+ }
+ this._mirrorMultiSuiteSessionsToStorage();
+ if (normalizeRecoveryEntityRevision(session.revision)
+ > normalizeRecoveryEntityRevision(session._lastDurableRecoveryRevision)
+ && !await this._commitMultiSuiteRecovery(session)) {
+ throw new Error('Multi-suite finalize recovery was not committed');
}
- // 更新状态
- await this._updatePracticeRecordsState();
- this.refreshOverviewData && this.refreshOverviewData();
-
- // 清理会话
- this.multiSuiteSessionsMap.delete(session.baseExamId);
+ // v2 finalizeSuite is idempotent only when the record and operation
+ // id remain byte-for-byte stable across retries.
+ await this._saveSuitePracticeRecord(frozenRecord);
+ record = frozenRecord;
session.status = 'completed';
+ session.lastUpdate = Date.now();
+ this._mirrorMultiSuiteSessionsToStorage();
+ } catch (error) {
+ console.error('[MultiSuite] 聚合记录失败:', error);
+ session.status = 'finalizing';
+ session.lastUpdate = Date.now();
+ this._mirrorMultiSuiteSessionsToStorage();
+ try {
+ window.showMessage && window.showMessage('多套题记录保存失败,请稍后重试。', 'error');
+ } catch (notificationError) {
+ console.warn('[MultiSuite] 显示聚合保存失败通知时出错:', notificationError);
+ }
+ return false;
+ }
+ // From here on the aggregate record is authoritative. Every remaining action is best-effort
+ // and must not turn the committed submission into a NACK or another persistence attempt.
+ const aggregatedSpellingErrors = Array.isArray(record.spellingErrors) ? record.spellingErrors : [];
+ if (aggregatedSpellingErrors.length > 0 && window.spellingErrorCollector) {
+ await this._runSuitePostCommitStep('保存多套题拼写错误', async () => {
+ await window.spellingErrorCollector.saveErrors(aggregatedSpellingErrors);
+ console.log('[MultiSuite] 已保存拼写错误到词表:', aggregatedSpellingErrors.length);
+ });
+ }
+ await this._runSuitePostCommitStep('同步多套题练习记录', () => this._updatePracticeRecordsState());
+ await this._runSuitePostCommitStep('刷新多套题总览', () => {
+ this.refreshOverviewData && this.refreshOverviewData();
+ });
+ await this._runSuitePostCommitStep('清理多套题会话', async () => {
+ // Old v2 frozen entries have no canonical submission metadata, so recovery remains their durable receipt.
+ if (session.suiteResults.some((result) => result?.rawData?.submissionId
+ && !result?.metadata?.submissionId)) return;
+ const recovery = global.AppData && global.AppData.recovery;
+ if (recovery && typeof recovery.discardActiveSession === 'function') {
+ if (!this._ownsMultiSuiteRecoveryOwnership(session)) {
+ throw new Error('Multi-suite recovery lease is not owned');
+ }
+ const receipt = await recovery.discardActiveSession(String(session.id), {
+ operationId: `multi-suite-recovery:${String(session.id)}:discard`,
+ expectedEntityRevision: normalizeRecoveryEntityRevision(session._lastDurableRecoveryRevision),
+ commitGuard: () => this._ownsMultiSuiteRecoveryOwnership(session)
+ });
+ if (!receipt || receipt.committed !== true) {
+ throw new Error('Multi-suite recovery discard was not confirmed');
+ }
+ }
+ if (this.multiSuiteSessionsMap
+ && this.multiSuiteSessionsMap.get(session.baseExamId)?.id === session.id) {
+ this.multiSuiteSessionsMap.delete(session.baseExamId);
+ }
+ this._mirrorMultiSuiteSessionsToStorage();
+ await this._releaseSuiteRecoveryClaim('multi', session);
+ });
+ await this._runSuitePostCommitStep('显示多套题完成通知', () => {
window.showMessage && window.showMessage('多套题练习已完成,已保存 ' + session.suiteResults.length + ' 条套题记录。', 'success');
+ });
+ console.log('[MultiSuite] consolidated record saved:', record.id);
+ return true;
+ },
-
-
- console.log('[MultiSuite] consolidated record saved:', record.id);
-
+ async _runSuitePostCommitStep(label, callback) {
+ try {
+ await callback();
+ return true;
} catch (error) {
- console.error('[MultiSuite] 聚合记录失败:', error);
- session.status = 'error';
- window.showMessage && window.showMessage('多套题记录保存失败,请稍后重试。', 'error');
+ console.warn(`[SuitePractice] ${label}失败(聚合记录已保存):`, error);
+ return false;
}
},
@@ -1933,7 +6239,7 @@
questionId: error.questionId,
suiteId: error.suiteId || result.suiteId,
examId: error.examId || result.examId,
- timestamp: error.timestamp || Date.now(),
+ timestamp: error.timestamp || result.timestamp || 0,
errorCount: error.errorCount || 1,
source: error.source || this._detectMultiSuiteSource(result.examId),
acceptedAnswers: Array.isArray(error.acceptedAnswers) ? error.acceptedAnswers.slice() : undefined,
@@ -1953,14 +6259,85 @@
return aggregated;
},
- async finalizeSuiteRecord(session) {
- if (!session || !session.results || !session.results.length) {
- await this._teardownSuiteSession(session);
- return;
+ async _finalizeSuiteRecordWithGate(session, options = {}) {
+ if (!session) return false;
+ if (!this._ownsSuiteRecoveryClaim('single', session)
+ && !await this._acquireSuiteRecoveryClaim('single', session)) return false;
+ if (session._finalizePromise && typeof session._finalizePromise.then === 'function') {
+ return session._finalizePromise;
+ }
+ const finalizePromise = this.finalizeSuiteRecord(session, options);
+ session._finalizePromise = finalizePromise;
+ try {
+ return await finalizePromise;
+ } finally {
+ if (session._finalizePromise === finalizePromise) {
+ session._finalizePromise = null;
+ }
+ }
+ },
+
+ async finalizeSuiteRecord(session, options = {}) {
+ if (!session || !Array.isArray(session.sequence) || !session.sequence.length) {
+ return false;
+ }
+ if (!this._ownsSuiteRecoveryClaim('single', session)
+ && !await this._acquireSuiteRecoveryClaim('single', session)) return false;
+
+ const sequenceIds = session.sequence
+ .map((entry) => entry && String(entry.examId || '').trim())
+ .filter(Boolean);
+ const results = Array.isArray(session.results) ? session.results : [];
+ const resultIds = results
+ .map((entry) => entry && String(entry.examId || '').trim())
+ .filter(Boolean);
+ const invalidResultIndex = results.findIndex((entry) => !this._isValidSuiteRecoveryResult(entry, sequenceIds));
+ const invalidResultExamId = invalidResultIndex >= 0 && results[invalidResultIndex]
+ ? String(results[invalidResultIndex].examId || '').trim()
+ : '';
+ const invalidSequenceIndex = invalidResultExamId ? sequenceIds.indexOf(invalidResultExamId) : -1;
+ const missingResultIndex = session.sequence.findIndex((entry) => (
+ entry && !resultIds.includes(String(entry.examId))
+ ));
+ const completeResults = Boolean(
+ sequenceIds.length === session.sequence.length
+ && resultIds.length === sequenceIds.length
+ && new Set(resultIds).size === resultIds.length
+ && invalidResultIndex < 0
+ && sequenceIds.every((examId) => resultIds.includes(examId))
+ );
+ if (!completeResults) {
+ delete session._suiteTeardownRegistrations;
+ const recoveryIndex = invalidSequenceIndex >= 0 ? invalidSequenceIndex : missingResultIndex;
+ const safeIndex = recoveryIndex >= 0
+ ? recoveryIndex
+ : Math.min(Math.max(0, Number(session.currentIndex) || 0), session.sequence.length - 1);
+ session.status = 'active';
+ session.currentIndex = safeIndex;
+ session.activeExamId = session.sequence[safeIndex] && session.sequence[safeIndex].examId || null;
+ session.finalizeOperationId = null;
+ session.finalizeRecord = null;
+ session.lastUpdate = Date.now();
+ await this._commitSuiteRecovery(session, { reason: 'incomplete-finalize' });
+ window.showMessage && window.showMessage('套题结果不完整,请完成缺失篇章后再提交。', 'warning');
+ return false;
}
+ if (!(session._suiteTeardownRegistrations instanceof Map)) {
+ // Freeze the exact suite-owned WindowProxy before finalization performs
+ // its first durable write. Receipt replay may delay teardown, but late
+ // messages must never replace the ownership snapshot in that interval.
+ session._suiteTeardownRegistrations = this._captureSuiteTeardownRegistrations(session);
+ }
session.status = 'finalizing';
+ session.currentIndex = session.sequence.length;
+ session.activeExamId = null;
+ session.lastUpdate = Date.now();
+ if (!await this._commitSuiteRecovery(session, { reason: 'finalize-start' })) {
+ return false;
+ }
+ let committed = false;
try {
const completionTime = Date.now();
const suiteEntries = session.results.map(entry => {
@@ -1976,6 +6353,8 @@
markedQuestions: Array.isArray(entry.markedQuestions) ? entry.markedQuestions.slice() : [],
highlights: this._resolveSuiteEntryHighlights(entry, draft),
noteText: this._resolveSuiteEntryNoteText(entry, draft),
+ notes: this._resolveSuiteEntryNotes(entry, draft),
+ noteOutlines: this._resolveSuiteEntryNoteOutlines(entry, draft),
scrollY: this._resolveSuiteEntryScrollY(entry, draft),
rawData: this._sanitizeSuiteRawData(entry.rawData)
};
@@ -2030,7 +6409,7 @@
const dateLabel = this._formatSuiteDateLabel(startTime);
const displayTitle = dateLabel + '套题练习' + suiteSequence;
- const record = {
+ const builtRecord = {
id: session.id,
examId: 'suite-' + session.id,
title: displayTitle,
@@ -2073,56 +6452,67 @@
sessionId: session.id
};
+ const expectedOperationId = `practice-suite:${String(session.id)}:finalize`;
+ session.finalizeOperationId = expectedOperationId;
+ const persistedRecord = session.finalizeRecord && this._isValidSuiteFinalizeRecord(session, session.finalizeRecord)
+ ? this._cloneSuitePlainObject(session.finalizeRecord)
+ : builtRecord;
+ const record = persistedRecord;
+ record.operationId = expectedOperationId;
+ session.finalizeRecord = this._cloneSuitePlainObject(record);
+ if (!await this._commitSuiteRecovery(session, { reason: 'finalize-record' })) {
+ const recoveryError = new Error('Suite finalize recovery state was not committed');
+ recoveryError.code = 'RECOVERY_COMMIT_NOT_CONFIRMED';
+ throw recoveryError;
+ }
+
await this._saveSuitePracticeRecord(record);
- await this._updatePracticeRecordsState();
- this.refreshOverviewData && this.refreshOverviewData();
- window.showMessage && window.showMessage('套题练习已完成,记录已保存。', 'success');
+ committed = true;
session.status = 'completed';
} catch (error) {
console.error('[SuitePractice] 保存套题记录失败:', error);
- window.showMessage && window.showMessage('套题记录保存失败,系统将尝试恢复到普通模式。', 'error');
- await this._savePartialSuiteAsIndividual(session);
- session.status = 'error';
- } finally {
- await this._teardownSuiteSession(session);
- }
- },
-
- async _fetchSuiteExamIndex() {
- let list = this.getState ? this.getState('exam.index') : null;
- if (!Array.isArray(list) || !list.length) {
try {
- const activeKey = await storage.get('active_exam_index_key', 'exam_index');
- list = await storage.get(activeKey, []);
- if (!Array.isArray(list) || !list.length) {
- list = await storage.get('exam_index', []);
- }
- } catch (error) {
- console.warn('[SuitePractice] Failed to load exam index, falling back to the default bank.', error);
- list = await storage.get('exam_index', []);
+ window.showMessage && window.showMessage('套题记录保存失败,恢复快照已保留,请稍后重试。', 'error');
+ } catch (notificationError) {
+ console.warn('[SuitePractice] 显示套题保存失败通知时出错:', notificationError);
}
+ session.status = 'finalizing';
+ session.lastUpdate = Date.now();
+ await this._commitSuiteRecovery(session, { notify: false, reason: 'finalize-error' });
+ }
+
+ if (committed) {
+ await this._runSuitePostCommitStep('同步套题练习记录', () => this._updatePracticeRecordsState());
+ await this._runSuitePostCommitStep('刷新套题总览', () => {
+ this.refreshOverviewData && this.refreshOverviewData();
+ });
+ await this._runSuitePostCommitStep('显示套题完成通知', () => {
+ window.showMessage && window.showMessage('套题练习已完成,记录已保存。', 'success');
+ });
+ }
+
+ if (committed && !options.deferTeardown) {
+ await this._runSuitePostCommitStep('清理套题会话窗口', () => this._teardownSuiteSession(session));
}
+ return committed;
+ },
+ async _fetchSuiteExamIndex() {
+ const list = await window.resolveActiveLibraryIndex();
return Array.isArray(list) ? list.filter(Boolean) : [];
},
async _listPracticeRecordsViaAPI() {
const normalizeList = (list) => (Array.isArray(list) ? list : []);
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- return normalizeList(await window.PracticeRecordAPI.list());
- }
-
- return [];
+ // Filtering needs suiteEntries and suite markers, but never highlights or notes.
+ // The detail projection contains those fields without loading the annotation layer.
+ return normalizeList(await window.AppData.practice.list({ projection: 'detail' }));
},
async _recalculatePracticeStatsFromRecords() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.recalculateStats === 'function') {
- await window.PracticeRecordAPI.recalculateStats();
- return true;
- }
- console.warn('[SuitePractice] 统一练习统计 API 未就绪');
- return false;
+ await window.AppData.practice.getStats();
+ return true;
},
async _loadSuitePracticeRecordsForFiltering() {
@@ -2386,7 +6776,7 @@
},
async _launchSuiteSessionFromSequence(sequence, options = {}) {
- const suiteWindowName = options.suiteWindowName || 'ielts-suite-mode-tab';
+ const requestedSuiteWindowName = options.suiteWindowName || 'ielts-suite-mode-tab';
const flowMode = options.flowMode || 'simulation';
const frequencyScope = options.frequencyScope || 'all';
const launchLabel = options.launchLabel || (
@@ -2394,29 +6784,51 @@
? '驻足模式'
: (flowMode === 'simulation' ? '模拟模式' : '经典模式')
);
+ const normalizedSequence = Array.isArray(sequence)
+ ? sequence.filter(item => item && item.examId && item.exam)
+ : [];
+ if (!normalizedSequence.length) {
+ window.showMessage && window.showMessage('未找到可用的套题题目。', 'warning');
+ return false;
+ }
+ if (typeof this.openExam !== 'function') {
+ window.showMessage && window.showMessage('当前版本暂不支持套题练习自动打开题目。', 'error');
+ return false;
+ }
+ if (this.currentSuiteSession
+ && ['active', 'initializing', 'finalizing'].includes(this.currentSuiteSession.status)) {
+ window.showMessage && window.showMessage('套题练习正在进行中,请先完成当前套题。', 'warning');
+ return false;
+ }
+ const suiteSessionId = this._generateSuiteSessionId();
+ const suiteWindowName = this._resolveSuiteWindowName(suiteSessionId, requestedSuiteWindowName);
+ const firstEntry = normalizedSequence[0];
+ let launchOwnership = null;
+ let launchSession = null;
+ let initialDurableReceiptConfirmed = false;
try {
- if (this.currentSuiteSession && this.currentSuiteSession.status === 'active') {
- window.showMessage && window.showMessage('套题练习正在进行中,请先完成当前套题。', 'warning');
- return false;
+ if (this.currentSuiteSession && this.currentSuiteSession.status === 'completed') {
+ const completedSession = this.currentSuiteSession;
+ const tornDown = await this._teardownSuiteSession(completedSession);
+ if (!tornDown || this.currentSuiteSession) {
+ window.showMessage && window.showMessage('上一套题记录已保存,但恢复状态尚未安全清理,请稍后重试。', 'warning');
+ return false;
+ }
}
-
- if (typeof this.openExam !== 'function') {
- window.showMessage && window.showMessage('当前版本暂不支持套题练习自动打开题目。', 'error');
+ if (this.currentSuiteSession && ['active', 'initializing', 'finalizing'].includes(this.currentSuiteSession.status)) {
+ window.showMessage && window.showMessage('套题练习正在进行中,请先完成当前套题。', 'warning');
return false;
}
- const normalizedSequence = Array.isArray(sequence)
- ? sequence.filter(item => item && item.examId && item.exam)
- : [];
- if (!normalizedSequence.length) {
- window.showMessage && window.showMessage('未找到可用的套题题目。', 'warning');
- return false;
- }
+ // Completed-session teardown may yield and fail. Reserve the first target
+ // only after it succeeds, but before the first launch-related await.
+ launchOwnership = this._beginSuiteExamLaunchOwnership(firstEntry.examId, {
+ windowName: suiteWindowName
+ });
this._clearSuiteHandshakes();
- const suiteSessionId = this._generateSuiteSessionId();
const lockedAutoAdvance = flowMode === 'stationary'
? false
: true;
@@ -2425,7 +6837,9 @@
const suiteTimerLimitSeconds = 3600;
const session = {
id: suiteSessionId,
- status: 'initializing',
+ _suiteGeneration: (this._suiteSessionGeneration = Math.max(0, Number(this._suiteSessionGeneration) || 0) + 1),
+ _lastDurableRecoveryRevision: 0,
+ status: 'active',
startTime: timerAnchorMs,
sequence: normalizedSequence,
currentIndex: 0,
@@ -2442,19 +6856,64 @@
flowMode,
frequencyScope,
autoAdvanceAfterSubmit: lockedAutoAdvance,
+ activeExamId: firstEntry.examId,
windowRef: null,
windowName: suiteWindowName
};
-
+ launchSession = session;
+ if (!await this._acquireSuiteRecoveryClaim('single', session)) {
+ if (launchOwnership && typeof this._rollbackExamLaunchOwnership === 'function') {
+ this._rollbackExamLaunchOwnership(launchOwnership);
+ }
+ return false;
+ }
+ if (!this._isSuiteExamLaunchOwnershipCurrent(firstEntry.examId, launchOwnership)) {
+ if (launchOwnership && typeof this._rollbackExamLaunchOwnership === 'function') {
+ this._rollbackExamLaunchOwnership(launchOwnership);
+ }
+ await this._releaseSuiteRecoveryClaim('single', session);
+ return false;
+ }
this.currentSuiteSession = session;
+ session.lastUpdate = Date.now();
+ const initialLaunchStillOwned = () => this.currentSuiteSession === session
+ && String(session.activeExamId || '') === String(firstEntry.examId)
+ && this._isSuiteExamLaunchOwnershipCurrent(firstEntry.examId, launchOwnership);
+ const recoveryCommitted = await this._commitSuiteRecovery(session, {
+ reason: 'suite-start',
+ commitGuard: initialLaunchStillOwned,
+ onDurableReceipt: () => { initialDurableReceiptConfirmed = true; }
+ });
+ if (!recoveryCommitted) {
+ if (initialDurableReceiptConfirmed) {
+ session.windowRef = null;
+ session._restoredFromStorage = true;
+ this._registerSuiteSequence(session);
+ return false;
+ }
+ if (launchOwnership && typeof this._rollbackExamLaunchOwnership === 'function') {
+ this._rollbackExamLaunchOwnership(launchOwnership);
+ }
+ if (this.currentSuiteSession === session) this.currentSuiteSession = null;
+ this._clearSessionStorage(session);
+ await this._releaseSuiteRecoveryClaim('single', session);
+ return false;
+ }
+ if (!this._isSuiteExamLaunchOwnershipCurrent(firstEntry.examId, launchOwnership)) {
+ session.windowRef = null;
+ session._restoredFromStorage = true;
+ this._registerSuiteSequence(session);
+ return false;
+ }
this._registerSuiteSequence(session);
- const firstEntry = normalizedSequence[0];
window.showMessage && window.showMessage(launchLabel + ' 已启动,正在打开第一篇。', 'info');
let examWindow = null;
+ let targetRegistration = null;
try {
- examWindow = await this.openExam(firstEntry.examId, {
+ const openOptions = {
+ examDefinition: firstEntry.exam,
target: 'tab',
windowName: suiteWindowName,
suiteSessionId,
@@ -2463,31 +6922,64 @@
suiteTimerLimitSeconds,
sequenceIndex: 0,
sequenceTotal: normalizedSequence.length
- });
+ };
+ if (launchOwnership) openOptions.launchOwnership = launchOwnership;
+ examWindow = await this.openExam(firstEntry.examId, openOptions);
+ if (examWindow && !examWindow.closed) {
+ targetRegistration = this._captureSuiteNavigationRegistration(
+ firstEntry.examId,
+ examWindow,
+ session,
+ launchOwnership
+ );
+ }
} catch (openError) {
console.error('[SuitePractice] 打开首篇失败:', openError);
examWindow = null;
}
- if (!examWindow || examWindow.closed) {
- throw new Error('first_exam_window_unavailable');
+ if (!examWindow || examWindow.closed
+ || !targetRegistration
+ || targetRegistration.window !== examWindow
+ || !this._isSuiteNavigationRegistrationCurrent(
+ firstEntry.examId,
+ targetRegistration,
+ session
+ )) {
+ session.windowRef = null;
+ session._restoredFromStorage = true;
+ window.showMessage && window.showMessage('首篇窗口未能打开,套题已安全保存;允许弹窗后可继续。', 'warning');
+ return false;
}
session.windowRef = examWindow;
this._ensureSuiteWindowGuard(session, session.windowRef);
- session.status = 'active';
- session.activeExamId = firstEntry.examId;
+ session._restoredFromStorage = false;
+ session.lastUpdate = Date.now();
this._focusSuiteWindow(session.windowRef);
if (flowMode === 'simulation') {
this._sendSimulationContext(session, firstEntry.examId, session.windowRef);
}
- return true;
+ return this._isSuiteNavigationRegistrationCurrent(
+ firstEntry.examId,
+ targetRegistration,
+ session
+ );
} catch (error) {
console.error('[SuitePractice] 启动失败:', error);
- window.showMessage && window.showMessage('套题练习启动失败,请稍后重试。', 'error');
- if (this.currentSuiteSession) {
- await this._abortSuiteSession(this.currentSuiteSession, { reason: 'startup_failed' });
+ if (!initialDurableReceiptConfirmed) {
+ if (launchOwnership && typeof this._rollbackExamLaunchOwnership === 'function') {
+ this._rollbackExamLaunchOwnership(launchOwnership);
+ }
+ if (launchSession && this.currentSuiteSession === launchSession) {
+ this.currentSuiteSession = null;
+ this._clearSessionStorage(launchSession);
+ }
+ if (launchSession && this._ownsSuiteRecoveryClaim('single', launchSession)) {
+ await this._releaseSuiteRecoveryClaim('single', launchSession);
+ }
}
+ window.showMessage && window.showMessage('套题练习启动失败,请稍后重试。', 'error');
return false;
}
},
@@ -2495,6 +6987,13 @@
return 'suite_' + Date.now().toString(36) + '_' + Math.random().toString(16).slice(2, 8);
},
+ _resolveSuiteWindowName(sessionId, requestedName = 'ielts-suite-mode-tab') {
+ const id = String(sessionId || '').trim();
+ const base = String(requestedName || 'ielts-suite-mode-tab').trim() || 'ielts-suite-mode-tab';
+ if (!id || base.endsWith(`-${id}`)) return base;
+ return `${base}-${id}`;
+ },
+
_registerSuiteSequence(session) {
if (!this.suiteExamMap) {
this.suiteExamMap = new Map();
@@ -2582,14 +7081,161 @@
};
},
+ _isValidSuiteScoreInfo(scoreInfo) {
+ if (!scoreInfo || typeof scoreInfo !== 'object' || Array.isArray(scoreInfo)) return false;
+ const correct = Number(scoreInfo.correct);
+ const total = Number(scoreInfo.total);
+ const accuracy = Number(scoreInfo.accuracy);
+ const percentage = Number(scoreInfo.percentage);
+ return Boolean(
+ Number.isFinite(correct)
+ && Number.isFinite(total)
+ && Number.isFinite(accuracy)
+ && Number.isFinite(percentage)
+ && correct >= 0
+ && total >= 0
+ && correct <= total
+ && accuracy >= 0
+ && accuracy <= 1
+ && percentage >= 0
+ && percentage <= 100
+ );
+ },
+
+ _isValidSuiteRecoveryResult(entry, sequenceIds = []) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
+ const examId = String(entry.examId || '').trim();
+ const duration = Number(entry.duration);
+ return Boolean(
+ examId
+ && (!sequenceIds.length || sequenceIds.includes(examId))
+ && Number.isFinite(duration)
+ && duration >= 0
+ && this._isValidSuiteScoreInfo(entry.scoreInfo)
+ && entry.answers
+ && typeof entry.answers === 'object'
+ && entry.answerComparison
+ && typeof entry.answerComparison === 'object'
+ );
+ },
+
+ _isValidSuiteFinalizeEntry(entry, sequenceIds = []) {
+ return Boolean(
+ this._isValidSuiteRecoveryResult(entry, sequenceIds)
+ && Array.isArray(entry.markedQuestions)
+ && Array.isArray(entry.highlights)
+ && typeof entry.noteText === 'string'
+ && Array.isArray(entry.notes)
+ && Array.isArray(entry.noteOutlines)
+ && Number.isFinite(Number(entry.scrollY))
+ );
+ },
+
+ _isValidSuiteFinalizeRecord(session, record) {
+ if (!session || !record || typeof record !== 'object') return false;
+ const sequenceIds = Array.isArray(session.sequence)
+ ? session.sequence.map((entry) => entry && String(entry.examId || '').trim()).filter(Boolean)
+ : [];
+ const recordEntries = Array.isArray(record.suiteEntries) ? record.suiteEntries : [];
+ const recordIds = recordEntries
+ .map((entry) => entry && String(entry.examId || '').trim()).filter(Boolean);
+ const results = Array.isArray(session.results) ? session.results : [];
+ const resultIds = results.map((entry) => entry && String(entry.examId || '').trim()).filter(Boolean);
+ const expectedAnswers = {};
+ const expectedComparison = {};
+ let expectedCorrect = 0;
+ let expectedTotal = 0;
+ results.forEach((entry) => {
+ const examId = String(entry && entry.examId || '').trim();
+ const prefix = examId ? `${examId}::` : '';
+ expectedCorrect += Number(entry && entry.scoreInfo && entry.scoreInfo.correct) || 0;
+ expectedTotal += Number(entry && entry.scoreInfo && entry.scoreInfo.total) || 0;
+ Object.entries(entry && entry.answers && typeof entry.answers === 'object' ? entry.answers : {})
+ .forEach(([questionId, answer]) => {
+ expectedAnswers[prefix + questionId] = answer;
+ });
+ Object.entries(entry && entry.answerComparison && typeof entry.answerComparison === 'object' ? entry.answerComparison : {})
+ .forEach(([questionId, comparison]) => {
+ expectedComparison[prefix + questionId] = comparison;
+ });
+ });
+ const expectedAccuracy = expectedTotal > 0 ? expectedCorrect / expectedTotal : 0;
+ const expectedPercentage = Math.round(expectedAccuracy * 100);
+ const scoreInfo = record.scoreInfo;
+ const numericMatches = (left, right) => Number.isFinite(Number(left)) && Number(left) === Number(right);
+ return Boolean(
+ session.id
+ && String(record.id || '') === String(session.id)
+ && String(record.sessionId || '') === String(session.id)
+ && sequenceIds.length > 0
+ && record.examId === `suite-${String(session.id)}`
+ && record.type === 'reading'
+ && record.suiteMode === true
+ && record.frequency === 'suite'
+ && typeof record.title === 'string'
+ && typeof record.date === 'string'
+ && typeof record.startTime === 'string'
+ && typeof record.endTime === 'string'
+ && Number.isFinite(Number(record.duration))
+ && Number(record.duration) >= 0
+ && this._isValidSuiteScoreInfo(scoreInfo)
+ && numericMatches(record.totalQuestions, expectedTotal)
+ && numericMatches(record.correctAnswers, expectedCorrect)
+ && numericMatches(scoreInfo.correct, expectedCorrect)
+ && numericMatches(scoreInfo.total, expectedTotal)
+ && Math.abs(Number(scoreInfo.accuracy) - expectedAccuracy) < 1e-9
+ && Number(scoreInfo.percentage) === expectedPercentage
+ && this._suiteValuesEqual(record.answers, expectedAnswers)
+ && this._suiteValuesEqual(record.answerComparison, expectedComparison)
+ && recordEntries.length === sequenceIds.length
+ && recordIds.length === sequenceIds.length
+ && new Set(recordIds).size === recordIds.length
+ && recordIds.every((examId, index) => examId === sequenceIds[index])
+ && resultIds.length === sequenceIds.length
+ && new Set(resultIds).size === resultIds.length
+ && resultIds.every((examId) => sequenceIds.includes(examId))
+ && recordEntries.every((entry, index) => {
+ const result = results.find((candidate) => String(candidate && candidate.examId || '').trim() === sequenceIds[index]);
+ return this._isValidSuiteFinalizeEntry(entry, sequenceIds)
+ && result
+ && numericMatches(entry.duration, result.duration)
+ && this._suiteValuesEqual(entry.scoreInfo, result.scoreInfo)
+ && this._suiteValuesEqual(entry.answers, result.answers)
+ && this._suiteValuesEqual(entry.answerComparison, result.answerComparison);
+ })
+ && record.metadata
+ && typeof record.metadata === 'object'
+ && String(record.metadata.suiteSessionId || '') === String(session.id)
+ && Number(record.metadata.suiteEntryCount) === sequenceIds.length
+ && record.realData
+ && typeof record.realData === 'object'
+ && record.realData.source === 'suite_mode'
+ && numericMatches(record.realData.correct, expectedCorrect)
+ && numericMatches(record.realData.total, expectedTotal)
+ && numericMatches(record.realData.duration, record.duration)
+ && typeof record.operationId === 'string'
+ && record.operationId === `practice-suite:${String(session.id)}:finalize`
+ );
+ },
+
async _saveSuitePracticeRecord(record) {
- if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.saveRecord !== 'function') {
- throw new Error('统一练习记录存储未就绪');
- }
- await window.PracticeRecordAPI.saveRecord(record, { updateStats: true });
- await this._cleanupSuiteEntryRecords(record).catch(error => {
- console.warn('[SuitePractice] 清理套题子记录失败:', error);
+ const childSessionIds = [];
+ (Array.isArray(record && record.suiteEntries) ? record.suiteEntries : []).forEach((entry) => {
+ const raw = entry && entry.rawData || {};
+ const sessionId = raw.sessionId || (entry && (entry.sessionId || entry.suiteEntrySessionId));
+ if (sessionId && String(sessionId) !== String(record.sessionId || '')) childSessionIds.push(String(sessionId));
});
+ const receipt = await window.AppData.practice.finalizeSuite({
+ record,
+ childSessionIds,
+ operationId: record.operationId
+ });
+ if (!receipt || receipt.committed !== true) {
+ const error = new Error('Suite aggregate commit was not confirmed');
+ error.code = 'SUITE_COMMIT_NOT_CONFIRMED';
+ throw error;
+ }
+ return receipt.record || record;
},
async _cleanupSuiteEntryRecords(record) {
@@ -2622,14 +7268,7 @@
return;
}
- if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.deleteMany !== 'function') {
- throw new Error('统一练习记录删除 API 未就绪');
- }
- const result = await window.PracticeRecordAPI.deleteMany(Array.from(entrySessionIds), { updateStats: true, matchBy: 'sessionId' });
- const deletedCount = Number(result && result.deletedCount) || 0;
- if (deletedCount > 0) {
- console.log('[SuitePractice] cleared ' + deletedCount + ' suite child records');
- }
+ // Child cleanup is committed atomically by practice.finalizeSuite.
},
async _updatePracticeRecordsState() {
@@ -2638,22 +7277,20 @@
await window.syncPracticeRecords({ forceRender: true });
return;
} else {
- const latest = await this._listPracticeRecordsViaAPI();
- if (this.setState) {
- this.setState('practice.records', Array.isArray(latest) ? latest : []);
+ const [latest, index] = await Promise.all([
+ window.AppData.practice.list({ projection: 'light' }),
+ window.resolveActiveLibraryIndex()
+ ]);
+ if (typeof window.refreshBrowseProgressFromRecords === 'function') {
+ window.refreshBrowseProgressFromRecords(latest, index);
+ }
+ if (typeof window.updatePracticeView === 'function') {
+ window.updatePracticeView(latest, index);
}
}
} catch (error) {
console.warn('[SuitePractice] 同步练习记录失败:', error);
}
-
- try {
- if (typeof window.updatePracticeView === 'function') {
- window.updatePracticeView();
- }
- } catch (error) {
- console.warn('[SuitePractice] 刷新练习视图失败:', error);
- }
},
_formatSuiteDateLabel(timestamp) {
@@ -2720,24 +7357,6 @@
return count + 1;
},
- async _savePartialSuiteAsIndividual(session) {
- if (!session || !session.results || !session.results.length) {
- return;
- }
-
- for (const entry of session.results) {
- try {
- await this.saveRealPracticeData(entry.examId, this._buildSuiteEntryIndividualPayload(session, entry), { forceIndividualSave: true });
- this.updateExamStatus && this.updateExamStatus(entry.examId, 'completed');
- } catch (error) {
- console.error('[SuitePractice] 保存单篇记录失败:', error);
- }
- }
-
- await this._updatePracticeRecordsState();
- this.refreshOverviewData && this.refreshOverviewData();
- },
-
_focusSuiteWindow(targetWindow) {
if (!targetWindow || targetWindow.closed) {
return;
@@ -2770,76 +7389,320 @@
}
},
+ _isSuiteSessionCurrentOwner(session) {
+ if (!session || !this.currentSuiteSession) {
+ return true;
+ }
+ const current = this.currentSuiteSession;
+ if (current === session) {
+ return true;
+ }
+ if (String(current.id || '') !== String(session.id || '')) {
+ return false;
+ }
+ const currentGeneration = Number(current._suiteGeneration);
+ const sessionGeneration = Number(session._suiteGeneration);
+ return Number.isFinite(currentGeneration)
+ && currentGeneration > 0
+ && currentGeneration === sessionGeneration;
+ },
+
+ _captureSuiteTeardownRegistrations(session) {
+ const registrations = new Map();
+ const binding = session && session.windowBinding && typeof session.windowBinding === 'object'
+ ? session.windowBinding
+ : null;
+ const examId = String(binding && binding.examId || '').trim();
+ const expectedSessionId = String(binding && binding.expectedSessionId || '').trim();
+ const windowSessionToken = String(binding && binding.windowSessionToken || '').trim();
+ const sessionGeneration = Number(binding && binding.sessionGeneration);
+ if (!examId
+ || !expectedSessionId
+ || !windowSessionToken
+ || !Number.isInteger(sessionGeneration)
+ || sessionGeneration <= 0) {
+ return registrations;
+ }
+
+ const current = this.examWindows && this.examWindows.get(examId);
+ const suiteWindow = session.windowRef || (current && current.window) || null;
+ const exactCurrent = Boolean(
+ current
+ && current.window === suiteWindow
+ && String(current.suiteSessionId || '') === String(session.id || '')
+ && String(current.expectedSessionId || '') === expectedSessionId
+ && String(current.windowSessionToken || '') === windowSessionToken
+ && Number(current.sessionGeneration) === sessionGeneration
+ );
+ registrations.set(examId, {
+ windowInfo: exactCurrent ? current : null,
+ window: suiteWindow,
+ suiteSessionId: session.id,
+ expectedSessionId,
+ windowSessionToken,
+ sessionGeneration
+ });
+ return registrations;
+ },
+
+ _isSuiteTeardownRegistrationCurrent(examId, registration) {
+ if (typeof this._isExamSessionRegistrationCurrent === 'function') {
+ return this._isExamSessionRegistrationCurrent(examId, registration);
+ }
+ const current = this.examWindows && this.examWindows.get(examId);
+ return Boolean(
+ registration
+ && registration.windowInfo
+ && current === registration.windowInfo
+ && current.window === registration.window
+ && String(current.suiteSessionId || '') === String(registration.suiteSessionId || '')
+ && String(current.expectedSessionId || '') === String(registration.expectedSessionId || '')
+ && String(current.windowSessionToken || '') === String(registration.windowSessionToken || '')
+ && Number(current.sessionGeneration) === Number(registration.sessionGeneration)
+ );
+ },
+
+ _isSuiteWindowReassigned(targetWindow, registrations) {
+ if (!targetWindow) {
+ return false;
+ }
+ const remembered = this._reassignedExamWindowRegistrations
+ && this._reassignedExamWindowRegistrations.get(targetWindow);
+ if (remembered && remembered.size) {
+ for (const [examId, registration] of registrations || []) {
+ const marker = typeof this._buildExamWindowRegistrationMarker === 'function'
+ ? this._buildExamWindowRegistrationMarker(examId, registration)
+ : JSON.stringify([
+ String(examId || ''),
+ String(registration && registration.suiteSessionId || ''),
+ String(registration && registration.expectedSessionId || ''),
+ String(registration && registration.windowSessionToken || ''),
+ Number.isInteger(Number(registration && registration.sessionGeneration))
+ ? Number(registration.sessionGeneration)
+ : null
+ ]);
+ if (remembered.has(marker)) {
+ return true;
+ }
+ }
+ }
+ if (!this.examWindows) {
+ return false;
+ }
+ for (const [examId, current] of this.examWindows.entries()) {
+ if (!current || current.window !== targetWindow) {
+ continue;
+ }
+ const registration = registrations && registrations.get(String(examId));
+ if (!registration || !this._isSuiteTeardownRegistrationCurrent(examId, registration)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ _clearSuiteWindowReassignmentMarkers(targetWindow, registrations) {
+ const remembered = targetWindow
+ && this._reassignedExamWindowRegistrations
+ && this._reassignedExamWindowRegistrations.get(targetWindow);
+ if (!remembered || !remembered.size) return;
+ for (const [examId, registration] of registrations || []) {
+ const marker = typeof this._buildExamWindowRegistrationMarker === 'function'
+ ? this._buildExamWindowRegistrationMarker(examId, registration)
+ : JSON.stringify([
+ String(examId || ''),
+ String(registration && registration.suiteSessionId || ''),
+ String(registration && registration.expectedSessionId || ''),
+ String(registration && registration.windowSessionToken || ''),
+ Number.isInteger(Number(registration && registration.sessionGeneration))
+ ? Number(registration.sessionGeneration)
+ : null
+ ]);
+ remembered.delete(marker);
+ }
+ if (!remembered.size) {
+ this._reassignedExamWindowRegistrations.delete(targetWindow);
+ }
+ },
+
+ _isSuiteOperationOwner(session) {
+ return Boolean(
+ session
+ && this.currentSuiteSession
+ && this._isSuiteSessionCurrentOwner(session)
+ && session._suiteTeardownInProgress !== true
+ && session._suiteRecoveryWritesBlocked !== true
+ );
+ },
+
+ _canContinueSuiteOperation(session) {
+ return this._isSuiteOperationOwner(session) && session.status === 'active';
+ },
+
async _teardownSuiteSession(session) {
if (!session) {
- return;
+ return false;
+ }
+ if (session._teardownPromise && typeof session._teardownPromise.then === 'function') {
+ return session._teardownPromise;
+ }
+ const teardownPromise = this._teardownSuiteSessionInternal(session);
+ session._teardownPromise = teardownPromise;
+ try {
+ return await teardownPromise;
+ } finally {
+ if (session._teardownPromise === teardownPromise) {
+ session._teardownPromise = null;
+ }
+ }
+ },
+
+ async _freezeSuiteRecoveryWrites(session) {
+ if (!session) return false;
+ session._suiteRecoveryWritesBlocked = true;
+ const pending = session._suiteRecoveryCommitTail;
+ if (pending && typeof pending.then === 'function') {
+ try {
+ await pending;
+ } catch (_) {}
+ }
+ return true;
+ },
+
+ async _teardownSuiteSessionInternal(session) {
+ // A delayed receipt teardown must never own a newer suite session.
+ if (!this._isSuiteSessionCurrentOwner(session)) {
+ return false;
+ }
+
+ // Capture the completed suite's exact binding before teardown yields. A normal
+ // practice may reuse both the exam id and WindowProxy while persistence drains.
+ const teardownRegistrations = session._suiteTeardownRegistrations instanceof Map
+ ? session._suiteTeardownRegistrations
+ : this._captureSuiteTeardownRegistrations(session);
+ session._suiteTeardownRegistrations = teardownRegistrations;
+ const frozenWindowEntry = Array.from(teardownRegistrations.entries())
+ .find(([, registration]) => registration && registration.window);
+ const frozenWindowRegistration = frozenWindowEntry && frozenWindowEntry[1];
+ const suiteWindow = frozenWindowRegistration
+ ? frozenWindowRegistration.window
+ : session.windowRef;
+
+ const writesWereBlocked = session._suiteRecoveryWritesBlocked === true;
+ session._suiteTeardownInProgress = true;
+ await this._freezeSuiteRecoveryWrites(session);
+ if (!this._isSuiteSessionCurrentOwner(session)) {
+ session._suiteRecoveryWritesBlocked = writesWereBlocked;
+ session._suiteTeardownInProgress = false;
+ return false;
+ }
+ if (!await this._discardPersistentSuiteRecovery(session)) {
+ session._suiteRecoveryWritesBlocked = writesWereBlocked;
+ session._suiteTeardownInProgress = false;
+ return false;
+ }
+ if (session.submitReceiptTeardownTimer) {
+ clearTimeout(session.submitReceiptTeardownTimer);
+ session.submitReceiptTeardownTimer = null;
}
this._clearSuiteHandshakes();
- if (session.windowRef && !session.windowRef.closed && typeof session.windowRef.postMessage === 'function') {
+ const suiteWindowWasReassigned = this._isSuiteWindowReassigned(suiteWindow, teardownRegistrations);
+ const suiteWindowRegistrationIsCurrent = Boolean(
+ frozenWindowEntry
+ && this._isSuiteTeardownRegistrationCurrent(frozenWindowEntry[0], frozenWindowRegistration)
+ );
+ const currentFrozenExamRegistration = frozenWindowEntry && this.examWindows
+ ? this.examWindows.get(frozenWindowEntry[0])
+ : null;
+ const suiteWindowWasDisplaced = Boolean(
+ frozenWindowEntry
+ && currentFrozenExamRegistration
+ && currentFrozenExamRegistration !== frozenWindowRegistration.windowInfo
+ && currentFrozenExamRegistration.window
+ && currentFrozenExamRegistration.window !== suiteWindow
+ );
+ const suiteWindowCloseOwnershipProven = suiteWindowRegistrationIsCurrent || suiteWindowWasDisplaced;
+ if (!suiteWindowWasReassigned
+ && suiteWindowRegistrationIsCurrent
+ && suiteWindow
+ && !suiteWindow.closed
+ && typeof suiteWindow.postMessage === 'function') {
try {
- session.windowRef.postMessage({
- type: 'SUITE_FORCE_CLOSE',
- data: {
- suiteSessionId: session.id || null
- }
- }, '*');
+ this._postExamMessage(frozenWindowEntry[0], suiteWindow, 'SUITE_FORCE_CLOSE', {
+ suiteSessionId: session.id || null
+ });
} catch (forceCloseError) {
console.warn('[SuitePractice] 无法通知套题窗口关闭:', forceCloseError);
}
}
- this._releaseSuiteWindowGuard(session.windowRef);
- this._safelyCloseWindow(session.windowRef);
+ this._releaseSuiteWindowGuard(suiteWindow, session.id);
+ if (!suiteWindowWasReassigned && suiteWindowCloseOwnershipProven) {
+ this._safelyCloseWindow(suiteWindow);
+ }
+ this._clearSuiteWindowReassignmentMarkers(suiteWindow, teardownRegistrations);
- if (session.sequence && session.sequence.length) {
- const cleanupTasks = session.sequence.map(item => this.cleanupExamSession ? this.cleanupExamSession(item.examId) : Promise.resolve());
+ if (this.cleanupExamSession && teardownRegistrations.size) {
+ const cleanupTasks = Array.from(teardownRegistrations.entries()).map(([examId, expectedRegistration]) => (
+ this.cleanupExamSession(examId, { expectedRegistration })
+ ));
await Promise.allSettled(cleanupTasks);
}
if (this.suiteExamMap) {
- session.sequence && session.sequence.forEach(item => this.suiteExamMap.delete(item.examId));
+ session.sequence && session.sequence.forEach(item => {
+ if (item && item.examId != null
+ && this.suiteExamMap.get(String(item.examId)) === session.id) {
+ this.suiteExamMap.delete(String(item.examId));
+ }
+ });
}
- if (this.currentSuiteSession && this.currentSuiteSession.id === session.id) {
+ if (this.currentSuiteSession && this._isSuiteSessionCurrentOwner(session)) {
+ if (session._suitePendingTerminalStatus) {
+ session.status = session._suitePendingTerminalStatus;
+ }
this.currentSuiteSession = null;
}
session.windowRef = null;
+ session._suiteTeardownInProgress = false;
+ delete session._suiteTeardownRegistrations;
+ delete session._suitePendingTerminalStatus;
if (typeof this._clearSuiteHandshakes === 'function') {
this._clearSuiteHandshakes();
}
- this._clearSessionStorage();
+ this._clearSessionStorage(session);
+ await this._releaseSuiteRecoveryClaim('single', session);
+ return true;
},
async _abortSuiteSession(session, options = {}) {
if (!session) {
- return;
+ return false;
}
-
- session.status = 'aborted';
-
- this._clearSuiteHandshakes();
-
- const skipExamId = options.skipExamId;
- if (session.results && session.results.length) {
- for (const entry of session.results) {
- if (skipExamId && entry.examId === skipExamId) {
- continue;
- }
- try {
- await this.saveRealPracticeData(entry.examId, this._buildSuiteEntryIndividualPayload(session, entry), { forceIndividualSave: true });
- this.updateExamStatus && this.updateExamStatus(entry.examId, 'completed');
- } catch (error) {
- console.error('[SuitePractice] 套题中断时保存记录失败:', error);
- }
+ if (session._finalizePromise && typeof session._finalizePromise.then === 'function') {
+ if (options.reason === 'user_discard') {
+ window.showMessage && window.showMessage('套题正在完成保存,请稍候。', 'warning');
}
- await this._updatePracticeRecordsState();
- this.refreshOverviewData && this.refreshOverviewData();
+ return false;
}
- await this._teardownSuiteSession(session);
+ session._suitePendingTerminalStatus = 'aborted';
+ const tornDown = await this._teardownSuiteSession(session);
+ if (!tornDown) {
+ delete session._suitePendingTerminalStatus;
+ // A failed user abort leaves an active suite free to navigate or
+ // rebind before the next attempt. Re-capture that live ownership
+ // instead of reusing the failed attempt's stale window snapshot.
+ // Completed receipt teardown retries intentionally keep theirs.
+ if (session.status !== 'completed') {
+ delete session._suiteTeardownRegistrations;
+ }
+ }
+ return tornDown;
},
_openNamedSuiteWindow(windowName, session = null) {
@@ -2849,7 +7712,7 @@
let reopened = null;
try {
- reopened = window.open('about:blank', normalizedName);
+ reopened = window.open('', normalizedName);
} catch (error) {
console.warn('[SuitePractice] 无法重建套题标签:', error);
reopened = null;
@@ -2876,6 +7739,347 @@
return this._openNamedSuiteWindow(windowName, session);
},
+ async _verifySuiteWindowBinding(candidate, session, targetEntry, binding) {
+ if (!candidate || candidate.closed || !session || !targetEntry || !binding
+ || typeof global.addEventListener !== 'function'
+ || typeof global.removeEventListener !== 'function'
+ || typeof candidate.postMessage !== 'function'
+ || typeof this.generateWindowSessionToken !== 'function') {
+ return false;
+ }
+ const challenge = this.generateWindowSessionToken(`rebind-${String(targetEntry.examId)}`);
+ const expectedSuiteId = String(session.id || '');
+ const expectedExamId = String(targetEntry.examId || '');
+ const expectedSessionId = String(binding.expectedSessionId || '');
+ const expectedToken = String(binding.windowSessionToken || '');
+ const expectedGeneration = Number(binding.sessionGeneration);
+ if (!challenge || !expectedSuiteId || !expectedExamId || !expectedSessionId
+ || !expectedToken || !Number.isInteger(expectedGeneration) || expectedGeneration <= 0) {
+ return false;
+ }
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (verified) => {
+ if (settled) return;
+ settled = true;
+ try { global.removeEventListener('message', onMessage); } catch (_) {}
+ if (timer) clearTimeout(timer);
+ resolve(Boolean(verified));
+ };
+ const onMessage = (event) => {
+ const envelope = event && event.data;
+ const data = envelope && envelope.data;
+ if (!envelope || String(envelope.type || '').toUpperCase() !== 'SUITE_REBIND_PROOF'
+ || String(envelope.source || '') !== 'practice_page'
+ || event.source !== candidate
+ || !data
+ || String(data.challenge || '') !== challenge
+ || String(data.suiteSessionId || '') !== expectedSuiteId
+ || String(data.examId || '') !== expectedExamId
+ || String(data.sessionId || '') !== expectedSessionId
+ || String(data.windowSessionToken || '') !== expectedToken
+ || Number(data.windowSessionGeneration) !== expectedGeneration) {
+ return;
+ }
+ finish(true);
+ };
+ const timer = setTimeout(() => finish(false), 800);
+ global.addEventListener('message', onMessage);
+ try {
+ candidate.postMessage({
+ type: 'SUITE_REBIND_CHALLENGE',
+ source: 'exam_host',
+ timestamp: Date.now(),
+ data: {
+ challenge,
+ suiteSessionId: expectedSuiteId,
+ examId: expectedExamId,
+ windowSessionToken: expectedToken
+ }
+ }, '*');
+ } catch (_) {
+ finish(false);
+ }
+ });
+ },
+
+ async _tryRebindSuiteWindow(session, targetEntry, options = {}) {
+ if (!session || !targetEntry || !targetEntry.examId || !session.windowBinding) return null;
+ const binding = session.windowBinding;
+ const bindingExamId = String(binding.examId || '').trim();
+ if (!bindingExamId
+ || !Array.isArray(session.sequence)
+ || !session.sequence.some((entry) => entry && String(entry.examId) === bindingExamId)
+ || !session.sequence.some((entry) => entry && String(entry.examId) === String(targetEntry.examId))) {
+ return null;
+ }
+ const expectedSessionId = typeof binding.expectedSessionId === 'string'
+ ? binding.expectedSessionId.trim()
+ : '';
+ const previousToken = typeof binding.windowSessionToken === 'string'
+ ? binding.windowSessionToken.trim()
+ : '';
+ const previousGeneration = Number(binding.sessionGeneration);
+ if (!expectedSessionId || !previousToken || !Number.isInteger(previousGeneration) || previousGeneration <= 0) {
+ return null;
+ }
+ const windowName = typeof options.windowName === 'string' && options.windowName.trim()
+ ? options.windowName
+ : (session.windowName || 'ielts-suite-mode-tab');
+ const suppliedLaunchOwnership = options.ownership || options.launchOwnership || null;
+ const launchOwnership = suppliedLaunchOwnership
+ || this._beginSuiteExamLaunchOwnership(targetEntry.examId, { windowName });
+ let launchReservationSettled = false;
+ const rollbackRebindLaunchOwnership = () => {
+ if (launchReservationSettled) return false;
+ launchReservationSettled = true;
+ if (!launchOwnership
+ || typeof this._rollbackExamLaunchOwnership !== 'function') {
+ return false;
+ }
+ return this._rollbackExamLaunchOwnership(launchOwnership) === true;
+ };
+ const abortRebindReservation = () => {
+ rollbackRebindLaunchOwnership();
+ return null;
+ };
+ const allowSuppliedFallback = () => {
+ if (!suppliedLaunchOwnership
+ || launchOwnership !== suppliedLaunchOwnership
+ || !this._isSuiteExamLaunchOwnershipCurrent(targetEntry.examId, launchOwnership)) {
+ return abortRebindReservation();
+ }
+ launchReservationSettled = true;
+ return {
+ window: null,
+ ownership: launchOwnership,
+ fallbackAllowed: true
+ };
+ };
+ try {
+ if (!launchOwnership
+ || !this._isSuiteExamLaunchOwnershipCurrent(targetEntry.examId, launchOwnership)) {
+ return abortRebindReservation();
+ }
+ if (!await this._ensureSuiteRecoveryClaim('single', session)
+ || this.currentSuiteSession !== session
+ || session.windowBinding !== binding
+ || !this._isSuiteExamLaunchOwnershipCurrent(targetEntry.examId, launchOwnership)) {
+ return abortRebindReservation();
+ }
+ const candidate = this._reacquireSuiteWindow(windowName, null);
+ if (!candidate || candidate.closed) return allowSuppliedFallback();
+ let candidateIsBlank = false;
+ try {
+ candidateIsBlank = !candidate.location || candidate.location.href === 'about:blank';
+ } catch (_) {
+ candidateIsBlank = false;
+ }
+ if (candidateIsBlank) {
+ const existingLaunchOwner = this._examLaunchWindowOwnerships
+ && this._examLaunchWindowOwnerships.get(candidate);
+ if (!existingLaunchOwner && typeof this._safelyCloseWindow === 'function') {
+ // window.open('', name) creates about:blank when no named target
+ // exists. Close only that unmanaged probe; an ordinary launch that
+ // already owns the proxy must remain untouched.
+ this._safelyCloseWindow(candidate);
+ }
+ return allowSuppliedFallback();
+ }
+ if (typeof this._captureExamSessionRegistration !== 'function'
+ || typeof this._isExamSessionRegistrationCurrent !== 'function') {
+ return abortRebindReservation();
+ }
+ const challengedBinding = binding;
+ const challengedRegistrations = [];
+ const challengedEntries = this.examWindows && typeof this.examWindows.entries === 'function'
+ ? Array.from(this.examWindows.entries())
+ : [];
+ for (const [registeredExamId, windowInfo] of challengedEntries) {
+ if (!windowInfo || windowInfo.window !== candidate) continue;
+ const registration = this._captureExamSessionRegistration(registeredExamId, windowInfo);
+ if (!registration) return abortRebindReservation();
+ challengedRegistrations.push({
+ examId: registeredExamId,
+ registration
+ });
+ }
+ const challengedNavigationOwnership = this._examWindowCommittedNavigationOwners
+ && typeof this._examWindowCommittedNavigationOwners.get === 'function'
+ ? this._examWindowCommittedNavigationOwners.get(candidate) || null
+ : null;
+ const challengedCandidateStillCurrent = (expectedBinding = challengedBinding) => {
+ if (this.currentSuiteSession !== session
+ || !this._ownsSuiteRecoveryClaim('single', session)
+ || !this._isSuiteExamLaunchOwnershipCurrent(targetEntry.examId, launchOwnership)
+ || session.windowBinding !== expectedBinding
+ || !candidate
+ || candidate.closed) {
+ return false;
+ }
+ const currentEntries = this.examWindows && typeof this.examWindows.entries === 'function'
+ ? Array.from(this.examWindows.entries())
+ : [];
+ const currentCandidateEntries = currentEntries
+ .filter(([, windowInfo]) => windowInfo && windowInfo.window === candidate);
+ if (currentCandidateEntries.length !== challengedRegistrations.length) {
+ return false;
+ }
+ if (!challengedRegistrations.every(({ examId, registration }) => (
+ registration.window === candidate
+ && this._isExamSessionRegistrationCurrent(examId, registration) === true
+ ))) {
+ return false;
+ }
+ const currentNavigationOwnership = this._examWindowCommittedNavigationOwners
+ && typeof this._examWindowCommittedNavigationOwners.get === 'function'
+ ? this._examWindowCommittedNavigationOwners.get(candidate) || null
+ : null;
+ return currentNavigationOwnership === challengedNavigationOwnership;
+ };
+ if (!challengedCandidateStillCurrent()) return abortRebindReservation();
+ let bindingVerified = false;
+ try {
+ bindingVerified = await this._verifySuiteWindowBinding(candidate, session, targetEntry, binding);
+ } catch (error) {
+ throw error;
+ }
+ if (!challengedCandidateStillCurrent()) {
+ return abortRebindReservation();
+ }
+ if (!bindingVerified) {
+ session._suiteWindowNameConflict = true;
+ return abortRebindReservation();
+ }
+ // Reserve the target name before the asynchronous proof, but do not claim
+ // the candidate WindowProxy or replace its installed registration until
+ // the proof succeeds. A newer launch reservation therefore invalidates
+ // this continuation without disturbing the page currently in the tab.
+ if (!this._isSuiteExamLaunchOwnershipCurrent(targetEntry.examId, launchOwnership)
+ || !this._claimSuiteExamLaunchWindow(launchOwnership, candidate)
+ || !this._isSuiteExamLaunchOwnershipCurrent(targetEntry.examId, launchOwnership, candidate)
+ || !challengedCandidateStillCurrent()) {
+ return abortRebindReservation();
+ }
+ if (typeof this.setupExamWindowManagement !== 'function'
+ || typeof this.generateWindowSessionToken !== 'function') {
+ return abortRebindReservation();
+ }
+ const nextToken = this.generateWindowSessionToken(targetEntry.examId);
+ const nextGeneration = previousGeneration + 1;
+ const previousBinding = this._cloneSuitePlainObject(binding);
+ const nextBinding = {
+ examId: String(targetEntry.examId),
+ expectedSessionId,
+ windowSessionToken: nextToken,
+ sessionGeneration: nextGeneration,
+ expectedUrl: binding.expectedUrl || '',
+ expectedOrigin: binding.expectedOrigin || '',
+ allowOpaqueOrigin: binding.allowOpaqueOrigin === true
+ };
+ const rebindStillOwned = () => this.currentSuiteSession === session
+ && this._ownsSuiteRecoveryClaim('single', session)
+ && session.windowBinding === nextBinding
+ && challengedCandidateStillCurrent(nextBinding)
+ && this._isSuiteExamLaunchOwnershipCurrent(
+ targetEntry.examId,
+ launchOwnership,
+ candidate
+ );
+ session.windowBinding = nextBinding;
+ let rebindDurableReceiptConfirmed = false;
+ let rebindCommitted = false;
+ try {
+ rebindCommitted = await this._commitSuiteRecovery(session, {
+ reason: 'window-rebind',
+ commitGuard: rebindStillOwned,
+ windowBindingSnapshotOverride: nextBinding,
+ onDurableReceipt: () => { rebindDurableReceiptConfirmed = true; }
+ });
+ } catch (error) {
+ if (!rebindDurableReceiptConfirmed
+ && this.currentSuiteSession === session
+ && this._ownsSuiteRecoveryClaim('single', session)
+ && session.windowBinding === nextBinding) {
+ session.windowBinding = previousBinding;
+ }
+ throw error;
+ }
+ if (!rebindCommitted || !rebindStillOwned()) {
+ if (!rebindDurableReceiptConfirmed
+ && this.currentSuiteSession === session
+ && this._ownsSuiteRecoveryClaim('single', session)
+ && session.windowBinding === nextBinding) {
+ session.windowBinding = previousBinding;
+ }
+ return abortRebindReservation();
+ }
+ if (!rebindStillOwned()) return abortRebindReservation();
+ const setupOptions = {
+ target: 'tab',
+ expectedUrl: nextBinding.expectedUrl,
+ suiteSessionId: session.id,
+ suiteFlowMode: session.flowMode || 'simulation',
+ suiteTimerMode: session.suiteTimerMode || 'countdown',
+ suiteTimerLimitSeconds: Number.isFinite(Number(session.suiteTimerLimitSeconds))
+ ? Number(session.suiteTimerLimitSeconds)
+ : 3600,
+ sequenceIndex: session.currentIndex,
+ sequenceTotal: session.sequence.length,
+ adoptWindowBinding: {
+ expectedSessionId,
+ windowSessionToken: nextToken,
+ sessionGeneration: nextGeneration
+ }
+ };
+ if (!rebindStillOwned()) return abortRebindReservation();
+ const reboundRegistration = this.setupExamWindowManagement(
+ candidate,
+ targetEntry.examId,
+ targetEntry.exam,
+ setupOptions
+ );
+ const reboundInfo = reboundRegistration && reboundRegistration.windowInfo;
+ if (!reboundInfo
+ || reboundInfo.window !== candidate
+ || reboundInfo.expectedSessionId !== expectedSessionId
+ || reboundInfo.windowSessionToken !== nextToken
+ || reboundInfo.sessionGeneration !== nextGeneration
+ || String(reboundInfo.suiteSessionId || '') !== String(session.id)) {
+ return abortRebindReservation();
+ }
+ if (!reboundRegistration
+ || !this._isSuiteNavigationRegistrationCurrent(
+ targetEntry.examId,
+ reboundRegistration,
+ session
+ )) {
+ return abortRebindReservation();
+ }
+ if (typeof this._commitExamLaunchOwnership !== 'function'
+ || this._commitExamLaunchOwnership(launchOwnership) !== true) {
+ return abortRebindReservation();
+ }
+ launchReservationSettled = true;
+ if (!this._isSuiteNavigationRegistrationCurrent(
+ targetEntry.examId,
+ reboundRegistration,
+ session
+ )) {
+ return abortRebindReservation();
+ }
+ session.windowBinding = nextBinding;
+ return {
+ window: candidate,
+ binding: session.windowBinding,
+ ownership: launchOwnership,
+ registration: reboundRegistration
+ };
+ } finally {
+ rollbackRebindLaunchOwnership();
+ }
+ },
+
_ensureSuiteWindowGuard(session, targetWindow) {
if (!session || !targetWindow || targetWindow.closed) {
return;
@@ -2907,6 +8111,11 @@
const recordAttempt = (reason) => {
this._recordSuiteCloseAttempt(session, reason);
+ if (session.status === 'completed' && typeof this._teardownSuiteSession === 'function') {
+ this._teardownSuiteSession(session).catch((teardownError) => {
+ console.warn('[SuitePractice] 已完成套题窗口清理失败:', teardownError);
+ });
+ }
};
const isSelfTarget = (rawTarget) => {
@@ -2968,7 +8177,7 @@
}
},
- _releaseSuiteWindowGuard(targetWindow) {
+ _releaseSuiteWindowGuard(targetWindow, expectedSuiteSessionId = '') {
if (!targetWindow) {
return;
}
@@ -2989,6 +8198,11 @@
if (!guardInfo) {
return;
}
+ const normalizedExpectedSuiteSessionId = String(expectedSuiteSessionId || '').trim();
+ if (normalizedExpectedSuiteSessionId
+ && String(guardInfo.sessionId || '') !== normalizedExpectedSuiteSessionId) {
+ return;
+ }
try {
if (guardInfo.nativeClose) {
@@ -3043,13 +8257,16 @@
* @param {string} examId - 考试ID(基础ID,不含套题后缀)
* @returns {object} 多套题会话对象
*/
- getOrCreateMultiSuiteSession(examId) {
+ getOrCreateMultiSuiteSession(examId, options = {}) {
if (!this.multiSuiteSessionsMap) {
this.multiSuiteSessionsMap = new Map();
}
// 提取基础examId(移除可能的套题后缀如 _set1, _suite1 等)
- const baseExamId = this._extractBaseExamId(examId);
+ const baseExamId = String(this._extractBaseExamId(examId) || '').trim();
+ if (!baseExamId) {
+ return null;
+ }
if (this.multiSuiteSessionsMap.has(baseExamId)) {
return this.multiSuiteSessionsMap.get(baseExamId);
@@ -3069,7 +8286,10 @@
}
};
- this.multiSuiteSessionsMap.set(baseExamId, session);
+ if (options.install !== false) {
+ this.multiSuiteSessionsMap.set(baseExamId, session);
+ this._mirrorMultiSuiteSessionsToStorage();
+ }
console.log('[MultiSuite] 创建新会话:', session.id, '基础ID:', baseExamId);
return session;
@@ -3175,9 +8395,3 @@
global.ExamSystemAppMixins = global.ExamSystemAppMixins || {};
global.ExamSystemAppMixins.suitePractice = mixin;
})(typeof window !== 'undefined' ? window : globalThis);
-
-
-
-
-
-
diff --git a/js/boot-fallbacks.js b/js/boot-fallbacks.js
index 8d4dd6d8..66f8531a 100644
--- a/js/boot-fallbacks.js
+++ b/js/boot-fallbacks.js
@@ -37,7 +37,6 @@
});
}
- var storage = window.storage;
// Fallback for navigation
if (typeof window.showView !== 'function') {
window.showView = function (viewName, resetCategory) {
@@ -98,7 +97,6 @@
if (normalized === 'practice' && typeof window.ensurePracticeRecordsSync === 'function') {
window.ensurePracticeRecordsSync('practice-view').catch(function () { });
}
- if (normalized === 'practice' && typeof window.updatePracticeView === 'function') window.updatePracticeView();
};
}
@@ -142,53 +140,24 @@
return fn.name === 'lazyProxy' || src.indexOf('ensureLazyGroup') !== -1 || src.indexOf('AppLazyLoader') !== -1;
};
- function _ensureFallbackDataIntegrityManager() {
- if (!window.dataIntegrityManager && window.DataIntegrityManager) {
- try {
- window.dataIntegrityManager = new window.DataIntegrityManager();
- } catch (error) {
- console.warn('[Fallback] 初始化 DataIntegrityManager 失败:', error);
- }
- }
- return window.dataIntegrityManager || null;
+ function _fallbackDownloadJson(data, filename) {
+ var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json; charset=utf-8' });
+ var url = URL.createObjectURL(blob);
+ var anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = filename;
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
+ URL.revokeObjectURL(url);
}
- var _fallbackDataIntegrityLoadPromise = null;
-
- function _ensureFallbackDataIntegrityManagerAsync() {
- var manager = _ensureFallbackDataIntegrityManager();
- if (manager) {
- return Promise.resolve(manager);
- }
-
- if (!_fallbackDataIntegrityLoadPromise) {
- if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') {
- _fallbackDataIntegrityLoadPromise = window.AppLazyLoader.ensureGroup('settings-tools');
- } else if (typeof document !== 'undefined' && !window.DataIntegrityManager) {
- _fallbackDataIntegrityLoadPromise = new Promise(function (resolve, reject) {
- var script = document.createElement('script');
- script.src = 'js/components/DataIntegrityManager.js';
- script.onload = resolve;
- script.onerror = function (error) {
- reject(error || new Error('failed to load DataIntegrityManager'));
- };
- document.head.appendChild(script);
- });
- } else {
- _fallbackDataIntegrityLoadPromise = Promise.resolve();
- }
- }
-
- return _fallbackDataIntegrityLoadPromise.then(function () {
- var readyManager = _ensureFallbackDataIntegrityManager();
- if (!readyManager) {
- throw new Error('数据管理模块未初始化');
- }
- return readyManager;
- }).catch(function (error) {
- _fallbackDataIntegrityLoadPromise = null;
- throw error;
- });
+ async function _fallbackExportAllData() {
+ await window.AppData.ready;
+ var snapshot = await window.AppData.backups.export();
+ _fallbackDownloadJson(snapshot, 'ielts-atlas-backup-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json');
+ try { await window.AppData.backups.recordExport({ type: 'full-v2', checksum: snapshot.checksum }); } catch (error) { console.warn('[Fallback] 导出历史记录失败:', error); }
+ return snapshot;
}
function _fallbackCreateElement(tag, attributes, children) {
@@ -291,21 +260,13 @@
return;
}
- var manager = null;
- try {
- manager = await _ensureFallbackDataIntegrityManagerAsync();
- } catch (error) {
- window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error');
- return;
- }
-
if (!confirm('确定要恢复备份 ' + backupId + ' 吗?当前数据将被覆盖。')) {
return;
}
try {
window.showMessage && window.showMessage('正在恢复备份...', 'info');
- await manager.restoreBackup(backupId);
+ await window.AppData.backups.restore(backupId);
window.showMessage && window.showMessage('备份恢复成功', 'success');
setTimeout(function () {
try {
@@ -384,30 +345,6 @@
};
}
- var ensureDataBackupManager = (function () {
- let loading = null;
- return function ensureDataBackupManager() {
- if (window.DataBackupManager) {
- return Promise.resolve(new window.DataBackupManager());
- }
- if (loading) {
- return loading.then(() => new window.DataBackupManager());
- }
- if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') {
- loading = window.AppLazyLoader.ensureGroup('settings-tools');
- return loading.then(() => new window.DataBackupManager());
- }
- loading = new Promise((resolve, reject) => {
- const script = document.createElement('script');
- script.src = 'js/utils/dataBackupManager.js';
- script.onload = () => resolve();
- script.onerror = (err) => reject(err || new Error('failed to load dataBackupManager'));
- document.head.appendChild(script);
- });
- return loading.then(() => new window.DataBackupManager());
- };
- })();
-
function showImportModeModal(onSelect) {
const overlay = document.createElement('div');
overlay.className = 'import-mode-overlay-lite';
@@ -431,7 +368,7 @@
const defs = [
{ mode: 'merge', icon: '📥', title: '增量导入', text: '合并新数据,保留现有记录。适合日常更新。' },
- { mode: 'replace', icon: '⚠️', title: '覆盖导入', text: '清空并替换所有记录。慎用,数据不可恢复。' }
+ { mode: 'replace', icon: '⚠️', title: '覆盖练习记录', text: '仅用文件中的练习记录替换现有记录;提交前会显示删除数量。' }
];
defs.forEach((def) => {
@@ -594,12 +531,29 @@
return;
}
try {
- const manager = await ensureDataBackupManager();
- const result = await manager.importPracticeData(data, {
- mergeMode: mode === 'replace' ? 'replace' : 'merge',
- createBackup: true,
- validateData: true
+ const payload = Array.isArray(data) ? { records: data } : data;
+ const preview = await window.AppData.backups.previewImport(payload, { practiceMode: mode === 'replace' ? 'replace' : 'merge' });
+ if (preview.destructive) {
+ const practice = preview.practice || {};
+ const summary = [
+ '这次导入会删除现有数据。',
+ `练习记录:现有 ${Number(practice.existingCount) || 0} 条 → 导入后 ${Number(practice.finalCount) || 0} 条`,
+ `将删除 ${Number(practice.removedCount) || 0} 条。`
+ ];
+ if (Array.isArray(preview.clearedKeys) && preview.clearedKeys.length) {
+ summary.push(`将清空数据域:${preview.clearedKeys.join('、')}`);
+ }
+ summary.push('', '是否确认继续?');
+ if (!window.confirm(summary.join('\n'))) {
+ window.showMessage && window.showMessage('已取消导入,现有数据未改变', 'info');
+ return;
+ }
+ }
+ const backup = await window.AppData.backups.create({ type: 'pre-import' });
+ const result = await window.AppData.backups.commitImport(preview.id, {
+ confirmDestructive: preview.destructive === true
});
+ try { await window.AppData.backups.recordImport({ type: preview.format, keys: preview.keys, backupId: backup.id, practice: preview.practice }); } catch (historyError) { console.warn('[Fallback] 导入历史记录失败:', historyError); }
window.showMessage && window.showMessage(`导入成功:新增 ${result.importedCount || 0} 条,跳过 ${result.skippedCount || 0} 条。`, 'success');
} catch (error) {
console.error('[importData] failed', error);
@@ -611,17 +565,8 @@
if (typeof window.exportAllData !== 'function') {
window.exportAllData = async function () {
- var manager = null;
try {
- manager = await _ensureFallbackDataIntegrityManagerAsync();
- } catch (error) {
- console.error('[Fallback] 数据导出模块加载失败:', error);
- window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error');
- return;
- }
-
- try {
- await manager.exportData();
+ await _fallbackExportAllData();
window.showMessage && window.showMessage('数据导出成功', 'success');
} catch (error) {
console.error('[Fallback] 数据导出失败:', error);
@@ -656,25 +601,14 @@
// Fallbacks for backup operations used by Settings
if (typeof window.createManualBackup !== 'function') {
window.createManualBackup = async function () {
- var manager = null;
try {
- manager = await _ensureFallbackDataIntegrityManagerAsync();
- } catch (error) {
- window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error');
- return;
- }
- try {
- var backup = await manager.createBackup(null, 'manual');
- if (backup && backup.external) {
- window.showMessage && window.showMessage('本地存储不足,已将备份下载为文件', 'warning');
- } else {
- window.showMessage && window.showMessage('备份创建成功: ' + (backup && backup.id ? backup.id : ''), 'success');
- }
+ var backup = await window.AppData.backups.create({ type: 'manual' });
+ window.showMessage && window.showMessage('备份创建成功: ' + (backup && backup.id ? backup.id : ''), 'success');
try { if (typeof window.showBackupList === 'function') { window.showBackupList(); } } catch (_) { }
} catch (error) {
if (_fallbackIsQuotaExceeded(error)) {
try {
- await manager.exportData();
+ await _fallbackExportAllData();
window.showMessage && window.showMessage('存储不足:已将数据导出为文件', 'warning');
} catch (exportErr) {
window.showMessage && window.showMessage('备份失败且导出失败: ' + (exportErr && exportErr.message ? exportErr.message : exportErr), 'error');
@@ -688,18 +622,10 @@
if (typeof window.showBackupList !== 'function') {
window.showBackupList = async function () {
- var manager = null;
- try {
- manager = await _ensureFallbackDataIntegrityManagerAsync();
- } catch (error) {
- window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error');
- return;
- }
-
_ensureFallbackBackupDelegates();
var backups = [];
try {
- backups = await manager.getBackupList();
+ backups = await window.AppData.backups.list();
} catch (error) {
console.warn('[Fallback] 获取备份列表失败:', error);
window.showMessage && window.showMessage('无法获取备份列表', 'error');
@@ -798,38 +724,11 @@
async function ensureDefaultConfig() {
try {
- var configs = [];
- if (window.storage && storage.get) {
- var maybeConfigs = storage.get('exam_index_configurations', []);
- configs = (maybeConfigs && typeof maybeConfigs.then === 'function') ? await maybeConfigs : maybeConfigs;
- }
+ var configs = await window.AppData.library.listConfigurations();
if (!Array.isArray(configs)) configs = [];
- var hasDefault = configs.some(function (c) { return c && c.key === 'exam_index'; });
- if (!hasDefault) {
- var count = Array.isArray(window.examIndex) ? window.examIndex.length : 0;
- configs.push({ name: '默认题库', key: 'exam_index', examCount: count, timestamp: Date.now() });
- if (window.storage && storage.set) {
- try {
- var maybeSetConfigs = storage.set('exam_index_configurations', configs);
- if (maybeSetConfigs && typeof maybeSetConfigs.then === 'function') await maybeSetConfigs;
- } catch (err) {
- console.warn('[Fallback] 无法保存 exam_index_configurations:', err);
- }
- }
- if (window.storage && storage.get) {
- try {
- var currentActive = storage.get('active_exam_index_key');
- currentActive = (currentActive && typeof currentActive.then === 'function') ? await currentActive : currentActive;
- if (!currentActive && window.storage && storage.set) {
- var maybeSetActive = storage.set('active_exam_index_key', 'exam_index');
- if (maybeSetActive && typeof maybeSetActive.then === 'function') await maybeSetActive;
- }
- } catch (activeErr) {
- console.warn('[Fallback] 无法校正 active_exam_index_key:', activeErr);
- }
- }
- }
- return configs;
+ var activeIndex = await window.resolveActiveLibraryIndex();
+ var count = Array.isArray(activeIndex) ? activeIndex.length : 0;
+ return [{ name: '默认题库', key: '', id: null, builtIn: true, sourceType: 'built-in-manifest', examCount: count }].concat(configs);
} catch (e) {
console.warn('[Fallback] ensureDefaultConfig 失败:', e);
return [];
@@ -856,23 +755,18 @@
window.showLibraryConfigListV2 = async function (options) {
var configs = [];
try {
- configs = (window.storage && storage.get) ? await storage.get('exam_index_configurations', []) : [];
+ configs = await ensureDefaultConfig();
} catch (e) {
configs = [];
}
- if (!Array.isArray(configs) || configs.length === 0) {
- configs = await ensureDefaultConfig();
- }
if (!Array.isArray(configs) || configs.length === 0) {
if (window.showMessage) showMessage('暂无题库配置记录', 'info');
return;
}
- var activeKey = 'exam_index';
+ var activeKey = null;
try {
- if (window.storage && storage.get) {
- activeKey = await storage.get('active_exam_index_key', 'exam_index');
- }
+ activeKey = await window.AppData.library.getActive();
} catch (e) { }
var containerId = options && typeof options.containerId === 'string' ? options.containerId : null;
@@ -915,12 +809,14 @@
configs.forEach(function (cfg) {
if (!cfg) return;
var item = document.createElement('div');
- item.className = 'library-config-panel__item' + (cfg.key === activeKey ? ' library-config-panel__item--active' : '');
+ var isDefault = cfg.builtIn === true;
+ var isActive = isDefault ? activeKey == null : cfg.key === activeKey;
+ item.className = 'library-config-panel__item' + (isActive ? ' library-config-panel__item--active' : '');
var info = document.createElement('div');
info.className = 'library-config-panel__info';
var titleLine = document.createElement('div');
- titleLine.textContent = (cfg.key === 'exam_index' ? '默认题库' : (cfg.name || cfg.key));
+ titleLine.textContent = (isDefault ? '默认题库' : (cfg.name || cfg.key));
info.appendChild(titleLine);
var meta = document.createElement('div');
@@ -938,18 +834,18 @@
switchBtn.className = 'btn btn-secondary';
switchBtn.type = 'button';
switchBtn.dataset.configAction = 'switch';
- switchBtn.dataset.configKey = cfg.key;
- if (cfg.key === activeKey) switchBtn.disabled = true;
+ switchBtn.dataset.configKey = cfg.key || '';
+ if (isActive) switchBtn.disabled = true;
switchBtn.textContent = '切换';
actions.appendChild(switchBtn);
- if (cfg.key !== 'exam_index') {
+ if (!isDefault) {
var deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-warning';
deleteBtn.type = 'button';
deleteBtn.dataset.configAction = 'delete';
- deleteBtn.dataset.configKey = cfg.key;
- if (cfg.key === activeKey) deleteBtn.disabled = true;
+ deleteBtn.dataset.configKey = cfg.key || '';
+ if (isActive) deleteBtn.disabled = true;
deleteBtn.textContent = '删除';
actions.appendChild(deleteBtn);
}
@@ -1326,29 +1222,14 @@
if (typeof window.getActiveLibraryConfigurationKey === 'function') {
try { return await window.getActiveLibraryConfigurationKey(); } catch (_) { }
}
- if (storage && storage.get) {
- try {
- var maybeKey = storage.get('active_exam_index_key', 'exam_index');
- var key = (maybeKey && typeof maybeKey.then === 'function') ? await maybeKey : maybeKey;
- return key || 'exam_index';
- } catch (_) { }
- }
- return 'exam_index';
+ return window.AppData.library.getActive();
}
async function _fallbackSetActiveLibraryKey(key) {
- if (!key) return;
if (typeof window.setActiveLibraryConfiguration === 'function') {
try { await window.setActiveLibraryConfiguration(key); return; } catch (_) { }
}
- if (storage && storage.set) {
- try {
- var maybe = storage.set('active_exam_index_key', key);
- if (maybe && typeof maybe.then === 'function') await maybe;
- } catch (err) {
- console.warn('[Fallback] 无法写入 active_exam_index_key:', err);
- }
- }
+ await window.AppData.library.activate(typeof key === 'string' && key.trim() ? key.trim() : null);
}
async function _fallbackSaveLibraryConfiguration(name, key, count) {
@@ -1356,51 +1237,28 @@
if (typeof window.saveLibraryConfiguration === 'function') {
try { await window.saveLibraryConfiguration(name, key, count); return; } catch (_) { }
}
- if (storage && storage.get && storage.set) {
- try {
- var existing = storage.get('exam_index_configurations', []);
- existing = (existing && typeof existing.then === 'function') ? await existing : existing;
- if (!Array.isArray(existing)) existing = [];
- var idx = existing.findIndex(function (c) { return c && c.key === key; });
- if (idx >= 0) { existing[idx] = entry; } else { existing.push(entry); }
- var maybeSave = storage.set('exam_index_configurations', existing);
- if (maybeSave && typeof maybeSave.then === 'function') await maybeSave;
- } catch (err) {
- console.warn('[Fallback] 保存题库配置失败:', err);
- }
- }
+ if (key) await window.AppData.library.updateConfiguration(entry);
}
async function _fallbackSaveIndexForKey(key, list) {
- if (storage && storage.set) {
- var maybe = storage.set(key, list);
- if (maybe && typeof maybe.then === 'function') {
- await maybe;
- }
- } else {
- try { window[key] = list; } catch (_) { }
- }
+ if (key) await window.AppData.library.import({ id: key, configuration: { id: key, key: key, name: key }, index: list });
}
async function _fallbackApplyLibraryConfig(key, dataset, options) {
if (typeof window.applyLibraryConfiguration === 'function') {
try { return await window.applyLibraryConfiguration(key, dataset, options || {}); } catch (_) { }
}
- // fallback:直接刷新内存状态与UI
- if (typeof window.setExamIndexState === 'function') {
- try { window.setExamIndexState(dataset); } catch (_) { }
- } else {
- try { window.examIndex = Array.isArray(dataset) ? dataset.slice() : []; } catch (_) { }
- }
+ var snapshot = Array.isArray(dataset) ? dataset.slice() : [];
if (options && options.setActive) {
await _fallbackSetActiveLibraryKey(key);
}
- try { if (typeof window.updateOverview === 'function') window.updateOverview(); } catch (_) { }
+ try { if (typeof window.updateOverview === 'function') window.updateOverview(snapshot); } catch (_) { }
try {
if (typeof window.loadExamList === 'function') {
- window.loadExamList();
+ window.loadExamList(snapshot);
}
} catch (_) { }
+ try { window.dispatchEvent(new CustomEvent('examIndexLoaded', { detail: { key: key, index: snapshot } })); } catch (_) { }
return true;
}
@@ -1611,15 +1469,7 @@
}
var activeKey = await _fallbackGetActiveLibraryKey();
- var currentIndex = (typeof window.getExamIndexState === 'function')
- ? window.getExamIndexState()
- : (Array.isArray(window.examIndex) ? window.examIndex : []);
- if (storage && storage.get) {
- try {
- var maybeCurrent = storage.get(activeKey, currentIndex);
- currentIndex = (maybeCurrent && typeof maybeCurrent.then === 'function') ? await maybeCurrent : maybeCurrent;
- } catch (_) { }
- }
+ var currentIndex = await window.resolveActiveLibraryIndex();
if (!Array.isArray(currentIndex)) currentIndex = [];
currentIndex = _fallbackNormalizeIndexForCustomConfig(currentIndex);
@@ -1662,7 +1512,7 @@
};
if (mode === 'full') {
- var targetKey = 'exam_index_' + Date.now();
+ var targetKey = 'library_import_' + Date.now();
var configName = (type === 'reading' ? '阅读' : '听力') + '全量-' + new Date().toLocaleString();
try {
await saveAndApply(targetKey, configName, true);
@@ -1690,7 +1540,7 @@
}
}
- var targetKeyInc = 'exam_index_' + Date.now();
+ var targetKeyInc = 'library_import_' + Date.now();
var configNameInc = (type === 'reading' ? '阅读' : '听力') + '增量-' + new Date().toLocaleString();
await saveAndApply(targetKeyInc, configNameInc, false);
await _fallbackApplyLibraryConfig(targetKeyInc, newIndex, { setActive: true, skipConfigRefresh: false });
diff --git a/js/bundles/browse.bundle.js b/js/bundles/browse.bundle.js
index e5129cff..9b89820f 100644
--- a/js/bundles/browse.bundle.js
+++ b/js/bundles/browse.bundle.js
@@ -233,12 +233,11 @@
if (!record) {
return false;
}
- var exam = index.find(function (item) {
- return item && (item.id === record.examId || item.title === record.title);
- });
- var examType = exam ? normalizeTypeValue(exam.type) : '';
- if (examType) {
- return examType === targetType;
+ var suiteEntries = ensureArray(record.suiteEntrySummaries);
+ if (suiteEntries.length) {
+ return suiteEntries.some(function (entry) {
+ return normalizeTypeValue(entry && entry.type) === targetType;
+ });
}
var recordType = normalizeTypeValue(
record.type ||
@@ -249,6 +248,13 @@
if (recordType) {
return recordType === targetType;
}
+ var exam = index.find(function (item) {
+ return item && (item.id === record.examId || item.title === record.title);
+ });
+ var examType = exam ? normalizeTypeValue(exam.type) : '';
+ if (examType) {
+ return examType === targetType;
+ }
// 无法确定类型时保持展示,避免题库切换导致历史记录被过滤掉
return true;
});
@@ -299,7 +305,9 @@
if (typeof value !== 'number' || isNaN(value)) {
return '0.0%';
}
- return value.toFixed(1) + '%';
+ // Practice-record summary UI: keep a single decimal place so
+ // correct/total ratios do not dump long floating tails into the list.
+ return (Math.round(value * 10) / 10).toFixed(1) + '%';
}
function formatMinutes(minutes) {
@@ -881,6 +889,21 @@
return used;
}
+ function addProjectedErrorCounts(counts, projectedCounts) {
+ if (!projectedCounts || typeof projectedCounts !== 'object') {
+ return false;
+ }
+ var used = false;
+ Object.keys(projectedCounts).forEach(function addProjected(type) {
+ var value = Math.max(0, Number(projectedCounts[type]) || 0);
+ if (value > 0) {
+ addRadarCount(counts, type, value);
+ used = true;
+ }
+ });
+ return used;
+ }
+
function addDetailCounts(counts, record) {
var questionTypeMap = buildReadingQuestionTypeMap(record);
var sources = getDetailSources(record);
@@ -907,7 +930,24 @@
function calculateReadingRadarData(records) {
var counts = {};
- var recentReadingRecords = ensureArray(records)
+ var radarCandidates = [];
+ ensureArray(records).forEach(function expandSuiteRecord(record) {
+ var suiteEntries = ensureArray(record && record.suiteEntrySummaries);
+ if (suiteEntries.length) {
+ suiteEntries.forEach(function addSuiteEntry(entry) {
+ if (!entry) {
+ return;
+ }
+ radarCandidates.push(Object.assign({}, entry, {
+ metadata: Object.assign({}, entry.metadata || {}, { type: entry.type }),
+ date: entry.date || (record && record.date)
+ }));
+ });
+ return;
+ }
+ radarCandidates.push(record);
+ });
+ var recentReadingRecords = radarCandidates
.filter(function filterReading(record) {
var metadata = record && record.metadata ? record.metadata : {};
var realData = record && record.realData ? record.realData : {};
@@ -927,6 +967,9 @@
.slice(0, 10);
recentReadingRecords.forEach(function collectRecord(record) {
+ if (addProjectedErrorCounts(counts, record && record.questionTypeErrorCounts)) {
+ return;
+ }
var performanceMap = record && (record.questionTypePerformance ||
(record.realData && record.realData.questionTypePerformance));
if (addPerformanceCounts(counts, performanceMap)) {
@@ -1550,31 +1593,24 @@
// 练习洞察卡片选中的组件(热力图 / 中高频余量 / 阅读雷达)持久化,
// 刷新或重开页面后沿用用户上次的选中组件,而不是总回到默认的热力图。
- var PRACTICE_WIDGET_PREFERENCE_KEY = 'practice_custom_widget';
var SUPPORTED_PRACTICE_WIDGETS = ['heatmap', 'priority', 'radar'];
+ var persistedPracticeWidget = null;
+ if (window.AppData && window.AppData.preferences) {
+ window.AppData.ready.then(function () { return window.AppData.preferences.getPracticeWidget(); }).then(function (value) {
+ persistedPracticeWidget = SUPPORTED_PRACTICE_WIDGETS.indexOf(value) >= 0 ? value : null;
+ }).catch(function () {});
+ }
function loadPersistedPracticeWidget() {
- try {
- if (typeof localStorage === 'undefined' || !localStorage) {
- return null;
- }
- var value = localStorage.getItem(PRACTICE_WIDGET_PREFERENCE_KEY);
- return SUPPORTED_PRACTICE_WIDGETS.indexOf(value) >= 0 ? value : null;
- } catch (_) {
- return null;
- }
+ return persistedPracticeWidget;
}
function persistPracticeWidget(widget) {
- try {
- if (typeof localStorage === 'undefined' || !localStorage) {
- return;
- }
- if (SUPPORTED_PRACTICE_WIDGETS.indexOf(widget) >= 0) {
- localStorage.setItem(PRACTICE_WIDGET_PREFERENCE_KEY, widget);
- }
- } catch (_) {
- /* 持久化失败不影响渲染 */
+ if (SUPPORTED_PRACTICE_WIDGETS.indexOf(widget) >= 0) {
+ persistedPracticeWidget = widget;
+ window.AppData.preferences.setPracticeWidget(widget).catch(function (error) {
+ console.warn('[PracticeWidget] 保存失败:', error);
+ });
}
}
@@ -2265,7 +2301,10 @@
var durationInSeconds = Number(record && record.duration) || 0;
var percentage = typeof record.percentage === 'number'
? record.percentage
- : Math.round((record.accuracy || 0) * 100);
+ : ((Number(record.accuracy) || 0) * 100);
+ if (!Number.isFinite(percentage)) {
+ percentage = 0;
+ }
var recordId = '';
if (record && record.id != null) {
@@ -2337,7 +2376,7 @@
createNode('div', {
className: 'record-percentage',
style: { color: helpers.getScoreColor(percentage) }
- }, percentage + '%')
+ }, formatPercentage(percentage))
]);
var actions = null;
@@ -3210,10 +3249,125 @@
};
}
+ var browseCompletionIndex = {
+ byExamId: new Map(),
+ byTitle: new Map(),
+ records: [],
+ ready: false
+ };
+
+ function rememberCompletionCandidate(map, key, candidate) {
+ if (!map || !key || !candidate) {
+ return;
+ }
+ var existing = map.get(key);
+ if (!existing || candidate.timestamp > existing.timestamp) {
+ map.set(key, candidate);
+ }
+ }
+
+ /**
+ * 在 setPracticeRecords 时重建一次正确率索引。
+ * 不使用 version 计数器;生命周期绑定“写状态那一次”。
+ */
+ function resolveRecordExamId(record) {
+ if (!record || typeof record !== 'object') {
+ return '';
+ }
+ var metadata = record.metadata && typeof record.metadata === 'object' ? record.metadata : {};
+ var realData = record.realData && typeof record.realData === 'object' ? record.realData : {};
+ var rawData = record.rawData && typeof record.rawData === 'object' ? record.rawData : {};
+ return record.examId || metadata.examId || realData.examId || rawData.examId || '';
+ }
+
+ function getBrowseSuiteEntries(record) {
+ if (!record || typeof record !== 'object') {
+ return [];
+ }
+ var summaries = Array.isArray(record.suiteEntrySummaries) ? record.suiteEntrySummaries : [];
+ if (summaries.length) {
+ return summaries;
+ }
+ return Array.isArray(record.suiteEntries) ? record.suiteEntries : [];
+ }
+
+ function rebuildBrowseCompletionIndex(records) {
+ var byExamId = new Map();
+ var byTitle = new Map();
+ var recordSnapshot = ensureArray(records).slice();
+ recordSnapshot.forEach(function indexRecord(record) {
+ if (!record || typeof record !== 'object') {
+ return;
+ }
+ var candidate = buildCompletionStatusCandidate(record);
+ var recordExamId = resolveRecordExamId(record);
+ if (recordExamId) {
+ rememberCompletionCandidate(byExamId, String(recordExamId), candidate);
+ }
+ var recordTitle = record.title || record.examTitle || (record.metadata && record.metadata.examTitle) || '';
+ if (recordTitle) {
+ rememberCompletionCandidate(byTitle, String(recordTitle), candidate);
+ }
+ var suiteEntries = getBrowseSuiteEntries(record);
+ suiteEntries.forEach(function indexSuiteEntry(entry) {
+ if (!entry || typeof entry !== 'object') {
+ return;
+ }
+ var comparableEntry = buildComparableSuiteEntryRecord(record, entry);
+ var entryCandidate = buildCompletionStatusCandidate(comparableEntry, record);
+ var entryExamId = resolveRecordExamId(comparableEntry);
+ if (entryExamId) {
+ rememberCompletionCandidate(byExamId, String(entryExamId), entryCandidate);
+ }
+ var entryTitle = comparableEntry.title || comparableEntry.examTitle || '';
+ if (entryTitle) {
+ rememberCompletionCandidate(byTitle, String(entryTitle), entryCandidate);
+ }
+ });
+ });
+ browseCompletionIndex = {
+ byExamId: byExamId,
+ byTitle: byTitle,
+ records: recordSnapshot,
+ ready: true
+ };
+ return browseCompletionIndex;
+ }
+
+ function ensureBrowseCompletionIndex() {
+ if (browseCompletionIndex.ready) {
+ return browseCompletionIndex;
+ }
+ return browseCompletionIndex;
+ }
+
LegacyExamListView.prototype._getCompletionStatus = function _getCompletionStatus(exam) {
- var source = (typeof global.getPracticeRecordsState === 'function')
- ? global.getPracticeRecordsState()
- : global.practiceRecords;
+ var index = ensureBrowseCompletionIndex();
+ var byId = null;
+ var byTitle = null;
+ if (exam && exam.id && index.byExamId.has(String(exam.id))) {
+ byId = index.byExamId.get(String(exam.id));
+ }
+ if (exam && exam.title && index.byTitle.has(String(exam.title))) {
+ byTitle = index.byTitle.get(String(exam.title));
+ }
+ // 同时有 examId / title 命中时取较新时间戳,避免旧 examId 遮蔽更新 title 匹配。
+ var indexed = null;
+ if (byId && byTitle) {
+ indexed = (Number(byId.timestamp) || 0) >= (Number(byTitle.timestamp) || 0) ? byId : byTitle;
+ } else {
+ indexed = byId || byTitle;
+ }
+ if (indexed) {
+ return {
+ percentage: typeof indexed.percentage === 'number' ? indexed.percentage : 0,
+ date: indexed.date || null,
+ duration: typeof indexed.duration === 'number' ? indexed.duration : 0
+ };
+ }
+
+ // Path/file fallback scans the same authoritative snapshot used to build the index.
+ var source = index.records;
var statuses = [];
ensureArray(source).forEach(function collectStatus(record) {
if (!record || typeof record !== 'object') {
@@ -3222,7 +3376,7 @@
if (recordMatchesExam(exam, record)) {
statuses.push(buildCompletionStatusCandidate(record));
}
- var suiteEntries = Array.isArray(record.suiteEntries) ? record.suiteEntries : [];
+ var suiteEntries = getBrowseSuiteEntries(record);
suiteEntries.forEach(function collectSuiteEntry(entry) {
if (!entry || typeof entry !== 'object') {
return;
@@ -3247,6 +3401,8 @@
};
};
+ global.rebuildBrowseCompletionIndex = rebuildBrowseCompletionIndex;
+
// --- Legacy navigation controller ---
function LegacyNavigationController(options) {
options = options || {};
@@ -3525,8 +3681,8 @@
};
LibraryConfigView.prototype._renderItem = function _renderItem(config, activeKey, allowDelete) {
- var isActive = activeKey === config.key;
- var isDefault = config.key === 'exam_index';
+ var isDefault = config.builtIn === true;
+ var isActive = isDefault ? activeKey == null : activeKey === config.key;
var className = this.classNames.item + (isActive ? ' ' + this.classNames.itemActive : '');
var item = this._createElement('div', {
@@ -3554,7 +3710,7 @@
type: 'button',
dataset: {
configAction: 'switch',
- configKey: config.key,
+ configKey: config.key || '',
configActive: isActive ? '1' : '0'
}
}, '切换');
@@ -3581,7 +3737,7 @@
type: 'button',
dataset: {
configAction: 'delete',
- configKey: config.key,
+ configKey: config.key || '',
configActive: isActive ? '1' : '0'
}
}, '删除');
@@ -3787,6 +3943,208 @@
})(window);
+/* ===== js/data/practiceRecordSource.js ===== */
+/**
+ * 练习记录来源判定 —— “什么算真实练习记录”的唯一权威定义。
+ *
+ * 背景(本文件存在的理由):
+ * 这条规则历史上被复制成了两套互不相通的实现,语义还不一样:
+ * - UI 侧 js/main.js `updatePracticeView` 只看顶层 `dataSource`;
+ * - 投影器侧 js/data/v2/appData.js `computeStats` / `computeAchievementProgress`
+ * 只看 `metadata.source === 'onboarding-demo'`。
+ * 结果是 `demo` / `e2e-seed` 这类记录“在练习记录页看不见,却计入成绩统计和成就解锁”,
+ * 用户会看到自己没做过的题影响了正确率与成就。
+ *
+ * 因此判定必须只有一份实现,并被所有消费方共享。本文件同时被打进
+ * core-foundation / reading-page / practice-page-enhancer / listening-record-bridge /
+ * listening-wrapper(供 appData.js 的投影器使用)和 browse(供 js/main.js 的渲染过滤使用)
+ * 等 bundle;appData.js 在启动时硬性要求本模块存在,缺失即抛错,杜绝“再退回本地副本”。
+ *
+ * ---------------------------------------------------------------------------
+ * 语义(两个维度,任一命中即判为非真实)
+ *
+ * 1) dataSource(顶层,回退 metadata.dataSource)
+ * - 缺失 / null / 空串 => **真实记录**
+ * - 'real' => 真实记录
+ * - 其它任何显式值 => 非真实(演示 / 种子 / 占位)
+ *
+ * “缺失即真实”是硬性约束,不得收窄:生产代码只在 practiceRecorder / examSessionMixin
+ * 三处写过该字段且都写 'real',套题聚合、听力桥接、legacy 迁移记录从来不写。
+ * 曾经有一版把“没标注”当成“非真实”,直接导致练习记录页整页空白(线上 P0)。
+ *
+ * 2) metadata.source
+ * 只精确匹配已知的演示/种子标记,**绝不做包含匹配**。
+ * 这个字段是被复用的:套题记录会写 'listening' / 'reading'(内容类型标签,见
+ * js/app/suitePracticeMixin.js),消息通道会写 'practice_page' / 'inline_collector'
+ * / 'suite_placeholder' / 'listening_record_bridge' / 'data_collector'(采集方式标签)。
+ * 任何模糊匹配都可能把真实记录判成演示数据,属于同一类 P0。
+ *
+ * 注意:`record.source` 与 `realData.source` 是采集方式标签而非来源标注,故不参与判定。
+ */
+(function initPracticeRecordSource(global) {
+ 'use strict';
+
+ // 同一份源码会被多个 bundle 内联(浏览器里 core-foundation 与 browse 都会执行一次),
+ // 重复赋值本身无害,但仍按仓库惯例做幂等保护,避免任何形态的静默覆盖。
+ if (global.PracticeRecordSource && global.PracticeRecordSource.__stable === true) {
+ return;
+ }
+
+ /** 被认可为“真实用户练习”的显式 dataSource 取值。 */
+ const REAL_DATA_SOURCES = Object.freeze(['real']);
+
+ /**
+ * 被认定为“演示 / 种子 / 夹具数据”的 metadata.source 取值(精确匹配,大小写与首尾空白无关)。
+ * 目前生产代码只会写出 'onboarding-demo'(js/components/onboardingTour.js);
+ * 其余是历史与测试夹具里出现过的等价写法,一并显式列出而不是靠模糊匹配推断。
+ */
+ const DEMO_SOURCE_MARKERS = Object.freeze([
+ 'onboarding-demo',
+ 'onboarding_demo',
+ 'onboardingdemo',
+ 'demo',
+ 'e2e-seed',
+ 'e2e_seed'
+ ]);
+
+ /** 只有新手引导自己的 marker 才有资格申请临时历史列表预览。 */
+ const ONBOARDING_PREVIEW_MARKERS = Object.freeze([
+ 'onboarding-demo',
+ 'onboarding_demo',
+ 'onboardingdemo'
+ ]);
+
+ const realDataSourceSet = new Set(REAL_DATA_SOURCES);
+ const demoSourceSet = new Set(DEMO_SOURCE_MARKERS);
+ const onboardingPreviewMarkerSet = new Set(ONBOARDING_PREVIEW_MARKERS);
+
+ function normalize(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim().toLowerCase();
+ }
+
+ function asObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+ }
+
+ function hasOwn(object, field) {
+ return Object.prototype.hasOwnProperty.call(object, field);
+ }
+
+ /** 读取记录的来源标注:顶层优先,回退 metadata(light 投影同样走这条回退链)。 */
+ function readDataSource(record) {
+ if (hasOwn(record, 'dataSource')) return normalize(record.dataSource);
+ const metadata = asObject(record.metadata);
+ return hasOwn(metadata, 'dataSource') ? normalize(metadata.dataSource) : '';
+ }
+
+ function readMetadataSource(record) {
+ return normalize(asObject(record.metadata).source);
+ }
+
+ /**
+ * 唯一判定入口:该记录是否算作用户的真实练习。
+ * 练习记录列表渲染、practice.stats 投影、achievements.progress 投影三者必须都用它,
+ * 三处结论一致是本模块的核心契约。
+ */
+ function isRealPracticeRecord(record) {
+ if (!record || typeof record !== 'object') return false;
+
+ const dataSource = readDataSource(record);
+ // 缺失/空值一律按真实记录对待(见文件头“缺失即真实”)。
+ if (dataSource !== '' && !realDataSourceSet.has(dataSource)) return false;
+
+ if (demoSourceSet.has(readMetadataSource(record))) return false;
+
+ return true;
+ }
+
+ /** isRealPracticeRecord 的补集,仅对合法记录对象成立(非对象既不真也不演示)。 */
+ function isDemoPracticeRecord(record) {
+ if (!record || typeof record !== 'object') return false;
+ return !isRealPracticeRecord(record);
+ }
+
+ function filterRealPracticeRecords(records) {
+ return (Array.isArray(records) ? records : []).filter(isRealPracticeRecord);
+ }
+
+ // -----------------------------------------------------------------------
+ // 引导预览白名单(仅影响渲染,永不影响统计与成就)
+ //
+ // 新手引导的"回顾模式"步骤会先把一条演示记录写进权威 practice records,
+ // 再等待它在练习记录列表里出现(js/components/onboardingTour.js
+ // `_injectDemoRecord` -> `_waitForSelector`),演示完成后立即删除。
+ //
+ // 这条记录按上面的判定确实是演示数据(metadata.source = 'onboarding-demo'),
+ // 所以它必须继续被 practice.stats / achievements.progress 排除。但引导要教用户
+ // 认识这一行 UI,因此需要一个**显式、按 id 限定、临时**的渲染例外。
+ //
+ // 关键设计:例外只存在于视图层白名单,投影器根本读不到它——
+ // 于是"是否真实"仍然只有一份判定,不会退回"UI 与统计各写一套"的老 bug。
+ // 历史上引导记录之所以能显示,只是因为没人给它写 dataSource(巧合而非设计)。
+ // -----------------------------------------------------------------------
+ const previewRecordIds = new Set();
+
+ function normalizeId(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim();
+ }
+
+ /** 登记一条允许在练习记录列表中预览的演示记录 id(引导步骤开始时调用)。 */
+ function allowPreviewRecordId(recordId) {
+ const id = normalizeId(recordId);
+ if (id) previewRecordIds.add(id);
+ return id !== '';
+ }
+
+ /** 撤销预览许可(引导结束/跳过/清理演示记录时调用)。 */
+ function clearPreviewRecordId(recordId) {
+ if (recordId === undefined) {
+ previewRecordIds.clear();
+ return true;
+ }
+ return previewRecordIds.delete(normalizeId(recordId));
+ }
+
+ function isPreviewRecord(record) {
+ if (!previewRecordIds.size || !record || typeof record !== 'object') return false;
+ if (!onboardingPreviewMarkerSet.has(readMetadataSource(record))) return false;
+ const id = normalizeId(record.id || record.recordId);
+ return Boolean(id && previewRecordIds.has(id));
+ }
+
+ /**
+ * 练习记录列表的渲染过滤:真实记录 + 已显式登记的引导预览记录。
+ * 统计/成就一律用 filterRealPracticeRecords,绝不用这个函数。
+ */
+ function filterRecordsForHistoryView(records) {
+ return (Array.isArray(records) ? records : [])
+ .filter((record) => isRealPracticeRecord(record) || isPreviewRecord(record));
+ }
+
+ const api = Object.freeze({
+ __stable: true,
+ REAL_DATA_SOURCES,
+ DEMO_SOURCE_MARKERS,
+ ONBOARDING_PREVIEW_MARKERS,
+ isRealPracticeRecord,
+ isDemoPracticeRecord,
+ filterRealPracticeRecords,
+ allowPreviewRecordId,
+ clearPreviewRecordId,
+ isPreviewRecord,
+ filterRecordsForHistoryView
+ });
+
+ global.PracticeRecordSource = api;
+
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = api;
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
/* ===== js/app/examActions.js ===== */
(function (global) {
'use strict';
@@ -4142,15 +4500,9 @@
return categories[Math.max(0, stageIndex)] || null;
}
- function findExamById(examId) {
- const list = Array.isArray(global.examIndex)
- ? global.examIndex
- : (global.appStateService && typeof global.appStateService.getExamIndex === 'function'
- ? global.appStateService.getExamIndex()
- : []);
- return Array.isArray(list)
- ? list.find((item) => item && String(item.id) === String(examId))
- : null;
+ function findExamById(examId, examIndex) {
+ const list = Array.isArray(examIndex) ? examIndex : [];
+ return list.find((item) => item && String(item.id) === String(examId)) || null;
}
function isReadingMemorizeBrowseMode() {
@@ -4199,8 +4551,11 @@
return (Array.isArray(exams) ? exams : []).filter(isReadingMemorizeExam);
}
- function launchReadingMemorizeExam(examId) {
- const exam = findExamById(examId);
+ async function launchReadingMemorizeExam(examId, examIndex = null) {
+ const list = Array.isArray(examIndex)
+ ? examIndex
+ : await global.resolveActiveLibraryIndex();
+ const exam = findExamById(examId, list);
if (!isReadingMemorizeExam(exam)) {
if (typeof global.showMessage === 'function') {
global.showMessage('该题目无法使用统一阅读页背题,请选择有 HTML 数据的阅读题。', 'warning');
@@ -4491,13 +4846,16 @@
}
}
- function handleCustomSuiteSelect(examId) {
+ async function handleCustomSuiteSelect(examId, examIndex = null) {
const draft = getCustomSuiteDraft();
if (!draft || draft.status === 'ready') {
return false;
}
- const exam = findExamById(examId);
+ const list = Array.isArray(examIndex)
+ ? examIndex
+ : await global.resolveActiveLibraryIndex();
+ const exam = findExamById(examId, list);
if (!exam) {
return false;
}
@@ -4598,7 +4956,7 @@
/**
* 加载并渲染题库列表
*/
- function loadExamList() {
+ function loadExamList(examIndex = []) {
console.log('[ExamActions] loadExamList called');
if (typeof global.setupBrowseControls === 'function') {
@@ -4618,13 +4976,13 @@
if (!memorizeSelectionActive && global.__browseFilterMode && global.__browseFilterMode !== 'default' && global.browseController) {
try {
if (!global.browseController.buttonContainer) {
- global.browseController.initialize('type-filter-buttons');
+ global.browseController.initialize('type-filter-buttons', examIndex);
}
if (global.browseController.currentMode !== global.__browseFilterMode) {
- global.browseController.setMode(global.__browseFilterMode);
+ global.browseController.setMode(global.__browseFilterMode, examIndex);
} else {
const activeFilter = global.browseController.activeFilter || 'all';
- global.browseController.applyFilter(activeFilter);
+ global.browseController.applyFilter(activeFilter, examIndex);
}
return;
} catch (error) {
@@ -4632,15 +4990,8 @@
}
}
- // 2. 获取题库快照
- let examIndexSnapshot = [];
- if (global.appStateService) {
- examIndexSnapshot = global.appStateService.getExamIndex();
- } else if (typeof global.getExamIndexState === 'function') {
- examIndexSnapshot = global.getExamIndexState();
- } else {
- examIndexSnapshot = Array.isArray(global.examIndex) ? global.examIndex : [];
- }
+ // 2. 使用控制器边界传入的本次题库快照。
+ const examIndexSnapshot = Array.isArray(examIndex) ? examIndex : [];
// 3. 获取筛选条件
let activeCategory = 'all';
@@ -5110,46 +5461,12 @@
return Promise.resolve();
}
- function ensureSettingsToolsReady() {
- if (global.AppLazyLoader && typeof global.AppLazyLoader.ensureGroup === 'function') {
- return global.AppLazyLoader.ensureGroup('settings-tools');
- }
- return ensureBrowseGroupReady();
- }
-
- async function ensureDataIntegrityManagerReady() {
- try {
- await ensureSettingsToolsReady();
- } catch (error) {
- console.warn('[ExamActions] 设置工具预加载失败,继续尝试导出:', error);
- }
-
- if (!global.dataIntegrityManager && global.DataIntegrityManager) {
- try {
- global.dataIntegrityManager = new global.DataIntegrityManager();
- } catch (error) {
- console.warn('[ExamActions] 初始化 DataIntegrityManager 失败:', error);
- }
- }
-
- return global.dataIntegrityManager || null;
- }
-
async function exportPracticeData() {
try {
- if (global.dataIntegrityManager && typeof global.dataIntegrityManager.exportData === 'function') {
- global.dataIntegrityManager.exportData();
- try { global.showMessage && global.showMessage('导出完成', 'success'); } catch (_) { }
- return;
- }
- } catch (_) { }
- try {
- var records = global.PracticeRecordAPI && typeof global.PracticeRecordAPI.list === 'function'
- ? await global.PracticeRecordAPI.list()
- : (global.getPracticeRecordsState ? global.getPracticeRecordsState() : []);
- var blob = new Blob([JSON.stringify(records, null, 2)], { type: 'application/json; charset=utf-8' });
+ var snapshot = await global.AppData.backups.export({ domains: ['practice'] });
+ var blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json; charset=utf-8' });
var url = URL.createObjectURL(blob);
- var a = document.createElement('a'); a.href = url; a.download = 'practice-records.json';
+ var a = document.createElement('a'); a.href = url; a.download = 'ielts-atlas-practice-v2.json';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
try { global.showMessage && global.showMessage('导出完成', 'success'); } catch (_) { }
@@ -5160,14 +5477,17 @@
}
async function exportAllData() {
- var manager = null;
try {
- manager = await ensureDataIntegrityManagerReady();
- if (manager && typeof manager.exportData === 'function') {
- await manager.exportData();
- try { global.showMessage && global.showMessage('数据导出成功', 'success'); } catch (_) { }
- return;
- }
+ var snapshot = await global.AppData.backups.export();
+ var blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json; charset=utf-8' });
+ var url = URL.createObjectURL(blob);
+ var anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = 'ielts-atlas-backup-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json';
+ document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(url);
+ try { await global.AppData.backups.recordExport({ type: 'full-v2', checksum: snapshot.checksum }); } catch (historyError) { console.warn('[ExamActions] 导出历史记录失败:', historyError); }
+ try { global.showMessage && global.showMessage('数据导出成功', 'success'); } catch (_) { }
+ return snapshot;
} catch (error) {
console.error('[ExamActions] 数据导出失败:', error);
if (typeof global.showMessage === 'function') {
@@ -5176,9 +5496,7 @@
return;
}
- if (typeof global.exportPracticeData === 'function') {
- return global.exportPracticeData();
- }
+ return null;
if (typeof global.showMessage === 'function') {
global.showMessage('Data manager module is unavailable.', 'warning');
}
@@ -5239,7 +5557,8 @@
isReadingMemorizeExam
};
- global.loadExamList = loadExamList;
+ // 全局 loadExamList 由 main.js 的适配器持有(无参时自解析题库索引);
+ // 此处仅通过 global.ExamActions.loadExamList 暴露,避免覆盖后无参调用拿到空数组。
global.resetBrowseViewToAll = resetBrowseViewToAll;
global.displayExams = displayExams;
global.setupExamActionHandlers = setupExamActionHandlers;
@@ -5316,12 +5635,11 @@
// 错误缓存,用于临时存储检测到的错误
this.errorCache = new Map();
- // 词表存储键配置
- this.storageKeys = {
- p1: 'vocab_list_p1_errors',
- p4: 'vocab_list_p4_errors',
- master: 'vocab_list_master_errors',
- custom: 'vocab_list_custom'
+ this.collectionIds = {
+ p1: 'spelling-errors-p1',
+ p4: 'spelling-errors-p4',
+ master: 'spelling-errors-master',
+ custom: 'custom'
};
this.lexiconCache = null;
@@ -5339,17 +5657,8 @@
*/
async init() {
try {
- // 等待存储系统就绪
- if (window.storage && window.storage.ready) {
- await window.storage.ready;
- }
-
- // 设置命名空间
- if (window.storage && typeof window.storage.setNamespace === 'function') {
- window.storage.setNamespace('exam_system');
- console.log('[SpellingErrorCollector] 存储命名空间已设置');
- }
-
+ if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab is unavailable');
+ await window.AppData.ready;
this.initialized = true;
console.log('[SpellingErrorCollector] 初始化完成');
} catch (error) {
@@ -5717,14 +6026,9 @@
try {
await this.ensureInitialized();
- const storageKey = this.storageKeys[listId] || listId;
-
- if (!window.storage) {
- console.warn('[SpellingErrorCollector] 存储系统不可用');
- return null;
- }
-
- const list = await window.storage.get(storageKey);
+ const collectionId = this.collectionIds[listId] || listId;
+ const collections = await window.AppData.vocab.listCollections();
+ const list = collections[collectionId];
const normalizedList = this.normalizeVocabListShape(list, listId, listId);
if (normalizedList) {
@@ -5736,7 +6040,7 @@
return null;
} catch (error) {
console.error(`[SpellingErrorCollector] 加载词表失败: ${listId}`, error);
- return null;
+ throw error;
}
}
@@ -5748,31 +6052,10 @@
async saveVocabList(vocabList) {
try {
await this.ensureInitialized();
-
- if (!vocabList || !vocabList.id) {
- console.error('[SpellingErrorCollector] 无效的词表对象');
- return false;
- }
-
- if (!Array.isArray(vocabList.words)) {
- vocabList.words = [];
- }
-
- vocabList = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList;
-
- // 更新统计信息
- vocabList.stats = vocabList.stats || {};
- vocabList.stats.totalWords = vocabList.words.length;
- vocabList.updatedAt = Date.now();
-
- const storageKey = this.storageKeys[vocabList.id] || vocabList.id;
-
- if (!window.storage) {
- console.warn('[SpellingErrorCollector] 存储系统不可用');
- return false;
- }
-
- await window.storage.set(storageKey, vocabList);
+ vocabList = this.prepareVocabList(vocabList);
+ if (!vocabList) return false;
+ const collectionId = this.collectionIds[vocabList.id] || vocabList.id;
+ await window.AppData.vocab.saveCollection(collectionId, vocabList);
console.log(`[SpellingErrorCollector] 保存词表成功: ${vocabList.id}, 单词数: ${vocabList.words.length}`);
return true;
@@ -5782,6 +6065,19 @@
}
}
+ prepareVocabList(vocabList) {
+ if (!vocabList || !vocabList.id) {
+ console.error('[SpellingErrorCollector] 无效的词表对象');
+ return null;
+ }
+ if (!Array.isArray(vocabList.words)) vocabList.words = [];
+ const normalized = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList;
+ normalized.stats = normalized.stats || {};
+ normalized.stats.totalWords = normalized.words.length;
+ normalized.updatedAt = Date.now();
+ return normalized;
+ }
+
/**
* 获取词表单词数量
* @param {string} listId - 词表ID
@@ -5793,7 +6089,7 @@
return list ? list.words.length : 0;
} catch (error) {
console.error(`[SpellingErrorCollector] 获取词表单词数失败: ${listId}`, error);
- return 0;
+ throw error;
}
}
@@ -6363,17 +6659,25 @@
try {
await this.ensureInitialized();
await this.ensureCoreLexicon();
-
- // 按来源分组错误
const errorsBySource = this.groupErrorsBySource(errors);
-
- // 保存到各个来源的词表
+ const pendingCollections = {};
for (const [source, sourceErrors] of Object.entries(errorsBySource)) {
- await this.saveErrorsToList(source, sourceErrors);
+ let vocabList = await this.loadVocabList(source);
+ if (!vocabList) vocabList = this.createEmptyList(source, source);
+ this.mergeErrorsToList(vocabList, sourceErrors);
+ const prepared = this.prepareVocabList(vocabList);
+ if (!prepared) throw new Error(`生成 ${source} 错词词表失败`);
+ pendingCollections[this.collectionIds[source] || source] = prepared;
}
- // 同步到综合词表
- await this.syncToMasterList(errors);
+ let masterList = await this.loadVocabList('master');
+ if (!masterList) masterList = this.createEmptyList('master', 'all');
+ this.mergeErrorsToList(masterList, errors);
+ const preparedMaster = this.prepareVocabList(masterList);
+ if (!preparedMaster) throw new Error('生成综合错词词表失败');
+ pendingCollections[this.collectionIds.master] = preparedMaster;
+
+ await window.AppData.vocab.saveCollections(pendingCollections);
console.log(`[SpellingErrorCollector] 保存完成,共保存 ${errors.length} 个错误`);
return true;
@@ -6524,7 +6828,9 @@
);
if (vocabList.words.length < originalLength) {
- await this.saveVocabList(vocabList);
+ if (!await this.saveVocabList(vocabList)) {
+ return false;
+ }
console.log(`[SpellingErrorCollector] 从词表 ${listId} 移除单词: ${word}`);
return true;
} else {
@@ -6554,7 +6860,9 @@
vocabList.words = [];
vocabList.updatedAt = Date.now();
- await this.saveVocabList(vocabList);
+ if (!await this.saveVocabList(vocabList)) {
+ return false;
+ }
console.log(`[SpellingErrorCollector] 清空词表: ${listId}`);
return true;
@@ -6586,59 +6894,11 @@
const PRACTICE_ENHANCER_BUILD_ID = '20250105';
async function getActiveExamIndexSnapshot() {
- const stateGetters = [
- () => (typeof global.getExamIndexState === 'function') ? global.getExamIndexState() : null,
- () => (typeof getExamIndexState === 'function') ? getExamIndexState : null
- ];
-
- for (const getterFactory of stateGetters) {
- try {
- const getter = getterFactory();
- if (typeof getter === 'function') {
- const state = getter();
- if (Array.isArray(state) && state.length) {
- return state.slice();
- }
- }
- } catch (_) { }
- }
-
- let activeKey = 'exam_index';
- try {
- if (typeof global.getActiveLibraryConfigurationKey === 'function') {
- const resolved = await global.getActiveLibraryConfigurationKey();
- if (resolved && typeof resolved === 'string' && resolved.trim()) {
- activeKey = resolved.trim();
- }
- } else {
- const storedKey = await storage.get('active_exam_index_key', 'exam_index');
- if (storedKey && typeof storedKey === 'string' && storedKey.trim()) {
- activeKey = storedKey.trim();
- }
- }
- } catch (_) {
- try {
- const storedKey = await storage.get('active_exam_index_key', 'exam_index');
- if (storedKey && typeof storedKey === 'string' && storedKey.trim()) {
- activeKey = storedKey.trim();
- }
- } catch (_) { }
+ if (typeof global.resolveActiveLibraryIndex !== 'function') {
+ throw new Error('LibraryManager.resolveActiveIndex is unavailable');
}
-
- let dataset = await storage.get(activeKey, []) || [];
- if ((!Array.isArray(dataset) || dataset.length === 0) && activeKey !== 'exam_index') {
- dataset = await storage.get('exam_index', []) || [];
- }
- if (!Array.isArray(dataset) || dataset.length === 0) {
- if (Array.isArray(global.examIndex) && global.examIndex.length) {
- dataset = global.examIndex.slice();
- } else if (typeof global.getReadingExamIndex === 'function') {
- dataset = global.getReadingExamIndex();
- } else if (Array.isArray(global.__READING_EXAM_INDEX__) && global.__READING_EXAM_INDEX__.length) {
- dataset = global.__READING_EXAM_INDEX__.slice();
- }
- }
- return Array.isArray(dataset) ? dataset : [];
+ const dataset = await global.resolveActiveLibraryIndex();
+ return Array.isArray(dataset) ? dataset.slice() : [];
}
async function findExamDefinition(examId) {
@@ -6651,20 +6911,6 @@
return match;
}
- const fallbacks = [
- Array.isArray(global.examIndex) ? global.examIndex : null,
- typeof global.getReadingExamIndex === 'function' ? global.getReadingExamIndex() : null,
- Array.isArray(global.__READING_EXAM_INDEX__) ? global.__READING_EXAM_INDEX__ : null,
- Array.isArray(global.listeningExamIndex) ? global.listeningExamIndex : null
- ];
- for (const fallback of fallbacks) {
- if (!Array.isArray(fallback)) continue;
- const found = fallback.find(entry => entry && entry.id === examId);
- if (found) {
- return found;
- }
- }
-
return null;
}
@@ -6944,21 +7190,598 @@
};
},
+ _captureExamLaunchRegistrationState(examId) {
+ const windowInfo = this.examWindows && this.examWindows.get(examId);
+ return Object.freeze({
+ hasRegistration: Boolean(windowInfo),
+ registrationEpoch: Number(this._examRegistrationEpochs
+ && this._examRegistrationEpochs.get(String(examId || '')) || 0),
+ registration: windowInfo
+ ? this._captureExamSessionRegistration(examId, windowInfo)
+ : null
+ });
+ },
+
+ _resolveExamLaunchTargetLeaseKeys(examId, options = {}) {
+ // Reserve every named browsing context this launch may navigate before
+ // the first await. A later explicit reuse of one of these names must
+ // invalidate the older launch before window.open() can navigate it.
+ const normalizedExamId = String(examId || '').trim();
+ const names = normalizedExamId
+ ? [`exam_${normalizedExamId}`, `pdf_${normalizedExamId}`]
+ : [];
+ if (options && typeof options.windowName === 'string') {
+ names.push(options.windowName.trim());
+ }
+ if (options && options.reuseWindow) {
+ try {
+ if (typeof options.reuseWindow.name === 'string') {
+ names.push(options.reuseWindow.name.trim());
+ }
+ } catch (_) {}
+ }
+ return Object.freeze(Array.from(new Set(names
+ .filter(name => name && !name.startsWith('_'))
+ .map(name => `window-name:${name}`))));
+ },
+
+ _recordExamWindowNavigation(targetWindow, examId = '') {
+ if (!targetWindow
+ || (typeof targetWindow !== 'object' && typeof targetWindow !== 'function')) {
+ return null;
+ }
+ if (!this._examWindowCommittedNavigationOwners) {
+ this._examWindowCommittedNavigationOwners = new WeakMap();
+ }
+ this._examWindowCommittedNavigationSequence = Math.max(
+ 0,
+ Number(this._examWindowCommittedNavigationSequence) || 0
+ ) + 1;
+ const navigationOwnership = Object.freeze({
+ examId: String(examId || ''),
+ sequence: this._examWindowCommittedNavigationSequence
+ });
+ this._examWindowCommittedNavigationOwners.set(targetWindow, navigationOwnership);
+ return navigationOwnership;
+ },
+
+ _isExamWindowNavigationCurrent(targetWindow, expectedNavigationOwnership) {
+ return Boolean(
+ targetWindow
+ && expectedNavigationOwnership
+ && this._examWindowCommittedNavigationOwners
+ && this._examWindowCommittedNavigationOwners.get(targetWindow) === expectedNavigationOwnership
+ );
+ },
+
+ _resolveExamLaunchProvenWindow(targetLeaseKey) {
+ if (!this._examLaunchProvenWindowByTargetKey) return null;
+ const stored = this._examLaunchProvenWindowByTargetKey.get(targetLeaseKey);
+ const targetWindow = stored && typeof stored.deref === 'function'
+ ? stored.deref()
+ : stored;
+ if (!targetWindow) {
+ this._examLaunchProvenWindowByTargetKey.delete(targetLeaseKey);
+ return null;
+ }
+ try {
+ if (targetWindow.closed === true) {
+ this._examLaunchProvenWindowByTargetKey.delete(targetLeaseKey);
+ const targetKeys = this._examLaunchProvenTargetKeysByWindow
+ && this._examLaunchProvenTargetKeysByWindow.get(targetWindow);
+ if (targetKeys) targetKeys.delete(targetLeaseKey);
+ return null;
+ }
+ } catch (_) {
+ // A cross-origin WindowProxy may reject property access while alive.
+ }
+ return targetWindow;
+ },
+
+ _storeExamLaunchProvenWindow(targetLeaseKey, targetWindow) {
+ if (!this._examLaunchProvenWindowByTargetKey) {
+ this._examLaunchProvenWindowByTargetKey = new Map();
+ }
+ const stored = typeof WeakRef === 'function'
+ ? new WeakRef(targetWindow)
+ : targetWindow;
+ this._examLaunchProvenWindowByTargetKey.set(targetLeaseKey, stored);
+ return targetWindow;
+ },
+
+ _claimExamLaunchWindowOwnership(ownership, targetWindow, provenTargetLeaseKeys = []) {
+ if (!ownership || !targetWindow || (typeof targetWindow !== 'object' && typeof targetWindow !== 'function')) {
+ return false;
+ }
+ const rollbackState = this._examLaunchOwnershipRollbackStates
+ && this._examLaunchOwnershipRollbackStates.get(ownership);
+ if (!rollbackState
+ || (this._committedExamLaunchOwnerships
+ && this._committedExamLaunchOwnerships.has(ownership))) {
+ // A launch token is only a pre-navigation reservation. Once it has
+ // committed (or rolled back), callers must use the installed exact
+ // registration instead of resurrecting its released name/window slots.
+ return false;
+ }
+ try {
+ if (targetWindow.closed) {
+ return false;
+ }
+ } catch (_) {
+ return false;
+ }
+ if (!this._examLaunchWindowOwnerships) {
+ this._examLaunchWindowOwnerships = new WeakMap();
+ }
+ if (!this._examLaunchOwnershipTargetLeaseKeys) {
+ this._examLaunchOwnershipTargetLeaseKeys = new WeakMap();
+ }
+ if (!this._examLaunchProvenTargetKeysByWindow) {
+ this._examLaunchProvenTargetKeysByWindow = new WeakMap();
+ }
+ if (!this._examLaunchProvenWindowByTargetKey) {
+ this._examLaunchProvenWindowByTargetKey = new Map();
+ }
+ const current = this._examLaunchWindowOwnerships.get(targetWindow);
+ if (current && Number(current.sequence) > Number(ownership.sequence)) {
+ return false;
+ }
+ const effectiveTargetLeaseKeys = new Set(
+ this._examLaunchOwnershipTargetLeaseKeys.get(ownership)
+ || ownership.targetLeaseKeys
+ || []
+ );
+ const newlyProvenKeys = new Set(
+ (Array.isArray(provenTargetLeaseKeys) ? provenTargetLeaseKeys : [provenTargetLeaseKeys])
+ .map(key => String(key || '').trim())
+ .filter(Boolean)
+ .map(key => key.startsWith('window-name:') ? key : `window-name:${key}`)
+ .filter(key => !key.slice('window-name:'.length).startsWith('_'))
+ );
+ let targetNameWasReadable = false;
+ try {
+ const targetName = typeof targetWindow.name === 'string'
+ ? targetWindow.name.trim()
+ : '';
+ targetNameWasReadable = true;
+ if (targetName && !targetName.startsWith('_')) {
+ newlyProvenKeys.add(`window-name:${targetName}`);
+ }
+ } catch (_) {}
+ if (targetNameWasReadable) {
+ const priorTargetKeys = this._examLaunchProvenTargetKeysByWindow.get(targetWindow);
+ if (priorTargetKeys) {
+ for (const targetLeaseKey of Array.from(priorTargetKeys)) {
+ if (newlyProvenKeys.has(targetLeaseKey)) continue;
+ if (this._resolveExamLaunchProvenWindow(targetLeaseKey) === targetWindow) {
+ this._examLaunchProvenWindowByTargetKey.delete(targetLeaseKey);
+ }
+ priorTargetKeys.delete(targetLeaseKey);
+ }
+ }
+ }
+ for (const targetLeaseKey of newlyProvenKeys) {
+ const previousWindow = this._resolveExamLaunchProvenWindow(targetLeaseKey);
+ if (previousWindow && previousWindow !== targetWindow) {
+ const previousKeys = this._examLaunchProvenTargetKeysByWindow.get(previousWindow);
+ if (previousKeys) previousKeys.delete(targetLeaseKey);
+ }
+ this._storeExamLaunchProvenWindow(targetLeaseKey, targetWindow);
+ const targetKeys = this._examLaunchProvenTargetKeysByWindow.get(targetWindow) || new Set();
+ targetKeys.add(targetLeaseKey);
+ this._examLaunchProvenTargetKeysByWindow.set(targetWindow, targetKeys);
+ effectiveTargetLeaseKeys.add(targetLeaseKey);
+ }
+ if (current !== ownership && this._examLaunchTargetOwnerships) {
+ // Cross-origin/PDF WindowProxy objects may throw when reading .name.
+ // Transfer only names still proven to resolve to this proxy. A mere
+ // reservation for the same name is not browsing-context proof. The
+ // previous launch reservation may already be committed/released; the
+ // weak browsing-context proof intentionally survives that release.
+ const inheritedTargetLeaseKeys = this._examLaunchProvenTargetKeysByWindow.get(targetWindow)
+ || [];
+ for (const targetLeaseKey of inheritedTargetLeaseKeys) {
+ if (this._resolveExamLaunchProvenWindow(targetLeaseKey) === targetWindow) {
+ effectiveTargetLeaseKeys.add(targetLeaseKey);
+ }
+ }
+ }
+ this._examLaunchOwnershipTargetLeaseKeys.set(ownership, effectiveTargetLeaseKeys);
+ if (this._examLaunchTargetOwnerships) {
+ for (const targetLeaseKey of effectiveTargetLeaseKeys) {
+ const mappedOwnership = this._examLaunchTargetOwnerships.get(targetLeaseKey);
+ if (!mappedOwnership
+ || Number(mappedOwnership.sequence) < Number(ownership.sequence)) {
+ if (rollbackState) rollbackState.targetKeys.add(targetLeaseKey);
+ this._examLaunchTargetOwnerships.set(targetLeaseKey, ownership);
+ }
+ }
+ }
+ if (rollbackState) rollbackState.windows.add(targetWindow);
+ this._examLaunchWindowOwnerships.set(targetWindow, ownership);
+ return true;
+ },
+
+ _beginExamLaunchOwnership(examId, options = {}) {
+ if (!this._examLaunchOwnerships) {
+ this._examLaunchOwnerships = new Map();
+ }
+ if (!this._examLaunchTargetOwnerships) {
+ this._examLaunchTargetOwnerships = new Map();
+ }
+ if (!this._examLaunchOwnershipTargetLeaseKeys) {
+ this._examLaunchOwnershipTargetLeaseKeys = new WeakMap();
+ }
+ if (!this._examLaunchOwnershipRollbackStates) {
+ this._examLaunchOwnershipRollbackStates = new WeakMap();
+ }
+ if (!this._examLaunchOwnershipExplicitWindows) {
+ this._examLaunchOwnershipExplicitWindows = new WeakMap();
+ }
+ if (!this._committedExamLaunchOwnerships) {
+ this._committedExamLaunchOwnerships = new WeakSet();
+ }
+ this._examLaunchOwnershipSequence = Math.max(
+ 0,
+ Number(this._examLaunchOwnershipSequence) || 0
+ ) + 1;
+ const targetLeaseKeys = this._resolveExamLaunchTargetLeaseKeys(examId, options);
+ const explicitWindow = options && options.reuseWindow && !options.reuseWindow.closed
+ ? options.reuseWindow
+ : null;
+ const ownership = Object.freeze({
+ examId: String(examId || ''),
+ initialState: this._captureExamLaunchRegistrationState(examId),
+ sequence: this._examLaunchOwnershipSequence,
+ targetLeaseKeys
+ });
+ if (explicitWindow) {
+ // Keep the WindowProxy outside the immutable token so commit/rollback
+ // can release the final strong reference deterministically.
+ this._examLaunchOwnershipExplicitWindows.set(ownership, explicitWindow);
+ }
+ const normalizedExamId = String(examId || '');
+ const rollbackState = {
+ examKey: normalizedExamId,
+ targetKeys: new Set(targetLeaseKeys),
+ windows: new Set()
+ };
+ this._examLaunchOwnershipRollbackStates.set(ownership, rollbackState);
+ this._examLaunchOwnershipTargetLeaseKeys.set(ownership, new Set(targetLeaseKeys));
+ this._examLaunchOwnerships.set(normalizedExamId, ownership);
+ for (const targetLeaseKey of targetLeaseKeys) {
+ this._examLaunchTargetOwnerships.set(targetLeaseKey, ownership);
+ }
+ if (explicitWindow) {
+ this._claimExamLaunchWindowOwnership(ownership, explicitWindow);
+ }
+ return ownership;
+ },
+
+ _releaseExamLaunchOwnershipReservation(ownership) {
+ const rollbackState = ownership
+ && this._examLaunchOwnershipRollbackStates
+ && this._examLaunchOwnershipRollbackStates.get(ownership);
+ if (!rollbackState) {
+ return { found: false, released: false };
+ }
+ let released = false;
+ if (this._examLaunchOwnerships
+ && this._examLaunchOwnerships.get(rollbackState.examKey) === ownership) {
+ this._examLaunchOwnerships.delete(rollbackState.examKey);
+ released = true;
+ }
+ if (this._examLaunchTargetOwnerships) {
+ for (const targetLeaseKey of rollbackState.targetKeys) {
+ if (this._examLaunchTargetOwnerships.get(targetLeaseKey) !== ownership) {
+ continue;
+ }
+ this._examLaunchTargetOwnerships.delete(targetLeaseKey);
+ released = true;
+ }
+ }
+ if (this._examLaunchWindowOwnerships) {
+ for (const targetWindow of rollbackState.windows) {
+ if (this._examLaunchWindowOwnerships.get(targetWindow) !== ownership) {
+ continue;
+ }
+ this._examLaunchWindowOwnerships.delete(targetWindow);
+ released = true;
+ }
+ }
+ this._examLaunchOwnershipRollbackStates.delete(ownership);
+ if (this._examLaunchOwnershipTargetLeaseKeys) {
+ this._examLaunchOwnershipTargetLeaseKeys.delete(ownership);
+ }
+ if (this._examLaunchOwnershipExplicitWindows) {
+ this._examLaunchOwnershipExplicitWindows.delete(ownership);
+ }
+ return { found: true, released };
+ },
+
+ _rollbackExamLaunchOwnership(ownership) {
+ // Launch ownership is a reservation for an unfinished open continuation,
+ // not the authority of an already installed page. On failure, release
+ // only slots that still point at this reservation. Restoring predecessor
+ // tokens can resurrect an older continuation after an A -> B -> A race;
+ // installed pages remain authoritative through their exact registration.
+ return this._releaseExamLaunchOwnershipReservation(ownership).released;
+ },
+
+ _commitExamLaunchOwnership(ownership) {
+ const release = this._releaseExamLaunchOwnershipReservation(ownership);
+ if (!release.found) {
+ return false;
+ }
+ if (!this._committedExamLaunchOwnerships) {
+ this._committedExamLaunchOwnerships = new WeakSet();
+ }
+ this._committedExamLaunchOwnerships.add(ownership);
+ return true;
+ },
+
+ _isExamLaunchRegistrationStateCurrent(examId, state) {
+ if (!state) {
+ return false;
+ }
+ if (state.hasRegistration) {
+ // Closing the frozen predecessor may legitimately remove it while a
+ // newer launch is waiting on the index/library. Absence is neutral;
+ // any different replacement tuple still invalidates this launch.
+ if (!this.examWindows || !this.examWindows.has(examId)) {
+ const currentEpoch = Number(this._examRegistrationEpochs
+ && this._examRegistrationEpochs.get(String(examId || '')) || 0);
+ return currentEpoch === Number(state.registrationEpoch || 0);
+ }
+ return Boolean(state.registration)
+ && this._isExamSessionRegistrationCurrent(examId, state.registration);
+ }
+ return !(this.examWindows && this.examWindows.has(examId));
+ },
+
+ _isExamLaunchOwnershipCurrent(examId, ownership, registrationState = null, targetWindow = null) {
+ if (!ownership
+ || (this._committedExamLaunchOwnerships
+ && this._committedExamLaunchOwnerships.has(ownership))
+ || !this._examLaunchOwnerships
+ || this._examLaunchOwnerships.get(String(examId || '')) !== ownership) {
+ return false;
+ }
+ const effectiveTargetLeaseKeys = this._examLaunchOwnershipTargetLeaseKeys
+ && this._examLaunchOwnershipTargetLeaseKeys.get(ownership)
+ || ownership.targetLeaseKeys
+ || [];
+ for (const targetLeaseKey of effectiveTargetLeaseKeys) {
+ if (!this._examLaunchTargetOwnerships
+ || this._examLaunchTargetOwnerships.get(targetLeaseKey) !== ownership) {
+ return false;
+ }
+ }
+ const hasExplicitWindow = Boolean(
+ this._examLaunchOwnershipExplicitWindows
+ && this._examLaunchOwnershipExplicitWindows.has(ownership)
+ );
+ if (hasExplicitWindow) {
+ const explicitWindow = this._examLaunchOwnershipExplicitWindows.get(ownership);
+ if (!explicitWindow
+ || !this._examLaunchWindowOwnerships
+ || this._examLaunchWindowOwnerships.get(explicitWindow) !== ownership) {
+ return false;
+ }
+ }
+ const ownedWindow = targetWindow || null;
+ if (ownedWindow) {
+ try {
+ if (ownedWindow.closed) {
+ return false;
+ }
+ } catch (_) {
+ return false;
+ }
+ if (!this._examLaunchWindowOwnerships
+ || this._examLaunchWindowOwnerships.get(ownedWindow) !== ownership) {
+ return false;
+ }
+ }
+ return registrationState
+ ? this._isExamLaunchRegistrationStateCurrent(examId, registrationState)
+ : true;
+ },
+
+ _isOwnedExamLaunchRegistrationCurrent(examId, ownership, expectedRegistration) {
+ return this._isExamLaunchOwnershipCurrent(
+ examId,
+ ownership,
+ null,
+ expectedRegistration && expectedRegistration.window
+ )
+ && Boolean(expectedRegistration)
+ && this._isExamSessionRegistrationCurrent(examId, expectedRegistration);
+ },
+
+ _isOpenExamRegistrationCurrent(examId, expectedRegistration, targetWindow = null) {
+ const ownedWindow = targetWindow || (expectedRegistration && expectedRegistration.window) || null;
+ if (!ownedWindow
+ || !expectedRegistration
+ || expectedRegistration.window !== ownedWindow
+ || !this._isExamSessionRegistrationCurrent(examId, expectedRegistration)) {
+ return false;
+ }
+ try {
+ return ownedWindow.closed !== true;
+ } catch (_) {
+ return false;
+ }
+ },
+
+ _recordExamLaunchRegistrationReceipt(examId, launchOwnership, registration) {
+ if (!launchOwnership
+ || (typeof launchOwnership !== 'object' && typeof launchOwnership !== 'function')) {
+ return false;
+ }
+ if (!this._examLaunchRegistrationReceipts) {
+ this._examLaunchRegistrationReceipts = new WeakMap();
+ }
+ // A receipt identifies the exact result of this open continuation; it
+ // must never fall back to whichever tuple later occupies examWindows.
+ this._examLaunchRegistrationReceipts.delete(launchOwnership);
+ const normalizedExamId = String(examId || '').trim();
+ const targetWindow = registration && registration.window || null;
+ if (!normalizedExamId
+ || String(launchOwnership.examId || '').trim() !== normalizedExamId
+ || !registration
+ || !this._isOpenExamRegistrationCurrent(
+ normalizedExamId,
+ registration,
+ targetWindow
+ )) {
+ return false;
+ }
+ this._examLaunchRegistrationReceipts.set(launchOwnership, Object.freeze({
+ examId: normalizedExamId,
+ window: targetWindow,
+ registration
+ }));
+ return true;
+ },
+
+ _captureExamLaunchRegistrationReceipt(examId, launchOwnership, targetWindow = null) {
+ if (!launchOwnership
+ || !this._examLaunchRegistrationReceipts
+ || (typeof launchOwnership !== 'object' && typeof launchOwnership !== 'function')) {
+ return null;
+ }
+ const receipt = this._examLaunchRegistrationReceipts.get(launchOwnership);
+ const normalizedExamId = String(examId || '').trim();
+ const expectedWindow = targetWindow || (receipt && receipt.window) || null;
+ if (!receipt
+ || !normalizedExamId
+ || String(launchOwnership.examId || '').trim() !== normalizedExamId
+ || receipt.examId !== normalizedExamId
+ || !expectedWindow
+ || receipt.window !== expectedWindow
+ || !this._isOpenExamRegistrationCurrent(
+ normalizedExamId,
+ receipt.registration,
+ expectedWindow
+ )) {
+ return null;
+ }
+ return receipt.registration;
+ },
+
+ async _abortOwnedExamLaunch(examId, targetWindow, launchOwnership, expectedRegistration) {
+ if (!targetWindow
+ || !expectedRegistration
+ || expectedRegistration.window !== targetWindow
+ || !this._isExamSessionRegistrationCurrent(examId, expectedRegistration)) {
+ return false;
+ }
+ const expectedNavigationOwnership = expectedRegistration.navigationOwnership
+ || (expectedRegistration.windowInfo && expectedRegistration.windowInfo.navigationOwnership)
+ || null;
+ // Once navigation has installed an exact provisional registration, that
+ // tuple owns rollback. A newer pre-navigation reservation must not strand
+ // this page in handshakeDeferred, nor prevent exact cleanup on failure.
+ await this.cleanupExamSession(examId, {
+ expectedRegistration,
+ recoverySessionId: expectedRegistration.expectedSessionId
+ });
+ const targetWasReassigned = Boolean(this.examWindows && Array.from(this.examWindows.values())
+ .some(info => info && info.window === targetWindow));
+ if (!targetWasReassigned
+ && this._isExamWindowNavigationCurrent(targetWindow, expectedNavigationOwnership)
+ && targetWindow !== window
+ && (() => {
+ try { return targetWindow.closed !== true; } catch (_) { return false; }
+ })()) {
+ try {
+ if (typeof this._releaseSuiteWindowGuard === 'function'
+ && expectedRegistration.suiteSessionId) {
+ this._releaseSuiteWindowGuard(targetWindow, expectedRegistration.suiteSessionId);
+ }
+ } catch (_) {}
+ try {
+ if (typeof targetWindow.close === 'function') {
+ targetWindow.close();
+ }
+ } catch (_) {}
+ }
+ window.showMessage && window.showMessage('练习会话启动失败,请重试。', 'error');
+ return true;
+ },
+
/**
* 打开指定题目进行练习
*/
async openExam(examId, options = {}) {
- const examIndex = await getActiveExamIndexSnapshot();
- const list = Array.isArray(examIndex) ? examIndex : (Array.isArray(window.examIndex) ? window.examIndex : []);
- const exam = list.find(e => e.id === examId);
+ const hasSuppliedLaunchOwnership = Boolean(
+ options
+ && Object.prototype.hasOwnProperty.call(options, 'launchOwnership')
+ );
+ const suppliedLaunchOwnership = hasSuppliedLaunchOwnership
+ ? options.launchOwnership
+ : null;
+ if (hasSuppliedLaunchOwnership) {
+ const suppliedTargetKeys = new Set(
+ suppliedLaunchOwnership && this._examLaunchOwnershipTargetLeaseKeys
+ && this._examLaunchOwnershipTargetLeaseKeys.get(suppliedLaunchOwnership)
+ || suppliedLaunchOwnership && suppliedLaunchOwnership.targetLeaseKeys
+ || []
+ );
+ const requestedTargetKeys = this._resolveExamLaunchTargetLeaseKeys(examId, options);
+ const expandsOwnership = requestedTargetKeys.some(key => !suppliedTargetKeys.has(key));
+ if (expandsOwnership || !this._isExamLaunchOwnershipCurrent(
+ examId,
+ suppliedLaunchOwnership,
+ null,
+ options && options.reuseWindow || null
+ )) {
+ return null;
+ }
+ }
+ const launchOwnership = hasSuppliedLaunchOwnership
+ ? suppliedLaunchOwnership
+ : this._beginExamLaunchOwnership(examId, options);
+ const rollbackUncommittedLaunch = () => !hasSuppliedLaunchOwnership
+ && this._rollbackExamLaunchOwnership(launchOwnership);
+ const initialLaunchState = launchOwnership.initialState;
const reviewMode = Boolean(options && options.reviewMode);
+ let exam = options && options.examDefinition && typeof options.examDefinition === 'object'
+ ? options.examDefinition
+ : null;
+ if (!exam) {
+ if (options && options.requireRecordProvenance) {
+ rollbackUncommittedLaunch();
+ throw new Error('历史记录的题库来源不可用');
+ }
+ let examIndex;
+ try {
+ examIndex = await getActiveExamIndexSnapshot();
+ } catch (indexError) {
+ rollbackUncommittedLaunch();
+ throw indexError;
+ }
+ if (!this._isExamLaunchOwnershipCurrent(examId, launchOwnership, initialLaunchState)) {
+ rollbackUncommittedLaunch();
+ return null;
+ }
+ const list = Array.isArray(examIndex) ? examIndex : [];
+ exam = list.find(e => e.id === examId);
+ }
const practiceMode = options && typeof options.practiceMode === 'string'
? options.practiceMode.trim().toLowerCase()
: '';
const memorizeMode = practiceMode === 'memorize';
+ if (!this._isExamLaunchOwnershipCurrent(examId, launchOwnership, initialLaunchState)) {
+ rollbackUncommittedLaunch();
+ return null;
+ }
+
if (!exam) {
window.showMessage('题目不存在', 'error');
+ rollbackUncommittedLaunch();
return;
}
@@ -6968,7 +7791,10 @@
: null;
if (readingLaunch && readingLaunch.mode === 'pdf_manual' && readingLaunch.pdfUrl) {
- return this._openPdfWindow(exam, readingLaunch.pdfUrl, options);
+ return this._openPdfWindow(exam, readingLaunch.pdfUrl, {
+ ...options,
+ launchOwnership
+ });
}
// 若无HTML,直接打开PDF
@@ -6977,10 +7803,13 @@
? window.buildResourcePath(exam, 'pdf')
: ((exam.path || '').replace(/\\/g, '/').replace(/\/+\//g, '/') + (exam.pdfFilename || ''));
const resolvedPdfUrl = this._ensureAbsoluteUrl(pdfUrl);
- return this._openPdfWindow(exam, resolvedPdfUrl, options);
+ return this._openPdfWindow(exam, resolvedPdfUrl, {
+ ...options,
+ launchOwnership
+ });
}
- const guardOptions = { ...options, examId };
+ const guardOptions = { ...options, examId, launchOwnership };
// 测试环境的套题练习统一使用占位页,避免因题目资源差异导致 E2E 不稳定
let examUrl = (readingLaunch && readingLaunch.mode === 'unified_html' && readingLaunch.url)
? readingLaunch.url
@@ -6994,29 +7823,227 @@
if (guardOptions.suiteSessionId && readingLaunch && readingLaunch.mode === 'unified_html') {
examUrl = this._appendSuiteContextToExamUrl(examUrl, guardOptions);
}
+ if (guardOptions.endlessMode) {
+ examUrl = this._appendEndlessContextToExamUrl(examUrl);
+ }
let examWindow = this.openExamWindow(examUrl, exam, guardOptions);
+ if (!examWindow
+ || !this._claimExamLaunchWindowOwnership(launchOwnership, examWindow)
+ || !this._isExamLaunchOwnershipCurrent(
+ examId,
+ launchOwnership,
+ null,
+ examWindow
+ )) {
+ return null;
+ }
+ if (!guardOptions.navigationOwnership) {
+ guardOptions.navigationOwnership = this._recordExamWindowNavigation(examWindow, examId);
+ }
+ let navigationRegistration = guardOptions.navigationRegistration;
+ if (!navigationRegistration || navigationRegistration.window !== examWindow) {
+ navigationRegistration = this._installExamNavigationProvisionalRegistration(
+ examId,
+ examWindow,
+ { ...guardOptions, expectedUrl: this._ensureAbsoluteUrl(examUrl) }
+ );
+ }
+ if (!this._isOpenExamRegistrationCurrent(examId, navigationRegistration, examWindow)) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ navigationRegistration
+ );
+ return null;
+ }
+ this._commitExamLaunchOwnership(launchOwnership);
try {
const guardedWindow = this._guardExamWindowContent(examWindow, exam, guardOptions);
if (guardedWindow) {
- examWindow = guardedWindow;
+ if (guardedWindow !== examWindow) {
+ examWindow = guardedWindow;
+ const marked = Number(this._markExamWindowReusePending(examWindow, { examId })) || 0;
+ if (marked > 0) guardOptions.windowReuseDetected = true;
+ }
+ navigationRegistration = this._installExamNavigationProvisionalRegistration(
+ examId,
+ examWindow,
+ { ...guardOptions, expectedUrl: this._ensureAbsoluteUrl(examUrl) }
+ );
}
} catch (guardError) {
console.warn('[App] 题目窗口占位页守护失败:', guardError);
}
- if (guardOptions.reuseWindow && examWindow && !examWindow.closed && typeof this._cleanupReusedWindowSessions === 'function') {
+ if (!this._isOpenExamRegistrationCurrent(examId, navigationRegistration, examWindow)) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ navigationRegistration
+ );
+ return null;
+ }
+ if (guardOptions.windowReuseDetected === true
+ && examWindow
+ && !examWindow.closed
+ && typeof this._cleanupReusedWindowSessions === 'function') {
await this._cleanupReusedWindowSessions(examWindow, examId);
+ if (!this._isOpenExamRegistrationCurrent(
+ examId,
+ navigationRegistration,
+ examWindow
+ )) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ navigationRegistration
+ );
+ return null;
+ }
}
- // 再进行会话记录与脚本注入
- if (!reviewMode && !memorizeMode) {
- await this.startPracticeSession(examId);
+ // 在启动窗口前捕获激活的题库配置 ID,确保后续练习记录 metadata 来源
+ // 一律按"启动时"的题库写入,避免用户在考试过程中切换题库导致提交时来源不一致。
+ if (!reviewMode) {
+ try {
+ await this._captureLaunchLibraryConfigurationId(examId, {
+ commitGuard: () => this._isOpenExamRegistrationCurrent(
+ examId,
+ navigationRegistration,
+ examWindow
+ )
+ });
+ } catch (captureError) {
+ console.warn('[App] 捕获启动题库配置 ID 失败:', captureError);
+ }
+ if (!this._isOpenExamRegistrationCurrent(
+ examId,
+ navigationRegistration,
+ examWindow
+ )) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ navigationRegistration
+ );
+ return null;
+ }
}
- this.injectDataCollectionScript(examWindow, examId, exam);
- this.setupExamWindowManagement(examWindow, examId, exam, options);
+ // Register the window first so the host expectedSessionId exists, then start the
+ // recorder with that same id. Starting the recorder before window setup used
+ // to mint a second session id that never matched INIT/COMPLETE.
+ const suiteBindingCheckpoint = typeof guardOptions.beforeSuiteHandshake === 'function'
+ ? guardOptions.beforeSuiteHandshake
+ : (guardOptions.suiteSessionId && typeof this._commitSuiteWindowBindingBeforeHandshake === 'function'
+ ? (context) => this._commitSuiteWindowBindingBeforeHandshake(
+ guardOptions.suiteSessionId,
+ context.examId,
+ context.examWindow,
+ context.windowInfo,
+ { commitGuard: context.commitGuard }
+ )
+ : null);
+ const deferSuiteHandshake = Boolean(guardOptions.suiteSessionId && suiteBindingCheckpoint);
+ const deferPracticeHandshake = !reviewMode && !memorizeMode;
+ const deferLaunchHandshake = deferSuiteHandshake || deferPracticeHandshake;
+ if (!this._isOpenExamRegistrationCurrent(
+ examId,
+ navigationRegistration,
+ examWindow
+ )) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ navigationRegistration
+ );
+ return null;
+ }
+ const setupRegistration = this.setupExamWindowManagement(examWindow, examId, exam, {
+ ...guardOptions,
+ expectedRegistration: navigationRegistration,
+ launchOwnership: null,
+ skipContentGuard: true,
+ deferInitialHandshake: deferLaunchHandshake,
+ expectedUrl: this._ensureAbsoluteUrl(examUrl)
+ });
+ if (!this._isOpenExamRegistrationCurrent(examId, setupRegistration, examWindow)) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ navigationRegistration
+ );
+ return null;
+ }
+ const registeredInfo = setupRegistration.windowInfo;
+ let launchRegistration = setupRegistration;
+ if (deferLaunchHandshake) {
+ this._buildExamInitPayload(examId, registeredInfo);
+ this.examWindows && this.examWindows.set(examId, registeredInfo);
+ launchRegistration = this._captureExamSessionRegistration(examId, registeredInfo);
+ if (!this._isOpenExamRegistrationCurrent(examId, launchRegistration, examWindow)) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ launchRegistration
+ );
+ return null;
+ }
+ }
+ if (!reviewMode && !memorizeMode) {
+ let startResult;
+ try {
+ startResult = await this.startPracticeSession(examId, {
+ examDefinition: exam,
+ expectedRegistration: launchRegistration
+ });
+ } catch (startError) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ launchRegistration
+ );
+ throw startError;
+ }
+ if (!this._isPracticeSessionOwnedSuccess(startResult)
+ || !this._isOpenExamRegistrationCurrent(
+ examId,
+ startResult.registration,
+ examWindow
+ )) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ launchRegistration
+ );
+ return null;
+ }
+ launchRegistration = startResult.registration;
+ if (!deferSuiteHandshake
+ && launchRegistration.windowInfo.handshakeDeferred === true) {
+ launchRegistration.windowInfo.handshakeDeferred = false;
+ this.restartExamHandshake(examWindow, examId, {
+ expectedRegistration: launchRegistration
+ });
+ }
+ }
+ if (!this._isOpenExamRegistrationCurrent(examId, launchRegistration, examWindow)) {
+ return null;
+ }
if (options && options.suiteSessionId) {
- const sessionInfo = this.ensureExamWindowSession(examId, examWindow);
+ if (!this._isOpenExamRegistrationCurrent(examId, launchRegistration, examWindow)) {
+ return null;
+ }
+ const sessionInfo = launchRegistration.windowInfo;
sessionInfo.suiteSessionId = options.suiteSessionId;
if (options.suiteFlowMode) {
sessionInfo.suiteFlowMode = options.suiteFlowMode;
@@ -7045,8 +8072,74 @@
sessionInfo.suiteSequenceTotal = options.sequenceTotal;
}
this.examWindows && this.examWindows.set(examId, sessionInfo);
+ launchRegistration = this._captureExamSessionRegistration(examId, sessionInfo);
+ if (!this._isOpenExamRegistrationCurrent(examId, launchRegistration, examWindow)) {
+ return null;
+ }
+ if (deferSuiteHandshake) {
+ const checkpointRegistration = launchRegistration;
+ const checkpointCommitGuard = () => this._isOpenExamRegistrationCurrent(
+ examId,
+ checkpointRegistration,
+ examWindow
+ );
+ let checkpointCommitted;
+ try {
+ checkpointCommitted = await suiteBindingCheckpoint({
+ examId,
+ examWindow,
+ windowInfo: sessionInfo,
+ expectedRegistration: checkpointRegistration,
+ launchOwnership: null,
+ commitGuard: checkpointCommitGuard
+ });
+ } catch (checkpointError) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ checkpointRegistration
+ );
+ throw checkpointError;
+ }
+ if (!this._isOpenExamRegistrationCurrent(
+ examId,
+ checkpointRegistration,
+ examWindow
+ )) {
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ checkpointRegistration
+ );
+ return null;
+ }
+ if (checkpointCommitted !== true) {
+ const checkpointError = new Error('Suite window binding was not durably committed before INIT');
+ checkpointError.code = 'RECOVERY_COMMIT_NOT_CONFIRMED';
+ await this._abortOwnedExamLaunch(
+ examId,
+ examWindow,
+ launchOwnership,
+ checkpointRegistration
+ );
+ throw checkpointError;
+ }
+ sessionInfo.handshakeDeferred = false;
+ this.restartExamHandshake(examWindow, examId, {
+ expectedRegistration: checkpointRegistration
+ });
+ launchRegistration = this._captureExamSessionRegistration(examId, sessionInfo);
+ }
}
+ if (!this._isOpenExamRegistrationCurrent(examId, launchRegistration, examWindow)) {
+ return null;
+ }
+ this.injectDataCollectionScript(examWindow, examId, exam, {
+ expectedRegistration: launchRegistration
+ });
if (reviewMode && typeof this._bindReviewWindowRef === 'function') {
this._bindReviewWindowRef(options.reviewSessionId, examWindow);
}
@@ -7056,21 +8149,63 @@
'info'
);
+ if (hasSuppliedLaunchOwnership
+ && !this._recordExamLaunchRegistrationReceipt(
+ examId,
+ launchOwnership,
+ launchRegistration
+ )) {
+ return null;
+ }
+
return examWindow;
} catch (error) {
console.error('Failed to open exam:', error);
window.showMessage('打开题目失败,请重试', 'error');
+ return null;
}
},
_openPdfWindow(exam, resolvedPdfUrl, options = {}) {
let pdfWin = null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const claimWindow = (candidateWindow, targetName = '') => !launchOwnership
+ || this._claimExamLaunchWindowOwnership(launchOwnership, candidateWindow, targetName);
+ const recordNavigation = (candidateWindow) => {
+ const navigationOwnership = this._recordExamWindowNavigation(
+ candidateWindow,
+ exam && exam.id
+ );
+ if (navigationOwnership) options.navigationOwnership = navigationOwnership;
+ return navigationOwnership;
+ };
if (options.reuseWindow && !options.reuseWindow.closed) {
try {
+ if (launchOwnership && !this._isExamLaunchOwnershipCurrent(
+ exam && exam.id,
+ launchOwnership,
+ null,
+ options.reuseWindow
+ )) {
+ return null;
+ }
options.reuseWindow.location.href = resolvedPdfUrl;
- options.reuseWindow.focus();
+ recordNavigation(options.reuseWindow);
+ // Direct assignment proves only the reused WindowProxy (and the
+ // actual readable .name). options.windowName was never resolved
+ // by window.open(), so it must not become browsing-context proof.
+ if (!claimWindow(options.reuseWindow)) {
+ return null;
+ }
+ this._markExamWindowReusePending(options.reuseWindow, {
+ unmanaged: true,
+ examId: exam && exam.id
+ });
+ try {
+ if (typeof options.reuseWindow.focus === 'function') options.reuseWindow.focus();
+ } catch (_) {}
pdfWin = options.reuseWindow;
} catch (reuseError) {
console.warn('[App] 无法复用已打开的标签,尝试重新打开:', reuseError);
@@ -7081,23 +8216,57 @@
if (options.target === 'tab') {
try {
pdfWin = window.open(resolvedPdfUrl, '_blank');
+ recordNavigation(pdfWin);
+ if (!claimWindow(pdfWin)) {
+ if (pdfWin && launchOwnership) {
+ return null;
+ }
+ pdfWin = null;
+ } else {
+ this._markExamWindowReusePending(pdfWin, {
+ unmanaged: true,
+ examId: exam && exam.id
+ });
+ }
} catch (_) { }
} else {
try {
pdfWin = window.open(resolvedPdfUrl, `pdf_${exam.id}`, 'width=1000,height=800,scrollbars=yes,resizable=yes,status=yes,toolbar=yes');
+ recordNavigation(pdfWin);
+ if (!claimWindow(pdfWin, `pdf_${exam.id}`)) {
+ if (pdfWin && launchOwnership) {
+ return null;
+ }
+ pdfWin = null;
+ } else {
+ this._markExamWindowReusePending(pdfWin, {
+ unmanaged: true,
+ examId: exam && exam.id
+ });
+ }
} catch (_) { }
}
}
if (!pdfWin) {
try {
+ if (!claimWindow(window)) {
+ return null;
+ }
window.location.href = resolvedPdfUrl;
+ recordNavigation(window);
+ this._markExamWindowReusePending(window, {
+ unmanaged: true,
+ examId: exam && exam.id
+ });
+ this._commitExamLaunchOwnership(launchOwnership);
return window;
} catch (error) {
throw new Error('无法打开PDF窗口,请检查弹窗设置');
}
}
+ this._commitExamLaunchOwnership(launchOwnership);
window.showMessage(`正在打开PDF: ${exam.title}`, 'info');
return pdfWin;
},
@@ -7137,13 +8306,310 @@
/**
* 在新窗口中打开题目
*/
+ _buildExamWindowRegistrationMarker(examId, registration) {
+ const info = registration || {};
+ const numericGeneration = Number(info.sessionGeneration);
+ return JSON.stringify([
+ String(examId || ''),
+ String(info.suiteSessionId || ''),
+ String(info.expectedSessionId || ''),
+ String(info.windowSessionToken || ''),
+ Number.isInteger(numericGeneration) ? numericGeneration : null
+ ]);
+ },
+
+ _rememberExamWindowReassignment(targetWindow, examId, registration) {
+ if (!targetWindow || !registration) return false;
+ if (!this._reassignedExamWindowRegistrations) {
+ this._reassignedExamWindowRegistrations = new WeakMap();
+ }
+ let markers = this._reassignedExamWindowRegistrations.get(targetWindow);
+ if (!markers) {
+ markers = new Set();
+ this._reassignedExamWindowRegistrations.set(targetWindow, markers);
+ }
+ markers.add(this._buildExamWindowRegistrationMarker(examId, registration));
+ return true;
+ },
+
+ _installExamNavigationProvisionalRegistration(examId, targetWindow, options = {}) {
+ const normalizedExamId = String(examId || '').trim();
+ if (!normalizedExamId || !targetWindow) return null;
+ try {
+ if (targetWindow.closed) return null;
+ } catch (_) {
+ return null;
+ }
+ if (!this.examWindows) this.examWindows = new Map();
+ const current = this.examWindows.get(normalizedExamId) || null;
+ if (current && current.window === targetWindow && current.launchProvisional === true) {
+ current.navigationOwnership = this._examWindowCommittedNavigationOwners
+ && this._examWindowCommittedNavigationOwners.get(targetWindow)
+ || current.navigationOwnership
+ || null;
+ return this._captureExamSessionRegistration(normalizedExamId, current);
+ }
+
+ const numericGeneration = Number(current && current.sessionGeneration);
+ const nextGeneration = Number.isSafeInteger(numericGeneration)
+ && numericGeneration >= 0
+ && numericGeneration < Number.MAX_SAFE_INTEGER
+ ? numericGeneration + 1
+ : 1;
+ let expectedSessionId = current && current.window === targetWindow
+ ? String(current.expectedSessionId || '').trim()
+ : '';
+ if (!expectedSessionId) {
+ expectedSessionId = String(this.generateSessionId(normalizedExamId) || '').trim();
+ }
+ const endpoint = this._resolveExamMessageEndpoint(options && options.expectedUrl || '');
+ const provisional = current && current.window === targetWindow
+ ? current
+ : {
+ window: targetWindow,
+ startTime: Date.now(),
+ status: 'reassigning',
+ expectedSessionId,
+ windowSessionToken: null,
+ windowSessionTokenSessionId: null,
+ sessionGeneration: nextGeneration,
+ closeMonitor: null
+ };
+ provisional.window = targetWindow;
+ provisional.status = 'reassigning';
+ provisional.handshakeDeferred = true;
+ provisional.windowReusePending = true;
+ provisional.launchProvisional = true;
+ provisional.navigationOwnership = this._examWindowCommittedNavigationOwners
+ && this._examWindowCommittedNavigationOwners.get(targetWindow)
+ || null;
+ provisional.suiteSessionId = options && options.suiteSessionId
+ ? String(options.suiteSessionId)
+ : null;
+ provisional.expectedUrl = endpoint.expectedUrl;
+ provisional.expectedOrigin = endpoint.expectedOrigin;
+ provisional.allowOpaqueOrigin = endpoint.allowOpaqueOrigin;
+ provisional.observedOrigin = '';
+ provisional.expectedSessionId = expectedSessionId;
+ if (!provisional.windowSessionToken
+ || String(provisional.windowSessionTokenSessionId || '') !== expectedSessionId) {
+ provisional.windowSessionToken = this.generateWindowSessionToken(normalizedExamId);
+ provisional.windowSessionTokenSessionId = expectedSessionId;
+ }
+ if (!Number.isInteger(provisional.sessionGeneration)) {
+ provisional.sessionGeneration = nextGeneration;
+ }
+ if (!this._examRegistrationEpochs) this._examRegistrationEpochs = new Map();
+ if (!current || current !== provisional) {
+ this._examRegistrationEpochs.set(
+ normalizedExamId,
+ Number(this._examRegistrationEpochs.get(normalizedExamId) || 0) + 1
+ );
+ }
+ this.examWindows.set(normalizedExamId, provisional);
+ return this._captureExamSessionRegistration(normalizedExamId, provisional);
+ },
+
+ _markExamWindowReusePending(targetWindow, options = {}) {
+ if (!targetWindow || !this.examWindows) {
+ return 0;
+ }
+ const unmanagedTarget = options && options.unmanaged === true;
+ const launchExamId = options && options.examId != null
+ ? String(options.examId).trim()
+ : '';
+ let marked = 0;
+ for (const [candidateExamId, current] of Array.from(this.examWindows.entries())) {
+ const normalizedCandidateExamId = String(candidateExamId || '').trim();
+ const reusesSameWindow = Boolean(current && current.window === targetWindow);
+ const supersedesSameExam = Boolean(
+ current
+ && launchExamId
+ && normalizedCandidateExamId === launchExamId
+ );
+ if (!reusesSameWindow && !supersedesSameExam) {
+ continue;
+ }
+ const previousSessionId = String(current.expectedSessionId || '').trim();
+ const previousSuiteSessionId = String(current.suiteSessionId || '').trim();
+ if (previousSuiteSessionId && reusesSameWindow) {
+ this._rememberExamWindowReassignment(targetWindow, candidateExamId, current);
+ }
+ const numericGeneration = Number(current.sessionGeneration);
+ const nextGeneration = Number.isSafeInteger(numericGeneration)
+ && numericGeneration >= 0
+ && numericGeneration < Number.MAX_SAFE_INTEGER
+ ? numericGeneration + 1
+ : 1;
+ let pendingSessionId = '';
+ let pendingToken = null;
+ try {
+ pendingSessionId = typeof this.generateSessionId === 'function'
+ ? String(this.generateSessionId(candidateExamId) || '')
+ : '';
+ pendingToken = typeof this.generateWindowSessionToken === 'function'
+ ? this.generateWindowSessionToken(candidateExamId)
+ : null;
+ } catch (_) {}
+ if (!pendingSessionId) {
+ pendingSessionId = `reuse-pending:${String(candidateExamId)}:${Date.now()}:${nextGeneration}`;
+ }
+ const activeSuite = this.currentSuiteSession;
+ const activeSuiteBinding = activeSuite
+ && activeSuite.windowBinding
+ && typeof activeSuite.windowBinding === 'object'
+ ? activeSuite.windowBinding
+ : null;
+ const recoveryOwnedBySuiteTeardown = Boolean(
+ previousSuiteSessionId
+ && activeSuite
+ && String(activeSuite.id || '') === previousSuiteSessionId
+ && String(activeSuiteBinding && activeSuiteBinding.examId || '') === String(candidateExamId)
+ && String(activeSuiteBinding && activeSuiteBinding.expectedSessionId || '') === previousSessionId
+ && String(activeSuiteBinding && activeSuiteBinding.windowSessionToken || '') === String(current.windowSessionToken || '')
+ && Number(activeSuiteBinding && activeSuiteBinding.sessionGeneration) === Number(current.sessionGeneration)
+ );
+ // Replace (rather than mutate) the registration synchronously after
+ // navigation and before openExam's first await. Delayed suite teardown
+ // and queued draft writes can no longer mistake the reused WindowProxy
+ // for the old suite attempt during that gap.
+ const pendingInfo = {
+ ...current,
+ window: targetWindow,
+ status: 'reassigning',
+ navigationOwnership: this._examWindowCommittedNavigationOwners
+ && this._examWindowCommittedNavigationOwners.get(targetWindow)
+ || null,
+ suiteSessionId: null,
+ expectedSessionId: pendingSessionId,
+ sessionId: null,
+ sessionGeneration: nextGeneration,
+ windowSessionToken: pendingToken,
+ windowSessionTokenSessionId: pendingToken ? pendingSessionId : null,
+ handshakeDeferred: true,
+ windowReusePending: true,
+ reassignedFromExpectedSessionId: previousSessionId || null,
+ reassignedFromSuiteTeardownOwner: recoveryOwnedBySuiteTeardown,
+ closeMonitor: null
+ };
+
+ // The listener and retry timer belong to the document that was just
+ // navigated away. Leaving either alive would let the replacement page
+ // complete a provisional handshake before openExam installs its final
+ // registration.
+ if (this.messageHandlers && this.messageHandlers.has(candidateExamId)) {
+ const previousHandler = this.messageHandlers.get(candidateExamId);
+ try {
+ if (previousHandler) window.removeEventListener('message', previousHandler);
+ } catch (_) {}
+ this.messageHandlers.delete(candidateExamId);
+ }
+ if (this._handshakeTimers && this._handshakeTimers.has(candidateExamId)) {
+ try { clearInterval(this._handshakeTimers.get(candidateExamId)); } catch (_) {}
+ this._handshakeTimers.delete(candidateExamId);
+ }
+ if (current.closeMonitor) {
+ try { clearInterval(current.closeMonitor); } catch (_) {}
+ }
+
+ if (unmanagedTarget) {
+ // A raw PDF has no enhanced-page handshake and therefore no later
+ // setupExamWindowManagement call to replace this provisional entry.
+ // Remove it now, while retaining the old suite recovery for the
+ // already-frozen delayed teardown.
+ this.examWindows.delete(candidateExamId);
+ const suite = this.currentSuiteSession;
+ const binding = suite && suite.windowBinding && typeof suite.windowBinding === 'object'
+ ? suite.windowBinding
+ : null;
+ const isExactSuiteOwner = Boolean(
+ previousSuiteSessionId
+ && suite
+ && String(suite.id || '') === previousSuiteSessionId
+ && String(binding && binding.examId || '') === String(candidateExamId)
+ && String(binding && binding.expectedSessionId || '') === previousSessionId
+ && String(binding && binding.windowSessionToken || '') === String(current.windowSessionToken || '')
+ && Number(binding && binding.sessionGeneration) === Number(current.sessionGeneration)
+ );
+ if (!isExactSuiteOwner
+ && previousSessionId
+ && typeof this._discardActiveSessionsForExam === 'function') {
+ const recoveryCleanupGuard = () => {
+ const replacement = this.examWindows && this.examWindows.get(candidateExamId);
+ return !replacement
+ || String(replacement.expectedSessionId || '').trim() !== previousSessionId;
+ };
+ Promise.resolve(this._discardActiveSessionsForExam(candidateExamId, {
+ expectedSessionId: previousSessionId,
+ commitGuard: recoveryCleanupGuard
+ })).catch((error) => {
+ console.warn('[App] 清理 PDF 复用窗口旧恢复会话失败:', candidateExamId, error);
+ });
+ }
+ } else {
+ if (!this._examRegistrationEpochs) this._examRegistrationEpochs = new Map();
+ const registrationEpochKey = String(candidateExamId || '');
+ this._examRegistrationEpochs.set(
+ registrationEpochKey,
+ Number(this._examRegistrationEpochs.get(registrationEpochKey) || 0) + 1
+ );
+ this.examWindows.set(candidateExamId, pendingInfo);
+ }
+ marked += 1;
+ }
+ return marked;
+ },
+
openExamWindow(examUrl, exam, options = {}) {
const reuseWindow = options.reuseWindow;
const finalUrl = this._ensureAbsoluteUrl(examUrl);
+ const launchOwnership = options && options.launchOwnership || null;
+ const claimWindow = (candidateWindow, targetName = '') => !launchOwnership
+ || this._claimExamLaunchWindowOwnership(launchOwnership, candidateWindow, targetName);
+ const recordNavigation = (candidateWindow) => {
+ const navigationOwnership = this._recordExamWindowNavigation(
+ candidateWindow,
+ options.examId
+ );
+ if (navigationOwnership) options.navigationOwnership = navigationOwnership;
+ return navigationOwnership;
+ };
+ const markWindowReuse = (candidateWindow) => {
+ const marked = Number(this._markExamWindowReusePending(candidateWindow, {
+ examId: options.examId
+ })) || 0;
+ const navigationRegistration = this._installExamNavigationProvisionalRegistration(
+ options.examId,
+ candidateWindow,
+ { ...options, expectedUrl: finalUrl }
+ );
+ if (navigationRegistration) {
+ options.navigationRegistration = navigationRegistration;
+ }
+ if (marked > 0) {
+ options.windowReuseDetected = true;
+ }
+ return marked;
+ };
if (reuseWindow && !reuseWindow.closed) {
try {
+ if (launchOwnership && !this._isExamLaunchOwnershipCurrent(
+ options.examId,
+ launchOwnership,
+ null,
+ reuseWindow
+ )) {
+ return null;
+ }
reuseWindow.location.href = finalUrl;
- reuseWindow.focus();
+ recordNavigation(reuseWindow);
+ if (!claimWindow(reuseWindow)) {
+ return null;
+ }
+ markWindowReuse(reuseWindow);
+ try {
+ if (typeof reuseWindow.focus === 'function') reuseWindow.focus();
+ } catch (_) {}
return reuseWindow;
} catch (error) {
console.warn('[App] 复用窗口失败,尝试重新打开:', error);
@@ -7157,6 +8623,15 @@
: '_blank';
try {
tabWindow = window.open(finalUrl, requestedName);
+ recordNavigation(tabWindow);
+ if (!claimWindow(tabWindow, requestedName)) {
+ if (tabWindow && launchOwnership) {
+ return null;
+ }
+ tabWindow = null;
+ } else {
+ markWindowReuse(tabWindow);
+ }
if (tabWindow && typeof tabWindow.focus === 'function') {
tabWindow.focus();
}
@@ -7178,12 +8653,26 @@
`exam_${exam.id}`,
windowFeatures
);
+ recordNavigation(examWindow);
+ if (!claimWindow(examWindow, `exam_${exam.id}`)) {
+ if (examWindow && launchOwnership) {
+ return null;
+ }
+ examWindow = null;
+ } else {
+ markWindowReuse(examWindow);
+ }
} catch (_) { }
// 弹窗被拦截时,降级为当前窗口打开,确保用户可进入练习页
if (!examWindow) {
try {
+ if (!claimWindow(window)) {
+ return null;
+ }
window.location.href = finalUrl;
+ recordNavigation(window);
+ markWindowReuse(window);
return window; // 以当前窗口作为返回引用
} catch (e) {
throw new Error('无法打开题目页面,请检查弹窗/文件路径设置');
@@ -7214,6 +8703,104 @@
}
},
+ _resolveExamMessageEndpoint(rawUrl) {
+ const href = this._ensureAbsoluteUrl(rawUrl);
+ if (!href) {
+ return { expectedUrl: '', expectedOrigin: '', allowOpaqueOrigin: false };
+ }
+ try {
+ const parsed = new URL(href, window.location.href);
+ // Chromium reports URL.origin as "file://" while postMessage events
+ // between file pages use the opaque origin "null".
+ if (parsed.protocol === 'file:') {
+ return {
+ expectedUrl: parsed.href,
+ expectedOrigin: 'null',
+ allowOpaqueOrigin: true
+ };
+ }
+ if (parsed.origin && parsed.origin !== 'null') {
+ return {
+ expectedUrl: parsed.href,
+ expectedOrigin: parsed.origin,
+ allowOpaqueOrigin: false
+ };
+ }
+ } catch (_) {
+ // An unparseable launch URL must never degrade to wildcard messaging.
+ }
+ return { expectedUrl: '', expectedOrigin: '', allowOpaqueOrigin: false };
+ },
+
+ _reportExamMessageRejected(examId, type, reason, event = null) {
+ if (!this._examMessageRejectionCounts) this._examMessageRejectionCounts = new Map();
+ const key = `${String(reason || 'unknown')}:${String(type || 'unknown')}`;
+ const count = Number(this._examMessageRejectionCounts.get(key) || 0) + 1;
+ this._examMessageRejectionCounts.set(key, count);
+ const incomingOrigin = event && typeof event.origin === 'string' ? event.origin : '';
+ const originClass = incomingOrigin === 'null'
+ ? 'opaque'
+ : (incomingOrigin && window.location && incomingOrigin === window.location.origin ? 'same-origin' : (incomingOrigin ? 'cross-origin' : 'missing'));
+ const detail = {
+ reason: String(reason || 'unknown'),
+ messageType: String(type || 'unknown'),
+ examId: String(examId || ''),
+ originClass,
+ count
+ };
+ if (count === 1 || count % 10 === 0) {
+ console.debug('[ExamMessage] rejected', detail);
+ }
+ try {
+ window.dispatchEvent(new CustomEvent('ielts-atlas:message-rejected', { detail }));
+ } catch (_) {
+ // Telemetry must never affect the security decision.
+ }
+ return false;
+ },
+
+ _postExamMessage(examId, targetWindow, type, data = {}, options = {}) {
+ if (!targetWindow || targetWindow.closed || typeof targetWindow.postMessage !== 'function') {
+ return false;
+ }
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ if (expectedRegistration && (
+ expectedRegistration.window !== targetWindow
+ || (launchOwnership
+ ? !this._isOwnedExamLaunchRegistrationCurrent(examId, launchOwnership, expectedRegistration)
+ : !this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ )) {
+ return false;
+ }
+ const windowInfo = expectedRegistration
+ ? expectedRegistration.windowInfo
+ : this.ensureExamWindowSession(examId, targetWindow);
+ const normalizedType = String(type || '').trim().toUpperCase();
+ if ((normalizedType === 'INIT_SESSION' || normalizedType === 'INIT_EXAM_SESSION')
+ && windowInfo.handshakeDeferred === true) {
+ return false;
+ }
+ const targetOrigin = windowInfo.expectedOrigin && windowInfo.expectedOrigin !== 'null'
+ ? windowInfo.expectedOrigin
+ : (windowInfo.allowOpaqueOrigin ? '*' : '');
+ if (!targetOrigin) {
+ console.warn('[App] 拒绝向未绑定可信 origin 的题目窗口发送消息:', type, examId);
+ return false;
+ }
+ const payload = Object.assign({}, data || {}, {
+ examId: data && data.examId != null ? data.examId : examId,
+ windowSessionToken: windowInfo.windowSessionToken
+ });
+ targetWindow.postMessage({
+ type,
+ data: payload,
+ source: 'exam_host',
+ timestamp: Date.now()
+ }, targetOrigin);
+ return true;
+ },
+
_appendSuiteContextToExamUrl(rawUrl, options = {}) {
if (!rawUrl) {
return rawUrl;
@@ -7251,6 +8838,19 @@
}
},
+ _appendEndlessContextToExamUrl(rawUrl) {
+ if (!rawUrl) {
+ return rawUrl;
+ }
+ try {
+ const parsed = new URL(rawUrl, (window && window.location && window.location.href) ? window.location.href : undefined);
+ parsed.searchParams.set('endless', '1');
+ return parsed.toString();
+ } catch (_) {
+ return rawUrl;
+ }
+ },
+
_normalizeSuiteTimerAnchor(value) {
if (value == null || value === '') {
return null;
@@ -7415,6 +9015,58 @@
if (!examWindow || examWindow.closed) {
return examWindow;
}
+ const retryOptions = options && typeof options === 'object' ? options : {};
+ const launchOwnership = retryOptions.launchOwnership || null;
+ const examId = retryOptions.examId;
+ const navigationRegistration = retryOptions.navigationRegistration || null;
+ const expectedNavigationOwnership = navigationRegistration
+ && navigationRegistration.navigationOwnership
+ || retryOptions.navigationOwnership
+ || null;
+ const recordNavigation = (targetWindow) => {
+ const navigationOwnership = this._recordExamWindowNavigation(targetWindow, examId);
+ if (navigationOwnership) retryOptions.navigationOwnership = navigationOwnership;
+ return navigationOwnership;
+ };
+ const ownsGuardWindow = (targetWindow = examWindow) => {
+ if (navigationRegistration) {
+ const currentWindowInfo = examId && this.examWindows
+ ? this.examWindows.get(examId)
+ : null;
+ return Boolean(
+ examId
+ && targetWindow === navigationRegistration.window
+ && currentWindowInfo
+ && currentWindowInfo.window === targetWindow
+ && currentWindowInfo.navigationOwnership === expectedNavigationOwnership
+ && this._isExamWindowNavigationCurrent(
+ targetWindow,
+ expectedNavigationOwnership
+ )
+ );
+ }
+ return !launchOwnership || Boolean(
+ examId
+ && targetWindow
+ && this._isExamLaunchOwnershipCurrent(
+ examId,
+ launchOwnership,
+ null,
+ targetWindow
+ )
+ );
+ };
+ if (!ownsGuardWindow()) {
+ return examWindow;
+ }
+ // Separate file:// documents have opaque origins. Reading a child
+ // window's location is forbidden even when both files are local,
+ // and the launch URL has already been resolved by openExam().
+ if (typeof window !== 'undefined'
+ && window.location
+ && window.location.protocol === 'file:') {
+ return examWindow;
+ }
const resolveHref = (targetWindow) => {
try {
@@ -7434,9 +9086,7 @@
const currentHref = resolveHref(examWindow);
const normalizedHref = (currentHref || '').toLowerCase();
- const retryOptions = options && typeof options === 'object' ? options : {};
const retryCount = Number.isFinite(retryOptions.guardRetryCount) ? retryOptions.guardRetryCount : 0;
- const examId = retryOptions.examId;
if (examId && this.examWindows && this.examWindows.has(examId)) {
const windowInfo = this.examWindows.get(examId);
@@ -7457,11 +9107,15 @@
const placeholderUrl = this._buildExamPlaceholderUrl(exam, retryOptions);
if (placeholderUrl) {
try {
+ if (!ownsGuardWindow()) {
+ return examWindow;
+ }
if (examWindow.location && typeof examWindow.location.replace === 'function') {
examWindow.location.replace(placeholderUrl);
} else {
examWindow.location.href = placeholderUrl;
}
+ recordNavigation(examWindow);
return examWindow;
} catch (forceError) {
console.warn('[App] 套题模式强制跳转占位页失败,继续使用原窗口:', forceError);
@@ -7477,6 +9131,9 @@
try {
setTimeout(() => {
try {
+ if (!ownsGuardWindow()) {
+ return;
+ }
this._guardExamWindowContent(examWindow, exam, {
...retryOptions,
guardRetryCount: nextCount
@@ -7515,20 +9172,32 @@
}
try {
+ if (!ownsGuardWindow()) {
+ return examWindow;
+ }
if (examWindow.location && typeof examWindow.location.replace === 'function') {
examWindow.location.replace(placeholderUrl);
+ recordNavigation(examWindow);
return examWindow;
}
examWindow.location.href = placeholderUrl;
+ recordNavigation(examWindow);
return examWindow;
} catch (navigationError) {
console.warn('[App] 题目窗口导航占位页失败,尝试重新打开:', navigationError);
try {
+ if (!ownsGuardWindow()) {
+ return examWindow;
+ }
const windowName = (options && options.windowName)
? String(options.windowName)
: (examWindow.name || '_blank');
const reopened = window.open(placeholderUrl, windowName);
- if (reopened) {
+ recordNavigation(reopened);
+ if (reopened
+ && (!launchOwnership
+ || (this._claimExamLaunchWindowOwnership(launchOwnership, reopened)
+ && ownsGuardWindow(reopened)))) {
return reopened;
}
} catch (openError) {
@@ -7542,6 +9211,7 @@
_buildExamPlaceholderUrl(exam = null, options = {}) {
const basePath = 'templates/exam-placeholder.html';
const params = new URLSearchParams();
+ params.set('suite_test', '1');
const safeSet = (key, value) => {
if (value == null) {
@@ -7618,9 +9288,29 @@
/**
* 注入数据采集脚本到练习页面
*/
- injectDataCollectionScript(examWindow, examId, exam = null) {
+ injectDataCollectionScript(examWindow, examId, exam = null, options = {}) {
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsInjection = () => {
+ if (!expectedRegistration) {
+ return true;
+ }
+ if (expectedRegistration.window !== examWindow) {
+ return false;
+ }
+ return launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ expectedRegistration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration);
+ };
+ if (!ownsInjection()) {
+ return false;
+ }
if (this._isUnifiedReadingExam(exam)) {
- return;
+ return false;
}
const isListeningExam = typeof this._isListeningLibraryExam === 'function'
@@ -7647,7 +9337,7 @@
};
const injectScript = () => {
try {
- if (!examWindow || examWindow.closed) {
+ if (!ownsInjection() || !examWindow || examWindow.closed) {
console.warn('[DataInjection] 目标窗口已关闭');
return;
}
@@ -7656,7 +9346,7 @@
? (examWindow.__listeningBridgeGetState || examWindow.__listeningBridgeComplete)
: (examWindow.practicePageEnhancer && typeof examWindow.practicePageEnhancer.initialize === 'function');
if (bridgeReady) {
- this.initializePracticeSession(examWindow, examId);
+ this.initializePracticeSession(examWindow, examId, options);
return;
}
@@ -7673,6 +9363,11 @@
return;
}
+ if (isListeningExam && doc.documentElement
+ && doc.documentElement.dataset.listeningWrapper === 'true') {
+ return;
+ }
+
// 套题占位页自带消息协议与按钮,不需要再注入增强器(避免重复发送 PRACTICE_COMPLETE)
try {
if (doc.getElementById('complete-exam-btn') && doc.getElementById('force-ready-btn')) {
@@ -7688,12 +9383,15 @@
: (host && typeof host.querySelector === 'function' ? host.querySelector(existingSelector) : null);
if (existingEnhancerScript) {
if (isListeningExam && (examWindow.__listeningBridgeGetState || examWindow.__listeningBridgeComplete)) {
- this.initializePracticeSession(examWindow, examId);
+ this.initializePracticeSession(examWindow, examId, options);
}
return;
}
let enhancerInjected = false;
const appendEnhancer = () => {
+ if (!ownsInjection()) {
+ return;
+ }
const alreadyReady = isListeningExam
? (examWindow.__listeningBridgeGetState || examWindow.__listeningBridgeComplete)
: (examWindow.practicePageEnhancer && typeof examWindow.practicePageEnhancer.initialize === 'function');
@@ -7710,7 +9408,9 @@
scriptEl.onload = () => {
setTimeout(() => {
try {
- this.initializePracticeSession(examWindow, examId);
+ if (ownsInjection()) {
+ this.initializePracticeSession(examWindow, examId, options);
+ }
} catch (sessionError) {
console.warn('[DataInjection] 初始化练习会话失败:', sessionError);
}
@@ -7718,28 +9418,33 @@
};
scriptEl.onerror = (loadError) => {
+ if (!ownsInjection()) {
+ return;
+ }
console.warn('[DataInjection] 加载增强器失败:', loadError);
scriptEl.remove();
if (!isListeningExam) {
- this.injectInlineScript(examWindow, examId);
+ this.injectInlineScript(examWindow, examId, options);
}
};
- host.appendChild(scriptEl);
+ if (ownsInjection()) {
+ host.appendChild(scriptEl);
+ }
};
appendEnhancer();
} catch (error) {
console.error('[DataInjection] 注入增强器脚本时出错:', error);
- if (!isListeningExam) {
- this.injectInlineScript(examWindow, examId);
+ if (ownsInjection() && !isListeningExam) {
+ this.injectInlineScript(examWindow, examId, options);
}
}
};
const checkAndInject = () => {
try {
- if (!examWindow || examWindow.closed) {
+ if (!ownsInjection() || !examWindow || examWindow.closed) {
return;
}
@@ -7750,23 +9455,43 @@
setTimeout(checkAndInject, 200);
}
} catch (error) {
- console.warn('[DataInjection] 检测题目页面就绪状态失败:', error);
+ if (ownsInjection()) {
+ console.warn('[DataInjection] 检测题目页面就绪状态失败:', error);
+ }
}
};
setTimeout(checkAndInject, 300);
+ return true;
},
/**
* 内联脚本注入(备用方案)
*/
- injectInlineScript(examWindow, examId) {
+ injectInlineScript(examWindow, examId, options = {}) {
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsInjection = () => !expectedRegistration || (
+ expectedRegistration.window === examWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ expectedRegistration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ );
try {
+ if (!ownsInjection()) {
+ return false;
+ }
if (!examWindow || !examWindow.document || !examWindow.document.head) {
throw new Error('inline_target_unavailable');
}
const sessionToken = `${examId}_${Date.now()}`;
+ // 备用方案注入时同步读取 host 端启动时捕获的题库配置 ID,确保 enhancer 也能拿到来源。
+ const launchLibraryConfigurationId = this._readLaunchLibraryConfigurationId(examId);
const inlineScript = examWindow.document.createElement('script');
inlineScript.type = 'text/javascript';
inlineScript.textContent = `
@@ -7782,7 +9507,26 @@
examId: ${JSON.stringify(examId)},
startTime: Date.now(),
answers: {},
- suite: {
+ // 启动时 host 端捕获的题库配置 ID;每条 INIT_SESSION 还会再次以
+ // initData.libraryConfigurationId 同步更新,确保即使延迟加载也能拿到正确来源。
+ libraryConfigurationId: ${JSON.stringify(launchLibraryConfigurationId || null)},
+ expectedParentOrigin: (function() {
+ try {
+ if (!document.referrer) return '';
+ var parsed = new URL(document.referrer, window.location.href);
+ // Chromium: file URL.origin is "file://", postMessage event.origin is "null".
+ if (parsed.protocol === 'file:') return '';
+ if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return '';
+ return parsed.origin;
+ } catch (_) {
+ return '';
+ }
+ })(),
+ parentOrigin: '',
+ parentOriginIsOpaque: false,
+ windowSessionToken: '',
+ submissionId: '',
+ suite: {
active: false,
sessionId: null,
guarded: false,
@@ -7791,12 +9535,41 @@
}
};
+ function createSubmissionId() {
+ try {
+ if (window.crypto && typeof window.crypto.randomUUID === 'function') {
+ return 'inline-submit-' + window.crypto.randomUUID();
+ }
+ } catch (_) {}
+ return 'inline-submit-' + (state.sessionId || state.examId || 'session') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2);
+ }
+
function sendMessage(type, data) {
if (!parentWindow || typeof parentWindow.postMessage !== 'function') {
return;
}
try {
- parentWindow.postMessage({ type: type, data: data || {} }, '*');
+ var targetOrigin = state.parentOrigin && state.parentOrigin !== 'null'
+ ? state.parentOrigin
+ : (state.expectedParentOrigin || (window.location.protocol === 'file:' ? '*' : ''));
+ if (!targetOrigin) return;
+ var payload = Object.assign({}, data || {});
+ if (type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT') {
+ if (!state.submissionId) {
+ state.submissionId = payload.submissionId || createSubmissionId();
+ }
+ payload.sessionId = payload.sessionId || state.sessionId || null;
+ payload.submissionId = payload.submissionId || state.submissionId;
+ }
+ parentWindow.postMessage({
+ type: type,
+ data: Object.assign(payload, {
+ suiteSessionId: state.suite.sessionId || null,
+ windowSessionToken: state.windowSessionToken || null
+ }),
+ source: 'inline_collector',
+ timestamp: Date.now()
+ }, targetOrigin);
} catch (error) {
console.warn('[InlineEnhancer] 无法发送消息:', error);
}
@@ -7913,11 +9686,21 @@
function handleInitSession(message) {
var initData = message && message.data ? message.data : {};
if (initData.sessionId) {
+ if (state.sessionId && String(state.sessionId) !== String(initData.sessionId)) {
+ state.submissionId = '';
+ }
state.sessionId = initData.sessionId;
}
if (initData.examId) {
state.examId = initData.examId;
}
+ // host 启动时捕获并随 INIT_SESSION 携带的题库配置 ID;这里同步更新 state,
+ // 在 enhancer 回传完成结果时一并透传,避免后续提交再读当前激活题库。
+ if (typeof initData.libraryConfigurationId !== 'undefined'
+ && initData.libraryConfigurationId !== null
+ && initData.libraryConfigurationId !== '') {
+ state.libraryConfigurationId = initData.libraryConfigurationId;
+ }
if (initData.suiteSessionId) {
state.suite.active = true;
state.suite.sessionId = initData.suiteSessionId;
@@ -7939,10 +9722,55 @@
}
if (message.type === 'INIT_SESSION') {
+ var initData = message.data || {};
+ var incomingOrigin = event && typeof event.origin === 'string' ? event.origin : '';
+ var declaredOrigin = typeof initData.parentOrigin === 'string' ? initData.parentOrigin : '';
+ var incomingToken = typeof initData.windowSessionToken === 'string'
+ ? initData.windowSessionToken.trim()
+ : '';
+ if (!event || event.source !== parentWindow || message.source !== 'exam_host' || !incomingToken) return;
+ var expectedParentOrigin = state.expectedParentOrigin
+ && state.expectedParentOrigin !== 'file://'
+ && String(state.expectedParentOrigin).indexOf('file:') !== 0
+ ? state.expectedParentOrigin
+ : '';
+ if (expectedParentOrigin) {
+ if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) return;
+ state.parentOrigin = expectedParentOrigin;
+ state.parentOriginIsOpaque = false;
+ } else if (window.location.protocol === 'file:') {
+ var trustedFileOrigin = (incomingOrigin === 'null' || incomingOrigin === 'file://')
+ && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://');
+ if (!trustedFileOrigin) return;
+ state.parentOrigin = 'null';
+ state.parentOriginIsOpaque = true;
+ } else {
+ var trustedWebOrigin = !!incomingOrigin
+ && incomingOrigin !== 'null'
+ && incomingOrigin !== 'file://'
+ && declaredOrigin === incomingOrigin;
+ if (!trustedWebOrigin) return;
+ state.parentOrigin = incomingOrigin;
+ state.parentOriginIsOpaque = false;
+ }
+ state.windowSessionToken = incomingToken;
handleInitSession(message);
return;
}
+ var messageData = message.data || {};
+ var messageToken = typeof messageData.windowSessionToken === 'string'
+ ? messageData.windowSessionToken.trim()
+ : '';
+ var messageOrigin = event && typeof event.origin === 'string' ? event.origin : '';
+ var originMatches = state.parentOriginIsOpaque
+ ? (messageOrigin === 'null' || messageOrigin === 'file://')
+ : Boolean(state.parentOrigin && messageOrigin === state.parentOrigin);
+ if (!event || event.source !== parentWindow || message.source !== 'exam_host'
+ || !originMatches || !state.windowSessionToken || messageToken !== state.windowSessionToken) {
+ return;
+ }
+
if (!state.suite.active) {
return;
}
@@ -8004,7 +9832,9 @@
examId: state.examId,
duration: Math.round((Date.now() - state.startTime) / 1000),
answers: state.answers,
- source: 'inline_collector'
+ source: 'inline_collector',
+ // 透传启动时捕获的题库配置 ID,便于 host 端 completeAttempt 写入 metadata 来源。
+ libraryConfigurationId: state.libraryConfigurationId || null
});
}
};
@@ -8021,35 +9851,65 @@
})();
`;
+ if (!ownsInjection()) {
+ return false;
+ }
examWindow.document.head.appendChild(inlineScript);
setTimeout(() => {
- this.initializePracticeSession(examWindow, examId);
+ if (ownsInjection()) {
+ this.initializePracticeSession(examWindow, examId, options);
+ }
}, 300);
-
+ return true;
} catch (error) {
+ if (!ownsInjection()) {
+ return false;
+ }
console.error('[DataInjection] 内联脚本注入失败:', error);
this.handleInjectionError(examId, error);
+ return false;
}
},
/**
* 初始化练习会话
*/
- initializePracticeSession(examWindow, examId) {
+ initializePracticeSession(examWindow, examId, options = {}) {
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsInitialization = () => !expectedRegistration || (
+ expectedRegistration.window === examWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ expectedRegistration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ );
try {
+ if (!ownsInitialization()) {
+ return false;
+ }
const now = Date.now();
- let existingInfo = null;
- if (this.examWindows && this.examWindows.has(examId)) {
+ let existingInfo = expectedRegistration
+ ? expectedRegistration.windowInfo
+ : null;
+ if (!existingInfo && this.examWindows && this.examWindows.has(examId)) {
existingInfo = this.examWindows.get(examId) || null;
}
- let suiteSessionId = existingInfo && existingInfo.suiteSessionId
+ const hasExplicitSuiteOwnership = Boolean(
+ existingInfo
+ && Object.prototype.hasOwnProperty.call(existingInfo, 'suiteSessionId')
+ );
+ let suiteSessionId = hasExplicitSuiteOwnership && existingInfo.suiteSessionId
? existingInfo.suiteSessionId
: null;
- if (!suiteSessionId && this.currentSuiteSession) {
+ if (!hasExplicitSuiteOwnership && !suiteSessionId && this.currentSuiteSession) {
const activeMatch = this.currentSuiteSession.activeExamId === examId;
const sequenceIndex = Number.isInteger(this.currentSuiteSession.currentIndex)
? this.currentSuiteSession.currentIndex
@@ -8064,7 +9924,12 @@
}
}
- const windowInfo = this.ensureExamWindowSession(examId, examWindow);
+ const windowInfo = expectedRegistration
+ ? expectedRegistration.windowInfo
+ : this.ensureExamWindowSession(examId, examWindow);
+ if (!ownsInitialization()) {
+ return false;
+ }
if (suiteSessionId && !windowInfo.suiteSessionId) {
windowInfo.suiteSessionId = suiteSessionId;
}
@@ -8082,10 +9947,10 @@
const initPayload = this._buildExamInitPayload(examId, windowInfo, { timestamp: now });
// 发送会话初始化消息
- examWindow.postMessage({
- type: 'INIT_SESSION',
- data: initPayload
- }, '*');
+ if (!ownsInitialization()) {
+ return false;
+ }
+ this._postExamMessage(examId, examWindow, 'INIT_SESSION', initPayload);
// 存储会话信息
if (!this.examWindows) {
@@ -8113,9 +9978,10 @@
suiteSessionId: suiteSessionId || null
}));
}
-
+ return true;
} catch (error) {
console.error('[DataInjection] 会话初始化失败:', error);
+ return false;
}
},
@@ -8133,13 +9999,7 @@
type: 'script_injection_error'
};
- // 保存错误日志到本地存储
- const errorLogs = await storage.get('injection_errors', []);
- errorLogs.push(errorInfo);
- if (errorLogs.length > 50) {
- errorLogs.splice(0, errorLogs.length - 50); // 保留最近50条错误
- }
- await storage.set('injection_errors', errorLogs);
+ console.warn('[DataInjection] 诊断信息:', errorInfo);
// 不显示错误给用户,静默处理
console.warn('[DataInjection] 将使用模拟数据模式');
@@ -8149,18 +10009,29 @@
* 设置题目窗口管理
*/
setupExamWindowManagement(examWindow, examId, exam = null, options = {}) {
- if (!examWindow) {
+ if (!examWindow || examWindow.closed) {
console.warn('[App] 缺少题目窗口引用,无法完成窗口管理');
return;
}
- try {
- const guardedWindow = this._guardExamWindowContent(examWindow, exam, { ...options, examId });
- if (guardedWindow) {
- examWindow = guardedWindow;
+ const expectedRegistration = options && options.expectedRegistration || null;
+ if (expectedRegistration && !this._isOpenExamRegistrationCurrent(
+ examId,
+ expectedRegistration,
+ examWindow
+ )) {
+ return null;
+ }
+
+ if (!(options && options.skipContentGuard === true)) {
+ try {
+ const guardedWindow = this._guardExamWindowContent(examWindow, exam, { ...options, examId });
+ if (guardedWindow) {
+ examWindow = guardedWindow;
+ }
+ } catch (guardError) {
+ console.warn('[App] 守护题目窗口内容失败:', guardError);
}
- } catch (guardError) {
- console.warn('[App] 守护题目窗口内容失败:', guardError);
}
// 存储窗口引用
@@ -8168,12 +10039,49 @@
this.examWindows = new Map();
}
- this.examWindows.set(examId, {
+ const previousWindowInfo = this.examWindows.get(examId);
+ if (previousWindowInfo && previousWindowInfo.closeMonitor) {
+ try {
+ clearInterval(previousWindowInfo.closeMonitor);
+ } catch (_) {}
+ }
+
+ const endpoint = this._resolveExamMessageEndpoint(
+ options && options.expectedUrl
+ ? options.expectedUrl
+ : (exam ? this.buildExamUrl(exam) : '')
+ );
+ const adoptedBinding = options && options.adoptWindowBinding && typeof options.adoptWindowBinding === 'object'
+ ? options.adoptWindowBinding
+ : null;
+ const adoptedSessionId = adoptedBinding && typeof adoptedBinding.expectedSessionId === 'string'
+ ? adoptedBinding.expectedSessionId.trim()
+ : '';
+ const adoptedToken = adoptedBinding && typeof adoptedBinding.windowSessionToken === 'string'
+ ? adoptedBinding.windowSessionToken.trim()
+ : '';
+ const adoptedGeneration = Number(adoptedBinding && adoptedBinding.sessionGeneration);
+ const canAdoptBinding = Boolean(
+ adoptedSessionId
+ && adoptedToken
+ && Number.isInteger(adoptedGeneration)
+ && adoptedGeneration > 0
+ );
+ const windowInfo = {
window: examWindow,
+ navigationOwnership: this._examWindowCommittedNavigationOwners
+ && this._examWindowCommittedNavigationOwners.get(examWindow)
+ || (previousWindowInfo && previousWindowInfo.navigationOwnership)
+ || null,
startTime: Date.now(),
status: 'active',
- expectedSessionId: null,
- origin: (typeof window !== 'undefined' && window.location) ? window.location.origin : '',
+ expectedSessionId: canAdoptBinding ? adoptedSessionId : null,
+ windowSessionToken: canAdoptBinding ? adoptedToken : null,
+ windowSessionTokenSessionId: canAdoptBinding ? adoptedSessionId : null,
+ expectedUrl: endpoint.expectedUrl,
+ expectedOrigin: endpoint.expectedOrigin,
+ allowOpaqueOrigin: endpoint.allowOpaqueOrigin,
+ observedOrigin: '',
suiteSessionId: (options && options.suiteSessionId) ? options.suiteSessionId : null,
suiteFlowMode: (options && options.suiteFlowMode) ? String(options.suiteFlowMode) : null,
suiteSequenceIndex: Number.isInteger(options && options.sequenceIndex) ? options.sequenceIndex : null,
@@ -8186,8 +10094,27 @@
: null,
readOnly: options && Object.prototype.hasOwnProperty.call(options, 'readOnly')
? Boolean(options.readOnly)
- : Boolean(options && options.reviewMode)
- });
+ : Boolean(options && options.reviewMode),
+ handshakeDeferred: Boolean(options && options.deferInitialHandshake),
+ // Async INIT/draft work must be tied to this exact registration.
+ // Reusing an exam ID replaces the map entry even when the browser
+ // keeps the same WindowProxy alive.
+ sessionGeneration: canAdoptBinding
+ ? adoptedGeneration
+ : (previousWindowInfo && Number.isFinite(previousWindowInfo.sessionGeneration)
+ ? previousWindowInfo.sessionGeneration + 1
+ : 1),
+ closeMonitor: null
+ };
+ if (!this._examRegistrationEpochs) this._examRegistrationEpochs = new Map();
+ const registrationEpochKey = String(examId || '');
+ this._examRegistrationEpochs.set(
+ registrationEpochKey,
+ Number(this._examRegistrationEpochs.get(registrationEpochKey) || 0) + 1
+ );
+ this.examWindows.set(examId, windowInfo);
+ this.ensureExamWindowSession(examId, examWindow);
+ const setupRegistration = this._captureExamSessionRegistration(examId, windowInfo);
// 监听窗口关闭事件
let checkClosed = null;
@@ -8196,41 +10123,52 @@
try {
if (examWindow.closed) {
clearInterval(checkClosed);
- this.handleExamWindowClosed(examId);
+ if (windowInfo.closeMonitor === checkClosed) {
+ windowInfo.closeMonitor = null;
+ }
+ this.handleExamWindowClosed(examId, examWindow);
}
} catch (monitorError) {
clearInterval(checkClosed);
console.warn('[App] 无法检测题目窗口状态:', monitorError);
}
}, 1000);
+ windowInfo.closeMonitor = checkClosed;
} catch (error) {
console.warn('[App] 启动窗口关闭监控失败:', error);
}
// 设置窗口通信
try {
- this.setupExamWindowCommunication(examWindow, examId, exam, options);
+ this.setupExamWindowCommunication(examWindow, examId, exam, {
+ ...options,
+ expectedRegistration: setupRegistration
+ });
} catch (error) {
console.warn('[App] 初始化题目窗口通信失败:', error);
}
// 启动与练习页的会话握手(file:// 下更可靠)
- try {
- this.startExamHandshake(examWindow, examId);
- } catch (e) {
- console.warn('[App] 启动握手失败:', e);
+ if (!windowInfo.handshakeDeferred) {
+ try {
+ this.startExamHandshake(examWindow, examId, {
+ expectedRegistration: setupRegistration,
+ launchOwnership: options && options.launchOwnership || null
+ });
+ } catch (e) {
+ console.warn('[App] 启动握手失败:', e);
+ }
}
- const emitInitEnvelope = () => {
- const windowInfo = this.ensureExamWindowSession(examId, examWindow);
- const initPayload = this._buildExamInitPayload(examId, windowInfo);
- try {
- examWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*');
- examWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*');
- } catch (postError) {
- console.warn('[App] 跨源初始化题目窗口失败:', postError);
+ const emitInitEnvelope = () => this._sendExamInitEnvelope(
+ examId,
+ examWindow,
+ {},
+ {
+ expectedRegistration: setupRegistration,
+ launchOwnership: options && options.launchOwnership || null
}
- };
+ );
if (!isFileProtocol) {
try {
@@ -8247,6 +10185,7 @@
if (!(options && options.reviewMode)) {
this.updateExamStatus(examId, 'in-progress');
}
+ return setupRegistration;
},
/**
@@ -8293,6 +10232,9 @@
'SUITE_CONFIG_UPDATE',
'VOCAB_HIGHLIGHT_SAVE',
'SIMULATION_DRAFT_SYNC',
+ 'READING_DRAFT_SYNC',
+ 'READING_ANNOTATION_SYNC',
+ 'PRACTICE_RECORD_SAVED',
'SIMULATION_NAVIGATE',
'SIMULATION_ACTIVE_EXAM_CHANGE',
'SIMULATION_SUBMIT'
@@ -8416,35 +10358,69 @@
const messageHandler = async (event) => {
// 取得当前题目窗口引用(可能在 handshake 期间被更新)
- const storedInfo = (this.examWindows && this.examWindows.get(examId)) || {};
+ const storedInfo = this.examWindows && this.examWindows.get(examId);
+ if (!storedInfo) {
+ this._reportExamMessageRejected(examId, '', 'missing-registration', event);
+ return;
+ }
+ const entryRegistration = this._captureExamSessionRegistration(examId, storedInfo);
+ // An uncommitted newer launch reservation must invalidate stale open
+ // continuations, but the currently registered page remains entitled to
+ // finish its protocol until navigation replaces this exact tuple.
+ const ownsEntryRegistration = entryRegistration
+ && this._isExamSessionRegistrationCurrent(examId, entryRegistration);
+ if (!ownsEntryRegistration) {
+ this._reportExamMessageRejected(examId, '', 'stale-registration', event);
+ return;
+ }
const expectedWindow = storedInfo.window || examWindow;
const sourceWindow = event ? (event.source || null) : null;
// 缺少来源窗口直接拒绝
if (!sourceWindow || !expectedWindow) {
+ this._reportExamMessageRejected(examId, '', 'missing-window', event);
return;
}
- // 校验来源域,允许 file:// (origin 为 null) 与同源页面
- if (event.origin && event.origin !== 'null') {
- const allowedOrigin = window.location && window.location.origin;
- if (allowedOrigin && event.origin !== allowedOrigin) {
- return;
- }
- }
-
const normalized = normalizeMessage(event.data);
if (!normalized) {
+ this._reportExamMessageRejected(examId, '', 'invalid-envelope', event);
+ return;
+ }
+
+ if (storedInfo.windowReusePending === true) {
+ this._reportExamMessageRejected(examId, normalized.type, 'window-reassignment-pending', event);
return;
}
- const windowInfo = this.ensureExamWindowSession(examId, expectedWindow);
+ const windowInfo = storedInfo;
const expectedSessionId = windowInfo.expectedSessionId || '';
+ // Most messages must still come from the exact exam window. A small
+ // suite/listening compatibility path below can prove an equivalent
+ // source with the window token and full session scope; do not reject
+ // before those constraints have been evaluated.
+ const sourceMatched = sourceWindow === expectedWindow;
+ const incomingOrigin = event && typeof event.origin === 'string' ? event.origin : '';
+ if (windowInfo.expectedOrigin && windowInfo.expectedOrigin !== 'null') {
+ if (incomingOrigin !== windowInfo.expectedOrigin) {
+ this._reportExamMessageRejected(examId, normalized.type, 'origin-mismatch', event);
+ return;
+ }
+ } else if (windowInfo.allowOpaqueOrigin) {
+ if (incomingOrigin !== 'null' && incomingOrigin !== 'file://') {
+ this._reportExamMessageRejected(examId, normalized.type, 'opaque-origin-mismatch', event);
+ return;
+ }
+ } else {
+ this._reportExamMessageRejected(examId, normalized.type, 'origin-unbound', event);
+ return;
+ }
// 放宽消息源过滤,兼容 inline_collector 与 practice_page
const src = normalized.sourceTag || '';
const allowedSources = new Set(['practice_page', 'inline_collector', 'suite_placeholder', 'listening_record_bridge']);
- if (src && !allowedSources.has(src)) {
+ if (!src || !allowedSources.has(src)) {
+ this._reportExamMessageRejected(examId, normalized.type, 'source-tag-mismatch', event);
return; // 非预期来源的消息忽略
}
@@ -8470,6 +10446,21 @@
const activeSuiteSequence = this.currentSuiteSession && Array.isArray(this.currentSuiteSession.sequence)
? this.currentSuiteSession.sequence
: [];
+ const registeredSuiteSessionId = windowInfo
+ && Object.prototype.hasOwnProperty.call(windowInfo, 'suiteSessionId')
+ && typeof windowInfo.suiteSessionId === 'string'
+ ? windowInfo.suiteSessionId.trim()
+ : '';
+ const ownsCurrentSuiteRegistration = Boolean(
+ registeredSuiteSessionId
+ && activeSuiteSessionId
+ && registeredSuiteSessionId === activeSuiteSessionId
+ );
+ const ownsPayloadSuiteProtocol = Boolean(
+ ownsCurrentSuiteRegistration
+ && payloadSuiteSessionId
+ && payloadSuiteSessionId === registeredSuiteSessionId
+ );
const isExamInActiveSuite = Boolean(
this.currentSuiteSession
&& activeSuiteSequence.some(item => item && String(item.examId) === expectedExamId)
@@ -8494,14 +10485,155 @@
const expectedWindowSessionToken = windowInfo && typeof windowInfo.windowSessionToken === 'string'
? windowInfo.windowSessionToken.trim()
: '';
+ const isTokenlessListeningBootstrap = Boolean(
+ type === 'SESSION_READY'
+ && !payloadWindowSessionToken
+ && sourceMatched
+ && src === 'listening_record_bridge'
+ && data.initialized === false
+ && (data.pageType === 'listening' || data.type === 'listening')
+ );
+ const isTokenlessSuiteBootstrap = Boolean(
+ type === 'SESSION_READY'
+ && !payloadWindowSessionToken
+ && sourceMatched
+ && src === 'suite_placeholder'
+ && data.pageType === 'suite-placeholder'
+ && (!payloadExamId || payloadExamId === expectedExamId)
+ && (!payloadSuiteSessionId || payloadSuiteSessionId === registeredSuiteSessionId)
+ );
+ const isTokenlessReadyBootstrap = isTokenlessListeningBootstrap || isTokenlessSuiteBootstrap;
+ const permitsPreInitWithoutToken = type === 'REQUEST_INIT' || isTokenlessReadyBootstrap;
+ if (!permitsPreInitWithoutToken && (
+ !expectedWindowSessionToken
+ || !payloadWindowSessionToken
+ || payloadWindowSessionToken !== expectedWindowSessionToken
+ )) {
+ this._reportExamMessageRejected(examId, type, 'token-mismatch', event);
+ return;
+ }
+ const requestsSuiteOwnedProtocol = isSimulationSuiteMessage
+ || type === 'SUITE_CLOSE_ATTEMPT'
+ || type === 'SUITE_CONFIG_UPDATE'
+ || (type === 'REVIEW_NAVIGATE' && (
+ data.suiteReviewMode === true
+ || Boolean(payloadSuiteSessionId)
+ || Boolean(registeredSuiteSessionId)
+ ))
+ || (type === 'SESSION_READY'
+ && !isTokenlessReadyBootstrap
+ && Boolean(payloadSuiteSessionId || registeredSuiteSessionId))
+ || ((type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT')
+ && Boolean(payloadSuiteSessionId || registeredSuiteSessionId));
+ if (requestsSuiteOwnedProtocol && !ownsPayloadSuiteProtocol) {
+ this._reportExamMessageRejected(examId, type, 'suite-registration-mismatch', event);
+ return;
+ }
const canRoutePayloadExamInActiveSuite = Boolean(
suiteRoutableMessageTypes.has(type)
&& isPayloadExamInActiveSuite
- && activeSuiteSessionId
- && payloadSuiteSessionId
- && payloadSuiteSessionId === activeSuiteSessionId
+ && ownsPayloadSuiteProtocol
);
- const sourceMatched = isLikelySameWindowContext(sourceWindow, expectedWindow);
+ const isReadingAnnotationSync = type === 'READING_ANNOTATION_SYNC';
+ const isReadingDraftSync = type === 'READING_DRAFT_SYNC';
+ if (isReadingAnnotationSync) {
+ const expectedReviewSessionId = windowInfo && windowInfo.reviewSessionId
+ ? String(windowInfo.reviewSessionId)
+ : '';
+ const payloadReviewSessionId = data && data.reviewSessionId != null
+ ? String(data.reviewSessionId)
+ : '';
+ const payloadRecordId = data && data.recordId != null ? String(data.recordId) : '';
+ const hasStrictSessionBinding = Boolean(
+ expectedSessionId
+ && payloadSessionId
+ && payloadSessionId === expectedSessionId
+ );
+ const hasStrictWindowToken = Boolean(
+ expectedWindowSessionToken
+ && payloadWindowSessionToken
+ && payloadWindowSessionToken === expectedWindowSessionToken
+ );
+ const hasStrictReviewBinding = Boolean(
+ windowInfo
+ && windowInfo.reviewMode
+ && expectedReviewSessionId
+ && payloadReviewSessionId === expectedReviewSessionId
+ );
+ // 单篇阅读 final-submit 后,结果页以已存档 recordId 发送标注同步:
+ // 不在 review 回放态,但 windowInfo.submittedRecordId 必须与 payload
+ // recordId 严格匹配,并仍受 source/会话/窗口 token/题号约束。
+ const hasSubmittedRecordBinding = Boolean(
+ windowInfo
+ && !windowInfo.reviewMode
+ && windowInfo.submittedRecordId
+ && payloadRecordId
+ && payloadRecordId === String(windowInfo.submittedRecordId)
+ );
+ if (
+ !sourceMatched
+ || !hasStrictSessionBinding
+ || !hasStrictWindowToken
+ || (!hasStrictReviewBinding && !hasSubmittedRecordBinding)
+ || !payloadExamId
+ || payloadExamId !== expectedExamId
+ ) {
+ return;
+ }
+ }
+ if (isReadingDraftSync) {
+ const hasStrictSessionBinding = Boolean(
+ expectedSessionId
+ && payloadSessionId
+ && payloadSessionId === expectedSessionId
+ );
+ const hasStrictWindowToken = Boolean(
+ expectedWindowSessionToken
+ && payloadWindowSessionToken
+ && payloadWindowSessionToken === expectedWindowSessionToken
+ );
+ const isLivePracticeWindow = Boolean(
+ windowInfo
+ && !windowInfo.reviewMode
+ && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize'
+ );
+ if (
+ !sourceMatched
+ || !hasStrictSessionBinding
+ || !hasStrictWindowToken
+ || !isLivePracticeWindow
+ || !payloadExamId
+ || payloadExamId !== expectedExamId
+ ) {
+ return;
+ }
+ }
+ if (type === 'SIMULATION_DRAFT_SYNC' && isExamInActiveSuite) {
+ const incomingUpdatedAt = Number(data && (data.draftUpdatedAt
+ ?? (data.draft && data.draft.updatedAt)
+ ?? data.updatedAt));
+ const suiteWindowBound = Boolean(
+ windowInfo
+ && windowInfo.suiteSessionId
+ && String(windowInfo.suiteSessionId) === activeSuiteSessionId
+ );
+ const exactSuiteDraftBinding = Boolean(
+ sourceMatched
+ && suiteWindowBound
+ && payloadSuiteSessionId === activeSuiteSessionId
+ && isPayloadExamInActiveSuite
+ && expectedWindowSessionToken
+ && payloadWindowSessionToken === expectedWindowSessionToken
+ && Number.isFinite(incomingUpdatedAt)
+ && incomingUpdatedAt > 0
+ && this.currentSuiteSession
+ && ['active', 'initializing'].includes(this.currentSuiteSession.status)
+ );
+ if (!exactSuiteDraftBinding) {
+ this._reportExamMessageRejected(examId, type, 'suite-draft-binding-mismatch', event);
+ return;
+ }
+ }
const payloadWindowInfo = payloadExamId && payloadExamId !== expectedExamId && this.examWindows
? this.examWindows.get(payloadExamId)
: null;
@@ -8550,22 +10682,26 @@
const allowSuiteSourceFallback = Boolean(
!sourceMatched
&& payloadExamId
+ && payloadSessionId
+ && expectedSessionId
+ && payloadSessionId === expectedSessionId
&& payloadTokenMatchesExpectedWindow
&& (payloadExamId === expectedExamId || isPayloadExamInActiveSuite)
- && (
- (payloadSuiteSessionId && activeSuiteSessionId && payloadSuiteSessionId === activeSuiteSessionId)
- || isExamInActiveSuite
- )
+ && ownsPayloadSuiteProtocol
);
const allowListeningSourceFallback = Boolean(
!sourceMatched
&& isListeningBridgeProtocolMessage
- && (
- (payloadExamId && payloadExamId === expectedExamId)
- || (payloadSessionId && expectedSessionId && payloadSessionId === expectedSessionId)
- )
+ && payloadTokenMatchesExpectedWindow
+ && payloadExamId
+ && payloadExamId === expectedExamId
+ && payloadSessionId
+ && expectedSessionId
+ && payloadSessionId === expectedSessionId
+ && (!payloadSuiteSessionId || !activeSuiteSessionId || payloadSuiteSessionId === activeSuiteSessionId)
);
if (!sourceMatched && !allowSuiteSourceFallback && !allowListeningSourceFallback) {
+ this._reportExamMessageRejected(examId, type, 'window-mismatch', event);
return;
}
if (windowInfo && sourceWindow && (sourceMatched || !expectedWindow || expectedWindow.closed)) {
@@ -8580,9 +10716,7 @@
|| type === 'SIMULATION_ACTIVE_EXAM_CHANGE'
|| type === 'SIMULATION_SUBMIT'
|| type === 'SESSION_READY')
- && payloadSuiteSessionId
- && activeSuiteSessionId
- && payloadSuiteSessionId === activeSuiteSessionId
+ && ownsPayloadSuiteProtocol
&& payloadExamId
&& (payloadExamId === expectedExamId || isPayloadExamInActiveSuite)
&& (
@@ -8620,9 +10754,6 @@
data.sessionId = expectedSessionId;
} else {
windowInfo.sessionId = payloadSessionId;
- if (!windowInfo.expectedSessionId) {
- windowInfo.expectedSessionId = payloadSessionId;
- }
}
} else if (type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT') {
if (!expectedSessionId) {
@@ -8648,8 +10779,19 @@
if (!data.sessionId && expectedSessionId) {
data.sessionId = expectedSessionId;
}
+ if (
+ (type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT')
+ && (
+ !String(data.submissionId || '').trim()
+ || !String(data.sessionId || '').trim()
+ || !String(payloadWindowSessionToken || '').trim()
+ )
+ ) {
+ this._reportExamMessageRejected(examId, type, 'missing-submission-contract', event);
+ return;
+ }
- windowInfo.origin = event.origin;
+ windowInfo.observedOrigin = event.origin;
windowInfo.lastMessageAt = Date.now();
windowInfo.lastMessageType = type;
if (payloadWindowSessionToken) {
@@ -8657,6 +10799,14 @@
}
this.examWindows.set(examId, windowInfo);
+ const eventRegistration = this._captureExamSessionRegistration(examId, windowInfo);
+ const ownsEventRegistration = eventRegistration
+ && this._isExamSessionRegistrationCurrent(examId, eventRegistration);
+ if (!ownsEventRegistration) {
+ this._reportExamMessageRejected(examId, type, 'stale-registration', event);
+ return;
+ }
+
switch (type) {
case 'exam_completed':
this.handleExamCompleted(examId, data);
@@ -8668,14 +10818,41 @@
this.handleExamError(examId, data);
break;
// 新增:处理数据采集器的消息
- case 'SESSION_READY':
- this.handleSessionReady(examId, data);
- if (typeof this._maybeRestoreSuiteReviewState === 'function') {
- this._maybeRestoreSuiteReviewState(examId, sourceWindow || expectedWindow, windowInfo).catch((restoreError) => {
+ case 'SESSION_READY': {
+ if (isTokenlessReadyBootstrap) {
+ this._sendExamInitEnvelope(examId, sourceWindow || examWindow, {}, {
+ expectedRegistration: eventRegistration
+ });
+ break;
+ }
+ const sessionReadyAccepted = this.handleSessionReady(examId, data, {
+ expectedRegistration: eventRegistration
+ });
+ const stillOwnsReadyRegistration = this._isExamSessionRegistrationCurrent(
+ examId,
+ eventRegistration
+ );
+ if (sessionReadyAccepted !== false
+ && stillOwnsReadyRegistration
+ && ownsPayloadSuiteProtocol
+ && typeof this._maybeRestoreSuiteReviewState === 'function') {
+ this._maybeRestoreSuiteReviewState(
+ examId,
+ sourceWindow || expectedWindow,
+ windowInfo,
+ {
+ expectedRegistration: eventRegistration,
+ commitGuard: () => this._isExamSessionRegistrationCurrent(
+ examId,
+ eventRegistration
+ )
+ }
+ ).catch((restoreError) => {
console.warn('[SuitePractice] 恢复回看态失败:', restoreError);
});
}
break;
+ }
case 'PROGRESS_UPDATE':
this.handleProgressUpdate(examId, data);
break;
@@ -8692,25 +10869,52 @@
console.info('[ReadingMemorize] 背题模式结果仅在统一阅读页内展示,跳过练习记录:', examId);
break;
}
- if (data && data.suiteSessionId && windowInfo) {
- windowInfo.suiteSessionId = data.suiteSessionId;
- this.examWindows && this.examWindows.set(examId, windowInfo);
- }
- await this.handlePracticeComplete(examId, data, sourceWindow || expectedWindow);
+ await this.handlePracticeComplete(
+ examId,
+ data,
+ sourceWindow || expectedWindow,
+ {
+ expectedRegistration: eventRegistration
+ }
+ );
break;
case 'ERROR_OCCURRED':
this.handleDataCollectionError(examId, data);
break;
case 'REQUEST_INIT':
- sendInitEnvelope(sourceWindow || examWindow);
+ this._sendExamInitEnvelope(examId, sourceWindow || examWindow, {}, {
+ expectedRegistration: eventRegistration
+ });
break;
case 'PRACTICE_RESET_REQUEST':
- await this.handlePracticeResetRequest(examId, data, sourceWindow || expectedWindow);
+ await this.handlePracticeResetRequest(
+ examId,
+ data,
+ sourceWindow || expectedWindow,
+ {
+ expectedRegistration: eventRegistration
+ }
+ );
break;
- case 'SUITE_CLOSE_ATTEMPT':
- console.warn('[SuitePractice] 练习页尝试关闭套题窗口:', data);
+ case 'SUITE_CLOSE_ATTEMPT': {
+ const suiteSession = this.currentSuiteSession;
+ const requestedSuiteSessionId = String(data && data.suiteSessionId || '').trim();
+ if (ownsPayloadSuiteProtocol
+ && sourceMatched
+ && suiteSession
+ && suiteSession.status === 'completed'
+ && requestedSuiteSessionId === String(suiteSession.id || '')
+ && typeof this._teardownSuiteSession === 'function') {
+ await this._teardownSuiteSession(suiteSession);
+ } else {
+ console.warn('[SuitePractice] 练习页尝试关闭进行中的套题窗口:', data);
+ }
break;
+ }
case 'SUITE_CONFIG_UPDATE': {
+ if (!ownsPayloadSuiteProtocol) {
+ break;
+ }
const autoAdvance = typeof data.autoAdvanceAfterSubmit === 'boolean'
? data.autoAdvanceAfterSubmit
: true;
@@ -8721,94 +10925,116 @@
window.practiceConfig.suite = {};
}
window.practiceConfig.suite.autoAdvanceAfterSubmit = autoAdvance;
- try {
- if (window.localStorage) {
- window.localStorage.setItem('suite_auto_advance_after_submit', String(autoAdvance));
- }
- } catch (_) {
- // ignore storage write failures
- }
+ await window.AppData.preferences.patchSuite({ autoAdvanceAfterSubmit: autoAdvance });
break;
}
case 'VOCAB_HIGHLIGHT_SAVE':
- if (typeof window.saveReadingHighlightVocab === 'function') {
- await window.saveReadingHighlightVocab(data);
+ if (!data || !String(data.requestId || '').trim()) {
+ this._reportExamMessageRejected(examId, type, 'missing-request-id', event);
+ break;
+ }
+ try {
+ const saved = typeof window.saveReadingHighlightVocab === 'function'
+ ? await window.saveReadingHighlightVocab(data)
+ : null;
+ this._announceVocabHighlightOutcome(
+ examId,
+ data,
+ sourceWindow || expectedWindow,
+ Boolean(saved),
+ saved ? '' : 'save_failed'
+ );
+ } catch (saveError) {
+ console.warn('[VocabStore] 阅读高亮生词保存异常:', saveError);
+ this._announceVocabHighlightOutcome(
+ examId,
+ data,
+ sourceWindow || expectedWindow,
+ false,
+ 'save_failed'
+ );
}
break;
case 'REVIEW_NAVIGATE':
- if (data && typeof this.handleSuiteReviewNavigate === 'function') {
- const activeSuiteId = this.currentSuiteSession && this.currentSuiteSession.id
- ? String(this.currentSuiteSession.id)
- : '';
- const windowSuiteId = windowInfo && windowInfo.suiteSessionId
- ? String(windowInfo.suiteSessionId)
- : '';
- const isExplicitSuiteNavigate = data.suiteReviewMode === true;
- const isActiveSuiteWindow = Boolean(windowSuiteId && activeSuiteId && windowSuiteId === activeSuiteId);
- if (isExplicitSuiteNavigate || isActiveSuiteWindow) {
- const payloadExamId = data.examId != null ? String(data.examId).trim() : '';
+ if (ownsPayloadSuiteProtocol) {
+ if (data && typeof this.handleSuiteReviewNavigate === 'function') {
+ const suiteReviewExamId = data.examId != null ? String(data.examId).trim() : '';
const hasPayloadExamInActiveSuite = Boolean(
- payloadExamId
+ suiteReviewExamId
&& this.currentSuiteSession
&& Array.isArray(this.currentSuiteSession.sequence)
- && this.currentSuiteSession.sequence.some(item => item && item.examId === payloadExamId)
- );
- const routedExamId = hasPayloadExamInActiveSuite ? payloadExamId : examId;
- const handledSuiteReview = await this.handleSuiteReviewNavigate(routedExamId, data, sourceWindow || expectedWindow);
- if (handledSuiteReview) {
- break;
- }
- }
- }
- await this.handleReviewReplayNavigate(examId, data, sourceWindow || expectedWindow);
- break;
- case 'SIMULATION_DRAFT_SYNC':
- if (this.currentSuiteSession && data && data.draft) {
- const incomingUpdatedAt = Number(data.draftUpdatedAt ?? data.draft.updatedAt);
- const previousDraft = this.currentSuiteSession.draftsByExam[routedExamId] || null;
- const previousUpdatedAt = Number(previousDraft && previousDraft.updatedAt);
- const shouldAcceptDraft = !(
- previousDraft
- && Number.isFinite(previousUpdatedAt)
- && Number.isFinite(incomingUpdatedAt)
- && incomingUpdatedAt < previousUpdatedAt
- );
- if (shouldAcceptDraft) {
- this.currentSuiteSession.draftsByExam[routedExamId] = {
- ...data.draft,
- updatedAt: Number.isFinite(incomingUpdatedAt) ? incomingUpdatedAt : Date.now()
- };
- }
- if (Number.isFinite(Number(data.elapsed))) {
- if (typeof this._deriveSuiteExamElapsedSeconds === 'function') {
- this.currentSuiteSession.elapsedByExam[routedExamId] = this._deriveSuiteExamElapsedSeconds(
- this.currentSuiteSession,
- routedExamId,
- Number(data.elapsed)
- );
- } else {
- this.currentSuiteSession.elapsedByExam[routedExamId] = Math.max(0, Number(data.elapsed));
- }
+ && this.currentSuiteSession.sequence.some(item => item && item.examId === suiteReviewExamId)
+ );
+ const suiteReviewRoutedExamId = hasPayloadExamInActiveSuite ? suiteReviewExamId : examId;
+ await this.handleSuiteReviewNavigate(
+ suiteReviewRoutedExamId,
+ data,
+ sourceWindow || expectedWindow,
+ {
+ expectedRegistration: eventRegistration,
+ commitGuard: () => this._isExamSessionRegistrationCurrent(
+ examId,
+ eventRegistration
+ )
+ }
+ );
}
- if (typeof this._mirrorSessionToStorage === 'function') {
- this._mirrorSessionToStorage(this.currentSuiteSession);
+ break;
+ }
+ await this.handleReviewReplayNavigate(
+ examId,
+ data,
+ sourceWindow || expectedWindow,
+ {
+ expectedRegistration: eventRegistration
}
+ );
+ break;
+ case 'SIMULATION_DRAFT_SYNC':
+ if (ownsPayloadSuiteProtocol && typeof this._handleSuiteDraftSync === 'function') {
+ await this._handleSuiteDraftSync(
+ routedExamId,
+ data,
+ windowInfo,
+ sourceWindow || expectedWindow,
+ {
+ expectedRegistration: eventRegistration,
+ commitGuard: () => this._isExamSessionRegistrationCurrent(
+ examId,
+ eventRegistration
+ )
+ }
+ );
}
break;
+ case 'READING_DRAFT_SYNC':
+ await this._queueReadingDraftSync(routedExamId, data, windowInfo);
+ break;
+ case 'READING_ANNOTATION_SYNC':
+ await this._queueReadingAnnotationSync(routedExamId, data, windowInfo);
+ break;
case 'SIMULATION_NAVIGATE':
- if (typeof this._handleSimulationNavigate === 'function') {
- await this._handleSimulationNavigate(routedExamId, data, sourceWindow || expectedWindow);
+ if (ownsPayloadSuiteProtocol && typeof this._handleSimulationNavigate === 'function') {
+ await this._handleSimulationNavigate(
+ routedExamId,
+ data,
+ sourceWindow || expectedWindow,
+ {
+ expectedRegistration: eventRegistration,
+ commitGuard: () => this._isExamSessionRegistrationCurrent(
+ examId,
+ eventRegistration
+ )
+ }
+ );
}
break;
case 'SIMULATION_ACTIVE_EXAM_CHANGE':
if (
this.currentSuiteSession
+ && this.currentSuiteSession.status === 'active'
+ && ownsPayloadSuiteProtocol
&& isPayloadExamInActiveSuite
- && (
- !payloadSuiteSessionId
- || !activeSuiteSessionId
- || payloadSuiteSessionId === activeSuiteSessionId
- )
) {
const activeIndex = activeSuiteSequence.findIndex(item => item && String(item.examId) === routedExamId);
this.currentSuiteSession.activeExamId = routedExamId;
@@ -8819,6 +11045,10 @@
if (sourceWindow && !sourceWindow.closed) {
this.currentSuiteSession.windowRef = sourceWindow;
}
+ if (typeof this._buildSuiteWindowBinding === 'function') {
+ const binding = this._buildSuiteWindowBinding(this.currentSuiteSession);
+ if (binding) this.currentSuiteSession.windowBinding = binding;
+ }
if (Number.isFinite(Number(data.elapsed))) {
if (typeof this._deriveSuiteExamElapsedSeconds === 'function') {
this.currentSuiteSession.elapsedByExam[routedExamId] = this._deriveSuiteExamElapsedSeconds(
@@ -8836,10 +11066,20 @@
}
break;
case 'SIMULATION_SUBMIT':
- if (windowInfo && windowInfo.reviewMode) {
+ if (!ownsPayloadSuiteProtocol || (windowInfo && windowInfo.reviewMode)) {
break;
}
- await this.handlePracticeComplete(routedExamId, data, sourceWindow || expectedWindow);
+ await this.handlePracticeComplete(
+ routedExamId,
+ data,
+ sourceWindow || expectedWindow,
+ {
+ expectedRegistration: routedExamId === examId
+ ? eventRegistration
+ : this._captureExamSessionRegistration(routedExamId),
+ launchOwnership: null
+ }
+ );
break;
default:
}
@@ -8864,17 +11104,14 @@
}
this.messageHandlers.set(examId, messageHandler);
- // 向题目窗口发送初始化消息(兼容 0.2 增强器监听的 INIT_SESSION)
- const sendInitEnvelope = (targetWindow) => {
- try {
- const windowInfo = this.ensureExamWindowSession(examId, targetWindow);
- const initPayload = this._buildExamInitPayload(examId, windowInfo);
- targetWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*');
- targetWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*');
- } catch (initError) {
- console.warn('[App] 发送初始化消息失败:', initError);
- }
- };
+ const setupRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const sendInitEnvelope = (targetWindow, registration = setupRegistration) => this._sendExamInitEnvelope(
+ examId,
+ targetWindow,
+ {},
+ registration ? { expectedRegistration: registration, launchOwnership } : {}
+ );
const tryAttachInitHandler = (targetWindow) => {
if (!targetWindow || isFileProtocol) {
@@ -8913,122 +11150,64 @@
/**
* 与练习页建立握手(重复发送 INIT_SESSION,直到收到 SESSION_READY)
*/
- startExamHandshake(examWindow, examId) {
+ startExamHandshake(examWindow, examId, options = {}) {
if (!this._handshakeTimers) this._handshakeTimers = new Map();
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsHandshake = () => !expectedRegistration || (
+ launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ expectedRegistration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ );
// 避免重复握手
if (this._handshakeTimers.has(examId)) return;
let attempts = 0;
const maxAttempts = 30; // ~9s
- const tick = () => {
+ const stopTimer = () => {
+ clearInterval(timer);
+ if (this._handshakeTimers.get(examId) === timer) {
+ this._handshakeTimers.delete(examId);
+ }
+ };
+ const tick = async () => {
+ if (!ownsHandshake()) {
+ stopTimer();
+ return;
+ }
if (examWindow && !examWindow.closed) {
try {
- const windowInfo = this.ensureExamWindowSession(examId, examWindow);
- const initPayload = this._buildExamInitPayload(examId, windowInfo);
+ const windowInfo = expectedRegistration
+ ? expectedRegistration.windowInfo
+ : this.ensureExamWindowSession(examId, examWindow);
windowInfo.handshakeAttempts = attempts + 1;
windowInfo.lastHandshakeAt = Date.now();
this.examWindows && this.examWindows.set(examId, windowInfo);
- // 直接发送两种事件名,确保增强器任何实现都能收到
- examWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*');
- examWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*');
+ await this._sendExamInitEnvelope(
+ examId,
+ examWindow,
+ {},
+ expectedRegistration ? { expectedRegistration, launchOwnership } : {}
+ );
} catch (_) { /* 忽略 */ }
}
attempts++;
if (attempts >= maxAttempts) {
- clearInterval(timer);
- this._handshakeTimers.delete(examId);
+ stopTimer();
console.warn('[App] 握手超时,练习页可能未加载增强器');
}
};
- const timer = setInterval(tick, 300);
+ const timer = setInterval(() => { tick(); }, 300);
this._handshakeTimers.set(examId, timer);
// 立即发送一次
tick();
},
- /**
- * 创建降级记录器
- */
- createFallbackRecorder() {
- return {
- handleRealPracticeData: async (examId, realData) => {
- try {
- // 获取题目信息
- const exam = await findExamDefinition(examId);
-
- if (!exam) {
- console.error('[FallbackRecorder] 无法找到题目信息:', examId);
- return null;
- }
-
- const api = window.PracticeRecordAPI;
- if (!api || typeof api.saveCompletion !== 'function') {
- throw new Error('统一练习记录 API 未就绪');
- }
- const practiceRecord = await api.saveCompletion(realData, {
- examId,
- sessionId: realData && realData.sessionId ? realData.sessionId : null,
- examEntry: exam,
- metadata: {
- examId,
- examTitle: exam.title || realData?.title || '',
- category: exam.category || realData?.category || 'unknown',
- frequency: exam.frequency || realData?.frequency || 'unknown',
- type: exam.type || realData?.type || null
- }
- });
-
- // 检查成就
- if (window.AchievementManager) {
- window.AchievementManager.check(practiceRecord).catch(console.warn);
- }
-
- return practiceRecord;
- } catch (error) {
- console.error('[FallbackRecorder] 保存失败:', error);
- return null;
- }
- },
-
- startSession: (examId) => {
- // 简单的会话管理
- return {
- examId: examId,
- startTime: new Date().toISOString(),
- sessionId: this.generateSessionId(examId),
- status: 'started'
- };
- },
-
- getPracticeRecords: async (filters = {}) => {
- try {
- const records = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function'
- ? await window.PracticeRecordAPI.list()
- : [];
-
- if (Object.keys(filters).length === 0) {
- return records;
- }
-
- return records.filter(record => {
- if (filters.examId && record.examId !== filters.examId) return false;
- if (filters.category && record.category !== filters.category) return false;
- if (filters.startDate && new Date(record.startTime) < new Date(filters.startDate)) return false;
- if (filters.endDate && new Date(record.startTime) > new Date(filters.endDate)) return false;
- if (filters.minAccuracy && record.accuracy < filters.minAccuracy) return false;
- if (filters.maxAccuracy && record.accuracy > filters.maxAccuracy) return false;
-
- return true;
- });
- } catch (error) {
- console.error('[FallbackRecorder] 获取记录失败:', error);
- return [];
- }
- }
- };
- },
-
// ExamBrowser组件已移除,使用内置的题目列表功能
/**
@@ -9148,7 +11327,15 @@
},
generateWindowSessionToken(examId) {
- const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
+ const cryptoApi = global.crypto;
+ if (!cryptoApi || typeof cryptoApi.getRandomValues !== 'function') {
+ throw new Error('Secure random generator is required for window session tokens');
+ }
+ const bytes = new Uint8Array(24);
+ cryptoApi.getRandomValues(bytes);
+ const suffix = Array.from(bytes)
+ .map(byte => byte.toString(16).padStart(2, '0'))
+ .join('');
const normalizedExamId = typeof examId === 'string'
? examId.trim().replace(/\s+/g, '-')
: (examId != null ? String(examId).trim().replace(/\s+/g, '-') : '');
@@ -9551,6 +11738,27 @@
: (Array.isArray(entry.realData?.highlights)
? entry.realData.highlights.slice()
: (Array.isArray(record.realData?.highlights) ? record.realData.highlights.slice() : [])));
+ const noteText = typeof entry.noteText === 'string'
+ ? entry.noteText
+ : (typeof entry.rawData?.noteText === 'string'
+ ? entry.rawData.noteText
+ : (typeof entry.realData?.noteText === 'string'
+ ? entry.realData.noteText
+ : (typeof record.realData?.noteText === 'string' ? record.realData.noteText : '')));
+ const notes = Array.isArray(entry.notes)
+ ? this._cloneReviewData(entry.notes)
+ : (Array.isArray(entry.rawData?.notes)
+ ? this._cloneReviewData(entry.rawData.notes)
+ : (Array.isArray(entry.realData?.notes)
+ ? this._cloneReviewData(entry.realData.notes)
+ : (Array.isArray(record.realData?.notes) ? this._cloneReviewData(record.realData.notes) : [])));
+ const noteOutlines = Array.isArray(entry.noteOutlines)
+ ? this._cloneReviewData(entry.noteOutlines)
+ : (Array.isArray(entry.rawData?.noteOutlines)
+ ? this._cloneReviewData(entry.rawData.noteOutlines)
+ : (Array.isArray(entry.realData?.noteOutlines)
+ ? this._cloneReviewData(entry.realData.noteOutlines)
+ : (Array.isArray(record.realData?.noteOutlines) ? this._cloneReviewData(record.realData.noteOutlines) : [])));
const scrollY = Number.isFinite(Number(entry.scrollY))
? Number(entry.scrollY)
: (Number.isFinite(Number(entry.rawData?.scrollY))
@@ -9584,35 +11792,657 @@
? entryMetadata.markedQuestions.slice()
: (Array.isArray(recordMetadata.markedQuestions) ? recordMetadata.markedQuestions.slice() : [])),
highlights,
+ noteText,
+ notes,
+ noteOutlines,
scrollY,
metadata: mergedMetadata
};
- built.allQuestionIds = this._collectReplayQuestionIds(built);
- builtEntries.push(built);
- });
- return builtEntries;
+ built.allQuestionIds = this._collectReplayQuestionIds(built);
+ builtEntries.push(built);
+ });
+ return builtEntries;
+ },
+
+ _ensureReviewReplayStore() {
+ if (!this.reviewReplaySessions) {
+ this.reviewReplaySessions = new Map();
+ }
+ return this.reviewReplaySessions;
+ },
+
+ async _resolveReviewExamDefinition(entry) {
+ if (!entry || typeof entry !== 'object' || !entry.examId) {
+ throw new Error('历史记录缺少题目标识');
+ }
+ if (typeof window.resolveExamForPracticeRecord !== 'function') {
+ throw new Error('历史记录题库解析器不可用');
+ }
+ const exam = await window.resolveExamForPracticeRecord(entry);
+ if (exam) return exam;
+ // resolveExamForPracticeRecord 在记录缺 provenance 时已回退到当前活动题库解析
+ // (见 libraryManager.resolveIndexForRecord)。走到这里说明 examId 在可解析的题库中
+ // 确实不存在——统一按“题目不可用”处理,不再因缺少 libraryConfigurationId 而拒绝回放,
+ // 那会误伤所有 v1 迁移来、迁移时无法唯一判定来源的旧记录。
+ throw new Error('该记录对应的题目在当前题库中不存在,可能题库已被删除或切换');
+ },
+
+ _buildReviewSession(record) {
+ const entries = this._buildReviewReplayEntriesFromRecord(record);
+ const validEntries = entries.filter((entry) => entry && entry.examId);
+ if (validEntries.length === 0) {
+ return null;
+ }
+ return {
+ sessionId: `review_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
+ recordId: record && record.id != null ? String(record.id) : '',
+ entries: validEntries,
+ currentIndex: 0,
+ windowRef: null,
+ readOnly: true
+ };
+ },
+
+ _cloneReadingDraftValue(value) {
+ if (value == null) {
+ return value;
+ }
+ try {
+ return JSON.parse(JSON.stringify(value));
+ } catch (_) {
+ if (Array.isArray(value)) {
+ return value.slice();
+ }
+ if (value && typeof value === 'object') {
+ return Object.assign({}, value);
+ }
+ return value;
+ }
+ },
+
+ _readingDraftId(examId, libraryConfigurationId = null) {
+ const normalizedExamId = String(examId || '').trim();
+ const normalizedConfigurationId = libraryConfigurationId == null
+ ? ''
+ : String(libraryConfigurationId).trim();
+ return normalizedConfigurationId
+ ? `reading-draft:${normalizedExamId}:${normalizedConfigurationId}`
+ : `reading-draft:${normalizedExamId}`;
+ },
+
+ _buildReadingDraftSnapshot(examId, data = {}, windowInfo = null) {
+ const source = data && data.draft && typeof data.draft === 'object' && !Array.isArray(data.draft)
+ ? data.draft
+ : (data && typeof data === 'object' ? data : {});
+ const answers = source.answers && typeof source.answers === 'object' && !Array.isArray(source.answers)
+ ? this._cloneReadingDraftValue(source.answers)
+ : {};
+ const highlights = Array.isArray(source.highlights) ? this._cloneReadingDraftValue(source.highlights) : [];
+ const notes = Array.isArray(source.notes) ? this._cloneReadingDraftValue(source.notes) : [];
+ const noteOutlines = Array.isArray(source.noteOutlines) ? this._cloneReadingDraftValue(source.noteOutlines) : [];
+ const markedQuestions = Array.isArray(source.markedQuestions)
+ ? this._cloneReadingDraftValue(source.markedQuestions)
+ : [];
+ const noteText = typeof source.noteText === 'string' ? source.noteText : '';
+ const scrollY = Number.isFinite(Number(source.scrollY)) ? Math.max(0, Number(source.scrollY)) : 0;
+ const updatedAt = Number(data.draftUpdatedAt ?? source.updatedAt);
+ const sessionId = data.sessionId != null
+ ? String(data.sessionId)
+ : (windowInfo && windowInfo.expectedSessionId ? String(windowInfo.expectedSessionId) : '');
+ const libraryConfigurationId = this._readLaunchLibraryConfigurationId(examId, windowInfo);
+ return {
+ id: this._readingDraftId(examId, libraryConfigurationId),
+ examId: String(examId),
+ libraryConfigurationId: libraryConfigurationId == null ? null : String(libraryConfigurationId),
+ sessionId,
+ answers,
+ highlights,
+ notes,
+ noteOutlines,
+ markedQuestions,
+ noteText,
+ scrollY,
+ updatedAt: Number.isFinite(updatedAt) ? updatedAt : Date.now(),
+ status: 'in_progress',
+ kind: 'reading_draft'
+ };
+ },
+
+ async _readReadingDraftStore() {
+ const drafts = await window.AppData.recovery.listDrafts();
+ const store = {};
+ (Array.isArray(drafts) ? drafts : []).forEach((draft) => {
+ if (draft && draft.kind === 'reading_draft' && draft.examId) {
+ const id = draft.id || this._readingDraftId(draft.examId, draft.libraryConfigurationId);
+ store[String(id)] = draft;
+ }
+ });
+ return store;
+ },
+
+ async _writeReadingDraftStore(store, changedDraft = null, options = {}) {
+ try {
+ if (changedDraft) {
+ const saveOptions = typeof options.commitGuard === 'function'
+ ? { commitGuard: options.commitGuard }
+ : {};
+ const receipt = await window.AppData.recovery.saveDraft(changedDraft, saveOptions);
+ if (!receipt || receipt.committed !== true) {
+ return false;
+ }
+ }
+ const drafts = await window.AppData.recovery.listDrafts();
+ const cutoff = Date.now() - (7 * 24 * 60 * 60 * 1000);
+ for (const draft of Array.isArray(drafts) ? drafts : []) {
+ const numericUpdatedAt = Number(draft && draft.updatedAt);
+ const draftUpdatedAt = Number.isFinite(numericUpdatedAt)
+ ? numericUpdatedAt
+ : Date.parse(draft && draft.updatedAt);
+ if (
+ draft
+ && draft.kind === 'reading_draft'
+ && draft.id !== changedDraft?.id
+ && (!Number.isFinite(draftUpdatedAt) || draftUpdatedAt < cutoff)
+ ) {
+ await window.AppData.recovery.discardDraft(draft.id);
+ }
+ }
+ return true;
+ } catch (error) {
+ console.warn('[ReadingDraftGateway] 写入草稿失败:', error);
+ return false;
+ }
+ },
+
+ async handleReadingDraftSync(examId, data = {}, windowInfo = null) {
+ const info = windowInfo || (this.examWindows && this.examWindows.get(examId));
+ if (!info || info.reviewMode) {
+ return false;
+ }
+ if (windowInfo && this.examWindows && this.examWindows.get(examId) !== info) {
+ return false;
+ }
+ if (String(info.practiceMode || '').toLowerCase() === 'memorize') {
+ return false;
+ }
+ // 用“本窗口的 suite 绑定”判断是否套题草稿,而不是看全局 currentSuiteSession:
+ // 否则当任意套题会话仍活跃时,普通独立阅读窗口(windowInfo.suiteSessionId 为空)
+ // 的草稿也会被拒绝,关闭该窗口会丢失该题的在做答案/笔记。
+ if (info.suiteSessionId) {
+ return typeof this._handleSuiteDraftSync === 'function'
+ ? this._handleSuiteDraftSync(examId, data, info, info.window)
+ : false;
+ }
+ const expectedSessionId = info.expectedSessionId ? String(info.expectedSessionId) : '';
+ const payloadSessionId = data && data.sessionId != null ? String(data.sessionId) : '';
+ if (!expectedSessionId || !payloadSessionId || payloadSessionId !== expectedSessionId) {
+ return false;
+ }
+ const expectedRegistration = this._captureExamSessionRegistration(examId, info);
+ if (!expectedRegistration) {
+ return false;
+ }
+ const payloadGeneration = Number(data && data.windowSessionGeneration);
+ if (Number.isInteger(payloadGeneration)
+ && payloadGeneration !== expectedRegistration.sessionGeneration) {
+ return false;
+ }
+ const isExactRegistration = () => (
+ this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ );
+ const isLiveRegistration = () => (
+ isExactRegistration()
+ && (!expectedRegistration.window || !expectedRegistration.window.closed)
+ );
+ // Reject already-closed/stale sources at the gateway. Once an accepted
+ // pagehide draft reaches the transaction, ownership (not liveness) is the
+ // commit condition so closing the page cannot discard its final answers.
+ if (!isLiveRegistration()) {
+ return false;
+ }
+ const draft = this._buildReadingDraftSnapshot(examId, data, info);
+ if (!draft.sessionId) {
+ return false;
+ }
+ // 必须在写队列里重新读取最新 store 再合并,否则并发不同 exam 的 write 会互相覆盖、
+ // 后写者会丢掉前者的草稿(整个 map 是同一个存储 key,read-modify-write 非原子)。
+ const store = await this._readReadingDraftStore();
+ if (!isExactRegistration()) {
+ return false;
+ }
+ const previous = store[String(draft.id)] || null;
+ const previousNumericUpdatedAt = Number(previous && previous.updatedAt);
+ const previousUpdatedAt = Number.isFinite(previousNumericUpdatedAt)
+ ? previousNumericUpdatedAt
+ : Date.parse(previous && previous.updatedAt);
+ const nextNumericUpdatedAt = Number(draft.updatedAt);
+ const nextUpdatedAt = Number.isFinite(nextNumericUpdatedAt)
+ ? nextNumericUpdatedAt
+ : Date.parse(draft.updatedAt);
+ if (
+ previous
+ && previous.sessionId === draft.sessionId
+ && Number.isFinite(previousUpdatedAt)
+ && Number.isFinite(nextUpdatedAt)
+ && nextUpdatedAt < previousUpdatedAt
+ ) {
+ return false;
+ }
+ store[String(draft.id)] = draft;
+ if (!isExactRegistration()) {
+ return false;
+ }
+ if (!await this._writeReadingDraftStore(store, draft, { commitGuard: isExactRegistration })) {
+ return false;
+ }
+ if (!isExactRegistration()) {
+ return false;
+ }
+ info.lastReadingDraft = draft;
+ info.lastReadingDraftAt = Date.now();
+ if (this.examWindows) {
+ this.examWindows.set(examId, info);
+ }
+ return true;
+ },
+
+ async _queueReadingDraftSync(examId, data = {}, windowInfo = null) {
+ // 同一宿主窗口内保持事件顺序;跨标签并发由 AppData/kernel CAS 处理。
+ if (!this._readingDraftStoreQueue || typeof this._readingDraftStoreQueue.then !== 'function') {
+ this._readingDraftStoreQueue = Promise.resolve();
+ }
+ const queuedRegistration = windowInfo
+ ? this._captureExamSessionRegistration(examId, windowInfo)
+ : null;
+ const queued = this._readingDraftStoreQueue
+ .catch(() => undefined)
+ .then(() => {
+ if (queuedRegistration
+ && !this._isExamSessionRegistrationCurrent(examId, queuedRegistration)) {
+ return false;
+ }
+ return this.handleReadingDraftSync(examId, data, windowInfo);
+ });
+ this._readingDraftStoreQueue = queued.catch(() => undefined).then(() => {
+ if (this._readingDraftStoreQueue === queued) {
+ this._readingDraftStoreQueue = Promise.resolve();
+ }
+ });
+ return queued;
+ },
+
+ async getReadingDraftForExam(examId, options = {}) {
+ const normalizedExamId = examId != null ? String(examId).trim() : '';
+ if (!normalizedExamId) {
+ return null;
+ }
+ const libraryConfigurationId = Object.prototype.hasOwnProperty.call(options, 'libraryConfigurationId')
+ ? options.libraryConfigurationId
+ : this._readLaunchLibraryConfigurationId(normalizedExamId, options.windowInfo);
+ const store = await this._readReadingDraftStore();
+ const draft = store[this._readingDraftId(normalizedExamId, libraryConfigurationId)] || null;
+ if (!draft || typeof draft !== 'object') {
+ return null;
+ }
+ // 仅用于“恢复未完成草稿”:跨开窗/重启时 expectedSessionId 会重新生成,
+ // 旧 draft 的 sessionId 必然与之不同;读取不写入任何数据,无跨会话覆盖风险,
+ // 因此这里不再用 sessionId 拦截,把旧草稿透传给调用方,由其在新 session 里继续答题。
+ // 写/清路径仍保留严格校验,避免跨会话误覆盖或误删。
+ const cloned = this._cloneReadingDraftValue(draft);
+ const expectedSessionId = options.sessionId != null ? String(options.sessionId) : '';
+ if (expectedSessionId && String(cloned.sessionId || '') !== expectedSessionId) {
+ cloned.sessionId = expectedSessionId;
+ }
+ return cloned;
+ },
+
+ async clearReadingDraftForExam(examId, options = {}) {
+ const normalizedExamId = examId != null ? String(examId).trim() : '';
+ if (!normalizedExamId) {
+ return false;
+ }
+ const commitGuard = options && typeof options.commitGuard === 'function'
+ ? options.commitGuard
+ : null;
+ if (commitGuard && commitGuard() !== true) {
+ return false;
+ }
+ const libraryConfigurationId = Object.prototype.hasOwnProperty.call(options, 'libraryConfigurationId')
+ ? options.libraryConfigurationId
+ : this._readLaunchLibraryConfigurationId(normalizedExamId, options.windowInfo);
+ const run = async () => {
+ const store = await this._readReadingDraftStore();
+ if (commitGuard && commitGuard() !== true) {
+ return false;
+ }
+ const existing = store[this._readingDraftId(normalizedExamId, libraryConfigurationId)] || null;
+ if (!existing) {
+ return false;
+ }
+ const expectedSessionId = options.sessionId != null ? String(options.sessionId) : '';
+ // completion 路径用 acceptResumeSessionId=true 调用:若用户是在恢复的草稿上继续答题,
+ // 存档里仍是恢复前的旧 sessionId,而完成事件带的是新 session id;
+ // 这里已由完成事件本身做过严格的 message/session 校验,可直接删除该题草稿,
+ // 避免已提交的答案在重开 SAME 题时被旧草稿复活。
+ if (expectedSessionId && String(existing.sessionId || '') !== expectedSessionId && !options.acceptResumeSessionId) {
+ return false;
+ }
+ if (commitGuard && commitGuard() !== true) {
+ return false;
+ }
+ const discardOptions = commitGuard ? { commitGuard } : {};
+ const revision = Number(existing && existing.revision);
+ if (Number.isSafeInteger(revision) && revision >= 0) {
+ discardOptions.expectedEntityRevision = revision;
+ }
+ const receipt = await window.AppData.recovery.discardDraft(existing.id, discardOptions);
+ return !receipt || receipt.committed !== false;
+ };
+ // 与当前窗口的 draft sync 顺序一致,物理并发控制仍由 kernel 负责。
+ if (!this._readingDraftStoreQueue || typeof this._readingDraftStoreQueue.then !== 'function') {
+ this._readingDraftStoreQueue = Promise.resolve();
+ }
+ const queued = this._readingDraftStoreQueue
+ .catch(() => undefined)
+ .then(run);
+ this._readingDraftStoreQueue = queued.catch(() => undefined).then(() => {
+ if (this._readingDraftStoreQueue === queued) {
+ this._readingDraftStoreQueue = Promise.resolve();
+ }
+ });
+ return queued;
+ },
+
+ async _isPracticeCompletionPersisted(record) {
+ const identityFields = ['id', 'examId', 'sessionId'];
+ const completionTime = (value) => value && (
+ value.endTime || value.completedAt || value.timestamp || value.date
+ );
+ if (!record || typeof record !== 'object'
+ || identityFields.some((key) => record[key] == null || String(record[key]).trim() === '')
+ || !completionTime(record)) {
+ return false;
+ }
+ try {
+ const persisted = await window.AppData.practice.get(String(record.id), { projection: 'light' });
+ if (!persisted || typeof persisted !== 'object') {
+ return false;
+ }
+ return identityFields.every((key) => String(persisted[key] ?? '') === String(record[key]))
+ && String(completionTime(persisted) || '') === String(completionTime(record));
+ } catch (error) {
+ console.warn('[ReadingDraftGateway] 无法确认完成记录已落库,保留草稿:', error);
+ return false;
+ }
+ },
+
+ async handleReadingAnnotationSync(examId, data = {}, windowInfo = null) {
+ const info = windowInfo || (this.examWindows && this.examWindows.get(examId));
+ if (!info) {
+ return false;
+ }
+ // 两条来源均可落库标注:①review 回放态,按 reviewSessionId 解析 recordId;
+ // ②单篇阅读 final-submit 后的结果页,按 windowInfo.submittedRecordId 直连
+ // 已存档的练习记录。两者都需要 payload.recordId 与解析出的 recordId 严格匹配。
+ let recordId = '';
+ if (info.reviewMode && info.reviewSessionId) {
+ const reviewSessionId = String(info.reviewSessionId);
+ const sessions = this._ensureReviewReplayStore();
+ const reviewSession = sessions.get(reviewSessionId);
+ if (!reviewSession || !reviewSession.recordId) {
+ return false;
+ }
+ recordId = String(reviewSession.recordId);
+ } else if (info.submittedRecordId) {
+ recordId = String(info.submittedRecordId);
+ } else {
+ return false;
+ }
+ if (data.recordId == null || String(data.recordId) !== recordId) {
+ return false;
+ }
+
+ const source = data.annotations && typeof data.annotations === 'object' && !Array.isArray(data.annotations)
+ ? data.annotations
+ : data;
+ const annotationPatch = {};
+ ['highlights', 'notes', 'noteOutlines', 'markedQuestions'].forEach((key) => {
+ if (Object.prototype.hasOwnProperty.call(source, key) && Array.isArray(source[key])) {
+ annotationPatch[key] = this._cloneReviewData(source[key]);
+ }
+ });
+ if (Object.prototype.hasOwnProperty.call(source, 'noteText') && typeof source.noteText === 'string') {
+ annotationPatch.noteText = source.noteText;
+ }
+ if (Object.prototype.hasOwnProperty.call(source, 'scrollY')) {
+ const scrollY = Number(source.scrollY);
+ if (Number.isFinite(scrollY)) {
+ annotationPatch.scrollY = Math.max(0, scrollY);
+ }
+ }
+ if (Object.keys(annotationPatch).length === 0) {
+ return false;
+ }
+
+ const normalizedExamId = String(examId);
+ await window.AppData.practice.updateAnnotations({
+ recordId,
+ examId: normalizedExamId,
+ patch: annotationPatch,
+ operationId: data.operationId || data.messageId || undefined
+ });
+
+ // 只有 review 回放分支需要同时更新内存中的 reviewSession.entries;
+ // 单篇 submitted 直连已存档记录的分支不持有 reviewSession,跳过。
+ if (info.reviewMode && info.reviewSessionId) {
+ const reviewSessionId = String(info.reviewSessionId);
+ const sessions = this._ensureReviewReplayStore();
+ const reviewSession = sessions.get(reviewSessionId);
+ if (reviewSession && Array.isArray(reviewSession.entries)) {
+ reviewSession.entries = reviewSession.entries.map((entry) => (
+ entry && String(entry.examId) === normalizedExamId
+ ? Object.assign({}, entry, annotationPatch)
+ : entry
+ ));
+ sessions.set(reviewSessionId, reviewSession);
+ }
+ }
+ return true;
+ },
+
+ async _queueReadingAnnotationSync(examId, data = {}, windowInfo = null) {
+ return this.handleReadingAnnotationSync(examId, data, windowInfo);
+ },
+
+ // 单篇阅读 final-submit 落库成功后,把已存档 recordId 写入 windowInfo 并
+ // postMessage 回结果页,使结果页笔记改动能以 READING_ANNOTATION_SYNC
+ // 持久化回该练习记录。套题流程不会走到这里(已在 handleSuitePracticeComplete 早退)。
+ _announceSubmittedReadingRecord(examId, savedRecord, completionData, sourceWindow, options = {}) {
+ try {
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsAnnouncement = () => !expectedRegistration || (
+ expectedRegistration.window === sourceWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(examId, launchOwnership, expectedRegistration)
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ );
+ if (!ownsAnnouncement()) {
+ return false;
+ }
+ const recordId = savedRecord && savedRecord.id != null ? String(savedRecord.id).trim() : '';
+ if (!recordId) {
+ return false;
+ }
+ const sessionId = completionData && completionData.sessionId != null
+ ? String(completionData.sessionId)
+ : '';
+ const targetWindow = (sourceWindow && !sourceWindow.closed) ? sourceWindow : null;
+ if (!targetWindow) {
+ return false;
+ }
+ if (!ownsAnnouncement()) {
+ return false;
+ }
+ const windowInfo = expectedRegistration
+ ? expectedRegistration.windowInfo
+ : this.ensureExamWindowSession(examId, targetWindow);
+ if (windowInfo) {
+ windowInfo.submittedRecordId = recordId;
+ windowInfo.window = targetWindow;
+ windowInfo.status = 'completed';
+ windowInfo.completedAt = windowInfo.completedAt || Date.now();
+ this.examWindows && this.examWindows.set(examId, windowInfo);
+ }
+ if (!ownsAnnouncement()) {
+ return false;
+ }
+ this._postExamMessage(examId, targetWindow, 'PRACTICE_RECORD_SAVED', {
+ examId,
+ recordId,
+ sessionId: sessionId || null
+ }, options);
+ return true;
+ } catch (_) {
+ // annotation persistence hint is best-effort
+ return false;
+ }
+ },
+
+ _announcePracticeSubmitOutcome(examId, completionData, sourceWindow, succeeded, details = {}, options = {}) {
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsAnnouncement = () => !expectedRegistration || (
+ expectedRegistration.window === sourceWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(examId, launchOwnership, expectedRegistration)
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ );
+ if (!ownsAnnouncement()) {
+ return false;
+ }
+ const submissionId = completionData && completionData.submissionId != null
+ ? String(completionData.submissionId).trim()
+ : '';
+ const sessionId = completionData && completionData.sessionId != null
+ ? String(completionData.sessionId).trim()
+ : '';
+ const targetWindow = sourceWindow && !sourceWindow.closed ? sourceWindow : null;
+ if (!submissionId || !sessionId || !targetWindow) {
+ return false;
+ }
+ try {
+ const type = succeeded ? 'PRACTICE_SUBMIT_ACK' : 'PRACTICE_SUBMIT_FAILED';
+ const payload = {
+ examId,
+ submissionId,
+ sessionId,
+ suiteSessionId: completionData && completionData.suiteSessionId
+ ? String(completionData.suiteSessionId)
+ : null,
+ errorCode: succeeded ? null : String(details.errorCode || 'save_failed')
+ };
+ if (!ownsAnnouncement()) {
+ return false;
+ }
+ const delivered = this._postExamMessage(examId, targetWindow, type, payload, options);
+ if (succeeded) {
+ if (!ownsAnnouncement()) {
+ return false;
+ }
+ const windowInfo = expectedRegistration
+ ? expectedRegistration.windowInfo
+ : this.ensureExamWindowSession(examId, targetWindow);
+ const receiptKey = `${sessionId}:${String(completionData?.suiteId || '').trim()}:${submissionId}`;
+ const receipts = windowInfo.practiceSubmitReceipts && typeof windowInfo.practiceSubmitReceipts === 'object'
+ ? windowInfo.practiceSubmitReceipts
+ : {};
+ receipts[receiptKey] = Object.assign({}, payload, { examId, succeeded: true });
+ const keys = Object.keys(receipts);
+ keys.slice(0, Math.max(0, keys.length - 8)).forEach((key) => delete receipts[key]);
+ windowInfo.practiceSubmitReceipts = receipts;
+ this.examWindows && this.examWindows.set(examId, windowInfo);
+ }
+ return delivered;
+ } catch (error) {
+ console.warn('[DataCollection] 提交结果回执发送失败:', error);
+ return false;
+ }
},
- _ensureReviewReplayStore() {
- if (!this.reviewReplaySessions) {
- this.reviewReplaySessions = new Map();
+ _announceVocabHighlightOutcome(examId, requestData, sourceWindow, succeeded, errorCode = '') {
+ const requestId = requestData && requestData.requestId != null
+ ? String(requestData.requestId).trim()
+ : '';
+ const sessionId = requestData && requestData.sessionId != null
+ ? String(requestData.sessionId).trim()
+ : '';
+ const targetWindow = sourceWindow && !sourceWindow.closed ? sourceWindow : null;
+ if (!requestId || !sessionId || !targetWindow) {
+ return false;
}
- return this.reviewReplaySessions;
+ return this._postExamMessage(
+ examId,
+ targetWindow,
+ succeeded ? 'VOCAB_HIGHLIGHT_SAVE_ACK' : 'VOCAB_HIGHLIGHT_SAVE_FAILED',
+ {
+ examId,
+ sessionId,
+ requestId,
+ errorCode: succeeded ? null : String(errorCode || 'save_failed')
+ }
+ );
},
- _buildReviewSession(record) {
- const entries = this._buildReviewReplayEntriesFromRecord(record);
- const validEntries = entries.filter((entry) => entry && entry.examId);
- if (validEntries.length === 0) {
- return null;
+ _replayPracticeSubmitReceipt(examId, completionData, sourceWindow) {
+ const submissionId = completionData && completionData.submissionId != null
+ ? String(completionData.submissionId).trim()
+ : '';
+ const sessionId = completionData && completionData.sessionId != null
+ ? String(completionData.sessionId).trim()
+ : '';
+ if (!submissionId || !sessionId || !sourceWindow || sourceWindow.closed) {
+ return false;
}
- return {
- sessionId: `review_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
- entries: validEntries,
- currentIndex: 0,
- windowRef: null,
- readOnly: true
- };
+ const windowInfo = this.ensureExamWindowSession(examId, sourceWindow);
+ const receipt = windowInfo.practiceSubmitReceipts
+ && windowInfo.practiceSubmitReceipts[`${sessionId}:${String(completionData?.suiteId || '').trim()}:${submissionId}`];
+ if (!receipt || receipt.succeeded !== true) {
+ return false;
+ }
+ this._announcePracticeSubmitOutcome(examId, completionData, sourceWindow, true);
+ return true;
+ },
+
+ _scheduleSuiteSubmitTeardown(session) {
+ if (!session || typeof this._teardownSuiteSession !== 'function') {
+ return false;
+ }
+ if (!(session._suiteTeardownRegistrations instanceof Map)
+ && typeof this._captureSuiteTeardownRegistrations === 'function') {
+ // Freeze the completed suite's exact binding before the receipt replay delay.
+ session._suiteTeardownRegistrations = this._captureSuiteTeardownRegistrations(session);
+ }
+ if (session.submitReceiptTeardownTimer) {
+ clearTimeout(session.submitReceiptTeardownTimer);
+ }
+ const timer = setTimeout(async () => {
+ let tornDown = false;
+ try {
+ tornDown = await this._teardownSuiteSession(session);
+ } catch (teardownError) {
+ console.warn('[SuitePractice] 提交回执重放窗口结束后清理套题会话失败:', teardownError);
+ }
+ if (!tornDown && session.submitReceiptTeardownTimer === timer) {
+ session.submitReceiptTeardownTimer = null;
+ if (this._isSuiteSessionCurrentOwner(session)) this._scheduleSuiteSubmitTeardown(session);
+ }
+ }, 30000);
+ session.submitReceiptTeardownTimer = timer;
+ if (timer && typeof timer.unref === 'function') {
+ timer.unref();
+ }
+ return true;
},
_bindReviewWindowRef(reviewSessionId, windowRef) {
@@ -9656,14 +12486,15 @@
}
const replayPayload = {
reviewSessionId: session.sessionId,
+ recordId: session.recordId || null,
reviewEntryIndex: safeIndex,
readOnly: session.readOnly !== false,
entry: this._cloneReviewData(entry)
};
const contextPayload = this._buildReviewContextPayload(session, safeIndex);
try {
- targetWindow.postMessage({ type: 'REPLAY_PRACTICE_RECORD', data: replayPayload }, '*');
- targetWindow.postMessage({ type: 'REVIEW_CONTEXT', data: contextPayload }, '*');
+ this._postExamMessage(examId, targetWindow, 'REPLAY_PRACTICE_RECORD', replayPayload);
+ this._postExamMessage(examId, targetWindow, 'REVIEW_CONTEXT', contextPayload);
return true;
} catch (error) {
console.warn('[ReviewReplay] 向题目页发送回放数据失败:', error);
@@ -9701,11 +12532,21 @@
return sent;
},
- async handleReviewReplayNavigate(examId, data = {}, sourceWindow = null) {
+ async handleReviewReplayNavigate(examId, data = {}, sourceWindow = null, options = {}) {
const windowInfo = this.examWindows && this.examWindows.get(examId);
if (!windowInfo || !windowInfo.reviewMode) {
return;
}
+ const expectedRegistration = options && options.expectedRegistration
+ ? options.expectedRegistration
+ : this._captureExamSessionRegistration(examId, windowInfo);
+ const ownsSourceReview = () => Boolean(expectedRegistration) && (
+ expectedRegistration.window === (sourceWindow || windowInfo.window)
+ && this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ );
+ if (!ownsSourceReview()) {
+ return null;
+ }
const sessionId = data.reviewSessionId
? String(data.reviewSessionId)
: (windowInfo.reviewSessionId ? String(windowInfo.reviewSessionId) : '');
@@ -9741,30 +12582,68 @@
return;
}
- session.currentIndex = nextIndex;
- session.windowRef = sourceWindow || windowInfo.window || session.windowRef || null;
- store.set(sessionId, session);
-
if (String(nextEntry.examId) === String(examId)) {
+ if (!ownsSourceReview()) {
+ return null;
+ }
+ session.currentIndex = nextIndex;
+ session.windowRef = sourceWindow || windowInfo.window || session.windowRef || null;
+ store.set(sessionId, session);
windowInfo.reviewEntryIndex = nextIndex;
this.examWindows && this.examWindows.set(examId, windowInfo);
this._sendReviewReplayMessages(examId, session.windowRef, session, nextIndex);
return;
}
- try {
- await this.cleanupExamSession(examId);
- } catch (error) {
- console.warn('[ReviewReplay] 清理旧题目会话失败:', error);
- }
-
- await this.openExam(nextEntry.examId, {
+ const reuseWindow = sourceWindow || windowInfo.window || session.windowRef || null;
+ const launchOptions = {
reviewMode: true,
readOnly: true,
reviewSessionId: sessionId,
reviewEntryIndex: nextIndex,
- reuseWindow: session.windowRef || null
+ reuseWindow,
+ requireRecordProvenance: true
+ };
+ // Resolve record provenance while reserving only the target exam/name. Do
+ // not claim the installed source WindowProxy until the resolver succeeds;
+ // a failed replay navigation must leave the current review page usable.
+ const targetLaunchOwnership = this._beginExamLaunchOwnership(nextEntry.examId, {
+ ...launchOptions,
+ reuseWindow: null
+ });
+ let examDefinition;
+ try {
+ examDefinition = await this._resolveReviewExamDefinition(nextEntry);
+ } catch (error) {
+ this._rollbackExamLaunchOwnership(targetLaunchOwnership);
+ console.warn('[ReviewReplay] 无法解析下一题,保留当前回顾页:', error);
+ return null;
+ }
+ if (!this._isExamLaunchOwnershipCurrent(
+ nextEntry.examId,
+ targetLaunchOwnership,
+ targetLaunchOwnership.initialState
+ ) || !ownsSourceReview()) {
+ return null;
+ }
+ if (reuseWindow && !this._claimExamLaunchWindowOwnership(
+ targetLaunchOwnership,
+ reuseWindow
+ )) {
+ return null;
+ }
+ const openedWindow = await this.openExam(nextEntry.examId, {
+ ...launchOptions,
+ examDefinition,
+ launchOwnership: targetLaunchOwnership
});
+ if (!openedWindow) {
+ return null;
+ }
+ session.currentIndex = nextIndex;
+ session.windowRef = openedWindow;
+ store.set(sessionId, session);
+ return openedWindow;
},
async openPracticeRecordReplay(record) {
@@ -9781,14 +12660,40 @@
throw new Error('无法解析首题题目标识');
}
- const openedWindow = await this.openExam(firstEntry.examId, {
+ const launchOptions = {
reviewMode: true,
readOnly: true,
reviewSessionId: session.sessionId,
- reviewEntryIndex: 0
+ reviewEntryIndex: 0,
+ requireRecordProvenance: true
+ };
+ const launchOwnership = this._beginExamLaunchOwnership(firstEntry.examId, launchOptions);
+ let examDefinition;
+ try {
+ examDefinition = await this._resolveReviewExamDefinition(firstEntry);
+ } catch (error) {
+ this._rollbackExamLaunchOwnership(launchOwnership);
+ store.delete(session.sessionId);
+ throw error;
+ }
+ if (!this._isExamLaunchOwnershipCurrent(
+ firstEntry.examId,
+ launchOwnership,
+ launchOwnership.initialState
+ )) {
+ store.delete(session.sessionId);
+ return null;
+ }
+ const openedWindow = await this.openExam(firstEntry.examId, {
+ ...launchOptions,
+ examDefinition,
+ launchOwnership
});
if (!openedWindow) {
store.delete(session.sessionId);
+ if (!this._isExamLaunchOwnershipCurrent(firstEntry.examId, launchOwnership)) {
+ return null;
+ }
throw new Error('无法打开回顾页面');
}
this._bindReviewWindowRef(session.sessionId, openedWindow);
@@ -9801,9 +12706,23 @@
info.expectedSessionId = this.generateSessionId(examId);
}
this._refreshExamWindowToken(examId, info);
- const suiteSessionId = typeof this._resolveSuiteSessionId === 'function'
- ? this._resolveSuiteSessionId(examId, info)
- : (info.suiteSessionId || null);
+ // A managed registration always carries suiteSessionId, including an
+ // explicit null for an ordinary launch. Only legacy payloads that do
+ // not have the field may infer suite ownership from global state.
+ const hasExplicitSuiteOwnership = Object.prototype.hasOwnProperty.call(info, 'suiteSessionId');
+ const suiteSessionId = hasExplicitSuiteOwnership
+ ? (info.suiteSessionId || null)
+ : (typeof this._resolveSuiteSessionId === 'function'
+ ? this._resolveSuiteSessionId(examId, info)
+ : null);
+ const activeSuite = suiteSessionId
+ && this.currentSuiteSession
+ && String(this.currentSuiteSession.id || '') === String(suiteSessionId)
+ ? this.currentSuiteSession
+ : null;
+ const autoAdvanceAfterSubmit = activeSuite && typeof activeSuite.autoAdvanceAfterSubmit === 'boolean'
+ ? activeSuite.autoAdvanceAfterSubmit
+ : (typeof info.autoAdvanceAfterSubmit === 'boolean' ? info.autoAdvanceAfterSubmit : null);
const timerContext = typeof this._resolveSuiteTimerContext === 'function'
? this._resolveSuiteTimerContext({}, info)
: {
@@ -9815,14 +12734,21 @@
? Math.floor(Number(extras.messageIssuedAtMs ?? extras.timestamp))
: Date.now();
info.lastInitMessageAt = messageIssuedAtMs;
+ // 启动时捕获的题库配置 ID:优先用 windowInfo 上预存值(启动时埋下),
+ // 否则从 mixin 私有 Map 兜底读,确保随 INIT_SESSION 携带到考试窗口。
+ const launchLibraryConfigurationId = Object.prototype.hasOwnProperty.call(info, 'libraryConfigurationId')
+ ? info.libraryConfigurationId
+ : this._readLaunchLibraryConfigurationId(examId);
const payload = {
examId: examId,
- parentOrigin: window.location.origin,
+ parentOrigin: info.allowOpaqueOrigin ? 'null' : window.location.origin,
sessionId: info.expectedSessionId,
windowSessionToken: info.windowSessionToken || null,
+ windowSessionGeneration: Number.isInteger(info.sessionGeneration) ? info.sessionGeneration : 0,
messageIssuedAtMs,
suiteSessionId: suiteSessionId || null,
suiteFlowMode: info.suiteFlowMode || null,
+ autoAdvanceAfterSubmit,
suiteTimerAnchorMs: timerContext.suiteTimerAnchorMs || null,
globalTimerAnchorMs: timerContext.globalTimerAnchorMs || null,
suiteTimerMode: timerContext.suiteTimerMode || null,
@@ -9842,23 +12768,99 @@
reviewEntryIndex: Number.isInteger(info.reviewEntryIndex) ? info.reviewEntryIndex : 0,
readOnly: Object.prototype.hasOwnProperty.call(info, 'readOnly')
? Boolean(info.readOnly)
- : Boolean(info.reviewMode)
+ : Boolean(info.reviewMode),
+ libraryConfigurationId: launchLibraryConfigurationId
};
+ if (
+ !payload.reviewMode
+ && !suiteSessionId
+ && !payload.suiteFlowMode
+ && info.lastReadingDraft
+ && typeof info.lastReadingDraft === 'object'
+ && String(info.lastReadingDraft.sessionId || '') === String(info.expectedSessionId || '')
+ ) {
+ payload.draft = this._cloneReadingDraftValue(info.lastReadingDraft);
+ }
if (extras && typeof extras === 'object') {
Object.assign(payload, extras);
}
+ // extras 显式提供 libraryConfigurationId 时不被覆盖;若 extras 显式带
+ // undefined/null(不应出现),保留启动捕获值以免丢失题库来源。
+ if (extras && typeof extras === 'object'
+ && Object.prototype.hasOwnProperty.call(extras, 'libraryConfigurationId')) {
+ payload.libraryConfigurationId = extras.libraryConfigurationId;
+ } else if (payload.libraryConfigurationId === undefined) {
+ payload.libraryConfigurationId = launchLibraryConfigurationId;
+ }
return payload;
},
- _sendExamInitEnvelope(examId, targetWindow, extras = {}) {
+ async _sendExamInitEnvelope(examId, targetWindow, extras = {}, options = {}) {
if (!targetWindow || targetWindow.closed) {
return null;
}
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsEnvelope = () => !expectedRegistration || (
+ expectedRegistration.window === targetWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ expectedRegistration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ );
+ if (!ownsEnvelope()) {
+ return null;
+ }
try {
- const windowInfo = this.ensureExamWindowSession(examId, targetWindow);
+ const windowInfo = expectedRegistration
+ ? expectedRegistration.windowInfo
+ : this.ensureExamWindowSession(examId, targetWindow);
+ if (windowInfo && windowInfo.handshakeDeferred === true) {
+ return null;
+ }
+ const registrationGeneration = Number(windowInfo && windowInfo.sessionGeneration) || 0;
+ const expectedSessionId = String(windowInfo && windowInfo.expectedSessionId || '');
+ const expectedToken = String(windowInfo && windowInfo.windowSessionToken || '');
+ const initEnvelopeEpoch = Math.max(0, Number(windowInfo && windowInfo.initEnvelopeEpoch) || 0) + 1;
+ windowInfo.initEnvelopeEpoch = initEnvelopeEpoch;
+ let restoredDraft = null;
+ if (
+ windowInfo
+ && !windowInfo.reviewMode
+ && !windowInfo.suiteSessionId
+ && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize'
+ && typeof this.getReadingDraftForExam === 'function'
+ && !(extras && Object.prototype.hasOwnProperty.call(extras, 'draft'))
+ ) {
+ try {
+ restoredDraft = await this.getReadingDraftForExam(examId, {
+ sessionId: windowInfo.expectedSessionId
+ });
+ } catch (_) {
+ // draft restore is best-effort
+ }
+ }
+ const currentWindowInfo = this.examWindows && this.examWindows.get(examId);
+ if (!windowInfo
+ || !ownsEnvelope()
+ || currentWindowInfo !== windowInfo
+ || windowInfo.window !== targetWindow
+ || Number(windowInfo.sessionGeneration) !== registrationGeneration
+ || String(windowInfo.expectedSessionId || '') !== expectedSessionId
+ || String(windowInfo.windowSessionToken || '') !== expectedToken
+ || Number(windowInfo.initEnvelopeEpoch) !== initEnvelopeEpoch
+ || windowInfo.handshakeDeferred === true) {
+ return null;
+ }
+ if (restoredDraft) {
+ windowInfo.lastReadingDraft = restoredDraft;
+ }
const initPayload = this._buildExamInitPayload(examId, windowInfo, extras);
- targetWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*');
- targetWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*');
+ this._postExamMessage(examId, targetWindow, 'INIT_SESSION', initPayload);
+ this._postExamMessage(examId, targetWindow, 'init_exam_session', initPayload);
return initPayload;
} catch (initError) {
console.warn('[App] 发送初始化消息失败:', initError);
@@ -9866,7 +12868,7 @@
}
},
- restartExamHandshake(examWindow, examId) {
+ restartExamHandshake(examWindow, examId, options = {}) {
if (this._handshakeTimers && this._handshakeTimers.has(examId)) {
try {
clearInterval(this._handshakeTimers.get(examId));
@@ -9875,7 +12877,7 @@
}
this._handshakeTimers.delete(examId);
}
- this.startExamHandshake(examWindow, examId);
+ this.startExamHandshake(examWindow, examId, options);
},
ensureExamWindowSession(examId, examWindow = null) {
@@ -9891,7 +12893,10 @@
expectedSessionId: this.generateSessionId(examId),
windowSessionToken: null,
windowSessionTokenSessionId: null,
- origin: (typeof window !== 'undefined' && window.location) ? window.location.origin : '',
+ expectedUrl: '',
+ expectedOrigin: '',
+ allowOpaqueOrigin: false,
+ observedOrigin: '',
suiteTimerAnchorMs: null,
globalTimerAnchorMs: null,
suiteTimerMode: null,
@@ -9903,7 +12908,9 @@
reviewMode: false,
reviewSessionId: null,
reviewEntryIndex: 0,
- readOnly: false
+ readOnly: false,
+ sessionGeneration: 1,
+ submittedRecordId: ''
});
}
@@ -9913,6 +12920,28 @@
windowInfo.window = examWindow;
}
+ if (!windowInfo.expectedOrigin && examWindow) {
+ try {
+ const currentHref = examWindow.location && typeof examWindow.location.href === 'string'
+ ? examWindow.location.href
+ : '';
+ const endpoint = this._resolveExamMessageEndpoint(currentHref);
+ const hostOrigin = window.location && window.location.origin;
+ const isTrustedSameOrigin = endpoint.expectedOrigin
+ && endpoint.expectedOrigin !== 'null'
+ && hostOrigin
+ && endpoint.expectedOrigin === hostOrigin;
+ const isTrustedLocalFile = endpoint.allowOpaqueOrigin && isFileProtocol;
+ if (isTrustedSameOrigin || isTrustedLocalFile) {
+ windowInfo.expectedUrl = endpoint.expectedUrl;
+ windowInfo.expectedOrigin = endpoint.expectedOrigin;
+ windowInfo.allowOpaqueOrigin = endpoint.allowOpaqueOrigin;
+ }
+ } catch (_) {
+ // Cross-origin WindowProxy locations are intentionally not probed further.
+ }
+ }
+
if (!windowInfo.expectedSessionId) {
windowInfo.expectedSessionId = this.generateSessionId(examId);
}
@@ -9936,17 +12965,100 @@
return windowInfo;
},
+ /**
+ * 在考试启动时捕获当前激活的题库配置 ID,写入 windowInfo 与 mixin 私有 Map,
+ * 供后续 INIT_SESSION payload 以及 completeAttempt 路径使用,避免提交时再读取
+ * 当前激活题库而拿到不一致的来源。
+ * 该方法为 async:必要时调用方需 await。
+ */
+ async _captureLaunchLibraryConfigurationId(examId, options = {}) {
+ if (!examId) return null;
+ const commitGuard = options && typeof options.commitGuard === 'function'
+ ? options.commitGuard
+ : null;
+ if (!this._launchLibraryConfigurationIds) {
+ this._launchLibraryConfigurationIds = new Map();
+ }
+ let configurationId = null;
+ try {
+ if (window.AppData && window.AppData.library
+ && typeof window.AppData.library.getActive === 'function') {
+ configurationId = await window.AppData.library.getActive();
+ }
+ } catch (captureError) {
+ console.warn('[ExamSession] 捕获启动题库配置 ID 失败:', captureError);
+ configurationId = null;
+ }
+ const normalized = (configurationId === undefined || configurationId === null)
+ ? null
+ : configurationId;
+ if (commitGuard && commitGuard() !== true) {
+ return null;
+ }
+ this._launchLibraryConfigurationIds.set(String(examId), normalized);
+ // 同步作用中 windowInfo:避免后续 _buildExamInitPayload 等同步路径漏读
+ try {
+ if (this.examWindows && this.examWindows.has(examId)) {
+ const windowInfo = this.examWindows.get(examId);
+ if (windowInfo && typeof windowInfo === 'object'
+ && !Object.prototype.hasOwnProperty.call(windowInfo, 'libraryConfigurationId')) {
+ windowInfo.libraryConfigurationId = normalized;
+ }
+ }
+ } catch (_) { /* 忽略:windowInfo 不存在不影响捕获 */ }
+ return normalized;
+ },
+
+ /**
+ * 同步读取指定 examId 启动时捕获的题库配置 ID;若无捕获返回 null。
+ * 优先取实时注入(realData.metadata / payload 显式传入)的值,再回退到启动时捕获值。
+ */
+ _readLaunchLibraryConfigurationId(examId, ...fromSources) {
+ for (const source of fromSources) {
+ if (source !== undefined && source !== null && typeof source === 'object') {
+ const metadata = source.metadata;
+ const direct = Object.prototype.hasOwnProperty.call(source, 'libraryConfigurationId')
+ ? source.libraryConfigurationId
+ : (metadata && Object.prototype.hasOwnProperty.call(metadata, 'libraryConfigurationId'))
+ ? metadata.libraryConfigurationId
+ : undefined;
+ if (direct !== undefined && direct !== null) {
+ return direct;
+ }
+ }
+ }
+ if (!this._launchLibraryConfigurationIds) {
+ return null;
+ }
+ return this._launchLibraryConfigurationIds.get(String(examId)) || null;
+ },
+
+ /**
+ * 清除指定 examId 启动时捕获的题库配置 ID(窗口关闭后调用)。
+ */
+ _discardLaunchLibraryConfigurationId(examId) {
+ if (this._launchLibraryConfigurationIds && examId) {
+ this._launchLibraryConfigurationIds.delete(String(examId));
+ }
+ },
+
_syncRecorderSessionStarted(examId, windowInfo, metadata = {}) {
const recorder = this.components && this.components.practiceRecorder;
if (!recorder || typeof recorder.handleSessionStarted !== 'function') {
return;
}
const sessionId = (windowInfo && windowInfo.expectedSessionId) || this.generateSessionId(examId);
+ // 注入启动时捕获的题库配置 ID,确保 recorder 会话上携带来源。
+ const mergedMetadata = Object.assign({}, metadata);
+ if (!Object.prototype.hasOwnProperty.call(mergedMetadata, 'libraryConfigurationId')) {
+ mergedMetadata.libraryConfigurationId =
+ this._readLaunchLibraryConfigurationId(examId, windowInfo, metadata);
+ }
try {
recorder.handleSessionStarted({
examId,
sessionId,
- metadata
+ metadata: mergedMetadata
});
} catch (recorderError) {
console.warn('[PracticeRecorder] 重置后同步会话状态失败:', recorderError);
@@ -9955,16 +13067,115 @@
async _removeActiveExamSessionMetadata(examId) {
try {
- const activeSessions = await storage.get('active_sessions', []);
- const updatedSessions = Array.isArray(activeSessions)
- ? activeSessions.filter(session => session && session.examId !== examId)
- : [];
- await storage.set('active_sessions', updatedSessions);
+ await this._discardActiveSessionsForExam(examId);
} catch (error) {
console.warn('[App] 清理活动会话元数据失败:', error);
}
},
+ _captureExamSessionRegistration(examId, windowInfo = null) {
+ const info = windowInfo || (this.examWindows && this.examWindows.get(examId));
+ if (!info) {
+ return null;
+ }
+ const numericGeneration = Number(info.sessionGeneration);
+ return Object.freeze({
+ windowInfo: info,
+ window: info.window || null,
+ navigationOwnership: info.navigationOwnership || null,
+ suiteSessionId: String(info.suiteSessionId || ''),
+ expectedSessionId: String(info.expectedSessionId || ''),
+ windowSessionToken: String(info.windowSessionToken || ''),
+ sessionGeneration: Number.isInteger(numericGeneration) ? numericGeneration : null
+ });
+ },
+
+ _isExamSessionRegistrationCurrent(examId, expectedRegistration) {
+ if (!expectedRegistration || !expectedRegistration.windowInfo || !this.examWindows) {
+ return false;
+ }
+ const current = this.examWindows.get(examId);
+ if (!current || current !== expectedRegistration.windowInfo) {
+ return false;
+ }
+ const numericGeneration = Number(current.sessionGeneration);
+ const currentGeneration = Number.isInteger(numericGeneration) ? numericGeneration : null;
+ return current.window === expectedRegistration.window
+ && String(current.suiteSessionId || '') === String(expectedRegistration.suiteSessionId || '')
+ && String(current.expectedSessionId || '') === String(expectedRegistration.expectedSessionId || '')
+ && String(current.windowSessionToken || '') === String(expectedRegistration.windowSessionToken || '')
+ && currentGeneration === expectedRegistration.sessionGeneration;
+ },
+
+ async _discardActiveSessionsForExam(examId, options = {}) {
+ const hasExpectedSessionId = Object.prototype.hasOwnProperty.call(options, 'expectedSessionId');
+ const commitGuard = options && typeof options.commitGuard === 'function'
+ ? options.commitGuard
+ : null;
+ const expectedSessionId = hasExpectedSessionId
+ ? String(options.expectedSessionId || '').trim()
+ : '';
+ // Ownership-gated cleanup must fail closed when no exact recovery id is known.
+ if (hasExpectedSessionId && !expectedSessionId) {
+ return 0;
+ }
+ if (commitGuard && commitGuard() !== true) {
+ return 0;
+ }
+ const activeSessions = await window.AppData.recovery.listActiveSessions();
+ if (commitGuard && commitGuard() !== true) {
+ return 0;
+ }
+ const matches = (Array.isArray(activeSessions) ? activeSessions : [])
+ .filter((session) => {
+ if (!session || String(session.examId || '') !== String(examId || '')) {
+ return false;
+ }
+ if (!hasExpectedSessionId) {
+ return true;
+ }
+ const entityId = String(session.id || session.recordId || '').trim();
+ const entitySessionId = entityId.startsWith('active-session:')
+ ? entityId.slice('active-session:'.length)
+ : entityId;
+ return String(session.sessionId || '').trim() === expectedSessionId
+ || entitySessionId === expectedSessionId;
+ });
+ let removedCount = 0;
+ for (const session of matches) {
+ if (commitGuard && commitGuard() !== true) {
+ return removedCount;
+ }
+ const entityId = session.id || session.sessionId || session.recordId;
+ if (entityId) {
+ const discardOptions = commitGuard ? { commitGuard } : {};
+ if (typeof window.AppData.recovery.getActiveSessionFence === 'function') {
+ const fence = await window.AppData.recovery.getActiveSessionFence(entityId);
+ if (commitGuard && commitGuard() !== true) {
+ return removedCount;
+ }
+ if (!fence || fence.exists !== true || fence.tombstoned === true) {
+ continue;
+ }
+ const revision = Number(fence.revision);
+ if (Number.isSafeInteger(revision) && revision >= 0) {
+ discardOptions.expectedEntityRevision = revision;
+ }
+ } else {
+ const revision = Number(session && session.revision);
+ if (Number.isSafeInteger(revision) && revision >= 0) {
+ discardOptions.expectedEntityRevision = revision;
+ }
+ }
+ const receipt = await window.AppData.recovery.discardActiveSession(entityId, discardOptions);
+ if (!receipt || receipt.committed !== false) {
+ removedCount += 1;
+ }
+ }
+ }
+ return removedCount;
+ },
+
_isResetCapableUnifiedReadingCompletion(data, sourceWindow = null) {
if (!sourceWindow || sourceWindow.closed) {
return false;
@@ -9984,8 +13195,19 @@
return renderMode === 'unified-reading' || pageType === 'unified-reading';
},
- async retainExamWindowAfterCompletion(examId, sourceWindow, data = {}) {
- const windowInfo = this.ensureExamWindowSession(examId, sourceWindow);
+ async retainExamWindowAfterCompletion(examId, sourceWindow, data = {}, options = {}) {
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsCompletion = () => Boolean(expectedRegistration) && (
+ expectedRegistration.window === sourceWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(examId, launchOwnership, expectedRegistration)
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ );
+ if (!ownsCompletion()) {
+ return false;
+ }
+ const windowInfo = expectedRegistration.windowInfo;
windowInfo.window = sourceWindow || windowInfo.window || null;
windowInfo.status = 'completed';
windowInfo.completedAt = Date.now();
@@ -9994,43 +13216,68 @@
windowInfo.reviewMode = false;
windowInfo.readOnly = false;
this.examWindows && this.examWindows.set(examId, windowInfo);
- await this._removeActiveExamSessionMetadata(examId);
+ await this._discardActiveSessionsForExam(examId, {
+ expectedSessionId: expectedRegistration.expectedSessionId,
+ commitGuard: ownsCompletion
+ });
+ return ownsCompletion();
},
- async handlePracticeResetRequest(examId, data = {}, sourceWindow = null) {
+ async handlePracticeResetRequest(examId, data = {}, sourceWindow = null, options = {}) {
+ const launchOwnership = options && options.launchOwnership || null;
+ const expectedRegistration = options && options.expectedRegistration
+ ? options.expectedRegistration
+ : this._captureExamSessionRegistration(examId);
const targetWindow = sourceWindow
- || (this.examWindows && this.examWindows.has(examId) ? this.examWindows.get(examId).window : null);
+ || (expectedRegistration && expectedRegistration.window)
+ || null;
if (!targetWindow || targetWindow.closed) {
window.showMessage && window.showMessage('题目窗口已关闭,无法重置测试', 'warning');
return;
}
+ const ownsResetRegistration = (registration = expectedRegistration) => Boolean(
+ registration
+ && registration.window === targetWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ registration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, registration))
+ );
+ if (!ownsResetRegistration()) {
+ return null;
+ }
const payload = data && typeof data === 'object' ? data : {};
const reason = String(payload.reason || '').trim().toLowerCase();
const fromPracticeMode = String(payload.fromPracticeMode || payload.practiceMode || '').trim().toLowerCase();
- const windowInfo = this.ensureExamWindowSession(examId, targetWindow);
+ const windowInfo = expectedRegistration.windowInfo;
const shouldReopenAsNormal = reason === 'memorize-start-test'
|| fromPracticeMode === 'memorize'
|| windowInfo.practiceMode === 'memorize';
if (shouldReopenAsNormal) {
- windowInfo.practiceMode = null;
- windowInfo.reviewMode = false;
- windowInfo.readOnly = false;
- windowInfo.status = 'active';
- this.examWindows && this.examWindows.set(examId, windowInfo);
- await this.openExam(examId, {
+ if (!ownsResetRegistration()) {
+ return null;
+ }
+ const reopenedWindow = await this.openExam(examId, {
target: 'tab',
windowName: 'ielts-reading-practice',
reuseWindow: targetWindow
});
- return;
+ return reopenedWindow ? true : null;
}
+ if (!ownsResetRegistration()) {
+ return null;
+ }
windowInfo.window = targetWindow;
windowInfo.status = 'active';
windowInfo.startTime = Date.now();
windowInfo.completedAt = null;
+ windowInfo.sessionGeneration = Math.max(0, Number(windowInfo.sessionGeneration) || 0) + 1;
windowInfo.expectedSessionId = this.generateSessionId(examId);
windowInfo.sessionId = null;
windowInfo.practiceMode = null;
@@ -10038,107 +13285,401 @@
windowInfo.reviewSessionId = null;
windowInfo.reviewEntryIndex = 0;
windowInfo.readOnly = false;
+ windowInfo.submittedRecordId = '';
windowInfo.dataCollectorReady = false;
windowInfo.lastResetAt = Date.now();
windowInfo.lastResetReason = reason || 'reset';
+ this._refreshExamWindowToken(examId, windowInfo);
this.examWindows && this.examWindows.set(examId, windowInfo);
- await this.startPracticeSession(examId);
- this._syncRecorderSessionStarted(examId, windowInfo, {
+ const resetRegistration = this._captureExamSessionRegistration(examId, windowInfo);
+ const startResult = await this.startPracticeSession(examId, {
+ expectedRegistration: resetRegistration,
+ launchOwnership
+ });
+ if (!this._isPracticeSessionOwnedSuccess(startResult)
+ || !ownsResetRegistration(startResult.registration)) {
+ return null;
+ }
+ const activeWindowInfo = startResult.registration.windowInfo;
+ if (!ownsResetRegistration(startResult.registration)) {
+ return null;
+ }
+ this._syncRecorderSessionStarted(examId, activeWindowInfo, {
pageType: 'unified-reading',
url: payload.normalUrl || payload.url || null,
title: payload.title || null,
resetReason: reason || 'reset'
});
- this._sendExamInitEnvelope(examId, targetWindow, {
+ await this._sendExamInitEnvelope(examId, targetWindow, {
practiceMode: null,
reviewMode: false,
readOnly: false
+ }, {
+ expectedRegistration: startResult.registration,
+ launchOwnership
});
- this.restartExamHandshake(targetWindow, examId);
+ if (!ownsResetRegistration(startResult.registration)) {
+ return null;
+ }
+ this.restartExamHandshake(targetWindow, examId, {
+ expectedRegistration: startResult.registration,
+ launchOwnership
+ });
+ if (!ownsResetRegistration(startResult.registration)) {
+ return null;
+ }
this.updateExamStatus(examId, 'in-progress');
+ return true;
},
/**
* 开始练习会话
*/
- async startPracticeSession(examId) {
- const exam = await findExamDefinition(examId);
- if (!exam) {
- console.error('Exam not found:', examId);
- window.showMessage && window.showMessage('题目索引未加载,请重试或重新导入题库。', 'error');
- return;
+ _isPracticeSessionOwnedSuccess(result) {
+ return Boolean(
+ result
+ && result.owned === true
+ && result.status === 'owned-success'
+ && result.registration
+ && result.sessionId
+ );
+ },
+
+ _buildPracticeSessionOwnedSuccess(
+ examId,
+ source,
+ sessionId,
+ value,
+ windowInfo,
+ launchOwnership = null
+ ) {
+ const registration = this._captureExamSessionRegistration(examId, windowInfo);
+ const ownsRegistration = launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(examId, launchOwnership, registration)
+ : this._isExamSessionRegistrationCurrent(examId, registration);
+ const normalizedSessionId = String(
+ sessionId || (registration && registration.expectedSessionId) || ''
+ ).trim();
+ if (!registration || !ownsRegistration || !normalizedSessionId) {
+ return null;
+ }
+ return Object.freeze({
+ owned: true,
+ status: 'owned-success',
+ examId: String(examId || ''),
+ source: String(source || ''),
+ sessionId: normalizedSessionId,
+ value: value == null ? null : value,
+ registration
+ });
+ },
+
+ async _saveOwnedPracticeSessionRecovery(examId, sessionId, expectedRegistration, options = {}) {
+ const normalizedSessionId = String(sessionId || '').trim();
+ const additionalGuard = options && typeof options.commitGuard === 'function'
+ ? options.commitGuard
+ : null;
+ const ownsRegistration = () => this._isExamSessionRegistrationCurrent(
+ examId,
+ expectedRegistration
+ ) && (!additionalGuard || additionalGuard() === true);
+ // openExam registers the WindowProxy before starting a session. Calls without that
+ // immutable owner tuple are legacy/stale and must not create unowned recovery data.
+ if (!normalizedSessionId || !expectedRegistration || !ownsRegistration()) {
+ return null;
+ }
+
+ const sessionData = {
+ id: `active-session:${normalizedSessionId}`,
+ examId: examId,
+ startTime: new Date().toISOString(),
+ status: 'started',
+ sessionId: normalizedSessionId
+ };
+ const receipt = await window.AppData.recovery.saveActiveSession(sessionData, {
+ commitGuard: ownsRegistration
+ });
+ if (!receipt || receipt.committed !== true) {
+ return null;
+ }
+ if (!ownsRegistration()) {
+ const cleanupGuard = () => {
+ const current = this.examWindows && this.examWindows.get(examId);
+ return this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ || !current
+ || String(current.expectedSessionId || '').trim() !== normalizedSessionId;
+ };
+ if (cleanupGuard()) {
+ await this._discardActiveSessionsForExam(examId, {
+ expectedSessionId: normalizedSessionId,
+ commitGuard: cleanupGuard
+ });
+ }
+ return null;
+ }
+ return sessionData;
+ },
+
+ async startPracticeSession(examId, options = {}) {
+ const launchOwnership = options && options.launchOwnership || null;
+ let expectedRegistration = options && options.expectedRegistration
+ ? options.expectedRegistration
+ : this._captureExamSessionRegistration(examId);
+ let windowInfo = expectedRegistration && expectedRegistration.windowInfo || null;
+ const ownsExpectedRegistration = () => Boolean(expectedRegistration) && (
+ launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ expectedRegistration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ );
+ if (!ownsExpectedRegistration()) {
+ return null;
+ }
+ const pendingCompletion = this._practiceCompletionGates
+ && this._practiceCompletionGates.get(String(examId || ''));
+ if (pendingCompletion && pendingCompletion.promise) {
+ try { await pendingCompletion.promise; } catch (_) {}
+ if (!ownsExpectedRegistration()) {
+ return null;
+ }
+ }
+
+ let hostSessionId = String(expectedRegistration.expectedSessionId || '').trim()
+ || String(this.generateSessionId(examId) || '').trim();
+ if (!hostSessionId) {
+ return null;
+ }
+ if (String(windowInfo.expectedSessionId || '').trim() !== hostSessionId) {
+ windowInfo.expectedSessionId = hostSessionId;
+ this._refreshExamWindowToken(examId, windowInfo);
+ this.examWindows.set(examId, windowInfo);
+ expectedRegistration = this._captureExamSessionRegistration(examId, windowInfo);
+ if (!ownsExpectedRegistration()) {
+ return null;
+ }
}
+ let exam = options && options.examDefinition && typeof options.examDefinition === 'object'
+ ? options.examDefinition
+ : null;
try {
+ if (!exam) {
+ exam = await findExamDefinition(examId);
+ if (!ownsExpectedRegistration()) {
+ return null;
+ }
+ }
+ if (!exam) {
+ console.error('Exam not found:', examId);
+ window.showMessage && window.showMessage('题目索引未加载,请重试或重新导入题库。', 'error');
+ return null;
+ }
+
// 优先使用新的练习页面管理器
if (window.practicePageManager) {
- const sessionId = await window.practicePageManager.startPracticeSession(examId, exam);
+ const managerResult = await window.practicePageManager.startPracticeSession(
+ examId,
+ Object.assign({}, exam, { sessionId: hostSessionId })
+ );
+ const managerSessionId = String(
+ typeof managerResult === 'string'
+ ? managerResult
+ : (managerResult && typeof managerResult === 'object'
+ ? (managerResult.sessionId || '')
+ : '')
+ ).trim();
+ if (!ownsExpectedRegistration()) {
+ const staleSessionId = managerSessionId || hostSessionId;
+ const staleRegistration = expectedRegistration;
+ const cleanupGuard = () => {
+ const current = this.examWindows && this.examWindows.get(examId);
+ return this._isExamSessionRegistrationCurrent(examId, staleRegistration)
+ || !current
+ || String(current.expectedSessionId || '').trim() !== staleSessionId;
+ };
+ if (staleSessionId && cleanupGuard()) {
+ await this._discardActiveSessionsForExam(examId, {
+ expectedSessionId: staleSessionId,
+ commitGuard: cleanupGuard
+ });
+ }
+ return null;
+ }
+ if (managerResult === false) {
+ return null;
+ }
+ if (managerSessionId && managerSessionId !== hostSessionId) {
+ windowInfo.expectedSessionId = managerSessionId;
+ this._refreshExamWindowToken(examId, windowInfo);
+ this.examWindows.set(examId, windowInfo);
+ expectedRegistration = this._captureExamSessionRegistration(examId, windowInfo);
+ if (!ownsExpectedRegistration()) {
+ return null;
+ }
+ hostSessionId = managerSessionId;
+ if (!windowInfo.handshakeDeferred && windowInfo.window) {
+ this.restartExamHandshake(windowInfo.window, examId, {
+ expectedRegistration,
+ launchOwnership
+ });
+ }
+ }
- // 更新题目状态
this.updateExamStatus(examId, 'in-progress');
- return sessionId;
+ return this._buildPracticeSessionOwnedSuccess(
+ examId,
+ 'manager',
+ hostSessionId,
+ managerResult,
+ windowInfo,
+ launchOwnership
+ );
}
// 使用练习记录器开始会话
if (this.components.practiceRecorder) {
+ const launchLibraryConfigurationId = this._readLaunchLibraryConfigurationId(examId);
+ const startPayload = Object.assign({}, exam, {
+ sessionId: hostSessionId,
+ libraryConfigurationId: launchLibraryConfigurationId
+ });
let sessionData;
if (typeof this.components.practiceRecorder.startPracticeSession === 'function') {
- sessionData = this.components.practiceRecorder.startPracticeSession(examId, exam);
+ sessionData = this.components.practiceRecorder.startPracticeSession(
+ examId,
+ startPayload
+ );
} else if (typeof this.components.practiceRecorder.startSession === 'function') {
- sessionData = this.components.practiceRecorder.startSession(examId, exam);
+ sessionData = this.components.practiceRecorder.startSession(
+ examId,
+ startPayload
+ );
} else {
console.warn('[App] PracticeRecorder没有可用的启动方法');
sessionData = null;
}
- } else {
- // 降级处理
- const sessionData = {
- examId: examId,
- startTime: new Date().toISOString(),
- status: 'started',
- sessionId: this.generateSessionId(examId)
- };
-
- const activeSessions = await storage.get('active_sessions', []);
- activeSessions.push(sessionData);
- await storage.set('active_sessions', activeSessions);
+ if (!ownsExpectedRegistration()) {
+ return null;
+ }
+ const recorderSessionId = String(
+ sessionData && sessionData.sessionId || hostSessionId
+ ).trim();
+ if (recorderSessionId && recorderSessionId !== hostSessionId) {
+ windowInfo.expectedSessionId = recorderSessionId;
+ this._refreshExamWindowToken(examId, windowInfo);
+ this.examWindows.set(examId, windowInfo);
+ expectedRegistration = this._captureExamSessionRegistration(examId, windowInfo);
+ if (!ownsExpectedRegistration()) {
+ return null;
+ }
+ hostSessionId = recorderSessionId;
+ if (!windowInfo.handshakeDeferred && windowInfo.window) {
+ this.restartExamHandshake(windowInfo.window, examId, {
+ expectedRegistration,
+ launchOwnership
+ });
+ }
+ }
+ this.updateExamStatus(examId, 'in-progress');
+ return this._buildPracticeSessionOwnedSuccess(
+ examId,
+ 'recorder',
+ hostSessionId,
+ sessionData,
+ windowInfo,
+ launchOwnership
+ );
}
- // 更新题目状态
+ const sessionData = await this._saveOwnedPracticeSessionRecovery(
+ examId,
+ hostSessionId,
+ expectedRegistration,
+ {
+ commitGuard: () => !launchOwnership
+ || this._isExamLaunchOwnershipCurrent(
+ examId,
+ launchOwnership,
+ null,
+ expectedRegistration.window
+ )
+ }
+ );
+ if (!sessionData || !ownsExpectedRegistration()) {
+ return null;
+ }
this.updateExamStatus(examId, 'in-progress');
-
+ return this._buildPracticeSessionOwnedSuccess(
+ examId,
+ 'recovery',
+ hostSessionId,
+ sessionData,
+ windowInfo,
+ launchOwnership
+ );
} catch (error) {
console.error('[App] 启动练习会话失败:', error);
-
- // 最终降级方案
- this.startPracticeSessionFallback(examId, exam);
+ return await this.startPracticeSessionFallback(examId, exam, {
+ sessionId: hostSessionId,
+ expectedRegistration,
+ launchOwnership
+ });
}
},
/**
* 降级启动练习会话
*/
- async startPracticeSessionFallback(examId, exam) {
-
- const sessionData = {
- examId: examId,
- startTime: new Date().toISOString(),
- status: 'started',
- sessionId: this.generateSessionId(examId)
- };
-
- const activeSessions = await storage.get('active_sessions', []);
- activeSessions.push(sessionData);
- await storage.set('active_sessions', activeSessions);
+ async startPracticeSessionFallback(examId, exam, options = {}) {
+ const sessionId = String(options && options.sessionId || '').trim();
+ const expectedRegistration = options && options.expectedRegistration || null;
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsExpectedRegistration = () => Boolean(expectedRegistration) && (
+ launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(
+ examId,
+ launchOwnership,
+ expectedRegistration
+ )
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ );
+ if (!sessionId || !ownsExpectedRegistration()) {
+ return null;
+ }
+ const sessionData = await this._saveOwnedPracticeSessionRecovery(
+ examId,
+ sessionId,
+ expectedRegistration,
+ {
+ commitGuard: () => !launchOwnership
+ || this._isExamLaunchOwnershipCurrent(
+ examId,
+ launchOwnership,
+ null,
+ expectedRegistration.window
+ )
+ }
+ );
+ if (!sessionData || !ownsExpectedRegistration()) {
+ return null;
+ }
- // 更新题目状态
this.updateExamStatus(examId, 'in-progress');
-
- // 尝试打开练习页面
const practiceUrl = `templates/ielts-exam-template.html?examId=${examId}`;
window.open(practiceUrl, `practice_${sessionData.sessionId}`, 'width=1200,height=800');
+ return this._buildPracticeSessionOwnedSuccess(
+ examId,
+ 'fallback',
+ sessionData.sessionId,
+ sessionData,
+ expectedRegistration.windowInfo,
+ launchOwnership
+ );
},
/**
@@ -10181,42 +13722,80 @@
/**
* 处理数据采集器会话就绪
*/
- handleSessionReady(examId, data) {
+ handleSessionReady(examId, data, options = {}) {
const payload = data && typeof data === 'object' ? data : {};
+ const expectedRegistration = options && options.expectedRegistration
+ || this._captureExamSessionRegistration(examId);
+ const launchOwnership = options && options.launchOwnership || null;
+ const ownsReadyRegistration = () => Boolean(expectedRegistration) && (
+ launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(examId, launchOwnership, expectedRegistration)
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ );
+ if (!ownsReadyRegistration()) {
+ return false;
+ }
+
+ const windowInfo = expectedRegistration.windowInfo;
+ const expectedSessionId = String(expectedRegistration.expectedSessionId || '').trim();
+ const payloadSessionId = typeof payload.sessionId === 'string'
+ ? payload.sessionId.trim()
+ : '';
+ // SESSION_READY is an acknowledgement of the host-issued identity. The
+ // page may never rotate that identity itself; manager/recorder alignment
+ // updates the registration on the host before READY is accepted.
+ if (payloadSessionId && expectedSessionId && payloadSessionId !== expectedSessionId) {
+ return false;
+ }
+
+ const hasManagedSuiteOwnership = Object.prototype.hasOwnProperty.call(windowInfo, 'suiteSessionId');
+ const registeredSuiteSessionId = hasManagedSuiteOwnership
+ ? String(windowInfo.suiteSessionId || '').trim()
+ : '';
+ const payloadSuiteSessionId = typeof payload.suiteSessionId === 'string'
+ ? payload.suiteSessionId.trim()
+ : '';
+ const activeSuite = this.currentSuiteSession;
+ const activeSuiteSessionId = String(activeSuite && activeSuite.id || '').trim();
+ const ownsCurrentSuiteRegistration = Boolean(
+ registeredSuiteSessionId
+ && activeSuiteSessionId
+ && registeredSuiteSessionId === activeSuiteSessionId
+ );
+ const ownsPayloadSuiteProtocol = Boolean(
+ ownsCurrentSuiteRegistration
+ && payloadSuiteSessionId
+ && payloadSuiteSessionId === registeredSuiteSessionId
+ );
+ // A payload may confirm an existing suite owner, but it must never
+ // promote an ordinary (explicit-null) registration into the suite.
+ if (payloadSuiteSessionId && payloadSuiteSessionId !== registeredSuiteSessionId) {
+ return false;
+ }
+ if (registeredSuiteSessionId && !ownsCurrentSuiteRegistration) {
+ return false;
+ }
+
const isListeningBridgeReady = payload.source === 'listening_record_bridge'
|| payload.metadata?.source === 'listening_record_bridge'
|| payload.pageType === 'listening'
|| payload.type === 'listening';
- const isPreInitListeningReady = Boolean(
- isListeningBridgeReady
- && payload.initialized === false
+ const isPreInitReady = (isListeningBridgeReady && payload.initialized === false) || (
+ !String(payload.windowSessionToken || '').trim()
+ && payload.pageType === 'suite-placeholder'
);
- // 更新会话状态
- let windowInfo = null;
- if (this.examWindows && this.examWindows.has(examId)) {
- windowInfo = this.examWindows.get(examId);
- } else {
- windowInfo = this.ensureExamWindowSession(examId);
+ if (isListeningBridgeReady) {
+ windowInfo.listeningBridgeSeen = true;
+ windowInfo.listeningBridgeInitialized = !isPreInitReady;
}
-
- if (windowInfo) {
- if (isListeningBridgeReady) {
- windowInfo.listeningBridgeSeen = true;
- windowInfo.listeningBridgeInitialized = !isPreInitListeningReady;
- }
- if (!isPreInitListeningReady) {
- windowInfo.dataCollectorReady = true;
- }
- if (payload.pageType) {
- windowInfo.pageType = payload.pageType;
- }
- if (!isPreInitListeningReady && payload.sessionId && windowInfo.expectedSessionId !== payload.sessionId) {
- windowInfo.expectedSessionId = payload.sessionId;
- }
- if (payload.suiteSessionId && !windowInfo.suiteSessionId) {
- windowInfo.suiteSessionId = payload.suiteSessionId;
- }
+ if (!isPreInitReady) {
+ windowInfo.dataCollectorReady = true;
+ }
+ if (payload.pageType) {
+ windowInfo.pageType = payload.pageType;
+ }
+ if (ownsPayloadSuiteProtocol) {
if (payload.suiteFlowMode && !windowInfo.suiteFlowMode) {
windowInfo.suiteFlowMode = payload.suiteFlowMode;
}
@@ -10229,27 +13808,34 @@
if (Array.isArray(payload.suiteSequence) && payload.suiteSequence.length) {
windowInfo.suiteSequence = payload.suiteSequence;
}
- if (payload.url) {
- windowInfo.latestUrl = payload.url;
- }
- this.examWindows && this.examWindows.set(examId, windowInfo);
}
+ if (payload.url) {
+ windowInfo.latestUrl = payload.url;
+ }
+ if (!ownsReadyRegistration()) {
+ return false;
+ }
+ this.examWindows && this.examWindows.set(examId, windowInfo);
- if (isPreInitListeningReady) {
+ if (isPreInitReady) {
try {
- const targetWindow = (windowInfo && windowInfo.window) || null;
+ const targetWindow = windowInfo.window || null;
if (targetWindow && typeof targetWindow.postMessage === 'function') {
- const initPayload = this._buildExamInitPayload(examId, windowInfo || {});
- targetWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*');
- targetWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*');
+ const initPayload = this._buildExamInitPayload(examId, windowInfo);
+ const sendOptions = { expectedRegistration, launchOwnership };
+ this._postExamMessage(examId, targetWindow, 'INIT_SESSION', initPayload, sendOptions);
+ this._postExamMessage(examId, targetWindow, 'init_exam_session', initPayload, sendOptions);
}
} catch (initError) {
- console.warn('[App] 听力桥预初始化 ready 后补发 INIT_SESSION 失败:', initError);
+ console.warn('[App] 预初始化 ready 后补发 INIT_SESSION 失败:', initError);
}
- return;
+ return ownsReadyRegistration();
}
- if (this.suiteExamMap && this.suiteExamMap.has(examId) && typeof this._handleSuiteSessionReady === 'function') {
+ if (ownsPayloadSuiteProtocol
+ && this.suiteExamMap
+ && this.suiteExamMap.has(examId)
+ && typeof this._handleSuiteSessionReady === 'function') {
try {
this._handleSuiteSessionReady(examId);
} catch (suiteReadyError) {
@@ -10257,11 +13843,27 @@
}
}
- if (!(windowInfo && windowInfo.reviewMode)
+ const stationarySuiteExam = Boolean(
+ ownsPayloadSuiteProtocol
+ && activeSuite
+ && activeSuite.status === 'active'
+ && activeSuite.flowMode === 'stationary'
+ && Array.isArray(activeSuite.sequence)
+ && activeSuite.sequence.some(item => item && String(item.examId) === String(examId))
+ );
+ if (stationarySuiteExam && typeof this._sendSuiteReviewState === 'function') {
+ try {
+ this._sendSuiteReviewState(activeSuite, examId, windowInfo.window || null);
+ } catch (suiteContextError) {
+ console.warn('[SuitePractice] 手动回看页面 ready 后补发上下文失败:', suiteContextError);
+ }
+ }
+
+ if (!windowInfo.reviewMode
&& this.components
&& this.components.practiceRecorder
&& typeof this.components.practiceRecorder.handleSessionStarted === 'function') {
- const recorderSessionId = (windowInfo && windowInfo.expectedSessionId) || payload.sessionId || this.generateSessionId(examId);
+ const recorderSessionId = expectedSessionId || this.generateSessionId(examId);
try {
this.components.practiceRecorder.handleSessionStarted({
examId,
@@ -10270,7 +13872,9 @@
pageType: payload.pageType || null,
url: payload.url || null,
title: payload.title || null,
- suiteSessionId: payload.suiteSessionId || null
+ suiteSessionId: registeredSuiteSessionId || null,
+ // 此处是练习页 SESSION_READY 后同步会话状态的时刻,注入启动时捕获的题库配置 ID。
+ libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, payload, windowInfo)
}
});
} catch (recorderError) {
@@ -10278,7 +13882,9 @@
}
}
- // 停止握手重试
+ if (!ownsReadyRegistration()) {
+ return false;
+ }
try {
if (this._handshakeTimers && this._handshakeTimers.has(examId)) {
clearInterval(this._handshakeTimers.get(examId));
@@ -10286,12 +13892,10 @@
}
} catch (_) { }
- if (windowInfo && windowInfo.reviewMode) {
+ if (windowInfo.reviewMode) {
this._dispatchReviewReplayForExam(examId, windowInfo.window || null);
}
-
- // 可以在这里发送额外的配置信息给数据采集器
- // 例如题目信息、特殊设置等
+ return true;
},
/**
@@ -10333,11 +13937,7 @@
return signals.includes('listening_record_bridge') || signals.includes('listening');
},
- _ensureRecorderSessionForListeningCompletion(examId, data) {
- if (!this._isListeningBridgeCompletionPayload(data)) {
- return;
- }
-
+ _ensureRecorderSessionForPracticeCompletion(examId, data, sourceWindow = null, defaults = {}) {
const recorder = this.components && this.components.practiceRecorder;
if (!recorder) {
return;
@@ -10363,18 +13963,35 @@
&& typeof recorder.activeSessions.has === 'function'
&& recorder.activeSessions.has(examId)
);
+ const pageType = defaults.pageType
+ || data?.pageType
+ || data?.metadata?.pageType
+ || data?.metadata?.type
+ || data?.type
+ || 'practice';
+ const practiceType = defaults.type
+ || data?.type
+ || data?.metadata?.type
+ || data?.metadata?.examType
+ || pageType;
+ const source = defaults.source
+ || data?.source
+ || data?.metadata?.source
+ || 'practice_page';
if (!hasActiveSession && typeof recorder.startPracticeSession === 'function') {
try {
recorder.startPracticeSession(examId, {
+ sessionId,
title: data?.title || data?.metadata?.examTitle || '',
category: data?.category || data?.pageType || data?.metadata?.category || '',
frequency: data?.frequency || data?.metadata?.frequency || '',
- type: 'listening',
- totalQuestions: data?.scoreInfo?.total || data?.totalQuestions || 0
+ type: practiceType,
+ totalQuestions: data?.scoreInfo?.total || data?.totalQuestions || 0,
+ libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, data, windowInfo)
});
} catch (startError) {
- console.warn('[PracticeRecorder] 听力完成前补建会话失败:', startError);
+ console.warn('[PracticeRecorder] 完成前补建会话失败:', startError);
}
}
@@ -10384,17 +14001,18 @@
examId,
sessionId,
metadata: {
- pageType: data?.pageType || 'listening',
- type: 'listening',
- examType: 'listening',
+ pageType,
+ type: practiceType,
+ examType: defaults.examType || practiceType,
url: data?.url || data?.metadata?.url || null,
title: data?.title || data?.metadata?.examTitle || null,
suiteSessionId: data?.suiteSessionId || data?.metadata?.suiteSessionId || null,
- source: data?.source || data?.metadata?.source || 'listening_record_bridge'
+ source,
+ libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, data, windowInfo)
}
});
} catch (startedError) {
- console.warn('[PracticeRecorder] 听力完成前同步会话状态失败:', startedError);
+ console.warn('[PracticeRecorder] 完成前同步会话状态失败:', startedError);
}
}
},
@@ -10428,14 +14046,66 @@
/**
* 处理练习完成(真实数据)
*/
- async handlePracticeComplete(examId, data, sourceWindow = null) {
+ async handlePracticeComplete(examId, data, sourceWindow = null, options = {}) {
+ const launchOwnership = options && options.launchOwnership || null;
+ const expectedRegistration = options && options.expectedRegistration
+ ? options.expectedRegistration
+ : this._captureExamSessionRegistration(examId);
+ sourceWindow = sourceWindow || (expectedRegistration && expectedRegistration.window) || null;
+ const ownsCompletionRegistration = () => Boolean(expectedRegistration) && (
+ expectedRegistration.window === sourceWindow
+ && this._isExamSessionRegistrationCurrent(examId, expectedRegistration)
+ );
+ const ownsCompletion = () => Boolean(expectedRegistration) && (
+ expectedRegistration.window === sourceWindow
+ && (launchOwnership
+ ? this._isOwnedExamLaunchRegistrationCurrent(examId, launchOwnership, expectedRegistration)
+ : this._isExamSessionRegistrationCurrent(examId, expectedRegistration))
+ );
+ if (!ownsCompletion()) {
+ return false;
+ }
if (data && !data.sessionId) {
- data.sessionId = `${examId}_${Date.now()}`;
+ data.sessionId = String(expectedRegistration.expectedSessionId || '').trim()
+ || `${examId}_${Date.now()}`;
+ }
+ const completionSessionId = String(data && data.sessionId || '').trim();
+ if (!completionSessionId
+ || completionSessionId !== String(expectedRegistration.expectedSessionId || '').trim()) {
+ return false;
+ }
+ const registrationInfoAtCompletion = expectedRegistration.windowInfo;
+ const hasManagedSuiteOwnershipAtCompletion = Object.prototype.hasOwnProperty.call(
+ registrationInfoAtCompletion,
+ 'suiteSessionId'
+ );
+ const registeredSuiteSessionId = String(expectedRegistration.suiteSessionId || '').trim();
+ const submittedSuiteSessionId = String(
+ data && (
+ data.suiteSessionId
+ || (data.metadata && data.metadata.suiteSessionId)
+ ) || ''
+ ).trim();
+ if (hasManagedSuiteOwnershipAtCompletion) {
+ if ((registeredSuiteSessionId && submittedSuiteSessionId !== registeredSuiteSessionId)
+ || (!registeredSuiteSessionId && submittedSuiteSessionId)) {
+ return false;
+ }
+ data = Object.assign({}, data, {
+ suiteSessionId: registeredSuiteSessionId || null
+ });
+ if (!registeredSuiteSessionId
+ && !String(data.practiceMode || data.metadata && data.metadata.practiceMode || '').trim()) {
+ data.practiceMode = 'single';
+ }
}
if (String(data?.practiceMode || data?.metadata?.practiceMode || '').toLowerCase() === 'memorize') {
console.info('[ReadingMemorize] 背题模式完成事件不保存为正式练习记录:', examId);
return;
}
+ if (this._replayPracticeSubmitReceipt(examId, data, sourceWindow)) {
+ return true;
+ }
// 听力桥返回的填空答案直接按 answerComparison 检测,不能依赖题源目录名必须包含 P1/P4。
try {
@@ -10477,73 +14147,243 @@
console.warn('[DataCollection] 拼写错误检测失败,已忽略:', error);
}
this._normalizeListeningSpellingErrors(examId, data);
+ // Reading/placeholder completions need the same active-session rebind that
+ // listening already performed: hot-upgraded PracticeRecorder instances otherwise
+ // reject production saves when activeSessions was empty.
+ this._ensureRecorderSessionForPracticeCompletion(examId, data, sourceWindow);
let suiteHandlerDeclined = false;
const payloadSuiteSessionId = (
data
&& typeof data === 'object'
- && typeof data.suiteSessionId === 'string'
- ) ? data.suiteSessionId.trim() : '';
- const hasMappedSuiteExam = Boolean(this.suiteExamMap && this.suiteExamMap.has(examId));
- const hasActiveSuiteSession = Boolean(
- this.currentSuiteSession
- && this.currentSuiteSession.status === 'active'
- && (!payloadSuiteSessionId || this.currentSuiteSession.id === payloadSuiteSessionId)
+ ) ? String(
+ data.suiteSessionId
+ || (data.metadata && data.metadata.suiteSessionId)
+ || ''
+ ).trim() : '';
+ const registrationInfo = expectedRegistration.windowInfo;
+ const hasManagedSuiteOwnership = Object.prototype.hasOwnProperty.call(
+ registrationInfo,
+ 'suiteSessionId'
);
- const shouldDelegateToSuiteHandler = Boolean(
+ const registrationSuiteSessionId = String(expectedRegistration.suiteSessionId || '').trim();
+ const hasPayloadSuiteEntry = Boolean(
data
&& typeof data === 'object'
&& typeof data.suiteId === 'string'
&& data.suiteId.trim()
- ) || hasMappedSuiteExam || Boolean(payloadSuiteSessionId) || hasActiveSuiteSession;
+ );
+ let shouldDelegateToSuiteHandler = false;
+ if (hasManagedSuiteOwnership) {
+ if (registrationSuiteSessionId) {
+ if (payloadSuiteSessionId !== registrationSuiteSessionId) {
+ return false;
+ }
+ shouldDelegateToSuiteHandler = true;
+ } else if (payloadSuiteSessionId || hasPayloadSuiteEntry) {
+ // A managed ordinary registration can never acquire suite ownership
+ // from payload/global state after launch.
+ return false;
+ }
+ } else {
+ // Compatibility for legacy registrations that predate the explicit
+ // suiteSessionId field. New managed windows never enter this branch.
+ const hasMappedSuiteExam = Boolean(this.suiteExamMap && this.suiteExamMap.has(examId));
+ const hasActiveSuiteSession = Boolean(
+ this.currentSuiteSession
+ && this.currentSuiteSession.status === 'active'
+ && (!payloadSuiteSessionId || this.currentSuiteSession.id === payloadSuiteSessionId)
+ );
+ shouldDelegateToSuiteHandler = hasPayloadSuiteEntry
+ || hasMappedSuiteExam
+ || Boolean(payloadSuiteSessionId)
+ || hasActiveSuiteSession;
+ }
if (shouldDelegateToSuiteHandler && typeof this.handleSuitePracticeComplete === 'function') {
try {
- const handled = await this.handleSuitePracticeComplete(examId, data, sourceWindow);
+ if (!ownsCompletion()) {
+ return false;
+ }
+ const suiteOutcome = await this.handleSuitePracticeComplete(examId, data, sourceWindow);
+ const handled = suiteOutcome === true || Boolean(suiteOutcome && suiteOutcome.handled);
if (handled) {
- return;
+ const committed = !suiteOutcome || typeof suiteOutcome !== 'object' || suiteOutcome.committed !== false;
+ const suiteErrorCode = String(suiteOutcome && suiteOutcome.errorCode || '').trim();
+ const acknowledgeDurableOutcome = committed || suiteErrorCode === 'suite_advance_superseded';
+ if (acknowledgeDurableOutcome ? !ownsCompletionRegistration() : !ownsCompletion()) {
+ return acknowledgeDurableOutcome;
+ }
+ this._announcePracticeSubmitOutcome(examId, data, sourceWindow, acknowledgeDurableOutcome, {
+ errorCode: suiteErrorCode
+ }, {
+ expectedRegistration,
+ ...(acknowledgeDurableOutcome ? {} : { launchOwnership })
+ });
+ if (committed && suiteOutcome && suiteOutcome.teardownSession && typeof this._teardownSuiteSession === 'function') {
+ try {
+ this._scheduleSuiteSubmitTeardown(suiteOutcome.teardownSession);
+ } catch (teardownError) {
+ console.warn('[SuitePractice] 套题已提交,但延迟清理调度失败:', teardownError);
+ }
+ }
+ return acknowledgeDurableOutcome;
}
suiteHandlerDeclined = true;
} catch (suiteError) {
- console.error('[SuitePractice] 处理套题结果失败,回退至普通流程:', suiteError);
- window.showMessage && window.showMessage('套题模式出现异常,记录将以单篇形式保存。', 'warning');
+ console.error('[SuitePractice] 处理套题结果失败,保留 v2 恢复快照:', suiteError);
+ window.showMessage && window.showMessage('套题模式出现异常,恢复快照已保留,请稍后重试。', 'error');
suiteHandlerDeclined = true;
}
}
+ if (suiteHandlerDeclined && shouldDelegateToSuiteHandler) {
+ return false;
+ }
+ if (shouldDelegateToSuiteHandler && typeof this.handleSuitePracticeComplete !== 'function') {
+ return false;
+ }
+
+ if (!ownsCompletion()) {
+ return false;
+ }
+ if (!this._practiceCompletionGates) {
+ this._practiceCompletionGates = new Map();
+ }
+ const priorCompletionGate = this._practiceCompletionGates.get(String(examId || ''));
+ if (priorCompletionGate && priorCompletionGate.promise) {
+ try { await priorCompletionGate.promise; } catch (_) {}
+ if (!ownsCompletion()) {
+ return false;
+ }
+ }
+ let releaseCompletionGate = null;
+ const completionGate = {
+ registration: expectedRegistration,
+ sessionId: completionSessionId,
+ promise: new Promise((resolve) => { releaseCompletionGate = resolve; })
+ };
+ this._practiceCompletionGates.set(String(examId || ''), completionGate);
+
const recorder = this.components && this.components.practiceRecorder;
- const completionData = suiteHandlerDeclined
- ? Object.assign({}, data, {
- allowStandaloneSave: true,
- metadata: Object.assign({}, data?.metadata || {}, { allowStandaloneSave: true, suiteRecovery: true })
- })
- : data;
- this._ensureRecorderSessionForListeningCompletion(examId, completionData);
+ const completionData = data;
+ // The generic completion rebind above already covers listening payloads.
+ let completionCommitted = false;
+ let completedViaFallback = false;
try {
+ let persistedRecord = null;
if (recorder && typeof recorder.handleSessionCompleted === 'function') {
try {
- await recorder.handleSessionCompleted(completionData);
+ persistedRecord = await recorder.handleSessionCompleted(completionData);
} catch (recErr) {
console.warn('[DataCollection] PracticeRecorder 完成事件处理失败,改用降级存储:', recErr);
- await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true });
+ persistedRecord = await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true });
+ completedViaFallback = true;
}
} else {
- await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true });
+ persistedRecord = await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true });
+ completedViaFallback = true;
+ }
+
+ if (!persistedRecord || typeof persistedRecord !== 'object' || !String(persistedRecord.id || '').trim()) {
+ throw new Error('Practice completion returned without a committed record');
+ }
+
+ let completionReadable = false;
+ if (typeof this._isPracticeCompletionPersisted === 'function') {
+ try {
+ completionReadable = await this._isPracticeCompletionPersisted(persistedRecord);
+ } catch (verificationError) {
+ console.warn('[DataCollection] 练习记录提交后回读失败,不影响已提交结果:', verificationError);
+ }
+ }
+ if (!completionReadable) {
+ throw new Error('Practice completion could not be verified in canonical storage');
+ }
+ completionCommitted = true;
+
+ if (completedViaFallback && recorder && typeof recorder.endPracticeSession === 'function') {
+ const activeRecorderSession = recorder.activeSessions
+ && typeof recorder.activeSessions.get === 'function'
+ ? recorder.activeSessions.get(examId)
+ : null;
+ if ((activeRecorderSession
+ && String(activeRecorderSession.sessionId || '').trim() === completionSessionId)
+ || (!activeRecorderSession && ownsCompletion())) {
+ recorder.endPracticeSession(examId);
+ }
+ }
+
+ if (!ownsCompletion()) {
+ return true;
+ }
+
+ // 单篇阅读 final-submit 落库成功后,把已存档 recordId 回传给结果页,
+ // 使其可以在只读提交态编辑笔记并以 READING_ANNOTATION_SYNC 持久化回该记录。
+ // 套题流程在上方的 handleSuitePracticeComplete 分支已 return,不会走到这里。
+ const completionOwnershipOptions = { expectedRegistration, launchOwnership };
+ this._announceSubmittedReadingRecord(
+ examId,
+ persistedRecord,
+ completionData,
+ sourceWindow,
+ completionOwnershipOptions
+ );
+ this._announcePracticeSubmitOutcome(
+ examId,
+ completionData,
+ sourceWindow,
+ true,
+ {},
+ completionOwnershipOptions
+ );
+
+ if (typeof this.clearReadingDraftForExam === 'function') {
+ try {
+ await this.clearReadingDraftForExam(examId, {
+ sessionId: completionData && completionData.sessionId
+ ? String(completionData.sessionId)
+ : null,
+ // 完成事件已通过严格的 message/session 校验,删除该题草稿时
+ // 允许命中“恢复前的旧 session id”的存档,避免已提交答案被复活。
+ acceptResumeSessionId: true,
+ commitGuard: ownsCompletion
+ });
+ } catch (_) {
+ // draft cleanup is best-effort
+ }
+ }
+ if (!ownsCompletion()) {
+ return true;
}
// 刷新内存中的练习记录,确保无需手动刷新即可看到
// 注意:数据已落库,UI 同步失败不应传播为"保存失败",否则会误导用户并可能诱发重复提交。
try {
+ if (!ownsCompletion()) {
+ return true;
+ }
if (typeof window.syncPracticeRecords === 'function') {
await window.syncPracticeRecords({ forceRender: true });
- } else if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- const latest = await window.PracticeRecordAPI.list();
- this.setState('practice.records', Array.isArray(latest) ? latest : []);
+ } else {
+ const [latest, index] = await Promise.all([
+ window.AppData.practice.list({ projection: 'light' }),
+ window.resolveActiveLibraryIndex()
+ ]);
+ if (typeof window.refreshBrowseProgressFromRecords === 'function') {
+ window.refreshBrowseProgressFromRecords(latest, index);
+ }
+ if (typeof window.updatePracticeView === 'function') {
+ window.updatePracticeView(latest, index);
+ }
}
} catch (syncErr) {
console.error('[DataCollection] 刷新练习记录失败(数据已保存,不影响落库结果):', syncErr);
}
+ if (!ownsCompletion()) {
+ return true;
+ }
// P1/P4:落库后同步保存错词到词表(multi-suite 在 finalizeMultiSuiteRecord 内处理)
if (Array.isArray(data?.spellingErrors) && data.spellingErrors.length > 0
@@ -10555,33 +14395,83 @@
console.warn('[DataCollection] 保存拼写错误词表失败(不影响主流程):', saveError);
}
}
+ if (!ownsCompletion()) {
+ return true;
+ }
// 更新UI状态
this.updateExamStatus(examId, 'completed');
// 显示完成通知(使用真实数据)
- await this.showRealCompletionNotification(examId, data);
-
- // 检查成就
- if (window.AchievementManager) {
- window.AchievementManager.check(data?.realData).catch(console.warn);
+ await this.showRealCompletionNotification(examId, data, {
+ commitGuard: ownsCompletion
+ });
+ if (!ownsCompletion()) {
+ return true;
}
- // 刷新练习记录显示
- if (typeof updatePracticeView === 'function') {
- updatePracticeView();
+ // 检查成就(解锁判定由 achievements.progress projector 负责,这里只读取差异并提示)
+ if (window.AchievementManager) {
+ window.AchievementManager.check().catch(console.warn);
}
} catch (error) {
console.error('[DataCollection] 处理练习完成数据失败:', error);
- window.showMessage && window.showMessage('练习记录保存失败,请稍后重试', 'error');
+ if (ownsCompletion()) {
+ window.showMessage && window.showMessage('练习记录保存失败,请稍后重试', 'error');
+ this._announcePracticeSubmitOutcome(examId, completionData, sourceWindow, false, {
+ errorCode: 'save_failed'
+ }, {
+ expectedRegistration,
+ launchOwnership
+ });
+ }
} finally {
- if (this._isResetCapableUnifiedReadingCompletion(completionData, sourceWindow)) {
- await this.retainExamWindowAfterCompletion(examId, sourceWindow, completionData);
- } else {
- this.cleanupExamSession(examId);
+ try {
+ if (completionCommitted) {
+ try {
+ if (ownsCompletion()) {
+ if (this._isResetCapableUnifiedReadingCompletion(completionData, sourceWindow)) {
+ await this.retainExamWindowAfterCompletion(
+ examId,
+ sourceWindow,
+ completionData,
+ { expectedRegistration, launchOwnership }
+ );
+ } else {
+ await this.cleanupExamSession(examId, {
+ expectedRegistration,
+ recoverySessionId: completionSessionId
+ });
+ }
+ } else {
+ const staleCleanupGuard = () => {
+ const current = this.examWindows && this.examWindows.get(examId);
+ return !current
+ || String(current.expectedSessionId || '').trim() !== completionSessionId;
+ };
+ if (staleCleanupGuard()) {
+ await this._discardActiveSessionsForExam(examId, {
+ expectedSessionId: completionSessionId,
+ commitGuard: staleCleanupGuard
+ });
+ }
+ }
+ } catch (cleanupError) {
+ console.warn('[DataCollection] 练习已提交,但会话清理失败:', cleanupError);
+ }
+ }
+ } finally {
+ if (this._practiceCompletionGates
+ && this._practiceCompletionGates.get(String(examId || '')) === completionGate) {
+ this._practiceCompletionGates.delete(String(examId || ''));
+ }
+ if (typeof releaseCompletionGate === 'function') {
+ releaseCompletionGate();
+ }
}
}
+ return completionCommitted;
},
/**
@@ -10598,12 +14488,7 @@
type: 'data_collection_error'
};
- const errorLogs = await storage.get('collection_errors', []);
- errorLogs.push(errorInfo);
- if (errorLogs.length > 50) {
- errorLogs.splice(0, errorLogs.length - 50);
- }
- await storage.set('collection_errors', errorLogs);
+ console.warn('[DataCollection] 诊断信息:', errorInfo);
// 标记该会话使用模拟数据
if (this.examWindows && this.examWindows.has(examId)) {
@@ -10682,17 +14567,16 @@
throw new Error(`无法找到题目信息: ${examId}`);
}
- const api = window.PracticeRecordAPI;
- if (!api || typeof api.saveCompletion !== 'function') {
- throw new Error('统一练习记录 API 未就绪');
- }
-
const metadata = Object.assign({}, realData?.metadata || {}, {
examId,
examTitle: exam.title || realData?.title || '',
category: exam.category || realData?.category || realData?.metadata?.category || 'unknown',
frequency: exam.frequency || realData?.frequency || realData?.metadata?.frequency || 'unknown',
- type: exam.type || realData?.type || realData?.practiceType || null
+ type: exam.type || realData?.type || realData?.practiceType || null,
+ // 启动时捕获的题库配置 ID;优先取 realData.metadata 显式值,再回退到启动时
+ // 在 openExam 捕获的 mixin 私有 Map 值,最后显式随 metadata 写入为 null,
+ // 让记录来源稳定不受到提交时当前激活题库的影响。
+ libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, realData)
});
const payload = Object.assign({}, realData, {
@@ -10704,19 +14588,17 @@
metadata
});
- const savedRecord = await api.saveCompletion(payload, {
- examId,
- sessionId: payload.sessionId || realData?.sessionId || null,
- examEntry: exam,
- metadata
- }, exam, {
- currentVersion: (window.scoreStorage && window.scoreStorage.currentVersion) || '1.0.0',
- maxRecords: (window.scoreStorage && window.scoreStorage.maxRecords) || 1000,
- updateStats: true
+ const receipt = await window.AppData.practice.completeAttempt({
+ record: payload,
+ operationId: payload.operationId
+ || payload.messageId
+ || (payload.submissionId
+ ? `practice-complete:${String(payload.examId || examId)}:${String(payload.sessionId || 'session')}:${String(payload.submissionId)}`
+ : undefined)
});
console.log('[DataCollection] 练习完成数据已保存到 canonical store');
- return savedRecord;
+ return receipt.record;
} catch (error) {
console.error('[DataCollection] 保存真实数据失败:', error);
throw error;
@@ -10726,12 +14608,21 @@
/**
* 显示真实完成通知
*/
- async showRealCompletionNotification(examId, realData) {
+ async showRealCompletionNotification(examId, realData, options = {}) {
+ const commitGuard = options && typeof options.commitGuard === 'function'
+ ? options.commitGuard
+ : null;
+ if (commitGuard && commitGuard() !== true) {
+ return false;
+ }
const examIndex = await getActiveExamIndexSnapshot();
+ if (commitGuard && commitGuard() !== true) {
+ return false;
+ }
const list = Array.isArray(examIndex) ? examIndex : [];
const exam = list.find(e => e.id === examId);
- if (!exam) return;
+ if (!exam) return false;
const scoreInfo = realData.scoreInfo;
if (scoreInfo) {
@@ -10764,27 +14655,108 @@
: 0;
window.showMessage(`练习完成!\n${exam.title}\n用时: ${duration} 分钟`, 'success');
}
+ return true;
},
/**
* 处理题目窗口关闭
*/
- handleExamWindowClosed(examId) {
+ async handleExamWindowClosed(examId, closedWindow = null) {
+ const info = this.examWindows && this.examWindows.get(examId);
+ if (!info) {
+ return false;
+ }
+ const closedRegistration = info
+ ? this._captureExamSessionRegistration(examId, info)
+ : null;
+ const expectedWindow = info && info.window ? info.window : null;
+ if (closedWindow && expectedWindow && closedWindow !== expectedWindow) {
+ return false;
+ }
+ if (info && info.closeMonitor) {
+ try { clearInterval(info.closeMonitor); } catch (_) {}
+ info.closeMonitor = null;
+ }
- if (this.suiteExamMap && this.suiteExamMap.has(examId) && this.currentSuiteSession && this.currentSuiteSession.status === 'active' && this.suiteExamMap.get(examId) === this.currentSuiteSession.id) {
- window.showMessage && window.showMessage('套题练习窗口已关闭,套题模式将被中断并回退到普通模式。', 'warning');
- if (typeof this._abortSuiteSession === 'function') {
- this._abortSuiteSession(this.currentSuiteSession, {}).catch(error => {
- console.error('[SuitePractice] 中断套题失败:', error);
- });
+ // A pagehide draft can already be accepted while its IDB transaction is
+ // still reading. Drain the queue before removing the registration that its
+ // commit guard owns; if a new registration appears while waiting, fail
+ // closed and leave that replacement untouched.
+ const pendingDraftWrites = !closedRegistration.suiteSessionId
+ ? this._readingDraftStoreQueue
+ : null;
+ if (pendingDraftWrites && typeof pendingDraftWrites.then === 'function') {
+ try { await pendingDraftWrites; } catch (_) {}
+ }
+ if (closedRegistration
+ && !this._isExamSessionRegistrationCurrent(examId, closedRegistration)) {
+ if (typeof this.cleanupExamSession === 'function') {
+ await this.cleanupExamSession(examId, { expectedRegistration: closedRegistration });
}
+ return false;
}
- // 更新题目状态
- this.updateExamStatus(examId, 'interrupted');
+ const suite = this.currentSuiteSession;
+ const isSuiteExam = Boolean(
+ suite
+ && this.suiteExamMap
+ && this.suiteExamMap.get(examId) === suite.id
+ && suite.status === 'active'
+ );
+ const isCompletedSuiteExam = Boolean(
+ suite
+ && this.suiteExamMap
+ && this.suiteExamMap.get(examId) === suite.id
+ && suite.status === 'completed'
+ );
+ if (isCompletedSuiteExam) {
+ // 记录已提交,子页完成后的关闭不应把末篇标成 interrupted;
+ // 会话清理由 30s teardown / 下次 launch 负责。
+ this.updateExamStatus(examId, 'completed');
+ if (typeof this.cleanupExamSession === 'function') {
+ await this.cleanupExamSession(examId, closedRegistration
+ ? { expectedRegistration: closedRegistration }
+ : {});
+ }
+ return true;
+ }
+ if (isSuiteExam) {
+ if (String(suite.activeExamId || '') !== String(examId)) {
+ return false;
+ }
+ if (closedWindow && suite.windowRef && closedWindow !== suite.windowRef) {
+ return false;
+ }
+ const pausedAtMs = Date.now();
+ if (suite.suiteTimerRunning !== false
+ || !Number.isFinite(Number(suite.suiteTimerPausedAtMs))) {
+ suite.suiteTimerPausedAtMs = pausedAtMs;
+ }
+ suite.suiteTimerRunning = false;
+ suite.windowRef = null;
+ suite.status = 'active';
+ suite.lastUpdate = pausedAtMs;
+ let persisted = false;
+ if (typeof this._commitSuiteRecovery === 'function') {
+ persisted = await this._commitSuiteRecovery(suite, { reason: 'window-close' });
+ }
+ if (!persisted && typeof this._mirrorSessionToStorage === 'function') {
+ this._mirrorSessionToStorage(suite);
+ }
+ if (persisted) {
+ window.showMessage && window.showMessage('套题练习窗口已关闭,当前进度已暂停并保留,可从套题模式继续。', 'warning');
+ } else {
+ window.showMessage && window.showMessage('套题窗口已关闭,但恢复快照保存失败,请勿关闭主页面。', 'error');
+ }
+ }
- // 清理会话
- this.cleanupExamSession(examId);
+ this.updateExamStatus(examId, 'interrupted');
+ if (typeof this.cleanupExamSession === 'function') {
+ await this.cleanupExamSession(examId, closedRegistration
+ ? { expectedRegistration: closedRegistration }
+ : {});
+ }
+ return true;
},
/**
@@ -10935,27 +14907,105 @@
return [];
}
const normalizedKeepExamId = keepExamId != null ? String(keepExamId).trim() : '';
- const staleExamIds = [];
+ const staleRegistrations = [];
+ const retainedRecoveryCleanups = [];
this.examWindows.forEach((windowInfo, candidateExamId) => {
const normalizedCandidateExamId = candidateExamId != null ? String(candidateExamId).trim() : '';
- if (!normalizedCandidateExamId || (normalizedKeepExamId && normalizedCandidateExamId === normalizedKeepExamId)) {
+ if (!normalizedCandidateExamId) {
return;
}
if (windowInfo && windowInfo.window === targetWindow) {
- staleExamIds.push(normalizedCandidateExamId);
+ const registration = this._captureExamSessionRegistration(
+ normalizedCandidateExamId,
+ windowInfo
+ );
+ if (registration) {
+ const recoverySessionId = windowInfo.reassignedFromSuiteTeardownOwner === true
+ ? ''
+ : String(
+ windowInfo.reassignedFromExpectedSessionId
+ || registration.expectedSessionId
+ || ''
+ ).trim();
+ const cleanup = {
+ examId: normalizedCandidateExamId,
+ registration,
+ recoverySessionId
+ };
+ if (normalizedKeepExamId && normalizedCandidateExamId === normalizedKeepExamId) {
+ if (recoverySessionId
+ && recoverySessionId !== String(registration.expectedSessionId || '').trim()) {
+ retainedRecoveryCleanups.push(cleanup);
+ }
+ } else {
+ staleRegistrations.push(cleanup);
+ }
+ }
}
});
- for (const staleExamId of staleExamIds) {
+ for (const retained of retainedRecoveryCleanups) {
+ const commitGuard = () => {
+ const current = this.examWindows && this.examWindows.get(retained.examId);
+ return this._isExamSessionRegistrationCurrent(
+ retained.examId,
+ retained.registration
+ ) && String(current && current.expectedSessionId || '').trim() !== retained.recoverySessionId;
+ };
+ try {
+ await this._discardActiveSessionsForExam(retained.examId, {
+ expectedSessionId: retained.recoverySessionId,
+ commitGuard
+ });
+ } catch (error) {
+ console.warn('[App] 清理复用窗口旧恢复会话失败:', retained.examId, error);
+ }
+ }
+ for (const stale of staleRegistrations) {
try {
- await this.cleanupExamSession(staleExamId);
+ await this.cleanupExamSession(stale.examId, {
+ expectedRegistration: stale.registration,
+ recoverySessionId: stale.recoverySessionId
+ });
} catch (error) {
- console.warn('[App] 清理复用窗口旧题目会话失败:', staleExamId, error);
+ console.warn('[App] 清理复用窗口旧题目会话失败:', stale.examId, error);
}
}
- return staleExamIds;
+ return staleRegistrations.map(stale => stale.examId);
},
- async cleanupExamSession(examId) {
+ async cleanupExamSession(examId, options = {}) {
+ const hasExpectedRegistration = Object.prototype.hasOwnProperty.call(options, 'expectedRegistration');
+ const expectedRegistration = hasExpectedRegistration ? options.expectedRegistration : null;
+ const requestedRecoverySessionId = Object.prototype.hasOwnProperty.call(options, 'recoverySessionId')
+ ? String(options.recoverySessionId || '').trim()
+ : String(expectedRegistration && expectedRegistration.expectedSessionId || '').trim();
+ const recoveryCleanupGuard = requestedRecoverySessionId
+ ? () => {
+ const current = this.examWindows && this.examWindows.get(examId);
+ return !current
+ || String(current.expectedSessionId || '').trim() !== requestedRecoverySessionId;
+ }
+ : null;
+ if (hasExpectedRegistration && !this._isExamSessionRegistrationCurrent(examId, expectedRegistration)) {
+ const current = this.examWindows && this.examWindows.get(examId);
+ // The map/handler now belong to another registration. The old recovery may
+ // still be removed by its exact session id, unless that id has been reused.
+ if (requestedRecoverySessionId
+ && (!current || String(current.expectedSessionId || '').trim() !== requestedRecoverySessionId)) {
+ await this._discardActiveSessionsForExam(examId, {
+ expectedSessionId: requestedRecoverySessionId,
+ commitGuard: recoveryCleanupGuard
+ });
+ }
+ return false;
+ }
+
+ const windowInfo = this.examWindows && this.examWindows.get(examId);
+ if (windowInfo && windowInfo.closeMonitor) {
+ try { clearInterval(windowInfo.closeMonitor); } catch (_) {}
+ windowInfo.closeMonitor = null;
+ }
+
// 清理窗口引用
if (this.examWindows && this.examWindows.has(examId)) {
this.examWindows.delete(examId);
@@ -10969,9 +15019,15 @@
}
// 清理活动会话
- const activeSessions = await storage.get('active_sessions', []);
- const updatedSessions = activeSessions.filter(session => session.examId !== examId);
- await storage.set('active_sessions', updatedSessions);
+ if (hasExpectedRegistration) {
+ await this._discardActiveSessionsForExam(examId, {
+ expectedSessionId: requestedRecoverySessionId,
+ commitGuard: recoveryCleanupGuard
+ });
+ } else {
+ await this._discardActiveSessionsForExam(examId);
+ }
+ return true;
},
/**
@@ -11153,7 +15209,7 @@
* 显示活动会话详情
*/
async showActiveSessionsDetails() {
- const activeSessions = await storage.get('active_sessions', []);
+ const activeSessions = await window.AppData.recovery.listActiveSessions();
const examIndex = await getActiveExamIndexSnapshot();
if (activeSessions.length === 0) {
@@ -11244,7 +15300,7 @@
* 关闭所有题目会话
*/
async closeAllExamSessions() {
- const activeSessions = await storage.get('active_sessions', []);
+ const activeSessions = await window.AppData.recovery.listActiveSessions();
activeSessions.forEach(session => {
this.closeExamSession(session.examId);
@@ -11375,29 +15431,15 @@
}
};
- function getActiveExamIndex() {
- try {
- if (typeof global.getExamIndexState === 'function') {
- const state = global.getExamIndexState();
- return Array.isArray(state) ? state : [];
- }
- } catch (_) { }
- return Array.isArray(global.examIndex) ? global.examIndex : null;
- }
-
function hasListeningEntries(index) {
return (Array.isArray(index) ? index : []).some((exam) => {
return exam && exam.type === 'listening';
});
}
- function hasActiveListeningLibrary() {
+ function hasActiveListeningLibrary(index) {
if (typeof global.hasActiveListeningLibrary === 'function') {
- return global.hasActiveListeningLibrary();
- }
- const index = getActiveExamIndex();
- if (index === null) {
- return true;
+ return global.hasActiveListeningLibrary(index);
}
return hasListeningEntries(index);
}
@@ -11427,7 +15469,7 @@
* 初始化控制器
* @param {string} containerId - 按钮容器的DOM ID
*/
- initialize(containerId = 'type-filter-buttons') {
+ initialize(containerId = 'type-filter-buttons', examIndex = []) {
this.buttonContainer = document.getElementById(containerId);
if (!this.buttonContainer) {
console.warn('[BrowseController] 按钮容器未找到:', containerId);
@@ -11435,10 +15477,10 @@
}
// 从全局状态恢复模式
- this.restoreMode();
+ this.restoreMode(examIndex);
// 渲染初始按钮
- this.renderFilterButtons();
+ this.renderFilterButtons(examIndex);
return true;
}
@@ -11447,7 +15489,7 @@
* 设置浏览模式
* @param {string} mode - 模式ID (default | frequency-p1 | frequency-p4)
*/
- setMode(mode) {
+ setMode(mode, examIndex = []) {
if (isReadingMemorizeBrowseMode()) {
mode = 'default';
}
@@ -11456,7 +15498,7 @@
return;
}
- const nextMode = isListeningMode(mode) && !hasActiveListeningLibrary()
+ const nextMode = isListeningMode(mode) && !hasActiveListeningLibrary(examIndex)
? 'default'
: mode;
this.currentMode = nextMode;
@@ -11466,10 +15508,10 @@
this.saveMode();
// 重新渲染按钮
- this.renderFilterButtons();
+ this.renderFilterButtons(examIndex);
// 应用筛选
- this.applyFilter(this.activeFilter);
+ this.applyFilter(this.activeFilter, examIndex);
}
/**
@@ -11483,13 +15525,13 @@
/**
* 渲染筛选按钮
*/
- renderFilterButtons() {
+ renderFilterButtons(examIndex = []) {
if (!this.buttonContainer) {
return;
}
const config = this.getCurrentModeConfig();
- const filters = this.getVisibleFilters(config);
+ const filters = this.getVisibleFilters(config, examIndex);
if (!filters.some((filter) => filter.id === this.activeFilter)) {
this.activeFilter = filters.length ? filters[0].id : 'all';
}
@@ -11521,8 +15563,13 @@
button.setAttribute('aria-pressed', filter.id === this.activeFilter ? 'true' : 'false');
// 绑定点击事件
- button.addEventListener('click', () => {
- this.handleFilterClick(filter.id);
+ button.addEventListener('click', async () => {
+ try {
+ const index = await global.resolveActiveLibraryIndex();
+ this.handleFilterClick(filter.id, index);
+ } catch (error) {
+ console.error('[BrowseController] 读取活动题库失败:', error);
+ }
});
this.buttonContainer.appendChild(button);
@@ -11534,16 +15581,16 @@
}
}
- getVisibleFilters(config) {
+ getVisibleFilters(config, examIndex = []) {
const normalized = config || this.getCurrentModeConfig();
const filters = Array.isArray(normalized.filters) ? normalized.filters : [];
if (isReadingMemorizeBrowseMode()) {
return BROWSE_MODES.default.filters.filter((filter) => filter.type === 'reading');
}
- if (normalized.id === 'default' && !hasActiveListeningLibrary()) {
+ if (normalized.id === 'default' && !hasActiveListeningLibrary(examIndex)) {
return filters.filter((filter) => filter.type !== 'listening');
}
- if (isListeningMode(normalized.id) && !hasActiveListeningLibrary()) {
+ if (isListeningMode(normalized.id) && !hasActiveListeningLibrary(examIndex)) {
return BROWSE_MODES.default.filters.filter((filter) => filter.type !== 'listening');
}
return filters.slice();
@@ -11553,14 +15600,14 @@
* 处理筛选按钮点击
* @param {string} filterId - 筛选器ID
*/
- handleFilterClick(filterId) {
+ handleFilterClick(filterId, examIndex = []) {
this.activeFilter = filterId;
// 更新按钮激活状态
this.updateButtonStates();
// 应用筛选
- this.applyFilter(filterId);
+ this.applyFilter(filterId, examIndex);
}
/**
@@ -11588,15 +15635,15 @@
* 应用筛选
* @param {string} filterId - 筛选器ID
*/
- applyFilter(filterId) {
+ applyFilter(filterId, examIndex = []) {
const config = this.getCurrentModeConfig();
if (config.filterLogic === 'type-based') {
// 默认模式:按类型筛选
- this.filterByType(filterId);
+ this.filterByType(filterId, examIndex);
} else if (config.filterLogic === 'folder-based') {
// 频率模式:按文件夹筛选
- this.filterByFolder(filterId);
+ this.filterByFolder(filterId, examIndex);
}
}
@@ -11604,10 +15651,10 @@
* 按类型筛选(默认模式)
* @param {string} type - 类型 (all | reading | listening)
*/
- filterByType(type) {
+ filterByType(type, examIndex = []) {
// 调用全局的 filterByType 函数
if (typeof global.filterByType === 'function') {
- global.filterByType(type);
+ global.filterByType(type, examIndex);
} else {
console.warn('[BrowseController] filterByType 函数未定义');
}
@@ -11617,7 +15664,7 @@
* 按文件夹筛选(频率模式)
* @param {string} filterId - 筛选器ID
*/
- filterByFolder(filterId) {
+ filterByFolder(filterId, examIndex = []) {
const config = this.getCurrentModeConfig();
const basePath = global.__browsePath || config.basePath || null;
const folders = config.folderMap[filterId];
@@ -11629,11 +15676,8 @@
return;
}
- // 获取题库索引
- const examIndex = this.getExamIndex();
-
// 筛选题目
- const filtered = examIndex.filter(exam => {
+ const filtered = (Array.isArray(examIndex) ? examIndex : []).filter(exam => {
if (!exam || !exam.path) {
return false;
}
@@ -11656,23 +15700,6 @@
// 显示筛选结果
this.displayFilteredExams(filtered);
}
-
-
-
- /**
- * 获取题库索引
- * @returns {Array} 题库数组
- */
- getExamIndex() {
- // 优先使用全局状态服务
- if (typeof global.getExamIndexState === 'function') {
- return global.getExamIndexState();
- }
-
- // 回退到全局变量
- return Array.isArray(global.examIndex) ? global.examIndex : [];
- }
-
/**
* 显示筛选后的题目
* @param {Array} exams - 题目数组
@@ -11710,11 +15737,11 @@
/**
* 从全局状态恢复模式
*/
- restoreMode() {
+ restoreMode(examIndex = []) {
try {
const savedMode = global.__browseFilterMode;
if (savedMode && BROWSE_MODES[savedMode]) {
- this.currentMode = isListeningMode(savedMode) && !hasActiveListeningLibrary()
+ this.currentMode = isListeningMode(savedMode) && !hasActiveListeningLibrary(examIndex)
? 'default'
: savedMode;
}
@@ -11726,8 +15753,8 @@
/**
* 重置为默认模式
*/
- resetToDefault() {
- this.setMode('default');
+ resetToDefault(examIndex = []) {
+ this.setMode('default', examIndex);
}
// ============================================================================
@@ -11850,10 +15877,8 @@
// 4. 调用 ExamActions.loadExamList 来执行真正的筛选和渲染
// 这确保了所有逻辑(包括频率模式、置顶等)都由 ExamActions 统一处理
- if (global.ExamActions && typeof global.ExamActions.loadExamList === 'function') {
- global.ExamActions.loadExamList();
- } else if (typeof global.loadExamList === 'function') {
- global.loadExamList();
+ if (typeof global.loadExamList === 'function') {
+ global.loadExamList(normalizedOptions.examIndex || null);
} else {
console.warn('[BrowseController] 无法加载题库列表: loadExamList 未定义');
}
@@ -11878,9 +15903,8 @@
global.BrowseController = BrowseController;
global.BROWSE_MODES = BROWSE_MODES;
global.refreshListeningAvailabilityUI = function refreshListeningAvailabilityUI(index) {
- const listeningAvailable = Array.isArray(index)
- ? hasListeningEntries(index)
- : hasActiveListeningLibrary();
+ const examIndex = Array.isArray(index) ? index : [];
+ const listeningAvailable = hasListeningEntries(examIndex);
const controller = global.browseController || null;
if (controller && isListeningMode(controller.currentMode) && !listeningAvailable) {
@@ -11901,7 +15925,7 @@
}
if (controller && controller.buttonContainer) {
- controller.renderFilterButtons();
+ controller.renderFilterButtons(examIndex);
} else {
const container = global.document && global.document.getElementById('type-filter-buttons');
const listeningButtons = container
@@ -12370,15 +16394,9 @@ class BrowseStateManager {
*/
initialize() {
console.log('[BrowseStateManager] 初始化浏览状态管理器');
-
- // 恢复保存的状态
- this.restorePersistentState();
-
// 设置事件监听器
this.setupEventListeners();
-
- // 初始化完成后通知订阅者
- this.notifySubscribers();
+ this.ready = this.restorePersistentState().finally(() => this.notifySubscribers());
}
/**
@@ -12533,7 +16551,7 @@ class BrowseStateManager {
/**
* 持久化状态
*/
- persistState() {
+ async persistState() {
try {
const dataToSave = {
currentFilter: this.currentFilter,
@@ -12543,7 +16561,7 @@ class BrowseStateManager {
timestamp: Date.now()
};
- localStorage.setItem('browse_state', JSON.stringify(dataToSave));
+ await window.AppData.preferences.patchBrowse({ stateManager: dataToSave });
console.log('[BrowseStateManager] 状态已持久化');
} catch (error) {
console.error('[BrowseStateManager] 持久化状态失败:', error);
@@ -12553,11 +16571,13 @@ class BrowseStateManager {
/**
* 恢复持久化的状态
*/
- restorePersistentState() {
+ async restorePersistentState() {
try {
- const savedData = localStorage.getItem('browse_state');
+ await window.AppData.ready;
+ const browse = await window.AppData.preferences.getBrowse();
+ const savedData = browse && browse.stateManager;
if (savedData) {
- const data = JSON.parse(savedData);
+ const data = savedData;
// 恢复基本状态
this.previousFilter = data.previousFilter || null;
@@ -13183,7 +17203,20 @@ window.BrowseStateManager = BrowseStateManager;
function compareAnswers(userAnswer, correctAnswer) {
const expected = splitAnswerTokens(correctAnswer);
- const actual = splitAnswerTokens(userAnswer);
+ let actual = splitAnswerTokens(userAnswer);
+
+ if (
+ expected.length === 1
+ && /^[A-Z]$/.test(expected[0])
+ && actual.length === 1
+ && !/^[A-Z]$/.test(actual[0])
+ && typeof userAnswer === 'string'
+ ) {
+ const labeledOption = userAnswer.trim().match(/^([A-Z])\s+\S/);
+ if (labeledOption) {
+ actual = [labeledOption[1]];
+ }
+ }
if (expected.length === 0 && actual.length === 0) {
return null;
@@ -13840,158 +17873,19 @@ window.BrowseStateManager = BrowseStateManager;
};
}
- function getAllExamIndexes(globalObj) {
- let readingIndex = null;
- if (globalObj && typeof globalObj.getReadingExamIndex === 'function') {
- try {
- readingIndex = globalObj.getReadingExamIndex();
- } catch (_) {
- readingIndex = null;
- }
- }
- const sources = [
- readingIndex,
- globalObj.__READING_EXAM_INDEX__,
- globalObj.examIndex,
- globalObj.readingExamIndex,
- globalObj.listeningExamIndex,
- globalObj.fullExamIndex,
- globalObj.practiceExamIndex
- ];
- return sources
- .filter(Array.isArray)
- .reduce((acc, list) => acc.concat(list), []);
- }
-
- function normalizeTitle(title) {
- return toStringKey(title)
- .toLowerCase()
- .replace(/[\s\-_\u3000]+/g, '')
- .replace(/[^\w\u4e00-\u9fa5]/g, '');
- }
-
- function findExamEntry(record, metadata, globalObj) {
- const indexes = getAllExamIndexes(globalObj);
- if (indexes.length === 0) {
- return null;
- }
-
- const candidateIds = [
- record && record.examId,
- record && record.originalExamId,
- record && record.derivedExamId,
- record && record.realData && record.realData.examId,
- metadata && metadata.examId,
- metadata && metadata.id
- ]
- .map(toStringKey)
- .filter(Boolean);
-
- // 1. 精确 ID 匹配
- if (candidateIds.length > 0) {
- const idLookup = new Map();
- indexes.forEach(item => {
- if (!item || typeof item !== 'object') {
- return;
- }
- const itemId = toStringKey(item.id);
- if (itemId) {
- idLookup.set(itemId.toLowerCase(), item);
- }
- });
-
- for (const id of candidateIds) {
- const normalizedId = id.toLowerCase();
- if (idLookup.has(normalizedId)) {
- return idLookup.get(normalizedId);
- }
- }
- }
-
- // 2. 通过 URL 路径匹配(针对全量题库)
- if (record && record.url) {
- const urlPath = record.url.toLowerCase();
- const match = indexes.find(item => {
- if (!item || !item.path) return false;
- const itemPath = item.path.toLowerCase();
- // 提取 URL 中的文件夹名称
- const urlParts = urlPath.split('/').filter(Boolean);
- const pathParts = itemPath.split('/').filter(Boolean);
-
- // 检查是否有共同的文件夹路径
- for (let i = 0; i < Math.min(urlParts.length, pathParts.length); i++) {
- if (urlParts[urlParts.length - 1 - i] === pathParts[pathParts.length - 1 - i]) {
- return true;
- }
- }
- return false;
- });
- if (match) {
- console.log('[AnswerComparisonUtils] 通过 URL 路径匹配到题目:', match.id, match.title);
- return match;
- }
+ function inferCategory(record, metadata, examEntry) {
+ if (metadata && metadata.category && metadata.category !== 'Unknown') {
+ return metadata.category;
}
- // 3. 精确标题匹配
- const candidateTitles = [
- metadata && metadata.examTitle,
- metadata && metadata.title,
- record && record.title,
- record && record.examTitle,
- record && record.realData && record.realData.title
- ]
- .map(normalizeTitle)
- .filter(Boolean);
-
- if (candidateTitles.length > 0) {
- const titleLookup = new Map();
- indexes.forEach(item => {
- if (!item || typeof item !== 'object') {
- return;
- }
- const itemTitle = normalizeTitle(item.title);
- if (itemTitle) {
- titleLookup.set(itemTitle, item);
- }
- });
-
- for (const title of candidateTitles) {
- if (titleLookup.has(title)) {
- return titleLookup.get(title);
- }
- }
-
- // 4. 模糊标题匹配(移除标签前缀后比较)
- for (const candidateTitle of candidateTitles) {
- const match = indexes.find(item => {
- if (!item || !item.title) return false;
- const itemTitle = normalizeTitle(item.title);
- // 移除标签前缀,如 "[听力全量-...] City Development" vs "City Development"
- const cleanCandidate = candidateTitle.replace(/^\[.*?\]\s*/, '');
- const cleanItem = itemTitle.replace(/^\[.*?\]\s*/, '');
- return cleanCandidate === cleanItem ||
- (cleanCandidate.length > 5 && cleanItem.includes(cleanCandidate)) ||
- (cleanItem.length > 5 && cleanCandidate.includes(cleanItem));
- });
- if (match) {
- console.log('[AnswerComparisonUtils] 通过模糊标题匹配到题目:', match.id, match.title);
- return match;
- }
- }
+ if (record && record.category && record.category !== 'Unknown') {
+ return record.category;
}
- return null;
- }
-
- function inferCategory(record, metadata, examEntry) {
if (examEntry && examEntry.category) {
return examEntry.category;
}
- if (metadata && metadata.category && metadata.category !== 'Unknown') {
- return metadata.category;
- }
-
const candidates = [
record && record.examId,
metadata && metadata.examId,
@@ -14016,7 +17910,7 @@ window.BrowseStateManager = BrowseStateManager;
return metadata && metadata.category ? metadata.category : 'Unknown';
}
- function enrichRecordMetadata(record) {
+ function enrichRecordMetadata(record, examEntry = null) {
if (!record || typeof record !== 'object') {
return {
category: 'Unknown',
@@ -14032,25 +17926,24 @@ window.BrowseStateManager = BrowseStateManager;
return metadata;
}
- const globalObj = global || {};
- const examEntry = findExamEntry(record, metadata, globalObj);
+ const resolvedExam = examEntry && typeof examEntry === 'object' ? examEntry : null;
- if (examEntry) {
- if (examEntry.title && !metadata.examTitle) {
- metadata.examTitle = examEntry.title;
+ if (resolvedExam) {
+ if (resolvedExam.title && !metadata.examTitle) {
+ metadata.examTitle = resolvedExam.title;
}
- if (examEntry.frequency && !metadata.frequency) {
- metadata.frequency = examEntry.frequency;
+ if (resolvedExam.frequency && !metadata.frequency) {
+ metadata.frequency = resolvedExam.frequency;
}
- if (examEntry.type && !metadata.type) {
- metadata.type = examEntry.type;
+ if (resolvedExam.type && !metadata.type) {
+ metadata.type = resolvedExam.type;
}
}
- metadata.category = inferCategory(record, metadata, examEntry);
+ metadata.category = inferCategory(record, metadata, resolvedExam);
if (!metadata.frequency) {
- if (examEntry && examEntry.frequency) {
- metadata.frequency = examEntry.frequency;
+ if (resolvedExam && resolvedExam.frequency) {
+ metadata.frequency = resolvedExam.frequency;
} else if (metadata.frequency == null) {
metadata.frequency = 'unknown';
}
@@ -14079,13 +17972,13 @@ window.BrowseStateManager = BrowseStateManager;
return metadata;
}
- function withEnrichedMetadata(record) {
+ function withEnrichedMetadata(record, examEntry = null) {
if (!record || typeof record !== 'object') {
return record;
}
const clone = Object.assign({}, record);
clone.metadata = Object.assign({}, record.metadata || {});
- enrichRecordMetadata(clone);
+ enrichRecordMetadata(clone, examEntry);
return clone;
}
@@ -14112,8 +18005,10 @@ window.BrowseStateManager = BrowseStateManager;
(function (global) {
'use strict';
- const BROWSE_VIEW_PREFERENCE_KEY = 'browse_view_preferences_v2';
let browsePreferencesCache = null;
+ let browsePreferencesReady = null;
+ let browsePreferenceWriteQueue = Promise.resolve();
+ const pendingBrowsePreferenceWrites = [];
let currentBrowseScrollElement = null;
let removeBrowseScrollListener = null;
let pendingBrowseAutoScroll = null;
@@ -14246,14 +18141,20 @@ window.BrowseStateManager = BrowseStateManager;
}
function loadBrowsePreferencesFromStorage() {
+ if (!browsePreferencesReady) {
+ browsePreferencesReady = Promise.resolve().then(async () => {
+ if (!global.AppData || !global.AppData.preferences) return;
+ await global.AppData.ready;
+ const parsed = await global.AppData.preferences.getBrowse();
+ const defaults = getDefaultBrowsePreferences();
+ const next = Object.assign({}, defaults, parsed || {});
+ if (!next.scrollPositions || typeof next.scrollPositions !== 'object') next.scrollPositions = {};
+ next.listAnchors = mergeBrowseAnchors({}, next.listAnchors);
+ browsePreferencesCache = next;
+ }).catch((error) => console.warn('[BrowsePreferences] 无法读取浏览偏好,使用默认值', error));
+ }
try {
- const raw = localStorage.getItem(BROWSE_VIEW_PREFERENCE_KEY);
- if (!raw) {
- return getDefaultBrowsePreferences();
- }
- const parsed = JSON.parse(raw);
- const defaults = getDefaultBrowsePreferences();
- const next = Object.assign({}, defaults, parsed || {});
+ const next = Object.assign({}, getDefaultBrowsePreferences(), browsePreferencesCache || {});
if (!next.scrollPositions || typeof next.scrollPositions !== 'object') {
next.scrollPositions = {};
}
@@ -14272,9 +18173,16 @@ window.BrowseStateManager = BrowseStateManager;
return browsePreferencesCache;
}
- function saveBrowseViewPreferences(partial = {}) {
- const current = getBrowseViewPreferences();
- const next = {
+ async function whenBrowseViewPreferencesReady() {
+ loadBrowsePreferencesFromStorage();
+ if (browsePreferencesReady) {
+ await browsePreferencesReady;
+ }
+ return getBrowseViewPreferences();
+ }
+
+ function mergeBrowsePreferences(current, partial = {}) {
+ return {
scrollPositions: Object.assign({}, current.scrollPositions, partial.scrollPositions || {}),
listAnchors: mergeBrowseAnchors(current.listAnchors, partial.listAnchors),
autoScrollEnabled: Object.prototype.hasOwnProperty.call(partial, 'autoScrollEnabled')
@@ -14284,15 +18192,39 @@ window.BrowseStateManager = BrowseStateManager;
? (partial.lastFilter || null)
: current.lastFilter
};
+ }
- try {
- localStorage.setItem(BROWSE_VIEW_PREFERENCE_KEY, JSON.stringify(next));
- browsePreferencesCache = next;
- } catch (error) {
- console.warn('[BrowsePreferences] 保存浏览偏好失败', error);
- browsePreferencesCache = next;
+ function saveBrowseViewPreferences(partial = {}) {
+ const request = { partial: Object.assign({}, partial) };
+ pendingBrowsePreferenceWrites.push(request);
+ const preview = pendingBrowsePreferenceWrites.reduce(
+ (current, pending) => mergeBrowsePreferences(current, pending.partial),
+ getBrowseViewPreferences()
+ );
+
+ if (!global.AppData || !global.AppData.preferences) {
+ pendingBrowsePreferenceWrites.splice(pendingBrowsePreferenceWrites.indexOf(request), 1);
+ console.warn('[BrowsePreferences] AppData.preferences 不可用,偏好未保存');
+ return preview;
}
- return browsePreferencesCache;
+
+ browsePreferenceWriteQueue = browsePreferenceWriteQueue.then(async () => {
+ await global.AppData.ready;
+ if (browsePreferencesReady) await browsePreferencesReady;
+ const next = mergeBrowsePreferences(getBrowseViewPreferences(), request.partial);
+ await global.AppData.preferences.patchBrowse(next);
+ browsePreferencesCache = next;
+ }).catch((error) => {
+ console.warn('[BrowsePreferences] 保存浏览偏好失败,保留上次已提交值', error);
+ }).finally(() => {
+ const index = pendingBrowsePreferenceWrites.indexOf(request);
+ if (index >= 0) pendingBrowsePreferenceWrites.splice(index, 1);
+ });
+ return preview;
+ }
+
+ function flushBrowsePreferenceWrites() {
+ return browsePreferenceWriteQueue.then(() => getBrowseViewPreferences());
}
function persistBrowseFilter(category, type) {
@@ -14534,20 +18466,20 @@ window.BrowseStateManager = BrowseStateManager;
};
}
- function findLastPracticeExamEntry(exams, category, type) {
+ function findLastPracticeExamEntry(exams, records, examIndex, category, type) {
const normalizedCategory = normalizeCategoryKey(category);
const normalizedType = normalizeExamType(type);
- const records = global.getPracticeRecordsState ? global.getPracticeRecordsState() : [];
- if (!Array.isArray(records) || records.length === 0) {
+ const recordSnapshot = Array.isArray(records) ? records : [];
+ if (recordSnapshot.length === 0) {
return null;
}
- const examIndex = global.getExamIndexState ? global.getExamIndexState() : [];
+ const indexSnapshot = Array.isArray(examIndex) ? examIndex : [];
let latest = null;
let latestTimestamp = Number.NEGATIVE_INFINITY;
- records.forEach((record) => {
- const info = resolveRecordExamInfo(record, examIndex);
+ recordSnapshot.forEach((record) => {
+ const info = resolveRecordExamInfo(record, indexSnapshot);
if (!info) {
return;
}
@@ -14699,7 +18631,7 @@ window.BrowseStateManager = BrowseStateManager;
return parts.join(' ');
}
- function setupBrowsePreferenceUI() {
+ async function setupBrowsePreferenceUI() {
const trigger = document.getElementById('browse-title-trigger');
const panel = document.getElementById('browse-preference-panel');
const checkbox = document.getElementById('browse-remember-position');
@@ -14708,7 +18640,7 @@ window.BrowseStateManager = BrowseStateManager;
return;
}
- const prefs = getBrowseViewPreferences();
+ const prefs = await whenBrowseViewPreferencesReady();
checkbox.checked = !!prefs.autoScrollEnabled;
updateBrowsePreferenceIndicator(prefs.autoScrollEnabled);
@@ -14769,18 +18701,18 @@ window.BrowseStateManager = BrowseStateManager;
});
}
- function handlePostExamListRender(exams, { category, type } = {}) {
+ async function handlePostExamListRender(exams, { category, type } = {}) {
const scrollEl = document.querySelector('#exam-list-container .exam-list');
if (!scrollEl) {
return;
}
+ const prefs = await whenBrowseViewPreferencesReady();
ensureBrowseScrollListener(scrollEl);
const normalizedCategory = normalizeCategoryKey(category || (global.getCurrentCategory ? global.getCurrentCategory() : 'all'));
const normalizedType = normalizeExamType(type || (global.getCurrentExamType ? global.getCurrentExamType() : 'all'));
const autoScrollContext = consumeBrowseAutoScroll(normalizedCategory, normalizedType);
- const prefs = getBrowseViewPreferences();
const applyScroll = () => {
const performFallback = () => {
@@ -14806,17 +18738,12 @@ window.BrowseStateManager = BrowseStateManager;
};
if (prefs.autoScrollEnabled && (normalizedCategory !== 'all' || normalizedType !== 'all')) {
- const entry = findLastPracticeExamEntry(exams, normalizedCategory, normalizedType);
- if (entry) {
- const retries = autoScrollContext ? 7 : 4;
- attemptScrollToEntry(entry, retries, performFallback);
- return;
- }
const anchor = getBrowseListAnchor(normalizedCategory, normalizedType);
if (anchor) {
const entryFromAnchor = findExamEntryByAnchor(exams, anchor);
if (entryFromAnchor) {
- attemptScrollToEntry(entryFromAnchor, 3, performFallback);
+ const retries = autoScrollContext ? 7 : 4;
+ attemptScrollToEntry(entryFromAnchor, retries, performFallback);
return;
}
}
@@ -14832,14 +18759,14 @@ window.BrowseStateManager = BrowseStateManager;
}
}
- function updateBrowseAnchorsFromRecords(records) {
+ function updateBrowseAnchorsFromRecords(records, examIndex) {
const list = Array.isArray(records) ? records : [];
- const examIndex = global.getExamIndexState ? global.getExamIndexState() : [];
+ const indexSnapshot = Array.isArray(examIndex) ? examIndex : [];
const updates = {};
const seenKeys = new Set();
list.forEach((record) => {
- const info = resolveRecordExamInfo(record, examIndex);
+ const info = resolveRecordExamInfo(record, indexSnapshot);
if (!info) {
return;
}
@@ -14895,7 +18822,9 @@ window.BrowseStateManager = BrowseStateManager;
global.normalizeExamType = normalizeExamType;
global.buildBrowseFilterKey = buildBrowseFilterKey;
global.getBrowseViewPreferences = getBrowseViewPreferences;
+ global.whenBrowseViewPreferencesReady = whenBrowseViewPreferencesReady;
global.saveBrowseViewPreferences = saveBrowseViewPreferences;
+ global.flushBrowsePreferenceWrites = flushBrowsePreferenceWrites;
global.persistBrowseFilter = persistBrowseFilter;
global.getPersistedBrowseFilter = getPersistedBrowseFilter;
global.updateBrowseAnchorsFromRecords = updateBrowseAnchorsFromRecords;
@@ -15141,7 +19070,11 @@ function ensureLegacyNavigation(options) {
syncOnNavigate: true,
onRepeatNavigate: function onRepeatNavigate(viewName) {
if (viewName === 'browse') {
- resetBrowseViewToAll();
+ if (window.ExamActions && typeof window.ExamActions.resetBrowseViewToAll === 'function') {
+ window.ExamActions.resetBrowseViewToAll();
+ } else if (typeof window.resetBrowseViewToAll === 'function') {
+ window.resetBrowseViewToAll();
+ }
}
},
onNavigate: function onNavigate(viewName) {
@@ -15180,12 +19113,6 @@ async function initializeLegacyComponents() {
setupBrowsePreferenceUI();
- // Setup UI Listeners
- const folderPicker = document.getElementById('folder-picker');
- if (folderPicker) {
- folderPicker.addEventListener('change', handleFolderSelection);
- }
-
// Initialize components
if (window.PDFHandler) {
pdfHandler = new PDFHandler();
@@ -15195,13 +19122,6 @@ async function initializeLegacyComponents() {
browseStateManager = new BrowseStateManager();
console.log('[System] 浏览状态管理器已初始化');
}
- if (window.DataIntegrityManager) {
- window.dataIntegrityManager = new DataIntegrityManager();
- console.log('[System] 数据完整性管理器已初始化');
- } else {
- console.info('[System] DataIntegrityManager 按需加载,跳过启动初始化');
- }
-
// 性能优化器已拆到 diagnostics-tools;浏览页保留无依赖降级路径。
if (window.PerformanceOptimizer) {
window.performanceOptimizer = new PerformanceOptimizer();
@@ -15210,93 +19130,47 @@ async function initializeLegacyComponents() {
console.info('[System] PerformanceOptimizer 按需加载,跳过启动初始化');
}
- // Clean up old cache and configurations for v1.1.0 upgrade (one-time only)
- let needsCleanup = false;
- try {
- needsCleanup = !localStorage.getItem('upgrade_v1_1_0_cleanup_done');
- } catch (error) {
- console.warn('[System] 检查升级标记失败,将继续执行清理流程', error);
- needsCleanup = true;
- }
-
- if (needsCleanup) {
- console.log('[System] 首次运行,执行升级清理...');
- try {
- await cleanupOldCache();
- } finally {
- try { localStorage.setItem('upgrade_v1_1_0_cleanup_done', '1'); } catch (_) { }
- }
- } else {
- console.log('[System] 升级清理已完成,跳过重复清理');
- }
-
// Load data and setup listeners
await loadLibraryInternal();
- startPracticeRecordsSyncInBackground('boot'); // 后台静默加载练习记录,避免阻塞首页
+ // 首页/题库浏览只使用摘要记录;完整 answers/realData 在进入练习历史页时再加载。
setupMessageListener(); // Listen for updates from child windows
- setupStorageSyncListener(); // Listen for storage changes from other tabs
-}
-
-// Clean up old cache and configurations
-async function cleanupOldCache() {
- try {
- console.log('[System] 正在清理旧缓存与配置...');
- await storage.remove('exam_index');
- await storage.remove('active_exam_index_key');
- await storage.set('exam_index_configurations', []);
- console.log('[System] 旧缓存清理完成');
- } catch (error) {
- console.warn('[System] 清理旧缓存时出错:', error);
- }
}
-
// --- Data Loading and Management ---
-// Phase 3: 练习记录同步 - 保留在 main.js(核心数据流,暂不迁移)
+// Practice history is read from AppData for each refresh. Only its signature is
+// retained as runtime UI state; record arrays never become a second authority.
+let lastPracticeRecordsSignature = null;
async function syncPracticeRecords(options = {}) {
- const { forceRender = false } = options || {};
- console.log('[System] 正在从存储中同步练习记录...');
- const previousRecords = typeof getPracticeRecordsState === 'function'
- ? getPracticeRecordsState()
- : (Array.isArray(window.practiceRecords) ? window.practiceRecords : []);
- let records = [];
- let loadError = null;
- try {
- records = await listCanonicalPracticeRecords();
- } catch (e) {
- console.warn('[System] 同步记录时发生错误:', e);
- loadError = e;
- records = Array.isArray(previousRecords) ? previousRecords.slice() : [];
- const errorMessage = String(e && e.message ? e.message : e).toLowerCase();
- if (errorMessage.includes('not ready') || errorMessage.includes('未就绪')) {
- setTimeout(() => {
- try {
- startPracticeRecordsSyncInBackground('api-ready-retry');
- } catch (_) { }
- }, 800);
- }
- if (Array.isArray(previousRecords) && previousRecords.length > 0) {
- console.warn('[System] canonical store 暂未就绪,保留当前内存中的练习记录,避免误清空视图。');
- }
- }
-
- if (loadError && (!Array.isArray(records) || records.length === 0) && (!Array.isArray(previousRecords) || previousRecords.length === 0)) {
- console.warn('[System] canonical store 暂未就绪,本次跳过练习记录视图刷新。');
- return;
- }
-
- // Normalize duration and percentages to avoid 0-second artifacts
+ const { forceRender = false, mode = 'summary' } = options || {};
+ const loadMode = mode === 'full' ? 'full' : 'summary';
+ let recordsUnchanged = false;
+ console.log(`[System] 正在从存储中同步练习记录... (mode=${loadMode})`);
+ let [records, insightRecords, examIndex] = await Promise.all([
+ listCanonicalPracticeRecordSummaries(),
+ window.AppData.practice.listInsights({ limit: 10 }),
+ resolveActiveExamIndex()
+ ]);
+ const insightsById = new Map((Array.isArray(insightRecords) ? insightRecords : [])
+ .filter((record) => record && record.id)
+ .map((record) => [String(record.id), record]));
+ records = (Array.isArray(records) ? records : []).map((record) =>
+ record && insightsById.has(String(record.id))
+ ? Object.assign({}, record, insightsById.get(String(record.id)))
+ : record);
+ if (loadMode === 'full') {
+ console.log('[System] mode=full 请求已限定为 light 视图刷新;完整记录请直接调用 AppData.practice.list()');
+ }
+
+ // Normalize duration and percentages to avoid 0-second artifacts(summary 无 realData/interactions)
try {
records = (records || []).map(r => {
- const rd = (r && r.realData) || {};
let duration = (typeof r.duration === 'number') ? r.duration : undefined;
if (!(Number.isFinite(duration) && duration > 0)) {
- const sInfo = r && (r.scoreInfo || rd.scoreInfo) || {};
+ const sInfo = r && r.scoreInfo || {};
const candidates = [
- r.duration, rd.duration, r.durationSeconds, r.duration_seconds,
+ r.duration, r.durationSeconds, r.duration_seconds,
r.elapsedSeconds, r.elapsed_seconds, r.timeSpent, r.time_spent,
- rd.durationSeconds, rd.elapsedSeconds, rd.timeSpent,
sInfo.duration, sInfo.timeSpent
];
for (const v of candidates) {
@@ -15306,26 +19180,16 @@ async function syncPracticeRecords(options = {}) {
if (!(Number.isFinite(duration) && duration > 0) && r && r.startTime && r.endTime) {
const s = new Date(r.startTime).getTime();
const e = new Date(r.endTime).getTime();
- if (Number.isFinite(s) && Number.isFinite(e) && e > s) {
- duration = Math.round((e - s) / 1000);
- }
- }
- if (!(Number.isFinite(duration) && duration > 0) && rd && Array.isArray(rd.interactions) && rd.interactions.length) {
- try {
- const ts = rd.interactions.map(x => x && Number(x.timestamp)).filter(n => Number.isFinite(n));
- if (ts.length) {
- const span = Math.max(...ts) - Math.min(...ts);
- if (Number.isFinite(span) && span > 0) duration = Math.floor(span / 1000);
- }
- } catch (_) { }
+ if (Number.isFinite(s) && Number.isFinite(e) && e > s) {
+ duration = Math.round((e - s) / 1000);
+ }
}
}
if (!Number.isFinite(duration)) duration = 0;
- // Coerce percentage/accuracy if only scoreInfo exists
- const sInfo = r && (r.scoreInfo || rd.scoreInfo) || {};
+ const sInfo = r && r.scoreInfo || {};
const correct = (typeof r.correctAnswers === 'number') ? r.correctAnswers : (typeof sInfo.correct === 'number' ? sInfo.correct : (typeof r.score === 'number' ? r.score : undefined));
- const total = (typeof r.totalQuestions === 'number') ? r.totalQuestions : (typeof sInfo.total === 'number' ? sInfo.total : (rd.answers ? Object.keys(rd.answers).length : undefined));
+ const total = (typeof r.totalQuestions === 'number') ? r.totalQuestions : (typeof sInfo.total === 'number' ? sInfo.total : undefined);
let accuracy = (typeof r.accuracy === 'number') ? r.accuracy : undefined;
let percentage = (typeof r.percentage === 'number') ? r.percentage : undefined;
if ((accuracy === undefined || percentage === undefined) && Number.isFinite(correct) && Number.isFinite(total) && total > 0) {
@@ -15338,138 +19202,71 @@ async function syncPracticeRecords(options = {}) {
});
} catch (e) { console.warn('[System] normalize durations failed:', e); }
- // 若数据未变则跳过 UI 刷新,避免无意义的列表重置
- // 使用轻量 listSummary 进行签名比对,无需反序列化+克隆完整记录数组
+ // Avoid resetting the list when the authoritative light projection is unchanged.
try {
- const prev = typeof getPracticeRecordsState === 'function'
- ? getPracticeRecordsState()
- : (Array.isArray(window.practiceRecords) ? window.practiceRecords : []);
const renderer = window.PracticeHistoryRenderer;
if (renderer && renderer.helpers && typeof renderer.helpers.computeRecordsSignature === 'function') {
- const prevSig = renderer.helpers.computeRecordsSignature(prev);
- // 若 forceRender 则跳过轻量查询,直接走完整加载
- if (!forceRender && window.PracticeRecordAPI && typeof window.PracticeRecordAPI.listSummary === 'function') {
- const summaries = await window.PracticeRecordAPI.listSummary();
- const nextSig = renderer.helpers.computeRecordsSignature(summaries);
- if (prevSig === nextSig) {
- console.log('[System] 练习记录未变化,跳过UI刷新');
- return;
- }
- } else {
- const nextSig = renderer.helpers.computeRecordsSignature(records);
- if (!forceRender && prevSig === nextSig) {
- console.log('[System] 练习记录未变化,跳过UI刷新');
- return;
- }
+ const nextSignature = renderer.helpers.computeRecordsSignature(records);
+ if (!forceRender && lastPracticeRecordsSignature === nextSignature) {
+ console.log('[System] 练习记录未变化,跳过UI刷新');
+ recordsUnchanged = true;
}
+ lastPracticeRecordsSignature = nextSignature;
}
} catch (_) { /* 保底不中断同步流程 */ }
- // 新增修复3D:确保全局变量和 app.state 都跟 canonical records 保持一致
- setPracticeRecordsState(records);
- try {
- if (window.app && window.app.state && window.app.state.practice) {
- const nextRecords = typeof getPracticeRecordsState === 'function'
- ? getPracticeRecordsState()
- : (Array.isArray(records) ? records : []);
- window.app.state.practice.records = Array.isArray(nextRecords) ? nextRecords.slice() : [];
- }
- } catch (error) {
- console.warn('[System] 同步练习记录到 App state 失败:', error);
- }
- refreshBrowseProgressFromRecords(records);
+ refreshBrowseProgressFromRecords(records, examIndex);
- console.log(`[System] ${records.length} 条练习记录已加载到内存。`);
- updatePracticeView();
+ console.log(`[System] 已从 AppData 加载 ${records.length} 条练习摘要。`);
+ if (!recordsUnchanged) {
+ updatePracticeView(records, examIndex);
+ }
+ return records;
}
let practiceRecordsLoadPromise = null;
-function ensurePracticeRecordsSync(trigger = 'default') {
+function ensurePracticeRecordsSync(trigger = 'default', options = {}) {
if (practiceRecordsLoadPromise) {
return practiceRecordsLoadPromise;
}
const loadTask = (async () => {
- await syncPracticeRecords();
- return true;
- })().catch((error) => {
- console.warn(`[System] 练习记录同步失败(${trigger}):`, error);
- return false;
- });
+ return syncPracticeRecords(Object.assign({ mode: 'summary' }, options || {}));
+ })();
practiceRecordsLoadPromise = loadTask.finally(() => {
practiceRecordsLoadPromise = null;
});
return practiceRecordsLoadPromise;
}
-function startPracticeRecordsSyncInBackground(trigger = 'default') {
- try {
- ensurePracticeRecordsSync(trigger);
- } catch (error) {
+function startPracticeRecordsSyncInBackground(trigger = 'default', options = {}) {
+ ensurePracticeRecordsSync(trigger, options).catch((error) => {
console.warn(`[System] 后台同步练习记录失败(${trigger}):`, error);
- }
+ });
}
async function listCanonicalPracticeRecords() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- const records = await window.PracticeRecordAPI.list();
- return Array.isArray(records) ? records : [];
- }
-
- throw new Error('统一练习记录 API 未就绪');
+ // 两个调用方(bulkDeleteRecords / deleteRecord)只用 id、title、date 做存在性校验与确认文案,
+ // light 投影已覆盖;删除本身走 AppData.practice.delete/deleteMany,不需要全量答题详情。
+ const records = await window.AppData.practice.list({ projection: 'light' });
+ return Array.isArray(records) ? records : [];
}
-async function replaceCanonicalPracticeRecords(records) {
- const finalRecords = Array.isArray(records) ? records : [];
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.replace === 'function') {
- await window.PracticeRecordAPI.replace(finalRecords, {
- maxRecords: (window.scoreStorage && window.scoreStorage.maxRecords) || 1000
- });
- return true;
- }
-
- throw new Error('统一练习记录 API 未就绪');
+async function listCanonicalPracticeRecordSummaries() {
+ const summaries = await window.AppData.practice.list({ projection: 'light' });
+ return Array.isArray(summaries) ? summaries : [];
}
-function cleanupLegacyPracticeRecordArtifacts() {
- // Unprefixed legacy keys only — never the active backend key.
- const legacyRawKeys = ['practice_records', 'old_prefix_practice_records'];
-
- try {
- legacyRawKeys.forEach((key) => {
- try { localStorage.removeItem(key); } catch (_) { }
- try { sessionStorage.removeItem(key); } catch (_) { }
- });
- } catch (error) {
- console.warn('[System] 清理 legacy 练习记录影子键失败:', error);
- }
-
- const storage = window.storage;
- const shadowKey = storage && typeof storage.getKey === 'function'
- ? storage.getKey('practice_records')
- : null;
- if (!shadowKey) {
- return;
+async function resolveActiveExamIndex() {
+ if (typeof window.resolveActiveLibraryIndex === 'function') {
+ const index = await window.resolveActiveLibraryIndex();
+ return Array.isArray(index) ? index : [];
}
-
- // When IndexedDB is blocked/unavailable, writePersistentValue stores canonical
- // practice_records under exam_system_practice_records in localStorage/sessionStorage.
- // Removing that key after replace/delete would wipe the just-persisted history.
- const mode = storage && storage.mode;
- const usesWebStorageBackend = mode === 'localStorage' || mode === 'sessionStorage';
- if (usesWebStorageBackend || storage.indexedDBBlocked || !storage.indexedDB) {
- return;
+ const manager = await ensureLibraryManagerReady();
+ if (manager && typeof manager.resolveActiveIndex === 'function') {
+ const index = await manager.resolveActiveIndex();
+ return Array.isArray(index) ? index : [];
}
-
- try { localStorage.removeItem(shadowKey); } catch (_) { }
- try { sessionStorage.removeItem(shadowKey); } catch (_) { }
-}
-
-async function persistPracticeRecordsAndRefresh(records, trigger = 'manual-update') {
- const finalRecords = Array.isArray(records) ? records : [];
- await replaceCanonicalPracticeRecords(finalRecords);
- cleanupLegacyPracticeRecordArtifacts();
- await syncPracticeRecords({ forceRender: true });
- return getPracticeRecordsState();
+ throw new Error('LibraryManager.resolveActiveIndex is unavailable');
}
const completionNoticeState = {
@@ -15532,6 +19329,30 @@ function extractCompletionSessionId(envelope) {
return null;
}
+// fallbackExamSessions 是纯内存 Map(js/app.js:50),主页刷新后会话映射即丢失。
+// 完成消息本身携带 examId(unifiedReadingPage.buildEnvelope / practicePageEnhancer.buildResultsPayload /
+// listeningRecordBridge.buildBridgePayload 都会写入),据此仍可走同一条持久化路径。
+function resolveCompletionExamId(envelope, payload) {
+ const sources = [payload, envelope, envelope && envelope.data];
+ for (const source of sources) {
+ if (!source || typeof source !== 'object') {
+ continue;
+ }
+ const candidates = [
+ source.examId,
+ source.derivedExamId,
+ source.metadata && typeof source.metadata === 'object' ? source.metadata.examId : null
+ ];
+ for (const candidate of candidates) {
+ const normalized = candidate == null ? '' : String(candidate).trim();
+ if (normalized) {
+ return normalized;
+ }
+ }
+ }
+ return null;
+}
+
function shouldAnnounceCompletion(sessionId) {
const now = Date.now();
if (sessionId && completionNoticeState.lastSessionId === sessionId) {
@@ -15663,6 +19484,17 @@ if (typeof window !== 'undefined') {
}
function setupMessageListener() {
+ const resolveFallbackMessageOrigin = () => {
+ const location = window.location || {};
+ const rawOrigin = typeof location.origin === 'string' ? location.origin : '';
+ const isOpaqueFile = location.protocol === 'file:'
+ || rawOrigin === 'null'
+ || rawOrigin === 'file://'
+ || rawOrigin.startsWith('file:');
+ return isOpaqueFile
+ ? { declaredOrigin: 'null', targetOrigin: '*' }
+ : { declaredOrigin: rawOrigin, targetOrigin: rawOrigin };
+ };
const findFallbackSessionByWindow = (sourceWindow) => {
if (!sourceWindow || !window.fallbackExamSessions || typeof fallbackExamSessions.entries !== 'function') {
return null;
@@ -15681,30 +19513,123 @@ function setupMessageListener() {
if (!entry || !entry.rec || !entry.rec.win || entry.rec.win.closed) {
return;
}
- const payload = entry.rec.initPayload || {
+ const messageOrigin = resolveFallbackMessageOrigin();
+ const targetOrigin = messageOrigin.targetOrigin;
+ if (!targetOrigin) return;
+ if (!entry.rec.windowSessionToken) {
+ const cryptoApi = window.crypto;
+ if (!cryptoApi || typeof cryptoApi.getRandomValues !== 'function') return;
+ const bytes = new Uint8Array(24);
+ cryptoApi.getRandomValues(bytes);
+ entry.rec.windowSessionToken = Array.from(bytes)
+ .map(byte => byte.toString(16).padStart(2, '0'))
+ .join('');
+ }
+ const payload = Object.assign({}, entry.rec.initPayload || {
examId: entry.rec.examId,
- parentOrigin: window.location.origin,
+ parentOrigin: messageOrigin.declaredOrigin,
sessionId: entry.rec.sessionId || entry.sid
- };
+ }, {
+ parentOrigin: messageOrigin.declaredOrigin,
+ windowSessionToken: entry.rec.windowSessionToken
+ });
+ entry.rec.initPayload = payload;
try {
- entry.rec.win.postMessage({ type: 'INIT_SESSION', data: payload }, '*');
- entry.rec.win.postMessage({ type: 'init_exam_session', data: payload }, '*');
+ entry.rec.win.postMessage({ type: 'INIT_SESSION', data: payload, source: 'exam_host' }, targetOrigin);
+ entry.rec.win.postMessage({ type: 'init_exam_session', data: payload, source: 'exam_host' }, targetOrigin);
} catch (_) { }
};
- window.addEventListener('message', (event) => {
- // 更兼容的安全检查:允许同源或file协议下的子窗口
+ const sendFallbackSubmitOutcome = (rec, payload, succeeded, errorCode = '') => {
+ const submissionId = payload && payload.submissionId != null ? String(payload.submissionId).trim() : '';
+ const sessionId = payload && payload.sessionId != null ? String(payload.sessionId).trim() : '';
+ if (!rec || !rec.win || rec.win.closed || !submissionId || !sessionId) return false;
+ const targetOrigin = resolveFallbackMessageOrigin().targetOrigin;
+ if (!targetOrigin || !rec.windowSessionToken) return false;
try {
- if (event.origin && event.origin !== 'null' && event.origin !== window.location.origin) {
- return;
- }
- } catch (_) { }
+ rec.win.postMessage({
+ type: succeeded ? 'PRACTICE_SUBMIT_ACK' : 'PRACTICE_SUBMIT_FAILED',
+ data: {
+ examId: payload.examId || rec.examId || null,
+ sessionId,
+ suiteSessionId: payload.suiteSessionId || null,
+ submissionId,
+ errorCode: succeeded ? null : (errorCode || 'save_failed'),
+ windowSessionToken: rec.windowSessionToken
+ },
+ source: 'exam_host',
+ timestamp: Date.now()
+ }, targetOrigin);
+ return true;
+ } catch (_) {
+ return false;
+ }
+ };
+
+ const sendFallbackVocabOutcome = (rec, payload, succeeded, errorCode = '') => {
+ const requestId = payload && payload.requestId != null ? String(payload.requestId).trim() : '';
+ const sessionId = payload && payload.sessionId != null
+ ? String(payload.sessionId).trim()
+ : String(rec && rec.sessionId || '');
+ if (!rec || !rec.win || rec.win.closed || !requestId || !sessionId || !rec.windowSessionToken) return false;
+ const targetOrigin = resolveFallbackMessageOrigin().targetOrigin;
+ if (!targetOrigin) return false;
+ try {
+ rec.win.postMessage({
+ type: succeeded ? 'VOCAB_HIGHLIGHT_SAVE_ACK' : 'VOCAB_HIGHLIGHT_SAVE_FAILED',
+ data: {
+ requestId,
+ examId: payload.examId || rec.examId || null,
+ sessionId,
+ errorCode: succeeded ? null : (errorCode || 'save_failed'),
+ windowSessionToken: rec.windowSessionToken
+ },
+ source: 'exam_host',
+ timestamp: Date.now()
+ }, targetOrigin);
+ return true;
+ } catch (_) {
+ return false;
+ }
+ };
+
+ const verifyFallbackPracticeCompletionRecord = async (record) => {
+ if (!record || typeof record !== 'object' || !record.id || !record.examId || !record.sessionId) {
+ return null;
+ }
+ if (!window.AppData || !window.AppData.practice || typeof window.AppData.practice.get !== 'function') {
+ return null;
+ }
+ const persisted = await window.AppData.practice.get(String(record.id), { projection: 'light' });
+ if (!persisted || typeof persisted !== 'object') {
+ return null;
+ }
+ return String(persisted.id || '') === String(record.id)
+ && String(persisted.examId || '') === String(record.examId)
+ && String(persisted.sessionId || '') === String(record.sessionId)
+ ? persisted
+ : null;
+ };
+ window.addEventListener('message', (event) => {
const data = event.data || {};
const type = data.type;
+ const payload = data && typeof data.data === 'object' ? data.data : data;
+ const matched = findFallbackSessionByWindow(event.source);
+ if (!matched || !matched.rec) return;
+ const isLocalFile = window.location && window.location.protocol === 'file:';
+ if (isLocalFile ? event.origin !== 'null' : event.origin !== window.location.origin) return;
+ const allowedSources = new Set(['practice_page', 'inline_collector', 'listening_record_bridge', 'suite_placeholder']);
+ if (!allowedSources.has(data.source || payload.source)) return;
+ const permitsPreInit = type === 'REQUEST_INIT'
+ || (type === 'SESSION_READY' && payload.initialized !== true);
+ if (!permitsPreInit && (
+ !matched.rec.windowSessionToken
+ || payload.windowSessionToken !== matched.rec.windowSessionToken
+ )) {
+ return;
+ }
if (type === 'SESSION_READY') {
- const payload = data && typeof data.data === 'object' ? data.data : data;
- const matched = findFallbackSessionByWindow(event.source);
if (payload && payload.initialized === false) {
sendFallbackInit(matched);
return;
@@ -15718,71 +19643,99 @@ function setupMessageListener() {
}
} catch (_) { }
} else if (type === 'REQUEST_INIT') {
- sendFallbackInit(findFallbackSessionByWindow(event.source));
+ sendFallbackInit(matched);
} else if (type === 'VOCAB_HIGHLIGHT_SAVE') {
const payload = data.data && typeof data.data === 'object' ? data.data : data;
- saveReadingHighlightVocab(payload).catch((error) => {
+ const requestId = payload && payload.requestId != null ? String(payload.requestId).trim() : '';
+ if (!requestId) return;
+ saveReadingHighlightVocab(payload).then((saved) => {
+ sendFallbackVocabOutcome(matched.rec, payload, Boolean(saved), saved ? '' : 'save_failed');
+ }).catch((error) => {
console.warn('[VocabStore] 阅读高亮生词保存异常:', error);
+ sendFallbackVocabOutcome(matched.rec, payload, false, 'save_failed');
});
} else if (type === 'PRACTICE_COMPLETE' || type === 'practice_completed') {
const payload = extractCompletionPayload(data) || {};
const sessionId = extractCompletionSessionId(data);
- const matchedByWindow = findFallbackSessionByWindow(event.source);
+ const matchedByWindow = matched;
const rec = sessionId ? (fallbackExamSessions.get(sessionId) || (matchedByWindow && matchedByWindow.rec)) : (matchedByWindow && matchedByWindow.rec);
const recSessionId = rec && (rec.sessionId || (matchedByWindow && matchedByWindow.sid) || sessionId);
if (recSessionId && payload && typeof payload === 'object') {
payload.sessionId = recSessionId;
}
+ if (!payload.submissionId || !recSessionId) return;
+ const receiptKey = payload.submissionId && recSessionId
+ ? `${recSessionId}:${String(payload.submissionId)}`
+ : '';
+ if (rec && receiptKey && rec.practiceSubmitReceipt === receiptKey) {
+ sendFallbackSubmitOutcome(rec, payload, true);
+ return;
+ }
const shouldNotify = shouldAnnounceCompletion(recSessionId || sessionId);
- if (rec) {
- console.log('[System] 收到练习完成,保存 canonical 记录');
- const cleanupAfterCompletion = () => {
- try { if (rec && rec.timer) clearInterval(rec.timer); } catch (_) { }
- try { fallbackExamSessions.delete(recSessionId || sessionId); } catch (_) { }
- };
- savePracticeCompletionRecord(rec.examId, payload).then(
- () => {
- // 保存成功:提示完成、展示摘要、同步记录。
- cleanupAfterCompletion();
- if (shouldNotify) {
- showMessage('练习已完成,正在更新记录...', 'success');
- showCompletionSummary(payload);
- }
- setTimeout(syncPracticeRecords, 300);
- },
- (saveError) => {
- // 保存失败:仍清理 timer/session,但不展示“已完成”成功横幅与摘要,
- // 避免在记录未落库时误导用户;同步一次以反映真实状态。
- console.error('[System] 练习完成记录保存失败:', saveError);
- cleanupAfterCompletion();
- if (shouldNotify) {
- showMessage('练习已完成,但记录保存失败,请重试或检查数据。', 'error');
- }
- setTimeout(syncPracticeRecords, 300);
+ const cleanupAfterCompletion = () => {
+ try { if (rec && rec.timer) clearInterval(rec.timer); } catch (_) { }
+ if (rec && receiptKey) {
+ try { if (rec.submitCleanupTimer) clearTimeout(rec.submitCleanupTimer); } catch (_) { }
+ rec.submitCleanupTimer = setTimeout(() => {
+ try { fallbackExamSessions.delete(recSessionId || sessionId); } catch (_) { }
+ }, 120000);
+ if (rec.submitCleanupTimer && typeof rec.submitCleanupTimer.unref === 'function') {
+ rec.submitCleanupTimer.unref();
}
- );
- } else {
- console.log('[System] 收到练习完成消息,正在同步记录...');
+ return;
+ }
+ try { fallbackExamSessions.delete(recSessionId || sessionId); } catch (_) { }
+ };
+ const onCompletionSaved = async (savedRecord) => {
+ const persistedRecord = await verifyFallbackPracticeCompletionRecord(savedRecord);
+ if (!persistedRecord) {
+ throw new Error('canonical_completion_readback_failed');
+ }
+ if (rec && receiptKey) rec.practiceSubmitReceipt = receiptKey;
+ sendFallbackSubmitOutcome(rec, payload, true);
+ // 保存成功:提示完成、展示摘要、同步记录。
+ cleanupAfterCompletion();
if (shouldNotify) {
showMessage('练习已完成,正在更新记录...', 'success');
showCompletionSummary(payload);
}
- setTimeout(syncPracticeRecords, 300);
+ setTimeout(() => ensurePracticeRecordsSync('completion-saved'), 300);
+ };
+ const onCompletionSaveFailed = (saveError) => {
+ sendFallbackSubmitOutcome(rec, payload, false, 'save_failed');
+ // 保存失败:仍清理 timer/session,但不展示“已完成”成功横幅与摘要,
+ // 避免在记录未落库时误导用户;同步一次以反映真实状态。
+ console.error('[System] 练习完成记录保存失败:', saveError);
+ cleanupAfterCompletion();
+ if (shouldNotify) {
+ showMessage('练习已完成,但记录保存失败,请重试或检查数据。', 'error');
+ }
+ setTimeout(() => ensurePracticeRecordsSync('completion-save-failed'), 300);
+ };
+ if (rec) {
+ console.log('[System] 收到练习完成,保存 canonical 记录');
+ savePracticeCompletionRecord(rec.examId, payload).then(onCompletionSaved).catch(onCompletionSaveFailed);
+ } else {
+ // 会话映射缺失(例如主页刷新后 fallbackExamSessions 已被清空)。此前这里只做只读同步,
+ // 记录一个字都不写却提示“练习已完成”。改为用消息自带的 examId 走同一条持久化路径。
+ const payloadExamId = resolveCompletionExamId(data, payload);
+ if (payloadExamId) {
+ console.log('[System] 会话映射缺失,改用消息自带 examId 保存 canonical 记录:', payloadExamId);
+ savePracticeCompletionRecord(payloadExamId, payload).then(onCompletionSaved).catch(onCompletionSaveFailed);
+ } else {
+ // 连 examId 都没有就无法归属到任何题目,必须明确报错,绝不能报成功。
+ console.error('[System] 练习完成消息缺少 examId,无法保存记录');
+ sendFallbackSubmitOutcome(rec, payload, false, 'missing_exam_id');
+ if (shouldNotify) {
+ showMessage('练习已完成,但记录保存失败:缺少题目标识,无法归档本次练习。', 'error');
+ }
+ setTimeout(() => ensurePracticeRecordsSync('completion-missing-exam-id'), 300);
+ }
}
}
});
}
-function setupStorageSyncListener() {
- window.addEventListener('storage-sync', (event) => {
- console.log('[System] 收到存储同步事件,正在更新练习记录...', event.detail);
- //可以选择性地只更新受影响的key,但为了简单起见,我们直接同步所有记录
- // if (event.detail && event.detail.key === 'practice_records') {
- syncPracticeRecords();
- // }
- });
-}
-
function normalizeFallbackAnswerValue(value) {
if (value === null || value === undefined) {
return '';
@@ -15962,9 +19915,9 @@ async function saveFallbackSpellingErrors(examId, realData, exam = {}) {
}
}
-function findExamForCompletion(examId, realData = {}) {
- const list = typeof getExamIndexState === 'function' ? getExamIndexState() : [];
- let exam = Array.isArray(list) ? (list.find(e => e.id === examId) || {}) : {};
+function findExamForCompletion(examId, realData = {}, examIndex = []) {
+ const list = Array.isArray(examIndex) ? examIndex : [];
+ let exam = list.find(e => e.id === examId) || {};
if (exam.id || !realData) {
return exam;
@@ -16052,31 +20005,45 @@ async function savePracticeCompletionRecord(examId, realData) {
return null;
}
- const api = window.PracticeRecordAPI;
- if (!api || typeof api.saveCompletion !== 'function') {
- throw new Error('统一练习记录 API 未就绪');
- }
- const exam = findExamForCompletion(examId, realData);
+ const examIndex = await resolveActiveExamIndex();
+ const exam = findExamForCompletion(examId, realData, examIndex);
const category = resolveCompletionCategory(exam, realData);
- const record = await api.saveCompletion(realData, {
- examId,
- examEntry: exam,
- metadata: {
+ // 启动时捕获的题库配置 ID:优先取 PRACTICE_COMPLETE 消息或 realData 已显式透传的值,
+ // 否则显式写入 null(保留 key),让 AppData provenance 不再回退到当前激活题库,
+ // 避免用户在考试过程中切换题库导致记录来源不一致。
+ const launchLibraryConfigurationId = (realData && realData.libraryConfigurationId != null
+ && realData.libraryConfigurationId !== '')
+ ? realData.libraryConfigurationId
+ : (realData && realData.metadata && realData.metadata.libraryConfigurationId != null
+ && realData.metadata.libraryConfigurationId !== '')
+ ? realData.metadata.libraryConfigurationId
+ : null;
+ const receipt = await window.AppData.practice.completeAttempt({
+ record: Object.assign({}, realData, {
+ examId,
+ title: realData.title || exam.title || '',
+ category,
+ frequency: exam.frequency || realData.frequency || 'unknown',
+ type: exam.type || realData.type || null,
+ metadata: Object.assign({}, realData.metadata || {}, {
examId,
examTitle: exam.title || realData.title || '',
category,
frequency: exam.frequency || realData.frequency || 'unknown',
- type: exam.type || realData.type || null
- }
- }, exam, {
- currentVersion: (window.scoreStorage && window.scoreStorage.currentVersion) || '0.6.2-fix',
- maxRecords: (window.scoreStorage && window.scoreStorage.maxRecords) || 1000,
- updateStats: true
+ type: exam.type || realData.type || null,
+ libraryConfigurationId: launchLibraryConfigurationId
+ })
+ }),
+ operationId: realData.operationId
+ || realData.messageId
+ || (realData.submissionId
+ ? `practice-complete:${examId}:${realData.sessionId || 'session'}:${realData.submissionId}`
+ : undefined)
});
await saveFallbackSpellingErrors(examId, realData, exam);
console.log('[PracticeRecord] 练习完成数据已保存到 canonical store');
- return record;
+ return receipt.record;
} catch (e) {
console.error('[PracticeRecord] 保存练习记录失败:', e);
throw e;
@@ -16145,14 +20112,14 @@ function getOverviewView() {
return overviewViewInstance;
}
-function updateOverview() {
+function updateOverview(examIndex = []) {
const categoryContainer = document.getElementById('category-overview');
if (!categoryContainer) {
console.warn('[Overview] 找不到 category-overview 容器');
return;
}
- const currentExamIndex = getExamIndexState();
+ const currentExamIndex = Array.isArray(examIndex) ? examIndex : [];
const statsService = window.AppServices && window.AppServices.overviewStats;
const stats = statsService ?
statsService.calculate(currentExamIndex) :
@@ -16578,10 +20545,35 @@ function recordMatchesExamType(record, targetType, examIndex) {
return true;
}
+// 练习记录渲染前的来源过滤。判定本身不在这里实现,而是复用
+// js/data/practiceRecordSource.js(与 practice.stats / achievements.progress 投影器同源),
+// 因为“列表看不见但计入统计”的 bug 正是由两处各写一套判定造成的。
+//
+// 用 filterRecordsForHistoryView 而不是 filterRealPracticeRecords:两者对"真实记录"的
+// 判定完全相同,前者额外放行新手引导显式登记的演示记录 id(引导需要用户看见那一行)。
+// 该例外只存在于视图层,投影器读不到,因此统计与成就仍严格排除演示数据。
+function filterRealPracticeRecordsForView(records) {
+ const list = Array.isArray(records) ? records : [];
+ const classifier = window.PracticeRecordSource;
+ if (!classifier || typeof classifier.filterRecordsForHistoryView !== 'function') {
+ // core-foundation 里的 appData.js 缺少该模块会直接抛错、应用根本起不来,
+ // 所以走到这里只能是加载顺序被破坏。此时绝不本地复刻判定:显式报错并保留全部记录,
+ // 宁可多显示演示记录,也不能重演"真实记录被吃掉、练习记录页整页空白"。
+ console.error('[PracticeHistory] PracticeRecordSource 未加载,已跳过演示记录过滤(判定必须与统计/成就同源)');
+ return list;
+ }
+ return classifier.filterRecordsForHistoryView(list);
+}
+
// Phase 3: 练习记录视图更新 - 保留在 main.js(依赖多个组件,暂不迁移)
-function updatePracticeView() {
- const rawRecords = getPracticeRecordsState();
- const records = rawRecords.filter((record) => record && (record.dataSource === 'real' || record.dataSource === undefined));
+function updatePracticeView(recordsSnapshot = [], examIndexSnapshot = []) {
+ const rawRecords = Array.isArray(recordsSnapshot) ? recordsSnapshot : [];
+ const examIndex = Array.isArray(examIndexSnapshot) ? examIndexSnapshot : [];
+ // 排除演示/种子记录。判定必须与 practice.stats / achievements.progress 两个投影器
+ // 完全一致,否则会重演“演示记录在列表里看不见,却计入成绩统计和成就解锁”。
+ // 唯一权威定义在 js/data/practiceRecordSource.js(含“dataSource 缺失即真实记录”,
+ // 该语义曾因被收窄导致练习记录页整页空白,不得回退)。
+ const records = filterRealPracticeRecordsForView(rawRecords);
const stats = window.PracticeStats;
const summary = stats && typeof stats.calculateSummary === 'function'
@@ -16610,10 +20602,9 @@ function updatePracticeView() {
const examType = getCurrentExamType();
if (examType !== 'all') {
if (stats && typeof stats.filterByExamType === 'function') {
- recordsToShow = stats.filterByExamType(recordsToShow, getExamIndexState(), examType);
+ recordsToShow = stats.filterByExamType(recordsToShow, examIndex, examType);
} else {
- const examIndexSnapshot = getExamIndexState();
- recordsToShow = recordsToShow.filter((record) => recordMatchesExamType(record, examType, examIndexSnapshot));
+ recordsToShow = recordsToShow.filter((record) => recordMatchesExamType(record, examType, examIndex));
}
}
@@ -16645,7 +20636,7 @@ function updatePracticeView() {
const priorityRenderer = ensurePracticePriorityRenderer();
if (priorityRenderer && typeof priorityRenderer.update === 'function') {
- priorityRenderer.update(recordsForInsights, getExamIndexState(), { examType });
+ priorityRenderer.update(recordsForInsights, examIndex, { examType });
}
// --- 4. Render history list ---
@@ -16677,7 +20668,7 @@ function searchPracticeHistory(query) {
if (clearButton) {
clearButton.hidden = window.__practiceHistoryQuery.length === 0;
}
- updatePracticeView();
+ startPracticeRecordsSyncInBackground('history-search', { forceRender: true });
}
function clearPracticeHistorySearch() {
@@ -16691,20 +20682,20 @@ function clearPracticeHistorySearch() {
searchPracticeHistory('');
}
-function refreshBrowseProgressFromRecords(recordsOverride = null) {
+function refreshBrowseProgressFromRecords(records, examIndex) {
try {
- const records = Array.isArray(recordsOverride)
- ? recordsOverride
- : (typeof getPracticeRecordsState === 'function'
- ? getPracticeRecordsState()
- : (Array.isArray(window.practiceRecords) ? window.practiceRecords : []));
+ const recordSnapshot = Array.isArray(records) ? records : [];
+ const indexSnapshot = Array.isArray(examIndex) ? examIndex : [];
if (typeof updateBrowseAnchorsFromRecords === 'function') {
- updateBrowseAnchorsFromRecords(records);
+ updateBrowseAnchorsFromRecords(recordSnapshot, indexSnapshot);
+ }
+ if (typeof rebuildBrowseCompletionIndex === 'function') {
+ rebuildBrowseCompletionIndex(recordSnapshot);
}
const browseView = document.getElementById('browse-view');
const isBrowseActive = browseView && browseView.classList.contains('active');
if (isBrowseActive && typeof loadExamList === 'function') {
- loadExamList();
+ loadExamList(indexSnapshot);
}
} catch (error) {
console.warn('[Browse] 刷新浏览进度失败:', error);
@@ -16717,28 +20708,11 @@ function ensurePracticeSessionSyncListener() {
return;
}
practiceSessionEventBound = true;
- document.addEventListener('practiceSessionCompleted', (event) => {
- try {
- const detail = event && event.detail ? event.detail : {};
- let record = detail.practiceRecord;
- if (record && typeof record === 'object') {
- record = enrichPracticeRecordForUI(record);
- const current = getPracticeRecordsState();
- const filtered = Array.isArray(current)
- ? current.filter((item) => item && item.id !== record.id)
- : [];
- setPracticeRecordsState([record, ...filtered]);
- updatePracticeView();
- refreshBrowseProgressFromRecords([record, ...filtered]);
- }
- } catch (syncError) {
- console.warn('[PracticeView] practiceSessionCompleted 事件处理失败:', syncError);
- } finally {
- // 仍然执行一次全面同步,确保 ScoreStorage/StorageRepo 状态一致
- setTimeout(() => {
- try { syncPracticeRecords(); } catch (_) { }
- }, 200);
- }
+ document.addEventListener('practiceSessionCompleted', () => {
+ startPracticeRecordsSyncInBackground('session-completed', {
+ mode: 'summary',
+ forceRender: true
+ });
});
}
@@ -16855,10 +20829,6 @@ function browseCategory(category, type = 'reading', filterMode = null, path = nu
try {
window.app.browseCategory(category, type, filterMode, path);
console.log('[browseCategory] Called app.browseCategory with filterMode:', filterMode);
- // 常规模式仍需刷新题库;频率模式由 browseController 接管
- if (!filterMode) {
- setTimeout(() => loadExamList(), 100);
- }
return;
} catch (error) {
console.warn('[browseCategory] window.app.browseCategory 调用失败,使用降级路径:', error);
@@ -16896,12 +20866,16 @@ function browseCategory(category, type = 'reading', filterMode = null, path = nu
}
}
-function filterByType(type) {
+async function filterByType(type, examIndexOverride = null) {
const requestedType = type;
+ let examIndex = Array.isArray(examIndexOverride) ? examIndexOverride : [];
try {
+ if (!Array.isArray(examIndexOverride)) {
+ examIndex = await resolveActiveExamIndex();
+ }
const listeningAvailable = typeof window.hasActiveListeningLibrary === 'function'
- ? window.hasActiveListeningLibrary()
- : (Array.isArray(getExamIndexState()) && getExamIndexState().some((exam) => exam && exam.type === 'listening'));
+ ? window.hasActiveListeningLibrary(examIndex)
+ : examIndex.some((exam) => exam && exam.type === 'listening');
if (requestedType === 'listening' && !listeningAvailable) {
type = 'all';
if (typeof window.showMessage === 'function') {
@@ -16928,7 +20902,7 @@ function filterByType(type) {
if (window.browseController &&
window.browseController.currentMode !== 'default' &&
typeof window.browseController.resetToDefault === 'function') {
- window.browseController.resetToDefault();
+ window.browseController.resetToDefault(examIndex);
}
// 更新题库浏览筛选按钮的 active 状态
@@ -16953,12 +20927,13 @@ function filterByType(type) {
}
// 刷新题库列表
- loadExamList();
+ await loadExamList(examIndex);
}
// 应用分类筛选(供 App/总览调用)
-function applyBrowseFilter(category = 'all', type = null, filterMode = null, path = null) {
+async function applyBrowseFilter(category = 'all', type = null, filterMode = null, path = null) {
try {
+ const indexSnapshot = await resolveActiveExamIndex();
const memorizeSelectionActive = isReadingMemorizeBrowseMode();
if (memorizeSelectionActive) {
category = 'all';
@@ -16980,7 +20955,6 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat
// 若未显式给出类型,则根据当前题库推断(同时存在时不限定类型)
if (!type || type === 'all') {
try {
- const indexSnapshot = getExamIndexState();
const hasReading = indexSnapshot.some(e => e.category === normalizedCategory && e.type === 'reading');
const hasListening = indexSnapshot.some(e => e.category === normalizedCategory && e.type === 'listening');
if (hasReading && !hasListening) type = 'reading';
@@ -16992,8 +20966,8 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat
const normalizedType = normalizeExamType(type);
const normalizedPath = (typeof path === 'string' && path.trim()) ? path.trim() : null;
const listeningAvailable = typeof window.hasActiveListeningLibrary === 'function'
- ? window.hasActiveListeningLibrary()
- : (Array.isArray(getExamIndexState()) && getExamIndexState().some((exam) => exam && exam.type === 'listening'));
+ ? window.hasActiveListeningLibrary(indexSnapshot)
+ : indexSnapshot.some((exam) => exam && exam.type === 'listening');
const effectiveFilterMode = listeningAvailable ? filterMode : null;
const effectiveType = (!listeningAvailable && normalizedType === 'listening') ? 'all' : normalizedType;
@@ -17006,9 +20980,9 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat
if (window.browseController) {
try {
if (!window.browseController.buttonContainer) {
- window.browseController.initialize('type-filter-buttons');
+ window.browseController.initialize('type-filter-buttons', indexSnapshot);
}
- window.browseController.setMode(effectiveFilterMode);
+ window.browseController.setMode(effectiveFilterMode, indexSnapshot);
} catch (error) {
console.warn('[Browse] 切换浏览模式失败:', error);
}
@@ -17020,7 +20994,7 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat
if (window.browseController &&
window.browseController.currentMode !== 'default' &&
typeof window.browseController.resetToDefault === 'function') {
- window.browseController.resetToDefault();
+ window.browseController.resetToDefault(indexSnapshot);
}
}
@@ -17034,7 +21008,7 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat
// 如果是频率模式,setMode 已经处理了刷新,不需要再次调用 loadExamList
// 只有在默认模式下才显式调用
if (!effectiveFilterMode) {
- loadExamList();
+ await loadExamList(indexSnapshot);
}
// 若未在浏览视图,则尽力切换
@@ -17055,16 +21029,21 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat
}
// Initialize browse view when it's activated
-function initializeBrowseView() {
+async function initializeBrowseView(options = {}) {
console.log('[System] Initializing browse view...');
- startPracticeRecordsSyncInBackground('browse-view');
+ const [examIndex] = await Promise.all([
+ resolveActiveExamIndex(),
+ typeof window.whenBrowseViewPreferencesReady === 'function'
+ ? window.whenBrowseViewPreferencesReady()
+ : Promise.resolve()
+ ]);
// 初始化 browseController
if (window.browseController && !window.browseController.buttonContainer) {
- window.browseController.initialize('type-filter-buttons');
+ window.browseController.initialize('type-filter-buttons', examIndex);
}
if (typeof window.refreshListeningAvailabilityUI === 'function') {
- window.refreshListeningAvailabilityUI();
+ window.refreshListeningAvailabilityUI(examIndex);
}
const persisted = getPersistedBrowseFilter();
@@ -17076,12 +21055,11 @@ function initializeBrowseView() {
setBrowseTitle(formatBrowseTitle('all', 'all'));
}
- ensurePracticeRecordsSync('browse-view').then(() => {
- refreshBrowseProgressFromRecords();
- });
setupBrowseSortControl();
setupBrowseFrequencyFilterControl();
- loadExamList();
+ if (!options.skipLoad) {
+ await loadExamList(examIndex);
+ }
}
function normalizeBrowseFrequencyFilter(value) {
@@ -17103,11 +21081,27 @@ function refreshBrowseResults() {
loadExamList();
}
-function setupBrowseControls() {
+let browseControlsSeeded = false;
+async function setupBrowseControls() {
+ if (!browseControlsSeeded) {
+ try {
+ const browse = await window.AppData.preferences.getBrowse();
+ if (browse) {
+ window.__browseSortMode = browse.sortMode || window.__browseSortMode;
+ window.__browseFrequencyFilter = browse.frequencyFilter || window.__browseFrequencyFilter;
+ }
+ } catch (_) { /* defaults remain active */ }
+ browseControlsSeeded = true;
+ }
setupBrowseSortControl();
setupBrowseFrequencyFilterControl();
}
+async function persistBrowsePreference(patch) {
+ const current = await window.AppData.preferences.getBrowse() || {};
+ await window.AppData.preferences.setBrowse(Object.assign({}, current, patch));
+}
+
function setupBrowseSortControl() {
const sortSelect = document.getElementById('browse-sort-select');
if (!sortSelect || sortSelect.dataset.bound === 'true') {
@@ -17118,22 +21112,12 @@ function setupBrowseSortControl() {
return mode === 'frequency-desc' || mode === 'difficulty-desc' ? mode : 'default';
};
let savedMode = String(window.__browseSortMode || '').trim().toLowerCase();
- if (!savedMode) {
- try {
- savedMode = String(window.localStorage.getItem('browse_sort_mode') || 'default').trim().toLowerCase();
- } catch (_) {
- savedMode = 'default';
- }
- }
+ if (!savedMode) savedMode = 'default';
sortSelect.value = normalizeSortMode(savedMode);
window.__browseSortMode = sortSelect.value;
sortSelect.addEventListener('change', () => {
window.__browseSortMode = normalizeSortMode(sortSelect.value);
- try {
- window.localStorage.setItem('browse_sort_mode', window.__browseSortMode);
- } catch (_) {
- // ignore storage failures
- }
+ persistBrowsePreference({ sortMode: window.__browseSortMode }).catch(console.warn);
refreshBrowseResults();
});
sortSelect.dataset.bound = 'true';
@@ -17158,13 +21142,6 @@ function setupBrowseFrequencyFilterControl() {
return;
}
let savedFilter = normalizeBrowseFrequencyFilter(window.__browseFrequencyFilter || 'all');
- if (savedFilter === 'all') {
- try {
- savedFilter = normalizeBrowseFrequencyFilter(window.localStorage.getItem('browse_frequency_filter') || 'all');
- } catch (_) {
- savedFilter = 'all';
- }
- }
window.__browseFrequencyFilter = savedFilter;
updateBrowseFrequencyButtons(savedFilter);
}
@@ -17174,11 +21151,7 @@ function filterByFrequency(filter) {
const current = normalizeBrowseFrequencyFilter(window.__browseFrequencyFilter || 'all');
const next = requested !== 'all' && requested === current ? 'all' : requested;
window.__browseFrequencyFilter = next;
- try {
- window.localStorage.setItem('browse_frequency_filter', next);
- } catch (_) {
- // ignore storage failures
- }
+ persistBrowsePreference({ frequencyFilter: next }).catch(console.warn);
updateBrowseFrequencyButtons(next);
refreshBrowseResults();
}
@@ -17223,33 +21196,36 @@ function filterRecordsByType(type) {
setTimeout(window.updateSegmentedIndicators, 10);
}
- updatePracticeView();
+ startPracticeRecordsSyncInBackground('record-type-filter', { forceRender: true });
}
-function loadExamList() {
- setupBrowseControls();
+async function loadExamList(examIndexOverride = null) {
+ await setupBrowseControls();
+ const examIndex = Array.isArray(examIndexOverride)
+ ? examIndexOverride
+ : await resolveActiveExamIndex();
if (window.ExamActions && typeof window.ExamActions.loadExamList === 'function') {
- return window.ExamActions.loadExamList();
+ return window.ExamActions.loadExamList(examIndex);
}
console.warn('[main.js] ExamActions.loadExamList 未就绪,尝试加载 browse-view 组');
if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') {
window.AppLazyLoader.ensureGroup('browse-view').then(function () {
setupBrowseControls();
if (window.ExamActions && typeof window.ExamActions.loadExamList === 'function') {
- window.ExamActions.loadExamList();
+ window.ExamActions.loadExamList(examIndex);
} else {
// 最终降级:直接 DOM 渲染
- loadExamListFallback();
+ loadExamListFallback(examIndex);
}
}).catch(function (err) {
console.error('[main.js] browse-view 组加载失败:', err);
- loadExamListFallback();
+ loadExamListFallback(examIndex);
});
} else {
// 无懒加载器,直接降级
- loadExamListFallback();
+ loadExamListFallback(examIndex);
}
}
@@ -17298,13 +21274,11 @@ function clearReadingMemorizeBrowseMode() {
}
}
-function selectReadingMemorizeExam(examId) {
+async function selectReadingMemorizeExam(examId) {
if (window.ExamActions && typeof window.ExamActions.launchReadingMemorizeExam === 'function') {
return window.ExamActions.launchReadingMemorizeExam(examId);
}
- const list = typeof getExamIndexState === 'function'
- ? getExamIndexState()
- : (Array.isArray(window.examIndex) ? window.examIndex : []);
+ const list = await resolveActiveExamIndex();
const exam = Array.isArray(list)
? list.find(function (item) { return item && String(item.id) === String(examId); })
: null;
@@ -17401,10 +21375,10 @@ function createFallbackExamCard(exam, options = {}) {
return item;
}
-function loadExamListFallback() {
+function loadExamListFallback(examIndexSnapshot = []) {
console.warn('[main.js] 使用降级渲染逻辑');
try {
- let examIndex = typeof getExamIndexState === 'function' ? getExamIndexState() : (Array.isArray(window.examIndex) ? window.examIndex : []);
+ let examIndex = Array.isArray(examIndexSnapshot) ? examIndexSnapshot : [];
const container = document.getElementById('exam-list-container');
if (!container) return;
@@ -17505,83 +21479,11 @@ function loadExamListFallback() {
}
}
-function resetBrowseViewToAll() {
- if (window.ExamActions && typeof window.ExamActions.resetBrowseViewToAll === 'function') {
- return window.ExamActions.resetBrowseViewToAll();
- }
- console.warn('[main.js] ExamActions.resetBrowseViewToAll 未就绪');
-
- // 清除频率模式状态,确保回到默认列表
- clearReadingMemorizeBrowseMode();
- window.__browseFilterMode = 'default';
- window.__browsePath = null;
-
- if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') {
- window.AppLazyLoader.ensureGroup('browse-view').then(function () {
- if (window.ExamActions && typeof window.ExamActions.resetBrowseViewToAll === 'function') {
- window.ExamActions.resetBrowseViewToAll();
- } else {
- // 降级:重置状态并重新加载
- if (typeof setBrowseFilterState === 'function') setBrowseFilterState('all', 'all');
- loadExamList();
- }
- }).catch(function () {
- if (typeof setBrowseFilterState === 'function') setBrowseFilterState('all', 'all');
- loadExamList();
- });
- } else {
- if (typeof setBrowseFilterState === 'function') setBrowseFilterState('all', 'all');
- loadExamList();
- }
-}
-
-function displayExams(exams) {
- if (window.ExamActions && typeof window.ExamActions.displayExams === 'function') {
- return window.ExamActions.displayExams(exams);
- }
- console.warn('[main.js] ExamActions.displayExams 未就绪,使用降级渲染');
-
- // 立即降级渲染(displayExams 需要同步执行)
- try {
- const container = document.getElementById('exam-list-container');
- if (!container) return;
-
- // 清除 loading 指示器(修复 P2 bug)
- const loadingEl = document.querySelector('#browse-view .loading');
- if (loadingEl) {
- loadingEl.style.display = 'none';
- }
-
- const memorizeSelectionActive = isReadingMemorizeBrowseMode();
- if (typeof window.syncReadingMemorizeBrowseModeUI === 'function') {
- window.syncReadingMemorizeBrowseModeUI();
- }
- const normalizedExams = memorizeSelectionActive
- ? filterReadingMemorizeExamsFallback(exams)
- : (Array.isArray(exams) ? exams : []);
- if (memorizeSelectionActive && typeof setBrowseTitle === 'function') {
- setBrowseTitle('阅读背题选题');
- }
- if (normalizedExams.length === 0) {
- container.innerHTML = '';
- return;
- }
-
- const list = document.createElement('div');
- list.className = 'exam-list';
- normalizedExams.forEach(function (exam) {
- if (!exam) return;
- list.appendChild(createFallbackExamCard(exam, {
- selectionMode: memorizeSelectionActive ? 'reading-memorize' : '',
- showMeta: true
- }));
- });
- container.innerHTML = '';
- container.appendChild(list);
- } catch (err) {
- console.error('[main.js] displayExams 降级渲染失败:', err);
- }
-}
+// resetBrowseViewToAll / displayExams 的唯一实现在 js/app/examActions.js,
+// 由其 IIFE 导出到 window.ExamActions 与 window 上。此处不再重复定义:
+// 两个文件同处 browse.bundle.js,重名的顶层声明会与 examActions 的全局写入
+// 静默互相覆盖(历史上 loadExamList 就因此渲染空白)。
+// 调用方请走 window.ExamActions.*(未加载时有 main-entry.js 的懒加载代理兜底)。
function getResourceCore() {
return window.ResourceCore || null;
@@ -17657,9 +21559,8 @@ function openExam(examId, options = {}) {
return showMessage('统一练习入口未就绪:app.openExam 不可用,已阻止打开原始题源 HTML。', 'error');
}
-function viewPDF(examId) {
- // 增加数组化防御
- const list = getExamIndexState();
+async function viewPDF(examId) {
+ const list = await resolveActiveExamIndex();
const exam = list.find(e => e.id === examId);
if (!exam || !exam.pdfFilename) return showMessage('未找到PDF文件', 'error');
@@ -17729,8 +21630,8 @@ function getViewName(viewName) {
}
}
-function updateSystemInfo() {
- const examIndexSnapshot = getExamIndexState();
+function updateSystemInfo(examIndex = []) {
+ const examIndexSnapshot = Array.isArray(examIndex) ? examIndex : [];
if (!examIndexSnapshot || examIndexSnapshot.length === 0) return;
const readingExams = examIndexSnapshot.filter(e => e.type === 'reading');
const listeningExams = examIndexSnapshot.filter(e => e.type === 'listening');
@@ -17859,14 +21760,14 @@ async function getActiveLibraryConfigurationKey() {
if (manager && typeof manager.getActiveLibraryConfigurationKey === 'function') {
return await manager.getActiveLibraryConfigurationKey();
}
- return await storage.get('active_exam_index_key', 'exam_index');
+ return window.AppData.library.getActive();
}
async function getLibraryConfigurations() {
const manager = await ensureLibraryManagerReady();
if (manager && typeof manager.getLibraryConfigurations === 'function') {
return await manager.getLibraryConfigurations();
}
- return await storage.get('exam_index_configurations', []);
+ return await window.AppData.library.listConfigurations();
}
async function saveLibraryConfiguration(name, key, examCount) {
const manager = await ensureLibraryManagerReady();
@@ -17880,19 +21781,21 @@ async function setActiveLibraryConfiguration(key) {
return await manager.setActiveLibraryConfiguration(key);
}
}
-function triggerFolderPicker() { document.getElementById('folder-picker').click(); }
-function handleFolderSelection(event) { /* legacy stub - replaced by modal-specific inputs */ }
-
// --- Library Loader Modal and Index Management ---
// ... other utility and management functions can be moved here ...
// --- Functions Restored from Backup ---
+let debouncedExamSearch = null;
+
function searchExams(query) {
toggleSearchClearButton(query);
if (window.performanceOptimizer && typeof window.performanceOptimizer.debounce === 'function') {
- const debouncedSearch = window.performanceOptimizer.debounce(performSearch, 300, 'exam_search');
- debouncedSearch(query);
+ // 跨 input 事件复用同一个 debounce 闭包,避免每个字符都排队一次搜索。
+ if (!debouncedExamSearch) {
+ debouncedExamSearch = window.performanceOptimizer.debounce(performSearch, 300, 'exam_search');
+ }
+ debouncedExamSearch(query);
} else {
// Fallback: direct call if optimizer not available
performSearch(query);
@@ -17923,8 +21826,8 @@ function clearSearch() {
searchExams('');
}
-function getBrowseFilteredExamBase() {
- const examIndex = getExamIndexState();
+function getBrowseFilteredExamBase(examIndexSnapshot = []) {
+ const examIndex = Array.isArray(examIndexSnapshot) ? examIndexSnapshot : [];
const activeCategory = typeof getCurrentCategory === 'function' ? getCurrentCategory() : 'all';
const activeExamType = typeof getCurrentExamType === 'function' ? getCurrentExamType() : 'all';
const isFrequencyMode = window.__browseFilterMode && window.__browseFilterMode !== 'default';
@@ -17960,7 +21863,7 @@ function getBrowseFilteredExamBase() {
return list;
}
-function performSearch(query) {
+async function performSearch(query) {
const normalizedQuery = query.toLowerCase().trim();
if (!normalizedQuery) {
loadExamList();
@@ -17969,7 +21872,7 @@ function performSearch(query) {
// 调试日志
console.log('[Search] 执行搜索,查询词:', normalizedQuery);
- const searchBase = getBrowseFilteredExamBase();
+ const searchBase = getBrowseFilteredExamBase(await resolveActiveExamIndex());
console.log('[Search] 当前筛选后索引数量:', searchBase.length);
const searchResults = searchBase.filter(exam => {
if (exam.searchText) {
@@ -17981,7 +21884,11 @@ function performSearch(query) {
});
console.log('[Search] 搜索结果数量:', searchResults.length);
- displayExams(searchResults);
+ if (window.ExamActions && typeof window.ExamActions.displayExams === 'function') {
+ window.ExamActions.displayExams(searchResults);
+ } else if (typeof window.displayExams === 'function') {
+ window.displayExams(searchResults);
+ }
}
async function toggleBulkDelete() {
@@ -17993,7 +21900,7 @@ async function toggleBulkDelete() {
if (typeof showMessage === 'function') {
showMessage('批量管理模式已开启,点击记录进行选择', 'info');
}
- updatePracticeView();
+ await syncPracticeRecords({ forceRender: true });
return;
}
@@ -18013,7 +21920,7 @@ async function toggleBulkDelete() {
clearSelectedRecordsState();
refreshBulkDeleteButton();
- updatePracticeView();
+ await syncPracticeRecords({ forceRender: true });
}
async function bulkDeleteRecords(selectedSnapshot = getSelectedRecordsState()) {
@@ -18025,21 +21932,21 @@ async function bulkDeleteRecords(selectedSnapshot = getSelectedRecordsState()) {
const records = await listCanonicalPracticeRecords();
const baseList = Array.isArray(records) ? records : [];
- const recordsToKeep = baseList.filter(record => !normalizedIds.includes(normalizeRecordId(record && record.id)));
-
- const deletedCount = baseList.length - recordsToKeep.length;
+ const recordIds = new Set(baseList.map((record) => normalizeRecordId(record && record.id)).filter(Boolean));
+ const deletedCount = normalizedIds.filter((id) => recordIds.has(id)).length;
if (deletedCount === 0) {
showMessage('未找到可删除的记录', 'warning');
return;
}
- await persistPracticeRecordsAndRefresh(recordsToKeep, 'bulk-delete');
+ await window.AppData.practice.deleteMany({ recordIds: normalizedIds });
+ await syncPracticeRecords({ forceRender: true, trigger: 'bulk-delete' });
showMessage(`已删除 ${deletedCount} 条记录`, 'success');
console.log(`[System] 批量删除了 ${deletedCount} 条练习记录`);
}
-function toggleRecordSelection(recordId) {
+async function toggleRecordSelection(recordId) {
if (!getBulkDeleteModeState()) return;
const normalizedId = normalizeRecordId(recordId);
@@ -18053,7 +21960,7 @@ function toggleRecordSelection(recordId) {
} else {
addSelectedRecordState(normalizedId);
}
- updatePracticeView(); // Re-render to show selection state
+ await syncPracticeRecords({ forceRender: true });
}
@@ -18075,15 +21982,19 @@ async function deleteRecord(recordId) {
const confirmMessage = `确定要删除这条练习记录吗?\n\n题目: ${record.title}\n时间: ${new Date(record.date).toLocaleString()}\n\n此操作不可恢复。`;
if (confirm(confirmMessage)) {
- const nextRecords = records.filter((record) => String(record.id) !== String(recordId));
- await persistPracticeRecordsAndRefresh(nextRecords, 'single-delete');
+ await window.AppData.practice.delete({ recordId });
+ await syncPracticeRecords({ forceRender: true, trigger: 'single-delete' });
showMessage('记录已删除', 'success');
}
}
async function clearPracticeData() {
if (confirm('确定要清除所有练习记录吗?此操作不可恢复。')) {
- await persistPracticeRecordsAndRefresh([], 'clear-all');
+ await window.AppData.practice.clear();
+ await syncPracticeRecords({ forceRender: true, trigger: 'clear-all' });
+ if (window.AppData && window.AppData.recovery && typeof window.AppData.recovery.clear === 'function') {
+ await window.AppData.recovery.clear();
+ }
processedSessions.clear();
clearSelectedRecordsState();
setBulkDeleteModeState(false);
@@ -18093,44 +22004,11 @@ async function clearPracticeData() {
}
async function clearCache() {
- const confirmMessage = '确定要清除所有缓存数据并清空练习记录吗?';
- if (!confirm(confirmMessage)) {
- return;
- }
-
- const localLegacyKeys = [
- 'exam_system_practice_records',
- 'upgrade_v1_1_0_cleanup_done',
- 'browse_state',
- 'hasSeenGplLicense',
- 'theme',
- 'bloom-theme-mode',
- 'blue-theme-mode'
- ];
-
- try {
- if (window.storage && typeof storage.clear === 'function') {
- await storage.clear();
- } else if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.clear === 'function') {
- await window.PracticeRecordAPI.clear({ updateStats: true });
- } else {
- throw new Error('统一练习记录 API 未就绪');
- }
- } catch (error) {
- console.warn('[clearCache] failed to clear managed storage:', error);
- }
-
- localLegacyKeys.forEach((key) => {
- try { localStorage.removeItem(key); } catch (_) { }
- });
- setPracticeRecordsState([]);
- processedSessions.clear();
- if (window.performanceOptimizer && typeof window.performanceOptimizer.cleanup === 'function') {
- window.performanceOptimizer.cleanup();
+ if (!window.SiteDataReset || typeof window.SiteDataReset.request !== 'function') {
+ showMessage('清除失败:全量重置服务未就绪', 'error');
+ return false;
}
-
- showMessage('缓存与练习记录已清除', 'success');
- setTimeout(() => { location.reload(); }, 1000);
+ return window.SiteDataReset.request();
}
let libraryConfigViewInstance = null;
@@ -18177,7 +22055,7 @@ function normalizeLibraryConfigurationRecords(rawConfigs) {
}
seenKeys.add(key);
normalized.push({
- name: key === 'exam_index' ? '默认题库' : key,
+ name: key,
key,
examCount: 0,
timestamp: now
@@ -18206,15 +22084,6 @@ function normalizeLibraryConfigurationRecords(rawConfigs) {
}
}
- if (!key && typeof record.name === 'string') {
- const nameKey = normalizeKey(record.name);
- if (/^exam_index(_\d+)?$/.test(nameKey)) {
- key = nameKey;
- record.key = key;
- mutated = true;
- }
- }
-
if (!key) {
mutated = true;
continue;
@@ -18249,7 +22118,7 @@ function normalizeLibraryConfigurationRecords(rawConfigs) {
seenKeys.add(key);
if (typeof record.name !== 'string' || !record.name.trim()) {
- record.name = key === 'exam_index' ? '默认题库' : key;
+ record.name = key;
mutated = true;
} else {
record.name = record.name.trim();
@@ -18285,6 +22154,7 @@ function normalizeLibraryConfigurationRecords(rawConfigs) {
async function resolveLibraryConfigurations() {
const rawConfigs = await getLibraryConfigurations();
+ const activeIndex = await resolveActiveExamIndex();
let configs = Array.isArray(rawConfigs) ? rawConfigs : [];
let mutated = false;
@@ -18292,28 +22162,22 @@ async function resolveLibraryConfigurations() {
configs = normalizedResult.normalized;
mutated = normalizedResult.mutated;
- if (configs.length === 0) {
- try {
- const count = getExamIndexState().length;
- configs = [{
- name: '默认题库',
- key: 'exam_index',
- examCount: count,
- timestamp: Date.now()
- }];
- mutated = true;
- const activeKey = await storage.get('active_exam_index_key');
- if (!activeKey) {
- await storage.set('active_exam_index_key', 'exam_index');
- }
- } catch (error) {
- console.warn('[LibraryConfig] 无法初始化默认题库配置', error);
- }
+ if (!configs.some(config => config && config.builtIn === true)) {
+ configs.unshift({
+ name: '默认题库',
+ key: '',
+ id: null,
+ builtIn: true,
+ sourceType: 'built-in-manifest',
+ examCount: activeIndex.length
+ });
}
if (mutated) {
try {
- await storage.set('exam_index_configurations', configs);
+ for (const config of configs) {
+ if (config && config.key && config.builtIn !== true) await window.AppData.library.updateConfiguration(config);
+ }
} catch (error) {
console.warn('[LibraryConfig] 无法同步题库配置记录', error);
}
@@ -18369,15 +22233,14 @@ async function deleteLibraryConfiguration(key) {
async function debugCompareActiveIndexWithDefault() {
try {
const activeKey = await getActiveLibraryConfigurationKey();
- const activeIndex = Array.isArray(getExamIndexState()) ? getExamIndexState() : [];
+ const activeIndex = await resolveActiveExamIndex();
const defaultIndex = typeof window.getReadingExamIndex === 'function'
? window.getReadingExamIndex().map((exam) => Object.assign({}, exam, { type: 'reading' }))
: (Array.isArray(window.__READING_EXAM_INDEX__)
? window.__READING_EXAM_INDEX__.map((exam) => Object.assign({}, exam, { type: 'reading' }))
: []);
const defaultListening = Array.isArray(window.listeningExamIndex) ? window.listeningExamIndex : [];
- const storedDefault = await storage.get('exam_index', []);
- const combinedDefault = storedDefault.length ? storedDefault : [...defaultIndex, ...defaultListening];
+ const combinedDefault = [...defaultIndex, ...defaultListening];
const normalizeTail = (path) => {
const p = String(path || '').replace(/\\/g, '/').split('/').filter(Boolean);
@@ -18473,8 +22336,8 @@ function renderLibraryConfigFallback(container, configs, options) {
if (!config) {
return;
}
- const isActive = activeKey === config.key;
- const isDefault = config.key === 'exam_index';
+ const isDefault = config.builtIn === true;
+ const isActive = isDefault ? activeKey == null : activeKey === config.key;
const item = document.createElement('div');
item.className = 'library-config-panel__item' + (activeKey === config.key ? ' library-config-panel__item--active' : '');
@@ -18498,7 +22361,7 @@ function renderLibraryConfigFallback(container, configs, options) {
switchBtn.type = 'button';
switchBtn.className = 'btn btn-secondary';
switchBtn.dataset.configAction = 'switch';
- switchBtn.dataset.configKey = config.key;
+ switchBtn.dataset.configKey = config.key || '';
if (isActive) {
switchBtn.dataset.configActive = '1';
}
@@ -18670,10 +22533,7 @@ async function showLibraryConfigListV2(options) {
// 切换题库配置
async function switchLibraryConfig(configKey) {
- const key = typeof configKey === 'string' ? configKey.trim() : '';
- if (!key) {
- return;
- }
+ const key = typeof configKey === 'string' && configKey.trim() ? configKey.trim() : null;
try {
const activeKey = await getActiveLibraryConfigurationKey();
if (activeKey === key) {
@@ -18701,10 +22561,6 @@ async function deleteLibraryConfig(configKey) {
if (!key) {
return;
}
- if (key === 'exam_index') {
- showMessage('默认题库不可删除', 'warning');
- return;
- }
try {
const activeKey = await getActiveLibraryConfigurationKey();
if (activeKey === key) {
@@ -18839,12 +22695,12 @@ function openExamWithFallback(exam, delay = 600) {
}
// Phase 3: 随机练习 - 已迁移到 app-actions.js
-function startRandomPractice(category, type = 'reading', filterMode = null, path = null) {
+async function startRandomPractice(category, type = 'reading', filterMode = null, path = null) {
if (window.AppActions && typeof window.AppActions.startRandomPractice === 'function') {
return window.AppActions.startRandomPractice(category, type, filterMode, path);
}
// 降级:直接执行
- const list = getExamIndexState();
+ const list = await resolveActiveExamIndex();
const normalizedType = (!type || type === 'all') ? null : type;
const normalizedPath = (typeof path === 'string' && path.trim()) ? path.trim() : null;
@@ -18890,6 +22746,7 @@ ensurePracticeSessionSyncListener();
if (global.AppLazyLoader && typeof global.AppLazyLoader.markProvided === "function") {
global.AppLazyLoader.markProvided([
"js/views/legacyViewBundle.js",
+ "js/data/practiceRecordSource.js",
"js/app/examActions.js",
"js/app/spellingErrorCollector.js",
"js/app/examSessionMixin.js",
diff --git a/js/bundles/core-foundation.bundle.js b/js/bundles/core-foundation.bundle.js
index 8504ee72..c0472335 100644
--- a/js/bundles/core-foundation.bundle.js
+++ b/js/bundles/core-foundation.bundle.js
@@ -6,34 +6,8 @@
return;
}
- const FLAG_KEY = '__ielts_test_env__';
const LOCATION_HINTS = ['test_env=1', 'suite_test=1', 'ci=1'];
- const readStorageFlag = () => {
- try {
- if (global.localStorage) {
- return global.localStorage.getItem(FLAG_KEY) === 'true';
- }
- } catch (error) {
- console.warn('[EnvDetector] 无法读取测试标记:', error);
- }
- return false;
- };
-
- const persistFlag = (value) => {
- try {
- if (global.localStorage) {
- if (value) {
- global.localStorage.setItem(FLAG_KEY, 'true');
- } else {
- global.localStorage.removeItem(FLAG_KEY);
- }
- }
- } catch (error) {
- console.warn('[EnvDetector] 无法写入测试标记:', error);
- }
- };
-
const shouldActivateFromLocation = () => {
if (!global.location) {
return false;
@@ -50,33 +24,19 @@
}
if (shouldActivateFromLocation()) {
- this.enableTestEnvironment({ persist: true });
- return true;
- }
-
- if (readStorageFlag()) {
- global.__IELTS_FORCE_TEST_ENV__ = true;
- return true;
- }
-
- const userAgent = (global.navigator && global.navigator.userAgent) || '';
- if (/\b(playwright|puppeteer|headlesschrome)\b/i.test(userAgent)) {
+ this.enableTestEnvironment();
return true;
}
return false;
},
- enableTestEnvironment(options = {}) {
+ enableTestEnvironment() {
global.__IELTS_FORCE_TEST_ENV__ = true;
- if (options.persist !== false) {
- persistFlag(true);
- }
},
disableTestEnvironment() {
global.__IELTS_FORCE_TEST_ENV__ = false;
- persistFlag(false);
}
};
@@ -95,8 +55,6 @@
return;
}
- const STORAGE_KEY = 'exam_system_log_config_v2';
-
// Default configuration
const DEFAULT_CONFIG = {
level: 'info',
@@ -106,7 +64,7 @@
'PerformanceOptimizer': 'warn',
'System': 'info',
'PracticeRecorder': 'info',
- 'ScoreStorage': 'info'
+ 'DataKernel': 'warn'
}
};
@@ -132,6 +90,7 @@
this.debug = this.debug.bind(this);
this.overrideConsole();
+ Promise.resolve().then(() => this.hydrateConfig());
// Output initialization message
this.internalLog('info', 'Logger initialized', {
@@ -141,41 +100,47 @@
}
/**
- * Load configuration from localStorage or use defaults
+ * Build configuration from defaults and explicit bootstrap overrides.
*/
loadConfig(externalConfig) {
- let storedConfig = {};
- try {
- const stored = global.localStorage.getItem(STORAGE_KEY);
- if (stored) {
- storedConfig = JSON.parse(stored);
- }
- } catch (e) {
- // Ignore storage errors
- }
-
return {
- level: externalConfig.level || storedConfig.level || DEFAULT_CONFIG.level,
+ level: externalConfig.level || DEFAULT_CONFIG.level,
categories: {
...DEFAULT_CONFIG.categories,
- ...(storedConfig.categories || {}),
...(externalConfig.categories || {})
}
};
}
+ async hydrateConfig() {
+ try {
+ if (!global.AppData) return;
+ await global.AppData.ready;
+ const storedConfig = await global.AppData.preferences.getLogConfig();
+ if (!storedConfig || typeof storedConfig !== 'object') return;
+ this.config = {
+ level: storedConfig.level || this.config.level,
+ categories: { ...this.config.categories, ...(storedConfig.categories || {}) }
+ };
+ } catch (error) {
+ this.nativeConsole.warn('[AppLogger] 无法读取日志配置:', error);
+ }
+ }
+
/**
- * Save current configuration to localStorage
+ * Save current configuration through the preferences domain.
*/
saveConfig() {
- try {
- global.localStorage.setItem(STORAGE_KEY, JSON.stringify({
+ if (!global.AppData) return Promise.resolve(false);
+ return global.AppData.ready.then(() =>
+ global.AppData.preferences.setLogConfig({
level: this.config.level,
categories: this.config.categories
- }));
- } catch (e) {
- // Ignore storage errors
- }
+ })
+ ).then(() => true).catch((error) => {
+ this.nativeConsole.warn('[AppLogger] 无法保存日志配置:', error);
+ return false;
+ });
}
/**
@@ -366,8876 +331,6478 @@
})(typeof window !== 'undefined' ? window : (typeof global !== 'undefined' ? global : this));
-/* ===== js/utils/storage.js ===== */
-(function initStorage(window) {
-'use strict';
-
+/* ===== js/data/practiceRecordSource.js ===== */
/**
- * 本地存储工具类
- * 提供统一的数据存储和检索接口
+ * 练习记录来源判定 —— “什么算真实练习记录”的唯一权威定义。
+ *
+ * 背景(本文件存在的理由):
+ * 这条规则历史上被复制成了两套互不相通的实现,语义还不一样:
+ * - UI 侧 js/main.js `updatePracticeView` 只看顶层 `dataSource`;
+ * - 投影器侧 js/data/v2/appData.js `computeStats` / `computeAchievementProgress`
+ * 只看 `metadata.source === 'onboarding-demo'`。
+ * 结果是 `demo` / `e2e-seed` 这类记录“在练习记录页看不见,却计入成绩统计和成就解锁”,
+ * 用户会看到自己没做过的题影响了正确率与成就。
+ *
+ * 因此判定必须只有一份实现,并被所有消费方共享。本文件同时被打进
+ * core-foundation / reading-page / practice-page-enhancer / listening-record-bridge /
+ * listening-wrapper(供 appData.js 的投影器使用)和 browse(供 js/main.js 的渲染过滤使用)
+ * 等 bundle;appData.js 在启动时硬性要求本模块存在,缺失即抛错,杜绝“再退回本地副本”。
+ *
+ * ---------------------------------------------------------------------------
+ * 语义(两个维度,任一命中即判为非真实)
+ *
+ * 1) dataSource(顶层,回退 metadata.dataSource)
+ * - 缺失 / null / 空串 => **真实记录**
+ * - 'real' => 真实记录
+ * - 其它任何显式值 => 非真实(演示 / 种子 / 占位)
+ *
+ * “缺失即真实”是硬性约束,不得收窄:生产代码只在 practiceRecorder / examSessionMixin
+ * 三处写过该字段且都写 'real',套题聚合、听力桥接、legacy 迁移记录从来不写。
+ * 曾经有一版把“没标注”当成“非真实”,直接导致练习记录页整页空白(线上 P0)。
+ *
+ * 2) metadata.source
+ * 只精确匹配已知的演示/种子标记,**绝不做包含匹配**。
+ * 这个字段是被复用的:套题记录会写 'listening' / 'reading'(内容类型标签,见
+ * js/app/suitePracticeMixin.js),消息通道会写 'practice_page' / 'inline_collector'
+ * / 'suite_placeholder' / 'listening_record_bridge' / 'data_collector'(采集方式标签)。
+ * 任何模糊匹配都可能把真实记录判成演示数据,属于同一类 P0。
+ *
+ * 注意:`record.source` 与 `realData.source` 是采集方式标签而非来源标注,故不参与判定。
*/
-const STORAGE_INTERNAL_ACCESS_TOKEN = Symbol('StorageManager.internalAccessToken');
+(function initPracticeRecordSource(global) {
+ 'use strict';
-const createInternalAccessOptions = (options = {}) => {
- return Object.assign({}, options, {
- skipPracticeCoreRedirect: true,
- internalAccessToken: STORAGE_INTERNAL_ACCESS_TOKEN
- });
-};
-
-const hasInternalAccessOptions = (options = {}) => {
- return Boolean(options && options.internalAccessToken === STORAGE_INTERNAL_ACCESS_TOKEN);
-};
-
-class StorageManager {
- constructor() {
- this.prefix = 'exam_system_';
- this.version = '0.6.2-fix';
- this.localStorageAvailable = false;
- this.sessionStorageAvailable = false;
- this.backendPreferenceKey = this.prefix + 'storage_backend';
- this.indexedDBBlocked = false;
- this.volatileMode = false;
- this.mode = 'indexeddb';
- this.protectedDataKeys = new Set([
- 'practice_records',
- 'user_stats'
- ]);
- this.persistentKeys = new Set([
- 'practice_records',
- 'user_stats',
- 'manual_backups',
- 'backup_settings',
- 'export_history',
- 'import_history',
- 'exam_index',
- 'exam_index_configurations',
- 'active_exam_index_key',
- 'settings',
- 'learning_goals'
- ]);
- this.ready = this.initializeStorage().catch(error => {
- console.error('[Storage] 初始化失败:', error);
- throw error;
- });
+ // 同一份源码会被多个 bundle 内联(浏览器里 core-foundation 与 browse 都会执行一次),
+ // 重复赋值本身无害,但仍按仓库惯例做幂等保护,避免任何形态的静默覆盖。
+ if (global.PracticeRecordSource && global.PracticeRecordSource.__stable === true) {
+ return;
}
- async waitForInitialization(skipReady = false) {
- if (!skipReady) {
- await this.ready;
- }
- }
+ /** 被认可为“真实用户练习”的显式 dataSource 取值。 */
+ const REAL_DATA_SOURCES = Object.freeze(['real']);
- isProtectedDataKey(key) {
- return this.protectedDataKeys.has(String(key || ''));
- }
+ /**
+ * 被认定为“演示 / 种子 / 夹具数据”的 metadata.source 取值(精确匹配,大小写与首尾空白无关)。
+ * 目前生产代码只会写出 'onboarding-demo'(js/components/onboardingTour.js);
+ * 其余是历史与测试夹具里出现过的等价写法,一并显式列出而不是靠模糊匹配推断。
+ */
+ const DEMO_SOURCE_MARKERS = Object.freeze([
+ 'onboarding-demo',
+ 'onboarding_demo',
+ 'onboardingdemo',
+ 'demo',
+ 'e2e-seed',
+ 'e2e_seed'
+ ]);
- isProtectedStorageKey(storageKey) {
- const key = String(storageKey || '');
- if (!key.startsWith(this.prefix)) {
- return false;
- }
- return this.isProtectedDataKey(key.slice(this.prefix.length));
- }
+ /** 只有新手引导自己的 marker 才有资格申请临时历史列表预览。 */
+ const ONBOARDING_PREVIEW_MARKERS = Object.freeze([
+ 'onboarding-demo',
+ 'onboarding_demo',
+ 'onboardingdemo'
+ ]);
- async getPracticeRecordAPI(options = {}) {
- const api = window.PracticeRecordAPI;
- if (api) {
- return api;
- }
- if (options.skipReady || !this.ready || this._resolvingPracticeRecordAPI) {
- return null;
- }
- this._resolvingPracticeRecordAPI = true;
- try {
- await this.ready;
- return window.PracticeRecordAPI || null;
- } finally {
- this._resolvingPracticeRecordAPI = false;
- }
- }
+ const realDataSourceSet = new Set(REAL_DATA_SOURCES);
+ const demoSourceSet = new Set(DEMO_SOURCE_MARKERS);
+ const onboardingPreviewMarkerSet = new Set(ONBOARDING_PREVIEW_MARKERS);
- async readProtectedDataKey(key, defaultValue = null, options = {}) {
- const api = await this.getPracticeRecordAPI(options);
- if (key === 'practice_records') {
- if (api && typeof api.list === 'function') {
- return await api.list();
- }
- throw new Error('Storage.get(practice_records): PracticeRecordAPI.list not ready');
- }
- if (key === 'user_stats') {
- if (api && typeof api.readStats === 'function') {
- return await api.readStats({ fallback: defaultValue });
- }
- throw new Error('Storage.get(user_stats): PracticeRecordAPI.readStats not ready');
- }
- return defaultValue;
+ function normalize(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim().toLowerCase();
}
- /**
- * 初始化存储系统
- */
- checkStorageAvailability(getter) {
- try {
- const store = getter();
- if (!store || typeof store.setItem !== 'function') {
- return false;
- }
- const testKey = this.prefix + 'storage_test_' + Math.random().toString(36).slice(2);
- store.setItem(testKey, '1');
- store.removeItem(testKey);
- return true;
- } catch (_) {
- return false;
- }
+ function asObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
}
- getStoredBackendPreference() {
- try {
- if (this.sessionStorageAvailable && sessionStorage.getItem(this.backendPreferenceKey)) {
- return sessionStorage.getItem(this.backendPreferenceKey);
- }
- } catch (_) { /* ignore */ }
- try {
- if (this.localStorageAvailable && localStorage.getItem(this.backendPreferenceKey)) {
- return localStorage.getItem(this.backendPreferenceKey);
- }
- } catch (_) { /* ignore */ }
- return null;
+ function hasOwn(object, field) {
+ return Object.prototype.hasOwnProperty.call(object, field);
}
- setBackendPreference(mode) {
- try {
- if (mode === 'session' && this.sessionStorageAvailable) {
- sessionStorage.setItem(this.backendPreferenceKey, 'session');
- return;
- }
- if (mode === 'local' && this.localStorageAvailable) {
- localStorage.setItem(this.backendPreferenceKey, 'local');
- return;
- }
- } catch (_) { /* ignore */ }
+ /** 读取记录的来源标注:顶层优先,回退 metadata(light 投影同样走这条回退链)。 */
+ function readDataSource(record) {
+ if (hasOwn(record, 'dataSource')) return normalize(record.dataSource);
+ const metadata = asObject(record.metadata);
+ return hasOwn(metadata, 'dataSource') ? normalize(metadata.dataSource) : '';
}
- clearBackendPreference() {
- try { if (this.sessionStorageAvailable) { sessionStorage.removeItem(this.backendPreferenceKey); } } catch (_) {}
- try { if (this.localStorageAvailable) { localStorage.removeItem(this.backendPreferenceKey); } } catch (_) {}
+ function readMetadataSource(record) {
+ return normalize(asObject(record.metadata).source);
}
- async initializeStorage() {
- console.log('[Storage] 开始初始化存储系统');
- try {
- this.localStorageAvailable = this.checkStorageAvailability(() => localStorage);
- this.sessionStorageAvailable = this.checkStorageAvailability(() => sessionStorage);
- if (this.localStorageAvailable) {
- console.log('[Storage] localStorage 可用,将使用 localStorage 作为主要存储');
- this.setBackendPreference('local');
- } else {
- console.warn('[Storage] localStorage 不可用');
- }
- if (this.sessionStorageAvailable) {
- console.log('[Storage] sessionStorage 可用,可作为退路');
- } else {
- console.warn('[Storage] sessionStorage 不可用');
- }
-
- const storedPreference = this.getStoredBackendPreference();
- if (storedPreference === 'session') {
- this.useSessionStorageFallback = true;
- }
- if (!this.localStorageAvailable && this.sessionStorageAvailable) {
- this.useSessionStorageFallback = true;
- }
-
- // 强制初始化 IndexedDB 以实现 Hybrid 模式,并在版本检查前确保 DB ready
- console.log('[Storage] 强制初始化 IndexedDB 以实现 Hybrid 模式');
- await this.initializeIndexedDBStorage();
+ /**
+ * 唯一判定入口:该记录是否算作用户的真实练习。
+ * 练习记录列表渲染、practice.stats 投影、achievements.progress 投影三者必须都用它,
+ * 三处结论一致是本模块的核心契约。
+ */
+ function isRealPracticeRecord(record) {
+ if (!record || typeof record !== 'object') return false;
- // 初始化版本信息
- const currentVersion = await this.get('system_version', null, { skipReady: true });
- console.log(`[Storage] 当前版本: ${currentVersion}, 目标版本: ${this.version}`);
+ const dataSource = readDataSource(record);
+ // 缺失/空值一律按真实记录对待(见文件头“缺失即真实”)。
+ if (dataSource !== '' && !realDataSourceSet.has(dataSource)) return false;
- if (!currentVersion) {
- // 首次安装
- console.log('[Storage] 首次安装,初始化默认数据');
- await this.handleVersionUpgrade(null, { skipReady: true });
- } else if (currentVersion !== this.version) {
- // 版本升级
- console.log('[Storage] 版本升级,迁移数据');
- await this.handleVersionUpgrade(currentVersion, { skipReady: true });
- } else {
- console.log('[Storage] 版本匹配,跳过初始化');
- }
+ if (demoSourceSet.has(readMetadataSource(record))) return false;
- // 添加恢复逻辑
- } catch (error) {
- console.warn('[Storage] 初始化基本存储能力失败,尝试继续:', error);
- await this.initializeIndexedDBStorage();
- }
+ return true;
}
- /**
- * 初始化IndexedDB存储
- */
- initializeIndexedDBStorage() {
- console.log('[Storage] 开始初始化 IndexedDB');
- if (this.indexedDBBlocked) {
- return Promise.resolve();
- }
- return new Promise((resolve, reject) => {
- try {
- // 检查IndexedDB支持
- if (!window.indexedDB) {
- this.indexedDBBlocked = true;
- this.indexedDB = null;
- if (this.localStorageAvailable || this.sessionStorageAvailable) {
- this.volatileMode = false;
- this.mode = this.localStorageAvailable ? 'localStorage' : 'sessionStorage';
- console.warn('[Storage] IndexedDB 不支持,将使用现有本地/会话存储');
- resolve();
- return;
- }
- this.volatileMode = true;
- this.mode = 'volatile';
- console.warn('[Storage] IndexedDB 不支持且无本地存储,fallback 到内存存储');
- this.fallbackStorage = new Map();
- resolve();
- return;
- }
-
- this.dbName = 'ExamSystemDB';
- this.dbVersion = 1;
-
- console.log(`[Storage] 打开 IndexedDB 数据库: ${this.dbName}, 版本: ${this.dbVersion}`);
- const request = indexedDB.open(this.dbName, this.dbVersion);
- request.addEventListener('error', () => {
- this.indexedDB = null;
- if (this.localStorageAvailable || this.sessionStorageAvailable) {
- this.volatileMode = false;
- this.mode = this.localStorageAvailable ? 'localStorage' : 'sessionStorage';
- return;
- }
- this.volatileMode = true;
- this.mode = 'volatile';
- this.fallbackStorage = this.fallbackStorage || new Map();
- });
- request.addEventListener('success', () => {
- this.volatileMode = false;
- this.mode = 'indexeddb';
- });
+ /** isRealPracticeRecord 的补集,仅对合法记录对象成立(非对象既不真也不演示)。 */
+ function isDemoPracticeRecord(record) {
+ if (!record || typeof record !== 'object') return false;
+ return !isRealPracticeRecord(record);
+ }
- request.onerror = (event) => {
- console.error('[Storage] IndexedDB 打开失败:', event.target.error);
- this.indexedDBBlocked = true;
- if (this.localStorageAvailable || this.sessionStorageAvailable) {
- console.warn('[Storage] 使用 local/sessionStorage 作为回退存储');
- this.indexedDB = null;
- resolve();
- return;
- }
- this.fallbackStorage = new Map();
- resolve();
- };
+ function filterRealPracticeRecords(records) {
+ return (Array.isArray(records) ? records : []).filter(isRealPracticeRecord);
+ }
- request.onupgradeneeded = (event) => {
- console.log('[Storage] IndexedDB 升级事件触发,旧版本:', event.oldVersion, '新版本:', event.newVersion);
- const db = event.target.result;
-
- // 创建存储对象
- if (!db.objectStoreNames.contains('keyValueStore')) {
- console.log('[Storage] 创建 objectStore: keyValueStore');
- const store = db.createObjectStore('keyValueStore', { keyPath: 'key' });
- store.createIndex('timestamp', 'timestamp', { unique: false });
- console.log('[Storage] objectStore 创建成功');
- } else {
- console.log('[Storage] objectStore 已存在,跳过创建');
- }
- };
+ // -----------------------------------------------------------------------
+ // 引导预览白名单(仅影响渲染,永不影响统计与成就)
+ //
+ // 新手引导的"回顾模式"步骤会先把一条演示记录写进权威 practice records,
+ // 再等待它在练习记录列表里出现(js/components/onboardingTour.js
+ // `_injectDemoRecord` -> `_waitForSelector`),演示完成后立即删除。
+ //
+ // 这条记录按上面的判定确实是演示数据(metadata.source = 'onboarding-demo'),
+ // 所以它必须继续被 practice.stats / achievements.progress 排除。但引导要教用户
+ // 认识这一行 UI,因此需要一个**显式、按 id 限定、临时**的渲染例外。
+ //
+ // 关键设计:例外只存在于视图层白名单,投影器根本读不到它——
+ // 于是"是否真实"仍然只有一份判定,不会退回"UI 与统计各写一套"的老 bug。
+ // 历史上引导记录之所以能显示,只是因为没人给它写 dataSource(巧合而非设计)。
+ // -----------------------------------------------------------------------
+ const previewRecordIds = new Set();
- request.onsuccess = (event) => {
- this.indexedDB = event.target.result;
- this.indexedDBBlocked = false;
- console.log('[Storage] IndexedDB 初始化成功,数据库:', this.indexedDB.name, '版本:', this.indexedDB.version);
-
- // 迁移localStorage数据到IndexedDB
- console.log('[Storage] 开始从 localStorage 迁移数据');
- Promise.resolve()
- .then(() => this.migrateFromLocalStorage())
- .then(() => resolve())
- .catch((migrationError) => {
- console.warn('[Storage] 迁移过程中出现问题,但继续初始化:', migrationError);
- resolve();
- });
- };
+ function normalizeId(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim();
+ }
- } catch (error) {
- console.error('[Storage] IndexedDB 初始化失败:', error);
- this.indexedDBBlocked = true;
- if (this.localStorageAvailable || this.sessionStorageAvailable) {
- console.warn('[Storage] IndexedDB 初始化失败,将使用 local/sessionStorage');
- this.indexedDB = null;
- resolve();
- return;
- }
- this.fallbackStorage = new Map();
- resolve();
- }
- });
+ /** 登记一条允许在练习记录列表中预览的演示记录 id(引导步骤开始时调用)。 */
+ function allowPreviewRecordId(recordId) {
+ const id = normalizeId(recordId);
+ if (id) previewRecordIds.add(id);
+ return id !== '';
}
- /**
- * 确保 IndexedDB 已 ready
- */
- async ensureIndexedDBReady() {
- if (this.indexedDBBlocked) {
- return;
- }
- if (!this.indexedDB) {
- try {
- await this.initializeIndexedDBStorage();
- } catch (err) {
- this.indexedDBBlocked = true;
- }
+ /** 撤销预览许可(引导结束/跳过/清理演示记录时调用)。 */
+ function clearPreviewRecordId(recordId) {
+ if (recordId === undefined) {
+ previewRecordIds.clear();
+ return true;
}
+ return previewRecordIds.delete(normalizeId(recordId));
}
- async tryPromoteToIndexedDB(serializedValue, key) {
- try {
- if (!this.indexedDB) {
- await this.initializeIndexedDBStorage();
- }
- if (this.indexedDB) {
- await this.setToIndexedDB(this.getKey(key), serializedValue);
- this.useSessionStorageFallback = false;
- this.setBackendPreference('local');
- this.dispatchStorageSync(key);
- return true;
- }
- } catch (e) {
- console.warn('[Storage] 提升到 IndexedDB 失败,继续使用退路:', e);
- }
- return false;
+ function isPreviewRecord(record) {
+ if (!previewRecordIds.size || !record || typeof record !== 'object') return false;
+ if (!onboardingPreviewMarkerSet.has(readMetadataSource(record))) return false;
+ const id = normalizeId(record.id || record.recordId);
+ return Boolean(id && previewRecordIds.has(id));
}
/**
- * 从localStorage迁移数据到IndexedDB
+ * 练习记录列表的渲染过滤:真实记录 + 已显式登记的引导预览记录。
+ * 统计/成就一律用 filterRealPracticeRecords,绝不用这个函数。
*/
- async migrateFromLocalStorage() {
- console.log('[Storage] 开始数据迁移');
- try {
- if (!this.indexedDB) {
- console.warn('[Storage] IndexedDB 不可用,跳过迁移');
- return;
- }
+ function filterRecordsForHistoryView(records) {
+ return (Array.isArray(records) ? records : [])
+ .filter((record) => isRealPracticeRecord(record) || isPreviewRecord(record));
+ }
- const keys = Object.keys(localStorage);
- const migrationKeys = keys.filter(key => key.startsWith(this.prefix));
- console.log(`[Storage] 发现 ${migrationKeys.length} 条需要迁移的键`);
+ const api = Object.freeze({
+ __stable: true,
+ REAL_DATA_SOURCES,
+ DEMO_SOURCE_MARKERS,
+ ONBOARDING_PREVIEW_MARKERS,
+ isRealPracticeRecord,
+ isDemoPracticeRecord,
+ filterRealPracticeRecords,
+ allowPreviewRecordId,
+ clearPreviewRecordId,
+ isPreviewRecord,
+ filterRecordsForHistoryView
+ });
- if (migrationKeys.length === 0) {
- console.log('[Storage] 无数据需要迁移');
- return;
- }
+ global.PracticeRecordSource = api;
- let migratedCount = 0;
- let failedCount = 0;
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = api;
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
- for (const key of migrationKeys) {
- try {
- const value = localStorage.getItem(key);
- if (value) {
- await this.setToIndexedDB(key, value);
- localStorage.removeItem(key);
- migratedCount++;
- console.log(`[Storage] 成功迁移键: ${key}`);
- }
- } catch (error) {
- console.warn(`[Storage] 迁移数据失败: ${key}`, error);
- failedCount++;
- }
- }
- console.log(`[Storage] 数据迁移完成: ${migratedCount} 成功, ${failedCount} 失败`);
- } catch (error) {
- console.error('[Storage] 数据迁移失败:', error);
+/* ===== js/data/v2/dataCatalog.js ===== */
+(function installDataCatalog(global) {
+ 'use strict';
+
+ const V2_SCHEMA_VERSION = 2;
+
+ function clone(value) {
+ if (value === undefined) return undefined;
+ if (typeof structuredClone === 'function') {
+ try { return structuredClone(value); } catch (_) { /* fall through */ }
}
+ return JSON.parse(JSON.stringify(value));
}
- /**
- * 存储到IndexedDB
- */
- setToIndexedDB(key, value) {
- return new Promise((resolve, reject) => {
- if (!this.indexedDB) {
- reject(new Error('IndexedDB not available'));
- return;
- }
+ function objectDefault() { return {}; }
+ function arrayDefault() { return []; }
+ function nullableDefault() { return null; }
+ function normalizeArray(value) { return Array.isArray(value) ? clone(value) : []; }
+ function normalizeObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? clone(value) : {};
+ }
+ function normalizeNullableString(value) {
+ return value === null || value === undefined || value === '' ? null : String(value);
+ }
+ function isArray(value) { return Array.isArray(value); }
+ function isObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }
+ function isNullableString(value) { return value === null || typeof value === 'string'; }
- const transaction = this.indexedDB.transaction(['keyValueStore'], 'readwrite');
- const store = transaction.objectStore('keyValueStore');
+ const CATALOG_OWNERS = new Set([
+ 'settings', 'library', 'recovery', 'backups', 'vocab',
+ 'preferences', 'goals', 'achievements', 'system', 'practice'
+ ]);
+ const CATALOG_CLASSIFICATIONS = new Set(['authoritative', 'preference', 'session', 'system']);
+ const IMPORT_POLICIES = new Set(['replace', 'patch', 'merge-by-id', 'ignore']);
- const data = {
- key: key,
- value: value,
- timestamp: Date.now()
- };
+ function isNonEmptyString(value) {
+ return typeof value === 'string' && Boolean(value.trim());
+ }
- const request = store.put(data);
+ function ownerFromKey(logicalKey) {
+ const dot = String(logicalKey || '').indexOf('.');
+ return dot > 0 ? logicalKey.slice(0, dot) : '';
+ }
- request.onsuccess = () => resolve(true);
- request.onerror = () => reject(request.error);
+ function freezeEntry(entry) {
+ const logicalKey = String(entry.logicalKey || '');
+ const owner = ownerFromKey(logicalKey);
+ const next = Object.assign({}, entry, {
+ logicalKey,
+ owner,
+ schemaVersion: V2_SCHEMA_VERSION,
+ export: entry.export === true,
+ import: entry.import || 'ignore'
});
+ return Object.freeze(next);
}
- /**
- * 从IndexedDB获取数据
- */
- getFromIndexedDB(key) {
- return new Promise((resolve, reject) => {
- if (!this.indexedDB) {
- reject(new Error('IndexedDB not available'));
- return;
+ // Minimal document catalog. Practice lives in entity stores (summaries/details/annotations),
+ // not as document keys. import merge identity is resolved in AppData, not here.
+ const definitions = [
+ {
+ logicalKey: 'settings.values', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'library.configurations', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'library.importedIndexes', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'library.activeConfigurationId', classification: 'authoritative',
+ defaultValue: nullableDefault, normalize: normalizeNullableString, validate: isNullableString,
+ export: true, import: 'replace'
+ },
+ {
+ logicalKey: 'recovery.activeSessions', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.drafts', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.interrupted', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.rejectedCompletions', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.windowSession', classification: 'session',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'backups.entries', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'backups.settings', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'backups.exportHistory', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'backups.importHistory', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'vocab.words', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'vocab.userConfig', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'vocab.lists', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'preferences.values', classification: 'preference',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'goals.items', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'achievements.manual', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'achievements.progress', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'system.migrations', classification: 'system',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'system.operationJournal', classification: 'system',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ }
+ ].map(freezeEntry);
+
+ function validateCatalog(entries) {
+ if (!Array.isArray(entries) || !entries.length) throw new Error('DataCatalog requires at least one entry');
+ const logicalKeys = new Set();
+ for (const entry of entries) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
+ throw new Error('DataCatalog entry must be an object');
+ }
+ if (!isNonEmptyString(entry.logicalKey) || logicalKeys.has(entry.logicalKey)) {
+ throw new Error(`DataCatalog duplicate/invalid logical key: ${entry.logicalKey}`);
+ }
+ logicalKeys.add(entry.logicalKey);
+ }
+ for (const entry of entries) {
+ if (!CATALOG_OWNERS.has(entry.owner) || !entry.logicalKey.startsWith(`${entry.owner}.`)) {
+ throw new Error(`DataCatalog owner conflict for ${entry.logicalKey}: ${entry.owner}`);
+ }
+ if (!CATALOG_CLASSIFICATIONS.has(entry.classification)
+ || !Number.isInteger(entry.schemaVersion) || entry.schemaVersion !== V2_SCHEMA_VERSION
+ || typeof entry.defaultValue !== 'function'
+ || typeof entry.normalize !== 'function'
+ || typeof entry.validate !== 'function'
+ || typeof entry.export !== 'boolean'
+ || !IMPORT_POLICIES.has(entry.import)) {
+ throw new Error(`DataCatalog incomplete contract: ${entry.logicalKey}`);
}
-
- const transaction = this.indexedDB.transaction(['keyValueStore'], 'readonly');
- const store = transaction.objectStore('keyValueStore');
- const request = store.get(key);
-
- request.onsuccess = () => {
- if (request.result) {
- resolve(request.result.value);
- } else {
- resolve(null);
+ try {
+ const defaultValue = entry.defaultValue();
+ if (!entry.validate(defaultValue) || !entry.validate(entry.normalize(defaultValue))) {
+ throw new Error('invalid default');
}
- };
- request.onerror = () => reject(request.error);
- });
- }
-
- /**
- * 从IndexedDB删除数据
- */
- removeFromIndexedDB(key) {
- return new Promise((resolve, reject) => {
- if (!this.indexedDB) {
- reject(new Error('IndexedDB not available'));
- return;
+ } catch (_) {
+ throw new Error(`DataCatalog invalid default contract: ${entry.logicalKey}`);
}
+ }
+ return true;
+ }
- const transaction = this.indexedDB.transaction(['keyValueStore'], 'readwrite');
- const store = transaction.objectStore('keyValueStore');
- const request = store.delete(key);
+ validateCatalog(definitions);
+ const byKey = new Map(definitions.map((entry) => [entry.logicalKey, entry]));
+ const DataCatalog = Object.freeze({
+ version: V2_SCHEMA_VERSION,
+ list() { return definitions.slice(); },
+ get(logicalKey) {
+ const entry = byKey.get(String(logicalKey || ''));
+ if (!entry) throw new Error(`DataCatalog unknown logical key: ${logicalKey}`);
+ return entry;
+ },
+ has(logicalKey) { return byKey.has(String(logicalKey || '')); },
+ validate: validateCatalog,
+ clone
+ });
- request.onsuccess = () => resolve(true);
- request.onerror = () => reject(request.error);
- });
- }
+ Object.defineProperty(global, '__AppDataV2Catalog', {
+ value: DataCatalog,
+ enumerable: false,
+ configurable: true,
+ writable: false
+ });
+})(typeof window !== 'undefined' ? window : globalThis);
- /**
- * 处理版本升级
- */
- async handleVersionUpgrade(oldVersion, options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- console.log(`Upgrading storage from ${oldVersion || 'unknown'} to ${this.version}`);
- // 在这里处理数据迁移逻辑
- if (!oldVersion) {
- // 首次安装,初始化默认数据
- await this.initializeDefaultData({ skipReady });
- }
+/* ===== js/data/v2/dataKernel.js ===== */
+(function installDataKernel(global) {
+ 'use strict';
- await this.set('system_version', this.version, { skipReady });
+ if (global.AppData) return;
+
+ const catalog = global.__AppDataV2Catalog;
+ if (!catalog) throw new Error('AppData v2 requires DataCatalog before DataKernel');
+
+ const DATABASE_NAME = 'IELTSAtlasDataV2';
+ // Version 2 uses a new schema, but initialization must still import the durable
+ // ExamSystemDB data owned by releases which predate AppData v2.
+ const DATABASE_VERSION = 2;
+ const DOCUMENT_STORE = 'documents';
+ const SYSTEM_STORE = 'system';
+ const ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']);
+ const STORE_NAMES = Object.freeze([DOCUMENT_STORE, SYSTEM_STORE].concat(ENTITY_STORES));
+ const OPERATION_JOURNAL_WINDOW = 500;
+ const COMMIT_CHANNEL_NAME = `${DATABASE_NAME}:committed`;
+ const DEFAULT_IDB_MUTATION_TIMEOUT_MS = 30000;
+ const DEFAULT_IDB_REQUEST_TIMEOUT_MS = 30000;
+ const MAX_TIMER_DELAY_MS = 2147483647;
+ const LEGACY_DATABASE_NAME = 'ExamSystemDB';
+ const LEGACY_STORE_NAME = 'keyValueStore';
+ const LEGACY_EXTERNAL_DATABASE_NAME = 'ExamSystemExternalBackup';
+ const LEGACY_EXTERNAL_STORE_NAME = 'handles';
+ const LEGACY_EXTERNAL_HANDLE_KEY = 'backup_directory';
+ const LEGACY_EXTERNAL_FILENAME = 'practice-backup-latest.json';
+ const LEGACY_UNPREFIXED_WEB_KEYS = Object.freeze([
+ 'practice_records',
+ 'vocab_user_config',
+ 'user_achievements'
+ ]);
- // 执行遗留数据迁移(只运行一次)
- if (!await this.get('migration_completed', null, { skipReady })) {
- console.log('[Storage] 检测到未完成迁移,开始执行...');
- await this.migrateLegacyData({ skipReady });
- } else {
- console.log('[Storage] 迁移已完成,跳过');
- }
+ function clone(value) { return catalog.clone(value); }
+ function nowIso() { return new Date().toISOString(); }
+ function randomId(prefix) {
+ const random = global.crypto && typeof global.crypto.randomUUID === 'function'
+ ? global.crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`;
+ return `${prefix || 'op'}_${random}`;
}
- /**
- * 初始化默认数据
- */
- async initializeDefaultData(options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- const defaultData = {
- settings: {
- theme: 'light',
- notifications: true,
- autoSave: true,
- reminderTime: '19:00'
- },
- exam_index: null,
- learning_goals: []
- };
-
- for (const [key, value] of Object.entries(defaultData)) {
- const existingValue = await this.get(key, null, { skipReady });
- if (existingValue === null || existingValue === undefined) {
- console.log(`[Storage] 初始化默认数据: ${key}`);
- await this.set(key, value, { skipReady });
- } else {
- console.log(`[Storage] 保留现有数据: ${key} (${Array.isArray(existingValue) ? existingValue.length + ' 项' : typeof existingValue})`);
- }
+ class AppDataError extends Error {
+ constructor(code, message, details = {}) {
+ super(message);
+ this.name = 'AppDataError';
+ this.code = code;
+ this.committed = false;
+ this.details = details;
}
}
+ function validation(message, details) { return new AppDataError('VALIDATION', message, details || {}); }
+ function corruption(message, details) { return new AppDataError('CORRUPT_RECORD', message, details || {}); }
- /**
- * 设置存储命名空间
- */
- setNamespace(namespace) {
- if (typeof namespace === 'string' && namespace.trim()) {
- this.prefix = namespace.trim() + '_';
- console.log('[Storage] 命名空间已设置为:', this.prefix);
- } else {
- console.warn('[Storage] 无效的命名空间:', namespace);
+ function normalizeTimeoutMs(value, fallback) {
+ if (value === undefined || value === null || value === '') return fallback;
+ const numeric = Number(value);
+ return Number.isFinite(numeric) && numeric > 0 ? Math.min(numeric, MAX_TIMER_DELAY_MS) : fallback;
+ }
+ function scheduleTimeout(handler, delayMs) {
+ if (typeof global.setTimeout !== 'function') throw new Error('setTimeout unavailable');
+ const handle = global.setTimeout.call(global, handler, delayMs);
+ if (handle === null || handle === undefined) throw new Error('setTimeout did not return a handle');
+ return handle;
+ }
+ function cancelTimeout(handle) {
+ if (handle !== null && handle !== undefined && typeof global.clearTimeout === 'function') {
+ try { global.clearTimeout.call(global, handle); } catch (_) { /* already gone */ }
+ }
+ }
+ function withDeadline(handle, timeoutMs, description, resolve, reject) {
+ let settled = false;
+ let timer = null;
+ const settle = (callback, value) => {
+ if (settled) return;
+ settled = true;
+ cancelTimeout(timer);
+ callback(value);
+ };
+ const expire = (error) => {
+ if (settled) return;
+ settled = true;
+ try { if (handle && typeof handle.abort === 'function') handle.abort(); } catch (_) { /* best effort */ }
+ reject(error);
+ };
+ try {
+ timer = scheduleTimeout(() => expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} timed out after ${timeoutMs}ms`, {
+ operation: description, timeoutMs, reason: 'timeout'
+ })), timeoutMs);
+ } catch (error) {
+ expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} watchdog unavailable`, {
+ operation: description, reason: 'watchdog-unavailable', cause: error && error.message
+ }));
}
+ return { resolve(value) { settle(resolve, value); }, reject(error) { settle(reject, error); } };
}
- /**
- * 生成完整的存储键名
- */
- getKey(key) {
- return this.prefix + key;
+ function canonicalizeJson(value, path = '$', ancestors = new Set()) {
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
+ if (typeof value === 'number') {
+ if (!Number.isFinite(value)) throw validation(`Non-finite number at ${path}`, { path });
+ return Object.is(value, -0) ? 0 : value;
+ }
+ if (typeof value !== 'object' || value === undefined || typeof value === 'bigint' || typeof value === 'function' || typeof value === 'symbol') {
+ throw validation(`Non-JSON value at ${path}`, { path, type: typeof value });
+ }
+ if (ancestors.has(value)) throw validation(`Cyclic data at ${path}`, { path });
+ const prototype = Object.getPrototypeOf(value);
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw validation(`Non-plain object at ${path}`, { path });
+ if (typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function'
+ && Reflect.ownKeys(value).some((key) => typeof key === 'symbol')) {
+ throw validation(`Symbol-keyed property at ${path}`, { path });
+ }
+ ancestors.add(value);
+ try {
+ if (Array.isArray(value)) {
+ return value.map((item, index) => {
+ if (!Object.prototype.hasOwnProperty.call(value, index)) throw validation(`Sparse array entry at ${path}[${index}]`, { path });
+ return canonicalizeJson(item, `${path}[${index}]`, ancestors);
+ });
+ }
+ const result = {};
+ for (const key of Object.keys(value).sort()) {
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
+ if (!descriptor || descriptor.get || descriptor.set) throw validation(`Accessor property at ${path}.${key}`, { path });
+ result[key] = canonicalizeJson(descriptor.value, `${path}.${key}`, ancestors);
+ }
+ return result;
+ } finally { ancestors.delete(value); }
}
-
- createStoredEnvelope(value) {
- const compressedValue = this.compressData(value);
- return JSON.stringify({
- data: compressedValue,
- timestamp: Date.now(),
- version: this.version,
- compressed: compressedValue !== value
- });
+ function stableStringifyCanonical(value) {
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
+ if (Array.isArray(value)) return `[${value.map(stableStringifyCanonical).join(',')}]`;
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyCanonical(value[key])}`).join(',')}}`;
}
-
- parseStoredEnvelope(serializedValue, defaultValue = undefined) {
- if (serializedValue === undefined || serializedValue === null) {
- return defaultValue;
+ function stableStringify(value) { return stableStringifyCanonical(canonicalizeJson(value)); }
+ function checksum(value) {
+ const input = stableStringify(value);
+ let hash = 2166136261;
+ for (let index = 0; index < input.length; index += 1) { hash ^= input.charCodeAt(index); hash = Math.imul(hash, 16777619); }
+ return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`;
+ }
+ function legacyTimestamp(value) {
+ if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return -Infinity;
+ if (Number.isFinite(Number(value))) return Number(value);
+ const parsed = Date.parse(value == null ? '' : String(value));
+ return Number.isFinite(parsed) ? parsed : -Infinity;
+ }
+ function parseLegacyCandidate(value, outerTimestamp) {
+ let parsed = value;
+ let timestamp = legacyTimestamp(outerTimestamp);
+ const hasOuterTimestamp = timestamp !== -Infinity;
+ for (let depth = 0; depth < 3; depth += 1) {
+ if (typeof parsed === 'string') {
+ try { parsed = JSON.parse(parsed); } catch (_) { if (depth === 0) return null; break; }
+ } else if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, 'data')
+ && (Object.prototype.hasOwnProperty.call(parsed, 'version') || Object.prototype.hasOwnProperty.call(parsed, 'compressed'))) {
+ const innerTimestamp = legacyTimestamp(parsed.timestamp);
+ if (!hasOuterTimestamp && innerTimestamp > timestamp) timestamp = innerTimestamp;
+ parsed = parsed.data;
+ } else break;
+ }
+ return { value: clone(parsed), timestamp };
+ }
+ function parseLegacyValue(value) {
+ const candidate = parseLegacyCandidate(value);
+ return candidate ? candidate.value : clone(value);
+ }
+ async function readLegacyValues(indexedDBApi = global.indexedDB, storage = global.localStorage, sessionStorageApi = global.sessionStorage) {
+ const values = {};
+ const candidates = {};
+ let readComplete = true;
+ const consider = (alias, rawValue, timestamp, sourceRank) => {
+ const candidate = parseLegacyCandidate(rawValue, timestamp);
+ if (!candidate) return;
+ const previous = candidates[alias];
+ if (!previous || candidate.timestamp > previous.timestamp
+ || (candidate.timestamp === previous.timestamp && sourceRank < previous.sourceRank)) {
+ candidates[alias] = Object.assign(candidate, { sourceRank });
+ }
+ };
+ if (indexedDBApi && typeof indexedDBApi.open === 'function') {
+ await new Promise((resolve) => {
+ let request;
+ let createdEmptyDatabase = false;
+ try { request = indexedDBApi.open(LEGACY_DATABASE_NAME); } catch (_) { readComplete = false; resolve(); return; }
+ request.onerror = () => { if (!createdEmptyDatabase) readComplete = false; resolve(); };
+ request.onupgradeneeded = () => {
+ createdEmptyDatabase = true;
+ try { request.transaction.abort(); } catch (_) {}
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(LEGACY_STORE_NAME)) { db.close(); resolve(); return; }
+ const tx = db.transaction(LEGACY_STORE_NAME, 'readonly');
+ const keys = tx.objectStore(LEGACY_STORE_NAME).getAllKeys();
+ const rows = tx.objectStore(LEGACY_STORE_NAME).getAll();
+ tx.oncomplete = () => {
+ (keys.result || []).forEach((key, index) => {
+ const row = (rows.result || [])[index];
+ // v1's keyValueStore persisted { key, value, timestamp } rows.
+ const validRow = row && typeof row === 'object'
+ && Object.prototype.hasOwnProperty.call(row, 'key')
+ && String(row.key) === String(key)
+ && Object.prototype.hasOwnProperty.call(row, 'value');
+ if (!validRow) {
+ readComplete = false;
+ return;
+ }
+ consider(String(key).replace(/^exam_system_/, ''), row.value, row.timestamp, 0);
+ });
+ db.close(); resolve();
+ };
+ tx.onerror = tx.onabort = () => { readComplete = false; db.close(); resolve(); };
+ };
+ });
}
- const parsed = JSON.parse(serializedValue);
- return parsed && Object.prototype.hasOwnProperty.call(parsed, 'data')
- ? parsed.data
- : defaultValue;
+ for (const [sourceRank, fallbackStorage] of [storage, sessionStorageApi].entries()) {
+ if (!fallbackStorage || typeof fallbackStorage.key !== 'function') continue;
+ for (let index = 0; index < Number(fallbackStorage.length || 0); index += 1) {
+ const key = fallbackStorage.key(index);
+ if (!key) continue;
+ const alias = key.startsWith('exam_system_')
+ ? key.slice('exam_system_'.length)
+ : (LEGACY_UNPREFIXED_WEB_KEYS.includes(key) ? key : null);
+ if (!alias) continue;
+ try { consider(alias, fallbackStorage.getItem(key), null, sourceRank + 1); } catch (_) { /* inaccessible fallback */ }
+ }
+ }
+ for (const [alias, candidate] of Object.entries(candidates)) values[alias] = candidate.value;
+ Object.defineProperty(values, '__legacyReadComplete', {
+ value: readComplete,
+ enumerable: false,
+ configurable: false,
+ writable: false
+ });
+ return values;
+ }
+ async function readLegacyExternalBackup(indexedDBApi = global.indexedDB) {
+ if (!indexedDBApi || typeof indexedDBApi.open !== 'function') return null;
+ const directoryHandle = await new Promise((resolve) => {
+ let request;
+ let settled = false;
+ const finish = (value) => {
+ if (settled) return;
+ settled = true;
+ resolve(value || null);
+ };
+ try { request = indexedDBApi.open(LEGACY_EXTERNAL_DATABASE_NAME); } catch (_) { finish(null); return; }
+ request.onerror = () => finish(null);
+ request.onupgradeneeded = () => {
+ try { request.transaction.abort(); } catch (_) {}
+ finish(null);
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(LEGACY_EXTERNAL_STORE_NAME)) {
+ db.close(); finish(null); return;
+ }
+ const get = db.transaction(LEGACY_EXTERNAL_STORE_NAME, 'readonly')
+ .objectStore(LEGACY_EXTERNAL_STORE_NAME).get(LEGACY_EXTERNAL_HANDLE_KEY);
+ get.onerror = () => { db.close(); finish(null); };
+ get.onsuccess = () => { db.close(); finish(get.result); };
+ };
+ });
+ if (!directoryHandle || typeof directoryHandle.queryPermission !== 'function') return null;
+ if (await directoryHandle.queryPermission({ mode: 'read' }) !== 'granted') return null;
+ const fileHandle = await directoryHandle.getFileHandle(LEGACY_EXTERNAL_FILENAME, { create: false });
+ const parsed = JSON.parse(await (await fileHandle.getFile()).text());
+ const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.data !== undefined
+ ? parsed.data : parsed;
+ return payload && typeof payload === 'object' && !Array.isArray(payload) ? clone(payload) : null;
+ }
+ function lookupEntry(logicalKey) {
+ if (!catalog.has(logicalKey)) throw validation(`Unknown AppData logical key: ${logicalKey}`, { logicalKey });
+ return catalog.get(logicalKey);
+ }
+ function storeFor(logicalKey) {
+ const entry = lookupEntry(logicalKey);
+ if (entry.classification === 'session') throw validation(`${logicalKey} is not durable kernel data`, { logicalKey });
+ return entry.classification === 'system' ? SYSTEM_STORE : DOCUMENT_STORE;
+ }
+ function makeEnvelope(entry, data, options = {}) {
+ const state = options.state === 'cleared' ? 'cleared' : 'present';
+ if (options.state !== undefined && state !== options.state) throw validation(`Invalid envelope state for ${entry.logicalKey}`);
+ let normalized = null;
+ if (state === 'present') {
+ try { normalized = options.normalized ? data : entry.normalize(canonicalizeJson(data, `$.${entry.logicalKey}`)); } catch (error) {
+ throw validation(`Unable to normalize ${entry.logicalKey}`, { cause: error && error.message });
+ }
+ normalized = canonicalizeJson(normalized, `$.${entry.logicalKey}`);
+ if (!entry.validate(normalized)) throw validation(`Invalid data for ${entry.logicalKey}`, { logicalKey: entry.logicalKey });
+ }
+ const revision = options.revision === undefined ? 1 : Number(options.revision);
+ if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid revision for ${entry.logicalKey}`);
+ const payload = { schemaVersion: entry.schemaVersion, revision, operationId: String(options.operationId || randomId('op')),
+ updatedAt: options.updatedAt || nowIso(), state, data: normalized };
+ payload.checksum = checksum(payload.data);
+ return Object.freeze(payload);
+ }
+ function validateEnvelope(entry, envelope) {
+ try {
+ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)
+ || Number(envelope.schemaVersion) !== Number(entry.schemaVersion)
+ || !Number.isInteger(Number(envelope.revision)) || Number(envelope.revision) < 1
+ || typeof envelope.operationId !== 'string' || !envelope.operationId
+ || typeof envelope.updatedAt !== 'string' || !envelope.updatedAt
+ || (envelope.state !== 'present' && envelope.state !== 'cleared')) return false;
+ const data = canonicalizeJson(envelope.data, `$.${entry.logicalKey}`);
+ return (envelope.state !== 'cleared' || data === null)
+ && (envelope.state !== 'present' || entry.validate(data)) && envelope.checksum === checksum(data);
+ } catch (_) { return false; }
+ }
+ function operationId(value) {
+ if (value === undefined || value === null || value === '') return randomId('mutation');
+ if (typeof value !== 'string' || !value.trim()) throw validation('operationId must be a non-empty string');
+ return value;
}
-
- readWebStorageValue(storage, storageKey) {
- if (!storage || typeof storage.getItem !== 'function') {
- return null;
+ function expectedRevision(value, label) {
+ if (value === undefined || value === null) return null;
+ const revision = Number(value);
+ if (!Number.isInteger(revision) || revision < 0) throw validation(`Invalid expectedRevision for ${label}`);
+ return revision;
+ }
+ function compactJournal(journal) {
+ const ranked = Object.entries(journal).sort((left, right) => Number(right[1].sequence) - Number(left[1].sequence));
+ for (let index = OPERATION_JOURNAL_WINDOW; index < ranked.length; index += 1) delete journal[ranked[index][0]];
+ }
+ function readJournal(row) {
+ const envelope = row && row.envelope;
+ return envelope && envelope.state === 'present' && envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data)
+ ? clone(envelope.data) : {};
+ }
+ function journalResult(journal, spec) {
+ const existing = journal[spec.operationId];
+ if (!existing) return null;
+ if (existing.fingerprint !== spec.fingerprint || !existing.receipt) {
+ throw new AppDataError('CONFLICT', `operationId is already bound to another request: ${spec.operationId}`, { operationId: spec.operationId });
+ }
+ return clone(existing.receipt);
+ }
+ function writeJournal(journal, spec, receipt) {
+ const sequence = Object.values(journal).reduce((maximum, item) => Math.max(maximum, Number(item.sequence) || 0), 0) + 1;
+ journal[spec.operationId] = { fingerprint: spec.fingerprint, receipt: clone(receipt), sequence, committedAt: nowIso() };
+ compactJournal(journal);
+ return journal;
+ }
+ function putJournal(tx, currentRow, journal, spec, receipt) {
+ const current = currentRow && currentRow.envelope;
+ const envelope = makeEnvelope(lookupEntry('system.operationJournal'), writeJournal(journal, spec, receipt), {
+ revision: current ? Number(current.revision) + 1 : 1,
+ operationId: spec.operationId,
+ normalized: true
+ });
+ tx.objectStore(SYSTEM_STORE).put({ logicalKey: 'system.operationJournal', envelope: canonicalizeJson(envelope) });
+ }
+
+ class IndexedDBDriver {
+ constructor(indexedDBApi, options) {
+ this.indexedDB = indexedDBApi;
+ this.db = null;
+ this.mutationTimeoutMs = options.mutationTimeoutMs;
+ this.requestTimeoutMs = options.requestTimeoutMs;
+ }
+ async initialize() {
+ if (!this.indexedDB || typeof this.indexedDB.open !== 'function') throw new Error('IndexedDB unavailable');
+ this.db = await new Promise((resolve, reject) => {
+ const request = this.indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
+ let abandoned = false;
+ let settle;
+ request.onsuccess = () => { if (abandoned || !settle) { try { request.result.close(); } catch (_) {} } else settle.resolve(request.result); };
+ request.onupgradeneeded = (event) => {
+ const db = request.result;
+ if (event.oldVersion < 2) {
+ for (const name of ['authoritative', 'derived']) {
+ if (db.objectStoreNames.contains(name)) db.deleteObjectStore(name);
+ }
+ }
+ for (const name of STORE_NAMES) {
+ if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, { keyPath: name === DOCUMENT_STORE || name === SYSTEM_STORE ? 'logicalKey' : 'recordId' });
+ }
+ };
+ settle = withDeadline({ abort() { abandoned = true; } }, this.requestTimeoutMs, 'open', resolve, reject);
+ request.onerror = () => settle.reject(request.error || new Error('Unable to open IndexedDB'));
+ request.onblocked = () => {
+ abandoned = true;
+ settle.reject(new Error('IndexedDB upgrade blocked'));
+ };
+ });
+ this.db.onversionchange = () => this.close();
+ return this;
}
- try {
- return storage.getItem(storageKey);
- } catch (_) {
- return null;
+ close() { const db = this.db; this.db = null; try { if (db) db.close(); } catch (_) {} }
+ _open() { if (!this.db) throw new Error('IndexedDB connection closed'); }
+ _transaction(stores, mode, description, work, mutation = false) {
+ this._open();
+ return new Promise((resolve, reject) => {
+ let failure = null;
+ let value;
+ let tx;
+ try { tx = this.db.transaction(stores, mode); } catch (error) { reject(error); return; }
+ const settle = withDeadline(tx, mutation ? this.mutationTimeoutMs : this.requestTimeoutMs, description, resolve, reject);
+ tx.oncomplete = () => settle.resolve(clone(value));
+ tx.onerror = (event) => {
+ const requestError = event && event.target && event.target.error;
+ const transactionError = tx.error;
+ if (requestError || transactionError) {
+ failure = failure || requestError || transactionError;
+ }
+ };
+ tx.onabort = () => settle.reject(failure || tx.error || new Error(`IndexedDB ${description} aborted`));
+ const fail = (error) => { failure = failure || error; try { tx.abort(); } catch (_) {} };
+ try { work(tx, (result) => { value = result; }, fail); } catch (error) { fail(error); }
+ });
+ }
+ readEnvelope(logicalKey) {
+ const store = storeFor(logicalKey);
+ return this._transaction([store], 'readonly', `read ${logicalKey}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).get(logicalKey);
+ request.onsuccess = () => done(request.result ? request.result.envelope : null);
+ request.onerror = () => fail(request.error || new Error(`Read failed: ${logicalKey}`));
+ });
+ }
+ readEntity(store, recordId) {
+ return this._transaction([store], 'readonly', `read ${store}/${recordId}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).get(recordId);
+ request.onsuccess = () => done(request.result || null);
+ request.onerror = () => fail(request.error || new Error('Entity read failed'));
+ });
+ }
+ readPracticeSnapshot(recordIds = null, options = {}) {
+ const stores = Array.isArray(options.stores) && options.stores.length
+ ? Array.from(new Set(options.stores.map((store) => entityStore(store))))
+ : ENTITY_STORES.slice();
+ const requested = recordIds === null || recordIds === undefined
+ ? null
+ : new Set((Array.isArray(recordIds) ? recordIds : [recordIds])
+ .map((value) => String(value || ''))
+ .filter(Boolean));
+ return this._transaction(stores, 'readonly', 'read practice snapshot', (tx, done, fail) => {
+ const result = Object.fromEntries(stores.map((store) => [store, []]));
+ let remaining = stores.length;
+ const finishStore = (store, rows) => {
+ result[store] = (rows || []).filter((row) => !requested || requested.has(String(row && row.recordId || '')));
+ remaining -= 1;
+ if (!remaining) done(result);
+ };
+ for (const store of stores) {
+ const objectStore = tx.objectStore(store);
+ const request = requested && requested.size === 1
+ ? objectStore.get(Array.from(requested)[0])
+ : objectStore.getAll();
+ request.onsuccess = () => {
+ const rows = requested && requested.size === 1
+ ? (request.result ? [request.result] : [])
+ : request.result;
+ finishStore(store, rows);
+ };
+ request.onerror = () => fail(request.error || new Error(`Practice snapshot read failed: ${store}`));
+ }
+ });
+ }
+ listEntities(store) {
+ return this._transaction([store], 'readonly', `list ${store}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).getAll();
+ request.onsuccess = () => done(request.result || []);
+ request.onerror = () => fail(request.error || new Error('Entity list failed'));
+ });
+ }
+ atomic(spec) {
+ return this._transaction(spec.stores, 'readwrite', `mutation ${spec.operationId}`, (tx, done, fail) => {
+ const journalRequest = tx.objectStore(SYSTEM_STORE).get('system.operationJournal');
+ journalRequest.onerror = () => fail(journalRequest.error || new Error('Journal read failed'));
+ journalRequest.onsuccess = () => {
+ try { spec.apply(tx, journalRequest.result || null, readJournal(journalRequest.result), done, fail); } catch (error) { fail(error); }
+ };
+ }, true);
+ }
+ exportSnapshot(envelopeKeys) {
+ return this._transaction(STORE_NAMES, 'readonly', 'snapshot export', (tx, done, fail) => {
+ const result = { envelopes: {}, entities: {} };
+ let remaining = STORE_NAMES.length;
+ for (const store of STORE_NAMES) {
+ const request = tx.objectStore(store).getAll();
+ request.onerror = () => fail(request.error || new Error(`Snapshot read failed: ${store}`));
+ request.onsuccess = () => {
+ if (store === DOCUMENT_STORE || store === SYSTEM_STORE) {
+ for (const row of request.result || []) if (envelopeKeys(row.logicalKey)) result.envelopes[row.logicalKey] = row.envelope;
+ } else result.entities[store] = request.result || [];
+ remaining -= 1;
+ if (!remaining) done(result);
+ };
+ }
+ });
}
}
- writeWebStorageValue(storage, storageKey, serializedValue) {
- if (!storage || typeof storage.setItem !== 'function') {
- return false;
+ function entityStore(store) {
+ const value = String(store || '');
+ if (!ENTITY_STORES.includes(value)) throw validation(`Unknown entity store: ${value}`, { store: value });
+ return value;
+ }
+ function validateEntityRow(store, row) {
+ if (!row || typeof row !== 'object' || Array.isArray(row)
+ || typeof row.recordId !== 'string' || !row.recordId
+ || !Number.isInteger(Number(row.revision)) || Number(row.revision) < 1
+ || typeof row.operationId !== 'string' || !row.operationId
+ || typeof row.updatedAt !== 'string' || !row.updatedAt) {
+ throw corruption(`Invalid entity row: ${store}`, { store, recordId: row && row.recordId || null });
+ }
+ const data = canonicalizeJson(row.data, `$.${store}.${row.recordId}`);
+ if (row.checksum !== checksum(data)) {
+ throw corruption(`Entity checksum mismatch: ${store}/${row.recordId}`, { store, recordId: row.recordId });
+ }
+ return row;
+ }
+ function normalizeEntityOperation(operation, index) {
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw validation(`Invalid entity operation at index ${index}`);
+ const type = String(operation.type || '');
+ const store = entityStore(operation.store);
+ if (!['upsert', 'delete', 'clear'].includes(type)) throw validation(`Invalid entity operation type: ${type}`);
+ const recordId = type === 'clear' ? null : String(operation.recordId || '');
+ if (type !== 'clear' && !recordId.trim()) throw validation(`Entity operation ${type} requires recordId`);
+ const data = type === 'upsert' ? canonicalizeJson(operation.data, `$.operations[${index}].data`) : null;
+ return { type, store, recordId, data, expectedRevision: expectedRevision(operation.expectedRevision, `${store}/${recordId || '*'}`) };
+ }
+ function receiptFor(operationIdValue, revisions, warnings, pending) {
+ const receipt = { committed: true, revisions, operationId: operationIdValue,
+ derived: { status: pending.length ? 'pending' : 'ready', pending: pending.slice() }, warnings: warnings.slice() };
+ const keys = Object.keys(revisions); if (keys.length === 1) receipt.revision = revisions[keys[0]];
+ return receipt;
+ }
+
+ class DataKernel {
+ constructor(options = {}) {
+ this.driver = null;
+ this.backend = null;
+ this.state = 'created';
+ this.failure = null;
+ this.indexedDB = Object.prototype.hasOwnProperty.call(options, 'indexedDB') ? options.indexedDB : global.indexedDB;
+ this.indexedDBMutationTimeoutMs = normalizeTimeoutMs(options.indexedDBMutationTimeoutMs, DEFAULT_IDB_MUTATION_TIMEOUT_MS);
+ this.indexedDBRequestTimeoutMs = normalizeTimeoutMs(options.indexedDBRequestTimeoutMs, DEFAULT_IDB_REQUEST_TIMEOUT_MS);
+ this.committedListeners = new Set();
+ this.commitChannel = null;
+ this.instanceId = randomId('kernel');
+ this.ready = null;
+ }
+ _initializeCommitChannel() {
+ if (this.commitChannel || typeof global.BroadcastChannel !== 'function') return;
+ try {
+ const channel = new global.BroadcastChannel(COMMIT_CHANNEL_NAME);
+ channel.onmessage = (message) => {
+ const data = message && message.data;
+ if (!data || data.sourceInstanceId === this.instanceId
+ || typeof data.operationId !== 'string' || !Array.isArray(data.targets)) return;
+ this._dispatchCommitted({
+ operationId: data.operationId,
+ targets: clone(data.targets),
+ receipt: data.receipt ? clone(data.receipt) : null,
+ remote: true
+ });
+ };
+ this.commitChannel = channel;
+ } catch (_) {
+ this.commitChannel = null;
+ }
+ }
+ _closeCommitChannel() {
+ const channel = this.commitChannel;
+ this.commitChannel = null;
+ try { if (channel) channel.close(); } catch (_) {}
+ }
+ initialize() {
+ if (this.ready) return this.ready;
+ this.state = 'initializing';
+ this.ready = new IndexedDBDriver(this.indexedDB, {
+ mutationTimeoutMs: this.indexedDBMutationTimeoutMs, requestTimeoutMs: this.indexedDBRequestTimeoutMs
+ }).initialize().then((driver) => {
+ this.driver = driver; this.backend = 'indexeddb-v2'; this.state = 'ready'; this._initializeCommitChannel(); return this;
+ }).catch((error) => { this.state = 'failed'; this.failure = error; this.driver = null; this.backend = null;
+ throw error instanceof AppDataError ? error : new AppDataError('BACKEND_UNAVAILABLE', 'IndexedDB is required for AppData v2', { cause: error && error.message }); });
+ return this.ready;
+ }
+ close() {
+ if (this.driver) this.driver.close();
+ this.driver = null; this.backend = null;
+ this._closeCommitChannel();
+ if (this.state !== 'failed') this.state = 'closed';
+ }
+ _assertReady() {
+ if (this.state === 'failed') throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 backend failed', { cause: this.failure && this.failure.message });
+ if (this.state !== 'ready' || !this.driver) throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 is not initialized');
+ }
+ _latch(error) {
+ const quotaName = String(error && error.name || '').toUpperCase();
+ const quotaCode = String(error && error.code || '').toUpperCase();
+ if (error && (
+ quotaName === 'QUOTAEXCEEDEDERROR'
+ || quotaName === 'NS_ERROR_DOM_QUOTA_REACHED'
+ || quotaCode === 'NS_ERROR_DOM_QUOTA_REACHED'
+ || quotaCode === 'QUOTAEXCEEDEDERROR'
+ || quotaCode === '22'
+ || quotaCode === '1014'
+ )) return new AppDataError('QUOTA_EXCEEDED', 'IndexedDB write failed: storage quota exceeded', { cause: error.message });
+ this.state = 'failed'; this.failure = error; if (this.driver) this.driver.close(); this.driver = null; this.backend = null; this._closeCommitChannel();
+ return new AppDataError('BACKEND_UNAVAILABLE', 'Active IndexedDB backend failed; reload is required', { cause: error && error.message });
+ }
+ onCommitted(listener) {
+ if (typeof listener !== 'function') throw validation('Committed listener must be a function');
+ this.committedListeners.add(listener); return () => this.committedListeners.delete(listener);
+ }
+ _dispatchCommitted(event) {
+ if (!event || !this.committedListeners.size) return;
+ const schedule = typeof global.queueMicrotask === 'function' ? global.queueMicrotask.bind(global) : (callback) => Promise.resolve().then(callback);
+ schedule(() => Array.from(this.committedListeners).forEach((listener) => { try { Promise.resolve(listener(clone(event))).catch(() => {}); } catch (_) {} }));
+ }
+ _notifyCommitted(targets, receipt) {
+ if (!targets.length) return;
+ const event = { operationId: receipt.operationId, targets: clone(targets), receipt: clone(receipt), remote: false };
+ this._dispatchCommitted(event);
+ if (this.commitChannel) {
+ try {
+ this.commitChannel.postMessage({
+ sourceInstanceId: this.instanceId,
+ operationId: event.operationId,
+ targets: event.targets,
+ receipt: event.receipt
+ });
+ } catch (_) { /* cross-realm notification is best effort */ }
+ }
+ }
+ async getEnvelope(logicalKey) {
+ this._assertReady(); const entry = lookupEntry(logicalKey);
+ try { const envelope = await this.driver.readEnvelope(logicalKey); if (envelope && !validateEnvelope(entry, envelope)) throw corruption(`Invalid envelope: ${logicalKey}`, { logicalKey }); return envelope; }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async read(logicalKey, options = {}) {
+ const entry = lookupEntry(logicalKey); const envelope = await this.getEnvelope(logicalKey);
+ const data = !envelope || envelope.state === 'cleared' ? entry.defaultValue() : envelope.data;
+ return options.withMeta ? { data: clone(data), envelope: envelope ? clone(envelope) : null } : clone(data);
+ }
+ _documentSpec(changes, options) {
+ if (!Array.isArray(changes) || (!changes.length && !options.allowNoop && !options.noop)) throw validation('DataKernel.mutate requires changes');
+ if (options.commitGuard !== undefined && typeof options.commitGuard !== 'function') throw validation('commitGuard must be a synchronous function');
+ const opId = operationId(options.operationId); const seen = new Set();
+ const prepared = changes.map((change, index) => {
+ if (!change || typeof change !== 'object' || Array.isArray(change)) throw validation(`Invalid mutation change at index ${index}`);
+ const logicalKey = String(change.logicalKey || ''); const entry = lookupEntry(logicalKey);
+ if (logicalKey === 'system.operationJournal') throw validation('system.operationJournal is managed by DataKernel');
+ if (seen.has(logicalKey)) throw validation(`Duplicate mutation key: ${logicalKey}`); seen.add(logicalKey);
+ const state = change.state === 'cleared' ? 'cleared' : 'present';
+ if (change.state !== undefined && state !== change.state) throw validation(`Invalid mutation state for ${logicalKey}`);
+ if (state === 'cleared' && entry.classification === 'system') throw validation(`${logicalKey} cannot be cleared`);
+ let data = null;
+ if (state === 'present') { try { data = canonicalizeJson(entry.normalize(canonicalizeJson(change.data)), '$.data'); } catch (error) { throw validation(`Unable to normalize ${logicalKey}`, { cause: error && error.message }); } if (!entry.validate(data)) throw validation(`Invalid data for ${logicalKey}`); }
+ return { logicalKey, entry, state, data, expectedRevision: expectedRevision(change.expectedRevision, logicalKey) };
+ });
+ const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings');
+ if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings');
+ const fingerprint = options.intent === undefined
+ ? checksum({ changes: prepared.map((item) => ({ logicalKey: item.logicalKey, state: item.state, data: item.data, expectedRevision: item.expectedRevision })), warnings })
+ : checksum({ mutationType: 'documents', intent: canonicalizeJson(options.intent, '$.intent'), warnings });
+ return {
+ operationId: opId,
+ changes: prepared,
+ pending: [],
+ warnings,
+ fingerprint,
+ commitGuard: typeof options.commitGuard === 'function' ? options.commitGuard : null,
+ stores: Array.from(new Set([SYSTEM_STORE].concat(prepared.map((item) => storeFor(item.logicalKey)))))
+ };
}
- try {
- storage.setItem(storageKey, serializedValue);
- return true;
- } catch (_) {
- return false;
+ async mutate(changes, options = {}) {
+ this._assertReady(); const spec = this._documentSpec(changes, options);
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const assertCommitGuard = () => {
+ if (!spec.commitGuard) return;
+ let allowed = false;
+ try {
+ allowed = spec.commitGuard() === true;
+ } catch (error) {
+ throw new AppDataError('PRECONDITION_FAILED', 'Mutation commit guard threw', {
+ operationId: spec.operationId,
+ cause: error && error.message
+ });
+ }
+ if (!allowed) {
+ throw new AppDataError('PRECONDITION_FAILED', 'Mutation commit guard rejected the write', {
+ operationId: spec.operationId
+ });
+ }
+ };
+ const replay = journalResult(journal, spec);
+ if (replay) {
+ try { assertCommitGuard(); done(replay); } catch (error) { fail(error); }
+ return;
+ }
+ const reads = spec.changes.map((change) => ({ change, request: tx.objectStore(storeFor(change.logicalKey)).get(change.logicalKey) }));
+ let remaining = reads.length;
+ const finish = () => {
+ assertCommitGuard();
+ const revisions = {};
+ for (const item of reads) {
+ const current = item.request.result ? item.request.result.envelope : null;
+ if (current && !validateEnvelope(item.change.entry, current)) throw corruption(`Invalid stored envelope: ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey });
+ const revision = current ? Number(current.revision) : 0;
+ if (item.change.expectedRevision !== null && item.change.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey, expectedRevision: item.change.expectedRevision, actualRevision: revision });
+ const envelope = makeEnvelope(item.change.entry, item.change.data, { state: item.change.state, revision: revision + 1, operationId: spec.operationId, normalized: true });
+ tx.objectStore(storeFor(item.change.logicalKey)).put({ logicalKey: item.change.logicalKey, envelope: canonicalizeJson(envelope) }); revisions[item.change.logicalKey] = envelope.revision;
+ }
+ const receipt = receiptFor(spec.operationId, revisions, spec.warnings, []);
+ putJournal(tx, journalRow, journal, spec, receipt);
+ done(receipt);
+ };
+ if (!remaining) { finish(); return; }
+ for (const item of reads) { item.request.onerror = () => fail(item.request.error || new Error('Mutation read failed')); item.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; }
+ } }));
+ const targets = spec.changes.filter((change) => change.entry.owner !== 'backups' && (change.entry.classification === 'authoritative' || change.entry.classification === 'preference')).map((change) => ({ logicalKey: change.logicalKey, state: change.state, owner: change.entry.owner, classification: change.entry.classification }));
+ this._notifyCommitted(targets, receipt); return receipt;
+ } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD' || error.code === 'PRECONDITION_FAILED')) throw error; throw this._latch(error); }
+ }
+ async journalNoop(options = {}) { return this.mutate([], Object.assign({}, options, { allowNoop: true })); }
+ async readEntity(store, recordId, options = {}) {
+ this._assertReady(); store = entityStore(store); const id = String(recordId || ''); if (!id) throw validation('readEntity requires recordId');
+ try {
+ const row = await this.driver.readEntity(store, id);
+ if (!row) return null;
+ validateEntityRow(store, row);
+ return options.withMeta ? clone(row) : clone(row.data);
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async readPracticeSnapshot(recordIds = null, options = {}) {
+ this._assertReady();
+ const ids = recordIds === null || recordIds === undefined
+ ? null
+ : (Array.isArray(recordIds) ? recordIds : [recordIds])
+ .map((value) => String(value || ''))
+ .filter(Boolean);
+ try {
+ const snapshot = await this.driver.readPracticeSnapshot(ids, options);
+ const result = {};
+ const stores = Array.isArray(options.stores) && options.stores.length
+ ? Array.from(new Set(options.stores.map((store) => entityStore(store))))
+ : ENTITY_STORES;
+ for (const store of stores) {
+ const validRows = (snapshot && Array.isArray(snapshot[store]) ? snapshot[store] : [])
+ .filter((row) => {
+ try { validateEntityRow(store, row); return true; }
+ catch (error) {
+ if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false;
+ throw error;
+ }
+ });
+ result[store] = options.withMeta
+ ? clone(validRows)
+ : validRows.map((row) => clone(row.data));
+ }
+ return result;
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async listEntities(store, options = {}) {
+ this._assertReady(); store = entityStore(store);
+ if (store !== 'practiceSummaries') throw validation('Only practiceSummaries supports listEntities; load details and annotations by recordId');
+ try {
+ const rows = await this.driver.listEntities(store);
+ const validRows = rows.filter((row) => {
+ try { validateEntityRow(store, row); return true; }
+ catch (error) {
+ if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false;
+ throw error;
+ }
+ });
+ return options.withMeta ? clone(validRows) : validRows.map((row) => clone(row.data));
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async mutateEntities(operations, options = {}) {
+ this._assertReady(); if (!Array.isArray(operations) || !operations.length) throw validation('mutateEntities requires operations');
+ const opId = operationId(options.operationId); const items = operations.map(normalizeEntityOperation); const seen = new Set();
+ for (const item of items) { const key = `${item.store}/${item.recordId || '*'}`; if (seen.has(key)) throw validation(`Duplicate entity operation: ${key}`); seen.add(key); }
+ for (const store of ENTITY_STORES) {
+ const scoped = items.filter((item) => item.store === store);
+ if (scoped.some((item) => item.type === 'clear') && scoped.length > 1) {
+ throw validation(`Entity clear cannot be combined with other operations for ${store}`);
+ }
+ }
+ const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings');
+ if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings');
+ const fingerprint = options.intent === undefined
+ ? checksum({ operations: items, warnings })
+ : checksum({ mutationType: 'entities', intent: canonicalizeJson(options.intent, '$.intent'), warnings });
+ const spec = { operationId: opId, warnings, pending: [], fingerprint, stores: Array.from(new Set([SYSTEM_STORE].concat(items.map((item) => item.store)))) };
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const reads = items.filter((item) => item.type !== 'clear').map((item) => ({ item, request: tx.objectStore(item.store).get(item.recordId) })); let remaining = reads.length;
+ const finish = () => { const revisions = {};
+ for (const read of reads) { const current = read.request.result || null; const revision = current ? Number(current.revision) : 0;
+ if (read.item.expectedRevision !== null && read.item.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${read.item.store}/${read.item.recordId}`);
+ const key = `${read.item.store}/${read.item.recordId}`; if (read.item.type === 'delete') { tx.objectStore(read.item.store).delete(read.item.recordId); revisions[key] = revision + 1; } else { const next = { recordId: read.item.recordId, revision: revision + 1, operationId: spec.operationId, updatedAt: nowIso(), data: read.item.data }; next.checksum = checksum(next.data); tx.objectStore(read.item.store).put(next); revisions[key] = next.revision; }
+ }
+ for (const item of items.filter((item) => item.type === 'clear')) { tx.objectStore(item.store).clear(); revisions[`${item.store}/*`] = 0; }
+ const receipt = receiptFor(spec.operationId, revisions, warnings, []); putJournal(tx, journalRow, journal, spec, receipt); done(receipt); };
+ if (!remaining) { try { finish(); } catch (error) { fail(error); } return; }
+ for (const read of reads) { read.request.onerror = () => fail(read.request.error || new Error('Entity mutation read failed')); read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; }
+ } }));
+ this._notifyCommitted(items.map((item) => ({ store: item.store, recordId: item.recordId, type: item.type })), receipt); return receipt;
+ } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
+ }
+ async exportSnapshot(options = {}) {
+ this._assertReady();
+ try {
+ const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys.map((key) => String(key))) : null;
+ const shouldExport = (logicalKey) => {
+ if (!catalog.has(logicalKey)) return false;
+ const entry = lookupEntry(logicalKey);
+ if (selected && !selected.has(logicalKey)) return false;
+ if (entry.export === true) return true;
+ return options.includeSystem === true && entry.classification === 'system';
+ };
+ const data = await this.driver.exportSnapshot(shouldExport);
+ // Full/partial snapshots must be dense for their declared catalog
+ // range. An absent physical row means the catalog default, not an
+ // instruction that future importers should guess about.
+ for (const entry of catalog.list()) {
+ if (!shouldExport(entry.logicalKey)
+ || Object.prototype.hasOwnProperty.call(data.envelopes, entry.logicalKey)) continue;
+ data.envelopes[entry.logicalKey] = makeEnvelope(entry, null, {
+ state: 'cleared',
+ operationId: 'snapshot-default'
+ });
+ }
+ if (Array.isArray(options.entityStores)) {
+ const selectedStores = new Set(options.entityStores.map(entityStore));
+ for (const store of ENTITY_STORES) if (!selectedStores.has(store)) delete data.entities[store];
+ }
+ const payload = { envelopes: data.envelopes, entities: data.entities };
+ return { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: selected ? 'partial' : 'full', createdAt: nowIso(), backend: this.backend, envelopes: data.envelopes, entities: data.entities, checksum: checksum(payload) };
+ } catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async installSnapshot(snapshot, options = {}) {
+ this._assertReady(); const source = snapshot && snapshot.envelopes ? snapshot : { envelopes: snapshot, entities: {} };
+ const envelopes = canonicalizeJson(source.envelopes, '$.envelopes'); const entities = canonicalizeJson(source.entities || {}, '$.entities');
+ if (!envelopes || typeof envelopes !== 'object' || Array.isArray(envelopes) || !entities || typeof entities !== 'object' || Array.isArray(entities)) throw validation('Snapshot is invalid');
+ if (source.checksum && source.checksum !== checksum({ envelopes, entities })) throw validation('Snapshot checksum mismatch');
+ const changes = [];
+ for (const [logicalKey, envelope] of Object.entries(envelopes)) {
+ const entry = lookupEntry(logicalKey);
+ if (entry.classification === 'system' || entry.classification === 'session' || entry.import === 'ignore') continue;
+ if (!validateEnvelope(entry, envelope)) throw validation(`Invalid snapshot envelope: ${logicalKey}`);
+ changes.push({ logicalKey, entry, envelope });
+ }
+ const entityRows = {};
+ for (const store of ENTITY_STORES) {
+ if (!Object.prototype.hasOwnProperty.call(entities, store)) continue;
+ const rows = entities[store];
+ if (!Array.isArray(rows)) throw validation(`Invalid snapshot entities: ${store}`);
+ const ids = new Set();
+ entityRows[store] = rows.map((row) => {
+ if (!row || typeof row !== 'object' || !String(row.recordId || '')) throw validation(`Invalid snapshot entity: ${store}`);
+ const recordId = String(row.recordId);
+ if (ids.has(recordId)) throw validation(`Duplicate snapshot entity: ${store}/${recordId}`);
+ ids.add(recordId);
+ const data = canonicalizeJson(row.data);
+ if (!row.checksum || row.checksum !== checksum(data)) throw validation(`Invalid snapshot entity checksum: ${store}/${recordId}`);
+ const revision = row.revision === undefined ? 1 : Number(row.revision);
+ if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid snapshot entity revision: ${store}/${recordId}`);
+ return { recordId, revision, operationId: String(row.operationId || options.operationId || 'snapshot'), updatedAt: String(row.updatedAt || nowIso()), data, checksum: checksum(data) };
+ });
+ }
+ if (!changes.length && !Object.keys(entityRows).length) throw validation('Snapshot contains no importable data');
+ const resetJournal = options.resetJournal === true;
+ const expectedRevisionToken = options.expectedRevisionToken && typeof options.expectedRevisionToken === 'object'
+ ? canonicalizeJson(options.expectedRevisionToken, '$.expectedRevisionToken')
+ : null;
+ const opId = operationId(options.operationId || randomId('restore')); const spec = { operationId: opId, warnings: [], pending: [], fingerprint: checksum({ envelopes: changes.map((item) => [item.logicalKey, item.envelope]), entities: entityRows, resetJournal, expectedRevisionToken }), stores: STORE_NAMES.slice() };
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const documentChecks = expectedRevisionToken && expectedRevisionToken.documents || {};
+ const entityChecks = expectedRevisionToken && expectedRevisionToken.entities || {};
+ const reads = Object.entries(documentChecks).map(([logicalKey, expected]) => ({
+ kind: 'document', logicalKey, expected, request: tx.objectStore(DOCUMENT_STORE).get(logicalKey)
+ })).concat(Object.entries(entityChecks).map(([store, expected]) => ({
+ kind: 'entities', store, expected, request: tx.objectStore(store).getAll()
+ })));
+ const finish = () => {
+ for (const read of reads) {
+ if (read.kind === 'document') {
+ const current = read.request.result ? read.request.result.envelope : null;
+ const actualRevision = current ? Number(current.revision) : 0;
+ const expectedRevision = Number(read.expected) || 0;
+ if (actualRevision !== expectedRevision) {
+ throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.logicalKey}`, { logicalKey: read.logicalKey, expectedRevision, actualRevision });
+ }
+ } else {
+ const actual = Object.fromEntries((read.request.result || []).map((row) => [String(row.recordId), Number(row.revision) || 0]));
+ const expected = read.expected && typeof read.expected === 'object' ? read.expected : {};
+ const ids = new Set(Object.keys(actual).concat(Object.keys(expected)));
+ for (const recordId of ids) {
+ const current = actual[recordId] || 0;
+ const wanted = Number(expected[recordId]) || 0;
+ if (current !== wanted) {
+ throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.store}/${recordId}`, { store: read.store, recordId });
+ }
+ }
+ }
+ }
+ const revisions = {};
+ for (const item of changes) { tx.objectStore(DOCUMENT_STORE).put({ logicalKey: item.logicalKey, envelope: makeEnvelope(item.entry, item.envelope.data, { state: item.envelope.state, revision: item.envelope.revision, operationId: spec.operationId, normalized: true }) }); revisions[item.logicalKey] = Number(item.envelope.revision); }
+ for (const [store, rows] of Object.entries(entityRows)) {
+ tx.objectStore(store).clear();
+ for (const row of rows) tx.objectStore(store).put(row);
+ }
+ const receipt = receiptFor(spec.operationId, revisions, [], []); putJournal(tx, journalRow, resetJournal ? {} : journal, spec, receipt); done(receipt);
+ };
+ if (!reads.length) { try { finish(); } catch (error) { fail(error); } return; }
+ let remaining = reads.length;
+ for (const read of reads) {
+ read.request.onerror = () => fail(read.request.error || new Error('Snapshot revalidation read failed'));
+ read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } };
+ }
+ } }));
+ const targets = changes
+ .filter((item) => item.entry.owner !== 'backups')
+ .map((item) => ({ logicalKey: item.logicalKey, state: item.envelope.state, owner: item.entry.owner, classification: item.entry.classification }))
+ .concat(Object.keys(entityRows).map((store) => ({ store, recordId: null, type: 'replace' })));
+ this._notifyCommitted(targets, receipt);
+ return receipt;
+ }
+ catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
}
+ status() { return Object.freeze({ state: this.state, backend: this.backend, failure: this.failure ? this.failure.message : null }); }
}
- async writePersistentValue(key, value, options = {}) {
- if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) {
- throw new Error(`Storage.writePersistentValue(${key}) is internal-only`);
- }
- const serializedValue = this.createStoredEnvelope(value);
- const storageKey = this.getKey(key);
+ Object.defineProperty(global, '__AppDataV2Internals', { value: { catalog, DataKernel, AppDataError, makeEnvelope, validateEnvelope, checksum, stableStringify, canonicalizeJson, clone, randomId, nowIso, parseLegacyValue, readLegacyValues, readLegacyExternalBackup, constants: Object.freeze({ DATABASE_NAME, DATABASE_VERSION, DOCUMENT_STORE, SYSTEM_STORE, ENTITY_STORES, OPERATION_JOURNAL_WINDOW }) }, enumerable: false, configurable: true, writable: false });
+})(typeof window !== 'undefined' ? window : globalThis);
- if (this.indexedDB && !this.indexedDBBlocked) {
- await this.setToIndexedDB(storageKey, serializedValue);
- this.dispatchStorageSync(key);
- return true;
- }
- if (this.localStorageAvailable && this.writeWebStorageValue(localStorage, storageKey, serializedValue)) {
- this.mode = 'localStorage';
- this.volatileMode = false;
- this.dispatchStorageSync(key);
- return true;
- }
+/* ===== js/data/v2/appData.js ===== */
+(function installAppData(global) {
+ 'use strict';
- if (this.sessionStorageAvailable && this.writeWebStorageValue(sessionStorage, storageKey, serializedValue)) {
- this.mode = 'sessionStorage';
- this.volatileMode = false;
- this.dispatchStorageSync(key);
- return true;
- }
+ const internals = global.__AppDataV2Internals;
+ if (!internals || typeof internals.DataKernel !== 'function') {
+ throw new Error('AppData v2 requires DataKernel');
+ }
+ const {
+ DataKernel,
+ AppDataError,
+ catalog,
+ clone,
+ randomId,
+ nowIso,
+ checksum
+ } = internals;
+ const kernel = new DataKernel();
+ const importPlans = new Map();
+ const RECOVERY_KEYS = Object.freeze({
+ activeSession: 'recovery.activeSessions',
+ draft: 'recovery.drafts',
+ interrupted: 'recovery.interrupted',
+ rejectedCompletion: 'recovery.rejectedCompletions'
+ });
+ const PREFERENCE_FIELDS = Object.freeze({
+ theme: 'theme', browse: 'browse', timer: 'timer', suite: 'suite', candidateCode: 'candidateCode',
+ resourceBasePrefix: 'resourceBasePrefix', onboarding: 'onboarding', readingDisplay: 'readingDisplay',
+ threeBackground: 'threeBackground', themePortal: 'themePortal', practiceWidget: 'practiceWidget',
+ consent: 'consent', logConfig: 'logConfig'
+ });
+ const PRACTICE_ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']);
- if (this.fallbackStorage) {
- this.fallbackStorage.set(storageKey, serializedValue);
- this.dispatchStorageSync(key);
- return true;
+ function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
+ function asArray(value) { return Array.isArray(value) ? value : []; }
+ function idOf(value, fields) {
+ for (const field of fields) {
+ if (value && value[field] !== undefined && value[field] !== null && value[field] !== '') return String(value[field]);
}
-
- this.volatileMode = true;
- this.mode = 'volatile';
- this.fallbackStorage = this.fallbackStorage || new Map();
- this.fallbackStorage.set(storageKey, serializedValue);
- this.dispatchStorageSync(key);
- return true;
+ return '';
}
- async readPersistentValue(key, defaultValue = undefined, options = {}) {
- if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) {
- throw new Error(`Storage.readPersistentValue(${key}) is internal-only`);
+ function importedLibraryId(value, options = {}) {
+ const id = value === null || value === undefined ? '' : String(value).trim();
+ if (!id && options.nullable) return null;
+ if (!id) throw new AppDataError('VALIDATION', 'Imported library configuration id is required');
+ if (/^exam_index(?:_|$)/.test(id)) {
+ throw new AppDataError('VALIDATION', 'Unsupported library configuration id');
}
- const storageKey = this.getKey(key);
-
- if (this.fallbackStorage && this.fallbackStorage.has(storageKey)) {
- return this.parseStoredEnvelope(this.fallbackStorage.get(storageKey), defaultValue);
+ return id;
+ }
+ function assertObject(value, message) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new AppDataError('VALIDATION', message);
+ }
+ function assertArray(value, message) {
+ if (!Array.isArray(value)) throw new AppDataError('VALIDATION', message);
+ }
+ function jsonValue(value, label = 'value') {
+ try {
+ const serialized = JSON.stringify(value, (_key, current) => {
+ if (typeof current === 'bigint') return String(current);
+ if (typeof current === 'number' && !Number.isFinite(current)) return null;
+ return current;
+ });
+ if (serialized === undefined) return null;
+ return JSON.parse(serialized);
+ } catch (error) {
+ throw new AppDataError('VALIDATION', `${label} must be JSON-serializable`, { cause: error && error.message });
}
-
- if (this.indexedDB && !this.indexedDBBlocked) {
- const serializedValue = await this.getFromIndexedDB(storageKey);
- return this.parseStoredEnvelope(serializedValue, defaultValue);
+ }
+ function operationId(command, prefix, semanticPayload = command) {
+ const id = command && command.operationId ? String(command.operationId) : randomId(prefix);
+ jsonValue(semanticPayload, `${prefix} payload`);
+ return id;
+ }
+ function mutationOptions(command, prefix, semanticPayload, extra = {}) {
+ const source = asObject(command);
+ const payload = jsonValue(semanticPayload, `${prefix} payload`);
+ const intent = { command: prefix, payload };
+ if (Object.prototype.hasOwnProperty.call(source, 'expectedRevision')) {
+ intent.expectedRevision = source.expectedRevision;
}
-
- if (this.localStorageAvailable) {
- return this.parseStoredEnvelope(this.readWebStorageValue(localStorage, storageKey), defaultValue);
+ return Object.assign({}, extra, {
+ operationId: operationId(source, prefix, payload),
+ intent
+ });
+ }
+ function optionsMutationOptions(options, prefix, semanticPayload, extra = {}) {
+ return mutationOptions(asObject(options), prefix, semanticPayload, extra);
+ }
+ function deterministicEntityId(prefix, operation) {
+ return `${prefix}_${checksum({ operationId: String(operation) }).replace(/[^a-z0-9]+/gi, '')}`;
+ }
+ function normalizeAccuracyRatio(value, label = 'accuracy') {
+ if (value === undefined || value === null || value === '') return null;
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) {
+ throw new AppDataError('VALIDATION', `${label} must be between 0 and 100`);
}
+ return numeric > 1 ? numeric / 100 : numeric;
+ }
+ function defaultStats() {
+ return {
+ totalPractices: 0, totalQuestions: 0, correctAnswers: 0, averageAccuracy: 0,
+ reading: { practices: 0, questions: 0, correct: 0, accuracy: 0 },
+ listening: { practices: 0, questions: 0, correct: 0, accuracy: 0 },
+ lastUpdated: nowIso()
+ };
+ }
- if (this.sessionStorageAvailable) {
- return this.parseStoredEnvelope(this.readWebStorageValue(sessionStorage, storageKey), defaultValue);
+ function firstNonNegative(...values) {
+ for (const value of values) {
+ if (value === null || value === undefined || value === '' || typeof value === 'object') continue;
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric;
}
-
- return defaultValue;
+ return null;
}
- async removePersistentValue(key, options = {}) {
- if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) {
- throw new Error(`Storage.removePersistentValue(${key}) is internal-only`);
+ function normalizePracticeScore(record) {
+ const scoreInfo = asObject(record.scoreInfo);
+ const legacyScoreInfo = asObject(asObject(record.realData).scoreInfo);
+ const overloadedAnswers = record.correctAnswers;
+ if (overloadedAnswers && typeof overloadedAnswers === 'object') {
+ record.correctAnswerMap = Object.assign(
+ {},
+ clone(asObject(overloadedAnswers)),
+ clone(asObject(record.correctAnswerMap))
+ );
}
- const storageKey = this.getKey(key);
+ const correct = firstNonNegative(
+ overloadedAnswers,
+ record.correctAnswersCount,
+ scoreInfo.correctAnswers,
+ scoreInfo.correct,
+ legacyScoreInfo.correctAnswers,
+ legacyScoreInfo.correct
+ );
+ if (correct !== null) record.correctAnswers = correct;
+ else if (overloadedAnswers && typeof overloadedAnswers === 'object') record.correctAnswers = 0;
+ const total = firstNonNegative(
+ record.totalQuestions,
+ record.questionCount,
+ scoreInfo.totalQuestions,
+ scoreInfo.total,
+ legacyScoreInfo.totalQuestions,
+ legacyScoreInfo.total
+ );
+ if (total !== null) record.totalQuestions = total;
+ }
- if (this.fallbackStorage) {
- this.fallbackStorage.delete(storageKey);
+ function mergeAnswers(target, source) {
+ if (Array.isArray(source)) {
+ source.forEach((item, index) => {
+ if (!item || typeof item !== 'object') return;
+ const questionId = idOf(item, ['questionId', 'questionNumber', 'id', 'number']) || String(index + 1);
+ const answer = item.answer ?? item.value ?? item.userAnswer ?? item.selectedAnswer;
+ if (answer !== undefined) target[questionId] = clone(answer);
+ });
+ return;
}
-
- if (this.indexedDB && !this.indexedDBBlocked) {
- await this.removeFromIndexedDB(storageKey);
+ for (const [questionId, answer] of Object.entries(asObject(source))) {
+ target[String(questionId)] = clone(answer);
}
+ }
- try { localStorage.removeItem(storageKey); } catch (_) { }
- try { sessionStorage.removeItem(storageKey); } catch (_) { }
- this.dispatchStorageSync(key);
- return true;
+ function normalizePracticeAnswers(record) {
+ const answers = {};
+ const raw = asObject(record.rawData);
+ const rawReal = asObject(raw.realData);
+ const real = asObject(record.realData);
+ for (const source of [
+ rawReal.answerMap, rawReal.answerList, rawReal.answers,
+ raw.answerMap, raw.answerList, raw.answers,
+ real.answerMap, real.answerList, real.answers,
+ record.answerMap, record.answerList, record.answers
+ ]) mergeAnswers(answers, source);
+ if (Object.keys(answers).length) record.answers = answers;
}
- async clearPersistentStorage(options = {}) {
- if (!hasInternalAccessOptions(options)) {
- throw new Error('Storage.clearPersistentStorage is internal-only');
+ function questionTypeErrorCounts(source) {
+ const counts = {};
+ const add = (type, count = 1) => {
+ const key = String(type || '').trim();
+ if (key && count > 0) counts[key] = (counts[key] || 0) + count;
+ };
+ for (const [type, value] of Object.entries(asObject(source && source.questionTypePerformance))) {
+ const metrics = asObject(value);
+ const total = firstNonNegative(metrics.totalQuestions, metrics.total);
+ const correct = firstNonNegative(metrics.correctAnswers, metrics.correct);
+ if (total !== null && correct !== null) add(type, Math.max(0, total - correct));
}
- if (this.fallbackStorage) {
- this.fallbackStorage.clear();
+ for (const detail of Object.values(asObject(asObject(source && source.scoreInfo).details))) {
+ if (detail && detail.isCorrect === false) add(detail.questionType || detail.type);
}
+ return counts;
+ }
- if (this.indexedDB && !this.indexedDBBlocked) {
- const transaction = this.indexedDB.transaction(['keyValueStore'], 'readwrite');
- const store = transaction.objectStore('keyValueStore');
- const request = store.clear();
- await new Promise((resolve, reject) => {
- request.onsuccess = () => resolve();
- request.onerror = () => reject(request.error);
+ function canonicalizeRecord(input) {
+ assertObject(input, 'practice record must be an object');
+ const record = jsonValue(input, 'practice record');
+ record.id = idOf(record, ['id', 'recordId', 'sessionId']) || randomId('record');
+ record.sessionId = idOf(record, ['sessionId']) || record.id;
+ record.timestamp = record.timestamp || record.completedAt || record.date || nowIso();
+ record.completedAt = record.completedAt || record.timestamp;
+ record.type = record.type || record.examType || (record.metadata && record.metadata.type) || 'practice';
+ record.metadata = asObject(record.metadata);
+ if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId;
+ if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId;
+ normalizePracticeAnswers(record);
+ normalizePracticeScore(record);
+ for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) {
+ if (record[field] === undefined || record[field] === null || record[field] === '') continue;
+ const numeric = Number(record[field]);
+ if (!Number.isFinite(numeric) || numeric < 0) throw new AppDataError('VALIDATION', `practice record ${field} must be a non-negative number`);
+ record[field] = numeric;
+ }
+ if (record.accuracy !== undefined) record.accuracy = normalizeAccuracyRatio(record.accuracy, 'practice record accuracy');
+ return jsonValue(record, 'canonical practice record');
+ }
+
+ function lightSuiteEntry(source, fallbackType = null) {
+ const entry = asObject(source);
+ const scoreInfo = asObject(entry.scoreInfo);
+ const realScoreInfo = asObject(asObject(entry.realData).scoreInfo);
+ const metadata = asObject(entry.metadata);
+ const totalQuestions = firstNonNegative(entry.totalQuestions, scoreInfo.totalQuestions, scoreInfo.total, realScoreInfo.totalQuestions, realScoreInfo.total) ?? 0;
+ const correctAnswers = firstNonNegative(entry.correctAnswers, scoreInfo.correctAnswers, scoreInfo.correct, realScoreInfo.correctAnswers, realScoreInfo.correct) ?? 0;
+ const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy;
+ const accuracy = normalizeAccuracyRatio(
+ explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0),
+ 'suite entry accuracy'
+ ) || 0;
+ const percentage = Number(entry.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0;
+ return jsonValue({
+ id: entry.id || null,
+ sessionId: entry.sessionId || null,
+ examId: entry.examId || metadata.examId || null,
+ title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '',
+ type: entry.type || metadata.type || fallbackType,
+ date: entry.date || entry.completedAt || entry.timestamp || null,
+ duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0,
+ totalQuestions,
+ correctAnswers,
+ accuracy,
+ percentage,
+ questionTypeErrorCounts: questionTypeErrorCounts(entry)
+ }, 'suite entry light projection');
+ }
+
+ function lightFromCanonical(source) {
+ const scoreInfo = asObject(source.scoreInfo);
+ const realScoreInfo = asObject(asObject(source.realData).scoreInfo);
+ const metadata = asObject(source.metadata);
+ const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field);
+ const dataSource = hasOwn(source, 'dataSource')
+ ? source.dataSource
+ : (hasOwn(metadata, 'dataSource') ? metadata.dataSource : undefined);
+ const totalQuestions = Number(source.totalQuestions ?? scoreInfo.totalQuestions ?? scoreInfo.total ?? realScoreInfo.totalQuestions ?? realScoreInfo.total ?? 0) || 0;
+ const correctAnswers = Number(source.correctAnswers ?? scoreInfo.correctAnswers ?? scoreInfo.correct ?? realScoreInfo.correctAnswers ?? realScoreInfo.correct ?? 0) || 0;
+ const explicitAccuracy = source.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy;
+ const accuracy = normalizeAccuracyRatio(
+ explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0),
+ 'practice light accuracy'
+ ) || 0;
+ return jsonValue({
+ id: source.id,
+ sessionId: source.sessionId,
+ examId: source.examId || source.metadata.examId || null,
+ title: source.title || source.examTitle || (source.metadata && source.metadata.examTitle) || source.metadata.title || '',
+ type: source.type,
+ mode: source.mode || source.practiceMode || null,
+ timestamp: source.timestamp,
+ completedAt: source.completedAt,
+ date: source.date || source.completedAt || source.timestamp || null,
+ startTime: source.startTime || null,
+ endTime: source.endTime || null,
+ duration: Number(source.duration ?? source.durationSeconds ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0,
+ totalQuestions,
+ correctAnswers,
+ accuracy,
+ percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0,
+ score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null,
+ questionTypeErrorCounts: questionTypeErrorCounts(source),
+ // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined`
+ // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。
+ // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。
+ dataSource,
+ // Summaries are list indexes. Keep only the metadata needed to filter, show a
+ // source label, or locate the originating library; details stay in their entity.
+ metadata: Object.fromEntries([
+ // `source` must stay: PracticeRecordSource uses metadata.source demo markers
+ // (e.g. onboarding-demo) so light/stats/achievements stay consistent with full.
+ 'examId', 'examTitle', 'title', 'type', 'category', 'frequency',
+ 'dataSource', 'source', 'libraryConfigurationId'
+ ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])),
+ suite: source.suite == null ? null : clone(asObject(source.suite)),
+ suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(
+ entry,
+ String(source.type || '').replace(/-suite$/, '') || null
+ ))
+ }, 'practice light projection');
+ }
+
+ function projectLight(record) {
+ if (!record) return null;
+ return lightFromCanonical(canonicalizeRecord(record));
+ }
+
+ function firstNonEmpty(...values) {
+ let first;
+ for (const value of values) {
+ if (value === undefined || value === null) continue;
+ if (first === undefined) first = value;
+ if (Array.isArray(value) && value.length) return clone(value);
+ if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length) return clone(value);
+ if (typeof value !== 'object') return clone(value);
+ }
+ return first === undefined ? {} : clone(first);
+ }
+
+ const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'questionTypeErrorCounts', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries']);
+ const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']);
+
+ function withoutRawData(value) {
+ if (Array.isArray(value)) return value.map(withoutRawData);
+ if (!value || typeof value !== 'object') return clone(value);
+ const clean = {};
+ for (const [key, item] of Object.entries(value)) {
+ if (key !== 'realData' && key !== 'rawData') clean[key] = withoutRawData(item);
+ }
+ return clean;
+ }
+
+ function splitPracticeRecord(input) {
+ const source = canonicalizeRecord(input);
+ const summary = lightFromCanonical(source);
+ const detail = { recordId: source.id };
+ const annotations = { recordId: source.id };
+ for (const [key, value] of Object.entries(source)) {
+ if (key === 'realData' || key === 'rawData' || key === 'answerMap' || key === 'answerList' || SUMMARY_FIELDS.has(key)) continue;
+ if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value);
+ else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => {
+ const next = Object.assign({}, asObject(entry));
+ const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData));
+ for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) {
+ if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]);
+ }
+ const annotation = {};
+ for (const annotationKey of ANNOTATION_FIELDS) {
+ if (hasOwn(next, annotationKey)) { annotation[annotationKey] = next[annotationKey]; delete next[annotationKey]; }
+ if (next.realData && hasOwn(next.realData, annotationKey)) delete next.realData[annotationKey];
+ if (next.rawData && hasOwn(next.rawData, annotationKey)) delete next.rawData[annotationKey];
+ }
+ delete next.realData; delete next.rawData;
+ if (Object.keys(annotation).length) {
+ if (!annotations.suiteEntries) annotations.suiteEntries = {};
+ annotations.suiteEntries[String(next.examId || asObject(next.metadata).examId || next.id || Object.keys(annotations.suiteEntries).length)] = annotation;
+ }
+ return withoutRawData(next);
});
- }
-
- try {
- Object.keys(localStorage)
- .filter((key) => key.startsWith(this.prefix))
- .forEach((key) => localStorage.removeItem(key));
- } catch (_) { }
- try {
- Object.keys(sessionStorage)
- .filter((key) => key.startsWith(this.prefix))
- .forEach((key) => sessionStorage.removeItem(key));
- } catch (_) { }
-
- this.clearBackendPreference();
- window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key: '*' } }));
- return true;
+ else detail[key] = withoutRawData(value);
+ }
+ // Accept the old mirror only as an input normalization boundary; it is never persisted.
+ const realData = asObject(source.realData); const rawData = asObject(source.rawData);
+ for (const key of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) {
+ if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]);
+ }
+ for (const key of ANNOTATION_FIELDS) {
+ if (hasOwn(annotations, key)) continue;
+ if (hasOwn(realData, key)) annotations[key] = withoutRawData(realData[key]);
+ else if (hasOwn(rawData, key)) annotations[key] = withoutRawData(rawData[key]);
+ }
+ return { summary: jsonValue(summary, 'practice summary'), detail: jsonValue(detail, 'practice detail'), annotations: jsonValue(annotations, 'practice annotations') };
+ }
+
+ function joinPracticeRecord(summary, detail, annotations, projection = 'full') {
+ if (!summary) return null;
+ const mode = String(projection || 'full').toLowerCase();
+ const light = clone(summary);
+ if (mode === 'light' || mode === 'summary') return light;
+ const joined = Object.assign({}, light, clone(asObject(detail)));
+ delete joined.recordId;
+ if (mode === 'detail' || mode === 'medium') return jsonValue(joined, 'practice detail projection');
+ const annotationData = asObject(annotations);
+ for (const [key, value] of Object.entries(annotationData)) if (key !== 'recordId' && key !== 'suiteEntries') joined[key] = clone(value);
+ if (Array.isArray(joined.suiteEntries)) {
+ const suiteAnnotations = asObject(annotationData.suiteEntries);
+ joined.suiteEntries = joined.suiteEntries.map((entry) => Object.assign({}, entry, clone(suiteAnnotations[String(entry.examId || asObject(entry.metadata).examId || entry.id)] || {})));
+ }
+ return jsonValue(joined, 'practice full projection');
+ }
+
+ function projectDetail(record) { return joinPracticeRecord(splitPracticeRecord(record).summary, splitPracticeRecord(record).detail, null, 'detail'); }
+
+ // “什么算真实练习记录”只有一份定义(js/data/practiceRecordSource.js)。
+ // 这里必须硬性依赖而不是本地兜底:曾经投影器与 js/main.js 各写一套判定,
+ // 导致演示/种子记录在列表里看不见却计入统计与成就。缺失即启动失败,
+ // 让漏配 bundle 在开发期就暴露,而不是运行时静默退回旧语义。
+ const practiceRecordSource = global.PracticeRecordSource;
+ if (!practiceRecordSource || typeof practiceRecordSource.isRealPracticeRecord !== 'function') {
+ throw new Error('AppData v2 requires PracticeRecordSource (js/data/practiceRecordSource.js)');
+ }
+ const isRealPracticeRecord = practiceRecordSource.isRealPracticeRecord;
+
+ function computeStats(records) {
+ const stats = defaultStats();
+ for (const record of asArray(records).filter(isRealPracticeRecord)) {
+ const summary = projectLight(record);
+ const type = String(summary.type || '').toLowerCase();
+ const target = type.includes('listen') ? stats.listening : stats.reading;
+ stats.totalPractices += 1;
+ stats.totalQuestions += summary.totalQuestions;
+ stats.correctAnswers += summary.correctAnswers;
+ target.practices += 1;
+ target.questions += summary.totalQuestions;
+ target.correct += summary.correctAnswers;
+ }
+ stats.averageAccuracy = stats.totalQuestions ? (stats.correctAnswers / stats.totalQuestions) * 100 : 0;
+ for (const target of [stats.reading, stats.listening]) target.accuracy = target.questions ? (target.correct / target.questions) * 100 : 0;
+ stats.lastUpdated = nowIso();
+ return stats;
}
- /**
- * 压缩数据
- */
- compressData(data) {
- try {
- // 切记:不要压缩数组,避免把列表写坏
- if (Array.isArray(data)) {
- return data;
- }
- // 仅对体积较大的“对象记录”压缩
- if (data && typeof data === 'object') {
- const len = JSON.stringify(data).length;
- if (len > 1000) {
- return this.compressObject(data);
- }
- }
- return data;
- } catch (error) {
- console.warn('[Storage] 数据压缩失败,使用原始数据:', error);
- return data;
- }
+ function validIso(value) {
+ if (value === null || value === undefined || value === '') return null;
+ const time = new Date(value).getTime();
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
}
- /**
- * 压缩对象数据
- */
- compressObject(obj) {
- // 只保留核心字段:用户答案、canonical 正确答案表、正误、得分、正确率、答题时长、答题时间
- const coreFields = [
- 'id', 'examId', 'title', 'category', 'frequency',
- 'score', 'totalQuestions', 'correctAnswers', 'correctAnswerMap', 'accuracy', 'percentage', 'duration',
- 'startTime', 'endTime', 'date', 'sessionId', 'timestamp',
- 'dataSource', 'realData'
- ];
-
- const compressed = {};
+ function practiceType(record) {
+ const metadata = asObject(record.metadata);
+ const hints = [record.type, record.practiceType, metadata.type, metadata.examType, metadata.practiceType,
+ record.examId, record.title, metadata.examId, metadata.title].filter(Boolean).join(' ').toLowerCase();
+ if (hints.includes('listen') || hints.includes('audio') || hints.includes('hearing')) return 'listening';
+ if (hints.includes('read')) return 'reading';
+ return null;
+ }
- // 只保留核心字段
- coreFields.forEach(field => {
- if (obj.hasOwnProperty(field)) {
- compressed[field] = obj[field];
- }
- });
+ function accuracyRatio(record) {
+ const summary = lightFromCanonical(record);
+ const value = Number(summary.accuracy);
+ if (!Number.isFinite(value)) return 0;
+ return Math.max(0, Math.min(1, value > 1 ? value / 100 : value));
+ }
- // 压缩realData,只保留核心内容
- if (obj.realData) {
- compressed.realData = this.compressRealData(obj.realData);
+ function durationSeconds(record) {
+ const scoreInfo = asObject(record.scoreInfo);
+ const realData = asObject(record.realData);
+ const realScoreInfo = asObject(realData.scoreInfo);
+ for (const value of [record.duration, realData.duration, scoreInfo.duration, scoreInfo.timeSpent, realScoreInfo.duration, realScoreInfo.timeSpent]) {
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric;
}
-
- return compressed;
+ return 0;
}
- /**
- * 合并记录数组,避免重复
- * 基于 id 去重,保留最新的记录(按 updatedAt/createdAt/endTime/startTime 多字段回退)
- */
- mergeRecords(current, legacy) {
- if (!Array.isArray(current)) current = [];
- if (!Array.isArray(legacy)) return current;
-
- // canonical 记录的主要时间字段是 updatedAt/createdAt/endTime/startTime,
- // 不保证有顶层 timestamp。用多字段回退取最大时间戳,避免保留旧副本丢新副本。
- const resolveTimestamp = (record) => {
- if (!record || typeof record !== 'object') return 0;
- const candidates = [
- record.updatedAt,
- record.createdAt,
- record.endTime,
- record.startTime,
- record.date,
- record.timestamp
- ];
- for (let i = 0; i < candidates.length; i += 1) {
- const value = candidates[i];
- if (!value) continue;
- const time = new Date(value).getTime();
- if (Number.isFinite(time)) return time;
+ function earlierUnlock(left, right) {
+ const leftIso = validIso(left);
+ const rightIso = validIso(right);
+ if (!leftIso) return rightIso;
+ if (!rightIso) return leftIso;
+ return new Date(leftIso).getTime() <= new Date(rightIso).getTime() ? leftIso : rightIso;
+ }
+
+ function laterUnlock(left, right) {
+ const leftIso = validIso(left);
+ const rightIso = validIso(right);
+ if (!leftIso) return rightIso;
+ if (!rightIso) return leftIso;
+ return new Date(leftIso).getTime() >= new Date(rightIso).getTime() ? leftIso : rightIso;
+ }
+
+ function computeAchievementProgress(records, manual, existing) {
+ const items = asArray(records).filter(isRealPracticeRecord).map(canonicalizeRecord)
+ .map((record, index) => ({
+ record,
+ index,
+ unlockedAt: validIso(record.completedAt || record.timestamp),
+ time: new Date(record.completedAt || record.timestamp).getTime()
+ }))
+ .sort((left, right) => {
+ const leftTime = Number.isFinite(left.time) ? left.time : Number.MAX_SAFE_INTEGER;
+ const rightTime = Number.isFinite(right.time) ? right.time : Number.MAX_SAFE_INTEGER;
+ return leftTime - rightTime || left.index - right.index;
+ });
+ const candidates = {};
+ const setThreshold = (id, list, count) => {
+ if (list.length >= count) candidates[id] = list[count - 1].unlockedAt;
+ };
+ setThreshold('first_step', items, 1);
+ setThreshold('practice_bronze', items, 10);
+ setThreshold('practice_silver', items, 50);
+ setThreshold('practice_gold', items, 100);
+ setThreshold('practice_platinum', items, 200);
+
+ const reading = items.filter((item) => practiceType(item.record) === 'reading');
+ const listening = items.filter((item) => practiceType(item.record) === 'listening');
+ setThreshold('reading_first', reading, 1);
+ setThreshold('reading_bronze', reading, 10);
+ setThreshold('reading_silver', reading, 50);
+ setThreshold('reading_gold', reading, 100);
+ setThreshold('listening_first', listening, 1);
+ setThreshold('listening_bronze', listening, 10);
+ setThreshold('listening_silver', listening, 50);
+ setThreshold('listening_gold', listening, 100);
+ if (reading.length >= 10 && listening.length >= 10) candidates.balanced_foundation = laterUnlock(reading[9].unlockedAt, listening[9].unlockedAt);
+ if (reading.length >= 30 && listening.length >= 30) candidates.balanced_advanced = laterUnlock(reading[29].unlockedAt, listening[29].unlockedAt);
+
+ let cumulativeDuration = 0;
+ let cumulativeAccuracy = 0;
+ let perfectCount = 0;
+ let speedCount = 0;
+ for (let index = 0; index < items.length; index += 1) {
+ const item = items[index];
+ const accuracy = accuracyRatio(item.record);
+ const duration = durationSeconds(item.record);
+ cumulativeDuration += duration;
+ cumulativeAccuracy += accuracy;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_60') && cumulativeDuration >= 3600) candidates.time_focus_60 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_300') && cumulativeDuration >= 18000) candidates.time_focus_300 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_1000') && cumulativeDuration >= 60000) candidates.time_focus_1000 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_stable') && index + 1 >= 10 && cumulativeAccuracy / (index + 1) >= 0.7) candidates.accuracy_stable = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_elite') && index + 1 >= 20 && cumulativeAccuracy / (index + 1) >= 0.85) candidates.accuracy_elite = item.unlockedAt;
+ if (accuracy >= 1) {
+ perfectCount += 1;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_perfect')) candidates.accuracy_perfect = item.unlockedAt;
+ if (perfectCount === 3) candidates.perfect_three = item.unlockedAt;
+ if (perfectCount === 10) candidates.perfect_ten = item.unlockedAt;
+ }
+ if (duration > 0 && duration <= 300 && accuracy > 0.8) {
+ speedCount += 1;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'speed_demon')) candidates.speed_demon = item.unlockedAt;
+ if (speedCount === 3) candidates.speed_three = item.unlockedAt;
+ if (speedCount === 10) candidates.speed_ten = item.unlockedAt;
+ }
+ }
+
+ const dayItems = new Map();
+ for (const item of items) {
+ if (!item.unlockedAt) continue;
+ const day = item.unlockedAt.slice(0, 10);
+ if (!dayItems.has(day)) dayItems.set(day, item.unlockedAt);
+ }
+ const days = Array.from(dayItems.keys()).sort();
+ let streak = 0;
+ let previousDay = null;
+ for (const day of days) {
+ const currentDay = new Date(`${day}T00:00:00.000Z`).getTime();
+ streak = previousDay !== null && currentDay - previousDay === 86400000 ? streak + 1 : 1;
+ previousDay = currentDay;
+ if (streak === 3 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_bronze')) candidates.streak_bronze = dayItems.get(day);
+ if (streak === 7 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_silver')) candidates.streak_silver = dayItems.get(day);
+ if (streak === 30 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_gold')) candidates.streak_gold = dayItems.get(day);
+ if (streak === 60 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_platinum')) candidates.streak_platinum = dayItems.get(day);
+ }
+
+ const progress = {};
+ const mergeUnlocked = (source) => {
+ for (const [rawId, value] of Object.entries(asObject(source))) {
+ if (!value || rawId === 'updatedAt') continue;
+ const id = rawId;
+ const unlockedAt = value && typeof value === 'object' ? validIso(value.unlockedAt) : null;
+ if (!progress[id]) progress[id] = { unlockedAt };
+ else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt);
}
- return 0;
};
+ mergeUnlocked(existing);
+ mergeUnlocked(manual);
+ for (const [id, unlockedAt] of Object.entries(candidates)) {
+ if (!progress[id]) progress[id] = { unlockedAt: validIso(unlockedAt) };
+ else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt);
+ }
+ return jsonValue(progress, 'achievement progress');
+ }
- const mergedMap = new Map();
- [...current, ...legacy].forEach(record => {
- if (record && record.id) {
- const existing = mergedMap.get(record.id);
- if (!existing || (resolveTimestamp(record) > resolveTimestamp(existing))) {
- mergedMap.set(record.id, record);
+ // Entity records are authoritative. Projections are assembled on reads, never cached or
+ // scheduled as follow-up work; this keeps a successful write immediately observable.
+ async function retryMergeConflict(options, task, maxAttempts = 3) {
+ const explicitRevision = hasOwn(options, 'expectedRevision');
+ let lastError;
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ try {
+ return await task();
+ } catch (error) {
+ lastError = error;
+ if (explicitRevision || !error || error.code !== 'CONFLICT' || attempt + 1 >= maxAttempts) {
+ throw error;
}
- } else if (record && record.timestamp) {
- // 如果无 id,使用 timestamp 过滤
- mergedMap.set(record.timestamp, record);
}
- });
-
- return Array.from(mergedMap.values()).sort((a, b) => resolveTimestamp(b) - resolveTimestamp(a));
- }
-
- async listPracticeRecordsCanonical(options = {}) {
- const { skipReady = false } = options;
-
- const api = await this.getPracticeRecordAPI({ skipReady });
- if (api && typeof api.list === 'function') {
- const records = await api.list();
- return Array.isArray(records) ? records : [];
}
+ throw lastError;
+ }
- throw new Error('Storage.listPracticeRecordsCanonical: unified store not ready');
+ async function readCollectionMeta(logicalKey) {
+ const meta = await kernel.read(logicalKey, { withMeta: true });
+ return { items: asArray(meta.data), revision: meta.envelope ? Number(meta.envelope.revision) : 0 };
}
- async replacePracticeRecordsCanonical(records, options = {}) {
- const { skipReady = false, updateStats } = options;
- if (!Array.isArray(records)) {
- throw new Error('Storage.replacePracticeRecordsCanonical requires an array of records');
+ function retainBackupEntries(items, limit = 20, preserveIds = []) {
+ const cap = Math.max(1, Number(limit) || 20);
+ const newestFirst = (left, right) => String(right.timestamp || '').localeCompare(String(left.timestamp || ''));
+ const entries = asArray(items).filter(Boolean).sort(newestFirst);
+ const retained = [];
+ const retainedIds = new Set();
+ const requestedIds = new Set(asArray(preserveIds).map(String).filter(Boolean));
+ for (const item of entries) {
+ const id = String(item.id);
+ if (retained.length >= cap || retainedIds.has(id) || !requestedIds.has(id)) continue;
+ retained.push(item);
+ retainedIds.add(id);
}
-
- const api = await this.getPracticeRecordAPI({ skipReady });
- if (api && typeof api.replace === 'function') {
- // 透传 updateStats 选项:导入/回滚场景同时写入 user_stats,
- // 若此处 recalculateStats 会和并发 writeUserStatsCanonical 竞争,谁后写谁生效。
- // 默认 undefined 让 api.replace 自行决定(保存路径会重算),导入路径传 false 跳过。
- await api.replace(records, { maxRecords: 1000, updateStats });
- return true;
+ for (const item of entries) {
+ const id = String(item.id);
+ if (retained.length >= cap) break;
+ if (retainedIds.has(id)) continue;
+ retained.push(item);
+ retainedIds.add(id);
}
-
- throw new Error('Storage.replacePracticeRecordsCanonical: unified store not ready');
+ return retained;
}
- async writeUserStatsCanonical(stats, options = {}) {
- const { skipReady = false } = options;
- const api = await this.getPracticeRecordAPI({ skipReady });
- if (api && typeof api.writeStats === 'function') {
- return await api.writeStats(stats);
- }
- throw new Error('Storage.writeUserStatsCanonical: unified stats store not ready');
+ function hasOwn(value, key) {
+ return Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
}
- async mergePracticeRecordsCanonical(records, options = {}) {
- if (!Array.isArray(records)) {
- throw new Error('Storage.mergePracticeRecordsCanonical requires an array of records');
- }
- const current = await this.listPracticeRecordsCanonical(options);
- const merged = this.mergeRecords(current, records);
- await this.replacePracticeRecordsCanonical(merged, options);
- return merged;
+ function normalizeLibraryConfigurationId(value) {
+ return importedLibraryId(value, { nullable: true });
}
- /**
- * 压缩realData数据
- */
- compressRealData(realData) {
- const compressed = {
- score: realData.score,
- totalQuestions: realData.totalQuestions,
- accuracy: realData.accuracy,
- percentage: realData.percentage,
- duration: realData.duration,
- answers: realData.answers || {},
- correctAnswerMap: realData.correctAnswerMap || {},
- isRealData: realData.isRealData,
- source: realData.source
- };
+ async function practiceRecordWithLibraryProvenance(source, command, options = {}) {
+ assertObject(source, 'practice record must be an object');
+ const record = jsonValue(source, 'practice record');
+ const metadata = asObject(record.metadata);
+ let configurationId;
- // 压缩答案历史,只保留每个题目的最后一次答案
- if (realData.answerHistory) {
- const latestAnswers = {};
- Object.entries(realData.answerHistory).forEach(([questionId, history]) => {
- if (Array.isArray(history) && history.length > 0) {
- latestAnswers[questionId] = history[history.length - 1];
- }
- });
- compressed.answerHistory = latestAnswers;
+ if (hasOwn(command, 'libraryConfigurationId')) {
+ configurationId = command.libraryConfigurationId;
+ } else if (hasOwn(metadata, 'libraryConfigurationId')) {
+ configurationId = metadata.libraryConfigurationId;
+ } else if (hasOwn(record, 'libraryConfigurationId')) {
+ configurationId = record.libraryConfigurationId;
+ } else {
+ configurationId = await kernel.read('library.activeConfigurationId');
}
- // 压缩交互记录,只保留最近50次
- if (realData.interactions && Array.isArray(realData.interactions)) {
- compressed.interactions = realData.interactions.slice(-50);
- }
+ const normalizedId = normalizeLibraryConfigurationId(configurationId);
+ record.metadata = Object.assign({}, metadata, { libraryConfigurationId: normalizedId });
- // 压缩详细的题目比较信息
- if (realData.answerComparison) {
- const simplifiedComparison = {};
- Object.entries(realData.answerComparison).forEach(([questionId, comparison]) => {
- simplifiedComparison[questionId] = {
- userAnswer: comparison.userAnswer || '',
- isCorrect: typeof comparison.isCorrect === 'boolean' ? comparison.isCorrect : null
- };
+ if (options.includeSuiteEntries && Array.isArray(record.suiteEntries)) {
+ record.suiteEntries = record.suiteEntries.map((entry) => {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry;
+ const next = jsonValue(entry, 'practice suite entry');
+ const entryMetadata = asObject(next.metadata);
+ const entryId = hasOwn(entryMetadata, 'libraryConfigurationId')
+ ? normalizeLibraryConfigurationId(entryMetadata.libraryConfigurationId)
+ : normalizedId;
+ next.metadata = Object.assign({}, entryMetadata, { libraryConfigurationId: entryId });
+ return next;
});
- compressed.answerComparison = simplifiedComparison;
}
- return compressed;
+ return record;
}
- /**
- * 存储数据
- */
- async set(key, value, options = {}) {
- const { skipReady = false } = options;
- const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options);
- await this.waitForInitialization(skipReady);
- try {
- await this.ensureIndexedDBReady();
- if (protectedPublicAccess) {
- throw new Error(`Storage.set(${key}) is disabled; use PracticeRecordAPI`);
- }
- return await this.writePersistentValue(key, value, options);
- } catch (error) {
- console.error('[Storage] set 操作错误:', error);
- this.handleStorageError(key, value, error, options);
- if (protectedPublicAccess) {
- throw error;
- }
- return false;
- }
+ function practiceRecordMatches(record, identities) {
+ const expected = new Set(asArray(identities).map((value) => String(value || '')).filter(Boolean));
+ if (!expected.size || !record || typeof record !== 'object') return false;
+ return ['id', 'recordId', 'sessionId'].some((field) => {
+ const value = record[field];
+ return value !== undefined && value !== null && expected.has(String(value));
+ });
}
- /**
- * 向数组追加新项
- * @param {string} key - 存储键名
- * @param {*} value - 要追加的项
- * @returns {Promise} 成功返回 true,失败返回 false
- */
- async append(key, value, options = {}) {
- const { skipReady = false } = options;
- const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options);
- await this.waitForInitialization(skipReady);
- try {
- await this.ensureIndexedDBReady();
- if (protectedPublicAccess) {
- throw new Error(`Storage.append(${key}) is disabled; use PracticeRecordAPI`);
- }
- let currentList = await this.readPersistentValue(key, [], options);
- if (!Array.isArray(currentList)) {
- currentList = [];
+ function practiceLayerId(row) {
+ return String(row && (row.recordId || row.id || row.sessionId) || '');
+ }
+ async function practiceLayers(recordId, withMeta = false) {
+ const snapshot = await kernel.readPracticeSnapshot([recordId], { withMeta });
+ const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null;
+ return { summary: find('practiceSummaries'), detail: find('practiceDetails'), annotations: find('practiceAnnotations') };
+ }
+ function entityRevision(row) { return row ? Number(row.revision) : 0; }
+ function practiceUpserts(recordId, layers, existing = {}) {
+ return [
+ { type: 'upsert', store: 'practiceSummaries', recordId, data: layers.summary, expectedRevision: entityRevision(existing.summary) },
+ { type: 'upsert', store: 'practiceDetails', recordId, data: layers.detail, expectedRevision: entityRevision(existing.detail) },
+ { type: 'upsert', store: 'practiceAnnotations', recordId, data: layers.annotations, expectedRevision: entityRevision(existing.annotations) }
+ ];
+ }
+ async function joinedPractice(recordId, projection, snapshot = null) {
+ const mode = String(projection || 'full').toLowerCase();
+ const stores = mode === 'light' || mode === 'summary'
+ ? ['practiceSummaries']
+ : (mode === 'detail' || mode === 'medium' ? ['practiceSummaries', 'practiceDetails'] : undefined);
+ const layers = snapshot || await kernel.readPracticeSnapshot([recordId], { stores });
+ const find = (store) => asArray(layers[store]).find((row) => practiceLayerId(row) === String(recordId)) || null;
+ const summary = find('practiceSummaries');
+ if (!summary) return null;
+ if (mode === 'light' || mode === 'summary') return clone(summary);
+ const detail = find('practiceDetails');
+ if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode);
+ return joinPracticeRecord(summary, detail, find('practiceAnnotations'), mode);
+ }
+ const practice = Object.freeze({
+ async list(options = {}) {
+ await ready;
+ const projection = String(options.projection || 'full').toLowerCase();
+ const summaries = await kernel.listEntities('practiceSummaries');
+ if (projection === 'light' || projection === 'summary') return summaries;
+ const stores = projection === 'detail' || projection === 'medium'
+ ? ['practiceSummaries', 'practiceDetails']
+ : undefined;
+ const snapshot = await kernel.readPracticeSnapshot(null, { stores });
+ return (await Promise.all(asArray(snapshot.practiceSummaries)
+ .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))).filter(Boolean);
+ },
+ async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); },
+ async completeAttempt(command) {
+ await ready;
+ const source = command && (command.record || command.attempt) ? (command.record || command.attempt) : command;
+ const mutation = mutationOptions(command, 'practice-complete', source);
+ const recordInput = await practiceRecordWithLibraryProvenance(source, command);
+ if (!idOf(recordInput, ['id', 'recordId', 'sessionId'])) recordInput.id = deterministicEntityId('record', mutation.operationId);
+ const layers = splitPracticeRecord(recordInput); const recordId = layers.summary.id;
+ const receipt = await retryMergeConflict(command || {}, async () => kernel.mutateEntities(
+ practiceUpserts(recordId, layers, await practiceLayers(recordId, true)), mutation));
+ return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') });
+ },
+ async finalizeSuite(command) {
+ await ready; assertObject(command, 'finalizeSuite command is required');
+ const mutation = mutationOptions(command, 'practice-suite', command);
+ const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true });
+ if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId);
+ const layers = splitPracticeRecord(input); const recordId = layers.summary.id;
+ const childIdentities = asArray(command.childRecordIds || command.childSessionIds).map(String);
+ const children = new Set((await kernel.listEntities('practiceSummaries'))
+ .filter((summary) => practiceRecordMatches(summary, childIdentities))
+ .map((summary) => idOf(summary, ['id', 'recordId', 'sessionId'])));
+ children.delete(recordId);
+ const receipt = await retryMergeConflict(command, async () => {
+ const existing = await practiceLayers(recordId, true);
+ const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id })));
+ return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation);
+ });
+ return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') });
+ },
+ async updateAnnotations(command) {
+ await ready; assertObject(command, 'updateAnnotations command is required'); const recordId = String(command.recordId || '');
+ return retryMergeConflict(command, async () => {
+ const current = await practiceLayers(recordId, true); if (!current.summary) throw new AppDataError('VALIDATION', `Unknown practice record: ${recordId}`);
+ if (command.expectedRevision !== undefined && Number(command.expectedRevision) !== entityRevision(current.annotations)) throw new AppDataError('CONFLICT', `Revision conflict for practice annotations ${recordId}`);
+ const annotations = Object.assign({ recordId }, clone(asObject(current.annotations && current.annotations.data)));
+ const detail = clone(asObject(current.detail && current.detail.data)); const examId = String(command.examId || current.summary.data.examId || 'default');
+ if (Array.isArray(detail.suiteEntries) && detail.suiteEntries.length) {
+ if (!detail.suiteEntries.some((entry) => String(entry.examId || asObject(entry.metadata).examId || '') === examId)) throw new AppDataError('VALIDATION', `Suite record ${recordId} does not contain exam ${examId}`);
+ annotations.suiteEntries = Object.assign({}, asObject(annotations.suiteEntries), { [examId]: Object.assign({}, asObject(annotations.suiteEntries)[examId], clone(asObject(command.patch))) });
+ } else {
+ if (current.summary.data.examId && String(current.summary.data.examId) !== examId) throw new AppDataError('VALIDATION', `Record ${recordId} does not match exam ${examId}`);
+ annotations.annotations = Object.assign({}, asObject(annotations.annotations), { [examId]: Object.assign({}, asObject(annotations.annotations)[examId], clone(asObject(command.patch))) });
+ Object.assign(annotations, clone(asObject(command.patch)));
+ }
+ return kernel.mutateEntities([{
+ type: 'upsert',
+ store: 'practiceAnnotations',
+ recordId,
+ data: annotations,
+ expectedRevision: entityRevision(current.annotations)
+ }], mutationOptions(command, 'practice-annotations', command));
+ });
+ },
+ async delete(command) {
+ await ready; const recordId = String(command && (command.recordId || command.id) || command || ''); if (!recordId) throw new AppDataError('VALIDATION', 'practice record id is required');
+ const found = await kernel.readEntity('practiceSummaries', recordId); if (!found) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete', { recordId })), { deletedCount: 0, noop: true });
+ const receipt = await kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId })), mutationOptions(command, 'practice-delete', { recordId }));
+ return Object.assign({}, receipt, { deletedCount: 1 });
+ },
+ async deleteMany(command) {
+ await ready; assertObject(command, 'practice.deleteMany command is required'); const recordIds = Array.from(new Set(asArray(command.recordIds).map(String).filter(Boolean)));
+ if (!recordIds.length) throw new AppDataError('VALIDATION', 'practice.deleteMany requires recordIds'); const summaries = await kernel.listEntities('practiceSummaries'); const ids = recordIds.filter((id) => summaries.some((item) => practiceRecordMatches(item, [id])));
+ if (!ids.length) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete-many', { recordIds })), { deletedCount: 0, noop: true });
+ const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length });
+ },
+ async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); },
+ async listInsights(options = {}) {
+ await ready;
+ const limit = Math.max(1, Math.min(50, Number(options.limit) || 10));
+ const summaries = (await kernel.listEntities('practiceSummaries'))
+ .slice()
+ .sort((left, right) => String(right.date || right.completedAt || right.timestamp || '')
+ .localeCompare(String(left.date || left.completedAt || left.timestamp || '')))
+ .slice(0, limit);
+ return Promise.all(summaries.map(async (summary) => {
+ if (Object.keys(asObject(summary.questionTypeErrorCounts)).length) return clone(summary);
+ const detail = await kernel.readEntity('practiceDetails', summary.id);
+ return jsonValue(Object.assign({}, clone(summary), {
+ questionTypeErrorCounts: questionTypeErrorCounts(detail)
+ }), 'practice insight');
+ }));
+ },
+ async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); },
+ projectLight,
+ projectDetail
+ });
+
+ const settings = Object.freeze({
+ async getAll() { await ready; return kernel.read('settings.values'); },
+ async patch(values, options = {}) {
+ await ready; assertObject(values, 'settings.patch requires an object');
+ const mutation = optionsMutationOptions(options, 'settings-patch', values);
+ return retryMergeConflict(options, async () => {
+ const current = await kernel.read('settings.values', { withMeta: true });
+ return kernel.mutate([{ logicalKey: 'settings.values', data: Object.assign({}, asObject(current.data), clone(values)), expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ });
+ },
+ async reset(options = {}) { await ready; const current = await kernel.read('settings.values', { withMeta: true }); return kernel.mutate([{ logicalKey: 'settings.values', state: 'cleared', expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], optionsMutationOptions(options, 'settings-reset', { reset: true })); }
+ });
+
+ const library = Object.freeze({
+ async listConfigurations() { await ready; return kernel.read('library.configurations'); },
+ async getActive() { await ready; return kernel.read('library.activeConfigurationId'); },
+ async getIndex(configurationId) {
+ await ready;
+ const id = importedLibraryId(configurationId, { nullable: true });
+ if (id === null) return [];
+ const indexes = await kernel.read('library.importedIndexes');
+ return asArray(indexes[id]);
+ },
+ async updateConfiguration(configuration, options = {}) {
+ await ready; assertObject(configuration, 'library.updateConfiguration requires an object');
+ const id = importedLibraryId(idOf(configuration, ['id', 'key', 'configId']));
+ const current = await kernel.read('library.configurations', { withMeta: true });
+ const configs = asArray(current.data);
+ const index = configs.findIndex((item) => idOf(item, ['id', 'key', 'configId']) === id);
+ const next = Object.assign({}, index >= 0 ? configs[index] : {}, clone(configuration), { id, key: id });
+ if (index >= 0) configs[index] = next; else configs.push(next);
+ return kernel.mutate([{ logicalKey: 'library.configurations', data: configs, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-config', configuration));
+ },
+ async activate(configurationId, options = {}) {
+ await ready;
+ const id = importedLibraryId(configurationId, { nullable: true });
+ const current = await kernel.read('library.activeConfigurationId', { withMeta: true });
+ return kernel.mutate([{ logicalKey: 'library.activeConfigurationId', data: id, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-activate', { configurationId: id }));
+ },
+ async import(command) {
+ await ready; assertObject(command, 'library.import requires a command');
+ const id = importedLibraryId(command.id || command.configurationId || randomId('library'));
+ const configsMeta = await kernel.read('library.configurations', { withMeta: true });
+ const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true });
+ const configs = asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id);
+ configs.push(Object.assign({}, asObject(command.configuration), { id, key: id }));
+ const indexes = Object.assign({}, asObject(indexesMeta.data), { [id]: asArray(command.index) });
+ return kernel.mutate([
+ { logicalKey: 'library.configurations', data: configs, expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 },
+ { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 }
+ ], mutationOptions(command, 'library-import', command));
+ },
+ async remove(configurationId, options = {}) {
+ await ready; const id = importedLibraryId(configurationId);
+ const configsMeta = await kernel.read('library.configurations', { withMeta: true });
+ const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true });
+ const activeMeta = await kernel.read('library.activeConfigurationId', { withMeta: true });
+ const indexes = Object.assign({}, asObject(indexesMeta.data)); delete indexes[id];
+ const changes = [
+ { logicalKey: 'library.configurations', data: asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id), expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 },
+ { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 }
+ ];
+ if (String(activeMeta.data || '') === id) {
+ changes.push({ logicalKey: 'library.activeConfigurationId', data: null, expectedRevision: activeMeta.envelope ? activeMeta.envelope.revision : 0 });
}
- currentList.push(value);
- return await this.writePersistentValue(key, currentList, options);
- } catch (error) {
- console.error('[Storage] Append error:', error);
- this.handleStorageError(key, value, error, options);
- if (protectedPublicAccess) {
- throw error;
+ return kernel.mutate(changes, optionsMutationOptions(options, 'library-remove', { configurationId: id }));
+ },
+ async resolveIndex() {
+ await ready;
+ const [activeId, indexes] = await Promise.all([kernel.read('library.activeConfigurationId'), kernel.read('library.importedIndexes')]);
+ return activeId && Array.isArray(asObject(indexes)[activeId]) ? clone(indexes[activeId]) : clone([]);
+ }
+ });
+
+ function recoveryKey(kind) {
+ const key = RECOVERY_KEYS[String(kind || '')];
+ if (!key) throw new AppDataError('VALIDATION', `Unknown recovery kind: ${kind}`);
+ return key;
+ }
+ // Recovery document TTL is an AppData domain rule, not a catalog policy field.
+ const RECOVERY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+ function recoveryTimestamp(item) {
+ for (const field of ['updatedAt', 'lastActivity', 'tempSavedAt', 'timestamp', 'createdAt']) {
+ const parsed = Date.parse(item && item[field]);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return null;
+ }
+ async function pruneRecoveryKey(logicalKey) {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const items = asArray(current.data);
+ const cutoff = Date.now() - RECOVERY_TTL_MS;
+ const firstOwnersById = new Map();
+ items.forEach((item) => {
+ const entityId = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (entityId && !firstOwnersById.has(String(entityId))) {
+ firstOwnersById.set(String(entityId), item);
+ }
+ });
+ const retainedEntityIds = new Set();
+ firstOwnersById.forEach((owner, entityId) => {
+ const timestamp = recoveryTimestamp(owner);
+ if (timestamp === null || timestamp > cutoff) retainedEntityIds.add(entityId);
+ });
+ const retained = items.filter((item) => {
+ const entityId = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (entityId) return retainedEntityIds.has(String(entityId));
+ const timestamp = recoveryTimestamp(item);
+ return timestamp === null || timestamp > cutoff;
+ });
+ if (retained.length === items.length) return items;
+ try {
+ await kernel.mutate([{ logicalKey, data: retained, expectedRevision: current.envelope ? current.envelope.revision : 0 }], {
+ operationId: randomId('recovery-ttl')
+ });
+ return retained;
+ } catch (error) {
+ if (!(error instanceof AppDataError) || error.code !== 'CONFLICT' || attempt === 2) throw error;
}
- return false;
}
+ return kernel.read(logicalKey);
+ }
+ async function cleanupExpiredRecovery() {
+ for (const logicalKey of Object.values(RECOVERY_KEYS)) await pruneRecoveryKey(logicalKey);
+ }
+ const windowSession = Object.freeze({
+ save(name, value) {
+ if (!global.sessionStorage) throw new AppDataError('BACKEND_UNAVAILABLE', 'sessionStorage unavailable');
+ const logicalName = String(name || 'default');
+ const payload = { schemaVersion: catalog.version, updatedAt: nowIso(), data: clone(value) };
+ global.sessionStorage.setItem(`ielts_atlas:v2:session:${logicalName}`, JSON.stringify(payload));
+ return true;
+ },
+ get(name) {
+ if (!global.sessionStorage) return null;
+ const raw = global.sessionStorage.getItem(`ielts_atlas:v2:session:${String(name || 'default')}`);
+ if (!raw) return null;
+ const payload = JSON.parse(raw);
+ return payload && payload.schemaVersion === catalog.version ? clone(payload.data) : null;
+ },
+ discard(name) {
+ if (global.sessionStorage) global.sessionStorage.removeItem(`ielts_atlas:v2:session:${String(name || 'default')}`);
+ return true;
+ }
+ });
+
+ const recoveryMutationTails = new Map();
+ function enqueueRecoveryMutation(logicalKey, task) {
+ const previous = recoveryMutationTails.get(logicalKey) || Promise.resolve();
+ const result = previous.then(task, task);
+ recoveryMutationTails.set(logicalKey, result.catch(() => undefined));
+ return result;
}
- async get(key, defaultValue = null, options = {}) {
- const { skipReady = false, skipPracticeCoreRedirect = false } = options;
- const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options);
- await this.waitForInitialization(skipReady);
- try {
- await this.ensureIndexedDBReady();
- if (protectedPublicAccess) {
- return await this.readProtectedDataKey(key, defaultValue, options);
- }
- return await this.readPersistentValue(key, defaultValue, options);
- } catch (error) {
- console.error('Storage get error:', error);
- if (protectedPublicAccess) {
- throw error;
+ async function readRecovery(kind, id) {
+ await ready;
+ const firstItemsById = new Set();
+ const tombstonedFirstItems = new Set();
+ const items = (await pruneRecoveryKey(recoveryKey(kind))).filter((item) => {
+ const entityId = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (entityId) {
+ const normalizedId = String(entityId);
+ if (!firstItemsById.has(normalizedId)) {
+ firstItemsById.add(normalizedId);
+ if (item && item._recoveryTombstone === true) {
+ tombstonedFirstItems.add(normalizedId);
+ }
+ } else if (tombstonedFirstItems.has(normalizedId)) {
+ // saveRecovery/discardRecovery use findIndex over the raw collection.
+ // If that exact first owner is a tombstone, never expose a later
+ // duplicate as a writable entity. Non-tombstone duplicates remain
+ // visible because recovery reconciliation consumes their raw marker
+ // metadata while independently honoring first-owner CAS semantics.
+ return false;
+ }
}
- return defaultValue;
+ return !(item && item._recoveryTombstone === true);
+ });
+ return id == null ? items : items.find((item) => idOf(item, ['id', 'sessionId', 'recordId']) === String(id)) || null;
+ }
+ async function readRecoveryFence(kind, id) {
+ await ready;
+ const normalizedId = String(id ?? '');
+ if (!normalizedId) {
+ return { id: normalizedId, exists: false, tombstoned: false, revision: 0 };
+ }
+ const items = await pruneRecoveryKey(recoveryKey(kind));
+ const owner = items.find((item) => (
+ idOf(item, ['id', 'sessionId', 'recordId']) === normalizedId
+ ));
+ if (!owner) {
+ return { id: normalizedId, exists: false, tombstoned: false, revision: 0 };
}
+ return {
+ id: normalizedId,
+ exists: true,
+ tombstoned: owner._recoveryTombstone === true,
+ revision: recoveryEntityRevision(owner)
+ };
}
-
- /**
- * 删除数据
- */
- async remove(key, options = {}) {
- const { skipReady = false } = options;
- const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options);
- await this.waitForInitialization(skipReady);
+ function expectedRecoveryEntityRevision(options = {}) {
+ if (!Object.prototype.hasOwnProperty.call(options, 'expectedEntityRevision')) return null;
+ const revision = Number(options.expectedEntityRevision);
+ if (!Number.isSafeInteger(revision) || revision < 0) {
+ throw new AppDataError('VALIDATION', 'recovery expectedEntityRevision must be a non-negative safe integer');
+ }
+ return revision;
+ }
+ function recoveryEntityRevision(item) {
+ const revision = Number(item && item.revision);
+ return Number.isSafeInteger(revision) && revision >= 0 ? revision : 0;
+ }
+ function recoveryExclusiveGroup(options = {}) {
+ const group = String(options && options.exclusiveGroup || '').trim();
+ if (group.length > 128) {
+ throw new AppDataError('VALIDATION', 'recovery exclusiveGroup must not exceed 128 characters');
+ }
+ return group;
+ }
+ function recoveryEntityExclusiveGroup(item) {
+ const explicit = String(item && item._recoveryExclusiveGroup || '').trim();
+ if (explicit) return explicit;
+ const schema = String(item && item.schema || '').trim();
+ const version = Number(item && item.version);
+ if (version === 2 && schema === 'suite-session-v2') {
+ // Upgrade compatibility: suite recoveries written before group metadata was
+ // introduced still occupy the same logical singleton group.
+ return 'suite-practice';
+ }
+ return '';
+ }
+ function staleRecoveryReceipt(mutation, expectedRevision, actualRevision) {
+ return {
+ committed: false,
+ stale: true,
+ code: 'STALE_RECOVERY_WRITE',
+ operationId: mutation.operationId,
+ expectedEntityRevision: expectedRevision,
+ actualEntityRevision: actualRevision
+ };
+ }
+ function guardedRecoveryReceipt(mutation) {
+ return {
+ committed: false,
+ stale: true,
+ code: 'STALE_RECOVERY_WRITE',
+ reason: 'COMMIT_GUARD_REJECTED',
+ operationId: mutation.operationId
+ };
+ }
+ async function saveRecovery(kind, value, options = {}) {
+ await ready; assertObject(value, `recovery ${kind} value must be an object`);
+ if (options.commitGuard !== undefined && typeof options.commitGuard !== 'function') {
+ throw new AppDataError('VALIDATION', 'recovery commitGuard must be a synchronous function');
+ }
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-save`, value);
+ const expectedEntityRevision = expectedRecoveryEntityRevision(options);
+ const exclusiveGroup = recoveryExclusiveGroup(options);
+ const key = recoveryKey(kind);
+ const id = idOf(value, ['id', 'sessionId', 'recordId']) || deterministicEntityId('recovery', mutation.operationId);
+ const item = Object.assign({}, clone(value), { id: value.id || id, updatedAt: nowIso() });
+ if (exclusiveGroup) item._recoveryExclusiveGroup = exclusiveGroup;
+ if (expectedEntityRevision !== null && recoveryEntityRevision(item) <= expectedEntityRevision) {
+ throw new AppDataError('VALIDATION', 'recovery entity revision must advance beyond expectedEntityRevision');
+ }
+ let receipt;
try {
- await this.ensureIndexedDBReady();
- if (protectedPublicAccess) {
- throw new Error(`Storage.remove(${key}) is disabled; use PracticeRecordAPI`);
- }
- return await this.removePersistentValue(key, options);
+ receipt = await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id);
+ if (expectedEntityRevision !== null) {
+ const actualEntityRevision = index >= 0 ? recoveryEntityRevision(current.items[index]) : 0;
+ if (actualEntityRevision !== expectedEntityRevision) {
+ return staleRecoveryReceipt(mutation, expectedEntityRevision, actualEntityRevision);
+ }
+ }
+ if (exclusiveGroup) {
+ const seenEntityIds = new Set();
+ const conflicting = current.items.find((entry) => {
+ const entryId = idOf(entry, ['id', 'sessionId', 'recordId']);
+ if (!entryId || seenEntityIds.has(entryId)) return false;
+ seenEntityIds.add(entryId);
+ // AppData CAS always updates the raw first owner for an id. Shadow
+ // duplicates neither conflict with that owner nor become a second
+ // logical group member; a first-owner tombstone hides the whole id.
+ return entryId !== id
+ && entry
+ && entry._recoveryTombstone !== true
+ && recoveryEntityExclusiveGroup(entry) === exclusiveGroup;
+ });
+ if (conflicting) {
+ return {
+ committed: false,
+ stale: true,
+ code: 'RECOVERY_GROUP_CONFLICT',
+ operationId: mutation.operationId,
+ conflictingEntityId: idOf(conflicting, ['id', 'sessionId', 'recordId']) || null
+ };
+ }
+ }
+ if (index >= 0) current.items[index] = item; else current.items.push(item);
+ const kernelOptions = typeof options.commitGuard === 'function'
+ ? Object.assign({}, mutation, { commitGuard: options.commitGuard })
+ : mutation;
+ return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], kernelOptions);
+ }));
} catch (error) {
- console.error('Storage remove error:', error);
- if (protectedPublicAccess) {
- throw error;
+ if (error && error.code === 'PRECONDITION_FAILED') {
+ return guardedRecoveryReceipt(mutation);
}
- return false;
+ throw error;
}
+ if (!receipt || receipt.committed !== true) return receipt;
+ const committedItem = (await kernel.read(key))
+ .find((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id);
+ return Object.assign({}, receipt, { item: clone(committedItem || item) });
}
-
- /**
- * 清空所有数据
- */
- async clear(options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
+ async function discardRecovery(kind, id, options = {}) {
+ await ready;
+ if (options.commitGuard !== undefined && typeof options.commitGuard !== 'function') {
+ throw new AppDataError('VALIDATION', 'recovery commitGuard must be a synchronous function');
+ }
+ const key = recoveryKey(kind);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-discard`, { id: String(id) });
+ const expectedEntityRevision = expectedRecoveryEntityRevision(options);
try {
- await this.ensureIndexedDBReady();
- if (!hasInternalAccessOptions(options)) {
- const api = await this.getPracticeRecordAPI(options);
- if (!api || typeof api.clear !== 'function' || typeof api.resetStats !== 'function') {
- throw new Error('Storage.clear: PracticeRecordAPI clear/resetStats not ready');
+ return await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === String(id));
+ const kernelOptions = typeof options.commitGuard === 'function'
+ ? Object.assign({}, mutation, { commitGuard: options.commitGuard })
+ : mutation;
+ if (expectedEntityRevision !== null) {
+ const actualEntityRevision = index >= 0 ? recoveryEntityRevision(current.items[index]) : 0;
+ if (actualEntityRevision !== expectedEntityRevision) {
+ return staleRecoveryReceipt(mutation, expectedEntityRevision, actualEntityRevision);
+ }
+ const tombstone = {
+ id: String(id),
+ revision: Math.min(Number.MAX_SAFE_INTEGER, actualEntityRevision + 1),
+ _recoveryTombstone: true,
+ discardedAt: Date.now(),
+ updatedAt: nowIso()
+ };
+ if (index >= 0) current.items[index] = tombstone;
+ else current.items.push(tombstone);
+ return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], kernelOptions);
}
- await api.clear({ updateStats: false });
- await api.resetStats();
- }
- return await this.clearPersistentStorage(createInternalAccessOptions(options));
+ const next = current.items.filter((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) !== String(id));
+ return kernel.mutate([{ logicalKey: key, data: next, expectedRevision: current.revision }], kernelOptions);
+ }));
} catch (error) {
- console.error('Storage clear error:', error);
- return false;
+ if (error && error.code === 'PRECONDITION_FAILED') {
+ return guardedRecoveryReceipt(mutation);
+ }
+ throw error;
}
}
-
- /**
- * 检查存储配额是否充足
- */
- async checkStorageQuota(dataSize, options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- try {
- console.log(`[Storage] 检查存储配额,需要空间: ${dataSize} 字节`);
- if (this.fallbackStorage) {
- console.log('[Storage] 内存存储,无配额限制');
- return true; // 内存存储没有配额限制
- }
-
- const storageInfo = await this.getStorageInfo({ skipReady });
- if (!storageInfo) {
- console.warn('[Storage] 无法获取存储信息,拒绝操作');
- return false;
- }
-
- console.log(`[Storage] 当前存储类型: ${storageInfo.type}, 已用: ${storageInfo.used} 字节`);
-
- if (storageInfo.type === 'Hybrid' || storageInfo.type === 'IndexedDB') {
- // 混合存储或IndexedDB没有固定配额限制,但我们仍然检查数据大小
- const maxSize = 105 * 1024 * 1024; // 105MB限制 (localStorage 5MB + IndexedDB 100MB)
- const hasSpace = storageInfo.used + dataSize <= maxSize;
- console.log(`[Storage] Hybrid/IndexedDB 检查: 已用 ${storageInfo.used}, 需要 ${dataSize}, 最大 ${maxSize}, 结果: ${hasSpace}`);
- return hasSpace;
+ async function clearRecovery(kind, options = {}) {
+ await ready;
+ const key = recoveryKey(kind);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-clear`, { kind });
+ return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ if (options.expectedRevision !== undefined && Number(options.expectedRevision) !== current.revision) {
+ throw new AppDataError('CONFLICT', `Revision conflict while clearing recovery ${kind}`, { expectedRevision: options.expectedRevision, actualRevision: current.revision });
}
+ return kernel.mutate([{ logicalKey: key, state: 'cleared', expectedRevision: current.revision }], mutation);
+ }));
+ }
+ async function clearAllRecovery(options = {}) {
+ const results = {};
+ for (const kind of Object.keys(RECOVERY_KEYS)) {
+ results[kind] = await clearRecovery(kind, options);
+ }
+ return results;
+ }
+ function recoveryIdSet(source, kind) {
+ const values = source && Array.isArray(source[kind]) ? source[kind] : [];
+ return new Set(values.map((value) => String(value || '').trim()).filter(Boolean));
+ }
+ async function cleanupRecoveryForRetry(options = {}) {
+ await ready;
+ const preserve = options.preserve && typeof options.preserve === 'object' ? options.preserve : {};
+ const discardable = options.discardable && typeof options.discardable === 'object' ? options.discardable : {};
+ const removedByKind = {};
+ const receipts = {};
+ let removedCount = 0;
+
+ for (const kind of Object.keys(RECOVERY_KEYS)) {
+ const key = recoveryKey(kind);
+ const preservedIds = recoveryIdSet(preserve, kind);
+ const discardableIds = recoveryIdSet(discardable, kind);
+ const result = await enqueueRecoveryMutation(key, () => retryMergeConflict({}, async () => {
+ const current = await readCollectionMeta(key);
+ const cutoff = Date.now() - RECOVERY_TTL_MS;
+ const removedIds = [];
+ const firstOwnersById = new Map();
+ current.items.forEach((item) => {
+ const entityId = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (entityId && !firstOwnersById.has(String(entityId))) {
+ firstOwnersById.set(String(entityId), item);
+ }
+ });
+ const retainedEntityIds = new Set();
+ firstOwnersById.forEach((owner, entityId) => {
+ if (preservedIds.has(entityId)) {
+ retainedEntityIds.add(entityId);
+ return;
+ }
+ const timestamp = recoveryTimestamp(owner);
+ const expired = timestamp !== null && timestamp <= cutoff;
+ const tombstone = owner && owner._recoveryTombstone === true;
+ const explicitlyDiscardable = !tombstone && discardableIds.has(entityId);
+ if (expired || explicitlyDiscardable) {
+ removedIds.push(entityId);
+ } else {
+ retainedEntityIds.add(entityId);
+ }
+ });
+ const retained = current.items.filter((item) => {
+ const id = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (id) return retainedEntityIds.has(String(id));
+ const timestamp = recoveryTimestamp(item);
+ const expired = timestamp !== null && timestamp <= cutoff;
+ const tombstone = item && item._recoveryTombstone === true;
+ if (tombstone && !expired) return true;
+ return !expired;
+ });
+ if (retained.length === current.items.length) {
+ return { receipt: null, removedIds: [] };
+ }
+ const receipt = await kernel.mutate([{
+ logicalKey: key,
+ data: retained,
+ expectedRevision: current.revision
+ }], {
+ operationId: randomId(`recovery-cleanup-${kind}`)
+ });
+ return { receipt, removedIds };
+ }));
+ removedByKind[kind] = result.removedIds;
+ removedCount += result.removedIds.length;
+ if (result.receipt) receipts[kind] = result.receipt;
+ }
- const currentUsage = storageInfo.used;
- const quota = 5 * 1024 * 1024; // 5MB
- const availableSpace = quota - currentUsage;
-
- // 预留20%的缓冲空间
- const bufferSpace = quota * 0.2;
- const safeAvailableSpace = availableSpace - bufferSpace;
+ return {
+ committed: true,
+ removedCount,
+ removedByKind,
+ receipts
+ };
+ }
+ const recovery = Object.freeze({
+ windowSession,
+ async clear(options = {}) { return clearAllRecovery(options); },
+ async cleanupForRetry(options = {}) { return cleanupRecoveryForRetry(options); },
+ async listActiveSessions() { return readRecovery('activeSession'); },
+ async getActiveSession(id) { return readRecovery('activeSession', id); },
+ async getActiveSessionFence(id) { return readRecoveryFence('activeSession', id); },
+ async saveActiveSession(value, options) { return saveRecovery('activeSession', value, options); },
+ async completeActiveSession(id, options) { return discardRecovery('activeSession', id, options); },
+ async discardActiveSession(id, options) { return discardRecovery('activeSession', id, options); },
+ async listDrafts() { return readRecovery('draft'); },
+ async getDraft(id) { return readRecovery('draft', id); },
+ async saveDraft(value, options) { return saveRecovery('draft', value, options); },
+ async discardDraft(id, options) { return discardRecovery('draft', id, options); },
+ async listInterrupted() { return readRecovery('interrupted'); },
+ async getInterrupted(id) { return readRecovery('interrupted', id); },
+ async saveInterrupted(value, options) { return saveRecovery('interrupted', value, options); },
+ async discardInterrupted(id, options) { return discardRecovery('interrupted', id, options); },
+ async listRejectedCompletions() { return readRecovery('rejectedCompletion'); },
+ async getRejectedCompletion(id) { return readRecovery('rejectedCompletion', id); },
+ async saveRejectedCompletion(value, options) { return saveRecovery('rejectedCompletion', value, options); },
+ async discardRejectedCompletion(id, options) { return discardRecovery('rejectedCompletion', id, options); }
+ });
- console.log(`[Storage] localStorage 检查: 当前使用 ${(currentUsage / 1024).toFixed(2)}KB, 总配额 ${quota / 1024}KB, 可用 ${(availableSpace / 1024).toFixed(2)}KB, 安全可用 ${(safeAvailableSpace / 1024).toFixed(2)}KB, 需要 ${(dataSize / 1024).toFixed(2)}KB`);
+ function isImportableEntry(entry) {
+ return entry
+ && entry.classification !== 'system'
+ && entry.classification !== 'session'
+ && entry.import !== 'ignore';
+ }
- const hasSpace = safeAvailableSpace >= dataSize;
- if (!hasSpace) {
- console.warn('[Storage] localStorage 空间不足');
- }
- return hasSpace;
- } catch (error) {
- console.error('[Storage] 配额检查错误:', error);
- return false;
- }
+ function isPlainImportObject(value) {
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
}
- /**
- * 获取存储使用情况
- */
- async getStorageInfo(options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- try {
- if (this.fallbackStorage) {
- return {
- type: 'volatile',
- mode: this.mode,
- volatile: true,
- used: this.fallbackStorage.size,
- available: Infinity
- };
- }
+ function isV2SnapshotShape(parsed) {
+ return isPlainImportObject(parsed)
+ && parsed.format === 'ielts-atlas-data-v2'
+ && isPlainImportObject(parsed.envelopes)
+ && isPlainImportObject(parsed.entities);
+ }
- if (this.indexedDB && !this.indexedDBBlocked) {
- const indexedDBUsed = await this.getIndexedDBUsage();
- return {
- type: 'indexedDB',
- mode: this.mode,
- volatile: false,
- used: indexedDBUsed,
- available: Infinity,
- breakdown: {
- indexedDB: indexedDBUsed
- }
- };
- }
+ const POISONED_V2_WRAPPER_ALIASES = Object.freeze({
+ 'settings.values': Object.freeze(['exam_system_settings', 'exam_system_user_settings', 'exam_system_system_settings']),
+ 'vocab.userConfig': Object.freeze(['exam_system_vocab_user_config']),
+ 'achievements.manual': Object.freeze(['exam_system_user_achievements', 'exam_system_achievement_manual_state'])
+ });
+ const LIBRARY_IMPORT_KEYS = Object.freeze([
+ 'library.configurations',
+ 'library.importedIndexes',
+ 'library.activeConfigurationId'
+ ]);
- if (this.fallbackStorage) {
- return {
- type: 'memory',
- used: this.fallbackStorage.size,
- available: Infinity
- };
+ function canonicalizeV2Import(parsed) {
+ const warnings = [];
+ const repairedKeys = [];
+ const ignoredKeys = [];
+ const envelopes = {};
+ for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) {
+ if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`);
+ const envelope = clone(rawEnvelope);
+ const data = envelope && envelope.state === 'present' ? envelope.data : null;
+ if (logicalKey === 'library.activeConfigurationId' && String(data) === '[object Object]') {
+ ignoredKeys.push(logicalKey);
+ warnings.push('Skipped poisoned active library id');
+ continue;
}
-
- if (this.indexedDB) {
- try {
- // 获取所有存储的使用情况
- const localStorageUsed = this.getLocalStorageUsage();
- const indexedDBUsed = await this.getIndexedDBUsage();
- const totalUsed = localStorageUsed + indexedDBUsed;
-
- return {
- type: 'Hybrid',
- used: totalUsed,
- available: Infinity, // 混合存储没有固定配额
- breakdown: {
- localStorage: localStorageUsed,
- indexedDB: indexedDBUsed
- }
- };
- } catch (error) {
- console.warn('[Storage] 获取混合存储使用情况失败:', error);
- // 降级到localStorage
+ if (isPlainImportObject(data)
+ && Object.prototype.hasOwnProperty.call(data, 'key')
+ && Object.prototype.hasOwnProperty.call(data, 'value')
+ && String(data.key || '').startsWith('exam_system_')) {
+ const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey] || [];
+ const decoded = aliases.includes(String(data.key)) ? internals.parseLegacyValue(data.value) : null;
+ if (!isPlainImportObject(decoded)) {
+ ignoredKeys.push(logicalKey);
+ warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`);
+ continue;
}
+ const overlay = Object.fromEntries(Object.entries(data)
+ .filter(([key]) => key !== 'key' && key !== 'value' && key !== 'timestamp'));
+ envelope.data = Object.assign({}, decoded, overlay);
+ envelope.checksum = checksum(envelope.data);
+ repairedKeys.push(logicalKey);
+ warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`);
}
+ envelopes[logicalKey] = envelope;
+ }
- let used = 0;
- const keys = Object.keys(localStorage);
- keys.forEach(key => {
- if (key.startsWith(this.prefix)) {
- used += localStorage.getItem(key).length;
+ if (parsed.scope === 'full') {
+ const presentLibraryKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key));
+ if (presentLibraryKeys.length && presentLibraryKeys.length !== LIBRARY_IMPORT_KEYS.length) {
+ for (const key of presentLibraryKeys) {
+ delete envelopes[key];
+ ignoredKeys.push(key);
}
- });
-
- return {
- type: 'localStorage',
- used: used,
- available: 5 * 1024 * 1024 - used // 假设5MB限制
- };
- } catch (error) {
- console.error('Storage info error:', error);
- return null;
+ warnings.push('Skipped incomplete library data');
+ }
}
+ const exportableKeys = catalog.list()
+ .filter((entry) => entry.export === true && isImportableEntry(entry))
+ .map((entry) => entry.logicalKey);
+ const missingKeys = parsed.scope === 'full'
+ ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key))
+ : [];
+ const degraded = parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length);
+ return {
+ envelopes,
+ warnings,
+ repairedKeys,
+ ignoredKeys,
+ missingKeys,
+ declaredScope: parsed.scope,
+ effectiveScope: degraded ? 'partial' : parsed.scope,
+ trust: degraded ? 'degraded-partial' : (parsed.scope === 'full' ? 'trusted-full' : 'partial')
+ };
}
- /**
- * 获取localStorage使用情况
- */
- getLocalStorageUsage() {
- try {
- let used = 0;
- const keys = Object.keys(localStorage);
- keys.forEach(key => {
- if (key.startsWith(this.prefix)) {
- used += localStorage.getItem(key).length;
- }
- });
- return used;
- } catch (error) {
- console.error('Get localStorage usage error:', error);
- return 0;
- }
+ function resolveImportReplaceFlags(options = {}) {
+ const source = asObject(options);
+ const practiceMode = String(source.practiceMode || source.mergeMode || '').toLowerCase();
+ const replaceAll = source.replace === true;
+ return {
+ replaceDocuments: replaceAll,
+ // Call sites (practiceRecorder / boot-fallbacks) pass practiceMode replace|merge.
+ replacePractice: replaceAll || practiceMode === 'replace'
+ };
}
- /**
- * 获取IndexedDB使用情况
- */
- getIndexedDBUsage() {
- return new Promise((resolve, reject) => {
- if (!this.indexedDB) {
- reject(new Error('IndexedDB not available'));
- return;
+ function pickFirstRecordArray(candidates) {
+ for (const candidate of asArray(candidates)) {
+ if (Array.isArray(candidate.records) && candidate.records.some(isPlainImportObject)) {
+ return { source: candidate.source, records: candidate.records };
}
-
- const transaction = this.indexedDB.transaction(['keyValueStore'], 'readonly');
- const store = transaction.objectStore('keyValueStore');
- const request = store.getAll();
-
- request.onsuccess = () => {
- const items = request.result;
- let totalSize = 0;
-
- items.forEach(item => {
- if (item.key.startsWith(this.prefix) && item.value) {
- totalSize += item.value.length;
- }
- });
-
- resolve(totalSize);
- };
-
- request.onerror = () => reject(request.error);
- });
+ }
+ return null;
}
/**
- * 清理旧数据
+ * Historical v1 export shapes (opensource / pre-AppData-v2):
+ * - practiceRecorder.exportData: { exportDate, version, practiceRecords, userStats }
+ * - DataBackupManager: { exportInfo, practiceRecords, userStats?, backups? }
+ * - BackupAPI dual schema: practice_records / practiceRecords (+ nested data.*)
+ * - bare array of records, or { records: [...] }
+ * Recognition only — no dual backend and no local store migration.
*/
- async cleanupOldData(options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- try {
- console.log('[Storage] 开始清理旧数据...');
-
- const practiceRecords = await this.listPracticeRecordsCanonical({ skipReady });
- if (practiceRecords.length > 0) {
- console.log(`[Storage] 练习记录数据保留${practiceRecords.length}条记录,跳过压缩以保护答案数据完整性`);
+ function extractLegacyPracticeRecords(payload) {
+ const sources = [];
+ const add = (source, records) => {
+ if (Array.isArray(records) && records.some(isPlainImportObject)) {
+ sources.push({ source, records });
}
+ };
- // 清理错误日志
- const errorLogs = await this.get('injection_errors', [], { skipReady });
- if (errorLogs.length > 20) {
- const logsToKeep = errorLogs.slice(-20); // 保留最近20条
- await this.set('injection_errors', logsToKeep, { skipReady });
- console.log(`[Storage] 已清理错误日志,从${errorLogs.length}条减少到${logsToKeep.length}条`);
+ if (Array.isArray(payload)) {
+ add('(root array)', payload);
+ } else if (isPlainImportObject(payload)) {
+ const preferred = pickFirstRecordArray([
+ { source: 'practice_records', records: payload.practice_records },
+ { source: 'practiceRecords', records: payload.practiceRecords },
+ { source: 'records', records: payload.records }
+ ]);
+ if (preferred) add(preferred.source, preferred.records);
+
+ const data = isPlainImportObject(payload.data) ? payload.data : null;
+ if (data) {
+ const nested = pickFirstRecordArray([
+ { source: 'data.practice_records', records: data.practice_records },
+ { source: 'data.practiceRecords', records: data.practiceRecords }
+ ]);
+ if (nested) add(nested.source, nested.records);
+ else if (isPlainImportObject(data.practice_records)) add('data.practice_records.data', data.practice_records.data);
+ else if (isPlainImportObject(data.practiceRecords)) add('data.practiceRecords.data', data.practiceRecords.data);
+ if (isPlainImportObject(data.exam_system_practice_records)) {
+ add('data.exam_system_practice_records.data', data.exam_system_practice_records.data);
+ }
}
-
- const collectionErrors = await this.get('collection_errors', [], { skipReady });
- if (collectionErrors.length > 20) {
- const logsToKeep = collectionErrors.slice(-20);
- await this.set('collection_errors', logsToKeep, { skipReady });
- console.log(`[Storage] 已清理数据收集错误日志,从${collectionErrors.length}条减少到${logsToKeep.length}条`);
+ if (isPlainImportObject(payload.exam_system_practice_records)) {
+ add('exam_system_practice_records.data', payload.exam_system_practice_records.data);
}
+ }
- // 清理活动会话(保留最近的)
- const activeSessions = await this.get('active_sessions', [], { skipReady });
- const now = Date.now();
- const recentSessions = activeSessions.filter(session => {
- const sessionTime = new Date(session.startTime).getTime();
- const hoursDiff = (now - sessionTime) / (1000 * 60 * 60);
- return hoursDiff < 1; // 只保留1小时内的会话
- });
-
- if (recentSessions.length !== activeSessions.length) {
- await this.set('active_sessions', recentSessions, { skipReady });
- console.log(`[Storage] 已清理过期会话,从${activeSessions.length}个减少到${recentSessions.length}个`);
+ const seen = new Set();
+ const records = [];
+ for (const entry of sources) {
+ for (const item of asArray(entry.records)) {
+ if (!isPlainImportObject(item)) continue;
+ const identity = idOf(item, ['id', 'recordId', 'sessionId']);
+ if (identity) {
+ if (seen.has(identity)) continue;
+ seen.add(identity);
+ }
+ records.push(item);
}
-
- } catch (error) {
- console.error('[Storage] 清理旧数据失败:', error);
}
+ return {
+ records,
+ sources: sources.map((entry) => entry.source)
+ };
}
- /**
- * 迁移遗留数据到新命名空间
- * 只运行一次
- */
- async migrateLegacyData(options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- console.log('[Storage] 开始迁移遗留数据');
- try {
- const legacyKeys = Object.keys(localStorage).filter(k =>
- k === 'practice_records' ||
- k === 'user_progress' ||
- k === 'scores' ||
- k.startsWith('old_prefix_')
+ function entityRowFromLayer(recordId, data, operationId) {
+ const payload = jsonValue(data, 'import practice entity');
+ return {
+ recordId: String(recordId),
+ revision: 1,
+ operationId: String(operationId || `import-${recordId}`),
+ updatedAt: nowIso(),
+ data: payload,
+ checksum: checksum(payload)
+ };
+ }
+
+ function convertLegacyPracticeImport(payload) {
+ const extracted = extractLegacyPracticeRecords(payload);
+ if (!extracted.records.length) {
+ throw new AppDataError(
+ 'VALIDATION',
+ 'Import file is neither a v2 snapshot nor a recognizable v1 practice export'
);
+ }
- if (legacyKeys.length === 0) {
- console.log('[Storage] 无遗留数据需要迁移');
- await this.set('migration_completed', true, { skipReady });
- } else {
- let migratedCount = 0;
- let deferredPracticeMigration = false;
- for (const oldKey of legacyKeys) {
- try {
- const legacyDataStr = localStorage.getItem(oldKey);
- if (!legacyDataStr) continue;
+ const entities = {
+ practiceSummaries: [],
+ practiceDetails: [],
+ practiceAnnotations: []
+ };
+ const warnings = [];
+ let skipped = 0;
- let legacyData;
- try {
- legacyData = JSON.parse(legacyDataStr);
- } catch (parseError) {
- console.warn(`[Storage] 解析遗留数据失败: ${oldKey}`, parseError);
- continue;
- }
+ for (const raw of extracted.records) {
+ try {
+ const layers = splitPracticeRecord(raw);
+ const recordId = layers.summary.id;
+ const operationId = `import-v1-${recordId}`;
+ entities.practiceSummaries.push(entityRowFromLayer(recordId, layers.summary, operationId));
+ entities.practiceDetails.push(entityRowFromLayer(recordId, layers.detail, operationId));
+ entities.practiceAnnotations.push(entityRowFromLayer(recordId, layers.annotations, operationId));
+ } catch (error) {
+ skipped += 1;
+ warnings.push(`Skipped invalid practice record: ${error && error.message ? error.message : error}`);
+ }
+ }
- if (!Array.isArray(legacyData)) {
- console.warn(`[Storage] 遗留数据非数组,跳过: ${oldKey}`);
- continue;
- }
+ if (!entities.practiceSummaries.length) {
+ throw new AppDataError('VALIDATION', 'Import file practice records could not be normalized');
+ }
- if (legacyData.length === 0) {
- console.log('[Storage] 旧数据为空,跳过迁移');
- continue;
- }
+ const accepted = entities.practiceSummaries.length;
+ return {
+ format: 'v1',
+ scope: 'partial',
+ envelopes: {},
+ entities,
+ checksum: null,
+ warnings,
+ practiceSummary: {
+ accepted,
+ importedCount: accepted,
+ skippedCount: skipped,
+ sources: extracted.sources.slice()
+ }
+ };
+ }
- // 对应新键(去除 old_prefix_ 如果存在)
- let newKey = oldKey.replace(/^old_prefix_/, '');
- const isPracticeRecordsKey = newKey === 'practice_records';
- if (isPracticeRecordsKey) {
- await this.mergePracticeRecordsCanonical(legacyData, { skipReady });
- } else {
- const current = await this.get(newKey, [], { skipReady });
- const merged = this.mergeRecords(current, legacyData);
- await this.set(newKey, merged, { skipReady });
- }
+ function parseImportPayload(payload) {
+ let parsed;
+ try { parsed = typeof payload === 'string' ? JSON.parse(payload) : jsonValue(payload, 'import payload'); }
+ catch (error) {
+ if (error instanceof AppDataError) throw error;
+ throw new AppDataError('VALIDATION', 'Import payload is not valid JSON', { cause: error && error.message });
+ }
- // 删除旧键
- localStorage.removeItem(oldKey);
- migratedCount++;
- console.log(`[Storage] 成功迁移并合并数据: ${oldKey} -> ${newKey} (${legacyData.length} 项)`);
- } catch (migrateError) {
- const newKey = oldKey.replace(/^old_prefix_/, '');
- if (newKey === 'practice_records') {
- deferredPracticeMigration = true;
- }
- console.error(`[Storage] 迁移失败: ${oldKey}`, migrateError);
- }
- }
+ // Bare record arrays are a historical import convenience (UI file pickers).
+ if (Array.isArray(parsed)) return convertLegacyPracticeImport(parsed);
+ if (!parsed || typeof parsed !== 'object') throw new AppDataError('VALIDATION', 'Import payload must be an object');
- console.log(`[Storage] 数据迁移完成: ${migratedCount} 个键成功迁移`);
- if (deferredPracticeMigration) {
- console.warn('[Storage] 练习记录迁移已延后,等待 PracticeRecordAPI 就绪后重试');
- } else {
- await this.set('migration_completed', true, { skipReady });
+ if (isV2SnapshotShape(parsed)) {
+ if (Number(parsed.schemaVersion) !== Number(catalog.version)) {
+ throw new AppDataError('VALIDATION', 'Import schema version mismatch');
+ }
+ if (!parsed.checksum || parsed.checksum !== checksum({ envelopes: parsed.envelopes, entities: parsed.entities })) {
+ throw new AppDataError('VALIDATION', 'Import checksum mismatch');
+ }
+ if (parsed.scope !== 'full' && parsed.scope !== 'partial') {
+ throw new AppDataError('VALIDATION', 'Import scope must be full or partial');
+ }
+ const scope = parsed.scope;
+ for (const [store, rows] of Object.entries(parsed.entities)) {
+ if (!PRACTICE_ENTITY_STORES.includes(store) || !Array.isArray(rows)) {
+ throw new AppDataError('VALIDATION', `Invalid import entity store: ${store}`);
+ }
+ for (const row of rows) {
+ if (!row || typeof row !== 'object' || Array.isArray(row) || !String(row.recordId || '')) {
+ throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`);
+ }
}
}
-
- if (!await this.get('my_melody_migration_completed', null, { skipReady })) {
- console.log('[Storage] 检查 MyMelody 遗留键迁移...');
- const canonicalPracticeKey = this.getKey('practice_records');
- console.warn('[Storage] 跳过 MyMelody 遗留键迁移:旧键与 canonical practice_records 键相同,继续迁移会误删当前记录', canonicalPracticeKey);
- await this.set('my_melody_migration_completed', true, { skipReady });
+ if (scope === 'full' && PRACTICE_ENTITY_STORES.some((store) => !Object.prototype.hasOwnProperty.call(parsed.entities, store))) {
+ throw new AppDataError('VALIDATION', 'Full import is missing a practice entity layer');
}
+ const canonical = canonicalizeV2Import(parsed);
+ return {
+ format: 'v2',
+ scope: canonical.effectiveScope,
+ declaredScope: canonical.declaredScope,
+ envelopes: canonical.envelopes,
+ entities: parsed.entities,
+ checksum: parsed.checksum,
+ warnings: canonical.warnings,
+ practiceSummary: null,
+ repairedKeys: canonical.repairedKeys,
+ ignoredKeys: canonical.ignoredKeys,
+ missingKeys: canonical.missingKeys,
+ trust: canonical.trust
+ };
+ }
- } catch (error) {
- console.error('[Storage] 迁移遗留数据失败:', error);
- // 即使失败也设置标志,避免无限重试
- await this.set('migration_completed', true, { skipReady });
+ // Explicit but malformed v2 claims must not fall through to legacy parsers.
+ if (parsed.format === 'ielts-atlas-data-v2') {
+ throw new AppDataError('VALIDATION', 'Only valid v2 snapshots can be imported');
}
+
+ return convertLegacyPracticeImport(parsed);
}
- /**
- * 从备份文件恢复数据
- */
- async restoreFromBackup(options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- console.log('[Storage] 开始从备份恢复数据');
-
- const backupPath = 'assets/data/backup-practice-records.json';
- const isFileProtocol = typeof window !== 'undefined'
- && window.location
- && window.location.protocol === 'file:';
-
- // Chromium 下 fetch(file://...) 会直接抛错;备份属于可选项,跳过即可。
- if (isFileProtocol) {
- console.info('[Storage] file:// 环境跳过内置备份恢复');
- return false;
- }
+ function collectionIdentityFields(logicalKey) {
+ if (logicalKey === 'library.configurations') return ['id', 'key', 'configId'];
+ if (logicalKey.startsWith('recovery.')) return ['id', 'sessionId', 'recordId'];
+ if (logicalKey === 'backups.entries') return ['id'];
+ if (logicalKey === 'vocab.words') return ['id', 'word', 'key'];
+ if (logicalKey === 'goals.items') return ['id', 'goalId'];
+ return ['id', 'sessionId', 'recordId'];
+ }
- try {
- const response = await fetch(backupPath);
- if (!response.ok) {
- return false;
- }
- const backupData = await response.json();
- if (!backupData || !Array.isArray(backupData.practice_records)) {
- console.warn('[Storage] 备份数据格式无效');
- return false;
+ function collectionIdentity(logicalKey, value) {
+ const identity = idOf(value, collectionIdentityFields(logicalKey));
+ return logicalKey === 'vocab.words' ? identity.trim().toLowerCase() : identity;
+ }
+
+ function mergeCollection(existing, incoming, logicalKey) {
+ const result = asArray(existing).map((item) => clone(item));
+ const positions = new Map();
+ result.forEach((item, index) => {
+ const identity = collectionIdentity(logicalKey, item);
+ if (identity) positions.set(identity, index);
+ });
+ for (const rawItem of asArray(incoming)) {
+ const item = jsonValue(rawItem, `${logicalKey} item`);
+ const identity = collectionIdentity(logicalKey, item);
+ if (!identity) throw new AppDataError('VALIDATION', `${logicalKey} import item has no stable identity`);
+ if (positions.has(identity)) result[positions.get(identity)] = item;
+ else {
+ positions.set(identity, result.length);
+ result.push(item);
}
- // 运行期恢复必须走统一记录 API;raw practice_records 只允许启动迁移兼容使用。
- await this.replacePracticeRecordsCanonical(backupData.practice_records, { skipReady });
- console.log('[Storage] 从备份恢复 practice_records 成功');
- return true;
- } catch (error) {
- console.warn('[Storage] 备份恢复失败,已跳过:', error);
- return false;
}
+ return result;
}
- /**
- * 处理存储错误
- */
- handleStorageError(key, value, error, options = {}) {
- console.error('[Storage] 存储错误:', error);
-
- // 如果是配额错误,尝试切换到备用存储
- if (error.name === 'QuotaExceededError') {
- this.handleStorageQuotaExceeded(key, value, options);
- } else {
- // 其他错误
- if (window.showMessage) {
- window.showMessage('数据保存失败,请检查浏览器设置', 'error');
+ function mergeImportValue(entry, existing, incoming) {
+ const policy = entry.import;
+ if (policy === 'merge-by-id') return mergeCollection(existing, incoming, entry.logicalKey);
+ if (policy === 'patch') {
+ if (Array.isArray(existing) || Array.isArray(incoming)) {
+ // Array-shaped keys should use merge-by-id; treat accidental patch as replace.
+ return clone(incoming);
}
-
- // 触发存储错误事件
- document.dispatchEvent(new CustomEvent('storageError', {
- detail: { key, value, error }
- }));
+ return Object.assign({}, asObject(existing), asObject(incoming));
}
+ if (policy === 'replace') return clone(incoming);
+ throw new AppDataError('VALIDATION', `Unsupported import policy for ${entry.logicalKey}: ${policy}`);
}
- /**
- * 导出数据
- */
- async exportData(options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- try {
- const data = {};
-
- // 1. 导出内存存储数据
- if (this.fallbackStorage) {
- this.fallbackStorage.forEach((value, key) => {
- if (key.startsWith(this.prefix)) {
- if (this.isProtectedStorageKey(key)) {
- return;
- }
- const cleanKey = key.replace(this.prefix, '');
- data[cleanKey] = JSON.parse(value);
- }
+ async function currentEntitySnapshot() {
+ const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true });
+ const result = {};
+ for (const store of PRACTICE_ENTITY_STORES) {
+ if (store === 'practiceSummaries') result[store] = summaries;
+ else result[store] = (await Promise.all(summaries.map((summary) => kernel.readEntity(store, summary.recordId, { withMeta: true })))).filter(Boolean);
+ }
+ return result;
+ }
+ function practiceEntityIds(rows) {
+ return new Set(asArray(rows).map((row) => String(row && row.recordId || '')).filter(Boolean));
+ }
+ function assertPracticeEntitySetsMatch(entities, message) {
+ const expected = practiceEntityIds(entities.practiceSummaries);
+ for (const store of PRACTICE_ENTITY_STORES.slice(1)) {
+ const actual = practiceEntityIds(entities[store]);
+ if (actual.size !== expected.size || Array.from(expected).some((recordId) => !actual.has(recordId))) {
+ throw new AppDataError('VALIDATION', message || 'Practice import entity layers must contain the same recordIds', {
+ counts: Object.fromEntries(PRACTICE_ENTITY_STORES.map((name) => [name, practiceEntityIds(entities[name]).size]))
});
- console.log(`[Storage] 已导出内存存储数据 ${this.fallbackStorage.size} 条`);
}
-
- // 2. 导出IndexedDB数据
- if (this.indexedDB) {
- try {
- const items = await this.getAllFromIndexedDB();
- const indexedDBData = {};
- items.forEach(item => {
- if (item.key.startsWith(this.prefix) && !this.isProtectedStorageKey(item.key)) {
- const cleanKey = item.key.replace(this.prefix, '');
- indexedDBData[cleanKey] = JSON.parse(item.value);
- }
- });
- // 合并IndexedDB数据
- Object.assign(data, indexedDBData);
- console.log(`[Storage] 已导出IndexedDB数据 ${Object.keys(indexedDBData).length} 条`);
- } catch (error) {
- console.warn('[Storage] IndexedDB导出失败:', error);
- }
+ }
+ }
+ async function createImportPlan(parsed, options = {}) {
+ const { replaceDocuments, replacePractice } = resolveImportReplaceFlags(options);
+ const snapshot = { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: parsed.scope, envelopes: {}, entities: {} };
+ const revisionToken = { documents: {}, entities: {} };
+ const keys = []; const clearedKeys = [];
+ const warnings = asArray(parsed.warnings).map(String);
+ for (const [logicalKey, envelope] of Object.entries(asObject(parsed.envelopes))) {
+ if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`);
+ const entry = catalog.get(logicalKey); if (!isImportableEntry(entry)) continue;
+ if (!internals.validateEnvelope(entry, envelope)) throw new AppDataError('VALIDATION', `Invalid import envelope: ${logicalKey}`);
+ if (envelope.state === 'cleared' && !replaceDocuments && options.applyClears !== true) {
+ warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`);
+ continue;
+ }
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ revisionToken.documents[logicalKey] = current.envelope ? Number(current.envelope.revision) || 0 : 0;
+ let next = envelope;
+ if (!replaceDocuments && envelope.state === 'present') {
+ next = internals.makeEnvelope(entry, mergeImportValue(entry, current.data, envelope.data), { operationId: randomId('import-merge') });
+ }
+ snapshot.envelopes[logicalKey] = next;
+ keys.push(logicalKey);
+ if (next.state === 'cleared') clearedKeys.push(logicalKey);
+ }
+
+ // A full replace mirrors all exportable user data. Missing physical
+ // envelopes mean catalog defaults, represented here as explicit clears.
+ if (replaceDocuments && parsed.scope === 'full') {
+ for (const entry of catalog.list().filter((candidate) => candidate.export === true && isImportableEntry(candidate))) {
+ if (Object.prototype.hasOwnProperty.call(snapshot.envelopes, entry.logicalKey)) continue;
+ snapshot.envelopes[entry.logicalKey] = internals.makeEnvelope(entry, null, {
+ state: 'cleared',
+ operationId: randomId('import-clear')
+ });
+ keys.push(entry.logicalKey);
+ clearedKeys.push(entry.logicalKey);
+ }
+ }
+
+ // Any successful practice import installs all three stores together. Merge
+ // may update a subset only when the final recordId sets remain identical.
+ const sourceStores = Object.keys(asObject(parsed.entities));
+ let practiceExistingCount = null;
+ let practiceIncomingCount = null;
+ if (sourceStores.length) {
+ if (replacePractice && PRACTICE_ENTITY_STORES.some((store) => !sourceStores.includes(store))) {
+ throw new AppDataError('VALIDATION', 'Practice replace requires summaries, details, and annotations');
+ }
+ const current = await currentEntitySnapshot();
+ revisionToken.entities = Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, Object.fromEntries(
+ asArray(current[store]).map((row) => [String(row.recordId), Number(row.revision) || 0])
+ )]));
+ practiceExistingCount = asArray(current.practiceSummaries).length;
+ practiceIncomingCount = asArray(parsed.entities.practiceSummaries).length;
+ const existing = replacePractice
+ ? Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, []]))
+ : current;
+ for (const store of PRACTICE_ENTITY_STORES) {
+ const rows = asArray(existing[store]).map(clone);
+ const positions = new Map(rows.map((row, index) => [String(row.recordId), index]));
+ for (const row of asArray(parsed.entities[store])) {
+ if (!row || !String(row.recordId || '')) throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`);
+ const index = positions.get(String(row.recordId));
+ if (index === undefined) {
+ positions.set(String(row.recordId), rows.length);
+ rows.push(clone(row));
+ } else rows[index] = clone(row);
+ }
+ snapshot.entities[store] = rows;
+ }
+ assertPracticeEntitySetsMatch(snapshot.entities);
+ }
+
+ snapshot.checksum = checksum({ envelopes: snapshot.envelopes, entities: snapshot.entities });
+ const practiceSummary = parsed.practiceSummary
+ ? clone(parsed.practiceSummary)
+ : (Object.prototype.hasOwnProperty.call(snapshot.entities, 'practiceSummaries')
+ ? {
+ accepted: Number(practiceIncomingCount) || 0,
+ importedCount: Number(practiceIncomingCount) || 0,
+ skippedCount: 0,
+ existingCount: Number(practiceExistingCount) || 0,
+ incomingCount: Number(practiceIncomingCount) || 0,
+ finalCount: asArray(snapshot.entities.practiceSummaries).length,
+ removedCount: Math.max(0, (Number(practiceExistingCount) || 0)
+ - asArray(snapshot.entities.practiceSummaries).length)
+ }
+ : null);
+ if (practiceSummary && practiceSummary.existingCount === undefined) {
+ practiceSummary.existingCount = Number(practiceExistingCount) || 0;
+ practiceSummary.incomingCount = Number(practiceIncomingCount) || Number(practiceSummary.importedCount) || 0;
+ practiceSummary.finalCount = asArray(snapshot.entities.practiceSummaries).length;
+ practiceSummary.removedCount = Math.max(0, practiceSummary.existingCount - practiceSummary.finalCount);
+ }
+ const destructive = clearedKeys.length > 0
+ || Boolean(practiceSummary && Number(practiceSummary.removedCount) > 0);
+ return {
+ snapshot,
+ keys,
+ clearedKeys,
+ warnings,
+ practiceSummary,
+ destructive,
+ resetJournal: replaceDocuments && replacePractice,
+ revisionToken,
+ diagnostics: {
+ format: parsed.format,
+ replaceDocuments,
+ replacePractice,
+ declaredScope: parsed.declaredScope || parsed.scope,
+ effectiveScope: parsed.scope,
+ trust: parsed.trust || (parsed.format === 'v2' ? 'trusted-full' : 'degraded-partial'),
+ missingKeys: clone(parsed.missingKeys || []),
+ repairedKeys: clone(parsed.repairedKeys || []),
+ ignoredKeys: clone(parsed.ignoredKeys || [])
+ }
+ };
+ }
+ async function createRestoreSnapshot(backup) {
+ const parsed = parseImportPayload(asObject(backup && backup.data));
+ if (parsed.format !== 'v2') throw new AppDataError('VALIDATION', 'Only v2 snapshots can be restored from local backups');
+ if (backup.checksum && backup.checksum !== parsed.checksum) throw new AppDataError('VALIDATION', 'Backup checksum mismatch');
+ return (await createImportPlan(parsed, { replace: true })).snapshot;
+ }
+
+ const backups = Object.freeze({
+ onDataCommitted(listener) { return kernel.onCommitted(listener); },
+ async getSettings() { await ready; return kernel.read('backups.settings'); },
+ async setSettings(values, options = {}) { await ready; const current = await kernel.read('backups.settings', { withMeta: true }); return kernel.mutate([{ logicalKey: 'backups.settings', data: asObject(values), expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'backup-settings', values)); },
+ async getExportHistory() { await ready; return kernel.read('backups.exportHistory'); },
+ async getImportHistory() { await ready; return kernel.read('backups.importHistory'); },
+ async recordExport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.exportHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup export history entry'))); return kernel.mutate([{ logicalKey: 'backups.exportHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-export-history', entry)); },
+ async recordImport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.importHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup import history entry'))); return kernel.mutate([{ logicalKey: 'backups.importHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-import-history', entry)); },
+ async create(options = {}) {
+ await ready; const current = await readCollectionMeta('backups.entries');
+ const mutation = optionsMutationOptions(options, 'backup-create', { id: options.id || null, type: options.type || 'manual' });
+ const backupId = options.id || (options.operationId ? `backup_${checksum({ operationId: String(options.operationId) }).replace(/[^a-z0-9]/gi, '')}` : randomId('backup'));
+ const existing = current.items.find((item) => String(item.id) === String(backupId));
+ if (existing) {
+ if (String(existing.operationId || '') === String(mutation.operationId)
+ && String(existing.type || 'manual') === String(options.type || 'manual')) {
+ return clone(existing);
+ }
+ throw new AppDataError('CONFLICT', `Backup id already exists: ${backupId}`, {
+ backupId: String(backupId)
+ });
}
+ const snapshot = await kernel.exportSnapshot();
+ const backup = { id: backupId, operationId: mutation.operationId, timestamp: nowIso(), type: options.type || 'manual', version: 2, data: snapshot, size: JSON.stringify(snapshot).length, checksum: snapshot.checksum };
+ current.items.unshift(backup);
+ current.items = retainBackupEntries(current.items, 20, options.preserveIds);
+ await kernel.mutate([{ logicalKey: 'backups.entries', data: current.items, expectedRevision: current.revision }], mutation);
+ const committed = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(backupId));
+ return clone(committed || backup);
+ },
+ async list() { await ready; return kernel.read('backups.entries'); },
+ async delete(id, options = {}) { await ready; const current = await readCollectionMeta('backups.entries'); return kernel.mutate([{ logicalKey: 'backups.entries', data: current.items.filter((item) => String(item.id) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-delete', { id: String(id) })); },
+ async export(options = {}) {
+ await ready;
+ if (options.backupId !== undefined && options.backupId !== null) {
+ const backupId = String(options.backupId);
+ const stored = asArray(await kernel.read('backups.entries'))
+ .find((item) => String(item && item.id) === backupId);
+ if (!stored) throw new AppDataError('VALIDATION', `Unknown backup: ${backupId}`);
+ const portable = jsonValue(stored, 'stored backup export');
+ if (!portable.data || !portable.checksum || portable.checksum !== portable.data.checksum) {
+ throw new AppDataError('VALIDATION', `Backup checksum mismatch: ${backupId}`);
+ }
+ return portable;
+ }
+ const domains = Array.isArray(options.domains) ? new Set(options.domains.map(String)) : null;
+ const logicalKeys = domains
+ ? catalog.list()
+ .filter((entry) => domains.has(entry.owner) && entry.export === true)
+ .map((entry) => entry.logicalKey)
+ : null;
+ const entityStores = !domains || domains.has('practice')
+ ? undefined
+ : [];
+ return kernel.exportSnapshot(Object.assign(
+ logicalKeys ? { logicalKeys } : {},
+ entityStores ? { entityStores } : {}
+ ));
+ },
+ async previewImport(payload, options = {}) {
+ await ready; const parsed = parseImportPayload(payload); const prepared = await createImportPlan(parsed, options); const planId = randomId('import-plan');
+ const cutoff = Date.now() - (30 * 60 * 1000);
+ for (const [id, existing] of importPlans) {
+ if (Date.parse(existing.createdAt) < cutoff || importPlans.size >= 20) importPlans.delete(id);
+ }
+ const plan = { id: planId, format: parsed.format, scope: parsed.scope, keys: prepared.keys, clearedKeys: prepared.clearedKeys, warnings: prepared.warnings, createdAt: nowIso(), snapshot: prepared.snapshot, practiceSummary: prepared.practiceSummary, diagnostics: prepared.diagnostics, destructive: prepared.destructive, resetJournal: prepared.resetJournal, revisionToken: prepared.revisionToken, signature: checksum(prepared.snapshot) };
+ importPlans.set(planId, plan); return { id: planId, format: plan.format, scope: plan.scope, keys: plan.keys, clearedKeys: clone(plan.clearedKeys), warnings: clone(plan.warnings), createdAt: plan.createdAt, practice: clone(plan.practiceSummary), diagnostics: clone(plan.diagnostics), destructive: plan.destructive };
+ },
+ async commitImport(planId, options = {}) {
+ await ready; const plan = importPlans.get(String(planId)); if (!plan) throw new AppDataError('VALIDATION', `Unknown import plan: ${planId}`);
+ if (plan.destructive && options.confirmDestructive !== true) {
+ throw new AppDataError('VALIDATION', 'Destructive import requires explicit confirmation');
+ }
+ const mutation = optionsMutationOptions(options, 'import-commit', {
+ planId: plan.id,
+ signature: plan.signature
+ }, { warnings: plan.warnings });
+ const receipt = await kernel.installSnapshot(plan.snapshot, Object.assign({}, mutation, {
+ resetJournal: plan.resetJournal === true,
+ expectedRevisionToken: plan.revisionToken
+ }));
+ importPlans.delete(String(planId));
+ return Object.assign({}, receipt, plan.practiceSummary || {}, { practice: clone(plan.practiceSummary) });
+ },
+ async restore(id, options = {}) {
+ await ready; const backup = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(id));
+ if (!backup) throw new AppDataError('VALIDATION', `Unknown backup: ${id}`);
+ const snapshot = await createRestoreSnapshot(backup);
+ const restoreMutation = optionsMutationOptions(options, 'backup-restore', {
+ backupId: String(id),
+ checksum: backup.checksum || checksum(backup.data)
+ }, { resetJournal: true });
+ const preRestoreOperationId = `${restoreMutation.operationId}:pre-restore`;
+ const preRestoreBackupId = `pre_restore_${checksum({
+ operationId: restoreMutation.operationId,
+ backupId: String(id),
+ checksum: backup.checksum || checksum(backup.data)
+ }).replace(/[^a-z0-9]/gi, '')}`;
+ const preRestoreBackup = await backups.create({
+ id: preRestoreBackupId,
+ operationId: preRestoreOperationId,
+ type: 'pre-restore',
+ preserveIds: [String(id)]
+ });
+ const receipt = await kernel.installSnapshot(snapshot, restoreMutation);
+ return Object.assign({}, receipt, { preRestoreBackupId: preRestoreBackup.id });
+ }
+ });
- // 3. 导出localStorage数据
- const localStorageKeys = Object.keys(localStorage);
- const appKeys = localStorageKeys.filter(key => key.startsWith(this.prefix));
- appKeys.forEach(key => {
- const cleanKey = key.replace(this.prefix, '');
- if (this.isProtectedDataKey(cleanKey)) {
- return;
- }
- try {
- const value = localStorage.getItem(key);
- if (value) {
- data[cleanKey] = JSON.parse(value);
+ let vocabMutationTail = Promise.resolve();
+ function enqueueVocabMutation(task) {
+ const result = vocabMutationTail.then(task, task);
+ vocabMutationTail = result.catch(() => undefined);
+ return result;
+ }
+ function retryVocabMutation(options, task) {
+ return enqueueVocabMutation(() => retryMergeConflict(options, task));
+ }
+
+ const vocab = Object.freeze({
+ async listWords() { await ready; return kernel.read('vocab.words'); },
+ async saveWords(words, options = {}) {
+ await ready; assertArray(words, 'vocab.saveWords requires an array');
+ const mutation = optionsMutationOptions(options, 'vocab-words', words);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.words', { withMeta: true });
+ return kernel.mutate([{
+ logicalKey: 'vocab.words',
+ data: words,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async getConfig() { await ready; return kernel.read('vocab.userConfig'); },
+ async setConfig(config, options = {}) {
+ await ready;
+ const mutation = optionsMutationOptions(options, 'vocab-config', config);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.userConfig', { withMeta: true });
+ return kernel.mutate([{
+ logicalKey: 'vocab.userConfig',
+ data: asObject(config),
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async patchConfig(patch, options = {}) {
+ await ready; assertObject(patch, 'vocab.patchConfig requires an object');
+ const mutation = optionsMutationOptions(options, 'vocab-config-patch', patch);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.userConfig', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), clone(patch));
+ return kernel.mutate([{
+ logicalKey: 'vocab.userConfig',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async activateList(listId, options = {}) { return this.patchConfig({ activeListId: String(listId || 'default') }, options); },
+ async listCollections() { await ready; return kernel.read('vocab.lists'); },
+ async saveCollection(id, value, options = {}) {
+ await ready;
+ const collectionId = String(id);
+ const mutation = optionsMutationOptions(options, 'vocab-list', { id: collectionId, value });
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) });
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async saveCollections(values, options = {}) {
+ await ready;
+ assertObject(values, 'vocab.saveCollections requires an object');
+ const upserts = Object.fromEntries(Object.entries(values).map(([id, value]) => [String(id), clone(value)]));
+ const mutation = optionsMutationOptions(options, 'vocab-lists-batch', upserts);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), upserts);
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async upsertCollectionWord(collectionId, word, options = {}) {
+ await ready; assertObject(word, 'vocab.upsertCollectionWord requires a word');
+ const id = String(collectionId || '');
+ if (!id) throw new AppDataError('VALIDATION', 'vocab collection id is required');
+ const identity = String(word.word || word.id || '').trim().toLowerCase();
+ if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required');
+ const mutation = optionsMutationOptions(options, 'vocab-word', { collectionId: id, word });
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const collections = Object.assign({}, asObject(current.data));
+ const existing = collections[id];
+ const list = existing && typeof existing === 'object' && !Array.isArray(existing)
+ ? Object.assign({}, clone(existing), { words: asArray(existing.words) })
+ : { id, words: asArray(existing) };
+ const index = list.words.findIndex((item) => String(item && (item.word || item.id) || '').trim().toLowerCase() === identity);
+ const nextWord = Object.assign({}, index >= 0 ? list.words[index] : {}, clone(word), { updatedAt: word.updatedAt || nowIso() });
+ if (!nextWord.createdAt) nextWord.createdAt = nextWord.updatedAt;
+ if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord);
+ list.updatedAt = nowIso();
+ collections[id] = list;
+ const receipt = await kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: collections,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { word: clone(nextWord) });
+ });
+ },
+ async readList(listId) { await ready; const id = String(listId || 'default'); if (id === 'default') return kernel.read('vocab.words'); const collections = await kernel.read('vocab.lists'); return Object.prototype.hasOwnProperty.call(collections, id) ? clone(collections[id]) : null; },
+ async replaceListWords(command, options = {}) {
+ await ready; assertObject(command, 'vocab.replaceListWords requires a command');
+ const id = String(command.listId || 'default'); const words = asArray(command.words);
+ if (id === 'default') return this.saveWords(words, options);
+ const mutation = optionsMutationOptions(options, 'vocab-list-words-replace', command);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const collections = Object.assign({}, asObject(current.data));
+ collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() });
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: collections,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async mergeListWords(command, options = {}) {
+ await ready;
+ assertObject(command, 'vocab.mergeListWords requires a command');
+ const listId = String(command.listId || 'default');
+ const incoming = asArray(command.words);
+ const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists';
+ const mutation = optionsMutationOptions(options, 'vocab-words-merge', command);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const collections = listId === 'default' ? null : Object.assign({}, asObject(current.data));
+ const storedList = listId === 'default'
+ ? asArray(current.data)
+ : (function readStoredCollection() {
+ const collection = collections[listId];
+ return collection && typeof collection === 'object' && !Array.isArray(collection)
+ ? asArray(collection.words)
+ : asArray(collection);
+ }());
+ const merged = storedList.map((word) => clone(word));
+ const positions = new Map();
+ merged.forEach((word, index) => {
+ const identity = String(word && (word.word || word.id) || '').trim().toLowerCase();
+ if (identity) positions.set(identity, index);
+ });
+ let addedCount = 0;
+ let updatedCount = 0;
+ for (const rawWord of incoming) {
+ assertObject(rawWord, 'vocab.mergeListWords entries must be objects');
+ const identity = String(rawWord.word || rawWord.id || '').trim().toLowerCase();
+ if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required');
+ if (!positions.has(identity)) {
+ positions.set(identity, merged.length);
+ merged.push(clone(rawWord));
+ addedCount += 1;
+ continue;
}
- } catch (error) {
- console.warn(`[Storage] 解析localStorage数据失败: ${cleanKey}`, error);
+ const index = positions.get(identity);
+ const existing = asObject(merged[index]);
+ const patch = {};
+ if (typeof rawWord.meaning === 'string' && rawWord.meaning.trim()) patch.meaning = rawWord.meaning.trim();
+ if (typeof rawWord.example === 'string' && rawWord.example.trim()) patch.example = rawWord.example.trim();
+ if (typeof rawWord.freq === 'number' && Number.isFinite(rawWord.freq)) patch.freq = rawWord.freq;
+ merged[index] = Object.assign({}, existing, patch, { updatedAt: nowIso() });
+ updatedCount += 1;
+ }
+ const data = listId === 'default'
+ ? merged
+ : Object.assign({}, collections, {
+ [listId]: Object.assign(
+ {},
+ (function collectionBaseForWrite() {
+ const collection = collections[listId];
+ return collection && typeof collection === 'object' && !Array.isArray(collection)
+ ? clone(collection)
+ : {};
+ }()),
+ { id: listId, words: merged, updatedAt: nowIso() }
+ )
+ });
+ const receipt = await kernel.mutate([{
+ logicalKey,
+ data,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { listId, words: clone(merged), addedCount, updatedCount });
+ });
+ },
+ async patchWord(command, options = {}) {
+ await ready; assertObject(command, 'vocab.patchWord requires a command');
+ const listId = String(command.listId || 'default'); const wordId = String(command.wordId || command.id || '');
+ if (!wordId) throw new AppDataError('VALIDATION', 'vocab word id is required');
+ const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists';
+ const mutation = optionsMutationOptions(
+ Object.assign({}, options, { operationId: command.operationId || options.operationId }),
+ 'vocab-word-patch',
+ command
+ );
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const collections = listId === 'default' ? null : asObject(current.data);
+ const list = listId === 'default'
+ ? asArray(current.data)
+ : asArray(asObject(collections[listId]).words);
+ const index = list.findIndex((word) => idOf(word, ['id', 'word', 'key']) === wordId);
+ if (index < 0) throw new AppDataError('VALIDATION', `Unknown vocab word: ${wordId}`);
+ const updated = Object.assign({}, list[index], clone(asObject(command.patch)), { id: list[index].id || wordId, updatedAt: nowIso() });
+ const next = list.slice(); next[index] = updated;
+ const data = listId === 'default'
+ ? next
+ : Object.assign({}, collections, {
+ [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() })
+ });
+ const receipt = await kernel.mutate([{
+ logicalKey,
+ data,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { word: clone(updated) });
+ });
+ },
+ async replaceProgress(command, options = {}) {
+ await ready; assertObject(command, 'vocab.replaceProgress requires a command');
+ const listId = String(command.listId || 'default'); const words = asArray(command.words);
+ const mutation = optionsMutationOptions(options, 'vocab-progress', command);
+ return retryVocabMutation(options, async () => {
+ const configMeta = await kernel.read('vocab.userConfig', { withMeta: true });
+ const changes = [{
+ logicalKey: 'vocab.userConfig',
+ data: Object.assign({}, asObject(configMeta.data), asObject(command.config), { activeListId: listId }),
+ expectedRevision: configMeta.envelope ? configMeta.envelope.revision : 0
+ }];
+ if (listId === 'default') {
+ const wordsMeta = await kernel.read('vocab.words', { withMeta: true });
+ changes.push({ logicalKey: 'vocab.words', data: words, expectedRevision: wordsMeta.envelope ? wordsMeta.envelope.revision : 0 });
+ } else {
+ const listsMeta = await kernel.read('vocab.lists', { withMeta: true }); const lists = Object.assign({}, asObject(listsMeta.data));
+ lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words });
+ changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 });
}
+ return kernel.mutate(changes, mutation);
});
- console.log(`[Storage] 已导出localStorage数据 ${appKeys.length} 条`);
+ }
+ });
- data.practice_records = await this.readProtectedDataKey('practice_records', [], { skipReady });
- data.user_stats = await this.readProtectedDataKey('user_stats', null, { skipReady });
+ async function readPreferences() { await ready; return kernel.read('preferences.values'); }
+ let preferenceMutationTail = Promise.resolve();
+ function enqueuePreferenceMutation(task) {
+ const result = preferenceMutationTail.then(task, task);
+ preferenceMutationTail = result.catch(() => undefined);
+ return result;
+ }
+ async function writePreference(field, value, options = {}) {
+ const mutation = optionsMutationOptions(options, 'preference-set', { field, value });
+ return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => {
+ const current = await kernel.read('preferences.values', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), { [field]: clone(value) });
+ return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ }));
+ }
+ async function patchPreference(field, patch, options = {}) {
+ await ready;
+ const mutation = optionsMutationOptions(options, 'preference-patch', { field, patch });
+ return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => {
+ const current = await kernel.read('preferences.values', { withMeta: true });
+ const values = asObject(current.data);
+ const next = Object.assign({}, values, { [field]: Object.assign({}, asObject(values[field]), asObject(patch)) });
+ return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ }));
+ }
+ const preferences = Object.freeze({
+ async getAll() { return readPreferences(); },
+ async getTheme() { return (await readPreferences())[PREFERENCE_FIELDS.theme] ?? null; }, async setTheme(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.theme, value, options); },
+ async getBrowse() { return clone((await readPreferences())[PREFERENCE_FIELDS.browse] ?? null); }, async setBrowse(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.browse, value, options); }, async patchBrowse(value, options) { return patchPreference(PREFERENCE_FIELDS.browse, value, options); },
+ async getTimer(scope) { const timer = clone((await readPreferences())[PREFERENCE_FIELDS.timer] ?? {}); return scope ? clone(timer[String(scope)] ?? null) : timer; }, async setTimer(scope, value, options) { return patchPreference(PREFERENCE_FIELDS.timer, { [String(scope)]: clone(value) }, options); },
+ async getSuite() { return clone((await readPreferences())[PREFERENCE_FIELDS.suite] ?? null); }, async setSuite(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.suite, value, options); }, async patchSuite(value, options) { return patchPreference(PREFERENCE_FIELDS.suite, value, options); },
+ async getCandidateCode() { return (await readPreferences())[PREFERENCE_FIELDS.candidateCode] ?? null; }, async setCandidateCode(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.candidateCode, value, options); }
+ ,async getResourceBasePrefix() { return (await readPreferences())[PREFERENCE_FIELDS.resourceBasePrefix] ?? null; }, async setResourceBasePrefix(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.resourceBasePrefix, value, options); },
+ async getOnboarding() { return clone((await readPreferences())[PREFERENCE_FIELDS.onboarding] ?? {}); }, async setOnboarding(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.onboarding, asObject(value), options); },
+ async getReadingDisplay() { return clone((await readPreferences())[PREFERENCE_FIELDS.readingDisplay] ?? null); }, async setReadingDisplay(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.readingDisplay, value, options); },
+ async getThreeBackground() { return (await readPreferences())[PREFERENCE_FIELDS.threeBackground] ?? null; }, async setThreeBackground(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.threeBackground, value, options); },
+ async getThemePortal() { return clone((await readPreferences())[PREFERENCE_FIELDS.themePortal] ?? null); }, async setThemePortal(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.themePortal, value, options); },
+ async getPracticeWidget() { return (await readPreferences())[PREFERENCE_FIELDS.practiceWidget] ?? null; }, async setPracticeWidget(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.practiceWidget, value, options); },
+ async getConsent() { return clone((await readPreferences())[PREFERENCE_FIELDS.consent] ?? {}); }, async setConsent(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.consent, asObject(value), options); },
+ async getLogConfig() { return clone((await readPreferences())[PREFERENCE_FIELDS.logConfig] ?? null); }, async setLogConfig(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.logConfig, asObject(value), options); }
+ });
- console.log(`[Storage] 数据导出完成,总计 ${Object.keys(data).length} 条记录`);
+ const goals = Object.freeze({
+ async list() { await ready; return kernel.read('goals.items'); },
+ async save(goal, options = {}) { await ready; assertObject(goal, 'goals.save requires an object'); const mutation = optionsMutationOptions(options, 'goal-save', goal); const current = await readCollectionMeta('goals.items'); const id = idOf(goal, ['id', 'goalId']) || deterministicEntityId('goal', mutation.operationId); const item = Object.assign({}, clone(goal), { id }); const index = current.items.findIndex((entry) => idOf(entry, ['id', 'goalId']) === id); if (index >= 0) current.items[index] = item; else current.items.push(item); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items, expectedRevision: current.revision }], mutation); },
+ async delete(id, options = {}) { await ready; const current = await readCollectionMeta('goals.items'); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items.filter((item) => idOf(item, ['id', 'goalId']) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'goal-delete', { id: String(id) })); }
+ });
- return {
- version: this.version,
- exportDate: new Date().toISOString(),
- data: data,
- storageInfo: {
- totalRecords: Object.keys(data).length,
- sources: {
- memory: this.fallbackStorage ? this.fallbackStorage.size : 0,
- indexedDB: this.indexedDB ? Object.keys(data).length - (this.fallbackStorage ? this.fallbackStorage.size : 0) - appKeys.length : 0,
- localStorage: appKeys.length
- }
- }
- };
- } catch (error) {
- console.error('Export data error:', error);
- return null;
+ function deliveryTimestamp(value) {
+ const candidate = value && typeof value === 'object' ? value.unlockedAt : value;
+ const time = typeof candidate === 'string' && candidate.trim() ? Date.parse(candidate) : NaN;
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
+ }
+
+ function mergeDeliveryAcknowledgements(current, incoming) {
+ const merged = Object.assign({}, asObject(current));
+ for (const [id, value] of Object.entries(asObject(incoming))) {
+ const key = String(id).trim();
+ if (!key) continue;
+ const previous = deliveryTimestamp(merged[key]);
+ const next = deliveryTimestamp(value);
+ if (!hasOwn(merged, key) || (next && (!previous || next < previous))) {
+ merged[key] = next;
+ } else if (previous) {
+ merged[key] = previous;
+ } else {
+ merged[key] = null;
+ }
}
+ return merged;
}
- /**
- * 从IndexedDB获取所有数据
- */
- getAllFromIndexedDB() {
- return new Promise((resolve, reject) => {
- if (!this.indexedDB) {
- reject(new Error('IndexedDB not available'));
- return;
- }
+ const achievements = Object.freeze({
+ async getAll() {
+ await ready;
+ const progress = await retryMergeConflict({}, async () => {
+ const [summaries, manual, current] = await Promise.all([
+ kernel.listEntities('practiceSummaries'),
+ kernel.read('achievements.manual'),
+ kernel.read('achievements.progress', { withMeta: true })
+ ]);
+ const projected = asObject(computeAchievementProgress(summaries, manual, current.data));
+ if (checksum(projected) !== checksum(asObject(current.data))) {
+ await kernel.mutate([{
+ logicalKey: 'achievements.progress',
+ data: projected,
+ expectedRevision: current.envelope ? Number(current.envelope.revision) : 0
+ }], {
+ operationId: `achievement-progress-${current.envelope ? Number(current.envelope.revision) : 0}-${checksum(projected)}`
+ });
+ }
+ return projected;
+ }, 5);
+ if (Object.prototype.hasOwnProperty.call(progress, 'fresh')) delete progress.fresh;
+ Object.defineProperty(progress, 'fresh', { value: true, enumerable: false });
+ return progress;
+ },
+ async retryPending() { return achievements.getAll(); },
+ async acknowledgeDelivery(unlocked, options = {}) {
+ await ready;
+ assertObject(unlocked, 'achievements.acknowledgeDelivery requires an object');
+ const requested = clone(unlocked);
+ const mutation = optionsMutationOptions(options, 'achievement-delivery-acknowledge', requested);
+ return retryMergeConflict({}, async () => {
+ const current = await kernel.read('settings.values', { withMeta: true });
+ const settingsValue = asObject(current.data);
+ const delivery = asObject(settingsValue.achievementDelivery);
+ const acknowledged = mergeDeliveryAcknowledgements(delivery.acknowledged, requested);
+ return kernel.mutate([{
+ logicalKey: 'settings.values',
+ data: Object.assign({}, settingsValue, {
+ achievementDelivery: { version: 1, acknowledged }
+ }),
+ expectedRevision: current.envelope ? Number(current.envelope.revision) : 0
+ }], mutation);
+ }, 5);
+ },
+ async getManualState() { await ready; return kernel.read('achievements.manual'); }
+ });
- const transaction = this.indexedDB.transaction(['keyValueStore'], 'readonly');
- const store = transaction.objectStore('keyValueStore');
- const request = store.getAll();
+ const LEGACY_DOCUMENT_ALIASES = Object.freeze({
+ 'settings.values': ['user_settings', 'settings', 'system_settings'],
+ 'recovery.activeSessions': ['active_sessions'], 'recovery.drafts': ['temp_practice_records'],
+ 'recovery.interrupted': ['interrupted_records'], 'recovery.rejectedCompletions': ['rejected_completion_payloads'],
+ 'backups.entries': ['manual_backups'], 'backups.settings': ['backup_settings'],
+ 'backups.exportHistory': ['export_history'], 'backups.importHistory': ['import_history'],
+ 'vocab.words': ['vocab_words'], 'vocab.userConfig': ['vocab_user_config'], 'vocab.lists': ['vocab_lists'],
+ 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'],
+ 'achievements.manual': ['achievement_manual_state', 'user_achievements']
+ });
+ const LEGACY_PREFERENCE_ALIASES = Object.freeze({
+ theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse',
+ practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode',
+ ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed'
+ });
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => reject(request.error);
- });
+ function mergeLegacySources(indexedDbValue, externalValue) {
+ const indexedDb = asObject(indexedDbValue);
+ const external = asObject(externalValue);
+ const merged = Object.assign({}, external, indexedDb);
+ const records = new Map();
+ const addRecords = (value) => {
+ const list = Array.isArray(value) ? value : asArray(asObject(value).data);
+ list.forEach((record) => {
+ const id = idOf(record, ['id', 'recordId', 'sessionId']);
+ records.set(id ? `id:${id}` : `content:${checksum(record)}`, clone(record));
+ });
+ };
+ addRecords(external.practice_records || external.practiceRecords);
+ addRecords(indexedDb.practice_records);
+ if (records.size) merged.practice_records = Array.from(records.values());
+ return merged;
}
- /**
- * 导入数据
- */
- async importData(importedData, options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- try {
- if (!importedData || !importedData.data) {
- throw new Error('Invalid import data format');
- }
-
- const importEntries = Object.entries(importedData.data);
- const api = await this.getPracticeRecordAPI({ skipReady });
- const hasPracticeRecords = importEntries.some(([key]) => key === 'practice_records');
- const hasUserStats = importEntries.some(([key]) => key === 'user_stats');
- if (hasPracticeRecords && (!api || typeof api.replace !== 'function')) {
- throw new Error('Storage.importData: unified practice record store not ready');
- }
- if (hasUserStats && (!api || typeof api.writeStats !== 'function')) {
- throw new Error('Storage.importData: unified user stats store not ready');
- }
-
- // 备份当前数据
- const backup = await this.exportData({ skipReady });
- const importEntry = ([key, value]) => {
- const nextValue = value && Object.prototype.hasOwnProperty.call(value, 'data')
- ? value.data
- : value;
- if (key === 'practice_records') {
- // updateStats: false — 导入时 user_stats 会通过 writeUserStatsCanonical 独立写入,
- // 若此处 recalculateStats 会和并发写入竞争,导致备份中的统计值被覆盖。
- return this.replacePracticeRecordsCanonical(nextValue, { skipReady, updateStats: false });
- }
- if (key === 'user_stats') {
- return this.writeUserStatsCanonical(nextValue, { skipReady });
- }
- return this.set(key, nextValue, { skipReady });
- };
+ function legacyLibraryBundle(legacy) {
+ const idMap = new Map();
+ const indexes = {};
+ for (const [oldId, value] of Object.entries(asObject(legacy))) {
+ if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations' || !asArray(value).length) continue;
+ const id = `legacy-library-${checksum(oldId).replace(/^fnv1a-/, '')}`;
+ idMap.set(oldId, id);
+ indexes[id] = clone(value);
+ }
+ if (!idMap.size) return null;
+ const configurations = new Map();
+ asArray(legacy.exam_index_configurations).forEach((configuration) => {
+ const oldId = idOf(configuration, ['id', 'key', 'configId']);
+ const id = idMap.get(oldId);
+ if (id) configurations.set(id, Object.assign({}, clone(configuration), { id, key: id, examCount: indexes[id].length }));
+ });
+ for (const [oldId, id] of idMap) {
+ if (!configurations.has(id)) configurations.set(id, {
+ id,
+ key: id,
+ name: `迁移的自定义题库 (${oldId})`,
+ examCount: indexes[id].length,
+ sourceType: 'legacy-import'
+ });
+ }
+ return {
+ configurations: Array.from(configurations.values()),
+ indexes,
+ activeId: idMap.get(String(legacy.active_exam_index_key || '')) || null
+ };
+ }
+ async function migrateLegacyData() {
+ // Unit embedders may provide a deliberately minimal kernel bootstrap.
+ if (typeof internals.readLegacyValues !== 'function') return;
+ const migrationMeta = await kernel.read('system.migrations', { withMeta: true });
+ const migrationState = asObject(migrationMeta.data);
+ const v1Complete = asObject(migrationState.v1ToV2).status === 'complete';
+ const externalConsumed = asObject(migrationState.externalBackupV1).status === 'consumed';
+ let externalBackup = null;
+ if (!externalConsumed && typeof internals.readLegacyExternalBackup === 'function') {
+ try { externalBackup = await internals.readLegacyExternalBackup(); }
+ catch (error) {
+ if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message);
+ }
+ }
+ if (v1Complete && !externalBackup) return;
+
+ const indexedDb = await internals.readLegacyValues();
+ if (indexedDb && indexedDb.__legacyReadComplete === false) {
+ throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup');
+ }
+ const legacy = mergeLegacySources(indexedDb, externalBackup);
+ const changes = [];
+ for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) {
+ const current = await kernel.getEnvelope(logicalKey);
+ if (current) continue;
+ const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key));
+ if (alias) changes.push({ logicalKey, data: legacy[alias], expectedRevision: 0 });
+ }
+ const libraryBundle = legacyLibraryBundle(legacy);
+ if (libraryBundle) {
+ if (!(await kernel.getEnvelope('library.configurations'))) changes.push({ logicalKey: 'library.configurations', data: libraryBundle.configurations, expectedRevision: 0 });
+ if (!(await kernel.getEnvelope('library.importedIndexes'))) changes.push({ logicalKey: 'library.importedIndexes', data: libraryBundle.indexes, expectedRevision: 0 });
+ if (!(await kernel.getEnvelope('library.activeConfigurationId'))) changes.push({ logicalKey: 'library.activeConfigurationId', data: libraryBundle.activeId, expectedRevision: 0 });
+ }
+ if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) {
+ const preferences = {};
+ for (const [alias, target] of Object.entries(LEGACY_PREFERENCE_ALIASES)) {
+ if (!Object.prototype.hasOwnProperty.call(legacy, alias)) continue;
+ const path = target.split('.'); let cursor = preferences;
+ path.slice(0, -1).forEach((part) => { cursor[part] = asObject(cursor[part]); cursor = cursor[part]; });
+ cursor[path[path.length - 1]] = clone(legacy[alias]);
+ }
+ if (Object.keys(preferences).length) changes.push({ logicalKey: 'preferences.values', data: preferences, expectedRevision: 0 });
+ }
+ if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) {
+ changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 });
+ }
+ if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-${internals.checksum(changes)}` });
+ const recordsValue = legacy.practice_records;
+ const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data);
+ const operations = [];
+ for (const [index, record] of records.entries()) {
try {
- // 清空现有数据
- await this.clear({ skipReady });
-
- // 导入新数据
- const importPromises = importEntries.map(importEntry);
-
- await Promise.all(importPromises);
-
- return { success: true, message: 'Data imported successfully' };
- } catch (importError) {
- // 恢复备份
- console.error('Import failed, restoring backup:', importError);
- await this.clear({ skipReady });
-
- if (backup && backup.data) {
- const restorePromises = Object.entries(backup.data).map(importEntry);
- await Promise.all(restorePromises);
+ const candidate = clone(record);
+ if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) candidate.id = `legacy_${index}_${internals.checksum(record)}`;
+ const canonical = canonicalizeRecord(candidate);
+ const parts = splitPracticeRecord(canonical);
+ for (const [store, data] of [
+ ['practiceSummaries', parts.summary],
+ ['practiceDetails', parts.detail],
+ ['practiceAnnotations', parts.annotations]
+ ]) {
+ if (!await kernel.readEntity(store, canonical.id)) {
+ operations.push({ type: 'upsert', store, recordId: canonical.id, data, expectedRevision: 0 });
+ }
}
-
- throw importError;
+ } catch (error) {
+ if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message);
}
- } catch (error) {
- console.error('Import data error:', error);
- return { success: false, message: error.message };
}
- }
+ if (operations.length) {
+ await kernel.mutateEntities(operations, { operationId: `legacy-practice-${internals.checksum(records)}` });
+ }
- /**
- * 数据验证
- */
- validateData(key, data) {
- const validators = {
- practice_records: (records) => {
- return Array.isArray(records) && records.every(record =>
- record.id && record.examId && record.startTime && record.endTime
- );
- },
- user_stats: (stats) => {
- return stats && typeof stats.totalPractices === 'number';
- },
- exam_index: (index) => {
- return !index || (Array.isArray(index) && index.every(exam =>
- exam.id && exam.title && exam.category
- ));
- }
+ const nextMigrationState = Object.assign({}, migrationState);
+ if (!v1Complete) nextMigrationState.v1ToV2 = {
+ version: 1,
+ status: 'complete',
+ completedAt: nowIso(),
+ sourceChecksum: checksum(indexedDb),
+ sourceRecordCount: asArray(indexedDb.practice_records).length
};
+ if (externalBackup) nextMigrationState.externalBackupV1 = {
+ version: 1,
+ status: 'consumed',
+ completedAt: nowIso(),
+ sourceChecksum: checksum(externalBackup)
+ };
+ await kernel.mutate([{
+ logicalKey: 'system.migrations',
+ data: nextMigrationState,
+ expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0
+ }], { operationId: `legacy-migration-${checksum(nextMigrationState)}` });
+ }
+
+ const ready = kernel.initialize()
+ .then(async () => {
+ // Legacy migration and recovery cleanup are best-effort: a failure here
+ // (e.g. one malformed v1 record) must not brick the data layer for every
+ // read that awaits `ready`. Only a genuine backend init failure below is fatal.
+ try {
+ await migrateLegacyData();
+ } catch (error) {
+ if (global.console && console.error) console.error('[AppData v2] legacy migration skipped:', error);
+ }
+ try {
+ await cleanupExpiredRecovery();
+ } catch (error) {
+ if (global.console && console.warn) console.warn('[AppData v2] recovery cleanup skipped:', error);
+ }
+ return true;
+ })
+ .catch((error) => {
+ if (global.console && console.error) console.error('[AppData v2] initialization blocked:', error);
+ throw error instanceof AppDataError ? error : new AppDataError('INITIALIZATION_BLOCKED', error && error.message || 'AppData v2 initialization failed');
+ });
- const validator = validators[key];
- return validator ? validator(data) : true;
+ const AppData = { practice, settings, library, recovery, backups, vocab, preferences, goals, achievements };
+ Object.defineProperties(AppData, {
+ ready: { value: ready, enumerable: false },
+ status: { value: () => kernel.status(), enumerable: false }
+ });
+ Object.freeze(AppData);
+ Object.defineProperty(global, 'AppData', { value: AppData, enumerable: true, configurable: false, writable: false });
+ if (!Reflect.deleteProperty(global, '__AppDataV2Internals')) {
+ throw new Error('AppData v2 failed to close its internal bootstrap channel');
}
+ if (!Reflect.deleteProperty(global, '__AppDataV2Catalog')) {
+ throw new Error('AppData v2 failed to close its catalog bootstrap channel');
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
- /**
- * 启动存储监控
- */
- async startStorageMonitoring() {
- await this.waitForInitialization();
- console.log('[Storage] 启动存储监控...');
-
- // 定期检查存储使用情况
- this.monitoringInterval = setInterval(async () => {
- try {
- const storageInfo = await this.getStorageInfo();
- if (storageInfo) {
- const usagePercent = storageInfo.type === 'localStorage'
- ? (storageInfo.used / (5 * 1024 * 1024)) * 100
- : (storageInfo.used / (105 * 1024 * 1024)) * 100;
-
- const maxSize = storageInfo.type === 'localStorage' ? '5MB' :
- storageInfo.type === 'Hybrid' ? '105MB' : '100MB';
- console.log(`[Storage] 使用率: ${usagePercent.toFixed(2)}% (${(storageInfo.used / 1024).toFixed(2)}KB / ${maxSize})`);
-
- // 显示详细的存储分布
- if (storageInfo.breakdown) {
- console.log(`[Storage] 存储分布: localStorage ${(storageInfo.breakdown.localStorage / 1024).toFixed(2)}KB, IndexedDB ${(storageInfo.breakdown.indexedDB / 1024).toFixed(2)}KB`);
- }
- // 当使用率超过80%时,自动清理
- if (usagePercent > 80) {
- console.warn('[Storage] 存储使用率过高,自动清理旧数据');
- await this.cleanupOldData();
+/* ===== js/core/externalBackupService.js ===== */
+/**
+ * V2 external disk backup adapter.
+ *
+ * The selected directory is not a DataKernel backend. Durable application
+ * commits stay authoritative in AppData; this adapter writes portable v2
+ * snapshots after the commit and isolates every filesystem failure.
+ */
+(function initExternalBackupService(global) {
+ 'use strict';
- // 清理后再次检查
- const newStorageInfo = await this.getStorageInfo();
- if (newStorageInfo) {
- const newUsagePercent = newStorageInfo.type === 'localStorage'
- ? (newStorageInfo.used / (5 * 1024 * 1024)) * 100
- : (newStorageInfo.used / (105 * 1024 * 1024)) * 100;
+ if (global.ExternalBackupService && global.ExternalBackupService.__v2 === true) return;
- console.log(`[Storage] 清理后使用率: ${newUsagePercent.toFixed(2)}%`);
+ var DB_NAME = 'IELTSAtlasExternalBackupV2';
+ var DB_VERSION = 1;
+ var STORE_NAME = 'binding';
+ var HANDLE_KEY = 'directory-handle';
+ var META_KEY = 'metadata';
+ var LATEST_FILENAME = 'ielts-atlas-backup-latest.json';
+ var WRITE_DELAY_MS = 8000;
+ var ENTRY_ID = 'external-backup-entry-btn';
+ var MODAL_ID = 'external-backup-modal';
- // 如果仍然超过90%,显示警告
- if (newUsagePercent > 90) {
- if (window.showMessage) {
- window.showMessage('存储空间即将不足,建议导出数据备份', 'warning');
- }
- }
- }
- }
- }
- } catch (error) {
- console.error('[Storage] 存储监控错误:', error);
- }
- }, 300000); // 每5分钟检查一次
+ var state = {
+ ready: false,
+ readyPromise: null,
+ initialized: false,
+ suspended: false,
+ directoryHandle: null,
+ permission: 'prompt',
+ dirty: false,
+ dirtyGeneration: 0,
+ writing: false,
+ writeQueue: Promise.resolve(),
+ silentFlushTimer: null,
+ unsubscribeCommitted: null,
+ visibilityHandler: null,
+ meta: {
+ directoryName: null,
+ lastWriteAt: null,
+ lastChecksum: null,
+ lastWriteError: null,
+ awaitingRestore: false
+ }
+ };
- // 页面卸载时清理监控 - 全局事件必须使用原生 addEventListener
- window.addEventListener('beforeunload', () => {
- if (this.monitoringInterval) {
- clearInterval(this.monitoringInterval);
- }
- });
+ function nowIso() {
+ return new Date().toISOString();
}
- // ==================== 词表存储专用方法 ====================
+ function dayKey(date) {
+ var year = date.getFullYear();
+ var month = String(date.getMonth() + 1).padStart(2, '0');
+ var day = String(date.getDate()).padStart(2, '0');
+ return year + '-' + month + '-' + day;
+ }
- /**
- * 词表存储键常量
- */
- getVocabStorageKeys() {
+ function cloneMeta(value) {
+ var source = value && typeof value === 'object' ? value : {};
return {
- P1_ERRORS: 'vocab_list_p1_errors',
- P4_ERRORS: 'vocab_list_p4_errors',
- MASTER_ERRORS: 'vocab_list_master_errors',
- CUSTOM: 'vocab_list_custom',
- READING_HIGHLIGHTS: 'vocab_list_reading_highlights',
- ACTIVE_LIST: 'vocab_active_list'
+ directoryName: source.directoryName ? String(source.directoryName) : null,
+ lastWriteAt: source.lastWriteAt ? String(source.lastWriteAt) : null,
+ lastChecksum: source.lastChecksum ? String(source.lastChecksum) : null,
+ lastWriteError: source.lastWriteError ? String(source.lastWriteError) : null,
+ awaitingRestore: source.awaitingRestore === true
};
}
- /**
- * 验证词表数据结构
- */
- validateVocabList(vocabList) {
- if (!vocabList || typeof vocabList !== 'object') {
- return { valid: false, error: '词表数据无效' };
- }
-
- const requiredFields = ['id', 'name', 'source', 'words', 'createdAt', 'updatedAt'];
- for (const field of requiredFields) {
- if (!(field in vocabList)) {
- return { valid: false, error: `缺少必需字段: ${field}` };
- }
+ function getIndexedDB() {
+ try {
+ return global.indexedDB || null;
+ } catch (_) {
+ return null;
}
+ }
- if (!Array.isArray(vocabList.words)) {
- return { valid: false, error: 'words 字段必须是数组' };
- }
+ function supportsFileSystemAccess() {
+ return typeof global.showDirectoryPicker === 'function'
+ && global.isSecureContext !== false;
+ }
- // 验证每个单词条目
- for (const word of vocabList.words) {
- if (!word.word || typeof word.word !== 'string') {
- return { valid: false, error: '单词条目缺少有效的 word 字段' };
+ function openBindingDb() {
+ return new Promise(function (resolve, reject) {
+ var indexedDb = getIndexedDB();
+ if (!indexedDb) {
+ reject(new Error('IndexedDB unavailable for directory binding'));
+ return;
}
- if (!word.timestamp || typeof word.timestamp !== 'number') {
- return { valid: false, error: '单词条目缺少有效的 timestamp 字段' };
+ var request;
+ try {
+ request = indexedDb.open(DB_NAME, DB_VERSION);
+ } catch (error) {
+ reject(error);
+ return;
}
- }
-
- return { valid: true };
+ request.onerror = function () {
+ reject(request.error || new Error('Failed to open external backup binding database'));
+ };
+ request.onupgradeneeded = function (event) {
+ var db = event.target.result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME);
+ };
+ request.onsuccess = function () {
+ resolve(request.result);
+ };
+ });
}
- /**
- * 清理词表数据
- * 移除重复单词,保留最新的记录
- */
- cleanVocabList(vocabList) {
- if (!vocabList || !Array.isArray(vocabList.words)) {
- return vocabList;
+ async function readStoredValue(key) {
+ var db = await openBindingDb();
+ try {
+ return await new Promise(function (resolve, reject) {
+ var tx = db.transaction(STORE_NAME, 'readonly');
+ var request = tx.objectStore(STORE_NAME).get(key);
+ request.onsuccess = function () { resolve(request.result); };
+ request.onerror = function () { reject(request.error || tx.error); };
+ tx.onabort = function () { reject(tx.error || new Error('Binding read transaction aborted')); };
+ });
+ } finally {
+ try { db.close(); } catch (_) { /* ignore */ }
}
-
- const wordMap = new Map();
-
- // 按时间戳排序,保留最新的
- vocabList.words.forEach(word => {
- const key = word.word.toLowerCase().trim();
- const existing = wordMap.get(key);
-
- if (!existing || word.timestamp > existing.timestamp) {
- wordMap.set(key, word);
- }
- });
-
- vocabList.words = Array.from(wordMap.values());
- vocabList.updatedAt = Date.now();
-
- return vocabList;
}
- /**
- * 保存词表数据
- */
- async saveVocabList(vocabList, options = {}) {
- const { skipReady = false } = options;
-
+ async function writeStoredValues(values) {
+ var db = await openBindingDb();
try {
- // 验证数据
- const validation = this.validateVocabList(vocabList);
- if (!validation.valid) {
- console.error('[Storage] 词表数据验证失败:', validation.error);
- return false;
- }
+ await new Promise(function (resolve, reject) {
+ var tx = db.transaction(STORE_NAME, 'readwrite');
+ var store = tx.objectStore(STORE_NAME);
+ Object.keys(values).forEach(function (key) {
+ store.put(values[key], key);
+ });
+ tx.oncomplete = function () { resolve(); };
+ tx.onerror = function () { reject(tx.error || new Error('Binding write transaction failed')); };
+ tx.onabort = function () { reject(tx.error || new Error('Binding write transaction aborted')); };
+ });
+ } finally {
+ try { db.close(); } catch (_) { /* ignore */ }
+ }
+ }
- // 清理数据
- const cleanedList = this.cleanVocabList(vocabList);
-
- // 确定存储键
- const keys = this.getVocabStorageKeys();
- let storageKey;
-
- switch (cleanedList.source) {
- case 'p1':
- storageKey = keys.P1_ERRORS;
- break;
- case 'p4':
- storageKey = keys.P4_ERRORS;
- break;
- case 'all':
- storageKey = keys.MASTER_ERRORS;
- break;
- case 'user':
- storageKey = keys.CUSTOM;
- break;
- case 'reading-highlight':
- storageKey = keys.READING_HIGHLIGHTS;
- break;
- default:
- storageKey = cleanedList.id;
- }
-
- console.log(`[Storage] 保存词表: ${storageKey}, 单词数: ${cleanedList.words.length}`);
-
- // 保存到存储
- const success = await this.set(storageKey, cleanedList, { skipReady });
-
- if (success) {
- console.log(`[Storage] 词表保存成功: ${storageKey}`);
- }
+ async function clearStoredBinding() {
+ var db = await openBindingDb();
+ try {
+ await new Promise(function (resolve, reject) {
+ var tx = db.transaction(STORE_NAME, 'readwrite');
+ var store = tx.objectStore(STORE_NAME);
+ store.delete(HANDLE_KEY);
+ store.delete(META_KEY);
+ tx.oncomplete = function () { resolve(); };
+ tx.onerror = function () { reject(tx.error || new Error('Binding clear transaction failed')); };
+ tx.onabort = function () { reject(tx.error || new Error('Binding clear transaction aborted')); };
+ });
+ } finally {
+ try { db.close(); } catch (_) { /* ignore */ }
+ }
+ }
- return success;
+ async function persistMeta(patch) {
+ if (state.suspended) return false;
+ state.meta = Object.assign({}, state.meta, cloneMeta(Object.assign({}, state.meta, patch || {})));
+ try {
+ await writeStoredValues((function () {
+ var values = {};
+ values[META_KEY] = state.meta;
+ return values;
+ })());
} catch (error) {
- console.error('[Storage] 保存词表失败:', error);
- return false;
+ if (global.console && console.warn) console.warn('[ExternalBackup v2] metadata persistence failed:', error);
}
+ return true;
}
- /**
- * 加载词表数据
- */
- async loadVocabList(listId, options = {}) {
- const { skipReady = false } = options;
-
+ async function queryPermission(handle, mode) {
+ if (!handle) return 'denied';
try {
- const keys = this.getVocabStorageKeys();
- let storageKey;
-
- // 根据 listId 确定存储键
- if (listId === 'spelling-errors-p1') {
- storageKey = keys.P1_ERRORS;
- } else if (listId === 'spelling-errors-p4') {
- storageKey = keys.P4_ERRORS;
- } else if (listId === 'spelling-errors-master') {
- storageKey = keys.MASTER_ERRORS;
- } else if (listId === 'custom') {
- storageKey = keys.CUSTOM;
- } else if (listId === 'reading-highlights') {
- storageKey = keys.READING_HIGHLIGHTS;
- } else {
- storageKey = listId;
- }
-
- console.log(`[Storage] 加载词表: ${storageKey}`);
-
- const vocabList = await this.get(storageKey, null, { skipReady });
-
- if (!vocabList) {
- console.log(`[Storage] 词表不存在: ${storageKey}`);
- return null;
- }
-
- if (Array.isArray(vocabList)) {
- const now = new Date().toISOString();
- const sourceMap = {
- 'spelling-errors-p1': 'p1',
- 'spelling-errors-p4': 'p4',
- 'spelling-errors-master': 'all',
- 'custom': 'user',
- 'reading-highlights': 'reading-highlight'
- };
- const nameMap = {
- 'spelling-errors-p1': 'P1 拼写错误',
- 'spelling-errors-p4': 'P4 拼写错误',
- 'spelling-errors-master': '综合错误词表',
- 'custom': '自定义词表',
- 'reading-highlights': '阅读高亮生词'
- };
- return {
- id: listId,
- name: nameMap[listId] || listId,
- source: sourceMap[listId] || listId,
- words: vocabList,
- createdAt: now,
- updatedAt: now
- };
+ if (typeof handle.queryPermission === 'function') {
+ return await handle.queryPermission({ mode: mode || 'readwrite' });
}
+ } catch (_) { /* ignore */ }
+ return 'prompt';
+ }
- // 验证加载的数据
- const validation = this.validateVocabList(vocabList);
- if (!validation.valid) {
- console.error('[Storage] 加载的词表数据无效:', validation.error);
- return null;
+ async function ensurePermission(handle, interactive) {
+ var permission = await queryPermission(handle, 'readwrite');
+ if (permission === 'granted') {
+ state.permission = permission;
+ return true;
+ }
+ if (!interactive) {
+ state.permission = permission;
+ return false;
+ }
+ try {
+ if (typeof handle.requestPermission === 'function') {
+ permission = await handle.requestPermission({ mode: 'readwrite' });
}
-
- console.log(`[Storage] 词表加载成功: ${storageKey}, 单词数: ${vocabList.words.length}`);
- return vocabList;
- } catch (error) {
- console.error('[Storage] 加载词表失败:', error);
- return null;
+ } catch (_) {
+ permission = 'denied';
}
+ state.permission = permission;
+ return permission === 'granted';
}
- /**
- * 获取词表单词数量
- */
- async getVocabListWordCount(listId, options = {}) {
- const { skipReady = false } = options;
-
+ async function requestPersistentStorage() {
try {
- const vocabList = await this.loadVocabList(listId, { skipReady });
- return vocabList ? vocabList.words.length : 0;
- } catch (error) {
- console.error('[Storage] 获取词表单词数量失败:', error);
- return 0;
+ var storage = global.navigator && global.navigator.storage;
+ if (!storage || typeof storage.persist !== 'function') return false;
+ if (typeof storage.persisted === 'function' && await storage.persisted()) return true;
+ return await storage.persist();
+ } catch (_) {
+ return false;
}
}
- /**
- * 添加单词到词表
- */
- async addWordToVocabList(listId, word, options = {}) {
- const { skipReady = false } = options;
-
+ async function writeAndVerify(directoryHandle, filename, text, snapshot) {
+ var fileHandle = await directoryHandle.getFileHandle(filename, { create: true });
+ var writable = await fileHandle.createWritable();
try {
- let vocabList = await this.loadVocabList(listId, { skipReady });
-
- if (!vocabList) {
- // 创建新词表
- vocabList = {
- id: listId,
- name: this.getVocabListName(listId),
- source: this.getVocabListSource(listId),
- words: [],
- createdAt: Date.now(),
- updatedAt: Date.now()
- };
- }
-
- // 检查单词是否已存在
- const existingIndex = vocabList.words.findIndex(w =>
- w.word.toLowerCase() === word.word.toLowerCase()
- );
-
- if (existingIndex >= 0) {
- // 更新现有单词
- vocabList.words[existingIndex] = {
- ...vocabList.words[existingIndex],
- ...word,
- errorCount: (vocabList.words[existingIndex].errorCount || 0) + 1,
- timestamp: Date.now()
- };
- } else {
- // 添加新单词
- vocabList.words.push({
- ...word,
- errorCount: word.errorCount || 1,
- timestamp: word.timestamp || Date.now()
- });
- }
-
- vocabList.updatedAt = Date.now();
-
- return await this.saveVocabList(vocabList, { skipReady });
+ await writable.write(text);
+ await writable.close();
} catch (error) {
- console.error('[Storage] 添加单词到词表失败:', error);
- return false;
+ try { await writable.abort(); } catch (_) { /* ignore */ }
+ throw error;
}
- }
-
- /**
- * 从词表中移除单词
- */
- async removeWordFromVocabList(listId, word, options = {}) {
- const { skipReady = false } = options;
+ var file = await fileHandle.getFile();
+ var storedText = await file.text();
+ var stored;
try {
- const vocabList = await this.loadVocabList(listId, { skipReady });
-
- if (!vocabList) {
- return false;
- }
-
- const normalizedWord = word.toLowerCase().trim();
- vocabList.words = vocabList.words.filter(w =>
- w.word.toLowerCase().trim() !== normalizedWord
- );
-
- vocabList.updatedAt = Date.now();
-
- return await this.saveVocabList(vocabList, { skipReady });
+ stored = JSON.parse(storedText);
} catch (error) {
- console.error('[Storage] 从词表移除单词失败:', error);
- return false;
+ throw new Error('Backup verification failed: written file is not valid JSON');
+ }
+ if (!stored || stored.format !== 'ielts-atlas-data-v2'
+ || stored.schemaVersion !== snapshot.schemaVersion
+ || stored.checksum !== snapshot.checksum) {
+ throw new Error('Backup verification failed: snapshot metadata mismatch');
}
+ return storedText.length;
}
- /**
- * 获取词表名称
- */
- getVocabListName(listId) {
- const names = {
- 'spelling-errors-p1': 'P1 拼写错误',
- 'spelling-errors-p4': 'P4 拼写错误',
- 'spelling-errors-master': '综合错误词表',
- 'custom': '自定义词表'
- };
- return names[listId] || listId;
+ async function fileExists(directoryHandle, filename) {
+ try {
+ await directoryHandle.getFileHandle(filename, { create: false });
+ return true;
+ } catch (error) {
+ if (error && error.name === 'NotFoundError') return false;
+ throw error;
+ }
}
- /**
- * 获取词表来源
- */
- getVocabListSource(listId) {
- if (listId.includes('p1')) return 'p1';
- if (listId.includes('p4')) return 'p4';
- if (listId.includes('master')) return 'all';
- return 'user';
+ function uniqueGenerationFilename(date) {
+ var time = [
+ String(date.getHours()).padStart(2, '0'),
+ String(date.getMinutes()).padStart(2, '0'),
+ String(date.getSeconds()).padStart(2, '0'),
+ String(date.getMilliseconds()).padStart(3, '0')
+ ].join('');
+ return 'ielts-atlas-backup-' + dayKey(date) + '-' + time + '.json';
}
- /**
- * 获取所有词表的元数据
- */
- async getAllVocabListsMetadata(options = {}) {
- const { skipReady = false } = options;
-
- const keys = this.getVocabStorageKeys();
- const listIds = [
- 'spelling-errors-p1',
- 'spelling-errors-p4',
- 'spelling-errors-master',
- 'custom'
- ];
-
- const metadata = [];
-
- for (const listId of listIds) {
- const count = await this.getVocabListWordCount(listId, { skipReady });
- metadata.push({
- id: listId,
- name: this.getVocabListName(listId),
- source: this.getVocabListSource(listId),
- wordCount: count
- });
+ function requireBackupApi() {
+ var backups = global.AppData && global.AppData.backups;
+ if (!backups || typeof backups.export !== 'function'
+ || typeof backups.previewImport !== 'function'
+ || typeof backups.commitImport !== 'function') {
+ throw new Error('AppData v2 backup API is unavailable');
}
-
- return metadata;
+ return backups;
}
- // ==================== 数据同步逻辑 ====================
-
- /**
- * 同步词表数据(跨会话)
- * 处理数据冲突,使用最新时间戳
- */
- async syncVocabList(listId, newData, options = {}) {
- const { skipReady = false } = options;
-
+ async function withDiskWriteLock(callback) {
+ var previous = state.writeQueue.catch(function () {});
+ var releaseCurrent;
+ state.writeQueue = new Promise(function (resolve) {
+ releaseCurrent = resolve;
+ });
+ await previous;
try {
- console.log(`[Storage] 开始同步词表: ${listId}`);
-
- // 加载现有数据
- const existingList = await this.loadVocabList(listId, { skipReady });
-
- if (!existingList) {
- // 没有现有数据,直接保存新数据
- console.log(`[Storage] 无现有数据,直接保存新词表`);
- return await this.saveVocabList(newData, { skipReady });
+ var locks = global.navigator && global.navigator.locks;
+ if (locks && typeof locks.request === 'function') {
+ return await locks.request('ielts-atlas-external-backup-write', { mode: 'exclusive' }, callback);
}
-
- // 合并数据,解决冲突
- const mergedList = this.mergeVocabLists(existingList, newData);
-
- console.log(`[Storage] 词表合并完成,单词数: ${mergedList.words.length}`);
-
- // 保存合并后的数据
- return await this.saveVocabList(mergedList, { skipReady });
- } catch (error) {
- console.error('[Storage] 同步词表失败:', error);
- return false;
+ return await callback();
+ } finally {
+ releaseCurrent();
}
}
- /**
- * 合并两个词表,解决冲突
- * 使用最新时间戳的数据
- */
- mergeVocabLists(existing, incoming) {
- // 使用最新的元数据
- const merged = {
- id: existing.id,
- name: existing.name,
- source: existing.source,
- words: [],
- createdAt: existing.createdAt,
- updatedAt: Math.max(existing.updatedAt, incoming.updatedAt)
- };
-
- // 创建单词映射
- const wordMap = new Map();
+ async function refreshStoredBindingForWrite() {
+ if (state.suspended) return;
+ var stored = await Promise.all([
+ readStoredValue(HANDLE_KEY),
+ readStoredValue(META_KEY)
+ ]);
+ state.directoryHandle = stored[0] || null;
+ if (stored[1]) state.meta = cloneMeta(stored[1]);
+ if (state.directoryHandle && !state.meta.directoryName) {
+ state.meta.directoryName = state.directoryHandle.name || 'backup';
+ }
+ }
- // 先添加现有单词
- existing.words.forEach(word => {
- const key = word.word.toLowerCase().trim();
- wordMap.set(key, word);
- });
+ async function writeToBoundDirectory(options) {
+ var opts = options || {};
+ await ensureReady();
+ if (state.suspended) return { success: false, reason: 'suspended' };
+ return withDiskWriteLock(async function () {
+ if (state.suspended) return { success: false, reason: 'suspended' };
+ if (state.writing) return { success: false, reason: 'busy' };
+ try {
+ await refreshStoredBindingForWrite();
+ } catch (error) {
+ return { success: false, reason: 'binding_unavailable', error: error };
+ }
+ if (!state.directoryHandle) return { success: false, reason: 'unbound' };
+ if (state.meta.awaitingRestore && opts.allowOverwriteExisting !== true) {
+ return { success: false, reason: 'restore_required' };
+ }
- // 合并新单词,使用最新时间戳
- incoming.words.forEach(word => {
- const key = word.word.toLowerCase().trim();
- const existingWord = wordMap.get(key);
-
- if (!existingWord || word.timestamp > existingWord.timestamp) {
- // 新单词或更新的单词
- wordMap.set(key, {
- ...existingWord,
- ...word,
- errorCount: (existingWord?.errorCount || 0) + (word.errorCount || 1)
+ var startedGeneration = state.dirtyGeneration;
+ var followupNeeded = false;
+ state.writing = true;
+ refreshPanel();
+ try {
+ if (!await ensurePermission(state.directoryHandle, opts.interactive === true)) {
+ await persistMeta({ lastWriteError: 'permission_denied' });
+ return { success: false, reason: 'permission_denied' };
+ }
+
+ var backups = requireBackupApi();
+ var snapshot = await backups.export();
+ if (!snapshot || snapshot.format !== 'ielts-atlas-data-v2' || !snapshot.checksum) {
+ throw new Error('AppData returned an invalid v2 backup snapshot');
+ }
+ if (!opts.force && snapshot.checksum === state.meta.lastChecksum) {
+ state.dirty = state.dirtyGeneration !== startedGeneration;
+ followupNeeded = state.dirty;
+ return { success: true, reason: 'unchanged', skipped: true, checksum: snapshot.checksum };
+ }
+
+ var text = JSON.stringify(snapshot, null, 2);
+ var writeDate = new Date();
+ var datedFilename = 'ielts-atlas-backup-' + dayKey(writeDate) + '.json';
+ if (await fileExists(state.directoryHandle, datedFilename)) {
+ datedFilename = uniqueGenerationFilename(writeDate);
+ }
+ await writeAndVerify(state.directoryHandle, datedFilename, text, snapshot);
+ var bytes = await writeAndVerify(state.directoryHandle, LATEST_FILENAME, text, snapshot);
+
+ var latestSnapshot = await backups.export();
+ var changedDuringWrite = state.dirtyGeneration !== startedGeneration
+ || !latestSnapshot || latestSnapshot.checksum !== snapshot.checksum;
+ if (changedDuringWrite && state.dirtyGeneration === startedGeneration) {
+ state.dirtyGeneration += 1;
+ }
+ state.dirty = changedDuringWrite;
+ followupNeeded = changedDuringWrite;
+ await persistMeta({
+ directoryName: state.directoryHandle.name || state.meta.directoryName || 'backup',
+ lastWriteAt: nowIso(),
+ lastChecksum: snapshot.checksum,
+ lastWriteError: null
});
+ return {
+ success: true,
+ reason: 'written',
+ filename: LATEST_FILENAME,
+ generationFilename: datedFilename,
+ checksum: snapshot.checksum,
+ bytes: bytes,
+ followupPending: followupNeeded
+ };
+ } catch (error) {
+ followupNeeded = state.dirtyGeneration !== startedGeneration;
+ await persistMeta({ lastWriteError: error && error.message ? error.message : String(error) });
+ if (global.console && console.error) console.error('[ExternalBackup v2] write failed:', error);
+ return { success: false, reason: 'write_error', error: error };
+ } finally {
+ state.writing = false;
+ if (followupNeeded && state.directoryHandle) scheduleSilentFlush();
+ refreshPanel();
}
});
-
- merged.words = Array.from(wordMap.values());
-
- return merged;
}
- /**
- * 批量同步所有词表
- */
- async syncAllVocabLists(options = {}) {
- const { skipReady = false } = options;
-
- try {
- console.log('[Storage] 开始批量同步所有词表');
-
- const listIds = [
- 'spelling-errors-p1',
- 'spelling-errors-p4',
- 'spelling-errors-master',
- 'custom'
- ];
-
- const results = [];
+ async function bindDirectory(options) {
+ if (state.suspended) throw new Error('本地备份服务正在重置');
+ if (!supportsFileSystemAccess()) {
+ throw new Error('当前浏览器不支持绑定本地文件夹(请使用 Chrome/Edge 并通过 http(s) 或 localhost 打开)');
+ }
+ var handle = await global.showDirectoryPicker({
+ id: 'ielts-atlas-external-backup',
+ mode: 'readwrite',
+ startIn: 'documents'
+ });
+ if (!handle) throw new Error('未选择文件夹');
+ if (!await ensurePermission(handle, true)) throw new Error('未获得文件夹读写权限');
- for (const listId of listIds) {
- const list = await this.loadVocabList(listId, { skipReady });
- if (list) {
- const success = await this.syncVocabList(listId, list, { skipReady });
- results.push({ listId, success });
- }
- }
+ var existingBackupFound = await fileExists(handle, LATEST_FILENAME);
+ var meta = cloneMeta({
+ directoryName: handle.name || 'backup',
+ lastWriteAt: null,
+ lastChecksum: null,
+ lastWriteError: null,
+ awaitingRestore: existingBackupFound
+ });
+ var values = {};
+ values[HANDLE_KEY] = handle;
+ values[META_KEY] = meta;
+ await writeStoredValues(values);
+ state.directoryHandle = handle;
+ state.meta = meta;
+ state.dirty = !existingBackupFound;
+ state.dirtyGeneration += 1;
+ await requestPersistentStorage();
- console.log('[Storage] 批量同步完成:', results);
- return results;
- } catch (error) {
- console.error('[Storage] 批量同步失败:', error);
- return [];
+ var writeResult = null;
+ if (!existingBackupFound && (!options || options.writeNow !== false)) {
+ writeResult = await writeToBoundDirectory({ interactive: true, force: true });
}
+ refreshPanel();
+ return {
+ directoryName: meta.directoryName,
+ existingBackupFound: existingBackupFound,
+ writeResult: writeResult
+ };
}
- /**
- * 确保数据持久化(页面关闭前)
- */
- async ensureDataPersisted(options = {}) {
- const { skipReady = false } = options;
-
- try {
- console.log('[Storage] 确保数据持久化');
-
- // 强制刷新所有待写入的数据
- if (this.indexedDB) {
- // IndexedDB 事务会自动提交,无需额外操作
- console.log('[Storage] IndexedDB 数据已自动持久化');
- }
-
- // 同步所有词表
- await this.syncAllVocabLists({ skipReady });
-
- console.log('[Storage] 数据持久化完成');
- return true;
- } catch (error) {
- console.error('[Storage] 数据持久化失败:', error);
- return false;
+ function cancelSilentFlush() {
+ if (state.silentFlushTimer) {
+ global.clearTimeout(state.silentFlushTimer);
+ state.silentFlushTimer = null;
}
}
- /**
- * 监听页面卸载事件,确保数据持久化
- */
- setupBeforeUnloadHandler() {
- // 使用 beforeunload 事件确保数据保存
- window.addEventListener('beforeunload', async (event) => {
- try {
- console.log('[Storage] 页面即将关闭,确保数据持久化');
-
- // 同步保存所有待写入的数据
- await this.ensureDataPersisted({ skipReady: true });
+ function clearBindingState() {
+ state.directoryHandle = null;
+ state.permission = 'prompt';
+ state.dirty = false;
+ state.dirtyGeneration += 1;
+ state.meta = cloneMeta({});
+ refreshPanel();
+ }
- console.log('[Storage] 数据持久化完成');
- } catch (error) {
- console.error('[Storage] beforeunload 数据持久化失败:', error);
- }
+ async function unbindDirectory() {
+ cancelSilentFlush();
+ await withDiskWriteLock(async function () {
+ await clearStoredBinding();
+ clearBindingState();
});
-
- console.log('[Storage] beforeunload 处理器已设置');
+ return true;
}
- /**
- * 检测数据冲突
- */
- detectVocabListConflict(list1, list2) {
- if (!list1 || !list2) return false;
-
- // 检查是否有相同单词但不同内容
- const conflicts = [];
-
- const map1 = new Map(list1.words.map(w => [w.word.toLowerCase(), w]));
- const map2 = new Map(list2.words.map(w => [w.word.toLowerCase(), w]));
-
- for (const [word, data1] of map1) {
- const data2 = map2.get(word);
- if (data2 && data1.timestamp !== data2.timestamp) {
- conflicts.push({
- word,
- data1,
- data2,
- resolution: data1.timestamp > data2.timestamp ? 'use_list1' : 'use_list2'
- });
- }
+ async function prepareForFullReset() {
+ state.suspended = true;
+ cancelSilentFlush();
+ if (typeof state.unsubscribeCommitted === 'function') {
+ try { state.unsubscribeCommitted(); } catch (_) { /* ignore */ }
}
-
- return conflicts.length > 0 ? conflicts : false;
- }
-
- /**
- * 解决词表冲突
- */
- resolveVocabListConflict(list1, list2, strategy = 'latest') {
- if (strategy === 'latest') {
- return this.mergeVocabLists(list1, list2);
- } else if (strategy === 'keep_list1') {
- return list1;
- } else if (strategy === 'keep_list2') {
- return list2;
+ state.unsubscribeCommitted = null;
+ if (global.document && state.visibilityHandler) {
+ try { global.document.removeEventListener('visibilitychange', state.visibilityHandler); } catch (_) { /* ignore */ }
}
+ state.visibilityHandler = null;
+ state.initialized = false;
- return this.mergeVocabLists(list1, list2);
+ await withDiskWriteLock(async function () {
+ await clearStoredBinding();
+ clearBindingState();
+ });
+ return {
+ success: true,
+ diskFilesPreserved: true,
+ bindingCleared: true
+ };
}
- // ==================== 降级存储方案 ====================
-
- /**
- * 检测 IndexedDB 可用性
- */
- isIndexedDBAvailable() {
+ async function readLatestPayload(interactive) {
+ await ensureReady();
+ if (!state.directoryHandle) throw new Error('请先绑定备份文件夹');
+ if (!await ensurePermission(state.directoryHandle, interactive === true)) {
+ throw new Error('需要允许文件夹访问权限');
+ }
+ var fileHandle;
try {
- // 检查浏览器是否支持 IndexedDB
- if (!window.indexedDB) {
- console.log('[Storage] IndexedDB 不支持');
- return false;
- }
-
- // 检查是否已成功初始化
- if (this.indexedDB) {
- console.log('[Storage] IndexedDB 可用');
- return true;
- }
-
- console.log('[Storage] IndexedDB 未初始化');
- return false;
+ fileHandle = await state.directoryHandle.getFileHandle(LATEST_FILENAME, { create: false });
} catch (error) {
- console.error('[Storage] IndexedDB 可用性检测失败:', error);
- return false;
+ throw new Error('未找到 ' + LATEST_FILENAME);
}
- }
-
- /**
- * 检测 localStorage 可用性
- */
- isLocalStorageAvailable() {
+ var file = await fileHandle.getFile();
+ var text = await file.text();
try {
- const testKey = '__storage_test__';
- localStorage.setItem(testKey, 'test');
- localStorage.removeItem(testKey);
- console.log('[Storage] localStorage 可用');
- return true;
- } catch (error) {
- console.error('[Storage] localStorage 不可用:', error);
- return false;
+ return JSON.parse(text);
+ } catch (_) {
+ throw new Error('本地备份文件不是有效的 JSON');
}
}
- /**
- * 获取当前存储类型
- */
- getCurrentStorageType() {
- if (this.fallbackStorage) {
- return 'memory';
- } else if (this.indexedDB) {
- return 'indexedDB';
- } else if (this.isLocalStorageAvailable()) {
- return 'localStorage';
+ function summarizePreview(preview) {
+ var keys = Array.isArray(preview.keys) ? preview.keys : [];
+ var cleared = Array.isArray(preview.clearedKeys) ? preview.clearedKeys : [];
+ var practice = preview.practice || {};
+ var lines = [
+ '将从本地磁盘备份覆盖恢复当前数据。',
+ '格式:' + (preview.format || 'unknown') + (preview.scope ? ' / ' + preview.scope : ''),
+ '数据域:' + (keys.length ? keys.join('、') : '无')
+ ];
+ if (cleared.length) lines.push('将清空:' + cleared.join('、'));
+ if (practice && Number.isFinite(Number(practice.finalCount))) {
+ lines.push('练习记录:现有 ' + (Number(practice.existingCount) || 0)
+ + ' 条 → 恢复后 ' + Number(practice.finalCount) + ' 条'
+ + '(删除 ' + (Number(practice.removedCount) || 0) + ' 条)');
+ }
+ var diagnostics = preview.diagnostics || {};
+ if (Array.isArray(diagnostics.missingKeys) && diagnostics.missingKeys.length) {
+ lines.push('备份缺失且将保留现状:' + diagnostics.missingKeys.join('、'));
+ }
+ if (Array.isArray(diagnostics.repairedKeys) && diagnostics.repairedKeys.length) {
+ lines.push('已修复旧格式数据:' + diagnostics.repairedKeys.join('、'));
+ }
+ if (Array.isArray(diagnostics.ignoredKeys) && diagnostics.ignoredKeys.length) {
+ lines.push('已隔离不安全数据:' + diagnostics.ignoredKeys.join('、'));
+ }
+ if (Array.isArray(preview.warnings) && preview.warnings.length) {
+ lines.push('警告:' + preview.warnings.join(';'));
}
- return 'none';
+ lines.push('', '恢复前会创建一个应用内安全快照。是否继续?');
+ return lines.join('\n');
}
- /**
- * 处理存储空间不足
- */
- async handleStorageQuotaExceeded(key, value, options = {}) {
- console.warn('[Storage] 存储空间不足,尝试清理');
-
+ function createOperationId(prefix) {
try {
- if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) {
- console.error(`[Storage] ${key} 空间不足时禁止 raw fallback`);
- if (window.showMessage) {
- window.showMessage('练习数据保存空间不足,请先导出备份并清理空间', 'error');
- }
- return false;
+ if (global.crypto && typeof global.crypto.randomUUID === 'function') {
+ return prefix + '-' + global.crypto.randomUUID();
}
+ } catch (_) { /* ignore */ }
+ return prefix + '-' + Date.now() + '-' + Math.random().toString(16).slice(2);
+ }
- // 1. 清理旧数据
- await this.cleanupOldData({ skipReady: true });
-
- // 2. 再次尝试保存
- const retrySuccess = await this.set(key, value, { skipReady: true });
- if (retrySuccess) {
- console.log('[Storage] 清理后保存成功');
- return true;
+ async function restorePayload(payload, options) {
+ var opts = options || {};
+ var backups = requireBackupApi();
+ var preview = await backups.previewImport(payload, {
+ replace: true,
+ practiceMode: 'replace',
+ applyClears: true,
+ fullRestore: true
+ });
+ var confirmed = opts.confirmed === true;
+ if (!confirmed) {
+ try {
+ confirmed = global.confirm(summarizePreview(preview));
+ } catch (_) {
+ confirmed = false;
}
+ }
+ if (!confirmed) return { success: false, reason: 'cancelled', preview: preview };
- // 3. 如果仍然失败,尝试降级存储
- console.warn('[Storage] 清理后仍然失败,尝试降级存储');
-
- const storageType = this.getCurrentStorageType();
-
- if (storageType === 'indexedDB') {
- // 降级到 localStorage
- console.log('[Storage] 从 IndexedDB 降级到 localStorage');
- try {
- const serializedValue = JSON.stringify({
- data: value,
- timestamp: Date.now(),
- version: this.version
- });
- localStorage.setItem(this.getKey(key), serializedValue);
- console.log('[Storage] localStorage 保存成功');
- return true;
- } catch (localStorageError) {
- console.error('[Storage] localStorage 保存失败:', localStorageError);
- }
+ await backups.create({
+ type: 'pre-external-restore',
+ operationId: createOperationId('pre-external-restore')
+ });
+ var result = await backups.commitImport(preview.id, {
+ operationId: opts.operationId || createOperationId('external-restore'),
+ confirmDestructive: preview.destructive === true
+ });
+ try {
+ if (typeof backups.recordImport === 'function') {
+ await backups.recordImport({
+ source: 'external-backup',
+ format: preview.format,
+ keys: preview.keys,
+ clearedKeys: preview.clearedKeys,
+ practice: preview.practice || null
+ });
}
+ } catch (historyError) {
+ if (global.console && console.warn) console.warn('[ExternalBackup v2] import history failed:', historyError);
+ }
+ return { success: true, preview: preview, result: result };
+ }
- // 4. 最后降级到内存存储
- console.warn('[Storage] 降级到内存存储');
- if (!this.fallbackStorage) {
- this.fallbackStorage = new Map();
- }
- const serializedValue = JSON.stringify({
- data: value,
- timestamp: Date.now(),
- version: this.version
+ async function restoreFromLatest(options) {
+ var payload = await readLatestPayload(true);
+ var result = await restorePayload(payload, options);
+ if (result && result.success) {
+ state.dirty = false;
+ await persistMeta({
+ lastChecksum: payload && payload.checksum ? payload.checksum : state.meta.lastChecksum,
+ lastWriteError: null,
+ awaitingRestore: false
});
- this.fallbackStorage.set(this.getKey(key), serializedValue);
-
- // 提示用户
- if (window.showMessage) {
- window.showMessage('存储空间不足,数据已保存到临时存储,请导出备份', 'warning');
- }
-
- return true;
- } catch (error) {
- console.error('[Storage] 处理存储空间不足失败:', error);
-
- // 最终失败,提示用户
- if (window.showMessage) {
- window.showMessage('存储空间严重不足,无法保存数据,请清理旧数据', 'error');
- }
-
- return false;
}
+ return result;
}
- /**
- * 词表专用降级保存
- */
- async saveVocabListWithFallback(vocabList, options = {}) {
- const { skipReady = false } = options;
-
- try {
- // 首先尝试正常保存
- const success = await this.saveVocabList(vocabList, { skipReady });
-
- if (success) {
- return true;
- }
-
- // 如果失败,尝试降级保存
- console.warn('[Storage] 词表保存失败,尝试降级保存');
-
- // 压缩词表数据
- const compressedList = this.compressVocabList(vocabList);
-
- // 再次尝试保存压缩后的数据
- const compressedSuccess = await this.saveVocabList(compressedList, { skipReady });
+ function scheduleSilentFlush() {
+ if (state.suspended || state.meta.awaitingRestore) return;
+ if (state.silentFlushTimer) global.clearTimeout(state.silentFlushTimer);
+ state.silentFlushTimer = global.setTimeout(function () {
+ state.silentFlushTimer = null;
+ return flushSilentlyIfPermitted().catch(function (error) {
+ if (global.console && console.warn) console.warn('[ExternalBackup v2] silent flush failed:', error);
+ });
+ }, WRITE_DELAY_MS);
+ }
- if (compressedSuccess) {
- console.log('[Storage] 压缩后保存成功');
- return true;
- }
+ function markDirty() {
+ if (state.suspended) return;
+ state.dirty = true;
+ state.dirtyGeneration += 1;
+ refreshPanel();
+ scheduleSilentFlush();
+ }
- // 如果仍然失败,使用降级存储
- return await this.handleStorageQuotaExceeded(
- this.getVocabStorageKey(vocabList.id),
- compressedList
- );
- } catch (error) {
- console.error('[Storage] 词表降级保存失败:', error);
- return false;
+ async function flushSilentlyIfPermitted() {
+ await ensureReady();
+ if (state.suspended) return { success: false, reason: 'suspended' };
+ if (state.meta.awaitingRestore) return { success: false, reason: 'restore_required' };
+ if (!state.directoryHandle || !state.dirty) {
+ return { success: false, reason: 'skip' };
+ }
+ if (state.writing) {
+ scheduleSilentFlush();
+ return { success: false, reason: 'busy' };
+ }
+ if (!await ensurePermission(state.directoryHandle, false)) {
+ refreshPanel();
+ return { success: false, reason: 'permission_denied' };
}
+ return writeToBoundDirectory({ interactive: false, force: false });
}
- /**
- * 压缩词表数据
- */
- compressVocabList(vocabList) {
+ function getStatus() {
return {
- id: vocabList.id,
- name: vocabList.name,
- source: vocabList.source,
- words: vocabList.words.map(word => ({
- word: word.word,
- userInput: word.userInput,
- timestamp: word.timestamp,
- errorCount: word.errorCount
- // 移除其他非必要字段
- })),
- createdAt: vocabList.createdAt,
- updatedAt: vocabList.updatedAt
+ supported: supportsFileSystemAccess(),
+ bound: !!state.directoryHandle,
+ directoryName: state.meta.directoryName,
+ permission: state.permission,
+ permissionGranted: state.permission === 'granted',
+ dirty: state.dirty,
+ writing: state.writing,
+ suspended: state.suspended,
+ lastWriteAt: state.meta.lastWriteAt,
+ lastChecksum: state.meta.lastChecksum,
+ lastWriteError: state.meta.lastWriteError,
+ awaitingRestore: state.meta.awaitingRestore
};
}
- /**
- * 获取词表存储键
- */
- getVocabStorageKey(listId) {
- const keys = this.getVocabStorageKeys();
-
- if (listId === 'spelling-errors-p1') return keys.P1_ERRORS;
- if (listId === 'spelling-errors-p4') return keys.P4_ERRORS;
- if (listId === 'spelling-errors-master') return keys.MASTER_ERRORS;
- if (listId === 'custom') return keys.CUSTOM;
-
- return listId;
+ function formatTime(value) {
+ if (!value) return '';
+ var parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString();
}
- /**
- * 检查存储健康状态
- */
- async checkStorageHealth(options = {}) {
- const { skipReady = false } = options;
-
- try {
- const health = {
- indexedDB: this.isIndexedDBAvailable(),
- localStorage: this.isLocalStorageAvailable(),
- currentType: this.getCurrentStorageType(),
- quotaStatus: 'unknown'
- };
+ function formatStatusText(status) {
+ if (!status.supported) return '当前环境不支持文件夹绑定,请使用「导出到下载」和「导入数据」。';
+ if (!status.bound) return '未绑定本地备份文件夹。';
+ var parts = ['已绑定:' + (status.directoryName || '文件夹')];
+ if (status.awaitingRestore) parts.push('检测到已有备份,请先恢复');
+ if (!status.permissionGranted) parts.push('需要重新授权');
+ if (status.writing) parts.push('正在写入');
+ else if (status.lastWriteAt) parts.push('上次写入 ' + formatTime(status.lastWriteAt));
+ else parts.push('尚未写入');
+ if (status.dirty) parts.push('有未备份的新数据');
+ if (status.lastWriteError) parts.push('最近错误:' + status.lastWriteError);
+ return parts.join(' · ');
+ }
- // 检查配额状态
- const storageInfo = await this.getStorageInfo({ skipReady });
- if (storageInfo) {
- const usagePercent = storageInfo.type === 'localStorage'
- ? (storageInfo.used / (5 * 1024 * 1024)) * 100
- : (storageInfo.used / (105 * 1024 * 1024)) * 100;
-
- if (usagePercent < 70) {
- health.quotaStatus = 'healthy';
- } else if (usagePercent < 90) {
- health.quotaStatus = 'warning';
- } else {
- health.quotaStatus = 'critical';
- }
+ function notify(message, type) {
+ if (typeof global.showMessage === 'function') {
+ global.showMessage(message, type || 'info');
+ } else if (global.console && console.log) {
+ console.log('[ExternalBackup v2] ' + message);
+ }
+ }
- health.usagePercent = usagePercent;
- health.used = storageInfo.used;
- }
+ function makeButton(id, label) {
+ var button = global.document.createElement('button');
+ button.type = 'button';
+ button.id = id;
+ button.className = 'btn data-mgmt-btn';
+ button.textContent = label;
+ return button;
+ }
- console.log('[Storage] 存储健康状态:', health);
- return health;
- } catch (error) {
- console.error('[Storage] 检查存储健康状态失败:', error);
- return {
- indexedDB: false,
- localStorage: false,
- currentType: 'none',
- quotaStatus: 'error'
- };
- }
+ function getModal() {
+ return global.document ? global.document.getElementById(MODAL_ID) : null;
}
- // ==================== 数据导出功能 ====================
+ function ensureModalDom() {
+ if (!global.document || !global.document.body) return null;
+ var existing = getModal();
+ if (existing) return existing;
- /**
- * 导出练习记录
- */
- async exportPracticeRecords(options = {}) {
- const { skipReady = false, format = 'json' } = options;
+ var modal = global.document.createElement('div');
+ modal.id = MODAL_ID;
+ modal.className = 'theme-modal external-backup-modal shui-secondary-modal shui-secondary-modal--sm';
+ modal.setAttribute('role', 'dialog');
+ modal.setAttribute('aria-modal', 'true');
+ modal.setAttribute('aria-labelledby', 'external-backup-title');
- try {
- console.log('[Storage] 开始导出练习记录');
+ var content = global.document.createElement('div');
+ content.className = 'theme-modal-content external-backup-modal__content shui-secondary-modal__content';
+ var header = global.document.createElement('div');
+ header.className = 'theme-modal-header external-backup-modal__header shui-secondary-modal__header';
+ var title = global.document.createElement('h3');
+ title.id = 'external-backup-title';
+ title.textContent = '本地磁盘备份';
+ var closeButton = global.document.createElement('button');
+ closeButton.type = 'button';
+ closeButton.className = 'theme-modal-close';
+ closeButton.setAttribute('aria-label', '关闭');
+ closeButton.innerHTML = '×';
+ header.appendChild(title);
+ header.appendChild(closeButton);
- const records = await this.listPracticeRecordsCanonical({ skipReady });
+ var body = global.document.createElement('div');
+ body.className = 'theme-modal-body external-backup-modal__body shui-secondary-modal__body';
+ var panel = global.document.createElement('div');
+ panel.id = 'external-backup-panel';
+ panel.className = 'external-backup-panel external-backup-panel--modal';
+ var description = global.document.createElement('p');
+ description.className = 'external-backup-panel__desc';
+ description.textContent = '绑定本地文件夹后,IELTS Atlas 会写入完整的 v2 数据快照。磁盘文件不会因清理浏览器站点数据而删除;后台写入不会主动请求权限。';
+ var statusCard = global.document.createElement('div');
+ statusCard.className = 'external-backup-status-card';
+ var statusLabel = global.document.createElement('div');
+ statusLabel.className = 'external-backup-status-card__label';
+ statusLabel.textContent = '当前状态';
+ var statusText = global.document.createElement('div');
+ statusText.id = 'external-backup-status';
+ statusText.className = 'external-backup-panel__status';
+ statusText.textContent = '状态加载中…';
+ statusCard.appendChild(statusLabel);
+ statusCard.appendChild(statusText);
- const exportData = {
- type: 'practice_records',
- version: this.version,
- exportDate: new Date().toISOString(),
- recordCount: records.length,
- records: records
- };
+ var tips = global.document.createElement('ul');
+ tips.className = 'external-backup-panel__tips';
+ [
+ '支持 Chrome / Edge 的安全上下文;其他环境继续使用手动导出',
+ '备份文件包含练习、设置、词汇、题库配置等可迁移数据',
+ '磁盘 JSON 为明文文件,请妥善保管'
+ ].forEach(function (text) {
+ var item = global.document.createElement('li');
+ item.textContent = text;
+ tips.appendChild(item);
+ });
- console.log(`[Storage] 练习记录导出完成,共 ${records.length} 条`);
+ var actions = global.document.createElement('div');
+ actions.className = 'external-backup-panel__actions';
+ var bindButton = makeButton('external-backup-bind-btn', '📁 绑定备份文件夹');
+ var writeButton = makeButton('external-backup-write-btn', '💾 立即写入备份');
+ var restoreButton = makeButton('external-backup-restore-btn', '♻️ 从文件夹恢复');
+ var unbindButton = makeButton('external-backup-unbind-btn', '🔓 解除绑定');
+ unbindButton.classList.add('external-backup-btn--ghost');
+ actions.appendChild(bindButton);
+ actions.appendChild(writeButton);
+ actions.appendChild(restoreButton);
+ actions.appendChild(unbindButton);
+
+ panel.appendChild(description);
+ panel.appendChild(statusCard);
+ panel.appendChild(tips);
+ panel.appendChild(actions);
+ body.appendChild(panel);
+ content.appendChild(header);
+ content.appendChild(body);
+ modal.appendChild(content);
+ global.document.body.appendChild(modal);
- if (format === 'json') {
- return JSON.stringify(exportData, null, 2);
+ closeButton.addEventListener('click', closeModal);
+ modal.addEventListener('click', function (event) {
+ if (event.target === modal) closeModal();
+ });
+ bindButton.addEventListener('click', async function () {
+ try {
+ var bound = await bindDirectory({ writeNow: true });
+ if (bound.existingBackupFound) {
+ notify('已绑定并检测到现有备份;为防止覆盖,请先从文件夹恢复', 'warning');
+ } else if (bound.writeResult && !bound.writeResult.success) {
+ notify('文件夹已绑定,但首次写入失败', 'warning');
+ } else {
+ notify('已绑定并写入:' + bound.directoryName, 'success');
+ }
+ } catch (error) {
+ notify(error && error.name === 'AbortError' ? '已取消选择文件夹' : (error.message || '绑定失败'), error && error.name === 'AbortError' ? 'info' : 'error');
+ }
+ refreshPanel();
+ });
+ writeButton.addEventListener('click', async function () {
+ var result = await writeToBoundDirectory({ interactive: true, force: true });
+ if (result.success) notify(result.skipped ? '备份内容无变化' : '已写入 ' + result.filename, 'success');
+ else if (result.reason === 'unbound') notify('请先绑定备份文件夹', 'warning');
+ else if (result.reason === 'restore_required') notify('检测到现有备份,请先从文件夹恢复,避免覆盖', 'warning');
+ else if (result.reason === 'permission_denied') notify('需要允许文件夹访问权限', 'warning');
+ else notify('写入失败:' + (result.error && result.error.message || result.reason), 'error');
+ });
+ restoreButton.addEventListener('click', async function () {
+ try {
+ var restored = await restoreFromLatest();
+ if (restored.success) {
+ notify('已从本地磁盘备份恢复', 'success');
+ if (typeof global.syncPracticeRecords === 'function') {
+ Promise.resolve(global.syncPracticeRecords({ forceRender: true })).catch(function () {});
+ }
+ } else if (restored.reason === 'cancelled') {
+ notify('已取消恢复', 'info');
+ }
+ } catch (error) {
+ notify(error && error.message ? error.message : '恢复失败', 'error');
+ }
+ });
+ unbindButton.addEventListener('click', async function () {
+ var confirmed = true;
+ try {
+ confirmed = global.confirm('解除绑定后将停止自动写入;磁盘上的 JSON 文件不会删除。确定?');
+ } catch (_) { /* ignore */ }
+ if (!confirmed) return;
+ try {
+ await unbindDirectory();
+ notify('已解除本地备份文件夹绑定', 'info');
+ } catch (error) {
+ notify(error && error.message ? error.message : '解除绑定失败', 'error');
}
+ });
+ return modal;
+ }
- return exportData;
- } catch (error) {
- console.error('[Storage] 导出练习记录失败:', error);
- return null;
+ function refreshPanel() {
+ if (!global.document) return;
+ var status = getStatus();
+ var statusElement = global.document.getElementById('external-backup-status');
+ if (statusElement) {
+ statusElement.textContent = formatStatusText(status);
+ statusElement.dataset.state = !status.supported ? 'unsupported'
+ : !status.bound ? 'unbound'
+ : !status.permissionGranted ? 'need-auth'
+ : status.dirty ? 'stale' : 'ok';
}
+ var entry = global.document.getElementById(ENTRY_ID);
+ if (entry) {
+ entry.textContent = !status.bound ? '📁 本地磁盘备份'
+ : !status.permissionGranted ? '📁 本地备份 · 需授权'
+ : status.dirty ? '📁 本地备份 · 待更新' : '📁 本地备份 · 已就绪';
+ entry.dataset.state = statusElement && statusElement.dataset.state || 'unbound';
+ }
+ var bindButton = global.document.getElementById('external-backup-bind-btn');
+ var writeButton = global.document.getElementById('external-backup-write-btn');
+ var restoreButton = global.document.getElementById('external-backup-restore-btn');
+ var unbindButton = global.document.getElementById('external-backup-unbind-btn');
+ if (bindButton) bindButton.disabled = !status.supported || status.writing;
+ if (writeButton) writeButton.disabled = !status.bound || status.writing;
+ if (restoreButton) restoreButton.disabled = !status.bound || status.writing;
+ if (unbindButton) unbindButton.disabled = !status.bound || status.writing;
}
- /**
- * 导出词表数据
- */
- async exportVocabLists(options = {}) {
- const { skipReady = false, format = 'json', listIds = null } = options;
+ function openModal() {
+ var modal = ensureModalDom();
+ if (modal) modal.classList.add('show');
+ ensureReady().then(async function () {
+ if (state.directoryHandle) state.permission = await queryPermission(state.directoryHandle, 'readwrite');
+ refreshPanel();
+ }).catch(function (error) {
+ if (global.console && console.warn) console.warn('[ExternalBackup v2] initialization failed:', error);
+ refreshPanel();
+ });
+ }
- try {
- console.log('[Storage] 开始导出词表数据');
-
- const vocabLists = [];
- const targetListIds = listIds || [
- 'spelling-errors-p1',
- 'spelling-errors-p4',
- 'spelling-errors-master',
- 'custom'
- ];
+ function closeModal() {
+ var modal = getModal();
+ if (modal) modal.classList.remove('show');
+ }
- for (const listId of targetListIds) {
- const list = await this.loadVocabList(listId, { skipReady });
- if (list && list.words.length > 0) {
- vocabLists.push(list);
+ async function ensureReady() {
+ if (state.ready) return true;
+ if (state.readyPromise) return state.readyPromise;
+ state.readyPromise = (async function () {
+ if (global.AppData && global.AppData.ready) await global.AppData.ready;
+ if (state.suspended) {
+ state.ready = true;
+ return false;
+ }
+ if (supportsFileSystemAccess()) {
+ try {
+ var stored = await Promise.all([
+ readStoredValue(HANDLE_KEY),
+ readStoredValue(META_KEY)
+ ]);
+ state.directoryHandle = stored[0] || null;
+ state.meta = cloneMeta(stored[1]);
+ if (state.directoryHandle) {
+ state.permission = await queryPermission(state.directoryHandle, 'readwrite');
+ if (!state.meta.directoryName) {
+ state.meta.directoryName = state.directoryHandle.name || 'backup';
+ }
+ }
+ } catch (error) {
+ if (global.console && console.warn) console.warn('[ExternalBackup v2] binding load failed:', error);
}
}
-
- const exportData = {
- type: 'vocabulary_lists',
- version: this.version,
- exportDate: new Date().toISOString(),
- listCount: vocabLists.length,
- totalWords: vocabLists.reduce((sum, list) => sum + list.words.length, 0),
- lists: vocabLists
- };
-
- console.log(`[Storage] 词表导出完成,共 ${vocabLists.length} 个词表,${exportData.totalWords} 个单词`);
-
- if (format === 'json') {
- return JSON.stringify(exportData, null, 2);
+ var backups = global.AppData && global.AppData.backups;
+ if (backups && typeof backups.onDataCommitted === 'function' && !state.unsubscribeCommitted) {
+ state.unsubscribeCommitted = backups.onDataCommitted(markDirty);
+ }
+ if (state.directoryHandle && backups && typeof backups.export === 'function') {
+ try {
+ var currentSnapshot = await backups.export();
+ if (!currentSnapshot || currentSnapshot.checksum !== state.meta.lastChecksum) {
+ state.dirty = true;
+ state.dirtyGeneration += 1;
+ }
+ } catch (error) {
+ if (global.console && console.warn) console.warn('[ExternalBackup v2] freshness check failed:', error);
+ }
}
+ state.ready = true;
+ if (state.dirty && state.permission === 'granted') scheduleSilentFlush();
+ refreshPanel();
+ return true;
+ })();
+ return state.readyPromise;
+ }
- return exportData;
- } catch (error) {
- console.error('[Storage] 导出词表数据失败:', error);
- return null;
+ async function init() {
+ await ensureReady();
+ if (state.suspended) return false;
+ ensureModalDom();
+ refreshPanel();
+ if (global.document && !state.initialized) {
+ state.visibilityHandler = function () {
+ if (state.suspended) return;
+ if (global.document.visibilityState === 'hidden') {
+ flushSilentlyIfPermitted().catch(function () {});
+ } else if (state.directoryHandle) {
+ queryPermission(state.directoryHandle, 'readwrite').then(function (permission) {
+ state.permission = permission;
+ if (permission === 'granted' && state.dirty) scheduleSilentFlush();
+ refreshPanel();
+ });
+ }
+ };
+ global.document.addEventListener('visibilitychange', state.visibilityHandler);
}
+ state.initialized = true;
+ return true;
}
- /**
- * 导出单个词表
- */
- async exportSingleVocabList(listId, options = {}) {
- const { skipReady = false, format = 'json' } = options;
+ global.ExternalBackupService = Object.freeze({
+ __v2: true,
+ LATEST_FILENAME: LATEST_FILENAME,
+ supportsFileSystemAccess: supportsFileSystemAccess,
+ ensureReady: ensureReady,
+ init: init,
+ openModal: openModal,
+ closeModal: closeModal,
+ bindDirectory: bindDirectory,
+ unbindDirectory: unbindDirectory,
+ prepareForFullReset: prepareForFullReset,
+ writeNow: function (options) {
+ return writeToBoundDirectory(Object.assign({ interactive: true, force: true }, options || {}));
+ },
+ restoreFromLatest: restoreFromLatest,
+ restorePayload: restorePayload,
+ getStatus: getStatus,
+ markDirty: markDirty,
+ flushSilentlyIfPermitted: flushSilentlyIfPermitted,
+ refreshPanel: refreshPanel,
+ requestPersistentStorage: requestPersistentStorage
+ });
- try {
- console.log(`[Storage] 开始导出词表: ${listId}`);
+ function boot() {
+ init().catch(function (error) {
+ if (global.console && console.warn) console.warn('[ExternalBackup v2] boot failed:', error);
+ });
+ }
- const list = await this.loadVocabList(listId, { skipReady });
+ if (global.document && global.document.readyState === 'loading') {
+ global.document.addEventListener('DOMContentLoaded', boot);
+ } else {
+ boot();
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
- if (!list) {
- console.warn(`[Storage] 词表不存在: ${listId}`);
- return null;
- }
- const exportData = {
- type: 'vocabulary_list',
- version: this.version,
- exportDate: new Date().toISOString(),
- list: list
- };
+/* ===== js/core/siteDataReset.js ===== */
+/** Clear all browser-local IELTS Atlas data while preserving external JSON files. */
+(function initSiteDataReset(global) {
+ 'use strict';
- console.log(`[Storage] 词表导出完成: ${listId}, ${list.words.length} 个单词`);
+ if (global.SiteDataReset && global.SiteDataReset.__v2 === true) {
+ global.clearCache = global.SiteDataReset.request;
+ return;
+ }
- if (format === 'json') {
- return JSON.stringify(exportData, null, 2);
- }
+ const DATABASE_NAMES = Object.freeze([
+ 'IELTSAtlasDataV2',
+ 'ExamSystemDB',
+ 'IELTSAtlasExternalBackupV2'
+ ]);
+ let resetPromise = null;
- return exportData;
- } catch (error) {
- console.error('[Storage] 导出词表失败:', error);
- return null;
+ function notify(message, type = 'info') {
+ if (typeof global.showMessage === 'function') global.showMessage(message, type);
+ else if (global.console && typeof global.console.log === 'function') {
+ global.console.log(`[SiteDataReset] ${message}`);
}
}
- /**
- * 导出完整数据(包括练习记录和词表)
- */
- async exportCompleteData(options = {}) {
- const { skipReady = false, format = 'json' } = options;
-
- try {
- console.log('[Storage] 开始导出完整数据');
+ function deleteDatabase(name) {
+ return new Promise((resolve, reject) => {
+ const indexedDB = global.indexedDB;
+ if (!indexedDB || typeof indexedDB.deleteDatabase !== 'function') {
+ resolve({ name, skipped: true });
+ return;
+ }
- // 导出所有数据
- const allData = await this.exportData({ skipReady });
+ let request;
+ try { request = indexedDB.deleteDatabase(name); }
+ catch (error) { reject(error); return; }
- // 导出练习记录
- const practiceRecords = await this.exportPracticeRecords({
- skipReady,
- format: 'object'
- });
+ request.onsuccess = () => resolve({ name, deleted: true });
+ request.onerror = () => reject(request.error || new Error(`删除数据库失败:${name}`));
+ request.onblocked = () => notify(
+ `数据库 ${name} 正被其他 IELTS Atlas 标签页占用。请关闭其他标签页,清理会自动继续。`,
+ 'warning'
+ );
+ });
+ }
- // 导出词表
- const vocabLists = await this.exportVocabLists({
- skipReady,
- format: 'object'
- });
-
- const exportData = {
- type: 'complete_export',
- version: this.version,
- exportDate: new Date().toISOString(),
- summary: {
- totalRecords: allData?.storageInfo?.totalRecords || 0,
- practiceRecords: practiceRecords?.recordCount || 0,
- vocabLists: vocabLists?.listCount || 0,
- totalWords: vocabLists?.totalWords || 0
- },
- data: {
- all: allData,
- practiceRecords: practiceRecords,
- vocabLists: vocabLists
- }
- };
-
- console.log('[Storage] 完整数据导出完成');
-
- if (format === 'json') {
- return JSON.stringify(exportData, null, 2);
- }
-
- return exportData;
- } catch (error) {
- console.error('[Storage] 导出完整数据失败:', error);
- return null;
+ async function stopExternalBackup() {
+ const service = global.ExternalBackupService;
+ if (!service) return;
+ if (typeof service.prepareForFullReset === 'function') {
+ await service.prepareForFullReset();
+ } else if (typeof service.unbindDirectory === 'function') {
+ await service.unbindDirectory();
}
}
- /**
- * 下载导出数据为文件
- */
- downloadExportData(data, filename = null) {
- try {
- if (!data) {
- console.error('[Storage] 无数据可导出');
- return false;
+ function clearWebStorage() {
+ const errors = [];
+ for (const name of ['localStorage', 'sessionStorage']) {
+ try {
+ const storage = global[name];
+ if (storage && typeof storage.clear === 'function') storage.clear();
+ } catch (error) {
+ errors.push({ stage: 'clear-web-storage', storage: name, error });
}
-
- // 确保数据是字符串格式
- const jsonString = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
-
- // 创建 Blob
- const blob = new Blob([jsonString], { type: 'application/json' });
-
- // 生成文件名
- const defaultFilename = `ielts-practice-export-${new Date().toISOString().split('T')[0]}.json`;
- const finalFilename = filename || defaultFilename;
-
- // 创建下载链接
- const url = URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = finalFilename;
-
- // 触发下载
- document.body.appendChild(link);
- link.click();
-
- // 清理
- document.body.removeChild(link);
- URL.revokeObjectURL(url);
-
- console.log(`[Storage] 数据已下载: ${finalFilename}`);
- return true;
- } catch (error) {
- console.error('[Storage] 下载导出数据失败:', error);
- return false;
}
+ return errors;
}
- /**
- * 导出并下载练习记录
- */
- async exportAndDownloadPracticeRecords(filename = null) {
- try {
- const data = await this.exportPracticeRecords({ format: 'json' });
- if (data) {
- const defaultFilename = `practice-records-${new Date().toISOString().split('T')[0]}.json`;
- return this.downloadExportData(data, filename || defaultFilename);
- }
- return false;
- } catch (error) {
- console.error('[Storage] 导出并下载练习记录失败:', error);
- return false;
- }
+ function reload(options) {
+ if (options.reload === false) return false;
+ if (!global.location || typeof global.location.reload !== 'function') return false;
+ global.location.reload();
+ return true;
}
- /**
- * 导出并下载词表数据
- */
- async exportAndDownloadVocabLists(filename = null) {
- try {
- const data = await this.exportVocabLists({ format: 'json' });
- if (data) {
- const defaultFilename = `vocab-lists-${new Date().toISOString().split('T')[0]}.json`;
- return this.downloadExportData(data, filename || defaultFilename);
+ async function perform(options = {}) {
+ if (resetPromise) return resetPromise;
+ resetPromise = (async () => {
+ try {
+ await stopExternalBackup();
+ } catch (error) {
+ notify('外部备份仍在写入,本次清理已取消。', 'error');
+ return {
+ success: false,
+ reason: 'external_backup_busy',
+ terminal: false,
+ error,
+ databases: DATABASE_NAMES.slice(),
+ externalBackupFilesPreserved: true
+ };
}
- return false;
- } catch (error) {
- console.error('[Storage] 导出并下载词表数据失败:', error);
- return false;
- }
- }
- /**
- * 导出并下载完整数据
- */
- async exportAndDownloadCompleteData(filename = null) {
- try {
- const data = await this.exportCompleteData({ format: 'json' });
- if (data) {
- const defaultFilename = `complete-data-${new Date().toISOString().split('T')[0]}.json`;
- return this.downloadExportData(data, filename || defaultFilename);
+ const results = await Promise.allSettled(DATABASE_NAMES.map(deleteDatabase));
+ const errors = results.flatMap((result, index) => result.status === 'rejected'
+ ? [{ stage: 'delete-database', database: DATABASE_NAMES[index], error: result.reason }]
+ : []);
+ errors.push(...clearWebStorage());
+ if (errors.length) {
+ notify('本地数据仅部分清除,请关闭其他标签页后重试。', 'error');
+ return {
+ success: false,
+ reason: 'partial_reset',
+ terminal: false,
+ errors,
+ databases: DATABASE_NAMES.slice(),
+ externalBackupFilesPreserved: true
+ };
}
- return false;
- } catch (error) {
- console.error('[Storage] 导出并下载完整数据失败:', error);
- return false;
- }
- }
-
- /**
- * 导入词表数据
- */
- async importVocabLists(importData, options = {}) {
- const { skipReady = false, merge = true } = options;
- try {
- console.log('[Storage] 开始导入词表数据');
+ return {
+ success: true,
+ terminal: reload(options),
+ databases: DATABASE_NAMES.slice(),
+ externalBackupFilesPreserved: true
+ };
+ })();
- if (!importData || !importData.lists) {
- console.error('[Storage] 导入数据格式无效');
- return false;
- }
+ try { return await resetPromise; }
+ finally { resetPromise = null; }
+ }
- let successCount = 0;
- let failCount = 0;
+ async function request(options = {}) {
+ let confirmed = options.confirmed === true;
+ if (!confirmed) {
+ try {
+ confirmed = global.confirm(
+ '确定要清除全部浏览器本地数据并返回首次启动状态吗?\n\n'
+ + '练习记录、题库、词汇、设置、应用内备份和本地文件夹绑定都会清除;'
+ + '外部文件夹中的 JSON 备份不会删除。'
+ );
+ } catch (_) { confirmed = false; }
+ }
+ if (!confirmed) return { success: false, reason: 'cancelled', terminal: false };
- for (const list of importData.lists) {
- try {
- if (merge) {
- // 合并模式:与现有数据合并
- const success = await this.syncVocabList(list.id, list, { skipReady });
- if (success) {
- successCount++;
- } else {
- failCount++;
- }
- } else {
- // 覆盖模式:直接保存
- const success = await this.saveVocabList(list, { skipReady });
- if (success) {
- successCount++;
- } else {
- failCount++;
- }
- }
- } catch (error) {
- console.error(`[Storage] 导入词表失败: ${list.id}`, error);
- failCount++;
- }
+ notify('正在清除全部本地数据...', 'info');
+ try { return await perform(options); }
+ catch (error) {
+ if (global.console && typeof global.console.error === 'function') {
+ global.console.error('[SiteDataReset] full reset failed:', error);
}
-
- console.log(`[Storage] 词表导入完成: ${successCount} 成功, ${failCount} 失败`);
- return { successCount, failCount };
- } catch (error) {
- console.error('[Storage] 导入词表数据失败:', error);
- return false;
+ notify(`清除失败:${error && error.message ? error.message : '浏览器存储不可用'}`, 'error');
+ return { success: false, reason: 'reset_failed', terminal: false, error };
}
}
-}
-const STORAGE_SYNC_IGNORED_KEYS = new Set([
- 'namespace_test',
- 'namespace_test_practice',
- 'namespace_test_enhancer'
-]);
+ global.SiteDataReset = Object.freeze({ __v2: true, DATABASE_NAMES, perform, request });
+ global.clearCache = request;
+})(typeof window !== 'undefined' ? window : globalThis);
-StorageManager.prototype.dispatchStorageSync = function(key) {
- try {
- const normalizedKey = typeof key === 'string' ? key.replace(this.prefix, '') : key;
- if (normalizedKey && STORAGE_SYNC_IGNORED_KEYS.has(normalizedKey)) {
- return;
- }
- } catch (_) {
- // ignore errors resolving key
- }
- window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key } }));
-};
-// 创建全局存储实例
-class PreferenceStore {
- constructor(prefix = 'exam_system_') {
- this.prefix = prefix;
- this.ready = Promise.resolve();
- }
+/* ===== js/core/practiceCore.js ===== */
+(function initPracticeCore(global) {
+ 'use strict';
- setNamespace(namespace) {
- if (typeof namespace === 'string' && namespace.trim()) {
- this.prefix = namespace.trim() + '_';
- }
+ if (global.PracticeCore && global.PracticeCore.__stable === true) {
+ return;
}
- getScopedKey(key) {
- return key.startsWith(this.prefix) ? key : this.prefix + key;
- }
+ const MESSAGE_TYPE_ALIASES = Object.freeze({
+ practice_complete: 'PRACTICE_COMPLETE',
+ practice_completed: 'PRACTICE_COMPLETE',
+ PracticeComplete: 'PRACTICE_COMPLETE',
+ SESSION_COMPLETE: 'PRACTICE_COMPLETE',
+ session_complete: 'PRACTICE_COMPLETE',
+ session_completed: 'PRACTICE_COMPLETE',
+ EXAM_FINISHED: 'PRACTICE_COMPLETE',
+ QUIZ_COMPLETE: 'PRACTICE_COMPLETE',
+ QUIZ_COMPLETED: 'PRACTICE_COMPLETE',
+ TEST_COMPLETE: 'PRACTICE_COMPLETE',
+ LESSON_COMPLETE: 'PRACTICE_COMPLETE',
+ WORKOUT_COMPLETE: 'PRACTICE_COMPLETE',
+ SESSION_READY: 'SESSION_READY',
+ session_ready: 'SESSION_READY',
+ EXAM_COMPLETED: 'exam_completed',
+ EXAM_PROGRESS: 'exam_progress',
+ EXAM_ERROR: 'exam_error',
+ progress_update: 'PROGRESS_UPDATE',
+ SESSION_PROGRESS: 'PROGRESS_UPDATE',
+ session_progress: 'PROGRESS_UPDATE',
+ practice_progress: 'PROGRESS_UPDATE',
+ SESSION_ERROR: 'ERROR_OCCURRED',
+ session_error: 'ERROR_OCCURRED',
+ practice_error: 'ERROR_OCCURRED',
+ REQUEST_INIT: 'REQUEST_INIT',
+ request_init: 'REQUEST_INIT',
+ REQUEST_SESSION_INIT: 'REQUEST_INIT',
+ INIT_SESSION: 'INIT_SESSION',
+ init_session: 'INIT_SESSION'
+ });
- getStorageArea(session = false) {
- return session ? window.sessionStorage : window.localStorage;
- }
+ const PRACTICE_COMPLETE_TYPES = new Set([
+ 'PRACTICE_COMPLETE',
+ 'PRACTICE_COMPLETED',
+ 'SESSION_COMPLETE',
+ 'SESSION_COMPLETED',
+ 'EXAM_FINISHED',
+ 'QUIZ_COMPLETE',
+ 'QUIZ_COMPLETED',
+ 'TEST_COMPLETE',
+ 'LESSON_COMPLETE',
+ 'WORKOUT_COMPLETE'
+ ]);
- serialize(value) {
- return JSON.stringify({ data: value, timestamp: Date.now() });
+ function isPlainObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value);
}
- deserialize(rawValue, defaultValue = null) {
- if (!rawValue) {
- return defaultValue;
+ function safeParseJson(value) {
+ if (typeof value !== 'string') {
+ return null;
}
try {
- const parsed = JSON.parse(rawValue);
- return parsed && Object.prototype.hasOwnProperty.call(parsed, 'data')
- ? parsed.data
- : defaultValue;
+ return JSON.parse(value);
} catch (_) {
- return defaultValue;
+ return null;
}
}
- async get(key, defaultValue = null, options = {}) {
- const storage = this.getStorageArea(options.session === true);
- return this.deserialize(storage.getItem(this.getScopedKey(key)), defaultValue);
- }
-
- async set(key, value, options = {}) {
- const storage = this.getStorageArea(options.session === true);
- storage.setItem(this.getScopedKey(key), this.serialize(value));
- window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key } }));
- return true;
- }
-
- async remove(key, options = {}) {
- const storage = this.getStorageArea(options.session === true);
- storage.removeItem(this.getScopedKey(key));
- window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key } }));
- return true;
- }
-
- async clear(options = {}) {
- const storage = this.getStorageArea(options.session === true);
- Object.keys(storage)
- .filter((key) => key.startsWith(this.prefix))
- .forEach((key) => storage.removeItem(key));
- return true;
- }
-}
-
-class StorageKeyRegistry {
- constructor() {
- this.preferenceKeys = new Set([
- 'theme_settings',
- 'current_theme',
- 'keyboard_shortcuts_enabled',
- 'sound_effects_enabled',
- 'auto_save_enabled',
- 'notifications_enabled',
- 'theme',
- 'bloom-theme-mode',
- 'blue-theme-mode',
- 'browse_state',
- 'hasSeenGplLicense',
- 'preferred_theme_portal'
- ]);
- this.sessionKeys = new Set([
- 'preferred_theme_skip_session'
- ]);
- }
-
- resolve(key) {
- if (this.sessionKeys.has(key)) {
- return { key, storageClass: 'session' };
+ function clonePlainObject(value) {
+ if (value == null || typeof value !== 'object') {
+ return value ?? null;
}
- if (this.preferenceKeys.has(key)) {
- return { key, storageClass: 'preference' };
+ if (Array.isArray(value)) {
+ return value.map((item) => clonePlainObject(item)).filter((item) => item !== undefined);
}
- return { key, storageClass: 'persistent' };
+ const clone = {};
+ Object.keys(value).forEach((key) => {
+ clone[key] = clonePlainObject(value[key]);
+ });
+ return clone;
}
-}
-class StorageFacade {
- constructor(options = {}) {
- this.persistentStore = options.persistentStore;
- this.preferenceStore = options.preferenceStore;
- this.keyRegistry = options.keyRegistry;
- this.ready = this.persistentStore ? this.persistentStore.ready : Promise.resolve();
+ /**
+ * Resolve the complete reading-annotation snapshot from canonical and legacy
+ * locations. Explicit root values win so review edits can replace an older
+ * realData mirror; the returned object is deep-cloned and safe to persist.
+ */
+ function resolveAnnotationState(recordData = {}, fallbackSources = [], options = {}) {
+ const root = isPlainObject(recordData) ? recordData : {};
+ const rawData = isPlainObject(root.rawData) ? root.rawData : {};
+ const realData = isPlainObject(root.realData) ? root.realData : {};
+ const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {};
+ const sources = [root, rawData, realData, rawRealData]
+ .concat(Array.isArray(fallbackSources) ? fallbackSources : [fallbackSources])
+ .filter((source) => isPlainObject(source));
+
+ const pickArray = (field) => {
+ const source = sources.find((candidate) => (
+ Array.isArray(candidate[field])
+ && (!options.preferNonEmptyArrays || candidate[field].length)
+ )) || sources.find((candidate) => Array.isArray(candidate[field]));
+ return source ? clonePlainObject(source[field]) : [];
+ };
+ const pickString = (field) => {
+ const source = sources.find((candidate) => typeof candidate[field] === 'string');
+ return source ? source[field] : '';
+ };
+ const scrollSource = sources.find((candidate) => (
+ candidate.scrollY !== undefined
+ && candidate.scrollY !== null
+ && Number.isFinite(Number(candidate.scrollY))
+ ));
+
+ return {
+ highlights: pickArray('highlights'),
+ markedQuestions: pickArray('markedQuestions'),
+ noteText: pickString('noteText'),
+ notes: pickArray('notes'),
+ noteOutlines: pickArray('noteOutlines'),
+ scrollY: scrollSource ? Number(scrollSource.scrollY) : 0
+ };
}
- setNamespace(namespace) {
- if (this.persistentStore && typeof this.persistentStore.setNamespace === 'function') {
- this.persistentStore.setNamespace(namespace);
- }
- if (this.preferenceStore && typeof this.preferenceStore.setNamespace === 'function') {
- this.preferenceStore.setNamespace(namespace);
- }
+ function ensureNumber(value, fallback = 0) {
+ const numeric = Number(value);
+ return Number.isFinite(numeric) ? numeric : fallback;
}
- resolveStore(key) {
- const entry = this.keyRegistry.resolve(key);
- if (entry.storageClass === 'preference') {
- return { entry, store: this.preferenceStore, options: { session: false } };
+ function normalizeDateCandidate(value) {
+ if (!value) {
+ return null;
}
- if (entry.storageClass === 'session') {
- return { entry, store: this.preferenceStore, options: { session: true } };
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
+ return value.toISOString();
}
- return { entry, store: this.persistentStore, options: {} };
- }
-
- async get(key, defaultValue = null, options = {}) {
- const target = this.resolveStore(key);
- return await target.store.get(key, defaultValue, Object.assign({}, target.options, options));
- }
-
- async set(key, value, options = {}) {
- const target = this.resolveStore(key);
- return await target.store.set(key, value, Object.assign({}, target.options, options));
- }
-
- async remove(key, options = {}) {
- const target = this.resolveStore(key);
- return await target.store.remove(key, Object.assign({}, target.options, options));
- }
-
- async clear(options = {}) {
- if (this.persistentStore && typeof this.persistentStore.clear === 'function') {
- await this.persistentStore.clear(options);
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return new Date(value).toISOString();
}
- if (this.preferenceStore && typeof this.preferenceStore.clear === 'function') {
- await this.preferenceStore.clear({ session: false });
- await this.preferenceStore.clear({ session: true });
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return null;
+ }
+ if (/^\d+$/.test(trimmed)) {
+ const numeric = Number(trimmed);
+ if (Number.isFinite(numeric)) {
+ return new Date(trimmed.length > 10 ? numeric : numeric * 1000).toISOString();
+ }
+ }
+ const parsed = new Date(trimmed);
+ if (!Number.isNaN(parsed.getTime())) {
+ return parsed.toISOString();
+ }
}
- return true;
+ return null;
}
- async getStorageInfo(options = {}) {
- const persistentInfo = this.persistentStore && typeof this.persistentStore.getStorageInfo === 'function'
- ? await this.persistentStore.getStorageInfo(options)
- : null;
- return Object.assign({}, persistentInfo || {}, {
- facade: 'storage-facade',
- volatile: Boolean(this.persistentStore && this.persistentStore.volatileMode)
- });
- }
-}
-
-const storageManager = new StorageManager();
-const preferenceStore = new PreferenceStore(storageManager.prefix);
-const storageKeyRegistry = new StorageKeyRegistry();
-const storageFacade = new StorageFacade({
- persistentStore: storageManager,
- preferenceStore,
- keyRegistry: storageKeyRegistry
-});
-
-window.persistentStore = storageManager;
-window.preferenceStore = preferenceStore;
-window.storageKeyRegistry = storageKeyRegistry;
-window.storage = storageFacade;
-Object.defineProperty(window, '__installStorageInternalAccess', {
- value(install) {
- if (typeof install !== 'function') {
- throw new Error('__installStorageInternalAccess requires an installer function');
- }
- const result = install(createInternalAccessOptions, hasInternalAccessOptions);
- if (result !== false) {
- try {
- delete window.__installStorageInternalAccess;
- } catch (_) {
- window.__installStorageInternalAccess = undefined;
+ function firstDateCandidate() {
+ for (let index = 0; index < arguments.length; index += 1) {
+ const normalized = normalizeDateCandidate(arguments[index]);
+ if (normalized) {
+ return normalized;
}
}
- return result;
- },
- enumerable: false,
- configurable: true,
- writable: false
-});
-
-// 启动存储监控和数据同步
-storageManager.ready
- .then(() => {
- storageManager.startStorageMonitoring();
- storageManager.setupBeforeUnloadHandler();
- })
- .catch(error => {
- console.error('[Storage] 存储初始化失败,监控未启动:', error);
- });
-})(typeof window !== 'undefined' ? window : globalThis);
-
-
-/* ===== js/core/storageProviderRegistry.js ===== */
-(function(window) {
- const listeners = new Set();
- let providers = null;
+ return null;
+ }
- function normalizeProviders(input) {
- if (!input || typeof input !== 'object') {
- return null;
- }
- const normalized = {
- storageManager: input.storageManager || window.storage || null,
- persistentStore: input.persistentStore || window.persistentStore || null,
- preferenceStore: input.preferenceStore || window.preferenceStore || null,
- repositories: input.repositories || null,
- simpleStorageWrapper: input.simpleStorageWrapper || null
- };
- if (!normalized.repositories) {
- return null;
+ function firstStringCandidate() {
+ for (let index = 0; index < arguments.length; index += 1) {
+ const value = arguments[index];
+ if (value === undefined || value === null) {
+ continue;
+ }
+ const trimmed = String(value).trim();
+ if (trimmed) {
+ return trimmed;
+ }
}
- return normalized;
+ return null;
}
- function notifyListeners(payload) {
- listeners.forEach((listener) => {
- try {
- listener(payload);
- } catch (error) {
- console.error('[StorageProviderRegistry] listener failed:', error);
- }
- });
- }
+ function resolveDurationSeconds(recordData = {}, startTime = null, endTime = null) {
+ const realData = isPlainObject(recordData.realData) ? recordData.realData : {};
+ const scoreInfo = isPlainObject(recordData.scoreInfo)
+ ? recordData.scoreInfo
+ : (isPlainObject(realData.scoreInfo) ? realData.scoreInfo : {});
+ const candidates = [
+ recordData.duration,
+ realData.duration,
+ recordData.durationSeconds,
+ recordData.duration_seconds,
+ recordData.elapsedSeconds,
+ recordData.elapsed_seconds,
+ recordData.timeSpent,
+ recordData.time_spent,
+ realData.durationSeconds,
+ realData.elapsedSeconds,
+ realData.timeSpent,
+ scoreInfo.duration,
+ scoreInfo.timeSpent
+ ];
- function registerStorageProviders(input) {
- const normalized = normalizeProviders(input);
- if (!normalized) {
- throw new Error('registerStorageProviders requires repositories');
+ for (let index = 0; index < candidates.length; index += 1) {
+ const numeric = Number(candidates[index]);
+ if (Number.isFinite(numeric) && numeric > 0) {
+ return numeric;
+ }
}
- providers = normalized;
- if (!window.dataRepositories) {
- window.dataRepositories = normalized.repositories;
- }
- if (!window.storage && normalized.storageManager) {
- window.storage = normalized.storageManager;
- }
- if (!window.persistentStore && normalized.persistentStore) {
- window.persistentStore = normalized.persistentStore;
- }
- if (!window.preferenceStore && normalized.preferenceStore) {
- window.preferenceStore = normalized.preferenceStore;
- }
- if (normalized.simpleStorageWrapper && !window.simpleStorageWrapper) {
- window.simpleStorageWrapper = normalized.simpleStorageWrapper;
+ const start = startTime ? new Date(startTime).getTime() : NaN;
+ const end = endTime ? new Date(endTime).getTime() : NaN;
+ if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
+ return Math.round((end - start) / 1000);
}
- notifyListeners(Object.assign({}, providers));
- return providers;
- }
-
- function onProvidersReady(callback) {
- if (typeof callback !== 'function') {
- return () => {};
+ if (Array.isArray(realData.interactions) && realData.interactions.length) {
+ const timestamps = realData.interactions
+ .map(item => item && Number(item.timestamp))
+ .filter(value => Number.isFinite(value));
+ if (timestamps.length) {
+ const span = Math.max(...timestamps) - Math.min(...timestamps);
+ if (Number.isFinite(span) && span > 0) {
+ return Math.floor(span / 1000);
+ }
+ }
}
- listeners.add(callback);
- if (providers) {
- try {
- callback(Object.assign({}, providers));
- } catch (error) {
- console.error('[StorageProviderRegistry] immediate callback failed:', error);
+
+ for (let index = 0; index < candidates.length; index += 1) {
+ const numeric = Number(candidates[index]);
+ if (Number.isFinite(numeric) && numeric >= 0) {
+ return numeric;
}
}
- return () => listeners.delete(callback);
- }
- function getCurrentProviders() {
- return providers ? Object.assign({}, providers) : null;
+ return 0;
}
- window.StorageProviderRegistry = {
- registerStorageProviders,
- onProvidersReady,
- getCurrentProviders
- };
-})(window);
-
-
-/* ===== js/data/dataSources/storageDataSource.js ===== */
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
-
- function isProtectedPracticeDataKey(key) {
- return key === 'practice_records' || key === 'user_stats';
+ function normalizePracticeType(rawType) {
+ if (!rawType) return null;
+ const normalized = String(rawType).toLowerCase();
+ if (normalized.includes('listen')) return 'listening';
+ if (normalized.includes('read')) return 'reading';
+ return null;
}
- class StorageTransactionContext {
- constructor(storageManager, options = {}) {
- this.storage = storageManager;
- this.createInternalOptions = typeof options.createInternalOptions === 'function'
- ? options.createInternalOptions
- : null;
- this.operations = [];
- this.cache = new Map();
- }
-
- _internalOptions(key) {
- if (this.createInternalOptions) {
- return this.createInternalOptions();
- }
- if (isProtectedPracticeDataKey(key)) {
- throw new Error(`StorageTransactionContext cannot access protected key ${key} without internal storage access`);
- }
- return { skipPracticeCoreRedirect: true };
- }
+ function resolveRecordDate(recordData = {}, now = new Date().toISOString()) {
+ const metadata = isPlainObject(recordData.metadata) ? recordData.metadata : {};
+ const candidates = [
+ metadata.date,
+ recordData.date,
+ recordData.endTime,
+ recordData.end_time,
+ recordData.completedAt,
+ recordData.finishedAt,
+ recordData.finishTime,
+ recordData.startTime,
+ recordData.start_time,
+ recordData.startedAt,
+ recordData.createdAt,
+ recordData.timestamp,
+ now
+ ];
- async get(key, defaultValue) {
- if (this.cache.has(key)) {
- return this.cache.get(key);
+ for (let i = 0; i < candidates.length; i += 1) {
+ const normalized = normalizeDateCandidate(candidates[i]);
+ if (normalized) {
+ return normalized;
}
- const resolvedDefault = typeof defaultValue === 'function' ? defaultValue() : defaultValue;
- const value = await this.storage.get(key, resolvedDefault, this._internalOptions(key));
- const finalValue = value === undefined ? resolvedDefault : value;
- this.cache.set(key, finalValue);
- return finalValue;
- }
-
- set(key, value) {
- this.cache.set(key, value);
- this.operations.push({ type: 'set', key, value });
}
- remove(key) {
- this.cache.delete(key);
- this.operations.push({ type: 'remove', key });
- }
+ return now;
+ }
- async commit() {
- for (const op of this.operations) {
- if (op.type === 'set') {
- await this.storage.set(op.key, op.value, this._internalOptions(op.key));
- } else if (op.type === 'remove') {
- await this.storage.remove(op.key, this._internalOptions(op.key));
- }
- }
- this.operations = [];
+ function inferExamId(recordData = {}) {
+ if (!recordData || typeof recordData !== 'object') {
+ return null;
}
- async rollback() {
- this.operations = [];
+ const metadata = isPlainObject(recordData.metadata) ? recordData.metadata : {};
+ const direct = firstStringCandidate(
+ recordData.examId,
+ recordData.exam_id,
+ recordData.examID,
+ metadata.examId,
+ metadata.exam_id
+ );
+ if (direct) {
+ return direct;
}
- }
-
- class StorageDataSource {
- constructor(storageManager, options = {}) {
- if (!storageManager) {
- throw new Error('StorageDataSource requires a StorageManager instance');
+ if (Array.isArray(recordData.suiteEntries)) {
+ const suiteExam = recordData.suiteEntries.find((entry) => entry && entry.examId);
+ if (suiteExam) {
+ return suiteExam.examId;
}
- this.storage = storageManager;
- this.createInternalOptions = typeof options.createInternalOptions === 'function'
- ? options.createInternalOptions
- : null;
- this._queue = Promise.resolve();
}
-
- _internalOptions(key) {
- if (this.createInternalOptions) {
- return this.createInternalOptions();
- }
- if (isProtectedPracticeDataKey(key)) {
- throw new Error(`StorageDataSource cannot access protected key ${key} without internal storage access`);
+ if (typeof recordData.id === 'string') {
+ const match = recordData.id.match(/^record_([^_]+)_/);
+ if (match && match[1]) {
+ return match[1];
}
- return { skipPracticeCoreRedirect: true };
}
- async read(key, defaultValue) {
- const resolvedDefault = typeof defaultValue === 'function' ? defaultValue() : defaultValue;
- const value = await this.storage.get(key, resolvedDefault, this._internalOptions(key));
- return value === undefined ? resolvedDefault : value;
- }
+ return null;
+ }
- async write(key, value) {
- return this._enqueue(async () => {
- await this.storage.set(key, value, this._internalOptions(key));
- return true;
- });
+ function normalizeAnswerValue(value) {
+ const sanitizer = global.AnswerSanitizer;
+ if (sanitizer && typeof sanitizer.normalizeValue === 'function') {
+ return sanitizer.normalizeValue(value);
}
- async remove(key) {
- return this._enqueue(async () => {
- await this.storage.remove(key, this._internalOptions(key));
- return true;
- });
+ if (value === undefined || value === null) {
+ return '';
}
-
- async runTransaction(handler, options = {}) {
- if (typeof handler !== 'function') {
- throw new Error('StorageDataSource.runTransaction requires a handler function');
- }
- const label = options.label || 'storage-transaction';
- return this._enqueue(async () => {
- const context = new StorageTransactionContext(this.storage, {
- createInternalOptions: this.createInternalOptions
- });
- try {
- const result = await handler(context);
- await context.commit();
- return result;
- } catch (error) {
- await context.rollback();
- console.error(`[StorageDataSource] Transaction failed (${label}):`, error);
- throw error;
- }
- });
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ return /^\[object\s/i.test(trimmed) ? '' : trimmed;
}
-
- _enqueue(task) {
- const next = this._queue.then(task);
- this._queue = next.catch(() => {});
- return next;
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value).trim();
}
- }
-
- ExamData.StorageTransactionContext = StorageTransactionContext;
- ExamData.StorageDataSource = StorageDataSource;
-})(window);
-
-
-/* ===== js/data/repositories/baseRepository.js ===== */
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
-
- function cloneValue(value) {
- if (value === null || value === undefined) {
- return value;
+ if (Array.isArray(value)) {
+ return value.map((item) => normalizeAnswerValue(item)).filter(Boolean).join(',');
}
- if (typeof structuredClone === 'function') {
- try {
- return structuredClone(value);
- } catch (_) {
- // Fallback to JSON serialization below
+ if (typeof value === 'object') {
+ const preferKeys = ['value', 'label', 'text', 'answer', 'content', 'userAnswer', 'correctAnswer'];
+ for (let i = 0; i < preferKeys.length; i += 1) {
+ const entry = value[preferKeys[i]];
+ if (typeof entry === 'string') {
+ const trimmed = entry.trim();
+ if (trimmed && !/^\[object\s/i.test(trimmed)) {
+ return trimmed;
+ }
+ }
}
- }
- try {
- return JSON.parse(JSON.stringify(value));
- } catch (_) {
- return value;
- }
- }
-
- class BaseRepository {
- constructor(options) {
- const {
- dataSource,
- key,
- name,
- defaultValue = null,
- migrations = [],
- validators = [],
- cloneOnRead = true
- } = options || {};
-
- if (!dataSource) {
- throw new Error('BaseRepository requires a dataSource instance');
+ if (typeof value.innerText === 'string') {
+ const text = value.innerText.trim();
+ if (text && !/^\[object\s/i.test(text)) {
+ return text;
+ }
}
- if (!key) {
- throw new Error('BaseRepository requires a storage key');
+ if (typeof value.textContent === 'string') {
+ const text = value.textContent.trim();
+ if (text && !/^\[object\s/i.test(text)) {
+ return text;
+ }
}
-
- this.dataSource = dataSource;
- this.key = key;
- this.name = name || key;
- this.defaultValue = defaultValue;
- this.migrations = Array.isArray(migrations) ? migrations.slice() : [migrations];
- this.validators = Array.isArray(validators) ? validators.slice() : [validators];
- this.cloneOnRead = cloneOnRead;
- }
-
- _resolveDefaultValue(override) {
- const candidate = override !== undefined ? override : this.defaultValue;
- return typeof candidate === 'function' ? candidate() : candidate;
+ return '';
}
- async read(options = {}) {
- const { transaction, defaultValue, skipValidation = false, clone = undefined } = options;
- const resolvedDefault = this._resolveDefaultValue(defaultValue);
- const sourceValue = transaction
- ? await transaction.get(this.key, resolvedDefault)
- : await this.dataSource.read(this.key, resolvedDefault);
-
- let value = sourceValue === undefined ? resolvedDefault : sourceValue;
- value = await this.applyMigrations(value, { transaction });
+ return String(value).trim();
+ }
- if (!skipValidation) {
- this.validate(value);
- }
+ function isNoiseKey(key) {
+ if (!key) return true;
- if (clone === false || (!this.cloneOnRead && clone === undefined)) {
- return value;
- }
- return cloneValue(value);
+ const keyStr = String(key).toLowerCase();
+ const noiseKeys = [
+ 'playback-speed', 'playbackspeed', 'volume-slider', 'volumeslider',
+ 'audio-volume', 'audiocurrenttime', 'audio-duration', 'audioduration',
+ 'settings', 'lastfocuselement', 'sessionid', 'examid',
+ 'nextexamid', 'previousexamid', 'folder', 'source', 'result',
+ 'metadata', 'practicesettings', 'config', 'state'
+ ];
+ if (noiseKeys.includes(keyStr)) {
+ return true;
}
- async write(value, options = {}) {
- const { transaction, skipValidation = false, clone = true } = options;
- if (!skipValidation) {
- this.validate(value);
- }
- const dataToPersist = clone ? cloneValue(value) : value;
- if (transaction) {
- transaction.set(this.key, dataToPersist);
+ const noisePatterns = [
+ /playback/i, /volume/i, /slider/i, /speed/i,
+ /audio/i, /duration/i, /config/i, /setting/i
+ ];
+ for (let i = 0; i < noisePatterns.length; i += 1) {
+ if (noisePatterns[i].test(keyStr)) {
return true;
}
- await this.dataSource.write(this.key, dataToPersist);
- return true;
}
- async remove(options = {}) {
- const { transaction } = options;
- if (transaction) {
- transaction.remove(this.key);
+ const questionMatch = keyStr.match(/q?(\d+)/);
+ if (questionMatch) {
+ const number = parseInt(questionMatch[1], 10);
+ if (number < 1 || number > 200) {
return true;
}
- await this.dataSource.remove(this.key);
- return true;
}
- async applyMigrations(value, context = {}) {
- let current = value;
- for (const migration of this.migrations) {
- if (typeof migration === 'function') {
- current = await migration(current, { key: this.key, name: this.name, ...context });
- }
- }
- return current;
- }
+ return false;
+ }
- validate(value) {
- const errors = [];
- for (const validator of this.validators) {
- if (typeof validator !== 'function') {
- continue;
- }
- try {
- const result = validator(value);
- if (result === false) {
- errors.push(`${this.name} 数据验证失败`);
- } else if (typeof result === 'string') {
- errors.push(result);
- } else if (result && typeof result === 'object') {
- if (result.valid === false || result.isValid === false) {
- errors.push(result.message || result.error || `${this.name} 数据验证失败`);
- }
- }
- } catch (error) {
- errors.push(error.message || String(error));
- }
- }
- if (errors.length > 0) {
- const err = new Error(`[${this.name}] 数据验证失败: ${errors.join('; ')}`);
- err.validationErrors = errors;
- throw err;
- }
- return true;
+ function normalizeQuestionKey(rawKey, index) {
+ if (rawKey == null || rawKey === '') {
+ return `q${index + 1}`;
}
+ const key = String(rawKey).trim();
+ return key.startsWith('q') ? key : `q${key}`;
+ }
- async runConsistencyCheck(options = {}) {
- try {
- const data = await this.read({ ...options, skipValidation: false });
- return { valid: true, data, errors: [] };
- } catch (error) {
- const errors = error.validationErrors || [error.message || String(error)];
- return { valid: false, errors };
- }
- }
-
- registerMigration(fn) {
- if (typeof fn === 'function') {
- this.migrations.push(fn);
- }
+ function normalizeReplayQuestionKey(rawKey, index) {
+ if (rawKey == null || rawKey === '') {
+ return Number.isInteger(index) ? `q${index + 1}` : '';
}
-
- registerValidator(fn) {
- if (typeof fn === 'function') {
- this.validators.push(fn);
- }
+ const raw = String(rawKey).trim();
+ if (!raw) {
+ return Number.isInteger(index) ? `q${index + 1}` : '';
}
- }
-
- ExamData.cloneValue = cloneValue;
- ExamData.BaseRepository = BaseRepository;
-})(window);
-
-
-/* ===== js/data/repositories/dataRepositoryRegistry.js ===== */
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
-
- class DataRepositoryRegistry {
- constructor(dataSource) {
- if (!dataSource) {
- throw new Error('DataRepositoryRegistry requires a dataSource instance');
- }
- this.dataSource = dataSource;
- this._repositories = new Map();
+ const splitIndex = raw.lastIndexOf('::');
+ const value = splitIndex >= 0 ? raw.slice(splitIndex + 2).trim() : raw;
+ if (!value) {
+ return Number.isInteger(index) ? `q${index + 1}` : '';
}
-
- register(name, repository) {
- if (!name) {
- throw new Error('Repository name is required');
- }
- if (!repository) {
- throw new Error(`Repository instance missing for ${name}`);
- }
- this._repositories.set(name, repository);
+ const explicitQuestion = value.match(/^q\s*[-_ ]?(\d+)$/i) || value.match(/\bq\s*[-_ ]?(\d+)\b/i);
+ if (explicitQuestion) {
+ return `q${explicitQuestion[1]}`;
}
-
- get(name) {
- return this._repositories.get(name);
+ if (/^\d+$/.test(value)) {
+ return `q${value}`;
}
-
- listNames() {
- return Array.from(this._repositories.keys());
+ const trailingNumber = value.match(/(\d+)(?!.*\d)/);
+ if (trailingNumber) {
+ return `q${trailingNumber[1]}`;
}
+ return value.toLowerCase();
+ }
- async transaction(names, handler) {
- if (typeof handler !== 'function') {
- throw new Error('transaction handler must be a function');
- }
- const targetNames = Array.isArray(names) && names.length > 0 ? names : this.listNames();
- return this.dataSource.runTransaction(async (tx) => {
- const scope = {};
- for (const name of targetNames) {
- if (this._repositories.has(name)) {
- scope[name] = this._repositories.get(name);
- }
+ function normalizeReplayMap(rawMap = {}) {
+ const normalized = {};
+ if (Array.isArray(rawMap)) {
+ rawMap.forEach((entry, index) => {
+ if (entry == null) {
+ return;
}
- return handler(scope, tx);
- }, { label: `registry:${targetNames.join(',')}` });
- }
-
- async runConsistencyChecks(names) {
- const targetNames = Array.isArray(names) && names.length > 0 ? names : this.listNames();
- const report = {};
- for (const name of targetNames) {
- const repo = this._repositories.get(name);
- if (repo && typeof repo.runConsistencyCheck === 'function') {
- try {
- report[name] = await repo.runConsistencyCheck();
- } catch (error) {
- report[name] = {
- valid: false,
- errors: [error.message || String(error)]
- };
- }
+ if (typeof entry !== 'object') {
+ normalized[`q${index + 1}`] = entry;
+ return;
}
- }
- return report;
- }
- }
-
- ExamData.DataRepositoryRegistry = DataRepositoryRegistry;
-})(window);
-
-
-/* ===== js/data/repositories/practiceRepository.js ===== */
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
- const BaseRepository = ExamData.BaseRepository;
-
- function ensureArray(value) {
- return Array.isArray(value) ? value : [];
- }
-
- class PracticeRepository extends BaseRepository {
- constructor(dataSource, options = {}) {
- super({
- dataSource,
- key: options.key || 'practice_records',
- name: options.name || 'practice_records',
- defaultValue: () => [],
- migrations: [
- (value) => ensureArray(value),
- ...(options.migrations || [])
- ],
- validators: [
- (value) => Array.isArray(value) || 'practice_records 必须为数组',
- ...(options.validators || [])
- ],
- cloneOnRead: options.cloneOnRead !== false
+ const normalizedKey = normalizeReplayQuestionKey(
+ entry.questionId ?? entry.question ?? entry.id,
+ index
+ );
+ if (!normalizedKey) {
+ return;
+ }
+ const hasAnswerValue = Object.prototype.hasOwnProperty.call(entry, 'answer')
+ || Object.prototype.hasOwnProperty.call(entry, 'value');
+ const isComparisonEntry = !hasAnswerValue && (
+ Object.prototype.hasOwnProperty.call(entry, 'userAnswer')
+ || Object.prototype.hasOwnProperty.call(entry, 'correctAnswer')
+ || Object.prototype.hasOwnProperty.call(entry, 'isCorrect')
+ );
+ normalized[normalizedKey] = isComparisonEntry
+ ? clonePlainObject(entry)
+ : (Object.prototype.hasOwnProperty.call(entry, 'answer')
+ ? entry.answer
+ : (Object.prototype.hasOwnProperty.call(entry, 'value') ? entry.value : clonePlainObject(entry)));
});
- this.maxRecords = options.maxRecords || 1000;
- }
-
- normalizeRecord(record) {
- if (!record || typeof record !== 'object') {
- throw new Error('practice record 必须是对象');
- }
- const normalized = { ...record };
- if (!normalized.id) {
- normalized.id = `record_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
- } else {
- normalized.id = String(normalized.id);
- }
return normalized;
}
-
- validatePracticeRecord(record) {
- const errors = [];
- if (!record || typeof record !== 'object') {
- errors.push('记录必须是对象');
- } else {
- if (!record.id || typeof record.id !== 'string') {
- errors.push('记录缺少有效的 id');
- }
- if (!record.type || typeof record.type !== 'string') {
- errors.push('记录缺少有效的 type');
- }
- if (record.score === undefined || record.score === null || typeof record.score !== 'number') {
- errors.push('记录缺少有效的 score');
- }
- if (record.score !== undefined && typeof record.score !== 'number') {
- errors.push('score 必须是数字');
- }
- if (record.totalQuestions !== undefined && typeof record.totalQuestions !== 'number') {
- errors.push('totalQuestions 必须是数字');
- }
- if (record.correctAnswers !== undefined && typeof record.correctAnswers !== 'number') {
- errors.push('correctAnswers 必须是数字');
- }
- if (record.duration !== undefined && typeof record.duration !== 'number') {
- errors.push('duration 必须是数字');
- }
- if (!record.date) {
- errors.push('记录缺少有效的 date');
- } else if (Number.isNaN(new Date(record.date).getTime())) {
- errors.push('date 格式无效');
- }
- }
- return {
- isValid: errors.length === 0,
- errors
- };
+ if (!rawMap || typeof rawMap !== 'object') {
+ return normalized;
}
-
- _assertRecord(record) {
- const validation = this.validatePracticeRecord(record);
- if (!validation.isValid) {
- const error = new Error(`[practice_records] 记录无效: ${validation.errors.join(', ')}`);
- error.validationErrors = validation.errors;
- throw error;
+ Object.entries(rawMap).forEach(([key, value], index) => {
+ const normalizedKey = normalizeReplayQuestionKey(key, index);
+ if (normalizedKey) {
+ normalized[normalizedKey] = value;
}
- return true;
- }
-
- async list(options = {}) {
- return await this.read({ ...options, clone: options.clone !== false });
- }
+ });
+ return normalized;
+ }
- async getById(id, options = {}) {
- const records = await this.read({ ...options, clone: true });
- return records.find(r => r.id === id) || null;
- }
+ function normalizeAnswerMap(rawAnswers = {}) {
+ const map = {};
- async overwrite(records, options = {}) {
- const list = ensureArray(records).map((record) => {
- const normalized = this.normalizeRecord(record);
- this._assertRecord(normalized);
- return normalized;
+ if (Array.isArray(rawAnswers)) {
+ rawAnswers.forEach((entry, index) => {
+ if (!entry) return;
+ const key = normalizeQuestionKey(entry.questionId, index);
+ const rawValue = entry.answer ?? entry.userAnswer ?? entry.value ?? entry;
+ map[key] = normalizeAnswerValue(rawValue);
});
- await this.write(list, { ...options, skipValidation: true });
- return true;
- }
-
- async upsert(record, options = {}) {
- const normalized = this.normalizeRecord(record);
- this._assertRecord(normalized);
- const merge = options.merge === true;
- return this.dataSource.runTransaction(async (tx) => {
- let records = await this.read({ transaction: tx, skipValidation: true, clone: true });
- records = ensureArray(records);
- const index = records.findIndex(r => r.id === normalized.id);
- if (index >= 0) {
- records[index] = merge ? { ...records[index], ...normalized } : normalized;
- } else {
- records.unshift(normalized);
- }
- if (this.maxRecords && records.length > this.maxRecords) {
- records = records.slice(0, this.maxRecords);
- }
- await this.write(records, { transaction: tx, skipValidation: true, clone: false });
- return normalized;
- }, { label: 'practice-upsert' });
+ return map;
}
- async removeById(id, options = {}) {
- if (!id) return 0;
- const removed = await this.removeByIds([id], options);
- return removed;
+ if (!rawAnswers || typeof rawAnswers !== 'object') {
+ return map;
}
- async removeByIds(ids, options = {}) {
- const idSet = new Set((ids || []).filter(Boolean).map(String));
- if (idSet.size === 0) {
- return 0;
+ Object.entries(rawAnswers).forEach(([rawKey, rawValue], index) => {
+ if (isNoiseKey(rawKey)) {
+ return;
}
- return this.dataSource.runTransaction(async (tx) => {
- let records = await this.read({ transaction: tx, skipValidation: true, clone: true });
- records = ensureArray(records);
- const next = records.filter(record => !idSet.has(record.id));
- const removed = records.length - next.length;
- if (removed > 0) {
- await this.write(next, { transaction: tx, skipValidation: true, clone: false });
- }
- return removed;
- }, { label: 'practice-remove' });
- }
+ const key = normalizeQuestionKey(rawKey, index);
+ const resolvedValue = rawValue && typeof rawValue === 'object' && 'answer' in rawValue
+ ? rawValue.answer
+ : rawValue;
+ map[key] = normalizeAnswerValue(resolvedValue);
+ });
- async update(id, updates = {}, options = {}) {
- if (!id) {
- throw new Error('update 需要记录 id');
- }
- if (!updates || typeof updates !== 'object') {
- throw new Error('updates 必须是对象');
- }
- return this.dataSource.runTransaction(async (tx) => {
- let records = await this.read({ transaction: tx, skipValidation: true, clone: true });
- records = ensureArray(records);
- const index = records.findIndex(record => record.id === String(id));
- if (index === -1) {
- return null;
- }
- const updated = { ...records[index], ...updates };
- this._assertRecord(updated);
- records[index] = updated;
- await this.write(records, { transaction: tx, skipValidation: true, clone: false });
- return updated;
- }, { label: 'practice-update' });
- }
+ return map;
+ }
- async count(options = {}) {
- const records = await this.read({ ...options, clone: false, skipValidation: false });
- return Array.isArray(records) ? records.length : 0;
+ function normalizeAnswerComparison(comparison) {
+ if (!comparison || typeof comparison !== 'object') {
+ return {};
}
- async clear(options = {}) {
- await this.write([], { ...options, skipValidation: true });
- return true;
+ const sanitizer = global.AnswerSanitizer;
+ if (sanitizer && typeof sanitizer.sanitizeComparisonMap === 'function') {
+ return sanitizer.sanitizeComparisonMap(comparison);
}
- async runConsistencyCheck(options = {}) {
- const report = await super.runConsistencyCheck(options);
- if (!report.valid) {
- return report;
- }
- const errors = [];
- const records = ensureArray(report.data);
- for (const record of records) {
- const validation = this.validatePracticeRecord(record);
- if (!validation.isValid) {
- errors.push(`记录 ${record && record.id ? record.id : 'unknown'}: ${validation.errors.join(', ')}`);
- }
+ const normalized = {};
+ Object.entries(comparison).forEach(([questionId, entry]) => {
+ if (isNoiseKey(questionId) || !entry || typeof entry !== 'object') {
+ return;
}
- if (errors.length > 0) {
- return { valid: false, errors };
+ const userAnswer = normalizeAnswerValue(entry.userAnswer ?? entry.user ?? entry.answer);
+ const correctAnswer = normalizeAnswerValue(entry.correctAnswer ?? entry.correct);
+ if (!userAnswer && !correctAnswer) {
+ return;
}
- return { valid: true, data: records, errors: [] };
- }
- }
-
- ExamData.PracticeRepository = PracticeRepository;
-})(window);
-
-
-/* ===== js/data/repositories/settingsRepository.js ===== */
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
- const BaseRepository = ExamData.BaseRepository;
+ normalized[questionId] = {
+ questionId: entry.questionId || questionId,
+ userAnswer,
+ correctAnswer,
+ isCorrect: typeof entry.isCorrect === 'boolean' ? entry.isCorrect : null
+ };
+ });
- function ensureObject(value) {
- return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+ return normalized;
}
- class SettingsRepository extends BaseRepository {
- constructor(dataSource, options = {}) {
- super({
- dataSource,
- key: options.key || 'user_settings',
- name: options.name || 'user_settings',
- defaultValue: () => ({}),
- migrations: [
- (value) => ensureObject(value),
- ...(options.migrations || [])
- ],
- validators: [
- (value) => (value && typeof value === 'object' && !Array.isArray(value)) || 'user_settings 必须是对象',
- ...(options.validators || [])
- ],
- cloneOnRead: options.cloneOnRead !== false
- });
+ function convertComparisonToMap(comparison, key = 'correctAnswer') {
+ if (!comparison || typeof comparison !== 'object') {
+ return {};
}
+ const map = {};
+ Object.entries(comparison).forEach(([questionId, entry]) => {
+ if (!entry || typeof entry !== 'object') return;
+ const value = entry[key] ?? (key === 'correctAnswer' ? entry.correct : entry.userAnswer ?? entry.user);
+ if (value != null && String(value).trim() !== '') {
+ map[questionId] = value;
+ }
+ });
+ return map;
+ }
- async getAll(options = {}) {
- return await this.read({ ...options, clone: options.clone !== false });
+ function convertComparisonToDetails(comparison) {
+ if (!comparison || typeof comparison !== 'object') {
+ return null;
}
+ const details = {};
+ Object.entries(comparison).forEach(([questionId, entry]) => {
+ if (!entry || typeof entry !== 'object') return;
+ details[questionId] = {
+ userAnswer: normalizeAnswerValue(entry.userAnswer ?? entry.user ?? entry.answer),
+ correctAnswer: normalizeAnswerValue(entry.correctAnswer ?? entry.correct),
+ isCorrect: typeof entry.isCorrect === 'boolean' ? entry.isCorrect : null
+ };
+ });
+ return details;
+ }
- async saveAll(settings, options = {}) {
- const prepared = ensureObject(settings);
- await this.write(prepared, { ...options, skipValidation: false });
- return true;
- }
+ function buildAnswerDetails(answerMap = {}, correctMap = {}) {
+ const details = {};
+ const keys = new Set([
+ ...Object.keys(answerMap || {}),
+ ...Object.keys(correctMap || {})
+ ]);
- async get(key, defaultValue = null, options = {}) {
- const settings = await this.read({ ...options, clone: true });
- if (Object.prototype.hasOwnProperty.call(settings, key)) {
- return settings[key];
+ keys.forEach((questionId) => {
+ const userAnswer = normalizeAnswerValue(answerMap[questionId]);
+ const correctAnswer = normalizeAnswerValue(correctMap[questionId]);
+ let isCorrect = null;
+ if (correctAnswer) {
+ const matchCore = global.AnswerMatchCore;
+ isCorrect = matchCore && typeof matchCore.compareAnswers === 'function'
+ ? matchCore.compareAnswers(userAnswer, correctAnswer) === true
+ : userAnswer.toLowerCase() === correctAnswer.toLowerCase();
}
- return typeof defaultValue === 'function' ? defaultValue() : defaultValue;
- }
-
- async set(key, value, options = {}) {
- return this.merge({ [key]: value }, options);
- }
+ details[questionId] = {
+ userAnswer: userAnswer || '-',
+ correctAnswer: correctAnswer || '-',
+ isCorrect
+ };
+ });
- async merge(patch, options = {}) {
- if (!patch || typeof patch !== 'object') {
- throw new Error('merge 需要对象参数');
- }
- return this.dataSource.runTransaction(async (tx) => {
- const current = ensureObject(await this.read({ transaction: tx, skipValidation: true, clone: true }));
- const next = { ...current, ...patch };
- await this.write(next, { transaction: tx, skipValidation: false, clone: false });
- return next;
- }, { label: 'settings-merge' });
- }
+ return details;
+ }
- async removeKey(key, options = {}) {
- return this.dataSource.runTransaction(async (tx) => {
- const current = ensureObject(await this.read({ transaction: tx, skipValidation: true, clone: true }));
- if (!Object.prototype.hasOwnProperty.call(current, key)) {
- return current;
- }
- delete current[key];
- await this.write(current, { transaction: tx, skipValidation: false, clone: false });
- return current;
- }, { label: 'settings-remove-key' });
+ function compareAnswerValues(userAnswer, correctAnswer) {
+ if (userAnswer == null || correctAnswer == null) {
+ return false;
}
-
- async clear(options = {}) {
- await this.write({}, { ...options, skipValidation: true });
- return true;
+ const matchCore = global.AnswerMatchCore;
+ if (matchCore && typeof matchCore.compareAnswers === 'function') {
+ return matchCore.compareAnswers(userAnswer, correctAnswer) === true;
}
+ return String(userAnswer).trim().toLowerCase() === String(correctAnswer).trim().toLowerCase();
}
- ExamData.SettingsRepository = SettingsRepository;
-})(window);
-
-
-/* ===== js/data/repositories/backupRepository.js ===== */
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
- const BaseRepository = ExamData.BaseRepository;
-
- function ensureArray(value) {
- return Array.isArray(value) ? value : [];
+ function mergeReplayMapFirstWins() {
+ const merged = {};
+ Array.prototype.slice.call(arguments).forEach((source) => {
+ if (!source || typeof source !== 'object' || Array.isArray(source)) {
+ return;
+ }
+ const normalized = normalizeReplayMap(source);
+ Object.entries(normalized).forEach(([key, value]) => {
+ if (!Object.prototype.hasOwnProperty.call(merged, key)) {
+ merged[key] = value;
+ }
+ });
+ });
+ return merged;
}
- class BackupRepository extends BaseRepository {
- constructor(dataSource, options = {}) {
- super({
- dataSource,
- key: options.key || 'manual_backups',
- name: options.name || 'manual_backups',
- defaultValue: () => [],
- migrations: [
- (value) => ensureArray(value),
- ...(options.migrations || [])
- ],
- validators: [
- (value) => Array.isArray(value) || 'manual_backups 必须是数组',
- ...(options.validators || [])
- ],
- cloneOnRead: options.cloneOnRead !== false
- });
- this.maxBackups = options.maxBackups || 20;
- }
+ function buildReplayCorrectAnswerMap(entry = {}) {
+ const realData = isPlainObject(entry.realData) ? entry.realData : {};
+ const rawData = isPlainObject(entry.rawData) ? entry.rawData : {};
+ const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {};
+ return mergeReplayMapFirstWins(
+ entry.correctAnswerMap,
+ realData.correctAnswerMap,
+ rawData.correctAnswerMap,
+ rawRealData.correctAnswerMap
+ );
+ }
- normalizeBackup(backup) {
- if (!backup || typeof backup !== 'object') {
- throw new Error('备份数据必须是对象');
- }
- const normalized = { ...backup };
- normalized.id = normalized.id ? String(normalized.id) : `backup_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
- normalized.timestamp = normalized.timestamp || new Date().toISOString();
- normalized.type = normalized.type || 'manual';
- normalized.version = normalized.version || '0.6.2-fix';
- normalized.data = normalized.data || {};
- normalized.size = normalized.size || JSON.stringify(normalized.data).length;
- return normalized;
- }
+ function buildReplayResultSnapshot(entry = {}) {
+ const realData = isPlainObject(entry.realData) ? entry.realData : {};
+ const rawData = isPlainObject(entry.rawData) ? entry.rawData : {};
+ const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {};
+ const answers = mergeReplayMapFirstWins(
+ entry.answers,
+ realData.answers,
+ rawData.answers,
+ rawRealData.answers
+ );
+ const correctAnswerMap = buildReplayCorrectAnswerMap(entry);
+ const rawComparison = mergeReplayMapFirstWins(
+ entry.answerComparison,
+ realData.answerComparison,
+ rawData.answerComparison,
+ rawRealData.answerComparison
+ );
+ const questionIds = new Set([
+ ...Object.keys(answers),
+ ...Object.keys(correctAnswerMap),
+ ...Object.keys(rawComparison),
+ ...(Array.isArray(entry.allQuestionIds)
+ ? entry.allQuestionIds.map((item, index) => normalizeReplayQuestionKey(item, index)).filter(Boolean)
+ : [])
+ ]);
- validateBackup(backup) {
- const errors = [];
- if (!backup || typeof backup !== 'object') {
- errors.push('备份必须是对象');
- } else {
- if (!backup.id) {
- errors.push('备份缺少 id');
- }
- if (!backup.timestamp) {
- errors.push('备份缺少 timestamp');
- }
- if (!backup.data || typeof backup.data !== 'object') {
- errors.push('备份缺少 data 对象');
- }
+ let correctCount = 0;
+ const answerComparison = {};
+ questionIds.forEach((questionId) => {
+ const rawEntry = rawComparison[questionId];
+ const comparisonEntry = isPlainObject(rawEntry) ? rawEntry : {};
+ const userAnswer = Object.prototype.hasOwnProperty.call(comparisonEntry, 'userAnswer')
+ ? comparisonEntry.userAnswer
+ : (Object.prototype.hasOwnProperty.call(answers, questionId) ? answers[questionId] : '');
+ const hasCanonicalCorrectAnswer = Object.prototype.hasOwnProperty.call(correctAnswerMap, questionId);
+ const correctAnswer = hasCanonicalCorrectAnswer ? correctAnswerMap[questionId] : '';
+ const isCorrect = hasCanonicalCorrectAnswer
+ ? compareAnswerValues(userAnswer, correctAnswer)
+ : null;
+ if (isCorrect) {
+ correctCount += 1;
}
- return {
- isValid: errors.length === 0,
- errors
+ answerComparison[questionId] = {
+ questionId,
+ userAnswer,
+ correctAnswer,
+ isCorrect
};
- }
-
- _assertBackup(backup) {
- const validation = this.validateBackup(backup);
- if (!validation.isValid) {
- const error = new Error(`[manual_backups] 备份无效: ${validation.errors.join(', ')}`);
- error.validationErrors = validation.errors;
- throw error;
- }
- return true;
- }
+ });
- async list(options = {}) {
- return await this.read({ ...options, clone: options.clone !== false });
- }
+ const totalQuestions = questionIds.size;
+ const sourceScoreInfo = isPlainObject(entry.scoreInfo)
+ ? entry.scoreInfo
+ : (isPlainObject(realData.scoreInfo)
+ ? realData.scoreInfo
+ : (isPlainObject(rawData.scoreInfo) ? rawData.scoreInfo : {}));
+ const scoreInfo = clonePlainObject(sourceScoreInfo) || {};
+ const hasCompleteCanonicalCorrectAnswers = totalQuestions > 0
+ && Array.from(questionIds).every(questionId => Object.prototype.hasOwnProperty.call(correctAnswerMap, questionId));
+ scoreInfo.correct = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.correct))
+ ? correctCount
+ : Number(scoreInfo.correct);
+ scoreInfo.total = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.total))
+ ? totalQuestions
+ : Number(scoreInfo.total);
+ scoreInfo.totalQuestions = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.totalQuestions))
+ ? scoreInfo.total
+ : Number(scoreInfo.totalQuestions);
+ const existingAccuracy = Number(scoreInfo.accuracy);
+ scoreInfo.accuracy = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(existingAccuracy)
+ ? (scoreInfo.totalQuestions > 0 ? scoreInfo.correct / scoreInfo.totalQuestions : 0)
+ : existingAccuracy;
+ scoreInfo.percentage = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.percentage))
+ ? Math.round(scoreInfo.accuracy * 100)
+ : Number(scoreInfo.percentage);
+ scoreInfo.answerKeyComplete = hasCompleteCanonicalCorrectAnswers;
- async add(backup, options = {}) {
- const normalized = this.normalizeBackup(backup);
- this._assertBackup(normalized);
- return this.dataSource.runTransaction(async (tx) => {
- let backups = ensureArray(await this.read({ transaction: tx, skipValidation: true, clone: true }));
- backups.unshift(normalized);
- if (this.maxBackups && backups.length > this.maxBackups) {
- backups = backups.slice(0, this.maxBackups);
- }
- await this.write(backups, { transaction: tx, skipValidation: true, clone: false });
- return normalized;
- }, { label: 'backup-add' });
- }
+ const annotations = resolveAnnotationState(entry);
- async saveAll(backups, options = {}) {
- const prepared = ensureArray(backups).map((item) => {
- const normalized = this.normalizeBackup(item);
- this._assertBackup(normalized);
- return normalized;
- });
- await this.write(prepared, { ...options, skipValidation: true });
- return true;
- }
+ return {
+ answers,
+ correctAnswers: correctAnswerMap,
+ correctAnswerMap,
+ answerComparison,
+ scoreInfo,
+ ...annotations
+ };
+ }
- async delete(id, options = {}) {
- if (!id) return false;
- const targetId = String(id);
- return this.dataSource.runTransaction(async (tx) => {
- let backups = ensureArray(await this.read({ transaction: tx, skipValidation: true, clone: true }));
- const next = backups.filter(backup => backup.id !== targetId);
- const deleted = next.length !== backups.length;
- if (deleted) {
- await this.write(next, { transaction: tx, skipValidation: true, clone: false });
- }
- return deleted;
- }, { label: 'backup-delete' });
+ function deriveCorrectMapFromDetails(details) {
+ if (!details || typeof details !== 'object') {
+ return {};
}
+ const map = {};
+ Object.entries(details).forEach(([questionId, info]) => {
+ if (!info) return;
+ const correctAnswer = info.correctAnswer || info.answer || info.value;
+ if (correctAnswer != null) {
+ map[questionId] = normalizeAnswerValue(correctAnswer);
+ }
+ });
+ return map;
+ }
- async getById(id, options = {}) {
- if (!id) return null;
- const backups = await this.read({ ...options, clone: true });
- return backups.find(backup => backup.id === String(id)) || null;
+ function buildAnswerArray(answers, correctMap = {}) {
+ if (Array.isArray(answers)) {
+ return answers.map((answer, index) => {
+ const questionId = answer.questionId || `q${index + 1}`;
+ const userAnswer = normalizeAnswerValue(answer.answer);
+ const normalizedCorrect = normalizeAnswerValue(answer.correctAnswer ?? correctMap[questionId]);
+ return {
+ questionId,
+ answer: userAnswer,
+ correctAnswer: normalizedCorrect,
+ correct: normalizedCorrect ? compareAnswerValues(userAnswer, normalizedCorrect) : Boolean(answer.correct),
+ timeSpent: ensureNumber(answer.timeSpent, 0),
+ questionType: answer.questionType || 'unknown',
+ timestamp: answer.timestamp || new Date().toISOString()
+ };
+ });
}
- async clear(options = {}) {
- await this.write([], { ...options, skipValidation: true });
- return true;
- }
+ const answerMap = normalizeAnswerMap(answers);
+ const keys = new Set([
+ ...Object.keys(answerMap),
+ ...Object.keys(correctMap || {})
+ ]);
- async prune(limit, options = {}) {
- const max = typeof limit === 'number' && limit > 0 ? limit : this.maxBackups;
- return this.dataSource.runTransaction(async (tx) => {
- let backups = ensureArray(await this.read({ transaction: tx, skipValidation: true, clone: true }));
- if (backups.length <= max) {
- return backups.length;
- }
- const next = backups.slice(0, max);
- await this.write(next, { transaction: tx, skipValidation: true, clone: false });
- return next.length;
- }, { label: 'backup-prune' });
- }
+ const list = [];
+ keys.forEach((questionId, index) => {
+ const userAnswer = normalizeAnswerValue(answerMap[questionId]);
+ const normalizedCorrect = normalizeAnswerValue(correctMap[questionId]);
+ const isCorrect = normalizedCorrect ? compareAnswerValues(userAnswer, normalizedCorrect) : false;
+ list.push({
+ questionId: questionId || `q${index + 1}`,
+ answer: userAnswer,
+ correctAnswer: normalizedCorrect,
+ correct: isCorrect,
+ timeSpent: 0,
+ questionType: 'unknown',
+ timestamp: new Date().toISOString()
+ });
+ });
+ return list;
}
- ExamData.BackupRepository = BackupRepository;
-})(window);
-
-
-/* ===== js/data/repositories/metaRepository.js ===== */
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
- const BaseRepository = ExamData.BaseRepository;
-
- class MetaRepository {
- constructor(dataSource, definitions = {}) {
- if (!dataSource) {
- throw new Error('MetaRepository requires a dataSource instance');
+ function deriveTotalQuestionCount(recordData = {}, fallbackLength = 0) {
+ const candidates = [
+ recordData.totalQuestions,
+ recordData.questionCount,
+ recordData.question_count,
+ typeof recordData.questions === 'number' ? recordData.questions : null,
+ recordData.scoreInfo && recordData.scoreInfo.total,
+ recordData.scoreInfo && recordData.scoreInfo.totalQuestions,
+ recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.totalQuestions,
+ recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.total,
+ recordData.realData && recordData.realData.totalQuestions,
+ recordData.realData && recordData.realData.questionCount
+ ];
+ for (let i = 0; i < candidates.length; i += 1) {
+ const numeric = Number(candidates[i]);
+ if (Number.isFinite(numeric) && numeric >= 0) {
+ return numeric;
}
- this.dataSource = dataSource;
- this.repositories = new Map();
- Object.entries(definitions).forEach(([key, config]) => {
- this.registerKey(key, config);
- });
}
- registerKey(key, config = {}) {
- const repository = new BaseRepository({
- dataSource: this.dataSource,
- key,
- name: config.name || `meta:${key}`,
- defaultValue: config.defaultValue !== undefined ? config.defaultValue : null,
- migrations: config.migrations || [],
- validators: config.validators || [],
- cloneOnRead: config.cloneOnRead !== false
- });
- this.repositories.set(key, repository);
- return repository;
+ if (Array.isArray(recordData.answers)) {
+ return recordData.answers.length;
}
-
- _getRepo(key) {
- const repo = this.repositories.get(key);
- if (!repo) {
- throw new Error(`MetaRepository 未注册键: ${key}`);
+ if (Array.isArray(recordData.answerList)) {
+ return recordData.answerList.length;
+ }
+ const detailSources = [
+ recordData.answerDetails,
+ recordData.scoreInfo && recordData.scoreInfo.details,
+ recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details
+ ];
+ for (let i = 0; i < detailSources.length; i += 1) {
+ const details = detailSources[i];
+ if (details && typeof details === 'object') {
+ return Object.keys(details).length;
}
- return repo;
}
- async get(key, defaultValue, options = {}) {
- const repo = this._getRepo(key);
- const resolvedDefault = defaultValue !== undefined ? defaultValue : undefined;
- return repo.read({ ...options, defaultValue: resolvedDefault, clone: options.clone !== false });
- }
+ return fallbackLength || 0;
+ }
- async set(key, value, options = {}) {
- const repo = this._getRepo(key);
- await repo.write(value, { ...options, skipValidation: false, clone: options.clone !== false });
- return true;
+ function deriveCorrectAnswerCount(recordData = {}, answers = []) {
+ const numericCandidates = [
+ recordData.correctAnswers,
+ recordData.correctAnswersCount,
+ recordData.correctCount,
+ recordData.correct,
+ recordData.score,
+ recordData.scoreInfo && recordData.scoreInfo.correct,
+ recordData.scoreInfo && recordData.scoreInfo.score,
+ recordData.realData && recordData.realData.correctAnswersCount,
+ recordData.realData && recordData.realData.correctCount,
+ recordData.realData && recordData.realData.correct,
+ recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.correct,
+ recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.score
+ ];
+ for (let i = 0; i < numericCandidates.length; i += 1) {
+ const numeric = Number(numericCandidates[i]);
+ if (Number.isFinite(numeric) && numeric >= 0) {
+ return numeric;
+ }
}
- async remove(key, options = {}) {
- const repo = this._getRepo(key);
- await repo.remove(options);
- return true;
+ if (Array.isArray(answers) && answers.length > 0) {
+ return answers.reduce((sum, answer) => {
+ if (!answer || typeof answer !== 'object') {
+ return sum;
+ }
+ return (answer.correct === true || answer.isCorrect === true) ? sum + 1 : sum;
+ }, 0);
}
- async runConsistencyCheck(keys) {
- const targetKeys = Array.isArray(keys) && keys.length ? keys : Array.from(this.repositories.keys());
- const report = {};
- for (const key of targetKeys) {
- const repo = this.repositories.get(key);
- if (!repo) continue;
- report[key] = await repo.runConsistencyCheck();
- }
- return report;
- }
- }
-
- ExamData.MetaRepository = MetaRepository;
-})(window);
-
-
-/* ===== js/data/index.js ===== */
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
-
- function createDefaultUserStats() {
- const now = new Date().toISOString();
- return {
- totalPractices: 0,
- totalTimeSpent: 0,
- averageScore: 0,
- categoryStats: {},
- questionTypeStats: {},
- streakDays: 0,
- lastPracticeDate: null,
- achievements: [],
- createdAt: now,
- updatedAt: now
- };
- }
-
- function createDefaultVocabConfig() {
- return {
- dailyNew: 20,
- reviewLimit: 100,
- masteryCount: 4,
- theme: 'auto',
- notify: true
- };
- }
-
- function createMetaFacade(metaRepo) {
- function assertAllowedKey(key) {
- if (key === 'user_stats') {
- throw new Error('user_stats must go through PracticeRecordAPI');
- }
- }
-
- return Object.freeze({
- async get(key, defaultValue, options = {}) {
- assertAllowedKey(key);
- return await metaRepo.get(key, defaultValue, options);
- },
- async set(key, value, options = {}) {
- assertAllowedKey(key);
- return await metaRepo.set(key, value, options);
- },
- async remove(key, options = {}) {
- assertAllowedKey(key);
- return await metaRepo.remove(key, options);
- },
- async runConsistencyCheck(keys) {
- const targetKeys = Array.isArray(keys)
- ? keys.filter((key) => key !== 'user_stats')
- : undefined;
- return await metaRepo.runConsistencyCheck(targetKeys);
- }
- });
- }
-
- function bootstrap() {
- if (!window.persistentStore) {
- console.warn('[data/index] StorageManager 未就绪,延迟初始化数据仓库');
- setTimeout(bootstrap, 100);
- return;
- }
-
- if (window.dataRepositories) {
- return;
- }
-
- if (!window.PracticeCore || typeof window.PracticeCore.__installInternalRepositories !== 'function') {
- console.warn('[data/index] PracticeCore internal installer 未就绪,延迟初始化数据仓库');
- setTimeout(bootstrap, 100);
- return;
- }
-
- let createInternalOptions = null;
- if (typeof window.__installStorageInternalAccess === 'function') {
- window.__installStorageInternalAccess((factory) => {
- createInternalOptions = typeof factory === 'function' ? factory : null;
- return Boolean(createInternalOptions);
- });
- }
- if (!createInternalOptions) {
- console.warn('[data/index] Storage internal access 未就绪,延迟初始化数据仓库');
- setTimeout(bootstrap, 100);
- return;
- }
-
- const dataSource = new ExamData.StorageDataSource(window.persistentStore, {
- createInternalOptions
- });
- const registry = new ExamData.DataRepositoryRegistry(dataSource);
-
- const practiceRepo = new ExamData.PracticeRepository(dataSource, { maxRecords: 1000 });
- const settingsRepo = new ExamData.SettingsRepository(dataSource);
- const backupRepo = new ExamData.BackupRepository(dataSource, { maxBackups: 20 });
- const metaRepo = new ExamData.MetaRepository(dataSource, {
- user_stats: {
- defaultValue: createDefaultUserStats,
- validators: [
- (value) => (value && typeof value === 'object' && !Array.isArray(value)) || 'user_stats 必须为对象'
- ]
- },
- storage_version: {
- defaultValue: () => null,
- validators: [
- (value) => value === null || typeof value === 'string' || 'storage_version 必须是字符串或 null'
- ],
- cloneOnRead: false
- },
- data_restored: {
- defaultValue: () => false,
- validators: [
- (value) => typeof value === 'boolean' || 'data_restored 必须是布尔值'
- ],
- cloneOnRead: false
- },
- active_sessions: {
- defaultValue: () => [],
- validators: [
- (value) => Array.isArray(value) || 'active_sessions 必须为数组'
- ]
- },
- temp_practice_records: {
- defaultValue: () => [],
- validators: [
- (value) => Array.isArray(value) || 'temp_practice_records 必须为数组'
- ]
- },
- interrupted_records: {
- defaultValue: () => [],
- validators: [
- (value) => Array.isArray(value) || 'interrupted_records 必须为数组'
- ]
- },
- exam_index: {
- defaultValue: () => [],
- validators: [
- (value) => Array.isArray(value) || 'exam_index 必须为数组'
- ]
- },
- vocab_words: {
- defaultValue: () => [],
- validators: [
- (value) => Array.isArray(value) || 'vocab_words 必须为数组'
- ]
- },
- vocab_user_config: {
- defaultValue: createDefaultVocabConfig,
- validators: [
- (value) => (value && typeof value === 'object' && !Array.isArray(value)) || 'vocab_user_config 必须为对象'
- ]
- },
- vocab_review_queue: {
- defaultValue: () => [],
- validators: [
- (value) => Array.isArray(value) || 'vocab_review_queue 必须为数组'
- ]
- },
- vocab_list_reading_highlights: {
- defaultValue: () => [],
- validators: [
- (value) => (
- Array.isArray(value)
- || (value && typeof value === 'object' && Array.isArray(value.words))
- ) || 'vocab_list_reading_highlights 必须为数组或词表对象'
- ]
- },
- legacy_practice_records_migrated: {
- defaultValue: () => false,
- validators: [
- (value) => typeof value === 'boolean' || 'legacy_practice_records_migrated 必须为布尔值'
- ],
- cloneOnRead: false
- }
- });
-
- registry.register('practice', practiceRepo);
- registry.register('settings', settingsRepo);
- registry.register('backups', backupRepo);
- registry.register('meta', metaRepo);
-
- const internalApi = {
- get practice() { return practiceRepo; },
- get settings() { return settingsRepo; },
- get backups() { return backupRepo; },
- get meta() { return metaRepo; },
- transaction(names, handler) {
- return registry.transaction(names, handler);
- },
- runConsistencyChecks(names) {
- return registry.runConsistencyChecks(names);
+ const detailSources = [
+ recordData.answerDetails,
+ recordData.scoreInfo && recordData.scoreInfo.details,
+ recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details
+ ];
+ for (let i = 0; i < detailSources.length; i += 1) {
+ const details = detailSources[i];
+ if (!details || typeof details !== 'object') {
+ continue;
}
- };
- window.PracticeCore.__installInternalRepositories(internalApi, { createInternalOptions });
- if (window.__installStorageInternalAccess) {
- try {
- delete window.__installStorageInternalAccess;
- } catch (_) {
- window.__installStorageInternalAccess = undefined;
- }
- }
- const metaFacade = createMetaFacade(metaRepo);
- const api = {
- get settings() { return settingsRepo; },
- get backups() { return backupRepo; },
- get meta() { return metaFacade; },
- transaction(names, handler) {
- const targetNames = Array.isArray(names) ? names : [];
- if (targetNames.includes('practice')) {
- throw new Error('practice_records transactions must go through PracticeRecordAPI');
+ let hasFlag = false;
+ let correctCount = 0;
+ Object.values(details).forEach((detail) => {
+ if (!detail || typeof detail !== 'object') {
+ return;
}
- return registry.transaction(names, handler);
- },
- runConsistencyChecks(names) {
- const targetNames = Array.isArray(names)
- ? names.filter((name) => name !== 'practice')
- : undefined;
- return registry.runConsistencyChecks(targetNames);
- }
- };
- const registryApi = window.StorageProviderRegistry;
- if (registryApi && typeof registryApi.registerStorageProviders === 'function') {
- registryApi.registerStorageProviders({
- repositories: api,
- storageManager: window.storage || null,
- persistentStore: window.persistentStore || null,
- preferenceStore: window.preferenceStore || null
+ if (detail.isCorrect === true || detail.correct === true) {
+ correctCount += 1;
+ }
+ hasFlag = hasFlag || typeof detail.isCorrect === 'boolean' || typeof detail.correct === 'boolean';
});
- } else {
- window.dataRepositories = api;
+ if (hasFlag) {
+ return correctCount;
+ }
}
- ExamData.registry = registry;
- ExamData.createDefaultUserStats = createDefaultUserStats;
- ExamData.createDefaultVocabConfig = createDefaultVocabConfig;
- console.log('[data/index] 数据仓库初始化完成');
- }
-
- bootstrap();
-})(window);
-
-
-/* ===== js/core/practiceCore.js ===== */
-(function initPracticeCore(global) {
- 'use strict';
-
- if (global.PracticeCore && global.PracticeCore.__stable === true) {
- return;
- }
-
- const MESSAGE_TYPE_ALIASES = Object.freeze({
- practice_complete: 'PRACTICE_COMPLETE',
- practice_completed: 'PRACTICE_COMPLETE',
- PracticeComplete: 'PRACTICE_COMPLETE',
- SESSION_COMPLETE: 'PRACTICE_COMPLETE',
- session_complete: 'PRACTICE_COMPLETE',
- session_completed: 'PRACTICE_COMPLETE',
- EXAM_FINISHED: 'PRACTICE_COMPLETE',
- QUIZ_COMPLETE: 'PRACTICE_COMPLETE',
- QUIZ_COMPLETED: 'PRACTICE_COMPLETE',
- TEST_COMPLETE: 'PRACTICE_COMPLETE',
- LESSON_COMPLETE: 'PRACTICE_COMPLETE',
- WORKOUT_COMPLETE: 'PRACTICE_COMPLETE',
- SESSION_READY: 'SESSION_READY',
- session_ready: 'SESSION_READY',
- EXAM_COMPLETED: 'exam_completed',
- EXAM_PROGRESS: 'exam_progress',
- EXAM_ERROR: 'exam_error',
- progress_update: 'PROGRESS_UPDATE',
- SESSION_PROGRESS: 'PROGRESS_UPDATE',
- session_progress: 'PROGRESS_UPDATE',
- practice_progress: 'PROGRESS_UPDATE',
- SESSION_ERROR: 'ERROR_OCCURRED',
- session_error: 'ERROR_OCCURRED',
- practice_error: 'ERROR_OCCURRED',
- REQUEST_INIT: 'REQUEST_INIT',
- request_init: 'REQUEST_INIT',
- REQUEST_SESSION_INIT: 'REQUEST_INIT',
- INIT_SESSION: 'INIT_SESSION',
- init_session: 'INIT_SESSION'
- });
-
- const PRACTICE_COMPLETE_TYPES = new Set([
- 'PRACTICE_COMPLETE',
- 'PRACTICE_COMPLETED',
- 'SESSION_COMPLETE',
- 'SESSION_COMPLETED',
- 'EXAM_FINISHED',
- 'QUIZ_COMPLETE',
- 'QUIZ_COMPLETED',
- 'TEST_COMPLETE',
- 'LESSON_COMPLETE',
- 'WORKOUT_COMPLETE'
- ]);
-
- const STORAGE_KEYS = Object.freeze({
- practiceRecords: 'practice_records',
- userStats: 'user_stats',
- activeSessions: 'active_sessions',
- tempPracticeRecords: 'temp_practice_records'
- });
- let internalRepositories = null;
- // 由 data/index.js 在仓库注入时通过 __installInternalRepositories 第二参数传入,
- // 使仓库未注入前的 fallback 路径也能拿到 storage internal token,避免被新保护层拒绝。
- let internalStorageAccess = null;
-
- function isPlainObject(value) {
- return value && typeof value === 'object' && !Array.isArray(value);
+ return 0;
}
- function safeParseJson(value) {
- if (typeof value !== 'string') {
- return null;
- }
- try {
- return JSON.parse(value);
- } catch (_) {
- return null;
- }
- }
+ function buildMetadata(recordData = {}, type) {
+ const metadata = Object.assign({}, recordData.metadata || {});
+ const examId = recordData.examId;
+ const fallbackTitle = recordData.title || recordData.examTitle || recordData.examName || recordData.name || examId || 'Unknown Exam';
+ const fallbackCategory = recordData.category || recordData.examCategory || recordData.section || recordData.mode || metadata.category || 'Unknown';
+ const fallbackFrequency = recordData.frequency || metadata.frequency || 'unknown';
- function clonePlainObject(value) {
- if (value == null || typeof value !== 'object') {
- return value ?? null;
+ metadata.examTitle = metadata.examTitle || metadata.title || fallbackTitle;
+ metadata.category = metadata.category || fallbackCategory;
+ metadata.frequency = metadata.frequency || fallbackFrequency;
+ metadata.type = type;
+ metadata.examType = metadata.examType || type;
+ if (recordData.suiteSessionId && !metadata.suiteSessionId) {
+ metadata.suiteSessionId = recordData.suiteSessionId;
}
- if (Array.isArray(value)) {
- return value.map((item) => clonePlainObject(item)).filter((item) => item !== undefined);
+ if (recordData.practiceMode && !metadata.practiceMode) {
+ metadata.practiceMode = recordData.practiceMode;
}
- const clone = {};
- Object.keys(value).forEach((key) => {
- clone[key] = clonePlainObject(value[key]);
- });
- return clone;
+ return metadata;
}
- function ensureNumber(value, fallback = 0) {
- const numeric = Number(value);
- return Number.isFinite(numeric) ? numeric : fallback;
+ function inferPracticeType(recordData = {}) {
+ const metadata = recordData.metadata || {};
+ const normalized = normalizePracticeType(
+ recordData.type
+ || metadata.type
+ || metadata.examType
+ || recordData.category
+ || recordData.mode
+ || recordData.section
+ || (recordData.examId && String(recordData.examId).toLowerCase().includes('listening') ? 'listening' : null)
+ );
+ return normalized || 'reading';
}
- function normalizeDateCandidate(value) {
- if (!value) {
- return null;
- }
- if (value instanceof Date && !Number.isNaN(value.getTime())) {
- return value.toISOString();
- }
- if (typeof value === 'number' && Number.isFinite(value)) {
- return new Date(value).toISOString();
+ function standardizeSuiteEntries(entries) {
+ if (!Array.isArray(entries)) {
+ return [];
}
- if (typeof value === 'string') {
- const trimmed = value.trim();
- if (!trimmed) {
+ return entries.map((entry, index) => {
+ if (!entry || typeof entry !== 'object') {
return null;
}
- if (/^\d+$/.test(trimmed)) {
- const numeric = Number(trimmed);
- if (Number.isFinite(numeric)) {
- return new Date(trimmed.length > 10 ? numeric : numeric * 1000).toISOString();
+ const answerComparisonSource = entry.answerComparison
+ || (entry.realData && entry.realData.answerComparison)
+ || (entry.rawData && entry.rawData.answerComparison)
+ || (entry.scoreInfo && entry.scoreInfo.details)
+ || null;
+ const entryCorrectMap = resolveRecordCorrectAnswerMap(entry, { comparison: answerComparisonSource });
+ const normalizedAnswers = buildAnswerArray(entry.answers || entry.answerList || [], entryCorrectMap);
+ const answerMap = normalizedAnswers.reduce((map, item) => {
+ if (item && item.questionId) {
+ map[item.questionId] = item.answer || '';
}
- }
- const parsed = new Date(trimmed);
- if (!Number.isNaN(parsed.getTime())) {
- return parsed.toISOString();
- }
- }
- return null;
+ return map;
+ }, {});
+ // 旧/导入的套题条目可能只在 entry.metadata.markedQuestions 保留标记题,
+ // 与顶层 standardizeRecord(见下方 resolveAnnotationState(recordData, [recordData.metadata]))
+ // 保持一致,将 entry.metadata 作为兜底来源传入,避免根级 markedQuestions: [] 被回放
+ // 逻辑视作权威而丢弃已保存的标记题。
+ const metadata = entry.metadata ? Object.assign({}, entry.metadata) : {};
+ const annotations = resolveAnnotationState(entry, [entry.metadata], { preferNonEmptyArrays: true });
+ metadata.markedQuestions = clonePlainObject(annotations.markedQuestions);
+ return {
+ examId: entry.examId || null,
+ title: entry.title || entry.examTitle || `套题第${index + 1}篇`,
+ category: entry.category || (entry.metadata && entry.metadata.category) || '套题',
+ duration: ensureNumber(entry.duration, 0),
+ scoreInfo: entry.scoreInfo ? clonePlainObject(entry.scoreInfo) : null,
+ answers: answerMap,
+ correctAnswerMap: entryCorrectMap,
+ answerComparison: clonePlainObject(answerComparisonSource) || null,
+ metadata,
+ ...annotations,
+ rawData: entry.rawData ? clonePlainObject(entry.rawData) : null
+ };
+ }).filter(Boolean);
}
- function firstDateCandidate() {
- for (let index = 0; index < arguments.length; index += 1) {
- const normalized = normalizeDateCandidate(arguments[index]);
- if (normalized) {
- return normalized;
+ function mergeAnswerSources() {
+ const merged = {};
+ Array.prototype.slice.call(arguments).forEach((source) => {
+ if (!source) {
+ return;
}
- }
- return null;
+ const normalized = normalizeAnswerMap(source);
+ Object.entries(normalized).forEach(([key, value]) => {
+ if (value == null) {
+ return;
+ }
+ const trimmed = String(value).trim();
+ if (!trimmed) {
+ return;
+ }
+ if (!Object.prototype.hasOwnProperty.call(merged, key)) {
+ merged[key] = trimmed;
+ }
+ });
+ });
+ return merged;
}
- function firstStringCandidate() {
- for (let index = 0; index < arguments.length; index += 1) {
- const value = arguments[index];
- if (value === undefined || value === null) {
- continue;
- }
- const trimmed = String(value).trim();
- if (trimmed) {
- return trimmed;
- }
- }
- return null;
+ function resolveCorrectAnswerMap() {
+ const sources = Array.prototype.slice.call(arguments).filter((source) => isPlainObject(source));
+ return mergeAnswerSources.apply(null, sources);
}
- function resolveDurationSeconds(recordData = {}, startTime = null, endTime = null) {
+ function resolveRecordCorrectAnswerMap(recordData = {}, options = {}) {
+ if (!isPlainObject(recordData)) {
+ return {};
+ }
const realData = isPlainObject(recordData.realData) ? recordData.realData : {};
- const scoreInfo = isPlainObject(recordData.scoreInfo)
- ? recordData.scoreInfo
- : (isPlainObject(realData.scoreInfo) ? realData.scoreInfo : {});
- const candidates = [
- recordData.duration,
- realData.duration,
- recordData.durationSeconds,
- recordData.duration_seconds,
- recordData.elapsedSeconds,
- recordData.elapsed_seconds,
- recordData.timeSpent,
- recordData.time_spent,
- realData.durationSeconds,
- realData.elapsedSeconds,
- realData.timeSpent,
- scoreInfo.duration,
- scoreInfo.timeSpent
- ];
+ const rawData = isPlainObject(recordData.rawData) ? recordData.rawData : {};
+ const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {};
+ const comparisonSource = options.comparison
+ || recordData.answerComparison
+ || realData.answerComparison
+ || rawData.answerComparison
+ || rawRealData.answerComparison
+ || null;
+ return resolveCorrectAnswerMap(
+ ...(Array.isArray(options.prioritySources) ? options.prioritySources : []),
+ recordData.correctAnswerMap,
+ realData.correctAnswerMap,
+ rawData.correctAnswerMap,
+ rawRealData.correctAnswerMap,
+ recordData.correctAnswers,
+ realData.correctAnswers,
+ rawData.correctAnswers,
+ rawRealData.correctAnswers,
+ deriveCorrectMapFromDetails(recordData.answerDetails),
+ deriveCorrectMapFromDetails(recordData.scoreInfo && recordData.scoreInfo.details),
+ deriveCorrectMapFromDetails(realData.scoreInfo && realData.scoreInfo.details),
+ deriveCorrectMapFromDetails(rawData.scoreInfo && rawData.scoreInfo.details),
+ deriveCorrectMapFromDetails(rawRealData.scoreInfo && rawRealData.scoreInfo.details),
+ ...(Array.isArray(options.detailSources)
+ ? options.detailSources.map((details) => deriveCorrectMapFromDetails(details))
+ : []),
+ convertComparisonToMap(comparisonSource, 'correctAnswer')
+ );
+ }
- for (let index = 0; index < candidates.length; index += 1) {
- const numeric = Number(candidates[index]);
- if (Number.isFinite(numeric) && numeric > 0) {
- return numeric;
- }
- }
+ function defaultGenerateRecordId() {
+ return `record_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ }
- const start = startTime ? new Date(startTime).getTime() : NaN;
- const end = endTime ? new Date(endTime).getTime() : NaN;
- if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
- return Math.round((end - start) / 1000);
- }
+ function standardizeRecord(recordData, options = {}) {
+ const now = new Date().toISOString();
+ const type = inferPracticeType(recordData);
+ const recordDate = resolveRecordDate(recordData, now);
+ const resolvedExamId = inferExamId(recordData);
+ const recordId = firstStringCandidate(
+ recordData.id,
+ recordData.recordId,
+ recordData.record_id,
+ recordData.practiceId,
+ recordData.practice_id,
+ recordData.uuid
+ );
+ const metadata = buildMetadata(
+ Object.assign({}, recordData, { examId: resolvedExamId }),
+ type
+ );
+ const comparisonSource = recordData.answerComparison
+ || (recordData.realData && recordData.realData.answerComparison)
+ || null;
+ let normalizedCorrectMap = resolveRecordCorrectAnswerMap(recordData, { comparison: comparisonSource });
- if (Array.isArray(realData.interactions) && realData.interactions.length) {
- const timestamps = realData.interactions
- .map(item => item && Number(item.timestamp))
- .filter(value => Number.isFinite(value));
- if (timestamps.length) {
- const span = Math.max(...timestamps) - Math.min(...timestamps);
- if (Number.isFinite(span) && span > 0) {
- return Math.floor(span / 1000);
- }
+ const normalizedAnswers = buildAnswerArray(recordData.answers || recordData.answerList || [], normalizedCorrectMap);
+ let answerMap = normalizedAnswers.reduce((map, item) => {
+ if (item && item.questionId) {
+ map[item.questionId] = item.answer || '';
}
+ return map;
+ }, {});
+ if ((!answerMap || Object.keys(answerMap).length === 0) && comparisonSource) {
+ answerMap = convertComparisonToMap(comparisonSource, 'userAnswer');
}
- for (let index = 0; index < candidates.length; index += 1) {
- const numeric = Number(candidates[index]);
- if (Number.isFinite(numeric) && numeric >= 0) {
- return numeric;
- }
+ const derivedTotalQuestions = deriveTotalQuestionCount(recordData, normalizedAnswers.length);
+ const derivedCorrectAnswers = deriveCorrectAnswerCount(recordData, normalizedAnswers);
+ const totalQuestions = ensureNumber(recordData.totalQuestions, derivedTotalQuestions);
+ const correctAnswers = ensureNumber(recordData.correctAnswers, derivedCorrectAnswers);
+ let accuracy = ensureNumber(
+ recordData.accuracy
+ ?? (recordData.realData && recordData.realData.accuracy)
+ ?? (recordData.scoreInfo && recordData.scoreInfo.accuracy)
+ ?? (recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.accuracy)
+ ?? recordData.percentage
+ ?? (recordData.scoreInfo && recordData.scoreInfo.percentage),
+ totalQuestions > 0 ? correctAnswers / totalQuestions : 0
+ );
+ if (accuracy > 1 && accuracy <= 100) {
+ accuracy = accuracy / 100;
+ }
+ if (!Number.isFinite(accuracy) || accuracy < 0) {
+ accuracy = 0;
+ } else if (accuracy > 1) {
+ accuracy = 1;
}
- return 0;
- }
-
- function normalizePracticeType(rawType) {
- if (!rawType) return null;
- const normalized = String(rawType).toLowerCase();
- if (normalized.includes('listen')) return 'listening';
- if (normalized.includes('read')) return 'reading';
- return null;
- }
+ const detailSource = recordData.answerDetails
+ || (recordData.scoreInfo && recordData.scoreInfo.details)
+ || (recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details)
+ || (comparisonSource ? convertComparisonToDetails(comparisonSource) : null)
+ || buildAnswerDetails(answerMap, normalizedCorrectMap);
- function resolveRecordDate(recordData = {}, now = new Date().toISOString()) {
- const metadata = isPlainObject(recordData.metadata) ? recordData.metadata : {};
- const candidates = [
- metadata.date,
+ const startTime = firstDateCandidate(
+ recordData.startTime,
+ recordData.start_time,
+ recordData.startedAt,
+ recordData.createdAt,
+ recordData.timestamp,
recordData.date,
+ recordDate
+ ) || recordDate;
+ const endTime = firstDateCandidate(
recordData.endTime,
recordData.end_time,
recordData.completedAt,
recordData.finishedAt,
recordData.finishTime,
- recordData.startTime,
- recordData.start_time,
- recordData.startedAt,
- recordData.createdAt,
- recordData.timestamp,
- now
- ];
-
- for (let i = 0; i < candidates.length; i += 1) {
- const normalized = normalizeDateCandidate(candidates[i]);
- if (normalized) {
- return normalized;
- }
- }
-
- return now;
- }
-
- function inferExamId(recordData = {}) {
- if (!recordData || typeof recordData !== 'object') {
- return null;
- }
-
- const metadata = isPlainObject(recordData.metadata) ? recordData.metadata : {};
- const direct = firstStringCandidate(
- recordData.examId,
- recordData.exam_id,
- recordData.examID,
- metadata.examId,
- metadata.exam_id
- );
- if (direct) {
- return direct;
- }
- if (Array.isArray(recordData.suiteEntries)) {
- const suiteExam = recordData.suiteEntries.find((entry) => entry && entry.examId);
- if (suiteExam) {
- return suiteExam.examId;
- }
- }
- if (typeof recordData.id === 'string') {
- const match = recordData.id.match(/^record_([^_]+)_/);
- if (match && match[1]) {
- return match[1];
- }
- }
-
- return null;
- }
-
- function normalizeAnswerValue(value) {
- const sanitizer = global.AnswerSanitizer;
- if (sanitizer && typeof sanitizer.normalizeValue === 'function') {
- return sanitizer.normalizeValue(value);
- }
-
- if (value === undefined || value === null) {
- return '';
- }
- if (typeof value === 'string') {
- const trimmed = value.trim();
- return /^\[object\s/i.test(trimmed) ? '' : trimmed;
- }
- if (typeof value === 'number' || typeof value === 'boolean') {
- return String(value).trim();
- }
- if (Array.isArray(value)) {
- return value.map((item) => normalizeAnswerValue(item)).filter(Boolean).join(',');
- }
- if (typeof value === 'object') {
- const preferKeys = ['value', 'label', 'text', 'answer', 'content', 'userAnswer', 'correctAnswer'];
- for (let i = 0; i < preferKeys.length; i += 1) {
- const entry = value[preferKeys[i]];
- if (typeof entry === 'string') {
- const trimmed = entry.trim();
- if (trimmed && !/^\[object\s/i.test(trimmed)) {
- return trimmed;
- }
- }
- }
- if (typeof value.innerText === 'string') {
- const text = value.innerText.trim();
- if (text && !/^\[object\s/i.test(text)) {
- return text;
- }
- }
- if (typeof value.textContent === 'string') {
- const text = value.textContent.trim();
- if (text && !/^\[object\s/i.test(text)) {
- return text;
- }
- }
- return '';
- }
-
- return String(value).trim();
- }
-
- function isNoiseKey(key) {
- if (!key) return true;
-
- const keyStr = String(key).toLowerCase();
- const noiseKeys = [
- 'playback-speed', 'playbackspeed', 'volume-slider', 'volumeslider',
- 'audio-volume', 'audiocurrenttime', 'audio-duration', 'audioduration',
- 'settings', 'lastfocuselement', 'sessionid', 'examid',
- 'nextexamid', 'previousexamid', 'folder', 'source', 'result',
- 'metadata', 'practicesettings', 'config', 'state'
- ];
- if (noiseKeys.includes(keyStr)) {
- return true;
- }
-
- const noisePatterns = [
- /playback/i, /volume/i, /slider/i, /speed/i,
- /audio/i, /duration/i, /config/i, /setting/i
- ];
- for (let i = 0; i < noisePatterns.length; i += 1) {
- if (noisePatterns[i].test(keyStr)) {
- return true;
- }
- }
-
- const questionMatch = keyStr.match(/q?(\d+)/);
- if (questionMatch) {
- const number = parseInt(questionMatch[1], 10);
- if (number < 1 || number > 200) {
- return true;
- }
- }
-
- return false;
- }
-
- function normalizeQuestionKey(rawKey, index) {
- if (rawKey == null || rawKey === '') {
- return `q${index + 1}`;
- }
- const key = String(rawKey).trim();
- return key.startsWith('q') ? key : `q${key}`;
- }
-
- function normalizeReplayQuestionKey(rawKey, index) {
- if (rawKey == null || rawKey === '') {
- return Number.isInteger(index) ? `q${index + 1}` : '';
- }
- const raw = String(rawKey).trim();
- if (!raw) {
- return Number.isInteger(index) ? `q${index + 1}` : '';
- }
- const splitIndex = raw.lastIndexOf('::');
- const value = splitIndex >= 0 ? raw.slice(splitIndex + 2).trim() : raw;
- if (!value) {
- return Number.isInteger(index) ? `q${index + 1}` : '';
- }
- const explicitQuestion = value.match(/^q\s*[-_ ]?(\d+)$/i) || value.match(/\bq\s*[-_ ]?(\d+)\b/i);
- if (explicitQuestion) {
- return `q${explicitQuestion[1]}`;
- }
- if (/^\d+$/.test(value)) {
- return `q${value}`;
- }
- const trailingNumber = value.match(/(\d+)(?!.*\d)/);
- if (trailingNumber) {
- return `q${trailingNumber[1]}`;
- }
- return value.toLowerCase();
- }
-
- function normalizeReplayMap(rawMap = {}) {
- const normalized = {};
- if (Array.isArray(rawMap)) {
- rawMap.forEach((entry, index) => {
- if (entry == null) {
- return;
- }
- if (typeof entry !== 'object') {
- normalized[`q${index + 1}`] = entry;
- return;
- }
- const normalizedKey = normalizeReplayQuestionKey(
- entry.questionId ?? entry.question ?? entry.id,
- index
- );
- if (!normalizedKey) {
- return;
- }
- const hasAnswerValue = Object.prototype.hasOwnProperty.call(entry, 'answer')
- || Object.prototype.hasOwnProperty.call(entry, 'value');
- const isComparisonEntry = !hasAnswerValue && (
- Object.prototype.hasOwnProperty.call(entry, 'userAnswer')
- || Object.prototype.hasOwnProperty.call(entry, 'correctAnswer')
- || Object.prototype.hasOwnProperty.call(entry, 'isCorrect')
- );
- normalized[normalizedKey] = isComparisonEntry
- ? clonePlainObject(entry)
- : (Object.prototype.hasOwnProperty.call(entry, 'answer')
- ? entry.answer
- : (Object.prototype.hasOwnProperty.call(entry, 'value') ? entry.value : clonePlainObject(entry)));
- });
- return normalized;
- }
- if (!rawMap || typeof rawMap !== 'object') {
- return normalized;
- }
- Object.entries(rawMap).forEach(([key, value], index) => {
- const normalizedKey = normalizeReplayQuestionKey(key, index);
- if (normalizedKey) {
- normalized[normalizedKey] = value;
- }
- });
- return normalized;
- }
-
- function normalizeAnswerMap(rawAnswers = {}) {
- const map = {};
-
- if (Array.isArray(rawAnswers)) {
- rawAnswers.forEach((entry, index) => {
- if (!entry) return;
- const key = normalizeQuestionKey(entry.questionId, index);
- const rawValue = entry.answer ?? entry.userAnswer ?? entry.value ?? entry;
- map[key] = normalizeAnswerValue(rawValue);
- });
- return map;
- }
-
- if (!rawAnswers || typeof rawAnswers !== 'object') {
- return map;
- }
-
- Object.entries(rawAnswers).forEach(([rawKey, rawValue], index) => {
- if (isNoiseKey(rawKey)) {
- return;
- }
- const key = normalizeQuestionKey(rawKey, index);
- const resolvedValue = rawValue && typeof rawValue === 'object' && 'answer' in rawValue
- ? rawValue.answer
- : rawValue;
- map[key] = normalizeAnswerValue(resolvedValue);
- });
-
- return map;
- }
-
- function normalizeAnswerComparison(comparison) {
- if (!comparison || typeof comparison !== 'object') {
- return {};
- }
-
- const sanitizer = global.AnswerSanitizer;
- if (sanitizer && typeof sanitizer.sanitizeComparisonMap === 'function') {
- return sanitizer.sanitizeComparisonMap(comparison);
- }
-
- const normalized = {};
- Object.entries(comparison).forEach(([questionId, entry]) => {
- if (isNoiseKey(questionId) || !entry || typeof entry !== 'object') {
- return;
- }
- const userAnswer = normalizeAnswerValue(entry.userAnswer ?? entry.user ?? entry.answer);
- const correctAnswer = normalizeAnswerValue(entry.correctAnswer ?? entry.correct);
- if (!userAnswer && !correctAnswer) {
- return;
- }
- normalized[questionId] = {
- questionId: entry.questionId || questionId,
- userAnswer,
- correctAnswer,
- isCorrect: typeof entry.isCorrect === 'boolean' ? entry.isCorrect : null
- };
- });
-
- return normalized;
- }
-
- function convertComparisonToMap(comparison, key = 'correctAnswer') {
- if (!comparison || typeof comparison !== 'object') {
- return {};
- }
- const map = {};
- Object.entries(comparison).forEach(([questionId, entry]) => {
- if (!entry || typeof entry !== 'object') return;
- const value = entry[key] ?? (key === 'correctAnswer' ? entry.correct : entry.userAnswer ?? entry.user);
- if (value != null && String(value).trim() !== '') {
- map[questionId] = value;
- }
- });
- return map;
- }
-
- function convertComparisonToDetails(comparison) {
- if (!comparison || typeof comparison !== 'object') {
- return null;
- }
- const details = {};
- Object.entries(comparison).forEach(([questionId, entry]) => {
- if (!entry || typeof entry !== 'object') return;
- details[questionId] = {
- userAnswer: normalizeAnswerValue(entry.userAnswer ?? entry.user ?? entry.answer),
- correctAnswer: normalizeAnswerValue(entry.correctAnswer ?? entry.correct),
- isCorrect: typeof entry.isCorrect === 'boolean' ? entry.isCorrect : null
- };
- });
- return details;
- }
-
- function buildAnswerDetails(answerMap = {}, correctMap = {}) {
- const details = {};
- const keys = new Set([
- ...Object.keys(answerMap || {}),
- ...Object.keys(correctMap || {})
- ]);
-
- keys.forEach((questionId) => {
- const userAnswer = normalizeAnswerValue(answerMap[questionId]);
- const correctAnswer = normalizeAnswerValue(correctMap[questionId]);
- let isCorrect = null;
- if (correctAnswer) {
- const matchCore = global.AnswerMatchCore;
- isCorrect = matchCore && typeof matchCore.compareAnswers === 'function'
- ? matchCore.compareAnswers(userAnswer, correctAnswer) === true
- : userAnswer.toLowerCase() === correctAnswer.toLowerCase();
- }
- details[questionId] = {
- userAnswer: userAnswer || '-',
- correctAnswer: correctAnswer || '-',
- isCorrect
- };
- });
-
- return details;
- }
-
- function compareAnswerValues(userAnswer, correctAnswer) {
- if (userAnswer == null || correctAnswer == null) {
- return false;
- }
- const matchCore = global.AnswerMatchCore;
- if (matchCore && typeof matchCore.compareAnswers === 'function') {
- return matchCore.compareAnswers(userAnswer, correctAnswer) === true;
- }
- return String(userAnswer).trim().toLowerCase() === String(correctAnswer).trim().toLowerCase();
- }
-
- function mergeReplayMapFirstWins() {
- const merged = {};
- Array.prototype.slice.call(arguments).forEach((source) => {
- if (!source || typeof source !== 'object' || Array.isArray(source)) {
- return;
- }
- const normalized = normalizeReplayMap(source);
- Object.entries(normalized).forEach(([key, value]) => {
- if (!Object.prototype.hasOwnProperty.call(merged, key)) {
- merged[key] = value;
- }
- });
- });
- return merged;
- }
-
- function buildReplayCorrectAnswerMap(entry = {}) {
- const realData = isPlainObject(entry.realData) ? entry.realData : {};
- const rawData = isPlainObject(entry.rawData) ? entry.rawData : {};
- const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {};
- return mergeReplayMapFirstWins(
- entry.correctAnswerMap,
- realData.correctAnswerMap,
- rawData.correctAnswerMap,
- rawRealData.correctAnswerMap
- );
- }
-
- function buildReplayResultSnapshot(entry = {}) {
- const realData = isPlainObject(entry.realData) ? entry.realData : {};
- const rawData = isPlainObject(entry.rawData) ? entry.rawData : {};
- const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {};
- const answers = mergeReplayMapFirstWins(
- entry.answers,
- realData.answers,
- rawData.answers,
- rawRealData.answers
- );
- const correctAnswerMap = buildReplayCorrectAnswerMap(entry);
- const rawComparison = mergeReplayMapFirstWins(
- entry.answerComparison,
- realData.answerComparison,
- rawData.answerComparison,
- rawRealData.answerComparison
- );
- const questionIds = new Set([
- ...Object.keys(answers),
- ...Object.keys(correctAnswerMap),
- ...Object.keys(rawComparison),
- ...(Array.isArray(entry.allQuestionIds)
- ? entry.allQuestionIds.map((item, index) => normalizeReplayQuestionKey(item, index)).filter(Boolean)
- : [])
- ]);
-
- let correctCount = 0;
- const answerComparison = {};
- questionIds.forEach((questionId) => {
- const rawEntry = rawComparison[questionId];
- const comparisonEntry = isPlainObject(rawEntry) ? rawEntry : {};
- const userAnswer = Object.prototype.hasOwnProperty.call(comparisonEntry, 'userAnswer')
- ? comparisonEntry.userAnswer
- : (Object.prototype.hasOwnProperty.call(answers, questionId) ? answers[questionId] : '');
- const hasCanonicalCorrectAnswer = Object.prototype.hasOwnProperty.call(correctAnswerMap, questionId);
- const correctAnswer = hasCanonicalCorrectAnswer ? correctAnswerMap[questionId] : '';
- const isCorrect = hasCanonicalCorrectAnswer
- ? compareAnswerValues(userAnswer, correctAnswer)
- : null;
- if (isCorrect) {
- correctCount += 1;
- }
- answerComparison[questionId] = {
- questionId,
- userAnswer,
- correctAnswer,
- isCorrect
- };
- });
-
- const totalQuestions = questionIds.size;
- const sourceScoreInfo = isPlainObject(entry.scoreInfo)
- ? entry.scoreInfo
- : (isPlainObject(realData.scoreInfo)
- ? realData.scoreInfo
- : (isPlainObject(rawData.scoreInfo) ? rawData.scoreInfo : {}));
- const scoreInfo = clonePlainObject(sourceScoreInfo) || {};
- const hasCompleteCanonicalCorrectAnswers = totalQuestions > 0
- && Array.from(questionIds).every(questionId => Object.prototype.hasOwnProperty.call(correctAnswerMap, questionId));
- scoreInfo.correct = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.correct))
- ? correctCount
- : Number(scoreInfo.correct);
- scoreInfo.total = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.total))
- ? totalQuestions
- : Number(scoreInfo.total);
- scoreInfo.totalQuestions = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.totalQuestions))
- ? scoreInfo.total
- : Number(scoreInfo.totalQuestions);
- const existingAccuracy = Number(scoreInfo.accuracy);
- scoreInfo.accuracy = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(existingAccuracy)
- ? (scoreInfo.totalQuestions > 0 ? scoreInfo.correct / scoreInfo.totalQuestions : 0)
- : existingAccuracy;
- scoreInfo.percentage = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.percentage))
- ? Math.round(scoreInfo.accuracy * 100)
- : Number(scoreInfo.percentage);
- scoreInfo.answerKeyComplete = hasCompleteCanonicalCorrectAnswers;
-
- return {
- answers,
- correctAnswers: correctAnswerMap,
- correctAnswerMap,
- answerComparison,
- scoreInfo
- };
- }
-
- function deriveCorrectMapFromDetails(details) {
- if (!details || typeof details !== 'object') {
- return {};
- }
- const map = {};
- Object.entries(details).forEach(([questionId, info]) => {
- if (!info) return;
- const correctAnswer = info.correctAnswer || info.answer || info.value;
- if (correctAnswer != null) {
- map[questionId] = normalizeAnswerValue(correctAnswer);
- }
- });
- return map;
- }
-
- function buildAnswerArray(answers, correctMap = {}) {
- if (Array.isArray(answers)) {
- return answers.map((answer, index) => {
- const questionId = answer.questionId || `q${index + 1}`;
- const userAnswer = normalizeAnswerValue(answer.answer);
- const normalizedCorrect = normalizeAnswerValue(answer.correctAnswer ?? correctMap[questionId]);
- return {
- questionId,
- answer: userAnswer,
- correctAnswer: normalizedCorrect,
- correct: normalizedCorrect ? compareAnswerValues(userAnswer, normalizedCorrect) : Boolean(answer.correct),
- timeSpent: ensureNumber(answer.timeSpent, 0),
- questionType: answer.questionType || 'unknown',
- timestamp: answer.timestamp || new Date().toISOString()
- };
- });
- }
-
- const answerMap = normalizeAnswerMap(answers);
- const keys = new Set([
- ...Object.keys(answerMap),
- ...Object.keys(correctMap || {})
- ]);
-
- const list = [];
- keys.forEach((questionId, index) => {
- const userAnswer = normalizeAnswerValue(answerMap[questionId]);
- const normalizedCorrect = normalizeAnswerValue(correctMap[questionId]);
- const isCorrect = normalizedCorrect ? compareAnswerValues(userAnswer, normalizedCorrect) : false;
- list.push({
- questionId: questionId || `q${index + 1}`,
- answer: userAnswer,
- correctAnswer: normalizedCorrect,
- correct: isCorrect,
- timeSpent: 0,
- questionType: 'unknown',
- timestamp: new Date().toISOString()
- });
- });
- return list;
- }
-
- function deriveTotalQuestionCount(recordData = {}, fallbackLength = 0) {
- const candidates = [
- recordData.totalQuestions,
- recordData.questionCount,
- recordData.question_count,
- typeof recordData.questions === 'number' ? recordData.questions : null,
- recordData.scoreInfo && recordData.scoreInfo.total,
- recordData.scoreInfo && recordData.scoreInfo.totalQuestions,
- recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.totalQuestions,
- recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.total,
- recordData.realData && recordData.realData.totalQuestions,
- recordData.realData && recordData.realData.questionCount
- ];
- for (let i = 0; i < candidates.length; i += 1) {
- const numeric = Number(candidates[i]);
- if (Number.isFinite(numeric) && numeric >= 0) {
- return numeric;
- }
- }
-
- if (Array.isArray(recordData.answers)) {
- return recordData.answers.length;
- }
- if (Array.isArray(recordData.answerList)) {
- return recordData.answerList.length;
- }
- const detailSources = [
- recordData.answerDetails,
- recordData.scoreInfo && recordData.scoreInfo.details,
- recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details
- ];
- for (let i = 0; i < detailSources.length; i += 1) {
- const details = detailSources[i];
- if (details && typeof details === 'object') {
- return Object.keys(details).length;
- }
- }
-
- return fallbackLength || 0;
- }
-
- function deriveCorrectAnswerCount(recordData = {}, answers = []) {
- const numericCandidates = [
- recordData.correctAnswers,
- recordData.correctAnswersCount,
- recordData.correctCount,
- recordData.correct,
- recordData.score,
- recordData.scoreInfo && recordData.scoreInfo.correct,
- recordData.scoreInfo && recordData.scoreInfo.score,
- recordData.realData && recordData.realData.correctAnswersCount,
- recordData.realData && recordData.realData.correctCount,
- recordData.realData && recordData.realData.correct,
- recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.correct,
- recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.score
- ];
- for (let i = 0; i < numericCandidates.length; i += 1) {
- const numeric = Number(numericCandidates[i]);
- if (Number.isFinite(numeric) && numeric >= 0) {
- return numeric;
- }
- }
-
- if (Array.isArray(answers) && answers.length > 0) {
- return answers.reduce((sum, answer) => {
- if (!answer || typeof answer !== 'object') {
- return sum;
- }
- return (answer.correct === true || answer.isCorrect === true) ? sum + 1 : sum;
- }, 0);
- }
-
- const detailSources = [
- recordData.answerDetails,
- recordData.scoreInfo && recordData.scoreInfo.details,
- recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details
- ];
- for (let i = 0; i < detailSources.length; i += 1) {
- const details = detailSources[i];
- if (!details || typeof details !== 'object') {
- continue;
- }
- let hasFlag = false;
- let correctCount = 0;
- Object.values(details).forEach((detail) => {
- if (!detail || typeof detail !== 'object') {
- return;
- }
- if (detail.isCorrect === true || detail.correct === true) {
- correctCount += 1;
- }
- hasFlag = hasFlag || typeof detail.isCorrect === 'boolean' || typeof detail.correct === 'boolean';
- });
- if (hasFlag) {
- return correctCount;
- }
- }
-
- return 0;
- }
-
- function buildMetadata(recordData = {}, type) {
- const metadata = Object.assign({}, recordData.metadata || {});
- const examId = recordData.examId;
- const fallbackTitle = recordData.title || recordData.examTitle || recordData.examName || recordData.name || examId || 'Unknown Exam';
- const fallbackCategory = recordData.category || recordData.examCategory || recordData.section || recordData.mode || metadata.category || 'Unknown';
- const fallbackFrequency = recordData.frequency || metadata.frequency || 'unknown';
-
- metadata.examTitle = metadata.examTitle || metadata.title || fallbackTitle;
- metadata.category = metadata.category || fallbackCategory;
- metadata.frequency = metadata.frequency || fallbackFrequency;
- metadata.type = type;
- metadata.examType = metadata.examType || type;
- if (recordData.suiteSessionId && !metadata.suiteSessionId) {
- metadata.suiteSessionId = recordData.suiteSessionId;
- }
- if (recordData.practiceMode && !metadata.practiceMode) {
- metadata.practiceMode = recordData.practiceMode;
- }
- return metadata;
- }
-
- function inferPracticeType(recordData = {}) {
- const metadata = recordData.metadata || {};
- const normalized = normalizePracticeType(
- recordData.type
- || metadata.type
- || metadata.examType
- || recordData.category
- || recordData.mode
- || recordData.section
- || (recordData.examId && String(recordData.examId).toLowerCase().includes('listening') ? 'listening' : null)
- );
- return normalized || 'reading';
- }
-
- function standardizeSuiteEntries(entries) {
- if (!Array.isArray(entries)) {
- return [];
- }
- return entries.map((entry, index) => {
- if (!entry || typeof entry !== 'object') {
- return null;
- }
- const answerComparisonSource = entry.answerComparison
- || (entry.realData && entry.realData.answerComparison)
- || (entry.rawData && entry.rawData.answerComparison)
- || (entry.scoreInfo && entry.scoreInfo.details)
- || null;
- const entryCorrectMap = resolveRecordCorrectAnswerMap(entry, { comparison: answerComparisonSource });
- const normalizedAnswers = buildAnswerArray(entry.answers || entry.answerList || [], entryCorrectMap);
- const answerMap = normalizedAnswers.reduce((map, item) => {
- if (item && item.questionId) {
- map[item.questionId] = item.answer || '';
- }
- return map;
- }, {});
- const highlights = Array.isArray(entry.highlights)
- ? entry.highlights.slice()
- : (Array.isArray(entry.rawData && entry.rawData.highlights) ? entry.rawData.highlights.slice() : []);
- const scrollY = Number.isFinite(Number(entry.scrollY))
- ? Number(entry.scrollY)
- : (Number.isFinite(Number(entry.rawData && entry.rawData.scrollY)) ? Number(entry.rawData.scrollY) : 0);
- return {
- examId: entry.examId || null,
- title: entry.title || entry.examTitle || `套题第${index + 1}篇`,
- category: entry.category || (entry.metadata && entry.metadata.category) || '套题',
- duration: ensureNumber(entry.duration, 0),
- scoreInfo: entry.scoreInfo ? clonePlainObject(entry.scoreInfo) : null,
- answers: answerMap,
- correctAnswerMap: entryCorrectMap,
- answerComparison: clonePlainObject(answerComparisonSource) || null,
- metadata: entry.metadata ? Object.assign({}, entry.metadata) : {},
- highlights,
- scrollY,
- rawData: entry.rawData ? clonePlainObject(entry.rawData) : null
- };
- }).filter(Boolean);
- }
-
- function mergeAnswerSources() {
- const merged = {};
- Array.prototype.slice.call(arguments).forEach((source) => {
- if (!source) {
- return;
- }
- const normalized = normalizeAnswerMap(source);
- Object.entries(normalized).forEach(([key, value]) => {
- if (value == null) {
- return;
- }
- const trimmed = String(value).trim();
- if (!trimmed) {
- return;
- }
- if (!Object.prototype.hasOwnProperty.call(merged, key)) {
- merged[key] = trimmed;
- }
- });
- });
- return merged;
- }
-
- function resolveCorrectAnswerMap() {
- const sources = Array.prototype.slice.call(arguments).filter((source) => isPlainObject(source));
- return mergeAnswerSources.apply(null, sources);
- }
-
- function resolveRecordCorrectAnswerMap(recordData = {}, options = {}) {
- if (!isPlainObject(recordData)) {
- return {};
- }
- const realData = isPlainObject(recordData.realData) ? recordData.realData : {};
- const rawData = isPlainObject(recordData.rawData) ? recordData.rawData : {};
- const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {};
- const comparisonSource = options.comparison
- || recordData.answerComparison
- || realData.answerComparison
- || rawData.answerComparison
- || rawRealData.answerComparison
- || null;
- return resolveCorrectAnswerMap(
- ...(Array.isArray(options.prioritySources) ? options.prioritySources : []),
- recordData.correctAnswerMap,
- realData.correctAnswerMap,
- rawData.correctAnswerMap,
- rawRealData.correctAnswerMap,
- recordData.correctAnswers,
- realData.correctAnswers,
- rawData.correctAnswers,
- rawRealData.correctAnswers,
- deriveCorrectMapFromDetails(recordData.answerDetails),
- deriveCorrectMapFromDetails(recordData.scoreInfo && recordData.scoreInfo.details),
- deriveCorrectMapFromDetails(realData.scoreInfo && realData.scoreInfo.details),
- deriveCorrectMapFromDetails(rawData.scoreInfo && rawData.scoreInfo.details),
- deriveCorrectMapFromDetails(rawRealData.scoreInfo && rawRealData.scoreInfo.details),
- ...(Array.isArray(options.detailSources)
- ? options.detailSources.map((details) => deriveCorrectMapFromDetails(details))
- : []),
- convertComparisonToMap(comparisonSource, 'correctAnswer')
- );
- }
-
- function defaultGenerateRecordId() {
- return `record_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
- }
-
- function standardizeRecord(recordData, options = {}) {
- const now = new Date().toISOString();
- const type = inferPracticeType(recordData);
- const recordDate = resolveRecordDate(recordData, now);
- const resolvedExamId = inferExamId(recordData);
- const recordId = firstStringCandidate(
- recordData.id,
- recordData.recordId,
- recordData.record_id,
- recordData.practiceId,
- recordData.practice_id,
- recordData.uuid
- );
- const metadata = buildMetadata(
- Object.assign({}, recordData, { examId: resolvedExamId }),
- type
- );
- const comparisonSource = recordData.answerComparison
- || (recordData.realData && recordData.realData.answerComparison)
- || null;
- let normalizedCorrectMap = resolveRecordCorrectAnswerMap(recordData, { comparison: comparisonSource });
-
- const normalizedAnswers = buildAnswerArray(recordData.answers || recordData.answerList || [], normalizedCorrectMap);
- let answerMap = normalizedAnswers.reduce((map, item) => {
- if (item && item.questionId) {
- map[item.questionId] = item.answer || '';
- }
- return map;
- }, {});
- if ((!answerMap || Object.keys(answerMap).length === 0) && comparisonSource) {
- answerMap = convertComparisonToMap(comparisonSource, 'userAnswer');
- }
-
- const derivedTotalQuestions = deriveTotalQuestionCount(recordData, normalizedAnswers.length);
- const derivedCorrectAnswers = deriveCorrectAnswerCount(recordData, normalizedAnswers);
- const totalQuestions = ensureNumber(recordData.totalQuestions, derivedTotalQuestions);
- const correctAnswers = ensureNumber(recordData.correctAnswers, derivedCorrectAnswers);
- let accuracy = ensureNumber(
- recordData.accuracy
- ?? (recordData.realData && recordData.realData.accuracy)
- ?? (recordData.scoreInfo && recordData.scoreInfo.accuracy)
- ?? (recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.accuracy)
- ?? recordData.percentage
- ?? (recordData.scoreInfo && recordData.scoreInfo.percentage),
- totalQuestions > 0 ? correctAnswers / totalQuestions : 0
- );
- if (accuracy > 1 && accuracy <= 100) {
- accuracy = accuracy / 100;
- }
- if (!Number.isFinite(accuracy) || accuracy < 0) {
- accuracy = 0;
- } else if (accuracy > 1) {
- accuracy = 1;
- }
-
- const detailSource = recordData.answerDetails
- || (recordData.scoreInfo && recordData.scoreInfo.details)
- || (recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details)
- || (comparisonSource ? convertComparisonToDetails(comparisonSource) : null)
- || buildAnswerDetails(answerMap, normalizedCorrectMap);
-
- const startTime = firstDateCandidate(
- recordData.startTime,
- recordData.start_time,
- recordData.startedAt,
- recordData.createdAt,
- recordData.timestamp,
- recordData.date,
- recordDate
- ) || recordDate;
- const endTime = firstDateCandidate(
- recordData.endTime,
- recordData.end_time,
- recordData.completedAt,
- recordData.finishedAt,
- recordData.finishTime,
- recordDate
- ) || recordDate;
- const resolvedTitle = recordData.title
- || metadata.examTitle
- || metadata.title
- || recordData.examTitle
- || recordData.examName
- || recordData.name
- || recordData.examId
- || '未命名练习';
- const normalizedSuiteEntries = standardizeSuiteEntries(recordData.suiteEntries || []);
- const normalizedComparison = comparisonSource && typeof comparisonSource === 'object'
- ? clonePlainObject(comparisonSource)
- : null;
- const realDataCorrectAnswers = clonePlainObject(normalizedCorrectMap || {});
- const generateRecordId = typeof options.generateRecordId === 'function'
- ? options.generateRecordId
- : defaultGenerateRecordId;
-
- return {
- id: recordId || generateRecordId(),
- examId: resolvedExamId,
- sessionId: recordData.sessionId || recordData.sessionID || null,
- title: resolvedTitle,
- type,
- startTime,
- endTime,
- duration: resolveDurationSeconds(recordData, startTime, endTime),
- date: recordDate,
- status: recordData.status || 'completed',
- score: ensureNumber(recordData.score ?? recordData.finalScore ?? (recordData.realData && recordData.realData.score), correctAnswers),
- totalQuestions,
- correctAnswers,
- accuracy,
- answers: normalizedAnswers,
- answerDetails: detailSource || null,
- correctAnswerMap: normalizedCorrectMap || {},
- questionTypePerformance: recordData.questionTypePerformance || {},
- metadata,
- frequency: recordData.frequency || metadata.frequency || null,
- suiteMode: Boolean(recordData.suiteMode || ((recordData.frequency || metadata.frequency || '').toLowerCase() === 'suite')),
- suiteSessionId: recordData.suiteSessionId || (metadata && metadata.suiteSessionId) || null,
- suiteEntries: normalizedSuiteEntries,
- scoreInfo: recordData.scoreInfo
- ? Object.assign({}, recordData.scoreInfo, {
- details: recordData.scoreInfo.details || detailSource || null
- })
- : (detailSource ? { details: detailSource } : null),
- realData: recordData.realData
- ? Object.assign({}, recordData.realData, {
- answers: (recordData.realData && recordData.realData.answers) || answerMap,
- correctAnswers: realDataCorrectAnswers,
- correctAnswerMap: clonePlainObject(normalizedCorrectMap || {}),
- scoreInfo: Object.assign({}, (recordData.realData && recordData.realData.scoreInfo) || {}, {
- details: (recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details) || detailSource || null
- }),
- answerComparison: (recordData.realData && recordData.realData.answerComparison)
- ? clonePlainObject(recordData.realData.answerComparison)
- : (normalizedComparison || null)
- })
- : (normalizedComparison ? { answerComparison: normalizedComparison } : null),
- answerComparison: normalizedComparison,
- version: options.currentVersion || recordData.version || '0.6.2-fix',
- createdAt: firstDateCandidate(recordData.createdAt, recordData.startTime, recordData.start_time, recordDate) || now,
- updatedAt: firstDateCandidate(recordData.updatedAt, recordData.endTime, recordData.end_time, now) || now
- };
- }
-
- function extractEnvelopeData(envelope) {
- const candidates = [envelope.data, envelope.payload, envelope.detail];
- for (let i = 0; i < candidates.length; i += 1) {
- const candidate = candidates[i];
- if (isPlainObject(candidate)) return candidate;
- if (typeof candidate === 'string') {
- const parsed = safeParseJson(candidate);
- if (isPlainObject(parsed)) return parsed;
- }
- }
- if (Array.isArray(envelope.args)) {
- for (let i = 0; i < envelope.args.length; i += 1) {
- const candidate = envelope.args[i];
- if (isPlainObject(candidate)) return candidate;
- }
- }
- const fallback = {};
- const baseKeys = new Set(['type', 'messageType', 'action', 'event', 'data', 'payload', 'detail', 'args', 'source', 'message', 'messageData']);
- let hasFallback = false;
- Object.keys(envelope || {}).forEach((key) => {
- if (!baseKeys.has(key)) {
- fallback[key] = envelope[key];
- hasFallback = true;
- }
- });
- return hasFallback ? fallback : {};
- }
-
- function normalizeMessageType(value) {
- if (typeof value !== 'string') {
- return '';
- }
- const normalized = value.trim();
- if (!normalized) {
- return '';
- }
- return MESSAGE_TYPE_ALIASES[normalized] || normalized.toUpperCase();
- }
-
- function normalizeMessage(rawEnvelope, depth = 0) {
- if (depth > 2) {
- return null;
- }
-
- let envelope = rawEnvelope;
- if (typeof envelope === 'string') {
- envelope = safeParseJson(envelope);
- }
- if (!isPlainObject(envelope)) {
- return null;
- }
-
- const rawType = envelope.type || envelope.messageType || envelope.action || envelope.event || '';
- const type = normalizeMessageType(rawType);
-
- if (!type) {
- const nested = envelope.message || envelope.messageData;
- if (nested) {
- return normalizeMessage(nested, depth + 1);
- }
- return null;
- }
-
- const data = extractEnvelopeData(envelope);
- const sourceTag = typeof envelope.source === 'string'
- ? envelope.source
- : (typeof data.source === 'string' ? data.source : '');
-
- return { type, data: isPlainObject(data) ? data : {}, sourceTag, rawType: rawType || type };
- }
-
- function isPracticeCompleteType(type) {
- if (!type) {
- return false;
- }
- return PRACTICE_COMPLETE_TYPES.has(type) || normalizeMessageType(type) === 'PRACTICE_COMPLETE';
- }
-
- function buildEnvelope(type, data) {
- return {
- type,
- data: isPlainObject(data) ? data : {}
- };
- }
-
- function deriveCategory(recordPayload = {}, examEntry = null, metadata = {}) {
- if (metadata.category) {
- return metadata.category;
- }
- if (recordPayload.category) {
- return recordPayload.category;
- }
- if (examEntry && examEntry.category) {
- return examEntry.category;
- }
- if (recordPayload.pageType) {
- return recordPayload.pageType;
- }
- if (recordPayload.url) {
- const match = String(recordPayload.url).match(/\b(P[1-4])\b/i);
- if (match) return match[1].toUpperCase();
- }
- if (recordPayload.title) {
- const match = String(recordPayload.title).match(/\b(P[1-4])\b/i);
- if (match) return match[1].toUpperCase();
- }
- return 'Unknown';
- }
-
- function deriveFrequency(recordPayload = {}, examEntry = null, metadata = {}) {
- return recordPayload.frequency
- || metadata.frequency
- || (examEntry && examEntry.frequency)
- || 'unknown';
- }
-
- function fromCompletion(payload, sessionContext = {}, examEntry = null, options = {}) {
- const normalizedMessage = normalizeMessage(payload);
- const rawPayload = normalizedMessage && isPracticeCompleteType(normalizedMessage.type)
- ? normalizedMessage.data
- : (isPlainObject(payload) ? payload : {});
-
- if (!rawPayload || typeof rawPayload !== 'object') {
- return null;
- }
-
- const scoreInfo = Object.assign({}, rawPayload.scoreInfo || {});
- const metadata = Object.assign({}, sessionContext.metadata || {}, rawPayload.metadata || {});
- const resolvedExamId = rawPayload.examId
- || sessionContext.examId
- || metadata.examId
- || (examEntry && examEntry.id)
- || null;
- const answerComparison = normalizeAnswerComparison(
- rawPayload.answerComparison || (rawPayload.realData && rawPayload.realData.answerComparison) || null
- );
- const answerMap = mergeAnswerSources(
- rawPayload.answerMap,
- rawPayload.answers,
- rawPayload.realData && rawPayload.realData.answers,
- sessionContext.answers,
- convertComparisonToMap(answerComparison, 'userAnswer')
- );
- const correctAnswerMap = mergeAnswerSources(
- rawPayload.correctAnswerMap,
- rawPayload.realData && rawPayload.realData.correctAnswerMap,
- sessionContext.correctAnswerMap,
- rawPayload.correctAnswers,
- rawPayload.realData && rawPayload.realData.correctAnswers,
- deriveCorrectMapFromDetails(scoreInfo.details),
- deriveCorrectMapFromDetails(rawPayload.realData && rawPayload.realData.scoreInfo && rawPayload.realData.scoreInfo.details),
- convertComparisonToMap(answerComparison, 'correctAnswer')
- );
- const answerDetails = rawPayload.answerDetails
- || scoreInfo.details
- || (rawPayload.realData && rawPayload.realData.scoreInfo && rawPayload.realData.scoreInfo.details)
- || buildAnswerDetails(answerMap, correctAnswerMap);
- const answerList = buildAnswerArray(answerMap, correctAnswerMap);
- const totalQuestions = ensureNumber(
- rawPayload.totalQuestions ?? scoreInfo.total ?? scoreInfo.totalQuestions,
- Object.keys(correctAnswerMap).length || Object.keys(answerMap).length
- );
- const correctAnswers = ensureNumber(
- rawPayload.correctAnswers ?? rawPayload.correctAnswersCount ?? scoreInfo.correct ?? scoreInfo.score ?? rawPayload.score,
- deriveCorrectAnswerCount({ answerDetails, scoreInfo }, answerList)
- );
- let accuracy = typeof rawPayload.accuracy === 'number'
- ? rawPayload.accuracy
- : (typeof scoreInfo.accuracy === 'number'
- ? scoreInfo.accuracy
- : (totalQuestions > 0 ? correctAnswers / totalQuestions : 0));
- if (accuracy > 1 && accuracy <= 100) {
- accuracy = accuracy / 100;
- }
- const percentage = typeof scoreInfo.percentage === 'number'
- ? scoreInfo.percentage
- : Math.round(accuracy * 100);
- const completedAt = resolveRecordDate({
- metadata,
- date: rawPayload.date,
- endTime: rawPayload.endTime,
- completedAt: rawPayload.completedAt,
- startTime: rawPayload.startTime,
- timestamp: rawPayload.timestamp
- });
- const duration = ensureNumber(
- rawPayload.duration,
- (rawPayload.endTime && rawPayload.startTime)
- ? Math.round((new Date(rawPayload.endTime) - new Date(rawPayload.startTime)) / 1000)
- : ensureNumber(sessionContext.duration, 0)
- );
- const startTime = rawPayload.startTime
- ? new Date(rawPayload.startTime).toISOString()
- : (sessionContext.startTime
- ? new Date(sessionContext.startTime).toISOString()
- : new Date(new Date(completedAt).getTime() - duration * 1000).toISOString());
- const endTime = rawPayload.endTime
- ? new Date(rawPayload.endTime).toISOString()
- : completedAt;
- const category = deriveCategory(rawPayload, examEntry, metadata);
- const frequency = deriveFrequency(rawPayload, examEntry, metadata);
- const title = rawPayload.title
- || metadata.examTitle
- || metadata.title
- || (examEntry && examEntry.title)
- || resolvedExamId
- || '未命名练习';
- const resolvedHighlights = Array.isArray(rawPayload.highlights)
- ? rawPayload.highlights.slice()
- : (Array.isArray(rawPayload.realData && rawPayload.realData.highlights)
- ? rawPayload.realData.highlights.slice()
- : (Array.isArray(sessionContext.highlights) ? sessionContext.highlights.slice() : []));
- const resolvedMarkedQuestions = Array.isArray(rawPayload.markedQuestions)
- ? rawPayload.markedQuestions.slice()
- : (Array.isArray(rawPayload.realData && rawPayload.realData.markedQuestions)
- ? rawPayload.realData.markedQuestions.slice()
- : (Array.isArray(sessionContext.markedQuestions) ? sessionContext.markedQuestions.slice() : []));
- const resolvedScrollY = Number.isFinite(Number(rawPayload.scrollY))
- ? Number(rawPayload.scrollY)
- : (Number.isFinite(Number(rawPayload.realData && rawPayload.realData.scrollY))
- ? Number(rawPayload.realData.scrollY)
- : (Number.isFinite(Number(sessionContext.scrollY)) ? Number(sessionContext.scrollY) : 0));
- const resolvedNoteText = typeof rawPayload.noteText === 'string'
- ? rawPayload.noteText
- : (typeof rawPayload.realData?.noteText === 'string'
- ? rawPayload.realData.noteText
- : (typeof sessionContext.noteText === 'string' ? sessionContext.noteText : ''));
- const resolvedQuestionTypeMap = isPlainObject(rawPayload.questionTypeMap)
- ? clonePlainObject(rawPayload.questionTypeMap)
- : (isPlainObject(rawPayload.realData && rawPayload.realData.questionTypeMap)
- ? clonePlainObject(rawPayload.realData.questionTypeMap)
- : {});
- const suiteEntries = rawPayload.suiteEntries || metadata.suiteEntries || [];
- const suiteSessionId = rawPayload.suiteSessionId || metadata.suiteSessionId || sessionContext.suiteSessionId || null;
-
- return standardizeRecord({
- id: rawPayload.id,
- examId: resolvedExamId,
- sessionId: rawPayload.sessionId || sessionContext.sessionId || null,
- title,
- type: rawPayload.type || metadata.type || metadata.examType || (examEntry && examEntry.type) || sessionContext.type || null,
- startTime,
- endTime,
- duration,
- date: completedAt,
- status: rawPayload.status || 'completed',
- score: ensureNumber(rawPayload.score ?? scoreInfo.score, correctAnswers),
- totalQuestions,
- correctAnswers,
- accuracy,
- answers: answerList,
- answerDetails,
- correctAnswerMap,
- answerComparison,
- questionTypePerformance: rawPayload.questionTypePerformance || {},
- metadata: Object.assign({}, metadata, {
- examId: resolvedExamId,
- examTitle: title,
- category,
- frequency,
- markedQuestions: resolvedMarkedQuestions.slice()
- }),
- frequency,
- suiteMode: Boolean(rawPayload.suiteMode || (String(rawPayload.practiceMode || metadata.practiceMode || '').toLowerCase() === 'suite')),
- suiteSessionId,
- suiteEntries,
- highlights: resolvedHighlights.slice(),
- scrollY: resolvedScrollY,
- markedQuestions: resolvedMarkedQuestions.slice(),
- noteText: resolvedNoteText,
- questionTypeMap: resolvedQuestionTypeMap,
- scoreInfo: Object.assign({}, scoreInfo, {
- correct: correctAnswers,
- total: totalQuestions,
- accuracy,
- percentage,
- details: scoreInfo.details || answerDetails,
- source: scoreInfo.source || rawPayload.pageType || rawPayload.source || 'practice_page'
- }),
- realData: Object.assign({}, rawPayload.realData || {}, {
- answers: answerMap,
- correctAnswers: correctAnswerMap,
- answerComparison,
- correctAnswerMap,
- highlights: resolvedHighlights.slice(),
- scrollY: resolvedScrollY,
- markedQuestions: resolvedMarkedQuestions.slice(),
- noteText: resolvedNoteText,
- questionTypeMap: resolvedQuestionTypeMap,
- scoreInfo: Object.assign({}, (rawPayload.realData && rawPayload.realData.scoreInfo) || scoreInfo, {
- correct: correctAnswers,
- total: totalQuestions,
- accuracy,
- percentage,
- details: answerDetails,
- source: scoreInfo.source || rawPayload.pageType || rawPayload.source || 'practice_page'
- }),
- interactions: rawPayload.interactions || [],
- isRealData: true,
- source: scoreInfo.source || rawPayload.pageType || rawPayload.source || 'practice_page',
- sessionId: rawPayload.sessionId || sessionContext.sessionId || null
- })
- }, options);
- }
-
- function getRepositories() {
- return internalRepositories;
- }
-
- function getStorageManager(storageManager) {
- return storageManager || global.persistentStore || global.storage || null;
- }
-
- function getStorageInternalOptions(storage) {
- // 仓库注入后用 token 化选项,确保 fallback 读写能通过 storage 的 internal-only 保护。
- if (internalStorageAccess && typeof internalStorageAccess.createInternalOptions === 'function') {
- try {
- return internalStorageAccess.createInternalOptions({});
- } catch (_) {
- // fallthrough 到旧行为
- }
- }
- return { skipPracticeCoreRedirect: true };
- }
-
- function syncPracticeRecordState(records) {
- const syncAppState = (nextRecords) => {
- try {
- if (global.app && global.app.state && global.app.state.practice) {
- global.app.state.practice.records = Array.isArray(nextRecords) ? nextRecords.slice() : [];
- }
- } catch (_) {}
- };
-
- if (typeof global.setPracticeRecordsState === 'function') {
- try {
- const finalRecords = global.setPracticeRecordsState(records);
- syncAppState(finalRecords);
- return;
- } catch (error) {
- console.warn('[PracticeCore] 同步 practice records 状态失败:', error);
- }
- }
- syncAppState(records);
- }
-
- async function readPracticeRecords(storageManager) {
- const repos = getRepositories();
- if (repos && repos.practice && typeof repos.practice.list === 'function') {
- return await repos.practice.list();
- }
- const storage = getStorageManager(storageManager);
- if (storage && typeof storage.readPersistentValue === 'function') {
- return await storage.readPersistentValue(STORAGE_KEYS.practiceRecords, [], getStorageInternalOptions(storage));
- }
- if (storage && typeof storage.get === 'function') {
- return await storage.get(STORAGE_KEYS.practiceRecords, [], getStorageInternalOptions(storage));
- }
- return [];
- }
-
- /**
- * 轻量投影:读取原始数组(clone:false 跳过 structuredClone),映射为精简 summary 对象。
- * 排除 answers/answerDetails/correctAnswerMap/suiteEntries[]/realData/answerComparison 等重字段,
- * 供练习历史列表、趋势图、热力图、成就统计等只需时间戳和元数据的消费者使用,
- * 避免大数据量下反序列化+克隆全部记录导致的前端渲染卡顿和内存溢出。
- */
- function projectRecordSummary(record) {
- if (!record || typeof record !== 'object') {
- return null;
- }
- const scoreInfo = record.scoreInfo || {};
- const metadata = record.metadata || {};
- // 轻量 suiteEntries 投影:仅保留签名字段,不含 answers/correctAnswerMap/realData
- const rawSuiteEntries = Array.isArray(record.suiteEntries) ? record.suiteEntries : [];
- const suiteEntries = rawSuiteEntries.map(function (entry) {
- if (!entry || typeof entry !== 'object') { return null; }
- const entryMeta = entry.metadata || {};
- const entryScore = entry.scoreInfo || {};
- return {
- id: entry.id || '',
- examId: entry.examId || entryMeta.examId || '',
- title: entry.title || entryMeta.examTitle || '',
- percentage: Number(entry.percentage != null ? entry.percentage : entryScore.percentage) || 0,
- duration: Number(entry.duration != null ? entry.duration : (entry.rawData && entry.rawData.duration)) || 0
- };
- }).filter(Boolean);
- return {
- id: record.id || record.sessionId || '',
- sessionId: record.sessionId || null,
- examId: record.examId || metadata.examId || null,
- title: record.title || metadata.examTitle || '',
- type: record.type || metadata.type || 'reading',
- practiceType: record.practiceType || metadata.practiceType || metadata.examType || null,
- url: record.url || metadata.url || null,
- startTime: record.startTime || null,
- endTime: record.endTime || null,
- date: record.date || null,
- duration: Number(record.duration ?? scoreInfo.duration ?? scoreInfo.timeSpent) || 0,
- percentage: Number(record.percentage ?? scoreInfo.percentage) || 0,
- accuracy: Number(record.accuracy ?? scoreInfo.accuracy) || 0,
- score: Number(record.score ?? scoreInfo.score) || 0,
- totalQuestions: Number(record.totalQuestions ?? scoreInfo.total) || 0,
- correctAnswers: Number(record.correctAnswers ?? scoreInfo.correct) || 0,
- status: record.status || 'completed',
- suiteMode: Boolean(record.suiteMode),
- suiteEntryCount: rawSuiteEntries.length,
- suiteEntries: suiteEntries,
- suiteSessionId: record.suiteSessionId || (metadata.suiteSessionId) || null,
- // questionTypePerformance 是小对象(每题型 {total,correct}),不是重字段,保留供 recalculateStats 使用
- questionTypePerformance: record.questionTypePerformance || null,
- // 轻量 scoreInfo 子集:供 accuracy/duration 等 fallback 读取
- scoreInfo: {
- accuracy: scoreInfo.accuracy != null ? scoreInfo.accuracy : null,
- duration: scoreInfo.duration != null ? scoreInfo.duration : null,
- timeSpent: scoreInfo.timeSpent != null ? scoreInfo.timeSpent : null,
- percentage: scoreInfo.percentage != null ? scoreInfo.percentage : null,
- score: scoreInfo.score != null ? scoreInfo.score : null,
- total: scoreInfo.total != null ? scoreInfo.total : null,
- correct: scoreInfo.correct != null ? scoreInfo.correct : null
- },
- metadata: {
- category: metadata.category || record.category || null,
- examTitle: metadata.examTitle || record.title || '',
- frequency: metadata.frequency || record.frequency || 'unknown',
- type: metadata.type || record.type || null,
- examType: metadata.examType || null,
- practiceType: metadata.practiceType || null,
- examId: metadata.examId || null,
- title: metadata.title || null,
- url: metadata.url || null
- },
- updatedAt: record.updatedAt || null,
- createdAt: record.createdAt || null
- };
- }
-
- async function readPracticeRecordSummaries(storageManager) {
- const repos = getRepositories();
- let records;
- if (repos && repos.practice && typeof repos.practice.read === 'function') {
- // clone:false 跳过 structuredClone,在投影后原始重字段不会进入返回值
- records = await repos.practice.read({ clone: false });
- } else {
- records = await readPracticeRecords(storageManager);
- }
- if (!Array.isArray(records)) {
- return [];
- }
- return records
- .map(projectRecordSummary)
- .filter(Boolean);
- }
-
- /**
- * 轻量计数:使用 repository.count()(clone:false + .length),不构造 summary 数组。
- */
- async function countPracticeRecords(storageManager) {
- const repos = getRepositories();
- if (repos && repos.practice && typeof repos.practice.count === 'function') {
- return await repos.practice.count();
- }
- const records = await readPracticeRecords(storageManager);
- return Array.isArray(records) ? records.length : 0;
- }
-
- async function writePracticeRecords(records, storageManager) {
- const finalRecords = Array.isArray(records) ? records : [];
- const repos = getRepositories();
- if (repos && repos.practice && typeof repos.practice.overwrite === 'function') {
- await repos.practice.overwrite(finalRecords);
- syncPracticeRecordState(finalRecords);
- return true;
- }
- const storage = getStorageManager(storageManager);
- if (storage && typeof storage.writePersistentValue === 'function') {
- const result = await storage.writePersistentValue(STORAGE_KEYS.practiceRecords, finalRecords, getStorageInternalOptions(storage));
- syncPracticeRecordState(finalRecords);
- return result;
- }
- return false;
- }
-
- async function readMeta(key, defaultValue, storageManager) {
- const repos = getRepositories();
- if (repos && repos.meta && typeof repos.meta.get === 'function') {
- return await repos.meta.get(key, defaultValue);
- }
- const storage = getStorageManager(storageManager);
- if (storage && typeof storage.readPersistentValue === 'function') {
- return await storage.readPersistentValue(key, defaultValue, getStorageInternalOptions(storage));
- }
- if (storage && typeof storage.get === 'function') {
- return await storage.get(key, defaultValue, getStorageInternalOptions(storage));
- }
- return defaultValue;
- }
-
- async function writeMeta(key, value, storageManager) {
- const repos = getRepositories();
- if (repos && repos.meta && typeof repos.meta.set === 'function') {
- await repos.meta.set(key, value);
- return true;
- }
- const storage = getStorageManager(storageManager);
- if (storage && typeof storage.writePersistentValue === 'function') {
- return await storage.writePersistentValue(key, value, getStorageInternalOptions(storage));
- }
- return false;
- }
-
- async function removeMeta(key, storageManager) {
- const repos = getRepositories();
- if (repos && repos.meta && typeof repos.meta.remove === 'function') {
- await repos.meta.remove(key);
- return true;
- }
- const storage = getStorageManager(storageManager);
- if (storage && typeof storage.removePersistentValue === 'function') {
- return await storage.removePersistentValue(key, getStorageInternalOptions(storage));
- }
- return false;
- }
-
- function extractSessionId(record) {
- if (!record || typeof record !== 'object') {
- return null;
- }
- const rawId = record.sessionId
- || (record.realData && record.realData.sessionId)
- || (record.metadata && record.metadata.sessionId)
- || null;
- if (!rawId) return null;
- return String(rawId).trim() || null;
- }
-
- function dedupePracticeRecords(records) {
- // 仅按 record.id 去重,不按 sessionId 全局去重。
- // sessionId 在套题场景中是容器标识,不是 attempt 唯一键;
- // 多条不同 id 的记录可能共享同一 sessionId(如同一套题的不同 passage),
- // 按 sessionId 去重会永久丢弃合法记录。
- const seenIds = new Set();
- const deduped = [];
-
- (Array.isArray(records) ? records : []).forEach((record) => {
- if (!record || typeof record !== 'object') {
- return;
- }
- const recordId = record.id != null ? String(record.id) : null;
-
- if (recordId && seenIds.has(recordId)) {
- return;
- }
-
- if (recordId) seenIds.add(recordId);
- deduped.push(record);
- });
-
- return deduped;
- }
-
- function getRecordTimestamp(record) {
- if (!record || typeof record !== 'object') {
- return 0;
- }
- const candidates = [
- record.updatedAt,
- record.createdAt,
- record.endTime,
- record.startTime,
- record.date,
- record.timestamp
- ];
- for (let index = 0; index < candidates.length; index += 1) {
- const value = candidates[index];
- if (!value) {
- continue;
- }
- const time = new Date(value).getTime();
- if (Number.isFinite(time)) {
- return time;
- }
- }
- return 0;
- }
-
- function handlesStorageKey(key) {
- return key === STORAGE_KEYS.practiceRecords
- || key === STORAGE_KEYS.userStats
- || key === STORAGE_KEYS.activeSessions
- || key === STORAGE_KEYS.tempPracticeRecords;
- }
-
- async function replacePracticeRecords(records, options = {}) {
- const canonical = dedupePracticeRecords(
- (Array.isArray(records) ? records : []).map((record) => standardizeRecord(record, options))
- );
- canonical.sort((a, b) => getRecordTimestamp(b) - getRecordTimestamp(a));
- if (Number.isFinite(options.maxRecords) && options.maxRecords > 0 && canonical.length > options.maxRecords) {
- canonical.splice(options.maxRecords);
- }
- return await writePracticeRecords(canonical, options.storageManager);
- }
-
- async function savePracticeRecord(record, options = {}) {
- const standardizedRecord = standardizeRecord(record, options);
- let records = await readPracticeRecords(options.storageManager);
- records = Array.isArray(records) ? records.slice() : [];
-
- const existingIndex = records.findIndex((entry) => entry && String(entry.id) === String(standardizedRecord.id));
- if (existingIndex >= 0) {
- records[existingIndex] = standardizedRecord;
- } else {
- records.unshift(standardizedRecord);
- }
-
- // 仅当同一 sessionId 且同一 examId 时才移除旧记录(同一篇练习的重复提交覆盖)。
- // 不同 examId 但共享 sessionId 的记录(如套题不同 passage)必须保留。
- const standardizedSessionId = extractSessionId(standardizedRecord);
- const standardizedExamId = standardizedRecord.examId || null;
- if (standardizedSessionId) {
- records = records.filter((entry, index) => {
- if (index === 0) {
- return true;
- }
- const sessionId = extractSessionId(entry);
- const examId = entry && entry.examId || null;
- const sameSession = sessionId && sessionId === standardizedSessionId;
- const sameExam = standardizedExamId && examId && examId === standardizedExamId;
- return !(sameSession && sameExam && String(entry.id) !== String(standardizedRecord.id));
- });
- }
-
- records = dedupePracticeRecords(records);
- records.sort((a, b) => getRecordTimestamp(b) - getRecordTimestamp(a));
- if (Number.isFinite(options.maxRecords) && options.maxRecords > 0 && records.length > options.maxRecords) {
- records.splice(options.maxRecords);
- }
- await writePracticeRecords(records, options.storageManager);
- return standardizedRecord;
- }
-
- async function routeStorageSet(storageManager, key, value, options = {}) {
- if (key === STORAGE_KEYS.practiceRecords) {
- return await replacePracticeRecords(value, {
- currentVersion: options.currentVersion || '0.6.2-fix',
- maxRecords: options.maxRecords || 1000,
- storageManager
- });
- }
- if (key === STORAGE_KEYS.userStats || key === STORAGE_KEYS.activeSessions || key === STORAGE_KEYS.tempPracticeRecords) {
- return await writeMeta(key, value, storageManager);
- }
- return null;
- }
-
- async function routeStorageRemove(storageManager, key) {
- if (key === STORAGE_KEYS.practiceRecords) {
- return await writePracticeRecords([], storageManager);
- }
- if (key === STORAGE_KEYS.userStats || key === STORAGE_KEYS.activeSessions || key === STORAGE_KEYS.tempPracticeRecords) {
- return await removeMeta(key, storageManager);
- }
- return null;
- }
-
- const contracts = Object.freeze({
- ensureNumber,
- normalizePracticeType,
- inferPracticeType,
- resolveRecordDate,
- inferExamId,
- normalizeAnswerValue,
- isNoiseKey,
- normalizeAnswerMap,
- normalizeReplayQuestionKey,
- normalizeReplayMap,
- normalizeAnswerComparison,
- mergeAnswerSources,
- buildReplayCorrectAnswerMap,
- buildReplayResultSnapshot,
- resolveCorrectAnswerMap,
- resolveRecordCorrectAnswerMap,
- compareAnswerValues,
- buildAnswerArray,
- buildAnswerDetails,
- deriveCorrectMapFromDetails,
- deriveCorrectAnswerCount,
- deriveTotalQuestionCount,
- convertComparisonToMap,
- convertComparisonToDetails,
- buildMetadata,
- standardizeRecord,
- standardizeSuiteEntries,
- clonePlainObject
- });
-
- const protocol = Object.freeze({
- MESSAGE_TYPE_ALIASES,
- PRACTICE_COMPLETE_TYPES,
- normalizeMessageType,
- normalizeMessage,
- isPracticeCompleteType,
- buildEnvelope
- });
-
- const ingestor = Object.freeze({
- fromCompletion
- });
-
- const internalStore = Object.freeze({
- STORAGE_KEYS,
- handlesStorageKey,
- listPracticeRecords: readPracticeRecords,
- listPracticeRecordSummaries: readPracticeRecordSummaries,
- countPracticeRecords,
- replacePracticeRecords,
- savePracticeRecord,
- routeStorageSet,
- routeStorageRemove,
- readMeta,
- writeMeta,
- removeMeta,
- syncPracticeRecordState
- });
-
- const publicStore = Object.freeze({
- STORAGE_KEYS,
- handlesStorageKey,
- listPracticeRecords: readPracticeRecords,
- listPracticeRecordSummaries: readPracticeRecordSummaries,
- countPracticeRecords,
- readMeta,
- syncPracticeRecordState
- });
-
- const practiceCore = {
- __stable: true,
- version: '0.6.2-fix',
- contracts,
- protocol,
- ingestor,
- store: publicStore
- };
- Object.defineProperty(practiceCore, '__installRecordAPI', {
- value: function(install) {
- if (typeof install !== 'function') {
- throw new Error('PracticeCore.__installRecordAPI requires an installer function');
- }
- return install(internalStore);
- },
- enumerable: false,
- configurable: true,
- writable: false
- });
- Object.defineProperty(practiceCore, '__installInternalRepositories', {
- value: function(repositories, installers) {
- if (!repositories || typeof repositories !== 'object') {
- throw new Error('PracticeCore.__installInternalRepositories requires repositories');
- }
- internalRepositories = repositories;
- // 接收 storage internal token factory,供 fallback 路径使用。
- if (installers && typeof installers.createInternalOptions === 'function') {
- internalStorageAccess = { createInternalOptions: installers.createInternalOptions };
- }
- try {
- delete practiceCore.__installInternalRepositories;
- } catch (_) {
- practiceCore.__installInternalRepositories = undefined;
- }
- return true;
- },
- enumerable: false,
- configurable: true,
- writable: false
- });
- global.PracticeCore = practiceCore;
-})(typeof window !== 'undefined' ? window : globalThis);
-
-
-/* ===== js/core/practiceRecordAPI.js ===== */
-(function initPracticeRecordAPI(global) {
- 'use strict';
-
- const DEFAULT_VERSION = '0.6.2-fix';
- const DEFAULT_MAX_RECORDS = 1000;
-
- if (global.PracticeRecordAPI && global.PracticeRecordAPI.__stable === true) {
- return;
- }
-
- let recordStore = null;
-
- function getPracticeCore() {
- return global.PracticeCore || null;
- }
-
- function installRecordStore() {
- if (recordStore) {
- return recordStore;
- }
- const core = getPracticeCore();
- if (!core || typeof core.__installRecordAPI !== 'function') {
- return null;
- }
- recordStore = core.__installRecordAPI((store) => store || null);
- try {
- delete core.__installRecordAPI;
- } catch (_) {
- core.__installRecordAPI = undefined;
- }
- return recordStore;
- }
-
- function getRecordStore() {
- return recordStore || installRecordStore();
- }
-
- installRecordStore();
-
- function getDefaultSaveOptions(options = {}) {
- const source = options && typeof options === 'object' ? options : {};
- const normalized = {
- currentVersion: source.currentVersion || DEFAULT_VERSION,
- maxRecords: DEFAULT_MAX_RECORDS
- };
- const maxRecords = Number(source.maxRecords);
- if (Number.isFinite(maxRecords) && maxRecords > 0) {
- normalized.maxRecords = maxRecords;
- }
- Object.keys(source).forEach((key) => {
- if (source[key] !== undefined) {
- normalized[key] = source[key];
- }
- });
- normalized.currentVersion = normalized.currentVersion || DEFAULT_VERSION;
- normalized.maxRecords = Number.isFinite(Number(normalized.maxRecords)) && Number(normalized.maxRecords) > 0
- ? Number(normalized.maxRecords)
- : DEFAULT_MAX_RECORDS;
- return normalized;
- }
-
- function toIdString(value) {
- return value == null ? '' : String(value);
- }
-
- function isPlainObject(value) {
- return value && typeof value === 'object' && !Array.isArray(value);
- }
-
- function clonePlainObject(value) {
- if (value == null || typeof value !== 'object') {
- return value ?? null;
- }
- if (Array.isArray(value)) {
- return value.map((item) => clonePlainObject(item));
- }
- const clone = {};
- Object.keys(value).forEach((key) => {
- clone[key] = clonePlainObject(value[key]);
- });
- return clone;
- }
-
- function getDefaultStats() {
- if (global.ExamData && typeof global.ExamData.createDefaultUserStats === 'function') {
- return clonePlainObject(global.ExamData.createDefaultUserStats());
- }
- const now = new Date().toISOString();
- return {
- totalPractices: 0,
- totalTimeSpent: 0,
- averageScore: 0,
- categoryStats: {},
- questionTypeStats: {},
- streakDays: 0,
- practiceDays: [],
- lastPracticeDate: null,
- achievements: [],
- createdAt: now,
- updatedAt: now
- };
- }
-
- function toCamelCaseKey(key) {
- return String(key)
- .replace(/[-_\s]+([a-zA-Z0-9])/g, (_, group) => group.toUpperCase())
- .replace(/^[A-Z]/, match => match.toLowerCase());
- }
-
- function normalizeStatsAliases(stats) {
- if (!isPlainObject(stats)) {
- return {};
- }
- const normalized = {};
- Object.entries(stats).forEach(([key, value]) => {
- normalized[toCamelCaseKey(key)] = value;
- });
- return normalized;
- }
-
- function prepareStats(stats) {
- const source = normalizeStatsAliases(stats);
- const prepared = Object.assign({}, getDefaultStats(), clonePlainObject(source));
- prepared.categoryStats = isPlainObject(source.categoryStats) ? clonePlainObject(source.categoryStats) : {};
- prepared.questionTypeStats = isPlainObject(source.questionTypeStats) ? clonePlainObject(source.questionTypeStats) : {};
- prepared.practiceDays = Array.isArray(source.practiceDays) ? source.practiceDays.slice() : [];
- prepared.achievements = Array.isArray(source.achievements) ? source.achievements.slice() : [];
- prepared.updatedAt = source.updatedAt || new Date().toISOString();
- return prepared;
- }
-
- function getCoreContracts() {
- const core = getPracticeCore();
- return core && core.contracts ? core.contracts : null;
- }
-
- function normalizeRecord(record, options = {}) {
- if (!isPlainObject(record)) {
- return null;
- }
-
- const contracts = getCoreContracts();
- if (!contracts || typeof contracts.standardizeRecord !== 'function') {
- throw new Error('PracticeRecordAPI.normalizeRecord: PracticeCore.contracts.standardizeRecord not ready');
- }
-
- const preserveIds = options.preserveIds !== false;
- const safePrefix = options.fallbackIdPrefix || 'record';
- const sourceId = record.id
- ?? record.recordId
- ?? record.record_id
- ?? record.practiceId
- ?? record.practice_id
- ?? record.sessionId
- ?? record.sessionID
- ?? record.timestamp
- ?? record.uuid;
- const candidate = clonePlainObject(record) || {};
-
- let id = preserveIds && sourceId ? String(sourceId).trim() : '';
- if (!id) {
- const index = Number.isFinite(Number(options.index)) ? Number(options.index) : 0;
- id = `${safePrefix}_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 8)}`;
- }
- candidate.id = id;
-
- if (record.recordStatus !== undefined && candidate.status === undefined) {
- candidate.status = record.recordStatus;
- }
-
- const generateRecordId = typeof options.generateRecordId === 'function'
- ? options.generateRecordId
- : () => id;
- const standardized = contracts.standardizeRecord(candidate, Object.assign({}, options, {
- currentVersion: options.currentVersion || DEFAULT_VERSION,
- generateRecordId
- }));
- return standardized && standardized.examId ? standardized : null;
- }
-
- function normalizeDateValue(value) {
- if (!value) {
- return null;
- }
- if (value instanceof Date && !Number.isNaN(value.getTime())) {
- return value.toISOString();
- }
- if (typeof value === 'number' && Number.isFinite(value)) {
- return new Date(value).toISOString();
- }
- if (typeof value === 'string') {
- const trimmed = value.trim();
- if (!trimmed) {
- return null;
- }
- if (/^\d+$/.test(trimmed)) {
- const numeric = Number(trimmed);
- if (Number.isFinite(numeric)) {
- const milliseconds = trimmed.length > 10 ? numeric : numeric * 1000;
- return new Date(milliseconds).toISOString();
- }
- }
- const parsed = new Date(trimmed);
- if (!Number.isNaN(parsed.getTime())) {
- return parsed.toISOString();
- }
- }
- return null;
- }
-
- function getRecordTimestamp(record) {
- if (!record || typeof record !== 'object') {
- return 0;
- }
- const candidates = [
- record.updatedAt,
- record.createdAt,
- record.endTime,
- record.startTime,
- record.timestamp,
- record.date
- ];
- for (let index = 0; index < candidates.length; index += 1) {
- const iso = normalizeDateValue(candidates[index]);
- if (iso) {
- const time = new Date(iso).getTime();
- if (Number.isFinite(time)) {
- return time;
- }
- }
- }
- return 0;
- }
-
- function mergeRecordDetails(existing, incoming, options = {}) {
- const merged = Object.assign({}, existing || {}, incoming || {});
- if (isPlainObject(existing && existing.metadata) || isPlainObject(incoming && incoming.metadata)) {
- merged.metadata = Object.assign(
- {},
- isPlainObject(existing && existing.metadata) ? existing.metadata : {},
- isPlainObject(incoming && incoming.metadata) ? incoming.metadata : {}
- );
- }
- if (isPlainObject(existing && existing.realData) || isPlainObject(incoming && incoming.realData)) {
- merged.realData = Object.assign(
- {},
- isPlainObject(existing && existing.realData) ? existing.realData : {},
- isPlainObject(incoming && incoming.realData) ? incoming.realData : {}
- );
- }
- return normalizeRecord(merged, Object.assign({}, options, {
- generateRecordId: () => String(merged.id || (incoming && incoming.id) || (existing && existing.id) || `record_${Date.now()}`)
- }));
- }
-
- async function readStats(options = {}) {
- const fallback = Object.prototype.hasOwnProperty.call(options, 'fallback')
- ? options.fallback
- : getDefaultStats();
-
- const store = getRecordStore();
- if (!store || typeof store.readMeta !== 'function') {
- throw new Error('PracticeRecordAPI.readStats: unified meta store not ready');
- }
-
- return prepareStats(await store.readMeta('user_stats', fallback));
- }
-
- async function writeStats(stats) {
- const finalStats = prepareStats(stats);
- const store = getRecordStore();
-
- if (store && typeof store.writeMeta === 'function') {
- await store.writeMeta('user_stats', finalStats);
- return finalStats;
- }
-
- throw new Error('PracticeRecordAPI.writeStats: unified meta store not ready');
- }
-
- function normalizeDay(value) {
- if (!value) return null;
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return null;
- return date.toISOString().slice(0, 10);
- }
-
- function calculateStreakDays(days) {
- const sorted = Array.isArray(days) ? days.slice().sort() : [];
- if (sorted.length === 0) return 0;
- let streak = 1;
- for (let index = sorted.length - 1; index > 0; index -= 1) {
- const current = new Date(sorted[index]);
- const previous = new Date(sorted[index - 1]);
- const diffDays = Math.round((current - previous) / 86400000);
- if (diffDays === 1) {
- streak += 1;
- continue;
- }
- if (diffDays > 1) break;
- }
- return streak;
- }
-
- function normalizeAccuracyForStats(record) {
- const values = [
- record && record.accuracy,
- record && record.scoreInfo && record.scoreInfo.accuracy,
- record && record.realData && record.realData.scoreInfo && record.realData.scoreInfo.accuracy
- ];
- for (let index = 0; index < values.length; index += 1) {
- const numeric = Number(values[index]);
- if (Number.isFinite(numeric)) {
- if (numeric > 1 && numeric <= 100) {
- return numeric / 100;
- }
- return Math.max(0, Math.min(1, numeric));
- }
- }
- const correct = Number(record && (record.correctAnswers ?? record.scoreInfo?.correct ?? record.score));
- const total = Number(record && (record.totalQuestions ?? record.scoreInfo?.total));
- return Number.isFinite(correct) && Number.isFinite(total) && total > 0
- ? Math.max(0, Math.min(1, correct / total))
- : 0;
- }
-
- function applyRecordToStats(stats, record) {
- if (!stats || !record || typeof record !== 'object') {
- return;
- }
-
- const duration = Math.max(0, Number(record.duration) || 0);
- const accuracy = normalizeAccuracyForStats(record);
- const category = String((record.metadata && record.metadata.category) || record.category || record.type || '').trim();
- const day = normalizeDay(record.date || record.endTime || record.startTime || record.createdAt);
-
- stats.totalPractices += 1;
- stats.totalTimeSpent += duration;
- const totalScore = (stats.averageScore * (stats.totalPractices - 1)) + accuracy;
- stats.averageScore = stats.totalPractices > 0 ? totalScore / stats.totalPractices : 0;
-
- stats.categoryStats = isPlainObject(stats.categoryStats) ? stats.categoryStats : {};
- if (category) {
- if (!stats.categoryStats[category]) {
- stats.categoryStats[category] = {
- practices: 0,
- avgScore: 0,
- timeSpent: 0,
- bestScore: 0,
- totalQuestions: 0,
- correctAnswers: 0
- };
- }
- const categoryStats = stats.categoryStats[category];
- categoryStats.practices += 1;
- categoryStats.timeSpent += duration;
- categoryStats.bestScore = Math.max(categoryStats.bestScore || 0, accuracy);
- categoryStats.totalQuestions += Number(record.totalQuestions) || 0;
- categoryStats.correctAnswers += Number(record.correctAnswers) || 0;
- categoryStats.avgScore = ((categoryStats.avgScore || 0) * (categoryStats.practices - 1) + accuracy) / categoryStats.practices;
- }
-
- stats.questionTypeStats = isPlainObject(stats.questionTypeStats) ? stats.questionTypeStats : {};
- if (isPlainObject(record.questionTypePerformance)) {
- Object.entries(record.questionTypePerformance).forEach(([type, performance]) => {
- if (!stats.questionTypeStats[type]) {
- stats.questionTypeStats[type] = {
- practices: 0,
- accuracy: 0,
- totalQuestions: 0,
- correctAnswers: 0
- };
- }
- const typeStats = stats.questionTypeStats[type];
- typeStats.practices += 1;
- typeStats.totalQuestions += Number(performance && performance.total) || 0;
- typeStats.correctAnswers += Number(performance && performance.correct) || 0;
- typeStats.accuracy = typeStats.totalQuestions > 0
- ? typeStats.correctAnswers / typeStats.totalQuestions
- : 0;
- });
- }
-
- if (day) {
- const days = new Set(Array.isArray(stats.practiceDays) ? stats.practiceDays : []);
- days.add(day);
- stats.practiceDays = Array.from(days).sort();
- stats.lastPracticeDate = stats.practiceDays[stats.practiceDays.length - 1] || null;
- stats.streakDays = calculateStreakDays(stats.practiceDays);
- }
- stats.updatedAt = new Date().toISOString();
- }
-
- async function recalculateStats() {
- // 使用轻量 listSummary 避免反序列化+克隆完整记录(answers/suiteEntries/realData 等重字段)。
- // summary 已包含 applyRecordToStats 所需的全部字段:duration, accuracy, metadata.category,
- // date/endTime/startTime/createdAt, totalQuestions, correctAnswers, questionTypePerformance。
- const records = await listSummary();
- const stats = getDefaultStats();
- (Array.isArray(records) ? records : []).forEach((record) => applyRecordToStats(stats, record));
- return await writeStats(stats);
- }
-
- async function resetStats(stats = null) {
- return await writeStats(isPlainObject(stats) ? stats : getDefaultStats());
- }
-
- async function mergeStats(stats, options = {}) {
- if (!isPlainObject(stats)) {
- return await readStats();
- }
-
- const mergeMode = options.mergeMode || options.mode || 'merge';
- if (mergeMode === 'replace') {
- return await writeStats(stats);
- }
-
- const existing = await readStats({ fallback: {} });
- const merged = Object.assign({}, existing);
- Object.entries(stats).forEach(([key, value]) => {
- if (value === undefined || value === null) {
- return;
- }
- const current = existing[key];
- if (typeof value === 'number' && typeof current === 'number') {
- merged[key] = Math.max(value, current);
- return;
- }
- if (isPlainObject(value) && isPlainObject(current)) {
- merged[key] = Object.assign({}, current, value);
- return;
- }
- merged[key] = clonePlainObject(value);
- });
-
- return await writeStats(merged);
- }
-
- async function updateStatsForSavedRecord(record, options = {}) {
- if (!record || options.updateStats === false) {
- return false;
- }
-
- await recalculateStats();
- return true;
- }
-
- async function list() {
- const store = getRecordStore();
- if (!store || typeof store.listPracticeRecords !== 'function') {
- throw new Error('PracticeRecordAPI.list: unified store not ready');
- }
-
- const records = await store.listPracticeRecords();
- return Array.isArray(records) ? records : [];
- }
-
- /**
- * 轻量投影查询:返回每条记录的元数据摘要,不含 answers/correctAnswerMap/
- * suiteEntries[]/realData 等重字段。底层以 clone:false 读取原始数组后即时映射,
- * 避免大数据量下 structuredClone 全部记录导致内存溢出和渲染卡顿。
- * 供练习历史列表签名、趋势图、热力图、成就统计等只需时间戳和元数据的消费者使用。
- */
- async function listSummary(options = {}) {
- const store = getRecordStore();
- if (!store || typeof store.listPracticeRecordSummaries !== 'function') {
- // 回退:store 尚未支持 summary 时从完整记录投影
- const records = await list();
- return records.map(_projectSummary).filter(Boolean);
- }
- const summaries = await store.listPracticeRecordSummaries();
- return Array.isArray(summaries) ? summaries : [];
- }
-
- /** 返回记录总数,不加载记录数组到内存 */
- async function count(options = {}) {
- const store = getRecordStore();
- if (store && typeof store.countPracticeRecords === 'function') {
- return await store.countPracticeRecords();
- }
- // 回退:store 不支持 count 时从 summary 长度获取
- if (store && typeof store.listPracticeRecordSummaries === 'function') {
- const summaries = await store.listPracticeRecordSummaries();
- return Array.isArray(summaries) ? summaries.length : 0;
- }
- const records = await list();
- return Array.isArray(records) ? records.length : 0;
- }
-
- /** 返回去重后的 examId 列表,供 overview 统计使用 */
- async function distinctExamIds(options = {}) {
- const summaries = await listSummary(options);
- const seen = new Set();
- const result = [];
- for (let i = 0; i < summaries.length; i += 1) {
- const examId = summaries[i] && summaries[i].examId;
- if (examId && !seen.has(examId)) {
- seen.add(examId);
- result.push(examId);
- }
- }
- return result;
- }
-
- /** 纯函数投影:从单条完整记录提取轻量 summary */
- function _projectSummary(record) {
- if (!record || typeof record !== 'object') {
- return null;
- }
- const scoreInfo = record.scoreInfo || {};
- const metadata = record.metadata || {};
- // 轻量 suiteEntries 投影:仅保留签名字段,不含 answers/correctAnswerMap/realData
- const rawSuiteEntries = Array.isArray(record.suiteEntries) ? record.suiteEntries : [];
- const suiteEntries = rawSuiteEntries.map(function (entry) {
- if (!entry || typeof entry !== 'object') { return null; }
- const entryMeta = entry.metadata || {};
- const entryScore = entry.scoreInfo || {};
- return {
- id: entry.id || '',
- examId: entry.examId || entryMeta.examId || '',
- title: entry.title || entryMeta.examTitle || '',
- percentage: Number(entry.percentage != null ? entry.percentage : entryScore.percentage) || 0,
- duration: Number(entry.duration != null ? entry.duration : (entry.rawData && entry.rawData.duration)) || 0
- };
- }).filter(Boolean);
- return {
- id: record.id || record.sessionId || '',
- sessionId: record.sessionId || null,
- examId: record.examId || metadata.examId || null,
- title: record.title || metadata.examTitle || '',
- type: record.type || metadata.type || 'reading',
- practiceType: record.practiceType || metadata.practiceType || metadata.examType || null,
- url: record.url || metadata.url || null,
- startTime: record.startTime || null,
- endTime: record.endTime || null,
- date: record.date || null,
- duration: Number(record.duration != null ? record.duration : (scoreInfo.duration != null ? scoreInfo.duration : scoreInfo.timeSpent)) || 0,
- percentage: Number(record.percentage != null ? record.percentage : scoreInfo.percentage) || 0,
- accuracy: Number(record.accuracy != null ? record.accuracy : scoreInfo.accuracy) || 0,
- score: Number(record.score != null ? record.score : scoreInfo.score) || 0,
- totalQuestions: Number(record.totalQuestions != null ? record.totalQuestions : scoreInfo.total) || 0,
- correctAnswers: Number(record.correctAnswers != null ? record.correctAnswers : scoreInfo.correct) || 0,
- status: record.status || 'completed',
- suiteMode: Boolean(record.suiteMode),
- suiteEntryCount: rawSuiteEntries.length,
- suiteEntries: suiteEntries,
- suiteSessionId: record.suiteSessionId || metadata.suiteSessionId || null,
- questionTypePerformance: record.questionTypePerformance || null,
- scoreInfo: {
- accuracy: scoreInfo.accuracy != null ? scoreInfo.accuracy : null,
- duration: scoreInfo.duration != null ? scoreInfo.duration : null,
- timeSpent: scoreInfo.timeSpent != null ? scoreInfo.timeSpent : null,
- percentage: scoreInfo.percentage != null ? scoreInfo.percentage : null,
- score: scoreInfo.score != null ? scoreInfo.score : null,
- total: scoreInfo.total != null ? scoreInfo.total : null,
- correct: scoreInfo.correct != null ? scoreInfo.correct : null
- },
- metadata: {
- category: metadata.category || record.category || null,
- examTitle: metadata.examTitle || record.title || '',
- frequency: metadata.frequency || record.frequency || 'unknown',
- type: metadata.type || record.type || null,
- examType: metadata.examType || null,
- practiceType: metadata.practiceType || null,
- examId: metadata.examId || null,
- title: metadata.title || null,
- url: metadata.url || null
- },
- updatedAt: record.updatedAt || null,
- createdAt: record.createdAt || null
- };
- }
-
- async function getById(recordId) {
- const targetId = toIdString(recordId);
- if (!targetId) {
- return null;
- }
- const records = await list();
- return records.find((record) => {
- if (!record || typeof record !== 'object') {
- return false;
- }
- return toIdString(record.id) === targetId || toIdString(record.sessionId) === targetId;
- }) || null;
- }
-
- async function replace(records, options = {}) {
- if (!Array.isArray(records)) {
- throw new Error('PracticeRecordAPI.replace requires an array of records');
- }
- const finalRecords = records;
- const saveOptions = getDefaultSaveOptions(options);
- const store = getRecordStore();
- if (store && typeof store.replacePracticeRecords === 'function') {
- await store.replacePracticeRecords(finalRecords, saveOptions);
- if (options.updateStats !== false) {
- await recalculateStats();
- }
- return finalRecords;
- }
-
- throw new Error('PracticeRecordAPI.replace: unified store not ready');
- }
-
- async function mergeRecords(records, options = {}) {
- if (!Array.isArray(records)) {
- throw new Error('PracticeRecordAPI.mergeRecords requires an array of records');
- }
-
- const mergeMode = options.mergeMode || options.mode || 'merge';
- const normalizeOptions = getDefaultSaveOptions(options);
- const incomingRecords = records
- .map((record, index) => normalizeRecord(record, Object.assign({}, normalizeOptions, {
- preserveIds: options.preserveIds !== false,
- fallbackIdPrefix: options.fallbackIdPrefix || 'record',
- index
- })))
- .filter(Boolean);
- const existingRecords = await list();
-
- if (mergeMode === 'replace') {
- await replace(incomingRecords, Object.assign({}, options, { updateStats: options.updateStats !== false }));
- return {
- importedCount: incomingRecords.length,
- updatedCount: existingRecords.length,
- skippedCount: 0,
- finalCount: incomingRecords.length,
- records: incomingRecords
- };
- }
-
- const indexMap = new Map();
- existingRecords.forEach((record, index) => {
- if (record && record.id !== undefined && record.id !== null) {
- indexMap.set(String(record.id), { record, index });
- }
- });
-
- const mergedRecords = existingRecords.slice();
- let importedCount = 0;
- let updatedCount = 0;
- let skippedCount = 0;
-
- incomingRecords.forEach((record) => {
- if (!record || record.id === undefined || record.id === null) {
- return;
- }
-
- const key = String(record.id);
- const existing = indexMap.get(key);
-
- if (!existing) {
- mergedRecords.push(record);
- indexMap.set(key, { record, index: mergedRecords.length - 1 });
- importedCount += 1;
- return;
- }
-
- if (mergeMode === 'skip') {
- skippedCount += 1;
- return;
- }
-
- const existingTimestamp = getRecordTimestamp(existing.record);
- const incomingTimestamp = getRecordTimestamp(record);
- if (incomingTimestamp >= existingTimestamp) {
- const merged = mergeRecordDetails(existing.record, record, normalizeOptions);
- mergedRecords[existing.index] = merged;
- indexMap.set(key, { record: merged, index: existing.index });
- updatedCount += 1;
- return;
- }
-
- skippedCount += 1;
- });
-
- mergedRecords.sort((a, b) => getRecordTimestamp(b) - getRecordTimestamp(a));
- await replace(mergedRecords, Object.assign({}, options, { updateStats: options.updateStats !== false }));
-
- return {
- importedCount,
- updatedCount,
- skippedCount,
- finalCount: mergedRecords.length,
- records: mergedRecords
- };
- }
-
- async function restoreRecords(records, options = {}) {
- if (!Array.isArray(records)) {
- throw new Error('PracticeRecordAPI.restoreRecords requires an array of records');
- }
-
- await replace(records, Object.assign({}, options, { updateStats: false }));
- if (isPlainObject(options.stats)) {
- await writeStats(options.stats);
- } else if (options.updateStats !== false) {
- await recalculateStats();
- }
- return {
- restoredCount: records.length,
- statsRestored: isPlainObject(options.stats)
- };
- }
-
- async function clear(options = {}) {
- await replace([], Object.assign({}, options, { updateStats: false }));
- if (options.updateStats === true) {
- await resetStats();
- }
- return true;
- }
-
- async function deleteMany(recordIds, options = {}) {
- const ids = Array.isArray(recordIds) ? recordIds.map(toIdString).filter(Boolean) : [];
- if (ids.length === 0) {
- return { deletedCount: 0, deletedRecords: [], records: await list() };
- }
-
- const idSet = new Set(ids);
- // 默认仅按 record.id 删除,避免共享 sessionId 的不同记录被误删。
- // matchBy: 'sessionId' 时才按 sessionId 匹配(用于 suite 子记录清理等显式场景)。
- const matchBySessionId = options.matchBy === 'sessionId';
- const records = await list();
- const deletedRecords = [];
- const remainingRecords = [];
-
- (Array.isArray(records) ? records : []).forEach((record) => {
- const recordId = toIdString(record && record.id);
- const sessionId = toIdString(record && record.sessionId);
- const idMatch = recordId && idSet.has(recordId);
- const sessionMatch = matchBySessionId && sessionId && idSet.has(sessionId);
- if (idMatch || sessionMatch) {
- deletedRecords.push(record);
- return;
- }
- remainingRecords.push(record);
- });
-
- if (deletedRecords.length > 0) {
- await replace(remainingRecords, options);
- }
-
- return {
- deletedCount: deletedRecords.length,
- deletedRecords,
- records: remainingRecords
- };
- }
-
- async function deleteById(recordId, options = {}) {
- const result = await deleteMany([recordId], options);
- return {
- deleted: result.deletedCount > 0,
- record: result.deletedRecords[0] || null,
- records: result.records
- };
- }
-
- async function saveRecord(record, options = {}) {
- if (!record || typeof record !== 'object') {
- throw new Error('PracticeRecordAPI.saveRecord requires a record object');
- }
-
- const saveOptions = getDefaultSaveOptions(options);
- const store = getRecordStore();
- if (!store || typeof store.savePracticeRecord !== 'function') {
- throw new Error('PracticeRecordAPI.saveRecord: PracticeCore store not ready');
- }
- const normalizedRecord = normalizeRecord(record, saveOptions);
- if (!normalizedRecord || !normalizedRecord.examId) {
- throw new Error('PracticeRecordAPI.saveRecord requires a canonical examId');
- }
-
- const savedRecord = await store.savePracticeRecord(normalizedRecord, saveOptions);
-
- if (options.updateStats !== false) {
- await updateStatsForSavedRecord(savedRecord, options);
- }
-
- return savedRecord;
- }
-
- function fromCompletion(payload, context = {}, examEntry = null, options = {}) {
- const core = getPracticeCore();
- if (!core || !core.ingestor || typeof core.ingestor.fromCompletion !== 'function') {
- return null;
- }
- return core.ingestor.fromCompletion(payload, context || {}, examEntry || null, getDefaultSaveOptions(options));
- }
-
- async function saveCompletion(payload, context = {}, examEntry = null, options = {}) {
- const record = fromCompletion(payload, context, examEntry, options);
- if (!record) {
- throw new Error('PracticeRecordAPI.saveCompletion could not build canonical record');
- }
- return await saveRecord(record, options);
- }
-
- function normalizeAccuracy(value) {
- const numeric = Number(value);
- if (!Number.isFinite(numeric) || numeric < 0) {
- return 0;
- }
- if (numeric > 1 && numeric <= 100) {
- return numeric / 100;
- }
- return Math.min(numeric, 1);
- }
-
- function toSummaryMetrics(record = {}) {
- const total = Number(record.totalQuestions ?? record.scoreInfo?.total ?? record.scoreInfo?.totalQuestions ?? record.realData?.scoreInfo?.total ?? record.realData?.totalQuestions);
- const correct = Number(record.correctAnswers ?? record.score ?? record.scoreInfo?.correct ?? record.scoreInfo?.score ?? record.realData?.scoreInfo?.correct ?? record.realData?.score);
- const safeTotal = Number.isFinite(total) && total >= 0 ? total : 0;
- const safeCorrect = Number.isFinite(correct) && correct >= 0 ? correct : 0;
-
- let accuracy = normalizeAccuracy(record.accuracy ?? record.scoreInfo?.accuracy ?? record.realData?.scoreInfo?.accuracy ?? (safeTotal > 0 ? safeCorrect / safeTotal : 0));
- const percentageCandidate = Number(record.percentage ?? record.scoreInfo?.percentage ?? record.realData?.scoreInfo?.percentage);
- const percentage = Number.isFinite(percentageCandidate) && percentageCandidate >= 0 && percentageCandidate <= 100
- ? percentageCandidate
- : Math.round(accuracy * 100);
- const hasExplicitAccuracy = record.accuracy != null
- || record.scoreInfo?.accuracy != null
- || record.realData?.scoreInfo?.accuracy != null;
- accuracy = percentage > 1 && !hasExplicitAccuracy
- ? percentage / 100
- : accuracy;
-
- return {
- totalQuestions: safeTotal,
- correctAnswers: safeCorrect,
- accuracy,
- percentage,
- duration: Number(record.duration ?? record.realData?.duration) || 0
- };
- }
-
- function toReplayEntries(record, projector) {
- if (typeof projector === 'function') {
- return projector(record);
- }
- return [];
- }
-
- global.PracticeRecordAPI = {
- __stable: true,
- version: '0.6.2-fix',
- list,
- listSummary,
- count,
- distinctExamIds,
- getById,
- replace,
- mergeRecords,
- restoreRecords,
- clear,
- deleteById,
- deleteMany,
- saveRecord,
- normalizeRecord,
- fromCompletion,
- saveCompletion,
- toSummaryMetrics,
- toReplayEntries,
- getDefaultStats,
- prepareStats,
- readStats,
- writeStats,
- mergeStats,
- resetStats,
- recalculateStats,
- updateStatsForSavedRecord
- };
-
- if (global.persistentStore && typeof global.persistentStore.migrateLegacyData === 'function') {
- Promise.resolve()
- .then(() => global.persistentStore.migrateLegacyData({ skipReady: true }))
- .catch((error) => {
- console.warn('[PracticeRecordAPI] 延后练习记录迁移失败:', error);
- });
- }
-})(typeof window !== 'undefined' ? window : globalThis);
-
-
-/* ===== js/core/backupAPI.js ===== */
-(function initBackupAPI(global) {
- 'use strict';
-
- if (global.BackupAPI && global.BackupAPI.__stable === true) {
- return;
- }
-
- const DEFAULT_VERSION = '0.6.2-form';
- const DEFAULT_MAX_BACKUPS = 20;
-
- function isPlainObject(value) {
- return value && typeof value === 'object' && !Array.isArray(value);
- }
-
- function cloneJson(value) {
- if (value == null) return value;
- try {
- return JSON.parse(JSON.stringify(value));
- } catch (_) {
- return value;
- }
- }
-
- function getStorageFacade() {
- if (global.storage && typeof global.storage.get === 'function') {
- return global.storage;
- }
- // Some boot paths / VM tests expose bare global storage without attaching to window
- try {
- if (typeof storage !== 'undefined' && storage && typeof storage.get === 'function') {
- return storage;
- }
- } catch (_) { /* ignore ReferenceError in strict scopes */ }
- return null;
- }
-
- function getRepositories() {
- if (global.dataRepositories && global.dataRepositories.backups) {
- return global.dataRepositories;
- }
- const registry = global.StorageProviderRegistry;
- if (registry && typeof registry.getCurrentProviders === 'function') {
- const current = registry.getCurrentProviders();
- if (current && current.repositories && current.repositories.backups) {
- return current.repositories;
- }
- }
- if (global.simpleStorageWrapper && global.simpleStorageWrapper.backupRepo) {
- return {
- backups: global.simpleStorageWrapper.backupRepo,
- meta: global.simpleStorageWrapper.metaRepo || null,
- settings: global.simpleStorageWrapper.settingsRepo || null
- };
- }
- return null;
- }
-
- function getBackupRepo() {
- const repos = getRepositories();
- return repos && repos.backups ? repos.backups : null;
- }
-
- function getMetaRepo() {
- const repos = getRepositories();
- return repos && repos.meta ? repos.meta : null;
- }
-
- async function readMeta(key, fallback = null) {
- const meta = getMetaRepo();
- if (meta && typeof meta.get === 'function') {
- return await meta.get(key, fallback);
- }
- const storageFacade = getStorageFacade();
- if (storageFacade) {
- return await storageFacade.get(key, fallback);
- }
- return fallback;
- }
-
- async function writeMeta(key, value) {
- const meta = getMetaRepo();
- if (meta && typeof meta.set === 'function') {
- await meta.set(key, value);
- return true;
- }
- const storageFacade = getStorageFacade();
- if (storageFacade) {
- await storageFacade.set(key, value);
- return true;
- }
- throw new Error('BackupAPI: meta store not ready');
- }
-
- function resolvePracticeRecords(data) {
- if (!data || typeof data !== 'object') return null;
- if (Array.isArray(data.practice_records)) return data.practice_records;
- if (Array.isArray(data.practiceRecords)) return data.practiceRecords;
- return null;
- }
-
- function resolveUserStats(data) {
- if (!data || typeof data !== 'object') return null;
- if (isPlainObject(data.user_stats)) return data.user_stats;
- if (isPlainObject(data.userStats)) return data.userStats;
- return null;
- }
-
- function resolveExamIndex(data) {
- if (!data || typeof data !== 'object') return null;
- if (Array.isArray(data.exam_index)) return data.exam_index;
- if (Array.isArray(data.examIndex)) return data.examIndex;
- return null;
- }
-
- function resolveStorageVersion(data) {
- if (!data || typeof data !== 'object') return null;
- if (data.storage_version != null) return data.storage_version;
- if (data.storageVersion != null) return data.storageVersion;
- return null;
- }
-
- /**
- * Canonical dual-schema payload so any legacy restore path can read snake or camel keys.
- */
- function normalizePayload(data = {}) {
- const source = isPlainObject(data) ? data : {};
- const records = resolvePracticeRecords(source);
- const stats = resolveUserStats(source);
- const examIndex = resolveExamIndex(source);
- const storageVersion = resolveStorageVersion(source);
- const payload = { ...source };
-
- if (records) {
- payload.practice_records = records;
- payload.practiceRecords = records;
- }
- if (stats) {
- payload.user_stats = stats;
- payload.userStats = stats;
- }
- if (examIndex) {
- payload.exam_index = examIndex;
- payload.examIndex = examIndex;
- }
- if (storageVersion != null) {
- payload.storage_version = storageVersion;
- payload.storageVersion = storageVersion;
- }
- return payload;
- }
-
- async function captureSnapshot(extra = {}) {
- let practiceRecords = [];
- let userStats = null;
-
- if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.list === 'function') {
- const listed = await global.PracticeRecordAPI.list();
- practiceRecords = Array.isArray(listed) ? listed : [];
- }
- if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.readStats === 'function') {
- userStats = await global.PracticeRecordAPI.readStats();
- }
-
- const examIndex = await readMeta('exam_index', []);
- const storageVersion = await readMeta('storage_version', null);
-
- return normalizePayload({
- practice_records: practiceRecords,
- user_stats: userStats,
- exam_index: Array.isArray(examIndex) ? examIndex : [],
- storage_version: storageVersion,
- ...(isPlainObject(extra) ? extra : {})
- });
- }
-
- async function list(options = {}) {
- const repo = getBackupRepo();
- if (repo && typeof repo.list === 'function') {
- const backups = await repo.list(options);
- return Array.isArray(backups) ? backups : [];
- }
- const storageFacade = getStorageFacade();
- if (storageFacade) {
- const backups = await storageFacade.get('manual_backups', []);
- return Array.isArray(backups) ? backups : [];
- }
- throw new Error('BackupAPI.list: backup repository not ready');
- }
-
- async function getById(id, options = {}) {
- if (!id) return null;
- const repo = getBackupRepo();
- if (repo && typeof repo.getById === 'function') {
- return await repo.getById(id, options);
- }
- const backups = await list(options);
- return backups.find((item) => item && String(item.id) === String(id)) || null;
- }
-
- async function add(backup, options = {}) {
- const repo = getBackupRepo();
- const normalizedData = normalizePayload(backup && backup.data ? backup.data : {});
- const entry = {
- ...(backup && typeof backup === 'object' ? backup : {}),
- id: (backup && backup.id) || `backup_${Date.now()}`,
- timestamp: (backup && backup.timestamp) || new Date().toISOString(),
- type: (backup && backup.type) || 'manual',
- version: (backup && backup.version) || DEFAULT_VERSION,
- data: normalizedData
- };
- entry.size = entry.size || JSON.stringify(entry.data).length;
-
- if (repo && typeof repo.add === 'function') {
- return await repo.add(entry, options);
- }
-
- // Fallback: raw storage (tests / early boot)
- const storageFacade = getStorageFacade();
- if (storageFacade) {
- const backups = await storageFacade.get('manual_backups', []);
- const list = Array.isArray(backups) ? backups.slice() : [];
- list.unshift(entry);
- const max = options.maxBackups || DEFAULT_MAX_BACKUPS;
- while (list.length > max) {
- list.pop();
- }
- await storageFacade.set('manual_backups', list);
- return entry;
- }
-
- throw new Error('BackupAPI.add: backup repository not ready');
- }
-
- async function create(options = {}) {
- const {
- id = null,
- type = 'manual',
- data = null,
- extra = null,
- version = DEFAULT_VERSION
- } = options;
-
- const snapshot = data != null
- ? normalizePayload(data)
- : await captureSnapshot(extra || {});
-
- const backupId = id || `backup_${Date.now()}`;
- const entry = await add({
- id: backupId,
- timestamp: new Date().toISOString(),
- type,
- version,
- data: snapshot
- });
-
- return entry && entry.id ? entry.id : backupId;
- }
-
- async function restorePayload(data, options = {}) {
- const payload = normalizePayload(data || {});
- const records = resolvePracticeRecords(payload);
- const stats = resolveUserStats(payload);
- const examIndex = resolveExamIndex(payload);
- const storageVersion = resolveStorageVersion(payload);
- const restoreRecords = options.restoreRecords !== false;
- const restoreExamIndex = options.restoreExamIndex !== false;
- const restoreStorageVersion = options.restoreStorageVersion !== false;
-
- if (restoreRecords && records != null) {
- if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.restoreRecords === 'function') {
- await global.PracticeRecordAPI.restoreRecords(records, {
- stats: isPlainObject(stats) ? stats : null,
- updateStats: true
- });
- } else {
- throw new Error('BackupAPI.restore: PracticeRecordAPI.restoreRecords not ready');
- }
- } else if (isPlainObject(stats) && global.PracticeRecordAPI && typeof global.PracticeRecordAPI.resetStats === 'function') {
- await global.PracticeRecordAPI.resetStats(stats);
- }
-
- if (restoreExamIndex && examIndex) {
- await writeMeta('exam_index', examIndex);
- }
-
- if (restoreStorageVersion && storageVersion != null) {
- await writeMeta('storage_version', storageVersion);
- }
-
- // Optional system settings (DataIntegrityManager snapshots)
- if (isPlainObject(payload.system_settings)) {
- const repos = getRepositories();
- if (repos && repos.settings && typeof repos.settings.getAll === 'function') {
- const current = await repos.settings.getAll();
- await repos.settings.saveAll({ ...current, ...payload.system_settings });
- }
- }
-
- return {
- restoredRecords: records != null,
- restoredStats: isPlainObject(stats),
- restoredExamIndex: Boolean(restoreExamIndex && examIndex),
- restoredStorageVersion: Boolean(restoreStorageVersion && storageVersion != null)
- };
- }
-
- async function restore(backupId, options = {}) {
- if (!backupId) {
- throw new Error('BackupAPI.restore: invalid backup id');
- }
- const backup = await getById(backupId);
- if (!backup) {
- throw new Error(`BackupAPI.restore: backup ${backupId} not found`);
- }
- const result = await restorePayload(backup.data || {}, options);
- return { backup, ...result };
- }
-
- async function clear(options = {}) {
- const repo = getBackupRepo();
- if (repo && typeof repo.clear === 'function') {
- await repo.clear(options);
- return true;
- }
- const storageFacade = getStorageFacade();
- if (storageFacade) {
- await storageFacade.set('manual_backups', []);
- return true;
- }
- throw new Error('BackupAPI.clear: backup repository not ready');
- }
-
- async function remove(id, options = {}) {
- const repo = getBackupRepo();
- if (repo && typeof repo.delete === 'function') {
- return await repo.delete(id, options);
- }
- const backups = await list();
- const next = backups.filter((item) => item && String(item.id) !== String(id));
- if (next.length === backups.length) return false;
- if (repo && typeof repo.saveAll === 'function') {
- await repo.saveAll(next, options);
- return true;
- }
- const storageFacade = getStorageFacade();
- if (storageFacade) {
- await storageFacade.set('manual_backups', next);
- return true;
- }
- return false;
- }
-
- async function prune(limit, options = {}) {
- const repo = getBackupRepo();
- if (repo && typeof repo.prune === 'function') {
- return await repo.prune(limit, options);
- }
- const max = typeof limit === 'number' && limit > 0 ? limit : DEFAULT_MAX_BACKUPS;
- const backups = await list();
- if (backups.length <= max) return backups.length;
- const next = backups.slice(0, max);
- if (repo && typeof repo.saveAll === 'function') {
- await repo.saveAll(next, options);
- } else {
- const storageFacade = getStorageFacade();
- if (storageFacade) {
- await storageFacade.set('manual_backups', next);
- }
- }
- return next.length;
- }
-
- global.BackupAPI = {
- __stable: true,
- version: DEFAULT_VERSION,
- list,
- getById,
- add,
- create,
- captureSnapshot,
- normalizePayload,
- restore,
- restorePayload,
- clear,
- remove,
- prune,
- resolvePracticeRecords,
- resolveUserStats,
- resolveExamIndex,
- resolveStorageVersion
- };
-})(typeof window !== 'undefined' ? window : globalThis);
-
-
-/* ===== js/core/externalBackupService.js ===== */
-/**
- * External disk backup via File System Access API.
- * Browser-internal backups (manual_backups) cannot survive site-data clears;
- * this service writes JSON into a user-chosen local folder.
- *
- * Policy:
- * - Silent write only when a directory handle already has granted permission.
- * - Daily reminder at most once per calendar day (permission / bind / stale write).
- * - Download export is never auto-triggered; only after explicit user click.
- */
-(function initExternalBackupService(global) {
- 'use strict';
-
- if (global.ExternalBackupService && global.ExternalBackupService.__stable === true) {
- return;
- }
-
- var META_KEY = 'exam_system_external_backup_meta';
- var DB_NAME = 'ExamSystemExternalBackup';
- var DB_VERSION = 1;
- var STORE_NAME = 'handles';
- var HANDLE_KEY = 'backup_directory';
- var LATEST_FILENAME = 'practice-backup-latest.json';
- var DAY_MS = 24 * 60 * 60 * 1000;
- var STALE_WRITE_MS = DAY_MS;
- var REMIND_BANNER_ID = 'external-backup-remind-banner';
- var VERSION = '0.6.2-fix';
-
- var state = {
- ready: false,
- readyPromise: null,
- directoryHandle: null,
- meta: null,
- dirty: false,
- writing: false,
- lastSnapshotHash: null,
- silentFlushTimer: null
- };
-
- function nowIso() {
- return new Date().toISOString();
- }
-
- function dayKey(date) {
- var d = date instanceof Date ? date : new Date(date || Date.now());
- if (Number.isNaN(d.getTime())) {
- d = new Date();
- }
- var y = d.getFullYear();
- var m = String(d.getMonth() + 1).padStart(2, '0');
- var day = String(d.getDate()).padStart(2, '0');
- return y + '-' + m + '-' + day;
- }
-
- function isPlainObject(value) {
- return value && typeof value === 'object' && !Array.isArray(value);
- }
-
- function notify(message, type) {
- if (typeof global.showMessage === 'function') {
- global.showMessage(message, type || 'info');
- }
- }
-
- function defaultMeta() {
- return {
- enabled: false,
- directoryName: null,
- lastWriteAt: null,
- lastWriteOk: false,
- lastWriteError: null,
- lastRemindDay: null,
- lastPermissionOk: false,
- lastRestorePromptDay: null,
- recordCountAtLastWrite: 0,
- createdAt: nowIso(),
- updatedAt: nowIso()
- };
- }
-
- function readMeta() {
- try {
- var raw = global.localStorage && global.localStorage.getItem(META_KEY);
- if (!raw) {
- return defaultMeta();
- }
- var parsed = JSON.parse(raw);
- return Object.assign(defaultMeta(), isPlainObject(parsed) ? parsed : {});
- } catch (_) {
- return defaultMeta();
- }
- }
-
- function writeMeta(patch) {
- var next = Object.assign({}, state.meta || readMeta(), isPlainObject(patch) ? patch : {}, {
- updatedAt: nowIso()
- });
- state.meta = next;
- try {
- if (global.localStorage) {
- global.localStorage.setItem(META_KEY, JSON.stringify(next));
- }
- } catch (error) {
- console.warn('[ExternalBackup] meta write failed:', error);
- }
- dispatchStatus();
- return next;
- }
-
- function dispatchStatus() {
- try {
- global.dispatchEvent(new CustomEvent('external-backup-status', {
- detail: getStatus()
- }));
- } catch (_) { /* ignore */ }
- }
-
- function supportsFileSystemAccess() {
- return !!(
- global.showDirectoryPicker &&
- typeof global.showDirectoryPicker === 'function' &&
- global.isSecureContext !== false
- );
- }
-
- function supportsFilePickerRead() {
- return !!(global.showOpenFilePicker && typeof global.showOpenFilePicker === 'function');
- }
-
- function openHandleDb() {
- return new Promise(function (resolve, reject) {
- if (!global.indexedDB) {
- reject(new Error('IndexedDB unavailable'));
- return;
- }
- var request = global.indexedDB.open(DB_NAME, DB_VERSION);
- request.onerror = function () {
- reject(request.error || new Error('Failed to open external backup DB'));
- };
- request.onupgradeneeded = function (event) {
- var db = event.target.result;
- if (!db.objectStoreNames.contains(STORE_NAME)) {
- db.createObjectStore(STORE_NAME);
- }
- };
- request.onsuccess = function () {
- resolve(request.result);
- };
- });
- }
-
- function idbRequest(request) {
- return new Promise(function (resolve, reject) {
- request.onsuccess = function () { resolve(request.result); };
- request.onerror = function () { reject(request.error); };
- });
- }
-
- async function saveDirectoryHandle(handle) {
- var db = await openHandleDb();
- try {
- var tx = db.transaction(STORE_NAME, 'readwrite');
- var store = tx.objectStore(STORE_NAME);
- await idbRequest(store.put(handle, HANDLE_KEY));
- } finally {
- try { db.close(); } catch (_) { /* ignore */ }
- }
- }
-
- async function loadDirectoryHandle() {
- var db = await openHandleDb();
- try {
- var tx = db.transaction(STORE_NAME, 'readonly');
- var store = tx.objectStore(STORE_NAME);
- return await idbRequest(store.get(HANDLE_KEY));
- } finally {
- try { db.close(); } catch (_) { /* ignore */ }
- }
- }
-
- async function clearDirectoryHandle() {
- var db = await openHandleDb();
- try {
- var tx = db.transaction(STORE_NAME, 'readwrite');
- var store = tx.objectStore(STORE_NAME);
- await idbRequest(store.delete(HANDLE_KEY));
- } finally {
- try { db.close(); } catch (_) { /* ignore */ }
- }
- }
-
- async function queryHandlePermission(handle, mode) {
- if (!handle) {
- return 'denied';
- }
- try {
- if (typeof handle.queryPermission === 'function') {
- return await handle.queryPermission({ mode: mode || 'readwrite' });
- }
- } catch (_) { /* ignore */ }
- return 'prompt';
- }
-
- async function requestHandlePermission(handle, mode) {
- if (!handle) {
- return 'denied';
- }
- try {
- if (typeof handle.requestPermission === 'function') {
- return await handle.requestPermission({ mode: mode || 'readwrite' });
- }
- } catch (_) { /* ignore */ }
- // Some Chromium builds treat existing handles as usable without requestPermission.
- return await queryHandlePermission(handle, mode);
- }
-
- async function ensurePermission(handle, interactive) {
- if (!handle) {
- return false;
- }
- var current = await queryHandlePermission(handle, 'readwrite');
- if (current === 'granted') {
- writeMeta({ lastPermissionOk: true });
- return true;
- }
- if (!interactive) {
- writeMeta({ lastPermissionOk: false });
- return false;
- }
- var next = await requestHandlePermission(handle, 'readwrite');
- var ok = next === 'granted';
- writeMeta({ lastPermissionOk: ok });
- return ok;
- }
-
- async function requestPersistentStorage() {
- try {
- if (!global.navigator || !global.navigator.storage || typeof global.navigator.storage.persist !== 'function') {
- return false;
- }
- var already = typeof global.navigator.storage.persisted === 'function'
- ? await global.navigator.storage.persisted()
- : false;
- if (already) {
- return true;
- }
- return await global.navigator.storage.persist();
- } catch (error) {
- console.warn('[ExternalBackup] persist() failed:', error);
- return false;
- }
- }
-
- async function captureSnapshot() {
- if (global.BackupAPI && typeof global.BackupAPI.captureSnapshot === 'function') {
- var snapshot = await global.BackupAPI.captureSnapshot();
- return global.BackupAPI.normalizePayload
- ? global.BackupAPI.normalizePayload(snapshot)
- : snapshot;
- }
-
- var practiceRecords = [];
- var userStats = null;
- if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.list === 'function') {
- var listed = await global.PracticeRecordAPI.list();
- practiceRecords = Array.isArray(listed) ? listed : [];
- }
- if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.readStats === 'function') {
- userStats = await global.PracticeRecordAPI.readStats();
- }
-
- var examIndex = [];
- var storageVersion = null;
- try {
- if (global.storage && typeof global.storage.get === 'function') {
- examIndex = await global.storage.get('exam_index', []);
- storageVersion = await global.storage.get('storage_version', null);
- }
- } catch (_) { /* ignore */ }
-
- return {
- practice_records: practiceRecords,
- practiceRecords: practiceRecords,
- user_stats: userStats,
- userStats: userStats,
- exam_index: Array.isArray(examIndex) ? examIndex : [],
- examIndex: Array.isArray(examIndex) ? examIndex : [],
- storage_version: storageVersion,
- storageVersion: storageVersion
- };
- }
-
- function buildExportDocument(snapshot) {
- return {
- exportDate: nowIso(),
- version: VERSION,
- source: 'external-backup-service',
- note: 'Disk backup for IELTS Atlas. Survives browser cache clears. Import via 设置 → 导入数据.',
- data: snapshot
- };
- }
-
- function stableHash(payload) {
- try {
- var text = JSON.stringify(payload);
- var hash = 0;
- for (var i = 0; i < text.length; i += 1) {
- hash = ((hash << 5) - hash) + text.charCodeAt(i);
- hash |= 0;
- }
- return String(hash);
- } catch (_) {
- return String(Date.now());
- }
- }
-
- async function writeTextFile(directoryHandle, filename, text) {
- var fileHandle = await directoryHandle.getFileHandle(filename, { create: true });
- var writable = await fileHandle.createWritable();
- try {
- await writable.write(text);
- await writable.close();
- } catch (error) {
- try { await writable.abort(); } catch (_) { /* ignore */ }
- throw error;
- }
- }
-
- async function readTextFile(directoryHandle, filename) {
- var fileHandle = await directoryHandle.getFileHandle(filename, { create: false });
- var file = await fileHandle.getFile();
- return await file.text();
- }
-
- async function writeToBoundDirectory(options) {
- var opts = options || {};
- if (state.writing) {
- return { success: false, reason: 'busy' };
- }
- if (!state.directoryHandle) {
- return { success: false, reason: 'unbound' };
- }
-
- state.writing = true;
- try {
- var interactive = opts.interactive === true;
- var permitted = await ensurePermission(state.directoryHandle, interactive);
- if (!permitted) {
- writeMeta({ lastWriteOk: false, lastWriteError: 'permission_denied' });
- return { success: false, reason: 'permission_denied' };
- }
-
- var snapshot = await captureSnapshot();
- var doc = buildExportDocument(snapshot);
- var text = JSON.stringify(doc, null, 2);
- var hash = stableHash(doc.data);
-
- if (!opts.force && hash === state.lastSnapshotHash && state.meta && state.meta.lastWriteOk) {
- return { success: true, reason: 'unchanged', skipped: true };
- }
-
- await writeTextFile(state.directoryHandle, LATEST_FILENAME, text);
-
- if (opts.datedCopy !== false) {
- try {
- var dated = 'practice-backup-' + dayKey(new Date()) + '.json';
- await writeTextFile(state.directoryHandle, dated, text);
- } catch (datedError) {
- console.warn('[ExternalBackup] dated copy failed:', datedError);
- }
- }
-
- var recordCount = Array.isArray(snapshot.practice_records)
- ? snapshot.practice_records.length
- : (Array.isArray(snapshot.practiceRecords) ? snapshot.practiceRecords.length : 0);
-
- state.lastSnapshotHash = hash;
- state.dirty = false;
- writeMeta({
- enabled: true,
- lastWriteAt: nowIso(),
- lastWriteOk: true,
- lastWriteError: null,
- lastPermissionOk: true,
- recordCountAtLastWrite: recordCount
- });
-
- return {
- success: true,
- reason: 'written',
- filename: LATEST_FILENAME,
- recordCount: recordCount,
- bytes: text.length
- };
- } catch (error) {
- console.error('[ExternalBackup] write failed:', error);
- writeMeta({
- lastWriteOk: false,
- lastWriteError: error && error.message ? error.message : String(error)
- });
- return {
- success: false,
- reason: 'write_error',
- error: error
- };
- } finally {
- state.writing = false;
- }
- }
-
- async function bindDirectory(options) {
- if (!supportsFileSystemAccess()) {
- throw new Error('当前浏览器不支持绑定本地文件夹(需要 Chrome/Edge,且非 file:// 打开)');
- }
-
- var handle = await global.showDirectoryPicker({
- id: 'ielts-atlas-external-backup',
- mode: 'readwrite',
- startIn: 'documents'
- });
-
- if (!handle) {
- throw new Error('未选择文件夹');
- }
-
- var permitted = await ensurePermission(handle, true);
- if (!permitted) {
- throw new Error('未获得文件夹读写权限');
- }
-
- await saveDirectoryHandle(handle);
- state.directoryHandle = handle;
- writeMeta({
- enabled: true,
- directoryName: handle.name || 'backup',
- lastPermissionOk: true,
- lastWriteError: null
- });
-
- var writeNow = !options || options.writeNow !== false;
- var writeResult = null;
- if (writeNow) {
- writeResult = await writeToBoundDirectory({ interactive: true, force: true });
- }
-
- await requestPersistentStorage();
- return {
- directoryName: handle.name || 'backup',
- writeResult: writeResult
- };
- }
-
- async function unbindDirectory() {
- state.directoryHandle = null;
- state.lastSnapshotHash = null;
- try {
- await clearDirectoryHandle();
- } catch (error) {
- console.warn('[ExternalBackup] clear handle failed:', error);
- }
- writeMeta({
- enabled: false,
- directoryName: null,
- lastPermissionOk: false,
- lastWriteError: null
- });
- return true;
- }
-
- async function restoreFromLatest(options) {
- var opts = options || {};
- if (!state.directoryHandle) {
- throw new Error('尚未绑定备份文件夹');
- }
- var permitted = await ensurePermission(state.directoryHandle, opts.interactive !== false);
- if (!permitted) {
- throw new Error('需要文件夹读取权限才能恢复');
- }
-
- var text = await readTextFile(state.directoryHandle, LATEST_FILENAME);
- var payload = JSON.parse(text);
- var data = payload && payload.data ? payload.data : payload;
-
- if (global.BackupAPI && typeof global.BackupAPI.restorePayload === 'function') {
- await global.BackupAPI.restorePayload(data, opts);
- } else if (global.DataBackupManager || global.dataBackupManager) {
- throw new Error('请使用设置页「导入数据」选择备份文件完成恢复');
- } else {
- throw new Error('恢复 API 未就绪');
- }
-
- writeMeta({ lastRestorePromptDay: dayKey(new Date()) });
- return true;
- }
-
- async function pickAndRestoreFile() {
- if (supportsFilePickerRead()) {
- var handles = await global.showOpenFilePicker({
- multiple: false,
- types: [{
- description: 'IELTS Atlas backup JSON',
- accept: { 'application/json': ['.json'] }
- }]
- });
- var fileHandle = handles && handles[0];
- if (!fileHandle) {
- throw new Error('未选择文件');
- }
- var file = await fileHandle.getFile();
- var text = await file.text();
- var payload = JSON.parse(text);
- var data = payload && payload.data ? payload.data : payload;
- if (global.BackupAPI && typeof global.BackupAPI.restorePayload === 'function') {
- await global.BackupAPI.restorePayload(data);
- return true;
- }
- throw new Error('恢复 API 未就绪');
- }
-
- // Fallback: reuse existing import flow
- if (typeof global.importData === 'function') {
- global.importData();
- return false;
- }
- throw new Error('当前环境不支持文件选择器,请使用「导入数据」');
- }
-
- async function countPracticeRecords() {
- try {
- if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.list === 'function') {
- var list = await global.PracticeRecordAPI.list();
- return Array.isArray(list) ? list.length : 0;
- }
- } catch (_) { /* ignore */ }
- return 0;
- }
-
- async function hasReadableLatestBackup() {
- if (!state.directoryHandle) {
- return false;
- }
- try {
- var permitted = await ensurePermission(state.directoryHandle, false);
- if (!permitted) {
- return false;
- }
- await state.directoryHandle.getFileHandle(LATEST_FILENAME, { create: false });
- return true;
- } catch (_) {
- return false;
- }
- }
-
- function buildReminder(status) {
- if (!status) {
- return null;
- }
-
- if (!status.supported) {
- return null;
- }
-
- if (!status.bound) {
- return {
- level: 'info',
- code: 'bind',
- title: '建议绑定本地备份文件夹',
- message: '练习数据只存在浏览器内,清缓存会丢失。绑定文件夹后可一键写入磁盘备份。',
- primaryAction: 'bind',
- primaryLabel: '绑定文件夹',
- secondaryAction: null,
- secondaryLabel: null
- };
- }
-
- if (!status.permissionGranted) {
- return {
- level: 'warning',
- code: 'permission',
- title: '本地备份需要重新授权',
- message: '已绑定「' + (status.directoryName || '备份文件夹') + '」,但当前没有写入权限。',
- primaryAction: 'reauth',
- primaryLabel: '重新授权并写入',
- secondaryAction: 'unbind',
- secondaryLabel: '解除绑定'
- };
- }
-
- if (status.staleWrite || status.dirty) {
- return {
- level: 'info',
- code: 'write',
- title: '本地备份可更新',
- message: status.lastWriteAt
- ? ('距上次写入已超过一天或有新练习数据(上次:' + formatTime(status.lastWriteAt) + ')。')
- : '尚未写入磁盘备份,建议现在写入。',
- primaryAction: 'write',
- primaryLabel: '立即写入备份',
- secondaryAction: null,
- secondaryLabel: null
- };
- }
-
- return null;
- }
-
- function formatTime(iso) {
- if (!iso) return '—';
- try {
- return new Date(iso).toLocaleString();
- } catch (_) {
- return String(iso);
- }
- }
-
- function shouldShowDailyReminder(reminder) {
- if (!reminder) {
- return false;
- }
- var meta = state.meta || readMeta();
- var today = dayKey(new Date());
- if (meta.lastRemindDay === today) {
- return false;
- }
- return true;
- }
-
- function markReminded() {
- writeMeta({ lastRemindDay: dayKey(new Date()) });
- }
-
- function removeRemindBanner() {
- var el = global.document && global.document.getElementById(REMIND_BANNER_ID);
- if (el && el.parentNode) {
- el.parentNode.removeChild(el);
- }
- }
-
- function renderRemindBanner(reminder) {
- if (!global.document || !global.document.body || !reminder) {
- return;
- }
-
- removeRemindBanner();
-
- var banner = global.document.createElement('div');
- banner.id = REMIND_BANNER_ID;
- banner.className = 'external-backup-banner external-backup-banner--' + (reminder.level || 'info');
- banner.setAttribute('role', 'status');
-
- var glass = global.document.createElement('div');
- glass.className = 'external-backup-banner__glass';
-
- var text = global.document.createElement('div');
- text.className = 'external-backup-banner__text';
- var title = global.document.createElement('strong');
- title.textContent = reminder.title;
- var msg = global.document.createElement('span');
- msg.textContent = reminder.message;
- text.appendChild(title);
- text.appendChild(msg);
-
- var actions = global.document.createElement('div');
- actions.className = 'external-backup-banner__actions';
-
- function makeBtn(label, action, primary) {
- var btn = global.document.createElement('button');
- btn.type = 'button';
- btn.className = primary
- ? 'btn external-backup-banner__btn external-backup-banner__btn--primary'
- : 'btn external-backup-banner__btn external-backup-banner__btn--ghost';
- btn.textContent = label;
- btn.addEventListener('click', function () {
- handleReminderAction(action);
- });
- return btn;
- }
-
- if (reminder.primaryAction) {
- actions.appendChild(makeBtn(reminder.primaryLabel || '确定', reminder.primaryAction, true));
- }
- if (reminder.secondaryAction) {
- actions.appendChild(makeBtn(reminder.secondaryLabel || '取消', reminder.secondaryAction, false));
- }
-
- var dismiss = global.document.createElement('button');
- dismiss.type = 'button';
- dismiss.className = 'external-backup-banner__dismiss';
- dismiss.setAttribute('aria-label', '关闭提醒');
- dismiss.textContent = '×';
- dismiss.addEventListener('click', function () {
- markReminded();
- removeRemindBanner();
- });
-
- glass.appendChild(text);
- glass.appendChild(actions);
- glass.appendChild(dismiss);
- banner.appendChild(glass);
- global.document.body.appendChild(banner);
- markReminded();
- }
-
- async function handleReminderAction(action) {
- try {
- if (action === 'bind') {
- var bound = await bindDirectory({ writeNow: true });
- removeRemindBanner();
- if (bound.writeResult && bound.writeResult.success) {
- notify('已绑定并写入本地备份:' + (bound.directoryName || ''), 'success');
- } else {
- notify('已绑定文件夹:' + (bound.directoryName || '') + ',请点击「立即写入备份」', 'info');
- }
- refreshUi();
- return;
- }
- if (action === 'reauth' || action === 'write') {
- var result = await writeToBoundDirectory({ interactive: true, force: true });
- removeRemindBanner();
- if (result.success) {
- notify(result.skipped ? '备份已是最新' : '本地备份已写入', 'success');
- } else if (result.reason === 'permission_denied') {
- notify('仍未获得文件夹权限,请在浏览器弹窗中允许访问', 'warning');
- } else if (result.reason === 'unbound') {
- notify('尚未绑定备份文件夹', 'warning');
- } else {
- notify('写入失败:' + (result.error && result.error.message ? result.error.message : result.reason), 'error');
- }
- refreshUi();
- return;
- }
- if (action === 'unbind') {
- await unbindDirectory();
- removeRemindBanner();
- notify('已解除本地备份文件夹绑定', 'info');
- refreshUi();
- }
- } catch (error) {
- if (error && error.name === 'AbortError') {
- notify('已取消', 'info');
- return;
- }
- console.error('[ExternalBackup] reminder action failed:', error);
- notify(error && error.message ? error.message : '操作失败', 'error');
- }
- }
-
- function getStatus() {
- var meta = state.meta || readMeta();
- var lastWriteAt = meta.lastWriteAt || null;
- var lastWriteAge = lastWriteAt ? (Date.now() - new Date(lastWriteAt).getTime()) : Infinity;
- var staleWrite = !lastWriteAt || !Number.isFinite(lastWriteAge) || lastWriteAge >= STALE_WRITE_MS;
- var permissionGranted = !!(state.directoryHandle && meta.lastPermissionOk);
+ recordDate
+ ) || recordDate;
+ const resolvedTitle = recordData.title
+ || metadata.examTitle
+ || metadata.title
+ || recordData.examTitle
+ || recordData.examName
+ || recordData.name
+ || recordData.examId
+ || '未命名练习';
+ const normalizedSuiteEntries = standardizeSuiteEntries(recordData.suiteEntries || []);
+ const normalizedComparison = comparisonSource && typeof comparisonSource === 'object'
+ ? clonePlainObject(comparisonSource)
+ : null;
+ const realDataCorrectAnswers = clonePlainObject(normalizedCorrectMap || {});
+ const annotations = resolveAnnotationState(recordData, [recordData.metadata]);
+ metadata.markedQuestions = clonePlainObject(annotations.markedQuestions);
+ const generateRecordId = typeof options.generateRecordId === 'function'
+ ? options.generateRecordId
+ : defaultGenerateRecordId;
return {
- supported: supportsFileSystemAccess(),
- secureContext: global.isSecureContext !== false,
- bound: !!(state.directoryHandle && meta.enabled),
- directoryName: meta.directoryName || null,
- permissionGranted: permissionGranted,
- lastWriteAt: lastWriteAt,
- lastWriteOk: !!meta.lastWriteOk,
- lastWriteError: meta.lastWriteError || null,
- lastWriteAgeMs: Number.isFinite(lastWriteAge) ? lastWriteAge : null,
- staleWrite: staleWrite,
- dirty: !!state.dirty,
- writing: !!state.writing,
- recordCountAtLastWrite: meta.recordCountAtLastWrite || 0,
- latestFilename: LATEST_FILENAME,
- lastRemindDay: meta.lastRemindDay || null
+ id: recordId || generateRecordId(),
+ examId: resolvedExamId,
+ sessionId: recordData.sessionId || recordData.sessionID || null,
+ title: resolvedTitle,
+ type,
+ startTime,
+ endTime,
+ duration: resolveDurationSeconds(recordData, startTime, endTime),
+ date: recordDate,
+ status: recordData.status || 'completed',
+ score: ensureNumber(recordData.score ?? recordData.finalScore ?? (recordData.realData && recordData.realData.score), correctAnswers),
+ totalQuestions,
+ correctAnswers,
+ accuracy,
+ answers: normalizedAnswers,
+ answerDetails: detailSource || null,
+ correctAnswerMap: normalizedCorrectMap || {},
+ questionTypePerformance: recordData.questionTypePerformance || {},
+ metadata,
+ frequency: recordData.frequency || metadata.frequency || null,
+ suiteMode: Boolean(recordData.suiteMode || ((recordData.frequency || metadata.frequency || '').toLowerCase() === 'suite')),
+ suiteSessionId: recordData.suiteSessionId || (metadata && metadata.suiteSessionId) || null,
+ suiteEntries: normalizedSuiteEntries,
+ ...annotations,
+ scoreInfo: recordData.scoreInfo
+ ? Object.assign({}, recordData.scoreInfo, {
+ details: recordData.scoreInfo.details || detailSource || null
+ })
+ : (detailSource ? { details: detailSource } : null),
+ realData: Object.assign({}, recordData.realData || {}, {
+ answers: (recordData.realData && recordData.realData.answers) || answerMap,
+ correctAnswers: realDataCorrectAnswers,
+ correctAnswerMap: clonePlainObject(normalizedCorrectMap || {}),
+ scoreInfo: Object.assign({}, (recordData.realData && recordData.realData.scoreInfo) || {}, {
+ details: (recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details) || detailSource || null
+ }),
+ answerComparison: (recordData.realData && recordData.realData.answerComparison)
+ ? clonePlainObject(recordData.realData.answerComparison)
+ : (normalizedComparison || null),
+ ...clonePlainObject(annotations)
+ }),
+ answerComparison: normalizedComparison,
+ version: options.currentVersion || recordData.version || '0.6.2-fix',
+ createdAt: firstDateCandidate(recordData.createdAt, recordData.startTime, recordData.start_time, recordDate) || now,
+ updatedAt: firstDateCandidate(recordData.updatedAt, recordData.endTime, recordData.end_time, now) || now
};
}
- async function refreshPermissionFlag() {
- if (!state.directoryHandle) {
- writeMeta({ lastPermissionOk: false });
- return false;
- }
- var ok = await ensurePermission(state.directoryHandle, false);
- return ok;
- }
-
- async function maybeShowDailyReminder(options) {
- var opts = options || {};
- await ensureReady();
- await refreshPermissionFlag();
- var status = getStatus();
- var reminder = buildReminder(status);
- if (!reminder) {
- if (opts.force) {
- removeRemindBanner();
- }
- return null;
- }
- if (opts.force || shouldShowDailyReminder(reminder)) {
- if (opts.render !== false) {
- renderRemindBanner(reminder);
- }
- return reminder;
- }
- return null;
- }
-
- async function maybePromptEmptyStoreRecovery() {
- await ensureReady();
- var count = await countPracticeRecords();
- if (count > 0) {
- return false;
- }
- var readable = await hasReadableLatestBackup();
- if (!readable) {
- return false;
- }
-
- var meta = state.meta || readMeta();
- var today = dayKey(new Date());
- if (meta.lastRestorePromptDay === today) {
- return false;
- }
- writeMeta({ lastRestorePromptDay: today });
-
- var dirName = (state.meta && state.meta.directoryName) || '备份文件夹';
- var ok = false;
- try {
- ok = global.confirm(
- '检测到浏览器内练习记录为空,但本地备份文件夹「' + dirName +
- '」中有 ' + LATEST_FILENAME + '。是否立即恢复?'
- );
- } catch (_) {
- ok = false;
- }
- if (!ok) {
- return false;
- }
- try {
- await restoreFromLatest({ interactive: true });
- notify('已从本地备份文件夹恢复数据', 'success');
- try {
- if (typeof global.updateOverview === 'function') {
- global.updateOverview();
- }
- } catch (_) { /* ignore */ }
- try {
- global.dispatchEvent(new CustomEvent('practiceRecordsUpdated', {
- detail: { source: 'external-backup-restore' }
- }));
- } catch (_) { /* ignore */ }
- return true;
- } catch (error) {
- console.error('[ExternalBackup] restore failed:', error);
- notify('恢复失败:' + (error && error.message ? error.message : error), 'error');
- return false;
- }
- }
-
- function markDirty() {
- state.dirty = true;
- dispatchStatus();
- scheduleSilentFlush();
- }
-
- /**
- * When folder permission is already granted, write silently after data changes.
- * Never auto-downloads; never prompts for permission here.
- */
- function scheduleSilentFlush() {
- if (state.silentFlushTimer) {
- global.clearTimeout(state.silentFlushTimer);
- }
- state.silentFlushTimer = global.setTimeout(function () {
- state.silentFlushTimer = null;
- flushSilentlyIfPermitted().catch(function (error) {
- console.warn('[ExternalBackup] silent flush failed:', error);
- });
- }, 8000);
- }
-
- async function flushSilentlyIfPermitted() {
- await ensureReady();
- if (!state.directoryHandle || !state.dirty || state.writing) {
- return { success: false, reason: 'skip' };
- }
- var permitted = await ensurePermission(state.directoryHandle, false);
- if (!permitted) {
- // Permission missing: daily banner handles re-auth; do not prompt here.
- return { success: false, reason: 'permission_denied' };
- }
- return writeToBoundDirectory({ interactive: false, force: false });
- }
-
- function refreshUi() {
- try {
- if (typeof global.refreshExternalBackupPanel === 'function') {
- global.refreshExternalBackupPanel();
- }
- } catch (_) { /* ignore */ }
- dispatchStatus();
- }
-
- function formatStatusText(status) {
- if (!status.supported) {
- return '当前环境不支持文件夹绑定(请用 Chrome/Edge 通过 http(s) 打开;file:// 下请用「导出到下载」)。';
- }
- if (!status.bound) {
- return '未绑定本地备份文件夹。绑定后可一键写入磁盘,避免清缓存丢数据。';
- }
- var parts = [];
- parts.push('已绑定:' + (status.directoryName || '文件夹'));
- if (!status.permissionGranted) {
- parts.push('权限失效,需重新授权');
- } else if (status.lastWriteAt) {
- parts.push('上次写入 ' + formatTime(status.lastWriteAt));
- if (status.lastWriteOk === false) {
- parts.push('最近一次写入失败');
- }
- } else {
- parts.push('尚未写入');
- }
- if (status.dirty) {
- parts.push('有未备份的新数据');
- }
- return parts.join(' · ');
- }
-
- function formatEntryLabel(status) {
- if (!status.supported) {
- return '📁 本地磁盘备份';
- }
- if (!status.bound) {
- return '📁 本地磁盘备份';
- }
- if (!status.permissionGranted) {
- return '📁 本地备份 · 需授权';
- }
- if (status.staleWrite || status.dirty) {
- return '📁 本地备份 · 待更新';
- }
- return '📁 本地备份 · 已就绪';
- }
-
- var ENTRY_ID = 'external-backup-entry-btn';
- var MODAL_ID = 'external-backup-modal';
- var modalBound = false;
-
- function getModal() {
- return global.document ? global.document.getElementById(MODAL_ID) : null;
- }
-
- function openModal() {
- ensureModalDom();
- var modal = getModal();
- if (modal) {
- modal.classList.add('show');
- refreshExternalBackupPanel();
- }
- }
-
- function closeModal() {
- var modal = getModal();
- if (modal) {
- modal.classList.remove('show');
- }
- }
-
- function makeActionButton(id, label) {
- var btn = global.document.createElement('button');
- btn.type = 'button';
- btn.className = 'btn data-mgmt-btn';
- btn.id = id;
- btn.textContent = label;
- return btn;
- }
-
- function ensureEntryButton() {
- var panel = global.document && global.document.querySelector('#settings-view .data-management-panel');
- if (!panel) {
- return null;
+ function extractEnvelopeData(envelope) {
+ const candidates = [envelope.data, envelope.payload, envelope.detail];
+ for (let i = 0; i < candidates.length; i += 1) {
+ const candidate = candidates[i];
+ if (isPlainObject(candidate)) return candidate;
+ if (typeof candidate === 'string') {
+ const parsed = safeParseJson(candidate);
+ if (isPlainObject(parsed)) return parsed;
+ }
}
- var entry = global.document.getElementById(ENTRY_ID);
- if (entry) {
- return entry;
+ if (Array.isArray(envelope.args)) {
+ for (let i = 0; i < envelope.args.length; i += 1) {
+ const candidate = envelope.args[i];
+ if (isPlainObject(candidate)) return candidate;
+ }
}
+ const fallback = {};
+ const baseKeys = new Set(['type', 'messageType', 'action', 'event', 'data', 'payload', 'detail', 'args', 'source', 'message', 'messageData']);
+ let hasFallback = false;
+ Object.keys(envelope || {}).forEach((key) => {
+ if (!baseKeys.has(key)) {
+ fallback[key] = envelope[key];
+ hasFallback = true;
+ }
+ });
+ return hasFallback ? fallback : {};
+ }
- var actions = panel.querySelector('.hero-settings-actions');
- if (!actions) {
- return null;
+ function normalizeMessageType(value) {
+ if (typeof value !== 'string') {
+ return '';
}
-
- entry = global.document.createElement('button');
- entry.type = 'button';
- entry.className = 'btn data-mgmt-btn';
- entry.id = ENTRY_ID;
- entry.textContent = '📁 本地磁盘备份';
-
- // Prefer leading position so the recommended action is easy to find.
- if (actions.firstChild) {
- actions.insertBefore(entry, actions.firstChild);
- } else {
- actions.appendChild(entry);
+ const normalized = value.trim();
+ if (!normalized) {
+ return '';
}
- return entry;
+ return MESSAGE_TYPE_ALIASES[normalized] || normalized.toUpperCase();
}
- function ensureModalDom() {
- if (!global.document || !global.document.body) {
+ function normalizeMessage(rawEnvelope, depth = 0) {
+ if (depth > 2) {
return null;
}
- var modal = getModal();
- if (modal) {
- if (!modalBound) {
- bindModalEvents(modal);
- }
- return modal;
+ let envelope = rawEnvelope;
+ if (typeof envelope === 'string') {
+ envelope = safeParseJson(envelope);
+ }
+ if (!isPlainObject(envelope)) {
+ return null;
}
- modal = global.document.createElement('div');
- modal.id = MODAL_ID;
- modal.className = 'theme-modal external-backup-modal shui-secondary-modal shui-secondary-modal--sm';
- modal.setAttribute('role', 'dialog');
- modal.setAttribute('aria-modal', 'true');
- modal.setAttribute('aria-labelledby', 'external-backup-title');
-
- var content = global.document.createElement('div');
- content.className = 'theme-modal-content external-backup-modal__content shui-secondary-modal__content';
-
- var header = global.document.createElement('div');
- header.className = 'theme-modal-header external-backup-modal__header shui-secondary-modal__header';
-
- var titleGroup = global.document.createElement('div');
- titleGroup.className = 'external-backup-modal__title-group shui-secondary-modal__title-group';
-
- var eyebrow = global.document.createElement('div');
- eyebrow.className = 'external-backup-modal__eyebrow shui-secondary-modal__eyebrow';
- eyebrow.textContent = 'DISK BACKUP';
-
- var title = global.document.createElement('h3');
- title.id = 'external-backup-title';
- title.textContent = '本地磁盘备份';
-
- titleGroup.appendChild(eyebrow);
- titleGroup.appendChild(title);
-
- var closeBtn = global.document.createElement('button');
- closeBtn.type = 'button';
- closeBtn.className = 'theme-modal-close';
- closeBtn.setAttribute('aria-label', '关闭');
- closeBtn.innerHTML = '×';
-
- header.appendChild(titleGroup);
- header.appendChild(closeBtn);
-
- var body = global.document.createElement('div');
- body.className = 'theme-modal-body external-backup-modal__body shui-secondary-modal__body';
-
- var host = global.document.createElement('div');
- host.id = 'external-backup-panel';
- host.className = 'external-backup-panel external-backup-panel--modal';
-
- var desc = global.document.createElement('p');
- desc.className = 'external-backup-panel__desc';
- desc.textContent = '绑定本地文件夹后,可把练习数据写入磁盘 JSON。清浏览器缓存不会删除该文件夹中的文件;已授权时可在后台静默更新;每天最多提醒一次,且不会自动下载。';
-
- var statusCard = global.document.createElement('div');
- statusCard.className = 'external-backup-status-card';
-
- var statusLabel = global.document.createElement('div');
- statusLabel.className = 'external-backup-status-card__label';
- statusLabel.textContent = '当前状态';
-
- var status = global.document.createElement('div');
- status.id = 'external-backup-status';
- status.className = 'external-backup-panel__status';
- status.textContent = '状态加载中…';
-
- statusCard.appendChild(statusLabel);
- statusCard.appendChild(status);
-
- var tips = global.document.createElement('ul');
- tips.className = 'external-backup-panel__tips';
- [
- '推荐使用 Chrome / Edge,通过 http(s) 或 localhost 打开',
- 'file:// 环境通常无法绑定文件夹,请改用「导出到下载」',
- '应用内备份只防导入误操作,防不了清缓存'
- ].forEach(function (line) {
- var li = global.document.createElement('li');
- li.textContent = line;
- tips.appendChild(li);
- });
-
- var actions = global.document.createElement('div');
- actions.className = 'external-backup-panel__actions';
-
- var bindBtn = makeActionButton('external-backup-bind-btn', '📁 绑定备份文件夹');
- var writeBtn = makeActionButton('external-backup-write-btn', '💾 立即写入备份');
- var restoreBtn = makeActionButton('external-backup-restore-btn', '♻️ 从文件夹恢复');
- var unbindBtn = makeActionButton('external-backup-unbind-btn', '🔓 解除绑定');
- unbindBtn.classList.add('external-backup-btn--ghost');
-
- actions.appendChild(bindBtn);
- actions.appendChild(writeBtn);
- actions.appendChild(restoreBtn);
- actions.appendChild(unbindBtn);
-
- host.appendChild(desc);
- host.appendChild(statusCard);
- host.appendChild(tips);
- host.appendChild(actions);
- body.appendChild(host);
-
- content.appendChild(header);
- content.appendChild(body);
- modal.appendChild(content);
- global.document.body.appendChild(modal);
-
- bindBtn.addEventListener('click', async function () {
- try {
- await ensureReady();
- var result = await bindDirectory({ writeNow: true });
- if (result.writeResult && result.writeResult.success) {
- notify('已绑定并写入:' + result.directoryName, 'success');
- } else {
- notify('已绑定:' + result.directoryName, 'success');
- }
- } catch (error) {
- if (error && error.name === 'AbortError') {
- notify('已取消选择文件夹', 'info');
- } else {
- notify(error && error.message ? error.message : '绑定失败', 'error');
- }
- } finally {
- refreshExternalBackupPanel();
- }
- });
-
- writeBtn.addEventListener('click', async function () {
- try {
- await ensureReady();
- var result = await writeToBoundDirectory({ interactive: true, force: true });
- if (result.success) {
- notify(result.skipped ? '备份内容无变化' : ('已写入 ' + (result.filename || LATEST_FILENAME)), 'success');
- } else if (result.reason === 'unbound') {
- notify('请先绑定备份文件夹', 'warning');
- } else if (result.reason === 'permission_denied') {
- notify('需要允许文件夹访问权限', 'warning');
- } else {
- notify('写入失败:' + (result.error && result.error.message ? result.error.message : result.reason), 'error');
- }
- } catch (error) {
- notify(error && error.message ? error.message : '写入失败', 'error');
- } finally {
- refreshExternalBackupPanel();
- }
- });
-
- restoreBtn.addEventListener('click', async function () {
- try {
- await ensureReady();
- var statusNow = getStatus();
- if (!statusNow.bound) {
- await pickAndRestoreFile();
- notify('已从文件恢复(或已打开导入流程)', 'success');
- return;
- }
- var ok = true;
- try {
- ok = global.confirm('将用文件夹中的 ' + LATEST_FILENAME + ' 覆盖/恢复练习数据,是否继续?');
- } catch (_) { /* ignore */ }
- if (!ok) {
- return;
- }
- await restoreFromLatest({ interactive: true });
- notify('已从本地备份文件夹恢复', 'success');
- try {
- if (typeof global.updateOverview === 'function') {
- global.updateOverview();
- }
- } catch (_) { /* ignore */ }
- } catch (error) {
- if (error && error.name === 'AbortError') {
- notify('已取消', 'info');
- } else {
- notify(error && error.message ? error.message : '恢复失败', 'error');
- }
- } finally {
- refreshExternalBackupPanel();
- }
- });
+ const rawType = envelope.type || envelope.messageType || envelope.action || envelope.event || '';
+ const type = normalizeMessageType(rawType);
- unbindBtn.addEventListener('click', async function () {
- try {
- await ensureReady();
- var ok = true;
- try {
- ok = global.confirm('解除绑定后将不再写入该文件夹(磁盘上的备份文件仍保留)。确定?');
- } catch (_) { /* ignore */ }
- if (!ok) {
- return;
- }
- await unbindDirectory();
- notify('已解除绑定', 'info');
- } catch (error) {
- notify(error && error.message ? error.message : '解除绑定失败', 'error');
- } finally {
- refreshExternalBackupPanel();
+ if (!type) {
+ const nested = envelope.message || envelope.messageData;
+ if (nested) {
+ return normalizeMessage(nested, depth + 1);
}
- });
-
- bindModalEvents(modal);
- return modal;
- }
-
- function bindModalEvents(modal) {
- if (!modal || modalBound) {
- return;
- }
- modalBound = true;
-
- var closeBtn = modal.querySelector('.theme-modal-close');
- if (closeBtn) {
- closeBtn.addEventListener('click', closeModal);
+ return null;
}
- modal.addEventListener('click', function (event) {
- if (event.target === modal) {
- closeModal();
- }
- });
- global.document.addEventListener('keydown', function (event) {
- if (event.key === 'Escape' && modal.classList.contains('show')) {
- closeModal();
- }
- });
- var entry = ensureEntryButton();
- if (entry && !entry.__externalBackupBound) {
- entry.__externalBackupBound = true;
- entry.addEventListener('click', function (event) {
- event.preventDefault();
- openModal();
- });
- }
- }
+ const data = extractEnvelopeData(envelope);
+ const sourceTag = typeof envelope.source === 'string'
+ ? envelope.source
+ : (typeof data.source === 'string' ? data.source : '');
- function ensurePanelDom() {
- // Compact entry on settings page + secondary modal body.
- ensureEntryButton();
- var modal = ensureModalDom();
- return modal ? modal.querySelector('#external-backup-panel') : null;
+ return { type, data: isPlainObject(data) ? data : {}, sourceTag, rawType: rawType || type };
}
- function refreshExternalBackupPanel() {
- var host = ensurePanelDom();
- var status = getStatus();
-
- var entry = global.document && global.document.getElementById(ENTRY_ID);
- if (entry) {
- entry.textContent = formatEntryLabel(status);
- entry.dataset.state = status.bound
- ? (status.permissionGranted ? (status.staleWrite || status.dirty ? 'stale' : 'ok') : 'need-auth')
- : (status.supported ? 'unbound' : 'unsupported');
- entry.title = formatStatusText(status);
- }
-
- if (!host) {
- return;
- }
-
- var statusEl = host.querySelector('#external-backup-status');
- if (statusEl) {
- statusEl.textContent = formatStatusText(status);
- statusEl.dataset.state = status.bound
- ? (status.permissionGranted ? (status.staleWrite || status.dirty ? 'stale' : 'ok') : 'need-auth')
- : (status.supported ? 'unbound' : 'unsupported');
- }
-
- var writeBtn = host.querySelector('#external-backup-write-btn');
- var unbindBtn = host.querySelector('#external-backup-unbind-btn');
- var restoreBtn = host.querySelector('#external-backup-restore-btn');
- var bindBtn = host.querySelector('#external-backup-bind-btn');
-
- if (bindBtn) {
- bindBtn.disabled = !status.supported;
- bindBtn.textContent = status.bound ? '📁 更换备份文件夹' : '📁 绑定备份文件夹';
- }
- if (writeBtn) {
- writeBtn.disabled = !status.bound || status.writing;
- }
- if (unbindBtn) {
- unbindBtn.disabled = !status.bound;
- }
- if (restoreBtn) {
- restoreBtn.disabled = false;
+ function isPracticeCompleteType(type) {
+ if (!type) {
+ return false;
}
+ return PRACTICE_COMPLETE_TYPES.has(type) || normalizeMessageType(type) === 'PRACTICE_COMPLETE';
}
- function onStorageSync(event) {
- var key = event && event.detail ? event.detail.key : null;
- if (!key || key === '*' || key === 'practice_records' || key === 'user_stats' ||
- String(key).indexOf('practice') !== -1 || String(key).indexOf('vocab') !== -1) {
- markDirty();
- }
+ function buildEnvelope(type, data) {
+ return {
+ type,
+ data: isPlainObject(data) ? data : {}
+ };
}
- async function ensureReady() {
- if (state.ready) {
- return true;
+ function deriveCategory(recordPayload = {}, examEntry = null, metadata = {}) {
+ if (metadata.category) {
+ return metadata.category;
}
- if (state.readyPromise) {
- return state.readyPromise;
+ if (recordPayload.category) {
+ return recordPayload.category;
}
- state.readyPromise = (async function () {
- state.meta = readMeta();
- try {
- var handle = await loadDirectoryHandle();
- if (handle) {
- state.directoryHandle = handle;
- var ok = await ensurePermission(handle, false);
- writeMeta({
- enabled: true,
- directoryName: handle.name || state.meta.directoryName || 'backup',
- lastPermissionOk: ok
- });
- }
- } catch (error) {
- console.warn('[ExternalBackup] load handle failed:', error);
- }
- state.ready = true;
- return true;
- })();
- return state.readyPromise;
- }
-
- async function init() {
- await ensureReady();
- ensurePanelDom();
- refreshExternalBackupPanel();
- await requestPersistentStorage();
-
- // Daily reminder + empty-store recovery (deferred so PracticeRecordAPI can boot)
- global.setTimeout(function () {
- maybeShowDailyReminder({ render: true }).catch(function (error) {
- console.warn('[ExternalBackup] daily reminder failed:', error);
- });
- maybePromptEmptyStoreRecovery().catch(function (error) {
- console.warn('[ExternalBackup] recovery prompt failed:', error);
- });
- }, 1800);
-
- // Re-check when user returns to the tab (still at most once/day)
- if (global.document) {
- global.document.addEventListener('visibilitychange', function () {
- if (global.document.visibilityState === 'visible') {
- maybeShowDailyReminder({ render: true }).catch(function () { /* ignore */ });
- refreshExternalBackupPanel();
- } else if (global.document.visibilityState === 'hidden') {
- // Best-effort silent write when leaving the tab (no permission prompt)
- flushSilentlyIfPermitted().catch(function () { /* ignore */ });
- }
- });
+ if (examEntry && examEntry.category) {
+ return examEntry.category;
}
+ if (recordPayload.pageType) {
+ return recordPayload.pageType;
+ }
+ if (recordPayload.url) {
+ const match = String(recordPayload.url).match(/\b(P[1-4])\b/i);
+ if (match) return match[1].toUpperCase();
+ }
+ if (recordPayload.title) {
+ const match = String(recordPayload.title).match(/\b(P[1-4])\b/i);
+ if (match) return match[1].toUpperCase();
+ }
+ return 'Unknown';
}
- // Listen for data changes early
- try {
- global.addEventListener('storage-sync', onStorageSync);
- global.addEventListener('practiceRecordsUpdated', markDirty);
- } catch (_) { /* ignore */ }
-
- global.ExternalBackupService = {
- __stable: true,
- LATEST_FILENAME: LATEST_FILENAME,
- supportsFileSystemAccess: supportsFileSystemAccess,
- ensureReady: ensureReady,
- init: init,
- openModal: openModal,
- closeModal: closeModal,
- bindDirectory: bindDirectory,
- unbindDirectory: unbindDirectory,
- writeNow: function (options) {
- return writeToBoundDirectory(Object.assign({ interactive: true, force: true }, options || {}));
- },
- restoreFromLatest: restoreFromLatest,
- pickAndRestoreFile: pickAndRestoreFile,
- getStatus: getStatus,
- formatStatusText: formatStatusText,
- maybeShowDailyReminder: maybeShowDailyReminder,
- maybePromptEmptyStoreRecovery: maybePromptEmptyStoreRecovery,
- markDirty: markDirty,
- flushSilentlyIfPermitted: flushSilentlyIfPermitted,
- refreshPanel: refreshExternalBackupPanel,
- requestPersistentStorage: requestPersistentStorage
- };
-
- global.refreshExternalBackupPanel = refreshExternalBackupPanel;
-
- function boot() {
- init().catch(function (error) {
- console.warn('[ExternalBackup] init failed:', error);
- });
- }
-
- if (global.document && global.document.readyState === 'loading') {
- global.document.addEventListener('DOMContentLoaded', boot);
- } else {
- boot();
+ function deriveFrequency(recordPayload = {}, examEntry = null, metadata = {}) {
+ return recordPayload.frequency
+ || metadata.frequency
+ || (examEntry && examEntry.frequency)
+ || 'unknown';
}
-})(typeof window !== 'undefined' ? window : globalThis);
+ function fromCompletion(payload, sessionContext = {}, examEntry = null, options = {}) {
+ const normalizedMessage = normalizeMessage(payload);
+ const rawPayload = normalizedMessage && isPracticeCompleteType(normalizedMessage.type)
+ ? normalizedMessage.data
+ : (isPlainObject(payload) ? payload : {});
-/* ===== js/core/practiceStore.js ===== */
-(function initPracticeStore(global) {
- 'use strict';
-
- function getPracticeRecordAPI() {
- if (!global.PracticeRecordAPI) {
- throw new Error('PracticeStore: PracticeRecordAPI not ready');
+ if (!rawPayload || typeof rawPayload !== 'object') {
+ return null;
}
- return global.PracticeRecordAPI;
- }
- async function list() {
- var api = getPracticeRecordAPI();
- if (typeof api.list !== 'function') {
- throw new Error('PracticeStore.list: PracticeRecordAPI.list not ready');
+ const scoreInfo = Object.assign({}, rawPayload.scoreInfo || {});
+ const metadata = Object.assign({}, sessionContext.metadata || {}, rawPayload.metadata || {});
+ const resolvedExamId = rawPayload.examId
+ || sessionContext.examId
+ || metadata.examId
+ || (examEntry && examEntry.id)
+ || null;
+ const answerComparison = normalizeAnswerComparison(
+ rawPayload.answerComparison || (rawPayload.realData && rawPayload.realData.answerComparison) || null
+ );
+ const answerMap = mergeAnswerSources(
+ rawPayload.answerMap,
+ rawPayload.answers,
+ rawPayload.realData && rawPayload.realData.answers,
+ sessionContext.answers,
+ convertComparisonToMap(answerComparison, 'userAnswer')
+ );
+ const correctAnswerMap = mergeAnswerSources(
+ rawPayload.correctAnswerMap,
+ rawPayload.realData && rawPayload.realData.correctAnswerMap,
+ sessionContext.correctAnswerMap,
+ rawPayload.correctAnswers,
+ rawPayload.realData && rawPayload.realData.correctAnswers,
+ deriveCorrectMapFromDetails(scoreInfo.details),
+ deriveCorrectMapFromDetails(rawPayload.realData && rawPayload.realData.scoreInfo && rawPayload.realData.scoreInfo.details),
+ convertComparisonToMap(answerComparison, 'correctAnswer')
+ );
+ const answerDetails = rawPayload.answerDetails
+ || scoreInfo.details
+ || (rawPayload.realData && rawPayload.realData.scoreInfo && rawPayload.realData.scoreInfo.details)
+ || buildAnswerDetails(answerMap, correctAnswerMap);
+ const answerList = buildAnswerArray(answerMap, correctAnswerMap);
+ const totalQuestions = ensureNumber(
+ rawPayload.totalQuestions ?? scoreInfo.total ?? scoreInfo.totalQuestions,
+ Object.keys(correctAnswerMap).length || Object.keys(answerMap).length
+ );
+ const correctAnswers = ensureNumber(
+ rawPayload.correctAnswers ?? rawPayload.correctAnswersCount ?? scoreInfo.correct ?? scoreInfo.score ?? rawPayload.score,
+ deriveCorrectAnswerCount({ answerDetails, scoreInfo }, answerList)
+ );
+ let accuracy = typeof rawPayload.accuracy === 'number'
+ ? rawPayload.accuracy
+ : (typeof scoreInfo.accuracy === 'number'
+ ? scoreInfo.accuracy
+ : (totalQuestions > 0 ? correctAnswers / totalQuestions : 0));
+ if (accuracy > 1 && accuracy <= 100) {
+ accuracy = accuracy / 100;
}
- var records = await api.list();
- return Array.isArray(records) ? records : [];
- }
+ const percentage = typeof scoreInfo.percentage === 'number'
+ ? scoreInfo.percentage
+ : Math.round(accuracy * 100);
+ const completedAt = resolveRecordDate({
+ metadata,
+ date: rawPayload.date,
+ endTime: rawPayload.endTime,
+ completedAt: rawPayload.completedAt,
+ startTime: rawPayload.startTime,
+ timestamp: rawPayload.timestamp
+ });
+ const duration = ensureNumber(
+ rawPayload.duration,
+ (rawPayload.endTime && rawPayload.startTime)
+ ? Math.round((new Date(rawPayload.endTime) - new Date(rawPayload.startTime)) / 1000)
+ : ensureNumber(sessionContext.duration, 0)
+ );
+ const startTime = rawPayload.startTime
+ ? new Date(rawPayload.startTime).toISOString()
+ : (sessionContext.startTime
+ ? new Date(sessionContext.startTime).toISOString()
+ : new Date(new Date(completedAt).getTime() - duration * 1000).toISOString());
+ const endTime = rawPayload.endTime
+ ? new Date(rawPayload.endTime).toISOString()
+ : completedAt;
+ const category = deriveCategory(rawPayload, examEntry, metadata);
+ const frequency = deriveFrequency(rawPayload, examEntry, metadata);
+ const title = rawPayload.title
+ || metadata.examTitle
+ || metadata.title
+ || (examEntry && examEntry.title)
+ || resolvedExamId
+ || '未命名练习';
+ const annotations = resolveAnnotationState(rawPayload, [sessionContext]);
+ const resolvedQuestionTypeMap = isPlainObject(rawPayload.questionTypeMap)
+ ? clonePlainObject(rawPayload.questionTypeMap)
+ : (isPlainObject(rawPayload.realData && rawPayload.realData.questionTypeMap)
+ ? clonePlainObject(rawPayload.realData.questionTypeMap)
+ : {});
+ const suiteEntries = rawPayload.suiteEntries || metadata.suiteEntries || [];
+ const suiteSessionId = rawPayload.suiteSessionId || metadata.suiteSessionId || sessionContext.suiteSessionId || null;
- async function replace(records, options) {
- var finalRecords = Array.isArray(records) ? records : [];
- var api = getPracticeRecordAPI();
- if (typeof api.replace !== 'function') {
- throw new Error('PracticeStore.replace: PracticeRecordAPI.replace not ready');
- }
- await api.replace(finalRecords, Object.assign({ updateStats: true }, options || {}));
- return true;
+ return standardizeRecord({
+ id: rawPayload.id,
+ examId: resolvedExamId,
+ sessionId: rawPayload.sessionId || sessionContext.sessionId || null,
+ title,
+ type: rawPayload.type || metadata.type || metadata.examType || (examEntry && examEntry.type) || sessionContext.type || null,
+ startTime,
+ endTime,
+ duration,
+ date: completedAt,
+ status: rawPayload.status || 'completed',
+ score: ensureNumber(rawPayload.score ?? scoreInfo.score, correctAnswers),
+ totalQuestions,
+ correctAnswers,
+ accuracy,
+ answers: answerList,
+ answerDetails,
+ correctAnswerMap,
+ answerComparison,
+ questionTypePerformance: rawPayload.questionTypePerformance || {},
+ metadata: Object.assign({}, metadata, {
+ examId: resolvedExamId,
+ examTitle: title,
+ category,
+ frequency,
+ markedQuestions: clonePlainObject(annotations.markedQuestions)
+ }),
+ frequency,
+ suiteMode: Boolean(rawPayload.suiteMode || (String(rawPayload.practiceMode || metadata.practiceMode || '').toLowerCase() === 'suite')),
+ suiteSessionId,
+ suiteEntries,
+ ...annotations,
+ questionTypeMap: resolvedQuestionTypeMap,
+ scoreInfo: Object.assign({}, scoreInfo, {
+ correct: correctAnswers,
+ total: totalQuestions,
+ accuracy,
+ percentage,
+ details: scoreInfo.details || answerDetails,
+ source: scoreInfo.source || rawPayload.pageType || rawPayload.source || 'practice_page'
+ }),
+ realData: Object.assign({}, rawPayload.realData || {}, {
+ answers: answerMap,
+ correctAnswers: correctAnswerMap,
+ answerComparison,
+ correctAnswerMap,
+ ...clonePlainObject(annotations),
+ questionTypeMap: resolvedQuestionTypeMap,
+ scoreInfo: Object.assign({}, (rawPayload.realData && rawPayload.realData.scoreInfo) || scoreInfo, {
+ correct: correctAnswers,
+ total: totalQuestions,
+ accuracy,
+ percentage,
+ details: answerDetails,
+ source: scoreInfo.source || rawPayload.pageType || rawPayload.source || 'practice_page'
+ }),
+ interactions: rawPayload.interactions || [],
+ isRealData: true,
+ source: scoreInfo.source || rawPayload.pageType || rawPayload.source || 'practice_page',
+ sessionId: rawPayload.sessionId || sessionContext.sessionId || null
+ })
+ }, options);
}
- async function save(record, options) {
- var api = getPracticeRecordAPI();
- if (typeof api.saveRecord !== 'function') {
- throw new Error('PracticeStore.save: PracticeRecordAPI.saveRecord not ready');
- }
- return api.saveRecord(record, Object.assign({ updateStats: true }, options || {}));
- }
+ const contracts = Object.freeze({
+ ensureNumber,
+ normalizePracticeType,
+ inferPracticeType,
+ resolveRecordDate,
+ inferExamId,
+ normalizeAnswerValue,
+ isNoiseKey,
+ normalizeAnswerMap,
+ normalizeReplayQuestionKey,
+ normalizeReplayMap,
+ normalizeAnswerComparison,
+ mergeAnswerSources,
+ buildReplayCorrectAnswerMap,
+ buildReplayResultSnapshot,
+ resolveCorrectAnswerMap,
+ resolveRecordCorrectAnswerMap,
+ compareAnswerValues,
+ buildAnswerArray,
+ buildAnswerDetails,
+ deriveCorrectMapFromDetails,
+ deriveCorrectAnswerCount,
+ deriveTotalQuestionCount,
+ convertComparisonToMap,
+ convertComparisonToDetails,
+ buildMetadata,
+ standardizeRecord,
+ standardizeSuiteEntries,
+ resolveAnnotationState,
+ clonePlainObject
+ });
- async function clear(options) {
- var api = getPracticeRecordAPI();
- if (typeof api.clear === 'function') {
- await api.clear(Object.assign({ updateStats: true }, options || {}));
- return true;
- }
- return replace([], options || {});
- }
+ const protocol = Object.freeze({
+ MESSAGE_TYPE_ALIASES,
+ PRACTICE_COMPLETE_TYPES,
+ normalizeMessageType,
+ normalizeMessage,
+ isPracticeCompleteType,
+ buildEnvelope
+ });
+
+ const ingestor = Object.freeze({
+ fromCompletion
+ });
- global.PracticeStore = Object.assign({}, global.PracticeStore || {}, {
- list: list,
- replace: replace,
- save: save,
- clear: clear
+ const practiceCore = Object.freeze({
+ __stable: true,
+ version: '0.6.2-fix',
+ contracts,
+ protocol,
+ ingestor
});
+ global.PracticeCore = practiceCore;
})(typeof window !== 'undefined' ? window : globalThis);
@@ -9245,8 +6812,6 @@ storageManager.ready
const PATH_PROTOCOL_RE = /^(?:[a-z]+:)?\/\//i;
const WINDOWS_DRIVE_RE = /^[A-Za-z]:\\/;
- const PATH_MAP_STORAGE_PREFIX = 'exam_path_map__';
- const BASE_PREFIX_STORAGE_KEY = 'resource.basePrefix';
const PATH_FALLBACK_ORDER = ['map', 'fallback', 'raw', 'relative-up', 'relative-design'];
const RAW_DEFAULT_PATH_MAP = {
reading: {
@@ -9413,10 +6978,6 @@ storageManager.ready
return result;
}
- function getPathMapStorageKey(key) {
- return PATH_MAP_STORAGE_PREFIX + key;
- }
-
function setActivePathMap(map) {
const normalized = normalizePathMap(map);
try { global.__activeLibraryPathMap = normalized; } catch (_) { }
@@ -9435,14 +6996,10 @@ storageManager.ready
}
async function loadPathMapForConfiguration(key) {
- if (!key || !global.storage || typeof global.storage.get !== 'function') {
- return clonePathMap(DEFAULT_PATH_MAP);
- }
+ if (!key || !global.AppData || !global.AppData.library) return clonePathMap(DEFAULT_PATH_MAP);
try {
- const stored = await global.storage.get(getPathMapStorageKey(key));
- if (stored && typeof stored === 'object') {
- return normalizePathMap(stored, DEFAULT_PATH_MAP);
- }
+ const index = await global.AppData.library.getIndex(key);
+ return index.length ? derivePathMapFromIndex(index, DEFAULT_PATH_MAP) : clonePathMap(DEFAULT_PATH_MAP);
} catch (error) {
console.warn('[ResourceCore] 读取路径映射失败:', error);
}
@@ -9459,14 +7016,6 @@ storageManager.ready
? normalizePathMap(overrideMap, fallback)
: derivePathMapFromIndex(exams, fallback);
- if (global.storage && typeof global.storage.set === 'function') {
- try {
- await global.storage.set(getPathMapStorageKey(key), derived);
- } catch (error) {
- console.warn('[ResourceCore] 写入路径映射失败:', error);
- }
- }
-
if (options.setActive) {
setActivePathMap(derived);
}
@@ -9474,25 +7023,16 @@ storageManager.ready
}
async function deletePathMapForConfiguration(key) {
- if (!key || !global.storage || typeof global.storage.remove !== 'function') {
- return false;
- }
- try {
- await global.storage.remove(getPathMapStorageKey(key));
- return true;
- } catch (error) {
- console.warn('[ResourceCore] 删除路径映射失败:', error);
- return false;
- }
+ return Boolean(key);
}
async function refreshPathMap() {
- if (!global.storage || typeof global.storage.get !== 'function') {
+ if (!global.AppData || !global.AppData.library) {
return setActivePathMap(getPathMap());
}
try {
- const key = await global.storage.get('active_exam_index_key', 'exam_index');
- const next = await loadPathMapForConfiguration(key || 'exam_index');
+ const key = await global.AppData.library.getActive();
+ const next = await loadPathMapForConfiguration(key);
return setActivePathMap(next);
} catch (error) {
console.warn('[ResourceCore] 刷新路径映射失败:', error);
@@ -9618,22 +7158,13 @@ storageManager.ready
return null;
}
- function loadStoredBasePrefix() {
- try {
- return localStorage.getItem(BASE_PREFIX_STORAGE_KEY) || '';
- } catch (_) {
- return '';
- }
- }
-
- function storeBasePrefix(value) {
- try {
- if (value) {
- localStorage.setItem(BASE_PREFIX_STORAGE_KEY, value);
- } else {
- localStorage.removeItem(BASE_PREFIX_STORAGE_KEY);
- }
- } catch (_) { }
+ let storedBasePrefix = '';
+
+ function storeBasePrefix(value) {
+ storedBasePrefix = value || '';
+ if (global.AppData && global.AppData.preferences) {
+ global.AppData.preferences.setResourceBasePrefix(storedBasePrefix).catch(() => {});
+ }
}
function getBasePrefix() {
@@ -9642,7 +7173,7 @@ storageManager.ready
return direct;
}
- const stored = normalizeBasePrefix(loadStoredBasePrefix());
+ const stored = normalizeBasePrefix(storedBasePrefix);
if (stored && stored !== './') {
global.RESOURCE_BASE_PREFIX = stored;
return stored;
@@ -9664,6 +7195,13 @@ storageManager.ready
return normalized;
}
+ if (global.AppData && global.AppData.preferences) {
+ global.AppData.preferences.getResourceBasePrefix().then((value) => {
+ storedBasePrefix = value || '';
+ if (!global.RESOURCE_BASE_PREFIX && storedBasePrefix) global.RESOURCE_BASE_PREFIX = normalizeBasePrefix(storedBasePrefix);
+ }).catch(() => {});
+ }
+
function resolveGeneratedReadingRuntimeUrl(exam, kind = 'html') {
if (!exam || kind === 'pdf') {
return '';
@@ -9845,3986 +7383,3651 @@ storageManager.ready
return false;
}
- const resourceProbeCache = new Map();
-
- function probeResource(url) {
- if (!url) {
- return Promise.resolve(false);
- }
- if (resourceProbeCache.has(url)) {
- return resourceProbeCache.get(url);
- }
- const attempt = (async () => {
- if (shouldBypassProbe(url)) {
- return true;
- }
- try {
- const response = await fetch(url, { method: 'HEAD', cache: 'no-store' });
- if (response && (response.ok || response.status === 304 || response.status === 405 || response.type === 'opaque')) {
- return true;
- }
- if (response && response.status >= 400) {
- return false;
- }
- } catch (_) {
- if (shouldBypassProbe(url)) {
- return true;
- }
- }
- return false;
- })();
- resourceProbeCache.set(url, attempt);
- return attempt;
- }
-
- async function resolveResource(exam, kind = 'html') {
- const attempts = getResourceAttempts(exam, kind);
- for (let i = 0; i < attempts.length; i += 1) {
- const entry = attempts[i];
- try {
- const ok = await probeResource(entry.path);
- if (ok) {
- return { url: entry.path, attempts };
- }
- } catch (error) {
- console.warn('[ResourceCore] 资源探测失败:', entry, error);
- }
- }
- return { url: '', attempts };
- }
-
- global.ResourceCore = {
- __stable: true,
- version: '0.6.2-fix',
- RAW_DEFAULT_PATH_MAP,
- DEFAULT_PATH_MAP,
- PATH_MAP_STORAGE_PREFIX,
- PATH_FALLBACK_ORDER,
- clonePathMap,
- normalizePathRoot,
- mergeRootWithFallback,
- buildOverridePathMap,
- derivePathMapFromIndex,
- getPathMapStorageKey,
- getPathMap,
- setActivePathMap,
- loadPathMapForConfiguration,
- savePathMapForConfiguration,
- deletePathMapForConfiguration,
- refreshPathMap,
- getBasePrefix,
- setBasePrefix,
- resolveExamBasePath,
- buildResourcePath,
- getResourceAttempts,
- resolveResource,
- sanitizeFilename,
- encodePathSegments,
- detectScriptBasePrefix,
- normalizeBasePrefix
- };
-})(typeof window !== 'undefined' ? window : globalThis);
-
-
-/* ===== assets/generated/reading-exams/manifest.js ===== */
-(function registerReadingExamManifest(global) {
- 'use strict';
- const PATH_ROOT = {
- "reading": "三月/",
- "listening": "ListeningPractice/"
- };
- const manifest = {
- "p1-high-01": {
- "examId": "p1-high-01",
- "dataKey": "p1-high-01",
- "script": "./p1-high-01.js",
- "title": "A Brief History of Tea 茶叶简史",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/1. P1 - A Brief History of Tea 茶叶简史【高】/",
- "filename": "1. P1 - A Brief History of Tea 茶叶简史【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/1. P1 - A Brief History of Tea 茶叶简史.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-02": {
- "examId": "p1-low-02",
- "dataKey": "p1-low-02",
- "script": "./p1-low-02.js",
- "title": "Maori Fish Hooks 毛利鱼钩",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/10. P1 - Maori Fish Hooks 毛利鱼钩/",
- "filename": "10. P1 - Maori Fish Hooks 毛利鱼钩.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/10. P1 - Maori Fish Hooks 毛利鱼钩.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-03": {
- "examId": "p3-high-03",
- "dataKey": "p3-high-03",
- "script": "./p3-high-03.js",
- "title": "What makes a musical expert_ 音乐天赋",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/100. P3 - What makes a musical expert_ 音乐天赋【高】/",
- "filename": "100. P3 - What makes a musical expert_ 音乐天赋【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/100. P3 - What makes a musical expert_ 音乐天赋.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-04": {
- "examId": "p3-high-04",
- "dataKey": "p3-high-04",
- "script": "./p3-high-04.js",
- "title": "Yawning 打呵欠",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/101. P3 - Yawning 打呵欠【高】/",
- "filename": "101. P3 - Yawning 打呵欠【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/101. P3 - Yawning 打哈欠.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-05": {
- "examId": "p1-high-05",
- "dataKey": "p1-high-05",
- "script": "./p1-high-05.js",
- "title": "Katherine Mansfield 新西兰作家",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/102. P1 - Katherine Mansfield 新西兰作家【高】/",
- "filename": "102. P1 - Katherine Mansfield 新西兰作家【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/102. P1 - Katherine Mansfield 新西兰作家.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-06": {
- "examId": "p2-low-06",
- "dataKey": "p2-low-06",
- "script": "./p2-low-06.js",
- "title": "Biomimicry 仿生学",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/103. P2 - Biomimicry 仿生学/",
- "filename": "103. P2 - Biomimicry 仿生学.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/103. P2 - Biomimicry 仿生学.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-07": {
- "examId": "p3-low-07",
- "dataKey": "p3-low-07",
- "script": "./p3-low-07.js",
- "title": "Star Performers 明星员工",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/104. P3 - Star Performers 明星员工/",
- "filename": "104. P3 - Star Performers 明星员工.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/104. P3 - Star Performers 明星员工.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-08": {
- "examId": "p2-low-08",
- "dataKey": "p2-low-08",
- "script": "./p2-low-08.js",
- "title": "How the Petri dish supports scientific advances 培养皿",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/105. P2 - How the Petri dish supports scientific advances 培养皿/",
- "filename": "105. P2 - How the Petri dish supports scientific advances 培养皿.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/105. P2 - How the Petri dish supports scientific advances 培养皿.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-09": {
- "examId": "p2-high-09",
- "dataKey": "p2-high-09",
- "script": "./p2-high-09.js",
- "title": "Early Approaches to Organisational Design 组织设计",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/106. P2 - Early Approaches to Organisational Design 组织设计【高】/",
- "filename": "106. P2 - Early Approaches to Organisational Design 组织设计【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/106. P2 - Early Approaches to Organisational Design 组织设计.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-medium-10": {
- "examId": "p2-medium-10",
- "dataKey": "p2-medium-10",
- "script": "./p2-medium-10.js",
- "title": "A study of western celebrity 西方名人",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/107. P2 - A study of western celebrity 西方名人【次】/",
- "filename": "107. P2 - A study of western celebrity 西方名人【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/107. P2 - A study of western celebrity 西方名人.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-11": {
- "examId": "p1-low-11",
- "dataKey": "p1-low-11",
- "script": "./p1-low-11.js",
- "title": "Bovids 牛科动物",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/108. P1 - Bovids 牛科动物/",
- "filename": "108. P1 - Bovids 牛科动物.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/108. P1 - Bovids 牛科动物.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-12": {
- "examId": "p3-low-12",
- "dataKey": "p3-low-12",
- "script": "./p3-low-12.js",
- "title": "Humanities and the health professional 人文医学",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/109. P3 - Humanities and the health professional 人文医学/",
- "filename": "109. P3 - Humanities and the health professional 人文医学.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/109. P3 - Humanities and the health professional 人文医学.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-13": {
- "examId": "p1-low-13",
- "dataKey": "p1-low-13",
- "script": "./p1-low-13.js",
- "title": "Report on a university drama project 大学戏剧项目报告",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/11. P1 - Report on a university drama project 大学戏剧项目报告/",
- "filename": "11. P1 - Report on a university drama project 大学戏剧项目报告.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/11. P1 - Report on a university drama project 大学戏剧项目报告.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-14": {
- "examId": "p2-high-14",
- "dataKey": "p2-high-14",
- "script": "./p2-high-14.js",
- "title": "Should space be explored by robots or by humans 人机太空探索",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/110. P2 - Should space be explored by robots or by humans 人机太空探索【高】/",
- "filename": "110. P2 - Should space be explored by robots or by humans 人机太空探索【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/110. P2 - Should space be explored by robots or by humans 人机太空探索.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-15": {
- "examId": "p3-high-15",
- "dataKey": "p3-high-15",
- "script": "./p3-high-15.js",
- "title": "Whale Culture 鲸鱼文化",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/111. P3 - Whale Culture 鲸鱼文化【高】/",
- "filename": "111. P3 - Whale Culture 鲸鱼文化【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/111. P3 - Whale Culture 鲸鱼文化.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-16": {
- "examId": "p2-high-16",
- "dataKey": "p2-high-16",
- "script": "./p2-high-16.js",
- "title": "The Importance of Law 法律的意义",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/112. P2 - The Importance of Law 法律的意义【高】/",
- "filename": "112. P2 - The Importance of Law 法律的意义【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/112. P2 - The Importance of Law 法律的意义.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-17": {
- "examId": "p2-high-17",
- "dataKey": "p2-high-17",
- "script": "./p2-high-17.js",
- "title": "Herbal Medicines 新西兰草药",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/113. P2 - Herbal Medicines 新西兰草药【高】/",
- "filename": "113. P2 - Herbal Medicines 新西兰草药【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/113. P2 - Herbal Medicines 新西兰草药.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-18": {
- "examId": "p3-medium-18",
- "dataKey": "p3-medium-18",
- "script": "./p3-medium-18.js",
- "title": "Unlocking the mystery of dreams 梦的解析",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/114. P3 - Unlocking the mystery of dreams 梦的解析【次】/",
- "filename": "114. P3 - Unlocking the mystery of dreams 梦的解析【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/114. P3 - Unlocking the mystery of dreams 梦的解析.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-19": {
- "examId": "p2-high-19",
- "dataKey": "p2-high-19",
- "script": "./p2-high-19.js",
- "title": "Mind Music 脑海中的音乐(心灵音乐)",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】/",
- "filename": "115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/115. P2 - Mind Music 脑海中的音乐(心灵音乐).pdf",
- "sourceKind": "generated-reading"
- },
- "p1-medium-20": {
- "examId": "p1-medium-20",
- "dataKey": "p1-medium-20",
- "script": "./p1-medium-20.js",
- "title": "The Development of Plastics 塑料的发展史",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/116. P1 - The Development of Plastics 塑料的发展史【次】/",
- "filename": "116. P1 - The Development of Plastics 塑料的发展史【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/116. P1 - The Development of Plastics 塑料的发展史.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-21": {
- "examId": "p2-high-21",
- "dataKey": "p2-high-21",
- "script": "./p2-high-21.js",
- "title": "Stress Less 工作压力",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/117. P2 - Stress Less 工作压力【高】/",
- "filename": "117. P2 - Stress Less 工作压力【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/117. P2 - Stress Less 工作压力.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-22": {
- "examId": "p3-medium-22",
- "dataKey": "p3-medium-22",
- "script": "./p3-medium-22.js",
- "title": "Neanderthal Technology 尼安德特人的生存技艺",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】/",
- "filename": "118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/118. P3 - Neanderthal Technology 尼安德特人的生存技艺.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-23": {
- "examId": "p2-high-23",
- "dataKey": "p2-high-23",
- "script": "./p2-high-23.js",
- "title": "The Constant Evolution of the Humble Tomato 番茄的演化",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】/",
- "filename": "119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-24": {
- "examId": "p1-high-24",
- "dataKey": "p1-high-24",
- "script": "./p1-high-24.js",
- "title": "Rubber 橡胶",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/12. P1 - Rubber 橡胶【高】/",
- "filename": "12. P1 - Rubber 橡胶【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/12. P1 - Rubber 橡胶.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-25": {
- "examId": "p2-high-25",
- "dataKey": "p2-high-25",
- "script": "./p2-high-25.js",
- "title": "Will Eating Less Make You Live Longer 节食与长寿",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】/",
- "filename": "120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/120. P2 - Will Eating Less Make You Live Longer 节食与长寿.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-27": {
- "examId": "p1-high-27",
- "dataKey": "p1-high-27",
- "script": "./p1-high-27.js",
- "title": "Footprints in the Mud 恐龙脚印",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/122. P1 - Footprints in the Mud 恐龙脚印【高】/",
- "filename": "122. P1 - Footprints in the Mud 恐龙脚印【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/122. P1 - Footprints in the Mud 恐龙脚印.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-28": {
- "examId": "p3-low-28",
- "dataKey": "p3-low-28",
- "script": "./p3-low-28.js",
- "title": "Images and Places 风景与印记",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/123. P3 - Images and Places 风景与印记/",
- "filename": "123. P3 - Images and Places 风景与印记.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/123. P3 - Images and Places 风景与印记.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-medium-29": {
- "examId": "p1-medium-29",
- "dataKey": "p1-medium-29",
- "script": "./p1-medium-29.js",
- "title": "The extinction of the cave bear 洞熊的灭绝",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/124. P1 - The extinction of the cave bear 洞熊的灭绝【次】/",
- "filename": "124. P1 - The extinction of the cave bear 洞熊的灭绝【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/124. P1 - The extinction of the cave bear 洞熊的灭绝.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-30": {
- "examId": "p1-low-30",
- "dataKey": "p1-low-30",
- "script": "./p1-low-30.js",
- "title": "Investing in the Future 投资未来",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/125. P1 - Investing in the Future 投资未来/",
- "filename": "125. P1 - Investing in the Future 投资未来.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/125. P1 - Investing in the Future 投资未来.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-31": {
- "examId": "p1-high-31",
- "dataKey": "p1-high-31",
- "script": "./p1-high-31.js",
- "title": "Dolls through the ages 玩偶的变迁史",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/126. P1 - Dolls through the ages 玩偶的变迁史【高】/",
- "filename": "126. P1 - Dolls through the ages 玩偶的变迁史【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/126. P1 - Dolls through the ages 玩偶的变迁史.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-32": {
- "examId": "p3-high-32",
- "dataKey": "p3-high-32",
- "script": "./p3-high-32.js",
- "title": "Science and Filmmaking 电影科学(CGI)",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/127. P3 - Science and Filmmaking 电影科学(CGI)【高】/",
- "filename": "127. P3 - Science and Filmmaking 电影科学(CGI)【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/127. P3 - Science and Filmmaking 电影科学(CGI).pdf",
- "sourceKind": "generated-reading"
- },
- "p1-medium-33": {
- "examId": "p1-medium-33",
- "dataKey": "p1-medium-33",
- "script": "./p1-medium-33.js",
- "title": "The Pyramid of Cestius 罗马金字塔",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/128. P1 - The Pyramid of Cestius 罗马金字塔【次】/",
- "filename": "128. P1 - The Pyramid of Cestius 罗马金字塔【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/128. P1 - The Pyramid of Cestius 罗马金字塔.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-34": {
- "examId": "p1-low-34",
- "dataKey": "p1-low-34",
- "script": "./p1-low-34.js",
- "title": "The Slow Food Organization 慢食运动组织",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/129. P1 - The Slow Food Organization 慢食运动组织/",
- "filename": "129. P1 - The Slow Food Organization 慢食运动组织.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/129. P1 - The Slow Food Organization 慢食运动组织.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-35": {
- "examId": "p1-low-35",
- "dataKey": "p1-low-35",
- "script": "./p1-low-35.js",
- "title": "Sweet Trouble 澳洲制糖产业",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/13. P1 - Sweet Trouble 澳洲制糖产业/",
- "filename": "13. P1 - Sweet Trouble 澳洲制糖产业.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/13. P1 - Sweet Trouble 澳洲制糖产业.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-36": {
- "examId": "p3-low-36",
- "dataKey": "p3-low-36",
- "script": "./p3-low-36.js",
- "title": "Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA/",
- "filename": "130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-37": {
- "examId": "p2-low-37",
- "dataKey": "p2-low-37",
- "script": "./p2-low-37.js",
- "title": "Keeping the water away 洪水防控",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/131. P2 - Keeping the water away 洪水防控/",
- "filename": "131. P2 - Keeping the water away 洪水防控.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/131. P2 - Keeping the water away 洪水防控.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-38": {
- "examId": "p3-low-38",
- "dataKey": "p3-low-38",
- "script": "./p3-low-38.js",
- "title": "Research into the effects of different teaching styles 教学风格研究",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/132. P3 - Research into the effects of different teaching styles 教学风格研究/",
- "filename": "132. P3 - Research into the effects of different teaching styles 教学风格研究.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/132. P3 - Research into the effects of different teaching styles 教学风格研究.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-39": {
- "examId": "p2-low-39",
- "dataKey": "p2-low-39",
- "script": "./p2-low-39.js",
- "title": "How to be Happy 如何获得幸福",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/133. P2 - How to be Happy 如何获得幸福/",
- "filename": "133. P2 - How to be Happy 如何获得幸福.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/133. P2 - How to be Happy 如何获得幸福.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-40": {
- "examId": "p1-low-40",
- "dataKey": "p1-low-40",
- "script": "./p1-low-40.js",
- "title": "Dyes and fabric dyeing 染料的历史",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/134. P1 - Dyes and fabric dyeing 染料的历史/",
- "filename": "134. P1 - Dyes and fabric dyeing 染料的历史.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/134. P1 - Dyes and fabric dyeing 染料的历史.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-41": {
- "examId": "p2-low-41",
- "dataKey": "p2-low-41",
- "script": "./p2-low-41.js",
- "title": "The Myth of the Eight-hour Sleep 八小时睡眠",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠/",
- "filename": "135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-42": {
- "examId": "p3-low-42",
- "dataKey": "p3-low-42",
- "script": "./p3-low-42.js",
- "title": "The peopling of Patagonia 巴塔哥尼亚的人类迁徙",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙/",
- "filename": "136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-43": {
- "examId": "p3-low-43",
- "dataKey": "p3-low-43",
- "script": "./p3-low-43.js",
- "title": "What is social history 社会史",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/137. P3 - What is social history 社会史/",
- "filename": "137. P3 - What is social history 社会史.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/137. P3 - What is social history 社会史.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-44": {
- "examId": "p3-low-44",
- "dataKey": "p3-low-44",
- "script": "./p3-low-44.js",
- "title": "Conformity 从众心理",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/138. P3 - Conformity 从众心理/",
- "filename": "138. P3 - Conformity 从众心理.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/138. P3 - Conformity 从众心理.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-45": {
- "examId": "p1-low-45",
- "dataKey": "p1-low-45",
- "script": "./p1-low-45.js",
- "title": "Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究/",
- "filename": "139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-46": {
- "examId": "p1-low-46",
- "dataKey": "p1-low-46",
- "script": "./p1-low-46.js",
- "title": "Sydney Opera House 悉尼歌剧院",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/14. P1 - Sydney Opera House 悉尼歌剧院/",
- "filename": "14. P1 - Sydney Opera House 悉尼歌剧院.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/14. P1 - Sydney Opera House 悉尼歌剧院.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-47": {
- "examId": "p1-low-47",
- "dataKey": "p1-low-47",
- "script": "./p1-low-47.js",
- "title": "The Burgess Shale fossils 伯吉斯页岩",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/140. P1 - The Burgess Shale fossils 伯吉斯页岩/",
- "filename": "140. P1 - The Burgess Shale fossils 伯吉斯页岩.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/140. P1 - The Burgess Shale fossils 伯吉斯页岩.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-48": {
- "examId": "p1-low-48",
- "dataKey": "p1-low-48",
- "script": "./p1-low-48.js",
- "title": "The history of the guitar 吉他的历史",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/141. P1 - The history of the guitar 吉他的历史/",
- "filename": "141. P1 - The history of the guitar 吉他的历史.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "",
- "sourceKind": "generated-reading"
- },
- "p2-low-49": {
- "examId": "p2-low-49",
- "dataKey": "p2-low-49",
- "script": "./p2-low-49.js",
- "title": "Born to Trade 交易的本能",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/142. P2 - Born to Trade 交易的本能/",
- "filename": "142. P2 - Born to Trade 交易的本能.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/142. P2 - Born to Trade 交易的本能.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-50": {
- "examId": "p2-low-50",
- "dataKey": "p2-low-50",
- "script": "./p2-low-50.js",
- "title": "Jellyfish – The Dominant Species 水母·海洋中的优势物种",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种/",
- "filename": "143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-51": {
- "examId": "p2-low-51",
- "dataKey": "p2-low-51",
- "script": "./p2-low-51.js",
- "title": "The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异/",
- "filename": "144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-52": {
- "examId": "p1-low-52",
- "dataKey": "p1-low-52",
- "script": "./p1-low-52.js",
- "title": "Caral an ancient South American city 卡拉尔古城",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/145. P1 - Caral an ancient South American city 卡拉尔古城/",
- "filename": "145. P1 - Caral an ancient South American city 卡拉尔古城.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/145. P1 - Caral an ancient South American city 卡拉尔古城.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-53": {
- "examId": "p1-low-53",
- "dataKey": "p1-low-53",
- "script": "./p1-low-53.js",
- "title": "The Early History of Olive Oil 橄榄油的历史",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/146. P1 - The Early History of Olive Oil 橄榄油的历史/",
- "filename": "146. P1 - The Early History of Olive Oil 橄榄油的历史.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/146. P1 - The Early History of Olive Oil 橄榄油的历史.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-54": {
- "examId": "p3-low-54",
- "dataKey": "p3-low-54",
- "script": "./p3-low-54.js",
- "title": "Movement Underwater 水下运动",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/147. P3 - Movement Underwater 水下运动/",
- "filename": "147. P3 - Movement Underwater 水下运动.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/147. P3 - Movement Underwater 水下运动.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-55": {
- "examId": "p3-low-55",
- "dataKey": "p3-low-55",
- "script": "./p3-low-55.js",
- "title": "Improving Patient Safety 药品包装设计",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/148. P3 - Improving Patient Safety 药品包装设计/",
- "filename": "148. P3 - Improving Patient Safety 药品包装设计.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/148. P3 - Improving Patient Safety 药品包装设计.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-56": {
- "examId": "p3-low-56",
- "dataKey": "p3-low-56",
- "script": "./p3-low-56.js",
- "title": "Learning to be bilingual 双语学习",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/149. P3 - Learning to be bilingual 双语学习/",
- "filename": "149. P3 - Learning to be bilingual 双语学习.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/149. P3 - Learning to be bilingual 双语学习.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-medium-57": {
- "examId": "p1-medium-57",
- "dataKey": "p1-medium-57",
- "script": "./p1-medium-57.js",
- "title": "The Blockbuster Phenomenon 博物馆爆款现象",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】/",
- "filename": "15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-medium-58": {
- "examId": "p2-medium-58",
- "dataKey": "p2-medium-58",
- "script": "./p2-medium-58.js",
- "title": "Insect Decision-Making 昆虫决策",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/150. P2 - Insect Decision-Making 昆虫决策【次】/",
- "filename": "150. P2 - Insect Decision-Making 昆虫决策【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/150. P2 - Insect Decision-Making 昆虫决策.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-59": {
- "examId": "p3-low-59",
- "dataKey": "p3-low-59",
- "script": "./p3-low-59.js",
- "title": "Inside the mind of a fan 观赛心境",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/151. P3 - Inside the mind of a fan 观赛心境/",
- "filename": "151. P3 - Inside the mind of a fan 观赛心境.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/151. P3 - Inside the mind of a fan 观赛心境.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-medium-60": {
- "examId": "p1-medium-60",
- "dataKey": "p1-medium-60",
- "script": "./p1-medium-60.js",
- "title": "Sorry—who are you 脸盲症",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/152. P1 - Sorry—who are you 脸盲症【次】/",
- "filename": "152. P1 - Sorry—who are you 脸盲症【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/152. P1 - Sorry—who are you 脸盲症.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-61": {
- "examId": "p1-low-61",
- "dataKey": "p1-low-61",
- "script": "./p1-low-61.js",
- "title": "Carnivorous plants 食虫植物",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/153. P1 - Carnivorous plants 食虫植物/",
- "filename": "153. P1 - Carnivorous plants 食虫植物.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/153. P1 - Carnivorous plants 食虫植物.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-62": {
- "examId": "p2-low-62",
- "dataKey": "p2-low-62",
- "script": "./p2-low-62.js",
- "title": "The purpose of facial expressions 面部表情",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/154. P2 - The purpose of facial expressions 面部表情/",
- "filename": "154. P2 - The purpose of facial expressions 面部表情.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/154. P2 - The purpose of facial expressions 面部表情.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-medium-63": {
- "examId": "p1-medium-63",
- "dataKey": "p1-medium-63",
- "script": "./p1-medium-63.js",
- "title": "A Brief History of Humans and Food 人类食物的历史",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/155. P1 - A Brief History of Humans and Food 人类食物的历史【次】/",
- "filename": "155. P1 - A Brief History of Humans and Food 人类食物的历史【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/155. P1 - A Brief History of Humans and Food 人类食物的历史.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-64": {
- "examId": "p2-low-64",
- "dataKey": "p2-low-64",
- "script": "./p2-low-64.js",
- "title": "New filter promises clean water for millions 新型泥土净水器",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/156. P2 - New filter promises clean water for millions 新型泥土净水器/",
- "filename": "156. P2 - New filter promises clean water for millions 新型泥土净水器.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/156. P2 - New filter promises clean water for millions 新型泥土净水器.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-65": {
- "examId": "p2-low-65",
- "dataKey": "p2-low-65",
- "script": "./p2-low-65.js",
- "title": "Boring Buildings 无聊建筑",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/157. P2 - Boring Buildings 无聊建筑/",
- "filename": "157. P2 - Boring Buildings 无聊建筑.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/157. P2 - Boring Buildings 无聊建筑.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-66": {
- "examId": "p3-medium-66",
- "dataKey": "p3-medium-66",
- "script": "./p3-medium-66.js",
- "title": "Mercator - The Map Maker 地理制图师",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/158. P3 - Mercator - The Map Maker 地理制图师【次】/",
- "filename": "158. P3 - Mercator - The Map Maker 地理制图师【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/158. P3 - Mercator - The Map Maker 地理制图师.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-67": {
- "examId": "p1-low-67",
- "dataKey": "p1-low-67",
- "script": "./p1-low-67.js",
- "title": "Scented Plants 植物的味道",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/159. P1 - Scented Plants 植物的味道/",
- "filename": "159. P1 - Scented Plants 植物的味道.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/159. P1 - Scented Plants 植物的味道.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-68": {
- "examId": "p1-low-68",
- "dataKey": "p1-low-68",
- "script": "./p1-low-68.js",
- "title": "The Clipper Races 帆船竞速",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/16. P1 - The Clipper Races 帆船竞速/",
- "filename": "16. P1 - The Clipper Races 帆船竞速.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/16. P1 - The Clipper Races 帆船竞速.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-69": {
- "examId": "p1-low-69",
- "dataKey": "p1-low-69",
- "script": "./p1-low-69.js",
- "title": "An important language development 楔形文字",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/160. P1 - An important language development 楔形文字/",
- "filename": "160. P1 - An important language development 楔形文字.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/160. P1 - An important language development 楔形文字.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-70": {
- "examId": "p1-low-70",
- "dataKey": "p1-low-70",
- "script": "./p1-low-70.js",
- "title": "Fluorescence Deep sea discovery深海发光生物研究",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/161. P1 - Fluorescence Deep sea discovery深海发光生物研究/",
- "filename": "161. P1 - Fluorescence Deep sea discovery深海发光生物研究.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/161. P1 - Deep sea discovery 深海发光生物研究.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-71": {
- "examId": "p3-low-71",
- "dataKey": "p3-low-71",
- "script": "./p3-low-71.js",
- "title": "Sea Change for Salinity 土地盐碱化",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/162. P3 - Sea Change for Salinity 土地盐碱化/",
- "filename": "162. P3 - Sea Change for Salinity 土地盐碱化.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/162. P3 - Sea Change for Salinity 土地盐碱化.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-72": {
- "examId": "p1-low-72",
- "dataKey": "p1-low-72",
- "script": "./p1-low-72.js",
- "title": "How to find your way out of a food desert 城市食物荒漠",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/163. P1 - How to find your way out of a food desert 城市食物荒漠/",
- "filename": "163. P1 - How to find your way out of a food desert 城市食物荒漠.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/163. P1 - How to find your way out of a food desert 城市食物荒漠.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-73": {
- "examId": "p2-low-73",
- "dataKey": "p2-low-73",
- "script": "./p2-low-73.js",
- "title": "The Power of Smell 嗅觉的力量",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/164. P2 - The Power of Smell 嗅觉的力量/",
- "filename": "164. P2 - The Power of Smell 嗅觉的力量.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/164. P2 - The Power of Smell 嗅觉的力量.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-74": {
- "examId": "p3-low-74",
- "dataKey": "p3-low-74",
- "script": "./p3-low-74.js",
- "title": "The Placebo Effect5 安慰剂效应",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/165. P3 - The Placebo Effect5 安慰剂效应/",
- "filename": "165. P3 - The Placebo Effect5 安慰剂效应.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/165. P3 - The Placebo Effect5 安慰剂效应.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-75": {
- "examId": "p2-low-75",
- "dataKey": "p2-low-75",
- "script": "./p2-low-75.js",
- "title": "Lean Production Innovation 精益生产",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/166. P2 - Lean Production Innovation 精益生产/",
- "filename": "166. P2 - Lean Production Innovation 精益生产.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/166. P2 - Lean Production Innovation 精益生产.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-76": {
- "examId": "p3-low-76",
- "dataKey": "p3-low-76",
- "script": "./p3-low-76.js",
- "title": "Sign, Baby, Sign! 美国手语",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/167. P3 - Sign, Baby, Sign! 美国手语/",
- "filename": "167. P3 - Sign, Baby, Sign! 美国手语.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/167. P3 - Sign, Baby, Sign! 美国手语.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-77": {
- "examId": "p2-low-77",
- "dataKey": "p2-low-77",
- "script": "./p2-low-77.js",
- "title": "Mammoth Kill 猛犸象的灭绝",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/168. P2 - Mammoth Kill 猛犸象的灭绝/",
- "filename": "168. P2 - Mammoth Kill 猛犸象的灭绝.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/168. P2 - Mammoth Kill 猛犸象的灭绝.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-78": {
- "examId": "p3-low-78",
- "dataKey": "p3-low-78",
- "script": "./p3-low-78.js",
- "title": "The Costs of Brand Loyalty 品牌忠诚的代价",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价/",
- "filename": "169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-79": {
- "examId": "p1-high-79",
- "dataKey": "p1-high-79",
- "script": "./p1-high-79.js",
- "title": "The Development of The Silk Industry 丝绸产业发展",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/17. P1 - The Development of The Silk Industry 丝绸产业发展【高】/",
- "filename": "17. P1 - The Development of The Silk Industry 丝绸产业发展【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/17. P1 - The Development of The Silk Industry 丝绸产业发展.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-80": {
- "examId": "p1-low-80",
- "dataKey": "p1-low-80",
- "script": "./p1-low-80.js",
- "title": "The unsung sense 被低估的嗅觉",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/170. P1 - The unsung sense 被低估的嗅觉/",
- "filename": "170. P1 - The unsung sense 被低估的嗅觉.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/170. P1 - The unsung sense 被低估的嗅觉.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-81": {
- "examId": "p1-low-81",
- "dataKey": "p1-low-81",
- "script": "./p1-low-81.js",
- "title": "Salt 盐的历史",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/171. P1 - Salt 盐的历史/",
- "filename": "171. P1 - Salt 盐的历史.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/171. P1 - Salt 盐的历史.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-82": {
- "examId": "p1-high-82",
- "dataKey": "p1-high-82",
- "script": "./p1-high-82.js",
- "title": "Think Small 微观科学",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/172. P1 - Think Small 微观科学【高】/",
- "filename": "172. P1 - Think Small 微观科学.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/172. P1 - Think Small 微观科学.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-83": {
- "examId": "p3-low-83",
- "dataKey": "p3-low-83",
- "script": "./p3-low-83.js",
- "title": "1018纸笔 Looking for inspiration 寻找灵感",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/173. 1018纸笔 P3 - Looking for inspiration 寻找灵感/",
- "filename": "173. 1018纸笔 P3 - Looking for inspiration 寻找灵感.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/173. P3(1018纸笔 ) - Looking for inspiration 寻找灵感.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-84": {
- "examId": "p1-low-84",
- "dataKey": "p1-low-84",
- "script": "./p1-low-84.js",
- "title": "Why good ideas fail TF公司",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/174. P1 - Why good ideas fail TF公司/",
- "filename": "174. P1 - Why good ideas fail TF公司.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/174. P1 - Why good ideas fail TF公司.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-85": {
- "examId": "p3-low-85",
- "dataKey": "p3-low-85",
- "script": "./p3-low-85.js",
- "title": "Music soothes and awes 音乐疗愈",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/175. P3 - Music soothes and awes 音乐疗愈/",
- "filename": "175. P3 - Music soothes and awes 音乐疗愈.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/175. P3 - Music soothes and awes 音乐疗愈.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-medium-86": {
- "examId": "p2-medium-86",
- "dataKey": "p2-medium-86",
- "script": "./p2-medium-86.js",
- "title": "Urban Regeneration 柏林公园改造",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/176. P2 - Urban Regeneration 柏林公园改造【次】/",
- "filename": "176. P2 - Urban Regeneration 柏林公园改造.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/176. P2 - Urban Regeneration 柏林公园改造.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-87": {
- "examId": "p2-low-87",
- "dataKey": "p2-low-87",
- "script": "./p2-low-87.js",
- "title": "1025纸笔Speaking of Nothing [Pretest] 闲聊的意义",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义/",
- "filename": "177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/177. P2(1025纸笔)[Pretest] - Speaking of Nothing 闲聊的意义.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-88": {
- "examId": "p3-low-88",
- "dataKey": "p3-low-88",
- "script": "./p3-low-88.js",
- "title": "1025纸笔Translating a key to international understanding 翻译的艺术",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术/",
- "filename": "178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/178. P3(1025纸笔)[Pretest] - Translating a key to international understanding 翻译的艺术.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-89": {
- "examId": "p3-high-89",
- "dataKey": "p3-high-89",
- "script": "./p3-high-89.js",
- "title": "Looking at daily life in ancient Rome 古罗马的日常",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/179. P3 - Looking at daily life in ancient Rome 古罗马的日常【高】/",
- "filename": "179. P3 - Looking at daily life in ancient Rome 古罗马的日常.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/179. P3 - Looking at daily life in ancient Rome 古罗马的日常.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-90": {
- "examId": "p1-high-90",
- "dataKey": "p1-high-90",
- "script": "./p1-high-90.js",
- "title": "The History of Tea 茶叶的历史",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/18. P1 - The History of Tea 茶叶的历史【高】/",
- "filename": "18. P1 - The History of Tea 茶叶的历史【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/18. P1 - The History of Tea 茶叶的历史.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-91": {
- "examId": "p2-high-91",
- "dataKey": "p2-high-91",
- "script": "./p2-high-91.js",
- "title": "Australia’s camouflaged creatures 澳洲伪装生物",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物【高】/",
- "filename": "180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-92": {
- "examId": "p1-high-92",
- "dataKey": "p1-high-92",
- "script": "./p1-high-92.js",
- "title": "Dust and the American West 美国西部尘埃",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/181. P1 - Dust and the American West 美国西部尘埃【高】/",
- "filename": "181. P1 - Dust and the American West 美国西部尘埃.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/181. P1 - Dust and the American West 美国西部尘埃.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-medium-93": {
- "examId": "p2-medium-93",
- "dataKey": "p2-medium-93",
- "script": "./p2-medium-93.js",
- "title": "Antarctic research 南极考察",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/182. P2 - Antarctic research 南极考察【次】/",
- "filename": "182. P2 - Antarctic research 南极考察.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/182. P2 - Antarctic research 南极考察.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-94": {
- "examId": "p2-low-94",
- "dataKey": "p2-low-94",
- "script": "./p2-low-94.js",
- "title": "The importance of being playful 玩耍的重要性",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/183. P2 - The importance of being playful 玩耍的重要性/",
- "filename": "183. P2 - The importance of being playful 玩耍的重要性.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/183. P2 - The importance of being playful 玩耍的重要性.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-95": {
- "examId": "p3-low-95",
- "dataKey": "p3-low-95",
- "script": "./p3-low-95.js",
- "title": "The strange world of sight 奇异的视觉世界",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/184. P3 - The strange world of sight 奇异的视觉世界/",
- "filename": "184. P3 - The strange world of sight 奇异的视觉世界.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/184. P3 - The strange world of sight 奇异的视觉世界.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-96": {
- "examId": "p2-low-96",
- "dataKey": "p2-low-96",
- "script": "./p2-low-96.js",
- "title": "[Pretest] Why Do We Need Sleep 睡眠的目的",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的/",
- "filename": "185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-97": {
- "examId": "p3-low-97",
- "dataKey": "p3-low-97",
- "script": "./p3-low-97.js",
- "title": "Saving languages 拯救濒危语言",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/186. P3 - Saving languages 拯救濒危语言/",
- "filename": "186. P3 - Saving languages 拯救濒危语言.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/186. P3 - Saving languages 拯救濒危语言.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-98": {
- "examId": "p3-low-98",
- "dataKey": "p3-low-98",
- "script": "./p3-low-98.js",
- "title": "Petrol power an eco-revolution 交通的革命",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/187. P3 - Petrol power an eco-revolution 交通的革命/",
- "filename": "187. P3 - Petrol power an eco-revolution 交通的革命.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/187. P3 - Petrol power an eco-revolution 交通的革命.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-99": {
- "examId": "p1-low-99",
- "dataKey": "p1-low-99",
- "script": "./p1-low-99.js",
- "title": "The history of the bar code 条形码的历史",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/188. P1 - The history of the bar code 条形码的历史/",
- "filename": "188. P1 - The history of the bar code 条形码的历史.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/188. P1 - The history of the bar code 条形码的历史.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-100": {
- "examId": "p3-low-100",
- "dataKey": "p3-low-100",
- "script": "./p3-low-100.js",
- "title": "Mirror 镜子研究",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/189. P3 - Mirror 镜子研究/",
- "filename": "189. P3 - Mirror 镜子研究.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/189. P3 - Mirror 镜子研究.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-101": {
- "examId": "p1-high-101",
- "dataKey": "p1-high-101",
- "script": "./p1-high-101.js",
- "title": "The Impact of the Potato 土豆的影响",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 1,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/19. P1 - The Impact of the Potato 土豆的影响【高】/",
- "filename": "19. P1 - The Impact of the Potato 土豆的影响【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/19. P1 - The Impact of the Potato 土豆的影响.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-102": {
- "examId": "p2-low-102",
- "dataKey": "p2-low-102",
- "script": "./p2-low-102.js",
- "title": "The power of music 音乐的力量",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/190. P2 - The power of music 音乐的力量/",
- "filename": "190. P2 - The power of music 音乐的力量.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/190. P2 - The power of music 音乐的力量.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-103": {
- "examId": "p2-low-103",
- "dataKey": "p2-low-103",
- "script": "./p2-low-103.js",
- "title": "The economic effect of climate 气候对经济的影响",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/191. P2 - The economic effect of climate 气候对经济的影响/",
- "filename": "191. P2 - The economic effect of climate 气候对经济的影响.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/191. P2 - The economic effect of climate 气候对经济的影响.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-104": {
- "examId": "p2-low-104",
- "dataKey": "p2-low-104",
- "script": "./p2-low-104.js",
- "title": "1115纸笔Should we stop eating meat 是否应该吃素",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素/",
- "filename": "192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-105": {
- "examId": "p1-high-105",
- "dataKey": "p1-high-105",
- "script": "./p1-high-105.js",
- "title": "A survivor’s story 新西兰猫头鹰",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/2. P1 - A survivor’s story 新西兰猫头鹰【高】/",
- "filename": "2. P1 - A survivor’s story 新西兰猫头鹰【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/2. P1 - A survivor’s story 新西兰猫头鹰.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-106": {
- "examId": "p1-low-106",
- "dataKey": "p1-low-106",
- "script": "./p1-low-106.js",
- "title": "The Importance of Business Cards 名片的重要性",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/20. P1 - The Importance of Business Cards 名片的重要性/",
- "filename": "20. P1 - The Importance of Business Cards 名片的重要性.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/20. P1 - The Importance of Business Cards 名片的重要性.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-107": {
- "examId": "p1-low-107",
- "dataKey": "p1-low-107",
- "script": "./p1-low-107.js",
- "title": "The life of Beatrix Potter 彼得兔作家",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/21. P1 - The life of Beatrix Potter 彼得兔作家/",
- "filename": "21. P1 - The life of Beatrix Potter 彼得兔作家.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/21. P1 - The life of Beatrix Potter 彼得兔作家.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-108": {
- "examId": "p1-low-108",
- "dataKey": "p1-low-108",
- "script": "./p1-low-108.js",
- "title": "The nature of Yawning 打哈欠的本质",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/22. P1 - The nature of Yawning 打哈欠的本质/",
- "filename": "22. P1 - The nature of Yawning 打哈欠的本质.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/22. P1 - The nature of Yawning 打哈欠的本质.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-109": {
- "examId": "p1-low-109",
- "dataKey": "p1-low-109",
- "script": "./p1-low-109.js",
- "title": "The Origin of Paper 造纸术起源",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/23. P1 - The Origin of Paper 造纸术起源/",
- "filename": "23. P1 - The Origin of Paper 造纸术起源.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/23. P1 - The Origin of Paper 造纸术起源.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-110": {
- "examId": "p1-high-110",
- "dataKey": "p1-high-110",
- "script": "./p1-high-110.js",
- "title": "The Pearls 珍珠",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/24. P1 - The Pearls 珍珠【高】/",
- "filename": "24. P1 - The Pearls 珍珠【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/24. P1 - The Pearls 珍珠.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-111": {
- "examId": "p1-low-111",
- "dataKey": "p1-low-111",
- "script": "./p1-low-111.js",
- "title": "The Rise and Fall of Detective Stories 侦探小说的兴衰",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰/",
- "filename": "25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-112": {
- "examId": "p1-low-112",
- "dataKey": "p1-low-112",
- "script": "./p1-low-112.js",
- "title": "The Tuatara of New Zealand 新西兰蜥蜴",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴/",
- "filename": "26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-113": {
- "examId": "p1-low-113",
- "dataKey": "p1-low-113",
- "script": "./p1-low-113.js",
- "title": "Thomas Young The last man who knew everything 托马斯·杨",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/27. P1 - Thomas Young The last man who knew everything 托马斯·杨/",
- "filename": "27. P1 - Thomas Young The last man who knew everything 托马斯·杨.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/27. P1 - Thomas Young The last man who knew everything 托马斯·杨.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-114": {
- "examId": "p1-low-114",
- "dataKey": "p1-low-114",
- "script": "./p1-low-114.js",
- "title": "Triumph of the City 城市的胜利",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 1.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/28. P1 - Triumph of the City 城市的胜利/",
- "filename": "28. P1 - Triumph of the City 城市的胜利.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/28. P1 - Triumph of the City 城市的胜利.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-medium-115": {
- "examId": "p1-medium-115",
- "dataKey": "p1-medium-115",
- "script": "./p1-medium-115.js",
- "title": "Tunnelling under the Thames 泰晤士河隧道",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】/",
- "filename": "29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/29. P1 - Tunnelling under the Thames 泰晤士河隧道.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-116": {
- "examId": "p1-low-116",
- "dataKey": "p1-low-116",
- "script": "./p1-low-116.js",
- "title": "Advertising Needs Attention 广告的吸引力",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/3. P1 - Advertising Needs Attention 广告的吸引力/",
- "filename": "3. P1 - Advertising Needs Attention 广告的吸引力.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/3. P1 - Advertising Needs Attention 广告的吸引力.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-medium-117": {
- "examId": "p1-medium-117",
- "dataKey": "p1-medium-117",
- "script": "./p1-medium-117.js",
- "title": "What Lucy Taught Us 露西化石",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/30. P1 - What Lucy Taught Us 露西化石【次】/",
- "filename": "30. P1 - What Lucy Taught Us 露西化石【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/30. P1 - What Lucy Taught Us 露西化石.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-118": {
- "examId": "p1-high-118",
- "dataKey": "p1-high-118",
- "script": "./p1-high-118.js",
- "title": "William Gilbert and Magnetism 电磁学之父",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/31. P1 - William Gilbert and Magnetism 电磁学之父【高】/",
- "filename": "31. P1 - William Gilbert and Magnetism 电磁学之父【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/31. P1 - William Gilbert and Magnetism 电磁学之父.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-medium-119": {
- "examId": "p1-medium-119",
- "dataKey": "p1-medium-119",
- "script": "./p1-medium-119.js",
- "title": "Wood 新西兰木材产业",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/32. P1 - Wood 新西兰木材产业【次】/",
- "filename": "32. P1 - Wood 新西兰木材产业【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/32. P1 - Wood 新西兰木材产业.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-120": {
- "examId": "p2-high-120",
- "dataKey": "p2-high-120",
- "script": "./p2-high-120.js",
- "title": "A new look for Talbot Park 奥克兰社区改造",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/33. P2 - A new look for Talbot Park 奥克兰社区改造【高】/",
- "filename": "ai_studio_code (9).html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/33. P2 - A new look for Talbot Park 奥克兰社区改造.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-medium-121": {
- "examId": "p2-medium-121",
- "dataKey": "p2-medium-121",
- "script": "./p2-medium-121.js",
- "title": "A unique golden textile 蜘蛛丝",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/34. P2 - A unique golden textile 蜘蛛丝【次】/",
- "filename": "34. P2 - A unique golden textile 蜘蛛丝【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/34. P2 - A unique golden textile 蜘蛛丝.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-122": {
- "examId": "p2-low-122",
- "dataKey": "p2-low-122",
- "script": "./p2-low-122.js",
- "title": "Biophilic Design 亲自然设计",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/35. P2 - Biophilic Design 亲自然设计/",
- "filename": "35. P2 - Biophilic Design 亲自然设计.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/35. P2 - Biophilic Design 亲自然设计.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-123": {
- "examId": "p2-high-123",
- "dataKey": "p2-high-123",
- "script": "./p2-high-123.js",
- "title": "Bird Migration 鸟类迁徙",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/36. P2 - Bird Migration 鸟类迁徙【高】/",
- "filename": "36. P2 - Bird Migration 鸟类迁徙【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/36. P2 - Bird Migration 鸟类迁徙.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-124": {
- "examId": "p2-high-124",
- "dataKey": "p2-high-124",
- "script": "./p2-high-124.js",
- "title": "Corporate Social Responsibility 企业社会责任",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/37. P2 - Corporate Social Responsibility 企业社会责任【高】/",
- "filename": "37. P2 - Corporate Social Responsibility 企业社会责任【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/37. P2 - Corporate Social Responsibility 企业社会责任.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-125": {
- "examId": "p2-low-125",
- "dataKey": "p2-low-125",
- "script": "./p2-low-125.js",
- "title": "Egypt’s ancient boat-builders 古埃及造船",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/38. P2 - Egypt’s ancient boat-builders 古埃及造船/",
- "filename": "38. P2 - Egypt’s ancient boat-builders 古埃及造船.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/38. P2 - Egypt’s ancient boat-builders 古埃及造船.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-medium-126": {
- "examId": "p2-medium-126",
- "dataKey": "p2-medium-126",
- "script": "./p2-medium-126.js",
- "title": "How are deserts formed 沙漠成因",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/39. P2 - How are deserts formed 沙漠成因【次】/",
- "filename": "39. P2 - How are deserts formed 沙漠成因【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/39. P2 - How are deserts formed 沙漠成因.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-127": {
- "examId": "p1-low-127",
- "dataKey": "p1-low-127",
- "script": "./p1-low-127.js",
- "title": "Ambergris 龙涎香",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/4. P1 - Ambergris 龙涎香/",
- "filename": "4. P1 - Ambergris 龙涎香.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/4. P1 - Ambergris 龙涎香.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-128": {
- "examId": "p2-high-128",
- "dataKey": "p2-high-128",
- "script": "./p2-high-128.js",
- "title": "How Well Do We Concentrate_ 多任务处理",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/40. P2 - How Well Do We Concentrate_ 多任务处理【高】/",
- "filename": "40. P2 - How Well Do We Concentrate_ 多任务处理【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/40. P2 - How Well Do We Concentrate_ 多任务处理.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-medium-129": {
- "examId": "p2-medium-129",
- "dataKey": "p2-medium-129",
- "script": "./p2-medium-129.js",
- "title": "Intelligent behaviour in birds 鸟类智慧行为",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】/",
- "filename": "41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/41. P2 - Intelligent behaviour in birds 鸟类智慧行为.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-130": {
- "examId": "p2-high-130",
- "dataKey": "p2-high-130",
- "script": "./p2-high-130.js",
- "title": "Investment in shares versus investment in other assets 回报数据分析",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】/",
- "filename": "42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/42. P2 - Investment in shares versus investment in other assets 回报数据分析.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-131": {
- "examId": "p2-high-131",
- "dataKey": "p2-high-131",
- "script": "./p2-high-131.js",
- "title": "Learning from the Romans 罗马混凝土",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/43. P2 - Learning from the Romans 罗马混凝土【高】/",
- "filename": "43. P2 - Learning from the Romans 罗马混凝土【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/43. P2 - Learning from the Romans 罗马混凝土.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-132": {
- "examId": "p2-low-132",
- "dataKey": "p2-low-132",
- "script": "./p2-low-132.js",
- "title": "Orientation of Birds 鸟类的定位能力",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/44. P2 - Orientation of Birds 鸟类的定位能力/",
- "filename": "44. P2 - Orientation of Birds 鸟类的定位能力.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/44. P2 - Orientation of Birds 鸟类的定位能力.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-133": {
- "examId": "p2-high-133",
- "dataKey": "p2-high-133",
- "script": "./p2-high-133.js",
- "title": "Playing soccer 街头足球",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/45. P2 - Playing soccer 街头足球【高】/",
- "filename": "45. P2 - Playing soccer 街头足球【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/45. P2 - Playing soccer 街头足球.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-134": {
- "examId": "p2-high-134",
- "dataKey": "p2-high-134",
- "script": "./p2-high-134.js",
- "title": "Roller coaster 过山车",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/46. P2 - Roller coaster 过山车【高】/",
- "filename": "46. P2 - Roller coaster 过山车【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/46. P2 - Roller coaster 过山车.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-135": {
- "examId": "p2-low-135",
- "dataKey": "p2-low-135",
- "script": "./p2-low-135.js",
- "title": "Skyscraper Farming 摩天大楼种植",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/47. P2 - Skyscraper Farming 摩天大楼种植/",
- "filename": "47. P2 - Skyscraper Farming 摩天大楼种植.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/47. P2 - Skyscraper Farming 摩天大楼种植.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-136": {
- "examId": "p2-high-136",
- "dataKey": "p2-high-136",
- "script": "./p2-high-136.js",
- "title": "Solving the problem of waste disposal 垃圾处理",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/48. P2 - Solving the problem of waste disposal 垃圾处理【高】/",
- "filename": "48. P2 - Solving the problem of waste disposal 垃圾处理【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/48. P2 - Solving the problem of waste disposal 垃圾处理.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-137": {
- "examId": "p2-high-137",
- "dataKey": "p2-high-137",
- "script": "./p2-high-137.js",
- "title": "Surviving city life 动物适应城市",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/49. P2 - Surviving city life 动物适应城市【高】/",
- "filename": "49. P2 - Surviving city life 动物适应城市【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/49. P2 - Surviving city life 动物适应城市.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-138": {
- "examId": "p1-low-138",
- "dataKey": "p1-low-138",
- "script": "./p1-low-138.js",
- "title": "Australian artist Margaret Preston 澳大利亚艺术家",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家/",
- "filename": "5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-139": {
- "examId": "p2-high-139",
- "dataKey": "p2-high-139",
- "script": "./p2-high-139.js",
- "title": "The conquest of malaria in Italy 意大利疟疾防治",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】/",
- "filename": "50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/50. P2 - The conquest of malaria in Italy 意大利疟疾防治.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-140": {
- "examId": "p2-low-140",
- "dataKey": "p2-low-140",
- "script": "./p2-low-140.js",
- "title": "The dingo debate 澳洲野犬",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/51. P2 - The dingo debate 澳洲野犬/",
- "filename": "51. P2 - The dingo debate 澳洲野犬.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-141": {
- "examId": "p2-high-141",
- "dataKey": "p2-high-141",
- "script": "./p2-high-141.js",
- "title": "The fascinating world of attine ants 切叶蚁",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/52. P2 - The fascinating world of attine ants 切叶蚁【高】/",
- "filename": "52. P2 - The fascinating world of attine ants 切叶蚁【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/52. P2 - The fascinating world of attine ants 切叶蚁.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-142": {
- "examId": "p2-low-142",
- "dataKey": "p2-low-142",
- "script": "./p2-low-142.js",
- "title": "The fashion industry 时尚产业",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/53. P2 - The fashion industry 时尚产业/",
- "filename": "53. P2 - The fashion industry 时尚产业.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/53. P2 - The fashion industry 时尚产业.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-143": {
- "examId": "p2-low-143",
- "dataKey": "p2-low-143",
- "script": "./p2-low-143.js",
- "title": "The impact of invasive species 入侵物种的影响",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/54. P2 - The impact of invasive species 入侵物种的影响/",
- "filename": "54. P2 - The impact of invasive species 入侵物种的影响.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/54. P2 - The impact of invasive species 入侵物种的影响.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-medium-144": {
- "examId": "p2-medium-144",
- "dataKey": "p2-medium-144",
- "script": "./p2-medium-144.js",
- "title": "The plan to bring an asteroid to Earth 捕获小行星",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】/",
- "filename": "55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/55. P2 - The plan to bring an asteroid to Earth 捕获小行星.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-145": {
- "examId": "p2-high-145",
- "dataKey": "p2-high-145",
- "script": "./p2-high-145.js",
- "title": "The return of monkey life 猴群回归",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/56. P2 - The return of monkey life 猴群回归【高】/",
- "filename": "56. P2 - The return of monkey life 猴群回归【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/56. P2 - The return of monkey life 猴群回归.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-medium-146": {
- "examId": "p2-medium-146",
- "dataKey": "p2-medium-146",
- "script": "./p2-medium-146.js",
- "title": "The Tasmanian Tiger 袋狼",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/57. P2 - The Tasmanian Tiger 袋狼【次】/",
- "filename": "57. P2 - The Tasmanian Tiger 袋狼【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/57. P2 - The Tasmanian Tiger 袋狼.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-147": {
- "examId": "p2-low-147",
- "dataKey": "p2-low-147",
- "script": "./p2-low-147.js",
- "title": "Who wrote Shakespeare's plays 莎士比亚",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/58. P2 - Who wrote Shakespeare's plays 莎士比亚/",
- "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-148": {
- "examId": "p2-low-148",
- "dataKey": "p2-low-148",
- "script": "./p2-low-148.js",
- "title": "Why do we need the arts_ 艺术的意义",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/59. P2 - Why do we need the arts_ 艺术的意义/",
- "filename": "59. P2 - Why do we need the arts_ 艺术的意义.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/59. P2 - Why do we need the arts_ 艺术的意义.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-149": {
- "examId": "p1-low-149",
- "dataKey": "p1-low-149",
- "script": "./p1-low-149.js",
- "title": "Categorizing societies 社会分类",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/6. P1 - Categorizing societies 社会分类/",
- "filename": "6. P1 - Categorizing societies 社会分类html.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/6. P1 - Categorizing societies 社会分类.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-150": {
- "examId": "p3-high-150",
- "dataKey": "p3-high-150",
- "script": "./p3-high-150.js",
- "title": "A closer examination of a study on verbal and non-verbal messages 语言表达研究",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】/",
- "filename": "60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-151": {
- "examId": "p3-low-151",
- "dataKey": "p3-low-151",
- "script": "./p3-low-151.js",
- "title": "Book Review The Discovery of Slowness 富兰克林(慢的发现)",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现)/",
- "filename": "61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-152": {
- "examId": "p3-medium-152",
- "dataKey": "p3-medium-152",
- "script": "./p3-medium-152.js",
- "title": "Charles Darwin and Evolutionary Psychology 进化心理学",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】/",
- "filename": "62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-153": {
- "examId": "p3-low-153",
- "dataKey": "p3-low-153",
- "script": "./p3-low-153.js",
- "title": "Crossing the Threshold 奥克兰美术馆",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/63. P3 - Crossing the Threshold 奥克兰美术馆/",
- "filename": "63. P3 - Crossing the Threshold 奥克兰美术馆.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/63. P3 - Crossing the Threshold 奥克兰美术馆.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-154": {
- "examId": "p3-medium-154",
- "dataKey": "p3-medium-154",
- "script": "./p3-medium-154.js",
- "title": "Decisions, Decisions 决策之间",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/64. P3 - Decisions, Decisions 决策之间【次】/",
- "filename": "64. P3 - Decisions, Decisions 决策之间【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/64. P3 - Decisions, Decisions 决策之间.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-155": {
- "examId": "p3-medium-155",
- "dataKey": "p3-medium-155",
- "script": "./p3-medium-155.js",
- "title": "Does class size matter_ 课堂规模",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/65. P3 - Does class size matter_ 课堂规模【次】/",
- "filename": "65. P3 - Does class size matter_ 课堂规模【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/65. P3 - Does class size matter 课堂规模.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-156": {
- "examId": "p3-high-156",
- "dataKey": "p3-high-156",
- "script": "./p3-high-156.js",
- "title": "Elephant Communication 大象交流",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/66. P3 - Elephant Communication 大象交流【高】/",
- "filename": "66. P3 - Elephant Communication 大象交流【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/66. P3 - Elephant Communication 大象交流.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-157": {
- "examId": "p3-high-157",
- "dataKey": "p3-high-157",
- "script": "./p3-high-157.js",
- "title": "Flower Power 鲜花的力量(花之力)",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 3.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/67. P3 - Flower Power 鲜花的力量(花之力)【高】/",
- "filename": "67. P3 - Flower Power 鲜花的力量(花之力)【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/67. P3 - Flower Power 鲜花的力量(花之力).pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-158": {
- "examId": "p3-low-158",
- "dataKey": "p3-low-158",
- "script": "./p3-low-158.js",
- "title": "Game theory 博弈论",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/68. P3 - Game theory 博弈论/",
- "filename": "68. P3 - Game theory 博弈论.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/68. P3 - Game theory 博弈论.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-159": {
- "examId": "p3-high-159",
- "dataKey": "p3-high-159",
- "script": "./p3-high-159.js",
- "title": "Grimm’s Fairy Tales 格林童话",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/69. P3 - Grimm’s Fairy Tales 格林童话【高】/",
- "filename": "69. P3 - Grimm’s Fairy Tales 格林童话【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/69. P3 - Grimm’s Fairy Tales 格林童话.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-160": {
- "examId": "p1-low-160",
- "dataKey": "p1-low-160",
- "script": "./p1-low-160.js",
- "title": "Chili peppers 辣椒的历史",
- "category": "P1",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/7. P1 - Chili peppers 辣椒的历史/",
- "filename": "7. P1 - Chili peppers 辣椒的历史.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/7. P1 - Chili peppers 辣椒的历史.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-161": {
- "examId": "p3-high-161",
- "dataKey": "p3-high-161",
- "script": "./p3-high-161.js",
- "title": "Insect-inspired robots 昆虫机器人",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/70. P3 - Insect-inspired robots 昆虫机器人【高】/",
- "filename": "70. P3 - Insect-inspired robots 昆虫机器人【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/70. P3 - Insect-inspired robots 昆虫机器人.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-162": {
- "examId": "p3-medium-162",
- "dataKey": "p3-medium-162",
- "script": "./p3-medium-162.js",
- "title": "Jean Piaget (1896–1980) 让·皮亚杰",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】/",
- "filename": "71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-163": {
- "examId": "p3-low-163",
- "dataKey": "p3-low-163",
- "script": "./p3-low-163.js",
- "title": "Keeping the Fun in Funfairs 游乐场设计科学",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学/",
- "filename": "72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-164": {
- "examId": "p3-high-164",
- "dataKey": "p3-high-164",
- "script": "./p3-high-164.js",
- "title": "Language Strategy in Multinational Companies 跨国公司语言策略",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】/",
- "filename": "73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-165": {
- "examId": "p3-low-165",
- "dataKey": "p3-low-165",
- "script": "./p3-low-165.js",
- "title": "Let’s teach them how to teach 教他们如何教学",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/74. P3 - Let’s teach them how to teach 教他们如何教学/",
- "filename": "74. P3 - Let’s teach them how to teach 教他们如何教学.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/74. P3 - Let’s teach them how to teach 教他们如何教学.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-166": {
- "examId": "p3-low-166",
- "dataKey": "p3-low-166",
- "script": "./p3-low-166.js",
- "title": "Life on Mars_ 火星地球化改造",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/75. P3 - Life on Mars_ 火星地球化改造/",
- "filename": "75. P3 - Life on Mars_ 火星地球化改造.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/75. P3 - Life on Mars 火星地球化改造.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-167": {
- "examId": "p3-high-167",
- "dataKey": "p3-high-167",
- "script": "./p3-high-167.js",
- "title": "Living dunes 流动沙丘",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/76. P3 - Living dunes 流动沙丘【高】/",
- "filename": "76. P3 - Living dunes 流动沙丘【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/76. P3 - Living dunes 流动沙丘.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-168": {
- "examId": "p3-medium-168",
- "dataKey": "p3-medium-168",
- "script": "./p3-medium-168.js",
- "title": "Marketing and the information age 信息时代营销",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/77. P3 - Marketing and the information age 信息时代营销【次】/",
- "filename": "77. P3 - Marketing and the information age 信息时代营销【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/77. P3 - Marketing and the information age 信息时代营销.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-169": {
- "examId": "p3-medium-169",
- "dataKey": "p3-medium-169",
- "script": "./p3-medium-169.js",
- "title": "(无题目) Music Language We All Speak 音乐语言",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4.5,
- "path": "睡着过项目组/1.11月高频文章[94篇+18背景]/P3 (29高+6次高)/2. P3次高频 (6篇)/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】/",
- "filename": "78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-170": {
- "examId": "p3-high-170",
- "dataKey": "p3-high-170",
- "script": "./p3-high-170.js",
- "title": "Pacific Navigation and Voyaging 太平洋航海",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】/",
- "filename": "79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/79. P3 - Pacific Navigation and Voyaging 太平洋航海.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-171": {
- "examId": "p1-high-171",
- "dataKey": "p1-high-171",
- "script": "./p1-high-171.js",
- "title": "Fishbourne Roman Palace 罗马宫殿",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/8. P1 - Fishbourne Roman Palace 罗马宫殿【高】/",
- "filename": "8. P1 - Fishbourne Roman Palace 罗马宫殿【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/8. P1 - Fishbourne Roman Palace 罗马宫殿.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-172": {
- "examId": "p3-low-172",
- "dataKey": "p3-low-172",
- "script": "./p3-low-172.js",
- "title": "Rebranding art museums 博物馆品牌重塑",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/80. P3 - Rebranding art museums 博物馆品牌重塑/",
- "filename": "80. P3 - Rebranding art museums 博物馆品牌重塑.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/80. P3 - Rebranding art museums 博物馆品牌重塑.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-173": {
- "examId": "p3-high-173",
- "dataKey": "p3-high-173",
- "script": "./p3-high-173.js",
- "title": "Robert Louis Stevenson 苏格兰作家",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/81. P3 - Robert Louis Stevenson 苏格兰作家【高】/",
- "filename": "81. P3 - Robert Louis Stevenson 苏格兰作家【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/81. P3 - Robert Louis Stevenson 苏格兰作家.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-174": {
- "examId": "p3-high-174",
- "dataKey": "p3-high-174",
- "script": "./p3-high-174.js",
- "title": "Some views on the use of headphones 耳机使用",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/82. P3 - Some views on the use of headphones 耳机使用【高】/",
- "filename": "82. P3 - Some views on the use of headphones 耳机使用【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/82. P3 - Some views on the use of headphones 耳机使用.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-175": {
- "examId": "p3-low-175",
- "dataKey": "p3-low-175",
- "script": "./p3-low-175.js",
- "title": "Termite Mounds 白蚁丘",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/83. P3 - Termite Mounds 白蚁丘/",
- "filename": "83. P3 - Termite Mounds 白蚁丘.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/83. P3 - Termite Mounds 白蚁丘.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-176": {
- "examId": "p3-medium-176",
- "dataKey": "p3-medium-176",
- "script": "./p3-medium-176.js",
- "title": "The Analysis of Fear 猴子恐惧实验",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/84. P3 - The Analysis of Fear 猴子恐惧实验【次】/",
- "filename": "84. P3 - The Analysis of Fear 猴子恐惧实验【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/84. P3 - The Analysis of Fear 猴子恐惧实验.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-177": {
- "examId": "p3-medium-177",
- "dataKey": "p3-medium-177",
- "script": "./p3-medium-177.js",
- "title": "The Art of Deception 欺骗的艺术",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/85. P3 - The Art of Deception 欺骗的艺术【次】/",
- "filename": "85. P3 - The Art of Deception 欺骗的艺术【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/85. P3 - The Art of Deception 欺骗的艺术.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-178": {
- "examId": "p3-high-178",
- "dataKey": "p3-high-178",
- "script": "./p3-high-178.js",
- "title": "The benefits of learning an instrument 学乐器的好处",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/86. P3 - The benefits of learning an instrument 学乐器的好处【高】/",
- "filename": "86. P3 - The benefits of learning an instrument 学乐器的好处【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/86. P3 - The benefits of learning an instrument 学乐器的好处.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-179": {
- "examId": "p3-medium-179",
- "dataKey": "p3-medium-179",
- "script": "./p3-medium-179.js",
- "title": "The Exploration of Mars 火星探索",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/87. P3 - The Exploration of Mars 火星探索【次】/",
- "filename": "87. P3 - The Exploration of Mars 火星探索【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/87. P3 - The Exploration of Mars 火星探索.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-180": {
- "examId": "p3-high-180",
- "dataKey": "p3-high-180",
- "script": "./p3-high-180.js",
- "title": "The fluoridation controversy 氟化水争议",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/88. P3 - The fluoridation controversy 氟化水争议【高】/",
- "filename": "88. P3 - The fluoridation controversy 氟化水争议【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/88. P3 - The fluoridation controversy 氟化水争议.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-181": {
- "examId": "p3-high-181",
- "dataKey": "p3-high-181",
- "script": "./p3-high-181.js",
- "title": "The Fruit Book 果实之书",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/89. P3 - The Fruit Book 果实之书【高】/",
- "filename": "89. P3 - The Fruit Book 果实之书【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/89. P3 - The Fruit Book 果实之书.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-medium-182": {
- "examId": "p1-medium-182",
- "dataKey": "p1-medium-182",
- "script": "./p1-medium-182.js",
- "title": "Listening to the Ocean 海洋探测",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/9. P1 - Listening to the Ocean 海洋探测【次】/",
- "filename": "9. P1 - Listening to the Ocean 海洋探测【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/9. P1 - Listening to the Ocean 海洋探测.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-183": {
- "examId": "p3-medium-183",
- "dataKey": "p3-medium-183",
- "script": "./p3-medium-183.js",
- "title": "The hazards of multitasking 多任务处理",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/90. P3 - The hazards of multitasking 多任务处理【次】/",
- "filename": "90. P3 - The hazards of multitasking 多任务处理【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/90. P3 - The hazards of multitasking 多任务处理.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-184": {
- "examId": "p3-high-184",
- "dataKey": "p3-high-184",
- "script": "./p3-high-184.js",
- "title": "The New Zealand writer Margaret Mahy 新西兰女作家",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】/",
- "filename": "91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-185": {
- "examId": "p3-medium-185",
- "dataKey": "p3-medium-185",
- "script": "./p3-medium-185.js",
- "title": "The Pirahã people of Brazil 巴西皮拉罕部落语言",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】/",
- "filename": "92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-186": {
- "examId": "p3-low-186",
- "dataKey": "p3-low-186",
- "script": "./p3-low-186.js",
- "title": "The Robbers Cave Study (山洞)群体行为实验",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/93. P3 - The Robbers Cave Study (山洞)群体行为实验/",
- "filename": "93. P3 - The Robbers Cave Study (山洞)群体行为实验.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/93. P3 - The Robbers Cave Study (山洞)群体行为实验.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-187": {
- "examId": "p3-low-187",
- "dataKey": "p3-low-187",
- "script": "./p3-low-187.js",
- "title": "The science of sleep 睡眠的科学",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/94. P3 - The science of sleep 睡眠的科学/",
- "filename": "94. P3 - The science of sleep 睡眠的科学.pdf.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/94. P3 - The science of sleep 睡眠的科学.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-188": {
- "examId": "p3-medium-188",
- "dataKey": "p3-medium-188",
- "script": "./p3-medium-188.js",
- "title": "The Significant Role of Mother Tongue in Education 母语教育",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】/",
- "filename": "95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/95. P3 - The Significant Role of Mother Tongue in Education 母语教育.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-189": {
- "examId": "p3-high-189",
- "dataKey": "p3-high-189",
- "script": "./p3-high-189.js",
- "title": "The tuatara – past and future 新西兰蜥蜴",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/96. P3 - The tuatara – past and future 新西兰蜥蜴【高】/",
- "filename": "96. P3 - The tuatara – past and future 新西兰蜥蜴【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/96. P3 - The tuatara – past and future 新西兰蜥蜴.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-190": {
- "examId": "p3-low-190",
- "dataKey": "p3-low-190",
- "script": "./p3-low-190.js",
- "title": "The value of literary prizes 文学奖项的价值",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/97. P3 - The value of literary prizes 文学奖项的价值/",
- "filename": "97. P3 - The value of literary prizes 文学奖项的价值.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/97. P3 - The value of literary prizes 文学奖项的价值.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-191": {
- "examId": "p3-medium-191",
- "dataKey": "p3-medium-191",
- "script": "./p3-medium-191.js",
- "title": "Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】/",
- "filename": "98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-192": {
- "examId": "p3-high-192",
- "dataKey": "p3-high-192",
- "script": "./p3-high-192.js",
- "title": "Voynich Manuscript 伏尼契手稿",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4.5,
- "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/99. P3 - Voynich Manuscript 伏尼契手稿【高】/",
- "filename": "99. P3 - Voynich Manuscript 伏尼契手稿【高】.html",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/99. P3 - Voynich Manuscript 伏尼契手稿.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-200": {
- "examId": "p1-high-200",
- "dataKey": "p1-high-200",
- "script": "./p1-high-200.js",
- "title": "Australia’s Airborne Dentists 澳洲飞行牙医",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2,
- "path": "三月/1.P1 高频/",
- "filename": "200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医【高】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-211": {
- "examId": "p1-high-211",
- "dataKey": "p1-high-211",
- "script": "./p1-high-211.js",
- "title": "Ahead of its time 新西兰头骨",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2.5,
- "path": "三月/1.P1 高频/",
- "filename": "211. P1 - Ahead of its time 新西兰头骨【高】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/211. P1 - Ahead of its time 新西兰头骨.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-216": {
- "examId": "p1-high-216",
- "dataKey": "p1-high-216",
- "script": "./p1-high-216.js",
- "title": "Australia’s cane toad problem 澳洲蟾蜍",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2.5,
- "path": "三月/1.P1 高频/",
- "filename": "216. P1 - Australia’s cane toad problem 澳洲蟾蜍【高】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/216. P1 - Australia’s cane toad problem 澳洲蟾蜍.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-194": {
- "examId": "p1-high-194",
- "dataKey": "p1-high-194",
- "script": "./p1-high-194.js",
- "title": "The history of the British wool industry 英国羊毛产业的历史",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 2.5,
- "path": "三月/2.P1 次高频/",
- "filename": "194. P1 - The history of the British wool industry 英国羊毛产业的历史【高】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/194. P1 - The history of the British wool industry 英国羊毛产业的历史.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-222": {
- "examId": "p2-low-222",
- "dataKey": "p2-low-222",
- "script": "./p2-low-222.js",
- "title": "Ideal Homes 理想居所",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "三月/",
- "filename": "222. P2 - Ideal Homes 理想居所.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/222. P2 - Ideal Homes 理想居所.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-low-223": {
- "examId": "p1-low-223",
- "dataKey": "p1-low-223",
- "script": "./p1-low-223.js",
- "title": "Effect and Cause 湖泊海啸研究",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 3.5,
- "path": "三月/",
- "filename": "223. P1 - Effect and Cause 湖泊海啸研究.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/223. P1 - Effect and Cause 湖泊海啸研究.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-201": {
- "examId": "p2-high-201",
- "dataKey": "p2-high-201",
- "script": "./p2-high-201.js",
- "title": "Multi-tasking and the brain 大脑与多任务处理",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "三月/3.P2 高频/",
- "filename": "201. P2 - Multi-tasking and the brain 大脑与多任务处理【高】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/201. P2 - Multi-tasking and the brain 大脑与多任务处理.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-medium-217": {
- "examId": "p2-medium-217",
- "dataKey": "p2-medium-217",
- "script": "./p2-medium-217.js",
- "title": "A mechanical friend for children 孩子的机器人朋友",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "三月/3.P2 高频/",
- "filename": "217. P2 - A mechanical friend for children 孩子的机器人朋友【次】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/217. P2 - A mechanical friend for children 孩子的机器人朋友.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-192": {
- "examId": "p2-high-192",
- "dataKey": "p2-high-192",
- "script": "./p2-high-192.js",
- "title": "P2(1115纸笔) - Should we stop eating meat 是否应该吃素",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "三月/4.P2 次高频/",
- "filename": "192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素【高】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-medium-209": {
- "examId": "p2-medium-209",
- "dataKey": "p2-medium-209",
- "script": "./p2-medium-209.js",
- "title": "Decision Fatigue 决策疲劳",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "三月/4.P2 次高频/",
- "filename": "209. P2 - Decision Fatigue 决策疲劳【次】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/209. P2 - Decision Fatigue 决策疲劳.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-medium-213": {
- "examId": "p2-medium-213",
- "dataKey": "p2-medium-213",
- "script": "./p2-medium-213.js",
- "title": "Growing more for less 卫星农业",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "三月/4.P2 次高频/",
- "filename": "213. P2 - Growing more for less 卫星农业【次】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/213. P2 - Growing more for less 卫星农业.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-051": {
- "examId": "p2-low-051",
- "dataKey": "p2-low-051",
- "script": "./p2-low-051.js",
- "title": "The dingo debate 澳洲野犬_澳洲野狗",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "三月/4.P2 次高频/",
- "filename": "51. P2 - The dingo debate 澳洲野犬_澳洲野狗.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-medium-058": {
- "examId": "p2-medium-058",
- "dataKey": "p2-medium-058",
- "script": "./p2-medium-058.js",
- "title": "Who wrote Shakespeare's plays 莎士比亚",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "三月/4.P2 次高频/",
- "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚【次】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-204": {
- "examId": "p3-high-204",
- "dataKey": "p3-high-204",
- "script": "./p3-high-204.js",
- "title": "When people are ‘deaf’ to music 失乐症",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "三月/5.P3 高频/",
- "filename": "204. P3 - When people are ‘deaf’ to music 失乐症【高】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/204. P3 - When people are ‘deaf’ to music 失乐症.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-206": {
- "examId": "p3-high-206",
- "dataKey": "p3-high-206",
- "script": "./p3-high-206.js",
- "title": "200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4.5,
- "path": "三月/5.P3 高频/",
- "filename": "206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 亚洲风景展【高】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-212": {
- "examId": "p3-high-212",
- "dataKey": "p3-high-212",
- "script": "./p3-high-212.js",
- "title": "Children’s literature studies today 儿童文学",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4.5,
- "path": "三月/5.P3 高频/",
- "filename": "212. P3 - Children’s literature studies today 儿童文学【高】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/212. P3 - Children’s literature studies today 儿童文学.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-218": {
- "examId": "p3-high-218",
- "dataKey": "p3-high-218",
- "script": "./p3-high-218.js",
- "title": "The Causes of Linguistic Change 语音的演变",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4.5,
- "path": "三月/5.P3 高频/",
- "filename": "218. P3 - The Causes of Linguistic Change 语音的演变【高】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/218. P3 - The Causes of Linguistic Change 语音的演变.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-219": {
- "examId": "p3-low-219",
- "dataKey": "p3-low-219",
- "script": "./p3-low-219.js",
- "title": "The origin of language 语言的起源",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "三月/5.P3 高频/",
- "filename": "219. P3 - The origin of language 语言的起源.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/219. P3 - The origin of language 语言的起源.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-999": {
- "examId": "p3-low-999",
- "dataKey": "p3-low-999",
- "script": "./p3-low-999.js",
- "title": "Risk taking",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4,
- "path": "三月/5.P3 高频/",
- "filename": "P3 - Risk taking.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "",
- "sourceKind": "generated-reading"
- },
- "p3-medium-197": {
- "examId": "p3-medium-197",
- "dataKey": "p3-medium-197",
- "script": "./p3-medium-197.js",
- "title": "Australia’s Megafauna Controversy 巨兽灭绝",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4.5,
- "path": "三月/6.P3 次高频/",
- "filename": "197. P3 - Australia’s Megafauna Controversy 巨兽灭绝【次】.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/197. P3 - Australia’s Megafauna Controversy 巨兽灭绝.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-198": {
- "examId": "p3-low-198",
- "dataKey": "p3-low-198",
- "script": "./p3-low-198.js",
- "title": "Child’s Play in Medieval England 中世纪的游戏",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4,
- "path": "三月/6.P3 次高频/",
- "filename": "198. P3 - Child’s Play in Medieval England 中世纪的游戏.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/198. P3 - Child’s Play in Medieval England 中世纪的游戏.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-078": {
- "examId": "p3-low-078",
- "dataKey": "p3-low-078",
- "script": "./p3-low-078.js",
- "title": "P3 (ds做出来的) - Music Language We All Speak 音乐语言",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": 4.5,
- "path": "三月/6.P3 次高频/",
- "filename": "78. P3 (ds做出来的) - Music Language We All Speak 音乐语言.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-227": {
- "examId": "p1-high-227",
- "dataKey": "p1-high-227",
- "script": "./p1-high-227.js",
- "title": "The Whale Goes to Court 鲸鱼油",
- "category": "P1",
- "frequency": "次高频",
- "difficultyScore": 3,
- "path": "ReadingPractice/PDF/",
- "filename": "227. P1 - The Whale Goes to Court 鲸鱼油.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/227. P1 - The Whale Goes to Court 鲸鱼油.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-225": {
- "examId": "p2-high-225",
- "dataKey": "p2-high-225",
- "script": "./p2-high-225.js",
- "title": "The problem of graffiti 涂鸦之困",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3.5,
- "path": "ReadingPractice/PDF/",
- "filename": "225. P2 - The problem of graffiti 涂鸦之困.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/225. P2 - The problem of graffiti 涂鸦之困.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-228": {
- "examId": "p3-high-228",
- "dataKey": "p3-high-228",
- "script": "./p3-high-228.js",
- "title": "On art and artists 艺术与艺术家",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": 4.5,
- "path": "ReadingPractice/PDF/",
- "filename": "228. P3 - On art and artists 艺术与艺术家.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/228. P3 - On art and artists 艺术与艺术家.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-229": {
- "examId": "p1-high-229",
- "dataKey": "p1-high-229",
- "script": "./p1-high-229.js",
- "title": "New Understanding of Giraffes in the Wild 野生长颈鹿",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2.5,
- "path": "ReadingPractice/PDF/",
- "filename": "229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-230": {
- "examId": "p1-high-230",
- "dataKey": "p1-high-230",
- "script": "./p1-high-230.js",
- "title": "The History of the Pencil 铅笔的历史",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 1.5,
- "path": "ReadingPractice/PDF/",
- "filename": "230. P1 - The History of the Pencil 铅笔的历史.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/230. P1 - The History of the Pencil 铅笔的历史.pdf",
- "sourceKind": "generated-reading"
- },
- "p1-high-231": {
- "examId": "p1-high-231",
- "dataKey": "p1-high-231",
- "script": "./p1-high-231.js",
- "title": "The History of the Pencil 铅笔的历史(流程图版)",
- "category": "P1",
- "frequency": "高频",
- "difficultyScore": 2,
- "path": "ReadingPractice/PDF/",
- "filename": "231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-232": {
- "examId": "p2-high-232",
- "dataKey": "p2-high-232",
- "script": "./p2-high-232.js",
- "title": "The origin and development of applause 掌声的历史",
- "category": "P2",
- "frequency": "次高频",
- "difficultyScore": 4,
- "path": "ReadingPractice/PDF/",
- "filename": "232. P2 - The origin and development of applause 掌声的历史.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/232. P2 - The origin and development of applause 掌声的历史.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-233": {
- "examId": "p2-high-233",
- "dataKey": "p2-high-233",
- "script": "./p2-high-233.js",
- "title": "Why don’t we sleep 失眠的原因",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 3,
- "path": "ReadingPractice/PDF/",
- "filename": "233. P2 - Why don’t we sleep 失眠的原因.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/233. P2 - Why don’t we sleep 失眠的原因.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-234": {
- "examId": "p2-high-234",
- "dataKey": "p2-high-234",
- "script": "./p2-high-234.js",
- "title": "How do plants talk to each other 植物交流",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": 4,
- "path": "ReadingPractice/PDF/",
- "filename": "234. P2 - The Secret Language of Plants 植物交流.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/234. P2 - The Secret Language of Plants 植物交流.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-high-221": {
- "examId": "p3-high-221",
- "dataKey": "p3-high-221",
- "script": "./p3-high-221.js",
- "title": "The Animal Connection 动物联结",
- "category": "P3",
- "frequency": "次高频",
- "difficultyScore": null,
- "path": "ReadingPractice/PDF/",
- "filename": "221. P3 - The Animal Connection 动物联结.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/221. P3 - The Animal Connection 动物联结.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-235": {
- "examId": "p2-high-235",
- "dataKey": "p2-high-235",
- "script": "./p2-high-235.js",
- "title": "The return of the black-footed ferret 黑足鼬",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": null,
- "path": "ReadingPractice/PDF/",
- "filename": "235. P2 - The return of the black-footed ferret 黑足鼬.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/235. P2 - The return of the black-footed ferret 黑足鼬.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-236": {
- "examId": "p2-high-236",
- "dataKey": "p2-high-236",
- "script": "./p2-high-236.js",
- "title": "War of the Plants 植物的战争",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": null,
- "path": "",
- "filename": "",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "",
- "sourceKind": "generated-reading"
- },
- "p3-high-229": {
- "examId": "p3-high-229",
- "dataKey": "p3-high-229",
- "script": "./p3-high-229.js",
- "title": "All in the family 兄弟姐妹的影响",
- "category": "P3",
- "frequency": "高频",
- "difficultyScore": null,
- "path": "ReadingPractice/PDF/",
- "filename": "237. P3 - All in the family 兄弟姐妹的影响.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/237. P3 - All in the family 兄弟姐妹的影响.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-high-239": {
- "examId": "p2-high-239",
- "dataKey": "p2-high-239",
- "script": "./p2-high-239.js",
- "title": "Nanotechnology: the science of the very small 纳米科技",
- "category": "P2",
- "frequency": "高频",
- "difficultyScore": null,
- "path": "ReadingPractice/PDF/",
- "filename": "239. P2 - Nanotechnology the science of the very small 纳米科技.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/239. P2 - Nanotechnology the science of the very small 纳米科技.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-240": {
- "examId": "p2-low-240",
- "dataKey": "p2-low-240",
- "script": "./p2-low-240.js",
- "title": "Coins - the first form of money 硬币起源",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "assets/generated/reading-exams/",
- "filename": "reading-practice-unified.html",
- "hasHtml": true,
- "hasPdf": false,
- "pdfFilename": "",
- "sourceKind": "generated-reading"
- },
- "p1-high-240": {
- "examId": "p1-high-240",
- "dataKey": "p1-high-240",
- "script": "./p1-high-240.js",
- "title": "The Origins of Weather Forecasting 天气预报",
- "category": "P1",
- "frequency": "high",
- "difficultyScore": null,
- "path": "ReadingPractice/PDF/",
- "filename": "240. P1 - The Origins of Weather Forecasting 天气预报.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/240. P1 - The Origins of Weather Forecasting 天气预报.pdf",
- "sourceKind": "generated-reading"
- },
- "p2-low-242": {
- "examId": "p2-low-242",
- "dataKey": "p2-low-242",
- "script": "./p2-low-242.js",
- "title": "Walking and shoes in eighteenth-century London 伦敦鞋子的发展史",
- "category": "P2",
- "frequency": "low",
- "difficultyScore": null,
- "path": "ReadingPractice/PDF/",
- "filename": "242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-low-240": {
- "examId": "p3-low-240",
- "dataKey": "p3-low-240",
- "script": "./p3-low-240.js",
- "title": "How a prehistoric predator took to the skies 翼龙飞行",
- "category": "P3",
- "frequency": "low",
- "difficultyScore": null,
- "path": "ReadingPractice/PDF/",
- "filename": "P3 - How a prehistoric predator took to the skies.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/P3 - How a prehistoric predator took to the skies.pdf",
- "sourceKind": "generated-reading"
- },
- "p3-medium-241": {
- "examId": "p3-medium-241",
- "dataKey": "p3-medium-241",
- "script": "./p3-medium-241.js",
- "title": "Who looks after the children in today's Britain? 育儿分工",
- "category": "P3",
- "frequency": "medium",
- "difficultyScore": null,
- "path": "ReadingPractice/PDF/",
- "filename": "P3 - Who looks after the children in today's Britain.pdf",
- "hasHtml": true,
- "hasPdf": true,
- "pdfFilename": "ReadingPractice/PDF/P3 - Who looks after the children in today's Britain.pdf",
- "sourceKind": "generated-reading"
- }
- };
-
- function clonePathRoot() {
- return Object.assign({}, PATH_ROOT);
- }
-
- function cloneIndexEntry(entry) {
- return Object.assign({}, entry);
- }
-
- function buildReadingExamIndex() {
- const index = Object.keys(manifest).map(function mapEntry(id) {
- const entry = manifest[id] || {};
- return {
- id: entry.examId || id,
- title: entry.title || '',
- category: entry.category || '',
- frequency: entry.frequency || '',
- difficultyScore: entry.difficultyScore,
- path: entry.path || '',
- filename: entry.filename || '',
- hasHtml: entry.hasHtml === true,
- hasPdf: entry.hasPdf === true,
- pdfFilename: entry.pdfFilename || '',
- sourceKind: entry.sourceKind || (entry.script ? 'generated-reading' : 'pdf-only'),
- type: 'reading'
- };
- });
- index.pathRoot = clonePathRoot();
- return index;
- }
-
- function getReadingExamIndex() {
- const index = global.__READING_EXAM_INDEX__;
- const cloned = Array.isArray(index) ? index.map(cloneIndexEntry) : buildReadingExamIndex();
- cloned.pathRoot = clonePathRoot();
- return cloned;
- }
-
- global.__READING_EXAM_MANIFEST__ = manifest;
- global.__READING_EXAM_INDEX__ = buildReadingExamIndex();
- global.__READING_EXAM_INDEX__.pathRoot = clonePathRoot();
- global.__READING_EXAM_PATH_ROOT__ = clonePathRoot();
- global.getReadingExamIndex = getReadingExamIndex;
- global.getReadingExamIndex.pathRoot = clonePathRoot();
- global.completeExamIndex = getReadingExamIndex();
-})(typeof window !== "undefined" ? window : globalThis);
-
-
-/* ===== js/utils/stateSerializer.js ===== */
-/**
- * 状态序列化适配器
- * 解决Set/Map对象无法直接JSON序列化的问题
- */
-
-class StateSerializer {
- /**
- * 序列化状态值,处理特殊对象类型
- */
- static serialize(value) {
- if (value === null || value === undefined) {
- return value;
- }
-
- // 处理Set对象
- if (value instanceof Set) {
- return {
- __type: 'Set',
- __value: Array.from(value)
- };
- }
-
- // 处理Map对象
- if (value instanceof Map) {
- return {
- __type: 'Map',
- __value: Array.from(value.entries())
- };
- }
-
- // 处理Date对象
- if (value instanceof Date) {
- return {
- __type: 'Date',
- __value: value.toISOString()
- };
- }
-
- // 处理普通对象,递归处理嵌套
- if (typeof value === 'object') {
- if (Array.isArray(value)) {
- return value.map(item => StateSerializer.serialize(item));
- } else {
- const serialized = {};
- for (const [key, val] of Object.entries(value)) {
- serialized[key] = StateSerializer.serialize(val);
- }
- return serialized;
- }
- }
-
- // 基本类型直接返回
- return value;
- }
-
- /**
- * 反序列化状态值,恢复特殊对象类型
- */
- static deserialize(value) {
- if (value === null || value === undefined) {
- return value;
- }
-
- // 检查是否是特殊类型对象
- if (typeof value === 'object' && value !== null && '__type' in value) {
- switch (value.__type) {
- case 'Set':
- return new Set(value.__value);
- case 'Map':
- return new Map(value.__value);
- case 'Date':
- return new Date(value.__value);
- default:
- console.warn(`[StateSerializer] 未知类型: ${value.__type}`);
- return value.__value;
- }
- }
-
- // 处理数组
- if (Array.isArray(value)) {
- return value.map(item => StateSerializer.deserialize(item));
- }
-
- // 处理普通对象,递归处理嵌套
- if (typeof value === 'object') {
- const deserialized = {};
- for (const [key, val] of Object.entries(value)) {
- deserialized[key] = StateSerializer.deserialize(val);
- }
- return deserialized;
- }
-
- // 基本类型直接返回
- return value;
- }
-
- /**
- * 验证序列化/反序列化的一致性
- */
- static validate(originalValue) {
- try {
- const serialized = StateSerializer.serialize(originalValue);
- const deserialized = StateSerializer.deserialize(serialized);
-
- // 对于Set/Map,深度比较内容
- if (originalValue instanceof Set) {
- const originalArray = Array.from(originalValue);
- const deserializedArray = Array.from(deserialized);
- return JSON.stringify(originalArray.sort()) === JSON.stringify(deserializedArray.sort());
- }
-
- if (originalValue instanceof Map) {
- const originalArray = Array.from(originalValue.entries()).sort();
- const deserializedArray = Array.from(deserialized.entries()).sort();
- return JSON.stringify(originalArray) === JSON.stringify(deserializedArray);
- }
-
- // 其他类型直接比较
- return JSON.stringify(originalValue) === JSON.stringify(deserialized);
- } catch (error) {
- console.error('[StateSerializer] 验证失败:', error);
- return false;
- }
- }
-
- /**
- * 创建存储适配器,包装storage对象
- */
- static createStorageAdapter(baseStorage) {
- return {
- async get(key, defaultValue = null) {
- try {
- const value = await baseStorage.get(key, defaultValue);
- return StateSerializer.deserialize(value);
- } catch (error) {
- console.error(`[StateSerializer] 获取数据失败 ${key}:`, error);
- return defaultValue;
- }
- },
-
- async set(key, value) {
- try {
- const serializedValue = StateSerializer.serialize(value);
- return await baseStorage.set(key, serializedValue);
- } catch (error) {
- console.error(`[StateSerializer] 设置数据失败 ${key}:`, error);
- throw error;
- }
- },
-
- async remove(key) {
- try {
- return await baseStorage.remove(key);
- } catch (error) {
- console.error(`[StateSerializer] 删除数据失败 ${key}:`, error);
- throw error;
- }
- },
-
- async clear() {
- try {
- return await baseStorage.clear();
- } catch (error) {
- console.error('[StateSerializer] 清空存储失败:', error);
- throw error;
- }
- }
- };
- }
-}
-
-// 导出供使用
-if (typeof module !== 'undefined' && module.exports) {
- module.exports = StateSerializer;
-}
-
-
-/* ===== js/utils/simpleStorageWrapper.js ===== */
-(function(window) {
- class SimpleStorageWrapper {
- constructor(repositories) {
- this.repos = repositories;
- }
-
- get settingsRepo() { return this.repos.settings; }
- get backupRepo() { return this.repos.backups; }
- get metaRepo() { return this.repos.meta; }
-
- isPracticeDataKey(key) {
- return key === 'practice_records' || key === 'user_stats';
- }
-
- getPracticeRecordAPI() {
- const api = window.PracticeRecordAPI;
- if (!api) {
- throw new Error('PracticeRecordAPI unavailable');
- }
- return api;
- }
-
- rejectPracticeDataWrite(methodName, targetName) {
- throw new Error(`SimpleStorageWrapper.${methodName} is disabled; use ${targetName}`);
- }
-
- async getPracticeRecords() {
- const api = this.getPracticeRecordAPI();
- if (typeof api.list !== 'function') {
- throw new Error('PracticeRecordAPI.list unavailable');
- }
- return await api.list();
- }
-
- async savePracticeRecords() {
- this.rejectPracticeDataWrite('savePracticeRecords', 'PracticeRecordAPI.replace');
- }
-
- async addPracticeRecord() {
- this.rejectPracticeDataWrite('addPracticeRecord', 'PracticeRecordAPI.saveRecord');
- }
-
- async getById(id) {
- const api = this.getPracticeRecordAPI();
- if (typeof api.getById !== 'function') {
- throw new Error('PracticeRecordAPI.getById unavailable');
- }
- return await api.getById(id);
- }
-
- async update() {
- this.rejectPracticeDataWrite('update', 'PracticeRecordAPI.saveRecord');
- }
-
- async delete() {
- this.rejectPracticeDataWrite('delete', 'PracticeRecordAPI.deleteById');
- }
-
- async deletePracticeRecord() {
- this.rejectPracticeDataWrite('deletePracticeRecord', 'PracticeRecordAPI.deleteById');
- }
+ const resourceProbeCache = new Map();
- async deletePracticeRecords() {
- this.rejectPracticeDataWrite('deletePracticeRecords', 'PracticeRecordAPI.deleteMany');
+ function probeResource(url) {
+ if (!url) {
+ return Promise.resolve(false);
}
-
- async getPracticeRecordsCount() {
- const records = await this.getPracticeRecords();
- return Array.isArray(records) ? records.length : 0;
+ if (resourceProbeCache.has(url)) {
+ return resourceProbeCache.get(url);
}
-
- validatePracticeRecord(record) {
- const errors = [];
- if (!record || typeof record !== 'object') {
- errors.push('记录必须是对象');
- } else {
- if (!record.id || typeof record.id !== 'string') {
- errors.push('记录缺少有效的 id');
- }
- if (!record.type || typeof record.type !== 'string') {
- errors.push('记录缺少有效的 type');
- }
- if (record.score === undefined || record.score === null || typeof record.score !== 'number') {
- errors.push('记录缺少有效的 score');
- }
- if (record.totalQuestions !== undefined && typeof record.totalQuestions !== 'number') {
- errors.push('totalQuestions 必须是数字');
+ const attempt = (async () => {
+ if (shouldBypassProbe(url)) {
+ return true;
+ }
+ try {
+ const response = await fetch(url, { method: 'HEAD', cache: 'no-store' });
+ if (response && (response.ok || response.status === 304 || response.status === 405 || response.type === 'opaque')) {
+ return true;
}
- if (record.correctAnswers !== undefined && typeof record.correctAnswers !== 'number') {
- errors.push('correctAnswers 必须是数字');
+ if (response && response.status >= 400) {
+ return false;
}
- if (record.duration !== undefined && typeof record.duration !== 'number') {
- errors.push('duration 必须是数字');
+ } catch (_) {
+ if (shouldBypassProbe(url)) {
+ return true;
}
- if (!record.date) {
- errors.push('记录缺少有效的 date');
- } else if (Number.isNaN(new Date(record.date).getTime())) {
- errors.push('date 格式无效');
+ }
+ return false;
+ })();
+ resourceProbeCache.set(url, attempt);
+ return attempt;
+ }
+
+ async function resolveResource(exam, kind = 'html') {
+ const attempts = getResourceAttempts(exam, kind);
+ for (let i = 0; i < attempts.length; i += 1) {
+ const entry = attempts[i];
+ try {
+ const ok = await probeResource(entry.path);
+ if (ok) {
+ return { url: entry.path, attempts };
}
+ } catch (error) {
+ console.warn('[ResourceCore] 资源探测失败:', entry, error);
}
- return {
- isValid: errors.length === 0,
- errors
- };
}
+ return { url: '', attempts };
+ }
- async getUserSettings() { return await this.settingsRepo.getAll(); }
- async saveUserSettings(settings) { await this.settingsRepo.saveAll(settings); return true; }
- async getUserSetting(key, defaultValue = null) { return await this.settingsRepo.get(key, defaultValue); }
- async setUserSetting(key, value) { await this.settingsRepo.set(key, value); return true; }
+ global.ResourceCore = {
+ __stable: true,
+ version: '0.6.2-fix',
+ RAW_DEFAULT_PATH_MAP,
+ DEFAULT_PATH_MAP,
+ PATH_FALLBACK_ORDER,
+ clonePathMap,
+ normalizePathRoot,
+ mergeRootWithFallback,
+ buildOverridePathMap,
+ derivePathMapFromIndex,
+ getPathMap,
+ setActivePathMap,
+ loadPathMapForConfiguration,
+ savePathMapForConfiguration,
+ deletePathMapForConfiguration,
+ refreshPathMap,
+ getBasePrefix,
+ setBasePrefix,
+ resolveExamBasePath,
+ buildResourcePath,
+ getResourceAttempts,
+ resolveResource,
+ sanitizeFilename,
+ encodePathSegments,
+ detectScriptBasePrefix,
+ normalizeBasePrefix
+ };
+})(typeof window !== 'undefined' ? window : globalThis);
- async getBackups() { return await this.backupRepo.list(); }
- async saveBackups(backups) { await this.backupRepo.saveAll(backups); return true; }
- async addBackup(backup) { await this.backupRepo.add(backup); return true; }
- async deleteBackup(id) { return await this.backupRepo.delete(id); }
- async clearBackups() { await this.backupRepo.clear(); return true; }
- async get(key, defaultValue = null) {
- if (this.isPracticeDataKey(key)) {
- const api = this.getPracticeRecordAPI();
- if (key === 'practice_records') {
- if (typeof api.list !== 'function') {
- throw new Error('PracticeRecordAPI.list unavailable');
- }
- return await api.list();
- }
- if (typeof api.readStats !== 'function') {
- throw new Error('PracticeRecordAPI.readStats unavailable');
- }
- return await api.readStats({ fallback: defaultValue });
- }
- return await this.metaRepo.get(key, defaultValue);
- }
+/* ===== assets/generated/reading-exams/manifest.js ===== */
+(function registerReadingExamManifest(global) {
+ 'use strict';
+ const PATH_ROOT = {
+ "reading": "三月/",
+ "listening": "ListeningPractice/"
+ };
+ const manifest = {
+ "p1-high-01": {
+ "examId": "p1-high-01",
+ "dataKey": "p1-high-01",
+ "script": "./p1-high-01.js",
+ "title": "A Brief History of Tea 茶叶简史",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/1. P1 - A Brief History of Tea 茶叶简史【高】/",
+ "filename": "1. P1 - A Brief History of Tea 茶叶简史【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/1. P1 - A Brief History of Tea 茶叶简史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-02": {
+ "examId": "p1-low-02",
+ "dataKey": "p1-low-02",
+ "script": "./p1-low-02.js",
+ "title": "Maori Fish Hooks 毛利鱼钩",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/10. P1 - Maori Fish Hooks 毛利鱼钩/",
+ "filename": "10. P1 - Maori Fish Hooks 毛利鱼钩.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/10. P1 - Maori Fish Hooks 毛利鱼钩.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-03": {
+ "examId": "p3-high-03",
+ "dataKey": "p3-high-03",
+ "script": "./p3-high-03.js",
+ "title": "What makes a musical expert_ 音乐天赋",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/100. P3 - What makes a musical expert_ 音乐天赋【高】/",
+ "filename": "100. P3 - What makes a musical expert_ 音乐天赋【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/100. P3 - What makes a musical expert_ 音乐天赋.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-04": {
+ "examId": "p3-high-04",
+ "dataKey": "p3-high-04",
+ "script": "./p3-high-04.js",
+ "title": "Yawning 打呵欠",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/101. P3 - Yawning 打呵欠【高】/",
+ "filename": "101. P3 - Yawning 打呵欠【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/101. P3 - Yawning 打哈欠.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-05": {
+ "examId": "p1-high-05",
+ "dataKey": "p1-high-05",
+ "script": "./p1-high-05.js",
+ "title": "Katherine Mansfield 新西兰作家",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/102. P1 - Katherine Mansfield 新西兰作家【高】/",
+ "filename": "102. P1 - Katherine Mansfield 新西兰作家【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/102. P1 - Katherine Mansfield 新西兰作家.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-06": {
+ "examId": "p2-low-06",
+ "dataKey": "p2-low-06",
+ "script": "./p2-low-06.js",
+ "title": "Biomimicry 仿生学",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/103. P2 - Biomimicry 仿生学/",
+ "filename": "103. P2 - Biomimicry 仿生学.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/103. P2 - Biomimicry 仿生学.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-07": {
+ "examId": "p3-low-07",
+ "dataKey": "p3-low-07",
+ "script": "./p3-low-07.js",
+ "title": "Star Performers 明星员工",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/104. P3 - Star Performers 明星员工/",
+ "filename": "104. P3 - Star Performers 明星员工.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/104. P3 - Star Performers 明星员工.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-08": {
+ "examId": "p2-low-08",
+ "dataKey": "p2-low-08",
+ "script": "./p2-low-08.js",
+ "title": "How the Petri dish supports scientific advances 培养皿",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/105. P2 - How the Petri dish supports scientific advances 培养皿/",
+ "filename": "105. P2 - How the Petri dish supports scientific advances 培养皿.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/105. P2 - How the Petri dish supports scientific advances 培养皿.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-09": {
+ "examId": "p2-high-09",
+ "dataKey": "p2-high-09",
+ "script": "./p2-high-09.js",
+ "title": "Early Approaches to Organisational Design 组织设计",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/106. P2 - Early Approaches to Organisational Design 组织设计【高】/",
+ "filename": "106. P2 - Early Approaches to Organisational Design 组织设计【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/106. P2 - Early Approaches to Organisational Design 组织设计.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-medium-10": {
+ "examId": "p2-medium-10",
+ "dataKey": "p2-medium-10",
+ "script": "./p2-medium-10.js",
+ "title": "A study of western celebrity 西方名人",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/107. P2 - A study of western celebrity 西方名人【次】/",
+ "filename": "107. P2 - A study of western celebrity 西方名人【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/107. P2 - A study of western celebrity 西方名人.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-11": {
+ "examId": "p1-low-11",
+ "dataKey": "p1-low-11",
+ "script": "./p1-low-11.js",
+ "title": "Bovids 牛科动物",
+ "category": "P1",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/108. P1 - Bovids 牛科动物/",
+ "filename": "108. P1 - Bovids 牛科动物.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/108. P1 - Bovids 牛科动物.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-12": {
+ "examId": "p3-low-12",
+ "dataKey": "p3-low-12",
+ "script": "./p3-low-12.js",
+ "title": "Humanities and the health professional 人文医学",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/109. P3 - Humanities and the health professional 人文医学/",
+ "filename": "109. P3 - Humanities and the health professional 人文医学.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/109. P3 - Humanities and the health professional 人文医学.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-13": {
+ "examId": "p1-low-13",
+ "dataKey": "p1-low-13",
+ "script": "./p1-low-13.js",
+ "title": "Report on a university drama project 大学戏剧项目报告",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/11. P1 - Report on a university drama project 大学戏剧项目报告/",
+ "filename": "11. P1 - Report on a university drama project 大学戏剧项目报告.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/11. P1 - Report on a university drama project 大学戏剧项目报告.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-14": {
+ "examId": "p2-high-14",
+ "dataKey": "p2-high-14",
+ "script": "./p2-high-14.js",
+ "title": "Should space be explored by robots or by humans 人机太空探索",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/110. P2 - Should space be explored by robots or by humans 人机太空探索【高】/",
+ "filename": "110. P2 - Should space be explored by robots or by humans 人机太空探索【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/110. P2 - Should space be explored by robots or by humans 人机太空探索.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-15": {
+ "examId": "p3-high-15",
+ "dataKey": "p3-high-15",
+ "script": "./p3-high-15.js",
+ "title": "Whale Culture 鲸鱼文化",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/111. P3 - Whale Culture 鲸鱼文化【高】/",
+ "filename": "111. P3 - Whale Culture 鲸鱼文化【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/111. P3 - Whale Culture 鲸鱼文化.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-16": {
+ "examId": "p2-high-16",
+ "dataKey": "p2-high-16",
+ "script": "./p2-high-16.js",
+ "title": "The Importance of Law 法律的意义",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/112. P2 - The Importance of Law 法律的意义【高】/",
+ "filename": "112. P2 - The Importance of Law 法律的意义【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/112. P2 - The Importance of Law 法律的意义.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-17": {
+ "examId": "p2-high-17",
+ "dataKey": "p2-high-17",
+ "script": "./p2-high-17.js",
+ "title": "Herbal Medicines 新西兰草药",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/113. P2 - Herbal Medicines 新西兰草药【高】/",
+ "filename": "113. P2 - Herbal Medicines 新西兰草药【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/113. P2 - Herbal Medicines 新西兰草药.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-18": {
+ "examId": "p3-medium-18",
+ "dataKey": "p3-medium-18",
+ "script": "./p3-medium-18.js",
+ "title": "Unlocking the mystery of dreams 梦的解析",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/114. P3 - Unlocking the mystery of dreams 梦的解析【次】/",
+ "filename": "114. P3 - Unlocking the mystery of dreams 梦的解析【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/114. P3 - Unlocking the mystery of dreams 梦的解析.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-19": {
+ "examId": "p2-high-19",
+ "dataKey": "p2-high-19",
+ "script": "./p2-high-19.js",
+ "title": "Mind Music 脑海中的音乐(心灵音乐)",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】/",
+ "filename": "115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/115. P2 - Mind Music 脑海中的音乐(心灵音乐).pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-medium-20": {
+ "examId": "p1-medium-20",
+ "dataKey": "p1-medium-20",
+ "script": "./p1-medium-20.js",
+ "title": "The Development of Plastics 塑料的发展史",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/116. P1 - The Development of Plastics 塑料的发展史【次】/",
+ "filename": "116. P1 - The Development of Plastics 塑料的发展史【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/116. P1 - The Development of Plastics 塑料的发展史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-21": {
+ "examId": "p2-high-21",
+ "dataKey": "p2-high-21",
+ "script": "./p2-high-21.js",
+ "title": "Stress Less 工作压力",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/117. P2 - Stress Less 工作压力【高】/",
+ "filename": "117. P2 - Stress Less 工作压力【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/117. P2 - Stress Less 工作压力.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-22": {
+ "examId": "p3-medium-22",
+ "dataKey": "p3-medium-22",
+ "script": "./p3-medium-22.js",
+ "title": "Neanderthal Technology 尼安德特人的生存技艺",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】/",
+ "filename": "118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/118. P3 - Neanderthal Technology 尼安德特人的生存技艺.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-23": {
+ "examId": "p2-high-23",
+ "dataKey": "p2-high-23",
+ "script": "./p2-high-23.js",
+ "title": "The Constant Evolution of the Humble Tomato 番茄的演化",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】/",
+ "filename": "119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-24": {
+ "examId": "p1-high-24",
+ "dataKey": "p1-high-24",
+ "script": "./p1-high-24.js",
+ "title": "Rubber 橡胶",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/12. P1 - Rubber 橡胶【高】/",
+ "filename": "12. P1 - Rubber 橡胶【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/12. P1 - Rubber 橡胶.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-25": {
+ "examId": "p2-high-25",
+ "dataKey": "p2-high-25",
+ "script": "./p2-high-25.js",
+ "title": "Will Eating Less Make You Live Longer 节食与长寿",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】/",
+ "filename": "120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/120. P2 - Will Eating Less Make You Live Longer 节食与长寿.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-27": {
+ "examId": "p1-high-27",
+ "dataKey": "p1-high-27",
+ "script": "./p1-high-27.js",
+ "title": "Footprints in the Mud 恐龙脚印",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/122. P1 - Footprints in the Mud 恐龙脚印【高】/",
+ "filename": "122. P1 - Footprints in the Mud 恐龙脚印【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/122. P1 - Footprints in the Mud 恐龙脚印.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-28": {
+ "examId": "p3-low-28",
+ "dataKey": "p3-low-28",
+ "script": "./p3-low-28.js",
+ "title": "Images and Places 风景与印记",
+ "category": "P3",
+ "frequency": "次高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/123. P3 - Images and Places 风景与印记/",
+ "filename": "123. P3 - Images and Places 风景与印记.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/123. P3 - Images and Places 风景与印记.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-medium-29": {
+ "examId": "p1-medium-29",
+ "dataKey": "p1-medium-29",
+ "script": "./p1-medium-29.js",
+ "title": "The extinction of the cave bear 洞熊的灭绝",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/124. P1 - The extinction of the cave bear 洞熊的灭绝【次】/",
+ "filename": "124. P1 - The extinction of the cave bear 洞熊的灭绝【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/124. P1 - The extinction of the cave bear 洞熊的灭绝.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-30": {
+ "examId": "p1-low-30",
+ "dataKey": "p1-low-30",
+ "script": "./p1-low-30.js",
+ "title": "Investing in the Future 投资未来",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/125. P1 - Investing in the Future 投资未来/",
+ "filename": "125. P1 - Investing in the Future 投资未来.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/125. P1 - Investing in the Future 投资未来.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-31": {
+ "examId": "p1-high-31",
+ "dataKey": "p1-high-31",
+ "script": "./p1-high-31.js",
+ "title": "Dolls through the ages 玩偶的变迁史",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/126. P1 - Dolls through the ages 玩偶的变迁史【高】/",
+ "filename": "126. P1 - Dolls through the ages 玩偶的变迁史【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/126. P1 - Dolls through the ages 玩偶的变迁史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-32": {
+ "examId": "p3-high-32",
+ "dataKey": "p3-high-32",
+ "script": "./p3-high-32.js",
+ "title": "Science and Filmmaking 电影科学(CGI)",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/127. P3 - Science and Filmmaking 电影科学(CGI)【高】/",
+ "filename": "127. P3 - Science and Filmmaking 电影科学(CGI)【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/127. P3 - Science and Filmmaking 电影科学(CGI).pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-medium-33": {
+ "examId": "p1-medium-33",
+ "dataKey": "p1-medium-33",
+ "script": "./p1-medium-33.js",
+ "title": "The Pyramid of Cestius 罗马金字塔",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/128. P1 - The Pyramid of Cestius 罗马金字塔【次】/",
+ "filename": "128. P1 - The Pyramid of Cestius 罗马金字塔【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/128. P1 - The Pyramid of Cestius 罗马金字塔.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-34": {
+ "examId": "p1-low-34",
+ "dataKey": "p1-low-34",
+ "script": "./p1-low-34.js",
+ "title": "The Slow Food Organization 慢食运动组织",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/129. P1 - The Slow Food Organization 慢食运动组织/",
+ "filename": "129. P1 - The Slow Food Organization 慢食运动组织.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/129. P1 - The Slow Food Organization 慢食运动组织.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-35": {
+ "examId": "p1-low-35",
+ "dataKey": "p1-low-35",
+ "script": "./p1-low-35.js",
+ "title": "Sweet Trouble 澳洲制糖产业",
+ "category": "P1",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/13. P1 - Sweet Trouble 澳洲制糖产业/",
+ "filename": "13. P1 - Sweet Trouble 澳洲制糖产业.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/13. P1 - Sweet Trouble 澳洲制糖产业.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-36": {
+ "examId": "p3-low-36",
+ "dataKey": "p3-low-36",
+ "script": "./p3-low-36.js",
+ "title": "Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA/",
+ "filename": "130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-37": {
+ "examId": "p2-low-37",
+ "dataKey": "p2-low-37",
+ "script": "./p2-low-37.js",
+ "title": "Keeping the water away 洪水防控",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/131. P2 - Keeping the water away 洪水防控/",
+ "filename": "131. P2 - Keeping the water away 洪水防控.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/131. P2 - Keeping the water away 洪水防控.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-38": {
+ "examId": "p3-low-38",
+ "dataKey": "p3-low-38",
+ "script": "./p3-low-38.js",
+ "title": "Research into the effects of different teaching styles 教学风格研究",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/132. P3 - Research into the effects of different teaching styles 教学风格研究/",
+ "filename": "132. P3 - Research into the effects of different teaching styles 教学风格研究.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/132. P3 - Research into the effects of different teaching styles 教学风格研究.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-39": {
+ "examId": "p2-low-39",
+ "dataKey": "p2-low-39",
+ "script": "./p2-low-39.js",
+ "title": "How to be Happy 如何获得幸福",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/133. P2 - How to be Happy 如何获得幸福/",
+ "filename": "133. P2 - How to be Happy 如何获得幸福.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/133. P2 - How to be Happy 如何获得幸福.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-40": {
+ "examId": "p1-low-40",
+ "dataKey": "p1-low-40",
+ "script": "./p1-low-40.js",
+ "title": "Dyes and fabric dyeing 染料的历史",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/134. P1 - Dyes and fabric dyeing 染料的历史/",
+ "filename": "134. P1 - Dyes and fabric dyeing 染料的历史.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/134. P1 - Dyes and fabric dyeing 染料的历史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-41": {
+ "examId": "p2-low-41",
+ "dataKey": "p2-low-41",
+ "script": "./p2-low-41.js",
+ "title": "The Myth of the Eight-hour Sleep 八小时睡眠",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠/",
+ "filename": "135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-42": {
+ "examId": "p3-low-42",
+ "dataKey": "p3-low-42",
+ "script": "./p3-low-42.js",
+ "title": "The peopling of Patagonia 巴塔哥尼亚的人类迁徙",
+ "category": "P3",
+ "frequency": "次高频",
+ "difficultyScore": 4.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙/",
+ "filename": "136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-43": {
+ "examId": "p3-low-43",
+ "dataKey": "p3-low-43",
+ "script": "./p3-low-43.js",
+ "title": "What is social history 社会史",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/137. P3 - What is social history 社会史/",
+ "filename": "137. P3 - What is social history 社会史.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/137. P3 - What is social history 社会史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-44": {
+ "examId": "p3-low-44",
+ "dataKey": "p3-low-44",
+ "script": "./p3-low-44.js",
+ "title": "Conformity 从众心理",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/138. P3 - Conformity 从众心理/",
+ "filename": "138. P3 - Conformity 从众心理.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/138. P3 - Conformity 从众心理.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-45": {
+ "examId": "p1-low-45",
+ "dataKey": "p1-low-45",
+ "script": "./p1-low-45.js",
+ "title": "Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究/",
+ "filename": "139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-46": {
+ "examId": "p1-low-46",
+ "dataKey": "p1-low-46",
+ "script": "./p1-low-46.js",
+ "title": "Sydney Opera House 悉尼歌剧院",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/14. P1 - Sydney Opera House 悉尼歌剧院/",
+ "filename": "14. P1 - Sydney Opera House 悉尼歌剧院.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/14. P1 - Sydney Opera House 悉尼歌剧院.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-47": {
+ "examId": "p1-low-47",
+ "dataKey": "p1-low-47",
+ "script": "./p1-low-47.js",
+ "title": "The Burgess Shale fossils 伯吉斯页岩",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/140. P1 - The Burgess Shale fossils 伯吉斯页岩/",
+ "filename": "140. P1 - The Burgess Shale fossils 伯吉斯页岩.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/140. P1 - The Burgess Shale fossils 伯吉斯页岩.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-48": {
+ "examId": "p1-low-48",
+ "dataKey": "p1-low-48",
+ "script": "./p1-low-48.js",
+ "title": "The history of the guitar 吉他的历史",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/141. P1 - The history of the guitar 吉他的历史/",
+ "filename": "141. P1 - The history of the guitar 吉他的历史.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-49": {
+ "examId": "p2-low-49",
+ "dataKey": "p2-low-49",
+ "script": "./p2-low-49.js",
+ "title": "Born to Trade 交易的本能",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/142. P2 - Born to Trade 交易的本能/",
+ "filename": "142. P2 - Born to Trade 交易的本能.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/142. P2 - Born to Trade 交易的本能.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-50": {
+ "examId": "p2-low-50",
+ "dataKey": "p2-low-50",
+ "script": "./p2-low-50.js",
+ "title": "Jellyfish – The Dominant Species 水母·海洋中的优势物种",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种/",
+ "filename": "143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-51": {
+ "examId": "p2-low-51",
+ "dataKey": "p2-low-51",
+ "script": "./p2-low-51.js",
+ "title": "The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异/",
+ "filename": "144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-52": {
+ "examId": "p1-low-52",
+ "dataKey": "p1-low-52",
+ "script": "./p1-low-52.js",
+ "title": "Caral an ancient South American city 卡拉尔古城",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/145. P1 - Caral an ancient South American city 卡拉尔古城/",
+ "filename": "145. P1 - Caral an ancient South American city 卡拉尔古城.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/145. P1 - Caral an ancient South American city 卡拉尔古城.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-53": {
+ "examId": "p1-low-53",
+ "dataKey": "p1-low-53",
+ "script": "./p1-low-53.js",
+ "title": "The Early History of Olive Oil 橄榄油的历史",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/146. P1 - The Early History of Olive Oil 橄榄油的历史/",
+ "filename": "146. P1 - The Early History of Olive Oil 橄榄油的历史.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/146. P1 - The Early History of Olive Oil 橄榄油的历史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-54": {
+ "examId": "p3-low-54",
+ "dataKey": "p3-low-54",
+ "script": "./p3-low-54.js",
+ "title": "Movement Underwater 水下运动",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/147. P3 - Movement Underwater 水下运动/",
+ "filename": "147. P3 - Movement Underwater 水下运动.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/147. P3 - Movement Underwater 水下运动.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-55": {
+ "examId": "p3-low-55",
+ "dataKey": "p3-low-55",
+ "script": "./p3-low-55.js",
+ "title": "Improving Patient Safety 药品包装设计",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/148. P3 - Improving Patient Safety 药品包装设计/",
+ "filename": "148. P3 - Improving Patient Safety 药品包装设计.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/148. P3 - Improving Patient Safety 药品包装设计.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-56": {
+ "examId": "p3-low-56",
+ "dataKey": "p3-low-56",
+ "script": "./p3-low-56.js",
+ "title": "Learning to be bilingual 双语学习",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/149. P3 - Learning to be bilingual 双语学习/",
+ "filename": "149. P3 - Learning to be bilingual 双语学习.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/149. P3 - Learning to be bilingual 双语学习.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-medium-57": {
+ "examId": "p1-medium-57",
+ "dataKey": "p1-medium-57",
+ "script": "./p1-medium-57.js",
+ "title": "The Blockbuster Phenomenon 博物馆爆款现象",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】/",
+ "filename": "15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-medium-58": {
+ "examId": "p2-medium-58",
+ "dataKey": "p2-medium-58",
+ "script": "./p2-medium-58.js",
+ "title": "Insect Decision-Making 昆虫决策",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/150. P2 - Insect Decision-Making 昆虫决策【次】/",
+ "filename": "150. P2 - Insect Decision-Making 昆虫决策【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/150. P2 - Insect Decision-Making 昆虫决策.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-59": {
+ "examId": "p3-low-59",
+ "dataKey": "p3-low-59",
+ "script": "./p3-low-59.js",
+ "title": "Inside the mind of a fan 观赛心境",
+ "category": "P3",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/151. P3 - Inside the mind of a fan 观赛心境/",
+ "filename": "151. P3 - Inside the mind of a fan 观赛心境.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/151. P3 - Inside the mind of a fan 观赛心境.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-medium-60": {
+ "examId": "p1-medium-60",
+ "dataKey": "p1-medium-60",
+ "script": "./p1-medium-60.js",
+ "title": "Sorry—who are you 脸盲症",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/152. P1 - Sorry—who are you 脸盲症【次】/",
+ "filename": "152. P1 - Sorry—who are you 脸盲症【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/152. P1 - Sorry—who are you 脸盲症.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-61": {
+ "examId": "p1-low-61",
+ "dataKey": "p1-low-61",
+ "script": "./p1-low-61.js",
+ "title": "Carnivorous plants 食虫植物",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/153. P1 - Carnivorous plants 食虫植物/",
+ "filename": "153. P1 - Carnivorous plants 食虫植物.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/153. P1 - Carnivorous plants 食虫植物.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-62": {
+ "examId": "p2-low-62",
+ "dataKey": "p2-low-62",
+ "script": "./p2-low-62.js",
+ "title": "The purpose of facial expressions 面部表情",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/154. P2 - The purpose of facial expressions 面部表情/",
+ "filename": "154. P2 - The purpose of facial expressions 面部表情.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/154. P2 - The purpose of facial expressions 面部表情.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-medium-63": {
+ "examId": "p1-medium-63",
+ "dataKey": "p1-medium-63",
+ "script": "./p1-medium-63.js",
+ "title": "A Brief History of Humans and Food 人类食物的历史",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/155. P1 - A Brief History of Humans and Food 人类食物的历史【次】/",
+ "filename": "155. P1 - A Brief History of Humans and Food 人类食物的历史【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/155. P1 - A Brief History of Humans and Food 人类食物的历史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-64": {
+ "examId": "p2-low-64",
+ "dataKey": "p2-low-64",
+ "script": "./p2-low-64.js",
+ "title": "New filter promises clean water for millions 新型泥土净水器",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/156. P2 - New filter promises clean water for millions 新型泥土净水器/",
+ "filename": "156. P2 - New filter promises clean water for millions 新型泥土净水器.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/156. P2 - New filter promises clean water for millions 新型泥土净水器.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-65": {
+ "examId": "p2-low-65",
+ "dataKey": "p2-low-65",
+ "script": "./p2-low-65.js",
+ "title": "Boring Buildings 无聊建筑",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/157. P2 - Boring Buildings 无聊建筑/",
+ "filename": "157. P2 - Boring Buildings 无聊建筑.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/157. P2 - Boring Buildings 无聊建筑.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-66": {
+ "examId": "p3-medium-66",
+ "dataKey": "p3-medium-66",
+ "script": "./p3-medium-66.js",
+ "title": "Mercator - The Map Maker 地理制图师",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/158. P3 - Mercator - The Map Maker 地理制图师【次】/",
+ "filename": "158. P3 - Mercator - The Map Maker 地理制图师【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/158. P3 - Mercator - The Map Maker 地理制图师.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-67": {
+ "examId": "p1-low-67",
+ "dataKey": "p1-low-67",
+ "script": "./p1-low-67.js",
+ "title": "Scented Plants 植物的味道",
+ "category": "P1",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/159. P1 - Scented Plants 植物的味道/",
+ "filename": "159. P1 - Scented Plants 植物的味道.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/159. P1 - Scented Plants 植物的味道.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-68": {
+ "examId": "p1-low-68",
+ "dataKey": "p1-low-68",
+ "script": "./p1-low-68.js",
+ "title": "The Clipper Races 帆船竞速",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/16. P1 - The Clipper Races 帆船竞速/",
+ "filename": "16. P1 - The Clipper Races 帆船竞速.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/16. P1 - The Clipper Races 帆船竞速.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-69": {
+ "examId": "p1-low-69",
+ "dataKey": "p1-low-69",
+ "script": "./p1-low-69.js",
+ "title": "An important language development 楔形文字",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": 2,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/160. P1 - An important language development 楔形文字/",
+ "filename": "160. P1 - An important language development 楔形文字.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/160. P1 - An important language development 楔形文字.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-70": {
+ "examId": "p1-low-70",
+ "dataKey": "p1-low-70",
+ "script": "./p1-low-70.js",
+ "title": "Fluorescence Deep sea discovery深海发光生物研究",
+ "category": "P1",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/161. P1 - Fluorescence Deep sea discovery深海发光生物研究/",
+ "filename": "161. P1 - Fluorescence Deep sea discovery深海发光生物研究.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/161. P1 - Deep sea discovery 深海发光生物研究.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-71": {
+ "examId": "p3-low-71",
+ "dataKey": "p3-low-71",
+ "script": "./p3-low-71.js",
+ "title": "Sea Change for Salinity 土地盐碱化",
+ "category": "P3",
+ "frequency": "次高频",
+ "difficultyScore": 4.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/162. P3 - Sea Change for Salinity 土地盐碱化/",
+ "filename": "162. P3 - Sea Change for Salinity 土地盐碱化.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/162. P3 - Sea Change for Salinity 土地盐碱化.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-72": {
+ "examId": "p1-low-72",
+ "dataKey": "p1-low-72",
+ "script": "./p1-low-72.js",
+ "title": "How to find your way out of a food desert 城市食物荒漠",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/163. P1 - How to find your way out of a food desert 城市食物荒漠/",
+ "filename": "163. P1 - How to find your way out of a food desert 城市食物荒漠.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/163. P1 - How to find your way out of a food desert 城市食物荒漠.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-73": {
+ "examId": "p2-low-73",
+ "dataKey": "p2-low-73",
+ "script": "./p2-low-73.js",
+ "title": "The Power of Smell 嗅觉的力量",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/164. P2 - The Power of Smell 嗅觉的力量/",
+ "filename": "164. P2 - The Power of Smell 嗅觉的力量.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/164. P2 - The Power of Smell 嗅觉的力量.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-74": {
+ "examId": "p3-low-74",
+ "dataKey": "p3-low-74",
+ "script": "./p3-low-74.js",
+ "title": "The Placebo Effect5 安慰剂效应",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/165. P3 - The Placebo Effect5 安慰剂效应/",
+ "filename": "165. P3 - The Placebo Effect5 安慰剂效应.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/165. P3 - The Placebo Effect5 安慰剂效应.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-75": {
+ "examId": "p2-low-75",
+ "dataKey": "p2-low-75",
+ "script": "./p2-low-75.js",
+ "title": "Lean Production Innovation 精益生产",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/166. P2 - Lean Production Innovation 精益生产/",
+ "filename": "166. P2 - Lean Production Innovation 精益生产.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/166. P2 - Lean Production Innovation 精益生产.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-76": {
+ "examId": "p3-low-76",
+ "dataKey": "p3-low-76",
+ "script": "./p3-low-76.js",
+ "title": "Sign, Baby, Sign! 美国手语",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/167. P3 - Sign, Baby, Sign! 美国手语/",
+ "filename": "167. P3 - Sign, Baby, Sign! 美国手语.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/167. P3 - Sign, Baby, Sign! 美国手语.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-77": {
+ "examId": "p2-low-77",
+ "dataKey": "p2-low-77",
+ "script": "./p2-low-77.js",
+ "title": "Mammoth Kill 猛犸象的灭绝",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/168. P2 - Mammoth Kill 猛犸象的灭绝/",
+ "filename": "168. P2 - Mammoth Kill 猛犸象的灭绝.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/168. P2 - Mammoth Kill 猛犸象的灭绝.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-78": {
+ "examId": "p3-low-78",
+ "dataKey": "p3-low-78",
+ "script": "./p3-low-78.js",
+ "title": "The Costs of Brand Loyalty 品牌忠诚的代价",
+ "category": "P3",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价/",
+ "filename": "169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-79": {
+ "examId": "p1-high-79",
+ "dataKey": "p1-high-79",
+ "script": "./p1-high-79.js",
+ "title": "The Development of The Silk Industry 丝绸产业发展",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": 2,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/17. P1 - The Development of The Silk Industry 丝绸产业发展【高】/",
+ "filename": "17. P1 - The Development of The Silk Industry 丝绸产业发展【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/17. P1 - The Development of The Silk Industry 丝绸产业发展.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-80": {
+ "examId": "p1-low-80",
+ "dataKey": "p1-low-80",
+ "script": "./p1-low-80.js",
+ "title": "The unsung sense 被低估的嗅觉",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/170. P1 - The unsung sense 被低估的嗅觉/",
+ "filename": "170. P1 - The unsung sense 被低估的嗅觉.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/170. P1 - The unsung sense 被低估的嗅觉.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-81": {
+ "examId": "p1-low-81",
+ "dataKey": "p1-low-81",
+ "script": "./p1-low-81.js",
+ "title": "Salt 盐的历史",
+ "category": "P1",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/171. P1 - Salt 盐的历史/",
+ "filename": "171. P1 - Salt 盐的历史.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/171. P1 - Salt 盐的历史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-82": {
+ "examId": "p1-high-82",
+ "dataKey": "p1-high-82",
+ "script": "./p1-high-82.js",
+ "title": "Think Small 微观科学",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/172. P1 - Think Small 微观科学【高】/",
+ "filename": "172. P1 - Think Small 微观科学.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/172. P1 - Think Small 微观科学.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-83": {
+ "examId": "p3-low-83",
+ "dataKey": "p3-low-83",
+ "script": "./p3-low-83.js",
+ "title": "1018纸笔 Looking for inspiration 寻找灵感",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/173. 1018纸笔 P3 - Looking for inspiration 寻找灵感/",
+ "filename": "173. 1018纸笔 P3 - Looking for inspiration 寻找灵感.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/173. P3(1018纸笔 ) - Looking for inspiration 寻找灵感.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-84": {
+ "examId": "p1-low-84",
+ "dataKey": "p1-low-84",
+ "script": "./p1-low-84.js",
+ "title": "Why good ideas fail TF公司",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/174. P1 - Why good ideas fail TF公司/",
+ "filename": "174. P1 - Why good ideas fail TF公司.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/174. P1 - Why good ideas fail TF公司.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-85": {
+ "examId": "p3-low-85",
+ "dataKey": "p3-low-85",
+ "script": "./p3-low-85.js",
+ "title": "Music soothes and awes 音乐疗愈",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/175. P3 - Music soothes and awes 音乐疗愈/",
+ "filename": "175. P3 - Music soothes and awes 音乐疗愈.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/175. P3 - Music soothes and awes 音乐疗愈.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-medium-86": {
+ "examId": "p2-medium-86",
+ "dataKey": "p2-medium-86",
+ "script": "./p2-medium-86.js",
+ "title": "Urban Regeneration 柏林公园改造",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/176. P2 - Urban Regeneration 柏林公园改造【次】/",
+ "filename": "176. P2 - Urban Regeneration 柏林公园改造.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/176. P2 - Urban Regeneration 柏林公园改造.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-87": {
+ "examId": "p2-low-87",
+ "dataKey": "p2-low-87",
+ "script": "./p2-low-87.js",
+ "title": "1025纸笔Speaking of Nothing [Pretest] 闲聊的意义",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义/",
+ "filename": "177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/177. P2(1025纸笔)[Pretest] - Speaking of Nothing 闲聊的意义.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-88": {
+ "examId": "p3-low-88",
+ "dataKey": "p3-low-88",
+ "script": "./p3-low-88.js",
+ "title": "1025纸笔Translating a key to international understanding 翻译的艺术",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术/",
+ "filename": "178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/178. P3(1025纸笔)[Pretest] - Translating a key to international understanding 翻译的艺术.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-89": {
+ "examId": "p3-high-89",
+ "dataKey": "p3-high-89",
+ "script": "./p3-high-89.js",
+ "title": "Looking at daily life in ancient Rome 古罗马的日常",
+ "category": "P3",
+ "frequency": "次高频",
+ "difficultyScore": 5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/179. P3 - Looking at daily life in ancient Rome 古罗马的日常【高】/",
+ "filename": "179. P3 - Looking at daily life in ancient Rome 古罗马的日常.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/179. P3 - Looking at daily life in ancient Rome 古罗马的日常.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-90": {
+ "examId": "p1-high-90",
+ "dataKey": "p1-high-90",
+ "script": "./p1-high-90.js",
+ "title": "The History of Tea 茶叶的历史",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/18. P1 - The History of Tea 茶叶的历史【高】/",
+ "filename": "18. P1 - The History of Tea 茶叶的历史【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/18. P1 - The History of Tea 茶叶的历史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-91": {
+ "examId": "p2-high-91",
+ "dataKey": "p2-high-91",
+ "script": "./p2-high-91.js",
+ "title": "Australia’s camouflaged creatures 澳洲伪装生物",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物【高】/",
+ "filename": "180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-92": {
+ "examId": "p1-high-92",
+ "dataKey": "p1-high-92",
+ "script": "./p1-high-92.js",
+ "title": "Dust and the American West 美国西部尘埃",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/181. P1 - Dust and the American West 美国西部尘埃【高】/",
+ "filename": "181. P1 - Dust and the American West 美国西部尘埃.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/181. P1 - Dust and the American West 美国西部尘埃.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-medium-93": {
+ "examId": "p2-medium-93",
+ "dataKey": "p2-medium-93",
+ "script": "./p2-medium-93.js",
+ "title": "Antarctic research 南极考察",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/182. P2 - Antarctic research 南极考察【次】/",
+ "filename": "182. P2 - Antarctic research 南极考察.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/182. P2 - Antarctic research 南极考察.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-94": {
+ "examId": "p2-low-94",
+ "dataKey": "p2-low-94",
+ "script": "./p2-low-94.js",
+ "title": "The importance of being playful 玩耍的重要性",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/183. P2 - The importance of being playful 玩耍的重要性/",
+ "filename": "183. P2 - The importance of being playful 玩耍的重要性.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/183. P2 - The importance of being playful 玩耍的重要性.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-95": {
+ "examId": "p3-low-95",
+ "dataKey": "p3-low-95",
+ "script": "./p3-low-95.js",
+ "title": "The strange world of sight 奇异的视觉世界",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/184. P3 - The strange world of sight 奇异的视觉世界/",
+ "filename": "184. P3 - The strange world of sight 奇异的视觉世界.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/184. P3 - The strange world of sight 奇异的视觉世界.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-96": {
+ "examId": "p2-low-96",
+ "dataKey": "p2-low-96",
+ "script": "./p2-low-96.js",
+ "title": "[Pretest] Why Do We Need Sleep 睡眠的目的",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的/",
+ "filename": "185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-97": {
+ "examId": "p3-low-97",
+ "dataKey": "p3-low-97",
+ "script": "./p3-low-97.js",
+ "title": "Saving languages 拯救濒危语言",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/186. P3 - Saving languages 拯救濒危语言/",
+ "filename": "186. P3 - Saving languages 拯救濒危语言.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/186. P3 - Saving languages 拯救濒危语言.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-98": {
+ "examId": "p3-low-98",
+ "dataKey": "p3-low-98",
+ "script": "./p3-low-98.js",
+ "title": "Petrol power an eco-revolution 交通的革命",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/187. P3 - Petrol power an eco-revolution 交通的革命/",
+ "filename": "187. P3 - Petrol power an eco-revolution 交通的革命.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/187. P3 - Petrol power an eco-revolution 交通的革命.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-99": {
+ "examId": "p1-low-99",
+ "dataKey": "p1-low-99",
+ "script": "./p1-low-99.js",
+ "title": "The history of the bar code 条形码的历史",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/188. P1 - The history of the bar code 条形码的历史/",
+ "filename": "188. P1 - The history of the bar code 条形码的历史.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/188. P1 - The history of the bar code 条形码的历史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-100": {
+ "examId": "p3-low-100",
+ "dataKey": "p3-low-100",
+ "script": "./p3-low-100.js",
+ "title": "Mirror 镜子研究",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/189. P3 - Mirror 镜子研究/",
+ "filename": "189. P3 - Mirror 镜子研究.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/189. P3 - Mirror 镜子研究.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-101": {
+ "examId": "p1-high-101",
+ "dataKey": "p1-high-101",
+ "script": "./p1-high-101.js",
+ "title": "The Impact of the Potato 土豆的影响",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 1,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/19. P1 - The Impact of the Potato 土豆的影响【高】/",
+ "filename": "19. P1 - The Impact of the Potato 土豆的影响【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/19. P1 - The Impact of the Potato 土豆的影响.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-102": {
+ "examId": "p2-low-102",
+ "dataKey": "p2-low-102",
+ "script": "./p2-low-102.js",
+ "title": "The power of music 音乐的力量",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/190. P2 - The power of music 音乐的力量/",
+ "filename": "190. P2 - The power of music 音乐的力量.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/190. P2 - The power of music 音乐的力量.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-103": {
+ "examId": "p2-low-103",
+ "dataKey": "p2-low-103",
+ "script": "./p2-low-103.js",
+ "title": "The economic effect of climate 气候对经济的影响",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/191. P2 - The economic effect of climate 气候对经济的影响/",
+ "filename": "191. P2 - The economic effect of climate 气候对经济的影响.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/191. P2 - The economic effect of climate 气候对经济的影响.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-104": {
+ "examId": "p2-low-104",
+ "dataKey": "p2-low-104",
+ "script": "./p2-low-104.js",
+ "title": "1115纸笔Should we stop eating meat 是否应该吃素",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素/",
+ "filename": "192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-105": {
+ "examId": "p1-high-105",
+ "dataKey": "p1-high-105",
+ "script": "./p1-high-105.js",
+ "title": "A survivor’s story 新西兰猫头鹰",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/2. P1 - A survivor’s story 新西兰猫头鹰【高】/",
+ "filename": "2. P1 - A survivor’s story 新西兰猫头鹰【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/2. P1 - A survivor’s story 新西兰猫头鹰.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-106": {
+ "examId": "p1-low-106",
+ "dataKey": "p1-low-106",
+ "script": "./p1-low-106.js",
+ "title": "The Importance of Business Cards 名片的重要性",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/20. P1 - The Importance of Business Cards 名片的重要性/",
+ "filename": "20. P1 - The Importance of Business Cards 名片的重要性.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/20. P1 - The Importance of Business Cards 名片的重要性.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-107": {
+ "examId": "p1-low-107",
+ "dataKey": "p1-low-107",
+ "script": "./p1-low-107.js",
+ "title": "The life of Beatrix Potter 彼得兔作家",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/21. P1 - The life of Beatrix Potter 彼得兔作家/",
+ "filename": "21. P1 - The life of Beatrix Potter 彼得兔作家.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/21. P1 - The life of Beatrix Potter 彼得兔作家.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-108": {
+ "examId": "p1-low-108",
+ "dataKey": "p1-low-108",
+ "script": "./p1-low-108.js",
+ "title": "The nature of Yawning 打哈欠的本质",
+ "category": "P1",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/22. P1 - The nature of Yawning 打哈欠的本质/",
+ "filename": "22. P1 - The nature of Yawning 打哈欠的本质.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/22. P1 - The nature of Yawning 打哈欠的本质.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-109": {
+ "examId": "p1-low-109",
+ "dataKey": "p1-low-109",
+ "script": "./p1-low-109.js",
+ "title": "The Origin of Paper 造纸术起源",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/23. P1 - The Origin of Paper 造纸术起源/",
+ "filename": "23. P1 - The Origin of Paper 造纸术起源.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/23. P1 - The Origin of Paper 造纸术起源.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-110": {
+ "examId": "p1-high-110",
+ "dataKey": "p1-high-110",
+ "script": "./p1-high-110.js",
+ "title": "The Pearls 珍珠",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/24. P1 - The Pearls 珍珠【高】/",
+ "filename": "24. P1 - The Pearls 珍珠【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/24. P1 - The Pearls 珍珠.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-111": {
+ "examId": "p1-low-111",
+ "dataKey": "p1-low-111",
+ "script": "./p1-low-111.js",
+ "title": "The Rise and Fall of Detective Stories 侦探小说的兴衰",
+ "category": "P1",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰/",
+ "filename": "25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-112": {
+ "examId": "p1-low-112",
+ "dataKey": "p1-low-112",
+ "script": "./p1-low-112.js",
+ "title": "The Tuatara of New Zealand 新西兰蜥蜴",
+ "category": "P1",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴/",
+ "filename": "26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-113": {
+ "examId": "p1-low-113",
+ "dataKey": "p1-low-113",
+ "script": "./p1-low-113.js",
+ "title": "Thomas Young The last man who knew everything 托马斯·杨",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/27. P1 - Thomas Young The last man who knew everything 托马斯·杨/",
+ "filename": "27. P1 - Thomas Young The last man who knew everything 托马斯·杨.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/27. P1 - Thomas Young The last man who knew everything 托马斯·杨.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-114": {
+ "examId": "p1-low-114",
+ "dataKey": "p1-low-114",
+ "script": "./p1-low-114.js",
+ "title": "Triumph of the City 城市的胜利",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": 1.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/28. P1 - Triumph of the City 城市的胜利/",
+ "filename": "28. P1 - Triumph of the City 城市的胜利.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/28. P1 - Triumph of the City 城市的胜利.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-medium-115": {
+ "examId": "p1-medium-115",
+ "dataKey": "p1-medium-115",
+ "script": "./p1-medium-115.js",
+ "title": "Tunnelling under the Thames",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】/",
+ "filename": "29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/29. P1 - Tunnelling under the Thames 泰晤士河隧道.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-116": {
+ "examId": "p1-low-116",
+ "dataKey": "p1-low-116",
+ "script": "./p1-low-116.js",
+ "title": "Advertising Needs Attention 广告的吸引力",
+ "category": "P1",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/3. P1 - Advertising Needs Attention 广告的吸引力/",
+ "filename": "3. P1 - Advertising Needs Attention 广告的吸引力.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/3. P1 - Advertising Needs Attention 广告的吸引力.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-medium-117": {
+ "examId": "p1-medium-117",
+ "dataKey": "p1-medium-117",
+ "script": "./p1-medium-117.js",
+ "title": "What Lucy Taught Us 露西化石",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/30. P1 - What Lucy Taught Us 露西化石【次】/",
+ "filename": "30. P1 - What Lucy Taught Us 露西化石【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/30. P1 - What Lucy Taught Us 露西化石.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-118": {
+ "examId": "p1-high-118",
+ "dataKey": "p1-high-118",
+ "script": "./p1-high-118.js",
+ "title": "William Gilbert and Magnetism 电磁学之父",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/31. P1 - William Gilbert and Magnetism 电磁学之父【高】/",
+ "filename": "31. P1 - William Gilbert and Magnetism 电磁学之父【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/31. P1 - William Gilbert and Magnetism 电磁学之父.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-medium-119": {
+ "examId": "p1-medium-119",
+ "dataKey": "p1-medium-119",
+ "script": "./p1-medium-119.js",
+ "title": "Wood 新西兰木材产业",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": 2,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/32. P1 - Wood 新西兰木材产业【次】/",
+ "filename": "32. P1 - Wood 新西兰木材产业【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/32. P1 - Wood 新西兰木材产业.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-120": {
+ "examId": "p2-high-120",
+ "dataKey": "p2-high-120",
+ "script": "./p2-high-120.js",
+ "title": "A new look for Talbot Park 奥克兰社区改造",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/33. P2 - A new look for Talbot Park 奥克兰社区改造【高】/",
+ "filename": "ai_studio_code (9).html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/33. P2 - A new look for Talbot Park 奥克兰社区改造.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-medium-121": {
+ "examId": "p2-medium-121",
+ "dataKey": "p2-medium-121",
+ "script": "./p2-medium-121.js",
+ "title": "A unique golden textile 蜘蛛丝",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/34. P2 - A unique golden textile 蜘蛛丝【次】/",
+ "filename": "34. P2 - A unique golden textile 蜘蛛丝【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/34. P2 - A unique golden textile 蜘蛛丝.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-122": {
+ "examId": "p2-low-122",
+ "dataKey": "p2-low-122",
+ "script": "./p2-low-122.js",
+ "title": "Biophilic Design 亲自然设计",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/35. P2 - Biophilic Design 亲自然设计/",
+ "filename": "35. P2 - Biophilic Design 亲自然设计.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/35. P2 - Biophilic Design 亲自然设计.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-123": {
+ "examId": "p2-high-123",
+ "dataKey": "p2-high-123",
+ "script": "./p2-high-123.js",
+ "title": "Bird Migration 鸟类迁徙",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/36. P2 - Bird Migration 鸟类迁徙【高】/",
+ "filename": "36. P2 - Bird Migration 鸟类迁徙【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/36. P2 - Bird Migration 鸟类迁徙.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-124": {
+ "examId": "p2-high-124",
+ "dataKey": "p2-high-124",
+ "script": "./p2-high-124.js",
+ "title": "Corporate Social Responsibility 企业社会责任",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/37. P2 - Corporate Social Responsibility 企业社会责任【高】/",
+ "filename": "37. P2 - Corporate Social Responsibility 企业社会责任【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/37. P2 - Corporate Social Responsibility 企业社会责任.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-125": {
+ "examId": "p2-low-125",
+ "dataKey": "p2-low-125",
+ "script": "./p2-low-125.js",
+ "title": "Egypt’s ancient boat-builders 古埃及造船",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/38. P2 - Egypt’s ancient boat-builders 古埃及造船/",
+ "filename": "38. P2 - Egypt’s ancient boat-builders 古埃及造船.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/38. P2 - Egypt’s ancient boat-builders 古埃及造船.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-medium-126": {
+ "examId": "p2-medium-126",
+ "dataKey": "p2-medium-126",
+ "script": "./p2-medium-126.js",
+ "title": "How are deserts formed 沙漠成因",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/39. P2 - How are deserts formed 沙漠成因【次】/",
+ "filename": "39. P2 - How are deserts formed 沙漠成因【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/39. P2 - How are deserts formed 沙漠成因.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-127": {
+ "examId": "p1-low-127",
+ "dataKey": "p1-low-127",
+ "script": "./p1-low-127.js",
+ "title": "Ambergris 龙涎香",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/4. P1 - Ambergris 龙涎香/",
+ "filename": "4. P1 - Ambergris 龙涎香.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/4. P1 - Ambergris 龙涎香.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-128": {
+ "examId": "p2-high-128",
+ "dataKey": "p2-high-128",
+ "script": "./p2-high-128.js",
+ "title": "How Well Do We Concentrate_ 多任务处理",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/40. P2 - How Well Do We Concentrate_ 多任务处理【高】/",
+ "filename": "40. P2 - How Well Do We Concentrate_ 多任务处理【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/40. P2 - How Well Do We Concentrate_ 多任务处理.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-medium-129": {
+ "examId": "p2-medium-129",
+ "dataKey": "p2-medium-129",
+ "script": "./p2-medium-129.js",
+ "title": "Intelligent behaviour in birds 鸟类智慧行为",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】/",
+ "filename": "41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/41. P2 - Intelligent behaviour in birds 鸟类智慧行为.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-130": {
+ "examId": "p2-high-130",
+ "dataKey": "p2-high-130",
+ "script": "./p2-high-130.js",
+ "title": "Investment in shares versus investment in other assets 回报数据分析",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】/",
+ "filename": "42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/42. P2 - Investment in shares versus investment in other assets 回报数据分析.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-131": {
+ "examId": "p2-high-131",
+ "dataKey": "p2-high-131",
+ "script": "./p2-high-131.js",
+ "title": "Learning from the Romans 罗马混凝土",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/43. P2 - Learning from the Romans 罗马混凝土【高】/",
+ "filename": "43. P2 - Learning from the Romans 罗马混凝土【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/43. P2 - Learning from the Romans 罗马混凝土.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-132": {
+ "examId": "p2-low-132",
+ "dataKey": "p2-low-132",
+ "script": "./p2-low-132.js",
+ "title": "Orientation of Birds 鸟类的定位能力",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/44. P2 - Orientation of Birds 鸟类的定位能力/",
+ "filename": "44. P2 - Orientation of Birds 鸟类的定位能力.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/44. P2 - Orientation of Birds 鸟类的定位能力.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-133": {
+ "examId": "p2-high-133",
+ "dataKey": "p2-high-133",
+ "script": "./p2-high-133.js",
+ "title": "Playing soccer 街头足球",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/45. P2 - Playing soccer 街头足球【高】/",
+ "filename": "45. P2 - Playing soccer 街头足球【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/45. P2 - Playing soccer 街头足球.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-134": {
+ "examId": "p2-high-134",
+ "dataKey": "p2-high-134",
+ "script": "./p2-high-134.js",
+ "title": "Roller coaster 过山车",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/46. P2 - Roller coaster 过山车【高】/",
+ "filename": "46. P2 - Roller coaster 过山车【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/46. P2 - Roller coaster 过山车.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-135": {
+ "examId": "p2-low-135",
+ "dataKey": "p2-low-135",
+ "script": "./p2-low-135.js",
+ "title": "Skyscraper Farming 摩天大楼种植",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/47. P2 - Skyscraper Farming 摩天大楼种植/",
+ "filename": "47. P2 - Skyscraper Farming 摩天大楼种植.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/47. P2 - Skyscraper Farming 摩天大楼种植.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-136": {
+ "examId": "p2-high-136",
+ "dataKey": "p2-high-136",
+ "script": "./p2-high-136.js",
+ "title": "Solving the problem of waste disposal 垃圾处理",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/48. P2 - Solving the problem of waste disposal 垃圾处理【高】/",
+ "filename": "48. P2 - Solving the problem of waste disposal 垃圾处理【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/48. P2 - Solving the problem of waste disposal 垃圾处理.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-137": {
+ "examId": "p2-high-137",
+ "dataKey": "p2-high-137",
+ "script": "./p2-high-137.js",
+ "title": "Surviving city life 动物适应城市",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/49. P2 - Surviving city life 动物适应城市【高】/",
+ "filename": "49. P2 - Surviving city life 动物适应城市【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/49. P2 - Surviving city life 动物适应城市.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-138": {
+ "examId": "p1-low-138",
+ "dataKey": "p1-low-138",
+ "script": "./p1-low-138.js",
+ "title": "Australian artist Margaret Preston 澳大利亚艺术家",
+ "category": "P1",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家/",
+ "filename": "5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-139": {
+ "examId": "p2-high-139",
+ "dataKey": "p2-high-139",
+ "script": "./p2-high-139.js",
+ "title": "The conquest of malaria in Italy 意大利疟疾防治",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】/",
+ "filename": "50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/50. P2 - The conquest of malaria in Italy 意大利疟疾防治.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-140": {
+ "examId": "p2-low-140",
+ "dataKey": "p2-low-140",
+ "script": "./p2-low-140.js",
+ "title": "The dingo debate 澳洲野犬",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/51. P2 - The dingo debate 澳洲野犬/",
+ "filename": "51. P2 - The dingo debate 澳洲野犬.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-141": {
+ "examId": "p2-high-141",
+ "dataKey": "p2-high-141",
+ "script": "./p2-high-141.js",
+ "title": "The fascinating world of attine ants 切叶蚁",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/52. P2 - The fascinating world of attine ants 切叶蚁【高】/",
+ "filename": "52. P2 - The fascinating world of attine ants 切叶蚁【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/52. P2 - The fascinating world of attine ants 切叶蚁.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-142": {
+ "examId": "p2-low-142",
+ "dataKey": "p2-low-142",
+ "script": "./p2-low-142.js",
+ "title": "The fashion industry 时尚产业",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/53. P2 - The fashion industry 时尚产业/",
+ "filename": "53. P2 - The fashion industry 时尚产业.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/53. P2 - The fashion industry 时尚产业.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-143": {
+ "examId": "p2-low-143",
+ "dataKey": "p2-low-143",
+ "script": "./p2-low-143.js",
+ "title": "The impact of invasive species 入侵物种的影响",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/54. P2 - The impact of invasive species 入侵物种的影响/",
+ "filename": "54. P2 - The impact of invasive species 入侵物种的影响.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/54. P2 - The impact of invasive species 入侵物种的影响.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-medium-144": {
+ "examId": "p2-medium-144",
+ "dataKey": "p2-medium-144",
+ "script": "./p2-medium-144.js",
+ "title": "The plan to bring an asteroid to Earth 捕获小行星",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】/",
+ "filename": "55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/55. P2 - The plan to bring an asteroid to Earth 捕获小行星.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-145": {
+ "examId": "p2-high-145",
+ "dataKey": "p2-high-145",
+ "script": "./p2-high-145.js",
+ "title": "The return of monkey life 猴群回归",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/56. P2 - The return of monkey life 猴群回归【高】/",
+ "filename": "56. P2 - The return of monkey life 猴群回归【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/56. P2 - The return of monkey life 猴群回归.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-medium-146": {
+ "examId": "p2-medium-146",
+ "dataKey": "p2-medium-146",
+ "script": "./p2-medium-146.js",
+ "title": "The Tasmanian Tiger 袋狼",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/57. P2 - The Tasmanian Tiger 袋狼【次】/",
+ "filename": "57. P2 - The Tasmanian Tiger 袋狼【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/57. P2 - The Tasmanian Tiger 袋狼.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-147": {
+ "examId": "p2-low-147",
+ "dataKey": "p2-low-147",
+ "script": "./p2-low-147.js",
+ "title": "Who wrote Shakespeare's plays 莎士比亚",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/58. P2 - Who wrote Shakespeare's plays 莎士比亚/",
+ "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-148": {
+ "examId": "p2-low-148",
+ "dataKey": "p2-low-148",
+ "script": "./p2-low-148.js",
+ "title": "Why do we need the arts_ 艺术的意义",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/59. P2 - Why do we need the arts_ 艺术的意义/",
+ "filename": "59. P2 - Why do we need the arts_ 艺术的意义.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/59. P2 - Why do we need the arts_ 艺术的意义.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-149": {
+ "examId": "p1-low-149",
+ "dataKey": "p1-low-149",
+ "script": "./p1-low-149.js",
+ "title": "Categorizing societies 社会分类",
+ "category": "P1",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/6. P1 - Categorizing societies 社会分类/",
+ "filename": "6. P1 - Categorizing societies 社会分类html.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/6. P1 - Categorizing societies 社会分类.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-150": {
+ "examId": "p3-high-150",
+ "dataKey": "p3-high-150",
+ "script": "./p3-high-150.js",
+ "title": "A closer examination of a study on verbal and non-verbal messages 语言表达研究",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】/",
+ "filename": "60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-151": {
+ "examId": "p3-low-151",
+ "dataKey": "p3-low-151",
+ "script": "./p3-low-151.js",
+ "title": "Book Review The Discovery of Slowness 富兰克林(慢的发现)",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现)/",
+ "filename": "61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-152": {
+ "examId": "p3-medium-152",
+ "dataKey": "p3-medium-152",
+ "script": "./p3-medium-152.js",
+ "title": "Charles Darwin and Evolutionary Psychology 进化心理学",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】/",
+ "filename": "62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-153": {
+ "examId": "p3-low-153",
+ "dataKey": "p3-low-153",
+ "script": "./p3-low-153.js",
+ "title": "Crossing the Threshold 奥克兰美术馆",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/63. P3 - Crossing the Threshold 奥克兰美术馆/",
+ "filename": "63. P3 - Crossing the Threshold 奥克兰美术馆.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/63. P3 - Crossing the Threshold 奥克兰美术馆.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-154": {
+ "examId": "p3-medium-154",
+ "dataKey": "p3-medium-154",
+ "script": "./p3-medium-154.js",
+ "title": "Decisions, Decisions 决策之间",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/64. P3 - Decisions, Decisions 决策之间【次】/",
+ "filename": "64. P3 - Decisions, Decisions 决策之间【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/64. P3 - Decisions, Decisions 决策之间.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-155": {
+ "examId": "p3-medium-155",
+ "dataKey": "p3-medium-155",
+ "script": "./p3-medium-155.js",
+ "title": "Does class size matter_ 课堂规模",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": 4.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/65. P3 - Does class size matter_ 课堂规模【次】/",
+ "filename": "65. P3 - Does class size matter_ 课堂规模【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/65. P3 - Does class size matter 课堂规模.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-156": {
+ "examId": "p3-high-156",
+ "dataKey": "p3-high-156",
+ "script": "./p3-high-156.js",
+ "title": "Elephant Communication 大象交流",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/66. P3 - Elephant Communication 大象交流【高】/",
+ "filename": "66. P3 - Elephant Communication 大象交流【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/66. P3 - Elephant Communication 大象交流.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-157": {
+ "examId": "p3-high-157",
+ "dataKey": "p3-high-157",
+ "script": "./p3-high-157.js",
+ "title": "Flower Power 鲜花的力量(花之力)",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/67. P3 - Flower Power 鲜花的力量(花之力)【高】/",
+ "filename": "67. P3 - Flower Power 鲜花的力量(花之力)【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/67. P3 - Flower Power 鲜花的力量(花之力).pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-158": {
+ "examId": "p3-low-158",
+ "dataKey": "p3-low-158",
+ "script": "./p3-low-158.js",
+ "title": "Game theory 博弈论",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/68. P3 - Game theory 博弈论/",
+ "filename": "68. P3 - Game theory 博弈论.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/68. P3 - Game theory 博弈论.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-159": {
+ "examId": "p3-high-159",
+ "dataKey": "p3-high-159",
+ "script": "./p3-high-159.js",
+ "title": "Grimm’s Fairy Tales 格林童话",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/69. P3 - Grimm’s Fairy Tales 格林童话【高】/",
+ "filename": "69. P3 - Grimm’s Fairy Tales 格林童话【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/69. P3 - Grimm’s Fairy Tales 格林童话.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-160": {
+ "examId": "p1-low-160",
+ "dataKey": "p1-low-160",
+ "script": "./p1-low-160.js",
+ "title": "Chili peppers 辣椒的历史",
+ "category": "P1",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/7. P1 - Chili peppers 辣椒的历史/",
+ "filename": "7. P1 - Chili peppers 辣椒的历史.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/7. P1 - Chili peppers 辣椒的历史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-161": {
+ "examId": "p3-high-161",
+ "dataKey": "p3-high-161",
+ "script": "./p3-high-161.js",
+ "title": "Insect-inspired robots 昆虫机器人",
+ "category": "P3",
+ "frequency": "次高频",
+ "difficultyScore": 4.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/70. P3 - Insect-inspired robots 昆虫机器人【高】/",
+ "filename": "70. P3 - Insect-inspired robots 昆虫机器人【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/70. P3 - Insect-inspired robots 昆虫机器人.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-162": {
+ "examId": "p3-medium-162",
+ "dataKey": "p3-medium-162",
+ "script": "./p3-medium-162.js",
+ "title": "Jean Piaget (1896–1980) 让·皮亚杰",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": 5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】/",
+ "filename": "71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-163": {
+ "examId": "p3-low-163",
+ "dataKey": "p3-low-163",
+ "script": "./p3-low-163.js",
+ "title": "Keeping the Fun in Funfairs 游乐场设计科学",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学/",
+ "filename": "72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-164": {
+ "examId": "p3-high-164",
+ "dataKey": "p3-high-164",
+ "script": "./p3-high-164.js",
+ "title": "Language Strategy in Multinational Companies 跨国公司语言策略",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】/",
+ "filename": "73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-165": {
+ "examId": "p3-low-165",
+ "dataKey": "p3-low-165",
+ "script": "./p3-low-165.js",
+ "title": "Let’s teach them how to teach 教他们如何教学",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/74. P3 - Let’s teach them how to teach 教他们如何教学/",
+ "filename": "74. P3 - Let’s teach them how to teach 教他们如何教学.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/74. P3 - Let’s teach them how to teach 教他们如何教学.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-166": {
+ "examId": "p3-low-166",
+ "dataKey": "p3-low-166",
+ "script": "./p3-low-166.js",
+ "title": "Life on Mars_ 火星地球化改造",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/75. P3 - Life on Mars_ 火星地球化改造/",
+ "filename": "75. P3 - Life on Mars_ 火星地球化改造.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/75. P3 - Life on Mars 火星地球化改造.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-167": {
+ "examId": "p3-high-167",
+ "dataKey": "p3-high-167",
+ "script": "./p3-high-167.js",
+ "title": "Living dunes 流动沙丘",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/76. P3 - Living dunes 流动沙丘【高】/",
+ "filename": "76. P3 - Living dunes 流动沙丘【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/76. P3 - Living dunes 流动沙丘.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-168": {
+ "examId": "p3-medium-168",
+ "dataKey": "p3-medium-168",
+ "script": "./p3-medium-168.js",
+ "title": "Marketing and the information age 信息时代营销",
+ "category": "P3",
+ "frequency": "次高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/77. P3 - Marketing and the information age 信息时代营销【次】/",
+ "filename": "77. P3 - Marketing and the information age 信息时代营销【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/77. P3 - Marketing and the information age 信息时代营销.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-169": {
+ "examId": "p3-medium-169",
+ "dataKey": "p3-medium-169",
+ "script": "./p3-medium-169.js",
+ "title": "(无题目) Music Language We All Speak 音乐语言",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": 4.5,
+ "path": "睡着过项目组/1.11月高频文章[94篇+18背景]/P3 (29高+6次高)/2. P3次高频 (6篇)/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】/",
+ "filename": "78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-170": {
+ "examId": "p3-high-170",
+ "dataKey": "p3-high-170",
+ "script": "./p3-high-170.js",
+ "title": "Pacific Navigation and Voyaging 太平洋航海",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】/",
+ "filename": "79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/79. P3 - Pacific Navigation and Voyaging 太平洋航海.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-171": {
+ "examId": "p1-high-171",
+ "dataKey": "p1-high-171",
+ "script": "./p1-high-171.js",
+ "title": "Fishbourne Roman Palace 罗马宫殿",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/8. P1 - Fishbourne Roman Palace 罗马宫殿【高】/",
+ "filename": "8. P1 - Fishbourne Roman Palace 罗马宫殿【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/8. P1 - Fishbourne Roman Palace 罗马宫殿.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-172": {
+ "examId": "p3-low-172",
+ "dataKey": "p3-low-172",
+ "script": "./p3-low-172.js",
+ "title": "Rebranding art museums 博物馆品牌重塑",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/80. P3 - Rebranding art museums 博物馆品牌重塑/",
+ "filename": "80. P3 - Rebranding art museums 博物馆品牌重塑.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/80. P3 - Rebranding art museums 博物馆品牌重塑.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-173": {
+ "examId": "p3-high-173",
+ "dataKey": "p3-high-173",
+ "script": "./p3-high-173.js",
+ "title": "Robert Louis Stevenson",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/81. P3 - Robert Louis Stevenson 苏格兰作家【高】/",
+ "filename": "81. P3 - Robert Louis Stevenson 苏格兰作家【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/81. P3 - Robert Louis Stevenson 苏格兰作家.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-174": {
+ "examId": "p3-high-174",
+ "dataKey": "p3-high-174",
+ "script": "./p3-high-174.js",
+ "title": "Some views on the use of headphones 耳机使用",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/82. P3 - Some views on the use of headphones 耳机使用【高】/",
+ "filename": "82. P3 - Some views on the use of headphones 耳机使用【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/82. P3 - Some views on the use of headphones 耳机使用.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-175": {
+ "examId": "p3-low-175",
+ "dataKey": "p3-low-175",
+ "script": "./p3-low-175.js",
+ "title": "Termite Mounds 白蚁丘",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/83. P3 - Termite Mounds 白蚁丘/",
+ "filename": "83. P3 - Termite Mounds 白蚁丘.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/83. P3 - Termite Mounds 白蚁丘.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-176": {
+ "examId": "p3-medium-176",
+ "dataKey": "p3-medium-176",
+ "script": "./p3-medium-176.js",
+ "title": "The Analysis of Fear 猴子恐惧实验",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/84. P3 - The Analysis of Fear 猴子恐惧实验【次】/",
+ "filename": "84. P3 - The Analysis of Fear 猴子恐惧实验【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/84. P3 - The Analysis of Fear 猴子恐惧实验.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-177": {
+ "examId": "p3-medium-177",
+ "dataKey": "p3-medium-177",
+ "script": "./p3-medium-177.js",
+ "title": "The Art of Deception 欺骗的艺术",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/85. P3 - The Art of Deception 欺骗的艺术【次】/",
+ "filename": "85. P3 - The Art of Deception 欺骗的艺术【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/85. P3 - The Art of Deception 欺骗的艺术.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-178": {
+ "examId": "p3-high-178",
+ "dataKey": "p3-high-178",
+ "script": "./p3-high-178.js",
+ "title": "The benefits of learning an instrument 学乐器的好处",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/86. P3 - The benefits of learning an instrument 学乐器的好处【高】/",
+ "filename": "86. P3 - The benefits of learning an instrument 学乐器的好处【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/86. P3 - The benefits of learning an instrument 学乐器的好处.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-179": {
+ "examId": "p3-medium-179",
+ "dataKey": "p3-medium-179",
+ "script": "./p3-medium-179.js",
+ "title": "The Exploration of Mars 火星探索",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/87. P3 - The Exploration of Mars 火星探索【次】/",
+ "filename": "87. P3 - The Exploration of Mars 火星探索【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/87. P3 - The Exploration of Mars 火星探索.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-180": {
+ "examId": "p3-high-180",
+ "dataKey": "p3-high-180",
+ "script": "./p3-high-180.js",
+ "title": "The fluoridation controversy 氟化水争议",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/88. P3 - The fluoridation controversy 氟化水争议【高】/",
+ "filename": "88. P3 - The fluoridation controversy 氟化水争议【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/88. P3 - The fluoridation controversy 氟化水争议.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-181": {
+ "examId": "p3-high-181",
+ "dataKey": "p3-high-181",
+ "script": "./p3-high-181.js",
+ "title": "The Fruit Book 果实之书",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/89. P3 - The Fruit Book 果实之书【高】/",
+ "filename": "89. P3 - The Fruit Book 果实之书【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/89. P3 - The Fruit Book 果实之书.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-medium-182": {
+ "examId": "p1-medium-182",
+ "dataKey": "p1-medium-182",
+ "script": "./p1-medium-182.js",
+ "title": "Listening to the Ocean 海洋探测",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": 3,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/9. P1 - Listening to the Ocean 海洋探测【次】/",
+ "filename": "9. P1 - Listening to the Ocean 海洋探测【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/9. P1 - Listening to the Ocean 海洋探测.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-183": {
+ "examId": "p3-medium-183",
+ "dataKey": "p3-medium-183",
+ "script": "./p3-medium-183.js",
+ "title": "The hazards of multitasking 多任务处理",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/90. P3 - The hazards of multitasking 多任务处理【次】/",
+ "filename": "90. P3 - The hazards of multitasking 多任务处理【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/90. P3 - The hazards of multitasking 多任务处理.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-184": {
+ "examId": "p3-high-184",
+ "dataKey": "p3-high-184",
+ "script": "./p3-high-184.js",
+ "title": "The New Zealand writer Margaret Mahy 新西兰女作家",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】/",
+ "filename": "91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-185": {
+ "examId": "p3-medium-185",
+ "dataKey": "p3-medium-185",
+ "script": "./p3-medium-185.js",
+ "title": "The Pirahã people of Brazil 巴西皮拉罕部落语言",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】/",
+ "filename": "92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-186": {
+ "examId": "p3-low-186",
+ "dataKey": "p3-low-186",
+ "script": "./p3-low-186.js",
+ "title": "The Robbers Cave Study (山洞)群体行为实验",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/93. P3 - The Robbers Cave Study (山洞)群体行为实验/",
+ "filename": "93. P3 - The Robbers Cave Study (山洞)群体行为实验.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/93. P3 - The Robbers Cave Study (山洞)群体行为实验.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-187": {
+ "examId": "p3-low-187",
+ "dataKey": "p3-low-187",
+ "script": "./p3-low-187.js",
+ "title": "The science of sleep 睡眠的科学",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/94. P3 - The science of sleep 睡眠的科学/",
+ "filename": "94. P3 - The science of sleep 睡眠的科学.pdf.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/94. P3 - The science of sleep 睡眠的科学.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-188": {
+ "examId": "p3-medium-188",
+ "dataKey": "p3-medium-188",
+ "script": "./p3-medium-188.js",
+ "title": "The Significant Role of Mother Tongue in Education 母语教育",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】/",
+ "filename": "95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/95. P3 - The Significant Role of Mother Tongue in Education 母语教育.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-189": {
+ "examId": "p3-high-189",
+ "dataKey": "p3-high-189",
+ "script": "./p3-high-189.js",
+ "title": "The tuatara – past and future 新西兰蜥蜴",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/96. P3 - The tuatara – past and future 新西兰蜥蜴【高】/",
+ "filename": "96. P3 - The tuatara – past and future 新西兰蜥蜴【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/96. P3 - The tuatara – past and future 新西兰蜥蜴.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-190": {
+ "examId": "p3-low-190",
+ "dataKey": "p3-low-190",
+ "script": "./p3-low-190.js",
+ "title": "The value of literary prizes 文学奖项的价值",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/97. P3 - The value of literary prizes 文学奖项的价值/",
+ "filename": "97. P3 - The value of literary prizes 文学奖项的价值.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/97. P3 - The value of literary prizes 文学奖项的价值.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-191": {
+ "examId": "p3-medium-191",
+ "dataKey": "p3-medium-191",
+ "script": "./p3-medium-191.js",
+ "title": "Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】/",
+ "filename": "98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-192": {
+ "examId": "p3-high-192",
+ "dataKey": "p3-high-192",
+ "script": "./p3-high-192.js",
+ "title": "Voynich Manuscript 伏尼契手稿",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4.5,
+ "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/99. P3 - Voynich Manuscript 伏尼契手稿【高】/",
+ "filename": "99. P3 - Voynich Manuscript 伏尼契手稿【高】.html",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/99. P3 - Voynich Manuscript 伏尼契手稿.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-200": {
+ "examId": "p1-high-200",
+ "dataKey": "p1-high-200",
+ "script": "./p1-high-200.js",
+ "title": "Australia’s Airborne Dentists 澳洲飞行牙医",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2,
+ "path": "三月/1.P1 高频/",
+ "filename": "200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医【高】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-211": {
+ "examId": "p1-high-211",
+ "dataKey": "p1-high-211",
+ "script": "./p1-high-211.js",
+ "title": "Ahead of its time 新西兰头骨",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "三月/1.P1 高频/",
+ "filename": "211. P1 - Ahead of its time 新西兰头骨【高】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/211. P1 - Ahead of its time 新西兰头骨.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-216": {
+ "examId": "p1-high-216",
+ "dataKey": "p1-high-216",
+ "script": "./p1-high-216.js",
+ "title": "Australia’s cane toad problem 澳洲蟾蜍",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "三月/1.P1 高频/",
+ "filename": "216. P1 - Australia’s cane toad problem 澳洲蟾蜍【高】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/216. P1 - Australia’s cane toad problem 澳洲蟾蜍.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-194": {
+ "examId": "p1-high-194",
+ "dataKey": "p1-high-194",
+ "script": "./p1-high-194.js",
+ "title": "The history of the British wool industry 英国羊毛产业的历史",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": 2.5,
+ "path": "三月/2.P1 次高频/",
+ "filename": "194. P1 - The history of the British wool industry 英国羊毛产业的历史【高】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/194. P1 - The history of the British wool industry 英国羊毛产业的历史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-222": {
+ "examId": "p2-low-222",
+ "dataKey": "p2-low-222",
+ "script": "./p2-low-222.js",
+ "title": "Ideal Homes 理想居所",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "三月/",
+ "filename": "222. P2 - Ideal Homes 理想居所.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/222. P2 - Ideal Homes 理想居所.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-low-223": {
+ "examId": "p1-low-223",
+ "dataKey": "p1-low-223",
+ "script": "./p1-low-223.js",
+ "title": "Effect and Cause 湖泊海啸研究",
+ "category": "P1",
+ "frequency": "次高频",
+ "difficultyScore": 3.5,
+ "path": "三月/",
+ "filename": "223. P1 - Effect and Cause 湖泊海啸研究.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/223. P1 - Effect and Cause 湖泊海啸研究.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-201": {
+ "examId": "p2-high-201",
+ "dataKey": "p2-high-201",
+ "script": "./p2-high-201.js",
+ "title": "Multi-tasking and the brain 大脑与多任务处理",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "三月/3.P2 高频/",
+ "filename": "201. P2 - Multi-tasking and the brain 大脑与多任务处理【高】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/201. P2 - Multi-tasking and the brain 大脑与多任务处理.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-medium-217": {
+ "examId": "p2-medium-217",
+ "dataKey": "p2-medium-217",
+ "script": "./p2-medium-217.js",
+ "title": "A mechanical friend for children 孩子的机器人朋友",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "三月/3.P2 高频/",
+ "filename": "217. P2 - A mechanical friend for children 孩子的机器人朋友【次】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/217. P2 - A mechanical friend for children 孩子的机器人朋友.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-192": {
+ "examId": "p2-high-192",
+ "dataKey": "p2-high-192",
+ "script": "./p2-high-192.js",
+ "title": "P2(1115纸笔) - Should we stop eating meat 是否应该吃素",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": 3.5,
+ "path": "三月/4.P2 次高频/",
+ "filename": "192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素【高】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-medium-209": {
+ "examId": "p2-medium-209",
+ "dataKey": "p2-medium-209",
+ "script": "./p2-medium-209.js",
+ "title": "Decision Fatigue 决策疲劳",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "三月/4.P2 次高频/",
+ "filename": "209. P2 - Decision Fatigue 决策疲劳【次】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/209. P2 - Decision Fatigue 决策疲劳.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-medium-213": {
+ "examId": "p2-medium-213",
+ "dataKey": "p2-medium-213",
+ "script": "./p2-medium-213.js",
+ "title": "Growing more for less 卫星农业",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "三月/4.P2 次高频/",
+ "filename": "213. P2 - Growing more for less 卫星农业【次】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/213. P2 - Growing more for less 卫星农业.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-051": {
+ "examId": "p2-low-051",
+ "dataKey": "p2-low-051",
+ "script": "./p2-low-051.js",
+ "title": "The dingo debate 澳洲野犬_澳洲野狗",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "三月/4.P2 次高频/",
+ "filename": "51. P2 - The dingo debate 澳洲野犬_澳洲野狗.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-medium-058": {
+ "examId": "p2-medium-058",
+ "dataKey": "p2-medium-058",
+ "script": "./p2-medium-058.js",
+ "title": "Who wrote Shakespeare's plays 莎士比亚",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "三月/4.P2 次高频/",
+ "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚【次】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-204": {
+ "examId": "p3-high-204",
+ "dataKey": "p3-high-204",
+ "script": "./p3-high-204.js",
+ "title": "When people are ‘deaf’ to music 失乐症",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "三月/5.P3 高频/",
+ "filename": "204. P3 - When people are ‘deaf’ to music 失乐症【高】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/204. P3 - When people are ‘deaf’ to music 失乐症.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-206": {
+ "examId": "p3-high-206",
+ "dataKey": "p3-high-206",
+ "script": "./p3-high-206.js",
+ "title": "200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4.5,
+ "path": "三月/5.P3 高频/",
+ "filename": "206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 亚洲风景展【高】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-212": {
+ "examId": "p3-high-212",
+ "dataKey": "p3-high-212",
+ "script": "./p3-high-212.js",
+ "title": "Children’s literature studies today 儿童文学",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4.5,
+ "path": "三月/5.P3 高频/",
+ "filename": "212. P3 - Children’s literature studies today 儿童文学【高】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/212. P3 - Children’s literature studies today 儿童文学.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-218": {
+ "examId": "p3-high-218",
+ "dataKey": "p3-high-218",
+ "script": "./p3-high-218.js",
+ "title": "The Causes of Linguistic Change 语音的演变",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4.5,
+ "path": "三月/5.P3 高频/",
+ "filename": "218. P3 - The Causes of Linguistic Change 语音的演变【高】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/218. P3 - The Causes of Linguistic Change 语音的演变.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-219": {
+ "examId": "p3-low-219",
+ "dataKey": "p3-low-219",
+ "script": "./p3-low-219.js",
+ "title": "The origin of language 语言的起源",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "三月/5.P3 高频/",
+ "filename": "219. P3 - The origin of language 语言的起源.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/219. P3 - The origin of language 语言的起源.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-999": {
+ "examId": "p3-low-999",
+ "dataKey": "p3-low-999",
+ "script": "./p3-low-999.js",
+ "title": "Risk taking",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "三月/5.P3 高频/",
+ "filename": "P3 - Risk taking.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-197": {
+ "examId": "p3-medium-197",
+ "dataKey": "p3-medium-197",
+ "script": "./p3-medium-197.js",
+ "title": "Australia’s Megafauna Controversy 巨兽灭绝",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4.5,
+ "path": "三月/6.P3 次高频/",
+ "filename": "197. P3 - Australia’s Megafauna Controversy 巨兽灭绝【次】.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/197. P3 - Australia’s Megafauna Controversy 巨兽灭绝.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-198": {
+ "examId": "p3-low-198",
+ "dataKey": "p3-low-198",
+ "script": "./p3-low-198.js",
+ "title": "Child’s Play in Medieval England 中世纪的游戏",
+ "category": "P3",
+ "frequency": "次高频",
+ "difficultyScore": 4,
+ "path": "三月/6.P3 次高频/",
+ "filename": "198. P3 - Child’s Play in Medieval England 中世纪的游戏.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/198. P3 - Child’s Play in Medieval England 中世纪的游戏.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-078": {
+ "examId": "p3-low-078",
+ "dataKey": "p3-low-078",
+ "script": "./p3-low-078.js",
+ "title": "P3 (ds做出来的) - Music Language We All Speak 音乐语言",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": 4.5,
+ "path": "三月/6.P3 次高频/",
+ "filename": "78. P3 (ds做出来的) - Music Language We All Speak 音乐语言.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-227": {
+ "examId": "p1-high-227",
+ "dataKey": "p1-high-227",
+ "script": "./p1-high-227.js",
+ "title": "The Whale Goes to Court 鲸鱼油",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "ReadingPractice/PDF/",
+ "filename": "227. P1 - The Whale Goes to Court 鲸鱼油.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/227. P1 - The Whale Goes to Court 鲸鱼油.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-225": {
+ "examId": "p2-high-225",
+ "dataKey": "p2-high-225",
+ "script": "./p2-high-225.js",
+ "title": "The problem of graffiti 涂鸦之困",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3.5,
+ "path": "ReadingPractice/PDF/",
+ "filename": "225. P2 - The problem of graffiti 涂鸦之困.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/225. P2 - The problem of graffiti 涂鸦之困.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-228": {
+ "examId": "p3-high-228",
+ "dataKey": "p3-high-228",
+ "script": "./p3-high-228.js",
+ "title": "On art and artists 艺术与艺术家",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": 4.5,
+ "path": "ReadingPractice/PDF/",
+ "filename": "228. P3 - On art and artists 艺术与艺术家.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/228. P3 - On art and artists 艺术与艺术家.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-229": {
+ "examId": "p1-high-229",
+ "dataKey": "p1-high-229",
+ "script": "./p1-high-229.js",
+ "title": "New Understanding of Giraffes in the Wild 野生长颈鹿",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2.5,
+ "path": "ReadingPractice/PDF/",
+ "filename": "229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-230": {
+ "examId": "p1-high-230",
+ "dataKey": "p1-high-230",
+ "script": "./p1-high-230.js",
+ "title": "The History of the Pencil 铅笔的历史",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 1.5,
+ "path": "ReadingPractice/PDF/",
+ "filename": "230. P1 - The History of the Pencil 铅笔的历史.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/230. P1 - The History of the Pencil 铅笔的历史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-231": {
+ "examId": "p1-high-231",
+ "dataKey": "p1-high-231",
+ "script": "./p1-high-231.js",
+ "title": "The History of the Pencil 铅笔的历史(流程图版)",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": 2,
+ "path": "ReadingPractice/PDF/",
+ "filename": "231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-232": {
+ "examId": "p2-high-232",
+ "dataKey": "p2-high-232",
+ "script": "./p2-high-232.js",
+ "title": "The origin and development of applause 掌声的历史",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "ReadingPractice/PDF/",
+ "filename": "232. P2 - The origin and development of applause 掌声的历史.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/232. P2 - The origin and development of applause 掌声的历史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-233": {
+ "examId": "p2-high-233",
+ "dataKey": "p2-high-233",
+ "script": "./p2-high-233.js",
+ "title": "Why don’t we sleep 失眠的原因",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 3,
+ "path": "ReadingPractice/PDF/",
+ "filename": "233. P2 - Why don’t we sleep 失眠的原因.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/233. P2 - Why don’t we sleep 失眠的原因.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-234": {
+ "examId": "p2-high-234",
+ "dataKey": "p2-high-234",
+ "script": "./p2-high-234.js",
+ "title": "How do plants talk to each other 植物交流",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": 4,
+ "path": "ReadingPractice/PDF/",
+ "filename": "234. P2 - The Secret Language of Plants 植物交流.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/234. P2 - The Secret Language of Plants 植物交流.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-221": {
+ "examId": "p3-high-221",
+ "dataKey": "p3-high-221",
+ "script": "./p3-high-221.js",
+ "title": "The Animal Connection 动物联结",
+ "category": "P3",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "ReadingPractice/PDF/",
+ "filename": "221. P3 - The Animal Connection 动物联结.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/221. P3 - The Animal Connection 动物联结.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-235": {
+ "examId": "p2-high-235",
+ "dataKey": "p2-high-235",
+ "script": "./p2-high-235.js",
+ "title": "The return of the black-footed ferret 黑足鼬",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": null,
+ "path": "ReadingPractice/PDF/",
+ "filename": "235. P2 - The return of the black-footed ferret 黑足鼬.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/235. P2 - The return of the black-footed ferret 黑足鼬.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-236": {
+ "examId": "p2-high-236",
+ "dataKey": "p2-high-236",
+ "script": "./p2-high-236.js",
+ "title": "War of the Plants 植物的战争",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "",
+ "filename": "",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "",
+ "sourceKind": "generated-reading"
+ },
+ "p3-high-229": {
+ "examId": "p3-high-229",
+ "dataKey": "p3-high-229",
+ "script": "./p3-high-229.js",
+ "title": "All in the family 兄弟姐妹的影响",
+ "category": "P3",
+ "frequency": "高频",
+ "difficultyScore": null,
+ "path": "ReadingPractice/PDF/",
+ "filename": "237. P3 - All in the family 兄弟姐妹的影响.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/237. P3 - All in the family 兄弟姐妹的影响.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-high-239": {
+ "examId": "p2-high-239",
+ "dataKey": "p2-high-239",
+ "script": "./p2-high-239.js",
+ "title": "Nanotechnology: the science of the very small 纳米科技",
+ "category": "P2",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "ReadingPractice/PDF/",
+ "filename": "239. P2 - Nanotechnology the science of the very small 纳米科技.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/239. P2 - Nanotechnology the science of the very small 纳米科技.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-240": {
+ "examId": "p2-low-240",
+ "dataKey": "p2-low-240",
+ "script": "./p2-low-240.js",
+ "title": "Coins - the first form of money 硬币起源",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": null,
+ "path": "assets/generated/reading-exams/",
+ "filename": "reading-practice-unified.html",
+ "hasHtml": true,
+ "hasPdf": false,
+ "pdfFilename": "",
+ "sourceKind": "generated-reading"
+ },
+ "p1-high-240": {
+ "examId": "p1-high-240",
+ "dataKey": "p1-high-240",
+ "script": "./p1-high-240.js",
+ "title": "The Origins of Weather Forecasting 天气预报",
+ "category": "P1",
+ "frequency": "高频",
+ "difficultyScore": null,
+ "path": "ReadingPractice/PDF/",
+ "filename": "240. P1 - The Origins of Weather Forecasting 天气预报.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/240. P1 - The Origins of Weather Forecasting 天气预报.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-low-242": {
+ "examId": "p2-low-242",
+ "dataKey": "p2-low-242",
+ "script": "./p2-low-242.js",
+ "title": "Walking and shoes in eighteenth-century London 伦敦鞋子的发展史",
+ "category": "P2",
+ "frequency": "高频",
+ "difficultyScore": null,
+ "path": "ReadingPractice/PDF/",
+ "filename": "242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-low-240": {
+ "examId": "p3-low-240",
+ "dataKey": "p3-low-240",
+ "script": "./p3-low-240.js",
+ "title": "How a prehistoric predator took to the skies 翼龙飞行",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "ReadingPractice/PDF/",
+ "filename": "P3 - How a prehistoric predator took to the skies.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/P3 - How a prehistoric predator took to the skies.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-241": {
+ "examId": "p3-medium-241",
+ "dataKey": "p3-medium-241",
+ "script": "./p3-medium-241.js",
+ "title": "Who looks after the children in today's Britain? 育儿分工",
+ "category": "P3",
+ "frequency": "low",
+ "difficultyScore": null,
+ "path": "ReadingPractice/PDF/",
+ "filename": "P3 - Who looks after the children in today's Britain.pdf",
+ "hasHtml": true,
+ "hasPdf": true,
+ "pdfFilename": "ReadingPractice/PDF/P3 - Who looks after the children in today's Britain.pdf",
+ "sourceKind": "generated-reading"
+ },
+ "p2-medium-243": {
+ "examId": "p2-medium-243",
+ "dataKey": "p2-medium-243",
+ "script": "./p2-medium-243.js",
+ "title": "The internal body clock",
+ "category": "P2",
+ "frequency": "次高频",
+ "difficultyScore": 3.5,
+ "path": "",
+ "filename": "",
+ "hasHtml": false,
+ "hasPdf": false,
+ "pdfFilename": "",
+ "sourceKind": "generated-reading"
+ },
+ "p3-medium-244": {
+ "examId": "p3-medium-244",
+ "dataKey": "p3-medium-244",
+ "script": "./p3-medium-244.js",
+ "title": "Look who was talking",
+ "category": "P3",
+ "frequency": "次高频",
+ "difficultyScore": 4,
+ "path": "",
+ "filename": "",
+ "hasHtml": false,
+ "hasPdf": false,
+ "pdfFilename": "",
+ "sourceKind": "generated-reading"
+ }
- async set(key, value) {
- if (this.isPracticeDataKey(key)) {
- if (key === 'practice_records') {
- this.rejectPracticeDataWrite('set(practice_records)', 'PracticeRecordAPI.replace');
- }
- this.rejectPracticeDataWrite('set(user_stats)', 'PracticeRecordAPI.writeStats');
- }
- await this.metaRepo.set(key, value);
- return true;
- }
+ };
- async remove(key) {
- if (this.isPracticeDataKey(key)) {
- if (key === 'practice_records') {
- this.rejectPracticeDataWrite('remove(practice_records)', 'PracticeRecordAPI.clear');
- }
- this.rejectPracticeDataWrite('remove(user_stats)', 'PracticeRecordAPI.resetStats');
- }
- await this.metaRepo.remove(key);
- return true;
- }
- }
+ function clonePathRoot() {
+ return Object.assign({}, PATH_ROOT);
+ }
- function connectWrapper(repositories) {
- if (!repositories) {
- return;
- }
- if (window.simpleStorageWrapper && window.simpleStorageWrapper.repos === repositories) {
- return;
- }
- window.simpleStorageWrapper = new SimpleStorageWrapper(repositories);
- console.log('[SimpleStorageWrapper] 已连接新的数据仓库接口');
- }
+ function cloneIndexEntry(entry) {
+ return Object.assign({}, entry);
+ }
- const registry = window.StorageProviderRegistry;
- if (registry && typeof registry.onProvidersReady === 'function') {
- registry.onProvidersReady(({ repositories }) => connectWrapper(repositories));
- const current = registry.getCurrentProviders && registry.getCurrentProviders();
- if (current && current.repositories) {
- connectWrapper(current.repositories);
- }
- } else if (window.dataRepositories) {
- connectWrapper(window.dataRepositories);
- } else {
- console.warn('[SimpleStorageWrapper] 数据仓库尚未可用,等待外部注入');
- }
+ function buildReadingExamIndex() {
+ const index = Object.keys(manifest).map(function mapEntry(id) {
+ const entry = manifest[id] || {};
+ return {
+ id: entry.examId || id,
+ title: entry.title || '',
+ category: entry.category || '',
+ frequency: entry.frequency || '',
+ difficultyScore: entry.difficultyScore,
+ path: entry.path || '',
+ filename: entry.filename || '',
+ hasHtml: entry.hasHtml === true,
+ hasPdf: entry.hasPdf === true,
+ pdfFilename: entry.pdfFilename || '',
+ sourceKind: entry.sourceKind || (entry.script ? 'generated-reading' : 'pdf-only'),
+ type: 'reading'
+ };
+ });
+ index.pathRoot = clonePathRoot();
+ return index;
+ }
+
+ function getReadingExamIndex() {
+ const index = global.__READING_EXAM_INDEX__;
+ const cloned = Array.isArray(index) ? index.map(cloneIndexEntry) : buildReadingExamIndex();
+ cloned.pathRoot = clonePathRoot();
+ return cloned;
+ }
- window.SimpleStorageWrapper = SimpleStorageWrapper;
-})(window);
+ global.__READING_EXAM_MANIFEST__ = manifest;
+ global.__READING_EXAM_INDEX__ = buildReadingExamIndex();
+ global.__READING_EXAM_INDEX__.pathRoot = clonePathRoot();
+ global.__READING_EXAM_PATH_ROOT__ = clonePathRoot();
+ global.getReadingExamIndex = getReadingExamIndex;
+ global.getReadingExamIndex.pathRoot = clonePathRoot();
+ global.completeExamIndex = getReadingExamIndex();
+})(typeof window !== "undefined" ? window : globalThis);
/* ===== js/app/state-service.js ===== */
@@ -13835,32 +11038,6 @@ if (typeof module !== 'undefined' && module.exports) {
return Array.isArray(value) ? value.slice() : [];
}
- function cloneValue(value) {
- if (value === null || value === undefined) {
- return value;
- }
- if (typeof global.structuredClone === 'function') {
- try {
- return global.structuredClone(value);
- } catch (_) { }
- }
- try {
- return JSON.parse(JSON.stringify(value));
- } catch (_) {
- if (Array.isArray(value)) {
- return value.map((item) => cloneValue(item));
- }
- if (value && typeof value === 'object') {
- return Object.assign({}, value);
- }
- return value;
- }
- }
-
- function clonePracticeRecords(records) {
- return Array.isArray(records) ? records.map((record) => cloneValue(record)) : [];
- }
-
function cloneSet(value) {
if (value instanceof Set) {
return new Set(value);
@@ -14001,8 +11178,6 @@ if (typeof module !== 'undefined' && module.exports) {
this.globalBindingsInstalled = false;
this.state = {
- examIndex: cloneArray(global.examIndex),
- practiceRecords: [],
filteredExams: Array.isArray(global.filteredExams) ? global.filteredExams : [],
browseFilter: normalizeFilter(global.__browseFilter),
bulkDeleteMode: !!global.bulkDeleteMode,
@@ -14013,8 +11188,6 @@ if (typeof module !== 'undefined' && module.exports) {
};
this.listeners = {
- examIndex: new Set(),
- practiceRecords: new Set(),
filteredExams: new Set(),
browseFilter: new Set(),
bulkDeleteMode: new Set(),
@@ -14070,13 +11243,11 @@ if (typeof module !== 'undefined' && module.exports) {
try {
if (app.state.exam) {
- app.state.exam.index = this.state.examIndex;
app.state.exam.currentCategory = this.state.browseFilter.category;
app.state.exam.currentExamType = this.state.browseFilter.type;
app.state.exam.filteredExams = this.state.filteredExams;
}
if (app.state.practice) {
- app.state.practice.records = clonePracticeRecords(this.state.practiceRecords);
app.state.practice.selectedRecords = this.state.selectedRecords;
app.state.practice.bulkDeleteMode = this.state.bulkDeleteMode;
}
@@ -14096,12 +11267,6 @@ if (typeof module !== 'undefined' && module.exports) {
syncFromAppPath(path, value) {
switch (path) {
- case 'exam.index':
- this.setExamIndex(value, { syncApp: false });
- break;
- case 'practice.records':
- this.setPracticeRecords(value, { syncApp: false });
- break;
case 'exam.filteredExams':
this.setFilteredExams(value, { syncApp: false });
break;
@@ -14141,41 +11306,6 @@ if (typeof module !== 'undefined' && module.exports) {
}
}
- getExamIndex() {
- return this.state.examIndex;
- }
-
- setExamIndex(list, options = {}) {
- const normalized = assignExamSequenceNumbers(cloneArray(list));
- this.state.examIndex = normalized;
- if (options.syncApp !== false) {
- this.applyToApp();
- }
- emit(this.listeners, 'examIndex', this.state.examIndex);
- return this.state.examIndex;
- }
-
- getPracticeRecords() {
- return clonePracticeRecords(this.state.practiceRecords);
- }
-
- setPracticeRecords(records, options = {}) {
- const normalized = clonePracticeRecords(records);
- this.state.practiceRecords = normalized;
- if (options.syncApp !== false) {
- this.applyToApp();
- }
- emit(this.listeners, 'practiceRecords', clonePracticeRecords(this.state.practiceRecords));
- if (typeof global.updateBrowseAnchorsFromRecords === 'function') {
- try {
- global.updateBrowseAnchorsFromRecords(clonePracticeRecords(this.state.practiceRecords));
- } catch (error) {
- console.warn('[AppStateService] updateBrowseAnchorsFromRecords failed:', error);
- }
- }
- return clonePracticeRecords(this.state.practiceRecords);
- }
-
getFilteredExams() {
return this.state.filteredExams;
}
@@ -14430,18 +11560,6 @@ if (typeof module !== 'undefined' && module.exports) {
const service = this;
- globalRef.getExamIndexState = function getExamIndexState() {
- return service.getExamIndex();
- };
- globalRef.setExamIndexState = function setExamIndexState(list) {
- return service.setExamIndex(list);
- };
- globalRef.getPracticeRecordsState = function getPracticeRecordsState() {
- return service.getPracticeRecords();
- };
- globalRef.setPracticeRecordsState = function setPracticeRecordsState(records) {
- return service.setPracticeRecords(records);
- };
globalRef.getFilteredExamsState = function getFilteredExamsState() {
return service.getFilteredExams();
};
@@ -14501,14 +11619,6 @@ if (typeof module !== 'undefined' && module.exports) {
};
globalRef.assignExamSequenceNumbers = assignExamSequenceNumbers;
- defineGlobalProperty(globalRef, 'examIndex', {
- get: () => service.getExamIndex(),
- set: (value) => service.setExamIndex(value)
- });
- defineGlobalProperty(globalRef, 'practiceRecords', {
- get: () => service.getPracticeRecords(),
- set: (value) => service.setPracticeRecords(value)
- });
defineGlobalProperty(globalRef, 'filteredExams', {
get: () => service.getFilteredExams(),
set: (value) => service.setFilteredExams(value)
@@ -15360,23 +12470,14 @@ if (typeof module !== 'undefined' && module.exports) {
&& global.listeningExamIndex.length > 0;
}
- function getActiveExamIndexSnapshot() {
- try {
- if (typeof global.getExamIndexState === 'function') {
- return global.getExamIndexState();
- }
- } catch (_) { }
- return Array.isArray(global.examIndex) ? global.examIndex : [];
- }
-
function hasActiveListeningLibrary(index) {
- return hasListeningEntries(Array.isArray(index) ? index : getActiveExamIndexSnapshot());
+ return hasListeningEntries(index);
}
function refreshListeningAvailabilityUI(index) {
if (typeof global.refreshListeningAvailabilityUI === 'function') {
try {
- global.refreshListeningAvailabilityUI(Array.isArray(index) ? index : getActiveExamIndexSnapshot());
+ global.refreshListeningAvailabilityUI(Array.isArray(index) ? index : []);
return;
} catch (error) {
console.warn('[LibraryManager] 刷新听力入口状态失败:', error);
@@ -15465,36 +12566,26 @@ if (typeof module !== 'undefined' && module.exports) {
}
async getActiveLibraryConfigurationKey() {
- return global.storage.get('active_exam_index_key', 'exam_index');
+ return global.AppData.library.getActive();
}
async setActiveLibraryConfiguration(key) {
- try {
- await global.storage.set('active_exam_index_key', key);
- } catch (error) {
- console.error('[LibraryManager] 设置活动题库配置失败:', error);
- }
+ return global.AppData.library.activate(typeof key === 'string' && key.trim() ? key.trim() : null);
}
async getLibraryConfigurations() {
- return global.storage.get('exam_index_configurations', []);
+ const configurations = await global.AppData.library.listConfigurations();
+ return [{ name: '默认题库', key: '', id: null, builtIn: true, sourceType: 'built-in-manifest' }]
+ .concat(Array.isArray(configurations) ? configurations : []);
}
async saveLibraryConfiguration(name, key, examCount, metadata = {}) {
try {
- let configs = await global.storage.get('exam_index_configurations', []);
- if (!Array.isArray(configs)) {
- configs = [];
- }
+ if (!key) return;
const safeMetadata = metadata && typeof metadata === 'object' ? metadata : {};
- const entry = Object.assign({}, safeMetadata, { name, key, examCount, timestamp: Date.now() });
- const existingIndex = configs.findIndex((item) => item && item.key === key);
- if (existingIndex >= 0) {
- configs[existingIndex] = Object.assign({}, configs[existingIndex], entry);
- } else {
- configs.push(entry);
- }
- await global.storage.set('exam_index_configurations', configs);
+ await global.AppData.library.updateConfiguration(Object.assign({}, safeMetadata, {
+ id: key, key, name, examCount, timestamp: Date.now()
+ }));
} catch (error) {
console.error('[LibraryManager] 保存题库配置失败:', error);
}
@@ -15590,20 +12681,100 @@ if (typeof module !== 'undefined' && module.exports) {
: [];
}
- finishLibraryLoading(startTime) {
+ finishLibraryLoading(startTime, index) {
const loadTime = (typeof performance !== 'undefined' && performance.now) ? performance.now() - startTime : 0;
if (typeof global.reportBootStage === 'function') {
global.reportBootStage('题库装载完成', 75);
}
- try { global.updateOverview && global.updateOverview(); } catch (_) { }
- refreshListeningAvailabilityUI();
- try { global.refreshBrowseProgressFromRecords && global.refreshBrowseProgressFromRecords(); } catch (_) { }
+ try { global.updateOverview && global.updateOverview(index); } catch (_) { }
+ refreshListeningAvailabilityUI(index);
+ if (typeof global.startPracticeRecordsSyncInBackground === 'function') {
+ global.startPracticeRecordsSyncInBackground('library-loaded', { forceRender: true });
+ }
try {
- global.dispatchEvent(new CustomEvent('examIndexLoaded'));
+ global.dispatchEvent(new CustomEvent('examIndexLoaded', { detail: { index: cloneArray(index) } }));
} catch (_) { }
return loadTime;
}
+ async resolveDefaultIndex() {
+ await global.AppData.ready;
+ if (global.ensureExamDataScripts) {
+ try { await global.ensureExamDataScripts(); } catch (_) { }
+ }
+ return this.normalizeIndexForCustomConfig(
+ this.getDefaultReadingIndex().concat(this.resolveDefaultTypeIndex('listening'))
+ );
+ }
+
+ async resolveIndexForConfiguration(configurationId) {
+ await global.AppData.ready;
+ const id = typeof configurationId === 'string' && configurationId.trim()
+ ? configurationId.trim()
+ : null;
+ if (id === null) return this.resolveDefaultIndex();
+ return this.normalizeIndexForCustomConfig(await global.AppData.library.getIndex(id));
+ }
+
+ getRecordLibraryProvenance(record) {
+ const metadata = record && record.metadata && typeof record.metadata === 'object'
+ ? record.metadata
+ : {};
+ if (Object.prototype.hasOwnProperty.call(metadata, 'libraryConfigurationId')) {
+ const value = metadata.libraryConfigurationId;
+ return { known: true, configurationId: typeof value === 'string' && value.trim() ? value.trim() : null };
+ }
+ if (record && Object.prototype.hasOwnProperty.call(record, 'libraryConfigurationId')) {
+ const value = record.libraryConfigurationId;
+ return { known: true, configurationId: typeof value === 'string' && value.trim() ? value.trim() : null };
+ }
+ return { known: false, configurationId: null };
+ }
+
+ async resolveIndexForRecord(record) {
+ const provenance = this.getRecordLibraryProvenance(record);
+ // 记录带明确题库来源时严格按来源解析——多题库场景下这能防止同一 examId
+ // 被解析到别的库里的错误题目。
+ if (provenance.known) {
+ return this.resolveIndexForConfiguration(provenance.configurationId);
+ }
+ // 来源未知(几乎都是 v1 迁移来的旧记录:迁移时无法唯一确定来源就不会补
+ // libraryConfigurationId)。条件降级:只有当用户没有任何自定义题库时,
+ // examId 只可能对应默认库里的唯一题目,回退到当前活动题库解析是安全的
+ // (即 v1 一贯行为,修复旧记录回顾/详情/导出全部失败)。一旦存在自定义
+ // 题库,同一 examId 可能在多个库指向不同题目,无来源就无法安全判定,
+ // 保守返回空索引,由调用方按“题目不可用”提示,绝不静默解析到错题。
+ let customConfigCount = 0;
+ try {
+ const configurations = await global.AppData.library.listConfigurations();
+ customConfigCount = Array.isArray(configurations) ? configurations.length : 0;
+ } catch (_) {
+ // 读配置失败时按保守处理,不回退。
+ return [];
+ }
+ if (customConfigCount === 0) {
+ return this.resolveActiveIndex();
+ }
+ return [];
+ }
+
+ async resolveExamForRecord(record) {
+ if (!record || typeof record !== 'object') return null;
+ const metadata = record.metadata && typeof record.metadata === 'object' ? record.metadata : {};
+ const candidateIds = [record.examId, metadata.examId]
+ .filter((value) => value !== null && value !== undefined && String(value).trim())
+ .map((value) => String(value));
+ if (!candidateIds.length) return null;
+ const index = await this.resolveIndexForRecord(record);
+ return index.find((exam) => exam && candidateIds.includes(String(exam.id))) || null;
+ }
+
+ async resolveActiveIndex() {
+ await global.AppData.ready;
+ const activeId = await global.AppData.library.getActive();
+ return this.resolveIndexForConfiguration(activeId);
+ }
+
async loadActiveLibrary(forceReload = false) {
const startTime = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
if (typeof global.reportBootStage === 'function') {
@@ -15611,37 +12782,36 @@ if (typeof module !== 'undefined' && module.exports) {
}
const rawKey = await this.getActiveLibraryConfigurationKey();
- const activeConfigKey = typeof rawKey === 'string' && rawKey.trim() ? rawKey.trim() : 'exam_index';
- const isDefaultConfig = activeConfigKey === 'exam_index';
+ const activeConfigKey = typeof rawKey === 'string' && rawKey.trim() ? rawKey.trim() : null;
+ const isDefaultConfig = activeConfigKey === null;
let cachedData = null;
try {
if (!isDefaultConfig) {
- cachedData = await global.storage.get(activeConfigKey);
+ cachedData = await global.AppData.library.getIndex(activeConfigKey);
} else {
- await global.storage.set('active_exam_index_key', 'exam_index');
+ await global.AppData.library.activate(null);
}
} catch (error) {
console.warn('[LibraryManager] 读取题库缓存失败:', error);
}
- if (!forceReload && !isDefaultConfig && Array.isArray(cachedData) && cachedData.length > 0) {
- const updatedIndex = global.setExamIndexState ? global.setExamIndexState(cachedData) : cachedData;
+ if (!isDefaultConfig && Array.isArray(cachedData) && cachedData.length > 0) {
+ const updatedIndex = this.normalizeIndexForCustomConfig(cachedData);
+ if (typeof global.assignExamSequenceNumbers === 'function') global.assignExamSequenceNumbers(updatedIndex);
await this.savePathMapForConfiguration(activeConfigKey, updatedIndex, { setActive: true });
- this.finishLibraryLoading(startTime);
+ this.finishLibraryLoading(startTime, updatedIndex);
return updatedIndex;
}
if (!isDefaultConfig) {
const normalized = Array.isArray(cachedData) ? cachedData : [];
- if (global.setExamIndexState) {
- global.setExamIndexState(normalized);
- }
if (!normalized.length && typeof global.showMessage === 'function') {
- global.showMessage('当前题库配置没有数据,请重新导入或切换至默认题库。', 'warning');
+ global.showMessage('当前题库配置没有数据,已自动切换至默认题库。', 'warning');
}
- this.finishLibraryLoading(startTime);
- return normalized;
+ // Continue through the built-in manifest path. Returning the empty
+ // custom index here used to dispatch examIndexLoaded([]) and left an
+ // otherwise valid generated Reading manifest invisible.
}
try {
@@ -15660,11 +12830,8 @@ if (typeof module !== 'undefined' && module.exports) {
const listeningExams = this.resolveDefaultTypeIndex('listening');
if (!readingExams.length && !listeningExams.length) {
- if (global.setExamIndexState) {
- global.setExamIndexState([]);
- }
console.warn('[LibraryManager] 未检测到默认题库脚本中的题源数据');
- this.finishLibraryLoading(startTime);
+ this.finishLibraryLoading(startTime, []);
return [];
}
@@ -15672,7 +12839,7 @@ if (typeof module !== 'undefined' && module.exports) {
if (typeof global.assignExamSequenceNumbers === 'function') {
global.assignExamSequenceNumbers(combined);
}
- const updatedIndex = global.setExamIndexState ? global.setExamIndexState(combined) : combined;
+ const updatedIndex = combined;
const metadata = {
source: 'default-script',
@@ -15691,22 +12858,19 @@ if (typeof module !== 'undefined' && module.exports) {
const overrideMap = this.buildOverridePathMap(metadata, this.DEFAULT_PATH_MAP);
- await global.storage.set('exam_index', updatedIndex);
- await this.saveLibraryConfiguration('默认题库', 'exam_index', updatedIndex.length);
- await this.setActiveLibraryConfiguration('exam_index');
- await this.savePathMapForConfiguration('exam_index', updatedIndex, { setActive: true, overrideMap });
+ if (isDefaultConfig) {
+ await this.setActiveLibraryConfiguration(null);
+ }
+ this.setActivePathMap(overrideMap);
- this.finishLibraryLoading(startTime);
+ this.finishLibraryLoading(startTime, updatedIndex);
return updatedIndex;
} catch (error) {
console.error('[LibraryManager] 加载默认题库失败:', error);
if (typeof global.showMessage === 'function') {
global.showMessage('题库刷新失败: ' + (error && error.message ? error.message : error), 'error');
}
- if (global.setExamIndexState) {
- global.setExamIndexState([]);
- }
- this.finishLibraryLoading(startTime);
+ this.finishLibraryLoading(startTime, []);
return [];
}
}
@@ -15730,7 +12894,7 @@ if (typeof module !== 'undefined' && module.exports) {
if (entry.trim() === key) {
mutated = true;
return {
- name: key === 'exam_index' ? '默认题库' : key,
+ name: key,
key,
examCount,
timestamp: now
@@ -15748,7 +12912,8 @@ if (typeof module !== 'undefined' && module.exports) {
return entry;
});
if (mutated) {
- await global.storage.set('exam_index_configurations', updated);
+ const target = updated.find((entry) => entry && entry.key === key);
+ if (target) await global.AppData.library.updateConfiguration(target);
}
} catch (error) {
console.warn('[LibraryManager] 无法刷新题库配置元数据', error);
@@ -15756,11 +12921,10 @@ if (typeof module !== 'undefined' && module.exports) {
}
async fetchLibraryDataset(key) {
- if (!key) {
- return [];
- }
try {
- const dataset = await global.storage.get(key);
+ const dataset = !key
+ ? this.resolveDefaultTypeIndex('reading').concat(this.resolveDefaultTypeIndex('listening'))
+ : await global.AppData.library.getIndex(key);
return Array.isArray(dataset) ? dataset : [];
} catch (error) {
console.warn('[LibraryManager] 无法读取题库数据:', key, error);
@@ -15786,20 +12950,13 @@ if (typeof module !== 'undefined' && module.exports) {
async resolveBaseLibraryIndex(activeKey) {
let currentIndex = [];
- const key = typeof activeKey === 'string' && activeKey.trim() ? activeKey.trim() : 'exam_index';
+ const key = typeof activeKey === 'string' && activeKey.trim() ? activeKey.trim() : null;
try {
currentIndex = await this.fetchLibraryDataset(key);
} catch (_) {
currentIndex = [];
}
- if (!Array.isArray(currentIndex) || currentIndex.length === 0) {
- try {
- currentIndex = global.getExamIndexState ? global.getExamIndexState() : [];
- } catch (_) {
- currentIndex = [];
- }
- }
- if ((!Array.isArray(currentIndex) || currentIndex.length === 0) && key === 'exam_index') {
+ if ((!Array.isArray(currentIndex) || currentIndex.length === 0) && key === null) {
const reading = this.resolveDefaultTypeIndex('reading');
const listening = this.resolveDefaultTypeIndex('listening');
currentIndex = reading.concat(listening);
@@ -15823,7 +12980,7 @@ if (typeof module !== 'undefined' && module.exports) {
return this.normalizeIndexForCustomConfig(next);
}
- async buildUniqueImportedConfigKey(prefix = 'exam_index') {
+ async buildUniqueImportedConfigKey(prefix = 'library_import') {
let configs = [];
try {
configs = await this.getLibraryConfigurations();
@@ -15842,16 +12999,8 @@ if (typeof module !== 'undefined' && module.exports) {
if (used.has(key)) {
continue;
}
- try {
- const stored = global.storage && typeof global.storage.get === 'function'
- ? await global.storage.get(key)
- : null;
- if (!stored) {
- return key;
- }
- } catch (_) {
- return key;
- }
+ const stored = await global.AppData.library.getIndex(key);
+ if (!stored.length) return key;
}
return `${prefix}_${now}_${Math.random().toString(36).slice(2, 8)}`;
}
@@ -15922,7 +13071,7 @@ if (typeof module !== 'undefined' && module.exports) {
try { global.assignExamSequenceNumbers(newIndex); } catch (_) { }
}
- const key = options.key || await this.buildUniqueImportedConfigKey('exam_index');
+ const key = options.key || await this.buildUniqueImportedConfigKey('library_import');
const name = options.name || this.buildImportedConfigName(type, mode, options.label);
const counts = countIndexTypes(newIndex);
const sourceReport = options.discoveryResult && options.discoveryResult.report
@@ -15936,22 +13085,23 @@ if (typeof module !== 'undefined' && module.exports) {
mode,
accepted: additions.length,
rejected: sourceReport ? Number(sourceReport.rejected) || 0 : 0,
- createdFrom: activeKey || 'exam_index',
+ createdFrom: activeKey || null,
label: options.label || '',
timestamp: Date.now()
}
};
- await global.storage.set(key, newIndex);
- const pathFallback = await this.loadPathMapForConfiguration(activeKey || 'exam_index');
+ const pathFallback = await this.loadPathMapForConfiguration(activeKey);
const pathMap = this.resourceCore && typeof this.resourceCore.derivePathMapFromIndex === 'function'
? this.resourceCore.derivePathMapFromIndex(newIndex, pathFallback || this.DEFAULT_PATH_MAP)
: (pathFallback || null);
- await this.savePathMapForConfiguration(key, newIndex, {
- overrideMap: pathMap,
- setActive: options.activate !== false
+ await global.AppData.library.import({
+ id: key,
+ configuration: Object.assign({}, metadata, { id: key, key, name, examCount: newIndex.length, timestamp: Date.now() }),
+ index: newIndex,
+ operationId: options.operationId
});
- await this.saveLibraryConfiguration(name, key, newIndex.length, metadata);
+ if (options.activate !== false) this.setActivePathMap(pathMap);
let applied = true;
if (options.activate !== false) {
@@ -15982,15 +13132,13 @@ if (typeof module !== 'undefined' && module.exports) {
return false;
}
+ await this.setActiveLibraryConfiguration(key);
const currentPathMap = await this.loadPathMapForConfiguration(key);
const pathMap = this.resourceCore && typeof this.resourceCore.derivePathMapFromIndex === 'function'
? this.resourceCore.derivePathMapFromIndex(exams, currentPathMap || this.DEFAULT_PATH_MAP)
: (currentPathMap || null);
this.setActivePathMap(pathMap);
- if (global.setExamIndexState) {
- global.setExamIndexState(exams);
- }
refreshListeningAvailabilityUI(exams);
if (typeof global.setBrowseFilterState === 'function') {
global.setBrowseFilterState('all', 'all');
@@ -15999,24 +13147,18 @@ if (typeof module !== 'undefined' && module.exports) {
global.setFilteredExamsState([]);
}
- try {
- await this.setActiveLibraryConfiguration(key);
- } catch (error) {
- console.warn('[LibraryManager] 无法写入当前题库配置:', error);
- }
-
await this.updateLibraryConfigurationMetadata(key, exams.length);
await this.savePathMapForConfiguration(key, exams, {
overrideMap: pathMap,
setActive: true
});
- try { global.updateSystemInfo && global.updateSystemInfo(); } catch (_) { }
- try { global.updateOverview && global.updateOverview(); } catch (_) { }
- try { global.loadExamList && global.loadExamList(); } catch (_) { }
+ try { global.updateSystemInfo && global.updateSystemInfo(exams); } catch (_) { }
+ try { global.updateOverview && global.updateOverview(exams); } catch (_) { }
+ try { global.loadExamList && global.loadExamList(exams); } catch (_) { }
try {
- global.dispatchEvent(new CustomEvent('examIndexLoaded', { detail: { key } }));
+ global.dispatchEvent(new CustomEvent('examIndexLoaded', { detail: { key, index: cloneArray(exams) } }));
} catch (error) {
console.warn('[LibraryManager] 题库切换事件派发失败', error);
}
@@ -16042,10 +13184,6 @@ if (typeof module !== 'undefined' && module.exports) {
if (!configKey) {
return { deleted: false, reason: 'invalid-key' };
}
- if (configKey === 'exam_index') {
- return { deleted: false, reason: 'default-config' };
- }
-
const activeKey = await this.getActiveLibraryConfigurationKey();
if (activeKey === configKey) {
return { deleted: false, reason: 'active-config' };
@@ -16088,12 +13226,8 @@ if (typeof module !== 'undefined' && module.exports) {
return { deleted: false, reason: 'not-found' };
}
- if (!global.storage || typeof global.storage.remove !== 'function') {
- return { deleted: false, reason: 'storage-remove-unavailable' };
- }
- await global.storage.remove(configKey);
+ await global.AppData.library.remove(configKey);
await this.deletePathMapForConfiguration(configKey);
- await global.storage.set('exam_index_configurations', nextConfigs);
return {
deleted: true,
@@ -16103,7 +13237,8 @@ if (typeof module !== 'undefined' && module.exports) {
}
async loadLibrary(keyOrForceReload) {
- if (keyOrForceReload === 'default' || keyOrForceReload === 'exam_index') {
+ if (keyOrForceReload === 'default' || keyOrForceReload === null) {
+ await this.setActiveLibraryConfiguration(null);
return this.loadActiveLibrary(true);
}
if (typeof keyOrForceReload === 'string' && keyOrForceReload) {
@@ -16124,7 +13259,7 @@ if (typeof module !== 'undefined' && module.exports) {
async function switchLibraryConfig(key) {
const manager = getInstance();
- const nextKey = key || await manager.getActiveLibraryConfigurationKey() || 'exam_index';
+ const nextKey = typeof key === 'string' && key.trim() ? key.trim() : null;
return manager.applyLibraryConfiguration(nextKey);
}
@@ -16132,10 +13267,25 @@ if (typeof module !== 'undefined' && module.exports) {
return getInstance().loadLibrary(keyOrForceReload);
}
+ async function resolveActiveLibraryIndex() {
+ return getInstance().resolveActiveIndex();
+ }
+
+ async function resolveLibraryIndexForPracticeRecord(record) {
+ return getInstance().resolveIndexForRecord(record);
+ }
+
+ async function resolveExamForPracticeRecord(record) {
+ return getInstance().resolveExamForRecord(record);
+ }
+
global.LibraryManager = {
getInstance,
switchLibraryConfig,
loadLibrary,
+ resolveActiveIndex: resolveActiveLibraryIndex,
+ resolveIndexForRecord: resolveLibraryIndexForPracticeRecord,
+ resolveExamForRecord: resolveExamForPracticeRecord,
get RAW_DEFAULT_PATH_MAP() {
const manager = getInstance();
return manager.RAW_DEFAULT_PATH_MAP;
@@ -16174,6 +13324,9 @@ if (typeof module !== 'undefined' && module.exports) {
global.isBuiltInListeningLibraryAvailable = isBuiltInListeningLibraryAvailable;
global.switchLibraryConfig = switchLibraryConfig;
global.loadLibrary = loadLibrary;
+ global.resolveActiveLibraryIndex = resolveActiveLibraryIndex;
+ global.resolveLibraryIndexForPracticeRecord = resolveLibraryIndexForPracticeRecord;
+ global.resolveExamForPracticeRecord = resolveExamForPracticeRecord;
})(typeof window !== 'undefined' ? window : globalThis);
@@ -16183,25 +13336,15 @@ if (typeof module !== 'undefined' && module.exports) {
global.AppLazyLoader.markProvided([
"js/utils/environmentDetector.js",
"js/utils/logger.js",
- "js/utils/storage.js",
- "js/core/storageProviderRegistry.js",
- "js/data/dataSources/storageDataSource.js",
- "js/data/repositories/baseRepository.js",
- "js/data/repositories/dataRepositoryRegistry.js",
- "js/data/repositories/practiceRepository.js",
- "js/data/repositories/settingsRepository.js",
- "js/data/repositories/backupRepository.js",
- "js/data/repositories/metaRepository.js",
- "js/data/index.js",
- "js/core/practiceCore.js",
- "js/core/practiceRecordAPI.js",
- "js/core/backupAPI.js",
+ "js/data/practiceRecordSource.js",
+ "js/data/v2/dataCatalog.js",
+ "js/data/v2/dataKernel.js",
+ "js/data/v2/appData.js",
"js/core/externalBackupService.js",
- "js/core/practiceStore.js",
+ "js/core/siteDataReset.js",
+ "js/core/practiceCore.js",
"js/core/resourceCore.js",
"assets/generated/reading-exams/manifest.js",
- "js/utils/stateSerializer.js",
- "js/utils/simpleStorageWrapper.js",
"js/app/state-service.js",
"js/services/libraryDiscovery.js",
"js/services/libraryManager.js"
diff --git a/js/bundles/diagnostics.bundle.js b/js/bundles/diagnostics.bundle.js
index 0bdd4088..c59120c3 100644
--- a/js/bundles/diagnostics.bundle.js
+++ b/js/bundles/diagnostics.bundle.js
@@ -125,8 +125,11 @@ class SystemDiagnostics {
/**
* 测试单个题目的通信功能
*/
- async testExamCommunication(examId, timeout = 10000) {
- const exam = window.examIndex?.find(e => e.id === examId);
+ async testExamCommunication(examId, timeout = 10000, examIndex = null) {
+ const index = Array.isArray(examIndex)
+ ? examIndex
+ : await window.resolveActiveLibraryIndex();
+ const exam = index.find(e => e.id === examId);
if (!exam) {
return {
examId,
@@ -179,7 +182,10 @@ class SystemDiagnostics {
}
};
- examWindow.postMessage(testMessage, '*');
+ examWindow.postMessage(
+ testMessage,
+ window.location.protocol === 'file:' ? '*' : window.location.origin
+ );
// 等待响应
const result = await new Promise((resolve) => {
@@ -237,14 +243,17 @@ class SystemDiagnostics {
/**
* 批量测试通信功能
*/
- async testMultipleExams(examIds, concurrency = 3) {
+ async testMultipleExams(examIds, concurrency = 3, examIndex = null) {
console.log(`[SystemDiagnostics] 开始批量测试 ${examIds.length} 个题目的通信功能`);
+ const index = Array.isArray(examIndex)
+ ? examIndex
+ : await window.resolveActiveLibraryIndex();
const results = [];
for (let i = 0; i < examIds.length; i += concurrency) {
const batch = examIds.slice(i, i + concurrency);
const batchResults = await Promise.all(
- batch.map(examId => this.testExamCommunication(examId))
+ batch.map(examId => this.testExamCommunication(examId, 10000, index))
);
results.push(...batchResults);
}
@@ -298,7 +307,7 @@ class SystemDiagnostics {
connection.window.postMessage({
type: 'HEARTBEAT',
timestamp: Date.now()
- }, '*');
+ }, window.location.protocol === 'file:' ? '*' : window.location.origin);
}
} catch (error) {
this.handleConnectionLost(examId, 'connection_error');
@@ -526,7 +535,7 @@ class SystemDiagnostics {
async fullSystemDiagnostics() {
console.log('[SystemDiagnostics] 开始完整系统诊断...');
- const examIndex = window.examIndex || [];
+ const examIndex = await window.resolveActiveLibraryIndex();
const diagnosticReport = {
timestamp: Date.now(),
indexValidation: null,
@@ -543,7 +552,7 @@ class SystemDiagnostics {
// 如果有失败的题目,进行通信测试
if (diagnosticReport.indexValidation.failedExams.length > 0) {
const failedExamIds = diagnosticReport.indexValidation.failedExams.map(exam => exam.id);
- diagnosticReport.communicationTest = await this.testMultipleExams(failedExamIds.slice(0, 5)); // 限制测试数量
+ diagnosticReport.communicationTest = await this.testMultipleExams(failedExamIds.slice(0, 5), 3, examIndex); // 限制测试数量
}
} catch (error) {
console.error('[SystemDiagnostics] 索引验证失败:', error);
@@ -1142,6 +1151,7 @@ class PerformanceOptimizer {
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
+ timeout = null;
func(...args);
};
clearTimeout(timeout);
diff --git a/js/bundles/legacy-app.bundle.js b/js/bundles/legacy-app.bundle.js
index 7bd8c2a2..6e665ccb 100644
--- a/js/bundles/legacy-app.bundle.js
+++ b/js/bundles/legacy-app.bundle.js
@@ -40,7 +40,6 @@
});
}
- var storage = window.storage;
// Fallback for navigation
if (typeof window.showView !== 'function') {
window.showView = function (viewName, resetCategory) {
@@ -101,7 +100,6 @@
if (normalized === 'practice' && typeof window.ensurePracticeRecordsSync === 'function') {
window.ensurePracticeRecordsSync('practice-view').catch(function () { });
}
- if (normalized === 'practice' && typeof window.updatePracticeView === 'function') window.updatePracticeView();
};
}
@@ -145,53 +143,24 @@
return fn.name === 'lazyProxy' || src.indexOf('ensureLazyGroup') !== -1 || src.indexOf('AppLazyLoader') !== -1;
};
- function _ensureFallbackDataIntegrityManager() {
- if (!window.dataIntegrityManager && window.DataIntegrityManager) {
- try {
- window.dataIntegrityManager = new window.DataIntegrityManager();
- } catch (error) {
- console.warn('[Fallback] 初始化 DataIntegrityManager 失败:', error);
- }
- }
- return window.dataIntegrityManager || null;
+ function _fallbackDownloadJson(data, filename) {
+ var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json; charset=utf-8' });
+ var url = URL.createObjectURL(blob);
+ var anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = filename;
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
+ URL.revokeObjectURL(url);
}
- var _fallbackDataIntegrityLoadPromise = null;
-
- function _ensureFallbackDataIntegrityManagerAsync() {
- var manager = _ensureFallbackDataIntegrityManager();
- if (manager) {
- return Promise.resolve(manager);
- }
-
- if (!_fallbackDataIntegrityLoadPromise) {
- if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') {
- _fallbackDataIntegrityLoadPromise = window.AppLazyLoader.ensureGroup('settings-tools');
- } else if (typeof document !== 'undefined' && !window.DataIntegrityManager) {
- _fallbackDataIntegrityLoadPromise = new Promise(function (resolve, reject) {
- var script = document.createElement('script');
- script.src = 'js/components/DataIntegrityManager.js';
- script.onload = resolve;
- script.onerror = function (error) {
- reject(error || new Error('failed to load DataIntegrityManager'));
- };
- document.head.appendChild(script);
- });
- } else {
- _fallbackDataIntegrityLoadPromise = Promise.resolve();
- }
- }
-
- return _fallbackDataIntegrityLoadPromise.then(function () {
- var readyManager = _ensureFallbackDataIntegrityManager();
- if (!readyManager) {
- throw new Error('数据管理模块未初始化');
- }
- return readyManager;
- }).catch(function (error) {
- _fallbackDataIntegrityLoadPromise = null;
- throw error;
- });
+ async function _fallbackExportAllData() {
+ await window.AppData.ready;
+ var snapshot = await window.AppData.backups.export();
+ _fallbackDownloadJson(snapshot, 'ielts-atlas-backup-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json');
+ try { await window.AppData.backups.recordExport({ type: 'full-v2', checksum: snapshot.checksum }); } catch (error) { console.warn('[Fallback] 导出历史记录失败:', error); }
+ return snapshot;
}
function _fallbackCreateElement(tag, attributes, children) {
@@ -294,21 +263,13 @@
return;
}
- var manager = null;
- try {
- manager = await _ensureFallbackDataIntegrityManagerAsync();
- } catch (error) {
- window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error');
- return;
- }
-
if (!confirm('确定要恢复备份 ' + backupId + ' 吗?当前数据将被覆盖。')) {
return;
}
try {
window.showMessage && window.showMessage('正在恢复备份...', 'info');
- await manager.restoreBackup(backupId);
+ await window.AppData.backups.restore(backupId);
window.showMessage && window.showMessage('备份恢复成功', 'success');
setTimeout(function () {
try {
@@ -387,30 +348,6 @@
};
}
- var ensureDataBackupManager = (function () {
- let loading = null;
- return function ensureDataBackupManager() {
- if (window.DataBackupManager) {
- return Promise.resolve(new window.DataBackupManager());
- }
- if (loading) {
- return loading.then(() => new window.DataBackupManager());
- }
- if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') {
- loading = window.AppLazyLoader.ensureGroup('settings-tools');
- return loading.then(() => new window.DataBackupManager());
- }
- loading = new Promise((resolve, reject) => {
- const script = document.createElement('script');
- script.src = 'js/utils/dataBackupManager.js';
- script.onload = () => resolve();
- script.onerror = (err) => reject(err || new Error('failed to load dataBackupManager'));
- document.head.appendChild(script);
- });
- return loading.then(() => new window.DataBackupManager());
- };
- })();
-
function showImportModeModal(onSelect) {
const overlay = document.createElement('div');
overlay.className = 'import-mode-overlay-lite';
@@ -434,7 +371,7 @@
const defs = [
{ mode: 'merge', icon: '📥', title: '增量导入', text: '合并新数据,保留现有记录。适合日常更新。' },
- { mode: 'replace', icon: '⚠️', title: '覆盖导入', text: '清空并替换所有记录。慎用,数据不可恢复。' }
+ { mode: 'replace', icon: '⚠️', title: '覆盖练习记录', text: '仅用文件中的练习记录替换现有记录;提交前会显示删除数量。' }
];
defs.forEach((def) => {
@@ -597,12 +534,29 @@
return;
}
try {
- const manager = await ensureDataBackupManager();
- const result = await manager.importPracticeData(data, {
- mergeMode: mode === 'replace' ? 'replace' : 'merge',
- createBackup: true,
- validateData: true
+ const payload = Array.isArray(data) ? { records: data } : data;
+ const preview = await window.AppData.backups.previewImport(payload, { practiceMode: mode === 'replace' ? 'replace' : 'merge' });
+ if (preview.destructive) {
+ const practice = preview.practice || {};
+ const summary = [
+ '这次导入会删除现有数据。',
+ `练习记录:现有 ${Number(practice.existingCount) || 0} 条 → 导入后 ${Number(practice.finalCount) || 0} 条`,
+ `将删除 ${Number(practice.removedCount) || 0} 条。`
+ ];
+ if (Array.isArray(preview.clearedKeys) && preview.clearedKeys.length) {
+ summary.push(`将清空数据域:${preview.clearedKeys.join('、')}`);
+ }
+ summary.push('', '是否确认继续?');
+ if (!window.confirm(summary.join('\n'))) {
+ window.showMessage && window.showMessage('已取消导入,现有数据未改变', 'info');
+ return;
+ }
+ }
+ const backup = await window.AppData.backups.create({ type: 'pre-import' });
+ const result = await window.AppData.backups.commitImport(preview.id, {
+ confirmDestructive: preview.destructive === true
});
+ try { await window.AppData.backups.recordImport({ type: preview.format, keys: preview.keys, backupId: backup.id, practice: preview.practice }); } catch (historyError) { console.warn('[Fallback] 导入历史记录失败:', historyError); }
window.showMessage && window.showMessage(`导入成功:新增 ${result.importedCount || 0} 条,跳过 ${result.skippedCount || 0} 条。`, 'success');
} catch (error) {
console.error('[importData] failed', error);
@@ -614,17 +568,8 @@
if (typeof window.exportAllData !== 'function') {
window.exportAllData = async function () {
- var manager = null;
try {
- manager = await _ensureFallbackDataIntegrityManagerAsync();
- } catch (error) {
- console.error('[Fallback] 数据导出模块加载失败:', error);
- window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error');
- return;
- }
-
- try {
- await manager.exportData();
+ await _fallbackExportAllData();
window.showMessage && window.showMessage('数据导出成功', 'success');
} catch (error) {
console.error('[Fallback] 数据导出失败:', error);
@@ -659,25 +604,14 @@
// Fallbacks for backup operations used by Settings
if (typeof window.createManualBackup !== 'function') {
window.createManualBackup = async function () {
- var manager = null;
try {
- manager = await _ensureFallbackDataIntegrityManagerAsync();
- } catch (error) {
- window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error');
- return;
- }
- try {
- var backup = await manager.createBackup(null, 'manual');
- if (backup && backup.external) {
- window.showMessage && window.showMessage('本地存储不足,已将备份下载为文件', 'warning');
- } else {
- window.showMessage && window.showMessage('备份创建成功: ' + (backup && backup.id ? backup.id : ''), 'success');
- }
+ var backup = await window.AppData.backups.create({ type: 'manual' });
+ window.showMessage && window.showMessage('备份创建成功: ' + (backup && backup.id ? backup.id : ''), 'success');
try { if (typeof window.showBackupList === 'function') { window.showBackupList(); } } catch (_) { }
} catch (error) {
if (_fallbackIsQuotaExceeded(error)) {
try {
- await manager.exportData();
+ await _fallbackExportAllData();
window.showMessage && window.showMessage('存储不足:已将数据导出为文件', 'warning');
} catch (exportErr) {
window.showMessage && window.showMessage('备份失败且导出失败: ' + (exportErr && exportErr.message ? exportErr.message : exportErr), 'error');
@@ -691,18 +625,10 @@
if (typeof window.showBackupList !== 'function') {
window.showBackupList = async function () {
- var manager = null;
- try {
- manager = await _ensureFallbackDataIntegrityManagerAsync();
- } catch (error) {
- window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error');
- return;
- }
-
_ensureFallbackBackupDelegates();
var backups = [];
try {
- backups = await manager.getBackupList();
+ backups = await window.AppData.backups.list();
} catch (error) {
console.warn('[Fallback] 获取备份列表失败:', error);
window.showMessage && window.showMessage('无法获取备份列表', 'error');
@@ -801,38 +727,11 @@
async function ensureDefaultConfig() {
try {
- var configs = [];
- if (window.storage && storage.get) {
- var maybeConfigs = storage.get('exam_index_configurations', []);
- configs = (maybeConfigs && typeof maybeConfigs.then === 'function') ? await maybeConfigs : maybeConfigs;
- }
+ var configs = await window.AppData.library.listConfigurations();
if (!Array.isArray(configs)) configs = [];
- var hasDefault = configs.some(function (c) { return c && c.key === 'exam_index'; });
- if (!hasDefault) {
- var count = Array.isArray(window.examIndex) ? window.examIndex.length : 0;
- configs.push({ name: '默认题库', key: 'exam_index', examCount: count, timestamp: Date.now() });
- if (window.storage && storage.set) {
- try {
- var maybeSetConfigs = storage.set('exam_index_configurations', configs);
- if (maybeSetConfigs && typeof maybeSetConfigs.then === 'function') await maybeSetConfigs;
- } catch (err) {
- console.warn('[Fallback] 无法保存 exam_index_configurations:', err);
- }
- }
- if (window.storage && storage.get) {
- try {
- var currentActive = storage.get('active_exam_index_key');
- currentActive = (currentActive && typeof currentActive.then === 'function') ? await currentActive : currentActive;
- if (!currentActive && window.storage && storage.set) {
- var maybeSetActive = storage.set('active_exam_index_key', 'exam_index');
- if (maybeSetActive && typeof maybeSetActive.then === 'function') await maybeSetActive;
- }
- } catch (activeErr) {
- console.warn('[Fallback] 无法校正 active_exam_index_key:', activeErr);
- }
- }
- }
- return configs;
+ var activeIndex = await window.resolveActiveLibraryIndex();
+ var count = Array.isArray(activeIndex) ? activeIndex.length : 0;
+ return [{ name: '默认题库', key: '', id: null, builtIn: true, sourceType: 'built-in-manifest', examCount: count }].concat(configs);
} catch (e) {
console.warn('[Fallback] ensureDefaultConfig 失败:', e);
return [];
@@ -859,23 +758,18 @@
window.showLibraryConfigListV2 = async function (options) {
var configs = [];
try {
- configs = (window.storage && storage.get) ? await storage.get('exam_index_configurations', []) : [];
+ configs = await ensureDefaultConfig();
} catch (e) {
configs = [];
}
- if (!Array.isArray(configs) || configs.length === 0) {
- configs = await ensureDefaultConfig();
- }
if (!Array.isArray(configs) || configs.length === 0) {
if (window.showMessage) showMessage('暂无题库配置记录', 'info');
return;
}
- var activeKey = 'exam_index';
+ var activeKey = null;
try {
- if (window.storage && storage.get) {
- activeKey = await storage.get('active_exam_index_key', 'exam_index');
- }
+ activeKey = await window.AppData.library.getActive();
} catch (e) { }
var containerId = options && typeof options.containerId === 'string' ? options.containerId : null;
@@ -918,12 +812,14 @@
configs.forEach(function (cfg) {
if (!cfg) return;
var item = document.createElement('div');
- item.className = 'library-config-panel__item' + (cfg.key === activeKey ? ' library-config-panel__item--active' : '');
+ var isDefault = cfg.builtIn === true;
+ var isActive = isDefault ? activeKey == null : cfg.key === activeKey;
+ item.className = 'library-config-panel__item' + (isActive ? ' library-config-panel__item--active' : '');
var info = document.createElement('div');
info.className = 'library-config-panel__info';
var titleLine = document.createElement('div');
- titleLine.textContent = (cfg.key === 'exam_index' ? '默认题库' : (cfg.name || cfg.key));
+ titleLine.textContent = (isDefault ? '默认题库' : (cfg.name || cfg.key));
info.appendChild(titleLine);
var meta = document.createElement('div');
@@ -941,18 +837,18 @@
switchBtn.className = 'btn btn-secondary';
switchBtn.type = 'button';
switchBtn.dataset.configAction = 'switch';
- switchBtn.dataset.configKey = cfg.key;
- if (cfg.key === activeKey) switchBtn.disabled = true;
+ switchBtn.dataset.configKey = cfg.key || '';
+ if (isActive) switchBtn.disabled = true;
switchBtn.textContent = '切换';
actions.appendChild(switchBtn);
- if (cfg.key !== 'exam_index') {
+ if (!isDefault) {
var deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-warning';
deleteBtn.type = 'button';
deleteBtn.dataset.configAction = 'delete';
- deleteBtn.dataset.configKey = cfg.key;
- if (cfg.key === activeKey) deleteBtn.disabled = true;
+ deleteBtn.dataset.configKey = cfg.key || '';
+ if (isActive) deleteBtn.disabled = true;
deleteBtn.textContent = '删除';
actions.appendChild(deleteBtn);
}
@@ -1329,29 +1225,14 @@
if (typeof window.getActiveLibraryConfigurationKey === 'function') {
try { return await window.getActiveLibraryConfigurationKey(); } catch (_) { }
}
- if (storage && storage.get) {
- try {
- var maybeKey = storage.get('active_exam_index_key', 'exam_index');
- var key = (maybeKey && typeof maybeKey.then === 'function') ? await maybeKey : maybeKey;
- return key || 'exam_index';
- } catch (_) { }
- }
- return 'exam_index';
+ return window.AppData.library.getActive();
}
async function _fallbackSetActiveLibraryKey(key) {
- if (!key) return;
if (typeof window.setActiveLibraryConfiguration === 'function') {
try { await window.setActiveLibraryConfiguration(key); return; } catch (_) { }
}
- if (storage && storage.set) {
- try {
- var maybe = storage.set('active_exam_index_key', key);
- if (maybe && typeof maybe.then === 'function') await maybe;
- } catch (err) {
- console.warn('[Fallback] 无法写入 active_exam_index_key:', err);
- }
- }
+ await window.AppData.library.activate(typeof key === 'string' && key.trim() ? key.trim() : null);
}
async function _fallbackSaveLibraryConfiguration(name, key, count) {
@@ -1359,51 +1240,28 @@
if (typeof window.saveLibraryConfiguration === 'function') {
try { await window.saveLibraryConfiguration(name, key, count); return; } catch (_) { }
}
- if (storage && storage.get && storage.set) {
- try {
- var existing = storage.get('exam_index_configurations', []);
- existing = (existing && typeof existing.then === 'function') ? await existing : existing;
- if (!Array.isArray(existing)) existing = [];
- var idx = existing.findIndex(function (c) { return c && c.key === key; });
- if (idx >= 0) { existing[idx] = entry; } else { existing.push(entry); }
- var maybeSave = storage.set('exam_index_configurations', existing);
- if (maybeSave && typeof maybeSave.then === 'function') await maybeSave;
- } catch (err) {
- console.warn('[Fallback] 保存题库配置失败:', err);
- }
- }
+ if (key) await window.AppData.library.updateConfiguration(entry);
}
async function _fallbackSaveIndexForKey(key, list) {
- if (storage && storage.set) {
- var maybe = storage.set(key, list);
- if (maybe && typeof maybe.then === 'function') {
- await maybe;
- }
- } else {
- try { window[key] = list; } catch (_) { }
- }
+ if (key) await window.AppData.library.import({ id: key, configuration: { id: key, key: key, name: key }, index: list });
}
async function _fallbackApplyLibraryConfig(key, dataset, options) {
if (typeof window.applyLibraryConfiguration === 'function') {
try { return await window.applyLibraryConfiguration(key, dataset, options || {}); } catch (_) { }
}
- // fallback:直接刷新内存状态与UI
- if (typeof window.setExamIndexState === 'function') {
- try { window.setExamIndexState(dataset); } catch (_) { }
- } else {
- try { window.examIndex = Array.isArray(dataset) ? dataset.slice() : []; } catch (_) { }
- }
+ var snapshot = Array.isArray(dataset) ? dataset.slice() : [];
if (options && options.setActive) {
await _fallbackSetActiveLibraryKey(key);
}
- try { if (typeof window.updateOverview === 'function') window.updateOverview(); } catch (_) { }
+ try { if (typeof window.updateOverview === 'function') window.updateOverview(snapshot); } catch (_) { }
try {
if (typeof window.loadExamList === 'function') {
- window.loadExamList();
+ window.loadExamList(snapshot);
}
} catch (_) { }
+ try { window.dispatchEvent(new CustomEvent('examIndexLoaded', { detail: { key: key, index: snapshot } })); } catch (_) { }
return true;
}
@@ -1614,15 +1472,7 @@
}
var activeKey = await _fallbackGetActiveLibraryKey();
- var currentIndex = (typeof window.getExamIndexState === 'function')
- ? window.getExamIndexState()
- : (Array.isArray(window.examIndex) ? window.examIndex : []);
- if (storage && storage.get) {
- try {
- var maybeCurrent = storage.get(activeKey, currentIndex);
- currentIndex = (maybeCurrent && typeof maybeCurrent.then === 'function') ? await maybeCurrent : maybeCurrent;
- } catch (_) { }
- }
+ var currentIndex = await window.resolveActiveLibraryIndex();
if (!Array.isArray(currentIndex)) currentIndex = [];
currentIndex = _fallbackNormalizeIndexForCustomConfig(currentIndex);
@@ -1665,7 +1515,7 @@
};
if (mode === 'full') {
- var targetKey = 'exam_index_' + Date.now();
+ var targetKey = 'library_import_' + Date.now();
var configName = (type === 'reading' ? '阅读' : '听力') + '全量-' + new Date().toLocaleString();
try {
await saveAndApply(targetKey, configName, true);
@@ -1693,7 +1543,7 @@
}
}
- var targetKeyInc = 'exam_index_' + Date.now();
+ var targetKeyInc = 'library_import_' + Date.now();
var configNameInc = (type === 'reading' ? '阅读' : '听力') + '增量-' + new Date().toLocaleString();
await saveAndApply(targetKeyInc, configNameInc, false);
await _fallbackApplyLibraryConfig(targetKeyInc, newIndex, { setActive: true, skipConfigRefresh: false });
@@ -1758,118 +1608,6 @@
})();
-/* ===== js/patches/runtime-fixes.js ===== */
-// Runtime fixes to smooth async storage + recovery under file://
-(function () {
- 'use strict';
-
- function ensureCompatPatch(global) {
- if (!global || (global.CompatPatch && typeof global.CompatPatch.register === 'function')) {
- return global && global.CompatPatch ? global.CompatPatch : null;
- }
- var patches = [];
- var register = function register(name, metadata) {
- if (!name) {
- return null;
- }
- var patch = Object.assign({
- name: String(name),
- owner: 'legacy',
- reason: '',
- removeAfter: ''
- }, metadata || {});
- patches.push(patch);
- return patch;
- };
- var list = function list() {
- return patches.slice();
- };
- global.CompatPatch = Object.assign({}, global.CompatPatch || {}, {
- register: register,
- list: list
- });
- return global.CompatPatch;
- }
-
- ensureCompatPatch(window);
-
- if (window.CompatPatch && typeof window.CompatPatch.register === 'function') {
- window.CompatPatch.register('practice-recorder-temp-recovery-async', {
- owner: 'practice',
- reason: 'file protocol compatible recovery for legacy temporary practice records',
- removeAfter: 'after PracticeRecorder recovery is canonical'
- });
- }
-
- try {
- // Patch PracticeRecorder.recoverTemporaryRecords to a robust async version
- const patchPracticeRecorder = () => {
- const PR = window.PracticeRecorder;
- if (!PR || !PR.prototype) return false;
-
- const original = PR.prototype.recoverTemporaryRecords;
- PR.prototype.recoverTemporaryRecords = async function () {
- try {
- const raw = (window.storage && storage.get)
- ? await storage.get('temp_practice_records', [])
- : [];
- const tempRecords = Array.isArray(raw) ? raw : [];
-
- if (tempRecords.length === 0) {
- console.log('[PracticeRecorder] 没有需要恢复的临时记录');
- return;
- }
-
- console.log(`[PracticeRecorder] 发现 ${tempRecords.length} 条临时记录,开始恢复...`);
-
- let recoveredCount = 0;
- const failed = [];
-
- for (const tempRecord of tempRecords) {
- try {
- const { tempSavedAt, needsRecovery, ...cleanRecord } = tempRecord || {};
- const sanitized = (this && typeof this.sanitizeRecoveredRecord === 'function')
- ? this.sanitizeRecoveredRecord(cleanRecord)
- : cleanRecord;
- if (!sanitized || !sanitized.examId) {
- console.warn('[PracticeRecorder] 跳过无法修正的临时记录(缺少 examId 或字段无效)', cleanRecord && cleanRecord.id);
- continue;
- }
- if (this && typeof this.savePracticeRecord === 'function') {
- await this.savePracticeRecord(sanitized);
- }
- recoveredCount++;
- console.log(`[PracticeRecorder] 恢复记录成功: ${sanitized && sanitized.id}`);
- } catch (e) {
- console.error(`[PracticeRecorder] 恢复记录失败: ${tempRecord && tempRecord.id}`, e);
- failed.push(tempRecord);
- }
- }
-
- if (failed.length === 0) {
- if (window.storage && storage.remove) await storage.remove('temp_practice_records');
- console.log(`[PracticeRecorder] 所有 ${recoveredCount} 条临时记录恢复成功`);
- } else {
- if (window.storage && storage.set) await storage.set('temp_practice_records', failed);
- console.log(`[PracticeRecorder] 恢复了 ${recoveredCount} 条记录,${failed.length} 条失败`);
- }
- } catch (error) {
- console.error('[PracticeRecorder] 恢复临时记录时出错:', error);
- }
- };
-
- console.log('[RuntimeFixes] PracticeRecorder.recoverTemporaryRecords 已替换为异步实现');
- return true;
- };
-
- const tryPatch = () => {
- if (!patchPracticeRecorder()) setTimeout(tryPatch, 100);
- };
- tryPatch();
- } catch (_) {}
-})();
-
-
/* ===== js/app.js ===== */
/**
* 主应用程序
@@ -1886,17 +1624,14 @@ class ExamSystemApp {
this.state = {
// 考试相关状态
exam: {
- index: [],
currentCategory: 'all',
currentExamType: 'all',
filteredExams: [],
- configurations: {},
- activeConfigKey: 'exam_index'
+ configurations: {}
},
// 练习相关状态
practice: {
- records: [],
selectedRecords: new Set(),
bulkDeleteMode: false,
dataCollector: null
@@ -1915,7 +1650,6 @@ class ExamSystemApp {
// 组件实例
components: {
- dataIntegrityManager: null,
pdfHandler: null,
browseStateManager: null,
practiceListScroller: null
@@ -1952,62 +1686,6 @@ class ExamSystemApp {
const current = this.getState(path);
this.setState(path, { ...current, ...updates });
},
- async persistState(path, storageKey = null) {
- const value = this.getState(path);
- const key = storageKey || path.replace('.', '_');
- try {
- const serializedValue = StateSerializer.serialize(value);
- await storage.set(key, serializedValue);
- } catch (error) {
- console.error(`[App] 持久化状态失败 ${path}:`, error);
- }
- },
- async persistMultipleState(mapping) {
- const promises = Object.entries(mapping).map(([path, storageKey]) =>
- this.persistState(path, storageKey)
- );
- try {
- await Promise.all(promises);
- } catch (error) {
- console.error('[App] 批量持久化状态失败:', error);
- }
- },
- async loadState(path, storageKey = null) {
- const key = storageKey || path.replace('.', '_');
- try {
- const value = await storage.get(key, null);
- if (value !== null) {
- const deserializedValue = StateSerializer.deserialize(value);
- this.setState(path, deserializedValue);
- return deserializedValue;
- }
- } catch (error) {
- console.error(`[App] 加载状态失败 ${path}:`, error);
- }
- return null;
- },
- async loadPersistedState() {
- const stateMappings = {
- exam: 'app_exam_state',
- practice: 'app_practice_state',
- ui: 'app_ui_state',
- system: 'app_system_state'
- };
- for (const [path, storageKey] of Object.entries(stateMappings)) {
- await this.loadState(path, storageKey);
- }
- console.log('[App] 持久化状态加载完成');
- },
- async saveAllState() {
- const stateMappings = {
- exam: 'app_exam_state',
- practice: 'app_practice_state',
- ui: 'app_ui_state',
- system: 'app_system_state'
- };
- await this.persistMultipleState(stateMappings);
- console.log('[App] 所有状态已保存');
- },
async checkComponents() {
console.log('=== 组件加载检查 ===');
try {
@@ -2046,12 +1724,9 @@ class ExamSystemApp {
console.log(`${name}: ${status}`);
});
console.log('\n=== 数据检查 ===');
- const practiceRecordsCount = this.getState('practice.records')?.length || 0;
- console.log(`practiceRecords: ${practiceRecordsCount} 条记录`);
try {
- const records = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function'
- ? await window.PracticeRecordAPI.list()
- : [];
+ // 只统计条数,light 投影即可,避免为诊断日志拉取全量答题详情。
+ const records = await window.AppData.practice.list({ projection: 'light' });
const count = Array.isArray(records) ? records.length : 0;
console.log(`canonical practice records: ${count} 条记录`);
} catch (_) {
@@ -2078,11 +1753,6 @@ class ExamSystemApp {
console.warn('[App] AppStateService connect failed:', error);
}
}
- Object.defineProperty(window, 'dataIntegrityManager', {
- get: () => this.state.components.dataIntegrityManager,
- set: (value) => this.setState('components.dataIntegrityManager', value),
- configurable: true
- });
Object.defineProperty(window, 'pdfHandler', {
get: () => this.state.components.pdfHandler,
set: (value) => this.setState('components.pdfHandler', value),
@@ -2099,33 +1769,22 @@ class ExamSystemApp {
const integratedBootstrapMixin = {
checkDependencies() {
- const requiredGlobals = ['storage'];
+ const requiredGlobals = ['AppData'];
const missing = requiredGlobals.filter((name) => !window[name]);
if (missing.length > 0) {
throw new Error(`Missing required dependencies: ${missing.join(', ')}`);
}
},
async initializeComponents() {
- const optionalComponents = [];
- try {
- await this.initializeCoreComponents();
- if (optionalComponents.length > 0) {
- try {
- await this.waitForComponents(optionalComponents, 5000);
- await this.initializeOptionalComponents();
- } catch (_) {
- await this.initializeAvailableOptionalComponents();
- }
- } else {
- await this.initializeOptionalComponents();
- }
- } catch (error) {
- console.error('[App] 核心组件加载失败:', error);
- throw error;
- }
+ await this.initializeCoreComponents();
},
async initializeCoreComponents() {
if (this.instantiatePracticeRecorder()) {
+ // PracticeRecorder restores durable sessions asynchronously. The
+ // hot-upgrade rebind must run after that restore has completed;
+ // otherwise the recovery snapshot can overwrite the host session
+ // that we are about to seed.
+ await this._practiceRecorderRebindPromise;
return;
}
console.warn('[App] PracticeRecorder类不可用,使用降级记录器');
@@ -2138,14 +1797,119 @@ class ExamSystemApp {
return false;
}
try {
- this.components.practiceRecorder = new PracticeRecorder();
+ const previous = this.components && this.components.practiceRecorder
+ ? this.components.practiceRecorder
+ : null;
+ if (previous && previous.constructor === window.PracticeRecorder && previous.isFallback !== true) {
+ return true;
+ }
+ const recorder = new PracticeRecorder();
+ this.components.practiceRecorder = recorder;
this.ensurePracticeRecorderEvents();
+ // Hot-upgrade from the bootstrap fallback must re-seed live host sessions;
+ // otherwise PRACTICE_COMPLETE finds no activeSessions and production rejects
+ // synthetic saves, so the child never receives PRACTICE_SUBMIT_ACK / results.
+ const recorderReady = recorder.ready && typeof recorder.ready.then === 'function'
+ ? recorder.ready
+ : Promise.resolve();
+ this._practiceRecorderRebindPromise = Promise.resolve(recorderReady)
+ .then(() => this._rebindPracticeRecorderSessions(recorder, previous))
+ .catch((rebindError) => {
+ console.warn('[App] PracticeRecorder ready 后重建活动会话失败:', rebindError);
+ });
return true;
} catch (error) {
console.error('[App] PracticeRecorder初始化失败:', error);
return false;
}
},
+ _rebindPracticeRecorderSessions(recorder, previousRecorder = null) {
+ if (!recorder || typeof recorder.startPracticeSession !== 'function') {
+ return;
+ }
+ const seeded = new Set();
+ try {
+ if (this.examWindows && typeof this.examWindows.forEach === 'function') {
+ this.examWindows.forEach((info, examId) => {
+ if (!info || !examId) {
+ return;
+ }
+ if (info.reviewMode || String(info.practiceMode || '').toLowerCase() === 'memorize') {
+ return;
+ }
+ if (info.status === 'completed' || info.status === 'closed') {
+ return;
+ }
+ const sessionId = info.expectedSessionId || info.sessionId || null;
+ if (!sessionId) {
+ return;
+ }
+ try {
+ recorder.startPracticeSession(examId, {
+ sessionId: String(sessionId),
+ title: info.title || info.examTitle || '',
+ category: info.category || info.pageType || '',
+ frequency: info.frequency || '',
+ libraryConfigurationId: Object.prototype.hasOwnProperty.call(info, 'libraryConfigurationId')
+ ? info.libraryConfigurationId
+ : (typeof this._readLaunchLibraryConfigurationId === 'function'
+ ? this._readLaunchLibraryConfigurationId(examId, null, info)
+ : null)
+ });
+ if (typeof recorder.handleSessionStarted === 'function') {
+ recorder.handleSessionStarted({
+ examId,
+ sessionId: String(sessionId),
+ metadata: {
+ pageType: info.pageType || null,
+ suiteSessionId: info.suiteSessionId || null,
+ source: 'recorder-hot-upgrade',
+ libraryConfigurationId: Object.prototype.hasOwnProperty.call(info, 'libraryConfigurationId')
+ ? info.libraryConfigurationId
+ : null
+ }
+ });
+ }
+ seeded.add(String(examId));
+ } catch (seedError) {
+ console.warn('[App] 升级 PracticeRecorder 时重建活动会话失败:', examId, seedError);
+ }
+ });
+ }
+ } catch (error) {
+ console.warn('[App] 升级 PracticeRecorder 时扫描 examWindows 失败:', error);
+ }
+
+ // Carry over any sessions the fallback stub tracked in-memory before the class loaded.
+ try {
+ const priorSessions = previousRecorder && previousRecorder.activeSessions;
+ if (priorSessions && typeof priorSessions.forEach === 'function') {
+ priorSessions.forEach((session, examId) => {
+ if (!examId || seeded.has(String(examId)) || !session) {
+ return;
+ }
+ const sessionId = session.sessionId || session.id || null;
+ if (!sessionId) {
+ return;
+ }
+ try {
+ recorder.startPracticeSession(examId, Object.assign({}, session.metadata || {}, {
+ sessionId: String(sessionId),
+ title: session.metadata && (session.metadata.examTitle || session.metadata.title) || '',
+ totalQuestions: session.progress && session.progress.totalQuestions || 0,
+ libraryConfigurationId: session.metadata && session.metadata.libraryConfigurationId != null
+ ? session.metadata.libraryConfigurationId
+ : null
+ }));
+ } catch (seedError) {
+ console.warn('[App] 升级 PracticeRecorder 时迁移降级会话失败:', examId, seedError);
+ }
+ });
+ }
+ } catch (error) {
+ console.warn('[App] 升级 PracticeRecorder 时读取降级会话失败:', error);
+ }
+ },
ensurePracticeRecorderEvents() {
if (this._practiceRecorderEventsBound) {
return;
@@ -2155,36 +1919,61 @@ class ExamSystemApp {
}
},
createFallbackRecorder() {
- function normalizeRecords(records) {
- return Array.isArray(records) ? records : [];
- }
+ const activeSessions = new Map();
+ const start = (examId, examData = {}) => {
+ const sessionId = (examData && examData.sessionId)
+ || `fallback_${examId || 'exam'}_${Date.now()}`;
+ const session = {
+ examId: examId || '',
+ startTime: new Date().toISOString(),
+ sessionId,
+ status: 'started',
+ progress: {
+ totalQuestions: examData && examData.totalQuestions || 0
+ },
+ metadata: {
+ examTitle: examData && examData.title || '',
+ category: examData && examData.category || '',
+ frequency: examData && examData.frequency || '',
+ libraryConfigurationId: examData && examData.libraryConfigurationId != null
+ ? examData.libraryConfigurationId
+ : null
+ }
+ };
+ if (examId) {
+ activeSessions.set(examId, session);
+ }
+ return session;
+ };
return {
- startPracticeSession: (examId) => ({ examId: examId || '', startTime: Date.now(), sessionId: `fallback_${Date.now()}`, status: 'started' }),
- startSession: (examId) => ({ examId: examId || '', startTime: Date.now(), sessionId: `fallback_${Date.now()}`, status: 'started' }),
- handleRealPracticeData: async () => null,
- savePracticeRecord: async (record) => {
- try {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.saveRecord === 'function') {
- await window.PracticeRecordAPI.saveRecord(record);
- } else {
- throw new Error('统一练习记录存储未就绪');
- }
- } catch (error) {
- console.warn('[App] 降级记录器保存失败:', error);
+ activeSessions,
+ isFallback: true,
+ startPracticeSession: start,
+ startSession: start,
+ handleSessionStarted: (data) => {
+ if (!data || !data.examId || !data.sessionId) {
+ return;
}
- return record || null;
- },
- getPracticeRecords: async () => {
- try {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- return normalizeRecords(await window.PracticeRecordAPI.list());
- }
- return [];
- } catch (error) {
- console.warn('[App] 降级记录器读取失败:', error);
- return [];
+ const existing = activeSessions.get(data.examId) || {
+ examId: data.examId,
+ startTime: new Date().toISOString(),
+ status: 'started',
+ metadata: {}
+ };
+ existing.sessionId = data.sessionId;
+ existing.status = 'active';
+ if (data.metadata) {
+ existing.metadata = Object.assign({}, existing.metadata || {}, data.metadata);
}
- }
+ activeSessions.set(data.examId, existing);
+ },
+ savePracticeRecord: async (record) => {
+ const receipt = await window.AppData.practice.completeAttempt({ record });
+ return receipt && receipt.record ? receipt.record : null;
+ },
+ // 兼容用的记录列表读取:调用方只做列表/统计展示,light 投影已覆盖,
+ // 不需要拉取答题详情、笔记与高亮等重负载字段。
+ getPracticeRecords: async () => window.AppData.practice.list({ projection: 'light' })
};
},
schedulePracticeRecorderUpgrade(maxAttempts = 20, interval = 500) {
@@ -2209,43 +1998,6 @@ class ExamSystemApp {
this._practiceRecorderUpgradeTimer = setInterval(tryUpgrade, interval);
tryUpgrade();
},
- async initializeOptionalComponents() {},
- async initializeAvailableOptionalComponents() {
- const availableComponents = [].filter((name) => window[name]);
- if (availableComponents.length > 0) {
- await this.initializeOptionalComponents();
- } else {
- console.warn('[App] 没有发现可用的可选组件');
- }
- },
- async waitForComponents(requiredClasses = ['ExamBrowser'], timeout = 3000) {
- const startTime = Date.now();
- const checkInterval = 100;
- while (Date.now() - startTime < timeout) {
- const loadingStatus = requiredClasses.map((className) => {
- const isLoaded = window[className] && typeof window[className] === 'function';
- if (!isLoaded) {
- console.debug(`[App] 等待组件: ${className}`);
- }
- return { className, isLoaded };
- });
- const allLoaded = loadingStatus.every((status) => status.isLoaded);
- if (allLoaded) {
- return true;
- }
- await new Promise((resolve) => setTimeout(resolve, checkInterval));
- }
- const missingClasses = requiredClasses.filter((className) => !window[className] || typeof window[className] !== 'function');
- const loadedClasses = requiredClasses.filter((className) => window[className] && typeof window[className] === 'function');
- const errorMessage = [
- `组件加载超时 (${timeout}ms)`,
- `已加载: ${loadedClasses.join(', ') || '无'}`,
- `缺失: ${missingClasses.join(', ')}`,
- '请检查组件文件是否正确加载'
- ].join('\n');
- console.error('[App] 组件加载失败:', errorMessage);
- throw new Error(errorMessage);
- }
};
const integratedFallbackMixin = {
@@ -2510,11 +2262,17 @@ class ExamSystemApp {
case 'browse':
if (window.__pendingBrowseFilter && typeof window.applyBrowseFilter === 'function') {
const { category, type, filterMode, path } = window.__pendingBrowseFilter;
- try {
- window.applyBrowseFilter(category, type, filterMode, path);
- } finally {
- delete window.__pendingBrowseFilter;
- }
+ Promise.resolve(
+ typeof window.initializeBrowseView === 'function'
+ ? window.initializeBrowseView({ skipLoad: true })
+ : null
+ ).then(() => window.applyBrowseFilter(category, type, filterMode, path))
+ .catch((error) => {
+ console.warn('[App] 应用待处理题库筛选失败:', error);
+ })
+ .finally(() => {
+ delete window.__pendingBrowseFilter;
+ });
} else if (typeof window.initializeBrowseView === 'function') {
window.initializeBrowseView();
}
@@ -2525,6 +2283,9 @@ class ExamSystemApp {
.then(() => (typeof window.ensureBrowseGroup === 'function' ? window.ensureBrowseGroup() : null))
.then(() => (typeof window.ensurePracticeSuiteReady === 'function' ? window.ensurePracticeSuiteReady() : null))
.then(() => {
+ if (typeof window.ensurePracticeRecordsSync === 'function') {
+ return window.ensurePracticeRecordsSync('practice-view');
+ }
if (typeof window.syncPracticeRecords === 'function') {
return window.syncPracticeRecords();
}
@@ -2554,6 +2315,7 @@ class ExamSystemApp {
}
},
browseCategory(category, type = null, filterMode = null, path = null) {
+ const wasAlreadyInBrowse = this.currentView === 'browse';
try {
window.__pendingBrowseFilter = { category, type, filterMode, path };
const descriptor = Object.getOwnPropertyDescriptor(window, '__browseFilter');
@@ -2568,14 +2330,16 @@ class ExamSystemApp {
} catch (_) {}
this.navigateToView('browse');
try {
- if (typeof window.applyBrowseFilter === 'function' && document.getElementById('browse-view')?.classList.contains('active')) {
+ // 非 browse → browse 时,onViewActivated 已经消费 pending filter;
+ // 只有原本就在 browse 页时才需要补一次应用,避免双重加载。
+ if (wasAlreadyInBrowse && typeof window.applyBrowseFilter === 'function' && document.getElementById('browse-view')?.classList.contains('active')) {
window.applyBrowseFilter(category, type, filterMode, path);
delete window.__pendingBrowseFilter;
}
} catch (_) {}
},
async startCategoryPractice(category) {
- const examIndex = await storage.get('exam_index', []);
+ const examIndex = await window.resolveActiveLibraryIndex();
const categoryExams = examIndex.filter((exam) => exam.category === category);
if (categoryExams.length === 0) {
window.showMessage(`${category} 分类暂无可用题目`, 'warning');
@@ -2599,8 +2363,6 @@ class ExamSystemApp {
this.checkDependencies();
this.updateLoadingMessage('正在初始化状态管理...');
this.initializeGlobalCompatibility();
- this.updateLoadingMessage('正在加载持久化状态...');
- await this.loadPersistedState();
this.updateLoadingMessage('正在初始化响应式功能...');
this.initializeResponsiveFeatures();
this.updateLoadingMessage('正在加载系统组件...');
@@ -2801,20 +2563,13 @@ class ExamSystemApp {
},
async loadInitialData() {
try {
- const examIndex = await storage.get('exam_index', []);
- if (Array.isArray(examIndex)) {
- this.setState('exam.index', examIndex);
- }
- const practiceRecords = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function'
- ? await window.PracticeRecordAPI.list()
- : [];
- if (Array.isArray(practiceRecords)) {
- this.setState('practice.records', practiceRecords);
- }
- const browseFilter = await storage.get('browse_filter', { category: 'all', type: 'all' });
+ const browsePreference = await window.AppData.preferences.getBrowse();
+ const browseFilter = browsePreference && browsePreference.filter
+ ? browsePreference.filter
+ : { category: 'all', type: 'all' };
this.setState('ui.browseFilter', browseFilter);
await this.loadUserStats();
- this.updateOverviewStats();
+ await this.updateOverviewStats();
} catch (error) {
console.error('Failed to load initial data:', error);
}
@@ -2830,15 +2585,15 @@ class ExamSystemApp {
lastPracticeDate: null,
achievements: []
};
- const stats = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function'
- ? await window.PracticeRecordAPI.readStats({ fallback })
- : fallback;
+ const stats = Object.assign({}, fallback, await window.AppData.practice.getStats());
this.userStats = stats;
return stats;
},
async updateOverviewStats() {
- const examIndex = this.getState('exam.index') || [];
- const practiceRecords = this.getState('practice.records') || [];
+ const [examIndex, practiceRecords] = await Promise.all([
+ window.resolveActiveLibraryIndex(),
+ window.AppData.practice.list({ projection: 'light' })
+ ]);
if (!Array.isArray(examIndex) || !Array.isArray(practiceRecords)) {
console.warn('[App] 状态管理中的数据格式异常');
return;
@@ -2876,7 +2631,7 @@ class ExamSystemApp {
},
updateCategoryStats(examIndex, practiceRecords) {
const categories = ['P1', 'P2', 'P3'];
- const list = Array.isArray(examIndex) ? examIndex : (Array.isArray(window.examIndex) ? window.examIndex : []);
+ const list = Array.isArray(examIndex) ? examIndex : [];
categories.forEach((category) => {
const categoryExams = list.filter((exam) => exam.category === category);
const categoryRecords = practiceRecords.filter((record) => {
@@ -2964,7 +2719,12 @@ class ExamSystemApp {
},
onStartEndless() {
if (window.AppActions && typeof window.AppActions.startEndlessPractice === 'function') {
- window.AppActions.startEndlessPractice();
+ Promise.resolve(window.AppActions.startEndlessPractice()).catch((error) => {
+ console.error('[App] 无尽模式启动失败:', error);
+ if (typeof window.showMessage === 'function') {
+ window.showMessage('无尽模式启动失败,请稍后重试', 'error');
+ }
+ });
return;
}
if (typeof window.showMessage === 'function') {
@@ -3000,12 +2760,6 @@ class ExamSystemApp {
}
},
destroy() {
- this.persistMultipleState({
- 'exam.index': 'exam_index',
- 'ui.browseFilter': 'browse_filter',
- 'exam.currentCategory': 'current_category',
- 'exam.currentExamType': 'current_exam_type'
- });
window.removeEventListener('resize', this.handleResize);
if (this.sessionMonitorInterval) {
clearInterval(this.sessionMonitorInterval);
@@ -3152,7 +2906,7 @@ window.addEventListener('beforeunload', () => {
* 兼容 file:// 协议
*
* 数据层约定(0.6.2-fix 之后):
- * - 示例记录必须经 PracticeRecordAPI.saveRecord,且具备 canonical examId
+ * - 示例记录必须经 AppData.practice.completeAttempt,且具备 canonical examId
* - 回放依赖 realData.answers(object map)+ correctAnswerMap
* - 引导状态键使用 exam_system_ 前缀,并兼容迁移旧键
*/
@@ -3187,19 +2941,6 @@ window.addEventListener('beforeunload', () => {
type: 'reading'
});
- // 存储键名(带前缀;读取时兼容旧键)
- const STORAGE_KEYS = {
- COMPLETED: 'exam_system_onboarding_completed',
- CURRENT_STEP: 'exam_system_onboarding_step',
- LAST_SHOWN: 'exam_system_onboarding_last_shown'
- };
-
- const LEGACY_STORAGE_KEYS = {
- COMPLETED: 'onboardingCompleted',
- CURRENT_STEP: 'onboardingStep',
- LAST_SHOWN: 'onboardingLastShown'
- };
-
const HISTORY_ITEM_SELECTOR =
`#history-list .history-item.history-record-item[data-record-id="${DEMO_RECORD_ID}"]`;
const HISTORY_TITLE_SELECTOR =
@@ -3391,16 +3132,6 @@ window.addEventListener('beforeunload', () => {
nextText: '下一步',
lockScroll: true,
disableHighlightPointer: true
- },
- {
- id: 'local-backup',
- target: '#external-backup-entry-btn',
- title: '💾 本地磁盘备份',
- content: '若浏览器支持,可绑定本地文件夹做磁盘备份,与导出 JSON 互为补充。',
- position: 'top',
- nextText: '下一步',
- lockScroll: true,
- disableHighlightPointer: true
}
]
},
@@ -3450,71 +3181,51 @@ window.addEventListener('beforeunload', () => {
// 状态管理器
class TourStateManager {
constructor() {
- this._storage = this._getStorage();
- this._migrateLegacyKeys();
+ this._state = { completed: false, currentStep: 0, lastShown: null };
+ this.ready = this._load();
}
- _getStorage() {
- try {
- localStorage.setItem('__test__', '1');
- localStorage.removeItem('__test__');
- return localStorage;
- } catch (e) {
- const mem = {};
- return {
- getItem: (k) => (Object.prototype.hasOwnProperty.call(mem, k) ? mem[k] : null),
- setItem: (k, v) => { mem[k] = String(v); },
- removeItem: (k) => { delete mem[k]; }
- };
- }
+ async _load() {
+ if (!global.AppData || !global.AppData.preferences) return;
+ await global.AppData.ready;
+ const stored = await global.AppData.preferences.getOnboarding();
+ this._state = {
+ completed: stored.completed === true || stored.completed === 'true',
+ currentStep: Number.isFinite(Number(stored.currentStep)) ? Number(stored.currentStep) : 0,
+ lastShown: stored.lastShown || null
+ };
}
- _migrateLegacyKeys() {
- Object.keys(STORAGE_KEYS).forEach((name) => {
- const nextKey = STORAGE_KEYS[name];
- const legacyKey = LEGACY_STORAGE_KEYS[name];
- if (!legacyKey) return;
- try {
- const current = this._storage.getItem(nextKey);
- if (current !== null && current !== undefined && current !== '') return;
- const legacy = this._storage.getItem(legacyKey);
- if (legacy === null || legacy === undefined || legacy === '') return;
- this._storage.setItem(nextKey, legacy);
- this._storage.removeItem(legacyKey);
- } catch (_) {
- // ignore migration failures
- }
+ _persist() {
+ if (!global.AppData || !global.AppData.preferences) return;
+ global.AppData.preferences.setOnboarding(this._state).catch((error) => {
+ console.warn('[Onboarding] 保存引导状态失败:', error);
});
}
isCompleted() {
- return this._storage.getItem(STORAGE_KEYS.COMPLETED) === 'true';
+ return this._state.completed === true;
}
getCurrentStep() {
- const step = this._storage.getItem(STORAGE_KEYS.CURRENT_STEP);
- return step ? parseInt(step, 10) : 0;
+ return this._state.currentStep || 0;
}
setStep(step) {
- this._storage.setItem(STORAGE_KEYS.CURRENT_STEP, String(step));
- this._storage.setItem(STORAGE_KEYS.LAST_SHOWN, String(Date.now()));
+ this._state.currentStep = Number(step) || 0;
+ this._state.lastShown = Date.now();
+ this._persist();
}
markCompleted() {
- this._storage.setItem(STORAGE_KEYS.COMPLETED, 'true');
- this._storage.removeItem(STORAGE_KEYS.CURRENT_STEP);
+ this._state.completed = true;
+ this._state.currentStep = 0;
+ this._persist();
}
reset() {
- this._storage.removeItem(STORAGE_KEYS.COMPLETED);
- this._storage.removeItem(STORAGE_KEYS.CURRENT_STEP);
- this._storage.removeItem(STORAGE_KEYS.LAST_SHOWN);
- try {
- this._storage.removeItem(LEGACY_STORAGE_KEYS.COMPLETED);
- this._storage.removeItem(LEGACY_STORAGE_KEYS.CURRENT_STEP);
- this._storage.removeItem(LEGACY_STORAGE_KEYS.LAST_SHOWN);
- } catch (_) {}
+ this._state = { completed: false, currentStep: 0, lastShown: null };
+ this._persist();
}
}
@@ -3735,8 +3446,9 @@ window.addEventListener('beforeunload', () => {
destroy() {
this.clearHighlight();
if (this._overlay) {
- this._overlay.classList.remove('is-active');
- setTimeout(() => this._overlay?.remove(), 300);
+ const overlay = this._overlay;
+ overlay.classList.remove('is-active');
+ setTimeout(() => overlay.remove(), 300);
this._overlay = null;
}
if (this._holeEl) {
@@ -3744,8 +3456,9 @@ window.addEventListener('beforeunload', () => {
this._holeEl = null;
}
if (this._tooltip) {
- this._tooltip.classList.remove('is-visible');
- setTimeout(() => this._tooltip?.remove(), 300);
+ const tooltip = this._tooltip;
+ tooltip.classList.remove('is-visible');
+ setTimeout(() => tooltip.remove(), 300);
this._tooltip = null;
}
}
@@ -3763,7 +3476,11 @@ window.addEventListener('beforeunload', () => {
this._boundKeyHandler = null;
this._currentSubStep = 0;
this._inSubSteps = false;
- this._demoInjectPromise = null;
+ this._demoInjectTask = null;
+ this._demoCleanupPromise = null;
+ this._lifecycleToken = 0;
+ this._startTimer = null;
+ this._selectorWaiters = new Set();
this._lastDemoInjectResult = null;
this._clickWaitCleanup = null;
this._scrollBlocked = false;
@@ -3773,12 +3490,14 @@ window.addEventListener('beforeunload', () => {
this._savedScrollTop = 0;
}
- init() {
+ async init() {
+ await this._stateManager.ready;
if (this._stateManager.isCompleted()) {
return;
}
- setTimeout(() => {
+ this._startTimer = setTimeout(() => {
+ this._startTimer = null;
this.start();
}, 1500);
}
@@ -3789,6 +3508,7 @@ window.addEventListener('beforeunload', () => {
// 每次启动使用步骤副本,避免限级回放补丁污染默认配置
this._steps = cloneSteps(this._baseSteps);
this._currentStep = fromBeginning ? 0 : this._stateManager.getCurrentStep();
+ this._lifecycleToken += 1;
this._isActive = true;
this._inSubSteps = false;
this._currentSubStep = 0;
@@ -3812,6 +3532,14 @@ window.addEventListener('beforeunload', () => {
stop() {
this._isActive = false;
+ this._lifecycleToken += 1;
+ if (this._startTimer !== null) {
+ clearTimeout(this._startTimer);
+ this._startTimer = null;
+ }
+ this._cancelSelectorWaits();
+ this._clearDemoRecordPreview();
+ void this._cleanupDemoRecord();
this._clearClickWait();
this._unlockScroll();
this._unlockPointer();
@@ -4114,8 +3842,10 @@ window.addEventListener('beforeunload', () => {
};
if (subStep.action === 'injectDemoRecord') {
+ const lifecycleToken = this._lifecycleToken;
Promise.resolve(this._injectDemoRecord())
.then((result) => {
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) return;
this._lastDemoInjectResult = result;
if (!result || !result.ok) {
this._showInjectFailureSubStep(parentStep, result);
@@ -4128,6 +3858,7 @@ window.addEventListener('beforeunload', () => {
proceed();
})
.catch((err) => {
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) return;
console.error('[Onboarding] 注入示例记录失败:', err);
this._lastDemoInjectResult = { ok: false, reason: 'exception', error: err };
this._showInjectFailureSubStep(parentStep, this._lastDemoInjectResult);
@@ -4315,28 +4046,8 @@ window.addEventListener('beforeunload', () => {
let list = [];
try {
- if (typeof global.getExamIndexState === 'function') {
- list = global.getExamIndexState();
- } else if (Array.isArray(global.examIndex)) {
- list = global.examIndex;
- }
- } catch (_) {}
-
- if (!Array.isArray(list) || list.length === 0) {
- try {
- const storage = global.persistentStore || global.storage;
- if (storage && typeof storage.get === 'function') {
- let activeKey = 'exam_index';
- try {
- activeKey = await storage.get('active_exam_index_key', 'exam_index') || 'exam_index';
- } catch (_) {}
- list = await storage.get(activeKey, []) || [];
- if ((!Array.isArray(list) || list.length === 0) && activeKey !== 'exam_index') {
- list = await storage.get('exam_index', []) || [];
- }
- }
- } catch (_) {}
- }
+ list = await global.resolveActiveLibraryIndex();
+ } catch (_) { }
if (!Array.isArray(list)) list = [];
@@ -4505,44 +4216,82 @@ window.addEventListener('beforeunload', () => {
}));
}
- _waitForSelector(selector, maxWait = 4000) {
+ _waitForSelector(selector, maxWait = 4000, lifecycleToken = this._lifecycleToken) {
return new Promise((resolve) => {
const startTime = Date.now();
+ const waiter = { timer: null, settle: null };
+ const settle = (value) => {
+ if (!this._selectorWaiters.has(waiter)) return;
+ if (waiter.timer !== null) clearTimeout(waiter.timer);
+ this._selectorWaiters.delete(waiter);
+ resolve(value);
+ };
const check = () => {
+ waiter.timer = null;
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) {
+ settle(null);
+ return;
+ }
const el = document.querySelector(selector);
if (el) {
- resolve(el);
+ settle(el);
return;
}
if (Date.now() - startTime > maxWait) {
- resolve(null);
+ settle(null);
return;
}
- setTimeout(check, 120);
+ waiter.timer = setTimeout(check, 120);
};
+ waiter.settle = settle;
+ this._selectorWaiters.add(waiter);
check();
});
}
+ _cancelSelectorWaits() {
+ for (const waiter of Array.from(this._selectorWaiters)) {
+ waiter.settle(null);
+ }
+ }
+
+ _isDemoLifecycleCurrent(token) {
+ return this._isActive && token === this._lifecycleToken;
+ }
+
async _injectDemoRecord() {
- if (this._demoInjectPromise) {
- return this._demoInjectPromise;
+ const lifecycleToken = this._lifecycleToken;
+ if (this._demoInjectTask && this._demoInjectTask.token === lifecycleToken) {
+ return this._demoInjectTask.promise;
}
- this._demoInjectPromise = (async () => {
- const api = global.PracticeRecordAPI;
- if (!api || typeof api.saveRecord !== 'function') {
- return { ok: false, reason: 'PracticeRecordAPI unavailable' };
+ const injectPromise = (async () => {
+ const api = global.AppData && global.AppData.practice;
+ if (!api || typeof api.completeAttempt !== 'function') {
+ return { ok: false, reason: 'AppData.practice unavailable' };
}
const examContext = await this._resolveDemoExamContext();
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) {
+ return { ok: false, reason: 'cancelled' };
+ }
+ if (this._demoCleanupPromise) await this._demoCleanupPromise;
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) {
+ return { ok: false, reason: 'cancelled' };
+ }
const demoRecordObj = this._buildDemoRecord(examContext);
+ // 演示记录带 metadata.source = 'onboarding-demo',会被统一的来源判定
+ // (js/data/practiceRecordSource.js)排除在练习记录列表、成绩统计与成就之外。
+ // 引导需要用户看见这一行,所以显式为这一个 id 申请"视图层预览"许可:
+ // 只放行渲染,投影器读不到该白名单,统计与成就仍然不会被演示数据污染。
+ this._allowDemoRecordPreview();
+
try {
- // 避免污染 user_stats
- await api.saveRecord(demoRecordObj, { updateStats: false });
+ await api.completeAttempt({ record: demoRecordObj });
} catch (err) {
console.error('[Onboarding] 注入示例记录失败:', err);
+ this._clearDemoRecordPreview();
return {
ok: false,
reason: err && err.message ? err.message : 'saveRecord failed',
@@ -4550,8 +4299,24 @@ window.addEventListener('beforeunload', () => {
};
}
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) {
+ if (this._demoCleanupPromise) await this._demoCleanupPromise;
+ await this._cleanupDemoRecord({ refresh: false });
+ return { ok: false, reason: 'cancelled' };
+ }
+
await this._refreshPracticeHistory();
- const row = await this._waitForSelector(HISTORY_ITEM_SELECTOR, 5000);
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) {
+ if (this._demoCleanupPromise) await this._demoCleanupPromise;
+ await this._cleanupDemoRecord({ refresh: false });
+ return { ok: false, reason: 'cancelled' };
+ }
+ const row = await this._waitForSelector(HISTORY_ITEM_SELECTOR, 5000, lifecycleToken);
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) {
+ if (this._demoCleanupPromise) await this._demoCleanupPromise;
+ await this._cleanupDemoRecord({ refresh: false });
+ return { ok: false, reason: 'cancelled' };
+ }
if (!row) {
return {
ok: false,
@@ -4570,33 +4335,70 @@ window.addEventListener('beforeunload', () => {
recordId: DEMO_RECORD_ID
};
})();
+ this._demoInjectTask = { token: lifecycleToken, promise: injectPromise };
try {
- return await this._demoInjectPromise;
+ return await injectPromise;
} finally {
- this._demoInjectPromise = null;
+ if (this._demoInjectTask && this._demoInjectTask.promise === injectPromise) {
+ this._demoInjectTask = null;
+ }
+ }
+ }
+
+ /**
+ * 申请/撤销演示记录的"视图层预览"许可。
+ * 见 js/data/practiceRecordSource.js 的引导预览白名单说明:许可只影响练习记录列表渲染,
+ * practice.stats 与 achievements.progress 投影器永远按"演示数据"排除这条记录。
+ */
+ _allowDemoRecordPreview() {
+ const classifier = global.PracticeRecordSource;
+ if (classifier && typeof classifier.allowPreviewRecordId === 'function') {
+ classifier.allowPreviewRecordId(DEMO_RECORD_ID);
+ }
+ }
+
+ _clearDemoRecordPreview() {
+ const classifier = global.PracticeRecordSource;
+ if (classifier && typeof classifier.clearPreviewRecordId === 'function') {
+ classifier.clearPreviewRecordId(DEMO_RECORD_ID);
}
}
- async _cleanupDemoRecord() {
- const api = global.PracticeRecordAPI;
- if (!api || typeof api.deleteById !== 'function') {
+ async _cleanupDemoRecord(options = {}) {
+ // 先撤销预览许可再删除并重渲染:即使删除失败,这条演示记录也不会继续留在列表里。
+ this._clearDemoRecordPreview();
+
+ if (this._demoCleanupPromise) return this._demoCleanupPromise;
+
+ const api = global.AppData && global.AppData.practice;
+ if (!api || typeof api.delete !== 'function') {
return;
}
- try {
- await api.deleteById(DEMO_RECORD_ID, { updateStats: false });
- if (typeof global.syncPracticeRecords === 'function') {
- await Promise.resolve(global.syncPracticeRecords({ forceRender: true }));
- } else if (global.app && typeof global.app.renderPracticeHistory === 'function') {
- await Promise.resolve(global.app.renderPracticeHistory());
- } else {
- global.dispatchEvent(new CustomEvent('practiceRecordsUpdated', {
- detail: { source: 'onboarding-cleanup' }
- }));
+ const refresh = options.refresh !== false;
+ const cleanupPromise = (async () => {
+ try {
+ await api.delete({ recordId: DEMO_RECORD_ID });
+ if (!refresh) return;
+ if (typeof global.syncPracticeRecords === 'function') {
+ await Promise.resolve(global.syncPracticeRecords({ forceRender: true }));
+ } else if (global.app && typeof global.app.renderPracticeHistory === 'function') {
+ await Promise.resolve(global.app.renderPracticeHistory());
+ } else {
+ global.dispatchEvent(new CustomEvent('practiceRecordsUpdated', {
+ detail: { source: 'onboarding-cleanup' }
+ }));
+ }
+ } catch (err) {
+ console.warn('[Onboarding] 清理示例记录失败:', err);
}
- } catch (err) {
- console.warn('[Onboarding] 清理示例记录失败:', err);
+ })();
+ this._demoCleanupPromise = cleanupPromise;
+ try {
+ await cleanupPromise;
+ } finally {
+ if (this._demoCleanupPromise === cleanupPromise) this._demoCleanupPromise = null;
}
}
@@ -4726,7 +4528,6 @@ window.addEventListener('beforeunload', () => {
}
_complete() {
- this._cleanupDemoRecord();
this._stateManager.markCompleted();
this.stop();
}
@@ -4766,7 +4567,6 @@ window.addEventListener('beforeunload', () => {
if (global.AppLazyLoader && typeof global.AppLazyLoader.markProvided === "function") {
global.AppLazyLoader.markProvided([
"js/boot-fallbacks.js",
- "js/patches/runtime-fixes.js",
"js/app.js",
"js/components/onboardingTour.js"
]);
diff --git a/js/bundles/listening-record-bridge.bundle.js b/js/bundles/listening-record-bridge.bundle.js
index 88af9408..042c7a33 100644
--- a/js/bundles/listening-record-bridge.bundle.js
+++ b/js/bundles/listening-record-bridge.bundle.js
@@ -1,5 +1,3840 @@
/* Generated by scripts/build-bundles.mjs. Do not edit by hand. */
+/* ===== js/data/practiceRecordSource.js ===== */
+/**
+ * 练习记录来源判定 —— “什么算真实练习记录”的唯一权威定义。
+ *
+ * 背景(本文件存在的理由):
+ * 这条规则历史上被复制成了两套互不相通的实现,语义还不一样:
+ * - UI 侧 js/main.js `updatePracticeView` 只看顶层 `dataSource`;
+ * - 投影器侧 js/data/v2/appData.js `computeStats` / `computeAchievementProgress`
+ * 只看 `metadata.source === 'onboarding-demo'`。
+ * 结果是 `demo` / `e2e-seed` 这类记录“在练习记录页看不见,却计入成绩统计和成就解锁”,
+ * 用户会看到自己没做过的题影响了正确率与成就。
+ *
+ * 因此判定必须只有一份实现,并被所有消费方共享。本文件同时被打进
+ * core-foundation / reading-page / practice-page-enhancer / listening-record-bridge /
+ * listening-wrapper(供 appData.js 的投影器使用)和 browse(供 js/main.js 的渲染过滤使用)
+ * 等 bundle;appData.js 在启动时硬性要求本模块存在,缺失即抛错,杜绝“再退回本地副本”。
+ *
+ * ---------------------------------------------------------------------------
+ * 语义(两个维度,任一命中即判为非真实)
+ *
+ * 1) dataSource(顶层,回退 metadata.dataSource)
+ * - 缺失 / null / 空串 => **真实记录**
+ * - 'real' => 真实记录
+ * - 其它任何显式值 => 非真实(演示 / 种子 / 占位)
+ *
+ * “缺失即真实”是硬性约束,不得收窄:生产代码只在 practiceRecorder / examSessionMixin
+ * 三处写过该字段且都写 'real',套题聚合、听力桥接、legacy 迁移记录从来不写。
+ * 曾经有一版把“没标注”当成“非真实”,直接导致练习记录页整页空白(线上 P0)。
+ *
+ * 2) metadata.source
+ * 只精确匹配已知的演示/种子标记,**绝不做包含匹配**。
+ * 这个字段是被复用的:套题记录会写 'listening' / 'reading'(内容类型标签,见
+ * js/app/suitePracticeMixin.js),消息通道会写 'practice_page' / 'inline_collector'
+ * / 'suite_placeholder' / 'listening_record_bridge' / 'data_collector'(采集方式标签)。
+ * 任何模糊匹配都可能把真实记录判成演示数据,属于同一类 P0。
+ *
+ * 注意:`record.source` 与 `realData.source` 是采集方式标签而非来源标注,故不参与判定。
+ */
+(function initPracticeRecordSource(global) {
+ 'use strict';
+
+ // 同一份源码会被多个 bundle 内联(浏览器里 core-foundation 与 browse 都会执行一次),
+ // 重复赋值本身无害,但仍按仓库惯例做幂等保护,避免任何形态的静默覆盖。
+ if (global.PracticeRecordSource && global.PracticeRecordSource.__stable === true) {
+ return;
+ }
+
+ /** 被认可为“真实用户练习”的显式 dataSource 取值。 */
+ const REAL_DATA_SOURCES = Object.freeze(['real']);
+
+ /**
+ * 被认定为“演示 / 种子 / 夹具数据”的 metadata.source 取值(精确匹配,大小写与首尾空白无关)。
+ * 目前生产代码只会写出 'onboarding-demo'(js/components/onboardingTour.js);
+ * 其余是历史与测试夹具里出现过的等价写法,一并显式列出而不是靠模糊匹配推断。
+ */
+ const DEMO_SOURCE_MARKERS = Object.freeze([
+ 'onboarding-demo',
+ 'onboarding_demo',
+ 'onboardingdemo',
+ 'demo',
+ 'e2e-seed',
+ 'e2e_seed'
+ ]);
+
+ /** 只有新手引导自己的 marker 才有资格申请临时历史列表预览。 */
+ const ONBOARDING_PREVIEW_MARKERS = Object.freeze([
+ 'onboarding-demo',
+ 'onboarding_demo',
+ 'onboardingdemo'
+ ]);
+
+ const realDataSourceSet = new Set(REAL_DATA_SOURCES);
+ const demoSourceSet = new Set(DEMO_SOURCE_MARKERS);
+ const onboardingPreviewMarkerSet = new Set(ONBOARDING_PREVIEW_MARKERS);
+
+ function normalize(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim().toLowerCase();
+ }
+
+ function asObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+ }
+
+ function hasOwn(object, field) {
+ return Object.prototype.hasOwnProperty.call(object, field);
+ }
+
+ /** 读取记录的来源标注:顶层优先,回退 metadata(light 投影同样走这条回退链)。 */
+ function readDataSource(record) {
+ if (hasOwn(record, 'dataSource')) return normalize(record.dataSource);
+ const metadata = asObject(record.metadata);
+ return hasOwn(metadata, 'dataSource') ? normalize(metadata.dataSource) : '';
+ }
+
+ function readMetadataSource(record) {
+ return normalize(asObject(record.metadata).source);
+ }
+
+ /**
+ * 唯一判定入口:该记录是否算作用户的真实练习。
+ * 练习记录列表渲染、practice.stats 投影、achievements.progress 投影三者必须都用它,
+ * 三处结论一致是本模块的核心契约。
+ */
+ function isRealPracticeRecord(record) {
+ if (!record || typeof record !== 'object') return false;
+
+ const dataSource = readDataSource(record);
+ // 缺失/空值一律按真实记录对待(见文件头“缺失即真实”)。
+ if (dataSource !== '' && !realDataSourceSet.has(dataSource)) return false;
+
+ if (demoSourceSet.has(readMetadataSource(record))) return false;
+
+ return true;
+ }
+
+ /** isRealPracticeRecord 的补集,仅对合法记录对象成立(非对象既不真也不演示)。 */
+ function isDemoPracticeRecord(record) {
+ if (!record || typeof record !== 'object') return false;
+ return !isRealPracticeRecord(record);
+ }
+
+ function filterRealPracticeRecords(records) {
+ return (Array.isArray(records) ? records : []).filter(isRealPracticeRecord);
+ }
+
+ // -----------------------------------------------------------------------
+ // 引导预览白名单(仅影响渲染,永不影响统计与成就)
+ //
+ // 新手引导的"回顾模式"步骤会先把一条演示记录写进权威 practice records,
+ // 再等待它在练习记录列表里出现(js/components/onboardingTour.js
+ // `_injectDemoRecord` -> `_waitForSelector`),演示完成后立即删除。
+ //
+ // 这条记录按上面的判定确实是演示数据(metadata.source = 'onboarding-demo'),
+ // 所以它必须继续被 practice.stats / achievements.progress 排除。但引导要教用户
+ // 认识这一行 UI,因此需要一个**显式、按 id 限定、临时**的渲染例外。
+ //
+ // 关键设计:例外只存在于视图层白名单,投影器根本读不到它——
+ // 于是"是否真实"仍然只有一份判定,不会退回"UI 与统计各写一套"的老 bug。
+ // 历史上引导记录之所以能显示,只是因为没人给它写 dataSource(巧合而非设计)。
+ // -----------------------------------------------------------------------
+ const previewRecordIds = new Set();
+
+ function normalizeId(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim();
+ }
+
+ /** 登记一条允许在练习记录列表中预览的演示记录 id(引导步骤开始时调用)。 */
+ function allowPreviewRecordId(recordId) {
+ const id = normalizeId(recordId);
+ if (id) previewRecordIds.add(id);
+ return id !== '';
+ }
+
+ /** 撤销预览许可(引导结束/跳过/清理演示记录时调用)。 */
+ function clearPreviewRecordId(recordId) {
+ if (recordId === undefined) {
+ previewRecordIds.clear();
+ return true;
+ }
+ return previewRecordIds.delete(normalizeId(recordId));
+ }
+
+ function isPreviewRecord(record) {
+ if (!previewRecordIds.size || !record || typeof record !== 'object') return false;
+ if (!onboardingPreviewMarkerSet.has(readMetadataSource(record))) return false;
+ const id = normalizeId(record.id || record.recordId);
+ return Boolean(id && previewRecordIds.has(id));
+ }
+
+ /**
+ * 练习记录列表的渲染过滤:真实记录 + 已显式登记的引导预览记录。
+ * 统计/成就一律用 filterRealPracticeRecords,绝不用这个函数。
+ */
+ function filterRecordsForHistoryView(records) {
+ return (Array.isArray(records) ? records : [])
+ .filter((record) => isRealPracticeRecord(record) || isPreviewRecord(record));
+ }
+
+ const api = Object.freeze({
+ __stable: true,
+ REAL_DATA_SOURCES,
+ DEMO_SOURCE_MARKERS,
+ ONBOARDING_PREVIEW_MARKERS,
+ isRealPracticeRecord,
+ isDemoPracticeRecord,
+ filterRealPracticeRecords,
+ allowPreviewRecordId,
+ clearPreviewRecordId,
+ isPreviewRecord,
+ filterRecordsForHistoryView
+ });
+
+ global.PracticeRecordSource = api;
+
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = api;
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
+/* ===== js/data/v2/dataCatalog.js ===== */
+(function installDataCatalog(global) {
+ 'use strict';
+
+ const V2_SCHEMA_VERSION = 2;
+
+ function clone(value) {
+ if (value === undefined) return undefined;
+ if (typeof structuredClone === 'function') {
+ try { return structuredClone(value); } catch (_) { /* fall through */ }
+ }
+ return JSON.parse(JSON.stringify(value));
+ }
+
+ function objectDefault() { return {}; }
+ function arrayDefault() { return []; }
+ function nullableDefault() { return null; }
+ function normalizeArray(value) { return Array.isArray(value) ? clone(value) : []; }
+ function normalizeObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? clone(value) : {};
+ }
+ function normalizeNullableString(value) {
+ return value === null || value === undefined || value === '' ? null : String(value);
+ }
+ function isArray(value) { return Array.isArray(value); }
+ function isObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }
+ function isNullableString(value) { return value === null || typeof value === 'string'; }
+
+ const CATALOG_OWNERS = new Set([
+ 'settings', 'library', 'recovery', 'backups', 'vocab',
+ 'preferences', 'goals', 'achievements', 'system', 'practice'
+ ]);
+ const CATALOG_CLASSIFICATIONS = new Set(['authoritative', 'preference', 'session', 'system']);
+ const IMPORT_POLICIES = new Set(['replace', 'patch', 'merge-by-id', 'ignore']);
+
+ function isNonEmptyString(value) {
+ return typeof value === 'string' && Boolean(value.trim());
+ }
+
+ function ownerFromKey(logicalKey) {
+ const dot = String(logicalKey || '').indexOf('.');
+ return dot > 0 ? logicalKey.slice(0, dot) : '';
+ }
+
+ function freezeEntry(entry) {
+ const logicalKey = String(entry.logicalKey || '');
+ const owner = ownerFromKey(logicalKey);
+ const next = Object.assign({}, entry, {
+ logicalKey,
+ owner,
+ schemaVersion: V2_SCHEMA_VERSION,
+ export: entry.export === true,
+ import: entry.import || 'ignore'
+ });
+ return Object.freeze(next);
+ }
+
+ // Minimal document catalog. Practice lives in entity stores (summaries/details/annotations),
+ // not as document keys. import merge identity is resolved in AppData, not here.
+ const definitions = [
+ {
+ logicalKey: 'settings.values', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'library.configurations', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'library.importedIndexes', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'library.activeConfigurationId', classification: 'authoritative',
+ defaultValue: nullableDefault, normalize: normalizeNullableString, validate: isNullableString,
+ export: true, import: 'replace'
+ },
+ {
+ logicalKey: 'recovery.activeSessions', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.drafts', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.interrupted', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.rejectedCompletions', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.windowSession', classification: 'session',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'backups.entries', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'backups.settings', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'backups.exportHistory', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'backups.importHistory', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'vocab.words', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'vocab.userConfig', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'vocab.lists', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'preferences.values', classification: 'preference',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'goals.items', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'achievements.manual', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'achievements.progress', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'system.migrations', classification: 'system',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'system.operationJournal', classification: 'system',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ }
+ ].map(freezeEntry);
+
+ function validateCatalog(entries) {
+ if (!Array.isArray(entries) || !entries.length) throw new Error('DataCatalog requires at least one entry');
+ const logicalKeys = new Set();
+ for (const entry of entries) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
+ throw new Error('DataCatalog entry must be an object');
+ }
+ if (!isNonEmptyString(entry.logicalKey) || logicalKeys.has(entry.logicalKey)) {
+ throw new Error(`DataCatalog duplicate/invalid logical key: ${entry.logicalKey}`);
+ }
+ logicalKeys.add(entry.logicalKey);
+ }
+ for (const entry of entries) {
+ if (!CATALOG_OWNERS.has(entry.owner) || !entry.logicalKey.startsWith(`${entry.owner}.`)) {
+ throw new Error(`DataCatalog owner conflict for ${entry.logicalKey}: ${entry.owner}`);
+ }
+ if (!CATALOG_CLASSIFICATIONS.has(entry.classification)
+ || !Number.isInteger(entry.schemaVersion) || entry.schemaVersion !== V2_SCHEMA_VERSION
+ || typeof entry.defaultValue !== 'function'
+ || typeof entry.normalize !== 'function'
+ || typeof entry.validate !== 'function'
+ || typeof entry.export !== 'boolean'
+ || !IMPORT_POLICIES.has(entry.import)) {
+ throw new Error(`DataCatalog incomplete contract: ${entry.logicalKey}`);
+ }
+ try {
+ const defaultValue = entry.defaultValue();
+ if (!entry.validate(defaultValue) || !entry.validate(entry.normalize(defaultValue))) {
+ throw new Error('invalid default');
+ }
+ } catch (_) {
+ throw new Error(`DataCatalog invalid default contract: ${entry.logicalKey}`);
+ }
+ }
+ return true;
+ }
+
+ validateCatalog(definitions);
+ const byKey = new Map(definitions.map((entry) => [entry.logicalKey, entry]));
+ const DataCatalog = Object.freeze({
+ version: V2_SCHEMA_VERSION,
+ list() { return definitions.slice(); },
+ get(logicalKey) {
+ const entry = byKey.get(String(logicalKey || ''));
+ if (!entry) throw new Error(`DataCatalog unknown logical key: ${logicalKey}`);
+ return entry;
+ },
+ has(logicalKey) { return byKey.has(String(logicalKey || '')); },
+ validate: validateCatalog,
+ clone
+ });
+
+ Object.defineProperty(global, '__AppDataV2Catalog', {
+ value: DataCatalog,
+ enumerable: false,
+ configurable: true,
+ writable: false
+ });
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
+/* ===== js/data/v2/dataKernel.js ===== */
+(function installDataKernel(global) {
+ 'use strict';
+
+ if (global.AppData) return;
+
+ const catalog = global.__AppDataV2Catalog;
+ if (!catalog) throw new Error('AppData v2 requires DataCatalog before DataKernel');
+
+ const DATABASE_NAME = 'IELTSAtlasDataV2';
+ // Version 2 uses a new schema, but initialization must still import the durable
+ // ExamSystemDB data owned by releases which predate AppData v2.
+ const DATABASE_VERSION = 2;
+ const DOCUMENT_STORE = 'documents';
+ const SYSTEM_STORE = 'system';
+ const ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']);
+ const STORE_NAMES = Object.freeze([DOCUMENT_STORE, SYSTEM_STORE].concat(ENTITY_STORES));
+ const OPERATION_JOURNAL_WINDOW = 500;
+ const COMMIT_CHANNEL_NAME = `${DATABASE_NAME}:committed`;
+ const DEFAULT_IDB_MUTATION_TIMEOUT_MS = 30000;
+ const DEFAULT_IDB_REQUEST_TIMEOUT_MS = 30000;
+ const MAX_TIMER_DELAY_MS = 2147483647;
+ const LEGACY_DATABASE_NAME = 'ExamSystemDB';
+ const LEGACY_STORE_NAME = 'keyValueStore';
+ const LEGACY_EXTERNAL_DATABASE_NAME = 'ExamSystemExternalBackup';
+ const LEGACY_EXTERNAL_STORE_NAME = 'handles';
+ const LEGACY_EXTERNAL_HANDLE_KEY = 'backup_directory';
+ const LEGACY_EXTERNAL_FILENAME = 'practice-backup-latest.json';
+ const LEGACY_UNPREFIXED_WEB_KEYS = Object.freeze([
+ 'practice_records',
+ 'vocab_user_config',
+ 'user_achievements'
+ ]);
+
+ function clone(value) { return catalog.clone(value); }
+ function nowIso() { return new Date().toISOString(); }
+ function randomId(prefix) {
+ const random = global.crypto && typeof global.crypto.randomUUID === 'function'
+ ? global.crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`;
+ return `${prefix || 'op'}_${random}`;
+ }
+
+ class AppDataError extends Error {
+ constructor(code, message, details = {}) {
+ super(message);
+ this.name = 'AppDataError';
+ this.code = code;
+ this.committed = false;
+ this.details = details;
+ }
+ }
+ function validation(message, details) { return new AppDataError('VALIDATION', message, details || {}); }
+ function corruption(message, details) { return new AppDataError('CORRUPT_RECORD', message, details || {}); }
+
+ function normalizeTimeoutMs(value, fallback) {
+ if (value === undefined || value === null || value === '') return fallback;
+ const numeric = Number(value);
+ return Number.isFinite(numeric) && numeric > 0 ? Math.min(numeric, MAX_TIMER_DELAY_MS) : fallback;
+ }
+ function scheduleTimeout(handler, delayMs) {
+ if (typeof global.setTimeout !== 'function') throw new Error('setTimeout unavailable');
+ const handle = global.setTimeout.call(global, handler, delayMs);
+ if (handle === null || handle === undefined) throw new Error('setTimeout did not return a handle');
+ return handle;
+ }
+ function cancelTimeout(handle) {
+ if (handle !== null && handle !== undefined && typeof global.clearTimeout === 'function') {
+ try { global.clearTimeout.call(global, handle); } catch (_) { /* already gone */ }
+ }
+ }
+ function withDeadline(handle, timeoutMs, description, resolve, reject) {
+ let settled = false;
+ let timer = null;
+ const settle = (callback, value) => {
+ if (settled) return;
+ settled = true;
+ cancelTimeout(timer);
+ callback(value);
+ };
+ const expire = (error) => {
+ if (settled) return;
+ settled = true;
+ try { if (handle && typeof handle.abort === 'function') handle.abort(); } catch (_) { /* best effort */ }
+ reject(error);
+ };
+ try {
+ timer = scheduleTimeout(() => expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} timed out after ${timeoutMs}ms`, {
+ operation: description, timeoutMs, reason: 'timeout'
+ })), timeoutMs);
+ } catch (error) {
+ expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} watchdog unavailable`, {
+ operation: description, reason: 'watchdog-unavailable', cause: error && error.message
+ }));
+ }
+ return { resolve(value) { settle(resolve, value); }, reject(error) { settle(reject, error); } };
+ }
+
+ function canonicalizeJson(value, path = '$', ancestors = new Set()) {
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
+ if (typeof value === 'number') {
+ if (!Number.isFinite(value)) throw validation(`Non-finite number at ${path}`, { path });
+ return Object.is(value, -0) ? 0 : value;
+ }
+ if (typeof value !== 'object' || value === undefined || typeof value === 'bigint' || typeof value === 'function' || typeof value === 'symbol') {
+ throw validation(`Non-JSON value at ${path}`, { path, type: typeof value });
+ }
+ if (ancestors.has(value)) throw validation(`Cyclic data at ${path}`, { path });
+ const prototype = Object.getPrototypeOf(value);
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw validation(`Non-plain object at ${path}`, { path });
+ if (typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function'
+ && Reflect.ownKeys(value).some((key) => typeof key === 'symbol')) {
+ throw validation(`Symbol-keyed property at ${path}`, { path });
+ }
+ ancestors.add(value);
+ try {
+ if (Array.isArray(value)) {
+ return value.map((item, index) => {
+ if (!Object.prototype.hasOwnProperty.call(value, index)) throw validation(`Sparse array entry at ${path}[${index}]`, { path });
+ return canonicalizeJson(item, `${path}[${index}]`, ancestors);
+ });
+ }
+ const result = {};
+ for (const key of Object.keys(value).sort()) {
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
+ if (!descriptor || descriptor.get || descriptor.set) throw validation(`Accessor property at ${path}.${key}`, { path });
+ result[key] = canonicalizeJson(descriptor.value, `${path}.${key}`, ancestors);
+ }
+ return result;
+ } finally { ancestors.delete(value); }
+ }
+ function stableStringifyCanonical(value) {
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
+ if (Array.isArray(value)) return `[${value.map(stableStringifyCanonical).join(',')}]`;
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyCanonical(value[key])}`).join(',')}}`;
+ }
+ function stableStringify(value) { return stableStringifyCanonical(canonicalizeJson(value)); }
+ function checksum(value) {
+ const input = stableStringify(value);
+ let hash = 2166136261;
+ for (let index = 0; index < input.length; index += 1) { hash ^= input.charCodeAt(index); hash = Math.imul(hash, 16777619); }
+ return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`;
+ }
+ function legacyTimestamp(value) {
+ if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return -Infinity;
+ if (Number.isFinite(Number(value))) return Number(value);
+ const parsed = Date.parse(value == null ? '' : String(value));
+ return Number.isFinite(parsed) ? parsed : -Infinity;
+ }
+ function parseLegacyCandidate(value, outerTimestamp) {
+ let parsed = value;
+ let timestamp = legacyTimestamp(outerTimestamp);
+ const hasOuterTimestamp = timestamp !== -Infinity;
+ for (let depth = 0; depth < 3; depth += 1) {
+ if (typeof parsed === 'string') {
+ try { parsed = JSON.parse(parsed); } catch (_) { if (depth === 0) return null; break; }
+ } else if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, 'data')
+ && (Object.prototype.hasOwnProperty.call(parsed, 'version') || Object.prototype.hasOwnProperty.call(parsed, 'compressed'))) {
+ const innerTimestamp = legacyTimestamp(parsed.timestamp);
+ if (!hasOuterTimestamp && innerTimestamp > timestamp) timestamp = innerTimestamp;
+ parsed = parsed.data;
+ } else break;
+ }
+ return { value: clone(parsed), timestamp };
+ }
+ function parseLegacyValue(value) {
+ const candidate = parseLegacyCandidate(value);
+ return candidate ? candidate.value : clone(value);
+ }
+ async function readLegacyValues(indexedDBApi = global.indexedDB, storage = global.localStorage, sessionStorageApi = global.sessionStorage) {
+ const values = {};
+ const candidates = {};
+ let readComplete = true;
+ const consider = (alias, rawValue, timestamp, sourceRank) => {
+ const candidate = parseLegacyCandidate(rawValue, timestamp);
+ if (!candidate) return;
+ const previous = candidates[alias];
+ if (!previous || candidate.timestamp > previous.timestamp
+ || (candidate.timestamp === previous.timestamp && sourceRank < previous.sourceRank)) {
+ candidates[alias] = Object.assign(candidate, { sourceRank });
+ }
+ };
+ if (indexedDBApi && typeof indexedDBApi.open === 'function') {
+ await new Promise((resolve) => {
+ let request;
+ let createdEmptyDatabase = false;
+ try { request = indexedDBApi.open(LEGACY_DATABASE_NAME); } catch (_) { readComplete = false; resolve(); return; }
+ request.onerror = () => { if (!createdEmptyDatabase) readComplete = false; resolve(); };
+ request.onupgradeneeded = () => {
+ createdEmptyDatabase = true;
+ try { request.transaction.abort(); } catch (_) {}
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(LEGACY_STORE_NAME)) { db.close(); resolve(); return; }
+ const tx = db.transaction(LEGACY_STORE_NAME, 'readonly');
+ const keys = tx.objectStore(LEGACY_STORE_NAME).getAllKeys();
+ const rows = tx.objectStore(LEGACY_STORE_NAME).getAll();
+ tx.oncomplete = () => {
+ (keys.result || []).forEach((key, index) => {
+ const row = (rows.result || [])[index];
+ // v1's keyValueStore persisted { key, value, timestamp } rows.
+ const validRow = row && typeof row === 'object'
+ && Object.prototype.hasOwnProperty.call(row, 'key')
+ && String(row.key) === String(key)
+ && Object.prototype.hasOwnProperty.call(row, 'value');
+ if (!validRow) {
+ readComplete = false;
+ return;
+ }
+ consider(String(key).replace(/^exam_system_/, ''), row.value, row.timestamp, 0);
+ });
+ db.close(); resolve();
+ };
+ tx.onerror = tx.onabort = () => { readComplete = false; db.close(); resolve(); };
+ };
+ });
+ }
+ for (const [sourceRank, fallbackStorage] of [storage, sessionStorageApi].entries()) {
+ if (!fallbackStorage || typeof fallbackStorage.key !== 'function') continue;
+ for (let index = 0; index < Number(fallbackStorage.length || 0); index += 1) {
+ const key = fallbackStorage.key(index);
+ if (!key) continue;
+ const alias = key.startsWith('exam_system_')
+ ? key.slice('exam_system_'.length)
+ : (LEGACY_UNPREFIXED_WEB_KEYS.includes(key) ? key : null);
+ if (!alias) continue;
+ try { consider(alias, fallbackStorage.getItem(key), null, sourceRank + 1); } catch (_) { /* inaccessible fallback */ }
+ }
+ }
+ for (const [alias, candidate] of Object.entries(candidates)) values[alias] = candidate.value;
+ Object.defineProperty(values, '__legacyReadComplete', {
+ value: readComplete,
+ enumerable: false,
+ configurable: false,
+ writable: false
+ });
+ return values;
+ }
+ async function readLegacyExternalBackup(indexedDBApi = global.indexedDB) {
+ if (!indexedDBApi || typeof indexedDBApi.open !== 'function') return null;
+ const directoryHandle = await new Promise((resolve) => {
+ let request;
+ let settled = false;
+ const finish = (value) => {
+ if (settled) return;
+ settled = true;
+ resolve(value || null);
+ };
+ try { request = indexedDBApi.open(LEGACY_EXTERNAL_DATABASE_NAME); } catch (_) { finish(null); return; }
+ request.onerror = () => finish(null);
+ request.onupgradeneeded = () => {
+ try { request.transaction.abort(); } catch (_) {}
+ finish(null);
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(LEGACY_EXTERNAL_STORE_NAME)) {
+ db.close(); finish(null); return;
+ }
+ const get = db.transaction(LEGACY_EXTERNAL_STORE_NAME, 'readonly')
+ .objectStore(LEGACY_EXTERNAL_STORE_NAME).get(LEGACY_EXTERNAL_HANDLE_KEY);
+ get.onerror = () => { db.close(); finish(null); };
+ get.onsuccess = () => { db.close(); finish(get.result); };
+ };
+ });
+ if (!directoryHandle || typeof directoryHandle.queryPermission !== 'function') return null;
+ if (await directoryHandle.queryPermission({ mode: 'read' }) !== 'granted') return null;
+ const fileHandle = await directoryHandle.getFileHandle(LEGACY_EXTERNAL_FILENAME, { create: false });
+ const parsed = JSON.parse(await (await fileHandle.getFile()).text());
+ const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.data !== undefined
+ ? parsed.data : parsed;
+ return payload && typeof payload === 'object' && !Array.isArray(payload) ? clone(payload) : null;
+ }
+ function lookupEntry(logicalKey) {
+ if (!catalog.has(logicalKey)) throw validation(`Unknown AppData logical key: ${logicalKey}`, { logicalKey });
+ return catalog.get(logicalKey);
+ }
+ function storeFor(logicalKey) {
+ const entry = lookupEntry(logicalKey);
+ if (entry.classification === 'session') throw validation(`${logicalKey} is not durable kernel data`, { logicalKey });
+ return entry.classification === 'system' ? SYSTEM_STORE : DOCUMENT_STORE;
+ }
+ function makeEnvelope(entry, data, options = {}) {
+ const state = options.state === 'cleared' ? 'cleared' : 'present';
+ if (options.state !== undefined && state !== options.state) throw validation(`Invalid envelope state for ${entry.logicalKey}`);
+ let normalized = null;
+ if (state === 'present') {
+ try { normalized = options.normalized ? data : entry.normalize(canonicalizeJson(data, `$.${entry.logicalKey}`)); } catch (error) {
+ throw validation(`Unable to normalize ${entry.logicalKey}`, { cause: error && error.message });
+ }
+ normalized = canonicalizeJson(normalized, `$.${entry.logicalKey}`);
+ if (!entry.validate(normalized)) throw validation(`Invalid data for ${entry.logicalKey}`, { logicalKey: entry.logicalKey });
+ }
+ const revision = options.revision === undefined ? 1 : Number(options.revision);
+ if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid revision for ${entry.logicalKey}`);
+ const payload = { schemaVersion: entry.schemaVersion, revision, operationId: String(options.operationId || randomId('op')),
+ updatedAt: options.updatedAt || nowIso(), state, data: normalized };
+ payload.checksum = checksum(payload.data);
+ return Object.freeze(payload);
+ }
+ function validateEnvelope(entry, envelope) {
+ try {
+ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)
+ || Number(envelope.schemaVersion) !== Number(entry.schemaVersion)
+ || !Number.isInteger(Number(envelope.revision)) || Number(envelope.revision) < 1
+ || typeof envelope.operationId !== 'string' || !envelope.operationId
+ || typeof envelope.updatedAt !== 'string' || !envelope.updatedAt
+ || (envelope.state !== 'present' && envelope.state !== 'cleared')) return false;
+ const data = canonicalizeJson(envelope.data, `$.${entry.logicalKey}`);
+ return (envelope.state !== 'cleared' || data === null)
+ && (envelope.state !== 'present' || entry.validate(data)) && envelope.checksum === checksum(data);
+ } catch (_) { return false; }
+ }
+ function operationId(value) {
+ if (value === undefined || value === null || value === '') return randomId('mutation');
+ if (typeof value !== 'string' || !value.trim()) throw validation('operationId must be a non-empty string');
+ return value;
+ }
+ function expectedRevision(value, label) {
+ if (value === undefined || value === null) return null;
+ const revision = Number(value);
+ if (!Number.isInteger(revision) || revision < 0) throw validation(`Invalid expectedRevision for ${label}`);
+ return revision;
+ }
+ function compactJournal(journal) {
+ const ranked = Object.entries(journal).sort((left, right) => Number(right[1].sequence) - Number(left[1].sequence));
+ for (let index = OPERATION_JOURNAL_WINDOW; index < ranked.length; index += 1) delete journal[ranked[index][0]];
+ }
+ function readJournal(row) {
+ const envelope = row && row.envelope;
+ return envelope && envelope.state === 'present' && envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data)
+ ? clone(envelope.data) : {};
+ }
+ function journalResult(journal, spec) {
+ const existing = journal[spec.operationId];
+ if (!existing) return null;
+ if (existing.fingerprint !== spec.fingerprint || !existing.receipt) {
+ throw new AppDataError('CONFLICT', `operationId is already bound to another request: ${spec.operationId}`, { operationId: spec.operationId });
+ }
+ return clone(existing.receipt);
+ }
+ function writeJournal(journal, spec, receipt) {
+ const sequence = Object.values(journal).reduce((maximum, item) => Math.max(maximum, Number(item.sequence) || 0), 0) + 1;
+ journal[spec.operationId] = { fingerprint: spec.fingerprint, receipt: clone(receipt), sequence, committedAt: nowIso() };
+ compactJournal(journal);
+ return journal;
+ }
+ function putJournal(tx, currentRow, journal, spec, receipt) {
+ const current = currentRow && currentRow.envelope;
+ const envelope = makeEnvelope(lookupEntry('system.operationJournal'), writeJournal(journal, spec, receipt), {
+ revision: current ? Number(current.revision) + 1 : 1,
+ operationId: spec.operationId,
+ normalized: true
+ });
+ tx.objectStore(SYSTEM_STORE).put({ logicalKey: 'system.operationJournal', envelope: canonicalizeJson(envelope) });
+ }
+
+ class IndexedDBDriver {
+ constructor(indexedDBApi, options) {
+ this.indexedDB = indexedDBApi;
+ this.db = null;
+ this.mutationTimeoutMs = options.mutationTimeoutMs;
+ this.requestTimeoutMs = options.requestTimeoutMs;
+ }
+ async initialize() {
+ if (!this.indexedDB || typeof this.indexedDB.open !== 'function') throw new Error('IndexedDB unavailable');
+ this.db = await new Promise((resolve, reject) => {
+ const request = this.indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
+ let abandoned = false;
+ let settle;
+ request.onsuccess = () => { if (abandoned || !settle) { try { request.result.close(); } catch (_) {} } else settle.resolve(request.result); };
+ request.onupgradeneeded = (event) => {
+ const db = request.result;
+ if (event.oldVersion < 2) {
+ for (const name of ['authoritative', 'derived']) {
+ if (db.objectStoreNames.contains(name)) db.deleteObjectStore(name);
+ }
+ }
+ for (const name of STORE_NAMES) {
+ if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, { keyPath: name === DOCUMENT_STORE || name === SYSTEM_STORE ? 'logicalKey' : 'recordId' });
+ }
+ };
+ settle = withDeadline({ abort() { abandoned = true; } }, this.requestTimeoutMs, 'open', resolve, reject);
+ request.onerror = () => settle.reject(request.error || new Error('Unable to open IndexedDB'));
+ request.onblocked = () => {
+ abandoned = true;
+ settle.reject(new Error('IndexedDB upgrade blocked'));
+ };
+ });
+ this.db.onversionchange = () => this.close();
+ return this;
+ }
+ close() { const db = this.db; this.db = null; try { if (db) db.close(); } catch (_) {} }
+ _open() { if (!this.db) throw new Error('IndexedDB connection closed'); }
+ _transaction(stores, mode, description, work, mutation = false) {
+ this._open();
+ return new Promise((resolve, reject) => {
+ let failure = null;
+ let value;
+ let tx;
+ try { tx = this.db.transaction(stores, mode); } catch (error) { reject(error); return; }
+ const settle = withDeadline(tx, mutation ? this.mutationTimeoutMs : this.requestTimeoutMs, description, resolve, reject);
+ tx.oncomplete = () => settle.resolve(clone(value));
+ tx.onerror = (event) => {
+ const requestError = event && event.target && event.target.error;
+ const transactionError = tx.error;
+ if (requestError || transactionError) {
+ failure = failure || requestError || transactionError;
+ }
+ };
+ tx.onabort = () => settle.reject(failure || tx.error || new Error(`IndexedDB ${description} aborted`));
+ const fail = (error) => { failure = failure || error; try { tx.abort(); } catch (_) {} };
+ try { work(tx, (result) => { value = result; }, fail); } catch (error) { fail(error); }
+ });
+ }
+ readEnvelope(logicalKey) {
+ const store = storeFor(logicalKey);
+ return this._transaction([store], 'readonly', `read ${logicalKey}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).get(logicalKey);
+ request.onsuccess = () => done(request.result ? request.result.envelope : null);
+ request.onerror = () => fail(request.error || new Error(`Read failed: ${logicalKey}`));
+ });
+ }
+ readEntity(store, recordId) {
+ return this._transaction([store], 'readonly', `read ${store}/${recordId}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).get(recordId);
+ request.onsuccess = () => done(request.result || null);
+ request.onerror = () => fail(request.error || new Error('Entity read failed'));
+ });
+ }
+ readPracticeSnapshot(recordIds = null, options = {}) {
+ const stores = Array.isArray(options.stores) && options.stores.length
+ ? Array.from(new Set(options.stores.map((store) => entityStore(store))))
+ : ENTITY_STORES.slice();
+ const requested = recordIds === null || recordIds === undefined
+ ? null
+ : new Set((Array.isArray(recordIds) ? recordIds : [recordIds])
+ .map((value) => String(value || ''))
+ .filter(Boolean));
+ return this._transaction(stores, 'readonly', 'read practice snapshot', (tx, done, fail) => {
+ const result = Object.fromEntries(stores.map((store) => [store, []]));
+ let remaining = stores.length;
+ const finishStore = (store, rows) => {
+ result[store] = (rows || []).filter((row) => !requested || requested.has(String(row && row.recordId || '')));
+ remaining -= 1;
+ if (!remaining) done(result);
+ };
+ for (const store of stores) {
+ const objectStore = tx.objectStore(store);
+ const request = requested && requested.size === 1
+ ? objectStore.get(Array.from(requested)[0])
+ : objectStore.getAll();
+ request.onsuccess = () => {
+ const rows = requested && requested.size === 1
+ ? (request.result ? [request.result] : [])
+ : request.result;
+ finishStore(store, rows);
+ };
+ request.onerror = () => fail(request.error || new Error(`Practice snapshot read failed: ${store}`));
+ }
+ });
+ }
+ listEntities(store) {
+ return this._transaction([store], 'readonly', `list ${store}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).getAll();
+ request.onsuccess = () => done(request.result || []);
+ request.onerror = () => fail(request.error || new Error('Entity list failed'));
+ });
+ }
+ atomic(spec) {
+ return this._transaction(spec.stores, 'readwrite', `mutation ${spec.operationId}`, (tx, done, fail) => {
+ const journalRequest = tx.objectStore(SYSTEM_STORE).get('system.operationJournal');
+ journalRequest.onerror = () => fail(journalRequest.error || new Error('Journal read failed'));
+ journalRequest.onsuccess = () => {
+ try { spec.apply(tx, journalRequest.result || null, readJournal(journalRequest.result), done, fail); } catch (error) { fail(error); }
+ };
+ }, true);
+ }
+ exportSnapshot(envelopeKeys) {
+ return this._transaction(STORE_NAMES, 'readonly', 'snapshot export', (tx, done, fail) => {
+ const result = { envelopes: {}, entities: {} };
+ let remaining = STORE_NAMES.length;
+ for (const store of STORE_NAMES) {
+ const request = tx.objectStore(store).getAll();
+ request.onerror = () => fail(request.error || new Error(`Snapshot read failed: ${store}`));
+ request.onsuccess = () => {
+ if (store === DOCUMENT_STORE || store === SYSTEM_STORE) {
+ for (const row of request.result || []) if (envelopeKeys(row.logicalKey)) result.envelopes[row.logicalKey] = row.envelope;
+ } else result.entities[store] = request.result || [];
+ remaining -= 1;
+ if (!remaining) done(result);
+ };
+ }
+ });
+ }
+ }
+
+ function entityStore(store) {
+ const value = String(store || '');
+ if (!ENTITY_STORES.includes(value)) throw validation(`Unknown entity store: ${value}`, { store: value });
+ return value;
+ }
+ function validateEntityRow(store, row) {
+ if (!row || typeof row !== 'object' || Array.isArray(row)
+ || typeof row.recordId !== 'string' || !row.recordId
+ || !Number.isInteger(Number(row.revision)) || Number(row.revision) < 1
+ || typeof row.operationId !== 'string' || !row.operationId
+ || typeof row.updatedAt !== 'string' || !row.updatedAt) {
+ throw corruption(`Invalid entity row: ${store}`, { store, recordId: row && row.recordId || null });
+ }
+ const data = canonicalizeJson(row.data, `$.${store}.${row.recordId}`);
+ if (row.checksum !== checksum(data)) {
+ throw corruption(`Entity checksum mismatch: ${store}/${row.recordId}`, { store, recordId: row.recordId });
+ }
+ return row;
+ }
+ function normalizeEntityOperation(operation, index) {
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw validation(`Invalid entity operation at index ${index}`);
+ const type = String(operation.type || '');
+ const store = entityStore(operation.store);
+ if (!['upsert', 'delete', 'clear'].includes(type)) throw validation(`Invalid entity operation type: ${type}`);
+ const recordId = type === 'clear' ? null : String(operation.recordId || '');
+ if (type !== 'clear' && !recordId.trim()) throw validation(`Entity operation ${type} requires recordId`);
+ const data = type === 'upsert' ? canonicalizeJson(operation.data, `$.operations[${index}].data`) : null;
+ return { type, store, recordId, data, expectedRevision: expectedRevision(operation.expectedRevision, `${store}/${recordId || '*'}`) };
+ }
+ function receiptFor(operationIdValue, revisions, warnings, pending) {
+ const receipt = { committed: true, revisions, operationId: operationIdValue,
+ derived: { status: pending.length ? 'pending' : 'ready', pending: pending.slice() }, warnings: warnings.slice() };
+ const keys = Object.keys(revisions); if (keys.length === 1) receipt.revision = revisions[keys[0]];
+ return receipt;
+ }
+
+ class DataKernel {
+ constructor(options = {}) {
+ this.driver = null;
+ this.backend = null;
+ this.state = 'created';
+ this.failure = null;
+ this.indexedDB = Object.prototype.hasOwnProperty.call(options, 'indexedDB') ? options.indexedDB : global.indexedDB;
+ this.indexedDBMutationTimeoutMs = normalizeTimeoutMs(options.indexedDBMutationTimeoutMs, DEFAULT_IDB_MUTATION_TIMEOUT_MS);
+ this.indexedDBRequestTimeoutMs = normalizeTimeoutMs(options.indexedDBRequestTimeoutMs, DEFAULT_IDB_REQUEST_TIMEOUT_MS);
+ this.committedListeners = new Set();
+ this.commitChannel = null;
+ this.instanceId = randomId('kernel');
+ this.ready = null;
+ }
+ _initializeCommitChannel() {
+ if (this.commitChannel || typeof global.BroadcastChannel !== 'function') return;
+ try {
+ const channel = new global.BroadcastChannel(COMMIT_CHANNEL_NAME);
+ channel.onmessage = (message) => {
+ const data = message && message.data;
+ if (!data || data.sourceInstanceId === this.instanceId
+ || typeof data.operationId !== 'string' || !Array.isArray(data.targets)) return;
+ this._dispatchCommitted({
+ operationId: data.operationId,
+ targets: clone(data.targets),
+ receipt: data.receipt ? clone(data.receipt) : null,
+ remote: true
+ });
+ };
+ this.commitChannel = channel;
+ } catch (_) {
+ this.commitChannel = null;
+ }
+ }
+ _closeCommitChannel() {
+ const channel = this.commitChannel;
+ this.commitChannel = null;
+ try { if (channel) channel.close(); } catch (_) {}
+ }
+ initialize() {
+ if (this.ready) return this.ready;
+ this.state = 'initializing';
+ this.ready = new IndexedDBDriver(this.indexedDB, {
+ mutationTimeoutMs: this.indexedDBMutationTimeoutMs, requestTimeoutMs: this.indexedDBRequestTimeoutMs
+ }).initialize().then((driver) => {
+ this.driver = driver; this.backend = 'indexeddb-v2'; this.state = 'ready'; this._initializeCommitChannel(); return this;
+ }).catch((error) => { this.state = 'failed'; this.failure = error; this.driver = null; this.backend = null;
+ throw error instanceof AppDataError ? error : new AppDataError('BACKEND_UNAVAILABLE', 'IndexedDB is required for AppData v2', { cause: error && error.message }); });
+ return this.ready;
+ }
+ close() {
+ if (this.driver) this.driver.close();
+ this.driver = null; this.backend = null;
+ this._closeCommitChannel();
+ if (this.state !== 'failed') this.state = 'closed';
+ }
+ _assertReady() {
+ if (this.state === 'failed') throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 backend failed', { cause: this.failure && this.failure.message });
+ if (this.state !== 'ready' || !this.driver) throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 is not initialized');
+ }
+ _latch(error) {
+ const quotaName = String(error && error.name || '').toUpperCase();
+ const quotaCode = String(error && error.code || '').toUpperCase();
+ if (error && (
+ quotaName === 'QUOTAEXCEEDEDERROR'
+ || quotaName === 'NS_ERROR_DOM_QUOTA_REACHED'
+ || quotaCode === 'NS_ERROR_DOM_QUOTA_REACHED'
+ || quotaCode === 'QUOTAEXCEEDEDERROR'
+ || quotaCode === '22'
+ || quotaCode === '1014'
+ )) return new AppDataError('QUOTA_EXCEEDED', 'IndexedDB write failed: storage quota exceeded', { cause: error.message });
+ this.state = 'failed'; this.failure = error; if (this.driver) this.driver.close(); this.driver = null; this.backend = null; this._closeCommitChannel();
+ return new AppDataError('BACKEND_UNAVAILABLE', 'Active IndexedDB backend failed; reload is required', { cause: error && error.message });
+ }
+ onCommitted(listener) {
+ if (typeof listener !== 'function') throw validation('Committed listener must be a function');
+ this.committedListeners.add(listener); return () => this.committedListeners.delete(listener);
+ }
+ _dispatchCommitted(event) {
+ if (!event || !this.committedListeners.size) return;
+ const schedule = typeof global.queueMicrotask === 'function' ? global.queueMicrotask.bind(global) : (callback) => Promise.resolve().then(callback);
+ schedule(() => Array.from(this.committedListeners).forEach((listener) => { try { Promise.resolve(listener(clone(event))).catch(() => {}); } catch (_) {} }));
+ }
+ _notifyCommitted(targets, receipt) {
+ if (!targets.length) return;
+ const event = { operationId: receipt.operationId, targets: clone(targets), receipt: clone(receipt), remote: false };
+ this._dispatchCommitted(event);
+ if (this.commitChannel) {
+ try {
+ this.commitChannel.postMessage({
+ sourceInstanceId: this.instanceId,
+ operationId: event.operationId,
+ targets: event.targets,
+ receipt: event.receipt
+ });
+ } catch (_) { /* cross-realm notification is best effort */ }
+ }
+ }
+ async getEnvelope(logicalKey) {
+ this._assertReady(); const entry = lookupEntry(logicalKey);
+ try { const envelope = await this.driver.readEnvelope(logicalKey); if (envelope && !validateEnvelope(entry, envelope)) throw corruption(`Invalid envelope: ${logicalKey}`, { logicalKey }); return envelope; }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async read(logicalKey, options = {}) {
+ const entry = lookupEntry(logicalKey); const envelope = await this.getEnvelope(logicalKey);
+ const data = !envelope || envelope.state === 'cleared' ? entry.defaultValue() : envelope.data;
+ return options.withMeta ? { data: clone(data), envelope: envelope ? clone(envelope) : null } : clone(data);
+ }
+ _documentSpec(changes, options) {
+ if (!Array.isArray(changes) || (!changes.length && !options.allowNoop && !options.noop)) throw validation('DataKernel.mutate requires changes');
+ if (options.commitGuard !== undefined && typeof options.commitGuard !== 'function') throw validation('commitGuard must be a synchronous function');
+ const opId = operationId(options.operationId); const seen = new Set();
+ const prepared = changes.map((change, index) => {
+ if (!change || typeof change !== 'object' || Array.isArray(change)) throw validation(`Invalid mutation change at index ${index}`);
+ const logicalKey = String(change.logicalKey || ''); const entry = lookupEntry(logicalKey);
+ if (logicalKey === 'system.operationJournal') throw validation('system.operationJournal is managed by DataKernel');
+ if (seen.has(logicalKey)) throw validation(`Duplicate mutation key: ${logicalKey}`); seen.add(logicalKey);
+ const state = change.state === 'cleared' ? 'cleared' : 'present';
+ if (change.state !== undefined && state !== change.state) throw validation(`Invalid mutation state for ${logicalKey}`);
+ if (state === 'cleared' && entry.classification === 'system') throw validation(`${logicalKey} cannot be cleared`);
+ let data = null;
+ if (state === 'present') { try { data = canonicalizeJson(entry.normalize(canonicalizeJson(change.data)), '$.data'); } catch (error) { throw validation(`Unable to normalize ${logicalKey}`, { cause: error && error.message }); } if (!entry.validate(data)) throw validation(`Invalid data for ${logicalKey}`); }
+ return { logicalKey, entry, state, data, expectedRevision: expectedRevision(change.expectedRevision, logicalKey) };
+ });
+ const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings');
+ if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings');
+ const fingerprint = options.intent === undefined
+ ? checksum({ changes: prepared.map((item) => ({ logicalKey: item.logicalKey, state: item.state, data: item.data, expectedRevision: item.expectedRevision })), warnings })
+ : checksum({ mutationType: 'documents', intent: canonicalizeJson(options.intent, '$.intent'), warnings });
+ return {
+ operationId: opId,
+ changes: prepared,
+ pending: [],
+ warnings,
+ fingerprint,
+ commitGuard: typeof options.commitGuard === 'function' ? options.commitGuard : null,
+ stores: Array.from(new Set([SYSTEM_STORE].concat(prepared.map((item) => storeFor(item.logicalKey)))))
+ };
+ }
+ async mutate(changes, options = {}) {
+ this._assertReady(); const spec = this._documentSpec(changes, options);
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const assertCommitGuard = () => {
+ if (!spec.commitGuard) return;
+ let allowed = false;
+ try {
+ allowed = spec.commitGuard() === true;
+ } catch (error) {
+ throw new AppDataError('PRECONDITION_FAILED', 'Mutation commit guard threw', {
+ operationId: spec.operationId,
+ cause: error && error.message
+ });
+ }
+ if (!allowed) {
+ throw new AppDataError('PRECONDITION_FAILED', 'Mutation commit guard rejected the write', {
+ operationId: spec.operationId
+ });
+ }
+ };
+ const replay = journalResult(journal, spec);
+ if (replay) {
+ try { assertCommitGuard(); done(replay); } catch (error) { fail(error); }
+ return;
+ }
+ const reads = spec.changes.map((change) => ({ change, request: tx.objectStore(storeFor(change.logicalKey)).get(change.logicalKey) }));
+ let remaining = reads.length;
+ const finish = () => {
+ assertCommitGuard();
+ const revisions = {};
+ for (const item of reads) {
+ const current = item.request.result ? item.request.result.envelope : null;
+ if (current && !validateEnvelope(item.change.entry, current)) throw corruption(`Invalid stored envelope: ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey });
+ const revision = current ? Number(current.revision) : 0;
+ if (item.change.expectedRevision !== null && item.change.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey, expectedRevision: item.change.expectedRevision, actualRevision: revision });
+ const envelope = makeEnvelope(item.change.entry, item.change.data, { state: item.change.state, revision: revision + 1, operationId: spec.operationId, normalized: true });
+ tx.objectStore(storeFor(item.change.logicalKey)).put({ logicalKey: item.change.logicalKey, envelope: canonicalizeJson(envelope) }); revisions[item.change.logicalKey] = envelope.revision;
+ }
+ const receipt = receiptFor(spec.operationId, revisions, spec.warnings, []);
+ putJournal(tx, journalRow, journal, spec, receipt);
+ done(receipt);
+ };
+ if (!remaining) { finish(); return; }
+ for (const item of reads) { item.request.onerror = () => fail(item.request.error || new Error('Mutation read failed')); item.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; }
+ } }));
+ const targets = spec.changes.filter((change) => change.entry.owner !== 'backups' && (change.entry.classification === 'authoritative' || change.entry.classification === 'preference')).map((change) => ({ logicalKey: change.logicalKey, state: change.state, owner: change.entry.owner, classification: change.entry.classification }));
+ this._notifyCommitted(targets, receipt); return receipt;
+ } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD' || error.code === 'PRECONDITION_FAILED')) throw error; throw this._latch(error); }
+ }
+ async journalNoop(options = {}) { return this.mutate([], Object.assign({}, options, { allowNoop: true })); }
+ async readEntity(store, recordId, options = {}) {
+ this._assertReady(); store = entityStore(store); const id = String(recordId || ''); if (!id) throw validation('readEntity requires recordId');
+ try {
+ const row = await this.driver.readEntity(store, id);
+ if (!row) return null;
+ validateEntityRow(store, row);
+ return options.withMeta ? clone(row) : clone(row.data);
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async readPracticeSnapshot(recordIds = null, options = {}) {
+ this._assertReady();
+ const ids = recordIds === null || recordIds === undefined
+ ? null
+ : (Array.isArray(recordIds) ? recordIds : [recordIds])
+ .map((value) => String(value || ''))
+ .filter(Boolean);
+ try {
+ const snapshot = await this.driver.readPracticeSnapshot(ids, options);
+ const result = {};
+ const stores = Array.isArray(options.stores) && options.stores.length
+ ? Array.from(new Set(options.stores.map((store) => entityStore(store))))
+ : ENTITY_STORES;
+ for (const store of stores) {
+ const validRows = (snapshot && Array.isArray(snapshot[store]) ? snapshot[store] : [])
+ .filter((row) => {
+ try { validateEntityRow(store, row); return true; }
+ catch (error) {
+ if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false;
+ throw error;
+ }
+ });
+ result[store] = options.withMeta
+ ? clone(validRows)
+ : validRows.map((row) => clone(row.data));
+ }
+ return result;
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async listEntities(store, options = {}) {
+ this._assertReady(); store = entityStore(store);
+ if (store !== 'practiceSummaries') throw validation('Only practiceSummaries supports listEntities; load details and annotations by recordId');
+ try {
+ const rows = await this.driver.listEntities(store);
+ const validRows = rows.filter((row) => {
+ try { validateEntityRow(store, row); return true; }
+ catch (error) {
+ if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false;
+ throw error;
+ }
+ });
+ return options.withMeta ? clone(validRows) : validRows.map((row) => clone(row.data));
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async mutateEntities(operations, options = {}) {
+ this._assertReady(); if (!Array.isArray(operations) || !operations.length) throw validation('mutateEntities requires operations');
+ const opId = operationId(options.operationId); const items = operations.map(normalizeEntityOperation); const seen = new Set();
+ for (const item of items) { const key = `${item.store}/${item.recordId || '*'}`; if (seen.has(key)) throw validation(`Duplicate entity operation: ${key}`); seen.add(key); }
+ for (const store of ENTITY_STORES) {
+ const scoped = items.filter((item) => item.store === store);
+ if (scoped.some((item) => item.type === 'clear') && scoped.length > 1) {
+ throw validation(`Entity clear cannot be combined with other operations for ${store}`);
+ }
+ }
+ const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings');
+ if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings');
+ const fingerprint = options.intent === undefined
+ ? checksum({ operations: items, warnings })
+ : checksum({ mutationType: 'entities', intent: canonicalizeJson(options.intent, '$.intent'), warnings });
+ const spec = { operationId: opId, warnings, pending: [], fingerprint, stores: Array.from(new Set([SYSTEM_STORE].concat(items.map((item) => item.store)))) };
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const reads = items.filter((item) => item.type !== 'clear').map((item) => ({ item, request: tx.objectStore(item.store).get(item.recordId) })); let remaining = reads.length;
+ const finish = () => { const revisions = {};
+ for (const read of reads) { const current = read.request.result || null; const revision = current ? Number(current.revision) : 0;
+ if (read.item.expectedRevision !== null && read.item.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${read.item.store}/${read.item.recordId}`);
+ const key = `${read.item.store}/${read.item.recordId}`; if (read.item.type === 'delete') { tx.objectStore(read.item.store).delete(read.item.recordId); revisions[key] = revision + 1; } else { const next = { recordId: read.item.recordId, revision: revision + 1, operationId: spec.operationId, updatedAt: nowIso(), data: read.item.data }; next.checksum = checksum(next.data); tx.objectStore(read.item.store).put(next); revisions[key] = next.revision; }
+ }
+ for (const item of items.filter((item) => item.type === 'clear')) { tx.objectStore(item.store).clear(); revisions[`${item.store}/*`] = 0; }
+ const receipt = receiptFor(spec.operationId, revisions, warnings, []); putJournal(tx, journalRow, journal, spec, receipt); done(receipt); };
+ if (!remaining) { try { finish(); } catch (error) { fail(error); } return; }
+ for (const read of reads) { read.request.onerror = () => fail(read.request.error || new Error('Entity mutation read failed')); read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; }
+ } }));
+ this._notifyCommitted(items.map((item) => ({ store: item.store, recordId: item.recordId, type: item.type })), receipt); return receipt;
+ } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
+ }
+ async exportSnapshot(options = {}) {
+ this._assertReady();
+ try {
+ const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys.map((key) => String(key))) : null;
+ const shouldExport = (logicalKey) => {
+ if (!catalog.has(logicalKey)) return false;
+ const entry = lookupEntry(logicalKey);
+ if (selected && !selected.has(logicalKey)) return false;
+ if (entry.export === true) return true;
+ return options.includeSystem === true && entry.classification === 'system';
+ };
+ const data = await this.driver.exportSnapshot(shouldExport);
+ // Full/partial snapshots must be dense for their declared catalog
+ // range. An absent physical row means the catalog default, not an
+ // instruction that future importers should guess about.
+ for (const entry of catalog.list()) {
+ if (!shouldExport(entry.logicalKey)
+ || Object.prototype.hasOwnProperty.call(data.envelopes, entry.logicalKey)) continue;
+ data.envelopes[entry.logicalKey] = makeEnvelope(entry, null, {
+ state: 'cleared',
+ operationId: 'snapshot-default'
+ });
+ }
+ if (Array.isArray(options.entityStores)) {
+ const selectedStores = new Set(options.entityStores.map(entityStore));
+ for (const store of ENTITY_STORES) if (!selectedStores.has(store)) delete data.entities[store];
+ }
+ const payload = { envelopes: data.envelopes, entities: data.entities };
+ return { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: selected ? 'partial' : 'full', createdAt: nowIso(), backend: this.backend, envelopes: data.envelopes, entities: data.entities, checksum: checksum(payload) };
+ } catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async installSnapshot(snapshot, options = {}) {
+ this._assertReady(); const source = snapshot && snapshot.envelopes ? snapshot : { envelopes: snapshot, entities: {} };
+ const envelopes = canonicalizeJson(source.envelopes, '$.envelopes'); const entities = canonicalizeJson(source.entities || {}, '$.entities');
+ if (!envelopes || typeof envelopes !== 'object' || Array.isArray(envelopes) || !entities || typeof entities !== 'object' || Array.isArray(entities)) throw validation('Snapshot is invalid');
+ if (source.checksum && source.checksum !== checksum({ envelopes, entities })) throw validation('Snapshot checksum mismatch');
+ const changes = [];
+ for (const [logicalKey, envelope] of Object.entries(envelopes)) {
+ const entry = lookupEntry(logicalKey);
+ if (entry.classification === 'system' || entry.classification === 'session' || entry.import === 'ignore') continue;
+ if (!validateEnvelope(entry, envelope)) throw validation(`Invalid snapshot envelope: ${logicalKey}`);
+ changes.push({ logicalKey, entry, envelope });
+ }
+ const entityRows = {};
+ for (const store of ENTITY_STORES) {
+ if (!Object.prototype.hasOwnProperty.call(entities, store)) continue;
+ const rows = entities[store];
+ if (!Array.isArray(rows)) throw validation(`Invalid snapshot entities: ${store}`);
+ const ids = new Set();
+ entityRows[store] = rows.map((row) => {
+ if (!row || typeof row !== 'object' || !String(row.recordId || '')) throw validation(`Invalid snapshot entity: ${store}`);
+ const recordId = String(row.recordId);
+ if (ids.has(recordId)) throw validation(`Duplicate snapshot entity: ${store}/${recordId}`);
+ ids.add(recordId);
+ const data = canonicalizeJson(row.data);
+ if (!row.checksum || row.checksum !== checksum(data)) throw validation(`Invalid snapshot entity checksum: ${store}/${recordId}`);
+ const revision = row.revision === undefined ? 1 : Number(row.revision);
+ if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid snapshot entity revision: ${store}/${recordId}`);
+ return { recordId, revision, operationId: String(row.operationId || options.operationId || 'snapshot'), updatedAt: String(row.updatedAt || nowIso()), data, checksum: checksum(data) };
+ });
+ }
+ if (!changes.length && !Object.keys(entityRows).length) throw validation('Snapshot contains no importable data');
+ const resetJournal = options.resetJournal === true;
+ const expectedRevisionToken = options.expectedRevisionToken && typeof options.expectedRevisionToken === 'object'
+ ? canonicalizeJson(options.expectedRevisionToken, '$.expectedRevisionToken')
+ : null;
+ const opId = operationId(options.operationId || randomId('restore')); const spec = { operationId: opId, warnings: [], pending: [], fingerprint: checksum({ envelopes: changes.map((item) => [item.logicalKey, item.envelope]), entities: entityRows, resetJournal, expectedRevisionToken }), stores: STORE_NAMES.slice() };
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const documentChecks = expectedRevisionToken && expectedRevisionToken.documents || {};
+ const entityChecks = expectedRevisionToken && expectedRevisionToken.entities || {};
+ const reads = Object.entries(documentChecks).map(([logicalKey, expected]) => ({
+ kind: 'document', logicalKey, expected, request: tx.objectStore(DOCUMENT_STORE).get(logicalKey)
+ })).concat(Object.entries(entityChecks).map(([store, expected]) => ({
+ kind: 'entities', store, expected, request: tx.objectStore(store).getAll()
+ })));
+ const finish = () => {
+ for (const read of reads) {
+ if (read.kind === 'document') {
+ const current = read.request.result ? read.request.result.envelope : null;
+ const actualRevision = current ? Number(current.revision) : 0;
+ const expectedRevision = Number(read.expected) || 0;
+ if (actualRevision !== expectedRevision) {
+ throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.logicalKey}`, { logicalKey: read.logicalKey, expectedRevision, actualRevision });
+ }
+ } else {
+ const actual = Object.fromEntries((read.request.result || []).map((row) => [String(row.recordId), Number(row.revision) || 0]));
+ const expected = read.expected && typeof read.expected === 'object' ? read.expected : {};
+ const ids = new Set(Object.keys(actual).concat(Object.keys(expected)));
+ for (const recordId of ids) {
+ const current = actual[recordId] || 0;
+ const wanted = Number(expected[recordId]) || 0;
+ if (current !== wanted) {
+ throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.store}/${recordId}`, { store: read.store, recordId });
+ }
+ }
+ }
+ }
+ const revisions = {};
+ for (const item of changes) { tx.objectStore(DOCUMENT_STORE).put({ logicalKey: item.logicalKey, envelope: makeEnvelope(item.entry, item.envelope.data, { state: item.envelope.state, revision: item.envelope.revision, operationId: spec.operationId, normalized: true }) }); revisions[item.logicalKey] = Number(item.envelope.revision); }
+ for (const [store, rows] of Object.entries(entityRows)) {
+ tx.objectStore(store).clear();
+ for (const row of rows) tx.objectStore(store).put(row);
+ }
+ const receipt = receiptFor(spec.operationId, revisions, [], []); putJournal(tx, journalRow, resetJournal ? {} : journal, spec, receipt); done(receipt);
+ };
+ if (!reads.length) { try { finish(); } catch (error) { fail(error); } return; }
+ let remaining = reads.length;
+ for (const read of reads) {
+ read.request.onerror = () => fail(read.request.error || new Error('Snapshot revalidation read failed'));
+ read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } };
+ }
+ } }));
+ const targets = changes
+ .filter((item) => item.entry.owner !== 'backups')
+ .map((item) => ({ logicalKey: item.logicalKey, state: item.envelope.state, owner: item.entry.owner, classification: item.entry.classification }))
+ .concat(Object.keys(entityRows).map((store) => ({ store, recordId: null, type: 'replace' })));
+ this._notifyCommitted(targets, receipt);
+ return receipt;
+ }
+ catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
+ }
+ status() { return Object.freeze({ state: this.state, backend: this.backend, failure: this.failure ? this.failure.message : null }); }
+ }
+
+ Object.defineProperty(global, '__AppDataV2Internals', { value: { catalog, DataKernel, AppDataError, makeEnvelope, validateEnvelope, checksum, stableStringify, canonicalizeJson, clone, randomId, nowIso, parseLegacyValue, readLegacyValues, readLegacyExternalBackup, constants: Object.freeze({ DATABASE_NAME, DATABASE_VERSION, DOCUMENT_STORE, SYSTEM_STORE, ENTITY_STORES, OPERATION_JOURNAL_WINDOW }) }, enumerable: false, configurable: true, writable: false });
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
+/* ===== js/data/v2/appData.js ===== */
+(function installAppData(global) {
+ 'use strict';
+
+ const internals = global.__AppDataV2Internals;
+ if (!internals || typeof internals.DataKernel !== 'function') {
+ throw new Error('AppData v2 requires DataKernel');
+ }
+ const {
+ DataKernel,
+ AppDataError,
+ catalog,
+ clone,
+ randomId,
+ nowIso,
+ checksum
+ } = internals;
+ const kernel = new DataKernel();
+ const importPlans = new Map();
+ const RECOVERY_KEYS = Object.freeze({
+ activeSession: 'recovery.activeSessions',
+ draft: 'recovery.drafts',
+ interrupted: 'recovery.interrupted',
+ rejectedCompletion: 'recovery.rejectedCompletions'
+ });
+ const PREFERENCE_FIELDS = Object.freeze({
+ theme: 'theme', browse: 'browse', timer: 'timer', suite: 'suite', candidateCode: 'candidateCode',
+ resourceBasePrefix: 'resourceBasePrefix', onboarding: 'onboarding', readingDisplay: 'readingDisplay',
+ threeBackground: 'threeBackground', themePortal: 'themePortal', practiceWidget: 'practiceWidget',
+ consent: 'consent', logConfig: 'logConfig'
+ });
+ const PRACTICE_ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']);
+
+ function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
+ function asArray(value) { return Array.isArray(value) ? value : []; }
+ function idOf(value, fields) {
+ for (const field of fields) {
+ if (value && value[field] !== undefined && value[field] !== null && value[field] !== '') return String(value[field]);
+ }
+ return '';
+ }
+
+ function importedLibraryId(value, options = {}) {
+ const id = value === null || value === undefined ? '' : String(value).trim();
+ if (!id && options.nullable) return null;
+ if (!id) throw new AppDataError('VALIDATION', 'Imported library configuration id is required');
+ if (/^exam_index(?:_|$)/.test(id)) {
+ throw new AppDataError('VALIDATION', 'Unsupported library configuration id');
+ }
+ return id;
+ }
+ function assertObject(value, message) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new AppDataError('VALIDATION', message);
+ }
+ function assertArray(value, message) {
+ if (!Array.isArray(value)) throw new AppDataError('VALIDATION', message);
+ }
+ function jsonValue(value, label = 'value') {
+ try {
+ const serialized = JSON.stringify(value, (_key, current) => {
+ if (typeof current === 'bigint') return String(current);
+ if (typeof current === 'number' && !Number.isFinite(current)) return null;
+ return current;
+ });
+ if (serialized === undefined) return null;
+ return JSON.parse(serialized);
+ } catch (error) {
+ throw new AppDataError('VALIDATION', `${label} must be JSON-serializable`, { cause: error && error.message });
+ }
+ }
+ function operationId(command, prefix, semanticPayload = command) {
+ const id = command && command.operationId ? String(command.operationId) : randomId(prefix);
+ jsonValue(semanticPayload, `${prefix} payload`);
+ return id;
+ }
+ function mutationOptions(command, prefix, semanticPayload, extra = {}) {
+ const source = asObject(command);
+ const payload = jsonValue(semanticPayload, `${prefix} payload`);
+ const intent = { command: prefix, payload };
+ if (Object.prototype.hasOwnProperty.call(source, 'expectedRevision')) {
+ intent.expectedRevision = source.expectedRevision;
+ }
+ return Object.assign({}, extra, {
+ operationId: operationId(source, prefix, payload),
+ intent
+ });
+ }
+ function optionsMutationOptions(options, prefix, semanticPayload, extra = {}) {
+ return mutationOptions(asObject(options), prefix, semanticPayload, extra);
+ }
+ function deterministicEntityId(prefix, operation) {
+ return `${prefix}_${checksum({ operationId: String(operation) }).replace(/[^a-z0-9]+/gi, '')}`;
+ }
+ function normalizeAccuracyRatio(value, label = 'accuracy') {
+ if (value === undefined || value === null || value === '') return null;
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) {
+ throw new AppDataError('VALIDATION', `${label} must be between 0 and 100`);
+ }
+ return numeric > 1 ? numeric / 100 : numeric;
+ }
+ function defaultStats() {
+ return {
+ totalPractices: 0, totalQuestions: 0, correctAnswers: 0, averageAccuracy: 0,
+ reading: { practices: 0, questions: 0, correct: 0, accuracy: 0 },
+ listening: { practices: 0, questions: 0, correct: 0, accuracy: 0 },
+ lastUpdated: nowIso()
+ };
+ }
+
+ function firstNonNegative(...values) {
+ for (const value of values) {
+ if (value === null || value === undefined || value === '' || typeof value === 'object') continue;
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric;
+ }
+ return null;
+ }
+
+ function normalizePracticeScore(record) {
+ const scoreInfo = asObject(record.scoreInfo);
+ const legacyScoreInfo = asObject(asObject(record.realData).scoreInfo);
+ const overloadedAnswers = record.correctAnswers;
+ if (overloadedAnswers && typeof overloadedAnswers === 'object') {
+ record.correctAnswerMap = Object.assign(
+ {},
+ clone(asObject(overloadedAnswers)),
+ clone(asObject(record.correctAnswerMap))
+ );
+ }
+ const correct = firstNonNegative(
+ overloadedAnswers,
+ record.correctAnswersCount,
+ scoreInfo.correctAnswers,
+ scoreInfo.correct,
+ legacyScoreInfo.correctAnswers,
+ legacyScoreInfo.correct
+ );
+ if (correct !== null) record.correctAnswers = correct;
+ else if (overloadedAnswers && typeof overloadedAnswers === 'object') record.correctAnswers = 0;
+ const total = firstNonNegative(
+ record.totalQuestions,
+ record.questionCount,
+ scoreInfo.totalQuestions,
+ scoreInfo.total,
+ legacyScoreInfo.totalQuestions,
+ legacyScoreInfo.total
+ );
+ if (total !== null) record.totalQuestions = total;
+ }
+
+ function mergeAnswers(target, source) {
+ if (Array.isArray(source)) {
+ source.forEach((item, index) => {
+ if (!item || typeof item !== 'object') return;
+ const questionId = idOf(item, ['questionId', 'questionNumber', 'id', 'number']) || String(index + 1);
+ const answer = item.answer ?? item.value ?? item.userAnswer ?? item.selectedAnswer;
+ if (answer !== undefined) target[questionId] = clone(answer);
+ });
+ return;
+ }
+ for (const [questionId, answer] of Object.entries(asObject(source))) {
+ target[String(questionId)] = clone(answer);
+ }
+ }
+
+ function normalizePracticeAnswers(record) {
+ const answers = {};
+ const raw = asObject(record.rawData);
+ const rawReal = asObject(raw.realData);
+ const real = asObject(record.realData);
+ for (const source of [
+ rawReal.answerMap, rawReal.answerList, rawReal.answers,
+ raw.answerMap, raw.answerList, raw.answers,
+ real.answerMap, real.answerList, real.answers,
+ record.answerMap, record.answerList, record.answers
+ ]) mergeAnswers(answers, source);
+ if (Object.keys(answers).length) record.answers = answers;
+ }
+
+ function questionTypeErrorCounts(source) {
+ const counts = {};
+ const add = (type, count = 1) => {
+ const key = String(type || '').trim();
+ if (key && count > 0) counts[key] = (counts[key] || 0) + count;
+ };
+ for (const [type, value] of Object.entries(asObject(source && source.questionTypePerformance))) {
+ const metrics = asObject(value);
+ const total = firstNonNegative(metrics.totalQuestions, metrics.total);
+ const correct = firstNonNegative(metrics.correctAnswers, metrics.correct);
+ if (total !== null && correct !== null) add(type, Math.max(0, total - correct));
+ }
+ for (const detail of Object.values(asObject(asObject(source && source.scoreInfo).details))) {
+ if (detail && detail.isCorrect === false) add(detail.questionType || detail.type);
+ }
+ return counts;
+ }
+
+ function canonicalizeRecord(input) {
+ assertObject(input, 'practice record must be an object');
+ const record = jsonValue(input, 'practice record');
+ record.id = idOf(record, ['id', 'recordId', 'sessionId']) || randomId('record');
+ record.sessionId = idOf(record, ['sessionId']) || record.id;
+ record.timestamp = record.timestamp || record.completedAt || record.date || nowIso();
+ record.completedAt = record.completedAt || record.timestamp;
+ record.type = record.type || record.examType || (record.metadata && record.metadata.type) || 'practice';
+ record.metadata = asObject(record.metadata);
+ if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId;
+ if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId;
+ normalizePracticeAnswers(record);
+ normalizePracticeScore(record);
+ for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) {
+ if (record[field] === undefined || record[field] === null || record[field] === '') continue;
+ const numeric = Number(record[field]);
+ if (!Number.isFinite(numeric) || numeric < 0) throw new AppDataError('VALIDATION', `practice record ${field} must be a non-negative number`);
+ record[field] = numeric;
+ }
+ if (record.accuracy !== undefined) record.accuracy = normalizeAccuracyRatio(record.accuracy, 'practice record accuracy');
+ return jsonValue(record, 'canonical practice record');
+ }
+
+ function lightSuiteEntry(source, fallbackType = null) {
+ const entry = asObject(source);
+ const scoreInfo = asObject(entry.scoreInfo);
+ const realScoreInfo = asObject(asObject(entry.realData).scoreInfo);
+ const metadata = asObject(entry.metadata);
+ const totalQuestions = firstNonNegative(entry.totalQuestions, scoreInfo.totalQuestions, scoreInfo.total, realScoreInfo.totalQuestions, realScoreInfo.total) ?? 0;
+ const correctAnswers = firstNonNegative(entry.correctAnswers, scoreInfo.correctAnswers, scoreInfo.correct, realScoreInfo.correctAnswers, realScoreInfo.correct) ?? 0;
+ const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy;
+ const accuracy = normalizeAccuracyRatio(
+ explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0),
+ 'suite entry accuracy'
+ ) || 0;
+ const percentage = Number(entry.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0;
+ return jsonValue({
+ id: entry.id || null,
+ sessionId: entry.sessionId || null,
+ examId: entry.examId || metadata.examId || null,
+ title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '',
+ type: entry.type || metadata.type || fallbackType,
+ date: entry.date || entry.completedAt || entry.timestamp || null,
+ duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0,
+ totalQuestions,
+ correctAnswers,
+ accuracy,
+ percentage,
+ questionTypeErrorCounts: questionTypeErrorCounts(entry)
+ }, 'suite entry light projection');
+ }
+
+ function lightFromCanonical(source) {
+ const scoreInfo = asObject(source.scoreInfo);
+ const realScoreInfo = asObject(asObject(source.realData).scoreInfo);
+ const metadata = asObject(source.metadata);
+ const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field);
+ const dataSource = hasOwn(source, 'dataSource')
+ ? source.dataSource
+ : (hasOwn(metadata, 'dataSource') ? metadata.dataSource : undefined);
+ const totalQuestions = Number(source.totalQuestions ?? scoreInfo.totalQuestions ?? scoreInfo.total ?? realScoreInfo.totalQuestions ?? realScoreInfo.total ?? 0) || 0;
+ const correctAnswers = Number(source.correctAnswers ?? scoreInfo.correctAnswers ?? scoreInfo.correct ?? realScoreInfo.correctAnswers ?? realScoreInfo.correct ?? 0) || 0;
+ const explicitAccuracy = source.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy;
+ const accuracy = normalizeAccuracyRatio(
+ explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0),
+ 'practice light accuracy'
+ ) || 0;
+ return jsonValue({
+ id: source.id,
+ sessionId: source.sessionId,
+ examId: source.examId || source.metadata.examId || null,
+ title: source.title || source.examTitle || (source.metadata && source.metadata.examTitle) || source.metadata.title || '',
+ type: source.type,
+ mode: source.mode || source.practiceMode || null,
+ timestamp: source.timestamp,
+ completedAt: source.completedAt,
+ date: source.date || source.completedAt || source.timestamp || null,
+ startTime: source.startTime || null,
+ endTime: source.endTime || null,
+ duration: Number(source.duration ?? source.durationSeconds ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0,
+ totalQuestions,
+ correctAnswers,
+ accuracy,
+ percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0,
+ score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null,
+ questionTypeErrorCounts: questionTypeErrorCounts(source),
+ // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined`
+ // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。
+ // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。
+ dataSource,
+ // Summaries are list indexes. Keep only the metadata needed to filter, show a
+ // source label, or locate the originating library; details stay in their entity.
+ metadata: Object.fromEntries([
+ // `source` must stay: PracticeRecordSource uses metadata.source demo markers
+ // (e.g. onboarding-demo) so light/stats/achievements stay consistent with full.
+ 'examId', 'examTitle', 'title', 'type', 'category', 'frequency',
+ 'dataSource', 'source', 'libraryConfigurationId'
+ ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])),
+ suite: source.suite == null ? null : clone(asObject(source.suite)),
+ suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(
+ entry,
+ String(source.type || '').replace(/-suite$/, '') || null
+ ))
+ }, 'practice light projection');
+ }
+
+ function projectLight(record) {
+ if (!record) return null;
+ return lightFromCanonical(canonicalizeRecord(record));
+ }
+
+ function firstNonEmpty(...values) {
+ let first;
+ for (const value of values) {
+ if (value === undefined || value === null) continue;
+ if (first === undefined) first = value;
+ if (Array.isArray(value) && value.length) return clone(value);
+ if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length) return clone(value);
+ if (typeof value !== 'object') return clone(value);
+ }
+ return first === undefined ? {} : clone(first);
+ }
+
+ const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'questionTypeErrorCounts', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries']);
+ const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']);
+
+ function withoutRawData(value) {
+ if (Array.isArray(value)) return value.map(withoutRawData);
+ if (!value || typeof value !== 'object') return clone(value);
+ const clean = {};
+ for (const [key, item] of Object.entries(value)) {
+ if (key !== 'realData' && key !== 'rawData') clean[key] = withoutRawData(item);
+ }
+ return clean;
+ }
+
+ function splitPracticeRecord(input) {
+ const source = canonicalizeRecord(input);
+ const summary = lightFromCanonical(source);
+ const detail = { recordId: source.id };
+ const annotations = { recordId: source.id };
+ for (const [key, value] of Object.entries(source)) {
+ if (key === 'realData' || key === 'rawData' || key === 'answerMap' || key === 'answerList' || SUMMARY_FIELDS.has(key)) continue;
+ if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value);
+ else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => {
+ const next = Object.assign({}, asObject(entry));
+ const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData));
+ for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) {
+ if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]);
+ }
+ const annotation = {};
+ for (const annotationKey of ANNOTATION_FIELDS) {
+ if (hasOwn(next, annotationKey)) { annotation[annotationKey] = next[annotationKey]; delete next[annotationKey]; }
+ if (next.realData && hasOwn(next.realData, annotationKey)) delete next.realData[annotationKey];
+ if (next.rawData && hasOwn(next.rawData, annotationKey)) delete next.rawData[annotationKey];
+ }
+ delete next.realData; delete next.rawData;
+ if (Object.keys(annotation).length) {
+ if (!annotations.suiteEntries) annotations.suiteEntries = {};
+ annotations.suiteEntries[String(next.examId || asObject(next.metadata).examId || next.id || Object.keys(annotations.suiteEntries).length)] = annotation;
+ }
+ return withoutRawData(next);
+ });
+ else detail[key] = withoutRawData(value);
+ }
+ // Accept the old mirror only as an input normalization boundary; it is never persisted.
+ const realData = asObject(source.realData); const rawData = asObject(source.rawData);
+ for (const key of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) {
+ if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]);
+ }
+ for (const key of ANNOTATION_FIELDS) {
+ if (hasOwn(annotations, key)) continue;
+ if (hasOwn(realData, key)) annotations[key] = withoutRawData(realData[key]);
+ else if (hasOwn(rawData, key)) annotations[key] = withoutRawData(rawData[key]);
+ }
+ return { summary: jsonValue(summary, 'practice summary'), detail: jsonValue(detail, 'practice detail'), annotations: jsonValue(annotations, 'practice annotations') };
+ }
+
+ function joinPracticeRecord(summary, detail, annotations, projection = 'full') {
+ if (!summary) return null;
+ const mode = String(projection || 'full').toLowerCase();
+ const light = clone(summary);
+ if (mode === 'light' || mode === 'summary') return light;
+ const joined = Object.assign({}, light, clone(asObject(detail)));
+ delete joined.recordId;
+ if (mode === 'detail' || mode === 'medium') return jsonValue(joined, 'practice detail projection');
+ const annotationData = asObject(annotations);
+ for (const [key, value] of Object.entries(annotationData)) if (key !== 'recordId' && key !== 'suiteEntries') joined[key] = clone(value);
+ if (Array.isArray(joined.suiteEntries)) {
+ const suiteAnnotations = asObject(annotationData.suiteEntries);
+ joined.suiteEntries = joined.suiteEntries.map((entry) => Object.assign({}, entry, clone(suiteAnnotations[String(entry.examId || asObject(entry.metadata).examId || entry.id)] || {})));
+ }
+ return jsonValue(joined, 'practice full projection');
+ }
+
+ function projectDetail(record) { return joinPracticeRecord(splitPracticeRecord(record).summary, splitPracticeRecord(record).detail, null, 'detail'); }
+
+ // “什么算真实练习记录”只有一份定义(js/data/practiceRecordSource.js)。
+ // 这里必须硬性依赖而不是本地兜底:曾经投影器与 js/main.js 各写一套判定,
+ // 导致演示/种子记录在列表里看不见却计入统计与成就。缺失即启动失败,
+ // 让漏配 bundle 在开发期就暴露,而不是运行时静默退回旧语义。
+ const practiceRecordSource = global.PracticeRecordSource;
+ if (!practiceRecordSource || typeof practiceRecordSource.isRealPracticeRecord !== 'function') {
+ throw new Error('AppData v2 requires PracticeRecordSource (js/data/practiceRecordSource.js)');
+ }
+ const isRealPracticeRecord = practiceRecordSource.isRealPracticeRecord;
+
+ function computeStats(records) {
+ const stats = defaultStats();
+ for (const record of asArray(records).filter(isRealPracticeRecord)) {
+ const summary = projectLight(record);
+ const type = String(summary.type || '').toLowerCase();
+ const target = type.includes('listen') ? stats.listening : stats.reading;
+ stats.totalPractices += 1;
+ stats.totalQuestions += summary.totalQuestions;
+ stats.correctAnswers += summary.correctAnswers;
+ target.practices += 1;
+ target.questions += summary.totalQuestions;
+ target.correct += summary.correctAnswers;
+ }
+ stats.averageAccuracy = stats.totalQuestions ? (stats.correctAnswers / stats.totalQuestions) * 100 : 0;
+ for (const target of [stats.reading, stats.listening]) target.accuracy = target.questions ? (target.correct / target.questions) * 100 : 0;
+ stats.lastUpdated = nowIso();
+ return stats;
+ }
+
+ function validIso(value) {
+ if (value === null || value === undefined || value === '') return null;
+ const time = new Date(value).getTime();
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
+ }
+
+ function practiceType(record) {
+ const metadata = asObject(record.metadata);
+ const hints = [record.type, record.practiceType, metadata.type, metadata.examType, metadata.practiceType,
+ record.examId, record.title, metadata.examId, metadata.title].filter(Boolean).join(' ').toLowerCase();
+ if (hints.includes('listen') || hints.includes('audio') || hints.includes('hearing')) return 'listening';
+ if (hints.includes('read')) return 'reading';
+ return null;
+ }
+
+ function accuracyRatio(record) {
+ const summary = lightFromCanonical(record);
+ const value = Number(summary.accuracy);
+ if (!Number.isFinite(value)) return 0;
+ return Math.max(0, Math.min(1, value > 1 ? value / 100 : value));
+ }
+
+ function durationSeconds(record) {
+ const scoreInfo = asObject(record.scoreInfo);
+ const realData = asObject(record.realData);
+ const realScoreInfo = asObject(realData.scoreInfo);
+ for (const value of [record.duration, realData.duration, scoreInfo.duration, scoreInfo.timeSpent, realScoreInfo.duration, realScoreInfo.timeSpent]) {
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric;
+ }
+ return 0;
+ }
+
+ function earlierUnlock(left, right) {
+ const leftIso = validIso(left);
+ const rightIso = validIso(right);
+ if (!leftIso) return rightIso;
+ if (!rightIso) return leftIso;
+ return new Date(leftIso).getTime() <= new Date(rightIso).getTime() ? leftIso : rightIso;
+ }
+
+ function laterUnlock(left, right) {
+ const leftIso = validIso(left);
+ const rightIso = validIso(right);
+ if (!leftIso) return rightIso;
+ if (!rightIso) return leftIso;
+ return new Date(leftIso).getTime() >= new Date(rightIso).getTime() ? leftIso : rightIso;
+ }
+
+ function computeAchievementProgress(records, manual, existing) {
+ const items = asArray(records).filter(isRealPracticeRecord).map(canonicalizeRecord)
+ .map((record, index) => ({
+ record,
+ index,
+ unlockedAt: validIso(record.completedAt || record.timestamp),
+ time: new Date(record.completedAt || record.timestamp).getTime()
+ }))
+ .sort((left, right) => {
+ const leftTime = Number.isFinite(left.time) ? left.time : Number.MAX_SAFE_INTEGER;
+ const rightTime = Number.isFinite(right.time) ? right.time : Number.MAX_SAFE_INTEGER;
+ return leftTime - rightTime || left.index - right.index;
+ });
+ const candidates = {};
+ const setThreshold = (id, list, count) => {
+ if (list.length >= count) candidates[id] = list[count - 1].unlockedAt;
+ };
+ setThreshold('first_step', items, 1);
+ setThreshold('practice_bronze', items, 10);
+ setThreshold('practice_silver', items, 50);
+ setThreshold('practice_gold', items, 100);
+ setThreshold('practice_platinum', items, 200);
+
+ const reading = items.filter((item) => practiceType(item.record) === 'reading');
+ const listening = items.filter((item) => practiceType(item.record) === 'listening');
+ setThreshold('reading_first', reading, 1);
+ setThreshold('reading_bronze', reading, 10);
+ setThreshold('reading_silver', reading, 50);
+ setThreshold('reading_gold', reading, 100);
+ setThreshold('listening_first', listening, 1);
+ setThreshold('listening_bronze', listening, 10);
+ setThreshold('listening_silver', listening, 50);
+ setThreshold('listening_gold', listening, 100);
+ if (reading.length >= 10 && listening.length >= 10) candidates.balanced_foundation = laterUnlock(reading[9].unlockedAt, listening[9].unlockedAt);
+ if (reading.length >= 30 && listening.length >= 30) candidates.balanced_advanced = laterUnlock(reading[29].unlockedAt, listening[29].unlockedAt);
+
+ let cumulativeDuration = 0;
+ let cumulativeAccuracy = 0;
+ let perfectCount = 0;
+ let speedCount = 0;
+ for (let index = 0; index < items.length; index += 1) {
+ const item = items[index];
+ const accuracy = accuracyRatio(item.record);
+ const duration = durationSeconds(item.record);
+ cumulativeDuration += duration;
+ cumulativeAccuracy += accuracy;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_60') && cumulativeDuration >= 3600) candidates.time_focus_60 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_300') && cumulativeDuration >= 18000) candidates.time_focus_300 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_1000') && cumulativeDuration >= 60000) candidates.time_focus_1000 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_stable') && index + 1 >= 10 && cumulativeAccuracy / (index + 1) >= 0.7) candidates.accuracy_stable = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_elite') && index + 1 >= 20 && cumulativeAccuracy / (index + 1) >= 0.85) candidates.accuracy_elite = item.unlockedAt;
+ if (accuracy >= 1) {
+ perfectCount += 1;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_perfect')) candidates.accuracy_perfect = item.unlockedAt;
+ if (perfectCount === 3) candidates.perfect_three = item.unlockedAt;
+ if (perfectCount === 10) candidates.perfect_ten = item.unlockedAt;
+ }
+ if (duration > 0 && duration <= 300 && accuracy > 0.8) {
+ speedCount += 1;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'speed_demon')) candidates.speed_demon = item.unlockedAt;
+ if (speedCount === 3) candidates.speed_three = item.unlockedAt;
+ if (speedCount === 10) candidates.speed_ten = item.unlockedAt;
+ }
+ }
+
+ const dayItems = new Map();
+ for (const item of items) {
+ if (!item.unlockedAt) continue;
+ const day = item.unlockedAt.slice(0, 10);
+ if (!dayItems.has(day)) dayItems.set(day, item.unlockedAt);
+ }
+ const days = Array.from(dayItems.keys()).sort();
+ let streak = 0;
+ let previousDay = null;
+ for (const day of days) {
+ const currentDay = new Date(`${day}T00:00:00.000Z`).getTime();
+ streak = previousDay !== null && currentDay - previousDay === 86400000 ? streak + 1 : 1;
+ previousDay = currentDay;
+ if (streak === 3 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_bronze')) candidates.streak_bronze = dayItems.get(day);
+ if (streak === 7 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_silver')) candidates.streak_silver = dayItems.get(day);
+ if (streak === 30 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_gold')) candidates.streak_gold = dayItems.get(day);
+ if (streak === 60 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_platinum')) candidates.streak_platinum = dayItems.get(day);
+ }
+
+ const progress = {};
+ const mergeUnlocked = (source) => {
+ for (const [rawId, value] of Object.entries(asObject(source))) {
+ if (!value || rawId === 'updatedAt') continue;
+ const id = rawId;
+ const unlockedAt = value && typeof value === 'object' ? validIso(value.unlockedAt) : null;
+ if (!progress[id]) progress[id] = { unlockedAt };
+ else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt);
+ }
+ };
+ mergeUnlocked(existing);
+ mergeUnlocked(manual);
+ for (const [id, unlockedAt] of Object.entries(candidates)) {
+ if (!progress[id]) progress[id] = { unlockedAt: validIso(unlockedAt) };
+ else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt);
+ }
+ return jsonValue(progress, 'achievement progress');
+ }
+
+ // Entity records are authoritative. Projections are assembled on reads, never cached or
+ // scheduled as follow-up work; this keeps a successful write immediately observable.
+ async function retryMergeConflict(options, task, maxAttempts = 3) {
+ const explicitRevision = hasOwn(options, 'expectedRevision');
+ let lastError;
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ try {
+ return await task();
+ } catch (error) {
+ lastError = error;
+ if (explicitRevision || !error || error.code !== 'CONFLICT' || attempt + 1 >= maxAttempts) {
+ throw error;
+ }
+ }
+ }
+ throw lastError;
+ }
+
+ async function readCollectionMeta(logicalKey) {
+ const meta = await kernel.read(logicalKey, { withMeta: true });
+ return { items: asArray(meta.data), revision: meta.envelope ? Number(meta.envelope.revision) : 0 };
+ }
+
+ function retainBackupEntries(items, limit = 20, preserveIds = []) {
+ const cap = Math.max(1, Number(limit) || 20);
+ const newestFirst = (left, right) => String(right.timestamp || '').localeCompare(String(left.timestamp || ''));
+ const entries = asArray(items).filter(Boolean).sort(newestFirst);
+ const retained = [];
+ const retainedIds = new Set();
+ const requestedIds = new Set(asArray(preserveIds).map(String).filter(Boolean));
+ for (const item of entries) {
+ const id = String(item.id);
+ if (retained.length >= cap || retainedIds.has(id) || !requestedIds.has(id)) continue;
+ retained.push(item);
+ retainedIds.add(id);
+ }
+ for (const item of entries) {
+ const id = String(item.id);
+ if (retained.length >= cap) break;
+ if (retainedIds.has(id)) continue;
+ retained.push(item);
+ retainedIds.add(id);
+ }
+ return retained;
+ }
+
+ function hasOwn(value, key) {
+ return Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
+ }
+
+ function normalizeLibraryConfigurationId(value) {
+ return importedLibraryId(value, { nullable: true });
+ }
+
+ async function practiceRecordWithLibraryProvenance(source, command, options = {}) {
+ assertObject(source, 'practice record must be an object');
+ const record = jsonValue(source, 'practice record');
+ const metadata = asObject(record.metadata);
+ let configurationId;
+
+ if (hasOwn(command, 'libraryConfigurationId')) {
+ configurationId = command.libraryConfigurationId;
+ } else if (hasOwn(metadata, 'libraryConfigurationId')) {
+ configurationId = metadata.libraryConfigurationId;
+ } else if (hasOwn(record, 'libraryConfigurationId')) {
+ configurationId = record.libraryConfigurationId;
+ } else {
+ configurationId = await kernel.read('library.activeConfigurationId');
+ }
+
+ const normalizedId = normalizeLibraryConfigurationId(configurationId);
+ record.metadata = Object.assign({}, metadata, { libraryConfigurationId: normalizedId });
+
+ if (options.includeSuiteEntries && Array.isArray(record.suiteEntries)) {
+ record.suiteEntries = record.suiteEntries.map((entry) => {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry;
+ const next = jsonValue(entry, 'practice suite entry');
+ const entryMetadata = asObject(next.metadata);
+ const entryId = hasOwn(entryMetadata, 'libraryConfigurationId')
+ ? normalizeLibraryConfigurationId(entryMetadata.libraryConfigurationId)
+ : normalizedId;
+ next.metadata = Object.assign({}, entryMetadata, { libraryConfigurationId: entryId });
+ return next;
+ });
+ }
+
+ return record;
+ }
+
+ function practiceRecordMatches(record, identities) {
+ const expected = new Set(asArray(identities).map((value) => String(value || '')).filter(Boolean));
+ if (!expected.size || !record || typeof record !== 'object') return false;
+ return ['id', 'recordId', 'sessionId'].some((field) => {
+ const value = record[field];
+ return value !== undefined && value !== null && expected.has(String(value));
+ });
+ }
+
+ function practiceLayerId(row) {
+ return String(row && (row.recordId || row.id || row.sessionId) || '');
+ }
+ async function practiceLayers(recordId, withMeta = false) {
+ const snapshot = await kernel.readPracticeSnapshot([recordId], { withMeta });
+ const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null;
+ return { summary: find('practiceSummaries'), detail: find('practiceDetails'), annotations: find('practiceAnnotations') };
+ }
+ function entityRevision(row) { return row ? Number(row.revision) : 0; }
+ function practiceUpserts(recordId, layers, existing = {}) {
+ return [
+ { type: 'upsert', store: 'practiceSummaries', recordId, data: layers.summary, expectedRevision: entityRevision(existing.summary) },
+ { type: 'upsert', store: 'practiceDetails', recordId, data: layers.detail, expectedRevision: entityRevision(existing.detail) },
+ { type: 'upsert', store: 'practiceAnnotations', recordId, data: layers.annotations, expectedRevision: entityRevision(existing.annotations) }
+ ];
+ }
+ async function joinedPractice(recordId, projection, snapshot = null) {
+ const mode = String(projection || 'full').toLowerCase();
+ const stores = mode === 'light' || mode === 'summary'
+ ? ['practiceSummaries']
+ : (mode === 'detail' || mode === 'medium' ? ['practiceSummaries', 'practiceDetails'] : undefined);
+ const layers = snapshot || await kernel.readPracticeSnapshot([recordId], { stores });
+ const find = (store) => asArray(layers[store]).find((row) => practiceLayerId(row) === String(recordId)) || null;
+ const summary = find('practiceSummaries');
+ if (!summary) return null;
+ if (mode === 'light' || mode === 'summary') return clone(summary);
+ const detail = find('practiceDetails');
+ if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode);
+ return joinPracticeRecord(summary, detail, find('practiceAnnotations'), mode);
+ }
+ const practice = Object.freeze({
+ async list(options = {}) {
+ await ready;
+ const projection = String(options.projection || 'full').toLowerCase();
+ const summaries = await kernel.listEntities('practiceSummaries');
+ if (projection === 'light' || projection === 'summary') return summaries;
+ const stores = projection === 'detail' || projection === 'medium'
+ ? ['practiceSummaries', 'practiceDetails']
+ : undefined;
+ const snapshot = await kernel.readPracticeSnapshot(null, { stores });
+ return (await Promise.all(asArray(snapshot.practiceSummaries)
+ .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))).filter(Boolean);
+ },
+ async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); },
+ async completeAttempt(command) {
+ await ready;
+ const source = command && (command.record || command.attempt) ? (command.record || command.attempt) : command;
+ const mutation = mutationOptions(command, 'practice-complete', source);
+ const recordInput = await practiceRecordWithLibraryProvenance(source, command);
+ if (!idOf(recordInput, ['id', 'recordId', 'sessionId'])) recordInput.id = deterministicEntityId('record', mutation.operationId);
+ const layers = splitPracticeRecord(recordInput); const recordId = layers.summary.id;
+ const receipt = await retryMergeConflict(command || {}, async () => kernel.mutateEntities(
+ practiceUpserts(recordId, layers, await practiceLayers(recordId, true)), mutation));
+ return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') });
+ },
+ async finalizeSuite(command) {
+ await ready; assertObject(command, 'finalizeSuite command is required');
+ const mutation = mutationOptions(command, 'practice-suite', command);
+ const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true });
+ if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId);
+ const layers = splitPracticeRecord(input); const recordId = layers.summary.id;
+ const childIdentities = asArray(command.childRecordIds || command.childSessionIds).map(String);
+ const children = new Set((await kernel.listEntities('practiceSummaries'))
+ .filter((summary) => practiceRecordMatches(summary, childIdentities))
+ .map((summary) => idOf(summary, ['id', 'recordId', 'sessionId'])));
+ children.delete(recordId);
+ const receipt = await retryMergeConflict(command, async () => {
+ const existing = await practiceLayers(recordId, true);
+ const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id })));
+ return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation);
+ });
+ return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') });
+ },
+ async updateAnnotations(command) {
+ await ready; assertObject(command, 'updateAnnotations command is required'); const recordId = String(command.recordId || '');
+ return retryMergeConflict(command, async () => {
+ const current = await practiceLayers(recordId, true); if (!current.summary) throw new AppDataError('VALIDATION', `Unknown practice record: ${recordId}`);
+ if (command.expectedRevision !== undefined && Number(command.expectedRevision) !== entityRevision(current.annotations)) throw new AppDataError('CONFLICT', `Revision conflict for practice annotations ${recordId}`);
+ const annotations = Object.assign({ recordId }, clone(asObject(current.annotations && current.annotations.data)));
+ const detail = clone(asObject(current.detail && current.detail.data)); const examId = String(command.examId || current.summary.data.examId || 'default');
+ if (Array.isArray(detail.suiteEntries) && detail.suiteEntries.length) {
+ if (!detail.suiteEntries.some((entry) => String(entry.examId || asObject(entry.metadata).examId || '') === examId)) throw new AppDataError('VALIDATION', `Suite record ${recordId} does not contain exam ${examId}`);
+ annotations.suiteEntries = Object.assign({}, asObject(annotations.suiteEntries), { [examId]: Object.assign({}, asObject(annotations.suiteEntries)[examId], clone(asObject(command.patch))) });
+ } else {
+ if (current.summary.data.examId && String(current.summary.data.examId) !== examId) throw new AppDataError('VALIDATION', `Record ${recordId} does not match exam ${examId}`);
+ annotations.annotations = Object.assign({}, asObject(annotations.annotations), { [examId]: Object.assign({}, asObject(annotations.annotations)[examId], clone(asObject(command.patch))) });
+ Object.assign(annotations, clone(asObject(command.patch)));
+ }
+ return kernel.mutateEntities([{
+ type: 'upsert',
+ store: 'practiceAnnotations',
+ recordId,
+ data: annotations,
+ expectedRevision: entityRevision(current.annotations)
+ }], mutationOptions(command, 'practice-annotations', command));
+ });
+ },
+ async delete(command) {
+ await ready; const recordId = String(command && (command.recordId || command.id) || command || ''); if (!recordId) throw new AppDataError('VALIDATION', 'practice record id is required');
+ const found = await kernel.readEntity('practiceSummaries', recordId); if (!found) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete', { recordId })), { deletedCount: 0, noop: true });
+ const receipt = await kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId })), mutationOptions(command, 'practice-delete', { recordId }));
+ return Object.assign({}, receipt, { deletedCount: 1 });
+ },
+ async deleteMany(command) {
+ await ready; assertObject(command, 'practice.deleteMany command is required'); const recordIds = Array.from(new Set(asArray(command.recordIds).map(String).filter(Boolean)));
+ if (!recordIds.length) throw new AppDataError('VALIDATION', 'practice.deleteMany requires recordIds'); const summaries = await kernel.listEntities('practiceSummaries'); const ids = recordIds.filter((id) => summaries.some((item) => practiceRecordMatches(item, [id])));
+ if (!ids.length) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete-many', { recordIds })), { deletedCount: 0, noop: true });
+ const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length });
+ },
+ async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); },
+ async listInsights(options = {}) {
+ await ready;
+ const limit = Math.max(1, Math.min(50, Number(options.limit) || 10));
+ const summaries = (await kernel.listEntities('practiceSummaries'))
+ .slice()
+ .sort((left, right) => String(right.date || right.completedAt || right.timestamp || '')
+ .localeCompare(String(left.date || left.completedAt || left.timestamp || '')))
+ .slice(0, limit);
+ return Promise.all(summaries.map(async (summary) => {
+ if (Object.keys(asObject(summary.questionTypeErrorCounts)).length) return clone(summary);
+ const detail = await kernel.readEntity('practiceDetails', summary.id);
+ return jsonValue(Object.assign({}, clone(summary), {
+ questionTypeErrorCounts: questionTypeErrorCounts(detail)
+ }), 'practice insight');
+ }));
+ },
+ async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); },
+ projectLight,
+ projectDetail
+ });
+
+ const settings = Object.freeze({
+ async getAll() { await ready; return kernel.read('settings.values'); },
+ async patch(values, options = {}) {
+ await ready; assertObject(values, 'settings.patch requires an object');
+ const mutation = optionsMutationOptions(options, 'settings-patch', values);
+ return retryMergeConflict(options, async () => {
+ const current = await kernel.read('settings.values', { withMeta: true });
+ return kernel.mutate([{ logicalKey: 'settings.values', data: Object.assign({}, asObject(current.data), clone(values)), expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ });
+ },
+ async reset(options = {}) { await ready; const current = await kernel.read('settings.values', { withMeta: true }); return kernel.mutate([{ logicalKey: 'settings.values', state: 'cleared', expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], optionsMutationOptions(options, 'settings-reset', { reset: true })); }
+ });
+
+ const library = Object.freeze({
+ async listConfigurations() { await ready; return kernel.read('library.configurations'); },
+ async getActive() { await ready; return kernel.read('library.activeConfigurationId'); },
+ async getIndex(configurationId) {
+ await ready;
+ const id = importedLibraryId(configurationId, { nullable: true });
+ if (id === null) return [];
+ const indexes = await kernel.read('library.importedIndexes');
+ return asArray(indexes[id]);
+ },
+ async updateConfiguration(configuration, options = {}) {
+ await ready; assertObject(configuration, 'library.updateConfiguration requires an object');
+ const id = importedLibraryId(idOf(configuration, ['id', 'key', 'configId']));
+ const current = await kernel.read('library.configurations', { withMeta: true });
+ const configs = asArray(current.data);
+ const index = configs.findIndex((item) => idOf(item, ['id', 'key', 'configId']) === id);
+ const next = Object.assign({}, index >= 0 ? configs[index] : {}, clone(configuration), { id, key: id });
+ if (index >= 0) configs[index] = next; else configs.push(next);
+ return kernel.mutate([{ logicalKey: 'library.configurations', data: configs, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-config', configuration));
+ },
+ async activate(configurationId, options = {}) {
+ await ready;
+ const id = importedLibraryId(configurationId, { nullable: true });
+ const current = await kernel.read('library.activeConfigurationId', { withMeta: true });
+ return kernel.mutate([{ logicalKey: 'library.activeConfigurationId', data: id, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-activate', { configurationId: id }));
+ },
+ async import(command) {
+ await ready; assertObject(command, 'library.import requires a command');
+ const id = importedLibraryId(command.id || command.configurationId || randomId('library'));
+ const configsMeta = await kernel.read('library.configurations', { withMeta: true });
+ const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true });
+ const configs = asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id);
+ configs.push(Object.assign({}, asObject(command.configuration), { id, key: id }));
+ const indexes = Object.assign({}, asObject(indexesMeta.data), { [id]: asArray(command.index) });
+ return kernel.mutate([
+ { logicalKey: 'library.configurations', data: configs, expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 },
+ { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 }
+ ], mutationOptions(command, 'library-import', command));
+ },
+ async remove(configurationId, options = {}) {
+ await ready; const id = importedLibraryId(configurationId);
+ const configsMeta = await kernel.read('library.configurations', { withMeta: true });
+ const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true });
+ const activeMeta = await kernel.read('library.activeConfigurationId', { withMeta: true });
+ const indexes = Object.assign({}, asObject(indexesMeta.data)); delete indexes[id];
+ const changes = [
+ { logicalKey: 'library.configurations', data: asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id), expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 },
+ { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 }
+ ];
+ if (String(activeMeta.data || '') === id) {
+ changes.push({ logicalKey: 'library.activeConfigurationId', data: null, expectedRevision: activeMeta.envelope ? activeMeta.envelope.revision : 0 });
+ }
+ return kernel.mutate(changes, optionsMutationOptions(options, 'library-remove', { configurationId: id }));
+ },
+ async resolveIndex() {
+ await ready;
+ const [activeId, indexes] = await Promise.all([kernel.read('library.activeConfigurationId'), kernel.read('library.importedIndexes')]);
+ return activeId && Array.isArray(asObject(indexes)[activeId]) ? clone(indexes[activeId]) : clone([]);
+ }
+ });
+
+ function recoveryKey(kind) {
+ const key = RECOVERY_KEYS[String(kind || '')];
+ if (!key) throw new AppDataError('VALIDATION', `Unknown recovery kind: ${kind}`);
+ return key;
+ }
+ // Recovery document TTL is an AppData domain rule, not a catalog policy field.
+ const RECOVERY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+ function recoveryTimestamp(item) {
+ for (const field of ['updatedAt', 'lastActivity', 'tempSavedAt', 'timestamp', 'createdAt']) {
+ const parsed = Date.parse(item && item[field]);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return null;
+ }
+ async function pruneRecoveryKey(logicalKey) {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const items = asArray(current.data);
+ const cutoff = Date.now() - RECOVERY_TTL_MS;
+ const firstOwnersById = new Map();
+ items.forEach((item) => {
+ const entityId = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (entityId && !firstOwnersById.has(String(entityId))) {
+ firstOwnersById.set(String(entityId), item);
+ }
+ });
+ const retainedEntityIds = new Set();
+ firstOwnersById.forEach((owner, entityId) => {
+ const timestamp = recoveryTimestamp(owner);
+ if (timestamp === null || timestamp > cutoff) retainedEntityIds.add(entityId);
+ });
+ const retained = items.filter((item) => {
+ const entityId = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (entityId) return retainedEntityIds.has(String(entityId));
+ const timestamp = recoveryTimestamp(item);
+ return timestamp === null || timestamp > cutoff;
+ });
+ if (retained.length === items.length) return items;
+ try {
+ await kernel.mutate([{ logicalKey, data: retained, expectedRevision: current.envelope ? current.envelope.revision : 0 }], {
+ operationId: randomId('recovery-ttl')
+ });
+ return retained;
+ } catch (error) {
+ if (!(error instanceof AppDataError) || error.code !== 'CONFLICT' || attempt === 2) throw error;
+ }
+ }
+ return kernel.read(logicalKey);
+ }
+ async function cleanupExpiredRecovery() {
+ for (const logicalKey of Object.values(RECOVERY_KEYS)) await pruneRecoveryKey(logicalKey);
+ }
+ const windowSession = Object.freeze({
+ save(name, value) {
+ if (!global.sessionStorage) throw new AppDataError('BACKEND_UNAVAILABLE', 'sessionStorage unavailable');
+ const logicalName = String(name || 'default');
+ const payload = { schemaVersion: catalog.version, updatedAt: nowIso(), data: clone(value) };
+ global.sessionStorage.setItem(`ielts_atlas:v2:session:${logicalName}`, JSON.stringify(payload));
+ return true;
+ },
+ get(name) {
+ if (!global.sessionStorage) return null;
+ const raw = global.sessionStorage.getItem(`ielts_atlas:v2:session:${String(name || 'default')}`);
+ if (!raw) return null;
+ const payload = JSON.parse(raw);
+ return payload && payload.schemaVersion === catalog.version ? clone(payload.data) : null;
+ },
+ discard(name) {
+ if (global.sessionStorage) global.sessionStorage.removeItem(`ielts_atlas:v2:session:${String(name || 'default')}`);
+ return true;
+ }
+ });
+
+ const recoveryMutationTails = new Map();
+ function enqueueRecoveryMutation(logicalKey, task) {
+ const previous = recoveryMutationTails.get(logicalKey) || Promise.resolve();
+ const result = previous.then(task, task);
+ recoveryMutationTails.set(logicalKey, result.catch(() => undefined));
+ return result;
+ }
+
+ async function readRecovery(kind, id) {
+ await ready;
+ const firstItemsById = new Set();
+ const tombstonedFirstItems = new Set();
+ const items = (await pruneRecoveryKey(recoveryKey(kind))).filter((item) => {
+ const entityId = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (entityId) {
+ const normalizedId = String(entityId);
+ if (!firstItemsById.has(normalizedId)) {
+ firstItemsById.add(normalizedId);
+ if (item && item._recoveryTombstone === true) {
+ tombstonedFirstItems.add(normalizedId);
+ }
+ } else if (tombstonedFirstItems.has(normalizedId)) {
+ // saveRecovery/discardRecovery use findIndex over the raw collection.
+ // If that exact first owner is a tombstone, never expose a later
+ // duplicate as a writable entity. Non-tombstone duplicates remain
+ // visible because recovery reconciliation consumes their raw marker
+ // metadata while independently honoring first-owner CAS semantics.
+ return false;
+ }
+ }
+ return !(item && item._recoveryTombstone === true);
+ });
+ return id == null ? items : items.find((item) => idOf(item, ['id', 'sessionId', 'recordId']) === String(id)) || null;
+ }
+ async function readRecoveryFence(kind, id) {
+ await ready;
+ const normalizedId = String(id ?? '');
+ if (!normalizedId) {
+ return { id: normalizedId, exists: false, tombstoned: false, revision: 0 };
+ }
+ const items = await pruneRecoveryKey(recoveryKey(kind));
+ const owner = items.find((item) => (
+ idOf(item, ['id', 'sessionId', 'recordId']) === normalizedId
+ ));
+ if (!owner) {
+ return { id: normalizedId, exists: false, tombstoned: false, revision: 0 };
+ }
+ return {
+ id: normalizedId,
+ exists: true,
+ tombstoned: owner._recoveryTombstone === true,
+ revision: recoveryEntityRevision(owner)
+ };
+ }
+ function expectedRecoveryEntityRevision(options = {}) {
+ if (!Object.prototype.hasOwnProperty.call(options, 'expectedEntityRevision')) return null;
+ const revision = Number(options.expectedEntityRevision);
+ if (!Number.isSafeInteger(revision) || revision < 0) {
+ throw new AppDataError('VALIDATION', 'recovery expectedEntityRevision must be a non-negative safe integer');
+ }
+ return revision;
+ }
+ function recoveryEntityRevision(item) {
+ const revision = Number(item && item.revision);
+ return Number.isSafeInteger(revision) && revision >= 0 ? revision : 0;
+ }
+ function recoveryExclusiveGroup(options = {}) {
+ const group = String(options && options.exclusiveGroup || '').trim();
+ if (group.length > 128) {
+ throw new AppDataError('VALIDATION', 'recovery exclusiveGroup must not exceed 128 characters');
+ }
+ return group;
+ }
+ function recoveryEntityExclusiveGroup(item) {
+ const explicit = String(item && item._recoveryExclusiveGroup || '').trim();
+ if (explicit) return explicit;
+ const schema = String(item && item.schema || '').trim();
+ const version = Number(item && item.version);
+ if (version === 2 && schema === 'suite-session-v2') {
+ // Upgrade compatibility: suite recoveries written before group metadata was
+ // introduced still occupy the same logical singleton group.
+ return 'suite-practice';
+ }
+ return '';
+ }
+ function staleRecoveryReceipt(mutation, expectedRevision, actualRevision) {
+ return {
+ committed: false,
+ stale: true,
+ code: 'STALE_RECOVERY_WRITE',
+ operationId: mutation.operationId,
+ expectedEntityRevision: expectedRevision,
+ actualEntityRevision: actualRevision
+ };
+ }
+ function guardedRecoveryReceipt(mutation) {
+ return {
+ committed: false,
+ stale: true,
+ code: 'STALE_RECOVERY_WRITE',
+ reason: 'COMMIT_GUARD_REJECTED',
+ operationId: mutation.operationId
+ };
+ }
+ async function saveRecovery(kind, value, options = {}) {
+ await ready; assertObject(value, `recovery ${kind} value must be an object`);
+ if (options.commitGuard !== undefined && typeof options.commitGuard !== 'function') {
+ throw new AppDataError('VALIDATION', 'recovery commitGuard must be a synchronous function');
+ }
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-save`, value);
+ const expectedEntityRevision = expectedRecoveryEntityRevision(options);
+ const exclusiveGroup = recoveryExclusiveGroup(options);
+ const key = recoveryKey(kind);
+ const id = idOf(value, ['id', 'sessionId', 'recordId']) || deterministicEntityId('recovery', mutation.operationId);
+ const item = Object.assign({}, clone(value), { id: value.id || id, updatedAt: nowIso() });
+ if (exclusiveGroup) item._recoveryExclusiveGroup = exclusiveGroup;
+ if (expectedEntityRevision !== null && recoveryEntityRevision(item) <= expectedEntityRevision) {
+ throw new AppDataError('VALIDATION', 'recovery entity revision must advance beyond expectedEntityRevision');
+ }
+ let receipt;
+ try {
+ receipt = await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id);
+ if (expectedEntityRevision !== null) {
+ const actualEntityRevision = index >= 0 ? recoveryEntityRevision(current.items[index]) : 0;
+ if (actualEntityRevision !== expectedEntityRevision) {
+ return staleRecoveryReceipt(mutation, expectedEntityRevision, actualEntityRevision);
+ }
+ }
+ if (exclusiveGroup) {
+ const seenEntityIds = new Set();
+ const conflicting = current.items.find((entry) => {
+ const entryId = idOf(entry, ['id', 'sessionId', 'recordId']);
+ if (!entryId || seenEntityIds.has(entryId)) return false;
+ seenEntityIds.add(entryId);
+ // AppData CAS always updates the raw first owner for an id. Shadow
+ // duplicates neither conflict with that owner nor become a second
+ // logical group member; a first-owner tombstone hides the whole id.
+ return entryId !== id
+ && entry
+ && entry._recoveryTombstone !== true
+ && recoveryEntityExclusiveGroup(entry) === exclusiveGroup;
+ });
+ if (conflicting) {
+ return {
+ committed: false,
+ stale: true,
+ code: 'RECOVERY_GROUP_CONFLICT',
+ operationId: mutation.operationId,
+ conflictingEntityId: idOf(conflicting, ['id', 'sessionId', 'recordId']) || null
+ };
+ }
+ }
+ if (index >= 0) current.items[index] = item; else current.items.push(item);
+ const kernelOptions = typeof options.commitGuard === 'function'
+ ? Object.assign({}, mutation, { commitGuard: options.commitGuard })
+ : mutation;
+ return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], kernelOptions);
+ }));
+ } catch (error) {
+ if (error && error.code === 'PRECONDITION_FAILED') {
+ return guardedRecoveryReceipt(mutation);
+ }
+ throw error;
+ }
+ if (!receipt || receipt.committed !== true) return receipt;
+ const committedItem = (await kernel.read(key))
+ .find((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id);
+ return Object.assign({}, receipt, { item: clone(committedItem || item) });
+ }
+ async function discardRecovery(kind, id, options = {}) {
+ await ready;
+ if (options.commitGuard !== undefined && typeof options.commitGuard !== 'function') {
+ throw new AppDataError('VALIDATION', 'recovery commitGuard must be a synchronous function');
+ }
+ const key = recoveryKey(kind);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-discard`, { id: String(id) });
+ const expectedEntityRevision = expectedRecoveryEntityRevision(options);
+ try {
+ return await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === String(id));
+ const kernelOptions = typeof options.commitGuard === 'function'
+ ? Object.assign({}, mutation, { commitGuard: options.commitGuard })
+ : mutation;
+ if (expectedEntityRevision !== null) {
+ const actualEntityRevision = index >= 0 ? recoveryEntityRevision(current.items[index]) : 0;
+ if (actualEntityRevision !== expectedEntityRevision) {
+ return staleRecoveryReceipt(mutation, expectedEntityRevision, actualEntityRevision);
+ }
+ const tombstone = {
+ id: String(id),
+ revision: Math.min(Number.MAX_SAFE_INTEGER, actualEntityRevision + 1),
+ _recoveryTombstone: true,
+ discardedAt: Date.now(),
+ updatedAt: nowIso()
+ };
+ if (index >= 0) current.items[index] = tombstone;
+ else current.items.push(tombstone);
+ return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], kernelOptions);
+ }
+ const next = current.items.filter((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) !== String(id));
+ return kernel.mutate([{ logicalKey: key, data: next, expectedRevision: current.revision }], kernelOptions);
+ }));
+ } catch (error) {
+ if (error && error.code === 'PRECONDITION_FAILED') {
+ return guardedRecoveryReceipt(mutation);
+ }
+ throw error;
+ }
+ }
+ async function clearRecovery(kind, options = {}) {
+ await ready;
+ const key = recoveryKey(kind);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-clear`, { kind });
+ return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ if (options.expectedRevision !== undefined && Number(options.expectedRevision) !== current.revision) {
+ throw new AppDataError('CONFLICT', `Revision conflict while clearing recovery ${kind}`, { expectedRevision: options.expectedRevision, actualRevision: current.revision });
+ }
+ return kernel.mutate([{ logicalKey: key, state: 'cleared', expectedRevision: current.revision }], mutation);
+ }));
+ }
+ async function clearAllRecovery(options = {}) {
+ const results = {};
+ for (const kind of Object.keys(RECOVERY_KEYS)) {
+ results[kind] = await clearRecovery(kind, options);
+ }
+ return results;
+ }
+ function recoveryIdSet(source, kind) {
+ const values = source && Array.isArray(source[kind]) ? source[kind] : [];
+ return new Set(values.map((value) => String(value || '').trim()).filter(Boolean));
+ }
+ async function cleanupRecoveryForRetry(options = {}) {
+ await ready;
+ const preserve = options.preserve && typeof options.preserve === 'object' ? options.preserve : {};
+ const discardable = options.discardable && typeof options.discardable === 'object' ? options.discardable : {};
+ const removedByKind = {};
+ const receipts = {};
+ let removedCount = 0;
+
+ for (const kind of Object.keys(RECOVERY_KEYS)) {
+ const key = recoveryKey(kind);
+ const preservedIds = recoveryIdSet(preserve, kind);
+ const discardableIds = recoveryIdSet(discardable, kind);
+ const result = await enqueueRecoveryMutation(key, () => retryMergeConflict({}, async () => {
+ const current = await readCollectionMeta(key);
+ const cutoff = Date.now() - RECOVERY_TTL_MS;
+ const removedIds = [];
+ const firstOwnersById = new Map();
+ current.items.forEach((item) => {
+ const entityId = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (entityId && !firstOwnersById.has(String(entityId))) {
+ firstOwnersById.set(String(entityId), item);
+ }
+ });
+ const retainedEntityIds = new Set();
+ firstOwnersById.forEach((owner, entityId) => {
+ if (preservedIds.has(entityId)) {
+ retainedEntityIds.add(entityId);
+ return;
+ }
+ const timestamp = recoveryTimestamp(owner);
+ const expired = timestamp !== null && timestamp <= cutoff;
+ const tombstone = owner && owner._recoveryTombstone === true;
+ const explicitlyDiscardable = !tombstone && discardableIds.has(entityId);
+ if (expired || explicitlyDiscardable) {
+ removedIds.push(entityId);
+ } else {
+ retainedEntityIds.add(entityId);
+ }
+ });
+ const retained = current.items.filter((item) => {
+ const id = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (id) return retainedEntityIds.has(String(id));
+ const timestamp = recoveryTimestamp(item);
+ const expired = timestamp !== null && timestamp <= cutoff;
+ const tombstone = item && item._recoveryTombstone === true;
+ if (tombstone && !expired) return true;
+ return !expired;
+ });
+ if (retained.length === current.items.length) {
+ return { receipt: null, removedIds: [] };
+ }
+ const receipt = await kernel.mutate([{
+ logicalKey: key,
+ data: retained,
+ expectedRevision: current.revision
+ }], {
+ operationId: randomId(`recovery-cleanup-${kind}`)
+ });
+ return { receipt, removedIds };
+ }));
+ removedByKind[kind] = result.removedIds;
+ removedCount += result.removedIds.length;
+ if (result.receipt) receipts[kind] = result.receipt;
+ }
+
+ return {
+ committed: true,
+ removedCount,
+ removedByKind,
+ receipts
+ };
+ }
+ const recovery = Object.freeze({
+ windowSession,
+ async clear(options = {}) { return clearAllRecovery(options); },
+ async cleanupForRetry(options = {}) { return cleanupRecoveryForRetry(options); },
+ async listActiveSessions() { return readRecovery('activeSession'); },
+ async getActiveSession(id) { return readRecovery('activeSession', id); },
+ async getActiveSessionFence(id) { return readRecoveryFence('activeSession', id); },
+ async saveActiveSession(value, options) { return saveRecovery('activeSession', value, options); },
+ async completeActiveSession(id, options) { return discardRecovery('activeSession', id, options); },
+ async discardActiveSession(id, options) { return discardRecovery('activeSession', id, options); },
+ async listDrafts() { return readRecovery('draft'); },
+ async getDraft(id) { return readRecovery('draft', id); },
+ async saveDraft(value, options) { return saveRecovery('draft', value, options); },
+ async discardDraft(id, options) { return discardRecovery('draft', id, options); },
+ async listInterrupted() { return readRecovery('interrupted'); },
+ async getInterrupted(id) { return readRecovery('interrupted', id); },
+ async saveInterrupted(value, options) { return saveRecovery('interrupted', value, options); },
+ async discardInterrupted(id, options) { return discardRecovery('interrupted', id, options); },
+ async listRejectedCompletions() { return readRecovery('rejectedCompletion'); },
+ async getRejectedCompletion(id) { return readRecovery('rejectedCompletion', id); },
+ async saveRejectedCompletion(value, options) { return saveRecovery('rejectedCompletion', value, options); },
+ async discardRejectedCompletion(id, options) { return discardRecovery('rejectedCompletion', id, options); }
+ });
+
+ function isImportableEntry(entry) {
+ return entry
+ && entry.classification !== 'system'
+ && entry.classification !== 'session'
+ && entry.import !== 'ignore';
+ }
+
+ function isPlainImportObject(value) {
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
+ }
+
+ function isV2SnapshotShape(parsed) {
+ return isPlainImportObject(parsed)
+ && parsed.format === 'ielts-atlas-data-v2'
+ && isPlainImportObject(parsed.envelopes)
+ && isPlainImportObject(parsed.entities);
+ }
+
+ const POISONED_V2_WRAPPER_ALIASES = Object.freeze({
+ 'settings.values': Object.freeze(['exam_system_settings', 'exam_system_user_settings', 'exam_system_system_settings']),
+ 'vocab.userConfig': Object.freeze(['exam_system_vocab_user_config']),
+ 'achievements.manual': Object.freeze(['exam_system_user_achievements', 'exam_system_achievement_manual_state'])
+ });
+ const LIBRARY_IMPORT_KEYS = Object.freeze([
+ 'library.configurations',
+ 'library.importedIndexes',
+ 'library.activeConfigurationId'
+ ]);
+
+ function canonicalizeV2Import(parsed) {
+ const warnings = [];
+ const repairedKeys = [];
+ const ignoredKeys = [];
+ const envelopes = {};
+ for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) {
+ if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`);
+ const envelope = clone(rawEnvelope);
+ const data = envelope && envelope.state === 'present' ? envelope.data : null;
+ if (logicalKey === 'library.activeConfigurationId' && String(data) === '[object Object]') {
+ ignoredKeys.push(logicalKey);
+ warnings.push('Skipped poisoned active library id');
+ continue;
+ }
+ if (isPlainImportObject(data)
+ && Object.prototype.hasOwnProperty.call(data, 'key')
+ && Object.prototype.hasOwnProperty.call(data, 'value')
+ && String(data.key || '').startsWith('exam_system_')) {
+ const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey] || [];
+ const decoded = aliases.includes(String(data.key)) ? internals.parseLegacyValue(data.value) : null;
+ if (!isPlainImportObject(decoded)) {
+ ignoredKeys.push(logicalKey);
+ warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`);
+ continue;
+ }
+ const overlay = Object.fromEntries(Object.entries(data)
+ .filter(([key]) => key !== 'key' && key !== 'value' && key !== 'timestamp'));
+ envelope.data = Object.assign({}, decoded, overlay);
+ envelope.checksum = checksum(envelope.data);
+ repairedKeys.push(logicalKey);
+ warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`);
+ }
+ envelopes[logicalKey] = envelope;
+ }
+
+ if (parsed.scope === 'full') {
+ const presentLibraryKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key));
+ if (presentLibraryKeys.length && presentLibraryKeys.length !== LIBRARY_IMPORT_KEYS.length) {
+ for (const key of presentLibraryKeys) {
+ delete envelopes[key];
+ ignoredKeys.push(key);
+ }
+ warnings.push('Skipped incomplete library data');
+ }
+ }
+ const exportableKeys = catalog.list()
+ .filter((entry) => entry.export === true && isImportableEntry(entry))
+ .map((entry) => entry.logicalKey);
+ const missingKeys = parsed.scope === 'full'
+ ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key))
+ : [];
+ const degraded = parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length);
+ return {
+ envelopes,
+ warnings,
+ repairedKeys,
+ ignoredKeys,
+ missingKeys,
+ declaredScope: parsed.scope,
+ effectiveScope: degraded ? 'partial' : parsed.scope,
+ trust: degraded ? 'degraded-partial' : (parsed.scope === 'full' ? 'trusted-full' : 'partial')
+ };
+ }
+
+ function resolveImportReplaceFlags(options = {}) {
+ const source = asObject(options);
+ const practiceMode = String(source.practiceMode || source.mergeMode || '').toLowerCase();
+ const replaceAll = source.replace === true;
+ return {
+ replaceDocuments: replaceAll,
+ // Call sites (practiceRecorder / boot-fallbacks) pass practiceMode replace|merge.
+ replacePractice: replaceAll || practiceMode === 'replace'
+ };
+ }
+
+ function pickFirstRecordArray(candidates) {
+ for (const candidate of asArray(candidates)) {
+ if (Array.isArray(candidate.records) && candidate.records.some(isPlainImportObject)) {
+ return { source: candidate.source, records: candidate.records };
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Historical v1 export shapes (opensource / pre-AppData-v2):
+ * - practiceRecorder.exportData: { exportDate, version, practiceRecords, userStats }
+ * - DataBackupManager: { exportInfo, practiceRecords, userStats?, backups? }
+ * - BackupAPI dual schema: practice_records / practiceRecords (+ nested data.*)
+ * - bare array of records, or { records: [...] }
+ * Recognition only — no dual backend and no local store migration.
+ */
+ function extractLegacyPracticeRecords(payload) {
+ const sources = [];
+ const add = (source, records) => {
+ if (Array.isArray(records) && records.some(isPlainImportObject)) {
+ sources.push({ source, records });
+ }
+ };
+
+ if (Array.isArray(payload)) {
+ add('(root array)', payload);
+ } else if (isPlainImportObject(payload)) {
+ const preferred = pickFirstRecordArray([
+ { source: 'practice_records', records: payload.practice_records },
+ { source: 'practiceRecords', records: payload.practiceRecords },
+ { source: 'records', records: payload.records }
+ ]);
+ if (preferred) add(preferred.source, preferred.records);
+
+ const data = isPlainImportObject(payload.data) ? payload.data : null;
+ if (data) {
+ const nested = pickFirstRecordArray([
+ { source: 'data.practice_records', records: data.practice_records },
+ { source: 'data.practiceRecords', records: data.practiceRecords }
+ ]);
+ if (nested) add(nested.source, nested.records);
+ else if (isPlainImportObject(data.practice_records)) add('data.practice_records.data', data.practice_records.data);
+ else if (isPlainImportObject(data.practiceRecords)) add('data.practiceRecords.data', data.practiceRecords.data);
+ if (isPlainImportObject(data.exam_system_practice_records)) {
+ add('data.exam_system_practice_records.data', data.exam_system_practice_records.data);
+ }
+ }
+ if (isPlainImportObject(payload.exam_system_practice_records)) {
+ add('exam_system_practice_records.data', payload.exam_system_practice_records.data);
+ }
+ }
+
+ const seen = new Set();
+ const records = [];
+ for (const entry of sources) {
+ for (const item of asArray(entry.records)) {
+ if (!isPlainImportObject(item)) continue;
+ const identity = idOf(item, ['id', 'recordId', 'sessionId']);
+ if (identity) {
+ if (seen.has(identity)) continue;
+ seen.add(identity);
+ }
+ records.push(item);
+ }
+ }
+ return {
+ records,
+ sources: sources.map((entry) => entry.source)
+ };
+ }
+
+ function entityRowFromLayer(recordId, data, operationId) {
+ const payload = jsonValue(data, 'import practice entity');
+ return {
+ recordId: String(recordId),
+ revision: 1,
+ operationId: String(operationId || `import-${recordId}`),
+ updatedAt: nowIso(),
+ data: payload,
+ checksum: checksum(payload)
+ };
+ }
+
+ function convertLegacyPracticeImport(payload) {
+ const extracted = extractLegacyPracticeRecords(payload);
+ if (!extracted.records.length) {
+ throw new AppDataError(
+ 'VALIDATION',
+ 'Import file is neither a v2 snapshot nor a recognizable v1 practice export'
+ );
+ }
+
+ const entities = {
+ practiceSummaries: [],
+ practiceDetails: [],
+ practiceAnnotations: []
+ };
+ const warnings = [];
+ let skipped = 0;
+
+ for (const raw of extracted.records) {
+ try {
+ const layers = splitPracticeRecord(raw);
+ const recordId = layers.summary.id;
+ const operationId = `import-v1-${recordId}`;
+ entities.practiceSummaries.push(entityRowFromLayer(recordId, layers.summary, operationId));
+ entities.practiceDetails.push(entityRowFromLayer(recordId, layers.detail, operationId));
+ entities.practiceAnnotations.push(entityRowFromLayer(recordId, layers.annotations, operationId));
+ } catch (error) {
+ skipped += 1;
+ warnings.push(`Skipped invalid practice record: ${error && error.message ? error.message : error}`);
+ }
+ }
+
+ if (!entities.practiceSummaries.length) {
+ throw new AppDataError('VALIDATION', 'Import file practice records could not be normalized');
+ }
+
+ const accepted = entities.practiceSummaries.length;
+ return {
+ format: 'v1',
+ scope: 'partial',
+ envelopes: {},
+ entities,
+ checksum: null,
+ warnings,
+ practiceSummary: {
+ accepted,
+ importedCount: accepted,
+ skippedCount: skipped,
+ sources: extracted.sources.slice()
+ }
+ };
+ }
+
+ function parseImportPayload(payload) {
+ let parsed;
+ try { parsed = typeof payload === 'string' ? JSON.parse(payload) : jsonValue(payload, 'import payload'); }
+ catch (error) {
+ if (error instanceof AppDataError) throw error;
+ throw new AppDataError('VALIDATION', 'Import payload is not valid JSON', { cause: error && error.message });
+ }
+
+ // Bare record arrays are a historical import convenience (UI file pickers).
+ if (Array.isArray(parsed)) return convertLegacyPracticeImport(parsed);
+ if (!parsed || typeof parsed !== 'object') throw new AppDataError('VALIDATION', 'Import payload must be an object');
+
+ if (isV2SnapshotShape(parsed)) {
+ if (Number(parsed.schemaVersion) !== Number(catalog.version)) {
+ throw new AppDataError('VALIDATION', 'Import schema version mismatch');
+ }
+ if (!parsed.checksum || parsed.checksum !== checksum({ envelopes: parsed.envelopes, entities: parsed.entities })) {
+ throw new AppDataError('VALIDATION', 'Import checksum mismatch');
+ }
+ if (parsed.scope !== 'full' && parsed.scope !== 'partial') {
+ throw new AppDataError('VALIDATION', 'Import scope must be full or partial');
+ }
+ const scope = parsed.scope;
+ for (const [store, rows] of Object.entries(parsed.entities)) {
+ if (!PRACTICE_ENTITY_STORES.includes(store) || !Array.isArray(rows)) {
+ throw new AppDataError('VALIDATION', `Invalid import entity store: ${store}`);
+ }
+ for (const row of rows) {
+ if (!row || typeof row !== 'object' || Array.isArray(row) || !String(row.recordId || '')) {
+ throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`);
+ }
+ }
+ }
+ if (scope === 'full' && PRACTICE_ENTITY_STORES.some((store) => !Object.prototype.hasOwnProperty.call(parsed.entities, store))) {
+ throw new AppDataError('VALIDATION', 'Full import is missing a practice entity layer');
+ }
+ const canonical = canonicalizeV2Import(parsed);
+ return {
+ format: 'v2',
+ scope: canonical.effectiveScope,
+ declaredScope: canonical.declaredScope,
+ envelopes: canonical.envelopes,
+ entities: parsed.entities,
+ checksum: parsed.checksum,
+ warnings: canonical.warnings,
+ practiceSummary: null,
+ repairedKeys: canonical.repairedKeys,
+ ignoredKeys: canonical.ignoredKeys,
+ missingKeys: canonical.missingKeys,
+ trust: canonical.trust
+ };
+ }
+
+ // Explicit but malformed v2 claims must not fall through to legacy parsers.
+ if (parsed.format === 'ielts-atlas-data-v2') {
+ throw new AppDataError('VALIDATION', 'Only valid v2 snapshots can be imported');
+ }
+
+ return convertLegacyPracticeImport(parsed);
+ }
+
+ function collectionIdentityFields(logicalKey) {
+ if (logicalKey === 'library.configurations') return ['id', 'key', 'configId'];
+ if (logicalKey.startsWith('recovery.')) return ['id', 'sessionId', 'recordId'];
+ if (logicalKey === 'backups.entries') return ['id'];
+ if (logicalKey === 'vocab.words') return ['id', 'word', 'key'];
+ if (logicalKey === 'goals.items') return ['id', 'goalId'];
+ return ['id', 'sessionId', 'recordId'];
+ }
+
+ function collectionIdentity(logicalKey, value) {
+ const identity = idOf(value, collectionIdentityFields(logicalKey));
+ return logicalKey === 'vocab.words' ? identity.trim().toLowerCase() : identity;
+ }
+
+ function mergeCollection(existing, incoming, logicalKey) {
+ const result = asArray(existing).map((item) => clone(item));
+ const positions = new Map();
+ result.forEach((item, index) => {
+ const identity = collectionIdentity(logicalKey, item);
+ if (identity) positions.set(identity, index);
+ });
+ for (const rawItem of asArray(incoming)) {
+ const item = jsonValue(rawItem, `${logicalKey} item`);
+ const identity = collectionIdentity(logicalKey, item);
+ if (!identity) throw new AppDataError('VALIDATION', `${logicalKey} import item has no stable identity`);
+ if (positions.has(identity)) result[positions.get(identity)] = item;
+ else {
+ positions.set(identity, result.length);
+ result.push(item);
+ }
+ }
+ return result;
+ }
+
+ function mergeImportValue(entry, existing, incoming) {
+ const policy = entry.import;
+ if (policy === 'merge-by-id') return mergeCollection(existing, incoming, entry.logicalKey);
+ if (policy === 'patch') {
+ if (Array.isArray(existing) || Array.isArray(incoming)) {
+ // Array-shaped keys should use merge-by-id; treat accidental patch as replace.
+ return clone(incoming);
+ }
+ return Object.assign({}, asObject(existing), asObject(incoming));
+ }
+ if (policy === 'replace') return clone(incoming);
+ throw new AppDataError('VALIDATION', `Unsupported import policy for ${entry.logicalKey}: ${policy}`);
+ }
+
+ async function currentEntitySnapshot() {
+ const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true });
+ const result = {};
+ for (const store of PRACTICE_ENTITY_STORES) {
+ if (store === 'practiceSummaries') result[store] = summaries;
+ else result[store] = (await Promise.all(summaries.map((summary) => kernel.readEntity(store, summary.recordId, { withMeta: true })))).filter(Boolean);
+ }
+ return result;
+ }
+ function practiceEntityIds(rows) {
+ return new Set(asArray(rows).map((row) => String(row && row.recordId || '')).filter(Boolean));
+ }
+ function assertPracticeEntitySetsMatch(entities, message) {
+ const expected = practiceEntityIds(entities.practiceSummaries);
+ for (const store of PRACTICE_ENTITY_STORES.slice(1)) {
+ const actual = practiceEntityIds(entities[store]);
+ if (actual.size !== expected.size || Array.from(expected).some((recordId) => !actual.has(recordId))) {
+ throw new AppDataError('VALIDATION', message || 'Practice import entity layers must contain the same recordIds', {
+ counts: Object.fromEntries(PRACTICE_ENTITY_STORES.map((name) => [name, practiceEntityIds(entities[name]).size]))
+ });
+ }
+ }
+ }
+ async function createImportPlan(parsed, options = {}) {
+ const { replaceDocuments, replacePractice } = resolveImportReplaceFlags(options);
+ const snapshot = { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: parsed.scope, envelopes: {}, entities: {} };
+ const revisionToken = { documents: {}, entities: {} };
+ const keys = []; const clearedKeys = [];
+ const warnings = asArray(parsed.warnings).map(String);
+ for (const [logicalKey, envelope] of Object.entries(asObject(parsed.envelopes))) {
+ if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`);
+ const entry = catalog.get(logicalKey); if (!isImportableEntry(entry)) continue;
+ if (!internals.validateEnvelope(entry, envelope)) throw new AppDataError('VALIDATION', `Invalid import envelope: ${logicalKey}`);
+ if (envelope.state === 'cleared' && !replaceDocuments && options.applyClears !== true) {
+ warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`);
+ continue;
+ }
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ revisionToken.documents[logicalKey] = current.envelope ? Number(current.envelope.revision) || 0 : 0;
+ let next = envelope;
+ if (!replaceDocuments && envelope.state === 'present') {
+ next = internals.makeEnvelope(entry, mergeImportValue(entry, current.data, envelope.data), { operationId: randomId('import-merge') });
+ }
+ snapshot.envelopes[logicalKey] = next;
+ keys.push(logicalKey);
+ if (next.state === 'cleared') clearedKeys.push(logicalKey);
+ }
+
+ // A full replace mirrors all exportable user data. Missing physical
+ // envelopes mean catalog defaults, represented here as explicit clears.
+ if (replaceDocuments && parsed.scope === 'full') {
+ for (const entry of catalog.list().filter((candidate) => candidate.export === true && isImportableEntry(candidate))) {
+ if (Object.prototype.hasOwnProperty.call(snapshot.envelopes, entry.logicalKey)) continue;
+ snapshot.envelopes[entry.logicalKey] = internals.makeEnvelope(entry, null, {
+ state: 'cleared',
+ operationId: randomId('import-clear')
+ });
+ keys.push(entry.logicalKey);
+ clearedKeys.push(entry.logicalKey);
+ }
+ }
+
+ // Any successful practice import installs all three stores together. Merge
+ // may update a subset only when the final recordId sets remain identical.
+ const sourceStores = Object.keys(asObject(parsed.entities));
+ let practiceExistingCount = null;
+ let practiceIncomingCount = null;
+ if (sourceStores.length) {
+ if (replacePractice && PRACTICE_ENTITY_STORES.some((store) => !sourceStores.includes(store))) {
+ throw new AppDataError('VALIDATION', 'Practice replace requires summaries, details, and annotations');
+ }
+ const current = await currentEntitySnapshot();
+ revisionToken.entities = Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, Object.fromEntries(
+ asArray(current[store]).map((row) => [String(row.recordId), Number(row.revision) || 0])
+ )]));
+ practiceExistingCount = asArray(current.practiceSummaries).length;
+ practiceIncomingCount = asArray(parsed.entities.practiceSummaries).length;
+ const existing = replacePractice
+ ? Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, []]))
+ : current;
+ for (const store of PRACTICE_ENTITY_STORES) {
+ const rows = asArray(existing[store]).map(clone);
+ const positions = new Map(rows.map((row, index) => [String(row.recordId), index]));
+ for (const row of asArray(parsed.entities[store])) {
+ if (!row || !String(row.recordId || '')) throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`);
+ const index = positions.get(String(row.recordId));
+ if (index === undefined) {
+ positions.set(String(row.recordId), rows.length);
+ rows.push(clone(row));
+ } else rows[index] = clone(row);
+ }
+ snapshot.entities[store] = rows;
+ }
+ assertPracticeEntitySetsMatch(snapshot.entities);
+ }
+
+ snapshot.checksum = checksum({ envelopes: snapshot.envelopes, entities: snapshot.entities });
+ const practiceSummary = parsed.practiceSummary
+ ? clone(parsed.practiceSummary)
+ : (Object.prototype.hasOwnProperty.call(snapshot.entities, 'practiceSummaries')
+ ? {
+ accepted: Number(practiceIncomingCount) || 0,
+ importedCount: Number(practiceIncomingCount) || 0,
+ skippedCount: 0,
+ existingCount: Number(practiceExistingCount) || 0,
+ incomingCount: Number(practiceIncomingCount) || 0,
+ finalCount: asArray(snapshot.entities.practiceSummaries).length,
+ removedCount: Math.max(0, (Number(practiceExistingCount) || 0)
+ - asArray(snapshot.entities.practiceSummaries).length)
+ }
+ : null);
+ if (practiceSummary && practiceSummary.existingCount === undefined) {
+ practiceSummary.existingCount = Number(practiceExistingCount) || 0;
+ practiceSummary.incomingCount = Number(practiceIncomingCount) || Number(practiceSummary.importedCount) || 0;
+ practiceSummary.finalCount = asArray(snapshot.entities.practiceSummaries).length;
+ practiceSummary.removedCount = Math.max(0, practiceSummary.existingCount - practiceSummary.finalCount);
+ }
+ const destructive = clearedKeys.length > 0
+ || Boolean(practiceSummary && Number(practiceSummary.removedCount) > 0);
+ return {
+ snapshot,
+ keys,
+ clearedKeys,
+ warnings,
+ practiceSummary,
+ destructive,
+ resetJournal: replaceDocuments && replacePractice,
+ revisionToken,
+ diagnostics: {
+ format: parsed.format,
+ replaceDocuments,
+ replacePractice,
+ declaredScope: parsed.declaredScope || parsed.scope,
+ effectiveScope: parsed.scope,
+ trust: parsed.trust || (parsed.format === 'v2' ? 'trusted-full' : 'degraded-partial'),
+ missingKeys: clone(parsed.missingKeys || []),
+ repairedKeys: clone(parsed.repairedKeys || []),
+ ignoredKeys: clone(parsed.ignoredKeys || [])
+ }
+ };
+ }
+ async function createRestoreSnapshot(backup) {
+ const parsed = parseImportPayload(asObject(backup && backup.data));
+ if (parsed.format !== 'v2') throw new AppDataError('VALIDATION', 'Only v2 snapshots can be restored from local backups');
+ if (backup.checksum && backup.checksum !== parsed.checksum) throw new AppDataError('VALIDATION', 'Backup checksum mismatch');
+ return (await createImportPlan(parsed, { replace: true })).snapshot;
+ }
+
+ const backups = Object.freeze({
+ onDataCommitted(listener) { return kernel.onCommitted(listener); },
+ async getSettings() { await ready; return kernel.read('backups.settings'); },
+ async setSettings(values, options = {}) { await ready; const current = await kernel.read('backups.settings', { withMeta: true }); return kernel.mutate([{ logicalKey: 'backups.settings', data: asObject(values), expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'backup-settings', values)); },
+ async getExportHistory() { await ready; return kernel.read('backups.exportHistory'); },
+ async getImportHistory() { await ready; return kernel.read('backups.importHistory'); },
+ async recordExport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.exportHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup export history entry'))); return kernel.mutate([{ logicalKey: 'backups.exportHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-export-history', entry)); },
+ async recordImport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.importHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup import history entry'))); return kernel.mutate([{ logicalKey: 'backups.importHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-import-history', entry)); },
+ async create(options = {}) {
+ await ready; const current = await readCollectionMeta('backups.entries');
+ const mutation = optionsMutationOptions(options, 'backup-create', { id: options.id || null, type: options.type || 'manual' });
+ const backupId = options.id || (options.operationId ? `backup_${checksum({ operationId: String(options.operationId) }).replace(/[^a-z0-9]/gi, '')}` : randomId('backup'));
+ const existing = current.items.find((item) => String(item.id) === String(backupId));
+ if (existing) {
+ if (String(existing.operationId || '') === String(mutation.operationId)
+ && String(existing.type || 'manual') === String(options.type || 'manual')) {
+ return clone(existing);
+ }
+ throw new AppDataError('CONFLICT', `Backup id already exists: ${backupId}`, {
+ backupId: String(backupId)
+ });
+ }
+ const snapshot = await kernel.exportSnapshot();
+ const backup = { id: backupId, operationId: mutation.operationId, timestamp: nowIso(), type: options.type || 'manual', version: 2, data: snapshot, size: JSON.stringify(snapshot).length, checksum: snapshot.checksum };
+ current.items.unshift(backup);
+ current.items = retainBackupEntries(current.items, 20, options.preserveIds);
+ await kernel.mutate([{ logicalKey: 'backups.entries', data: current.items, expectedRevision: current.revision }], mutation);
+ const committed = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(backupId));
+ return clone(committed || backup);
+ },
+ async list() { await ready; return kernel.read('backups.entries'); },
+ async delete(id, options = {}) { await ready; const current = await readCollectionMeta('backups.entries'); return kernel.mutate([{ logicalKey: 'backups.entries', data: current.items.filter((item) => String(item.id) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-delete', { id: String(id) })); },
+ async export(options = {}) {
+ await ready;
+ if (options.backupId !== undefined && options.backupId !== null) {
+ const backupId = String(options.backupId);
+ const stored = asArray(await kernel.read('backups.entries'))
+ .find((item) => String(item && item.id) === backupId);
+ if (!stored) throw new AppDataError('VALIDATION', `Unknown backup: ${backupId}`);
+ const portable = jsonValue(stored, 'stored backup export');
+ if (!portable.data || !portable.checksum || portable.checksum !== portable.data.checksum) {
+ throw new AppDataError('VALIDATION', `Backup checksum mismatch: ${backupId}`);
+ }
+ return portable;
+ }
+ const domains = Array.isArray(options.domains) ? new Set(options.domains.map(String)) : null;
+ const logicalKeys = domains
+ ? catalog.list()
+ .filter((entry) => domains.has(entry.owner) && entry.export === true)
+ .map((entry) => entry.logicalKey)
+ : null;
+ const entityStores = !domains || domains.has('practice')
+ ? undefined
+ : [];
+ return kernel.exportSnapshot(Object.assign(
+ logicalKeys ? { logicalKeys } : {},
+ entityStores ? { entityStores } : {}
+ ));
+ },
+ async previewImport(payload, options = {}) {
+ await ready; const parsed = parseImportPayload(payload); const prepared = await createImportPlan(parsed, options); const planId = randomId('import-plan');
+ const cutoff = Date.now() - (30 * 60 * 1000);
+ for (const [id, existing] of importPlans) {
+ if (Date.parse(existing.createdAt) < cutoff || importPlans.size >= 20) importPlans.delete(id);
+ }
+ const plan = { id: planId, format: parsed.format, scope: parsed.scope, keys: prepared.keys, clearedKeys: prepared.clearedKeys, warnings: prepared.warnings, createdAt: nowIso(), snapshot: prepared.snapshot, practiceSummary: prepared.practiceSummary, diagnostics: prepared.diagnostics, destructive: prepared.destructive, resetJournal: prepared.resetJournal, revisionToken: prepared.revisionToken, signature: checksum(prepared.snapshot) };
+ importPlans.set(planId, plan); return { id: planId, format: plan.format, scope: plan.scope, keys: plan.keys, clearedKeys: clone(plan.clearedKeys), warnings: clone(plan.warnings), createdAt: plan.createdAt, practice: clone(plan.practiceSummary), diagnostics: clone(plan.diagnostics), destructive: plan.destructive };
+ },
+ async commitImport(planId, options = {}) {
+ await ready; const plan = importPlans.get(String(planId)); if (!plan) throw new AppDataError('VALIDATION', `Unknown import plan: ${planId}`);
+ if (plan.destructive && options.confirmDestructive !== true) {
+ throw new AppDataError('VALIDATION', 'Destructive import requires explicit confirmation');
+ }
+ const mutation = optionsMutationOptions(options, 'import-commit', {
+ planId: plan.id,
+ signature: plan.signature
+ }, { warnings: plan.warnings });
+ const receipt = await kernel.installSnapshot(plan.snapshot, Object.assign({}, mutation, {
+ resetJournal: plan.resetJournal === true,
+ expectedRevisionToken: plan.revisionToken
+ }));
+ importPlans.delete(String(planId));
+ return Object.assign({}, receipt, plan.practiceSummary || {}, { practice: clone(plan.practiceSummary) });
+ },
+ async restore(id, options = {}) {
+ await ready; const backup = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(id));
+ if (!backup) throw new AppDataError('VALIDATION', `Unknown backup: ${id}`);
+ const snapshot = await createRestoreSnapshot(backup);
+ const restoreMutation = optionsMutationOptions(options, 'backup-restore', {
+ backupId: String(id),
+ checksum: backup.checksum || checksum(backup.data)
+ }, { resetJournal: true });
+ const preRestoreOperationId = `${restoreMutation.operationId}:pre-restore`;
+ const preRestoreBackupId = `pre_restore_${checksum({
+ operationId: restoreMutation.operationId,
+ backupId: String(id),
+ checksum: backup.checksum || checksum(backup.data)
+ }).replace(/[^a-z0-9]/gi, '')}`;
+ const preRestoreBackup = await backups.create({
+ id: preRestoreBackupId,
+ operationId: preRestoreOperationId,
+ type: 'pre-restore',
+ preserveIds: [String(id)]
+ });
+ const receipt = await kernel.installSnapshot(snapshot, restoreMutation);
+ return Object.assign({}, receipt, { preRestoreBackupId: preRestoreBackup.id });
+ }
+ });
+
+ let vocabMutationTail = Promise.resolve();
+ function enqueueVocabMutation(task) {
+ const result = vocabMutationTail.then(task, task);
+ vocabMutationTail = result.catch(() => undefined);
+ return result;
+ }
+ function retryVocabMutation(options, task) {
+ return enqueueVocabMutation(() => retryMergeConflict(options, task));
+ }
+
+ const vocab = Object.freeze({
+ async listWords() { await ready; return kernel.read('vocab.words'); },
+ async saveWords(words, options = {}) {
+ await ready; assertArray(words, 'vocab.saveWords requires an array');
+ const mutation = optionsMutationOptions(options, 'vocab-words', words);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.words', { withMeta: true });
+ return kernel.mutate([{
+ logicalKey: 'vocab.words',
+ data: words,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async getConfig() { await ready; return kernel.read('vocab.userConfig'); },
+ async setConfig(config, options = {}) {
+ await ready;
+ const mutation = optionsMutationOptions(options, 'vocab-config', config);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.userConfig', { withMeta: true });
+ return kernel.mutate([{
+ logicalKey: 'vocab.userConfig',
+ data: asObject(config),
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async patchConfig(patch, options = {}) {
+ await ready; assertObject(patch, 'vocab.patchConfig requires an object');
+ const mutation = optionsMutationOptions(options, 'vocab-config-patch', patch);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.userConfig', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), clone(patch));
+ return kernel.mutate([{
+ logicalKey: 'vocab.userConfig',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async activateList(listId, options = {}) { return this.patchConfig({ activeListId: String(listId || 'default') }, options); },
+ async listCollections() { await ready; return kernel.read('vocab.lists'); },
+ async saveCollection(id, value, options = {}) {
+ await ready;
+ const collectionId = String(id);
+ const mutation = optionsMutationOptions(options, 'vocab-list', { id: collectionId, value });
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) });
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async saveCollections(values, options = {}) {
+ await ready;
+ assertObject(values, 'vocab.saveCollections requires an object');
+ const upserts = Object.fromEntries(Object.entries(values).map(([id, value]) => [String(id), clone(value)]));
+ const mutation = optionsMutationOptions(options, 'vocab-lists-batch', upserts);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), upserts);
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async upsertCollectionWord(collectionId, word, options = {}) {
+ await ready; assertObject(word, 'vocab.upsertCollectionWord requires a word');
+ const id = String(collectionId || '');
+ if (!id) throw new AppDataError('VALIDATION', 'vocab collection id is required');
+ const identity = String(word.word || word.id || '').trim().toLowerCase();
+ if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required');
+ const mutation = optionsMutationOptions(options, 'vocab-word', { collectionId: id, word });
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const collections = Object.assign({}, asObject(current.data));
+ const existing = collections[id];
+ const list = existing && typeof existing === 'object' && !Array.isArray(existing)
+ ? Object.assign({}, clone(existing), { words: asArray(existing.words) })
+ : { id, words: asArray(existing) };
+ const index = list.words.findIndex((item) => String(item && (item.word || item.id) || '').trim().toLowerCase() === identity);
+ const nextWord = Object.assign({}, index >= 0 ? list.words[index] : {}, clone(word), { updatedAt: word.updatedAt || nowIso() });
+ if (!nextWord.createdAt) nextWord.createdAt = nextWord.updatedAt;
+ if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord);
+ list.updatedAt = nowIso();
+ collections[id] = list;
+ const receipt = await kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: collections,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { word: clone(nextWord) });
+ });
+ },
+ async readList(listId) { await ready; const id = String(listId || 'default'); if (id === 'default') return kernel.read('vocab.words'); const collections = await kernel.read('vocab.lists'); return Object.prototype.hasOwnProperty.call(collections, id) ? clone(collections[id]) : null; },
+ async replaceListWords(command, options = {}) {
+ await ready; assertObject(command, 'vocab.replaceListWords requires a command');
+ const id = String(command.listId || 'default'); const words = asArray(command.words);
+ if (id === 'default') return this.saveWords(words, options);
+ const mutation = optionsMutationOptions(options, 'vocab-list-words-replace', command);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const collections = Object.assign({}, asObject(current.data));
+ collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() });
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: collections,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async mergeListWords(command, options = {}) {
+ await ready;
+ assertObject(command, 'vocab.mergeListWords requires a command');
+ const listId = String(command.listId || 'default');
+ const incoming = asArray(command.words);
+ const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists';
+ const mutation = optionsMutationOptions(options, 'vocab-words-merge', command);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const collections = listId === 'default' ? null : Object.assign({}, asObject(current.data));
+ const storedList = listId === 'default'
+ ? asArray(current.data)
+ : (function readStoredCollection() {
+ const collection = collections[listId];
+ return collection && typeof collection === 'object' && !Array.isArray(collection)
+ ? asArray(collection.words)
+ : asArray(collection);
+ }());
+ const merged = storedList.map((word) => clone(word));
+ const positions = new Map();
+ merged.forEach((word, index) => {
+ const identity = String(word && (word.word || word.id) || '').trim().toLowerCase();
+ if (identity) positions.set(identity, index);
+ });
+ let addedCount = 0;
+ let updatedCount = 0;
+ for (const rawWord of incoming) {
+ assertObject(rawWord, 'vocab.mergeListWords entries must be objects');
+ const identity = String(rawWord.word || rawWord.id || '').trim().toLowerCase();
+ if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required');
+ if (!positions.has(identity)) {
+ positions.set(identity, merged.length);
+ merged.push(clone(rawWord));
+ addedCount += 1;
+ continue;
+ }
+ const index = positions.get(identity);
+ const existing = asObject(merged[index]);
+ const patch = {};
+ if (typeof rawWord.meaning === 'string' && rawWord.meaning.trim()) patch.meaning = rawWord.meaning.trim();
+ if (typeof rawWord.example === 'string' && rawWord.example.trim()) patch.example = rawWord.example.trim();
+ if (typeof rawWord.freq === 'number' && Number.isFinite(rawWord.freq)) patch.freq = rawWord.freq;
+ merged[index] = Object.assign({}, existing, patch, { updatedAt: nowIso() });
+ updatedCount += 1;
+ }
+ const data = listId === 'default'
+ ? merged
+ : Object.assign({}, collections, {
+ [listId]: Object.assign(
+ {},
+ (function collectionBaseForWrite() {
+ const collection = collections[listId];
+ return collection && typeof collection === 'object' && !Array.isArray(collection)
+ ? clone(collection)
+ : {};
+ }()),
+ { id: listId, words: merged, updatedAt: nowIso() }
+ )
+ });
+ const receipt = await kernel.mutate([{
+ logicalKey,
+ data,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { listId, words: clone(merged), addedCount, updatedCount });
+ });
+ },
+ async patchWord(command, options = {}) {
+ await ready; assertObject(command, 'vocab.patchWord requires a command');
+ const listId = String(command.listId || 'default'); const wordId = String(command.wordId || command.id || '');
+ if (!wordId) throw new AppDataError('VALIDATION', 'vocab word id is required');
+ const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists';
+ const mutation = optionsMutationOptions(
+ Object.assign({}, options, { operationId: command.operationId || options.operationId }),
+ 'vocab-word-patch',
+ command
+ );
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const collections = listId === 'default' ? null : asObject(current.data);
+ const list = listId === 'default'
+ ? asArray(current.data)
+ : asArray(asObject(collections[listId]).words);
+ const index = list.findIndex((word) => idOf(word, ['id', 'word', 'key']) === wordId);
+ if (index < 0) throw new AppDataError('VALIDATION', `Unknown vocab word: ${wordId}`);
+ const updated = Object.assign({}, list[index], clone(asObject(command.patch)), { id: list[index].id || wordId, updatedAt: nowIso() });
+ const next = list.slice(); next[index] = updated;
+ const data = listId === 'default'
+ ? next
+ : Object.assign({}, collections, {
+ [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() })
+ });
+ const receipt = await kernel.mutate([{
+ logicalKey,
+ data,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { word: clone(updated) });
+ });
+ },
+ async replaceProgress(command, options = {}) {
+ await ready; assertObject(command, 'vocab.replaceProgress requires a command');
+ const listId = String(command.listId || 'default'); const words = asArray(command.words);
+ const mutation = optionsMutationOptions(options, 'vocab-progress', command);
+ return retryVocabMutation(options, async () => {
+ const configMeta = await kernel.read('vocab.userConfig', { withMeta: true });
+ const changes = [{
+ logicalKey: 'vocab.userConfig',
+ data: Object.assign({}, asObject(configMeta.data), asObject(command.config), { activeListId: listId }),
+ expectedRevision: configMeta.envelope ? configMeta.envelope.revision : 0
+ }];
+ if (listId === 'default') {
+ const wordsMeta = await kernel.read('vocab.words', { withMeta: true });
+ changes.push({ logicalKey: 'vocab.words', data: words, expectedRevision: wordsMeta.envelope ? wordsMeta.envelope.revision : 0 });
+ } else {
+ const listsMeta = await kernel.read('vocab.lists', { withMeta: true }); const lists = Object.assign({}, asObject(listsMeta.data));
+ lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words });
+ changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 });
+ }
+ return kernel.mutate(changes, mutation);
+ });
+ }
+ });
+
+ async function readPreferences() { await ready; return kernel.read('preferences.values'); }
+ let preferenceMutationTail = Promise.resolve();
+ function enqueuePreferenceMutation(task) {
+ const result = preferenceMutationTail.then(task, task);
+ preferenceMutationTail = result.catch(() => undefined);
+ return result;
+ }
+ async function writePreference(field, value, options = {}) {
+ const mutation = optionsMutationOptions(options, 'preference-set', { field, value });
+ return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => {
+ const current = await kernel.read('preferences.values', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), { [field]: clone(value) });
+ return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ }));
+ }
+ async function patchPreference(field, patch, options = {}) {
+ await ready;
+ const mutation = optionsMutationOptions(options, 'preference-patch', { field, patch });
+ return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => {
+ const current = await kernel.read('preferences.values', { withMeta: true });
+ const values = asObject(current.data);
+ const next = Object.assign({}, values, { [field]: Object.assign({}, asObject(values[field]), asObject(patch)) });
+ return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ }));
+ }
+ const preferences = Object.freeze({
+ async getAll() { return readPreferences(); },
+ async getTheme() { return (await readPreferences())[PREFERENCE_FIELDS.theme] ?? null; }, async setTheme(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.theme, value, options); },
+ async getBrowse() { return clone((await readPreferences())[PREFERENCE_FIELDS.browse] ?? null); }, async setBrowse(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.browse, value, options); }, async patchBrowse(value, options) { return patchPreference(PREFERENCE_FIELDS.browse, value, options); },
+ async getTimer(scope) { const timer = clone((await readPreferences())[PREFERENCE_FIELDS.timer] ?? {}); return scope ? clone(timer[String(scope)] ?? null) : timer; }, async setTimer(scope, value, options) { return patchPreference(PREFERENCE_FIELDS.timer, { [String(scope)]: clone(value) }, options); },
+ async getSuite() { return clone((await readPreferences())[PREFERENCE_FIELDS.suite] ?? null); }, async setSuite(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.suite, value, options); }, async patchSuite(value, options) { return patchPreference(PREFERENCE_FIELDS.suite, value, options); },
+ async getCandidateCode() { return (await readPreferences())[PREFERENCE_FIELDS.candidateCode] ?? null; }, async setCandidateCode(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.candidateCode, value, options); }
+ ,async getResourceBasePrefix() { return (await readPreferences())[PREFERENCE_FIELDS.resourceBasePrefix] ?? null; }, async setResourceBasePrefix(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.resourceBasePrefix, value, options); },
+ async getOnboarding() { return clone((await readPreferences())[PREFERENCE_FIELDS.onboarding] ?? {}); }, async setOnboarding(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.onboarding, asObject(value), options); },
+ async getReadingDisplay() { return clone((await readPreferences())[PREFERENCE_FIELDS.readingDisplay] ?? null); }, async setReadingDisplay(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.readingDisplay, value, options); },
+ async getThreeBackground() { return (await readPreferences())[PREFERENCE_FIELDS.threeBackground] ?? null; }, async setThreeBackground(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.threeBackground, value, options); },
+ async getThemePortal() { return clone((await readPreferences())[PREFERENCE_FIELDS.themePortal] ?? null); }, async setThemePortal(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.themePortal, value, options); },
+ async getPracticeWidget() { return (await readPreferences())[PREFERENCE_FIELDS.practiceWidget] ?? null; }, async setPracticeWidget(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.practiceWidget, value, options); },
+ async getConsent() { return clone((await readPreferences())[PREFERENCE_FIELDS.consent] ?? {}); }, async setConsent(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.consent, asObject(value), options); },
+ async getLogConfig() { return clone((await readPreferences())[PREFERENCE_FIELDS.logConfig] ?? null); }, async setLogConfig(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.logConfig, asObject(value), options); }
+ });
+
+ const goals = Object.freeze({
+ async list() { await ready; return kernel.read('goals.items'); },
+ async save(goal, options = {}) { await ready; assertObject(goal, 'goals.save requires an object'); const mutation = optionsMutationOptions(options, 'goal-save', goal); const current = await readCollectionMeta('goals.items'); const id = idOf(goal, ['id', 'goalId']) || deterministicEntityId('goal', mutation.operationId); const item = Object.assign({}, clone(goal), { id }); const index = current.items.findIndex((entry) => idOf(entry, ['id', 'goalId']) === id); if (index >= 0) current.items[index] = item; else current.items.push(item); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items, expectedRevision: current.revision }], mutation); },
+ async delete(id, options = {}) { await ready; const current = await readCollectionMeta('goals.items'); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items.filter((item) => idOf(item, ['id', 'goalId']) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'goal-delete', { id: String(id) })); }
+ });
+
+ function deliveryTimestamp(value) {
+ const candidate = value && typeof value === 'object' ? value.unlockedAt : value;
+ const time = typeof candidate === 'string' && candidate.trim() ? Date.parse(candidate) : NaN;
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
+ }
+
+ function mergeDeliveryAcknowledgements(current, incoming) {
+ const merged = Object.assign({}, asObject(current));
+ for (const [id, value] of Object.entries(asObject(incoming))) {
+ const key = String(id).trim();
+ if (!key) continue;
+ const previous = deliveryTimestamp(merged[key]);
+ const next = deliveryTimestamp(value);
+ if (!hasOwn(merged, key) || (next && (!previous || next < previous))) {
+ merged[key] = next;
+ } else if (previous) {
+ merged[key] = previous;
+ } else {
+ merged[key] = null;
+ }
+ }
+ return merged;
+ }
+
+ const achievements = Object.freeze({
+ async getAll() {
+ await ready;
+ const progress = await retryMergeConflict({}, async () => {
+ const [summaries, manual, current] = await Promise.all([
+ kernel.listEntities('practiceSummaries'),
+ kernel.read('achievements.manual'),
+ kernel.read('achievements.progress', { withMeta: true })
+ ]);
+ const projected = asObject(computeAchievementProgress(summaries, manual, current.data));
+ if (checksum(projected) !== checksum(asObject(current.data))) {
+ await kernel.mutate([{
+ logicalKey: 'achievements.progress',
+ data: projected,
+ expectedRevision: current.envelope ? Number(current.envelope.revision) : 0
+ }], {
+ operationId: `achievement-progress-${current.envelope ? Number(current.envelope.revision) : 0}-${checksum(projected)}`
+ });
+ }
+ return projected;
+ }, 5);
+ if (Object.prototype.hasOwnProperty.call(progress, 'fresh')) delete progress.fresh;
+ Object.defineProperty(progress, 'fresh', { value: true, enumerable: false });
+ return progress;
+ },
+ async retryPending() { return achievements.getAll(); },
+ async acknowledgeDelivery(unlocked, options = {}) {
+ await ready;
+ assertObject(unlocked, 'achievements.acknowledgeDelivery requires an object');
+ const requested = clone(unlocked);
+ const mutation = optionsMutationOptions(options, 'achievement-delivery-acknowledge', requested);
+ return retryMergeConflict({}, async () => {
+ const current = await kernel.read('settings.values', { withMeta: true });
+ const settingsValue = asObject(current.data);
+ const delivery = asObject(settingsValue.achievementDelivery);
+ const acknowledged = mergeDeliveryAcknowledgements(delivery.acknowledged, requested);
+ return kernel.mutate([{
+ logicalKey: 'settings.values',
+ data: Object.assign({}, settingsValue, {
+ achievementDelivery: { version: 1, acknowledged }
+ }),
+ expectedRevision: current.envelope ? Number(current.envelope.revision) : 0
+ }], mutation);
+ }, 5);
+ },
+ async getManualState() { await ready; return kernel.read('achievements.manual'); }
+ });
+
+ const LEGACY_DOCUMENT_ALIASES = Object.freeze({
+ 'settings.values': ['user_settings', 'settings', 'system_settings'],
+ 'recovery.activeSessions': ['active_sessions'], 'recovery.drafts': ['temp_practice_records'],
+ 'recovery.interrupted': ['interrupted_records'], 'recovery.rejectedCompletions': ['rejected_completion_payloads'],
+ 'backups.entries': ['manual_backups'], 'backups.settings': ['backup_settings'],
+ 'backups.exportHistory': ['export_history'], 'backups.importHistory': ['import_history'],
+ 'vocab.words': ['vocab_words'], 'vocab.userConfig': ['vocab_user_config'], 'vocab.lists': ['vocab_lists'],
+ 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'],
+ 'achievements.manual': ['achievement_manual_state', 'user_achievements']
+ });
+ const LEGACY_PREFERENCE_ALIASES = Object.freeze({
+ theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse',
+ practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode',
+ ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed'
+ });
+
+ function mergeLegacySources(indexedDbValue, externalValue) {
+ const indexedDb = asObject(indexedDbValue);
+ const external = asObject(externalValue);
+ const merged = Object.assign({}, external, indexedDb);
+ const records = new Map();
+ const addRecords = (value) => {
+ const list = Array.isArray(value) ? value : asArray(asObject(value).data);
+ list.forEach((record) => {
+ const id = idOf(record, ['id', 'recordId', 'sessionId']);
+ records.set(id ? `id:${id}` : `content:${checksum(record)}`, clone(record));
+ });
+ };
+ addRecords(external.practice_records || external.practiceRecords);
+ addRecords(indexedDb.practice_records);
+ if (records.size) merged.practice_records = Array.from(records.values());
+ return merged;
+ }
+
+ function legacyLibraryBundle(legacy) {
+ const idMap = new Map();
+ const indexes = {};
+ for (const [oldId, value] of Object.entries(asObject(legacy))) {
+ if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations' || !asArray(value).length) continue;
+ const id = `legacy-library-${checksum(oldId).replace(/^fnv1a-/, '')}`;
+ idMap.set(oldId, id);
+ indexes[id] = clone(value);
+ }
+ if (!idMap.size) return null;
+ const configurations = new Map();
+ asArray(legacy.exam_index_configurations).forEach((configuration) => {
+ const oldId = idOf(configuration, ['id', 'key', 'configId']);
+ const id = idMap.get(oldId);
+ if (id) configurations.set(id, Object.assign({}, clone(configuration), { id, key: id, examCount: indexes[id].length }));
+ });
+ for (const [oldId, id] of idMap) {
+ if (!configurations.has(id)) configurations.set(id, {
+ id,
+ key: id,
+ name: `迁移的自定义题库 (${oldId})`,
+ examCount: indexes[id].length,
+ sourceType: 'legacy-import'
+ });
+ }
+ return {
+ configurations: Array.from(configurations.values()),
+ indexes,
+ activeId: idMap.get(String(legacy.active_exam_index_key || '')) || null
+ };
+ }
+
+ async function migrateLegacyData() {
+ // Unit embedders may provide a deliberately minimal kernel bootstrap.
+ if (typeof internals.readLegacyValues !== 'function') return;
+ const migrationMeta = await kernel.read('system.migrations', { withMeta: true });
+ const migrationState = asObject(migrationMeta.data);
+ const v1Complete = asObject(migrationState.v1ToV2).status === 'complete';
+ const externalConsumed = asObject(migrationState.externalBackupV1).status === 'consumed';
+ let externalBackup = null;
+ if (!externalConsumed && typeof internals.readLegacyExternalBackup === 'function') {
+ try { externalBackup = await internals.readLegacyExternalBackup(); }
+ catch (error) {
+ if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message);
+ }
+ }
+ if (v1Complete && !externalBackup) return;
+
+ const indexedDb = await internals.readLegacyValues();
+ if (indexedDb && indexedDb.__legacyReadComplete === false) {
+ throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup');
+ }
+ const legacy = mergeLegacySources(indexedDb, externalBackup);
+ const changes = [];
+ for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) {
+ const current = await kernel.getEnvelope(logicalKey);
+ if (current) continue;
+ const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key));
+ if (alias) changes.push({ logicalKey, data: legacy[alias], expectedRevision: 0 });
+ }
+ const libraryBundle = legacyLibraryBundle(legacy);
+ if (libraryBundle) {
+ if (!(await kernel.getEnvelope('library.configurations'))) changes.push({ logicalKey: 'library.configurations', data: libraryBundle.configurations, expectedRevision: 0 });
+ if (!(await kernel.getEnvelope('library.importedIndexes'))) changes.push({ logicalKey: 'library.importedIndexes', data: libraryBundle.indexes, expectedRevision: 0 });
+ if (!(await kernel.getEnvelope('library.activeConfigurationId'))) changes.push({ logicalKey: 'library.activeConfigurationId', data: libraryBundle.activeId, expectedRevision: 0 });
+ }
+ if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) {
+ const preferences = {};
+ for (const [alias, target] of Object.entries(LEGACY_PREFERENCE_ALIASES)) {
+ if (!Object.prototype.hasOwnProperty.call(legacy, alias)) continue;
+ const path = target.split('.'); let cursor = preferences;
+ path.slice(0, -1).forEach((part) => { cursor[part] = asObject(cursor[part]); cursor = cursor[part]; });
+ cursor[path[path.length - 1]] = clone(legacy[alias]);
+ }
+ if (Object.keys(preferences).length) changes.push({ logicalKey: 'preferences.values', data: preferences, expectedRevision: 0 });
+ }
+ if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) {
+ changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 });
+ }
+ if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-${internals.checksum(changes)}` });
+ const recordsValue = legacy.practice_records;
+ const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data);
+ const operations = [];
+ for (const [index, record] of records.entries()) {
+ try {
+ const candidate = clone(record);
+ if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) candidate.id = `legacy_${index}_${internals.checksum(record)}`;
+ const canonical = canonicalizeRecord(candidate);
+ const parts = splitPracticeRecord(canonical);
+ for (const [store, data] of [
+ ['practiceSummaries', parts.summary],
+ ['practiceDetails', parts.detail],
+ ['practiceAnnotations', parts.annotations]
+ ]) {
+ if (!await kernel.readEntity(store, canonical.id)) {
+ operations.push({ type: 'upsert', store, recordId: canonical.id, data, expectedRevision: 0 });
+ }
+ }
+ } catch (error) {
+ if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message);
+ }
+ }
+ if (operations.length) {
+ await kernel.mutateEntities(operations, { operationId: `legacy-practice-${internals.checksum(records)}` });
+ }
+
+ const nextMigrationState = Object.assign({}, migrationState);
+ if (!v1Complete) nextMigrationState.v1ToV2 = {
+ version: 1,
+ status: 'complete',
+ completedAt: nowIso(),
+ sourceChecksum: checksum(indexedDb),
+ sourceRecordCount: asArray(indexedDb.practice_records).length
+ };
+ if (externalBackup) nextMigrationState.externalBackupV1 = {
+ version: 1,
+ status: 'consumed',
+ completedAt: nowIso(),
+ sourceChecksum: checksum(externalBackup)
+ };
+ await kernel.mutate([{
+ logicalKey: 'system.migrations',
+ data: nextMigrationState,
+ expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0
+ }], { operationId: `legacy-migration-${checksum(nextMigrationState)}` });
+ }
+
+ const ready = kernel.initialize()
+ .then(async () => {
+ // Legacy migration and recovery cleanup are best-effort: a failure here
+ // (e.g. one malformed v1 record) must not brick the data layer for every
+ // read that awaits `ready`. Only a genuine backend init failure below is fatal.
+ try {
+ await migrateLegacyData();
+ } catch (error) {
+ if (global.console && console.error) console.error('[AppData v2] legacy migration skipped:', error);
+ }
+ try {
+ await cleanupExpiredRecovery();
+ } catch (error) {
+ if (global.console && console.warn) console.warn('[AppData v2] recovery cleanup skipped:', error);
+ }
+ return true;
+ })
+ .catch((error) => {
+ if (global.console && console.error) console.error('[AppData v2] initialization blocked:', error);
+ throw error instanceof AppDataError ? error : new AppDataError('INITIALIZATION_BLOCKED', error && error.message || 'AppData v2 initialization failed');
+ });
+
+ const AppData = { practice, settings, library, recovery, backups, vocab, preferences, goals, achievements };
+ Object.defineProperties(AppData, {
+ ready: { value: ready, enumerable: false },
+ status: { value: () => kernel.status(), enumerable: false }
+ });
+ Object.freeze(AppData);
+ Object.defineProperty(global, 'AppData', { value: AppData, enumerable: true, configurable: false, writable: false });
+ if (!Reflect.deleteProperty(global, '__AppDataV2Internals')) {
+ throw new Error('AppData v2 failed to close its internal bootstrap channel');
+ }
+ if (!Reflect.deleteProperty(global, '__AppDataV2Catalog')) {
+ throw new Error('AppData v2 failed to close its catalog bootstrap channel');
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
/* ===== js/utils/answerMatchCore.js ===== */
(function initAnswerMatchCore(global) {
'use strict';
@@ -123,7 +3958,20 @@
function compareAnswers(userAnswer, correctAnswer) {
const expected = splitAnswerTokens(correctAnswer);
- const actual = splitAnswerTokens(userAnswer);
+ let actual = splitAnswerTokens(userAnswer);
+
+ if (
+ expected.length === 1
+ && /^[A-Z]$/.test(expected[0])
+ && actual.length === 1
+ && !/^[A-Z]$/.test(actual[0])
+ && typeof userAnswer === 'string'
+ ) {
+ const labeledOption = userAnswer.trim().match(/^([A-Z])\s+\S/);
+ if (labeledOption) {
+ actual = [labeledOption[1]];
+ }
+ }
if (expected.length === 0 && actual.length === 0) {
return null;
@@ -269,12 +4117,11 @@
// 错误缓存,用于临时存储检测到的错误
this.errorCache = new Map();
- // 词表存储键配置
- this.storageKeys = {
- p1: 'vocab_list_p1_errors',
- p4: 'vocab_list_p4_errors',
- master: 'vocab_list_master_errors',
- custom: 'vocab_list_custom'
+ this.collectionIds = {
+ p1: 'spelling-errors-p1',
+ p4: 'spelling-errors-p4',
+ master: 'spelling-errors-master',
+ custom: 'custom'
};
this.lexiconCache = null;
@@ -292,17 +4139,8 @@
*/
async init() {
try {
- // 等待存储系统就绪
- if (window.storage && window.storage.ready) {
- await window.storage.ready;
- }
-
- // 设置命名空间
- if (window.storage && typeof window.storage.setNamespace === 'function') {
- window.storage.setNamespace('exam_system');
- console.log('[SpellingErrorCollector] 存储命名空间已设置');
- }
-
+ if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab is unavailable');
+ await window.AppData.ready;
this.initialized = true;
console.log('[SpellingErrorCollector] 初始化完成');
} catch (error) {
@@ -670,14 +4508,9 @@
try {
await this.ensureInitialized();
- const storageKey = this.storageKeys[listId] || listId;
-
- if (!window.storage) {
- console.warn('[SpellingErrorCollector] 存储系统不可用');
- return null;
- }
-
- const list = await window.storage.get(storageKey);
+ const collectionId = this.collectionIds[listId] || listId;
+ const collections = await window.AppData.vocab.listCollections();
+ const list = collections[collectionId];
const normalizedList = this.normalizeVocabListShape(list, listId, listId);
if (normalizedList) {
@@ -689,7 +4522,7 @@
return null;
} catch (error) {
console.error(`[SpellingErrorCollector] 加载词表失败: ${listId}`, error);
- return null;
+ throw error;
}
}
@@ -701,31 +4534,10 @@
async saveVocabList(vocabList) {
try {
await this.ensureInitialized();
-
- if (!vocabList || !vocabList.id) {
- console.error('[SpellingErrorCollector] 无效的词表对象');
- return false;
- }
-
- if (!Array.isArray(vocabList.words)) {
- vocabList.words = [];
- }
-
- vocabList = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList;
-
- // 更新统计信息
- vocabList.stats = vocabList.stats || {};
- vocabList.stats.totalWords = vocabList.words.length;
- vocabList.updatedAt = Date.now();
-
- const storageKey = this.storageKeys[vocabList.id] || vocabList.id;
-
- if (!window.storage) {
- console.warn('[SpellingErrorCollector] 存储系统不可用');
- return false;
- }
-
- await window.storage.set(storageKey, vocabList);
+ vocabList = this.prepareVocabList(vocabList);
+ if (!vocabList) return false;
+ const collectionId = this.collectionIds[vocabList.id] || vocabList.id;
+ await window.AppData.vocab.saveCollection(collectionId, vocabList);
console.log(`[SpellingErrorCollector] 保存词表成功: ${vocabList.id}, 单词数: ${vocabList.words.length}`);
return true;
@@ -735,6 +4547,19 @@
}
}
+ prepareVocabList(vocabList) {
+ if (!vocabList || !vocabList.id) {
+ console.error('[SpellingErrorCollector] 无效的词表对象');
+ return null;
+ }
+ if (!Array.isArray(vocabList.words)) vocabList.words = [];
+ const normalized = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList;
+ normalized.stats = normalized.stats || {};
+ normalized.stats.totalWords = normalized.words.length;
+ normalized.updatedAt = Date.now();
+ return normalized;
+ }
+
/**
* 获取词表单词数量
* @param {string} listId - 词表ID
@@ -746,7 +4571,7 @@
return list ? list.words.length : 0;
} catch (error) {
console.error(`[SpellingErrorCollector] 获取词表单词数失败: ${listId}`, error);
- return 0;
+ throw error;
}
}
@@ -1316,17 +5141,25 @@
try {
await this.ensureInitialized();
await this.ensureCoreLexicon();
-
- // 按来源分组错误
const errorsBySource = this.groupErrorsBySource(errors);
-
- // 保存到各个来源的词表
+ const pendingCollections = {};
for (const [source, sourceErrors] of Object.entries(errorsBySource)) {
- await this.saveErrorsToList(source, sourceErrors);
+ let vocabList = await this.loadVocabList(source);
+ if (!vocabList) vocabList = this.createEmptyList(source, source);
+ this.mergeErrorsToList(vocabList, sourceErrors);
+ const prepared = this.prepareVocabList(vocabList);
+ if (!prepared) throw new Error(`生成 ${source} 错词词表失败`);
+ pendingCollections[this.collectionIds[source] || source] = prepared;
}
- // 同步到综合词表
- await this.syncToMasterList(errors);
+ let masterList = await this.loadVocabList('master');
+ if (!masterList) masterList = this.createEmptyList('master', 'all');
+ this.mergeErrorsToList(masterList, errors);
+ const preparedMaster = this.prepareVocabList(masterList);
+ if (!preparedMaster) throw new Error('生成综合错词词表失败');
+ pendingCollections[this.collectionIds.master] = preparedMaster;
+
+ await window.AppData.vocab.saveCollections(pendingCollections);
console.log(`[SpellingErrorCollector] 保存完成,共保存 ${errors.length} 个错误`);
return true;
@@ -1477,7 +5310,9 @@
);
if (vocabList.words.length < originalLength) {
- await this.saveVocabList(vocabList);
+ if (!await this.saveVocabList(vocabList)) {
+ return false;
+ }
console.log(`[SpellingErrorCollector] 从词表 ${listId} 移除单词: ${word}`);
return true;
} else {
@@ -1507,7 +5342,9 @@
vocabList.words = [];
vocabList.updatedAt = Date.now();
- await this.saveVocabList(vocabList);
+ if (!await this.saveVocabList(vocabList)) {
+ return false;
+ }
console.log(`[SpellingErrorCollector] 清空词表: ${listId}`);
return true;
@@ -1530,11 +5367,328 @@
})();
+/* ===== js/utils/safeObjectLiteralParser.js ===== */
+(function (root, factory) {
+ 'use strict';
+
+ var api = factory();
+ if (typeof module === 'object' && module.exports) {
+ module.exports = api;
+ }
+ if (root) {
+ root.SafeObjectLiteralParser = api;
+ }
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ 'use strict';
+
+ var DEFAULT_LIMITS = Object.freeze({
+ maxInputLength: 1024 * 1024,
+ maxDepth: 40,
+ maxProperties: 5000,
+ maxStringLength: 256 * 1024
+ });
+ function ParseError(message, index) {
+ this.name = 'SafeObjectLiteralParseError';
+ this.message = message + ' at index ' + index;
+ this.index = index;
+ if (Error.captureStackTrace) Error.captureStackTrace(this, ParseError);
+ }
+ ParseError.prototype = Object.create(Error.prototype);
+ ParseError.prototype.constructor = ParseError;
+
+ function makeLimits(options) {
+ options = options || {};
+ var limits = {};
+ Object.keys(DEFAULT_LIMITS).forEach(function (key) {
+ var configured = Number(options[key]);
+ limits[key] = Number.isFinite(configured) && configured > 0
+ ? Math.floor(configured)
+ : DEFAULT_LIMITS[key];
+ });
+ return limits;
+ }
+
+ function Parser(source, options) {
+ if (typeof source !== 'string') throw new TypeError('source must be a string');
+ this.source = source;
+ this.length = source.length;
+ this.index = 0;
+ this.depth = 0;
+ this.propertyCount = 0;
+ this.limits = makeLimits(options);
+ if (this.length > this.limits.maxInputLength) {
+ throw new ParseError('input exceeds maximum length', 0);
+ }
+ }
+
+ Parser.prototype.fail = function (message) {
+ throw new ParseError(message, this.index);
+ };
+
+ Parser.prototype.skipSpace = function () {
+ while (this.index < this.length) {
+ var ch = this.source.charAt(this.index);
+ if (/\s/.test(ch)) {
+ this.index++;
+ continue;
+ }
+ if (ch === '/' && this.source.charAt(this.index + 1) === '/') {
+ this.index += 2;
+ while (this.index < this.length && !/[\r\n]/.test(this.source.charAt(this.index))) {
+ this.index++;
+ }
+ continue;
+ }
+ if (ch === '/' && this.source.charAt(this.index + 1) === '*') {
+ var end = this.source.indexOf('*/', this.index + 2);
+ if (end < 0) this.fail('unterminated block comment');
+ this.index = end + 2;
+ continue;
+ }
+ break;
+ }
+ };
+
+ Parser.prototype.enter = function () {
+ this.depth++;
+ if (this.depth > this.limits.maxDepth) this.fail('maximum nesting depth exceeded');
+ };
+
+ Parser.prototype.leave = function () {
+ this.depth--;
+ };
+
+ Parser.prototype.countProperty = function () {
+ this.propertyCount++;
+ if (this.propertyCount > this.limits.maxProperties) {
+ this.fail('maximum property count exceeded');
+ }
+ };
+
+ Parser.prototype.parseString = function () {
+ var quote = this.source.charAt(this.index++);
+ var result = '';
+ while (this.index < this.length) {
+ var ch = this.source.charAt(this.index++);
+ if (ch === quote) return result;
+ if (ch === '\r' || ch === '\n') this.fail('unescaped newline in string');
+ if (ch !== '\\') {
+ result += ch;
+ } else {
+ if (this.index >= this.length) this.fail('unterminated string escape');
+ var escape = this.source.charAt(this.index++);
+ var simple = {
+ b: '\b',
+ f: '\f',
+ n: '\n',
+ r: '\r',
+ t: '\t',
+ v: '\v',
+ '0': '\0',
+ '\\': '\\',
+ '/': '/',
+ '"': '"',
+ "'": "'"
+ };
+ if (Object.prototype.hasOwnProperty.call(simple, escape)) {
+ if (escape === '0' && /[0-9]/.test(this.source.charAt(this.index))) {
+ this.fail('legacy octal escapes are not supported');
+ }
+ result += simple[escape];
+ } else if (escape === 'x') {
+ var hex = this.source.slice(this.index, this.index + 2);
+ if (!/^[0-9a-fA-F]{2}$/.test(hex)) this.fail('invalid hex escape');
+ result += String.fromCharCode(parseInt(hex, 16));
+ this.index += 2;
+ } else if (escape === 'u') {
+ var unicode = this.source.slice(this.index, this.index + 4);
+ if (!/^[0-9a-fA-F]{4}$/.test(unicode)) this.fail('invalid unicode escape');
+ result += String.fromCharCode(parseInt(unicode, 16));
+ this.index += 4;
+ } else {
+ this.fail('unsupported string escape');
+ }
+ }
+ if (result.length > this.limits.maxStringLength) {
+ this.fail('string exceeds maximum length');
+ }
+ }
+ this.fail('unterminated string');
+ };
+
+ Parser.prototype.parseNumber = function () {
+ var remaining = this.source.slice(this.index);
+ var match = remaining.match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
+ if (!match) this.fail('invalid number');
+ var next = remaining.charAt(match[0].length);
+ if (next && /[A-Za-z0-9_$\.]/.test(next)) this.fail('invalid number suffix');
+ this.index += match[0].length;
+ var value = Number(match[0]);
+ if (!Number.isFinite(value)) this.fail('non-finite numbers are not supported');
+ return value;
+ };
+
+ Parser.prototype.parseIdentifier = function () {
+ var match = this.source.slice(this.index).match(/^[A-Za-z_$][A-Za-z0-9_$]*/);
+ if (!match) this.fail('expected identifier');
+ this.index += match[0].length;
+ return match[0];
+ };
+
+ Parser.prototype.parseKey = function () {
+ this.skipSpace();
+ var ch = this.source.charAt(this.index);
+ var key;
+ if (ch === '"' || ch === "'") {
+ key = this.parseString();
+ } else if (/[A-Za-z_$]/.test(ch)) {
+ key = this.parseIdentifier();
+ } else {
+ var match = this.source.slice(this.index).match(/^(?:0|[1-9]\d*)/);
+ if (!match) this.fail('object keys must be quoted strings, identifiers, or integers');
+ key = match[0];
+ this.index += match[0].length;
+ }
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
+ this.fail('forbidden object key "' + key + '"');
+ }
+ return key;
+ };
+
+ Parser.prototype.parseObject = function () {
+ var result = Object.create(null);
+ this.index++;
+ this.enter();
+ this.skipSpace();
+ if (this.source.charAt(this.index) === '}') {
+ this.index++;
+ this.leave();
+ return result;
+ }
+ while (this.index < this.length) {
+ var key = this.parseKey();
+ this.countProperty();
+ this.skipSpace();
+ if (this.source.charAt(this.index) !== ':') {
+ this.fail('object properties require a colon');
+ }
+ this.index++;
+ result[key] = this.parseValue();
+ this.skipSpace();
+ var ch = this.source.charAt(this.index);
+ if (ch === '}') {
+ this.index++;
+ this.leave();
+ return result;
+ }
+ if (ch !== ',') this.fail('expected comma or closing brace');
+ this.index++;
+ this.skipSpace();
+ if (this.source.charAt(this.index) === '}') {
+ this.index++;
+ this.leave();
+ return result;
+ }
+ }
+ this.fail('unterminated object');
+ };
+
+ Parser.prototype.parseArray = function () {
+ var result = [];
+ this.index++;
+ this.enter();
+ this.skipSpace();
+ if (this.source.charAt(this.index) === ']') {
+ this.index++;
+ this.leave();
+ return result;
+ }
+ while (this.index < this.length) {
+ this.countProperty();
+ result.push(this.parseValue());
+ this.skipSpace();
+ var ch = this.source.charAt(this.index);
+ if (ch === ']') {
+ this.index++;
+ this.leave();
+ return result;
+ }
+ if (ch !== ',') this.fail('expected comma or closing bracket');
+ this.index++;
+ this.skipSpace();
+ if (this.source.charAt(this.index) === ']') {
+ this.index++;
+ this.leave();
+ return result;
+ }
+ }
+ this.fail('unterminated array');
+ };
+
+ Parser.prototype.parseValue = function () {
+ this.skipSpace();
+ var ch = this.source.charAt(this.index);
+ if (ch === '{') return this.parseObject();
+ if (ch === '[') return this.parseArray();
+ if (ch === '"' || ch === "'") return this.parseString();
+ if (ch === '-' || /[0-9]/.test(ch)) return this.parseNumber();
+ if (/[A-Za-z_$]/.test(ch)) {
+ var identifier = this.parseIdentifier();
+ if (identifier === 'true') return true;
+ if (identifier === 'false') return false;
+ if (identifier === 'null') return null;
+ this.fail('unsupported value "' + identifier + '"');
+ }
+ this.fail('unsupported value');
+ };
+
+ function parseAt(source, startIndex, options) {
+ var parser = new Parser(source, options);
+ parser.index = Math.max(0, Number(startIndex) || 0);
+ parser.skipSpace();
+ if (parser.source.charAt(parser.index) !== '{') {
+ parser.fail('expected object literal');
+ }
+ var value = parser.parseObject();
+ return { value: value, endIndex: parser.index };
+ }
+
+ function parse(source, options) {
+ var parsed = parseAt(source, 0, options);
+ var parser = new Parser(source, options);
+ parser.index = parsed.endIndex;
+ parser.skipSpace();
+ if (parser.index !== parser.length) parser.fail('unexpected trailing input');
+ return parsed.value;
+ }
+
+ return Object.freeze({
+ ParseError: ParseError,
+ parse: parse,
+ parseAt: parseAt
+ });
+});
+
+
/* ===== js/listeningRecordBridge.js ===== */
(function () {
'use strict';
var TAG = '[ListeningBridge]';
+ var HOST_MESSAGE_SOURCE = 'exam_host';
+
+ function deriveParentOriginFromReferrer() {
+ try {
+ if (!window.document || !window.document.referrer) return '';
+ var parsed = new URL(window.document.referrer, window.location.href);
+ // Chromium: file URL.origin is "file://", postMessage event.origin is "null".
+ if (parsed.protocol === 'file:') return '';
+ if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return '';
+ return parsed.origin;
+ } catch (e) {
+ return '';
+ }
+ }
var state = {
sessionId: null,
@@ -1544,8 +5698,13 @@
initialized: false,
completed: false,
parentWindow: null,
+ expectedParentOrigin: deriveParentOriginFromReferrer(),
+ parentOrigin: '',
+ parentOriginIsOpaque: false,
+ windowSessionToken: '',
initRequestTimer: null,
- initRequestAttempts: 0
+ initRequestAttempts: 0,
+ pendingCompletion: null
};
function log() {
@@ -1570,6 +5729,22 @@
return null;
}
+ function createSubmissionId() {
+ try {
+ if (window.crypto && typeof window.crypto.randomUUID === 'function') {
+ return 'listening-submit-' + window.crypto.randomUUID();
+ }
+ if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
+ var bytes = new Uint8Array(16);
+ window.crypto.getRandomValues(bytes);
+ return 'listening-submit-' + Array.prototype.map.call(bytes, function (byte) {
+ return byte.toString(16).padStart(2, '0');
+ }).join('');
+ }
+ } catch (_) {}
+ return 'listening-submit-' + Date.now() + '-' + Math.random().toString(36).slice(2);
+ }
+
function sendMessage(type, data) {
var pw = state.parentWindow || findParentWindow();
if (!pw) {
@@ -1577,7 +5752,17 @@
return false;
}
try {
- pw.postMessage({ type: type, data: data || {}, source: 'listening_record_bridge', timestamp: Date.now() }, '*');
+ var targetOrigin = state.parentOrigin && state.parentOrigin !== 'null'
+ ? state.parentOrigin
+ : (state.expectedParentOrigin || (window.location.protocol === 'file:' ? '*' : ''));
+ if (!targetOrigin) {
+ warn('无法 send message — trusted parent origin is unavailable');
+ return false;
+ }
+ var secureData = Object.assign({}, data || {}, {
+ windowSessionToken: state.windowSessionToken || null
+ });
+ pw.postMessage({ type: type, data: secureData, source: 'listening_record_bridge', timestamp: Date.now() }, targetOrigin);
return true;
} catch (e) {
warn('postMessage failed:', e);
@@ -1860,30 +6045,11 @@
function parseObjectLiteral(text, startIndex, label) {
label = label || 'inline';
if (startIndex >= text.length) return null;
- var depth = 0;
- var i = startIndex;
- var started = false;
- var objectStart = -1;
- for (; i < text.length; i++) {
- var ch = text.charAt(i);
- if (ch === '{') {
- if (!started) objectStart = i;
- depth++;
- started = true;
- }
- else if (ch === '}') { depth--; if (started && depth === 0) break; }
- else if (ch === '\'' || ch === '"') {
- var quote = ch;
- for (i++; i < text.length; i++) {
- if (text.charAt(i) === '\\' && i + 1 < text.length) { i++; continue; }
- if (text.charAt(i) === quote) break;
- }
- }
- }
- if (!started || depth !== 0) return null;
- var snippet = text.substring(objectStart, i + 1);
try {
- return (new Function('return (' + snippet + ')'))();
+ if (!window.SafeObjectLiteralParser || typeof window.SafeObjectLiteralParser.parseAt !== 'function') {
+ throw new Error('SafeObjectLiteralParser is unavailable');
+ }
+ return window.SafeObjectLiteralParser.parseAt(text, startIndex).value;
} catch (e) {
warn('parseObjectLiteral failed for', label, e);
return null;
@@ -2286,13 +6452,35 @@
};
}
+ function sendPendingCompletion(reason) {
+ var pending = state.pendingCompletion;
+ if (!pending || state.completed) return false;
+ if (!state.initialized || !state.windowSessionToken) {
+ sendInitRequest(reason || 'complete_before_init');
+ return false;
+ }
+ if (!pending.payload) {
+ pending.payload = buildBridgePayload(pending.details);
+ pending.payload.submissionId = pending.submissionId;
+ }
+ log(
+ 'sending PRACTICE_COMPLETE, submissionId=' + pending.submissionId
+ + ' correct=' + pending.payload.scoreInfo.correct + '/' + pending.payload.scoreInfo.total
+ );
+ return sendMessage('PRACTICE_COMPLETE', pending.payload);
+ }
+
function onComplete(options) {
options = options || {};
if (state.completed) {
log('already completed, skipping');
return true;
}
- state.completed = true;
+ if (state.pendingCompletion) {
+ sendPendingCompletion('completion_retry');
+ scheduleCompletionRetries(state.pendingCompletion.options || options);
+ return true;
+ }
var allowGenerated = !!options.allowGenerated;
var details = extractAttemptDetails(window, { allowGenerated: allowGenerated });
@@ -2305,17 +6493,17 @@
}
if (!details.length) {
warn('no details extracted, cannot complete');
- state.completed = false;
return false;
}
- var payload = buildBridgePayload(details);
- log('sending PRACTICE_COMPLETE, correct=' + payload.scoreInfo.correct + '/' + payload.scoreInfo.total);
- if (!state.initialized) {
- sendInitRequest('complete_before_init');
- }
- sendMessage('PRACTICE_COMPLETE', payload);
- clearCompletionRetryTimers();
+ state.pendingCompletion = {
+ submissionId: createSubmissionId(),
+ details: details,
+ options: Object.assign({}, options),
+ payload: null
+ };
+ sendPendingCompletion(state.initialized ? 'completion_created' : 'complete_before_init');
+ scheduleCompletionRetries(options);
return true;
}
@@ -2335,9 +6523,9 @@
for (var i = 0; i < retryDelays.length; i++) {
(function (delay) {
completionRetryTimers.push(setTimeout(function () {
- if (!state.completed) {
- onComplete(options || {});
- }
+ if (state.completed) return;
+ if (state.pendingCompletion) sendPendingCompletion('completion_timeout');
+ else onComplete(options || {});
}, delay));
})(retryDelays[i]);
}
@@ -2556,18 +6744,71 @@
if (type === 'INIT_SESSION' || type === 'init_exam_session') {
var payload = data.data || data;
- if (event.source && event.source !== window && typeof event.source.postMessage === 'function') {
- state.parentWindow = event.source;
+ if (!state.parentWindow || event.source !== state.parentWindow || data.source !== HOST_MESSAGE_SOURCE) return;
+ var incomingOrigin = typeof event.origin === 'string' ? event.origin : '';
+ var declaredOrigin = typeof payload.parentOrigin === 'string' ? payload.parentOrigin : '';
+ var incomingToken = typeof payload.windowSessionToken === 'string' ? payload.windowSessionToken.trim() : '';
+ if (!incomingToken) return;
+ var expectedParentOrigin = state.expectedParentOrigin
+ && state.expectedParentOrigin !== 'file://'
+ && String(state.expectedParentOrigin).indexOf('file:') !== 0
+ ? state.expectedParentOrigin
+ : '';
+ if (expectedParentOrigin) {
+ if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) return;
+ state.parentOrigin = expectedParentOrigin;
+ state.parentOriginIsOpaque = false;
+ } else if (window.location.protocol === 'file:') {
+ var trustedFileOrigin = incomingOrigin === 'null'
+ && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://');
+ if (!trustedFileOrigin) return;
+ state.parentOrigin = 'null';
+ state.parentOriginIsOpaque = true;
+ } else {
+ var trustedWebOrigin = !!incomingOrigin
+ && incomingOrigin !== 'null'
+ && incomingOrigin !== 'file://'
+ && declaredOrigin === incomingOrigin;
+ if (!trustedWebOrigin) return;
+ state.parentOrigin = incomingOrigin;
+ state.parentOriginIsOpaque = false;
}
+ var previousSessionId = state.sessionId;
+ state.windowSessionToken = incomingToken;
state.sessionId = payload.sessionId || state.sessionId || (state.examId + '_' + Date.now());
state.examId = payload.examId || state.examId;
state.suiteSessionId = payload.suiteSessionId || state.suiteSessionId || null;
state.startTime = toTimestampMs(payload.startTime, toTimestampMs(state.startTime, Date.now()));
state.initialized = true;
stopInitRequestLoop();
+ if (state.pendingCompletion && String(previousSessionId || '') !== String(state.sessionId || '')) {
+ state.pendingCompletion.payload = null;
+ }
log('INIT_SESSION received — examId=' + state.examId + ' sessionId=' + state.sessionId);
sendSessionReady('ready');
+ if (state.pendingCompletion) {
+ sendPendingCompletion('init_received');
+ }
+ } else if (type === 'PRACTICE_SUBMIT_ACK' || type === 'PRACTICE_SUBMIT_FAILED') {
+ var outcome = data.data || data;
+ if (!state.parentWindow || event.source !== state.parentWindow || data.source !== HOST_MESSAGE_SOURCE) return;
+ var outcomeOrigin = typeof event.origin === 'string' ? event.origin : '';
+ if (state.parentOriginIsOpaque ? outcomeOrigin !== 'null' : (!state.parentOrigin || outcomeOrigin !== state.parentOrigin)) return;
+ if (!outcome || String(outcome.windowSessionToken || '') !== String(state.windowSessionToken || '')) return;
+ var pending = state.pendingCompletion;
+ if (!pending
+ || String(outcome.submissionId || '') !== String(pending.submissionId || '')
+ || String(outcome.sessionId || '') !== String(state.sessionId || '')) return;
+ if (type === 'PRACTICE_SUBMIT_ACK') {
+ state.completed = true;
+ state.pendingCompletion = null;
+ clearCompletionRetryTimers();
+ log('PRACTICE_COMPLETE persisted, submissionId=' + outcome.submissionId);
+ } else {
+ warn('PRACTICE_COMPLETE persistence failed, retrying submissionId=' + outcome.submissionId);
+ scheduleCompletionRetries(pending.options || {});
+ }
}
});
}
@@ -2637,8 +6878,13 @@
(function markBundleProvided(global) {
if (global.AppLazyLoader && typeof global.AppLazyLoader.markProvided === "function") {
global.AppLazyLoader.markProvided([
+ "js/data/practiceRecordSource.js",
+ "js/data/v2/dataCatalog.js",
+ "js/data/v2/dataKernel.js",
+ "js/data/v2/appData.js",
"js/utils/answerMatchCore.js",
"js/app/spellingErrorCollector.js",
+ "js/utils/safeObjectLiteralParser.js",
"js/listeningRecordBridge.js"
]);
}
diff --git a/js/bundles/listening-wrapper.bundle.js b/js/bundles/listening-wrapper.bundle.js
index 4a1afee2..28e37b6b 100644
--- a/js/bundles/listening-wrapper.bundle.js
+++ b/js/bundles/listening-wrapper.bundle.js
@@ -1,11 +1,3844 @@
/* Generated by scripts/build-bundles.mjs. Do not edit by hand. */
+/* ===== js/data/practiceRecordSource.js ===== */
+/**
+ * 练习记录来源判定 —— “什么算真实练习记录”的唯一权威定义。
+ *
+ * 背景(本文件存在的理由):
+ * 这条规则历史上被复制成了两套互不相通的实现,语义还不一样:
+ * - UI 侧 js/main.js `updatePracticeView` 只看顶层 `dataSource`;
+ * - 投影器侧 js/data/v2/appData.js `computeStats` / `computeAchievementProgress`
+ * 只看 `metadata.source === 'onboarding-demo'`。
+ * 结果是 `demo` / `e2e-seed` 这类记录“在练习记录页看不见,却计入成绩统计和成就解锁”,
+ * 用户会看到自己没做过的题影响了正确率与成就。
+ *
+ * 因此判定必须只有一份实现,并被所有消费方共享。本文件同时被打进
+ * core-foundation / reading-page / practice-page-enhancer / listening-record-bridge /
+ * listening-wrapper(供 appData.js 的投影器使用)和 browse(供 js/main.js 的渲染过滤使用)
+ * 等 bundle;appData.js 在启动时硬性要求本模块存在,缺失即抛错,杜绝“再退回本地副本”。
+ *
+ * ---------------------------------------------------------------------------
+ * 语义(两个维度,任一命中即判为非真实)
+ *
+ * 1) dataSource(顶层,回退 metadata.dataSource)
+ * - 缺失 / null / 空串 => **真实记录**
+ * - 'real' => 真实记录
+ * - 其它任何显式值 => 非真实(演示 / 种子 / 占位)
+ *
+ * “缺失即真实”是硬性约束,不得收窄:生产代码只在 practiceRecorder / examSessionMixin
+ * 三处写过该字段且都写 'real',套题聚合、听力桥接、legacy 迁移记录从来不写。
+ * 曾经有一版把“没标注”当成“非真实”,直接导致练习记录页整页空白(线上 P0)。
+ *
+ * 2) metadata.source
+ * 只精确匹配已知的演示/种子标记,**绝不做包含匹配**。
+ * 这个字段是被复用的:套题记录会写 'listening' / 'reading'(内容类型标签,见
+ * js/app/suitePracticeMixin.js),消息通道会写 'practice_page' / 'inline_collector'
+ * / 'suite_placeholder' / 'listening_record_bridge' / 'data_collector'(采集方式标签)。
+ * 任何模糊匹配都可能把真实记录判成演示数据,属于同一类 P0。
+ *
+ * 注意:`record.source` 与 `realData.source` 是采集方式标签而非来源标注,故不参与判定。
+ */
+(function initPracticeRecordSource(global) {
+ 'use strict';
+
+ // 同一份源码会被多个 bundle 内联(浏览器里 core-foundation 与 browse 都会执行一次),
+ // 重复赋值本身无害,但仍按仓库惯例做幂等保护,避免任何形态的静默覆盖。
+ if (global.PracticeRecordSource && global.PracticeRecordSource.__stable === true) {
+ return;
+ }
+
+ /** 被认可为“真实用户练习”的显式 dataSource 取值。 */
+ const REAL_DATA_SOURCES = Object.freeze(['real']);
+
+ /**
+ * 被认定为“演示 / 种子 / 夹具数据”的 metadata.source 取值(精确匹配,大小写与首尾空白无关)。
+ * 目前生产代码只会写出 'onboarding-demo'(js/components/onboardingTour.js);
+ * 其余是历史与测试夹具里出现过的等价写法,一并显式列出而不是靠模糊匹配推断。
+ */
+ const DEMO_SOURCE_MARKERS = Object.freeze([
+ 'onboarding-demo',
+ 'onboarding_demo',
+ 'onboardingdemo',
+ 'demo',
+ 'e2e-seed',
+ 'e2e_seed'
+ ]);
+
+ /** 只有新手引导自己的 marker 才有资格申请临时历史列表预览。 */
+ const ONBOARDING_PREVIEW_MARKERS = Object.freeze([
+ 'onboarding-demo',
+ 'onboarding_demo',
+ 'onboardingdemo'
+ ]);
+
+ const realDataSourceSet = new Set(REAL_DATA_SOURCES);
+ const demoSourceSet = new Set(DEMO_SOURCE_MARKERS);
+ const onboardingPreviewMarkerSet = new Set(ONBOARDING_PREVIEW_MARKERS);
+
+ function normalize(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim().toLowerCase();
+ }
+
+ function asObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+ }
+
+ function hasOwn(object, field) {
+ return Object.prototype.hasOwnProperty.call(object, field);
+ }
+
+ /** 读取记录的来源标注:顶层优先,回退 metadata(light 投影同样走这条回退链)。 */
+ function readDataSource(record) {
+ if (hasOwn(record, 'dataSource')) return normalize(record.dataSource);
+ const metadata = asObject(record.metadata);
+ return hasOwn(metadata, 'dataSource') ? normalize(metadata.dataSource) : '';
+ }
+
+ function readMetadataSource(record) {
+ return normalize(asObject(record.metadata).source);
+ }
+
+ /**
+ * 唯一判定入口:该记录是否算作用户的真实练习。
+ * 练习记录列表渲染、practice.stats 投影、achievements.progress 投影三者必须都用它,
+ * 三处结论一致是本模块的核心契约。
+ */
+ function isRealPracticeRecord(record) {
+ if (!record || typeof record !== 'object') return false;
+
+ const dataSource = readDataSource(record);
+ // 缺失/空值一律按真实记录对待(见文件头“缺失即真实”)。
+ if (dataSource !== '' && !realDataSourceSet.has(dataSource)) return false;
+
+ if (demoSourceSet.has(readMetadataSource(record))) return false;
+
+ return true;
+ }
+
+ /** isRealPracticeRecord 的补集,仅对合法记录对象成立(非对象既不真也不演示)。 */
+ function isDemoPracticeRecord(record) {
+ if (!record || typeof record !== 'object') return false;
+ return !isRealPracticeRecord(record);
+ }
+
+ function filterRealPracticeRecords(records) {
+ return (Array.isArray(records) ? records : []).filter(isRealPracticeRecord);
+ }
+
+ // -----------------------------------------------------------------------
+ // 引导预览白名单(仅影响渲染,永不影响统计与成就)
+ //
+ // 新手引导的"回顾模式"步骤会先把一条演示记录写进权威 practice records,
+ // 再等待它在练习记录列表里出现(js/components/onboardingTour.js
+ // `_injectDemoRecord` -> `_waitForSelector`),演示完成后立即删除。
+ //
+ // 这条记录按上面的判定确实是演示数据(metadata.source = 'onboarding-demo'),
+ // 所以它必须继续被 practice.stats / achievements.progress 排除。但引导要教用户
+ // 认识这一行 UI,因此需要一个**显式、按 id 限定、临时**的渲染例外。
+ //
+ // 关键设计:例外只存在于视图层白名单,投影器根本读不到它——
+ // 于是"是否真实"仍然只有一份判定,不会退回"UI 与统计各写一套"的老 bug。
+ // 历史上引导记录之所以能显示,只是因为没人给它写 dataSource(巧合而非设计)。
+ // -----------------------------------------------------------------------
+ const previewRecordIds = new Set();
+
+ function normalizeId(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim();
+ }
+
+ /** 登记一条允许在练习记录列表中预览的演示记录 id(引导步骤开始时调用)。 */
+ function allowPreviewRecordId(recordId) {
+ const id = normalizeId(recordId);
+ if (id) previewRecordIds.add(id);
+ return id !== '';
+ }
+
+ /** 撤销预览许可(引导结束/跳过/清理演示记录时调用)。 */
+ function clearPreviewRecordId(recordId) {
+ if (recordId === undefined) {
+ previewRecordIds.clear();
+ return true;
+ }
+ return previewRecordIds.delete(normalizeId(recordId));
+ }
+
+ function isPreviewRecord(record) {
+ if (!previewRecordIds.size || !record || typeof record !== 'object') return false;
+ if (!onboardingPreviewMarkerSet.has(readMetadataSource(record))) return false;
+ const id = normalizeId(record.id || record.recordId);
+ return Boolean(id && previewRecordIds.has(id));
+ }
+
+ /**
+ * 练习记录列表的渲染过滤:真实记录 + 已显式登记的引导预览记录。
+ * 统计/成就一律用 filterRealPracticeRecords,绝不用这个函数。
+ */
+ function filterRecordsForHistoryView(records) {
+ return (Array.isArray(records) ? records : [])
+ .filter((record) => isRealPracticeRecord(record) || isPreviewRecord(record));
+ }
+
+ const api = Object.freeze({
+ __stable: true,
+ REAL_DATA_SOURCES,
+ DEMO_SOURCE_MARKERS,
+ ONBOARDING_PREVIEW_MARKERS,
+ isRealPracticeRecord,
+ isDemoPracticeRecord,
+ filterRealPracticeRecords,
+ allowPreviewRecordId,
+ clearPreviewRecordId,
+ isPreviewRecord,
+ filterRecordsForHistoryView
+ });
+
+ global.PracticeRecordSource = api;
+
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = api;
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
+/* ===== js/data/v2/dataCatalog.js ===== */
+(function installDataCatalog(global) {
+ 'use strict';
+
+ const V2_SCHEMA_VERSION = 2;
+
+ function clone(value) {
+ if (value === undefined) return undefined;
+ if (typeof structuredClone === 'function') {
+ try { return structuredClone(value); } catch (_) { /* fall through */ }
+ }
+ return JSON.parse(JSON.stringify(value));
+ }
+
+ function objectDefault() { return {}; }
+ function arrayDefault() { return []; }
+ function nullableDefault() { return null; }
+ function normalizeArray(value) { return Array.isArray(value) ? clone(value) : []; }
+ function normalizeObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? clone(value) : {};
+ }
+ function normalizeNullableString(value) {
+ return value === null || value === undefined || value === '' ? null : String(value);
+ }
+ function isArray(value) { return Array.isArray(value); }
+ function isObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }
+ function isNullableString(value) { return value === null || typeof value === 'string'; }
+
+ const CATALOG_OWNERS = new Set([
+ 'settings', 'library', 'recovery', 'backups', 'vocab',
+ 'preferences', 'goals', 'achievements', 'system', 'practice'
+ ]);
+ const CATALOG_CLASSIFICATIONS = new Set(['authoritative', 'preference', 'session', 'system']);
+ const IMPORT_POLICIES = new Set(['replace', 'patch', 'merge-by-id', 'ignore']);
+
+ function isNonEmptyString(value) {
+ return typeof value === 'string' && Boolean(value.trim());
+ }
+
+ function ownerFromKey(logicalKey) {
+ const dot = String(logicalKey || '').indexOf('.');
+ return dot > 0 ? logicalKey.slice(0, dot) : '';
+ }
+
+ function freezeEntry(entry) {
+ const logicalKey = String(entry.logicalKey || '');
+ const owner = ownerFromKey(logicalKey);
+ const next = Object.assign({}, entry, {
+ logicalKey,
+ owner,
+ schemaVersion: V2_SCHEMA_VERSION,
+ export: entry.export === true,
+ import: entry.import || 'ignore'
+ });
+ return Object.freeze(next);
+ }
+
+ // Minimal document catalog. Practice lives in entity stores (summaries/details/annotations),
+ // not as document keys. import merge identity is resolved in AppData, not here.
+ const definitions = [
+ {
+ logicalKey: 'settings.values', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'library.configurations', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'library.importedIndexes', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'library.activeConfigurationId', classification: 'authoritative',
+ defaultValue: nullableDefault, normalize: normalizeNullableString, validate: isNullableString,
+ export: true, import: 'replace'
+ },
+ {
+ logicalKey: 'recovery.activeSessions', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.drafts', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.interrupted', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.rejectedCompletions', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.windowSession', classification: 'session',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'backups.entries', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'backups.settings', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'backups.exportHistory', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'backups.importHistory', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'vocab.words', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'vocab.userConfig', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'vocab.lists', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'preferences.values', classification: 'preference',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'goals.items', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'achievements.manual', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'achievements.progress', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'system.migrations', classification: 'system',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'system.operationJournal', classification: 'system',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ }
+ ].map(freezeEntry);
+
+ function validateCatalog(entries) {
+ if (!Array.isArray(entries) || !entries.length) throw new Error('DataCatalog requires at least one entry');
+ const logicalKeys = new Set();
+ for (const entry of entries) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
+ throw new Error('DataCatalog entry must be an object');
+ }
+ if (!isNonEmptyString(entry.logicalKey) || logicalKeys.has(entry.logicalKey)) {
+ throw new Error(`DataCatalog duplicate/invalid logical key: ${entry.logicalKey}`);
+ }
+ logicalKeys.add(entry.logicalKey);
+ }
+ for (const entry of entries) {
+ if (!CATALOG_OWNERS.has(entry.owner) || !entry.logicalKey.startsWith(`${entry.owner}.`)) {
+ throw new Error(`DataCatalog owner conflict for ${entry.logicalKey}: ${entry.owner}`);
+ }
+ if (!CATALOG_CLASSIFICATIONS.has(entry.classification)
+ || !Number.isInteger(entry.schemaVersion) || entry.schemaVersion !== V2_SCHEMA_VERSION
+ || typeof entry.defaultValue !== 'function'
+ || typeof entry.normalize !== 'function'
+ || typeof entry.validate !== 'function'
+ || typeof entry.export !== 'boolean'
+ || !IMPORT_POLICIES.has(entry.import)) {
+ throw new Error(`DataCatalog incomplete contract: ${entry.logicalKey}`);
+ }
+ try {
+ const defaultValue = entry.defaultValue();
+ if (!entry.validate(defaultValue) || !entry.validate(entry.normalize(defaultValue))) {
+ throw new Error('invalid default');
+ }
+ } catch (_) {
+ throw new Error(`DataCatalog invalid default contract: ${entry.logicalKey}`);
+ }
+ }
+ return true;
+ }
+
+ validateCatalog(definitions);
+ const byKey = new Map(definitions.map((entry) => [entry.logicalKey, entry]));
+ const DataCatalog = Object.freeze({
+ version: V2_SCHEMA_VERSION,
+ list() { return definitions.slice(); },
+ get(logicalKey) {
+ const entry = byKey.get(String(logicalKey || ''));
+ if (!entry) throw new Error(`DataCatalog unknown logical key: ${logicalKey}`);
+ return entry;
+ },
+ has(logicalKey) { return byKey.has(String(logicalKey || '')); },
+ validate: validateCatalog,
+ clone
+ });
+
+ Object.defineProperty(global, '__AppDataV2Catalog', {
+ value: DataCatalog,
+ enumerable: false,
+ configurable: true,
+ writable: false
+ });
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
+/* ===== js/data/v2/dataKernel.js ===== */
+(function installDataKernel(global) {
+ 'use strict';
+
+ if (global.AppData) return;
+
+ const catalog = global.__AppDataV2Catalog;
+ if (!catalog) throw new Error('AppData v2 requires DataCatalog before DataKernel');
+
+ const DATABASE_NAME = 'IELTSAtlasDataV2';
+ // Version 2 uses a new schema, but initialization must still import the durable
+ // ExamSystemDB data owned by releases which predate AppData v2.
+ const DATABASE_VERSION = 2;
+ const DOCUMENT_STORE = 'documents';
+ const SYSTEM_STORE = 'system';
+ const ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']);
+ const STORE_NAMES = Object.freeze([DOCUMENT_STORE, SYSTEM_STORE].concat(ENTITY_STORES));
+ const OPERATION_JOURNAL_WINDOW = 500;
+ const COMMIT_CHANNEL_NAME = `${DATABASE_NAME}:committed`;
+ const DEFAULT_IDB_MUTATION_TIMEOUT_MS = 30000;
+ const DEFAULT_IDB_REQUEST_TIMEOUT_MS = 30000;
+ const MAX_TIMER_DELAY_MS = 2147483647;
+ const LEGACY_DATABASE_NAME = 'ExamSystemDB';
+ const LEGACY_STORE_NAME = 'keyValueStore';
+ const LEGACY_EXTERNAL_DATABASE_NAME = 'ExamSystemExternalBackup';
+ const LEGACY_EXTERNAL_STORE_NAME = 'handles';
+ const LEGACY_EXTERNAL_HANDLE_KEY = 'backup_directory';
+ const LEGACY_EXTERNAL_FILENAME = 'practice-backup-latest.json';
+ const LEGACY_UNPREFIXED_WEB_KEYS = Object.freeze([
+ 'practice_records',
+ 'vocab_user_config',
+ 'user_achievements'
+ ]);
+
+ function clone(value) { return catalog.clone(value); }
+ function nowIso() { return new Date().toISOString(); }
+ function randomId(prefix) {
+ const random = global.crypto && typeof global.crypto.randomUUID === 'function'
+ ? global.crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`;
+ return `${prefix || 'op'}_${random}`;
+ }
+
+ class AppDataError extends Error {
+ constructor(code, message, details = {}) {
+ super(message);
+ this.name = 'AppDataError';
+ this.code = code;
+ this.committed = false;
+ this.details = details;
+ }
+ }
+ function validation(message, details) { return new AppDataError('VALIDATION', message, details || {}); }
+ function corruption(message, details) { return new AppDataError('CORRUPT_RECORD', message, details || {}); }
+
+ function normalizeTimeoutMs(value, fallback) {
+ if (value === undefined || value === null || value === '') return fallback;
+ const numeric = Number(value);
+ return Number.isFinite(numeric) && numeric > 0 ? Math.min(numeric, MAX_TIMER_DELAY_MS) : fallback;
+ }
+ function scheduleTimeout(handler, delayMs) {
+ if (typeof global.setTimeout !== 'function') throw new Error('setTimeout unavailable');
+ const handle = global.setTimeout.call(global, handler, delayMs);
+ if (handle === null || handle === undefined) throw new Error('setTimeout did not return a handle');
+ return handle;
+ }
+ function cancelTimeout(handle) {
+ if (handle !== null && handle !== undefined && typeof global.clearTimeout === 'function') {
+ try { global.clearTimeout.call(global, handle); } catch (_) { /* already gone */ }
+ }
+ }
+ function withDeadline(handle, timeoutMs, description, resolve, reject) {
+ let settled = false;
+ let timer = null;
+ const settle = (callback, value) => {
+ if (settled) return;
+ settled = true;
+ cancelTimeout(timer);
+ callback(value);
+ };
+ const expire = (error) => {
+ if (settled) return;
+ settled = true;
+ try { if (handle && typeof handle.abort === 'function') handle.abort(); } catch (_) { /* best effort */ }
+ reject(error);
+ };
+ try {
+ timer = scheduleTimeout(() => expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} timed out after ${timeoutMs}ms`, {
+ operation: description, timeoutMs, reason: 'timeout'
+ })), timeoutMs);
+ } catch (error) {
+ expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} watchdog unavailable`, {
+ operation: description, reason: 'watchdog-unavailable', cause: error && error.message
+ }));
+ }
+ return { resolve(value) { settle(resolve, value); }, reject(error) { settle(reject, error); } };
+ }
+
+ function canonicalizeJson(value, path = '$', ancestors = new Set()) {
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
+ if (typeof value === 'number') {
+ if (!Number.isFinite(value)) throw validation(`Non-finite number at ${path}`, { path });
+ return Object.is(value, -0) ? 0 : value;
+ }
+ if (typeof value !== 'object' || value === undefined || typeof value === 'bigint' || typeof value === 'function' || typeof value === 'symbol') {
+ throw validation(`Non-JSON value at ${path}`, { path, type: typeof value });
+ }
+ if (ancestors.has(value)) throw validation(`Cyclic data at ${path}`, { path });
+ const prototype = Object.getPrototypeOf(value);
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw validation(`Non-plain object at ${path}`, { path });
+ if (typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function'
+ && Reflect.ownKeys(value).some((key) => typeof key === 'symbol')) {
+ throw validation(`Symbol-keyed property at ${path}`, { path });
+ }
+ ancestors.add(value);
+ try {
+ if (Array.isArray(value)) {
+ return value.map((item, index) => {
+ if (!Object.prototype.hasOwnProperty.call(value, index)) throw validation(`Sparse array entry at ${path}[${index}]`, { path });
+ return canonicalizeJson(item, `${path}[${index}]`, ancestors);
+ });
+ }
+ const result = {};
+ for (const key of Object.keys(value).sort()) {
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
+ if (!descriptor || descriptor.get || descriptor.set) throw validation(`Accessor property at ${path}.${key}`, { path });
+ result[key] = canonicalizeJson(descriptor.value, `${path}.${key}`, ancestors);
+ }
+ return result;
+ } finally { ancestors.delete(value); }
+ }
+ function stableStringifyCanonical(value) {
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
+ if (Array.isArray(value)) return `[${value.map(stableStringifyCanonical).join(',')}]`;
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyCanonical(value[key])}`).join(',')}}`;
+ }
+ function stableStringify(value) { return stableStringifyCanonical(canonicalizeJson(value)); }
+ function checksum(value) {
+ const input = stableStringify(value);
+ let hash = 2166136261;
+ for (let index = 0; index < input.length; index += 1) { hash ^= input.charCodeAt(index); hash = Math.imul(hash, 16777619); }
+ return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`;
+ }
+ function legacyTimestamp(value) {
+ if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return -Infinity;
+ if (Number.isFinite(Number(value))) return Number(value);
+ const parsed = Date.parse(value == null ? '' : String(value));
+ return Number.isFinite(parsed) ? parsed : -Infinity;
+ }
+ function parseLegacyCandidate(value, outerTimestamp) {
+ let parsed = value;
+ let timestamp = legacyTimestamp(outerTimestamp);
+ const hasOuterTimestamp = timestamp !== -Infinity;
+ for (let depth = 0; depth < 3; depth += 1) {
+ if (typeof parsed === 'string') {
+ try { parsed = JSON.parse(parsed); } catch (_) { if (depth === 0) return null; break; }
+ } else if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, 'data')
+ && (Object.prototype.hasOwnProperty.call(parsed, 'version') || Object.prototype.hasOwnProperty.call(parsed, 'compressed'))) {
+ const innerTimestamp = legacyTimestamp(parsed.timestamp);
+ if (!hasOuterTimestamp && innerTimestamp > timestamp) timestamp = innerTimestamp;
+ parsed = parsed.data;
+ } else break;
+ }
+ return { value: clone(parsed), timestamp };
+ }
+ function parseLegacyValue(value) {
+ const candidate = parseLegacyCandidate(value);
+ return candidate ? candidate.value : clone(value);
+ }
+ async function readLegacyValues(indexedDBApi = global.indexedDB, storage = global.localStorage, sessionStorageApi = global.sessionStorage) {
+ const values = {};
+ const candidates = {};
+ let readComplete = true;
+ const consider = (alias, rawValue, timestamp, sourceRank) => {
+ const candidate = parseLegacyCandidate(rawValue, timestamp);
+ if (!candidate) return;
+ const previous = candidates[alias];
+ if (!previous || candidate.timestamp > previous.timestamp
+ || (candidate.timestamp === previous.timestamp && sourceRank < previous.sourceRank)) {
+ candidates[alias] = Object.assign(candidate, { sourceRank });
+ }
+ };
+ if (indexedDBApi && typeof indexedDBApi.open === 'function') {
+ await new Promise((resolve) => {
+ let request;
+ let createdEmptyDatabase = false;
+ try { request = indexedDBApi.open(LEGACY_DATABASE_NAME); } catch (_) { readComplete = false; resolve(); return; }
+ request.onerror = () => { if (!createdEmptyDatabase) readComplete = false; resolve(); };
+ request.onupgradeneeded = () => {
+ createdEmptyDatabase = true;
+ try { request.transaction.abort(); } catch (_) {}
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(LEGACY_STORE_NAME)) { db.close(); resolve(); return; }
+ const tx = db.transaction(LEGACY_STORE_NAME, 'readonly');
+ const keys = tx.objectStore(LEGACY_STORE_NAME).getAllKeys();
+ const rows = tx.objectStore(LEGACY_STORE_NAME).getAll();
+ tx.oncomplete = () => {
+ (keys.result || []).forEach((key, index) => {
+ const row = (rows.result || [])[index];
+ // v1's keyValueStore persisted { key, value, timestamp } rows.
+ const validRow = row && typeof row === 'object'
+ && Object.prototype.hasOwnProperty.call(row, 'key')
+ && String(row.key) === String(key)
+ && Object.prototype.hasOwnProperty.call(row, 'value');
+ if (!validRow) {
+ readComplete = false;
+ return;
+ }
+ consider(String(key).replace(/^exam_system_/, ''), row.value, row.timestamp, 0);
+ });
+ db.close(); resolve();
+ };
+ tx.onerror = tx.onabort = () => { readComplete = false; db.close(); resolve(); };
+ };
+ });
+ }
+ for (const [sourceRank, fallbackStorage] of [storage, sessionStorageApi].entries()) {
+ if (!fallbackStorage || typeof fallbackStorage.key !== 'function') continue;
+ for (let index = 0; index < Number(fallbackStorage.length || 0); index += 1) {
+ const key = fallbackStorage.key(index);
+ if (!key) continue;
+ const alias = key.startsWith('exam_system_')
+ ? key.slice('exam_system_'.length)
+ : (LEGACY_UNPREFIXED_WEB_KEYS.includes(key) ? key : null);
+ if (!alias) continue;
+ try { consider(alias, fallbackStorage.getItem(key), null, sourceRank + 1); } catch (_) { /* inaccessible fallback */ }
+ }
+ }
+ for (const [alias, candidate] of Object.entries(candidates)) values[alias] = candidate.value;
+ Object.defineProperty(values, '__legacyReadComplete', {
+ value: readComplete,
+ enumerable: false,
+ configurable: false,
+ writable: false
+ });
+ return values;
+ }
+ async function readLegacyExternalBackup(indexedDBApi = global.indexedDB) {
+ if (!indexedDBApi || typeof indexedDBApi.open !== 'function') return null;
+ const directoryHandle = await new Promise((resolve) => {
+ let request;
+ let settled = false;
+ const finish = (value) => {
+ if (settled) return;
+ settled = true;
+ resolve(value || null);
+ };
+ try { request = indexedDBApi.open(LEGACY_EXTERNAL_DATABASE_NAME); } catch (_) { finish(null); return; }
+ request.onerror = () => finish(null);
+ request.onupgradeneeded = () => {
+ try { request.transaction.abort(); } catch (_) {}
+ finish(null);
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(LEGACY_EXTERNAL_STORE_NAME)) {
+ db.close(); finish(null); return;
+ }
+ const get = db.transaction(LEGACY_EXTERNAL_STORE_NAME, 'readonly')
+ .objectStore(LEGACY_EXTERNAL_STORE_NAME).get(LEGACY_EXTERNAL_HANDLE_KEY);
+ get.onerror = () => { db.close(); finish(null); };
+ get.onsuccess = () => { db.close(); finish(get.result); };
+ };
+ });
+ if (!directoryHandle || typeof directoryHandle.queryPermission !== 'function') return null;
+ if (await directoryHandle.queryPermission({ mode: 'read' }) !== 'granted') return null;
+ const fileHandle = await directoryHandle.getFileHandle(LEGACY_EXTERNAL_FILENAME, { create: false });
+ const parsed = JSON.parse(await (await fileHandle.getFile()).text());
+ const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.data !== undefined
+ ? parsed.data : parsed;
+ return payload && typeof payload === 'object' && !Array.isArray(payload) ? clone(payload) : null;
+ }
+ function lookupEntry(logicalKey) {
+ if (!catalog.has(logicalKey)) throw validation(`Unknown AppData logical key: ${logicalKey}`, { logicalKey });
+ return catalog.get(logicalKey);
+ }
+ function storeFor(logicalKey) {
+ const entry = lookupEntry(logicalKey);
+ if (entry.classification === 'session') throw validation(`${logicalKey} is not durable kernel data`, { logicalKey });
+ return entry.classification === 'system' ? SYSTEM_STORE : DOCUMENT_STORE;
+ }
+ function makeEnvelope(entry, data, options = {}) {
+ const state = options.state === 'cleared' ? 'cleared' : 'present';
+ if (options.state !== undefined && state !== options.state) throw validation(`Invalid envelope state for ${entry.logicalKey}`);
+ let normalized = null;
+ if (state === 'present') {
+ try { normalized = options.normalized ? data : entry.normalize(canonicalizeJson(data, `$.${entry.logicalKey}`)); } catch (error) {
+ throw validation(`Unable to normalize ${entry.logicalKey}`, { cause: error && error.message });
+ }
+ normalized = canonicalizeJson(normalized, `$.${entry.logicalKey}`);
+ if (!entry.validate(normalized)) throw validation(`Invalid data for ${entry.logicalKey}`, { logicalKey: entry.logicalKey });
+ }
+ const revision = options.revision === undefined ? 1 : Number(options.revision);
+ if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid revision for ${entry.logicalKey}`);
+ const payload = { schemaVersion: entry.schemaVersion, revision, operationId: String(options.operationId || randomId('op')),
+ updatedAt: options.updatedAt || nowIso(), state, data: normalized };
+ payload.checksum = checksum(payload.data);
+ return Object.freeze(payload);
+ }
+ function validateEnvelope(entry, envelope) {
+ try {
+ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)
+ || Number(envelope.schemaVersion) !== Number(entry.schemaVersion)
+ || !Number.isInteger(Number(envelope.revision)) || Number(envelope.revision) < 1
+ || typeof envelope.operationId !== 'string' || !envelope.operationId
+ || typeof envelope.updatedAt !== 'string' || !envelope.updatedAt
+ || (envelope.state !== 'present' && envelope.state !== 'cleared')) return false;
+ const data = canonicalizeJson(envelope.data, `$.${entry.logicalKey}`);
+ return (envelope.state !== 'cleared' || data === null)
+ && (envelope.state !== 'present' || entry.validate(data)) && envelope.checksum === checksum(data);
+ } catch (_) { return false; }
+ }
+ function operationId(value) {
+ if (value === undefined || value === null || value === '') return randomId('mutation');
+ if (typeof value !== 'string' || !value.trim()) throw validation('operationId must be a non-empty string');
+ return value;
+ }
+ function expectedRevision(value, label) {
+ if (value === undefined || value === null) return null;
+ const revision = Number(value);
+ if (!Number.isInteger(revision) || revision < 0) throw validation(`Invalid expectedRevision for ${label}`);
+ return revision;
+ }
+ function compactJournal(journal) {
+ const ranked = Object.entries(journal).sort((left, right) => Number(right[1].sequence) - Number(left[1].sequence));
+ for (let index = OPERATION_JOURNAL_WINDOW; index < ranked.length; index += 1) delete journal[ranked[index][0]];
+ }
+ function readJournal(row) {
+ const envelope = row && row.envelope;
+ return envelope && envelope.state === 'present' && envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data)
+ ? clone(envelope.data) : {};
+ }
+ function journalResult(journal, spec) {
+ const existing = journal[spec.operationId];
+ if (!existing) return null;
+ if (existing.fingerprint !== spec.fingerprint || !existing.receipt) {
+ throw new AppDataError('CONFLICT', `operationId is already bound to another request: ${spec.operationId}`, { operationId: spec.operationId });
+ }
+ return clone(existing.receipt);
+ }
+ function writeJournal(journal, spec, receipt) {
+ const sequence = Object.values(journal).reduce((maximum, item) => Math.max(maximum, Number(item.sequence) || 0), 0) + 1;
+ journal[spec.operationId] = { fingerprint: spec.fingerprint, receipt: clone(receipt), sequence, committedAt: nowIso() };
+ compactJournal(journal);
+ return journal;
+ }
+ function putJournal(tx, currentRow, journal, spec, receipt) {
+ const current = currentRow && currentRow.envelope;
+ const envelope = makeEnvelope(lookupEntry('system.operationJournal'), writeJournal(journal, spec, receipt), {
+ revision: current ? Number(current.revision) + 1 : 1,
+ operationId: spec.operationId,
+ normalized: true
+ });
+ tx.objectStore(SYSTEM_STORE).put({ logicalKey: 'system.operationJournal', envelope: canonicalizeJson(envelope) });
+ }
+
+ class IndexedDBDriver {
+ constructor(indexedDBApi, options) {
+ this.indexedDB = indexedDBApi;
+ this.db = null;
+ this.mutationTimeoutMs = options.mutationTimeoutMs;
+ this.requestTimeoutMs = options.requestTimeoutMs;
+ }
+ async initialize() {
+ if (!this.indexedDB || typeof this.indexedDB.open !== 'function') throw new Error('IndexedDB unavailable');
+ this.db = await new Promise((resolve, reject) => {
+ const request = this.indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
+ let abandoned = false;
+ let settle;
+ request.onsuccess = () => { if (abandoned || !settle) { try { request.result.close(); } catch (_) {} } else settle.resolve(request.result); };
+ request.onupgradeneeded = (event) => {
+ const db = request.result;
+ if (event.oldVersion < 2) {
+ for (const name of ['authoritative', 'derived']) {
+ if (db.objectStoreNames.contains(name)) db.deleteObjectStore(name);
+ }
+ }
+ for (const name of STORE_NAMES) {
+ if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, { keyPath: name === DOCUMENT_STORE || name === SYSTEM_STORE ? 'logicalKey' : 'recordId' });
+ }
+ };
+ settle = withDeadline({ abort() { abandoned = true; } }, this.requestTimeoutMs, 'open', resolve, reject);
+ request.onerror = () => settle.reject(request.error || new Error('Unable to open IndexedDB'));
+ request.onblocked = () => {
+ abandoned = true;
+ settle.reject(new Error('IndexedDB upgrade blocked'));
+ };
+ });
+ this.db.onversionchange = () => this.close();
+ return this;
+ }
+ close() { const db = this.db; this.db = null; try { if (db) db.close(); } catch (_) {} }
+ _open() { if (!this.db) throw new Error('IndexedDB connection closed'); }
+ _transaction(stores, mode, description, work, mutation = false) {
+ this._open();
+ return new Promise((resolve, reject) => {
+ let failure = null;
+ let value;
+ let tx;
+ try { tx = this.db.transaction(stores, mode); } catch (error) { reject(error); return; }
+ const settle = withDeadline(tx, mutation ? this.mutationTimeoutMs : this.requestTimeoutMs, description, resolve, reject);
+ tx.oncomplete = () => settle.resolve(clone(value));
+ tx.onerror = (event) => {
+ const requestError = event && event.target && event.target.error;
+ const transactionError = tx.error;
+ if (requestError || transactionError) {
+ failure = failure || requestError || transactionError;
+ }
+ };
+ tx.onabort = () => settle.reject(failure || tx.error || new Error(`IndexedDB ${description} aborted`));
+ const fail = (error) => { failure = failure || error; try { tx.abort(); } catch (_) {} };
+ try { work(tx, (result) => { value = result; }, fail); } catch (error) { fail(error); }
+ });
+ }
+ readEnvelope(logicalKey) {
+ const store = storeFor(logicalKey);
+ return this._transaction([store], 'readonly', `read ${logicalKey}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).get(logicalKey);
+ request.onsuccess = () => done(request.result ? request.result.envelope : null);
+ request.onerror = () => fail(request.error || new Error(`Read failed: ${logicalKey}`));
+ });
+ }
+ readEntity(store, recordId) {
+ return this._transaction([store], 'readonly', `read ${store}/${recordId}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).get(recordId);
+ request.onsuccess = () => done(request.result || null);
+ request.onerror = () => fail(request.error || new Error('Entity read failed'));
+ });
+ }
+ readPracticeSnapshot(recordIds = null, options = {}) {
+ const stores = Array.isArray(options.stores) && options.stores.length
+ ? Array.from(new Set(options.stores.map((store) => entityStore(store))))
+ : ENTITY_STORES.slice();
+ const requested = recordIds === null || recordIds === undefined
+ ? null
+ : new Set((Array.isArray(recordIds) ? recordIds : [recordIds])
+ .map((value) => String(value || ''))
+ .filter(Boolean));
+ return this._transaction(stores, 'readonly', 'read practice snapshot', (tx, done, fail) => {
+ const result = Object.fromEntries(stores.map((store) => [store, []]));
+ let remaining = stores.length;
+ const finishStore = (store, rows) => {
+ result[store] = (rows || []).filter((row) => !requested || requested.has(String(row && row.recordId || '')));
+ remaining -= 1;
+ if (!remaining) done(result);
+ };
+ for (const store of stores) {
+ const objectStore = tx.objectStore(store);
+ const request = requested && requested.size === 1
+ ? objectStore.get(Array.from(requested)[0])
+ : objectStore.getAll();
+ request.onsuccess = () => {
+ const rows = requested && requested.size === 1
+ ? (request.result ? [request.result] : [])
+ : request.result;
+ finishStore(store, rows);
+ };
+ request.onerror = () => fail(request.error || new Error(`Practice snapshot read failed: ${store}`));
+ }
+ });
+ }
+ listEntities(store) {
+ return this._transaction([store], 'readonly', `list ${store}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).getAll();
+ request.onsuccess = () => done(request.result || []);
+ request.onerror = () => fail(request.error || new Error('Entity list failed'));
+ });
+ }
+ atomic(spec) {
+ return this._transaction(spec.stores, 'readwrite', `mutation ${spec.operationId}`, (tx, done, fail) => {
+ const journalRequest = tx.objectStore(SYSTEM_STORE).get('system.operationJournal');
+ journalRequest.onerror = () => fail(journalRequest.error || new Error('Journal read failed'));
+ journalRequest.onsuccess = () => {
+ try { spec.apply(tx, journalRequest.result || null, readJournal(journalRequest.result), done, fail); } catch (error) { fail(error); }
+ };
+ }, true);
+ }
+ exportSnapshot(envelopeKeys) {
+ return this._transaction(STORE_NAMES, 'readonly', 'snapshot export', (tx, done, fail) => {
+ const result = { envelopes: {}, entities: {} };
+ let remaining = STORE_NAMES.length;
+ for (const store of STORE_NAMES) {
+ const request = tx.objectStore(store).getAll();
+ request.onerror = () => fail(request.error || new Error(`Snapshot read failed: ${store}`));
+ request.onsuccess = () => {
+ if (store === DOCUMENT_STORE || store === SYSTEM_STORE) {
+ for (const row of request.result || []) if (envelopeKeys(row.logicalKey)) result.envelopes[row.logicalKey] = row.envelope;
+ } else result.entities[store] = request.result || [];
+ remaining -= 1;
+ if (!remaining) done(result);
+ };
+ }
+ });
+ }
+ }
+
+ function entityStore(store) {
+ const value = String(store || '');
+ if (!ENTITY_STORES.includes(value)) throw validation(`Unknown entity store: ${value}`, { store: value });
+ return value;
+ }
+ function validateEntityRow(store, row) {
+ if (!row || typeof row !== 'object' || Array.isArray(row)
+ || typeof row.recordId !== 'string' || !row.recordId
+ || !Number.isInteger(Number(row.revision)) || Number(row.revision) < 1
+ || typeof row.operationId !== 'string' || !row.operationId
+ || typeof row.updatedAt !== 'string' || !row.updatedAt) {
+ throw corruption(`Invalid entity row: ${store}`, { store, recordId: row && row.recordId || null });
+ }
+ const data = canonicalizeJson(row.data, `$.${store}.${row.recordId}`);
+ if (row.checksum !== checksum(data)) {
+ throw corruption(`Entity checksum mismatch: ${store}/${row.recordId}`, { store, recordId: row.recordId });
+ }
+ return row;
+ }
+ function normalizeEntityOperation(operation, index) {
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw validation(`Invalid entity operation at index ${index}`);
+ const type = String(operation.type || '');
+ const store = entityStore(operation.store);
+ if (!['upsert', 'delete', 'clear'].includes(type)) throw validation(`Invalid entity operation type: ${type}`);
+ const recordId = type === 'clear' ? null : String(operation.recordId || '');
+ if (type !== 'clear' && !recordId.trim()) throw validation(`Entity operation ${type} requires recordId`);
+ const data = type === 'upsert' ? canonicalizeJson(operation.data, `$.operations[${index}].data`) : null;
+ return { type, store, recordId, data, expectedRevision: expectedRevision(operation.expectedRevision, `${store}/${recordId || '*'}`) };
+ }
+ function receiptFor(operationIdValue, revisions, warnings, pending) {
+ const receipt = { committed: true, revisions, operationId: operationIdValue,
+ derived: { status: pending.length ? 'pending' : 'ready', pending: pending.slice() }, warnings: warnings.slice() };
+ const keys = Object.keys(revisions); if (keys.length === 1) receipt.revision = revisions[keys[0]];
+ return receipt;
+ }
+
+ class DataKernel {
+ constructor(options = {}) {
+ this.driver = null;
+ this.backend = null;
+ this.state = 'created';
+ this.failure = null;
+ this.indexedDB = Object.prototype.hasOwnProperty.call(options, 'indexedDB') ? options.indexedDB : global.indexedDB;
+ this.indexedDBMutationTimeoutMs = normalizeTimeoutMs(options.indexedDBMutationTimeoutMs, DEFAULT_IDB_MUTATION_TIMEOUT_MS);
+ this.indexedDBRequestTimeoutMs = normalizeTimeoutMs(options.indexedDBRequestTimeoutMs, DEFAULT_IDB_REQUEST_TIMEOUT_MS);
+ this.committedListeners = new Set();
+ this.commitChannel = null;
+ this.instanceId = randomId('kernel');
+ this.ready = null;
+ }
+ _initializeCommitChannel() {
+ if (this.commitChannel || typeof global.BroadcastChannel !== 'function') return;
+ try {
+ const channel = new global.BroadcastChannel(COMMIT_CHANNEL_NAME);
+ channel.onmessage = (message) => {
+ const data = message && message.data;
+ if (!data || data.sourceInstanceId === this.instanceId
+ || typeof data.operationId !== 'string' || !Array.isArray(data.targets)) return;
+ this._dispatchCommitted({
+ operationId: data.operationId,
+ targets: clone(data.targets),
+ receipt: data.receipt ? clone(data.receipt) : null,
+ remote: true
+ });
+ };
+ this.commitChannel = channel;
+ } catch (_) {
+ this.commitChannel = null;
+ }
+ }
+ _closeCommitChannel() {
+ const channel = this.commitChannel;
+ this.commitChannel = null;
+ try { if (channel) channel.close(); } catch (_) {}
+ }
+ initialize() {
+ if (this.ready) return this.ready;
+ this.state = 'initializing';
+ this.ready = new IndexedDBDriver(this.indexedDB, {
+ mutationTimeoutMs: this.indexedDBMutationTimeoutMs, requestTimeoutMs: this.indexedDBRequestTimeoutMs
+ }).initialize().then((driver) => {
+ this.driver = driver; this.backend = 'indexeddb-v2'; this.state = 'ready'; this._initializeCommitChannel(); return this;
+ }).catch((error) => { this.state = 'failed'; this.failure = error; this.driver = null; this.backend = null;
+ throw error instanceof AppDataError ? error : new AppDataError('BACKEND_UNAVAILABLE', 'IndexedDB is required for AppData v2', { cause: error && error.message }); });
+ return this.ready;
+ }
+ close() {
+ if (this.driver) this.driver.close();
+ this.driver = null; this.backend = null;
+ this._closeCommitChannel();
+ if (this.state !== 'failed') this.state = 'closed';
+ }
+ _assertReady() {
+ if (this.state === 'failed') throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 backend failed', { cause: this.failure && this.failure.message });
+ if (this.state !== 'ready' || !this.driver) throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 is not initialized');
+ }
+ _latch(error) {
+ const quotaName = String(error && error.name || '').toUpperCase();
+ const quotaCode = String(error && error.code || '').toUpperCase();
+ if (error && (
+ quotaName === 'QUOTAEXCEEDEDERROR'
+ || quotaName === 'NS_ERROR_DOM_QUOTA_REACHED'
+ || quotaCode === 'NS_ERROR_DOM_QUOTA_REACHED'
+ || quotaCode === 'QUOTAEXCEEDEDERROR'
+ || quotaCode === '22'
+ || quotaCode === '1014'
+ )) return new AppDataError('QUOTA_EXCEEDED', 'IndexedDB write failed: storage quota exceeded', { cause: error.message });
+ this.state = 'failed'; this.failure = error; if (this.driver) this.driver.close(); this.driver = null; this.backend = null; this._closeCommitChannel();
+ return new AppDataError('BACKEND_UNAVAILABLE', 'Active IndexedDB backend failed; reload is required', { cause: error && error.message });
+ }
+ onCommitted(listener) {
+ if (typeof listener !== 'function') throw validation('Committed listener must be a function');
+ this.committedListeners.add(listener); return () => this.committedListeners.delete(listener);
+ }
+ _dispatchCommitted(event) {
+ if (!event || !this.committedListeners.size) return;
+ const schedule = typeof global.queueMicrotask === 'function' ? global.queueMicrotask.bind(global) : (callback) => Promise.resolve().then(callback);
+ schedule(() => Array.from(this.committedListeners).forEach((listener) => { try { Promise.resolve(listener(clone(event))).catch(() => {}); } catch (_) {} }));
+ }
+ _notifyCommitted(targets, receipt) {
+ if (!targets.length) return;
+ const event = { operationId: receipt.operationId, targets: clone(targets), receipt: clone(receipt), remote: false };
+ this._dispatchCommitted(event);
+ if (this.commitChannel) {
+ try {
+ this.commitChannel.postMessage({
+ sourceInstanceId: this.instanceId,
+ operationId: event.operationId,
+ targets: event.targets,
+ receipt: event.receipt
+ });
+ } catch (_) { /* cross-realm notification is best effort */ }
+ }
+ }
+ async getEnvelope(logicalKey) {
+ this._assertReady(); const entry = lookupEntry(logicalKey);
+ try { const envelope = await this.driver.readEnvelope(logicalKey); if (envelope && !validateEnvelope(entry, envelope)) throw corruption(`Invalid envelope: ${logicalKey}`, { logicalKey }); return envelope; }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async read(logicalKey, options = {}) {
+ const entry = lookupEntry(logicalKey); const envelope = await this.getEnvelope(logicalKey);
+ const data = !envelope || envelope.state === 'cleared' ? entry.defaultValue() : envelope.data;
+ return options.withMeta ? { data: clone(data), envelope: envelope ? clone(envelope) : null } : clone(data);
+ }
+ _documentSpec(changes, options) {
+ if (!Array.isArray(changes) || (!changes.length && !options.allowNoop && !options.noop)) throw validation('DataKernel.mutate requires changes');
+ if (options.commitGuard !== undefined && typeof options.commitGuard !== 'function') throw validation('commitGuard must be a synchronous function');
+ const opId = operationId(options.operationId); const seen = new Set();
+ const prepared = changes.map((change, index) => {
+ if (!change || typeof change !== 'object' || Array.isArray(change)) throw validation(`Invalid mutation change at index ${index}`);
+ const logicalKey = String(change.logicalKey || ''); const entry = lookupEntry(logicalKey);
+ if (logicalKey === 'system.operationJournal') throw validation('system.operationJournal is managed by DataKernel');
+ if (seen.has(logicalKey)) throw validation(`Duplicate mutation key: ${logicalKey}`); seen.add(logicalKey);
+ const state = change.state === 'cleared' ? 'cleared' : 'present';
+ if (change.state !== undefined && state !== change.state) throw validation(`Invalid mutation state for ${logicalKey}`);
+ if (state === 'cleared' && entry.classification === 'system') throw validation(`${logicalKey} cannot be cleared`);
+ let data = null;
+ if (state === 'present') { try { data = canonicalizeJson(entry.normalize(canonicalizeJson(change.data)), '$.data'); } catch (error) { throw validation(`Unable to normalize ${logicalKey}`, { cause: error && error.message }); } if (!entry.validate(data)) throw validation(`Invalid data for ${logicalKey}`); }
+ return { logicalKey, entry, state, data, expectedRevision: expectedRevision(change.expectedRevision, logicalKey) };
+ });
+ const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings');
+ if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings');
+ const fingerprint = options.intent === undefined
+ ? checksum({ changes: prepared.map((item) => ({ logicalKey: item.logicalKey, state: item.state, data: item.data, expectedRevision: item.expectedRevision })), warnings })
+ : checksum({ mutationType: 'documents', intent: canonicalizeJson(options.intent, '$.intent'), warnings });
+ return {
+ operationId: opId,
+ changes: prepared,
+ pending: [],
+ warnings,
+ fingerprint,
+ commitGuard: typeof options.commitGuard === 'function' ? options.commitGuard : null,
+ stores: Array.from(new Set([SYSTEM_STORE].concat(prepared.map((item) => storeFor(item.logicalKey)))))
+ };
+ }
+ async mutate(changes, options = {}) {
+ this._assertReady(); const spec = this._documentSpec(changes, options);
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const assertCommitGuard = () => {
+ if (!spec.commitGuard) return;
+ let allowed = false;
+ try {
+ allowed = spec.commitGuard() === true;
+ } catch (error) {
+ throw new AppDataError('PRECONDITION_FAILED', 'Mutation commit guard threw', {
+ operationId: spec.operationId,
+ cause: error && error.message
+ });
+ }
+ if (!allowed) {
+ throw new AppDataError('PRECONDITION_FAILED', 'Mutation commit guard rejected the write', {
+ operationId: spec.operationId
+ });
+ }
+ };
+ const replay = journalResult(journal, spec);
+ if (replay) {
+ try { assertCommitGuard(); done(replay); } catch (error) { fail(error); }
+ return;
+ }
+ const reads = spec.changes.map((change) => ({ change, request: tx.objectStore(storeFor(change.logicalKey)).get(change.logicalKey) }));
+ let remaining = reads.length;
+ const finish = () => {
+ assertCommitGuard();
+ const revisions = {};
+ for (const item of reads) {
+ const current = item.request.result ? item.request.result.envelope : null;
+ if (current && !validateEnvelope(item.change.entry, current)) throw corruption(`Invalid stored envelope: ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey });
+ const revision = current ? Number(current.revision) : 0;
+ if (item.change.expectedRevision !== null && item.change.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey, expectedRevision: item.change.expectedRevision, actualRevision: revision });
+ const envelope = makeEnvelope(item.change.entry, item.change.data, { state: item.change.state, revision: revision + 1, operationId: spec.operationId, normalized: true });
+ tx.objectStore(storeFor(item.change.logicalKey)).put({ logicalKey: item.change.logicalKey, envelope: canonicalizeJson(envelope) }); revisions[item.change.logicalKey] = envelope.revision;
+ }
+ const receipt = receiptFor(spec.operationId, revisions, spec.warnings, []);
+ putJournal(tx, journalRow, journal, spec, receipt);
+ done(receipt);
+ };
+ if (!remaining) { finish(); return; }
+ for (const item of reads) { item.request.onerror = () => fail(item.request.error || new Error('Mutation read failed')); item.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; }
+ } }));
+ const targets = spec.changes.filter((change) => change.entry.owner !== 'backups' && (change.entry.classification === 'authoritative' || change.entry.classification === 'preference')).map((change) => ({ logicalKey: change.logicalKey, state: change.state, owner: change.entry.owner, classification: change.entry.classification }));
+ this._notifyCommitted(targets, receipt); return receipt;
+ } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD' || error.code === 'PRECONDITION_FAILED')) throw error; throw this._latch(error); }
+ }
+ async journalNoop(options = {}) { return this.mutate([], Object.assign({}, options, { allowNoop: true })); }
+ async readEntity(store, recordId, options = {}) {
+ this._assertReady(); store = entityStore(store); const id = String(recordId || ''); if (!id) throw validation('readEntity requires recordId');
+ try {
+ const row = await this.driver.readEntity(store, id);
+ if (!row) return null;
+ validateEntityRow(store, row);
+ return options.withMeta ? clone(row) : clone(row.data);
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async readPracticeSnapshot(recordIds = null, options = {}) {
+ this._assertReady();
+ const ids = recordIds === null || recordIds === undefined
+ ? null
+ : (Array.isArray(recordIds) ? recordIds : [recordIds])
+ .map((value) => String(value || ''))
+ .filter(Boolean);
+ try {
+ const snapshot = await this.driver.readPracticeSnapshot(ids, options);
+ const result = {};
+ const stores = Array.isArray(options.stores) && options.stores.length
+ ? Array.from(new Set(options.stores.map((store) => entityStore(store))))
+ : ENTITY_STORES;
+ for (const store of stores) {
+ const validRows = (snapshot && Array.isArray(snapshot[store]) ? snapshot[store] : [])
+ .filter((row) => {
+ try { validateEntityRow(store, row); return true; }
+ catch (error) {
+ if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false;
+ throw error;
+ }
+ });
+ result[store] = options.withMeta
+ ? clone(validRows)
+ : validRows.map((row) => clone(row.data));
+ }
+ return result;
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async listEntities(store, options = {}) {
+ this._assertReady(); store = entityStore(store);
+ if (store !== 'practiceSummaries') throw validation('Only practiceSummaries supports listEntities; load details and annotations by recordId');
+ try {
+ const rows = await this.driver.listEntities(store);
+ const validRows = rows.filter((row) => {
+ try { validateEntityRow(store, row); return true; }
+ catch (error) {
+ if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false;
+ throw error;
+ }
+ });
+ return options.withMeta ? clone(validRows) : validRows.map((row) => clone(row.data));
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async mutateEntities(operations, options = {}) {
+ this._assertReady(); if (!Array.isArray(operations) || !operations.length) throw validation('mutateEntities requires operations');
+ const opId = operationId(options.operationId); const items = operations.map(normalizeEntityOperation); const seen = new Set();
+ for (const item of items) { const key = `${item.store}/${item.recordId || '*'}`; if (seen.has(key)) throw validation(`Duplicate entity operation: ${key}`); seen.add(key); }
+ for (const store of ENTITY_STORES) {
+ const scoped = items.filter((item) => item.store === store);
+ if (scoped.some((item) => item.type === 'clear') && scoped.length > 1) {
+ throw validation(`Entity clear cannot be combined with other operations for ${store}`);
+ }
+ }
+ const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings');
+ if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings');
+ const fingerprint = options.intent === undefined
+ ? checksum({ operations: items, warnings })
+ : checksum({ mutationType: 'entities', intent: canonicalizeJson(options.intent, '$.intent'), warnings });
+ const spec = { operationId: opId, warnings, pending: [], fingerprint, stores: Array.from(new Set([SYSTEM_STORE].concat(items.map((item) => item.store)))) };
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const reads = items.filter((item) => item.type !== 'clear').map((item) => ({ item, request: tx.objectStore(item.store).get(item.recordId) })); let remaining = reads.length;
+ const finish = () => { const revisions = {};
+ for (const read of reads) { const current = read.request.result || null; const revision = current ? Number(current.revision) : 0;
+ if (read.item.expectedRevision !== null && read.item.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${read.item.store}/${read.item.recordId}`);
+ const key = `${read.item.store}/${read.item.recordId}`; if (read.item.type === 'delete') { tx.objectStore(read.item.store).delete(read.item.recordId); revisions[key] = revision + 1; } else { const next = { recordId: read.item.recordId, revision: revision + 1, operationId: spec.operationId, updatedAt: nowIso(), data: read.item.data }; next.checksum = checksum(next.data); tx.objectStore(read.item.store).put(next); revisions[key] = next.revision; }
+ }
+ for (const item of items.filter((item) => item.type === 'clear')) { tx.objectStore(item.store).clear(); revisions[`${item.store}/*`] = 0; }
+ const receipt = receiptFor(spec.operationId, revisions, warnings, []); putJournal(tx, journalRow, journal, spec, receipt); done(receipt); };
+ if (!remaining) { try { finish(); } catch (error) { fail(error); } return; }
+ for (const read of reads) { read.request.onerror = () => fail(read.request.error || new Error('Entity mutation read failed')); read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; }
+ } }));
+ this._notifyCommitted(items.map((item) => ({ store: item.store, recordId: item.recordId, type: item.type })), receipt); return receipt;
+ } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
+ }
+ async exportSnapshot(options = {}) {
+ this._assertReady();
+ try {
+ const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys.map((key) => String(key))) : null;
+ const shouldExport = (logicalKey) => {
+ if (!catalog.has(logicalKey)) return false;
+ const entry = lookupEntry(logicalKey);
+ if (selected && !selected.has(logicalKey)) return false;
+ if (entry.export === true) return true;
+ return options.includeSystem === true && entry.classification === 'system';
+ };
+ const data = await this.driver.exportSnapshot(shouldExport);
+ // Full/partial snapshots must be dense for their declared catalog
+ // range. An absent physical row means the catalog default, not an
+ // instruction that future importers should guess about.
+ for (const entry of catalog.list()) {
+ if (!shouldExport(entry.logicalKey)
+ || Object.prototype.hasOwnProperty.call(data.envelopes, entry.logicalKey)) continue;
+ data.envelopes[entry.logicalKey] = makeEnvelope(entry, null, {
+ state: 'cleared',
+ operationId: 'snapshot-default'
+ });
+ }
+ if (Array.isArray(options.entityStores)) {
+ const selectedStores = new Set(options.entityStores.map(entityStore));
+ for (const store of ENTITY_STORES) if (!selectedStores.has(store)) delete data.entities[store];
+ }
+ const payload = { envelopes: data.envelopes, entities: data.entities };
+ return { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: selected ? 'partial' : 'full', createdAt: nowIso(), backend: this.backend, envelopes: data.envelopes, entities: data.entities, checksum: checksum(payload) };
+ } catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async installSnapshot(snapshot, options = {}) {
+ this._assertReady(); const source = snapshot && snapshot.envelopes ? snapshot : { envelopes: snapshot, entities: {} };
+ const envelopes = canonicalizeJson(source.envelopes, '$.envelopes'); const entities = canonicalizeJson(source.entities || {}, '$.entities');
+ if (!envelopes || typeof envelopes !== 'object' || Array.isArray(envelopes) || !entities || typeof entities !== 'object' || Array.isArray(entities)) throw validation('Snapshot is invalid');
+ if (source.checksum && source.checksum !== checksum({ envelopes, entities })) throw validation('Snapshot checksum mismatch');
+ const changes = [];
+ for (const [logicalKey, envelope] of Object.entries(envelopes)) {
+ const entry = lookupEntry(logicalKey);
+ if (entry.classification === 'system' || entry.classification === 'session' || entry.import === 'ignore') continue;
+ if (!validateEnvelope(entry, envelope)) throw validation(`Invalid snapshot envelope: ${logicalKey}`);
+ changes.push({ logicalKey, entry, envelope });
+ }
+ const entityRows = {};
+ for (const store of ENTITY_STORES) {
+ if (!Object.prototype.hasOwnProperty.call(entities, store)) continue;
+ const rows = entities[store];
+ if (!Array.isArray(rows)) throw validation(`Invalid snapshot entities: ${store}`);
+ const ids = new Set();
+ entityRows[store] = rows.map((row) => {
+ if (!row || typeof row !== 'object' || !String(row.recordId || '')) throw validation(`Invalid snapshot entity: ${store}`);
+ const recordId = String(row.recordId);
+ if (ids.has(recordId)) throw validation(`Duplicate snapshot entity: ${store}/${recordId}`);
+ ids.add(recordId);
+ const data = canonicalizeJson(row.data);
+ if (!row.checksum || row.checksum !== checksum(data)) throw validation(`Invalid snapshot entity checksum: ${store}/${recordId}`);
+ const revision = row.revision === undefined ? 1 : Number(row.revision);
+ if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid snapshot entity revision: ${store}/${recordId}`);
+ return { recordId, revision, operationId: String(row.operationId || options.operationId || 'snapshot'), updatedAt: String(row.updatedAt || nowIso()), data, checksum: checksum(data) };
+ });
+ }
+ if (!changes.length && !Object.keys(entityRows).length) throw validation('Snapshot contains no importable data');
+ const resetJournal = options.resetJournal === true;
+ const expectedRevisionToken = options.expectedRevisionToken && typeof options.expectedRevisionToken === 'object'
+ ? canonicalizeJson(options.expectedRevisionToken, '$.expectedRevisionToken')
+ : null;
+ const opId = operationId(options.operationId || randomId('restore')); const spec = { operationId: opId, warnings: [], pending: [], fingerprint: checksum({ envelopes: changes.map((item) => [item.logicalKey, item.envelope]), entities: entityRows, resetJournal, expectedRevisionToken }), stores: STORE_NAMES.slice() };
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const documentChecks = expectedRevisionToken && expectedRevisionToken.documents || {};
+ const entityChecks = expectedRevisionToken && expectedRevisionToken.entities || {};
+ const reads = Object.entries(documentChecks).map(([logicalKey, expected]) => ({
+ kind: 'document', logicalKey, expected, request: tx.objectStore(DOCUMENT_STORE).get(logicalKey)
+ })).concat(Object.entries(entityChecks).map(([store, expected]) => ({
+ kind: 'entities', store, expected, request: tx.objectStore(store).getAll()
+ })));
+ const finish = () => {
+ for (const read of reads) {
+ if (read.kind === 'document') {
+ const current = read.request.result ? read.request.result.envelope : null;
+ const actualRevision = current ? Number(current.revision) : 0;
+ const expectedRevision = Number(read.expected) || 0;
+ if (actualRevision !== expectedRevision) {
+ throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.logicalKey}`, { logicalKey: read.logicalKey, expectedRevision, actualRevision });
+ }
+ } else {
+ const actual = Object.fromEntries((read.request.result || []).map((row) => [String(row.recordId), Number(row.revision) || 0]));
+ const expected = read.expected && typeof read.expected === 'object' ? read.expected : {};
+ const ids = new Set(Object.keys(actual).concat(Object.keys(expected)));
+ for (const recordId of ids) {
+ const current = actual[recordId] || 0;
+ const wanted = Number(expected[recordId]) || 0;
+ if (current !== wanted) {
+ throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.store}/${recordId}`, { store: read.store, recordId });
+ }
+ }
+ }
+ }
+ const revisions = {};
+ for (const item of changes) { tx.objectStore(DOCUMENT_STORE).put({ logicalKey: item.logicalKey, envelope: makeEnvelope(item.entry, item.envelope.data, { state: item.envelope.state, revision: item.envelope.revision, operationId: spec.operationId, normalized: true }) }); revisions[item.logicalKey] = Number(item.envelope.revision); }
+ for (const [store, rows] of Object.entries(entityRows)) {
+ tx.objectStore(store).clear();
+ for (const row of rows) tx.objectStore(store).put(row);
+ }
+ const receipt = receiptFor(spec.operationId, revisions, [], []); putJournal(tx, journalRow, resetJournal ? {} : journal, spec, receipt); done(receipt);
+ };
+ if (!reads.length) { try { finish(); } catch (error) { fail(error); } return; }
+ let remaining = reads.length;
+ for (const read of reads) {
+ read.request.onerror = () => fail(read.request.error || new Error('Snapshot revalidation read failed'));
+ read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } };
+ }
+ } }));
+ const targets = changes
+ .filter((item) => item.entry.owner !== 'backups')
+ .map((item) => ({ logicalKey: item.logicalKey, state: item.envelope.state, owner: item.entry.owner, classification: item.entry.classification }))
+ .concat(Object.keys(entityRows).map((store) => ({ store, recordId: null, type: 'replace' })));
+ this._notifyCommitted(targets, receipt);
+ return receipt;
+ }
+ catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
+ }
+ status() { return Object.freeze({ state: this.state, backend: this.backend, failure: this.failure ? this.failure.message : null }); }
+ }
+
+ Object.defineProperty(global, '__AppDataV2Internals', { value: { catalog, DataKernel, AppDataError, makeEnvelope, validateEnvelope, checksum, stableStringify, canonicalizeJson, clone, randomId, nowIso, parseLegacyValue, readLegacyValues, readLegacyExternalBackup, constants: Object.freeze({ DATABASE_NAME, DATABASE_VERSION, DOCUMENT_STORE, SYSTEM_STORE, ENTITY_STORES, OPERATION_JOURNAL_WINDOW }) }, enumerable: false, configurable: true, writable: false });
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
+/* ===== js/data/v2/appData.js ===== */
+(function installAppData(global) {
+ 'use strict';
+
+ const internals = global.__AppDataV2Internals;
+ if (!internals || typeof internals.DataKernel !== 'function') {
+ throw new Error('AppData v2 requires DataKernel');
+ }
+ const {
+ DataKernel,
+ AppDataError,
+ catalog,
+ clone,
+ randomId,
+ nowIso,
+ checksum
+ } = internals;
+ const kernel = new DataKernel();
+ const importPlans = new Map();
+ const RECOVERY_KEYS = Object.freeze({
+ activeSession: 'recovery.activeSessions',
+ draft: 'recovery.drafts',
+ interrupted: 'recovery.interrupted',
+ rejectedCompletion: 'recovery.rejectedCompletions'
+ });
+ const PREFERENCE_FIELDS = Object.freeze({
+ theme: 'theme', browse: 'browse', timer: 'timer', suite: 'suite', candidateCode: 'candidateCode',
+ resourceBasePrefix: 'resourceBasePrefix', onboarding: 'onboarding', readingDisplay: 'readingDisplay',
+ threeBackground: 'threeBackground', themePortal: 'themePortal', practiceWidget: 'practiceWidget',
+ consent: 'consent', logConfig: 'logConfig'
+ });
+ const PRACTICE_ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']);
+
+ function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
+ function asArray(value) { return Array.isArray(value) ? value : []; }
+ function idOf(value, fields) {
+ for (const field of fields) {
+ if (value && value[field] !== undefined && value[field] !== null && value[field] !== '') return String(value[field]);
+ }
+ return '';
+ }
+
+ function importedLibraryId(value, options = {}) {
+ const id = value === null || value === undefined ? '' : String(value).trim();
+ if (!id && options.nullable) return null;
+ if (!id) throw new AppDataError('VALIDATION', 'Imported library configuration id is required');
+ if (/^exam_index(?:_|$)/.test(id)) {
+ throw new AppDataError('VALIDATION', 'Unsupported library configuration id');
+ }
+ return id;
+ }
+ function assertObject(value, message) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new AppDataError('VALIDATION', message);
+ }
+ function assertArray(value, message) {
+ if (!Array.isArray(value)) throw new AppDataError('VALIDATION', message);
+ }
+ function jsonValue(value, label = 'value') {
+ try {
+ const serialized = JSON.stringify(value, (_key, current) => {
+ if (typeof current === 'bigint') return String(current);
+ if (typeof current === 'number' && !Number.isFinite(current)) return null;
+ return current;
+ });
+ if (serialized === undefined) return null;
+ return JSON.parse(serialized);
+ } catch (error) {
+ throw new AppDataError('VALIDATION', `${label} must be JSON-serializable`, { cause: error && error.message });
+ }
+ }
+ function operationId(command, prefix, semanticPayload = command) {
+ const id = command && command.operationId ? String(command.operationId) : randomId(prefix);
+ jsonValue(semanticPayload, `${prefix} payload`);
+ return id;
+ }
+ function mutationOptions(command, prefix, semanticPayload, extra = {}) {
+ const source = asObject(command);
+ const payload = jsonValue(semanticPayload, `${prefix} payload`);
+ const intent = { command: prefix, payload };
+ if (Object.prototype.hasOwnProperty.call(source, 'expectedRevision')) {
+ intent.expectedRevision = source.expectedRevision;
+ }
+ return Object.assign({}, extra, {
+ operationId: operationId(source, prefix, payload),
+ intent
+ });
+ }
+ function optionsMutationOptions(options, prefix, semanticPayload, extra = {}) {
+ return mutationOptions(asObject(options), prefix, semanticPayload, extra);
+ }
+ function deterministicEntityId(prefix, operation) {
+ return `${prefix}_${checksum({ operationId: String(operation) }).replace(/[^a-z0-9]+/gi, '')}`;
+ }
+ function normalizeAccuracyRatio(value, label = 'accuracy') {
+ if (value === undefined || value === null || value === '') return null;
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) {
+ throw new AppDataError('VALIDATION', `${label} must be between 0 and 100`);
+ }
+ return numeric > 1 ? numeric / 100 : numeric;
+ }
+ function defaultStats() {
+ return {
+ totalPractices: 0, totalQuestions: 0, correctAnswers: 0, averageAccuracy: 0,
+ reading: { practices: 0, questions: 0, correct: 0, accuracy: 0 },
+ listening: { practices: 0, questions: 0, correct: 0, accuracy: 0 },
+ lastUpdated: nowIso()
+ };
+ }
+
+ function firstNonNegative(...values) {
+ for (const value of values) {
+ if (value === null || value === undefined || value === '' || typeof value === 'object') continue;
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric;
+ }
+ return null;
+ }
+
+ function normalizePracticeScore(record) {
+ const scoreInfo = asObject(record.scoreInfo);
+ const legacyScoreInfo = asObject(asObject(record.realData).scoreInfo);
+ const overloadedAnswers = record.correctAnswers;
+ if (overloadedAnswers && typeof overloadedAnswers === 'object') {
+ record.correctAnswerMap = Object.assign(
+ {},
+ clone(asObject(overloadedAnswers)),
+ clone(asObject(record.correctAnswerMap))
+ );
+ }
+ const correct = firstNonNegative(
+ overloadedAnswers,
+ record.correctAnswersCount,
+ scoreInfo.correctAnswers,
+ scoreInfo.correct,
+ legacyScoreInfo.correctAnswers,
+ legacyScoreInfo.correct
+ );
+ if (correct !== null) record.correctAnswers = correct;
+ else if (overloadedAnswers && typeof overloadedAnswers === 'object') record.correctAnswers = 0;
+ const total = firstNonNegative(
+ record.totalQuestions,
+ record.questionCount,
+ scoreInfo.totalQuestions,
+ scoreInfo.total,
+ legacyScoreInfo.totalQuestions,
+ legacyScoreInfo.total
+ );
+ if (total !== null) record.totalQuestions = total;
+ }
+
+ function mergeAnswers(target, source) {
+ if (Array.isArray(source)) {
+ source.forEach((item, index) => {
+ if (!item || typeof item !== 'object') return;
+ const questionId = idOf(item, ['questionId', 'questionNumber', 'id', 'number']) || String(index + 1);
+ const answer = item.answer ?? item.value ?? item.userAnswer ?? item.selectedAnswer;
+ if (answer !== undefined) target[questionId] = clone(answer);
+ });
+ return;
+ }
+ for (const [questionId, answer] of Object.entries(asObject(source))) {
+ target[String(questionId)] = clone(answer);
+ }
+ }
+
+ function normalizePracticeAnswers(record) {
+ const answers = {};
+ const raw = asObject(record.rawData);
+ const rawReal = asObject(raw.realData);
+ const real = asObject(record.realData);
+ for (const source of [
+ rawReal.answerMap, rawReal.answerList, rawReal.answers,
+ raw.answerMap, raw.answerList, raw.answers,
+ real.answerMap, real.answerList, real.answers,
+ record.answerMap, record.answerList, record.answers
+ ]) mergeAnswers(answers, source);
+ if (Object.keys(answers).length) record.answers = answers;
+ }
+
+ function questionTypeErrorCounts(source) {
+ const counts = {};
+ const add = (type, count = 1) => {
+ const key = String(type || '').trim();
+ if (key && count > 0) counts[key] = (counts[key] || 0) + count;
+ };
+ for (const [type, value] of Object.entries(asObject(source && source.questionTypePerformance))) {
+ const metrics = asObject(value);
+ const total = firstNonNegative(metrics.totalQuestions, metrics.total);
+ const correct = firstNonNegative(metrics.correctAnswers, metrics.correct);
+ if (total !== null && correct !== null) add(type, Math.max(0, total - correct));
+ }
+ for (const detail of Object.values(asObject(asObject(source && source.scoreInfo).details))) {
+ if (detail && detail.isCorrect === false) add(detail.questionType || detail.type);
+ }
+ return counts;
+ }
+
+ function canonicalizeRecord(input) {
+ assertObject(input, 'practice record must be an object');
+ const record = jsonValue(input, 'practice record');
+ record.id = idOf(record, ['id', 'recordId', 'sessionId']) || randomId('record');
+ record.sessionId = idOf(record, ['sessionId']) || record.id;
+ record.timestamp = record.timestamp || record.completedAt || record.date || nowIso();
+ record.completedAt = record.completedAt || record.timestamp;
+ record.type = record.type || record.examType || (record.metadata && record.metadata.type) || 'practice';
+ record.metadata = asObject(record.metadata);
+ if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId;
+ if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId;
+ normalizePracticeAnswers(record);
+ normalizePracticeScore(record);
+ for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) {
+ if (record[field] === undefined || record[field] === null || record[field] === '') continue;
+ const numeric = Number(record[field]);
+ if (!Number.isFinite(numeric) || numeric < 0) throw new AppDataError('VALIDATION', `practice record ${field} must be a non-negative number`);
+ record[field] = numeric;
+ }
+ if (record.accuracy !== undefined) record.accuracy = normalizeAccuracyRatio(record.accuracy, 'practice record accuracy');
+ return jsonValue(record, 'canonical practice record');
+ }
+
+ function lightSuiteEntry(source, fallbackType = null) {
+ const entry = asObject(source);
+ const scoreInfo = asObject(entry.scoreInfo);
+ const realScoreInfo = asObject(asObject(entry.realData).scoreInfo);
+ const metadata = asObject(entry.metadata);
+ const totalQuestions = firstNonNegative(entry.totalQuestions, scoreInfo.totalQuestions, scoreInfo.total, realScoreInfo.totalQuestions, realScoreInfo.total) ?? 0;
+ const correctAnswers = firstNonNegative(entry.correctAnswers, scoreInfo.correctAnswers, scoreInfo.correct, realScoreInfo.correctAnswers, realScoreInfo.correct) ?? 0;
+ const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy;
+ const accuracy = normalizeAccuracyRatio(
+ explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0),
+ 'suite entry accuracy'
+ ) || 0;
+ const percentage = Number(entry.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0;
+ return jsonValue({
+ id: entry.id || null,
+ sessionId: entry.sessionId || null,
+ examId: entry.examId || metadata.examId || null,
+ title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '',
+ type: entry.type || metadata.type || fallbackType,
+ date: entry.date || entry.completedAt || entry.timestamp || null,
+ duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0,
+ totalQuestions,
+ correctAnswers,
+ accuracy,
+ percentage,
+ questionTypeErrorCounts: questionTypeErrorCounts(entry)
+ }, 'suite entry light projection');
+ }
+
+ function lightFromCanonical(source) {
+ const scoreInfo = asObject(source.scoreInfo);
+ const realScoreInfo = asObject(asObject(source.realData).scoreInfo);
+ const metadata = asObject(source.metadata);
+ const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field);
+ const dataSource = hasOwn(source, 'dataSource')
+ ? source.dataSource
+ : (hasOwn(metadata, 'dataSource') ? metadata.dataSource : undefined);
+ const totalQuestions = Number(source.totalQuestions ?? scoreInfo.totalQuestions ?? scoreInfo.total ?? realScoreInfo.totalQuestions ?? realScoreInfo.total ?? 0) || 0;
+ const correctAnswers = Number(source.correctAnswers ?? scoreInfo.correctAnswers ?? scoreInfo.correct ?? realScoreInfo.correctAnswers ?? realScoreInfo.correct ?? 0) || 0;
+ const explicitAccuracy = source.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy;
+ const accuracy = normalizeAccuracyRatio(
+ explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0),
+ 'practice light accuracy'
+ ) || 0;
+ return jsonValue({
+ id: source.id,
+ sessionId: source.sessionId,
+ examId: source.examId || source.metadata.examId || null,
+ title: source.title || source.examTitle || (source.metadata && source.metadata.examTitle) || source.metadata.title || '',
+ type: source.type,
+ mode: source.mode || source.practiceMode || null,
+ timestamp: source.timestamp,
+ completedAt: source.completedAt,
+ date: source.date || source.completedAt || source.timestamp || null,
+ startTime: source.startTime || null,
+ endTime: source.endTime || null,
+ duration: Number(source.duration ?? source.durationSeconds ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0,
+ totalQuestions,
+ correctAnswers,
+ accuracy,
+ percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0,
+ score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null,
+ questionTypeErrorCounts: questionTypeErrorCounts(source),
+ // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined`
+ // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。
+ // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。
+ dataSource,
+ // Summaries are list indexes. Keep only the metadata needed to filter, show a
+ // source label, or locate the originating library; details stay in their entity.
+ metadata: Object.fromEntries([
+ // `source` must stay: PracticeRecordSource uses metadata.source demo markers
+ // (e.g. onboarding-demo) so light/stats/achievements stay consistent with full.
+ 'examId', 'examTitle', 'title', 'type', 'category', 'frequency',
+ 'dataSource', 'source', 'libraryConfigurationId'
+ ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])),
+ suite: source.suite == null ? null : clone(asObject(source.suite)),
+ suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(
+ entry,
+ String(source.type || '').replace(/-suite$/, '') || null
+ ))
+ }, 'practice light projection');
+ }
+
+ function projectLight(record) {
+ if (!record) return null;
+ return lightFromCanonical(canonicalizeRecord(record));
+ }
+
+ function firstNonEmpty(...values) {
+ let first;
+ for (const value of values) {
+ if (value === undefined || value === null) continue;
+ if (first === undefined) first = value;
+ if (Array.isArray(value) && value.length) return clone(value);
+ if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length) return clone(value);
+ if (typeof value !== 'object') return clone(value);
+ }
+ return first === undefined ? {} : clone(first);
+ }
+
+ const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'questionTypeErrorCounts', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries']);
+ const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']);
+
+ function withoutRawData(value) {
+ if (Array.isArray(value)) return value.map(withoutRawData);
+ if (!value || typeof value !== 'object') return clone(value);
+ const clean = {};
+ for (const [key, item] of Object.entries(value)) {
+ if (key !== 'realData' && key !== 'rawData') clean[key] = withoutRawData(item);
+ }
+ return clean;
+ }
+
+ function splitPracticeRecord(input) {
+ const source = canonicalizeRecord(input);
+ const summary = lightFromCanonical(source);
+ const detail = { recordId: source.id };
+ const annotations = { recordId: source.id };
+ for (const [key, value] of Object.entries(source)) {
+ if (key === 'realData' || key === 'rawData' || key === 'answerMap' || key === 'answerList' || SUMMARY_FIELDS.has(key)) continue;
+ if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value);
+ else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => {
+ const next = Object.assign({}, asObject(entry));
+ const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData));
+ for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) {
+ if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]);
+ }
+ const annotation = {};
+ for (const annotationKey of ANNOTATION_FIELDS) {
+ if (hasOwn(next, annotationKey)) { annotation[annotationKey] = next[annotationKey]; delete next[annotationKey]; }
+ if (next.realData && hasOwn(next.realData, annotationKey)) delete next.realData[annotationKey];
+ if (next.rawData && hasOwn(next.rawData, annotationKey)) delete next.rawData[annotationKey];
+ }
+ delete next.realData; delete next.rawData;
+ if (Object.keys(annotation).length) {
+ if (!annotations.suiteEntries) annotations.suiteEntries = {};
+ annotations.suiteEntries[String(next.examId || asObject(next.metadata).examId || next.id || Object.keys(annotations.suiteEntries).length)] = annotation;
+ }
+ return withoutRawData(next);
+ });
+ else detail[key] = withoutRawData(value);
+ }
+ // Accept the old mirror only as an input normalization boundary; it is never persisted.
+ const realData = asObject(source.realData); const rawData = asObject(source.rawData);
+ for (const key of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) {
+ if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]);
+ }
+ for (const key of ANNOTATION_FIELDS) {
+ if (hasOwn(annotations, key)) continue;
+ if (hasOwn(realData, key)) annotations[key] = withoutRawData(realData[key]);
+ else if (hasOwn(rawData, key)) annotations[key] = withoutRawData(rawData[key]);
+ }
+ return { summary: jsonValue(summary, 'practice summary'), detail: jsonValue(detail, 'practice detail'), annotations: jsonValue(annotations, 'practice annotations') };
+ }
+
+ function joinPracticeRecord(summary, detail, annotations, projection = 'full') {
+ if (!summary) return null;
+ const mode = String(projection || 'full').toLowerCase();
+ const light = clone(summary);
+ if (mode === 'light' || mode === 'summary') return light;
+ const joined = Object.assign({}, light, clone(asObject(detail)));
+ delete joined.recordId;
+ if (mode === 'detail' || mode === 'medium') return jsonValue(joined, 'practice detail projection');
+ const annotationData = asObject(annotations);
+ for (const [key, value] of Object.entries(annotationData)) if (key !== 'recordId' && key !== 'suiteEntries') joined[key] = clone(value);
+ if (Array.isArray(joined.suiteEntries)) {
+ const suiteAnnotations = asObject(annotationData.suiteEntries);
+ joined.suiteEntries = joined.suiteEntries.map((entry) => Object.assign({}, entry, clone(suiteAnnotations[String(entry.examId || asObject(entry.metadata).examId || entry.id)] || {})));
+ }
+ return jsonValue(joined, 'practice full projection');
+ }
+
+ function projectDetail(record) { return joinPracticeRecord(splitPracticeRecord(record).summary, splitPracticeRecord(record).detail, null, 'detail'); }
+
+ // “什么算真实练习记录”只有一份定义(js/data/practiceRecordSource.js)。
+ // 这里必须硬性依赖而不是本地兜底:曾经投影器与 js/main.js 各写一套判定,
+ // 导致演示/种子记录在列表里看不见却计入统计与成就。缺失即启动失败,
+ // 让漏配 bundle 在开发期就暴露,而不是运行时静默退回旧语义。
+ const practiceRecordSource = global.PracticeRecordSource;
+ if (!practiceRecordSource || typeof practiceRecordSource.isRealPracticeRecord !== 'function') {
+ throw new Error('AppData v2 requires PracticeRecordSource (js/data/practiceRecordSource.js)');
+ }
+ const isRealPracticeRecord = practiceRecordSource.isRealPracticeRecord;
+
+ function computeStats(records) {
+ const stats = defaultStats();
+ for (const record of asArray(records).filter(isRealPracticeRecord)) {
+ const summary = projectLight(record);
+ const type = String(summary.type || '').toLowerCase();
+ const target = type.includes('listen') ? stats.listening : stats.reading;
+ stats.totalPractices += 1;
+ stats.totalQuestions += summary.totalQuestions;
+ stats.correctAnswers += summary.correctAnswers;
+ target.practices += 1;
+ target.questions += summary.totalQuestions;
+ target.correct += summary.correctAnswers;
+ }
+ stats.averageAccuracy = stats.totalQuestions ? (stats.correctAnswers / stats.totalQuestions) * 100 : 0;
+ for (const target of [stats.reading, stats.listening]) target.accuracy = target.questions ? (target.correct / target.questions) * 100 : 0;
+ stats.lastUpdated = nowIso();
+ return stats;
+ }
+
+ function validIso(value) {
+ if (value === null || value === undefined || value === '') return null;
+ const time = new Date(value).getTime();
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
+ }
+
+ function practiceType(record) {
+ const metadata = asObject(record.metadata);
+ const hints = [record.type, record.practiceType, metadata.type, metadata.examType, metadata.practiceType,
+ record.examId, record.title, metadata.examId, metadata.title].filter(Boolean).join(' ').toLowerCase();
+ if (hints.includes('listen') || hints.includes('audio') || hints.includes('hearing')) return 'listening';
+ if (hints.includes('read')) return 'reading';
+ return null;
+ }
+
+ function accuracyRatio(record) {
+ const summary = lightFromCanonical(record);
+ const value = Number(summary.accuracy);
+ if (!Number.isFinite(value)) return 0;
+ return Math.max(0, Math.min(1, value > 1 ? value / 100 : value));
+ }
+
+ function durationSeconds(record) {
+ const scoreInfo = asObject(record.scoreInfo);
+ const realData = asObject(record.realData);
+ const realScoreInfo = asObject(realData.scoreInfo);
+ for (const value of [record.duration, realData.duration, scoreInfo.duration, scoreInfo.timeSpent, realScoreInfo.duration, realScoreInfo.timeSpent]) {
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric;
+ }
+ return 0;
+ }
+
+ function earlierUnlock(left, right) {
+ const leftIso = validIso(left);
+ const rightIso = validIso(right);
+ if (!leftIso) return rightIso;
+ if (!rightIso) return leftIso;
+ return new Date(leftIso).getTime() <= new Date(rightIso).getTime() ? leftIso : rightIso;
+ }
+
+ function laterUnlock(left, right) {
+ const leftIso = validIso(left);
+ const rightIso = validIso(right);
+ if (!leftIso) return rightIso;
+ if (!rightIso) return leftIso;
+ return new Date(leftIso).getTime() >= new Date(rightIso).getTime() ? leftIso : rightIso;
+ }
+
+ function computeAchievementProgress(records, manual, existing) {
+ const items = asArray(records).filter(isRealPracticeRecord).map(canonicalizeRecord)
+ .map((record, index) => ({
+ record,
+ index,
+ unlockedAt: validIso(record.completedAt || record.timestamp),
+ time: new Date(record.completedAt || record.timestamp).getTime()
+ }))
+ .sort((left, right) => {
+ const leftTime = Number.isFinite(left.time) ? left.time : Number.MAX_SAFE_INTEGER;
+ const rightTime = Number.isFinite(right.time) ? right.time : Number.MAX_SAFE_INTEGER;
+ return leftTime - rightTime || left.index - right.index;
+ });
+ const candidates = {};
+ const setThreshold = (id, list, count) => {
+ if (list.length >= count) candidates[id] = list[count - 1].unlockedAt;
+ };
+ setThreshold('first_step', items, 1);
+ setThreshold('practice_bronze', items, 10);
+ setThreshold('practice_silver', items, 50);
+ setThreshold('practice_gold', items, 100);
+ setThreshold('practice_platinum', items, 200);
+
+ const reading = items.filter((item) => practiceType(item.record) === 'reading');
+ const listening = items.filter((item) => practiceType(item.record) === 'listening');
+ setThreshold('reading_first', reading, 1);
+ setThreshold('reading_bronze', reading, 10);
+ setThreshold('reading_silver', reading, 50);
+ setThreshold('reading_gold', reading, 100);
+ setThreshold('listening_first', listening, 1);
+ setThreshold('listening_bronze', listening, 10);
+ setThreshold('listening_silver', listening, 50);
+ setThreshold('listening_gold', listening, 100);
+ if (reading.length >= 10 && listening.length >= 10) candidates.balanced_foundation = laterUnlock(reading[9].unlockedAt, listening[9].unlockedAt);
+ if (reading.length >= 30 && listening.length >= 30) candidates.balanced_advanced = laterUnlock(reading[29].unlockedAt, listening[29].unlockedAt);
+
+ let cumulativeDuration = 0;
+ let cumulativeAccuracy = 0;
+ let perfectCount = 0;
+ let speedCount = 0;
+ for (let index = 0; index < items.length; index += 1) {
+ const item = items[index];
+ const accuracy = accuracyRatio(item.record);
+ const duration = durationSeconds(item.record);
+ cumulativeDuration += duration;
+ cumulativeAccuracy += accuracy;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_60') && cumulativeDuration >= 3600) candidates.time_focus_60 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_300') && cumulativeDuration >= 18000) candidates.time_focus_300 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_1000') && cumulativeDuration >= 60000) candidates.time_focus_1000 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_stable') && index + 1 >= 10 && cumulativeAccuracy / (index + 1) >= 0.7) candidates.accuracy_stable = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_elite') && index + 1 >= 20 && cumulativeAccuracy / (index + 1) >= 0.85) candidates.accuracy_elite = item.unlockedAt;
+ if (accuracy >= 1) {
+ perfectCount += 1;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_perfect')) candidates.accuracy_perfect = item.unlockedAt;
+ if (perfectCount === 3) candidates.perfect_three = item.unlockedAt;
+ if (perfectCount === 10) candidates.perfect_ten = item.unlockedAt;
+ }
+ if (duration > 0 && duration <= 300 && accuracy > 0.8) {
+ speedCount += 1;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'speed_demon')) candidates.speed_demon = item.unlockedAt;
+ if (speedCount === 3) candidates.speed_three = item.unlockedAt;
+ if (speedCount === 10) candidates.speed_ten = item.unlockedAt;
+ }
+ }
+
+ const dayItems = new Map();
+ for (const item of items) {
+ if (!item.unlockedAt) continue;
+ const day = item.unlockedAt.slice(0, 10);
+ if (!dayItems.has(day)) dayItems.set(day, item.unlockedAt);
+ }
+ const days = Array.from(dayItems.keys()).sort();
+ let streak = 0;
+ let previousDay = null;
+ for (const day of days) {
+ const currentDay = new Date(`${day}T00:00:00.000Z`).getTime();
+ streak = previousDay !== null && currentDay - previousDay === 86400000 ? streak + 1 : 1;
+ previousDay = currentDay;
+ if (streak === 3 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_bronze')) candidates.streak_bronze = dayItems.get(day);
+ if (streak === 7 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_silver')) candidates.streak_silver = dayItems.get(day);
+ if (streak === 30 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_gold')) candidates.streak_gold = dayItems.get(day);
+ if (streak === 60 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_platinum')) candidates.streak_platinum = dayItems.get(day);
+ }
+
+ const progress = {};
+ const mergeUnlocked = (source) => {
+ for (const [rawId, value] of Object.entries(asObject(source))) {
+ if (!value || rawId === 'updatedAt') continue;
+ const id = rawId;
+ const unlockedAt = value && typeof value === 'object' ? validIso(value.unlockedAt) : null;
+ if (!progress[id]) progress[id] = { unlockedAt };
+ else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt);
+ }
+ };
+ mergeUnlocked(existing);
+ mergeUnlocked(manual);
+ for (const [id, unlockedAt] of Object.entries(candidates)) {
+ if (!progress[id]) progress[id] = { unlockedAt: validIso(unlockedAt) };
+ else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt);
+ }
+ return jsonValue(progress, 'achievement progress');
+ }
+
+ // Entity records are authoritative. Projections are assembled on reads, never cached or
+ // scheduled as follow-up work; this keeps a successful write immediately observable.
+ async function retryMergeConflict(options, task, maxAttempts = 3) {
+ const explicitRevision = hasOwn(options, 'expectedRevision');
+ let lastError;
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ try {
+ return await task();
+ } catch (error) {
+ lastError = error;
+ if (explicitRevision || !error || error.code !== 'CONFLICT' || attempt + 1 >= maxAttempts) {
+ throw error;
+ }
+ }
+ }
+ throw lastError;
+ }
+
+ async function readCollectionMeta(logicalKey) {
+ const meta = await kernel.read(logicalKey, { withMeta: true });
+ return { items: asArray(meta.data), revision: meta.envelope ? Number(meta.envelope.revision) : 0 };
+ }
+
+ function retainBackupEntries(items, limit = 20, preserveIds = []) {
+ const cap = Math.max(1, Number(limit) || 20);
+ const newestFirst = (left, right) => String(right.timestamp || '').localeCompare(String(left.timestamp || ''));
+ const entries = asArray(items).filter(Boolean).sort(newestFirst);
+ const retained = [];
+ const retainedIds = new Set();
+ const requestedIds = new Set(asArray(preserveIds).map(String).filter(Boolean));
+ for (const item of entries) {
+ const id = String(item.id);
+ if (retained.length >= cap || retainedIds.has(id) || !requestedIds.has(id)) continue;
+ retained.push(item);
+ retainedIds.add(id);
+ }
+ for (const item of entries) {
+ const id = String(item.id);
+ if (retained.length >= cap) break;
+ if (retainedIds.has(id)) continue;
+ retained.push(item);
+ retainedIds.add(id);
+ }
+ return retained;
+ }
+
+ function hasOwn(value, key) {
+ return Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
+ }
+
+ function normalizeLibraryConfigurationId(value) {
+ return importedLibraryId(value, { nullable: true });
+ }
+
+ async function practiceRecordWithLibraryProvenance(source, command, options = {}) {
+ assertObject(source, 'practice record must be an object');
+ const record = jsonValue(source, 'practice record');
+ const metadata = asObject(record.metadata);
+ let configurationId;
+
+ if (hasOwn(command, 'libraryConfigurationId')) {
+ configurationId = command.libraryConfigurationId;
+ } else if (hasOwn(metadata, 'libraryConfigurationId')) {
+ configurationId = metadata.libraryConfigurationId;
+ } else if (hasOwn(record, 'libraryConfigurationId')) {
+ configurationId = record.libraryConfigurationId;
+ } else {
+ configurationId = await kernel.read('library.activeConfigurationId');
+ }
+
+ const normalizedId = normalizeLibraryConfigurationId(configurationId);
+ record.metadata = Object.assign({}, metadata, { libraryConfigurationId: normalizedId });
+
+ if (options.includeSuiteEntries && Array.isArray(record.suiteEntries)) {
+ record.suiteEntries = record.suiteEntries.map((entry) => {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry;
+ const next = jsonValue(entry, 'practice suite entry');
+ const entryMetadata = asObject(next.metadata);
+ const entryId = hasOwn(entryMetadata, 'libraryConfigurationId')
+ ? normalizeLibraryConfigurationId(entryMetadata.libraryConfigurationId)
+ : normalizedId;
+ next.metadata = Object.assign({}, entryMetadata, { libraryConfigurationId: entryId });
+ return next;
+ });
+ }
+
+ return record;
+ }
+
+ function practiceRecordMatches(record, identities) {
+ const expected = new Set(asArray(identities).map((value) => String(value || '')).filter(Boolean));
+ if (!expected.size || !record || typeof record !== 'object') return false;
+ return ['id', 'recordId', 'sessionId'].some((field) => {
+ const value = record[field];
+ return value !== undefined && value !== null && expected.has(String(value));
+ });
+ }
+
+ function practiceLayerId(row) {
+ return String(row && (row.recordId || row.id || row.sessionId) || '');
+ }
+ async function practiceLayers(recordId, withMeta = false) {
+ const snapshot = await kernel.readPracticeSnapshot([recordId], { withMeta });
+ const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null;
+ return { summary: find('practiceSummaries'), detail: find('practiceDetails'), annotations: find('practiceAnnotations') };
+ }
+ function entityRevision(row) { return row ? Number(row.revision) : 0; }
+ function practiceUpserts(recordId, layers, existing = {}) {
+ return [
+ { type: 'upsert', store: 'practiceSummaries', recordId, data: layers.summary, expectedRevision: entityRevision(existing.summary) },
+ { type: 'upsert', store: 'practiceDetails', recordId, data: layers.detail, expectedRevision: entityRevision(existing.detail) },
+ { type: 'upsert', store: 'practiceAnnotations', recordId, data: layers.annotations, expectedRevision: entityRevision(existing.annotations) }
+ ];
+ }
+ async function joinedPractice(recordId, projection, snapshot = null) {
+ const mode = String(projection || 'full').toLowerCase();
+ const stores = mode === 'light' || mode === 'summary'
+ ? ['practiceSummaries']
+ : (mode === 'detail' || mode === 'medium' ? ['practiceSummaries', 'practiceDetails'] : undefined);
+ const layers = snapshot || await kernel.readPracticeSnapshot([recordId], { stores });
+ const find = (store) => asArray(layers[store]).find((row) => practiceLayerId(row) === String(recordId)) || null;
+ const summary = find('practiceSummaries');
+ if (!summary) return null;
+ if (mode === 'light' || mode === 'summary') return clone(summary);
+ const detail = find('practiceDetails');
+ if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode);
+ return joinPracticeRecord(summary, detail, find('practiceAnnotations'), mode);
+ }
+ const practice = Object.freeze({
+ async list(options = {}) {
+ await ready;
+ const projection = String(options.projection || 'full').toLowerCase();
+ const summaries = await kernel.listEntities('practiceSummaries');
+ if (projection === 'light' || projection === 'summary') return summaries;
+ const stores = projection === 'detail' || projection === 'medium'
+ ? ['practiceSummaries', 'practiceDetails']
+ : undefined;
+ const snapshot = await kernel.readPracticeSnapshot(null, { stores });
+ return (await Promise.all(asArray(snapshot.practiceSummaries)
+ .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))).filter(Boolean);
+ },
+ async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); },
+ async completeAttempt(command) {
+ await ready;
+ const source = command && (command.record || command.attempt) ? (command.record || command.attempt) : command;
+ const mutation = mutationOptions(command, 'practice-complete', source);
+ const recordInput = await practiceRecordWithLibraryProvenance(source, command);
+ if (!idOf(recordInput, ['id', 'recordId', 'sessionId'])) recordInput.id = deterministicEntityId('record', mutation.operationId);
+ const layers = splitPracticeRecord(recordInput); const recordId = layers.summary.id;
+ const receipt = await retryMergeConflict(command || {}, async () => kernel.mutateEntities(
+ practiceUpserts(recordId, layers, await practiceLayers(recordId, true)), mutation));
+ return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') });
+ },
+ async finalizeSuite(command) {
+ await ready; assertObject(command, 'finalizeSuite command is required');
+ const mutation = mutationOptions(command, 'practice-suite', command);
+ const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true });
+ if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId);
+ const layers = splitPracticeRecord(input); const recordId = layers.summary.id;
+ const childIdentities = asArray(command.childRecordIds || command.childSessionIds).map(String);
+ const children = new Set((await kernel.listEntities('practiceSummaries'))
+ .filter((summary) => practiceRecordMatches(summary, childIdentities))
+ .map((summary) => idOf(summary, ['id', 'recordId', 'sessionId'])));
+ children.delete(recordId);
+ const receipt = await retryMergeConflict(command, async () => {
+ const existing = await practiceLayers(recordId, true);
+ const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id })));
+ return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation);
+ });
+ return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') });
+ },
+ async updateAnnotations(command) {
+ await ready; assertObject(command, 'updateAnnotations command is required'); const recordId = String(command.recordId || '');
+ return retryMergeConflict(command, async () => {
+ const current = await practiceLayers(recordId, true); if (!current.summary) throw new AppDataError('VALIDATION', `Unknown practice record: ${recordId}`);
+ if (command.expectedRevision !== undefined && Number(command.expectedRevision) !== entityRevision(current.annotations)) throw new AppDataError('CONFLICT', `Revision conflict for practice annotations ${recordId}`);
+ const annotations = Object.assign({ recordId }, clone(asObject(current.annotations && current.annotations.data)));
+ const detail = clone(asObject(current.detail && current.detail.data)); const examId = String(command.examId || current.summary.data.examId || 'default');
+ if (Array.isArray(detail.suiteEntries) && detail.suiteEntries.length) {
+ if (!detail.suiteEntries.some((entry) => String(entry.examId || asObject(entry.metadata).examId || '') === examId)) throw new AppDataError('VALIDATION', `Suite record ${recordId} does not contain exam ${examId}`);
+ annotations.suiteEntries = Object.assign({}, asObject(annotations.suiteEntries), { [examId]: Object.assign({}, asObject(annotations.suiteEntries)[examId], clone(asObject(command.patch))) });
+ } else {
+ if (current.summary.data.examId && String(current.summary.data.examId) !== examId) throw new AppDataError('VALIDATION', `Record ${recordId} does not match exam ${examId}`);
+ annotations.annotations = Object.assign({}, asObject(annotations.annotations), { [examId]: Object.assign({}, asObject(annotations.annotations)[examId], clone(asObject(command.patch))) });
+ Object.assign(annotations, clone(asObject(command.patch)));
+ }
+ return kernel.mutateEntities([{
+ type: 'upsert',
+ store: 'practiceAnnotations',
+ recordId,
+ data: annotations,
+ expectedRevision: entityRevision(current.annotations)
+ }], mutationOptions(command, 'practice-annotations', command));
+ });
+ },
+ async delete(command) {
+ await ready; const recordId = String(command && (command.recordId || command.id) || command || ''); if (!recordId) throw new AppDataError('VALIDATION', 'practice record id is required');
+ const found = await kernel.readEntity('practiceSummaries', recordId); if (!found) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete', { recordId })), { deletedCount: 0, noop: true });
+ const receipt = await kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId })), mutationOptions(command, 'practice-delete', { recordId }));
+ return Object.assign({}, receipt, { deletedCount: 1 });
+ },
+ async deleteMany(command) {
+ await ready; assertObject(command, 'practice.deleteMany command is required'); const recordIds = Array.from(new Set(asArray(command.recordIds).map(String).filter(Boolean)));
+ if (!recordIds.length) throw new AppDataError('VALIDATION', 'practice.deleteMany requires recordIds'); const summaries = await kernel.listEntities('practiceSummaries'); const ids = recordIds.filter((id) => summaries.some((item) => practiceRecordMatches(item, [id])));
+ if (!ids.length) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete-many', { recordIds })), { deletedCount: 0, noop: true });
+ const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length });
+ },
+ async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); },
+ async listInsights(options = {}) {
+ await ready;
+ const limit = Math.max(1, Math.min(50, Number(options.limit) || 10));
+ const summaries = (await kernel.listEntities('practiceSummaries'))
+ .slice()
+ .sort((left, right) => String(right.date || right.completedAt || right.timestamp || '')
+ .localeCompare(String(left.date || left.completedAt || left.timestamp || '')))
+ .slice(0, limit);
+ return Promise.all(summaries.map(async (summary) => {
+ if (Object.keys(asObject(summary.questionTypeErrorCounts)).length) return clone(summary);
+ const detail = await kernel.readEntity('practiceDetails', summary.id);
+ return jsonValue(Object.assign({}, clone(summary), {
+ questionTypeErrorCounts: questionTypeErrorCounts(detail)
+ }), 'practice insight');
+ }));
+ },
+ async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); },
+ projectLight,
+ projectDetail
+ });
+
+ const settings = Object.freeze({
+ async getAll() { await ready; return kernel.read('settings.values'); },
+ async patch(values, options = {}) {
+ await ready; assertObject(values, 'settings.patch requires an object');
+ const mutation = optionsMutationOptions(options, 'settings-patch', values);
+ return retryMergeConflict(options, async () => {
+ const current = await kernel.read('settings.values', { withMeta: true });
+ return kernel.mutate([{ logicalKey: 'settings.values', data: Object.assign({}, asObject(current.data), clone(values)), expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ });
+ },
+ async reset(options = {}) { await ready; const current = await kernel.read('settings.values', { withMeta: true }); return kernel.mutate([{ logicalKey: 'settings.values', state: 'cleared', expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], optionsMutationOptions(options, 'settings-reset', { reset: true })); }
+ });
+
+ const library = Object.freeze({
+ async listConfigurations() { await ready; return kernel.read('library.configurations'); },
+ async getActive() { await ready; return kernel.read('library.activeConfigurationId'); },
+ async getIndex(configurationId) {
+ await ready;
+ const id = importedLibraryId(configurationId, { nullable: true });
+ if (id === null) return [];
+ const indexes = await kernel.read('library.importedIndexes');
+ return asArray(indexes[id]);
+ },
+ async updateConfiguration(configuration, options = {}) {
+ await ready; assertObject(configuration, 'library.updateConfiguration requires an object');
+ const id = importedLibraryId(idOf(configuration, ['id', 'key', 'configId']));
+ const current = await kernel.read('library.configurations', { withMeta: true });
+ const configs = asArray(current.data);
+ const index = configs.findIndex((item) => idOf(item, ['id', 'key', 'configId']) === id);
+ const next = Object.assign({}, index >= 0 ? configs[index] : {}, clone(configuration), { id, key: id });
+ if (index >= 0) configs[index] = next; else configs.push(next);
+ return kernel.mutate([{ logicalKey: 'library.configurations', data: configs, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-config', configuration));
+ },
+ async activate(configurationId, options = {}) {
+ await ready;
+ const id = importedLibraryId(configurationId, { nullable: true });
+ const current = await kernel.read('library.activeConfigurationId', { withMeta: true });
+ return kernel.mutate([{ logicalKey: 'library.activeConfigurationId', data: id, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-activate', { configurationId: id }));
+ },
+ async import(command) {
+ await ready; assertObject(command, 'library.import requires a command');
+ const id = importedLibraryId(command.id || command.configurationId || randomId('library'));
+ const configsMeta = await kernel.read('library.configurations', { withMeta: true });
+ const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true });
+ const configs = asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id);
+ configs.push(Object.assign({}, asObject(command.configuration), { id, key: id }));
+ const indexes = Object.assign({}, asObject(indexesMeta.data), { [id]: asArray(command.index) });
+ return kernel.mutate([
+ { logicalKey: 'library.configurations', data: configs, expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 },
+ { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 }
+ ], mutationOptions(command, 'library-import', command));
+ },
+ async remove(configurationId, options = {}) {
+ await ready; const id = importedLibraryId(configurationId);
+ const configsMeta = await kernel.read('library.configurations', { withMeta: true });
+ const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true });
+ const activeMeta = await kernel.read('library.activeConfigurationId', { withMeta: true });
+ const indexes = Object.assign({}, asObject(indexesMeta.data)); delete indexes[id];
+ const changes = [
+ { logicalKey: 'library.configurations', data: asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id), expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 },
+ { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 }
+ ];
+ if (String(activeMeta.data || '') === id) {
+ changes.push({ logicalKey: 'library.activeConfigurationId', data: null, expectedRevision: activeMeta.envelope ? activeMeta.envelope.revision : 0 });
+ }
+ return kernel.mutate(changes, optionsMutationOptions(options, 'library-remove', { configurationId: id }));
+ },
+ async resolveIndex() {
+ await ready;
+ const [activeId, indexes] = await Promise.all([kernel.read('library.activeConfigurationId'), kernel.read('library.importedIndexes')]);
+ return activeId && Array.isArray(asObject(indexes)[activeId]) ? clone(indexes[activeId]) : clone([]);
+ }
+ });
+
+ function recoveryKey(kind) {
+ const key = RECOVERY_KEYS[String(kind || '')];
+ if (!key) throw new AppDataError('VALIDATION', `Unknown recovery kind: ${kind}`);
+ return key;
+ }
+ // Recovery document TTL is an AppData domain rule, not a catalog policy field.
+ const RECOVERY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+ function recoveryTimestamp(item) {
+ for (const field of ['updatedAt', 'lastActivity', 'tempSavedAt', 'timestamp', 'createdAt']) {
+ const parsed = Date.parse(item && item[field]);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return null;
+ }
+ async function pruneRecoveryKey(logicalKey) {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const items = asArray(current.data);
+ const cutoff = Date.now() - RECOVERY_TTL_MS;
+ const firstOwnersById = new Map();
+ items.forEach((item) => {
+ const entityId = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (entityId && !firstOwnersById.has(String(entityId))) {
+ firstOwnersById.set(String(entityId), item);
+ }
+ });
+ const retainedEntityIds = new Set();
+ firstOwnersById.forEach((owner, entityId) => {
+ const timestamp = recoveryTimestamp(owner);
+ if (timestamp === null || timestamp > cutoff) retainedEntityIds.add(entityId);
+ });
+ const retained = items.filter((item) => {
+ const entityId = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (entityId) return retainedEntityIds.has(String(entityId));
+ const timestamp = recoveryTimestamp(item);
+ return timestamp === null || timestamp > cutoff;
+ });
+ if (retained.length === items.length) return items;
+ try {
+ await kernel.mutate([{ logicalKey, data: retained, expectedRevision: current.envelope ? current.envelope.revision : 0 }], {
+ operationId: randomId('recovery-ttl')
+ });
+ return retained;
+ } catch (error) {
+ if (!(error instanceof AppDataError) || error.code !== 'CONFLICT' || attempt === 2) throw error;
+ }
+ }
+ return kernel.read(logicalKey);
+ }
+ async function cleanupExpiredRecovery() {
+ for (const logicalKey of Object.values(RECOVERY_KEYS)) await pruneRecoveryKey(logicalKey);
+ }
+ const windowSession = Object.freeze({
+ save(name, value) {
+ if (!global.sessionStorage) throw new AppDataError('BACKEND_UNAVAILABLE', 'sessionStorage unavailable');
+ const logicalName = String(name || 'default');
+ const payload = { schemaVersion: catalog.version, updatedAt: nowIso(), data: clone(value) };
+ global.sessionStorage.setItem(`ielts_atlas:v2:session:${logicalName}`, JSON.stringify(payload));
+ return true;
+ },
+ get(name) {
+ if (!global.sessionStorage) return null;
+ const raw = global.sessionStorage.getItem(`ielts_atlas:v2:session:${String(name || 'default')}`);
+ if (!raw) return null;
+ const payload = JSON.parse(raw);
+ return payload && payload.schemaVersion === catalog.version ? clone(payload.data) : null;
+ },
+ discard(name) {
+ if (global.sessionStorage) global.sessionStorage.removeItem(`ielts_atlas:v2:session:${String(name || 'default')}`);
+ return true;
+ }
+ });
+
+ const recoveryMutationTails = new Map();
+ function enqueueRecoveryMutation(logicalKey, task) {
+ const previous = recoveryMutationTails.get(logicalKey) || Promise.resolve();
+ const result = previous.then(task, task);
+ recoveryMutationTails.set(logicalKey, result.catch(() => undefined));
+ return result;
+ }
+
+ async function readRecovery(kind, id) {
+ await ready;
+ const firstItemsById = new Set();
+ const tombstonedFirstItems = new Set();
+ const items = (await pruneRecoveryKey(recoveryKey(kind))).filter((item) => {
+ const entityId = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (entityId) {
+ const normalizedId = String(entityId);
+ if (!firstItemsById.has(normalizedId)) {
+ firstItemsById.add(normalizedId);
+ if (item && item._recoveryTombstone === true) {
+ tombstonedFirstItems.add(normalizedId);
+ }
+ } else if (tombstonedFirstItems.has(normalizedId)) {
+ // saveRecovery/discardRecovery use findIndex over the raw collection.
+ // If that exact first owner is a tombstone, never expose a later
+ // duplicate as a writable entity. Non-tombstone duplicates remain
+ // visible because recovery reconciliation consumes their raw marker
+ // metadata while independently honoring first-owner CAS semantics.
+ return false;
+ }
+ }
+ return !(item && item._recoveryTombstone === true);
+ });
+ return id == null ? items : items.find((item) => idOf(item, ['id', 'sessionId', 'recordId']) === String(id)) || null;
+ }
+ async function readRecoveryFence(kind, id) {
+ await ready;
+ const normalizedId = String(id ?? '');
+ if (!normalizedId) {
+ return { id: normalizedId, exists: false, tombstoned: false, revision: 0 };
+ }
+ const items = await pruneRecoveryKey(recoveryKey(kind));
+ const owner = items.find((item) => (
+ idOf(item, ['id', 'sessionId', 'recordId']) === normalizedId
+ ));
+ if (!owner) {
+ return { id: normalizedId, exists: false, tombstoned: false, revision: 0 };
+ }
+ return {
+ id: normalizedId,
+ exists: true,
+ tombstoned: owner._recoveryTombstone === true,
+ revision: recoveryEntityRevision(owner)
+ };
+ }
+ function expectedRecoveryEntityRevision(options = {}) {
+ if (!Object.prototype.hasOwnProperty.call(options, 'expectedEntityRevision')) return null;
+ const revision = Number(options.expectedEntityRevision);
+ if (!Number.isSafeInteger(revision) || revision < 0) {
+ throw new AppDataError('VALIDATION', 'recovery expectedEntityRevision must be a non-negative safe integer');
+ }
+ return revision;
+ }
+ function recoveryEntityRevision(item) {
+ const revision = Number(item && item.revision);
+ return Number.isSafeInteger(revision) && revision >= 0 ? revision : 0;
+ }
+ function recoveryExclusiveGroup(options = {}) {
+ const group = String(options && options.exclusiveGroup || '').trim();
+ if (group.length > 128) {
+ throw new AppDataError('VALIDATION', 'recovery exclusiveGroup must not exceed 128 characters');
+ }
+ return group;
+ }
+ function recoveryEntityExclusiveGroup(item) {
+ const explicit = String(item && item._recoveryExclusiveGroup || '').trim();
+ if (explicit) return explicit;
+ const schema = String(item && item.schema || '').trim();
+ const version = Number(item && item.version);
+ if (version === 2 && schema === 'suite-session-v2') {
+ // Upgrade compatibility: suite recoveries written before group metadata was
+ // introduced still occupy the same logical singleton group.
+ return 'suite-practice';
+ }
+ return '';
+ }
+ function staleRecoveryReceipt(mutation, expectedRevision, actualRevision) {
+ return {
+ committed: false,
+ stale: true,
+ code: 'STALE_RECOVERY_WRITE',
+ operationId: mutation.operationId,
+ expectedEntityRevision: expectedRevision,
+ actualEntityRevision: actualRevision
+ };
+ }
+ function guardedRecoveryReceipt(mutation) {
+ return {
+ committed: false,
+ stale: true,
+ code: 'STALE_RECOVERY_WRITE',
+ reason: 'COMMIT_GUARD_REJECTED',
+ operationId: mutation.operationId
+ };
+ }
+ async function saveRecovery(kind, value, options = {}) {
+ await ready; assertObject(value, `recovery ${kind} value must be an object`);
+ if (options.commitGuard !== undefined && typeof options.commitGuard !== 'function') {
+ throw new AppDataError('VALIDATION', 'recovery commitGuard must be a synchronous function');
+ }
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-save`, value);
+ const expectedEntityRevision = expectedRecoveryEntityRevision(options);
+ const exclusiveGroup = recoveryExclusiveGroup(options);
+ const key = recoveryKey(kind);
+ const id = idOf(value, ['id', 'sessionId', 'recordId']) || deterministicEntityId('recovery', mutation.operationId);
+ const item = Object.assign({}, clone(value), { id: value.id || id, updatedAt: nowIso() });
+ if (exclusiveGroup) item._recoveryExclusiveGroup = exclusiveGroup;
+ if (expectedEntityRevision !== null && recoveryEntityRevision(item) <= expectedEntityRevision) {
+ throw new AppDataError('VALIDATION', 'recovery entity revision must advance beyond expectedEntityRevision');
+ }
+ let receipt;
+ try {
+ receipt = await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id);
+ if (expectedEntityRevision !== null) {
+ const actualEntityRevision = index >= 0 ? recoveryEntityRevision(current.items[index]) : 0;
+ if (actualEntityRevision !== expectedEntityRevision) {
+ return staleRecoveryReceipt(mutation, expectedEntityRevision, actualEntityRevision);
+ }
+ }
+ if (exclusiveGroup) {
+ const seenEntityIds = new Set();
+ const conflicting = current.items.find((entry) => {
+ const entryId = idOf(entry, ['id', 'sessionId', 'recordId']);
+ if (!entryId || seenEntityIds.has(entryId)) return false;
+ seenEntityIds.add(entryId);
+ // AppData CAS always updates the raw first owner for an id. Shadow
+ // duplicates neither conflict with that owner nor become a second
+ // logical group member; a first-owner tombstone hides the whole id.
+ return entryId !== id
+ && entry
+ && entry._recoveryTombstone !== true
+ && recoveryEntityExclusiveGroup(entry) === exclusiveGroup;
+ });
+ if (conflicting) {
+ return {
+ committed: false,
+ stale: true,
+ code: 'RECOVERY_GROUP_CONFLICT',
+ operationId: mutation.operationId,
+ conflictingEntityId: idOf(conflicting, ['id', 'sessionId', 'recordId']) || null
+ };
+ }
+ }
+ if (index >= 0) current.items[index] = item; else current.items.push(item);
+ const kernelOptions = typeof options.commitGuard === 'function'
+ ? Object.assign({}, mutation, { commitGuard: options.commitGuard })
+ : mutation;
+ return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], kernelOptions);
+ }));
+ } catch (error) {
+ if (error && error.code === 'PRECONDITION_FAILED') {
+ return guardedRecoveryReceipt(mutation);
+ }
+ throw error;
+ }
+ if (!receipt || receipt.committed !== true) return receipt;
+ const committedItem = (await kernel.read(key))
+ .find((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id);
+ return Object.assign({}, receipt, { item: clone(committedItem || item) });
+ }
+ async function discardRecovery(kind, id, options = {}) {
+ await ready;
+ if (options.commitGuard !== undefined && typeof options.commitGuard !== 'function') {
+ throw new AppDataError('VALIDATION', 'recovery commitGuard must be a synchronous function');
+ }
+ const key = recoveryKey(kind);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-discard`, { id: String(id) });
+ const expectedEntityRevision = expectedRecoveryEntityRevision(options);
+ try {
+ return await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === String(id));
+ const kernelOptions = typeof options.commitGuard === 'function'
+ ? Object.assign({}, mutation, { commitGuard: options.commitGuard })
+ : mutation;
+ if (expectedEntityRevision !== null) {
+ const actualEntityRevision = index >= 0 ? recoveryEntityRevision(current.items[index]) : 0;
+ if (actualEntityRevision !== expectedEntityRevision) {
+ return staleRecoveryReceipt(mutation, expectedEntityRevision, actualEntityRevision);
+ }
+ const tombstone = {
+ id: String(id),
+ revision: Math.min(Number.MAX_SAFE_INTEGER, actualEntityRevision + 1),
+ _recoveryTombstone: true,
+ discardedAt: Date.now(),
+ updatedAt: nowIso()
+ };
+ if (index >= 0) current.items[index] = tombstone;
+ else current.items.push(tombstone);
+ return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], kernelOptions);
+ }
+ const next = current.items.filter((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) !== String(id));
+ return kernel.mutate([{ logicalKey: key, data: next, expectedRevision: current.revision }], kernelOptions);
+ }));
+ } catch (error) {
+ if (error && error.code === 'PRECONDITION_FAILED') {
+ return guardedRecoveryReceipt(mutation);
+ }
+ throw error;
+ }
+ }
+ async function clearRecovery(kind, options = {}) {
+ await ready;
+ const key = recoveryKey(kind);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-clear`, { kind });
+ return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ if (options.expectedRevision !== undefined && Number(options.expectedRevision) !== current.revision) {
+ throw new AppDataError('CONFLICT', `Revision conflict while clearing recovery ${kind}`, { expectedRevision: options.expectedRevision, actualRevision: current.revision });
+ }
+ return kernel.mutate([{ logicalKey: key, state: 'cleared', expectedRevision: current.revision }], mutation);
+ }));
+ }
+ async function clearAllRecovery(options = {}) {
+ const results = {};
+ for (const kind of Object.keys(RECOVERY_KEYS)) {
+ results[kind] = await clearRecovery(kind, options);
+ }
+ return results;
+ }
+ function recoveryIdSet(source, kind) {
+ const values = source && Array.isArray(source[kind]) ? source[kind] : [];
+ return new Set(values.map((value) => String(value || '').trim()).filter(Boolean));
+ }
+ async function cleanupRecoveryForRetry(options = {}) {
+ await ready;
+ const preserve = options.preserve && typeof options.preserve === 'object' ? options.preserve : {};
+ const discardable = options.discardable && typeof options.discardable === 'object' ? options.discardable : {};
+ const removedByKind = {};
+ const receipts = {};
+ let removedCount = 0;
+
+ for (const kind of Object.keys(RECOVERY_KEYS)) {
+ const key = recoveryKey(kind);
+ const preservedIds = recoveryIdSet(preserve, kind);
+ const discardableIds = recoveryIdSet(discardable, kind);
+ const result = await enqueueRecoveryMutation(key, () => retryMergeConflict({}, async () => {
+ const current = await readCollectionMeta(key);
+ const cutoff = Date.now() - RECOVERY_TTL_MS;
+ const removedIds = [];
+ const firstOwnersById = new Map();
+ current.items.forEach((item) => {
+ const entityId = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (entityId && !firstOwnersById.has(String(entityId))) {
+ firstOwnersById.set(String(entityId), item);
+ }
+ });
+ const retainedEntityIds = new Set();
+ firstOwnersById.forEach((owner, entityId) => {
+ if (preservedIds.has(entityId)) {
+ retainedEntityIds.add(entityId);
+ return;
+ }
+ const timestamp = recoveryTimestamp(owner);
+ const expired = timestamp !== null && timestamp <= cutoff;
+ const tombstone = owner && owner._recoveryTombstone === true;
+ const explicitlyDiscardable = !tombstone && discardableIds.has(entityId);
+ if (expired || explicitlyDiscardable) {
+ removedIds.push(entityId);
+ } else {
+ retainedEntityIds.add(entityId);
+ }
+ });
+ const retained = current.items.filter((item) => {
+ const id = idOf(item, ['id', 'sessionId', 'recordId']);
+ if (id) return retainedEntityIds.has(String(id));
+ const timestamp = recoveryTimestamp(item);
+ const expired = timestamp !== null && timestamp <= cutoff;
+ const tombstone = item && item._recoveryTombstone === true;
+ if (tombstone && !expired) return true;
+ return !expired;
+ });
+ if (retained.length === current.items.length) {
+ return { receipt: null, removedIds: [] };
+ }
+ const receipt = await kernel.mutate([{
+ logicalKey: key,
+ data: retained,
+ expectedRevision: current.revision
+ }], {
+ operationId: randomId(`recovery-cleanup-${kind}`)
+ });
+ return { receipt, removedIds };
+ }));
+ removedByKind[kind] = result.removedIds;
+ removedCount += result.removedIds.length;
+ if (result.receipt) receipts[kind] = result.receipt;
+ }
+
+ return {
+ committed: true,
+ removedCount,
+ removedByKind,
+ receipts
+ };
+ }
+ const recovery = Object.freeze({
+ windowSession,
+ async clear(options = {}) { return clearAllRecovery(options); },
+ async cleanupForRetry(options = {}) { return cleanupRecoveryForRetry(options); },
+ async listActiveSessions() { return readRecovery('activeSession'); },
+ async getActiveSession(id) { return readRecovery('activeSession', id); },
+ async getActiveSessionFence(id) { return readRecoveryFence('activeSession', id); },
+ async saveActiveSession(value, options) { return saveRecovery('activeSession', value, options); },
+ async completeActiveSession(id, options) { return discardRecovery('activeSession', id, options); },
+ async discardActiveSession(id, options) { return discardRecovery('activeSession', id, options); },
+ async listDrafts() { return readRecovery('draft'); },
+ async getDraft(id) { return readRecovery('draft', id); },
+ async saveDraft(value, options) { return saveRecovery('draft', value, options); },
+ async discardDraft(id, options) { return discardRecovery('draft', id, options); },
+ async listInterrupted() { return readRecovery('interrupted'); },
+ async getInterrupted(id) { return readRecovery('interrupted', id); },
+ async saveInterrupted(value, options) { return saveRecovery('interrupted', value, options); },
+ async discardInterrupted(id, options) { return discardRecovery('interrupted', id, options); },
+ async listRejectedCompletions() { return readRecovery('rejectedCompletion'); },
+ async getRejectedCompletion(id) { return readRecovery('rejectedCompletion', id); },
+ async saveRejectedCompletion(value, options) { return saveRecovery('rejectedCompletion', value, options); },
+ async discardRejectedCompletion(id, options) { return discardRecovery('rejectedCompletion', id, options); }
+ });
+
+ function isImportableEntry(entry) {
+ return entry
+ && entry.classification !== 'system'
+ && entry.classification !== 'session'
+ && entry.import !== 'ignore';
+ }
+
+ function isPlainImportObject(value) {
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
+ }
+
+ function isV2SnapshotShape(parsed) {
+ return isPlainImportObject(parsed)
+ && parsed.format === 'ielts-atlas-data-v2'
+ && isPlainImportObject(parsed.envelopes)
+ && isPlainImportObject(parsed.entities);
+ }
+
+ const POISONED_V2_WRAPPER_ALIASES = Object.freeze({
+ 'settings.values': Object.freeze(['exam_system_settings', 'exam_system_user_settings', 'exam_system_system_settings']),
+ 'vocab.userConfig': Object.freeze(['exam_system_vocab_user_config']),
+ 'achievements.manual': Object.freeze(['exam_system_user_achievements', 'exam_system_achievement_manual_state'])
+ });
+ const LIBRARY_IMPORT_KEYS = Object.freeze([
+ 'library.configurations',
+ 'library.importedIndexes',
+ 'library.activeConfigurationId'
+ ]);
+
+ function canonicalizeV2Import(parsed) {
+ const warnings = [];
+ const repairedKeys = [];
+ const ignoredKeys = [];
+ const envelopes = {};
+ for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) {
+ if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`);
+ const envelope = clone(rawEnvelope);
+ const data = envelope && envelope.state === 'present' ? envelope.data : null;
+ if (logicalKey === 'library.activeConfigurationId' && String(data) === '[object Object]') {
+ ignoredKeys.push(logicalKey);
+ warnings.push('Skipped poisoned active library id');
+ continue;
+ }
+ if (isPlainImportObject(data)
+ && Object.prototype.hasOwnProperty.call(data, 'key')
+ && Object.prototype.hasOwnProperty.call(data, 'value')
+ && String(data.key || '').startsWith('exam_system_')) {
+ const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey] || [];
+ const decoded = aliases.includes(String(data.key)) ? internals.parseLegacyValue(data.value) : null;
+ if (!isPlainImportObject(decoded)) {
+ ignoredKeys.push(logicalKey);
+ warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`);
+ continue;
+ }
+ const overlay = Object.fromEntries(Object.entries(data)
+ .filter(([key]) => key !== 'key' && key !== 'value' && key !== 'timestamp'));
+ envelope.data = Object.assign({}, decoded, overlay);
+ envelope.checksum = checksum(envelope.data);
+ repairedKeys.push(logicalKey);
+ warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`);
+ }
+ envelopes[logicalKey] = envelope;
+ }
+
+ if (parsed.scope === 'full') {
+ const presentLibraryKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key));
+ if (presentLibraryKeys.length && presentLibraryKeys.length !== LIBRARY_IMPORT_KEYS.length) {
+ for (const key of presentLibraryKeys) {
+ delete envelopes[key];
+ ignoredKeys.push(key);
+ }
+ warnings.push('Skipped incomplete library data');
+ }
+ }
+ const exportableKeys = catalog.list()
+ .filter((entry) => entry.export === true && isImportableEntry(entry))
+ .map((entry) => entry.logicalKey);
+ const missingKeys = parsed.scope === 'full'
+ ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key))
+ : [];
+ const degraded = parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length);
+ return {
+ envelopes,
+ warnings,
+ repairedKeys,
+ ignoredKeys,
+ missingKeys,
+ declaredScope: parsed.scope,
+ effectiveScope: degraded ? 'partial' : parsed.scope,
+ trust: degraded ? 'degraded-partial' : (parsed.scope === 'full' ? 'trusted-full' : 'partial')
+ };
+ }
+
+ function resolveImportReplaceFlags(options = {}) {
+ const source = asObject(options);
+ const practiceMode = String(source.practiceMode || source.mergeMode || '').toLowerCase();
+ const replaceAll = source.replace === true;
+ return {
+ replaceDocuments: replaceAll,
+ // Call sites (practiceRecorder / boot-fallbacks) pass practiceMode replace|merge.
+ replacePractice: replaceAll || practiceMode === 'replace'
+ };
+ }
+
+ function pickFirstRecordArray(candidates) {
+ for (const candidate of asArray(candidates)) {
+ if (Array.isArray(candidate.records) && candidate.records.some(isPlainImportObject)) {
+ return { source: candidate.source, records: candidate.records };
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Historical v1 export shapes (opensource / pre-AppData-v2):
+ * - practiceRecorder.exportData: { exportDate, version, practiceRecords, userStats }
+ * - DataBackupManager: { exportInfo, practiceRecords, userStats?, backups? }
+ * - BackupAPI dual schema: practice_records / practiceRecords (+ nested data.*)
+ * - bare array of records, or { records: [...] }
+ * Recognition only — no dual backend and no local store migration.
+ */
+ function extractLegacyPracticeRecords(payload) {
+ const sources = [];
+ const add = (source, records) => {
+ if (Array.isArray(records) && records.some(isPlainImportObject)) {
+ sources.push({ source, records });
+ }
+ };
+
+ if (Array.isArray(payload)) {
+ add('(root array)', payload);
+ } else if (isPlainImportObject(payload)) {
+ const preferred = pickFirstRecordArray([
+ { source: 'practice_records', records: payload.practice_records },
+ { source: 'practiceRecords', records: payload.practiceRecords },
+ { source: 'records', records: payload.records }
+ ]);
+ if (preferred) add(preferred.source, preferred.records);
+
+ const data = isPlainImportObject(payload.data) ? payload.data : null;
+ if (data) {
+ const nested = pickFirstRecordArray([
+ { source: 'data.practice_records', records: data.practice_records },
+ { source: 'data.practiceRecords', records: data.practiceRecords }
+ ]);
+ if (nested) add(nested.source, nested.records);
+ else if (isPlainImportObject(data.practice_records)) add('data.practice_records.data', data.practice_records.data);
+ else if (isPlainImportObject(data.practiceRecords)) add('data.practiceRecords.data', data.practiceRecords.data);
+ if (isPlainImportObject(data.exam_system_practice_records)) {
+ add('data.exam_system_practice_records.data', data.exam_system_practice_records.data);
+ }
+ }
+ if (isPlainImportObject(payload.exam_system_practice_records)) {
+ add('exam_system_practice_records.data', payload.exam_system_practice_records.data);
+ }
+ }
+
+ const seen = new Set();
+ const records = [];
+ for (const entry of sources) {
+ for (const item of asArray(entry.records)) {
+ if (!isPlainImportObject(item)) continue;
+ const identity = idOf(item, ['id', 'recordId', 'sessionId']);
+ if (identity) {
+ if (seen.has(identity)) continue;
+ seen.add(identity);
+ }
+ records.push(item);
+ }
+ }
+ return {
+ records,
+ sources: sources.map((entry) => entry.source)
+ };
+ }
+
+ function entityRowFromLayer(recordId, data, operationId) {
+ const payload = jsonValue(data, 'import practice entity');
+ return {
+ recordId: String(recordId),
+ revision: 1,
+ operationId: String(operationId || `import-${recordId}`),
+ updatedAt: nowIso(),
+ data: payload,
+ checksum: checksum(payload)
+ };
+ }
+
+ function convertLegacyPracticeImport(payload) {
+ const extracted = extractLegacyPracticeRecords(payload);
+ if (!extracted.records.length) {
+ throw new AppDataError(
+ 'VALIDATION',
+ 'Import file is neither a v2 snapshot nor a recognizable v1 practice export'
+ );
+ }
+
+ const entities = {
+ practiceSummaries: [],
+ practiceDetails: [],
+ practiceAnnotations: []
+ };
+ const warnings = [];
+ let skipped = 0;
+
+ for (const raw of extracted.records) {
+ try {
+ const layers = splitPracticeRecord(raw);
+ const recordId = layers.summary.id;
+ const operationId = `import-v1-${recordId}`;
+ entities.practiceSummaries.push(entityRowFromLayer(recordId, layers.summary, operationId));
+ entities.practiceDetails.push(entityRowFromLayer(recordId, layers.detail, operationId));
+ entities.practiceAnnotations.push(entityRowFromLayer(recordId, layers.annotations, operationId));
+ } catch (error) {
+ skipped += 1;
+ warnings.push(`Skipped invalid practice record: ${error && error.message ? error.message : error}`);
+ }
+ }
+
+ if (!entities.practiceSummaries.length) {
+ throw new AppDataError('VALIDATION', 'Import file practice records could not be normalized');
+ }
+
+ const accepted = entities.practiceSummaries.length;
+ return {
+ format: 'v1',
+ scope: 'partial',
+ envelopes: {},
+ entities,
+ checksum: null,
+ warnings,
+ practiceSummary: {
+ accepted,
+ importedCount: accepted,
+ skippedCount: skipped,
+ sources: extracted.sources.slice()
+ }
+ };
+ }
+
+ function parseImportPayload(payload) {
+ let parsed;
+ try { parsed = typeof payload === 'string' ? JSON.parse(payload) : jsonValue(payload, 'import payload'); }
+ catch (error) {
+ if (error instanceof AppDataError) throw error;
+ throw new AppDataError('VALIDATION', 'Import payload is not valid JSON', { cause: error && error.message });
+ }
+
+ // Bare record arrays are a historical import convenience (UI file pickers).
+ if (Array.isArray(parsed)) return convertLegacyPracticeImport(parsed);
+ if (!parsed || typeof parsed !== 'object') throw new AppDataError('VALIDATION', 'Import payload must be an object');
+
+ if (isV2SnapshotShape(parsed)) {
+ if (Number(parsed.schemaVersion) !== Number(catalog.version)) {
+ throw new AppDataError('VALIDATION', 'Import schema version mismatch');
+ }
+ if (!parsed.checksum || parsed.checksum !== checksum({ envelopes: parsed.envelopes, entities: parsed.entities })) {
+ throw new AppDataError('VALIDATION', 'Import checksum mismatch');
+ }
+ if (parsed.scope !== 'full' && parsed.scope !== 'partial') {
+ throw new AppDataError('VALIDATION', 'Import scope must be full or partial');
+ }
+ const scope = parsed.scope;
+ for (const [store, rows] of Object.entries(parsed.entities)) {
+ if (!PRACTICE_ENTITY_STORES.includes(store) || !Array.isArray(rows)) {
+ throw new AppDataError('VALIDATION', `Invalid import entity store: ${store}`);
+ }
+ for (const row of rows) {
+ if (!row || typeof row !== 'object' || Array.isArray(row) || !String(row.recordId || '')) {
+ throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`);
+ }
+ }
+ }
+ if (scope === 'full' && PRACTICE_ENTITY_STORES.some((store) => !Object.prototype.hasOwnProperty.call(parsed.entities, store))) {
+ throw new AppDataError('VALIDATION', 'Full import is missing a practice entity layer');
+ }
+ const canonical = canonicalizeV2Import(parsed);
+ return {
+ format: 'v2',
+ scope: canonical.effectiveScope,
+ declaredScope: canonical.declaredScope,
+ envelopes: canonical.envelopes,
+ entities: parsed.entities,
+ checksum: parsed.checksum,
+ warnings: canonical.warnings,
+ practiceSummary: null,
+ repairedKeys: canonical.repairedKeys,
+ ignoredKeys: canonical.ignoredKeys,
+ missingKeys: canonical.missingKeys,
+ trust: canonical.trust
+ };
+ }
+
+ // Explicit but malformed v2 claims must not fall through to legacy parsers.
+ if (parsed.format === 'ielts-atlas-data-v2') {
+ throw new AppDataError('VALIDATION', 'Only valid v2 snapshots can be imported');
+ }
+
+ return convertLegacyPracticeImport(parsed);
+ }
+
+ function collectionIdentityFields(logicalKey) {
+ if (logicalKey === 'library.configurations') return ['id', 'key', 'configId'];
+ if (logicalKey.startsWith('recovery.')) return ['id', 'sessionId', 'recordId'];
+ if (logicalKey === 'backups.entries') return ['id'];
+ if (logicalKey === 'vocab.words') return ['id', 'word', 'key'];
+ if (logicalKey === 'goals.items') return ['id', 'goalId'];
+ return ['id', 'sessionId', 'recordId'];
+ }
+
+ function collectionIdentity(logicalKey, value) {
+ const identity = idOf(value, collectionIdentityFields(logicalKey));
+ return logicalKey === 'vocab.words' ? identity.trim().toLowerCase() : identity;
+ }
+
+ function mergeCollection(existing, incoming, logicalKey) {
+ const result = asArray(existing).map((item) => clone(item));
+ const positions = new Map();
+ result.forEach((item, index) => {
+ const identity = collectionIdentity(logicalKey, item);
+ if (identity) positions.set(identity, index);
+ });
+ for (const rawItem of asArray(incoming)) {
+ const item = jsonValue(rawItem, `${logicalKey} item`);
+ const identity = collectionIdentity(logicalKey, item);
+ if (!identity) throw new AppDataError('VALIDATION', `${logicalKey} import item has no stable identity`);
+ if (positions.has(identity)) result[positions.get(identity)] = item;
+ else {
+ positions.set(identity, result.length);
+ result.push(item);
+ }
+ }
+ return result;
+ }
+
+ function mergeImportValue(entry, existing, incoming) {
+ const policy = entry.import;
+ if (policy === 'merge-by-id') return mergeCollection(existing, incoming, entry.logicalKey);
+ if (policy === 'patch') {
+ if (Array.isArray(existing) || Array.isArray(incoming)) {
+ // Array-shaped keys should use merge-by-id; treat accidental patch as replace.
+ return clone(incoming);
+ }
+ return Object.assign({}, asObject(existing), asObject(incoming));
+ }
+ if (policy === 'replace') return clone(incoming);
+ throw new AppDataError('VALIDATION', `Unsupported import policy for ${entry.logicalKey}: ${policy}`);
+ }
+
+ async function currentEntitySnapshot() {
+ const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true });
+ const result = {};
+ for (const store of PRACTICE_ENTITY_STORES) {
+ if (store === 'practiceSummaries') result[store] = summaries;
+ else result[store] = (await Promise.all(summaries.map((summary) => kernel.readEntity(store, summary.recordId, { withMeta: true })))).filter(Boolean);
+ }
+ return result;
+ }
+ function practiceEntityIds(rows) {
+ return new Set(asArray(rows).map((row) => String(row && row.recordId || '')).filter(Boolean));
+ }
+ function assertPracticeEntitySetsMatch(entities, message) {
+ const expected = practiceEntityIds(entities.practiceSummaries);
+ for (const store of PRACTICE_ENTITY_STORES.slice(1)) {
+ const actual = practiceEntityIds(entities[store]);
+ if (actual.size !== expected.size || Array.from(expected).some((recordId) => !actual.has(recordId))) {
+ throw new AppDataError('VALIDATION', message || 'Practice import entity layers must contain the same recordIds', {
+ counts: Object.fromEntries(PRACTICE_ENTITY_STORES.map((name) => [name, practiceEntityIds(entities[name]).size]))
+ });
+ }
+ }
+ }
+ async function createImportPlan(parsed, options = {}) {
+ const { replaceDocuments, replacePractice } = resolveImportReplaceFlags(options);
+ const snapshot = { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: parsed.scope, envelopes: {}, entities: {} };
+ const revisionToken = { documents: {}, entities: {} };
+ const keys = []; const clearedKeys = [];
+ const warnings = asArray(parsed.warnings).map(String);
+ for (const [logicalKey, envelope] of Object.entries(asObject(parsed.envelopes))) {
+ if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`);
+ const entry = catalog.get(logicalKey); if (!isImportableEntry(entry)) continue;
+ if (!internals.validateEnvelope(entry, envelope)) throw new AppDataError('VALIDATION', `Invalid import envelope: ${logicalKey}`);
+ if (envelope.state === 'cleared' && !replaceDocuments && options.applyClears !== true) {
+ warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`);
+ continue;
+ }
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ revisionToken.documents[logicalKey] = current.envelope ? Number(current.envelope.revision) || 0 : 0;
+ let next = envelope;
+ if (!replaceDocuments && envelope.state === 'present') {
+ next = internals.makeEnvelope(entry, mergeImportValue(entry, current.data, envelope.data), { operationId: randomId('import-merge') });
+ }
+ snapshot.envelopes[logicalKey] = next;
+ keys.push(logicalKey);
+ if (next.state === 'cleared') clearedKeys.push(logicalKey);
+ }
+
+ // A full replace mirrors all exportable user data. Missing physical
+ // envelopes mean catalog defaults, represented here as explicit clears.
+ if (replaceDocuments && parsed.scope === 'full') {
+ for (const entry of catalog.list().filter((candidate) => candidate.export === true && isImportableEntry(candidate))) {
+ if (Object.prototype.hasOwnProperty.call(snapshot.envelopes, entry.logicalKey)) continue;
+ snapshot.envelopes[entry.logicalKey] = internals.makeEnvelope(entry, null, {
+ state: 'cleared',
+ operationId: randomId('import-clear')
+ });
+ keys.push(entry.logicalKey);
+ clearedKeys.push(entry.logicalKey);
+ }
+ }
+
+ // Any successful practice import installs all three stores together. Merge
+ // may update a subset only when the final recordId sets remain identical.
+ const sourceStores = Object.keys(asObject(parsed.entities));
+ let practiceExistingCount = null;
+ let practiceIncomingCount = null;
+ if (sourceStores.length) {
+ if (replacePractice && PRACTICE_ENTITY_STORES.some((store) => !sourceStores.includes(store))) {
+ throw new AppDataError('VALIDATION', 'Practice replace requires summaries, details, and annotations');
+ }
+ const current = await currentEntitySnapshot();
+ revisionToken.entities = Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, Object.fromEntries(
+ asArray(current[store]).map((row) => [String(row.recordId), Number(row.revision) || 0])
+ )]));
+ practiceExistingCount = asArray(current.practiceSummaries).length;
+ practiceIncomingCount = asArray(parsed.entities.practiceSummaries).length;
+ const existing = replacePractice
+ ? Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, []]))
+ : current;
+ for (const store of PRACTICE_ENTITY_STORES) {
+ const rows = asArray(existing[store]).map(clone);
+ const positions = new Map(rows.map((row, index) => [String(row.recordId), index]));
+ for (const row of asArray(parsed.entities[store])) {
+ if (!row || !String(row.recordId || '')) throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`);
+ const index = positions.get(String(row.recordId));
+ if (index === undefined) {
+ positions.set(String(row.recordId), rows.length);
+ rows.push(clone(row));
+ } else rows[index] = clone(row);
+ }
+ snapshot.entities[store] = rows;
+ }
+ assertPracticeEntitySetsMatch(snapshot.entities);
+ }
+
+ snapshot.checksum = checksum({ envelopes: snapshot.envelopes, entities: snapshot.entities });
+ const practiceSummary = parsed.practiceSummary
+ ? clone(parsed.practiceSummary)
+ : (Object.prototype.hasOwnProperty.call(snapshot.entities, 'practiceSummaries')
+ ? {
+ accepted: Number(practiceIncomingCount) || 0,
+ importedCount: Number(practiceIncomingCount) || 0,
+ skippedCount: 0,
+ existingCount: Number(practiceExistingCount) || 0,
+ incomingCount: Number(practiceIncomingCount) || 0,
+ finalCount: asArray(snapshot.entities.practiceSummaries).length,
+ removedCount: Math.max(0, (Number(practiceExistingCount) || 0)
+ - asArray(snapshot.entities.practiceSummaries).length)
+ }
+ : null);
+ if (practiceSummary && practiceSummary.existingCount === undefined) {
+ practiceSummary.existingCount = Number(practiceExistingCount) || 0;
+ practiceSummary.incomingCount = Number(practiceIncomingCount) || Number(practiceSummary.importedCount) || 0;
+ practiceSummary.finalCount = asArray(snapshot.entities.practiceSummaries).length;
+ practiceSummary.removedCount = Math.max(0, practiceSummary.existingCount - practiceSummary.finalCount);
+ }
+ const destructive = clearedKeys.length > 0
+ || Boolean(practiceSummary && Number(practiceSummary.removedCount) > 0);
+ return {
+ snapshot,
+ keys,
+ clearedKeys,
+ warnings,
+ practiceSummary,
+ destructive,
+ resetJournal: replaceDocuments && replacePractice,
+ revisionToken,
+ diagnostics: {
+ format: parsed.format,
+ replaceDocuments,
+ replacePractice,
+ declaredScope: parsed.declaredScope || parsed.scope,
+ effectiveScope: parsed.scope,
+ trust: parsed.trust || (parsed.format === 'v2' ? 'trusted-full' : 'degraded-partial'),
+ missingKeys: clone(parsed.missingKeys || []),
+ repairedKeys: clone(parsed.repairedKeys || []),
+ ignoredKeys: clone(parsed.ignoredKeys || [])
+ }
+ };
+ }
+ async function createRestoreSnapshot(backup) {
+ const parsed = parseImportPayload(asObject(backup && backup.data));
+ if (parsed.format !== 'v2') throw new AppDataError('VALIDATION', 'Only v2 snapshots can be restored from local backups');
+ if (backup.checksum && backup.checksum !== parsed.checksum) throw new AppDataError('VALIDATION', 'Backup checksum mismatch');
+ return (await createImportPlan(parsed, { replace: true })).snapshot;
+ }
+
+ const backups = Object.freeze({
+ onDataCommitted(listener) { return kernel.onCommitted(listener); },
+ async getSettings() { await ready; return kernel.read('backups.settings'); },
+ async setSettings(values, options = {}) { await ready; const current = await kernel.read('backups.settings', { withMeta: true }); return kernel.mutate([{ logicalKey: 'backups.settings', data: asObject(values), expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'backup-settings', values)); },
+ async getExportHistory() { await ready; return kernel.read('backups.exportHistory'); },
+ async getImportHistory() { await ready; return kernel.read('backups.importHistory'); },
+ async recordExport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.exportHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup export history entry'))); return kernel.mutate([{ logicalKey: 'backups.exportHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-export-history', entry)); },
+ async recordImport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.importHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup import history entry'))); return kernel.mutate([{ logicalKey: 'backups.importHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-import-history', entry)); },
+ async create(options = {}) {
+ await ready; const current = await readCollectionMeta('backups.entries');
+ const mutation = optionsMutationOptions(options, 'backup-create', { id: options.id || null, type: options.type || 'manual' });
+ const backupId = options.id || (options.operationId ? `backup_${checksum({ operationId: String(options.operationId) }).replace(/[^a-z0-9]/gi, '')}` : randomId('backup'));
+ const existing = current.items.find((item) => String(item.id) === String(backupId));
+ if (existing) {
+ if (String(existing.operationId || '') === String(mutation.operationId)
+ && String(existing.type || 'manual') === String(options.type || 'manual')) {
+ return clone(existing);
+ }
+ throw new AppDataError('CONFLICT', `Backup id already exists: ${backupId}`, {
+ backupId: String(backupId)
+ });
+ }
+ const snapshot = await kernel.exportSnapshot();
+ const backup = { id: backupId, operationId: mutation.operationId, timestamp: nowIso(), type: options.type || 'manual', version: 2, data: snapshot, size: JSON.stringify(snapshot).length, checksum: snapshot.checksum };
+ current.items.unshift(backup);
+ current.items = retainBackupEntries(current.items, 20, options.preserveIds);
+ await kernel.mutate([{ logicalKey: 'backups.entries', data: current.items, expectedRevision: current.revision }], mutation);
+ const committed = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(backupId));
+ return clone(committed || backup);
+ },
+ async list() { await ready; return kernel.read('backups.entries'); },
+ async delete(id, options = {}) { await ready; const current = await readCollectionMeta('backups.entries'); return kernel.mutate([{ logicalKey: 'backups.entries', data: current.items.filter((item) => String(item.id) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-delete', { id: String(id) })); },
+ async export(options = {}) {
+ await ready;
+ if (options.backupId !== undefined && options.backupId !== null) {
+ const backupId = String(options.backupId);
+ const stored = asArray(await kernel.read('backups.entries'))
+ .find((item) => String(item && item.id) === backupId);
+ if (!stored) throw new AppDataError('VALIDATION', `Unknown backup: ${backupId}`);
+ const portable = jsonValue(stored, 'stored backup export');
+ if (!portable.data || !portable.checksum || portable.checksum !== portable.data.checksum) {
+ throw new AppDataError('VALIDATION', `Backup checksum mismatch: ${backupId}`);
+ }
+ return portable;
+ }
+ const domains = Array.isArray(options.domains) ? new Set(options.domains.map(String)) : null;
+ const logicalKeys = domains
+ ? catalog.list()
+ .filter((entry) => domains.has(entry.owner) && entry.export === true)
+ .map((entry) => entry.logicalKey)
+ : null;
+ const entityStores = !domains || domains.has('practice')
+ ? undefined
+ : [];
+ return kernel.exportSnapshot(Object.assign(
+ logicalKeys ? { logicalKeys } : {},
+ entityStores ? { entityStores } : {}
+ ));
+ },
+ async previewImport(payload, options = {}) {
+ await ready; const parsed = parseImportPayload(payload); const prepared = await createImportPlan(parsed, options); const planId = randomId('import-plan');
+ const cutoff = Date.now() - (30 * 60 * 1000);
+ for (const [id, existing] of importPlans) {
+ if (Date.parse(existing.createdAt) < cutoff || importPlans.size >= 20) importPlans.delete(id);
+ }
+ const plan = { id: planId, format: parsed.format, scope: parsed.scope, keys: prepared.keys, clearedKeys: prepared.clearedKeys, warnings: prepared.warnings, createdAt: nowIso(), snapshot: prepared.snapshot, practiceSummary: prepared.practiceSummary, diagnostics: prepared.diagnostics, destructive: prepared.destructive, resetJournal: prepared.resetJournal, revisionToken: prepared.revisionToken, signature: checksum(prepared.snapshot) };
+ importPlans.set(planId, plan); return { id: planId, format: plan.format, scope: plan.scope, keys: plan.keys, clearedKeys: clone(plan.clearedKeys), warnings: clone(plan.warnings), createdAt: plan.createdAt, practice: clone(plan.practiceSummary), diagnostics: clone(plan.diagnostics), destructive: plan.destructive };
+ },
+ async commitImport(planId, options = {}) {
+ await ready; const plan = importPlans.get(String(planId)); if (!plan) throw new AppDataError('VALIDATION', `Unknown import plan: ${planId}`);
+ if (plan.destructive && options.confirmDestructive !== true) {
+ throw new AppDataError('VALIDATION', 'Destructive import requires explicit confirmation');
+ }
+ const mutation = optionsMutationOptions(options, 'import-commit', {
+ planId: plan.id,
+ signature: plan.signature
+ }, { warnings: plan.warnings });
+ const receipt = await kernel.installSnapshot(plan.snapshot, Object.assign({}, mutation, {
+ resetJournal: plan.resetJournal === true,
+ expectedRevisionToken: plan.revisionToken
+ }));
+ importPlans.delete(String(planId));
+ return Object.assign({}, receipt, plan.practiceSummary || {}, { practice: clone(plan.practiceSummary) });
+ },
+ async restore(id, options = {}) {
+ await ready; const backup = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(id));
+ if (!backup) throw new AppDataError('VALIDATION', `Unknown backup: ${id}`);
+ const snapshot = await createRestoreSnapshot(backup);
+ const restoreMutation = optionsMutationOptions(options, 'backup-restore', {
+ backupId: String(id),
+ checksum: backup.checksum || checksum(backup.data)
+ }, { resetJournal: true });
+ const preRestoreOperationId = `${restoreMutation.operationId}:pre-restore`;
+ const preRestoreBackupId = `pre_restore_${checksum({
+ operationId: restoreMutation.operationId,
+ backupId: String(id),
+ checksum: backup.checksum || checksum(backup.data)
+ }).replace(/[^a-z0-9]/gi, '')}`;
+ const preRestoreBackup = await backups.create({
+ id: preRestoreBackupId,
+ operationId: preRestoreOperationId,
+ type: 'pre-restore',
+ preserveIds: [String(id)]
+ });
+ const receipt = await kernel.installSnapshot(snapshot, restoreMutation);
+ return Object.assign({}, receipt, { preRestoreBackupId: preRestoreBackup.id });
+ }
+ });
+
+ let vocabMutationTail = Promise.resolve();
+ function enqueueVocabMutation(task) {
+ const result = vocabMutationTail.then(task, task);
+ vocabMutationTail = result.catch(() => undefined);
+ return result;
+ }
+ function retryVocabMutation(options, task) {
+ return enqueueVocabMutation(() => retryMergeConflict(options, task));
+ }
+
+ const vocab = Object.freeze({
+ async listWords() { await ready; return kernel.read('vocab.words'); },
+ async saveWords(words, options = {}) {
+ await ready; assertArray(words, 'vocab.saveWords requires an array');
+ const mutation = optionsMutationOptions(options, 'vocab-words', words);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.words', { withMeta: true });
+ return kernel.mutate([{
+ logicalKey: 'vocab.words',
+ data: words,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async getConfig() { await ready; return kernel.read('vocab.userConfig'); },
+ async setConfig(config, options = {}) {
+ await ready;
+ const mutation = optionsMutationOptions(options, 'vocab-config', config);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.userConfig', { withMeta: true });
+ return kernel.mutate([{
+ logicalKey: 'vocab.userConfig',
+ data: asObject(config),
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async patchConfig(patch, options = {}) {
+ await ready; assertObject(patch, 'vocab.patchConfig requires an object');
+ const mutation = optionsMutationOptions(options, 'vocab-config-patch', patch);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.userConfig', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), clone(patch));
+ return kernel.mutate([{
+ logicalKey: 'vocab.userConfig',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async activateList(listId, options = {}) { return this.patchConfig({ activeListId: String(listId || 'default') }, options); },
+ async listCollections() { await ready; return kernel.read('vocab.lists'); },
+ async saveCollection(id, value, options = {}) {
+ await ready;
+ const collectionId = String(id);
+ const mutation = optionsMutationOptions(options, 'vocab-list', { id: collectionId, value });
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) });
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async saveCollections(values, options = {}) {
+ await ready;
+ assertObject(values, 'vocab.saveCollections requires an object');
+ const upserts = Object.fromEntries(Object.entries(values).map(([id, value]) => [String(id), clone(value)]));
+ const mutation = optionsMutationOptions(options, 'vocab-lists-batch', upserts);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), upserts);
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async upsertCollectionWord(collectionId, word, options = {}) {
+ await ready; assertObject(word, 'vocab.upsertCollectionWord requires a word');
+ const id = String(collectionId || '');
+ if (!id) throw new AppDataError('VALIDATION', 'vocab collection id is required');
+ const identity = String(word.word || word.id || '').trim().toLowerCase();
+ if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required');
+ const mutation = optionsMutationOptions(options, 'vocab-word', { collectionId: id, word });
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const collections = Object.assign({}, asObject(current.data));
+ const existing = collections[id];
+ const list = existing && typeof existing === 'object' && !Array.isArray(existing)
+ ? Object.assign({}, clone(existing), { words: asArray(existing.words) })
+ : { id, words: asArray(existing) };
+ const index = list.words.findIndex((item) => String(item && (item.word || item.id) || '').trim().toLowerCase() === identity);
+ const nextWord = Object.assign({}, index >= 0 ? list.words[index] : {}, clone(word), { updatedAt: word.updatedAt || nowIso() });
+ if (!nextWord.createdAt) nextWord.createdAt = nextWord.updatedAt;
+ if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord);
+ list.updatedAt = nowIso();
+ collections[id] = list;
+ const receipt = await kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: collections,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { word: clone(nextWord) });
+ });
+ },
+ async readList(listId) { await ready; const id = String(listId || 'default'); if (id === 'default') return kernel.read('vocab.words'); const collections = await kernel.read('vocab.lists'); return Object.prototype.hasOwnProperty.call(collections, id) ? clone(collections[id]) : null; },
+ async replaceListWords(command, options = {}) {
+ await ready; assertObject(command, 'vocab.replaceListWords requires a command');
+ const id = String(command.listId || 'default'); const words = asArray(command.words);
+ if (id === 'default') return this.saveWords(words, options);
+ const mutation = optionsMutationOptions(options, 'vocab-list-words-replace', command);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const collections = Object.assign({}, asObject(current.data));
+ collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() });
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: collections,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async mergeListWords(command, options = {}) {
+ await ready;
+ assertObject(command, 'vocab.mergeListWords requires a command');
+ const listId = String(command.listId || 'default');
+ const incoming = asArray(command.words);
+ const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists';
+ const mutation = optionsMutationOptions(options, 'vocab-words-merge', command);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const collections = listId === 'default' ? null : Object.assign({}, asObject(current.data));
+ const storedList = listId === 'default'
+ ? asArray(current.data)
+ : (function readStoredCollection() {
+ const collection = collections[listId];
+ return collection && typeof collection === 'object' && !Array.isArray(collection)
+ ? asArray(collection.words)
+ : asArray(collection);
+ }());
+ const merged = storedList.map((word) => clone(word));
+ const positions = new Map();
+ merged.forEach((word, index) => {
+ const identity = String(word && (word.word || word.id) || '').trim().toLowerCase();
+ if (identity) positions.set(identity, index);
+ });
+ let addedCount = 0;
+ let updatedCount = 0;
+ for (const rawWord of incoming) {
+ assertObject(rawWord, 'vocab.mergeListWords entries must be objects');
+ const identity = String(rawWord.word || rawWord.id || '').trim().toLowerCase();
+ if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required');
+ if (!positions.has(identity)) {
+ positions.set(identity, merged.length);
+ merged.push(clone(rawWord));
+ addedCount += 1;
+ continue;
+ }
+ const index = positions.get(identity);
+ const existing = asObject(merged[index]);
+ const patch = {};
+ if (typeof rawWord.meaning === 'string' && rawWord.meaning.trim()) patch.meaning = rawWord.meaning.trim();
+ if (typeof rawWord.example === 'string' && rawWord.example.trim()) patch.example = rawWord.example.trim();
+ if (typeof rawWord.freq === 'number' && Number.isFinite(rawWord.freq)) patch.freq = rawWord.freq;
+ merged[index] = Object.assign({}, existing, patch, { updatedAt: nowIso() });
+ updatedCount += 1;
+ }
+ const data = listId === 'default'
+ ? merged
+ : Object.assign({}, collections, {
+ [listId]: Object.assign(
+ {},
+ (function collectionBaseForWrite() {
+ const collection = collections[listId];
+ return collection && typeof collection === 'object' && !Array.isArray(collection)
+ ? clone(collection)
+ : {};
+ }()),
+ { id: listId, words: merged, updatedAt: nowIso() }
+ )
+ });
+ const receipt = await kernel.mutate([{
+ logicalKey,
+ data,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { listId, words: clone(merged), addedCount, updatedCount });
+ });
+ },
+ async patchWord(command, options = {}) {
+ await ready; assertObject(command, 'vocab.patchWord requires a command');
+ const listId = String(command.listId || 'default'); const wordId = String(command.wordId || command.id || '');
+ if (!wordId) throw new AppDataError('VALIDATION', 'vocab word id is required');
+ const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists';
+ const mutation = optionsMutationOptions(
+ Object.assign({}, options, { operationId: command.operationId || options.operationId }),
+ 'vocab-word-patch',
+ command
+ );
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const collections = listId === 'default' ? null : asObject(current.data);
+ const list = listId === 'default'
+ ? asArray(current.data)
+ : asArray(asObject(collections[listId]).words);
+ const index = list.findIndex((word) => idOf(word, ['id', 'word', 'key']) === wordId);
+ if (index < 0) throw new AppDataError('VALIDATION', `Unknown vocab word: ${wordId}`);
+ const updated = Object.assign({}, list[index], clone(asObject(command.patch)), { id: list[index].id || wordId, updatedAt: nowIso() });
+ const next = list.slice(); next[index] = updated;
+ const data = listId === 'default'
+ ? next
+ : Object.assign({}, collections, {
+ [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() })
+ });
+ const receipt = await kernel.mutate([{
+ logicalKey,
+ data,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { word: clone(updated) });
+ });
+ },
+ async replaceProgress(command, options = {}) {
+ await ready; assertObject(command, 'vocab.replaceProgress requires a command');
+ const listId = String(command.listId || 'default'); const words = asArray(command.words);
+ const mutation = optionsMutationOptions(options, 'vocab-progress', command);
+ return retryVocabMutation(options, async () => {
+ const configMeta = await kernel.read('vocab.userConfig', { withMeta: true });
+ const changes = [{
+ logicalKey: 'vocab.userConfig',
+ data: Object.assign({}, asObject(configMeta.data), asObject(command.config), { activeListId: listId }),
+ expectedRevision: configMeta.envelope ? configMeta.envelope.revision : 0
+ }];
+ if (listId === 'default') {
+ const wordsMeta = await kernel.read('vocab.words', { withMeta: true });
+ changes.push({ logicalKey: 'vocab.words', data: words, expectedRevision: wordsMeta.envelope ? wordsMeta.envelope.revision : 0 });
+ } else {
+ const listsMeta = await kernel.read('vocab.lists', { withMeta: true }); const lists = Object.assign({}, asObject(listsMeta.data));
+ lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words });
+ changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 });
+ }
+ return kernel.mutate(changes, mutation);
+ });
+ }
+ });
+
+ async function readPreferences() { await ready; return kernel.read('preferences.values'); }
+ let preferenceMutationTail = Promise.resolve();
+ function enqueuePreferenceMutation(task) {
+ const result = preferenceMutationTail.then(task, task);
+ preferenceMutationTail = result.catch(() => undefined);
+ return result;
+ }
+ async function writePreference(field, value, options = {}) {
+ const mutation = optionsMutationOptions(options, 'preference-set', { field, value });
+ return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => {
+ const current = await kernel.read('preferences.values', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), { [field]: clone(value) });
+ return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ }));
+ }
+ async function patchPreference(field, patch, options = {}) {
+ await ready;
+ const mutation = optionsMutationOptions(options, 'preference-patch', { field, patch });
+ return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => {
+ const current = await kernel.read('preferences.values', { withMeta: true });
+ const values = asObject(current.data);
+ const next = Object.assign({}, values, { [field]: Object.assign({}, asObject(values[field]), asObject(patch)) });
+ return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ }));
+ }
+ const preferences = Object.freeze({
+ async getAll() { return readPreferences(); },
+ async getTheme() { return (await readPreferences())[PREFERENCE_FIELDS.theme] ?? null; }, async setTheme(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.theme, value, options); },
+ async getBrowse() { return clone((await readPreferences())[PREFERENCE_FIELDS.browse] ?? null); }, async setBrowse(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.browse, value, options); }, async patchBrowse(value, options) { return patchPreference(PREFERENCE_FIELDS.browse, value, options); },
+ async getTimer(scope) { const timer = clone((await readPreferences())[PREFERENCE_FIELDS.timer] ?? {}); return scope ? clone(timer[String(scope)] ?? null) : timer; }, async setTimer(scope, value, options) { return patchPreference(PREFERENCE_FIELDS.timer, { [String(scope)]: clone(value) }, options); },
+ async getSuite() { return clone((await readPreferences())[PREFERENCE_FIELDS.suite] ?? null); }, async setSuite(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.suite, value, options); }, async patchSuite(value, options) { return patchPreference(PREFERENCE_FIELDS.suite, value, options); },
+ async getCandidateCode() { return (await readPreferences())[PREFERENCE_FIELDS.candidateCode] ?? null; }, async setCandidateCode(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.candidateCode, value, options); }
+ ,async getResourceBasePrefix() { return (await readPreferences())[PREFERENCE_FIELDS.resourceBasePrefix] ?? null; }, async setResourceBasePrefix(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.resourceBasePrefix, value, options); },
+ async getOnboarding() { return clone((await readPreferences())[PREFERENCE_FIELDS.onboarding] ?? {}); }, async setOnboarding(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.onboarding, asObject(value), options); },
+ async getReadingDisplay() { return clone((await readPreferences())[PREFERENCE_FIELDS.readingDisplay] ?? null); }, async setReadingDisplay(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.readingDisplay, value, options); },
+ async getThreeBackground() { return (await readPreferences())[PREFERENCE_FIELDS.threeBackground] ?? null; }, async setThreeBackground(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.threeBackground, value, options); },
+ async getThemePortal() { return clone((await readPreferences())[PREFERENCE_FIELDS.themePortal] ?? null); }, async setThemePortal(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.themePortal, value, options); },
+ async getPracticeWidget() { return (await readPreferences())[PREFERENCE_FIELDS.practiceWidget] ?? null; }, async setPracticeWidget(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.practiceWidget, value, options); },
+ async getConsent() { return clone((await readPreferences())[PREFERENCE_FIELDS.consent] ?? {}); }, async setConsent(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.consent, asObject(value), options); },
+ async getLogConfig() { return clone((await readPreferences())[PREFERENCE_FIELDS.logConfig] ?? null); }, async setLogConfig(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.logConfig, asObject(value), options); }
+ });
+
+ const goals = Object.freeze({
+ async list() { await ready; return kernel.read('goals.items'); },
+ async save(goal, options = {}) { await ready; assertObject(goal, 'goals.save requires an object'); const mutation = optionsMutationOptions(options, 'goal-save', goal); const current = await readCollectionMeta('goals.items'); const id = idOf(goal, ['id', 'goalId']) || deterministicEntityId('goal', mutation.operationId); const item = Object.assign({}, clone(goal), { id }); const index = current.items.findIndex((entry) => idOf(entry, ['id', 'goalId']) === id); if (index >= 0) current.items[index] = item; else current.items.push(item); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items, expectedRevision: current.revision }], mutation); },
+ async delete(id, options = {}) { await ready; const current = await readCollectionMeta('goals.items'); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items.filter((item) => idOf(item, ['id', 'goalId']) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'goal-delete', { id: String(id) })); }
+ });
+
+ function deliveryTimestamp(value) {
+ const candidate = value && typeof value === 'object' ? value.unlockedAt : value;
+ const time = typeof candidate === 'string' && candidate.trim() ? Date.parse(candidate) : NaN;
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
+ }
+
+ function mergeDeliveryAcknowledgements(current, incoming) {
+ const merged = Object.assign({}, asObject(current));
+ for (const [id, value] of Object.entries(asObject(incoming))) {
+ const key = String(id).trim();
+ if (!key) continue;
+ const previous = deliveryTimestamp(merged[key]);
+ const next = deliveryTimestamp(value);
+ if (!hasOwn(merged, key) || (next && (!previous || next < previous))) {
+ merged[key] = next;
+ } else if (previous) {
+ merged[key] = previous;
+ } else {
+ merged[key] = null;
+ }
+ }
+ return merged;
+ }
+
+ const achievements = Object.freeze({
+ async getAll() {
+ await ready;
+ const progress = await retryMergeConflict({}, async () => {
+ const [summaries, manual, current] = await Promise.all([
+ kernel.listEntities('practiceSummaries'),
+ kernel.read('achievements.manual'),
+ kernel.read('achievements.progress', { withMeta: true })
+ ]);
+ const projected = asObject(computeAchievementProgress(summaries, manual, current.data));
+ if (checksum(projected) !== checksum(asObject(current.data))) {
+ await kernel.mutate([{
+ logicalKey: 'achievements.progress',
+ data: projected,
+ expectedRevision: current.envelope ? Number(current.envelope.revision) : 0
+ }], {
+ operationId: `achievement-progress-${current.envelope ? Number(current.envelope.revision) : 0}-${checksum(projected)}`
+ });
+ }
+ return projected;
+ }, 5);
+ if (Object.prototype.hasOwnProperty.call(progress, 'fresh')) delete progress.fresh;
+ Object.defineProperty(progress, 'fresh', { value: true, enumerable: false });
+ return progress;
+ },
+ async retryPending() { return achievements.getAll(); },
+ async acknowledgeDelivery(unlocked, options = {}) {
+ await ready;
+ assertObject(unlocked, 'achievements.acknowledgeDelivery requires an object');
+ const requested = clone(unlocked);
+ const mutation = optionsMutationOptions(options, 'achievement-delivery-acknowledge', requested);
+ return retryMergeConflict({}, async () => {
+ const current = await kernel.read('settings.values', { withMeta: true });
+ const settingsValue = asObject(current.data);
+ const delivery = asObject(settingsValue.achievementDelivery);
+ const acknowledged = mergeDeliveryAcknowledgements(delivery.acknowledged, requested);
+ return kernel.mutate([{
+ logicalKey: 'settings.values',
+ data: Object.assign({}, settingsValue, {
+ achievementDelivery: { version: 1, acknowledged }
+ }),
+ expectedRevision: current.envelope ? Number(current.envelope.revision) : 0
+ }], mutation);
+ }, 5);
+ },
+ async getManualState() { await ready; return kernel.read('achievements.manual'); }
+ });
+
+ const LEGACY_DOCUMENT_ALIASES = Object.freeze({
+ 'settings.values': ['user_settings', 'settings', 'system_settings'],
+ 'recovery.activeSessions': ['active_sessions'], 'recovery.drafts': ['temp_practice_records'],
+ 'recovery.interrupted': ['interrupted_records'], 'recovery.rejectedCompletions': ['rejected_completion_payloads'],
+ 'backups.entries': ['manual_backups'], 'backups.settings': ['backup_settings'],
+ 'backups.exportHistory': ['export_history'], 'backups.importHistory': ['import_history'],
+ 'vocab.words': ['vocab_words'], 'vocab.userConfig': ['vocab_user_config'], 'vocab.lists': ['vocab_lists'],
+ 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'],
+ 'achievements.manual': ['achievement_manual_state', 'user_achievements']
+ });
+ const LEGACY_PREFERENCE_ALIASES = Object.freeze({
+ theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse',
+ practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode',
+ ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed'
+ });
+
+ function mergeLegacySources(indexedDbValue, externalValue) {
+ const indexedDb = asObject(indexedDbValue);
+ const external = asObject(externalValue);
+ const merged = Object.assign({}, external, indexedDb);
+ const records = new Map();
+ const addRecords = (value) => {
+ const list = Array.isArray(value) ? value : asArray(asObject(value).data);
+ list.forEach((record) => {
+ const id = idOf(record, ['id', 'recordId', 'sessionId']);
+ records.set(id ? `id:${id}` : `content:${checksum(record)}`, clone(record));
+ });
+ };
+ addRecords(external.practice_records || external.practiceRecords);
+ addRecords(indexedDb.practice_records);
+ if (records.size) merged.practice_records = Array.from(records.values());
+ return merged;
+ }
+
+ function legacyLibraryBundle(legacy) {
+ const idMap = new Map();
+ const indexes = {};
+ for (const [oldId, value] of Object.entries(asObject(legacy))) {
+ if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations' || !asArray(value).length) continue;
+ const id = `legacy-library-${checksum(oldId).replace(/^fnv1a-/, '')}`;
+ idMap.set(oldId, id);
+ indexes[id] = clone(value);
+ }
+ if (!idMap.size) return null;
+ const configurations = new Map();
+ asArray(legacy.exam_index_configurations).forEach((configuration) => {
+ const oldId = idOf(configuration, ['id', 'key', 'configId']);
+ const id = idMap.get(oldId);
+ if (id) configurations.set(id, Object.assign({}, clone(configuration), { id, key: id, examCount: indexes[id].length }));
+ });
+ for (const [oldId, id] of idMap) {
+ if (!configurations.has(id)) configurations.set(id, {
+ id,
+ key: id,
+ name: `迁移的自定义题库 (${oldId})`,
+ examCount: indexes[id].length,
+ sourceType: 'legacy-import'
+ });
+ }
+ return {
+ configurations: Array.from(configurations.values()),
+ indexes,
+ activeId: idMap.get(String(legacy.active_exam_index_key || '')) || null
+ };
+ }
+
+ async function migrateLegacyData() {
+ // Unit embedders may provide a deliberately minimal kernel bootstrap.
+ if (typeof internals.readLegacyValues !== 'function') return;
+ const migrationMeta = await kernel.read('system.migrations', { withMeta: true });
+ const migrationState = asObject(migrationMeta.data);
+ const v1Complete = asObject(migrationState.v1ToV2).status === 'complete';
+ const externalConsumed = asObject(migrationState.externalBackupV1).status === 'consumed';
+ let externalBackup = null;
+ if (!externalConsumed && typeof internals.readLegacyExternalBackup === 'function') {
+ try { externalBackup = await internals.readLegacyExternalBackup(); }
+ catch (error) {
+ if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message);
+ }
+ }
+ if (v1Complete && !externalBackup) return;
+
+ const indexedDb = await internals.readLegacyValues();
+ if (indexedDb && indexedDb.__legacyReadComplete === false) {
+ throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup');
+ }
+ const legacy = mergeLegacySources(indexedDb, externalBackup);
+ const changes = [];
+ for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) {
+ const current = await kernel.getEnvelope(logicalKey);
+ if (current) continue;
+ const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key));
+ if (alias) changes.push({ logicalKey, data: legacy[alias], expectedRevision: 0 });
+ }
+ const libraryBundle = legacyLibraryBundle(legacy);
+ if (libraryBundle) {
+ if (!(await kernel.getEnvelope('library.configurations'))) changes.push({ logicalKey: 'library.configurations', data: libraryBundle.configurations, expectedRevision: 0 });
+ if (!(await kernel.getEnvelope('library.importedIndexes'))) changes.push({ logicalKey: 'library.importedIndexes', data: libraryBundle.indexes, expectedRevision: 0 });
+ if (!(await kernel.getEnvelope('library.activeConfigurationId'))) changes.push({ logicalKey: 'library.activeConfigurationId', data: libraryBundle.activeId, expectedRevision: 0 });
+ }
+ if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) {
+ const preferences = {};
+ for (const [alias, target] of Object.entries(LEGACY_PREFERENCE_ALIASES)) {
+ if (!Object.prototype.hasOwnProperty.call(legacy, alias)) continue;
+ const path = target.split('.'); let cursor = preferences;
+ path.slice(0, -1).forEach((part) => { cursor[part] = asObject(cursor[part]); cursor = cursor[part]; });
+ cursor[path[path.length - 1]] = clone(legacy[alias]);
+ }
+ if (Object.keys(preferences).length) changes.push({ logicalKey: 'preferences.values', data: preferences, expectedRevision: 0 });
+ }
+ if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) {
+ changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 });
+ }
+ if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-${internals.checksum(changes)}` });
+ const recordsValue = legacy.practice_records;
+ const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data);
+ const operations = [];
+ for (const [index, record] of records.entries()) {
+ try {
+ const candidate = clone(record);
+ if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) candidate.id = `legacy_${index}_${internals.checksum(record)}`;
+ const canonical = canonicalizeRecord(candidate);
+ const parts = splitPracticeRecord(canonical);
+ for (const [store, data] of [
+ ['practiceSummaries', parts.summary],
+ ['practiceDetails', parts.detail],
+ ['practiceAnnotations', parts.annotations]
+ ]) {
+ if (!await kernel.readEntity(store, canonical.id)) {
+ operations.push({ type: 'upsert', store, recordId: canonical.id, data, expectedRevision: 0 });
+ }
+ }
+ } catch (error) {
+ if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message);
+ }
+ }
+ if (operations.length) {
+ await kernel.mutateEntities(operations, { operationId: `legacy-practice-${internals.checksum(records)}` });
+ }
+
+ const nextMigrationState = Object.assign({}, migrationState);
+ if (!v1Complete) nextMigrationState.v1ToV2 = {
+ version: 1,
+ status: 'complete',
+ completedAt: nowIso(),
+ sourceChecksum: checksum(indexedDb),
+ sourceRecordCount: asArray(indexedDb.practice_records).length
+ };
+ if (externalBackup) nextMigrationState.externalBackupV1 = {
+ version: 1,
+ status: 'consumed',
+ completedAt: nowIso(),
+ sourceChecksum: checksum(externalBackup)
+ };
+ await kernel.mutate([{
+ logicalKey: 'system.migrations',
+ data: nextMigrationState,
+ expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0
+ }], { operationId: `legacy-migration-${checksum(nextMigrationState)}` });
+ }
+
+ const ready = kernel.initialize()
+ .then(async () => {
+ // Legacy migration and recovery cleanup are best-effort: a failure here
+ // (e.g. one malformed v1 record) must not brick the data layer for every
+ // read that awaits `ready`. Only a genuine backend init failure below is fatal.
+ try {
+ await migrateLegacyData();
+ } catch (error) {
+ if (global.console && console.error) console.error('[AppData v2] legacy migration skipped:', error);
+ }
+ try {
+ await cleanupExpiredRecovery();
+ } catch (error) {
+ if (global.console && console.warn) console.warn('[AppData v2] recovery cleanup skipped:', error);
+ }
+ return true;
+ })
+ .catch((error) => {
+ if (global.console && console.error) console.error('[AppData v2] initialization blocked:', error);
+ throw error instanceof AppDataError ? error : new AppDataError('INITIALIZATION_BLOCKED', error && error.message || 'AppData v2 initialization failed');
+ });
+
+ const AppData = { practice, settings, library, recovery, backups, vocab, preferences, goals, achievements };
+ Object.defineProperties(AppData, {
+ ready: { value: ready, enumerable: false },
+ status: { value: () => kernel.status(), enumerable: false }
+ });
+ Object.freeze(AppData);
+ Object.defineProperty(global, 'AppData', { value: AppData, enumerable: true, configurable: false, writable: false });
+ if (!Reflect.deleteProperty(global, '__AppDataV2Internals')) {
+ throw new Error('AppData v2 failed to close its internal bootstrap channel');
+ }
+ if (!Reflect.deleteProperty(global, '__AppDataV2Catalog')) {
+ throw new Error('AppData v2 failed to close its catalog bootstrap channel');
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
/* ===== js/utils/practiceTimerPreferences.js ===== */
(function initPracticeTimerPreferences(global) {
'use strict';
- var READING_KEY = 'ielts_reading_timer_preferences_v2';
- var LISTENING_KEY = 'ielts_listening_timer_preferences_v1';
var VERSION = 1;
var DEFAULTS = {
version: VERSION,
@@ -42,26 +3875,38 @@
};
}
- function keyFor(scope) {
- return String(scope || '').toLowerCase() === 'listening' ? LISTENING_KEY : READING_KEY;
+ var cache = Object.create(null);
+ var hydrationPromise = null;
+ function normalizeScope(scope) { return String(scope || '').toLowerCase() === 'listening' ? 'listening' : 'reading'; }
+ function hydrateTimerPreferences() {
+ if (cache.reading && cache.listening) return Promise.resolve(true);
+ if (hydrationPromise) return hydrationPromise;
+ if (!global.AppData || !global.AppData.preferences) return Promise.resolve(false);
+ hydrationPromise = Promise.resolve().then(async function loadTimerPreferences() {
+ await global.AppData.ready;
+ var stored = await global.AppData.preferences.getTimer();
+ cache.reading = normalize(stored && stored.reading);
+ cache.listening = normalize(stored && stored.listening);
+ return true;
+ }).catch(function onTimerPreferenceLoadError(error) {
+ hydrationPromise = null;
+ console.warn('[PracticeTimerPreferences] 加载失败:', error);
+ return false;
+ });
+ return hydrationPromise;
}
function read(scope) {
- try {
- var raw = global.localStorage && global.localStorage.getItem(keyFor(scope));
- return normalize(raw ? JSON.parse(raw) : null);
- } catch (_) {
- return normalize(null);
- }
+ return normalize(cache[normalizeScope(scope)]);
}
- function save(scope, preferences) {
+ async function save(scope, preferences) {
+ await hydrateTimerPreferences();
+ if (!global.AppData || !global.AppData.preferences) throw new Error('AppData.preferences is unavailable');
+ var normalizedScope = normalizeScope(scope);
var next = normalize(preferences);
- try {
- if (global.localStorage) {
- global.localStorage.setItem(keyFor(scope), JSON.stringify(next));
- }
- } catch (_) { }
+ await global.AppData.preferences.setTimer(normalizedScope, next);
+ cache[normalizedScope] = next;
return next;
}
@@ -69,17 +3914,16 @@
return clampMinutes(value, DEFAULTS.countdownMinutes) * 60;
}
- global.PracticeTimerPreferences = {
+ var api = {
VERSION: VERSION,
- READING_KEY: READING_KEY,
- LISTENING_KEY: LISTENING_KEY,
DEFAULTS: Object.freeze(Object.assign({}, DEFAULTS)),
normalize: normalize,
read: read,
save: save,
- keyFor: keyFor,
minutesToSeconds: minutesToSeconds
};
+ Object.defineProperty(api, 'ready', { enumerable: true, get: hydrateTimerPreferences });
+ global.PracticeTimerPreferences = api;
})(typeof window !== 'undefined' ? window : globalThis);
@@ -90,8 +3934,8 @@
var BRIDGE_SCRIPT_URL = '/js/bundles/listening-record-bridge.bundle.js';
var ADAPTER_STYLE_ID = 'listening-unified-wrapper-adapter-style';
var TIMER_INTERVAL_MS = 1000;
- var CANDIDATE_CODE_PREF_KEY = 'ielts_reading_candidate_code_preferences_v1';
var CANDIDATE_CODE_PATTERN = /^\d{6}$/;
+ var candidateCodeCache = { mode: 'auto', customCode: '' };
var state = {
examId: '',
sourceUrl: '',
@@ -105,7 +3949,11 @@
bridgeInjected: false,
bridgeReady: false,
pendingMessages: [],
- parentWindow: null,
+ parentWindow: global.opener || (global.parent && global.parent !== global ? global.parent : null),
+ expectedParentOrigin: '',
+ parentOrigin: '',
+ parentOriginIsOpaque: false,
+ windowSessionToken: '',
timerInterval: null,
lastTimerText: ''
};
@@ -113,9 +3961,18 @@
function sameOrigin() {
return global.location && global.location.origin && global.location.origin !== 'null'
? global.location.origin
- : '*';
+ : (global.location && global.location.protocol === 'file:' ? '*' : '');
}
+ try {
+ if (global.document && global.document.referrer) {
+ var referrerUrl = new URL(global.document.referrer, global.location.href);
+ state.expectedParentOrigin = referrerUrl.origin && referrerUrl.origin !== 'null'
+ ? referrerUrl.origin
+ : '';
+ }
+ } catch (_) { }
+
function normalizeSafeId(value, fallback) {
var text = String(value || '').trim();
return /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,180}$/.test(text) ? text : fallback;
@@ -211,20 +4068,15 @@
}
function readCandidateCodePreferences() {
- try {
- var raw = global.localStorage && global.localStorage.getItem(CANDIDATE_CODE_PREF_KEY);
- var parsed = raw ? JSON.parse(raw) : null;
- var mode = parsed && parsed.mode === 'custom' ? 'custom' : 'auto';
- var customCode = parsed && typeof parsed.customCode === 'string'
- ? parsed.customCode.replace(/\D/g, '').slice(0, 6)
- : '';
- return {
- mode: mode,
- customCode: CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : ''
- };
- } catch (_) {
- return { mode: 'auto', customCode: '' };
- }
+ return Object.assign({}, candidateCodeCache);
+ }
+
+ async function loadCandidateCodePreferences() {
+ await global.AppData.ready;
+ var stored = await global.AppData.preferences.getCandidateCode();
+ var mode = stored && stored.mode === 'custom' ? 'custom' : 'auto';
+ var customCode = stored && typeof stored.customCode === 'string' ? stored.customCode.replace(/\D/g, '').slice(0, 6) : '';
+ candidateCodeCache = { mode: mode, customCode: CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' };
}
function resolveCandidateCode() {
@@ -916,7 +4768,9 @@
return;
}
try {
- win.postMessage(message, sameOrigin());
+ var targetOrigin = sameOrigin();
+ if (!targetOrigin) throw new Error('iframe target origin unavailable');
+ win.postMessage(message, targetOrigin);
} catch (_) {
state.pendingMessages.push(message);
}
@@ -955,35 +4809,74 @@
return;
}
try {
- target.postMessage(message, sameOrigin());
+ var targetOrigin = state.parentOrigin && state.parentOrigin !== 'null'
+ ? state.parentOrigin
+ : (state.expectedParentOrigin || (global.location.protocol === 'file:' ? '*' : ''));
+ if (!targetOrigin) return;
+ target.postMessage(message, targetOrigin);
} catch (_) { }
}
- function handleParentMessage(message, source) {
- if (source && source !== global && typeof source.postMessage === 'function') {
- state.parentWindow = source;
- }
+ function handleParentMessage(event) {
+ var message = event && event.data;
+ var source = event && event.source;
var type = message && message.type;
if (type === 'INIT_SESSION' || type === 'init_exam_session') {
var payload = message.data || message;
+ var incomingOrigin = typeof event.origin === 'string' ? event.origin : '';
+ var declaredOrigin = typeof payload.parentOrigin === 'string' ? payload.parentOrigin : '';
+ var incomingToken = typeof payload.windowSessionToken === 'string' ? payload.windowSessionToken.trim() : '';
+ if (!state.parentWindow || source !== state.parentWindow || message.source !== 'exam_host' || !incomingToken) return;
+ if (state.expectedParentOrigin) {
+ if (incomingOrigin !== state.expectedParentOrigin || declaredOrigin !== state.expectedParentOrigin) return;
+ state.parentOrigin = state.expectedParentOrigin;
+ state.parentOriginIsOpaque = false;
+ } else {
+ if (incomingOrigin !== 'null' || declaredOrigin !== 'null' || global.location.protocol !== 'file:') return;
+ state.parentOrigin = 'null';
+ state.parentOriginIsOpaque = true;
+ }
+ state.windowSessionToken = incomingToken;
state.examId = normalizeSafeId(payload.examId, state.examId || 'listening-unknown');
state.sessionId = normalizeSafeId(payload.sessionId, state.sessionId || (state.examId + '_' + Date.now()));
state.suiteSessionId = normalizeSafeId(payload.suiteSessionId, state.suiteSessionId || '');
state.startTime = Number.isFinite(Number(payload.startTime)) ? Number(payload.startTime) : state.startTime;
+ } else {
+ var messagePayload = message && message.data || {};
+ var messageOrigin = typeof event.origin === 'string' ? event.origin : '';
+ var messageToken = typeof messagePayload.windowSessionToken === 'string' ? messagePayload.windowSessionToken.trim() : '';
+ var originMatches = state.parentOriginIsOpaque
+ ? messageOrigin === 'null'
+ : Boolean(state.parentOrigin && messageOrigin === state.parentOrigin);
+ if (!state.parentWindow || source !== state.parentWindow || message.source !== 'exam_host'
+ || !originMatches || !state.windowSessionToken || messageToken !== state.windowSessionToken) return;
}
forwardToIframe(message);
}
function handleMessage(event) {
- if (!event || !event.data || (event.origin && event.origin !== global.location.origin)) {
+ if (!event || !event.data) {
return;
}
var frameWindow = getFrameWindow();
if (event.source && frameWindow && event.source === frameWindow) {
+ var frameOrigin = sameOrigin();
+ if (frameOrigin === '*') {
+ if (event.origin !== 'null') return;
+ } else if (!frameOrigin || event.origin !== frameOrigin) {
+ return;
+ }
+ var framePayload = event.data && event.data.data || {};
+ var permitsPreInit = event.data.type === 'REQUEST_INIT'
+ || (event.data.type === 'SESSION_READY' && framePayload.initialized !== true);
+ if (!permitsPreInit && (
+ !state.windowSessionToken
+ || framePayload.windowSessionToken !== state.windowSessionToken
+ )) return;
forwardToParent(event.data);
return;
}
- handleParentMessage(event.data, event.source);
+ handleParentMessage(event);
}
function exposeCompatibilityApi() {
@@ -1023,7 +4916,9 @@
};
}
- function init() {
+ async function init() {
+ await loadCandidateCodePreferences();
+ if (global.PracticeTimerPreferences && global.PracticeTimerPreferences.ready) await global.PracticeTimerPreferences.ready;
var root = getRoot();
var frame = getFrame();
if (!root || !frame) {
@@ -1059,6 +4954,10 @@
(function markBundleProvided(global) {
if (global.AppLazyLoader && typeof global.AppLazyLoader.markProvided === "function") {
global.AppLazyLoader.markProvided([
+ "js/data/practiceRecordSource.js",
+ "js/data/v2/dataCatalog.js",
+ "js/data/v2/dataKernel.js",
+ "js/data/v2/appData.js",
"js/utils/practiceTimerPreferences.js",
"js/listeningUnifiedWrapper.js"
]);
diff --git a/js/bundles/more.bundle.js b/js/bundles/more.bundle.js
index 6cd40d44..aec1939a 100644
--- a/js/bundles/more.bundle.js
+++ b/js/bundles/more.bundle.js
@@ -24,6 +24,10 @@
const DEFAULT_EXPORT_VERSION = '0.6.2-fix';
+ function isPlainObject(value) {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+ }
+
function normalizeFrequency(value) {
if (value == null || value === '') {
return null;
@@ -96,26 +100,21 @@
}
function cloneProgressEntry(raw) {
- if (!raw || typeof raw !== 'object') {
+ if (!isPlainObject(raw)) {
return null;
}
- if (!raw.word || !raw.meaning) {
+ const word = typeof raw.word === 'string' ? raw.word.trim() : '';
+ const meaning = typeof raw.meaning === 'string' ? raw.meaning.trim() : '';
+ if (!word || !meaning) {
return null;
}
- const clone = {};
- Object.keys(raw).forEach((key) => {
- clone[key] = raw[key];
- });
- return clone;
+ return { ...raw, word, meaning };
}
function buildImportResult(type, entries, meta = {}) {
const safeEntries = Array.isArray(entries) ? entries.filter(Boolean) : [];
const normalizedMeta = { ...meta };
normalizedMeta.category = normalizeCategory(normalizedMeta.category, type === 'progress' ? 'user' : 'external');
- if (Array.isArray(normalizedMeta.reviewQueue)) {
- normalizedMeta.reviewQueue = normalizedMeta.reviewQueue.map((item) => String(item));
- }
return {
type,
entries: safeEntries,
@@ -239,19 +238,39 @@
}
if (payload && typeof payload === 'object' && Array.isArray(payload.words)) {
const metaCategory = extractCategory(payload.meta, null);
- const category = extractCategory(payload, metaCategory || 'external');
- const looksProgress = typeof payload.version === 'string'
- || Array.isArray(payload.reviewQueue)
- || payload.words.some((item) => item && (item.id || item.box || item.correctCount || item.lastReviewed || item.nextReview));
+ const declaredType = typeof payload.type === 'string' ? payload.type.trim().toLowerCase() : '';
+ const explicitProgress = declaredType === 'progress' || declaredType === 'progress-backup';
+ const hasListId = typeof payload.listId === 'string' && payload.listId.trim();
+ const hasV2ProgressEnvelope = typeof payload.version === 'string'
+ && isPlainObject(payload.config)
+ && hasListId;
+ const legacyProgressEnvelope = !declaredType
+ && typeof payload.version === 'string'
+ && isPlainObject(payload.config)
+ && Array.isArray(payload.reviewQueue)
+ && !hasListId;
+ if (legacyProgressEnvelope) {
+ throw new Error('不支持 v1 进度备份,请使用 v2 格式重新导出');
+ }
+ if (explicitProgress && !hasV2ProgressEnvelope) {
+ throw new Error('进度备份缺少 v2 词表或配置数据');
+ }
+ const looksProgress = (explicitProgress || !declaredType) && hasV2ProgressEnvelope;
+ const category = extractCategory(payload, metaCategory || (looksProgress ? 'user' : 'external'));
if (looksProgress) {
- const entries = payload.words.map(cloneProgressEntry).filter(Boolean);
+ const entries = payload.words.map(cloneProgressEntry);
+ if (entries.some((entry) => !entry)) {
+ throw new Error('进度备份包含无效词汇数据');
+ }
return buildImportResult('progress', entries, {
format: 'json',
originalLength: payload.words.length,
+ listId: typeof payload.listId === 'string' && payload.listId.trim()
+ ? payload.listId.trim()
+ : undefined,
category: category || 'user',
version: typeof payload.version === 'string' ? payload.version : undefined,
- config: payload.config && typeof payload.config === 'object' ? { ...payload.config } : undefined,
- reviewQueue: Array.isArray(payload.reviewQueue) ? payload.reviewQueue.slice() : undefined,
+ config: isPlainObject(payload.config) ? { ...payload.config } : undefined,
name: typeof payload.name === 'string' ? payload.name : undefined,
source: typeof payload.source === 'string' ? payload.source : undefined,
exportedAt: typeof payload.exportedAt === 'string' ? payload.exportedAt : undefined
@@ -321,18 +340,23 @@
return normalizedResult;
}
- async function exportProgress() {
- const store = window.VocabStore;
- if (!store || typeof store.init !== 'function') {
- throw new Error('VocabStore 未加载');
+ async function exportProgress(words) {
+ if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab 未加载');
+ if (!Array.isArray(words)) throw new Error('当前词表尚未加载');
+ await window.AppData.ready;
+ const config = await window.AppData.vocab.getConfig();
+ const listId = config.activeListId || 'default';
+ const entries = words.map(cloneProgressEntry);
+ if (entries.some((entry) => !entry)) {
+ throw new Error('当前词表包含无效词汇数据');
}
- await store.init();
const payload = {
+ type: 'progress',
version: DEFAULT_EXPORT_VERSION,
exportedAt: new Date().toISOString(),
- config: store.getConfig(),
- words: store.getWords(),
- reviewQueue: store.getReviewQueue()
+ listId,
+ config,
+ words: entries
};
return new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
}
@@ -723,53 +747,40 @@
id: 'default',
name: 'IELTS 核心词表',
icon: '📚',
- source: 'builtin',
- storageKey: 'vocab_words'
+ source: 'builtin'
},
'spelling-errors-p1': {
id: 'spelling-errors-p1',
name: 'P1 拼写错误',
icon: '📝',
- source: 'p1',
- storageKey: 'vocab_list_p1_errors'
+ source: 'p1'
},
'spelling-errors-p4': {
id: 'spelling-errors-p4',
name: 'P4 拼写错误',
icon: '📝',
- source: 'p4',
- storageKey: 'vocab_list_p4_errors'
+ source: 'p4'
},
'spelling-errors-master': {
id: 'spelling-errors-master',
name: '综合错误词表',
icon: '📚',
- source: 'all',
- storageKey: 'vocab_list_master_errors'
+ source: 'all'
},
'custom': {
id: 'custom',
name: '自定义词表',
icon: '✏️',
- source: 'user',
- storageKey: 'vocab_list_custom'
+ source: 'user'
},
'reading-highlights': {
id: 'reading-highlights',
name: '阅读高亮生词',
icon: '📖',
- source: 'reading-highlight',
- storageKey: 'vocab_list_reading_highlights'
+ source: 'reading-highlight'
}
});
- const STORAGE_KEYS = Object.freeze({
- WORDS: 'vocab_words',
- CONFIG: 'vocab_user_config',
- REVIEW_QUEUE: 'vocab_review_queue',
- ACTIVE_LIST: 'vocab_active_list_id'
- });
-
const DEFAULT_CONFIG = Object.freeze({
dailyNew: 20,
reviewLimit: 100,
@@ -778,29 +789,37 @@
notify: true
});
- const DEFAULT_REVIEW_QUEUE = Object.freeze([]);
const DEFAULT_LIST_ID = 'default';
const DEFAULT_LEXICON_URL = 'assets/wordlists/ielts_core.json';
const SPELLING_ERROR_LIST_IDS = new Set(['spelling-errors-p1', 'spelling-errors-p4', 'spelling-errors-master']);
+ const CONFIG_LIMITS = Object.freeze({
+ dailyNew: { min: 0, max: 200 },
+ reviewLimit: { min: 1, max: 300 },
+ masteryCount: { min: 1, max: 10 }
+ });
+ const VALID_THEMES = new Set(['auto', 'light', 'dark']);
const state = {
- repositories: null,
- metaRepo: null,
- storageManager: null,
words: [],
wordIndex: new Map(),
config: { ...DEFAULT_CONFIG },
- reviewQueue: DEFAULT_REVIEW_QUEUE.slice(),
ready: false,
readyPromise: null,
readyResolvers: [],
loadingPromise: null,
- registryUnsubscribe: null,
lastLoadSource: 'init',
activeListId: DEFAULT_LIST_ID,
listCache: new Map()
};
+ function cloneValue(value) {
+ if (value === undefined) return undefined;
+ if (typeof structuredClone === 'function') {
+ try { return structuredClone(value); } catch (_) { /* fall through */ }
+ }
+ return JSON.parse(JSON.stringify(value));
+ }
+
function emitReady(value) {
if (state.ready) {
return;
@@ -999,69 +1018,50 @@
});
}
- async function persist(key, value) {
- try {
- if (state.metaRepo && typeof state.metaRepo.set === 'function') {
- await state.metaRepo.set(key, value, { clone: true });
- return true;
- }
- if (state.storageManager && typeof state.storageManager.set === 'function') {
- await state.storageManager.set(key, value);
- return true;
- }
- if (typeof localStorage !== 'undefined') {
- localStorage.setItem(key, JSON.stringify(value));
- return true;
- }
- } catch (error) {
- console.error('[VocabStore] persist error:', error);
- }
- return false;
+ async function requireVocabData() {
+ if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab is unavailable');
+ await window.AppData.ready;
+ return window.AppData.vocab;
}
- async function read(key, defaultValue) {
- if (state.metaRepo && typeof state.metaRepo.get === 'function') {
- try {
- const value = await state.metaRepo.get(key, defaultValue);
- if (value !== undefined) {
- return value;
- }
- } catch (error) {
- console.warn('[VocabStore] metaRepo读取失败:', error);
- }
- }
- if (state.storageManager && typeof state.storageManager.get === 'function') {
- try {
- const value = await state.storageManager.get(key, defaultValue);
- if (value !== undefined) {
- return value;
- }
- } catch (error) {
- console.warn('[VocabStore] storageManager读取失败:', error);
- }
- }
- if (typeof localStorage !== 'undefined') {
- try {
- const raw = localStorage.getItem(key);
- if (!raw) {
- return defaultValue;
- }
- return JSON.parse(raw);
- } catch (error) {
- console.warn('[VocabStore] localStorage解析失败:', error);
- }
- }
- return defaultValue;
+ async function readListData(listId) {
+ const vocab = await requireVocabData();
+ if (listId === DEFAULT_LIST_ID) return vocab.listWords();
+ const collections = await vocab.listCollections();
+ return Object.prototype.hasOwnProperty.call(collections, listId) ? collections[listId] : null;
+ }
+
+ async function saveListData(listId, value) {
+ const vocab = await requireVocabData();
+ const words = value && typeof value === 'object' && Array.isArray(value.words) ? value.words : value;
+ await vocab.replaceListWords({ listId, words: Array.isArray(words) ? words : [] });
+ return true;
+ }
+
+ async function saveConfigData(configPatch = state.config) {
+ const vocab = await requireVocabData();
+ await vocab.patchConfig(Object.assign({}, configPatch, { activeListId: state.activeListId }));
+ return true;
}
function mergeConfig(config) {
const base = { ...DEFAULT_CONFIG };
- if (config && typeof config === 'object') {
- Object.keys(DEFAULT_CONFIG).forEach((key) => {
- if (typeof config[key] !== 'undefined') {
- base[key] = config[key];
- }
- });
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
+ return base;
+ }
+ Object.keys(CONFIG_LIMITS).forEach((key) => {
+ const value = config[key];
+ const limits = CONFIG_LIMITS[key];
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ return;
+ }
+ base[key] = Math.min(limits.max, Math.max(limits.min, Math.floor(value)));
+ });
+ if (typeof config.theme === 'string' && VALID_THEMES.has(config.theme)) {
+ base.theme = config.theme;
+ }
+ if (typeof config.notify === 'boolean') {
+ base.notify = config.notify;
}
return base;
}
@@ -1071,15 +1071,6 @@
rebuildIndex();
}
- function getStorageKeyForListId(listId) {
- const targetId = typeof listId === 'string' && VOCAB_LISTS[listId] ? listId : DEFAULT_LIST_ID;
- return VOCAB_LISTS[targetId].storageKey;
- }
-
- function getActiveStorageKey() {
- return getStorageKeyForListId(state.activeListId);
- }
-
function isSpellingErrorList(listId) {
return SPELLING_ERROR_LIST_IDS.has(listId);
}
@@ -1193,29 +1184,26 @@
return state.loadingPromise;
}
state.loadingPromise = (async () => {
- const [storedConfig, storedQueue, storedActiveList] = await Promise.all([
- read(STORAGE_KEYS.CONFIG, { ...DEFAULT_CONFIG }),
- read(STORAGE_KEYS.REVIEW_QUEUE, DEFAULT_REVIEW_QUEUE.slice()),
- read(STORAGE_KEYS.ACTIVE_LIST, DEFAULT_LIST_ID)
- ]);
+ const vocab = await requireVocabData();
+ const storedConfig = await vocab.getConfig();
+ const storedActiveList = storedConfig && storedConfig.activeListId;
state.activeListId = typeof storedActiveList === 'string' && VOCAB_LISTS[storedActiveList]
? storedActiveList
: DEFAULT_LIST_ID;
- const activeStorageKey = getStorageKeyForListId(state.activeListId);
- const storedWords = await read(activeStorageKey, []);
+ const storedWords = await readListData(state.activeListId);
const normalizedWords = normalizeStoredListWords(storedWords, state.activeListId);
if (normalizedWords.length) {
setWordsInternal(normalizedWords);
- state.lastLoadSource = state.metaRepo ? 'meta' : (state.storageManager ? 'storage' : 'localStorage');
+ state.lastLoadSource = 'appData-v2';
}
state.config = mergeConfig(storedConfig);
- state.reviewQueue = Array.isArray(storedQueue) ? storedQueue.map((id) => String(id)) : [];
})()
.catch((error) => {
console.error('[VocabStore] 初始化加载失败:', error);
+ throw error;
})
.finally(() => {
state.loadingPromise = null;
@@ -1225,8 +1213,7 @@
async function ensureDefaultLexicon() {
try {
- const defaultStorageKey = getStorageKeyForListId(DEFAULT_LIST_ID);
- const storedDefault = await read(defaultStorageKey, []);
+ const storedDefault = await readListData(DEFAULT_LIST_ID);
const normalizedStored = normalizeStoredListWords(storedDefault, DEFAULT_LIST_ID);
const pollutedBySpellingList = isLikelySpellingErrorSnapshot(normalizedStored);
if (normalizedStored.length && !pollutedBySpellingList) {
@@ -1244,7 +1231,7 @@
console.warn('[VocabStore] 默认词库为空');
return [];
}
- await persist(defaultStorageKey, normalized);
+ await saveListData(DEFAULT_LIST_ID, normalized);
if (state.activeListId === DEFAULT_LIST_ID) {
setWordsInternal(normalized);
state.lastLoadSource = 'default';
@@ -1266,8 +1253,8 @@
});
return normalized;
} catch (error) {
- console.warn('[VocabStore] 默认词库加载失败:', error);
- return [];
+ console.error('[VocabStore] 默认词库加载失败:', error);
+ throw error;
}
}
@@ -1277,87 +1264,39 @@
emitReady(true);
}
- function connectToProviders() {
- if (state.registryUnsubscribe || state.repositories || state.storageManager) {
- return;
- }
- const registry = window.StorageProviderRegistry;
- if (registry && typeof registry.onProvidersReady === 'function') {
- state.registryUnsubscribe = registry.onProvidersReady((payload) => {
- if (payload && payload.repositories) {
- attachRepositories(payload.repositories);
- }
- if (payload && payload.storageManager) {
- state.storageManager = payload.storageManager;
- }
- });
- const current = typeof registry.getCurrentProviders === 'function' ? registry.getCurrentProviders() : null;
- if (current) {
- if (current.repositories) {
- attachRepositories(current.repositories);
- }
- if (current.storageManager) {
- state.storageManager = current.storageManager;
- }
- }
- return;
- }
- if (window.dataRepositories) {
- attachRepositories(window.dataRepositories);
- }
- if (window.storage) {
- state.storageManager = window.storage;
- }
- }
-
- async function attachRepositories(repositories) {
- if (!repositories || state.repositories === repositories) {
- return;
- }
- state.repositories = repositories;
- state.metaRepo = repositories.meta || null;
- await loadState();
- if (!state.words.length) {
- await ensureDefaultLexicon();
- }
- await persist(getActiveStorageKey(), state.words);
- await persist(STORAGE_KEYS.CONFIG, state.config);
- await persist(STORAGE_KEYS.REVIEW_QUEUE, state.reviewQueue);
- emitReady(true);
- }
-
function getWords() {
- return state.words.map((word) => ({ ...word }));
+ return cloneValue(state.words);
}
- async function setWords(words) {
+ async function mergeWords(words) {
const normalized = Array.isArray(words)
? words.map((word) => normalizeWordRecord(word)).filter(Boolean)
: [];
- setWordsInternal(normalized);
- await persist(getActiveStorageKey(), normalized);
+ const vocab = await requireVocabData();
+ const receipt = await vocab.mergeListWords({ listId: state.activeListId, words: normalized });
+ const committedWords = Array.isArray(receipt.words) ? receipt.words : [];
+ setWordsInternal(committedWords.map((word) => normalizeWordRecord(word)).filter(Boolean));
state.listCache.delete(state.activeListId);
- return getWords();
+ return {
+ words: getWords(),
+ addedCount: Number(receipt.addedCount) || 0,
+ updatedCount: Number(receipt.updatedCount) || 0
+ };
}
async function updateWord(id, patch = {}) {
if (!id || !state.wordIndex.has(id)) {
return null;
}
- const original = state.wordIndex.get(id);
- const updated = normalizeWordRecord({
- ...original,
- ...patch,
- id,
- updatedAt: getNow()
- });
+ const vocab = await requireVocabData();
+ const receipt = await vocab.patchWord({ listId: state.activeListId, wordId: id, patch });
+ const updated = normalizeWordRecord(receipt.word);
const index = state.words.findIndex((word) => word.id === id);
if (index >= 0 && updated) {
state.words.splice(index, 1, updated);
state.wordIndex.set(id, updated);
- await persist(getActiveStorageKey(), state.words);
state.listCache.delete(state.activeListId);
- return { ...updated };
+ return cloneValue(updated);
}
return null;
}
@@ -1367,19 +1306,31 @@
}
async function setConfig(config) {
- state.config = mergeConfig(config);
- await persist(STORAGE_KEYS.CONFIG, state.config);
+ const next = mergeConfig(config);
+ await saveConfigData(next);
+ state.config = next;
return getConfig();
}
- function getReviewQueue() {
- return state.reviewQueue.slice();
- }
-
- async function setReviewQueue(queue) {
- state.reviewQueue = Array.isArray(queue) ? queue.map((id) => String(id)) : [];
- await persist(STORAGE_KEYS.REVIEW_QUEUE, state.reviewQueue);
- return getReviewQueue();
+ async function replaceProgress(words, config, listId) {
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
+ throw new Error('进度备份缺少有效配置');
+ }
+ const requestedListId = typeof listId === 'string' ? listId.trim() : '';
+ if (!requestedListId || !VOCAB_LISTS[requestedListId]) {
+ throw new Error('进度备份包含未知词表');
+ }
+ const normalized = Array.isArray(words)
+ ? words.map((word) => normalizeWordRecord(word)).filter(Boolean)
+ : [];
+ const nextConfig = mergeConfig({ ...config, activeListId: requestedListId });
+ const vocab = await requireVocabData();
+ await vocab.replaceProgress({ listId: requestedListId, words: normalized, config: nextConfig });
+ state.config = nextConfig;
+ state.activeListId = requestedListId;
+ setWordsInternal(normalized);
+ state.listCache.delete(requestedListId);
+ return { words: getWords(), config: getConfig() };
}
function getDueWords(referenceTime = new Date()) {
@@ -1518,8 +1469,7 @@
}
try {
- const storageKey = listConfig.storageKey;
- let storedData = await read(storageKey, null);
+ let storedData = await readListData(listId);
if (listId === DEFAULT_LIST_ID && (!storedData || (Array.isArray(storedData) && storedData.length === 0))) {
const ensured = await ensureDefaultLexicon();
storedData = ensured;
@@ -1554,7 +1504,7 @@
return listData;
} catch (error) {
console.error('[VocabStore] loadList 失败:', error);
- return null;
+ throw error;
}
}
@@ -1579,26 +1529,12 @@
}
try {
- // 保存当前词表到存储(如果有修改)
- if (state.activeListId && state.words.length > 0) {
- const currentConfig = VOCAB_LISTS[state.activeListId];
- if (currentConfig) {
- await persist(currentConfig.storageKey, state.words);
- }
- }
-
- // 切换到新词表
+ const vocab = await requireVocabData();
+ await vocab.activateList(listId);
state.activeListId = listId;
setWordsInternal(listData.words || []);
state.listCache.delete(listId);
- // 保存激活的词表 ID
- await persist(STORAGE_KEYS.ACTIVE_LIST, listId);
-
- // 清空复习队列(新词表需要重新生成队列)
- state.reviewQueue = [];
- await persist(STORAGE_KEYS.REVIEW_QUEUE, []);
-
return true;
} catch (error) {
console.error('[VocabStore] setActiveList 失败:', error);
@@ -1625,8 +1561,7 @@
// 从存储读取
try {
- const listConfig = VOCAB_LISTS[listId];
- const storedData = await read(listConfig.storageKey, null);
+ const storedData = await readListData(listId);
// 检查是否为拼写错误词表格式
if (storedData && typeof storedData === 'object' && Array.isArray(storedData.words)) {
@@ -1638,7 +1573,7 @@
return 0;
} catch (error) {
console.error('[VocabStore] getListWordCount 失败:', error);
- return 0;
+ throw error;
}
}
@@ -1706,8 +1641,7 @@
}
await init();
const listId = 'reading-highlights';
- const listConfig = VOCAB_LISTS[listId];
- const storedData = await read(listConfig.storageKey, []);
+ const storedData = await readListData(listId);
const words = normalizeStoredListWords(storedData, listId);
const key = normalized.word.toLowerCase();
const existingIndex = words.findIndex((entry) => String(entry.word || '').trim().toLowerCase() === key);
@@ -1723,7 +1657,7 @@
} else {
words.push(normalized);
}
- await persist(listConfig.storageKey, words.filter(Boolean));
+ await saveListData(listId, words.filter(Boolean));
state.listCache.delete(listId);
if (state.activeListId === listId) {
setWordsInternal(words.filter(Boolean));
@@ -1733,7 +1667,6 @@
async function init() {
ensureReadyPromise();
- connectToProviders();
if (!state.ready) {
await bootstrap();
}
@@ -1743,12 +1676,11 @@
const api = {
init,
getWords,
- setWords,
+ mergeWords,
updateWord,
getConfig,
setConfig,
- getReviewQueue,
- setReviewQueue,
+ replaceProgress,
getDueWords,
getNewWords,
loadList,
@@ -2389,6 +2321,9 @@
reviewLimit: { min: 1, max: 300 },
masteryCount: { min: 1, max: 10 }
});
+ const LIST_PAGE_SIZE = 200;
+ const LIST_SEARCH_DEBOUNCE_MS = 180;
+ const MODAL_FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const state = {
container: null,
@@ -2403,9 +2338,18 @@
menuOpen: false,
ui: {
sidePanelManual: null,
- lastFocus: null,
importing: false,
exporting: false,
+ listBrowserQuery: '',
+ listBrowserLearnedOnly: false,
+ listBrowserPage: 1,
+ listSearchTimer: null,
+ modalEpoch: 0,
+ modalOwner: null,
+ settingsRestoreFocus: null,
+ listRestoreFocus: null,
+ settingsSaveToken: 0,
+ settingsSaveTail: Promise.resolve(),
listSwitcher: null,
listSwitcherListenerAttached: false
},
@@ -2464,6 +2408,52 @@
return state.elements.settingsModal?.dataset.open === 'true';
}
+ function isListModalOpen() {
+ return state.elements.listModal?.dataset.open === 'true';
+ }
+
+ function isListModalPending() {
+ return state.ui.modalOwner === 'list-pending';
+ }
+
+ function focusElement(target) {
+ const fallback = state.elements.menuButton;
+ const focusTarget = target && typeof target.focus === 'function' ? target : fallback;
+ if (focusTarget && typeof focusTarget.focus === 'function') {
+ focusTarget.focus();
+ }
+ }
+
+ function trapModalFocus(event, dialog) {
+ if (!dialog) {
+ return;
+ }
+ const focusable = Array.from(dialog.querySelectorAll(MODAL_FOCUSABLE_SELECTOR))
+ .filter((element) => !element.hidden && !element.disabled);
+ if (!focusable.length) {
+ event.preventDefault();
+ focusElement(dialog);
+ return;
+ }
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ const active = document.activeElement;
+ if (event.shiftKey && (active === first || !dialog.contains(active))) {
+ event.preventDefault();
+ focusElement(last);
+ } else if (!event.shiftKey && (active === last || !dialog.contains(active))) {
+ event.preventDefault();
+ focusElement(first);
+ }
+ }
+
+ function clearListSearchTimer() {
+ if (state.ui.listSearchTimer) {
+ clearTimeout(state.ui.listSearchTimer);
+ state.ui.listSearchTimer = null;
+ }
+ }
+
function clampNumber(value, min, max) {
if (typeof value !== 'number' || Number.isNaN(value)) {
return null;
@@ -2571,6 +2561,7 @@