From 56ca4ef0c4b401fc9b5bd04e764548cdc916c1d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Sat, 1 Aug 2026 18:29:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BB=BA=E7=AB=8B=E6=97=A0=E5=89=AF=E4=BD=9C?= =?UTF-8?q?=E7=94=A8=20UI=20=E8=A7=86=E8=A7=89=E9=AA=8C=E6=94=B6=E5=A4=B9?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/NativeUiVisualAcceptanceFixture.java | 381 ++++++++++++++++++ ...UiVisualAcceptanceFixtureContractTest.java | 79 ++++ .../NativeUiVisualAcceptanceFixtureTest.java | 93 +++++ 3 files changed, 553 insertions(+) create mode 100644 src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixture.java create mode 100644 src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixtureContractTest.java create mode 100644 src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixtureTest.java diff --git a/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixture.java b/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixture.java new file mode 100644 index 0000000..bf27196 --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixture.java @@ -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 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 REQUIRED_IMAGE_NAMES = CAPTURE_SPECS.stream() + .map(CaptureSpec::fileName) + .toList(); + + static List requiredImageNames() { + return REQUIRED_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); + + FixtureWindow fixture = FxTestSupport.fx(() -> FixtureWindow.open(outputDirectory)); + try { + List 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 navItems; + private final MainWindow mainWindow; + private final Stage stage; + private final NavigationRail navigationRail; + private final ManualExecutor manualExecutor = new ManualExecutor(); + private final AsyncOperationController 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 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 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 field(Object target, String name, Class type) + throws ReflectiveOperationException { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + return type.cast(field.get(target)); + } + } + + private static List descendants(Parent parent) { + List descendants = new ArrayList<>(); + Deque 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() { + } +} diff --git a/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixtureContractTest.java b/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixtureContractTest.java new file mode 100644 index 0000000..d301efe --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixtureContractTest.java @@ -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); + } + } +} diff --git a/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixtureTest.java b/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixtureTest.java new file mode 100644 index 0000000..213954a --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixtureTest.java @@ -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 captures = (List) capture.invoke(null); + + assertEquals(6, captures.size()); + 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); + 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))); + } +}