增加真实场景视觉验收截图
This commit is contained in:
+113
@@ -0,0 +1,113 @@
|
|||||||
|
package com.example.workutils.system.ui;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assumptions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
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 NativeUiSceneVisualAcceptanceFixtureTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void capturesSixDistinctRealJavaFxScenesWithoutSynthesizingNativeChrome() throws Exception {
|
||||||
|
Assumptions.assumeTrue(NativeUiVisualAcceptanceFixture.captureEnabled(),
|
||||||
|
() -> "Enable explicitly with -D"
|
||||||
|
+ NativeUiVisualAcceptanceFixture.ENABLED_PROPERTY + "=true");
|
||||||
|
assertNotNull(System.getProperty(NativeUiVisualAcceptanceFixture.OUTPUT_PROPERTY),
|
||||||
|
() -> "Missing -D" + NativeUiVisualAcceptanceFixture.OUTPUT_PROPERTY);
|
||||||
|
|
||||||
|
Method capture = captureMethod();
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Object> captures = (List<Object>) capture.invoke(null);
|
||||||
|
|
||||||
|
assertEquals(6, captures.size());
|
||||||
|
List<String> actualNames = new ArrayList<>();
|
||||||
|
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);
|
||||||
|
BufferedImage image = ImageIO.read(path.toFile());
|
||||||
|
|
||||||
|
actualNames.add(path.getFileName().toString());
|
||||||
|
assertTrue(Files.isRegularFile(path), () -> "Missing scene capture: " + path);
|
||||||
|
assertTrue(Files.size(path) > 0, () -> "Empty scene capture: " + path);
|
||||||
|
assertNotNull(image, () -> "Unreadable scene PNG: " + path);
|
||||||
|
assertEquals(image.getWidth(), width);
|
||||||
|
assertEquals(image.getHeight(), height);
|
||||||
|
assertSceneDimensions(path, width, height);
|
||||||
|
assertTrue(hasVisibleContent(image),
|
||||||
|
() -> "Scene snapshot is blank or black: " + path);
|
||||||
|
imageHashes.add(sha256(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(List.of(
|
||||||
|
"scene-welcome-expanded-1440.png",
|
||||||
|
"scene-welcome-collapsed-1440.png",
|
||||||
|
"scene-running-1440.png",
|
||||||
|
"scene-welcome-expanded-1024.png",
|
||||||
|
"scene-welcome-collapsed-1024.png",
|
||||||
|
"scene-running-1024.png"), actualNames);
|
||||||
|
assertEquals(6, imageHashes.size(),
|
||||||
|
"Every real Scene state and width must produce a distinct image");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertSceneDimensions(Path path, int width, int height) {
|
||||||
|
String name = path.getFileName().toString();
|
||||||
|
int stageWidth = name.contains("1440") ? 1440 : 1024;
|
||||||
|
int stageHeight = name.contains("1440") ? 900 : 768;
|
||||||
|
assertTrue(width <= stageWidth && width >= stageWidth - 32,
|
||||||
|
() -> "Unexpected Scene width for " + path + ": " + width);
|
||||||
|
assertTrue(height <= stageHeight && height >= stageHeight - 80,
|
||||||
|
() -> "Unexpected Scene height for " + path + ": " + height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasVisibleContent(BufferedImage image) {
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Method captureMethod() {
|
||||||
|
try {
|
||||||
|
return NativeUiVisualAcceptanceFixture.class
|
||||||
|
.getDeclaredMethod("captureConfiguredSceneOutput");
|
||||||
|
} catch (NoSuchMethodException missingCapture) {
|
||||||
|
return fail("Missing real JavaFX Scene snapshot entry point", missingCapture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
-13
@@ -46,7 +46,7 @@ import static org.junit.jupiter.api.Assertions.assertSame;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explicitly enabled, test-only native screenshot fixture for the real application shell.
|
* Explicitly enabled, test-only visual acceptance fixture for the real application shell.
|
||||||
* The fixture deliberately owns no plugin, configuration, network, Git, or export service.
|
* The fixture deliberately owns no plugin, configuration, network, Git, or export service.
|
||||||
*/
|
*/
|
||||||
final class NativeUiVisualAcceptanceFixture {
|
final class NativeUiVisualAcceptanceFixture {
|
||||||
@@ -66,28 +66,24 @@ final class NativeUiVisualAcceptanceFixture {
|
|||||||
private static final List<String> REQUIRED_IMAGE_NAMES = CAPTURE_SPECS.stream()
|
private static final List<String> REQUIRED_IMAGE_NAMES = CAPTURE_SPECS.stream()
|
||||||
.map(CaptureSpec::fileName)
|
.map(CaptureSpec::fileName)
|
||||||
.toList();
|
.toList();
|
||||||
|
private static final List<String> REQUIRED_SCENE_IMAGE_NAMES = CAPTURE_SPECS.stream()
|
||||||
|
.map(spec -> "scene-" + spec.fileName())
|
||||||
|
.toList();
|
||||||
|
|
||||||
static List<String> requiredImageNames() {
|
static List<String> requiredImageNames() {
|
||||||
return REQUIRED_IMAGE_NAMES;
|
return REQUIRED_IMAGE_NAMES;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<String> requiredSceneImageNames() {
|
||||||
|
return REQUIRED_SCENE_IMAGE_NAMES;
|
||||||
|
}
|
||||||
|
|
||||||
static boolean captureEnabled() {
|
static boolean captureEnabled() {
|
||||||
return "true".equals(System.getProperty(ENABLED_PROPERTY));
|
return "true".equals(System.getProperty(ENABLED_PROPERTY));
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<CapturedImage> captureConfiguredOutput() throws Exception {
|
static List<CapturedImage> captureConfiguredOutput() throws Exception {
|
||||||
if (!captureEnabled()) {
|
Path outputDirectory = configuredOutputDirectory("Native screenshot capture");
|
||||||
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));
|
FixtureWindow fixture = FxTestSupport.fx(() -> FixtureWindow.open(outputDirectory));
|
||||||
try {
|
try {
|
||||||
List<CapturedImage> captures = new ArrayList<>();
|
List<CapturedImage> captures = new ArrayList<>();
|
||||||
@@ -103,6 +99,41 @@ final class NativeUiVisualAcceptanceFixture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the real JavaFX Scene tree only. Native window chrome is intentionally excluded
|
||||||
|
* and remains covered by the decorated-Stage assertions and Robot capture path above.
|
||||||
|
*/
|
||||||
|
static List<CapturedImage> captureConfiguredSceneOutput() throws Exception {
|
||||||
|
Path outputDirectory = configuredOutputDirectory("JavaFX Scene snapshot capture");
|
||||||
|
FixtureWindow fixture = FxTestSupport.fx(() -> FixtureWindow.open(outputDirectory));
|
||||||
|
try {
|
||||||
|
List<CapturedImage> captures = new ArrayList<>();
|
||||||
|
for (CaptureSpec spec : CAPTURE_SPECS) {
|
||||||
|
FxTestSupport.fx(() -> fixture.configure(spec));
|
||||||
|
settleNativeWindow();
|
||||||
|
captures.add(FxTestSupport.fx(() -> fixture.captureScene(spec)));
|
||||||
|
}
|
||||||
|
return List.copyOf(captures);
|
||||||
|
} finally {
|
||||||
|
FxTestSupport.fx(fixture::close);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path configuredOutputDirectory(String captureLabel) throws IOException {
|
||||||
|
if (!captureEnabled()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
captureLabel + " requires -D" + ENABLED_PROPERTY + "=true");
|
||||||
|
}
|
||||||
|
String configuredOutput = System.getProperty(OUTPUT_PROPERTY);
|
||||||
|
if (configuredOutput == null || configuredOutput.isBlank()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
captureLabel + " requires -D" + OUTPUT_PROPERTY + "=<directory>");
|
||||||
|
}
|
||||||
|
Path outputDirectory = Path.of(configuredOutput).toAbsolutePath().normalize();
|
||||||
|
Files.createDirectories(outputDirectory);
|
||||||
|
return outputDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
private static void settleNativeWindow() throws Exception {
|
private static void settleNativeWindow() throws Exception {
|
||||||
FxTestSupport.fx(() -> { });
|
FxTestSupport.fx(() -> { });
|
||||||
Thread.sleep(200);
|
Thread.sleep(200);
|
||||||
@@ -242,6 +273,29 @@ final class NativeUiVisualAcceptanceFixture {
|
|||||||
return new CapturedImage(target, actualWidth, actualHeight);
|
return new CapturedImage(target, actualWidth, actualHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CapturedImage captureScene(CaptureSpec spec) throws IOException {
|
||||||
|
assertTrue(javafx.application.Platform.isFxApplicationThread());
|
||||||
|
Scene scene = stage.getScene();
|
||||||
|
scene.getRoot().applyCss();
|
||||||
|
scene.getRoot().layout();
|
||||||
|
validateShell(spec);
|
||||||
|
|
||||||
|
WritableImage image = scene.snapshot(null);
|
||||||
|
Path target = outputDirectory.resolve("scene-" + 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());
|
||||||
|
assertEquals(scene.getWidth(), actualWidth, 1.0,
|
||||||
|
"Scene snapshot width must match the real Scene");
|
||||||
|
assertEquals(scene.getHeight(), actualHeight, 1.0,
|
||||||
|
"Scene snapshot height must match the real Scene");
|
||||||
|
assertTrue(Files.isRegularFile(target), () -> "Missing Scene capture: " + target);
|
||||||
|
assertTrue(Files.size(target) > 0, () -> "Empty Scene capture: " + target);
|
||||||
|
return new CapturedImage(target, actualWidth, actualHeight);
|
||||||
|
}
|
||||||
|
|
||||||
void close() {
|
void close() {
|
||||||
operationStatusPane.dispose();
|
operationStatusPane.dispose();
|
||||||
stage.close();
|
stage.close();
|
||||||
|
|||||||
+14
@@ -39,6 +39,20 @@ class NativeUiVisualAcceptanceFixtureContractTest {
|
|||||||
requiredImageNames.invoke(null));
|
requiredImageNames.invoke(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fixtureDeclaresEveryRequiredRealSceneCapture() throws Exception {
|
||||||
|
Method requiredSceneImageNames = method("requiredSceneImageNames");
|
||||||
|
|
||||||
|
assertEquals(List.of(
|
||||||
|
"scene-welcome-expanded-1440.png",
|
||||||
|
"scene-welcome-collapsed-1440.png",
|
||||||
|
"scene-running-1440.png",
|
||||||
|
"scene-welcome-expanded-1024.png",
|
||||||
|
"scene-welcome-collapsed-1024.png",
|
||||||
|
"scene-running-1024.png"),
|
||||||
|
requiredSceneImageNames.invoke(null));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void fixtureIsDisabledUnlessTheExplicitPropertyIsTrue() throws Exception {
|
void fixtureIsDisabledUnlessTheExplicitPropertyIsTrue() throws Exception {
|
||||||
Method captureEnabled = method("captureEnabled");
|
Method captureEnabled = method("captureEnabled");
|
||||||
|
|||||||
Reference in New Issue
Block a user