147 lines
7.4 KiB
Python
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
|