Files
Scoreboard/server/internal/export/load_test.go
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
2026-09-11 21:47:07 +08:00

620 lines
22 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 export
import (
"context"
"database/sql"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
)
// 导出与 WS 快照必须同源:同一赛事库上,总积分榜的总分、奖牌与名次不能对不上。
func TestExportStandingsMatchesSnapshot(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("导出总表: %v", err)
}
hub := realtime.NewHub(db, testLogger())
defer func() { _ = hub.Close() }()
snap, err := hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("读取 WS 快照: %v", err)
}
if len(wb.Team) != len(snap.Standings) {
t.Fatalf("单位数不一致:导出 %d,快照 %d", len(wb.Team), len(snap.Standings))
}
for _, s := range snap.Standings {
idx := -1
for i := range wb.Team {
if wb.Team[i].TeamID == s.TeamID {
idx = i
break
}
}
if idx < 0 {
t.Fatalf("快照中的单位 %d 不在导出结果里", s.TeamID)
}
row := wb.Team[idx]
if row.Points != s.TotalPoints || row.Gold != s.Gold || row.Silver != s.Silver ||
row.Bronze != s.Bronze || row.Medals != s.MedalTotal || row.Rank != s.Rank {
t.Errorf("单位 %d 口径不一致:导出 %+v,快照 %+v", s.TeamID, row, s)
}
}
}
// 只排名不计分(scored=0)的项目不进奖牌榜,即使它配了计分通道。
func TestLoadRankedExcludesRankOnlyEvents(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
stmts := []string{
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, status)
VALUES (1, 'E2', '飞行人员战斗体能', 'individual', 'auto', 'score', 'desc', 0, 'published')`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 2, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (2, 2, 1, 1, '201')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, state)
VALUES (2, 3, 1, '100', 100, 'published')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
// scored=0 的小项照样有通道名次(引擎只排名不计分也会产出名次),
// 靠 loadRanked 的 scored 过滤把它们挡在奖牌之外。
seedChannelRanks(t, db)
ranked, err := loadRanked(db)
if err != nil {
t.Fatalf("读取名次: %v", err)
}
for _, rr := range ranked {
if rr.EventID == 2 {
t.Fatalf("scored=0 的项目不应产出奖牌名次: %+v", rr)
}
}
if countRows(t, db, "channel_ranks") == 0 {
t.Fatal("用例前提不成立:channel_ranks 是空的,过滤没被真正考验")
}
var event2Ranks int
if err := db.QueryRow(`SELECT count(*) FROM channel_ranks WHERE event_id = 2`).Scan(&event2Ranks); err != nil {
t.Fatalf("统计小项 2 的通道名次: %v", err)
}
if event2Ranks == 0 {
t.Fatal("用例前提不成立:只排名的小项没有通道名次行,scored 过滤没被真正考验")
}
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("导出总表: %v", err)
}
for _, row := range wb.Team {
if row.TeamID == 1 && row.Gold != 1 {
t.Errorf("T1 金牌 = %d,期望 1(只排名项目不得计牌)", row.Gold)
}
}
}
// 排序口径取自 settings.standings.tie_break,不再是硬编码默认序。
func TestLoadStandingsHonoursTieBreak(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// T1 金牌 1 但总分 5,T2 无牌但总分 10。
for _, s := range []string{
`UPDATE score_ledger SET points = 5 WHERE team_id = 1`,
`UPDATE score_ledger SET points = 10 WHERE team_id = 2`,
} {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
seedChannelRanks(t, db)
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("默认口径导出: %v", err)
}
if wb.Team[0].Code != "T2" {
t.Fatalf("默认按总分排序,第一名 = %q,期望 T2", wb.Team[0].Code)
}
if _, err := db.Exec(`INSERT INTO settings(key, value) VALUES ('standings.tie_break', 'gold,points')
ON CONFLICT(key) DO UPDATE SET value = excluded.value`); err != nil {
t.Fatalf("写入 tie_break: %v", err)
}
wb, err = LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("金牌优先导出: %v", err)
}
if wb.Team[0].Code != "T1" {
t.Errorf("tie_break=gold,points 时第一名 = %q,期望 T1", wb.Team[0].Code)
}
}
// 停用但仍有积分的单位要显示编码/名称,不能是空行。
func TestLoadStandingsShowsInactiveTeam(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
for _, s := range []string{
`INSERT INTO teams(code, name, short_name, sort_order, active)
VALUES ('T3', '第三分公司', '三公司', 3, 0)`,
`INSERT INTO score_ledger(team_id, source_type, source_id, points)
VALUES (3, 'adjustment', 99, 7)`,
} {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("导出总表: %v", err)
}
idx := -1
for i := range wb.Team {
if wb.Team[i].TeamID == 3 {
idx = i
break
}
}
if idx < 0 {
t.Fatalf("停用单位未出现在榜单:%+v", wb.Team)
}
row := wb.Team[idx]
if row.Code != "T3" || row.Name != "第三分公司" || row.ShortName != "三公司" {
t.Errorf("停用单位展示字段不完整: %+v", row)
}
if row.Points != 7 {
t.Errorf("停用单位总分 = %v,期望 7", row.Points)
}
}
// 单位积分明细的「合计」与总积分榜同口径:个人分要乘 individual_to_team_weight。
func TestTeamDetailTotalAppliesWeight(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
if _, err := db.Exec(`UPDATE settings SET value = '2' WHERE key = 'standings.individual_to_team_weight'`); err != nil {
t.Fatalf("修改折算系数: %v", err)
}
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("读取积分明细: %v", err)
}
if wb.IndividualToTeamWeight != 2 {
t.Fatalf("折算系数 = %v,期望 2", wb.IndividualToTeamWeight)
}
rows := teamDetailRows(wb.Rows, wb.IndividualToTeamWeight)
if len(rows) < 2 {
t.Fatalf("明细行数 = %d,期望含合计行", len(rows))
}
// 与 REST 一致:个人分逐行折算,合计等于各行之和。
if got, ok := rows[0][8].(float64); !ok || got != 20 {
t.Errorf("明细行积分 = %v,期望 20(10 × 系数 2)", rows[0][8])
}
total, ok := rows[len(rows)-1][8].(float64)
if !ok {
t.Fatalf("合计列类型异常: %T", rows[len(rows)-1][8])
}
if total != 20 {
t.Errorf("合计 = %v,期望 20(10 × 系数 2)", total)
}
}
// 「按队内平均」(basis=aggregate)产生的流水 entry_id 为空,从 entries 取不到组别;
// 单位在本小项只参加一个组别时那个组别就是唯一答案,导出必须把它补上,不能留空。
// 运动员 / 名次 / 成绩留空是合理的:聚合分不对应单个运动员。
func TestTeamDetailAggregateRowKeepsCategory(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 模拟聚合通道产出的流水:entry_id 与 result_id 都为空(见 isAggregateLedger)。
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (1, NULL, 1, NULL, NULL, 'result', 99, 15, 1)`); err != nil {
t.Fatalf("写入聚合流水: %v", err)
}
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("读取积分明细: %v", err)
}
agg, ok := findAggregateRow(wb.Rows)
if !ok {
t.Fatalf("聚合流水没出现在明细里: %+v", wb.Rows)
}
if agg.Category != "男子" {
t.Errorf("聚合流水的组别 = %q,期望 男子", agg.Category)
}
if agg.AthleteName != "" || agg.Rank != 0 || agg.ResultText != "" {
t.Errorf("聚合流水不该带单个运动员的信息: %+v", agg)
}
}
// 单位在同一小项参加了多个组别时,库里同一 (小项, 单位) 有多条内容相同、无法区分的聚合流水,
// 回推不出组别;导出宁可留空也不猜(要修得先往 score_ledger 上落组别,属于模型改动)。
func TestTeamDetailAggregateRowAmbiguousCategoryStaysEmpty(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
stmts := []string{
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子', 2)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('王五', '女', 1)`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 2, 1, 3, '103')`,
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, count_to_team)
VALUES (1, NULL, 1, NULL, NULL, 'result', 99, 15, 1)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("读取积分明细: %v", err)
}
agg, ok := findAggregateRow(wb.Rows)
if !ok {
t.Fatalf("聚合流水没出现在明细里: %+v", wb.Rows)
}
if agg.Category != "" {
t.Errorf("无法回推的聚合流水组别 = %q,期望留空", agg.Category)
}
}
// findAggregateRow 找出那条聚合流水(测试里用 15 分做标记,种子数据只有 10 与 8 分)。
func findAggregateRow(rows []TeamDetailRow) (TeamDetailRow, bool) {
for _, r := range rows {
if r.Points == 15 {
return r, true
}
}
return TeamDetailRow{}, false
}
// 三个奖牌开关随名次行从 events / event_score_channels 带出来(devdocs 17 §4.5):
// 关掉小项的 award_medals、或通道的 count_to_medals,奖牌就该消失;
// 只关 count_to_team_medals 时个人榜仍有牌、单位榜没有。
func TestLoadRankedCarriesMedalSwitches(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedChannelRanks(t, db)
ranked, err := loadRanked(db)
if err != nil {
t.Fatalf("读取名次: %v", err)
}
if len(ranked) == 0 {
t.Fatal("用例前提不成立:读不到任何名次")
}
for _, rr := range ranked {
if !rr.AwardMedals || !rr.CountToMedals || !rr.CountToTeamMedals {
t.Fatalf("三列默认全为 1,开关应全为 true: %+v", rr)
}
}
if gold := standingsGold(t, db, 1); gold != 1 {
t.Fatalf("基线 T1 金牌 = %d,期望 1", gold)
}
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE id = 1`); err != nil {
t.Fatalf("关闭小项奖牌结算: %v", err)
}
if gold := standingsGold(t, db, 1); gold != 0 {
t.Errorf("award_medals=0 时 T1 金牌 = %d,期望 0", gold)
}
// 奖牌明细与榜单同口径(medals.go 走 domain.BuildMedalStandings),关掉开关后也不该再出牌。
if sheets := medalSheetsOf(t, db); len(sheets) != 0 {
t.Errorf("award_medals=0 时奖牌明细仍出分表: %v", sheets)
}
if _, err := db.Exec(`UPDATE events SET award_medals = 1 WHERE id = 1`); err != nil {
t.Fatalf("恢复小项奖牌结算: %v", err)
}
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 0 WHERE event_id = 1`); err != nil {
t.Fatalf("关闭通道奖牌: %v", err)
}
if gold := standingsGold(t, db, 1); gold != 0 {
t.Errorf("count_to_medals=0 时 T1 金牌 = %d,期望 0", gold)
}
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 1 WHERE event_id = 1`); err != nil {
t.Fatalf("恢复通道奖牌: %v", err)
}
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team_medals = 0 WHERE event_id = 1`); err != nil {
t.Fatalf("关闭个人奖牌并入单位: %v", err)
}
if gold := standingsGold(t, db, 1); gold != 0 {
t.Errorf("count_to_team_medals=0 时 T1 单位金牌 = %d,期望 0", gold)
}
if gold := athleteGold(t, db, 1); gold != 1 {
t.Errorf("count_to_team_medals=0 时张三个人金牌 = %d,期望 1(只影响单位榜)", gold)
}
}
// medalSheetsOf 取奖牌明细里出了分表的单位 id(没有奖牌时为空)。
func medalSheetsOf(t *testing.T, db *sql.DB) []int64 {
t.Helper()
wb, err := LoadMedalDetailWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取奖牌明细: %v", err)
}
out := make([]int64, 0, len(wb.Sheets))
for _, s := range wb.Sheets {
out = append(out, s.Team.ID)
}
return out
}
// standingsGold / athleteGold 取导出总表里某个单位 / 运动员的金牌数。
func standingsGold(t *testing.T, db *sql.DB, teamID int64) int {
t.Helper()
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取总表: %v", err)
}
for _, row := range wb.Team {
if row.TeamID == teamID {
return row.Gold
}
}
return 0
}
func athleteGold(t *testing.T, db *sql.DB, athleteID int64) int {
t.Helper()
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取总表: %v", err)
}
for _, row := range wb.Athlete {
if row.AthleteID == athleteID {
return row.Gold
}
}
return 0
}
// 分项成绩表的名次列取「主通道」名次(devdocs 17 §11.3):双通道项目里 scope=athlete 的通道优先,
// 一张报名项只显示一个名次;channel_ranks 里没有对应行时回落到 results.rank。
func TestEventRowsUseMainChannelRank(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 补一条团体通道,让「100米跑」变成双通道项目(与 TEAM_PULLUP 同形)。
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 1, 'team', 'manual', id FROM scoring_profiles WHERE code = 'team_top6'`); err != nil {
t.Fatalf("补团体通道: %v", err)
}
seedChannelRanks(t, db)
// 个人名次只认 channel_ranks:把 results.rank 改坏,成绩表仍要显示个人通道的 1。
if _, err := db.Exec(`UPDATE results SET rank = 4 WHERE id = 1`); err != nil {
t.Fatalf("改坏 results.rank: %v", err)
}
wb, err := LoadEventWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("导出分项成绩: %v", err)
}
rows := eventRows(wb.Sheets[0].Rows)
if len(rows) != 2 {
t.Fatalf("行数 = %d,期望 2(一条报名项一行,不按通道复制)", len(rows))
}
// 行序按 results.rank 排(T1 被改坏成 4),所以按号码布找那一行。
byBib := make(map[string][]any, len(rows))
for _, r := range rows {
byBib[r[1].(string)] = r
}
if got := byBib["101"][0]; got != "1" {
t.Errorf("101 的名次列 = %v,期望主通道(个人通道)的 1", got)
}
if got := byBib["102"][0]; got != "2" {
t.Errorf("102 的名次列 = %v,期望 2", got)
}
}
// 团体手工 / 聚合这类没有成绩行的流水也要显示 channel_ranks 里的团体名次
// (迁移前这类流水的 rank 恒为 0,devdocs 17 §11.3)。
func TestTeamDetailShowsSubjectRank(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 团体手工通道:名次按单位录入,流水没有 entry_id / result_id,写入侧把 source_id 退化为通道 id。
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 1, 'team', 'manual', id FROM scoring_profiles WHERE code = 'team_top6'`); err != nil {
t.Fatalf("补团体手工通道: %v", err)
}
var channelID int64
if err := db.QueryRow(`SELECT id FROM event_score_channels WHERE event_id = 1 AND scope = 'team'`).Scan(&channelID); err != nil {
t.Fatalf("读通道 id: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, athlete_id, team_id, rank, points)
VALUES (1, 1, ?, 'manual', 'team', 't:1', NULL, NULL, NULL, 1, 2, 0)`, channelID); err != nil {
t.Fatalf("造团体手工名次: %v", err)
}
// 流水与写入侧一致:source_id 退化为通道 id(见 domain.ledgerFromRanked)。
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (1, NULL, 1, NULL, NULL, 'result', ?, 6, 1)`, channelID); err != nil {
t.Fatalf("造团体手工流水: %v", err)
}
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("读取积分明细: %v", err)
}
var found bool
for _, r := range wb.Rows {
if r.Points != 6 {
continue
}
found = true
if r.Rank != 2 {
t.Errorf("团体手工流水的名次 = %d,期望 2(来自 channel_ranks)", r.Rank)
}
}
if !found {
t.Fatalf("团体手工流水没出现在明细里: %+v", wb.Rows)
}
}
// 「个人+团体」项目的个人通道流水(count_to_team = 0)不进单位总分,
// 导出侧与 REST / WS 快照必须同口径。
func TestLoadStandingsSkipsPersonalOnlyLedger(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 单位 1 再加一条「只记个人」的流水:导出总分不得变化。
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (1, 1, 1, 1, 1, 'result', 1, 7, 0)`); err != nil {
t.Fatalf("写入个人通道流水: %v", err)
}
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("导出总表: %v", err)
}
for _, row := range wb.Team {
if row.TeamID != 1 {
continue
}
if row.Points != 10 {
t.Fatalf("单位1 总分 = %v,期望 10(不计入单位总分的流水应被跳过)", row.Points)
}
}
// 个人榜照常算上这条流水:10 + 7 = 17。
for _, row := range wb.Athlete {
if row.AthleteID == 1 && row.Points != 17 {
t.Fatalf("个人榜得分 = %v,期望 17", row.Points)
}
}
}
// seedDraftAndBlank 在 seedEvent 之上补两类「还没发布」的报名:
// 王五有一条草稿成绩(录了没发布),赵六一条成绩都没有。种子里的两条是已发布成绩。
func seedDraftAndBlank(t *testing.T, db *sql.DB) {
t.Helper()
stmts := []string{
`INSERT INTO athletes(name, gender, team_id) VALUES ('王五', '男', 1)`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 3, '103')`,
// rank 留空:草稿还没参与排名,也让行序稳定落在已发布成绩之后。
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (1, 3, NULL, '12.10', 12.10, 's', 'draft')`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('赵六', '男', 2)`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 2, 4, '104')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
}
// 草稿行不能从表上消失,但要按「未发布」呈现:成绩列写「分数未录入」,名次与积分填「—」;
// 连成绩行都没有的行三格一律占位;已发布的格子一个都不许变。
func TestEventRowsShowUnpublishedDraftWithoutDroppingIt(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 先取一份只有已发布成绩的导出,等下用它证明已发布行没被新行挤动。
before, err := LoadEventWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("导出已发布成绩: %v", err)
}
if len(before.Sheets) != 1 {
t.Fatalf("组别数 = %d,期望 1", len(before.Sheets))
}
publishedRows := eventRows(before.Sheets[0].Rows)
seedDraftAndBlank(t, db)
after, err := LoadEventWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("导出含草稿的成绩: %v", err)
}
rows := eventRows(after.Sheets[0].Rows)
if len(rows) != 4 {
t.Fatalf("行数 = %d,期望 4(已发布 2 + 草稿 1 + 没成绩 1): %v", len(rows), rows)
}
// 已发布的两行:每个格子与「没有那两条未发布报名时」完全一致。
for i, want := range publishedRows {
if !equalCells(rows[i], want) {
t.Errorf("第 %d 行被改动了:得到 %v,期望 %v", i+1, rows[i], want)
}
}
// 再钉一遍字面值:这两行是现场打印件上的样子,不能因为新增占位逻辑而漂移。
wantPublished := [][]any{
{"1", "101", "第一分公司", "张三", "11.58 秒", 10.0, "有效", ""},
{"2", "102", "第二分公司", "李四", "11.90 秒", 8.0, "有效", ""},
}
for i, want := range wantPublished {
if !equalCells(rows[i], want) {
t.Errorf("已发布第 %d 行 = %v,期望 %v", i+1, rows[i], want)
}
}
// 草稿行(王五):占一行,成绩列是「分数未录入」,名次与积分是「—」。
if got := rows[2]; !equalCells(got, []any{
"—", "103", "第一分公司", "王五", "分数未录入", "—", "草稿", "",
}) {
t.Errorf("草稿行 = %v,期望名次 / 积分占位、成绩写「分数未录入」", got)
}
// 完全没成绩的行(赵六):三个成绩格子都不留空白也不写 0,状态仍是未录入。
if got := rows[3]; !equalCells(got, []any{
"—", "104", "第二分公司", "赵六", "—", "—", "未录入", "",
}) {
t.Errorf("没成绩的行 = %v,期望名次 / 成绩 / 积分都是「—」、状态未录入", got)
}
// 逐列再钉一次:这三格的列序是 名次(0) / 成绩(4) / 积分(5),改列序时这里会先炸。
for _, col := range []int{0, 4, 5} {
if got := rows[3][col]; got != "—" {
t.Errorf("没成绩的行第 %d 列 = %v,期望「—」", col, got)
}
}
}
// includeDrafts 打开时(草稿也导出)草稿行的真实值照常写出;
// 连成绩行都没有的行仍然三格占位——它本来就没有值可写。
func TestEventRowsIncludeDraftsKeepsDraftValues(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedDraftAndBlank(t, db)
wb, err := LoadEventWorkbook(db, 1, Options{IncludeDrafts: true})
if err != nil {
t.Fatalf("带草稿导出: %v", err)
}
rows := eventRows(wb.Sheets[0].Rows)
if len(rows) != 4 {
t.Fatalf("行数 = %d,期望 4", len(rows))
}
if got := rows[2]; !equalCells(got, []any{
"", "103", "第一分公司", "王五", "12.10 秒", 0.0, "草稿", "",
}) {
t.Errorf("带草稿导出时草稿行 = %v,期望写出草稿成绩值", got)
}
if got := rows[3]; !equalCells(got, []any{
"—", "104", "第二分公司", "赵六", "—", "—", "未录入", "",
}) {
t.Errorf("带草稿导出时没成绩的行 = %v,期望三格仍是「—」", got)
}
}
// equalCells 逐格比较两行;float64 与 string 都按值比。
func equalCells(got, want []any) bool {
if len(got) != len(want) {
return false
}
for i := range want {
if got[i] != want[i] {
return false
}
}
return true
}