package export import ( "database/sql" "path/filepath" "testing" "github.com/xuri/excelize/v2" ) // seedUnsettledEvent 造一屏「延迟结算」现场:3 个人报名同一个组别,只有 1 个人出了成绩。 // // 已发布的那条**没有名次、没有流水**——延迟结算(standings.live_on_publish=false)下名次与流水 // 要等整组录完才写(httpapi 的 scopeSettled),这正是 A17 说的「已发布未结算」。 // 另外两条分别是「有草稿没发布」与「连成绩行都没有」,三种占位态因此能并排比。 func seedUnsettledEvent(t *testing.T, db *sql.DB) { t.Helper() stmts := []string{ `INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`, `INSERT INTO athletes(name, gender, team_id) VALUES ('张三', '男', 1)`, `INSERT INTO athletes(name, gender, team_id) VALUES ('李四', '男', 1)`, `INSERT INTO athletes(name, gender, team_id) VALUES ('王五', '男', 1)`, `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`, `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`, `INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`, `INSERT INTO event_score_channels(event_id, scope, basis, profile_id) SELECT 1, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 1, '101')`, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 2, '102')`, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 3, '103')`, `INSERT INTO results(event_id, entry_id, result_text, result_value, unit, state) VALUES (1, 1, '12.30', 12.30, 's', 'published')`, `INSERT INTO results(event_id, entry_id, result_text, result_value, unit, state) VALUES (1, 2, '12.40', 12.40, 's', 'draft')`, `UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`, } for _, s := range stmts { if _, err := db.Exec(s); err != nil { t.Fatalf("造数据 %q: %v", s, err) } } } // A17:延迟结算下「已发布但整组未结算」的行必须写成「名次 —、积分 —、状态 未结算」, // 不能是「名次空 + 积分 0 + 状态 有效」——打印件上后者会被读成「算出来就是 0 分」, // 正是 R18 立项要避免的误读。分项成绩表与单页表逐格一致(两者都走 eventRows)。 func TestUnsettledRowsPrintPlaceholders(t *testing.T) { db := openEventDB(t) seedUnsettledEvent(t, db) dir := t.TempDir() eventDst := filepath.Join(dir, "分项.xlsx") if err := ExportEventXLSX(db, 1, eventDst, Options{}); err != nil { t.Fatalf("导出分项成绩表: %v", err) } flatDst := filepath.Join(dir, "单页.xlsx") if err := ExportFlatXLSX(db, flatDst, Options{}); err != nil { t.Fatalf("导出单页成绩表: %v", err) } // 三种占位态并排:未结算 / 草稿未发布 / 完全没录。列序见 eventColumns。 want := [][]string{ {"—", "101", "第一分公司", "张三", "12.30 秒", "—", "未结算", ""}, {"—", "102", "第一分公司", "李四", "分数未录入", "—", "草稿", ""}, {"—", "103", "第一分公司", "王五", "—", "—", "未录入", ""}, } f := openXLSX(t, eventDst) defer func() { _ = f.Close() }() rows, err := f.GetRows("田径·100米跑·男子") if err != nil { t.Fatalf("读取分项成绩: %v", err) } for i, wantRow := range want { for c, v := range wantRow { if got := cell(rows, rowHeader+i, c); got != v { t.Errorf("分项成绩表第 %d 行第 %d 列 = %q,期望 %q", i+1, c, got, v) } } } // 单页表比多 sheet 版多三列(大项 / 小项 / 组别),后 8 列同序。 ff := openXLSX(t, flatDst) defer func() { _ = ff.Close() }() flatRows, err := ff.GetRows(flatSheet) if err != nil { t.Fatalf("读取单页成绩: %v", err) } for i, wantRow := range want { for c, v := range wantRow { if got := cell(flatRows, rowHeader+i, c+3); got != v { t.Errorf("单页表第 %d 行第 %d 列 = %q,期望 %q(与分项成绩表逐格一致)", i+1, c+3, got, v) } } } } // A17 的反向钉子:实时结算(默认)下分批发布同样会让「已发布 < 应出成绩」, // 但那一批当场结算过、名次与积分都在——不能被标成未结算。 // 同组的犯规行也不许被改写成「未结算」:判罚结果是现场明确给出的,不能弄丢。 func TestSettledAndPenalisedRowsAreNotMarkedUnsettled(t *testing.T) { db := openEventDB(t) seedUnsettledEvent(t, db) stmts := []string{ // 已结算:名次与流水都写好了(发布即结算的形态)。 `UPDATE results SET rank = 1 WHERE entry_id = 1`, `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, 'result', 1, 10)`, // 第二个人改为「已发布 + 犯规」:犯规不占名次,状态列要保住「犯规」。 `UPDATE results SET state = 'published', status = 'dq' WHERE entry_id = 2`, } for _, s := range stmts { if _, err := db.Exec(s); err != nil { t.Fatalf("造数据 %q: %v", s, err) } } dst := filepath.Join(t.TempDir(), "分项.xlsx") if err := ExportEventXLSX(db, 1, dst, Options{}); err != nil { t.Fatalf("导出分项成绩表: %v", err) } f := openXLSX(t, dst) defer func() { _ = f.Close() }() rows, err := f.GetRows("田径·100米跑·男子") if err != nil { t.Fatalf("读取分项成绩: %v", err) } // 已结算的那条:名次 1、积分 10、状态 有效。 if got := cell(rows, rowHeader, 0); got != "1" { t.Errorf("已结算行名次 = %q,期望 1(不该被当成未结算)", got) } if got := cell(rows, rowHeader, 5); got != "10" { t.Errorf("已结算行积分 = %q,期望 10", got) } if got := cell(rows, rowHeader, 6); got != "有效" { t.Errorf("已结算行状态 = %q,期望 有效", got) } // 犯规的那条:状态仍是「犯规」,不能被改写成「未结算」(判罚是现场明确给出的结论)。 dq := findRowByCell(t, rows, 1, "102") if got := cell(rows, dq, 6); got != "犯规" { t.Errorf("犯规行状态 = %q,期望 犯规(判罚结果不能被改写成未结算)", got) } } // findRowByCell 在表里找「第 col 列等于 value」的数据行下标;找不到直接失败。 func findRowByCell(t *testing.T, rows [][]string, col int, value string) int { t.Helper() for r := rowHeader; r < len(rows); r++ { if cell(rows, r, col) == value { return r } } t.Fatalf("表里找不到第 %d 列 = %q 的行", col, value) return -1 } // seedTieAndRelay 在 seedEvent 之上补两类现场常见、却最容易把列宽顶爆的数据: // 并列名次(名次列会写成「3(并列)」)与接力队名单(运动员/队 列写成顿号列表)。 func seedTieAndRelay(t *testing.T, db *sql.DB) { t.Helper() stmts := []string{ `UPDATE results SET rank = 3, is_tie = 1`, `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) VALUES (1, 'E2', '4×100米接力', 'team', 'auto', 'time', 'asc', '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, 'team', 'result', id FROM scoring_profiles WHERE code = 'team_top6'`, `INSERT INTO entries(event_id, category_id, team_id, member_names, bib) VALUES (2, 2, 1, '["王磊","赵雷","陈刚2","郑凯"]', '701')`, `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state) VALUES (2, 3, 1, '0:44.12', 44.12, 's', 'published')`, `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points) VALUES (1, NULL, 2, 3, 3, 'result', 3, 15)`, } for _, s := range stmts { if _, err := db.Exec(s); err != nil { t.Fatalf("造数据 %q: %v", s, err) } } } // TestColumnsFitContent 守住「不折行的列必须放得下实际内容」: // 名次列写「3(并列)」、运动员/队 列写接力队名单,列宽不够时 Excel 会把右侧的字硬裁掉 // (这一类列没有折行),领导拿到的表就是缺字的。kindText 列有折行,超宽只会换行,不在此列。 func TestColumnsFitContent(t *testing.T) { db := openEventDB(t) seedEvent(t, db) seedTieAndRelay(t, db) seedChannelRanks(t, db) cases := []struct { name string file string export func(dst string) error sheets []sheetLayout }{ { name: "分项成绩表", file: "分项.xlsx", export: func(dst string) error { return ExportEventXLSX(db, 0, dst, Options{}) }, sheets: []sheetLayout{ {"田径·100米跑·男子", eventColumns}, {"田径·4×100米接力·男子", eventColumns}, }, }, { name: "全部成绩(单页)", file: "单页.xlsx", export: func(dst string) error { return ExportFlatXLSX(db, dst, Options{}) }, sheets: []sheetLayout{ {flatSheet, flatColumns}, }, }, { name: "单位奖牌明细", file: "奖牌.xlsx", export: func(dst string) error { return ExportMedalDetailXLSX(db, dst, Options{}) }, sheets: []sheetLayout{ {medalDetailSheet, medalDetailColumns}, {"第一分公司奖牌明细", medalTeamColumns}, }, }, } dir := t.TempDir() for _, tc := range cases { dst := filepath.Join(dir, tc.file) if err := tc.export(dst); err != nil { t.Fatalf("导出%s: %v", tc.name, err) } f := openXLSX(t, dst) for _, sl := range tc.sheets { assertColumnsFit(t, f, sl.sheet, sl.columns) } if err := f.Close(); err != nil { t.Fatalf("关闭 %s: %v", dst, err) } } } // sheetLayout 是一张表的列定义,用来知道哪些列不折行。 type sheetLayout struct { sheet string columns []column } // participantTitle 是接力队名单所在列的表头,分项成绩表与奖牌明细都印这一列。 const participantTitle = "运动员/队" // assertColumnsFit 逐列核对:不折行的列,列宽必须放得下该列最长的一条数据。 func assertColumnsFit(t *testing.T, f *excelize.File, sheet string, cols []column) { t.Helper() rows, err := f.GetRows(sheet) if err != nil { t.Fatalf("读取 %s: %v", sheet, err) } for i, c := range cols { // 折行列(kindText)超宽只会换行,不丢字;但「运动员/队」是接力队名单, // 一个名单折成两三行会把每页能印的行数压掉一半,所以它也按「一行放得下」要求。 if c.kind == kindText && c.title != participantTitle { continue } name, err := excelize.ColumnNumberToName(i + 1) if err != nil { t.Fatalf("第 %d 列的列名: %v", i+1, err) } width, err := f.GetColWidth(sheet, name) if err != nil { t.Fatalf("读取 %s!%s 的列宽: %v", sheet, name, err) } if width != c.width { t.Errorf("%s「%s」列宽 = %v,期望 %v(版式与列定义应同源)", sheet, c.title, width, c.width) } // 前 4 行是标题 / 副标题 / 空行 / 表头,数据从第 5 行(下标 4)起。 for r := 4; r < len(rows); r++ { v := cell(rows, r, i) w := displayWidth(v) if w <= width { continue } avail := width if c.kind != kindText { // 不折行的列:右侧连着空格时 Excel 会让文字溢出显示,真正被裁的是 // 「溢出到有内容的那一列」——名次列右边紧挨着非空的号码,正是缺陷现场。 for j := i + 1; j < len(cols) && cell(rows, r, j) == ""; j++ { avail += cols[j].width } } if w > avail { t.Errorf("%s 第 %d 行「%s」列 = %q 需要 %v 个显示宽度,右侧可用的只有 %v,会被裁掉或折行", sheet, r+1, c.title, v, w, avail) } } } } // displayWidth 按 Excel 的粗略口径估算显示宽度:东亚全角字符(汉字、全角括号、顿号)算 2, // 其余算 1。列宽就是按这个口径定的,用它判断一格会不会被裁。 func displayWidth(s string) float64 { var w float64 for _, r := range s { switch { case r >= 0x1100 && (r <= 0x115F || r == 0x2329 || r == 0x232A || (r >= 0x2E80 && r <= 0xA4CF) || (r >= 0xAC00 && r <= 0xD7A3) || (r >= 0xF900 && r <= 0xFAFF) || (r >= 0xFE30 && r <= 0xFE6F) || (r >= 0xFF00 && r <= 0xFF60) || (r >= 0xFFE0 && r <= 0xFFE6)): w += 2 default: w++ } } return w }