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())
|
.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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "";
|
||||||
}
|
}
|
||||||
|
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('.');
|
int lastDot = identifier.lastIndexOf('.');
|
||||||
if (lastDot <= 0) {
|
if (lastDot <= 0) {
|
||||||
return identifier;
|
yield identifier;
|
||||||
}
|
}
|
||||||
int prevDot = identifier.lastIndexOf('.', lastDot - 1);
|
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) {
|
private static String normalize(String s) {
|
||||||
|
|||||||
Reference in New Issue
Block a user