feat(scoring): 新增「队内最差(最后一名冲线)」聚合口径;版本 dev_0.5.11
## 需求(主人原话)
> 记分通道团体名次的名次来源缺一个模式,添加一个「按队内最低分」。
> 有一个特殊项目是取团队的最后一人的成绩(最后一名冲线)来排名的。
## 口径(这里最容易写反,所以定死)
新增 `aggregate_mode = 'worst'`。**不叫 `min`/`max`** —— 它的方向是**派生**的,叫 `min` 会让
「越大越好」的那半项目被写错。**跟着项目的比较方向自动取**:
| 项目 `compare` | 含义 | 队内最差 = |
| --- | --- | --- |
| `asc`(越小越好,如时间) | 行军 / 越野 | **max** = 最后一名冲线的那个人 |
| `desc`(越大越好,如次数) | 引体向上 | **min** = 做得最少的那个 |
方向判据**各只有一处**:Go 的 `domain.worseValue`、前端预览的 `worseThan`,
两边都有 asc/desc 双向用例钉着。规则包里写 `min`/`max` 会被校验**拦下并报错**。
## 改动
- **迁移 `0027`**:SQLite 改不了已有的 `CHECK`,按官方做法**重建 `event_score_channels`**,
取值放开到 `avg/sum/worst`;⚠️ 重建时**先把子表 `channel_ranks` 寄存到临时表再原样灌回**
(父表被 DROP 会级联清空名次 —— 这一步漏了就会把已发布的名次全丢)。
- **引擎**:`scoreByAggregate` 支持第三种口径。
- **接口 / 规则包**:读、写、校验、快照、导出五处都认第三个取值。
- **界面**:「聚合口径」由**两个按钮变三个**(平均 / 总和 / **队内最低分**),
提示文案写明两种方向,不丢一个 `worst` 让人猜。
- **前端预览同步**(`web/src/admin/api/scoring.ts`)—— 前一版刚修完「预览与后端算分不一致」,
这次不允许再开一个新口子。
- 文档:`docs/使用手册.md`、`docs/升级说明.md`、`devdocs/18` 各补一处。
## 两条边界(都是刻意的)
- **弃权 / 失格不算参与,且它的成绩不代表队伍**:队里 5 / 6 / 弃赛(0) → 队内最差是 **5**,不是 0。
否则谁都可以挂一个弃赛的人在队里垫底。
- **一个有效成绩都没有的主体一律不参与排名**(`avg_mode` 在这一口径下不起作用)。
若按 0 参与:`asc` 里 0 是全场最快、`desc` 里 0 是全场最差 —— 两边都会让没完赛的队白拿名次。
## 验证
- **老口径逐字节不变**:新增 golden `TestAggregateModesLegacyUnchanged`(`avg` / `sum` 结果与改动前一致)。
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **953 例** 0 fail / Console 96 例 0 fail。
- 迁移用例覆盖:老行原样、子表名次一行不少、id 不漂、非法值仍拒、幂等、级联删除仍在。
## 版本
`dev_0.5.10` → **`dev_0.5.11`**(含数据迁移 `0027`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
This commit is contained in:
1 parent
a85115c96e
commit
48208f395b
30 files changed
+1394
-85
No files matched your search
@@ -0,0 +1,167 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件是「聚合通道两个既有口径(avg / sum)逐字节不变」的硬门槛回归(本需求:新增 worst 口径)。
|
||||
//
|
||||
// 为什么要有它:worst 的取值分支就长在 scoreByAggregate 里,而那一段同时算了 avg 与 sum
|
||||
// (同一个桶、同一段 `switch`)。改错一处(例如把 sum 的累加挪进新分支、或把「没成绩不参与排名」
|
||||
// 的判断顺手改成对所有口径生效),现场看到的不是崩溃,而是**榜单悄悄变样**——所以钉住签名。
|
||||
//
|
||||
// 签名文本只写「谁拿到什么值、什么名次、多少分」这些事实,不写结构体形状:
|
||||
// 汇总值按 %v 打印、流水只打事实列,将来给 RankedResult 加字段不会把这份签名带跑偏。
|
||||
//
|
||||
// 签名取自 **worst 实现之前**的代码,实现之后必须一字不差(同一棵树、同一组输入)。
|
||||
const aggregateModesGoldenSHA256 = "572e64a7b03ea79205ca4150fb5312ddab4e4202ec7151d02a5acf0351cf482e"
|
||||
|
||||
// TestAggregateModesLegacyUnchanged 跑遍 avg / sum 两个老口径 × avg_mode(valid/all) × 几种现场形态,
|
||||
// 把结果拼成一份稳定文本并比对签名。
|
||||
func TestAggregateModesLegacyUnchanged(t *testing.T) {
|
||||
dump := aggregateModesDump(t)
|
||||
|
||||
// 防空转:每种形态都必须真的留下痕迹,否则签名只是一份空文本。
|
||||
for _, marker := range []string{
|
||||
"case=roster-differs mode=unset avg=valid",
|
||||
"case=roster-differs mode=avg avg=valid",
|
||||
"case=roster-differs mode=sum avg=valid",
|
||||
"case=roster-differs mode=avg avg=all",
|
||||
"case=roster-differs mode=sum avg=all",
|
||||
"case=missing-value ",
|
||||
"case=non-registered ",
|
||||
"case=tie ",
|
||||
"ledger ",
|
||||
"ranked ",
|
||||
} {
|
||||
if !strings.Contains(dump, marker) {
|
||||
t.Fatalf("签名里缺少形态 %q,对拍没有覆盖到它:\n%s", marker, dump)
|
||||
}
|
||||
}
|
||||
// worst 是本次新增的口径,绝不能出现在这份「老口径」签名里(出现了说明老口径串了口径)。
|
||||
if strings.Contains(dump, string(AggregateWorst)) {
|
||||
t.Fatalf("老口径的签名里出现了 worst:\n%s", dump)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(dump))
|
||||
got := hex.EncodeToString(sum[:])
|
||||
if got != aggregateModesGoldenSHA256 {
|
||||
t.Fatalf("聚合通道的老口径(avg / sum)变了:\n签名 = %s\n期望 = %s\n\n%s",
|
||||
got, aggregateModesGoldenSHA256, dump)
|
||||
}
|
||||
}
|
||||
|
||||
// aggregateModesDump 把「几个现场形态 × 两个老口径 × 两种 avg_mode」的引擎产出拼成一份稳定文本。
|
||||
//
|
||||
// 稳定性来源:流水与名次按切片顺序打印(引擎的输出顺序本身是确定的),
|
||||
// 同一组输入每次跑出来的文本完全一样(没有 map 遍历、没有并发)。
|
||||
func aggregateModesDump(t *testing.T) string {
|
||||
t.Helper()
|
||||
var lines []string
|
||||
|
||||
appendScore := func(name string, es EventScore) {
|
||||
for _, rr := range es.Ranked {
|
||||
value := "nil"
|
||||
if rr.ResultValue != nil {
|
||||
value = fmt.Sprintf("%v", *rr.ResultValue)
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"%s ranked key=%s scope=%s basis=%s team=%d athlete=%d rank=%d points=%v value=%s shares=%s",
|
||||
name, rr.SubjectKey, rr.Scope, rr.Basis, rr.TeamID, rr.AthleteID, rr.Rank, rr.Points, value, formatShares(rr.Shares)))
|
||||
}
|
||||
for _, l := range es.Ledger {
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"%s ledger team=%d athlete=%d entry=%d result=%d source=%s/%d points=%v countToTeam=%v voided=%v",
|
||||
name, l.TeamID, l.AthleteID, l.EntryID, l.ResultID, l.SourceType, l.SourceID,
|
||||
l.Points, l.CountToTeam, l.Voided))
|
||||
}
|
||||
}
|
||||
|
||||
// 三种现场形态,各自钉住 scoreByAggregate 里的一段:
|
||||
// roster-differs:各队人数不同 —— avg 与 sum 的名次会分叉(现场分辨口径就看这一组);
|
||||
// missing-value :有人没成绩 —— avg 的 valid/all 分母不同,sum 只累加有效成绩;
|
||||
// non-registered:弃权 / 失格 —— 既不算参与也不进分母;
|
||||
// tie :汇总值相同 —— 走并列与顺延。
|
||||
cases := []struct {
|
||||
name string
|
||||
compare CompareMode
|
||||
build func() ([]Entry, []Result)
|
||||
}{
|
||||
{
|
||||
name: "roster-differs",
|
||||
compare: CompareDesc,
|
||||
build: func() ([]Entry, []Result) {
|
||||
return fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 1},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 9},
|
||||
{entryID: 4, athleteID: 4, teamID: 1003, value: 7},
|
||||
{entryID: 5, athleteID: 5, teamID: 1003, value: 8},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing-value",
|
||||
compare: CompareAsc,
|
||||
build: func() ([]Entry, []Result) {
|
||||
return fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, noValue: true},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
|
||||
{entryID: 4, athleteID: 4, teamID: 1002, value: 6},
|
||||
{entryID: 5, athleteID: 5, teamID: 1003, noValue: true},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-registered",
|
||||
compare: CompareAsc,
|
||||
build: func() ([]Entry, []Result) {
|
||||
return fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 12, entryStatus: EntryStatusScratched},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 11, status: ResultStatusDQ},
|
||||
{entryID: 4, athleteID: 4, teamID: 1002, value: 30},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tie",
|
||||
compare: CompareAsc,
|
||||
build: func() ([]Entry, []Result) {
|
||||
return fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 5},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 5},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 5},
|
||||
{entryID: 4, athleteID: 4, teamID: 1003, value: 9},
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
for _, mode := range []struct {
|
||||
name string
|
||||
mode AggregateMode
|
||||
}{
|
||||
// 缺省(老库老客户端保存的通道根本不带这一列)与显式 avg 必须给出同一份结果。
|
||||
{"unset", ""},
|
||||
{"avg", AggregateAvg},
|
||||
{"sum", AggregateSum},
|
||||
} {
|
||||
for _, avgMode := range []AvgMode{AvgModeValid, AvgModeAll} {
|
||||
entries, results := tc.build()
|
||||
ev := teamEvent(2) // 权重 2:证明汇总值还要乘项目权重
|
||||
ev.Compare = tc.compare
|
||||
ev.AvgMode = avgMode
|
||||
name := fmt.Sprintf("case=%s mode=%s avg=%s", tc.name, mode.name, avgMode)
|
||||
appendScore(name, aggScore(t, ev, mode.mode, entries, results))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 聚合口径的第三个取值 worst(按队内最差排名)。
|
||||
//
|
||||
// 现场:有的项目按「团队最后一人的成绩」排名——越野 / 行军这类**最后一名冲线**的项目,
|
||||
// 以及引体向上这类「按做得最少的那个算」的项目。同一个「队内最差」,方向变了算法就反过来:
|
||||
//
|
||||
// compare=asc (越小越好,如时间):队内最差 = 最大 → 最后一名冲线的那个人;
|
||||
// compare=desc(越大越好,如次数):队内最差 = 最小 → 做得最少的那个。
|
||||
//
|
||||
// 这一条是本需求最容易写反的地方,下面每组用例都同时用 asc 与 desc 跑一遍:
|
||||
// 哪一边写成固定的 min / max,另一边立刻红。
|
||||
|
||||
// worstScore 用 worst 口径跑一次团体聚合通道。
|
||||
func worstScore(t *testing.T, ev Event, entries []Entry, results []Result) EventScore {
|
||||
t.Helper()
|
||||
return aggScore(t, ev, AggregateWorst, entries, results)
|
||||
}
|
||||
|
||||
// TestAggregateWorstTakesSlowestWhenAsc:compare=asc(时间,越小越好)时取**最大**的那份成绩
|
||||
// —— 队里最后一名冲线的人是全队的成绩。
|
||||
func TestAggregateWorstTakesSlowestWhenAsc(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
// 1001:12 / 15 / 20 分钟,最后一名的 20 分钟代表全队。
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 12},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 15},
|
||||
{entryID: 3, athleteID: 3, teamID: 1001, value: 20},
|
||||
// 1002:13 / 14 分钟,最慢的 14 分钟代表全队。
|
||||
{entryID: 4, athleteID: 4, teamID: 1002, value: 13},
|
||||
{entryID: 5, athleteID: 5, teamID: 1002, value: 14},
|
||||
// 1003:单人 30 分钟。
|
||||
{entryID: 6, athleteID: 6, teamID: 1003, value: 30},
|
||||
})
|
||||
ev := teamEvent(1)
|
||||
ev.Compare = CompareAsc
|
||||
|
||||
got := aggregateByTeam(t, worstScore(t, ev, entries, results))
|
||||
// 1002(14)快于 1001(20)快于 1003(30)。
|
||||
if !nearlyEqual(got[1002].value, 14) || got[1002].rank != 1 || !nearlyEqual(got[1002].points, 15) {
|
||||
t.Errorf("1002 应取队内最慢的 14 分钟、第 1 名 15 分,实际 %+v", got[1002])
|
||||
}
|
||||
if !nearlyEqual(got[1001].value, 20) || got[1001].rank != 2 || !nearlyEqual(got[1001].points, 10) {
|
||||
t.Errorf("1001 应取队内最慢的 20 分钟、第 2 名 10 分,实际 %+v", got[1001])
|
||||
}
|
||||
if !nearlyEqual(got[1003].value, 30) || got[1003].rank != 3 || !nearlyEqual(got[1003].points, 8) {
|
||||
t.Errorf("1003 应取 30 分钟、第 3 名 8 分,实际 %+v", got[1003])
|
||||
}
|
||||
|
||||
// 与 avg 不同口径不同名次,证明这里真的取了「最差」而不是「平均」:
|
||||
// 平均下来 1001 = 15.67、1002 = 13.5、1003 = 30 —— 前三名不变,所以换一组数据再看(见下一个用例)。
|
||||
// 这一组只钉住「取的是哪个值」。
|
||||
avg := aggregateByTeam(t, aggScore(t, ev, AggregateAvg, entries, results))
|
||||
if nearlyEqual(avg[1001].value, got[1001].value) {
|
||||
t.Fatalf("worst 与 avg 取到同一个值(%v),说明没取队内最差", avg[1001].value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregateWorstTakesFewestWhenDesc:compare=desc(次数,越大越好)时取**最小**的那份成绩
|
||||
// —— 队里做得最少的那个是全队的成绩。这一组同时钉住「与 avg 的名次真的会互换」。
|
||||
func TestAggregateWorstTakesFewestWhenDesc(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
// 1001:20 个 + 1 个 —— 队内最差是 1 个(平均 10.5)。
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 20},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 1},
|
||||
// 1002:5 个 + 6 个 —— 队内最差是 5 个(平均 5.5)。
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 5},
|
||||
{entryID: 4, athleteID: 4, teamID: 1002, value: 6},
|
||||
})
|
||||
ev := teamEvent(1)
|
||||
ev.Compare = CompareDesc
|
||||
|
||||
got := aggregateByTeam(t, worstScore(t, ev, entries, results))
|
||||
// 最差成绩:1002 的 5 个 > 1001 的 1 个 → 1002 第一。
|
||||
if !nearlyEqual(got[1002].value, 5) || got[1002].rank != 1 || !nearlyEqual(got[1002].points, 15) {
|
||||
t.Errorf("1002 应取队内最少的 5 个、第 1 名 15 分,实际 %+v", got[1002])
|
||||
}
|
||||
if !nearlyEqual(got[1001].value, 1) || got[1001].rank != 2 || !nearlyEqual(got[1001].points, 10) {
|
||||
t.Errorf("1001 应取队内最少的 1 个、第 2 名 10 分,实际 %+v", got[1001])
|
||||
}
|
||||
|
||||
// 平均口径下名次恰好相反(1001 平均 10.5 > 1002 平均 5.5)——两种口径不能互相顶替。
|
||||
avg := aggregateByTeam(t, aggScore(t, ev, AggregateAvg, entries, results))
|
||||
if avg[1001].rank != 1 || avg[1002].rank != 2 {
|
||||
t.Fatalf("avg 口径下应 1001 第一,实际 %+v", avg)
|
||||
}
|
||||
if got[1001].rank == avg[1001].rank {
|
||||
t.Fatalf("worst 与 avg 的名次应互换:worst=%+v avg=%+v", got, avg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregateWorstTie:两支队伍的「最差成绩」相同即并列,并列口径沿用积分模板的 tieMode(默认顺延)。
|
||||
func TestAggregateWorstTie(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
// 1001:10 / 20 —— 最慢 20。
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 20},
|
||||
// 1002:单人 20 —— 同样是最慢 20,并列。
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 20},
|
||||
// 1003:25。
|
||||
{entryID: 4, athleteID: 4, teamID: 1003, value: 25},
|
||||
})
|
||||
ev := teamEvent(1)
|
||||
ev.Compare = CompareAsc
|
||||
|
||||
got := aggregateByTeam(t, worstScore(t, ev, entries, results))
|
||||
if got[1001].rank != 1 || got[1002].rank != 1 {
|
||||
t.Errorf("最差成绩相同的两队应并列第 1,实际 1001=%+v 1002=%+v", got[1001], got[1002])
|
||||
}
|
||||
if !nearlyEqual(got[1001].points, 15) || !nearlyEqual(got[1002].points, 15) {
|
||||
t.Errorf("并列第 1 的两队各得 15 分,实际 %v / %v", got[1001].points, got[1002].points)
|
||||
}
|
||||
// 并列顺延:第三名那队拿第 3 名那份分(8),不是第 2 名(10)。
|
||||
if got[1003].rank != 3 || !nearlyEqual(got[1003].points, 8) {
|
||||
t.Errorf("并列之后应顺延到第 3 名 8 分,实际 %+v", got[1003])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregateWorstSkipsSubjectWithoutAnyValidResult:一个有效成绩都没有的主体不参与排名、不得分,
|
||||
// 与 avg 的 valid 口径同一条规则 —— 没人冲线就没有「最后一名冲线」可言。
|
||||
//
|
||||
// 关键是**不能**把「没成绩」当成 0 去比:asc 项目里 0 是最快的,会让一支没人完赛的队伍白拿第一;
|
||||
// desc 项目里 0 又是最差的,同样荒谬。avg_mode=all 也照样不参与(worst 没有分母可用)。
|
||||
func TestAggregateWorstSkipsSubjectWithoutAnyValidResult(t *testing.T) {
|
||||
build := func() ([]Entry, []Result) {
|
||||
return fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 12},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 20},
|
||||
// 1002 全队一条有效成绩都没有(都还没录)。
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, noValue: true},
|
||||
{entryID: 4, athleteID: 4, teamID: 1002, noValue: true},
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
compare CompareMode
|
||||
avgMode AvgMode
|
||||
}{
|
||||
{"asc + valid", CompareAsc, AvgModeValid},
|
||||
{"asc + all", CompareAsc, AvgModeAll},
|
||||
{"desc + valid", CompareDesc, AvgModeValid},
|
||||
{"desc + all", CompareDesc, AvgModeAll},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entries, results := build()
|
||||
ev := teamEvent(1)
|
||||
ev.Compare = tc.compare
|
||||
ev.AvgMode = tc.avgMode
|
||||
|
||||
got := aggregateByTeam(t, worstScore(t, ev, entries, results))
|
||||
if got[1002].rank != 0 || got[1002].points != 0 {
|
||||
t.Fatalf("全队没有有效成绩的 1002 不应参与排名,实际 %+v", got[1002])
|
||||
}
|
||||
// 1001 独自参与,拿第 1 名的分(不是被「0 分的空队」挤到第 2 名)。
|
||||
if got[1001].rank != 1 || !nearlyEqual(got[1001].points, 15) {
|
||||
t.Fatalf("1001 应第 1 名 15 分,实际 %+v", got[1001])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregateWorstIgnoresNonRegisteredEntries:弃权 / 失格(status 非 registered)的报名项
|
||||
// 既不算参与、也不算成绩 —— 它的成绩再差也不能代表队伍。
|
||||
//
|
||||
// 现场的含义:一支队伍有人弃赛,队伍的成绩由**实际出赛的人**里最差的那个决定,
|
||||
// 而不是由「没来的人」决定(否则弃赛反而是最差成绩,谁都愿意报一个弃赛的人垫底)。
|
||||
func TestAggregateWorstIgnoresNonRegisteredEntries(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
// 1001:一人 30 分钟正常完赛,另一人 50 分钟但弃赛 —— 队伍成绩取 30。
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 30},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 50, entryStatus: EntryStatusScratched},
|
||||
// 1002:一人 40 分钟完赛,另一人失格 —— 队伍成绩取 40。
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 40},
|
||||
{entryID: 4, athleteID: 4, teamID: 1002, value: 99, entryStatus: EntryStatusDQ},
|
||||
})
|
||||
ev := teamEvent(1)
|
||||
ev.Compare = CompareAsc
|
||||
|
||||
got := aggregateByTeam(t, worstScore(t, ev, entries, results))
|
||||
if !nearlyEqual(got[1001].value, 30) || got[1001].rank != 1 {
|
||||
t.Errorf("1001 应取实际出赛里最慢的 30 分钟、第 1 名,实际 %+v", got[1001])
|
||||
}
|
||||
if !nearlyEqual(got[1002].value, 40) || got[1002].rank != 2 {
|
||||
t.Errorf("1002 应取实际出赛的 40 分钟、第 2 名,实际 %+v", got[1002])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregateWorstAgainstAvgAndSum:同一组数据下三种口径各给一个值,互不相等 ——
|
||||
// 现场切换口径时看得见差别(也说明 worst 不是 avg / sum 的另一种写法)。
|
||||
func TestAggregateWorstAgainstAvgAndSum(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 30},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 20},
|
||||
{entryID: 4, athleteID: 4, teamID: 1002, value: 21},
|
||||
})
|
||||
ev := teamEvent(1)
|
||||
ev.Compare = CompareAsc
|
||||
|
||||
worst := aggregateByTeam(t, worstScore(t, ev, entries, results))
|
||||
avg := aggregateByTeam(t, aggScore(t, ev, AggregateAvg, entries, results))
|
||||
sum := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results))
|
||||
|
||||
// worst:1002(21)快于 1001(30);
|
||||
// avg:1002(20.5)快于 1001(20)—— 等等,平均下 1001 是 20、1002 是 20.5,所以 avg 里 1001 第一;
|
||||
// sum:1001 40 > 1002 41,所以 sum 里 1002 第一。
|
||||
if worst[1002].rank != 1 || worst[1001].rank != 2 {
|
||||
t.Errorf("worst 下应 1002 第 1,实际 %+v", worst)
|
||||
}
|
||||
if avg[1001].rank != 1 || avg[1002].rank != 2 {
|
||||
t.Errorf("avg 下应 1001 第 1(20 < 20.5),实际 %+v", avg)
|
||||
}
|
||||
if worst[1001].value == avg[1001].value || worst[1001].value == sum[1001].value {
|
||||
t.Errorf("三种口径的汇总值不该相同:worst=%v avg=%v sum=%v",
|
||||
worst[1001].value, avg[1001].value, sum[1001].value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregateModeWorstNormalization:worst 是合法取值,认不出的取值仍退回 avg。
|
||||
func TestAggregateModeWorstNormalization(t *testing.T) {
|
||||
if got := (ScoreChannel{AggregateMode: AggregateWorst}).aggregateMode(); got != AggregateWorst {
|
||||
t.Fatalf("worst 应原样保留,实际 %q", got)
|
||||
}
|
||||
if got := (ScoreChannel{}).aggregateMode(); got != AggregateAvg {
|
||||
t.Fatalf("零值通道的汇总口径 = %q,期望 avg", got)
|
||||
}
|
||||
for _, bogus := range []AggregateMode{"", "worse", "WORST", "min", "max", "biggest"} {
|
||||
if got := (ScoreChannel{AggregateMode: bogus}).aggregateMode(); got != AggregateAvg {
|
||||
t.Errorf("取值 %q 应退回 avg,实际 %q", bogus, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregateWorstLedgerAndRankedShape:worst 口径产出的流水与名次明细形状与既有口径一致
|
||||
// (EntryID=0 的聚合名次、TeamID 是主体、Shares 独占),免得读侧(榜单 / 导出)按别的形状解析。
|
||||
func TestAggregateWorstLedgerAndRankedShape(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 12},
|
||||
{entryID: 2, athleteID: 2, teamID: 1002, value: 20},
|
||||
})
|
||||
ev := teamEvent(1)
|
||||
ev.Compare = CompareAsc
|
||||
es := worstScore(t, ev, entries, results)
|
||||
|
||||
if len(es.Ranked) != 2 {
|
||||
t.Fatalf("应产出 2 条名次明细,实际 %+v", es.Ranked)
|
||||
}
|
||||
for _, rr := range es.Ranked {
|
||||
if rr.EntryID != 0 || rr.ResultID != 0 {
|
||||
t.Errorf("聚合名次不应挂报名项 / 成绩:%+v", rr)
|
||||
}
|
||||
if rr.Scope != ScopeTeam || rr.Basis != BasisAggregate {
|
||||
t.Errorf("名次明细的 scope/basis 应照通道来:%+v", rr)
|
||||
}
|
||||
if !reflect.DeepEqual(rr.Shares, wholeShare(rr.TeamID)) {
|
||||
t.Errorf("聚合名次按主体单位独占份额,实际 %+v", rr.Shares)
|
||||
}
|
||||
}
|
||||
got := pointsByTeam(es.Ledger)
|
||||
if !nearlyEqual(got[1001], 15) || !nearlyEqual(got[1002], 10) {
|
||||
t.Fatalf("单位流水 = %v,期望 1001=15 1002=10", got)
|
||||
}
|
||||
}
|
||||
@@ -591,7 +591,10 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
|
||||
//
|
||||
// 汇总口径(通道的 aggregate_mode,缺省 avg):
|
||||
// - avg:团体成绩 = 成员成绩的平均值;
|
||||
// - sum:团体成绩 = 成员成绩的总和。
|
||||
// - sum:团体成绩 = 成员成绩的总和;
|
||||
// - worst:团体成绩 = 成员成绩里**最差的那一份**。方向随项目的 compare 自动取,见 worseValue:
|
||||
// compare=asc(越小越好,如时间)→ 取最大,即**最后一名冲线**的那个人;
|
||||
// compare=desc(越大越好,如次数)→ 取最小,即**做得最少**的那个。
|
||||
//
|
||||
// avg 的平均值分母由项目的 avg_mode 决定:
|
||||
// - valid:分子 = 有效成绩之和,分母 = 有有效成绩的人数;一个人都没成绩就不参与排名、不得分;
|
||||
@@ -601,7 +604,14 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
|
||||
// sum 只累加有效成绩(没成绩的人按 0 计入和不计入是一样的和),所以 avg_mode 在这里只剩一个作用:
|
||||
// 决定「一个有效成绩都没有的单位算不算 0 分参与排名」——valid 不参与、all 参与,与 avg 完全一致。
|
||||
//
|
||||
// 弃权 / 失格(status 非 registered)的报名项既不算参与也不算进分母,与「不占名次」的既有口径一致。
|
||||
// worst 没有分母,「没成绩按 0 计入」对它也就无从谈起,所以 avg_mode 在这个口径下不起任何作用:
|
||||
// 一个有效成绩都没有的主体一律不参与排名、不得分(与 avg 的 valid 口径同一条规则)。
|
||||
// **绝不能**改成「按 0 参与」:0 在 asc 项目里是全场最快、在 desc 项目里是全场最差,
|
||||
// 无论哪一边,都会让一支没人完赛(或全队都还没录成绩)的队伍白拿名次与分。
|
||||
//
|
||||
// 弃权 / 失格(status 非 registered)的报名项既不算参与也不算进分母,与「不占名次」的既有口径一致;
|
||||
// 在 worst 下这条尤其要紧:队里有人弃赛时,队伍成绩由**实际出赛的人**里最差的那个决定,
|
||||
// 而不是由「没来的人」决定(否则谁都想在队里挂一个弃赛的人来垫底)。
|
||||
func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
|
||||
if in.Event.Compare == CompareNone {
|
||||
return nil, ErrCompareRequired
|
||||
@@ -613,11 +623,13 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
subject int64
|
||||
entry Entry
|
||||
sum float64 // 有效成绩之和
|
||||
valid int // 有有效成绩的人数(valid 口径的分母)
|
||||
roster int // 报名人数(all 口径的分母)
|
||||
subject int64
|
||||
entry Entry
|
||||
sum float64 // 有效成绩之和
|
||||
valid int // 有有效成绩的人数(valid 口径的分母)
|
||||
roster int // 报名人数(all 口径的分母)
|
||||
worst float64 // 队内最差的那一份有效成绩(worst 口径)
|
||||
hasWorst bool // 是否已经取到过一份有效成绩
|
||||
}
|
||||
order := make([]int64, 0)
|
||||
buckets := make(map[int64]*bucket)
|
||||
@@ -643,20 +655,32 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
|
||||
if v, ok := validResultValue(r, found, in.Options); ok {
|
||||
b.sum += v
|
||||
b.valid++
|
||||
if !b.hasWorst || worseValue(v, b.worst, in.Event.Compare) {
|
||||
b.worst = v
|
||||
b.hasWorst = true
|
||||
}
|
||||
}
|
||||
}
|
||||
sumMode := ch.aggregateMode() == AggregateSum
|
||||
mode := ch.aggregateMode()
|
||||
allMode := in.Event.avgMode() == AvgModeAll
|
||||
agg := make([]candidate, 0, len(order))
|
||||
for _, id := range order {
|
||||
b := buckets[id]
|
||||
if b.valid == 0 && !allMode {
|
||||
// 一个人都没有效成绩的单位不参与团体排名,也不得分(两种汇总口径都一样)。
|
||||
switch {
|
||||
case mode == AggregateWorst:
|
||||
// 队内最差:一个有效成绩都没有的主体不参与排名(worst 没有分母,avg_mode 在这里不适用)。
|
||||
if !b.hasWorst {
|
||||
continue
|
||||
}
|
||||
case b.valid == 0 && !allMode:
|
||||
// 一个人都没有效成绩的单位不参与团体排名,也不得分(avg 与 sum 都一样)。
|
||||
continue
|
||||
}
|
||||
var value float64
|
||||
switch {
|
||||
case sumMode:
|
||||
case mode == AggregateWorst:
|
||||
value = b.worst
|
||||
case mode == AggregateSum:
|
||||
// 求和口径:只累加有效成绩(没成绩按 0 计入与否不改变和)。
|
||||
value = b.sum
|
||||
case allMode:
|
||||
@@ -734,6 +758,21 @@ func nearlyEqual(a, b float64) bool {
|
||||
return math.Abs(a-b) <= rankEpsilon
|
||||
}
|
||||
|
||||
// worseValue 判断 a 是不是比 b 更「差」——**方向由项目的 compare 决定,这是本口径最容易写反的一处**:
|
||||
//
|
||||
// compare=asc (越小越好,如时间):更差 = 更大 → 队内最差是**最后一名冲线**的那个人;
|
||||
// compare=desc(越大越好,如次数):更差 = 更小 → 队内最差是**做得最少**的那个人。
|
||||
//
|
||||
// 写死成 min 或 max 都会让一半的项目算反:越野这类时间项目会把「跑得最快的人」当成全队成绩、
|
||||
// 引体向上这类次数项目会把「做得最多的人」当成全队成绩。两种错法在代码上都读得通
|
||||
// (`v < worst` 看着很正常),所以用 asc / desc 双向用例钉住,别凭直觉改。
|
||||
func worseValue(a, b float64, compare CompareMode) bool {
|
||||
if compare == CompareDesc {
|
||||
return a < b
|
||||
}
|
||||
return a > b
|
||||
}
|
||||
|
||||
// validResultValue 判断一条成绩是否算「有效成绩」:成绩存在、已发布(或允许草稿)、
|
||||
// 状态正常,且有可比较的数值。聚合通道按 avg_mode 决定没成绩的人算不算进分母。
|
||||
func validResultValue(r Result, found bool, opts ScoreOptions) (float64, bool) {
|
||||
|
||||
@@ -44,6 +44,12 @@ const (
|
||||
AggregateAvg AggregateMode = "avg"
|
||||
// AggregateSum 按主体成员成绩的总和排名。
|
||||
AggregateSum AggregateMode = "sum"
|
||||
// AggregateWorst 按主体成员成绩里**最差的那一份**排名(队内最差 / 最后一名冲线)。
|
||||
//
|
||||
// 刻意不叫 min / max:它到底是取最小还是最大**取决于项目的比较方向**
|
||||
// (asc 越小越好 → 取最大,即最后一名冲线;desc 越大越好 → 取最小,即做得最少的那个),
|
||||
// 叫 min 或 max 都会让后来人把其中一半的项目写反,见 scoreByAggregate 的说明。
|
||||
AggregateWorst AggregateMode = "worst"
|
||||
)
|
||||
|
||||
// ResultType 成绩的物理量类型,决定比较与展示方式。
|
||||
@@ -241,7 +247,7 @@ type EventCategory struct {
|
||||
// ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。
|
||||
// 一个项目可配 0..N 个通道;0 个表示只排名不计分。
|
||||
// basis=aggregate 把同一主体的成绩汇总成一个值再排名:汇总口径由本通道的 aggregate_mode 决定
|
||||
// (avg 平均 / sum 求和),平均值内部的分母口径由项目的 avg_mode 决定。
|
||||
// (avg 平均 / sum 求和 / worst 队内最差),平均值内部的分母口径由项目的 avg_mode 决定。
|
||||
type ScoreChannel struct {
|
||||
ID int64 `json:"id"`
|
||||
EventID int64 `json:"eventId"`
|
||||
@@ -519,9 +525,28 @@ func (c ScoreChannel) aggregateMode() AggregateMode {
|
||||
if c.AggregateMode == AggregateSum {
|
||||
return AggregateSum
|
||||
}
|
||||
if c.AggregateMode == AggregateWorst {
|
||||
return AggregateWorst
|
||||
}
|
||||
return AggregateAvg
|
||||
}
|
||||
|
||||
// AggregateModeOf 把库里 / 请求体里的汇总口径字符串还原成引擎口径;认不出的取值退回缺省。
|
||||
//
|
||||
// 缺省(avg)刻意返回**零值**而不是 "avg":这一列在通道响应体与导出的规则包里都按 omitempty 下发,
|
||||
// 填上 "avg" 会让老数据的响应体与导出文件凭空多出一个字段(老库 / 老客户端的对拍当场对不上)。
|
||||
// 三个读路径(REST 列表、计分读通道、规则包导出)都走它,免得各写一份、漏掉新取值。
|
||||
func AggregateModeOf(stored string) AggregateMode {
|
||||
switch AggregateMode(stored) {
|
||||
case AggregateSum:
|
||||
return AggregateSum
|
||||
case AggregateWorst:
|
||||
return AggregateWorst
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// CountsToMedals 未配置时按 true 处理:只有显式关闭的通道才不发奖牌。
|
||||
func (c ScoreChannel) CountsToMedals() bool {
|
||||
return c.CountToMedals == nil || *c.CountToMedals
|
||||
|
||||
Reference in new issue
Block a user