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) } }