Files
Scoreboard/server/internal/httpapi/live_events_test.go
T
AskaEth 08a5740fb2 feat: 分批发布 + 实时赛况页 + 显式批次层 + 成绩框实时提示 + 窄屏适配 + 版本号单一真相
## 分批发布(现场:100 米跑道只有 8 条,不能等所有人跑完才发)
- 空成绩从"错误"降级为"提示",不再阻塞发布;真会算错(写法无法识别、秒位 ≥ 60、
  手动名次缺名次)仍挡住。发布确认框说明"只发已填的 M 人,其余保持未发布"。
- 服务端核实:publishResults/computeEventScore 本就不会因缺成绩报错
  (collectCandidates 直接跳过没有 results 的报名项),根因在前端防呆。

## 实时赛况(新大屏页 + 定向接口)
- 新增 `GET /api/display/live`(guest 可读):返回「当前项目」判定 + 该项目**全量**已发布成绩
  (不受快照 latestResults 的 20 行截断)。回退链:manual 指定 > 排期命中 > 最近发布。
  设置键 display.live_events / display.live_source。
- 大屏新页 `live`「实时赛况」:1 个项目单栏、2 个自动左右分栏;失败保留上一次画面
  (下一场开始前持续显示上一场);30s 轮询(判定依赖当前时间)+ 快照变更触发。
- 管理端「大屏控制」新增「实时赛况」卡;该卡走 PUT /api/settings(admin-only),
  故对非管理员整组置灰 + 提示条(不新增角色)。

## 显式批次层(组别之下)
- 迁移 0019 `entries.heat`;批次仅是录入/展示分组,**不参与算分**(有"分批与不分批结果
  完全一致"的断言守住)。批量设置接口 + 导入 CSV 可选列 heat + 录入页批次下拉 +
  一键均分(按 N 人切片,已有批次号接着往后排)。

## 成绩输入框实时悬浮提示
- 聚焦格旁的浮动气泡:pointer-events:none、不占布局不改行高、贴底自动上翻;
  合法显示解析结果(1:06 → = 66 秒)、可疑/不合法显示原因并带那一格原文;
  与发布防呆共用同一套纯函数(entryGuards.ts),口径不会分叉。

## 窄屏 / 手机端布局
- 真浏览器(Playwright + chromium)在 1440/1024/768/390 四宽度逐页审计 + 溢出探针,
  四轮迭代修复;桌面端 1440 前后像素差异 < 0.25%(无回归);修掉如「修改密码」输入框
  塌成 22px 这类真问题。

## 版本号单一真相
- 新增仓库根 `VERSION`:构建时注入二进制(-X main.version)并同步 console/package.json
  的版本(产物名跟随)。源码里不再有人工维护的版本号(兜底值改为 `dev`,故意不写数字)。

测试:服务端 13 包全绿;前端 vue-tsc 通过、249 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q80、新增 devdocs/16 自定义大屏模板设计文档)。
2026-09-11 04:05:02 +08:00

206 lines
8.1 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 (
"net/http"
"strconv"
"testing"
"time"
)
type liveEventsBody struct {
Source string `json:"source"`
EventIDs []int64 `json:"eventIds"`
Events []struct {
Event struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"event"`
Reason string `json:"reason"`
Groups []struct {
EventID int64 `json:"eventId"`
CategoryID int64 `json:"categoryId"`
CategoryName string
Rows []struct {
EntryID int64 `json:"entryId"`
Rank *int `json:"rank"`
ResultText string `json:"resultText"`
} `json:"rows"`
} `json:"groups"`
} `json:"events"`
}
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)
}
}