Files
Scoreboard/server/internal/export/load_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

296 lines
10 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"
"testing"
"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))
}
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)
}
}
}
// 只排名不计分(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)
}
}
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)
}
}
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)
}
}
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)
}
}
// 停用但仍有积分的单位要显示编码/名称,不能是空行。
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
}
// 「个人+团体」项目的个人通道流水(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)
}
}
}