From b581538ad9a16d8ef1b559776c18be247fcf5445 Mon Sep 17 00:00:00 2001 From: Markus Fleischhacker Date: Sun, 11 Oct 2026 09:48:56 +0200 Subject: [PATCH] Move the autosave logic from Controller into AutosaveController Like the other controller collaborators, AutosaveController gets what it acts on (Model, DialogService, the recent folders, the store) plus an Operations interface for the view, the restore import and the JavaFX thread. The 5 s delay is a Timer that unit tests replace, so the autosave behavior is now covered by unit tests with a mocked DialogService. --- .../controller/AutosaveController.java | 383 ++++++++++++++++++ .../controller/AutosaveWriter.java | 8 +- .../controller/Controller.java | 281 +++---------- .../controller/AutosaveControllerTest.java | 328 +++++++++++++++ .../controller/AutosaveTests.java | 36 +- 5 files changed, 786 insertions(+), 250 deletions(-) create mode 100644 src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveController.java create mode 100644 src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveControllerTest.java diff --git a/src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveController.java b/src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveController.java new file mode 100644 index 0000000..11f545c --- /dev/null +++ b/src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveController.java @@ -0,0 +1,383 @@ +/* + * Copyright (C) 2026 Markus Fleischhacker + * + * This file is part of Bounding Box Editor + * + * Bounding Box Editor is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Bounding Box Editor is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Bounding Box Editor. If not, see . + */ +package com.github.mfl28.boundingboxeditor.controller; + +import com.github.mfl28.boundingboxeditor.model.Model; +import com.github.mfl28.boundingboxeditor.model.data.ImageAnnotation; +import com.github.mfl28.boundingboxeditor.model.data.ImageAnnotationData; +import com.github.mfl28.boundingboxeditor.ui.DialogService; +import javafx.animation.PauseTransition; +import javafx.scene.control.ButtonBar; +import javafx.stage.Stage; +import javafx.util.Duration; + +import java.io.File; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.function.Function; + +/** + * Saves the not yet exported annotations of the open folder a few seconds after the last change (into an + * {@link AutosaveStore}), deletes the autosave once they are exported or discarded, and offers to restore it when the + * folder is opened again, or at startup. + */ +class AutosaveController { + static final String RESTORE_DIALOG_TITLE = "Restore Unsaved Annotations"; + private static final Duration DELAY = Duration.seconds(5); + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault()); + + private final Model model; + private final DialogService dialogService; + private final Stage stage; + private final RecentImageFolders recentImageFolders; + private final AutosaveStore store; + private final AutosaveWriter writer; + private final Timer delay; + private final Operations operations; + // The folder whose images are shown, which the autosave belongs to. + private File currentFolder; + // While it is open whether to restore the shown folder's autosave, and while it is restored, nothing is saved. + private boolean paused = false; + // After the window was closed. + private boolean stopped = false; + private boolean restoring = false; + // Set when the user agreed at startup to restore the last folder's autosave, so that it isn't asked again. + private File folderToRestoreWithoutAsking; + + /** + * What the autosave needs from the rest of the app. + */ + interface Operations { + /** + * Stores the shown image's shapes in the model. + */ + void updateModelFromView(); + + boolean isDrawingInProgress(); + + /** + * Starts importing the annotations of an autosave file (in the JSON format). The import calls + * {@link #onRestoreFinished(boolean)} or {@link #onRestoreFailed()} when it is done. + */ + void restore(File autosaveFile); + + /** + * Opens an image folder (which then calls {@link #onFolderOpened(File)}). + */ + void openImageFolder(File folder); + + /** + * Runs an action on the JavaFX thread (the autosave thread reports through it). + */ + void runOnUiThread(Runnable action); + + /** + * Shows when the annotations were last saved automatically. + * + * @param time the time, or null if there is no autosave + */ + void showAutosaveTime(Instant time); + + void showAutosaveFailed(String reason); + } + + /** + * The delay after a change before the annotations are saved. + */ + interface Timer { + void restart(); + + void stop(); + } + + /** + * Creates the autosave for the user's application data folder, with its own writer thread. + */ + static AutosaveController create(Model model, DialogService dialogService, Stage stage, + RecentImageFolders recentImageFolders, Operations operations) { + return new AutosaveController(model, dialogService, stage, recentImageFolders, AutosaveStore.createDefault(), + AutosaveWriter.createExecutor(), AutosaveController::createDelay, operations); + } + + /** + * Creates the autosave. + * + * @param executor the executor (with one thread) that writes the autosaves + * @param timerFactory creates the delay after a change, for the action that saves + */ + AutosaveController(Model model, DialogService dialogService, Stage stage, RecentImageFolders recentImageFolders, + AutosaveStore store, ExecutorService executor, Function timerFactory, + Operations operations) { + this.model = model; + this.dialogService = dialogService; + this.stage = stage; + this.recentImageFolders = recentImageFolders; + this.store = store; + this.operations = operations; + this.writer = new AutosaveWriter(store, executor, this::onSaved, + exception -> onSaveFailed(exception.getMessage())); + this.delay = timerFactory.apply(this::saveNow); + + // Exported (or discarded, when another folder is opened) annotations need no autosave anymore. + model.savedProperty().addListener((observable, oldValue, newValue) -> { + if(paused) { + return; + } + + if(Boolean.TRUE.equals(newValue)) { + delay.stop(); + delete(currentFolder); + } else { + schedule(); + } + }); + } + + AutosaveStore getStore() { + return store; + } + + /** + * Restarts the delay after a change. + */ + void onEdited() { + schedule(); + } + + /** + * Saves the annotations of the shown folder now (as after the delay), unless they are exported. + */ + void saveNow() { + final File folder = currentFolder; + + if(folder == null || paused || stopped) { + return; + } + + if(operations.isDrawingInProgress()) { + schedule(); + return; + } + + operations.updateModelFromView(); + + if(model.isSaved()) { + delete(folder); + return; + } + + writer.save(folder, createSnapshot()); + } + + /** + * Waits until the autosaves that were started are written (or deleted). + */ + void awaitCompletion() { + writer.awaitCompletion(); + } + + /** + * Called when a folder was opened: asks whether to restore its autosave (unless the user already agreed at + * startup), and restores or deletes it. + */ + void onFolderOpened(File folder) { + currentFolder = folder; + final Optional lastSaved = store.lastSaved(folder); + final boolean restoreWithoutAsking = folder.equals(folderToRestoreWithoutAsking); + folderToRestoreWithoutAsking = null; + + if(lastSaved.isPresent()) { + paused = true; + delay.stop(); + + final boolean restore = restoreWithoutAsking + || dialogService.displayYesNoDialogAndGetResult(RESTORE_DIALOG_TITLE, + String.format("The annotations of this folder from %s were not exported. They were saved " + + "automatically. Restore them?", + TIME_FORMATTER.format(lastSaved.get())), stage) == ButtonBar.ButtonData.YES; + + if(restore) { + restoring = true; + operations.restore(store.fileFor(folder).toFile()); + } else { + paused = false; + delete(folder); + } + } + + showAutosaveTime(); + } + + /** + * Called when no folder is shown anymore. + */ + void onFolderClosed() { + currentFolder = null; + showAutosaveTime(); + } + + boolean isRestoring() { + return restoring; + } + + /** + * Called when the autosave was imported. + * + * @param anyRestored whether any annotations were restored + */ + void onRestoreFinished(boolean anyRestored) { + restoring = false; + paused = false; + + // Restored annotations are still not exported. + if(anyRestored) { + model.setSaved(false); + } + } + + void onRestoreFailed() { + restoring = false; + paused = false; + } + + /** + * Called when the user quits without saving: the annotations are discarded, so is their autosave. + */ + void discard() { + delete(currentFolder); + } + + /** + * Stops saving, after the save that is running (once the window is closed or the app quits). + */ + void stop() { + stopped = true; + delay.stop(); + writer.awaitCompletion(); + } + + /** + * At startup, offers to reopen the most recently opened folder if its annotations were not exported. + */ + void offerToRestoreLastSession() { + final List folders = recentImageFolders.getFolders(); + + if(folders.isEmpty() || currentFolder != null) { + return; + } + + final File folder = folders.getFirst(); + final Optional lastSaved = store.lastSaved(folder); + + if(!folder.isDirectory() || lastSaved.isEmpty()) { + return; + } + + final ButtonBar.ButtonData answer = dialogService.displayYesNoDialogAndGetResult( + RESTORE_DIALOG_TITLE, + String.format("The annotations of the folder \"%s\" from %s were not exported. They were saved " + + "automatically. Open the folder and restore them?", + folder.getPath(), TIME_FORMATTER.format(lastSaved.get())), stage); + + if(answer == ButtonBar.ButtonData.YES) { + openFolderAndRestore(folder); + } + } + + /** + * Opens an image folder and restores its autosave without asking. + * + * @param folder the image folder + */ + void openFolderAndRestore(File folder) { + folderToRestoreWithoutAsking = folder; + operations.openImageFolder(folder); + } + + // Called on the autosave thread. + private void onSaved() { + operations.runOnUiThread(this::showAutosaveTime); + } + + // Called on the autosave thread. + private void onSaveFailed(String reason) { + operations.runOnUiThread(() -> operations.showAutosaveFailed(reason)); + } + + private void schedule() { + if(currentFolder != null && !paused && !stopped) { + delay.restart(); + } + } + + /** + * Deletes the autosave of a folder, after the autosaves that were already started, and waits for it (so that it + * is gone even if the app quits right afterwards). + */ + private void delete(File folder) { + if(folder == null) { + return; + } + + writer.delete(folder); + showAutosaveTime(); + } + + private void showAutosaveTime() { + operations.showAutosaveTime(currentFolder == null ? null : store.lastSaved(currentFolder).orElse(null)); + } + + /** + * Copies the annotations (with their tags and parts), so that they can be saved off the JavaFX thread while the + * user goes on editing. + */ + private ImageAnnotationData createSnapshot() { + final ImageAnnotationData data = model.createImageAnnotationData(); + final List annotations = data.imageAnnotations().stream() + .map(annotation -> new ImageAnnotation(annotation.getImageMetaData(), + annotation.getBoundingShapeData().stream().map(ShapeClipboardController::copyOf).toList())) + .toList(); + return new ImageAnnotationData(annotations, Map.copyOf(data.categoryNameToBoundingShapeCountMap()), + Map.copyOf(data.categoryNameToCategoryMap())); + } + + private static Timer createDelay(Runnable action) { + final PauseTransition transition = new PauseTransition(DELAY); + transition.setOnFinished(event -> action.run()); + + return new Timer() { + @Override + public void restart() { + transition.playFromStart(); + } + + @Override + public void stop() { + transition.stop(); + } + }; + } +} diff --git a/src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveWriter.java b/src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveWriter.java index 4d3fffa..0187e79 100644 --- a/src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveWriter.java +++ b/src/main/java/com/github/mfl28/boundingboxeditor/controller/AutosaveWriter.java @@ -60,14 +60,14 @@ final class AutosaveWriter { } /** - * Creates a writer with its own daemon thread. + * Creates the executor for a writer: one daemon thread. */ - static AutosaveWriter create(AutosaveStore store, Runnable onSaved, Consumer onFailed) { - return new AutosaveWriter(store, Executors.newSingleThreadExecutor(runnable -> { + static ExecutorService createExecutor() { + return Executors.newSingleThreadExecutor(runnable -> { final Thread thread = new Thread(runnable, "autosave"); thread.setDaemon(true); return thread; - }), onSaved, onFailed); + }); } /** diff --git a/src/main/java/com/github/mfl28/boundingboxeditor/controller/Controller.java b/src/main/java/com/github/mfl28/boundingboxeditor/controller/Controller.java index 063ce80..e236e1e 100644 --- a/src/main/java/com/github/mfl28/boundingboxeditor/controller/Controller.java +++ b/src/main/java/com/github/mfl28/boundingboxeditor/controller/Controller.java @@ -23,7 +23,6 @@ import com.github.mfl28.boundingboxeditor.model.Model; import com.github.mfl28.boundingboxeditor.model.data.BoundingShapeData; import com.github.mfl28.boundingboxeditor.model.data.ImageAnnotation; -import com.github.mfl28.boundingboxeditor.model.data.ImageAnnotationData; import com.github.mfl28.boundingboxeditor.model.data.ImageMetaData; import com.github.mfl28.boundingboxeditor.model.data.IoMetaData; import com.github.mfl28.boundingboxeditor.model.data.ObjectCategory; @@ -43,7 +42,6 @@ import com.github.mfl28.boundingboxeditor.ui.statusevents.ImageFilesLoadingSuccessfulEvent; import com.github.mfl28.boundingboxeditor.utils.ColorUtils; import com.github.mfl28.boundingboxeditor.utils.ImageUtils; -import javafx.animation.PauseTransition; import javafx.application.HostServices; import javafx.application.Platform; import javafx.beans.binding.Bindings; @@ -61,14 +59,9 @@ import javafx.scene.paint.Color; import javafx.stage.Stage; import javafx.stage.WindowEvent; -import javafx.util.Duration; import java.io.File; -import java.io.IOException; import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; import java.util.*; import java.util.function.Supplier; @@ -147,25 +140,6 @@ public class Controller { private final ShapeClipboardController shapeClipboardController = new ShapeClipboardController(new ShapeClipboardOperations()); private boolean editCheckpointScheduled = false; - // Autosave: the annotations are saved a few seconds after the last change, until they are exported. - private static final Duration AUTOSAVE_DELAY = Duration.seconds(5); - static final String RESTORE_AUTOSAVE_DIALOG_TITLE = "Restore Unsaved Annotations"; - private static final DateTimeFormatter AUTOSAVE_TIME_FORMATTER = - DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withZone(ZoneId.systemDefault()); - private final AutosaveStore autosaveStore = AutosaveStore.createDefault(); - private final PauseTransition autosaveDelay = new PauseTransition(AUTOSAVE_DELAY); - // Saves and deletes in order, off the JavaFX thread. - private final AutosaveWriter autosaveWriter = - AutosaveWriter.create(autosaveStore, this::onAutosaved, this::onAutosaveFailed); - // The folder whose images are shown, which the autosave belongs to. - private File currentImageFolder; - // While it is open whether to restore the shown folder's autosave, and while it is restored, nothing is saved. - private boolean autosavePaused = false; - // After the window was closed. - private boolean autosaveStopped = false; - private boolean restoringAutosave = false; - // Set when the user agreed at startup to restore the last folder's autosave, so that it isn't asked again. - private File folderToRestoreWithoutAsking; private final ListChangeListener boundingShapeCountPerCategoryListener = createBoundingShapeCountPerCategoryListener(); private final ChangeListener imageLoadProgressListener = createImageLoadingProgressListener(); @@ -173,6 +147,7 @@ public class Controller { private final IoMetaData ioMetaData = new IoMetaData(); private final PreferencesStore preferencesStore = PreferencesStore.forApplication(); private final RecentImageFolders recentImageFolders = new RecentImageFolders(preferencesStore); + private final AutosaveController autosaveController; final List keyCombinationHandlers; String lastLoadedImageUrl; private final ChangeListener selectedFileIndexListener = createSelectedFileIndexListener(); @@ -218,9 +193,10 @@ public Controller(final Stage mainStage, final MainView view, final HostServices view.connectToController(this); setUpModelListeners(); setUpServices(); - setUpAutosave(); + autosaveController = AutosaveController.create(model, dialogService, stage, recentImageFolders, + new AutosaveOperations()); // After the window was shown. - Platform.runLater(this::offerToRestoreLastSession); + Platform.runLater(autosaveController::offerToRestoreLastSession); } public void onRegisterSettingsAction() { @@ -470,10 +446,10 @@ public void onRegisterExitAction() { } // The user decided not to keep the annotations. - deleteAutosave(currentImageFolder); + autosaveController.discard(); } - autosaveDelay.stop(); + autosaveController.stop(); savePreferences(); imageFolderController.stopWatching(); makeClientUnavailable(); @@ -826,8 +802,7 @@ private void onImageMetaDataLoadingSucceeded(WorkerStateEvent workerStateEvent) private void onAnnotationImportSucceeded(WorkerStateEvent workerStateEvent) { ImageAnnotationImportResult importResult = annotationImportService.getValue(); - final boolean restoredAutosave = restoringAutosave; - restoringAutosave = false; + final boolean restoredAutosave = autosaveController.isRestoring(); if(importResult.getNrSuccessfullyProcessedItems() != 0) { model.updateFromImageAnnotationData(importResult.getImageAnnotationData(), importResult.getOperationType()); @@ -835,12 +810,7 @@ private void onAnnotationImportSucceeded(WorkerStateEvent workerStateEvent) { } if(restoredAutosave) { - autosavePaused = false; - - // Restored annotations are still not exported. - if(importResult.getNrSuccessfullyProcessedItems() != 0) { - model.setSaved(false); - } + autosaveController.onRestoreFinished(importResult.getNrSuccessfullyProcessedItems() != 0); } updateViewFileExplorerFileInfoElements(); @@ -865,9 +835,8 @@ private void onAnnotationImportSucceeded(WorkerStateEvent workerStateEvent) { private void onIoServiceFailed(WorkerStateEvent event) { - if(annotationImportService.equals(event.getSource()) && restoringAutosave) { - restoringAutosave = false; - autosavePaused = false; + if(annotationImportService.equals(event.getSource()) && autosaveController.isRestoring()) { + autosaveController.onRestoreFailed(); } final Throwable exception = event.getSource().getException(); @@ -1285,7 +1254,7 @@ private void setupStage() { event.consume(); }); // Once the window is closed, nothing is saved anymore (also not into the next test's autosave folder). - stage.addEventHandler(WindowEvent.WINDOW_HIDDEN, event -> stopAutosaving()); + stage.addEventHandler(WindowEvent.WINDOW_HIDDEN, event -> autosaveController.stop()); stage.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { if (!Objects.equals(event.getTarget(), view.getEditorImageView())) { @@ -1308,165 +1277,19 @@ private void scheduleEditCheckpoint() { Platform.runLater(() -> { editCheckpointScheduled = false; editHistoryController.checkpoint(); - scheduleAutosave(); + autosaveController.onEdited(); }); } } - /** - * Saves the annotations of the shown folder now (as after the autosave delay), unless they are exported. - */ - void autosaveNow() { - final File folder = currentImageFolder; - - if(folder == null || autosavePaused || autosaveStopped) { - return; - } - - if(view.getEditorImagePane().isDrawingInProgress()) { - scheduleAutosave(); - return; - } - - updateModelFromView(); - - if(model.isSaved()) { - deleteAutosave(folder); - return; - } - - autosaveWriter.save(folder, createAutosaveSnapshot()); - } - - /** - * Waits until the autosaves that were started are written (or deleted). - */ - void waitForAutosave() { - autosaveWriter.awaitCompletion(); - } - - AutosaveStore getAutosaveStore() { - return autosaveStore; + AutosaveController getAutosaveController() { + return autosaveController; } RecentImageFolders getRecentImageFolders() { return recentImageFolders; } - private void setUpAutosave() { - autosaveDelay.setOnFinished(event -> autosaveNow()); - - // Exported (or discarded, when another folder is opened) annotations need no autosave anymore. - model.savedProperty().addListener((observable, oldValue, newValue) -> { - if(autosavePaused) { - return; - } - - if(Boolean.TRUE.equals(newValue)) { - autosaveDelay.stop(); - deleteAutosave(currentImageFolder); - } else { - scheduleAutosave(); - } - }); - } - - private void scheduleAutosave() { - if(currentImageFolder != null && !autosavePaused && !autosaveStopped) { - autosaveDelay.playFromStart(); - } - } - - private void stopAutosaving() { - autosaveStopped = true; - autosaveDelay.stop(); - autosaveWriter.awaitCompletion(); - } - - /** - * Deletes the autosave of a folder, after the autosaves that were already started, and waits for it (so that it - * is gone even if the app quits right afterwards). - */ - private void deleteAutosave(File folder) { - if(folder == null) { - return; - } - - autosaveWriter.delete(folder); - showAutosaveTime(); - } - - // Called on the autosave thread. - private void onAutosaved() { - Platform.runLater(this::showAutosaveTime); - } - - // Called on the autosave thread. - private void onAutosaveFailed(IOException exception) { - Platform.runLater(() -> view.getStatusBar().setStatusEvent(new AutosaveFailedEvent(exception.getMessage()))); - } - - /** - * Shows when the annotations of the shown folder were last saved automatically (in the status bar's saved-status - * tooltip). - */ - private void showAutosaveTime() { - view.getStatusBar().setAutosaveTime(currentImageFolder == null ? null - : autosaveStore.lastSaved(currentImageFolder).orElse(null)); - } - - /** - * Copies the annotations (with their tags and parts), so that they can be saved off the JavaFX thread while the - * user goes on editing. - */ - private ImageAnnotationData createAutosaveSnapshot() { - final ImageAnnotationData data = model.createImageAnnotationData(); - final List annotations = data.imageAnnotations().stream() - .map(annotation -> new ImageAnnotation(annotation.getImageMetaData(), - annotation.getBoundingShapeData().stream().map(ShapeClipboardController::copyOf).toList())) - .toList(); - return new ImageAnnotationData(annotations, Map.copyOf(data.categoryNameToBoundingShapeCountMap()), - Map.copyOf(data.categoryNameToCategoryMap())); - } - - /** - * At startup, offers to reopen the most recently opened folder if its annotations were not exported. - */ - void offerToRestoreLastSession() { - final List folders = recentImageFolders.getFolders(); - - if(folders.isEmpty() || currentImageFolder != null) { - return; - } - - final File folder = folders.getFirst(); - final Optional lastSaved = autosaveStore.lastSaved(folder); - - if(!folder.isDirectory() || lastSaved.isEmpty()) { - return; - } - - final ButtonBar.ButtonData answer = dialogService.displayYesNoDialogAndGetResult( - RESTORE_AUTOSAVE_DIALOG_TITLE, - String.format("The annotations of the folder \"%s\" from %s were not exported. They were saved " - + "automatically. Open the folder and restore them?", - folder.getPath(), AUTOSAVE_TIME_FORMATTER.format(lastSaved.get())), stage); - - if(answer == ButtonBar.ButtonData.YES) { - loadImageFilesAndRestoreAutosave(folder); - } - } - - /** - * Opens an image folder and restores its autosaved annotations without asking. - * - * @param folder the image folder - */ - void loadImageFilesAndRestoreAutosave(File folder) { - folderToRestoreWithoutAsking = folder; - loadImageFiles(folder); - } - /** * Discards all edit histories and starts a new one for the shown image, e.g. after annotations were imported. */ @@ -1507,38 +1330,6 @@ public void startExport(File destination, ImageAnnotationSaveStrategy.Type forma * Loads and shows the image folders opened by the {@link ImageFolderController}. */ private class ImageFolderOperations implements ImageFolderController.Operations { - /** - * Asks whether to restore the autosaved annotations of a folder that was opened (unless the user already agreed - * at startup), and restores or deletes them. - */ - private void offerToRestoreAutosave(File folder) { - final Optional lastSaved = autosaveStore.lastSaved(folder); - final boolean restoreWithoutAsking = folder.equals(folderToRestoreWithoutAsking); - folderToRestoreWithoutAsking = null; - - if(lastSaved.isEmpty()) { - return; - } - - autosavePaused = true; - autosaveDelay.stop(); - - final boolean restore = restoreWithoutAsking - || dialogService.displayYesNoDialogAndGetResult(RESTORE_AUTOSAVE_DIALOG_TITLE, - String.format("The annotations of this folder from %s were not exported. They were saved " - + "automatically. Restore them?", - AUTOSAVE_TIME_FORMATTER.format(lastSaved.get())), stage) == ButtonBar.ButtonData.YES; - - if(restore) { - restoringAutosave = true; - startAnnotationImportService(autosaveStore.fileFor(folder).toFile(), - ImageAnnotationLoadStrategy.Type.JSON); - } else { - autosavePaused = false; - deleteAutosave(folder); - } - } - @Override public void updateModelFromView() { Controller.this.updateModelFromView(); @@ -1570,16 +1361,13 @@ public void showLoadedImageFiles(ImageMetaDataLoadingResult result, File folder, recentImageFolders.add(folder); view.getStatusBar().setStatusEvent(new ImageFilesLoadingSuccessfulEvent(result, folder)); - currentImageFolder = folder; - offerToRestoreAutosave(folder); - showAutosaveTime(); + autosaveController.onFolderOpened(folder); } @Override public void clearWorkspace() { clearViewAndModel(); - currentImageFolder = null; - showAutosaveTime(); + autosaveController.onFolderClosed(); } } @@ -1730,4 +1518,41 @@ public boolean hasPreviousImage() { return imageFileFilterController.hasPreviousProperty().get(); } } + + private class AutosaveOperations implements AutosaveController.Operations { + @Override + public void updateModelFromView() { + Controller.this.updateModelFromView(); + } + + @Override + public boolean isDrawingInProgress() { + return view.getEditorImagePane().isDrawingInProgress(); + } + + @Override + public void restore(File autosaveFile) { + startAnnotationImportService(autosaveFile, ImageAnnotationLoadStrategy.Type.JSON); + } + + @Override + public void openImageFolder(File folder) { + loadImageFiles(folder); + } + + @Override + public void runOnUiThread(Runnable action) { + Platform.runLater(action); + } + + @Override + public void showAutosaveTime(Instant time) { + view.getStatusBar().setAutosaveTime(time); + } + + @Override + public void showAutosaveFailed(String reason) { + view.getStatusBar().setStatusEvent(new AutosaveFailedEvent(reason)); + } + } } diff --git a/src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveControllerTest.java b/src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveControllerTest.java new file mode 100644 index 0000000..7c90107 --- /dev/null +++ b/src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveControllerTest.java @@ -0,0 +1,328 @@ +/* + * Copyright (C) 2026 Markus Fleischhacker + * + * This file is part of Bounding Box Editor + * + * Bounding Box Editor is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Bounding Box Editor is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Bounding Box Editor. If not, see . + */ +package com.github.mfl28.boundingboxeditor.controller; + +import com.github.mfl28.boundingboxeditor.model.Model; +import com.github.mfl28.boundingboxeditor.model.data.*; +import com.github.mfl28.boundingboxeditor.ui.DialogService; +import javafx.collections.FXCollections; +import javafx.scene.control.ButtonBar; +import javafx.scene.paint.Color; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@Tag("unit") +class AutosaveControllerTest { + private static final String IMAGE = "a.jpg"; + + private final Model model = new Model(); + private final DialogService dialogService = mock(DialogService.class); + private final RecentImageFolders recentImageFolders = mock(RecentImageFolders.class); + private final AutosaveController.Operations operations = mock(AutosaveController.Operations.class); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final ObjectCategory category = new ObjectCategory("boat", Color.RED); + private FakeTimer timer; + private AutosaveStore store; + private AutosaveController autosave; + + @TempDir + Path tempDir; + private File folder; + + @BeforeEach + void setUp() throws Exception { + folder = Files.createDirectory(tempDir.resolve("images")).toFile(); + model.getImageFileNameToMetaDataMap().put(IMAGE, + new ImageMetaData(IMAGE, "images", new File(folder, IMAGE).toURI().toString(), 100, 50, 3)); + model.setImageFiles(List.of(new File(folder, IMAGE))); + when(recentImageFolders.getFolders()).thenReturn(FXCollections.observableArrayList()); + // The autosave thread reports through the UI thread, which these tests don't have. + doAnswer(invocation -> { + invocation.getArgument(0).run(); + return null; + }).when(operations).runOnUiThread(any()); + + store = new AutosaveStore(tempDir.resolve("autosave")); + autosave = new AutosaveController(model, dialogService, null, recentImageFolders, store, executor, + action -> timer = new FakeTimer(action), operations); + } + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + @Test + void onEditing_ShouldSaveTheAnnotationsWhenTheDelayRunsOut() { + autosave.onFolderOpened(folder); + assertFalse(timer.running); + + annotate(); + assertTrue(timer.running); + + timer.fire(); + autosave.awaitCompletion(); + + assertTrue(Files.isRegularFile(store.fileFor(folder))); + verify(operations).updateModelFromView(); + verify(operations, atLeastOnce()).showAutosaveTime(notNull()); + verifyNoInteractions(dialogService); + } + + @Test + void onEditingAgain_ShouldRestartTheDelay() { + autosave.onFolderOpened(folder); + annotate(); + timer.running = false; + + autosave.onEdited(); + + assertTrue(timer.running); + } + + @Test + void onSavingWhileAShapeIsDrawn_ShouldWaitForTheDrawingToFinish() { + autosave.onFolderOpened(folder); + annotate(); + when(operations.isDrawingInProgress()).thenReturn(true); + + timer.fire(); + autosave.awaitCompletion(); + + assertFalse(Files.exists(store.fileFor(folder))); + assertTrue(timer.running); + } + + @Test + void onExportingTheAnnotations_ShouldDeleteTheAutosave() { + autosave.onFolderOpened(folder); + annotate(); + timer.fire(); + autosave.awaitCompletion(); + clearInvocations(operations); + + model.setSaved(true); + + assertFalse(Files.exists(store.fileFor(folder))); + assertFalse(timer.running); + verify(operations).showAutosaveTime(null); + } + + @Test + void onSavingExportedAnnotations_ShouldDeleteTheAutosaveInstead() throws Exception { + store.save(folder, annotationData()); + when(dialogService.displayYesNoDialogAndGetResult(any(), any(), any())).thenReturn(ButtonBar.ButtonData.YES); + autosave.onFolderOpened(folder); + autosave.onRestoreFinished(false); + + autosave.saveNow(); + + assertFalse(Files.exists(store.fileFor(folder))); + } + + @Test + void onQuittingWithoutSaving_ShouldDeleteTheAutosave() { + autosave.onFolderOpened(folder); + annotate(); + timer.fire(); + autosave.awaitCompletion(); + + autosave.discard(); + + assertFalse(Files.exists(store.fileFor(folder))); + } + + @Test + void onOpeningAFolderWithAutosave_WhenRestoring_ShouldImportItAndKeepItUnsaved() throws Exception { + store.save(folder, annotationData()); + when(dialogService.displayYesNoDialogAndGetResult(eq(AutosaveController.RESTORE_DIALOG_TITLE), + startsWith("The annotations of this folder from "), + any())).thenReturn(ButtonBar.ButtonData.YES); + + autosave.onFolderOpened(folder); + + verify(operations).restore(store.fileFor(folder).toFile()); + assertTrue(autosave.isRestoring()); + // Nothing is saved while it is restored. + model.setSaved(false); + assertFalse(timer.running); + + model.setSaved(true); + autosave.onRestoreFinished(true); + + assertFalse(autosave.isRestoring()); + assertFalse(model.isSaved()); + assertTrue(timer.running); + assertTrue(Files.isRegularFile(store.fileFor(folder))); + } + + @Test + void onOpeningAFolderWithAutosave_WhenNotRestoring_ShouldDeleteIt() throws Exception { + store.save(folder, annotationData()); + when(dialogService.displayYesNoDialogAndGetResult(any(), any(), any())).thenReturn(ButtonBar.ButtonData.NO); + + autosave.onFolderOpened(folder); + + verify(operations, never()).restore(any()); + assertFalse(Files.exists(store.fileFor(folder))); + assertFalse(autosave.isRestoring()); + } + + @Test + void onFailingToRestore_ShouldSaveAgainAfterEdits() throws Exception { + store.save(folder, annotationData()); + when(dialogService.displayYesNoDialogAndGetResult(any(), any(), any())).thenReturn(ButtonBar.ButtonData.YES); + autosave.onFolderOpened(folder); + + autosave.onRestoreFailed(); + annotate(); + + assertFalse(autosave.isRestoring()); + assertTrue(timer.running); + } + + @Test + void onStartingWithAnAutosaveOfTheLastFolder_WhenAccepting_ShouldOpenAndRestoreItWithoutAskingAgain() + throws Exception { + store.save(folder, annotationData()); + when(recentImageFolders.getFolders()).thenReturn(FXCollections.observableArrayList(folder)); + when(dialogService.displayYesNoDialogAndGetResult(eq(AutosaveController.RESTORE_DIALOG_TITLE), + contains("Open the folder and restore them?"), any())) + .thenReturn(ButtonBar.ButtonData.YES); + + autosave.offerToRestoreLastSession(); + verify(operations).openImageFolder(folder); + + // Opening the folder restores it. + autosave.onFolderOpened(folder); + verify(operations).restore(store.fileFor(folder).toFile()); + verify(dialogService, times(1)).displayYesNoDialogAndGetResult(any(), any(), any()); + } + + @Test + void onStartingWithAnAutosaveOfTheLastFolder_WhenDeclining_ShouldNotOpenIt() throws Exception { + store.save(folder, annotationData()); + when(recentImageFolders.getFolders()).thenReturn(FXCollections.observableArrayList(folder)); + when(dialogService.displayYesNoDialogAndGetResult(any(), any(), any())).thenReturn(ButtonBar.ButtonData.NO); + + autosave.offerToRestoreLastSession(); + + verify(operations, never()).openImageFolder(any()); + assertTrue(Files.isRegularFile(store.fileFor(folder))); + } + + @Test + void onStarting_WithoutAnAutosaveOfTheLastFolder_ShouldNotAsk() { + when(recentImageFolders.getFolders()).thenReturn(FXCollections.observableArrayList(folder)); + + autosave.offerToRestoreLastSession(); + + verifyNoInteractions(dialogService); + } + + @Test + void onStopping_ShouldNotSaveAnymore() { + autosave.onFolderOpened(folder); + annotate(); + + autosave.stop(); + autosave.onEdited(); + autosave.saveNow(); + autosave.awaitCompletion(); + + assertFalse(timer.running); + assertFalse(Files.exists(store.fileFor(folder))); + } + + @Test + void onClosingTheFolder_ShouldNotSaveAnymore() { + autosave.onFolderOpened(folder); + annotate(); + + autosave.onFolderClosed(); + autosave.saveNow(); + autosave.awaitCompletion(); + + assertFalse(Files.exists(store.fileFor(folder))); + verify(operations, atLeastOnce()).showAutosaveTime(null); + } + + @Test + void onFailingToSave_ShouldShowTheError() throws Exception { + // A file in place of the autosave folder makes saving fail. + Files.createFile(store.directory()); + autosave.onFolderOpened(folder); + annotate(); + + timer.fire(); + autosave.awaitCompletion(); + + verify(operations).showAutosaveFailed(notNull()); + } + + private void annotate() { + model.updateCurrentBoundingShapeData(List.of(new BoundingBoxData(category, 0.1, 0.1, 0.5, 0.5, List.of()))); + } + + private ImageAnnotationData annotationData() { + final ImageAnnotation annotation = new ImageAnnotation(model.getImageFileNameToMetaDataMap().get(IMAGE), + List.of(new BoundingBoxData(category, 0.1, 0.1, 0.5, 0.5, List.of()))); + return new ImageAnnotationData(List.of(annotation), Map.of(category.getName(), 1), + Map.of(category.getName(), category)); + } + + private static final class FakeTimer implements AutosaveController.Timer { + private final Runnable action; + private boolean running; + + FakeTimer(Runnable action) { + this.action = action; + } + + @Override + public void restart() { + running = true; + } + + @Override + public void stop() { + running = false; + } + + void fire() { + running = false; + action.run(); + } + } +} diff --git a/src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveTests.java b/src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveTests.java index 58d5b72..4f09d5c 100644 --- a/src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveTests.java +++ b/src/test/java/com/github/mfl28/boundingboxeditor/controller/AutosaveTests.java @@ -63,7 +63,7 @@ void start(Stage stage) { void onEditingAnnotations_ShouldSaveThemAutomaticallyUntilTheyAreExported(FxRobot robot, TestInfo testinfo, @TempDir Path tempDir) { final File folder = testImageFolder(); - final AutosaveStore store = controller.getAutosaveStore(); + final AutosaveStore store = controller.getAutosaveController().getStore(); robot.interact(() -> controller.loadImageFiles(folder)); waitUntilCurrentImageIsLoaded(testinfo); enterNewCategory(robot, "Boat", testinfo); @@ -71,8 +71,8 @@ void onEditingAnnotations_ShouldSaveThemAutomaticallyUntilTheyAreExported(FxRobo waitUntil(() -> mainView.getCurrentBoundingShapes().size() == 1, "the drawn box", testinfo); // (Instead of waiting for the autosave delay.) - robot.interact(controller::autosaveNow); - controller.waitForAutosave(); + robot.interact(controller.getAutosaveController()::saveNow); + controller.getAutosaveController().awaitCompletion(); verifyThat(Files.isRegularFile(store.fileFor(folder)), Matchers.is(true), saveScreenshot(testinfo)); verifyThat(model.isSaved(), Matchers.is(false), saveScreenshot(testinfo)); @@ -91,7 +91,7 @@ void onEditingAnnotations_ShouldSaveThemAutomaticallyUntilTheyAreExported(FxRobo ImageAnnotationSaveStrategy.Type.JSON)); WaitForAsyncUtils.waitForFxEvents(); timeOutAssertServiceSucceeded(controller.getAnnotationExportService(), testinfo); - controller.waitForAutosave(); + controller.getAutosaveController().awaitCompletion(); verifyThat(model.isSaved(), Matchers.is(true), saveScreenshot(testinfo)); verifyThat(Files.exists(store.fileFor(folder)), Matchers.is(false), saveScreenshot(testinfo)); @@ -107,7 +107,7 @@ void onOpeningFolderWithAutosave_WhenRestoring_ShouldLoadTheUnsavedAnnotations(F writeAutosave(folder); robot.interact(() -> controller.loadImageFiles(folder)); - final Stage dialog = timeOutGetTopModalStage(robot, Controller.RESTORE_AUTOSAVE_DIALOG_TITLE, testinfo); + final Stage dialog = timeOutGetTopModalStage(robot, AutosaveController.RESTORE_DIALOG_TITLE, testinfo); timeOutLookUpInStageAndClickOn(robot, dialog, "Yes", testinfo); waitUntil(() -> model.getImageFileNameToAnnotationMap().containsKey(IMAGE_FILE_NAME), "the restored annotation", @@ -118,15 +118,15 @@ void onOpeningFolderWithAutosave_WhenRestoring_ShouldLoadTheUnsavedAnnotations(F verifyThat(shapes.getFirst().getTags(), Matchers.contains("difficult"), saveScreenshot(testinfo)); // Still not exported, so still unsaved, and the autosave is kept. verifyThat(model.isSaved(), Matchers.is(false), saveScreenshot(testinfo)); - controller.waitForAutosave(); - verifyThat(Files.isRegularFile(controller.getAutosaveStore().fileFor(folder)), Matchers.is(true), + controller.getAutosaveController().awaitCompletion(); + verifyThat(Files.isRegularFile(controller.getAutosaveController().getStore().fileFor(folder)), Matchers.is(true), saveScreenshot(testinfo)); verifyThat(mainView.getStatusBar().getSavedStatusDescription(), Matchers.startsWith("The annotations are not exported yet. They were saved automatically "), saveScreenshot(testinfo)); // The autosave folder doesn't become the folder for importing annotations. verifyThat(controller.getIoMetaData().getDefaultAnnotationLoadingDirectory(), - Matchers.not(Matchers.equalTo(controller.getAutosaveStore().directory().toFile())), + Matchers.not(Matchers.equalTo(controller.getAutosaveController().getStore().directory().toFile())), saveScreenshot(testinfo)); } @@ -137,13 +137,13 @@ void onOpeningFolderWithAutosave_WhenNotRestoring_ShouldDeleteIt(FxRobot robot, writeAutosave(folder); robot.interact(() -> controller.loadImageFiles(folder)); - final Stage dialog = timeOutGetTopModalStage(robot, Controller.RESTORE_AUTOSAVE_DIALOG_TITLE, testinfo); + final Stage dialog = timeOutGetTopModalStage(robot, AutosaveController.RESTORE_DIALOG_TITLE, testinfo); timeOutLookUpInStageAndClickOn(robot, dialog, "No", testinfo); waitUntilCurrentImageIsLoaded(testinfo); - controller.waitForAutosave(); + controller.getAutosaveController().awaitCompletion(); verifyThat(model.getImageFileNameToAnnotationMap().isEmpty(), Matchers.is(true), saveScreenshot(testinfo)); - verifyThat(Files.exists(controller.getAutosaveStore().fileFor(folder)), Matchers.is(false), + verifyThat(Files.exists(controller.getAutosaveController().getStore().fileFor(folder)), Matchers.is(false), saveScreenshot(testinfo)); } @@ -154,7 +154,7 @@ void onRestoringAutosaveWithoutAsking_ShouldPlaceTheShapesOnTheLoadedImage(FxRob writeAutosave(folder); // The annotations may be restored before the image is loaded (no dialog in between, as at startup). - robot.interact(() -> controller.loadImageFilesAndRestoreAutosave(folder)); + robot.interact(() -> controller.getAutosaveController().openFolderAndRestore(folder)); waitUntilCurrentImageIsLoaded(testinfo); waitUntil(() -> mainView.getCurrentBoundingShapes().size() == 1, "the restored box", testinfo); WaitForAsyncUtils.waitForFxEvents(); @@ -179,8 +179,8 @@ void onStartingWithAnAutosaveOfTheLastFolder_WhenAccepting_ShouldOpenTheFolderAn robot.interact(() -> controller.getRecentImageFolders().add(folder)); // The dialog waits for an answer, so it is opened later on the JavaFX thread. - Platform.runLater(controller::offerToRestoreLastSession); - final Stage dialog = timeOutGetTopModalStage(robot, Controller.RESTORE_AUTOSAVE_DIALOG_TITLE, testinfo); + Platform.runLater(controller.getAutosaveController()::offerToRestoreLastSession); + final Stage dialog = timeOutGetTopModalStage(robot, AutosaveController.RESTORE_DIALOG_TITLE, testinfo); timeOutLookUpInStageAndClickOn(robot, dialog, "Yes", testinfo); // The folder is opened and restored without asking again. @@ -203,13 +203,13 @@ void onFailingToSaveAutomatically_ShouldShowTheErrorInTheStatusBar(FxRobot robot waitUntil(() -> mainView.getCurrentBoundingShapes().size() == 1, "the drawn box", testinfo); // A file in place of the autosave folder makes saving fail. - final Path directory = controller.getAutosaveStore().directory(); + final Path directory = controller.getAutosaveController().getStore().directory(); deleteRecursively(directory); Files.createFile(directory); try { - robot.interact(controller::autosaveNow); - controller.waitForAutosave(); + robot.interact(controller.getAutosaveController()::saveNow); + controller.getAutosaveController().awaitCompletion(); WaitForAsyncUtils.waitForFxEvents(); verifyThat(mainView.getStatusBar().getCurrentEventMessage(), @@ -244,7 +244,7 @@ private void writeAutosave(File folder) throws Exception { new File(folder, IMAGE_FILE_NAME).toURI().toString(), 3854, 2033, 3); final BoundingBoxData box = new BoundingBoxData(category, 0.2, 0.2, 0.6, 0.6, List.of("difficult")); - controller.getAutosaveStore().save(folder, new ImageAnnotationData( + controller.getAutosaveController().getStore().save(folder, new ImageAnnotationData( List.of(new ImageAnnotation(image, List.of(box))), Map.of("Boat", 1), Map.of("Boat", category))); }