Files
AskaEth 9c52dc4b38 feat(display): 大屏「赛事日程侧边栏」(手填日程 + 竖向进度轴 + 热开关 + 每屏覆盖);版本 dev_0.5.16
主人点名要的功能:大屏控制页加一个**可热开关**的侧边栏,在**指定的屏幕**上(不管它当前在哪一页)叠一个
**手填的赛事日程列表**,带**竖向进度条**;日程**不与登记的小项时间关联**、完全手填;竖向时间轴要好看,
**字多时字号自动缩放与换行**要做到位(不能过小、不能过大)。

设计口径(主人逐条拍板):放不下时**围绕当前项开窗**;进度条是**一条竖向时间轴 + 三态节点**;
**深色半透明 + 毛玻璃**;已完成的项**淡化保留**;备注**默认上屏 + 一个总开关**;
明细覆盖层 / 全屏公告期间**自动收起**、结束后自动回来;编辑权限**录入员及以上**;单屏覆盖按**实例 id**。

服务端(走大屏公告那条路 —— PUT /api/settings 是 admin-only,会与「录入员可改」冲突)
- 新增 `schedule` 动作 + 白名单;`POST /api/display/command` 同一个 handler:**先校验 → 再转发 → 最后落库**
  (校验排在转发前,非法配置不会在大屏上闪一帧)
- 落库到赛事库 settings 的 `display.schedule`(**不新建表、不写迁移**,随赛事包走);
  与公告共用那份「写设置键 + bumpVersion」的实现
- **读写不对称容错**:写侧严格 400(含中文提示与第几条),读侧坏数据一律静默当空 —— 老数据 / 手改库不会炸大屏
- 上限:40 条;time ≤20 / title ≤40 / note ≤60(按字符,清洗控制字符);宽度夹 15–40;覆盖 ≤50 条
- 口径收口三处:**时间允许为空**(与管理端同口径)、**showNote 缺键 = true**(区分「键不存在」与「显式 false」)、
  **`inherit` 不落库**(不再白占覆盖预算)

大屏端
- 新增 `ScheduleSidebar.vue` + `schedulePanel.ts`(纯函数:开窗 / 三态 / 整体进度 / 字号与行数 / 按显示宽度截断 /
  单屏覆盖 / 抑制 / 指纹去重)
- 字号是**舞台设计坐标**(1920×1080),靠 `.stage` 的 `transform: scale()` 整体缩放,**不做二次缩放**
  (wiring 断言禁止出现 `window.`/`innerWidth`/`vh`);复用既有的 `fitRowFontPx`(**16–56px 硬上下限**)与 `textEm`;
  固定点迭代 ≤4 轮;**贴下限时宁可行数少也不缩字**;标题 ≤3 行、备注 ≤2 行,超出截断
- 容器 `position:absolute; inset:0; z-index:50`(**不用 fixed** —— `.stage` 带 transform,是 fixed 后代的包含块);
  **独立于 detail 状态机**(goto 会关明细、明细会停轮播,侧边栏必须不受影响)
- 深色半透明 + `backdrop-filter`,并带 `@supports not (backdrop-filter: blur(1px))` 退化为更不透明的深底
- 关着时**不留任何 DOM**(逐字节等同于没有这个功能);`prefers-reduced-motion` 下不做动效

管理端
- 大屏控制页(左列、公告之后)新增「赛事日程」卡片:条目表格(时间/标题/备注/已完成 + 上移/下移/删除)、
  总开关、备注上屏、位置与宽度、**每块屏三态覆盖**(跟随 / 强制开 / 强制关;切回「跟随」会真删掉那条键)
- 保存走 `adminApi.displayCommand`(**不走 admin-only 的设置接口**);本地校验不过**一个请求都不发**
- 排序只有上移 / 下移(该页面禁止挂 pointerdown/mousedown 监听,有既有测试钉着)

测试
- 服务端 16 个包全绿;前端 **1174 例**全过(基线 1096,本批 +78:大屏端 44 + 管理端 34)
- 大屏端另有 **40 项 playwright 实机**(用 `page.routeWebSocket` 假造实时通道):开/关后 DOM 无残留、
  五个页面下都在、`lock=0` 真等轮播翻页且侧边栏不掉、明细与公告期间自动收起并自动回来、
  **1080p 与 4K 字号完全相同**、`reducedMotion` 下无动画;并钉住两条硬指标(真机列表高度 ≥ 纯函数预算、
  容量×行高 ≤ 真机高度)

文档:`docs/升级说明` 新增 `dev_0.5.16`;`使用手册` 9.2 + 17.6;`常见问题` Q108–Q111;`上线检查清单`/`部署指南`
各一条;`devdocs/05·07·13` 同步。

⚠️ 一条**既有边界**(本版故意没改,按主人「兼容性最大化」的要求):导出的名次口径是「通道名次优先」、
屏幕是「成绩行名次优先」,只在「先按决赛计分发布、之后又只更新名次发布一次」这种序列下分叉
(导出显示上次计分的名次)。正常赛程遇不到;遇到时对该组再「按决赛成绩发布」一次即恢复一致。
已写进《升级说明》④ 与《常见问题》Q111。
2026-09-17 20:04:50 +08:00

