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
13 changes: 0 additions & 13 deletions app/components/gameCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,19 +73,6 @@ export function createGameCard(manifest, progress) {
scoreElem.setAttribute('aria-label', `Stats for ${manifest.name}: ${scoreElem.textContent}`);
}

if (manifest.id === 'field-of-view' && progress) {
scoreElem = document.createElement('p');
scoreElem.className = 'game-high-score';

if (typeof progress.bestThresholdMs === 'number') {
scoreElem.textContent = `All-time Best Threshold: ${progress.bestThresholdMs}ms`;
} else {
scoreElem.textContent = 'All-time Best Threshold: No data yet';
}

scoreElem.setAttribute('aria-label', `Stats for ${manifest.name}: ${scoreElem.textContent}`);
}

const button = document.createElement('button');
button.type = 'button';
button.textContent = `Play ${manifest.name}`;
Expand Down
3 changes: 2 additions & 1 deletion app/games/fast-piggie/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ function _runRound() {

_roundTimer = setTimeout(() => {
clearImages(_ctx, width, height, wedgeCount);
_currentRound._imagesHiddenAt = Date.now();
_clickEnabled = true;
_hoveredWedge = -1;
_selectedWedge = -1;
Expand Down Expand Up @@ -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;
}

Expand Down
25 changes: 25 additions & 0 deletions app/games/fast-piggie/tests/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,31 @@ describe('init() — loadImages .catch() fallback', () => {
});
});

// ===========================================================================
// _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
// ===========================================================================
Expand Down
53 changes: 53 additions & 0 deletions app/games/field-of-view/tests/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,59 @@ describe('field-of-view index', () => {
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();
Expand Down
39 changes: 39 additions & 0 deletions app/games/orbit-sprite-memory/tests/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,20 @@ describe('exported helper utilities', () => {

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', () => {
Expand Down Expand Up @@ -538,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;
});
});
72 changes: 72 additions & 0 deletions app/games/registry.integration.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
29 changes: 27 additions & 2 deletions app/interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>}
Expand Down Expand Up @@ -99,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', () => {
Expand Down Expand Up @@ -129,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.';
Expand Down
53 changes: 53 additions & 0 deletions app/interface.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -296,4 +296,57 @@ describe('interface.js', () => {
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();
});
});
});
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.