Files
Scoreboard/server/internal/httpapi/individual_team_test.go
T
AskaEth 6bd7bd1627 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 文件通过。
2026-09-10 12:02:00 +08:00

217 lines
9.9 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 httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// 「个人+团体」的算分口径(REST 端到端):
// - 个人通道按个人成绩排名,分只记在个人头上,不进单位总分;
// - 团体通道把同单位成员成绩取平均后排名,这部分才进单位总分。
func TestIndividualTeamDoubleCountStandings(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 换成「个人+团体」:成绩越大越好,团体成绩按成员平均。
env.exec(t, `UPDATE events SET event_type = 'individual_team', result_type = 'count',
compare = 'desc', avg_mode = 'valid' WHERE id = ?`, ids.event)
var individualProfile, teamProfile int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&individualProfile); err != nil {
t.Fatalf("读取个人模板: %v", err)
}
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
t.Fatalf("读取团体模板: %v", err)
}
// 请求体里个人通道显式关闭 countToTeam,团体通道不写该字段(缺省计入)。
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event),
map[string]any{"channels": []map[string]any{
{"scope": "athlete", "basis": "result", "profileId": individualProfile, "sortOrder": 0, "countToTeam": false},
{"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 1},
}}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("保存通道应 200,得到 %d %+v", status, env2.Error)
}
_, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/channels", ids.event), nil, true)
channels := decodeData[[]map[string]any](t, env2)
if len(channels) != 2 {
t.Fatalf("通道数 = %d,期望 2", len(channels))
}
if got := channels[0]["countToTeam"]; got != false {
t.Fatalf("个人通道 countToTeam = %v,期望 false", got)
}
if got := channels[1]["countToTeam"]; got != true {
t.Fatalf("缺省通道 countToTeam = %v,期望 true", got)
}
// 单位 A 一人 10,单位 B 一人 20:个人通道 8/10,团体通道 10/15。
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 10, "status": "ok"},
{"entryId": ids.entryB, "resultValue": 20, "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event,
}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
points := map[string]float64{}
for _, row := range decodeData[[]map[string]any](t, env2) {
points[row["teamCode"].(string)] = row["totalPoints"].(float64)
}
// 只算团体通道:A 第 2 名 10 分,B 第 1 名 15 分。
if points["A"] != 10 || points["B"] != 15 {
t.Fatalf("单位总分 = %v,期望 A:10 / B:15(个人通道不计入)", points)
}
// 个人榜照常保留个人通道的分:B(20)10 分排第一,A(10)8 分排第二。
_, env2 = env.do(client, http.MethodGet, "/api/standings/athletes", nil, false)
athletes := decodeData[[]map[string]any](t, env2)
if len(athletes) != 2 {
t.Fatalf("个人榜行数 = %d,期望 2", len(athletes))
}
if got := athletes[0]["totalPoints"].(float64); got != 10 {
t.Fatalf("个人榜榜首得分 = %v,期望 10", got)
}
if got := athletes[1]["totalPoints"].(float64); got != 8 {
t.Fatalf("个人榜第二名得分 = %v,期望 8(个人通道的分保留)", got)
}
// 单位积分明细与总积分榜同口径:不计入单位总分的流水不出现在明细里。
_, env2 = env.do(client, http.MethodGet,
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
breakdown := decodeData[map[string]any](t, env2)
if got := breakdown["totalPoints"].(float64); got != 10 {
t.Fatalf("单位 A 明细合计 = %v,期望 10(与总积分榜一致)", got)
}
items := breakdown["items"].([]any)
if len(items) != 1 {
t.Fatalf("单位 A 明细行数 = %d,期望 1(只有团体通道的流水)", len(items))
}
}
// avg_mode 决定团体平均值的分母:valid 只算有成绩的人,all 把没成绩的人按 0 计入。
func TestIndividualTeamAvgModeChangesRanking(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// A 两人(只有一人录成绩 10),B 两人(6 / 7)。
eventID := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status, avg_mode)
VALUES (?, 'E_BOTH', '团体引体向上', 'individual_team', 'auto', 'count',
'desc', 1, 1, 'ongoing', 'valid')`, ids.sport)
cat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
athA2 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王五', '男', ?, 1)`, ids.teamA)
athB2 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('赵六', '男', ?, 1)`, ids.teamB)
entryA1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamA, ids.athleteA)
env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamA, athA2)
entryB1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamB, ids.athB)
entryB2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamB, athB2)
var teamProfile int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
t.Fatalf("读取团体模板: %v", err)
}
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'aggregate', ?, 0)`, eventID, teamProfile)
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": eventID,
"results": []map[string]any{
{"entryId": entryA1, "resultValue": 10, "status": "ok"},
{"entryId": entryB1, "resultValue": 6, "status": "ok"},
{"entryId": entryB2, "resultValue": 7, "status": "ok"},
},
}, true)
standings := func(name string) map[string]float64 {
t.Helper()
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": eventID,
}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("%s发布应 200,得到 %d %+v", name, status, env2.Error)
}
_, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
out := map[string]float64{}
for _, row := range decodeData[[]map[string]any](t, env2) {
out[row["teamCode"].(string)] = row["totalPoints"].(float64)
}
return out
}
// valid:A 平均 10/1 = 10,B 平均 6.5 → A 第一 15 分、B 第二 10 分。
if got := standings("valid"); got["A"] != 15 || got["B"] != 10 {
t.Fatalf("valid 口径总分 = %v,期望 A:15 / B:10", got)
}
// all:A 平均 (10+0)/2 = 5,B 平均 6.5 → B 第一 15 分、A 第二 10 分。
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), map[string]any{
"sportId": ids.sport, "code": "E_BOTH", "name": "团体引体向上", "eventType": "individual_team",
"resultMode": "auto", "resultType": "count", "compare": "desc", "scored": true,
"multiplier": 1, "status": "ongoing", "avgMode": "all",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("改 avgMode 应 200,得到 %d %+v", status, env2.Error)
}
if got := decodeData[map[string]any](t, env2)["avgMode"]; got != "all" {
t.Fatalf("改回 avgMode = %v,期望 all", got)
}
if got := standings("all"); got["A"] != 10 || got["B"] != 15 {
t.Fatalf("all 口径总分 = %v,期望 A:10 / B:15", got)
}
}
// avgMode 的读写与校验:缺省 valid、非法值 400。
func TestEventAvgModeRoundTrip(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 老客户端不传 avgMode:按 valid 存。
status, env2 := env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "E_ALL", "name": "双计项", "eventType": "individual_team",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("建项目应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
if created["avgMode"] != "valid" {
t.Fatalf("缺省 avgMode = %v,期望 valid", created["avgMode"])
}
eventID := int64(created["id"].(float64))
_, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d", eventID), nil, true)
if got := decodeData[map[string]any](t, env2); got["avgMode"] != "valid" {
t.Fatalf("读回 avgMode = %v,期望 valid", got["avgMode"])
}
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), map[string]any{
"sportId": ids.sport, "code": "E_ALL", "name": "双计项", "eventType": "individual_team",
"avgMode": "bogus",
}, true)
if status != http.StatusBadRequest {
t.Fatalf("非法 avgMode 应 400,得到 %d %+v", status, env2.Error)
}
}