diff --git a/internal/e2e/follow_test.go b/internal/e2e/follow_test.go new file mode 100644 index 00000000..2778bff0 --- /dev/null +++ b/internal/e2e/follow_test.go @@ -0,0 +1,274 @@ +//go:build e2e + +package e2e + +// 「我的关注」页头分组下拉的真窗口回归(票 #367 的那一档)。 +// +// # 为什么要有这一族 +// +// 这一票把左边 200 宽的分组栏收成页头右上角的一枚下拉,而界面层那一族 +// (`internal/ui/follow_test.go` / `ticket213_test.go`)造的是**假外壳**—— +// 它断不到两件事: +// +// 1. **下拉的弹层在真窗口上真的摆开**(弹层是**覆盖层**,无头 Tester 那一层 +// 和真窗口的 hit chain / 覆盖层合成是两条路——`SelectBase` 的 `Popup` 走 +// `coreOverlay`,真窗口上它盖在内容区上头还是压在底下,只有真窗口验得了)。 +// 2. **Esc 真的把弹层收掉**(`OverlayShortcut` 那条路在无头里是一条 `Key` +// 调用,在真窗口上是「覆盖层先吃这个键」的一整条分发)。 +// +// # 怎么跑 +// +// BIU_RUN_MYGO_E2E=1 go test -tags e2e ./internal/e2e/ -run TestTheFollowGroupSelect +// +// ⚠️ **要一个真窗口**:它开一个窗口并驱动它,所以本机得能开窗口。 +// +// ⚠️ **它跑的是整个应用**(不是 fixture):要验的就是这个应用的那一枚下拉。 +// +// ⚠️ **它只断「弹层开得了、Esc 收得掉」这两件窗级的事**——「哪一项摆什么 +// 颜色、分组名长怎么截、三态怎么画」都归界面层那一族和视觉审查,这里不碰 +// (理由同 `minicurtain_test.go` 顶上那一段:E2E 断机制和几何,不断像不像)。 + +import ( + "os" + "testing" + "time" +) + +// TestTheFollowGroupSelect 断**页头那枚「选择关注分组」在真窗口上点得开、 +// Esc 收得掉**。 +// +// # 为什么判据是「那一枚 Label 在不在」而不是「弹层的某一项在不在」 +// +// 弹层里的项是**动态**的(「全部关注(N)」/ 分组名 / 「还没有分组」/「正在 +// 加载分组…」/「分组没拉出来」——哪一条出现取决于 B 站那一拉回什么),而 +// E2E 起的是**真应用**:种的是假凭据(见 `seedFakeSession`),分组那一拉真 +// 发出去真被拒,弹层里画的是「分组没拉出来」——那也是「弹层摆开了」。所以 +// **开不开**的判据只用「弹层里有东西出现」,而**收没收掉**用「弹层独有的 +// 那几句都不在了」。 +// +// ⚠️ **「选择关注分组」那枚 Label 本身每一帧都在**(它是触发器的无障碍名, +// 弹层收着时也在),所以不能拿「它出现了」当弹层开了——得拿**弹层里独有的 +// 那句话**(三态里的任何一句都算)。 +func TestTheFollowGroupSelect(t *testing.T) { + if os.Getenv("BIU_RUN_MYGO_E2E") != "1" { + t.Skip("设置 BIU_RUN_MYGO_E2E=1 才运行真窗口 E2E") + } + + // ⚠️ **要先种一份假登录态**:「我的关注」标了 `HideInMenuWhenLoggedOut`, + // 没凭据时侧栏里根本没有它(见 `startRealAppWith` 那一段)。 + client, stop := startRealAppWith(t, true) + defer stop() + window := firstContentWindow(t, client) + + // ⚠️ **先把窗口催出一帧**:`act` 按 key 找人走的是**上一帧留下的元素树** + // (`protocol.go` 的 act → `d.tree`),而 occluded 的窗一帧都没画过时那棵 + // 树是空的——问出来的「没有那一项」其实是「还没有树」。`pumpFrame` 逼一帧 + // 再走(理由同 `minicurtain_test.go` 那一处)。 + pumpFrame(t, client, window) + + // 先走到「我的关注」:侧栏那一项的 key 是 `side-item:/follow` + //(`sidebar.go` 的 `side-item:` + `rt.Match`)。 + // + // ⚠️ **用 `act` 的 press 按,不用 `input` 的 click**:侧栏那一项是整个 + // 可点的一块,`press` 走无障碍那条路——它比「点在某个坐标上」更贴近 + // 「用户按了这一项」,而且不随窗口尺寸和侧栏折叠态漂。 + // + // ⚠️ **等的是「页头那一枚出现了」而不是「侧栏那一项被点了」**:换路由 + // 是异步的,那一帧可能还没画——`waitForTree` 是等「出现了」,不是 + // 「等了几帧」(`docs/agents/testing.md` 的判据)。 + // ⚠️ **按 id 走 `act` 的 press**:`side-item:` 那一格自己没进树(树里只有 + // 它里面那枚按钮的 Label),而 `Bounds` 在这个应用里全是零(accessibility + // 不报位置),坐标点不着——所以只能按 **id**(`act` 认 id 不认 Label)。 + // 而那一枚按钮的 `actions` 里有 `press`(它是 `flagClickable` 的), + // 按 id 发过去就真的是「按了这一项」。 + id, ok := e2eNodeIDByLabel(t, client, window, "我的关注") + if !ok { + t.Fatalf("侧栏里没有「我的关注」那一项:\n%s", treeDump(t, client, window)) + } + err := automationCall(client, 500, "act", map[string]any{ + "window": window, + "id": id, + "action": "press", + }, nil) + if err != nil { + t.Fatalf("按侧栏「我的关注」:%v", err) + } + if !waitForTree(t, client, window, "选择关注分组", 20*time.Second) { + t.Fatalf("进关注页之后没有「选择关注分组」那一枚:\n%s", treeDump(t, client, window)) + } + + // 点开下拉。 + // + // ⚠️ **触发器也是按 id 发 press**:它是 `SelectBase` 的 Trigger( + // `flagClickable`,`actions` 里有 `press`),挂在「选择关注分组」那个 + // Label 上(`follow.go` 的 `trigger.Label(...)`)——和侧栏那一项同一 + // 条路。 + if !followSelectOpen(t, client, window) { + t.Fatalf("点开「选择关注分组」之后弹层没摆开:\n%s", treeDump(t, client, window)) + } + + // Esc 收掉它。 + // + // ⚠️ **Esc 走 `input` 的 key**(`OverlayShortcut` 那条路),不走菜单: + // 「收弹层」是覆盖层自己的事,不是应用菜单的事。 + err = automationCall(client, 501, "input", map[string]any{ + "window": window, + "events": []map[string]any{ + {"type": "key", "key": "Escape"}, + }, + }, nil) + if err != nil { + t.Fatalf("按 Esc:%v", err) + } + // 弹层里那句话没了才算收掉(等「消失」也要等,不能问一次就当收了)。 + // + // ⚠️ **「全部关注」不在 gone 名单里**:它同时是触发器收起时写的字(停在 + // 「全部关注」那一格时 `followPaneTitle` 就回它)——弹层收掉之后它**还在** + // 树上,拿它当「弹层独有的字」这一条就永远等不到它消失。 + if !waitForTreeGone(t, client, window, "选择关注分组", 15*time.Second, "正在加载分组", "分组没拉出来", "还没有分组") { + t.Fatalf("按了 Esc 之后弹层没收掉:\n%s", treeDump(t, client, window)) + } +} + +// followSelectOpen 点开页头那枚「选择关注分组」,等弹层里出现**任何一句 +// 弹层独有的字**(三态里的哪一句都行——假凭据下分组那拉真发真败,画的是 +// 「分组没拉出来」,那也算弹层摆开了)。 +func followSelectOpen(t *testing.T, client *automationClient, window int) bool { + t.Helper() + id, ok := e2eNodeIDByLabel(t, client, window, "选择关注分组") + if !ok { + t.Logf("树里没有按得动的「选择关注分组」") + return false + } + err := automationCall(client, 502, "act", map[string]any{ + "window": window, + "id": id, + "action": "press", + }, nil) + if err != nil { + t.Logf("按「选择关注分组」那一枚:%v", err) + return false + } + // 弹层里独有的任何一句:三态里的哪一句都算「摆开了」。 + // + // ⚠️ **「全部关注」不在名单里**:它同时是**触发器收起时**写的字(停在 + // 「全部关注」那一格时 `followPaneTitle` 就回它)——拿它当「弹层开了」 + // 的判据,会在弹层还没开时就命中(那一枚每帧都在)。这里只能靠**三态 + // 独有的**那三句。 + for _, want := range []string{"正在加载分组", "分组没拉出来", "还没有分组"} { + if waitForTree(t, client, window, want, 8*time.Second) { + return true + } + } + return false +} + +// e2eNode 是元素树里的一项(只取这一族用得上的那几个字段)。 +type e2eNode struct { + ID any `json:"id"` + Parent any `json:"parent"` + Label string `json:"label"` + Actions []string `json:"actions"` +} + +func hasE2EAction(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} + +// e2eNodeIDByLabel 是「树里写着这个词、且按得动」的那一项的 id。 +// +// ⚠️ **认 id 不认 Label**:`act` 只接 id 或 key,而「选择关注分组」「我的 +// 关注」那几格自己没上 Key(`side-item:` 那格是容器,不进树)——Label 是 +// 名字、key 是钥匙,名字对得上不等于钥匙开得了。按 id 是唯一能按到那一枚的 +// 路。 +// +// ⚠️ **为什么要挑 `actions` 里有 `press` 的**:Label 是画上去的字,字自己 +// 也在树里占一项(`Text`),而**字不吃 press**——给它发 press 等于按了一张 +// 图片。可点的是**外面那一格**(`flagClickable` 的那一个),它的 `actions` +// 里才有 `press`。 +// +// ⚠️ **`Parent` 记的是父的 id,不是下标**(`protocol.go` 塞的是 +// `t.Nodes[n.Parent].ID`)——往父链上找要**按 id 找父**,别把 `Parent` +// 当下标用。 +func e2eNodeIDByLabel(t *testing.T, client *automationClient, window int, label string) (any, bool) { + t.Helper() + var tree struct { + Nodes []e2eNode `json:"nodes"` + } + if err := automationCall(client, 503, "tree", map[string]any{"window": window}, &tree); err != nil { + return nil, false + } + byID := make(map[any]int, len(tree.Nodes)) + for i, n := range tree.Nodes { + byID[n.ID] = i + } + for _, n := range tree.Nodes { + if n.Label != label { + continue + } + // 理想情况:写着这个词的那一项自己就按得动(按钮把 Label 带在身上)。 + if hasE2EAction(n.Actions, "press") { + return n.ID, true + } + // 不然它是一个 Text——往父链上找第一个有 press 的,那才是「按这里」。 + for p := n.Parent; p != nil; { + pi, ok := byID[p] + if !ok { + break + } + pn := tree.Nodes[pi] + if hasE2EAction(pn.Actions, "press") { + return pn.ID, true + } + p = pn.Parent + } + } + return nil, false +} + +// waitForTreeGone 等那几句**弹层独有的字**都从树里消失(触发器自己的 +// Label 不算——它收着弹层时也在)。 +// +// ⚠️ **它和 `waitForTree` 是同一件事的反面**:Esc 是异步的,弹层收掉是 +// 下一帧的事——问一次就断定收没收到,是「等了几帧」那种判据。 +func waitForTreeGone(t *testing.T, client *automationClient, window int, keep string, timeout time.Duration, gone ...string) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for { + var tree struct { + Nodes []struct { + Label string `json:"label"` + } `json:"nodes"` + } + if err := automationCall(client, 504, "tree", map[string]any{"window": window}, &tree); err != nil { + return false + } + labels := make(map[string]bool, len(tree.Nodes)) + for _, n := range tree.Nodes { + labels[n.Label] = true + } + // 触发器自己还在(它是「收起那一格」,收着弹层时也在)。 + if !labels[keep] { + return false + } + anyGone := false + for _, g := range gone { + if labels[g] { + anyGone = true + break + } + } + if !anyGone { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(100 * time.Millisecond) + } +} diff --git a/internal/e2e/menu_test.go b/internal/e2e/menu_test.go index c49085ec..eec94e38 100644 --- a/internal/e2e/menu_test.go +++ b/internal/e2e/menu_test.go @@ -127,6 +127,22 @@ func TestTheApplicationMenu(t *testing.T) { // ⚠️ **编的是根目录那个 main**,不是 `fixture/` 里的那个——要验的就是 // **这个应用的菜单**,而菜单是应用级的。 func startRealApp(t *testing.T) (*automationClient, func()) { + return startRealAppWith(t, false) +} + +// startRealAppWith 是 `startRealApp` 的内里,`seedSession` 说要不要先往 +// E2E 那份数据目录里塞一份**假**登录态。 +// +// ⚠️ **为什么要有这一档**:有几页只在「登录了」才出现(侧栏的「我的关注 / +// 稍后再看 / 历史记录」标了 `HideInMenuWhenLoggedOut`),而 E2E 起的是真 +// 应用——没凭据时它们压根不在菜单里,连路由都进不去。塞一份假 `session.json` +// 之后应用当「已登录」跑(`fileStore.Load` 只看 SESSDATA 在不在),而那三页 +// 就都能进了。 +// +// ⚠️ **假的是「登录态」,不是「B 站」**:接口还是会真发(假的 SESSDATA 会被 +// B 站拒),所以「列表拉到没有」走的是**真失败**那条路——而那一档的弹层 +// 三态(「分组没拉出来」)正是要验的。 +func startRealAppWith(t *testing.T, seedSession bool) (*automationClient, func()) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) socketDir, socket, err := makeAutomationSocket() @@ -135,6 +151,11 @@ func startRealApp(t *testing.T) (*automationClient, func()) { t.Fatalf("创建自动化 socket:%v", err) } binary := filepath.Join(socketDir, "biugo-e2e") + // ⚠️ **先种凭据再启动**:`fileStore` 读的是启动那一刻的数据目录—— + // 进程起来之后再写就来不及了(它只在启动时 Load 一次)。 + if seedSession { + seedFakeSession(t, filepath.Join(socketDir, "data")) + } var buildOut, buildErr bytes.Buffer build := exec.Command("go", "build", "-tags", "e2e", "-o", binary, ".") build.Dir = repoRoot(t) @@ -173,6 +194,40 @@ func startRealApp(t *testing.T) (*automationClient, func()) { return client, stop } +// seedFakeSession 往 E2E 那份数据目录里写一份**假** `session.json`。 +// +// ⚠️ **只让 `fileStore.Load` 当「已登录」**:判据是 `SESSDATA` 在不在 +// (`credentials.go` 的 `LoggedIn`)——值是不是真的能过 B 站不验(E2E 里 +// 接口真发、真被拒,而那正是「拉失败」那条路)。Cookies 里那三条(SESSDATA / +// bili_jct / DedeUserID)和顶上的同名字段对齐,不然 `Normalize` 会以 Cookies +// 为准把上面那三个字段抹掉。 +func seedFakeSession(t *testing.T, dataDir string) { + t.Helper() + dir := filepath.Join(dataDir, "credentials") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("建凭据目录:%v", err) + } + session := map[string]any{ + "SESSDATA": "e2e-fake-sessdata", + "bili_jct": "e2e-fake-jct", + "DedeUserID": "2333", + "savedAt": time.Now().UTC().Format(time.RFC3339), + "cookies": []map[string]any{ + {"name": "SESSDATA", "value": "e2e-fake-sessdata", "domain": ".bilibili.com", "path": "/"}, + {"name": "bili_jct", "value": "e2e-fake-jct", "domain": ".bilibili.com", "path": "/"}, + {"name": "DedeUserID", "value": "2333", "domain": ".bilibili.com", "path": "/"}, + }, + } + blob, err := json.Marshal(session) + if err != nil { + t.Fatalf("序列化假登录态:%v", err) + } + // 权限收 0600:`fileStore.readFile` 会把比它宽的权限判成「被放宽」。 + if err := os.WriteFile(filepath.Join(dir, "session.json"), blob, 0o600); err != nil { + t.Fatalf("写假登录态:%v", err) + } +} + // windowTitle 是那个内容窗口的标题。 // // ⚠️ **第一栏(应用菜单)的名字就是窗口标题**(框架的 `RoleAppMenu` 用 diff --git a/internal/ui/currentitem_test.go b/internal/ui/currentitem_test.go index bba21132..c34c2026 100644 --- a/internal/ui/currentitem_test.go +++ b/internal/ui/currentitem_test.go @@ -152,30 +152,9 @@ func TestTheCurrentItemIsFlatEverywhere(t *testing.T) { name = "深色" } t.Run(name, func(t *testing.T) { - t.Run("关注页分组", func(t *testing.T) { - b := newUIExplore(t) - uiFollowTags(b, map[int]any{-10: "特别关注"}) - uiFollowList(b, 3, []any{uiFollowUser(11, "第一个 UP")}) - uiFollowSearch(b, "", nil) - s, tt, _ := newSpaceShell(t, b, true, "77", systemIsDark(dark)) - s.navigate(followPath) - tt.Frame() - f := s.followPageOf() - waitFrames(t, tt, "分组到了", func() bool { return f.tagStarted }) - tt.Frame() - - cur, ok := tt.Find("全部关注(3)") - if !ok { - t.Fatalf("左栏没有「全部关注(3)」那一格,文字有 %q", tt.Texts()) - } - sib, ok := tt.Find("特别关注(1)") - if !ok { - t.Fatalf("左栏没有「特别关注(1)」那一格,文字有 %q", tt.Texts()) - } - theme := BuildTheme(app.DefaultSettings(""), dark) - assertCurrentIsFlat(t, currentSpot{cur, sib, "关注页分组", - flatSolidCurrentText(theme)}, tt.Image(), dark) - }) + // ⚠️ **关注页原来有第三处(左边那列分组)——#367 改版把它收成页头 + // 下拉了**:下拉里「当前值」是淡底(`enumSelect` 的口径),不是导航列 + // 上那种「当前在哪」的实心主色信标,所以它不再归这一条管。 t.Run("动态页作者栏", func(t *testing.T) { b := newUIExplore(t) diff --git a/internal/ui/follow.go b/internal/ui/follow.go index bd4deb81..959aeca4 100644 --- a/internal/ui/follow.go +++ b/internal/ui/follow.go @@ -52,7 +52,6 @@ import ( "github.com/ShellMonster/PudMusic/internal/notice" "github.com/ShellMonster/PudMusic/internal/ui/rowkey" - "github.com/ShellMonster/PudMusic/internal/ui/widgets/flat" "github.com/ShellMonster/PudMusic/internal/ui/widgets/leading" "github.com/ShellMonster/PudMusic/internal/ui/widgets/list" "github.com/ShellMonster/PudMusic/internal/ui/widgets/motion" @@ -69,8 +68,16 @@ import ( // 而且错得很安静——这一页退回占位页,没人看得出来(理由同 `laterPath`)。 const followPath = "/follow" -// followPaneW 是左边那一列的宽度。上游是 `w-50`(200)。 -const followPaneW = 200 +// followGroupSelectH / followGroupSelectMinW 是右上角那个分组下拉的尺寸。 +// +// ⚠️ **它得认得出「是一个下拉」**:收起那一格只写当前那一组的名字 + 一枚箭头, +// 比搜索框矮一截(`settingsSelectH` 是 32,页头里和它对齐);而宽度按当前那一格 +// 的名字走(分组名是用户自己起的,可长可短),设一个下限不让它缩成一格箭 +// 头。 +const ( + followGroupSelectH = settingsSelectH + followGroupSelectMinW = 120 +) // followCardSide 是一张卡上头像的边长。上游是 `h-32 w-32`(128)。 const followCardSide = 128 @@ -122,14 +129,14 @@ var followActionBarKey = struct{ followActionBar bool }{} // (`follow-list/index.tsx:296`)。 const followSearchLabel = "搜索关注" -// followTabAll 是左边第一格「全部关注」的身份。 +// followTabAll 是下拉里第一项「全部关注」的身份。 // // ⚠️ **用 `all` 这个串而不是空串**:空串在「分组列表还没拉到」那一档也是空串, // 而那一档要画的是「还没拉到」,不是「全部关注」。两个都空的话那一帧画出来 -// 第一格没有名字,而用户会以为整个分组列表是空的。 +// 那一项没有名字,而用户会以为整个分组列表是空的。 const followTabAll = "all" -// followTagTab 是分组那一格的身份。分组 id 可正可负(B 站固定有 -10 特别关注 +// followTagTab 是分组那一项的身份。分组 id 可正可负(B 站固定有 -10 特别关注 // 和 0 默认分组),所以身份是带前缀的串而不是那个数本身。 func followTagTab(tagID int) string { return "tag:" + strconv.Itoa(tagID) } @@ -191,7 +198,7 @@ var followEmptyTag = emptyState{ // ⚠️ **只由界面线程读写**,所以里面没有一个锁;后台那一条线只往 box 那个带锁 // 的信箱里投,投完叫醒界面(铁律三)。 type followPage struct { - // ── 左边那一列 ── + // ── 分组那一摊 ── // // tags 是关注分组。tagStarted / tagErr 是这一摊自己的状态——它和右边那一格 // 是两次独立的请求,各拉各的,各失败各的。 @@ -215,13 +222,9 @@ type followPage struct { pager upPager - // state 是网格的行状态,paneState 是左边那一列的。建一次存着,每帧用 - //(铁律四:换列数是布局变了,每帧新建等于每帧跳回顶上)。 - // - // ⚠️ **两个 ListState 不能共用**:MyGo 会在「同一个 ListState 被两个 List - // 显示」时 panic,而共用还会把两边的滚动位置搅在一起。 - state ui.ListState - paneState ui.ListState + // state 是网格的行状态。建一次存着,每帧用(铁律四:换列数是布局变了, + // 每帧新建等于每帧跳回顶上)。 + state ui.ListState // rowKeys 记着网格每一行的身份(**预先装箱好的那一份**)。 // // ⚠️ 装箱那一步是要分配的(`runtime.convTstring`),现算的话每帧每行一次 @@ -371,13 +374,11 @@ func (f *followPage) resetUsers() { // ⚠️ **外面已经包了一层 recover**(`router.go` 的 `Shell.page`)。 // ⚠️ **问 `r` 不要问 `c.Router()`**(理由同 `upspace.go`)。 // -// ⚠️ **这一页没有页标题,而名字是有的**(票 #127):它写在**左边那一列顶上** -// ——`followPaneTitle` 给出「全部关注」/ 那一组的名字 / 「我的关注」 -// (理由见那个函数)。上游那一页的 `

