From 023a7a1171d09444618f1bc9c2467d38f28447d2 Mon Sep 17 00:00:00 2001 From: ShellMonster Date: Mon, 2 Nov 2026 21:03:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8D=A2=E9=A1=B5=E7=9A=84=E6=97=B6=E5=80=99?= =?UTF-8?q?=E6=8A=8A=E6=B5=AE=E5=B1=82=E6=94=B6=E6=8E=89=EF=BC=9A=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E6=8A=BD=E5=B1=89=E3=80=81=E6=B8=85=E7=A9=BA=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E3=80=81=E6=92=AD=E6=94=BE=E6=9D=A1=E4=B8=8E=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=E7=9A=84=E6=B5=AE=E5=B1=82=E3=80=81=E6=AD=8C=E8=AF=8D?= =?UTF-8?q?=E6=97=8B=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/e2e/drawer_nav_test.go | 205 +++++++++++++++++++++++++++++ internal/e2e/menu_test.go | 24 +++- internal/ui/notfound.go | 89 ++++++++++++- internal/ui/notfound_test.go | 224 ++++++++++++++++++++++++++++++++ 4 files changed, 538 insertions(+), 4 deletions(-) create mode 100644 internal/e2e/drawer_nav_test.go diff --git a/internal/e2e/drawer_nav_test.go b/internal/e2e/drawer_nav_test.go new file mode 100644 index 00000000..34ffc8a4 --- /dev/null +++ b/internal/e2e/drawer_nav_test.go @@ -0,0 +1,205 @@ +//go:build e2e + +package e2e + +// 换页收浮层的真窗口回归(评论抽屉换页不收那一票)。 +// +// # 为什么要开真窗口 +// +// 界面层那一族(`internal/ui/notfound_test.go` 的 `TestNavigating*`)造的是 +// **假外壳**:它直接置 `s.drawerOpen = true` 等标志、再调 `s.router.Push`, +// 量的是「`syncRoute` 认出换页之后那几个标志收没收」。而这一条要验的是 +// **整条用户路径**:播放条上点开抽屉 → 换一页 → 抽屉在**真窗口的无障碍树** +// 里真的不在了。 +// +// # 为什么用**队列抽屉**而不是评论抽屉 +// +// 评论键只在「当前这一首在 B 站上有对象」时才摆(`App.CommentsWorthAsking` — +// `internal/ui/comments_entry.go`):本地文件压根没有那一枚。而命令行交进来 +// 的文件一定是本地文件(`OnOpenFile` 那一条),UP 名那一条也要歌带 +// `ArtistMid`——所以真窗口 E2E 里**评论抽屉开不出来**。 +// +// 队列抽屉(`播放列表` 那一枚)**没有前提条件**:按下就 `s.drawerOpen = !`, +// 而它和评论抽屉是**同一副 Overlay 骨架**(`ui.Overlay` + 右侧滑入 + +// `Role(ui.RoleDialog)`),换页收它们的是**同一个出口** +// (`syncRoute` → `closeOverlaysForNav`)——用它验这条路径同样成立。 +// +// ⚠️ **判据是无障碍树里有没有「播放列表」那个 dialog**:抽屉的面板挂着 +// `Role(ui.RoleDialog).Label("播放列表")`(`queue_drawer.go`),它在树里是 +// role=dialog 的节点;而播放条上那一枚键的无障碍名也是「播放列表」—— +// **按 role 分**才不会把键当成抽屉。 +// +// # 怎么跑 +// +// BIU_RUN_MYGO_E2E=1 go test -tags e2e ./internal/e2e/ -run TestNavigatingClosesTheQueueDrawer +// +// ⚠️ **要一个真窗口**:它开一个窗口并驱动它,所以本机得能开窗口。 + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// navNode 是无障碍树里这一项要的字段(id + 角色 + 名字 + key)。 +type navNode struct { + ID string `json:"id"` + Role string `json:"role"` + Label string `json:"label"` + Key any `json:"key"` +} + +// navTree 读窗口的元素树。 +func navTree(t *testing.T, client *automationClient, window int) []navNode { + t.Helper() + var tree struct { + Nodes []navNode `json:"nodes"` + } + if err := automationCall(client, 500, "tree", map[string]any{"window": window}, &tree); err != nil { + t.Fatalf("读元素树:%v", err) + } + return tree.Nodes +} + +// queueDialogInTree 报树上有没有「播放列表」那个 **dialog** 节点。 +// +// ⚠️ **按 role 分**:播放条上那一枚键的无障碍名也是「播放列表」,而抽屉的 +// 面板才是 `Role(ui.RoleDialog)`——只按 label 找会把键当成抽屉。 +func queueDialogInTree(nodes []navNode) bool { + for _, n := range nodes { + if n.Role == "dialog" && n.Label == "播放列表" { + return true + } + } + return false +} + +// waitForQueueDialog 等「播放列表」那个 dialog 节点出现(want=true)或消失 +// (want=false)。 +// +// ⚠️ **等的是「出现了/消失了」,不是「等了几帧」**:抽屉进出场是动画 +// (面板滑进滑出那 200ms 里节点还在树上),固定毫秒数是机器忙的时候最会飘的 +// 判据(`docs/agents/testing.md`)。 +func waitForQueueDialog(t *testing.T, client *automationClient, window int, want bool, timeout time.Duration) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for { + if queueDialogInTree(navTree(t, client, window)) == want { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(100 * time.Millisecond) + } +} + +// TestNavigatingClosesTheQueueDrawer 断**真窗口上**:队列抽屉开着,换一次页, +// 抽屉收掉。 +// +// 三段: +// +// 1. **命令行把一首 FLAC 交进来**(应用起来就放它——播放条上那一组键 +// 有歌的时候才齐;队列那一枚本身没前提,带歌是顺带给「换页前后播放条 +// 还在」一个更真的状态); +// 2. **按播放条上那一枚「播放列表」** → 抽屉在树里出现 +// (role=dialog、label="播放列表"); +// 3. **点应用菜单「设置…」**换页 → 那个 dialog 节点从树里消失。 +// +// ⚠️ **换页走的是应用菜单「设置…」**(`openSettingsFromMenu` → +// `s.navigate("/settings")`),不是 UP 名:UP 名那一条要歌带 +// `ArtistMid`,命令行交进来的本地文件没有 mid——而收浮层的是 `syncRoute` +// 那一处收口,**哪条换页路径都一样**(理由见 `internal/ui/notfound.go`)。 +// +// ⚠️ **为什么是菜单而不是侧栏/顶栏**:抽屉开着的时候它的蒙版是**模态**的 +// (`queue_drawer.go` 的 `back.Modal()`),框架的无障碍树在模态开着时**只给 +// 模态自己和它上面的东西**(`mygo/ui/access.go` 的 accessTree:「What is +// behind a dialog is inert」)——侧栏、顶栏在树上**整个不在**,`act` 按不 +// 到任何一枚页面上的键。而**应用菜单是系统画的、不走元素树**,它在模态 +// 开着的时候照常点得——这也是**真人**唯一的换页道。 +func TestNavigatingClosesTheQueueDrawer(t *testing.T) { + if os.Getenv("BIU_RUN_MYGO_E2E") != "1" { + t.Skip("设置 BIU_RUN_MYGO_E2E=1 才运行真窗口 E2E") + } + + // 那一首 FLAC 在仓库里(解码器的测试素材),命令行交进来 = 应用起来就放它。 + flac := filepath.Join(repoRoot(t), "internal", "service", "audio", "flacdec", "testdata", "stereo16.flac") + if _, err := os.Stat(flac); err != nil { + t.Skipf("找不到 FLAC 测试素材:%v", err) + } + + client, stop := startRealAppWithArgs(t, flac) + defer stop() + window := firstContentWindow(t, client) + + // 播放条上那一枚「播放列表」要在。 + if !waitForTree(t, client, window, "播放列表", 20*time.Second) { + t.Fatalf("播放条上没有「播放列表」那一枚:\n%s", treeDump(t, client, window)) + } + + // 点开抽屉。「播放列表」那枚键**没有 key**(`iconKey` 没挂 key——它就是 + // 一个普通的图标键),所以按**元素 id** 按:先在树上把它找出来。 + id := findNodeID(t, client, window, func(n navNode) bool { + return n.Label == "播放列表" && n.Role == "button" + }) + if id == "" { + t.Fatalf("树上找不到「播放列表」那一枚:\n%s", treeDump(t, client, window)) + } + if err := automationCall(client, 501, "act", map[string]any{ + "window": window, + "id": id, + "action": "press", + }, nil); err != nil { + t.Fatalf("按播放条上那一枚「播放列表」:%v", err) + } + if !waitForQueueDialog(t, client, window, true, 20*time.Second) { + t.Fatalf("按了「播放列表」之后抽屉没开:\n%s", treeDump(t, client, window)) + } + + // 换一页:应用菜单「设置…」。模态开着的时候页面上一个可点的节点都不在 + // 树上(见顶上那段「为什么是菜单」),菜单是系统画的、不走元素树。 + if err := automationCall(client, 502, "menu", map[string]any{ + "window": window, + "path": []string{windowTitle(t, client, window), "设置…"}, + }, nil); err != nil { + t.Fatalf("点应用菜单「设置…」:%v", err) + } + if !waitForQueueDialog(t, client, window, false, 20*time.Second) { + t.Fatalf("换页之后队列抽屉还开着:\n%s", navTreeDump(t, client, window)) + } +} + +// navTreeDump 把窗口的元素树(id / role / key / label 全列出)打出来,失败时用。 +func navTreeDump(t *testing.T, client *automationClient, window int) string { + t.Helper() + var b strings.Builder + for _, n := range navTree(t, client, window) { + fmt.Fprintf(&b, "id=%s role=%s key=%v label=%q\n", n.ID, n.Role, n.Key, n.Label) + } + return b.String() +} + +// findNodeID 在树上找第一个满足 cond 的节点的 id(字符串形式)。 +func findNodeID(t *testing.T, client *automationClient, window int, cond func(navNode) bool) string { + t.Helper() + var tree struct { + Nodes []struct { + ID string `json:"id"` + Role string `json:"role"` + Label string `json:"label"` + Key any `json:"key"` + } `json:"nodes"` + } + if err := automationCall(client, 503, "tree", map[string]any{"window": window}, &tree); err != nil { + t.Fatalf("读元素树:%v", err) + } + for _, n := range tree.Nodes { + if cond(navNode{Role: n.Role, Label: n.Label, Key: n.Key}) { + return n.ID + } + } + return "" +} diff --git a/internal/e2e/menu_test.go b/internal/e2e/menu_test.go index c49085ec..868f8db8 100644 --- a/internal/e2e/menu_test.go +++ b/internal/e2e/menu_test.go @@ -127,6 +127,18 @@ func TestTheApplicationMenu(t *testing.T) { // ⚠️ **编的是根目录那个 main**,不是 `fixture/` 里的那个——要验的就是 // **这个应用的菜单**,而菜单是应用级的。 func startRealApp(t *testing.T) (*automationClient, func()) { + t.Helper() + return startRealAppWithArgs(t) +} + +// startRealAppWithArgs 同 `startRealApp`,但把 args 原样放在二进制后面—— +// 命令行里给的是一个音频文件的话,应用起来那一首就放起来了(框架的 +// `fileArgs` 把它投给 `OnOpenFile`,见 `internal/ui/openfile.go`)。 +// +// ⚠️ **为什么单独一支而不给 `startRealApp` 加参数**:原来那三支调用方一个 +// 参数都不传,改签名的话那三处都得跟一遍——而「起应用」和「起应用放一首歌」 +// 是两件事,分开写读起来也清楚。 +func startRealAppWithArgs(t *testing.T, args ...string) (*automationClient, func()) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) socketDir, socket, err := makeAutomationSocket() @@ -136,7 +148,15 @@ func startRealApp(t *testing.T) (*automationClient, func()) { } binary := filepath.Join(socketDir, "biugo-e2e") var buildOut, buildErr bytes.Buffer - build := exec.Command("go", "build", "-tags", "e2e", "-o", binary, ".") + // ⚠️ **`packageFileExtensions` 要按 mygo.json 注进去**:裸 `go build` 不跑 + // `go tool mygo build` 的那一道 ldflags(`mygo/cmd/mygo/build.go` 的 + // `packageFlags`),框架的 `fileArgs` 看到的就是空串——命令行里的音频文件 + // **一件都不投**给 `OnOpenFile`(`mygo/deeplink.go:99`),而 `handleLaunchArgs` + // 又按「框架会交的我们不碰」把它跳过(`internal/ui/openfile.go`),症状是 + // 「命令行给了文件、起来却什么都没放」。注进去之后,框架那一半照常投。 + build := exec.Command("go", "build", "-tags", "e2e", + "-ldflags", "-X github.com/egoist/mygo.packageFileExtensions=mp3,flac,wav,m4a,aac,ogg,aiff,aif,aifc", + "-o", binary, ".") build.Dir = repoRoot(t) build.Stdout = &buildOut build.Stderr = &buildErr @@ -146,7 +166,7 @@ func startRealApp(t *testing.T) (*automationClient, func()) { t.Fatalf("构建真应用(带 e2e 标签):%v\nstdout:\n%s\nstderr:\n%s", err, buildOut.String(), buildErr.String()) } - cmd := exec.CommandContext(ctx, binary) + cmd := exec.CommandContext(ctx, binary, args...) // ⚠️ **单独的一份数据目录**(`automation_e2e.go` 顶上那段说明了为什么): // 应用有单实例锁,而锁在用户数据目录里——**用户机器上已经跑着这个应用 // 时**(开发时几乎总是这样),E2E 起来的那一份会抢不到锁、把命令行递过去、 diff --git a/internal/ui/notfound.go b/internal/ui/notfound.go index d891d6d1..01f9284b 100644 --- a/internal/ui/notfound.go +++ b/internal/ui/notfound.go @@ -26,11 +26,13 @@ const ( notFoundTick = time.Second ) -// syncRoute 是「换页了」这件事唯一的收口,也是那个倒计时唯一的驱动。 +// syncRoute 是「换页了」这件事唯一的收口:那个倒计时由它驱动,换页要一起收掉 +// 的浮层也由它收(`closeOverlaysForNav`)。 // // **收在这里而不是 navigate**:后退/前进按钮(viewNav 里那对 BackButton / // ForwardButton)走的是 Router 自己的历史,不经过 navigate。漏了这一处, -// 「404 页 3 秒后跳首页」就会在用户已经退回别的页之后照样把他拽回首页。 +// 「404 页 3 秒后跳首页」就会在用户已经退回别的页之后照样把他拽回首页; +// 而评论抽屉、队列抽屉、分集浮层、倍速菜单就会在换页之后还悬在画面上。 // // 这一段跑在主线程的 view 里,所以改这些字段不违反铁律一;后台那一跳只调 // wakeHandle.invalidate,不碰它们(见 clock.go 顶上那条硬规矩)。 @@ -48,6 +50,9 @@ func (s *Shell) syncRoute() { return } s.shownPath = path + // 换页了这一拍把浮层一起收掉(票:评论抽屉换页不收)。收在这里的理由同 + // 上面那段注释——**换页不止 navigate 一条道**,而这里是唯一都过的那一处。 + s.closeOverlaysForNav() if lookup(path) == notFound { s.armNotFound() } else { @@ -55,6 +60,86 @@ func (s *Shell) syncRoute() { } } +// closeOverlaysForNav 换页的时候把浮在页面上的那几层收掉。 +// +// # 收哪几层、为什么 +// +// 换页了旧页的浮层都不该留着——它们盖的是**上一页**的东西,下一页上悬着一块 +// 上一页的浮层,用户第一眼看到的是「这页坏了」: +// +// - `commentsOpen`(评论抽屉,`ui.Overlay`); +// - `drawerOpen`(队列抽屉,同款 Overlay); +// - `queueClearOpen`(抽屉里那道「清空播放列表」二次确认):**它不是抽屉的 +// 一部分,是自己活着的一层模态**——抽屉收了它状态还在,下次开抽屉那个 +// 没回答过的问题直接复活(这是对抗性审查点名的状态泄漏); +// - `partOpen`(分集浮层,框架 `Popover`); +// - `rateOpen`(倍速菜单,框架 `Popover`); +// - `sleepOpen`(睡眠定时,`Popover`); +// - `volOpen`(音量,`Popover`); +// - `errDetailOpen`(播放条「错误详情」,`Popover`); +// - `searchSuggest`(搜索联想面板,`PopoverBase`); +// - `avatarMenuOpen`(头像下拉,`PopoverBase`); +// - `dynMenuOpen`(动态卡片「⋯」菜单,`PopoverBase`); +// - `favPanelOpen`(折叠侧栏的收藏夹浮层,`PopoverBase`); +// - `full.partsOpen`(满屏播放器里的分集面板); +// - `lyric.offsetOpen` / `lyric.fontOpen`(播放条歌词区右下角的两个浮层, +// `PopoverBase`); +// - `fullLyric.offsetOpen` / `fullLyric.fontOpen`(满屏歌词区同款两个)。 +// +// ⚠️ **这些 Popover 都得显式收**:它们的「点外面自己关」那一条对**换页** +// 无效——换页不是「点外面」,那一层框架自己不会收。 +// +// # 收的是「意图」(`open = false`),不是「阶段」 +// +// `commentsPhase` / `drawerPhase` 是各自的阶段机(`syncCommentsDrawerPhase` / +// `syncDrawerPhase`)推的——置 open=false 之后它们自己走 `drawerOut`,面板 +// **滑出去**那 200ms 照旧走。把阶段机也硬掰成 `drawerGone` 的话是「啪」地 +// 没了,不是滑出去。 +// +// ⚠️ **不存在「抽屉内部发起的换页」**:评论抽屉里能点的只有「展开/收起/楼中楼/ +// 刷新」(`comments.go` / `comments_body.go`),队列抽屉里是队列行和清空—— +// **没有一处调 `s.navigate`**。所以「先收抽屉、换页这一帧才发生」的顺序里 +// 没有「收早了把正在导航的那下关动作弄闪烁」的问题。 +// +// # 明确不收的,以及为什么 +// +// 下面这几层**也是浮层**,但换页时**故意留着**——取舍写在这里,不是漏掉: +// +// - **收藏夹管理的几个弹窗**(`folderEdit.open`、`folderEdit.crop.open`、 +// `folderSelect.open`、`folderDelete.open`,见 `viewFolderDialogs`): +// 它们是**装着用户输入的表单**——敲了一半的收藏夹名、刚裁好的封面、 +// 刚勾好的几个夹。换页收掉等于**静默丢掉用户没存的工作**;而它们是居中的 +// 模态,压在新页上读起来是「在等你回答」,不是「这页坏了」。 +// - **各页的破坏性确认框**(`laterClearOpen`、`collection.confirm`、 +// `downloads.ask`、`local.rmOpen`):同上——那是一个**没回答完的问题**, +// `doIt` 闭包里还攥着页面上下文;换页替用户把「删除吗」按成「算了」是 +// 替他做决定。 +// - **歌词搜索/预览**(`lyricSearch.open` / `lyricSearch.preview`): +// 搜索框里的词和挑到一半的候选同理。 +// - **关于面板**(`viewAbout` 那一层):应用级信息,从菜单开得着、跟当前 +// 页没有上下文,不属于「上一页的东西」。 +// - **满屏播放器**(`full.open`):它是**播放面本身**不是「压在页上的 +// 浮层」——换页把播放器收了是拿导航当停止用,明显不对。 +func (s *Shell) closeOverlaysForNav() { + s.commentsOpen = false + s.drawerOpen = false + s.queueClearOpen = false + s.partOpen = false + s.rateOpen = false + s.sleepOpen = false + s.volOpen = false + s.errDetailOpen = false + s.searchSuggest = false + s.avatarMenuOpen = false + s.dynMenuOpen = false + s.favPanelOpen = false + s.full.partsOpen = false + s.lyric.offsetOpen = false + s.lyric.fontOpen = false + s.fullLyric.offsetOpen = false + s.fullLyric.fontOpen = false +} + // armNotFound 记下进 404 的那一刻,起那个每秒一跳的倒计时。 // // 先收旧的:404 → 另一个 404(`/没有这条` → `/也没有这条`)也算换页,会走到 diff --git a/internal/ui/notfound_test.go b/internal/ui/notfound_test.go index fe7479ee..bd35b93b 100644 --- a/internal/ui/notfound_test.go +++ b/internal/ui/notfound_test.go @@ -354,6 +354,230 @@ func TestEveryPageViewIsGuarded(t *testing.T) { } } +// ───────────────────────────────────────────────────────────────────────────── +// 换页收浮层 +// ───────────────────────────────────────────────────────────────────────────── +// +// # 为什么收在 syncRoute 而不是 navigate +// +// `navigate` 是**一条**换页的路:它清 Toast、复位 crash、`router.Push`—— +// **它不碰浮层**(那是这一票之前的事)。而「换页」不止那一条道: +// +// - 顶栏那对 `ui.BackButton` / `ForwardButton`(navbar.go)是**框架控件**—— +// 按下去直接 `r.Go(±1)`,走 Router 自己的历史,**根本不经过 Shell**。 +// - `navigateBack`(shortcut_dispatch.go 的后退快捷键)走 `s.router.Back()`。 +// - `goLogin`(login.go)走 `s.router.Push`。 +// - 404 的倒计时回首页(本文件上面那一段)也是 `s.navigate`。 +// +// ⇒ 所以「换页了」唯一的收口是 `syncRoute`(理由同上面那段注释):它在 +// `syncUnderlay` 里**每帧**跑,拿 `s.shownPath` 的边沿认出「换页了」。 +// +// # 收哪几层 +// +// 换页了旧页的浮层都不该留着——它们盖的是**上一页**的东西,下一页上悬着一块 +// 上一页的浮层,用户第一眼看到的是「这页坏了」。 +// +// - `commentsOpen`(评论抽屉,`ui.Overlay`):置 false 之后 +// `syncCommentsDrawerPhase` 会走 `drawerOut` 正常滑出,不是硬消失。 +// - `drawerOpen`(队列抽屉,同款 Overlay):同上。 +// - `queueClearOpen`(抽屉里的「清空播放列表」确认):**不是抽屉的一部分**—— +// 抽屉收了它状态还活着,下次开抽屉那个没回答的问题直接复活。 +// - `partOpen`(分集浮层):框架 `Popover`——「点外面自己关」那一条对**换页** +// 无效(换页不是「点外面」),所以得显式收。 +// - `rateOpen`(倍速菜单):同上,框架 Popover。 +// - `sleepOpen` / `volOpen` / `errDetailOpen`(播放条上同款的 Popover)。 +// - `searchSuggest`(搜索联想,`PopoverBase`)/ `avatarMenuOpen` / +// `dynMenuOpen` / `favPanelOpen`(同款 PopoverBase)。 +// - `full.partsOpen`(满屏的分集面板)与 `lyric` / `fullLyric` 各自的 +// `offsetOpen` / `fontOpen`(歌词区右下角那两个 `PopoverBase`)。 +// +// 没问的(**显式不收**,理由在 `closeOverlaysForNav` 顶上那段):收藏夹管理 +// 那几个装着输入的弹窗、各页的破坏性确认框、歌词搜索/预览、关于面板、 +// 满屏播放器本身。 +// +// ⚠️ **收的是「意图」(open=false),不是「阶段」**:`commentsPhase` / +// `drawerPhase` 是各自的 `syncXxx` 阶段机推的——把阶段机也硬掰成 `drawerGone` +// 的话面板是**硬消失**的(没有滑出那一段),而「抽屉该收」收的是「该走了」 +// 这个意图,滑出那 200ms 照旧走。 +// +// ⚠️ **抽屉里没有「自己发起换页」的控件**:评论抽屉里能点的只有「展开/收起/ +// 楼中楼/刷新」(`comments.go` / `comments_body.go`),队列抽屉里能点的只有 +// 队列行和清空——**没有一处调 `s.navigate`**。所以「先收抽屉、换页在下一帧 +// 才发生」的顺序不存在「收早了把正在导航的那下关动作弄闪烁」的问题。 + +// openAllOverlays 把这一票管的浮层**全都打开**。 +// +// ⚠️ **外壳得是真的在线那一档**(`onlineTester`,pagelist 顶成 403): +// `viewPartButton` 在「没有歌 / 这一首问不到分集」那一支**每帧都把 `partOpen` +// 收掉**——拿 `newShell` 直接置 `s.partOpen = true` 的话,下一帧它又被播放条 +// 自己收回去,「换页收没收」就量不出来(收的是播放条,不是换页)。而 +// 「pagelist 顶成 403」那一档 `PartsWorthAsking` 仍是 true——入口还在、 +// `partOpen` 留得住。 +// +// ⚠️ **三个浮层是真的点开的,不是置位**:`partOpen`(`openPartsDrawer`,点开 +// 顺道把「入口还摆着」也验了)和**搜索联想**(窗口得有焦点 + 点一下搜索框—— +// `searchSuggest` 在「框没焦点」那一支每帧自动收,直接置位下一帧就被搜索框 +// 自己收回去,和 `partOpen` 是同一个坑;而 `tt.SetFocused(true)` 无头窗口默认 +// 是没焦点的,少这一句点了也白点)。 +// +// ⚠️ **其余几枚是「置位就够了」**:`sleepOpen` / `volOpen` / `errDetailOpen` / +// `avatarMenuOpen` / `dynMenuOpen` / `favPanelOpen` / `queueClearOpen` / +// `full.partsOpen` / `lyric` 与 `fullLyric` 的 `offsetOpen`/`fontOpen`—— +// 它们都是「点了开、不点不收」的普通标志(`volOpen` 有个闲置宽限,但下一帧 +// 断言时远没到点),没有 `partOpen`/`searchSuggest` 那种每帧收回的分支。 +func openAllOverlays(t *testing.T, s *Shell, tt *mygoui.Tester) { + t.Helper() + // ⚠️ **搜索联想不在这里开**(单独有 `TestNavigatingClosesTheSuggest`): + // 它要点搜索框 + 敲字才置位,而抽屉那块蒙版盖住整窗(播放条除外)——抽屉 + // 开着的时候搜索框点不到,两个状态在无头 Tester 里**造不出来同框**。它 + // 单独断「开着换页就丢」,见那条测试。 + s.commentsOpen = true + s.drawerOpen = true + s.queueClearOpen = true + s.rateOpen = true + s.sleepOpen = true + s.volOpen = true + s.errDetailOpen = true + s.avatarMenuOpen = true + s.dynMenuOpen = true + s.favPanelOpen = true + s.full.partsOpen = true + s.lyric.offsetOpen = true + s.lyric.fontOpen = true + s.fullLyric.offsetOpen = true + s.fullLyric.fontOpen = true + openPartsDrawer(t, tt) + if !s.partOpen { + t.Fatal("分集浮层没点开——起点就不对(入口被收了的话,换页收它这条就量不到)") + } +} + +// overlaysStillOpen 报这些浮层里还有几个开着。 +func overlaysStillOpen(s *Shell) map[string]bool { + return map[string]bool{ + "评论抽屉": s.commentsOpen, + "队列抽屉": s.drawerOpen, + "清空队列确认框": s.queueClearOpen, + "分集浮层": s.partOpen, + "倍速菜单": s.rateOpen, + "睡眠定时": s.sleepOpen, + "音量浮层": s.volOpen, + "错误详情": s.errDetailOpen, + "头像下拉": s.avatarMenuOpen, + "动态卡片菜单": s.dynMenuOpen, + "收藏夹浮层": s.favPanelOpen, + "满屏分集面板": s.full.partsOpen, + "歌词字号浮层": s.lyric.fontOpen, + "歌词偏移浮层": s.lyric.offsetOpen, + "满屏歌词字号": s.fullLyric.fontOpen, + "满屏歌词偏移": s.fullLyric.offsetOpen, + } +} + +// TestNavigatingClosesTheOverlays 断「换页 → 浮层全收」。 +// +// 🔴 **这是这一票的主验收**:`openAllOverlays` 把该收的那一整片打开(两个 +// 抽屉、清空确认、五个 Popover、五个 PopoverBase、满屏分集、两对歌词旋钮)—— +// 换一页,一层不剩。 +// +// ⚠️ **走的是 `syncRoute` 那条路**(不是 `navigate`):换页不止 `navigate` +// 一条道(见上面那段),所以这里模拟的是**路由历史直接变**(顶栏那对 +// BackButton / ForwardButton 就是这么走的)——`s.router.Push` 之后推一帧, +// `syncRoute` 拿 `shownPath` 的边沿认出换页、把浮层收掉。 +func TestNavigatingClosesTheOverlays(t *testing.T) { + s, tt := onlineTester(t, nil, true) + openAllOverlays(t, s, tt) + + // 换页:不经过 `navigate`(它只清 Toast 和 crash),直接改路由历史—— + // 顶栏那对 BackButton / ForwardButton 就是这么走的(`r.Go(±1)`)。 + s.router.Push("/history") + tt.Frame() + + for name, open := range overlaysStillOpen(s) { + if open { + t.Errorf("换页之后 %s 还开着", name) + } + } +} + +// TestNavigatingBackClosesTheOverlays 断「**后退**也收浮层」。 +// +// 🔴 **这是 `syncRoute` 为什么必须管这件事的另一半**:后退走的是 Router +// 自己的历史(`s.router.Back()`,顶栏 `ui.BackButton` 就是按它), +// 根本不经过 `navigate`——只在 `navigate` 里收的话这条路径就漏了。 +func TestNavigatingBackClosesTheOverlays(t *testing.T) { + s, tt := onlineTester(t, nil, true) + + s.router.Push("/history") + tt.Frame() + openAllOverlays(t, s, tt) + + // 后退:Router 自己的历史,不经过 navigate。 + s.router.Back() + tt.Frame() + + for name, open := range overlaysStillOpen(s) { + if open { + t.Errorf("后退之后 %s 还开着", name) + } + } +} + +// TestNavigatingClosesTheSuggest 断「搜索联想开着换页也收」。 +// +// ⚠️ **它单独一条,不在 `openAllOverlays` 里**:联想面板要点搜索框 + 敲字才 +// 开,而两个抽屉的蒙版盖住整窗(播放条除外)——抽屉开着的时候搜索框根本点 +// 不到,「抽屉 + 联想同时开」在无头 Tester 里造不出来(真人那里也一样: +// 抽屉蒙版下面那一层点不动)。所以这一条单独断「它开着换页就丢」——同一 +// 个 `closeOverlaysForNav`,起点不同罢了。 +func TestNavigatingClosesTheSuggest(t *testing.T) { + s, tt := onlineTester(t, nil, true) + tt.SetFocused(true) + if err := tt.Click(searchLabel); err != nil { + t.Fatalf("点不到搜索框:%v,文字有 %q", err, tt.Texts()) + } + tt.Type("周") + tt.Frame() + if !s.searchSuggest { + t.Fatal("敲了字联想浮层没开——起点就不对") + } + + s.router.Push("/history") + tt.Frame() + + if s.searchSuggest { + t.Error("换页之后搜索联想还开着") + } +} + +// TestNavigatingDoesNotLeaveTheDrawerPhasesStuck 断「收的是意图,不是硬掰阶段」。 +// +// 🔴 **置 open=false 之后,阶段机该自己走「退场」那一段**——`commentsPhase` +// 从 `drawerShown` 变 `drawerOut`(不是直接 `drawerGone`),面板滑出那 200ms +// 照旧走;硬掰成 `drawerGone` 的话是「啪」地没了,不是滑出去。 +func TestNavigatingDoesNotLeaveTheDrawerPhasesStuck(t *testing.T) { + s, tt := newShell(t) + + // 抽屉先开到「到位了」那一档。 + s.commentsOpen = true + tt.Frame() + s.commentsPhase = drawerShown + s.drawerOpen = true + tt.Frame() + s.drawerPhase = drawerShown + + s.router.Push("/history") + tt.Frame() + + if s.commentsPhase != drawerOut { + t.Errorf("换页之后 commentsPhase 是 %d,want %d(它该开始往窗口外面滑,不是硬消失)", + s.commentsPhase, drawerOut) + } + if s.drawerPhase != drawerOut { + t.Errorf("换页之后 drawerPhase 是 %d,want %d(同上)", s.drawerPhase, drawerOut) + } +} + // TestThe404PageHasAWayOutRightNow 钉住「404 上立刻能回去」。 // // # 为什么这一条要紧