Files
Scoreboard/web/src/shared/tests/joint-teams.test.ts
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

155 lines
5.5 KiB
TypeScript
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.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
isJointTeam,
jointTeamLabel,
normalizeTeamShares,
shareText,
shareWeightOf,
splitSharePoints,
teamSharePayload,
} from '../jointTeams'
import type { EntryTeamShare } from '../types'
/**
* 联队的展示与拆分口径(devdocs/19)。要钉住的是:
* 1. **普通报名一字不变**:没有 teamShares(老服务端)/ 只有一条时,收口成主单位独占 100%,
* 拼出来的名字就是主单位名,界面上与升级前完全一样;
* 2. 份额是**相对权重**:1+1 是 50/50,2+1 是 67/33(不是 200/100);
* 3. 积分平均拆、接受 .5:15 分给两家就是 7.5 + 7.5,且 2:1 这种除不尽的权重
* 两份之和必须**逐位等于**总分(末位取余数,不许出现 9.999999999999998)。
*/
function share(over: Partial<EntryTeamShare> & Pick<EntryTeamShare, 'teamId'>): EntryTeamShare {
return { weight: 1, ...over }
}
test('没有 teamShares(老服务端)时按主单位独占 100%', () => {
const shares = normalizeTeamShares(undefined, {
teamId: 3,
weight: 1,
teamCode: 'A',
teamName: '第一分公司',
})
assert.equal(shares.length, 1)
assert.deepEqual(shares[0], {
teamId: 3,
teamCode: 'A',
teamName: '第一分公司',
weight: 1,
percent: 100,
medal: true,
})
assert.equal(isJointTeam(shares), false, '单单位不是联队')
})
test('份额整组为空且没有主单位时返回空数组,不造 0 号单位', () => {
assert.deepEqual(normalizeTeamShares([], null), [])
assert.deepEqual(normalizeTeamShares([], { teamId: 0, weight: 1 }), [])
})
test('1+1 均分:各 50%,联队名把两个单位名都写全', () => {
const shares = normalizeTeamShares([
share({ teamId: 1, teamCode: 'A', teamName: '第一分公司' }),
share({ teamId: 2, teamCode: 'B', teamName: '第二分公司' }),
])
assert.equal(isJointTeam(shares), true)
assert.deepEqual(
shares.map((item) => item.percent),
[50, 50],
)
assert.equal(jointTeamLabel(shares), '第一分公司+第二分公司联队')
assert.equal(shareText(shares[0] as (typeof shares)[number]), '第一分公司 50%')
})
test('2+1 是相对权重:67% 与 33%(不是 200% / 100%)', () => {
const shares = normalizeTeamShares([
share({ teamId: 1, weight: 2, teamCode: 'A', teamName: '第一分公司' }),
share({ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' }),
])
assert.deepEqual(
shares.map((item) => item.percent),
[67, 33],
)
// 非均分时把权重也写出来,现场一眼看得出「为什么不是一半一半」。
assert.equal(shareText(shares[0] as (typeof shares)[number]), '第一分公司 67%(权重 2)')
})
test('权重缺省 / 0 / 负数一律按 1(均分),不产生 NaN%', () => {
assert.equal(shareWeightOf(undefined), 1)
assert.equal(shareWeightOf(0), 1)
assert.equal(shareWeightOf(-3), 1)
assert.equal(shareWeightOf(Number.NaN), 1)
const shares = normalizeTeamShares([
{ teamId: 1, weight: 0, teamName: '甲' },
{ teamId: 2, weight: Number.NaN, teamName: '乙' },
])
assert.deepEqual(
shares.map((item) => item.percent),
[50, 50],
)
})
test('奖牌缺省 true;只有显式 false 才是「这枚牌不记给它」', () => {
const shares = normalizeTeamShares([
share({ teamId: 1, teamName: '甲' }),
share({ teamId: 2, teamName: '乙', medal: false }),
])
assert.equal(shares[0]?.medal, true)
assert.equal(shares[1]?.medal, false)
})
test('名字缺失时退回单位编码,再退回「单位 id」,不留空白格', () => {
const shares = normalizeTeamShares([
share({ teamId: 1, teamCode: 'A' }),
share({ teamId: 2 }),
])
assert.equal(jointTeamLabel(shares), 'A+单位 2联队')
})
test('脏数据(非法 teamId / 重复单位)在读取侧丢掉,界面不列两遍', () => {
const shares = normalizeTeamShares([
share({ teamId: 1, teamName: '甲' }),
share({ teamId: 1, teamName: '甲的重复行' }),
share({ teamId: 0, teamName: '零号' }),
])
assert.equal(shares.length, 1)
assert.equal(shares[0]?.teamName, '甲')
})
test('积分按份额拆:15 分给两家是 7.5 + 7.5(接受 .5)', () => {
const split = splitSharePoints(15, [{ weight: 1 }, { weight: 1 }])
assert.deepEqual(split, [7.5, 7.5])
})
test('除不尽的权重:各份之和逐位等于总分,不出现浮点零头', () => {
const total = 15
const split = splitSharePoints(total, [{ weight: 2 }, { weight: 1 }])
assert.equal(split[0]! + split[1]!, total, '两份之和必须正好等于总分')
assert.equal(split[1], 15 - split[0]!)
assert.equal(split[0], 10)
assert.equal(split[1], 5)
// 三单位 1:1:1 同样要闭合。
const three = splitSharePoints(15, [{ weight: 1 }, { weight: 1 }, { weight: 1 }])
assert.equal(
three.reduce((sum, part) => sum + part, 0),
15,
)
})
test('单条份额(普通报名)拆出来就是原分,与升级前逐位相同', () => {
assert.deepEqual(splitSharePoints(15, [{ weight: 1 }]), [15])
assert.deepEqual(splitSharePoints(7.5, [{ weight: 2 }]), [7.5])
})
test('写侧载荷只带 teamId / weight(+ 显式关闭的 medal),不带读侧展示字段', () => {
const payload = teamSharePayload([
{ teamId: 1, weight: 2, teamCode: 'A', teamName: '第一分公司' },
{ teamId: 2, weight: 1, medal: false, teamCode: 'B', teamName: '第二分公司' },
])
assert.deepEqual(payload, [
{ teamId: 1, weight: 2 },
{ teamId: 2, weight: 1, medal: false },
])
})