` 给的也是同一句话 -// (`src/pages/follow-list/index.tsx:206`:`全部关注` / 分组名 / `我的关注`), -// 只是上游把它摆在右上角、我们摆在左边那一列的头顶上——那一列本来就是分组 -// 列表,「现在是哪一组」和「哪一列」是同一件事。 -// 顶栏那一处页名票 #127 去掉之后,这一页的名字就只剩这一处,**不重复**。 +// ⚠️ **这一页的页名是页头那个 24 号的「我的关注」**(`viewFollowHeader`),而 +// 「现在看的是哪一组」是页头右上角那枚下拉自己的事(`followPaneTitle` 给 +// 「全部关注」/ 那一组的名字 / 「我的关注」——分组还没拉到时的兜底)。上游 +// 那一页把同一句话塌成一个 `

`(`src/pages/follow-list/index.tsx:206`)—— +// 我们这里是**两个字**:页名不换,下拉跟着组换。 func (s *Shell) viewFollow(c *ui.Context, _ *ui.Route) { // 🔴 铁律二:每帧第一件事就是装主题,而且必须在最开头。 s.applyFrameTheme(c) @@ -404,14 +405,12 @@ func (s *Shell) viewFollow(c *ui.Context, _ *ui.Route) { return } - ui.Row(c).Key("page:" + followPath).Grow(1).FillHeight().Children(func() { - s.viewFollowPane(c, f) - ui.Column(c).Key("follow-body").Grow(1).FillHeight().Padding(space.Px16). - Gap(space.Px8).Children(func() { + ui.Column(c).Key("page:"+followPath).Grow(1).Padding(space.None, pagePadX).Gap(space.Px8). + Children(func() { + s.viewFollowHeader(c, f) s.viewFollowSearch(c, f) s.viewFollowGrid(c, f) }) - }) // 「回到顶部」那一枚。滚过 400 像素才出现(上游的数,见 `widgets/list` 的 // `BackToTop`)。 @@ -480,140 +479,159 @@ func (f *followPage) scrollTop() { f.state.ScrollTo(0, ui.Start) } -// viewFollowPane 是左边那一列:标题 + 「全部关注(N)」+ 每个分组一格。 -// -// # 这一列是**侧栏的邻居**:画法照侧栏那一套(票 #367) -// -// 这一列紧挨着侧栏摆着,用户一眼就要看出它们是同一件东西的两级。所以这一列 -// 的每一处都取侧栏那一份,不另写一份: -// -// - **底色用方案的 `sidebar`**(`Shell.sidebarBackground`,和侧栏那一栏同一个 -// 出口)——于是侧栏 + 这一列是同一片「导航区」,内容区是另一片。 -// - **选中那一格是实心主色底**(`flatSolidCurrent`)—— -// 和侧栏菜单当前项**同一个函数**,所以两个色值逐字节相同。 -// - **没选中的是次要色**:这一列是这一页里的第二层导航,选中那一格才是要看的 -// 那一格;次要色让「现在在哪」一眼看得出来。 -// - **行高、字号、内边距**照侧栏收藏夹那一行(`viewFavoriteRow`):14 的字、 -// 上下 6 的内边距、行与行之间 2 的缝。 -// ⚠️ **侧栏的「菜单项」是 13**(框架的默认字号,macOS 上 13)——那是**带图标** -// 的那一族;这一列是**纯文字**的行,和侧栏的收藏夹行同族,所以是 14 -// (DESIGN.md §二的正文档;「用户动态」那一页的作者栏也是 14)。 -// ⚠️ **左右不一样**:侧栏收藏夹那一行的**左**内边距是 20(票 #371:那一列 -// 的字在 36,因为分组标题左边有一枚箭头图标),而这一列的字在 16——这一列 -// 自己就贴着内容区的左边沿,用不着给谁让位。 -// - **圆角走主题的 `Radius`**,底色、字色一个都不写死——方案换了整套跟着换。 -// -// ⚠️ **左边的行和右边的格是两个 `ListState`,不能共用**(MyGo 会在同一个 -// ListState 被两个 List 显示时 panic)。 -func (s *Shell) viewFollowPane(c *ui.Context, f *followPage) { +// viewFollowHeader 是页头那一行:左边标题 + 关注总数,右边分组下拉。 +// +// 上游 `follow-list/index.tsx:206` 的 `

