云端 Mac 上测试 iOS 撤销与重做:分组与状态一致性

云端 Mac 上测试 iOS 撤销与重做:分组与状态一致性

编辑器在本地能撤销,接入云端 Mac 测试后却出现“一次拖拽要撤销十次”或“撤销成功但不能重做”。先别给按钮补逻辑:把编辑动作、历史记录和界面刷新拆开,验证一个用户动作究竟对应几个历史组。下面用整数位置代替画布坐标,建立可以放进 iOS 单元测试目标的最小契约。

先固定撤销的操作单位

列出三种输入:单次按钮调整、连续拖拽、程序加载文档。按钮调整形成一组;拖拽从开始到结束形成一组;加载文档不进入编辑历史。不要把每一次位置回调都理解为一次用户操作。

UndoManager 默认可以按事件循环自动分组。测试若依赖这层行为,执行时机变化就可能改变组边界。这里关闭 groupsByEvent,由业务入口显式配对开始与结束;生产界面也必须使用同一策略,不能只在测试里关掉。

所有模型修改和历史操作都放到主执行器。开始测试前确认 groupingLevel 为零,不要在未结束的组中执行撤销。取消拖拽究竟恢复起点还是保留当前位置,应先写成产品规则,再实现对应路径。

验收对象不是“按钮可以点击”,而是“撤销一次回到动作前,重做一次回到动作后”。界面显示、模型值和历史栈必须同时符合这个契约。

用同一入口注册反向操作

在测试目标新增文件,替换示例类名即可运行。初始值直接设为零,不调用修改入口;否则初始化也会成为可撤销动作。修改入口保存的是修改前的值,而不是稍后读取目标的当前值。

import Foundation
import XCTest

@MainActor
final class PositionEditor {
    private(set) var position = 0
    let history = UndoManager()

    init() {
        history.groupsByEvent = false
    }

    func setPosition(_ next: Int) {
        guard next != position else { return }
        let previous = position
        history.registerUndo(withTarget: self) { target in
            target.setPosition(previous)
        }
        position = next
    }
}

final class PositionHistoryTests: XCTestCase {
    @MainActor
    func testGroupedDragCanUndoAndRedo() {
        let editor = PositionEditor()
        let history = editor.history

        history.beginUndoGrouping()
        editor.setPosition(10)
        editor.setPosition(20)
        history.endUndoGrouping()

        XCTAssertEqual(history.groupingLevel, 0)
        XCTAssertEqual(editor.position, 20)
        XCTAssertTrue(history.canUndo)

        history.undo()
        XCTAssertEqual(editor.position, 0)
        XCTAssertTrue(history.canRedo)

        history.redo()
        XCTAssertEqual(editor.position, 20)
        XCTAssertFalse(history.canRedo)
    }
}

撤销时,组内操作按反向顺序执行:先回到十,再回到零。回调再次经过修改入口,UndoManager 在撤销阶段把新的反向操作放进重做栈。因此不能为了“避免递归”改成直接赋值;那样数据恢复了,重做记录却丢了。

示例回调由主执行器上的历史操作同步触发。接入实际界面后也要保持这一调用边界,不要把回调转成异步任务,否则注册时可能已经离开撤销阶段。

把历史分支写进断言

先保留上述正向案例,再增加三个独立测试。每个测试新建模型,不能共享上一个用例留下的历史栈。

第一,完成一个组后撤销,再开启新组写入不同值。断言新值生效且 canRedo 为假:新的编辑已经形成历史分支,不应再重做被放弃的路径。

第二,连续建立两个组,分别调整位置。撤销一次只能回到第二组开始前,不能直接回到初始状态;再撤销一次才恢复初始值。这能发现按钮入口忘记结束分组的问题。

第三,提交相同值。示例中的提前返回应保证没有新增有效历史;在全新模型上执行同值修改后,断言 canUndo 为假。不要只检查数值没变,否则空历史动作仍可能让按钮错误亮起。

把断言放在每次操作之后,而不是只检查最终结果。两次错误操作也可能恰好把值恢复到正确终点。

收住文档与异步边界

文档切换时,优先为新文档建立新的模型与 UndoManager。若必须复用实例,应先结束当前编辑动作,再清空历史并加载新值,确保旧文档的回调不能修改新文档。不要在撤销回调尚未完成时切换对象。

长期运行的编辑器可以设置 levelsOfUndo 限制历史组数量,但它不是内存字节上限。保存整张图片或大型数组作为旧值时,仍要评估每组保留的数据量。

异步计算不能跨越用户手势直接保持一个开放组。发起计算时记录文档标识和修订号,结果返回后先检查两者是否仍匹配;过期结果丢弃。通过校验的结果再在主执行器内开启、修改并结束一个完整组。

对应测试不靠休眠等待:让测试替身暂存完成回调,切换文档或继续编辑后再主动触发它,断言状态与历史都没有被旧结果污染。

在云端执行并验收界面

将模型测试加入现有 iOS 测试目标,使用已启动模拟器的设备标识运行;项目路径与 Scheme 换成仓库中的真实值。

set -euo pipefail
: "${SIMULATOR_UDID:?Set SIMULATOR_UDID}"
mkdir -p artifacts
RESULT_PATH="artifacts/undo-$(uuidgen).xcresult"
xcodebuild test \
  -project EditorApp.xcodeproj \
  -scheme EditorApp \
  -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \
  -only-testing:EditorAppTests/PositionHistoryTests \
  -resultBundlePath "$RESULT_PATH"

唯一结果路径避免重复运行覆盖现场。失败时先看值恢复、组边界还是重做能力不符,不要用清空全部历史掩盖错误。

最后补一条界面验收:拖拽后撤销,检查画布位置与按钮状态;重做后再次检查。若模型测试通过而画布未更新,应排查观察通知与界面绑定,而不是重新注册历史。模型契约稳定后,再用真机确认连续手势的分组边界符合用户预期。

常见问题

撤销回调里需要再次注册反向操作吗?

需要。撤销回调通过同一修改入口恢复旧值时,应注册恢复前的值;UndoManager 会在撤销阶段把这次注册放入重做栈。直接赋值只恢复数据,不能建立对应的重做操作。

模拟器测试能替代触摸手势验收吗?

不能。模型测试可以固定分组、历史栈和状态恢复契约;模拟器界面测试可以检查按钮行为。手势是否自然、连续拖拽的操作边界是否符合用户预期,还需要真机验收。

把构建队列交给独享云端 Mac

选择物理节点、机型与计费周期,让 Xcode 构建和自动化任务在自己的 macOS 环境中运行。实际可用状态以控制台实时返回为准。

继续核对你的构建环境

对照机型规格与操作说明,确定下一步配置。