Files
Scoreboard/server/internal/domain/joint_team_test.go
T
AskaEth 830a4dbdb7 feat(joint-team): 联队 + 份额分成 —— 迁移 0024、引擎按份额拆、导出 / 大屏 / 录入三路打通;版本 dev_0.5.3
## 需求
现场 6 项球类比赛里,有两个单位因参赛人数不足**合并成一个单位参赛**。
做法不是新建「联队单位」(那样榜单会多出第 8 个单位,且分和奖牌分不到原单位头上),
而是**一条报名挂多个单位、按份额拆**。

## 拍板口径
- 份额**可配、默认均分**
- 积分**平均拆、接受 .5**(团体第 1 名 15 分 → 两个单位各 7.5 分)
- 奖牌**各记一枚**(两个单位各 1 枚完整金牌,不是各 0.5 枚)
- 显示上把联队名字写全(形如 `第一分公司+第二分公司联队`),成员单位的 logo 都放
- 导入表里单位编码写 `A+B`

## 一、核心层(数据模型 + 引擎 + 存储 + 报名写入 + 导入解析)
- 迁移 `0024_entry_team_shares.sql`:新建 `entry_team_shares(entry_id, team_id, points_share,
  medal_share, sort_order)`。**两个口径分开表达**是刻意的——积分要拆成小数(7.5)、奖牌要整枚
  各记,用一个数表达就会把「7.5 分」和「半枚金牌」绑在一起,奖牌榜立刻错。
  `entries.team_id` 保留为**主单位**;老数据 `INSERT OR IGNORE` 回填成「主单位独占 100%」。
- 引擎:`ResolveShares`(引擎与读侧共用)、`splitSharePoints`(按权重归一、**末位吞余数**,
  各份之和恒等于原来那一份)、`TeamShare.Medal`(缺省整枚)。积分按权重拆,奖牌每个份额各记整枚。
- 报名写入:`PUT /api/entries/{id}/team-shares`(recorder 及以上,保存后当场重算该小项组别并推榜)。
- 导入:`team_code` 支持 `A+B`(主单位 = 加号左边,均分,奖牌各整枚);非法形态
  (`A+` / `+B` / `A++B` / `A+A` / 成员 >5 / 单位不存在)**行级拦下、不落库**,给人话报错。

## 二、导出与导入模板
- 11 份导出件:联队写全成员单位(`第一分公司+第二分公司联队`),并注明**分拆给了哪几家、各多少**
  (份额占比 + 各单位实得积分,取自按份额拆完的流水)。
- 单位奖牌明细:奖牌格写「金牌(联队 …,各单位各记 1 枚)」;含联队时副标题说明
  「各单位奖牌数之和会大于实际颁发数」——这是口径 3 的必然结果,**不是 bug**。
- 按单位筛选导出把成员单位一起算上(否则 B 单位导自己的名单会看不到自己参加的那场联队比赛)。
- 分工作表模板:`A+B` 写法挂在「单位编码」表头的**单元格批注 + 输入提示**(中英各一份);
  **表头文字、列序、12 列、工作表命名一个字未动**(动了模板就导不回去)。

## 三、前端与大屏
- 录入页 / 复核页「单位」列:联队显示 `联队 第一分公司 67%(权重 2) 第二分公司 33%`,
  点该格打开「成员单位与份额」弹窗(加/删成员、改权重、主单位徽标、实时比例预览)。
  **普通报名(单单位)的文案、元素、类名一字未动**,只多了一个 `@click` 入口与悬停提示。
- 大屏:分项成绩 / 实时赛况单位列显示联队全名并**放上两个单位的 logo**(单位列宽按该行的
  logo 数留位);底栏同理。

## 四、验证(都留了证据)
- **老数据逐字节不变**(改算分引擎的硬门槛):引擎层 golden `a2aa1c97…`、接口层 golden
  `aa6cea37…`,两个哈希由主会话**在修前树上亲手重跑确认**;导出层另有 11 份导出件逐行对拍,
  差异 **0 处**。
