diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8fc2ae..4f520ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,9 +95,9 @@ jobs: - name: Build release archives env: RELEASE_VERSION: ${{ github.ref_name }} - run: ./gradlew distZip distTar -PreleaseVersion="${RELEASE_VERSION#v}" --no-daemon --stacktrace --console=plain + run: ./gradlew distChecksums -PreleaseVersion="${RELEASE_VERSION#v}" --no-daemon --stacktrace --console=plain - name: Publish release env: GH_TOKEN: ${{ github.token }} - run: gh release create "$GITHUB_REF_NAME" build/distributions/*.zip build/distributions/*.tar.gz --verify-tag --title "$GITHUB_REF_NAME" --notes "" + run: gh release create "$GITHUB_REF_NAME" build/distributions/*.zip build/distributions/*.tar.gz build/distributions/*.sha256 --verify-tag --title "$GITHUB_REF_NAME" --notes "" diff --git a/.gitignore b/.gitignore index 4fbc1c6..f690eb2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ build/ output/ logs/ +config/ +saves/ runtime-classpath.txt *.class .idea/ diff --git a/README.md b/README.md index f12deb8..4eb1ad6 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,43 @@ public final class MyMachinePreview implements PreviewEntrypoint { `previewedClass()` records the production GUI class in `bounds.json`. This makes it possible to verify that the screenshot came from production code rather than a lookalike adapter. -The Galaxia Oxygen Filler under `integrations/galaxia-oxygen-filler` demonstrates this integration shape. It requires a compiled Galaxia checkout and its production dependencies in `runtime-classpath.txt`. +For a larger production integration, keep the preview catalog in the target repository, for example under `tools/gui-preview`. The previewer builds the required production classes and discovers their runtime classpath automatically. + +### Production GUI catalog + +Implement `PreviewCatalog` when one project needs several production GUI states. Each `PreviewScenario` names the production class, creates only the local client-visible state needed by that GUI, and may attach tags, expected assets, or an action script: + +```java +public final class MyModPreviews implements PreviewCatalog { + + @Override + public List scenarios() { + return List.of(PreviewScenario.define( + "machine/default", + "machine screen with representative local state", + "machine", + MyMachineGui.class, + MyMachinePreview::new) + .tags("default", "interaction") + .actions("actions/machine.txt")); + } +} +``` + +Set `preview.entrypoint` in `preview.properties` to the catalog class. Scenario IDs use `family/name`. The `default` tag selects the canonical verification state. Every production class in the catalog must have exactly one `default` scenario. + +```bat +preview.bat list project-directory +preview.bat doctor project-directory +preview.bat open project-directory machine/default +preview.bat render project-directory machine/default +preview.bat verify project-directory +preview.bat verify project-directory machine +preview.bat verify project-directory --full +preview.bat verify project-directory --failed +``` + +`verify` runs default scenarios in isolated workers. `--full` also runs non-default states and their action scripts. `--failed` reruns failures from the previous report. Use `list` to discover IDs and `doctor` to check the production classpath, assets, and catalog before rendering. ## Additional project inputs diff --git a/build.gradle.kts b/build.gradle.kts index 50ecda7..520b629 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,6 @@ +import java.security.MessageDigest +import java.util.HexFormat + plugins { java application @@ -44,13 +47,42 @@ dependencies { } val distributionZip = tasks.named("distZip") +val distributionTar = tasks.named("distTar") + +val distributionChecksums = tasks.register("distChecksums") { + group = "distribution" + description = "Writes SHA-256 files for the portable release archives." + dependsOn(distributionZip, distributionTar) + + doLast { + listOf(distributionZip.get().archiveFile.get().asFile, distributionTar.get().archiveFile.get().asFile) + .forEach { archive -> + val digest = MessageDigest.getInstance("SHA-256") + archive.inputStream().use { input -> + val buffer = ByteArray(8192) + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + val checksum = HexFormat.of().formatHex(digest.digest()) + file(archive.parentFile.resolve(archive.name + ".sha256")) + .writeText("$checksum ${archive.name}\n") + } + } +} tasks.test { useJUnitPlatform() - dependsOn(distributionZip) + dependsOn(distributionChecksums) doFirst { systemProperty("preview.distribution.zip", distributionZip.get().archiveFile.get().asFile) + systemProperty( + "preview.distribution.zip.checksum", + distributionZip.get().archiveFile.get().asFile.parentFile.resolve( + distributionZip.get().archiveFile.get().asFile.name + ".sha256")) systemProperty( "modularui.test.jar", bundledRuntime.single { it.name.startsWith("ModularUI2-") }) @@ -80,12 +112,13 @@ distributions { from("THIRD_PARTY_NOTICES.md") from("examples") { into("examples") + exclude("**/build/**", "**/output/**", "**/logs/**", "**/runtime-classpath.txt") } } } } -tasks.named("distTar") { +distributionTar { compression = Compression.GZIP archiveExtension.set("tar.gz") } diff --git a/integrations/galaxia-oxygen-filler/preview.properties b/examples/catalog-demo/preview.properties similarity index 62% rename from integrations/galaxia-oxygen-filler/preview.properties rename to examples/catalog-demo/preview.properties index 3662bad..ca73684 100644 --- a/integrations/galaxia-oxygen-filler/preview.properties +++ b/examples/catalog-demo/preview.properties @@ -1,4 +1,4 @@ -preview.entrypoint=example.OxygenFillerPreview +preview.entrypoint=example.CatalogDemo screen.width=1920 screen.height=1080 gui.scale=auto diff --git a/examples/catalog-demo/src/preview/java/example/CatalogDemo.java b/examples/catalog-demo/src/preview/java/example/CatalogDemo.java new file mode 100644 index 0000000..d2a0716 --- /dev/null +++ b/examples/catalog-demo/src/preview/java/example/CatalogDemo.java @@ -0,0 +1,40 @@ +package example; + +import com.cleanroommc.modularui.screen.ModularPanel; +import com.cleanroommc.modularui.widgets.ButtonWidget; +import com.cleanroommc.modularui.widgets.TextWidget; +import dev.modularui.preview.PreviewCatalog; +import dev.modularui.preview.PreviewEntrypoint; +import dev.modularui.preview.PreviewScenario; +import java.util.List; + +public final class CatalogDemo implements PreviewCatalog { + + @Override + public List scenarios() { + return List.of(PreviewScenario.define( + "demo/default", + "clickable catalog example", + "demo", + CatalogDemo.class, + DemoEntrypoint::new).tags("default", "interaction")); + } + + private static final class DemoEntrypoint implements PreviewEntrypoint { + + @Override + public Class previewedClass() { + return CatalogDemo.class; + } + + @Override + public Object createPanel(Context context) { + return ModularPanel.defaultPanel("catalog_demo", 176, 90) + .child(new ButtonWidget<>() + .pos(58, 32) + .size(60, 24) + .onMousePressed(button -> true) + .child(new TextWidget<>("Click me").coverChildren())); + } + } +} diff --git a/integrations/galaxia-oxygen-filler/runtime-classpath.example.txt b/integrations/galaxia-oxygen-filler/runtime-classpath.example.txt deleted file mode 100644 index fa484e3..0000000 --- a/integrations/galaxia-oxygen-filler/runtime-classpath.example.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Generate runtime-classpath.txt from the Galaxia compile/runtime classpath. -# It must include, at minimum: -# C:\path\to\Galaxia\build\classes\java\main -# C:\path\to\Galaxia\build\resources\main -# C:\path\to\each production dependency required by OxygenFillerGUI diff --git a/integrations/galaxia-oxygen-filler/src/preview/java/example/OxygenFillerPreview.java b/integrations/galaxia-oxygen-filler/src/preview/java/example/OxygenFillerPreview.java deleted file mode 100644 index 312af5d..0000000 --- a/integrations/galaxia-oxygen-filler/src/preview/java/example/OxygenFillerPreview.java +++ /dev/null @@ -1,33 +0,0 @@ -package example; - -import com.cleanroommc.modularui.value.sync.PanelSyncManager; -import com.gtnewhorizons.galaxia.core.config.ConfigMachines; -import com.gtnewhorizons.galaxia.registry.block.tile.machine.TileEntityOxygenFiller; -import com.gtnewhorizons.galaxia.registry.block.tile.machine.gui.OxygenFillerGUI; - -import dev.modularui.preview.PreviewEntrypoint; - -public final class OxygenFillerPreview implements PreviewEntrypoint { - - @Override - public String owner() { - return "galaxia"; - } - - @Override - public Class previewedClass() { - return OxygenFillerGUI.class; - } - - @Override - public Object createPanel(Context context) { - ConfigMachines.filler.maxEnergyBuffer = 2_000; - ConfigMachines.filler.maxOxygenBuffer = 10_000; - - TileEntityOxygenFiller tile = new TileEntityOxygenFiller(); - tile.storedEnergy = 1_250; - tile.active = true; - - return OxygenFillerGUI.build(tile, null, (PanelSyncManager) context.panelSyncManager()); - } -} diff --git a/integrations/galaxia-starmap/actions.txt b/integrations/galaxia-starmap/actions.txt deleted file mode 100644 index 6787323..0000000 --- a/integrations/galaxia-starmap/actions.txt +++ /dev/null @@ -1,4 +0,0 @@ -capture orbital-overview -move 28 235 -click left -capture mars-expanded diff --git a/integrations/galaxia-starmap/preview.properties b/integrations/galaxia-starmap/preview.properties deleted file mode 100644 index 417c706..0000000 --- a/integrations/galaxia-starmap/preview.properties +++ /dev/null @@ -1,5 +0,0 @@ -preview.entrypoint=example.GalaxiaStarmapPreview -screen.width=1920 -screen.height=1080 -gui.scale=2 -screen.background=#000000 diff --git a/integrations/galaxia-starmap/runtime-classpath.example.txt b/integrations/galaxia-starmap/runtime-classpath.example.txt deleted file mode 100644 index 727d395..0000000 --- a/integrations/galaxia-starmap/runtime-classpath.example.txt +++ /dev/null @@ -1,3 +0,0 @@ -# Generate runtime-classpath.txt from Galaxia's runtime classpath. -# Include Galaxia's compiled classes and resources, but omit ModularUI and ModularUI2; -# the previewer supplies the versions it was built and verified against. diff --git a/integrations/galaxia-starmap/src/preview/java/example/GalaxiaStarmapPreview.java b/integrations/galaxia-starmap/src/preview/java/example/GalaxiaStarmapPreview.java deleted file mode 100644 index acae1c6..0000000 --- a/integrations/galaxia-starmap/src/preview/java/example/GalaxiaStarmapPreview.java +++ /dev/null @@ -1,81 +0,0 @@ -package example; - -import com.cleanroommc.modularui.value.sync.PanelSyncManager; -import com.gtnewhorizons.galaxia.client.gui.orbitalGUI.GalacticChartGui; -import com.gtnewhorizons.galaxia.registry.celestial.CelestialRegistry; -import com.gtnewhorizons.galaxia.registry.celestial.asteroid.AsteroidFieldOrbitResolver; -import com.gtnewhorizons.galaxia.registry.orbital.OrbitalMechanics; - -import dev.modularui.preview.PreviewEntrypoint; -import net.minecraft.block.Block; -import net.minecraft.client.Minecraft; -import net.minecraft.client.entity.EntityClientPlayerMP; - -import gregtech.api.enums.Materials; -import net.minecraftforge.fluids.Fluid; -import net.minecraftforge.fluids.FluidRegistry; - -/** Runs Galaxia's production Starmap builder against representative local client state. */ -public final class GalaxiaStarmapPreview implements PreviewEntrypoint { - - @Override - public String owner() { - return "galaxia"; - } - - @Override - public Class previewedClass() { - return GalacticChartGui.class; - } - - @Override - public Object createPanel(Context context) { - initializeFmlSide(); - setVanillaBootstrap(true); - try { - if (Block.blockRegistry.getObject("water") == null) { - Block.registerBlocks(); - } - Class.forName("net.minecraft.init.Blocks", true, getClass().getClassLoader()); - } catch (ClassNotFoundException e) { - throw new IllegalStateException("Failed to initialize vanilla block constants", e); - } finally { - setVanillaBootstrap(false); - } - if (Materials.Air.getGas(1L) == null) { - Fluid air = new Fluid("air"); - FluidRegistry.registerFluid(air); - Materials.Air.mGas = air; - } - OrbitalMechanics.registerMinorBodyResolver(AsteroidFieldOrbitResolver.INSTANCE); - CelestialRegistry.freezeAndBake(); - - EntityClientPlayerMP player = Minecraft.getMinecraft().thePlayer; - player.dimension = 0; - - return new GalacticChartGui().build((PanelSyncManager) context.panelSyncManager(), player); - } - - private static void setVanillaBootstrap(boolean active) { - try { - Class loader = Class.forName("cpw.mods.fml.common.Loader"); - loader.getMethod(active ? "beginVanillaBootstrap" : "endVanillaBootstrap").invoke(null); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Preview Loader does not support vanilla registry bootstrapping", e); - } - } - - private static void initializeFmlSide() { - try { - Class log = Class.forName("cpw.mods.fml.relauncher.FMLRelaunchLog"); - java.lang.reflect.Field field = log.getDeclaredField("side"); - field.setAccessible(true); - if (field.get(null) == null) { - field.set(null, cpw.mods.fml.relauncher.Side.CLIENT); - } - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Failed to initialize the local FML side", e); - } - } - -} diff --git a/preview.sh b/preview.sh index cea1b73..ed3232d 100644 --- a/preview.sh +++ b/preview.sh @@ -48,4 +48,4 @@ if [ ! -f "$preview_launcher" ]; then fi fi -exec "$preview_launcher" "$@" +exec sh "$preview_launcher" "$@" diff --git a/src/main/java/com/cleanroommc/modularui/api/drawable/IKey.java b/src/main/java/com/cleanroommc/modularui/api/drawable/IKey.java index 2c46c74..efe4eef 100644 --- a/src/main/java/com/cleanroommc/modularui/api/drawable/IKey.java +++ b/src/main/java/com/cleanroommc/modularui/api/drawable/IKey.java @@ -1,13 +1,11 @@ package com.cleanroommc.modularui.api.drawable; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; import java.util.Locale; -import java.util.Properties; import com.cleanroommc.modularui.widgets.TextWidget; +import net.minecraft.util.StatCollector; + public interface IKey { static IKey str(String text) { @@ -47,8 +45,6 @@ public String get() { final class LangKey implements IKey { - private static final Properties TRANSLATIONS = loadTranslations(); - private final String key; private final Object[] arguments; @@ -59,20 +55,8 @@ private LangKey(String key, Object[] arguments) { @Override public String get() { - String translated = TRANSLATIONS.getProperty(key, key); + String translated = StatCollector.translateToLocal(key); return arguments.length == 0 ? translated : String.format(Locale.ROOT, translated, arguments); } - - private static Properties loadTranslations() { - Properties translations = new Properties(); - try (var stream = IKey.class.getResourceAsStream("/assets/galaxia/lang/en_US.lang")) { - if (stream != null) { - translations.load(new InputStreamReader(stream, StandardCharsets.UTF_8)); - } - } catch (IOException exception) { - throw new IllegalStateException("Could not load preview translations", exception); - } - return translations; - } } } diff --git a/src/main/java/dev/modularui/preview/PreviewActionRunner.java b/src/main/java/dev/modularui/preview/PreviewActionRunner.java index b4966b6..1a83261 100644 --- a/src/main/java/dev/modularui/preview/PreviewActionRunner.java +++ b/src/main/java/dev/modularui/preview/PreviewActionRunner.java @@ -22,17 +22,34 @@ public final class PreviewActionRunner { public List run(Path projectRoot, String className, Path actionsFile, Path outputDirectory, PreviewScreen screen) throws IOException { + return run(projectRoot, className, null, actionsFile, outputDirectory, screen); + } + + public List run(Path projectRoot, String className, String scenarioId, Path actionsFile, + Path outputDirectory, PreviewScreen screen) throws IOException { + List actions = parse(actionsFile); + Files.createDirectories(outputDirectory); + try (PreviewSession session = PreviewEngine.open(projectRoot, className, scenarioId, screen)) { + return run(session, className, actionsFile, outputDirectory, actions); + } + } + + public List run(PreviewSession session, String className, Path actionsFile, Path outputDirectory) + throws IOException { List actions = parse(actionsFile); + return run(session, className, actionsFile, outputDirectory, actions); + } + + private List run(PreviewSession session, String className, Path actionsFile, Path outputDirectory, + List actions) throws IOException { List results = new ArrayList<>(); Set captureNames = new HashSet<>(); Cursor cursor = new Cursor(); UiPreviewRunner artifacts = new UiPreviewRunner(); Files.createDirectories(outputDirectory); - try (PreviewSession session = PreviewEngine.open(projectRoot, className, screen)) { - for (ScriptAction action : actions) { - execute(action, actionsFile, outputDirectory, className, session, artifacts, results, captureNames, - cursor); - } + for (ScriptAction action : actions) { + execute(action, actionsFile, outputDirectory, className, session, artifacts, results, captureNames, + cursor); } writeResults(outputDirectory.resolve("actions.json"), results); return List.copyOf(results); diff --git a/src/main/java/dev/modularui/preview/PreviewCatalog.java b/src/main/java/dev/modularui/preview/PreviewCatalog.java new file mode 100644 index 0000000..1199b3a --- /dev/null +++ b/src/main/java/dev/modularui/preview/PreviewCatalog.java @@ -0,0 +1,36 @@ +package dev.modularui.preview; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** A discoverable set of named preview scenarios. */ +@FunctionalInterface +public interface PreviewCatalog { + + List scenarios(); + + default List validatedScenarios() { + List scenarios = List.copyOf(scenarios()); + Set ids = new HashSet<>(); + for (PreviewScenario scenario : scenarios) { + if (scenario == null) throw new IllegalArgumentException("Preview catalog contains a null scenario"); + if (!ids.add(scenario.id())) { + throw new IllegalArgumentException("Duplicate preview scenario ID: " + scenario.id()); + } + } + return scenarios.stream() + .sorted(java.util.Comparator.comparing(PreviewScenario::id)) + .toList(); + } + + default PreviewScenario requireScenario(String id) { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("A preview scenario is required. Run 'preview list '."); + } + return validatedScenarios().stream() + .filter(scenario -> scenario.id().equals(id)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown preview scenario: " + id)); + } +} diff --git a/src/main/java/dev/modularui/preview/PreviewCommand.java b/src/main/java/dev/modularui/preview/PreviewCommand.java index dd1c016..143cbd1 100644 --- a/src/main/java/dev/modularui/preview/PreviewCommand.java +++ b/src/main/java/dev/modularui/preview/PreviewCommand.java @@ -1,62 +1,87 @@ package dev.modularui.preview; import java.nio.file.Path; +import java.time.Duration; import java.util.HashMap; +import java.util.HashSet; import java.util.Locale; import java.util.Map; +import java.util.Set; record PreviewCommand( Mode mode, Path projectRoot, + String scenarioId, String className, Path outputDirectory, Path configuration, - Path actions) { + Path actions, + VerifyOptions verification) { private static final String USAGE = """ Usage: preview init - preview render [--class ] [--output ] [--config ] [--actions ] - preview open [--class ] [--config ] - preview watch [--class ] [--output ] [--config ] + preview list [--class ] + preview render [scenario] [--class ] [--output ] [--config ] [--actions ] + preview open [scenario] [--class ] [--config ] + preview watch [scenario] [--class ] [--output ] [--config ] + preview verify [family-or-scenario] [--full] [--failed] [--output ] [--jobs ] + preview doctor [--class ] preview help """; enum Mode { INIT, + LIST, RENDER, OPEN, WATCH, + VERIFY, + DOCTOR, HELP } static PreviewCommand parse(String[] arguments) { if (arguments.length == 1 && (arguments[0].equals("help") || arguments[0].equals("--help"))) { - return new PreviewCommand(Mode.HELP, null, null, null, null, null); + return command(Mode.HELP, null, null, Map.of(), Set.of()); } if (arguments.length < 2) throw new IllegalArgumentException("Missing preview command or project directory"); Mode mode = parseMode(arguments[0]); - Path projectRoot = Path.of(arguments[1]) - .toAbsolutePath(); + Path projectRoot = Path.of(arguments[1]).toAbsolutePath(); if (mode == Mode.INIT) { if (arguments.length != 2) throw new IllegalArgumentException("init does not accept options"); - return new PreviewCommand(mode, projectRoot, null, null, null, null); + return command(mode, projectRoot, null, Map.of(), Set.of()); } - Map options = parseOptions(arguments); + int optionStart = 2; + String scenarioId = null; + if (optionStart < arguments.length && !arguments[optionStart].startsWith("--")) { + scenarioId = arguments[optionStart++]; + } + if ((mode == Mode.LIST || mode == Mode.DOCTOR) && scenarioId != null) { + throw new IllegalArgumentException(mode.name().toLowerCase(Locale.ROOT) + " does not accept a scenario"); + } + ParsedOptions options = parseOptions(arguments, optionStart); rejectUnsupportedOptions(mode, options); + return command(mode, projectRoot, scenarioId, options.values(), options.flags()); + } + + static String usage() { + return USAGE; + } + + private static PreviewCommand command(Mode mode, Path projectRoot, String scenarioId, Map options, + Set flags) { return new PreviewCommand( mode, projectRoot, + scenarioId, options.get("--class"), pathOption(options, "--output"), pathOption(options, "--config"), - pathOption(options, "--actions")); - } - - static String usage() { - return USAGE; + pathOption(options, "--actions"), + VerifyOptions.from(options, flags)); } private static Mode parseMode(String value) { @@ -69,33 +94,74 @@ private static Mode parseMode(String value) { } } - private static Map parseOptions(String[] arguments) { - Map options = new HashMap<>(); - for (int index = 2; index < arguments.length; index += 2) { - String name = arguments[index]; + private static ParsedOptions parseOptions(String[] arguments, int start) { + Map values = new HashMap<>(); + Set flags = new HashSet<>(); + for (int index = start; index < arguments.length;) { + String name = arguments[index++]; if (!name.startsWith("--")) throw new IllegalArgumentException("Unexpected argument: " + name); - if (index + 1 >= arguments.length) throw new IllegalArgumentException("Missing value for option: " + name); - if (options.put(name, arguments[index + 1]) != null) { + if (name.equals("--full") || name.equals("--failed")) { + if (!flags.add(name)) throw new IllegalArgumentException("Duplicate option: " + name); + continue; + } + if (index >= arguments.length) throw new IllegalArgumentException("Missing value for option: " + name); + if (values.put(name, arguments[index++]) != null) { throw new IllegalArgumentException("Duplicate option: " + name); } } - return options; + return new ParsedOptions(Map.copyOf(values), Set.copyOf(flags)); } - private static void rejectUnsupportedOptions(Mode mode, Map options) { - for (String option : options.keySet()) { + private static void rejectUnsupportedOptions(Mode mode, ParsedOptions options) { + for (String option : options.values().keySet()) { boolean supported = switch (option) { - case "--class", "--config" -> true; - case "--output" -> mode == Mode.RENDER || mode == Mode.WATCH; + case "--class" -> true; + case "--config" -> mode == Mode.RENDER || mode == Mode.OPEN || mode == Mode.WATCH + || mode == Mode.VERIFY; + case "--output" -> mode == Mode.RENDER || mode == Mode.WATCH || mode == Mode.VERIFY; case "--actions" -> mode == Mode.RENDER; + case "--jobs", "--timeout-default", "--timeout-extended" -> mode == Mode.VERIFY; default -> false; }; - if (!supported) throw new IllegalArgumentException("Unsupported option for " + mode.name().toLowerCase(Locale.ROOT) + ": " + option); + if (!supported) throw unsupported(mode, option); + } + if (mode != Mode.VERIFY && !options.flags().isEmpty()) { + throw unsupported(mode, options.flags().iterator().next()); } } + private static IllegalArgumentException unsupported(Mode mode, String option) { + return new IllegalArgumentException( + "Unsupported option for " + mode.name().toLowerCase(Locale.ROOT) + ": " + option); + } + private static Path pathOption(Map options, String name) { String value = options.get(name); return value == null ? null : Path.of(value).toAbsolutePath(); } + + record VerifyOptions(boolean full, boolean failedOnly, int jobs, Duration defaultTimeout, Duration extendedTimeout) { + + private static VerifyOptions from(Map options, Set flags) { + int defaultJobs = Math.max(1, Math.min(4, Runtime.getRuntime().availableProcessors())); + return new VerifyOptions( + flags.contains("--full"), + flags.contains("--failed"), + positiveInteger(options, "--jobs", defaultJobs), + Duration.ofSeconds(positiveInteger(options, "--timeout-default", 30)), + Duration.ofSeconds(positiveInteger(options, "--timeout-extended", 120))); + } + + private static int positiveInteger(Map options, String name, int defaultValue) { + String value = options.get(name); + if (value == null) return defaultValue; + try { + int parsed = Integer.parseInt(value); + if (parsed > 0) return parsed; + } catch (NumberFormatException ignored) {} + throw new IllegalArgumentException(name + " must be a positive integer"); + } + } + + private record ParsedOptions(Map values, Set flags) {} } diff --git a/src/main/java/dev/modularui/preview/PreviewDrawContext.java b/src/main/java/dev/modularui/preview/PreviewDrawContext.java index 97e340c..d5d0708 100644 --- a/src/main/java/dev/modularui/preview/PreviewDrawContext.java +++ b/src/main/java/dev/modularui/preview/PreviewDrawContext.java @@ -4,15 +4,19 @@ import java.awt.BasicStroke; import java.awt.Color; import java.awt.Graphics2D; +import java.awt.Paint; import java.awt.Polygon; import java.awt.RenderingHints; import java.awt.Shape; +import java.awt.TexturePaint; import java.awt.image.BufferedImage; import java.awt.geom.AffineTransform; import java.awt.geom.Area; import java.awt.geom.Line2D; +import java.awt.geom.NoninvertibleTransformException; import java.awt.geom.Path2D; import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.FloatBuffer; @@ -35,9 +39,18 @@ public static void run(Graphics2D graphics, Runnable drawable) { run(graphics, null, drawable); } + public static void run(Graphics2D graphics, int framebufferHeight, Runnable drawable) { + run(graphics, null, framebufferHeight, drawable); + } + public static List run(Graphics2D graphics, AssetResolver assets, Runnable drawable) { + return run(graphics, assets, -1, drawable); + } + + public static List run( + Graphics2D graphics, AssetResolver assets, int framebufferHeight, Runnable drawable) { State previous = CURRENT.get(); - State state = new State(graphics, assets); + State state = new State(graphics, assets, framebufferHeight); CURRENT.set(state); try { drawable.run(); @@ -123,6 +136,10 @@ public static void enable(int capability) { State state = requireState(); if (capability == org.lwjgl.opengl.GL11.GL_STENCIL_TEST) state.stencilEnabled = true; if (capability == org.lwjgl.opengl.GL11.GL_LINE_SMOOTH) state.smoothLines = true; + if (capability == org.lwjgl.opengl.GL11.GL_SCISSOR_TEST) { + state.scissorEnabled = true; + applyClip(state); + } } public static void disable(int capability) { @@ -131,10 +148,34 @@ public static void disable(int capability) { state.smoothLines = false; return; } - if (capability != org.lwjgl.opengl.GL11.GL_STENCIL_TEST) return; - state.stencilEnabled = false; - state.stencilClips.clear(); - state.graphics.setClip(state.originalClip); + if (capability == org.lwjgl.opengl.GL11.GL_SCISSOR_TEST) { + state.scissorEnabled = false; + applyClip(state); + return; + } + if (capability == org.lwjgl.opengl.GL11.GL_STENCIL_TEST) { + state.stencilEnabled = false; + state.stencilClips.clear(); + applyClip(state); + } + } + + public static void scissor(int x, int y, int width, int height) { + State state = requireState(); + if (state.framebufferHeight < 0) { + throw new IllegalStateException("Framebuffer height is required for OpenGL scissor clipping"); + } + Shape framebufferClip = new Rectangle2D.Double( + x, + state.framebufferHeight - y - height, + Math.max(0, width), + Math.max(0, height)); + try { + state.scissorClip = state.graphics.getTransform().createInverse().createTransformedShape(framebufferClip); + } catch (NoninvertibleTransformException exception) { + throw new IllegalStateException("Could not map OpenGL scissor bounds to GUI coordinates", exception); + } + if (state.scissorEnabled) applyClip(state); } public static void colorMask(boolean red, boolean green, boolean blue, boolean alpha) { @@ -189,6 +230,8 @@ public static void bindTexture(ResourceLocation location) { BufferedImage image = ImageIO.read(new ByteArrayInputStream(asset.bytes())); if (image == null) throw new IllegalArgumentException("Unsupported preview texture: " + asset.source()); state.texture = image; + state.repeatTextureX = false; + state.repeatTextureY = false; state.assetSources.add(asset.source()); } catch (IOException exception) { throw new IllegalStateException("Could not decode preview texture: " + asset.source(), exception); @@ -247,6 +290,17 @@ public static void getMatrix(FloatBuffer target) { target.put(matrix); } + public static void textureParameter(int target, int parameter, int value) { + if (target != org.lwjgl.opengl.GL11.GL_TEXTURE_2D) return; + State state = requireState(); + if (parameter == org.lwjgl.opengl.GL11.GL_TEXTURE_WRAP_S) { + state.repeatTextureX = value == org.lwjgl.opengl.GL11.GL_REPEAT; + } + if (parameter == org.lwjgl.opengl.GL11.GL_TEXTURE_WRAP_T) { + state.repeatTextureY = value == org.lwjgl.opengl.GL11.GL_REPEAT; + } + } + private static Graphics2D requireGraphics() { return requireState().graphics; } @@ -327,8 +381,20 @@ private static void finishStencilCapture(State state) { state.stencilClips.push(clip); } state.capturedStencil = null; - if (state.stencilClips.isEmpty()) state.graphics.setClip(state.originalClip); - else state.graphics.setClip(state.stencilClips.peek()); + applyClip(state); + } + + private static void applyClip(State state) { + Area clip = state.originalClip == null ? null : new Area(state.originalClip); + if (state.scissorEnabled && state.scissorClip != null) clip = intersect(clip, state.scissorClip); + if (!state.stencilClips.isEmpty()) clip = intersect(clip, state.stencilClips.peek()); + state.graphics.setClip(clip); + } + + private static Area intersect(Area current, Shape next) { + if (current == null) return new Area(next); + current.intersect(new Area(next)); + return current; } private static void drawLine(double[] positions, int first, int second) { @@ -372,6 +438,20 @@ private static void drawTexturedQuad(double[] positions, double[] textureCoordin maxU = Math.max(maxU, textureCoordinates[index * 2]); maxV = Math.max(maxV, textureCoordinates[index * 2 + 1]); } + if (state.repeatTextureX && state.repeatTextureY && maxU > minU && maxV > minV) { + double tileWidth = (maxX - minX) / (maxU - minU); + double tileHeight = (maxY - minY) / (maxV - minV); + Rectangle2D anchor = new Rectangle2D.Double( + minX - minU * tileWidth, + minY - minV * tileHeight, + tileWidth, + tileHeight); + Paint previousPaint = state.graphics.getPaint(); + state.graphics.setPaint(new TexturePaint(state.texture, anchor)); + state.graphics.fill(new Rectangle2D.Double(minX, minY, maxX - minX, maxY - minY)); + state.graphics.setPaint(previousPaint); + return; + } int sourceX0 = clamp((int) Math.floor(minU * state.texture.getWidth()), 0, state.texture.getWidth()); int sourceY0 = clamp((int) Math.floor(minV * state.texture.getHeight()), 0, state.texture.getHeight()); int sourceX1 = clamp((int) Math.ceil(maxU * state.texture.getWidth()), 0, state.texture.getWidth()); @@ -413,6 +493,7 @@ private static final class State { private final Graphics2D graphics; private final AssetResolver assets; + private final int framebufferHeight; private final Shape originalClip; private final Deque matrices = new ArrayDeque<>(); private final Deque stencilClips = new ArrayDeque<>(); @@ -423,14 +504,19 @@ private static final class State { private int immediateMode = -1; private final List immediateVertices = new ArrayList<>(); private boolean stencilEnabled; + private boolean scissorEnabled; + private boolean repeatTextureX; + private boolean repeatTextureY; private boolean smoothLines; private float lineWidth = 1F; private int stencilDepthPass = org.lwjgl.opengl.GL11.GL_KEEP; private Area capturedStencil; + private Shape scissorClip; - private State(Graphics2D graphics, AssetResolver assets) { + private State(Graphics2D graphics, AssetResolver assets, int framebufferHeight) { this.graphics = graphics; this.assets = assets; + this.framebufferHeight = framebufferHeight; this.originalClip = graphics.getClip(); } } diff --git a/src/main/java/dev/modularui/preview/PreviewEngine.java b/src/main/java/dev/modularui/preview/PreviewEngine.java index eb1d06e..f111bb1 100644 --- a/src/main/java/dev/modularui/preview/PreviewEngine.java +++ b/src/main/java/dev/modularui/preview/PreviewEngine.java @@ -15,21 +15,49 @@ public static Preflight preflight(Path projectRoot, String entrypoint) { } public static PreviewSession open(Path projectRoot, String entrypoint, PreviewScreen screen) { + return open(projectRoot, entrypoint, null, screen); + } + + public static PreviewSession open(Path projectRoot, String entrypoint, String scenarioId, PreviewScreen screen) { PreviewProject project = PreviewProject.open(projectRoot); - return open(project, entrypoint, screen); + return open(project, entrypoint, scenarioId, screen); } static PreviewSession open(Path projectRoot, String entrypoint, PreviewScreen screen, Path compiledOutput) { - return open(PreviewProject.open(projectRoot, compiledOutput), entrypoint, screen); + return open(projectRoot, entrypoint, null, screen, compiledOutput); + } + + static PreviewSession open(Path projectRoot, String entrypoint, String scenarioId, PreviewScreen screen, + Path compiledOutput) { + return open(PreviewProject.open(projectRoot, compiledOutput), entrypoint, scenarioId, screen); + } + + static PreviewSession openPrepared(Path projectRoot, String entrypoint, String scenarioId, PreviewScreen screen, + Path compiledOutput) { + PreviewProject project = PreviewProject.open(projectRoot, compiledOutput); + requireValidProject(project, entrypoint); + return ProjectRuntime.openSession(project, entrypoint, scenarioId, screen); } - private static PreviewSession open(PreviewProject project, String entrypoint, PreviewScreen screen) { + public static List scenarios(Path projectRoot, String entrypoint) { + PreviewProject project = PreviewProject.open(projectRoot); + project.compileSources(); + requireValidProject(project, entrypoint); + return ProjectRuntime.listScenarios(project, entrypoint); + } + + private static PreviewSession open(PreviewProject project, String entrypoint, String scenarioId, + PreviewScreen screen) { project.compileSources(); + requireValidProject(project, entrypoint); + return ProjectRuntime.openSession(project, entrypoint, scenarioId, screen); + } + + private static void requireValidProject(PreviewProject project, String entrypoint) { Preflight preflight = ProjectPreflight.inspect(project, entrypoint); if (preflight.status() == Status.FAILED) { throw new IllegalArgumentException("Preview project preflight failed: " + preflight.diagnostics()); } - return ProjectRuntime.openSession(project, entrypoint, screen); } public enum Status { diff --git a/src/main/java/dev/modularui/preview/PreviewEntrypoint.java b/src/main/java/dev/modularui/preview/PreviewEntrypoint.java index e503a5d..30a5c9c 100644 --- a/src/main/java/dev/modularui/preview/PreviewEntrypoint.java +++ b/src/main/java/dev/modularui/preview/PreviewEntrypoint.java @@ -1,9 +1,29 @@ package dev.modularui.preview; +import java.util.Objects; +import java.util.function.Function; + /** Builds the production ModularUI2 panel for a representative preview state. */ @FunctionalInterface public interface PreviewEntrypoint { + static PreviewEntrypoint of(Class previewedClass, Function panelFactory) { + Objects.requireNonNull(previewedClass, "previewedClass"); + Objects.requireNonNull(panelFactory, "panelFactory"); + return new PreviewEntrypoint() { + + @Override + public Class previewedClass() { + return previewedClass; + } + + @Override + public Object createPanel(Context context) { + return panelFactory.apply(context); + } + }; + } + default String owner() { return "preview"; } diff --git a/src/main/java/dev/modularui/preview/PreviewEnvironment.java b/src/main/java/dev/modularui/preview/PreviewEnvironment.java new file mode 100644 index 0000000..c67d5df --- /dev/null +++ b/src/main/java/dev/modularui/preview/PreviewEnvironment.java @@ -0,0 +1,41 @@ +package dev.modularui.preview; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; + +final class PreviewEnvironment { + + private PreviewEnvironment() {} + + static String version() { + String configured = System.getProperty("preview.version"); + if (configured != null && !configured.isBlank()) return configured; + String packaged = UiPreviewMain.class.getPackage().getImplementationVersion(); + return packaged == null ? "development" : packaged; + } + + static String javaVersion() { + return System.getProperty("java.version", "unknown"); + } + + static String projectCommit(Path projectRoot) { + Process process = null; + try { + process = new ProcessBuilder("git", "-C", projectRoot.toString(), "rev-parse", "HEAD") + .redirectErrorStream(true) + .start(); + if (!process.waitFor(3, TimeUnit.SECONDS) || process.exitValue() != 0) return "unknown"; + String value = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + return value.isBlank() ? "unknown" : value; + } catch (IOException exception) { + return "unknown"; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return "unknown"; + } finally { + if (process != null && process.isAlive()) process.destroyForcibly(); + } + } +} diff --git a/src/main/java/dev/modularui/preview/PreviewGeneration.java b/src/main/java/dev/modularui/preview/PreviewGeneration.java index 8968c75..9378721 100644 --- a/src/main/java/dev/modularui/preview/PreviewGeneration.java +++ b/src/main/java/dev/modularui/preview/PreviewGeneration.java @@ -19,10 +19,15 @@ private PreviewGeneration(Path root, PreviewSession session, PreviewResult initi } static PreviewGeneration open(Path projectRoot, String className, PreviewScreen screen, Path generationsRoot) { + return open(projectRoot, className, null, screen, generationsRoot); + } + + static PreviewGeneration open(Path projectRoot, String className, String scenarioId, PreviewScreen screen, + Path generationsRoot) { Path root = createRoot(generationsRoot); PreviewSession session = null; try { - session = PreviewEngine.open(projectRoot, className, screen, root.resolve("classes")); + session = PreviewEngine.open(projectRoot, className, scenarioId, screen, root.resolve("classes")); return new PreviewGeneration(root, session, session.render()); } catch (RuntimeException | Error failure) { cleanupFailedOpen(root, session, failure); diff --git a/src/main/java/dev/modularui/preview/PreviewScenario.java b/src/main/java/dev/modularui/preview/PreviewScenario.java new file mode 100644 index 0000000..604a080 --- /dev/null +++ b/src/main/java/dev/modularui/preview/PreviewScenario.java @@ -0,0 +1,205 @@ +package dev.modularui.preview; + +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +/** Immutable metadata and local-state factory for one production GUI preview. */ +public final class PreviewScenario { + + private static final Pattern STABLE_NAME = Pattern.compile("[a-z0-9]+(?:-[a-z0-9]+)*"); + private static final Pattern STABLE_ID = Pattern.compile( + "[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*)+"); + + private final String id; + private final String description; + private final String family; + private final Class previewedClass; + private final Supplier localStateFactory; + private final List tags; + private final TimeoutCategory timeout; + private final List expectedAssets; + private final String actions; + + private PreviewScenario(String id, String description, String family, Class previewedClass, + Supplier localStateFactory, List tags, TimeoutCategory timeout, + List expectedAssets, String actions) { + this.id = stableId(id); + this.description = requiredText(description, "Preview scenario description"); + this.family = stableName(family, "Preview scenario family"); + this.previewedClass = Objects.requireNonNull(previewedClass, "previewedClass"); + this.localStateFactory = Objects.requireNonNull(localStateFactory, "localStateFactory"); + this.tags = stableNames(tags, "Preview scenario tag"); + this.timeout = Objects.requireNonNull(timeout, "timeout"); + this.expectedAssets = expectedAssets.stream() + .map(asset -> requiredText(asset, "Expected asset")) + .distinct() + .sorted() + .toList(); + this.actions = validateActions(actions); + } + + public static PreviewScenario define(String id, String description, String family, Class previewedClass, + Supplier localStateFactory) { + return new PreviewScenario( + id, + description, + family, + previewedClass, + localStateFactory, + List.of(), + TimeoutCategory.DEFAULT, + List.of(), + null); + } + + public PreviewScenario tags(String... tags) { + return copy(stableNames(Arrays.asList(tags), "Preview scenario tag"), timeout, expectedAssets, actions); + } + + public PreviewScenario timeout(TimeoutCategory timeout) { + return copy(tags, Objects.requireNonNull(timeout, "timeout"), expectedAssets, actions); + } + + public PreviewScenario expectAssets(String... expectedAssets) { + return copy(tags, timeout, Arrays.asList(expectedAssets), actions); + } + + public PreviewScenario actions(String actions) { + return copy(tags, timeout, expectedAssets, actions); + } + + public String id() { + return id; + } + + public String description() { + return description; + } + + public String family() { + return family; + } + + public Class previewedClass() { + return previewedClass; + } + + public List tags() { + return tags; + } + + public TimeoutCategory timeout() { + return timeout; + } + + public List expectedAssets() { + return expectedAssets; + } + + public Optional actions() { + return Optional.ofNullable(actions); + } + + public Metadata metadata() { + return new Metadata( + id, + description, + family, + previewedClass.getName(), + tags, + timeout, + expectedAssets, + actions); + } + + public PreviewEntrypoint createEntrypoint() { + PreviewEntrypoint entrypoint = localStateFactory.get(); + if (entrypoint == null) { + throw new IllegalArgumentException("Preview scenario returned null local state: " + id); + } + Class actualPreviewedClass = entrypoint.previewedClass(); + if (actualPreviewedClass == null) { + throw new IllegalArgumentException("Preview scenario returned a null previewed class: " + id); + } + if (!previewedClass.equals(actualPreviewedClass)) { + throw new IllegalArgumentException( + "Preview scenario " + id + " declares " + previewedClass.getName() + + " but its local state previews " + actualPreviewedClass.getName()); + } + return entrypoint; + } + + private PreviewScenario copy(List tags, TimeoutCategory timeout, List expectedAssets, + String actions) { + return new PreviewScenario( + id, + description, + family, + previewedClass, + localStateFactory, + tags, + timeout, + expectedAssets, + actions); + } + + private static String stableId(String value) { + String id = requiredText(value, "Preview scenario ID"); + if (!STABLE_ID.matcher(id).matches()) { + throw new IllegalArgumentException("Invalid preview scenario ID: " + id); + } + return id; + } + + private static String stableName(String value, String label) { + String name = requiredText(value, label); + if (!STABLE_NAME.matcher(name).matches()) throw new IllegalArgumentException("Invalid " + label + ": " + name); + return name; + } + + private static List stableNames(List values, String label) { + return values.stream() + .map(value -> stableName(value, label)) + .distinct() + .sorted() + .toList(); + } + + private static String requiredText(String value, String label) { + if (value == null || value.isBlank() || !value.equals(value.trim())) { + throw new IllegalArgumentException(label + " must be non-blank and trimmed"); + } + return value; + } + + private static String validateActions(String actions) { + if (actions == null) return null; + String value = requiredText(actions, "Preview scenario actions path"); + Path path = Path.of(value); + if (path.isAbsolute() || path.normalize().startsWith("..")) { + throw new IllegalArgumentException("Preview scenario actions path must stay within the project: " + value); + } + return path.normalize() + .toString() + .replace('\\', '/'); + } + + public enum TimeoutCategory { + DEFAULT, + EXTENDED + } + + public record Metadata(String id, String description, String family, String previewedClass, List tags, + TimeoutCategory timeout, List expectedAssets, String actions) { + + public Metadata { + tags = List.copyOf(tags); + expectedAssets = List.copyOf(expectedAssets); + } + } +} diff --git a/src/main/java/dev/modularui/preview/PreviewSession.java b/src/main/java/dev/modularui/preview/PreviewSession.java index 88cb331..f1494b3 100644 --- a/src/main/java/dev/modularui/preview/PreviewSession.java +++ b/src/main/java/dev/modularui/preview/PreviewSession.java @@ -4,6 +4,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.function.Supplier; /** A live, isolated preview of one production ModularUI2 panel. */ @@ -19,6 +20,7 @@ public final class PreviewSession implements AutoCloseable { private final String panelClassName; private final Bounds panelBounds; private final Path panelCodeSource; + private final PreviewScenario.Metadata scenario; private final Thread ownerThread; private final Interaction interaction; private final Supplier renderer; @@ -27,7 +29,7 @@ public final class PreviewSession implements AutoCloseable { public PreviewSession(AutoCloseable runtime, AutoCloseable lifecycle, String entrypointClassName, Path entrypointCodeSource, String previewedClassName, Path previewedCodeSource, String panelName, String panelClassName, Bounds panelBounds, Path panelCodeSource, List widgets, - Interaction interaction, Supplier renderer) { + PreviewScenario.Metadata scenario, Interaction interaction, Supplier renderer) { this.runtime = runtime; this.lifecycle = lifecycle; this.entrypointClassName = entrypointClassName; @@ -38,6 +40,7 @@ public PreviewSession(AutoCloseable runtime, AutoCloseable lifecycle, String ent this.panelClassName = panelClassName; this.panelBounds = panelBounds; this.panelCodeSource = panelCodeSource; + this.scenario = scenario; this.ownerThread = Thread.currentThread(); this.widgets = List.copyOf(widgets); this.interaction = interaction; @@ -76,6 +79,10 @@ public Path panelCodeSource() { return panelCodeSource; } + public Optional scenario() { + return Optional.ofNullable(scenario); + } + public List widgets() { return widgets; } diff --git a/src/main/java/dev/modularui/preview/PreviewVerifier.java b/src/main/java/dev/modularui/preview/PreviewVerifier.java new file mode 100644 index 0000000..38f1f36 --- /dev/null +++ b/src/main/java/dev/modularui/preview/PreviewVerifier.java @@ -0,0 +1,428 @@ +package dev.modularui.preview; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** Coordinates isolated catalog workers and owns the stable verification reports. */ +final class PreviewVerifier { + + private static final Gson JSON = new GsonBuilder().serializeNulls().setPrettyPrinting().create(); + + VerificationSummary verify(Path projectRoot, String entrypoint, String selection, Path output, + Path configuration, PreviewCommand.VerifyOptions options) throws IOException { + List catalog; + try { + catalog = PreviewEngine.scenarios(projectRoot, entrypoint); + } catch (RuntimeException failure) { + Files.createDirectories(output); + PreviewWorkerResult result = syntheticFailure( + projectRoot, + "catalog", + preparationCategory(failure), + failureMessage(failure), + output.resolve("catalog"), + 0); + VerificationSummary summary = summary(projectRoot, entrypoint, options, List.of(result)); + writeReports(output, summary); + return summary; + } + validateCanonicalScenarios(catalog); + List selected = select(catalog, selection, options.full() || options.failedOnly()); + if (options.failedOnly()) selected = retainPreviousFailures(output, projectRoot, entrypoint, selected); + if (selected.isEmpty()) throw new IllegalArgumentException("No preview scenarios matched this verification run"); + + Files.createDirectories(output); + Path compiledOutput = projectRoot.resolve("build/classes/java/preview").toAbsolutePath().normalize(); + ExecutorService workers = Executors.newFixedThreadPool(Math.min(options.jobs(), selected.size())); + List results = new ArrayList<>(); + try { + List> futures = selected.stream() + .map(scenario -> workers.submit(() -> runWorker( + projectRoot, + entrypoint, + scenario, + output.resolve(scenario.id()), + configuration, + compiledOutput, + options.full(), + timeout(scenario, options)))) + .toList(); + for (Future future : futures) { + try { + results.add(future.get()); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IOException("Preview verification was interrupted", exception); + } catch (ExecutionException exception) { + throw new IOException("Preview worker coordination failed", exception.getCause()); + } + } + } finally { + workers.shutdownNow(); + } + results.sort(Comparator.comparing(PreviewWorkerResult::scenarioId)); + VerificationSummary summary = summary(projectRoot, entrypoint, options, results); + writeReports(output, summary); + return summary; + } + + private PreviewWorkerResult runWorker(Path projectRoot, String entrypoint, PreviewScenario.Metadata scenario, + Path output, Path configuration, Path compiledOutput, boolean runActions, Duration timeout) throws IOException { + resetOutput(output); + Files.createDirectories(output); + Path log = output.resolve("error.log"); + Process process = new ProcessBuilder( + javaExecutable(), + "-Djoml.nounsafe=true", + "-cp", + childClasspath(), + PreviewWorkerMain.class.getName(), + projectRoot.toString(), + entrypoint, + scenario.id(), + output.toString(), + configuration.toString(), + compiledOutput.toString(), + Boolean.toString(runActions)) + .directory(output.toFile()) + .redirectErrorStream(true) + .redirectOutput(log.toFile()) + .start(); + boolean completed; + try { + completed = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + throw new IOException("Preview worker was interrupted: " + scenario.id(), exception); + } + if (!completed) { + process.destroyForcibly(); + awaitTermination(process); + return syntheticFailure( + projectRoot, + scenario.id(), + "timeout", + "Worker exceeded its " + timeout.toSeconds() + " second timeout", + output, + timeout.toMillis()); + } + Path diagnostic = output.resolve("diagnostic.json"); + if (!Files.isRegularFile(diagnostic)) { + return syntheticFailure( + projectRoot, + scenario.id(), + "render_error", + "Worker exited with code " + process.exitValue() + " without a diagnostic", + output, + 0); + } + PreviewWorkerResult result; + try (var reader = Files.newBufferedReader(diagnostic, StandardCharsets.UTF_8)) { + result = JSON.fromJson(reader, PreviewWorkerResult.class); + } catch (RuntimeException exception) { + return syntheticFailure( + projectRoot, + scenario.id(), + "render_error", + "Worker produced malformed diagnostic output", + output, + 0); + } + if (result == null || !scenario.id().equals(result.scenarioId()) + || !(result.status().equals("passed") || result.status().equals("failed"))) { + return syntheticFailure( + projectRoot, + scenario.id(), + "render_error", + "Worker diagnostic did not match the selected scenario", + output, + 0); + } + if (process.exitValue() != 0 && result.passed()) { + return syntheticFailure( + projectRoot, + scenario.id(), + "render_error", + "Worker exited with code " + process.exitValue() + " after reporting success", + output, + result.durationMillis()); + } + return withErrorLog(result, output); + } + + private PreviewWorkerResult syntheticFailure(Path projectRoot, String scenarioId, String category, + String message, Path output, long durationMillis) throws IOException { + Map artifacts = new LinkedHashMap<>(); + if (Files.isRegularFile(output.resolve("error.log"))) artifacts.put("error", "error.log"); + PreviewWorkerResult result = new PreviewWorkerResult( + 1, + scenarioId, + "failed", + category, + message, + durationMillis, + PreviewEnvironment.version(), + PreviewEnvironment.javaVersion(), + PreviewEnvironment.projectCommit(projectRoot), + null, + null, + null, + List.of(), + List.of(), + artifacts); + writeDiagnostic(output, result); + return result; + } + + private PreviewWorkerResult withErrorLog(PreviewWorkerResult result, Path output) { + if (!Files.isRegularFile(output.resolve("error.log"))) return result; + Map artifacts = new LinkedHashMap<>(result.artifacts()); + artifacts.put("error", "error.log"); + return new PreviewWorkerResult( + result.schemaVersion(), + result.scenarioId(), + result.status(), + result.category(), + result.message(), + result.durationMillis(), + result.previewerVersion(), + result.javaVersion(), + result.projectCommit(), + result.previewedClass(), + result.previewedCodeSource(), + result.panelCodeSource(), + result.assets(), + result.warnings(), + artifacts); + } + + private List select(List catalog, String selection, + boolean full) { + if (selection != null) { + List exact = catalog.stream() + .filter(scenario -> scenario.id().equals(selection)) + .toList(); + if (!exact.isEmpty()) return exact; + List family = catalog.stream() + .filter(scenario -> scenario.family().equals(selection)) + .toList(); + if (family.isEmpty()) throw new IllegalArgumentException("Unknown preview family or scenario: " + selection); + return family; + } + if (full) return catalog; + return catalog.stream().filter(scenario -> scenario.tags().contains("default")).toList(); + } + + private void validateCanonicalScenarios(List catalog) { + Map> roots = catalog.stream() + .collect(Collectors.groupingBy(PreviewScenario.Metadata::previewedClass)); + for (Map.Entry> root : roots.entrySet()) { + List defaults = root.getValue().stream() + .filter(scenario -> scenario.tags().contains("default")) + .map(PreviewScenario.Metadata::id) + .toList(); + if (defaults.size() != 1) { + throw new IllegalArgumentException("Production GUI " + root.getKey() + + " must have exactly one default preview scenario; found " + defaults); + } + } + } + + private List retainPreviousFailures(Path output, Path projectRoot, String entrypoint, + List selected) throws IOException { + Path summaryFile = output.resolve("summary.json"); + if (!Files.isRegularFile(summaryFile)) { + throw new IllegalArgumentException("No previous verification summary exists: " + summaryFile); + } + VerificationSummary previous; + try (var reader = Files.newBufferedReader(summaryFile, StandardCharsets.UTF_8)) { + previous = JSON.fromJson(reader, VerificationSummary.class); + } catch (JsonParseException exception) { + throw new IllegalArgumentException("Previous verification summary is malformed: " + summaryFile, + exception); + } + String normalizedRoot = projectRoot.toAbsolutePath().normalize().toString(); + if (previous == null || !normalizedRoot.equals(previous.projectRoot()) + || !entrypoint.equals(previous.entrypoint())) { + throw new IllegalArgumentException("Previous verification summary is not compatible with this project"); + } + Set failed = previous.results().stream() + .filter(result -> !result.passed()) + .map(PreviewWorkerResult::scenarioId) + .collect(Collectors.toSet()); + return selected.stream().filter(scenario -> failed.contains(scenario.id())).toList(); + } + + private VerificationSummary summary(Path projectRoot, String entrypoint, PreviewCommand.VerifyOptions options, + List results) { + List reportResults = results.stream().map(this::withReportArtifactPaths).toList(); + int passed = (int) reportResults.stream().filter(PreviewWorkerResult::passed).count(); + return new VerificationSummary( + 1, + reportResults.stream().allMatch(PreviewWorkerResult::passed) ? "passed" : "failed", + projectRoot.toAbsolutePath().normalize().toString(), + entrypoint, + PreviewEnvironment.version(), + PreviewEnvironment.javaVersion(), + PreviewEnvironment.projectCommit(projectRoot), + options.full() ? "full" : options.failedOnly() ? "failed" : "fast", + reportResults.size(), + passed, + reportResults.size() - passed, + reportResults); + } + + private PreviewWorkerResult withReportArtifactPaths(PreviewWorkerResult result) { + Map artifacts = result.artifacts().entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> result.scenarioId() + "/" + entry.getValue(), + (first, ignored) -> first, + LinkedHashMap::new)); + return new PreviewWorkerResult( + result.schemaVersion(), + result.scenarioId(), + result.status(), + result.category(), + result.message(), + result.durationMillis(), + result.previewerVersion(), + result.javaVersion(), + result.projectCommit(), + result.previewedClass(), + result.previewedCodeSource(), + result.panelCodeSource(), + result.assets(), + result.warnings(), + artifacts); + } + + private void writeReports(Path output, VerificationSummary summary) throws IOException { + writeAtomically(output.resolve("summary.json"), JSON.toJson(summary) + System.lineSeparator()); + StringBuilder text = new StringBuilder(); + text.append("preview verification: ") + .append(summary.passed()).append(" passed, ") + .append(summary.failed()).append(" failed\n"); + for (PreviewWorkerResult result : summary.results()) { + text.append(result.passed() ? "PASS " : "FAIL ") + .append(result.scenarioId()) + .append(" (").append(result.durationMillis()).append(" ms)"); + if (!result.passed()) text.append(" [").append(result.category()).append("] ").append(result.message()); + if (!result.passed() && result.artifacts().containsKey("error")) { + text.append(" -> ").append(result.artifacts().get("error")); + } + text.append('\n'); + } + writeAtomically(output.resolve("summary.txt"), text.toString()); + } + + private void writeDiagnostic(Path output, PreviewWorkerResult result) throws IOException { + writeAtomically(output.resolve("diagnostic.json"), JSON.toJson(result) + System.lineSeparator()); + } + + private void writeAtomically(Path target, String contents) throws IOException { + Files.createDirectories(target.getParent()); + Path candidate = target.resolveSibling(target.getFileName() + ".candidate"); + Files.writeString(candidate, contents, StandardCharsets.UTF_8); + Files.move(candidate, target, StandardCopyOption.REPLACE_EXISTING); + } + + private void resetOutput(Path output) throws IOException { + Path normalized = output.toAbsolutePath().normalize(); + if (Files.notExists(normalized)) return; + try (var paths = Files.walk(normalized)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.deleteIfExists(path); + } + } + + private Duration timeout(PreviewScenario.Metadata scenario, PreviewCommand.VerifyOptions options) { + return scenario.timeout() == PreviewScenario.TimeoutCategory.EXTENDED + ? options.extendedTimeout() + : options.defaultTimeout(); + } + + private String javaExecutable() { + String name = System.getProperty("os.name").toLowerCase(java.util.Locale.ROOT).contains("win") + ? "java.exe" + : "java"; + return Path.of(System.getProperty("java.home"), "bin", name).toString(); + } + + private String childClasspath() { + Path base = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); + return java.util.Arrays.stream(System.getProperty("java.class.path").split( + java.util.regex.Pattern.quote(File.pathSeparator))) + .map(Path::of) + .map(path -> path.isAbsolute() ? path : base.resolve(path).normalize()) + .map(Path::toString) + .collect(Collectors.joining(File.pathSeparator)); + } + + private void awaitTermination(Process process) { + try { + process.waitFor(5, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + + private String preparationCategory(RuntimeException failure) { + String message = failureMessage(failure); + if (message.contains("Preview source compilation failed")) return "compile_error"; + if (message.startsWith("[classpath_error]") || message.startsWith("[gradle_error]") + || message.startsWith("[missing_output]")) { + return "classpath_error"; + } + if (message.contains("entrypoint.missing")) return "missing_class"; + return "render_error"; + } + + private String failureMessage(Throwable failure) { + String message = failure.getMessage(); + return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message; + } + + record VerificationSummary( + int schemaVersion, + String status, + String projectRoot, + String entrypoint, + String previewerVersion, + String javaVersion, + String projectCommit, + String mode, + int selected, + int passed, + int failed, + List results) { + + VerificationSummary { + results = List.copyOf(results); + } + + boolean allPassed() { + return failed == 0; + } + } +} diff --git a/src/main/java/dev/modularui/preview/PreviewWindow.java b/src/main/java/dev/modularui/preview/PreviewWindow.java index ede3431..0586f43 100644 --- a/src/main/java/dev/modularui/preview/PreviewWindow.java +++ b/src/main/java/dev/modularui/preview/PreviewWindow.java @@ -36,10 +36,14 @@ public final class PreviewWindow { private static final Duration WATCH_DEBOUNCE = Duration.ofMillis(300); public void open(Path projectRoot, String className, PreviewScreen screen) throws Exception { + open(projectRoot, className, null, screen); + } + + public void open(Path projectRoot, String className, String scenarioId, PreviewScreen screen) throws Exception { PreviewInputQueue inputs = new PreviewInputQueue(); AtomicReference failure = new AtomicReference<>(); Thread sessionThread = new Thread( - () -> runSession(projectRoot, className, screen, inputs, failure), + () -> runSession(projectRoot, className, scenarioId, screen, inputs, failure), "modularui-preview-session"); sessionThread.start(); sessionThread.join(); @@ -47,20 +51,25 @@ public void open(Path projectRoot, String className, PreviewScreen screen) throw } public void watch(Path projectRoot, String className, Path outputDirectory, Path configuration) throws Exception { + watch(projectRoot, className, null, outputDirectory, configuration); + } + + public void watch(Path projectRoot, String className, String scenarioId, Path outputDirectory, + Path configuration) throws Exception { PreviewInputQueue inputs = new PreviewInputQueue(); AtomicReference failure = new AtomicReference<>(); Thread sessionThread = new Thread( - () -> runWatch(projectRoot, className, outputDirectory, configuration, inputs, failure), + () -> runWatch(projectRoot, className, scenarioId, outputDirectory, configuration, inputs, failure), "modularui-preview-watch"); sessionThread.start(); sessionThread.join(); rethrow(failure.get()); } - private void runSession(Path projectRoot, String className, PreviewScreen screen, PreviewInputQueue inputs, - AtomicReference failure) { + private void runSession(Path projectRoot, String className, String scenarioId, PreviewScreen screen, + PreviewInputQueue inputs, AtomicReference failure) { WindowHandle window = null; - try (PreviewSession session = PreviewEngine.open(projectRoot, className, screen)) { + try (PreviewSession session = PreviewEngine.open(projectRoot, className, scenarioId, screen)) { window = createWindow(className, session.render().image(), inputs); while (true) { PreviewInput input = inputs.take(); @@ -76,8 +85,8 @@ private void runSession(Path projectRoot, String className, PreviewScreen screen } } - private void runWatch(Path projectRoot, String className, Path outputDirectory, Path configuration, - PreviewInputQueue inputs, AtomicReference failure) { + private void runWatch(Path projectRoot, String className, String scenarioId, Path outputDirectory, + Path configuration, PreviewInputQueue inputs, AtomicReference failure) { WindowHandle window = null; PreviewGeneration active = null; try { @@ -85,7 +94,7 @@ private void runWatch(Path projectRoot, String className, Path outputDirectory, PreviewInputSnapshot initial = capture(projectRoot, configuration, window); PreviewWatchState watchState = new PreviewWatchState(initial, WATCH_DEBOUNCE); window.showBuilding("Building initial preview..."); - active = rebuild(projectRoot, className, outputDirectory, configuration, window, active); + active = rebuild(projectRoot, className, scenarioId, outputDirectory, configuration, window, active); while (true) { PreviewInput input = inputs.poll(WATCH_POLL_MILLIS); @@ -100,7 +109,7 @@ private void runWatch(Path projectRoot, String className, Path outputDirectory, } if (!watchState.rebuildReady(System.nanoTime())) continue; window.showBuilding("Rebuilding preview..."); - active = rebuild(projectRoot, className, outputDirectory, configuration, window, active); + active = rebuild(projectRoot, className, scenarioId, outputDirectory, configuration, window, active); } } catch (Throwable throwable) { failure.set(throwable); @@ -111,14 +120,15 @@ private void runWatch(Path projectRoot, String className, Path outputDirectory, } } - private PreviewGeneration rebuild(Path projectRoot, String className, Path outputDirectory, Path configuration, - WindowHandle window, PreviewGeneration active) { + private PreviewGeneration rebuild(Path projectRoot, String className, String scenarioId, Path outputDirectory, + Path configuration, WindowHandle window, PreviewGeneration active) { PreviewGeneration candidate = null; try { PreviewScreen screen = PreviewScreen.load(configuration); candidate = PreviewGeneration.open( projectRoot, className, + scenarioId, screen, projectRoot.resolve("build/preview-generations")); new UiPreviewRunner().writeArtifacts( diff --git a/src/main/java/dev/modularui/preview/PreviewWorkerMain.java b/src/main/java/dev/modularui/preview/PreviewWorkerMain.java new file mode 100644 index 0000000..39bbf0a --- /dev/null +++ b/src/main/java/dev/modularui/preview/PreviewWorkerMain.java @@ -0,0 +1,194 @@ +package dev.modularui.preview; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Internal one-scenario process entrypoint used by catalog verification. */ +public final class PreviewWorkerMain { + + private static final Gson JSON = new GsonBuilder().serializeNulls().setPrettyPrinting().create(); + + private PreviewWorkerMain() {} + + public static void main(String[] arguments) { + int exitCode = run(arguments); + if (exitCode != 0) System.exit(exitCode); + } + + static int run(String[] arguments) { + if (arguments.length != 7) { + System.err.println("Preview worker received an invalid argument count"); + return 2; + } + Path projectRoot = Path.of(arguments[0]).toAbsolutePath().normalize(); + String entrypoint = arguments[1]; + String scenarioId = arguments[2]; + Path output = Path.of(arguments[3]).toAbsolutePath().normalize(); + Path configuration = Path.of(arguments[4]).toAbsolutePath().normalize(); + Path compiledOutput = Path.of(arguments[5]).toAbsolutePath().normalize(); + boolean runActions = Boolean.parseBoolean(arguments[6]); + long started = System.nanoTime(); + try { + Files.createDirectories(output); + PreviewScreen screen = PreviewScreen.load(configuration); + try (PreviewSession session = PreviewEngine.openPrepared( + projectRoot, entrypoint, scenarioId, screen, compiledOutput)) { + PreviewResult rendered = session.render(); + new UiPreviewRunner().writeArtifacts(output, entrypoint, session, rendered); + PreviewScenario.Metadata scenario = session.scenario() + .orElseThrow(() -> new WorkerFailure("render_error", "Worker did not load scenario metadata")); + validate(session, rendered, scenario); + if (runActions && scenario.actions() != null) { + Path actions = projectRoot.resolve(scenario.actions()).normalize(); + if (!actions.startsWith(projectRoot) || !Files.isRegularFile(actions)) { + throw new WorkerFailure("interaction_error", "Scenario action script is missing: " + actions); + } + try { + new PreviewActionRunner().run(session, entrypoint, actions, output); + } catch (IOException | RuntimeException exception) { + throw new WorkerFailure("interaction_error", message(exception), exception); + } + } + PreviewWorkerResult result = result( + projectRoot, + scenarioId, + "passed", + null, + "rendered successfully", + started, + session, + rendered, + output); + writeDiagnostic(output, result); + return 0; + } + } catch (Throwable failure) { + failure.printStackTrace(System.err); + String category = failure instanceof WorkerFailure workerFailure + ? workerFailure.category() + : category(failure); + PreviewWorkerResult result = result( + projectRoot, + scenarioId, + "failed", + category, + message(failure), + started, + null, + null, + output); + try { + writeDiagnostic(output, result); + } catch (IOException diagnosticFailure) { + diagnosticFailure.printStackTrace(System.err); + } + return 1; + } + } + + private static void validate(PreviewSession session, PreviewResult result, PreviewScenario.Metadata scenario) { + if (!scenario.previewedClass().equals(session.previewedClassName())) { + throw new WorkerFailure("missing_class", "Expected production class " + scenario.previewedClass() + + " but loaded " + session.previewedClassName()); + } + if (!result.warnings().isEmpty()) { + throw new WorkerFailure("unexpected_warning", "Render emitted " + result.warnings().size() + " warning(s)"); + } + if (result.widgets().isEmpty()) { + throw new WorkerFailure("incomplete_bounds", "Render did not report any widget bounds"); + } + List missingAssets = scenario.expectedAssets().stream() + .filter(expected -> result.assetSources().stream().noneMatch(actual -> matchesAsset(expected, actual))) + .toList(); + if (!missingAssets.isEmpty()) { + throw new WorkerFailure("missing_asset", "Expected assets were not rendered: " + missingAssets); + } + } + + private static boolean matchesAsset(String expected, String actual) { + String normalizedExpected = expected.replace('\\', '/'); + int namespace = normalizedExpected.indexOf(':'); + if (namespace > 0) { + normalizedExpected = "assets/" + normalizedExpected.substring(0, namespace) + "/" + + normalizedExpected.substring(namespace + 1); + } + return actual.replace('\\', '/').endsWith(normalizedExpected); + } + + private static PreviewWorkerResult result(Path projectRoot, String scenarioId, String status, String category, + String message, long started, PreviewSession session, PreviewResult rendered, Path output) { + Map artifacts = new LinkedHashMap<>(); + recordArtifact(output, artifacts, "preview", "preview.png"); + recordArtifact(output, artifacts, "bounds", "bounds.json"); + recordArtifact(output, artifacts, "actions", "actions.json"); + return new PreviewWorkerResult( + 1, + scenarioId, + status, + category, + message, + (System.nanoTime() - started) / 1_000_000L, + PreviewEnvironment.version(), + PreviewEnvironment.javaVersion(), + PreviewEnvironment.projectCommit(projectRoot), + session == null ? null : session.previewedClassName(), + session == null ? null : session.previewedCodeSource().toString(), + session == null ? null : session.panelCodeSource().toString(), + rendered == null ? List.of() : rendered.assetSources(), + rendered == null ? List.of() : rendered.warnings(), + artifacts); + } + + private static void recordArtifact(Path output, Map artifacts, String key, String name) { + if (Files.isRegularFile(output.resolve(name))) artifacts.put(key, name); + } + + private static void writeDiagnostic(Path output, PreviewWorkerResult result) throws IOException { + Files.createDirectories(output); + Path candidate = output.resolve("diagnostic.json.candidate"); + Files.writeString(candidate, JSON.toJson(result) + System.lineSeparator(), StandardCharsets.UTF_8); + Files.move(candidate, output.resolve("diagnostic.json"), StandardCopyOption.REPLACE_EXISTING); + } + + private static String category(Throwable failure) { + String message = message(failure); + if (message.startsWith("[classpath_error]") || message.startsWith("[missing_output]")) { + return "classpath_error"; + } + if (message.contains("Could not load preview runtime class")) return "missing_class"; + if (message.contains("Could not invoke") || message.contains("Could not create")) return "missing_method"; + return "render_error"; + } + + private static String message(Throwable failure) { + String message = failure.getMessage(); + return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message; + } + + private static final class WorkerFailure extends RuntimeException { + + private final String category; + + private WorkerFailure(String category, String message) { + super(message); + this.category = category; + } + + private WorkerFailure(String category, String message, Throwable cause) { + super(message, cause); + this.category = category; + } + + private String category() { + return category; + } + } +} diff --git a/src/main/java/dev/modularui/preview/PreviewWorkerResult.java b/src/main/java/dev/modularui/preview/PreviewWorkerResult.java new file mode 100644 index 0000000..bba9b80 --- /dev/null +++ b/src/main/java/dev/modularui/preview/PreviewWorkerResult.java @@ -0,0 +1,32 @@ +package dev.modularui.preview; + +import java.util.List; +import java.util.Map; + +record PreviewWorkerResult( + int schemaVersion, + String scenarioId, + String status, + String category, + String message, + long durationMillis, + String previewerVersion, + String javaVersion, + String projectCommit, + String previewedClass, + String previewedCodeSource, + String panelCodeSource, + List assets, + List warnings, + Map artifacts) { + + PreviewWorkerResult { + assets = List.copyOf(assets); + warnings = List.copyOf(warnings); + artifacts = Map.copyOf(artifacts); + } + + boolean passed() { + return status.equals("passed"); + } +} diff --git a/src/main/java/dev/modularui/preview/UiPreviewMain.java b/src/main/java/dev/modularui/preview/UiPreviewMain.java index ee1151f..4040d07 100644 --- a/src/main/java/dev/modularui/preview/UiPreviewMain.java +++ b/src/main/java/dev/modularui/preview/UiPreviewMain.java @@ -15,8 +15,7 @@ public static void main(String[] args) { static int run(String[] args, PrintStream output, PrintStream error) { try { - execute(PreviewCommand.parse(args), output); - return 0; + return execute(PreviewCommand.parse(args), output); } catch (IllegalArgumentException exception) { error.println(exception.getMessage()); error.println("Run 'preview help' to see the supported commands."); @@ -28,14 +27,14 @@ static int run(String[] args, PrintStream output, PrintStream error) { } } - private static void execute(PreviewCommand command, PrintStream output) throws Exception { + private static int execute(PreviewCommand command, PrintStream output) throws Exception { if (command.mode() == PreviewCommand.Mode.HELP) { output.print(PreviewCommand.usage()); - return; + return 0; } if (command.mode() == PreviewCommand.Mode.INIT) { initialize(command.projectRoot(), output); - return; + return 0; } Path projectRoot = command.projectRoot(); @@ -45,20 +44,47 @@ private static void execute(PreviewCommand command, PrintStream output) throws E .toAbsolutePath() : command.configuration(); Path outputDirectory = command.outputDirectory() == null - ? Path.of("output", simpleName(className)) + ? defaultOutputDirectory(command, className) .toAbsolutePath() : command.outputDirectory(); + if (command.mode() == PreviewCommand.Mode.LIST) { + list(projectRoot, className, output); + return 0; + } + if (command.mode() == PreviewCommand.Mode.DOCTOR) { + doctor(projectRoot, className, output); + return 0; + } + if (command.mode() == PreviewCommand.Mode.VERIFY) { + PreviewVerifier.VerificationSummary summary = new PreviewVerifier().verify( + projectRoot, + className, + command.scenarioId(), + outputDirectory, + configuration, + command.verification()); + output.println("Verification summary: " + outputDirectory.resolve("summary.txt")); + output.println("Machine summary: " + outputDirectory.resolve("summary.json")); + output.println(summary.passed() + " passed, " + summary.failed() + " failed"); + return summary.allPassed() ? 0 : 1; + } if (command.mode() == PreviewCommand.Mode.WATCH) { - new PreviewWindow().watch(projectRoot, className, outputDirectory, configuration); - return; + new PreviewWindow().watch(projectRoot, className, command.scenarioId(), outputDirectory, configuration); + return 0; } PreviewScreen screen = PreviewScreen.load(configuration); - switch (command.mode()) { - case RENDER -> render(command, projectRoot, className, outputDirectory, screen, output); - case OPEN -> new PreviewWindow().open(projectRoot, className, screen); + return switch (command.mode()) { + case RENDER -> { + render(command, projectRoot, className, outputDirectory, screen, output); + yield 0; + } + case OPEN -> { + new PreviewWindow().open(projectRoot, className, command.scenarioId(), screen); + yield 0; + } default -> throw new IllegalArgumentException("Unsupported preview command: " + command.mode()); - } + }; } private static void initialize(Path projectRoot, PrintStream output) { @@ -76,6 +102,7 @@ private static void render(PreviewCommand command, Path projectRoot, String clas new PreviewActionRunner().run( projectRoot, className, + command.scenarioId(), command.actions(), outputDirectory, screen); @@ -86,6 +113,7 @@ private static void render(PreviewCommand command, Path projectRoot, String clas PreviewResult result = new UiPreviewRunner().preview( projectRoot, className, + command.scenarioId(), outputDirectory, screen); output.println("Preview PNG: " + outputDirectory.resolve("preview.png")); @@ -93,6 +121,25 @@ private static void render(PreviewCommand command, Path projectRoot, String clas output.println("Warnings: " + result.warnings().size()); } + private static void list(Path projectRoot, String className, PrintStream output) { + for (PreviewScenario.Metadata scenario : PreviewEngine.scenarios(projectRoot, className)) { + output.println(scenario.id() + "\t" + scenario.family() + "\t" + scenario.description()); + } + } + + private static void doctor(Path projectRoot, String className, PrintStream output) { + PreviewProject project = PreviewProject.open(projectRoot); + java.util.List scenarios = PreviewEngine.scenarios(projectRoot, className); + output.println("ModularUI2 Preview: " + PreviewEnvironment.version()); + output.println("JDK: " + Runtime.version().feature() + " (" + PreviewEnvironment.javaVersion() + ")"); + output.println("Project: " + projectRoot.toAbsolutePath().normalize()); + output.println("Entrypoint: " + className); + output.println("Preview sources: " + project.previewSources()); + output.println("Runtime entries: " + project.productionRuntime().size()); + output.println("Scenarios: " + scenarios.size()); + output.println("Project commit: " + PreviewEnvironment.projectCommit(projectRoot)); + } + private static String defaultClassName(Path projectRoot) { return PreviewProject.open(projectRoot) .property("preview.entrypoint") @@ -105,6 +152,13 @@ private static String simpleName(String className) { return packageSeparator < 0 ? className : className.substring(packageSeparator + 1); } + private static Path defaultOutputDirectory(PreviewCommand command, String className) { + if (command.mode() == PreviewCommand.Mode.VERIFY) return command.projectRoot().resolve("output/verify"); + return command.scenarioId() == null + ? Path.of("output", simpleName(className)) + : Path.of("output").resolve(command.scenarioId()); + } + private static String failureMessage(Exception exception) { String message = exception.getMessage(); return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message; diff --git a/src/main/java/dev/modularui/preview/UiPreviewRunner.java b/src/main/java/dev/modularui/preview/UiPreviewRunner.java index c4e0eca..0360b21 100644 --- a/src/main/java/dev/modularui/preview/UiPreviewRunner.java +++ b/src/main/java/dev/modularui/preview/UiPreviewRunner.java @@ -22,7 +22,12 @@ public PreviewResult preview(Path projectRoot, String className, Path outputDire public PreviewResult preview(Path projectRoot, String className, Path outputDirectory, PreviewScreen screen) throws IOException { - try (PreviewSession session = PreviewEngine.open(projectRoot, className, screen)) { + return preview(projectRoot, className, null, outputDirectory, screen); + } + + public PreviewResult preview(Path projectRoot, String className, String scenarioId, Path outputDirectory, + PreviewScreen screen) throws IOException { + try (PreviewSession session = PreviewEngine.open(projectRoot, className, scenarioId, screen)) { PreviewResult result = session.render(); writeArtifacts(outputDirectory, className, session, result); return result; @@ -179,6 +184,7 @@ String toJson(String className, PreviewSession session, PreviewResult result) { json.append(",\n \"previewedCodeSource\": \"") .append(escapeJson(session.previewedCodeSource().toString())) .append("\""); + session.scenario().ifPresent(scenario -> appendScenario(json, scenario)); json.append(",\n \"panelName\": \"") .append(escapeJson(session.panelName())) .append("\""); @@ -217,6 +223,30 @@ String toJson(String className, PreviewSession session, PreviewResult result) { return json.toString(); } + private void appendScenario(StringBuilder json, PreviewScenario.Metadata scenario) { + json.append(",\n \"scenario\": {\"id\": \"") + .append(escapeJson(scenario.id())) + .append("\", \"description\": \"") + .append(escapeJson(scenario.description())) + .append("\", \"family\": \"") + .append(escapeJson(scenario.family())) + .append("\", \"previewedClass\": \"") + .append(escapeJson(scenario.previewedClass())) + .append("\", \"timeout\": \"") + .append(scenario.timeout().name().toLowerCase(java.util.Locale.ROOT)) + .append("\", \"tags\": ["); + appendWarnings(json, scenario.tags()); + json.append("\n ], \"expectedAssets\": ["); + appendWarnings(json, scenario.expectedAssets()); + json.append("\n ]"); + if (scenario.actions() != null) { + json.append(", \"actions\": \"") + .append(escapeJson(scenario.actions())) + .append('"'); + } + json.append('}'); + } + private void appendWidgets(StringBuilder json, List widgets) { for (int index = 0; index < widgets.size(); index++) { WidgetBounds widget = widgets.get(index); diff --git a/src/main/java/dev/modularui/preview/project/PreviewProject.java b/src/main/java/dev/modularui/preview/project/PreviewProject.java index c27cfe1..4de5bc0 100644 --- a/src/main/java/dev/modularui/preview/project/PreviewProject.java +++ b/src/main/java/dev/modularui/preview/project/PreviewProject.java @@ -26,17 +26,20 @@ public final class PreviewProject { private final Path root; private final Path compiledOutput; + private final Path previewSources; private final List assetSources; private final List bundledRuntime; - private final List productionRuntime; + private final ProductionRuntime productionRuntime; private final List libraries; private final List extensions; private final Map properties; - private PreviewProject(Path root, Path compiledOutput, List assetSources, List bundledRuntime, - List productionRuntime, List libraries, List extensions, Map properties) { + private PreviewProject(Path root, Path compiledOutput, Path previewSources, List assetSources, + List bundledRuntime, ProductionRuntime productionRuntime, List libraries, List extensions, + Map properties) { this.root = root; this.compiledOutput = compiledOutput; + this.previewSources = previewSources; this.assetSources = assetSources; this.bundledRuntime = bundledRuntime; this.productionRuntime = productionRuntime; @@ -89,6 +92,7 @@ public static PreviewProject open(Path root) { public static PreviewProject open(Path root, Path compiledOutput) { Path normalizedRoot = root.toAbsolutePath() .normalize(); + Map properties = loadProperties(normalizedRoot.resolve("preview.properties")); List assetSources = Stream.of( normalizedRoot.resolve("src/preview/resources"), normalizedRoot.resolve("assets")) @@ -98,12 +102,13 @@ public static PreviewProject open(Path root, Path compiledOutput) { normalizedRoot, compiledOutput.toAbsolutePath() .normalize(), + configuredPath(normalizedRoot, properties.getOrDefault("preview.sources", "src/preview/java")), assetSources, locateBundledRuntime(), - loadRuntimeClasspath(normalizedRoot, normalizedRoot.resolve("runtime-classpath.txt")), + ProductionRuntime.open(normalizedRoot, properties), discoverJars(normalizedRoot.resolve("libs")), discoverJars(normalizedRoot.resolve("extensions")), - loadProperties(normalizedRoot.resolve("preview.properties"))); + properties); } public Path root() { @@ -111,7 +116,7 @@ public Path root() { } public Path previewSources() { - return root.resolve("src/preview/java"); + return previewSources; } public void compileSources() { @@ -129,7 +134,11 @@ public void compileSources() { JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); if (compiler == null) { - throw new IllegalStateException("Preview sources require a JDK, but no Java compiler is available"); + throw new IllegalStateException("[jdk_error] Preview sources require a JDK, but no Java compiler is available"); + } + if (Runtime.version().feature() < 25) { + throw new IllegalStateException("[jdk_error] ModularUI2 Preview requires JDK 25 or newer; found " + + Runtime.version().feature()); } Path output = compiledOutput; try { @@ -140,7 +149,7 @@ public void compileSources() { String projectClasspath = Stream.of( bundledRuntime.stream(), - productionRuntime.stream(), + productionRuntime().stream(), libraries.stream(), extensions.stream()) .flatMap(stream -> stream) @@ -175,7 +184,7 @@ public List libraries() { } public List productionRuntime() { - return productionRuntime; + return productionRuntime.resolve(); } public List extensions() { @@ -192,7 +201,7 @@ public List watchedInputs(Path configuration) { root.resolve("extensions"), root.resolve("runtime-classpath.txt"), configuration), - productionRuntime.stream()) + productionRuntime.watchedInputs().stream()) .flatMap(stream -> stream) .map(path -> path.toAbsolutePath() .normalize()) @@ -205,7 +214,7 @@ public List runtimeArtifacts() { Stream.of(compiledOutput) .filter(Files::isDirectory), bundledRuntime.stream(), - productionRuntime.stream(), + productionRuntime().stream(), libraries.stream(), extensions.stream()) .flatMap(stream -> stream) @@ -261,20 +270,8 @@ private static Map loadProperties(Path file) { .collect(java.util.stream.Collectors.toUnmodifiableMap(name -> name, loaded::getProperty)); } - private static List loadRuntimeClasspath(Path root, Path file) { - if (!Files.isRegularFile(file)) return List.of(); - try { - return Files.readAllLines(file) - .stream() - .map(String::trim) - .filter(line -> !line.isEmpty() && !line.startsWith("#")) - .map(Path::of) - .map(path -> path.isAbsolute() ? path : root.resolve(path)) - .map(path -> path.toAbsolutePath() - .normalize()) - .toList(); - } catch (IOException exception) { - throw new IllegalArgumentException("Could not read production runtime classpath: " + file, exception); - } + private static Path configuredPath(Path root, String configured) { + Path path = Path.of(configured.trim()); + return (path.isAbsolute() ? path : root.resolve(path)).toAbsolutePath().normalize(); } } diff --git a/src/main/java/dev/modularui/preview/project/ProductionRuntime.java b/src/main/java/dev/modularui/preview/project/ProductionRuntime.java new file mode 100644 index 0000000..8619b55 --- /dev/null +++ b/src/main/java/dev/modularui/preview/project/ProductionRuntime.java @@ -0,0 +1,279 @@ +package dev.modularui.preview.project; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +/** Owns preparation and caching of a production project's runtime classpath. */ +final class ProductionRuntime { + + private static final String CLASSPATH_FILE = "runtime-classpath.txt"; + private static final String PRODUCTION_PROJECT = "production.project"; + private static final String PRODUCTION_TASK = "production.gradle.task"; + private static final String DEFAULT_TASK = "classes"; + private static final String PROBE_TASK = "_modularUiPreviewClasspath"; + private static final String OUTPUT_ENVIRONMENT = "MODULAR_UI_PREVIEW_CLASSPATH_OUTPUT"; + private static final String INIT_SCRIPT_RESOURCE = "/dev/modularui/preview/gradle/preview-classpath.init.gradle"; + private static final Duration GRADLE_TIMEOUT = Duration.ofMinutes(5); + + private final Path previewRoot; + private final Path classpathFile; + private final Path productionProject; + private final String productionTask; + + private ProductionRuntime(Path previewRoot, Path classpathFile, Path productionProject, String productionTask) { + this.previewRoot = previewRoot; + this.classpathFile = classpathFile; + this.productionProject = productionProject; + this.productionTask = productionTask; + } + + static ProductionRuntime open(Path previewRoot, Map properties) { + Path classpathFile = previewRoot.resolve(CLASSPATH_FILE); + String configuredProject = properties.get(PRODUCTION_PROJECT); + if (configuredProject == null) return new ProductionRuntime(previewRoot, classpathFile, null, null); + if (Files.isRegularFile(classpathFile)) { + throw failure("classpath_error", PRODUCTION_PROJECT + " cannot be combined with " + CLASSPATH_FILE); + } + Path project = resolve(previewRoot, configuredProject); + String task = properties.getOrDefault(PRODUCTION_TASK, DEFAULT_TASK).trim(); + if (!task.matches("[A-Za-z0-9:_-]+")) { + throw failure("gradle_error", "Invalid production Gradle task: " + task); + } + return new ProductionRuntime(previewRoot, null, project, task); + } + + List resolve() { + List runtime = productionProject == null ? loadClasspathFile() : resolveGradleProject(); + return runtime.stream() + .filter(path -> !isModularUiRuntime(path)) + .distinct() + .toList(); + } + + List watchedInputs() { + if (productionProject == null) { + if (classpathFile == null) return List.of(); + return Stream.concat(Stream.of(classpathFile), loadClasspathFile().stream()).toList(); + } + return productionInputs(); + } + + private List loadClasspathFile() { + if (classpathFile == null || !Files.isRegularFile(classpathFile)) return List.of(); + try { + return validateEntries(Files.readAllLines(classpathFile), previewRoot); + } catch (IOException exception) { + throw failure("classpath_error", "Could not read production runtime classpath: " + classpathFile, + exception); + } + } + + private List resolveGradleProject() { + if (!Files.isDirectory(productionProject)) { + throw failure("gradle_error", "Production project directory does not exist: " + productionProject); + } + Path cache = previewRoot.resolve("build/preview-runtime"); + Path fingerprintFile = cache.resolve("fingerprint.txt"); + Path cachedClasspath = cache.resolve("runtime-classpath.txt"); + String fingerprint = fingerprint(); + try { + if (Files.isRegularFile(fingerprintFile) + && fingerprint.equals(Files.readString(fingerprintFile, StandardCharsets.UTF_8).trim()) + && Files.isRegularFile(cachedClasspath)) { + List cached = validateEntries(Files.readAllLines(cachedClasspath), productionProject); + if (!cached.isEmpty()) return cached; + } + Files.createDirectories(cache); + Path initScript = materializeInitScript(cache); + Path candidate = cache.resolve("runtime-classpath.candidate.txt"); + Files.deleteIfExists(candidate); + runGradle(initScript, candidate, cache.resolve("gradle.log")); + if (!Files.isRegularFile(candidate)) { + throw failure("missing_output", "Gradle did not produce a production classpath: " + candidate); + } + List resolved = validateEntries(Files.readAllLines(candidate), productionProject).stream() + .filter(path -> !isModularUiRuntime(path)) + .toList(); + if (resolved.isEmpty()) { + throw failure("classpath_error", "Gradle produced an empty production classpath for " + + productionProject); + } + Files.write(cachedClasspath, resolved.stream().map(Path::toString).toList(), StandardCharsets.UTF_8); + Files.writeString(fingerprintFile, fingerprint + System.lineSeparator(), StandardCharsets.UTF_8); + Files.deleteIfExists(candidate); + return resolved; + } catch (IOException exception) { + throw failure("classpath_error", "Could not cache the production classpath below " + cache, exception); + } + } + + private void runGradle(Path initScript, Path output, Path log) throws IOException { + Path windowsWrapper = productionProject.resolve("gradlew.bat"); + Path unixWrapper = productionProject.resolve("gradlew"); + boolean windows = System.getProperty("os.name").toLowerCase(java.util.Locale.ROOT).contains("win"); + Path wrapper = windows ? windowsWrapper : unixWrapper; + if (!Files.isRegularFile(wrapper)) { + throw failure("gradle_error", "Production project Gradle wrapper is missing: " + wrapper); + } + List command = new ArrayList<>(); + if (windows) { + command.add("cmd.exe"); + command.add("/d"); + command.add("/c"); + } + command.add(wrapper.toString()); + command.add("--no-configuration-cache"); + command.add("--console=plain"); + command.add("--init-script"); + command.add(initScript.toString()); + command.add(productionTask); + command.add(PROBE_TASK); + ProcessBuilder builder = new ProcessBuilder(command) + .directory(productionProject.toFile()) + .redirectErrorStream(true) + .redirectOutput(log.toFile()); + builder.environment().put(OUTPUT_ENVIRONMENT, output.toString()); + Process process = builder.start(); + boolean completed; + try { + completed = process.waitFor(GRADLE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + throw failure("gradle_error", "Interrupted while preparing the production classpath", exception); + } + if (!completed) { + process.destroyForcibly(); + throw failure("gradle_error", "Production Gradle preparation timed out after " + + GRADLE_TIMEOUT.toSeconds() + " seconds. Log: " + log); + } + if (process.exitValue() != 0) { + throw failure("gradle_error", "Production Gradle preparation failed with exit code " + + process.exitValue() + ". Log: " + log); + } + } + + private Path materializeInitScript(Path cache) throws IOException { + Path script = cache.resolve("preview-classpath.init.gradle"); + byte[] expected = initScriptBytes(); + if (Files.notExists(script) || !java.util.Arrays.equals(expected, Files.readAllBytes(script))) { + Path candidate = cache.resolve("preview-classpath.init.gradle.candidate"); + Files.write(candidate, expected); + Files.move(candidate, script, StandardCopyOption.REPLACE_EXISTING); + } + return script; + } + + private static byte[] initScriptBytes() throws IOException { + try (InputStream input = ProductionRuntime.class.getResourceAsStream(INIT_SCRIPT_RESOURCE)) { + if (input == null) throw failure("classpath_error", "Missing packaged Gradle classpath probe"); + return input.readAllBytes(); + } + } + + private String fingerprint() { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + update(digest, productionProject.toString()); + update(digest, productionTask); + digest.update(initScriptBytes()); + for (Path input : fingerprintFiles()) { + update(digest, productionProject.relativize(input).toString().replace('\\', '/')); + digest.update(Files.readAllBytes(input)); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (IOException | NoSuchAlgorithmException exception) { + throw failure("classpath_error", "Could not fingerprint production inputs in " + productionProject, + exception); + } + } + + private List fingerprintFiles() throws IOException { + try (Stream paths = Files.walk(productionProject)) { + return paths.filter(Files::isRegularFile) + .filter(this::isProductionInput) + .sorted(Comparator.comparing(Path::toString)) + .toList(); + } + } + + private List productionInputs() { + return Stream.of( + productionProject.resolve("src/main"), + productionProject.resolve("buildSrc"), + productionProject.resolve("gradle"), + productionProject.resolve("build.gradle"), + productionProject.resolve("build.gradle.kts"), + productionProject.resolve("settings.gradle"), + productionProject.resolve("settings.gradle.kts"), + productionProject.resolve("gradle.properties")) + .filter(Files::exists) + .toList(); + } + + private boolean isProductionInput(Path path) { + Path relative = productionProject.relativize(path); + if (relative.startsWith("src/main") || relative.startsWith("buildSrc") || relative.startsWith("gradle")) { + return true; + } + if (relative.getNameCount() != 1) return false; + return switch (relative.getFileName().toString()) { + case "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "gradle.properties" -> + true; + default -> false; + }; + } + + private static List validateEntries(List lines, Path base) { + List entries = lines.stream() + .map(String::trim) + .filter(line -> !line.isEmpty() && !line.startsWith("#")) + .map(Path::of) + .map(path -> path.isAbsolute() ? path : base.resolve(path)) + .map(path -> path.toAbsolutePath().normalize()) + .distinct() + .toList(); + for (Path entry : entries) { + if (Files.notExists(entry)) throw failure("missing_output", "Production classpath entry is missing: " + entry); + } + return entries; + } + + private static boolean isModularUiRuntime(Path path) { + if (!Files.isRegularFile(path)) return false; + String name = path.getFileName().toString().toLowerCase(java.util.Locale.ROOT); + return name.startsWith("modularui-") || name.startsWith("modularui2-"); + } + + private static Path resolve(Path root, String configured) { + Path path = Path.of(configured.trim()); + return (path.isAbsolute() ? path : root.resolve(path)).toAbsolutePath().normalize(); + } + + private static void update(MessageDigest digest, String value) { + digest.update(value.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + } + + private static IllegalArgumentException failure(String category, String message) { + return new IllegalArgumentException("[" + category + "] " + message); + } + + private static IllegalArgumentException failure(String category, String message, Throwable cause) { + return new IllegalArgumentException("[" + category + "] " + message, cause); + } +} diff --git a/src/main/java/dev/modularui/preview/runtime/ProjectRuntime.java b/src/main/java/dev/modularui/preview/runtime/ProjectRuntime.java index ecfdda4..22137b7 100644 --- a/src/main/java/dev/modularui/preview/runtime/ProjectRuntime.java +++ b/src/main/java/dev/modularui/preview/runtime/ProjectRuntime.java @@ -2,10 +2,12 @@ import dev.modularui.preview.Bounds; import dev.modularui.preview.PreviewEntrypoint; +import dev.modularui.preview.PreviewCatalog; import dev.modularui.preview.PreviewDrawContext; import dev.modularui.preview.PreviewResult; import dev.modularui.preview.PreviewScreen; import dev.modularui.preview.PreviewSession; +import dev.modularui.preview.PreviewScenario; import dev.modularui.preview.MouseButton; import dev.modularui.preview.ScreenLayout; import dev.modularui.preview.ScrollDirection; @@ -26,6 +28,8 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.function.Supplier; +import net.minecraft.client.Minecraft; import net.minecraft.util.StatCollector; public final class ProjectRuntime implements AutoCloseable { @@ -48,10 +52,15 @@ Class loadClass(String className) throws ClassNotFoundException { } public static PreviewSession openSession(PreviewProject project, String entrypointName, PreviewScreen previewScreen) { + return openSession(project, entrypointName, null, previewScreen); + } + + public static PreviewSession openSession(PreviewProject project, String entrypointName, String scenarioId, + PreviewScreen previewScreen) { ProjectRuntime runtime = open(project.runtimeArtifacts()); try { runtime.initialiseForgeClientSide(); - return runtime.createSession(project, entrypointName, previewScreen); + return runtime.createSession(project, entrypointName, scenarioId, previewScreen); } catch (RuntimeException | LinkageError exception) { try { runtime.close(); @@ -62,6 +71,15 @@ public static PreviewSession openSession(PreviewProject project, String entrypoi } } + public static List listScenarios(PreviewProject project, String entrypointName) { + try (ProjectRuntime runtime = open(project.runtimeArtifacts())) { + runtime.initialiseForgeClientSide(); + return runtime.readScenarios(entrypointName); + } catch (IOException exception) { + throw new IllegalStateException("Could not close preview catalog runtime", exception); + } + } + @SuppressWarnings({ "unchecked", "rawtypes" }) private void initialiseForgeClientSide() { try { @@ -100,17 +118,42 @@ private static Object primitiveDefault(Class type) { return type.isPrimitive() && type != void.class ? Array.get(Array.newInstance(type, 1), 0) : null; } - private PreviewSession createSession(PreviewProject project, String entrypointName, PreviewScreen previewScreen) { + private List readScenarios(String entrypointName) { + Thread thread = Thread.currentThread(); + ClassLoader previous = thread.getContextClassLoader(); + thread.setContextClassLoader(classLoader); + try { + Object root = instantiate(loadClass(entrypointName)); + if (!(root instanceof PreviewCatalog catalog)) { + throw new IllegalArgumentException("Preview entrypoint is not a scenario catalog: " + entrypointName); + } + return catalog.validatedScenarios() + .stream() + .map(PreviewScenario::metadata) + .toList(); + } catch (ClassNotFoundException exception) { + throw new IllegalStateException("Could not load preview catalog", exception); + } finally { + thread.setContextClassLoader(previous); + } + } + + private PreviewSession createSession(PreviewProject project, String entrypointName, String scenarioId, + PreviewScreen previewScreen) { Thread thread = Thread.currentThread(); ClassLoader previous = thread.getContextClassLoader(); thread.setContextClassLoader(classLoader); try { Class entrypointClass = loadClass(entrypointName); - if (!PreviewEntrypoint.class.isAssignableFrom(entrypointClass)) { + if (!PreviewEntrypoint.class.isAssignableFrom(entrypointClass) + && !PreviewCatalog.class.isAssignableFrom(entrypointClass)) { throw new IllegalArgumentException( - "Preview entrypoint must implement " + PreviewEntrypoint.class.getName() + ": " + entrypointName); + "Preview entrypoint must implement " + PreviewEntrypoint.class.getName() + " or " + + PreviewCatalog.class.getName() + ": " + entrypointName); } - PreviewEntrypoint entrypoint = (PreviewEntrypoint) instantiate(entrypointClass); + Object root = instantiate(entrypointClass); + ResolvedEntrypoint resolved = resolveEntrypoint(root, entrypointName, scenarioId); + PreviewEntrypoint entrypoint = resolved.entrypoint(); Class previewedClass = entrypoint.previewedClass(); if (previewedClass == null) { throw new IllegalArgumentException("Preview entrypoint returned a null previewed class: " + entrypointName); @@ -118,6 +161,9 @@ private PreviewSession createSession(PreviewProject project, String entrypointNa AssetResolver assets = createAssetResolver(project); AssetResolver.Translations translations = assets.translations("en_US"); StatCollector.installTranslations(translations.values()); + Minecraft.getMinecraft().displayWidth = previewScreen.width(); + Minecraft.getMinecraft().displayHeight = previewScreen.height(); + Minecraft.getMinecraft().gameSettings.guiScale = previewScreen.requestedGuiScale(); Class modularSyncManagerClass = loadClass("com.cleanroommc.modularui.value.sync.ModularSyncManager"); Class panelSyncManagerClass = loadClass("com.cleanroommc.modularui.value.sync.PanelSyncManager"); Object modularSyncManager = instantiateSyncManager(modularSyncManagerClass, true); @@ -172,31 +218,60 @@ private PreviewSession createSession(PreviewProject project, String entrypointNa bounds, codeSource, widgets, + resolved.scenario(), new PreviewSession.Interaction() { @Override public void moveMouse(int screenX, int screenY) { - withContextClassLoader(classLoader, () -> { - invoke(contextClass, context, "updateState", - new Class[] { int.class, int.class, float.class }, - layout.toLogicalX(screenX), layout.toLogicalY(screenY), 0F); - invoke(screenClass, screen, "onFrameUpdate", new Class[0]); + withTranslations(translations, () -> { + withContextClassLoader(classLoader, () -> { + invoke(contextClass, context, "updateState", + new Class[] { int.class, int.class, float.class }, + layout.toLogicalX(screenX), layout.toLogicalY(screenY), 0F); + }); + advanceFrame(); + return null; }); } @Override public boolean press(MouseButton button) { - return dispatchMouse(screenClass, screen, button.modularUiCode(), true); + return withTranslations(translations, () -> { + boolean handled = dispatchMouse(screenClass, screen, button.modularUiCode(), true); + advanceUi(); + return handled; + }); } @Override public boolean release(MouseButton button) { - return dispatchMouse(screenClass, screen, button.modularUiCode(), false); + return withTranslations(translations, () -> { + boolean handled = dispatchMouse(screenClass, screen, button.modularUiCode(), false); + advanceUi(); + return handled; + }); } @Override public boolean scroll(ScrollDirection direction, int amount) { - return dispatchScroll(screenClass, screen, scrollDirectionClass, direction, amount); + return withTranslations(translations, () -> { + boolean handled = dispatchScroll(screenClass, screen, scrollDirectionClass, direction, amount); + advanceUi(); + return handled; + }); + } + + private void advanceUi() { + withContextClassLoader( + classLoader, + () -> invoke(screenClass, screen, "onUpdate", new Class[0])); + advanceFrame(); + } + + private void advanceFrame() { + withContextClassLoader( + classLoader, + () -> invoke(screenClass, screen, "onFrameUpdate", new Class[0])); } }, () -> render(screenClass, screen, panel, bounds, previewScreen, layout, assets, translations)); @@ -208,6 +283,18 @@ public boolean scroll(ScrollDirection direction, int amount) { } } + private ResolvedEntrypoint resolveEntrypoint(Object root, String entrypointName, String scenarioId) { + if (root instanceof PreviewCatalog catalog) { + PreviewScenario scenario = catalog.requireScenario(scenarioId); + return new ResolvedEntrypoint(scenario.createEntrypoint(), scenario.metadata()); + } + if (scenarioId != null) { + throw new IllegalArgumentException( + "Preview project does not define scenarios, so it cannot select: " + scenarioId); + } + return new ResolvedEntrypoint((PreviewEntrypoint) root, null); + } + private PreviewResult render(Class screenClass, Object screen, Object panel, Bounds panelBounds, PreviewScreen previewScreen, ScreenLayout layout, AssetResolver assets, AssetResolver.Translations translations) { @@ -230,6 +317,7 @@ private PreviewResult render(Class screenClass, Object screen, Object panel, PreviewDrawContext.run( graphics, assets, + layout.screenHeight(), () -> invoke(screenClass, screen, "drawScreen", new Class[0])))); } finally { StatCollector.clearTranslations(); @@ -495,6 +583,8 @@ private static final class IsolatedClassLoader extends URLClassLoader { "net.minecraft.inventory.", "net.minecraft.item.", "net.minecraft.util.ResourceLocation", + "net.minecraft.util.RegistryNamespaced", + "net.minecraft.util.ObjectIntIdentityMap", "net.minecraft.util.StatCollector", "net.minecraft.util.StringTranslate", "cpw.mods.fml.common.ICrashCallable", @@ -537,4 +627,15 @@ private boolean isParentOwned(String name) { .anyMatch(name::startsWith); } } + + private static T withTranslations(AssetResolver.Translations translations, Supplier action) { + StatCollector.installTranslations(translations.values()); + try { + return action.get(); + } finally { + StatCollector.clearTranslations(); + } + } + + private record ResolvedEntrypoint(PreviewEntrypoint entrypoint, PreviewScenario.Metadata scenario) {} } diff --git a/src/main/java/net/minecraft/block/Block.java b/src/main/java/net/minecraft/block/Block.java new file mode 100644 index 0000000..14f3cb0 --- /dev/null +++ b/src/main/java/net/minecraft/block/Block.java @@ -0,0 +1,4 @@ +package net.minecraft.block; + +/** Descriptor-only block type used by ItemStack's Minecraft 1.7.10 constructors. */ +public class Block {} diff --git a/src/main/java/net/minecraft/client/Minecraft.java b/src/main/java/net/minecraft/client/Minecraft.java index 97d1d2c..847e6c5 100644 --- a/src/main/java/net/minecraft/client/Minecraft.java +++ b/src/main/java/net/minecraft/client/Minecraft.java @@ -5,9 +5,12 @@ import net.minecraft.client.audio.SoundHandler; import net.minecraft.client.entity.EntityClientPlayerMP; import net.minecraft.client.gui.FontRenderer; +import net.minecraft.client.gui.GuiScreen; import net.minecraft.client.multiplayer.WorldClient; +import net.minecraft.client.multiplayer.ServerData; import net.minecraft.client.renderer.texture.TextureManager; import net.minecraft.client.resources.IResourceManager; +import net.minecraft.client.settings.GameSettings; public class Minecraft { @@ -18,6 +21,10 @@ public class Minecraft { public final EntityClientPlayerMP thePlayer = new EntityClientPlayerMP(); public final WorldClient theWorld = null; public final File mcDataDir = new File(System.getProperty("java.io.tmpdir"), "modularui2-preview"); + public final GameSettings gameSettings = new GameSettings(); + public int displayWidth = 854; + public int displayHeight = 480; + public GuiScreen currentScreen; private final SoundHandler soundHandler = new SoundHandler(); private final IResourceManager resourceManager = location -> { throw new FileNotFoundException(location.toString()); @@ -42,4 +49,16 @@ public TextureManager getTextureManager() { public static long getSystemTime() { return System.currentTimeMillis(); } + + public ServerData func_147104_D() { + return null; + } + + public boolean func_152349_b() { + return false; + } + + public void displayGuiScreen(GuiScreen screen) { + currentScreen = screen; + } } diff --git a/src/main/java/net/minecraft/client/gui/GuiScreen.java b/src/main/java/net/minecraft/client/gui/GuiScreen.java index 33514a7..17cd372 100644 --- a/src/main/java/net/minecraft/client/gui/GuiScreen.java +++ b/src/main/java/net/minecraft/client/gui/GuiScreen.java @@ -16,6 +16,18 @@ public class GuiScreen extends Gui implements GuiScreenAccessor { private List buttonList = new ArrayList<>(); private final List labelList = new ArrayList<>(); + public static boolean isShiftKeyDown() { + return false; + } + + public static boolean isCtrlKeyDown() { + return false; + } + + public static boolean isAltKeyDown() { + return false; + } + public void drawWorldBackground(int tint) {} public boolean doesGuiPauseGame() { diff --git a/src/main/java/net/minecraft/client/multiplayer/ServerData.java b/src/main/java/net/minecraft/client/multiplayer/ServerData.java new file mode 100644 index 0000000..b865ffe --- /dev/null +++ b/src/main/java/net/minecraft/client/multiplayer/ServerData.java @@ -0,0 +1,3 @@ +package net.minecraft.client.multiplayer; + +public class ServerData {} diff --git a/src/main/java/net/minecraft/client/renderer/OpenGlHelper.java b/src/main/java/net/minecraft/client/renderer/OpenGlHelper.java index cf1bb8c..12cebfe 100644 --- a/src/main/java/net/minecraft/client/renderer/OpenGlHelper.java +++ b/src/main/java/net/minecraft/client/renderer/OpenGlHelper.java @@ -2,7 +2,11 @@ public final class OpenGlHelper { + public static int lightmapTexUnit; + private OpenGlHelper() {} public static void glBlendFunc(int source, int destination, int sourceAlpha, int destinationAlpha) {} + + public static void setLightmapTextureCoords(int textureUnit, float x, float y) {} } diff --git a/src/main/java/net/minecraft/client/renderer/entity/RenderItem.java b/src/main/java/net/minecraft/client/renderer/entity/RenderItem.java index 08368e4..6a76adf 100644 --- a/src/main/java/net/minecraft/client/renderer/entity/RenderItem.java +++ b/src/main/java/net/minecraft/client/renderer/entity/RenderItem.java @@ -6,8 +6,14 @@ public class RenderItem { + private static final RenderItem INSTANCE = new RenderItem(); + public float zLevel; + public static RenderItem getInstance() { + return INSTANCE; + } + public void renderItemAndEffectIntoGUI(FontRenderer fontRenderer, TextureManager textureManager, ItemStack stack, int x, int y) {} diff --git a/src/main/java/net/minecraft/client/settings/GameSettings.java b/src/main/java/net/minecraft/client/settings/GameSettings.java new file mode 100644 index 0000000..0d8ff62 --- /dev/null +++ b/src/main/java/net/minecraft/client/settings/GameSettings.java @@ -0,0 +1,6 @@ +package net.minecraft.client.settings; + +public class GameSettings { + + public int guiScale; +} diff --git a/src/main/java/net/minecraft/entity/Entity.java b/src/main/java/net/minecraft/entity/Entity.java index ad56f40..923b173 100644 --- a/src/main/java/net/minecraft/entity/Entity.java +++ b/src/main/java/net/minecraft/entity/Entity.java @@ -4,4 +4,7 @@ public class Entity { /** Current dimension id, matching the public field exposed by Minecraft 1.7.10. */ public int dimension; + public double posX; + public double posY; + public double posZ; } diff --git a/src/main/java/net/minecraft/item/Item.java b/src/main/java/net/minecraft/item/Item.java new file mode 100644 index 0000000..64f45ef --- /dev/null +++ b/src/main/java/net/minecraft/item/Item.java @@ -0,0 +1,44 @@ +package net.minecraft.item; + +import net.minecraft.util.RegistryNamespaced; + +public class Item { + + public static final RegistryNamespaced itemRegistry = new RegistryNamespaced(); + + private String unlocalizedName = "item.unknown"; + + public static int getIdFromItem(Item item) { + return itemRegistry.getIDForObject(item); + } + + public Item setUnlocalizedName(String name) { + unlocalizedName = name.startsWith("item.") ? name : "item." + name; + return this; + } + + public String getUnlocalizedName() { + return unlocalizedName; + } + + public String getUnlocalizedName(ItemStack stack) { + return getUnlocalizedName(); + } + + public String getItemStackDisplayName(ItemStack stack) { + return net.minecraft.util.StatCollector.translateToLocal(getUnlocalizedName(stack) + ".name") + .trim(); + } + + public int getDamage(ItemStack stack) { + return stack.getItemDamage(); + } + + public void setDamage(ItemStack stack, int damage) { + stack.setItemDamage(damage); + } + + public boolean getHasSubtypes() { + return false; + } +} diff --git a/src/main/java/net/minecraft/item/ItemStack.java b/src/main/java/net/minecraft/item/ItemStack.java index f87e45e..706d21a 100644 --- a/src/main/java/net/minecraft/item/ItemStack.java +++ b/src/main/java/net/minecraft/item/ItemStack.java @@ -1,6 +1,72 @@ package net.minecraft.item; +import net.minecraft.block.Block; + public class ItemStack { - public ItemStack() {} + public int stackSize; + private Item item; + private int itemDamage; + + public ItemStack() { + this((Item) null, 0); + } + + public ItemStack(Item item) { + this(item, 1); + } + + public ItemStack(Block block) { + this(block, 1); + } + + public ItemStack(Block block, int amount) { + this(block, amount, 0); + } + + public ItemStack(Block block, int amount, int damage) { + this((Item) null, amount, damage); + } + + public ItemStack(Item item, int amount) { + this(item, amount, 0); + } + + public ItemStack(Item item, int amount, int damage) { + this.item = item; + this.stackSize = amount; + this.itemDamage = damage; + } + + public Item getItem() { + return item; + } + + public String getDisplayName() { + return item == null ? "" : item.getItemStackDisplayName(this); + } + + public String getUnlocalizedName() { + return item == null ? "item.null" : item.getUnlocalizedName(this); + } + + public int getItemDamage() { + return itemDamage; + } + + public void setItemDamage(int damage) { + itemDamage = damage; + } + + public ItemStack copy() { + return new ItemStack(item, stackSize, itemDamage); + } + + public boolean isItemEqual(ItemStack other) { + return other != null && item == other.item && itemDamage == other.itemDamage; + } + + public boolean hasTagCompound() { + return false; + } } diff --git a/src/main/java/net/minecraft/util/ObjectIntIdentityMap.java b/src/main/java/net/minecraft/util/ObjectIntIdentityMap.java new file mode 100644 index 0000000..2e9c241 --- /dev/null +++ b/src/main/java/net/minecraft/util/ObjectIntIdentityMap.java @@ -0,0 +1,40 @@ +package net.minecraft.util; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +public class ObjectIntIdentityMap implements Iterable { + + private final Map ids = new IdentityHashMap<>(); + private final List values = new ArrayList<>(); + + public void func_148746_a(Object value, int id) { + ids.put(value, id); + while (values.size() <= id) { + values.add(null); + } + values.set(id, value); + } + + public int func_148747_b(Object value) { + return ids.getOrDefault(value, -1); + } + + public Object func_148745_a(int id) { + return id >= 0 && id < values.size() ? values.get(id) : null; + } + + public boolean func_148744_b(int id) { + return func_148745_a(id) != null; + } + + @Override + public Iterator iterator() { + return values.stream() + .filter(value -> value != null) + .iterator(); + } +} diff --git a/src/main/java/net/minecraft/util/RegistryNamespaced.java b/src/main/java/net/minecraft/util/RegistryNamespaced.java new file mode 100644 index 0000000..2b788e5 --- /dev/null +++ b/src/main/java/net/minecraft/util/RegistryNamespaced.java @@ -0,0 +1,72 @@ +package net.minecraft.util; + +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Set; + +public class RegistryNamespaced implements Iterable { + + protected final Map registryObjects = new HashMap<>(); + private final Map names = new IdentityHashMap<>(); + protected final ObjectIntIdentityMap underlyingIntegerMap = new ObjectIntIdentityMap(); + + public Object getObject(Object key) { + return getObject((String) key); + } + + public void putObject(Object key, Object value) { + Object previous = registryObjects.put(key, value); + if (previous != null) { + names.remove(previous); + } + names.put(value, String.valueOf(key)); + } + + public Set getKeys() { + return Collections.unmodifiableSet(registryObjects.keySet()); + } + + public Object getObject(String key) { + return registryObjects.get(ensureNamespaced(key)); + } + + public void addObject(int id, String key, Object value) { + underlyingIntegerMap.func_148746_a(value, id); + putObject(ensureNamespaced(key), value); + } + + public String getNameForObject(Object value) { + return names.get(value); + } + + public boolean containsKey(String key) { + return registryObjects.containsKey(ensureNamespaced(key)); + } + + public boolean containsKey(Object key) { + return containsKey((String) key); + } + + public boolean containsId(int id) { + return underlyingIntegerMap.func_148744_b(id); + } + + public int getIDForObject(Object value) { + return underlyingIntegerMap.func_148747_b(value); + } + + public Object getObjectById(int id) { + return underlyingIntegerMap.func_148745_a(id); + } + + @Override + public java.util.Iterator iterator() { + return underlyingIntegerMap.iterator(); + } + + protected static String ensureNamespaced(String key) { + return key.indexOf(':') < 0 ? "minecraft:" + key : key; + } +} diff --git a/src/main/java/org/lwjgl/input/Keyboard.java b/src/main/java/org/lwjgl/input/Keyboard.java new file mode 100644 index 0000000..267586c --- /dev/null +++ b/src/main/java/org/lwjgl/input/Keyboard.java @@ -0,0 +1,15 @@ +package org.lwjgl.input; + +/** Headless keyboard boundary. Scripted previews currently expose no pressed keys. */ +public final class Keyboard { + + private Keyboard() {} + + public static boolean isCreated() { + return true; + } + + public static boolean isKeyDown(int key) { + return false; + } +} diff --git a/src/main/java/org/lwjgl/opengl/GL11.java b/src/main/java/org/lwjgl/opengl/GL11.java index 41f4f43..0863853 100644 --- a/src/main/java/org/lwjgl/opengl/GL11.java +++ b/src/main/java/org/lwjgl/opengl/GL11.java @@ -26,7 +26,11 @@ public final class GL11 { public static final int GL_DEPTH_TEST = 0x0B71; public static final int GL_LIGHTING = 0x0B50; public static final int GL_STENCIL_TEST = 0x0B90; + public static final int GL_SCISSOR_TEST = 0x0C11; public static final int GL_TEXTURE_2D = 0x0DE1; + public static final int GL_TEXTURE_WRAP_S = 0x2802; + public static final int GL_TEXTURE_WRAP_T = 0x2803; + public static final int GL_REPEAT = 0x2901; public static final int GL_TEXTURE_BINDING_2D = 0x8069; public static final int GL_MODELVIEW = 0x1700; public static final int GL_PROJECTION = 0x1701; @@ -167,6 +171,10 @@ public static void glPushClientAttrib(int mask) {} public static void glReadPixels(int x, int y, int width, int height, int format, int type, FloatBuffer target) {} + public static void glScissor(int x, int y, int width, int height) { + PreviewDrawContext.scissor(x, y, width, height); + } + public static void glShadeModel(int mode) {} public static void glStencilFunc(int function, int reference, int mask) {} @@ -177,5 +185,9 @@ public static void glStencilOp(int fail, int depthFail, int depthPass) { PreviewDrawContext.stencilOperation(depthPass); } + public static void glTexParameteri(int target, int parameter, int value) { + PreviewDrawContext.textureParameter(target, parameter, value); + } + public static void glViewport(int x, int y, int width, int height) {} } diff --git a/src/main/resources/dev/modularui/preview/gradle/preview-classpath.init.gradle b/src/main/resources/dev/modularui/preview/gradle/preview-classpath.init.gradle new file mode 100644 index 0000000..5743d02 --- /dev/null +++ b/src/main/resources/dev/modularui/preview/gradle/preview-classpath.init.gradle @@ -0,0 +1,44 @@ +gradle.projectsEvaluated { + def root = gradle.rootProject + if (root.tasks.findByName('_modularUiPreviewClasspath') != null) { + return + } + root.tasks.register('_modularUiPreviewClasspath') { + def sourceSets = root.extensions.findByName('sourceSets') + if (sourceSets == null) { + throw new GradleException('The production project does not expose Java source sets') + } + def main = sourceSets.findByName('main') + if (main == null) { + throw new GradleException('The production project does not expose the main source set') + } + dependsOn(main.classesTaskName) + doLast { + def outputPath = System.getenv('MODULAR_UI_PREVIEW_CLASSPATH_OUTPUT') + if (outputPath == null || outputPath.isBlank()) { + throw new GradleException('Missing ModularUI2 Preview classpath output location') + } + def runtime = root.configurations.findByName(main.runtimeClasspathConfigurationName) + if (runtime == null) { + throw new GradleException('The production project does not expose the main runtime classpath') + } + def compile = root.configurations.findByName(main.compileClasspathConfigurationName) + if (compile == null) { + throw new GradleException('The production project does not expose the main compile classpath') + } + def entries = new LinkedHashSet() + sourceSets.each { sourceSet -> + if (!sourceSet.name.toLowerCase(Locale.ROOT).contains('test')) { + entries.addAll(sourceSet.output.files.findAll { it.exists() }) + } + } + entries.addAll(compile.files) + entries.addAll(runtime.files) + def output = new File(outputPath) + output.parentFile.mkdirs() + output.withWriter('UTF-8') { writer -> + entries.each { writer.writeLine(it.absolutePath) } + } + } + } +} diff --git a/src/test/java/com/cleanroommc/modularui/api/drawable/IKeyTest.java b/src/test/java/com/cleanroommc/modularui/api/drawable/IKeyTest.java new file mode 100644 index 0000000..47b2492 --- /dev/null +++ b/src/test/java/com/cleanroommc/modularui/api/drawable/IKeyTest.java @@ -0,0 +1,25 @@ +package com.cleanroommc.modularui.api.drawable; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; + +import net.minecraft.util.StatCollector; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +final class IKeyTest { + + @AfterEach + void clearTranslations() { + StatCollector.clearTranslations(); + } + + @Test + void resolvesLanguageKeysFromTheActivePreviewTranslations() { + StatCollector.installTranslations(Map.of("example.preview.label", "Translated %s")); + + assertEquals("Translated panel", IKey.lang("example.preview.label", "panel").get()); + } +} diff --git a/src/test/java/dev/modularui/preview/DistributionLayoutTest.java b/src/test/java/dev/modularui/preview/DistributionLayoutTest.java index 70d594d..fd733f7 100644 --- a/src/test/java/dev/modularui/preview/DistributionLayoutTest.java +++ b/src/test/java/dev/modularui/preview/DistributionLayoutTest.java @@ -7,6 +7,9 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.util.HexFormat; import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -44,6 +47,9 @@ void zipContainsAPortableRunnableToolWithoutBuildMachinePaths() throws Exception assertTrue(entries.stream().anyMatch(name -> name.equals("bin/modularui2-preview.bat"))); assertTrue(entries.stream().anyMatch(name -> name.startsWith("lib/") && name.endsWith(".jar"))); assertFalse(entries.stream().anyMatch(name -> name.endsWith("java-executable.txt"))); + assertFalse( + entries.stream().anyMatch(DistributionLayoutTest::isGeneratedExampleArtifact), + "Distribution must not contain generated example output"); String checkout = Path.of("").toAbsolutePath().normalize().toString(); assertNoTextEntryContains(archive, entry -> entry.getName().endsWith(".bat") @@ -52,10 +58,29 @@ void zipContainsAPortableRunnableToolWithoutBuildMachinePaths() throws Exception } } + @Test + void releaseChecksumMatchesThePortableZip() throws Exception { + Path archive = Path.of(System.getProperty("preview.distribution.zip")); + Path checksumFile = Path.of(System.getProperty("preview.distribution.zip.checksum")); + String expected = Files.readString(checksumFile, StandardCharsets.UTF_8).split("\\s+")[0]; + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (var input = Files.newInputStream(archive)) { + input.transferTo(new java.security.DigestOutputStream(java.io.OutputStream.nullOutputStream(), digest)); + } + + assertTrue(expected.equals(HexFormat.of().formatHex(digest.digest()))); + } + private static void assertContains(Set entries, String required) { assertTrue(entries.contains(required), () -> "Missing distribution entry: " + required); } + private static boolean isGeneratedExampleArtifact(String name) { + return name.startsWith("examples/") + && (name.contains("/build/") || name.contains("/output/") || name.contains("/logs/") + || name.endsWith("/runtime-classpath.txt")); + } + private static void assertNoTextEntryContains(ZipFile archive, Predicate filter, String value) throws IOException { for (ZipEntry entry : archive.stream().filter(filter).toList()) { diff --git a/src/test/java/dev/modularui/preview/DistributionRuntimeTest.java b/src/test/java/dev/modularui/preview/DistributionRuntimeTest.java new file mode 100644 index 0000000..9ca90f6 --- /dev/null +++ b/src/test/java/dev/modularui/preview/DistributionRuntimeTest.java @@ -0,0 +1,90 @@ +package dev.modularui.preview; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.concurrent.TimeUnit; +import java.util.zip.ZipFile; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class DistributionRuntimeTest { + + @TempDir + Path temporaryDirectory; + + @Test + void extractedReleaseRunsHelpListsACatalogAndVerifiesItsDefaultScenario() throws Exception { + Path archive = Path.of(System.getProperty("preview.distribution.zip")); + Path extracted = temporaryDirectory.resolve("release"); + extract(archive, extracted); + Path root; + try (var directories = Files.list(extracted)) { + root = directories.filter(Files::isDirectory).findFirst().orElseThrow(); + } + + CommandResult help = run(root, "help"); + assertEquals(0, help.exitCode(), help.output()); + assertTrue(help.output().contains("verify")); + + Path catalog = root.resolve("examples/catalog-demo"); + CommandResult list = run(root, "list", catalog.toString()); + assertEquals(0, list.exitCode(), list.output()); + assertTrue(list.output().contains("demo/default")); + + Path output = temporaryDirectory.resolve("verify-output"); + CommandResult verify = run(root, "verify", catalog.toString(), "--output", output.toString()); + assertEquals(0, verify.exitCode(), verify.output()); + assertTrue(Files.isRegularFile(output.resolve("summary.json"))); + assertTrue(Files.isRegularFile(output.resolve("demo/default/preview.png"))); + } + + private static CommandResult run(Path root, String... arguments) throws Exception { + boolean windows = System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win"); + java.util.List command = new java.util.ArrayList<>(); + if (windows) { + command.add("cmd.exe"); + command.add("/d"); + command.add("/c"); + command.add(root.resolve("preview.bat").toString()); + } else { + command.add("sh"); + command.add(root.resolve("preview.sh").toString()); + } + command.addAll(java.util.List.of(arguments)); + ProcessBuilder builder = new ProcessBuilder(command).directory(root.toFile()).redirectErrorStream(true); + builder.environment().put("JAVA_HOME", System.getProperty("java.home")); + Process process = builder.start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (!process.waitFor(60, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new AssertionError("Release command timed out: " + command); + } + return new CommandResult(process.exitValue(), output); + } + + private static void extract(Path archive, Path destination) throws IOException { + Files.createDirectories(destination); + try (ZipFile zip = new ZipFile(archive.toFile())) { + for (var entry : zip.stream().toList()) { + Path target = destination.resolve(entry.getName()).normalize(); + if (!target.startsWith(destination)) throw new IOException("Unsafe archive entry: " + entry.getName()); + if (entry.isDirectory()) { + Files.createDirectories(target); + } else { + Files.createDirectories(target.getParent()); + try (var input = zip.getInputStream(entry)) { + Files.copy(input, target); + } + } + } + } + } + + private record CommandResult(int exitCode, String output) {} +} diff --git a/src/test/java/dev/modularui/preview/PreviewDrawContextTest.java b/src/test/java/dev/modularui/preview/PreviewDrawContextTest.java index 1625dda..1940e17 100644 --- a/src/test/java/dev/modularui/preview/PreviewDrawContextTest.java +++ b/src/test/java/dev/modularui/preview/PreviewDrawContextTest.java @@ -3,12 +3,20 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import dev.modularui.preview.assets.AssetResolver; import java.awt.Color; import java.awt.Graphics2D; import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; import java.util.stream.IntStream; +import javax.imageio.ImageIO; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.lwjgl.opengl.GL11; +import net.minecraft.util.ResourceLocation; class PreviewDrawContextTest { @@ -133,6 +141,65 @@ void keepsOpenGlLineWidthInFramebufferPixelsWhenGuiCoordinatesAreScaled() { assertEquals(1, paintedRows); } + @Test + void clipsScaledGuiCoordinatesToAnOpenGlFramebufferScissor() { + BufferedImage image = new BufferedImage(20, 20, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = image.createGraphics(); + graphics.scale(2, 2); + try { + PreviewDrawContext.run(graphics, image.getHeight(), () -> { + GL11.glEnable(GL11.GL_SCISSOR_TEST); + GL11.glScissor(4, 6, 8, 6); + PreviewDrawContext.drawRect(0, 0, 10, 10, Color.RED.getRGB()); + + GL11.glDisable(GL11.GL_SCISSOR_TEST); + PreviewDrawContext.drawRect(0, 0, 1, 1, Color.BLUE.getRGB()); + }); + } finally { + graphics.dispose(); + } + + assertEquals(Color.BLUE.getRGB(), image.getRGB(1, 1)); + assertEquals(0, image.getRGB(3, 10)); + assertEquals(Color.RED.getRGB(), image.getRGB(5, 10)); + assertEquals(0, image.getRGB(12, 10)); + assertEquals(0, image.getRGB(5, 7)); + assertEquals(0, image.getRGB(5, 14)); + } + + @Test + void repeatsBoundTexturesWhenOpenGlTextureWrappingIsEnabled(@TempDir Path assets) throws IOException { + Path texture = assets.resolve("assets/test/textures/tile.png"); + Files.createDirectories(texture.getParent()); + BufferedImage tile = new BufferedImage(2, 1, BufferedImage.TYPE_INT_ARGB); + tile.setRGB(0, 0, Color.RED.getRGB()); + tile.setRGB(1, 0, Color.BLUE.getRGB()); + ImageIO.write(tile, "png", texture.toFile()); + + BufferedImage image = new BufferedImage(8, 2, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = image.createGraphics(); + try { + PreviewDrawContext.run(graphics, new AssetResolver(List.of(assets)), image.getHeight(), () -> { + PreviewDrawContext.bindTexture(new ResourceLocation("test", "textures/tile.png")); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL11.GL_REPEAT); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL11.GL_REPEAT); + PreviewDrawContext.drawVertices( + GL11.GL_QUADS, + new double[] { 0, 0, 0, 8, 0, 0, 8, 2, 0, 0, 2, 0 }, + new double[] { 0, 0, 4, 0, 4, 1, 0, 1 }, + new int[] { Color.WHITE.getRGB(), Color.WHITE.getRGB(), Color.WHITE.getRGB(), + Color.WHITE.getRGB() }, + 4); + }); + } finally { + graphics.dispose(); + } + + for (int x = 0; x < image.getWidth(); x++) { + assertEquals(x % 2 == 0 ? Color.RED.getRGB() : Color.BLUE.getRGB(), image.getRGB(x, 0)); + } + } + private static void drawQuad(int left, int top, int right, int bottom, int color) { PreviewDrawContext.drawVertices( GL11.GL_QUADS, diff --git a/src/test/java/dev/modularui/preview/PreviewEngineTest.java b/src/test/java/dev/modularui/preview/PreviewEngineTest.java index ea32393..b480077 100644 --- a/src/test/java/dev/modularui/preview/PreviewEngineTest.java +++ b/src/test/java/dev/modularui/preview/PreviewEngineTest.java @@ -174,7 +174,7 @@ void opensARealModularUiPanelWithALinkedSyncHandlerAndUsesItsLayoutAndArtifact() } @Test - void sharesInstalledTranslationsWithProductionRuntimeClasses() throws Exception { + void sharesInstalledTranslationsWithProductionRuntimeClassesThroughoutTheSession() throws Exception { Path projectRoot = Files.createDirectories(temporaryDirectory.resolve("translated-panel-preview")); Path libraries = Files.createDirectories(projectRoot.resolve("libs")); writeClassJar( @@ -193,6 +193,7 @@ void sharesInstalledTranslationsWithProductionRuntimeClasses() throws Exception package example; import com.cleanroommc.modularui.screen.ModularPanel; + import com.cleanroommc.modularui.widgets.ButtonWidget; import dev.modularui.preview.PreviewEntrypoint; import net.minecraft.util.StatCollector; @@ -203,16 +204,38 @@ public Object createPanel(PreviewEntrypoint.Context context) { if (!"Translated label".equals(label)) { throw new IllegalStateException("Production runtime did not receive preview translations: " + label); } - return ModularPanel.defaultPanel("translated_panel", 176, 220); + return ModularPanel.defaultPanel("translated_panel", 176, 220) + .child(new TranslatedButton().pos(68, 100).size(40, 20)); + } + + private static final class TranslatedButton extends ButtonWidget { + private TranslatedButton() { + onMousePressed(mouseButton -> { + String label = StatCollector.translateToLocal("example.preview.label"); + if (!"Translated label".equals(label)) { + throw new IllegalStateException( + "Live session interaction did not receive preview translations: " + label); + } + return true; + }); + } } } """); - try (PreviewSession ignored = PreviewEngine.open( + try (PreviewSession session = PreviewEngine.open( projectRoot, "example.TranslatedPanelPreview", new PreviewScreen(800, 600, 1))) { - // Opening the production panel proves that its StatCollector sees the installed language map. + WidgetBounds button = session.widgets() + .stream() + .filter(widget -> widget.type() + .equals("TranslatedButton")) + .findFirst() + .orElseThrow(); + session.moveMouse(button.screen().x() + button.screen().width() / 2, + button.screen().y() + button.screen().height() / 2); + assertTrue(session.click(MouseButton.LEFT)); } } @@ -228,7 +251,7 @@ void routesLocalMouseClicksThroughTheRealModularUiScreen() throws Exception { WidgetBounds button = session.widgets() .stream() .filter(widget -> widget.type() - .equals("ButtonWidget")) + .equals("DeferredButton")) .findFirst() .orElseThrow(); assertTrue(containsColor(session.render().image(), button.screen(), 0xFFFF5555)); @@ -268,7 +291,7 @@ void executesScriptedActionsInOneLiveSessionAndCapturesTheResult() throws Except assertTrue(containsColor(ImageIO.read(capture.resolve("preview.png").toFile()), new Bounds(380, 290, 40, 20), 0xFF55FF55)); assertTrue(Files.readString(capture.resolve("bounds.json")) - .contains("\"ButtonWidget\"")); + .contains("\"DeferredButton\"")); assertTrue(Files.readString(capture.resolve("actions.json")) .contains("\"handled\": true")); } @@ -453,27 +476,42 @@ public final class InteractivePanelPreview implements PreviewEntrypoint { @Override public Object createPanel(PreviewEntrypoint.Context context) { return ModularPanel.defaultPanel("interactive_panel", 176, 100) - .child(new ButtonWidget<>() + .child(new DeferredButton() .name("toggle") .pos(68, 40) .size(40, 20) - .onMousePressed(mouseButton -> { - color = mouseButton == 1 ? 0xFF5555FF : 0xFF55FF55; - return true; - }) - .onMouseReleased(mouseButton -> { - if (mouseButton == 1) color = 0xFFFFFF55; - return true; - }) - .onMouseScrolled((direction, amount) -> { - color = 0xFF55FFFF; - return true; - }) .child(new TextWidget<>(IKey.dynamic(() -> color == 0xFFFF5555 ? "OFF" : "ON")) .color(() -> color) .shadow(false) .coverChildren())); } + + private final class DeferredButton extends ButtonWidget { + private Integer pendingColor; + + private DeferredButton() { + onMousePressed(mouseButton -> { + pendingColor = mouseButton == 1 ? 0xFF5555FF : 0xFF55FF55; + return true; + }); + onMouseReleased(mouseButton -> { + if (mouseButton == 1) pendingColor = 0xFFFFFF55; + return true; + }); + onMouseScrolled((direction, amount) -> { + pendingColor = 0xFF55FFFF; + return true; + }); + } + + @Override + public void onUpdate() { + super.onUpdate(); + if (pendingColor == null) return; + color = pendingColor; + pendingColor = null; + } + } } """); } diff --git a/src/test/java/dev/modularui/preview/PreviewScenarioTest.java b/src/test/java/dev/modularui/preview/PreviewScenarioTest.java new file mode 100644 index 0000000..1446faf --- /dev/null +++ b/src/test/java/dev/modularui/preview/PreviewScenarioTest.java @@ -0,0 +1,70 @@ +package dev.modularui.preview; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class PreviewScenarioTest { + + @Test + void rejectsMalformedCatalogMetadataThroughThePublishedCatalogContract() { + IllegalArgumentException malformedId = assertThrows( + IllegalArgumentException.class, + () -> scenario("Machines/Running")); + assertTrue(malformedId.getMessage().contains("Invalid preview scenario ID")); + + PreviewScenario duplicate = scenario("machines/running"); + PreviewCatalog catalog = () -> List.of(duplicate, duplicate); + IllegalArgumentException duplicateId = assertThrows( + IllegalArgumentException.class, + catalog::validatedScenarios); + assertEquals("Duplicate preview scenario ID: machines/running", duplicateId.getMessage()); + } + + @Test + void rejectsActionScriptsThatEscapeThePreviewProject() { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> scenario("machines/running").actions("../outside.txt")); + + assertTrue(failure.getMessage().contains("must stay within the project")); + } + + @Test + void createsConciseProductionEntrypoints() { + Object panel = new Object(); + Object syncManager = new Object(); + PreviewEntrypoint entrypoint = PreviewEntrypoint.of( + PreviewScenarioTest.class, + context -> { + assertEquals(syncManager, context.panelSyncManager()); + return panel; + }); + + assertEquals(PreviewScenarioTest.class, entrypoint.previewedClass()); + assertEquals(panel, entrypoint.createPanel(new PreviewEntrypoint.Context(syncManager))); + } + + private static PreviewScenario scenario(String id) { + return PreviewScenario.define( + id, + "running machine", + "machines", + PreviewScenarioTest.class, + () -> new PreviewEntrypoint() { + + @Override + public Class previewedClass() { + return PreviewScenarioTest.class; + } + + @Override + public Object createPanel(Context context) { + throw new UnsupportedOperationException("Metadata tests do not create a panel"); + } + }); + } +} diff --git a/src/test/java/dev/modularui/preview/UiPreviewMainTest.java b/src/test/java/dev/modularui/preview/UiPreviewMainTest.java index c5b6ed7..f0df048 100644 --- a/src/test/java/dev/modularui/preview/UiPreviewMainTest.java +++ b/src/test/java/dev/modularui/preview/UiPreviewMainTest.java @@ -63,9 +63,11 @@ void helpListsEveryCommandAndUnknownOptionsFailBeforeOpeningAProject() { assertEquals(0, help); String usage = outputBytes.toString(StandardCharsets.UTF_8); assertTrue(usage.contains("init")); + assertTrue(usage.contains("list")); assertTrue(usage.contains("render")); assertTrue(usage.contains("open")); assertTrue(usage.contains("watch")); + assertTrue(usage.contains("doctor")); assertEquals("", errorBytes.toString(StandardCharsets.UTF_8)); outputBytes.reset(); @@ -100,6 +102,79 @@ void initializedProjectRendersThroughThePublishedCommand() { assertTrue(Files.isRegularFile(outputDirectory.resolve("bounds.json"))); } + @Test + void catalogProjectsListDeterministicallyAndRenderTheSelectedScenario() throws Exception { + Path project = temporaryDirectory.resolve("catalog-preview"); + Path outputDirectory = temporaryDirectory.resolve("catalog-output"); + writeCatalogProject(project); + ByteArrayOutputStream outputBytes = new ByteArrayOutputStream(); + ByteArrayOutputStream errorBytes = new ByteArrayOutputStream(); + PrintStream output = new PrintStream(outputBytes, true, StandardCharsets.UTF_8); + PrintStream error = new PrintStream(errorBytes, true, StandardCharsets.UTF_8); + + int listed = UiPreviewMain.run(new String[] { "list", project.toString() }, output, error); + + assertEquals(0, listed, errorBytes.toString(StandardCharsets.UTF_8)); + String listing = outputBytes.toString(StandardCharsets.UTF_8); + assertTrue(listing.indexOf("machines/empty") < listing.indexOf("machines/running")); + assertTrue(listing.contains("idle machine")); + assertTrue(listing.contains("running machine")); + + outputBytes.reset(); + int diagnosed = UiPreviewMain.run(new String[] { "doctor", project.toString() }, output, error); + assertEquals(0, diagnosed, errorBytes.toString(StandardCharsets.UTF_8)); + String diagnosis = outputBytes.toString(StandardCharsets.UTF_8); + assertTrue(diagnosis.contains("JDK: 25")); + assertTrue(diagnosis.contains("Scenarios: 2")); + + outputBytes.reset(); + int rendered = UiPreviewMain.run( + new String[] { + "render", project.toString(), "machines/running", "--output", outputDirectory.toString() + }, + output, + error); + + assertEquals(0, rendered, errorBytes.toString(StandardCharsets.UTF_8)); + String bounds = Files.readString(outputDirectory.resolve("bounds.json"), StandardCharsets.UTF_8); + assertTrue(bounds.contains("\"id\": \"machines/running\"")); + assertTrue(bounds.contains("\"family\": \"machines\"")); + assertTrue(bounds.contains("\"previewedClass\": \"example.CatalogPreview\"")); + assertTrue(bounds.contains("\"panelName\": \"running_machine\"")); + } + + @Test + void catalogProjectsRejectUnknownAndDuplicateScenarioIdsBeforeRendering() throws Exception { + Path project = temporaryDirectory.resolve("invalid-catalog-preview"); + writeCatalogProject(project); + ByteArrayOutputStream outputBytes = new ByteArrayOutputStream(); + ByteArrayOutputStream errorBytes = new ByteArrayOutputStream(); + PrintStream output = new PrintStream(outputBytes, true, StandardCharsets.UTF_8); + PrintStream error = new PrintStream(errorBytes, true, StandardCharsets.UTF_8); + + int unknown = UiPreviewMain.run( + new String[] { "render", project.toString(), "machines/missing" }, + output, + error); + + assertEquals(2, unknown); + assertTrue(errorBytes.toString(StandardCharsets.UTF_8).contains("Unknown preview scenario: machines/missing")); + + Path source = project.resolve("src/preview/java/example/CatalogPreview.java"); + Files.writeString( + source, + Files.readString(source, StandardCharsets.UTF_8) + .replace("\"machines/empty\"", "\"machines/running\""), + StandardCharsets.UTF_8); + outputBytes.reset(); + errorBytes.reset(); + + int duplicate = UiPreviewMain.run(new String[] { "list", project.toString() }, output, error); + + assertEquals(2, duplicate); + assertTrue(errorBytes.toString(StandardCharsets.UTF_8).contains("Duplicate preview scenario ID: machines/running")); + } + @Test void failedArtifactPublicationPreservesThePreviousPreview() throws Exception { Path project = temporaryDirectory.resolve("transactional-preview"); @@ -135,4 +210,62 @@ void failedArtifactPublicationPreservesThePreviousPreview() throws Exception { assertEquals(1, failed); assertArrayEquals(previousPreview, Files.readAllBytes(outputDirectory.resolve("preview.png"))); } + + private static void writeCatalogProject(Path project) throws Exception { + Files.createDirectories(project.resolve("src/preview/java/example")); + Files.writeString( + project.resolve("preview.properties"), + "preview.entrypoint=example.CatalogPreview\n" + + "screen.width=1920\n" + + "screen.height=1080\n" + + "gui.scale=auto\n" + + "screen.background=#101820\n"); + Files.writeString( + project.resolve("src/preview/java/example/CatalogPreview.java"), + """ + package example; + + import com.cleanroommc.modularui.screen.ModularPanel; + import dev.modularui.preview.PreviewCatalog; + import dev.modularui.preview.PreviewEntrypoint; + import dev.modularui.preview.PreviewScenario; + import java.util.List; + + public final class CatalogPreview implements PreviewCatalog { + @Override + public List scenarios() { + return List.of( + PreviewScenario.define( + "machines/running", + "running machine", + "machines", + CatalogPreview.class, + () -> panel("running_machine")) + .tags("default", "interaction") + .expectAssets("example:textures/gui/machine.png") + .actions("actions/running.txt"), + PreviewScenario.define( + "machines/empty", + "idle machine", + "machines", + CatalogPreview.class, + () -> panel("empty_machine"))); + } + + private static PreviewEntrypoint panel(String name) { + return new PreviewEntrypoint() { + @Override + public Class previewedClass() { + return CatalogPreview.class; + } + + @Override + public Object createPanel(Context context) { + return ModularPanel.defaultPanel(name, 176, 90); + } + }; + } + } + """); + } } diff --git a/src/test/java/dev/modularui/preview/VerificationCliTest.java b/src/test/java/dev/modularui/preview/VerificationCliTest.java new file mode 100644 index 0000000..5424e80 --- /dev/null +++ b/src/test/java/dev/modularui/preview/VerificationCliTest.java @@ -0,0 +1,184 @@ +package dev.modularui.preview; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class VerificationCliTest { + + @TempDir + Path temporaryDirectory; + + @Test + void verifiesCatalogsInIsolatedWorkersAndContinuesAfterCrashesTimeoutsAndMalformedResults() throws Exception { + Path project = temporaryDirectory.resolve("verification-preview"); + Path output = temporaryDirectory.resolve("verification-output"); + writeVerificationProject(project); + + RunResult family = run("verify", project.toString(), "healthy", "--output", output.toString()); + + assertEquals(0, family.exitCode(), family.error()); + assertTrue(Files.isRegularFile(output.resolve("healthy/default/preview.png"))); + String familySummary = Files.readString(output.resolve("summary.json"), StandardCharsets.UTF_8); + assertTrue(familySummary.contains("\"scenarioId\": \"healthy/default\"")); + assertFalse(familySummary.contains("failures/crash")); + + RunResult full = run( + "verify", + project.toString(), + "--full", + "--output", + output.toString(), + "--jobs", + "2", + "--timeout-default", + "1"); + + assertEquals(1, full.exitCode()); + String summary = Files.readString(output.resolve("summary.json"), StandardCharsets.UTF_8); + assertTrue(summary.contains("\"passed\": 1")); + assertTrue(summary.contains("\"failed\": 3")); + assertTrue(summary.contains("\"category\": \"timeout\"")); + assertTrue(summary.contains("\"category\": \"render_error\"")); + assertTrue(Files.isRegularFile(output.resolve("healthy/default/preview.png"))); + assertTrue(Files.isRegularFile(output.resolve("healthy/default/actions.json"))); + assertTrue(Files.isRegularFile(output.resolve("healthy/default/captures/clicked/preview.png"))); + assertTrue(Files.isRegularFile(output.resolve("failures/crash/error.log"))); + assertTrue(Files.isRegularFile(output.resolve("failures/timeout/error.log"))); + assertTrue(Files.isRegularFile(output.resolve("failures/malformed/error.log"))); + + RunResult failedOnly = run( + "verify", + project.toString(), + "--failed", + "--output", + output.toString(), + "--jobs", + "2", + "--timeout-default", + "1"); + + assertEquals(1, failedOnly.exitCode()); + String failedSummary = Files.readString(output.resolve("summary.json"), StandardCharsets.UTF_8); + assertTrue(failedSummary.contains("\"selected\": 3")); + assertFalse(failedSummary.contains("\"scenarioId\": \"healthy/default\"")); + } + + @Test + void reportsCatalogCompilationFailuresWithoutStartingWorkers() throws Exception { + Path project = Files.createDirectories(temporaryDirectory.resolve("broken-preview")); + Path output = temporaryDirectory.resolve("broken-output"); + Path source = project.resolve("src/preview/java/example/BrokenCatalog.java"); + Files.createDirectories(source.getParent()); + Files.writeString(source, "package example; public class BrokenCatalog { this is not Java }"); + Files.writeString(project.resolve("preview.properties"), "preview.entrypoint=example.BrokenCatalog\n"); + + RunResult result = run("verify", project.toString(), "--output", output.toString()); + + assertEquals(1, result.exitCode(), result.error()); + String summary = Files.readString(output.resolve("summary.json"), StandardCharsets.UTF_8); + assertTrue(summary.contains("\"category\": \"compile_error\"")); + assertTrue(Files.isRegularFile(output.resolve("catalog/diagnostic.json"))); + } + + private static RunResult run(String... arguments) { + ByteArrayOutputStream outputBytes = new ByteArrayOutputStream(); + ByteArrayOutputStream errorBytes = new ByteArrayOutputStream(); + int exitCode = UiPreviewMain.run( + arguments, + new PrintStream(outputBytes, true, StandardCharsets.UTF_8), + new PrintStream(errorBytes, true, StandardCharsets.UTF_8)); + return new RunResult( + exitCode, + outputBytes.toString(StandardCharsets.UTF_8), + errorBytes.toString(StandardCharsets.UTF_8)); + } + + private static void writeVerificationProject(Path project) throws Exception { + Files.createDirectories(project.resolve("src/preview/java/example")); + Files.createDirectories(project.resolve("actions")); + Files.writeString(project.resolve("actions/healthy.txt"), "move-widget 0/0\nclick left\ncapture clicked\n"); + Files.writeString( + project.resolve("preview.properties"), + "preview.entrypoint=example.VerificationCatalog\n" + + "screen.width=800\n" + + "screen.height=600\n" + + "gui.scale=1\n" + + "screen.background=#101820\n"); + Files.writeString( + project.resolve("src/preview/java/example/VerificationCatalog.java"), + """ + package example; + + import com.cleanroommc.modularui.screen.ModularPanel; + import com.cleanroommc.modularui.widgets.ButtonWidget; + import dev.modularui.preview.PreviewCatalog; + import dev.modularui.preview.PreviewEntrypoint; + import dev.modularui.preview.PreviewScenario; + import java.nio.file.Files; + import java.nio.file.Path; + import java.util.List; + + public final class VerificationCatalog implements PreviewCatalog { + @Override + public List scenarios() { + return List.of( + scenario("failures/crash", "crashed worker", () -> Runtime.getRuntime().halt(17)), + scenario("failures/malformed", "malformed worker result", () -> { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + Files.writeString(Path.of("diagnostic.json"), "{broken"); + } catch (Exception ignored) {} + })); + throw new IllegalStateException("worker failed after installing its shutdown hook"); + }), + scenario("failures/timeout", "timed out worker", () -> { + try { + Thread.sleep(10_000L); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + }), + scenario("healthy/default", "healthy interactive panel", () -> {}) + .tags("default", "interaction") + .timeout(PreviewScenario.TimeoutCategory.EXTENDED) + .actions("actions/healthy.txt")); + } + + private static PreviewScenario scenario(String id, String description, Runnable beforePanel) { + return PreviewScenario.define( + id, + description, + id.substring(0, id.indexOf('/')), + VerificationCatalog.class, + () -> new PreviewEntrypoint() { + @Override + public Class previewedClass() { + return VerificationCatalog.class; + } + + @Override + public Object createPanel(Context context) { + beforePanel.run(); + return ModularPanel.defaultPanel(id.replace('/', '_'), 176, 90) + .child(new ButtonWidget<>() + .pos(68, 35) + .size(40, 20) + .onMousePressed(button -> true)); + } + }); + } + } + """); + } + + private record RunResult(int exitCode, String output, String error) {} +} diff --git a/src/test/java/dev/modularui/preview/project/ExternalProductionProjectTest.java b/src/test/java/dev/modularui/preview/project/ExternalProductionProjectTest.java new file mode 100644 index 0000000..cefd882 --- /dev/null +++ b/src/test/java/dev/modularui/preview/project/ExternalProductionProjectTest.java @@ -0,0 +1,136 @@ +package dev.modularui.preview.project; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.modularui.preview.PreviewEngine; +import dev.modularui.preview.PreviewScreen; +import dev.modularui.preview.PreviewSession; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ExternalProductionProjectTest { + + @TempDir + Path temporaryDirectory; + + @Test + void discoversCachesAndInvalidatesAnExternalProductionClasspathWithoutChangingItsBuildFiles() throws Exception { + Path production = Files.createDirectories(temporaryDirectory.resolve("production")); + Path productionSource = production.resolve("src/main/java/fixture/ProductionGui.java"); + Path productionClasses = production.resolve("build/classes/java/main"); + Files.createDirectories(productionSource.getParent()); + Files.createDirectories(productionClasses); + Files.writeString(productionSource, "package fixture; public final class ProductionGui {}\n"); + compile(productionSource, productionClasses, List.of()); + Path buildFile = Files.writeString(production.resolve("build.gradle.kts"), "plugins { java }\n"); + byte[] originalBuildFile = Files.readAllBytes(buildFile); + Path duplicateRuntime = production.resolve("libs/ModularUI2-duplicate.jar"); + Files.createDirectories(duplicateRuntime.getParent()); + Files.write(duplicateRuntime, new byte[0]); + writeFakeGradleWrapper(production, productionClasses, duplicateRuntime); + + Path preview = Files.createDirectories(temporaryDirectory.resolve("preview")); + Path previewSource = preview.resolve("external-src/fixture/ExternalPreview.java"); + Files.createDirectories(previewSource.getParent()); + Files.writeString( + preview.resolve("preview.properties"), + "preview.entrypoint=fixture.ExternalPreview\n" + + "preview.sources=external-src\n" + + "production.project=../production\n" + + "screen.width=800\n" + + "screen.height=600\n" + + "gui.scale=1\n" + + "screen.background=#101820\n"); + Files.writeString( + previewSource, + """ + package fixture; + + import com.cleanroommc.modularui.screen.ModularPanel; + import dev.modularui.preview.PreviewEntrypoint; + + public final class ExternalPreview implements PreviewEntrypoint { + @Override + public Class previewedClass() { + return ProductionGui.class; + } + + @Override + public Object createPanel(Context context) { + return ModularPanel.defaultPanel("external_production", 176, 90); + } + } + """); + + PreviewProject first = PreviewProject.open(preview); + assertEquals(preview.resolve("external-src"), first.previewSources()); + assertEquals(List.of(productionClasses), first.productionRuntime()); + assertFalse(first.runtimeArtifacts().contains(duplicateRuntime)); + assertEquals(1, wrapperRuns(production)); + + try (PreviewSession session = PreviewEngine.open( + preview, + "fixture.ExternalPreview", + new PreviewScreen(800, 600, 1))) { + assertEquals("fixture.ProductionGui", session.previewedClassName()); + assertEquals(productionClasses.toRealPath(), session.previewedCodeSource().toRealPath()); + } + assertEquals(1, wrapperRuns(production)); + assertEquals(List.of(productionClasses), PreviewProject.open(preview).productionRuntime()); + assertEquals(1, wrapperRuns(production)); + + Files.writeString(productionSource, "\n", StandardCharsets.UTF_8, java.nio.file.StandardOpenOption.APPEND); + + assertEquals(List.of(productionClasses), PreviewProject.open(preview).productionRuntime()); + assertEquals(2, wrapperRuns(production)); + assertTrue(java.util.Arrays.equals(originalBuildFile, Files.readAllBytes(buildFile))); + } + + private static void compile(Path source, Path output, List classpath) { + String joinedClasspath = classpath.stream() + .map(Path::toString) + .collect(java.util.stream.Collectors.joining(File.pathSeparator)); + int result = ToolProvider.getSystemJavaCompiler().run( + null, + null, + null, + "-classpath", + joinedClasspath, + "-d", + output.toString(), + source.toString()); + assertEquals(0, result); + } + + private static void writeFakeGradleWrapper(Path production, Path classes, Path duplicateRuntime) throws Exception { + if (System.getProperty("os.name").toLowerCase(java.util.Locale.ROOT).contains("win")) { + Files.writeString( + production.resolve("gradlew.bat"), + "@echo off\r\n" + + "echo run>>\"%~dp0wrapper-runs.txt\"\r\n" + + ">\"%MODULAR_UI_PREVIEW_CLASSPATH_OUTPUT%\" echo " + classes + "\r\n" + + ">>\"%MODULAR_UI_PREVIEW_CLASSPATH_OUTPUT%\" echo " + duplicateRuntime + "\r\n"); + return; + } + Path wrapper = Files.writeString( + production.resolve("gradlew"), + "#!/usr/bin/env sh\n" + + "printf 'run\\n' >> \"$(dirname \"$0\")/wrapper-runs.txt\"\n" + + "printf '%s\\n' '" + classes + "' '" + duplicateRuntime + + "' > \"$MODULAR_UI_PREVIEW_CLASSPATH_OUTPUT\"\n"); + wrapper.toFile().setExecutable(true); + } + + private static long wrapperRuns(Path production) throws Exception { + Path runs = production.resolve("wrapper-runs.txt"); + return Files.notExists(runs) ? 0 : Files.readAllLines(runs).size(); + } +} diff --git a/src/test/java/dev/modularui/preview/runtime/MinecraftRegistryShimTest.java b/src/test/java/dev/modularui/preview/runtime/MinecraftRegistryShimTest.java new file mode 100644 index 0000000..96ad358 --- /dev/null +++ b/src/test/java/dev/modularui/preview/runtime/MinecraftRegistryShimTest.java @@ -0,0 +1,53 @@ +package dev.modularui.preview.runtime; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.client.renderer.entity.RenderItem; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.util.RegistryNamespaced; +import org.junit.jupiter.api.Test; +import org.lwjgl.input.Keyboard; + +class MinecraftRegistryShimTest { + + @Test + void resolvesVanillaNamesFromForgeRegistryWrites() { + RegistryNamespaced registry = new RegistryNamespaced(); + Object grass = new Object(); + + registry.putObject("minecraft:grass", grass); + + assertSame(grass, registry.getObject("grass")); + assertTrue(registry.containsKey("grass")); + } + + @Test + void exposesItemStackDamageThroughTheForgeItemAbi() { + Item item = new Item(); + ItemStack stack = new ItemStack(item, 1, 3); + + assertEquals(3, item.getDamage(stack)); + item.setDamage(stack, 7); + assertEquals(7, stack.getItemDamage()); + } + + @Test + void exposesTheSharedMinecraftItemRenderer() { + assertSame(RenderItem.getInstance(), RenderItem.getInstance()); + } + + @Test + void resolvesItemIdsAndReportsNoHeadlessKeyboardModifiers() { + Item item = new Item(); + Item.itemRegistry.addObject(9000, "preview_test", item); + + assertEquals(9000, Item.getIdFromItem(item)); + assertFalse(GuiScreen.isShiftKeyDown()); + assertFalse(Keyboard.isKeyDown(42)); + } +}