Files
Scoreboard/server/internal/export/load_test.go
T
AskaEth 877ff15823 feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修)
- 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同**
  (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致
- 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到
- 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢);
  另加守卫「compare=none 的小项不得配 basis=result/aggregate」
- 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0
  (7 人三人并列第 5 时第 5、6 名的分也一起没了)
- 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分

## 现场反馈(主人实测报的)
- 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组),
  与「作废」语义分开;取消路径不产生任何副作用
- 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」:
  管理端、大屏覆盖层、xlsx 备注列三处同源
- 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦
- 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响)

## 新功能
- 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切)

## 安装器
- 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、
  安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540
- 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败

自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。
端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
2026-09-13 15:20:18 +08:00

826 lines
31 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"
"encoding/json"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/xuri/excelize/v2"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"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))
}
medalRank := make(map[int64]int, len(wb.Medal))
for _, m := range wb.Medal {
medalRank[m.TeamID] = m.Rank
}
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)
}
// 单位奖牌榜的名次也必须同源:xlsx 的奖牌榜名次 = 快照下发的 medalRank。
if got := medalRank[s.TeamID]; got != s.MedalRank {
t.Errorf("单位 %d 的奖牌榜名次不一致:导出 %d,快照 %d", s.TeamID, got, s.MedalRank)
}
}
}
// 只排名不计分(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)
}
}
// 单位奖牌榜独立于积分榜(主人 2026-09-13 拍板,06 报告 P1④):
// T1 有 1 金但只 5 分、T2 无牌却 10 分——总积分榜 T2 在前,奖牌榜必须 T1 在前。
// 三处同源:xlsx 的「单位奖牌榜」sheet 与 WS 快照下发的 medalRank 都出自 domain.BuildMedalStandings。
func TestLoadStandingsMedalBoardIndependentOfPoints(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 与 TestLoadStandingsHonoursTieBreak 同一份造数: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 len(wb.Medal) != 2 || wb.Medal[0].Code != "T1" || wb.Medal[0].Rank != 1 || wb.Medal[0].Gold != 1 {
t.Fatalf("奖牌榜应按金牌排,榜首 = %+v,期望 T1(1 金、名次 1)", wb.Medal)
}
if wb.Medal[1].Code != "T2" || wb.Medal[1].Rank != 2 {
t.Fatalf("奖牌榜次席 = %+v,期望 T2(名次 2)", wb.Medal[1])
}
// WS 快照(大屏推送)必须给出同一套奖牌榜名次。
hub := realtime.NewHub(db, testLogger())
defer func() { _ = hub.Close() }()
snap, err := hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("读取 WS 快照: %v", err)
}
rows := make(map[string]dto.StandingRow, len(snap.Standings))
for _, r := range snap.Standings {
rows[r.TeamCode] = r
}
if rows["T1"].MedalRank != 1 || rows["T2"].MedalRank != 2 {
t.Fatalf("快照的奖牌榜名次 = T1:%d T2:%d,期望 1 / 2", rows["T1"].MedalRank, rows["T2"].MedalRank)
}
// 两套名次确实解耦:T1 总分低,总积分榜名次仍是 2。
if rows["T1"].Rank != 2 || rows["T2"].Rank != 1 {
t.Fatalf("快照的总积分榜名次 = T1:%d T2:%d,期望 2 / 1", rows["T1"].Rank, rows["T2"].Rank)
}
// xlsx 的「单位奖牌榜」sheet 必须照同一个顺序落表(表头下一行就是第一名)。
path := filepath.Join(t.TempDir(), "standings.xlsx")
if err := ExportStandingsXLSX(db, path, Options{}); err != nil {
t.Fatalf("导出总表 xlsx: %v", err)
}
f, err := excelize.OpenFile(path)
if err != nil {
t.Fatalf("打开导出件: %v", err)
}
defer func() { _ = f.Close() }()
sheet := sheetNamed(t, f, "单位奖牌榜")
sheetRows, err := f.GetRows(sheet)
if err != nil {
t.Fatalf("读取 %s: %v", sheet, err)
}
// 表头之前还有大标题 / 副标题 / 空行,按表头文字定位,别写死行号。
head := -1
for i, row := range sheetRows {
if len(row) > 0 && row[0] == "名次" {
head = i
break
}
}
if head < 0 {
t.Fatalf("%s 里找不到表头行:%v", sheet, sheetRows)
}
if len(sheetRows) < head+3 {
t.Fatalf("%s 数据行数 = %d,期望表头 + 2 行", sheet, len(sheetRows)-head-1)
}
// 列序:名次 / 单位代码 / 单位名称 / 金牌 / 银牌 / 铜牌 / 奖牌合计 / 总分。
first, second := sheetRows[head+1], sheetRows[head+2]
if first[0] != "1" || first[1] != "T1" || first[3] != "1" {
t.Fatalf("奖牌榜首行 = %v,期望 1 / T1 / 1 金", first)
}
if second[0] != "2" || second[1] != "T2" {
t.Fatalf("奖牌榜次行 = %v,期望 2 / T2", second)
}
}
// sheetNamed 按后缀取工作表名:导出件的工作表名带赛事名前缀(如「测试运动会 · 单位奖牌榜」)。
func sheetNamed(t *testing.T, f *excelize.File, suffix string) string {
t.Helper()
for _, name := range f.GetSheetList() {
if strings.HasSuffix(name, suffix) {
return name
}
}
t.Fatalf("导出件里没有以 %q 结尾的工作表:%v", suffix, f.GetSheetList())
return ""
}
// 停用但仍有积分的单位要显示编码/名称,不能是空行。
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)
}
}
// 「备注」列要带上修订痕迹:屏幕上挂着「已修订 ×2」而导出件一个字没有,
// 事后拿表格对账的人就不知道该信哪份。判定口径见 internal/revisions。
func TestTeamDetailRemarkCarriesRevisionTrail(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedDetailRevisions(t, db)
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("读取积分明细: %v", err)
}
rows := teamDetailRows(wb.Rows, wb.IndividualToTeamWeight)
if len(rows) < 2 {
t.Fatalf("明细行数 = %d,期望含合计行", len(rows))
}
// 备注是第 10 列(下标 9)。按「来源」找行,不按下标 —— 行序由名次决定,别把断言写死在行号上。
for _, row := range rows {
switch row[7] {
case "成绩":
if got, ok := row[9].(string); !ok ||
!regexp.MustCompile(`^已修订 ×1;最后一次 \d{2}:\d{2} 成绩录入笔误$`).MatchString(got) {
t.Errorf("成绩行备注 = %v,期望 `已修订 ×1;最后一次 HH:MM 成绩录入笔误`", row[9])
}
case "破纪录加分":
// 破纪录加分是站在成绩旁边的另一笔分,不继承成绩的修订痕迹(见 revisions.KeyFor)。
if got, _ := row[9].(string); got != "" {
t.Errorf("破纪录加分行备注 = %q,期望留空(改的是成绩,不是这笔加分)", got)
}
}
}
// 合计行的备注留空,不被带上。
if got, _ := rows[len(rows)-1][9].(string); got != "" {
t.Errorf("合计行备注 = %q,期望留空", got)
}
}
// 没改过的成绩备注必须留空 —— 导出侧同样不许出现「其实没改过却写了已修订」。
func TestTeamDetailRemarkEmptyWhenNeverRevised(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 只发布过一版(有基线、没改动):能追溯,但一个字都不该写。
writeRevision(t, db, "2026-09-13 02:40:00", "", []map[string]any{
{"entryId": 1, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"},
})
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("读取积分明细: %v", err)
}
rows := teamDetailRows(wb.Rows, wb.IndividualToTeamWeight)
if got, _ := rows[0][9].(string); got != "" {
t.Errorf("没改过的成绩备注 = %q,期望留空", got)
}
}
// seedDetailRevisions 造一条「发布 → 改成绩 → 重新发布」的历史:张三那条 11.58 被改成 11.42。
// 两条修订都带上同小项另一条成绩,证明「别人改了」不会把修订痕迹算到自己头上。
func seedDetailRevisions(t *testing.T, db *sql.DB) {
t.Helper()
writeRevision(t, db, "2026-09-13 02:40:00", "", []map[string]any{
{"entryId": 1, "rank": 1, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "state": "published"},
{"entryId": 2, "rank": 2, "resultText": "11.90", "resultValue": 11.90, "unit": "s", "state": "published"},
})
writeRevision(t, db, "2026-09-13 02:58:00", "", []map[string]any{
{"entryId": 1, "rank": 1, "resultText": "11.42", "resultValue": 11.42, "unit": "s", "state": "published",
"note": "成绩录入笔误"},
// 名次 2 → 1 是改张三的成绩后引擎重算的,李四这条自己没动过。
{"entryId": 2, "rank": 1, "resultText": "11.90", "resultValue": 11.90, "unit": "s", "state": "published"},
})
// 同一报名项上再挂一条破纪录加分流水:它不该继承成绩的修订痕迹。
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points)
VALUES (1, 1, 1, 1, 1, 'record_bonus', 1, 3)`); err != nil {
t.Fatalf("写入破纪录加分流水: %v", err)
}
}
// writeRevision 往 result_revisions 里插一版快照(字段名与 httpapi.resultDTO 对齐)。
func writeRevision(t *testing.T, db *sql.DB, createdAt, message string, results []map[string]any) {
t.Helper()
raw, err := json.Marshal(results)
if err != nil {
t.Fatalf("序列化快照: %v", err)
}
if _, err := db.Exec(`INSERT INTO result_revisions (event_id, action, snapshot_json, message, created_at)
VALUES (1, 'publish', ?, ?, ?)`, string(raw), message, createdAt); err != nil {
t.Fatalf("插入修订: %v", err)
}
}
// 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
}