Files
Scoreboard/server/internal/httpapi/display_schedule_test.go
T
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

651 lines
27 KiB
Go
Raw 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 httpapi_test
import (
"database/sql"
"encoding/json"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
)
// 一份最小可用的日程配置:含中文、含 note / done 缺省、含单屏覆盖。
// 字段名与 web/src/shared/types.ts 的 ScheduleSidebarConfig 逐字对应。
func schedulePayload() map[string]any {
return map[string]any{
"items": []map[string]any{
{"time": "08:30", "title": "男子 100 米预赛", "note": "检录 08:00", "done": true},
{"time": "上午", "title": "开幕式"},
{"time": "待定", "title": "闭幕式", "done": false},
},
"visible": true,
"side": "left",
"widthPercent": 22,
"showNote": true,
"instanceOverrides": map[string]any{"browser-1": "on", "browser-2": "off"},
}
}
// postSchedule 下发一条 schedule 指令;csrf 只在已登录请求上带(未登录请求没有 CSRF Cookie)。
func postSchedule(t *testing.T, env *testEnv, client *http.Client, payload any, csrf bool) (int, envelope) {
t.Helper()
return env.do(client, http.MethodPost, "/api/display/command",
map[string]any{"action": "schedule", "payload": payload}, csrf)
}
// 日程保存:**先转发、再落库**,并留下 settings 变更(大屏靠它 resync 才看得到)。
// 落库的 JSON 形状要与前端类型逐字对上:小驼峰键、note/done 可缺、中文原样。
func TestDisplayCommandScheduleForwardsThenPersists(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
before := changeLogCount(t, env, "settings", "upsert")
status, env2 := postSchedule(t, env, client, schedulePayload(), true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("保存日程应 200,得到 %d %+v", status, env2.Error)
}
// 转发:大屏要立刻看到侧边栏(热开关),不能只落库等下次快照。
if len(sender.sent) != 1 || sender.sent[0] != realtime.CmdSchedule {
t.Fatalf("日程必须下发给实时层,得到 %+v", sender.sent)
}
if len(sender.targets) != 1 || sender.targets[0] != nil {
t.Fatalf("不带 targets 时应按广播下发(nil),得到 %+v", sender.targets)
}
// 落库:只写 settings 行不会有广播,必须有这条 change_log,且它的载荷要带上日程
// ——大屏就是靠这条变更重拉设置、把侧边栏显示出来的。
if got := changeLogCount(t, env, "settings", "upsert"); got != before+1 {
t.Fatalf("保存日程应新增 1 条 settings change_log,得到 %d → %d", before, got)
}
var payload string
if err := env.ev.QueryRow(
`SELECT payload_json FROM change_log WHERE entity_type = 'settings' AND action = 'upsert'
ORDER BY id DESC LIMIT 1`).Scan(&payload); err != nil {
t.Fatalf("读 change_log 载荷: %v", err)
}
if !strings.Contains(payload, `"schedule"`) || !strings.Contains(payload, "男子 100 米预赛") {
t.Fatalf("settings 变更的载荷应带上整份日程,得到 %s", payload)
}
raw := settingValue(t, env, "display.schedule")
var stored map[string]any
if err := json.Unmarshal([]byte(raw), &stored); err != nil {
t.Fatalf("落库值应是 JSON: %v (%s)", err, raw)
}
for _, key := range []string{"items", "visible", "side", "widthPercent", "showNote", "instanceOverrides"} {
if _, ok := stored[key]; !ok {
t.Fatalf("落库 JSON 缺少小驼峰键 %s: %s", key, raw)
}
}
if stored["visible"] != true || stored["side"] != "left" || stored["showNote"] != true {
t.Fatalf("开关与左右应原样落库: %s", raw)
}
if stored["widthPercent"].(float64) != 22 {
t.Fatalf("宽度应落库为 22: %s", raw)
}
items := stored["items"].([]any)
if len(items) != 3 {
t.Fatalf("应落库 3 条日程,得到 %d: %s", len(items), raw)
}
first := items[0].(map[string]any)
if first["time"] != "08:30" || first["title"] != "男子 100 米预赛" ||
first["note"] != "检录 08:00" || first["done"] != true {
t.Fatalf("第一条日程应原样落库: %+v", first)
}
// note / done 是可缺字段:没填的那条不该被补成空串,前端按可选字段解析。
second := items[1].(map[string]any)
if _, ok := second["note"]; ok {
t.Fatalf("没填备注的条目不该带 note 键: %+v", second)
}
if _, ok := second["done"]; ok {
t.Fatalf("未勾选的条目不该带 done 键: %+v", second)
}
overrides := stored["instanceOverrides"].(map[string]any)
if overrides["browser-1"] != "on" || overrides["browser-2"] != "off" {
t.Fatalf("单屏覆盖应原样落库: %+v", overrides)
}
audit := waitDisplayAudit(t, env)
for _, want := range []string{`"action":"schedule"`, `"matched":1`} {
if !strings.Contains(audit, want) {
t.Fatalf("审计应含 %s,得到 %s", want, audit)
}
}
}
// 保存 → 读回一致:管理端与快照走的是同一个 ParseEventSettings,读回来的形状必须与写进去的一样。
func TestDisplayCommandScheduleRoundTrip(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
env.createUser("boss", "boss-pass", "admin")
boss, _ := env.client()
env.login(boss, "boss", "boss-pass")
if status, e := postSchedule(t, env, client, schedulePayload(), true); status != http.StatusOK {
t.Fatalf("保存日程应 200,得到 %d %+v", status, e.Error)
}
_, env2 := env.do(boss, http.MethodGet, "/api/settings", nil, false)
settings := decodeData[map[string]any](t, env2)
schedule, ok := settings["display"].(map[string]any)["schedule"].(map[string]any)
if !ok {
t.Fatalf("读回的 settings 应带 display.schedule: %+v", settings["display"])
}
if schedule["visible"] != true || schedule["side"] != "left" || schedule["showNote"] != true {
t.Fatalf("读回的开关 / 左右 / 备注开关应与写进去的一致: %+v", schedule)
}
if schedule["widthPercent"].(float64) != 22 {
t.Fatalf("读回的宽度应为 22: %+v", schedule)
}
items := schedule["items"].([]any)
if len(items) != 3 {
t.Fatalf("读回 3 条日程,得到 %d: %+v", len(items), items)
}
last := items[2].(map[string]any)
if last["time"] != "待定" || last["title"] != "闭幕式" {
t.Fatalf("手填的自由时间文本(「待定」)应原样读回: %+v", last)
}
// done 是可缺字段:false 不下线,前端按「没这个键 = 未完成」解析(这也是「当前项」的判据)。
if v, ok := last["done"]; ok {
t.Fatalf("未完成项不该带 done 键,得到 %+v", v)
}
if overrides := schedule["instanceOverrides"].(map[string]any); overrides["browser-2"] != "off" {
t.Fatalf("单屏覆盖应原样读回: %+v", schedule)
}
}
// 上限与非法输入:一律 400 + 中文提示,且**不转发、不落库**。
// 静默截断会让操作员以为配置生效了,所以这里必须拒绝而不是存一半。
func TestDisplayCommandScheduleRejectsOverLimit(t *testing.T) {
items := func(n int) []map[string]any {
out := make([]map[string]any, 0, n)
for i := 0; i < n; i++ {
out = append(out, map[string]any{"time": "08:30", "title": "第 " + strings.Repeat("x", i%3) + " 项"})
}
return out
}
payloadWith := func(mutate func(map[string]any)) map[string]any {
p := schedulePayload()
mutate(p)
return p
}
for _, tc := range []struct {
name string
payload any
want string
}{
{"条目超过 40 条", payloadWith(func(p map[string]any) { p["items"] = items(41) }), "日程条目最多 40 条"},
{"标题为空", payloadWith(func(p map[string]any) {
p["items"] = []map[string]any{{"time": "08:30", "title": "\n\x00"}}
}), "第 1 条日程的标题不能为空"},
{"时间超过 20 字", payloadWith(func(p map[string]any) {
p["items"] = []map[string]any{{"time": strings.Repeat("时", 21), "title": "开幕式"}}
}), "时间最长 20 个字符"},
{"标题超过 40 字", payloadWith(func(p map[string]any) {
p["items"] = []map[string]any{{"time": "08:30", "title": strings.Repeat("赛", 41)}}
}), "标题最长 40 个字符"},
{"备注超过 60 字", payloadWith(func(p map[string]any) {
p["items"] = []map[string]any{{"time": "08:30", "title": "开幕式", "note": strings.Repeat("备", 61)}}
}), "备注最长 60 个字符"},
{"第二项越界也要指出是第几条", payloadWith(func(p map[string]any) {
p["items"] = []map[string]any{
{"time": "08:30", "title": "开幕式"},
{"time": "09:00", "title": strings.Repeat("赛", 41)},
}
}), "第 2 条日程的标题最长 40 个字符"},
{"单屏覆盖超过 50 条", payloadWith(func(p map[string]any) {
overrides := map[string]any{}
for i := 0; i < 51; i++ {
overrides["screen-"+string(rune('a'+i%26))+strings.Repeat("x", i/26)] = "on"
}
p["instanceOverrides"] = overrides
}), "单屏覆盖最多 50 条"},
{"单屏覆盖取值不是三态", payloadWith(func(p map[string]any) {
p["instanceOverrides"] = map[string]any{"browser-1": "maybe"}
}), "取值只能是 inherit / on / off"},
{"单屏覆盖的实例 id 超长", payloadWith(func(p map[string]any) {
p["instanceOverrides"] = map[string]any{strings.Repeat("i", 121): "on"}
}), "实例 id 最长 120 个字符"},
{"payload 不是对象", []any{1, 2, 3}, "格式不正确"},
} {
t.Run(tc.name, func(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
before := changeLogCount(t, env, "settings", "upsert")
status, env2 := postSchedule(t, env, client, tc.payload, true)
if status != http.StatusBadRequest || env2.Error == nil {
t.Fatalf("非法日程应 400,得到 %d %+v", status, env2.Error)
}
if env2.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("错误码应为 VALIDATION_FAILED,得到 %+v", env2.Error)
}
if !strings.Contains(env2.Error.Message, tc.want) {
t.Fatalf("提示应含 %q(说清哪一项超限),得到 %q", tc.want, env2.Error.Message)
}
if len(sender.sent) != 0 {
t.Fatalf("非法日程不该下发(否则大屏先画一帧错的),得到 %+v", sender.sent)
}
if got := settingOrEmpty(t, env, "display.schedule"); got != "" {
t.Fatalf("非法日程不该落库,得到 %q", got)
}
if got := changeLogCount(t, env, "settings", "upsert"); got != before {
t.Fatalf("非法日程不该产生 settings 变更,得到 %d → %d", before, got)
}
})
}
}
// 时间**允许为空**:管理端的 validateSchedule 只卡时间长度、不卡必填(「先把标题排上、
// 时间回头补」是现场的常见流程),服务端必须同口径 —— 否则「校验不过时一个请求都不发」
// 这条承诺在这一格上不成立,操作员会先看到请求发出去、再被退一句 400。
// 空串 / 全空白 / 只含控制字符都算空,落库就是空串(不是特殊占位,也不是整条丢掉)。
func TestDisplayCommandScheduleAllowsEmptyTime(t *testing.T) {
for _, tc := range []struct {
name string
time string
}{
{"空串", ""},
{"全空白", " "},
{"只含控制字符", "\n\t\x00"},
} {
t.Run(tc.name, func(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
status, env2 := postSchedule(t, env, client, map[string]any{
"items": []map[string]any{{"time": tc.time, "title": "闭幕式"}},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("时间为空应保存成功,得到 %d %+v", status, env2.Error)
}
if len(sender.sent) != 1 || sender.sent[0] != realtime.CmdSchedule {
t.Fatalf("时间为空的日程也要下发给大屏,得到 %+v", sender.sent)
}
var stored struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil {
t.Fatalf("解析落库值: %v", err)
}
if len(stored.Items) != 1 {
t.Fatalf("条目应保留,得到 %+v", stored.Items)
}
if got, ok := stored.Items[0]["time"]; !ok || got != "" {
t.Fatalf("时间应原样落库为空串(键要在、值要是空串),得到 %+v", stored.Items[0])
}
if stored.Items[0]["title"] != "闭幕式" {
t.Fatalf("标题应原样落库: %+v", stored.Items[0])
}
})
}
}
// showNote 缺省 = **true**(备注默认上屏):没带这个字段的客户端不能把备注悄悄关掉。
// bool 零值分不出「键没带」与「显式 false」,所以这条用例两种都要钉。
func TestDisplayCommandScheduleShowNoteDefaultsTrue(t *testing.T) {
for _, tc := range []struct {
name string
body map[string]any
want bool
}{
{"不带 showNote", map[string]any{}, true},
{"显式 true", map[string]any{"showNote": true}, true},
{"显式 false", map[string]any{"showNote": false}, false},
} {
t.Run(tc.name, func(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
env.createUser("boss", "boss-pass", "admin")
boss, _ := env.client()
env.login(boss, "boss", "boss-pass")
body := map[string]any{"items": []map[string]any{{"time": "08:30", "title": "开幕式"}}}
for k, v := range tc.body {
body[k] = v
}
if status, e := postSchedule(t, env, client, body, true); status != http.StatusOK {
t.Fatalf("应 200,得到 %d %+v", status, e.Error)
}
// 落库的 JSON 里必须是显式的布尔(不吃零值),再看读回的口径。
var stored struct {
ShowNote *bool `json:"showNote"`
}
if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil {
t.Fatalf("解析落库值: %v", err)
}
if stored.ShowNote == nil || *stored.ShowNote != tc.want {
t.Fatalf("落库的 showNote 应为 %v,得到 %v", tc.want, stored.ShowNote)
}
_, env2 := env.do(boss, http.MethodGet, "/api/settings", nil, false)
schedule := decodeData[map[string]any](t, env2)["display"].(map[string]any)["schedule"].(map[string]any)
if schedule["showNote"] != tc.want {
t.Fatalf("读回的 showNote 应为 %v,得到 %v", tc.want, schedule["showNote"])
}
})
}
// 类型不对(不是布尔)按「格式不正确」挡在门外,而不是悄悄当开:
// 写入口是人在操作,含糊放过一个坏 payload 只会让现场更难看懂。
// (读侧没有出口,遇到这种值就回落 true,见 dto.ScheduleShowNote。)
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
status, env2 := postSchedule(t, env, client, map[string]any{
"items": []map[string]any{{"time": "08:30", "title": "开幕式"}}, "showNote": "no",
}, true)
if status != http.StatusBadRequest || env2.Error == nil {
t.Fatalf("showNote 不是布尔应 400,得到 %d %+v", status, env2.Error)
}
if !strings.Contains(env2.Error.Message, "格式不正确") {
t.Fatalf("提示应说清是格式问题,得到 %q", env2.Error.Message)
}
}
// `inherit` 是「没有这条设置」(缺省就是它):写侧接受但不落库,绝不白占 50 条预算。
func TestDisplayCommandScheduleDropsInheritOverrides(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
status, env2 := postSchedule(t, env, client, map[string]any{
"items": []map[string]any{{"time": "08:30", "title": "开幕式"}},
"instanceOverrides": map[string]any{"browser-1": "inherit", "browser-2": "on"},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("inherit 应被接受(不算非法),得到 %d %+v", status, env2.Error)
}
var stored struct {
InstanceOverrides map[string]string `json:"instanceOverrides"`
}
if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil {
t.Fatalf("解析落库值: %v", err)
}
if len(stored.InstanceOverrides) != 1 || stored.InstanceOverrides["browser-2"] != "on" {
t.Fatalf("只该留下 browser-2=on,得到 %+v", stored.InstanceOverrides)
}
// 全是 inherit 时整块键都不该出现(前端按可选字段解析)。
if status, e := postSchedule(t, env, client, map[string]any{
"items": []map[string]any{{"time": "08:30", "title": "开幕式"}},
"instanceOverrides": map[string]any{"browser-1": "inherit"},
}, true); status != http.StatusOK {
t.Fatalf("应 200,得到 %d %+v", status, e.Error)
}
if raw := settingValue(t, env, "display.schedule"); strings.Contains(raw, "instanceOverrides") {
t.Fatalf("全是 inherit 时不该落库这个键: %s", raw)
}
}
// 清洗:控制字符一律剥掉(复用 realtime.CleanUserText),首尾空白去掉;
// 归一:widthPercent 夹到 15–40、缺省回落默认宽度,side 认不出时回落 right。
func TestDisplayCommandScheduleCleansAndClamps(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
status, env2 := postSchedule(t, env, client, map[string]any{
"items": []map[string]any{
{"time": " 08:30\t", "title": "男子\n100\x00米预赛", "note": "检录\r\n08:00", "done": true},
},
"visible": true,
"side": "middle",
"widthPercent": 100,
"showNote": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("保存在上限内的日程应 200,得到 %d %+v", status, env2.Error)
}
var stored struct {
Items []struct {
Time string `json:"time"`
Title string `json:"title"`
Note string `json:"note"`
Done bool `json:"done"`
} `json:"items"`
Side string `json:"side"`
WidthPercent float64 `json:"widthPercent"`
}
if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil {
t.Fatalf("解析落库值: %v", err)
}
if stored.Items[0].Time != "08:30" || stored.Items[0].Title != "男子100米预赛" ||
stored.Items[0].Note != "检录08:00" {
t.Fatalf("控制字符应被剥掉、首尾空白应去掉: %+v", stored.Items[0])
}
if !stored.Items[0].Done {
t.Fatalf("已完成应原样保留: %+v", stored.Items[0])
}
if stored.WidthPercent != 40 {
t.Fatalf("线上限 40:widthPercent 100 应夹到 40,得到 %v", stored.WidthPercent)
}
if stored.Side != "right" {
t.Fatalf("认不出的 side 应回落 right,得到 %q", stored.Side)
}
// 转发的也是归一后的值(否则大屏会先按 100% 画一帧)。
if audit := waitDisplayAudit(t, env); !strings.Contains(audit, `"widthPercent":40`) {
t.Fatalf("审计里的配置应是归一后的值,得到 %s", audit)
}
// 下上限与缺省。
for _, tc := range []struct {
name string
body map[string]any
side string
width float64
}{
{"过窄夹到 15", map[string]any{"items": []any{}, "side": "left", "widthPercent": 5}, "left", 15},
{"缺省回落 22", map[string]any{"items": []any{}}, "right", 22},
{"负数回落 22", map[string]any{"items": []any{}, "widthPercent": -3}, "right", 22},
{"大小写不敏感", map[string]any{"items": []any{}, "side": "LEFT"}, "left", 22},
} {
t.Run(tc.name, func(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
if status, e := postSchedule(t, env, client, tc.body, true); status != http.StatusOK {
t.Fatalf("应 200,得到 %d %+v", status, e.Error)
}
var got struct {
Side string `json:"side"`
WidthPercent float64 `json:"widthPercent"`
}
if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &got); err != nil {
t.Fatalf("解析落库值: %v", err)
}
if got.Side != tc.side || got.WidthPercent != tc.width {
t.Fatalf("归一结果应为 side=%s width=%v,得到 side=%s width=%v",
tc.side, tc.width, got.Side, got.WidthPercent)
}
})
}
}
// 权限:与大屏公告(同一条 display:command 通道)同档 —— 未登录 401、录入员 200、管理员 200。
// 两种 action 各发一次,用同一次请求比对状态码,避免「档位被悄悄改动」看不出来。
func TestDisplayCommandSchedulePermissionMatchesAnnounce(t *testing.T) {
sender := &strictDisplaySender{}
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Display = sender })
env.createUser("recorder", "recorder-pass", "recorder")
env.createUser("boss", "boss-pass", "admin")
anon, _ := env.client()
// 未登录请求不带 CSRF(中间件对 guest 请求直接放行,由 RequireWrite 判权限)。
scheduleStatus, _ := postSchedule(t, env, anon, schedulePayload(), false)
announceStatus, _ := env.do(anon, http.MethodPost, "/api/display/command",
map[string]any{"action": "announce", "payload": map[string]any{"text": "决赛推迟"}}, false)
if scheduleStatus != announceStatus {
t.Fatalf("日程与公告应同档:未登录日程 %d,公告 %d", scheduleStatus, announceStatus)
}
if scheduleStatus != http.StatusUnauthorized {
t.Fatalf("未登录应被挡下(写接口未登录一律 401),得到 %d", scheduleStatus)
}
for _, who := range []struct{ user, pass string }{{"recorder", "recorder-pass"}, {"boss", "boss-pass"}} {
client, _ := env.client()
env.login(client, who.user, who.pass)
if status, e := postSchedule(t, env, client, schedulePayload(), true); status != http.StatusOK {
t.Fatalf("%s 保存日程应 200,得到 %d %+v", who.user, status, e.Error)
}
}
}
// 定点点名的屏全离线:与既有 action 同样的 404,且日程不落库、不产生 settings 变更。
func TestDisplayCommandScheduleOfflineTargetReturns404(t *testing.T) {
matched := 0
sender := &strictDisplaySender{matched: &matched}
env, client := newDisplayEnv(t, sender)
before := changeLogCount(t, env, "settings", "upsert")
status, env2 := env.do(client, http.MethodPost, "/api/display/command", map[string]any{
"action": "schedule",
"payload": schedulePayload(),
"targets": []string{"gone-screen"},
}, true)
if status != http.StatusNotFound || env2.Error == nil {
t.Fatalf("目标离线应 404,得到 %d %+v", status, env2.Error)
}
if env2.Error.Message != "目标大屏已离线,请刷新在线列表" {
t.Fatalf("404 消息应与既有 action 一致,得到 %q", env2.Error.Message)
}
if got := settingOrEmpty(t, env, "display.schedule"); got != "" {
t.Fatalf("没命中任何屏时日程不该落库,得到 %q", got)
}
if got := changeLogCount(t, env, "settings", "upsert"); got != before {
t.Fatalf("定向失败不该产生 settings 变更,得到 %d → %d", before, got)
}
}
// 读取侧容错:库里那个值被改坏(手工改库 / 写到一半断电)时,settings 照常 200、
// display.schedule 当空,大屏按「没有这个功能」处理 —— 一条脏设置绝不能把大屏打不开。
func TestSettingsTolerantToBrokenScheduleValue(t *testing.T) {
env := newTestEnv(t)
env.createUser("boss", "boss-pass", "admin")
client, _ := env.client()
env.login(client, "boss", "boss-pass")
writeRaw := func(raw string) {
t.Helper()
if _, err := env.ev.Exec(
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
"display.schedule", raw); err != nil {
t.Fatalf("写入配置: %v", err)
}
}
readSchedule := func() (map[string]any, bool) {
t.Helper()
status, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("坏数据下 settings 仍应 200,得到 %d %+v", status, env2.Error)
}
display := decodeData[map[string]any](t, env2)["display"].(map[string]any)
v, ok := display["schedule"].(map[string]any)
return v, ok
}
for _, tc := range []struct{ name, raw string }{
{"坏 JSON", `{items: [`},
{"不是对象", `"日程"`},
{"截断的 JSON", `{"items":[{"time":"08:30","title":"开幕`},
} {
writeRaw(tc.raw)
if _, ok := readSchedule(); ok {
t.Fatalf("%s:坏数据应读成 null(当空),得到非空值", tc.name)
}
}
// 结构上不成立的部分丢掉:**标题**缺失的条目、非三态的覆盖,越界的条数取前 40。
// 时间为空的条目是合法的,必须留着(与写入口、管理端同口径),`time` 读成空串。
writeRaw(`{"items":[{"time":"08:30","title":"开幕式"},{"time":"","title":"时间待定"},` +
`{"time":"09:00","title":""},{"title":"连 time 键都没有"}],` +
`"visible":true,"side":"up","widthPercent":999,"showNote":false,` +
`"instanceOverrides":{"browser-1":"on","browser-2":"maybe","":"on"}}`)
got, ok := readSchedule()
if !ok {
t.Fatal("结构合法的部分应照样读出来")
}
items := got["items"].([]any)
if len(items) != 3 {
t.Fatalf("只有标题为空的条目该丢掉,应留 3 条,得到 %+v", items)
}
if empty := items[1].(map[string]any); empty["title"] != "时间待定" || empty["time"] != "" {
t.Fatalf("时间为空的条目应保留且 time 读成空串,得到 %+v", empty)
}
if noKey := items[2].(map[string]any); noKey["title"] != "连 time 键都没有" || noKey["time"] != "" {
t.Fatalf("连 time 键都没有的条目也该保留(当空串),得到 %+v", noKey)
}
if got["side"] != "right" {
t.Fatalf("认不出的 side 读侧也应回落 right,得到 %v", got["side"])
}
if got["widthPercent"].(float64) != 40 {
t.Fatalf("越界宽度读侧应夹到 40,得到 %v", got["widthPercent"])
}
if got["visible"] != true {
t.Fatalf("总开关应照样读出来: %+v", got)
}
overrides := got["instanceOverrides"].(map[string]any)
if len(overrides) != 1 || overrides["browser-1"] != "on" {
t.Fatalf("非三态与空 id 的覆盖应丢掉,只留 browser-1: %+v", overrides)
}
// 条数越界:读侧只取前 40 条,不报错。
var sb strings.Builder
sb.WriteString(`{"items":[`)
for i := 0; i < 41; i++ {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(`{"time":"08:30","title":"项"}`)
}
sb.WriteString(`],"visible":true}`)
writeRaw(sb.String())
got, ok = readSchedule()
if !ok {
t.Fatal("条数越界时其余字段仍应读出来")
}
if got := got["items"].([]any); len(got) != 40 {
t.Fatalf("读侧应只取前 40 条,得到 %d", len(got))
}
}
// 未接入实时层时(s.display == nil)日程照样落库:大屏后连上来靠的是 settings + 快照。
func TestDisplayCommandSchedulePersistsWithoutRealtime(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
if status, e := postSchedule(t, env, client, schedulePayload(), true); status != http.StatusOK {
t.Fatalf("没有实时层时应落库并 200,得到 %d %+v", status, e.Error)
}
var n int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM settings WHERE key = 'display.schedule'`).Scan(&n); err != nil {
t.Fatalf("查配置: %v", err)
}
if n != 1 {
t.Fatalf("日程应落库,得到 %d 行", n)
}
}
// 漏了 payload(客户端 bug)时给中文 400,而不是把大屏切成一个空日程。
func TestDisplayCommandScheduleRequiresPayload(t *testing.T) {
sender := &strictDisplaySender{}
env, client := newDisplayEnv(t, sender)
status, env2 := env.do(client, http.MethodPost, "/api/display/command",
map[string]any{"action": "schedule"}, true)
if status != http.StatusBadRequest || env2.Error == nil {
t.Fatalf("缺 payload 应 400,得到 %d %+v", status, env2.Error)
}
if !strings.Contains(env2.Error.Message, "缺少 payload") {
t.Fatalf("提示应说清缺什么,得到 %q", env2.Error.Message)
}
if len(sender.sent) != 0 {
t.Fatalf("缺 payload 不该下发,得到 %+v", sender.sent)
}
var v sql.NullString
if err := env.ev.QueryRow(`SELECT value FROM settings WHERE key = 'display.schedule'`).Scan(&v); err == nil {
t.Fatalf("缺 payload 不该落库,得到 %q", v.String)
}
}