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
205 changes: 205 additions & 0 deletions internal/e2e/drawer_nav_test.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
24 changes: 22 additions & 2 deletions internal/e2e/menu_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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 起来的那一份会抢不到锁、把命令行递过去、
Expand Down
89 changes: 87 additions & 2 deletions internal/ui/notfound.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 顶上那条硬规矩)。
Expand All @@ -48,13 +50,96 @@ func (s *Shell) syncRoute() {
return
}
s.shownPath = path
// 换页了这一拍把浮层一起收掉(票:评论抽屉换页不收)。收在这里的理由同
// 上面那段注释——**换页不止 navigate 一条道**,而这里是唯一都过的那一处。
s.closeOverlaysForNav()
if lookup(path) == notFound {
s.armNotFound()
} else {
s.disarmNotFound()
}
}

// 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(`/没有这条` → `/也没有这条`)也算换页,会走到
Expand Down
Loading
Loading