package rules import ( "database/sql" "errors" "path/filepath" "reflect" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/store" ) const applyPackYAML = `format: scoreboard-rules formatVersion: 1 meta: { code: demo-rules, name: 导入目标 } standings: individualToTeamWeight: 1 tieBreak: [points, gold, silver, bronze, teamCode] medalCountsTeamEvents: true showIndividualBoard: true showIndividualMedals: true profiles: - code: individual_top6 name: 个人项目前六名 awardRanks: 6 tieMode: skip rules: - { rank: 1, points: 12 } - { rank: 2, points: 9 } - { rank: 3, points: 7 } - { rank: 4, points: 4 } - { rank: 5, points: 3 } - { rank: 6, points: 2 } - code: team_top6 name: 团体项目前六名 awardRanks: 6 tieMode: skip rules: - { rank: 1, points: 15 } - { rank: 2, points: 10 } - { rank: 3, points: 8 } - { rank: 4, points: 4 } - { rank: 5, points: 2 } - { rank: 6, points: 1 } - code: special_top3 name: 特别项目前三名 awardRanks: 3 tieMode: higher rules: - { rank: 1, points: 5 } - { rank: 2, points: 3 } - { rank: 3, points: 1 } recordBonus: enabled: false mode: max levels: - { code: meet, name: 赛会纪录, points: 2 } manualAdjustment: enabled: false reasonRequired: true channelTemplates: - match: { eventType: individual } channels: - { scope: athlete, basis: result, profile: individual_top6 } - match: { eventType: team } channels: - { scope: team, basis: manual, profile: team_top6 } eventOverrides: - eventCode: EVT_IND name: 个人示例 multiplier: 2 channels: - { scope: athlete, basis: result, profile: individual_top6 } - { scope: team, basis: manual, profile: team_top6 } - eventCode: EVT_TEAM channels: [] ` func openEventDB(t *testing.T) *sql.DB { t.Helper() path := filepath.Join(t.TempDir(), "event.db") dsn := "file:" + path + "?_pragma=journal_mode(WAL)" + "&_pragma=foreign_keys(1)" + "&_pragma=busy_timeout(5000)" db, err := sql.Open("sqlite", dsn) if err != nil { t.Fatalf("打开赛事库: %v", err) } db.SetMaxOpenConns(1) t.Cleanup(func() { _ = db.Close() }) if err := store.MigrateEvent(db); err != nil { t.Fatalf("迁移赛事库: %v", err) } return db } // seedEvents 建一个示例大项与两个项目,并给团体项目挂一条通道,用于验证通道删除。 func seedEvents(t *testing.T, db *sql.DB) { t.Helper() res, err := db.Exec(`INSERT INTO sports (code, name) VALUES ('demo-sport', '示例大项')`) if err != nil { t.Fatalf("插入大项: %v", err) } sportID, err := res.LastInsertId() if err != nil { t.Fatalf("读取大项 id: %v", err) } for _, ev := range []struct { code, name, eventType, resultType, compare string }{ {"EVT_IND", "个人示例", "individual", "time", "asc"}, {"EVT_TEAM", "团体示例", "team", "none", "none"}, } { if _, err := db.Exec(`INSERT INTO events (sport_id, code, name, event_type, result_type, compare, scored, multiplier) VALUES (?, ?, ?, ?, ?, ?, 1, 1)`, sportID, ev.code, ev.name, ev.eventType, ev.resultType, ev.compare); err != nil { t.Fatalf("插入项目 %s: %v", ev.code, err) } } var teamProfileID int64 if err := db.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfileID); err != nil { t.Fatalf("查询团体模板: %v", err) } var teamEventID int64 if err := db.QueryRow(`SELECT id FROM events WHERE code = 'EVT_TEAM'`).Scan(&teamEventID); err != nil { t.Fatalf("查询团体项目: %v", err) } if _, err := db.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) VALUES (?, 'team', 'manual', ?, 0)`, teamEventID, teamProfileID); err != nil { t.Fatalf("插入团体通道: %v", err) } } func TestApplyWritesChangesAndIsIdempotent(t *testing.T) { db := openEventDB(t) seedEvents(t, db) next, err := Parse([]byte(applyPackYAML)) if err != nil { t.Fatalf("Parse: %v", err) } cur, err := Load(db) if err != nil { t.Fatalf("Load: %v", err) } if len(cur.Pack.Profiles) != 2 { t.Fatalf("迁移后应有 2 个模板,实际 %d", len(cur.Pack.Profiles)) } cs, err := Diff(cur, next) if err != nil { t.Fatalf("Diff: %v", err) } if cs.Empty() { t.Fatal("差异不应为空") } // 预览阶段不落库。 if got := countRows(t, db, "scoring_profiles"); got != 2 { t.Fatalf("Diff 不应写库,模板数 = %d", got) } res, err := Apply(db, cs) if err != nil { t.Fatalf("Apply: %v", err) } if res.ProfilesWritten != 3 || res.ProfilesDeleted != 0 || res.ChannelsWritten != 2 { t.Errorf("落库统计异常: %+v", res) } if got := countRows(t, db, "scoring_profiles"); got != 3 { t.Errorf("模板数 = %d,期望 3", got) } if got := queryFloat(t, db, `SELECT points FROM scoring_profile_rules r JOIN scoring_profiles p ON p.id = r.profile_id WHERE p.code = 'individual_top6' AND r.rank = 1`); got != 12 { t.Errorf("个人模板第 1 名分值 = %v,期望 12", got) } if got := countRows(t, db, "event_score_channels"); got != 2 { t.Errorf("通道数 = %d,期望 2(EVT_IND 双通道、EVT_TEAM 清空)", got) } if got := queryInt(t, db, `SELECT COUNT(*) FROM event_score_channels c JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_IND'`); got != 2 { t.Errorf("EVT_IND 通道数 = %d,期望 2", got) } if got := queryInt(t, db, `SELECT COUNT(*) FROM event_score_channels c JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_TEAM'`); got != 0 { t.Errorf("EVT_TEAM 通道数 = %d,期望 0", got) } if got := queryFloat(t, db, `SELECT multiplier FROM events WHERE code = 'EVT_IND'`); got != 2 { t.Errorf("EVT_IND 权重 = %v,期望 2", got) } if got := queryString(t, db, `SELECT value FROM settings WHERE key = 'record_bonus.levels'`); got == "" { t.Error("破纪录档位应写入 settings") } // 重复导入必须幂等:第二次差异为空,Apply 不再写任何东西。 cur2, err := Load(db) if err != nil { t.Fatalf("再次 Load: %v", err) } cs2, err := Diff(cur2, next) if err != nil { t.Fatalf("再次 Diff: %v", err) } if !cs2.Empty() { t.Fatalf("重复导入应无差异: %+v", cs2) } if _, err := Apply(db, cs2); err != nil { t.Fatalf("重复 Apply: %v", err) } } func TestExportRoundTripThroughDB(t *testing.T) { db := openEventDB(t) seedEvents(t, db) next, err := Parse([]byte(applyPackYAML)) if err != nil { t.Fatalf("Parse: %v", err) } cur, err := Load(db) if err != nil { t.Fatalf("Load: %v", err) } cs, err := Diff(cur, next) if err != nil { t.Fatalf("Diff: %v", err) } if _, err := Apply(db, cs); err != nil { t.Fatalf("Apply: %v", err) } meta := Meta{Code: "demo-rules", Name: "导入目标", Version: "1.0.0", UpdatedAt: "2026-01-01", Author: "tester"} exported, err := Export(db, meta) if err != nil { t.Fatalf("Export: %v", err) } if exported.Meta != meta { t.Errorf("导出 meta 错误: %+v", exported.Meta) } if len(exported.Profiles) != 3 { t.Fatalf("导出模板数 = %d,期望 3", len(exported.Profiles)) } if exported.Profiles[0].Code != "individual_top6" || exported.Profiles[0].Rules[0].Points != 12 { t.Errorf("导出模板内容错误: %+v", exported.Profiles[0]) } var indOverride *EventOverride for i := range exported.EventOverrides { if exported.EventOverrides[i].EventCode == "EVT_IND" { indOverride = &exported.EventOverrides[i] } } if indOverride == nil { t.Fatal("导出的项目例外里应包含 EVT_IND") } if indOverride.Multiplier == nil || *indOverride.Multiplier != 2 { t.Errorf("EVT_IND 权重导出错误: %+v", indOverride.Multiplier) } if indOverride.Channels == nil || len(*indOverride.Channels) != 2 { t.Errorf("EVT_IND 通道导出错误: %+v", indOverride.Channels) } raw, err := Marshal(exported) if err != nil { t.Fatalf("Marshal: %v", err) } reparsed, err := Parse(raw) if err != nil { t.Fatalf("重新 Parse 导出结果: %v", err) } if !reflect.DeepEqual(exported, reparsed) { t.Errorf("导出结果往返不一致:\n%+v\n%+v", exported, reparsed) } } // TestApplyClearsChannelsForUnscoredEvent 验证「只排名不计分」的项目导入后固定 0 通道(口径 2): // 否则它会继承模板通道,把名次带进奖牌榜。 func TestApplyClearsChannelsForUnscoredEvent(t *testing.T) { db := openEventDB(t) seedEvents(t, db) var indID, profileID int64 if err := db.QueryRow(`SELECT id FROM events WHERE code = 'EVT_IND'`).Scan(&indID); err != nil { t.Fatalf("查询个人项目: %v", err) } if err := db.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil { t.Fatalf("查询个人模板: %v", err) } if _, err := db.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) VALUES (?, 'athlete', 'result', ?, 0)`, indID, profileID); err != nil { t.Fatalf("插入个人通道: %v", err) } if _, err := db.Exec(`UPDATE events SET scored = 0 WHERE id = ?`, indID); err != nil { t.Fatalf("关闭计分开关: %v", err) } next, err := Parse([]byte(applyPackYAML)) if err != nil { t.Fatalf("Parse: %v", err) } cur, err := Load(db) if err != nil { t.Fatalf("Load: %v", err) } cs, err := Diff(cur, next) if err != nil { t.Fatalf("Diff: %v", err) } if _, err := Apply(db, cs); err != nil { t.Fatalf("Apply: %v", err) } if got := queryInt(t, db, `SELECT COUNT(*) FROM event_score_channels c JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_IND'`); got != 0 { t.Fatalf("不计分项目的通道数 = %d,期望 0", got) } } func TestApplyRefusesToDeleteReferencedProfile(t *testing.T) { db := openEventDB(t) seedEvents(t, db) // 这份规则包没有 team_top6,而 EVT_TEAM 的通道仍引用它,删除必须被拒绝。 const packYAML = `format: scoreboard-rules formatVersion: 1 meta: { code: demo, name: 精简规则包 } profiles: - code: individual_top6 name: 个人项目前六名 awardRanks: 1 tieMode: skip rules: - { rank: 1, points: 1 } ` next, err := Parse([]byte(packYAML)) if err != nil { t.Fatalf("Parse: %v", err) } cur, err := Load(db) if err != nil { t.Fatalf("Load: %v", err) } cs, err := Diff(cur, next) if err != nil { t.Fatalf("Diff: %v", err) } if _, err := Apply(db, cs); !errors.Is(err, ErrProfileInUse) { t.Fatalf("期望 ErrProfileInUse,实际 %v", err) } if got := countRows(t, db, "scoring_profiles"); got != 2 { t.Errorf("事务应回滚,模板数 = %d,期望 2", got) } } func TestApplyNilChangeSet(t *testing.T) { db := openEventDB(t) if _, err := Apply(db, nil); !errors.Is(err, ErrNilChangeSet) { t.Fatalf("期望 ErrNilChangeSet,实际 %v", err) } } // 「个人+团体」默认模板的落库 / 导出 / 差异比对: // 个人通道 countToTeam=false(只记个人),团体通道缺省 true(进单位总分)。 func TestApplyIndividualTeamTemplateCountToTeam(t *testing.T) { db := openEventDB(t) seedEvents(t, db) if _, err := db.Exec(`INSERT INTO events (sport_id, code, name, event_type, result_type, compare, scored, multiplier) VALUES (1, 'EVT_BOTH', '团体引体向上', 'individual_team', 'count', 'desc', 1, 1)`); err != nil { t.Fatalf("插入双计项目: %v", err) } next, err := Parse([]byte(individualTeamPackYAML)) if err != nil { t.Fatalf("Parse: %v", err) } if _, err := Apply(db, &ChangeSet{Next: next}); err != nil { t.Fatalf("Apply: %v", err) } rows, err := db.Query(`SELECT c.scope, c.basis, c.count_to_team FROM event_score_channels c JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_BOTH' ORDER BY c.sort_order, c.id`) if err != nil { t.Fatalf("查询通道: %v", err) } defer func() { _ = rows.Close() }() type want struct { scope, basis string countToTeam int } wants := []want{{"athlete", "result", 0}, {"team", "aggregate", 1}} i := 0 for rows.Next() { var scope, basis string var countToTeam int if err := rows.Scan(&scope, &basis, &countToTeam); err != nil { t.Fatalf("扫描通道: %v", err) } if i >= len(wants) || scope != wants[i].scope || basis != wants[i].basis || countToTeam != wants[i].countToTeam { t.Fatalf("第 %d 条通道 = %s/%s/%d,期望 %+v", i, scope, basis, countToTeam, wants[i]) } i++ } if i != len(wants) { t.Fatalf("EVT_BOTH 通道数 = %d,期望 %d", i, len(wants)) } // 导出:只有显式关闭的通道写 false,缺省的团体通道不带该字段。 exported, err := Export(db, Meta{Code: "demo", Name: "导出"}) if err != nil { t.Fatalf("Export: %v", err) } var chans []Channel for _, ov := range exported.EventOverrides { if ov.EventCode == "EVT_BOTH" && ov.Channels != nil { chans = *ov.Channels } } if len(chans) != 2 { t.Fatalf("导出通道数 = %d,期望 2(%+v)", len(chans), chans) } if chans[0].CountToTeam == nil || chans[0].CountsToTeam() { t.Errorf("个人通道应导出 countToTeam: false,实际 %+v", chans[0].CountToTeam) } if chans[1].CountToTeam != nil || !chans[1].CountsToTeam() { t.Errorf("团体通道应保持缺省 true,实际 %+v", chans[1].CountToTeam) } // 导出结果重新导入必须无差异:*bool 与缺省值不能凭空造出差异。 reparsed, err := Parse(mustMarshal(t, exported)) if err != nil { t.Fatalf("重新 Parse: %v", err) } cur, err := Load(db) if err != nil { t.Fatalf("Load: %v", err) } cs, err := Diff(cur, reparsed) if err != nil { t.Fatalf("Diff: %v", err) } if !cs.Empty() { t.Fatalf("导出再导入不应有差异: %+v", cs) } } // 确认导入时通常只剩目标规则包(预览的 ChangeSet 不跨请求),这条路要能走通。 func TestApplyAcceptsChangeSetWithOnlyNext(t *testing.T) { db := openEventDB(t) next, err := Parse([]byte(applyPackYAML)) if err != nil { t.Fatalf("Parse: %v", err) } res, err := Apply(db, &ChangeSet{Next: next}) if err != nil { t.Fatalf("Apply: %v", err) } if res.ProfilesWritten != 3 { t.Errorf("模板写入数 = %d,期望 3", res.ProfilesWritten) } if got := countRows(t, db, "scoring_profiles"); got != 3 { t.Errorf("模板数 = %d,期望 3", got) } } func countRows(t *testing.T, db *sql.DB, table string) int { t.Helper() var n int if err := db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&n); err != nil { t.Fatalf("统计 %s: %v", table, err) } return n } func queryInt(t *testing.T, db *sql.DB, query string) int { t.Helper() var n int if err := db.QueryRow(query).Scan(&n); err != nil { t.Fatalf("查询 %q: %v", query, err) } return n } func queryFloat(t *testing.T, db *sql.DB, query string) float64 { t.Helper() var v float64 if err := db.QueryRow(query).Scan(&v); err != nil { t.Fatalf("查询 %q: %v", query, err) } return v } func queryString(t *testing.T, db *sql.DB, query string) string { t.Helper() var v string if err := db.QueryRow(query).Scan(&v); err != nil { if errors.Is(err, sql.ErrNoRows) { return "" } t.Fatalf("查询 %q: %v", query, err) } return v } func mustMarshal(t *testing.T, pack *RulePack) []byte { t.Helper() raw, err := Marshal(pack) if err != nil { t.Fatalf("Marshal: %v", err) } return raw }