diff --git a/src/main/java/com/example/workutils/system/ui/async/AsyncOperationController.java b/src/main/java/com/example/workutils/system/ui/async/AsyncOperationController.java index e7bb479..b201e79 100644 --- a/src/main/java/com/example/workutils/system/ui/async/AsyncOperationController.java +++ b/src/main/java/com/example/workutils/system/ui/async/AsyncOperationController.java @@ -6,6 +6,7 @@ import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; import java.util.Objects; +import java.util.concurrent.CancellationException; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; @@ -45,16 +46,27 @@ public final class AsyncOperationController { try { T result = task.execute(context); uiDispatcher.accept(() -> { - publish(stateMachine.succeed( - cancellationRequested.get() ? "已停止" : "操作完成")); + publish(cancellationRequested.get() + ? stateMachine.cancel() + : stateMachine.succeed("操作完成")); running.set(false); onSuccess.accept(result); }); } catch (Throwable error) { + if (error instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } uiDispatcher.accept(() -> { - publish(stateMachine.fail(readableMessage(error))); + boolean cancelled = cancellationRequested.get() + && (error instanceof CancellationException + || error instanceof InterruptedException); + publish(cancelled + ? stateMachine.cancel() + : stateMachine.fail(readableMessage(error))); running.set(false); - onFailure.accept(error); + if (!cancelled) { + onFailure.accept(error); + } }); } }); @@ -80,7 +92,8 @@ public final class AsyncOperationController { private void prepareStart(String message) { OperationState current = stateMachine.snapshot().state(); - if (current == OperationState.SUCCEEDED || current == OperationState.FAILED) { + if (current == OperationState.SUCCEEDED || current == OperationState.FAILED + || current == OperationState.CANCELLED) { stateMachine.reset(); } cancellationRequested.set(false); diff --git a/src/main/java/com/example/workutils/system/ui/async/OperationState.java b/src/main/java/com/example/workutils/system/ui/async/OperationState.java index e20d5c8..e418f27 100644 --- a/src/main/java/com/example/workutils/system/ui/async/OperationState.java +++ b/src/main/java/com/example/workutils/system/ui/async/OperationState.java @@ -4,6 +4,7 @@ public enum OperationState { IDLE, RUNNING, CANCELLING, + CANCELLED, SUCCEEDED, FAILED } diff --git a/src/main/java/com/example/workutils/system/ui/async/OperationStateMachine.java b/src/main/java/com/example/workutils/system/ui/async/OperationStateMachine.java index 5053ddf..84ff475 100644 --- a/src/main/java/com/example/workutils/system/ui/async/OperationStateMachine.java +++ b/src/main/java/com/example/workutils/system/ui/async/OperationStateMachine.java @@ -30,13 +30,19 @@ public final class OperationStateMachine { return update(OperationState.SUCCEEDED, message, 1); } + public synchronized OperationSnapshot cancel() { + requireState(OperationState.CANCELLING); + return update(OperationState.CANCELLED, "已取消", snapshot.progress()); + } + public synchronized OperationSnapshot fail(String message) { requireState(OperationState.RUNNING, OperationState.CANCELLING); return update(OperationState.FAILED, message, snapshot.progress()); } public synchronized OperationSnapshot reset() { - requireState(OperationState.SUCCEEDED, OperationState.FAILED); + requireState(OperationState.SUCCEEDED, OperationState.FAILED, + OperationState.CANCELLED); snapshot = OperationSnapshot.idle(); return snapshot; } diff --git a/src/main/java/com/example/workutils/system/ui/async/OperationStatusPane.java b/src/main/java/com/example/workutils/system/ui/async/OperationStatusPane.java index a4d9e61..4fc8900 100644 --- a/src/main/java/com/example/workutils/system/ui/async/OperationStatusPane.java +++ b/src/main/java/com/example/workutils/system/ui/async/OperationStatusPane.java @@ -122,6 +122,7 @@ public final class OperationStatusPane extends HBox { case IDLE -> "就绪"; case RUNNING -> "处理中…"; case CANCELLING -> "正在停止…"; + case CANCELLED -> "已取消"; case SUCCEEDED -> "操作完成"; case FAILED -> "操作失败"; }; diff --git a/src/test/java/com/example/workutils/system/ui/SharedPaneAccessibilityTest.java b/src/test/java/com/example/workutils/system/ui/SharedPaneAccessibilityTest.java index 935dba7..0e02392 100644 --- a/src/test/java/com/example/workutils/system/ui/SharedPaneAccessibilityTest.java +++ b/src/test/java/com/example/workutils/system/ui/SharedPaneAccessibilityTest.java @@ -127,6 +127,33 @@ class SharedPaneAccessibilityTest { }); } + @Test + void cancelledStatusShowsTerminalMessageWithoutSuccessStyleOrCancelButton() + throws Exception { + runFx(() -> { + AtomicReference pendingOperation = new AtomicReference<>(); + AsyncOperationController controller = + new AsyncOperationController<>(pendingOperation::set, Runnable::run); + OperationStatusPane pane = new OperationStatusPane(controller, true); + + controller.start("导出中", context -> null, ignored -> { }); + Button cancelButton = pane.getChildren().stream() + .filter(Button.class::isInstance) + .map(Button.class::cast) + .findFirst() + .orElseThrow(); + cancelButton.fire(); + pendingOperation.get().run(); + + assertEquals(OperationState.CANCELLED, controller.snapshot().state()); + assertTrue(pane.getAccessibleText().contains("已取消")); + assertFalse(cancelButton.isVisible()); + assertFalse(cancelButton.isManaged()); + assertFalse(pane.getStyleClass().contains("operation-status-success")); + assertFalse(pane.getStyleClass().contains("operation-status-error")); + }); + } + @Test void enterOpensSelectedHistoryResult() throws Exception { runFx(() -> { diff --git a/src/test/java/com/example/workutils/system/ui/async/AsyncOperationControllerTest.java b/src/test/java/com/example/workutils/system/ui/async/AsyncOperationControllerTest.java index c886ac2..80096be 100644 --- a/src/test/java/com/example/workutils/system/ui/async/AsyncOperationControllerTest.java +++ b/src/test/java/com/example/workutils/system/ui/async/AsyncOperationControllerTest.java @@ -4,12 +4,16 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; class AsyncOperationControllerTest { @@ -63,27 +67,140 @@ class AsyncOperationControllerTest { } @Test - void cancellationIsVisibleToRunningTask() throws Exception { + void cooperativeCancellationPublishesCancelledAndPreservesReturnedResult() throws Exception { AsyncOperationController controller = new AsyncOperationController<>(executor, Runnable::run); CountDownLatch started = new CountDownLatch(1); CountDownLatch observedCancellation = new CountDownLatch(1); CountDownLatch finished = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(); controller.start("运行中", context -> { started.countDown(); while (!context.isCancellationRequested()) { Thread.onSpinWait(); } observedCancellation.countDown(); - return "cancelled"; - }, ignored -> finished.countDown()); + return "valid-result"; + }, value -> { + result.set(value); + finished.countDown(); + }); assertTrue(started.await(2, TimeUnit.SECONDS)); controller.cancel(); assertTrue(observedCancellation.await(2, TimeUnit.SECONDS)); assertTrue(finished.await(2, TimeUnit.SECONDS)); - assertEquals(OperationState.SUCCEEDED, controller.snapshot().state()); + assertEquals("valid-result", result.get()); + assertEquals(OperationState.CANCELLED, controller.snapshot().state()); + assertEquals("已取消", controller.snapshot().message()); + assertFalse(controller.runningProperty().get()); + } + + @Test + void cancellationExceptionAfterRequestPublishesCancelledWithoutFailureCallback() { + AtomicReference pendingOperation = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + AsyncOperationController controller = + new AsyncOperationController<>(pendingOperation::set, Runnable::run); + + controller.start("运行中", context -> { + if (context.isCancellationRequested()) { + throw new CancellationException("stopped"); + } + return "unexpected"; + }, ignored -> { }, failure::set); + controller.cancel(); + + pendingOperation.get().run(); + + assertEquals(OperationState.CANCELLED, controller.snapshot().state()); + assertEquals("已取消", controller.snapshot().message()); + assertNull(failure.get()); + assertFalse(controller.runningProperty().get()); + } + + @Test + void unexpectedErrorAfterCancellationRemainsFailed() { + AtomicReference pendingOperation = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + AsyncOperationController controller = + new AsyncOperationController<>(pendingOperation::set, Runnable::run); + + controller.start("运行中", context -> { + throw new IllegalStateException("broken after cancel"); + }, ignored -> { }, failure::set); + controller.cancel(); + + pendingOperation.get().run(); + + assertEquals(OperationState.FAILED, controller.snapshot().state()); + assertEquals("broken after cancel", controller.snapshot().message()); + assertEquals("broken after cancel", failure.get().getMessage()); + assertFalse(controller.runningProperty().get()); + } + + @Test + void cancellationExceptionWithoutRequestRemainsFailed() { + AtomicReference pendingOperation = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + AsyncOperationController controller = + new AsyncOperationController<>(pendingOperation::set, Runnable::run); + + controller.start("运行中", context -> { + throw new CancellationException("unexpected cancellation"); + }, ignored -> { }, failure::set); + + pendingOperation.get().run(); + + assertEquals(OperationState.FAILED, controller.snapshot().state()); + assertEquals("unexpected cancellation", controller.snapshot().message()); + assertTrue(failure.get() instanceof CancellationException); + } + + @Test + void interruptedCancellationPreservesInterruptFlagAndPublishesCancelled() { + AtomicReference pendingOperation = new AtomicReference<>(); + AtomicBoolean failureCalled = new AtomicBoolean(); + AsyncOperationController controller = + new AsyncOperationController<>(pendingOperation::set, Runnable::run); + + controller.start("运行中", context -> { + throw new InterruptedException("interrupted"); + }, ignored -> { }, ignored -> failureCalled.set(true)); + controller.cancel(); + + try { + pendingOperation.get().run(); + + assertTrue(Thread.currentThread().isInterrupted()); + assertEquals(OperationState.CANCELLED, controller.snapshot().state()); + assertFalse(failureCalled.get()); + } finally { + Thread.interrupted(); + } + } + + @Test + void unexpectedInterruptionPreservesInterruptFlagAndRemainsFailed() { + AtomicReference pendingOperation = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + AsyncOperationController controller = + new AsyncOperationController<>(pendingOperation::set, Runnable::run); + + controller.start("运行中", context -> { + throw new InterruptedException("unexpected interrupt"); + }, ignored -> { }, failure::set); + + try { + pendingOperation.get().run(); + + assertTrue(Thread.currentThread().isInterrupted()); + assertEquals(OperationState.FAILED, controller.snapshot().state()); + assertTrue(failure.get() instanceof InterruptedException); + } finally { + Thread.interrupted(); + } } } diff --git a/src/test/java/com/example/workutils/system/ui/async/LoadingButtonTextTest.java b/src/test/java/com/example/workutils/system/ui/async/LoadingButtonTextTest.java index f69dbfa..a2807bf 100644 --- a/src/test/java/com/example/workutils/system/ui/async/LoadingButtonTextTest.java +++ b/src/test/java/com/example/workutils/system/ui/async/LoadingButtonTextTest.java @@ -165,6 +165,31 @@ class LoadingButtonTextTest { }); } + @Test + void cancelledOperationRestoresIdleTextGraphicAndAccessibility() throws Exception { + runFx(() -> { + AtomicReference pendingOperation = new AtomicReference<>(); + AsyncOperationController controller = + new AsyncOperationController<>(pendingOperation::set, Runnable::run); + Rectangle originalGraphic = new Rectangle(12, 12); + Button button = new Button("开始扫描", originalGraphic); + button.setAccessibleText("扫描文件"); + LoadingButtonBinder.bind(button, controller, "开始扫描"); + + controller.start("扫描中", context -> null, ignored -> { }); + controller.cancel(); + pendingOperation.get().run(); + + assertEquals(OperationState.CANCELLED, controller.snapshot().state()); + assertEquals("开始扫描", button.getText()); + assertSame(originalGraphic, button.getGraphic()); + assertEquals("扫描文件", button.getAccessibleText()); + assertFalse(button.isDisable()); + assertFalse(button.getPseudoClassStates().contains( + PseudoClass.getPseudoClass("running"))); + }); + } + private static void runFx(ThrowingRunnable action) throws Exception { FutureTask task = new FutureTask<>(() -> { action.run(); diff --git a/src/test/java/com/example/workutils/system/ui/async/OperationStateMachineTest.java b/src/test/java/com/example/workutils/system/ui/async/OperationStateMachineTest.java index df144bf..63e5794 100644 --- a/src/test/java/com/example/workutils/system/ui/async/OperationStateMachineTest.java +++ b/src/test/java/com/example/workutils/system/ui/async/OperationStateMachineTest.java @@ -52,4 +52,28 @@ class OperationStateMachineTest { machine.start("再次运行"); assertEquals(OperationState.RUNNING, machine.snapshot().state()); } + + @Test + void cancellationRequestMustReachCancelledBeforeReset() { + OperationStateMachine machine = new OperationStateMachine(); + + machine.start("运行"); + machine.requestCancel(); + machine.cancel(); + + assertEquals(OperationState.CANCELLED, machine.snapshot().state()); + assertEquals("已取消", machine.snapshot().message()); + + machine.reset(); + machine.start("再次运行"); + assertEquals(OperationState.RUNNING, machine.snapshot().state()); + } + + @Test + void runningOperationCannotSkipCancellingState() { + OperationStateMachine machine = new OperationStateMachine(); + machine.start("运行"); + + assertThrows(IllegalStateException.class, machine::cancel); + } }