Files
Scoreboard/server/internal/httpapi/live_events_test.go
T
AskaEth 56ed90ff8b fix(display,entry): 修大屏团体名次显示「-」与分项成绩第 3 栏缺第 1 名;发布改弹窗选口径;会话设备名与重命名;录分搜索框;版本 dev_0.5.15
本批七项(三处是现场报的 bug,四处是新功能):

① 大屏上「纯团体项目」(如团体 4x100 接力)的名次全是「-」
   根因不在算分:纯团体名次只落在 channel_ranks,而 results.rank 被有意收窄成
   「个人名次」、对团体主体一直是 NULL;大屏的两个读者只读 results.rank、没有兜底
   (导出件与积分明细下钻早就有兜底,所以当时是「导出有、屏幕没有」)。
   改成读侧回落:results.rank 优先、为空时用通道名次。算分与写回一行未动。
   ⚠️ 优先级的理由:只更新名次的发布只更新 results.rank、不重建通道名次,
   若通道名次优先,个人项目会在那种发布之后显示旧名次。
   ⚠️ 已发布的数据不需要重发或重算,升级后大屏下一次刷新即显示。
   ⚠️ 仍未覆盖:只在「团体名次」卡片里按单位录名次、成绩行上没填的那类(无成绩行可挂)。

② 分项成绩页第 3 栏看不到第 1 名
   根因:快照 latestResults 的窗口原来按「行」截断(LIMIT 20),而屏上是一组一栏,
   切口只能落在最旧那一栏(通常是第 3 栏)内部,把该组建号最早的行挤掉
   (手录项目从第 1 名往下录 → 第 1 名建号最早)。
   改成「最近 8 组 × 每组 10 行」(先按每组最新发布时间取 8 组,再每组按名次取前 10 行);
   组的 publishedAt 改为组内最新一条。底栏「最新完成项目」一起吃这个修复。
   ⚠️ 取舍:单组超过 10 行的部分不再下发(前端每栏只画 6 行、实时赛况上限 10 行);
   将来把每栏行数上限调到 10 以上时,必须同步调这里的 R。

③ 录分列表「运动员 / 队」列可限宽 + 逐行展开收起
   多人团体名单以前把整列撑宽。收起限宽 14em(省略号)、超过 24 个显示宽度
   (中文算 2)出现「展开 / 收起」;个人项目不出现按钮;展开状态只在本机内存。
   取舍:展开那一行会把列略微撑宽(成绩列右移约 80px),收起即恢复。

④ 录入页「以决赛成绩发布」从常驻勾选框改成发布弹窗里二选一
   默认「只更新名次(不计分、不发奖牌)」;每次打开都复位、不吃上一次;
   Alt+Enter 走同一个入口;取消 = 什么都不发生。
   审核页保持原样的常驻勾选框(两个页面口径一样、形态不同)。

⑤ 大屏控制页「在线大屏」列表:可读设备名 + 手动重命名 + 修重复条目
   - 自动设备标签(系统 · 浏览器 · 分辨率 · 手机/平板/桌面),由大屏端在握手时上报,
     服务端截断到 80/300 字符并清掉控制字符;老客户端不报时列表与升级前逐字一致。
     ⚠️ 浏览器没有读主机名/设备名的 API,自动标签只是辅助区分。
   - 手动重命名(录入员及以上,与切页同档):存在系统库 settings 的
     display.instance_aliases(机器级、重启与切赛事都不丢),清除即回到自动标签;
     显示优先级 alias → device → id(id 降级成小字)。上限 200 条,满了拒绝新增并提示,
     不静默顶掉别的设备。设备离线过久条目被清理后别名仍留着。
   - 修:以前给局域网设备切页后列表底部会多出一条该设备的离线条目
     (翻页会补发握手,而浏览器没有稳定 id、服务端每次新开一条实例)。
     现在浏览器本机记住实例 id(localStorage),服务端也复用同一条连接已有的 id。

⑥ 局域网地址条目都能出二维码
   以前只有第一条常驻出码;现在其余每条多一个「二维码」按钮:点一下临时显示,
   再点一次 / Esc / 点空白处收起,同一时刻只留一个,且只为当前打开的那条懒生成 SVG
   (不在渲染时为每条地址都算一遍)。第一条的常驻码与「复制链接」未变。

⑦ 录分列表加「搜姓名 / 号码布」搜索框(人多项目找人不费劲)
   复用既有的「姓名 / 工号 / 号码布」匹配口径(未新增第三份),支持模糊、大小写不敏感、
   全角空格、号码布数字口径(007 搜 7、7 搜 007);命中高亮;提示「找到 N 条 / 共 M 条」;
   × / Esc / 清除三种清空方式;搜不到给明确空状态。
   ⚠️ 搜索只影响表格显示:发布范围、进度条(仍是该批次总人数)、勾选、清空范围、
      预览算分一律不受影响,界面上也写明了这一点。
   ⚠️ 搜索状态下粘贴跟随「屏幕上看到的顺序」(隐藏的行不会被写到)——避免第二条成绩
      静默写到看不见的人身上。

测试:四套自检 ×2 全绿(finalcheck_0515*.log:前端 1096 例 / Console 96 例 / 服务端 14 条 ok,
EXIT=0)。①②都做了红/绿对照(改回旧实现,新用例当场变红;②的失败信息即现场形状
「第 3 栏应从第 1 名开始,实际名次 [5 6 7 8]」)。⑥⑦用真浏览器(playwright + chromium)
实机跑过(24 项 / 29 项全过,含「进度条数字不变」「搜索框里按 Alt+Enter 什么也不发生」)。

无数据库迁移;已有成绩、名次、积分与榜单不变。
文档:《升级说明》新增 dev_0.5.15;《使用手册》发布/录入/大屏控制三块与批量订正过期说法;
《常见问题》新增 Q105–Q107;《部署指南》《上线检查清单》同步;
devdocs/05(hello 新字段 + 别名接口)、13(第五十五轮)、16/06/07/18 与两处前端注释的旧窗口说法一并订正。
2026-09-17 18:19:38 +08:00

367 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}