Respect linkResolution in HTML sidebar and hover highlighting.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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 += `<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 {
|
||||
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;
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user