package dto import ( "encoding/json" "strconv" "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) } } // 字体 id 是给大屏渲染用的展示字段,非 admin 的快照也必须带上它, // 否则 guest/recorder 打开的大屏只会用默认字体。 func TestEventSettingsWithoutAdminFieldsKeepsFonts(t *testing.T) { title := "builtin:noto-serif" body := "custom:思源宋体.ttf" full := EventSettings{Display: EventSettingsDisplay{FontTitle: &title, FontBody: &body}} got := full.WithoutAdminFields() if got.Display.FontTitle == nil || *got.Display.FontTitle != title { t.Fatalf("非 admin 快照应保留标题字体,得到 %v", got.Display.FontTitle) } if got.Display.FontBody == nil || *got.Display.FontBody != body { t.Fatalf("非 admin 快照应保留正文字体,得到 %v", got.Display.FontBody) } raw, err := json.Marshal(got) 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["fontTitle"]) != `"`+title+`"` || string(display["fontBody"]) != `"`+body+`"` { t.Fatalf("字体应随 display 一起下发: %s", raw) } } // 字体取自 settings 表的 display.font_title / display.font_body; // 空串与缺键都表示用默认字体,必须下发显式 null。 func TestParseEventSettingsFonts(t *testing.T) { withFonts := ParseEventSettings(map[string]string{ "display.font_title": "builtin:misans", "display.font_body": "custom:my-font.ttf", }) if withFonts.Display.FontTitle == nil || *withFonts.Display.FontTitle != "builtin:misans" { t.Fatalf("标题字体应随 settings 下发,得到 %v", withFonts.Display.FontTitle) } if withFonts.Display.FontBody == nil || *withFonts.Display.FontBody != "custom:my-font.ttf" { t.Fatalf("正文字体应随 settings 下发,得到 %v", withFonts.Display.FontBody) } empty := ParseEventSettings(map[string]string{"display.font_title": "", "display.font_body": ""}) if empty.Display.FontTitle != nil || empty.Display.FontBody != nil { t.Fatalf("空串表示默认字体,应解析为 null,得到 %v / %v", empty.Display.FontTitle, empty.Display.FontBody) } 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["fontTitle"]) != "null" || string(display["fontBody"]) != "null" { t.Fatalf("未配置字体时应显式序列化为 null: %s", raw) } } // 「实时赛况」的两个设置键:live_events 存逗号分隔 id(最多 2 个),live_source 只认 auto/manual。 func TestParseLiveEventSettings(t *testing.T) { got := ParseEventSettings(map[string]string{ SettingKeyLiveEvents: "12, 15", SettingKeyLiveSource: "MANUAL", }) if len(got.Display.LiveEvents) != 2 || got.Display.LiveEvents[0] != 12 || got.Display.LiveEvents[1] != 15 { t.Fatalf("应解析出人工指定的两个 id,得到 %+v", got.Display.LiveEvents) } if got.Display.LiveSource != LiveSourceManual { t.Fatalf("大小写应归一成 manual,得到 %q", got.Display.LiveSource) } // 缺键 / 空值:空数组 + 默认 auto。 empty := ParseEventSettings(map[string]string{}) if empty.Display.LiveEvents == nil || len(empty.Display.LiveEvents) != 0 { t.Fatalf("缺键应下发空数组而不是 null,得到 %+v", empty.Display.LiveEvents) } if empty.Display.LiveSource != LiveSourceAuto { t.Fatalf("缺键应默认 auto,得到 %q", empty.Display.LiveSource) } 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["liveEvents"]) != "[]" || string(display["liveSource"]) != `"auto"` { t.Fatalf("缺键时应下发 [] 与 auto: %s", raw) } } // 解析要能容忍手工改库留下的脏值:坏项丢弃、去重、超过 2 个截断。 func TestParseLiveEventIDs(t *testing.T) { cases := []struct { raw string want string }{ {"", ""}, {"12", "12"}, {"12,15", "12,15"}, {" 3 , 4 , 5 ", "3,4"}, {"7,7,8", "7,8"}, {"abc", ""}, {"0,-1,6", "6"}, } for _, tc := range cases { got := ParseLiveEventIDs(tc.raw) joined := "" for i, id := range got { if i > 0 { joined += "," } joined += strconv.FormatInt(id, 10) } if joined != tc.want { t.Fatalf("ParseLiveEventIDs(%q) = %q,期望 %q", tc.raw, joined, tc.want) } } } func TestFormatLiveEventIDs(t *testing.T) { if got := FormatLiveEventIDs([]int64{9, 11, 13}); got != "9,11" { t.Fatalf("最多存 2 个,得到 %q", got) } if got := FormatLiveEventIDs(nil); got != "" { t.Fatalf("空列表应存空串,得到 %q", got) } if got := FormatLiveEventIDs([]int64{0, -2, 5}); got != "5" { t.Fatalf("非法 id 应丢弃,得到 %q", got) } } func TestNormalizeLiveSource(t *testing.T) { cases := map[string]string{ "": LiveSourceAuto, "auto": LiveSourceAuto, "AUTO": LiveSourceAuto, "manual": LiveSourceManual, " Manual": LiveSourceManual, "x": "", } for raw, want := range cases { if got := NormalizeLiveSource(raw); got != want { t.Fatalf("NormalizeLiveSource(%q) = %q,期望 %q", raw, got, want) } } } // 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) } }