- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
382 lines
11 KiB
Go
382 lines
11 KiB
Go
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)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 确认导入时通常只剩目标规则包(预览的 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
|
||
}
|