Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ function tablesWith(extra: Record<string, Row[]> = {}): Record<string, Row[]> {
{ id: TEAM_B, timezone: 'UTC' },
],
baseball_events: [],
baseball_player_stats: [],
// #379 — recentStats reads the canonical box-score tables now.
baseball_box_score_batting: [],
baseball_box_score_pitching: [],
baseball_games: [],
baseball_actions: [],
baseball_task_assignments: [],
baseball_coach_notes: [],
Expand Down
204 changes: 180 additions & 24 deletions src/contracts/baseball/product-trust/player-today-honest-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,19 @@
// ONLY by `error` — `available` is identical in both, which is the
// actual fabrication risk: a caller that checks only `available` cannot
// tell "genuinely nothing assigned" from "the query failed".
// 4. A hand-entered recent-stat row (no import_run_id, no
// source_trust_level) never gets a fabricated trust/provenance object;
// a stamped row does.
// 4. recentStats (#379 — box-score sourced, not the deprecated flat
// per-session stat table) never fabricates trust/provenance: a
// box-score row carries no CSV-import provenance columns at all, so
// every recentStats entry gets an honest trust:null/provenance:null
// rather than an implied import lineage that doesn't exist.
// 5. #845 review fix: a player with ZERO box-score rows but real history in
// the deprecated baseball_player_stats table must still see REAL
// recentStats entries (sourceLayer:'legacy-fallback'), never a silent
// honest-LOOKING empty list caused by the #379 migration itself — the
// same box-score > legacy-fallback > no-data precedence
// legacy-stat-adapters.ts enforces for aggregate rows. A legacy row's
// real stamped provenance (when it has any) surfaces too, never
// flattened to null just because it came through the fallback path.
//
// Source of truth: `getPlayerToday` in
// src/lib/baseball/read-models/player-today.ts.
Expand Down Expand Up @@ -65,7 +75,11 @@ function baseTables(extra: Record<string, Row[]> = {}): Record<string, Row[]> {
baseball_players: [{ id: PLAYER_ID, user_id: USER_ID }],
baseball_team_members: [{ id: 'mem-1', team_id: TEAM_ID, player_id: PLAYER_ID }],
baseball_events: [],
baseball_player_stats: [],
// #379 — recentStats now sources from the canonical box-score layer, not
// the deprecated flat/aggregate stat layer.
baseball_box_score_batting: [],
baseball_box_score_pitching: [],
baseball_games: [],
helm_lifting_sessions: [],
baseball_actions: [],
helm_lifting_readiness_checkins: [],
Expand Down Expand Up @@ -198,19 +212,118 @@ describe('getPlayerToday — sub-read failures are distinguishable from honest e
});
});

