Files
vyndr/tests/unit/pricedLines.test.js
T
builtbykev 4f3f433aae Complete S7 priced-line nudge: freshness on a long-open page + reversible gate
REPORT-FIRST correction. This order's premise — "S7 is a shell that doesn't
update per selection" — is not what the code does. pricedForSelection is a
useMemo on [pricedIndex, selectedPlayer, stat] and setSelectedPlayer/setStat
fire on every user pick, so the chips already update per selection, and the
prior session's verification of that stands. The genuine gap was FRESHNESS: the
snapshot fetch depended on [sport] only, so pricedIndex was fetched once per
sport-change and never refreshed. The pricing cron re-prices at five UTC hours,
so a scanner left open across a cron boundary surfaced hour-stale priced lines.
That is the real defect, and the only one fixed.

FRESHNESS. The fetch is now a refreshPriced callback re-run when the held
snapshot is older than PRICED_STALE_MS (30s, matching the /api/snapshot cache)
at the moment of use — on selection change and on window focus — so a long-open
page never shows a stale line. Sport change still clears the index first, so the
old sport's lines never flash.

STALE-TAP was already safe and is unchanged: the scan submit re-fetches the live
snapshot server-side, so a chip that's gone stale between render and tap either
lands on a real triplet (still priced) or degrades to the honest empty state
(rotated away) — proven in the prior session and re-confirmed here (an
off-snapshot line returns no market and shows the empty state).

REVERSIBLE GATE. The whole nudge sits behind one PRICED_NUDGE_ENABLED flag: false
empties the surfaced set, so the scanner falls back to S6's link-only empty
state with the chips gone. Shipping enabled only after the cases are proven this
session; the flag is the instant revert lever.

DISPLAY-LAYER ONLY. Only scan/page.tsx changed. GradeResultCard, PriceTriplet,
gradeAdapter, valueState, both scan routes and the pure pricedLines helper are
byte-identical — Scan A and the scan-submit resolution are untouched, and the
change is independently revertible.

Tests 3765 passed / 303 suites, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-22 12:09:33 -04:00

115 lines
5.4 KiB
JavaScript

