Harden export pipeline identifier consistency and HTML link parity tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,7 +7,6 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
|||||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
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.analysis.resolver.MachineEnumCanonicalizer;
|
|
||||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||||
import click.kamil.springstatemachineexporter.model.Transition;
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -127,29 +126,9 @@ public class AnalysisResult {
|
|||||||
if (stateTypeFqn == null && eventTypeFqn == null) {
|
if (stateTypeFqn == null && eventTypeFqn == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
StateMachineTypeResolver.MachineTypes machineTypes =
|
click.kamil.springstatemachineexporter.analysis.service.AnalysisResultFinalizer.applyCanonicalization(
|
||||||
new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn);
|
this,
|
||||||
MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes);
|
new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn));
|
||||||
if (states != null) {
|
|
||||||
this.states = MachineEnumCanonicalizer.canonicalizeStates(states, stateTypeFqn);
|
|
||||||
}
|
|
||||||
if (startStates != null) {
|
|
||||||
startStates = MachineEnumCanonicalizer.canonicalizeStateLabels(startStates, stateTypeFqn);
|
|
||||||
}
|
|
||||||
if (endStates != null) {
|
|
||||||
endStates = MachineEnumCanonicalizer.canonicalizeStateLabels(endStates, stateTypeFqn);
|
|
||||||
}
|
|
||||||
if (metadata != null && metadata.getTriggers() != null) {
|
|
||||||
List<TriggerPoint> canonicalTriggers = metadata.getTriggers().stream()
|
|
||||||
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
|
|
||||||
.collect(java.util.stream.Collectors.toList());
|
|
||||||
metadata = CodebaseMetadata.builder()
|
|
||||||
.triggers(canonicalTriggers)
|
|
||||||
.entryPoints(metadata.getEntryPoints())
|
|
||||||
.callChains(metadata.getCallChains())
|
|
||||||
.properties(metadata.getProperties())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TriggerPoint resolveTrigger(
|
private static TriggerPoint resolveTrigger(
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.service;
|
||||||
|
|
||||||
|
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.MatchedTransition;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
|
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
|
||||||
|
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Event;
|
||||||
|
import click.kamil.springstatemachineexporter.model.State;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
|
import click.kamil.springstatemachineexporter.service.ExportService;
|
||||||
|
import click.kamil.springstatemachineexporter.service.JsonImportService;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression gate: after the full {@link ExportService} pipeline, enum-backed machines must not
|
||||||
|
* contain mixed short-form ({@code OrderState.NEW}) and package-canonical
|
||||||
|
* ({@code com.example.order.OrderState.NEW}) identifiers in the same export.
|
||||||
|
*/
|
||||||
|
class ExportPipelineIdentifierConsistencyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonReExportShouldNotMixShortAndPackageCanonicalStateIdentifiers(@TempDir Path tempDir) throws Exception {
|
||||||
|
writeSampleProject(tempDir);
|
||||||
|
|
||||||
|
TriggerPoint trigger = TriggerPoint.builder()
|
||||||
|
.event("OrderEvent.PAY")
|
||||||
|
.sourceState("OrderState.NEW")
|
||||||
|
.className("com.example.web.OrderController")
|
||||||
|
.methodName("pay")
|
||||||
|
.sourceFile("OrderController.java")
|
||||||
|
.polymorphicEvents(List.of("OrderEvent.PAY"))
|
||||||
|
.build();
|
||||||
|
CallChain chain = CallChain.builder().triggerPoint(trigger).build();
|
||||||
|
|
||||||
|
AnalysisResult shortForm = AnalysisResult.builder()
|
||||||
|
.name("com.example.config.OrderStateMachineConfiguration")
|
||||||
|
.stateTypeFqn("com.example.order.OrderState")
|
||||||
|
.eventTypeFqn("com.example.order.OrderEvent")
|
||||||
|
.states(Set.of(
|
||||||
|
State.of("OrderState.NEW", "OrderState.NEW"),
|
||||||
|
State.of("UNDEFINED", "com.example.order.OrderState.PAID")))
|
||||||
|
.transitions(List.of(
|
||||||
|
shortTransition("OrderEvent.PAY", "OrderState.NEW", "OrderState.PAID"),
|
||||||
|
multiSourceTransition("OrderEvent.PAY", List.of("OrderState.NEW", "OrderState.PAID"), "OrderState.PAID")))
|
||||||
|
.startStates(Set.of("OrderState.NEW"))
|
||||||
|
.endStates(Set.of("OrderState.PAID"))
|
||||||
|
.metadata(CodebaseMetadata.builder()
|
||||||
|
.triggers(List.of(trigger))
|
||||||
|
.callChains(List.of(chain))
|
||||||
|
.properties(Map.of("default", Map.of(
|
||||||
|
"app.event", "OrderEvent.PAY",
|
||||||
|
"app.state", "OrderState.NEW")))
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Path jsonFile = tempDir.resolve("machine.json");
|
||||||
|
Files.writeString(jsonFile, new JsonExporter().export(shortForm, ExportOptions.builder().build()));
|
||||||
|
|
||||||
|
Path outputDir = tempDir.resolve("out");
|
||||||
|
ExportService exportService = new ExportService(List.of(new JsonExporter()));
|
||||||
|
exportService.runJsonExporter(jsonFile, outputDir, List.of("json"), List.of("default"));
|
||||||
|
|
||||||
|
AnalysisResult exported = new JsonImportService().importAnalysisResult(
|
||||||
|
outputDir.resolve("com.example.config.OrderStateMachineConfiguration")
|
||||||
|
.resolve("com.example.config.OrderStateMachineConfiguration.json"));
|
||||||
|
|
||||||
|
assertNoMixedEnumIdentifiers(exported);
|
||||||
|
assertThat(exported.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNotEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void astExportShouldNotMixShortAndPackageCanonicalStateIdentifiers(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
|
||||||
|
Path outputDir = tempDir.resolve("enterprise-out");
|
||||||
|
|
||||||
|
ExportService exportService = new ExportService(List.of(new JsonExporter()));
|
||||||
|
exportService.runExporter(enterprise, outputDir, List.of("json"), true, List.of());
|
||||||
|
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
try (var dirs = Files.list(outputDir)) {
|
||||||
|
List<Path> machineDirs = dirs.filter(Files::isDirectory).toList();
|
||||||
|
assertThat(machineDirs).isNotEmpty();
|
||||||
|
for (Path machineDir : machineDirs) {
|
||||||
|
Path json = machineDir.resolve(machineDir.getFileName() + ".json");
|
||||||
|
JsonNode root = mapper.readTree(json.toFile());
|
||||||
|
if (!root.hasNonNull("stateTypeFqn") || !root.get("stateTypeFqn").asText().contains(".")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
AnalysisResult result = new JsonImportService().importAnalysisResult(json);
|
||||||
|
assertNoMixedEnumIdentifiers(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void propertyResolutionShouldCanonicalizeCallChainTriggersBeforeRelink(@TempDir Path tempDir) throws Exception {
|
||||||
|
writeSampleProject(tempDir);
|
||||||
|
|
||||||
|
TriggerPoint trigger = TriggerPoint.builder()
|
||||||
|
.event("${app.event}")
|
||||||
|
.sourceState("${app.state}")
|
||||||
|
.className("com.example.web.OrderController")
|
||||||
|
.methodName("pay")
|
||||||
|
.sourceFile("OrderController.java")
|
||||||
|
.polymorphicEvents(List.of("${app.event}"))
|
||||||
|
.eventTypeFqn("com.example.order.OrderEvent")
|
||||||
|
.stateTypeFqn("com.example.order.OrderState")
|
||||||
|
.build();
|
||||||
|
CallChain chain = CallChain.builder()
|
||||||
|
.triggerPoint(trigger)
|
||||||
|
.matchedTransitions(List.of(MatchedTransition.builder()
|
||||||
|
.event("OrderEvent.PAY")
|
||||||
|
.sourceState("OrderState.NEW")
|
||||||
|
.targetState("OrderState.PAID")
|
||||||
|
.build()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
AnalysisResult result = AnalysisResult.builder()
|
||||||
|
.name("com.example.config.OrderStateMachineConfiguration")
|
||||||
|
.stateTypeFqn("com.example.order.OrderState")
|
||||||
|
.eventTypeFqn("com.example.order.OrderEvent")
|
||||||
|
.transitions(List.of(shortTransition("OrderEvent.PAY", "OrderState.NEW", "OrderState.PAID")))
|
||||||
|
.metadata(CodebaseMetadata.builder()
|
||||||
|
.triggers(List.of(trigger))
|
||||||
|
.callChains(List.of(chain))
|
||||||
|
.properties(Map.of("default", Map.of(
|
||||||
|
"app.event", "OrderEvent.PAY",
|
||||||
|
"app.state", "OrderState.NEW")))
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
result.applyResolution(Map.of("app.event", "OrderEvent.PAY", "app.state", "OrderState.NEW"));
|
||||||
|
|
||||||
|
TriggerPoint chainTrigger = result.getMetadata().getCallChains().get(0).getTriggerPoint();
|
||||||
|
assertThat(chainTrigger.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY");
|
||||||
|
assertThat(chainTrigger.getSourceState()).isEqualTo("com.example.order.OrderState.NEW");
|
||||||
|
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getSourceState())
|
||||||
|
.isEqualTo("com.example.order.OrderState.NEW");
|
||||||
|
assertNoMixedEnumIdentifiers(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void assertNoMixedEnumIdentifiers(AnalysisResult result) {
|
||||||
|
String stateTypeFqn = result.getStateTypeFqn();
|
||||||
|
String eventTypeFqn = result.getEventTypeFqn();
|
||||||
|
if (stateTypeFqn == null || !stateTypeFqn.contains(".")
|
||||||
|
|| MachineEnumCanonicalizer.isStringOrPrimitiveType(stateTypeFqn)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (eventTypeFqn == null || !eventTypeFqn.contains(".")
|
||||||
|
|| MachineEnumCanonicalizer.isStringOrPrimitiveType(eventTypeFqn)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> stateIds = collectStateIdentifiers(result);
|
||||||
|
Set<String> eventIds = collectEventIdentifiers(result);
|
||||||
|
|
||||||
|
assertCanonicalSet(stateIds, stateTypeFqn, "state");
|
||||||
|
assertCanonicalSet(eventIds, eventTypeFqn, "event");
|
||||||
|
assertNoShortFormWhenPackageFormPresent(stateIds, stateTypeFqn);
|
||||||
|
assertNoShortFormWhenPackageFormPresent(eventIds, eventTypeFqn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertCanonicalSet(Set<String> identifiers, String enumTypeFqn, String label) {
|
||||||
|
for (String id : identifiers) {
|
||||||
|
if (id == null || id.isBlank() || id.startsWith("<") || id.startsWith("\"")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!MachineEnumCanonicalizer.isMachineEnumReference(id, enumTypeFqn)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assertThat(id)
|
||||||
|
.as("%s identifier must be package-canonical: %s", label, id)
|
||||||
|
.isEqualTo(MachineEnumCanonicalizer.canonicalizeLabel(id, enumTypeFqn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertNoShortFormWhenPackageFormPresent(Set<String> identifiers, String enumTypeFqn) {
|
||||||
|
Set<String> packageForms = new HashSet<>();
|
||||||
|
Set<String> shortForms = new HashSet<>();
|
||||||
|
for (String id : identifiers) {
|
||||||
|
if (id == null || !MachineEnumCanonicalizer.isMachineEnumReference(id, enumTypeFqn)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (id.startsWith(enumTypeFqn + ".")) {
|
||||||
|
packageForms.add(id);
|
||||||
|
String fn = fnForm(id);
|
||||||
|
if (fn != null) {
|
||||||
|
shortForms.add(fn);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shortForms.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String pkg : packageForms) {
|
||||||
|
String fn = fnForm(pkg);
|
||||||
|
assertThat(identifiers)
|
||||||
|
.as("mixed short/package identifiers for %s", pkg)
|
||||||
|
.doesNotContain(fn);
|
||||||
|
}
|
||||||
|
assertThat(shortForms.stream().filter(id -> !id.startsWith(enumTypeFqn + ".")).toList())
|
||||||
|
.as("unexpected bare short forms while package forms exist: %s vs %s", shortForms, packageForms)
|
||||||
|
.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fnForm(String identifier) {
|
||||||
|
if (identifier == null || identifier.isBlank()) {
|
||||||
|
return identifier;
|
||||||
|
}
|
||||||
|
int lastDot = identifier.lastIndexOf('.');
|
||||||
|
if (lastDot <= 0) {
|
||||||
|
return identifier;
|
||||||
|
}
|
||||||
|
int prevDot = identifier.lastIndexOf('.', lastDot - 1);
|
||||||
|
return prevDot > 0 ? identifier.substring(prevDot + 1) : identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> collectStateIdentifiers(AnalysisResult result) {
|
||||||
|
Set<String> ids = new LinkedHashSet<>();
|
||||||
|
if (result.getStates() != null) {
|
||||||
|
result.getStates().forEach(s -> addIfPresent(ids, s.fullIdentifier()));
|
||||||
|
}
|
||||||
|
if (result.getStartStates() != null) {
|
||||||
|
ids.addAll(result.getStartStates());
|
||||||
|
}
|
||||||
|
if (result.getEndStates() != null) {
|
||||||
|
ids.addAll(result.getEndStates());
|
||||||
|
}
|
||||||
|
if (result.getTransitions() != null) {
|
||||||
|
for (Transition t : result.getTransitions()) {
|
||||||
|
addStates(ids, t.getSourceStates());
|
||||||
|
addStates(ids, t.getTargetStates());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.getMetadata() != null) {
|
||||||
|
if (result.getMetadata().getTriggers() != null) {
|
||||||
|
for (TriggerPoint trigger : result.getMetadata().getTriggers()) {
|
||||||
|
addIfPresent(ids, trigger.getSourceState());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.getMetadata().getCallChains() != null) {
|
||||||
|
for (CallChain chain : result.getMetadata().getCallChains()) {
|
||||||
|
if (chain.getTriggerPoint() != null) {
|
||||||
|
addIfPresent(ids, chain.getTriggerPoint().getSourceState());
|
||||||
|
}
|
||||||
|
if (chain.getMatchedTransitions() != null) {
|
||||||
|
for (MatchedTransition mt : chain.getMatchedTransitions()) {
|
||||||
|
addIfPresent(ids, mt.getSourceState());
|
||||||
|
addIfPresent(ids, mt.getTargetState());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids.removeIf(id -> id == null || id.isBlank());
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> collectEventIdentifiers(AnalysisResult result) {
|
||||||
|
Set<String> ids = new LinkedHashSet<>();
|
||||||
|
if (result.getTransitions() != null) {
|
||||||
|
for (Transition t : result.getTransitions()) {
|
||||||
|
if (t.getEvent() != null) {
|
||||||
|
addIfPresent(ids, t.getEvent().fullIdentifier());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.getMetadata() != null) {
|
||||||
|
if (result.getMetadata().getTriggers() != null) {
|
||||||
|
for (TriggerPoint trigger : result.getMetadata().getTriggers()) {
|
||||||
|
addIfPresent(ids, trigger.getEvent());
|
||||||
|
if (trigger.getPolymorphicEvents() != null) {
|
||||||
|
ids.addAll(trigger.getPolymorphicEvents());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.getMetadata().getCallChains() != null) {
|
||||||
|
for (CallChain chain : result.getMetadata().getCallChains()) {
|
||||||
|
if (chain.getTriggerPoint() != null) {
|
||||||
|
addIfPresent(ids, chain.getTriggerPoint().getEvent());
|
||||||
|
if (chain.getTriggerPoint().getPolymorphicEvents() != null) {
|
||||||
|
ids.addAll(chain.getTriggerPoint().getPolymorphicEvents());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chain.getMatchedTransitions() != null) {
|
||||||
|
for (MatchedTransition mt : chain.getMatchedTransitions()) {
|
||||||
|
addIfPresent(ids, mt.getEvent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids.removeIf(id -> id == null || id.isBlank());
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addStates(Set<String> ids, List<State> states) {
|
||||||
|
if (states == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (State state : states) {
|
||||||
|
addIfPresent(ids, state.fullIdentifier());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addIfPresent(Set<String> ids, String value) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
ids.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Transition shortTransition(String event, String source, String target) {
|
||||||
|
Transition transition = new Transition();
|
||||||
|
transition.setEvent(Event.of(event, event));
|
||||||
|
transition.setSourceStates(List.of(State.of(source, source)));
|
||||||
|
transition.setTargetStates(List.of(State.of(target, target)));
|
||||||
|
return transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Transition multiSourceTransition(String event, List<String> sources, String target) {
|
||||||
|
Transition transition = new Transition();
|
||||||
|
transition.setEvent(Event.of(event, event));
|
||||||
|
transition.setSourceStates(sources.stream().map(s -> State.of(s, s)).toList());
|
||||||
|
transition.setTargetStates(List.of(State.of(target, target)));
|
||||||
|
return transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeSampleProject(Path projectRoot) throws Exception {
|
||||||
|
Path javaRoot = projectRoot.resolve("src/main/java");
|
||||||
|
Path orderPkg = javaRoot.resolve("com/example/order");
|
||||||
|
Path configPkg = javaRoot.resolve("com/example/config");
|
||||||
|
Files.createDirectories(orderPkg);
|
||||||
|
Files.createDirectories(configPkg);
|
||||||
|
Files.writeString(projectRoot.resolve("build.gradle"), "plugins { id 'java' }");
|
||||||
|
Files.writeString(orderPkg.resolve("OrderState.java"),
|
||||||
|
"package com.example.order; public enum OrderState { NEW, PAID }");
|
||||||
|
Files.writeString(orderPkg.resolve("OrderEvent.java"),
|
||||||
|
"package com.example.order; public enum OrderEvent { PAY }");
|
||||||
|
Files.writeString(configPkg.resolve("OrderStateMachineConfiguration.java"),
|
||||||
|
"""
|
||||||
|
package com.example.config;
|
||||||
|
import com.example.order.OrderEvent;
|
||||||
|
import com.example.order.OrderState;
|
||||||
|
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
|
||||||
|
public class OrderStateMachineConfiguration
|
||||||
|
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path findProjectRoot() {
|
||||||
|
Path current = Path.of(".").toAbsolutePath();
|
||||||
|
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
|
||||||
|
current = current.getParent();
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.html;
|
||||||
|
|
||||||
|
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.MatchedTransition;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
|
||||||
|
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
|
||||||
|
import click.kamil.springstatemachineexporter.html.exporter.HtmlExporter;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Event;
|
||||||
|
import click.kamil.springstatemachineexporter.model.State;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
|
import click.kamil.springstatemachineexporter.service.ExportService;
|
||||||
|
import click.kamil.springstatemachineexporter.service.JsonImportService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end HTML link-key consistency: SVG {@code #link_*} anchors must align with
|
||||||
|
* {@code matchedTransitions} produced by the finalized export pipeline.
|
||||||
|
*/
|
||||||
|
class HtmlTransitionLinkConsistencyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonReExportHtmlShouldContainLinkKeysForEveryMatchedTransition(@TempDir Path tempDir) throws Exception {
|
||||||
|
writeSampleProject(tempDir);
|
||||||
|
|
||||||
|
TriggerPoint trigger = TriggerPoint.builder()
|
||||||
|
.event("OrderEvent.PAY")
|
||||||
|
.sourceState("OrderState.NEW")
|
||||||
|
.className("com.example.web.OrderController")
|
||||||
|
.methodName("pay")
|
||||||
|
.sourceFile("OrderController.java")
|
||||||
|
.polymorphicEvents(List.of("OrderEvent.PAY"))
|
||||||
|
.build();
|
||||||
|
CallChain chain = CallChain.builder().triggerPoint(trigger).build();
|
||||||
|
|
||||||
|
AnalysisResult shortForm = AnalysisResult.builder()
|
||||||
|
.name("com.example.config.OrderStateMachineConfiguration")
|
||||||
|
.stateTypeFqn("com.example.order.OrderState")
|
||||||
|
.eventTypeFqn("com.example.order.OrderEvent")
|
||||||
|
.transitions(List.of(shortTransition("OrderEvent.PAY", "OrderState.NEW", "OrderState.PAID")))
|
||||||
|
.startStates(Set.of("OrderState.NEW"))
|
||||||
|
.endStates(Set.of("OrderState.PAID"))
|
||||||
|
.metadata(CodebaseMetadata.builder()
|
||||||
|
.triggers(List.of(trigger))
|
||||||
|
.callChains(List.of(chain))
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Path jsonFile = tempDir.resolve("machine.json");
|
||||||
|
Files.writeString(jsonFile, new JsonExporter().export(shortForm, ExportOptions.builder().build()));
|
||||||
|
|
||||||
|
Path outputDir = tempDir.resolve("out");
|
||||||
|
ExportService exportService = new ExportService(List.of(new HtmlExporter(), new JsonExporter()));
|
||||||
|
exportService.runJsonExporter(jsonFile, outputDir, List.of("html", "json"));
|
||||||
|
|
||||||
|
Path machineDir = outputDir.resolve("com.example.config.OrderStateMachineConfiguration");
|
||||||
|
Path htmlFile = machineDir.resolve("com.example.config.OrderStateMachineConfiguration.html");
|
||||||
|
Path finalizedJson = machineDir.resolve("com.example.config.OrderStateMachineConfiguration.json");
|
||||||
|
assertThat(htmlFile).exists();
|
||||||
|
|
||||||
|
String html = Files.readString(htmlFile);
|
||||||
|
AnalysisResult finalized = new JsonImportService().importAnalysisResult(finalizedJson);
|
||||||
|
|
||||||
|
List<MatchedTransition> matched = finalized.getMetadata().getCallChains().stream()
|
||||||
|
.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("OrderState_NEW__OrderEvent_PAY");
|
||||||
|
assertThat(html).contains("com.example.order.OrderEvent.PAY");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enterpriseAstExportHtmlShouldContainTransitionLinkKeys(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
|
||||||
|
Path outputDir = tempDir.resolve("enterprise-out");
|
||||||
|
|
||||||
|
ExportService exportService = new ExportService(List.of(new HtmlExporter()));
|
||||||
|
exportService.runExporter(enterprise, outputDir, List.of("html"), true, List.of(), null,
|
||||||
|
"OrderStateMachineConfiguration");
|
||||||
|
|
||||||
|
Path machineDir = outputDir.resolve("click.kamil.enterprise.machines.order.OrderStateMachineConfiguration");
|
||||||
|
Path htmlFile = machineDir.resolve("click.kamil.enterprise.machines.order.OrderStateMachineConfiguration.html");
|
||||||
|
assertThat(htmlFile).exists();
|
||||||
|
|
||||||
|
String html = Files.readString(htmlFile);
|
||||||
|
assertThat(html).contains("OrderState_NEW__OrderEvent_PAY");
|
||||||
|
assertThat(html).contains("function buildLinkKey(source, event)");
|
||||||
|
assertThat(html).contains("\"matchedTransitions\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors {@code template.html} link-key derivation for regression checks. */
|
||||||
|
private static String buildLinkKey(String source, String event) {
|
||||||
|
if (event == null || event.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String formattedEvent = formatForLinkId(event);
|
||||||
|
if (source == null || source.isBlank()) {
|
||||||
|
return "__" + normalize(formattedEvent);
|
||||||
|
}
|
||||||
|
return normalize(formatForLinkId(source)) + "__" + normalize(formattedEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatForLinkId(String identifier) {
|
||||||
|
if (identifier == null || identifier.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int lastDot = identifier.lastIndexOf('.');
|
||||||
|
if (lastDot <= 0) {
|
||||||
|
return identifier;
|
||||||
|
}
|
||||||
|
int prevDot = identifier.lastIndexOf('.', lastDot - 1);
|
||||||
|
return prevDot > 0 ? identifier.substring(prevDot + 1) : identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String s) {
|
||||||
|
if (s == null || s.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return s.replaceAll("[^a-zA-Z0-9]", "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Transition shortTransition(String event, String source, String target) {
|
||||||
|
Transition transition = new Transition();
|
||||||
|
transition.setEvent(Event.of(event, event));
|
||||||
|
transition.setSourceStates(List.of(State.of(source, source)));
|
||||||
|
transition.setTargetStates(List.of(State.of(target, target)));
|
||||||
|
return transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeSampleProject(Path projectRoot) throws Exception {
|
||||||
|
Path javaRoot = projectRoot.resolve("src/main/java");
|
||||||
|
Path orderPkg = javaRoot.resolve("com/example/order");
|
||||||
|
Path configPkg = javaRoot.resolve("com/example/config");
|
||||||
|
Files.createDirectories(orderPkg);
|
||||||
|
Files.createDirectories(configPkg);
|
||||||
|
Files.writeString(projectRoot.resolve("build.gradle"), "plugins { id 'java' }");
|
||||||
|
Files.writeString(orderPkg.resolve("OrderState.java"),
|
||||||
|
"package com.example.order; public enum OrderState { NEW, PAID }");
|
||||||
|
Files.writeString(orderPkg.resolve("OrderEvent.java"),
|
||||||
|
"package com.example.order; public enum OrderEvent { PAY }");
|
||||||
|
Files.writeString(configPkg.resolve("OrderStateMachineConfiguration.java"),
|
||||||
|
"""
|
||||||
|
package com.example.config;
|
||||||
|
import com.example.order.OrderEvent;
|
||||||
|
import com.example.order.OrderState;
|
||||||
|
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
|
||||||
|
public class OrderStateMachineConfiguration
|
||||||
|
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path findProjectRoot() {
|
||||||
|
Path current = Path.of(".").toAbsolutePath();
|
||||||
|
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
|
||||||
|
current = current.getParent();
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user