"""The Python implementation has to agree with the Go one, byte for byte. The golden files in ``testdata/`` are the ones committed for the Go implementation, so every test below is also a cross-implementation check: same fixtures in, same bytes out. The suite is hermetic; the one test that talks to the site is opt-in. """ from __future__ import annotations import os import re import sys import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[2] # repository root FIXTURES = ROOT / "testdata" sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from swehockey import ( # noqa: E402 Client, DriftError, parse_leagues, parse_schedule, parse_seasons, render, schedule_path, ) from swehockey.fetch import check_charset # noqa: E402 from swehockey.output import first_diff # noqa: E402 MODERN_GOLDEN = [ ("schedule_20961.html", "schedule_20961.json"), ("schedule_legacy.html", "schedule_legacy.json"), ("schedule_legacy_playoffs.html", "schedule_legacy_playoffs.json"), ] def read_fixture(name: str) -> str: return (FIXTURES / name).read_text(encoding="utf-8") def parse_fixture(name: str): return parse_schedule(20961, read_fixture(name)) def render_fixture(name: str) -> bytes: return render("json", parse_fixture(name)) class TestGolden(unittest.TestCase): """Identical HTML must give identical bytes - and the same bytes the Go implementation produces.""" def test_matches_the_go_goldens(self): for fixture, golden in MODERN_GOLDEN: with self.subTest(fixture=fixture): got = render_fixture(fixture) want = (FIXTURES / golden).read_bytes() self.assertEqual( got, want, f"Python output drifted from the Go golden:\n{first_diff(want, got)}", ) def test_render_is_pure(self): a = render_fixture("schedule_20961.html") b = render_fixture("schedule_20961.html") self.assertEqual(a, b, "two runs over the same page produced different bytes") for forbidden in (b"fetched_at", b"generated_at", b"timestamp"): self.assertNotIn(forbidden, a, "output must be a pure function of the page") def test_csv(self): data = render("csv", parse_fixture("schedule_20961.html")).decode("utf-8") lines = data.rstrip("\n").split("\n") self.assertEqual(len(lines), 11, f"csv has {len(lines)} lines, want 11") self.assertEqual( lines[0], "season_id,game_id,event_id,date,time,home,away,home_goals,away_goals,periods,spectators,venue", ) self.assertIn("1:1-1 2:2-1 3:2-2", lines[1]) # An unplayed game leaves the score cells empty rather than writing 0-0. self.assertNotIn(",0,0,", lines[-1]) class TestModernLayout(unittest.TestCase): def setUp(self): self.schedule = parse_fixture("schedule_20961.html") def test_shape(self): self.assertEqual(self.schedule.layout, "modern") self.assertEqual(self.schedule.last_update, "2026-09-26 22:41") self.assertEqual(len(self.schedule.games), 10) self.assertEqual(self.schedule.played, 8) def test_date_carry_forward(self): # A date is printed only on the first row of its group; the rest inherit. want = ["2026-09-19"] * 6 + ["2026-09-22"] * 2 + ["2027-03-16"] * 2 self.assertEqual([g.date for g in self.schedule.games], want) def test_periods_of_every_era(self): self.assertEqual(len(self.schedule.games[1].periods), 4) # overtime self.assertEqual(len(self.schedule.games[6].periods), 5) # shootout def test_values(self): first = self.schedule.games[0] self.assertEqual((first.home, first.away), ("Frölunda HC", "Växjö Lakers HC")) self.assertEqual(first.event_id, "1109941") self.assertEqual(first.spectators, 12044) self.assertEqual((first.home_goals, first.away_goals), (5, 4)) last = self.schedule.games[-1] self.assertFalse(last.played) self.assertIsNone(last.home_goals) self.assertEqual(last.periods, []) class TestLegacyLayout(unittest.TestCase): def setUp(self): self.schedule = parse_fixture("schedule_legacy.html") def test_date_and_time_share_a_cell(self): first = self.schedule.games[0] self.assertEqual((first.date, first.time), ("2019-03-30", "15:15")) self.assertEqual(self.schedule.layout, "legacy") def test_round_carry_forward(self): # The site prints the round only on the first row of that round. self.assertEqual([g.round for g in self.schedule.games[:4]], ["8", "8", "8", "8"]) self.assertEqual([g.round for g in self.schedule.games[4:]], ["22", "23"]) def test_six_period_game(self): # How many periods a game has depends on the era; the sum is the check. first = self.schedule.games[0] self.assertEqual(len(first.periods), 6) self.assertEqual(first.home_goals, 3) class TestLegacyPlayoffs(unittest.TestCase): def test_team_cell_notes_are_dropped(self): # Old team cells end with Eighth final 1, which is not a name. first = parse_fixture("schedule_legacy_playoffs.html").games[0] self.assertEqual((first.home, first.away), ("HV 71", "Leksands IF")) self.assertEqual((first.date, first.time, first.round), ("2014-03-10", "19:00", "1")) def mutate(name: str, pattern: str, repl: str) -> str: """Apply a regexp replacement to a fixture. The pattern must match, otherwise the test would silently become vacuous. """ page = read_fixture(name) new, count = re.subn(pattern, repl, page, flags=re.S) if count == 0: raise AssertionError(f"mutation pattern {pattern!r} does not match the fixture any more") return new class TestDrift(unittest.TestCase): """Every way the page can change shape has to produce a named, actionable error instead of a plausible-looking table.""" CASES = [ ("renamed header", r">Spectators<", ">Publik<", "no table with a known header"), ( "extra column", r'(