Respect linkResolution in HTML sidebar and hover highlighting.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 20:51:25 +02:00
parent 4f3d6c40e7
commit 1146cdbae6
5 changed files with 243 additions and 38 deletions

View File

@@ -1,6 +1,9 @@
package click.kamil.springstatemachineexporter.html.exporter; package click.kamil.springstatemachineexporter.html.exporter;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; 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.ExportOptions;
import click.kamil.springstatemachineexporter.exporter.PlantUml; import click.kamil.springstatemachineexporter.exporter.PlantUml;
import click.kamil.springstatemachineexporter.exporter.StateMachineExporter; import click.kamil.springstatemachineexporter.exporter.StateMachineExporter;
@@ -69,7 +72,7 @@ public class HtmlExporter implements StateMachineExporter {
.collect(Collectors.toSet()); .collect(Collectors.toSet());
if (result.getMetadata().getCallChains() != null) { if (result.getMetadata().getCallChains() != null) {
hasEntryPoints = result.getMetadata().getCallChains().stream() hasEntryPoints = result.getMetadata().getCallChains().stream()
.anyMatch(c -> c.getMatchedTransitions() != null && !c.getMatchedTransitions().isEmpty()); .anyMatch(HtmlExporter::isSidebarVisibleChain);
} }
} }
boolean hasFlows = result.getFlows() != null && !result.getFlows().isEmpty(); 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() { private String loadTemplate() {
try (InputStream is = getClass().getResourceAsStream("/html/template.html")) { try (InputStream is = getClass().getResourceAsStream("/html/template.html")) {
if (is == null) throw new RuntimeException("HTML template not found"); if (is == null) throw new RuntimeException("HTML template not found");

View File

@@ -370,14 +370,31 @@
return 'Lifecycle: ' + m[1].toLowerCase(); 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) { function chainsForEntryPoint(ep) {
return (metadata.metadata.callChains || []).filter(c => return (metadata.metadata.callChains || []).filter(c =>
c.entryPoint.className === ep.className && c.entryPoint.className === ep.className &&
c.entryPoint.methodName === ep.methodName && 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() { function populateSidebar() {
const list = document.getElementById('ep-list'); const list = document.getElementById('ep-list');
if (!list) return; if (!list) return;
@@ -402,10 +419,12 @@
if (lifecycleChain) { if (lifecycleChain) {
const label = formatLifecycleLabel(lifecycleChain.triggerPoint.event); const label = formatLifecycleLabel(lifecycleChain.triggerPoint.event);
triggersHtml += `<span class="badge lifecycle" style="font-size: 0.65rem; padding: 2px 6px; background:#ecfdf5; color:#047857; border:1px solid #a7f3d0;">${label}</span>`; triggersHtml += `<span class="badge lifecycle" style="font-size: 0.65rem; padding: 2px 6px; background:#ecfdf5; color:#047857; border:1px solid #a7f3d0;">${label}</span>`;
} else if (chains.some(isUnresolvedExternalChain) && !chains.some(isResolvedChain)) {
triggersHtml += `<span class="badge unresolved-external" style="font-size: 0.65rem; padding: 2px 6px; background:#fff7ed; color:#c2410c; border:1px solid #fed7aa;">Unresolved external</span>`;
} else { } else {
triggersHtml += 'Triggers: '; triggersHtml += 'Triggers: ';
const addedEvents = new Set(); const addedEvents = new Set();
chains.forEach(c => { chains.filter(isResolvedChain).forEach(c => {
c.matchedTransitions.forEach(t => { c.matchedTransitions.forEach(t => {
const evName = t.event || ""; const evName = t.event || "";
if (!addedEvents.has(evName)) { if (!addedEvents.has(evName)) {
@@ -449,19 +468,17 @@
function highlightEntryPoint(ep) { function highlightEntryPoint(ep) {
clearHighlights(); clearHighlights();
const chains = chainsForEntryPoint(ep); const chains = resolvedChainsForEntryPoint(ep);
const seen = new Set(); const seen = new Set();
let steps = []; let steps = [];
chains.forEach(c => { chains.forEach(c => {
if (c.matchedTransitions && c.matchedTransitions.length > 0) { c.matchedTransitions.forEach(t => {
c.matchedTransitions.forEach(t => { const key = buildLinkKey(t.sourceState, t.event);
const key = buildLinkKey(t.sourceState, t.event); if (!key || seen.has(key)) return;
if (!key || seen.has(key)) return; seen.add(key);
seen.add(key); steps.push({ event: t.event, source: t.sourceState, ambiguous: c.triggerPoint && c.triggerPoint.ambiguous });
steps.push({ event: t.event, source: t.sourceState, ambiguous: c.triggerPoint && c.triggerPoint.ambiguous }); });
});
}
}); });
if (steps.length === 0) return; if (steps.length === 0) return;

View File

@@ -1,38 +1,65 @@
package click.kamil.springstatemachineexporter.html; 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.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.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.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class ExtendedStateMachineLifecycleHtmlTest { class ExtendedStateMachineLifecycleHtmlTest {
private static final Path EXTENDED_GOLDEN_JSON = Path.of(
"../state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json")
.toAbsolutePath()
.normalize();
@Test @Test
void shouldShowLifecycleRestoreEndpointsInSidebar(@TempDir Path tempDir) throws Exception { void shouldShowLifecycleRestoreEndpointsInSidebar() {
assertThat(EXTENDED_GOLDEN_JSON).exists(); 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())); Transition transition = new Transition();
exportService.runJsonExporter(EXTENDED_GOLDEN_JSON, tempDir, List.of("html"), List.of()); 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"); AnalysisResult result = AnalysisResult.builder()
Path htmlFile = machineDir.resolve("click.kamil.examples.statemachine.extended.config.ExtendedStateMachineConfig.html"); .name("click.kamil.examples.statemachine.extended.config.ExtendedStateMachineConfig")
assertThat(htmlFile).exists(); .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("function formatLifecycleLabel");
assertThat(html).contains("POST /api/payment/{id}/capture"); assertThat(html).contains("POST /api/payment/{id}/capture");
assertThat(html).contains("Restore persisted state"); assertThat(html).contains("Restore persisted state");
assertThat(html).contains("[LIFECYCLE:RESTORE]"); assertThat(html).contains("[LIFECYCLE:RESTORE]");
assertThat(html).contains("function isLifecycleChain(c)");
} }
} }

View File

@@ -6,6 +6,9 @@ import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow; 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.analysis.service.EnrichmentService;
import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator; import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
@@ -112,11 +115,47 @@ public class HtmlExporterTest {
assertThat(html).contains("id=\"ep-list\""); 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 @Test
void shouldNotRenderSidebarWhenNoMatchedTransitionsExist() throws IOException { 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.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()) .triggerPoint(click.kamil.springstatemachineexporter.analysis.model.TriggerPoint.builder().event("TEST_EVENT").build())
.linkResolution(LinkResolution.NO_MATCH)
.matchedTransitions(List.of()) // EMPTY .matchedTransitions(List.of()) // EMPTY
.build(); .build();
click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata metadata = click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder() click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata metadata = click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()

View File

@@ -3,6 +3,8 @@ package click.kamil.springstatemachineexporter.html;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; 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.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.exporter.ExportOptions; import click.kamil.springstatemachineexporter.exporter.ExportOptions;
@@ -132,13 +134,11 @@ class HtmlTransitionLinkConsistencyTest {
"com.example.order.OrderState.SHIPPED"))) "com.example.order.OrderState.SHIPPED")))
.startStates(Set.of("com.example.order.OrderState.NEW")) .startStates(Set.of("com.example.order.OrderState.NEW"))
.endStates(Set.of("com.example.order.OrderState.SHIPPED")) .endStates(Set.of("com.example.order.OrderState.SHIPPED"))
.metadata(CodebaseMetadata.builder() .flows(List.of(click.kamil.springstatemachineexporter.analysis.model.BusinessFlow.builder()
.flows(List.of(click.kamil.springstatemachineexporter.analysis.model.BusinessFlow.builder() .name("Ship only")
.name("Ship only") .description("Should not light PAY")
.description("Should not light PAY") .steps(List.of("SHIP"))
.steps(List.of("SHIP")) .build()))
.build()))
.build())
.build(); .build();
String html = new HtmlExporter().export(finalizedShape, ExportOptions.builder().build()); String html = new HtmlExporter().export(finalizedShape, ExportOptions.builder().build());
@@ -166,6 +166,107 @@ class HtmlTransitionLinkConsistencyTest {
assertThat(html).contains("\"matchedTransitions\""); 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. */ /** Mirrors {@code template.html} link-key derivation for regression checks. */
private static String buildLinkKey(String source, String event) { private static String buildLinkKey(String source, String event) {
if (event == null || event.isBlank()) { if (event == null || event.isBlank()) {