v2 extended analysis render update

This commit is contained in:
2026-06-14 21:32:13 +02:00
parent 2b6b727de6
commit 38d708f85b
10 changed files with 254 additions and 2299 deletions

File diff suppressed because one or more lines are too long

View File

@@ -23,6 +23,9 @@ public class AnalysisResult {
private final Set<String> endStates; private final Set<String> endStates;
private final boolean renderChoicesAsDiamonds; private final boolean renderChoicesAsDiamonds;
@Builder.Default
private final List<BusinessFlow> flows = java.util.Collections.emptyList();
@Builder.Default @Builder.Default
private CodebaseMetadata metadata = CodebaseMetadata.empty(); private CodebaseMetadata metadata = CodebaseMetadata.empty();

View File

@@ -0,0 +1,18 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
import java.util.List;
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class BusinessFlow {
private final String name;
private final String description;
private final List<String> steps; // List of Event names in order
}

View File

@@ -5,6 +5,7 @@ import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnrich
import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider; import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService; import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
import click.kamil.springstatemachineexporter.analysis.service.JdtIntelligenceProvider; import click.kamil.springstatemachineexporter.analysis.service.JdtIntelligenceProvider;
@@ -24,10 +25,7 @@ import java.io.IOException;
import java.io.PrintWriter; import java.io.PrintWriter;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Collections; import java.util.*;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Slf4j @Slf4j
public class ExportService { public class ExportService {
@@ -72,10 +70,25 @@ public class ExportService {
context.loadLibraryHints(hintsFile); context.loadLibraryHints(hintsFile);
} }
List<BusinessFlow> flows = new ArrayList<>();
Path flowsFile = inputDir.resolve("flows.json");
if (!Files.exists(flowsFile)) {
flowsFile = inputDir.resolve("src/main/resources/flows.json");
}
if (Files.exists(flowsFile)) {
log.info("Loading business flows from {}", flowsFile.toAbsolutePath());
try {
flows = new com.fasterxml.jackson.databind.ObjectMapper()
.readValue(flowsFile.toFile(), new com.fasterxml.jackson.core.type.TypeReference<List<BusinessFlow>>() {});
} catch (Exception e) {
log.error("Failed to load flows.json", e);
}
}
context.scan(inputDir); context.scan(inputDir);
CodebaseIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, inputDir); CodebaseIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, inputDir);
exportAll(context, intelligence, outputDir, selectedFormats, renderChoicesAsDiamonds); exportAll(context, intelligence, outputDir, selectedFormats, renderChoicesAsDiamonds, flows);
} }
public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats) throws IOException { public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats) throws IOException {
@@ -85,7 +98,7 @@ public class ExportService {
generateOutputs(outputDir, result, selectedFormats); generateOutputs(outputDir, result, selectedFormats);
} }
private void exportAll(CodebaseContext context, CodebaseIntelligenceProvider intelligence, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException { private void exportAll(CodebaseContext context, CodebaseIntelligenceProvider intelligence, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<BusinessFlow> flows) throws IOException {
Set<String> processedLocations = new HashSet<>(); Set<String> processedLocations = new HashSet<>();
// 1. Find entry point classes (annotated or extending adapter) // 1. Find entry point classes (annotated or extending adapter)
@@ -93,7 +106,7 @@ public class ExportService {
for (TypeDeclaration td : entryPoints) { for (TypeDeclaration td : entryPoints) {
String fqn = context.getFqn(td); String fqn = context.getFqn(td);
if (processedLocations.add(fqn)) { if (processedLocations.add(fqn)) {
processEntryPoint(td, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds); processEntryPoint(td, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds, flows);
} }
} }
@@ -106,12 +119,12 @@ public class ExportService {
String uniqueId = parentFqn + "#" + methodName; String uniqueId = parentFqn + "#" + methodName;
if (processedLocations.add(uniqueId)) { if (processedLocations.add(uniqueId)) {
processBeanMethod(m, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds); processBeanMethod(m, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds, flows);
} }
} }
} }
private void processEntryPoint(TypeDeclaration td, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException { private void processEntryPoint(TypeDeclaration td, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<BusinessFlow> flows) throws IOException {
String className = context.getFqn(td); String className = context.getFqn(td);
log.info("Processing state machine config: {}", className); log.info("Processing state machine config: {}", className);
@@ -134,6 +147,7 @@ public class ExportService {
.startStates(startStates) .startStates(startStates)
.endStates(endStates) .endStates(endStates)
.renderChoicesAsDiamonds(renderChoicesAsDiamonds) .renderChoicesAsDiamonds(renderChoicesAsDiamonds)
.flows(flows)
.build(); .build();
enrichmentService.enrich(result, context, intelligence); enrichmentService.enrich(result, context, intelligence);
@@ -141,7 +155,7 @@ public class ExportService {
generateOutputs(outputDir, result, selectedFormats); generateOutputs(outputDir, result, selectedFormats);
} }
private void processBeanMethod(MethodDeclaration m, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException { private void processBeanMethod(MethodDeclaration m, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<BusinessFlow> flows) throws IOException {
TypeDeclaration parentClass = (TypeDeclaration) m.getParent(); TypeDeclaration parentClass = (TypeDeclaration) m.getParent();
String parentFqn = context.getFqn(parentClass); String parentFqn = context.getFqn(parentClass);
String methodName = m.getName().getIdentifier(); String methodName = m.getName().getIdentifier();
@@ -160,6 +174,7 @@ public class ExportService {
.startStates(startStates) .startStates(startStates)
.endStates(endStates) .endStates(endStates)
.renderChoicesAsDiamonds(renderChoicesAsDiamonds) .renderChoicesAsDiamonds(renderChoicesAsDiamonds)
.flows(flows)
.build(); .build();
enrichmentService.enrich(result, context, intelligence); enrichmentService.enrich(result, context, intelligence);

View File

@@ -41,16 +41,55 @@
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 30px;
overflow-y: auto;
box-shadow: 10px 0 15px rgba(0,0,0,0.02); box-shadow: 10px 0 15px rgba(0,0,0,0.02);
z-index: 10; z-index: 10;
} }
header h1 { font-weight: 900; font-size: 1.6rem; color: var(--text); margin: 0 0 5px 0; letter-spacing: -0.5px; } .sidebar-header {
header p { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: var(--text-muted); margin: 0 0 35px 0; opacity: 0.7; } padding: 30px 30px 10px;
}
.ep-card { header h1 { font-weight: 900; font-size: 1.6rem; color: var(--text); margin: 0 0 5px 0; letter-spacing: -0.5px; }
header p { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: var(--text-muted); margin: 0 0 15px 0; opacity: 0.7; }
/* Tabs */
.tabs {
display: flex;
padding: 0 30px;
border-bottom: 1px solid var(--border);
gap: 20px;
}
.tab {
padding: 10px 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
position: relative;
transition: color 0.2s;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--primary); }
.tab.active::after {
content: '';
position: absolute;
bottom: -1px; left: 0; right: 0;
height: 2px;
background: var(--primary);
}
.tab-content {
flex-grow: 1;
padding: 25px 30px;
overflow-y: auto;
display: none;
}
.tab-content.active { display: block; }
.ep-card, .flow-card {
padding: 18px; padding: 18px;
background: #fff; background: #fff;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -59,9 +98,10 @@
cursor: pointer; cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0,0,0,0.04); box-shadow: 0 1px 3px rgba(0,0,0,0.04);
position: relative;
} }
.ep-card:hover { .ep-card:hover, .flow-card:hover {
border-color: var(--primary); border-color: var(--primary);
transform: translateX(5px); transform: translateX(5px);
box-shadow: 0 15px 30px -10px rgba(59, 130, 246, 0.15); box-shadow: 0 15px 30px -10px rgba(59, 130, 246, 0.15);
@@ -70,7 +110,8 @@
.ep-type { .ep-type {
font-size: 0.6rem; font-size: 0.6rem;
font-weight: 900; font-weight: 900;
padding: 3px 8px; border-radius: 6px; padding: 3px 8px;
border-radius: 6px;
display: inline-block; display: inline-block;
margin-bottom: 10px; margin-bottom: 10px;
text-transform: uppercase; text-transform: uppercase;
@@ -84,8 +125,8 @@
.type-kafka { background: #fefce8; color: #854d0e; } .type-kafka { background: #fefce8; color: #854d0e; }
.type-rabbit { background: #f0fdf4; color: #166534; } .type-rabbit { background: #f0fdf4; color: #166534; }
.ep-name { font-weight: 700; font-size: 0.95rem; margin-bottom: 6px; color: var(--text); } .ep-name, .flow-name { font-weight: 700; font-size: 0.95rem; margin-bottom: 6px; color: var(--text); }
.ep-fqn { font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; color: var(--text-muted); opacity: 0.8; } .ep-fqn, .flow-desc { font-family: 'Inter', sans-serif; font-size: 0.75rem; color: var(--text-muted); opacity: 0.9; line-height: 1.4; }
#main { flex-grow: 1; position: relative; background: var(--main-bg); overflow: hidden; } #main { flex-grow: 1; position: relative; background: var(--main-bg); overflow: hidden; }
#svg-container { #svg-container {
@@ -165,12 +206,25 @@
<body> <body>
<div id="sidebar"> <div id="sidebar">
<div class="sidebar-header">
<header> <header>
<h1>Explorer</h1> <h1>Explorer</h1>
<p>{{MACHINE_NAME}}</p> <p>{{MACHINE_NAME}}</p>
</header> </header>
</div>
<div class="tabs">
<div class="tab active" data-tab="explorer">Explorer</div>
<div class="tab" data-tab="flows">Business Flows</div>
</div>
<div id="tab-explorer" class="tab-content active">
<div id="ep-list"></div> <div id="ep-list"></div>
</div>
<div id="tab-flows" class="tab-content">
<div id="flow-list"></div>
</div>
</div> </div>
<div id="main"> <div id="main">
@@ -191,10 +245,30 @@
const svg = document.querySelector('svg'); const svg = document.querySelector('svg');
panZoomInstance = svgPanZoom(svg, { zoomEnabled: true, controlIconsEnabled: true, fit: true, center: true }); panZoomInstance = svgPanZoom(svg, { zoomEnabled: true, controlIconsEnabled: true, fit: true, center: true });
setupTabs();
populateSidebar(); populateSidebar();
populateFlows();
attachInteractivity(); attachInteractivity();
}); });
function setupTabs() {
const flows = metadata.flows || [];
if (flows.length === 0) {
// No flows? Hide the entire tab system to keep the UI clean
document.querySelector('.tabs').style.display = 'none';
document.querySelector('.tab-content[data-tab="flows"]')?.remove();
return;
}
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab, .tab-content').forEach(el => el.classList.remove('active'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
});
});
}
function populateSidebar() { function populateSidebar() {
const list = document.getElementById('ep-list'); const list = document.getElementById('ep-list');
const entryPoints = metadata.metadata.entryPoints || []; const entryPoints = metadata.metadata.entryPoints || [];
@@ -207,23 +281,52 @@
<div class="ep-name">${ep.name}</div> <div class="ep-name">${ep.name}</div>
<div class="ep-fqn">${ep.className}.${ep.methodName}</div> <div class="ep-fqn">${ep.className}.${ep.methodName}</div>
`; `;
card.onmouseenter = () => highlightPaths(ep); card.onmouseenter = () => highlightEntryPoint(ep);
card.onmouseleave = clearHighlights; card.onmouseleave = clearHighlights;
list.appendChild(card); list.appendChild(card);
}); });
} }
function highlightPaths(ep) { function populateFlows() {
const list = document.getElementById('flow-list');
const flows = metadata.flows || [];
if (flows.length === 0) {
list.innerHTML = '<p style="color:var(--text-muted); font-size:0.8rem; font-style:italic;">No business flows defined. Add a flows.json to your project to see them here.</p>';
return;
}
flows.forEach(flow => {
const card = document.createElement('div');
card.className = 'flow-card';
card.innerHTML = `
<div class="flow-name">${flow.name}</div>
<div class="flow-desc">${flow.description}</div>
`;
card.onmouseenter = () => highlightFlow(flow);
card.onmouseleave = clearHighlights;
list.appendChild(card);
});
}
function highlightEntryPoint(ep) {
clearHighlights(); clearHighlights();
const chains = metadata.metadata.callChains || []; const chains = metadata.metadata.callChains || [];
// Surgical Filter: Match by full class and method name
const relevantEvents = chains const relevantEvents = chains
.filter(c => c.entryPoint.className === ep.className && c.entryPoint.methodName === ep.methodName) .filter(c => c.entryPoint.className === ep.className && c.entryPoint.methodName === ep.methodName)
.map(c => c.triggerPoint.event); .map(c => c.triggerPoint.event);
if (relevantEvents.length === 0) return; if (relevantEvents.length === 0) return;
highlightEvents(relevantEvents);
}
function highlightFlow(flow) {
clearHighlights();
if (!flow.steps || flow.steps.length === 0) return;
highlightEvents(flow.steps);
}
function highlightEvents(events) {
const svg = document.querySelector('svg'); const svg = document.querySelector('svg');
// Dim everything first // Dim everything first
@@ -231,16 +334,15 @@
el.classList.add('dimmed'); el.classList.add('dimmed');
}); });
relevantEvents.forEach(evt => { events.forEach(evt => {
const normalized = normalize(evt); const normalized = normalize(evt);
const linkId = "#link_" + normalized; const linkId = "#link_" + normalized;
// Find <a> tags via hyperlinks
svg.querySelectorAll(`a[*|href="${linkId}"], a[href="${linkId}"]`).forEach(link => { svg.querySelectorAll(`a[*|href="${linkId}"], a[href="${linkId}"]`).forEach(link => {
link.classList.add('active-path'); link.classList.add('active-path');
link.querySelectorAll('*').forEach(child => child.classList.remove('dimmed')); link.querySelectorAll('*').forEach(child => child.classList.remove('dimmed'));
// Surgical Selection: Grab ONLY the immediate path and polygon // Surgical Selection
let prev = link.previousElementSibling; let prev = link.previousElementSibling;
let foundPath = false; let foundPath = false;
let foundPolygon = false; let foundPolygon = false;
@@ -262,7 +364,7 @@
}); });
}); });
// Keep all nodes visible for context // Un-dim all state nodes for context
svg.querySelectorAll('rect, circle, ellipse').forEach(el => { svg.querySelectorAll('rect, circle, ellipse').forEach(el => {
el.classList.remove('dimmed'); el.classList.remove('dimmed');
let next = el.nextElementSibling; let next = el.nextElementSibling;
@@ -280,7 +382,6 @@
const svg = document.querySelector('svg'); const svg = document.querySelector('svg');
const transitions = metadata.transitions || []; const transitions = metadata.transitions || [];
// Attach tooltips to hyperlink groups
svg.querySelectorAll('a').forEach(link => { svg.querySelectorAll('a').forEach(link => {
const href = link.getAttribute('xlink:href') || link.getAttribute('href'); const href = link.getAttribute('xlink:href') || link.getAttribute('href');
if (!href || !href.startsWith('#link_')) return; if (!href || !href.startsWith('#link_')) return;
@@ -292,7 +393,6 @@
if (metaTrans || chains.length > 0) { if (metaTrans || chains.length > 0) {
link.style.cursor = 'pointer'; link.style.cursor = 'pointer';
// Map to the associated path
let pathToTrigger = null; let pathToTrigger = null;
let prev = link.previousElementSibling; let prev = link.previousElementSibling;
while (prev) { while (prev) {
@@ -338,7 +438,7 @@
if (trans && trans.guard) { if (trans && trans.guard) {
html += `<div style="font-size:0.7rem; font-weight:800; color:#94a3b8; text-transform:uppercase; margin-bottom:4px">Guard</div> html += `<div style="font-size:0.7rem; font-weight:800; color:#94a3b8; text-transform:uppercase; margin-bottom:4px">Guard</div>
<div style="background:#f1f5f9; padding:8px; border-radius:6px; font-family:'JetBrains Mono', monospace; font-size:0.8rem; margin-bottom:12px; border:1px solid #e2e8f0;">${trans.guard.expression}</div>`; <div style="background:#f1f5f9; padding:6px; border-radius:4px; font-family:monospace; font-size:0.8rem; margin-bottom:12px; border:1px solid #e2e8f0;">${trans.guard.expression}</div>`;
} }
if (chains && chains.length > 0) { if (chains && chains.length > 0) {

View File

@@ -5,6 +5,7 @@ import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnrich
import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService; import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator; import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
@@ -23,23 +24,19 @@ import static org.assertj.core.api.Assertions.assertThat;
public class HtmlExporterTest { public class HtmlExporterTest {
@Test @Test
void verifyFirstStepOfGeneration() throws IOException { void shouldGenerateCompleteInteractivePortal() throws IOException {
// 1. Setup paths // 1. Setup paths
Path projectRoot = Path.of("..").toAbsolutePath().normalize(); Path projectRoot = Path.of("..").toAbsolutePath().normalize();
Path inputDir = projectRoot.resolve("state_machines/extended_analysis_sample"); Path inputDir = projectRoot.resolve("state_machines/ultimate_ecosystem_sm");
Path outputDir = projectRoot.resolve("state_machine_exporter/out_html_verify");
Files.createDirectories(outputDir); // 2. Perform Analysis
// 2. Perform Analysis (Same as core Main)
CodebaseContext context = new CodebaseContext(); CodebaseContext context = new CodebaseContext();
context.setSourcepath(List.of(inputDir.toString())); context.setSourcepath(List.of(inputDir.toString()));
context.setResolveBindings(true); context.setResolveBindings(true);
context.scan(inputDir); context.scan(inputDir);
// Find the ExtendedStateMachineConfig class
TypeDeclaration td = context.getTypeDeclarations().stream() TypeDeclaration td = context.getTypeDeclarations().stream()
.filter(t -> context.getFqn(t).equals("click.kamil.examples.statemachine.extended.config.ExtendedStateMachineConfig")) .filter(t -> context.getFqn(t).equals("click.kamil.ultimate.config.UltimateStateMachineConfig"))
.findFirst() .findFirst()
.orElseThrow(); .orElseThrow();
@@ -47,14 +44,19 @@ public class HtmlExporterTest {
List<Transition> transitions = aggregator.aggregateTransitions(td); List<Transition> transitions = aggregator.aggregateTransitions(td);
aggregator.aggregateStates(td); aggregator.aggregateStates(td);
// 3. Documented Flows
List<BusinessFlow> flows = List.of(
BusinessFlow.builder().name("Test Flow").description("Desc").steps(List.of("CREATE")).build()
);
AnalysisResult result = AnalysisResult.builder() AnalysisResult result = AnalysisResult.builder()
.name(context.getFqn(td)) .name("UltimatePortal")
.transitions(transitions) .transitions(transitions)
.startStates(aggregator.getInitialStates()) .startStates(aggregator.getInitialStates())
.endStates(aggregator.getEndStates()) .endStates(aggregator.getEndStates())
.flows(flows)
.build(); .build();
// 3. Perform Enrichment
EnrichmentService enrichmentService = new EnrichmentService(List.of( EnrichmentService enrichmentService = new EnrichmentService(List.of(
new TriggerEnricher(), new TriggerEnricher(),
new EntryPointEnricher(), new EntryPointEnricher(),
@@ -63,20 +65,24 @@ public class HtmlExporterTest {
)); ));
enrichmentService.enrich(result, context, new click.kamil.springstatemachineexporter.analysis.service.JdtIntelligenceProvider(context, inputDir)); enrichmentService.enrich(result, context, new click.kamil.springstatemachineexporter.analysis.service.JdtIntelligenceProvider(context, inputDir));
// 4. Run HtmlExporter // 4. Export
HtmlExporter exporter = new HtmlExporter(); HtmlExporter exporter = new HtmlExporter();
String html = exporter.export(result, ExportOptions.builder().build()); String html = exporter.export(result, ExportOptions.builder().build());
Path outputFile = outputDir.resolve("Verification.html"); // 5. Assertions - Structural Integrity
Files.writeString(outputFile, html);
System.out.println("Verification HTML generated at: " + outputFile.toAbsolutePath());
// 5. Verification
assertThat(html).contains("<!DOCTYPE html>"); assertThat(html).contains("<!DOCTYPE html>");
assertThat(html).contains("Explorer"); assertThat(html).contains("UltimatePortal");
assertThat(html).contains(result.getName()); assertThat(html).contains("Test Flow"); // Flows tab check
assertThat(html).contains("<svg");
// We will improve the "Decorator" to inject identifying classes more robustly // 6. Assertions - Data Payloads
// assertThat(html).contains("link_SUBMIT_EVENT"); assertThat(html).contains("TicketRequest"); // @RequestBody extraction
assertThat(html).contains("ticketId"); // JMS param extraction
assertThat(html).contains("ticket.close.queue"); // Destination extraction
// 7. Assertions - Surgical SVG Identification
// Every event should be wrapped in an identifying hyperlink for robust JS selection
assertThat(html).contains("#link_CREATE");
assertThat(html).contains("#link_ASSIGN");
assertThat(html).contains("#link_CLOSE_TICKET");
} }
} }

View File

@@ -0,0 +1,39 @@
package click.kamil.springstatemachineexporter.html.exporter;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class SvgDecoratorTest {
private final SvgDecorator decorator = new SvgDecorator();
@Test
void shouldMakeSvgResponsive() {
String rawSvg = "<svg width=\"500px\" height=\"300px\" style=\"width:500px;height:300px;background:#FFF;\" version=\"1.1\"><defs/></svg>";
String decorated = decorator.decorate(rawSvg);
assertThat(decorated).contains("width=\"100%\"");
assertThat(decorated).contains("height=\"100%\"");
assertThat(decorated).doesNotContain("width=\"500px\"");
assertThat(decorated).doesNotContain("style=\"width:500px;height:300px;background:#FFF;\"");
}
@Test
void shouldNotTouchInternalElementDimensions() {
String rawSvg = "<svg width=\"500\"><rect width=\"10\" height=\"10\"/></svg>";
String decorated = decorator.decorate(rawSvg);
assertThat(decorated).contains("<svg width=\"100%\"");
assertThat(decorated).contains("<rect width=\"10\" height=\"10\"/>");
}
@Test
void shouldInjectInteractivityStyles() {
String rawSvg = "<svg><defs/></svg>";
String decorated = decorator.decorate(rawSvg);
assertThat(decorated).contains(".active-path");
assertThat(decorated).contains(".dimmed");
assertThat(decorated).contains("@keyframes sm-flow");
}
}

View File

@@ -0,0 +1,17 @@
[
{
"name": "Standard Ticket Lifecycle",
"description": "The typical path for a ticket: Creation -> Triage -> Progress -> Resolution -> Closing.",
"steps": ["CREATE", "ASSIGN", "RESOLVE", "CLOSE_TICKET"]
},
{
"name": "AWS-Triggered Escalation",
"description": "Emergency flow triggered by AWS infrastructure: SNS Update -> SQS Escalation.",
"steps": ["SNS_UPDATE", "SQS_UPDATE", "ESCALATE"]
},
{
"name": "Reactive Creation Path",
"description": "Modern non-blocking ticket creation flow via WebFlux.",
"steps": ["CREATE_REACTIVE", "ASSIGN"]
}
]