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("没有任何可识别工作表应报错") } } // B03:工作表名带尾随空格时也必须认得出来,且取表要用文件里的原名。 // 现场把导出件另存 / 手工改过表名后很容易多出个空格;旧代码「认表用 TrimSpace 后的名字、 // 取表却用契约里的名字」,于是报出一句与事实相反的错:表就在文件里,却说它不存在。 func TestReadBaseDataPackageTrimsSheetName(t *testing.T) { const spaced = BaseDataSheetTeams + " " // "单位 ":末尾一个空格 f := excelize.NewFile() defer func() { _ = f.Close() }() if err := f.SetSheetName("Sheet1", spaced); err != nil { t.Fatalf("重命名工作表为带尾随空格的名字: %v", err) } rows := [][]string{ {"单位编码", "单位名称", "启用"}, {"T9", "第九分公司", "是"}, } for r, row := range rows { for c, v := range row { name, err := excelize.CoordinatesToCellName(c+1, r+1) if err != nil { t.Fatalf("算单元格坐标: %v", err) } if err := f.SetCellValue(spaced, 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 != BaseDataSheetTeams { t.Fatalf("应认出「单位」表,且 Sheet 报契约里的名字,得到 %+v", tables) } if len(tables[0].Rows) != 1 { t.Fatalf("数据行数 = %d,期望 1:%+v", len(tables[0].Rows), tables[0].Rows) } if got := tables[0].Rows[0].Cells["code"]; got != "T9" { t.Errorf("单位编码 = %q,期望 T9", got) } if got := tables[0].Rows[0].Cells["name"]; got != "第九分公司" { t.Errorf("单位名称 = %q,期望 第九分公司", got) } } 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("非整数应报不合法") } }