diff --git a/src/services/settleSource.js b/src/services/settleSource.js index be6f333..bafb88d 100644 --- a/src/services/settleSource.js +++ b/src/services/settleSource.js @@ -131,12 +131,21 @@ async function resolveOutcome(args = {}) { } if (meaning === 'void') { const allVoid = games.length > 0 && games.map(classifyGameState).every((s) => s === 'void'); - return { - state: 'void', - value: null, - reason: allVoid ? 'game_postponed_or_cancelled' : 'player_dnp', - source: 'schedule', - }; + // POSITIVE evidence only: the games themselves were postponed/cancelled. + if (allVoid) { + return { state: 'void', value: null, reason: 'game_postponed_or_cancelled', source: 'schedule' }; + } + // ── DO NOT VOID ON PLAYER-ABSENCE ALONE. ────────────────────────────── + // This branch used to return void/'player_dnp' and it was WRONG: absence + // from a date can equally mean the ROW'S DATE IS WRONG. Verified live — + // Freddie Freeman's 97-game log has no 2026-07-18 because he played the + // 17th and 19th (a doubleheader), and ledgerService derives game_date from + // the GRADE timestamp when the feed carries no game_time, so a 01:00/03:00 + // UTC snapshot labels rows with the PREVIOUS ET day. + // We cannot distinguish "did not play" from "mislabelled date" here, so we + // must not claim DNP. Unresolvable rows age out to 'unrecoverable' via the + // retry cap — honest, and excluded from the record either way. + return { state: 'unknown', value: null, reason: 'player_absent_unconfirmed', source: 'schedule' }; } return { state: 'unknown', value: null, reason: 'state_undetermined', source: 'schedule' }; } diff --git a/tests/unit/ledgerService.test.js b/tests/unit/ledgerService.test.js index a7a553d..69e7de1 100644 --- a/tests/unit/ledgerService.test.js +++ b/tests/unit/ledgerService.test.js @@ -179,7 +179,7 @@ describe('settleLedger — outcome + CLV vs the real result', () => { // Session 64 — BEHAVIOUR CHANGED ON PURPOSE. A missing game-log row used to // mean "pending forever". It now depends on what the day's games actually did. - test('no row + game FINAL → VOID (confirmed DNP), no longer immortal', async () => { + test('no row + game FINAL → NOT voided (date may be wrong); attempts bump', async () => { const sb = fakeSb(); sb._state.selectResults = [ [{ id: 'r1' }], @@ -190,9 +190,10 @@ describe('settleLedger — outcome + CLV vs the real result', () => { sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10', getSchedule: async () => [{ status: 'Final' }], }); - expect(res.voided).toBe(1); + expect(res.voided).toBe(0); expect(res.settled).toBe(0); - expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'void', settlement_source: 'player_dnp' }); + expect(res.pending).toBe(1); + expect(sb._calls.updates[0].values.outcome).toBeUndefined(); }); test('no row + game NOT FINAL → stays pending, never voided', async () => { @@ -222,7 +223,7 @@ describe('settleLedger — outcome + CLV vs the real result', () => { const getPlayerStats = async () => ({ found: true, last10: [] }); const res = await ledger.settleLedger('mlb', { sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10', - getSchedule: async () => [], // nothing knowable + getSchedule: async () => [{ status: 'Final' }], // player absent, unconfirmed }); expect(res.unrecoverable).toBe(1); expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'unrecoverable' }); diff --git a/tests/unit/settleSource.test.js b/tests/unit/settleSource.test.js index f07e660..1ee87bc 100644 --- a/tests/unit/settleSource.test.js +++ b/tests/unit/settleSource.test.js @@ -71,14 +71,20 @@ describe('resolveOutcome', () => { expect(r.value).toBe(0); }); - test('DNP on a final game → VOID', async () => { + // 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('void'); - expect(r.reason).toBe('player_dnp'); + expect(r.state).toBe('unknown'); + expect(r.reason).toBe('player_absent_unconfirmed'); }); test('postponed game → VOID with the postponed reason', async () => {