package domain import ( "reflect" "strconv" "testing" ) // 本文件守住 devdocs/17 的两条底线: // 1. P0:同一份数据连续发布两次,流水与名次必须完全一致——团体通道绝不读 // 「athlete_id != 0 的报名项」的 results.rank(那是个人名次,读了会让同一份成绩发两次团体分); // 2. 团体名次的来源只有 in.TeamRanks 与团体报名项(athlete_id == 0)的 results.rank 两处, // 同一单位两边都有时以 TeamRanks 为准。 // teamPullupInput 构造 TEAM_PULLUP 场景:1 个小项、1 个组别、6 名运动员同属 A 队(1001), // 成绩 30/29/28/27/26/25(引体向上,次数多者名次靠前),两条通道 = // 个人通道(athlete + result + 个人表)+ 团体通道(team + manual + 团体表)。 // teamRanks 是录入员填的团体名次(可为空)。 func teamPullupInput(teamRanks []TeamManualRank) EventScoreInput { values := []float64{30, 29, 28, 27, 26, 25} ps := make([]participant, 0, len(values)) for i, v := range values { ps = append(ps, participant{ entryID: int64(i + 1), athleteID: int64(101 + i), teamID: 1001, name: "选手" + string(rune('A'+i)), value: v, }) } entries, results := fixture(ps) ev := individualEvent(1) ev.EventType = EventTypeIndividualTeam ev.Compare = CompareDesc return EventScoreInput{ Event: ev, Category: testCategory(), Channels: []ScoreChannel{ {ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1}, {ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisManual, ProfileID: 2}, }, Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()}, Entries: entries, Results: results, TeamRanks: teamRanks, } } // writeBackComputedRanks 模拟 httpapi 的 applyComputedRanks: // 只把个人通道(scope=athlete)算出的名次写回对应成绩的 results.rank, // 且要给 manual 通道的「录入位」让路(工单 A-1 / A-2,与 httpapi.scoring.go 同口径): // 配了 [athlete,manual] 时整项不写回,配了 [team,manual] 时团体报名项(AthleteID=0)不写回。 // 这里少一条判断,模拟出来的「第二次发布」就会跟真实系统不是一回事。 func writeBackComputedRanks(in EventScoreInput, ranked []RankedResult) { athleteManual, teamManual := false, false for _, ch := range in.Channels { if ch.Basis != BasisManual { continue } switch ch.Scope { case ScopeAthlete: athleteManual = true case ScopeTeam: teamManual = true } } for _, rr := range ranked { if rr.Scope != ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 { continue } if athleteManual || (teamManual && rr.AthleteID == 0) { continue } for i := range in.Results { if in.Results[i].ID == rr.ResultID { in.Results[i].Rank = rr.Rank } } } } // TestTeamEntryDualChannelPublishTwiceIsIdempotent 是工单 A-1 的引擎侧回归: // team 类型小项 + 3 条团体报名项(athlete_id 为空)+ 双通道 [athlete/result, team/manual]。 // 团体报名项的 results.rank 是「单位名次」的录入位,个人通道的自动名次不许写进去, // 否则第二次发布团体手工通道会把个人名次当成团体名次再发一遍分(10/8/6 → 25/18/14)。 func TestTeamEntryDualChannelPublishTwiceIsIdempotent(t *testing.T) { entries, results := fixture([]participant{ {entryID: 1, teamID: 1001, name: "甲队", value: 90}, {entryID: 2, teamID: 1002, name: "乙队", value: 80}, {entryID: 3, teamID: 1003, name: "丙队", value: 70}, }) ev := teamEvent(1) ev.Compare = CompareDesc in := EventScoreInput{ Event: ev, Category: testCategory(), Channels: []ScoreChannel{ {ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1}, teamManualChannel(2), }, Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()}, Entries: entries, Results: results, } first := mustScore(t, in) writeBackComputedRanks(in, first.Ranked) second := mustScore(t, in) if !reflect.DeepEqual(first, second) { t.Fatalf("两次发布的计分结果不一致:\n第一次 %+v\n第二次 %+v", first, second) } for _, r := range in.Results { if r.Rank != 0 { t.Fatalf("团体报名项的 results.rank 被自动名次写回,得到 %+v", r) } } // 团体手工通道没有录入名次:两次发布都只有个人通道的 10/8/6。 got := pointsByTeam(second.Ledger) for _, want := range []struct { teamID int64 points float64 }{{1001, 10}, {1002, 8}, {1003, 6}} { if !nearlyEqual(got[want.teamID], want.points) { t.Fatalf("单位 %d = %v,期望 %v(团体名次未录,不该多出团体分):%v", want.teamID, got[want.teamID], want.points, got) } } if len(second.Ranked) != 3 { t.Fatalf("名次明细 = %d 条,期望只有个人通道的 3 条:%+v", len(second.Ranked), second.Ranked) } } // TestAthleteManualRankNotOverwritten 是工单 A-2 的引擎侧回归: // [athlete,result] 与 [athlete,manual] 共存时,results.rank 是人工录入的个人名次, // 自动名次不许覆盖它;反复「发布 → 写回」必须得到同一份结果。 func TestAthleteManualRankNotOverwritten(t *testing.T) { // 成绩 30 / 29(compare=asc:数值小的靠前 → 自动名次本该是 乙1 第 1、甲1 第 2); // 人工名次反着填(甲1 第 1、乙1 第 2)。 entries, results := fixture([]participant{ {entryID: 1, athleteID: 101, teamID: 1001, name: "甲", value: 30, rank: 1}, {entryID: 2, athleteID: 102, teamID: 1002, name: "乙", value: 29, rank: 2}, }) in := EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{ athleteResultChannel(1), {ID: 2, EventID: 1, Scope: ScopeAthlete, Basis: BasisManual, ProfileID: 1}, }, Profiles: map[int64]ScoringProfile{1: individualTop6()}, Entries: entries, Results: results, } first := mustScore(t, in) writeBackComputedRanks(in, first.Ranked) second := mustScore(t, in) if !reflect.DeepEqual(first, second) { t.Fatalf("两次发布的计分结果不一致:\n第一次 %+v\n第二次 %+v", first, second) } if in.Results[0].Rank != 1 || in.Results[1].Rank != 2 { t.Fatalf("人工名次被自动名次覆盖:甲=%d、乙=%d,期望 1 / 2", in.Results[0].Rank, in.Results[1].Rank) } // 自动通道 8/10 + 人工通道 10/8:两队各 18 分。 got := pointsByTeam(second.Ledger) if !nearlyEqual(got[1001], 18) || !nearlyEqual(got[1002], 18) { t.Fatalf("两队应各 18 分(自动 + 人工),得到 %v", got) } } // TestTeamPullupPublishTwiceIsIdempotent 是 P0 回归: // 「发布 → 把个人名次写回 results.rank → 再发布一次」两次的 Ledger 与 Ranked 必须逐字段一致。 func TestTeamPullupPublishTwiceIsIdempotent(t *testing.T) { t.Run("团体名次已录:两次发布一致,A 队总分不跳变", func(t *testing.T) { in := teamPullupInput([]TeamManualRank{{TeamID: 1001, Rank: 1}}) first := mustScore(t, in) writeBackComputedRanks(in, first.Ranked) second := mustScore(t, in) if !reflect.DeepEqual(first.Ledger, second.Ledger) { t.Fatalf("两次发布的流水不一致:\n第一次 %+v\n第二次 %+v", first.Ledger, second.Ledger) } if !reflect.DeepEqual(first.Ranked, second.Ranked) { t.Fatalf("两次发布的名次不一致:\n第一次 %+v\n第二次 %+v", first.Ranked, second.Ranked) } // 团体通道的分数只来自团体名次:唯一一条单位流水,没有报名项 / 成绩(EntryID=0、ResultID=0)。 var teamLedger []LedgerEntry for _, l := range second.Ledger { if l.AthleteID == 0 { teamLedger = append(teamLedger, l) } } if len(teamLedger) != 1 { t.Fatalf("团体流水 = %d 条,期望 1 条:%+v", len(teamLedger), teamLedger) } l := teamLedger[0] if l.TeamID != 1001 || l.EntryID != 0 || l.ResultID != 0 { t.Fatalf("团体流水 = %+v,期望只记单位 1001 且无报名项 / 成绩", l) } if !nearlyEqual(l.Points, 15) { t.Fatalf("团体名次第 1 名的分 = %v,期望 15(团体表)", l.Points) } if l.SourceID != 2 { t.Fatalf("sourceID 兜底应取通道 id=2,得到 %d", l.SourceID) } if l.SourceType != SourceResult { t.Fatalf("SourceType = %q,期望 %q", l.SourceType, SourceResult) } // 团体通道只有一条名次明细,主体是单位,绝不消费那 6 条个人名次。 var teamRanked []RankedResult for _, rr := range second.Ranked { if rr.ChannelID == 2 { teamRanked = append(teamRanked, rr) } } if len(teamRanked) != 1 { t.Fatalf("团体通道名次 = %d 条,期望 1 条(只认团体名次):%+v", len(teamRanked), teamRanked) } if teamRanked[0].EntryID != 0 || teamRanked[0].AthleteID != 0 || teamRanked[0].SubjectKey != "t:1001" { t.Fatalf("团体名次 = %+v,期望合成主体 t:1001", teamRanked[0]) } if teamRanked[0].Rank != 1 { t.Fatalf("团体名次 Rank = %d,期望 1", teamRanked[0].Rank) } // 个人通道照旧按成绩排名 1..6。 ranks := rankByEntry(second.Ranked) for i := 1; i <= 6; i++ { if ranks[int64(i)] != i { t.Fatalf("个人名次 = %v,期望 entry%d → %d", ranks, i, i) } } // A 队总分 = 个人表 10+8+6+3+2+1 = 30,加团体名次第 1 名的 15,共 45; // 第二次发布如果团体通道又读了一遍个人名次,这里会变成 85(P0 的真机表现)。 for _, es := range []EventScore{first, second} { if got := SumByTeam(es.Ledger, 1)[1001].Total; !nearlyEqual(got, 45) { t.Fatalf("A 队总分 = %v,期望 45(30 个人 + 15 团体)", got) } } }) t.Run("团体名次未录:个人名次绝不进团体通道", func(t *testing.T) { in := teamPullupInput(nil) first := mustScore(t, in) writeBackComputedRanks(in, first.Ranked) second := mustScore(t, in) if !reflect.DeepEqual(first, second) { t.Fatalf("两次发布的计分结果不一致:\n第一次 %+v\n第二次 %+v", first, second) } for _, rr := range second.Ranked { if rr.ChannelID == 2 { t.Fatalf("没录团体名次时团体通道不应产出名次:%+v", rr) } } if got := SumByTeam(second.Ledger, 1)[1001].Total; !nearlyEqual(got, 30) { t.Fatalf("A 队总分 = %v,期望只算个人通道的 30(不跳变)", got) } }) } // TestTeamManualChannelCandidateSources 守住团体手工通道的候选来源。 func TestTeamManualChannelCandidateSources(t *testing.T) { score := func(t *testing.T, in EventScoreInput) EventScore { t.Helper() return mustScore(t, in) } t.Run("只认团体报名项兜底,个人报名项的名次不进团体通道", func(t *testing.T) { entries, results := fixture([]participant{ // 个人报名项:录入名次第 1,是个人名次,团体通道不许读。 {entryID: 1, athleteID: 101, teamID: 1001, name: "甲", rank: 1, noValue: true}, // 团体报名项(athlete_id = 0):团体名次第 2。 {entryID: 2, teamID: 1002, rank: 2, noValue: true}, }) es := score(t, EventScoreInput{ Event: teamEvent(1), Category: testCategory(), Channels: []ScoreChannel{teamManualChannel(2)}, Profiles: map[int64]ScoringProfile{2: teamTop6()}, Entries: entries, Results: results, }) got := pointsByTeam(es.Ledger) if !nearlyEqual(got[1002], 10) { t.Fatalf("单位 1002 = %v,期望团体名次第 2 名的 10 分:%v", got[1002], got) } if _, ok := got[1001]; ok { t.Fatalf("个人报名项的名次进了团体通道:%v", got) } if len(es.Ranked) != 1 || es.Ranked[0].TeamID != 1002 || es.Ranked[0].EntryID != 2 { t.Fatalf("团体名次明细 = %+v,期望只有单位 1002 的团体报名项", es.Ranked) } }) t.Run("TeamRanks 覆盖团体报名项的兜底名次", func(t *testing.T) { entries, results := fixture([]participant{ {entryID: 1, teamID: 1001, rank: 3, noValue: true}, }) es := score(t, EventScoreInput{ Event: teamEvent(1), Category: testCategory(), Channels: []ScoreChannel{teamManualChannel(2)}, Profiles: map[int64]ScoringProfile{2: teamTop6()}, Entries: entries, Results: results, TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 1}}, }) if got := pointsByTeam(es.Ledger); !nearlyEqual(got[1001], 15) { t.Fatalf("单位 1001 = %v,期望以 TeamRanks 的第 1 名计 15 分:%v", got[1001], got) } if len(es.Ranked) != 1 { t.Fatalf("同一单位应只产出一条名次,得到 %d 条:%+v", len(es.Ranked), es.Ranked) } if es.Ranked[0].Rank != 1 || es.Ranked[0].EntryID != 1 || es.Ranked[0].SubjectKey != "e:1" { t.Fatalf("覆盖后的名次 = %+v,期望报名项 1 记为第 1 名", es.Ranked[0]) } }) t.Run("rank<=0 的团体名次视为未填,退回团体报名项", func(t *testing.T) { entries, results := fixture([]participant{ {entryID: 1, teamID: 1001, rank: 2, noValue: true}, }) es := score(t, EventScoreInput{ Event: teamEvent(1), Category: testCategory(), Channels: []ScoreChannel{teamManualChannel(2)}, Profiles: map[int64]ScoringProfile{2: teamTop6()}, Entries: entries, Results: results, TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 0}, {TeamID: 1002, Rank: -1}}, }) got := pointsByTeam(es.Ledger) if !nearlyEqual(got[1001], 10) { t.Fatalf("单位 1001 = %v,期望退回团体报名项的第 2 名(10 分):%v", got[1001], got) } if _, ok := got[1002]; ok { t.Fatalf("rank<=0 的团体名次不应产出名次:%v", got) } if len(es.Ranked) != 1 { t.Fatalf("名次明细 = %d 条,期望 1 条:%+v", len(es.Ranked), es.Ranked) } }) t.Run("并列团体名次走模板的 tieMode", func(t *testing.T) { ranks := []TeamManualRank{{TeamID: 1001, Rank: 1}, {TeamID: 1002, Rank: 1}} cases := []struct { name string tie TieMode points float64 }{ {"顺延(默认)", TieSkip, 15}, {"平均", TieAverage, 12.5}, {"就高", TieHigher, 15}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { profile := teamTop6() profile.TieMode = tc.tie es := score(t, EventScoreInput{ Event: teamEvent(1), Category: testCategory(), Channels: []ScoreChannel{teamManualChannel(2)}, Profiles: map[int64]ScoringProfile{2: profile}, TeamRanks: ranks, }) if len(es.Ranked) != 2 { t.Fatalf("并列名次 = %d 条,期望 2 条:%+v", len(es.Ranked), es.Ranked) } got := pointsByTeam(es.Ledger) for _, rr := range es.Ranked { if rr.Rank != 1 { t.Fatalf("并列名次 = %+v,期望同为第 1 名", rr) } } if !nearlyEqual(got[1001], tc.points) || !nearlyEqual(got[1002], tc.points) { t.Fatalf("并列得分 = %v,期望各 %v", got, tc.points) } }) } }) } // TestRankedSubjectKeys 覆盖计分引擎各条产出路径的 SubjectKey 口径。 func TestRankedSubjectKeys(t *testing.T) { t.Run("result 通道按报名项", func(t *testing.T) { entries, results := fixture(lineupValues(11, 12)) es := mustScore(t, EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(1)}, Profiles: map[int64]ScoringProfile{1: individualTop6()}, Entries: entries, Results: results, }) for _, rr := range es.Ranked { if want := "e:" + strconv.FormatInt(rr.EntryID, 10); rr.SubjectKey != want { t.Fatalf("SubjectKey = %q,期望 %q(%+v)", rr.SubjectKey, want, rr) } } }) t.Run("团体手工名次按单位", func(t *testing.T) { es := mustScore(t, EventScoreInput{ Event: teamEvent(1), Category: testCategory(), Channels: []ScoreChannel{teamManualChannel(2)}, Profiles: map[int64]ScoringProfile{2: teamTop6()}, TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 1}, {TeamID: 1002, Rank: 2}}, }) want := []string{"t:1001", "t:1002"} if len(es.Ranked) != len(want) { t.Fatalf("名次条数 = %d,期望 %d", len(es.Ranked), len(want)) } for i, key := range want { if es.Ranked[i].SubjectKey != key { t.Fatalf("第 %d 条 SubjectKey = %q,期望 %q", i, es.Ranked[i].SubjectKey, key) } } }) t.Run("聚合通道按主体", func(t *testing.T) { entries, results := fixture([]participant{ {entryID: 1, athleteID: 1, teamID: 1001, value: 11}, {entryID: 2, athleteID: 2, teamID: 1001, value: 12}, {entryID: 3, athleteID: 3, teamID: 1002, value: 13}, }) ev := teamEvent(1) ev.Compare = CompareAsc es := mustScore(t, EventScoreInput{ Event: ev, Category: testCategory(), Channels: []ScoreChannel{ {ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisAggregate, ProfileID: 1}, {ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisAggregate, ProfileID: 2}, }, Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()}, Entries: entries, Results: results, }) for _, rr := range es.Ranked { var want string if rr.Scope == ScopeAthlete { want = "a:" + strconv.FormatInt(rr.AthleteID, 10) } else { want = "t:" + strconv.FormatInt(rr.TeamID, 10) } if rr.SubjectKey != want || rr.EntryID != 0 { t.Fatalf("聚合名次 = %+v,期望 SubjectKey %q 且 EntryID=0", rr, want) } } }) } // TestSubjectKeyOfFallback 直接覆盖 subjectKeyOf 的四层口径(含退化兜底)。 func TestSubjectKeyOfFallback(t *testing.T) { cases := []struct { name string rr RankedResult want string }{ {"有报名项按报名项", RankedResult{EntryID: 7, AthleteID: 101, TeamID: 1001, ChannelID: 3, Scope: ScopeAthlete}, "e:7"}, {"个人主体", RankedResult{AthleteID: 101, TeamID: 1001, ChannelID: 3, Scope: ScopeAthlete}, "a:101"}, {"单位主体", RankedResult{TeamID: 1001, ChannelID: 3, Scope: ScopeTeam}, "t:1001"}, {"团体名次但没有单位", RankedResult{AthleteID: 101, ChannelID: 3, Scope: ScopeTeam}, "ch:3"}, {"全空退化通道", RankedResult{ChannelID: 5}, "ch:5"}, {"连通道都没有", RankedResult{}, "ch:0"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := subjectKeyOf(tc.rr); got != tc.want { t.Fatalf("subjectKeyOf = %q,期望 %q", got, tc.want) } }) } }