Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Index.html
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ <h2 class="text-2xl font-bold mb-6 text-center text-purple-800">PDF 下載選項
<?!= HtmlService.createHtmlOutputFromFile('Interaction.js.html').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('JavaScript').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('UtilityFunctions.js').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('LockManager.js').getContent(); ?>
<script>App.init();</script>
</body>
</html>
60 changes: 6 additions & 54 deletions JavaScript.html
Original file line number Diff line number Diff line change
Expand Up @@ -739,60 +739,12 @@

// --- UTILITY ---

_getLocks: function () {
try {
return JSON.parse(localStorage.getItem('gemini_schedule_locks') || '{}');
} catch (e) {
return {};
}
},

_saveLocks: function (locks) {
localStorage.setItem('gemini_schedule_locks', JSON.stringify(locks));
},

acquireLock: function (scheduleId) {
const locks = this._getLocks();
const existingLock = locks[scheduleId];
const now = Date.now();

if (existingLock && existingLock.tabId !== this.tabId) {
const isStale = (now - existingLock.timestamp) > 15000; // 15 second stale threshold
if (!isStale) {
return false; // Lock is held by another tab
}
// If we are here, the lock is stale, so we break it.
}

// Acquire or update the lock
locks[scheduleId] = { tabId: this.tabId, timestamp: now };
this._saveLocks(locks);
return true;
},

releaseLock: function (scheduleId) {
if (!scheduleId) return;
const locks = this._getLocks();
if (locks[scheduleId] && locks[scheduleId].tabId === this.tabId) {
delete locks[scheduleId];
this._saveLocks(locks);
}
},

releaseCurrentLock: function () {
this.releaseLock(this.activeScheduleId);
},

refreshLockHeartbeat: function () {
if (this.isReadOnly || !this.activeScheduleId || this.activeScheduleId === AppConfig.ALL_SCHEDULES_ID) {
return;
}
const locks = this._getLocks();
if (locks[this.activeScheduleId] && locks[this.activeScheduleId].tabId === this.tabId) {
locks[this.activeScheduleId].timestamp = Date.now();
this._saveLocks(locks);
}
},
// _getLocks — moved to LockManager.js.html (Phase 1 PR2)
// _saveLocks — moved to LockManager.js.html (Phase 1 PR2)
// acquireLock — moved to LockManager.js.html (Phase 1 PR2)
// releaseLock — moved to LockManager.js.html (Phase 1 PR2)
// releaseCurrentLock — moved to LockManager.js.html (Phase 1 PR2)
// refreshLockHeartbeat — moved to LockManager.js.html (Phase 1 PR2)