` 在这一页是「全部关注 / 分组名 / +// 我的关注」,而**我们页头一直写「我的关注」**——「现在在哪一组」是右上角那个 +// 下拉自己的事(它的触发器写着当前那一格的名字),不是页头的事。上游那一支 +// 在「停在某一组」和「停在全部」之间换名字,而我们这一页的名字本来就不换。 +// +// 布局照 `later.go` 的 `viewLaterHeader`:标题在左、右边一枚控件,`Spacer` +// 吃掉中间的空白。 +func (s *Shell) viewFollowHeader(c *ui.Context, f *followPage) { t := c.Theme() - // 右缘一条分界线,照上游 `follow-list/index.tsx:204` 的 `border-r` - // (`border-divider/40`)。 - ui.Column(c).Key("follow-pane").Width(followPaneW).FillHeight(). - Padding(space.Px8, space.Px8).Gap(space.Px8). - // 🔴 **底色是方案的 `sidebar`,不是页面底色**:这一列和侧栏是邻居, - // 两栏同色才是一家人(理由见这一段的抬头)。 - Background(s.sidebarBackground(t)). - BorderWidth(0, 1, 0, 0).BorderColor(t.Border.Alpha(paneDividerA)).Children(func() { - // ⚠️ **失败也要说失败**:`tagErr` 有值的时候下面那一支已经把分组列表 - // 换成了错误提示,而标题还在——用户点「重试」时看得出自己重试的是 - // 哪一摊。 - // - // ⚠️ **这一句是「这一栏叫什么」,不是页标题**:所以它走**侧栏分组标题 - // 那一档**(12 / 500 / 次要色),而不是 14 的正文档——改之前它是 14 的 - // 正文色,而它下面那些选项是 13 的字,两样挨在一起、字号也差一个字, - // 用户会把「这一栏叫什么」和「这一栏里的选项」当成同一层。 - ui.Text(c, followPaneTitle(f)).FontSize(scale.Font(c, scale.Size12)). - FontWeight(weightMedium).TextColor(t.TextMuted). - PaddingX(space.Px8).MaxLines(1).Ellipsis("…") - // ⚠️ **「拉失败了」必须排在「空」前面**:反过来的话第一页失败时左边 - // 是一片「还没有分组」,而 `tagStarted` 仍是 false、`tagErr` 非 nil, - // 切走再切回来也不会重拉——用户会以为他真的一个分组都没建过。 - if !f.tagStarted && f.tagErr != nil { - s.viewFollowPaneFailed(c, f) - return - } - // 🔴 **「没拉到」和「一个分组都没有」是两件事**,界面上是两句话: - // 前者要按重试,后者不用(那是真的没有分组,B 站那会儿返回空数组)。 - if !f.tagStarted { - ui.Text(c, followPaneLoading).FontSize(scale.Font(c, scale.Size12)). - TextColor(t.TextMuted).PaddingX(space.Px8) - return + ui.Row(c).Key("follow:page-title").FillWidth().AlignItems(ui.Center).Children(func() { + ui.Text(c, "我的关注").FontSize(scale.Font(c, scale.PageTitle)) + // 总数是淡一档的数字徽章,跟在标题后面(上游 `index.tsx:224` 的 + // `allCount ? \`(${allCount})\` : ""`)。 + if f.total > 0 { + ui.Text(c, strconv.Itoa(f.total)).FontSize(scale.Font(c, scale.Size14)). + TextColor(t.TextMuted).Padding(space.Px2, space.Px6) } - // ⚠️ Key 挂在外面的 Column 上:List 是自己管状态的控件,事后再挂 key - // 会 panic(理由同 `dynamicfeed.go`)。 - // - // ⚠️ **没有分组的时候这一格不 Grow**:Grow 是「吃掉剩下的空间」,它一长, - // 后面那句「还没有分组」就被顶到这一列的最底下,离它该解释的那一格十万 - // 八千里(而那一格只有一行,压根不需要滚动)。 - groups := ui.Column(c).Key("follow-pane-list").FillWidth() - if len(f.tags) > 0 { - groups = groups.Grow(1).FillHeight() - } - groups.Children(func() { - // 登记「内容区里当前那一个能被方向键走位的」(票 #245 的 E4)。 - // ⚠️ **分组栏先登记、网格后登记**,于是网格赢——它是这一页的主内容。 - s.registerContentFocus(list.HandleFocus(&f.paneState.Handle)) - // ⚠️ **行与行之间 2 的缝**:侧栏的菜单列和收藏夹列都是这一档 - // (`sidebar.go` / `sidebar_favorites.go` 的 `Gap(space.Px2)`)。 - pane := ui.List(c, &f.paneState, len(f.tags)+1). - Grow(1).FillHeight().Gap(space.Px2). - Rows(func(r ui.ListRow) { s.viewFollowPaneRow(r.Context, f, r.Index) }) - s.trackScrollbar(pane) - }) - // 🔴 **「一个分组都没有」要有一句话**:什么都不摆的话用户以为这一列 - // 没拉出来(而它确实拉出来了,只是 B 站那会儿返回空数组)。 - // 抄侧栏收藏夹那一句的写法(`viewFavoritesEmptyLine`):12 的次要色。 - if len(f.tags) == 0 { - ui.Text(c, followPaneNoTags).FontSize(scale.Font(c, scale.Size12)). - TextColor(t.TextMuted).PaddingX(space.Px8) - } + ui.Spacer(c) + + s.viewFollowGroupSelect(c, f, t) }) } -// followPaneLoading 是「分组还在拉」那一句话。 +// viewFollowGroupSelect 是右上角那个「全部关注 ▾」下拉。 // -// ⚠️ **它是「还在拉」,不是「没有」**:这一档和「还没有分组」是两句话, -// 混起来用户会以为自己一个分组都没建过(而他只是还没等到)。 -const followPaneLoading = "正在加载分组…" - -// followPaneNoTags 是「一个分组都没有」那一句话。 +// # 为什么是它替掉左边那一栏 // -// ⚠️ **和侧栏那一句(「还没有收藏夹」)是同一个写法**:12 的次要色,一句话, -// 不摆插画——这一列只有 200 宽,插画摆不下,而这句话要说的事情很小。 -const followPaneNoTags = "还没有分组" - -// viewFollowPaneFailed 是「分组没拉出来」那一档:一句结论 + 原始错误 + 重试。 +// 原来左边 200 宽 × 全高的一栏只干「选组」一件事(票 #367),而分组是用户自己 +// 建的,想看有哪些分组是一个主动动作——所以它降为页头里的一枚下拉,内容区 +// 一行顶到窗口右缘。网易云那张参考图里「分组」也收在页头,不是一栏。 // -// # 三样东西各是各的 +// # 收起那一格写的是**当前那一格**的名字 // -// - **结论**("分组没拉出来"):12 的正文色——这一档说的是「出了什么事」。 -// - **原始错误**:12 的次要色,最多两行——给用户复现时用(和 -// `viewLoadError` 那一档同一个做法)。 -// - **重试**:一枚扁平按钮,**左对齐**——这一列里所有东西都是左对齐的, -// 而框架的按钮默认把内容居中,居中的那一枚和上面那两句话不在一条竖线上。 -func (s *Shell) viewFollowPaneFailed(c *ui.Context, f *followPage) { - t := c.Theme() - ui.Text(c, "分组没拉出来").FontSize(scale.Font(c, scale.Size12)). - TextColor(t.Text).PaddingX(space.Px8) - ui.Text(c, f.tagErr.Error()).FontSize(scale.Font(c, scale.Size12)). - TextColor(t.TextMuted).PaddingX(space.Px8).MaxLines(2).Ellipsis("…") - if flatButton(c, "重试拉分组").FontSize(scale.Font(c, scale.Size12)). - // ⚠️ **左右内边距是 8**:这一列里所有的字都起在同一条竖线上(这一列的 - // 内边距 8 + 行自己的内边距 8 = 16)。扁平按钮自带的 14 会把字推到 - // 22,那一列就歪了。 - Padding(space.Px4, space.Px8).Justify(ui.Start).Clicked() { - f.tagErr, f.tagLoading = nil, true - go s.fetchFollowTags(context.Background(), f) +// 停在「全部关注」写「全部关注」,停在某一组写那一组的名字(上游 +// `index.tsx:206` 的 `

` 同一个口径),而**不是**这一页的名字——这一页的 +// 名字是左边那个 24 号的「我的关注」。两件事在页头上是两个字。 +// +// # 弹层里的三态和原来左栏是同一套 +// +// 「分组还在拉」/「分组没拉出来(可重试)」/「还没有分组」——那三句话在左栏 +// 时是三行字,收成下拉之后是弹层里的三条。一句都不能少:B 站返回空数组和 +// 「我们没拉到」在界面上是两件事。 +// +// # 分组项的画法照 `enumSelect`(settings_enum.go) +// +// 高亮那一档是 `flatSolid`(实心主色底),当前值那一档是淡底——同一个出口, +// 和设置页的下拉一个样。唯一不同的是「全部关注(N)」和第一个分组之间加一道 +// 分界(票 #367 那条线的同一个理由:一个是全集,一个是用户自己建的组)。 +func (s *Shell) viewFollowGroupSelect(c *ui.Context, f *followPage, t *ui.Theme) { + // 收起那一格写的字:当前那一格的名字,和上游 index.tsx:206 的 `

