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.
This commit is contained in:
2026-07-26 09:16:39 +07:00
parent c34199c08b
commit f151e68c0a
2 changed files with 118 additions and 17 deletions
+50 -17
View File
@@ -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 00 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 = "<b>" + t1Label + "</b>"
}
right := t2Label
if t2.Result != nil && t2.Result.Outcome == "win" {
if declaredOutcome(t2) == "win" {
right = "<b>" + t2Label + "</b>"
}
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 00
// 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 {
+68
View File
@@ -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 00 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, "00") {
t.Errorf("fabricated 00 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, "00") {
t.Errorf("fabricated 00 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, "20") {
t.Errorf("score dropped despite a declared outcome: %q", got)
}
if !strings.Contains(got, "<b>T1</b>") {
t.Errorf("winner not bolded: %q", got)
}
}
func TestFormatEventLine_InProgress(t *testing.T) {
w := &TeamResult{GameWins: 1}
e := ScheduleEvent{