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;
|
package click.kamil.springstatemachineexporter.analysis.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.extern.jackson.Jacksonized;
|
import lombok.extern.jackson.Jacksonized;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder(toBuilder = true)
|
||||||
@Jacksonized
|
@Jacksonized
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public class MatchedTransition {
|
public class MatchedTransition {
|
||||||
private final String sourceState;
|
private final String sourceState;
|
||||||
private final String targetState;
|
private final String targetState;
|
||||||
private final String event;
|
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.AnalysisResult;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
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.LinkResolution;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
|
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
|
||||||
import click.kamil.springstatemachineexporter.exporter.PlantUml;
|
import click.kamil.springstatemachineexporter.exporter.PlantUml;
|
||||||
import click.kamil.springstatemachineexporter.exporter.StateMachineExporter;
|
import click.kamil.springstatemachineexporter.exporter.StateMachineExporter;
|
||||||
|
import click.kamil.springstatemachineexporter.exporter.TransitionLinkKey;
|
||||||
import click.kamil.springstatemachineexporter.model.Transition;
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
@@ -58,8 +61,9 @@ public class HtmlExporter implements StateMachineExporter {
|
|||||||
// 4. Load Template
|
// 4. Load Template
|
||||||
String template = loadTemplate();
|
String template = loadTemplate();
|
||||||
|
|
||||||
// 5. Prepare Metadata JSON
|
// 5. Prepare Metadata JSON (precompute link keys so HTML JS matches SVG identifiers)
|
||||||
String metadataJson = objectMapper.writeValueAsString(result);
|
AnalysisResult htmlMetadata = enrichLinkKeys(result, options);
|
||||||
|
String metadataJson = objectMapper.writeValueAsString(htmlMetadata);
|
||||||
|
|
||||||
// Check if we should render metadata pane
|
// Check if we should render metadata pane
|
||||||
boolean hideMetadataProp = Boolean.getBoolean("html.hideMetadataPane");
|
boolean hideMetadataProp = Boolean.getBoolean("html.hideMetadataPane");
|
||||||
@@ -147,6 +151,41 @@ public class HtmlExporter implements StateMachineExporter {
|
|||||||
return false;
|
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() {
|
private String loadTemplate() {
|
||||||
try (InputStream is = getClass().getResourceAsStream("/html/template.html")) {
|
try (InputStream is = getClass().getResourceAsStream("/html/template.html")) {
|
||||||
if (is == null) throw new RuntimeException("HTML template not found");
|
if (is == null) throw new RuntimeException("HTML template not found");
|
||||||
|
|||||||
@@ -492,12 +492,13 @@
|
|||||||
let steps = [];
|
let steps = [];
|
||||||
chains.forEach(c => {
|
chains.forEach(c => {
|
||||||
c.matchedTransitions.forEach(t => {
|
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;
|
if (!key || seen.has(key)) return;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
steps.push({
|
steps.push({
|
||||||
event: t.event,
|
event: t.event,
|
||||||
source: t.sourceState,
|
source: t.sourceState,
|
||||||
|
linkKey: t.linkKey,
|
||||||
ambiguous: c.linkResolution === 'AMBIGUOUS_WIDEN'
|
ambiguous: c.linkResolution === 'AMBIGUOUS_WIDEN'
|
||||||
|| (c.triggerPoint && c.triggerPoint.ambiguous)
|
|| (c.triggerPoint && c.triggerPoint.ambiguous)
|
||||||
});
|
});
|
||||||
@@ -528,7 +529,7 @@
|
|||||||
let isAmbiguous = false;
|
let isAmbiguous = false;
|
||||||
|
|
||||||
if (typeof step === 'object') {
|
if (typeof step === 'object') {
|
||||||
targetLinkKey = buildLinkKey(step.source, step.event);
|
targetLinkKey = step.linkKey || buildLinkKey(step.source, step.event);
|
||||||
isAmbiguous = step.ambiguous;
|
isAmbiguous = step.ambiguous;
|
||||||
} else if (step.includes("->")) {
|
} else if (step.includes("->")) {
|
||||||
const parts = step.split("->");
|
const parts = step.split("->");
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ class HtmlTransitionLinkConsistencyTest {
|
|||||||
|
|
||||||
assertThat(html).contains("Ambiguous link");
|
assertThat(html).contains("Ambiguous link");
|
||||||
assertThat(html).contains("#link_OrderState_NEW__OrderEvent_PAY");
|
assertThat(html).contains("#link_OrderState_NEW__OrderEvent_PAY");
|
||||||
|
assertThat(html).contains("\"linkKey\" : \"OrderState_NEW__OrderEvent_PAY\"");
|
||||||
assertThat(html).contains("com.example.web.AmbiguousController");
|
assertThat(html).contains("com.example.web.AmbiguousController");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user