增加真实场景视觉验收截图

This commit is contained in:
张家训
2026-08-01 18:46:15 +08:00
parent 56ca4ef0c4
commit 1290e3dc01
3 changed files with 194 additions and 13 deletions
@@ -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);
}
}
}
@@ -46,7 +46,7 @@ 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.
* Explicitly enabled, test-only visual acceptance fixture for the real application shell.
* The fixture deliberately owns no plugin, configuration, network, Git, or export service.
*/
final class NativeUiVisualAcceptanceFixture {
@@ -66,28 +66,24 @@ final class NativeUiVisualAcceptanceFixture {
private static final List<String> REQUIRED_IMAGE_NAMES = CAPTURE_SPECS.stream()
.map(CaptureSpec::fileName)
.toList();
private static final List<String> REQUIRED_SCENE_IMAGE_NAMES = CAPTURE_SPECS.stream()
.map(spec -> "scene-" + spec.fileName())
.toList();
static List<String> requiredImageNames() {
return REQUIRED_IMAGE_NAMES;
}
static List<String> requiredSceneImageNames() {
return REQUIRED_SCENE_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);
Path outputDirectory = configuredOutputDirectory("Native screenshot capture");
FixtureWindow fixture = FxTestSupport.fx(() -> FixtureWindow.open(outputDirectory));
try {
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 {
FxTestSupport.fx(() -> { });
Thread.sleep(200);
@@ -242,6 +273,29 @@ final class NativeUiVisualAcceptanceFixture {
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() {
operationStatusPane.dispose();
stage.close();
@@ -39,6 +39,20 @@ class NativeUiVisualAcceptanceFixtureContractTest {
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
void fixtureIsDisabledUnlessTheExplicitPropertyIsTrue() throws Exception {
Method captureEnabled = method("captureEnabled");