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 44c0812..cfe2ee5 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
@@ -1,6 +1,9 @@
package click.kamil.springstatemachineexporter.html.exporter;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
+import click.kamil.springstatemachineexporter.analysis.model.CallChain;
+import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
+import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
import click.kamil.springstatemachineexporter.exporter.PlantUml;
import click.kamil.springstatemachineexporter.exporter.StateMachineExporter;
@@ -69,7 +72,7 @@ public class HtmlExporter implements StateMachineExporter {
.collect(Collectors.toSet());
if (result.getMetadata().getCallChains() != null) {
hasEntryPoints = result.getMetadata().getCallChains().stream()
- .anyMatch(c -> c.getMatchedTransitions() != null && !c.getMatchedTransitions().isEmpty());
+ .anyMatch(HtmlExporter::isSidebarVisibleChain);
}
}
boolean hasFlows = result.getFlows() != null && !result.getFlows().isEmpty();
@@ -120,6 +123,24 @@ public class HtmlExporter implements StateMachineExporter {
}
}
+ private static boolean isSidebarVisibleChain(CallChain chain) {
+ if (chain == null) {
+ return false;
+ }
+ if (chain.getLinkResolution() == LinkResolution.UNRESOLVED_EXTERNAL) {
+ return true;
+ }
+ TriggerPoint trigger = chain.getTriggerPoint();
+ if (trigger != null && trigger.getEvent() != null && trigger.getEvent().startsWith("[LIFECYCLE:")) {
+ return true;
+ }
+ if (chain.getMatchedTransitions() != null && !chain.getMatchedTransitions().isEmpty()) {
+ LinkResolution resolution = chain.getLinkResolution();
+ return resolution == null || resolution == LinkResolution.RESOLVED;
+ }
+ return false;
+ }
+
private String loadTemplate() {
try (InputStream is = getClass().getResourceAsStream("/html/template.html")) {
if (is == null) throw new RuntimeException("HTML template not found");
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 4ae920d..30e3aab 100644
--- a/state_machine_exporter_html/src/main/resources/html/template.html
+++ b/state_machine_exporter_html/src/main/resources/html/template.html
@@ -370,14 +370,31 @@
return 'Lifecycle: ' + m[1].toLowerCase();
}
+ function isResolvedChain(c) {
+ if (!c.matchedTransitions || c.matchedTransitions.length === 0) return false;
+ return c.linkResolution === 'RESOLVED' || c.linkResolution == null;
+ }
+
+ function isUnresolvedExternalChain(c) {
+ return c.linkResolution === 'UNRESOLVED_EXTERNAL';
+ }
+
+ function isSidebarChain(c) {
+ return isLifecycleChain(c) || isResolvedChain(c) || isUnresolvedExternalChain(c);
+ }
+
function chainsForEntryPoint(ep) {
return (metadata.metadata.callChains || []).filter(c =>
c.entryPoint.className === ep.className &&
c.entryPoint.methodName === ep.methodName &&
- ((c.matchedTransitions && c.matchedTransitions.length > 0) || isLifecycleChain(c))
+ isSidebarChain(c)
);
}
+ function resolvedChainsForEntryPoint(ep) {
+ return chainsForEntryPoint(ep).filter(isResolvedChain);
+ }
+
function populateSidebar() {
const list = document.getElementById('ep-list');
if (!list) return;
@@ -402,10 +419,12 @@
if (lifecycleChain) {
const label = formatLifecycleLabel(lifecycleChain.triggerPoint.event);
triggersHtml += `${label}`;
+ } else if (chains.some(isUnresolvedExternalChain) && !chains.some(isResolvedChain)) {
+ triggersHtml += `Unresolved external`;
} else {
triggersHtml += 'Triggers: ';
const addedEvents = new Set();
- chains.forEach(c => {
+ chains.filter(isResolvedChain).forEach(c => {
c.matchedTransitions.forEach(t => {
const evName = t.event || "";
if (!addedEvents.has(evName)) {
@@ -449,19 +468,17 @@
function highlightEntryPoint(ep) {
clearHighlights();
- const chains = chainsForEntryPoint(ep);
-
+ const chains = resolvedChainsForEntryPoint(ep);
+
const seen = new Set();
let steps = [];
chains.forEach(c => {
- if (c.matchedTransitions && c.matchedTransitions.length > 0) {
- c.matchedTransitions.forEach(t => {
- const key = buildLinkKey(t.sourceState, t.event);
- if (!key || seen.has(key)) return;
- seen.add(key);
- steps.push({ event: t.event, source: t.sourceState, ambiguous: c.triggerPoint && c.triggerPoint.ambiguous });
- });
- }
+ c.matchedTransitions.forEach(t => {
+ const key = buildLinkKey(t.sourceState, t.event);
+ if (!key || seen.has(key)) return;
+ seen.add(key);
+ steps.push({ event: t.event, source: t.sourceState, ambiguous: c.triggerPoint && c.triggerPoint.ambiguous });
+ });
});
if (steps.length === 0) return;
diff --git a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/ExtendedStateMachineLifecycleHtmlTest.java b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/ExtendedStateMachineLifecycleHtmlTest.java
index 92faf06..98ccd9d 100644
--- a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/ExtendedStateMachineLifecycleHtmlTest.java
+++ b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/ExtendedStateMachineLifecycleHtmlTest.java
@@ -1,38 +1,65 @@
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.EntryPoint;
+import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
+import click.kamil.springstatemachineexporter.exporter.ExportOptions;
import click.kamil.springstatemachineexporter.html.exporter.HtmlExporter;
-import click.kamil.springstatemachineexporter.service.ExportService;
+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 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;
class ExtendedStateMachineLifecycleHtmlTest {
- private static final Path EXTENDED_GOLDEN_JSON = Path.of(
- "../state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json")
- .toAbsolutePath()
- .normalize();
-
@Test
- void shouldShowLifecycleRestoreEndpointsInSidebar(@TempDir Path tempDir) throws Exception {
- assertThat(EXTENDED_GOLDEN_JSON).exists();
+ void shouldShowLifecycleRestoreEndpointsInSidebar() {
+ EntryPoint captureEndpoint = EntryPoint.builder()
+ .type(EntryPoint.Type.REST)
+ .name("POST /api/payment/{id}/capture")
+ .className("click.kamil.examples.statemachine.extended.web.PaymentController")
+ .methodName("capturePaymentEndpoint")
+ .sourceFile("src/main/java/click/kamil/examples/statemachine/extended/web/PaymentController.java")
+ .build();
+ CallChain lifecycleChain = CallChain.builder()
+ .entryPoint(captureEndpoint)
+ .triggerPoint(TriggerPoint.builder()
+ .event("[LIFECYCLE:RESTORE]")
+ .className("click.kamil.examples.statemachine.extended.service.PaymentServiceImpl")
+ .methodName("processPayment")
+ .sourceFile("src/main/java/click/kamil/examples/statemachine/extended/service/PaymentServiceImpl.java")
+ .build())
+ .build();
- ExportService exportService = new ExportService(List.of(new HtmlExporter()));
- exportService.runJsonExporter(EXTENDED_GOLDEN_JSON, tempDir, List.of("html"), List.of());
+ Transition transition = new Transition();
+ transition.setEvent(Event.of("CAPTURE", "CAPTURE"));
+ transition.setSourceStates(List.of(State.of("INIT", "INIT")));
+ transition.setTargetStates(List.of(State.of("CAPTURED", "CAPTURED")));
- Path machineDir = tempDir.resolve("click.kamil.examples.statemachine.extended.config.ExtendedStateMachineConfig");
- Path htmlFile = machineDir.resolve("click.kamil.examples.statemachine.extended.config.ExtendedStateMachineConfig.html");
- assertThat(htmlFile).exists();
+ AnalysisResult result = AnalysisResult.builder()
+ .name("click.kamil.examples.statemachine.extended.config.ExtendedStateMachineConfig")
+ .transitions(List.of(transition))
+ .startStates(Set.of("INIT"))
+ .endStates(Set.of("CAPTURED"))
+ .metadata(CodebaseMetadata.builder()
+ .entryPoints(List.of(captureEndpoint))
+ .callChains(List.of(lifecycleChain))
+ .build())
+ .build();
+
+ String html = new HtmlExporter().export(result, ExportOptions.builder().includeMetadataPane(true).build());
- String html = Files.readString(htmlFile);
assertThat(html).contains("function formatLifecycleLabel");
assertThat(html).contains("POST /api/payment/{id}/capture");
assertThat(html).contains("Restore persisted state");
assertThat(html).contains("[LIFECYCLE:RESTORE]");
+ assertThat(html).contains("function isLifecycleChain(c)");
}
}
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 f7301e0..50c3524 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
@@ -6,6 +6,9 @@ import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
+import click.kamil.springstatemachineexporter.analysis.model.CallChain;
+import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
+import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
@@ -112,11 +115,47 @@ public class HtmlExporterTest {
assertThat(html).contains("id=\"ep-list\"");
}
+ @Test
+ void shouldRenderSidebarWhenUnresolvedExternalChainExists() throws IOException {
+ EntryPoint ep = EntryPoint.builder()
+ .type(EntryPoint.Type.REST)
+ .name("POST /api/transition/{event}")
+ .className("com.example.web.DispatcherController")
+ .methodName("dispatch")
+ .build();
+ CallChain chain = CallChain.builder()
+ .entryPoint(ep)
+ .linkResolution(LinkResolution.UNRESOLVED_EXTERNAL)
+ .triggerPoint(click.kamil.springstatemachineexporter.analysis.model.TriggerPoint.builder()
+ .event("OrderEvent.valueOf(eventString.toUpperCase())")
+ .external(true)
+ .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("SidebarUnresolvedTest")
+ .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()
+ CallChain chain = CallChain.builder()
.triggerPoint(click.kamil.springstatemachineexporter.analysis.model.TriggerPoint.builder().event("TEST_EVENT").build())
+ .linkResolution(LinkResolution.NO_MATCH)
.matchedTransitions(List.of()) // EMPTY
.build();
click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata metadata = click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
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
index a076194..a9e41f3 100644
--- 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
@@ -3,6 +3,8 @@ 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.EntryPoint;
+import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
@@ -132,13 +134,11 @@ class HtmlTransitionLinkConsistencyTest {
"com.example.order.OrderState.SHIPPED")))
.startStates(Set.of("com.example.order.OrderState.NEW"))
.endStates(Set.of("com.example.order.OrderState.SHIPPED"))
- .metadata(CodebaseMetadata.builder()
- .flows(List.of(click.kamil.springstatemachineexporter.analysis.model.BusinessFlow.builder()
- .name("Ship only")
- .description("Should not light PAY")
- .steps(List.of("SHIP"))
- .build()))
- .build())
+ .flows(List.of(click.kamil.springstatemachineexporter.analysis.model.BusinessFlow.builder()
+ .name("Ship only")
+ .description("Should not light PAY")
+ .steps(List.of("SHIP"))
+ .build()))
.build();
String html = new HtmlExporter().export(finalizedShape, ExportOptions.builder().build());
@@ -166,6 +166,107 @@ class HtmlTransitionLinkConsistencyTest {
assertThat(html).contains("\"matchedTransitions\"");
}
+ @Test
+ void htmlShouldRespectLinkResolutionInSidebarAndHighlighting() {
+ EntryPoint resolvedEp = EntryPoint.builder()
+ .type(EntryPoint.Type.REST)
+ .name("POST /orders/pay")
+ .className("com.example.web.OrderController")
+ .methodName("pay")
+ .build();
+ EntryPoint unresolvedEp = EntryPoint.builder()
+ .type(EntryPoint.Type.REST)
+ .name("POST /api/transition/{event}")
+ .className("com.example.web.DispatcherController")
+ .methodName("dispatch")
+ .build();
+ EntryPoint noMatchEp = EntryPoint.builder()
+ .type(EntryPoint.Type.REST)
+ .name("POST /orders/suspend")
+ .className("com.example.web.OrderController")
+ .methodName("suspend")
+ .build();
+
+ CallChain resolvedChain = CallChain.builder()
+ .entryPoint(resolvedEp)
+ .linkResolution(LinkResolution.RESOLVED)
+ .triggerPoint(TriggerPoint.builder()
+ .event("com.example.order.OrderEvent.PAY")
+ .className("com.example.web.OrderController")
+ .methodName("pay")
+ .build())
+ .matchedTransitions(List.of(MatchedTransition.builder()
+ .sourceState("com.example.order.OrderState.NEW")
+ .targetState("com.example.order.OrderState.PAID")
+ .event("com.example.order.OrderEvent.PAY")
+ .build()))
+ .build();
+ CallChain unresolvedChain = CallChain.builder()
+ .entryPoint(unresolvedEp)
+ .linkResolution(LinkResolution.UNRESOLVED_EXTERNAL)
+ .triggerPoint(TriggerPoint.builder()
+ .event("OrderEvent.valueOf(eventString.toUpperCase())")
+ .className("com.example.web.DispatcherController")
+ .methodName("dispatch")
+ .external(true)
+ .build())
+ .build();
+ CallChain noMatchChain = CallChain.builder()
+ .entryPoint(noMatchEp)
+ .linkResolution(LinkResolution.NO_MATCH)
+ .triggerPoint(TriggerPoint.builder()
+ .event("com.example.order.OrderEvent.SUSPEND")
+ .className("com.example.web.OrderController")
+ .methodName("suspend")
+ .build())
+ .build();
+ CallChain ambiguousChain = CallChain.builder()
+ .entryPoint(EntryPoint.builder()
+ .type(EntryPoint.Type.CUSTOM)
+ .name("ambiguous")
+ .className("com.example.web.AmbiguousController")
+ .methodName("trigger")
+ .build())
+ .linkResolution(LinkResolution.AMBIGUOUS_WIDEN)
+ .triggerPoint(TriggerPoint.builder()
+ .event("com.example.order.OrderEvent.PAY")
+ .ambiguous(true)
+ .build())
+ .matchedTransitions(List.of(MatchedTransition.builder()
+ .sourceState("com.example.order.OrderState.NEW")
+ .targetState("com.example.order.OrderState.PAID")
+ .event("com.example.order.OrderEvent.PAY")
+ .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(
+ "com.example.order.OrderEvent.PAY",
+ "com.example.order.OrderState.NEW",
+ "com.example.order.OrderState.PAID")))
+ .startStates(Set.of("com.example.order.OrderState.NEW"))
+ .endStates(Set.of("com.example.order.OrderState.PAID"))
+ .metadata(CodebaseMetadata.builder()
+ .entryPoints(List.of(resolvedEp, unresolvedEp, noMatchEp))
+ .callChains(List.of(resolvedChain, unresolvedChain, noMatchChain, ambiguousChain))
+ .build())
+ .build();
+
+ String html = new HtmlExporter().export(result, ExportOptions.builder().includeMetadataPane(true).build());
+
+ assertThat(html).contains("function isResolvedChain(c)");
+ assertThat(html).contains("function isSidebarChain(c)");
+ assertThat(html).contains("function resolvedChainsForEntryPoint(ep)");
+ assertThat(html).contains("Unresolved external");
+ assertThat(html).contains("const chains = resolvedChainsForEntryPoint(ep)");
+ assertThat(html).contains("linkResolution === 'UNRESOLVED_EXTERNAL'");
+ assertThat(html).contains("linkResolution === 'RESOLVED'");
+ assertThat(html).contains("id=\"sidebar\"");
+ }
+
/** Mirrors {@code template.html} link-key derivation for regression checks. */
private static String buildLinkKey(String source, String event) {
if (event == null || event.isBlank()) {