Files
hockey_new/tests/test_build202_penalty_rules.py

147 lines
7.4 KiB
Python

"""Real backend strength and production browser rules, without live I/O.
BUILD202_RULES_RUNTIME_ROOT selects an immutable earlier runtime for regression
proofs. The JavaScript fixture receives strength from the real Python calculator;
it does not recreate the hockey strength arithmetic in its HTTP doubles.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import pytest
ROOT = Path(__file__).resolve().parents[1]
RUNTIME_ROOT = Path(os.environ.get("BUILD202_RULES_RUNTIME_ROOT", ROOT))
if RUNTIME_ROOT != ROOT:
sys.path.insert(0, str(RUNTIME_ROOT))
from hockey_data.service import HockeyDataService
CASES = (
"misconduct_ten", "game_misconduct_twenty", "explicit_misconduct",
"nested_game_misconduct", "queued_minor", "awaiting_minor", "offsetting_minor",
"nested_offsetting_minor", "strength_override_camel", "strength_override_snake",
"ordinary_opposing_pair", "coincidental_pair_counts", "major_and_minor",
"five_on_three_one_minor", "double_current_segment", "shorthanded_and_empty_net",
"equal_minor_choice", "metadata_roundtrip", "goal_cycle_reset_with_discipline",
"manual_expiry_cycle_reset_with_discipline",
)
def penalty(ident: str, side: str = "away", preset: str = "2", remaining: int | None = None, **extra) -> dict:
duration = 240000 if preset in {"4", "2+2"} else 300000 if preset.startswith("5") else int(preset) * 60000
return {
"id": ident, "side": side, "teamPenalty": True, "player": None,
"infraction": {"id": "I-" + ident}, "preset": preset,
"durationMs": duration, "remainingMs": duration if remaining is None else remaining,
"running": False, "startedOnce": False, "finished": False,
"createdAt": 1, "updatedAt": 1, "lastTimestamp": 1000, **extra,
}
def scenario(case: str) -> dict:
counts = [0, 0]
plan = {"side": "home", "reason": "not_powerplay"}
if case == "misconduct_ten":
rows = [penalty("discipline", preset="10")]
elif case == "game_misconduct_twenty":
rows = [penalty("discipline", preset="20")]
elif case == "explicit_misconduct":
rows = [penalty("discipline", "home", penaltyKind="misconduct"), penalty("minor")]
counts, plan = [0, 1], {"side": "home", "reason": "minor_finished", "entry": "minor"}
elif case == "nested_game_misconduct":
rows = [penalty("discipline", infraction={"id": "discipline", "penalty_kind": "game_misconduct"})]
elif case in {"queued_minor", "awaiting_minor"}:
flag = "queued" if case == "queued_minor" else "awaitingStart"
rows = [penalty("served", "home"), penalty("waiting", **{flag: True})]
counts = [1, 0]
elif case == "offsetting_minor":
rows = [penalty("offset", offsetting=True)]
elif case == "nested_offsetting_minor":
rows = [penalty("offset", infraction={"id": "offset", "offsetting": True})]
elif case in {"strength_override_camel", "strength_override_snake"}:
flag = "affectsStrength" if case.endswith("camel") else "affects_strength"
rows = [penalty("personal", **{flag: False})]
elif case == "ordinary_opposing_pair":
rows = [penalty("left-two", "home"), penalty("right-four", preset="4")]
counts = [1, 1]
elif case == "coincidental_pair_counts":
rows = [penalty("coincidental", "home", coincidental=True), penalty("ordinary")]
counts = [1, 1]
elif case == "major_and_minor":
rows = [penalty("major", preset="5+20", remaining=10000), penalty("minor", remaining=90000)]
counts, plan = [0, 2], {"side": "home", "reason": "minor_finished", "entry": "minor"}
elif case == "five_on_three_one_minor":
rows = [penalty("long", remaining=90000), penalty("short", remaining=50000)]
counts, plan = [0, 2], {"side": "home", "reason": "minor_finished", "entry": "short"}
elif case == "double_current_segment":
rows = [penalty("A42", remaining=94000), penalty("A16", preset="2+2", remaining=205000)]
counts, plan = [0, 2], {"side": "home", "reason": "next_minor_segment", "entry": "A16"}
elif case == "shorthanded_and_empty_net":
rows = [penalty("home-minor", "home")]
counts = [1, 0]
elif case == "equal_minor_choice":
rows = [penalty("first", remaining=90000), penalty("second", remaining=90000)]
counts, plan = [0, 2], {"side": "home", "reason": "equal_minor_choice_required"}
elif case == "metadata_roundtrip":
rows = [penalty("offset", offsetting=True),
penalty("nested-offset", infraction={"id": "offset", "offsetting": True}),
penalty("personal", affectsStrength=False), penalty("snake-personal", affects_strength=False),
penalty("coincidental", coincidental=True), penalty("expired", remaining=0)]
counts = [0, 1]
plan = {"side": "home", "reason": "no_releasable_minor"}
elif case in {"goal_cycle_reset_with_discipline", "manual_expiry_cycle_reset_with_discipline"}:
rows = [penalty("minor", remaining=1000,
running=case.startswith("manual"), startedOnce=case.startswith("manual")),
penalty("discipline", "home", preset="10")]
counts, plan = [0, 1], {"side": "home", "reason": "minor_finished", "entry": "minor"}
else:
raise AssertionError(case)
return {"case": case, "penalties": rows, "counts": counts, "plan": plan}
@pytest.mark.parametrize("case", CASES)
def test_penalty_rules(case: str) -> None:
value = scenario(case)
timer_state = {"penalty_board": {"penalties": value["penalties"]}}
# This also exercises actual backend timer-state persistence normalization.
timer_state = HockeyDataService._normalise_game_timer_state(timer_state)
strength = HockeyDataService._strength_payload(
{}, stage="regular", current_period="2", timer_state=timer_state, language="ru",
)
assert [strength["home_penalties"], strength["away_penalties"]] == value["counts"], case
value["strength"] = strength
if "cycle_reset" in case:
value["next_penalties"] = [penalty("new-home", "home"), penalty("new-away")]
value["next_strength"] = HockeyDataService._strength_payload(
{}, stage="regular", current_period="2", timer_state={"penalty_board": {
"penalties": [penalty("discipline", "home", preset="10"), *value["next_penalties"]],
}}, language="ru",
)
node = shutil.which("node")
assert node, "Node is required for production browser rule regressions"
result = subprocess.run(
[node, str(ROOT / "tests/build202_penalty_rules.cjs"), case, str(RUNTIME_ROOT)],
cwd=ROOT, input=json.dumps(value), text=True, capture_output=True, timeout=20,
)
assert result.returncode == 0, result.stdout + result.stderr
output = json.loads(result.stdout)
assert output["passed"] == case
if case == "metadata_roundtrip":
restored = HockeyDataService._normalise_game_timer_state({
"penalty_board": {"penalties": output["snapshots"]},
})
assert HockeyDataService._active_penalty_counts(restored) == (0, 1)
saved = {item["id"]: item for item in restored["penalty_board"]["penalties"]}
assert saved["offset"]["offsetting"] is True
assert saved["nested-offset"]["offsetting"] is True
assert saved["personal"]["affectsStrength"] is False
assert saved["snake-personal"]["affectsStrength"] is False
assert saved["coincidental"]["coincidental"] is True