diff --git a/__mocks__/electron.js b/__mocks__/electron.js index c57fe7a..1285f34 100644 --- a/__mocks__/electron.js +++ b/__mocks__/electron.js @@ -1,3 +1,12 @@ +export const contextBridge = { + exposeInMainWorld: jest.fn(), +}; + +export const ipcRenderer = { + send: jest.fn(), + on: jest.fn(), + invoke: jest.fn(), +}; import { jest } from '@jest/globals'; export const app = { diff --git a/app/games/fast-piggie/index.js b/app/games/fast-piggie/index.js index f4dcbd2..e9a47d8 100644 --- a/app/games/fast-piggie/index.js +++ b/app/games/fast-piggie/index.js @@ -337,6 +337,7 @@ function _runRound() { _roundTimer = setTimeout(() => { clearImages(_ctx, width, height, wedgeCount); + _currentRound._imagesHiddenAt = Date.now(); _clickEnabled = true; _hoveredWedge = -1; _selectedWedge = -1; @@ -486,7 +487,7 @@ function _resolveRound(wedge) { // Track answer speed (time from images hidden to answer) // We'll store the time when images are hidden in _currentRound._imagesHiddenAt let answerSpeedMs = null; - if (_currentRound._imagesHiddenAt) { + if (_currentRound._imagesHiddenAt != null) { answerSpeedMs = Date.now() - _currentRound._imagesHiddenAt; } diff --git a/app/games/fast-piggie/tests/game.test.js b/app/games/fast-piggie/tests/game.test.js index c5bf563..062ce5a 100644 --- a/app/games/fast-piggie/tests/game.test.js +++ b/app/games/fast-piggie/tests/game.test.js @@ -10,6 +10,7 @@ import { calculateWedgeIndex, addScore, addMiss, + getBestStats, getScore, getRoundsPlayed, getLevel, @@ -416,3 +417,51 @@ describe('isRunning()', () => { expect(isRunning()).toBe(false); }); }); + +describe('getBestStats()', () => { + it('returns an object with maxScore, mostRounds, mostGuineaPigs, topSpeedMs', () => { + const stats = getBestStats(); + expect(stats).toHaveProperty('maxScore'); + expect(stats).toHaveProperty('mostRounds'); + expect(stats).toHaveProperty('mostGuineaPigs'); + expect(stats).toHaveProperty('topSpeedMs'); + }); + + it('maxScore reflects the highest score achieved since module load', () => { + startGame(); + addScore(); + addScore(); + stopGame(); + const stats = getBestStats(); + expect(stats.maxScore).toBeGreaterThanOrEqual(2); + }); + + it('mostGuineaPigs updates when addScore is called with a guineaPigs count', () => { + addScore(7); + const stats = getBestStats(); + expect(stats.mostGuineaPigs).toBeGreaterThanOrEqual(7); + }); + + it('topSpeedMs updates when addScore is called with an answerSpeedMs value', () => { + addScore(3, 500); + const stats = getBestStats(); + expect(stats.topSpeedMs).toBeLessThanOrEqual(500); + }); + + it('topSpeedMs is null when no speed has been recorded', () => { + // initGame() resets the session but session-best trackers are module-level; + // to get null we rely on a fresh import (module-level state). + // Here we just verify the type contract: null or a non-negative number. + const stats = getBestStats(); + expect(stats.topSpeedMs === null || typeof stats.topSpeedMs === 'number').toBe(true); + }); + + it('mostRounds reflects rounds played across sessions', () => { + startGame(); + addScore(); + addMiss(); + stopGame(); + const stats = getBestStats(); + expect(stats.mostRounds).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/app/games/fast-piggie/tests/index.test.js b/app/games/fast-piggie/tests/index.test.js index 4ea6b4e..cfb0cb8 100644 --- a/app/games/fast-piggie/tests/index.test.js +++ b/app/games/fast-piggie/tests/index.test.js @@ -1094,3 +1094,174 @@ describe('progress saving', () => { delete globalThis.api; }); }); + +// =========================================================================== +// stop() end-panel flow and return-to-main-menu wiring +// =========================================================================== +describe('stop() end-panel flow and _returnToMainMenu', () => { + it('stop() shows the end panel with score and high score', async () => { + plugin.start(); + await plugin.stop(); + const endPanel = container.querySelector('#fp-end-panel'); + expect(endPanel.hidden).toBe(false); + expect(container.querySelector('#fp-final-score').textContent).toBe('3'); + expect(container.querySelector('#fp-final-high-score').textContent).toBe('3'); + }); + + it('stop() hides the game area and End Game button', async () => { + plugin.start(); + await plugin.stop(); + expect(container.querySelector('#fp-game-area').hidden).toBe(true); + expect(container.querySelector('#fp-stop-btn').hidden).toBe(true); + }); + + it('clicking #fp-return-btn dispatches bsx:return-to-main-menu', () => { + let fired = false; + window.addEventListener('bsx:return-to-main-menu', () => { fired = true; }, { once: true }); + container.querySelector('#fp-return-btn').click(); + expect(fired).toBe(true); + }); +}); + +// =========================================================================== +// start button click callback (f[34] in index.js) +// =========================================================================== +describe('start button click event', () => { + it('clicking #fp-start-btn triggers plugin.start()', () => { + const startBtn = container.querySelector('#fp-start-btn'); + startBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(game.startGame).toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// _triggerFlash setTimeout callback and _resolveRound next-round timer +// (f[18] and f[30] in index.js) +// =========================================================================== +describe('_triggerFlash and next-round timers', () => { + function fireCorrectClick() { + container.querySelector('#fp-canvas').dispatchEvent( + new MouseEvent('click', { clientX: 250, clientY: 100, bubbles: true }), + ); + } + + beforeEach(() => { + game.calculateWedgeIndex.mockReturnValue(2); + game.checkAnswer.mockReturnValue(true); + plugin.start(); + jest.runAllTimers(); // advance past displayDurationMs → _clickEnabled = true + }); + + it('flash class is removed after the 600ms _triggerFlash timeout fires', () => { + fireCorrectClick(); + const flash = container.querySelector('#fp-flash'); + expect(flash.classList.contains('fp-flash--correct')).toBe(true); + jest.runAllTimers(); // fires 600ms flash timer + 1000ms next-round + next displayDurationMs + expect(flash.classList.contains('fp-flash--correct')).toBe(false); + }); + + it('next-round 1000ms timer calls _runRound (generateRound is called again)', () => { + game.generateRound.mockClear(); + fireCorrectClick(); + jest.runAllTimers(); // fires 600ms flash + 1000ms next-round → _runRound → generateRound + expect(game.generateRound).toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// loadImages onerror path and init() .catch() fallback +// (f[4] and f[33] in index.js) +// =========================================================================== +describe('loadImages() — onerror path', () => { + it('rejects when the image fails to load (onerror fired)', async () => { + const OriginalImage = globalThis.Image; + globalThis.Image = class { + set src(_) { + if (this.onerror) this.onerror(new Error('load failed')); + } + }; + + let rejected = false; + try { + await loadImages('bad.png'); + } catch { + rejected = true; + } + expect(rejected).toBe(true); + + globalThis.Image = OriginalImage; + }); +}); + +describe('init() — loadImages .catch() fallback', () => { + it('does not throw when image loading fails (falls back to null images)', async () => { + const OriginalImage = globalThis.Image; + globalThis.Image = class { + set src(_) { + if (this.onerror) this.onerror(new Error('load failed')); + } + }; + + // init() calls loadImages which will reject; .catch() sets _images = [null, null] + expect(() => plugin.init(buildContainer())).not.toThrow(); + // Flush microtasks so the .catch() callback executes + await Promise.resolve(); + await Promise.resolve(); + + globalThis.Image = OriginalImage; + }); +}); + +// =========================================================================== +// _resolveRound — no slot assignment when imageCount equals wedgeCount +// (covers the `return outlierWedgeIndex` else-path in _getCorrectWedgeIndex) +// =========================================================================== +describe('_resolveRound — no slot assignment when imageCount equals wedgeCount', () => { + beforeEach(() => { + game.generateRound.mockReturnValueOnce({ + wedgeCount: 6, + imageCount: 6, + displayDurationMs: 2000, + outlierWedgeIndex: 2, + }); + game.calculateWedgeIndex.mockReturnValue(2); + game.checkAnswer.mockReturnValue(true); + plugin.start(); + jest.runAllTimers(); + }); + + it('resolves round without slot assignment (imageCount === wedgeCount)', () => { + const canvas = container.querySelector('#fp-canvas'); + canvas.dispatchEvent(new MouseEvent('click', { clientX: 250, clientY: 100, bubbles: true })); + expect(game.addScore).toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// _showEndPanel and _returnToMainMenu +// =========================================================================== +describe('_showEndPanel and _returnToMainMenu', () => { + it('end panel contains Game Over heading after stop()', async () => { + plugin.start(); + await plugin.stop(); + const endPanel = container.querySelector('#fp-end-panel'); + expect(endPanel.textContent).toContain('Game Over'); + }); + + it('clicking #fp-play-again-btn resets and restarts game', () => { + const resetSpy = jest.spyOn(plugin, 'reset'); + const startSpy = jest.spyOn(plugin, 'start'); + container.querySelector('#fp-play-again-btn').click(); + expect(resetSpy).toHaveBeenCalled(); + expect(startSpy).toHaveBeenCalled(); + resetSpy.mockRestore(); + startSpy.mockRestore(); + }); + + it('clicking #fp-return-btn can be triggered repeatedly without throwing', () => { + expect(() => { + container.querySelector('#fp-return-btn').click(); + container.querySelector('#fp-return-btn').click(); + }).not.toThrow(); + }); +}); diff --git a/app/games/field-of-view/tests/index.test.js b/app/games/field-of-view/tests/index.test.js index 4b2d53b..382c0a3 100644 --- a/app/games/field-of-view/tests/index.test.js +++ b/app/games/field-of-view/tests/index.test.js @@ -264,4 +264,141 @@ describe('field-of-view index', () => { expect(document.querySelector('#fov-game-area').hidden).toBe(false); expect(gameMock.startGame).toHaveBeenCalled(); }); + + test('start and stop buttons invoke lifecycle handlers via click', () => { + gameMock.startGame.mockClear(); + gameMock.stopGame.mockClear(); + + document.querySelector('#fov-start-btn').click(); + document.querySelector('#fov-stop-btn').click(); + + expect(gameMock.startGame).toHaveBeenCalled(); + expect(gameMock.stopGame).toHaveBeenCalled(); + }); + + test('center secondary click updates center selection and can submit', () => { + plugin.start(); + jest.runAllTimers(); + + document.querySelector('#fov-center-secondary').click(); + document.querySelector('[data-index="1"]').click(); + + expect(gameMock.recordTrial).toHaveBeenCalled(); + }); + + test('center choice is ignored before response phase is enabled', () => { + gameMock.recordTrial.mockClear(); + + // Before start(), response input is disabled and chooseCenter should return early. + document.querySelector('#fov-center-secondary').click(); + + expect(gameMock.recordTrial).not.toHaveBeenCalled(); + }); + + test('feedback flash timeout clears stage flash class', () => { + gameMock.recordTrial.mockReturnValueOnce({ thresholdMs: 84.2, recentAccuracy: 0.8 }); + plugin.start(); + jest.runAllTimers(); + + document.querySelector('#fov-center-primary').click(); + document.querySelector('[data-index="1"]').click(); + + const stage = document.querySelector('#fov-stage'); + expect(stage.classList.contains('fov-stage--flash-correct')).toBe(true); + + jest.runOnlyPendingTimers(); + expect(stage.classList.contains('fov-stage--flash-correct')).toBe(false); + }); + + test('next-trial timer callback starts another trial when still running', () => { + gameMock.isRunning.mockReturnValue(true); + gameMock.createTrialLayout.mockClear(); + + plugin.start(); + jest.runAllTimers(); + + document.querySelector('#fov-center-primary').click(); + document.querySelector('[data-index="1"]').click(); + + jest.runOnlyPendingTimers(); + expect(gameMock.createTrialLayout).toHaveBeenCalledTimes(2); + }); + + test('stimulus and mask phases take raf else-path before completing', () => { + let t = 0; + nowSpy.mockRestore(); + nowSpy = jest.spyOn(performance, 'now').mockImplementation(() => { + t += 50; + return t; + }); + gameMock.getCurrentSoaMs.mockReturnValueOnce(300); + + plugin.start(); + jest.runAllTimers(); + + expect(document.querySelector('#fov-mask').hidden).toBe(false); + expect(document.querySelector('#fov-response').hidden).toBe(false); + }); + + test('stimulus raf loop iterates when elapsed is below soa (covers loop continuation)', () => { + // Use a slow-incrementing clock so elapsed < targetSoa on the first raf tick, + // forcing the stimulus raf loop to iterate at least once (line 368). + let t = 0; + nowSpy.mockRestore(); + nowSpy = jest.spyOn(performance, 'now').mockImplementation(() => { + t += 10; + return t; + }); + // targetSoa defaults to 84.2; with t+=10 the first tick gives elapsed<84.2 + // so the loop continuation branch is taken before the phase completes. + + plugin.start(); + jest.runAllTimers(); + + expect(document.querySelector('#fov-mask').hidden).toBe(false); + expect(document.querySelector('#fov-response').hidden).toBe(false); + }); + + test('stop during mask phase cancels pending mask raf (lines 175-176)', () => { + // Complete only the stimulus phase so a mask RAF is scheduled but not yet run. + plugin.start(); + jest.runOnlyPendingTimers(); // fires stimulus RAF → schedules mask RAF + // _maskRafId is now set; stopping here exercises the mask-raf cancellation path. + plugin.stop(); + + expect(document.querySelector('#fov-end-panel').hidden).toBe(false); + }); + + test('nowMs falls back to Date.now when performance.now is not a function (line 119)', () => { + nowSpy.mockRestore(); + const origNow = performance.now; + // Make performance.now not a function so nowMs uses Date.now() instead. + let dateT = 0; + const dateSpy = jest.spyOn(Date, 'now').mockImplementation(() => { + dateT += 200; + return dateT; + }); + // @ts-ignore + performance.now = null; + + plugin.start(); // runStimulusPhase → nowMs() → Date.now() + + expect(dateSpy).toHaveBeenCalled(); + + performance.now = origNow; + dateSpy.mockRestore(); + // Re-create a spy so afterEach's nowSpy.mockRestore() does not throw. + nowSpy = jest.spyOn(performance, 'now').mockReturnValue(10200); + // Clean up pending timers without running them to avoid infinite loops. + jest.clearAllTimers(); + }); + + test('start exits before trial creation when game is not running', () => { + gameMock.isRunning.mockReturnValue(false); + gameMock.createTrialLayout.mockClear(); + + plugin.start(); + + expect(gameMock.createTrialLayout).not.toHaveBeenCalled(); + }); }); diff --git a/app/games/high-speed-memory/tests/index.test.js b/app/games/high-speed-memory/tests/index.test.js index 85a0bff..ee96d6d 100644 --- a/app/games/high-speed-memory/tests/index.test.js +++ b/app/games/high-speed-memory/tests/index.test.js @@ -361,6 +361,37 @@ describe('playWrongSound', () => { globalThis.AudioContext = OriginalAudioContext; }); + + test('osc.onended swallows ctx.close() rejection', () => { + const mockOsc = { + connect: jest.fn(), + type: '', + frequency: { setValueAtTime: jest.fn() }, + gain: { setValueAtTime: jest.fn(), exponentialRampToValueAtTime: jest.fn() }, + start: jest.fn(), + stop: jest.fn(), + onended: null, + }; + const mockGainNode = { + connect: jest.fn(), + gain: { setValueAtTime: jest.fn(), exponentialRampToValueAtTime: jest.fn() }, + }; + const mockCtx = { + createOscillator: jest.fn(() => mockOsc), + createGain: jest.fn(() => mockGainNode), + destination: {}, + currentTime: 0, + close: jest.fn().mockRejectedValue(new Error('close failed')), + }; + const OriginalAudioContext = globalThis.AudioContext; + globalThis.AudioContext = jest.fn(() => mockCtx); + + playWrongSound(); + // Trigger onended — ctx.close() rejects, but the .catch(() => {}) swallows it + expect(() => { if (mockOsc.onended) mockOsc.onended(); }).not.toThrow(); + + globalThis.AudioContext = OriginalAudioContext; + }); }); // ── announce ────────────────────────────────────────────────────────────────── @@ -497,6 +528,19 @@ describe('renderGrid', () => { expect(btn.classList.contains('hsm-card--matched')).toBe(false); jest.useRealTimers(); }); + + test('clicking a card button triggers handleCardClick (click event listener)', () => { + jest.useFakeTimers(); + const container = buildContainer(); + plugin.init(container); + startRound(); + jest.runAllTimers(); // hide cards so flipLock is false + + const btn = container.querySelector('[data-id="0"]'); // Primary card + btn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(btn.classList.contains('hsm-card--matched')).toBe(true); + jest.useRealTimers(); + }); }); // ── hideCardEl / revealCardEl / markCardMatched / markCardWrong ─────────────── diff --git a/app/games/orbit-sprite-memory/tests/index.test.js b/app/games/orbit-sprite-memory/tests/index.test.js index 9d7828d..f89da78 100644 --- a/app/games/orbit-sprite-memory/tests/index.test.js +++ b/app/games/orbit-sprite-memory/tests/index.test.js @@ -65,6 +65,9 @@ const plugin = pluginModule.default; const gameMock = await import('../game.js'); const { + playPositiveSound, + playNegativeSound, + flashBoard, getSpriteBackgroundPosition, getCircleCoordinates, announce, @@ -77,6 +80,7 @@ const { startPlayback, startRound, submitSelection, + loadBestStatsFromProgress, returnToMainMenu, showEndPanel, } = pluginModule; @@ -99,8 +103,12 @@ function buildContainer() { 0 1 0 + 1 + 0 0 1 + 1 + 0 `; document.body.appendChild(el); return el; @@ -247,6 +255,142 @@ describe('exported helper utilities', () => { expect(document.querySelector('#osm-final-score').textContent).toBe('9'); expect(document.querySelector('#osm-final-level').textContent).toBe('4'); }); + + test('playPositiveSound closes context on onended and handles close rejection', async () => { + const onendedRefs = []; + const ctx = { + currentTime: 0, + destination: {}, + createOscillator: () => { + const osc = { + connect: jest.fn(), + type: '', + frequency: { setValueAtTime: jest.fn(), linearRampToValueAtTime: jest.fn() }, + start: jest.fn(), + stop: jest.fn(), + onended: null, + }; + onendedRefs.push(osc); + return osc; + }, + createGain: () => ({ + connect: jest.fn(), + gain: { + setValueAtTime: jest.fn(), + linearRampToValueAtTime: jest.fn(), + exponentialRampToValueAtTime: jest.fn(), + }, + }), + close: jest.fn(() => Promise.reject(new Error('close failed'))), + }; + const OriginalAudioContext = globalThis.AudioContext; + globalThis.AudioContext = jest.fn(() => ctx); + + playPositiveSound(); + onendedRefs[1].onended(); + await Promise.resolve(); + + expect(ctx.close).toHaveBeenCalled(); + globalThis.AudioContext = OriginalAudioContext; + }); + + test('playNegativeSound closes context on onended and handles close rejection', async () => { + let onended; + const ctx = { + currentTime: 0, + destination: {}, + createOscillator: () => ({ + connect: jest.fn(), + type: '', + frequency: { setValueAtTime: jest.fn(), linearRampToValueAtTime: jest.fn() }, + start: jest.fn(), + stop: jest.fn(), + set onended(fn) { + onended = fn; + }, + }), + createGain: () => ({ + connect: jest.fn(), + gain: { + setValueAtTime: jest.fn(), + linearRampToValueAtTime: jest.fn(), + exponentialRampToValueAtTime: jest.fn(), + }, + }), + close: jest.fn(() => Promise.reject(new Error('close failed'))), + }; + const OriginalAudioContext = globalThis.AudioContext; + globalThis.AudioContext = jest.fn(() => ctx); + + playNegativeSound(); + onended(); + await Promise.resolve(); + + expect(ctx.close).toHaveBeenCalled(); + globalThis.AudioContext = OriginalAudioContext; + }); + + test('flashBoard timeout callback removes flash classes', () => { + const board = document.querySelector('#osm-board'); + flashBoard('success'); + expect(board.classList.contains('osm-board--success')).toBe(true); + + jest.runOnlyPendingTimers(); + expect(board.classList.contains('osm-board--success')).toBe(false); + }); + + test('submitSelection runs reveal and nested next-round callbacks', () => { + plugin.start(); + jest.advanceTimersByTime(400); + + gameMock.createRound.mockClear(); + const buttons = document.querySelectorAll('.osm-choice-btn'); + buttons[0].click(); + buttons[2].click(); + buttons[4].click(); + + jest.runOnlyPendingTimers(); + jest.runOnlyPendingTimers(); + + expect(gameMock.createRound).toHaveBeenCalled(); + }); + + test('loadBestStatsFromProgress reads saved best stats when API is available', async () => { + const oldApi = globalThis.window.api; + globalThis.window.api = { + invoke: jest.fn().mockResolvedValue({ + games: { + 'orbit-sprite-memory': { + highScore: 12, + highestLevel: 4, + }, + }, + }), + }; + + await loadBestStatsFromProgress(); + + expect(document.querySelector('#osm-best-score').textContent).toBe('12'); + expect(document.querySelector('#osm-best-level').textContent).toBe('5'); + expect(document.querySelector('#osm-final-best-score').textContent).toBe('12'); + expect(document.querySelector('#osm-final-best-level').textContent).toBe('5'); + + globalThis.window.api = oldApi; + }); + + test('loadBestStatsFromProgress handles API rejection gracefully (line 268)', async () => { + const oldApi = globalThis.window.api; + globalThis.window.api = { + invoke: jest.fn().mockRejectedValue(new Error('network error')), + }; + + await loadBestStatsFromProgress(); + + // catch block calls updateBestStats(undefined) → defaults to 0 + expect(document.querySelector('#osm-best-score').textContent).toBe('0'); + + globalThis.window.api = oldApi; + }); }); describe('plugin contract and lifecycle', () => { @@ -281,6 +425,17 @@ describe('plugin contract and lifecycle', () => { expect(container.querySelector('#osm-instructions').hidden).toBe(true); }); + test('start button click is wired to start gameplay', () => { + const container = buildContainer(); + plugin.init(container); + gameMock.startGame.mockClear(); + + container.querySelector('#osm-start-btn').click(); + + expect(gameMock.startGame).toHaveBeenCalled(); + expect(container.querySelector('#osm-game-area').hidden).toBe(false); + }); + test('stop returns result and shows end panel', () => { const container = buildContainer(); plugin.init(container); @@ -319,6 +474,41 @@ describe('plugin contract and lifecycle', () => { globalThis.window.api = oldApi; }); + test('stop merges existing orbit progress and keeps higher historical bests', async () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + + const mockApi = { + invoke: jest.fn() + .mockResolvedValueOnce({ + playerId: 'default', + games: { + 'orbit-sprite-memory': { + highScore: 10, + sessionsPlayed: 2, + highestLevel: 3, + }, + }, + }) + .mockResolvedValueOnce(undefined), + }; + const oldApi = globalThis.window.api; + globalThis.window.api = mockApi; + + plugin.stop(); + await Promise.resolve(); + await Promise.resolve(); + + const saveCall = mockApi.invoke.mock.calls.find((call) => call[0] === 'progress:save'); + const saved = saveCall[1].data.games['orbit-sprite-memory']; + expect(saved.highScore).toBe(10); + expect(saved.highestLevel).toBe(3); + expect(saved.sessionsPlayed).toBe(3); + + globalThis.window.api = oldApi; + }); + test('reset returns to pre-game state', () => { const container = buildContainer(); plugin.init(container); @@ -362,4 +552,29 @@ describe('plugin contract and lifecycle', () => { container.querySelector('#osm-stop-btn').click(); expect(container.querySelector('#osm-end-panel').hidden).toBe(false); }); + + test('stop handles progress:load rejection in inner try-catch (line 579)', async () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + + // progress:load rejects → inner catch sets existing to empty defaults + // progress:save also rejects → outer catch swallows it + const mockApi = { + invoke: jest.fn().mockRejectedValue(new Error('IPC error')), + }; + const oldApi = globalThis.window.api; + globalThis.window.api = mockApi; + + plugin.stop(); + // Flush nested promise chains: outer IIFE → inner progress:load → inner catch + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // End panel should still appear despite progress errors + expect(container.querySelector('#osm-end-panel').hidden).toBe(false); + + globalThis.window.api = oldApi; + }); }); diff --git a/app/games/registry.integration.test.js b/app/games/registry.integration.test.js new file mode 100644 index 0000000..2c77009 --- /dev/null +++ b/app/games/registry.integration.test.js @@ -0,0 +1,72 @@ +/** + * registry.integration.test.js — Integration tests for the game plugin registry. + * + * Unlike registry.test.js (which mocks fs/promises), these tests read the REAL + * games directory on disk. They act as a "game discovery smoke test": if a game's + * manifest.json is missing, malformed, or its interface.html doesn't exist, these + * tests will fail — catching the class of "application fails to find and load games" + * regressions that unit tests (with mocked file I/O) cannot detect. + * + * @file Integration tests for game plugin registry (real filesystem). + */ +/** @jest-environment node */ +import { readFile } from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// Resolve the real games directory relative to this file. +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REAL_GAMES_PATH = __dirname; + +// Import the registry WITHOUT mocking fs so it reads the actual filesystem. +const { scanGamesDirectory } = await import('./registry.js'); + +describe('registry integration — real filesystem', () => { + /** @type {object[]} */ + let manifests; + + beforeAll(async () => { + manifests = await scanGamesDirectory(REAL_GAMES_PATH); + }); + + test('scanGamesDirectory finds at least one game', () => { + expect(manifests.length).toBeGreaterThan(0); + }); + + test('every manifest has the required fields: id, name, description, entryPoint', () => { + manifests.forEach((m) => { + expect(typeof m.id).toBe('string'); + expect(m.id.length).toBeGreaterThan(0); + expect(typeof m.name).toBe('string'); + expect(m.name.length).toBeGreaterThan(0); + expect(typeof m.description).toBe('string'); + expect(m.description.length).toBeGreaterThan(0); + expect(typeof m.entryPoint).toBe('string'); + expect(m.entryPoint.length).toBeGreaterThan(0); + }); + }); + + test('every game has an interface.html that can be read as a string', async () => { + for (const manifest of manifests) { + const htmlPath = path.join(REAL_GAMES_PATH, manifest.id, 'interface.html'); + const html = await readFile(htmlPath, 'utf8'); + expect(typeof html).toBe('string'); + expect(html.length).toBeGreaterThan(0); + } + }); + + test('every game entry-point file exists and can be read', async () => { + for (const manifest of manifests) { + const entryPath = path.join(REAL_GAMES_PATH, manifest.id, manifest.entryPoint); + const src = await readFile(entryPath, 'utf8'); + expect(typeof src).toBe('string'); + expect(src.length).toBeGreaterThan(0); + } + }); + + test('no game IDs are duplicated', () => { + const ids = manifests.map((m) => m.id); + const unique = new Set(ids); + expect(unique.size).toBe(ids.length); + }); +}); diff --git a/app/games/registry.test.js b/app/games/registry.test.js index 71e135b..2591a34 100644 --- a/app/games/registry.test.js +++ b/app/games/registry.test.js @@ -173,4 +173,34 @@ describe('loadGame', () => { 'nonexistent-game', ); }); + + test('uses the default import function when importFn is not provided', async () => { + mockReaddir.mockResolvedValue([dirent('test-game')]); + mockReadFile.mockResolvedValue(JSON.stringify(validManifest)); + + // No importFn passed — the default (p) => import(p) is used. + // Importing a non-existent mock path will reject; that is expected. + await expect(loadGame(GAMES_PATH, 'test-game')).rejects.toThrow(); + }); + + test('finds the correct game when multiple games are registered', async () => { + const secondManifest = { + id: 'second-game', + name: 'Second Game', + description: 'Another brain training game.', + entryPoint: 'index.js', + }; + mockReaddir.mockResolvedValue([dirent('test-game'), dirent('second-game')]); + mockReadFile + .mockResolvedValueOnce(JSON.stringify(validManifest)) + .mockResolvedValueOnce(JSON.stringify(secondManifest)); + const mockImportFn = jest.fn().mockResolvedValue({ default: mockPlugin }); + + const result = await loadGame(GAMES_PATH, 'second-game', mockImportFn); + + expect(result.manifest).toEqual(secondManifest); + expect(mockImportFn).toHaveBeenCalledWith( + path.join(GAMES_PATH, 'second-game', 'index.js'), + ); + }); }); diff --git a/app/interface.js b/app/interface.js index 7c05d58..f6a4d76 100644 --- a/app/interface.js +++ b/app/interface.js @@ -49,6 +49,23 @@ async function loadAndInitGame(gameId, gameContainer, announcer) { mod.default.init(gameContainer); } +/** + * Show an in-container error and restore the main-menu game selector. + * Called by game:select listeners when loadAndInitGame rejects. + * + * @param {string} gameId - ID of the game that failed to load. + * @param {HTMLElement} gameContainer - The main game container element. + * @param {HTMLElement} announcer - Aria-live announcer element. + * @param {Error} [err] - The error that caused the failure. + */ +function handleGameLoadError(gameId, gameContainer, announcer, err) { + // eslint-disable-next-line no-console + console.error(`Failed to load game "${gameId}".`, err); + announcer.textContent = 'Failed to load game. Returning to menu.'; + // Return to the game-selection screen so the player is not left on a blank page. + window.dispatchEvent(new Event('bsx:return-to-main-menu')); +} + /** * DOMContentLoaded event handler. Sets up the game selection UI and plugin loader. * @returns {Promise} @@ -76,7 +93,14 @@ document.addEventListener('DOMContentLoaded', async () => { } // Fetch the list of available games and render game cards. - const manifests = await window.api.invoke('games:list'); + let manifests = []; + try { + manifests = await window.api.invoke('games:list'); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed to load game list:', err); + gameSelector.textContent = 'Unable to load games. Please restart the app.'; + } manifests.forEach((manifest) => { let gameProgress = undefined; if (progress && progress.games && progress.games[manifest.id]) { @@ -92,7 +116,11 @@ document.addEventListener('DOMContentLoaded', async () => { gameSelector.addEventListener('game:select', async (event) => { const { gameId } = event.detail; gameSelector.remove(); - await loadAndInitGame(gameId, gameContainer, announcer); + try { + await loadAndInitGame(gameId, gameContainer, announcer); + } catch (err) { + handleGameLoadError(gameId, gameContainer, announcer, err); + } }); // Listen for custom event to return to main menu from any game window.addEventListener('bsx:return-to-main-menu', () => { @@ -122,7 +150,11 @@ document.addEventListener('DOMContentLoaded', async () => { selector.addEventListener('game:select', async (event) => { const { gameId } = event.detail; selector.remove(); - await loadAndInitGame(gameId, gameContainer, announcer); + try { + await loadAndInitGame(gameId, gameContainer, announcer); + } catch (err) { + handleGameLoadError(gameId, gameContainer, announcer, err); + } }); } announcer.textContent = 'Main menu loaded. Select a game.'; diff --git a/app/interface.test.js b/app/interface.test.js new file mode 100644 index 0000000..185e8ec --- /dev/null +++ b/app/interface.test.js @@ -0,0 +1,352 @@ +/** @jest-environment jsdom */ +import { jest } from '@jest/globals'; + +// Mock the game module dynamically imported by loadAndInitGame. +// Must be registered before interface.js is imported. +const mockGameInit = jest.fn(); + +// Capture the DOMContentLoaded callback before importing interface.js. +let domReadyCallback; + +// jest.unstable_mockModule MUST be called at module top level (with top-level await) so that +// the mock is registered before Jest's coverage instrumentation pre-loads interface.js. +await jest.unstable_mockModule('./games/fast-piggie/index.js', () => ({ + default: { init: mockGameInit }, +})); + +// Spy on document.addEventListener to capture the DOMContentLoaded callback before +// importing interface.js, so tests can invoke it directly. +const origDocAddEventListener = document.addEventListener.bind(document); +jest.spyOn(document, 'addEventListener').mockImplementation((event, cb, opts) => { + if (event === 'DOMContentLoaded') domReadyCallback = cb; + return origDocAddEventListener(event, cb, opts); +}); + +await import('./interface.js'); + +// Restore so that tests can use document.addEventListener normally. +document.addEventListener.mockRestore(); + +// ─── Test fixtures ──────────────────────────────────────────────────────────── + +const MANIFESTS = [{ id: 'fast-piggie', name: 'Test Game', description: 'A test game.' }]; + +const GAME_LOAD_RESULT = { + html: '
Game UI
', + manifest: { name: 'Test Game', entryPoint: 'index.js' }, +}; + +function setupApi({ progressData = {}, manifests = MANIFESTS, gameLoad = GAME_LOAD_RESULT } = {}) { + const invoke = jest.fn().mockImplementation((channel) => { + if (channel === 'progress:load') return Promise.resolve(progressData); + if (channel === 'games:list') return Promise.resolve(manifests); + if (channel === 'games:load') return Promise.resolve(gameLoad); + return Promise.resolve(null); + }); + global.window.api = { invoke, on: jest.fn() }; + return invoke; +} + +async function flush() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function dispatchGameSelect(gameId = 'fast-piggie') { + document.getElementById('game-selector').dispatchEvent( + new CustomEvent('game:select', { bubbles: true, detail: { gameId } }), + ); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('interface.js', () => { + beforeEach(() => { + document.body.innerHTML = + '
'; + document.head.innerHTML = ''; + mockGameInit.mockClear(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + document.head.innerHTML = ''; + }); + + // ── DOMContentLoaded initialisation ───────────────────────────────────────── + + describe('DOMContentLoaded initialisation', () => { + it('requests progress:load and games:list', async () => { + const invoke = setupApi(); + await domReadyCallback(); + expect(invoke).toHaveBeenCalledWith('progress:load', { playerId: 'default' }); + expect(invoke).toHaveBeenCalledWith('games:list'); + }); + + it('appends an aria-live polite announcer to the body', async () => { + setupApi(); + await domReadyCallback(); + const announcer = document.querySelector('[aria-live="polite"]'); + expect(announcer).not.toBeNull(); + expect(announcer.getAttribute('aria-atomic')).toBe('true'); + expect(announcer.classList.contains('sr-only')).toBe(true); + }); + + it('renders a game card for each manifest returned', async () => { + setupApi(); + await domReadyCallback(); + expect(document.querySelectorAll('#game-selector article').length).toBe(MANIFESTS.length); + }); + + it('passes per-game progress data to game cards when available', async () => { + setupApi({ progressData: { games: { 'fast-piggie': { highScore: 42 } } } }); + await domReadyCallback(); + const scoreElem = document.querySelector('.game-high-score'); + expect(scoreElem).not.toBeNull(); + expect(scoreElem.textContent).toContain('42'); + }); + + it('falls back to empty progress and still renders cards when progress:load rejects', + async () => { + const invoke = jest.fn().mockImplementation((channel) => { + if (channel === 'progress:load') return Promise.reject(new Error('disk error')); + if (channel === 'games:list') return Promise.resolve(MANIFESTS); + return Promise.resolve(null); + }); + global.window.api = { invoke, on: jest.fn() }; + await domReadyCallback(); + expect(document.querySelectorAll('#game-selector article').length).toBe(MANIFESTS.length); + }); + + it('shows an error message when games:list rejects', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { }); + const invoke = jest.fn().mockImplementation((channel) => { + if (channel === 'progress:load') return Promise.resolve({}); + if (channel === 'games:list') return Promise.reject(new Error('IPC error')); + return Promise.resolve(null); + }); + global.window.api = { invoke, on: jest.fn() }; + await domReadyCallback(); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load game list:', expect.any(Error)); + expect(document.getElementById('game-selector').textContent) + .toContain('Unable to load games'); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('game:select handler', () => { + it('removes the game selector from the DOM', async () => { + setupApi(); + await domReadyCallback(); + dispatchGameSelect(); + await flush(); + expect(document.getElementById('game-selector')).toBeNull(); + }); + + it('calls games:load and injects HTML into the game container', async () => { + const invoke = setupApi(); + await domReadyCallback(); + dispatchGameSelect(); + await flush(); + expect(invoke).toHaveBeenCalledWith('games:load', 'fast-piggie'); + expect(document.getElementById('game-container').innerHTML).toContain('game-ui'); + }); + + it('injects a game stylesheet link into the document head', async () => { + setupApi(); + await domReadyCallback(); + dispatchGameSelect(); + await flush(); + const link = document.getElementById('active-game-stylesheet'); + expect(link).not.toBeNull(); + expect(link.href).toContain('fast-piggie'); + }); + + it('replaces an existing game stylesheet rather than duplicating it', async () => { + setupApi(); + await domReadyCallback(); + // Pre-insert a stylesheet from a previous game. + const stale = document.createElement('link'); + stale.id = 'active-game-stylesheet'; + stale.href = './games/old-game/style.css'; + document.head.appendChild(stale); + dispatchGameSelect(); + await flush(); + const links = document.head.querySelectorAll('#active-game-stylesheet'); + expect(links.length).toBe(1); + expect(links[0].href).toContain('fast-piggie'); + }); + + it('calls the game module init function', async () => { + setupApi(); + await domReadyCallback(); + dispatchGameSelect(); + await flush(); + expect(mockGameInit).toHaveBeenCalledWith(document.getElementById('game-container')); + }); + + it('sets the announcer text after loading the game', async () => { + setupApi(); + await domReadyCallback(); + dispatchGameSelect(); + await flush(); + const announcer = document.querySelector('[aria-live="polite"]'); + expect(announcer.textContent).toContain('Test Game'); + }); + }); + + // ── bsx:return-to-main-menu handler (exercises removeGameStylesheet + restore) ─ + + describe('bsx:return-to-main-menu handler', () => { + it('clears the game container and removes the active stylesheet', async () => { + setupApi(); + await domReadyCallback(); + dispatchGameSelect(); + await flush(); + // Confirm game is loaded. + expect(document.getElementById('active-game-stylesheet')).not.toBeNull(); + + window.dispatchEvent(new Event('bsx:return-to-main-menu')); + expect(document.getElementById('game-container').querySelector('#game-ui')).toBeNull(); + expect(document.getElementById('game-container').querySelector('#game-selector')) + .not.toBeNull(); + expect(document.getElementById('active-game-stylesheet')).toBeNull(); + }); + + it('handles removeGameStylesheet gracefully when no stylesheet exists', async () => { + setupApi(); + await domReadyCallback(); + // Dispatch return-to-menu without ever loading a game (no stylesheet present). + expect(() => window.dispatchEvent(new Event('bsx:return-to-main-menu'))).not.toThrow(); + }); + + it('recreates the game-selector section inside the container', async () => { + setupApi(); + await domReadyCallback(); + dispatchGameSelect(); + await flush(); + window.dispatchEvent(new Event('bsx:return-to-main-menu')); + await flush(); + expect(document.getElementById('game-selector')).not.toBeNull(); + }); + + it('reloads and renders game cards after returning to the main menu', async () => { + setupApi(); + await domReadyCallback(); + dispatchGameSelect(); + await flush(); + window.dispatchEvent(new Event('bsx:return-to-main-menu')); + await flush(); + expect(document.querySelectorAll('#game-selector article').length).toBe(MANIFESTS.length); + }); + + it('sets the announcer text to the main menu message', async () => { + setupApi(); + await domReadyCallback(); + dispatchGameSelect(); + await flush(); + window.dispatchEvent(new Event('bsx:return-to-main-menu')); + const announcer = document.querySelector('[aria-live="polite"]'); + expect(announcer.textContent).toBe('Main menu loaded. Select a game.'); + }); + + it('does not recreate the selector if it already exists in the document', async () => { + setupApi(); + await domReadyCallback(); + // game-selector is still in its original sibling position (no game:select fired). + expect(document.getElementById('game-selector')).not.toBeNull(); + window.dispatchEvent(new Event('bsx:return-to-main-menu')); + // Should not create a duplicate. + expect(document.querySelectorAll('#game-selector').length).toBe(1); + }); + + it('reloads progress-aware game cards on return', async () => { + const invoke = jest.fn().mockImplementation((channel) => { + if (channel === 'progress:load') + return Promise.resolve({ games: { 'fast-piggie': { highScore: 77 } } }); + if (channel === 'games:list') return Promise.resolve(MANIFESTS); + if (channel === 'games:load') return Promise.resolve(GAME_LOAD_RESULT); + return Promise.resolve(null); + }); + global.window.api = { invoke, on: jest.fn() }; + await domReadyCallback(); + dispatchGameSelect(); + await flush(); + window.dispatchEvent(new Event('bsx:return-to-main-menu')); + await flush(); + const scoreElem = document.querySelector('#game-selector .game-high-score'); + expect(scoreElem).not.toBeNull(); + expect(scoreElem.textContent).toContain('77'); + }); + + it('handles game:select on the recreated selector after returning to menu', async () => { + const invoke = setupApi(); + await domReadyCallback(); + dispatchGameSelect(); + await flush(); + + window.dispatchEvent(new Event('bsx:return-to-main-menu')); + await flush(); + + document.getElementById('game-selector').dispatchEvent( + new CustomEvent('game:select', { bubbles: true, detail: { gameId: 'fast-piggie' } }), + ); + await flush(); + + expect(invoke).toHaveBeenCalledWith('games:load', 'fast-piggie'); + expect(mockGameInit).toHaveBeenCalledWith(document.getElementById('game-container')); + }); + }); + + // ── game:select error handling ──────────────────────────────────────────── + + describe('game:select error handling', () => { + it('returns to main menu when games:load rejects on initial selector', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn()); + const invoke = jest.fn().mockImplementation((channel) => { + if (channel === 'progress:load') return Promise.resolve({}); + if (channel === 'games:list') return Promise.resolve(MANIFESTS); + if (channel === 'games:load') return Promise.reject(new Error('load error')); + return Promise.resolve(null); + }); + global.window.api = { invoke, on: jest.fn() }; + await domReadyCallback(); + + dispatchGameSelect(); + await flush(); + + // The error handler fires bsx:return-to-main-menu, which restores the selector. + expect(document.getElementById('game-selector')).not.toBeNull(); + consoleErrorSpy.mockRestore(); + }); + + it('returns to main menu when games:load rejects on recreated selector', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn()); + const invoke = setupApi(); + await domReadyCallback(); + // Load a game successfully first to get to a game view. + dispatchGameSelect(); + await flush(); + + // Return to main menu (recreates selector). + window.dispatchEvent(new Event('bsx:return-to-main-menu')); + await flush(); + + // Now make games:load fail. + invoke.mockImplementation((channel) => { + if (channel === 'progress:load') return Promise.resolve({}); + if (channel === 'games:list') return Promise.resolve(MANIFESTS); + if (channel === 'games:load') return Promise.reject(new Error('load error')); + return Promise.resolve(null); + }); + + document.getElementById('game-selector').dispatchEvent( + new CustomEvent('game:select', { bubbles: true, detail: { gameId: 'fast-piggie' } }), + ); + await flush(); + + // Selector should be restored after error. + expect(document.getElementById('game-selector')).not.toBeNull(); + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/app/preload.test.js b/app/preload.test.js new file mode 100644 index 0000000..6f0e79a --- /dev/null +++ b/app/preload.test.js @@ -0,0 +1,98 @@ +/** @jest-environment node */ +import { jest } from '@jest/globals'; + +const mockIpcRenderer = { + send: jest.fn(), + on: jest.fn(), + invoke: jest.fn().mockResolvedValue('mocked-result'), +}; + +const mockContextBridge = { + exposeInMainWorld: jest.fn(), +}; + +global.require = jest.fn((moduleName) => { + if (moduleName === 'electron') { + return { + contextBridge: mockContextBridge, + ipcRenderer: mockIpcRenderer, + }; + } + throw new Error(`Unexpected module requested: ${moduleName}`); +}); + +await import('./preload.js'); + +// Capture the API object registered via contextBridge once at load time. +const api = mockContextBridge.exposeInMainWorld.mock.calls[0][1]; + +describe('preload.js', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIpcRenderer.invoke.mockResolvedValue('mocked-result'); + }); + + afterAll(() => { + delete global.require; + }); + + it('exposes "api" with send, receive, and invoke via contextBridge', () => { + expect(api).toEqual( + expect.objectContaining({ + send: expect.any(Function), + receive: expect.any(Function), + invoke: expect.any(Function), + }), + ); + }); + + describe('send', () => { + it('calls ipcRenderer.send for an allowed channel', () => { + api.send('sample_message', { payload: true }); + expect(mockIpcRenderer.send).toHaveBeenCalledWith('sample_message', { payload: true }); + }); + + it('does not call ipcRenderer.send for a blocked channel', () => { + api.send('blocked_channel', { payload: true }); + expect(mockIpcRenderer.send).not.toHaveBeenCalled(); + }); + }); + + describe('receive', () => { + it('calls ipcRenderer.on for an allowed channel', () => { + api.receive('sample_response', jest.fn()); + expect(mockIpcRenderer.on).toHaveBeenCalledWith('sample_response', expect.any(Function)); + }); + + it('does not call ipcRenderer.on for a blocked channel', () => { + api.receive('blocked_channel', jest.fn()); + expect(mockIpcRenderer.on).not.toHaveBeenCalled(); + }); + + it('strips the IPC event argument before invoking the callback', () => { + const callback = jest.fn(); + api.receive('sample_response', callback); + const wrappedHandler = mockIpcRenderer.on.mock.calls[0][1]; + wrappedHandler({ preventDefault: jest.fn() }, 'arg1', 'arg2'); + expect(callback).toHaveBeenCalledWith('arg1', 'arg2'); + expect(callback).not.toHaveBeenCalledWith( + expect.objectContaining({ preventDefault: expect.any(Function) }), 'arg1', 'arg2'); + }); + }); + + describe('invoke', () => { + it.each(['games:list', 'games:load', 'progress:save', 'progress:load', 'progress:reset'])( + 'calls ipcRenderer.invoke for allowed channel "%s"', + async (channel) => { + await api.invoke(channel, { data: 1 }); + expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(channel, { data: 1 }); + }, + ); + + it('rejects with a descriptive error for a blocked channel', async () => { + await expect(api.invoke('blocked_channel', {})).rejects.toThrow( + 'Blocked IPC channel: blocked_channel', + ); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index f1041bd..1a30aa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "brainspeedexercises", - "version": "1.0.0", + "name": "brain-speed-exercises", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "brainspeedexercises", - "version": "1.0.0", + "name": "brain-speed-exercises", + "version": "0.1.0", "license": "MIT", "dependencies": { "electron": "^38"