diff --git a/internal/modules/lol/format.go b/internal/modules/lol/format.go index c350329..72f4321 100644 --- a/internal/modules/lol/format.go +++ b/internal/modules/lol/format.go @@ -84,6 +84,42 @@ func teamLabel(t Team) string { return "TBD" } +// declaredOutcome returns a team's upstream-declared series outcome ("win" or +// "loss"), or "" when upstream has not published one. Two distinct upstream +// shapes collapse to "": a missing `result` object, and the far more common +// `{"outcome": null, "gameWins": 0}`. +func declaredOutcome(t Team) string { + if t.Result == nil { + return "" + } + return t.Result.Outcome +} + +// seriesWins returns a team's games won in the series, 0 when upstream has sent +// no result. Only meaningful once an outcome has been declared — see +// scoreIsPublished. +func seriesWins(t Team) int { + if t.Result == nil { + return 0 + } + return t.Result.GameWins +} + +// scoreIsPublished reports whether a finished series has a score we can quote. +// +// lolesports drives `event.state` off the broadcast timeline and fills +// `result.outcome`/`result.gameWins` from a separate per-game ingestion path, +// so a match reads as "completed" for hours before (or without ever) gaining a +// score. In that window every team carries `{"outcome": null, "gameWins": 0}`, +// which a nil-check cannot catch because the object itself is present — and +// Go's zero value for the absent gameWins then renders as a literal 0. +// +// An outcome on either side is enough: it proves the ingestion ran, so the +// gameWins alongside it are real even if the other side's result is sparse. +func scoreIsPublished(t1, t2 Team) bool { + return declaredOutcome(t1) != "" || declaredOutcome(t2) != "" +} + // formatEventLine renders one match as a single line. Already HTML-safe; // caller can join with "\n" inside a league section. func formatEventLine(e ScheduleEvent) string { @@ -107,31 +143,28 @@ func formatEventLine(e ScheduleEvent) string { switch e.State { case "completed": - var w1, w2 int - if t1.Result != nil { - w1 = t1.Result.GameWins - } - if t2.Result != nil { - w2 = t2.Result.GameWins + // Upstream says the series is over but has published no outcome, so we + // have no score to report. Show the matchup and say so rather than let + // the absent gameWins render as a 0–0 that nobody played. + if !scoreIsPublished(t1, t2) { + return fmt.Sprintf("☑️ %s vs %s%s%s · score pending", t1Label, t2Label, bo, block) } left := t1Label - if t1.Result != nil && t1.Result.Outcome == "win" { + if declaredOutcome(t1) == "win" { left = "" + t1Label + "" } right := t2Label - if t2.Result != nil && t2.Result.Outcome == "win" { + if declaredOutcome(t2) == "win" { right = "" + t2Label + "" } - return fmt.Sprintf("✅ %s %d–%d %s%s%s", left, w1, w2, right, bo, block) + return fmt.Sprintf("✅ %s %d–%d %s%s%s", + left, seriesWins(t1), seriesWins(t2), right, bo, block) case "inProgress": - var w1, w2 int - if t1.Result != nil { - w1 = t1.Result.GameWins - } - if t2.Result != nil { - w2 = t2.Result.GameWins - } - return fmt.Sprintf("🔴 LIVE %s %d–%d %s%s%s", t1Label, w1, w2, t2Label, bo, block) + // No published-score guard here: a live series legitimately sits at 0–0 + // until its first game resolves, and upstream declares no outcome until + // the series ends. + return fmt.Sprintf("🔴 LIVE %s %d–%d %s%s%s", + t1Label, seriesWins(t1), seriesWins(t2), t2Label, bo, block) default: t, err := time.Parse(time.RFC3339, e.StartTime) if err != nil { diff --git a/internal/modules/lol/format_test.go b/internal/modules/lol/format_test.go index f3926e8..6a52451 100644 --- a/internal/modules/lol/format_test.go +++ b/internal/modules/lol/format_test.go @@ -66,6 +66,74 @@ func TestFormatEventLine_Completed_BoldsWinner(t *testing.T) { } } +// Upstream flips state to "completed" when the broadcast window closes, but +// fills gameWins/outcome from a separate per-game ingestion path. In the gap it +// sends {"outcome": null, "gameWins": 0} for both teams — a shape that must not +// be reported as a real 0–0 draw. +func TestFormatEventLine_CompletedWithoutResults_OmitsScore(t *testing.T) { + pending := &TeamResult{} // json `{"outcome": null, "gameWins": 0}` + e := ScheduleEvent{ + StartTime: "2026-07-25T17:30:00Z", + State: "completed", + BlockName: "Week 1", + League: League{Slug: "lec", Name: "LEC"}, + Match: Match{ + Teams: []Team{ + {Code: "MKOI", Result: pending}, + {Code: "KC", Result: pending}, + }, + Strategy: Strategy{Type: "bestOf", Count: 3}, + }, + } + got := formatEventLine(e) + if strings.Contains(got, "0–0") { + t.Errorf("fabricated 0–0 score for unscored match: %q", got) + } + if strings.Contains(got, "✅") { + t.Errorf("unscored match should not use the scored-result glyph: %q", got) + } + if !strings.Contains(got, "MKOI vs KC") { + t.Errorf("missing matchup: %q", got) + } + if !strings.Contains(got, "Bo3") || !strings.Contains(got, "Week 1") { + t.Errorf("lost static metadata: %q", got) + } +} + +// A missing result object entirely (no `result` key) is the same class of +// unknown as a null outcome. +func TestFormatEventLine_CompletedNilResult_OmitsScore(t *testing.T) { + e := mkEvent("completed", "lck", "LCK", "T1", "GEN", "2026-05-09T05:00:00Z") + got := formatEventLine(e) + if strings.Contains(got, "0–0") { + t.Errorf("fabricated 0–0 score for nil-result match: %q", got) + } +} + +// One side declaring an outcome is enough to trust the score, even if the +// other side's result is absent. +func TestFormatEventLine_CompletedPartialResult_KeepsScore(t *testing.T) { + e := ScheduleEvent{ + StartTime: "2026-05-09T05:00:00Z", + State: "completed", + League: League{Slug: "lck", Name: "LCK"}, + Match: Match{ + Teams: []Team{ + {Code: "T1", Result: &TeamResult{Outcome: "win", GameWins: 2}}, + {Code: "GEN"}, + }, + Strategy: Strategy{Count: 3}, + }, + } + got := formatEventLine(e) + if !strings.Contains(got, "2–0") { + t.Errorf("score dropped despite a declared outcome: %q", got) + } + if !strings.Contains(got, "T1") { + t.Errorf("winner not bolded: %q", got) + } +} + func TestFormatEventLine_InProgress(t *testing.T) { w := &TeamResult{GameWins: 1} e := ScheduleEvent{