Files
Scoreboard/server/internal/realtime/snapshot_test.go
T
AskaEth 13f1f03cd0 fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)
P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
2026-09-09 22:16:58 +08:00

636 lines
24 KiB
Go

package realtime
import (
"context"
"database/sql"
"encoding/json"
"strings"
"testing"
"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", "第一单位", "一队")
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)
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))
}
for _, key := range []string{"teamId", "teamCode", "teamName", "totalPoints", "medalTotal", "rankChange", "gold", "rank"} {
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 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)
}
}
// 同一小项 + 组别的多条成绩必须落进同一组,不同组别/小项拆成不同组,组内按名次排序。
func TestSnapshotLatestResultsGrouped(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", "女子 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])
}
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)
insertChannel(t, db, event1, "team")
insertResult(t, db, event1, entry1, 1, "45.20", 45.20, "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 != 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)
insertChannel(t, db, event2, "team")
insertResult(t, db, event2, entry2, 1, "9.99", 9.99, "2026-09-09T10:00:01Z")
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)
}
}
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)
}
}
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)
}
func insertChannel(t *testing.T, db *sql.DB, eventID int64, scope string) {
t.Helper()
mustExec(t, db, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, ?, 'result', 1, 0)`, eventID, scope)
}
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)
insertChannel(t, db, event1, "athlete")
result1 := insertResult(t, db, event1, entry1, 2, "11.58", 11.58, "2026-09-09T10:00:00Z")
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 10)
entry2 := insertEntry(t, db, event2, cat2, team2, athlete2)
insertChannel(t, db, event2, "athlete")
insertResult(t, db, event2, entry2, 1, "22.10", 22.10, "2026-09-09T10:00:01Z")
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])
}
}
}