diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java new file mode 100644 index 0000000..95cc118 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java @@ -0,0 +1,96 @@ +package click.kamil.springstatemachineexporter.analysis.enricher; + +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.service.CodebaseIntelligenceProvider; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.model.State; +import click.kamil.springstatemachineexporter.model.Transition; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class TransitionLinkerEnricher implements AnalysisEnricher { + + @Override + public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) { + if (result.getMetadata() == null || result.getMetadata().getCallChains() == null) { + return; + } + + List updatedChains = new ArrayList<>(); + List stateMachineTransitions = result.getTransitions(); + + for (CallChain chain : result.getMetadata().getCallChains()) { + TriggerPoint tp = chain.getTriggerPoint(); + if (tp == null || tp.getEvent() == null) { + updatedChains.add(chain); + continue; + } + + String triggerEvent = simplify(tp.getEvent()); + String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null; + + List matched = new ArrayList<>(); + + for (Transition t : stateMachineTransitions) { + if (t.getEvent() != null) { + String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName(); + String smEvent = simplify(smEventRaw); + if (smEvent.equals(triggerEvent)) { + // Event matches. Check source state if provided + for (State smSourceState : t.getSourceStates()) { + String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName(); + String smSource = simplify(smSourceRaw); + if (triggerSource == null || triggerSource.equals(smSource)) { + for (State smTargetState : t.getTargetStates()) { + String sourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName(); + String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName(); + matched.add(MatchedTransition.builder() + .sourceState(sourceRaw) + .targetState(targetRaw) + .event(smEventRaw) + .build()); + } + } + } + } + } + } + + // Create a new CallChain with the matched transitions + CallChain updatedChain = CallChain.builder() + .entryPoint(chain.getEntryPoint()) + .methodChain(chain.getMethodChain()) + .triggerPoint(chain.getTriggerPoint()) + .contextMachineId(chain.getContextMachineId()) + .matchedTransitions(matched) + .build(); + + updatedChains.add(updatedChain); + } + + // Update the metadata with the new call chains + CodebaseMetadata updatedMetadata = CodebaseMetadata.builder() + .triggers(result.getMetadata().getTriggers()) + .entryPoints(result.getMetadata().getEntryPoints()) + .callChains(updatedChains) + .properties(result.getMetadata().getProperties()) + .build(); + + result.setMetadata(updatedMetadata); + } + + private String simplify(String name) { + if (name == null) return ""; + int dot = name.lastIndexOf('.'); + if (dot >= 0) { + return name.substring(dot + 1); + } + return name; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallChain.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallChain.java index 0f3fbed..2f53142 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallChain.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallChain.java @@ -15,4 +15,6 @@ public class CallChain { private final EntryPoint entryPoint; private final List methodChain; // e.g., ["Controller.submit", "Service.process", "Service.send"] private final TriggerPoint triggerPoint; + private final String contextMachineId; + private final List matchedTransitions; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/MatchedTransition.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/MatchedTransition.java new file mode 100644 index 0000000..b9fbef2 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/MatchedTransition.java @@ -0,0 +1,16 @@ +package click.kamil.springstatemachineexporter.analysis.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Builder; +import lombok.Data; +import lombok.extern.jackson.Jacksonized; + +@Data +@Builder +@Jacksonized +@JsonIgnoreProperties(ignoreUnknown = true) +public class MatchedTransition { + private final String sourceState; + private final String targetState; + private final String event; +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/TriggerPoint.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/TriggerPoint.java index 07bb330..ba0263e 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/TriggerPoint.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/TriggerPoint.java @@ -18,5 +18,6 @@ public class TriggerPoint { private final String sourceFile; private final String sourceModule; private final String stateMachineId; // Optional: to link to a specific SM instance + private final String sourceState; // Optional: if we can determine the expected current state private final int lineNumber; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java index 415e2ca..d99620d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java @@ -39,10 +39,12 @@ public class CallGraphBuilder { List path = findPath(startMethod, targetMethod, callGraph, new HashSet<>()); if (path != null) { TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph); + String contextMachineId = extractContextMachineId(path, callGraph); chains.add(CallChain.builder() .entryPoint(ep) .triggerPoint(resolvedTp) .methodChain(path) + .contextMachineId(contextMachineId) .build()); } } @@ -97,6 +99,24 @@ public class CallGraphBuilder { return tp; } + private String extractContextMachineId(List path, Map> callGraph) { + for (String node : path) { + List edges = callGraph.get(node); + if (edges != null) { + for (CallEdge edge : edges) { + String target = edge.getTargetMethod(); + if (target != null && (target.contains(".restore") || target.contains(".read"))) { + // Persister signatures usually like: restore(stateMachine, contextObj) + if (edge.getArguments().size() >= 2) { + return edge.getArguments().get(1); // The contextObj / machineId + } + } + } + } + } + return null; + } + private Map> buildCallGraph() { graph = new HashMap<>(); for (CompilationUnit cu : context.getCompilationUnits()) { @@ -131,13 +151,15 @@ public class CallGraphBuilder { String superFqn = context.getSuperclassFqn(td); if (superFqn != null) { TypeDeclaration superTd = context.getTypeDeclaration(superFqn); + String calledMethod = null; if (superTd != null) { - String calledMethod = resolveMethodInType(superTd, methodName); - if (calledMethod != null) { - List args = resolveArguments(node.arguments()); - graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args)); - } + calledMethod = resolveMethodInType(superTd, methodName); } + if (calledMethod == null) { + calledMethod = superFqn + "." + methodName; + } + List args = resolveArguments(node.arguments()); + graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args)); } } } @@ -153,8 +175,8 @@ public class CallGraphBuilder { for (Object argObj : astArguments) { Expression expr = (Expression) argObj; String val = constantResolver.resolve(expr, context); - if (val == null && expr instanceof SimpleName sn) { - val = sn.getIdentifier(); + if (val == null) { + val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc) } args.add(val); } @@ -217,22 +239,25 @@ public class CallGraphBuilder { private List findPath(String start, String target, Map> graph, Set visited) { if (start.equals(target)) return new ArrayList<>(List.of(start)); - if (!visited.add(start)) return null; + if (!visited.add(start)) return null; // Path-scoped cycle detection List neighbors = graph.get(start); if (neighbors != null) { for (CallEdge edge : neighbors) { String neighbor = edge.getTargetMethod(); if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) { + visited.remove(start); return new ArrayList<>(List.of(start, target)); } List path = findPath(neighbor, target, graph, visited); if (path != null) { path.add(0, start); + visited.remove(start); return path; } } } + visited.remove(start); return null; } 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 96d7043..5bfd7c8 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 @@ -33,7 +33,8 @@ public class GenericEventDetector { public boolean visit(MethodInvocation node) { String methodName = node.getName().getIdentifier(); - if ("sendEvent".equals(methodName)) { + if ("sendEvent".equals(methodName) || "sendEvents".equals(methodName) || + "sendEventCollect".equals(methodName) || "sendEventMono".equals(methodName)) { processSendEvent(node, cu, triggers); } @@ -124,8 +125,11 @@ public class GenericEventDetector { if (type == null) return null; + String sourceState = extractSourceState(node); + return TriggerPoint.builder() .event(eventValue) + .sourceState(sourceState) .className(context.getFqn(type)) .methodName(method != null ? method.getName().getIdentifier() : "initializer") .sourceFile(context.getRelativePath(context.getFqn(type))) @@ -133,15 +137,105 @@ public class GenericEventDetector { .build(); } + private String extractSourceState(ASTNode node) { + ASTNode current = node.getParent(); + 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)); + } + } 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(); + } + return null; + } + + private String extractStateFromExpression(Expression expr) { + if (expr instanceof InfixExpression infix) { + if (infix.getOperator() == InfixExpression.Operator.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)) { + return getSimpleNameString(left); + } + if (right instanceof QualifiedName || right instanceof SimpleName && !(left instanceof SimpleName)) { + return getSimpleNameString(right); + } + return getSimpleNameString(right); // Fallback + } + } else if (expr instanceof MethodInvocation mi) { + if ("equals".equals(mi.getName().getIdentifier()) && !mi.arguments().isEmpty()) { + return getSimpleNameString((Expression) mi.arguments().get(0)); + } + } + return null; + } + + private String getSimpleNameString(Expression expr) { + if (expr instanceof QualifiedName qn) { + return qn.getName().getIdentifier(); + } else if (expr instanceof FieldAccess fa) { + return fa.getName().getIdentifier(); + } else if (expr instanceof StringLiteral sl) { + return sl.getLiteralValue(); + } + return expr.toString(); + } + private String extractEventFromMessageBuilder(Expression expr) { + if (expr instanceof SimpleName sn) { + String varName = sn.getIdentifier(); + MethodDeclaration enclosingMethod = findEnclosingMethod(expr); + if (enclosingMethod != null) { + // Find variable declaration + final Expression[] initializer = new Expression[1]; + enclosingMethod.accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationFragment node) { + if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) { + initializer[0] = node.getInitializer(); + } + return super.visit(node); + } + @Override + public boolean visit(Assignment node) { + if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) { + initializer[0] = node.getRightHandSide(); + } + return super.visit(node); + } + }); + if (initializer[0] != null) { + return extractEventFromMessageBuilder(initializer[0]); // recursive + } + } + } + if (!(expr instanceof MethodInvocation mi)) return null; // Trace back chain: MessageBuilder.withPayload(event).setHeader(...).build() MethodInvocation current = mi; while (current != null) { String name = current.getName().getIdentifier(); - if ("withPayload".equals(name) && !current.arguments().isEmpty()) { + if (("withPayload".equals(name) || "just".equals(name)) && !current.arguments().isEmpty()) { Expression payloadExpr = (Expression) current.arguments().get(0); + + // If it's Mono.just(msg), where msg is a variable + if ("just".equals(name)) { + String extracted = extractEventFromMessageBuilder(payloadExpr); + if (extracted != null) return extracted; + } + String resolved = constantResolver.resolve(payloadExpr, context); if (resolved != null) return resolved; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/exporter/PlantUml.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/exporter/PlantUml.java index 441fc08..4a63866 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/exporter/PlantUml.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/exporter/PlantUml.java @@ -126,7 +126,7 @@ public class PlantUml implements StateMachineExporter { String label = buildLabel(t, options); if (options.isEmbedIdentifiers()) { String eventStr = t.getEvent() != null ? options.formatEvent(t.getEvent()) : null; - String linkId = eventStr != null && !eventStr.isBlank() ? "link_" + normalize(eventStr) : "link_anon_" + normalize(source) + "_" + normalize(target); + String linkId = eventStr != null && !eventStr.isBlank() ? "link_" + normalize(source) + "__" + normalize(eventStr) : "link_anon_" + normalize(source) + "_" + normalize(target); // Force a label even if empty to ensure the link group exists in SVG String displayLabel = label.isEmpty() ? " " : label; // Brackets [...] in the label break PlantUML link syntax [[url label]], 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 c746992..48bd6f6 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 @@ -44,7 +44,8 @@ public class ExportService { new TriggerEnricher(), new EntryPointEnricher(), new PropertyEnricher(), - new CallChainEnricher() + new CallChainEnricher(), + new click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher() ))); } @@ -212,6 +213,11 @@ public class ExportService { Set endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst); Set allStates = TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst); + if (allStates.isEmpty() && transitions.isEmpty()) { + log.info("Skipping empty state machine config: {}", className); + return; + } + AnalysisResult result = AnalysisResult.builder() .name(className) .states(allStates) @@ -243,6 +249,11 @@ public class ExportService { Set endStates = TransitionStateUtils.findEndStates(transitions, null); Set allStates = TransitionStateUtils.findAllStates(transitions, null, null); + if (allStates.isEmpty() && transitions.isEmpty()) { + log.info("Skipping empty state machine bean: {}", uniqueName); + return; + } + AnalysisResult result = AnalysisResult.builder() .name(uniqueName) .states(allStates) diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java new file mode 100644 index 0000000..b1c92b5 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java @@ -0,0 +1,121 @@ +package click.kamil.springstatemachineexporter.analysis.enricher; + +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.TriggerPoint; +import click.kamil.springstatemachineexporter.model.Event; +import click.kamil.springstatemachineexporter.model.State; +import click.kamil.springstatemachineexporter.model.Transition; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TransitionLinkerEnricherTest { + + private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher(); + + @Test + void shouldLinkTransitionByEventOnly() { + Transition t1 = new Transition(); + t1.setSourceStates(List.of(State.of("NEW", "NEW"))); + t1.setTargetStates(List.of(State.of("PAID", "PAID"))); + t1.setEvent(Event.of("PAY", "PAY")); + + Transition t2 = new Transition(); + t2.setSourceStates(List.of(State.of("FAILED", "FAILED"))); + t2.setTargetStates(List.of(State.of("PAID", "PAID"))); + t2.setEvent(Event.of("PAY", "PAY")); + + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder().event("PAY").build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .transitions(List.of(t1, t2)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, null, null); + + CallChain updatedChain = result.getMetadata().getCallChains().get(0); + assertThat(updatedChain.getMatchedTransitions()).hasSize(2); + assertThat(updatedChain.getMatchedTransitions()) + .extracting("sourceState") + .containsExactlyInAnyOrder("NEW", "FAILED"); + } + + @Test + void shouldLinkTransitionByEventAndSourceState() { + Transition t1 = new Transition(); + t1.setSourceStates(List.of(State.of("NEW", "NEW"))); + t1.setTargetStates(List.of(State.of("PAID", "PAID"))); + t1.setEvent(Event.of("PAY", "PAY")); + + Transition t2 = new Transition(); + t2.setSourceStates(List.of(State.of("FAILED", "FAILED"))); + t2.setTargetStates(List.of(State.of("PAID", "PAID"))); + t2.setEvent(Event.of("PAY", "PAY")); + + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder().event("PAY").sourceState("NEW").build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .transitions(List.of(t1, t2)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, null, null); + + CallChain updatedChain = result.getMetadata().getCallChains().get(0); + assertThat(updatedChain.getMatchedTransitions()).hasSize(1); + assertThat(updatedChain.getMatchedTransitions().get(0).getSourceState()).isEqualTo("NEW"); + assertThat(updatedChain.getMatchedTransitions().get(0).getTargetState()).isEqualTo("PAID"); + } + + @Test + void shouldLinkTransitionByFullIdentifier() { + Transition t1 = new Transition(); + t1.setSourceStates(List.of(State.of("\"NEW\"", "NEW"))); + t1.setTargetStates(List.of(State.of("\"PAID\"", "PAID"))); + // This simulates how Spring Config usually has Event as OrderEvents.PAY but its resolved string is "PAY_ORDER" + t1.setEvent(Event.of("OrderEvents.PAY", "PAY_ORDER")); + + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder().event("PAY_ORDER").sourceState("NEW").build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .transitions(List.of(t1)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, null, null); + + CallChain updatedChain = result.getMetadata().getCallChains().get(0); + assertThat(updatedChain.getMatchedTransitions()).hasSize(1); + assertThat(updatedChain.getMatchedTransitions().get(0).getSourceState()).isEqualTo("NEW"); + assertThat(updatedChain.getMatchedTransitions().get(0).getTargetState()).isEqualTo("PAID"); + assertThat(updatedChain.getMatchedTransitions().get(0).getEvent()).isEqualTo("PAY_ORDER"); + } + + @Test + void shouldHandleUnknownEvents() { + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder().event(null).build()) // Dynamic or unresolved event + .build(); + + AnalysisResult result = AnalysisResult.builder() + .transitions(List.of(new Transition())) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, null, null); + + CallChain updatedChain = result.getMetadata().getCallChains().get(0); + assertThat(updatedChain.getMatchedTransitions()).isNull(); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorTest.java new file mode 100644 index 0000000..d345aa5 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorTest.java @@ -0,0 +1,47 @@ +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.eclipse.jdt.core.dom.CompilationUnit; +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.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class GenericEventDetectorTest { + + @Test + void testMessageBuilderVariableAssignment(@TempDir Path tempDir) throws IOException { + Path dir = tempDir.resolve("com/example"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("MyService.java"), + "package com.example;\n" + + "import org.springframework.messaging.support.MessageBuilder;\n" + + "import org.springframework.messaging.Message;\n" + + "import org.springframework.statemachine.StateMachine;\n" + + "public class MyService {\n" + + " private StateMachine stateMachine;\n" + + " private void a(MyInterface myEvent) {\n" + + " Message msg = MessageBuilder.withPayload(myEvent).setHeader(\"k\", \"v\").build();\n" + + " stateMachine.sendEvent(Mono.just(msg));\n" + + " }\n" + + "}\n"); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + GenericEventDetector detector = new GenericEventDetector(context, new ConstantResolver(), List.of()); + CompilationUnit cu = context.getCompilationUnits().iterator().next(); + List triggers = detector.detect(cu); + + assertThat(triggers).hasSize(1); + assertThat(triggers.get(0).getEvent()).isEqualTo("myEvent"); + assertThat(triggers.get(0).getMethodName()).isEqualTo("a"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/service/ExportServiceEmptyStateTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/service/ExportServiceEmptyStateTest.java new file mode 100644 index 0000000..46ae7be --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/service/ExportServiceEmptyStateTest.java @@ -0,0 +1,55 @@ +package click.kamil.springstatemachineexporter.service; + +import click.kamil.springstatemachineexporter.exporter.StateMachineExporter; +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.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExportServiceEmptyStateTest { + + @TempDir + Path tempDir; + + @Test + void shouldSkipEmptyStateMachineConfigurations() throws Exception { + // Arrange + Path inputDir = tempDir.resolve("input"); + Files.createDirectories(inputDir); + Path outputDir = tempDir.resolve("output"); + Files.createDirectories(outputDir); + + String code = """ + package com.example; + import org.springframework.context.annotation.Configuration; + import org.springframework.statemachine.config.EnableStateMachine; + import org.springframework.statemachine.config.StateMachineConfigurerAdapter; + + @Configuration + @EnableStateMachine + public class EmptyStateMachineConfig extends StateMachineConfigurerAdapter { + // No configurers overridden, meaning 0 states and 0 transitions parsed + } + """; + + Files.writeString(inputDir.resolve("EmptyStateMachineConfig.java"), code); + + ExportService exportService = new ExportService(Collections.emptyList()); // No exporters needed, checking if it generates anything (it generates JSON natively) + + // Act + exportService.runExporter(inputDir, outputDir, List.of("plantuml"), true); + + // Assert + // The output directory should have no files created in it + long fileCount = Files.walk(outputDir) + .filter(Files::isRegularFile) + .count(); + + assertThat(fileCount).isZero(); + } +} diff --git a/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json index b2f324e..74b96ce 100644 --- a/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json @@ -7,6 +7,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/enterprise/api/SecurityInterceptor.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 17 }, { "event" : "PLACE_ORDER", @@ -15,6 +16,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/enterprise/service/OrderServiceImpl.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 16 }, { "event" : "CANCEL_ORDER", @@ -23,6 +25,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/enterprise/service/OrderServiceImpl.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 21 }, { "event" : "PAY_ORDER", @@ -31,6 +34,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/enterprise/service/ReactivePaymentService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 18 }, { "event" : "SHIP_ORDER", @@ -39,6 +43,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/enterprise/messaging/ShippingJmsListener.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 17 }, { "event" : "RETURN_ORDER", @@ -47,6 +52,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/enterprise/messaging/ReturnsRabbitListener.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 17 } ], "entryPoints" : [ { @@ -178,8 +184,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/enterprise/service/OrderServiceImpl.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 16 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "NEW", + "targetState" : "CHECK_AVAILABILITY", + "event" : "PLACE_ORDER" + } ] }, { "entryPoint" : { "type" : "REST", @@ -205,8 +218,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/enterprise/service/OrderServiceImpl.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 21 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "PAID", + "targetState" : "CANCELLED", + "event" : "CANCEL_ORDER" + } ] }, { "entryPoint" : { "type" : "CUSTOM", @@ -227,8 +247,11 @@ "sourceFile" : "src/main/java/click/kamil/examples/enterprise/api/SecurityInterceptor.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 17 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ ] }, { "entryPoint" : { "type" : "REST", @@ -254,8 +277,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/enterprise/service/ReactivePaymentService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 18 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "PENDING_PAYMENT", + "targetState" : "PAID", + "event" : "PAY_ORDER" + } ] }, { "entryPoint" : { "type" : "JMS", @@ -281,8 +311,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/enterprise/messaging/ShippingJmsListener.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 17 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "PAID", + "targetState" : "SHIPPED", + "event" : "SHIP_ORDER" + } ] }, { "entryPoint" : { "type" : "RABBIT", @@ -308,8 +345,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/enterprise/messaging/ReturnsRabbitListener.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 17 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "DELIVERED", + "targetState" : "RETURNED", + "event" : "RETURN_ORDER" + } ] } ], "properties" : { "default" : { } diff --git a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json index 6943c58..7afc96f 100644 --- a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json @@ -7,6 +7,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/OrderController.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 34 }, { "event" : "AUDIT_EVENT", @@ -15,6 +16,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/AuditInterceptor.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 16 }, { "event" : "EXTERNAL_TRIGGER", @@ -23,6 +25,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/OrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 19 }, { "event" : "SUBMIT_EVENT", @@ -31,6 +34,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/OrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 20 }, { "event" : "CANCEL_EVENT", @@ -39,6 +43,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/OrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 25 }, { "event" : "[LIFECYCLE:RESTORE]", @@ -47,6 +52,7 @@ "sourceFile" : null, "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 29 }, { "event" : "REACTIVE_EVENT", @@ -55,6 +61,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/ReactiveOrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 18 } ], "entryPoints" : [ { @@ -152,8 +159,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/OrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 19 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "START", + "targetState" : "PROCESSING", + "event" : "EXTERNAL_TRIGGER" + } ] }, { "entryPoint" : { "type" : "REST", @@ -175,8 +189,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/OrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 20 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "START", + "targetState" : "PROCESSING", + "event" : "SUBMIT_EVENT" + } ] }, { "entryPoint" : { "type" : "REST", @@ -198,8 +219,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/OrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 25 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "PROCESSING", + "targetState" : "CANCELLED", + "event" : "CANCEL_EVENT" + } ] }, { "entryPoint" : { "type" : "REST", @@ -221,8 +249,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/OrderController.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 34 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "PROCESSING", + "targetState" : "COMPLETED", + "event" : "FINISH" + } ] }, { "entryPoint" : { "type" : "REST", @@ -248,8 +283,11 @@ "sourceFile" : null, "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 29 - } + }, + "contextMachineId" : "orderId", + "matchedTransitions" : [ ] }, { "entryPoint" : { "type" : "REST", @@ -275,8 +313,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/ReactiveOrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 18 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "START", + "targetState" : "PROCESSING", + "event" : "REACTIVE_EVENT" + } ] }, { "entryPoint" : { "type" : "CUSTOM", @@ -297,8 +342,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/AuditInterceptor.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 16 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "START", + "targetState" : "START", + "event" : "AUDIT_EVENT" + } ] } ], "properties" : { "default" : { diff --git a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.json index eeab3fc..aa53aff 100644 --- a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.json @@ -7,6 +7,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/OrderController.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 34 }, { "event" : "AUDIT_EVENT", @@ -15,6 +16,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/AuditInterceptor.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 16 }, { "event" : "EXTERNAL_TRIGGER", @@ -23,6 +25,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/OrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 19 }, { "event" : "SUBMIT_EVENT", @@ -31,6 +34,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/OrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 20 }, { "event" : "CANCEL_EVENT", @@ -39,6 +43,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/OrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 25 }, { "event" : "[LIFECYCLE:RESTORE]", @@ -47,6 +52,7 @@ "sourceFile" : null, "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 29 }, { "event" : "REACTIVE_EVENT", @@ -55,6 +61,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/ReactiveOrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 18 } ], "entryPoints" : [ { @@ -152,8 +159,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/OrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 19 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "START", + "targetState" : "PROCESSING", + "event" : "EXTERNAL_TRIGGER" + } ] }, { "entryPoint" : { "type" : "REST", @@ -175,8 +189,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/OrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 20 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "START", + "targetState" : "PROCESSING", + "event" : "SUBMIT_EVENT" + } ] }, { "entryPoint" : { "type" : "REST", @@ -198,8 +219,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/OrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 25 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "PROCESSING", + "targetState" : "CANCELLED", + "event" : "CANCEL_EVENT" + } ] }, { "entryPoint" : { "type" : "REST", @@ -221,8 +249,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/OrderController.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 34 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "PROCESSING", + "targetState" : "COMPLETED", + "event" : "FINISH" + } ] }, { "entryPoint" : { "type" : "REST", @@ -248,8 +283,11 @@ "sourceFile" : null, "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 29 - } + }, + "contextMachineId" : "orderId", + "matchedTransitions" : [ ] }, { "entryPoint" : { "type" : "REST", @@ -275,8 +313,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/ReactiveOrderService.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 18 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "START", + "targetState" : "PROCESSING", + "event" : "REACTIVE_EVENT" + } ] }, { "entryPoint" : { "type" : "CUSTOM", @@ -297,8 +342,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/AuditInterceptor.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 16 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "START", + "targetState" : "START", + "event" : "AUDIT_EVENT" + } ] } ], "properties" : { "default" : { diff --git a/state_machine_exporter/src/test/resources/golden/InheritanceStateMachineConfig/InheritanceStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/InheritanceStateMachineConfig/InheritanceStateMachineConfig.json index 171a3dd..f5658b4 100644 --- a/state_machine_exporter/src/test/resources/golden/InheritanceStateMachineConfig/InheritanceStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/InheritanceStateMachineConfig/InheritanceStateMachineConfig.json @@ -7,6 +7,7 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/inheritance/service/ProcessingServiceImpl.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 15 } ], "entryPoints" : [ { @@ -53,8 +54,15 @@ "sourceFile" : "src/main/java/click/kamil/examples/statemachine/inheritance/service/ProcessingServiceImpl.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 15 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "START", + "targetState" : "WORKING", + "event" : "INHERITED_SUBMIT" + } ] } ], "properties" : { "default" : { } diff --git a/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.json b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.json index 78425f4..36c6321 100644 --- a/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.json +++ b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.json @@ -7,6 +7,7 @@ "sourceFile" : "core-module/src/main/java/click/kamil/maven/core/MavenOrderStateMachine.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 40 }, { "event" : "ORDER_EVENT", @@ -15,6 +16,7 @@ "sourceFile" : "core-module/src/main/java/click/kamil/maven/core/MavenOrderStateMachine.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 52 } ], "entryPoints" : [ { @@ -100,8 +102,15 @@ "sourceFile" : "core-module/src/main/java/click/kamil/maven/core/MavenOrderStateMachine.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 40 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "INIT", + "targetState" : "BUSY", + "event" : "SUBMIT" + } ] } ], "properties" : { "default" : { } diff --git a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.json index 8b0662f..5ba76a3 100644 --- a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.json @@ -7,6 +7,7 @@ "sourceFile" : "core-module/src/main/java/click/kamil/multi/core/OrderController.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 18 } ], "entryPoints" : [ { @@ -65,8 +66,15 @@ "sourceFile" : "core-module/src/main/java/click/kamil/multi/core/OrderController.java", "sourceModule" : null, "stateMachineId" : null, + "sourceState" : null, "lineNumber" : 18 - } + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "NEW", + "targetState" : "PROCESSING", + "event" : "SUBMIT" + } ] } ], "properties" : { "default" : { } diff --git a/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java b/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java index 0a4165b..44c0812 100644 --- a/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java +++ b/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java @@ -58,7 +58,20 @@ public class HtmlExporter implements StateMachineExporter { // Check if we should render metadata pane boolean hideMetadataProp = Boolean.getBoolean("html.hideMetadataPane"); - boolean hasEntryPoints = result.getMetadata() != null && result.getMetadata().getEntryPoints() != null && !result.getMetadata().getEntryPoints().isEmpty(); + boolean hasEntryPoints = false; + if (result.getMetadata() != null && result.getMetadata().getEntryPoints() != null && !result.getMetadata().getEntryPoints().isEmpty()) { + Set smEvents = result.getTransitions().stream() + .filter(t -> t.getEvent() != null) + .map(t -> { + String eventStr = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName(); + return eventStr == null ? null : eventStr.replaceAll("[^a-zA-Z0-9_]", "_"); + }) + .collect(Collectors.toSet()); + if (result.getMetadata().getCallChains() != null) { + hasEntryPoints = result.getMetadata().getCallChains().stream() + .anyMatch(c -> c.getMatchedTransitions() != null && !c.getMatchedTransitions().isEmpty()); + } + } boolean hasFlows = result.getFlows() != null && !result.getFlows().isEmpty(); boolean shouldRenderPane = !hideMetadataProp && options.isIncludeMetadataPane() && (hasEntryPoints || hasFlows); diff --git a/state_machine_exporter_html/src/main/resources/html/template.html b/state_machine_exporter_html/src/main/resources/html/template.html index 65db1a2..f8b42b0 100644 --- a/state_machine_exporter_html/src/main/resources/html/template.html +++ b/state_machine_exporter_html/src/main/resources/html/template.html @@ -327,14 +327,13 @@ const list = document.getElementById('ep-list'); if (!list) return; const entryPoints = metadata.metadata.entryPoints || []; - const machineEvents = new Set(metadata.transitions.map(t => t.event).filter(Boolean)); entryPoints.forEach(ep => { // Quality Filter: Hide if it doesn't trigger a machine event const chains = (metadata.metadata.callChains || []).filter(c => c.entryPoint.className === ep.className && c.entryPoint.methodName === ep.methodName && - machineEvents.has(c.triggerPoint.event) + c.matchedTransitions && c.matchedTransitions.length > 0 ); if (chains.length === 0) return; @@ -348,6 +347,22 @@
${ep.className}.${ep.methodName}
${sourceHint} `; + + // Add tags for matched events + let triggersHtml = '
Triggers: '; + const addedEvents = new Set(); + chains.forEach(c => { + c.matchedTransitions.forEach(t => { + const evName = t.event || ""; + if (!addedEvents.has(evName)) { + addedEvents.add(evName); + triggersHtml += `${evName} `; + } + }); + }); + triggersHtml += '
'; + card.innerHTML += triggersHtml; + card.onmouseenter = () => highlightEntryPoint(ep); card.onmouseleave = clearHighlights; list.appendChild(card); @@ -379,14 +394,21 @@ function highlightEntryPoint(ep) { clearHighlights(); - const machineEvents = new Set(metadata.transitions.map(t => t.event).filter(Boolean)); const chains = metadata.metadata.callChains || []; - const relevantEvents = chains - .filter(c => c.entryPoint.className === ep.className && c.entryPoint.methodName === ep.methodName && machineEvents.has(c.triggerPoint.event)) - .map(c => c.triggerPoint.event); + + let steps = []; + chains.forEach(c => { + if (c.entryPoint.className === ep.className && c.entryPoint.methodName === ep.methodName) { + if (c.matchedTransitions && c.matchedTransitions.length > 0) { + c.matchedTransitions.forEach(t => { + steps.push({ event: t.event, source: t.sourceState }); + }); + } + } + }); - if (relevantEvents.length === 0) return; - highlightEvents(relevantEvents); + if (steps.length === 0) return; + highlightEvents(steps); } function highlightFlow(flow) { @@ -404,19 +426,38 @@ }); steps.forEach(step => { - let linkId = ""; - if (step.includes("->")) { - // Named Choice Branch: "SRC->TGT" + let targetEvent = ""; + let targetSource = ""; + let targetAnon = ""; + + if (typeof step === 'object') { + targetEvent = normalize(step.event); + targetSource = normalize(step.source); + } else if (step.includes("->")) { const parts = step.split("->"); - linkId = "#link_anon_" + normalize(parts[0]) + "_" + normalize(parts[1]); + targetAnon = "#link_anon_" + normalize(parts[0]) + "_" + normalize(parts[1]); } else { - // Standard Event - linkId = "#link_" + normalize(step); + targetEvent = normalize(step); } svg.querySelectorAll('a').forEach(link => { const href = link.getAttribute('xlink:href') || link.getAttribute('href'); - if (href === linkId) { + if (!href) return; + + let matches = false; + if (targetAnon) { + matches = (href === targetAnon); + } else if (targetEvent) { + if (targetSource) { + // Exact match + matches = (href === "#link_" + targetSource + "__" + targetEvent); + } else { + // Match all sources for this event + matches = href.startsWith("#link_") && href.endsWith("__" + targetEvent); + } + } + + if (matches) { highlightLinkGroup(link); } }); @@ -481,20 +522,31 @@ const isAnon = linkId.startsWith('anon_'); let metaTrans = null; + let cleanEventName = linkId; if (isAnon) { metaTrans = transitions.find(t => !t.event && "anon_" + normalize(t.sourceStates[0].fullIdentifier) + "_" + normalize(t.targetStates[0].fullIdentifier) === linkId ); + cleanEventName = "Internal logic"; } else { - metaTrans = transitions.find(t => normalize(t.event) === linkId); + metaTrans = transitions.find(t => { + if (!t.event) return false; + const evStr = t.event.fullIdentifier || t.event.rawName || t.event; + const sourceStr = t.sourceStates && t.sourceStates.length > 0 ? (t.sourceStates[0].fullIdentifier || t.sourceStates[0].rawName) : ""; + const expectedLinkId = normalize(sourceStr) + "__" + normalize(evStr); + return expectedLinkId === linkId; + }); + if (metaTrans && metaTrans.event) { + cleanEventName = metaTrans.event.fullIdentifier || metaTrans.event.rawName || metaTrans.event; + } } if (metaTrans || isAnon) { link.style.cursor = 'pointer'; const setupTippy = (el) => { tippy(el, { - content: createTooltip(isAnon ? "Internal logic" : linkId, metaTrans), + content: createTooltip(cleanEventName, metaTrans), allowHTML: true, theme: 'modern', interactive: true, @@ -520,7 +572,13 @@ } function createTooltip(name, trans) { - const chains = (metadata.metadata.callChains || []).filter(c => normalize(c.triggerPoint.event) === name); + const chains = (metadata.metadata.callChains || []).filter(c => { + if (!c.matchedTransitions || c.matchedTransitions.length === 0) return false; + return c.matchedTransitions.some(t => { + const cEventStr = typeof t.event === 'object' ? (t.event.fullIdentifier || t.event.rawName || t.event) : t.event; + return normalize(cEventStr) === name; + }); + }); let html = `
diff --git a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlExporterTest.java b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlExporterTest.java index 561c98e..8ffaa97 100644 --- a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlExporterTest.java +++ b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlExporterTest.java @@ -81,8 +81,84 @@ public class HtmlExporterTest { // 7. Assertions - Surgical SVG Identification // Every event should be wrapped in an identifying hyperlink for robust JS selection - assertThat(html).contains("#link_CREATE"); - assertThat(html).contains("#link_ASSIGN"); - assertThat(html).contains("#link_CLOSE_TICKET"); + assertThat(html).contains("__CREATE"); + assertThat(html).contains("__ASSIGN"); + assertThat(html).contains("__CLOSE_TICKET"); + } + + @Test + void shouldRenderSidebarWhenMatchedTransitionsExist() throws IOException { + click.kamil.springstatemachineexporter.analysis.model.EntryPoint ep = click.kamil.springstatemachineexporter.analysis.model.EntryPoint.builder().name("Test").build(); + click.kamil.springstatemachineexporter.analysis.model.CallChain chain = click.kamil.springstatemachineexporter.analysis.model.CallChain.builder() + .matchedTransitions(List.of(click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder().event("TEST_EVENT").build())) + .build(); + click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata metadata = click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder() + .entryPoints(List.of(ep)) + .callChains(List.of(chain)) + .build(); + AnalysisResult result = AnalysisResult.builder() + .name("SidebarTest") + .transitions(List.of()) + .startStates(java.util.Collections.emptySet()) + .endStates(java.util.Collections.emptySet()) + .metadata(metadata) + .build(); + + HtmlExporter exporter = new HtmlExporter(); + String html = exporter.export(result, ExportOptions.builder().includeMetadataPane(true).build()); + + assertThat(html).contains("id=\"sidebar\""); + assertThat(html).contains("id=\"ep-list\""); + } + + @Test + void shouldNotRenderSidebarWhenNoMatchedTransitionsExist() throws IOException { + click.kamil.springstatemachineexporter.analysis.model.EntryPoint ep = click.kamil.springstatemachineexporter.analysis.model.EntryPoint.builder().name("Test").build(); + click.kamil.springstatemachineexporter.analysis.model.CallChain chain = click.kamil.springstatemachineexporter.analysis.model.CallChain.builder() + .triggerPoint(click.kamil.springstatemachineexporter.analysis.model.TriggerPoint.builder().event("TEST_EVENT").build()) + .matchedTransitions(List.of()) // EMPTY + .build(); + click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata metadata = click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder() + .entryPoints(List.of(ep)) + .callChains(List.of(chain)) + .build(); + + Transition transition = new Transition(); + transition.setEvent(click.kamil.springstatemachineexporter.model.Event.of("TEST_EVENT")); + transition.setSourceStates(List.of()); + transition.setTargetStates(List.of()); + + AnalysisResult result = AnalysisResult.builder() + .name("SidebarTestEmpty") + .transitions(List.of(transition)) + .startStates(java.util.Collections.emptySet()) + .endStates(java.util.Collections.emptySet()) + .metadata(metadata) + .build(); + + HtmlExporter exporter = new HtmlExporter(); + String html = exporter.export(result, ExportOptions.builder().includeMetadataPane(true).build()); + + assertThat(html).doesNotContain("id=\"sidebar\""); + assertThat(html).doesNotContain("id=\"ep-list\""); + } + + @Test + void shouldContainCorrectJsLogicForEventTooltipResolution() throws IOException { + HtmlExporter exporter = new HtmlExporter(); + AnalysisResult result = AnalysisResult.builder() + .name("JsTest") + .transitions(List.of()) + .startStates(java.util.Collections.emptySet()) + .endStates(java.util.Collections.emptySet()) + .build(); + + String html = exporter.export(result, ExportOptions.builder().build()); + + // Verify that the template gracefully unwraps the Event JSON object for interactive hovers in attachInteractivity + assertThat(html).contains("const evStr = t.event.fullIdentifier || t.event.rawName || t.event;"); + + // Verify that the template gracefully unwraps the Event JSON object when filtering call chains in createTooltip + assertThat(html).contains("const cEventStr = typeof t.event === 'object' ? (t.event.fullIdentifier || t.event.rawName || t.event) : t.event;"); } }