From b3fa7d4b8362f22e6df5cae65a732e9bd52f8145 Mon Sep 17 00:00:00 2001 From: Chris Hondl Date: Thu, 9 Jul 2026 22:07:27 -0700 Subject: [PATCH 1/2] Add pytest coverage for write gating and breakdown deferral Dependency-light tests for the full-field write-gate equality and the score-before-breakdown deferral policy. pytest is added to the dev group only; production dependencies are unchanged. --- backend/pyproject.toml | 5 +++ backend/tests/test_breakdown_deferral.py | 31 ++++++++++++++++++ backend/tests/test_write_gate.py | 41 ++++++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 backend/tests/test_breakdown_deferral.py create mode 100644 backend/tests/test_write_gate.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3669b28e..c2c6eb0f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -28,6 +28,11 @@ google-cloud-storage = "^3.1.0" [tool.poetry.group.dev.dependencies] pyinstrument = "^4.6.1" vulture = "^2.10" +pytest = "^7.4.0" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/backend/tests/test_breakdown_deferral.py b/backend/tests/test_breakdown_deferral.py new file mode 100644 index 00000000..c858a710 --- /dev/null +++ b/backend/tests/test_breakdown_deferral.py @@ -0,0 +1,31 @@ +from src.tba.read_tba import BREAKDOWN_GRACE_SECONDS, defer_missing_breakdown + +NOW = 1_700_000_000 +HOUR = 3600 + + +def test_recent_completed_missing_breakdown_is_deferred(): + assert defer_missing_breakdown(2026, True, False, NOW - HOUR, NOW) + + +def test_completed_with_breakdown_is_not_deferred(): + assert not defer_missing_breakdown(2026, True, True, NOW - HOUR, NOW) + + +def test_upcoming_match_is_not_deferred(): + assert not defer_missing_breakdown(2026, False, False, NOW, NOW) + + +def test_pre_2016_never_deferred(): + assert not defer_missing_breakdown(2015, True, False, NOW, NOW) + + +def test_fallback_releases_stale_missing_breakdown(): + stale = NOW - BREAKDOWN_GRACE_SECONDS - HOUR + assert not defer_missing_breakdown(2026, True, False, stale, NOW) + + +def test_boundary_at_grace_window_releases(): + assert not defer_missing_breakdown( + 2026, True, False, NOW - BREAKDOWN_GRACE_SECONDS, NOW + ) diff --git a/backend/tests/test_write_gate.py b/backend/tests/test_write_gate.py new file mode 100644 index 00000000..b5b07c95 --- /dev/null +++ b/backend/tests/test_write_gate.py @@ -0,0 +1,41 @@ +import attr + +from src.db.models import Event, TeamEvent, TeamYear + + +def test_team_year_rank_only_drift_is_detected(): + ty1 = TeamYear(team=254, year=2026, count=10, epa=100.0, total_epa_rank=1) + ty2 = attr.evolve(ty1, total_epa_rank=2) + assert str(ty1) == str(ty2) + assert ty1 != ty2 + + +def test_team_year_norm_epa_drift_is_detected(): + ty1 = TeamYear(team=254, year=2026, count=10, epa=100.0, norm_epa=1500.0) + ty2 = attr.evolve(ty1, norm_epa=1510.0) + assert str(ty1) == str(ty2) + assert ty1 != ty2 + + +def test_identical_team_years_are_equal(): + ty1 = TeamYear(team=254, year=2026, count=10, epa=100.0) + ty2 = attr.evolve(ty1) + assert ty1 == ty2 + + +def test_event_equality_sees_all_fields(): + e1 = Event(key="2026test", year=2026, name="Test", num_teams=30) + e2 = attr.evolve(e1, num_teams=31) + assert e1 != e2 + + +def test_team_event_component_epa_drift_is_detected(): + te1 = TeamEvent(team=254, year=2026, event="2026test", epa=50.0, auto_epa=10.0) + te2 = attr.evolve(te1, auto_epa=11.0) + assert str(te1) == str(te2) + assert te1 != te2 + + +def test_comparison_with_none_counts_as_changed(): + ty = TeamYear(team=254, year=2026) + assert ty != None # noqa: E711 From df497529d8ad1b4cf14c21bc3f1914c99cfee869 Mon Sep 17 00:00:00 2001 From: Chris Hondl Date: Fri, 10 Jul 2026 11:12:31 -0700 Subject: [PATCH 2/2] Add NaN-stability coverage for the write/publish gates F6: covers nan_safe_eq and the changed()/storage list-comparison call sites, asserting a NaN-bearing row is not counted changed while real drift still is. --- backend/tests/test_nan_stability.py | 62 +++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 backend/tests/test_nan_stability.py diff --git a/backend/tests/test_nan_stability.py b/backend/tests/test_nan_stability.py new file mode 100644 index 00000000..b4b63711 --- /dev/null +++ b/backend/tests/test_nan_stability.py @@ -0,0 +1,62 @@ +import attr + +from src.data.utils import nan_safe_eq +from src.db.models import Match, TeamEvent, TeamYear + + +def nan(): + # a fresh NaN each call: mirrors a recomputed value vs one read back from the + # DB/snapshot, which are distinct objects (identity short-circuit does not hide + # the NaN != NaN inequality) + return float("nan") + + +def test_identical_nan_field_is_equal(): + ty1 = TeamYear(team=254, year=2026, epa=nan()) + ty2 = attr.evolve(ty1, epa=nan()) + assert ty1 != ty2 # attrs field-wise !=: distinct NaN objects compare unequal + assert nan_safe_eq(ty1, ty2) # nan_safe_eq: NaN == NaN + + +def test_real_drift_still_detected_with_nan_present(): + ty1 = TeamYear(team=254, year=2026, epa=nan(), norm_epa=1500.0) + ty2 = attr.evolve(ty1, epa=nan(), norm_epa=1510.0) + assert not nan_safe_eq(ty1, ty2) + + +def test_non_nan_change_still_detected(): + te1 = TeamEvent(team=254, year=2026, event="2026test", epa=50.0, auto_epa=10.0) + te2 = attr.evolve(te1, auto_epa=11.0) + assert not nan_safe_eq(te1, te2) + + +def test_none_counts_as_changed(): + ty = TeamYear(team=254, year=2026) + assert not nan_safe_eq(ty, None) + + +def test_db_gate_skips_identical_nan_rows(): + # mirrors the changed() call site in src/data/utils.py + curr = {ty.pk(): ty for ty in [TeamYear(team=254, year=2026, epa=nan())]} + prev = {pk: attr.evolve(ty, epa=nan()) for pk, ty in curr.items()} + old_gate = [o for o in curr.values() if o != prev.get(o.pk())] + new_gate = [o for o in curr.values() if not nan_safe_eq(o, prev.get(o.pk()))] + assert old_gate != [] # old attrs-!= gate churns the NaN row + assert new_gate == [] # nan_safe_eq gate does not + + +def test_storage_gate_ordered_list_with_nan_is_stable(): + # mirrors event_to_matches[e.key] != orig_matches[e.key] in storage.py + m1 = Match(key="2026test_qm1", year=2026, event="2026test", epa_red_score_pred=nan()) + m2 = Match(key="2026test_qm2", year=2026, event="2026test") + curr = [m1, m2] + orig = [attr.evolve(m1, epa_red_score_pred=nan()), attr.evolve(m2)] + assert curr != orig # list eq is elementwise ==, broken by distinct NaNs + assert nan_safe_eq(curr, orig) + + +def test_storage_gate_detects_real_match_change(): + m1 = Match(key="2026test_qm1", year=2026, event="2026test", red_score=10) + curr = [m1] + orig = [attr.evolve(m1, red_score=17)] + assert not nan_safe_eq(curr, orig)