1134 lines
47 KiB
Go
Raw Permalink 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 realtime
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
)
// 快照必须与数据库同源:版本号取自 change_log,各列表取真实表,
// 且空库时序列化为 [] 而不是 null,避免前端判空逻辑踩坑。
func TestSnapshotContents(t *testing.T) {
env := newTestEnv(t)
db := env.db
mustExec(t, db, `UPDATE settings SET value = ? WHERE key = 'event.name'`, `测试运动会`)
team1 := insertTeam(t, db, "T1", "第一单位", "一队")
event1 := insertEvent(t, db, "E1", "100米")
cat1 := insertCategory(t, db, event1, "男子")
athlete1 := insertAthlete(t, db, "张三", team1)
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
insertChannel(t, db, event1, "athlete")
result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 9)
mustExec(t, db, `INSERT INTO records(event_id, level, result_value, result_text, holder_name, holder_team_id, achieved_at, is_current, source)
VALUES (?, 'meet', 11.2, '11.20', '李四', ?, '2025-09-01', 1, 'history')`, event1, team1)
version := insertChange(t, db, "result", 1, ActionUpsert, `{"rank":1}`)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if snap.Version != version {
t.Fatalf("快照版本应为 %d,得到 %d", version, snap.Version)
}
if snap.Settings.Event.Name != "测试运动会" {
t.Fatalf("settings 应聚合出嵌套 event.name,得到 %+v", snap.Settings.Event)
}
if len(snap.Teams) != 1 || snap.Teams[0].Code != "T1" || !snap.Teams[0].Active {
t.Fatalf("单位不符: %+v", snap.Teams)
}
if len(snap.Events) != 1 {
t.Fatalf("小项数应为 1,得到 %d", len(snap.Events))
}
if len(snap.Records) != 1 || snap.Records[0].HolderName != "李四" || snap.Records[0].ResultValue == nil {
t.Fatalf("纪录不符: %+v", snap.Records)
}
}
// 快照的线上 JSON 形状必须与 devdocs/05 第 3 节、web/src/shared/types.ts 一致:
// settings 是嵌套对象、standings 用 totalPoints/teamCode/…、latestResults 按小项+组别分组。
// 只断言 Go 字段会漏掉 json 标签写错这类线上事故,所以这里直接检查序列化结果。
func TestSnapshotWireContract(t *testing.T) {
env := newTestEnv(t)
db := env.db
mustExec(t, db, `UPDATE settings SET value = ? WHERE key = 'event.name'`, `测试运动会`)
team1 := insertTeam(t, db, "T1", "第一单位", "一队")
mustExec(t, db, `UPDATE teams SET logo_path = '/uploads/teams/t1.png' WHERE id = ?`, team1)
event1 := insertEvent(t, db, "E1", "男子 100 米")
cat1 := insertCategory(t, db, event1, "青年组")
athlete1 := insertAthlete(t, db, "张伟", team1)
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
ch1 := insertChannel(t, db, event1, "athlete")
result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
insertChannelRank(t, db, event1, cat1, ch1, entry1, result1, team1, athlete1, "athlete", "result", 1, 11.58)
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 9)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
raw, err := json.Marshal(snap)
if err != nil {
t.Fatalf("序列化快照: %v", err)
}
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
t.Fatalf("解析快照 JSON: %v", err)
}
// settings 必须是嵌套 EventSettings,不能再是点号扁平 map。
var settings map[string]json.RawMessage
if err := json.Unmarshal(top["settings"], &settings); err != nil {
t.Fatalf("settings 不是对象: %s", top["settings"])
}
if _, flat := settings["event.name"]; flat {
t.Fatalf("settings 仍是点号扁平 map: %s", top["settings"])
}
for _, key := range []string{"event", "display", "standings", "defaultProfileId", "requireApproval"} {
if _, ok := settings[key]; !ok {
t.Fatalf("settings 缺少嵌套字段 %s: %s", key, top["settings"])
}
}
var parsed dto.EventSettings
if err := json.Unmarshal(top["settings"], &parsed); err != nil {
t.Fatalf("按 EventSettings 解析失败: %v", err)
}
if parsed.Event.Name != "测试运动会" || parsed.Display.Title != "测试运动会" {
t.Fatalf("settings 默认值口径不符: %+v", parsed)
}
if parsed.Display.RotateSecond != 15 || parsed.Standings.IndividualToTeamWeight != 1 {
t.Fatalf("settings 默认值应为 REST 的 15s / 系数 1: %+v", parsed)
}
if parsed.DefaultProfileID == nil || *parsed.DefaultProfileID != 1 {
t.Fatalf("defaultProfileId 应由 scoring.default_profile 反查得到: %+v", parsed.DefaultProfileID)
}
// standings 必须是 totalPoints/teamCode/teamName/medalTotal/rankChange。
var rows []map[string]json.RawMessage
if err := json.Unmarshal(top["standings"], &rows); err != nil {
t.Fatalf("standings 不是数组: %s", top["standings"])
}
if len(rows) != 1 {
t.Fatalf("榜单行数应为 1,得到 %d", len(rows))
}
// medalRank 是单位奖牌榜的独立名次,必须随快照下发,否则大屏奖牌页只能退回积分榜名次。
for _, key := range []string{"teamId", "teamCode", "teamName", "totalPoints", "medalTotal", "rankChange", "gold", "rank", "medalRank"} {
if _, ok := rows[0][key]; !ok {
t.Fatalf("standings 缺少契约字段 %s: %s", key, top["standings"])
}
}
for _, banned := range []string{"points", "code", "name", "medals"} {
if _, ok := rows[0][banned]; ok {
t.Fatalf("standings 仍是 domain.StandingsRow 的字段 %s: %s", banned, top["standings"])
}
}
var typed []dto.StandingRow
if err := json.Unmarshal(top["standings"], &typed); err != nil {
t.Fatalf("按 StandingRow 解析失败: %v", err)
}
if typed[0].TeamCode != "T1" || typed[0].TotalPoints != 9 || typed[0].MedalTotal != 1 {
t.Fatalf("榜单内容不符: %+v", typed[0])
}
if typed[0].RankChange != nil {
t.Fatalf("快照没有对比基准,rankChange 应为 null: %+v", typed[0].RankChange)
}
// latestResults 必须按小项 + 组别分组,且行里带契约字段。
var groups []map[string]json.RawMessage
if err := json.Unmarshal(top["latestResults"], &groups); err != nil {
t.Fatalf("latestResults 不是数组: %s", top["latestResults"])
}
if len(groups) != 1 {
t.Fatalf("分组数应为 1,得到 %d: %s", len(groups), top["latestResults"])
}
for _, key := range []string{"eventId", "eventCode", "eventName", "categoryId", "categoryName", "publishedAt", "rows"} {
if _, ok := groups[0][key]; !ok {
t.Fatalf("成绩分组缺少契约字段 %s: %s", key, top["latestResults"])
}
}
var rawRows []map[string]json.RawMessage
if err := json.Unmarshal(groups[0]["rows"], &rawRows); err != nil {
t.Fatalf("成绩行不是数组: %s", groups[0]["rows"])
}
if len(rawRows) != 1 {
t.Fatalf("成绩行数应为 1,得到 %d: %s", len(rawRows), groups[0]["rows"])
}
// 行里必须带 logoPath(值为 null 时也要有键),否则前端读不到会拿到 undefined。
for _, key := range []string{"entryId", "teamId", "teamCode", "teamName", "shortName", "logoPath", "athleteName", "resultText"} {
if _, ok := rawRows[0][key]; !ok {
t.Fatalf("成绩行缺少契约字段 %s: %s", key, groups[0]["rows"])
}
}
var typedGroups []dto.EventResultGroup
if err := json.Unmarshal(top["latestResults"], &typedGroups); err != nil {
t.Fatalf("按 EventResultGroup 解析失败: %v", err)
}
resultRows := typedGroups[0].Rows
if len(resultRows) != 1 || resultRows[0].EntryID != entry1 || resultRows[0].TeamCode != "T1" ||
resultRows[0].AthleteName == nil || *resultRows[0].AthleteName != "张伟" {
t.Fatalf("成绩行不符: %+v", resultRows)
}
if resultRows[0].LogoPath == nil || *resultRows[0].LogoPath != "/uploads/teams/t1.png" {
t.Fatalf("成绩行应带所属单位的 logo: %+v", resultRows[0])
}
}
// 同一小项 + 组别的多条成绩必须落进同一组,不同组别/小项拆成不同组,组内按名次排序。
func TestSnapshotLatestResultsGrouped(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
team2 := insertTeam(t, db, "T2", "第二单位", "")
// 第一单位有 logo,第二单位是历史数据的空串(应下发 null)。
mustExec(t, db, `UPDATE teams SET logo_path = '/uploads/teams/t1.png' WHERE id = ?`, team1)
mustExec(t, db, `UPDATE teams SET logo_path = '' WHERE id = ?`, team2)
event1 := insertEvent(t, db, "E1", "男子 100 米")
event2 := insertEvent(t, db, "E2", "女子 100 米")
catA := insertCategory(t, db, event1, "青年组")
catB := insertCategory(t, db, event1, "中年组")
catC := insertCategory(t, db, event2, "青年组")
a1 := insertAthlete(t, db, "张伟", team1)
a2 := insertAthlete(t, db, "李强", team2)
a3 := insertAthlete(t, db, "王磊", team1)
a4 := insertAthlete(t, db, "李娜", team2)
e1 := insertEntry(t, db, event1, catA, team1, a1)
e2 := insertEntry(t, db, event1, catA, team2, a2)
e3 := insertEntry(t, db, event1, catB, team1, a3)
e4 := insertEntry(t, db, event2, catC, team2, a4)
// 故意先写名次靠后的行,验证组内会按名次重排而不是按取数顺序。
insertResult(t, db, event1, e2, 2, "11.72", 11.72, "2026-09-09T10:00:02Z")
insertResult(t, db, event1, e1, 1, "11.58", 11.58, "2026-09-09T10:00:01Z")
insertResult(t, db, event1, e3, 1, "12.30", 12.30, "2026-09-09T10:00:03Z")
insertResult(t, db, event2, e4, 1, "13.04", 13.04, "2026-09-09T10:00:04Z")
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.LatestResults) != 3 {
t.Fatalf("分组数应为 3,得到 %d: %+v", len(snap.LatestResults), snap.LatestResults)
}
byCategory := make(map[int64]dto.EventResultGroup, len(snap.LatestResults))
for _, g := range snap.LatestResults {
byCategory[g.CategoryID] = g
}
groupA, ok := byCategory[catA]
if !ok {
t.Fatalf("缺少青年组分组: %+v", snap.LatestResults)
}
if groupA.EventID != event1 || groupA.EventCode != "E1" || groupA.EventName != "男子 100 米" || groupA.CategoryName != "青年组" {
t.Fatalf("分组元信息不符: %+v", groupA)
}
if groupA.PublishedAt == nil || *groupA.PublishedAt != "2026-09-09T10:00:02Z" {
t.Fatalf("分组发布时间应取组内最新一条: %+v", groupA.PublishedAt)
}
if len(groupA.Rows) != 2 {
t.Fatalf("青年组应有 2 行,得到 %d: %+v", len(groupA.Rows), groupA.Rows)
}
if groupA.Rows[0].EntryID != e1 || groupA.Rows[1].EntryID != e2 {
t.Fatalf("组内应按名次排序: %+v", groupA.Rows)
}
if groupA.Rows[0].TeamCode != "T1" || groupA.Rows[0].TeamName != "第一单位" || groupA.Rows[0].ResultText != "11.58" {
t.Fatalf("成绩行字段不符: %+v", groupA.Rows[0])
}
// 每行的 logo 跟着「该行所属单位」走,与队名同源。
if groupA.Rows[0].LogoPath == nil || *groupA.Rows[0].LogoPath != "/uploads/teams/t1.png" {
t.Fatalf("第一单位的成绩行应带自己的 logo: %+v", groupA.Rows[0])
}
if groupA.Rows[1].LogoPath != nil {
t.Fatalf("无 logo 的单位应下发 null 而不是空串: %+v", groupA.Rows[1])
}
if len(byCategory[catB].Rows) != 1 || len(byCategory[catC].Rows) != 1 {
t.Fatalf("其余组别应各自成组: %+v", snap.LatestResults)
}
}
// 快照榜单口径必须与 REST 一致:个人流水按系数折算进单位总分。
func TestSnapshotStandingsAppliesIndividualWeight(t *testing.T) {
env := newTestEnv(t)
db := env.db
mustExec(t, db, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
team1 := insertTeam(t, db, "T1", "第一单位", "")
team2 := insertTeam(t, db, "T2", "第二单位", "")
event1 := insertEvent(t, db, "E1", "100米")
cat1 := insertCategory(t, db, event1, "男子")
athlete1 := insertAthlete(t, db, "张三", team1)
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
insertChannel(t, db, event1, "athlete")
result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
// 团体报名项没有运动员,流水只记单位。
cat2 := insertCategory(t, db, event1, "团体")
entry2 := insertEntry(t, db, event1, cat2, team2, 0)
result2 := insertResult(t, db, event1, entry2, 1, "45.20", 45.20, "2026-09-09T10:00:01Z")
// 个人流水 10 分按 0.5 折算成 5;仅记单位的流水不受系数影响。
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 10)
insertLedger(t, db, team2, 0, event1, entry2, result2, 4)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
points := make(map[string]float64, len(snap.Standings))
for _, row := range snap.Standings {
points[row.TeamCode] = row.TotalPoints
}
if points["T1"] != 5 {
t.Fatalf("个人流水应按系数 0.5 折算,T1 应得 5 分,得到 %v", points)
}
if points["T2"] != 4 {
t.Fatalf("仅记单位的流水不应折算,T2 应得 4 分,得到 %v", points)
}
if len(snap.Standings) == 0 || snap.Standings[0].TeamCode != "T1" {
t.Fatalf("按总分排序应 T1 在前: %+v", snap.Standings)
}
}
// 奖牌开关与 REST 一致:关闭后团体项目奖牌不计入单位奖牌榜。
func TestSnapshotStandingsMedalSwitch(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
event1 := insertEvent(t, db, "E1", "4x100 米接力")
cat1 := insertCategory(t, db, event1, "男子")
entry1 := insertEntry(t, db, event1, cat1, team1, 0)
ch1 := insertChannel(t, db, event1, "team")
r1 := insertResult(t, db, event1, entry1, 1, "45.20", 45.20, "2026-09-09T10:00:00Z")
insertChannelRank(t, db, event1, cat1, ch1, entry1, r1, team1, 0, "team", "result", 1, 45.20)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.Standings) != 1 || snap.Standings[0].Gold != 1 {
t.Fatalf("默认应计入团体项目奖牌: %+v", snap.Standings)
}
mustExec(t, db, `UPDATE settings SET value = 'false' WHERE key = 'standings.medal_counts_team_events'`)
snap, err = env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.Standings) != 1 || snap.Standings[0].Gold != 0 || snap.Standings[0].MedalTotal != 0 {
t.Fatalf("关闭奖牌开关后应清零: %+v", snap.Standings)
}
}
// 只统计配了计分通道的项目,且多通道不会把同一枚奖牌重复计入。
func TestSnapshotStandingsOnlyScoredChannels(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
team2 := insertTeam(t, db, "T2", "第二单位", "")
// event1 没有计分通道:即使有已发布名次也不应产生奖牌。
event1 := insertEvent(t, db, "E1", "表演项目")
cat1 := insertCategory(t, db, event1, "男子")
entry1 := insertEntry(t, db, event1, cat1, team1, 0)
insertResult(t, db, event1, entry1, 1, "1.00", 1, "2026-09-09T10:00:00Z")
// event2 配了计分通道:名次才会计入奖牌榜。
event2 := insertEvent(t, db, "E2", "团体项目")
cat2 := insertCategory(t, db, event2, "男子")
entry2 := insertEntry(t, db, event2, cat2, team2, 0)
ch2 := insertChannel(t, db, event2, "team")
r2 := insertResult(t, db, event2, entry2, 1, "9.99", 9.99, "2026-09-09T10:00:01Z")
insertChannelRank(t, db, event2, cat2, ch2, entry2, r2, team2, 0, "team", "result", 1, 9.99)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
medals := make(map[string]int, len(snap.Standings))
for _, row := range snap.Standings {
medals[row.TeamCode] = row.Gold
}
if medals["T1"] != 0 {
t.Fatalf("无计分通道的项目不应计奖牌,得到 %v", medals)
}
if medals["T2"] != 1 {
t.Fatalf("有计分通道的项目应计入奖牌,得到 %v", medals)
}
}
// 只排名不计分(scored=0)的项目即使配了计分通道也不计入快照奖牌榜,与导出侧同口径。
func TestSnapshotStandingsExcludesUnscoredEvents(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
event1 := insertEvent(t, db, "E1", "表演项目")
mustExec(t, db, `UPDATE events SET scored = 0 WHERE id = ?`, event1)
cat1 := insertCategory(t, db, event1, "男子")
entry1 := insertEntry(t, db, event1, cat1, team1, 0)
insertChannel(t, db, event1, "team")
insertResult(t, db, event1, entry1, 1, "9.99", 9.99, "2026-09-09T10:00:00Z")
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.Standings) != 1 || snap.Standings[0].Gold != 0 || snap.Standings[0].MedalTotal != 0 {
t.Fatalf("scored=0 的项目不应产出奖牌: %+v", snap.Standings)
}
}
func TestEmptySnapshotMarshalsEmptyLists(t *testing.T) {
env := newTestEnv(t)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建空快照: %v", err)
}
raw, err := json.Marshal(snap)
if err != nil {
t.Fatalf("序列化快照: %v", err)
}
for _, want := range []string{`"teams":[]`, `"events":[]`, `"standings":[]`, `"records":[]`, `"latestResults":[]`} {
if !strings.Contains(string(raw), want) {
t.Fatalf("空快照应包含 %s,实际 %s", want, raw)
}
}
// 空库也要下发结构完整的 settings,前端才能安全地读 settings.event.name。
var parsed struct {
Settings dto.EventSettings `json:"settings"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("解析空快照: %v", err)
}
if parsed.Settings.Event.Name == "" || parsed.Settings.Display.RotateSecond == 0 {
t.Fatalf("空快照 settings 应带默认值: %+v", parsed.Settings)
}
}
// 赛事日程侧边栏随 settings 下发(不用改快照:display.* 不是 admin-only 字段)。
// 库里那个值被改坏时快照必须照常构建、schedule 当空 —— 一条脏设置不能让大屏连不上。
func TestSnapshotScheduleToleratesBrokenValue(t *testing.T) {
env := newTestEnv(t)
db := env.db
mustExec(t, db, `INSERT INTO settings(key, value, updated_at) VALUES('display.schedule', ?, datetime('now'))`,
`{"items":[{"time":"08:30","title":"开幕式","done":true}],`+
`"visible":true,"side":"left","widthPercent":22,"showNote":true}`)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
got := snap.Settings.Display.Schedule
if got == nil || !got.Visible || got.Side != dto.ScheduleSideLeft || len(got.Items) != 1 {
t.Fatalf("日程应随快照下发: %+v", got)
}
// 线上 JSON 用大屏解析的键名(前端与 REST 同一份形状)。
raw, err := json.Marshal(snap.Settings)
if err != nil {
t.Fatalf("序列化 settings: %v", err)
}
if !strings.Contains(string(raw), `"schedule":{"items":[{"time":"08:30","title":"开幕式","done":true}]`) {
t.Fatalf("快照里日程的形状不对: %s", raw)
}
mustExec(t, db, `UPDATE settings SET value = '{坏 JSON' WHERE key = 'display.schedule'`)
snap, err = env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("坏数据下快照仍应构建成功: %v", err)
}
if snap.Settings.Display.Schedule != nil {
t.Fatalf("坏数据应读成 null,得到 %+v", snap.Settings.Display.Schedule)
}
}
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
t.Fatalf("执行 SQL %q: %v", q, err)
}
}
// ---------- 造数助手 ----------
func insertTeam(t *testing.T, db *sql.DB, code, name, shortName string) int64 {
t.Helper()
res, err := db.Exec(`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES (?, ?, ?, 0, 1)`,
code, name, shortName)
if err != nil {
t.Fatalf("插入单位 %s: %v", code, err)
}
return lastID(t, res)
}
func insertEvent(t *testing.T, db *sql.DB, code, name string) int64 {
t.Helper()
if _, err := db.Exec(`INSERT OR IGNORE INTO sports(code, name) VALUES ('S1', '田径')`); err != nil {
t.Fatalf("插入大项: %v", err)
}
res, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, ?, ?, 'individual', 'auto', 'time', 'asc', 'published')`, code, name)
if err != nil {
t.Fatalf("插入小项 %s: %v", code, err)
}
return lastID(t, res)
}
func insertCategory(t *testing.T, db *sql.DB, eventID int64, name string) int64 {
t.Helper()
res, err := db.Exec(`INSERT INTO event_categories(event_id, name) VALUES (?, ?)`, eventID, name)
if err != nil {
t.Fatalf("插入组别 %s: %v", name, err)
}
return lastID(t, res)
}
func insertAthlete(t *testing.T, db *sql.DB, name string, teamID int64) int64 {
t.Helper()
res, err := db.Exec(`INSERT INTO athletes(name, gender, team_id) VALUES (?, '男', ?)`, name, teamID)
if err != nil {
t.Fatalf("插入运动员 %s: %v", name, err)
}
return lastID(t, res)
}
// insertEntry 在 athleteID 为 0 时写 NULL,表示团体项目报名项。
func insertEntry(t *testing.T, db *sql.DB, eventID, categoryID, teamID, athleteID int64) int64 {
t.Helper()
var athlete any
if athleteID != 0 {
athlete = athleteID
}
res, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (?, ?, ?, ?, '101')`,
eventID, categoryID, teamID, athlete)
if err != nil {
t.Fatalf("插入报名项: %v", err)
}
return lastID(t, res)
}
// insertChannel 建一条按成绩排名的通道,返回通道 id(写通道名次时要用)。
func insertChannel(t *testing.T, db *sql.DB, eventID int64, scope string) int64 {
t.Helper()
res, err := db.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, ?, 'result', 1, 0)`, eventID, scope)
if err != nil {
t.Fatalf("插入计分通道: %v", err)
}
return lastID(t, res)
}
// insertChannelRank 写一条通道名次:大屏奖牌榜的读取来源(loadRanked 读 channel_ranks)。
// entryID 为 0 时主体是单位(subject_key 用 t:<teamID>),否则是报名项的 e:<entryID>。
func insertChannelRank(t *testing.T, db *sql.DB, eventID, categoryID, channelID, entryID, resultID, teamID, athleteID int64,
scope, basis string, rank int, value float64) {
t.Helper()
subject := fmt.Sprintf("e:%d", entryID)
if entryID == 0 {
subject = fmt.Sprintf("t:%d", teamID)
}
var entry, result, athlete, team any
if entryID != 0 {
entry = entryID
}
if resultID != 0 {
result = resultID
}
if athleteID != 0 {
athlete = athleteID
}
if teamID != 0 {
team = teamID
}
mustExec(t, db, `INSERT INTO channel_ranks (event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, athlete_id, team_id, rank, points, result_value, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 'ok')`,
eventID, categoryID, channelID, basis, scope, subject, entry, result, athlete, team, rank, value)
}
func insertResult(t *testing.T, db *sql.DB, eventID, entryID int64, rank int, text string, value float64, verifiedAt string) int64 {
t.Helper()
res, err := db.Exec(`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state, status, verified_at)
VALUES (?, ?, ?, ?, ?, 's', 'published', 'ok', ?)`, eventID, entryID, rank, text, value, verifiedAt)
if err != nil {
t.Fatalf("插入成绩: %v", err)
}
return lastID(t, res)
}
// insertLedger 写一条积分流水;athleteID 为 0 表示「仅记单位」。
func insertLedger(t *testing.T, db *sql.DB, teamID, athleteID, eventID, entryID, resultID int64, points float64) {
t.Helper()
var athlete, entry, result any
if athleteID != 0 {
athlete = athleteID
}
if entryID != 0 {
entry = entryID
}
if resultID != 0 {
result = resultID
}
mustExec(t, db, `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (?, ?, ?, ?, ?, 'result', 1, ?)`, teamID, athlete, eventID, entry, result, points)
}
func lastID(t *testing.T, res sql.Result) int64 {
t.Helper()
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("读取自增 id: %v", err)
}
return id
}
// 快照榜单排序必须读规则包落库的 standings.tie_break,不能只走硬编码默认序。
func TestSnapshotTieBreakFromSettings(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
team2 := insertTeam(t, db, "T2", "第二单位", "")
event1 := insertEvent(t, db, "E1", "100米")
event2 := insertEvent(t, db, "E2", "200米")
cat1 := insertCategory(t, db, event1, "男子")
cat2 := insertCategory(t, db, event2, "男子")
athlete1 := insertAthlete(t, db, "张三", team1)
athlete2 := insertAthlete(t, db, "李四", team2)
// T1:银牌 + 10 分;T2:金牌 + 0 分。默认「总分优先」T1 在前,按金牌优先则 T2 在前。
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
ch1 := insertChannel(t, db, event1, "athlete")
result1 := insertResult(t, db, event1, entry1, 2, "11.58", 11.58, "2026-09-09T10:00:00Z")
insertChannelRank(t, db, event1, cat1, ch1, entry1, result1, team1, athlete1, "athlete", "result", 2, 11.58)
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 10)
entry2 := insertEntry(t, db, event2, cat2, team2, athlete2)
ch2 := insertChannel(t, db, event2, "athlete")
result2 := insertResult(t, db, event2, entry2, 1, "22.10", 22.10, "2026-09-09T10:00:01Z")
insertChannelRank(t, db, event2, cat2, ch2, entry2, result2, team2, athlete2, "athlete", "result", 1, 22.10)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.Standings) != 2 || snap.Standings[0].TeamCode != "T1" {
t.Fatalf("未配置 tieBreak 时应按总分排序,T1 在前: %+v", snap.Standings)
}
mustExec(t, db, `INSERT INTO settings(key, value) VALUES('standings.tie_break', 'gold')
ON CONFLICT(key) DO UPDATE SET value = 'gold'`)
snap, err = env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.Standings) != 2 || snap.Standings[0].TeamCode != "T2" {
t.Fatalf("tieBreak=gold 时应按金牌排序,T2 在前: %+v", snap.Standings)
}
}
// 停用但有积分的单位必须像 REST 一样补出编码/名称;排名输入仍只含启用单位。
func TestSnapshotStandingsIncludesInactiveTeamDisplayFields(t *testing.T) {
env := newTestEnv(t)
db := env.db
insertTeam(t, db, "T1", "第一单位", "")
inactive := insertTeam(t, db, "T2", "第二单位", "")
mustExec(t, db, `UPDATE teams SET active = 0 WHERE id = ?`, inactive)
// 停用且无流水的单位不应出现在榜单里。
idle := insertTeam(t, db, "T3", "第三单位", "")
mustExec(t, db, `UPDATE teams SET active = 0 WHERE id = ?`, idle)
event1 := insertEvent(t, db, "E1", "100米")
cat1 := insertCategory(t, db, event1, "男子")
athlete2 := insertAthlete(t, db, "李四", inactive)
entry2 := insertEntry(t, db, event1, cat1, inactive, athlete2)
insertChannel(t, db, event1, "athlete")
result2 := insertResult(t, db, event1, entry2, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
insertLedger(t, db, inactive, athlete2, event1, entry2, result2, 5)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
// teams 是公开单位表,与 GET /api/teams 一样含停用单位。
if len(snap.Teams) != 3 {
t.Fatalf("快照单位表应含停用单位,得到 %+v", snap.Teams)
}
byCode := make(map[string]dto.StandingRow, len(snap.Standings))
for _, row := range snap.Standings {
byCode[row.TeamCode] = row
}
row, ok := byCode["T2"]
if !ok {
t.Fatalf("停用但有积分的单位应上榜: %+v", snap.Standings)
}
if row.TeamName != "第二单位" || row.TotalPoints != 5 {
t.Fatalf("停用单位应补出名称与积分: %+v", row)
}
if _, ok := byCode["T3"]; ok {
t.Fatalf("停用且无流水的单位不应上榜: %+v", snap.Standings)
}
if _, ok := byCode["T1"]; !ok {
t.Fatalf("启用单位应上榜: %+v", snap.Standings)
}
}
// 已作废(status=withdrawn)的成绩不得出现在大屏「最新成绩」。
func TestSnapshotLatestResultsExcludesVoided(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
event1 := insertEvent(t, db, "E1", "100米")
cat1 := insertCategory(t, db, event1, "男子")
athlete1 := insertAthlete(t, db, "张三", team1)
athlete2 := insertAthlete(t, db, "李四", team1)
keep := insertEntry(t, db, event1, cat1, team1, athlete1)
insertResult(t, db, event1, keep, 1, "11.58", 11.58, "2026-09-09T10:00:01Z")
// 同一报名项只能有一条成绩,作废行用另一个报名项。
voided := insertEntry(t, db, event1, cat1, team1, athlete2)
voidedResult := insertResult(t, db, event1, voided, 2, "11.72", 11.72, "2026-09-09T10:00:02Z")
mustExec(t, db, `UPDATE results SET status = 'withdrawn' WHERE id = ?`, voidedResult)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
for _, group := range snap.LatestResults {
for _, row := range group.Rows {
if row.EntryID == voided {
t.Fatalf("已作废成绩不应出现在最新成绩: %+v", row)
}
}
}
if len(snap.LatestResults) != 1 || len(snap.LatestResults[0].Rows) != 1 ||
snap.LatestResults[0].Rows[0].EntryID != keep {
t.Fatalf("最新成绩应只保留未作废的一条: %+v", snap.LatestResults)
}
}
// 快照的 teams/events 必须用 DTO 序列化:可空字段显式 null、必填字段不因零值消失。
func TestSnapshotTeamsEventsWireFieldsPresent(t *testing.T) {
env := newTestEnv(t)
db := env.db
insertTeam(t, db, "T1", "第一单位", "")
insertEvent(t, db, "E1", "100米")
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
raw, err := json.Marshal(snap)
if err != nil {
t.Fatalf("序列化快照: %v", err)
}
var top struct {
Teams []map[string]json.RawMessage `json:"teams"`
Events []map[string]json.RawMessage `json:"events"`
}
if err := json.Unmarshal(raw, &top); err != nil {
t.Fatalf("解析快照: %v", err)
}
for _, key := range []string{"id", "code", "name", "shortName", "logoPath", "color", "sortOrder", "active"} {
if _, ok := top.Teams[0][key]; !ok {
t.Fatalf("teams 缺少契约字段 %s: %s", key, top.Teams[0])
}
}
for _, key := range []string{"id", "sportId", "code", "name", "eventType", "resultMode", "resultType",
"compare", "recordEligible", "scored", "multiplier", "status", "scheduledAt", "venue", "sortOrder", "remark"} {
if _, ok := top.Events[0][key]; !ok {
t.Fatalf("events 缺少契约字段 %s: %s", key, top.Events[0])
}
}
}
// insertTeamEvent 建一个纯团体小项(event_type=team),与现场「团体 4x100 接力」同形。
func insertTeamEvent(t *testing.T, db *sql.DB, code, name string) int64 {
t.Helper()
if _, err := db.Exec(`INSERT OR IGNORE INTO sports(code, name) VALUES ('S1', '田径')`); err != nil {
t.Fatalf("插入大项: %v", err)
}
res, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, ?, ?, 'team', 'auto', 'time', 'asc', 'published')`, code, name)
if err != nil {
t.Fatalf("插入团体小项 %s: %v", code, err)
}
return lastID(t, res)
}
// insertTeamResult 写一条团体成绩:rank 列保持 NULL。
// 团体名次有意不写 results.rank(applyComputedRanks 只认个人主体),发布后的真实形状就是这样。
func insertTeamResult(t *testing.T, db *sql.DB, eventID, entryID int64, text string, value float64, verifiedAt string) int64 {
t.Helper()
res, err := db.Exec(`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state, status, verified_at)
VALUES (?, ?, NULL, ?, ?, 's', 'published', 'ok', ?)`, eventID, entryID, text, value, verifiedAt)
if err != nil {
t.Fatalf("插入团体成绩: %v", err)
}
return lastID(t, res)
}
// latestRowsByEntry 把快照「最新成绩」摊成 entryID -> 名次,便于逐条断言。
func latestRowsByEntry(t *testing.T, snap *Snapshot) map[int64]*int {
t.Helper()
out := make(map[int64]*int)
for _, group := range snap.LatestResults {
for _, row := range group.Rows {
out[row.EntryID] = row.Rank
}
}
return out
}
// 团体项目的名次只落 channel_ranks、results.rank 有意留空,快照的 latestResults 必须回落到
// channel_ranks:不回落的现场表现就是「团体 4x100 接力」发布之后,大屏分项成绩页与底栏上的
// 名次整列都是「-」。库形态与发布后的真实落库一致(发布链路端到端钉在 httpapi 侧:
// TestDisplayShowsTeamRankForTeamEvent)。
func TestSnapshotLatestResultsFallsBackToChannelRanks(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "一队")
team2 := insertTeam(t, db, "T2", "第二单位", "二队")
eventID := insertTeamEvent(t, db, "T4X100", "4x100 米接力")
catID := insertCategory(t, db, eventID, "男子")
entry1 := insertEntry(t, db, eventID, catID, team1, 0)
entry2 := insertEntry(t, db, eventID, catID, team2, 0)
channelID := insertChannel(t, db, eventID, "team")
// 故意先写名次靠后的那条,验证组内按名次重排。
result2 := insertTeamResult(t, db, eventID, entry2, "46.10", 46.10, "2026-09-09T10:00:02Z")
result1 := insertTeamResult(t, db, eventID, entry1, "45.20", 45.20, "2026-09-09T10:00:01Z")
insertChannelRank(t, db, eventID, catID, channelID, entry1, result1, team1, 0, "team", "result", 1, 45.20)
insertChannelRank(t, db, eventID, catID, channelID, entry2, result2, team2, 0, "team", "result", 2, 46.10)
var ranked int
if err := db.QueryRow(`SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, eventID).Scan(&ranked); err != nil {
t.Fatalf("统计团体名次写回: %v", err)
}
if ranked != 0 {
t.Fatalf("前提不成立:团体名次不该写回 results.rank,得到 %d 行", ranked)
}
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.LatestResults) != 1 || len(snap.LatestResults[0].Rows) != 2 {
t.Fatalf("应有 1 组 2 行,得到 %+v", snap.LatestResults)
}
ranks := latestRowsByEntry(t, snap)
for entryID, want := range map[int64]int{entry1: 1, entry2: 2} {
got := ranks[entryID]
if got == nil {
t.Fatalf("报名项 %d 的名次为空(大屏上就是「-」,得到 %+v)", entryID, snap.LatestResults)
}
if *got != want {
t.Fatalf("报名项 %d 的名次 = %d,期望 %d(来自 channel_ranks)", entryID, *got, want)
}
}
if snap.LatestResults[0].Rows[0].EntryID != entry1 {
t.Fatalf("组内应按名次排序(第一条应是第 1 名的报名项 %d),得到 %+v",
entry1, snap.LatestResults[0].Rows)
}
}
// 名次优先级必须是 results.rank 优先、channel_ranks 兜底:
// 「只更新名次」的发布(rankOnly)只写 results.rank、不重建 channel_ranks,
// 若 cr 优先,个人项目在这种发布之后会退回上一次的旧名次(从「正常」变成「显示旧名次」)。
func TestSnapshotLatestResultsPrefersResultRank(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
eventID := insertEvent(t, db, "E1", "100米")
catID := insertCategory(t, db, eventID, "男子")
athlete1 := insertAthlete(t, db, "张三", team1)
entry1 := insertEntry(t, db, eventID, catID, team1, athlete1)
channelID := insertChannel(t, db, eventID, "athlete")
// results.rank = 2 是「只更新名次」那次发布写下的新名次;channel_ranks 还是上一次发布的 1。
result1 := insertResult(t, db, eventID, entry1, 2, "11.72", 11.72, "2026-09-09T10:00:01Z")
insertChannelRank(t, db, eventID, catID, channelID, entry1, result1, team1, athlete1, "athlete", "result", 1, 11.72)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
got := latestRowsByEntry(t, snap)[entry1]
if got == nil {
t.Fatalf("名次为空(大屏上就是「-」),期望 results.rank 的 2")
}
if *got != 2 {
t.Fatalf("名次应是 results.rank 的 2(新名次),得到 %d", *got)
}
}
// shiftTS 把 ISO 时间往后推 n 秒,造数里用来让「名次越靠后、发布时间越晚」(= 从第 1 名往下发)。
func shiftTS(t *testing.T, base string, seconds int) string {
t.Helper()
ts, err := time.Parse(time.RFC3339, base)
if err != nil {
t.Fatalf("解析时间 %s: %v", base, err)
}
return ts.Add(time.Duration(seconds) * time.Second).Format(time.RFC3339)
}
// insertRankedGroup 造一组「小项 + 组别」:rows 条已发布成绩,名次 1..rows **按顺序录入**
// (results.id 随名次递增,第 1 名建号最早),返回小项 id。
// tsOf 给出每一行的发布时间:现场「从第 1 名往下录/往下发」时它随名次变晚,
// 而一次发布刷同一时间戳时它是常量(并列靠 r.id 决先后)。
func insertRankedGroup(t *testing.T, db *sql.DB, code, name string, teamID int64, rows int, tsOf func(rank int) string) int64 {
t.Helper()
eventID := insertEvent(t, db, code, name)
catID := insertCategory(t, db, eventID, "男子")
for rank := 1; rank <= rows; rank++ {
athlete := insertAthlete(t, db, fmt.Sprintf("%s-第%d名", code, rank), teamID)
entry := insertEntry(t, db, eventID, catID, teamID, athlete)
insertResult(t, db, eventID, entry, rank,
fmt.Sprintf("11.%02d", rank), 11+float64(rank)/100, tsOf(rank))
}
return eventID
}
// ranksOf 把一组的名次摊平成列表,只用于失败信息:直接 `%+v` 一整组行会打出一串指针地址。
func ranksOf(group dto.EventResultGroup) []string {
out := make([]string, 0, len(group.Rows))
for _, row := range group.Rows {
if row.Rank == nil {
out = append(out, "—")
continue
}
out = append(out, fmt.Sprint(*row.Rank))
}
return out
}
// 快照「最新成绩」的窗口是「最近 K 组 × 每组前 R 行」,不是「总 N 行」。
//
// 这条用例钉的是现场那个 bug:行按发布时间倒序取、组按「首次出现」建号 → **屏上最旧的那一栏
// 站在窗口尾部**,按总行数截断时切口只会落进那一组里,把「建号最早、也就是第 1 名」的行切掉。
// 现场表现就是「第 3 栏看不到第 1 名、从第 2 名开始」,而且症状跟着**栏位**走、不跟着项目走。
//
// 旧实现(全局 20 行窗口)在这里必然失败:24 行里最旧的 4 行(第 3 栏的第 1–4 名)被切掉,
// 第 3 栏第一行的名次是 5 而不是 1。既有用例拦不住它——那些用例每组最多 2 行、总量远小于 20。
func TestSnapshotLatestResultsKeepsRankOneInEveryGroup(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
const rowsPerGroup = 8
// 三栏的发布时间:第 3 栏(下标 2)最早发布、建号也最早,旧实现下正是它站在窗口尾部。
publishedBases := [3]string{"2026-09-09T10:00:00Z", "2026-09-09T11:00:00Z", "2026-09-09T09:00:00Z"}
events := make([]int64, len(publishedBases))
// 造数顺序刻意与栏位顺序相反:先发布「最终落在第 3 栏」的那一组,再发另外两组。
for _, col := range []int{2, 0, 1} {
base := publishedBases[col]
events[col] = insertRankedGroup(t, db, fmt.Sprintf("E%d", col+1),
fmt.Sprintf("大屏第 %d 栏的小项", col+1), team1, rowsPerGroup,
func(rank int) string { return shiftTS(t, base, rank) })
}
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
byEvent := make(map[int64]dto.EventResultGroup, len(snap.LatestResults))
totalRows := 0
for _, group := range snap.LatestResults {
byEvent[group.EventID] = group
totalRows += len(group.Rows)
}
if len(snap.LatestResults) < 3 {
t.Fatalf("三栏都应下发,得到 %d 组: %+v", len(snap.LatestResults), snap.LatestResults)
}
// 组数组的顺序不变(WS 契约):发布时间倒序,最旧的那一组在最尾部 = 屏上最后一栏。
if snap.LatestResults[len(snap.LatestResults)-1].EventID != events[2] {
t.Fatalf("最旧发布的组应排在组数组尾部(第 3 栏),得到 %+v",
[]int64{snap.LatestResults[0].EventID, snap.LatestResults[1].EventID, snap.LatestResults[2].EventID})
}
wantRows := min(rowsPerGroup, latestResultsRowsPerGroup)
for col, eventID := range events {
group, ok := byEvent[eventID]
if !ok {
t.Fatalf("第 %d 栏(小项 %d)整组都没下发: %+v", col+1, eventID, snap.LatestResults)
}
// 断言顺序有讲究:先看第 1 名在不在,失败信息才是这个 bug 的原始形状。
if group.Rows[0].Rank == nil || *group.Rows[0].Rank != 1 {
t.Fatalf("第 %d 栏应从第 1 名开始,实际名次 %v(整组行数 %d)", col+1, ranksOf(group), len(group.Rows))
}
for i, row := range group.Rows {
if row.Rank == nil || *row.Rank != i+1 {
t.Fatalf("第 %d 栏的名次应连续升序,实际 %v", col+1, ranksOf(group))
}
}
if len(group.Rows) != wantRows {
t.Fatalf("第 %d 栏应有 %d 行(每组上限 R=%d),实际名次 %v",
col+1, wantRows, latestResultsRowsPerGroup, ranksOf(group))
}
}
// 总行数 > 20 是用例的前提(旧口径正是从这里翻车),放最后断言:
// 失败信息才优先说清「是哪一栏少了第 1 名」。
if totalRows <= 20 {
t.Fatalf("用例前提不成立:总行数必须 > 20(旧实现翻车的条件),得到 %d", totalRows)
}
}
// 每组只保留**名次最靠前**的 R 行,且组的发布时间取组内最新一条(哪怕那一行被 R 截掉)。
//
// 这一条同时挡住两种退化:①「先按总行数截断再分组」(会留下 12 行、超过 R);
// ②「组内取发布时间最新的 R 行」(会少掉第 1 名)。旧实现在这里也是红的(12 行一条不落地留下)。
func TestSnapshotLatestResultsCapsRowsPerGroupByRank(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
base := "2026-09-09T10:00:00Z"
// 比 R 多 2 行:名次最靠后的两行必须被丢掉,第 1 名必须在。
rows := latestResultsRowsPerGroup + 2
eventID := insertRankedGroup(t, db, "E1", "男子 100 米", team1, rows,
func(rank int) string { return shiftTS(t, base, rank) })
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.LatestResults) != 1 {
t.Fatalf("应只有 1 组,得到 %d: %+v", len(snap.LatestResults), snap.LatestResults)
}
group := snap.LatestResults[0]
if group.EventID != eventID {
t.Fatalf("组的小项应是 %d,得到 %d", eventID, group.EventID)
}
if len(group.Rows) != latestResultsRowsPerGroup {
t.Fatalf("一组最多 %d 行,实际名次 %v", latestResultsRowsPerGroup, ranksOf(group))
}
for i, row := range group.Rows {
if row.Rank == nil || *row.Rank != i+1 {
t.Fatalf("留下的必须是名次最靠前的 %d 行,实际名次 %v", latestResultsRowsPerGroup, ranksOf(group))
}
}
// 组的发布时间 = 组内最新一条(第 12 名那行的 base+12s),不是「留下的第一行」的 base+1s。
wantPublishedAt := shiftTS(t, base, rows)
if group.PublishedAt == nil || *group.PublishedAt != wantPublishedAt {
t.Fatalf("组的发布时间应取组内最新一条 %s,得到 %v", wantPublishedAt, group.PublishedAt)
}
}
// 组数超过 K 时只保留**最近发布**的 K 组:组数组尾部(屏上最后一栏)先被丢掉。
func TestSnapshotLatestResultsCapsGroupCount(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
base := "2026-09-09T10:00:00Z"
const groupCount = latestResultsGroupLimit + 1
events := make([]int64, groupCount)
for i := 0; i < groupCount; i++ {
// 每组 1 行,发布时间依次变新:下标越大越新。
ts := shiftTS(t, base, i*60)
events[i] = insertRankedGroup(t, db, fmt.Sprintf("E%02d", i+1),
fmt.Sprintf("第 %d 个小项", i+1), team1, 1,
func(int) string { return ts })
}
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.LatestResults) != latestResultsGroupLimit {
t.Fatalf("组数应封顶在 K=%d,得到 %d", latestResultsGroupLimit, len(snap.LatestResults))
}
if snap.LatestResults[0].EventID != events[groupCount-1] {
t.Fatalf("最新发布的组应排在最前,得到小项 %d,期望 %d", snap.LatestResults[0].EventID, events[groupCount-1])
}
kept := make(map[int64]bool, len(snap.LatestResults))
for _, group := range snap.LatestResults {
kept[group.EventID] = true
}
if kept[events[0]] {
t.Fatalf("最旧的那一组应被丢掉: %+v", snap.LatestResults)
}
for i := 1; i < groupCount; i++ {
if !kept[events[i]] {
t.Fatalf("最近发布的 %d 组都应保留,缺少小项 %d: %+v", latestResultsGroupLimit, events[i], snap.LatestResults)
}
}
}
// 一次发布会把范围内的行刷成**同一个** verified_at(httpapi/scoring.go 的 setPublished 用
// datetime('now')),所以现场最常见的形态是「组内行时间全并列、跨组也可能并列」。
// 并列时按 r.id 决先后,组的顺序必须与旧实现逐位相同(行按 ts DESC, r.id DESC 取回、
// 组按首次出现建号 → 时间并列时等于「组内最大 r.id」倒序),否则大屏的栏位会换位置。
func TestSnapshotLatestResultsTiedPublishTimestamps(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
// 三组同一次发布:9:30:00 那一秒。
const sameTS = "2026-09-09T09:30:00Z"
const rowsPerGroup = 8
events := make([]int64, 3)
// 造数顺序 = 发布顺序:先发第 3 栏那一组(建号最早),再发第 1、2 栏。
insertOrder := []int{2, 0, 1}
for _, col := range insertOrder {
events[col] = insertRankedGroup(t, db, fmt.Sprintf("E%d", col+1),
fmt.Sprintf("大屏第 %d 栏的小项", col+1), team1, rowsPerGroup,
func(int) string { return sameTS })
}
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.LatestResults) != 3 {
t.Fatalf("三组都应下发,得到 %d 组", len(snap.LatestResults))
}
// 时间并列 → 靠 r.id 决先后:最后发布的那一组(第 2 栏)在最前,最先发布的(第 3 栏)在最后。
wantOrder := []int{1, 0, 2}
for i, col := range wantOrder {
if snap.LatestResults[i].EventID != events[col] {
t.Fatalf("并列时间的组顺序应与发布顺序相反,第 %d 位应是第 %d 栏的小项 %d,得到 %d",
i+1, col+1, events[col], snap.LatestResults[i].EventID)
}
}
byEvent := make(map[int64]dto.EventResultGroup, len(snap.LatestResults))
for _, group := range snap.LatestResults {
byEvent[group.EventID] = group
}
for col, eventID := range events {
group := byEvent[eventID]
if group.Rows[0].Rank == nil || *group.Rows[0].Rank != 1 {
t.Fatalf("第 %d 栏应从第 1 名开始,实际名次 %v", col+1, ranksOf(group))
}
if len(group.Rows) != rowsPerGroup {
t.Fatalf("第 %d 栏应有 %d 行,实际名次 %v", col+1, rowsPerGroup, ranksOf(group))
}
// 并列时间下组的发布时间也是同一条,不能变成「某一行的时间」以外的值。
if group.PublishedAt == nil || *group.PublishedAt != sameTS {
t.Fatalf("第 %d 栏的发布时间应是 %s,得到 %v", col+1, sameTS, group.PublishedAt)
}
}
}