## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修) - 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同** (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致 - 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到 - 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢); 另加守卫「compare=none 的小项不得配 basis=result/aggregate」 - 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0 (7 人三人并列第 5 时第 5、6 名的分也一起没了) - 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分 ## 现场反馈(主人实测报的) - 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组), 与「作废」语义分开;取消路径不产生任何副作用 - 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」: 管理端、大屏覆盖层、xlsx 备注列三处同源 - 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦 - 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响) ## 新功能 - 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切) ## 安装器 - 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、 安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540 - 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败 自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。 端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
353 lines
15 KiB
Go
353 lines
15 KiB
Go
package httpapi_test
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
// changeLogCount 统计某实体某动作的变更记录数。
|
|
func changeLogCount(t *testing.T, env *testEnv, entity, action string) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM change_log WHERE entity_type = ? AND action = ?`,
|
|
entity, action).Scan(&n); err != nil {
|
|
t.Fatalf("统计 change_log: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// 配置写操作必须写 change_log,否则 broadcastChanges 推空、大屏不刷新;
|
|
// 同时校验数值范围,浏览器 min/max 只是提示,不能当服务端校验。
|
|
func TestSettingsPatchValidationAndChangeLog(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
env.createUser("admin", "admin-pass", "admin")
|
|
client, _ := env.client()
|
|
env.login(client, "admin", "admin-pass")
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
body map[string]any
|
|
}{
|
|
{"轮播间隔过小", map[string]any{"display": map[string]any{"rotateSeconds": 1}}},
|
|
{"轮播间隔过大", map[string]any{"display": map[string]any{"rotateSeconds": 121}}},
|
|
{"折算系数为 0", map[string]any{"standings": map[string]any{"individualToTeamWeight": 0}}},
|
|
{"折算系数为负", map[string]any{"standings": map[string]any{"individualToTeamWeight": -1}}},
|
|
} {
|
|
status, env2 := env.do(client, http.MethodPut, "/api/settings", tc.body, true)
|
|
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
|
|
t.Fatalf("%s应 400 VALIDATION_FAILED,得到 %d %+v", tc.name, status, env2.Error)
|
|
}
|
|
}
|
|
|
|
before := changeLogCount(t, env, "settings", "upsert")
|
|
status, env2 := env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
|
"event": map[string]any{"name": "秋季运动会", "subtitle": ""},
|
|
"display": map[string]any{"rotateSeconds": 30},
|
|
"standings": map[string]any{"individualToTeamWeight": 0.5},
|
|
}, true)
|
|
if status != http.StatusOK || !env2.OK {
|
|
t.Fatalf("合法配置应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
if got := changeLogCount(t, env, "settings", "upsert"); got != before+1 {
|
|
t.Fatalf("配置写操作应新增 1 条 settings change_log,得到 %d → %d", before, got)
|
|
}
|
|
|
|
_, env2 = env.do(client, http.MethodGet, "/api/settings", nil, false)
|
|
settings := decodeData[map[string]any](t, env2)
|
|
event := settings["event"].(map[string]any)
|
|
if event["name"] != "秋季运动会" || event["subtitle"] != nil {
|
|
t.Fatalf("空串应归一为 null、名称应落库: %+v", event)
|
|
}
|
|
display := settings["display"].(map[string]any)
|
|
if display["rotateSeconds"].(float64) != 30 {
|
|
t.Fatalf("轮播间隔应落库为 30: %+v", display)
|
|
}
|
|
}
|
|
|
|
// 破纪录登记必须基于该项目一条已发布且状态正常的成绩,否则 recorder 可凭空加分。
|
|
func TestRecordBreakRequiresPublishedResult(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")
|
|
|
|
body := map[string]any{"eventId": ids.event, "entryId": ids.entryA, "level": "meet",
|
|
"resultValue": 10.5, "resultText": "10.50"}
|
|
|
|
// 没有成绩。
|
|
status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", body, true)
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("无成绩时登记破纪录应 400,得到 %d %+v", status, env2.Error)
|
|
}
|
|
|
|
// 只有草稿成绩。
|
|
env.exec(t, `INSERT INTO results (event_id, entry_id, rank, result_value, status, state)
|
|
VALUES (?, ?, 1, 10.5, 'ok', 'draft')`, ids.event, ids.entryA)
|
|
status, env2 = env.do(client, http.MethodPost, "/api/record-breaks", body, true)
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("草稿成绩登记破纪录应 400,得到 %d %+v", status, env2.Error)
|
|
}
|
|
|
|
// 已发布但小项不可破纪录。
|
|
env.exec(t, `UPDATE results SET state = 'published' WHERE entry_id = ?`, ids.entryA)
|
|
env.exec(t, `UPDATE events SET record_eligible = 0 WHERE id = ?`, ids.event)
|
|
status, env2 = env.do(client, http.MethodPost, "/api/record-breaks", body, true)
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("不可破纪录项目应 400,得到 %d %+v", status, env2.Error)
|
|
}
|
|
|
|
// 报名项不属于该项目。
|
|
env.exec(t, `UPDATE events SET record_eligible = 1 WHERE id = ?`, ids.event)
|
|
otherEvent := env.insert(t,
|
|
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
|
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
|
|
status, env2 = env.do(client, http.MethodPost, "/api/record-breaks",
|
|
map[string]any{"eventId": otherEvent, "entryId": ids.entryA, "level": "meet",
|
|
"resultValue": 10.5, "resultText": "10.50"}, true)
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("跨项目登记破纪录应 400,得到 %d %+v", status, env2.Error)
|
|
}
|
|
|
|
// 合法:已发布成绩 + 可破纪录项目。
|
|
status, env2 = env.do(client, http.MethodPost, "/api/record-breaks", body, true)
|
|
if status != http.StatusOK || !env2.OK {
|
|
t.Fatalf("合法登记应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
}
|
|
|
|
// 单位积分明细的合计必须与总积分榜同口径:个人流水乘折算系数。
|
|
func TestTeamBreakdownAppliesIndividualWeight(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.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
|
"eventId": ids.event,
|
|
"results": []map[string]any{
|
|
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
|
|
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
|
|
},
|
|
}, true)
|
|
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
|
"eventId": ids.event,
|
|
}, true); status != http.StatusOK {
|
|
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
env.exec(t, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
|
|
|
|
status, env2 := env.do(client, http.MethodGet,
|
|
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
|
|
if status != http.StatusOK || !env2.OK {
|
|
t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
breakdown := decodeData[map[string]any](t, env2)
|
|
if got := breakdown["totalPoints"].(float64); got != 5 {
|
|
t.Fatalf("合计应按系数折算为 5,得到 %v", got)
|
|
}
|
|
items := breakdown["items"].([]any)
|
|
if len(items) != 1 || items[0].(map[string]any)["points"].(float64) != 5 {
|
|
t.Fatalf("明细行也应按系数折算: %+v", items)
|
|
}
|
|
|
|
// 与总积分榜一致。
|
|
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
|
|
for _, row := range decodeData[[]map[string]any](t, env2) {
|
|
if row["teamCode"] == "A" && row["totalPoints"].(float64) != 5 {
|
|
t.Fatalf("总积分榜与明细合计应一致: %+v", row)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 单位积分明细的「组别」列不能整列变空:聚合通道(按队内平均)产出的流水 entry_id 与 result_id
|
|
// 都为空,从 entries 取不到组别,接口要按「单位在本小项只参加了一个组别」回推那一个
|
|
// (与 export.loadTeamDetailRows 的组别回推口径一致)。
|
|
func TestTeamBreakdownAggregateRowKeepsCategory(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")
|
|
|
|
// 模拟聚合通道产出的流水:entry_id 与 result_id 都为空(见 isAggregateLedger)。
|
|
env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
|
|
source_type, source_id, points, count_to_team)
|
|
VALUES (?, NULL, ?, NULL, NULL, 'result', 99, 15, 1)`, ids.teamA, ids.event)
|
|
|
|
status, env2 := env.do(client, http.MethodGet,
|
|
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
|
|
if status != http.StatusOK || !env2.OK {
|
|
t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
items := decodeData[map[string]any](t, env2)["items"].([]any)
|
|
if len(items) != 1 {
|
|
t.Fatalf("明细行数 = %d,期望 1(只插了一条聚合流水)", len(items))
|
|
}
|
|
item := items[0].(map[string]any)
|
|
if got := item["categoryName"]; got != "男子" {
|
|
t.Fatalf("聚合流水的组别 = %v,期望 男子(按单位唯一参加的组别回推)", got)
|
|
}
|
|
// 聚合分不对应单个运动员,运动员列为空是合理的(前端降级成单位名);但组别不能跟着空。
|
|
if name, ok := item["athleteName"]; ok && name != nil {
|
|
t.Fatalf("聚合流水不该带运动员名,得到 %v", name)
|
|
}
|
|
}
|
|
|
|
// 正常发布的个人流水(走报名项)必须带齐「谁 · 哪个项目 · 哪个组别」:
|
|
// 大屏明细的「运动员」列就靠这一行数据,任何一项为空都会让现场只看到项目与名次。
|
|
func TestTeamBreakdownIndividualRowCarriesWhoAndCategory(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.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
|
"eventId": ids.event,
|
|
"results": []map[string]any{
|
|
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
|
},
|
|
}, true)
|
|
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
|
"eventId": ids.event,
|
|
}, true); status != http.StatusOK {
|
|
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
|
|
status, env2 := env.do(client, http.MethodGet,
|
|
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
|
|
if status != http.StatusOK || !env2.OK {
|
|
t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
items := decodeData[map[string]any](t, env2)["items"].([]any)
|
|
if len(items) != 1 {
|
|
t.Fatalf("明细行数 = %d,期望 1", len(items))
|
|
}
|
|
item := items[0].(map[string]any)
|
|
for field, want := range map[string]any{
|
|
"athleteName": "张伟",
|
|
"categoryName": "男子",
|
|
"eventName": "100米跑",
|
|
"resultText": "11.58",
|
|
} {
|
|
if got := item[field]; got != want {
|
|
t.Fatalf("明细行的 %s = %v,期望 %v(大屏明细要能说清是谁在哪个项目拿了多少分)", field, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 单位在同一小项参加了多个组别时回推不出组别,明细宁可留空也不猜(与导出一致)。
|
|
func TestTeamBreakdownAmbiguousAggregateCategoryStaysEmpty(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")
|
|
|
|
otherCat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
|
VALUES (?, '女子', 1, 1)`, ids.event)
|
|
otherAthlete := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active)
|
|
VALUES ('王芳', '女', ?, 1)`, ids.teamA)
|
|
env.exec(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
|
VALUES (?, ?, ?, ?, 'registered')`, ids.event, otherCat, ids.teamA, otherAthlete)
|
|
env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id,
|
|
source_type, source_id, points, count_to_team)
|
|
VALUES (?, NULL, ?, NULL, NULL, 'result', 99, 15, 1)`, ids.teamA, ids.event)
|
|
|
|
status, env2 := env.do(client, http.MethodGet,
|
|
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
|
|
if status != http.StatusOK || !env2.OK {
|
|
t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
items := decodeData[map[string]any](t, env2)["items"].([]any)
|
|
if len(items) != 1 {
|
|
t.Fatalf("明细行数 = %d,期望 1(只插了一条聚合流水)", len(items))
|
|
}
|
|
if got := items[0].(map[string]any)["categoryName"]; got != nil {
|
|
t.Fatalf("两个组别都参加时组别应留空(不猜),得到 %v", got)
|
|
}
|
|
}
|
|
|
|
// 只排名不计分(scored=0)的项目即使配了计分通道,也不得在大屏奖牌榜产出奖牌;
|
|
// 导出侧已按 ev.scored = 1 过滤,两边口径必须一致,否则奖牌榜与成绩册对不上。
|
|
func TestStandingsExcludesUnscoredEventMedals(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")
|
|
|
|
unscored := env.insert(t,
|
|
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
|
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 0, 1, 'ongoing')`, ids.sport)
|
|
cat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, unscored)
|
|
var profileID int64
|
|
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
|
|
t.Fatalf("读取积分模板: %v", err)
|
|
}
|
|
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
|
VALUES (?, 'athlete', 'result', ?, 0)`, unscored, profileID)
|
|
entry := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
|
VALUES (?, ?, ?, ?, 'registered')`, unscored, cat, ids.teamB, ids.athB)
|
|
env.exec(t, `INSERT INTO results (event_id, entry_id, rank, result_value, status, state)
|
|
VALUES (?, ?, 1, 22.10, 'ok', 'published')`, unscored, entry)
|
|
|
|
_, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
|
|
standings := decodeData[[]map[string]any](t, env2)
|
|
for _, row := range standings {
|
|
if row["teamCode"] == "B" {
|
|
if row["gold"].(float64) != 0 || row["medalTotal"].(float64) != 0 {
|
|
t.Fatalf("scored=0 的项目不应计入奖牌榜: %+v", row)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// standings.tie_break 必须真正参与排序,否则换规则包后名次仍走硬编码默认序。
|
|
func TestStandingsTieBreakFromSettings(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 teams SET name = 'zzz' WHERE id = ?`, ids.teamA)
|
|
env.exec(t, `UPDATE teams SET name = 'aaa' WHERE id = ?`, ids.teamB)
|
|
|
|
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
|
"eventId": ids.event,
|
|
"results": []map[string]any{
|
|
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
|
|
{"entryId": ids.entryB, "resultValue": 11.58, "unit": "s", "status": "ok"},
|
|
},
|
|
}, true)
|
|
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
|
"eventId": ids.event,
|
|
}, true); status != http.StatusOK {
|
|
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
|
|
first := func() string {
|
|
t.Helper()
|
|
_, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
|
|
rows := decodeData[[]map[string]any](t, env2)
|
|
if len(rows) != 2 {
|
|
t.Fatalf("应返回 2 个单位,得到 %d", len(rows))
|
|
}
|
|
return rows[0]["teamCode"].(string)
|
|
}
|
|
|
|
if got := first(); got != "A" {
|
|
t.Fatalf("默认按单位编码排序应 A 在前,得到 %s", got)
|
|
}
|
|
env.exec(t, `INSERT INTO settings (key, value, updated_at) VALUES ('standings.tie_break', 'points,name', datetime('now'))
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
|
|
if got := first(); got != "B" {
|
|
t.Fatalf("配置 standings.tie_break=points,name 后应按名称排序(aaa 在前),得到 %s", got)
|
|
}
|
|
}
|