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
60 changes: 29 additions & 31 deletions tests/unit/backend.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,29 @@
* Backend GAS function tests using mock runtime.
* Ref: #51 — Wave 5 GAS mock testing phase 2
*
* Strategy: Wrap 程式碼.js source in a factory function that receives mock
* objects as params, returning the declared functions for direct testing.
* Strategy: Install GAS mocks as globals via vi.stubGlobal, then require()
* 程式碼.js directly so v8 can instrument it for coverage. Module cache is
* invalidated between calls to get a fresh evaluation (matching the old
* sandbox-per-test behavior).
*
* Ref: #107 — Migrated from new Function() sandbox to direct require() for
* v8 coverage instrumentation.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { describe, it, expect, vi } from 'vitest';
import { createRequire } from 'module';
import { createMockSheet, createMockSpreadsheetApp, createMockSession,
createMockLockService, createMockPropertiesService, createMockLogger,
createMockHtmlService, createMockDriveApp } from '../mocks/gasMocks.js';

const gasSource = readFileSync(
resolve(import.meta.dirname, '../../程式碼.js'),
'utf-8'
);
const _require = createRequire(import.meta.url);
const backendPath = _require.resolve('../../程式碼.js');

/**
* Create a sandboxed GAS environment — wraps 程式碼.js in a function that
* receives GAS globals as parameters and returns all declared functions.
* Create a GAS environment — installs mocks as globals, then require()s
* 程式碼.js with a fresh module evaluation (cache invalidated).
*
* Ref: #107 — replaced new Function() sandbox with direct require() so
* v8 coverage provider can instrument 程式碼.js.
*/
function createGasEnv(opts = {}) {
const sheets = opts.sheets || {};
Expand All @@ -31,26 +36,19 @@ function createGasEnv(opts = {}) {
const HtmlService = createMockHtmlService();
const DriveApp = createMockDriveApp(opts.driveFiles || {});

// Wrap the GAS source so that all top-level functions become properties
// of an object we can return. We inject the GAS globals as local variables.
const wrappedSource = `
return (function(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp) {
${gasSource}
return {
getConfig, _findScheduleRowInfo, _checkPermission, _getSs, getSheet, getOrCreateSheet,
doGet, getData, saveData, checkMetadata, addSchedule,
updateScheduleMetadata, deleteSchedule, copySchedule,
getVersions, getVersionData, getFontBase64FromDrive
};
})(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp);
`;

const factory = new Function(
'SpreadsheetApp', 'Session', 'LockService', 'PropertiesService', 'Logger', 'HtmlService', 'DriveApp',
wrappedSource
);

return factory(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp);
// Install GAS globals so 程式碼.js's top-level code can find them
vi.stubGlobal('SpreadsheetApp', SpreadsheetApp);
vi.stubGlobal('Session', Session);
vi.stubGlobal('LockService', LockService);
vi.stubGlobal('PropertiesService', PropertiesService);
vi.stubGlobal('Logger', Logger);
vi.stubGlobal('HtmlService', HtmlService);
vi.stubGlobal('DriveApp', DriveApp);

// Invalidate module cache to get a fresh evaluation (resets _ss memoization)
delete _require.cache[backendPath];

return _require(backendPath);
}

// ─── checkMetadata ───────────────────────────────────────────────────────
Expand Down
57 changes: 26 additions & 31 deletions tests/unit/backendSignatureContracts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,26 @@
*
* Ref: #107 — P0 Coverage Sprint Wave 1 (signature contracts)
*
* Strategy: Reuses the createGasEnv() sandbox pattern from backend.test.js
* to wrap 程式碼.js and expose all functions. Validates:
* Strategy: Installs GAS mocks as globals, then require()s 程式碼.js directly
* so v8 can instrument it for coverage. Validates:
* 1. All 17 functions exist and are typeof 'function'
* 2. Each function's .length matches its declared parameter count
* 3. The 9 frontend-called functions (via ServerApi.call) exist in env
*
* Ref: #107 — Migrated from new Function() sandbox to direct require().
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { createMockSheet, createMockSpreadsheetApp, createMockSession,
import { describe, it, expect, vi } from 'vitest';
import { createRequire } from 'module';
import { createMockSpreadsheetApp, createMockSession,
createMockLockService, createMockPropertiesService, createMockLogger,
createMockHtmlService, createMockDriveApp } from '../mocks/gasMocks.js';

const gasSource = readFileSync(
resolve(import.meta.dirname, '../../程式碼.js'),
'utf-8'
);
const _require = createRequire(import.meta.url);
const backendPath = _require.resolve('../../程式碼.js');

/**
* Create a sandboxed GAS environment — mirrors backend.test.js createGasEnv.
* Create a GAS environment — mirrors backend.test.js createGasEnv.
* Ref: #107 — direct require() for v8 coverage.
*/
function createGasEnv(opts = {}) {
const sheets = opts.sheets || {};
Expand All @@ -35,24 +35,17 @@ function createGasEnv(opts = {}) {
const HtmlService = createMockHtmlService();
const DriveApp = createMockDriveApp(opts.driveFiles || {});

const wrappedSource = `
return (function(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp) {
${gasSource}
return {
getConfig, _findScheduleRowInfo, _checkPermission, _getSs, getSheet, getOrCreateSheet,
doGet, getData, saveData, checkMetadata, addSchedule,
updateScheduleMetadata, deleteSchedule, copySchedule,
getVersions, getVersionData, getFontBase64FromDrive
};
})(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp);
`;

const factory = new Function(
'SpreadsheetApp', 'Session', 'LockService', 'PropertiesService', 'Logger', 'HtmlService', 'DriveApp',
wrappedSource
);

return factory(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp);
vi.stubGlobal('SpreadsheetApp', SpreadsheetApp);
vi.stubGlobal('Session', Session);
vi.stubGlobal('LockService', LockService);
vi.stubGlobal('PropertiesService', PropertiesService);
vi.stubGlobal('Logger', Logger);
vi.stubGlobal('HtmlService', HtmlService);
vi.stubGlobal('DriveApp', DriveApp);

delete _require.cache[backendPath];

return _require(backendPath);
}

// ─── All 17 top-level functions with expected parameter counts ────────────
Expand Down Expand Up @@ -116,8 +109,10 @@ describe('程式碼.js Signature Contracts', () => {
}
);

it('env exposes exactly 17 functions', () => {
const fnCount = Object.keys(env).filter(k => typeof env[k] === 'function').length;
it('env exposes exactly 17 production functions (excluding test helpers)', () => {
const fnCount = Object.keys(env)
.filter(k => typeof env[k] === 'function' && !k.startsWith('_reset'))
.length;
expect(fnCount).toBe(17);
});
});
Expand Down
42 changes: 16 additions & 26 deletions tests/unit/businessLogicEdgeCases.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@
*
* Closes #138
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { describe, it, expect, vi } from 'vitest';
import { createRequire } from 'module';
import {
timeToMinutes,
checkTimeConflict,
Expand All @@ -23,12 +22,10 @@ import { createMockSheet, createMockSpreadsheetApp, createMockSession,
createMockLockService, createMockPropertiesService, createMockLogger,
createMockHtmlService, createMockDriveApp } from '../mocks/gasMocks.js';

// ─── GAS environment factory (same as backend.test.js) ─────────────────────
// ─── GAS environment factory (Ref: #107 — direct require for v8 coverage) ──

const gasSource = readFileSync(
resolve(import.meta.dirname, '../../程式碼.js'),
'utf-8'
);
const _require = createRequire(import.meta.url);
const backendPath = _require.resolve('../../程式碼.js');

function createGasEnv(opts = {}) {
const sheets = opts.sheets || {};
Expand All @@ -40,24 +37,17 @@ function createGasEnv(opts = {}) {
const HtmlService = createMockHtmlService();
const DriveApp = createMockDriveApp(opts.driveFiles || {});

const wrappedSource = `
return (function(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp) {
${gasSource}
return {
getConfig, _findScheduleRowInfo, _checkPermission, _getSs, getSheet, getOrCreateSheet,
doGet, getData, saveData, checkMetadata, addSchedule,
updateScheduleMetadata, deleteSchedule, copySchedule,
getVersions, getVersionData, getFontBase64FromDrive
};
})(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp);
`;

const factory = new Function(
'SpreadsheetApp', 'Session', 'LockService', 'PropertiesService', 'Logger', 'HtmlService', 'DriveApp',
wrappedSource
);

return factory(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp);
vi.stubGlobal('SpreadsheetApp', SpreadsheetApp);
vi.stubGlobal('Session', Session);
vi.stubGlobal('LockService', LockService);
vi.stubGlobal('PropertiesService', PropertiesService);
vi.stubGlobal('Logger', Logger);
vi.stubGlobal('HtmlService', HtmlService);
vi.stubGlobal('DriveApp', DriveApp);

delete _require.cache[backendPath];

return _require(backendPath);
}

// ═══════════════════════════════════════════════════════════════════════════
Expand Down
65 changes: 31 additions & 34 deletions tests/unit/wiringContracts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,42 +40,39 @@ function extractProductionFunctions(source) {

const productionFunctions = extractProductionFunctions(productionSource);

// ─── Parse backend.test.js factory return ──────────────────────────────────

const backendTestSource = readFileSync(
resolve(import.meta.dirname, './backend.test.js'),
'utf-8'
// ─── Verify 程式碼.js exports via direct require ─────────────────────────────
// Ref: #107 — backend.test.js no longer uses factory return block; instead
// 程式碼.js exports its functions via conditional module.exports. We verify
// the wiring by requiring the module directly.

import { createRequire } from 'module';
import { createMockSpreadsheetApp, createMockSession,
createMockLockService, createMockPropertiesService, createMockLogger,
createMockHtmlService, createMockDriveApp } from '../mocks/gasMocks.js';

const _require = createRequire(import.meta.url);
const backendPath = _require.resolve('../../程式碼.js');

// Install minimal GAS mocks so require() succeeds (程式碼.js accesses no globals
// at module-load time, but the conditional export block reads function refs)
globalThis.SpreadsheetApp = createMockSpreadsheetApp({});
globalThis.Session = createMockSession('test@example.com');
globalThis.LockService = createMockLockService();
globalThis.PropertiesService = createMockPropertiesService({});
globalThis.Logger = createMockLogger();
globalThis.HtmlService = createMockHtmlService();
globalThis.DriveApp = createMockDriveApp({});

delete _require.cache[backendPath];
const backendExports = _require(backendPath);

// Extract function names from the module exports (excluding test-only helpers)
const factoryReturnNames = new Set(
Object.keys(backendExports).filter(k =>
typeof backendExports[k] === 'function' && !k.startsWith('_reset')
)
);

/**
* Extract function names from the factory return block in backend.test.js.
* The factory pattern returns an object literal: `return { fn1, fn2, ... };`
*/
function extractFactoryReturnNames(source) {
// Find the return { ... } block inside the wrapped source template literal
const returnBlockRegex = /return\s*\{[\s\S]*?\};\s*\}\)\(SpreadsheetApp/;
const blockMatch = source.match(returnBlockRegex);
if (!blockMatch) return new Set();

const block = blockMatch[0];
// Extract identifiers (handles multiline, trailing commas)
const names = new Set();
const idRegex = /\b(\w+)\b/g;
let m;
// Skip the 'return' keyword and the closing parens
const inner = block.replace(/^return\s*\{/, '').replace(/\};\s*\}\)\(SpreadsheetApp$/, '');
while ((m = idRegex.exec(inner)) !== null) {
const name = m[1];
// Filter out non-function tokens
if (name !== 'return' && name !== 'SpreadsheetApp') {
names.add(name);
}
}
return names;
}

const factoryReturnNames = extractFactoryReturnNames(backendTestSource);

// ─── Parse tests/lib/ exported functions ───────────────────────────────────

const libDir = resolve(import.meta.dirname, '../lib');
Expand Down
17 changes: 16 additions & 1 deletion 程式碼.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
* Throws an error if permission is denied.
* @param {string} createdBy The email of the user who created the schedule.
*/
function _checkPermission(createdBy) {

Check warning on line 43 in 程式碼.js

View workflow job for this annotation

GitHub Actions / validate

'createdBy' is defined but never used
const currentUser = Session.getActiveUser().getEmail();
// Ref: #62 — Guard against empty email (e.g. time-driven triggers return '')
if (!currentUser) throw new Error('未登入,無法執行此操作');
Expand Down Expand Up @@ -197,7 +197,7 @@
throw new Error("無效的數據格式。數據必須包含 scheduleId, scheduleData, 和 lastModified 時間戳。");
}

const ss = _getSs();

Check warning on line 200 in 程式碼.js

View workflow job for this annotation

GitHub Actions / validate

'ss' is assigned a value but never used
const dataSheet = getOrCreateSheet(SHEET_DATA);

const { index: rowIndex, values: rowValues } = _findScheduleRowInfo(scheduleId, dataSheet);
Expand Down Expand Up @@ -602,4 +602,19 @@
Logger.log(`從 Drive 獲取字體時發生錯誤: ${e.stack}`);
return { success: false, error: e.toString() };
}
}
}

// Ref: #107 — Enable vitest v8 coverage instrumentation.
// GAS runtime has no `module` global, so this export block is a no-op in production.
/* eslint-disable no-undef */
if (typeof module !== 'undefined') {
module.exports = {
_getSs, getConfig, _findScheduleRowInfo, _checkPermission,
getSheet, getOrCreateSheet, doGet, getData, saveData, checkMetadata,
addSchedule, updateScheduleMetadata, deleteSchedule, copySchedule,
getVersions, getVersionData, getFontBase64FromDrive,
// Test-only: allow resetting memoized spreadsheet reference between tests
_resetSs: () => { _ss = null; },
};
}
/* eslint-enable no-undef */
Loading