## 分批发布(现场: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 自定义大屏模板设计文档)。
292 lines
10 KiB
Go
292 lines
10 KiB
Go
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)
|
||
}
|
||
}
|