Files
AskaEth 9c52dc4b38 feat(display): 大屏「赛事日程侧边栏」(手填日程 + 竖向进度轴 + 热开关 + 每屏覆盖);版本 dev_0.5.16
主人点名要的功能:大屏控制页加一个**可热开关**的侧边栏,在**指定的屏幕**上(不管它当前在哪一页)叠一个
**手填的赛事日程列表**,带**竖向进度条**;日程**不与登记的小项时间关联**、完全手填;竖向时间轴要好看,
**字多时字号自动缩放与换行**要做到位(不能过小、不能过大)。

设计口径(主人逐条拍板):放不下时**围绕当前项开窗**;进度条是**一条竖向时间轴 + 三态节点**;
**深色半透明 + 毛玻璃**;已完成的项**淡化保留**;备注**默认上屏 + 一个总开关**;
明细覆盖层 / 全屏公告期间**自动收起**、结束后自动回来;编辑权限**录入员及以上**;单屏覆盖按**实例 id**。

服务端(走大屏公告那条路 —— PUT /api/settings 是 admin-only,会与「录入员可改」冲突)
- 新增 `schedule` 动作 + 白名单;`POST /api/display/command` 同一个 handler:**先校验 → 再转发 → 最后落库**
  (校验排在转发前,非法配置不会在大屏上闪一帧)
- 落库到赛事库 settings 的 `display.schedule`(**不新建表、不写迁移**,随赛事包走);
  与公告共用那份「写设置键 + bumpVersion」的实现
- **读写不对称容错**:写侧严格 400(含中文提示与第几条),读侧坏数据一律静默当空 —— 老数据 / 手改库不会炸大屏
- 上限:40 条;time ≤20 / title ≤40 / note ≤60(按字符,清洗控制字符);宽度夹 15–40;覆盖 ≤50 条
- 口径收口三处:**时间允许为空**(与管理端同口径)、**showNote 缺键 = true**(区分「键不存在」与「显式 false」)、
  **`inherit` 不落库**(不再白占覆盖预算)

大屏端
- 新增 `ScheduleSidebar.vue` + `schedulePanel.ts`(纯函数:开窗 / 三态 / 整体进度 / 字号与行数 / 按显示宽度截断 /
  单屏覆盖 / 抑制 / 指纹去重)
- 字号是**舞台设计坐标**(1920×1080),靠 `.stage` 的 `transform: scale()` 整体缩放,**不做二次缩放**
  (wiring 断言禁止出现 `window.`/`innerWidth`/`vh`);复用既有的 `fitRowFontPx`(**16–56px 硬上下限**)与 `textEm`;
  固定点迭代 ≤4 轮;**贴下限时宁可行数少也不缩字**;标题 ≤3 行、备注 ≤2 行,超出截断
- 容器 `position:absolute; inset:0; z-index:50`(**不用 fixed** —— `.stage` 带 transform,是 fixed 后代的包含块);
  **独立于 detail 状态机**(goto 会关明细、明细会停轮播,侧边栏必须不受影响)
- 深色半透明 + `backdrop-filter`,并带 `@supports not (backdrop-filter: blur(1px))` 退化为更不透明的深底
- 关着时**不留任何 DOM**(逐字节等同于没有这个功能);`prefers-reduced-motion` 下不做动效

管理端
- 大屏控制页(左列、公告之后)新增「赛事日程」卡片:条目表格(时间/标题/备注/已完成 + 上移/下移/删除)、
  总开关、备注上屏、位置与宽度、**每块屏三态覆盖**(跟随 / 强制开 / 强制关;切回「跟随」会真删掉那条键)
- 保存走 `adminApi.displayCommand`(**不走 admin-only 的设置接口**);本地校验不过**一个请求都不发**
- 排序只有上移 / 下移(该页面禁止挂 pointerdown/mousedown 监听,有既有测试钉着)

测试
- 服务端 16 个包全绿;前端 **1174 例**全过(基线 1096,本批 +78:大屏端 44 + 管理端 34)
- 大屏端另有 **40 项 playwright 实机**(用 `page.routeWebSocket` 假造实时通道):开/关后 DOM 无残留、
  五个页面下都在、`lock=0` 真等轮播翻页且侧边栏不掉、明细与公告期间自动收起并自动回来、
  **1080p 与 4K 字号完全相同**、`reducedMotion` 下无动画;并钉住两条硬指标(真机列表高度 ≥ 纯函数预算、
  容量×行高 ≤ 真机高度)

文档:`docs/升级说明` 新增 `dev_0.5.16`;`使用手册` 9.2 + 17.6;`常见问题` Q108–Q111;`上线检查清单`/`部署指南`
各一条;`devdocs/05·07·13` 同步。

⚠️ 一条**既有边界**(本版故意没改,按主人「兼容性最大化」的要求):导出的名次口径是「通道名次优先」、
屏幕是「成绩行名次优先」,只在「先按决赛计分发布、之后又只更新名次发布一次」这种序列下分叉
(导出显示上次计分的名次)。正常赛程遇不到;遇到时对该组再「按决赛成绩发布」一次即恢复一致。
已写进《升级说明》④ 与《常见问题》Q111。
2026-09-17 20:04:50 +08:00

488 lines
18 KiB
Go
Raw Permalink 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 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)
}
}