Files
Scoreboard/server/internal/dto/settings_test.go
T
AskaEth fc96d17660 feat: 字体可配并随赛事包打包 + 大屏调试标识收敛 + 报名表 CSV 编码兼容
三块改动,均为现场反馈:

一、字体配置与随包打包(此前「没有入口可以配置字体」,导出包也不带字体)
- 服务端新增 httpapi/fonts.go 与 4 条路由:GET /api/fonts(recorder)、
  POST /api/fonts(admin,multipart file)、DELETE /api/fonts/{name}(admin)、
  GET /uploads/fonts/{name}(匿名——大屏 @font-face 不带会话)
- 上传限 ttf/otf/woff/woff2、单个 20 MiB;文件名经 safeFontName 收敛且需与
  fontContentType 往返一致,挡住路径穿越;重名 409、删除被引用字体 409
- 新增设置键 display.font_title / display.font_body(DTO fontTitle/fontBody,
  随快照下发给大屏),写入时校验取值合法
- 导出:接上一直没被赋值的 IncludeFonts/FontsDir;包内落点用 uploads/fonts/
  (运行时只读这里,落顶层 fonts/ 等于没人读)
- 大屏按配置动态注册字体并挂 --display-font-title/body,加载失败静默回落默认字体
- 管理端「赛事信息」新增字体区块:标题/正文字体下拉、上传、字体库、实时预览

二、大屏显示收敛
- 数据版本号角标 vN 彻底移除(原先只有窗口化窗口会显示,两处显隐口径不一致)
- 窗口化窗口按 F11 全屏后自绘标题栏不让位:根因是 Console 用 win.setFullScreen()
  做窗口级全屏,而页面用 document.fullscreenElement 判断,后者恒为 null;
  新增 display-preload.js 下发窗口级全屏状态,页面侧 shell.ts 把两级全屏取或,
  无 preload 时(浏览器直连 /display)回落到文档级监听

三、报名表 CSV 编码兼容(用户报「解析中文字符遇到编码问题」)
- 服务端不再把原始字节直接喂给 csv.Reader:先摘 UTF-8 BOM,再认 UTF-16 LE/BE
  BOM,其余非合法 UTF-8 按 GB18030 解(GBK 是其子集)
- 修复前症状:GBK 文件(Excel 另存为 CSV 的默认编码)中文变乱码;带 BOM 的文件
  (Excel「CSV UTF-8」,也正是我们自己模板的格式)把 BOM 拼进第一列表头,
  报「报名表缺少列 event_code」——连「下载模板→填→传回来」都走不通
- golang.org/x/text 由 indirect 提升为直接依赖,未新增外部依赖

测试:服务端 12 包全绿(新增 fonts_test.go / fonts_internal_test.go /
handlers_import_encoding_test.go);前端 vue-tsc 通过、pnpm build 通过、157 例全过;
Console 语法检查 17 个文件通过。docs/ 与 devdocs/ 同步更新。
2026-09-10 17:45:11 +08:00

200 lines
7.6 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"
"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)
}
}
// 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)
}
}