☰
MooTool Next Compose 焦点环回归实践:用 ToolbarFocusCaptureTest 验证 HTTP 集合搜索的 Tab 焦点
2026/10/15 7:06:21 网站建设 项目流程
  • 桌面应用
  • 开发工具

【免费下载链接】MooTool

Handy tool set for developers

项目地址:https://gitcode.com/gh_mirrors/mo/MooTool
点击查看免费下载

本文围绕 MooTool 桌面工具集的 Compose 重写工程(next-compose)中一条典型的“焦点证据”改动记录(DIFF-428)展开,讲解该团队如何用ToolbarFocusCaptureTest在 CI 场景下回归MooCompactSearch组件 + modern 主题强调色(accent)焦点环的像素表现,并解释其与产品主窗 AX 帧、VaultRevisionMonitor基线重同步之间的协作关系。读完本文,你将理解一套可复用的“Compose 可访问性视觉证据”测试模式,包括焦点环 token 的来源、测试如何断言焦点环像素、证据帧的编号体系,以及为什么这类场景测试不能代替真实窗口手工验收。

背景:为什么需要一份 Compose 场景焦点环证据

MooTool 的next-compose分支在持续对齐原 Electron 版本与既有 Java 版本的视觉/交互行为。团队用一套DIFF 编号文档(见 next-compose/docs/diff/)逐条记录行为差异、改动内容与验证方式,而“Tab 焦点外描边(focus ring)是否在正确位置、使用正确颜色渲染”正是其中一类需要被持续回归的视觉细节。

在 DIFF-428 原文 中可以看到清晰的上下文:

  • 产品主窗此前已有118-http-search-tab-focus.png,这是一张AX(Accessibility)帧——即对真实运行窗口用screencapture/AX 坐标截图得到的画面;
  • 但产品窗帧依赖手工操作、耗时且不稳定,无法进入纯 CI 自动回归;
  • 因此需要与 DIFF-427(JSON Vault 搜索焦点环)、DIFF-097(Tab 焦点帧体系起点) 一样,在desktopTest中用ToolbarFocusCaptureTest生成一张Compose 场景帧,覆盖MooCompactSearch+ modern accent 焦点环,并以210dp宽度对齐真实 HTTP 集合列的布局。

一句话概括这条证据链的设计思想:“产品窗 AX 帧负责证明真实窗口里焦点环真的可见;Compose 场景帧负责在 CI 里自动防止焦点环 token 回归”,两者互补而不互相替代。

MooCompactSearch:被验证的紧凑搜索组件

HTTP 工具页的左侧是“集合”(已保存请求列表)列,顶部就是搜索框。真实调用发生在 HttpScreen.kt 的 CollectionPane:

MooCompactSearch( session.query, { session.query = it; onChanged() }, placeholder = container.t("common.search"), modifier = Modifier.weight(1f), )

组件本体定义在 Controls.kt,其关键行为包括:

状态边框颜色说明
聚焦(focused)colors.accent焦点时边框切换到强调色
悬停(hovered)colors.borderControlHover悬停高亮边框
默认colors.borderControl常规控件边框

同时,MooCompactSearch通过Modifier.mooFocusOutline(focused, shape)叠加外描边焦点环,并且仅在interfaceStyle = "modern"且非smartisan/miui-v5风格时,聚焦状态还会额外加一层focusRing半透明阴影(alpha = 0.35f)。这就是 DIFF-428 中“modern accent 焦点环”一词的准确含义——焦点环与边框都取自主题强调色体系。

MooCompactSearch还特意预留了fieldModifier: Modifier = Modifier参数(DIFF-427 引入),目的就是让测试可以通过focusRequester(...)把焦点主动授予内部BasicTextField。这是整套场景帧测试能够稳定请求焦点的前提。

焦点环 token 从哪来:focusRing 与强调色推导

