From 9b78d583fc6a3d6dc601018bc0346b86756e1e84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 03:14:19 +0000 Subject: [PATCH 1/4] Initial plan From 27953b20fb8a1452dba703cb9b12e00152872dea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 03:21:14 +0000 Subject: [PATCH 2/4] Add average response time display and fix feedback image spacing in Otter Stop Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/a116c851-04c6-4781-9aa4-261375177946 Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com> --- app/games/otter-stop/game.js | 32 ++++++++++ app/games/otter-stop/index.js | 23 +++++++ app/games/otter-stop/interface.html | 3 + app/games/otter-stop/style.css | 16 ++--- app/games/otter-stop/tests/game.test.js | 47 +++++++++++++++ app/games/otter-stop/tests/index.test.js | 77 ++++++++++++++++++++++++ 6 files changed, 191 insertions(+), 7 deletions(-) diff --git a/app/games/otter-stop/game.js b/app/games/otter-stop/game.js index fa5dcad..088466d 100644 --- a/app/games/otter-stop/game.js +++ b/app/games/otter-stop/game.js @@ -111,6 +111,13 @@ let consecutiveWrong = 0; */ let speedHistory = []; +/** + * Response times (ms) for go trials where the player actually responded. + * Used to compute the average response time displayed in the stats bar. + * @type {number[]} + */ +let goResponseTimes = []; + /** * Whether the next trial must be a go image (forced after any wrong outcome). * Ensures the player gets a fair chance to respond correctly before facing @@ -136,6 +143,7 @@ export function initGame() { consecutiveWrong = 0; forceGoNext = false; speedHistory = []; + goResponseTimes = []; } /** @@ -390,3 +398,27 @@ export function isRunning() { export function getSpeedHistory() { return [...speedHistory]; } + +/** + * Record a response time (ms) for a go trial where the player responded. + * Only go trials where the player actually presses Space should be counted. + * + * @param {number} ms - Time (ms) between when the go stimulus appeared and + * when the player responded. + */ +export function recordGoResponseTime(ms) { + goResponseTimes.push(ms); +} + +/** + * Return the running average of all recorded go-trial response times, rounded + * to the nearest millisecond. Returns null when no go responses have been + * recorded yet (i.e. at the start of a game). + * + * @returns {number|null} + */ +export function getAverageResponseMs() { + if (goResponseTimes.length === 0) return null; + const total = goResponseTimes.reduce((sum, t) => sum + t, 0); + return Math.round(total / goResponseTimes.length); +} diff --git a/app/games/otter-stop/index.js b/app/games/otter-stop/index.js index 0076809..e92442e 100644 --- a/app/games/otter-stop/index.js +++ b/app/games/otter-stop/index.js @@ -89,6 +89,9 @@ let _trendEmptyEl = null; /** @type {HTMLElement|null} */ let _trendLatestEl = null; +/** @type {HTMLElement|null} */ +let _avgResponseEl = null; + /** @type {HTMLElement|null} */ let _finalScoreEl = null; @@ -115,6 +118,12 @@ let _currentIsNoGo = false; /** Whether Space was pressed during the current trial window. */ let _spacePressedThisTrial = false; +/** + * Timestamp (ms) when the current go stimulus was shown. + * Null when no trial is active or the current stimulus is a no-go image. + */ +let _goTrialStartMs = null; + /** setTimeout handle for the trial display window. */ let _trialTimer = null; @@ -160,6 +169,10 @@ export function updateStats() { if (_scoreEl) _scoreEl.textContent = game.getScore(); if (_nogoHitsEl) _nogoHitsEl.textContent = game.getNoGoHits(); if (_intervalEl) _intervalEl.textContent = game.getCurrentIntervalMs(); + if (_avgResponseEl) { + const avgMs = game.getAverageResponseMs(); + _avgResponseEl.textContent = avgMs !== null ? avgMs : '--'; + } } /** @@ -276,6 +289,12 @@ export function showEndPanel(result) { export function endTrial() { if (!game.isRunning()) return; + // Record response time for go trials where the player actually responded. + if (!_currentIsNoGo && _spacePressedThisTrial && _goTrialStartMs !== null) { + game.recordGoResponseTime(Date.now() - _goTrialStartMs); + } + _goTrialStartMs = null; + const outcome = game.recordResponse(_currentIsNoGo, _spacePressedThisTrial); updateStats(); updateTrendChart(); @@ -319,6 +338,9 @@ export function beginTrial() { _currentImageKey = imageKey; _currentIsNoGo = isNoGo; + // Record when the go stimulus appears so we can measure reaction time. + _goTrialStartMs = isNoGo ? null : Date.now(); + showImage(imageKey); const intervalMs = game.getCurrentIntervalMs(); @@ -420,6 +442,7 @@ function init(container) { _trendLineEl = container.querySelector('#os-trend-line'); _trendEmptyEl = container.querySelector('#os-trend-empty'); _trendLatestEl = container.querySelector('#os-trend-latest'); + _avgResponseEl = container.querySelector('#os-avg-response'); _finalScoreEl = container.querySelector('#os-final-score'); _finalBestEl = container.querySelector('#os-final-best'); _finalNogoEl = container.querySelector('#os-final-nogo'); diff --git a/app/games/otter-stop/interface.html b/app/games/otter-stop/interface.html index 21e0865..bdc4526 100644 --- a/app/games/otter-stop/interface.html +++ b/app/games/otter-stop/interface.html @@ -51,6 +51,9 @@

How to Play

Interval: 1500 ms + + Avg Resp: -- ms + Session: 00:00 diff --git a/app/games/otter-stop/style.css b/app/games/otter-stop/style.css index 335c26e..321db33 100644 --- a/app/games/otter-stop/style.css +++ b/app/games/otter-stop/style.css @@ -113,25 +113,27 @@ .os-feedback { position: absolute; inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.5rem; background: rgba(255, 255, 255, 0.93); border-radius: 12px; z-index: 2; } .os-feedback__img { - flex: 1; - min-height: 0; + position: absolute; + inset: 0; width: 100%; + height: 100%; object-fit: contain; } .os-feedback__text { + position: absolute; + bottom: 0.5rem; + left: 0; + right: 0; + text-align: center; margin: 0; + padding: 0.1rem 0.5rem; font-size: 1.1rem; font-weight: 700; color: var(--text-heading); diff --git a/app/games/otter-stop/tests/game.test.js b/app/games/otter-stop/tests/game.test.js index 7e7554f..448a622 100644 --- a/app/games/otter-stop/tests/game.test.js +++ b/app/games/otter-stop/tests/game.test.js @@ -24,6 +24,8 @@ import { GO_KEYS, setGoKeys, getSpeedHistory, + recordGoResponseTime, + getAverageResponseMs, } from '../game.js'; /** Default go keys used by the test suite (matches built-in defaults). */ @@ -630,3 +632,48 @@ describe('getSpeedHistory()', () => { expect(getSpeedHistory()).toEqual([]); }); }); + +// ── recordGoResponseTime / getAverageResponseMs ─────────────────────────────── + +describe('getAverageResponseMs()', () => { + it('returns null when no go response times have been recorded', () => { + expect(getAverageResponseMs()).toBeNull(); + }); + + it('returns the single recorded value when only one response has been recorded', () => { + recordGoResponseTime(400); + expect(getAverageResponseMs()).toBe(400); + }); + + it('returns the rounded average of multiple recorded times', () => { + recordGoResponseTime(300); + recordGoResponseTime(500); + expect(getAverageResponseMs()).toBe(400); + }); + + it('rounds to the nearest millisecond', () => { + recordGoResponseTime(300); + recordGoResponseTime(301); + expect(getAverageResponseMs()).toBe(301); // Math.round(601 / 2) = 301 (rounds down 300.5) + }); + + it('resets to null after initGame()', () => { + recordGoResponseTime(350); + initGame(); + expect(getAverageResponseMs()).toBeNull(); + }); +}); + +describe('recordGoResponseTime()', () => { + it('accumulates response times so each additional entry changes the average', () => { + recordGoResponseTime(200); + expect(getAverageResponseMs()).toBe(200); + recordGoResponseTime(400); + expect(getAverageResponseMs()).toBe(300); + }); + + it('accepts any non-negative value including 0', () => { + recordGoResponseTime(0); + expect(getAverageResponseMs()).toBe(0); + }); +}); diff --git a/app/games/otter-stop/tests/index.test.js b/app/games/otter-stop/tests/index.test.js index 2c5613e..cac04d2 100644 --- a/app/games/otter-stop/tests/index.test.js +++ b/app/games/otter-stop/tests/index.test.js @@ -43,6 +43,8 @@ jest.unstable_mockModule('../game.js', () => ({ isRunning: jest.fn(() => true), setGoKeys: jest.fn(), getSpeedHistory: jest.fn(() => []), + getAverageResponseMs: jest.fn(() => null), + recordGoResponseTime: jest.fn(), IMAGE_KEYS: ['go-1.png', 'go-2.png', 'go-3.png', 'no-go'], NO_GO_KEY: 'no-go', })); @@ -122,6 +124,7 @@ function buildContainer() { 0 0 1500 + -- 0 0 0 @@ -349,6 +352,22 @@ describe('updateStats()', () => { updateStats(); expect(container.querySelector('#os-interval').textContent).toBe('500'); }); + + it('shows "--" for avg response when getAverageResponseMs returns null', () => { + const container = buildContainer(); + plugin.init(container); + gameMock.getAverageResponseMs.mockReturnValue(null); + updateStats(); + expect(container.querySelector('#os-avg-response').textContent).toBe('--'); + }); + + it('shows the numeric value when getAverageResponseMs returns a number', () => { + const container = buildContainer(); + plugin.init(container); + gameMock.getAverageResponseMs.mockReturnValue(320); + updateStats(); + expect(container.querySelector('#os-avg-response').textContent).toBe('320'); + }); }); // ── showImage / hideImage ───────────────────────────────────────────────────── @@ -821,6 +840,64 @@ describe('endTrial() — feedback timer fires after go miss', () => { }); }); +// ── response time recording ─────────────────────────────────────────────────── + +describe('response time recording', () => { + it('calls recordGoResponseTime when Space is pressed on a go trial', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + gameMock.pickNextImage.mockReturnValueOnce({ imageKey: 'go-1.png', isNoGo: false }); + beginTrial(); + clearAllTimers(); + + const event = new KeyboardEvent('keydown', { code: 'Space', bubbles: true, cancelable: true }); + handleKeyDown(event); + + expect(gameMock.recordGoResponseTime).toHaveBeenCalledWith(expect.any(Number)); + }); + + it('does not call recordGoResponseTime for a no-go trial', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + gameMock.pickNextImage.mockReturnValueOnce({ imageKey: 'no-go', isNoGo: true }); + beginTrial(); + clearAllTimers(); + gameMock.recordGoResponseTime.mockClear(); + endTrial(); + + expect(gameMock.recordGoResponseTime).not.toHaveBeenCalled(); + }); + + it('does not call recordGoResponseTime when player misses a go image (no Space press)', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + gameMock.pickNextImage.mockReturnValueOnce({ imageKey: 'go-1.png', isNoGo: false }); + beginTrial(); + clearAllTimers(); + gameMock.recordGoResponseTime.mockClear(); + // endTrial without pressing Space — simulates a miss + endTrial(); + + expect(gameMock.recordGoResponseTime).not.toHaveBeenCalled(); + }); + + it('calls recordGoResponseTime when stimulus area is clicked on a go trial', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + gameMock.pickNextImage.mockReturnValueOnce({ imageKey: 'go-1.png', isNoGo: false }); + beginTrial(); + clearAllTimers(); + + handleClick(); + + expect(gameMock.recordGoResponseTime).toHaveBeenCalledWith(expect.any(Number)); + }); +}); + // ── stop() with window.api present ─────────────────────────────────────────── describe('stop() — window.api IPC call', () => { From 83a4e646dabf2fcfd6a403b3fd4767daf20eb1b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 03:22:25 +0000 Subject: [PATCH 3/4] Fix incorrect comment in test for getAverageResponseMs rounding Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/a116c851-04c6-4781-9aa4-261375177946 Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com> --- app/games/otter-stop/tests/game.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/games/otter-stop/tests/game.test.js b/app/games/otter-stop/tests/game.test.js index 448a622..f9bf6a5 100644 --- a/app/games/otter-stop/tests/game.test.js +++ b/app/games/otter-stop/tests/game.test.js @@ -654,7 +654,7 @@ describe('getAverageResponseMs()', () => { it('rounds to the nearest millisecond', () => { recordGoResponseTime(300); recordGoResponseTime(301); - expect(getAverageResponseMs()).toBe(301); // Math.round(601 / 2) = 301 (rounds down 300.5) + expect(getAverageResponseMs()).toBe(301); // Math.round(601 / 2) = 301 }); it('resets to null after initGame()', () => { From 153c3a3dab21c457fca4252cab8ba86bdf6de498 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 04:10:30 +0000 Subject: [PATCH 4/4] Fix failing setGoKeys test and move avg-response stat update to end of sequence only Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/409342e8-51be-454f-95a0-6295f7d93d83 Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com> --- app/games/otter-stop/index.js | 11 +++-- app/games/otter-stop/tests/game.test.js | 6 ++- app/games/otter-stop/tests/index.test.js | 56 +++++++++++++++++------- 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/app/games/otter-stop/index.js b/app/games/otter-stop/index.js index b89f835..9671e27 100644 --- a/app/games/otter-stop/index.js +++ b/app/games/otter-stop/index.js @@ -169,10 +169,6 @@ export function updateStats() { if (_scoreEl) _scoreEl.textContent = game.getScore(); if (_nogoHitsEl) _nogoHitsEl.textContent = game.getNoGoHits(); if (_intervalEl) _intervalEl.textContent = game.getCurrentIntervalMs(); - if (_avgResponseEl) { - const avgMs = game.getAverageResponseMs(); - _avgResponseEl.textContent = avgMs !== null ? avgMs : '--'; - } } /** @@ -303,6 +299,13 @@ export function endTrial() { _currentImageKey = null; _currentIsNoGo = false; + // Update the average response stat only at the end of a complete sequence + // (i.e., after the no-go stimulus), so it reflects the whole go-run. + if (wasNoGo && _avgResponseEl) { + const avgMs = game.getAverageResponseMs(); + _avgResponseEl.textContent = avgMs !== null ? avgMs : '--'; + } + hideImage(); // Show feedback for no-go trials and for go images the player missed. diff --git a/app/games/otter-stop/tests/game.test.js b/app/games/otter-stop/tests/game.test.js index 72dba12..0989df1 100644 --- a/app/games/otter-stop/tests/game.test.js +++ b/app/games/otter-stop/tests/game.test.js @@ -79,7 +79,11 @@ describe('setGoKeys()', () => { it('pickNextImage() picks from the new keys after setGoKeys()', () => { setGoKeys(['custom.png']); - const spy = jest.spyOn(Math, 'random').mockReturnValue(0); + // Use a non-zero Math.random value during initGame() to guarantee a + // non-zero currentSequenceLength, then switch to 0 for the idx pick. + const spy = jest.spyOn(Math, 'random').mockReturnValue(0.5); + initGame(); // currentSequenceLength = Math.floor(0.5 * 6) = 3 + spy.mockReturnValue(0); // idx = Math.floor(0 * 1) = 0 → GO_KEYS[0] = 'custom.png' const { imageKey } = pickNextImage(); spy.mockRestore(); expect(imageKey).toBe('custom.png'); diff --git a/app/games/otter-stop/tests/index.test.js b/app/games/otter-stop/tests/index.test.js index cac04d2..2acbc7e 100644 --- a/app/games/otter-stop/tests/index.test.js +++ b/app/games/otter-stop/tests/index.test.js @@ -352,22 +352,6 @@ describe('updateStats()', () => { updateStats(); expect(container.querySelector('#os-interval').textContent).toBe('500'); }); - - it('shows "--" for avg response when getAverageResponseMs returns null', () => { - const container = buildContainer(); - plugin.init(container); - gameMock.getAverageResponseMs.mockReturnValue(null); - updateStats(); - expect(container.querySelector('#os-avg-response').textContent).toBe('--'); - }); - - it('shows the numeric value when getAverageResponseMs returns a number', () => { - const container = buildContainer(); - plugin.init(container); - gameMock.getAverageResponseMs.mockReturnValue(320); - updateStats(); - expect(container.querySelector('#os-avg-response').textContent).toBe('320'); - }); }); // ── showImage / hideImage ───────────────────────────────────────────────────── @@ -671,6 +655,46 @@ describe('endTrial()', () => { const fb = container.querySelector('#os-feedback'); expect(fb.hidden).toBe(true); }); + + it('does not update avg response stat after a go trial', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + gameMock.getAverageResponseMs.mockReturnValue(250); + gameMock.pickNextImage.mockReturnValueOnce({ imageKey: 'go-1', isNoGo: false }); + gameMock.recordResponse.mockReturnValueOnce('correct'); + beginTrial(); + clearAllTimers(); + endTrial(); + // Stat should remain at its initial value — not updated on a go trial. + expect(container.querySelector('#os-avg-response').textContent).toBe('--'); + }); + + it('updates avg response stat to a number after a no-go trial', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + gameMock.getAverageResponseMs.mockReturnValue(320); + gameMock.pickNextImage.mockReturnValueOnce({ imageKey: 'no-go', isNoGo: true }); + gameMock.recordResponse.mockReturnValueOnce('correct'); + beginTrial(); + clearAllTimers(); + endTrial(); + expect(container.querySelector('#os-avg-response').textContent).toBe('320'); + }); + + it('updates avg response stat to "--" after a no-go trial when no go response recorded', () => { + const container = buildContainer(); + plugin.init(container); + plugin.start(); + gameMock.getAverageResponseMs.mockReturnValue(null); + gameMock.pickNextImage.mockReturnValueOnce({ imageKey: 'no-go', isNoGo: true }); + gameMock.recordResponse.mockReturnValueOnce('correct'); + beginTrial(); + clearAllTimers(); + endTrial(); + expect(container.querySelector('#os-avg-response').textContent).toBe('--'); + }); }); describe('scheduleNextTrial()', () => {