## 分批发布(现场: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 自定义大屏模板设计文档)。
206 lines
8.1 KiB
Go
206 lines
8.1 KiB
Go
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)
|
||
}
|
||
}
|