S6 (a1): display — the full picture under the grammar
- 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>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
// real feed values; the refresh recaptures the close.
|
||||
|
||||
const { runIntradayRefresh, inSlateHours, __internals } = require('../../src/services/intradayRefreshService');
|
||||
const { signedDelta } = __internals;
|
||||
const { signedDelta, trackHistory, HISTORY_CAP } = __internals;
|
||||
|
||||
const NOW = '2026-07-11T20:00:00.000Z';
|
||||
|
||||
@@ -125,6 +125,67 @@ describe('runIntradayRefresh — directional handling', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// S6 (A1 board) — sparkline fuel: {t, line} history capture on each grade's
|
||||
// movement tracking, persisted inside the snapshot write-back (no new keys).
|
||||
describe('trackHistory — real {t, line} points, seeded, deduped, capped', () => {
|
||||
const LOCK_TS = '2026-07-11T14:00:00.000Z';
|
||||
const g = (extra = {}) => ({ ...GRADE, gradedAt: { line: 0.5, odds: -115, timestamp: LOCK_TS }, ...extra });
|
||||
|
||||
test('first capture seeds the LOCKED line at its own timestamp, then the move', () => {
|
||||
const h = trackHistory(g(), 1.5, NOW);
|
||||
expect(h).toEqual([{ t: LOCK_TS, line: 0.5 }, { t: NOW, line: 1.5 }]);
|
||||
});
|
||||
|
||||
test('a flat line never appends a duplicate point (a point means it MOVED)', () => {
|
||||
const h = trackHistory(g(), 0.5, NOW); // current == locked
|
||||
expect(h).toEqual([{ t: LOCK_TS, line: 0.5 }]);
|
||||
const again = trackHistory(g({ history: h }), 0.5, '2026-07-11T21:00:00.000Z');
|
||||
expect(again).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('an unparseable current line appends nothing (real points only)', () => {
|
||||
expect(trackHistory(g(), null, NOW)).toBeUndefined();
|
||||
const prior = [{ t: LOCK_TS, line: 0.5 }];
|
||||
expect(trackHistory(g({ history: prior }), 'n/a', NOW)).toBe(prior);
|
||||
});
|
||||
|
||||
test('capped at 24 points, newest kept', () => {
|
||||
const prior = Array.from({ length: HISTORY_CAP }, (_, i) => ({ t: `t${i}`, line: i }));
|
||||
const h = trackHistory(g({ history: prior }), 99, NOW);
|
||||
expect(h).toHaveLength(HISTORY_CAP);
|
||||
expect(h[HISTORY_CAP - 1]).toEqual({ t: NOW, line: 99 });
|
||||
expect(h[0]).toEqual({ t: 't1', line: 1 }); // oldest point aged out
|
||||
});
|
||||
});
|
||||
|
||||
describe('runIntradayRefresh — history rides the snapshot write-back', () => {
|
||||
test('a steam move persists history on the grade (locked point + current)', async () => {
|
||||
const { deps, store } = harness({ grades: [GRADE], oddsProps: [prop(1.5)], analyzeResult: null });
|
||||
await runIntradayRefresh('mlb', deps);
|
||||
const snap = store.get('snapshot:mlb:latest');
|
||||
expect(snap.grades[0].history).toEqual([
|
||||
{ t: NOW, line: 0.5 }, // seeded from the lock (gradedAt.timestamp === NOW here)
|
||||
{ t: NOW, line: 1.5 },
|
||||
]);
|
||||
// grades:{sport} carries the same enriched grades.
|
||||
expect(store.get('grades:mlb').grades[0].history).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a flat run keeps a single seeded point — the sparkline stays hidden (<3)', async () => {
|
||||
const { deps, store } = harness({ grades: [GRADE], oddsProps: [prop(0.5)], analyzeResult: null });
|
||||
await runIntradayRefresh('mlb', deps);
|
||||
expect(store.get('snapshot:mlb:latest').grades[0].history).toEqual([{ t: NOW, line: 0.5 }]);
|
||||
});
|
||||
|
||||
test('a prop gone from the feed leaves prior history untouched', async () => {
|
||||
const withHist = { ...GRADE, stat_type: 'hits', history: [{ t: 'x', line: 1 }] };
|
||||
const { deps, store } = harness({ grades: [withHist], oddsProps: [prop(1.5)], analyzeResult: null });
|
||||
await runIntradayRefresh('mlb', deps);
|
||||
const frozen = store.get('snapshot:mlb:latest').grades.find((g) => g.stat_type === 'hits');
|
||||
expect(frozen.history).toEqual([{ t: 'x', line: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inSlateHours — noon–midnight ET', () => {
|
||||
test('3 PM ET yes, 4 AM ET no', () => {
|
||||
expect(inSlateHours(new Date('2026-07-11T19:00:00Z'))).toBe(true); // 3 PM EDT
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -271,3 +271,64 @@ describe('indexProps — prefers a book row with both sides priced', () => {
|
||||
expect(idx['a guy|hits'].book).toBe('fanduel');
|
||||
});
|
||||
});
|
||||
|
||||
// S6 (A1 board) — CLV distribution on the model aggregate. The n>=20 gate is
|
||||
// centralized HERE (getModelAggregate) — consumers never re-derive it.
|
||||
describe('getModelAggregate — clv_distribution (n>=20 gate lives in the service)', () => {
|
||||
const { clvBucketIndex, CLV_BUCKETS } = ledger.__internals;
|
||||
|
||||
test('below 20 settles → clv_distribution is null (never a small-sample chart)', async () => {
|
||||
const sb = fakeSb();
|
||||
sb._state.selectResults = [
|
||||
Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 0.5 })),
|
||||
];
|
||||
const agg = await ledger.getModelAggregate({ sb });
|
||||
expect(agg.clv_distribution).toBeNull();
|
||||
});
|
||||
|
||||
test('at 20+ settles → seven ordered buckets, counts bucketed by signed clv', async () => {
|
||||
const sb = fakeSb();
|
||||
const rows = [
|
||||
...Array.from({ length: 8 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 0.5 })),
|
||||
...Array.from({ length: 4 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 1.5 })),
|
||||
...Array.from({ length: 5 }, () => ({ outcome: 'miss', clv_result: 'faded', clv: -0.5 })),
|
||||
...Array.from({ length: 2 }, () => ({ outcome: 'miss', clv_result: 'flat', clv: 0 })),
|
||||
{ outcome: 'push', clv_result: 'faded', clv: -3 }, // outlier clamps into the edge bucket
|
||||
];
|
||||
sb._state.selectResults = [rows];
|
||||
const agg = await ledger.getModelAggregate({ sb });
|
||||
const dist = agg.clv_distribution;
|
||||
expect(dist).toHaveLength(7);
|
||||
expect(dist.map((b) => b.label)).toEqual(CLV_BUCKETS.map((b) => b.label));
|
||||
expect(dist[0]).toMatchObject({ side: 'faded', count: 1 }); // [-2,-1) — clamped -3
|
||||
expect(dist[2]).toMatchObject({ side: 'faded', count: 5 }); // [-.5,0) — -0.5 closed on the left
|
||||
expect(dist[1]).toMatchObject({ side: 'faded', count: 0 }); // [-1,-.5) — empty
|
||||
expect(dist[3]).toMatchObject({ side: 'flat', count: 2 }); // exactly 0
|
||||
expect(dist[4]).toMatchObject({ side: 'beat', count: 8 }); // (0,.5]
|
||||
expect(dist[6]).toMatchObject({ side: 'beat', count: 4 }); // (1,2] — 1.5
|
||||
});
|
||||
|
||||
test('rows without a settled clv value never fabricate a bucket', async () => {
|
||||
const sb = fakeSb();
|
||||
sb._state.selectResults = [
|
||||
Array.from({ length: 20 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: null })),
|
||||
];
|
||||
const agg = await ledger.getModelAggregate({ sb });
|
||||
expect(agg.clv_distribution).toBeNull(); // no real clv values → nothing
|
||||
});
|
||||
|
||||
test('clvBucketIndex edges: boundaries land per the spec intervals', () => {
|
||||
expect(clvBucketIndex(0)).toBe(3);
|
||||
expect(clvBucketIndex(0.5)).toBe(4); // (0,.5] closed on the right
|
||||
expect(clvBucketIndex(0.51)).toBe(5);
|
||||
expect(clvBucketIndex(1)).toBe(5); // (.5,1]
|
||||
expect(clvBucketIndex(1.01)).toBe(6);
|
||||
expect(clvBucketIndex(-0.5)).toBe(2); // [-.5,0) closed on the left
|
||||
expect(clvBucketIndex(-0.51)).toBe(1); // [-1,-.5)
|
||||
expect(clvBucketIndex(-1)).toBe(1); // [-1,-.5) closed on the left
|
||||
expect(clvBucketIndex(-1.01)).toBe(0); // [-2,-1)
|
||||
expect(clvBucketIndex(-9)).toBe(0); // clamp
|
||||
expect(clvBucketIndex(9)).toBe(6); // clamp
|
||||
expect(clvBucketIndex(null)).toBe(-1); // absent beats wrong
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// S6 (A1 board) — ROW-GRAMMAR source locks (specs/ROW-GRAMMAR.md).
|
||||
// Density is unlimited when grammar is fixed: every element is DATA with ONE
|
||||
// meaning in the SAME slot on every row of its type. These tests fail if a
|
||||
// slot moves, a color changes meaning, or truncation sneaks onto a name.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
|
||||
const strip = read('components/vyndr/StatStrip.tsx');
|
||||
const spec = fs.readFileSync(path.join(__dirname, '..', '..', 'specs', 'ROW-GRAMMAR.md'), 'utf8');
|
||||
|
||||
/** Assert `markers` appear in this exact order in `src`, starting at `from`. */
|
||||
function assertOrder(src, markers, from = 0) {
|
||||
let cursor = from;
|
||||
for (const m of markers) {
|
||||
const i = src.indexOf(m, cursor);
|
||||
expect({ marker: m, found: i >= 0 }).toEqual({ marker: m, found: true });
|
||||
cursor = i + m.length;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
describe('ROW-GRAMMAR §2 — canonical prop-row slot order (snapshot mode)', () => {
|
||||
test('stat+line → market context (dot, movement) → model output (revision, grade) → outcome → actions → provenance', () => {
|
||||
// Anchor on the graded row's stat+side+line span (unique to it).
|
||||
const base = strip.indexOf("<span style={{ color: '#fff' }}>{p.stat} {p.side}{p.line}</span>");
|
||||
expect(base).toBeGreaterThan(-1);
|
||||
assertOrder(strip, [
|
||||
'<BestPriceDot p={p} />', // slot 4a — best available price
|
||||
'<MovementChip p={p} />', // slot 4b — market movement
|
||||
'p.revisedFrom', // slot 5a — revision strikethrough
|
||||
'<GradeBadge grade={p.grade}', // slot 5b — the current grade
|
||||
'<OutcomeChip p={p} />', // slot 6 — settled result
|
||||
'<ParlayBtn p={p} />', // slot 7a — action
|
||||
'<BookItTeaser p={p} />', // slot 7b — action
|
||||
'Graded {p.gradedAt.ago}', // slot 8 — provenance, always last
|
||||
], base);
|
||||
});
|
||||
|
||||
test('sub-line order: last-10 dots → sparkline → current-line delta', () => {
|
||||
const base = strip.indexOf('ROW-GRAMMAR sub-line');
|
||||
expect(base).toBeGreaterThan(-1);
|
||||
assertOrder(strip, [
|
||||
'<DotStrip dots={p.last10Dots} />',
|
||||
'<LineSparkline p={p} />',
|
||||
'Current {p.delta.currentLine}',
|
||||
], base);
|
||||
});
|
||||
|
||||
test('strip header: identity run (name → team → archetype) then viability chips', () => {
|
||||
const base = strip.indexOf('// compact');
|
||||
expect(base).toBeGreaterThan(-1);
|
||||
assertOrder(strip, [
|
||||
'<PlayerName',
|
||||
'{team}</span>',
|
||||
'<ArchetypeBadge archetype={archetype.primary}',
|
||||
'<ViabilityChips lineup={lineup} injury={injury} />',
|
||||
], base);
|
||||
});
|
||||
|
||||
test('settled/dead rows suppress actions (the bet is over)', () => {
|
||||
expect(strip).toContain('{!p.outcome && !p.dead && <ParlayBtn p={p} />}');
|
||||
expect(strip).toContain('{!p.outcome && !p.dead && <BookItTeaser p={p} />}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ROW-GRAMMAR §3 — one meaning per color', () => {
|
||||
const section = (start, end) => {
|
||||
const a = strip.indexOf(start);
|
||||
const b = end ? strip.indexOf(end, a) : strip.length;
|
||||
expect(a).toBeGreaterThan(-1);
|
||||
return strip.slice(a, b === -1 ? strip.length : b);
|
||||
};
|
||||
|
||||
test('sparkline is green/amber/dim — never red (nothing has settled)', () => {
|
||||
const src = section('export function LineSparkline', 'export function ViabilityChips');
|
||||
expect(src).toContain("'var(--g-a)'");
|
||||
expect(src).toContain("'var(--amber)'");
|
||||
expect(src).not.toContain('--miss');
|
||||
});
|
||||
|
||||
test('dot strip: filled = signal green, hollow = dim (never red/amber)', () => {
|
||||
const src = section('export function DotStrip', 'export function LineSparkline');
|
||||
expect(src).toContain('var(--g-a');
|
||||
expect(src).toContain('var(--text-2');
|
||||
expect(src).not.toContain('--miss');
|
||||
expect(src).not.toContain('--amber');
|
||||
});
|
||||
|
||||
test('movement chips keep STEAM amber / VALUE green (QA.20 family)', () => {
|
||||
const src = section('function MovementChip', 'export interface StripArchetype');
|
||||
expect(src).toContain("steam ? 'var(--amber");
|
||||
expect(src).not.toContain('--miss');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ROW-GRAMMAR §1.4 — no truncation of names/times/pitchers', () => {
|
||||
test('StatStrip never ellipsizes', () => {
|
||||
expect(strip).not.toMatch(/textOverflow|text-overflow|ellipsis/);
|
||||
});
|
||||
test('SearchModal result names wrap, never truncate', () => {
|
||||
const sm = read('components/vyndr/SearchModal.tsx');
|
||||
expect(sm).not.toMatch(/textOverflow|ellipsis/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ROW-GRAMMAR — the law is written down and wired', () => {
|
||||
test('spec exists with the canonical slot order and color law', () => {
|
||||
expect(spec).toContain('CANONICAL PROP-ROW SLOT ORDER');
|
||||
expect(spec).toContain('one meaning per color');
|
||||
expect(spec).toContain('never');
|
||||
expect(spec).not.toMatch(/!/); // VOICE — no exclamation points
|
||||
});
|
||||
test('data marks are mono', () => {
|
||||
// DotStrip + sub-line render with the mono class (data never sans).
|
||||
const dot = strip.slice(strip.indexOf('export function DotStrip'), strip.indexOf('export function LineSparkline'));
|
||||
expect(dot).toContain('className="mono"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// S6 (A1 board) — global search: ⌘K modal (players + teams) + mobile entry
|
||||
// points. Source locks + the static team registry's honesty rules.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
const { TEAMS, searchTeams, teamHref } = require('../../web/src/lib/teams');
|
||||
|
||||
describe('lib/teams — the static registry', () => {
|
||||
test('full leagues: 30 MLB, 30 NBA, 13 WNBA — soccer deliberately absent', () => {
|
||||
const by = (sp) => TEAMS.filter((t) => t.sport === sp);
|
||||
expect(by('mlb')).toHaveLength(30);
|
||||
expect(by('nba')).toHaveLength(30);
|
||||
expect(by('wnba')).toHaveLength(13);
|
||||
expect(by('soccer')).toHaveLength(0); // no canonical registry → absent beats wrong
|
||||
});
|
||||
|
||||
test('no duplicate abbr within a sport; every entry has name + abbr', () => {
|
||||
const seen = new Set();
|
||||
for (const t of TEAMS) {
|
||||
expect(Boolean(t.name && t.abbr && t.sport)).toBe(true);
|
||||
const k = `${t.sport}|${t.abbr}`;
|
||||
expect(seen.has(k)).toBe(false);
|
||||
seen.add(k);
|
||||
}
|
||||
});
|
||||
|
||||
test('searchTeams matches name substring, mascot prefix, and abbr prefix', () => {
|
||||
expect(searchTeams('yank')[0].name).toBe('New York Yankees');
|
||||
expect(searchTeams('NYY')[0].abbr).toBe('NYY');
|
||||
expect(searchTeams('tigers')[0].name).toBe('Detroit Tigers');
|
||||
expect(searchTeams('q')).toEqual([]); // under 2 chars → nothing
|
||||
expect(searchTeams('zzzz')).toEqual([]);
|
||||
});
|
||||
|
||||
test('teamHref → the S51 Team Hub route', () => {
|
||||
expect(teamHref({ abbr: 'DET', sport: 'mlb' })).toBe('/team/DET?sport=mlb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchModal — grouped results, keyboard nav, canonical links', () => {
|
||||
const src = read('components/vyndr/SearchModal.tsx');
|
||||
|
||||
test('players come from /api/players/search per sport (the canonical resolver)', () => {
|
||||
expect(src).toContain('/api/players/search?sport=');
|
||||
expect(src).toContain("['MLB', 'NBA', 'WNBA']");
|
||||
});
|
||||
test('grouped PLAYERS / TEAMS with arrow-key nav and Enter-to-open', () => {
|
||||
expect(src).toContain('"PLAYERS"');
|
||||
expect(src).toContain('"TEAMS"');
|
||||
expect(src).toContain("e.key === 'ArrowDown'");
|
||||
expect(src).toContain("e.key === 'ArrowUp'");
|
||||
expect(src).toContain("e.key === 'Enter'");
|
||||
expect(src).toContain("e.key === 'Escape'");
|
||||
});
|
||||
test('destinations are the canonical builders (playerHref / teamHref)', () => {
|
||||
expect(src).toContain("from '@/lib/playerHref'");
|
||||
expect(src).toContain('playerHref(name, res.sport)');
|
||||
expect(src).toContain('teamHref(t)');
|
||||
});
|
||||
test('debounced + stale-response guarded (never a flood, never a regression paint)', () => {
|
||||
expect(src).toContain('250');
|
||||
expect(src).toContain('seqRef');
|
||||
});
|
||||
});
|
||||
|
||||
describe('entry points — ⌘K, Nav icon, mobile More sheet', () => {
|
||||
test('GlobalHosts registers window.__search and the ⌘K/Ctrl-K shortcut', () => {
|
||||
const src = read('components/vyndr/GlobalHosts.tsx');
|
||||
expect(src).toContain('window.__search');
|
||||
expect(src).toContain('e.metaKey || e.ctrlKey');
|
||||
expect(src).toContain("=== 'k'");
|
||||
expect(src).toContain('<SearchModal');
|
||||
expect(src).toContain("removeEventListener('keydown'");
|
||||
});
|
||||
|
||||
test('Nav keeps a search icon button (the mobile/mouse path)', () => {
|
||||
const src = read('components/Nav.tsx');
|
||||
expect(src).toContain('window.__search && window.__search()');
|
||||
expect(src).toContain('aria-label="Search players and teams (⌘K)"');
|
||||
});
|
||||
|
||||
test('BottomTabBar: Search is the FIRST item in the More sheet; Explore tab stays', () => {
|
||||
const src = read('components/BottomTabBar.tsx');
|
||||
const sheetSearch = src.indexOf('window.__search) window.__search()');
|
||||
const items = src.indexOf('{MORE_ITEMS.map((it) =>');
|
||||
expect(sheetSearch).toBeGreaterThan(-1);
|
||||
expect(items).toBeGreaterThan(sheetSearch); // Search renders before the item list
|
||||
expect(src).toContain("{ id: 'explore', label: 'Explore', href: '/explore'"); // tab NOT replaced
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user