口径(用户确认): - 个人分按个人成绩排名给,**不计入单位总分**(下沉到通道级开关,不再动全局系数) - 团体成绩 = 每队成员成绩的**平均值** → 排名(并列顺延)→ 团体模板给分 → 计入单位总分 - 平均值口径是**小项级单选项**:默认「只算有效成绩」;「全部报名人员计入」= 分母为报名条数、 没成绩按 0 加入分子;在**成绩录入页**就地切换并立即保存 服务端: - 迁移 0017:score_ledger 加 count_to_team(重建,无子表);event_score_channels 加 count_to_team; events 加 avg_mode(默认 valid) - 规则包 Channel 加 countToTeam(缺省 true);channelTemplates 支持 individual_team → 默认 athlete(result, 不计入单位) + team(aggregate 队内平均) - 计分引擎:SumByTeam 跳过 count_to_team=0 的流水;实现 basis=aggregate 的队内平均团体排名 (valid 分母=有有效成绩人数;all 分母=报名人数;某队 0 有效成绩不参与) 前端: - 计分通道页:每行通道「计入单位总分」勾选 + basis 下拉「按队内平均」 - 成绩录入页:仅有队内平均通道的小项显示「平均值口径」单选(默认只算有效成绩),改动即存 - 录入页「实时积分预览」按新口径对齐(原先按主体求和且双通道重复计入,与发布后不一致) - 大屏/管理端团体成绩只显示数值,不出现「平均」字样 自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 134/134;console 16 文件通过。
517 lines
15 KiB
Go
517 lines
15 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)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|