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()) {