diff --git a/DataIO.js.html b/DataIO.js.html new file mode 100644 index 0000000..a36c746 --- /dev/null +++ b/DataIO.js.html @@ -0,0 +1,216 @@ + diff --git a/Index.html b/Index.html index ebf4441..bba72d9 100644 --- a/Index.html +++ b/Index.html @@ -477,6 +477,7 @@

PDF 下載選項 + diff --git a/JavaScript.html b/JavaScript.html index f57e48d..d73089b 100644 --- a/JavaScript.html +++ b/JavaScript.html @@ -431,191 +431,12 @@ this.ui.updateClearAllFiltersButtonVisibility(); }, - // --- API COMMUNICATION --- - loadVersions: async function () { - const select = AppElements.versionHistorySelect; - select.innerHTML = ''; - this.ui.showNotification('正在獲取版本列表...', 'info'); - try { - const versions = await ServerApi.call('getVersions', this.activeScheduleId); - if (!versions || versions.error) throw new Error(versions ? versions.error : '未知錯誤'); - - select.innerHTML = ''; - if (versions.length > 0) { - versions.forEach(v => { - const option = document.createElement('option'); - option.value = v.id; - option.textContent = `${new Date(v.id).toLocaleString()} by ${this.getShortUserName(v.user)}`; - select.appendChild(option); - }); - this.ui.showNotification('版本列表已更新', 'success'); - } else { - select.innerHTML = ''; - } - } catch (error) { - this.ui.showNotification('獲取版本列表失敗: ' + error.message, 'error'); - select.innerHTML = ''; - } - }, - - handleLoadVersion: async function () { - const select = AppElements.versionHistorySelect; - const versionId = select.value; - if (!versionId || select.options[select.selectedIndex].text === '沒有歷史紀錄') { - this.ui.showNotification('請選擇一個有效的版本', 'error'); - return; - } - if (this.isDirty && !await this.modals.showConfirm(`您有未儲存的變更,確定要讀取歷史版本並覆蓋當前的課表嗎?`)) { - return; - } - AppElements.versionHistoryModal.style.display = 'none'; - this.ui.showLoading('正在讀取歷史版本...'); - - try { - const data = await ServerApi.call('getVersionData', versionId); - if (!data || !data.success) throw new Error(data ? data.error : '未知錯誤'); - - const currentSchedule = this.schedules[this.activeScheduleId]; - currentSchedule.data.classrooms = data.classrooms || []; - currentSchedule.data.scheduleData = this.ensureDataIds(data.scheduleData || {}); - currentSchedule.data.tags = data.tags || []; - - this.saveSchedulesToLocal(); - this.loadSchedule(this.activeScheduleId); - - this.ui.updateSyncStatus(AppConfig.STATUS.OFFLINE, this.lastCloudModifiedTime); - this.ui.showNotification(`已在本地載入版本: ${new Date(data.versionId).toLocaleString()}`, 'info'); - } catch (error) { - this.ui.showNotification('讀取版本失敗: ' + error.message, 'error'); - } finally { - this.ui.hideLoading(); - } - }, - - saveDataToLocal: function () { - try { - // Sync the current working data back to the main schedules object - if (this.activeScheduleId && this.schedules[this.activeScheduleId]) { - const activeSchedule = this.schedules[this.activeScheduleId]; - if (activeSchedule.data) { - activeSchedule.data.scheduleData = this.scheduleData; - activeSchedule.data.classrooms = this.classrooms; - activeSchedule.data.tags = this.getAllTags(); // Also update the tags list for consistency - } - } - - // After saving, the list of all tags might have changed. - // We need to re-initialize the tag filter to get the fresh list, while preserving the current value. - let currentFilterValue = []; - if (this.tagFilterTagify) { - currentFilterValue = this.tagFilterTagify.value.map(tag => tag.value); - } - - this.tags = this.getAllTags(); // Recalculate all tags from current data - this.ui.initializeTagFilter(); // This will destroy and recreate the Tagify instance - - if (this.tagFilterTagify && currentFilterValue.length > 0) { - // Restore the previous value without triggering 'add' events. - // loadOriginalValues will only add valid tags from the new whitelist. - this.tagFilterTagify.loadOriginalValues(currentFilterValue); - } - - this.saveSchedulesToLocal(); - localStorage.setItem('activeScheduleId', this.activeScheduleId); - localStorage.setItem('app_version', AppConfig.APP_VERSION); - if (this.lastSyncTime) localStorage.setItem('lastSyncTime', this.lastSyncTime.toISOString()); - } catch (e) { - console.error("保存數據到本地失敗:", e); - this.ui.showNotification("保存數據失敗,可能是存儲空間不足", "error"); - } - }, - - loadDataFromServer: async function () { - if (this.isConnecting) { - this.ui.showNotification("A sync operation is already in progress.", "info"); - return; - } - this.isConnecting = true; - this.ui.manageLoadingState('start', { message: '正在從雲端同步資料...' }); - - try { - const result = await ServerApi.call('getData'); - if (!result || result.error) throw new Error(result ? result.error : '從服務器獲取的數據為空'); - - this.schedules = result.schedules || {}; - this.scheduleLastModified = {}; // Clear old timestamps - for (const id in this.schedules) { - if (this.schedules[id].lastModified) { - this.scheduleLastModified[id] = this.schedules[id].lastModified; - // The timestamp is sensitive and only needed for the save operation, - // let's not keep it in the main data object that gets saved to local storage. - delete this.schedules[id].lastModified; - } - } - - this.activeMetadataTimestamp = result.metadataTimestamp; - - // After fetching data, the client decides which schedule to load. - this.loadInitialSchedules(); - - this.lastSyncTime = new Date(); - this.saveSchedulesToLocal(); - this.ui.manageLoadingState('end', { success: true, message: '從雲端讀取數據成功!' }); - - } catch (error) { - this.ui.manageLoadingState('end', { success: false, message: `讀取失敗: ${error.message}` }); - } finally { - this.isConnecting = false; - } - }, - - saveDataToServer: async function () { - if (this.isConnecting) return; - this.isConnecting = true; - this.ui.manageLoadingState('start', { message: '正在檢查版本並儲存至雲端...' }); - - try { - const currentScheduleTimestamp = this.scheduleLastModified[this.activeScheduleId]; - if (!currentScheduleTimestamp) { - throw new Error("找不到當前課表的版本資訊,無法儲存。請嘗試重新載入。"); - } - - const dataToSend = { - scheduleId: this.activeScheduleId, - lastModified: currentScheduleTimestamp, // *** STEP 4: Attach the specific timestamp - scheduleData: { - classrooms: this.classrooms, - scheduleData: this.scheduleData, - tags: this.tags - } - }; - - const saveResult = await ServerApi.call('saveData', dataToSend); - - // *** STEP 4: Handle new conflict error - if (saveResult && saveResult.conflict) { - this.modals.showConfirm(saveResult.error, true); // textContent is XSS-safe; escapeHtml removed to prevent double-escaping - this.ui.manageLoadingState('end', { success: false, isConflict: true }); - return; - } - - if (!saveResult || !saveResult.success) { - throw new Error(saveResult?.error || '儲存時發生未知錯誤'); - } - - this.lastSyncTime = new Date(); - this.scheduleLastModified[this.activeScheduleId] = saveResult.lastModified; - this.historyModule.updateCleanSnapshot(); - this.historyModule.checkDirty(); - this.ui.manageLoadingState('end', { success: true, message: '數據已成功儲存到雲端!' }); - - } catch (error) { - this.ui.manageLoadingState('end', { success: false, message: `數據儲存失敗: ${error.message}` }); - } finally { - this.isConnecting = false; - } - }, + // loadVersions — moved to DataIO.js.html (Phase 1 PR5) + // handleLoadVersion — moved to DataIO.js.html (Phase 1 PR5) + // saveDataToLocal — moved to DataIO.js.html (Phase 1 PR5) + // loadDataFromServer — moved to DataIO.js.html (Phase 1 PR5) + // saveDataToServer — moved to DataIO.js.html (Phase 1 PR5) // findNextUpcomingClasses — moved to DataCollection.js.html (Phase 1 PR3) diff --git a/tests/helpers/sourceAnalysis.js b/tests/helpers/sourceAnalysis.js index e7f914b..6af4f2e 100644 --- a/tests/helpers/sourceAnalysis.js +++ b/tests/helpers/sourceAnalysis.js @@ -21,7 +21,16 @@ export function extractMethodBody(source, methodName) { const declPattern = new RegExp( `${methodName}\\s*:\\s*(?:async\\s+)?function\\s*\\([^)]*\\)\\s*\\{` ); - const match = declPattern.exec(source); + let match = declPattern.exec(source); + + // Also try IIFE-extracted pattern: App.methodName = (async)? function(...) { + if (!match) { + const iifePattern = new RegExp( + `App\\.${methodName}\\s*=\\s*(?:async\\s+)?function\\s*\\([^)]*\\)\\s*\\{` + ); + match = iifePattern.exec(source); + } + if (!match) return null; const startIdx = match.index + match[0].length; diff --git a/tests/unit/appWiringContracts.test.js b/tests/unit/appWiringContracts.test.js index b8017ad..ddb96e7 100644 --- a/tests/unit/appWiringContracts.test.js +++ b/tests/unit/appWiringContracts.test.js @@ -61,7 +61,7 @@ const privateMethods = extractPrivateMethods(jsHtmlSource); */ const EXTRACTED_TO_LIB = new Set([ // stateHelpers.js - 'handleEditClassroom', 'saveDataToServer', + 'handleEditClassroom', // interactionHelpers.js (handleDrop → applyDrop) 'handleDrop', // appLifecycleHelpers.js (new — this wave) @@ -81,10 +81,6 @@ const NOT_EXTRACTABLE = new Set([ 'handleScheduleListClick', // DOM event delegation + ServerApi + modals 'handleScheduleSelectChange', // DOM event + modal confirm + state (aggregateScheduleData already extracted) 'applyTagFilters', // Tagify instance + modal confirm + DOM - 'loadVersions', // DOM + ServerApi - 'handleLoadVersion', // DOM + ServerApi + state - 'saveDataToLocal', // localStorage + Tagify + DOM (core sync logic extracted as processServerLoadResult) - 'loadDataFromServer', // ServerApi + state orchestration (result processing extracted) 'isCurrentUserAdmin', // Global var IS_ADMIN (trivial, 1 line) 'printScheduleToPdf', // jsPDF + DOM + ServerApi (massively coupled) ]); @@ -122,6 +118,9 @@ const IIFE_EXTRACTED = new Set([ 'filterDataByTags', 'filterDataByActiveFilters', // FilterEngine.js.html (PR4) — private helpers also IIFE-extracted '_filterScheduleData', + // DataIO.js.html (PR5) + 'loadVersions', 'handleLoadVersion', 'saveDataToLocal', + 'loadDataFromServer', 'saveDataToServer', ]); // ─── Tests ───────────────────────────────────────────────────────────────── @@ -134,8 +133,8 @@ describe('JavaScript.html App method wiring contracts (#116)', () => { it('should have expected total App method count', () => { // All public methods (excluding underscore-prefixed private helpers) const publicMethods = appMethods.filter(m => !m.startsWith('_')); - // 48 original - 7 PR1 - 4 PR2 public - 11 PR3 public - 7 PR4 public = 19 remaining in JavaScript.html - expect(publicMethods.length).toBe(19); + // 48 original - 7 PR1 - 4 PR2 public - 11 PR3 public - 7 PR4 public - 5 PR5 = 14 remaining in JavaScript.html + expect(publicMethods.length).toBe(14); }); it('every public App method should be classified (extracted OR not-extractable)', () => { diff --git a/tests/unit/asyncMethodsWiring.test.js b/tests/unit/asyncMethodsWiring.test.js index fb83e98..62a70dd 100644 --- a/tests/unit/asyncMethodsWiring.test.js +++ b/tests/unit/asyncMethodsWiring.test.js @@ -22,6 +22,12 @@ const jsHtmlSource = readFileSync( 'utf-8' ); +// DataIO methods moved to IIFE module (Phase 1 PR5) +const dataIOSource = readFileSync( + resolve(import.meta.dirname, '../../DataIO.js.html'), + 'utf-8' +); + const gasSource = readFileSync( resolve(import.meta.dirname, '../../程式碼.js'), 'utf-8' @@ -54,30 +60,47 @@ const KNOWN_BACKEND_FUNCTIONS = extractGasFunctionNames(gasSource); * Note: applyTagFilters has NO ServerApi calls (pure frontend). * handleScheduleSelectChange has NO direct ServerApi calls (delegates to loadSchedule/loadDataFromServer). */ -const ASYNC_METHOD_WIRING = [ +// Methods in JavaScript.html +const JS_HTML_ASYNC_METHODS = [ ['handleAddSchedule', 227, ['addSchedule']], ['handleScheduleListClick', 259, ['updateScheduleMetadata', 'deleteSchedule', 'copySchedule']], ['handleScheduleSelectChange', 359, []], // No direct ServerApi.call — delegates to loadSchedule ['applyTagFilters', 433, []], // Pure frontend, no ServerApi - ['loadVersions', 505, ['getVersions']], - ['handleLoadVersion', 532, ['getVersionData']], - ['loadDataFromServer', 604, ['getData']], - ['saveDataToServer', 643, ['saveData']], ['printScheduleToPdf', 1117, ['getFontBase64FromDrive']], ]; +// Methods moved to DataIO.js.html (Phase 1 PR5) +const DATA_IO_ASYNC_METHODS = [ + ['loadVersions', 0, ['getVersions']], + ['handleLoadVersion', 0, ['getVersionData']], + ['loadDataFromServer', 0, ['getData']], + ['saveDataToServer', 0, ['saveData']], +]; + +const ASYNC_METHOD_WIRING = [...JS_HTML_ASYNC_METHODS, ...DATA_IO_ASYNC_METHODS]; + // ─── Helpers ───────────────────────────────────────────────────────────── /** - * Extract the body of an async method from JavaScript.html source. - * Searches for `methodName: async function` and captures the balanced braces body. + * Extract the body of an async method from source. + * Supports both object literal (`methodName: async function`) and + * IIFE-extracted (`App.methodName = async function`) patterns. */ function extractMethodBody(source, methodName) { - // Match the method declaration pattern in the App object literal + // Try object literal pattern first const declPattern = new RegExp( `${methodName}\\s*:\\s*async\\s+function\\s*\\([^)]*\\)\\s*\\{` ); - const match = declPattern.exec(source); + let match = declPattern.exec(source); + + // Try IIFE-extracted pattern: App.methodName = async function(...) { + if (!match) { + const iifePattern = new RegExp( + `App\\.${methodName}\\s*=\\s*async\\s+function\\s*\\([^)]*\\)\\s*\\{` + ); + match = iifePattern.exec(source); + } + if (!match) return null; // Find the balanced closing brace @@ -113,9 +136,9 @@ describe('Async App Methods — Wiring Smoke Tests (Static Analysis)', () => { // ─── 1. Method existence in source ──────────────────────────────────── - describe('all 9 async methods exist in JavaScript.html', () => { - it.each(ASYNC_METHOD_WIRING)( - '%s is declared as async method', + describe('all 5 JavaScript.html async methods exist', () => { + it.each(JS_HTML_ASYNC_METHODS)( + '%s is declared as async method in JavaScript.html', (methodName, _line, _expectedCalls) => { const body = extractMethodBody(jsHtmlSource, methodName); expect(body).not.toBeNull(); @@ -124,15 +147,34 @@ describe('Async App Methods — Wiring Smoke Tests (Static Analysis)', () => { ); }); + describe('all 4 DataIO.js.html async methods exist', () => { + it.each(DATA_IO_ASYNC_METHODS)( + '%s is declared as async method in DataIO.js.html', + (methodName, _line, _expectedCalls) => { + const body = extractMethodBody(dataIOSource, methodName); + expect(body).not.toBeNull(); + expect(body).toContain('async function'); + } + ); + }); + // ─── 2. ServerApi.call wiring ────────────────────────────────────────── + /** + * Helper: resolve the correct source for a method + */ + function resolveSource(methodName) { + if (DATA_IO_ASYNC_METHODS.some(([n]) => n === methodName)) return dataIOSource; + return jsHtmlSource; + } + describe('each method calls expected ServerApi.call targets', () => { it.each( ASYNC_METHOD_WIRING.filter(([, , calls]) => calls.length > 0) )( '%s calls ServerApi.call with correct function name(s)', (methodName, _line, expectedCalls) => { - const body = extractMethodBody(jsHtmlSource, methodName); + const body = extractMethodBody(resolveSource(methodName), methodName); expect(body).not.toBeNull(); const actualCalls = extractServerApiCalls(body); @@ -149,7 +191,7 @@ describe('Async App Methods — Wiring Smoke Tests (Static Analysis)', () => { )( '%s has no ServerApi.call (pure frontend / delegator)', (methodName, _line, _expectedCalls) => { - const body = extractMethodBody(jsHtmlSource, methodName); + const body = extractMethodBody(resolveSource(methodName), methodName); expect(body).not.toBeNull(); const actualCalls = extractServerApiCalls(body); @@ -173,12 +215,15 @@ describe('Async App Methods — Wiring Smoke Tests (Static Analysis)', () => { ); }); - // ─── 4. No unexpected ServerApi.call in JavaScript.html ──────────────── + // ─── 4. No unexpected ServerApi.call in source files ──────────────── describe('completeness — no undocumented ServerApi.call targets', () => { - it('all ServerApi.call targets in JavaScript.html are in our wiring map', () => { - // Extract ALL ServerApi.call from entire JavaScript.html - const allCalls = extractServerApiCalls(jsHtmlSource); + it('all ServerApi.call targets in JavaScript.html + DataIO.js.html are in our wiring map', () => { + // Extract ALL ServerApi.call from JavaScript.html and DataIO.js.html + const allCalls = [ + ...extractServerApiCalls(jsHtmlSource), + ...extractServerApiCalls(dataIOSource), + ]; const uniqueCalls = [...new Set(allCalls)]; // All targets documented in ASYNC_METHOD_WIRING diff --git a/tests/unit/lifecycleRegression.test.js b/tests/unit/lifecycleRegression.test.js index ad07827..bad1d5e 100644 --- a/tests/unit/lifecycleRegression.test.js +++ b/tests/unit/lifecycleRegression.test.js @@ -20,6 +20,7 @@ import { // ─── Source loading ────────────────────────────────────────────────────── const jsHtmlSource = loadSource(import.meta.dirname, '../../JavaScript.html'); +const dataIOSource = loadSource(import.meta.dirname, '../../DataIO.js.html'); const interactionSource = loadSource(import.meta.dirname, '../../Interaction.js.html'); const uiSource = loadSource(import.meta.dirname, '../../UI.js.html'); @@ -80,7 +81,7 @@ describe('Lifecycle Regression — init → load → render → interact → sav // ═══════════════════════════════════════════════════════════════════════ describe('Phase B: load — data flow from server', () => { - const loadBody = extractMethodBody(jsHtmlSource, 'loadDataFromServer'); + const loadBody = extractMethodBody(dataIOSource, 'loadDataFromServer'); it('loadDataFromServer method exists', () => { expect(loadBody).not.toBeNull(); @@ -91,30 +92,30 @@ describe('Lifecycle Regression — init → load → render → interact → sav expect(calls).toContain('getData'); }); - it('updates this.schedules from result', () => { - expect(containsCall(loadBody, 'this\\.schedules\\s*=')).toBe(true); + it('updates App.schedules from result', () => { + expect(containsCall(loadBody, 'App\\.schedules\\s*=')).toBe(true); }); - it('updates this.activeMetadataTimestamp', () => { - expect(containsCall(loadBody, 'this\\.activeMetadataTimestamp\\s*=')).toBe(true); + it('updates App.activeMetadataTimestamp', () => { + expect(containsCall(loadBody, 'App\\.activeMetadataTimestamp\\s*=')).toBe(true); }); it('calls loadInitialSchedules after fetching', () => { - expect(containsCall(loadBody, 'this\\.loadInitialSchedules')).toBe(true); + expect(containsCall(loadBody, 'App\\.loadInitialSchedules')).toBe(true); }); it('calls saveSchedulesToLocal after data processing', () => { - expect(containsCall(loadBody, 'this\\.saveSchedulesToLocal')).toBe(true); + expect(containsCall(loadBody, 'App\\.saveSchedulesToLocal')).toBe(true); }); it('calls manageLoadingState for start and end', () => { - expect(containsCall(loadBody, "this\\.ui\\.manageLoadingState\\(\\s*'start'")).toBe(true); - expect(containsCall(loadBody, "this\\.ui\\.manageLoadingState\\(\\s*'end'")).toBe(true); + expect(containsCall(loadBody, "App\\.ui\\.manageLoadingState\\(\\s*'start'")).toBe(true); + expect(containsCall(loadBody, "App\\.ui\\.manageLoadingState\\(\\s*'end'")).toBe(true); }); it('manages isConnecting flag', () => { - expect(containsCall(loadBody, 'this\\.isConnecting\\s*=\\s*true')).toBe(true); - expect(containsCall(loadBody, 'this\\.isConnecting\\s*=\\s*false')).toBe(true); + expect(containsCall(loadBody, 'App\\.isConnecting\\s*=\\s*true')).toBe(true); + expect(containsCall(loadBody, 'App\\.isConnecting\\s*=\\s*false')).toBe(true); }); }); @@ -262,7 +263,7 @@ describe('Lifecycle Regression — init → load → render → interact → sav // ═══════════════════════════════════════════════════════════════════════ describe('Phase E: save — data flow to server', () => { - const saveBody = extractMethodBody(jsHtmlSource, 'saveDataToServer'); + const saveBody = extractMethodBody(dataIOSource, 'saveDataToServer'); it('saveDataToServer method exists', () => { expect(saveBody).not.toBeNull(); @@ -273,14 +274,14 @@ describe('Lifecycle Regression — init → load → render → interact → sav expect(calls).toContain('saveData'); }); - it('collects data from this.classrooms, this.scheduleData, this.tags', () => { - expect(containsCall(saveBody, 'this\\.classrooms')).toBe(true); - expect(containsCall(saveBody, 'this\\.scheduleData')).toBe(true); - expect(containsCall(saveBody, 'this\\.tags')).toBe(true); + it('collects data from App.classrooms, App.scheduleData, App.tags', () => { + expect(containsCall(saveBody, 'App\\.classrooms')).toBe(true); + expect(containsCall(saveBody, 'App\\.scheduleData')).toBe(true); + expect(containsCall(saveBody, 'App\\.tags')).toBe(true); }); it('includes lastModified timestamp for conflict detection', () => { - expect(containsCall(saveBody, 'this\\.scheduleLastModified')).toBe(true); + expect(containsCall(saveBody, 'App\\.scheduleLastModified')).toBe(true); }); it('handles conflict response (saveResult.conflict)', () => { @@ -288,20 +289,20 @@ describe('Lifecycle Regression — init → load → render → interact → sav }); it('updates scheduleLastModified on success', () => { - expect(containsCall(saveBody, 'this\\.scheduleLastModified\\[this\\.activeScheduleId\\]\\s*=')).toBe(true); + expect(containsCall(saveBody, 'App\\.scheduleLastModified\\[App\\.activeScheduleId\\]\\s*=')).toBe(true); }); it('calls manageLoadingState for start and end', () => { - expect(containsCall(saveBody, "this\\.ui\\.manageLoadingState\\(\\s*'start'")).toBe(true); - expect(containsCall(saveBody, "this\\.ui\\.manageLoadingState\\(\\s*'end'")).toBe(true); + expect(containsCall(saveBody, "App\\.ui\\.manageLoadingState\\(\\s*'start'")).toBe(true); + expect(containsCall(saveBody, "App\\.ui\\.manageLoadingState\\(\\s*'end'")).toBe(true); }); it('manages isConnecting flag', () => { - expect(containsCall(saveBody, 'this\\.isConnecting')).toBe(true); + expect(containsCall(saveBody, 'App\\.isConnecting')).toBe(true); }); it('updates history clean snapshot on success', () => { - expect(containsCall(saveBody, 'this\\.historyModule\\.updateCleanSnapshot')).toBe(true); + expect(containsCall(saveBody, 'App\\.historyModule\\.updateCleanSnapshot')).toBe(true); }); }); @@ -320,8 +321,8 @@ describe('Lifecycle Regression — init → load → render → interact → sav }); it('load → render: loadDataFromServer calls loadInitialSchedules which calls loadSchedule', () => { - const loadBody = extractMethodBody(jsHtmlSource, 'loadDataFromServer'); - expect(containsCall(loadBody, 'this\\.loadInitialSchedules')).toBe(true); + const loadBody = extractMethodBody(dataIOSource, 'loadDataFromServer'); + expect(containsCall(loadBody, 'App\\.loadInitialSchedules')).toBe(true); const loadInitBody = extractMethodBody(jsHtmlSource, 'loadInitialSchedules'); expect(containsCall(loadInitBody, 'this\\.loadSchedule')).toBe(true); @@ -342,9 +343,9 @@ describe('Lifecycle Regression — init → load → render → interact → sav }); it('save → render: saveDataToServer updates history which can trigger re-render', () => { - const saveBody = extractMethodBody(jsHtmlSource, 'saveDataToServer'); - expect(containsCall(saveBody, 'this\\.historyModule\\.updateCleanSnapshot')).toBe(true); - expect(containsCall(saveBody, 'this\\.historyModule\\.checkDirty')).toBe(true); + const saveBody = extractMethodBody(dataIOSource, 'saveDataToServer'); + expect(containsCall(saveBody, 'App\\.historyModule\\.updateCleanSnapshot')).toBe(true); + expect(containsCall(saveBody, 'App\\.historyModule\\.checkDirty')).toBe(true); }); });