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)