package domain import ( "reflect" "testing" ) // 聚合口径(通道的 aggregate_mode:avg 按成员平均 / sum 按成员总和)的引擎口径。 // // 现场要在「平均」与「总和」之间临时定口径(团体引体向上这类双计项目领导还没拍板), // 所以这里钉住三件事:缺省必须是 avg(升级前后榜单一分不差)、人数相同时两种口径排名一致、 // 人数不同时两者可能不同(现场不能默认它们等价)。 // aggScore 用指定汇总口径跑一次团体聚合通道,返回「单位 → 平均/总和值、名次、积分」。 func aggScore(t *testing.T, ev Event, mode AggregateMode, entries []Entry, results []Result) EventScore { t.Helper() ch := teamAggregateChannel(2) ch.AggregateMode = mode return mustScore(t, EventScoreInput{ Event: ev, Category: testCategory(), Channels: []ScoreChannel{ch}, Profiles: map[int64]ScoringProfile{2: teamTop6()}, Entries: entries, Results: results, }) } // TestAggregateModeEqualRosterSameRanking:各队人数相同时 sum 与 avg 的排名与积分完全一致。 // 这是现场选口径的依据——人数整齐时选哪种都不影响榜单,人数不齐时才会分叉。 func TestAggregateModeEqualRosterSameRanking(t *testing.T) { // 三队各 2 人,compare=desc(越大越好)。总和 = 平均 × 2,是同一个单调变换。 entries, results := fixture([]participant{ {entryID: 1, athleteID: 1, teamID: 1001, value: 18}, {entryID: 2, athleteID: 2, teamID: 1001, value: 20}, {entryID: 3, athleteID: 3, teamID: 1002, value: 16}, {entryID: 4, athleteID: 4, teamID: 1002, value: 14}, {entryID: 5, athleteID: 5, teamID: 1003, value: 12}, {entryID: 6, athleteID: 6, teamID: 1003, value: 10}, }) ev := teamEvent(1) ev.Compare = CompareDesc avg := aggScore(t, ev, AggregateAvg, entries, results) sum := aggScore(t, ev, AggregateSum, entries, results) // 名次与积分逐队一致(录取前六名,三队都有分);汇总值本身当然不同(19 平均 / 38 总和)。 avgOutcome := aggregateByTeam(t, avg) sumOutcome := aggregateByTeam(t, sum) for _, teamID := range []int64{1001, 1002, 1003} { if avgOutcome[teamID].rank != sumOutcome[teamID].rank || !nearlyEqual(avgOutcome[teamID].points, sumOutcome[teamID].points) { t.Fatalf("人数相同时两种口径的名次/积分应一致:单位 %d avg=%+v sum=%+v", teamID, avgOutcome[teamID], sumOutcome[teamID]) } } if !reflect.DeepEqual(pointsByTeam(avg.Ledger), pointsByTeam(sum.Ledger)) { t.Fatalf("人数相同时两种口径的流水应一致:avg=%v sum=%v", pointsByTeam(avg.Ledger), pointsByTeam(sum.Ledger)) } // 顺带钉住名次本身:1001 平均 19 / 总和 38 → 第一。 got := aggregateByTeam(t, sum) if got[1001].rank != 1 || got[1002].rank != 2 || got[1003].rank != 3 { t.Fatalf("sum 口径名次 = %+v,期望 1001/1002/1003 依次第 1/2/3 名", got) } } // TestAggregateModeRosterDiffersFlipsRanking:各队人数不同时两种口径的排名会不同。 // 1001 队 2 人(10 + 1)、1002 队 1 人(9):平均看 1002 强,总和看 1001 强。 func TestAggregateModeRosterDiffersFlipsRanking(t *testing.T) { entries, results := 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}, }) ev := teamEvent(1) ev.Compare = CompareDesc avg := aggregateByTeam(t, aggScore(t, ev, AggregateAvg, entries, results)) sum := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results)) // 平均:1001 = 5.5 第二、1002 = 9 第一。 if !nearlyEqual(avg[1001].value, 5.5) || avg[1001].rank != 2 || avg[1001].points != 10 { t.Errorf("avg:1001 应 5.5 第 2 名 10 分,实际 %+v", avg[1001]) } if !nearlyEqual(avg[1002].value, 9) || avg[1002].rank != 1 || avg[1002].points != 15 { t.Errorf("avg:1002 应 9 第 1 名 15 分,实际 %+v", avg[1002]) } // 总和:1001 = 11 第一、1002 = 9 第二。 if !nearlyEqual(sum[1001].value, 11) || sum[1001].rank != 1 || sum[1001].points != 15 { t.Errorf("sum:1001 应 11 第 1 名 15 分,实际 %+v", sum[1001]) } if !nearlyEqual(sum[1002].value, 9) || sum[1002].rank != 2 || sum[1002].points != 10 { t.Errorf("sum:1002 应 9 第 2 名 10 分,实际 %+v", sum[1002]) } if avg[1001].rank == sum[1001].rank { t.Fatalf("人数不同时两种口径的名次不应相同:avg=%+v sum=%+v", avg, sum) } } // TestAggregateModeDefaultsToAverage:缺省(不带 aggregateMode / 老库老客户端)就是 avg, // 结果与显式写 avg 逐字段一致——现有数据与现有用例的行为一字不变。 func TestAggregateModeDefaultsToAverage(t *testing.T) { if got := (ScoreChannel{}).aggregateMode(); got != AggregateAvg { t.Fatalf("零值通道的汇总口径 = %q,期望 avg", got) } // 认不出的取值也退回 avg,不是退回 sum(库里有 CHECK,但内存里的对象仍要兜住)。 for _, bogus := range []AggregateMode{"", "average", "AVG", "SUM", "biggest"} { if got := (ScoreChannel{AggregateMode: bogus}).aggregateMode(); got != AggregateAvg { t.Errorf("取值 %q 应退回 avg,实际 %q", bogus, got) } } // 与 TestAggregateAvgMode 同一个现场(compare=asc,越小越好):1001 一人有成绩 10、一人没成绩; // 1002 两人各 4 / 6。valid 口径下 1002 平均 5 第一(15 分)、1001 平均 10 第二(10 分) // ——这是升级前的既有结果。 legacy := []struct { name string mode AvgMode build func() ([]Entry, []Result) want map[int64]aggregateOutcome }{ { name: "valid", mode: AvgModeValid, 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}, }) }, want: map[int64]aggregateOutcome{ 1002: {value: 5, points: 15, rank: 1}, 1001: {value: 10, points: 10, rank: 2}, }, }, { name: "all", mode: AvgModeAll, 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}, }) }, want: map[int64]aggregateOutcome{ 1001: {value: 5, points: 15, rank: 1}, 1002: {value: 5, points: 15, rank: 1}, }, }, } for _, tc := range legacy { t.Run("既有结果不变/"+tc.name, func(t *testing.T) { entries, results := tc.build() ev := teamEvent(1) ev.AvgMode = tc.mode // defaultScore:通道不带 aggregateMode(老库数据 / 老客户端保存的通道)。 defaultScore := aggScore(t, ev, "", entries, results) explicitAvg := aggScore(t, ev, AggregateAvg, entries, results) // 「缺省」与「显式 avg」必须逐字段一致(连流水顺序都一致)。 if !reflect.DeepEqual(defaultScore.Ledger, explicitAvg.Ledger) { t.Errorf("缺省口径的流水与显式 avg 不一致:\n缺省 %+v\n显式 %+v", defaultScore.Ledger, explicitAvg.Ledger) } if !reflect.DeepEqual(defaultScore.Ranked, explicitAvg.Ranked) { t.Errorf("缺省口径的名次与显式 avg 不一致:\n缺省 %+v\n显式 %+v", defaultScore.Ranked, explicitAvg.Ranked) } got := aggregateByTeam(t, defaultScore) for teamID, want := range tc.want { have := got[teamID] if !nearlyEqual(have.value, want.value) || have.rank != want.rank || !nearlyEqual(have.points, want.points) { t.Errorf("单位 %d = %+v,期望 %+v", teamID, have, want) } } if got := pointsByTeam(defaultScore.Ledger); !reflect.DeepEqual(got, pointsByTeam(explicitAvg.Ledger)) { t.Errorf("缺省口径的单位总分与显式 avg 不一致:%v / %v", got, pointsByTeam(explicitAvg.Ledger)) } }) } } // TestAggregateModeSumWithAvgMode:sum 只累加有效成绩,avg_mode 在求和口径下只剩 // 「一个有效成绩都没有的单位算不算 0 分参与排名」这一条作用(valid 不参与 / all 参与)。 func TestAggregateModeSumWithAvgMode(t *testing.T) { 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}, // 1003 全队一条有效成绩都没有:valid 不参与、all 按 0 参与(与平均口径同规则)。 {entryID: 4, athleteID: 4, teamID: 1003, noValue: true}, }) } t.Run("valid:没成绩的单位不成桶", func(t *testing.T) { entries, results := build() ev := teamEvent(1) ev.Compare = CompareDesc ev.AvgMode = AvgModeValid got := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results)) if !nearlyEqual(got[1001].value, 11) || got[1001].rank != 1 { t.Errorf("1001 应总和 11 第 1 名,实际 %+v", got[1001]) } if !nearlyEqual(got[1002].value, 9) || got[1002].rank != 2 { t.Errorf("1002 应总和 9 第 2 名,实际 %+v", got[1002]) } if got[1003].rank != 0 || got[1003].points != 0 { t.Errorf("1003 全队没成绩,不应参与排名,实际 %+v", got[1003]) } }) t.Run("all:没成绩的单位按 0 参与、排在最后", func(t *testing.T) { entries, results := build() ev := teamEvent(1) ev.Compare = CompareDesc ev.AvgMode = AvgModeAll got := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results)) // 求和:1001 = 11 第一;1002 = 9 第二;1003 = 0 第三(加 0 不改变其它队的和)。 if !nearlyEqual(got[1001].value, 11) || got[1001].rank != 1 { t.Errorf("1001 应总和 11 第 1 名,实际 %+v", got[1001]) } if !nearlyEqual(got[1002].value, 9) || got[1002].rank != 2 { t.Errorf("1002 应总和 9 第 2 名,实际 %+v", got[1002]) } if !nearlyEqual(got[1003].value, 0) || got[1003].rank != 3 { t.Errorf("1003 应总和 0 第 3 名,实际 %+v", got[1003]) } }) }