package export import ( "database/sql" "path/filepath" "testing" ) 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) } id, err := res.LastInsertId() if err != nil { t.Fatalf("取运动员 id: %v", err) } return id } // 个人得分明细:总表 + 每个有得分的单位一张分表,逐条列出得分来源。 func TestExportAthleteDetailXLSX(t *testing.T) { db := openEventDB(t) seedEvent(t, db) // T1 张三 10 分、T2 李四 8 分 dst := filepath.Join(t.TempDir(), "个人.xlsx") if err := ExportAthleteDetailXLSX(db, dst, Options{}); err != nil { t.Fatalf("导出个人得分明细: %v", err) } f := openXLSX(t, dst) defer func() { _ = f.Close() }() want := []string{"个人积分总明细", "第一分公司个人得分明细", "第二分公司个人得分明细"} if got := f.GetSheetList(); !equalStrings(got, want) { t.Fatalf("sheet 列表 = %v,期望 %v", got, want) } master, err := f.GetRows("个人积分总明细") if err != nil { t.Fatalf("读取总表: %v", err) } if len(master) != 6 { // 4 行版式 + 2 条流水 t.Fatalf("总表行数 = %d,期望 6", len(master)) } header := []string{"单位", "运动员", "大项", "小项", "组别", "名次", "成绩", "得分", "来源"} for i, want := range header { if got := cell(master, 3, i); got != want { t.Errorf("表头第 %d 列 = %q,期望 %q", i, got, want) } } first := []string{"第一分公司", "张三", "田径", "100米跑", "男子", "1", "11.58 秒", "10", "成绩"} for i, want := range first { if got := cell(master, 4, i); got != want { t.Errorf("首行第 %d 列 = %q,期望 %q", i, got, want) } } // 单位分表 = 总表去掉「单位」列。 team, err := f.GetRows("第一分公司个人得分明细") if err != nil { t.Fatalf("读取单位分表: %v", err) } if len(team) != 5 { // 4 行版式 + 1 条流水 t.Fatalf("第一分公司分表行数 = %d,期望 5", len(team)) } teamRow := []string{"张三", "田径", "100米跑", "男子", "1", "11.58 秒", "10", "成绩"} for i, want := range teamRow { if got := cell(team, 4, i); got != want { t.Errorf("分表首行第 %d 列 = %q,期望 %q", i, got, want) } } } // 跨端点一致性:明细里某人的得分之和 == 个人积分榜上那个人的总分; // 且折算系数只影响单位总分,个人明细必须一分不动。 func TestAthleteDetailMatchesBoardAndIgnoresWeight(t *testing.T) { db := openEventDB(t) seedEvent(t, db) assertAthleteDetailMatchesBoard(t, db) if _, err := db.Exec(`UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`); err != nil { t.Fatalf("修改折算系数: %v", err) } // 系数 0.5 下重跑:个人明细与个人积分榜仍然一致(都是原始分 10 / 8)。 assertAthleteDetailMatchesBoard(t, db) // 对照:同一份数据上单位总分被折半,证明系数确实生效,不是设置没写进去。 board, err := LoadStandingsWorkbook(db, Options{}) if err != nil { t.Fatalf("读取总积分榜: %v", err) } for _, row := range board.Team { if row.TeamID == 1 && row.Points != 5 { t.Errorf("系数 0.5 时单位 1 总分 = %v,期望 5", row.Points) } } // 单元格里必须是原始分 10,而不是折算后的 5。 dst := filepath.Join(t.TempDir(), "个人.xlsx") if err := ExportAthleteDetailXLSX(db, dst, Options{}); err != nil { t.Fatalf("系数 0.5 下导出个人得分明细: %v", err) } f := openXLSX(t, dst) defer func() { _ = f.Close() }() rows, err := f.GetRows("第一分公司个人得分明细") if err != nil { t.Fatalf("读取分表: %v", err) } if got := cell(rows, 4, 6); got != "10" { t.Errorf("系数 0.5 时个人明细得分 = %q,期望 10(原始分,不折算)", got) } } // 只记单位的流水不算个人得分;count_to_team = 0 的流水照样算在个人头上。 func TestAthleteDetailLedgerScope(t *testing.T) { db := openEventDB(t) seedEvent(t, db) // 仅记单位(athlete_id 为空):不进个人明细。 insertLedger(t, db, 1, 99) // 「个人+团体」项目的个人通道流水:计入个人榜,但不进单位总分。 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 := athleteDetailWorkbook(t, db) if got := athletePointsFromDetail(wb, "张三"); got != 17 { t.Errorf("张三明细得分 = %v,期望 17(10 + 只记个人的 7)", got) } board := athletePointsFromBoard(t, db) if board[1] != 17 { t.Errorf("个人积分榜张三总分 = %v,期望 17", board[1]) } for _, s := range wb.Sheets { for _, r := range s.Rows { if r.Points == 99 { t.Errorf("仅记单位的流水不应出现在个人明细: %+v", r) } } } } // 没有得分流水的运动员不出现在任何一张表里。 func TestAthleteDetailSkipsAthleteWithoutLedger(t *testing.T) { db := openEventDB(t) seedEvent(t, db) insertAthlete(t, db, "王五", 1) dst := filepath.Join(t.TempDir(), "个人.xlsx") if err := ExportAthleteDetailXLSX(db, dst, Options{}); err != nil { t.Fatalf("导出个人得分明细: %v", err) } f := openXLSX(t, dst) defer func() { _ = f.Close() }() for _, name := range f.GetSheetList() { rows, err := f.GetRows(name) if err != nil { t.Fatalf("读取 %s: %v", name, err) } for r := range rows { for c := 0; c < 9; c++ { if cell(rows, r, c) == "王五" { t.Errorf("%s 第 %d 行出现了无得分的运动员", name, r) } } } } } // 一条个人流水都没有时不报错:只有总表,能重新解析。 func TestExportAthleteDetailEmptyEvent(t *testing.T) { db := openEventDB(t) insertTeam(t, db, "T1", "第一分公司", "一公司", 1, true) dst := filepath.Join(t.TempDir(), "个人.xlsx") if err := ExportAthleteDetailXLSX(db, dst, Options{}); err != nil { t.Fatalf("空赛事导出个人得分明细: %v", err) } f := openXLSX(t, dst) defer func() { _ = f.Close() }() if got := f.GetSheetList(); !equalStrings(got, []string{"个人积分总明细"}) { t.Fatalf("sheet 列表 = %v,期望只有总表", got) } rows, err := f.GetRows("个人积分总明细") if err != nil { t.Fatalf("读取总表: %v", err) } if len(rows) != 4 { t.Fatalf("总表行数 = %d,期望 4", len(rows)) } if got := cell(rows, 3, 0); got != "单位" { t.Errorf("表头首列 = %q,期望 单位", got) } } func athleteDetailWorkbook(t *testing.T, db *sql.DB) AthleteDetailWorkbook { t.Helper() wb, err := LoadAthleteDetailWorkbook(db, Options{}) if err != nil { t.Fatalf("读取个人得分明细: %v", err) } return wb } // athletePointsFromBoard 是「个人积分榜」端点同源的口径:domain.BuildAthleteStandings 的总分。 func athletePointsFromBoard(t *testing.T, db *sql.DB) map[int64]float64 { t.Helper() wb, err := LoadStandingsWorkbook(db, Options{}) if err != nil { t.Fatalf("读取个人积分榜: %v", err) } out := make(map[int64]float64, len(wb.Athlete)) for _, row := range wb.Athlete { out[row.AthleteID] = row.Points } return out } func athletePointsFromDetail(wb AthleteDetailWorkbook, name string) float64 { var total float64 for _, s := range wb.Sheets { for _, r := range s.Rows { if r.AthleteName == name { total += r.Points } } } return total } // assertAthleteDetailMatchesBoard 逐人比对:明细得分之和 == 个人积分榜总分; // 并且两边的人必须一一对应(榜单上的人都有明细,明细里的人都在榜上)。 func assertAthleteDetailMatchesBoard(t *testing.T, db *sql.DB) { t.Helper() wb := athleteDetailWorkbook(t, db) board, err := LoadStandingsWorkbook(db, Options{}) if err != nil { t.Fatalf("读取个人积分榜: %v", err) } byName := make(map[string]float64, len(board.Athlete)) for _, row := range board.Athlete { byName[row.Name] = row.Points } detailNames := make(map[string]bool) for _, s := range wb.Sheets { for _, r := range s.Rows { detailNames[r.AthleteName] = true } } for name, want := range byName { if !detailNames[name] { t.Errorf("个人积分榜上的 %q 没有明细行", name) continue } got := athletePointsFromDetail(wb, name) t.Logf("运动员 %q:明细合计 %v,个人积分榜 %v", name, got, want) if got != want { t.Errorf("运动员 %q:明细合计 %v,个人积分榜 %v", name, got, want) } } for name := range detailNames { if _, ok := byName[name]; !ok { t.Errorf("明细里的运动员 %q 不在个人积分榜上", name) } } }