Files
swehockey/parse_test.go
T
Mats Tornberg 6b701819ca Deterministic swehockey.se schedule crawler
One HTTP GET per season, no browser: the page is server-rendered, so every row
arrives in one response and the extraction becomes a pure function of the HTML.

- layouts[] declares one profile per version of the site's table (modern and
  legacy), each identified by its header row, so a new version of the page is a
  new profile plus a fixture instead of a guess.
- The table is located by header signature, not by a CSS path.
- Every cell is shape-checked, and facts the site renders twice (desktop and
  mobile date, time in the tooltip) must agree.
- Cross-field invariants: periods sum to the score, ids are unique, played games
  always have an event link and unplayed ones never do.
- Output is sorted by date, time and id, so it does not inherit the site's row
  order, and it carries no fetch timestamp: same HTML, same bytes.
- Drift is a typed error and exit code 3, with the header it actually found.
- Tests run on committed HTML fixtures and golden JSON; TestLive is opt-in.

Verified over the full SHL archive: 27/27 seasons, 2119 games, 1779 played.
2026-09-27 08:47:25 +00:00

446 lines
14 KiB
Go

package main
import (
"bytes"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
// The tests never touch the network: they parse committed HTML slices. That is
// what makes the suite deterministic, and it is why a change in the site's
// markup shows up as a failing test with a readable message instead of as
// silently different data.
func parseFixture(t *testing.T, name string) *Schedule {
t.Helper()
f, err := os.Open(filepath.Join("testdata", name))
if err != nil {
t.Fatal(err)
}
defer f.Close()
s, err := ParseSchedule(20961, f)
if err != nil {
t.Fatalf("ParseSchedule(%s): %v", name, err)
}
return s
}
// TestGolden pins the exact bytes of the rendered output. Same HTML in, same
// bytes out - that is the property the whole design exists to provide.
func TestGolden(t *testing.T) {
for _, tc := range []struct{ fixture, golden string }{
{"schedule_20961.html", "schedule_20961.json"},
{"schedule_legacy.html", "schedule_legacy.json"},
{"schedule_legacy_playoffs.html", "schedule_legacy_playoffs.json"},
} {
t.Run(tc.fixture, func(t *testing.T) {
f, err := os.Open(filepath.Join("testdata", tc.fixture))
if err != nil {
t.Fatal(err)
}
defer f.Close()
s, err := ParseSchedule(20961, f)
if err != nil {
t.Fatalf("parse: %v", err)
}
got, err := render("json", s)
if err != nil {
t.Fatal(err)
}
want, err := os.ReadFile(filepath.Join("testdata", tc.golden))
if err != nil {
t.Fatalf("read golden (regenerate with -update flag): %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("output drifted from the golden file:\n%s", firstDiff(want, got))
}
})
}
}
func TestModernLayout(t *testing.T) {
s := parseFixture(t, "schedule_20961.html")
if s.Layout != "modern" {
t.Errorf("layout = %q, want modern", s.Layout)
}
if s.LastUpdate != "2026-09-26 22:41" {
t.Errorf("last update = %q", s.LastUpdate)
}
if len(s.Games) != 10 || s.Played() != 8 {
t.Fatalf("games = %d, played = %d; want 10/8", len(s.Games), s.Played())
}
// Date headings appear only on the first row of a date; the rest inherit.
// A date is printed only on the first row of its group; the rest inherit it.
want := []string{"2026-09-19", "2026-09-19", "2026-09-19", "2026-09-19", "2026-09-19", "2026-09-19",
"2026-09-22", "2026-09-22", "2027-03-16", "2027-03-16"}
for i, g := range s.Games {
if g.Date != want[i] {
t.Errorf("game %d: date = %s, want %s", i, g.Date, want[i])
}
}
// Overtime (4 periods) and a shootout (5 periods) keep every period.
if g := s.Games[1]; len(g.Periods) != 4 || *g.HomeGoals != 4 {
t.Errorf("game 2: %d periods, %d goals", len(g.Periods), *g.HomeGoals)
}
if g := s.Games[6]; len(g.Periods) != 5 || *g.HomeGoals != 3 {
t.Errorf("game 7: %d periods, %d goals", len(g.Periods), *g.HomeGoals)
}
// An unplayed game has a result of nil, not 0-0, and no periods.
if g := s.Games[len(s.Games)-1]; g.Played() || g.HomeGoals != nil || g.AwayGoals != nil || len(g.Periods) != 0 {
t.Errorf("last game should be unplayed: %+v", g)
}
if g := s.Games[0]; g.Home != "Frölunda HC" || g.Away != "Växjö Lakers HC" {
t.Errorf("teams not normalised: %q vs %q", g.Home, g.Away)
}
if g := s.Games[0]; g.EventID != "1109941" || g.Spectators == nil || *g.Spectators != 12044 {
t.Errorf("event id / spectators: %+v", g)
}
}
func TestLegacyLayout(t *testing.T) {
s := parseFixture(t, "schedule_legacy.html")
if s.Layout != "legacy" {
t.Errorf("layout = %q, want legacy", s.Layout)
}
g := s.Games[0]
// The legacy cell holds "<date> <time>" in one tooltip span.
if g.Date != "2019-03-30" || g.Time != "15:15" {
t.Errorf("date/time = %q %q", g.Date, g.Time)
}
// The site prints the round only on the first row of a round; the rest
// inherit it, exactly like the date headings of the modern layout.
if s.Games[0].Round != "8" || s.Games[1].Round != "8" {
t.Errorf("round carry-forward wrong: %q then %q", s.Games[0].Round, s.Games[1].Round)
}
// How many periods a game has depends on the era, so the count is not
// pinned; what must hold is that the periods add up to the result.
if g := s.Games[0]; len(g.Periods) != 6 || *g.HomeGoals != 3 {
t.Errorf("six-period game: %d periods, %d goals", len(g.Periods), *g.HomeGoals)
}
}
func TestLegacyPlayoffNotes(t *testing.T) {
s := parseFixture(t, "schedule_legacy_playoffs.html")
g := s.Games[0]
// Old team cells end with an <i>note</i> ("Eighth final 1") that is not
// part of a team name and must not end up in the field.
if g.Home != "HV 71" || g.Away != "Leksands IF" {
t.Errorf("teams = %q vs %q", g.Home, g.Away)
}
if g.Date != "2014-03-10" || g.Time != "19:00" || g.Round != "1" {
t.Errorf("date/time/round = %q %q %q", g.Date, g.Time, g.Round)
}
}
// mutate applies a regexp replacement to a fixture and returns the result. The
// pattern must match, otherwise the test would silently become vacuous.
func mutate(t *testing.T, name, pattern, repl string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatal(err)
}
re, err := regexp.Compile(pattern)
if err != nil {
t.Fatal(err)
}
if !re.Match(raw) {
t.Fatalf("mutation pattern %q does not match the fixture any more", pattern)
}
return string(re.ReplaceAll(raw, []byte(repl)))
}
func expectDrift(t *testing.T, page, wantSubstring string) {
t.Helper()
_, err := ParseSchedule(20961, strings.NewReader(page))
if err == nil {
t.Fatalf("expected a drift error containing %q, got none", wantSubstring)
}
de, ok := err.(*DriftError)
if !ok {
t.Fatalf("expected *DriftError, got %T: %v", err, err)
}
if !strings.Contains(de.Error(), wantSubstring) {
t.Errorf("error %q does not mention %q", de.Error(), wantSubstring)
}
if !strings.Contains(de.Error(), "parse.go") {
t.Errorf("error should tell the operator what to do: %q", de.Error())
}
}
// TestDrift is the important one: every way the page can change shape has to
// produce a named, actionable error instead of a plausible-looking table.
func TestDrift(t *testing.T) {
cases := []struct {
name, pattern, repl, want string
}{
{
name: "renamed header",
pattern: `>Spectators<`,
repl: `>Publik<`,
want: "no table with a known header",
},
{
name: "extra column",
pattern: `(?s)(<tr><td class="tdOdd[^>]*>2026-09-19</td>.*?)</tr>`,
repl: `${1}<td>x</td></tr>`,
want: "row width does not match the modern layout",
},
{
name: "score no longer matches the periods",
pattern: `>5\x{00a0}-\x{00a0}4<`,
repl: ">6\u00a0-\u00a04<",
want: "periods sum to",
},
{
name: "date group marker removed",
pattern: `<bold>2026-09-22</bold><br />`,
repl: `2026-09-22<br />`,
want: "unexpected date in a continuation row",
},
{
name: "duplicate game id",
pattern: `title="90001002"`,
repl: `title="90001001"`,
want: "duplicate game id",
},
{
name: "no last-update stamp",
pattern: `Last update:`,
repl: `Last update was:`,
want: `no "Last update:" stamp`,
},
{
name: "empty table",
pattern: `(?s)<tr><td class="tdOdd.*`,
repl: ``,
want: "no game rows",
},
{
name: "the start time stops parsing",
pattern: `>15:15</span>`,
repl: `>15h15</span>`,
want: "malformed start time",
},
{
name: "the venue column is empty",
pattern: `>Scandinavium</td>`,
repl: `></td>`,
want: "empty venue",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
expectDrift(t, mutate(t, "schedule_20961.html", tc.pattern, tc.repl), tc.want)
})
}
}
func TestDriftOnUnknownLayout(t *testing.T) {
// A brand new table shape must name itself in the error, so the next
// person does not have to download the page to find out what changed.
page := mutate(t, "schedule_20961.html", `>Game<`, `>Match<`)
expectDrift(t, page, `no table with a known header`)
_, err := ParseSchedule(20961, strings.NewReader(page))
if !strings.Contains(err.Error(), "Date Time Game Result Spectators Venue") {
t.Errorf("error should quote the header it found: %v", err)
}
}
// TestOrderIndependence: two rows swapped on the page must produce identical
// bytes. The site orders rows by round and by what it felt like printing, so
// the crawler imposes its own canonical order instead of inheriting one.
func TestOrderIndependence(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("testdata", "schedule_20961.html"))
if err != nil {
t.Fatal(err)
}
rowOf := func(id string) []byte {
i := bytes.Index(raw, []byte(`title="`+id+`"`))
if i < 0 {
t.Fatalf("fixture has no game %s", id)
}
start := bytes.LastIndex(raw[:i], []byte("<tr>"))
end := bytes.Index(raw[i:], []byte("</tr>")) + i + len("</tr>")
return raw[start:end]
}
// Two continuation rows of the same date: swapping them must not change a
// byte. The first row of a date group is not a candidate, because it is the
// one carrying the date heading the rest of the group inherits.
rowA, rowB := rowOf("90001003"), rowOf("90001004")
swapped := bytes.ReplaceAll(raw, rowB, []byte("@@ROW@@"))
swapped = bytes.ReplaceAll(swapped, rowA, rowB)
swapped = bytes.ReplaceAll(swapped, []byte("@@ROW@@"), rowA)
if bytes.Equal(swapped, raw) {
t.Fatal("the swap did not change anything")
}
parse := func(page []byte) []byte {
s, err := ParseSchedule(20961, bytes.NewReader(page))
if err != nil {
t.Fatalf("parse: %v", err)
}
return renderMust(t, s)
}
if a, b := parse(raw), parse(swapped); !bytes.Equal(a, b) {
t.Errorf("row order leaked into the output:\n%s", firstDiff(a, b))
}
}
func TestRenderIsPure(t *testing.T) {
a := renderMust(t, parseFixture(t, "schedule_20961.html"))
b := renderMust(t, parseFixture(t, "schedule_20961.html"))
if !bytes.Equal(a, b) {
t.Error("two runs over the same page produced different bytes")
}
// Nothing in the output may depend on when the crawl ran.
for _, forbidden := range []string{"fetched_at", "generated_at", "timestamp"} {
if bytes.Contains(a, []byte(forbidden)) {
t.Errorf("output contains %q; output must be a pure function of the page", forbidden)
}
}
}
func TestCSV(t *testing.T) {
b, err := render("csv", parseFixture(t, "schedule_20961.html"))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
if len(lines) != 11 {
t.Fatalf("csv has %d lines, want 11 (header + 10 games)", len(lines))
}
if lines[0] != "season_id,game_id,event_id,date,time,home,away,home_goals,away_goals,periods,spectators,venue" {
t.Errorf("unexpected csv header: %q", lines[0])
}
if !strings.Contains(lines[1], "1:1-1 2:2-1 3:2-2") {
t.Errorf("periods not serialised in row 1: %q", lines[1])
}
// Unplayed games leave the score cells empty rather than writing 0-0.
if last := lines[len(lines)-1]; strings.Contains(last, ",0,0,") {
t.Errorf("unplayed game looks like a 0-0 result: %q", last)
}
}
func TestSeasons(t *testing.T) {
f, err := os.Open(filepath.Join("testdata", "seasons_20961.html"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
seasons, err := ParseSeasons(20961, f)
if err != nil {
t.Fatal(err)
}
if len(seasons) < 2 {
t.Fatalf("seasons = %d, want at least 2", len(seasons))
}
var last int
for i, s := range seasons {
if s.ID <= 0 {
t.Errorf("group heading leaked into the list: %+v", s)
}
if i > 0 && s.ID <= last {
t.Errorf("seasons are not sorted by id: %d then %d", last, s.ID)
}
last = s.ID
}
ids := map[int]Season{}
for _, s := range seasons {
ids[s.ID] = s
}
for _, id := range []int{18263, 20961} {
s, ok := ids[id]
if !ok {
t.Fatalf("season %d missing from %d seasons", id, len(seasons))
}
if s.Label == "" {
t.Errorf("season %d has no label", id)
}
}
if got := ids[20961].Label; got != "2026-27" {
t.Errorf("label = %q, want 2026-27", got)
}
if got := SchedulePath(20961); got != "/ScheduleAndResults/Schedule/20961" {
t.Errorf("SchedulePath = %q", got)
}
}
func TestLeagues(t *testing.T) {
f, err := os.Open(filepath.Join("testdata", "seasons_20961.html"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
leagues, err := ParseLeagues(f)
if err != nil {
t.Fatal(err)
}
if len(leagues) == 0 {
t.Fatal("no leagues found")
}
for i := 1; i < len(leagues); i++ {
if leagues[i-1].ID >= leagues[i].ID {
t.Errorf("leagues are not sorted/deduplicated: %d then %d", leagues[i-1].ID, leagues[i].ID)
}
}
}
func TestCharsetGuard(t *testing.T) {
if err := checkCharset([]byte(`<meta charset="iso-8859-1">`)); err == nil {
t.Error("expected a charset error, not silent mojibake")
}
if err := checkCharset([]byte(`<meta charset="utf-8">`)); err != nil {
t.Errorf("utf-8 rejected: %v", err)
}
if err := checkCharset([]byte(`<html><body>no declaration</body></html>`)); err != nil {
t.Errorf("undeclared charset rejected: %v", err)
}
}
// TestLive is opt-in: it is the only test that talks to the site, and it is how
// you find out about drift the day it happens rather than the day the fixture
// is regenerated.
func TestLive(t *testing.T) {
if os.Getenv("SWEHOCKEY_LIVE") == "" {
t.Skip("set SWEHOCKEY_LIVE=1 to hit the real site")
}
cl := &Client{BaseURL: "https://stats.swehockey.se", Timeout: 60 * time.Second}
for _, tc := range []struct {
season int
layout string
}{
{20961, "modern"},
{18263, "modern"},
{17556, "legacy"},
} {
page, err := cl.get(t.Context(), SchedulePath(tc.season))
if err != nil {
t.Fatalf("season %d: %v", tc.season, err)
}
s, err := ParseSchedule(tc.season, bytes.NewReader(page))
if err != nil {
t.Fatalf("season %d: %v", tc.season, err)
}
if s.Layout != tc.layout {
t.Errorf("season %d: layout = %q, want %q", tc.season, s.Layout, tc.layout)
}
if len(s.Games) == 0 {
t.Errorf("season %d: no games", tc.season)
}
t.Logf("season %d: %d games, %d played, last update %s", s.SeasonID, len(s.Games), s.Played(), s.LastUpdate)
}
}
func renderMust(t *testing.T, s *Schedule) []byte {
t.Helper()
b, err := render("json", s)
if err != nil {
t.Fatal(err)
}
return b
}