AI Implementation feature(912): Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.02 (#55)

This commit was merged in pull request #55.
This commit is contained in:
2026-07-23 06:40:47 +00:00
parent a39a7daee8
commit 705530e71f
7 changed files with 429 additions and 232 deletions
@@ -0,0 +1,173 @@
jest.mock('@angular/common/http', () => ({
HttpClient: class {},
HttpErrorResponse: class {},
}));
import {
GraphProjectionDims,
GraphSeries,
isValidEventWindow,
projectGraphPoints,
} from '../../frontend/src/app/features/scoreboard/scoreboard.pure';
const DIMS: GraphProjectionDims = {
width: 800,
height: 320,
padL: 40,
padR: 16,
padT: 16,
padB: 28,
};
const innerW = DIMS.width - DIMS.padL - DIMS.padR;
const innerH = DIMS.height - DIMS.padT - DIMS.padB;
const xMin = DIMS.padL;
const xMax = DIMS.padL + innerW;
const yMin = DIMS.padT;
const yMax = DIMS.padT + innerH;
function parsePoints(str: string): Array<{ x: number; y: number }> {
if (!str) return [];
return str.split(' ').map((pair) => {
const [xs, ys] = pair.split(',');
return { x: Number(xs), y: Number(ys) };
});
}
describe('isValidEventWindow', () => {
it('returns false when end is before start', () => {
expect(
isValidEventWindow('2026-07-23T00:00:00.000Z', '2026-07-22T00:00:00.000Z'),
).toBe(false);
});
it('returns false when start equals end', () => {
expect(
isValidEventWindow('2026-07-23T00:00:00.000Z', '2026-07-23T00:00:00.000Z'),
).toBe(false);
});
it('returns false for non-finite Date strings', () => {
expect(isValidEventWindow('not a date', '2026-07-22T00:00:00.000Z')).toBe(false);
expect(isValidEventWindow('2026-07-23T00:00:00.000Z', 'garbage')).toBe(false);
});
it('returns false for null/undefined', () => {
expect(isValidEventWindow(null, '2026-07-22T00:00:00.000Z')).toBe(false);
expect(isValidEventWindow('2026-07-23T00:00:00.000Z', undefined)).toBe(false);
});
it('returns true only when end is strictly after start', () => {
expect(
isValidEventWindow('2026-07-22T00:00:00.000Z', '2026-07-23T00:00:00.000Z'),
).toBe(true);
});
});
describe('projectGraphPoints — reversed/invalid window (Job 912 repro)', () => {
const view = {
startUtc: '2026-07-23T00:00:00.000Z',
endUtc: '2026-07-22T00:00:00.000Z',
};
const series: GraphSeries = {
playerId: 'p1',
playerName: 'p1',
colorIndex: 0,
points: [
{ tUtc: '2026-07-22T12:00:00.000Z', value: 0 },
{ tUtc: '2026-07-22T18:00:00.000Z', value: 100 },
],
};
it('returns empty string when endUtc is before startUtc (no off-canvas polyline)', () => {
expect(projectGraphPoints(view, series, DIMS)).toBe('');
});
it('renders zero series for the component when the event window is reversed', () => {
const out = projectGraphPoints(view, series, DIMS);
expect(parsePoints(out)).toEqual([]);
});
});
describe('projectGraphPoints — happy path', () => {
const view = {
startUtc: '2025-01-01T00:00:00.000Z',
endUtc: '2025-01-02T00:00:00.000Z',
};
const series: GraphSeries = {
playerId: 'p1',
playerName: 'p1',
colorIndex: 0,
points: [
{ tUtc: '2025-01-01T00:00:00.000Z', value: 0 },
{ tUtc: '2025-01-01T12:00:00.000Z', value: 50 },
{ tUtc: '2025-01-02T00:00:00.000Z', value: 100 },
],
};
it('anchors the first point at padL and keeps every X inside the plot area', () => {
const out = projectGraphPoints(view, series, DIMS);
const pts = parsePoints(out);
expect(pts.length).toBe(3);
pts.forEach((p) => {
expect(Number.isFinite(p.x)).toBe(true);
expect(p.x).toBeGreaterThanOrEqual(xMin);
expect(p.x).toBeLessThanOrEqual(xMax);
expect(p.y).toBeGreaterThanOrEqual(yMin);
expect(p.y).toBeLessThanOrEqual(yMax);
});
expect(pts[0].x).toBeCloseTo(xMin, 2);
expect(pts[pts.length - 1].x).toBeLessThanOrEqual(xMax + 1e-6);
});
it('does not regress to the previous off-canvas X values', () => {
const out = projectGraphPoints(view, series, DIMS);
expect(out).not.toMatch(/16965896296/);
expect(out).not.toMatch(/-64281599960/);
expect(out).not.toMatch(/NaN/);
expect(out).not.toMatch(/Infinity/);
});
});
describe('projectGraphPoints — zero-span and invalid timestamps', () => {
it('returns empty string when startUtc equals endUtc', () => {
const view = {
startUtc: '2025-01-01T00:00:00.000Z',
endUtc: '2025-01-01T00:00:00.000Z',
};
const series: GraphSeries = {
playerId: 'p1',
playerName: 'p1',
colorIndex: 0,
points: [{ tUtc: '2025-01-01T00:00:00.000Z', value: 10 }],
};
expect(projectGraphPoints(view, series, DIMS)).toBe('');
});
it('produces only finite, in-bounds coordinates when a point timestamp is invalid', () => {
const view = {
startUtc: '2025-01-01T00:00:00.000Z',
endUtc: '2025-01-02T00:00:00.000Z',
};
const series: GraphSeries = {
playerId: 'p1',
playerName: 'p1',
colorIndex: 0,
points: [
{ tUtc: 'not-a-date', value: 50 },
{ tUtc: '2025-01-01T12:00:00.000Z', value: 100 },
],
};
const out = projectGraphPoints(view, series, DIMS);
const pts = parsePoints(out);
expect(pts.length).toBe(2);
pts.forEach((p) => {
expect(Number.isFinite(p.x)).toBe(true);
expect(Number.isFinite(p.y)).toBe(true);
expect(p.x).toBeGreaterThanOrEqual(xMin);
expect(p.x).toBeLessThanOrEqual(xMax);
expect(p.y).toBeGreaterThanOrEqual(yMin);
expect(p.y).toBeLessThanOrEqual(yMax);
});
});
});