- 地图脚本:localStorage 读写走 lsGet/lsSet 兜底(隐私模式下裸访问会打断整段脚本); preview() 在 /api/map-config 还没回来时不丢点(previewQueue 在 if (live) 之前排队); 异步回包一律发序号作废,取消后回来的包不再画 - photo-exif.js:EXIF 时间全 0 不再变成 1899-11-30 写进到访时间;读 count 前先按版本卡宽度 - 数据编辑页:三个筛选表单都把其余条件用隐藏域原样带上,提交不再把别的列表的筛选清掉 - 补全页:sessionStorage 的读写一律包 try(脏数据/隐私模式都会抛) - 窄屏:删掉被后面同特异性规则盖掉的死内边距;宽表堆叠类名成对、判据扩到所有 tb 表 - 无障碍:图片删除按钮补 aria-label,访客画廊图片补 alt - report.html 补上漏掉的 </tr>,fog-mask 的宽表带 tb-stack 本批不碰数据库与导出路径:往返包 823/323/8/30/791/557/2246 全等、 坐标偏差 0.000000000、时间偏差 0s、迷雾 8701 块一致。
235 lines
7.9 KiB
Go
235 lines
7.9 KiB
Go
package web
|
||
|
||
import (
|
||
"context"
|
||
"io/fs"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// TestWideTableCardsAreFullWidth 守住「宽表卡片必须整行」这条不变量。
|
||
//
|
||
// 这批表的 min-width 是 640 / 700,只要卡片被塞进 g2 的半宽列(视口 1440 时卡片只有 494px),
|
||
// 桌面端就得横滑 100+px 才看得全(/stats 三张、/report「南北两端」原来就是这样)。卡片能不能
|
||
// 放下只由它自己的宽度决定,所以宽表卡片一律不放进栅格——将来想给页面加 g2 排版时,这条会拦住。
|
||
//
|
||
// 同时钉住 CSS 侧的两个挂载点:.tw 得是容器、容器查询阈值得在(否则窄下来不会竖排,又会横滑)。
|
||
//
|
||
// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run WideTableCards -v
|
||
func TestWideTableCardsAreFullWidth(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
|
||
type target struct {
|
||
path string
|
||
h http.HandlerFunc
|
||
admin bool
|
||
}
|
||
tables := 0
|
||
for _, tg := range []target{
|
||
{"/stats", f.srv.statsPage, false},
|
||
{"/report", f.srv.reportPage, false},
|
||
{"/places", f.srv.placesPage, false},
|
||
{"/", f.srv.home, false},
|
||
{"/admin", f.srv.adminHome, true},
|
||
{"/admin/topics", f.srv.topicsAdminPage, true},
|
||
// 数据编辑页的三张表原来漏了 tb-stack:它们默认折叠在卡片里,探针量到的是 display:none,
|
||
// 「窄屏会不会横滑」这件事在默认态根本看不出来(站长 2026-09-19 就是这么反馈上来的)。
|
||
{"/admin/edit", f.srv.editPage, true},
|
||
{"/admin/photos", f.srv.adminPhotosPage, true},
|
||
} {
|
||
ctx := context.Background()
|
||
if tg.admin {
|
||
ctx = f.ctx
|
||
}
|
||
w := httptest.NewRecorder()
|
||
tg.h(w, httptest.NewRequest("GET", tg.path, nil).WithContext(ctx))
|
||
if w.Code != 200 {
|
||
t.Logf("%s 渲染 code=%d,跳过", tg.path, w.Code)
|
||
continue
|
||
}
|
||
tables += checkWideTablesOutsideGrid(t, tg.path, w.Body.String())
|
||
}
|
||
if tables == 0 {
|
||
t.Fatal("一页 tb-stack 表都没渲染出来:模板里的类名是不是被改掉了")
|
||
}
|
||
|
||
css, err := fs.ReadFile(staticFS, "static/css/app.css")
|
||
if err != nil {
|
||
t.Fatalf("读不到 app.css: %v", err)
|
||
}
|
||
// 堆叠规则必须包在 @media 里,而且阈值要够宽:
|
||
// 卡片内容宽 ≈ 视口 − 93(页面 + 卡片的内边距),最宽的表 min-width 700 ⇒ 至少 792 才放得下。
|
||
// 这条断言挡的是两个真实踩过的坑:① 阈值 620 时视口 621~793 的卡片两头不靠、照样横滑;
|
||
// ② 换成 @container 后旧手机(iOS<16 / Chrome<105)整块规则失效,表格退化成横滑 388px。
|
||
clean := stripCSSComments(string(css))
|
||
blocks := cssMediaBlocks(clean)
|
||
found := 0
|
||
for _, loc := range regexp.MustCompile(`tb-stack`).FindAllStringIndex(clean, -1) {
|
||
found++
|
||
blk, ok := innermostMedia(blocks, loc[0])
|
||
if !ok {
|
||
t.Errorf("app.css 里有 tb-stack 规则不在任何 @media 里:旧手机的容器查询/自适应会失效(附近:%s)",
|
||
snippetAt(clean, loc[0]))
|
||
continue
|
||
}
|
||
if blk.maxW < 792 {
|
||
t.Errorf("tb-stack 规则所在的 @media 阈值只有 %dpx:视口 %d~792 的卡片放不下表格,又会横滑",
|
||
blk.maxW, blk.maxW+1)
|
||
}
|
||
}
|
||
if found == 0 {
|
||
t.Error("app.css 里一条 tb-stack 堆叠规则都没有:宽表在窄屏不会竖排")
|
||
}
|
||
t.Logf("检查了 %d 张 tb-stack 表、%d 条堆叠规则", tables, found)
|
||
}
|
||
|
||
// stripCSSComments 去掉 /* */ 注释:注释里出现的 @media / @container 字样会把解析带偏。
|
||
func stripCSSComments(s string) string {
|
||
var b strings.Builder
|
||
for i := 0; i < len(s); {
|
||
if i+1 < len(s) && s[i] == '/' && s[i+1] == '*' {
|
||
j := strings.Index(s[i+2:], "*/")
|
||
if j < 0 {
|
||
break
|
||
}
|
||
i += 2 + j + 2
|
||
b.WriteByte(' ')
|
||
continue
|
||
}
|
||
b.WriteByte(s[i])
|
||
i++
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
type cssMediaBlock struct{ start, end, maxW int }
|
||
|
||
// cssMediaBlocks 找出所有 @media 块的区间和它的 max-width 阈值(没有就记 -1)。
|
||
func cssMediaBlocks(css string) []cssMediaBlock {
|
||
var out []cssMediaBlock
|
||
headRe := regexp.MustCompile(`@media([^{]*)\{`)
|
||
widthRe := regexp.MustCompile(`max-width:\s*(\d+)px`)
|
||
for _, m := range headRe.FindAllStringSubmatchIndex(css, -1) {
|
||
maxW := -1
|
||
if w := widthRe.FindStringSubmatch(css[m[2]:m[3]]); w != nil {
|
||
maxW, _ = strconv.Atoi(w[1])
|
||
}
|
||
depth, end := 0, -1
|
||
for i := m[1] - 1; i < len(css); i++ {
|
||
switch css[i] {
|
||
case '{':
|
||
depth++
|
||
case '}':
|
||
depth--
|
||
if depth == 0 {
|
||
end = i
|
||
}
|
||
}
|
||
if end >= 0 {
|
||
break
|
||
}
|
||
}
|
||
if end > 0 {
|
||
out = append(out, cssMediaBlock{m[0], end, maxW})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func innermostMedia(blocks []cssMediaBlock, pos int) (cssMediaBlock, bool) {
|
||
best := cssMediaBlock{}
|
||
ok := false
|
||
for _, b := range blocks {
|
||
if b.start < pos && pos < b.end && (!ok || b.start > best.start) {
|
||
best, ok = b, true
|
||
}
|
||
}
|
||
return best, ok
|
||
}
|
||
|
||
func snippetAt(s string, pos int) string {
|
||
lo, hi := pos-20, pos+30
|
||
if lo < 0 {
|
||
lo = 0
|
||
}
|
||
if hi > len(s) {
|
||
hi = len(s)
|
||
}
|
||
return strings.ReplaceAll(s[lo:hi], "\n", " ")
|
||
}
|
||
|
||
// checkWideTablesOutsideGrid 用 div 栈还原每个表格的祖先,确认两件事:
|
||
// ① 每张 tb-stack 表都不在任何 g2 网格里(半宽卡片会横滑);
|
||
// ② 凡是被 .tw 包住的表格都得带 tb-stack —— 否则窄屏没有任何堆叠规则可命中,照样横滑。
|
||
// 只靠「最近一个 class=\"grid g2\" 出现在前面」会被已闭合的网格误伤,所以按标签顺序配对。
|
||
// 返回这一页检查了几张堆叠表(数据为空时某些页面本来就没有表)。
|
||
func checkWideTablesOutsideGrid(t *testing.T, path, body string) int {
|
||
t.Helper()
|
||
tagRe := regexp.MustCompile(`(?s)<div\b[^>]*>|</div>|<table\b[^>]*>`)
|
||
classRe := regexp.MustCompile(`class="([^"]*)"`)
|
||
|
||
type frame struct{ g2, tw bool }
|
||
var stack []frame
|
||
seen := 0
|
||
for _, tag := range tagRe.FindAllString(body, -1) {
|
||
switch {
|
||
case strings.HasPrefix(tag, "</div"):
|
||
if len(stack) > 0 {
|
||
stack = stack[:len(stack)-1]
|
||
}
|
||
case strings.HasPrefix(tag, "<div"):
|
||
var f frame
|
||
if m := classRe.FindStringSubmatch(tag); m != nil {
|
||
for _, c := range strings.Fields(m[1]) {
|
||
switch c {
|
||
case "g2":
|
||
f.g2 = true
|
||
case "tw":
|
||
f.tw = true
|
||
}
|
||
}
|
||
}
|
||
stack = append(stack, f)
|
||
case strings.HasPrefix(tag, "<table"):
|
||
inTW, inG2 := false, false
|
||
for _, f := range stack {
|
||
inTW = inTW || f.tw
|
||
inG2 = inG2 || f.g2
|
||
}
|
||
if !strings.Contains(tag, "tb-stack") {
|
||
// 照片表由脚本逐格生成、列数固定,移动端走「隐藏第 3 列」那条路,暂不纳入堆叠。
|
||
if strings.Contains(tag, "photo-table") {
|
||
continue
|
||
}
|
||
// 判据从「在 .tw 里」扩成「只要用了 tb 这个宽表基类就得堆叠」:
|
||
// 漏了 .tw 包裹的宽表(/admin/fog/mask 的遮罩表就是这样)以前两条守卫都看不见。
|
||
usesTB := false
|
||
if m := classRe.FindStringSubmatch(tag); m != nil {
|
||
usesTB = strings.Contains(" "+m[1]+" ", " tb ")
|
||
}
|
||
if inTW || usesTB {
|
||
t.Errorf("%s:宽表没带 tb-stack(窄屏没有堆叠规则可命中,只能横滑):%s", path, tag)
|
||
}
|
||
continue
|
||
}
|
||
seen++
|
||
// 两个类是成对的:app.css 里每一条堆叠规则都写成 `.tw > table.tb.tb-stack …`。
|
||
// 只写 tb-stack 一条都命中不了,表格退回 `.tw > table.tb` 的 640px 底宽,
|
||
// 窄屏照旧横滑——而且横滑是在 .tw 里滚的,页面本身不溢出,肉眼很难发现
|
||
// (2026-09-20 加「活动与类别」卡片时就这么漏过一次)。
|
||
if m := classRe.FindStringSubmatch(tag); m == nil || !strings.Contains(" "+m[1]+" ", " tb ") {
|
||
t.Errorf("%s:tb-stack 表少了 tb 类(class=%q)——堆叠规则匹配不到,窄屏只能横滑", path, tag)
|
||
}
|
||
// 只有标了 tb-stack 的才算「宽表」;普通的窄表放进 g2 没问题,不该报。
|
||
if inG2 {
|
||
t.Errorf("%s:一张宽表被放在 g2 网格里(卡片只剩半宽,桌面端要横滑)", path)
|
||
}
|
||
}
|
||
}
|
||
return seen
|
||
}
|