From 0c6da63c771b1416abc804950d9a8b0bc4ff1e6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:59:01 +0000 Subject: [PATCH 1/3] Initial plan From a57ee582025da9dcd37377dcd449a623b0edebdc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:13:59 +0000 Subject: [PATCH 2/3] Address review feedback: fix imports, manifest, split index.js, aria labels, audio context, game logic validation Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com> Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/550ed7f7-9f1e-42b0-91bb-31cec38618ae --- app/components/gameCard.test.js | 83 ++--- app/games/field-of-view/audio.js | 83 +++++ app/games/field-of-view/game.js | 18 +- app/games/field-of-view/index.js | 337 ++++-------------- app/games/field-of-view/interface.html | 4 +- app/games/field-of-view/manifest.json | 2 +- app/games/field-of-view/progress.js | 62 ++++ app/games/field-of-view/render.js | 210 +++++++++++ app/games/field-of-view/tests/audio.test.js | 141 ++++++++ app/games/field-of-view/tests/index.test.js | 42 +-- .../field-of-view/tests/progress.test.js | 117 ++++++ app/games/field-of-view/tests/render.test.js | 248 +++++++++++++ app/index.html | 2 +- 13 files changed, 1007 insertions(+), 342 deletions(-) create mode 100644 app/games/field-of-view/audio.js create mode 100644 app/games/field-of-view/progress.js create mode 100644 app/games/field-of-view/render.js create mode 100644 app/games/field-of-view/tests/audio.test.js create mode 100644 app/games/field-of-view/tests/progress.test.js create mode 100644 app/games/field-of-view/tests/render.test.js diff --git a/app/components/gameCard.test.js b/app/components/gameCard.test.js index e42f5fc..5812cd2 100644 --- a/app/components/gameCard.test.js +++ b/app/components/gameCard.test.js @@ -1,44 +1,3 @@ -it('displays high score for Fast Piggie when provided', () => { - const manifest = { - id: 'fast-piggie', - name: 'Fast Piggie', - description: 'Test desc', - thumbnail: '/images/test.png', - }; - const progress = { highScore: 42 }; - const card = createGameCard(manifest, progress); - const scoreElem = card.querySelector('.game-high-score'); - expect(scoreElem).not.toBeNull(); - expect(scoreElem.textContent).toContain('42'); -}); - -it('displays all-time best threshold for Field of View when provided', () => { - const manifest = { - id: 'field-of-view', - name: 'Field of View', - description: 'Test desc', - thumbnail: '/images/test.png', - }; - const progress = { bestThresholdMs: 84.2 }; - const card = createGameCard(manifest, progress); - const scoreElem = card.querySelector('.game-high-score'); - expect(scoreElem).not.toBeNull(); - expect(scoreElem.textContent).toContain('All-time Best Threshold: 84.2ms'); -}); - -it('shows no-data text for Field of View when no best threshold exists', () => { - const manifest = { - id: 'field-of-view', - name: 'Field of View', - description: 'Test desc', - thumbnail: '/images/test.png', - }; - const progress = {}; - const card = createGameCard(manifest, progress); - const scoreElem = card.querySelector('.game-high-score'); - expect(scoreElem).not.toBeNull(); - expect(scoreElem.textContent).toContain('No data yet'); -}); import { createGameCard } from './gameCard.js'; const validManifest = { @@ -115,4 +74,46 @@ describe('createGameCard', () => { const button = card.querySelector('button'); expect(button.getAttribute('aria-label')).toBeTruthy(); }); + + it('displays high score for Fast Piggie when provided', () => { + const manifest = { + id: 'fast-piggie', + name: 'Fast Piggie', + description: 'Test desc', + thumbnail: '/images/test.png', + }; + const progress = { highScore: 42 }; + const card = createGameCard(manifest, progress); + const scoreElem = card.querySelector('.game-high-score'); + expect(scoreElem).not.toBeNull(); + expect(scoreElem.textContent).toContain('42'); + }); + + it('displays all-time best threshold for Field of View when provided', () => { + const manifest = { + id: 'field-of-view', + name: 'Field of View', + description: 'Test desc', + thumbnail: '/images/test.png', + }; + const progress = { bestThresholdMs: 84.2 }; + const card = createGameCard(manifest, progress); + const scoreElem = card.querySelector('.game-high-score'); + expect(scoreElem).not.toBeNull(); + expect(scoreElem.textContent).toContain('All-time Best Threshold: 84.2ms'); + }); + + it('shows no-data text for Field of View when no best threshold exists', () => { + const manifest = { + id: 'field-of-view', + name: 'Field of View', + description: 'Test desc', + thumbnail: '/images/test.png', + }; + const progress = {}; + const card = createGameCard(manifest, progress); + const scoreElem = card.querySelector('.game-high-score'); + expect(scoreElem).not.toBeNull(); + expect(scoreElem.textContent).toContain('No data yet'); + }); }); diff --git a/app/games/field-of-view/audio.js b/app/games/field-of-view/audio.js new file mode 100644 index 0000000..1d37e62 --- /dev/null +++ b/app/games/field-of-view/audio.js @@ -0,0 +1,83 @@ +/** + * audio.js - Audio feedback for the Field of View game. + * + * Manages a shared AudioContext for the session and plays short tones on + * each trial outcome. Creating a single context and reusing it avoids + * per-trial instantiation overhead and browser auto-play limits. + * + * @file Field of View audio feedback helpers. + */ + +/** @type {AudioContext|null} Shared audio context reused across trials. */ +let _audioCtx = null; + +/** + * Return the shared AudioContext, creating it on first use. + * Returns null when the Web Audio API is unavailable. + * + * @returns {AudioContext|null} + */ +export function getAudioContext() { + if (_audioCtx && _audioCtx.state !== 'closed') { + return _audioCtx; + } + const AudioCtx = (typeof AudioContext !== 'undefined' && AudioContext) + || (typeof window !== 'undefined' && window.webkitAudioContext) + || null; + if (!AudioCtx) return null; + try { + _audioCtx = new AudioCtx(); + } catch { + return null; + } + return _audioCtx; +} + +/** + * Play a short positive/negative sound cue for trial feedback. + * + * Reuses the shared AudioContext created by {@link getAudioContext}. + * Call this after the first user-gesture to satisfy browser autoplay policy. + * + * @param {boolean} isSuccess + */ +export function playFeedbackSound(isSuccess) { + const ctx = getAudioContext(); + if (!ctx) return; + + try { + if (ctx.state === 'suspended') { + ctx.resume().catch(() => { }); + } + + const now = ctx.currentTime; + + const toneA = ctx.createOscillator(); + const gainA = ctx.createGain(); + toneA.connect(gainA); + gainA.connect(ctx.destination); + + toneA.type = 'sine'; + toneA.frequency.setValueAtTime(isSuccess ? 740 : 220, now); + gainA.gain.setValueAtTime(0.0001, now); + gainA.gain.exponentialRampToValueAtTime(0.16, now + 0.02); + gainA.gain.exponentialRampToValueAtTime(0.0001, now + 0.18); + toneA.start(now); + toneA.stop(now + 0.2); + + const toneB = ctx.createOscillator(); + const gainB = ctx.createGain(); + toneB.connect(gainB); + gainB.connect(ctx.destination); + + toneB.type = isSuccess ? 'triangle' : 'sawtooth'; + toneB.frequency.setValueAtTime(isSuccess ? 940 : 170, now + 0.12); + gainB.gain.setValueAtTime(0.0001, now + 0.12); + gainB.gain.exponentialRampToValueAtTime(0.12, now + 0.15); + gainB.gain.exponentialRampToValueAtTime(0.0001, now + 0.3); + toneB.start(now + 0.12); + toneB.stop(now + 0.32); + } catch { + // Ignore audio errors in unsupported environments. + } +} diff --git a/app/games/field-of-view/game.js b/app/games/field-of-view/game.js index 738233a..83a79d5 100644 --- a/app/games/field-of-view/game.js +++ b/app/games/field-of-view/game.js @@ -131,9 +131,21 @@ export function initGame(options = {}) { startTimeMs = null; thresholdHistory = []; - downAfterSuccesses = options.downAfterSuccesses === 3 ? 3 : DEFAULT_DOWN_AFTER_SUCCESSES; - stepUpMs = options.stepUpMs || DEFAULT_STEP_UP_MS; - stepDownMs = options.stepDownMs || DEFAULT_STEP_DOWN_MS; + // Configure staircase success threshold: use a validated integer, defaulting when invalid. + if (Number.isFinite(options.downAfterSuccesses)) { + downAfterSuccesses = Math.max(1, Math.round(options.downAfterSuccesses)); + } else { + downAfterSuccesses = DEFAULT_DOWN_AFTER_SUCCESSES; + } + + // Configure step sizes with numeric validation and clamping to keep pacing reasonable. + const rawStepUp = Number.isFinite(options.stepUpMs) ? options.stepUpMs : DEFAULT_STEP_UP_MS; + stepUpMs = clamp(rawStepUp, MIN_SOA_MS, MAX_SOA_MS); + + const rawStepDown = Number.isFinite(options.stepDownMs) + ? options.stepDownMs + : DEFAULT_STEP_DOWN_MS; + stepDownMs = clamp(rawStepDown, MIN_SOA_MS, MAX_SOA_MS); const desiredBuffer = Number(options.accuracyBufferSize || DEFAULT_ACCURACY_BUFFER_SIZE); accuracyBufferSize = clamp(Math.round(desiredBuffer), 3, 5); diff --git a/app/games/field-of-view/index.js b/app/games/field-of-view/index.js index d7ebffb..ec6eb62 100644 --- a/app/games/field-of-view/index.js +++ b/app/games/field-of-view/index.js @@ -1,15 +1,17 @@ /** * index.js - Field of View plugin entry point. * - * Handles DOM rendering, high-precision timing flow, and plugin lifecycle. + * Handles DOM wiring, high-precision timing flow, and plugin lifecycle. + * Rendering utilities are in render.js, audio feedback in audio.js, + * and progress persistence in progress.js. * * @file Field of View game plugin (UI/controller layer). */ import * as game from './game.js'; - -/** Game identifier used for progress persistence. */ -const GAME_ID = 'field-of-view'; +import * as render from './render.js'; +import { playFeedbackSound } from './audio.js'; +import { saveProgress } from './progress.js'; /** Mask display duration in ms. */ const MASK_DURATION_MS = 120; @@ -20,9 +22,6 @@ const INTER_TRIAL_DELAY_MS = 350; /** Flash overlay duration for correct/incorrect feedback. */ const FEEDBACK_FLASH_MS = 220; -/** Path to Field of View image assets from renderer root. */ -const IMAGES_BASE_PATH = 'games/field-of-view/images/'; - /** @type {HTMLElement|null} */ let _container = null; /** @type {HTMLElement|null} */ @@ -80,7 +79,6 @@ let _stimulusRafId = null; let _maskRafId = null; /** @type {ReturnType|null} */ let _nextTrialTimer = null; - /** @type {ReturnType|null} */ let _flashTimer = null; @@ -127,99 +125,42 @@ function nowMs() { * @param {string} message */ function announce(message) { - if (_feedbackEl) { - _feedbackEl.textContent = message; - } -} - -/** - * Convert a ratio [0..1] to display percent. - * - * @param {number} value - * @returns {string} - */ -function percent(value) { - return `${Math.round(value * 100)}%`; -} - -/** - * Normalize millisecond values to at most 2 decimals without trailing zeros. - * - * @param {number} value - * @returns {string} - */ -function formatMs(value) { - return String(Number(value).toFixed(2)).replace(/\.00$/, ''); + render.announce(_feedbackEl, message); } /** * Update game stats in the status bar. */ function updateStats() { - if (_soaEl) _soaEl.textContent = String(game.getCurrentSoaMs()); - if (_thresholdEl) _thresholdEl.textContent = String(game.getCurrentSoaMs()); - if (_accuracyEl) _accuracyEl.textContent = percent(game.getRecentAccuracy()); - if (_trialsEl) _trialsEl.textContent = String(game.getTrialsCompleted()); -} - -/** - * Build SVG point string for threshold history polyline. - * - * @param {Array<{ thresholdMs: number }>} history - * @returns {string} - */ -function buildTrendPolylinePoints(history) { - if (!history || history.length === 0) { - return ''; - } - - const width = 300; - const height = 120; - const pad = 10; - - const values = history.map((entry) => entry.thresholdMs); - const min = Math.min(...values); - const max = Math.max(...values); - const span = Math.max(max - min, 1); - - const denominator = Math.max(history.length - 1, 1); - - return history.map((entry, index) => { - const x = pad + ((width - pad * 2) * index) / denominator; - const normalized = (entry.thresholdMs - min) / span; - const y = height - pad - normalized * (height - pad * 2); - return `${x.toFixed(2)},${y.toFixed(2)}`; - }).join(' '); + render.updateStats( + { + soaEl: _soaEl, + thresholdEl: _thresholdEl, + accuracyEl: _accuracyEl, + trialsEl: _trialsEl, + }, + { + soaMs: game.getCurrentSoaMs(), + accuracy: game.getRecentAccuracy(), + trialsCompleted: game.getTrialsCompleted(), + }, + ); } /** * Render the threshold history chart and summary values. */ -function renderThresholdTrend() { - const history = game.getThresholdHistory(); - const latest = history.length > 0 - ? history[history.length - 1].thresholdMs - : game.getCurrentSoaMs(); - - if (_trendLatestEl) { - _trendLatestEl.textContent = formatMs(latest); - } - - if (_finalBestThresholdEl) { - const best = history.length > 0 - ? Math.min(...history.map((entry) => entry.thresholdMs)) - : game.getCurrentSoaMs(); - _finalBestThresholdEl.textContent = formatMs(best); - } - - if (!_trendLineEl) return; - - const points = buildTrendPolylinePoints(history); - _trendLineEl.setAttribute('points', points); - - if (_trendEmptyEl) { - _trendEmptyEl.hidden = points.length > 0; - } +function updateThresholdTrend() { + render.renderThresholdTrend( + { + trendLineEl: _trendLineEl, + trendEmptyEl: _trendEmptyEl, + trendLatestEl: _trendLatestEl, + finalBestThresholdEl: _finalBestThresholdEl, + }, + game.getThresholdHistory(), + game.getCurrentSoaMs(), + ); } /** @@ -244,68 +185,6 @@ function clearAsyncHandles() { } } -/** - * Set current stage visual mode for stimulus, mask-only, or response overlay. - * - * @param {'stimulus'|'mask'|'response'} mode - */ -function setStageMode(mode) { - if (!_stageEl) return; - _stageEl.classList.remove('fov-stage--response'); - if (mode === 'response') { - _stageEl.classList.add('fov-stage--response'); - } -} - -/** - * Play a short positive/negative sound cue for trial feedback. - * - * @param {boolean} isSuccess - */ -function playFeedbackSound(isSuccess) { - const AudioCtx = (typeof AudioContext !== 'undefined' && AudioContext) - || (typeof window !== 'undefined' && window.webkitAudioContext) - || null; - if (!AudioCtx) return; - - try { - const ctx = new AudioCtx(); - const now = ctx.currentTime; - - const toneA = ctx.createOscillator(); - const gainA = ctx.createGain(); - toneA.connect(gainA); - gainA.connect(ctx.destination); - - toneA.type = 'sine'; - toneA.frequency.setValueAtTime(isSuccess ? 740 : 220, now); - gainA.gain.setValueAtTime(0.0001, now); - gainA.gain.exponentialRampToValueAtTime(0.16, now + 0.02); - gainA.gain.exponentialRampToValueAtTime(0.0001, now + 0.18); - toneA.start(now); - toneA.stop(now + 0.2); - - const toneB = ctx.createOscillator(); - const gainB = ctx.createGain(); - toneB.connect(gainB); - gainB.connect(ctx.destination); - - toneB.type = isSuccess ? 'triangle' : 'sawtooth'; - toneB.frequency.setValueAtTime(isSuccess ? 940 : 170, now + 0.12); - gainB.gain.setValueAtTime(0.0001, now + 0.12); - gainB.gain.exponentialRampToValueAtTime(0.12, now + 0.15); - gainB.gain.exponentialRampToValueAtTime(0.0001, now + 0.3); - toneB.start(now + 0.12); - toneB.stop(now + 0.32); - - toneB.onended = () => { - ctx.close().catch(() => { }); - }; - } catch { - // Ignore audio errors in unsupported environments. - } -} - /** * Flash green/red tint over stage for trial feedback. * @@ -327,47 +206,6 @@ function flashStageFeedback(isSuccess) { }, FEEDBACK_FLASH_MS); } -/** - * Toggle mask visibility with both hidden attribute and inline display fallback. - * - * @param {boolean} visible - */ -function setMaskVisible(visible) { - if (!_maskEl) return; - _maskEl.hidden = !visible; - _maskEl.style.display = visible ? 'grid' : 'none'; -} - -/** - * Return human-readable label text for a stimulus icon. - * - * @param {{ id: string }} icon - * @returns {string} - */ -function labelForIcon(icon) { - if (!icon) return 'Empty'; - if (icon.id === 'primary-kitten') return 'Primary kitten'; - if (icon.id === 'secondary-kitten') return 'Secondary kitten'; - if (icon.id === 'toy-1') return 'Toy 1'; - if (icon.id === 'toy-2') return 'Toy 2'; - return 'Stimulus'; -} - -/** - * Create an image element for a stimulus icon. - * - * @param {{ id: string, file: string }} icon - * @returns {HTMLImageElement} - */ -function createStimulusImage(icon) { - const img = document.createElement('img'); - img.src = `${IMAGES_BASE_PATH}${icon.file}`; - img.alt = labelForIcon(icon); - img.decoding = 'async'; - img.loading = 'eager'; - return img; -} - /** * Render the current trial board. * @@ -386,6 +224,10 @@ function renderBoard(revealStimulus) { btn.className = 'fov-cell'; btn.dataset.index = String(cell.index); + const row = Math.floor(cell.index / _currentTrial.gridSize) + 1; + const col = (cell.index % _currentTrial.gridSize) + 1; + btn.setAttribute('aria-label', `Row ${row}, column ${col}`); + if (cell.role === 'center') { btn.classList.add('fov-cell--center'); btn.disabled = true; @@ -395,7 +237,7 @@ function renderBoard(revealStimulus) { btn.classList.add('fov-cell--hidden'); btn.textContent = ' '; // keeps the cell geometry stable } else if (cell.icon) { - btn.appendChild(createStimulusImage(cell.icon)); + btn.appendChild(render.createStimulusImage(cell.icon)); } else { btn.textContent = ' '; } @@ -404,7 +246,7 @@ function renderBoard(revealStimulus) { if (!_responseEnabled || !_currentTrial) return; if (cell.index === _currentTrial.centerIndex) return; _selectedPeripheralIndex = cell.index; - updatePeripheralSelectionVisual(); + render.updatePeripheralSelectionVisual(_boardEl, _selectedPeripheralIndex); attemptAutoSubmit(); }); @@ -412,28 +254,16 @@ function renderBoard(revealStimulus) { }); } -/** - * Highlight selected peripheral cell in response phase. - */ -function updatePeripheralSelectionVisual() { - if (!_boardEl) return; - const cells = _boardEl.querySelectorAll('.fov-cell'); - cells.forEach((el) => { - const index = Number(el.getAttribute('data-index')); - if (index === _selectedPeripheralIndex) { - el.classList.add('fov-cell--selected'); - } else { - el.classList.remove('fov-cell--selected'); - } - }); -} - /** * Set selected center icon response. * + * The peripheral cell click handler in renderBoard carries an equivalent + * _responseEnabled guard. Both ignore input outside the response phase. + * * @param {'primary-kitten'|'secondary-kitten'} id */ function chooseCenter(id) { + if (!_responseEnabled) return; _selectedCenterId = id; if (_centerPrimaryBtn) { @@ -468,7 +298,7 @@ function resetResponseSelection() { if (_centerPrimaryBtn) _centerPrimaryBtn.setAttribute('aria-pressed', 'false'); if (_centerSecondaryBtn) _centerSecondaryBtn.setAttribute('aria-pressed', 'false'); - updatePeripheralSelectionVisual(); + render.updatePeripheralSelectionVisual(_boardEl, null); } /** @@ -478,10 +308,11 @@ function enterResponsePhase() { _responseEnabled = true; _responseStartMs = nowMs(); - setStageMode('response'); + render.setStageMode(_stageEl, 'response'); - setMaskVisible(true); + render.setMaskVisible(_maskEl, true); if (_boardEl) _boardEl.hidden = false; + if (_responseEl) _responseEl.hidden = false; renderBoard(false); @@ -492,9 +323,9 @@ function enterResponsePhase() { * Start mask phase for a fixed duration using requestAnimationFrame timing. */ function runMaskPhase() { - setStageMode('mask'); + render.setStageMode(_stageEl, 'mask'); - setMaskVisible(true); + render.setMaskVisible(_maskEl, true); if (_boardEl) _boardEl.hidden = true; const start = nowMs(); @@ -517,11 +348,13 @@ function runMaskPhase() { */ function runStimulusPhase() { _responseEnabled = false; + // Ensure the response panel is hidden while the stimulus and mask are active. + if (_responseEl) _responseEl.hidden = true; - setStageMode('stimulus'); + render.setStageMode(_stageEl, 'stimulus'); if (_boardEl) _boardEl.hidden = false; - setMaskVisible(false); + render.setMaskVisible(_maskEl, false); renderBoard(true); @@ -563,12 +396,13 @@ function submitResponse() { const success = centerCorrect && peripheralCorrect; _responseEnabled = false; + if (_responseEl) _responseEl.hidden = true; const reactionTimeMs = nowMs() - _responseStartMs; const trialUpdate = game.recordTrial({ success, reactionTimeMs }); updateStats(); - renderThresholdTrend(); + updateThresholdTrend(); playFeedbackSound(success); flashStageFeedback(success); @@ -580,7 +414,7 @@ function submitResponse() { if (_feedbackEl) { _feedbackEl.textContent = `${_feedbackEl.textContent} ` - + `(accuracy ${percent(trialUpdate.recentAccuracy)})`; + + `(accuracy ${render.percent(trialUpdate.recentAccuracy)})`; } if (game.isRunning()) { @@ -612,52 +446,6 @@ function buildIdleResult() { }; } -/** - * Save game progress asynchronously via IPC. - * - * @param {{ thresholdMs: number, trialsCompleted: number, recentAccuracy: number }} result - */ -function saveProgress(result) { - (async () => { - if (typeof window === 'undefined' || !window.api) return; - - try { - const fallback = { playerId: 'default', games: {} }; - let existing = fallback; - try { - existing = await window.api.invoke('progress:load', { playerId: 'default' }) || fallback; - } catch { - existing = fallback; - } - - const previous = (existing.games && existing.games[GAME_ID]) || {}; - const previousBest = Number(previous.bestThresholdMs || Number.POSITIVE_INFINITY); - const nextBest = Math.min(previousBest, result.thresholdMs); - - const updated = { - ...existing, - games: { - ...existing.games, - [GAME_ID]: { - highScore: Math.max(previous.highScore || 0, Math.round(1000 / result.thresholdMs)), - sessionsPlayed: (previous.sessionsPlayed || 0) + 1, - lastPlayed: new Date().toISOString(), - bestThresholdMs: Number(nextBest.toFixed(2)), - lastThresholdMs: Number(result.thresholdMs.toFixed(2)), - lastRecentAccuracy: result.recentAccuracy, - thresholdHistory: game.getThresholdHistory(), - trialsCompleted: result.trialsCompleted, - }, - }, - }; - - await window.api.invoke('progress:save', { playerId: 'default', data: updated }); - } catch { - // Swallow all progress save errors. - } - })(); -} - /** * Dispatch app-level event to return to the game selector screen. */ @@ -723,7 +511,7 @@ function init(gameContainer) { } updateStats(); - renderThresholdTrend(); + updateThresholdTrend(); } /** @@ -735,7 +523,6 @@ function start() { if (_instructionsEl) _instructionsEl.hidden = true; if (_endPanelEl) _endPanelEl.hidden = true; if (_gameAreaEl) _gameAreaEl.hidden = false; - if (_responseEl) _responseEl.hidden = false; startTrial(); } @@ -760,11 +547,13 @@ function stop() { if (_endPanelEl) _endPanelEl.hidden = false; if (_finalThresholdEl) _finalThresholdEl.textContent = String(result.thresholdMs); - if (_finalAccuracyEl) _finalAccuracyEl.textContent = percent(result.recentAccuracy); + if (_finalAccuracyEl) _finalAccuracyEl.textContent = render.percent(result.recentAccuracy); - renderThresholdTrend(); + updateThresholdTrend(); - saveProgress(result); + if (result.trialsCompleted > 0) { + saveProgress(result); + } return result; } @@ -782,8 +571,8 @@ function reset() { _selectedPeripheralIndex = null; if (_boardEl) _boardEl.innerHTML = ''; - setStageMode('stimulus'); - setMaskVisible(false); + render.setStageMode(_stageEl, 'stimulus'); + render.setMaskVisible(_maskEl, false); if (_responseEl) _responseEl.hidden = true; if (_feedbackEl) _feedbackEl.textContent = ''; if (_instructionsEl) _instructionsEl.hidden = false; @@ -791,7 +580,7 @@ function reset() { if (_endPanelEl) _endPanelEl.hidden = true; updateStats(); - renderThresholdTrend(); + updateThresholdTrend(); } export default { diff --git a/app/games/field-of-view/interface.html b/app/games/field-of-view/interface.html index c5f8c40..92246bd 100644 --- a/app/games/field-of-view/interface.html +++ b/app/games/field-of-view/interface.html @@ -44,11 +44,11 @@

Which kitten appeared

diff --git a/app/games/field-of-view/manifest.json b/app/games/field-of-view/manifest.json index 977f773..7585a78 100644 --- a/app/games/field-of-view/manifest.json +++ b/app/games/field-of-view/manifest.json @@ -1,7 +1,7 @@ { "id": "field-of-view", "name": "Field of View", - "description": "Identify center shape and peripheral star under rapid masked flashes.", + "description": "Identify the center kitten and the peripheral toys under rapid masked flashes.", "version": "0.1.0", "entryPoint": "index.js", "thumbnail": "images/thumbnail.png", diff --git a/app/games/field-of-view/progress.js b/app/games/field-of-view/progress.js new file mode 100644 index 0000000..a27855f --- /dev/null +++ b/app/games/field-of-view/progress.js @@ -0,0 +1,62 @@ +/** + * progress.js - Progress persistence for the Field of View game. + * + * Saves and retrieves session results via the renderer IPC bridge. + * This module must only be imported in renderer-side code and never + * calls Electron APIs directly. + * + * @file Field of View progress persistence helpers. + */ + +import * as game from './game.js'; + +/** Game identifier used for progress persistence. */ +export const GAME_ID = 'field-of-view'; + +/** + * Save game progress asynchronously via IPC. + * + * Only call this when an actual session was played (trialsCompleted > 0). + * + * @param {{ thresholdMs: number, trialsCompleted: number, recentAccuracy: number }} result + */ +export function saveProgress(result) { + (async () => { + if (typeof window === 'undefined' || !window.api) return; + + try { + const fallback = { playerId: 'default', games: {} }; + let existing = fallback; + try { + existing = await window.api.invoke('progress:load', { playerId: 'default' }) || fallback; + } catch { + existing = fallback; + } + + const previous = (existing.games && existing.games[GAME_ID]) || {}; + const previousBest = Number(previous.bestThresholdMs || Number.POSITIVE_INFINITY); + const nextBest = Math.min(previousBest, result.thresholdMs); + + const updated = { + ...existing, + games: { + ...existing.games, + [GAME_ID]: { + highScore: Math.max(previous.highScore || 0, Math.round(1000 / result.thresholdMs)), + sessionsPlayed: (previous.sessionsPlayed || 0) + 1, + lastPlayed: new Date().toISOString(), + bestThresholdMs: Number(nextBest.toFixed(2)), + lastThresholdMs: Number(result.thresholdMs.toFixed(2)), + lastRecentAccuracy: result.recentAccuracy, + thresholdHistory: game.getThresholdHistory(), + trialsCompleted: result.trialsCompleted, + }, + }, + }; + + await window.api.invoke('progress:save', { playerId: 'default', data: updated }); + } catch { + // Swallow all progress save errors. + } + })(); +} diff --git a/app/games/field-of-view/render.js b/app/games/field-of-view/render.js new file mode 100644 index 0000000..d44de77 --- /dev/null +++ b/app/games/field-of-view/render.js @@ -0,0 +1,210 @@ +/** + * render.js - Rendering utilities for the Field of View game. + * + * Contains pure formatting helpers and DOM rendering functions for stats, + * the threshold chart, the stimulus board, and stage state transitions. + * All DOM-touching functions accept element references as explicit parameters + * to keep this module free of module-level side-effects. + * + * @file Field of View rendering helpers. + */ + +/** Path to Field of View image assets from renderer root. */ +export const IMAGES_BASE_PATH = 'games/field-of-view/images/'; + +/** + * Convert a ratio [0..1] to display percent string. + * + * @param {number} value + * @returns {string} + */ +export function percent(value) { + return `${Math.round(value * 100)}%`; +} + +/** + * Normalize millisecond values to at most 2 decimals without trailing zeros. + * + * @param {number} value + * @returns {string} + */ +export function formatMs(value) { + return String(Number(value).toFixed(2)).replace(/\.00$/, ''); +} + +/** + * Return human-readable label text for a stimulus icon. + * + * @param {{ id: string }|null} icon + * @returns {string} + */ +export function labelForIcon(icon) { + if (!icon) return 'Empty'; + if (icon.id === 'primary-kitten') return 'Primary kitten'; + if (icon.id === 'secondary-kitten') return 'Secondary kitten'; + if (icon.id === 'toy-1') return 'Toy 1'; + if (icon.id === 'toy-2') return 'Toy 2'; + return 'Stimulus'; +} + +/** + * Create an image element for a stimulus icon. + * + * @param {{ id: string, file: string }} icon + * @returns {HTMLImageElement} + */ +export function createStimulusImage(icon) { + const img = document.createElement('img'); + img.src = `${IMAGES_BASE_PATH}${icon.file}`; + img.alt = labelForIcon(icon); + img.decoding = 'async'; + img.loading = 'eager'; + return img; +} + +/** + * Build SVG point string for threshold history polyline. + * + * @param {Array<{ thresholdMs: number }>} history + * @returns {string} + */ +export function buildTrendPolylinePoints(history) { + if (!history || history.length === 0) { + return ''; + } + + const width = 300; + const height = 120; + const pad = 10; + + const values = history.map((entry) => entry.thresholdMs); + const min = Math.min(...values); + const max = Math.max(...values); + const span = Math.max(max - min, 1); + + const denominator = Math.max(history.length - 1, 1); + + return history.map((entry, index) => { + const x = pad + ((width - pad * 2) * index) / denominator; + const normalized = (entry.thresholdMs - min) / span; + const y = height - pad - normalized * (height - pad * 2); + return `${x.toFixed(2)},${y.toFixed(2)}`; + }).join(' '); +} + +/** + * Announce status text in the UI feedback region. + * + * @param {HTMLElement|null} feedbackEl + * @param {string} message + */ +export function announce(feedbackEl, message) { + if (feedbackEl) { + feedbackEl.textContent = message; + } +} + +/** + * Set current stage visual mode for stimulus, mask-only, or response overlay. + * + * @param {HTMLElement|null} stageEl + * @param {'stimulus'|'mask'|'response'} mode + */ +export function setStageMode(stageEl, mode) { + if (!stageEl) return; + stageEl.classList.remove('fov-stage--response'); + if (mode === 'response') { + stageEl.classList.add('fov-stage--response'); + } +} + +/** + * Toggle mask visibility with both hidden attribute and inline display fallback. + * + * @param {HTMLElement|null} maskEl + * @param {boolean} visible + */ +export function setMaskVisible(maskEl, visible) { + if (!maskEl) return; + maskEl.hidden = !visible; + maskEl.style.display = visible ? 'grid' : 'none'; +} + +/** + * Update game stats in the status bar. + * + * @param {{ + * soaEl: HTMLElement|null, + * thresholdEl: HTMLElement|null, + * accuracyEl: HTMLElement|null, + * trialsEl: HTMLElement|null, + * }} els - Stat display elements. + * @param {{ + * soaMs: number, + * accuracy: number, + * trialsCompleted: number, + * }} stats - Current game state values. + */ +export function updateStats(els, stats) { + if (els.soaEl) els.soaEl.textContent = String(stats.soaMs); + if (els.thresholdEl) els.thresholdEl.textContent = String(stats.soaMs); + if (els.accuracyEl) els.accuracyEl.textContent = percent(stats.accuracy); + if (els.trialsEl) els.trialsEl.textContent = String(stats.trialsCompleted); +} + +/** + * Render the threshold history chart and summary values. + * + * @param {{ + * trendLineEl: SVGPolylineElement|null, + * trendEmptyEl: HTMLElement|null, + * trendLatestEl: HTMLElement|null, + * finalBestThresholdEl: HTMLElement|null, + * }} els - Chart and summary display elements. + * @param {Array<{ thresholdMs: number }>} history - Threshold history entries. + * @param {number} currentSoaMs - Current SOA used when history is empty. + */ +export function renderThresholdTrend(els, history, currentSoaMs) { + const latest = history.length > 0 + ? history[history.length - 1].thresholdMs + : currentSoaMs; + + if (els.trendLatestEl) { + els.trendLatestEl.textContent = formatMs(latest); + } + + if (els.finalBestThresholdEl) { + const best = history.length > 0 + ? Math.min(...history.map((entry) => entry.thresholdMs)) + : currentSoaMs; + els.finalBestThresholdEl.textContent = formatMs(best); + } + + if (!els.trendLineEl) return; + + const points = buildTrendPolylinePoints(history); + els.trendLineEl.setAttribute('points', points); + + if (els.trendEmptyEl) { + els.trendEmptyEl.hidden = points.length > 0; + } +} + +/** + * Highlight selected peripheral cell in response phase. + * + * @param {HTMLElement|null} boardEl + * @param {number|null} selectedIndex + */ +export function updatePeripheralSelectionVisual(boardEl, selectedIndex) { + if (!boardEl) return; + const cells = boardEl.querySelectorAll('.fov-cell'); + cells.forEach((el) => { + const index = Number(el.getAttribute('data-index')); + if (index === selectedIndex) { + el.classList.add('fov-cell--selected'); + } else { + el.classList.remove('fov-cell--selected'); + } + }); +} diff --git a/app/games/field-of-view/tests/audio.test.js b/app/games/field-of-view/tests/audio.test.js new file mode 100644 index 0000000..0309ed9 --- /dev/null +++ b/app/games/field-of-view/tests/audio.test.js @@ -0,0 +1,141 @@ +/** @jest-environment jsdom */ +/** + * audio.test.js - Unit tests for Field of View audio feedback module. + */ +import { + jest, + describe, + test, + expect, +} from '@jest/globals'; + +/** + * Build a complete mock AudioContext that satisfies all method calls in playFeedbackSound. + * + * @param {string} state - Initial context state ('running' | 'suspended' | 'closed'). + * @returns {{ mockCtx: object, MockAC: jest.Mock }} + */ +function buildMockAudioContext(state = 'running') { + const mockOscillator = { + connect: jest.fn(), + type: '', + frequency: { setValueAtTime: jest.fn() }, + start: jest.fn(), + stop: jest.fn(), + onended: null, + }; + + const mockGain = { + connect: jest.fn(), + gain: { + setValueAtTime: jest.fn(), + exponentialRampToValueAtTime: jest.fn(), + }, + }; + + const mockCtx = { + state, + currentTime: 0, + destination: {}, + createOscillator: jest.fn(() => ({ ...mockOscillator })), + createGain: jest.fn(() => ({ ...mockGain })), + resume: jest.fn().mockResolvedValue(undefined), + }; + + const MockAC = jest.fn(() => mockCtx); + return { mockCtx, MockAC }; +} + +// Import module once – ESM modules are singletons per test file. +const audioModule = await import('../audio.js'); +const { getAudioContext, playFeedbackSound } = audioModule; + +describe('getAudioContext', () => { + test('returns an AudioContext when available', () => { + const { mockCtx, MockAC } = buildMockAudioContext('running'); + const original = globalThis.AudioContext; + globalThis.AudioContext = MockAC; + + const ctx = getAudioContext(); + expect(ctx).toBe(mockCtx); + + globalThis.AudioContext = original; + }); + + test('returns cached context on repeated calls', () => { + // After the previous test, _audioCtx is set; calling again should return the same object. + const ctx1 = getAudioContext(); + const ctx2 = getAudioContext(); + expect(ctx1).toBe(ctx2); + }); + + test('handles constructor throwing by returning null', () => { + // Force a fresh attempt by marking the cached ctx as 'closed'. + const ctx = getAudioContext(); + if (ctx) ctx.state = 'closed'; + + const ThrowingAC = jest.fn(() => { throw new Error('no audio hardware'); }); + const original = globalThis.AudioContext; + globalThis.AudioContext = ThrowingAC; + + const result = getAudioContext(); + expect(result).toBeNull(); + + globalThis.AudioContext = original; + }); +}); + +describe('playFeedbackSound', () => { + test('plays success tone without throwing', () => { + const { mockCtx, MockAC } = buildMockAudioContext('running'); + // Reset module-level cache to force creating this mock ctx. + const existingCtx = getAudioContext(); + if (existingCtx) existingCtx.state = 'closed'; + + const original = globalThis.AudioContext; + globalThis.AudioContext = MockAC; + + expect(() => playFeedbackSound(true)).not.toThrow(); + + globalThis.AudioContext = original; + void mockCtx; + }); + + test('plays failure tone without throwing', () => { + expect(() => playFeedbackSound(false)).not.toThrow(); + }); + + test('resumes suspended AudioContext before playing and handles rejection', async () => { + const { mockCtx, MockAC } = buildMockAudioContext('suspended'); + // Use a rejecting resume to cover the catch handler. + mockCtx.resume = jest.fn().mockRejectedValue(new Error('cannot resume')); + + const existingCtx = getAudioContext(); + if (existingCtx) existingCtx.state = 'closed'; + + const original = globalThis.AudioContext; + globalThis.AudioContext = MockAC; + + expect(() => playFeedbackSound(true)).not.toThrow(); + + // Let the rejected promise settle (covers the catch handler). + await new Promise((resolve) => { setTimeout(resolve, 0); }); + + globalThis.AudioContext = original; + void mockCtx; + }); + + test('does not throw when no AudioContext is available', () => { + const existingCtx = getAudioContext(); + if (existingCtx) existingCtx.state = 'closed'; + + const original = globalThis.AudioContext; + delete globalThis.AudioContext; + if (globalThis.window) delete globalThis.window.webkitAudioContext; + + expect(() => playFeedbackSound(true)).not.toThrow(); + expect(() => playFeedbackSound(false)).not.toThrow(); + + globalThis.AudioContext = original; + }); +}); diff --git a/app/games/field-of-view/tests/index.test.js b/app/games/field-of-view/tests/index.test.js index 07f4c33..4b2d53b 100644 --- a/app/games/field-of-view/tests/index.test.js +++ b/app/games/field-of-view/tests/index.test.js @@ -53,9 +53,18 @@ jest.unstable_mockModule('../game.js', () => ({ getThresholdHistory: jest.fn(() => [{ trial: 1, thresholdMs: 200, success: true }]), })); +jest.unstable_mockModule('../audio.js', () => ({ + playFeedbackSound: jest.fn(), +})); + +jest.unstable_mockModule('../progress.js', () => ({ + saveProgress: jest.fn(), +})); + const pluginModule = await import('../index.js'); const plugin = pluginModule.default; const gameMock = await import('../game.js'); +const progressMock = await import('../progress.js'); function buildContainer() { const wrapper = document.createElement('div'); @@ -190,16 +199,7 @@ describe('field-of-view index', () => { expect(sources.some((src) => src.includes('toy1.png'))).toBe(true); }); - test('stop returns running result and updates end panel', async () => { - const originalWindow = globalThis.window; - globalThis.window = globalThis.window || {}; - - const invoke = jest.fn() - .mockResolvedValueOnce({ playerId: 'default', games: {} }) - .mockResolvedValueOnce(undefined); - const oldApi = globalThis.window.api; - globalThis.window.api = { invoke }; - + test('stop returns running result and updates end panel', () => { plugin.start(); const result = plugin.stop(); @@ -207,17 +207,9 @@ describe('field-of-view index', () => { expect(document.querySelector('#fov-end-panel').hidden).toBe(false); expect(document.querySelector('#fov-final-threshold').textContent).toBe('84.2'); expect(document.querySelector('#fov-final-best-threshold').textContent).toBe('200'); - - await Promise.resolve(); - await Promise.resolve(); - - expect(invoke).toHaveBeenCalledWith( - 'progress:save', - expect.objectContaining({ playerId: 'default' }), + expect(progressMock.saveProgress).toHaveBeenCalledWith( + expect.objectContaining({ thresholdMs: 84.2, trialsCompleted: 4 }), ); - - globalThis.window.api = oldApi; - globalThis.window = originalWindow; }); test('stop returns idle result when game is not running', () => { @@ -233,6 +225,16 @@ describe('field-of-view index', () => { }); }); + test('stop does not save progress when trialsCompleted is zero', () => { + gameMock.isRunning.mockReturnValueOnce(false); + gameMock.getTrialsCompleted.mockReturnValueOnce(0); + progressMock.saveProgress.mockClear(); + + plugin.stop(); + + expect(progressMock.saveProgress).not.toHaveBeenCalled(); + }); + test('reset returns to instruction state', () => { plugin.start(); plugin.reset(); diff --git a/app/games/field-of-view/tests/progress.test.js b/app/games/field-of-view/tests/progress.test.js new file mode 100644 index 0000000..8cbe823 --- /dev/null +++ b/app/games/field-of-view/tests/progress.test.js @@ -0,0 +1,117 @@ +/** @jest-environment jsdom */ +/** + * progress.test.js - Unit tests for Field of View progress persistence module. + */ +import { + jest, + describe, + test, + expect, + beforeEach, +} from '@jest/globals'; + +jest.unstable_mockModule('../game.js', () => ({ + getThresholdHistory: jest.fn(() => []), +})); + +const { saveProgress, GAME_ID } = await import('../progress.js'); + +describe('GAME_ID', () => { + test('is field-of-view', () => { + expect(GAME_ID).toBe('field-of-view'); + }); +}); + +describe('saveProgress', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('does nothing when window is undefined', () => { + const originalWindow = globalThis.window; + delete globalThis.window; + + expect(() => saveProgress({ thresholdMs: 100, trialsCompleted: 3, recentAccuracy: 0.8 })) + .not.toThrow(); + + globalThis.window = originalWindow; + }); + + test('does nothing when window.api is undefined', () => { + const savedApi = globalThis.window.api; + delete globalThis.window.api; + + expect(() => saveProgress({ thresholdMs: 100, trialsCompleted: 3, recentAccuracy: 0.8 })) + .not.toThrow(); + + globalThis.window.api = savedApi; + }); + + test('calls progress:save with correct data', async () => { + const existing = { playerId: 'default', games: { [GAME_ID]: { sessionsPlayed: 2 } } }; + const invoke = jest.fn() + .mockResolvedValueOnce(existing) + .mockResolvedValueOnce(undefined); + + globalThis.window = globalThis.window || {}; + const savedApi = globalThis.window.api; + globalThis.window.api = { invoke }; + + saveProgress({ thresholdMs: 100, trialsCompleted: 3, recentAccuracy: 0.8 }); + + // Wait for the async IIFE to run. + await new Promise((resolve) => { setTimeout(resolve, 0); }); + await new Promise((resolve) => { setTimeout(resolve, 0); }); + + expect(invoke).toHaveBeenCalledWith( + 'progress:save', + expect.objectContaining({ playerId: 'default' }), + ); + + const savedData = invoke.mock.calls[1][1].data; + expect(savedData.games[GAME_ID].sessionsPlayed).toBe(3); + expect(savedData.games[GAME_ID].lastThresholdMs).toBe(100); + + globalThis.window.api = savedApi; + }); + + test('falls back gracefully when progress:load rejects', async () => { + const invoke = jest.fn() + .mockRejectedValueOnce(new Error('load failed')) + .mockResolvedValueOnce(undefined); + + const savedApi = globalThis.window.api; + globalThis.window.api = { invoke }; + + expect(() => saveProgress({ thresholdMs: 80, trialsCompleted: 2, recentAccuracy: 0.6 })) + .not.toThrow(); + + await new Promise((resolve) => { setTimeout(resolve, 0); }); + await new Promise((resolve) => { setTimeout(resolve, 0); }); + + expect(invoke).toHaveBeenCalledWith( + 'progress:save', + expect.anything(), + ); + + globalThis.window.api = savedApi; + }); + + test('falls back gracefully when progress:save rejects', async () => { + const invoke = jest.fn() + .mockResolvedValueOnce({ playerId: 'default', games: {} }) + .mockRejectedValueOnce(new Error('save failed')); + + const savedApi = globalThis.window.api; + globalThis.window.api = { invoke }; + + expect(() => saveProgress({ thresholdMs: 80, trialsCompleted: 2, recentAccuracy: 0.6 })) + .not.toThrow(); + + await new Promise((resolve) => { setTimeout(resolve, 0); }); + await new Promise((resolve) => { setTimeout(resolve, 0); }); + await new Promise((resolve) => { setTimeout(resolve, 0); }); + + globalThis.window.api = savedApi; + }); +}); diff --git a/app/games/field-of-view/tests/render.test.js b/app/games/field-of-view/tests/render.test.js new file mode 100644 index 0000000..6c1f877 --- /dev/null +++ b/app/games/field-of-view/tests/render.test.js @@ -0,0 +1,248 @@ +/** @jest-environment jsdom */ +/** + * render.test.js - Unit tests for Field of View render utility module. + */ +import { + describe, + test, + expect, +} from '@jest/globals'; + +import { + IMAGES_BASE_PATH, + percent, + formatMs, + labelForIcon, + createStimulusImage, + buildTrendPolylinePoints, + announce, + setStageMode, + setMaskVisible, + updateStats, + renderThresholdTrend, + updatePeripheralSelectionVisual, +} from '../render.js'; + +describe('percent', () => { + test('converts 0 to 0%', () => expect(percent(0)).toBe('0%')); + test('converts 1 to 100%', () => expect(percent(1)).toBe('100%')); + test('converts 0.75 to 75%', () => expect(percent(0.75)).toBe('75%')); +}); + +describe('formatMs', () => { + test('removes trailing .00', () => expect(formatMs(84)).toBe('84')); + test('keeps significant decimals', () => expect(formatMs(84.5)).toBe('84.50')); + test('rounds to 2 decimal places', () => expect(formatMs(84.567)).toBe('84.57')); +}); + +describe('labelForIcon', () => { + test('returns Empty for null icon', () => expect(labelForIcon(null)).toBe('Empty')); + test('returns Primary kitten', () => { + expect(labelForIcon({ id: 'primary-kitten' })).toBe('Primary kitten'); + }); + test('returns Secondary kitten', () => { + expect(labelForIcon({ id: 'secondary-kitten' })).toBe('Secondary kitten'); + }); + test('returns Toy 1', () => expect(labelForIcon({ id: 'toy-1' })).toBe('Toy 1')); + test('returns Toy 2', () => expect(labelForIcon({ id: 'toy-2' })).toBe('Toy 2')); + test('returns Stimulus for unknown id', () => { + expect(labelForIcon({ id: 'unknown' })).toBe('Stimulus'); + }); +}); + +describe('createStimulusImage', () => { + test('returns an img element with correct src and alt', () => { + const icon = { id: 'primary-kitten', file: 'primaryKitten.png' }; + const img = createStimulusImage(icon); + expect(img.tagName).toBe('IMG'); + expect(img.src).toContain('primaryKitten.png'); + expect(img.alt).toBe('Primary kitten'); + expect(img.src).toContain(IMAGES_BASE_PATH); + }); +}); + +describe('buildTrendPolylinePoints', () => { + test('returns empty string for empty history', () => { + expect(buildTrendPolylinePoints([])).toBe(''); + }); + + test('returns empty string for null history', () => { + expect(buildTrendPolylinePoints(null)).toBe(''); + }); + + test('returns a non-empty point string for valid history', () => { + const history = [ + { thresholdMs: 200 }, + { thresholdMs: 150 }, + { thresholdMs: 100 }, + ]; + const result = buildTrendPolylinePoints(history); + expect(result).not.toBe(''); + expect(result.split(' ').length).toBe(3); + }); + + test('handles single-entry history without division by zero', () => { + const result = buildTrendPolylinePoints([{ thresholdMs: 300 }]); + expect(result).not.toBe(''); + }); +}); + +describe('announce', () => { + test('sets textContent on feedbackEl', () => { + const el = document.createElement('div'); + announce(el, 'Hello'); + expect(el.textContent).toBe('Hello'); + }); + + test('does nothing when feedbackEl is null', () => { + expect(() => announce(null, 'Hello')).not.toThrow(); + }); +}); + +describe('setStageMode', () => { + test('adds fov-stage--response class when mode is response', () => { + const el = document.createElement('div'); + setStageMode(el, 'response'); + expect(el.classList.contains('fov-stage--response')).toBe(true); + }); + + test('removes fov-stage--response class when mode is stimulus', () => { + const el = document.createElement('div'); + el.classList.add('fov-stage--response'); + setStageMode(el, 'stimulus'); + expect(el.classList.contains('fov-stage--response')).toBe(false); + }); + + test('does nothing when stageEl is null', () => { + expect(() => setStageMode(null, 'response')).not.toThrow(); + }); +}); + +describe('setMaskVisible', () => { + test('unhides the mask element', () => { + const el = document.createElement('div'); + el.hidden = true; + setMaskVisible(el, true); + expect(el.hidden).toBe(false); + expect(el.style.display).toBe('grid'); + }); + + test('hides the mask element', () => { + const el = document.createElement('div'); + setMaskVisible(el, false); + expect(el.hidden).toBe(true); + expect(el.style.display).toBe('none'); + }); + + test('does nothing when maskEl is null', () => { + expect(() => setMaskVisible(null, true)).not.toThrow(); + }); +}); + +describe('updateStats', () => { + test('populates stat elements', () => { + const soaEl = document.createElement('strong'); + const thresholdEl = document.createElement('strong'); + const accuracyEl = document.createElement('strong'); + const trialsEl = document.createElement('strong'); + + updateStats( + { soaEl, thresholdEl, accuracyEl, trialsEl }, + { soaMs: 150, accuracy: 0.8, trialsCompleted: 5 }, + ); + + expect(soaEl.textContent).toBe('150'); + expect(thresholdEl.textContent).toBe('150'); + expect(accuracyEl.textContent).toBe('80%'); + expect(trialsEl.textContent).toBe('5'); + }); + + test('tolerates null elements', () => { + expect(() => updateStats( + { soaEl: null, thresholdEl: null, accuracyEl: null, trialsEl: null }, + { soaMs: 100, accuracy: 0.5, trialsCompleted: 3 }, + )).not.toThrow(); + }); +}); + +describe('renderThresholdTrend', () => { + test('populates trend elements with history data', () => { + const trendLineEl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + const trendEmptyEl = document.createElement('p'); + const trendLatestEl = document.createElement('strong'); + const finalBestThresholdEl = document.createElement('strong'); + + const history = [ + { thresholdMs: 300, trial: 1, success: true }, + { thresholdMs: 250, trial: 2, success: true }, + ]; + + renderThresholdTrend( + { trendLineEl, trendEmptyEl, trendLatestEl, finalBestThresholdEl }, + history, + 500, + ); + + expect(trendLatestEl.textContent).toBe('250'); + expect(finalBestThresholdEl.textContent).toBe('250'); + expect(trendLineEl.getAttribute('points')).not.toBe(''); + expect(trendEmptyEl.hidden).toBe(true); + }); + + test('uses currentSoaMs when history is empty', () => { + const trendLatestEl = document.createElement('strong'); + const finalBestThresholdEl = document.createElement('strong'); + const trendEmptyEl = document.createElement('p'); + const trendLineEl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + + renderThresholdTrend( + { trendLineEl, trendEmptyEl, trendLatestEl, finalBestThresholdEl }, + [], + 400, + ); + + expect(trendLatestEl.textContent).toBe('400'); + expect(trendEmptyEl.hidden).toBe(false); + }); + + test('tolerates null trend line element', () => { + expect(() => renderThresholdTrend( + { + trendLineEl: null, + trendEmptyEl: null, + trendLatestEl: null, + finalBestThresholdEl: null, + }, + [], + 500, + )).not.toThrow(); + }); +}); + +describe('updatePeripheralSelectionVisual', () => { + test('adds selected class to matching cell', () => { + const board = document.createElement('div'); + const btn = document.createElement('button'); + btn.className = 'fov-cell'; + btn.setAttribute('data-index', '2'); + board.appendChild(btn); + + updatePeripheralSelectionVisual(board, 2); + expect(btn.classList.contains('fov-cell--selected')).toBe(true); + }); + + test('removes selected class from non-matching cells', () => { + const board = document.createElement('div'); + const btn = document.createElement('button'); + btn.className = 'fov-cell fov-cell--selected'; + btn.setAttribute('data-index', '3'); + board.appendChild(btn); + + updatePeripheralSelectionVisual(board, 2); + expect(btn.classList.contains('fov-cell--selected')).toBe(false); + }); + + test('tolerates null boardEl', () => { + expect(() => updatePeripheralSelectionVisual(null, 1)).not.toThrow(); + }); +}); diff --git a/app/index.html b/app/index.html index 0b3a900..fbddc54 100644 --- a/app/index.html +++ b/app/index.html @@ -5,7 +5,7 @@ - BrainSpeed Exercises + Brain Speed Exercises From c97d669688370d9427d034022c8b4840ac3aadee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:46:56 +0000 Subject: [PATCH 3/3] Revert response panel hide/show during trial phases to prevent layout shifts Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com> Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/adc8dc8c-bc12-402a-bdd6-6c45bc00a4a5 --- app/games/field-of-view/index.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/games/field-of-view/index.js b/app/games/field-of-view/index.js index ec6eb62..a9817b3 100644 --- a/app/games/field-of-view/index.js +++ b/app/games/field-of-view/index.js @@ -312,7 +312,6 @@ function enterResponsePhase() { render.setMaskVisible(_maskEl, true); if (_boardEl) _boardEl.hidden = false; - if (_responseEl) _responseEl.hidden = false; renderBoard(false); @@ -348,8 +347,6 @@ function runMaskPhase() { */ function runStimulusPhase() { _responseEnabled = false; - // Ensure the response panel is hidden while the stimulus and mask are active. - if (_responseEl) _responseEl.hidden = true; render.setStageMode(_stageEl, 'stimulus'); @@ -396,7 +393,6 @@ function submitResponse() { const success = centerCorrect && peripheralCorrect; _responseEnabled = false; - if (_responseEl) _responseEl.hidden = true; const reactionTimeMs = nowMs() - _responseStartMs; const trialUpdate = game.recordTrial({ success, reactionTimeMs }); @@ -523,6 +519,7 @@ function start() { if (_instructionsEl) _instructionsEl.hidden = true; if (_endPanelEl) _endPanelEl.hidden = true; if (_gameAreaEl) _gameAreaEl.hidden = false; + if (_responseEl) _responseEl.hidden = false; startTrial(); }