本批七项(三处是现场报的 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 与两处前端注释的旧窗口说法一并订正。
367 lines
16 KiB
Go
367 lines
16 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|