Harden export pipeline identifier consistency and HTML link parity tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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