编辑器在本地能撤销,接入云端 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 会在撤销阶段把这次注册放入重做栈。直接赋值只恢复数据,不能建立对应的重做操作。
模拟器测试能替代触摸手势验收吗?
不能。模型测试可以固定分组、历史栈和状态恢复契约;模拟器界面测试可以检查按钮行为。手势是否自然、连续拖拽的操作边界是否符合用户预期,还需要真机验收。