- 前端 773 → **809 例**全绿(旧用例一行未改);服务端 14 包两轮全绿;Console 68 例全绿。
- 真机联调:录入页份额弹窗、大屏两个 logo、奖牌榜 A/B 各 1 金、写路径(移除成员 → 保存 → 200)。

## 五、版本
`dev_0.5.2` → **`dev_0.5.3`**(本批含数据迁移 0024 + 新功能,必须与上一包区分开);
安装器素材重生成(版本号烙在位图里,实测只有 `brand.bmp` 的 1x/2x 两张变化,其余 92 张逐字节未变)。
2026-09-14 03:52:34 +08:00

378 lines
16 KiB
Go
Raw 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 domain
import (
"testing"
)
// 本文件钉住「联队 + 份额分成」的算分口径(devdocs/19 第三节的五条拍板)。
//
// 两个口径必须分开表达,测试也分成两组:
// - 积分按份额拆:团体第 1 名 15 分 → 联队的两个单位各 7.5 分(允许出现小数);
// - 奖牌整枚各记:同一个金牌 → 两个单位**各记 1 枚**(不是各 0.5 枚)。
//
// 另外单钉一处最容易错的口径:份额与 count_to_team / count_to_medals 两个通道开关叠加。
// 单位总分公式是 `Σ仅记单位的流水 + 全局系数 × Σ(记到个人且 count_to_team=1 的流水)`,
// 份额只改「这一份分记在哪个单位头上」,两个开关仍然按通道整体决定这条流水算不算数。
// jointShares 构造联队份额:两个单位均分(默认口径),奖牌各记整枚。
func jointShares(teamIDs ...int64) []TeamShare {
out := make([]TeamShare, 0, len(teamIDs))
for _, id := range teamIDs {
out = append(out, TeamShare{TeamID: id})
}
return out
}
// weightedShares 构造按给定权重拆分的份额(奖牌缺省 = 整枚各记)。
func weightedShares(pairs ...any) []TeamShare {
out := make([]TeamShare, 0, len(pairs)/2)
for i := 0; i+1 < len(pairs); i += 2 {
out = append(out, TeamShare{TeamID: pairs[i].(int64), Weight: pairs[i+1].(float64)})
}
return out
}
// medalOf 取单位奖牌榜上某单位的金银铜。
func medalOf(rows []MedalRow, teamID int64) (gold, silver, bronze int) {
for _, r := range rows {
if r.TeamID == teamID {
return r.Gold, r.Silver, r.Bronze
}
}
return 0, 0, 0
}
// teamMedalRows 用一份「引擎产出」直接算单位奖牌榜。
func teamMedalRows(t *testing.T, ledger []LedgerEntry, ranked []RankedResult, teamIDs ...int64) []MedalRow {
t.Helper()
return BuildMedalStandings(StandingsInput{
Teams: testTeams(teamIDs...),
Ledger: ledger,
Ranked: ranked,
})
}
// scoreTeamManualWithEntries 用团体手工名次通道跑一次计分:名次按单位录(联队录在主单位上),
// 报名项由调用方给(联队的名次想按份额拆,库里得先有那条联队报名)。
func scoreTeamManualWithEntries(t *testing.T, entries []Entry, ranks ...TeamManualRank) EventScore {
t.Helper()
return mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(9)},
Profiles: map[int64]ScoringProfile{9: teamTop6()},
Entries: entries,
TeamRanks: ranks,
})
}
// 口径 2:团体第 1 名 15 分,联队的两个单位各 7.5 分(奇数分平均拆,允许 .5)。
func TestJointTeamSplitsPointsEvenly(t *testing.T) {
// 团体报名项(athlete_id=0)+ 份额 [A,B],名次录在主单位 A 上。
entries, _ := fixture([]participant{{entryID: 1, teamID: 1, shares: jointShares(1, 2)}})
es := scoreTeamManualWithEntries(t, entries, TeamManualRank{TeamID: 1, Rank: 1})
pts := pointsByTeam(es.Ledger)
if !nearlyEqual(pts[1], 7.5) || !nearlyEqual(pts[2], 7.5) {
t.Fatalf("联队第 1 名应各得 7.5 分,得到 A=%v B=%v(流水:%+v)", pts[1], pts[2], es.Ledger)
}
// 拆出来的份数之和必须正好等于原来那一份,否则「一个单位改成联队」会悄悄改变总分。
if !nearlyEqual(pts[1]+pts[2], 15) {
t.Fatalf("两份之和应等于 15,得到 %v", pts[1]+pts[2])
}
// 每一条份额各是一条流水(各有 team_id),名次本身只有一条。
if len(es.Ledger) != 2 {
t.Fatalf("应产出 2 条流水(A、B 各一条),得到 %d 条:%+v", len(es.Ledger), es.Ledger)
}
for _, l := range es.Ledger {
if l.AthleteID != 0 {
t.Fatalf("团体通道的流水不该挂个人:%+v", l)
}
}
if len(es.Ranked) != 1 || len(es.Ranked[0].Shares) != 2 {
t.Fatalf("名次明细应只有一条、带两份份额:%+v", es.Ranked)
}
}
// 名次直接录在联队报名项上(成绩录入页填名次,不走按单位录的团体名次)也必须按份额拆。
func TestJointTeamSplitsPointsFromEntryRank(t *testing.T) {
entries, results := fixture([]participant{{
entryID: 1, teamID: 1, shares: jointShares(1, 2), rank: 1, noValue: true,
}})
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(9)},
Profiles: map[int64]ScoringProfile{9: teamTop6()},
Entries: entries, Results: results,
})
pts := pointsByTeam(es.Ledger)
if !nearlyEqual(pts[1], 7.5) || !nearlyEqual(pts[2], 7.5) {
t.Fatalf("联队报名项上的第 1 名应各得 7.5 分,得到 A=%v B=%v", pts[1], pts[2])
}
}
// 口径 3:奖牌整枚各记——联队拿到的金牌,两个单位各记 1 枚完整金牌。
func TestJointTeamMedalsAreWholeForEachMember(t *testing.T) {
entries, _ := fixture([]participant{{entryID: 1, teamID: 1, shares: jointShares(1, 2)}})
es := scoreTeamManualWithEntries(t, entries, TeamManualRank{TeamID: 1, Rank: 1})
rows := teamMedalRows(t, es.Ledger, es.Ranked, 1, 2, 3)
if gold, _, _ := medalOf(rows, 1); gold != 1 {
t.Fatalf("主单位 A 应记 1 枚金牌,得到 %d 枚(%+v)", gold, rows)
}
if gold, _, _ := medalOf(rows, 2); gold != 1 {
t.Fatalf("成员单位 B 应记 1 枚金牌(不是 0.5 枚),得到 %d 枚(%+v)", gold, rows)
}
if gold, _, _ := medalOf(rows, 3); gold != 0 {
t.Fatalf("没参赛的单位不该有奖牌,得到 %d 枚", gold)
}
}
// 可配权重:2:1 时积分按 2:1 拆,奖牌仍是各 1 枚(两个口径互不影响)。
func TestJointTeamWeightsAreConfigurable(t *testing.T) {
entries, _ := fixture([]participant{{
entryID: 1, teamID: 1, shares: weightedShares(int64(1), 2.0, int64(2), 1.0),
}})
es := scoreTeamManualWithEntries(t, entries, TeamManualRank{TeamID: 1, Rank: 1})
pts := pointsByTeam(es.Ledger)
if !nearlyEqual(pts[1], 10) || !nearlyEqual(pts[2], 5) {
t.Fatalf("2:1 的权重应拆成 10 / 5,得到 A=%v B=%v(流水:%+v)", pts[1], pts[2], es.Ledger)
}
if !nearlyEqual(pts[1]+pts[2], 15) {
t.Fatalf("2:1 拆分后两份之和应仍是 15,得到 %v", pts[1]+pts[2])
}
rows := teamMedalRows(t, es.Ledger, es.Ranked, 1, 2)
if gold, _, _ := medalOf(rows, 2); gold != 1 {
t.Fatalf("权重只影响积分,奖牌仍是整枚各记:B 应 1 枚金牌,得到 %d 枚", gold)
}
}
// 份额上的 medal 开关:显式关掉时该单位不记这枚奖牌(积分不受影响)。
func TestJointTeamMedalCanBeTurnedOffPerMember(t *testing.T) {
noMedal := false
shares := []TeamShare{{TeamID: 1, Medal: &noMedal}, {TeamID: 2}}
entries, _ := fixture([]participant{{entryID: 1, teamID: 1, shares: shares}})
es := scoreTeamManualWithEntries(t, entries, TeamManualRank{TeamID: 1, Rank: 1})
rows := teamMedalRows(t, es.Ledger, es.Ranked, 1, 2)
if gold, _, _ := medalOf(rows, 1); gold != 0 {
t.Fatalf("显式关掉奖牌的成员单位不该记牌,得到 %d 枚", gold)
}
if gold, _, _ := medalOf(rows, 2); gold != 1 {
t.Fatalf("没关掉的那个单位仍记 1 枚,得到 %d 枚", gold)
}
pts := pointsByTeam(es.Ledger)
if !nearlyEqual(pts[1], 7.5) || !nearlyEqual(pts[2], 7.5) {
t.Fatalf("奖牌开关不该影响积分分配,得到 A=%v B=%v", pts[1], pts[2])
}
}
// 份额 × 通道开关(count_to_team / count_to_medals)的叠加矩阵。
//
// 这一处最容易错:份额只改「这一份记在哪个单位头上」,两个开关仍然按通道整体决定
// 这条流水/名次算不算数。四种组合都必须能单独说清。
func TestJointTeamSharesWithChannelSwitches(t *testing.T) {
countToTeam := func(v bool) *bool { return &v }
t.Run("团体通道 + count_to_team=1:两份都进单位总分", func(t *testing.T) {
entries, _ := fixture([]participant{{entryID: 1, teamID: 1, shares: jointShares(1, 2)}})
es := scoreTeamManualWithEntries(t, entries, TeamManualRank{TeamID: 1, Rank: 1})
sums := SumByTeam(es.Ledger, 1)
if !nearlyEqual(sums[1].Total, 7.5) || !nearlyEqual(sums[2].Total, 7.5) {
t.Fatalf("两个单位各应得 7.5 分(仅记单位通道),得到 A=%+v B=%+v", sums[1], sums[2])
}
if !nearlyEqual(sums[1].TeamOnly, 7.5) {
t.Fatalf("团体通道的流水应记在「仅记单位」一栏,得到 %+v", sums[1])
}
})
t.Run("团体通道 + count_to_team=0:流水照记,但不进任何单位的总分", func(t *testing.T) {
ch := teamManualChannel(9)
ch.CountToTeam = countToTeam(false)
entries, _ := fixture([]participant{{entryID: 1, teamID: 1, shares: jointShares(1, 2)}})
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{ch}, Profiles: map[int64]ScoringProfile{9: teamTop6()},
Entries: entries, TeamRanks: []TeamManualRank{{TeamID: 1, Rank: 1}},
})
if len(es.Ledger) != 2 {
t.Fatalf("流水要照记(每条份额一条),得到 %d 条", len(es.Ledger))
}
for _, l := range es.Ledger {
if l.CountToTeam {
t.Fatalf("通道关掉计入单位总分后,流水上的 count_to_team 应为 false:%+v", l)
}
}
if sums := SumByTeam(es.Ledger, 1); len(sums) != 0 {
t.Fatalf("关掉 count_to_team 后不该有任何单位分,得到 %+v", sums)
}
})
t.Run("个人通道 + count_to_team=1:两份都按个人分进各自单位(乘系数)", func(t *testing.T) {
entries, results := fixture([]participant{{
entryID: 1, athleteID: 101, teamID: 1, shares: jointShares(1, 2), value: 10,
}})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(9)}, Profiles: map[int64]ScoringProfile{9: individualTop6()},
Entries: entries, Results: results,
})
sums := SumByTeam(es.Ledger, 1)
if !nearlyEqual(sums[1].Individual, 5) || !nearlyEqual(sums[2].Individual, 5) {
t.Fatalf("个人通道的两份应各进各自单位的个人分(各 5),得到 A=%+v B=%+v", sums[1], sums[2])
}
// 系数只乘一次:单位总分 = 系数 × 个人分,联队不会把这条成绩算两遍。
if sums := SumByTeam(es.Ledger, 0.5); !nearlyEqual(sums[1].Total, 2.5) {
t.Fatalf("系数 0.5 下 A 的总分应为 2.5,得到 %v", sums[1].Total)
}
// 个人榜不受份额影响:这两条流水加起来还是原来那 10 分。
if ath := SumByAthlete(es.Ledger); !nearlyEqual(ath[101], 10) {
t.Fatalf("个人榜上该运动员应仍是 10 分,得到 %v", ath[101])
}
})
t.Run("个人通道 + count_to_team=0:只记个人榜,不进任何单位总分", func(t *testing.T) {
ch := athleteResultChannel(9)
ch.CountToTeam = countToTeam(false)
entries, results := fixture([]participant{{
entryID: 1, athleteID: 101, teamID: 1, shares: jointShares(1, 2), value: 10,
}})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{ch}, Profiles: map[int64]ScoringProfile{9: individualTop6()},
Entries: entries, Results: results,
})
if sums := SumByTeam(es.Ledger, 1); len(sums) != 0 {
t.Fatalf("count_to_team=0 的个人通道不该进单位总分,得到 %+v", sums)
}
if ath := SumByAthlete(es.Ledger); !nearlyEqual(ath[101], 10) {
t.Fatalf("个人榜照旧记 10 分,得到 %v", ath[101])
}
})
t.Run("count_to_medals=0:不发奖牌(积分照发)", func(t *testing.T) {
ch := teamManualChannel(9)
no := false
ch.CountToMedals = &no
entries, _ := fixture([]participant{{entryID: 1, teamID: 1, shares: jointShares(1, 2)}})
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{ch}, Profiles: map[int64]ScoringProfile{9: teamTop6()},
Entries: entries, TeamRanks: []TeamManualRank{{TeamID: 1, Rank: 1}},
})
rows := teamMedalRows(t, es.Ledger, es.Ranked, 1, 2)
if gold, _, _ := medalOf(rows, 1); gold != 0 {
t.Fatalf("关掉奖牌通道后 A 不该有金牌,得到 %d 枚", gold)
}
if gold, _, _ := medalOf(rows, 2); gold != 0 {
t.Fatalf("关掉奖牌通道后 B 不该有金牌,得到 %d 枚", gold)
}
if pts := pointsByTeam(es.Ledger); !nearlyEqual(pts[1], 7.5) {
t.Fatalf("奖牌开关不影响积分:A 应仍是 7.5,得到 %v", pts[1])
}
})
t.Run("个人通道 + count_to_team_medals=0:个人榜有牌、单位榜没有", func(t *testing.T) {
ch := athleteResultChannel(9)
no := false
ch.CountToTeamMedals = &no
entries, results := fixture([]participant{{
entryID: 1, athleteID: 101, teamID: 1, shares: jointShares(1, 2), value: 10,
}})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{ch}, Profiles: map[int64]ScoringProfile{9: individualTop6()},
Entries: entries, Results: results,
})
rows := teamMedalRows(t, es.Ledger, es.Ranked, 1, 2)
if gold, _, _ := medalOf(rows, 1); gold != 0 {
t.Fatalf("个人奖牌不并入单位榜时 A 不该有金牌,得到 %d 枚", gold)
}
ath := BuildAthleteStandings(StandingsInput{
Athletes: []Athlete{{ID: 101, Name: "选手A", TeamID: 1}},
Ledger: es.Ledger, Ranked: es.Ranked,
})
if len(ath) != 1 || ath[0].Gold != 1 {
t.Fatalf("个人榜上该运动员仍应有 1 枚金牌,得到 %+v", ath)
}
})
}
// 联队报名不影响普通报名项:同一个小项里既有联队又有单单位时,两边各按自己的份额落分。
func TestJointTeamCoexistsWithSingleTeamEntries(t *testing.T) {
// 项目按成绩升序排名(individualEvent 的 compare=asc),联队跑得更好,拿第 1 名。
entries, results := fixture([]participant{
{entryID: 1, teamID: 1, shares: jointShares(1, 2), value: 80},
{entryID: 2, teamID: 3, value: 90},
})
ev := teamEvent(1)
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{{ID: 4, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 9}},
Profiles: map[int64]ScoringProfile{9: teamTop6()},
Entries: entries, Results: results,
})
pts := pointsByTeam(es.Ledger)
if !nearlyEqual(pts[1], 7.5) || !nearlyEqual(pts[2], 7.5) {
t.Fatalf("联队第 1 名应各得 7.5,得到 A=%v B=%v", pts[1], pts[2])
}
if !nearlyEqual(pts[3], 10) {
t.Fatalf("单独报名的第 2 名应独得 10 分,得到 %v", pts[3])
}
}
// 没有配份额(老数据、普通报名)时引擎的产出必须与升级前逐位一致:
// 一条流水、整份分、整枚牌,且拆分的浮点计算不能引入零头。
func TestWholeShareStaysExact(t *testing.T) {
entries, _ := fixture([]participant{{entryID: 1, teamID: 7}})
es := scoreTeamManualWithEntries(t, entries, TeamManualRank{TeamID: 7, Rank: 1})
if len(es.Ledger) != 1 {
t.Fatalf("独占 100%% 只该产出一条流水,得到 %d 条:%+v", len(es.Ledger), es.Ledger)
}
if got := es.Ledger[0].Points; got != 15 {
t.Fatalf("独占时的积分应逐位等于 15,得到 %v", got)
}
if got := es.Ranked[0].Shares; len(got) != 1 || got[0].TeamID != 7 || got[0].Weight != 1 || got[0].Medal != nil {
t.Fatalf("独占时的份额应与升级前完全一致(TeamID=7 Weight=1 Medal=nil),得到 %+v", got)
}
rows := teamMedalRows(t, es.Ledger, es.Ranked, 7, 8)
if gold, _, _ := medalOf(rows, 7); gold != 1 {
t.Fatalf("独占时该单位应记 1 枚金牌,得到 %d 枚", gold)
}
}
// splitSharePoints 的边界:权重非法(0 / 负数)按 1 处理,拆分之和恒等于总分。
func TestSplitSharePointsKeepsTotal(t *testing.T) {
cases := []struct {
name string
total float64
shares []TeamShare
want []float64
}{
{"独占", 15, []TeamShare{{TeamID: 1, Weight: 1}}, []float64{15}},
{"均分(奇数分)", 15, []TeamShare{{TeamID: 1}, {TeamID: 2}}, []float64{7.5, 7.5}},
{"2:1(除不尽)", 15, weightedShares(int64(1), 2.0, int64(2), 1.0), []float64{10, 5}},
{"三单位均分", 15, []TeamShare{{TeamID: 1}, {TeamID: 2}, {TeamID: 3}}, []float64{5, 5, 5}},
{"三点均分", 10, []TeamShare{{TeamID: 1}, {TeamID: 2}, {TeamID: 3}}, []float64{10.0 / 3, 10.0 / 3, 10.0 - 2*(10.0/3)}},
{"零权重按 1 处理", 15, weightedShares(int64(1), 0.0, int64(2), 1.0), []float64{7.5, 7.5}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := splitSharePoints(tc.total, tc.shares)
if len(got) != len(tc.want) {
t.Fatalf("份数 = %d,期望 %d(%v)", len(got), len(tc.want), got)
}
sum := 0.0
for i := range tc.want {
if !nearlyEqual(got[i], tc.want[i]) {
t.Errorf("第 %d 份 = %v,期望 %v(完整 %v)", i+1, got[i], tc.want[i], got)
}
sum += got[i]
}
if !nearlyEqual(sum, tc.total) {
t.Errorf("拆分之和 = %v,期望 %v(零头会让榜单印出 9.999999999999998)", sum, tc.total)
}
})
}
}