_collectFromScheduleData: function (dataSource, collectorFn) {
const resultSet = new Set();
Expand Down
70 changes: 70 additions & 0 deletions LockManager.js.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<script>
/**
* LockManager.js.html — Tab-level schedule lock management
*
* Part of #129 Phase 1 (PR2/7): IIFE-wrapped domain separation.
* Manages localStorage-based locks to prevent concurrent editing
* across browser tabs. Reads App.tabId, App.activeScheduleId,
* App.isReadOnly state properties.
*
* Load order: after JavaScript.html (App core), before App.init().
*/
(function(App) {

App._getLocks = function() {
try {
return JSON.parse(localStorage.getItem('gemini_schedule_locks') || '{}');
} catch (e) {
return {};
}
};

App._saveLocks = function(locks) {
localStorage.setItem('gemini_schedule_locks', JSON.stringify(locks));
};

App.acquireLock = function(scheduleId) {
const locks = App._getLocks();
const existingLock = locks[scheduleId];
const now = Date.now();

if (existingLock && existingLock.tabId !== App.tabId) {
const isStale = (now - existingLock.timestamp) > 15000; // 15 second stale threshold
if (!isStale) {
return false; // Lock is held by another tab
}
// If we are here, the lock is stale, so we break it.
}

// Acquire or update the lock
locks[scheduleId] = { tabId: App.tabId, timestamp: now };
App._saveLocks(locks);
return true;
};

App.releaseLock = function(scheduleId) {
if (!scheduleId) return;
const locks = App._getLocks();
if (locks[scheduleId] && locks[scheduleId].tabId === App.tabId) {
delete locks[scheduleId];
App._saveLocks(locks);
}
};

App.releaseCurrentLock = function() {
App.releaseLock(App.activeScheduleId);
};

App.refreshLockHeartbeat = function() {
if (App.isReadOnly || !App.activeScheduleId || App.activeScheduleId === AppConfig.ALL_SCHEDULES_ID) {
return;
}
const locks = App._getLocks();
if (locks[App.activeScheduleId] && locks[App.activeScheduleId].tabId === App.tabId) {
locks[App.activeScheduleId].timestamp = Date.now();
App._saveLocks(locks);
}
};

})(App);
</script>
18 changes: 9 additions & 9 deletions tests/unit/appWiringContracts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const EXTRACTED_TO_LIB = new Set([
// appLifecycleHelpers.js (new — this wave)
'loadInitialSchedules', 'loadSchedule', 'canManageCurrentScheduleSettings',
'loadAndApplyPersistedFilters', 'applyFilters', 'clearAdvancedFilters',
'clearAllFilters', 'refreshLockHeartbeat',
'clearAllFilters',
'saveSchedulesToLocal',
]);

Expand All @@ -98,18 +98,13 @@ const NOT_EXTRACTABLE = new Set([
'loadDataFromServer', // ServerApi + state orchestration (result processing extracted)
'isCurrentUserAdmin', // Global var IS_ADMIN (trivial, 1 line)
'printScheduleToPdf', // jsPDF + DOM + ServerApi (massively coupled)
'acquireLock', // Already extracted as createLockManager in frontendMocks.js
'releaseLock', // Already extracted as createLockManager in frontendMocks.js
'releaseCurrentLock', // Wrapper around releaseLock (1 line)
]);

/**
* Private helpers (underscore-prefixed) — internal implementation details.
* These are tested indirectly through their public callers.
*/
const PRIVATE_HELPERS = new Set([
'_getLocks',
'_saveLocks',
'_collectFromScheduleData',
'_collectFromAllCourses',
'_forEachCourse',
Expand All @@ -125,6 +120,9 @@ const IIFE_EXTRACTED = new Set([
// UtilityFunctions.js.html (PR1)
'getShortUserName', 'generateUniqueId', 'stringToHashCode',
'timeToMinutes', 'formatTime', 'formatTimestampForFilename', 'hexToRgb',
// LockManager.js.html (PR2)
'_getLocks', '_saveLocks', 'acquireLock', 'releaseLock',
'releaseCurrentLock', 'refreshLockHeartbeat',
]);

// ─── Tests ─────────────────────────────────────────────────────────────────
Expand All @@ -137,8 +135,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 IIFE-extracted = 41 remaining in JavaScript.html
expect(publicMethods.length).toBe(41);
// 48 original - 7 PR1 - 4 PR2 public = 37 remaining in JavaScript.html
expect(publicMethods.length).toBe(37);
});

it('every public App method should be classified (extracted OR not-extractable)', () => {
Expand All @@ -160,7 +158,9 @@ describe('JavaScript.html App method wiring contracts (#116)', () => {
});

it('private helpers should be accounted for', () => {
const unclassifiedPrivate = privateMethods.filter(m => !PRIVATE_HELPERS.has(m));
const unclassifiedPrivate = privateMethods.filter(m =>
!PRIVATE_HELPERS.has(m) && !IIFE_EXTRACTED.has(m)
);
expect(
unclassifiedPrivate,
`Unclassified private helpers: ${unclassifiedPrivate.join(', ')}`
Expand Down
75 changes: 54 additions & 21 deletions tests/unit/syncMethodsWiring.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ const jsHtmlSource = readFileSync(
'utf-8'
);

// LockManager methods moved to separate IIFE module (Phase 1 PR2)
const lockManagerSource = readFileSync(
resolve(import.meta.dirname, '../../LockManager.js.html'),
'utf-8'
);

// ─── Helpers (shared pattern from Wave 2) ────────────────────────────────

/**
Expand All @@ -30,7 +36,16 @@ 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 = function(...) {
if (!match) {
const iifePattern = new RegExp(
`App\\.${methodName}\\s*=\\s*function\\s*\\([^)]*\\)\\s*\\{`
);
match = iifePattern.exec(source);
}

if (!match) return null;

const startIdx = match.index + match[0].length;
Expand Down Expand Up @@ -117,31 +132,31 @@ const SYNC_METHOD_WIRING = [
'gemini_schedule_locks',
]],
['releaseCurrentLock', [
'this\\.releaseLock',
'this\\.activeScheduleId',
'App\\.releaseLock',
'App\\.activeScheduleId',
]],
['refreshLockHeartbeat', [
'this\\.isReadOnly',
'this\\.activeScheduleId',
'App\\.isReadOnly',
'App\\.activeScheduleId',
'AppConfig\\.ALL_SCHEDULES_ID',
'this\\._getLocks',
'this\\._saveLocks',
'App\\._getLocks',
'App\\._saveLocks',
'Date\\.now',
]],
];

// Also verify these helper methods that support the lock system
const LOCK_HELPER_METHODS = [
['acquireLock', [
'this\\._getLocks',
'this\\._saveLocks',
'this\\.tabId',
'App\\._getLocks',
'App\\._saveLocks',
'App\\.tabId',
'Date\\.now',
]],
['releaseLock', [
'this\\._getLocks',
'this\\._saveLocks',
'this\\.tabId',
'App\\._getLocks',
'App\\._saveLocks',
'App\\.tabId',
]],
];

Expand All @@ -153,14 +168,31 @@ describe('Sync App Methods — Wiring Smoke Tests (Static Analysis)', () => {

// ─── 1. Method existence ──────────────────────────────────────────────

describe('all sync methods exist in JavaScript.html', () => {
it.each(ALL_METHODS)(
'%s is declared as a method',
describe('all sync methods exist in JavaScript.html or IIFE modules', () => {
// Non-lock methods from JavaScript.html
const jsHtmlMethods = SYNC_METHOD_WIRING.filter(([n]) =>
!['_getLocks', '_saveLocks', 'releaseCurrentLock', 'refreshLockHeartbeat'].includes(n)
);
it.each(jsHtmlMethods)(
'%s is declared in JavaScript.html',
(methodName, _patterns) => {
const body = extractMethodBody(jsHtmlSource, methodName);
expect(body).not.toBeNull();
}
);

// Lock methods from LockManager.js.html
const lockMethods = [...ALL_METHODS.filter(([n]) =>
['_getLocks', '_saveLocks', 'acquireLock', 'releaseLock',
'releaseCurrentLock', 'refreshLockHeartbeat'].includes(n)
)];
it.each(lockMethods)(
'%s is declared in LockManager.js.html',
(methodName, _patterns) => {
const body = extractMethodBody(lockManagerSource, methodName);
expect(body).not.toBeNull();
}
);
});

// ─── 2. Wiring correctness ────────────────────────────────────────────
Expand Down Expand Up @@ -231,7 +263,8 @@ describe('Sync App Methods — Wiring Smoke Tests (Static Analysis)', () => {
const entry = ALL_METHODS.find(([n]) => n === methodName);
if (!entry) continue;
const [, patterns] = entry;
const body = extractMethodBody(jsHtmlSource, methodName);
// Lock methods now in LockManager.js.html
const body = extractMethodBody(lockManagerSource, methodName);

describe(methodName, () => {
it.each(patterns)(
Expand Down Expand Up @@ -264,16 +297,16 @@ describe('Sync App Methods — Wiring Smoke Tests (Static Analysis)', () => {
});

it('releaseCurrentLock delegates to releaseLock (thin wrapper)', () => {
const body = extractMethodBody(jsHtmlSource, 'releaseCurrentLock');
const body = extractMethodBody(lockManagerSource, 'releaseCurrentLock');
expect(body).not.toBeNull();
const lines = body.split('\n').filter(l => l.trim().length > 0);
expect(lines.length).toBeLessThanOrEqual(5);
expect(containsCall(body, 'this\\.releaseLock\\(this\\.activeScheduleId\\)')).toBe(true);
expect(containsCall(body, 'App\\.releaseLock\\(App\\.activeScheduleId\\)')).toBe(true);
});

it('_getLocks and _saveLocks use the same localStorage key', () => {
const getBody = extractMethodBody(jsHtmlSource, '_getLocks');
const saveBody = extractMethodBody(jsHtmlSource, '_saveLocks');
const getBody = extractMethodBody(lockManagerSource, '_getLocks');
const saveBody = extractMethodBody(lockManagerSource, '_saveLocks');
expect(getBody).not.toBeNull();
expect(saveBody).not.toBeNull();

Expand Down
Loading