feat: 「个人+团体」项目真正落地——通道级不计入单位 + 队内平均排名
口径(用户确认): - 个人分按个人成绩排名给,**不计入单位总分**(下沉到通道级开关,不再动全局系数) - 团体成绩 = 每队成员成绩的**平均值** → 排名(并列顺延)→ 团体模板给分 → 计入单位总分 - 平均值口径是**小项级单选项**:默认「只算有效成绩」;「全部报名人员计入」= 分母为报名条数、 没成绩按 0 加入分子;在**成绩录入页**就地切换并立即保存 服务端: - 迁移 0017:score_ledger 加 count_to_team(重建,无子表);event_score_channels 加 count_to_team; events 加 avg_mode(默认 valid) - 规则包 Channel 加 countToTeam(缺省 true);channelTemplates 支持 individual_team → 默认 athlete(result, 不计入单位) + team(aggregate 队内平均) - 计分引擎:SumByTeam 跳过 count_to_team=0 的流水;实现 basis=aggregate 的队内平均团体排名 (valid 分母=有有效成绩人数;all 分母=报名人数;某队 0 有效成绩不参与) 前端: - 计分通道页:每行通道「计入单位总分」勾选 + basis 下拉「按队内平均」 - 成绩录入页:仅有队内平均通道的小项显示「平均值口径」单选(默认只算有效成绩),改动即存 - 录入页「实时积分预览」按新口径对齐(原先按主体求和且双通道重复计入,与发布后不一致) - 大屏/管理端团体成绩只显示数值,不出现「平均」字样 自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 134/134;console 16 文件通过。
This commit is contained in:
1 parent
592e47aef7
commit
6bd7bd1627
47 files changed
+1770
-153
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode } from '../pages/avgMode'
|
||||
|
||||
// 「平均值口径」只在录入页改;项目没有组件测试工具,界面部分按仓库既有做法读源码断言。
|
||||
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||||
const CHANNELS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/ChannelsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('载入口径时归一化并清掉上一次的保存状态', () => {
|
||||
const state = loadAvgMode(108, 'all')
|
||||
assert.deepEqual(state, { eventId: 108, saved: 'all', mode: 'all', saving: false })
|
||||
// 旧数据 / 非法值一律按 valid。
|
||||
assert.equal(loadAvgMode(108, undefined).mode, 'valid')
|
||||
assert.equal(loadAvgMode(null, 'avg').mode, 'valid')
|
||||
})
|
||||
|
||||
test('选同一个口径或上一次保存未返回时不发请求', () => {
|
||||
const state = loadAvgMode(108, 'valid')
|
||||
|
||||
const same = selectAvgMode(state, 'valid')
|
||||
assert.equal(same.shouldSave, false)
|
||||
assert.equal(same.state, state)
|
||||
|
||||
const changed = selectAvgMode(state, 'all')
|
||||
assert.equal(changed.shouldSave, true)
|
||||
assert.equal(changed.state.mode, 'all')
|
||||
assert.equal(changed.state.saving, true)
|
||||
assert.equal(changed.state.saved, 'valid')
|
||||
|
||||
// 在途请求只允许一个:保存中再点另一个口径被忽略。
|
||||
const again = selectAvgMode(changed.state, 'valid')
|
||||
assert.equal(again.shouldSave, false)
|
||||
assert.equal(again.state.mode, 'all')
|
||||
})
|
||||
|
||||
test('保存成功记住新口径,失败退回原口径', () => {
|
||||
const pending = selectAvgMode(loadAvgMode(108, 'valid'), 'all').state
|
||||
|
||||
const ok = settleAvgMode(pending, true)
|
||||
assert.deepEqual(ok, { eventId: 108, saved: 'all', mode: 'all', saving: false })
|
||||
|
||||
const failed = settleAvgMode(pending, false)
|
||||
assert.deepEqual(failed, { eventId: 108, saved: 'valid', mode: 'valid', saving: false })
|
||||
|
||||
// 没有在途请求时回落是空操作。
|
||||
const idle = loadAvgMode(108, 'valid')
|
||||
assert.equal(settleAvgMode(idle, true), idle)
|
||||
})
|
||||
|
||||
test('口径选项覆盖两种分母口径与「按 0 计入」提示', () => {
|
||||
assert.deepEqual(
|
||||
AVG_MODE_OPTIONS.map((option) => option.value),
|
||||
['valid', 'all'],
|
||||
)
|
||||
assert.ok(AVG_MODE_OPTIONS.some((option) => option.label === '只算有效成绩'))
|
||||
assert.ok(AVG_MODE_OPTIONS.some((option) => option.label === '全部报名人员都计入' && option.hint === '没成绩的按 0 计入平均'))
|
||||
})
|
||||
|
||||
test('录入页只在存在 aggregate 通道时显示口径选择,并整体回写小项', () => {
|
||||
assert.match(ENTRY_SOURCE, /hasAggregateChannel\(channels\.value\)/)
|
||||
assert.match(ENTRY_SOURCE, /平均值口径/)
|
||||
assert.match(ENTRY_SOURCE, /v-if="showAvgMode"/)
|
||||
// 口径改动走 PUT /api/events/{id},不能混进成绩草稿的保存流程。
|
||||
assert.match(ENTRY_SOURCE, /client\.events\.update\(event\.id, eventInputOf\(event, \{ avgMode: mode \}\)\)/)
|
||||
})
|
||||
|
||||
test('计分通道页支持「按队内平均」与「计入单位总分」开关', () => {
|
||||
assert.match(CHANNELS_SOURCE, /\{ value: 'aggregate', label: '按队内平均' \}/)
|
||||
assert.match(CHANNELS_SOURCE, /团体成绩 = 该单位成员成绩的平均值/)
|
||||
assert.match(CHANNELS_SOURCE, /计入单位总分/)
|
||||
assert.match(CHANNELS_SOURCE, /只记在个人头上/)
|
||||
assert.match(CHANNELS_SOURCE, /countToTeam: row\.countToTeam/)
|
||||
})
|
||||
@@ -24,6 +24,7 @@ function makeEvent(overrides: Partial<Event> = {}): Event {
|
||||
venue: null,
|
||||
sortOrder: 1,
|
||||
remark: null,
|
||||
avgMode: 'valid',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -33,7 +34,7 @@ function makeProfile(overrides: Partial<ScoringProfile> = {}): ScoringProfile {
|
||||
}
|
||||
|
||||
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
|
||||
return { id: 1, eventId: 1, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, ...overrides }
|
||||
return { id: 1, eventId: 1, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, countToTeam: true, ...overrides }
|
||||
}
|
||||
|
||||
function makeRow(entryId: number, resultValue: number | null, teamId: number, rank: number | null = null): ScorableRow {
|
||||
@@ -258,6 +259,83 @@ test('团体行(athleteId 为 null)按单位通道正常拿名次与积分',
|
||||
close(result.teamPoints.get(101) ?? 0, 6)
|
||||
})
|
||||
|
||||
test('countToTeam=false 的通道只算个人分,不进单位总分', () => {
|
||||
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }] })
|
||||
const result = computeEventScore(
|
||||
[makeRow(1, 10, 100)],
|
||||
makeContext({
|
||||
channels: [makeChannel({ basis: 'result', countToTeam: false })],
|
||||
profiles: [profile],
|
||||
weight: 2,
|
||||
}),
|
||||
)
|
||||
close(result.rowPoints.get(1) ?? 0, 10)
|
||||
close(result.rowTeamPoints.get(1) ?? 0, 0)
|
||||
close(result.teamPoints.get(100) ?? 0, 0)
|
||||
// 名次照算,只是分不落到单位头上。
|
||||
assert.equal(result.ranks.get(1), 1)
|
||||
})
|
||||
|
||||
test('「个人+团体」:个人通道不进单位总分,团体通道按队内平均记分', () => {
|
||||
const athleteProfile = makeProfile({ id: 1, rules: [{ rank: 1, points: 10 }, { rank: 2, points: 8 }] })
|
||||
const teamProfile = makeProfile({ id: 2, rules: [{ rank: 1, points: 15 }, { rank: 2, points: 8 }] })
|
||||
const rows = [makeRow(1, 18, 100), makeRow(2, 20, 100), makeRow(3, 16, 101)]
|
||||
const result = computeEventScore(
|
||||
rows,
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'individual_team', resultType: 'count', compare: 'desc' }),
|
||||
channels: [
|
||||
makeChannel({ id: 1, scope: 'athlete', basis: 'result', profileId: 1, countToTeam: false }),
|
||||
makeChannel({ id: 2, scope: 'team', basis: 'aggregate', profileId: 2, countToTeam: true }),
|
||||
],
|
||||
profiles: [athleteProfile, teamProfile],
|
||||
}),
|
||||
)
|
||||
// 个人分照给:20 排第 1、18 排第 2、16 排第 3(录取前 2 名)。
|
||||
close(result.rowPoints.get(2) ?? 0, 10)
|
||||
close(result.rowPoints.get(1) ?? 0, 8)
|
||||
// 单位总分只来自团体通道:100 队平均 19 排第 1,101 队平均 16 排第 2。
|
||||
// 个人通道若也计入,100 队会多出个人第 1 名的 10 分。
|
||||
close(result.teamPoints.get(100) ?? 0, 15)
|
||||
close(result.teamPoints.get(101) ?? 0, 8)
|
||||
})
|
||||
|
||||
test('aggregate 通道按队内平均排名(valid 只算有效成绩)', () => {
|
||||
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }, { rank: 2, points: 5 }] })
|
||||
const result = computeEventScore(
|
||||
[makeRow(1, 10, 100), makeRow(2, 20, 100), makeRow(3, 16, 101)],
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'team', resultType: 'count', compare: 'desc' }),
|
||||
channels: [makeChannel({ scope: 'team', basis: 'aggregate' })],
|
||||
profiles: [profile],
|
||||
}),
|
||||
)
|
||||
// 100 队平均 15 < 101 队平均 16,desc 下 101 队第一。
|
||||
close(result.teamPoints.get(101) ?? 0, 10)
|
||||
close(result.teamPoints.get(100) ?? 0, 5)
|
||||
})
|
||||
|
||||
test('avgMode=all 把没成绩的报名项按 0 计入平均分母', () => {
|
||||
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }, { rank: 2, points: 5 }] })
|
||||
const makeRows = (): ScorableRow[] => [makeRow(1, 20, 100), makeRow(2, 20, 100), makeRow(3, null, 100), makeRow(4, 16, 101)]
|
||||
const context = (avgMode: Event['avgMode']): ScoreContext =>
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'team', resultType: 'count', compare: 'desc', avgMode }),
|
||||
channels: [makeChannel({ scope: 'team', basis: 'aggregate' })],
|
||||
profiles: [profile],
|
||||
})
|
||||
|
||||
// valid:100 队只按两条有效成绩平均(20)→ 第一。
|
||||
const valid = computeEventScore(makeRows(), context('valid'))
|
||||
close(valid.teamPoints.get(100) ?? 0, 10)
|
||||
close(valid.teamPoints.get(101) ?? 0, 5)
|
||||
|
||||
// all:第 3 条没成绩按 0 计入,100 队平均 40/3 → 落到第二。
|
||||
const all = computeEventScore(makeRows(), context('all'))
|
||||
close(all.teamPoints.get(101) ?? 0, 10)
|
||||
close(all.teamPoints.get(100) ?? 0, 5)
|
||||
})
|
||||
|
||||
test('团体行只配了录入名次(无计分通道)时也能算出名次', () => {
|
||||
const rows: ScorableRow[] = [
|
||||
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
|
||||
|
||||
Reference in new issue
Block a user