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.
446 lines
14 KiB
Go
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
|
|
}
|