Files
AskaEth 6bd7bd1627 feat: 「个人+团体」项目真正落地——通道级不计入单位 + 队内平均排名
口径(用户确认):
- 个人分按个人成绩排名给,**不计入单位总分**(下沉到通道级开关,不再动全局系数)
- 团体成绩 = 每队成员成绩的**平均值** → 排名(并列顺延)→ 团体模板给分 → 计入单位总分
- 平均值口径是**小项级单选项**:默认「只算有效成绩」;「全部报名人员计入」= 分母为报名条数、
  没成绩按 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 文件通过。
2026-09-10 12:02:00 +08:00

517 lines
15 KiB
Go
Raw Permalink 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 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
}