Precompute matched-transition link keys for HTML export
Embed linkKey on matchedTransitions in HTML metadata so explorer highlighting uses the same SVG #link_* suffix as PlantUML, avoiding JS/Java format drift while keeping golden JSON unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,16 +1,20 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@Builder(toBuilder = true)
|
||||
@Jacksonized
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class MatchedTransition {
|
||||
private final String sourceState;
|
||||
private final String targetState;
|
||||
private final String event;
|
||||
/** Precomputed {@code #link_*} suffix for HTML/SVG highlight; optional in JSON exports. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private final String linkKey;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package click.kamil.springstatemachineexporter.exporter;
|
||||
|
||||
/**
|
||||
* Builds SVG/HTML transition link keys ({@code Source__Event}) using the same formatting rules as
|
||||
* {@link PlantUml} embedded identifiers and the HTML explorer {@code buildLinkKey} helper.
|
||||
*/
|
||||
public final class TransitionLinkKey {
|
||||
|
||||
private TransitionLinkKey() {
|
||||
}
|
||||
|
||||
public static String build(String sourceStateIdentifier, String eventIdentifier) {
|
||||
return build(sourceStateIdentifier, eventIdentifier, EnumFormat.fn, EnumFormat.fn);
|
||||
}
|
||||
|
||||
public static String build(
|
||||
String sourceStateIdentifier,
|
||||
String eventIdentifier,
|
||||
EnumFormat stateFormat,
|
||||
EnumFormat eventFormat) {
|
||||
if (eventIdentifier == null || eventIdentifier.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String formattedEvent = formatIdentifier(eventIdentifier, eventFormat);
|
||||
if (sourceStateIdentifier == null || sourceStateIdentifier.isBlank()) {
|
||||
return "__" + normalize(formattedEvent);
|
||||
}
|
||||
return normalize(formatIdentifier(sourceStateIdentifier, stateFormat))
|
||||
+ "__"
|
||||
+ normalize(formattedEvent);
|
||||
}
|
||||
|
||||
static String formatIdentifier(String identifier, 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) {
|
||||
yield identifier;
|
||||
}
|
||||
int prevDot = identifier.lastIndexOf('.', lastDot - 1);
|
||||
yield prevDot > 0 ? identifier.substring(prevDot + 1) : identifier;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static String normalize(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return value.replaceAll("[^a-zA-Z0-9]", "_");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package click.kamil.springstatemachineexporter.exporter;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TransitionLinkKeyTest {
|
||||
|
||||
@Test
|
||||
void shouldBuildFnFormLinkKeyFromPackageCanonicalIdentifiers() {
|
||||
assertThat(TransitionLinkKey.build(
|
||||
"com.example.order.OrderState.NEW",
|
||||
"com.example.order.OrderEvent.PAY"))
|
||||
.isEqualTo("OrderState_NEW__OrderEvent_PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBuildFqnFormLinkKeyWhenRequested() {
|
||||
assertThat(TransitionLinkKey.build(
|
||||
"com.example.order.OrderState.NEW",
|
||||
"com.example.order.OrderEvent.PAY",
|
||||
EnumFormat.fqn,
|
||||
EnumFormat.fqn))
|
||||
.isEqualTo("com_example_order_OrderState_NEW__com_example_order_OrderEvent_PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBuildEventOnlyLinkKeyWhenSourceMissing() {
|
||||
assertThat(TransitionLinkKey.build(null, "com.example.order.OrderEvent.PAY"))
|
||||
.isEqualTo("__OrderEvent_PAY");
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@ 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.CodebaseMetadata;
|
||||
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;
|
||||
import click.kamil.springstatemachineexporter.exporter.PlantUml;
|
||||
import click.kamil.springstatemachineexporter.exporter.StateMachineExporter;
|
||||
import click.kamil.springstatemachineexporter.exporter.TransitionLinkKey;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
@@ -58,8 +61,9 @@ public class HtmlExporter implements StateMachineExporter {
|
||||
// 4. Load Template
|
||||
String template = loadTemplate();
|
||||
|
||||
// 5. Prepare Metadata JSON
|
||||
String metadataJson = objectMapper.writeValueAsString(result);
|
||||
// 5. Prepare Metadata JSON (precompute link keys so HTML JS matches SVG identifiers)
|
||||
AnalysisResult htmlMetadata = enrichLinkKeys(result, options);
|
||||
String metadataJson = objectMapper.writeValueAsString(htmlMetadata);
|
||||
|
||||
// Check if we should render metadata pane
|
||||
boolean hideMetadataProp = Boolean.getBoolean("html.hideMetadataPane");
|
||||
@@ -147,6 +151,41 @@ public class HtmlExporter implements StateMachineExporter {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static AnalysisResult enrichLinkKeys(AnalysisResult result, ExportOptions options) {
|
||||
CodebaseMetadata metadata = result.getMetadata();
|
||||
if (metadata == null || metadata.getCallChains() == null || metadata.getCallChains().isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<CallChain> enrichedChains = metadata.getCallChains().stream()
|
||||
.map(chain -> enrichChainLinkKeys(chain, options))
|
||||
.toList();
|
||||
return result.toBuilder()
|
||||
.metadata(metadata.toBuilder().callChains(enrichedChains).build())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static CallChain enrichChainLinkKeys(CallChain chain, ExportOptions options) {
|
||||
if (chain.getMatchedTransitions() == null || chain.getMatchedTransitions().isEmpty()) {
|
||||
return chain;
|
||||
}
|
||||
List<MatchedTransition> enriched = chain.getMatchedTransitions().stream()
|
||||
.map(mt -> enrichMatchedTransitionLinkKey(mt, options))
|
||||
.toList();
|
||||
return chain.toBuilder().matchedTransitions(enriched).build();
|
||||
}
|
||||
|
||||
private static MatchedTransition enrichMatchedTransitionLinkKey(MatchedTransition mt, ExportOptions options) {
|
||||
if (mt.getLinkKey() != null && !mt.getLinkKey().isBlank()) {
|
||||
return mt;
|
||||
}
|
||||
String linkKey = TransitionLinkKey.build(
|
||||
mt.getSourceState(),
|
||||
mt.getEvent(),
|
||||
options.getStateFormat(),
|
||||
options.getEventFormat());
|
||||
return mt.toBuilder().linkKey(linkKey).build();
|
||||
}
|
||||
|
||||
private String loadTemplate() {
|
||||
try (InputStream is = getClass().getResourceAsStream("/html/template.html")) {
|
||||
if (is == null) throw new RuntimeException("HTML template not found");
|
||||
|
||||
@@ -492,12 +492,13 @@
|
||||
let steps = [];
|
||||
chains.forEach(c => {
|
||||
c.matchedTransitions.forEach(t => {
|
||||
const key = buildLinkKey(t.sourceState, t.event);
|
||||
const key = t.linkKey || buildLinkKey(t.sourceState, t.event);
|
||||
if (!key || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
steps.push({
|
||||
event: t.event,
|
||||
source: t.sourceState,
|
||||
linkKey: t.linkKey,
|
||||
ambiguous: c.linkResolution === 'AMBIGUOUS_WIDEN'
|
||||
|| (c.triggerPoint && c.triggerPoint.ambiguous)
|
||||
});
|
||||
@@ -528,7 +529,7 @@
|
||||
let isAmbiguous = false;
|
||||
|
||||
if (typeof step === 'object') {
|
||||
targetLinkKey = buildLinkKey(step.source, step.event);
|
||||
targetLinkKey = step.linkKey || buildLinkKey(step.source, step.event);
|
||||
isAmbiguous = step.ambiguous;
|
||||
} else if (step.includes("->")) {
|
||||
const parts = step.split("->");
|
||||
|
||||
@@ -412,6 +412,7 @@ class HtmlTransitionLinkConsistencyTest {
|
||||
|
||||
assertThat(html).contains("Ambiguous link");
|
||||
assertThat(html).contains("#link_OrderState_NEW__OrderEvent_PAY");
|
||||
assertThat(html).contains("\"linkKey\" : \"OrderState_NEW__OrderEvent_PAY\"");
|
||||
assertThat(html).contains("com.example.web.AmbiguousController");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user