From 28c1e47c9537112ca10dbad7711392f3c44f005a Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Thu, 12 Nov 2026 09:47:59 +0100 Subject: [PATCH] Validate preview sessions against real classpath and module-path fixtures --- .gitignore | 2 + README.md | 6 +- designer-contracts/pom.xml | 11 + designer-preview-api/pom.xml | 11 + .../fxdesigner/preview/ExampleDescriptor.java | 20 +- .../fxdesigner/preview/PreviewProvider.java | 11 + .../fxdesigner/preview/PreviewSession.java | 23 ++ designer-target-tools/pom.xml | 11 + .../fxdesigner/target/ExampleCatalog.java | 75 ++++++ .../fxdesigner/target/PreviewFailure.java | 29 +++ .../fxdesigner/target/TargetPreview.java | 244 ++++++++++++++++++ .../fxdesigner/target/ExampleCatalogTest.java | 110 ++++++++ docs/initial-project-contract.md | 2 + docs/preview-target-fixtures.md | 71 +++++ docs/repository-bootstrap-plan.md | 4 +- .../preview/classpath/app/pom.xml | 24 ++ .../main/java/fixture/components/Card.java | 51 ++++ .../java/fixture/components/Greeting.java | 16 ++ .../fixture/components/PersonController.java | 22 ++ .../fixture/components/PersonViewModel.java | 7 + .../src/main/java/fixture/model/Address.java | 13 + .../src/main/java/fixture/model/Person.java | 28 ++ .../resources/fixture/components/Card.fxml | 2 + .../resources/fixture/components/Invalid.fxml | 2 + .../resources/fixture/components/Person.fxml | 2 + .../java/fixture/checks/PreviewChecks.java | 197 ++++++++++++++ .../test/java/fixture/preview/Examples.java | 62 +++++ .../neitzel-fx-designer/examples.properties | 56 ++++ .../classpath/neitzel-fx-designer.json | 15 ++ .../preview/classpath/pom.xml | 41 +++ .../preview/modulepath/app/pom.xml | 18 ++ .../main/java/fixture/components/Card.java | 51 ++++ .../java/fixture/components/Greeting.java | 16 ++ .../fixture/components/PersonController.java | 22 ++ .../fixture/components/PersonViewModel.java | 7 + .../src/main/java/fixture/model/Address.java | 13 + .../src/main/java/fixture/model/Person.java | 28 ++ .../app/src/main/java/module-info.java | 18 ++ .../resources/fixture/components/Card.fxml | 2 + .../resources/fixture/components/Invalid.fxml | 2 + .../resources/fixture/components/Person.fxml | 2 + .../preview/modulepath/checks/pom.xml | 24 ++ .../java/fixture/checks/PreviewChecks.java | 197 ++++++++++++++ .../checks/src/main/java/module-info.java | 10 + .../modulepath/neitzel-fx-designer.json | 19 ++ .../preview/modulepath/pom.xml | 43 +++ .../preview/modulepath/preview/pom.xml | 24 ++ .../main/java/fixture/preview/Examples.java | 62 +++++ .../preview/src/main/java/module-info.java | 10 + .../neitzel-fx-designer/examples.properties | 56 ++++ scripts/check-bootstrap.py | 1 + scripts/check-preview-fixtures.py | 83 ++++++ 52 files changed, 1867 insertions(+), 9 deletions(-) create mode 100644 designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/PreviewProvider.java create mode 100644 designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/PreviewSession.java create mode 100644 designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/ExampleCatalog.java create mode 100644 designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/PreviewFailure.java create mode 100644 designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TargetPreview.java create mode 100644 designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/ExampleCatalogTest.java create mode 100644 docs/preview-target-fixtures.md create mode 100644 integration-fixtures/preview/classpath/app/pom.xml create mode 100644 integration-fixtures/preview/classpath/app/src/main/java/fixture/components/Card.java create mode 100644 integration-fixtures/preview/classpath/app/src/main/java/fixture/components/Greeting.java create mode 100644 integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonController.java create mode 100644 integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonViewModel.java create mode 100644 integration-fixtures/preview/classpath/app/src/main/java/fixture/model/Address.java create mode 100644 integration-fixtures/preview/classpath/app/src/main/java/fixture/model/Person.java create mode 100644 integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Card.fxml create mode 100644 integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Invalid.fxml create mode 100644 integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Person.fxml create mode 100644 integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java create mode 100644 integration-fixtures/preview/classpath/app/src/test/java/fixture/preview/Examples.java create mode 100644 integration-fixtures/preview/classpath/app/src/test/resources/META-INF/neitzel-fx-designer/examples.properties create mode 100644 integration-fixtures/preview/classpath/neitzel-fx-designer.json create mode 100644 integration-fixtures/preview/classpath/pom.xml create mode 100644 integration-fixtures/preview/modulepath/app/pom.xml create mode 100644 integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/Card.java create mode 100644 integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/Greeting.java create mode 100644 integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonController.java create mode 100644 integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonViewModel.java create mode 100644 integration-fixtures/preview/modulepath/app/src/main/java/fixture/model/Address.java create mode 100644 integration-fixtures/preview/modulepath/app/src/main/java/fixture/model/Person.java create mode 100644 integration-fixtures/preview/modulepath/app/src/main/java/module-info.java create mode 100644 integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Card.fxml create mode 100644 integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Invalid.fxml create mode 100644 integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Person.fxml create mode 100644 integration-fixtures/preview/modulepath/checks/pom.xml create mode 100644 integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java create mode 100644 integration-fixtures/preview/modulepath/checks/src/main/java/module-info.java create mode 100644 integration-fixtures/preview/modulepath/neitzel-fx-designer.json create mode 100644 integration-fixtures/preview/modulepath/pom.xml create mode 100644 integration-fixtures/preview/modulepath/preview/pom.xml create mode 100644 integration-fixtures/preview/modulepath/preview/src/main/java/fixture/preview/Examples.java create mode 100644 integration-fixtures/preview/modulepath/preview/src/main/java/module-info.java create mode 100644 integration-fixtures/preview/modulepath/preview/src/main/resources/META-INF/neitzel-fx-designer/examples.properties create mode 100644 scripts/check-preview-fixtures.py diff --git a/.gitignore b/.gitignore index de64959..73908ba 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ __pycache__/ *.py[cod] .intellijPlatform/ + +/integration-fixtures/**/target/ diff --git a/README.md b/README.md index 60323ed..dd1a1e0 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Configuration 1.0 schemas, context selection, local JDK mappings and draft proto The shared [Build and Refresh workflow](docs/build-and-refresh.md) now builds isolated reactor copies and publishes immutable, leased snapshots, with explicit save approval and last-successful-build selection. +The [preview API and target adapter](docs/preview-target-fixtures.md) are validated with real classpath and named-module NeitzelLib applications, including bindings, rendering, readiness and cleanup. Each preview session requires a fresh target JVM. + The Component builder fix is integrated in NeitzelLib main. A [verified local development snapshot](docs/runtime/neitzellib-baseline.md) is recorded with source/artifact identity; a corrected released version is still unestablished. NeitzelLib sources and prototype implementations are not copied here. ## Validate the foundation @@ -20,14 +22,14 @@ Requirements: JDK 25 for builds, JDK 21 for target linkage validation, Python 3. python3 scripts/check-bootstrap.py --idea-home "$IDEA_HOME" --target-java-home "$TARGET_JAVA_HOME" ``` -On macOS `IDEA_HOME` points to the IDE application's `Contents` directory. The current SDK check uses its macOS product-info layout; other platforms are not qualified. The command checks runtime artifact hashes, clean-builds/installs the Maven modules, tests the adapters, checks bytecode baselines, runs target linkage under the supplied target JDK, builds the plugin ZIP, runs the real Maven snapshot smoke fixture, and validates documentation. It does not install or publish NeitzelLib. +On macOS `IDEA_HOME` points to the IDE application's `Contents` directory. The current SDK check uses its macOS product-info layout; other platforms are not qualified. The command checks runtime artifact hashes, clean-builds/installs the Maven modules, tests the adapters, checks bytecode baselines, runs target linkage under the supplied target JDK, builds the plugin ZIP, runs the real Maven snapshot and target-preview fixtures, and validates documentation. It does not install or publish NeitzelLib. Maven is pinned to **3.9.11** (Wrapper **3.3.4**); the separate IntelliJ build uses **Gradle 9.1.0** and IntelliJ Platform Gradle Plugin **2.20.1**. Wrapper distributions have pinned checksums. Shared-module-only validation is `./mvnw -B -ntp clean verify`; it does not qualify IntelliJ packaging. Plugin output is under `designer-intellij/build/distributions/`. ## Implementation references - [Initial project contract](docs/initial-project-contract.md): module responsibilities, configuration, snapshots, runtimes, preview sessions and communication. -- [Repository bootstrap plan](docs/repository-bootstrap-plan.md): ordered work packages and acceptance gates. Next: validate the preview API and target adapter against real fixtures. +- [Repository bootstrap plan](docs/repository-bootstrap-plan.md): ordered work packages and acceptance gates. Next: connect the first workflow in both minimal hosts. - [Decision provenance](docs/provenance.md): workspace planning and captured prototype evidence. - [Third-party notices](THIRD-PARTY-NOTICES.md): Maven wrapper provenance. diff --git a/designer-contracts/pom.xml b/designer-contracts/pom.xml index 70919da..deedbd6 100644 --- a/designer-contracts/pom.xml +++ b/designer-contracts/pom.xml @@ -19,6 +19,17 @@ + + org.apache.maven.plugins + maven-jar-plugin + + + + de.neitzel.fxdesigner.contracts + + + + org.apache.maven.plugins maven-enforcer-plugin diff --git a/designer-preview-api/pom.xml b/designer-preview-api/pom.xml index 1f23697..e3f946b 100644 --- a/designer-preview-api/pom.xml +++ b/designer-preview-api/pom.xml @@ -19,6 +19,17 @@ + + org.apache.maven.plugins + maven-jar-plugin + + + + de.neitzel.fxdesigner.preview + + + + org.apache.maven.plugins maven-enforcer-plugin diff --git a/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/ExampleDescriptor.java b/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/ExampleDescriptor.java index 3c8be9d..78b5489 100644 --- a/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/ExampleDescriptor.java +++ b/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/ExampleDescriptor.java @@ -1,12 +1,20 @@ package de.neitzel.fxdesigner.preview; -import java.util.Objects; -/** Non-executing example metadata. Factory/session signatures await real target fixtures. */ -public record ExampleDescriptor(String id, String label, String modelType) { +/** Resource-only metadata; reading this record must not initialize application classes. */ +public record ExampleDescriptor( + String id, + String label, + String modelType, + String providerClass, + String viewResource, + String componentPackage) { public ExampleDescriptor { - if (id == null || id.isBlank()) throw new IllegalArgumentException("Missing example ID"); - Objects.requireNonNull(label); - Objects.requireNonNull(modelType); + for (String s : + new String[] {id, label, modelType, providerClass, viewResource, componentPackage}) + if (s == null || s.isBlank() || s.length() > 4096) + throw new IllegalArgumentException("Invalid example metadata"); + if (viewResource.startsWith("/") || viewResource.contains("..") || viewResource.contains(":")) + throw new IllegalArgumentException("View resource must be a classloader-relative resource"); } } diff --git a/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/PreviewProvider.java b/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/PreviewProvider.java new file mode 100644 index 0000000..f1a7a8c --- /dev/null +++ b/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/PreviewProvider.java @@ -0,0 +1,11 @@ +package de.neitzel.fxdesigner.preview; + +/** Created only after example selection, in a fresh target JVM on its FX application thread. */ +public interface PreviewProvider { + Class modelType(); + + /** + * Must create a fresh Model graph and context/services; clean partial allocations if this throws. + */ + PreviewSession open(String exampleId) throws Exception; +} diff --git a/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/PreviewSession.java b/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/PreviewSession.java new file mode 100644 index 0000000..9265e7d --- /dev/null +++ b/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/PreviewSession.java @@ -0,0 +1,23 @@ +package de.neitzel.fxdesigner.preview; + +import java.util.concurrent.*; + +/** Owned example state. The NeitzelLib adapter requires C to be an ApplicationContext. */ +public interface PreviewSession extends AutoCloseable { + M model(); + + C context(); + + /** + * Called after View loading/CSS/layout, on FX. Never block FX; complete asynchronously if needed. + */ + default CompletionStage ready() { + return CompletableFuture.completedFuture(null); + } + + /** + * Release owned services/timers/listeners. Called once on FX, including load/readiness failure. + */ + @Override + void close() throws Exception; +} diff --git a/designer-target-tools/pom.xml b/designer-target-tools/pom.xml index b0485f7..8afaff9 100644 --- a/designer-target-tools/pom.xml +++ b/designer-target-tools/pom.xml @@ -34,6 +34,17 @@ + + org.apache.maven.plugins + maven-jar-plugin + + + + de.neitzel.fxdesigner.target + + + + org.apache.maven.plugins maven-enforcer-plugin diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/ExampleCatalog.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/ExampleCatalog.java new file mode 100644 index 0000000..e3155a8 --- /dev/null +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/ExampleCatalog.java @@ -0,0 +1,75 @@ +package de.neitzel.fxdesigner.target; + +import de.neitzel.fxdesigner.preview.ExampleDescriptor; +import java.io.*; +import java.net.URL; +import java.util.*; + +/** Reads only bounded metadata resources, never ServiceLoader providers or application classes. */ +public final class ExampleCatalog { + public static final String RESOURCE = "META-INF/neitzel-fx-designer/examples.properties"; + + private ExampleCatalog() {} + + public static List discover(ClassLoader loader) throws IOException { + var examples = new TreeMap(); + var resources = loader.getResources(RESOURCE); + int files = 0; + while (resources.hasMoreElements()) { + if (++files > 128) throw new IOException("Too many example catalogs"); + for (var e : read(resources.nextElement())) + if (examples.putIfAbsent(e.id(), e) != null) + throw new IOException("Duplicate example ID: " + e.id()); + if (examples.size() > 1024) throw new IOException("Too many examples"); + } + return List.copyOf(examples.values()); + } + + public static List read(URL resource) throws IOException { + var p = + new Properties() { + @Override + public synchronized Object put(Object key, Object value) { + if (containsKey(key)) throw new IllegalArgumentException("Duplicate descriptor key"); + return super.put(key, value); + } + }; + try (var in = resource.openStream()) { + byte[] bytes = in.readNBytes(1024 * 1024 + 1); + if (bytes.length > 1024 * 1024) throw new IOException("Example catalog exceeds 1 MiB"); + p.load( + new StringReader( + java.nio.charset.StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) + .decode(java.nio.ByteBuffer.wrap(bytes)) + .toString())); + if (!"1".equals(p.getProperty("format"))) + throw new IOException("Unsupported example catalog format"); + int count = Integer.parseInt(p.getProperty("count")); + if (count < 1 || count > 1024) throw new IOException("Invalid example count"); + var result = new ArrayList(); + var keys = new HashSet<>(Set.of("format", "count")); + for (int i = 0; i < count; i++) { + String prefix = "example." + i + "."; + String[] fields = { + "id", "label", "modelType", "providerClass", "viewResource", "componentPackage" + }; + for (String field : fields) keys.add(prefix + field); + result.add( + new ExampleDescriptor( + p.getProperty(prefix + "id"), + p.getProperty(prefix + "label"), + p.getProperty(prefix + "modelType"), + p.getProperty(prefix + "providerClass"), + p.getProperty(prefix + "viewResource"), + p.getProperty(prefix + "componentPackage"))); + } + if (!keys.equals(p.stringPropertyNames())) + throw new IOException("Unknown or missing example fields"); + return List.copyOf(result); + } catch (IllegalArgumentException e) { + throw new IOException("Invalid example catalog", e); + } + } +} diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/PreviewFailure.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/PreviewFailure.java new file mode 100644 index 0000000..62c250e --- /dev/null +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/PreviewFailure.java @@ -0,0 +1,29 @@ +package de.neitzel.fxdesigner.target; + +/** Structured target diagnostic; a host can display it without transporting application objects. */ +public final class PreviewFailure extends RuntimeException { + public enum Code { + SESSION_REUSE, + MODULE_ACCESS, + PROVIDER, + MODEL_TYPE, + CONTEXT_TYPE, + RESOURCE, + LOAD, + READINESS, + TIMEOUT, + CANCELLED, + CLEANUP + } + + private final Code code; + + public PreviewFailure(Code code, String message, Throwable cause) { + super(message, cause); + this.code = code; + } + + public Code code() { + return code; + } +} diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TargetPreview.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TargetPreview.java new file mode 100644 index 0000000..8dc4fb4 --- /dev/null +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TargetPreview.java @@ -0,0 +1,244 @@ +package de.neitzel.fxdesigner.target; + +import de.neitzel.fx.view.*; +import de.neitzel.fxdesigner.preview.*; +import de.neitzel.injection.ApplicationContext; +import java.time.Duration; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import javafx.application.Platform; +import javafx.geometry.Rectangle2D; +import javafx.scene.*; +import javafx.scene.image.WritableImage; +import javafx.scene.paint.Color; + +/** + * One preview session per target JVM: the library retains process-wide Model/ViewModel identities. + */ +public final class TargetPreview implements AutoCloseable { + private static final AtomicBoolean CONSUMED = new AtomicBoolean(); + private PreviewSession session; + private LoadedView view; + private Scene scene; + private boolean closed; + private final CompletableFuture ready = new CompletableFuture<>(); + private final CompletableFuture cleanup = new CompletableFuture<>(); + private final ScheduledExecutorService timer = + Executors.newSingleThreadScheduledExecutor( + r -> { + var t = new Thread(r, "preview-deadline"); + t.setDaemon(true); + return t; + }); + + public static TargetPreview open( + ExampleDescriptor descriptor, ClassLoader loader, Duration timeout) { + if (timeout.isNegative() || timeout.isZero() || timeout.compareTo(Duration.ofMinutes(5)) > 0) + throw new IllegalArgumentException("Invalid readiness timeout"); + if (!CONSUMED.compareAndSet(false, true)) + throw new PreviewFailure( + PreviewFailure.Code.SESSION_REUSE, + "Start a fresh target JVM for every preview session", + null); + var preview = new TargetPreview(); + preview.timer.schedule( + () -> + preview.failOnFx( + new PreviewFailure( + PreviewFailure.Code.TIMEOUT, "Preview readiness timed out", null)), + timeout.toMillis(), + TimeUnit.MILLISECONDS); + try { + Platform.runLater(() -> preview.load(descriptor, loader)); + } catch (RuntimeException e) { + preview.timer.shutdownNow(); + preview.ready.completeExceptionally(e); + preview.cleanup.complete(null); + } + return preview; + } + + public CompletionStage ready() { + return ready; + } + + public CompletionStage cleanup() { + return cleanup; + } + + /** Target-thread access for interactive preview and the adapter tests, never sent over IPC. */ + public LoadedView loadedView() { + fx(); + if (closed || view == null) throw new IllegalStateException("Preview not loaded"); + return view; + } + + private void load(ExampleDescriptor d, ClassLoader loader) { + fx(); + if (closed) return; + var previous = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(loader); + try { + // Named modules need this one package open for Reflections' ClassLoader resource enumeration. + for (Module module : ModuleLayer.boot().modules()) + if (module.getPackages().contains(d.componentPackage()) + && !module.isOpen(d.componentPackage())) + throw failure( + PreviewFailure.Code.MODULE_ACCESS, + "Open component package " + d.componentPackage() + " for NeitzelLib scanning", + null); + Class type = Class.forName(d.providerClass(), false, loader); + if (!PreviewProvider.class.isAssignableFrom(type)) + throw failure( + PreviewFailure.Code.PROVIDER, + "Configured class does not implement PreviewProvider", + null); + var provider = (PreviewProvider) type.getConstructor().newInstance(); + Class modelType = Class.forName(d.modelType(), false, loader); + if (provider.modelType() != modelType) + throw failure( + PreviewFailure.Code.MODEL_TYPE, "Descriptor/provider Model types differ", null); + session = provider.open(d.id()); + if (session == null || session.model() == null || !modelType.isInstance(session.model())) + throw failure( + PreviewFailure.Code.MODEL_TYPE, "Provider returned an incompatible Model", null); + if (!(session.context() instanceof ApplicationContext context)) + throw failure( + PreviewFailure.Code.CONTEXT_TYPE, + "NeitzelLib preview requires an ApplicationContext", + null); + var resource = loader.getResource(d.viewResource()); + if (resource == null) + throw failure( + PreviewFailure.Code.RESOURCE, + "View resource unavailable: " + d.viewResource() + "; check module resource openness", + null); + view = ViewLoader.load(resource, session.model(), context); + scene = new Scene(view.getRoot(), 420, 240); + layout(420, 240); + var wait = session.ready(); + if (wait == null) + throw failure(PreviewFailure.Code.READINESS, "Provider returned no readiness stage", null); + wait.whenComplete( + (v, e) -> + Platform.runLater( + () -> { + if (closed) return; + if (e != null) { + failOnFx( + failure(PreviewFailure.Code.READINESS, "Example readiness failed", e)); + return; + } + try { + layout(420, 240); + timer.shutdownNow(); + ready.complete(this); + } catch (Throwable error) { + failOnFx(classify(error)); + } + })); + } catch (Throwable e) { + failOnFx(classify(e)); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } + + private static PreviewFailure failure(PreviewFailure.Code code, String text, Throwable e) { + return new PreviewFailure(code, text, e); + } + + private static PreviewFailure classify(Throwable e) { + if (e instanceof PreviewFailure f) return f; + for (Throwable t = e; t != null; t = t.getCause()) + if (t instanceof java.lang.reflect.InaccessibleObjectException + || t instanceof IllegalAccessException + || t instanceof IllegalAccessError) + return failure( + PreviewFailure.Code.MODULE_ACCESS, + "Target module access denied; inspect declared exports/opens", + e); + return failure(PreviewFailure.Code.LOAD, "NeitzelLib View load failed", e); + } + + private void layout(int width, int height) { + view.getRoot().resize(width, height); + view.getRoot().applyCss(); + view.getRoot().layout(); + } + + /** Raw, row-major, straight-alpha RGBA8 matching the wire contract. Must run on FX. */ + public byte[] render(int width, int height) { + fx(); + if (closed || !ready.isDone() || ready.isCompletedExceptionally()) + throw new IllegalStateException("Preview not ready"); + if (width < 1 + || height < 1 + || width > 8192 + || height > 8192 + || (long) width * height * 4 > 16 * 1024 * 1024) + throw new IllegalArgumentException("Image exceeds protocol limits"); + layout(width, height); + var parameters = new SnapshotParameters(); + parameters.setFill(Color.TRANSPARENT); + parameters.setViewport(new Rectangle2D(0, 0, width, height)); + var image = view.getRoot().snapshot(parameters, new WritableImage(width, height)); + byte[] bytes = new byte[width * height * 4]; + var pixels = image.getPixelReader(); + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) { + int argb = pixels.getArgb(x, y), i = (y * width + x) * 4; + bytes[i] = (byte) (argb >>> 16); + bytes[i + 1] = (byte) (argb >>> 8); + bytes[i + 2] = (byte) argb; + bytes[i + 3] = (byte) (argb >>> 24); + } + return bytes; + } + + private static void fx() { + if (!Platform.isFxApplicationThread()) + throw new IllegalStateException("Must run on target FX thread"); + } + + private void failOnFx(PreviewFailure failure) { + if (!Platform.isFxApplicationThread()) { + Platform.runLater(() -> failOnFx(failure)); + return; + } + if (closed || (failure.code() == PreviewFailure.Code.TIMEOUT && ready.isDone())) return; + closeOnFx(failure); + ready.completeExceptionally(failure); + } + + private void closeOnFx(Throwable failure) { + fx(); + if (closed) return; + closed = true; + timer.shutdownNow(); + try { + if (session != null) session.close(); + cleanup.complete(null); + } catch (Throwable e) { + if (failure != null) failure.addSuppressed(e); + cleanup.completeExceptionally( + failure(PreviewFailure.Code.CLEANUP, "Example cleanup failed", e)); + } finally { + session = null; + view = null; + scene = null; + } + } + + public void cancel() { + failOnFx(failure(PreviewFailure.Code.CANCELLED, "Preview cancelled", null)); + } + + @Override + public void close() { + if (Platform.isFxApplicationThread()) { + if (!ready.isDone()) cancel(); + else closeOnFx(null); + } else Platform.runLater(this::close); + } +} diff --git a/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/ExampleCatalogTest.java b/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/ExampleCatalogTest.java new file mode 100644 index 0000000..73f2a09 --- /dev/null +++ b/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/ExampleCatalogTest.java @@ -0,0 +1,110 @@ +package de.neitzel.fxdesigner.target; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.*; +import java.util.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ExampleCatalogTest { + @TempDir Path temp; + private static final String VALID = + """ + format=1 + count=1 + example.0.id=person + example.0.label=Person + example.0.modelType=missing.Person + example.0.providerClass=missing.Provider + example.0.viewResource=views/Person.fxml + example.0.componentPackage=missing.components + """; + + URL resource(String text) throws Exception { + Path file = Files.createTempFile(temp, "catalog", ".properties"); + Files.writeString(file, text); + return file.toUri().toURL(); + } + + @Test + void readsMetadataWithoutLoadingClasses() throws Exception { + var examples = ExampleCatalog.read(resource(VALID)); + assertEquals("missing.Provider", examples.getFirst().providerClass()); + assertThrows(UnsupportedOperationException.class, () -> examples.clear()); + } + + @Test + void rejectsUnknownDuplicateAndMissingFields() throws Exception { + for (String value : + List.of( + VALID + "extra=value\n", + VALID + "format=1\n", + VALID.replace("example.0.id=person\n", ""))) { + URL url = resource(value); + assertThrows(IOException.class, () -> ExampleCatalog.read(url)); + } + } + + @Test + void rejectsVersionsCountsAndUnsafeResources() throws Exception { + for (String value : + List.of( + VALID.replace("format=1", "format=2"), + VALID.replace("count=1", "count=0"), + VALID.replace("count=1", "count=1025"), + VALID.replace("views/Person.fxml", "../Person.fxml"))) { + URL url = resource(value); + assertThrows(IOException.class, () -> ExampleCatalog.read(url)); + } + } + + @Test + void rejectsOversizeAndMalformedUtf8() throws Exception { + URL oversized = resource("x".repeat(1024 * 1024 + 1)); + assertThrows(IOException.class, () -> ExampleCatalog.read(oversized)); + Path malformed = temp.resolve("malformed"); + Files.write(malformed, new byte[] {(byte) 0xc3, 0x28}); + assertThrows(IOException.class, () -> ExampleCatalog.read(malformed.toUri().toURL())); + } + + @Test + void rejectsDuplicateIdsAcrossResources() throws Exception { + var urls = List.of(resource(VALID), resource(VALID)); + var loader = + new ClassLoader(null) { + public Enumeration getResources(String name) { + assertEquals(ExampleCatalog.RESOURCE, name); + return Collections.enumeration(urls); + } + }; + assertThrows(IOException.class, () -> ExampleCatalog.discover(loader)); + } + + @Test + void discoveryIsSortedAndBounded() throws Exception { + var urls = + List.of( + resource(VALID.replace("id=person", "id=z")), + resource(VALID.replace("id=person", "id=a"))); + var loader = + new ClassLoader(null) { + public Enumeration getResources(String name) { + return Collections.enumeration(urls); + } + }; + assertEquals( + List.of("a", "z"), ExampleCatalog.discover(loader).stream().map(e -> e.id()).toList()); + var many = new ArrayList(); + for (int i = 0; i < 129; i++) many.add(resource(VALID.replace("id=person", "id=p" + i))); + var excessive = + new ClassLoader(null) { + public Enumeration getResources(String name) { + return Collections.enumeration(many); + } + }; + assertThrows(IOException.class, () -> ExampleCatalog.discover(excessive)); + } +} diff --git a/docs/initial-project-contract.md b/docs/initial-project-contract.md index 066e108..38a17c1 100644 --- a/docs/initial-project-contract.md +++ b/docs/initial-project-contract.md @@ -100,6 +100,8 @@ FXML previews overlay the owning host's current parent and open Component drafts ## Discovery and preview API +Work package 6 now qualifies the [development API and target adapter](preview-target-fixtures.md) against real classpath/module-path fixtures. Session cleanup is cooperative; a fresh target JVM is required to release NeitzelLib process-wide Model/ViewModel state. + Metadata listing must not construct Models or invoke Controller methods. Separate example descriptors (stable IDs, display labels, compatible Model/View information and declared capabilities) from runtime construction. Generated descriptor metadata or equivalent non-constructing discovery must be tested; calling arbitrary provider initialization merely to list examples does not satisfy this boundary. Selecting an example creates a fresh owned preview session with its Model graph and explicitly configured services. Provide readiness and cleanup hooks. Ordinary examples become ready after load/CSS/layout; asynchronous examples signal readiness subject to cancellation/timeout. Construction, services, Controller actions and cleanup execute only in the target JVM. Verify the adapter against NeitzelLib's actual injection/loading lifecycle before publishing Java signatures. diff --git a/docs/preview-target-fixtures.md b/docs/preview-target-fixtures.md new file mode 100644 index 0000000..43de168 --- /dev/null +++ b/docs/preview-target-fixtures.md @@ -0,0 +1,71 @@ +# Preview API and target adapter qualification + +Work package 6 is implemented against the [verified NeitzelLib development artifact](runtime/neitzellib-baseline.md), Java 21 and application JavaFX 21.0.7. This qualifies target-side execution; it does not connect the designer UI, snapshot activation or worker transport. Those remain work package 7. + +## Discovery and construction + +The dependency-free Java 21 preview API contains: + +- `ExampleDescriptor(id, label, modelType, providerClass, viewResource, componentPackage)`. +- `PreviewProvider`: `modelType()` and `open(exampleId)`. +- `PreviewSession`: `model()`, `context()`, asynchronous `ready()` and `close()`. + +The NeitzelLib target adapter requires an `ApplicationContext` context. Providers have public no-argument constructors. They create fresh Models and explicitly configured services after selection, on the target FX thread. They own cleanup of partial allocations when construction throws before returning a session. The adapter owns the returned session and calls its cleanup once on load failure, readiness failure, timeout, cancellation or normal close. + +Discovery reads UTF-8 `META-INF/neitzel-fx-designer/examples.properties` resources without loading provider or Model classes. This development metadata contract has format version 1: + +```properties +format=1 +count=1 +example.0.id=standard +example.0.label=Standard person +example.0.modelType=fixture.model.Person +example.0.providerClass=fixture.preview.Examples +example.0.viewResource=fixture/components/Person.fxml +example.0.componentPackage=fixture.components +``` + +Resources are classloader-relative. Catalogs reject unknown/missing/duplicate keys, duplicate IDs across resources, invalid UTF-8 and unsupported versions. Limits are 1 MiB per catalog, 128 catalogs, 1,024 total examples and 4,096 characters per descriptor field. Discovery returns immutable descriptors ordered by ID. No ServiceLoader factory is invoked to list examples. + +After selection the adapter checks provider/Model compatibility, invokes NeitzelLib's real `ViewLoader`, creates a Scene and applies CSS/layout before asking for readiness. Readiness must never block FX. Rendering produces bounded row-major straight-alpha RGBA8 using the target's JavaFX. Structured failures distinguish module access, invalid Model/context/provider/resource, loading, readiness, cancellation, timeout and cleanup. Original exception causes remain available. + +These are tested development signatures, not a published API release or a promise of compatibility with every NeitzelLib version. + +## Lifecycle boundary established by the fixtures + +NeitzelLib's `ViewModels` retains Model/ViewModel identities in a process-wide identity map with no public disposal operation. Closing a session releases provider-owned resources but cannot clear that map. Consequently `TargetPreview` allows **one session per target JVM**, including failed sessions. Every restart and interactive preview needs a fresh process. + +The adapter's readiness deadline and cleanup are cooperative FX-thread operations. A blocked constructor, Controller or cleanup hook cannot be interrupted by the adapter. Work package 7 must enforce an external process deadline, bounded graceful shutdown and forced termination using the host-owned lifetime channel. The fixture runner enforces a 30-second process timeout. Session close is not proof that library-held objects have been garbage-collected. + +## Classpath and module path + +The [fixtures](../integration-fixtures/preview/) are ordinary Maven applications depending on artifacts, without copies of library source or Kit/IntelliJ dependencies: + +- Classpath: application in main sources; provider, descriptors and executable checks in test sources. +- Module path: named application, separate named preview module and named check launcher. Application and preview dependencies are declared normally in Maven. + +The preview API, contracts and target tools now publish stable `Automatic-Module-Name` manifest entries: `de.neitzel.fxdesigner.preview`, `de.neitzel.fxdesigner.contracts`, and `de.neitzel.fxdesigner.target`. They are automatic modules, not fully modularized libraries. The qualified NeitzelLib artifacts resolve as automatic modules `fx`, `injection` and `core`. + +The fixture's Component/Controller package is unconditionally open for NeitzelLib's Reflections package discovery and FXML injection. The Model package opens to `fx` for bean binding access. The provider package exports to the target adapter and check launcher. See the actual fixture module descriptors for the complete requires/exports declarations. Removing either Component openness or Model openness produces a structured `MODULE_ACCESS` failure. No broad command-line `--add-opens` repairs are applied. + +This qualifies the boot module layer and these packages, not arbitrary custom module layers or every application package topology. + +## Reproduce and evidence + +After installing the designer reactor and verified NeitzelLib artifact: + +```sh +python3 scripts/check-preview-fixtures.py --target-java-home "$TARGET_JAVA_HOME" +``` + +The same suite runs from `scripts/check-bootstrap.py`. It builds temporary copies of both fixture reactors with Java 21 and launches 24 target processes, covering 346 assertions: + +- Metadata discovery without provider initialization or Model/Controller construction. +- Constructor-injected Components, post-initialization custom attributes, inherited attribute coercion and invalid attributes. +- Named ViewModel selection, Controller handoff, configured services, parent and nested bindings, write-back, shared nested Model/ViewModel identity and distinct Component instances. +- Nonempty rendered images and image-size limits. +- Delayed readiness, timeout, cancellation, factory/load/readiness/cleanup failure and exactly-once cleanup. +- Fresh Models/services in separate JVMs and rejection of session reuse. +- Missing Component and Model module access. + +Logs and machine-readable results go to `designer-target-tools/target/preview-fixtures/`. Unit tests additionally validate hostile/malformed metadata. The suite requires a graphical environment capable of initializing JavaFX; macOS is qualified here. It does not validate headless rendering, Windows/Linux packaging, real worker IPC or either host UI. diff --git a/docs/repository-bootstrap-plan.md b/docs/repository-bootstrap-plan.md index 84fd9ba..9ac346f 100644 --- a/docs/repository-bootstrap-plan.md +++ b/docs/repository-bootstrap-plan.md @@ -1,6 +1,6 @@ # NeitzelFXDesigner repository bootstrap plan -Status: repository foundation implemented by the initial commit; work package 2 has a verified development artifact; work package 3 implements the module/service foundation; work package 4 implements configuration and protocol validation; work package 5 implements Build and Refresh with immutable snapshots; work packages 6–7 remain planned. Consolidated 2026-11-12. Scope is the ordered implementation start for the [accepted initial contract](initial-project-contract.md); the initial commit creates the foundation only and does not publish a library release. +Status: repository foundation implemented by the initial commit; work package 2 has a verified development artifact; work package 3 implements the module/service foundation; work package 4 implements configuration and protocol validation; work package 5 implements Build and Refresh with immutable snapshots; work package 6 validates the preview API and target adapter with real fixtures; work package 7 remains planned. Consolidated 2026-11-12. Scope is the ordered implementation start for the [accepted initial contract](initial-project-contract.md); the initial commit creates the foundation only and does not publish a library release. ## Starting-point evidence before bootstrap @@ -74,6 +74,8 @@ Resolve scoped Maven builds, preview source arrangements, explicit save/cancel a ### 6. Validate the preview API and target adapter against real fixtures +Implemented: resource-only discovery, typed sessions, real NeitzelLib loading/rendering and 24 classpath/module-path process checks. See [qualification and lifecycle limits](preview-target-fixtures.md). Library-held Model/ViewModel state requires a fresh JVM per session; external shutdown enforcement remains work package 7. + Add classpath/module-path Maven fixture applications using NeitzelLib artifacts. Implement non-constructing descriptor discovery and typed provider/session lifecycle against the real library. Test fresh Models/services, readiness, cleanup, invalid attributes and write-back. Freeze public Java signatures only after these tests establish the adapter contract. **Exit:** standalone target-side tests pass without Kit/IntelliJ dependencies leaking into application-facing artifacts; both preview source arrangements are represented. diff --git a/integration-fixtures/preview/classpath/app/pom.xml b/integration-fixtures/preview/classpath/app/pom.xml new file mode 100644 index 0000000..6e98132 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + de.neitzel.fxdesigner.fixtures + preview-classpath + 1 + + app-classpath + + + de.neitzel.lib + fx + 1.0-SNAPSHOT + compile + + + de.neitzel.fxdesigner + designer-target-tools + 0.1.0-SNAPSHOT + test + + + diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/Card.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/Card.java new file mode 100644 index 0000000..e260245 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/Card.java @@ -0,0 +1,51 @@ +package fixture.components; + +@jakarta.inject.Singleton +public class Card extends javafx.scene.layout.VBox implements de.neitzel.fx.view.HasViewModel { + public final Greeting greeting; + private final javafx.beans.property.ObjectProperty model = + new javafx.beans.property.SimpleObjectProperty<>(); + @javafx.fxml.FXML private javafx.scene.control.Label captionLabel; + @javafx.fxml.FXML private javafx.scene.control.TextField street; + public de.neitzel.fx.view.ViewModel received; + public boolean injected; + + public Card(Greeting greeting) { + this.greeting = greeting; + } + + @javafx.fxml.FXML + public void initialize() { + injected = true; + captionLabel.setText(greeting.prefix); + } + + public String getCaption() { + return captionLabel.getText(); + } + + public void setCaption(String value) { + if (!injected) throw new IllegalStateException("Caption applied before injection"); + captionLabel.setText(value); + } + + public javafx.beans.property.ObjectProperty modelProperty() { + return model; + } + + public Object getModel() { + return model.get(); + } + + public void setModel(Object value) { + model.set(value); + } + + public void setViewModel(de.neitzel.fx.view.ViewModel vm) { + received = vm; + } + + public javafx.scene.control.TextField street() { + return street; + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/Greeting.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/Greeting.java new file mode 100644 index 0000000..4e43d99 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/Greeting.java @@ -0,0 +1,16 @@ +package fixture.components; + +@jakarta.inject.Singleton +public class Greeting implements AutoCloseable { + public final String identity = java.util.UUID.randomUUID().toString(); + public String prefix = "sample"; + public int closes; + + public void close() { + closes++; + } + + public String text() { + return prefix + "-" + identity; + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonController.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonController.java new file mode 100644 index 0000000..2038917 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonController.java @@ -0,0 +1,22 @@ +package fixture.components; + +@jakarta.inject.Singleton +@de.neitzel.fx.view.ViewModelClass(PersonViewModel.class) +public class PersonController implements de.neitzel.fx.view.HasViewModel { + public final Greeting greeting; + public de.neitzel.fx.view.ViewModel received; + + public PersonController(Greeting greeting) { + this.greeting = greeting; + System.setProperty("fixture.controllers", "constructed"); + } + + public void setViewModel(de.neitzel.fx.view.ViewModel vm) { + received = vm; + } + + public String greeting() { + if (received == null) throw new IllegalStateException("No ViewModel before bindings"); + return greeting.text(); + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonViewModel.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonViewModel.java new file mode 100644 index 0000000..7ecd6a3 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonViewModel.java @@ -0,0 +1,7 @@ +package fixture.components; + +public class PersonViewModel extends de.neitzel.fx.view.ViewModel { + public PersonViewModel(Object model) { + super(model); + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/model/Address.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/model/Address.java new file mode 100644 index 0000000..d9ca5bd --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/model/Address.java @@ -0,0 +1,13 @@ +package fixture.model; + +public class Address { + private String street = "First Street"; + + public String getStreet() { + return street; + } + + public void setStreet(String street) { + this.street = street; + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/model/Person.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/model/Person.java new file mode 100644 index 0000000..dc845a4 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/model/Person.java @@ -0,0 +1,28 @@ +package fixture.model; + +public class Person { + private String name = "Ada"; + private Address home = new Address(); + + public Person() { + System.setProperty( + "fixture.models", + Integer.toString(Integer.parseInt(System.getProperty("fixture.models", "0")) + 1)); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Address getHome() { + return home; + } + + public void setHome(Address home) { + this.home = home; + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Card.fxml b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Card.fxml new file mode 100644 index 0000000..69dd8c6 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Card.fxml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Invalid.fxml b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Invalid.fxml new file mode 100644 index 0000000..cf85f01 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Invalid.fxml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Person.fxml b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Person.fxml new file mode 100644 index 0000000..9c4cf32 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Person.fxml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java b/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java new file mode 100644 index 0000000..65e9071 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java @@ -0,0 +1,197 @@ +package fixture.checks; + +import de.neitzel.fxdesigner.preview.*; +import de.neitzel.fxdesigner.target.*; +import fixture.components.*; +import fixture.model.*; +import fixture.preview.Examples; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import javafx.application.Platform; +import javafx.geometry.Pos; +import javafx.scene.control.*; + +public final class PreviewChecks { + static int checks; + + static void check(boolean b, String message) { + if (!b) throw new AssertionError(message); + checks++; + } + + static T fx(Callable action) throws Exception { + var result = new CompletableFuture(); + Platform.runLater( + () -> { + try { + result.complete(action.call()); + } catch (Throwable e) { + result.completeExceptionally(e); + } + }); + return result.get(10, TimeUnit.SECONDS); + } + + static PreviewFailure failure(Throwable error) { + for (Throwable t = error; t != null; t = t.getCause()) + if (t instanceof PreviewFailure f) return f; + throw new AssertionError("No structured preview diagnostic", error); + } + + public static void main(String[] args) throws Exception { + String mode = args[0]; + ClassLoader loader = PreviewChecks.class.getClassLoader(); + var descriptors = ExampleCatalog.discover(loader); + check(descriptors.size() == 9, "Descriptor discovery"); + check( + System.getProperty("fixture.provider.initialized") == null, + "Discovery initialized provider"); + check(System.getProperty("fixture.models") == null, "Discovery constructed Model"); + check(System.getProperty("fixture.controllers") == null, "Discovery constructed Controller"); + if (mode.equals("discovery")) { + System.out.println("PASS discovery " + checks); + return; + } + var started = new CountDownLatch(1); + Platform.startup( + () -> { + Platform.setImplicitExit(false); + started.countDown(); + }); + check(started.await(10, TimeUnit.SECONDS), "FX startup"); + TargetPreview preview = null; + try { + String id = mode.startsWith("missing-") ? "standard" : mode; + var descriptor = + descriptors.stream().filter(d -> d.id().equals(id)).findFirst().orElseThrow(); + preview = + TargetPreview.open( + descriptor, loader, Duration.ofMillis(mode.equals("timeout") ? 2000 : 10000)); + if (mode.equals("async") || mode.equals("cancel")) { + for (int i = 0; i < 100 && Examples.pending == null; i++) Thread.sleep(20); + check(Examples.pending != null, "Provider readiness created"); + check(!preview.ready().toCompletableFuture().isDone(), "Premature readiness"); + if (mode.equals("async")) Examples.pending.complete(null); + else preview.cancel(); + } + if (java.util.Set.of( + "timeout", + "cancel", + "readiness-failure", + "wrong-model", + "invalid-attribute", + "factory-failure", + "missing-components", + "missing-model") + .contains(mode)) { + try { + preview.ready().toCompletableFuture().get(15, TimeUnit.SECONDS); + throw new AssertionError("Failure accepted: " + mode); + } catch (ExecutionException error) { + var f = failure(error); + var expected = + switch (mode) { + case "timeout" -> PreviewFailure.Code.TIMEOUT; + case "cancel" -> PreviewFailure.Code.CANCELLED; + case "readiness-failure" -> PreviewFailure.Code.READINESS; + case "wrong-model" -> PreviewFailure.Code.MODEL_TYPE; + case "missing-components", "missing-model" -> PreviewFailure.Code.MODULE_ACCESS; + default -> PreviewFailure.Code.LOAD; + }; + check(f.code() == expected, "Wrong diagnostic: " + f.code() + " expected " + expected); + } + preview.cleanup().toCompletableFuture().get(5, TimeUnit.SECONDS); + if (!mode.equals("missing-components")) { + check(Examples.service.closes == 1, "Failure did not clean services once"); + if (!mode.equals("factory-failure")) + check(Examples.closes == 1, "Session cleanup missing"); + } + } else { + TargetPreview ready = preview.ready().toCompletableFuture().get(15, TimeUnit.SECONDS); + fx( + () -> { + var view = ready.loadedView(); + check(view.getViewModel() instanceof PersonViewModel, "Named ViewModel not selected"); + var controller = Examples.context.getComponent(PersonController.class); + check(controller.received == view.getViewModel(), "Controller ViewModel handoff"); + check(controller.greeting == Examples.service, "Configured service not injected"); + var card = (Card) view.getRoot().lookup("#card"); + var second = (Card) view.getRoot().lookup("#second"); + check(card != second, "Component instances reused"); + check( + card.getModel() == Examples.person.getHome() + && second.getModel() == card.getModel(), + "Nested Model identity not shared"); + check(card.received == second.received, "Nested ViewModel identity changed"); + check( + card.greeting == Examples.service && second.greeting == Examples.service, + "Component injection"); + check(card.getCaption().equals("FXML caption"), "Custom caption lost"); + check( + card.getPrefWidth() == 310 && card.getAlignment() == Pos.CENTER_LEFT, + "Inherited/coerced attributes lost"); + check(card.injected, "Component initialization"); + TextField name = (TextField) view.getRoot().lookup("#name"); + check(name.getText().equals("Ada"), "Initial parent binding"); + name.setText("Grace"); + check(Examples.person.getName().equals("Grace"), "Parent write-back"); + card.street().setText("Changed Street"); + check( + Examples.person.getHome().getStreet().equals("Changed Street") + && second.street().getText().equals("Changed Street"), + "Component write-back and shared Model"); + check( + ((Label) view.getRoot().lookup("#greeting")).getText().startsWith("configured-"), + "Computed controller binding"); + byte[] rgba = ready.render(420, 240); + check(rgba.length == 420 * 240 * 4, "Render size"); + boolean nonzero = false; + for (byte value : rgba) + if (value != 0) { + nonzero = true; + break; + } + check(nonzero, "Blank rendering"); + try { + ready.render(8192, 8192); + throw new AssertionError("Oversized image allowed"); + } catch (IllegalArgumentException expected) { + checks++; + } + return null; + }); + System.out.println( + "SESSION " + + Examples.service.identity + + " models=" + + System.getProperty("fixture.models")); + preview.close(); + try { + preview.cleanup().toCompletableFuture().get(5, TimeUnit.SECONDS); + check(!mode.equals("cleanup-failure"), "Cleanup failure hidden"); + } catch (ExecutionException error) { + check( + mode.equals("cleanup-failure") + && failure(error).code() == PreviewFailure.Code.CLEANUP, + "Wrong cleanup diagnostic"); + } + check( + Examples.closes == 1 && Examples.service.closes == 1, + "Owned resources not cleaned exactly once"); + preview.close(); + check(Examples.closes == 1, "Repeated close cleaned twice"); + } + try { + TargetPreview.open(descriptor, loader, Duration.ofSeconds(1)); + throw new AssertionError("Process session reused"); + } catch (PreviewFailure e) { + check(e.code() == PreviewFailure.Code.SESSION_REUSE, "Wrong reuse diagnostic"); + } + System.out.println("PASS " + mode + " " + checks); + } finally { + if (preview != null) preview.close(); + Platform.exit(); + } + } +} diff --git a/integration-fixtures/preview/classpath/app/src/test/java/fixture/preview/Examples.java b/integration-fixtures/preview/classpath/app/src/test/java/fixture/preview/Examples.java new file mode 100644 index 0000000..a1e3b99 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/test/java/fixture/preview/Examples.java @@ -0,0 +1,62 @@ +package fixture.preview; + +import de.neitzel.fxdesigner.preview.*; +import de.neitzel.injection.ApplicationContext; +import fixture.components.*; +import fixture.model.Person; +import java.util.concurrent.*; + +public final class Examples implements PreviewProvider { + static { + System.setProperty("fixture.provider.initialized", "true"); + } + + public static Person person; + public static ApplicationContext context; + public static Greeting service; + public static volatile CompletableFuture pending; + public static int closes; + + public Class modelType() { + return Person.class; + } + + public PreviewSession open(String id) throws Exception { + if (!javafx.application.Platform.isFxApplicationThread()) + throw new AssertionError("Provider not on FX"); + person = new Person(); + context = new ApplicationContext("fixture.components"); + service = context.getComponent(Greeting.class); + service.prefix = "configured"; + if (id.equals("factory-failure")) { + service.close(); + throw new IllegalStateException("Factory failed after cleaning partial resources"); + } + pending = new CompletableFuture<>(); + if (!java.util.Set.of("async", "timeout", "cancel", "readiness-failure").contains(id)) + pending.complete(null); + return new PreviewSession<>() { + public Person model() { + return id.equals("wrong-model") ? null : person; + } + + public ApplicationContext context() { + return context; + } + + public CompletionStage ready() { + if (id.equals("readiness-failure")) + return CompletableFuture.failedFuture(new IllegalStateException("Readiness failed")); + return pending; + } + + public void close() { + if (!javafx.application.Platform.isFxApplicationThread()) + throw new AssertionError("Cleanup not on FX"); + closes++; + service.close(); + if (id.equals("cleanup-failure")) throw new IllegalStateException("Cleanup failure"); + } + }; + } +} diff --git a/integration-fixtures/preview/classpath/app/src/test/resources/META-INF/neitzel-fx-designer/examples.properties b/integration-fixtures/preview/classpath/app/src/test/resources/META-INF/neitzel-fx-designer/examples.properties new file mode 100644 index 0000000..b429437 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/test/resources/META-INF/neitzel-fx-designer/examples.properties @@ -0,0 +1,56 @@ +format=1 +count=9 +example.0.id=standard +example.0.label=standard +example.0.modelType=fixture.model.Person +example.0.providerClass=fixture.preview.Examples +example.0.viewResource=fixture/components/Person.fxml +example.0.componentPackage=fixture.components +example.1.id=async +example.1.label=async +example.1.modelType=fixture.model.Person +example.1.providerClass=fixture.preview.Examples +example.1.viewResource=fixture/components/Person.fxml +example.1.componentPackage=fixture.components +example.2.id=timeout +example.2.label=timeout +example.2.modelType=fixture.model.Person +example.2.providerClass=fixture.preview.Examples +example.2.viewResource=fixture/components/Person.fxml +example.2.componentPackage=fixture.components +example.3.id=cancel +example.3.label=cancel +example.3.modelType=fixture.model.Person +example.3.providerClass=fixture.preview.Examples +example.3.viewResource=fixture/components/Person.fxml +example.3.componentPackage=fixture.components +example.4.id=readiness-failure +example.4.label=readiness-failure +example.4.modelType=fixture.model.Person +example.4.providerClass=fixture.preview.Examples +example.4.viewResource=fixture/components/Person.fxml +example.4.componentPackage=fixture.components +example.5.id=wrong-model +example.5.label=wrong-model +example.5.modelType=fixture.model.Person +example.5.providerClass=fixture.preview.Examples +example.5.viewResource=fixture/components/Person.fxml +example.5.componentPackage=fixture.components +example.6.id=invalid-attribute +example.6.label=invalid-attribute +example.6.modelType=fixture.model.Person +example.6.providerClass=fixture.preview.Examples +example.6.viewResource=fixture/components/Invalid.fxml +example.6.componentPackage=fixture.components +example.7.id=cleanup-failure +example.7.label=cleanup-failure +example.7.modelType=fixture.model.Person +example.7.providerClass=fixture.preview.Examples +example.7.viewResource=fixture/components/Person.fxml +example.7.componentPackage=fixture.components +example.8.id=factory-failure +example.8.label=factory-failure +example.8.modelType=fixture.model.Person +example.8.providerClass=fixture.preview.Examples +example.8.viewResource=fixture/components/Person.fxml +example.8.componentPackage=fixture.components diff --git a/integration-fixtures/preview/classpath/neitzel-fx-designer.json b/integration-fixtures/preview/classpath/neitzel-fx-designer.json new file mode 100644 index 0000000..7b5d72a --- /dev/null +++ b/integration-fixtures/preview/classpath/neitzel-fx-designer.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": "1.0", + "contexts": { + "fixture": { + "applicationModule": "app", + "launchMode": "classpath", + "buildRuntime": "java21", + "targetRuntime": "java21", + "preview": { + "sourceSet": "test", + "provider": "fixture.preview.Examples" + } + } + } +} diff --git a/integration-fixtures/preview/classpath/pom.xml b/integration-fixtures/preview/classpath/pom.xml new file mode 100644 index 0000000..8819c2f --- /dev/null +++ b/integration-fixtures/preview/classpath/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + de.neitzel.fxdesigner.fixtures + preview-classpath + 1 + pom + + app + + + 21 + UTF-8 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + + diff --git a/integration-fixtures/preview/modulepath/app/pom.xml b/integration-fixtures/preview/modulepath/app/pom.xml new file mode 100644 index 0000000..81af602 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/pom.xml @@ -0,0 +1,18 @@ + + + 4.0.0 + + de.neitzel.fxdesigner.fixtures + preview-fixture-modulepath + 1 + + app-modulepath + + + de.neitzel.lib + fx + 1.0-SNAPSHOT + compile + + + diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/Card.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/Card.java new file mode 100644 index 0000000..e260245 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/Card.java @@ -0,0 +1,51 @@ +package fixture.components; + +@jakarta.inject.Singleton +public class Card extends javafx.scene.layout.VBox implements de.neitzel.fx.view.HasViewModel { + public final Greeting greeting; + private final javafx.beans.property.ObjectProperty model = + new javafx.beans.property.SimpleObjectProperty<>(); + @javafx.fxml.FXML private javafx.scene.control.Label captionLabel; + @javafx.fxml.FXML private javafx.scene.control.TextField street; + public de.neitzel.fx.view.ViewModel received; + public boolean injected; + + public Card(Greeting greeting) { + this.greeting = greeting; + } + + @javafx.fxml.FXML + public void initialize() { + injected = true; + captionLabel.setText(greeting.prefix); + } + + public String getCaption() { + return captionLabel.getText(); + } + + public void setCaption(String value) { + if (!injected) throw new IllegalStateException("Caption applied before injection"); + captionLabel.setText(value); + } + + public javafx.beans.property.ObjectProperty modelProperty() { + return model; + } + + public Object getModel() { + return model.get(); + } + + public void setModel(Object value) { + model.set(value); + } + + public void setViewModel(de.neitzel.fx.view.ViewModel vm) { + received = vm; + } + + public javafx.scene.control.TextField street() { + return street; + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/Greeting.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/Greeting.java new file mode 100644 index 0000000..4e43d99 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/Greeting.java @@ -0,0 +1,16 @@ +package fixture.components; + +@jakarta.inject.Singleton +public class Greeting implements AutoCloseable { + public final String identity = java.util.UUID.randomUUID().toString(); + public String prefix = "sample"; + public int closes; + + public void close() { + closes++; + } + + public String text() { + return prefix + "-" + identity; + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonController.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonController.java new file mode 100644 index 0000000..2038917 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonController.java @@ -0,0 +1,22 @@ +package fixture.components; + +@jakarta.inject.Singleton +@de.neitzel.fx.view.ViewModelClass(PersonViewModel.class) +public class PersonController implements de.neitzel.fx.view.HasViewModel { + public final Greeting greeting; + public de.neitzel.fx.view.ViewModel received; + + public PersonController(Greeting greeting) { + this.greeting = greeting; + System.setProperty("fixture.controllers", "constructed"); + } + + public void setViewModel(de.neitzel.fx.view.ViewModel vm) { + received = vm; + } + + public String greeting() { + if (received == null) throw new IllegalStateException("No ViewModel before bindings"); + return greeting.text(); + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonViewModel.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonViewModel.java new file mode 100644 index 0000000..7ecd6a3 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/PersonViewModel.java @@ -0,0 +1,7 @@ +package fixture.components; + +public class PersonViewModel extends de.neitzel.fx.view.ViewModel { + public PersonViewModel(Object model) { + super(model); + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/model/Address.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/model/Address.java new file mode 100644 index 0000000..d9ca5bd --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/model/Address.java @@ -0,0 +1,13 @@ +package fixture.model; + +public class Address { + private String street = "First Street"; + + public String getStreet() { + return street; + } + + public void setStreet(String street) { + this.street = street; + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/model/Person.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/model/Person.java new file mode 100644 index 0000000..dc845a4 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/model/Person.java @@ -0,0 +1,28 @@ +package fixture.model; + +public class Person { + private String name = "Ada"; + private Address home = new Address(); + + public Person() { + System.setProperty( + "fixture.models", + Integer.toString(Integer.parseInt(System.getProperty("fixture.models", "0")) + 1)); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Address getHome() { + return home; + } + + public void setHome(Address home) { + this.home = home; + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/module-info.java b/integration-fixtures/preview/modulepath/app/src/main/java/module-info.java new file mode 100644 index 0000000..fb29ed7 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/module-info.java @@ -0,0 +1,18 @@ +module fixture.app { + requires fx; + requires injection; + requires jakarta.inject; + requires javafx.controls; + requires javafx.fxml; + + exports fixture.model to + fixture.preview, + fixture.checks; + exports fixture.components to + fixture.preview, + fixture.checks; + + opens fixture.components; + opens fixture.model to + fx; +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Card.fxml b/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Card.fxml new file mode 100644 index 0000000..69dd8c6 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Card.fxml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Invalid.fxml b/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Invalid.fxml new file mode 100644 index 0000000..cf85f01 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Invalid.fxml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Person.fxml b/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Person.fxml new file mode 100644 index 0000000..9c4cf32 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/Person.fxml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/integration-fixtures/preview/modulepath/checks/pom.xml b/integration-fixtures/preview/modulepath/checks/pom.xml new file mode 100644 index 0000000..c2fae8d --- /dev/null +++ b/integration-fixtures/preview/modulepath/checks/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + de.neitzel.fxdesigner.fixtures + preview-fixture-modulepath + 1 + + checks-modulepath + + + de.neitzel.fxdesigner.fixtures + preview-modulepath + 1 + compile + + + de.neitzel.fxdesigner + designer-target-tools + 0.1.0-SNAPSHOT + compile + + + diff --git a/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java b/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java new file mode 100644 index 0000000..65e9071 --- /dev/null +++ b/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java @@ -0,0 +1,197 @@ +package fixture.checks; + +import de.neitzel.fxdesigner.preview.*; +import de.neitzel.fxdesigner.target.*; +import fixture.components.*; +import fixture.model.*; +import fixture.preview.Examples; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import javafx.application.Platform; +import javafx.geometry.Pos; +import javafx.scene.control.*; + +public final class PreviewChecks { + static int checks; + + static void check(boolean b, String message) { + if (!b) throw new AssertionError(message); + checks++; + } + + static T fx(Callable action) throws Exception { + var result = new CompletableFuture(); + Platform.runLater( + () -> { + try { + result.complete(action.call()); + } catch (Throwable e) { + result.completeExceptionally(e); + } + }); + return result.get(10, TimeUnit.SECONDS); + } + + static PreviewFailure failure(Throwable error) { + for (Throwable t = error; t != null; t = t.getCause()) + if (t instanceof PreviewFailure f) return f; + throw new AssertionError("No structured preview diagnostic", error); + } + + public static void main(String[] args) throws Exception { + String mode = args[0]; + ClassLoader loader = PreviewChecks.class.getClassLoader(); + var descriptors = ExampleCatalog.discover(loader); + check(descriptors.size() == 9, "Descriptor discovery"); + check( + System.getProperty("fixture.provider.initialized") == null, + "Discovery initialized provider"); + check(System.getProperty("fixture.models") == null, "Discovery constructed Model"); + check(System.getProperty("fixture.controllers") == null, "Discovery constructed Controller"); + if (mode.equals("discovery")) { + System.out.println("PASS discovery " + checks); + return; + } + var started = new CountDownLatch(1); + Platform.startup( + () -> { + Platform.setImplicitExit(false); + started.countDown(); + }); + check(started.await(10, TimeUnit.SECONDS), "FX startup"); + TargetPreview preview = null; + try { + String id = mode.startsWith("missing-") ? "standard" : mode; + var descriptor = + descriptors.stream().filter(d -> d.id().equals(id)).findFirst().orElseThrow(); + preview = + TargetPreview.open( + descriptor, loader, Duration.ofMillis(mode.equals("timeout") ? 2000 : 10000)); + if (mode.equals("async") || mode.equals("cancel")) { + for (int i = 0; i < 100 && Examples.pending == null; i++) Thread.sleep(20); + check(Examples.pending != null, "Provider readiness created"); + check(!preview.ready().toCompletableFuture().isDone(), "Premature readiness"); + if (mode.equals("async")) Examples.pending.complete(null); + else preview.cancel(); + } + if (java.util.Set.of( + "timeout", + "cancel", + "readiness-failure", + "wrong-model", + "invalid-attribute", + "factory-failure", + "missing-components", + "missing-model") + .contains(mode)) { + try { + preview.ready().toCompletableFuture().get(15, TimeUnit.SECONDS); + throw new AssertionError("Failure accepted: " + mode); + } catch (ExecutionException error) { + var f = failure(error); + var expected = + switch (mode) { + case "timeout" -> PreviewFailure.Code.TIMEOUT; + case "cancel" -> PreviewFailure.Code.CANCELLED; + case "readiness-failure" -> PreviewFailure.Code.READINESS; + case "wrong-model" -> PreviewFailure.Code.MODEL_TYPE; + case "missing-components", "missing-model" -> PreviewFailure.Code.MODULE_ACCESS; + default -> PreviewFailure.Code.LOAD; + }; + check(f.code() == expected, "Wrong diagnostic: " + f.code() + " expected " + expected); + } + preview.cleanup().toCompletableFuture().get(5, TimeUnit.SECONDS); + if (!mode.equals("missing-components")) { + check(Examples.service.closes == 1, "Failure did not clean services once"); + if (!mode.equals("factory-failure")) + check(Examples.closes == 1, "Session cleanup missing"); + } + } else { + TargetPreview ready = preview.ready().toCompletableFuture().get(15, TimeUnit.SECONDS); + fx( + () -> { + var view = ready.loadedView(); + check(view.getViewModel() instanceof PersonViewModel, "Named ViewModel not selected"); + var controller = Examples.context.getComponent(PersonController.class); + check(controller.received == view.getViewModel(), "Controller ViewModel handoff"); + check(controller.greeting == Examples.service, "Configured service not injected"); + var card = (Card) view.getRoot().lookup("#card"); + var second = (Card) view.getRoot().lookup("#second"); + check(card != second, "Component instances reused"); + check( + card.getModel() == Examples.person.getHome() + && second.getModel() == card.getModel(), + "Nested Model identity not shared"); + check(card.received == second.received, "Nested ViewModel identity changed"); + check( + card.greeting == Examples.service && second.greeting == Examples.service, + "Component injection"); + check(card.getCaption().equals("FXML caption"), "Custom caption lost"); + check( + card.getPrefWidth() == 310 && card.getAlignment() == Pos.CENTER_LEFT, + "Inherited/coerced attributes lost"); + check(card.injected, "Component initialization"); + TextField name = (TextField) view.getRoot().lookup("#name"); + check(name.getText().equals("Ada"), "Initial parent binding"); + name.setText("Grace"); + check(Examples.person.getName().equals("Grace"), "Parent write-back"); + card.street().setText("Changed Street"); + check( + Examples.person.getHome().getStreet().equals("Changed Street") + && second.street().getText().equals("Changed Street"), + "Component write-back and shared Model"); + check( + ((Label) view.getRoot().lookup("#greeting")).getText().startsWith("configured-"), + "Computed controller binding"); + byte[] rgba = ready.render(420, 240); + check(rgba.length == 420 * 240 * 4, "Render size"); + boolean nonzero = false; + for (byte value : rgba) + if (value != 0) { + nonzero = true; + break; + } + check(nonzero, "Blank rendering"); + try { + ready.render(8192, 8192); + throw new AssertionError("Oversized image allowed"); + } catch (IllegalArgumentException expected) { + checks++; + } + return null; + }); + System.out.println( + "SESSION " + + Examples.service.identity + + " models=" + + System.getProperty("fixture.models")); + preview.close(); + try { + preview.cleanup().toCompletableFuture().get(5, TimeUnit.SECONDS); + check(!mode.equals("cleanup-failure"), "Cleanup failure hidden"); + } catch (ExecutionException error) { + check( + mode.equals("cleanup-failure") + && failure(error).code() == PreviewFailure.Code.CLEANUP, + "Wrong cleanup diagnostic"); + } + check( + Examples.closes == 1 && Examples.service.closes == 1, + "Owned resources not cleaned exactly once"); + preview.close(); + check(Examples.closes == 1, "Repeated close cleaned twice"); + } + try { + TargetPreview.open(descriptor, loader, Duration.ofSeconds(1)); + throw new AssertionError("Process session reused"); + } catch (PreviewFailure e) { + check(e.code() == PreviewFailure.Code.SESSION_REUSE, "Wrong reuse diagnostic"); + } + System.out.println("PASS " + mode + " " + checks); + } finally { + if (preview != null) preview.close(); + Platform.exit(); + } + } +} diff --git a/integration-fixtures/preview/modulepath/checks/src/main/java/module-info.java b/integration-fixtures/preview/modulepath/checks/src/main/java/module-info.java new file mode 100644 index 0000000..9510ae4 --- /dev/null +++ b/integration-fixtures/preview/modulepath/checks/src/main/java/module-info.java @@ -0,0 +1,10 @@ +module fixture.checks { + requires fixture.app; + requires fixture.preview; + requires de.neitzel.fxdesigner.target; + requires de.neitzel.fxdesigner.preview; + requires fx; + requires injection; + requires javafx.controls; + requires javafx.fxml; +} diff --git a/integration-fixtures/preview/modulepath/neitzel-fx-designer.json b/integration-fixtures/preview/modulepath/neitzel-fx-designer.json new file mode 100644 index 0000000..74bc31c --- /dev/null +++ b/integration-fixtures/preview/modulepath/neitzel-fx-designer.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": "1.0", + "contexts": { + "fixture": { + "applicationModule": "app", + "launchMode": "module-path", + "buildRuntime": "java21", + "targetRuntime": "java21", + "preview": { + "sourceSet": "module", + "module": "preview", + "provider": "fixture.preview.Examples" + }, + "modulePath": { + "entryModule": "fixture.checks" + } + } + } +} diff --git a/integration-fixtures/preview/modulepath/pom.xml b/integration-fixtures/preview/modulepath/pom.xml new file mode 100644 index 0000000..4b3c705 --- /dev/null +++ b/integration-fixtures/preview/modulepath/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + de.neitzel.fxdesigner.fixtures + preview-fixture-modulepath + 1 + pom + + app + preview + checks + + + 21 + UTF-8 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + + diff --git a/integration-fixtures/preview/modulepath/preview/pom.xml b/integration-fixtures/preview/modulepath/preview/pom.xml new file mode 100644 index 0000000..2460a19 --- /dev/null +++ b/integration-fixtures/preview/modulepath/preview/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + de.neitzel.fxdesigner.fixtures + preview-fixture-modulepath + 1 + + preview-modulepath + + + de.neitzel.fxdesigner.fixtures + app-modulepath + 1 + compile + + + de.neitzel.fxdesigner + designer-preview-api + 0.1.0-SNAPSHOT + compile + + + diff --git a/integration-fixtures/preview/modulepath/preview/src/main/java/fixture/preview/Examples.java b/integration-fixtures/preview/modulepath/preview/src/main/java/fixture/preview/Examples.java new file mode 100644 index 0000000..a1e3b99 --- /dev/null +++ b/integration-fixtures/preview/modulepath/preview/src/main/java/fixture/preview/Examples.java @@ -0,0 +1,62 @@ +package fixture.preview; + +import de.neitzel.fxdesigner.preview.*; +import de.neitzel.injection.ApplicationContext; +import fixture.components.*; +import fixture.model.Person; +import java.util.concurrent.*; + +public final class Examples implements PreviewProvider { + static { + System.setProperty("fixture.provider.initialized", "true"); + } + + public static Person person; + public static ApplicationContext context; + public static Greeting service; + public static volatile CompletableFuture pending; + public static int closes; + + public Class modelType() { + return Person.class; + } + + public PreviewSession open(String id) throws Exception { + if (!javafx.application.Platform.isFxApplicationThread()) + throw new AssertionError("Provider not on FX"); + person = new Person(); + context = new ApplicationContext("fixture.components"); + service = context.getComponent(Greeting.class); + service.prefix = "configured"; + if (id.equals("factory-failure")) { + service.close(); + throw new IllegalStateException("Factory failed after cleaning partial resources"); + } + pending = new CompletableFuture<>(); + if (!java.util.Set.of("async", "timeout", "cancel", "readiness-failure").contains(id)) + pending.complete(null); + return new PreviewSession<>() { + public Person model() { + return id.equals("wrong-model") ? null : person; + } + + public ApplicationContext context() { + return context; + } + + public CompletionStage ready() { + if (id.equals("readiness-failure")) + return CompletableFuture.failedFuture(new IllegalStateException("Readiness failed")); + return pending; + } + + public void close() { + if (!javafx.application.Platform.isFxApplicationThread()) + throw new AssertionError("Cleanup not on FX"); + closes++; + service.close(); + if (id.equals("cleanup-failure")) throw new IllegalStateException("Cleanup failure"); + } + }; + } +} diff --git a/integration-fixtures/preview/modulepath/preview/src/main/java/module-info.java b/integration-fixtures/preview/modulepath/preview/src/main/java/module-info.java new file mode 100644 index 0000000..796d909 --- /dev/null +++ b/integration-fixtures/preview/modulepath/preview/src/main/java/module-info.java @@ -0,0 +1,10 @@ +module fixture.preview { + requires fixture.app; + requires injection; + requires javafx.graphics; + requires de.neitzel.fxdesigner.preview; + + exports fixture.preview to + fixture.checks, + de.neitzel.fxdesigner.target; +} diff --git a/integration-fixtures/preview/modulepath/preview/src/main/resources/META-INF/neitzel-fx-designer/examples.properties b/integration-fixtures/preview/modulepath/preview/src/main/resources/META-INF/neitzel-fx-designer/examples.properties new file mode 100644 index 0000000..b429437 --- /dev/null +++ b/integration-fixtures/preview/modulepath/preview/src/main/resources/META-INF/neitzel-fx-designer/examples.properties @@ -0,0 +1,56 @@ +format=1 +count=9 +example.0.id=standard +example.0.label=standard +example.0.modelType=fixture.model.Person +example.0.providerClass=fixture.preview.Examples +example.0.viewResource=fixture/components/Person.fxml +example.0.componentPackage=fixture.components +example.1.id=async +example.1.label=async +example.1.modelType=fixture.model.Person +example.1.providerClass=fixture.preview.Examples +example.1.viewResource=fixture/components/Person.fxml +example.1.componentPackage=fixture.components +example.2.id=timeout +example.2.label=timeout +example.2.modelType=fixture.model.Person +example.2.providerClass=fixture.preview.Examples +example.2.viewResource=fixture/components/Person.fxml +example.2.componentPackage=fixture.components +example.3.id=cancel +example.3.label=cancel +example.3.modelType=fixture.model.Person +example.3.providerClass=fixture.preview.Examples +example.3.viewResource=fixture/components/Person.fxml +example.3.componentPackage=fixture.components +example.4.id=readiness-failure +example.4.label=readiness-failure +example.4.modelType=fixture.model.Person +example.4.providerClass=fixture.preview.Examples +example.4.viewResource=fixture/components/Person.fxml +example.4.componentPackage=fixture.components +example.5.id=wrong-model +example.5.label=wrong-model +example.5.modelType=fixture.model.Person +example.5.providerClass=fixture.preview.Examples +example.5.viewResource=fixture/components/Person.fxml +example.5.componentPackage=fixture.components +example.6.id=invalid-attribute +example.6.label=invalid-attribute +example.6.modelType=fixture.model.Person +example.6.providerClass=fixture.preview.Examples +example.6.viewResource=fixture/components/Invalid.fxml +example.6.componentPackage=fixture.components +example.7.id=cleanup-failure +example.7.label=cleanup-failure +example.7.modelType=fixture.model.Person +example.7.providerClass=fixture.preview.Examples +example.7.viewResource=fixture/components/Person.fxml +example.7.componentPackage=fixture.components +example.8.id=factory-failure +example.8.label=factory-failure +example.8.modelType=fixture.model.Person +example.8.providerClass=fixture.preview.Examples +example.8.viewResource=fixture/components/Person.fxml +example.8.componentPackage=fixture.components diff --git a/scripts/check-bootstrap.py b/scripts/check-bootstrap.py index e244ed6..22bbb11 100644 --- a/scripts/check-bootstrap.py +++ b/scripts/check-bootstrap.py @@ -37,6 +37,7 @@ version = subprocess.run([str(target_java), "-version"], capture_output=True, te if not re.search(r'version "21[.\"]', version.stderr): sys.exit("Target linkage gate requires Java 21") run([target_java, "-cp", classpath, "de.neitzel.fxdesigner.target.TargetToolInfo"]) +run([sys.executable, ROOT / "scripts/check-preview-fixtures.py", "--target-java-home", args.target_java_home]) run([wrapper, "-B", "-ntp", "-pl", "designer-standalone", "org.apache.maven.plugins:maven-dependency-plugin:3.6.1:build-classpath", "-DincludeScope=test", "-Dmdep.outputFile=target/smoke-classpath.txt"]) smoke_classpath = os.pathsep.join([str(ROOT / "designer-standalone/target/test-classes"), str(ROOT / "designer-standalone/target/classes"), (ROOT / "designer-standalone/target/smoke-classpath.txt").read_text().strip()]) run([Path(os.environ["JAVA_HOME"]) / "bin/java", "-cp", smoke_classpath, "de.neitzel.fxdesigner.build.BuildRefreshSmoke", ROOT, args.target_java_home]) diff --git a/scripts/check-preview-fixtures.py b/scripts/check-preview-fixtures.py new file mode 100644 index 0000000..3498937 --- /dev/null +++ b/scripts/check-preview-fixtures.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Qualify real NeitzelLib preview sessions on classpath and named module path.""" +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parent.parent +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--target-java-home", required=True) +args = parser.parse_args() +java_home = Path(args.target_java_home).resolve() +env = dict(os.environ, JAVA_HOME=str(java_home)) +output = ROOT / "designer-target-tools/target/preview-fixtures" +output.mkdir(parents=True, exist_ok=True) +results = [] + + +def run(label, command, timeout=180): + result = subprocess.run([str(x) for x in command], env=env, cwd=ROOT, + capture_output=True, text=True, timeout=timeout) + log = result.stdout + result.stderr + (output / (label + ".log")).write_text(log) + if result.returncode: + raise RuntimeError(label + "\n" + log[-12000:]) + return log + + +def build(path, label): + run(label, [ROOT / "mvnw", "-B", "-ntp", "-f", path / "pom.xml", "package", + "org.apache.maven.plugins:maven-dependency-plugin:3.6.1:build-classpath", + "-DincludeScope=test", "-Dmdep.outputFile=target/runtime.txt"]) + + +def launch(path, arrangement, mode, label): + if arrangement == "classpath": + target = path / "app/target" + cp = os.pathsep.join([str(target / "test-classes"), str(target / "classes"), + (target / "runtime.txt").read_text().strip()]) + command = ["-cp", cp, "fixture.checks.PreviewChecks"] + else: + target = path / "checks/target" + cp = str(target / "checks-modulepath-1.jar") + os.pathsep + (target / "runtime.txt").read_text().strip() + command = ["--module-path", cp, "--add-modules", "ALL-MODULE-PATH", + "-m", "fixture.checks/fixture.checks.PreviewChecks"] + log = run(label, [java_home / "bin/java", *command, mode], timeout=30) + match = re.search(r"PASS " + re.escape(mode) + r" (\d+)", log) + assert match, log + results.append({"scenario": label, "checks": int(match[1])}) + print(label + ": " + match[0], flush=True) + return log + + +with tempfile.TemporaryDirectory(prefix="designer-preview-") as temp: + for arrangement in ("classpath", "modulepath"): + path = Path(temp) / arrangement + shutil.copytree(ROOT / "integration-fixtures/preview" / arrangement, path, + ignore=shutil.ignore_patterns("target")) + build(path, arrangement + "-build") + first = None + for mode in ("discovery", "standard", "async", "timeout", "cancel", + "readiness-failure", "wrong-model", "invalid-attribute", + "cleanup-failure", "factory-failure"): + log = launch(path, arrangement, mode, arrangement + "-" + mode) + if mode == "standard": + first = re.search(r"SESSION (\S+) models=1", log)[1] + repeat = launch(path, arrangement, "standard", arrangement + "-fresh-process") + assert first != re.search(r"SESSION (\S+) models=1", repeat)[1], "Service identity reused" + if arrangement == "modulepath": + descriptor = path / "app/src/main/java/module-info.java" + original = descriptor.read_text() + for mode, declaration in (("missing-components", "opens fixture.components;"), + ("missing-model", "opens fixture.model to fx;")): + descriptor.write_text(re.sub(r"\s+".join(map(re.escape, declaration.split())), "", original)) + build(path, mode + "-build") + launch(path, arrangement, mode, mode) + descriptor.write_text(original) +(output / "results.json").write_text(json.dumps(results, indent=2) + "\n") +print(f"PASS {len(results)} target processes, {sum(r['checks'] for r in results)} fixture checks")