From 96f8f7e08ea0bc2453439ab5254357b3cfbe4b24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:41:13 +0000 Subject: [PATCH 1/4] Initial plan From 32d304bb7631363520c70fa28a8ac369140189e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 00:09:16 +0000 Subject: [PATCH 2/4] Add speed history tracking and trend chart to all games; add getSpeedHistory tests Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/60497adb-33a4-4f98-9fc7-52414d0e292d Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com> --- .../tests/trendChartService.test.js | 156 ++++++++++++++++++ app/components/trendChartService.js | 84 ++++++++++ app/games/directional-processing/game.js | 20 +++ app/games/directional-processing/index.js | 23 +++ .../directional-processing/interface.html | 13 ++ .../directional-processing/tests/game.test.js | 33 ++++ .../tests/index.test.js | 1 + app/games/fast-piggie/game.js | 19 +++ app/games/fast-piggie/index.js | 24 +++ app/games/fast-piggie/interface.html | 13 ++ app/games/fast-piggie/tests/game.test.js | 43 +++++ app/games/fast-piggie/tests/index.test.js | 1 + app/games/field-of-view/interface.html | 8 +- app/games/field-of-view/render.js | 45 ++--- app/games/high-speed-memory/game.js | 19 +++ app/games/high-speed-memory/index.js | 25 +++ app/games/high-speed-memory/interface.html | 13 ++ .../high-speed-memory/tests/game.test.js | 38 +++++ .../high-speed-memory/tests/index.test.js | 1 + app/games/object-track/game.js | 16 ++ app/games/object-track/index.js | 25 +++ app/games/object-track/interface.html | 13 ++ app/games/object-track/tests/game.test.js | 32 ++++ app/games/object-track/tests/index.test.js | 1 + app/games/orbit-sprite-memory/game.js | 19 +++ app/games/orbit-sprite-memory/index.js | 24 +++ app/games/orbit-sprite-memory/interface.html | 13 ++ .../orbit-sprite-memory/tests/game.test.js | 38 +++++ .../orbit-sprite-memory/tests/index.test.js | 1 + app/games/otter-stop/game.js | 19 +++ app/games/otter-stop/index.js | 23 +++ app/games/otter-stop/interface.html | 13 ++ app/games/otter-stop/tests/game.test.js | 32 ++++ app/games/otter-stop/tests/index.test.js | 1 + app/games/sound-sweep/game.js | 20 +++ app/games/sound-sweep/index.js | 23 +++ app/games/sound-sweep/interface.html | 13 ++ app/games/sound-sweep/tests/game.test.js | 33 ++++ app/games/sound-sweep/tests/index.test.js | 1 + app/style.css | 38 +++++ 40 files changed, 940 insertions(+), 37 deletions(-) create mode 100644 app/components/tests/trendChartService.test.js create mode 100644 app/components/trendChartService.js diff --git a/app/components/tests/trendChartService.test.js b/app/components/tests/trendChartService.test.js new file mode 100644 index 0000000..33d3f12 --- /dev/null +++ b/app/components/tests/trendChartService.test.js @@ -0,0 +1,156 @@ +/** @jest-environment jsdom */ +/** + * trendChartService.test.js — Unit tests for the centralized trend chart service. + * + * Exercises buildPolylinePoints and renderTrendChart against simple inputs, + * edge cases, and null-element guards. + * + * @file Tests for app/components/trendChartService.js + */ + +import { describe, test, expect } from '@jest/globals'; +import { buildPolylinePoints, renderTrendChart } from '../trendChartService.js'; + +// ── buildPolylinePoints ─────────────────────────────────────────────────────── + +describe('buildPolylinePoints', () => { + test('returns empty string for an empty array', () => { + expect(buildPolylinePoints([])).toBe(''); + }); + + test('returns empty string for null input', () => { + expect(buildPolylinePoints(null)).toBe(''); + }); + + test('returns empty string for undefined input', () => { + expect(buildPolylinePoints(undefined)).toBe(''); + }); + + test('returns a single point string for a one-element array', () => { + const result = buildPolylinePoints([300]); + expect(result).not.toBe(''); + expect(result.split(' ').length).toBe(1); + }); + + test('returns three coordinate pairs for a three-element array', () => { + const result = buildPolylinePoints([200, 150, 100]); + expect(result.split(' ').length).toBe(3); + }); + + test('each coordinate pair contains an x and y value separated by a comma', () => { + const result = buildPolylinePoints([100, 200]); + result.split(' ').forEach((pair) => { + expect(pair).toMatch(/^-?\d+(\.\d+)?,-?\d+(\.\d+)?$/); + }); + }); + + test('uses the full chart width between first and last point', () => { + const result = buildPolylinePoints([100, 200]); + const pairs = result.split(' ').map((p) => p.split(',').map(Number)); + // With CHART_PAD=10 and CHART_WIDTH=300, first x should be ~10, last x ~290 + expect(pairs[0][0]).toBeCloseTo(10, 1); + expect(pairs[1][0]).toBeCloseTo(290, 1); + }); + + test('places a single entry at leftmost x position', () => { + const result = buildPolylinePoints([500]); + const [x] = result.split(',').map(Number); + expect(x).toBeCloseTo(10, 1); + }); + + test('handles all-identical values without division errors', () => { + expect(() => buildPolylinePoints([100, 100, 100])).not.toThrow(); + const result = buildPolylinePoints([100, 100, 100]); + expect(result.split(' ').length).toBe(3); + }); + + test('larger values produce higher y coordinates (lower on SVG = larger value)', () => { + // With values [100, 200]: value 100 (min) → lower y, value 200 (max) → higher y + const result = buildPolylinePoints([100, 200]); + const pairs = result.split(' ').map((p) => p.split(',').map(Number)); + // value 100 is at min: y should be near bottom (large y number) + // value 200 is at max: y should be near top (small y number) + expect(pairs[0][1]).toBeGreaterThan(pairs[1][1]); + }); +}); + +// ── renderTrendChart ────────────────────────────────────────────────────────── + +describe('renderTrendChart', () => { + test('updates lineEl points attribute for a non-empty values array', () => { + const lineEl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + const emptyEl = document.createElement('p'); + const latestEl = document.createElement('strong'); + + renderTrendChart({ lineEl, emptyEl, latestEl }, [200, 150, 100], 500); + + expect(lineEl.getAttribute('points')).not.toBe(''); + expect(emptyEl.hidden).toBe(true); + expect(latestEl.textContent).toBe('100'); + }); + + test('clears the lineEl and shows emptyEl when values array is empty', () => { + const lineEl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + lineEl.setAttribute('points', '10,10 20,20'); + const emptyEl = document.createElement('p'); + const latestEl = document.createElement('strong'); + + renderTrendChart({ lineEl, emptyEl, latestEl }, [], 400); + + expect(lineEl.getAttribute('points')).toBe(''); + expect(emptyEl.hidden).toBe(false); + expect(latestEl.textContent).toBe('400'); + }); + + test('uses currentValue for latestEl when values array is empty', () => { + const latestEl = document.createElement('strong'); + renderTrendChart( + { lineEl: null, emptyEl: null, latestEl }, + [], + '350', + ); + expect(latestEl.textContent).toBe('350'); + }); + + test('uses the last value in the array as the latest value', () => { + const latestEl = document.createElement('strong'); + renderTrendChart( + { lineEl: null, emptyEl: null, latestEl }, + [500, 450, 380], + 500, + ); + expect(latestEl.textContent).toBe('380'); + }); + + test('tolerates null lineEl without throwing', () => { + expect(() => renderTrendChart( + { lineEl: null, emptyEl: null, latestEl: null }, + [100, 200], + 300, + )).not.toThrow(); + }); + + test('tolerates null emptyEl without throwing', () => { + const lineEl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + expect(() => renderTrendChart( + { lineEl, emptyEl: null, latestEl: null }, + [100, 200], + 300, + )).not.toThrow(); + }); + + test('tolerates null latestEl without throwing', () => { + const lineEl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + expect(() => renderTrendChart( + { lineEl, emptyEl: null, latestEl: null }, + [], + 300, + )).not.toThrow(); + }); + + test('renders empty string for latestEl when currentValue is null and values is empty', () => { + const latestEl = document.createElement('strong'); + renderTrendChart({ lineEl: null, emptyEl: null, latestEl }, [], null); + expect(latestEl.textContent).toBe(''); + }); +}); diff --git a/app/components/trendChartService.js b/app/components/trendChartService.js new file mode 100644 index 0000000..eaac0cb --- /dev/null +++ b/app/components/trendChartService.js @@ -0,0 +1,84 @@ +/** + * trendChartService.js — Centralized trend chart service for BrainSpeedExercises. + * + * Provides shared SVG polyline chart generation and rendering utilities used by + * all game plugins to display a speed/difficulty metric history during gameplay. + * Each game tracks its own history array and passes it here for rendering. + * + * @file Centralized trend chart rendering service. + */ + +// ── Chart geometry constants ─────────────────────────────────────────────────── + +/** SVG viewBox width in user units. */ +const CHART_WIDTH = 300; + +/** SVG viewBox height in user units. */ +const CHART_HEIGHT = 120; + +/** Padding from each edge of the SVG in user units. */ +const CHART_PAD = 10; + +// ── Core chart helpers ──────────────────────────────────────────────────────── + +/** + * Build an SVG polyline `points` attribute string from an array of numeric values. + * + * Maps each value onto an (x, y) coordinate within the padded SVG viewBox. + * The x axis represents progression over time (left = first, right = last); + * the y axis is scaled so the minimum value sits near the bottom and the + * maximum near the top (lower values appear lower on the chart). + * + * @param {number[]} values - Ordered array of numeric metric values to plot. + * @returns {string} Space-separated `"x,y"` coordinate pairs, or `""` when + * the array is empty or falsy. + */ +export function buildPolylinePoints(values) { + if (!values || values.length === 0) { + return ''; + } + + const min = Math.min(...values); + const max = Math.max(...values); + const span = Math.max(max - min, 1); + const denominator = Math.max(values.length - 1, 1); + + return values.map((value, index) => { + const x = CHART_PAD + ((CHART_WIDTH - CHART_PAD * 2) * index) / denominator; + const normalized = (value - min) / span; + const y = CHART_HEIGHT - CHART_PAD - normalized * (CHART_HEIGHT - CHART_PAD * 2); + return `${x.toFixed(2)},${y.toFixed(2)}`; + }).join(' '); +} + +/** + * Render (or update) a speed/difficulty trend chart with the supplied history. + * + * Accepts a plain object of nullable element references so callers can pass + * whatever subset of the chart elements they have wired up without crashing. + * + * @param {{ + * lineEl: SVGPolylineElement|null, + * emptyEl: HTMLElement|null, + * latestEl: HTMLElement|null, + * }} els - References to the chart's key DOM nodes. + * @param {number[]} values - Ordered array of numeric metric values to plot. + * @param {number|string} currentValue - The value to display when `values` is + * empty (typically the metric's starting/default value). + */ +export function renderTrendChart(els, values, currentValue) { + const latest = values.length > 0 ? values[values.length - 1] : currentValue; + + if (els.latestEl) { + els.latestEl.textContent = latest != null ? String(latest) : ''; + } + + if (!els.lineEl) return; + + const points = buildPolylinePoints(values); + els.lineEl.setAttribute('points', points); + + if (els.emptyEl) { + els.emptyEl.hidden = points.length > 0; + } +} diff --git a/app/games/directional-processing/game.js b/app/games/directional-processing/game.js index 6faa04d..66e79d4 100644 --- a/app/games/directional-processing/game.js +++ b/app/games/directional-processing/game.js @@ -68,6 +68,13 @@ let consecutiveCorrect = 0; /** @type {number} */ let consecutiveWrong = 0; +/** + * Session history of display durations in ms, one entry per completed trial. + * Used to render the in-game speed trend chart. + * @type {number[]} + */ +let speedHistory = []; + // ── Exported functions ──────────────────────────────────────────────────────── /** @@ -82,6 +89,7 @@ export function initGame() { trialsCompleted = 0; consecutiveCorrect = 0; consecutiveWrong = 0; + speedHistory = []; } /** @@ -157,6 +165,8 @@ export function recordTrial({ success }) { } } + speedHistory.push(LEVELS[currentLevel].displayDurationMs); + return { level: currentLevel, consecutiveCorrect, consecutiveWrong }; } @@ -222,3 +232,13 @@ export function getConsecutiveWrong() { export function isRunning() { return running; } + +/** + * Get the session speed history as an array of display durations in ms. + * One entry is appended per completed trial after any staircase adjustment. + * + * @returns {number[]} + */ +export function getSpeedHistory() { + return [...speedHistory]; +} diff --git a/app/games/directional-processing/index.js b/app/games/directional-processing/index.js index ad023d7..dfec98a 100644 --- a/app/games/directional-processing/index.js +++ b/app/games/directional-processing/index.js @@ -16,6 +16,7 @@ import { playFeedbackSound } from '../../components/audioService.js'; import { returnToMainMenu } from '../../components/gameUtils.js'; import { saveScore } from '../../components/scoreService.js'; import * as timerService from '../../components/timerService.js'; +import { renderTrendChart } from '../../components/trendChartService.js'; /** Game identifier used for progress persistence (must match manifest.json id). */ const GAME_ID = 'directional-processing'; @@ -57,6 +58,12 @@ let _trialsEl = null; let _streakEl = null; /** @type {HTMLElement|null} */ let _sessionTimerEl = null; +/** @type {SVGPolylineElement|null} */ +let _trendLineEl = null; +/** @type {HTMLElement|null} */ +let _trendEmptyEl = null; +/** @type {HTMLElement|null} */ +let _trendLatestEl = null; /** @type {HTMLElement|null} */ let _finalLevelEl = null; /** @type {HTMLElement|null} */ @@ -178,6 +185,17 @@ export function updateStats() { if (_streakEl) _streakEl.textContent = String(streak); } +/** + * Render the speed trend chart with the latest display-duration history. + */ +export function updateTrendChart() { + renderTrendChart( + { lineEl: _trendLineEl, emptyEl: _trendEmptyEl, latestEl: _trendLatestEl }, + game.getSpeedHistory(), + game.getCurrentLevelConfig().displayDurationMs, + ); +} + /** * Apply a brief colored flash to the stage to indicate correct/incorrect. * @@ -328,6 +346,7 @@ export function handleDirectionResponse(direction) { game.recordTrial({ success }); updateStats(); + updateTrendChart(); playFeedbackSound(success); flashStageFeedback(success); @@ -419,6 +438,9 @@ function init(gameContainer) { _trialsEl = _container.querySelector('#dp-trials'); _streakEl = _container.querySelector('#dp-streak'); _sessionTimerEl = _container.querySelector('#dp-session-timer'); + _trendLineEl = _container.querySelector('#dp-trend-line'); + _trendEmptyEl = _container.querySelector('#dp-trend-empty'); + _trendLatestEl = _container.querySelector('#dp-trend-latest'); _finalLevelEl = _container.querySelector('#dp-final-level'); _finalScoreEl = _container.querySelector('#dp-final-score'); _finalTrialsEl = _container.querySelector('#dp-final-trials'); @@ -530,6 +552,7 @@ function reset() { clearDirectionHighlights(); setDirectionButtonsEnabled(false); updateStats(); + updateTrendChart(); } export default { diff --git a/app/games/directional-processing/interface.html b/app/games/directional-processing/interface.html index b39956c..71c7a8b 100644 --- a/app/games/directional-processing/interface.html +++ b/app/games/directional-processing/interface.html @@ -69,6 +69,19 @@

How to Play

+ +
+

Speed Trend (ms)

+ + + +

Complete a trial to populate chart.

+

+ Latest: -- ms +

+
+
+ +
+

Speed Trend (ms)

+ + + +

Complete a round to populate chart.

+

+ Latest: -- ms +

+
+