-
Notifications
You must be signed in to change notification settings - Fork 0
Add real-time speed trend chart to all games via centralized service #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
96f8f7e
Initial plan
Copilot 32d304b
Add speed history tracking and trend chart to all games; add getSpeed…
Copilot a920d39
Fix renderThresholdTrend to pass raw currentSoaMs to renderTrendChart…
Copilot 7b62a17
Remove redundant blank-input tests per review feedback
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', () => { | ||
| 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(''); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed the
undefinedtest in 7b62a17. The[]andnulltests are kept since they exercise different branches ofif (!values || values.length === 0).