` + // 同一个口径(`followPaneTitle` 复用)。 + // + // ⚠️ **下拉的值绑 `f.tab` 本体**:`SelectBase` 选中那一项时是把值写进绑定的 + // 字段,绑到一个暂存格的话暂存格要在这一帧开头回填成 `f.tab`——而 `choose()` + // 在输入处理里写暂存格、回填在视图构建里跑,顺序反过来就把用户那一选抹掉了。 + // 绑本体则 `Changed()` 只在真的换了的时候才响,换组要做的几件事(放掉搜索词、 + // 换代、清行)都由 `applyFollowTab` 收着。 + sel := ui.SelectBase(c, &f.tab) + trigger := sel.Trigger.Height(followGroupSelectH).MinWidth(followGroupSelectMinW). + Radius(t.Radius).Background(t.Surface).Border(1, t.Border). + Padding(space.None, space.Px10).AlignItems(ui.Center).Gap(space.Px6) + trigger.Label("选择关注分组") + trigger.Children(func() { + ui.Text(c, followPaneTitle(f)).FontSize(scale.Font(c, scale.Size14)). + SingleLine().Grow(1) + ui.Icon(c, chevronIcon()).TextColor(t.TextMuted) + }) + + sel.Popup(func(panel ui.Element) { + popupPanel(t, panel.Grow(1).MaxWidth(280).Padding(space.Px4)).Children(func() { + // ⚠️ **三态先判**:分组还在拉 / 拉失败了 / 还没有分组——和原来左栏 + // 那三句话是同一套,收成下拉之后一样都不能少。 + switch { + case !f.tagStarted && f.tagErr != nil: + ui.Text(c, "分组没拉出来").FontSize(scale.Font(c, scale.Size12)). + TextColor(t.Text).PaddingX(space.Px8) + ui.Text(c, f.tagErr.Error()).FontSize(scale.Font(c, scale.Size12)). + TextColor(t.TextMuted).PaddingX(space.Px8).MaxLines(2).Ellipsis("…") + if flatButton(c, "重试拉分组").FontSize(scale.Font(c, scale.Size12)). + Padding(space.Px4, space.Px8).Justify(ui.Start).Clicked() { + f.tagErr, f.tagLoading = nil, true + go s.fetchFollowTags(context.Background(), f) + } + return + case !f.tagStarted: + ui.Text(c, "正在加载分组…").FontSize(scale.Font(c, scale.Size12)). + TextColor(t.TextMuted).PaddingX(space.Px8) + return + } + // 「全部关注(N)」是第一项;它和分组之间有一道分界(票 #367 那条线 + // 的同一个理由:一个是全集,一个是用户自己建的组)。 + s.viewFollowGroupItem(c, f, sel, followTabAll, + fmt.Sprintf("全部关注%s", countSuffix(f.total)), t) + if len(f.tags) > 0 { + ui.Divider(c).Background(t.Border).MarginY(space.Px4) + } + for _, tg := range f.tags { + name := tg.Name + countSuffix(tg.Count) + if name == "" { + name = "这一组" + } + s.viewFollowGroupItem(c, f, sel, followTagTab(tg.ID), name, t) + } + if len(f.tags) == 0 { + // 🔴 **「一个分组都没有」要有一句话**:什么都不摆的话弹层里只有 + // 一项「全部关注」,用户以为整个下拉没拉出来(而它确实拉出来了, + // 只是 B 站那会儿返回空数组)。 + ui.Text(c, "还没有分组").FontSize(scale.Font(c, scale.Size12)). + TextColor(t.TextMuted).PaddingX(space.Px8).PaddingY(space.Px4) + } + }) + }) + + // ⚠️ **`Changed()` 必须放在 `sel.Popup` 之后**(`input.go` 的注释:「先配好 + // 控件再问响应」):`Changed()` 内部会 `applyInputs()`——那一趟里跑的是弹层里 + // 那些 `Item` 的 `choose()`,而 Item 的输入回调是 `Popup` 这一段里注册上去的。 + // 放在 `Popup` 之前查的话,`applyInputs` 跑的时候这一帧的 Item 还没建出来, + // `choose()` 落到下一帧(`f.tab` 那时已被写进新值),`Changed()` 自己报 false + // ——`applyFollowTab` 永远不进,分组看着切了、底下那格还是上一组的人。 + if trigger.Changed() { + s.applyFollowTab(f) } } -// followPaneTitle 是左边顶上那一行的名字。 +// viewFollowGroupItem 是弹层里的一项。 +// +// 高亮(指针 / 键盘游标在这一项)走 `flatSolid`(实心主色底),当前值那一档 +// 是淡底——和 `enumSelect` 里那两项同一个出口(settings_enum.go:64-76)。 +func (s *Shell) viewFollowGroupItem(c *ui.Context, f *followPage, sel ui.SelectParts[string], + tab, label string, t *ui.Theme) { + opt := sel.Item(tab).Padding(space.Px6, space.Px10).Radius(t.Radius) + switch { + case opt.Highlighted(): + opt = flatSolid(c, opt) + case tab == f.tab: + opt = opt.Background(t.Surface) + } + opt.Children(func() { + ui.Text(c, label).FontSize(scale.Font(c, scale.Size14)).SingleLine().Grow(1) + }) +} + +// followPaneTitle 是下拉收起那一格写的名字:当前停在哪一格。 // // ⚠️ **每一档都说得出是哪一档**:分组没拉到 / 拉失败了 / 停在「全部关注」/ // 停在某一组。Biu 上游那一支(`index.tsx:206`)在这几种情况下都塌成同一个 // 「我的关注」,而左上角突然换名字是最难自己发现的那一类毛病。 +// +// ⚠️ **它和页头那个 24 号的「我的关注」是两回事**:页头是这一页的名字(不换), +// 这一格是「现在看的是哪一批」(跟着分组换)。两件事在页头上是两个字。 func followPaneTitle(f *followPage) string { if !f.tagStarted { return "我的关注" @@ -634,109 +652,6 @@ func followPaneTitle(f *followPage) string { return "这一组" } -// viewFollowPaneRow 画左边的一行。 -// -// 行身份是**这个分组是谁**(`followTabAll` / `followTagTab(id)`),不是序号 -// (铁律四)。B 站的分组 id 可正可负,撞不上 `all` 那个串。 -// -// # 一行长什么样(票 #367) -// -// 这一行是**侧栏菜单项的弟弟**:同样的淡底选中态、同样的字、同样的内边距。 -// 三样都取侧栏那一份: -// -// - 选中 → `flatSolidCurrent`(实心主色底); -// - 没选中 → 次要色; -// - 悬停 / 按下 → `flat.Feel`(压一层半透明的前景色,和侧栏菜单项同一档)。 -func (s *Shell) viewFollowPaneRow(c *ui.Context, f *followPage, i int) { - t := c.Theme() - - tab, label := followTabAll, fmt.Sprintf("全部关注%s", countSuffix(f.total)) - if i > 0 && i-1 < len(f.tags) { - tg := f.tags[i-1] - // 分组 id 可正可负,身份带前缀(见 followTagTab)。 - tab, label = followTagTab(tg.ID), tg.Name+countSuffix(tg.Count) - if label == "" { - label = "这一组" - } - } - - // 🔴 **「全部关注(N)」和后面的分组之间要有一道分界**(票 #367)。 - // - // 它们不是一伙的:前一个是「我关注的人」这个全集,后一个是「B 站上 - // 我自己建的分组」。摆成同一个样子的时候用户分不清「这一格是全部」还是 - // 「这一格也是一个分组」。 - // - // ⚠️ **分界画在第一个分组那一行的上面**,不画在「全部关注」那一行的下面: - // 画在下面的话,「全部关注」被选中时那道线就落在它的淡底上(淡底那一格 - // 的底色会盖住它),而「全部关注」恰恰是**最常被选中**的那一格。 - if i == 1 { - // ⚠️ **这一道线用 `t.Border` 的原色,不打折**:这一列左右两片是**同色** - // 的(都是方案的 `sidebar`),而这一列右缘那道线是隔着两种颜色的 - // (导航区 / 内容区)。同色之间要分界,只能靠线本身——打折的话 - // 暖纸那几套(线的底色是文字色叠 12%)几乎看不见,而经典那几套 - // (`#dbdbdb`)看得见,同一个控件在两套主题里差这么远不行。 - ui.Divider(c).Background(t.Border).MarginY(space.Px4) - } - - // ⚠️ **内边距是侧栏收藏夹那一行的那一份**(上下 6、左右 8):这一列和 - // 侧栏是邻居,行高和缩进得是一套。 - // - // ⚠️ **左右那 8 和侧栏收藏夹那一行不一样了**(票 #371):那一行的左内边距 - // 现在是 20(它的字在 36,分组标题左边有一枚箭头图标),而这一列的字在 - // 16——这一列自己就贴着内容区的左边沿。 - // - // ⚠️ **这一行自己不铺底色**:没选中的那些行是**透**的——底是这一列的底。 - // (框架那一层行悬停底在这一列里**不存在**:它只在「列表有游标」时才铺, - // 而这一列的列表没有游标,见下面 `flat.Feel` 那一段。) - box := ui.Row(c).Grow(1).Key("follow-tab:"+tab).Padding(space.Px6, space.Px8).Radius(t.Radius). - AlignItems(ui.Center) - if tab == f.tab { - // 当前正在看的那一格:和侧栏菜单当前项**同一套**画法(**实心主色底**, - // 见 `flatSolidCurrent`)。 - // - // 🔴 **这一列和侧栏是邻居**:它们挨着摆,而「当前在哪」在两列里说的是 - // 同一件事——两种画法并排的话,用户看到的是「这个应用没做完」(那正是 - // 审计 F-02 / F-06 当初删实心底的理由,而这一档反过来是用户点名要的)。 - // - // ⚠️ **字色是 `t.AccentText`**(主色底上读得出来的那一档),不是写死的 - // 白字——理由见 `flatSolidCurrent`。 - box = flatSolidCurrent(c, box) - } else { - // 没选中的那些是**次要色**:这一列是这一页里的第二层导航,选中那一格 - // 才是要看的那一格。侧栏的菜单项是正文色(那是第一层导航),这一列 - // 不是——两列的字色差一档,层次才分得出来。 - box = box.TextColor(t.TextMuted) - } - // 悬停 / 按下的反馈:和侧栏菜单项同一档(`flat.Feel`——压一层半透明的前景色)。 - // - // ⚠️ **这一层是这一列唯一的悬停反馈**:框架给「指针压着的那一行」铺的那层 - // `t.SurfaceHover`(`mygo/ui/list.go` 的 styleFn)**只在「列表有游标」时 - // 才铺**(`s.cursor() != nil`),而这一列的列表没有游标(`f.paneState` 的 - // `Selected` / `Selection` 从头到尾没设过)——所以这里没有第二层要盖。 - // - // ⚠️ **悬停那一层是「压」不是「换」**:换成 `t.SurfaceHover` 的话,指针一落到 - // 选中那一格上,实心底就被整块顶掉——「当前在哪」是这一列里唯一的方向标, - // 被一次悬停盖掉比多画一层底更坏(`widgets/flat` 的文件头写着同一件事)。 - // - // 🔴 **选中那一格要「按那一层底算」的那一支**:按主题算的话,浅色主题的 - // 正文色是黑的,8% 的黑压在主色底上几乎看不出变化(悬停反馈等于没有)。 - if tab == f.tab { - box = flatSolidCurrentFeel(c, box) - } else { - box = flat.Feel(c, box) - } - // ⚠️ **点了就换查询,而且换查询必须重置行**(见 resetUsers 的注释)。 - if box.Clicked() { - s.selectFollowTab(f, tab) - } - box.Children(func() { - // ⚠️ **单行 + 省略号**:分组名是用户自己起的(「常听的那些不太熟但是每首 - // 都想循环到会唱完整首的 UP 主」),不截断的话这一行会长高一截、把下面 - // 那些分组整体顶下去。侧栏收藏夹那一行是同一个做法。 - ui.Text(c, label).Grow(1).FontSize(scale.Font(c, scale.Size14)).MaxLines(1).Ellipsis("…") - }) -} - // countSuffix 是 Tab 名字后面那个括号。**0 就不画**(上游 `index.tsx:224` 是 // `allCount ? … : ""`)——一个空的「()」在界面上像加载坏了。 func countSuffix(n int) string { @@ -746,19 +661,17 @@ func countSuffix(n int) string { return fmt.Sprintf("(%d)", n) } -// selectFollowTab 切到某一格。 +// applyFollowTab 是换组要做的那几件事,在 `f.tab` 已经写进新值之后调。 // -// ⚠️ **切到已经停着的那一格什么都不做**:清一次行的话,用户点回刚才看的那一格 -// 就要重新拉一遍、滚动位置也回顶——而他没换任何条件。 -func (s *Shell) selectFollowTab(f *followPage, tab string) { - if f.tab == tab { - return - } - f.tab = tab +// 入口只有一条:下拉选中(`SelectBase` 自己把新值写进 `f.tab`,然后 +// `Changed()` 响)。⚠️ **所以这里不能再判一次「新值等不等于旧值」**——到这里 +// 时 `f.tab` 已经是新值,再比就永远相等,切换整个不生效。而「停在已经停着的 +// 那一项不响」这件事 `SelectBase` 的 `Changed()` 自己管(值没变它不报告)。 +func (s *Shell) applyFollowTab(f *followPage) { // ⚠️ **换分组顺手把搜索词放掉**:搜索框只在「全部关注」那一格画 //(上游 `index.tsx:280`),而那个词是按「全部关注」那条接口搜出来的。 // 留着它的话切回「全部关注」时用户会看到上次搜的那一批,而他记得自己没搜过。 - if tab != followTabAll { + if f.tab != followTabAll { f.applied, f.draft = "", "" } f.gen++ @@ -895,7 +808,7 @@ func (s *Shell) viewFollowGrid(c *ui.Context, f *followPage) { // ⚠️ Key 挂在外面的 Column 上,理由见 `dynamicfeed.go`。 ui.Column(c).Key("follow-grid").Grow(1).FillHeight().FillWidth().Children(func() { // 登记「内容区里当前那一个能被方向键走位的」(票 #245 的 E4)。 - // 网格在分组栏**之后**登记,所以它赢——它是这一页的主内容。 + // 网格是这一页里唯一的走位对象(分组栏已收成页头下拉,见票 #367 改版)。 // // ⚠️ **登记的是 `&f.keys` 而不是裸的句柄**:这一份的 `TakeFocus` 还要 // 把游标摆到第一行(走进来就在第一格上)。 @@ -1392,7 +1305,7 @@ func (s *Shell) fetchFollowTags(ctx context.Context, f *followPage) { // 🔴 **gen 也在这一份里**——它是「换分组 / 换搜索词时 +1」的那个代次,而下面 // 拿它填进 `followResult` 就是**读**它。第一版这里是 `gen: int(f.gen)`,直接 // 在 goroutine 里读界面线程正在写的那个字段:`go test -race` 一抓一个准 -// (Read at follow.go:889 / Previous write at selectFollowTab)。 +// (Read at follow.go:889 / Previous write at applyFollowTab)。 // // 🔴 **loaded 在这儿是有用的,不是多传**:B 站那三个接口给的是**总数**,而 // 「还有没有」要拿**累计加载数**去比总数——总数 25、每页 20,第二页发回来 5 个, diff --git a/internal/ui/follow_grid_test.go b/internal/ui/follow_grid_test.go index 078ad1d4..b6b1a7b2 100644 --- a/internal/ui/follow_grid_test.go +++ b/internal/ui/follow_grid_test.go @@ -47,12 +47,17 @@ import ( // // 列数是按**上一帧**量出来的容器宽算的(`followPage.width` → // `list.GridColumnsFor`),头一帧按一个还没量准的宽排成 5 列,下一帧量到真的 -// 才排成 3 列(理由同 `follow.go` 里 `keepFollowAnchor` 那段)。不等它稳的话, +// 才排成 4 列(理由同 `follow.go` 里 `keepFollowAnchor` 那段)。不等它稳的话, // 量到的是**上一档列数**摆出来的画面。 +// +// ⚠️ **4 列是票 #367 之后的事**:左边 200 宽的分组栏收成页头那个下拉之后, +// 同一个 1400×900 窗口里网格拿到 1148(≥ 1024 那一档),原来是 948(≥ 768 +// 那一档、3 列)。列数跟着容器宽走,所以这一处断言也跟着换。 func TestTheFollowGridHasSixteenBetweenRowsAndColumns(t *testing.T) { b := newUIExplore(t) uiFollowTags(b, map[int]any{0: "默认分组"}) - // 六个 UP 主:窗口摆得下三列,于是**两行**——行间距量两次,列间距量两次。 + // 六个 UP 主:窗口摆得下四列(左栏收成下拉之后网格宽 1148 ≥ 1024), + // 于是**两行**(4 + 2)——行间距量一次,列间距量三次。 uiFollowList(b, 6, []any{ uiFollowUser(11, "第一个 UP"), uiFollowUser(12, "第二个 UP"), uiFollowUser(13, "第三个 UP"), uiFollowUser(14, "第四个 UP"), @@ -69,8 +74,8 @@ func TestTheFollowGridHasSixteenBetweenRowsAndColumns(t *testing.T) { waitFrames(t, tt, "列数稳了", func() bool { return f.cols == list.GridColumnsFor(f.width) }) tt.Frame() - if f.cols != 3 { - t.Fatalf("网格宽 %v 应该是 3 列,实际是 %d 列", f.width, f.cols) + if f.cols != 4 { + t.Fatalf("网格宽 %v 应该是 4 列,实际是 %d 列", f.width, f.cols) } if len(f.users) != 6 { t.Fatalf("要六个人才量得出两行,实际是 %d 个", len(f.users)) diff --git a/internal/ui/follow_test.go b/internal/ui/follow_test.go index ac30855c..26454444 100644 --- a/internal/ui/follow_test.go +++ b/internal/ui/follow_test.go @@ -159,15 +159,45 @@ func TestTheFollowPageShowsTheFollowedPeople(t *testing.T) { t.Errorf("关注的 %q 没画出来,文字有 %q", want, tt.Texts()) } } - // 左边的分组 Tab:一个都不能少。⚠️ **-10 和 0 是 B 站固定给的**,少了 - // 「特别关注」在网页版明明有。 + // 页头:大号标题 + 关注总数。上游 index.tsx:206 的 `

