Как проверить отмену и повтор действий в редакторе iOS

Как проверить отмену и повтор действий в редакторе 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 имеет значение false. Новое редактирование создало отдельную ветку истории, поэтому повтор отброшенного пути больше не должен быть доступен.

Второй сценарий: последовательно создайте две группы, изменив позицию в каждой. Одна отмена должна вернуть состояние перед началом второй группы, а не сразу исходное состояние. Только вторая отмена должна восстановить начальное значение. Такой тест выявляет случаи, когда обработчик кнопки забывает закрыть группу.

Третий сценарий: передайте то же значение. Ранний выход в примере должен гарантировать, что в историю не добавится новая действительная операция. На новой модели выполните изменение с тем же значением и проверьте, что canUndo имеет значение false. Недостаточно проверить лишь неизменность значения: пустая операция в истории всё равно может ошибочно сделать кнопку активной.

Добавляйте проверки после каждой операции, а не только в конце сценария. Две ошибочные операции тоже могут случайно привести к правильному итоговому значению.

Контролируйте границы документа и асинхронных операций

При переключении документа предпочтительно создавать для него новую модель и новый UndoManager. Если экземпляр необходимо использовать повторно, сначала завершите текущее редактирование, затем очистите историю и загрузите новое значение. Обработчики старого документа не должны иметь возможности изменить новый документ. Не переключайте объект, пока обработчик отмены ещё выполняется.

В редакторе, работающем длительное время, можно задать levelsOfUndo, чтобы ограничить число групп истории. Но это не ограничение объёма памяти в байтах. Если в качестве прежнего значения сохраняется целое изображение или большой массив, всё равно нужно оценивать объём данных, удерживаемых каждой группой.

Не оставляйте группу открытой на время асинхронного вычисления, выходящего за границы пользовательского жеста. При запуске вычисления сохраните идентификатор документа и номер ревизии. Когда результат придёт, сначала проверьте, что оба значения по-прежнему совпадают; устаревший результат отбросьте. Если проверка пройдена, на главном акторе откройте группу, внесите изменение и закройте её.

Соответствующий тест не должен полагаться на ожидание через sleep. Пусть тестовый двойник сохранит обработчик завершения. После переключения документа или дальнейшего редактирования вызовите его вручную и проверьте, что старый результат не повлиял ни на состояние, ни на историю.

Запустите тесты в облаке и проверьте интерфейс

Добавьте тесты модели в существующий целевой модуль тестов 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

Выберите физический узел, модель Mac и период оплаты. Запускайте сборки Xcode и задачи автоматизации в собственной среде macOS. Актуальный статус доступности отображается в консоли.

Продолжите проверку среды сборки

Сверьте характеристики модели и инструкции, чтобы определить следующие шаги настройки.