diff --git a/src/main/java/com/example/workutils/system/ui/MainWindow.java b/src/main/java/com/example/workutils/system/ui/MainWindow.java index 059a17a..c28180b 100644 --- a/src/main/java/com/example/workutils/system/ui/MainWindow.java +++ b/src/main/java/com/example/workutils/system/ui/MainWindow.java @@ -121,9 +121,20 @@ public class MainWindow { menuButton.setFocusTraversable(true); menuButton.setOnAction(event -> navigationRail.toggle()); - Label brand = new Label("WorkUtils", IconFactory.create(AppIcon.APP, 20)); - brand.setContentDisplay(ContentDisplay.LEFT); - brand.setFont(Theme.sectionTitle()); + FontIcon brandIcon = IconFactory.create(AppIcon.APP, 18); + StackPane brandIconContainer = new StackPane(brandIcon); + brandIconContainer.setId("app-brand-icon-container"); + brandIconContainer.getStyleClass().add("app-brand-icon"); + brandIconContainer.setMinSize(32, 32); + brandIconContainer.setPrefSize(32, 32); + brandIconContainer.setMaxSize(32, 32); + brandIconContainer.setAccessibleText("WorkUtils 应用图标"); + brandIconContainer.setFocusTraversable(false); + + Label brandText = new Label("WorkUtils"); + brandText.setFont(Theme.sectionTitle()); + HBox brand = new HBox(Theme.S2, brandIconContainer, brandText); + brand.setAlignment(Pos.CENTER_LEFT); toolbarChevron = IconFactory.create(AppIcon.CHEVRON_RIGHT, 14); toolbarChevron.setVisible(false); diff --git a/src/main/java/com/example/workutils/system/ui/shell/NavigationRail.java b/src/main/java/com/example/workutils/system/ui/shell/NavigationRail.java index 189bd0c..f89be0d 100644 --- a/src/main/java/com/example/workutils/system/ui/shell/NavigationRail.java +++ b/src/main/java/com/example/workutils/system/ui/shell/NavigationRail.java @@ -9,6 +9,7 @@ import javafx.application.Platform; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.ReadOnlyBooleanWrapper; import javafx.css.PseudoClass; +import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.control.ContentDisplay; @@ -54,7 +55,13 @@ public final class NavigationRail extends VBox { buildBottomToggle(); Separator separator = new Separator(); + separator.setId("navigation-rail-separator"); separator.getStyleClass().add(Theme.CSS_NAV_SEP); + separator.setPadding(Insets.EMPTY); + separator.setMinHeight(1); + separator.setPrefHeight(1); + separator.setMaxHeight(1); + separator.setFocusTraversable(false); getChildren().addAll(itemBox, separator, collapseButton); expanded.addListener((observable, oldValue, newValue) -> updateAppearance()); diff --git a/src/main/resources/css/light-theme.css b/src/main/resources/css/light-theme.css index 19fe6b8..de7225b 100644 --- a/src/main/resources/css/light-theme.css +++ b/src/main/resources/css/light-theme.css @@ -30,7 +30,18 @@ } .nav-separator { - -fx-background-color: -workutils-border; + -fx-padding: 0; + -fx-min-height: 1px; + -fx-pref-height: 1px; + -fx-max-height: 1px; + -fx-background-color: transparent; +} + +.nav-separator .line { + -fx-border-color: -workutils-border transparent transparent transparent; + -fx-border-width: 1px 0 0 0; + -fx-border-insets: 0; + -fx-padding: 0; } /* ============ ToggleButton 导航 ============ */ @@ -99,6 +110,22 @@ -fx-max-height: 48px; } +.app-brand-icon { + -fx-alignment: center; + -fx-background-color: -workutils-primary; + -fx-background-radius: 6px; + -fx-min-width: 32px; + -fx-pref-width: 32px; + -fx-max-width: 32px; + -fx-min-height: 32px; + -fx-pref-height: 32px; + -fx-max-height: 32px; +} + +.app-brand-icon .app-icon { + -fx-icon-color: white; +} + /* ---- 内容区 ---- */ .content-area { -fx-background-color: -workutils-bg; @@ -153,6 +180,17 @@ -fx-background-color: derive(-workutils-primary, -12%); } +.btn-primary:disabled:running { + -fx-opacity: 1; + -fx-background-color: -workutils-primary; + -fx-text-fill: white; +} + +.btn-primary:disabled:running .loading-spinner { + -fx-progress-color: white; + -fx-opacity: 1; +} + .btn-danger { -fx-background-color: -workutils-danger; -fx-text-fill: white; diff --git a/src/test/java/com/example/workutils/system/ui/FxTestSupport.java b/src/test/java/com/example/workutils/system/ui/FxTestSupport.java index 831b58e..4ea9283 100644 --- a/src/test/java/com/example/workutils/system/ui/FxTestSupport.java +++ b/src/test/java/com/example/workutils/system/ui/FxTestSupport.java @@ -18,6 +18,7 @@ public final class FxTestSupport { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); } + Platform.setImplicitExit(false); } public static T fx(Callable action) throws Exception { diff --git a/src/test/java/com/example/workutils/system/ui/FxTestSupportTest.java b/src/test/java/com/example/workutils/system/ui/FxTestSupportTest.java new file mode 100644 index 0000000..f30f5e4 --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/FxTestSupportTest.java @@ -0,0 +1,17 @@ +package com.example.workutils.system.ui; + +import javafx.application.Platform; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +class FxTestSupportTest { + + @Test + void keepsToolkitAliveAcrossSequentialJavaFxTestClasses() throws Exception { + FxTestSupport.fx(() -> { }); + + assertFalse(Platform.isImplicitExit(), + "closing the last test Stage must not terminate the shared JavaFX toolkit"); + } +} diff --git a/src/test/java/com/example/workutils/system/ui/LightThemeCssRuntimeTest.java b/src/test/java/com/example/workutils/system/ui/LightThemeCssRuntimeTest.java index 7a6d482..c531835 100644 --- a/src/test/java/com/example/workutils/system/ui/LightThemeCssRuntimeTest.java +++ b/src/test/java/com/example/workutils/system/ui/LightThemeCssRuntimeTest.java @@ -1,9 +1,16 @@ package com.example.workutils.system.ui; +import com.example.workutils.system.ui.async.AsyncOperationController; +import com.example.workutils.system.ui.async.LoadingButtonBinder; +import com.example.workutils.system.ui.icon.AppIcon; +import com.example.workutils.system.ui.icon.IconFactory; import javafx.beans.property.SimpleStringProperty; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.css.CssParser; +import javafx.css.CssMetaData; +import javafx.css.PseudoClass; +import javafx.css.Styleable; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ComboBox; @@ -20,8 +27,10 @@ import javafx.scene.control.TextField; import javafx.scene.control.TreeCell; import javafx.scene.control.TreeItem; import javafx.scene.control.TreeView; +import javafx.scene.control.ProgressIndicator; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -32,6 +41,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import static com.example.workutils.system.ui.FxTestSupport.fx; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -89,6 +99,46 @@ class LightThemeCssRuntimeTest { }); } + @Test + void disabledRunningPrimaryButtonKeepsPrimaryContrastAndWhiteSpinner() throws Exception { + fx(() -> { + AtomicReference pendingOperation = new AtomicReference<>(); + AsyncOperationController controller = + new AsyncOperationController<>(pendingOperation::set, Runnable::run); + Button button = new Button( + "开始生成", IconFactory.createButtonIcon(AppIcon.EXPORT, "开始生成")); + button.getStyleClass().add(Theme.CSS_BTN_PRIMARY); + LoadingButtonBinder.bind(button, controller, "开始生成"); + + VBox root = new VBox(button); + Scene scene = new Scene(root, 320, 120); + scene.getStylesheets().add(copyRealStylesheetToUniqueUrl()); + root.applyCss(); + root.layout(); + + controller.start("正在生成预览", context -> null, ignored -> { }); + root.applyCss(); + root.layout(); + + assertTrue(button.isDisabled(), "running buttons must remain non-interactive"); + assertTrue(button.getPseudoClassStates().contains( + PseudoClass.getPseudoClass("running"))); + assertEquals(Color.web("#2563eb"), + button.getBackground().getFills().get(0).getFill()); + assertEquals(Color.WHITE, button.getTextFill()); + assertEquals(1, button.getOpacity(), 0.001, + "AtlantaFX disabled opacity must not wash out a running primary action"); + + ProgressIndicator spinner = assertInstanceOf( + ProgressIndicator.class, button.getGraphic()); + assertEquals(14, spinner.getPrefWidth()); + assertEquals(14, spinner.getPrefHeight()); + assertEquals(Color.WHITE, computedCssValue(spinner, "-fx-progress-color")); + assertTrue(pendingOperation.get() != null, + "the no-op executor must keep the fixture in RUNNING state"); + }); + } + @Test void repeatedUniqueLoadsPreserveAndRestoreParserErrorState() throws Exception { fx(() -> { @@ -267,6 +317,15 @@ class LightThemeCssRuntimeTest { .orElseThrow(); } + @SuppressWarnings({"rawtypes", "unchecked"}) + private static Object computedCssValue(Styleable styleable, String property) { + CssMetaData metadata = styleable.getCssMetaData().stream() + .filter(candidate -> candidate.getProperty().equals(property)) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing CSS property " + property)); + return metadata.getStyleableProperty(styleable).getValue(); + } + private record Fixture( Label pageTitle, Label sectionTitle, diff --git a/src/test/java/com/example/workutils/system/ui/NativeUiSceneVisualAcceptanceFixtureTest.java b/src/test/java/com/example/workutils/system/ui/NativeUiSceneVisualAcceptanceFixtureTest.java index e24772a..0d48c51 100644 --- a/src/test/java/com/example/workutils/system/ui/NativeUiSceneVisualAcceptanceFixtureTest.java +++ b/src/test/java/com/example/workutils/system/ui/NativeUiSceneVisualAcceptanceFixtureTest.java @@ -23,7 +23,7 @@ import static org.junit.jupiter.api.Assertions.fail; class NativeUiSceneVisualAcceptanceFixtureTest { @Test - void capturesSixDistinctRealJavaFxScenesWithoutSynthesizingNativeChrome() throws Exception { + void capturesTenDistinctRealJavaFxScenesWithoutSynthesizingNativeChrome() throws Exception { Assumptions.assumeTrue(NativeUiVisualAcceptanceFixture.captureEnabled(), () -> "Enable explicitly with -D" + NativeUiVisualAcceptanceFixture.ENABLED_PROPERTY + "=true"); @@ -34,7 +34,7 @@ class NativeUiSceneVisualAcceptanceFixtureTest { @SuppressWarnings("unchecked") List captures = (List) capture.invoke(null); - assertEquals(6, captures.size()); + assertEquals(10, captures.size()); List actualNames = new ArrayList<>(); Set imageHashes = new HashSet<>(); for (Object captured : captures) { @@ -64,8 +64,12 @@ class NativeUiSceneVisualAcceptanceFixtureTest { "scene-running-1440.png", "scene-welcome-expanded-1024.png", "scene-welcome-collapsed-1024.png", - "scene-running-1024.png"), actualNames); - assertEquals(6, imageHashes.size(), + "scene-running-1024.png", + "scene-selected-expanded-1440.png", + "scene-selected-collapsed-1440.png", + "scene-selected-expanded-1024.png", + "scene-selected-collapsed-1024.png"), actualNames); + assertEquals(10, imageHashes.size(), "Every real Scene state and width must produce a distinct image"); } 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 6068fed..8c07f0d 100644 --- a/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixture.java +++ b/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixture.java @@ -18,10 +18,13 @@ import javafx.scene.Parent; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ButtonBase; +import javafx.scene.control.Label; import javafx.scene.control.Labeled; import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.ToggleButton; import javafx.scene.image.WritableImage; import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; import javafx.scene.robot.Robot; import javafx.stage.Screen; import javafx.stage.Stage; @@ -39,6 +42,8 @@ import java.util.List; import java.util.Objects; import java.util.concurrent.Executor; +import org.kordamp.ikonli.javafx.FontIcon; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -62,11 +67,20 @@ final class NativeUiVisualAcceptanceFixture { 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 SCENE_CAPTURE_SPECS = sceneCaptureSpecs(); + private static final List NAVIGATION_LABELS = List.of( + "Git仓库同步", + "Git分支评审", + "文本对比", + "MyBatis SQL导出", + "MyBatis逆向工程", + "扫描功能", + "API 接口文档导出"); 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() + private static final List REQUIRED_SCENE_IMAGE_NAMES = SCENE_CAPTURE_SPECS.stream() .map(spec -> "scene-" + spec.fileName()) .toList(); @@ -78,6 +92,10 @@ final class NativeUiVisualAcceptanceFixture { return REQUIRED_SCENE_IMAGE_NAMES; } + static List navigationLabels() { + return NAVIGATION_LABELS; + } + static boolean captureEnabled() { return "true".equals(System.getProperty(ENABLED_PROPERTY)); } @@ -105,18 +123,18 @@ final class NativeUiVisualAcceptanceFixture { */ 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) { + List captures = new ArrayList<>(); + for (CaptureSpec spec : SCENE_CAPTURE_SPECS) { + FixtureWindow fixture = FxTestSupport.fx(() -> FixtureWindow.open(outputDirectory)); + try { FxTestSupport.fx(() -> fixture.configure(spec)); settleNativeWindow(); captures.add(FxTestSupport.fx(() -> fixture.captureScene(spec))); + } finally { + FxTestSupport.fx(fixture::close); } - return List.copyOf(captures); - } finally { - FxTestSupport.fx(fixture::close); } + return List.copyOf(captures); } private static Path configuredOutputDirectory(String captureLabel) throws IOException { @@ -175,6 +193,7 @@ final class NativeUiVisualAcceptanceFixture { private enum View { WELCOME, + SELECTED, RUNNING } @@ -189,6 +208,7 @@ final class NativeUiVisualAcceptanceFixture { private final MainWindow mainWindow; private final Stage stage; private final NavigationRail navigationRail; + private final Label toolbarTitle; private final ManualExecutor manualExecutor = new ManualExecutor(); private final AsyncOperationController operationController = new AsyncOperationController<>(manualExecutor, Runnable::run); @@ -197,14 +217,10 @@ final class NativeUiVisualAcceptanceFixture { private final OperationStatusPane operationStatusPane = new OperationStatusPane(operationController, false); private final VBox runningPane; + private int apiDocOpens; 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("开始生成视觉验收预览"); @@ -217,6 +233,12 @@ final class NativeUiVisualAcceptanceFixture { operationStatusPane); runningPane.setPadding(new Insets(Theme.S6)); + navItems = navigationItems(this::openApiDocFixture); + mainWindow = new MainWindow(navItems, new WorkUtilsProperties(), () -> { }); + stage = field(mainWindow, "stage", Stage.class); + navigationRail = field(mainWindow, "navigationRail", NavigationRail.class); + toolbarTitle = field(mainWindow, "toolbarTitle", Label.class); + stage.setMinWidth(0); stage.setMinHeight(0); assertSame(StageStyle.DECORATED, stage.getStyle(), @@ -240,7 +262,7 @@ final class NativeUiVisualAcceptanceFixture { if (spec.view() == View.WELCOME) { mainWindow.refreshNavItems(navItems); } else { - showRunningState(); + showApiDocState(spec.view() == View.RUNNING); } navigationRail.setExpanded(spec.expanded()); stage.toFront(); @@ -301,14 +323,28 @@ final class NativeUiVisualAcceptanceFixture { stage.close(); } - private void showRunningState() { - mainWindow.embedRaw(null, "任务执行", runningPane); - if (operationController.snapshot().state() != OperationState.RUNNING) { + private void showApiDocState(boolean running) { + int opensBefore = apiDocOpens; + navigationRail.selectPlugin("api-doc"); + assertEquals(opensBefore + 1, apiDocOpens, + "The safe api-doc fixture action must run once per selection"); + if (running && 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"); + if (running) { + assertEquals(OperationState.RUNNING, operationController.snapshot().state()); + assertTrue(manualExecutor.hasPendingTask(), + "The executor must hold the operation without executing it"); + } else { + assertEquals(OperationState.IDLE, operationController.snapshot().state()); + assertFalse(manualExecutor.hasPendingTask()); + } + } + + private void openApiDocFixture() { + apiDocOpens++; + mainWindow.embedPlugin( + "api-doc", null, "API 接口文档导出", runningPane); } private void validateShell(CaptureSpec spec) { @@ -327,6 +363,10 @@ final class NativeUiVisualAcceptanceFixture { assertEquals(DesignTokens.HEADER_HEIGHT, header.getBoundsInParent().getHeight(), 0.5); validateSettingsControl(scene, header); + if (spec.view() != View.WELCOME) { + validateSelectedApiDocState(scene); + } + if (spec.view() == View.RUNNING) { assertEquals(OperationState.RUNNING, operationController.snapshot().state()); assertTrue(operationButton.isVisible()); @@ -338,6 +378,23 @@ final class NativeUiVisualAcceptanceFixture { } } + private void validateSelectedApiDocState(Scene scene) { + ToggleButton apiDoc = (ToggleButton) scene.lookup("#nav-api-doc"); + assertNotNull(apiDoc); + assertTrue(apiDoc.isSelected()); + assertEquals("api-doc", navigationRail.selectedPluginId()); + assertTrue(apiDoc.getStyleClass().contains(Theme.CSS_NAV_CELL_ACTIVE)); + assertEquals(Color.web("#dbeafe"), + apiDoc.getBackground().getFills().get(0).getFill()); + assertEquals(3, + apiDoc.getBorder().getStrokes().get(0).getWidths().getLeft()); + assertEquals(Color.web("#2563eb"), apiDoc.getTextFill()); + assertEquals(Color.web("#2563eb"), + ((FontIcon) apiDoc.getGraphic()).getIconColor()); + assertTrue(toolbarTitle.isVisible()); + assertEquals("API 接口文档导出", toolbarTitle.getText()); + } + private void validateSettingsControl(Scene scene, Node header) { List settingsControls = descendants(scene.getRoot()).stream() .filter(ButtonBase.class::isInstance) @@ -377,14 +434,17 @@ final class NativeUiVisualAcceptanceFixture { stage.setY(desktop.getMinY() + (desktop.getHeight() - spec.stageHeight()) / 2); } - private static List navigationItems() { + private static List navigationItems(Runnable openApiDoc) { return List.of( + nav(AppIcon.GIT_SYNC, "Git仓库同步", "同步本地仓库", "git-sync"), + nav(AppIcon.GIT_REVIEW, "Git分支评审", "评审分支变更", "git-review"), 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")); + nav(AppIcon.SQL_EXPORT, "MyBatis SQL导出", "导出查询结果", "mybatisexport"), + nav(AppIcon.MYBATIS_GENERATOR, "MyBatis逆向工程", "生成 MyBatis 代码", + "mybatisgenerator"), + nav(AppIcon.SCANNER, "扫描功能", "扫描代码关键词", "scan"), + new MainWindow.NavItem(AppIcon.API_DOC, "API 接口文档导出", + "导出接口文档", openApiDoc, "api-doc")); } private static MainWindow.NavItem nav(AppIcon icon, String name, @@ -400,6 +460,19 @@ final class NativeUiVisualAcceptanceFixture { } } + private static List sceneCaptureSpecs() { + List specs = new ArrayList<>(CAPTURE_SPECS); + specs.add(new CaptureSpec( + "selected-expanded-1440.png", View.SELECTED, true, 1440, 900)); + specs.add(new CaptureSpec( + "selected-collapsed-1440.png", View.SELECTED, false, 1440, 900)); + specs.add(new CaptureSpec( + "selected-expanded-1024.png", View.SELECTED, true, 1024, 768)); + specs.add(new CaptureSpec( + "selected-collapsed-1024.png", View.SELECTED, false, 1024, 768)); + return List.copyOf(specs); + } + private static List descendants(Parent parent) { List descendants = new ArrayList<>(); Deque pending = new ArrayDeque<>(parent.getChildrenUnmodifiable()); 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 3c3a2a6..b153460 100644 --- a/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixtureContractTest.java +++ b/src/test/java/com/example/workutils/system/ui/NativeUiVisualAcceptanceFixtureContractTest.java @@ -49,10 +49,29 @@ class NativeUiVisualAcceptanceFixtureContractTest { "scene-running-1440.png", "scene-welcome-expanded-1024.png", "scene-welcome-collapsed-1024.png", - "scene-running-1024.png"), + "scene-running-1024.png", + "scene-selected-expanded-1440.png", + "scene-selected-collapsed-1440.png", + "scene-selected-expanded-1024.png", + "scene-selected-collapsed-1024.png"), requiredSceneImageNames.invoke(null)); } + @Test + void fixtureUsesTheApprovedSevenNavigationLabelsInOrder() throws Exception { + Method navigationLabels = method("navigationLabels"); + + assertEquals(List.of( + "Git仓库同步", + "Git分支评审", + "文本对比", + "MyBatis SQL导出", + "MyBatis逆向工程", + "扫描功能", + "API 接口文档导出"), + navigationLabels.invoke(null)); + } + @Test void fixtureIsDisabledUnlessTheExplicitPropertyIsTrue() throws Exception { Method captureEnabled = method("captureEnabled"); diff --git a/src/test/java/com/example/workutils/system/ui/shell/NavigationRailTest.java b/src/test/java/com/example/workutils/system/ui/shell/NavigationRailTest.java index 2c6fe64..118b885 100644 --- a/src/test/java/com/example/workutils/system/ui/shell/NavigationRailTest.java +++ b/src/test/java/com/example/workutils/system/ui/shell/NavigationRailTest.java @@ -11,6 +11,7 @@ import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ContentDisplay; import javafx.scene.control.Label; +import javafx.scene.control.Separator; import javafx.scene.control.ToggleButton; import javafx.scene.control.Tooltip; import javafx.scene.layout.HBox; @@ -56,10 +57,12 @@ class NavigationRailTest { window.embedPlugin("api-doc", "legacy-api-icon", "API", new StackPane()); HBox header = assertInstanceOf( HBox.class, stage(window).getScene().getRoot().lookup("#app-header")); + stage(window).getScene().getRoot().applyCss(); + stage(window).getScene().getRoot().layout(); assertEquals(6, header.getChildren().size()); Button menu = assertInstanceOf(Button.class, header.getChildren().get(0)); - Label brand = assertInstanceOf(Label.class, header.getChildren().get(1)); + HBox brand = assertInstanceOf(HBox.class, header.getChildren().get(1)); FontIcon chevron = assertInstanceOf(FontIcon.class, header.getChildren().get(2)); Label title = assertInstanceOf(Label.class, header.getChildren().get(3)); assertInstanceOf(Region.class, header.getChildren().get(4)); @@ -69,8 +72,26 @@ class NavigationRailTest { assertInstanceOf(FontIcon.class, menu.getGraphic()).getIconCode()); assertEquals("切换导航栏", menu.getAccessibleText()); assertTrue(menu.isFocusTraversable()); + assertEquals(2, brand.getChildren().size()); + StackPane brandIconContainer = assertInstanceOf( + StackPane.class, brand.getChildren().get(0)); + Label brandText = assertInstanceOf(Label.class, brand.getChildren().get(1)); + assertEquals("app-brand-icon-container", brandIconContainer.getId()); + assertTrue(brandIconContainer.getStyleClass().contains("app-brand-icon")); + assertEquals(32, brandIconContainer.getMinWidth()); + assertEquals(32, brandIconContainer.getPrefWidth()); + assertEquals(32, brandIconContainer.getMaxWidth()); + assertEquals(32, brandIconContainer.getMinHeight()); + assertEquals(32, brandIconContainer.getPrefHeight()); + assertEquals(32, brandIconContainer.getMaxHeight()); + assertEquals(Color.web("#2563eb"), + brandIconContainer.getBackground().getFills().get(0).getFill()); + FontIcon brandIcon = assertInstanceOf( + FontIcon.class, brandIconContainer.getChildren().get(0)); assertEquals(AppIcon.APP.glyph(), - assertInstanceOf(FontIcon.class, brand.getGraphic()).getIconCode()); + brandIcon.getIconCode()); + assertEquals(Color.WHITE, brandIcon.getIconColor()); + assertEquals("WorkUtils", brandText.getText()); assertEquals(AppIcon.CHEVRON_RIGHT.glyph(), chevron.getIconCode()); assertEquals("API", title.getText()); assertEquals(AppIcon.SETTINGS.glyph(), @@ -157,6 +178,39 @@ class NavigationRailTest { }); } + @Test + void bottomSeparatorIsExactlyOnePixelWithoutPaddingOrBackgroundBand() throws Exception { + fx(() -> { + NavigationRail rail = new NavigationRail(List.of(API_DOC), item -> { + }); + VBox root = new VBox(rail); + Scene scene = new Scene(root, DesignTokens.NAV_EXPANDED_WIDTH, 320); + Theme.applyCustomCss(scene); + root.applyCss(); + root.layout(); + + Separator separator = assertInstanceOf( + Separator.class, rail.lookup("#navigation-rail-separator")); + assertEquals(1, separator.getMinHeight()); + assertEquals(1, separator.getPrefHeight()); + assertEquals(1, separator.getMaxHeight()); + assertEquals(1, separator.getHeight()); + assertEquals(0, separator.getPadding().getTop()); + assertEquals(0, separator.getPadding().getRight()); + assertEquals(0, separator.getPadding().getBottom()); + assertEquals(0, separator.getPadding().getLeft()); + assertTrue(separator.getBackground() == null + || separator.getBackground().getFills().stream() + .allMatch(fill -> Color.TRANSPARENT.equals(fill.getFill())), + "the Separator control must not paint a gray-blue background band"); + + Region line = assertInstanceOf(Region.class, separator.lookup(".line")); + assertEquals(1, line.getBorder().getStrokes().get(0).getWidths().getTop()); + assertEquals(Color.web("#e2e8f0"), + line.getBorder().getStrokes().get(0).getTopStroke()); + }); + } + @Test void headerMenuAndBottomCollapseEntryDriveTheSameExpandedState() throws Exception { fx(() -> {