describe('getPlayerToday — recent stats never fabricate provenance for a hand-entered row (#377)', () => {
it('a hand-entered row (no import_run_id, no source_trust_level) gets trust:null + provenance:null', async () => {
describe('getPlayerToday — recent stats never fabricate provenance for a box-score row (#379)', () => {
// #379: recentStats migrated off the deprecated flat per-session stat
// table onto the canonical box-score layer (baseball_box_score_batting/
// _pitching joined to baseball_games). Box-score rows are staff-entered via
// the box-score save flow, not imported, so they carry no CSV-import
// provenance columns at all — trust/provenance must always be an honest
// null, never a fabricated stamp implying an import lineage that doesn't
// exist.
it('a game with a captured box-score line surfaces trust:null + provenance:null (no import lineage exists to stamp)', async () => {
fake = createFakeSupabase({
user: { id: USER_ID },
tables: baseTables({
baseball_box_score_batting: [
{ id: 'bb-1', game_id: 'game-1', player_id: PLAYER_ID, team_id: TEAM_ID },
],
baseball_games: [
{
id: 'game-1',
team_id: TEAM_ID,
game_date: DAY,
game_type: 'official_game',
opponent_name: 'Rival High',
},
],
}),
});

const result = await getPlayerToday(TEAM_ID, { forDate: DAY });
const row = result.recentStats.find((s) => s.id === 'game-1');
expect(row).toBeTruthy();
expect(row?.statType).toBe('official_game');
expect(row?.sessionDate).toBe(DAY);
expect(row?.sessionName).toBe('vs Rival High');
expect(row?.trust).toBeNull();
expect(row?.provenance).toBeNull();
});

it('a game with only a pitching line (no batting row) still surfaces in recentStats', async () => {
fake = createFakeSupabase({
user: { id: USER_ID },
tables: baseTables({
baseball_box_score_pitching: [
{ id: 'bp-1', game_id: 'game-2', player_id: PLAYER_ID, team_id: TEAM_ID },
],
baseball_games: [
{
id: 'game-2',
team_id: TEAM_ID,
game_date: DAY,
game_type: 'scrimmage',
opponent_name: null,
},
],
}),
});

const result = await getPlayerToday(TEAM_ID, { forDate: DAY });
const row = result.recentStats.find((s) => s.id === 'game-2');
expect(row).toBeTruthy();
expect(row?.statType).toBe('scrimmage');
// No opponent on file -> honest null, never a fabricated label.
expect(row?.sessionName).toBeNull();
expect(row?.trust).toBeNull();
expect(row?.provenance).toBeNull();
});

it('a FAILING box-score read returns recentStats:[] but sets `error` — distinguishable from a genuinely stat-less player', async () => {
failSelect(fake, 'baseball_box_score_batting', 'boom');

const result = await getPlayerToday(TEAM_ID, { forDate: DAY });
// Same shape as the honest-empty case (recentStats: [])...
expect(result.recentStats).toEqual([]);
// ...but `error` is non-null — the ONLY signal this is a failure, not
// "this player has no captured box-score lines yet" (mirrors the item-3
// sub-read contract above).
expect(result.error).toBe('Your recent stats could not be loaded.');
});
});

describe('getPlayerToday — legacy-fallback recentStats for a legacy-only player (#845)', () => {
// #845 review fix: the #379 migration onto the box-score layer regressed a
// player with real PRE-box-score history (zero box-score rows, real
// baseball_player_stats rows) from "shows real recent stats" to a silent,
// honest-LOOKING empty list — the migration itself was the bug, not a
// genuine absence of activity. fetchRecentBoxScoreActivity now falls back
// to baseball_player_stats ONLY when box-score is empty for this player.
it('zero box-score rows, real baseball_player_stats rows -> recentStats shows REAL data, not an honest-looking empty', async () => {
fake = createFakeSupabase({
user: { id: USER_ID },
tables: baseTables({
baseball_player_stats: [
{
id: 'stat-hand',
id: 'stat-1',
player_id: PLAYER_ID,
team_id: TEAM_ID,
stat_type: 'game',
session_date: '2026-03-10',
session_name: 'vs Rival High',
source: 'manual',
source_trust_level: null,
source_match_tier: null,
source_match_confidence: null,
source_external_id: null,
import_run_id: null,
},
{
id: 'stat-2',
player_id: PLAYER_ID,
team_id: TEAM_ID,
stat_type: 'batting',
session_date: DAY,
session_name: 'Hand-entered line',
stat_type: 'practice',
session_date: '2026-03-01',
session_name: null,
source: 'manual',
source_trust_level: null,
source_match_tier: null,
Expand All @@ -223,26 +336,71 @@ describe('getPlayerToday — recent stats never fabricate provenance for a hand-
});

const result = await getPlayerToday(TEAM_ID, { forDate: DAY });
const row = result.recentStats.find((s) => s.id === 'stat-hand');
expect(row).toBeTruthy();
expect(row?.trust).toBeNull();
expect(row?.provenance).toBeNull();
expect(result.recentStats).toHaveLength(2);
expect(result.recentStats.every((s) => s.sourceLayer === 'legacy-fallback')).toBe(true);
// Newest-first, same ordering contract as the box-score path.
expect(result.recentStats[0]?.id).toBe('stat-1');
expect(result.recentStats[0]?.sessionName).toBe('vs Rival High');
expect(result.error).toBeNull();
});

it('a stamped row (source_trust_level set) DOES get a real trust + provenance object', async () => {
it('an imported legacy row surfaces its REAL stamped trust/provenance (never flattened to null just because it came through the fallback)', async () => {
fake = createFakeSupabase({
user: { id: USER_ID },
tables: baseTables({
baseball_player_stats: [
{
id: 'stat-stamped',
id: 'stat-3',
player_id: PLAYER_ID,
team_id: TEAM_ID,
stat_type: 'batting',
session_date: DAY,
session_name: 'Imported line',
stat_type: 'game',
session_date: '2026-02-01',
session_name: 'Imported batch',
source: 'csv_import',
source_trust_level: 'staff_entered',
source_trust_level: 'unreviewed',
source_match_tier: 'exact_roster',
source_match_confidence: 0.92,
source_external_id: 'ext-1',
import_run_id: 'run-1',
},
],
baseball_import_runs: [{ id: 'run-1', review_state: 'pending_review' }],
}),
});

const result = await getPlayerToday(TEAM_ID, { forDate: DAY });
const row = result.recentStats.find((s) => s.id === 'stat-3');
expect(row?.sourceLayer).toBe('legacy-fallback');
expect(row?.trust).not.toBeNull();
expect(row?.provenance).not.toBeNull();
});

it('a box-score row present -> box-score wins outright, even with legacy rows also present (never blended)', async () => {
fake = createFakeSupabase({
user: { id: USER_ID },
tables: baseTables({
baseball_box_score_batting: [
{ id: 'bb-1', game_id: 'game-1', player_id: PLAYER_ID, team_id: TEAM_ID },
],
baseball_games: [
{
id: 'game-1',
team_id: TEAM_ID,
game_date: DAY,
game_type: 'official_game',
opponent_name: 'Rival High',
},
],
baseball_player_stats: [
{
id: 'stat-old',
player_id: PLAYER_ID,
team_id: TEAM_ID,
stat_type: 'game',
session_date: '2020-01-01',
session_name: 'Ancient session',
source: 'manual',
source_trust_level: null,
source_match_tier: null,
source_match_confidence: null,
source_external_id: null,
Expand All @@ -253,9 +411,7 @@ describe('getPlayerToday — recent stats never fabricate provenance for a hand-
});

const result = await getPlayerToday(TEAM_ID, { forDate: DAY });
const row = result.recentStats.find((s) => s.id === 'stat-stamped');
expect(row).toBeTruthy();
expect(row?.trust).not.toBeNull();
expect(row?.provenance).not.toBeNull();
expect(result.recentStats.every((s) => s.sourceLayer === 'box-score')).toBe(true);
expect(result.recentStats.find((s) => s.id === 'stat-old')).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,10 @@ describe('adaptLegacyPlayerStats — practice carve-out', () => {
});

describe('adaptLegacyPlayerStats — event-derived fields (null-safe)', () => {
it('defaults every event-derived field to null when no event input is given', () => {
it('defaults exit-velocity fields to null when no event input is given (no legacy equivalent exists)', () => {
const result = adaptLegacyPlayerStats({
playerId: 'p1',
legacy: legacyRow({ avg_pitch_velocity: 88 }),
legacy: legacyRow(), // avg_pitch_velocity / max_pitch_velocity both null here
boxScore: boxScoreRow(),
});

Expand All @@ -188,6 +188,46 @@ describe('adaptLegacyPlayerStats — event-derived fields (null-safe)', () => {
expect(result.event.avgPitchVelocity).toBeNull();
expect(result.event.maxPitchVelocity).toBeNull();
});

it('falls back to the legacy avg/max pitch-velocity scalars when no event input is given — a legitimate, previously-captured fallback (#379 residual)', () => {
const result = adaptLegacyPlayerStats({
playerId: 'p1',
legacy: legacyRow({ avg_pitch_velocity: 88, max_pitch_velocity: 95 }),
boxScore: boxScoreRow(),
});

expect(result.event.avgPitchVelocity).toBe(88);
expect(result.event.maxPitchVelocity).toBe(95);
// Exit velocity has NO legacy column at all — stays null-safe even
// though this same legacy row carries real pitch-velocity data.
expect(result.event.avgExitVelocity).toBeNull();
expect(result.event.maxExitVelocity).toBeNull();
});

it('prefers an explicit event-grain pitch-velocity reading over the legacy fallback when both are present', () => {
const result = adaptLegacyPlayerStats({
playerId: 'p1',
legacy: legacyRow({ avg_pitch_velocity: 88, max_pitch_velocity: 95 }),
boxScore: boxScoreRow(),
event: { avgPitchVelocity: 92.3 },
});

expect(result.event.avgPitchVelocity).toBe(92.3);
// maxPitchVelocity wasn't supplied in the event input -> falls back to
// the legacy scalar independently, per-field (not all-or-nothing).
expect(result.event.maxPitchVelocity).toBe(95);
});

it('reports null pitch-velocity (never fabricated) when neither an event reading nor a legacy row exists', () => {
const result = adaptLegacyPlayerStats({
playerId: 'p1',
legacy: null,
boxScore: boxScoreRow(),
});

expect(result.event.avgPitchVelocity).toBeNull();
expect(result.event.maxPitchVelocity).toBeNull();
});
});

describe('adaptLegacyStatsMap', () => {
Expand Down
Loading
Loading