/* ============================================================
Session 79 — priced-line surfacer + no-market empty state.
NON-FABRICATING: only real snapshot lines with a real book price surface.
============================================================ */
const { indexPricedLines, pricedLinesFor } = require('../../web/src/lib/pricedLines');
const GRADES = [
{ player_name: 'Gabriel Arias', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -165, fair_odds: -140 },
{ player_name: 'Ryan Jeffers', stat_type: 'hits', line: 1.5, direction: 'under', book_odds: -160, fair_odds: -135 },
{ player_name: 'Chase DeLauter', stat_type: 'doubles', line: 0.5, direction: 'over', book_odds: 340, fair_odds: 370 },
// no market → must NOT surface
{ player_name: 'No Market Guy', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: null },
];
describe('indexPricedLines — only real, priced rows', () => {
it('indexes a priced row by player+stat', () => {
const idx = indexPricedLines(GRADES);
const rows = pricedLinesFor(idx, 'Gabriel Arias', 'hits');
expect(rows).toEqual([{ line: 0.5, direction: 'over', book_odds: -165 }]);
});
it('EXCLUDES a graded row with no book price — not a surfaceable line', () => {
const idx = indexPricedLines(GRADES);
expect(pricedLinesFor(idx, 'No Market Guy', 'hits')).toEqual([]);
});
it('a DIFFERENT stat priced for the same player returns nothing for the picked stat', () => {
const idx = indexPricedLines(GRADES);
// DeLauter has doubles priced, NOT hits.
expect(pricedLinesFor(idx, 'Chase DeLauter', 'hits')).toEqual([]);
expect(pricedLinesFor(idx, 'Chase DeLauter', 'doubles')).toHaveLength(1);
});
it('an off-slate player returns nothing — never a nearest/suggested line', () => {
const idx = indexPricedLines(GRADES);
expect(pricedLinesFor(idx, 'Aaron Judge', 'hits')).toEqual([]);
});
it('resolves name variants via nameKey (A.J. / AJ)', () => {
const idx = indexPricedLines([{ player_name: 'A.J. Ewing', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -110 }]);
expect(pricedLinesFor(idx, 'AJ Ewing', 'hits')).toHaveLength(1);
});
it('sorts multiple priced lines by line', () => {
const idx = indexPricedLines([
{ player_name: 'X', stat_type: 'hits', line: 1.5, direction: 'over', book_odds: 120 },
{ player_name: 'X', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -150 },
]);
expect(pricedLinesFor(idx, 'X', 'hits').map((r) => r.line)).toEqual([0.5, 1.5]);
});
it('never fabricates on empty/garbage input', () => {
expect(pricedLinesFor(indexPricedLines([]), 'X', 'hits')).toEqual([]);
expect(pricedLinesFor(indexPricedLines(null), 'X', 'hits')).toEqual([]);
expect(pricedLinesFor(null, 'X', 'hits')).toEqual([]);
});
it('dedupes a repeated line+side', () => {
const idx = indexPricedLines([
{ player_name: 'X', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -150 },
{ player_name: 'X', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -150 },
]);
expect(pricedLinesFor(idx, 'X', 'hits')).toHaveLength(1);
});
});
describe('the empty state + surfacer are independently reversible', () => {
const fs = require('fs');
it('the working card/join/triplet are untouched (Scan A byte-identical)', () => {
// These files must NOT reference the new empty-state — proving the new UI is
// additive and removable without touching the working path.
const card = fs.readFileSync(require.resolve('../../web/src/components/vyndr/GradeResultCard.tsx'), 'utf8');
const route = fs.readFileSync(require.resolve('../../web/src/app/api/scan/route.ts'), 'utf8');
expect(card).not.toMatch(/NoMarketState/);
expect(route).not.toMatch(/NoMarketState|pricedLines/);
});
it('the empty state lives entirely in the scan page + its own component', () => {
const page = fs.readFileSync(require.resolve('../../web/src/app/scan/page.tsx'), 'utf8');
expect(page).toMatch(/NoMarketState/);
expect(page).toMatch(/result\.book_odds == null \|\| result\.fair_odds == null/);
});
});
describe('Session 80 — freshness + reversible gate (scan page wiring)', () => {
const fs = require('fs');
const src = fs.readFileSync(require.resolve('../../web/src/app/scan/page.tsx'), 'utf8');
it('re-fetches on selection change when the held snapshot is stale', () => {
// The Session-79 gap: the fetch depended on [sport] only, so a long-open
// page showed hour-stale priced lines. Now a stale index re-fetches at use.
expect(src).toMatch(/Date\.now\(\) - pricedFetchedAt > PRICED_STALE_MS/);
expect(src).toMatch(/\}, \[selectedPlayer, stat\]\)/);
});
it('also refreshes on window focus for a long-open page', () => {
expect(src).toMatch(/addEventListener\('focus', onFocus\)/);
});
it('STALE_MS matches the /api/snapshot cache (no over-fetching)', () => {
expect(src).toMatch(/PRICED_STALE_MS = 30_000/);
});
it('the whole nudge is behind ONE reversible gate flag', () => {
expect(src).toMatch(/const PRICED_NUDGE_ENABLED = true/);
// the surfaced set is empty when the gate is off → S6 link-only fallback
expect(src).toMatch(/PRICED_NUDGE_ENABLED && pricedIndex && selectedPlayer/);
});
it('clears the index on sport change (never shows the old sport lines)', () => {
expect(src).toMatch(/setPricedIndex\(null\);\s*\n\s*setPricedFetchedAt\(0\);\s*\n\s*refreshPriced\(\);/);
});
});