diff --git a/src/test/java/com/example/workutils/system/ui/NativeUiSceneVisualAcceptanceFixtureTest.java b/src/test/java/com/example/workutils/system/ui/NativeUiSceneVisualAcceptanceFixtureTest.java new file mode 100644 index 0000000..e24772a --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/NativeUiSceneVisualAcceptanceFixtureTest.java @@ -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 captures = (List) capture.invoke(null); + + assertEquals(6, captures.size()); + List actualNames = new ArrayList<>(); + Set 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); + } + } +} diff --git a/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixture.java b/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixture.java index bf27196..6068fed 100644 --- a/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixture.java +++ b/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixture.java @@ -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 REQUIRED_IMAGE_NAMES = CAPTURE_SPECS.stream() .map(CaptureSpec::fileName) .toList(); + private static final List REQUIRED_SCENE_IMAGE_NAMES = CAPTURE_SPECS.stream() + .map(spec -> "scene-" + spec.fileName()) + .toList(); static List requiredImageNames() { return REQUIRED_IMAGE_NAMES; } + static List requiredSceneImageNames() { + return REQUIRED_SCENE_IMAGE_NAMES; + } + static boolean captureEnabled() { return "true".equals(System.getProperty(ENABLED_PROPERTY)); } static List 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 + "="); - } - 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 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 captureConfiguredSceneOutput() throws Exception { + Path outputDirectory = configuredOutputDirectory("JavaFX Scene snapshot capture"); + FixtureWindow fixture = FxTestSupport.fx(() -> FixtureWindow.open(outputDirectory)); + try { + List 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 + "="); + } + 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(); diff --git a/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixtureContractTest.java b/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixtureContractTest.java index d301efe..3c3a2a6 100644 --- a/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixtureContractTest.java +++ b/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixtureContractTest.java @@ -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");