Files
Scoreboard/server/internal/export/basedata_test.go
T
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
2026-09-11 17:14:02 +08:00

292 lines
11 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 (
"bytes"
"database/sql"
"strings"
"testing"
"github.com/xuri/excelize/v2"
)
// seedBaseData 造一份字段尽量填满的基础数据:两个单位、两个大项、三个小项、四个组别。
// 含简称 / 颜色 / 权重 / 性别 / 年龄区间 / 场地 / 备注与一个停用的大项,
// 让导出→导入的往返能覆盖到「启用 = 否」这类容易被静默改掉的列。
func seedBaseData(t *testing.T, db *sql.DB) {
t.Helper()
stmts := []string{
`INSERT INTO teams(code, name, short_name, color, sort_order, active)
VALUES ('T1', '第一分公司', '一公司', '#3482FF', 1, 1)`,
`INSERT INTO teams(code, name, sort_order, active) VALUES ('T2', '第二分公司', 2, 0)`,
`INSERT INTO sports(code, name, short_name, sort_order, active) VALUES ('SP1', '田径', '田', 1, 1)`,
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP2', '游泳', 2, 0)`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, sort_order, venue, remark)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 2, 'ongoing', 1, '主体育场', '决赛')`,
`INSERT INTO events(sport_id, code, name) VALUES (1, 'E2', '4×100米接力')`,
`INSERT INTO events(sport_id, code, name, event_type) VALUES (2, 'E3', '100米自由泳', 'team')`,
`INSERT INTO event_categories(event_id, name, multiplier, gender, age_min, age_max, sort_order)
VALUES (1, '男子青年组', 1.5, '男', 18, 35, 1)`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子组', 2)`,
`INSERT INTO event_categories(event_id, name, gender, sort_order) VALUES (2, '团体组', '男', 1)`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (3, '男子', 1)`,
`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)
}
}
}
// findRecord 按某字段的取值找一行;顺序不是本测试要验证的东西,按内容找更稳。
func findRecord(t *testing.T, rows []BaseDataRow, field, value string) BaseDataRow {
t.Helper()
for _, row := range rows {
if row.Cells[field] == value {
return row
}
}
t.Fatalf("找不到 %s = %q 的行", field, value)
return BaseDataRow{}
}
// TestBaseDataPackageRoundTrip 是往返硬要求:导出的包原样读回来,四张表的表名、表头、
// 行数与每行的关键取值都要对得上——表头对不上,导回来就是「找不到表头行」。
func TestBaseDataPackageRoundTrip(t *testing.T) {
db := openEventDB(t)
seedBaseData(t, db)
wb, err := LoadBaseDataWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取基础数据: %v", err)
}
var buf bytes.Buffer
if err := WriteBaseDataXLSX(&buf, wb); err != nil {
t.Fatalf("写出基础数据包: %v", err)
}
tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("读回基础数据包: %v", err)
}
if len(tables) != 4 {
t.Fatalf("工作表数 = %d,期望 4", len(tables))
}
wantSheets := []string{BaseDataSheetTeams, BaseDataSheetSports, BaseDataSheetEvents, BaseDataSheetCategories}
wantRows := []int{2, 2, 3, 4}
for i, table := range tables {
if table.Sheet != wantSheets[i] {
t.Fatalf("第 %d 张表 = %q,期望 %q", i+1, table.Sheet, wantSheets[i])
}
if table.HeaderLine != rowHeader {
t.Errorf("%s 表头行 = %d,期望 %d", table.Sheet, table.HeaderLine, rowHeader)
}
if len(table.Rows) != wantRows[i] {
t.Errorf("%s 行数 = %d,期望 %d", table.Sheet, len(table.Rows), wantRows[i])
}
// 表头必须与导出契约逐字一致,否则导入端认不出列。
spec := BaseDataSpecs()[i]
for _, h := range spec.Headers {
found := false
for _, row := range table.Rows {
if row.Has[h.Field] {
found = true
break
}
}
if !found && len(table.Rows) > 0 {
t.Errorf("%s 缺少列 %q(字段 %s)", table.Sheet, h.Header, h.Field)
}
}
}
teams := tables[0]
if got := findRecord(t, teams.Rows, "code", "T1").Cells["code"]; got != "T1" {
t.Errorf("单位编码 = %q,期望 T1", got)
}
t1 := findRecord(t, teams.Rows, "code", "T1")
if got := t1.Cells["active"]; got != "是" {
t.Errorf("T1 启用 = %q,期望 是", got)
}
if got := t1.Cells["color"]; got != "#3482FF" {
t.Errorf("T1 颜色 = %q", got)
}
if got := findRecord(t, teams.Rows, "code", "T2").Cells["active"]; got != "否" {
t.Errorf("停用的 T2 启用 = %q,期望 否(往返不能把停用改回启用)", got)
}
events := tables[2]
e1 := findRecord(t, events.Rows, "code", "E1")
if got := e1.Cells["sportCode"]; got != "SP1" {
t.Errorf("小项大项编码 = %q,期望 SP1", got)
}
if got := e1.Cells["multiplier"]; got != "2" {
t.Errorf("E1 权重 = %q,期望 2", got)
}
if got := e1.Cells["venue"]; got != "主体育场" {
t.Errorf("E1 场地 = %q", got)
}
if got := e1.Cells["resultType"]; got != "time" {
t.Errorf("E1 结果类型 = %q,期望 time", got)
}
if got := findRecord(t, events.Rows, "code", "E3").Cells["sportCode"]; got != "SP2" {
t.Errorf("E3 大项编码 = %q,期望 SP2", got)
}
cats := tables[3]
findRecord(t, cats.Rows, "eventCode", "E1")
c1 := findRecord(t, cats.Rows, "name", "男子青年组")
if got := c1.Cells["eventCode"]; got != "E1" {
t.Errorf("组别所属小项编码 = %q,期望 E1(组别必须带小项编码)", got)
}
if got := c1.Cells["ageMin"]; got != "18" {
t.Errorf("年龄下限 = %q,期望 18", got)
}
if got := c1.Cells["gender"]; got != "男" {
t.Errorf("性别 = %q,期望 男", got)
}
if got := c1.Cells["multiplier"]; got != "1.5" {
t.Errorf("组别权重 = %q,期望 1.5", got)
}
// 再读一遍原始表头,确认表头文案与契约逐字一致(含「组别 sheet 带小项编码」这条)。
raw, err := excelize.OpenReader(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("打开基础数据包: %v", err)
}
defer func() { _ = raw.Close() }()
headerRow, err := raw.GetRows(BaseDataSheetCategories)
if err != nil {
t.Fatalf("读取组别表: %v", err)
}
for i, h := range []string{"小项编码", "组别名", "权重", "性别", "年龄下限", "年龄上限", "排序"} {
if got := cell(headerRow, rowHeader-1, i); got != h {
t.Errorf("组别表头第 %d 列 = %q,期望 %q", i+1, got, h)
}
}
}
// 手工整理的表通常把表头放在第 1 行,也必须认得出来,且行号要报真实行号。
func TestReadBaseDataPackageHeaderAtFirstRow(t *testing.T) {
f := excelize.NewFile()
defer func() { _ = f.Close() }()
if err := f.SetSheetName("Sheet1", "单位"); err != nil {
t.Fatalf("重命名工作表: %v", err)
}
rows := [][]string{
{"单位编码", "单位名称", "启用"},
{"T9", "第九分公司", "是"},
{"T8", "第八分公司", "否"},
}
for r, row := range rows {
for c, v := range row {
name, _ := excelize.CoordinatesToCellName(c+1, r+1)
if err := f.SetCellValue("单位", name, v); err != nil {
t.Fatalf("写单元格: %v", err)
}
}
}
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
t.Fatalf("写出测试工作簿: %v", err)
}
tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("读回: %v", err)
}
if len(tables) != 1 || tables[0].Sheet != "单位" {
t.Fatalf("只应认出单位表,得到 %+v", tables)
}
if tables[0].HeaderLine != 1 {
t.Errorf("表头行 = %d,期望 1", tables[0].HeaderLine)
}
if len(tables[0].Rows) != 2 {
t.Fatalf("行数 = %d,期望 2", len(tables[0].Rows))
}
if got := tables[0].Rows[0].Line; got != 2 {
t.Errorf("首行行号 = %d,期望 2", got)
}
if got := tables[0].Rows[1].Line; got != 3 {
t.Errorf("次行行号 = %d,期望 3", got)
}
// 文件里没有「简称」列:Has 必须是 false,导入端才知道「不改」而不是「写空」。
if tables[0].Rows[0].Has["shortName"] {
t.Errorf("文件里没有简称列,Has[shortName] 不该为 true")
}
if !tables[0].Rows[0].Has["active"] {
t.Errorf("文件里有启用列,Has[active] 应为 true")
}
if tables[0].Rows[0].Cells["name"] != "第九分公司" {
t.Errorf("单位名 = %q", tables[0].Rows[0].Cells["name"])
}
}
func TestReadBaseDataPackageErrors(t *testing.T) {
// 表名认得出来但表头找不到:必须点名缺哪一列,而不是笼统地说文件坏了。
f := excelize.NewFile()
defer func() { _ = f.Close() }()
if err := f.SetSheetName("Sheet1", "单位"); err != nil {
t.Fatalf("重命名工作表: %v", err)
}
if err := f.SetCellValue("单位", "A1", "随便写点什么"); err != nil {
t.Fatalf("写单元格: %v", err)
}
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
t.Fatalf("写出测试工作簿: %v", err)
}
if _, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes())); err == nil {
t.Fatal("找不到表头应报错")
} else if !strings.Contains(err.Error(), "单位编码") {
t.Errorf("错误信息应点名缺的表头,得到 %q", err.Error())
}
// 一张可识别的表都没有。
f2 := excelize.NewFile()
defer func() { _ = f2.Close() }()
if err := f2.SetCellValue("Sheet1", "A1", "无关内容"); err != nil {
t.Fatalf("写单元格: %v", err)
}
var buf2 bytes.Buffer
if err := f2.Write(&buf2); err != nil {
t.Fatalf("写出测试工作簿: %v", err)
}
if _, err := ReadBaseDataPackage(bytes.NewReader(buf2.Bytes())); err == nil {
t.Fatal("没有任何可识别工作表应报错")
}
}
func TestParseBaseDataHelpers(t *testing.T) {
for _, raw := range []string{"是", "1", "true", "启用", "YES", "on"} {
if v, ok := ParseBaseDataBool(raw, false); !ok || !v {
t.Errorf("ParseBaseDataBool(%q) = %v,%v,期望 true,true", raw, v, ok)
}
}
for _, raw := range []string{"否", "0", "false", "停用", "NO", "off"} {
if v, ok := ParseBaseDataBool(raw, true); !ok || v {
t.Errorf("ParseBaseDataBool(%q) = %v,%v,期望 false,true", raw, v, ok)
}
}
if v, ok := ParseBaseDataBool("", true); !ok || !v {
t.Errorf("空值应取 fallback,得到 %v,%v", v, ok)
}
if _, ok := ParseBaseDataBool("也许", true); ok {
t.Error("认不出的写法应报不合法")
}
if v, ok := ParseBaseDataFloat("", 1); !ok || v != 1 {
t.Errorf("空权重应取 fallback 1,得到 %v,%v", v, ok)
}
if _, ok := ParseBaseDataFloat("两倍", 1); ok {
t.Error("非数字权重应报不合法")
}
if v, ok := ParseBaseDataInt(""); !ok || v != nil {
t.Errorf("空整数应为 nil,true,得到 %v,%v", v, ok)
}
if _, ok := ParseBaseDataInt("三"); ok {
t.Error("非整数应报不合法")
}
}