Files
Scoreboard/server/internal/dto/settings_test.go
T
AskaEth 08a5740fb2 feat: 分批发布 + 实时赛况页 + 显式批次层 + 成绩框实时提示 + 窄屏适配 + 版本号单一真相
## 分批发布(现场:100 米跑道只有 8 条,不能等所有人跑完才发)
- 空成绩从"错误"降级为"提示",不再阻塞发布;真会算错(写法无法识别、秒位 ≥ 60、
  手动名次缺名次)仍挡住。发布确认框说明"只发已填的 M 人,其余保持未发布"。
- 服务端核实:publishResults/computeEventScore 本就不会因缺成绩报错
  (collectCandidates 直接跳过没有 results 的报名项),根因在前端防呆。

## 实时赛况(新大屏页 + 定向接口)
- 新增 `GET /api/display/live`(guest 可读):返回「当前项目」判定 + 该项目**全量**已发布成绩
  (不受快照 latestResults 的 20 行截断)。回退链:manual 指定 > 排期命中 > 最近发布。
  设置键 display.live_events / display.live_source。
- 大屏新页 `live`「实时赛况」:1 个项目单栏、2 个自动左右分栏;失败保留上一次画面
  (下一场开始前持续显示上一场);30s 轮询(判定依赖当前时间)+ 快照变更触发。
- 管理端「大屏控制」新增「实时赛况」卡;该卡走 PUT /api/settings(admin-only),
  故对非管理员整组置灰 + 提示条(不新增角色)。

## 显式批次层(组别之下)
- 迁移 0019 `entries.heat`;批次仅是录入/展示分组,**不参与算分**(有"分批与不分批结果
  完全一致"的断言守住)。批量设置接口 + 导入 CSV 可选列 heat + 录入页批次下拉 +
  一键均分(按 N 人切片,已有批次号接着往后排)。

## 成绩输入框实时悬浮提示
- 聚焦格旁的浮动气泡:pointer-events:none、不占布局不改行高、贴底自动上翻;
  合法显示解析结果(1:06 → = 66 秒)、可疑/不合法显示原因并带那一格原文;
  与发布防呆共用同一套纯函数(entryGuards.ts),口径不会分叉。

## 窄屏 / 手机端布局
- 真浏览器(Playwright + chromium)在 1440/1024/768/390 四宽度逐页审计 + 溢出探针,
  四轮迭代修复;桌面端 1440 前后像素差异 < 0.25%(无回归);修掉如「修改密码」输入框
  塌成 22px 这类真问题。

## 版本号单一真相
- 新增仓库根 `VERSION`:构建时注入二进制(-X main.version)并同步 console/package.json
  的版本(产物名跟随)。源码里不再有人工维护的版本号(兜底值改为 `dev`,故意不写数字)。

测试:服务端 13 包全绿;前端 vue-tsc 通过、249 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q80、新增 devdocs/16 自定义大屏模板设计文档)。
2026-09-11 04:05:02 +08:00

292 lines
10 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 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)
}
}