Files
Scoreboard/server/internal/httpapi/display_alias_test.go
T
AskaEth 56ed90ff8b fix(display,entry): 修大屏团体名次显示「-」与分项成绩第 3 栏缺第 1 名;发布改弹窗选口径;会话设备名与重命名;录分搜索框;版本 dev_0.5.15
本批七项(三处是现场报的 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 与两处前端注释的旧窗口说法一并订正。
2026-09-17 18:19:38 +08:00

707 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 (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/coder/websocket"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
)
// ---------- 别名用例的实时层替身 ----------
//
// 不用 review_fixes_test.go 里那个 fakeRealtime:这些用例要在两次请求**之间**改写实例列表
// (模拟「那台设备被 TTL 清理掉了」),而它的 instances 字段没有并发保护。这里给列表加锁,
// 写入与读取之间才有确定的同步关系。
type aliasFakeRealtime struct {
mu sync.Mutex
instances []realtime.Instance
}
func (f *aliasFakeRealtime) Broadcast(changes []realtime.Change) {}
func (f *aliasFakeRealtime) LoadChanges(ctx context.Context, sinceVersion int64, limit int) ([]realtime.Change, error) {
return nil, nil
}
func (f *aliasFakeRealtime) Instances() []realtime.Instance {
f.mu.Lock()
defer f.mu.Unlock()
return append([]realtime.Instance(nil), f.instances...)
}
// set 整批替换实例列表(用例在请求之间调用)。
func (f *aliasFakeRealtime) set(list ...realtime.Instance) {
f.mu.Lock()
defer f.mu.Unlock()
f.instances = list
}
// aliasRef 是别名用例专用的测试环境:recorder / admin 两个账号已建好。
type aliasRef struct {
env *testEnv
fake *aliasFakeRealtime
}
func newAliasEnv(t *testing.T) *aliasRef {
t.Helper()
fake := &aliasFakeRealtime{}
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Realtime = fake })
env.createUser("recorder", "recorder-pass", "recorder")
env.createUser("boss", "boss-pass", "admin")
return &aliasRef{env: env, fake: fake}
}
func (r *aliasRef) login(t *testing.T, username, password string) *http.Client {
t.Helper()
client, _ := r.env.client()
r.env.login(client, username, password)
return client
}
// putAlias 打一次改名接口。
func (r *aliasRef) putAlias(t *testing.T, client *http.Client, id, alias string) (int, envelope) {
t.Helper()
return r.env.do(client, http.MethodPut,
"/api/display/instances/"+id+"/alias", map[string]any{"alias": alias}, true)
}
// rawAliasSetting 直接读系统库里的原始设置值(绕开接口,验证「真的落盘了」)。
func rawAliasSetting(t *testing.T, db *sql.DB) string {
t.Helper()
var raw string
err := db.QueryRow(`SELECT value FROM settings WHERE key = 'display.instance_aliases'`).Scan(&raw)
if err == sql.ErrNoRows {
return ""
}
if err != nil {
t.Fatalf("读取别名设置: %v", err)
}
return raw
}
// ---------- 设置 / 覆盖 / 清除 ----------
func TestDisplayAliasSetReturnsInstance(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{
ID: "browser-1", Online: true, Page: "standings",
Device: "安卓 13 · Chrome 120 · 手机", DeviceRaw: "Mozilla/5.0 (Linux; Android 13)",
})
client := r.login(t, "recorder", "recorder-pass")
status, env := r.putAlias(t, client, "browser-1", "二号楼平板")
if status != http.StatusOK {
t.Fatalf("改名应 200,得到 %d %+v", status, env.Error)
}
got := decodeData[map[string]any](t, env)
if got["id"] != "browser-1" || got["alias"] != "二号楼平板" {
t.Fatalf("响应应是该实例的最新记录(含新别名): %+v", got)
}
// 设备字段与老字段一并返回:形状与列表元素一致。
if got["device"] != "安卓 13 · Chrome 120 · 手机" || got["deviceRaw"] != "Mozilla/5.0 (Linux; Android 13)" {
t.Fatalf("响应应带上设备字段: %+v", got)
}
for _, key := range []string{"online", "lastHeartbeat", "connectedAt", "ackVersion", "slow", "screen"} {
if _, ok := got[key]; !ok {
t.Fatalf("响应缺少老字段 %s: %+v", key, got)
}
}
// 列表里也看得到:别名是列表这一层与实例合并的。
list := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances"))
if len(list) != 1 || list[0]["alias"] != "二号楼平板" {
t.Fatalf("列表应带别名: %+v", list)
}
}
func TestDisplayAliasOverwriteAndTrim(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser-1", Online: true})
client := r.login(t, "recorder", "recorder-pass")
// 粘贴来的名字几乎都带首尾空白,服务端统一 trim 后再存。
if status, env := r.putAlias(t, client, "browser-1", " 二号楼平板 "); status != http.StatusOK {
t.Fatalf("首次改名应 200,得到 %d %+v", status, env.Error)
}
if status, env := r.putAlias(t, client, "browser-1", "三楼平板"); status != http.StatusOK {
t.Fatalf("覆盖改名应 200,得到 %d %+v", status, env.Error)
}
if got := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances")); len(got) != 1 || got[0]["alias"] != "三楼平板" {
t.Fatalf("列表应只剩最新别名: %+v", got)
}
var stored map[string]string
if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil {
t.Fatalf("别名设置应是 JSON 映射: %v", err)
}
if len(stored) != 1 || stored["browser-1"] != "三楼平板" {
t.Fatalf("覆盖后映射里应只剩最新别名: %+v", stored)
}
}
// 清除别名必须真把这条从映射里删掉(不是存空串),否则映射只会一路涨。
func TestDisplayAliasClearRemovesEntry(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser-1", Online: true}, realtime.Instance{ID: "browser-2", Online: true})
client := r.login(t, "recorder", "recorder-pass")
if status, _ := r.putAlias(t, client, "browser-1", "二号楼平板"); status != http.StatusOK {
t.Fatalf("首次改名应 200,得到 %d", status)
}
if status, _ := r.putAlias(t, client, "browser-2", "三楼平板"); status != http.StatusOK {
t.Fatalf("另一台改名应 200,得到 %d", status)
}
// 只空白 = 清除(与前端同口径)。
status, env := r.putAlias(t, client, "browser-1", " ")
if status != http.StatusOK {
t.Fatalf("清除别名应 200,得到 %d %+v", status, env.Error)
}
got := decodeData[map[string]any](t, env)
if _, ok := got["alias"]; ok {
t.Fatalf("清除后响应里不该再有 alias 键(omitempty): %+v", got)
}
var stored map[string]string
if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil {
t.Fatalf("别名设置应是 JSON 映射: %v", err)
}
if _, ok := stored["browser-1"]; ok {
t.Fatalf("清除后映射里不该还留着这一条: %+v", stored)
}
if stored["browser-2"] != "三楼平板" {
t.Fatalf("别的设备的别名不该被连带清掉: %+v", stored)
}
list := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances"))
for _, item := range list {
if item["id"] == "browser-1" {
if _, ok := item["alias"]; ok {
t.Fatalf("清除别名后列表应回到自动标签: %+v", item)
}
}
}
}
// ---------- 落盘:换连接 + 换服务端都还在 ----------
func TestDisplayAliasSurvivesServerRestart(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{
ID: "browser-1", Online: true, Device: "安卓 13 · 手机",
})
client := r.login(t, "recorder", "recorder-pass")
if status, env := r.putAlias(t, client, "browser-1", "二号楼平板"); status != http.StatusOK {
t.Fatalf("改名应 200,得到 %d %+v", status, env.Error)
}
// 1) 换一条全新的系统库连接读(绕开任何进程内状态):别名在库里。
path := sysDBPath(t, r.env.sys)
fresh, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("重新打开系统库: %v", err)
}
defer func() { _ = fresh.Close() }()
var stored map[string]string
if err := json.Unmarshal([]byte(rawAliasSetting(t, fresh)), &stored); err != nil {
t.Fatalf("重开系统库后别名设置应是 JSON 映射: %v", err)
}
if stored["browser-1"] != "二号楼平板" {
t.Fatalf("重开系统库后别名应还在: %+v", stored)
}
// 2) 再起一个全新的服务端(新的 handler、新的实时层替身),同一份系统库 / 赛事库。
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
restarted := &aliasFakeRealtime{}
restarted.set(realtime.Instance{ID: "browser-1", Online: true, Device: "安卓 13 · 手机"})
srv := httptest.NewServer(httpapi.New(httpapi.Options{
Logger: logger,
Version: "test",
System: r.env.sys,
Event: r.env.ev,
Auth: r.env.auth,
DataDir: t.TempDir(),
Realtime: restarted,
}))
defer srv.Close()
restartEnv := &testEnv{t: t, server: srv, sys: r.env.sys, ev: r.env.ev, auth: r.env.auth}
restartClient, _ := restartEnv.client()
restartEnv.login(restartClient, "recorder", "recorder-pass")
list := decodeData[[]map[string]any](t, mustDo(t, restartEnv, restartClient, http.MethodGet, "/api/display/instances"))
if len(list) != 1 || list[0]["alias"] != "二号楼平板" {
t.Fatalf("重开服务端后列表里应仍有别名: %+v", list)
}
}
// ---------- 实例过期后别名留着 ----------
func TestDisplayAliasKeptAfterInstancePruned(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser-1", Online: true})
client := r.login(t, "recorder", "recorder-pass")
if status, env := r.putAlias(t, client, "browser-1", "二号楼平板"); status != http.StatusOK {
t.Fatalf("改名应 200,得到 %d %+v", status, env.Error)
}
// 实例被 TTL 清理(注册表里没有它了):列表里当然没有条目,别名也不能跟着被删。
r.fake.set()
if list := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances")); len(list) != 0 {
t.Fatalf("实例被清理后列表应为空: %+v", list)
}
if status, env := r.putAlias(t, client, "browser-1", "x"); status != http.StatusNotFound {
t.Fatalf("给已不在注册表里的实例改名应 404,得到 %d %+v", status, env.Error)
}
var stored map[string]string
if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil {
t.Fatalf("别名设置应是 JSON 映射: %v", err)
}
if stored["browser-1"] != "二号楼平板" {
t.Fatalf("实例被清理不该牵连别名: %+v", stored)
}
// 那台设备再连上来:同一个实例 id,别名自动认回来。
r.fake.set(realtime.Instance{ID: "browser-1", Online: true, Device: "安卓 13 · 手机"})
list := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances"))
if len(list) != 1 || list[0]["alias"] != "二号楼平板" {
t.Fatalf("设备重连后应自动认回别名: %+v", list)
}
}
// ---------- 权限 ----------
// 权限与大屏切页同档(recorder):未登录与切页接口得到同一个状态码,录入员能改名、管理员同样能。
func TestDisplayAliasPermissionMatchesDisplayCommand(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser-1", Online: true})
anon, _ := r.env.client()
// 未登录请求不带 CSRF(中间件对 guest 请求直接放行,由 RequireWrite 判权限)。
aliasStatus, aliasEnv := r.env.do(anon, http.MethodPut,
"/api/display/instances/browser-1/alias", map[string]any{"alias": "x"}, false)
cmdStatus, _ := r.env.do(anon, http.MethodPost, "/api/display/command",
map[string]any{"action": "goto", "payload": map[string]any{"page": "standings"}}, false)
if aliasStatus != cmdStatus {
t.Fatalf("改名与大屏切页应同档:匿名访客改名 %d,切页 %d", aliasStatus, cmdStatus)
}
if aliasStatus != http.StatusUnauthorized {
t.Fatalf("匿名访客应被挡下(写接口未登录一律 401),得到 %d %+v", aliasStatus, aliasEnv.Error)
}
recorder := r.login(t, "recorder", "recorder-pass")
if status, env := r.putAlias(t, recorder, "browser-1", "二号楼平板"); status != http.StatusOK {
t.Fatalf("录入员改名应 200,得到 %d %+v", status, env.Error)
}
boss := r.login(t, "boss", "boss-pass")
if status, env := r.putAlias(t, boss, "browser-1", "一号楼大屏"); status != http.StatusOK {
t.Fatalf("管理员改名应 200,得到 %d %+v", status, env.Error)
}
}
// ---------- 404 / 超长 / 超量 ----------
// 实例 id 走路径,前端会先 encodeURIComponent:服务端必须按**解码后**的 id 去认实例。
func TestDisplayAliasAcceptsURLEncodedID(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser 1", Online: true})
client := r.login(t, "recorder", "recorder-pass")
status, env := r.env.do(client, http.MethodPut,
"/api/display/instances/browser%201/alias", map[string]any{"alias": "二号楼平板"}, true)
if status != http.StatusOK {
t.Fatalf("URL 编码的实例 id 应能认出来,得到 %d %+v", status, env.Error)
}
var stored map[string]string
if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil {
t.Fatalf("别名设置应是 JSON 映射: %v", err)
}
if stored["browser 1"] != "二号楼平板" {
t.Fatalf("别名应按解码后的 id 存: %+v", stored)
}
}
func TestDisplayAliasUnknownInstanceNotFound(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser-1", Online: true})
client := r.login(t, "recorder", "recorder-pass")
status, env := r.putAlias(t, client, "gone-1", "二号楼平板")
if status != http.StatusNotFound {
t.Fatalf("给不存在的实例改名应 404,得到 %d %+v", status, env.Error)
}
if env.Error == nil || !strings.Contains(env.Error.Message, "离线") {
t.Fatalf("404 提示里应说清「可能已经离线久了」: %+v", env.Error)
}
// 失败的请求不该留下任何东西。
if got := rawAliasSetting(t, r.env.sys); strings.Contains(got, "gone-1") {
t.Fatalf("404 不该写库: %s", got)
}
}
func TestDisplayAliasLengthLimit(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser-1", Online: true})
client := r.login(t, "recorder", "recorder-pass")
// 上限按**字符**算:40 个汉字正好合法(按字节算早就超了)。
if status, env := r.putAlias(t, client, "browser-1", strings.Repeat("平", 40)); status != http.StatusOK {
t.Fatalf("40 个字符的别名应 200,得到 %d %+v", status, env.Error)
}
status, env := r.putAlias(t, client, "browser-1", strings.Repeat("平", 41))
if status != http.StatusBadRequest {
t.Fatalf("超长别名应 400,得到 %d %+v", status, env.Error)
}
if env.Error == nil || !strings.Contains(env.Error.Message, "40") {
t.Fatalf("超长提示应说清上限: %+v", env.Error)
}
// 被拒绝的请求不该改动已存的值。
var stored map[string]string
if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil {
t.Fatalf("别名设置应是 JSON 映射: %v", err)
}
if stored["browser-1"] != strings.Repeat("平", 40) {
t.Fatalf("超长请求不该覆盖已存别名: %+v", stored)
}
// 带控制字符的别名同样要清干净再存(否则列表排版会被搞乱)。
if status, _ := r.putAlias(t, client, "browser-1", "二号楼\n平板\t"); status != http.StatusOK {
t.Fatalf("带控制字符的别名应 200,得到 %d", status)
}
if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil {
t.Fatalf("别名设置应是 JSON 映射: %v", err)
}
if stored["browser-1"] != "二号楼平板" {
t.Fatalf("别名里的控制字符应被清掉: %q", stored["browser-1"])
}
}
func TestDisplayAliasCapacityLimit(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "new-dev", Online: true}, realtime.Instance{ID: "dev-0", Online: true})
client := r.login(t, "recorder", "recorder-pass")
// 直接种满 200 条(逐条打接口太慢,这里要验的是上限判定本身)。
seed := make(map[string]string, 200)
for i := 0; i < 200; i++ {
seed[fmt.Sprintf("dev-%d", i)] = fmt.Sprintf("设备 %d", i)
}
raw, err := json.Marshal(seed)
if err != nil {
t.Fatalf("构造种入数据: %v", err)
}
r.env.execSys(t, `INSERT INTO settings (key, value, updated_at) VALUES ('display.instance_aliases', ?, datetime('now'))`,
string(raw))
status, env := r.putAlias(t, client, "new-dev", "二号楼平板")
if status != http.StatusConflict {
t.Fatalf("映射已满时新增应 409,得到 %d %+v", status, env.Error)
}
if env.Error == nil || !strings.Contains(env.Error.Message, "清除") {
t.Fatalf("拒绝提示应告诉操作员怎么办: %+v", env.Error)
}
// 已存在的条目仍然可以改名(覆盖不涨条数),也可以清除(腾位置)。
if status, env := r.putAlias(t, client, "dev-0", "换一个名字"); status != http.StatusOK {
t.Fatalf("已存在的条目改名应 200,得到 %d %+v", status, env.Error)
}
if status, env := r.putAlias(t, client, "dev-0", ""); status != http.StatusOK {
t.Fatalf("清除应 200,得到 %d %+v", status, env.Error)
}
if status, env := r.putAlias(t, client, "new-dev", "二号楼平板"); status != http.StatusOK {
t.Fatalf("腾出位置后新增应 200,得到 %d %+v", status, env.Error)
}
}
// 两个人同时给不同设备改名:一个都不许被另一个覆盖掉(读—改—写必须在同一个事务里)。
func TestDisplayAliasConcurrentRenamesKeepAll(t *testing.T) {
r := newAliasEnv(t)
const n = 8
instances := make([]realtime.Instance, 0, n)
for i := 0; i < n; i++ {
instances = append(instances, realtime.Instance{ID: fmt.Sprintf("browser-%d", i), Online: true})
}
r.fake.set(instances...)
// 只登录一次:登录接口有按 IP 的频控,每个 goroutine 各登一次会被 429 挡下。
// Cookie jar 自身并发安全,同一个 client 可以并发发起写请求。
client := r.login(t, "recorder", "recorder-pass")
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
status, env := r.putAlias(t, client, fmt.Sprintf("browser-%d", i), fmt.Sprintf("设备 %d", i))
if status != http.StatusOK {
t.Errorf("并发改名应 200,得到 %d %+v", status, env.Error)
}
}(i)
}
wg.Wait()
var stored map[string]string
if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil {
t.Fatalf("别名设置应是 JSON 映射: %v", err)
}
if len(stored) != n {
t.Fatalf("并发改名后应保留全部 %d 条,得到 %d 条: %+v", n, len(stored), stored)
}
for i := 0; i < n; i++ {
if stored[fmt.Sprintf("browser-%d", i)] != fmt.Sprintf("设备 %d", i) {
t.Fatalf("browser-%d 的别名不对: %+v", i, stored)
}
}
}
// ---------- 老字段一个不少 / 老客户端不受影响 ----------
// 三个新字段都是 omitempty:老客户端(不上报设备)的列表元素**形状与升级前逐字一致**,
// 管理端不会因为多出空串键而被判成回归;有值时才出现。
func TestDisplayInstancesKeepsLegacyShape(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(
realtime.Instance{ID: "old-1", Online: true, Page: "standings", Screen: "screen-a", RemoteAddr: "10.0.0.5:1234"},
realtime.Instance{ID: "new-1", Online: true, Device: "安卓 13 · 手机", DeviceRaw: "Mozilla/5.0"},
)
client := r.login(t, "recorder", "recorder-pass")
raw := mustDo(t, r.env, client, http.MethodGet, "/api/display/instances")
var list []map[string]any
if err := json.Unmarshal(raw.Data, &list); err != nil {
t.Fatalf("解析列表: %v", err)
}
if len(list) != 2 {
t.Fatalf("列表应有两项: %+v", list)
}
legacyKeys := []string{"id", "online", "lastHeartbeat", "connectedAt", "ackVersion", "slow", "screen"}
for _, item := range list {
for _, key := range legacyKeys {
if _, ok := item[key]; !ok {
t.Fatalf("老字段 %s 必须一个不少: %+v", key, item)
}
}
}
for _, item := range list {
if item["id"] != "old-1" {
continue
}
for _, key := range []string{"device", "deviceRaw", "alias"} {
if _, ok := item[key]; ok {
t.Fatalf("老客户端(没报设备)不该出现新键 %s: %+v", key, item)
}
}
}
for _, item := range list {
if item["id"] != "new-1" {
continue
}
if item["device"] != "安卓 13 · 手机" || item["deviceRaw"] != "Mozilla/5.0" {
t.Fatalf("报了设备字段的实例应带上它们: %+v", item)
}
if _, ok := item["alias"]; ok {
t.Fatalf("没设过别名时不该出现 alias 键: %+v", item)
}
}
}
// 设置值被改坏时(手工改库 / 别的版本写的)列表照常可用,只是退回显示实例 id。
func TestDisplayInstancesToleratesCorruptAliasSetting(t *testing.T) {
r := newAliasEnv(t)
r.fake.set(realtime.Instance{ID: "browser-1", Online: true})
client := r.login(t, "recorder", "recorder-pass")
r.env.execSys(t, `INSERT INTO settings (key, value, updated_at) VALUES ('display.instance_aliases', '不是 JSON', datetime('now'))`)
status, env := r.env.do(client, http.MethodGet, "/api/display/instances", nil, false)
if status != http.StatusOK {
t.Fatalf("坏设置不该让列表打不开,得到 %d %+v", status, env.Error)
}
list := decodeData[[]map[string]any](t, env)
if len(list) != 1 || list[0]["id"] != "browser-1" {
t.Fatalf("列表应照常返回实例: %+v", list)
}
if _, ok := list[0]["alias"]; ok {
t.Fatalf("坏设置下不该有别名: %+v", list[0])
}
}
// ---------- 端到端:真 Hub + 真 /ws + 真 HTTP ----------
// 现场原场景:局域网浏览器(URL 没有 instance,hello 不带 instanceId)连上来,
// 翻页让前端补发第二条 hello;管理端看到的应当是**一条**带设备标签的实例,且能给它改名。
//
// 这里不用替身:真的 realtime.Hub、真的 WebSocket、真的 HTTP 链路,只有浏览器那一端是
// 测试里手写的帧。
func TestDisplayAliasEndToEndWithRealHub(t *testing.T) {
var hub *realtime.Hub
env := newTestEnvWith(t, func(o *httpapi.Options) {
hub = realtime.NewHub(o.Event, o.Logger)
o.Realtime = hub
o.WS = hub.Handler()
})
t.Cleanup(func() { _ = hub.Close() })
env.createUser("recorder", "recorder-pass", "recorder")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
wsURL := "ws" + strings.TrimPrefix(env.server.URL, "http") + "/ws"
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatalf("连接 /ws: %v", err)
}
defer func() { _ = conn.CloseNow() }()
// hello 不带 instanceId / displayKey:局域网浏览器的真实形状。
hello := func(page string) {
t.Helper()
msg, err := json.Marshal(map[string]any{
"event": "hello",
"data": map[string]any{
"role": "display",
"page": page,
"device": "安卓 13 · Chrome 120 · 1080×2400 @3x · 手机",
"deviceRaw": "Mozilla/5.0 (Linux; Android 13) Chrome/120.0.0.0 Mobile Safari/537.36",
},
})
if err != nil {
t.Fatalf("编码 hello: %v", err)
}
if err := conn.Write(ctx, websocket.MessageText, msg); err != nil {
t.Fatalf("发送 hello: %v", err)
}
}
client := func() *http.Client {
t.Helper()
c, _ := env.client()
env.login(c, "recorder", "recorder-pass")
return c
}
admin := client()
list := func() []map[string]any {
t.Helper()
return decodeData[[]map[string]any](t, mustDo(t, env, admin, http.MethodGet, "/api/display/instances"))
}
waitList := func(what string, cond func([]map[string]any) bool) []map[string]any {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
var last []map[string]any
for time.Now().Before(deadline) {
last = list()
if cond(last) {
return last
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("等待「%s」超时,最后的列表: %+v", what, last)
return nil
}
hello("standings")
first := waitList("第一条 hello 被登记", func(l []map[string]any) bool {
return len(l) == 1 && l[0]["page"] == "standings"
})
id, _ := first[0]["id"].(string)
if !strings.HasPrefix(id, "display-") {
t.Fatalf("不带 instanceId 时实例 id 应由服务端生成,得到 %q", id)
}
if first[0]["device"] != "安卓 13 · Chrome 120 · 1080×2400 @3x · 手机" {
t.Fatalf("列表应带上设备标签: %+v", first[0])
}
// 翻页:前端 setPage 会再发一条同样不带 instanceId 的 hello——这正是那个 bug 的引信。
hello("medals")
second := waitList("翻页后的 hello 被登记", func(l []map[string]any) bool {
return len(l) == 1 && l[0]["page"] == "medals"
})
if second[0]["id"] != id {
t.Fatalf("同一条连接重复 hello 不该换实例:首次 %v,翻页后 %v", id, second[0]["id"])
}
// 改名:返回该实例的最新记录,列表里也立刻带上别名。
status, env2 := env.do(admin, http.MethodPut,
"/api/display/instances/"+id+"/alias", map[string]any{"alias": "二号楼平板"}, true)
if status != http.StatusOK {
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
}
if got := decodeData[map[string]any](t, env2); got["alias"] != "二号楼平板" || got["id"] != id {
t.Fatalf("改名应返回该实例的最新记录: %+v", got)
}
after := waitList("别名出现在列表里", func(l []map[string]any) bool {
return len(l) == 1 && l[0]["alias"] == "二号楼平板"
})
if after[0]["device"] == "" {
t.Fatalf("别名不该顶掉设备标签(两者各有各的用处): %+v", after[0])
}
// 清除:回到自动设备标签,注册表里仍然只有这一条。
status, env3 := env.do(admin, http.MethodPut,
"/api/display/instances/"+id+"/alias", map[string]any{"alias": " "}, true)
if status != http.StatusOK {
t.Fatalf("清除别名应 200,得到 %d %+v", status, env3.Error)
}
if _, ok := decodeData[map[string]any](t, env3)["alias"]; ok {
t.Fatalf("清除后响应不该再有 alias: %s", env3.Data)
}
if l := list(); len(l) != 1 || l[0]["device"] == "" {
t.Fatalf("清除后应回到自动设备标签且实例仍只有一条: %+v", l)
}
}
// ---------- 小工具 ----------
// execSys 在系统库上直接执行 SQL:别名的设置在 system.db 的 settings 表里,
// 而 testEnv.exec 写的是赛事库(种数据 / 改坏设置这两个场景都要用系统库)。
func (e *testEnv) execSys(t *testing.T, query string, args ...any) {
t.Helper()
if _, err := e.sys.Exec(query, args...); err != nil {
t.Fatalf("执行系统库 SQL 失败: %v\n%s", err, query)
}
}
func mustDo(t *testing.T, env *testEnv, client *http.Client, method, path string) envelope {
t.Helper()
status, body := env.do(client, method, path, nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("%s %s 应 200,得到 %d %+v", method, path, status, body.Error)
}
return body
}
// sysDBPath 取系统库的文件路径:用它另开一条连接读,绕开进程内可能存在的任何缓存。
func sysDBPath(t *testing.T, db *sql.DB) string {
t.Helper()
rows, err := db.Query(`PRAGMA database_list`)
if err != nil {
t.Fatalf("读取系统库路径: %v", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var seq int
var name, file string
if err := rows.Scan(&seq, &name, &file); err != nil {
t.Fatalf("扫描系统库路径: %v", err)
}
if name == "main" {
return file
}
}
t.Fatal("系统库路径为空")
return ""
}