建立无副作用 UI 视觉验收夹具
This commit is contained in:
@@ -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() {
|
||||
}
|
||||
}
|
||||
+79
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user