package dto import ( "encoding/json" "strconv" "strings" "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) } } // ---------- 赛事日程侧边栏 ---------- // 日程存在 display.schedule 里,随 ParseEventSettings 一起下发; // 缺键(老赛事 / 从没配过)必须是 null,前端按「没有这个功能」处理。 func TestParseEventSettingsSchedule(t *testing.T) { out := ParseEventSettings(map[string]string{ SettingKeySchedule: `{"items":[{"time":"08:30","title":"开幕式","done":true}],` + `"visible":true,"side":"left","widthPercent":22,"showNote":true}`, }) if out.Display.Schedule == nil { t.Fatal("配置了日程就应下发") } if !out.Display.Schedule.Visible || out.Display.Schedule.Side != ScheduleSideLeft { t.Fatalf("日程配置应原样解析: %+v", out.Display.Schedule) } if len(out.Display.Schedule.Items) != 1 || out.Display.Schedule.Items[0].Title != "开幕式" { t.Fatalf("条目应原样解析: %+v", out.Display.Schedule.Items) } // 时间留空的条目照样下发(只是那一条不显示时间格)。 withEmptyTime := ParseEventSettings(map[string]string{ SettingKeySchedule: `{"items":[{"time":"","title":"时间回头补"}],"visible":true}`, }) if items := withEmptyTime.Display.Schedule.Items; len(items) != 1 || items[0].Time != "" { t.Fatalf("时间为空的条目应照样下发: %+v", items) } // 这个 payload 没带 showNote:缺省的语义是 **true**(备注默认上屏),不是 Go 的零值 false。 if !withEmptyTime.Display.Schedule.ShowNote { t.Fatal("缺 showNote 键时应缺省为 true(备注默认上屏)") } if got := ParseEventSettings(nil).Display.Schedule; got != nil { t.Fatalf("没配过日程时应为 null,得到 %+v", got) } } // showNote 的缺省 = **true**:bool 的零值分不出「键不存在」与「显式 false」,而两者语义相反 // ——库里那份 JSON 没带这个键(别的客户端写的 / 手工改库 / 老数据)时,服务端下发 false 会 // 让备注在没人关过它的前提下莫名不上屏。管理端默认 true、大屏解析 `!== false` 也是 true。 func TestParseScheduleValueShowNoteDefault(t *testing.T) { for _, tc := range []struct { name string raw string want bool }{ {"缺键", `{"items":[]}`, true}, {"显式 true", `{"items":[],"showNote":true}`, true}, {"显式 false", `{"items":[],"showNote":false}`, false}, {"值为 null", `{"items":[],"showNote":null}`, true}, } { t.Run(tc.name, func(t *testing.T) { cfg := ParseScheduleValue(tc.raw) if cfg == nil { t.Fatalf("%s 应能解析", tc.raw) } if cfg.ShowNote != tc.want { t.Fatalf("%s 的 showNote 应为 %v,得到 %v", tc.raw, tc.want, cfg.ShowNote) } }) } // 序列化出去也必须是显式布尔(前端按布尔读,不给 undefined)。 raw, err := json.Marshal(ParseScheduleValue(`{"items":[]}`)) if err != nil { t.Fatalf("序列化: %v", err) } if !strings.Contains(string(raw), `"showNote":true`) { t.Fatalf("缺键解析出的配置序列化时 showNote 应为 true: %s", raw) } // showNote 是字符串这种**类型**损坏仍走既有口径「坏 JSON 整个当没有这个功能」: // 整份解析返回 nil(不是静默当开)。类型不对的字段与别的字段一样会解不动结构体。 if got := ParseScheduleValue(`{"items":[],"showNote":"no"}`); got != nil { t.Fatalf("类型不对的字段当空,得到 %+v", got) } } // 读入口**永不报错**:坏 JSON / 被手工改坏的值一律当空,绝不能让大屏整块不显示。 // 口径与 record_bonus.levels(rules/settings.go)、ParseLiveEventIDs 一致。 func TestParseScheduleValueTolerant(t *testing.T) { for _, raw := range []string{ "", " ", "{items: [", `"日程"`, `{"items":[{"time":"08:30","title":"开幕`, `[1,2,3]`, } { if got := ParseScheduleValue(raw); got != nil { t.Fatalf("坏数据 %q 应当空,得到 %+v", raw, got) } } // 结构上不成立的条目 / 覆盖丢掉,认不出的左右与越界宽度按读侧口径归一。 // 时间为空的条目**不丢**:写入口与管理端都允许时间留空(先排标题、回头补时间), // 读侧把它丢掉等于静默吞掉一条操作员亲手存下来的日程。 cfg := ParseScheduleValue(`{"items":[{"time":"08:30","title":"开幕式"},{"time":"","title":"时间待定"},` + `{"time":"09:00","title":" "},{"time":"上午","title":"趣味赛","note":"操场"}],` + `"visible":true,"side":"up","widthPercent":999,"showNote":true,` + `"instanceOverrides":{"browser-1":"off","browser-2":"maybe","browser-3":"inherit"}}`) if cfg == nil { t.Fatal("结构合法的部分应读出来") } if len(cfg.Items) != 3 || cfg.Items[0].Title != "开幕式" || cfg.Items[2].Note != "操场" { t.Fatalf("只有标题为空的条目该丢掉: %+v", cfg.Items) } if got := cfg.Items[1]; got.Title != "时间待定" || got.Time != "" { t.Fatalf("时间为空的条目应保留且 time 是空串: %+v", got) } if cfg.Side != ScheduleSideRight || cfg.WidthPercent != MaxScheduleWidthPercent { t.Fatalf("左右应回落 right、宽度应夹到 40: %+v", cfg) } if !cfg.Visible || !cfg.ShowNote { t.Fatalf("开关应原样读出: %+v", cfg) } // 非三态的值丢掉;`inherit` 也丢掉(它就是「没有这条设置」,留着只会白占条数预算)。 if len(cfg.InstanceOverrides) != 1 || cfg.InstanceOverrides["browser-1"] != ScheduleOverrideOff { t.Fatalf("非三态与 inherit 的覆盖都该丢掉: %+v", cfg.InstanceOverrides) } // 越界的条数只取前 N 条,不报错。 var sb strings.Builder sb.WriteString(`{"items":[`) for i := 0; i < MaxScheduleItems+1; i++ { if i > 0 { sb.WriteString(",") } sb.WriteString(`{"time":"08:30","title":"项"}`) } sb.WriteString(`]}`) if got := ParseScheduleValue(sb.String()); len(got.Items) != MaxScheduleItems { t.Fatalf("条目应截到 %d 条,得到 %d", MaxScheduleItems, len(got.Items)) } } // 归一函数是读写两侧共用的口径,单独钉住边界(写侧夹取、读侧容错都走它)。 func TestNormalizeScheduleSideAndWidth(t *testing.T) { for raw, want := range map[string]string{ "": "right", "right": "right", "left": "left", "LEFT": "left", " Left ": "left", "up": "right", "左右": "right", } { if got := NormalizeScheduleSide(raw); got != want { t.Fatalf("NormalizeScheduleSide(%q) = %q,期望 %q", raw, got, want) } } for raw, want := range map[float64]float64{ 0: DefaultScheduleWidthPercent, -3: DefaultScheduleWidthPercent, 1: MinScheduleWidthPercent, 14.9: MinScheduleWidthPercent, 22: 22, 40: 40, 100: MaxScheduleWidthPercent, } { if got := NormalizeScheduleWidth(raw); got != want { t.Fatalf("NormalizeScheduleWidth(%v) = %v,期望 %v", raw, got, want) } } } // 线上形状:JSON 键一律小驼峰(与既有 display.* 键一致), // items 空时是 [] 而不是 null —— 前端按数组解析,null 会当场炸。 func TestScheduleConfigJSONShape(t *testing.T) { raw, err := json.Marshal(ScheduleConfig{ Items: []ScheduleItem{}, Side: ScheduleSideRight, WidthPercent: 22, }) 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) } for _, key := range []string{"items", "visible", "side", "widthPercent", "showNote"} { if _, ok := top[key]; !ok { t.Fatalf("缺少小驼峰键 %s: %s", key, raw) } } if string(top["items"]) != "[]" { t.Fatalf("空日程应序列化为 []: %s", raw) } // 单屏覆盖可缺:全是 inherit 时不必写出来(写侧只在有内容时才建 map)。 if _, ok := top["instanceOverrides"]; ok { t.Fatalf("没有覆盖时不该出现 instanceOverrides: %s", raw) } // 可缺字段:note / done 缺省时不下线。 itemRaw, err := json.Marshal(ScheduleItem{Time: "08:30", Title: "开幕式"}) if err != nil { t.Fatalf("序列化条目: %v", err) } var item map[string]json.RawMessage if err := json.Unmarshal(itemRaw, &item); err != nil { t.Fatalf("解析条目: %v", err) } if len(item) != 2 { t.Fatalf("缺省字段不该上线,得到 %s", itemRaw) } }