diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/DynamicClasspathResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/DynamicClasspathResolver.java new file mode 100644 index 0000000..d94aca6 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/DynamicClasspathResolver.java @@ -0,0 +1,162 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import lombok.extern.slf4j.Slf4j; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +@Slf4j +public class DynamicClasspathResolver { + + public List resolveClasspath(Path projectRoot) { + log.info("Attempting dynamic classpath resolution for project at {}", projectRoot); + if (projectRoot == null || !Files.exists(projectRoot)) { + return Collections.emptyList(); + } + + if (Files.exists(projectRoot.resolve("pom.xml"))) { + return resolveMaven(projectRoot); + } else if (Files.exists(projectRoot.resolve("build.gradle")) || Files.exists(projectRoot.resolve("build.gradle.kts"))) { + return resolveGradle(projectRoot); + } + + log.info("No Maven or Gradle configuration found at {}. Proceeding without dynamic classpath.", projectRoot); + return Collections.emptyList(); + } + + protected List resolveMaven(Path projectRoot) { + log.info("Maven project detected. Resolving dependencies..."); + Path cpFile = null; + try { + cpFile = Files.createTempFile("state_machine_exporter_cp", ".txt"); + String mvnCmd = getCommand(projectRoot, "mvnw", "mvn"); + if (mvnCmd == null) { + log.warn("Maven executable not found."); + return Collections.emptyList(); + } + + ProcessBuilder pb = new ProcessBuilder( + mvnCmd, + "-q", + "dependency:build-classpath", + "-DincludeScope=compile", + "-Dmdep.outputFile=" + cpFile.toAbsolutePath().toString() + ); + pb.directory(projectRoot.toFile()); + + runProcess(pb); + + if (Files.exists(cpFile)) { + String cpString = Files.readString(cpFile).trim(); + if (!cpString.isEmpty()) { + return Arrays.asList(cpString.split(File.pathSeparator)); + } + } + } catch (Exception e) { + log.error("Failed to dynamically resolve Maven classpath", e); + } finally { + if (cpFile != null) { + try { + Files.deleteIfExists(cpFile); + } catch (IOException ignored) {} + } + } + return Collections.emptyList(); + } + + protected List resolveGradle(Path projectRoot) { + log.info("Gradle project detected. Resolving dependencies..."); + Path initScript = null; + try { + initScript = Files.createTempFile("state_machine_exporter_init", ".gradle"); + String gradleCmd = getCommand(projectRoot, "gradlew", "gradle"); + if (gradleCmd == null) { + log.warn("Gradle executable not found."); + return Collections.emptyList(); + } + + String scriptContent = """ + allprojects { + task smePrintClasspath { + doLast { + def cp = [] + if (configurations.findByName("compileClasspath")) { + cp = configurations.compileClasspath.files + } + println "SME_CLASSPATH_MARKER:" + cp.join(File.pathSeparator) + } + } + } + """; + Files.writeString(initScript, scriptContent); + + ProcessBuilder pb = new ProcessBuilder( + gradleCmd, + "-q", + "-I", initScript.toAbsolutePath().toString(), + "smePrintClasspath" + ); + pb.directory(projectRoot.toFile()); + + String output = runProcessAndGetOutput(pb); + for (String line : output.split("\\r?\\n")) { + if (line.startsWith("SME_CLASSPATH_MARKER:")) { + String cpString = line.substring("SME_CLASSPATH_MARKER:".length()).trim(); + if (!cpString.isEmpty()) { + return Arrays.asList(cpString.split(File.pathSeparator)); + } + } + } + } catch (Exception e) { + log.error("Failed to dynamically resolve Gradle classpath", e); + } finally { + if (initScript != null) { + try { + Files.deleteIfExists(initScript); + } catch (IOException ignored) {} + } + } + return Collections.emptyList(); + } + + private String getCommand(Path projectRoot, String wrapper, String systemBinary) { + Path wrapperUnix = projectRoot.resolve(wrapper); + Path wrapperWin = projectRoot.resolve(wrapper + ".bat"); + + boolean isWin = System.getProperty("os.name").toLowerCase().contains("win"); + + if (isWin && Files.exists(wrapperWin)) return wrapperWin.toAbsolutePath().toString(); + if (!isWin && Files.exists(wrapperUnix)) { + // Need absolute path like ./gradlew + return wrapperUnix.toAbsolutePath().toString(); + } + + return systemBinary; + } + + protected void runProcess(ProcessBuilder pb) throws IOException, InterruptedException { + Process p = pb.start(); + p.waitFor(); + } + + protected String runProcessAndGetOutput(ProcessBuilder pb) throws IOException, InterruptedException { + Process p = pb.start(); + StringBuilder out = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) { + String line; + while ((line = br.readLine()) != null) { + out.append(line).append("\n"); + } + } + p.waitFor(); + return out.toString(); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java index baec1fe..ee78606 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java @@ -172,40 +172,97 @@ public class GenericEventDetector { } private String extractSourceState(ASTNode node) { - ASTNode current = node.getParent(); + ASTNode current = node; while (current != null && !(current instanceof MethodDeclaration)) { - if (current instanceof IfStatement ifStmt) { - Expression expr = ifStmt.getExpression(); - String state = extractStateFromExpression(expr); - if (state != null) return state; - } else if (current instanceof SwitchCase switchCase) { - if (!switchCase.expressions().isEmpty()) { - return getSimpleNameString((Expression) switchCase.expressions().get(0)); + ASTNode parent = current.getParent(); + + // 1. Direct parent is IfStatement wrapper (e.g., if (state == X) { sm.sendEvent() }) + if (parent instanceof IfStatement ifStmt) { + // Only consider if our current node is in the 'then' or 'else' part, not the condition + if (ifStmt.getExpression() != current) { + Expression expr = ifStmt.getExpression(); + String state = extractStateFromExpression(expr); + if (state != null) return state; } - } else if (current instanceof SwitchStatement switchStmt) { - // If it's a switch block but we haven't hit a SwitchCase directly (AST hierarchy usually has SwitchCase as siblings of block statements) - // Actually, walking up from the node, we will hit the SwitchStatement, but we need the SwitchCase right before us in the block. - ASTNode parentBlock = current; - // It's complex to walk siblings. A simpler heuristic is to just use IfStatement and SwitchCase (if wrapped correctly). } - current = current.getParent(); + + // 2. Parent is a Block or SwitchStatement: perform sibling traversal + if (parent instanceof Block block) { + String state = extractStateFromSiblings(current, block.statements()); + if (state != null) return state; + } else if (parent instanceof SwitchStatement switchStmt) { + String state = extractStateFromSiblings(current, switchStmt.statements()); + if (state != null) return state; + } + + current = parent; } return null; } + private String extractStateFromSiblings(ASTNode currentNode, List statements) { + int index = statements.indexOf(currentNode); + if (index <= 0) return null; // No previous siblings + + // Walk backwards through siblings + for (int i = index - 1; i >= 0; i--) { + Object stmtObj = statements.get(i); + + if (stmtObj instanceof SwitchCase switchCase) { + if (!switchCase.expressions().isEmpty()) { + return getSimpleNameString((Expression) switchCase.expressions().get(0)); + } + } else if (stmtObj instanceof IfStatement ifStmt) { + // Guard clause detection: check if the 'then' block has a return/throw + if (isGuardClause(ifStmt.getThenStatement())) { + Expression expr = ifStmt.getExpression(); + // If it's a guard clause, it usually checks `state != EXPECTED`, so the true state *is* EXPECTED + String state = extractStateFromExpression(expr); + if (state != null) return state; + } + } + } + return null; + } + + private boolean isGuardClause(Statement thenStatement) { + if (thenStatement instanceof ReturnStatement || thenStatement instanceof ThrowStatement) { + return true; + } + if (thenStatement instanceof Block block) { + for (Object obj : block.statements()) { + if (obj instanceof ReturnStatement || obj instanceof ThrowStatement) { + return true; + } + } + } + return false; + } + private String extractStateFromExpression(Expression expr) { if (expr instanceof InfixExpression infix) { - if (infix.getOperator() == InfixExpression.Operator.EQUALS) { + if (infix.getOperator() == InfixExpression.Operator.EQUALS || infix.getOperator() == InfixExpression.Operator.NOT_EQUALS) { Expression left = infix.getLeftOperand(); Expression right = infix.getRightOperand(); - // Usually one is a method call like getState(), the other is an enum - if (left instanceof QualifiedName || left instanceof SimpleName && !(right instanceof SimpleName)) { + + // Ignore null checks entirely + if (left instanceof NullLiteral || right instanceof NullLiteral) { + return null; + } + + // Usually one is a method call like getState() or a variable like `state` + // and the other is the constant enum like `OrderState.PENDING` or `"PENDING"` + + // If one is a QualifiedName (enum constant) or StringLiteral, it's likely the state + if (left instanceof QualifiedName || left instanceof StringLiteral || left instanceof FieldAccess) { return getSimpleNameString(left); } - if (right instanceof QualifiedName || right instanceof SimpleName && !(left instanceof SimpleName)) { + if (right instanceof QualifiedName || right instanceof StringLiteral || right instanceof FieldAccess) { return getSimpleNameString(right); } - return getSimpleNameString(right); // Fallback + + // Fallback + return getSimpleNameString(right); } } else if (expr instanceof MethodInvocation mi) { if ("equals".equals(mi.getName().getIdentifier()) && !mi.arguments().isEmpty()) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java index 54fc1b2..a784f07 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java @@ -57,6 +57,9 @@ public class ExporterCommand implements Callable { @Option(names = {"--debug"}, description = "Enable diagnostic mode to show unresolved call chains and detailed analysis logging.", defaultValue = "false") private boolean debug; + @Option(names = {"--resolve-classpath"}, description = "Automatically run Maven/Gradle to resolve full classpath dependencies for perfect AST bindings.", defaultValue = "false") + private boolean resolveClasspath; + @Override public Integer call() throws Exception { var out = spec.commandLine().getOut(); @@ -87,7 +90,7 @@ public class ExporterCommand implements Callable { if (jsonFile != null) { exportService.runJsonExporter(jsonFile, outputDir, selectedFormats, profiles, eventFormat, stateFormat); } else { - exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, eventFormat, stateFormat); + exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, null, null, eventFormat, stateFormat, resolveClasspath); } out.println(CommandLine.Help.Ansi.AUTO.string("%n@|bold,green SUCCESS:|@ All state machines have been exported to " + outputDir.toAbsolutePath())); return 0; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java index 48bd6f6..a1b65a4 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java @@ -53,22 +53,22 @@ public class ExportService { public static final Set STATE_MACHINE_RETURN_TYPES = Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory"); public void runExporter(Path inputDir, Path outputDir, List selectedFormats, boolean renderChoicesAsDiamonds) throws IOException { - runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, Collections.emptyList(), click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn); + runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, Collections.emptyList(), null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false); } public void runExporter(Path inputDir, Path outputDir, List selectedFormats, boolean renderChoicesAsDiamonds, List activeProfiles) throws IOException { - runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn); + runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false); } public void runExporter(Path inputDir, Path outputDir, List selectedFormats, boolean renderChoicesAsDiamonds, List activeProfiles, Path flowsOverride, String machineFilter) throws IOException { - runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn); + runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false); } public void runExporter(Path inputDir, Path outputDir, List selectedFormats, boolean renderChoicesAsDiamonds, List activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException { - runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, eventFormat, stateFormat); + runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, eventFormat, stateFormat, false); } - public void runExporter(Path inputDir, Path outputDir, List selectedFormats, boolean renderChoicesAsDiamonds, List activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException { + public void runExporter(Path inputDir, Path outputDir, List selectedFormats, boolean renderChoicesAsDiamonds, List activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat, boolean resolveClasspath) throws IOException { CodebaseContext context = new CodebaseContext(); context.setActiveProfiles(activeProfiles); @@ -94,6 +94,18 @@ public class ExportService { .collect(Collectors.toList()); log.info("Setting JDT sourcepath: {}", sourcepaths); context.setSourcepath(sourcepaths); + + if (resolveClasspath) { + click.kamil.springstatemachineexporter.analysis.service.DynamicClasspathResolver classpathResolver = new click.kamil.springstatemachineexporter.analysis.service.DynamicClasspathResolver(); + List dynamicClasspath = classpathResolver.resolveClasspath(projectRoot); + if (!dynamicClasspath.isEmpty()) { + context.setClasspath(dynamicClasspath); + log.info("Injected {} external JARs into JDT ASTParser", dynamicClasspath.size()); + } + } else { + log.info("Dynamic classpath resolution disabled. Use --resolve-classpath to enable."); + } + context.setResolveBindings(true); Path hintsFile = inputDir.resolve("hints.json"); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DynamicClasspathResolverTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DynamicClasspathResolverTest.java new file mode 100644 index 0000000..a637eed --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DynamicClasspathResolverTest.java @@ -0,0 +1,78 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Tag("dynamic_classpath_resolver") +class DynamicClasspathResolverTest { + + @Test + void shouldResolveMavenClasspathDynamically(@TempDir Path tempDir) throws IOException { + // Create dummy pom.xml + Files.createFile(tempDir.resolve("pom.xml")); + + // Mock the resolver so it doesn't actually run maven, but writes the expected output file + DynamicClasspathResolver resolver = new DynamicClasspathResolver() { + @Override + protected void runProcess(ProcessBuilder pb) throws IOException { + // Pretend maven executed and generated the cp.txt + String cp = "/fake/m2/repo/spring-core.jar" + File.pathSeparator + "/fake/m2/repo/spring-context.jar"; + + String outputFileArg = pb.command().stream() + .filter(arg -> arg.startsWith("-Dmdep.outputFile=")) + .findFirst() + .orElseThrow(); + Path outputFile = Path.of(outputFileArg.substring("-Dmdep.outputFile=".length())); + + Files.writeString(outputFile, cp); + } + }; + + List classpath = resolver.resolveClasspath(tempDir); + + assertThat(classpath).containsExactly( + "/fake/m2/repo/spring-core.jar", + "/fake/m2/repo/spring-context.jar" + ); + } + + @Test + void shouldResolveGradleClasspathDynamically(@TempDir Path tempDir) throws IOException { + // Create dummy build.gradle + Files.createFile(tempDir.resolve("build.gradle")); + + DynamicClasspathResolver resolver = new DynamicClasspathResolver() { + @Override + protected String runProcessAndGetOutput(ProcessBuilder pb) { + // Pretend gradle executed and printed the marker + String cp = "/fake/gradle/caches/spring-core.jar" + File.pathSeparator + "/fake/gradle/caches/spring-context.jar"; + return "Some noisy gradle output\n" + + "SME_CLASSPATH_MARKER:" + cp + "\n" + + "BUILD SUCCESSFUL\n"; + } + }; + + List classpath = resolver.resolveClasspath(tempDir); + + assertThat(classpath).containsExactly( + "/fake/gradle/caches/spring-core.jar", + "/fake/gradle/caches/spring-context.jar" + ); + } + + @Test + void shouldReturnEmptyListIfNoBuildFileFound(@TempDir Path tempDir) { + DynamicClasspathResolver resolver = new DynamicClasspathResolver(); + List classpath = resolver.resolveClasspath(tempDir); + assertThat(classpath).isEmpty(); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorControlFlowTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorControlFlowTest.java new file mode 100644 index 0000000..824c444 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorControlFlowTest.java @@ -0,0 +1,132 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Tag("control_flow_sibling_avoidance") +class GenericEventDetectorControlFlowTest { + + @Test + void shouldDetectStateFromDirectIfWrapper(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class OrderService { + private StateMachine sm; + public void processOrder(OrderState state) { + if (state == OrderState.PENDING) { + sm.sendEvent(OrderEvent.PAY); + } + } + } + + class StateMachine { + public void sendEvent(OrderEvent e) {} + } + + enum OrderState { PENDING } + enum OrderEvent { PAY } + """; + Files.writeString(tempDir.resolve("OrderService.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + GenericEventDetector detector = new GenericEventDetector(context, new ConstantResolver(), Collections.emptyList()); + List triggers = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) context.getTypeDeclaration("com.example.OrderService").getRoot()); + + assertThat(triggers).hasSize(1); + TriggerPoint tp = triggers.get(0); + assertThat(tp.getEvent()).isEqualTo("OrderEvent.PAY"); + assertThat(tp.getSourceState()).isEqualTo("PENDING"); + } + + @Test + void shouldDetectStateFromGuardClauseSibling(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class OrderService { + private StateMachine sm; + public void processOrder(OrderState state) { + if (state != OrderState.PENDING) { + return; + } + + sm.sendEvent(OrderEvent.PAY); + } + } + + class StateMachine { + public void sendEvent(OrderEvent e) {} + } + + enum OrderState { PENDING } + enum OrderEvent { PAY } + """; + Files.writeString(tempDir.resolve("OrderService.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + GenericEventDetector detector = new GenericEventDetector(context, new ConstantResolver(), Collections.emptyList()); + List triggers = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) context.getTypeDeclaration("com.example.OrderService").getRoot()); + + assertThat(triggers).hasSize(1); + TriggerPoint tp = triggers.get(0); + assertThat(tp.getEvent()).isEqualTo("OrderEvent.PAY"); + assertThat(tp.getSourceState()).isEqualTo("PENDING"); + } + + @Test + void shouldDetectStateFromSwitchCaseSibling(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class OrderService { + private StateMachine sm; + public void processOrder(OrderState state) { + switch (state) { + case PENDING: + sm.sendEvent(OrderEvent.PAY); + break; + case PAID: + sm.sendEvent(OrderEvent.SHIP); + break; + } + } + } + + class StateMachine { + public void sendEvent(OrderEvent e) {} + } + + enum OrderState { PENDING, PAID } + enum OrderEvent { PAY, SHIP } + """; + Files.writeString(tempDir.resolve("OrderService.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + GenericEventDetector detector = new GenericEventDetector(context, new ConstantResolver(), Collections.emptyList()); + List triggers = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) context.getTypeDeclaration("com.example.OrderService").getRoot()); + + assertThat(triggers).hasSize(2); + + TriggerPoint payTrigger = triggers.stream().filter(t -> t.getEvent().equals("OrderEvent.PAY")).findFirst().get(); + assertThat(payTrigger.getSourceState()).isEqualTo("PENDING"); + + TriggerPoint shipTrigger = triggers.stream().filter(t -> t.getEvent().equals("OrderEvent.SHIP")).findFirst().get(); + assertThat(shipTrigger.getSourceState()).isEqualTo("PAID"); + } +}