Skip to content
Open
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
5 changes: 5 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
31 changes: 31 additions & 0 deletions backend/tests/test_breakdown_deferral.py
Original file line number Diff line number Diff line change
@@ -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
)
62 changes: 62 additions & 0 deletions backend/tests/test_nan_stability.py
Original file line number Diff line number Diff line change
@@ -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)
41 changes: 41 additions & 0 deletions backend/tests/test_write_gate.py
Original file line number Diff line number Diff line change
@@ -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