package domain import ( "fmt" "strings" "testing" ) // 本文件钉住「团体报名的队员身份」在引擎里的口径(devdocs/20): // // - 一条团体报名(athlete_id 为空)可以挂多个队员;个人通道给**每个队员各一份完整的**分 // (不是按份额拆,与联队 entry_team_shares 的「拆」相反),个人奖牌也是每人各一枚; // - **单位维度一份都不多**:单位总分与单位奖牌仍然只算这条报名一次,由通道开关 // count_to_team / count_to_team_medals 控制; // - 没有队员关联的老数据(Entry.Athletes 为空)逐字节不变。 // memberEntry 造一条带队员的团体报名。 func memberEntry(entryID, teamID int64, value float64, members ...EntryAthlete) participant { return participant{entryID: entryID, teamID: teamID, value: value, members: members} } // twoDoubles 是「一个单位派两个人打双打 + 另一个单位一个人」的最小现场形态: // 成绩越小越好(compare=asc,与 100 米那种打法一致)。 func twoDoubles() ([]Entry, []Result) { return fixture([]participant{ memberEntry(1, 1001, 11.2, EntryAthlete{AthleteID: 201, Name: "队员一"}, EntryAthlete{AthleteID: 202, Name: "队员二"}), {entryID: 2, teamID: 1002, value: 11.5}, }) } // twoDoublesLegacy 是升级前的老数据形态:同样两条报名、同样的成绩,但队员只有姓名文字 // (认不出是哪个人),也就是回填不出关联或干脆没回填的那些报名。 func twoDoublesLegacy() ([]Entry, []Result) { entries, results := twoDoubles() entries[0].Athletes = nil return entries, results } // doublesEventInput 是双打的计分输入:团体项目 + 一个按成绩排名的个人通道。 // teamFlagsOff 打开通道的两个单位开关「都不计入」(现场的双打口径)。 func doublesEventInput(entries []Entry, results []Result, teamFlagsOff bool) EventScoreInput { ch := athleteResultChannel(9) if teamFlagsOff { ch.CountToTeam = boolPtr(false) ch.CountToTeamMedals = boolPtr(false) } return EventScoreInput{ Event: teamEvent(1), Category: testCategory(), Channels: []ScoreChannel{ch}, Profiles: map[int64]ScoringProfile{9: individualTop6()}, Entries: entries, Results: results, } } // 双打:两个队员各拿一份完整的个人分(第 1 名 10 分 → 两人各 10 分,不是各 5 分), // 单位总分仍然只有一份 10 分;个人奖牌每人各一枚,单位奖牌只有一枚。 func TestTeamEntryMembersScoreIndividually(t *testing.T) { entries, results := twoDoubles() es := mustScore(t, doublesEventInput(entries, results, false)) // 名次明细:报名项自己那一份(单位维度认它)+ 每个队员一份个人副本。 wantRanked := []string{ "key=e:1 scope=athlete basis=result team=1001 athlete=0 rank=1 points=10", "key=e:1:a:201 scope=athlete basis=result team=1001 athlete=201 rank=1 points=10", "key=e:1:a:202 scope=athlete basis=result team=1001 athlete=202 rank=1 points=10", "key=e:2 scope=athlete basis=result team=1002 athlete=0 rank=2 points=8", } if got := rankedSummary(es.Ranked); strings.Join(got, "\n") != strings.Join(wantRanked, "\n") { t.Fatalf("名次明细不对:\n得到 %s期望 %s", strings.Join(got, "\n"), strings.Join(wantRanked, "\n")) } // 流水:单位那一份照旧(count_to_team=true),队员的个人分是「只记个人」的额外流水—— // 它们 count_to_team=false,所以既不会让单位总分变成两份,也不会进单位奖牌。 wantLedger := []string{ "team=1001 athlete=0 entry=1 result=100 points=10 countToTeam=true", "team=1001 athlete=201 entry=1 result=100 points=10 countToTeam=false", "team=1001 athlete=202 entry=1 result=100 points=10 countToTeam=false", "team=1002 athlete=0 entry=2 result=200 points=8 countToTeam=true", } if got := ledgerSummary(es.Ledger); strings.Join(got, "\n") != strings.Join(wantLedger, "\n") { t.Fatalf("流水不对:\n得到 %s期望 %s", strings.Join(got, "\n"), strings.Join(wantLedger, "\n")) } in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked} // 单位总分只算一次:10 分(不是 30 分),金牌一枚(不是三枚)。 rows := BuildTeamStandings(in) if len(rows) != 2 { t.Fatalf("积分榜应有两行,得到 %+v", rows) } if rows[0].TeamID != 1001 || !nearlyEqual(rows[0].Points, 10) || rows[0].TeamOnlyPoints != 10 || rows[0].IndividualPoints != 0 || rows[0].Gold != 1 || rows[0].Medals != 1 { t.Fatalf("双打单位那一行 = %+v,期望总分 10(仅单位 10、个人 0)、1 枚金牌(不进个人分)", rows[0]) } if rows[1].TeamID != 1002 || rows[1].Points != 8 || rows[1].Silver != 1 { t.Fatalf("对照单位那一行 = %+v,期望 8 分 1 银", rows[1]) } medals := BuildMedalStandings(in) if medals[0].TeamID != 1001 || medals[0].Gold != 1 || medals[0].Total != 1 { t.Fatalf("单位奖牌榜第一行 = %+v,期望单位 1001 只记 1 枚金牌", medals[0]) } // 个人榜:两个队员各一份满分、各一枚金牌。 athletes := BuildAthleteStandings(in) wantAthletes := []string{ "athlete=201 team=1001 points=10 1/0/0 medals=1", "athlete=202 team=1001 points=10 1/0/0 medals=1", } got := make([]string, 0, len(athletes)) for _, row := range athletes { got = append(got, fmt.Sprintf("athlete=%d team=%d points=%v %d/%d/%d medals=%d", row.AthleteID, row.TeamID, row.Points, row.Gold, row.Silver, row.Bronze, row.Medals)) } // 两人同分同牌,排序落到姓名(队员一 < 队员二),顺序稳定。 if strings.Join(got, "\n") != strings.Join(wantAthletes, "\n") { t.Fatalf("个人榜不对:\n得到 %s期望 %s", strings.Join(got, "\n"), strings.Join(wantAthletes, "\n")) } } // 现场的双打口径(通道两个单位开关都关掉):队员照拿个人分与个人奖牌,单位一分一牌都不拿。 func TestTeamEntryMembersWithTeamFlagsOff(t *testing.T) { entries, results := twoDoubles() es := mustScore(t, doublesEventInput(entries, results, true)) in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked} for _, row := range BuildTeamStandings(in) { if row.Points != 0 || row.Medals != 0 { t.Errorf("单位 %d 在「都不计入」下应为 0 分 0 牌,得到 %+v", row.TeamID, row) } } for _, row := range BuildMedalStandings(in) { if row.Total != 0 { t.Errorf("单位 %d 在「都不计入」下不该有奖牌,得到 %+v", row.TeamID, row) } } athletes := BuildAthleteStandings(in) if len(athletes) != 2 || athletes[0].Points != 10 || athletes[0].Gold != 1 || athletes[1].Points != 10 || athletes[1].Gold != 1 { t.Fatalf("队员的个人分与个人奖牌照发,得到 %+v", athletes) } } // 联队 + 队员同时存在(A+B 联队派两个人打双打):两套机制各管一维,不许互相污染—— // 单位那一份仍按份额**拆**(各 5 分)、奖牌各 1 枚;队员各拿**一整份**(各 10 分,不是各 5 分)。 func TestJointTeamWithMembersKeepsSharesAndCopiesSeparate(t *testing.T) { entries, results := fixture([]participant{ {entryID: 1, teamID: 1001, value: 11.2, shares: []TeamShare{{TeamID: 1001, Weight: 1}, {TeamID: 1002, Weight: 1}}, members: []EntryAthlete{{AthleteID: 201, Name: "队员一"}, {AthleteID: 202, Name: "队员二"}}}, }) es := mustScore(t, doublesEventInput(entries, results, false)) // 流水:单位那两份各 5 分(按份额拆),队员那两份各 10 分(整份,只记个人)。 ledger := ledgerSummary(es.Ledger) want := []string{ "team=1001 athlete=0 entry=1 result=100 points=5 countToTeam=true", "team=1002 athlete=0 entry=1 result=100 points=5 countToTeam=true", "team=1001 athlete=201 entry=1 result=100 points=10 countToTeam=false", "team=1001 athlete=202 entry=1 result=100 points=10 countToTeam=false", } if strings.Join(ledger, "\n") != strings.Join(want, "\n") { t.Fatalf("联队 + 队员的流水不对:\n得到 %s期望 %s", strings.Join(ledger, "\n"), strings.Join(want, "\n")) } in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked} for _, row := range BuildMedalStandings(in) { if row.Gold != 1 || row.Total != 1 { t.Errorf("联队单位 %d 应各记 1 枚金牌(队员不重复发牌),得到 %+v", row.TeamID, row) } } athletes := BuildAthleteStandings(in) if len(athletes) != 2 || athletes[0].Points != 10 || athletes[1].Points != 10 { t.Fatalf("联队里的队员各拿一整份个人分,得到 %+v", athletes) } } // 硬门槛:挂上队员之后,**单位维度逐字节不变**。 // // 同一份成绩算两遍:一遍是升级前的老数据形态(只有姓名文字、认不出人),一遍是升级后 // 回填出队员身份。单位那一份名次、流水、总分、奖牌必须一模一样——队员的分只走个人维度, // 绝不能渗进单位总分或单位奖牌(那是本单最容易错的地方)。 func TestTeamEntryMembersKeepTeamDimensionByteIdentical(t *testing.T) { legacyEntries, legacyResults := twoDoublesLegacy() without := mustScore(t, doublesEventInput(legacyEntries, legacyResults, false)) entries, results := twoDoubles() with := mustScore(t, doublesEventInput(entries, results, false)) before := teamDimensionDump(without) after := teamDimensionDump(with) if before != after { t.Fatalf("挂上队员后单位维度变了:\n之前\n%s之后\n%s", before, after) } if !strings.Contains(before, "单位榜") || !strings.Contains(before, "单位奖牌榜") { t.Fatalf("对拍文本里必须有单位榜与单位奖牌榜,否则等于两份空文本相等:\n%s", before) } // 防空转:老形态的个人维度确实是空的(队员认不出来就上不了个人榜)。 if got := len(BuildAthleteStandings(StandingsInput{Ledger: without.Ledger, Ranked: without.Ranked})); got != 0 { t.Fatalf("没有队员关联时个人榜应为空,得到 %d 行", got) } if got := len(BuildAthleteStandings(StandingsInput{Ledger: with.Ledger, Ranked: with.Ranked})); got != 2 { t.Fatalf("挂上队员后个人榜应有两个队员,得到 %d 行", got) } } // teamDimensionDump 把「单位维度」的全部事实拼成稳定文本:单位那一份流水、单位总分、 // 单位榜与单位奖牌榜。队员的个人流水不进这份文本(它不属于单位维度)。 func teamDimensionDump(es EventScore) string { var lines []string for _, l := range es.Ledger { if l.AthleteID != 0 { continue } lines = append(lines, fmt.Sprintf("流水 team=%d entry=%d result=%d points=%v countToTeam=%v", l.TeamID, l.EntryID, l.ResultID, l.Points, l.CountToTeam)) } for _, rr := range es.Ranked { if rr.AthleteID != 0 { continue } lines = append(lines, fmt.Sprintf("名次 key=%s scope=%s team=%d rank=%d points=%v shares=%s", rr.SubjectKey, rr.Scope, rr.TeamID, rr.Rank, rr.Points, formatShares(rr.Shares))) } in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked} for _, weight := range []float64{1, 0.5} { for _, exclude := range []bool{false, true} { for _, row := range BuildTeamStandings(StandingsInput{ Teams: in.Teams, Ledger: in.Ledger, Ranked: in.Ranked, IndividualToTeamWeight: weight, ExcludeTeamEventMedals: exclude, }) { lines = append(lines, fmt.Sprintf("单位榜 w=%v ex=%v team=%d pts=%v only=%v indiv=%v %d/%d/%d", weight, exclude, row.TeamID, row.Points, row.TeamOnlyPoints, row.IndividualPoints, row.Gold, row.Silver, row.Bronze)) } for _, row := range BuildMedalStandings(StandingsInput{ Teams: in.Teams, Ledger: in.Ledger, Ranked: in.Ranked, IndividualToTeamWeight: weight, ExcludeTeamEventMedals: exclude, }) { lines = append(lines, fmt.Sprintf("单位奖牌榜 w=%v ex=%v team=%d %d/%d/%d total=%d", weight, exclude, row.TeamID, row.Gold, row.Silver, row.Bronze, row.Total)) } } } return strings.Join(lines, "\n") + "\n" } // rankedSummary 打印名次明细里「谁、什么名次、多少分」这几件事。 func rankedSummary(ranked []RankedResult) []string { out := make([]string, 0, len(ranked)) for _, rr := range ranked { out = append(out, fmt.Sprintf("key=%s scope=%s basis=%s team=%d athlete=%d rank=%d points=%v", rr.SubjectKey, rr.Scope, rr.Basis, rr.TeamID, rr.AthleteID, rr.Rank, rr.Points)) } return out } // ledgerSummary 打印流水的归属与是否计入单位总分。 func ledgerSummary(ledger []LedgerEntry) []string { out := make([]string, 0, len(ledger)) for _, l := range ledger { out = append(out, fmt.Sprintf("team=%d athlete=%d entry=%d result=%d points=%v countToTeam=%v", l.TeamID, l.AthleteID, l.EntryID, l.ResultID, l.Points, l.CountToTeam)) } return out }