测试断言的颜色0x316DC0并非硬编码魔法值,而是主题 token 的取值。在 MooTheme.kt 中可以找到:

  • light 色板:focusRing = Color(0xFF316DC0)(见MooTheme.kt#L262);
  • dark 色板:focusRing = Color(0xFF94BDF4)(#L302);
  • 运行时:focusRing = accent.focusRing(#L710),即焦点环跟随用户选择的强调色;
  • 默认蓝色强调色:AccentPalette(..., Color(0xFF316DC0), ...)(#L899)。

因此测试里写val expected = 0x316DC0等价于在 light modern 主题、默认蓝色强调色下断言“外描边环精确落在 focusRing token 上”,一旦主题 token 或mooFocusOutline实现走样,像素计数就会失败。

mooFocusOutline本身是一个自绘实现(见 Controls.kt):在内容之上用drawWithContent画一圈 2dp 描边,且描边与控件之间留 2dp 间距,从而形成“外描边(outline)”而不是紧贴的边框:

val stroke = 2.dp.toPx() val gap = 2.dp.toPx() val inset = -(gap + stroke / 2f) drawRoundRect( color = ring, topLeft = Offset(inset, inset), ... style = Stroke(width = stroke) )

理解这一段,就能明白为什么断言用“命中像素数 ≥ 8”而不是“恰好 N 像素”——描边是沿圆角矩形绘制的弧线段,受字体渲染与抗锯齿影响,像素分布会变化,但焦点环颜色的像素一定大量存在。用下限断言可以兼顾“环必须可见”与“抗锯齿导致像素数浮动”。

测试拆解:captureHttpCollectionSearchFocusRing

DIFF-428 的核心交付就是ToolbarFocusCaptureTest.captureHttpCollectionSearchFocusRing。该测试类位于 next-compose/composeApp/src/desktopTest/kotlin/com/rememberber/mootool/next/compose/ui/workbench/ToolbarFocusCaptureTest.kt,整条测试的流程可以拆成五步:

  1. 搭场景:runDesktopComposeUiTest(width = 280, height = 120)起一个 280×120 的 Compose 测试画布;MooTheme(preference = Light, interfaceStyle = "modern")固定使用 light + modern 主题,保证 focusRing 一定是0xFF316DC0。
  2. 渲染目标组件:以Modifier.width(210.dp)渲染MooCompactSearch,placeholder = zh.t("common.search"),并通过fieldModifier.focusRequester(searchFocus)挂上FocusRequester、testTag("httpCollectionSearchField")。210dp 就是对齐 HTTP 集合列的真实宽度——这解释了 DIFF-428 标题里“210dp 宽对齐 HTTP 集合列”的含义。
  3. 请求焦点:runOnIdle { searchFocus.requestFocus() }后waitForIdle(),让 Compose 完成焦点转移与重绘。
  4. 截图:onRoot().captureToImage().toAwtImage()捕获画布并写为 PNG,输出到docs/evidence/2026-09-15-inspector-screencapture/windows/132-compose-http-collection-search-tab-focus.png;同时用File.length() > 500校验产物非空。
  5. 像素断言:countRingPixels(image, 0x316DC0) >= 8,不足 8 个命中像素则测试失败,直接阻断回归。

其中countRingPixels是一个朴素的整图扫描(#L694-L702):遍历每个像素,取 RGB 与目标颜色比较(image.getRGB(x, y) and 0x00FFFFFF == rgb)。这张 210×62 的证据帧本身也证实了测试画布最终被裁剪为 210dp 宽、62px 高的紧凑尺寸。

输出图片:132-compose-http-collection-search-tab-focus.png(Compose 场景中 210dp 宽MooCompactSearch的 accent 焦点环帧)。

作为对照,产品主窗同一位置的 AX 帧是 118-http-search-tab-focus.png(真实窗口截图,HTTP 页集合搜索可见焦点环)。

证据目录 results.md 对这两帧的定位写得很明确:132是“Compose 场景 HTTP 集合MooCompactSearch(210dp,DIFF-428,与118互补)”,即产品窗帧与 Compose 场景帧互为补充。

从 127 到 132:一套可复用的证据帧模式

ToolbarFocusCaptureTest并不是只为 HTTP 集合搜索写的孤立测试,而是一个不断扩展的回归家族。同文件里还包含了:

  • captureCommandSearchAndJsonCopyFocusRings→ 帧127/128(命令盘搜索占位、JSON 复制按钮,见 DIFF-097);
  • captureJsonVaultSearchFocusRing→ 帧131(JSON Vault 搜索,见 DIFF-427);
  • captureHostProfileSearchFocusRing→ 帧133(Host 方案列表搜索,220dp,见 DIFF-429);
  • captureHttpSendAndTranslationNowFocusRings→ 帧129/130(HTTP 发送、翻译按钮的p5Toolbar焦点环,见 DIFF-107)。

所有用例共用同一套“FocusRequester → requestFocus → captureToImage → countRingPixels”骨架,只是组件、宽度与目标颜色不同。这意味着:一旦在 UI 层新增一个可聚焦控件,都可以按同样的模板补一条场景帧测试,而不必重新设计验证方案。

这套帧的编号体系与产品窗帧(如116~126)区分开:127之后的编号专门留给 Compose 场景,避免与手工截取的产品窗 AX 帧混淆。同时文档反复强调(见 ToolbarFocusCaptureTest.kt):这是 Compose 场景帧,不是产品主窗,也不能代替系统 IME 走查。

顺带清理:JSON Vault 新建文件对话框的冗余 noteOwnWrite

DIFF-428 还携带了一个看似无关、实则同一治理主题的清理项:“JSON Vault 新建文件对话框去掉冗余noteOwnWrite(onSuccess已rebaselineAfterLocalCrud)”。

要理解这句话,需要回顾 Vault 磁盘监视器的工作机制。VaultRevisionMonitor(见 VaultRevisionMonitor.kt)以 400ms 间隔轮询 Vault 目录快照(snapshot()对每个文件计算 SHA-256 指纹),再与上一轮previous做diff()。如果本机自己的写盘动作不登记,就会被下一轮轮询当作“外部变更”,触发多余的notify*VaultTreeChanged与 Git 角标刷新。

两个配套手段分别是(见 VaultMonitorOwnWrite.kt):

  • noteOwnWriteJsonVaultFile(vault, path):写盘后把“相对路径 → 新哈希”登记进expectedHashes,下一轮轮询命中即剔除该路径,不当作外部变更(DIFF-422 起引入);
  • rebaselineAfterLocalCrud():包装VaultRevisionMonitor.rebaseline(),直接把previous = snapshot()整体重拍基线并清空expectedHashes,适用于删除、移动、重命名等路径集合发生变化的 CRUD 场景(DIFF-425 起引入)。

而 JSON Vault 新建文件的确认逻辑在 JsonScreen.kt:runCatching { ... container.jsonVault.createFile(path, body) ... }的onSuccess分支统一调用monitor.rebaselineAfterLocalCrud()。新建文件属于“新增路径”,rebaseline已经覆盖了整棵树的基线,之前额外单独noteOwnWrite同一路径就成了冗余操作——这正是 DIFF-428 清理的冗余。这个改动遵循 DIFF-425/426 确立的原则:“同路径保存/单文件导入仍用noteOwnWrite;新建/删除/移动/重命名等路径集合变化统一走rebaseline”。

验证:一条命令回归整套证据

DIFF-428 的验证入口是:

./gradlew :composeApp:desktopTest --offline

在 DIFF-428 记录时,本机该命令全量为534/534通过(JDK 21,离线模式);此后该测试类继续演进,到 DIFF-097 之后全量已增长到 263/263、后续切片阶段更扩展至千余用例(见 acceptance.md),但每次改动记录都会标注当时的全量通过数,保证证据可追溯。也可以只跑焦点类测试做快速定位:

./gradlew :composeApp:desktopTest --offline --tests '*ToolbarFocusCaptureTest*'

这一模式的优点在于:--offline保证构建不依赖网络;captureToImage在无头/桌面 Compose 测试环境中即可运行,因此像素断言可以作为常规单测进入 CI,而不是依赖人工截图流程。

结论与边界

DIFF-428 代表了一种可复制的工程实践:把“看得见的 UI 行为”编码为可自动断言的证据帧。其要点可归纳为:

  1. 用MooCompactSearch.fieldModifier暴露focusRequester,让测试能主动聚焦内部输入框;
  2. 用MooTheme(preference = Light, interfaceStyle = "modern")固定主题,使focusRing稳定为0xFF316DC0,从而可以用像素计数断言焦点环;
  3. 以210dp复刻真实 HTTP 集合列宽度,让场景帧与产品窗帧118具有可比性;
  4. 证据帧写入docs/evidence/2026-09-15-inspector-screencapture/windows/,与127–133系列场景帧形成统一编号体系,并与 AX 帧互补;
  5. 同时治理 Vault 监视器的写盘登记策略,避免“本机动作被误判为外部变更”。

同时必须强调其边界:这些帧是 Compose 场景帧,只能回归 UI token 与焦点环渲染,不能代替产品主窗 AX 帧,更不能代替系统 IME、托盘 TCC 等需要真实窗口与系统能力的手工验收。这正是 acceptance.md 中“Compose UI 测试不能代替 Swing 编辑器内部节点与安装镜像验收”的层级原则在焦点验证上的具体体现。

  • 桌面应用
  • 开发工具

【免费下载链接】MooTool

Handy tool set for developers

项目地址:https://gitcode.com/gh_mirrors/mo/MooTool
点击查看免费下载
上一篇:ho/homebrew-dotnet-sdk-versions预览版使用指南:抢先体验.NET最新特性
下一篇:终极游戏地图导航解决方案:Game Map Tracker双引擎技术深度解析

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询