package revisions_test import ( "context" "database/sql" "encoding/json" "testing" _ "modernc.org/sqlite" "git.yeij.top/AskaEth/Scoreboard/server/internal/revisions" ) // 一版快照里的成绩,字段名与 httpapi.resultDTO 的 JSON 对齐(只列判定用得到的)。 type snapResult struct { EntryID int64 `json:"entryId"` Rank *int `json:"rank"` ResultText string `json:"resultText"` ResultValue *float64 `json:"resultValue"` Unit string `json:"unit"` IsTie bool `json:"isTie"` Status string `json:"status"` State string `json:"state"` Note *string `json:"note"` } type revision struct { eventID int64 message string actorID any created string results []snapResult } func newDB(t *testing.T, rows []revision) *sql.DB { t.Helper() db, err := sql.Open("sqlite", "file::memory:?cache=shared") if err != nil { t.Fatalf("打开内存库: %v", err) } t.Cleanup(func() { _ = db.Close() }) if _, err := db.Exec(`CREATE TABLE result_revisions ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER NOT NULL, action TEXT NOT NULL, snapshot_json TEXT NOT NULL, actor_id INTEGER, message TEXT, created_at TEXT NOT NULL)`); err != nil { t.Fatalf("建表: %v", err) } for _, row := range rows { raw, err := json.Marshal(row.results) if err != nil { t.Fatalf("序列化快照: %v", err) } if _, err := db.Exec(`INSERT INTO result_revisions (event_id, action, snapshot_json, actor_id, message, created_at) VALUES (?, 'publish', ?, ?, ?, ?)`, row.eventID, string(raw), row.actorID, row.message, row.created); err != nil { t.Fatalf("插入修订: %v", err) } } return db } func f64(v float64) *float64 { return &v } func str(v string) *string { return &v } // result 造一条成绩快照;名次与 state 每次都不同,用来证明它们不参与判定。 func result(entryID int64, rank int, text string, value float64, state string) snapResult { return snapResult{ EntryID: entryID, Rank: &rank, ResultText: text, ResultValue: f64(value), Unit: "s", Status: "ok", State: state, } } func load(t *testing.T, db *sql.DB, keys ...revisions.Key) map[revisions.Key]revisions.Trail { t.Helper() trails, err := revisions.Load(context.Background(), db, keys) if err != nil { t.Fatalf("Load: %v", err) } return trails } // 发布 → 改成绩 → 重新发布:能读出「改过 1 次」、最后一次的时间与原因。 func TestLoadCountsRepublishAfterEdit(t *testing.T) { db := newDB(t, []revision{ {eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{ result(11, 1, "11.58", 11.58, "published"), result(12, 2, "11.92", 11.92, "published"), }}, {eventID: 1, created: "2026-09-13 02:50:00", results: []snapResult{ // 退回草稿:只有 state 变,成绩一个字没动。 result(11, 1, "11.58", 11.58, "draft"), result(12, 2, "11.92", 11.92, "published"), }}, {eventID: 1, message: "", actorID: 7, created: "2026-09-13 02:58:00", results: []snapResult{ result(11, 1, "11.42", 11.42, "published"), result(12, 3, "11.92", 11.92, "published"), }}, }) trails := load(t, db, revisions.Key{EventID: 1, EntryID: 11}, revisions.Key{EventID: 1, EntryID: 12}) got := trails[revisions.Key{EventID: 1, EntryID: 11}] if got.Count != 1 { t.Fatalf("11 号成绩改过 1 次,得到 %d", got.Count) } if got.At != "2026-09-13 02:58:00" { t.Fatalf("最后一次修订时间 = %q", got.At) } // 12 号成绩一个字没改,只有名次被引擎重算(2 → 3):不能算修订。 if other, ok := trails[revisions.Key{EventID: 1, EntryID: 12}]; !ok || other.Count != 0 { t.Fatalf("没改过的成绩不该报修订,得到 %+v(存在=%v)", other, ok) } } // 原因优先取这条成绩自己的备注(「退回草稿」写下的理由),修订行的 message 只作兜底。 func TestLoadPrefersResultNoteAsReason(t *testing.T) { db := newDB(t, []revision{ {eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{ result(11, 1, "11.58", 11.58, "published"), }}, {eventID: 1, message: "跳过 3 条只填了成绩文本的草稿", actorID: 7, created: "2026-09-13 02:58:00", results: []snapResult{func() snapResult { r := result(11, 1, "11.42", 11.42, "published") r.Note = str("成绩录入笔误") return r }()}}, }) got := load(t, db, revisions.Key{EventID: 1, EntryID: 11})[revisions.Key{EventID: 1, EntryID: 11}] if got.Reason != "成绩录入笔误" { t.Fatalf("原因应取成绩备注,得到 %q", got.Reason) } if got.ActorID == nil || *got.ActorID != 7 { t.Fatalf("操作人 = %v,期望 7", got.ActorID) } } // 没有备注时退回修订行的 message;两者都没有就是空串(调用方只显示时间,不编原因)。 func TestLoadReasonFallsBackToMessageThenEmpty(t *testing.T) { db := newDB(t, []revision{ {eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{result(11, 1, "11.58", 11.58, "published")}}, {eventID: 1, message: "现场复核后更正", created: "2026-09-13 02:58:00", results: []snapResult{result(11, 1, "11.42", 11.42, "published")}}, {eventID: 1, created: "2026-09-13 03:10:00", results: []snapResult{result(11, 1, "11.30", 11.30, "published")}}, }) got := load(t, db, revisions.Key{EventID: 1, EntryID: 11})[revisions.Key{EventID: 1, EntryID: 11}] if got.Count != 2 { t.Fatalf("改了两次应报 2,得到 %d", got.Count) } if got.Reason != "" { t.Fatalf("最后一次没写原因就应留空,得到 %q", got.Reason) } if got.At != "2026-09-13 03:10:00" { t.Fatalf("最后一次修订时间 = %q", got.At) } } // 一次都没在版本链里出现过(没有基线)时不返回该 Key —— 调用方据此显示为空,而不是 0。 func TestLoadOmitsKeysWithoutBaseline(t *testing.T) { db := newDB(t, []revision{ {eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{result(11, 1, "11.58", 11.58, "published")}}, }) trails := load(t, db, revisions.Key{EventID: 1, EntryID: 11}, revisions.Key{EventID: 1, EntryID: 99}, revisions.Key{EventID: 2, EntryID: 11}) if _, ok := trails[revisions.Key{EventID: 1, EntryID: 99}]; ok { t.Fatalf("没出现在任何快照里的成绩不该有痕迹") } if _, ok := trails[revisions.Key{EventID: 2, EntryID: 11}]; ok { t.Fatalf("别的项目下的同号报名项不该被认成同一条成绩") } if got := trails[revisions.Key{EventID: 1, EntryID: 11}]; got.Count != 0 { t.Fatalf("只发布过一次的成绩应追溯得到、且没改过,得到 %+v", got) } } // 「作废 / 改判」改的是状态不是成绩文本:也算改过(否则现场看不出这格被改判过)。 func TestLoadCountsStatusChange(t *testing.T) { db := newDB(t, []revision{ {eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{result(11, 1, "11.58", 11.58, "published")}}, {eventID: 1, created: "2026-09-13 03:00:00", results: []snapResult{func() snapResult { r := result(11, 1, "11.58", 11.58, "published") r.Status = "dq" return r }()}}, }) if got := load(t, db, revisions.Key{EventID: 1, EntryID: 11})[revisions.Key{EventID: 1, EntryID: 11}]; got.Count != 1 { t.Fatalf("改判(状态变化)应算改过 1 次,得到 %+v", got) } } // 同一条成绩的小项下别的成绩反复改,不能把它的修订次数带上去。 func TestLoadKeepsEventsApart(t *testing.T) { db := newDB(t, []revision{ {eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{ result(11, 1, "11.58", 11.58, "published"), result(21, 1, "22.10", 22.10, "published"), }}, {eventID: 1, created: "2026-09-13 03:00:00", results: []snapResult{ result(11, 1, "11.58", 11.58, "published"), result(21, 1, "21.80", 21.80, "published"), }}, {eventID: 2, created: "2026-09-13 03:10:00", results: []snapResult{result(11, 1, "11.42", 11.42, "published")}}, }) trails := load(t, db, revisions.Key{EventID: 1, EntryID: 11}, revisions.Key{EventID: 1, EntryID: 21}, revisions.Key{EventID: 2, EntryID: 11}) if got := trails[revisions.Key{EventID: 1, EntryID: 11}].Count; got != 0 { t.Fatalf("同一小项里别人改了,自己不算改过,得到 %d", got) } if got := trails[revisions.Key{EventID: 1, EntryID: 21}].Count; got != 1 { t.Fatalf("真正改了的那条应报 1,得到 %d", got) } if got := trails[revisions.Key{EventID: 2, EntryID: 11}].Count; got != 0 { t.Fatalf("另一个小项里的同号报名项不该受牵连,得到 %d", got) } } // 破纪录加分 / 人工调整 / 聚合流水没有「一条成绩」可追:KeyFor 不给它们身份, // 免得把成绩的修订说成「那笔加分被改过」。 func TestKeyForOnlyAcceptsScoreLedger(t *testing.T) { if got := revisions.KeyFor("record_bonus", 1, 11); got != (revisions.Key{}) { t.Fatalf("破纪录加分不该挂成绩的修订痕迹,得到 %+v", got) } if got := revisions.KeyFor("adjustment", 1, 11); got != (revisions.Key{}) { t.Fatalf("人工调整不该挂成绩的修订痕迹,得到 %+v", got) } if got := revisions.KeyFor("result", 0, 11); got != (revisions.Key{}) { t.Fatalf("没有小项的流水追不了,得到 %+v", got) } if got := revisions.KeyFor("result", 1, 0); got != (revisions.Key{}) { t.Fatalf("聚合流水没有报名项,追不了,得到 %+v", got) } if got := revisions.KeyFor("result", 1, 11); got != (revisions.Key{EventID: 1, EntryID: 11}) { t.Fatalf("成绩流水应给出身份,得到 %+v", got) } }