`(`全部关注` / + // 分组名 / `我的关注`)在左侧栏塌成同一个「我的关注」,而我们的页头是 + // **这一页的名字**——「现在在哪一组」是右上角那个下拉自己的事。 + // + // ⚠️ **「我的关注」侧栏里也有**(侧栏那一项的字)——`showsText` 找到 + // 侧栏那一枚也算过,`Find` 回的也是第一枚(侧栏的)。所以这里数**有几 + // 枚**:侧栏那一枚本来就在,页头那一枚画没画看的是「是不是 ≥2」。 + count := 0 + for _, txt := range tt.Texts() { + if txt == "我的关注" { + count++ + } + } + if count < 2 { + t.Errorf("「我的关注」只有 %d 枚——页头那一枚没画(侧栏那一枚是本来就有的)", count) + } + // ⚠️ **「3」也不能只问「树里有没有」**——树里任何一个单独的字「3」都算 + // 过。它得是**页头上那一枚**:量它的位置——在侧栏右缘(`sideTargetWidth`) + // 之外才算。 + total, ok := tt.Find("3") + if !ok { + t.Fatalf("页头没有关注总数,文字有 %q", tt.Texts()) + } + if total.X < float32(sideTargetWidth(s.app.SettingsSnapshot()))-1 { + t.Errorf("总数「3」画在 x=%.1f——在侧栏那边,不是页头上那一枚", total.X) + } + // ⚠️ **分组都在下拉里**:点开才看得见,而 -10 和 0 是 B 站固定给的, + // 少了「特别关注」在网页版明明有。 // // ⚠️ 名字后面那个括号也在断言里:上游 `index.tsx:238` 是 // `${tag.name}${tag.count ? `(${tag.count})` : ""}`,而 0 的那一档不画括号 // ——差的就是这一处,而空括号在界面上像加载坏了。 + if err := tt.Click("全部关注"); err != nil { + t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts()) + } + tt.Frame() for _, want := range []string{"全部关注(3)", "特别关注(1)", "默认分组(1)", "常听的(1)"} { if !showsText(tt, want) { - t.Errorf("左边没有 %q 那一格,文字有 %q", want, tt.Texts()) + t.Errorf("下拉里没有 %q 那一项,文字有 %q", want, tt.Texts()) } } // 签名和认证也要画出来(它们是这张卡上除头像之外仅有的两行字)。 @@ -177,10 +207,6 @@ func TestTheFollowPageShowsTheFollowedPeople(t *testing.T) { if !showsText(tt, "第一个 UP 的认证") { t.Errorf("认证没画出来,文字有 %q", tt.Texts()) } - // 切到某一格时左上角要跟着换名字(上游 index.tsx:206 那一支)。 - if !showsText(tt, "全部关注") { - t.Errorf("左上角没画「全部关注」,文字有 %q", tt.Texts()) - } } // TestNotLoggedInSaysSoAndSendsNothing 是这一页最要紧的一条。 @@ -264,8 +290,13 @@ func TestEachTagTabShowsThatTagsPeople(t *testing.T) { waitFrames(t, tt, "第一格到了", func() bool { return len(f.users) == 1 && f.tagStarted }) tt.Frame() + // ⚠️ **切组走下拉**:先点开触发器(它写的是当前那一格的名字),再选那一项。 + if err := tt.Click("全部关注"); err != nil { + t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts()) + } + tt.Frame() if err := tt.Click("常听的(1)"); err != nil { - t.Fatalf("点不到「常听的」那一格:%v,文字有 %q", err, tt.Texts()) + t.Fatalf("下拉里点不到「常听的」:%v,文字有 %q", err, tt.Texts()) } tt.Frame() // ⚠️ 等的是**那一个人**,不是「有一个人」:切组之前画面上就已经有一个人了, @@ -281,10 +312,10 @@ func TestEachTagTabShowsThatTagsPeople(t *testing.T) { if showsText(tt, "全部关注里的那个人") { t.Errorf("换组之后上一组的人还在画面上,文字有 %q", tt.Texts()) } - // ⚠️ **左上角跟着换**:停在某一组的时候它该是那一组的名字(上游 + // ⚠️ **下拉上的字跟着换**:停在某一组的时候触发器该写那一组的名字(上游 // index.tsx:206 那一支),而塌回「全部关注」的话用户以为自己在看全部。 if !showsText(tt, "常听的") { - t.Errorf("左上角没跟着换组,文字有 %q", tt.Texts()) + t.Errorf("分组下拉没跟着换组,文字有 %q", tt.Texts()) } // ⚠️ **搜索框只在「全部关注」那一格画**(上游 index.tsx:280):B 站的搜索 // 接口搜的是「我关注的人」的全集,搜不出「某一组里的谁」。 @@ -329,7 +360,12 @@ func TestTheSearchBoxFindsAFollowedPerson(t *testing.T) { t.Errorf("搜索那一支发出去的词是 %q", got) } // ⚠️ **「全部关注(N)」那个 N 不能被搜到的结果顶掉**:搜「周杰伦」搜到 1 个, - // 而那一格下面挂着的 N 说的是我关注了 1 个人——两件事,挂错了就是一句假话。 + // 而下拉里挂着的 N 说的是我关注了 1 个人——两件事,挂错了就是一句假话。 + // 下拉项点开才看得见,所以先开下拉再断。 + if err := tt.Click("全部关注"); err != nil { + t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts()) + } + tt.Frame() if !showsText(tt, "全部关注(1)") { t.Errorf("「全部关注」那个 N 被搜索结果顶掉了,文字有 %q", tt.Texts()) } @@ -873,10 +909,14 @@ func TestAFailedTagListDoesNotRetryItselfForever(t *testing.T) { "「没拉到」,而每次失败都要一帧新画面,于是自己转起来了", after-before, before, after) } - // 失败之后要说得清,并且给一条路走。 + // 失败之后要说得清,并且给一条路走。这两句在下拉**弹层里**,先点开触发器。 // // ⚠️ **这里要用 `tt.HasText`(子串),不能用 `showsText`(全等)**:那一行是 // 「分组没拉出来:<原因>」,原因跟着错走,而全等匹配钉不住一个前缀。 + if err := tt.Click("选择关注分组"); err != nil { + t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts()) + } + tt.Frame() if !tt.HasText("分组没拉出来") { t.Errorf("没说清是分组没拉出来,文字有 %q", tt.Texts()) } @@ -913,11 +953,19 @@ func TestAFailedTagListCanBeRetriedByHand(t *testing.T) { waitFrames(t, tt, "分组拉失败了", func() bool { return f.tagErr != nil }) failed = false // B 站这一次肯给了 + // 「重试拉分组」在下拉**弹层里**:先点开触发器。 + if err := tt.Click("选择关注分组"); err != nil { + t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts()) + } + tt.Frame() if err := tt.Click("重试拉分组"); err != nil { t.Fatalf("点不到重试:%v,文字有 %q", err, tt.Texts()) } waitFrames(t, tt, "重试之后分组到了", func() bool { return f.tagStarted && f.tagErr == nil }) tt.Frame() + // ⚠️ **别再点一下触发器**:「重试拉分组」是弹层里的普通按钮,不是下拉的 + // `Item`——点它不收弹层(弹层还开着),再点触发器就是把它**关上**。 + // 弹层每帧都重建,新拉到的分组自己长出来。 // ⚠️ 同样是子串:那一格的名字后面跟着 `(1)`(B 站的分组成员数)。 if !tt.HasText("重试之后有了") { t.Errorf("重试之后分组没画出来,文字有 %q", tt.Texts()) @@ -973,8 +1021,13 @@ func TestAFullyOverlappingPageStillLoadsTheNextOne(t *testing.T) { tt.Frame() scrollFollowToEnd(t, tt, f) + // 总数挂在下拉的「全部关注(N)」那一项上:点开下拉再断。 + if err := tt.Click("全部关注"); err != nil { + t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts()) + } + tt.Frame() if !showsText(tt, "全部关注(39)") { - t.Errorf("Tab 上的总数对不上,文字有 %q", tt.Texts()) + t.Errorf("下拉里的总数对不上,文字有 %q", tt.Texts()) } // ⚠️ **断的是「末尾」**:滚到底之后画面上是 25..39,所以问的是 UP 39。 // (问 UP 21 量到的是「屏幕能显示几行」,不是「分页有没有停」。) @@ -1008,6 +1061,12 @@ func TestSwitchingAccountsDoesNotKeepTheOldTotal(t *testing.T) { return f.selfMid == 77 && f.total == 7 }) tt.Frame() + // 「全部关注(7)」是下拉项,点开下拉才看得见;而判据是**画面上**那句话, + // 所以先开下拉再断。 + if err := tt.Click("全部关注"); err != nil { + t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts()) + } + tt.Frame() if !showsText(tt, "全部关注(7)") { t.Fatalf("第一个账号的总数没画出来,文字有 %q", tt.Texts()) } @@ -1015,7 +1074,7 @@ func TestSwitchingAccountsDoesNotKeepTheOldTotal(t *testing.T) { // 换分组:那个 N **不许动**——它说的是「这个账号关注了多少人」,跟着账号走而 // 不跟着「停在哪个分组」走(Biu 那边换组它也还在,见 `index.tsx:224`)。 if err := tt.Click("默认分组(1)"); err != nil { - t.Fatalf("点不到分组:%v,文字有 %q", err, tt.Texts()) + t.Fatalf("下拉里点不到分组:%v,文字有 %q", err, tt.Texts()) } tt.Frame() waitFrames(t, tt, "切到那一组", func() bool { return f.tab == followTagTab(0) }) @@ -1040,6 +1099,10 @@ func TestSwitchingAccountsDoesNotKeepTheOldTotal(t *testing.T) { // ⚠️ **判据没放宽,反而更严**:「全部关注(7)」那一栏正是 `f.total` 画出来的, // 而它换账号**之前**就在画面上——所以这个判据不可能提前成立,等到的就是 // 「重画成新账号那个数」。末尾那两行断言照样断内部的 `f.total`。 + // + // ⚠️ **这一帧画面上的 N 是下拉里那一项**:换账号那一刻下拉要是还开着, + // 触发器上的字会跟着换,而「全部关注(7)」那一句也会跟着消失——所以它**不用 + // 真的看得见**,只要不再出现在画面的文字里就算数。 waitFrames(t, tt, "新账号的那个 N 掉下来了", func() bool { return f.selfMid == 88 && !showsText(tt, "全部关注(7)") }) @@ -1047,7 +1110,7 @@ func TestSwitchingAccountsDoesNotKeepTheOldTotal(t *testing.T) { t.Errorf("换账号之后那个 N 仍然是 %d——它是上一个账号的关注数", f.total) } if showsText(tt, "全部关注(7)") { - t.Errorf("新账号的 Tab 上还挂着上一个账号的关注数,文字有 %q", tt.Texts()) + t.Errorf("新账号的下拉上还挂着上一个账号的关注数,文字有 %q", tt.Texts()) } } @@ -1133,9 +1196,11 @@ func TestTheFollowCardTypographyAndItsHoverBar(t *testing.T) { } } -// TestTheFollowPaneHasARightBorder 钉住左栏右缘那条分界线(上游 -// `follow-list/index.tsx:204` 的 `border-r`)。 -func TestTheFollowPaneHasARightBorder(t *testing.T) { +// TestTheFollowPageHasNoLeftPane 钉住「我的关注」不再有左边那一栏。 +// +// 票:分组从「页内一栏」降为「页头里的下拉」——200 宽 × 全高的分栏整个删掉, +// 内容区直接从侧栏右边开始,一行顶到窗口右缘。 +func TestTheFollowPageHasNoLeftPane(t *testing.T) { b := newUIExplore(t) uiFollowTags(b, map[int]any{0: "默认分组"}) uiFollowList(b, 1, []any{uiFollowUser(11, "一个人")}) @@ -1149,16 +1214,25 @@ func TestTheFollowPaneHasARightBorder(t *testing.T) { waitFrames(t, tt, "人到了", func() bool { return len(f.users) == 1 && f.tagStarted }) tt.Frame() - pageLeft := int(sideTargetWidth(s.app.SettingsSnapshot())) - img := tt.Image() - bx := pageLeft + followPaneW - 1 - if got, ref := img.RGBAAt(bx, 400), img.RGBAAt(bx-4, 400); got == ref { - t.Errorf("左栏右缘 (%d,400) 是 %v,和左边四像素一样——那条分界线没画", bx, got) + // 内容区(那张卡)的左缘应该贴着侧栏右缘,而不是再让出 200。 + // + // ⚠️ 量的不是「pane 还在不在」(代码里已经没有 pane 了),而是「卡是不是从 + // 侧栏右边就开始」——这才是「没有左栏」在画面上的意思。卡本身没有文字, + // 拿卡上的名字定位:第一张卡的名字就在卡上,它的左缘 ≈ 卡的左缘。 + av, ok := tt.Find("一个人") + if !ok { + t.Fatalf("卡上的名字没画出来,文字有 %q", tt.Texts()) + } + sideW := float32(sideTargetWidth(s.app.SettingsSnapshot())) + // 内容区从侧栏右缘开始,左边还有页边距(pagePadX)+ 卡里那圈留白。 + // 留出 200+ 才可能是「左栏还在」——这里卡一个比那小得多的数。 + if av.X-sideW > 64 { + t.Errorf("内容区的左缘在侧栏右边 %.0f 之外——左边那一栏还占着位置", av.X-sideW) } } // TestUnfollowingFromTheCardRemovesIt 钉住操作条上那个「取消关注」真的能按: -// 写接口走后台、成功后那一格从画面上消失、左栏的人数跟着减一。 +// 写接口走后台、成功后那一格从画面上消失、下拉的人数跟着减一。 func TestUnfollowingFromTheCardRemovesIt(t *testing.T) { b := newUIExplore(t) uiSpaceData(b, "我自己", 0, 1) @@ -1194,8 +1268,13 @@ func TestUnfollowingFromTheCardRemovesIt(t *testing.T) { if !b.hit("/x/relation/modify") { t.Errorf("取关没有真的发出去(假 B 站没收到 /x/relation/modify)") } + // 取关之后下拉里的「全部关注(N)」跟着减一:点开下拉再断。 + if err := tt.Click("全部关注"); err != nil { + t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts()) + } + tt.Frame() if !showsText(tt, "全部关注(1)") { - t.Errorf("取关之后左栏的人数没跟着减一,文字有 %q", tt.Texts()) + t.Errorf("取关之后下拉里的人数没跟着减一,文字有 %q", tt.Texts()) } } @@ -1252,7 +1331,7 @@ func TestTheFollowGridRowHasNoHoverSlab(t *testing.T) { homeFrame(tt) hovered := tt.Image() - // ② 指针停在左栏(离内容区远远的)。 + // ② 指针停在内容区左边那片空白上(离卡远远的)。 tt.Move(250, 600) homeFrame(tt) away := tt.Image() @@ -1317,7 +1396,7 @@ func TestTheFollowSentinelRowHasNoHoverSlab(t *testing.T) { homeFrame(tt) hovered := tt.Image() - // ② 指针停在左栏。 + // ② 指针停在内容区左边那片空白上。 tt.Move(250, 600) homeFrame(tt) away := tt.Image() diff --git a/internal/ui/realdata_test.go b/internal/ui/realdata_test.go index 2fb279e0..8aa0ebb5 100644 --- a/internal/ui/realdata_test.go +++ b/internal/ui/realdata_test.go @@ -356,6 +356,12 @@ var realDataLedger = []realDataPage{ s.navigate(followPath) realDataWait(t, tt, "关注的 UP 主画出来了", func() bool { return tt.HasText("关注页那位 UP 主") }) + // ⚠️ **「全部关注(N)」和分组名都在页头下拉里**(#367 改版):点开 + // 触发器它们才在画面上——`Want` 里的断言量的是「点开之后的那一屏」。 + if err := tt.Click("选择关注分组"); err != nil { + t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts()) + } + tt.Frame() return s, tt }, // ⚠️ 两条都要:一条是**接口给的真内容**,一条是「全部关注(N)」那个 N—— diff --git a/internal/ui/settings_enum.go b/internal/ui/settings_enum.go index 7738c890..677a501f 100644 --- a/internal/ui/settings_enum.go +++ b/internal/ui/settings_enum.go @@ -83,12 +83,14 @@ func enumSelect[T comparable](c *ui.Context, value *T, items []enumItem[T], labe // // MyGo 自己的 `ui.Select` 里那一个没导出(`ui/widgets.go` 的 `chevron`),这里 // 自己画一个同样的,免得为了一个 10x10 的箭头去改框架。 -func chevronIcon() *ui.SVG { - const shape = `` - return ui.MustParseSVG([]byte(`` + shape + ``)) -} +func chevronIcon() *ui.SVG { return chevronSVG } + +// chevronSVG 是解析好的那一份(`MustParseSVG` 的文档原话就是「解析一次,别在 +// view 里解析」):解析一次实测 70 次分配 / 5.4 KB,每帧都解析的话一个下拉就 +// 顶爆关注页那一档的帧预算(票 #367 实测 +56 次 / +5.9 KB)。 +var chevronSVG = ui.MustParseSVG([]byte(``)) // settingsSelectH 是设置页下拉收起时的高度(设计规范 §7.9:控件 32 高)。 const settingsSelectH = 32 diff --git a/internal/ui/ticket213_shot_test.go b/internal/ui/ticket213_shot_test.go index ddc6bc15..ee8d459e 100644 --- a/internal/ui/ticket213_shot_test.go +++ b/internal/ui/ticket213_shot_test.go @@ -1,15 +1,15 @@ -// 票 #367 的画面验收:「我的关注」那一页左边那列分组按当前风格重做。 +// 票 #367 改版的画面验收:「我的关注」的分组收进页头右上角的下拉。 // // BIU_SHOT_DIR=/some/dir go test ./internal/ui/ -run TestRenderFollowPaneShots // // # 深浅两套都要出 // -// 这一列的选中项是**淡主色底 + 按那层底现算的字**——浅色下那层底和页面底色差得 -// 很远,深色下只差十几个通道。**只看浅色的话深色下那一层根本看不出来**。 +// 下拉里高亮那一档是**实心主色底 + 按那层底现算的字**——浅色下那层底和页面底色 +// 差得很远,深色下只差十几个通道。**只看浅色的话深色下那一层根本看不出来**。 // // # 主题方案也要各看一眼 // -// 方案压过底色 / 主色 / 圆角三项,而这一列的底色、淡底、圆角全是从主题现算的 +// 方案压过底色 / 主色 / 圆角三项,而下拉的底色、高亮、圆角全是从主题现算的 // ——**经典那一套过了不等于方案那一套也过了**(暖纸的底是暖的、暮光是琥珀)。 // // ⚠️ **一个字都不许打真 B 站接口**:全是内存夹具 + 无头渲染。 @@ -28,14 +28,14 @@ import ( // // ⚠️ **名字里带一个很长的**:分组名过长时怎么办是这一票要定的事之一,出图里 // 得看得见它是怎么处理的。 +// +// ⚠️ **用 `uiFollowTagsOrdered`,不用 `uiFollowTags`**:出图是要给人看的,而 +// map 的遍历顺序是随机的——同一组数据深浅两张跑出来分组次序不一样的话, +// 对图的人得先猜「这俩是不是同一组数据」。 func followPaneShotFixture(t *testing.T) *uiExplore { t.Helper() b := newUIExplore(t) - uiFollowTags(b, map[int]any{ - -10: "特别关注", - 0: "默认分组", - 777: "常听的那些不太熟但是每首都想循环到会唱完整首的 UP 主", - }) + uiFollowTagsOrdered(b, followPaneTags) uiFollowList(b, 3, []any{ uiFollowUser(11, "第一个 UP"), uiFollowUser(12, "第二个 UP"), uiFollowUser(13, "第三个 UP"), @@ -60,14 +60,23 @@ func followPaneShotShell(t *testing.T, dark bool, preset app.ThemePreset) (*Shel waitFrames(t, tt, "关注的人和分组到了", func() bool { return len(f.users) == 3 && f.tagStarted }) - // 指针停在内容区:停在分组那一列上会叠一层悬停底,而那一格的颜色就不是 + // 指针停在内容区:停在下拉触发器上会叠一层悬停底,而那一格的颜色就不是 // 「平时的样子」了。 tt.Move(900, 500) homeFrame(tt) return s, tt } -// TestRenderFollowPaneShots 把这一页左边那列存成 PNG。 +// openFollowGroupSelectForShot 点开分组下拉再回一帧(弹层摆开的那一屏)。 +func openFollowGroupSelectForShot(t *testing.T, tt *mygoui.Tester) { + t.Helper() + if err := tt.Click("选择关注分组"); err != nil { + t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts()) + } + tt.Frame() +} + +// TestRenderFollowPaneShots 把这一页和下拉摆开的样子存成 PNG。 func TestRenderFollowPaneShots(t *testing.T) { dir := os.Getenv("BIU_SHOT_DIR") if dir == "" { @@ -79,36 +88,40 @@ func TestRenderFollowPaneShots(t *testing.T) { name = "深色" } t.Run(name, func(t *testing.T) { - // 01 经典(Biu 绿):这一列的「底子」。 + // 01 经典(Biu 绿):整页 + 收起那一格。 { _, tt := followPaneShotShell(t, dark, app.ThemePresetClassic) - save(t, dir, "01-分组栏-经典-"+name+".png", tt) + save(t, dir, "01-页面-经典-"+name+".png", tt) } - // 02 暖纸(默认方案):底色、淡底、圆角都是从方案来的。 + // 02 暖纸(默认方案):下拉摆开的那一屏——底色、高亮、圆角都从方案来。 { _, tt := followPaneShotShell(t, dark, app.ThemePresetWarmPaper) - save(t, dir, "02-分组栏-暖纸-"+name+".png", tt) + openFollowGroupSelectForShot(t, tt) + save(t, dir, "02-下拉摆开-暖纸-"+name+".png", tt) } // 03 暮光·琥珀:另一套色相(暖纸和暮光的底色差得最远)。 { _, tt := followPaneShotShell(t, dark, app.ThemePresetDuskAmber) - save(t, dir, "03-分组栏-暮光-"+name+".png", tt) + openFollowGroupSelectForShot(t, tt) + save(t, dir, "03-下拉摆开-暮光-"+name+".png", tt) } - // 07 悬停:指针停在**没选中**的那一格上。 + // 07 悬停:指针停在**没选中**的那一项上。 // - // ⚠️ **悬停反馈要和侧栏菜单项是同一档**(压一层半透明的前景色), + // ⚠️ **高亮那一档是实心主色底**(`flatSolid`,和设置页下拉同一个出口), // 所以这一张要看得见那一层。 { _, tt := followPaneShotShell(t, dark, app.ThemePresetWarmPaper) + openFollowGroupSelectForShot(t, tt) r, ok := tt.Find("特别关注(1)") if !ok { - t.Fatalf("分组那一列上没有「特别关注(1)」那一格,文字有 %q", tt.Texts()) + t.Fatalf("分组下拉里没有「特别关注(1)」那一项,文字有 %q", tt.Texts()) } tt.Move(r.X+r.W/2, r.Y+r.H/2) tt.Frame() save(t, dir, "07-悬停-"+name+".png", tt) } - // 04 三态:加载中 / 拉失败了 / 一个分组都没有。 + // 04 三态:加载中 / 拉失败了 / 一个分组都没有——**都在弹层里**,所以 + // 每一张都是「点开下拉的那一屏」。 // // ⚠️ **加载中那一档要停在一半**:分组那一拉是后台发的,而假 B 站 // 回得太快——存图的时候它已经回来了,画面上就成了「加载好了」那一档。 @@ -131,6 +144,7 @@ func TestRenderFollowPaneShots(t *testing.T) { s.navigate(followPath) tt.Frame() homeFrame(tt) + openFollowGroupSelectForShot(t, tt) save(t, dir, "04-加载中-"+name+".png", tt) } { @@ -150,6 +164,7 @@ func TestRenderFollowPaneShots(t *testing.T) { f := s.followPageOf() waitFrames(t, tt, "分组拉失败了", func() bool { return f.tagErr != nil }) homeFrame(tt) + openFollowGroupSelectForShot(t, tt) save(t, dir, "05-拉失败-"+name+".png", tt) } { @@ -167,6 +182,7 @@ func TestRenderFollowPaneShots(t *testing.T) { f := s.followPageOf() waitFrames(t, tt, "分组到了(空的)", func() bool { return f.tagStarted }) homeFrame(tt) + openFollowGroupSelectForShot(t, tt) save(t, dir, "06-没有分组-"+name+".png", tt) } }) diff --git a/internal/ui/ticket213_test.go b/internal/ui/ticket213_test.go index 4193f1ad..5e6bff1c 100644 --- a/internal/ui/ticket213_test.go +++ b/internal/ui/ticket213_test.go @@ -1,17 +1,17 @@ -// 票 #367:「我的关注」那一页左边那列分组,按当前的设计风格重做。 +// 票 #367 改版:「我的关注」左边那列分组收成页头右上角的一枚下拉。 // -// 这一票只动**左边那一列**(`follow.go` 的 `viewFollowPane` / `viewFollowPaneRow`): -// 底色、选中态、字号、内边距、三态。**布局骨架(左列 + 右网格)和右边那个网格 -// 一个像素没动**。 +// 这一票把那一列从「200 宽 × 全高的常驻栏」改成「页头上一枚『全部关注 ▾』下拉」: +// 选组是主动动作(分组是用户自己建的,想看有哪些分组是自己点开的),常驻一栏只为 +// 这一件事占地——内容区现在一行顶到窗口右缘。 // -// # 判据是**画面上的像素**,不是函数调用 +// # 判据是**画面上的像素**和**弹层里摆出来的字** // -// 「像一家人」只能看画面:底色是不是同一片、选中那一格是不是同一层淡底、字 -// 是不是同一档。所以这一组量的是像素(`bandPixels` / `modeOf` / `farthestFrom`), -// 而断「有没有写死颜色」的那一条量的是**源码**(照 `shadow_test.go` 那一族的路子)。 +// 原来钉在左栏上的那几条(「全部关注」和分组分得开、长名字截断不换行、三态都在), +// 收成下拉之后一样都不能少——只是它们现在活在一个**弹层**里:断言前先点开触发器 +// (`tt.Click("选择关注分组")`),不然弹层里的字根本不在画面上。 // -// ⚠️ **深浅两套都要跑**:淡底那一层在浅色下和底色差得很远、在深色下只差十几个 -// 通道——只看浅色的话深色下那一层根本验不出来。 +// ⚠️ **深浅两套都要跑**:高亮那一档(实心主色底)在浅色下和底色差得很远、在深色下 +// 只差十几个通道——只看浅色的话深色下那一层根本验不出来。 package ui import ( @@ -25,7 +25,6 @@ import ( "testing" "github.com/ShellMonster/PudMusic/internal/app" - "github.com/ShellMonster/PudMusic/internal/ui/widgets/ink" mygoui "github.com/egoist/mygo/ui" ) @@ -49,7 +48,7 @@ var followPaneTags = []followTagSpec{ // uiFollowTagsOrdered 给分组那一列装一份**顺序固定**的返回。 // // ⚠️ **`uiFollowTags`(`follow_test.go`)用的是 map**,而 map 的遍历顺序是随机的 -// ——每一轮跑出来的分组次序都不一样。而这一票要断言「第一格和后面那几格分得 +// ——每一轮跑出来的分组次序都不一样。而这一票要断言「第一项和后面那几项分得 // 开」,那要一个**确定的顺序**。 func uiFollowTagsOrdered(b *uiExplore, tags []followTagSpec) { b.on("/x/relation/tags", func(w http.ResponseWriter, r *http.Request) { @@ -65,18 +64,9 @@ func uiFollowTagsOrdered(b *uiExplore, tags []followTagSpec) { // followPaneSettings 是这一组测试用的那份设置。 // -// ⚠️ **方案要显式写暖纸,不能靠 `app.DefaultSettings("")` 的默认**:这一组要量的是 -// **暖纸**这一套,而**经典那一套的 `sidebar` 就是页面底色**(两个都是白)——于是 -// 「这一列的底 == 方案的 `sidebar`」这条判据在经典底下**恒真**:把这一列的底整个 -// 删掉,测试照样绿(2026-10-25 的评审抓出来的)。 -// -// 暖纸那一套的 `sidebar`(#EFE9DE)和页面底色(#F8F4ED)**不是一个色**,判据 -// 才立得住——`TestTheFollowPaneAndTheSidebarAreOneFamily` 里还有一条专门盯 -// 「这一套方案里这两个色不是同一个」。 -// -// (票 #406 之后这一包的 `TestMain` 不再把方案钉成经典,所以显式写这一行是 -// **钉住这一组要量的那一套**,而不是「躲开默认」——两者的区别是:将来默认方案 -// 再变,这一组仍然量暖纸。) +// ⚠️ **方案要显式写暖纸**:这一组量的是**暖纸**这一套,而经典那一套的 `sidebar` +// 就是页面底色(两个都是白)——有些判据在它下面恒真(2026-10-25 的评审抓出来 +// 的那一处同形)。 func followPaneSettings() app.Settings { st := app.DefaultSettings("") st.ThemePreset = app.ThemePresetWarmPaper @@ -105,98 +95,31 @@ func followPaneShell(t *testing.T, dark bool) (*Shell, *mygoui.Tester) { return s, tt } -// TestTheFollowPaneAndTheSidebarAreOneFamily 钉住「这一列和侧栏是同一片」。 +// openFollowGroupSelect 点开页头右上角那枚分组下拉。 // -// 三条判据: +// ⚠️ **靠 Label 找**:触发器写的是当前那一格的名字(「全部关注」/ 某一组 / +// 「我的关注」),那一格换组就变,而 Label 不变(`openSelect` 同一个理由)。 // -// 1. **底色是同一片**——侧栏那一栏和这一列都是方案的 `sidebar`(和内容区不同色)。 -// 改之前这一列是页面底色,于是它和内容区是一体的,而旁边的侧栏是另一片: -// 两片导航区夹着一片内容区,中间那道界是内容区的边。 -// 2. **这一套方案里 `sidebar` 和页面底色不是同一个色**——不然第一条**恒真** -// (经典那一套的 `sidebar` 就是页面底色,见 `followPaneSettings` 的注释)。 -// 3. **选中那一格是同一层淡底**——两列都用 `flatCurrent`(`accentSoft` 淡底 + -// 按那层底现算的字),所以两个色值逐字节相同。 -func TestTheFollowPaneAndTheSidebarAreOneFamily(t *testing.T) { - for _, dark := range []bool{false, true} { - name := "浅色" - if dark { - name = "深色" - } - t.Run(name, func(t *testing.T) { - s, tt := followPaneShell(t, dark) - theme := BuildTheme(followPaneSettings(), dark) - img := tt.Image() - - // 判据二:**这一套方案里,方案的 `sidebar` 和页面底色不是同一个色**。 - // - // ⚠️ **这一条是前一条的前提**:两个色一样的话,「这一列的底 == 侧栏的底 - // == 方案的 `sidebar`」恒成立——把这一列的底整个删掉,测试也绿 - //(2026-10-25 的评审抓出来的)。 - sidebar := s.sidebarBackground(theme) - if nearRGBA(rgbaOf(sidebar), rgbaOf(theme.Background), 3) { - t.Fatalf("这一套方案里 sidebar(%v)和页面底色(%v)是同一个色——"+ - "「这一列的底是方案的 sidebar」这条判据在它下面恒真,换一套方案", - sidebar, theme.Background) - } - - // 判据一:**底色同一片**。 - // - // ⚠️ **取样点都在两栏的空处**(侧栏 x=190、这一列 x=390,都在底下那 - // 一段空白里):取在菜单项上会量到那一项的底,取在收藏夹上会量到别 - // 的东西。 - want := rgbaOf(sidebar) - sidePx := pixelAt(img, 190, 650) - panePx := pixelAt(img, 390, 650) - if !nearRGBA(sidePx, want, 3) { - t.Errorf("侧栏的底色是 %v,want %v", sidePx, want) - } - if !nearRGBA(panePx, want, 3) { - t.Errorf("分组那一列的底色是 %v,want %v——它要和侧栏是同一片"+ - "(方案里的 `sidebar`)", panePx, want) - } - // 顺带钉住「内容区是另一片」:内容区的底是**页面底色**,不是 `sidebar`。 - bodyPx := pixelAt(img, 500, 650) - if !nearRGBA(bodyPx, rgbaOf(theme.Background), 3) { - t.Errorf("内容区的底色是 %v,want %v(页面底色)", bodyPx, theme.Background) - } - - // 判据三:**选中那一格是同一层淡底**。 - sideCur, ok := tt.Find("我的关注") - if !ok { - t.Fatalf("侧栏上没有「我的关注」那一项,文字有 %q", tt.Texts()) - } - paneCur, ok := tt.Find("全部关注(3)") - if !ok { - t.Fatalf("分组那一列上没有「全部关注(3)」那一格,文字有 %q", tt.Texts()) - } - sideBg := modeOf(bandPixels(img, sideCur)) - paneBgRow := modeOf(bandPixels(img, paneCur)) - if !nearRGBA(sideBg, paneBgRow, 3) { - t.Errorf("侧栏当前项的底色是 %v,分组那一列当前项的底色是 %v——"+ - "两列的选中态得是同一个数", sideBg, paneBgRow) - } - // 🔴 **这一档是实心主色底**(票:用户要「深底色 + 白字」):淡底那一 - // 档和侧栏底色的对比度只有 1.09:1 ~ 1.29:1——几乎分不出来。 - if !nearRGBA(paneBgRow, rgbaOf(theme.Accent), 3) { - t.Errorf("分组那一列当前项的底色是 %v,want %v(实心主色底)", - paneBgRow, theme.Accent) - } - }) +// ⚠️ **不验「摆开之后画面上有什么」**:弹层里有三态(加载中 / 拉失败了 / 正常 +// 名单),三态摆的东西互不相同——验「全部关注(N) 在不在」会把前两态挡在外面。 +// 点开没点开由各调用方自己的断言兜底(它们都在找弹层里具体的字)。 +func openFollowGroupSelect(t *testing.T, tt *mygoui.Tester) { + t.Helper() + if err := tt.Click("选择关注分组"); err != nil { + t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts()) } + tt.Frame() } -// TestTheFollowPaneSelectedRowIsReadable 钉住「选中那一格:淡底 + 读得出来的字」。 -// -// 三条判据缺一不可: +// TestTheFollowGroupSelectSeparatesAllFromTheGroups 钉住「全部关注(N)」和分组项 +// 分得开——它们不是一伙的:前一个是「我关注的人」这个全集,后一个是「B 站上我 +// 自己建的分组」。摆成同一个样子的时候用户分不清「这一格是全部」还是「这一格 +// 也是一个分组」。 // -// 1. **底是 `accentSoft` 淡底**,不是实心主色(§七「我们比上游好、不要改回去」 -// 里那一条;实心主色那一档是「被选中的选项」,不是「当前在哪」)。 -// 2. **字是按那层底现算的**(浅色下是压深到 ≥4.5:1 的那一档,深色下是主色)。 -// 3. **量出来的对比度 ≥ 4.5:1**——前两条是「配方对不对」,这一条是「屏幕上读不 -// 读得出来」。 -// -// 另外:**没选中的是次要色**——这一列是第二层导航,选中那一格才是要看的那一格。 -func TestTheFollowPaneSelectedRowIsReadable(t *testing.T) { +// 判据是**中间那道线**:它画在第一项和第一个分组之间,线两侧是同色(弹层面板 +// 是页面底色),所以只能靠线本身分——而它得是**主题的分隔线**(`t.Border`), +// 不是随便一道什么线。 +func TestTheFollowGroupSelectSeparatesAllFromTheGroups(t *testing.T) { for _, dark := range []bool{false, true} { name := "浅色" if dark { @@ -204,99 +127,38 @@ func TestTheFollowPaneSelectedRowIsReadable(t *testing.T) { } t.Run(name, func(t *testing.T) { _, tt := followPaneShell(t, dark) + openFollowGroupSelect(t, tt) theme := BuildTheme(followPaneSettings(), dark) img := tt.Image() cur, ok := tt.Find("全部关注(3)") if !ok { - t.Fatalf("分组那一列上没有「全部关注(3)」那一格,文字有 %q", tt.Texts()) - } - sib, ok := tt.Find("特别关注(1)") - if !ok { - t.Fatalf("分组那一列上没有「特别关注(1)」那一格,文字有 %q", tt.Texts()) - } - - // 底:实心主色(票:用户要「深底色 + 白字」)。 - curBg := modeOf(bandPixels(img, cur)) - if !nearRGBA(curBg, rgbaOf(theme.Accent), 3) { - t.Errorf("选中那一格的底色是 %v,want %v(实心主色底)", - curBg, theme.Accent) - } - // 字:主色底上读得出来的那一档(`t.AccentText`)。 - // - // ⚠️ **容差是 24**:字是抗锯齿画的,量到的那个像素是字色和底色的混合。 - curInk := farthestFrom(img, cur, curBg) - wantInk := flatSolidCurrentText(theme) - if !nearRGBA(curInk, rgbaOf(wantInk), 24) { - t.Errorf("选中那一格的字色是 %v,want %v", curInk, wantInk) - } - // 对比度:屏幕上真的读得出来。 - if r := ink.Ratio(rgbaToColor(curInk), rgbaToColor(curBg)); r < 4.5 { - t.Errorf("选中那一格的字压在底上只有 %.2f:1(< 4.5)", r) - } - - // 没选中的那一格:**次要色**。 - // - // ⚠️ **那一格没有底色**(没选中的行不铺底),所以它的「众数」是这一列 - // 的底色——字要从「离底最远的那一个像素」里量。 - sibBg := modeOf(bandPixels(img, sib)) - sibInk := farthestFrom(img, sib, sibBg) - if !nearRGBA(sibInk, rgbaOf(theme.TextMuted), 24) { - t.Errorf("没选中那一格的字色是 %v,want %v(次要色)", - sibInk, theme.TextMuted) - } - }) - } -} - -// TestTheFollowPaneSeparatesAllFromTheGroups 钉住「全部关注(N)」和分组格分得开。 -// -// 它们不是一伙的:前一个是「我关注的人」这个全集,后一个是「B 站上我自己建的 -// 分组」。摆成同一个样子的时候用户分不清「这一格是全部」还是「这一格也是一个 -// 分组」。 -// -// 判据是**中间那道线**:它画在第一个分组那一行的上面,线两侧是同色(这一列 -// 整列都是方案的 `sidebar`),所以只能靠线本身分——而它得是**主题的分隔线** -// (`t.Border`),不是随便一道什么线。 -func TestTheFollowPaneSeparatesAllFromTheGroups(t *testing.T) { - for _, dark := range []bool{false, true} { - name := "浅色" - if dark { - name = "深色" - } - t.Run(name, func(t *testing.T) { - s, tt := followPaneShell(t, dark) - theme := BuildTheme(followPaneSettings(), dark) - img := tt.Image() - - cur, ok := tt.Find("全部关注(3)") - if !ok { - t.Fatalf("分组那一列上没有「全部关注(3)」那一格,文字有 %q", tt.Texts()) + t.Fatalf("分组下拉里没有「全部关注(3)」那一项,文字有 %q", tt.Texts()) } first, ok := tt.Find("特别关注(1)") if !ok { - t.Fatalf("分组那一列上没有「特别关注(1)」那一格,文字有 %q", tt.Texts()) + t.Fatalf("分组下拉里没有「特别关注(1)」那一项,文字有 %q", tt.Texts()) } second, ok := tt.Find("默认分组(1)") if !ok { - t.Fatalf("分组那一列上没有「默认分组(1)」那一格,文字有 %q", tt.Texts()) + t.Fatalf("分组下拉里没有「默认分组(1)」那一项,文字有 %q", tt.Texts()) } - paneBg := rgbaOf(s.sidebarBackground(theme)) - // 线的颜色:主题的分隔线压在这一列的底上。 + paneBg := rgbaOf(theme.Background) + // 线的颜色:主题的分隔线压在面板底上。 line := rgbaOf(theme.Border.Over(rgbaToColor(paneBg))) - // 选中那一格(全部关注)的淡底——**它是这一带另一种「不是线」的色**, + // 高亮那一档的实心底——**它是这一带另一种「不是线」的色**, // 判据必须把它排除掉(理由见 findSeparator)。 - selBg := rgbaOf(flatCurrentBg(theme)) + selBg := rgbaOf(theme.Accent) y := findSeparator(img, cur.X, cur.X+cur.W, cur.Y+cur.H, first.Y, paneBg, selBg, line) if y < 0 { t.Errorf("「全部关注(3)」和「特别关注(1)」之间没有分界——" + - "那一格(全部关注)和后面那些(分组)不是一伙的") + "那一项(全部关注)和后面那些(分组)不是一伙的") return } - // ⚠️ **分界只在第一格和分组之间**:分组和分组之间**不该**有线, - // 否则那一列看起来像一列表格。 + // ⚠️ **分界只在第一项和分组之间**:分组和分组之间**不该**有线, + // 否则弹层看起来像一列表格。 if y2 := findSeparator(img, first.X, first.X+first.W, first.Y+first.H, second.Y, paneBg, selBg, line); y2 >= 0 { t.Errorf("「特别关注(1)」和「默认分组(1)」之间也有一道线(y=%d)——"+ @@ -305,12 +167,11 @@ func TestTheFollowPaneSeparatesAllFromTheGroups(t *testing.T) { // 🔴 **另一条独立的判据:分界那一带比别处宽**。 // - // 「全部关注」那一格和第一个分组之间要留出分界线自己的上下留白 - // (4 + 4),而分组和分组之间只有行与行之间那 2 的缝。所以前者的 - // 字距**明显更大**——线被删掉(或者被换成一段空隙)的话,这一条 - // 也会红。 + // 第一项和第一个分组之间要留出分界线自己的上下留白(4 + 4),而分组 + // 和分组之间只有项与项之间那一点缝。所以前者的字距**明显更大**——线 + // 被删掉(或者被换成一段空隙)的话,这一条也会红。 // - // ⚠️ **量的是「字」之间的距离**:行之间那 2 的缝在两处都有,只有 + // ⚠️ **量的是「字」之间的距离**:项之间那点缝在两处都有,只有 // 分界那一带多出分界线自己的留白。 gapFirst := first.Y - (cur.Y + cur.H) gapSecond := second.Y - (first.Y + first.H) @@ -326,11 +187,11 @@ func TestTheFollowPaneSeparatesAllFromTheGroups(t *testing.T) { // // # 一行要算「那道线」,得同时满足三样 // -// 1. **不是这一列的底色**(`bg`)——底色的行不是线; -// 2. **不是选中那一格的淡底**(`sel`)——⚠️ **这一条是必需的**(2026-10-25 -// 的评审抓出来的):「全部关注」那一格就铺着淡底,而暖纸浅色下那层淡底 -// (#E0F9E9)离那道线(#DBDBDB)**比离底色还近**——只判「离线更近」的 -// 话,淡底自己会被当成线,于是**把线删掉测试照样绿**。 +// 1. **不是面板的底色**(`bg`)——底色的行不是线; +// 2. **不是高亮那一档的实心底**(`sel`)——⚠️ **这一条是必需的**(2026-10-25 +// 的评审抓出来的同形坑):高亮那一格铺着实心底,而实心底离那道线**比离底色 +// 还近**——只判「离线更近」的话,实心底自己会被当成线,于是**把线删掉测试 +// 照样绿**。 // 3. **离那道线更近**(或者至少不比离底色远)——线的那一档压上去的。 // // ⚠️ **判据是「更靠近线那一档」,不是「等于线那个色」**:那一行的位置是算出来 @@ -347,7 +208,7 @@ func findSeparator(img *image.RGBA, x0, x1, y0, y1 float32, bg, sel, line color. continue } if maxDiff(p, sel) < 6 { - // 和选中那一格的淡底一样 → 那是那一格的底,不是线。 + // 和高亮那一档的实心底一样 → 那是那一项的底,不是线。 run = 0 continue } @@ -365,19 +226,19 @@ func findSeparator(img *image.RGBA, x0, x1, y0, y1 float32, bg, sel, line color. return -1 } -// TestTheFollowPaneTruncatesALongGroupName 钉住「分组名太长怎么办」。 +// TestTheFollowGroupSelectTruncatesALongGroupName 钉住「分组名太长怎么办」。 // // 答:**截断,不换行**——和侧栏收藏夹那一行、上游的 `truncate` 是同一个做法。 // // 三条判据: // -// 1. **那个名字一行放不下**(量出来比这一列能给的宽还长)——所以它**必须**被 +// 1. **那个名字一行放不下**(量出来比弹层能给的宽还长)——所以它**必须**被 // 截断,否则就该换行了。 -// 2. **它就是一行**:那一行的字和旁边那些短名字的字一样高(换行的话会高出一截, +// 2. **它就是一行**:那一项的字和旁边那些短名字的字一样高(换行的话会高出一截, // 把下面那些分组整体顶下去)。 -// 3. **它没有越过这一列的右缘**(截断,不是让它溢出到内容区那边去)。 -func TestTheFollowPaneTruncatesALongGroupName(t *testing.T) { - // ⚠️ **名字后面还跟着人数**(`全部关注(3)` 那一档的写法):量宽度和找那一格 +// 3. **它没有越过弹层的右缘**(弹层 `MaxWidth` 就是它的硬边界)。 +func TestTheFollowGroupSelectTruncatesALongGroupName(t *testing.T) { + // ⚠️ **名字后面还跟着人数**(`全部关注(3)` 那一档的写法):量宽度和找那一项 // 用的都是**画出来的那一句**。 const long = "常听的那些不太熟但是每首都想循环到会唱完整首的 UP 主(1)" @@ -388,54 +249,60 @@ func TestTheFollowPaneTruncatesALongGroupName(t *testing.T) { } t.Run(name, func(t *testing.T) { _, tt := followPaneShell(t, dark) + openFollowGroupSelect(t, tt) + short, ok := tt.Find("特别关注(1)") if !ok { - t.Fatalf("分组那一列上没有「特别关注(1)」那一格,文字有 %q", tt.Texts()) + t.Fatalf("分组下拉里没有「特别关注(1)」那一项,文字有 %q", tt.Texts()) } - // 判据一:**那一句本身有多宽**比「那一格有多宽」重要。 + // 判据一:**那一句本身有多宽**比「弹层有多宽」重要。 // - // ⚠️ **量的是同一个字号**(界面上那一格是 14,见 `follow.go`)。 + // ⚠️ **量的是同一个字号**(弹层里那一项是 14,见 `follow.go`)。 var measured float32 mygoui.NewTester(func(c *mygoui.Context) { measured, _ = c.MeasureText(0, mygoui.Span{Text: long, Size: 14}) }, 300, 60).Frame() if measured <= short.W { - t.Skipf("那一句只有 %.1f 宽,这一格有 %.1f——放得下,这一票没测到"+ + t.Skipf("那一句只有 %.1f 宽,这一项有 %.1f——放得下,这一票没测到"+ "「太长怎么办」", measured, short.W) } // 判据二:**它就是一行**:字的高度和旁边那一个短名字一样。 longBox, ok := tt.Find(long) if !ok { - t.Fatalf("分组那一列上没有那个长名字,文字有 %q", tt.Texts()) + t.Fatalf("分组下拉里没有那个长名字,文字有 %q", tt.Texts()) } if longBox.H > short.H+1 { t.Errorf("那个长名字占 %.1f 高,而短名字占 %.1f——它换行了,"+ "下面那些分组会被顶下去", longBox.H, short.H) } - // 判据三:**没有越过这一列的右缘**。 + // 判据三:**没有越过弹层的右缘**。 // - // ⚠️ **右缘是「全部关注(3)」那一格的右缘加一点**:那一格是这一列里 - // 最宽的(它的底色铺满整列),而长名字那一格和它左对齐。 + // ⚠️ **右缘是弹层里其它项的右缘**:弹层一整列对齐,最宽那一项的右缘 + // 就是弹层的内缘——长名字那一项不许伸出去。 cur, ok := tt.Find("全部关注(3)") if !ok { - t.Fatalf("分组那一列上没有「全部关注(3)」那一格,文字有 %q", tt.Texts()) + t.Fatalf("分组下拉里没有「全部关注(3)」那一项,文字有 %q", tt.Texts()) } if longBox.X+longBox.W > cur.X+cur.W+1 { - t.Errorf("那个长名字伸到 x=%.1f,而这一列到 x=%.1f——它溢出去了", - longBox.X+longBox.W, cur.X+cur.W) + t.Errorf("那个长名字伸到 x=%.1f,而「全部关注(3)」那一项到 x=%.1f——"+ + "它溢出弹层了", longBox.X+longBox.W, cur.X+cur.W) } }) } } -// TestTheFollowPaneHasThreeStates 钉住这一列的三态:**加载中 / 拉失败了 / 没有分组**。 +// TestTheFollowGroupSelectHasThreeStates 钉住弹层里的三态: +// **加载中 / 拉失败了 / 没有分组**。 +// +// 三句**互不通用**:用户读到「还没有分组」时不知道该做什么,而这三种情形要做的 +// 下一步完全不同(等 / 重试 / 去建分组)。 // -// 三句**互不通用**:用户读到「还没有分组」时不知道该做什么,而这一列的三种 -// 情形要做的下一步完全不同(等 / 重试 / 去建分组)。 -func TestTheFollowPaneHasThreeStates(t *testing.T) { +// ⚠️ **三句都在弹层里**:断言前都要先点开触发器——不然弹层根本没摆开,画面上 +// 一句都没有。 +func TestTheFollowGroupSelectHasThreeStates(t *testing.T) { t.Run("加载中", func(t *testing.T) { b := newUIExplore(t) gate := make(chan struct{}) @@ -451,11 +318,12 @@ func TestTheFollowPaneHasThreeStates(t *testing.T) { tt.Frame() homeFrame(tt) + openFollowGroupSelect(t, tt) if !showsText(tt, "正在加载分组…") { - t.Errorf("分组还在拉,这一列上却没有「正在加载分组…」,文字有 %q", tt.Texts()) + t.Errorf("分组还在拉,弹层里却没有「正在加载分组…」,文字有 %q", tt.Texts()) } if showsText(tt, "还没有分组") { - t.Error("分组还在拉,这一列上却说了「还没有分组」——那两件事不一样") + t.Error("分组还在拉,弹层里却说了「还没有分组」——那两件事不一样") } }) @@ -473,14 +341,15 @@ func TestTheFollowPaneHasThreeStates(t *testing.T) { waitFrames(t, tt, "分组拉失败了", func() bool { return f.tagErr != nil }) homeFrame(tt) + openFollowGroupSelect(t, tt) if !showsText(tt, "分组没拉出来") { - t.Errorf("分组拉失败了,这一列上却没有说「分组没拉出来」,文字有 %q", tt.Texts()) + t.Errorf("分组拉失败了,弹层里却没有说「分组没拉出来」,文字有 %q", tt.Texts()) } if !showsText(tt, "重试拉分组") { t.Errorf("分组拉失败了,却没有「重试拉分组」那一枚,文字有 %q", tt.Texts()) } if showsText(tt, "还没有分组") { - t.Error("分组拉失败了,这一列上却说了「还没有分组」——用户会以为他真的没建过") + t.Error("分组拉失败了,弹层里却说了「还没有分组」——用户会以为他真的没建过") } }) @@ -496,26 +365,63 @@ func TestTheFollowPaneHasThreeStates(t *testing.T) { waitFrames(t, tt, "分组到了(空的)", func() bool { return f.tagStarted }) homeFrame(tt) + openFollowGroupSelect(t, tt) if !showsText(tt, "还没有分组") { - t.Errorf("一个分组都没有,这一列上却没有说「还没有分组」,文字有 %q", tt.Texts()) + t.Errorf("一个分组都没有,弹层里却没有说「还没有分组」,文字有 %q", tt.Texts()) } if showsText(tt, "正在加载分组…") { - t.Error("分组已经拉到了(是空的),这一列上却还在说「正在加载分组…」") + t.Error("分组已经拉到了(是空的),弹层里却还在说「正在加载分组…」") } }) } +// TestTheFollowGroupSelectShowsTheCurrentGroup 钉住「收起那一格写的是当前那一格 +// 的名字」——停在「全部关注」写「全部关注」,停在某一组写那一组的名字(上游 +// `index.tsx:206` 的 `

