- 桌面应用
- RPA
- 计算机视觉
【免费下载链接】ZenlessZoneZero-OneDragon
绝区零 一条龙 | 全自动 | 自动闪避 | 自动每日 | 自动空洞 | 支持手柄
本文以 docs/develop/testing/README.md 及其配套文档 fixture_controller.md 为主体,结合主仓
src/one_dragon/base/operation/下的操作框架源码(operation.py、operation_edge.py)与「随便观」示例应用源码,讲解绝区零一条龙项目(ZenlessZoneZero-OneDragon)的自动化测试方法论。读者将掌握:测试代码放在哪里、如何运行、测试基建长什么样、如何判断一个 app/op 该写哪些测试(完整性判据)、如何写单节点单元测试与多帧流程测试,以及提交与代码规范。
1. 测试在哪里、怎么跑
测试代码不在主仓,而在独立仓zzz-od-test(主仓.gitignore忽略它,clone 到主仓根目录使用)。docs/develop/testing/目录的作用是记录怎么跑测试 + 测试基建 + 怎么判断写哪些 + 怎么写。
1.1 仓库布局
- 测试仓
zzz-od-testclone 到主仓根目录,IDE 中把zzz-od-test/设为Test Sources Root; - 主仓不保留测试(历史
tests/已废弃),新测试统一加到zzz-od-test/test/对应被测包的路径下; - 环境变量:把
zzz-od-test/.env.sample复制到主仓根目录为.env(部分测试需要;如果本地只跑自己改动的部分,可以不配全)。
1.2 运行测试
uv run --env-file .env pytest zzz-od-test/该命令基于项目使用的 uv 工具链(参见根目录 pyproject.toml 与 uv.lock),以.env注入环境变量后对整个测试仓执行 pytest。
2. 测试基建(zzz-od-test/test/conftest.py)
所有依赖识别能力的测试共享同一套基础设施,核心是三件套:
2.1test_contextfixture(session 级)
- 运行
ctx.init(),加载真 OCR与真 screen_info(即 assets/game_data/screen_info/ 下的画面定义); - 注入
MockController; - 全程只初始化一次,session 内复用;
- 使用
current_instance_idx=99隔离测试配置写盘,避免污染真实用户配置。
2.2 mock 下一帧
test_context.add_mock_screenshot(img):把任意一张图设为 controller 的下一帧;test_context.mock_screen(screen_name, state):从测试仓截图存档screens/<screen>/<state>.webp读取一帧并设上(存档说明见 screenshot_archive.md)。
2.3MockController
screenshot()返回 mock 帧;click()返回 in-bounds 布尔值(不真正点击),用于断言点击动作是否落点在合法范围内。
3. 完整性方法论:拿到 app/op,怎么判断写哪些测试
这是文档的核心章节,解决「一个 app/op 的测试怎样才算完整」的问题。方法论先给出一句话判据,再展开为「动作一(适用所有节点)+ 动作二(判定是否需要流程测试)」两套动作。
3.1 完整性判据(一句话)
一个 app/op 的测试完整,当且仅当:
- 每个节点的每个分支都有单测(动作一,适用所有节点);且
- 返回
round_wait或用until_*的节点额外有流程测试(动作二);且 - status 契约在写测试时人工对照下游
@node_from(动作一第 3 步)——不写对账工具。
注意边界:拓扑合法性(节点/边/start 唯一)由框架加载时校验(operation.py
_analyse_node_annotationsL240-292 与_init_networkL294-342,如「存在多个起始节点」「找不到节点 xx」等 ValueError),不计入测试职责。
3.2 动作一:数全分支 + 完整覆盖 + 对照下游(适用所有节点)
对每个@operation_node节点,先确定它的分支集合(= 可能返回的 status 集合)。节点有四种写法,分支来源各不相同:
(1)显式字面量:body 里有round_success/wait/retry/fail(status='xxx')→ 这些xxx就是分支。
(2)薄包装:return self.round_by_X(...),body 无显式 status 字面量 → 分支 =helper 返回契约。常见契约(可在 operation.py 中确认源码实现):
| helper | 返回契约 |
|---|---|
round_by_ocr_and_click_by_priority(targets) | round_success(status=匹配的 target)/round_retry('未匹配到目标文本')(L1227 起) |
round_by_find_and_click_area(screen, area)(无 until_) | 点击成功round_success(area_name)/ 未找到·点击失败round_retry/ 区域未配置round_fail(L894 起) |
round_by_find_and_click_area(screen, area)(带 until_) | 点击成功先round_wait(area_name),下一轮 until 满足后round_success(area_name) |
round_by_find_area(screen, area) | round_success(status=area_name)/ 未命中round_retry(L993 起) |
⚠️ area 类 helper 另有一个
round_fail('区域未配置 area')分支(配置错误,通常不测,但数分支时要知道它存在)。表中为非完整列表,round_by_ocr_and_click_with_action还能返回round_wait,round_by_click_area、round_by_goto_screen等请以 operation.py 源码为准。
(3)混合(显式守卫 + 薄包装):先if 守卫: return round_X(status='显式字面量'),再return self.round_by_Y(...)。此时分支 =显式 statuses ∪ helper 契约,不能只数 helper 那部分。源码示例:click_squad_team节点先not self.claim→round_success('跳过收获'),再走 ocr_and_click。
(4)委托子 op:round_by_op_result(op.execute())→0 自有分支,进子 op 测。委托分支的路由正确性依赖子 op 的测试——其下游@node_from(status=具体)边的 status 来自子 op 返回,对照时查子 op。
每个分支一个单测
mock 该分支输入(先读节点/helper 检测什么——OCR 文本?area?config?status 名是线索)→ 调节点 → 断言返回status+ 动作。要点:
- 断言类型按返回走,不要照搬
is_success:round_success/round_by_find_area命中 =True;round_wait/round_retry=False看 status; - 薄包装节点:mock 让 helper命中 / 不命中两种帧,断言两种返回;
until_find_all/until_not_find_all多帧场景:同一 op 实例连续调 2 次节点方法。
完整覆盖 + 对照下游(契约兜底,关键)
- 完整覆盖:上面数出的每个分支都要有单测断言 status,一个不漏;
- 对照下游:grep 本节点的所有
@node_from(from_name='本节点', ...),把「下游声明的 status」与「本节点返回的 status」对比。⚠️只比round_success/round_fail的 status——round_wait/round_retry是节点内自循环/重试,框架 WAIT/RETRY 直接 continue 同节点,不经@node_from,不进下游边(对应源码 operation.pyexecuteL412-512 中的if round_result.result == OperationRoundResultEnum.WAIT: continue与 RETRY 分支):- 下游
status=X声明的 X,本节点 success/fail 会返回吗?没有 =死边(拼错,或漏测分支); - 本节点 success/fail 返回的 X,有下游接吗?没有
status=X边不一定是死 status——可能被ignore_status=True兜底边接收(框架无精确匹配时走兜底,见 operation_edge.py L36-41 与_get_next_nodeL550-588 的final_next_node_id逻辑);都没有才是死 status(或 X 是终态——人工判); - 失败路由边:
@node_from(success=False)带具体 status的(status 来自子 op 失败/超时),确认其在失败场景可达(流程测试 mock 子 op 失败覆盖);多数失败边用默认ignore_status(status 无关,安全)。
- 下游
⚠️ 动作一适用所有节点,包括动作二判为「要画面变化」的。它们的每个分支也能单帧 mock 测(mock 一帧 → 调节点 → 断言返回
round_wait/success)——单帧 mock 跑完整execute()会卡死,但直接调节点方法不会。
3.3 动作二:这个节点要不要额外流程测试?
信号:节点会返回round_wait(= 下一轮重跑自身,期待画面演进),或用了until_find_all/until_not_find_all。
- 有→ 额外用
FixtureController(「会换帧的假游戏」)跑execute(),覆盖「点 → 等画面变 → 再识别」的推进/轮询(动作一单测覆盖不到的运行时推进)。注意五个坑:运行态前置 / 看门狗 / 恢复 clickon_click_in/ OpenGame 排除 / 剪贴板规避(详见 fixture_controller.md); - 没有(看一眼就返回,或只
round_retry同帧重试)→ 动作一的单测够; - ⚠️
round_retry不是画面变化信号(同帧重试,点击失败 / 未找到)。
流程测试是动作一的补充,不是替代:被判为需要流程测试的节点,仍要按动作一逐分支写单测。
3.4 完整度自检 checklist
- 每节点每分支(含薄包装的 helper 契约分支)都有单测?
- 返回
round_wait/ 用until_*的节点额外跑了流程测试? - 每节点写测试时对照了下游
@node_from(死边 / 死 status)?
3.5 status 契约:方法论约束(不写对账工具)
- 靠动作一「对照下游」:写每个节点测试时 grep 其
@node_from对照返回 status,死边/死 status 当场发现; - 为什么不写对账工具:静态猜 body 上游 status 不可靠——薄包装 status 来自 helper 的
target_cn/area_name,round_by_op_result透传子 op status,静态分析会误报漏报;单测驱动版仍要约定断言写法 + 方法↔节点名映射 + 处理终态噪声,成本/可靠性比不好。advisory + code review 兜底足矣; - 残余风险(诚实):无自动化持续保证,靠人/AI 写测试时对照 + review 二次兜底;薄包装节点「数全分支」必须查 helper 返回契约(动作一已述),否则对照失效。
3.6 示例:随便观(两动作走一遍)
以主仓 suibian_temple_app.py(随便观 App)为例,完整走一遍两动作:
| 节点 | 动作一:分支 | 动作二:返回 round_wait/until_*? | 写什么测试 |
|---|---|---|---|
check_initial_screen(L50-58) | 2(显式:是入口 / 不在) | 否 | 2 单测 |
handle_auto_manage/yum_cha_sin/good_goods/boo_box/pawnshop | 各 1(config 显式:'未开启…') | 否 | 各 1 单测 |
goto_suibian_temple(L66-96) | ~5(wait(result.status)/success(current_screen)/success('开始托管')/wait('返回')/retry('未识别当前画面')) | 是(ocr_and_click → 手写 round_wait) | 5 单测 + 流程测试 |
goto_adventure(子 op) | 薄包装(find_and_click_area + until_not_find_all) | 是 | 单测(until 连调 2 次)+ 流程测试 |
click_squad_team(混合) | 3('跳过收获' + ocr 命中/未命中) | 否 | 3 单测 |
click_finish/click_claim/click_confirm(纯薄包装) | 2(ocr 命中/未命中) | 否 | 各 2 单测 |
handle_adventure_squad/adventure_squad_2/craft/sales_stall/goto_category/back_at_last | 纯委托(0 自有分支) | — | 进子 op 测,不单列 |
结论:随便观应写 = 单测(app 层 config/入口/goto_suibian_temple各分支 + 子 op 薄包装/混合节点分支)+ 流程测试(goto_suibian_temple/Transport/BackToNormalWorld/goto_adventure,要 FixtureController + 凑帧,因实拍成本搁置)。
4. 测什么:归属与判据(支撑第 3 节的「为什么」)
| # | 测什么 | 归属 | 判据 | 载体 |
|---|---|---|---|---|
| 1 | 拓扑合法性 | 框架加载校验 | 不用 app 测 | 框架 |
| 2 | 单节点识别/决策(含薄包装 helper 契约分支) | 简单 mock 单测 | 第 3 节动作一 | 第 5 节 |
| 3 | 流转/轮询/重试/恢复(返回 round_wait / until_*) | FixtureController | 第 3 节动作二 | 第 6 节 |
| 4 | status 契约 | 单测断言(上游)+ 对照下游@node_from | 第 3 节动作一「对照下游」 | 第 3 节 |
判据原则:流转不能全交给底层(框架只管拓扑,不管 status 匹配与画面推进);流程测试只给「返回 round_wait / until_*」的节点,不是每个 app 端到端。
5. 怎么写:单节点测试
5.1 简单 / 单节点测试(常用)
范式:mock 一帧 + 直接调 op 的节点方法 + 断言。示例(对应test_suibian_temple_app.py::test_check_initial_screen_in_temple):
def test_xxx(test_context): test_context.mock_screen('打开游戏', 'ready') # 设一帧 op = SomeOp(test_context) op.screenshot() # 取 mock 帧 → last_screenshot result = op.check_screen() # 直接调节点 assert result.status == '打开游戏'覆盖:识别 + 单节点决策/分支。
5.2 断言:看 node 返回类型(别照搬 is_success)
node 返回的OperationRoundResult类型决定is_success,写断言前先读 node 代码确认返回类型:
| node 返回 | is_success | 断言用 |
|---|---|---|
round_success/round_by_find_area命中 /round_by_ocr_and_click命中 | True | assert result.is_success |
round_wait(点 area / click 后等下一轮) | False | assert result.status == '<匹配词>' |
round_retry(未识别重试) | False | assert not result.is_success或status |
不同 app 同类 node 返回类型可能不同,别照搬别的 app 的断言。源码上,round_success/round_wait/round_retry/round_fail分别构造OperationRoundResultEnum.SUCCESS/WAIT/RETRY/FAIL结果(operation.py L789-851)。
5.3 mock 哪帧:先读 node 逻辑 + status 名线索
测哪个 node、mock 哪帧,取决于该 node 在哪帧检测什么。读 node 代码确认 OCR/area 的检测目标画面 + 元素,别臆测:
- status 名是线索:如
round_success(status='已在邻里街坊-进入好物铺')暗示检测的是邻里街坊菜单的「好物铺」选项(进好物铺前置),不是好物铺画面的标题 logo → mock 邻里街坊菜单; - OCR/area 检测目标:node 的
round_by_ocr('X')/round_by_find_and_click_area(screen, area)检测的是哪种画面的什么元素——决定 mock 哪帧; - 失败别急着归因绕过:测试失败先回 node 代码确认检测目标,别直接「OCR 不到 → 换图绕过」。⚠️注意 LCS 误匹配:mock 帧里若含与 target 部分相似的文字(如入口「游历」tab vs target「游历小队」),LCS 会误命中(
str_utils.find_by_lcs的匹配阈值默认 0.5,见 operation.py L1210)——换个不含干扰文字的帧。
5.4 多分支用例:参数化 vs 独立方法(按逻辑同质性)
一个节点的多个分支,按分支逻辑是否同质选写法(pytest 行业惯例,旧约定非教条):
- 逻辑同质(同一套 mock → 调节点 → 断言,只输入/期望不同)→参数化
@pytest.mark.parametrize+ 数据表;每行一个分支,带ids=让失败用例名可读。例:某节点按标题 OCR 分多个子态,识别逻辑一样、只是标题 + 期望标志不同 → 一张表搞定(同质硬拆独立方法反而是反模式); - 逻辑异质(断言类型不同
is_successvsstatus/ 搭建不同 / 改 config / mock 子 op)→独立方法test_<场景>_<分支>,每个方法断言自己的返回。
「每个分支一个单测」:参数化每行 / 独立方法每个,都算一个单测,两种都满足。下方「薄包装节点:命中/不命中」是异质例子(命中看is_success、不命中看status→ 拆独立方法);同质多分支用参数化。
5.5 薄包装节点:mock 命中 / 不命中两帧
薄包装节点(return self.round_by_X(...))按 helper 契约(第 3 节动作一)mock 两种帧:
- 命中:mock 含 target/area 的帧 →
round_success(status=匹配词); - 不命中:mock 不含 target 的帧 →
round_retry('未匹配到目标文本')。
混合节点(守卫 + 薄包装)额外测守卫分支,例如claim=False→'跳过收获'。
5.6 config 分支:monkeypatch 改 config(不写盘)
config 开关分支用monkeypatch.setattr(ConfigClass, '字段', 值)改 config(不写盘、自动还原、实例 99 隔离)。⚠️ 这些'未开启'/'未开启自动托管'等 status 串是下游@node_from的匹配词,断言它们 = 守 app 编排边契约。
5.7until_find_all/until_not_find_all:同一 op 实例连续调 2 次(多帧 mock)
round_by_find_and_click_area(until_*=...)这类「click 后等画面变化」的 node(源码见 operation.py L934-966:node_clicked为 True 时才做 until 校验,否则先 click 并置node_clicked=True、返回round_wait),用last_screenshot验证 +node_clicked标志,需两轮:
- 第 1 轮(
node_clicked=False):mock click 前画面 → click →round_wait(status=area); - 第 2 轮(
node_clicked=True):mock click 后画面 → until 验证 area 消失/出现 →round_success。
测试在同一 op 实例上连续调 node 2 次(不经 runner——_reset_status_for_new_node只在 runner 进新 node 时重置node_clicked,手动连调保持标志,对应源码 operation.py L590-599)。示例:
op = SomeOp(test_context) test_context.mock_screen('随便观', '入口-手动态') # 第 1 帧:click 前 op.screenshot() r1 = op.goto_adventure() # click 按钮-游历 → round_wait assert r1.status == '按钮-游历' test_context.mock_screen('随便观', '游历') # 第 2 帧:click 后 op.screenshot() r2 = op.goto_adventure() # until 验证 → round_success assert r2.is_success6. 怎么写:流程测试(FixtureController)
6.1 是什么
FixtureController(MockController子类,位于zzz-od-test/test/harness/fixture_controller.py)= 一个「会反应的假游戏」:
screenshot()返回当前 phase 的固定截图;click/input/press_key记录动作 + 按剧本推进到下一 phase;- 配真 ctx(真 OCR + screen_info),op 的识别/流转/决策在固定帧上跑。
何时用(第 3 节动作二):op 有返回round_wait或用until_*的节点(流转/轮询/重试/恢复/多帧状态机),才需要跑完整execute()(对应源码 operation.pyexecuteL412-512 的多轮循环)。线性派发(每节点看一眼就返回)不用——动作一的单测够。示例:test_enter_game_flow.py(EnterGame 自动登录全流程:ready → 点进入游戏 → 登录服务器中 → ... → 大世界)。
覆盖:op 的流程逻辑(节点图边、轮询、恢复分支)——单节点测试覆盖不到的部分。
6.2 剧本(phase)
有序 phase 列表,每 phase = 一帧 + 退出条件:
on_click_in(region):click 落在 region(查 screen_info 的 areapc_rect,或剧本给坐标 rect)才推进。对有恢复性 click 的节点强制用这个(避免恢复 click 误推进);on_action:任何 click/input 推进。只用于「该 phase 唯一流程 click、无恢复 click」;on_polls(n):screenshot()调 n 次后自动推进(模拟游戏自动流转,如登录服务器中 → 大世界,无用户动作)。最脆弱(见 6.3 看门狗坑),优先少用。
末 phase 默认粘住(多余 sense 一律返末帧,抗轮询次数漂移)。
6.3 关键坑(必读)
运行态前置:op 的
execute()首轮查is_context_stop(_run_state==STOP默认)→ 直接退出(源码见 application_run_context.py L309 与 operation.py L430)。is_context_stop是只读属性(无 setter)。测试execute()前直接置ctx.run_context._run_state = RUNNING;finally复位(STOP+ctx.run_context.event_bus.unlisten_all_event(op)——sessiontest_context复用,不复位会污染后续测试)。用 harness 的enter_running_state(ctx, op)/reset_running_state(ctx, op)。看门狗(必须):op 框架的 round 上限只管 RETRY,WAIT 无上限(源码 L474-485:RETRY 受
node_max_retry_times限制,WAIT 直接continue)——剧本对不齐会在 WAIT 段死循环。用WatchdogOperationMixin(覆盖_execute_one_round,不是execute()),轮次上限 → 置_run_state=STOP,loop 下轮退出。恢复性 click + on_click_in:op 有些节点在 retry/recovery 时也 click(如 EnterGame
check_screen卡登录时点「国服-返回按钮」恢复)。这些 click不能推进 phase——该 phase 用on_click_in(期望的流程 area),只认流程 click,忽略恢复 click。OpenGame 排除:
OpenGame/OpenAndEnterGame会写 Windows 注册表(HDR)+subprocess.Popen拉 exe,FixtureController 拦不住。只测画面驱动的 op(如 EnterGame);is_game_window_ready=True绕过框架注入的 check-window 链(否则会被路由到「打开并进入游戏」调 OpenGame)。剪贴板规避:op 输账号密码可能走
PcClipboard.copy_and_paste(不走 controller,会写真实 OS 剪贴板)。测试 pinctx.game_config.type_input_way = INPUT(走keyboard.type分支,FixtureController 能 mock)。fixture 用monkeypatch改 controller + type_input_way(避免污染 sessiontest_context)。
6.4 参考实现
zzz-od-test/test/harness/fixture_controller.py(FixtureController + WatchdogOperationMixin + 运行态 helper);zzz-od-test/test/zzz_od/operation/enter_game/test_enter_game_flow.py(EnterGame 自动登录流程测试 + on_click_in 门控单测)。
7. 提交坑(重要)
测试文件在zzz-od-test独立仓。主仓git add zzz-od-test/...会被.gitignore静默跳过(不报错但未加入)。必须在测试仓内提交(协作边界说明见 AGENTS.md「提交流程与协作边界」):
git -C zzz-od-test add test/ && git -C zzz-od-test commit -m "..."8. 代码规范 / fixture 格式
8.1 代码规范(项目特有,过自检筛)
- 文件路径:测试文件放在被测文件的包路径 + 被测文件名的文件夹下(如被测
one_dragon/base/operation/one_dragon_context.py→zzz-od-test/test/one_dragon/base/operation/one_dragon_context/); - 单方法文件:每个测试文件专门测试单个方法的各种场景;
- fixture scope:用
pytest.fixture管理依赖;注意指定scope(如 session 级test_context只 init 一次); - 导入:不用
src(from one_dragon.base.operation import Operation✓;from src.one_dragon...✗); - 异步超时:异步测试方法必须加超时(如
@pytest.mark.timeout(3)),防止无限挂起。
8.2 测试 fixture 图:尽量 webp q90
测试 fixture 的整屏截图默认转 webp q90(省约 90% 体积,整屏识别无损效)。原则:满足测试为准——转后跑测试,过的留 webp;实测不过的保留 PNG。
- 能压:整屏画面匹配 / 事件识别(容差大);
- 保留 PNG:精度敏感(小地图角度)、含细文字 OCR(webp q90 致 OCR 空)、模板裁剪源(webp lossy → 裁剪放大 artifacts → 模板 conf 降);
- 转换:
cv2.imencode('.webp', img, [cv2.IMWRITE_WEBP_QUALITY, 90])+ndarray.tofile(path)(中文路径安全,非cv2.imwrite);批量转换可参考 convert_to_webp.py。原 PNG 保留,确认无引用且测试过后手动删; - 改引用:转后同步改测试代码
.png→.webp(保留 PNG 的不改)。
截图存档(zzz-od-test/screens/<screen>/<state>.webp)的格式约定与加图流程详见 screenshot_archive.md:webp q90 整屏识别无损(conf 损耗 <0.006)、1080p 原生不缩放以对齐 screen_info 的pc_rect坐标、文件名 = 子态可读名、冒号转下划线等。
9. 方法论小结
这套测试方法论的核心闭环是:从 app/op 出发 → 动作一逐节点数全分支写单测并对照下游@node_from守 status 契约 → 动作二对返回round_wait/用until_*的节点补 FixtureController 流程测试 → 自检 checklist → 在独立仓提交。它与操作框架的运行时语义(WAIT/RETRY 同节点自循环、node_clicked标志、ignore_status兜底边、拓扑加载校验)严格对齐,既不重复框架职责(拓扑校验),也不把流转逻辑全部交给底层,是一套「框架只管拓扑、测试守 status 与画面推进」的分层测试策略。
- 桌面应用
- RPA
- 计算机视觉
【免费下载链接】ZenlessZoneZero-OneDragon
绝区零 一条龙 | 全自动 | 自动闪避 | 自动每日 | 自动空洞 | 支持手柄
相关推荐
如何不再拖窗口边缘:macOS 窗口管理工具 Loop 的 3 步上手
如何不再拖窗口边缘:macOS 窗口管理工具 Loop 的 3 步上手 你肯定也遇到过这种场面:想把两个窗口切成三栏,拖了 5 分钟窗口边缘还是对不齐;切屏之后
桌面应用终极BottomNavigation测试指南:从单元测试到UI自动化的完整实践方案
终极BottomNavigation测试指南:从单元测试到UI自动化的完整实践方案 BottomNavigation是一个帮助开发者轻松实现Google底部导航
语言运行时后端开发工具包管理器前端构建测试Huma框架深度解析:Go语言中构建现代化REST API的完整指南
Huma框架深度解析:Go语言中构建现代化REST API的完整指南 Huma是一个专为Go语言设计的REST/HTTP API框架,它提供了OpenAPI 3
后端开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考