Files
Scoreboard/server/internal/domain/ledger_test.go
T
AskaEth 7f9701c465 fix: 审查修复波次(P0x4 + P1x30+)
- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力
- realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份
- httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表
- web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换
- console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复
- docs: 用户文档三份 + README
- 主代理: 破纪录加分接线(levels 未加载导致恒 0)
2026-09-09 21:06:40 +08:00

277 lines
9.5 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 domain
import (
"errors"
"testing"
)
func TestAppendLedger(t *testing.T) {
t.Run("自动续号", func(t *testing.T) {
out, err := AppendLedger([]LedgerEntry{{ID: 3, Points: 1}}, LedgerEntry{Points: 2}, LedgerEntry{Points: 3})
if err != nil {
t.Fatalf("追加失败: %v", err)
}
if len(out) != 3 || out[1].ID != 4 || out[2].ID != 5 {
t.Fatalf("id 续号错误: %+v", out)
}
})
t.Run("保留已有行", func(t *testing.T) {
base := []LedgerEntry{{ID: 1, Points: 5}}
out, _ := AppendLedger(base, LedgerEntry{Points: 2})
if base[0].Points != 5 || len(out) != 2 {
t.Fatalf("原流水被改动: base=%+v out=%+v", base, out)
}
})
t.Run("显式 id 冲突报错", func(t *testing.T) {
if _, err := AppendLedger([]LedgerEntry{{ID: 1}}, LedgerEntry{ID: 1}); !errors.Is(err, ErrDuplicateLedgerID) {
t.Fatalf("错误 = %v,期望 ErrDuplicateLedgerID", err)
}
})
}
func TestVoidLedger(t *testing.T) {
base := []LedgerEntry{{ID: 1, Points: 5}, {ID: 2, Points: 3}}
t.Run("作废指定流水", func(t *testing.T) {
out, err := VoidLedger(base, 1, "admin", "录入错误")
if err != nil {
t.Fatalf("作废失败: %v", err)
}
if !out[0].Voided || out[0].VoidedBy != "admin" || out[0].VoidReason != "录入错误" {
t.Fatalf("作废信息不全: %+v", out[0])
}
if out[1].Voided {
t.Fatalf("误作废了其他流水: %+v", out[1])
}
if base[0].Voided {
t.Fatal("作废不应原地修改入参")
}
})
t.Run("重复作废报错", func(t *testing.T) {
out, _ := VoidLedger(base, 1, "admin", "x")
if _, err := VoidLedger(out, 1, "admin", "y"); !errors.Is(err, ErrLedgerAlreadyVoided) {
t.Fatalf("错误 = %v,期望 ErrLedgerAlreadyVoided", err)
}
})
t.Run("流水不存在报错", func(t *testing.T) {
if _, err := VoidLedger(base, 404, "admin", "x"); !errors.Is(err, ErrLedgerNotFound) {
t.Fatalf("错误 = %v,期望 ErrLedgerNotFound", err)
}
})
}
func TestVoidBySource(t *testing.T) {
base := []LedgerEntry{
{ID: 1, SourceType: SourceResult, SourceID: 100, Points: 10},
{ID: 2, SourceType: SourceResult, SourceID: 100, Points: 8},
{ID: 3, SourceType: SourceResult, SourceID: 200, Points: 6},
}
out, count := VoidBySource(base, SourceResult, 100, "admin", "撤销")
if count != 2 {
t.Fatalf("作废条数 = %d,期望 2", count)
}
if !out[0].Voided || !out[1].Voided || out[2].Voided {
t.Fatalf("作废范围错误: %+v", out)
}
t.Run("重复作废是空操作", func(t *testing.T) {
again, count := VoidBySource(out, SourceResult, 100, "admin", "再撤销")
if count != 0 {
t.Fatalf("重复作废条数 = %d,期望 0", count)
}
if len(again) != len(out) {
t.Fatalf("流水条数变了: %d", len(again))
}
})
}
func TestSumByTeam(t *testing.T) {
ledger := []LedgerEntry{
ledgerFor(1, 10),
ledgerForAthlete(1, 101, 10),
ledgerFor(2, 6),
}
got := SumByTeam(ledger, 1)
if !nearlyEqual(got[1].Total, 20) || !nearlyEqual(got[1].TeamOnly, 10) || !nearlyEqual(got[1].Individual, 10) {
t.Fatalf("单位1汇总 = %+v,期望 总分20 / 单位10 / 个人10", got[1])
}
if !nearlyEqual(got[2].Total, 6) {
t.Fatalf("单位2汇总 = %+v,期望 6", got[2])
}
half := SumByTeam(ledger, 0.5)
if !nearlyEqual(half[1].Total, 15) {
t.Fatalf("系数 0.5 时单位1总分 = %v,期望 15", half[1].Total)
}
}
func TestSumByAthlete(t *testing.T) {
ledger := []LedgerEntry{
ledgerForAthlete(1, 101, 10),
ledgerForAthlete(1, 101, 2),
ledgerFor(1, 100), // 仅记单位,不进个人榜
}
got := SumByAthlete(ledger)
if len(got) != 1 || !nearlyEqual(got[101], 12) {
t.Fatalf("个人汇总 = %+v,期望只有 101:12", got)
}
}
func TestRecomputeEventLedger(t *testing.T) {
old := []LedgerEntry{
{ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10},
{ID: 2, EventID: 2, SourceType: SourceResult, SourceID: 200, TeamID: 1, Points: 8},
}
fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
t.Run("有变化时旧流水作废并追加新流水", func(t *testing.T) {
out, err := RecomputeEventLedger(old, 1, fresh, "judge", "更正")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
if len(out) != 3 {
t.Fatalf("流水条数 = %d,期望 3(旧2 + 新1)", len(out))
}
if !out[0].Voided || out[1].Voided {
t.Fatalf("作废范围错误: %+v", out)
}
if !nearlyEqual(SumByTeam(out, 1)[1].Total, 14) {
t.Fatalf("重算后总分 = %v,期望 6 + 8 = 14", SumByTeam(out, 1)[1].Total)
}
})
t.Run("无变化时原样返回", func(t *testing.T) {
same := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10}}
out, err := RecomputeEventLedger(old, 1, same, "judge", "重复发布")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
if len(out) != len(old) {
t.Fatalf("流水条数 = %d,期望 %d(幂等)", len(out), len(old))
}
})
t.Run("只影响目标小项", func(t *testing.T) {
out, _ := RecomputeEventLedger(old, 1, fresh, "judge", "更正")
if out[1].Voided {
t.Fatalf("其他小项的流水被误作废: %+v", out[1])
}
})
}
func TestApplyLedgerOps(t *testing.T) {
a := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10}}
b := []LedgerEntry{{EventID: 2, SourceType: SourceResult, SourceID: 200, TeamID: 2, Points: 8}}
t.Run("追加与作废可乱序收敛", func(t *testing.T) {
ops := []LedgerOp{
{Kind: LedgerOpAppend, Entries: b},
{Kind: LedgerOpAppend, Entries: a},
{Kind: LedgerOpVoidSource, SourceType: SourceResult, SourceID: 100, By: "admin", Reason: "撤销"},
}
out, err := ApplyLedgerOps(nil, ops)
if err != nil {
t.Fatalf("应用失败: %v", err)
}
got := SumByTeam(out, 1)
if !nearlyEqual(got[1].Total, 0) || !nearlyEqual(got[2].Total, 8) {
t.Fatalf("最终状态 = %+v,期望 1:0 / 2:8", got)
}
})
t.Run("未知操作报错", func(t *testing.T) {
if _, err := ApplyLedgerOps(nil, []LedgerOp{{Kind: "magic"}}); !errors.Is(err, ErrUnknownLedgerOp) {
t.Fatalf("错误 = %v,期望 ErrUnknownLedgerOp", err)
}
})
}
func TestAdjustmentsToLedger(t *testing.T) {
adj := PointAdjustment{ID: 1, TeamID: 5, Delta: -3, Category: AdjustmentPenalty, Reason: "犯规"}
t.Run("未启用时报错", func(t *testing.T) {
if _, err := AdjustmentsToLedger(ManualAdjustmentConfig{}, []PointAdjustment{adj}); !errors.Is(err, ErrManualAdjustmentDisabled) {
t.Fatalf("错误 = %v,期望 ErrManualAdjustmentDisabled", err)
}
})
t.Run("启用后转成流水", func(t *testing.T) {
out, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true, ReasonRequired: true}, []PointAdjustment{adj})
if err != nil {
t.Fatalf("转换失败: %v", err)
}
if len(out) != 1 || out[0].SourceType != SourceAdjustment || !nearlyEqual(out[0].Points, -3) {
t.Fatalf("流水 = %+v,期望一条 -3 的 adjustment", out)
}
})
t.Run("理由必填", func(t *testing.T) {
bad := adj
bad.Reason = " "
if _, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true, ReasonRequired: true}, []PointAdjustment{bad}); !errors.Is(err, ErrAdjustmentReasonRequired) {
t.Fatalf("错误 = %v,期望 ErrAdjustmentReasonRequired", err)
}
})
t.Run("已作废的调整被跳过", func(t *testing.T) {
voided := adj
voided.Voided = true
out, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true}, []PointAdjustment{voided})
if err != nil || len(out) != 0 {
t.Fatalf("流水 = %+v err=%v,期望空", out, err)
}
})
}
func TestActiveLedger(t *testing.T) {
ledger := []LedgerEntry{{ID: 1, Points: 1}, {ID: 2, Points: 2, Voided: true}}
got := ActiveLedger(ledger)
if len(got) != 1 || got[0].ID != 1 {
t.Fatalf("未作废流水 = %+v,期望只有 id=1", got)
}
}
// TestRecomputeEventLedgerKeepsAdjustments 验证重算只作废 result/record_bonus,
// 人工调整原样保留——否则每重算一次就把人工扣/加分抹掉。
func TestRecomputeEventLedgerKeepsAdjustments(t *testing.T) {
existing := []LedgerEntry{
{ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10},
{ID: 2, EventID: 1, SourceType: SourceRecordBonus, SourceID: 100, TeamID: 1, Points: 2},
{ID: 3, EventID: 1, SourceType: SourceAdjustment, SourceID: 7, TeamID: 1, Points: -3},
}
fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
out, err := RecomputeEventLedger(existing, 1, fresh, "judge", "成绩更正")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
for _, e := range out {
if e.SourceType == SourceAdjustment {
if e.Voided {
t.Fatalf("人工调整被误作废: %+v", e)
}
}
}
if got := SumByTeam(out, 1)[1].Total; !nearlyEqual(got, 3) {
t.Fatalf("重算后总分 = %v,期望 6(新成绩分)- 3(调整保留)= 3", got)
}
}
// TestRecomputeEventLedgerIdempotentWithAdjustments 验证带人工调整时重复重算也幂等。
func TestRecomputeEventLedgerIdempotentWithAdjustments(t *testing.T) {
existing := []LedgerEntry{
{ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6},
{ID: 2, EventID: 1, SourceType: SourceAdjustment, SourceID: 7, TeamID: 1, Points: -3},
}
fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
out, err := RecomputeEventLedger(existing, 1, fresh, "judge", "重复发布")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
if len(out) != len(existing) {
t.Fatalf("流水条数 = %d,期望 %d(幂等)", len(out), len(existing))
}
for _, e := range out {
if e.Voided {
t.Fatalf("幂等重算不应作废任何流水: %+v", e)
}
}
}