补齐异步取消终态语义
This commit is contained in:
@@ -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<T> {
|
||||
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<T> {
|
||||
|
||||
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);
|
||||
|
||||
@@ -4,6 +4,7 @@ public enum OperationState {
|
||||
IDLE,
|
||||
RUNNING,
|
||||
CANCELLING,
|
||||
CANCELLED,
|
||||
SUCCEEDED,
|
||||
FAILED
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ public final class OperationStatusPane extends HBox {
|
||||
case IDLE -> "就绪";
|
||||
case RUNNING -> "处理中…";
|
||||
case CANCELLING -> "正在停止…";
|
||||
case CANCELLED -> "已取消";
|
||||
case SUCCEEDED -> "操作完成";
|
||||
case FAILED -> "操作失败";
|
||||
};
|
||||
|
||||
@@ -127,6 +127,33 @@ class SharedPaneAccessibilityTest {
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelledStatusShowsTerminalMessageWithoutSuccessStyleOrCancelButton()
|
||||
throws Exception {
|
||||
runFx(() -> {
|
||||
AtomicReference<Runnable> pendingOperation = new AtomicReference<>();
|
||||
AsyncOperationController<Void> 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(() -> {
|
||||
|
||||
+121
-4
@@ -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<String> controller =
|
||||
new AsyncOperationController<>(executor, Runnable::run);
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch observedCancellation = new CountDownLatch(1);
|
||||
CountDownLatch finished = new CountDownLatch(1);
|
||||
|
||||
AtomicReference<String> 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<Runnable> pendingOperation = new AtomicReference<>();
|
||||
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
AsyncOperationController<String> 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<Runnable> pendingOperation = new AtomicReference<>();
|
||||
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
AsyncOperationController<String> 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<Runnable> pendingOperation = new AtomicReference<>();
|
||||
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
AsyncOperationController<String> 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<Runnable> pendingOperation = new AtomicReference<>();
|
||||
AtomicBoolean failureCalled = new AtomicBoolean();
|
||||
AsyncOperationController<String> 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<Runnable> pendingOperation = new AtomicReference<>();
|
||||
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
AsyncOperationController<String> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,31 @@ class LoadingButtonTextTest {
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelledOperationRestoresIdleTextGraphicAndAccessibility() throws Exception {
|
||||
runFx(() -> {
|
||||
AtomicReference<Runnable> pendingOperation = new AtomicReference<>();
|
||||
AsyncOperationController<Void> 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<Void> task = new FutureTask<>(() -> {
|
||||
action.run();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user