d3637e7abd
- specs/ROW-GRAMMAR.md: the row grammar law (slot order, color law, mark
law, mobile stacking, no-truncation), locked by tests/unit/rowGrammar.
StatStrip violations fixed: MovementChip before the grade (market
context before model output); ViabilityChips after the archetype
(identity is one contiguous run).
- Line-movement sparklines: intradayRefreshService.trackHistory captures
real {t,line} points per grade (seeded with the lock, deduped when
flat, capped 24) inside the snapshot write-back; StatStrip.LineSparkline
renders at >=3 points (green toward / amber against / dim flat).
- Last-10 dot strips: services/last10Dots (streaksService accessors) ->
/api/snapshot/:sport attaches last10_dots from rosterlogs:{sport};
StatStrip.DotStrip renders vs the LOCKED line, newest first.
- CLV distribution: getModelAggregate emits clv_distribution (7 signed
buckets, outliers clamped) only past the centralized n>=20 gate;
ledger MODEL header renders the bar strip.
- Global search: SearchModal (cmd-K via GlobalHosts + window.__search),
players via /api/players/search per sport + static lib/teams.js
(soccer deliberately absent); Nav search icon + Search first in the
mobile More sheet. Explore tab untouched.
- Landing LCP: fade-up floored at opacity .6 (hero h1 contentful on
first frame, S33 visible-floor rule) + IBM Plex Mono preload:false
(4 decorative font files off the slow-4G critical path).
2654 -> 2698 tests (226 suites) green; web build exit 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
97 lines
4.3 KiB
JavaScript
97 lines
4.3 KiB
JavaScript
// S6 (A1 board) — ●●○●● last-10 vs tonight's locked line (ROW-GRAMMAR §4).
|
|
// Pure math over the rosterlogs blob; stat access reuses streaksService's
|
|
// accessors (one source of truth for every field spelling). Real logs only.
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { computeLast10Dots, indexRosterLogs, attachLast10Dots, __internals } = require('../../src/services/last10Dots');
|
|
|
|
describe('computeLast10Dots', () => {
|
|
const games = [
|
|
{ date: '2026-07-10', hits: 2, totalBases: 5 },
|
|
{ date: '2026-07-09', hits: 0, totalBases: 0 },
|
|
{ date: '2026-07-08', hits: 1, totalBases: 2 },
|
|
];
|
|
|
|
test('hit = value ≥ line, boolean per game, newest first', () => {
|
|
expect(computeLast10Dots(games, 'mlb', 'hits', 1.5)).toEqual([true, false, false]);
|
|
expect(computeLast10Dots(games, 'mlb', 'total_bases', 1.5)).toEqual([true, false, true]);
|
|
});
|
|
|
|
test('tolerates the alternate field spellings (streaksService accessors)', () => {
|
|
const alt = [{ H: 3 }, { h: 0 }];
|
|
expect(computeLast10Dots(alt, 'mlb', 'hits', 0.5)).toEqual([true, false]);
|
|
});
|
|
|
|
test('nba/wnba stats work too (points, pra)', () => {
|
|
const log = [{ pts: 31, reb: 8, ast: 7 }, { pts: 12, reb: 2, ast: 1 }];
|
|
expect(computeLast10Dots(log, 'wnba', 'points', 20.5)).toEqual([true, false]);
|
|
expect(computeLast10Dots(log, 'nba', 'pra', 40.5)).toEqual([true, false]);
|
|
});
|
|
|
|
test('caps at 10 games', () => {
|
|
const long = Array.from({ length: 14 }, (_, i) => ({ hits: i % 2 }));
|
|
expect(computeLast10Dots(long, 'mlb', 'hits', 0.5)).toHaveLength(10);
|
|
});
|
|
|
|
test('absent → nothing: unknown stat, no games, bad line all return null', () => {
|
|
expect(computeLast10Dots(games, 'mlb', 'exit_velocity', 1.5)).toBeNull();
|
|
expect(computeLast10Dots([], 'mlb', 'hits', 1.5)).toBeNull();
|
|
expect(computeLast10Dots(games, 'mlb', 'hits', null)).toBeNull();
|
|
expect(computeLast10Dots(games, 'unknown_sport', 'hits', 1.5)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('indexRosterLogs + attachLast10Dots', () => {
|
|
const blob = [
|
|
{ name: 'A.J. Ewing', team: 'NYY', games: [{ hits: 2 }, { hits: 0 }, { hits: 1 }] },
|
|
{ name: 'No Games Guy', team: 'BOS', games: [] },
|
|
];
|
|
|
|
test('indexes by nameKey (variants merge) and skips empty logs', () => {
|
|
const idx = indexRosterLogs(blob);
|
|
expect(idx['aj ewing']).toHaveLength(3);
|
|
expect(Object.keys(idx)).toHaveLength(1);
|
|
});
|
|
|
|
test('attaches last10_dots against the LOCKED line; untouched when absent', () => {
|
|
const idx = indexRosterLogs(blob);
|
|
const grades = [
|
|
{ player: 'AJ Ewing', stat_type: 'hits', line: 2.5, gradedAt: { line: 0.5 }, grade: 'A' },
|
|
{ player: 'Someone Else', stat_type: 'hits', line: 0.5, grade: 'B' },
|
|
{ player: 'AJ Ewing', stat_type: 'exit_velocity', line: 90, grade: 'C' },
|
|
];
|
|
const out = attachLast10Dots(grades, idx, 'mlb');
|
|
// locked line (0.5) is used, not the current line (2.5).
|
|
expect(out[0].last10_dots).toEqual([true, false, true]);
|
|
expect(out[1].last10_dots).toBeUndefined(); // no log → nothing
|
|
expect(out[2].last10_dots).toBeUndefined(); // no accessor → nothing
|
|
expect(out[1]).toBe(grades[1]); // pass-through untouched
|
|
});
|
|
|
|
test('empty roster index is a no-op', () => {
|
|
const grades = [{ player: 'AJ Ewing', stat_type: 'hits', line: 0.5 }];
|
|
expect(attachLast10Dots(grades, {}, 'mlb')).toBe(grades);
|
|
});
|
|
});
|
|
|
|
describe('wiring locks', () => {
|
|
test('the public snapshot route reads rosterlogs and attaches dots', () => {
|
|
const route = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'routes', 'snapshot.js'), 'utf8');
|
|
expect(route).toContain('rosterlogs:${sport}');
|
|
expect(route).toContain('attachLast10Dots');
|
|
});
|
|
test('the slate adapter threads last10_dots + history onto strip props', () => {
|
|
const adapter = fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', 'lib', 'slateAdapter.js'), 'utf8');
|
|
expect(adapter).toContain('rec.last10_dots');
|
|
expect(adapter).toContain('rec.history');
|
|
});
|
|
test('every accessor is a function (the streaksService contract holds)', () => {
|
|
for (const [sport, map] of Object.entries(__internals.STAT_ACCESSORS)) {
|
|
for (const [stat, fn] of Object.entries(map)) {
|
|
expect({ sport, stat, isFn: typeof fn === 'function' }).toEqual({ sport, stat, isFn: true });
|
|
}
|
|
}
|
|
});
|
|
});
|