建立无副作用 UI 视觉验收夹具

This commit is contained in:
张家训
2026-08-01 18:29:50 +08:00
parent 9b26134e46
commit 56ca4ef0c4
3 changed files with 553 additions and 0 deletions
@@ -0,0 +1,381 @@
package com.example.workutils.system.ui;
import com.example.workutils.system.config.WorkUtilsProperties;
import com.example.workutils.system.ui.async.AsyncOperationController;
import com.example.workutils.system.ui.async.LoadingButtonBinder;
import com.example.workutils.system.ui.async.OperationState;
import com.example.workutils.system.ui.async.OperationStatusPane;
import com.example.workutils.system.ui.icon.AppIcon;
import com.example.workutils.system.ui.icon.IconFactory;
import com.example.workutils.system.ui.shell.NavigationRail;
import com.example.workutils.system.ui.shell.PageHeader;
import javafx.embed.swing.SwingFXUtils;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Rectangle2D;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBase;
import javafx.scene.control.Labeled;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.image.WritableImage;
import javafx.scene.layout.VBox;
import javafx.scene.robot.Robot;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javax.imageio.ImageIO;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Executor;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Explicitly enabled, test-only native screenshot fixture for the real application shell.
* The fixture deliberately owns no plugin, configuration, network, Git, or export service.
*/
final class NativeUiVisualAcceptanceFixture {
static final String ENABLED_PROPERTY = "workutils.visualAcceptance.enabled";
static final String OUTPUT_PROPERTY = "workutils.visualAcceptance.outputDir";
static final String HOLD_OPEN_PROPERTY = "workutils.visualAcceptance.holdOpenMillis";
private static final List<CaptureSpec> CAPTURE_SPECS = List.of(
new CaptureSpec("welcome-expanded-1440.png", View.WELCOME, true, 1440, 900),
new CaptureSpec("welcome-collapsed-1440.png", View.WELCOME, false, 1440, 900),
new CaptureSpec("running-1440.png", View.RUNNING, true, 1440, 900),
new CaptureSpec("welcome-expanded-1024.png", View.WELCOME, true, 1024, 768),
new CaptureSpec("welcome-collapsed-1024.png", View.WELCOME, false, 1024, 768),
new CaptureSpec("running-1024.png", View.RUNNING, true, 1024, 768));
private static final List<String> REQUIRED_IMAGE_NAMES = CAPTURE_SPECS.stream()
.map(CaptureSpec::fileName)
.toList();
static List<String> requiredImageNames() {
return REQUIRED_IMAGE_NAMES;
}
static boolean captureEnabled() {
return "true".equals(System.getProperty(ENABLED_PROPERTY));
}
static List<CapturedImage> captureConfiguredOutput() throws Exception {
if (!captureEnabled()) {
throw new IllegalStateException(
"Native screenshot capture requires -D" + ENABLED_PROPERTY + "=true");
}
String configuredOutput = System.getProperty(OUTPUT_PROPERTY);
if (configuredOutput == null || configuredOutput.isBlank()) {
throw new IllegalStateException(
"Native screenshot capture requires -D" + OUTPUT_PROPERTY + "=<directory>");
}
Path outputDirectory = Path.of(configuredOutput).toAbsolutePath().normalize();
Files.createDirectories(outputDirectory);
FixtureWindow fixture = FxTestSupport.fx(() -> FixtureWindow.open(outputDirectory));
try {
List<CapturedImage> captures = new ArrayList<>();
for (CaptureSpec spec : CAPTURE_SPECS) {
FxTestSupport.fx(() -> fixture.configure(spec));
settleNativeWindow();
holdOpenIfRequested(spec);
captures.add(FxTestSupport.fx(() -> fixture.capture(spec)));
}
return List.copyOf(captures);
} finally {
FxTestSupport.fx(fixture::close);
}
}
private static void settleNativeWindow() throws Exception {
FxTestSupport.fx(() -> { });
Thread.sleep(200);
FxTestSupport.fx(() -> { });
}
static long holdOpenMillis() {
String configured = System.getProperty(HOLD_OPEN_PROPERTY);
if (configured == null || configured.isBlank()) {
return 0;
}
long duration;
try {
duration = Long.parseLong(configured);
} catch (NumberFormatException invalidDuration) {
throw new IllegalArgumentException(
HOLD_OPEN_PROPERTY + " must be an integer number of milliseconds",
invalidDuration);
}
if (duration < 0 || duration > 60_000) {
throw new IllegalArgumentException(
HOLD_OPEN_PROPERTY + " must be between 0 and 60000 milliseconds");
}
return duration;
}
private static void holdOpenIfRequested(CaptureSpec spec) throws InterruptedException {
long duration = holdOpenMillis();
if (duration == 0) {
return;
}
System.out.printf("[visual-acceptance] holding %s for %d ms before Robot capture%n",
spec.fileName(), duration);
Thread.sleep(duration);
}
record CapturedImage(Path path, int width, int height) {
}
private enum View {
WELCOME,
RUNNING
}
private record CaptureSpec(String fileName, View view, boolean expanded,
int stageWidth, int stageHeight) {
}
private static final class FixtureWindow {
private final Path outputDirectory;
private final List<MainWindow.NavItem> navItems;
private final MainWindow mainWindow;
private final Stage stage;
private final NavigationRail navigationRail;
private final ManualExecutor manualExecutor = new ManualExecutor();
private final AsyncOperationController<Void> operationController =
new AsyncOperationController<>(manualExecutor, Runnable::run);
private final Button operationButton = new Button(
"开始生成", IconFactory.createButtonIcon(AppIcon.EXPORT, "开始生成"));
private final OperationStatusPane operationStatusPane =
new OperationStatusPane(operationController, false);
private final VBox runningPane;
private FixtureWindow(Path outputDirectory) throws ReflectiveOperationException {
this.outputDirectory = outputDirectory;
navItems = navigationItems();
mainWindow = new MainWindow(navItems, new WorkUtilsProperties(), () -> { });
stage = field(mainWindow, "stage", Stage.class);
navigationRail = field(mainWindow, "navigationRail", NavigationRail.class);
operationButton.setId("visual-fixture-operation");
operationButton.getStyleClass().add(Theme.CSS_BTN_PRIMARY);
operationButton.setAccessibleText("开始生成视觉验收预览");
LoadingButtonBinder.bind(operationButton, operationController, "开始生成");
operationStatusPane.setId("visual-fixture-status");
runningPane = new VBox(Theme.S4,
new PageHeader("任务执行", "无业务副作用的视觉验收状态"),
operationButton,
operationStatusPane);
runningPane.setPadding(new Insets(Theme.S6));
stage.setMinWidth(0);
stage.setMinHeight(0);
assertSame(StageStyle.DECORATED, stage.getStyle(),
"The fixture must retain the native decorated Stage");
mainWindow.show();
}
static FixtureWindow open(Path outputDirectory) throws ReflectiveOperationException {
assertTrue(javafx.application.Platform.isFxApplicationThread());
Theme.initTheme();
return new FixtureWindow(outputDirectory);
}
void configure(CaptureSpec spec) {
assertTrue(javafx.application.Platform.isFxApplicationThread());
ensureRequestedSizeFitsDesktop(spec);
stage.setWidth(spec.stageWidth());
stage.setHeight(spec.stageHeight());
centerOnPrimaryScreen(spec);
if (spec.view() == View.WELCOME) {
mainWindow.refreshNavItems(navItems);
} else {
showRunningState();
}
navigationRail.setExpanded(spec.expanded());
stage.toFront();
stage.requestFocus();
stage.getScene().getRoot().applyCss();
stage.getScene().getRoot().layout();
validateShell(spec);
}
CapturedImage capture(CaptureSpec spec) throws IOException {
assertTrue(javafx.application.Platform.isFxApplicationThread());
stage.getScene().getRoot().applyCss();
stage.getScene().getRoot().layout();
validateShell(spec);
WritableImage image = new Robot().getScreenCapture(
null, stage.getX(), stage.getY(), stage.getWidth(), stage.getHeight(), false);
Path target = outputDirectory.resolve(spec.fileName());
boolean written = ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png",
target.toFile());
assertTrue(written, () -> "No PNG writer for " + target);
int actualWidth = (int) Math.round(image.getWidth());
int actualHeight = (int) Math.round(image.getHeight());
assertTrue(Files.isRegularFile(target), () -> "Missing capture: " + target);
assertTrue(Files.size(target) > 0, () -> "Empty capture: " + target);
assertTrue(actualWidth >= spec.stageWidth(),
() -> "Capture width " + actualWidth + " < " + spec.stageWidth());
assertTrue(actualHeight >= spec.stageHeight() - 1,
() -> "Capture height " + actualHeight + " < " + spec.stageHeight());
return new CapturedImage(target, actualWidth, actualHeight);
}
void close() {
operationStatusPane.dispose();
stage.close();
}
private void showRunningState() {
mainWindow.embedRaw(null, "任务执行", runningPane);
if (operationController.snapshot().state() != OperationState.RUNNING) {
operationController.start("正在生成预览", context -> null, ignored -> { });
}
assertEquals(OperationState.RUNNING, operationController.snapshot().state());
assertTrue(manualExecutor.hasPendingTask(),
"The executor must hold the operation without executing it");
}
private void validateShell(CaptureSpec spec) {
assertTrue(stage.isShowing());
assertSame(StageStyle.DECORATED, stage.getStyle());
assertEquals(spec.stageWidth(), stage.getWidth(), 1.0,
"Stage width must equal the requested content width");
assertEquals(spec.stageHeight(), stage.getHeight(), 1.0);
assertEquals(spec.expanded() ? DesignTokens.NAV_EXPANDED_WIDTH
: DesignTokens.NAV_COLLAPSED_WIDTH,
navigationRail.getWidth(), 0.5);
Scene scene = stage.getScene();
Node header = scene.lookup("#app-header");
assertNotNull(header);
assertEquals(DesignTokens.HEADER_HEIGHT, header.getBoundsInParent().getHeight(), 0.5);
validateSettingsControl(scene, header);
if (spec.view() == View.RUNNING) {
assertEquals(OperationState.RUNNING, operationController.snapshot().state());
assertTrue(operationButton.isVisible());
assertTrue(operationButton.getGraphic() instanceof ProgressIndicator,
"LoadingButtonBinder must expose its real spinner");
assertTrue(operationButton.getText().contains("正在生成预览"));
assertTrue(operationStatusPane.isVisible());
assertTrue(operationStatusPane.isManaged());
}
}
private void validateSettingsControl(Scene scene, Node header) {
List<ButtonBase> settingsControls = descendants(scene.getRoot()).stream()
.filter(ButtonBase.class::isInstance)
.map(ButtonBase.class::cast)
.filter(Node::isVisible)
.filter(control -> "系统设置".equals(control.getText()))
.toList();
assertEquals(1, settingsControls.size(),
"Exactly one visible system settings control is required");
ButtonBase settings = settingsControls.get(0);
assertSame(scene.lookup("#settings-button"), settings);
assertTrue(descendants((Parent) header).contains(settings));
Bounds position = settings.localToScene(settings.getBoundsInLocal());
assertTrue(position.getMinX() > scene.getWidth() / 2,
"System settings must remain at the top-right");
boolean settingsInNavigation = descendants(navigationRail).stream()
.filter(Labeled.class::isInstance)
.map(Labeled.class::cast)
.anyMatch(control -> "系统设置".equals(control.getText()));
assertFalse(settingsInNavigation, "System settings must not appear in navigation");
}
private void ensureRequestedSizeFitsDesktop(CaptureSpec spec) {
Rectangle2D desktop = Screen.getPrimary().getVisualBounds();
assertTrue(desktop.getWidth() >= spec.stageWidth(),
() -> "Desktop width " + desktop.getWidth()
+ " cannot fit requested Stage width " + spec.stageWidth());
assertTrue(desktop.getHeight() >= spec.stageHeight(),
() -> "Desktop height " + desktop.getHeight()
+ " cannot fit requested Stage height " + spec.stageHeight());
}
private void centerOnPrimaryScreen(CaptureSpec spec) {
Rectangle2D desktop = Screen.getPrimary().getVisualBounds();
stage.setX(desktop.getMinX() + (desktop.getWidth() - spec.stageWidth()) / 2);
stage.setY(desktop.getMinY() + (desktop.getHeight() - spec.stageHeight()) / 2);
}
private static List<MainWindow.NavItem> navigationItems() {
return List.of(
nav(AppIcon.DIFF, "文本对比", "比较文本差异", "diff"),
nav(AppIcon.MYBATIS_GENERATOR, "代码生成", "生成 MyBatis 代码", "generator"),
nav(AppIcon.GIT_SYNC, "Git 同步", "同步本地仓库", "git-sync"),
nav(AppIcon.API_DOC, "API 文档", "导出接口文档", "api-doc"),
nav(AppIcon.SQL_EXPORT, "SQL 导出", "导出查询结果", "sql-export"),
nav(AppIcon.SCANNER, "关键词扫描", "扫描代码关键词", "scan"));
}
private static MainWindow.NavItem nav(AppIcon icon, String name,
String description, String pluginId) {
return new MainWindow.NavItem(icon, name, description, () -> { }, pluginId);
}
private static <T> T field(Object target, String name, Class<T> type)
throws ReflectiveOperationException {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
return type.cast(field.get(target));
}
}
private static List<Node> descendants(Parent parent) {
List<Node> descendants = new ArrayList<>();
Deque<Node> pending = new ArrayDeque<>(parent.getChildrenUnmodifiable());
while (!pending.isEmpty()) {
Node node = pending.removeFirst();
descendants.add(node);
if (node instanceof Parent childParent) {
pending.addAll(childParent.getChildrenUnmodifiable());
}
}
return descendants;
}
private static final class ManualExecutor implements Executor {
private Runnable pendingTask;
@Override
public void execute(Runnable command) {
if (pendingTask != null) {
throw new IllegalStateException("Only one pending visual fixture task is allowed");
}
pendingTask = Objects.requireNonNull(command, "command");
}
boolean hasPendingTask() {
return pendingTask != null;
}
}
private NativeUiVisualAcceptanceFixture() {
}
}
@@ -0,0 +1,79 @@
package com.example.workutils.system.ui;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
class NativeUiVisualAcceptanceFixtureContractTest {
private static final String ENABLED_PROPERTY =
"workutils.visualAcceptance.enabled";
private static final String HOLD_OPEN_PROPERTY =
"workutils.visualAcceptance.holdOpenMillis";
@AfterEach
void clearCaptureOptIn() {
System.clearProperty(ENABLED_PROPERTY);
System.clearProperty(HOLD_OPEN_PROPERTY);
}
@Test
void fixtureDeclaresEveryRequiredNativeCapture() throws Exception {
Class<?> fixture = loadFixture();
Method requiredImageNames = fixture.getDeclaredMethod("requiredImageNames");
assertEquals(List.of(
"welcome-expanded-1440.png",
"welcome-collapsed-1440.png",
"running-1440.png",
"welcome-expanded-1024.png",
"welcome-collapsed-1024.png",
"running-1024.png"),
requiredImageNames.invoke(null));
}
@Test
void fixtureIsDisabledUnlessTheExplicitPropertyIsTrue() throws Exception {
Method captureEnabled = method("captureEnabled");
assertFalse((boolean) captureEnabled.invoke(null));
System.setProperty(ENABLED_PROPERTY, "true");
assertTrue((boolean) captureEnabled.invoke(null));
System.setProperty(ENABLED_PROPERTY, "yes");
assertFalse((boolean) captureEnabled.invoke(null));
}
@Test
void holdOpenDefaultsToZeroAndRequiresAnExplicitDuration() throws Exception {
Method holdOpenMillis = method("holdOpenMillis");
assertEquals(0L, holdOpenMillis.invoke(null));
System.setProperty(HOLD_OPEN_PROPERTY, "1500");
assertEquals(1500L, holdOpenMillis.invoke(null));
}
private static Method method(String name) {
try {
return loadFixture().getDeclaredMethod(name);
} catch (NoSuchMethodException missingContract) {
return fail("Missing fixture contract method: " + name, missingContract);
}
}
private static Class<?> loadFixture() {
try {
return Class.forName(
"com.example.workutils.system.ui.NativeUiVisualAcceptanceFixture");
} catch (ClassNotFoundException missingFixture) {
return fail("Missing reusable native JavaFX visual acceptance fixture",
missingFixture);
}
}
}
@@ -0,0 +1,93 @@
package com.example.workutils.system.ui;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.awt.image.BufferedImage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
import javax.imageio.ImageIO;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
class NativeUiVisualAcceptanceFixtureTest {
private static final String OUTPUT_PROPERTY =
"workutils.visualAcceptance.outputDir";
@Test
void capturesSixDecoratedSideEffectFreeShellStates() throws Exception {
Assumptions.assumeTrue(NativeUiVisualAcceptanceFixture.captureEnabled(),
() -> "Enable explicitly with -D"
+ NativeUiVisualAcceptanceFixture.ENABLED_PROPERTY + "=true");
assertNotNull(System.getProperty(OUTPUT_PROPERTY),
() -> "Missing -D" + OUTPUT_PROPERTY);
Method capture = captureMethod();
@SuppressWarnings("unchecked")
List<Object> captures = (List<Object>) capture.invoke(null);
assertEquals(6, captures.size());
Set<String> imageHashes = new HashSet<>();
for (Object captured : captures) {
Method pathAccessor = captured.getClass().getDeclaredMethod("path");
Method widthAccessor = captured.getClass().getDeclaredMethod("width");
Method heightAccessor = captured.getClass().getDeclaredMethod("height");
Path path = (Path) pathAccessor.invoke(captured);
int width = (int) widthAccessor.invoke(captured);
int height = (int) heightAccessor.invoke(captured);
assertTrue(Files.isRegularFile(path), () -> "Missing capture: " + path);
assertTrue(Files.size(path) > 0, () -> "Empty capture: " + path);
assertTrue(width >= 1024, () -> "Capture too narrow: " + path + " " + width);
assertTrue(height >= 700, () -> "Capture too short: " + path + " " + height);
assertTrue(hasVisibleDesktopContent(path),
() -> "Robot returned a blank or black desktop capture: " + path);
imageHashes.add(sha256(path));
}
assertEquals(6, imageHashes.size(),
"Every requested UI state and width must produce a distinct capture");
}
private static Method captureMethod() {
try {
return NativeUiVisualAcceptanceFixture.class
.getDeclaredMethod("captureConfiguredOutput");
} catch (NoSuchMethodException missingCapture) {
return fail("Missing native JavaFX capture entry point", missingCapture);
}
}
private static boolean hasVisibleDesktopContent(Path path) throws Exception {
BufferedImage image = ImageIO.read(path.toFile());
long samples = 0;
long visible = 0;
for (int y = 0; y < image.getHeight(); y += 16) {
for (int x = 0; x < image.getWidth(); x += 16) {
int rgb = image.getRGB(x, y);
int red = (rgb >>> 16) & 0xff;
int green = (rgb >>> 8) & 0xff;
int blue = rgb & 0xff;
samples++;
if (red > 32 || green > 32 || blue > 32) {
visible++;
}
}
}
return visible * 4 > samples;
}
private static String sha256(Path path) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(Files.readAllBytes(path)));
}
}