diff --git a/internal/e2e/pager_seam_test.go b/internal/e2e/pager_seam_test.go new file mode 100644 index 00000000..23e2d3bb --- /dev/null +++ b/internal/e2e/pager_seam_test.go @@ -0,0 +1,179 @@ +//go:build e2e + +package e2e + +// 首页切 Tab 滑动中那条**接缝分割线**的真窗口回归(本票)。 +// +// # 界面层那一族断的是什么 +// +// `internal/ui/homepage_pager_test.go` 那一族断的是**几何**(`homePagerView` 记下 +// 两档的位置,分割线挂在「pager-seam」下、x 在接缝上)——而无头 Tester 的画面 +// 是**不透明白底**,接缝那 1 DIP 的线在「它和底色差多少」这种判据上量不稳 +// (半透明混色画上去的)。 +// +// 所以这一层断的是**它在真窗口上真的画出来**: +// +// 1. **滑动中**:capture 一张,元素树里「pager-seam」在、它的 bounds 里那一带 +// 有墨(`Border` 色,比底色深); +// 2. **静止时**:元素树里没有「pager-seam」——它只在滑动中出现。 +// +// # 怎么跑 +// +// BIU_RUN_MYGO_E2E=1 go test -tags e2e ./internal/e2e/ -run TestThePagerSeam +// +// ⚠️ **它跑的是整个应用**:切 Tab 走真窗口的那一条(act press → 滑动 → capture)。 +// +// ⚠️ **滑动是 200ms**:capture 要抢在那一段里——所以「先按 Tab、立刻 capture」, +// 而不是等「滑动结束了再看」。真窗口的动画走墙钟,`capture` 逼的那一帧就落在 +// 滑动中途(`pumpFrame` 同一条机制)。 +// +// ⚠️ **判据是「那一条竖带里有墨」,不是「精确比色」**:接缝线是 `Border` 色 +// (深色下 15% 白、浅色下 `#dbdbdb`),叠在内容上是一条**比底色深**的竖带—— +// 和 `darkerIn` 同一个数法。 + +import ( + "image/png" + "os" + "testing" + "time" +) + +// seamNode 是元素树里「pager-seam」那一项。 +type seamNode struct { + Key any `json:"key"` + Label string `json:"label"` + Bounds struct { + X, Y, Width, Height float64 + } +} + +// TestThePagerSeam 断**滑动中接缝处有分割线、静止时没有**。 +func TestThePagerSeam(t *testing.T) { + if os.Getenv("BIU_RUN_MYGO_E2E") != "1" { + t.Skip("设置 BIU_RUN_MYGO_E2E=1 才运行真窗口 E2E") + } + + client, stop := startRealApp(t) + defer stop() + window := firstContentWindow(t, client) + + // 先把窗口催出一帧(occluded 的窗一帧都没画过时无障碍树是空的)。 + pumpFrame(t, client, window) + + // 静止时:元素树里没有「pager-seam」。 + if n, ok := findSeamNode(t, client, window); ok { + t.Fatalf("没滑动的时候「pager-seam」已经在树上了(bounds %.0f,%.0f %.0fx%.0f)——"+ + "它只在滑动中出现", n.Bounds.X, n.Bounds.Y, n.Bounds.Width, n.Bounds.Height) + } + + // 按「鬼畜」那一档:切 Tab 走 `act` 的 press(它是 `flagClickable` 的)。 + // + // ⚠️ **认 Key 不认 Label**:「鬼畜」两个字是 Tab 上画的字,字自己不吃 press—— + // 可点的是包着它的那一格(`flagClickable`)。那一格的 key 是 `tab:` + 名字 + //(`tabs.go` 的 `tabKey`),按 key 发 press 就真的是「点了这一档」。 + if !waitForTree(t, client, window, "鬼畜", 20*time.Second) { + t.Fatalf("首页那一排里没有「鬼畜」:\n%s", treeDump(t, client, window)) + } + id, ok := e2eNodeIDByLabel(t, client, window, "鬼畜") + if !ok { + t.Fatalf("「鬼畜」那一档按不动:\n%s", treeDump(t, client, window)) + } + if err := automationCall(client, 600, "act", map[string]any{ + "window": window, + "id": id, + "action": "press", + }, nil); err != nil { + t.Fatalf("按「鬼畜」那一档:%v", err) + } + + // 立刻 capture:那一帧落在滑动中途(200ms 那段),接缝分割线在。 + // + // ⚠️ **不 sleep**:`capture` 逼的那一帧就是「现在」——滑动走墙钟,等的是 + // 「按下去之后那一帧」,不是「按下去之后等几帧」。 + img, geom := captureWindow(t, client, window) + seam, ok := findSeamNode(t, client, window) + if !ok { + // 没抢到滑动中途(200ms 太短,capture 落在走完之后):那一帧接缝已经 + // 收掉了——这一层只能再验「静止时没有」,画上去的样子由界面层的 + // shot 那一档和视觉审查看。 + t.Logf("capture 落在滑动走完之后(接缝已收)——只验静止时没有分割线") + if n, ok := findSeamNode(t, client, window); ok { + t.Errorf("滑动走完之后「pager-seam」还在树上(bounds %.0f,%.0f %.0fx%.0f)", + n.Bounds.X, n.Bounds.Y, n.Bounds.Width, n.Bounds.Height) + } + return + } + + // 分割线那一条竖带里有墨(`Border` 色,比底色深)。 + // + // ⚠️ **量的是它自己的 bounds**(DIP → 像素乘 `geom.scale`):接缝线 1 DIP + // 宽,bounds 就是它画的那个条。 + x0, y0 := geom.px(seam.Bounds.X), geom.py(seam.Bounds.Y) + x1, y1 := geom.px(seam.Bounds.X+seam.Bounds.Width), geom.py(seam.Bounds.Y+seam.Bounds.Height) + if x1 <= x0 || y1 <= y0 { + t.Fatalf("「pager-seam」的 bounds 是空的(%.0f,%.0f %.0fx%.0f)", + seam.Bounds.X, seam.Bounds.Y, seam.Bounds.Width, seam.Bounds.Height) + } + // 底色取它左边一格(接缝左边是滑出去的那一页的底色)。 + bg := pixelAt(img, geom, seam.Bounds.X-4, seam.Bounds.Y+4) + ink := 0 + for y := y0; y < y1 && y < img.Bounds().Dy(); y++ { + for x := x0; x < x1 && x < img.Bounds().Dx(); x++ { + p := img.RGBAAt(x, y) + if int(bg.R)+int(bg.G)+int(bg.B)-int(p.R)-int(p.G)-int(p.B) > 30 { + ink++ + } + } + } + if ink == 0 { + t.Errorf("「pager-seam」的 bounds(%.0f,%.0f %.0fx%.0f)里一点墨都没有——"+ + "接缝分割线没画上去(它要是 `Border` 色,比底色深的一条竖带)", + seam.Bounds.X, seam.Bounds.Y, seam.Bounds.Width, seam.Bounds.Height) + } + if os.Getenv("BIU_E2E_DUMP") != "" { + p := os.TempDir() + "/pager-seam.png" + f, _ := os.Create(p) + defer f.Close() + _ = png.Encode(f, img) + t.Logf("capture 落在 %s,接缝 bounds=%+v ink=%d", p, seam.Bounds, ink) + } + + // 滑动走完之后:接缝收掉。 + // + // ⚠️ **等树更新**:滑动是墙钟走的,等「pager-seam」从树里消失才算走完—— + // 问一次就当收掉了是「等了几帧」那种判据。 + deadline := time.Now().Add(2 * time.Second) + for { + if _, ok := findSeamNode(t, client, window); !ok { + break + } + if time.Now().After(deadline) { + t.Errorf("滑动走完之后「pager-seam」还在树上——它只在滑动中出现") + break + } + pumpFrame(t, client, window) + time.Sleep(30 * time.Millisecond) + } +} + +// findSeamNode 在元素树里找 key 是「home:pager:seam」或 Label 是「pager-seam」 +// 的那一项。 +// +// ⚠️ **两个都认**:真窗口里 key 上树(`treeJSON` 把 `keys[n.ID]` 塞成 `key`), +// 而无头 Tester 里那一项是 Label ——两个口径都找,免得哪天 key 的序列化变了 +// (`keyJSON` 对别的类型走 `fmt`)静默漏掉。 +func findSeamNode(t *testing.T, client *automationClient, window int) (seamNode, bool) { + t.Helper() + var tree struct { + Nodes []seamNode `json:"nodes"` + } + if err := automationCall(client, 601, "tree", map[string]any{"window": window}, &tree); err != nil { + t.Fatalf("读元素树:%v", err) + } + for _, n := range tree.Nodes { + if n.Label == "页面接缝" || n.Label == "pager-seam" || n.Key == "home:pager:seam" { + return n, true + } + } + return seamNode{}, false +} diff --git a/internal/ui/homepage_pager.go b/internal/ui/homepage_pager.go index f3f913bb..e2ed0951 100644 --- a/internal/ui/homepage_pager.go +++ b/internal/ui/homepage_pager.go @@ -105,6 +105,13 @@ const homePagerDuration = 200 * time.Millisecond // 「零值 = 500ms 弹簧」不是「没动画」。 var homePagerAnim = ui.Animation{Duration: homePagerDuration, Ease: motion.Unified} +// homePagerSeamW 是滑动中两页接缝那条分割线的宽(DIP)。 +// +// ⚠️ **和框架的 `ui.Divider` 同一个数**(1 DIP,`mygo/ui/widgets.go` 的 +// `coreDivider`):侧栏右缘、队列抽屉头下那条线都是这个宽——接缝这一道是 +// 同一种「分界」,不是装饰带。 +const homePagerSeamW = 1 + // homeBodyKeys 是内容块**每一档**的 key。 // // ⚠️ **存成 `any` 是为了一个分配都不多**:key 是 `any`,而把一个字符串装进 @@ -537,6 +544,47 @@ func (h *homePage) viewPager(c *ui.Context, s *Shell) { h.viewPagerPage(c, s, oldTab, width) h.viewPagerPage(c, s, newTab, width) }) + + // 接缝分割线:盖在两页交界的那一道竖缝上。 + // + // # 为什么要有它 + // + // 两页是**紧挨着**平移的,而容器是**直角** `Clip()`——榜单页那张 + // 112px 圆角 hero 被**拦腰竖切**,切出来的硬边和下一页直角内容 + // 之间没有任何过渡,画面上是一道「尖」。 + // + // ⇒ 在接缝处画一条竖线(网易云翻页那种缝隙感):它盖住被竖切的 + // 卡片右缘,也把两页顶部的高差弱化掉。 + // + // ⚠️ **它是 Absolute 的,不是 strip 里的第三段**:挂在**容器**上 + // (不是 strip 里)——容器是 `Clip()` 的那个框,它要的位置是「容器 + // 里的接缝 x」(`oldX + width`);塞进 strip 里就要跟着 strip 平移 + // (那时接缝在 strip 里是恒定的 `width`,但它的**可见**位置跟着 + // `oldX` 走,两者不是一回事),而且 strip 是一行,第三段会占一段 + // 宽度、把右页挤歪。 + // + // ⚠️ **x 是相对容器的**:`Left(oldX + width)`——`oldX` 是 strip 平移 + // 量(≤0),接缝就是左页右缘。它不取 `newX`:那个数在 strip 里, + // 相对容器是 `width + oldX`,是同一个数——取 `oldX + width` 少绕 + // 一层。 + // + // ⚠️ **只在滑动中出现**:静止时接缝不在画面上(ViewPager 的样子), + // 分割线也就不画——所以它在 `oldTab >= 0` 那一支里建,不在稳态那一 + // 支里。 + // + // ⚠️ **色取主题的 `Border`**(`c.Theme().Border`):和侧栏右缘、队列 + // 抽屉头下那条线同一个 token(`ui.Divider` 也是这个色),不硬编。 + // 宽度 1 DIP(和框架的 `ui.Divider` 一致)。 + // + // ⚠️ **不透明**:`Clip()` 已经把它裁在容器里,它自己是实色——不 + // 叠 alpha,免得接缝位置在深色底下发虚。 + // + // ⚠️ **不设 `RoleNone`**:e2e 要靠元素树的 bounds 量它的位置(判据 + // 见 `internal/e2e/pager_seam_test.go`),而 `RoleNone` 会把元素挡在 + // 树外。Label 用中文,无障碍朗读不会蹦英文串。 + ui.Box(c).Key("home:pager:seam").Label("页面接缝"). + Absolute().Left(oldX + width).Top(0).Bottom(0). + Width(homePagerSeamW).Background(c.Theme().Border) }) // ⚠️ **量的是容器的盒子**(上一帧的):两档横向错开的距离就是它。 h.pagerBox = box.LayoutBounds() diff --git a/internal/ui/homepage_pager_shot_test.go b/internal/ui/homepage_pager_shot_test.go new file mode 100644 index 00000000..d7999aa8 --- /dev/null +++ b/internal/ui/homepage_pager_shot_test.go @@ -0,0 +1,39 @@ +// 切 Tab 滑动中那条**接缝分割线**的画面验收(本票)。 +// +// BIU_SHOT_DIR=/some/dir go test ./internal/ui/ -run TestRenderPagerSeamShots +// +// 出三张:静止(没有分割线)、滑动中途(接缝处一条竖线)、走完之后(分割线收掉)。 +// +// ⚠️ **出图是材料,审查才是那道门**(AGENTS.md §1):这一份图是拿来看「分割线 +// 有没有盖住被竖切的卡片右缘、静止时确实不画」的——看完要把差在哪儿写进收尾 +// 说明。 +package ui + +import ( + "os" + "testing" + "time" +) + +// TestRenderPagerSeamShots 把滑动中途和静止时的 pager 各存一张 PNG。 +func TestRenderPagerSeamShots(t *testing.T) { + dir := os.Getenv("BIU_SHOT_DIR") + if dir == "" { + t.Skip("设 BIU_SHOT_DIR 才会存图") + } + + _, tt := newHome(t, fullSongs()) + save(t, dir, "pager-静止.png", tt) + + if err := tt.Click(homeTabs[1]); err != nil { + t.Fatalf("点不到 %q 那一档:%v", homeTabs[1], err) + } + // 推到动画的中途(旧的那一档已经走了一半,新的那一档刚进来一半)。 + tt.Advance(25 * time.Millisecond) + tt.Frame() + save(t, dir, "pager-滑动中途.png", tt) + + tt.Advance(homePagerDuration + 100*time.Millisecond) + tt.Frame() + save(t, dir, "pager-走完之后.png", tt) +} diff --git a/internal/ui/homepage_pager_test.go b/internal/ui/homepage_pager_test.go index 017b4adb..d90bf8d7 100644 --- a/internal/ui/homepage_pager_test.go +++ b/internal/ui/homepage_pager_test.go @@ -487,6 +487,87 @@ func TestThePagerShowsASkeletonWhileTheTabIsStillLoading(t *testing.T) { } } +// TestThePagerDrawsASeamBetweenTheTwoPagesWhileSliding 钉住**滑动中两页接缝处有 +// 一条分割线**(本票)。 +// +// # 为什么要有它 +// +// 滑动的时候两页是**紧挨着**平移的(`Left(oldX)`),而容器是**直角** `Clip()`—— +// 榜单页那张 112px 圆角 hero 被**拦腰竖切**,切出来的硬边和下一页直角内容之间 +// 没有任何过渡,画面上是一道「尖」。 +// +// ⇒ 修法是**在接缝处画一条竖向分割线**(网易云翻页那种缝隙感):它盖住被竖切 +// 的卡片右缘,也把两页顶部的高差弱化掉。 +// +// # 判据是**几何**,不是像素 +// +// 分割线是半透明混色画上去的(主题的 `Border`),画面上扫不到精确颜色—— +// 所以这一条断两样: +// +// 1. **元素在**:接缝处那一格挂在「页面接缝」这个名字下(`tt.Find` 按 +// Label 找); +// 2. **位置对**:它的横向位置在两页接缝上——左页右缘(`oldX + width`), +// 上下顶满容器。 +// +// 而「画上去的是不是分隔线颜色」由**出图自查**那一档看(BIU_SHOT_DIR), +// 像素判据留给 e2e。 +// +// ⚠️ **静态时不画**:两页都不动的时候接缝不在画面上(那是 ViewPager 的样子), +// 分割线也就不该在——所以这一条也断「停着的时候那个名字不在」。 +func TestThePagerDrawsASeamBetweenTheTwoPagesWhileSliding(t *testing.T) { + s, tt := newHome(t, fullSongs()) + + // 静态:没滑动,接缝不在画面上。 + if _, ok := tt.Find("页面接缝"); ok { + t.Fatalf("没滑动的时候接缝分割线已经在画面上了——它只在滑动中出现") + } + + if err := tt.Click(homeTabs[1]); err != nil { + t.Fatalf("点不到 %q 那一档:%v,文字有 %q", homeTabs[1], err, tt.Texts()) + } + // 推到动画的中途(旧的那一档已经走了一半,新的那一档刚进来一半)。 + tt.Advance(25 * time.Millisecond) + tt.Frame() + + g := homePagerView + if g.oldTab < 0 || g.newTab < 0 { + t.Fatalf("动画中途两档没有同时在(oldTab=%d newTab=%d)", g.oldTab, g.newTab) + } + seam, ok := tt.Find("页面接缝") + if !ok { + t.Fatalf("滑动中途接缝处没有分割线(文字有 %q)——\n"+ + "两页接缝处要画一条竖线,盖住被竖切的卡片右缘", tt.Texts()) + } + + // 它的 x 在左页右缘(oldX + width),上下顶满容器。 + // + // ⚠️ **容器的左沿用 `h.pagerBox`**(`viewPager` 每一帧量一份):`tt.Find` + // 按 Label 找,而容器挂的是 key 不是 Label——它不会上树。`pagerBox` 报的 + // 就是**上一帧**那个容器的盒子,接缝相对容器是 `oldX + width`,放到画面 + // 上就是 `pagerBox.X + oldX + width`。 + // + // ⚠️ **断「它贴在那个 x 上」,不断「它的宽是几」**:`tt.Find` 报的是裁切 + // 之后的盒子,接缝那条线被容器裁掉的那一半不在盒子里——所以只断**左沿**。 + wantX := s.home.pagerBox.X + g.oldX + g.width + if math.Abs(float64(seam.X-wantX)) > 2 { + t.Errorf("分割线的左沿在 x=%.1f,want %.1f(接缝 = 容器左沿 + oldX + width)——\n"+ + "它要正好落在两页接缝上,盖住被竖切的卡片右缘", seam.X, wantX) + } + if seam.W <= 0 || seam.W > 8 { + t.Errorf("分割线的宽是 %.1f——它是一条细线(want 0 < W ≤ 8)", seam.W) + } + if seam.H <= 0 { + t.Errorf("分割线的高是 %.1f——它要顶满容器(竖向一条线)", seam.H) + } + + // 走完之后:接缝不在,分割线也不在。 + tt.Advance(homePagerDuration + 100*time.Millisecond) + tt.Frame() + if _, ok := tt.Find("页面接缝"); ok { + t.Errorf("滑动走完之后接缝分割线还在画面上——它只在滑动中出现") + } +} + // 免得「这一组用不到」被当成「没写」。 var _ = mygoui.Smooth