Files
rond-with-you/internal/web/wide_table_layout_test.go
T
tamakyi da6fe54ab4 数据编辑页加「活动与类别」卡片:改名 / 删除 / 新增 / 合并
活动的管理能力其实后端早就有(create/update/delete 三个端点),但入口只藏在
「到访记录」卡片里——要先在下拉里选中某个活动,再点按钮弹 prompt,看不见
「哪些活动一条到访都没挂」(实测有 0 条的活动);类别则完全没有集中入口。

现在页首多了一张卡片(唯一默认展开的),两个表格都带用量:

- 活动:名称行内改、删除(有引用时二次确认并报条数)、新增,
  另有一条「把[A]并到[B]」——改名解决不了「两个名字指同一件事」,
  合并会把到访转过去、把 is_home/is_work 抄成目标的,再删源活动、清它的原始行留档。
- 类别:类别没有独立的表,值就长在 places.poi_category 上,所以「改名 / 清空」
  都是按值批量作用到地点;改成另一个已有值就是合并,不另做按钮。

新增端点:/admin/edit/category/rename、/admin/edit/category/clear、
/admin/edit/activity/merge。编辑页那条 DISTINCT poi_category 查询顺手改成
GROUP BY 带计数,一份数据两用。

顺带修一处会静默失效的写法:新表原来只写了 tb-stack,漏了配对的 tb,
而 CSS 全是 `.tw > table.tb.tb-stack …` ⇒ 一条堆叠规则都没命中,窄屏下表仍是
640px 底宽。横滑是在 .tw 里滚的、页面不溢出,肉眼很难发现,守卫测试也只查
「有没有 tb-stack」——已补上「有 tb-stack 必须有 tb」并做了变异验证。
2026-09-20 09:51:43 +08:00

226 lines
7.5 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 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 inTW && !strings.Contains(tag, "photo-table") {
t.Errorf("%s:.tw 里的表格没带 tb-stack(窄屏没有堆叠规则可命中,只能横滑)", path)
}
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
}