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:
@@ -40,6 +40,8 @@ public class HtmlExporter implements StateMachineExporter {
|
||||
.useLambdaGuards(options.isUseLambdaGuards())
|
||||
.renderChoicesAsDiamonds(options.isRenderChoicesAsDiamonds())
|
||||
.embedIdentifiers(true) // Force identifying classes in SVG
|
||||
.eventFormat(options.getEventFormat())
|
||||
.stateFormat(options.getStateFormat())
|
||||
.build();
|
||||
|
||||
String puml = pumlExporter.export(result, enrichedOptions);
|
||||
@@ -82,7 +84,9 @@ public class HtmlExporter implements StateMachineExporter {
|
||||
String finalHtml = template
|
||||
.replace("{{MACHINE_NAME}}", result.getName())
|
||||
.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 template.html uses {{SIDEBAR_HTML}} and {{TOGGLE_BUTTON_HTML}}, replace with actual HTML
|
||||
|
||||
@@ -309,6 +309,8 @@
|
||||
|
||||
<script>
|
||||
const metadata = JSON.parse(document.getElementById('metadata-json').textContent);
|
||||
const linkEventFormat = '{{LINK_EVENT_FORMAT}}';
|
||||
const linkStateFormat = '{{LINK_STATE_FORMAT}}';
|
||||
let panZoomInstance;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -637,9 +639,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
function formatForLinkId(identifier) {
|
||||
function formatForLinkId(identifier, format) {
|
||||
if (!identifier) return "";
|
||||
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('.');
|
||||
if (lastDot <= 0) return s;
|
||||
const prevDot = s.lastIndexOf('.', lastDot - 1);
|
||||
@@ -653,13 +661,13 @@
|
||||
|
||||
function buildLinkKey(source, event) {
|
||||
if (!event) return "";
|
||||
const formattedEvent = formatForLinkId(event);
|
||||
const formattedEvent = formatForLinkId(event, linkEventFormat);
|
||||
if (!source) return "__" + normalize(formattedEvent);
|
||||
return normalize(formatForLinkId(source)) + "__" + normalize(formattedEvent);
|
||||
return normalize(formatForLinkId(source, linkStateFormat)) + "__" + normalize(formattedEvent);
|
||||
}
|
||||
|
||||
function eventsMatchForLink(a, b) {
|
||||
return normalize(formatForLinkId(a)) === normalize(formatForLinkId(b));
|
||||
return normalize(formatForLinkId(a, linkEventFormat)) === normalize(formatForLinkId(b, linkEventFormat));
|
||||
}
|
||||
|
||||
function createTooltip(name, trans) {
|
||||
|
||||
@@ -195,7 +195,8 @@ public class HtmlExporterTest {
|
||||
|
||||
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)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,103 @@ class HtmlTransitionLinkConsistencyTest {
|
||||
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
|
||||
void enterpriseHtmlShouldContainMatchedTransitionLinkKeys(@TempDir Path tempDir) throws Exception {
|
||||
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. */
|
||||
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()) {
|
||||
return "";
|
||||
}
|
||||
String formattedEvent = formatForLinkId(event);
|
||||
String formattedEvent = formatForLinkId(event, eventFormat);
|
||||
if (source == null || source.isBlank()) {
|
||||
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()) {
|
||||
return "";
|
||||
}
|
||||
return switch (format) {
|
||||
case fqn -> identifier;
|
||||
case sn -> {
|
||||
int lastDot = identifier.lastIndexOf('.');
|
||||
yield lastDot >= 0 ? identifier.substring(lastDot + 1) : identifier;
|
||||
}
|
||||
case fn -> {
|
||||
int lastDot = identifier.lastIndexOf('.');
|
||||
if (lastDot <= 0) {
|
||||
return identifier;
|
||||
yield identifier;
|
||||
}
|
||||
int prevDot = identifier.lastIndexOf('.', lastDot - 1);
|
||||
return prevDot > 0 ? identifier.substring(prevDot + 1) : identifier;
|
||||
yield prevDot > 0 ? identifier.substring(prevDot + 1) : identifier;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static String normalize(String s) {
|
||||
|
||||
Reference in New Issue
Block a user