Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions internal/e2e/pager_seam_test.go
Original file line number Diff line number Diff line change
@@ -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
}
48 changes: 48 additions & 0 deletions internal/ui/homepage_pager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`,而把一个字符串装进
Expand Down Expand Up @@ -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()
Expand Down
39 changes: 39 additions & 0 deletions internal/ui/homepage_pager_shot_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
81 changes: 81 additions & 0 deletions internal/ui/homepage_pager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading