package export import ( "bytes" "path/filepath" "testing" "github.com/xuri/excelize/v2" ) func TestExportStandingsXLSX(t *testing.T) { db := openEventDB(t) seedEvent(t, db) seedChannelRanks(t, db) dst := filepath.Join(t.TempDir(), "总表.xlsx") if err := ExportStandingsXLSX(db, dst, Options{}); err != nil { t.Fatalf("导出总表: %v", err) } f, err := excelize.OpenFile(dst) if err != nil { t.Fatalf("重新打开 xlsx: %v", err) } defer func() { _ = f.Close() }() want := []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜"} if got := f.GetSheetList(); !equalStrings(got, want) { t.Fatalf("sheet 列表 = %v,期望 %v", got, want) } // 版式固定:1 标题 / 2 副标题 / 3 空行 / 4 表头 / 5 起数据;2 个单位 → 6 行。 team, err := f.GetRows("总积分榜") if err != nil { t.Fatalf("读取总积分榜: %v", err) } if len(team) != 6 { t.Fatalf("总积分榜行数 = %d,期望 6", len(team)) } if got := cell(team, 0, 0); got != "测试运动会 · 总积分榜" { t.Errorf("标题 = %q", got) } if got := cell(team, 3, 0); got != "名次" { t.Errorf("表头首列 = %q,期望 名次", got) } if got := cell(team, 4, 1); got != "T1" { t.Errorf("第一名单位代码 = %q,期望 T1", got) } if got := cell(team, 4, 3); got != "10" { t.Errorf("T1 总分 = %q,期望 10", got) } if got := cell(team, 4, 6); got != "1" { t.Errorf("T1 金牌 = %q,期望 1", got) } if got := cell(team, 5, 1); got != "T2" { t.Errorf("第二名单位代码 = %q,期望 T2", got) } medal, err := f.GetRows("单位奖牌榜") if err != nil { t.Fatalf("读取单位奖牌榜: %v", err) } if len(medal) != 6 { t.Fatalf("单位奖牌榜行数 = %d,期望 6", len(medal)) } if got := cell(medal, 4, 3); got != "1" { // T1 金牌 t.Errorf("T1 奖牌榜金牌 = %q,期望 1", got) } athlete, err := f.GetRows("个人积分奖牌榜") if err != nil { t.Fatalf("读取个人积分奖牌榜: %v", err) } if len(athlete) != 6 { t.Fatalf("个人积分奖牌榜行数 = %d,期望 6", len(athlete)) } if got := cell(athlete, 4, 1); got != "张三" { t.Errorf("个人榜第一行 = %q,期望 张三", got) } } func TestWriteStandingsXLSXStream(t *testing.T) { db := openEventDB(t) seedEvent(t, db) wb, err := LoadStandingsWorkbook(db, Options{}) if err != nil { t.Fatalf("读取总表数据: %v", err) } var buf bytes.Buffer if err := WriteStandingsXLSX(&buf, wb); err != nil { t.Fatalf("流式导出: %v", err) } f, err := excelize.OpenReader(bytes.NewReader(buf.Bytes())) if err != nil { t.Fatalf("打开流式导出的 xlsx: %v", err) } defer func() { _ = f.Close() }() if got := len(f.GetSheetList()); got != 3 { t.Errorf("sheet 数 = %d,期望 3", got) } } func TestExportEventXLSX(t *testing.T) { db := openEventDB(t) seedEvent(t, db) dst := filepath.Join(t.TempDir(), "分项.xlsx") if err := ExportEventXLSX(db, 1, dst, Options{}); err != nil { t.Fatalf("导出分项成绩表: %v", err) } f, err := excelize.OpenFile(dst) if err != nil { t.Fatalf("重新打开 xlsx: %v", err) } defer func() { _ = f.Close() }() want := []string{"田径·100米跑·男子"} if got := f.GetSheetList(); !equalStrings(got, want) { t.Fatalf("sheet 列表 = %v,期望 %v", got, want) } rows, err := f.GetRows(want[0]) if err != nil { t.Fatalf("读取分项成绩: %v", err) } if len(rows) != 6 { t.Fatalf("分项成绩行数 = %d,期望 6", len(rows)) } if got := cell(rows, 4, 0); got != "1" { t.Errorf("第一名名次 = %q,期望 1", got) } if got := cell(rows, 4, 1); got != "101" { t.Errorf("第一名号码 = %q,期望 101", got) } if got := cell(rows, 4, 3); got != "张三" { t.Errorf("第一名运动员 = %q,期望 张三", got) } if got := cell(rows, 4, 4); got != "11.58 秒" { t.Errorf("第一名成绩 = %q,期望 11.58 秒", got) } if got := cell(rows, 4, 5); got != "10" { t.Errorf("第一名积分 = %q,期望 10", got) } if got := cell(rows, 4, 6); got != "有效" { t.Errorf("第一名状态 = %q,期望 有效", got) } } func TestExportAllXLSX(t *testing.T) { db := openEventDB(t) seedEvent(t, db) dst := filepath.Join(t.TempDir(), "全部.xlsx") if err := ExportAllXLSX(db, dst, Options{}); err != nil { t.Fatalf("导出全部: %v", err) } f, err := excelize.OpenFile(dst) if err != nil { t.Fatalf("重新打开 xlsx: %v", err) } defer func() { _ = f.Close() }() want := []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"} if got := f.GetSheetList(); !equalStrings(got, want) { t.Fatalf("sheet 列表 = %v,期望 %v", got, want) } } func TestExportTeamDetailXLSX(t *testing.T) { db := openEventDB(t) seedEvent(t, db) dst := filepath.Join(t.TempDir(), "明细.xlsx") if err := ExportTeamDetailXLSX(db, 1, dst, Options{}); err != nil { t.Fatalf("导出单位积分明细: %v", err) } f, err := excelize.OpenFile(dst) if err != nil { t.Fatalf("重新打开 xlsx: %v", err) } defer func() { _ = f.Close() }() sheet := "第一分公司积分明细" rows, err := f.GetRows(sheet) if err != nil { t.Fatalf("读取 %s: %v", sheet, err) } // 4 行版式 + 1 条明细 + 1 行合计 if len(rows) != 6 { t.Fatalf("%s 行数 = %d,期望 6", sheet, len(rows)) } if got := cell(rows, 4, 1); got != "田径" { t.Errorf("明细大项 = %q,期望 田径", got) } if got := cell(rows, 4, 5); got != "1" { t.Errorf("明细名次 = %q,期望 1", got) } if got := cell(rows, 4, 7); got != "成绩" { t.Errorf("明细来源 = %q,期望 成绩", got) } if got := cell(rows, 4, 8); got != "10" { t.Errorf("明细积分 = %q,期望 10", got) } if got := cell(rows, 5, 0); got != "合计" { t.Errorf("合计行首列 = %q,期望 合计", got) } if got := cell(rows, 5, 8); got != "10" { t.Errorf("合计积分 = %q,期望 10", got) } // A18:单位积分明细的成绩列必须带上单位,与分项成绩表 / 个人明细同口径—— // 同一场比赛的同一个成绩,在一份表里是「11.58 秒」、在另一份里是「11.58」, // 两张表摆在一起核对时会被当成两个数。 if got := cell(rows, 4, 6); got != "11.58 秒" { t.Errorf("明细成绩 = %q,期望 11.58 秒(与分项成绩表同口径)", got) } } // A18 的对照面:同一个成绩在「单位积分明细」「个人积分总明细」「分项成绩表」里必须逐字一致。 func TestTeamDetailResultMatchesOtherExports(t *testing.T) { db := openEventDB(t) seedEvent(t, db) dir := t.TempDir() teamDst := filepath.Join(dir, "单位明细.xlsx") if err := ExportTeamDetailXLSX(db, 1, teamDst, Options{}); err != nil { t.Fatalf("导出单位积分明细: %v", err) } athDst := filepath.Join(dir, "个人明细.xlsx") if err := ExportAthleteDetailXLSX(db, athDst, Options{}); err != nil { t.Fatalf("导出个人积分总明细: %v", err) } eventDst := filepath.Join(dir, "分项.xlsx") if err := ExportEventXLSX(db, 1, eventDst, Options{}); err != nil { t.Fatalf("导出分项成绩表: %v", err) } teamRows := readSheet(t, teamDst, "第一分公司积分明细") athRows := readSheet(t, athDst, "第一分公司个人得分明细") eventRows := readSheet(t, eventDst, "田径·100米跑·男子") // 单位明细第 5 行(0 基下标 4)的张三 100 米;列 6 是「成绩」。 teamResult := cell(teamRows, 4, 6) // 个人明细分表数据从同一版式起,列 5 是「成绩」。 athResult := cell(athRows, 4, 5) // 分项成绩表列 4 是「成绩」。 eventResult := cell(eventRows, 4, 4) want := "11.58 秒" for name, got := range map[string]string{ "单位积分明细": teamResult, "个人积分总明细": athResult, "分项成绩表": eventResult, } { if got != want { t.Errorf("%s 里的成绩 = %q,期望 %q", name, got, want) } } } // B02:单单位「单位积分明细」在没有任何流水时也要有「合计」行(写 0), // 与总明细版版式一致,手册承诺的「末行是合计」才成立。 func TestEmptyTeamDetailKeepsTotalRow(t *testing.T) { db := openEventDB(t) seedEvent(t, db) // 第三个单位一条流水都没有:它是「新加进来的单位,还没拿分」的现场形态。 teamID := insertTeam(t, db, "T3", "第三分公司", "三公司", 3, true) dst := filepath.Join(t.TempDir(), "单位明细.xlsx") if err := ExportTeamDetailXLSX(db, teamID, dst, Options{}); err != nil { t.Fatalf("导出空单位明细: %v", err) } rows := readSheet(t, dst, "第三分公司积分明细") // 4 行版式 + 只有合计行 if len(rows) != 5 { t.Fatalf("空单位明细行数 = %d,期望 5(末行合计):%v", len(rows), rows) } if got := cell(rows, 4, 0); got != "合计" { t.Errorf("末行首列 = %q,期望 合计", got) } if got := cell(rows, 4, 8); got != "0" { t.Errorf("空单位合计 = %q,期望 0", got) } } // readSheet 打开落盘的 xlsx 并读出某张表;导出类用例一律真的写盘再读回,见 saveWorkbook 的说明。 func readSheet(t *testing.T, path, sheet string) [][]string { t.Helper() f := openXLSX(t, path) defer func() { _ = f.Close() }() rows, err := f.GetRows(sheet) if err != nil { t.Fatalf("读取 %s!%s: %v", path, sheet, err) } return rows } // TestLoadStandingsHonoursSettings 验证个人分折算系数确实来自赛事库设置。 func TestLoadStandingsHonoursSettings(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 := LoadStandingsWorkbook(db, Options{}) if err != nil { t.Fatalf("读取总表数据: %v", err) } if len(wb.Team) != 2 { t.Fatalf("单位数 = %d,期望 2", len(wb.Team)) } if wb.Team[0].Points != 20 { t.Errorf("系数 2 时 T1 总分 = %v,期望 20", wb.Team[0].Points) } } func TestLoadEventWorkbookMissingEvent(t *testing.T) { db := openEventDB(t) seedEvent(t, db) if _, err := LoadEventWorkbook(db, 999, Options{}); err == nil { t.Fatal("导出不存在的小项应报错") } } func cell(rows [][]string, r, c int) string { if r < 0 || r >= len(rows) { return "" } if c < 0 || c >= len(rows[r]) { return "" } return rows[r][c] } func equalStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }