本批七项(三处是现场报的 bug,四处是新功能): ① 大屏上「纯团体项目」(如团体 4x100 接力)的名次全是「-」 根因不在算分:纯团体名次只落在 channel_ranks,而 results.rank 被有意收窄成 「个人名次」、对团体主体一直是 NULL;大屏的两个读者只读 results.rank、没有兜底 (导出件与积分明细下钻早就有兜底,所以当时是「导出有、屏幕没有」)。 改成读侧回落:results.rank 优先、为空时用通道名次。算分与写回一行未动。 ⚠️ 优先级的理由:只更新名次的发布只更新 results.rank、不重建通道名次, 若通道名次优先,个人项目会在那种发布之后显示旧名次。 ⚠️ 已发布的数据不需要重发或重算,升级后大屏下一次刷新即显示。 ⚠️ 仍未覆盖:只在「团体名次」卡片里按单位录名次、成绩行上没填的那类(无成绩行可挂)。 ② 分项成绩页第 3 栏看不到第 1 名 根因:快照 latestResults 的窗口原来按「行」截断(LIMIT 20),而屏上是一组一栏, 切口只能落在最旧那一栏(通常是第 3 栏)内部,把该组建号最早的行挤掉 (手录项目从第 1 名往下录 → 第 1 名建号最早)。 改成「最近 8 组 × 每组 10 行」(先按每组最新发布时间取 8 组,再每组按名次取前 10 行); 组的 publishedAt 改为组内最新一条。底栏「最新完成项目」一起吃这个修复。 ⚠️ 取舍:单组超过 10 行的部分不再下发(前端每栏只画 6 行、实时赛况上限 10 行); 将来把每栏行数上限调到 10 以上时,必须同步调这里的 R。 ③ 录分列表「运动员 / 队」列可限宽 + 逐行展开收起 多人团体名单以前把整列撑宽。收起限宽 14em(省略号)、超过 24 个显示宽度 (中文算 2)出现「展开 / 收起」;个人项目不出现按钮;展开状态只在本机内存。 取舍:展开那一行会把列略微撑宽(成绩列右移约 80px),收起即恢复。 ④ 录入页「以决赛成绩发布」从常驻勾选框改成发布弹窗里二选一 默认「只更新名次(不计分、不发奖牌)」;每次打开都复位、不吃上一次; Alt+Enter 走同一个入口;取消 = 什么都不发生。 审核页保持原样的常驻勾选框(两个页面口径一样、形态不同)。 ⑤ 大屏控制页「在线大屏」列表:可读设备名 + 手动重命名 + 修重复条目 - 自动设备标签(系统 · 浏览器 · 分辨率 · 手机/平板/桌面),由大屏端在握手时上报, 服务端截断到 80/300 字符并清掉控制字符;老客户端不报时列表与升级前逐字一致。 ⚠️ 浏览器没有读主机名/设备名的 API,自动标签只是辅助区分。 - 手动重命名(录入员及以上,与切页同档):存在系统库 settings 的 display.instance_aliases(机器级、重启与切赛事都不丢),清除即回到自动标签; 显示优先级 alias → device → id(id 降级成小字)。上限 200 条,满了拒绝新增并提示, 不静默顶掉别的设备。设备离线过久条目被清理后别名仍留着。 - 修:以前给局域网设备切页后列表底部会多出一条该设备的离线条目 (翻页会补发握手,而浏览器没有稳定 id、服务端每次新开一条实例)。 现在浏览器本机记住实例 id(localStorage),服务端也复用同一条连接已有的 id。 ⑥ 局域网地址条目都能出二维码 以前只有第一条常驻出码;现在其余每条多一个「二维码」按钮:点一下临时显示, 再点一次 / Esc / 点空白处收起,同一时刻只留一个,且只为当前打开的那条懒生成 SVG (不在渲染时为每条地址都算一遍)。第一条的常驻码与「复制链接」未变。 ⑦ 录分列表加「搜姓名 / 号码布」搜索框(人多项目找人不费劲) 复用既有的「姓名 / 工号 / 号码布」匹配口径(未新增第三份),支持模糊、大小写不敏感、 全角空格、号码布数字口径(007 搜 7、7 搜 007);命中高亮;提示「找到 N 条 / 共 M 条」; × / Esc / 清除三种清空方式;搜不到给明确空状态。 ⚠️ 搜索只影响表格显示:发布范围、进度条(仍是该批次总人数)、勾选、清空范围、 预览算分一律不受影响,界面上也写明了这一点。 ⚠️ 搜索状态下粘贴跟随「屏幕上看到的顺序」(隐藏的行不会被写到)——避免第二条成绩 静默写到看不见的人身上。 测试:四套自检 ×2 全绿(finalcheck_0515*.log:前端 1096 例 / Console 96 例 / 服务端 14 条 ok, EXIT=0)。①②都做了红/绿对照(改回旧实现,新用例当场变红;②的失败信息即现场形状 「第 3 栏应从第 1 名开始,实际名次 [5 6 7 8]」)。⑥⑦用真浏览器(playwright + chromium) 实机跑过(24 项 / 29 项全过,含「进度条数字不变」「搜索框里按 Alt+Enter 什么也不发生」)。 无数据库迁移;已有成绩、名次、积分与榜单不变。 文档:《升级说明》新增 dev_0.5.15;《使用手册》发布/录入/大屏控制三块与批量订正过期说法; 《常见问题》新增 Q105–Q107;《部署指南》《上线检查清单》同步; devdocs/05(hello 新字段 + 别名接口)、13(第五十五轮)、16/06/07/18 与两处前端注释的旧窗口说法一并订正。
174 lines
6.6 KiB
Go
174 lines
6.6 KiB
Go
package realtime
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// ---------- 设备字段的清洗与截断 ----------
|
||
|
||
func TestSanitizeDeviceText(t *testing.T) {
|
||
longUA := strings.Repeat("汉", 400)
|
||
cases := []struct {
|
||
name string
|
||
in string
|
||
limit int
|
||
want string
|
||
}{
|
||
{"原样保留可读标签", "安卓 13 · Chrome 120 · 1080×2400 @3x · 手机", maxDeviceChars,
|
||
"安卓 13 · Chrome 120 · 1080×2400 @3x · 手机"},
|
||
// 控制字符是能把管理端列表排版搞乱的东西(换行 / 制表 / 退格 / DEL),一律清掉。
|
||
{"换行与制表被清掉", " 安卓 13\n\tChrome 120 \r\n", maxDeviceChars, "安卓 13Chrome 120"},
|
||
{"DEL 与 C0 控制字符被清掉", "安卓\x00\x1b[31m 13\x7f", maxDeviceChars, "安卓[31m 13"},
|
||
{"首尾空白去掉", " 平板 ", maxDeviceChars, "平板"},
|
||
{"只空白视作没报", " \t\n ", maxDeviceChars, ""},
|
||
{"空串保持空串", "", maxDeviceChars, ""},
|
||
// 长度按**字符**不是字节:按字节截会把中文腰斩成半个字。
|
||
{"按字符截断(中文)", strings.Repeat("汉", 81), maxDeviceChars, strings.Repeat("汉", 80)},
|
||
{"恰好等于上限不截断", strings.Repeat("x", maxDeviceChars), maxDeviceChars, strings.Repeat("x", maxDeviceChars)},
|
||
{"超长 UA 截到 300 字符", longUA, maxDeviceRawChars, strings.Repeat("汉", maxDeviceRawChars)},
|
||
{"limit <= 0 不截断", strings.Repeat("x", 10), 0, strings.Repeat("x", 10)},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := sanitizeDeviceText(tc.in, tc.limit)
|
||
if got != tc.want {
|
||
t.Fatalf("清洗结果不符:\n得到 %q\n期望 %q", got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 截断结果必须是合法 UTF-8(半个汉字会显示成替换方块)。
|
||
func TestSanitizeDeviceTextKeepsValidUTF8(t *testing.T) {
|
||
got := sanitizeDeviceText(strings.Repeat("平板", 100), 5)
|
||
if got != "平板平板平" {
|
||
t.Fatalf("按字符截断结果应为 %q,得到 %q", "平板平板平", got)
|
||
}
|
||
for _, r := range got {
|
||
if r == '\uFFFD' {
|
||
t.Fatalf("截断后出现无效 UTF-8 字节: %q", got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- 同一条连接重复 hello 不许产生新实例 ----------
|
||
|
||
// 局域网浏览器(hello 不带 instanceId)翻页后 setPage 会再补一条 hello,
|
||
// 第二次 hello 必须落在**同一个**实例上,否则注册表里会多出一条该设备的离线条目。
|
||
func TestRegisterDisplayReusesInstanceForRepeatHelloWithoutID(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetSnapshotMinInterval(0)
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, Page: "standings"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
first := env.hub.Instances()
|
||
if len(first) != 1 {
|
||
t.Fatalf("首次 hello 后应只有一个实例,得到 %d 个: %+v", len(first), first)
|
||
}
|
||
if !strings.HasPrefix(first[0].ID, "display-") {
|
||
t.Fatalf("不带 instanceId 时实例 id 应由服务端生成,得到 %s", first[0].ID)
|
||
}
|
||
|
||
// 第二次仍不带 instanceId(真实场景:翻页补发的 hello,端上没有本机 id)。
|
||
c.send(EventHello, helloPayload{Role: RoleDisplay, Page: "medals"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
got := env.hub.Instances()
|
||
if len(got) != 1 {
|
||
t.Fatalf("重复 hello 后注册表里应仍只有一个实例(旧实现会多出一条离线条目),得到 %d 个: %+v",
|
||
len(got), got)
|
||
}
|
||
if got[0].ID != first[0].ID {
|
||
t.Fatalf("重复 hello 应复用同一个实例 id:首次 %s,第二次 %s", first[0].ID, got[0].ID)
|
||
}
|
||
if !got[0].Online {
|
||
t.Fatalf("重复 hello 后实例应仍在线: %+v", got[0])
|
||
}
|
||
if got[0].Page != "medals" {
|
||
t.Fatalf("重复 hello 应刷新当前页为 medals,得到 %s", got[0].Page)
|
||
}
|
||
|
||
// 引用计数不能虚高:连接断开后实例必须立刻变成离线(虚高会显示成「还挂着一条连接」)。
|
||
c.close()
|
||
waitFor(t, "重复 hello 后实例仍能正确离线", func() bool {
|
||
insts := env.hub.Instances()
|
||
return len(insts) == 1 && !insts[0].Online
|
||
})
|
||
}
|
||
|
||
// 换了 instanceId 的连接仍然各自登记:复用只针对「这次没带 id」,不能把显式换 id 吃掉。
|
||
func TestRegisterDisplaySwitchesInstanceWhenIDChanges(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetSnapshotMinInterval(0)
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-1"})
|
||
c.recvEvent(EventSnapshot)
|
||
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "screen-2"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
insts := env.hub.Instances()
|
||
if len(insts) != 2 {
|
||
t.Fatalf("换 id 后应有两个实例,得到 %d 个: %+v", len(insts), insts)
|
||
}
|
||
if !instanceOnline(insts, "screen-2") {
|
||
t.Fatalf("新 id 应在线: %+v", insts)
|
||
}
|
||
// 旧实例的连接引用已归还,只剩「离线」的排查记录。
|
||
if instanceOnline(insts, "screen-1") {
|
||
t.Fatalf("旧 id 的连接引用应已归还,不该仍显示在线: %+v", insts)
|
||
}
|
||
}
|
||
|
||
// ---------- 设备字段进注册表 ----------
|
||
|
||
func TestRegisterDisplayStoresSanitizedDevice(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetSnapshotMinInterval(0)
|
||
|
||
c := env.dial(t, &helloPayload{
|
||
Role: RoleDisplay,
|
||
InstanceID: "pc-1",
|
||
Device: "安卓 13\n · Chrome 120\t",
|
||
DeviceRaw: "Mozilla/5.0 (Linux; Android 13)" + strings.Repeat("x", 400),
|
||
})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
insts := env.hub.Instances()
|
||
if len(insts) != 1 {
|
||
t.Fatalf("实例数应为 1,得到 %d", len(insts))
|
||
}
|
||
if insts[0].Device != "安卓 13 · Chrome 120" {
|
||
t.Fatalf("设备标签应清洗掉换行与制表,得到 %q", insts[0].Device)
|
||
}
|
||
if n := len([]rune(insts[0].DeviceRaw)); n != maxDeviceRawChars {
|
||
t.Fatalf("原始 UA 应按字符截到 %d,得到 %d 个字符", maxDeviceRawChars, n)
|
||
}
|
||
if strings.ContainsAny(insts[0].DeviceRaw, "\n\t") {
|
||
t.Fatalf("原始 UA 不该含控制字符: %q", insts[0].DeviceRaw)
|
||
}
|
||
|
||
// 老客户端(不带这两个字段)重连:不该把已经报上来的标签抹掉。
|
||
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "pc-1"})
|
||
c.recvEvent(EventSnapshot)
|
||
if got := env.hub.Instances()[0]; got.Device == "" || got.DeviceRaw == "" {
|
||
t.Fatalf("不带设备字段的重复 hello 不该清空已有标签: %+v", got)
|
||
}
|
||
}
|
||
|
||
// 老客户端(不带 device / deviceRaw)行为与今天一致:字段留空,注册表照常登记。
|
||
func TestRegisterDisplayWithoutDeviceFields(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "old-1"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
insts := env.hub.Instances()
|
||
if len(insts) != 1 || insts[0].ID != "old-1" || !insts[0].Online {
|
||
t.Fatalf("老客户端应照常登记: %+v", insts)
|
||
}
|
||
if insts[0].Device != "" || insts[0].DeviceRaw != "" {
|
||
t.Fatalf("老客户端没报设备字段时应留空: %+v", insts[0])
|
||
}
|
||
}
|