Files
vyndr/tests/unit/settleSource.test.js
T
builtbykev 270c4db47a STOP voiding on player-absence — it destroyed real results
Correctness fix to code I shipped minutes ago. The induced live settle
pass voided 64 rows as 'player_dnp' and a large share of them are WRONG:
the Jul 18 set is everyday starters (Freeman, Bellinger, Tucker, Chisholm,
Conforto). They played.

ROOT CAUSE — and my Phase 0 diagnosis was wrong. It is not DNP. The
ledger row's game_date is WRONG. ledgerService derives game_date from the
GRADE timestamp when the feed carries no game_time, and a 01:00/03:00 UTC
snapshot is 21:00/23:00 ET the PREVIOUS day, so rows get labelled with the
previous ET date. Verified against fresh season logs (cache disabled, so
not staleness; found:true, so not name resolution):
  Freddie Freeman  played Jul 17 and Jul 19 (x2, doubleheader) — NOT Jul 18
  Steven Kwan      played Jul 18 (x2) and Jul 19               — NOT Jul 17
Settlement was correct to find no game on the labelled date. My void logic
then converted a data-labelling bug into destroyed results.

FIX: never void on player-absence alone. Voiding now requires POSITIVE
evidence — the games themselves postponed/cancelled. Absence returns
'unknown' (reason player_absent_unconfirmed), so the row retries and ages
out to 'unrecoverable' at the cap. We cannot distinguish "did not play"
from "mislabelled date", so we must not claim DNP. Both terminal states
are excluded from the record denominator either way.

Window-decay remains genuinely fixed (full season log vs a rolling
window), and terminal states still prevent immortal rows.

NOT DONE HERE: the 64 wrong voids are still in the table, and the
game_date derivation is still wrong at the source. Both are reported for
the table — no healing in this order.

Suite green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 03:10:34 -04:00

141 lines
5.4 KiB
JavaScript

/**
* Session 64 Order 1 — date-targeted settlement + terminal states.
*
* The bug: a lookup miss did `pending += 1` with no terminal state, so a row
* that could NEVER settle (DNP, or the rolling window passed the game) was
* indistinguishable from one settling tomorrow. These lock the distinctions.
*/
const src = require('../../src/services/settleSource');
const matchesDate = (r, d) => r.date === d;
const statValue = (s, st) => (s && s[st] != null ? Number(s[st]) : null);
const base = {
sport: 'mlb', playerName: 'X', gameDate: '2026-07-17', statType: 'doubles',
matchesDate, statValue,
};
describe('classifyGameState', () => {
test('final states', () => {
expect(src.classifyGameState({ status: 'Final' })).toBe('final');
expect(src.classifyGameState({ state: 'post' })).toBe('final');
});
test('postponed/cancelled are void', () => {
expect(src.classifyGameState({ detailedState: 'Postponed' })).toBe('void');
expect(src.classifyGameState({ status: 'Cancelled' })).toBe('void');
});
test('SUSPENDED is NOT final — it resumes and must stay pending', () => {
expect(src.classifyGameState({ detailedState: 'Suspended' })).toBe('not_final');
});
test('scheduled / in progress are not final', () => {
expect(src.classifyGameState({ status: 'Scheduled' })).toBe('not_final');
expect(src.classifyGameState({ status: 'In Progress' })).toBe('not_final');
});
test('unknown when there is nothing to read', () => {
expect(src.classifyGameState({})).toBe('unknown');
});
});
describe('absenceMeaning — what a missing player row MEANS', () => {
test('any unplayed game → pending (never void a bet that may still settle)', () => {
expect(src.absenceMeaning([{ status: 'Final' }, { status: 'Suspended' }])).toBe('pending');
});
test('all games postponed → void', () => {
expect(src.absenceMeaning([{ status: 'Postponed' }])).toBe('void');
});
test('game final but player absent → void (confirmed DNP)', () => {
expect(src.absenceMeaning([{ status: 'Final' }])).toBe('void');
});
test('no schedule → unknown, not a guess', () => {
expect(src.absenceMeaning([])).toBe('unknown');
});
});
describe('resolveOutcome', () => {
test('player has a line on that date → SETTLED with the real value', async () => {
const r = await src.resolveOutcome({
...base,
getFullLog: async () => [{ date: '2026-07-17', stat: { doubles: 1 } }],
getSchedule: async () => [{ status: 'Final' }],
});
expect(r.state).toBe('settled');
expect(r.value).toBe(1);
});
test('a ZERO value still settles (0 is a real result, not absence)', async () => {
const r = await src.resolveOutcome({
...base,
getFullLog: async () => [{ date: '2026-07-17', stat: { doubles: 0 } }],
getSchedule: async () => [{ status: 'Final' }],
});
expect(r.state).toBe('settled');
expect(r.value).toBe(0);
});
// Session 64 — this MUST NOT void. Player-absence from a labelled date can
// equally mean the row's date is wrong (ledger derives game_date from the
// GRADE time when the feed has no game_time, so late-UTC snapshots label the
// previous ET day). Verified live: Freddie Freeman's 97-game log has no
// 2026-07-18 because he played the 17th and 19th. Voiding on absence
// destroyed real results.
test('player absent on a final game → UNKNOWN, never void (date may be wrong)', async () => {
const r = await src.resolveOutcome({
...base,
getFullLog: async () => [{ date: '2026-07-16', stat: { doubles: 0 } }],
getSchedule: async () => [{ status: 'Final' }],
});
expect(r.state).toBe('unknown');
expect(r.reason).toBe('player_absent_unconfirmed');
});
test('postponed game → VOID with the postponed reason', async () => {
const r = await src.resolveOutcome({
...base,
getFullLog: async () => [],
getSchedule: async () => [{ status: 'Postponed' }],
});
expect(r.state).toBe('void');
expect(r.reason).toBe('game_postponed_or_cancelled');
});
test('SUSPENDED game stays PENDING — never voided', async () => {
const r = await src.resolveOutcome({
...base,
getFullLog: async () => [],
getSchedule: async () => [{ status: 'Suspended' }],
});
expect(r.state).toBe('pending');
});
test('a game OUTSIDE a rolling window still settles from the full log', async () => {
// The exact live failure: Jul 12 game, window now starts Jul 6.
const full = Array.from({ length: 40 }, (_, i) => ({
date: `2026-07-${String(i + 1).padStart(2, '0')}`, stat: { doubles: i % 3 },
}));
const r = await src.resolveOutcome({
...base, gameDate: '2026-07-12',
getFullLog: async () => full,
getSchedule: async () => [{ status: 'Final' }],
});
expect(r.state).toBe('settled');
});
test('player PLAYED but the stat is missing from the box row → unknown, NEVER void', async () => {
const r = await src.resolveOutcome({
...base,
getFullLog: async () => [{ date: '2026-07-17', stat: { hits: 2 } }],
getSchedule: async () => [{ status: 'Final' }],
});
expect(r.state).toBe('unknown');
expect(r.reason).toBe('stat_not_in_box_row');
});
test('a throwing source degrades to unknown, never throws', async () => {
const r = await src.resolveOutcome({
...base,
getFullLog: async () => { throw new Error('net'); },
getSchedule: async () => { throw new Error('net'); },
});
expect(r.state).toBe('unknown');
});
});