雲端 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 環境中執行。實際可用狀態請以控制台即時回報為準。

繼續確認建置環境

比對機型規格與操作說明,決定下一步設定。