` 同一个口径)。塌回「我的关注」的话,用户以为自己在 +// 看全部,而画面上摆的是某一组的人。 +func TestTheFollowGroupSelectShowsTheCurrentGroup(t *testing.T) { + b := newUIExplore(t) + uiFollowTags(b, map[int]any{777: "常听的"}) + uiFollowList(b, 1, []any{uiFollowUser(11, "全部关注里的那个人")}) + uiFollowSearch(b, "", nil) + uiFollowTag(b, 777, []any{uiFollowUser(77, "常听的那个人")}) + + s, tt, _ := newSpaceShell(t, b, true, "77") + s.navigate(followPath) + tt.Frame() + f := s.followPageOf() + waitFrames(t, tt, "第一格到了", func() bool { return len(f.users) == 1 && f.tagStarted }) + tt.Frame() + + // 停在「全部关注」:收起那一格写「全部关注」。 + if !showsText(tt, "全部关注") { + t.Fatalf("收起那一格没写「全部关注」,文字有 %q", tt.Texts()) + } + + openFollowGroupSelect(t, tt) + if err := tt.Click("常听的(1)"); err != nil { + t.Fatalf("下拉里点不到「常听的」:%v,文字有 %q", err, tt.Texts()) + } + waitFrames(t, tt, "换到那一组", func() bool { return f.tab == followTagTab(777) }) + tt.Frame() + + // 停在某一组:收起那一格写那一组的名字。 + if !showsText(tt, "常听的") { + t.Errorf("换组之后收起那一格没跟着换,文字有 %q", tt.Texts()) + } +} + // TestTheFollowPaneHasNoHardcodedColorsOrRadii 钉住「颜色和圆角不许写死」。 // -// 这一列的底色、淡底、字色、圆角**全都是从主题现算的**:主题方案换了整套跟着 +// 这一页的底色、淡底、字色、圆角**全都是从主题现算的**:主题方案换了整套跟着 // 换(暖纸 / 暮光 / 柔雾 / 经典),而写死的色值只对其中一套是对的。 // -// 判据是**源码**:这一列不许出现颜色字面量,也不许给圆角写一个数。 +// 判据是**源码**:`follow.go` 不许出现颜色字面量,也不许给圆角写一个数。 // -// ⚠️ **认的是「这一列」那几处**:整个 `follow.go` 都不许有——右边那个网格 -// (`viewFollowCard` 那些)也是从主题取色的,而「取消关注」那一枚药丸的圆角 -// 是 **999**(药丸是形状,不跟主题走,见 `followActionPillRadius` 的注释)。 -// 所以药丸那一处是**特许**的,其余一处都不许写死。 +// ⚠️ **认的是整份 `follow.go`**:右边那个网格(`viewFollowCard` 那些)也是从 +// 主题取色的,而「取消关注」那一枚药丸的圆角是 **999**(药丸是形状,不跟主题走, +// 见 `followActionPillRadius` 的注释)。所以药丸那一处是**特许**的,其余一处都 +// 不许写死。 func TestTheFollowPaneHasNoHardcodedColorsOrRadii(t *testing.T) { fset := token.NewFileSet() f, err := parser.ParseFile(fset, "follow.go", nil, 0)