package httpapi_test import ( "context" "database/sql" "net/http" "strconv" "testing" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" ) type liveEventsBody struct { Source string `json:"source"` EventIDs []int64 `json:"eventIds"` Events []liveEventBody `json:"events"` } type liveEventBody struct { Event struct { ID int64 `json:"id"` Name string `json:"name"` } `json:"event"` Reason string `json:"reason"` Groups []liveGroupBody `json:"groups"` } type liveGroupBody struct { EventID int64 `json:"eventId"` CategoryID int64 `json:"categoryId"` CategoryName string `json:"categoryName"` Rows []liveRowBody `json:"rows"` } type liveRowBody struct { EntryID int64 `json:"entryId"` Rank *int `json:"rank"` ResultText string `json:"resultText"` } type settingsBody struct { Display struct { LiveEvents []int64 `json:"liveEvents"` LiveSource string `json:"liveSource"` } `json:"display"` } // 设置读写:display.live_events / display.live_source 要能存取,非法值必须当场拒绝。 func TestSettingsLiveEventsRoundTrip(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{ "display": map[string]any{"liveEvents": []int64{ids.event}, "liveSource": "manual"}, }, true); status != http.StatusOK { t.Fatalf("保存实时赛况设置应 200,得到 %d %+v", status, e.Error) } status, e := env.do(client, http.MethodGet, "/api/settings", nil, false) if status != http.StatusOK { t.Fatalf("读取设置应 200,得到 %d %+v", status, e.Error) } got := decodeData[settingsBody](t, e) if got.Display.LiveSource != "manual" || len(got.Display.LiveEvents) != 1 || got.Display.LiveEvents[0] != ids.event { t.Fatalf("设置应原样读回,得到 %+v", got.Display) } // 库里以逗号分隔的 id 文本存储,随赛事包一起走。 var raw string if err := env.ev.QueryRow(`SELECT value FROM settings WHERE key = 'display.live_events'`).Scan(&raw); err != nil { t.Fatalf("读取存储值: %v", err) } if raw != strconv.FormatInt(ids.event, 10) { t.Fatalf("display.live_events 应存逗号分隔 id,得到 %q", raw) } // 清空人工指定:传空数组。 if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{ "display": map[string]any{"liveEvents": []int64{}}, }, true); status != http.StatusOK { t.Fatalf("清空人工指定应 200,得到 %d %+v", status, e.Error) } _, e = env.do(client, http.MethodGet, "/api/settings", nil, false) if cleared := decodeData[settingsBody](t, e); len(cleared.Display.LiveEvents) != 0 { t.Fatalf("清空后应回空数组,得到 %+v", cleared.Display.LiveEvents) } // 非法取值全部拒绝。 for _, patch := range []map[string]any{ {"display": map[string]any{"liveSource": "sometimes"}}, {"display": map[string]any{"liveEvents": []int64{ids.event, ids.event + 1, ids.event + 2}}}, {"display": map[string]any{"liveEvents": []int64{999999}}}, } { status, e := env.do(client, http.MethodPut, "/api/settings", patch, true) if status != http.StatusBadRequest || e.Error == nil || e.Error.Code != "VALIDATION_FAILED" { t.Fatalf("非法设置应 400 VALIDATION_FAILED,得到 %d %+v(%+v)", status, e.Error, patch) } } } // 当前项目的判定:manual 认人工指定;auto 先排期命中;都不命中回退到最近发布过成绩的项目;空赛事不炸。 func TestDisplayLiveEndpoint(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") env.createUser("admin", "admin-pass", "admin") adminClient, _ := env.client() env.login(adminClient, "admin", "admin-pass") // 第二、三个小项:只用来验证人工指定(最多 2 个)。 other := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'pending')`, ids.sport) other2 := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) VALUES (?, 'M400', '400米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'pending')`, ids.sport) guest, _ := env.client() live := func() liveEventsBody { t.Helper() status, e := env.do(guest, http.MethodGet, "/api/display/live", nil, false) if status != http.StatusOK || !e.OK { t.Fatalf("读取实时赛况应 200,得到 %d %+v", status, e.Error) } return decodeData[liveEventsBody](t, e) } setDisplay := func(patch map[string]any) { t.Helper() if status, e := env.do(adminClient, http.MethodPut, "/api/settings", map[string]any{"display": patch}, true); status != http.StatusOK { t.Fatalf("保存设置应 200,得到 %d %+v", status, e.Error) } } // 1) manual 只认人工指定(两个)。 setDisplay(map[string]any{"liveSource": "manual", "liveEvents": []int64{other, other2}}) got := live() if got.Source != "manual" || len(got.EventIDs) != 2 || got.EventIDs[0] != other || got.EventIDs[1] != other2 { t.Fatalf("manual 应只认人工指定的两个项目 %d/%d,得到 %+v", other, other2, got) } if len(got.Events) != 2 || got.Events[0].Reason != "manual" || got.Events[0].Event.ID != other { t.Fatalf("manual 命中原因应为 manual,得到 %+v", got.Events) } if len(got.Events[0].Groups) != 0 { t.Fatalf("还没发布成绩时应回空组,得到 %+v", got.Events[0].Groups) } // 2) auto:排期落在当前窗口内的小项命中。 scheduled := time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339) env.exec(t, `UPDATE events SET scheduled_at = ? WHERE id = ?`, scheduled, ids.event) setDisplay(map[string]any{"liveSource": "auto", "liveEvents": []int64{}}) got = live() if got.Source != "auto" || len(got.EventIDs) != 1 || got.EventIDs[0] != ids.event { t.Fatalf("auto 应命中排期窗口内的小项 %d,得到 %+v", ids.event, got) } if got.Events[0].Reason != "scheduled" { t.Fatalf("排期命中的原因应为 scheduled,得到 %+v", got.Events[0]) } // 3) auto:排期都不命中时回退到最近发布过成绩的小项。 env.exec(t, `UPDATE events SET scheduled_at = NULL WHERE id = ?`, ids.event) if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": []map[string]any{ {"entryId": ids.entryA, "resultValue": 11.5, "resultText": "11.50", "unit": "s", "status": "ok"}, {"entryId": ids.entryB, "status": "ok"}, }, }, true); status != http.StatusOK { t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error) } if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true); status != http.StatusOK { t.Fatalf("发布应 200,得到 %d %+v", status, e.Error) } got = live() if len(got.EventIDs) != 1 || got.EventIDs[0] != ids.event { t.Fatalf("无排期命中时应回退到最近发布的小项 %d,得到 %+v", ids.event, got) } if got.Events[0].Reason != "recent" { t.Fatalf("回退的原因应为 recent,得到 %+v", got.Events[0]) } if len(got.Events[0].Groups) != 1 || len(got.Events[0].Groups[0].Rows) != 1 { t.Fatalf("最近发布的小项应带上已发布成绩(1 条),得到 %+v", got.Events[0].Groups) } if rank := got.Events[0].Groups[0].Rows[0].Rank; rank == nil || *rank != 1 { t.Fatalf("已发布成绩应带名次,得到 %+v", got.Events[0].Groups[0].Rows[0]) } } // 全新赛事(一个小项都没有)时不能报错。 func TestDisplayLiveEmptyEvent(t *testing.T) { env := newTestEnv(t) client, _ := env.client() status, e := env.do(client, http.MethodGet, "/api/display/live", nil, false) if status != http.StatusOK || !e.OK { t.Fatalf("空赛事读取实时赛况应 200,得到 %d %+v", status, e.Error) } got := decodeData[liveEventsBody](t, e) if len(got.EventIDs) != 0 || len(got.Events) != 0 { t.Fatalf("空赛事应回空列表,得到 %+v", got) } if got.Source != "auto" { t.Fatalf("未配置时默认 auto,得到 %q", got.Source) } } // ---------- 大屏名次:团体项目回落 channel_ranks / 个人项目仍以 results.rank 为准 ---------- // latestResultsRank 取 WS 快照 latestResults 里某条报名项的名次——大屏的分项成绩页与底栏读的就是它。 func latestResultsRank(t *testing.T, env *testEnv, entryID int64) *int { t.Helper() hub := realtime.NewHub(env.ev, nil) t.Cleanup(func() { _ = hub.Close() }) snap, err := hub.Snapshot(context.Background()) if err != nil { t.Fatalf("构建快照: %v", err) } for _, group := range snap.LatestResults { for _, row := range group.Rows { if row.EntryID == entryID { return row.Rank } } } t.Fatalf("快照最新成绩里没有报名项 %d 的行:%+v", entryID, snap.LatestResults) return nil } // liveRanksForEvent 取实时赛况(GET /api/display/live)里某小项各报名项的名次。 // 该小项没被选中时直接失败——这类用例验证的正是它必须出现在大屏上。 func liveRanksForEvent(t *testing.T, env *testEnv, eventID int64) map[int64]*int { t.Helper() guest, _ := env.client() status, resp := env.do(guest, http.MethodGet, "/api/display/live", nil, false) if status != http.StatusOK || !resp.OK { t.Fatalf("读取实时赛况应 200,得到 %d %+v", status, resp.Error) } body := decodeData[liveEventsBody](t, resp) for _, ev := range body.Events { if ev.Event.ID != eventID { continue } out := make(map[int64]*int) for _, group := range ev.Groups { for _, row := range group.Rows { out[row.EntryID] = row.Rank } } return out } t.Fatalf("实时赛况里没有小项 %d(已选中 %+v)", eventID, body.EventIDs) return nil } // fmtRank 把可空名次转成可读文本:为空就是现场那个「-」。 func fmtRank(rank *int) string { if rank == nil { return "空(大屏上就是「-」)" } return strconv.Itoa(*rank) } // 现场回归:纯团体小项(event_type=team、通道 scope=team + basis=result,即「按成绩自动排名」的 // 团体 4x100 接力)发布之后,大屏上显示该项目名次的地方都必须给出真实名次,不能是「-」。 // // 团体名次只落 channel_ranks、results.rank 有意留空(applyComputedRanks 只认个人主体,工单 A-1/A-2), // 所以大屏两个读者都得回落到 channel_ranks:WS 快照的 latestResults(分项成绩页 + 底栏) // 与实时赛况的 liveResultGroups(/api/display/live)。 func TestDisplayShowsTeamRankForTeamEvent(t *testing.T) { env, ids, client := recorderWithSeed(t) eventID, categoryID, firstEntry := seedTeamEvent(t, env, ids) // seedTeamEvent 只建了一个单位的报名项,再补一个单位,名次才排得出来。 secondEntry := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, NULL, 'registered')`, eventID, categoryID, ids.teamB) publishResults(t, env, client, eventID, "team-rank", map[string]any{"entryId": firstEntry, "resultValue": 45.20, "resultText": "45.20", "unit": "s", "status": "ok"}, map[string]any{"entryId": secondEntry, "resultValue": 46.10, "resultText": "46.10", "unit": "s", "status": "ok"}, ) // 1) 写侧不变:团体名次仍然不写 results.rank。 if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, eventID); n != 0 { t.Fatalf("团体名次不该写回 results.rank,得到 %d 行", n) } // 2) 名次落在 channel_ranks 上,且 entry_id / result_id 都有值(下钻与导出靠它关联)。 var ( rank int resultID sql.NullInt64 ) if err := env.ev.QueryRow(`SELECT rank, result_id FROM channel_ranks WHERE event_id = ? AND category_id = ? AND scope = 'team' AND basis = 'result' AND entry_id = ?`, eventID, categoryID, firstEntry).Scan(&rank, &resultID); err != nil { t.Fatalf("读团体名次: %v", err) } if rank != 1 || !resultID.Valid { t.Fatalf("channel_ranks 应有一行 rank=1 且挂 result_id,得到 rank=%d resultID=%+v", rank, resultID) } // 3) / 4) 大屏两个读者都带上名次。 for entryID, want := range map[int64]int{firstEntry: 1, secondEntry: 2} { if got := latestResultsRank(t, env, entryID); got == nil || *got != want { t.Fatalf("快照(分项成绩页 + 底栏)里报名项 %d 的名次 = %s,期望 %d", entryID, fmtRank(got), want) } } live := liveRanksForEvent(t, env, eventID) for entryID, want := range map[int64]int{firstEntry: 1, secondEntry: 2} { if got := live[entryID]; got == nil || *got != want { t.Fatalf("实时赛况里报名项 %d 的名次 = %s,期望 %d(完整:%+v)", entryID, fmtRank(got), want, live) } } } // 不回归:个人项目的名次以 results.rank 为准,channel_ranks 只是空值兜底。 // // 「只更新名次」的发布(rankOnly)只写 results.rank、不重建 channel_ranks:这里先把决赛计分发布, // 再改成绩按「只更新名次」发布——results.rank 换成新名次,channel_ranks 停在决赛那一版。 // 大屏若改成 channel_ranks 优先,这种发布之后就会显示上一次的旧名次。 func TestDisplayPrefersResultRankAfterRankOnlyPublish(t *testing.T) { env, ids, client := recorderWithSeed(t) publishResults(t, env, client, ids.event, "rank-pref-final", map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, ) assertRanksBothReaders(t, env, ids.event, map[int64]int{ids.entryA: 1, ids.entryB: 2}) // 改成绩(现场是退回草稿改完再发布),按「只更新名次」发布:名次换位。 env.exec(t, `UPDATE results SET result_value = 11.92, result_text = '11.92' WHERE entry_id = ?`, ids.entryA) env.exec(t, `UPDATE results SET result_value = 11.58, result_text = '11.58' WHERE entry_id = ?`, ids.entryB) publishWith(t, env, client, ids.event, "rank-pref-rankonly", rankOnlyTrue) // 前提:results.rank 是新名次,channel_ranks 还是旧名次(这种发布不重建它)。 if got := resultRank(t, env, ids.entryA); got != 2 { t.Fatalf("只更新名次的发布应把 A 写成第 2 名,得到 %d", got) } if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks WHERE event_id = ? AND entry_id = ? AND rank = 1`, ids.event, ids.entryA); n != 1 { t.Fatalf("前提不成立:channel_ranks 应停在旧名次(A 第 1 名),得到 %d 行", n) } // 大屏必须跟着 results.rank 走,不能退回 channel_ranks 的旧名次。 assertRanksBothReaders(t, env, ids.event, map[int64]int{ids.entryA: 2, ids.entryB: 1}) } // assertRanksBothReaders 断言一组报名项在大屏两个读者上的名次(快照 + 实时赛况)。 func assertRanksBothReaders(t *testing.T, env *testEnv, eventID int64, want map[int64]int) { t.Helper() live := liveRanksForEvent(t, env, eventID) for entryID, rank := range want { if got := latestResultsRank(t, env, entryID); got == nil || *got != rank { t.Fatalf("快照(分项成绩页 + 底栏)里报名项 %d 的名次 = %s,期望 %d", entryID, fmtRank(got), rank) } if got := live[entryID]; got == nil || *got != rank { t.Fatalf("实时赛况里报名项 %d 的名次 = %s,期望 %d(完整:%+v)", entryID, fmtRank(got), rank, live) } } }