修复刷新缓存与样式测试隔离

This commit is contained in:
张家训
2026-08-01 17:17:31 +08:00
parent 855195e286
commit cd1218b575
3 changed files with 203 additions and 15 deletions
@@ -54,6 +54,7 @@ public class MainWindow {
}
public void refreshNavItems(List<NavItem> newItems) {
embeddedCache.clear();
navItems = new ArrayList<>(newItems);
navigationRail.setItems(navItems);
showWelcomePage();
@@ -155,7 +156,10 @@ public class MainWindow {
if (item.action != null) {
item.action.run();
}
showEmbeddedFor(item);
if (!showEmbeddedFor(item)) {
navigationRail.clearSelection();
showWelcomePage();
}
}
private void showPluginToolbar(String title) {
@@ -200,15 +204,17 @@ public class MainWindow {
currentEmbedded = null;
}
private void showEmbeddedFor(NavItem item) {
private boolean showEmbeddedFor(NavItem item) {
if (item.pluginId == null) {
hidePluginToolbar();
return;
return false;
}
EmbeddedEntry entry = embeddedCache.get(item.pluginId);
if (entry != null) {
switchToEmbedded(item.pluginId, entry);
return true;
}
return false;
}
public void embedPlugin(String pluginId, String ignoredIcon, String name, Parent content) {
@@ -1,5 +1,8 @@
package com.example.workutils.system.ui;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.css.CssParser;
import javafx.scene.Scene;
import javafx.scene.control.Button;
@@ -19,35 +22,48 @@ import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.beans.property.SimpleStringProperty;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static com.example.workutils.system.ui.FxTestSupport.fx;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LightThemeCssRuntimeTest {
@TempDir
Path tempDir;
private final AtomicInteger stylesheetSequence = new AtomicInteger();
@Test
void realStylesheetAddsNoJavaFxParserErrors() throws Exception {
fx(() -> {
int errorsBefore = CssParser.errorsProperty().size();
ParserRun<Fixture> parserRun = observeParserErrors(this::createFixture);
createFixture();
List<CssParser.ParseError> newErrors = CssParser.errorsProperty()
.subList(errorsBefore, CssParser.errorsProperty().size());
assertTrue(newErrors.isEmpty(), () -> "JavaFX CSS parser errors: " + newErrors);
assertNoParserErrors(parserRun);
assertEquals(20, parserRun.value().pageTitle().getFont().getSize(),
"A computed style proves that the unique stylesheet URL was parsed");
});
}
@Test
void semanticClassesComputeApprovedTypographyAndDimensions() throws Exception {
fx(() -> {
Fixture fixture = createFixture();
ParserRun<Fixture> parserRun = observeParserErrors(this::createFixture);
Fixture fixture = parserRun.value();
assertNoParserErrors(parserRun);
assertEquals(20, fixture.pageTitle().getFont().getSize());
assertEquals(15, fixture.sectionTitle().getFont().getSize());
@@ -73,7 +89,90 @@ class LightThemeCssRuntimeTest {
});
}
private static Fixture createFixture() {
@Test
void repeatedUniqueLoadsPreserveAndRestoreParserErrorState() throws Exception {
fx(() -> {
ObservableList<CssParser.ParseError> globalErrors = CssParser.errorsProperty();
List<CssParser.ParseError> errorsBefore = List.copyOf(globalErrors);
CssParser.ParseError preExistingError = new CssParser.ParseError("pre-existing test sentinel");
globalErrors.add(preExistingError);
try {
ParserRun<Fixture> firstLoad = observeParserErrors(this::createFixture);
CssParser.ParseError capturedError = new CssParser.ParseError("captured test sentinel");
ParserRun<Void> isolatedError = observeParserErrors(() -> {
globalErrors.add(capturedError);
return null;
});
ParserRun<Fixture> secondLoad = observeParserErrors(this::createFixture);
assertNoParserErrors(firstLoad);
assertEquals(List.of(capturedError), isolatedError.errors());
assertNoParserErrors(secondLoad);
assertNotEquals(firstLoad.value().stylesheetUrl(), secondLoad.value().stylesheetUrl(),
"Each run must use a unique URL so JavaFX cannot reuse a cached parse");
assertEquals(20, firstLoad.value().pageTitle().getFont().getSize());
assertEquals(20, secondLoad.value().pageTitle().getFont().getSize());
List<CssParser.ParseError> expectedErrors = new ArrayList<>(errorsBefore);
expectedErrors.add(preExistingError);
assertEquals(expectedErrors, List.copyOf(globalErrors),
"The observer must preserve parser errors that predate a run");
} finally {
removeByIdentity(globalErrors, List.of(preExistingError));
assertEquals(errorsBefore, List.copyOf(globalErrors),
"The test must restore the parser's global error state");
}
});
}
private <T> ParserRun<T> observeParserErrors(ThrowingSupplier<T> action) throws Exception {
ObservableList<CssParser.ParseError> globalErrors = CssParser.errorsProperty();
List<CssParser.ParseError> errorsBefore = List.copyOf(globalErrors);
List<CssParser.ParseError> capturedErrors = new ArrayList<>();
ListChangeListener<CssParser.ParseError> listener = change -> {
while (change.next()) {
if (change.wasAdded()) {
capturedErrors.addAll(change.getAddedSubList());
}
}
};
globalErrors.addListener(listener);
try {
return new ParserRun<>(action.get(), List.copyOf(capturedErrors));
} finally {
globalErrors.removeListener(listener);
removeByIdentity(globalErrors, capturedErrors);
assertEquals(errorsBefore, List.copyOf(globalErrors),
"A stylesheet run must restore pre-existing JavaFX parser errors");
}
}
private static void removeByIdentity(
ObservableList<CssParser.ParseError> globalErrors,
List<CssParser.ParseError> errorsToRemove) {
for (int capturedIndex = errorsToRemove.size() - 1; capturedIndex >= 0; capturedIndex--) {
CssParser.ParseError capturedError = errorsToRemove.get(capturedIndex);
for (int globalIndex = globalErrors.size() - 1; globalIndex >= 0; globalIndex--) {
if (globalErrors.get(globalIndex) == capturedError) {
globalErrors.remove(globalIndex);
break;
}
}
}
}
private static void assertNoParserErrors(ParserRun<?> parserRun) {
assertTrue(parserRun.errors().isEmpty(),
() -> "JavaFX CSS parser errors: " + parserRun.errors());
}
private Fixture createFixture() throws IOException {
String stylesheetUrl = copyRealStylesheetToUniqueUrl();
Label pageTitle = labelWithStyle(Theme.CSS_PAGE_TITLE);
Label sectionTitle = labelWithStyle(Theme.CSS_SECTION_TITLE);
Label bodyText = labelWithStyle(Theme.CSS_BODY_TEXT);
@@ -116,13 +215,26 @@ class LightThemeCssRuntimeTest {
logArea, table, tree, compactList, tabPane,
defaultButton, textField, comboBox, header);
Scene scene = new Scene(root, 900, 1000);
Theme.applyCustomCss(scene);
scene.getStylesheets().add(stylesheetUrl);
root.applyCss();
root.layout();
return new Fixture(pageTitle, sectionTitle, bodyText, secondaryText,
logArea, table, tree, compactList, tabPane,
defaultButton, textField, comboBox, header);
defaultButton, textField, comboBox, header, stylesheetUrl);
}
private String copyRealStylesheetToUniqueUrl() throws IOException {
Path stylesheet = tempDir.resolve(
"light-theme-runtime-" + stylesheetSequence.incrementAndGet() + ".css");
try (InputStream input = LightThemeCssRuntimeTest.class.getClassLoader()
.getResourceAsStream("css/light-theme.css")) {
if (input == null) {
throw new IOException("Missing classpath resource css/light-theme.css");
}
Files.copy(input, stylesheet);
}
return stylesheet.toUri().toString();
}
private static Label labelWithStyle(String styleClass) {
@@ -168,6 +280,15 @@ class LightThemeCssRuntimeTest {
Button defaultButton,
TextField textField,
ComboBox<String> comboBox,
Region header) {
Region header,
String stylesheetUrl) {
}
private record ParserRun<T>(T value, List<CssParser.ParseError> errors) {
}
@FunctionalInterface
private interface ThrowingSupplier<T> {
T get() throws Exception;
}
}
@@ -25,6 +25,7 @@ import org.kordamp.ikonli.javafx.FontIcon;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
@@ -385,6 +386,66 @@ class NavigationRailTest {
});
}
@Test
void refreshInvalidatesCachedPaneAndConfigurationFailureCanBeRetried() throws Exception {
fx(() -> {
AtomicReference<MainWindow> windowRef = new AtomicReference<>();
AtomicBoolean configValid = new AtomicBoolean(true);
AtomicInteger configChecks = new AtomicInteger();
AtomicInteger paneBuilds = new AtomicInteger();
MainWindow.NavItem item = new MainWindow.NavItem(
AppIcon.API_DOC, "API", "API item", () -> {
configChecks.incrementAndGet();
if (!configValid.get()) {
return;
}
StackPane pane = new StackPane();
pane.setId("api-pane-" + paneBuilds.incrementAndGet());
windowRef.get().embedPlugin("api-doc", "legacy", "API", pane);
}, "api-doc");
MainWindow window = new MainWindow(
List.of(item), new WorkUtilsProperties(), () -> {
});
windowRef.set(window);
Parent root = stage(window).getScene().getRoot();
ToggleButton initialButton = assertInstanceOf(
ToggleButton.class, root.lookup("#nav-api-doc"));
initialButton.fire();
assertEquals(1, configChecks.get());
assertEquals(1, paneBuilds.get());
assertNotNull(root.lookup("#api-pane-1"));
configValid.set(false);
window.refreshNavItems(List.of(item));
NavigationRail rail = assertInstanceOf(
NavigationRail.class, root.lookup(".navigation-rail"));
ToggleButton refreshedButton = assertInstanceOf(
ToggleButton.class, root.lookup("#nav-api-doc"));
assertNull(rail.selectedPluginId());
assertNotNull(root.lookup(".welcome-page"));
refreshedButton.fire();
assertEquals(2, configChecks.get());
assertEquals(1, paneBuilds.get());
assertNull(root.lookup("#api-pane-1"));
assertNotNull(root.lookup(".welcome-page"));
assertNull(rail.selectedPluginId());
configValid.set(true);
refreshedButton.fire();
assertEquals(3, configChecks.get());
assertEquals(2, paneBuilds.get());
assertEquals("api-doc", rail.selectedPluginId());
assertNotNull(root.lookup("#api-pane-2"));
assertNull(root.lookup(".welcome-page"));
});
}
@Test
void collapsedRailUsesExactFixedWidthAndCollapsedPseudoClass() throws Exception {
fx(() -> {