Files
Scoreboard/server/internal/export/event_test.go
T
AskaEth 8dd599bd97 fix: 实机复测六批发现的 16 条缺陷(导入编码静默错误 / 导出观感 / 错误分级 / 大屏裁切)
先把「实机复测」能在这台 Linux 机器上验的全部自己验掉(真服务端 + 真 Chromium + 真样本),
六批全部通过,另挖出 16 条缺陷,逐条修完并复验:

导入
- 坏字节被静默替换成 U+FFFD 却报「组别不存在」→ 检测 RuneError 回 400 + 可操作中文文案
- UTF-16 无 BOM 误报「缺少列 event_code」→ 保守判据识别并正确解码
- CSV 语法错只有英文 → 中文提示,英文原文保留在 detail
- 表头缺列补「第 1 行应是表头」的指向

导出
- 名次列宽 8 → 11(放得下「3(并列)」,Excel 里不再硬裁)
- 运动员/队列宽 18 → 24(含奖牌明细的同一列)
- 聚合流水行补组别(仅单位在该小项只参加一个组别时回推;多组别宁可留空也不猜)
- docProps 作者从 excelize 默认的 xuri 改成中性名 + 真实导出时间
- Content-Disposition 补 ASCII 退化 filename(9 个下载端点,含此前没有测试的 project.zip)

错误分级与限流
- 未知大屏指令 500 → 400 VALIDATION_FAILED(传输层真失败仍走 5xx)
- 5xx 的 ERROR 日志补 role / ip(4xx 的 WARN 一直有)
- 写限流 30 → 120 次/分钟/用户(登录 5 次/分/IP 不动,性质不同)
- 赛事名含非法字符时导出给 400 + 可操作提示(原来永远 500「服务端异常」)

大屏与基础设施
- 实时赛况 2 栏成绩末位被裁(1:09.00 显示成 1:09.0)→ 成绩列按内容定宽 + nowrap;
  字号不缩,让位给可省略号截断的运动员列。真 Chromium 实测 slack −45px → +6px、0 裁切、
  scrollWidth 恒 1920(无横向滚动条);顺带修掉 1 栏长成绩同样被裁 8px 的问题
- logging 启动即预建两个日志目录(原来 interceptions/ 懒创建,现场会以为日志坏了)
- C→S 裸帧由静默忽略改为 WARN,并把「要套 {event,data} 外壳」写进日志
- 建单位/大项漏传 active 不再静默建成「停用」(新增 decodeJSONPresence 按键是否存在判意图)

文档
- README 单位 logo 措辞收窄到「大屏端」(管理端只负责上传配置)
- 导入规范补三类编码报错说明 + 现场另存结论 + 限流提示
- 部署指南补「导出前先看赛事名称」与「切换后怎么确认真的切过去了」
- devdocs 05/08/13 同步(含第三十五轮的完整记录与教训)

自检:服务端 13 包连跑两遍全绿;前端 257 例;vue-tsc + build 通过;Console 18 文件。
版本号 dev_0.1.4 → dev_0.1.5。
2026-09-11 15:35:42 +08:00

167 lines
5.7 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 (
"database/sql"
"path/filepath"
"testing"
"github.com/xuri/excelize/v2"
)
// 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)
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
}