公告: - 服务端把当前公告落库到 settings(display.announce_text / announce_seconds), 作为 display.announce 随 snapshot 与 /api/settings 下发(非 admin 快照也保留) - announce 指令除转发外写库;新增 announce_clear 清空并广播;两者写审计 - realtime 协议登记 announce_clear(补上,使清除能即时推到大屏) - 大屏把「全屏推送」与「底栏常驻」拆开:推送 seconds 秒后只退出全屏态,公告留在底栏, 直到被替换或清除;新增公告状态机(可测)与底栏取值优先级 推送中 > 服务端持久值 > 「暂无公告」;管理端加「清除公告」与当前公告状态 团体报名: - EntryPicker 团体/对抗模式允许不勾队员直接报名该单位(此前必须勾人,导致 「只想给单位报个团体项目」做不到、单位下没有运动员时完全报不了) - 核实录入页团体行:显示/填名次/保存/发布/算分闭环无缺陷(用真实服务端跑过), 抽出共享的 entryRowLabel 供两处复用 榜单口径: - 基础数据 → 积分模板页新增「榜单口径(全局)」卡片:individualToTeamWeight 数字输入(默认 1、0–100),说明「个人项目得分按该系数折算计入单位总分」, 保存走 PUT /api/settings;因该值在服务端是全局键、模板表里没有此字段, 故做成独立卡片并标注全局,不塞进模板表单 自检:服务端 11 包全绿;前端 vue-tsc + build、node --test 99/99。
141 lines
5.1 KiB
Go
141 lines
5.1 KiB
Go
package dto
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
|
)
|
|
|
|
// 非 admin 会话只能看到大屏渲染所需字段:admin-only 字段必须清零,
|
|
// 且序列化后仍是显式的 null / 0,不能变成字段缺失。
|
|
func TestEventSettingsWithoutAdminFields(t *testing.T) {
|
|
profileID := int64(3)
|
|
full := EventSettings{
|
|
Event: EventSettingsEvent{Name: "运动会"},
|
|
Display: EventSettingsDisplay{Title: "大屏", RotateSecond: 15},
|
|
Standings: EventSettingsStandings{IndividualToTeamWeight: 0.5, RankingMode: "points", ShowIndividualBoard: true, ShowIndividualMedals: false},
|
|
DefaultProfileID: &profileID,
|
|
RequireApproval: true,
|
|
}
|
|
|
|
got := full.WithoutAdminFields()
|
|
if got.DefaultProfileID != nil {
|
|
t.Fatalf("defaultProfileId 应置空,得到 %v", got.DefaultProfileID)
|
|
}
|
|
if got.RequireApproval {
|
|
t.Fatalf("requireApproval 应清零")
|
|
}
|
|
if got.Standings.IndividualToTeamWeight != 0 {
|
|
t.Fatalf("individualToTeamWeight 应清零,得到 %v", got.Standings.IndividualToTeamWeight)
|
|
}
|
|
if got.Event.Name != "运动会" || got.Display.Title != "大屏" || got.Display.RotateSecond != 15 {
|
|
t.Fatalf("event/display 不应被裁剪: %+v", got)
|
|
}
|
|
if got.Standings.RankingMode != "points" || !got.Standings.ShowIndividualBoard || got.Standings.ShowIndividualMedals {
|
|
t.Fatalf("榜单开关不应被裁剪: %+v", got.Standings)
|
|
}
|
|
|
|
raw, err := json.Marshal(got)
|
|
if err != nil {
|
|
t.Fatalf("序列化: %v", err)
|
|
}
|
|
var top map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &top); err != nil {
|
|
t.Fatalf("解析: %v", err)
|
|
}
|
|
if string(top["defaultProfileId"]) != "null" {
|
|
t.Fatalf("defaultProfileId 应显式序列化为 null: %s", raw)
|
|
}
|
|
if string(top["requireApproval"]) != "false" {
|
|
t.Fatalf("requireApproval 应显式序列化为 false: %s", raw)
|
|
}
|
|
var standings map[string]json.RawMessage
|
|
if err := json.Unmarshal(top["standings"], &standings); err != nil {
|
|
t.Fatalf("解析 standings: %v", err)
|
|
}
|
|
if string(standings["individualToTeamWeight"]) != "0" {
|
|
t.Fatalf("individualToTeamWeight 应显式序列化为 0: %s", raw)
|
|
}
|
|
}
|
|
|
|
// 公告是给大屏底栏用的展示字段,非 admin 的快照也必须带上它。
|
|
func TestEventSettingsWithoutAdminFieldsKeepsAnnounce(t *testing.T) {
|
|
announce := "决赛推迟到 15:00"
|
|
full := EventSettings{Display: EventSettingsDisplay{Announce: &announce}}
|
|
|
|
got := full.WithoutAdminFields()
|
|
if got.Display.Announce == nil || *got.Display.Announce != announce {
|
|
t.Fatalf("非 admin 快照应保留公告,得到 %v", got.Display.Announce)
|
|
}
|
|
}
|
|
|
|
// 公告正文取自 settings 表的 display.announce_text(由大屏指令写入);
|
|
// 空串表示无公告,必须下发显式 null,底栏才能回落到「暂无公告」。
|
|
func TestParseEventSettingsAnnounce(t *testing.T) {
|
|
withText := ParseEventSettings(map[string]string{"display.announce_text": "决赛推迟到 15:00"})
|
|
if withText.Display.Announce == nil || *withText.Display.Announce != "决赛推迟到 15:00" {
|
|
t.Fatalf("公告应随 settings 下发,得到 %v", withText.Display.Announce)
|
|
}
|
|
|
|
empty := ParseEventSettings(map[string]string{"display.announce_text": ""})
|
|
if empty.Display.Announce != nil {
|
|
t.Fatalf("空串表示无公告,应解析为 null,得到 %q", *empty.Display.Announce)
|
|
}
|
|
raw, err := json.Marshal(empty)
|
|
if err != nil {
|
|
t.Fatalf("序列化: %v", err)
|
|
}
|
|
var display map[string]json.RawMessage
|
|
if err := json.Unmarshal(mustField(t, raw, "display"), &display); err != nil {
|
|
t.Fatalf("解析 display: %v", err)
|
|
}
|
|
if string(display["announce"]) != "null" {
|
|
t.Fatalf("无公告时应显式序列化为 null: %s", raw)
|
|
}
|
|
}
|
|
|
|
// mustField 取出顶层字段的原始 JSON,供字段级断言使用。
|
|
func mustField(t *testing.T, raw []byte, key string) []byte {
|
|
t.Helper()
|
|
var top map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &top); err != nil {
|
|
t.Fatalf("解析 %s: %v (%s)", key, err, raw)
|
|
}
|
|
return top[key]
|
|
}
|
|
|
|
// 单位/小项的 DTO 必须把可空列写成显式 null,不能沿用 domain 的 omitempty。
|
|
func TestTeamAndEventFromDomainNullableFields(t *testing.T) {
|
|
teamRaw, err := json.Marshal(TeamFromDomain(domain.Team{ID: 1, Code: "T1", Name: "第一单位"}))
|
|
if err != nil {
|
|
t.Fatalf("序列化单位: %v", err)
|
|
}
|
|
var team map[string]json.RawMessage
|
|
if err := json.Unmarshal(teamRaw, &team); err != nil {
|
|
t.Fatalf("解析单位: %v", err)
|
|
}
|
|
for _, key := range []string{"shortName", "logoPath", "color"} {
|
|
if got, ok := team[key]; !ok || string(got) != "null" {
|
|
t.Fatalf("单位字段 %s 应为显式 null: %s", key, teamRaw)
|
|
}
|
|
}
|
|
|
|
eventRaw, err := json.Marshal(EventFromDomain(domain.Event{ID: 1, Code: "E1", Name: "100米"}))
|
|
if err != nil {
|
|
t.Fatalf("序列化小项: %v", err)
|
|
}
|
|
var event map[string]json.RawMessage
|
|
if err := json.Unmarshal(eventRaw, &event); err != nil {
|
|
t.Fatalf("解析小项: %v", err)
|
|
}
|
|
for _, key := range []string{"scheduledAt", "venue", "remark"} {
|
|
if got, ok := event[key]; !ok || string(got) != "null" {
|
|
t.Fatalf("小项字段 %s 应为显式 null: %s", key, eventRaw)
|
|
}
|
|
}
|
|
if string(event["sortOrder"]) != "0" {
|
|
t.Fatalf("sortOrder 应保留 0: %s", eventRaw)
|
|
}
|
|
}
|