主人点名要的功能:大屏控制页加一个**可热开关**的侧边栏,在**指定的屏幕**上(不管它当前在哪一页)叠一个
**手填的赛事日程列表**,带**竖向进度条**;日程**不与登记的小项时间关联**、完全手填;竖向时间轴要好看,
**字多时字号自动缩放与换行**要做到位(不能过小、不能过大)。
设计口径(主人逐条拍板):放不下时**围绕当前项开窗**;进度条是**一条竖向时间轴 + 三态节点**;
**深色半透明 + 毛玻璃**;已完成的项**淡化保留**;备注**默认上屏 + 一个总开关**;
明细覆盖层 / 全屏公告期间**自动收起**、结束后自动回来;编辑权限**录入员及以上**;单屏覆盖按**实例 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。
488 lines
18 KiB
Go
488 lines
18 KiB
Go
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)
|
||
}
|
||
}
|