diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/AnalysisResult.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/AnalysisResult.java index c9f38f3..10544c0 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/AnalysisResult.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/AnalysisResult.java @@ -7,7 +7,6 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; -import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver; import click.kamil.springstatemachineexporter.model.Transition; import lombok.Builder; @@ -127,29 +126,9 @@ public class AnalysisResult { if (stateTypeFqn == null && eventTypeFqn == null) { return; } - StateMachineTypeResolver.MachineTypes machineTypes = - new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn); - MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes); - if (states != null) { - this.states = MachineEnumCanonicalizer.canonicalizeStates(states, stateTypeFqn); - } - if (startStates != null) { - startStates = MachineEnumCanonicalizer.canonicalizeStateLabels(startStates, stateTypeFqn); - } - if (endStates != null) { - endStates = MachineEnumCanonicalizer.canonicalizeStateLabels(endStates, stateTypeFqn); - } - if (metadata != null && metadata.getTriggers() != null) { - List canonicalTriggers = metadata.getTriggers().stream() - .map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes)) - .collect(java.util.stream.Collectors.toList()); - metadata = CodebaseMetadata.builder() - .triggers(canonicalTriggers) - .entryPoints(metadata.getEntryPoints()) - .callChains(metadata.getCallChains()) - .properties(metadata.getProperties()) - .build(); - } + click.kamil.springstatemachineexporter.analysis.service.AnalysisResultFinalizer.applyCanonicalization( + this, + new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn)); } private static TriggerPoint resolveTrigger( diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExportPipelineIdentifierConsistencyTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExportPipelineIdentifierConsistencyTest.java new file mode 100644 index 0000000..845f766 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExportPipelineIdentifierConsistencyTest.java @@ -0,0 +1,374 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; +import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; +import click.kamil.springstatemachineexporter.exporter.ExportOptions; +import click.kamil.springstatemachineexporter.exporter.JsonExporter; +import click.kamil.springstatemachineexporter.model.Event; +import click.kamil.springstatemachineexporter.model.State; +import click.kamil.springstatemachineexporter.model.Transition; +import click.kamil.springstatemachineexporter.service.ExportService; +import click.kamil.springstatemachineexporter.service.JsonImportService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression gate: after the full {@link ExportService} pipeline, enum-backed machines must not + * contain mixed short-form ({@code OrderState.NEW}) and package-canonical + * ({@code com.example.order.OrderState.NEW}) identifiers in the same export. + */ +class ExportPipelineIdentifierConsistencyTest { + + @Test + void jsonReExportShouldNotMixShortAndPackageCanonicalStateIdentifiers(@TempDir Path tempDir) throws Exception { + writeSampleProject(tempDir); + + TriggerPoint trigger = TriggerPoint.builder() + .event("OrderEvent.PAY") + .sourceState("OrderState.NEW") + .className("com.example.web.OrderController") + .methodName("pay") + .sourceFile("OrderController.java") + .polymorphicEvents(List.of("OrderEvent.PAY")) + .build(); + CallChain chain = CallChain.builder().triggerPoint(trigger).build(); + + AnalysisResult shortForm = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .states(Set.of( + State.of("OrderState.NEW", "OrderState.NEW"), + State.of("UNDEFINED", "com.example.order.OrderState.PAID"))) + .transitions(List.of( + shortTransition("OrderEvent.PAY", "OrderState.NEW", "OrderState.PAID"), + multiSourceTransition("OrderEvent.PAY", List.of("OrderState.NEW", "OrderState.PAID"), "OrderState.PAID"))) + .startStates(Set.of("OrderState.NEW")) + .endStates(Set.of("OrderState.PAID")) + .metadata(CodebaseMetadata.builder() + .triggers(List.of(trigger)) + .callChains(List.of(chain)) + .properties(Map.of("default", Map.of( + "app.event", "OrderEvent.PAY", + "app.state", "OrderState.NEW"))) + .build()) + .build(); + + Path jsonFile = tempDir.resolve("machine.json"); + Files.writeString(jsonFile, new JsonExporter().export(shortForm, ExportOptions.builder().build())); + + Path outputDir = tempDir.resolve("out"); + ExportService exportService = new ExportService(List.of(new JsonExporter())); + exportService.runJsonExporter(jsonFile, outputDir, List.of("json"), List.of("default")); + + AnalysisResult exported = new JsonImportService().importAnalysisResult( + outputDir.resolve("com.example.config.OrderStateMachineConfiguration") + .resolve("com.example.config.OrderStateMachineConfiguration.json")); + + assertNoMixedEnumIdentifiers(exported); + assertThat(exported.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNotEmpty(); + } + + @Test + void astExportShouldNotMixShortAndPackageCanonicalStateIdentifiers(@TempDir Path tempDir) throws Exception { + Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise"); + Path outputDir = tempDir.resolve("enterprise-out"); + + ExportService exportService = new ExportService(List.of(new JsonExporter())); + exportService.runExporter(enterprise, outputDir, List.of("json"), true, List.of()); + + ObjectMapper mapper = new ObjectMapper(); + try (var dirs = Files.list(outputDir)) { + List machineDirs = dirs.filter(Files::isDirectory).toList(); + assertThat(machineDirs).isNotEmpty(); + for (Path machineDir : machineDirs) { + Path json = machineDir.resolve(machineDir.getFileName() + ".json"); + JsonNode root = mapper.readTree(json.toFile()); + if (!root.hasNonNull("stateTypeFqn") || !root.get("stateTypeFqn").asText().contains(".")) { + continue; + } + AnalysisResult result = new JsonImportService().importAnalysisResult(json); + assertNoMixedEnumIdentifiers(result); + } + } + } + + @Test + void propertyResolutionShouldCanonicalizeCallChainTriggersBeforeRelink(@TempDir Path tempDir) throws Exception { + writeSampleProject(tempDir); + + TriggerPoint trigger = TriggerPoint.builder() + .event("${app.event}") + .sourceState("${app.state}") + .className("com.example.web.OrderController") + .methodName("pay") + .sourceFile("OrderController.java") + .polymorphicEvents(List.of("${app.event}")) + .eventTypeFqn("com.example.order.OrderEvent") + .stateTypeFqn("com.example.order.OrderState") + .build(); + CallChain chain = CallChain.builder() + .triggerPoint(trigger) + .matchedTransitions(List.of(MatchedTransition.builder() + .event("OrderEvent.PAY") + .sourceState("OrderState.NEW") + .targetState("OrderState.PAID") + .build())) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .transitions(List.of(shortTransition("OrderEvent.PAY", "OrderState.NEW", "OrderState.PAID"))) + .metadata(CodebaseMetadata.builder() + .triggers(List.of(trigger)) + .callChains(List.of(chain)) + .properties(Map.of("default", Map.of( + "app.event", "OrderEvent.PAY", + "app.state", "OrderState.NEW"))) + .build()) + .build(); + + result.applyResolution(Map.of("app.event", "OrderEvent.PAY", "app.state", "OrderState.NEW")); + + TriggerPoint chainTrigger = result.getMetadata().getCallChains().get(0).getTriggerPoint(); + assertThat(chainTrigger.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY"); + assertThat(chainTrigger.getSourceState()).isEqualTo("com.example.order.OrderState.NEW"); + assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getSourceState()) + .isEqualTo("com.example.order.OrderState.NEW"); + assertNoMixedEnumIdentifiers(result); + } + + static void assertNoMixedEnumIdentifiers(AnalysisResult result) { + String stateTypeFqn = result.getStateTypeFqn(); + String eventTypeFqn = result.getEventTypeFqn(); + if (stateTypeFqn == null || !stateTypeFqn.contains(".") + || MachineEnumCanonicalizer.isStringOrPrimitiveType(stateTypeFqn)) { + return; + } + if (eventTypeFqn == null || !eventTypeFqn.contains(".") + || MachineEnumCanonicalizer.isStringOrPrimitiveType(eventTypeFqn)) { + return; + } + + Set stateIds = collectStateIdentifiers(result); + Set eventIds = collectEventIdentifiers(result); + + assertCanonicalSet(stateIds, stateTypeFqn, "state"); + assertCanonicalSet(eventIds, eventTypeFqn, "event"); + assertNoShortFormWhenPackageFormPresent(stateIds, stateTypeFqn); + assertNoShortFormWhenPackageFormPresent(eventIds, eventTypeFqn); + } + + private static void assertCanonicalSet(Set identifiers, String enumTypeFqn, String label) { + for (String id : identifiers) { + if (id == null || id.isBlank() || id.startsWith("<") || id.startsWith("\"")) { + continue; + } + if (!MachineEnumCanonicalizer.isMachineEnumReference(id, enumTypeFqn)) { + continue; + } + assertThat(id) + .as("%s identifier must be package-canonical: %s", label, id) + .isEqualTo(MachineEnumCanonicalizer.canonicalizeLabel(id, enumTypeFqn)); + } + } + + private static void assertNoShortFormWhenPackageFormPresent(Set identifiers, String enumTypeFqn) { + Set packageForms = new HashSet<>(); + Set shortForms = new HashSet<>(); + for (String id : identifiers) { + if (id == null || !MachineEnumCanonicalizer.isMachineEnumReference(id, enumTypeFqn)) { + continue; + } + if (id.startsWith(enumTypeFqn + ".")) { + packageForms.add(id); + String fn = fnForm(id); + if (fn != null) { + shortForms.add(fn); + } + } else { + shortForms.add(id); + } + } + for (String pkg : packageForms) { + String fn = fnForm(pkg); + assertThat(identifiers) + .as("mixed short/package identifiers for %s", pkg) + .doesNotContain(fn); + } + assertThat(shortForms.stream().filter(id -> !id.startsWith(enumTypeFqn + ".")).toList()) + .as("unexpected bare short forms while package forms exist: %s vs %s", shortForms, packageForms) + .isEmpty(); + } + + private static String fnForm(String identifier) { + if (identifier == null || identifier.isBlank()) { + return identifier; + } + int lastDot = identifier.lastIndexOf('.'); + if (lastDot <= 0) { + return identifier; + } + int prevDot = identifier.lastIndexOf('.', lastDot - 1); + return prevDot > 0 ? identifier.substring(prevDot + 1) : identifier; + } + + private static Set collectStateIdentifiers(AnalysisResult result) { + Set ids = new LinkedHashSet<>(); + if (result.getStates() != null) { + result.getStates().forEach(s -> addIfPresent(ids, s.fullIdentifier())); + } + if (result.getStartStates() != null) { + ids.addAll(result.getStartStates()); + } + if (result.getEndStates() != null) { + ids.addAll(result.getEndStates()); + } + if (result.getTransitions() != null) { + for (Transition t : result.getTransitions()) { + addStates(ids, t.getSourceStates()); + addStates(ids, t.getTargetStates()); + } + } + if (result.getMetadata() != null) { + if (result.getMetadata().getTriggers() != null) { + for (TriggerPoint trigger : result.getMetadata().getTriggers()) { + addIfPresent(ids, trigger.getSourceState()); + } + } + if (result.getMetadata().getCallChains() != null) { + for (CallChain chain : result.getMetadata().getCallChains()) { + if (chain.getTriggerPoint() != null) { + addIfPresent(ids, chain.getTriggerPoint().getSourceState()); + } + if (chain.getMatchedTransitions() != null) { + for (MatchedTransition mt : chain.getMatchedTransitions()) { + addIfPresent(ids, mt.getSourceState()); + addIfPresent(ids, mt.getTargetState()); + } + } + } + } + } + ids.removeIf(id -> id == null || id.isBlank()); + return ids; + } + + private static Set collectEventIdentifiers(AnalysisResult result) { + Set ids = new LinkedHashSet<>(); + if (result.getTransitions() != null) { + for (Transition t : result.getTransitions()) { + if (t.getEvent() != null) { + addIfPresent(ids, t.getEvent().fullIdentifier()); + } + } + } + if (result.getMetadata() != null) { + if (result.getMetadata().getTriggers() != null) { + for (TriggerPoint trigger : result.getMetadata().getTriggers()) { + addIfPresent(ids, trigger.getEvent()); + if (trigger.getPolymorphicEvents() != null) { + ids.addAll(trigger.getPolymorphicEvents()); + } + } + } + if (result.getMetadata().getCallChains() != null) { + for (CallChain chain : result.getMetadata().getCallChains()) { + if (chain.getTriggerPoint() != null) { + addIfPresent(ids, chain.getTriggerPoint().getEvent()); + if (chain.getTriggerPoint().getPolymorphicEvents() != null) { + ids.addAll(chain.getTriggerPoint().getPolymorphicEvents()); + } + } + if (chain.getMatchedTransitions() != null) { + for (MatchedTransition mt : chain.getMatchedTransitions()) { + addIfPresent(ids, mt.getEvent()); + } + } + } + } + } + ids.removeIf(id -> id == null || id.isBlank()); + return ids; + } + + private static void addStates(Set ids, List states) { + if (states == null) { + return; + } + for (State state : states) { + addIfPresent(ids, state.fullIdentifier()); + } + } + + private static void addIfPresent(Set ids, String value) { + if (value != null && !value.isBlank()) { + ids.add(value); + } + } + + private static Transition shortTransition(String event, String source, String target) { + Transition transition = new Transition(); + transition.setEvent(Event.of(event, event)); + transition.setSourceStates(List.of(State.of(source, source))); + transition.setTargetStates(List.of(State.of(target, target))); + return transition; + } + + private static Transition multiSourceTransition(String event, List sources, String target) { + Transition transition = new Transition(); + transition.setEvent(Event.of(event, event)); + transition.setSourceStates(sources.stream().map(s -> State.of(s, s)).toList()); + transition.setTargetStates(List.of(State.of(target, target))); + return transition; + } + + private static void writeSampleProject(Path projectRoot) throws Exception { + Path javaRoot = projectRoot.resolve("src/main/java"); + Path orderPkg = javaRoot.resolve("com/example/order"); + Path configPkg = javaRoot.resolve("com/example/config"); + Files.createDirectories(orderPkg); + Files.createDirectories(configPkg); + Files.writeString(projectRoot.resolve("build.gradle"), "plugins { id 'java' }"); + Files.writeString(orderPkg.resolve("OrderState.java"), + "package com.example.order; public enum OrderState { NEW, PAID }"); + Files.writeString(orderPkg.resolve("OrderEvent.java"), + "package com.example.order; public enum OrderEvent { PAY }"); + Files.writeString(configPkg.resolve("OrderStateMachineConfiguration.java"), + """ + package com.example.config; + import com.example.order.OrderEvent; + import com.example.order.OrderState; + import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter; + public class OrderStateMachineConfiguration + extends EnumStateMachineConfigurerAdapter { + } + """); + } + + private static Path findProjectRoot() { + Path current = Path.of(".").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("settings.gradle"))) { + current = current.getParent(); + } + return current; + } +} diff --git a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java new file mode 100644 index 0000000..9d365e6 --- /dev/null +++ b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java @@ -0,0 +1,179 @@ +package click.kamil.springstatemachineexporter.html; + +import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; +import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.exporter.ExportOptions; +import click.kamil.springstatemachineexporter.exporter.JsonExporter; +import click.kamil.springstatemachineexporter.html.exporter.HtmlExporter; +import click.kamil.springstatemachineexporter.model.Event; +import click.kamil.springstatemachineexporter.model.State; +import click.kamil.springstatemachineexporter.model.Transition; +import click.kamil.springstatemachineexporter.service.ExportService; +import click.kamil.springstatemachineexporter.service.JsonImportService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end HTML link-key consistency: SVG {@code #link_*} anchors must align with + * {@code matchedTransitions} produced by the finalized export pipeline. + */ +class HtmlTransitionLinkConsistencyTest { + + @Test + void jsonReExportHtmlShouldContainLinkKeysForEveryMatchedTransition(@TempDir Path tempDir) throws Exception { + writeSampleProject(tempDir); + + TriggerPoint trigger = TriggerPoint.builder() + .event("OrderEvent.PAY") + .sourceState("OrderState.NEW") + .className("com.example.web.OrderController") + .methodName("pay") + .sourceFile("OrderController.java") + .polymorphicEvents(List.of("OrderEvent.PAY")) + .build(); + CallChain chain = CallChain.builder().triggerPoint(trigger).build(); + + AnalysisResult shortForm = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .transitions(List.of(shortTransition("OrderEvent.PAY", "OrderState.NEW", "OrderState.PAID"))) + .startStates(Set.of("OrderState.NEW")) + .endStates(Set.of("OrderState.PAID")) + .metadata(CodebaseMetadata.builder() + .triggers(List.of(trigger)) + .callChains(List.of(chain)) + .build()) + .build(); + + Path jsonFile = tempDir.resolve("machine.json"); + Files.writeString(jsonFile, new JsonExporter().export(shortForm, ExportOptions.builder().build())); + + Path outputDir = tempDir.resolve("out"); + ExportService exportService = new ExportService(List.of(new HtmlExporter(), new JsonExporter())); + exportService.runJsonExporter(jsonFile, outputDir, List.of("html", "json")); + + Path machineDir = outputDir.resolve("com.example.config.OrderStateMachineConfiguration"); + Path htmlFile = machineDir.resolve("com.example.config.OrderStateMachineConfiguration.html"); + Path finalizedJson = machineDir.resolve("com.example.config.OrderStateMachineConfiguration.json"); + assertThat(htmlFile).exists(); + + String html = Files.readString(htmlFile); + AnalysisResult finalized = new JsonImportService().importAnalysisResult(finalizedJson); + + List matched = finalized.getMetadata().getCallChains().stream() + .flatMap(c -> c.getMatchedTransitions() == null ? java.util.stream.Stream.empty() + : c.getMatchedTransitions().stream()) + .toList(); + assertThat(matched).isNotEmpty(); + + for (MatchedTransition mt : matched) { + String linkKey = buildLinkKey(mt.getSourceState(), mt.getEvent()); + assertThat(html) + .as("HTML must contain SVG link for matched transition %s -> %s", mt.getSourceState(), mt.getEvent()) + .contains("#link_" + linkKey); + } + + assertThat(html).contains("OrderState_NEW__OrderEvent_PAY"); + assertThat(html).contains("com.example.order.OrderEvent.PAY"); + } + + @Test + void enterpriseAstExportHtmlShouldContainTransitionLinkKeys(@TempDir Path tempDir) throws Exception { + Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise"); + Path outputDir = tempDir.resolve("enterprise-out"); + + ExportService exportService = new ExportService(List.of(new HtmlExporter())); + exportService.runExporter(enterprise, outputDir, List.of("html"), true, List.of(), null, + "OrderStateMachineConfiguration"); + + Path machineDir = outputDir.resolve("click.kamil.enterprise.machines.order.OrderStateMachineConfiguration"); + Path htmlFile = machineDir.resolve("click.kamil.enterprise.machines.order.OrderStateMachineConfiguration.html"); + assertThat(htmlFile).exists(); + + String html = Files.readString(htmlFile); + assertThat(html).contains("OrderState_NEW__OrderEvent_PAY"); + assertThat(html).contains("function buildLinkKey(source, event)"); + assertThat(html).contains("\"matchedTransitions\""); + } + + /** Mirrors {@code template.html} link-key derivation for regression checks. */ + private static String buildLinkKey(String source, String event) { + if (event == null || event.isBlank()) { + return ""; + } + String formattedEvent = formatForLinkId(event); + if (source == null || source.isBlank()) { + return "__" + normalize(formattedEvent); + } + return normalize(formatForLinkId(source)) + "__" + normalize(formattedEvent); + } + + private static String formatForLinkId(String identifier) { + if (identifier == null || identifier.isBlank()) { + return ""; + } + int lastDot = identifier.lastIndexOf('.'); + if (lastDot <= 0) { + return identifier; + } + int prevDot = identifier.lastIndexOf('.', lastDot - 1); + return prevDot > 0 ? identifier.substring(prevDot + 1) : identifier; + } + + private static String normalize(String s) { + if (s == null || s.isBlank()) { + return ""; + } + return s.replaceAll("[^a-zA-Z0-9]", "_"); + } + + private static Transition shortTransition(String event, String source, String target) { + Transition transition = new Transition(); + transition.setEvent(Event.of(event, event)); + transition.setSourceStates(List.of(State.of(source, source))); + transition.setTargetStates(List.of(State.of(target, target))); + return transition; + } + + private static void writeSampleProject(Path projectRoot) throws Exception { + Path javaRoot = projectRoot.resolve("src/main/java"); + Path orderPkg = javaRoot.resolve("com/example/order"); + Path configPkg = javaRoot.resolve("com/example/config"); + Files.createDirectories(orderPkg); + Files.createDirectories(configPkg); + Files.writeString(projectRoot.resolve("build.gradle"), "plugins { id 'java' }"); + Files.writeString(orderPkg.resolve("OrderState.java"), + "package com.example.order; public enum OrderState { NEW, PAID }"); + Files.writeString(orderPkg.resolve("OrderEvent.java"), + "package com.example.order; public enum OrderEvent { PAY }"); + Files.writeString(configPkg.resolve("OrderStateMachineConfiguration.java"), + """ + package com.example.config; + import com.example.order.OrderEvent; + import com.example.order.OrderState; + import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter; + public class OrderStateMachineConfiguration + extends EnumStateMachineConfigurerAdapter { + } + """); + } + + private static Path findProjectRoot() { + Path current = Path.of(".").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("settings.gradle"))) { + current = current.getParent(); + } + return current; + } +}