L’annulation fonctionne dans l’éditeur en local, mais les tests sur un Mac cloud révèlent parfois qu’« il faut annuler dix fois pour revenir sur un seul glisser-déposer » ou que « l’annulation réussit, mais le rétablissement est impossible ». Avant d’ajouter de la logique aux boutons, séparez les modifications, la gestion de l’historique et le rafraîchissement de l’interface. Vérifiez ensuite combien de groupes d’historique correspond réellement à une action utilisateur. Nous utiliserons ici une position entière plutôt que des coordonnées sur le canevas pour définir un contrat minimal à intégrer dans une cible de tests unitaires iOS.
Définir d’abord l’unité d’annulation
Distinguez trois types d’entrée : un ajustement ponctuel par bouton, un déplacement continu par glisser-déposer et le chargement d’un document par le programme. Un ajustement par bouton constitue un groupe ; un glisser-déposer, du début à la fin, constitue lui aussi un groupe ; le chargement d’un document ne doit pas entrer dans l’historique des modifications. Chaque rappel signalant un changement de position ne représente pas nécessairement une nouvelle action utilisateur.
Par défaut, UndoManager peut regrouper automatiquement les opérations selon les cycles de la boucle d’événements. Si les tests reposent sur ce comportement, un changement dans le moment d’exécution peut déplacer les limites des groupes. Nous désactivons donc groupsByEvent et ouvrons puis fermons explicitement les groupes dans les points d’entrée de la logique métier. L’interface de production doit appliquer la même stratégie : il ne suffit pas de désactiver ce comportement dans les tests.
Toutes les modifications du modèle et toutes les opérations sur l’historique doivent s’exécuter sur l’acteur principal. Avant de lancer le test, vérifiez que groupingLevel vaut zéro ; n’effectuez aucune annulation tant qu’un groupe est encore ouvert. Définissez également la règle produit pour un glisser-déposer interrompu : faut-il revenir à la position de départ ou conserver la position actuelle ? Implémentez ensuite le comportement retenu.
Le critère d’acceptation n’est pas « le bouton est cliquable », mais « une annulation restaure l’état précédant l’action, et un rétablissement restaure l’état qui la suit ». L’affichage, la valeur du modèle et la pile d’historique doivent tous respecter ce contrat.
Enregistrer l’opération inverse par le même point d’entrée
Ajoutez un fichier à la cible de tests et adaptez les noms de classes de l’exemple pour l’exécuter dans votre projet. La valeur initiale est directement fixée à zéro, sans passer par la méthode de modification ; sinon, l’initialisation deviendrait elle aussi une action annulable. Cette méthode doit mémoriser la valeur précédant la modification, et non lire plus tard la valeur courante de la cible.
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)
}
}
Lors de l’annulation, les opérations du groupe sont exécutées dans l’ordre inverse : la position revient d’abord à dix, puis à zéro. Le rappel repasse par la méthode de modification ; pendant la phase d’annulation, UndoManager place les nouvelles opérations inverses dans la pile de rétablissement. Il ne faut donc pas remplacer cet appel par une affectation directe pour « éviter la récursion » : les données seraient restaurées, mais les opérations nécessaires au rétablissement seraient perdues.
Dans cet exemple, les opérations sur l’historique déclenchent le rappel de manière synchrone sur l’acteur principal. Conservez ce mode d’appel lors de l’intégration à l’interface réelle. Ne transformez pas le rappel en tâche asynchrone : au moment de l’enregistrement, la phase d’annulation pourrait déjà être terminée.
Vérifier les branches de l’historique avec des assertions
Conservez le cas nominal ci-dessus, puis ajoutez trois tests indépendants. Chaque test doit créer son propre modèle, sans réutiliser la pile d’historique laissée par le précédent.
Premier cas : terminez un groupe, annulez-le, puis ouvrez un nouveau groupe pour définir une autre valeur. Vérifiez que la nouvelle valeur est appliquée et que canRedo est faux. Cette nouvelle modification crée une branche dans l’historique : le chemin abandonné ne doit plus pouvoir être rétabli.
Deuxième cas : créez successivement deux groupes, chacun modifiant la position. Une seule annulation doit ramener à la position précédant le deuxième groupe, et non directement à l’état initial. Seule une seconde annulation doit restaurer la valeur initiale. Ce test permet de repérer un point d’entrée de bouton qui oublie de fermer son groupe.
Troisième cas : soumettez une valeur identique à la valeur actuelle. Le retour anticipé de l’exemple doit empêcher l’ajout d’une opération effective à l’historique. Sur un modèle neuf, effectuez cette modification à valeur identique, puis vérifiez que canUndo est faux. Ne vous contentez pas de vérifier que la valeur n’a pas changé : une opération vide dans l’historique pourrait tout de même activer le bouton à tort.
Placez les assertions après chaque opération, plutôt que de vérifier uniquement le résultat final. Deux opérations incorrectes peuvent, par hasard, ramener la valeur au résultat attendu.
Maîtriser le cycle de vie des documents et les traitements asynchrones
Lors d’un changement de document, privilégiez la création d’un nouveau modèle et d’un nouvel UndoManager pour le document suivant. Si vous devez réutiliser les instances, terminez d’abord l’action de modification en cours, puis videz l’historique et chargez la nouvelle valeur. Les rappels de l’ancien document ne doivent pas pouvoir modifier le nouveau. Ne changez pas d’objet tant qu’un rappel d’annulation n’est pas terminé.
Pour un éditeur utilisé sur de longues sessions, levelsOfUndo permet de limiter le nombre de groupes conservés dans l’historique, mais ce n’est pas une limite de mémoire en octets. Si vous mémorisez une image entière ou un tableau volumineux comme ancienne valeur, évaluez aussi la quantité de données retenue par chaque groupe.
Un calcul asynchrone ne doit pas maintenir un groupe ouvert au-delà du geste utilisateur. Au lancement du calcul, mémorisez l’identifiant du document et son numéro de révision. À la réception du résultat, vérifiez d’abord que les deux correspondent toujours ; sinon, ignorez le résultat devenu obsolète. Pour un résultat encore valide, ouvrez un groupe sur l’acteur principal, appliquez la modification, puis fermez ce groupe.
Les tests correspondants ne doivent pas attendre au moyen d’une pause temporisée. Faites conserver le rappel de fin par une doublure de test, changez de document ou poursuivez les modifications, puis déclenchez vous-même ce rappel. Vérifiez que ni l’état ni l’historique n’ont été altérés par l’ancien résultat.
Exécuter les tests dans le cloud et valider l’interface
Ajoutez les tests du modèle à la cible de tests iOS existante, puis exécutez-les avec l’identifiant d’un simulateur déjà démarré. Remplacez le chemin du projet et le Scheme par les valeurs réelles du dépôt.
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"
Un chemin de résultats unique empêche les exécutions successives d’écraser les traces précédentes. En cas d’échec, déterminez d’abord si le problème concerne la restauration de la valeur, les limites des groupes ou la possibilité de rétablir l’action. Ne masquez pas l’erreur en vidant tout l’historique.
Ajoutez enfin une vérification de l’interface : après un glisser-déposer, annulez l’action et contrôlez la position sur le canevas ainsi que l’état des boutons ; rétablissez ensuite l’action et vérifiez de nouveau ces éléments. Si les tests du modèle passent mais que le canevas ne se met pas à jour, examinez les notifications d’observation et les liaisons de l’interface, plutôt que d’enregistrer à nouveau les opérations dans l’historique. Une fois le contrat du modèle stabilisé, vérifiez sur un appareil physique que les limites des groupes lors de gestes continus correspondent aux attentes des utilisateurs.
Questions fréquentes
Faut-il enregistrer une opération inverse pendant l’annulation ?
Oui. Le retour à l’ancienne valeur par la même méthode de modification enregistre la valeur remplacée. Pendant l’annulation, UndoManager place cet enregistrement dans l’historique de rétablissement. Une affectation directe ne crée pas cette opération.
Le simulateur suffit-il pour valider les gestes d’annulation ?
Non. Les tests du modèle vérifient les groupes et la restauration de l’état, et les tests d’interface vérifient les boutons. Le confort des gestes et les limites d’un glissement continu demandent aussi une validation sur appareil.
Confiez vos builds à un Mac cloud dédié
Choisissez un nœud physique, un modèle et une durée de facturation pour exécuter vos builds Xcode et vos tâches d’automatisation dans votre propre environnement macOS. L’état de disponibilité est indiqué en temps réel dans la console.