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
152 changes: 152 additions & 0 deletions app/components/tests/trendChartService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/** @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', () => {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three tests for blank inputs is overkill in this context. [] and null seem more than sufficient.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the undefined test in 7b62a17. The [] and null tests are kept since they exercise different branches of if (!values || values.length === 0).

expect(buildPolylinePoints([])).toBe('');
});

test('returns empty string for null input', () => {
expect(buildPolylinePoints(null)).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('');
});
});
84 changes: 84 additions & 0 deletions app/components/trendChartService.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
20 changes: 20 additions & 0 deletions app/games/directional-processing/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────

/**
Expand All @@ -82,6 +89,7 @@ export function initGame() {
trialsCompleted = 0;
consecutiveCorrect = 0;
consecutiveWrong = 0;
speedHistory = [];
}

/**
Expand Down Expand Up @@ -157,6 +165,8 @@ export function recordTrial({ success }) {
}
}

speedHistory.push(LEVELS[currentLevel].displayDurationMs);

return { level: currentLevel, consecutiveCorrect, consecutiveWrong };
}

Expand Down Expand Up @@ -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];
}
23 changes: 23 additions & 0 deletions app/games/directional-processing/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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} */
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -328,6 +346,7 @@ export function handleDirectionResponse(direction) {
game.recordTrial({ success });

updateStats();
updateTrendChart();
playFeedbackSound(success);
flashStageFeedback(success);

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -530,6 +552,7 @@ function reset() {
clearDirectionHighlights();
setDirectionButtonsEnabled(false);
updateStats();
updateTrendChart();
}

export default {
Expand Down
13 changes: 13 additions & 0 deletions app/games/directional-processing/interface.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ <h3>How to Play</h3>
<!-- Live feedback region (correct / incorrect). -->
<div id="dp-feedback" class="dp-feedback" role="status" aria-live="polite"></div>

<!-- Speed trend chart -->
<section class="game-trend" aria-labelledby="dp-trend-title">
<h4 id="dp-trend-title">Speed Trend (ms)</h4>
<svg id="dp-trend-chart" class="game-trend__chart" viewBox="0 0 300 120" role="img"
aria-label="Display time trend over recent trials">
<polyline id="dp-trend-line" fill="none" stroke="currentColor" stroke-width="2" points=""></polyline>
</svg>
<p id="dp-trend-empty" class="game-trend__empty">Complete a trial to populate chart.</p>
<p class="game-trend__meta">
Latest: <strong id="dp-trend-latest">--</strong> ms
</p>
</section>

<div class="dp-controls">
<button id="dp-stop-btn" type="button" class="game-btn game-btn--secondary">
End Game
Expand Down
Loading
Loading