Align HTML SVG link keys with export enum formats

HtmlExporter now propagates event/state formats into PlantUML anchors and template.js buildLinkKey, with layered-dispatcher and fqn regression tests guarding endpoint highlight consistency.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 06:38:57 +02:00
parent d11f8afd79
commit 7799f8ce5f
4 changed files with 146 additions and 15 deletions

View File

@@ -40,6 +40,8 @@ public class HtmlExporter implements StateMachineExporter {
.useLambdaGuards(options.isUseLambdaGuards()) .useLambdaGuards(options.isUseLambdaGuards())
.renderChoicesAsDiamonds(options.isRenderChoicesAsDiamonds()) .renderChoicesAsDiamonds(options.isRenderChoicesAsDiamonds())
.embedIdentifiers(true) // Force identifying classes in SVG .embedIdentifiers(true) // Force identifying classes in SVG
.eventFormat(options.getEventFormat())
.stateFormat(options.getStateFormat())
.build(); .build();
String puml = pumlExporter.export(result, enrichedOptions); String puml = pumlExporter.export(result, enrichedOptions);
@@ -82,7 +84,9 @@ public class HtmlExporter implements StateMachineExporter {
String finalHtml = template String finalHtml = template
.replace("{{MACHINE_NAME}}", result.getName()) .replace("{{MACHINE_NAME}}", result.getName())
.replace("{{SVG_CONTENT}}", decoratedSvg) .replace("{{SVG_CONTENT}}", decoratedSvg)
.replace("{{METADATA_JSON}}", metadataJson); .replace("{{METADATA_JSON}}", metadataJson)
.replace("{{LINK_EVENT_FORMAT}}", options.getEventFormat().name())
.replace("{{LINK_STATE_FORMAT}}", options.getStateFormat().name());
if (shouldRenderPane) { if (shouldRenderPane) {
// If template.html uses {{SIDEBAR_HTML}} and {{TOGGLE_BUTTON_HTML}}, replace with actual HTML // If template.html uses {{SIDEBAR_HTML}} and {{TOGGLE_BUTTON_HTML}}, replace with actual HTML

View File

@@ -309,6 +309,8 @@
<script> <script>
const metadata = JSON.parse(document.getElementById('metadata-json').textContent); const metadata = JSON.parse(document.getElementById('metadata-json').textContent);
const linkEventFormat = '{{LINK_EVENT_FORMAT}}';
const linkStateFormat = '{{LINK_STATE_FORMAT}}';
let panZoomInstance; let panZoomInstance;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@@ -637,9 +639,15 @@
}); });
} }
function formatForLinkId(identifier) { function formatForLinkId(identifier, format) {
if (!identifier) return ""; if (!identifier) return "";
const s = String(identifier); const s = String(identifier);
const fmt = format || 'fn';
if (fmt === 'fqn') return s;
if (fmt === 'sn') {
const lastDot = s.lastIndexOf('.');
return lastDot >= 0 ? s.substring(lastDot + 1) : s;
}
const lastDot = s.lastIndexOf('.'); const lastDot = s.lastIndexOf('.');
if (lastDot <= 0) return s; if (lastDot <= 0) return s;
const prevDot = s.lastIndexOf('.', lastDot - 1); const prevDot = s.lastIndexOf('.', lastDot - 1);
@@ -653,13 +661,13 @@
function buildLinkKey(source, event) { function buildLinkKey(source, event) {
if (!event) return ""; if (!event) return "";
const formattedEvent = formatForLinkId(event); const formattedEvent = formatForLinkId(event, linkEventFormat);
if (!source) return "__" + normalize(formattedEvent); if (!source) return "__" + normalize(formattedEvent);
return normalize(formatForLinkId(source)) + "__" + normalize(formattedEvent); return normalize(formatForLinkId(source, linkStateFormat)) + "__" + normalize(formattedEvent);
} }
function eventsMatchForLink(a, b) { function eventsMatchForLink(a, b) {
return normalize(formatForLinkId(a)) === normalize(formatForLinkId(b)); return normalize(formatForLinkId(a, linkEventFormat)) === normalize(formatForLinkId(b, linkEventFormat));
} }
function createTooltip(name, trans) { function createTooltip(name, trans) {

View File

@@ -195,7 +195,8 @@ public class HtmlExporterTest {
String html = exporter.export(result, ExportOptions.builder().build()); String html = exporter.export(result, ExportOptions.builder().build());
assertThat(html).contains("function formatForLinkId(identifier)"); assertThat(html).contains("function formatForLinkId(identifier, format)");
assertThat(html).contains("const linkEventFormat = 'fn'");
assertThat(html).contains("function buildLinkKey(source, event)"); assertThat(html).contains("function buildLinkKey(source, event)");
} }
} }

View File

@@ -147,6 +147,103 @@ class HtmlTransitionLinkConsistencyTest {
assertThat(html).doesNotContain("match that event from any source"); assertThat(html).doesNotContain("match that event from any source");
} }
@Test
void layeredDispatcherHtmlShouldContainLinkKeysForEveryMatchedTransition(@TempDir Path tempDir) throws Exception {
Path sampleRoot = findProjectRoot().resolve("state_machines/layered_dispatcher_sample");
ExportService exportService = new ExportService(List.of(new HtmlExporter(), new JsonExporter()));
exportService.runExporter(sampleRoot, tempDir, List.of("html", "json"), true, List.of(), null, null,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
Path machineDir = tempDir.resolve(
"click.kamil.examples.statemachine.layered.order.config.StandardOrderStateMachineConfiguration");
Path htmlFile = machineDir.resolve(
"click.kamil.examples.statemachine.layered.order.config.StandardOrderStateMachineConfiguration.html");
Path finalizedJson = machineDir.resolve(
"click.kamil.examples.statemachine.layered.order.config.StandardOrderStateMachineConfiguration.json");
assertThat(htmlFile).exists();
String html = Files.readString(htmlFile);
AnalysisResult finalized = new JsonImportService().importAnalysisResult(finalizedJson);
List<MatchedTransition> matched = finalized.getMetadata().getCallChains().stream()
.filter(c -> c.getLinkResolution() == LinkResolution.RESOLVED)
.flatMap(c -> c.getMatchedTransitions() == null ? java.util.stream.Stream.empty()
: c.getMatchedTransitions().stream())
.toList();
assertThat(matched).isNotEmpty();
for (MatchedTransition mt : matched) {
String linkKey = buildLinkKey(mt.getSourceState(), mt.getEvent());
assertThat(html)
.as("HTML must contain SVG link for matched transition %s -> %s",
mt.getSourceState(), mt.getEvent())
.contains("#link_" + linkKey);
}
assertThat(html).contains("linkEventFormat = 'fn'");
assertThat(html).contains("OrderState_NEW__OrderTransitionEvent_PAY");
}
@Test
void htmlShouldAlignFqnLinkKeysWhenExportUsesFqnFormat(@TempDir Path tempDir) throws Exception {
writeSampleProject(tempDir);
AnalysisResult finalizedShape = 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(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /orders/pay")
.className("com.example.web.OrderController")
.methodName("pay")
.build()))
.callChains(List.of(CallChain.builder()
.entryPoint(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /orders/pay")
.className("com.example.web.OrderController")
.methodName("pay")
.build())
.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()))
.build())
.build();
ExportOptions fqnOptions = ExportOptions.builder()
.includeMetadataPane(true)
.eventFormat(click.kamil.springstatemachineexporter.exporter.EnumFormat.fqn)
.stateFormat(click.kamil.springstatemachineexporter.exporter.EnumFormat.fqn)
.build();
String html = new HtmlExporter().export(finalizedShape, fqnOptions);
String linkKey = buildLinkKey(
"com.example.order.OrderState.NEW",
"com.example.order.OrderEvent.PAY",
click.kamil.springstatemachineexporter.exporter.EnumFormat.fqn,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fqn);
assertThat(html).contains("linkEventFormat = 'fqn'");
assertThat(html).contains("#link_" + linkKey);
}
@Test @Test
void enterpriseHtmlShouldContainMatchedTransitionLinkKeys(@TempDir Path tempDir) throws Exception { void enterpriseHtmlShouldContainMatchedTransitionLinkKeys(@TempDir Path tempDir) throws Exception {
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise"); Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
@@ -269,26 +366,47 @@ class HtmlTransitionLinkConsistencyTest {
/** 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) {
return buildLinkKey(source, event,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
}
private static String buildLinkKey(
String source,
String event,
click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat,
click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat) {
if (event == null || event.isBlank()) { if (event == null || event.isBlank()) {
return ""; return "";
} }
String formattedEvent = formatForLinkId(event); String formattedEvent = formatForLinkId(event, eventFormat);
if (source == null || source.isBlank()) { if (source == null || source.isBlank()) {
return "__" + normalize(formattedEvent); return "__" + normalize(formattedEvent);
} }
return normalize(formatForLinkId(source)) + "__" + normalize(formattedEvent); return normalize(formatForLinkId(source, stateFormat)) + "__" + normalize(formattedEvent);
} }
private static String formatForLinkId(String identifier) { private static String formatForLinkId(
String identifier,
click.kamil.springstatemachineexporter.exporter.EnumFormat format) {
if (identifier == null || identifier.isBlank()) { if (identifier == null || identifier.isBlank()) {
return ""; return "";
} }
int lastDot = identifier.lastIndexOf('.'); return switch (format) {
if (lastDot <= 0) { case fqn -> identifier;
return identifier; case sn -> {
} int lastDot = identifier.lastIndexOf('.');
int prevDot = identifier.lastIndexOf('.', lastDot - 1); yield lastDot >= 0 ? identifier.substring(lastDot + 1) : identifier;
return prevDot > 0 ? identifier.substring(prevDot + 1) : identifier; }
case fn -> {
int lastDot = identifier.lastIndexOf('.');
if (lastDot <= 0) {
yield identifier;
}
int prevDot = identifier.lastIndexOf('.', lastDot - 1);
yield prevDot > 0 ? identifier.substring(prevDot + 1) : identifier;
}
};
} }
private static String normalize(String s) { private static String normalize(String s) {