From f151e68c0a552019647d8ce6f0ef294c8ba54956 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 26 Jul 2026 09:16:39 +0700 Subject: [PATCH] fix(lol): omit score when a finished match has none published lolesports drives event.state off the broadcast timeline but fills result.outcome and result.gameWins from a separate per-game ingestion path. A match therefore reads as "completed" for hours before gaining a score, and in that window every team carries {"outcome": null, "gameWins": 0}. The renderer used `Result != nil` as its has-a-score test, which cannot catch this: the result object is present, only its contents are empty. The absent gameWins fell through to Go's zero value and printed as a literal 0, so an unscored series was reported as a definitive draw ("MKOI 0-0 KC") with neither side bolded. Gate the score on a declared outcome instead, and render unscored finished matches as the matchup alone with a pending marker. An outcome on either side is enough to trust the score - it proves the ingestion ran. inProgress keeps rendering 0-0, which is truthful for a live series that has not resolved its first game. Also collapses the score extraction both branches duplicated into shared helpers. --- internal/modules/lol/format.go | 67 ++++++++++++++++++++-------- internal/modules/lol/format_test.go | 68 +++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 17 deletions(-) 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{