enrichment schema 2

This commit is contained in:
2026-06-13 07:33:30 +02:00
parent 8641c7266f
commit f85a68d9fc
12 changed files with 675 additions and 67 deletions

View File

@@ -39,3 +39,25 @@
- [ ] Add support for `@Value` tracking in fields and constructors. - [ ] Add support for `@Value` tracking in fields and constructors.
- [ ] Implement constant resolution for cross-class references in annotations. - [ ] Implement constant resolution for cross-class references in annotations.
- [ ] Integrate value propagation into the call graph analysis (linking injected values to `sendEvent` arguments). - [ ] Integrate value propagation into the call graph analysis (linking injected values to `sendEvent` arguments).
## Phase 8: Advanced String Expression Resolution
- [ ] Implement `ValueResolver` to handle string concatenations and variable references.
- [ ] Support cross-class constant lookup using JDT.
- [ ] Update all Enrichers (MVC, Rabbit, JMS) to use `ValueResolver` for metadata extraction.
- [ ] Verify with complex path patterns in the integration test suite.
## Phase 9: Instance Identification & Persistence Mapping
- [ ] Implement `InstanceIdentifier` to track State Machine IDs (via `@Qualifier`, bean names, or factories).
- [ ] Detect persistence restoration logic (`persister.restore`).
- [ ] Update all Enrichers to include `stateMachineId` and `isRestoredFromPersistence` in metadata.
- [ ] Verify multi-SM identification in the integration test suite.
## Phase 10: Incoming Payload Analysis
- [ ] Implement payload type extraction for REST and Message entry points.
- [ ] Detect usage of payload fields in `sendEvent` or `restore` calls.
- [ ] Add `payloadType` to `EntryPoint` and `TriggerPoint` models.
## Phase 11: Interceptor & Filter Mapping
- [ ] Implement detection of `HandlerInterceptor` and `Filter` implementations.
- [ ] Map interceptors to endpoints using `WebMvcConfigurer` analysis.
- [ ] Provide "Interceptor Context" metadata for each mapped endpoint.

View File

@@ -0,0 +1,42 @@
# Advanced Context Extraction: Payloads and Interceptors
## 1. Incoming Payload Analysis
To understand *what* data drives the state machine, we need to capture metadata about the incoming parameters at entry points.
### REST Endpoints
For methods annotated with `@PostMapping`, `@PutMapping`, etc.:
1. **Extract Parameters**: Scan method parameters for `@RequestBody`, `@PathVariable`, or `@RequestParam`.
2. **Type Extraction**: Capture the FQN of the parameter type (e.g., `com.example.OrderRequest`).
3. **Data Linkage**: If a field from this payload is used in a `sendEvent(payload.getEvent())` call, mark the transition as "Dynamic based on Payload".
### Message Listeners
For `@RabbitListener` or `@JmsListener`:
1. Identify the parameter receiving the message body.
2. Extract its type and any `@Header` or `@Payload` annotations.
## 2. Interceptor and Filter Analysis
Interceptors often perform cross-cutting logic (authentication, context injection) that affects how state machines are driven.
### Discovery Strategy
1. **Find Interceptors**: Scan for classes implementing `HandlerInterceptor` or `WebRequestInterceptor`.
2. **Registration Mapping**: Look for `WebMvcConfigurer.addInterceptors()` calls to identify which URL patterns each interceptor applies to.
3. **Context Injection**: Analyze if the interceptor modifies the request (e.g., `request.setAttribute("userContext", ...)`) or populates a `ThreadLocal` (like `SecurityContextHolder`).
### Linking to State Machines
If a controller uses a value from a known Interceptor-injected context to decide which event to send or which state machine to restore, we can link the Interceptor's logic to the State Machine flow.
## 3. Implementation in `EnrichmentService`
We will introduce a `ContextEnricher` that:
1. Builds a map of **Active Interceptors** per path.
2. Augments `EntryPoint` metadata with **Payload Type** information.
## 4. Modeling Updates
Update `EntryPoint` and `TriggerPoint` to include `payloadType` and `interceptors`.
```java
public record EntryPoint(
// ... existing fields ...
String payloadType,
List<String> activeInterceptors
) {}
```

View File

@@ -39,6 +39,13 @@ The State Machine Exporter now becomes a "Consumer" of intelligence.
- **Maintainability**: Adding support for a new framework (e.g., Micronaut) only requires updating the `IntelligenceProvider`, not touching the core state machine logic. - **Maintainability**: Adding support for a new framework (e.g., Micronaut) only requires updating the `IntelligenceProvider`, not touching the core state machine logic.
- **Performance**: We can run the SM Parser and the Intelligence Scanner in parallel since they are now independent. - **Performance**: We can run the SM Parser and the Intelligence Scanner in parallel since they are now independent.
## 5. Monorepo and Multi-Module Support
Applications are often split into multiple modules (e.g., `core`, `api`, `workers`).
- **Workspace Scanning**: The analyzer should treat the entire monorepo as a single codebase context.
- **Source Tracking**: Each metadata item (`TriggerPoint`, `EntryPoint`) includes a `sourceModule` identifier to show exactly where it was found.
- **Cross-Module Resolution**: Properties defined in one module's `application.yml` and used in another should be resolved globally.
- **Internal Dependency Following**: If the analyzer finds a call to a method in another module, it should continue the call-chain analysis into that module's source.
## Challenges ## Challenges
- **Multiple State Machines**: How to know which `StateMachine` instance is being used? - **Multiple State Machines**: How to know which `StateMachine` instance is being used?
- Initial heuristic: If there's only one, assume it's that one. - Initial heuristic: If there's only one, assume it's that one.

View File

@@ -0,0 +1,73 @@
# State Machine Instance Identification & Persistence Mapping
## 1. The Multi-SM Problem
In large applications, multiple State Machines often coexist. A "Trigger Point" (like a REST Controller) must be linked to the correct State Machine definition.
**Common Identification Patterns**:
- **Unique Types**: `StateMachine<OrderState, OrderEvent>` vs `StateMachine<UserState, UserEvent>`.
- **Bean Qualifiers**: `@Qualifier("orderStateMachine")`.
- **Factory IDs**: `factory.getStateMachine("order-123")`.
- **Persistence Restoration**: `persister.restore(sm, orderId)`.
## 2. Analysis Strategy: "Instance Tracking"
### 1. Definition Discovery (Existing)
Identify all configurations (classes with `@EnableStateMachineFactory` or `@EnableStateMachine`). Store their **Bean Names** and **Generic Types**.
### 2. Dependency Analysis
When a class (e.g., `OrderController`) uses a state machine:
1. **Identify the Field/Parameter**: Look for `StateMachine<S, E>`.
2. **Resolve Generic Types**: Match `S` and `E` against known SM definitions.
3. **Resolve Qualifiers**: Check for `@Qualifier` or variable names that match a SM bean name.
### 3. Loading & Persistence Analysis
A dedicated "Loading Detector" will look for persistence logic.
**Pattern: Persister Restore**
```java
persister.restore(stateMachine, id);
stateMachine.sendEvent(E);
```
**Static Strategy**:
- Find calls to `Persister.restore(sm, ...)` or `PersistStateChangeListener`.
- Link the variable `sm` to the restoration event.
- Mark the `TriggerPoint` as "Restored from Persistence".
**Pattern: Factory Creation**
```java
StateMachine sm = factory.getStateMachine(smId);
```
**Static Strategy**:
- Find `factory.getStateMachine(...)`.
- If the argument is a literal (e.g., `"order"`), map it to the SM definition named "order".
## 3. Implementation: `InstanceIdentifier`
We will introduce an `InstanceIdentifier` that works alongside the `ValueResolver`.
```java
public class InstanceIdentifier {
public StateMachineReference identify(VariableDeclaration var, CodebaseContext context) {
// 1. Check type generics
// 2. Check @Qualifier
// 3. Trace back to factory or persister calls
}
}
```
## 4. Modeling in `AnalysisResult`
The `TriggerPoint` will be enhanced with a `stateMachineId` or `configFqn` field.
```java
public record TriggerPoint(
String className,
String methodName,
String event,
String stateMachineId, // Links back to the specific SM
boolean isRestoredFromPersistence,
Map<String, String> metadata
) {}
```
## 5. Challenges
- **Generic Controllers**: A single base controller that handles multiple SMs via generics. We might need to report "Multiple Potential SMs".
- **Dynamic Factory IDs**: `factory.getStateMachine(payload.getType())`. Hard to resolve statically without data flow analysis.

View File

@@ -0,0 +1,55 @@
# Polyglot Analysis: Supporting Mixed Java and Kotlin
## 1. The Need for Language Abstraction
Professional JVM codebases are increasingly "Mixed Mode" (Java + Kotlin). Tying our analysis logic directly to JDT (Java AST) creates a bottleneck for Kotlin support.
## 2. The "Driver" Architecture
We decouple the **Intelligence Logic** (e.g., "Find all REST controllers") from the **AST Parser** (e.g., "Walk the JDT tree").
### 1. Language Driver Interface
```java
public interface LanguageDriver {
boolean supports(Path filePath);
// Unified Extraction Methods
List<RawClassMetadata> extractClasses(Path filePath);
List<RawMethodMetadata> extractMethods(RawClassMetadata cls);
List<RawInvocationMetadata> findInvocations(RawMethodMetadata method, String targetMethodName);
}
```
### 2. Unified Metadata Model (Intermediate Representation)
We move to a "Generic JVM Model" before performing the final state machine mapping.
```java
public record RawClassMetadata(
String fqn,
String language, // "java", "kotlin"
List<String> annotations,
String superclass,
List<String> interfaces
) {}
public record RawMethodMetadata(
String name,
List<String> annotations,
String returnType,
List<String> parameterTypes
) {}
```
## 3. Polyglot Analysis Flow
1. **File Discovery**: Find all `.java` and `.kt` files.
2. **Driver Assignment**: Delegate each file to the appropriate `LanguageDriver`.
3. **Cross-Language Resolution**: The `CodebaseContext` stores metadata for ALL classes.
- A Java controller calling a Kotlin service is resolved by looking up the Kotlin class's unified metadata.
4. **Enricher Execution**: Enrichers (like `SpringMvcEnricher`) now operate on the **Unified Metadata Model**, making them language-agnostic!
## 4. Why this is "Future Proof":
- **Enrichers are written once**: The logic to find `@PostMapping` only needs to know how to query the `RawClassMetadata` annotations, not how to traverse JDT vs. Kotlin PSI.
- **Easy Expansion**: To support Scala, Groovy, or even a newer Java version, you just add a new `LanguageDriver`.
## 5. Refactoring Plan
1. **Define Intermediate Models**: Create `RawClassMetadata` and `RawMethodMetadata`.
2. **Extract JDT Logic**: Move the current JDT-specific code into `JavaLanguageDriver`.
3. **Kotlin PoC (Phase 12)**: Implement a basic `KotlinLanguageDriver` using Regex or a lightweight parser (like Tree-Sitter) to prove the abstraction works.

View File

@@ -38,21 +38,30 @@ public class AppProps {
2. Index their fields with the prefix (e.g., `app.messaging.queueName`). 2. Index their fields with the prefix (e.g., `app.messaging.queueName`).
3. When these beans are injected into a service, link the service's usage to the resolved property value. 3. When these beans are injected into a service, link the service's usage to the resolved property value.
## 3. Property Resolver (The "Config Scanner") ## 3. Profile-Aware Property Resolver
A component dedicated to building a global map of available properties. A component dedicated to building a multi-dimensional map of available properties.
1. **File Scanning**: Parse `application.properties`, `application.yml`, and `bootstrap.yml`. 1. **File Scanning**: Parse `application.properties`, `application.yml`, and `bootstrap.yml`.
2. **Profile Support**: Handle `application-{profile}.yml` if a profile is active. 2. **Profile Identification**:
3. **Property Map**: Create a `Map<String, String>` of all key-value pairs. - From filenames: `application-{profile}.properties`.
- From YAML documents: `spring.config.activate.on-profile` (Spring Boot 2.4+).
3. **The Multi-Profile Map**:
- Instead of one map, we store: `Map<ProfileName, Map<Key, Value>>`.
- The "default" profile is the base.
## 4. Value Propagation (Data Flow) ## 4. Smart Value Propagation
Once a value is resolved or its placeholder is identified, we track its usage. When a trigger uses a property, we want to know it's profile-dependent.
**Example Trace**: **Example Trace**:
1. `application.yml` -> `app.queue: "orders"` 1. `application.yml` -> `app.queue: "orders"`
2. `OrderService` -> `@Value("${app.queue}") String q;` -> `this.queue = q;` 2. `application-prod.yml` -> `app.queue: "orders-prod"`
3. `OrderService.send()` -> `sm.sendEvent(this.queue);` 3. `TriggerPoint` stores:
4. **Resolution**: The "Event" for this trigger is "orders". - `placeholder: "${app.queue}"`
- `defaultResolvedValue: "orders"`
4. **Rendering Strategy**: The renderer can show "orders" by default, but provide a "Profile Toggle" to switch to "prod" and see the labels update to "orders-prod".
## 5. Metadata Retention
We should never "squash" profiles during analysis. The `CodebaseMetadata` will carry the full profile matrix to the exporter.
## 5. SpEL (Spring Expression Language) Lite ## 5. SpEL (Spring Expression Language) Lite
Full SpEL support is hard for static analysis, but we can support "Common Patterns": Full SpEL support is hard for static analysis, but we can support "Common Patterns":

View File

@@ -0,0 +1,53 @@
# Advanced String and Variable Resolution Strategy
## 1. The Problem
Annotations often use non-literal values for metadata:
- **Constants**: `@PostMapping(ApiConstants.SUBMIT_PATH)`
- **Concatenation**: `@RequestMapping(BASE + "/orders")`
- **Inherited Variables**: Use of fields defined in base classes.
Simple `StringLiteral` extraction fails in these cases. We need a recursive **Expression Evaluator**.
## 2. Evaluation Logic
### Constant Resolution
If an expression is a `SimpleName` or `QualifiedName`:
1. Use `CodebaseContext.getTypeDeclaration()` to find the class where the variable is defined.
2. Search for the `VariableDeclarationFragment`.
3. If it has an initializer that is a literal or another evaluatable expression, resolve it.
### Concatenation (Infix Expressions)
If an expression is an `InfixExpression` with the `+` operator:
1. Recursively resolve the left operand.
2. Recursively resolve the right operand.
3. Concatenate the results if both are strings.
### Spring Expression Language (SpEL) in Annotations
For values like `@Value("#{systemProperties['path.base'] + '/api'}")`:
1. Identify the SpEL string.
2. Use a "Lite" SpEL parser to extract keys.
3. Match against the `profiles` map in `CodebaseMetadata`.
## 3. Implementation: `ValueResolver`
We will introduce a central `ValueResolver` utility.
```java
public class ValueResolver {
public String resolveString(Expression expr, CodebaseContext context) {
if (expr instanceof StringLiteral sl) return sl.getLiteralValue();
if (expr instanceof InfixExpression ie) return resolveInfix(ie, context);
if (expr instanceof Name name) return resolveVariable(name, context);
// ... fallback to toString() or empty
}
}
```
## 4. Challenges
- **Circular Dependencies**: A + B, where B = A + C. We need a "visited" set to prevent infinite recursion.
- **Runtime-only values**: Some values cannot be resolved statically (e.g., values from a DB). In these cases, we should return the placeholder or a "Runtime Value" marker.
- **Method Calls**: `getPath() + "/orders"`. Resolving method return values is complex; initially, we will only support simple getters returning literals.
## 5. Integration
1. Update `AstUtils` or create `ValueResolver`.
2. Refactor `SpringMvcEnricher`, `RabbitMqEnricher`, and `JmsEnricher` to use the resolver for all path/queue/destination fields.
3. Update `integration_test_state_machine` with complex path examples.

View File

@@ -0,0 +1,165 @@
package click.kamil.springstatemachineexporter;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
import click.kamil.springstatemachineexporter.exporter.Dot;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
import click.kamil.springstatemachineexporter.exporter.PlantUml;
import click.kamil.springstatemachineexporter.exporter.Scxml;
import click.kamil.springstatemachineexporter.exporter.StateMachineExporter;
import click.kamil.springstatemachineexporter.service.ExportService;
import click.kamil.springstatemachineexporter.utils.TestScenario;
import net.sourceforge.plantuml.FileFormat;
import net.sourceforge.plantuml.FileFormatOption;
import net.sourceforge.plantuml.SourceStringReader;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
public class GoldenUpdater {
private static final List<StateMachineExporter> exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
private static final EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList());
private static final ExportService exportService = new ExportService(exporters, enrichmentService);
public static void main(String[] args) throws Exception {
List<TestScenario> scenarios = provideTestScenarios();
Path rootDir = Path.of("state_machine_exporter");
for (TestScenario scenario : scenarios) {
System.out.println("Updating golden files for: " + scenario.name());
Path tempDir = Files.createTempDirectory("golden-update-");
try {
exportService.runExporter(scenario.inputPath(), tempDir, null, true);
List<Path> generatedDirs;
try (var stream = Files.list(tempDir)) {
generatedDirs = stream.filter(Files::isDirectory).toList();
}
String targetBaseName = scenario.baseName();
Path actualDir = generatedDirs.stream()
.filter(d -> d.getFileName().toString().endsWith(targetBaseName))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("No output directory found for " + targetBaseName));
String actualBaseName = actualDir.getFileName().toString();
Path goldenDirPath = rootDir.resolve(scenario.goldenPath());
Files.createDirectories(goldenDirPath);
for (StateMachineExporter exporter : exporters) {
String fileName = actualBaseName + exporter.getFileExtension();
String goldenFileName = targetBaseName + exporter.getFileExtension();
Path actualFile = actualDir.resolve(fileName);
Path goldenFile = goldenDirPath.resolve(goldenFileName);
System.out.println(" Writing " + goldenFile);
Files.copy(actualFile, goldenFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
// Special case for PlantUML: also generate PNG
if (exporter instanceof PlantUml) {
String pngFileName = targetBaseName + ".png";
Path goldenPngFile = goldenDirPath.resolve(pngFileName);
System.out.println(" Rendering " + goldenPngFile);
String pumlContent = Files.readString(actualFile);
SourceStringReader reader = new SourceStringReader(pumlContent);
ByteArrayOutputStream os = new ByteArrayOutputStream();
reader.outputImage(os, new FileFormatOption(FileFormat.PNG));
Files.write(goldenPngFile, os.toByteArray());
}
}
} finally {
// delete tempDir
deleteDirectory(tempDir);
}
}
}
private static void deleteDirectory(Path path) throws IOException {
if (Files.isDirectory(path)) {
try (var stream = Files.list(path)) {
for (Path p : stream.toList()) {
deleteDirectory(p);
}
}
}
Files.delete(path);
}
private static List<TestScenario> provideTestScenarios() {
return List.of(
new TestScenario(
"Complex State Machine",
Path.of("state_machines/complex_state_machine"),
Path.of("src/test/resources/golden/ComplexStateMachineConfig"),
"ComplexStateMachineConfig"
),
new TestScenario(
"Fork Join State Machine",
Path.of("state_machines/forkjoin_state_machine"),
Path.of("src/test/resources/golden/ForkJoinStateMachineConfig"),
"ForkJoinStateMachineConfig"
),
new TestScenario(
"Kamil Enum State Machine",
Path.of("state_machines/enumstate_state_machine"),
Path.of("src/test/resources/golden/KamilEnumStateMachineConfiguration"),
"KamilEnumStateMachineConfiguration"
),
new TestScenario(
"Simple Enum Join State Machine",
Path.of("state_machines/simple_state_machine"),
Path.of("src/test/resources/golden/SimpleEnumStateMachineConfiguration"),
"SimpleEnumStateMachineConfiguration"
),
new TestScenario(
"One State Machine (Inheritance)",
Path.of("state_machines/inheritance_state_machine"),
Path.of("src/test/resources/golden/OneStateMachineConfiguration"),
"OneStateMachineConfiguration"
),
new TestScenario(
"Two State Machine (Extra Functions)",
Path.of("state_machines/inheritance_extra_functions_state_machine"),
Path.of("src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration"),
"TwoStateMachineConfiguration"
),
new TestScenario(
"Three State Machine (Extra Functions 2)",
Path.of("state_machines/inheritance_extra_functions2_state_machine"),
Path.of("src/test/resources/golden/ThreeStateMachineConfiguration"),
"ThreeStateMachineConfiguration"
),
new TestScenario(
"F1 State Machine (Example 4)",
Path.of("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/F1StateMachineConfiguration"),
"F1StateMachineConfiguration"
),
new TestScenario(
"F2 State Machine (Example 4)",
Path.of("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/F2StateMachineConfiguration"),
"F2StateMachineConfiguration"
),
new TestScenario(
"G1 State Machine (Example 4)",
Path.of("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/G1StateMachineConfiguration"),
"G1StateMachineConfiguration"
),
new TestScenario(
"G2 State Machine (Example 4)",
Path.of("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/G2StateMachineConfiguration"),
"G2StateMachineConfiguration"
)
);
}
}

View File

@@ -0,0 +1,162 @@
package click.kamil.springstatemachineexporter;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
import click.kamil.springstatemachineexporter.exporter.PlantUml;
import click.kamil.springstatemachineexporter.exporter.StateMachineExporter;
import click.kamil.springstatemachineexporter.service.ExportService;
import click.kamil.springstatemachineexporter.utils.TestScenario;
import net.sourceforge.plantuml.FileFormat;
import net.sourceforge.plantuml.FileFormatOption;
import net.sourceforge.plantuml.SourceStringReader;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Tag("e2e")
@Tag("plantuml")
@Tag("png")
public class PlantUmlE2ETest {
private final List<StateMachineExporter> exporters = List.of(new PlantUml());
private final EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList());
private final ExportService exportService = new ExportService(exporters, enrichmentService);
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath();
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
current = current.getParent();
}
return current;
}
private static List<TestScenario> provideTestScenarios() {
Path root = findProjectRoot();
return List.of(
new TestScenario(
"Complex State Machine",
root.resolve("state_machines/complex_state_machine"),
Path.of("src/test/resources/golden/ComplexStateMachineConfig"),
"ComplexStateMachineConfig"
),
new TestScenario(
"Fork Join State Machine",
root.resolve("state_machines/forkjoin_state_machine"),
Path.of("src/test/resources/golden/ForkJoinStateMachineConfig"),
"ForkJoinStateMachineConfig"
),
new TestScenario(
"Kamil Enum State Machine",
root.resolve("state_machines/enumstate_state_machine"),
Path.of("src/test/resources/golden/KamilEnumStateMachineConfiguration"),
"KamilEnumStateMachineConfiguration"
),
new TestScenario(
"Simple Enum Join State Machine",
root.resolve("state_machines/simple_state_machine"),
Path.of("src/test/resources/golden/SimpleEnumStateMachineConfiguration"),
"SimpleEnumStateMachineConfiguration"
),
new TestScenario(
"One State Machine (Inheritance)",
root.resolve("state_machines/inheritance_state_machine"),
Path.of("src/test/resources/golden/OneStateMachineConfiguration"),
"OneStateMachineConfiguration"
),
new TestScenario(
"Two State Machine (Extra Functions)",
root.resolve("state_machines/inheritance_extra_functions_state_machine"),
Path.of("src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration"),
"TwoStateMachineConfiguration"
),
new TestScenario(
"Three State Machine (Extra Functions 2)",
root.resolve("state_machines/inheritance_extra_functions2_state_machine"),
Path.of("src/test/resources/golden/ThreeStateMachineConfiguration"),
"ThreeStateMachineConfiguration"
),
new TestScenario(
"F1 State Machine (Example 4)",
root.resolve("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/F1StateMachineConfiguration"),
"F1StateMachineConfiguration"
),
new TestScenario(
"F2 State Machine (Example 4)",
root.resolve("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/F2StateMachineConfiguration"),
"F2StateMachineConfiguration"
),
new TestScenario(
"G1 State Machine (Example 4)",
root.resolve("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/G1StateMachineConfiguration"),
"G1StateMachineConfiguration"
),
new TestScenario(
"G2 State Machine (Example 4)",
root.resolve("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/G2StateMachineConfiguration"),
"G2StateMachineConfiguration"
)
);
}
@ParameterizedTest(name = "{0}")
@MethodSource("provideTestScenarios")
void testPngGenerationMatchesGolden(TestScenario scenario, @TempDir Path tempDir) throws Exception {
// 1. Generate PUML
exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml"), true);
List<Path> generatedDirs;
try (var stream = Files.list(tempDir)) {
generatedDirs = stream.filter(Files::isDirectory).toList();
}
String targetBaseName = scenario.baseName();
Path actualDir = generatedDirs.stream()
.filter(d -> d.getFileName().toString().endsWith(targetBaseName))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("No output directory found for " + targetBaseName));
String actualBaseName = actualDir.getFileName().toString();
Path pumlFile = actualDir.resolve(actualBaseName + ".puml");
assertThat(pumlFile).exists();
// 2. Render PNG using PlantUML library
String pumlContent = Files.readString(pumlFile);
SourceStringReader reader = new SourceStringReader(pumlContent);
ByteArrayOutputStream os = new ByteArrayOutputStream();
reader.outputImage(os, new FileFormatOption(FileFormat.PNG));
byte[] actualPngBytes = os.toByteArray();
// 3. Compare with Golden
Path goldenPngFile = scenario.goldenPath().resolve(targetBaseName + ".png");
if (Boolean.getBoolean("updateGolden")) {
Files.createDirectories(goldenPngFile.getParent());
Files.write(goldenPngFile, actualPngBytes);
System.out.println("Updated golden PNG: " + goldenPngFile);
} else {
assertThat(goldenPngFile)
.as("Golden PNG file %s should exist", goldenPngFile)
.exists();
byte[] expectedPngBytes = Files.readAllBytes(goldenPngFile);
assertThat(actualPngBytes)
.as("Visual regression detected for %s. PNG output does not match golden reference.", scenario.name())
.containsExactly(expectedPngBytes);
}
}
}

View File

@@ -25,71 +25,80 @@ public class PlantUmlRenderTest {
private final EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList()); private final EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList());
private final ExportService exportService = new ExportService(exporters, enrichmentService); private final ExportService exportService = new ExportService(exporters, enrichmentService);
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath();
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
current = current.getParent();
}
return current;
}
private static List<TestScenario> provideTestScenarios() { private static List<TestScenario> provideTestScenarios() {
Path root = findProjectRoot();
return List.of( return List.of(
new TestScenario( new TestScenario(
"Complex State Machine", "Complex State Machine",
Path.of("../state_machines/complex_state_machine"), root.resolve("state_machines/complex_state_machine"),
Path.of("src/test/resources/golden/ComplexStateMachineConfig"), Path.of("src/test/resources/golden/ComplexStateMachineConfig"),
"ComplexStateMachineConfig" "ComplexStateMachineConfig"
), ),
new TestScenario( new TestScenario(
"Fork Join State Machine", "Fork Join State Machine",
Path.of("../state_machines/forkjoin_state_machine"), root.resolve("state_machines/forkjoin_state_machine"),
Path.of("src/test/resources/golden/ForkJoinStateMachineConfig"), Path.of("src/test/resources/golden/ForkJoinStateMachineConfig"),
"ForkJoinStateMachineConfig" "ForkJoinStateMachineConfig"
), ),
new TestScenario( new TestScenario(
"Kamil Enum State Machine", "Kamil Enum State Machine",
Path.of("../state_machines/enumstate_state_machine"), root.resolve("state_machines/enumstate_state_machine"),
Path.of("src/test/resources/golden/KamilEnumStateMachineConfiguration"), Path.of("src/test/resources/golden/KamilEnumStateMachineConfiguration"),
"KamilEnumStateMachineConfiguration" "KamilEnumStateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"Simple Enum Join State Machine", "Simple Enum Join State Machine",
Path.of("../state_machines/simple_state_machine"), root.resolve("state_machines/simple_state_machine"),
Path.of("src/test/resources/golden/SimpleEnumStateMachineConfiguration"), Path.of("src/test/resources/golden/SimpleEnumStateMachineConfiguration"),
"SimpleEnumStateMachineConfiguration" "SimpleEnumStateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"One State Machine (Inheritance)", "One State Machine (Inheritance)",
Path.of("../state_machines/inheritance_state_machine"), root.resolve("state_machines/inheritance_state_machine"),
Path.of("src/test/resources/golden/OneStateMachineConfiguration"), Path.of("src/test/resources/golden/OneStateMachineConfiguration"),
"OneStateMachineConfiguration" "OneStateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"Two State Machine (Extra Functions)", "Two State Machine (Extra Functions)",
Path.of("../state_machines/inheritance_extra_functions_state_machine"), root.resolve("state_machines/inheritance_extra_functions_state_machine"),
Path.of("src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration"), Path.of("src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration"),
"TwoStateMachineConfiguration" "TwoStateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"Three State Machine (Extra Functions 2)", "Three State Machine (Extra Functions 2)",
Path.of("../state_machines/inheritance_extra_functions2_state_machine"), root.resolve("state_machines/inheritance_extra_functions2_state_machine"),
Path.of("src/test/resources/golden/ThreeStateMachineConfiguration"), Path.of("src/test/resources/golden/ThreeStateMachineConfiguration"),
"ThreeStateMachineConfiguration" "ThreeStateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"F1 State Machine (Example 4)", "F1 State Machine (Example 4)",
Path.of("../state_machines/inheritance_extra_functions3_state_machine"), root.resolve("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/F1StateMachineConfiguration"), Path.of("src/test/resources/golden/F1StateMachineConfiguration"),
"F1StateMachineConfiguration" "F1StateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"F2 State Machine (Example 4)", "F2 State Machine (Example 4)",
Path.of("../state_machines/inheritance_extra_functions3_state_machine"), root.resolve("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/F2StateMachineConfiguration"), Path.of("src/test/resources/golden/F2StateMachineConfiguration"),
"F2StateMachineConfiguration" "F2StateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"G1 State Machine (Example 4)", "G1 State Machine (Example 4)",
Path.of("../state_machines/inheritance_extra_functions3_state_machine"), root.resolve("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/G1StateMachineConfiguration"), Path.of("src/test/resources/golden/G1StateMachineConfiguration"),
"G1StateMachineConfiguration" "G1StateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"G2 State Machine (Example 4)", "G2 State Machine (Example 4)",
Path.of("../state_machines/inheritance_extra_functions3_state_machine"), root.resolve("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/G2StateMachineConfiguration"), Path.of("src/test/resources/golden/G2StateMachineConfiguration"),
"G2StateMachineConfiguration" "G2StateMachineConfiguration"
) )
@@ -101,7 +110,7 @@ public class PlantUmlRenderTest {
void testRenderMatchesGolden(TestScenario scenario, @TempDir Path tempDir) throws Exception { void testRenderMatchesGolden(TestScenario scenario, @TempDir Path tempDir) throws Exception {
exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml"), true); exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml"), true);
// Find the generated directory // Find the generated directory (it might be named with FQN)
List<Path> generatedDirs; List<Path> generatedDirs;
try (var stream = Files.list(tempDir)) { try (var stream = Files.list(tempDir)) {
generatedDirs = stream.filter(Files::isDirectory).toList(); generatedDirs = stream.filter(Files::isDirectory).toList();
@@ -111,37 +120,26 @@ public class PlantUmlRenderTest {
Path actualDir = generatedDirs.stream() Path actualDir = generatedDirs.stream()
.filter(d -> d.getFileName().toString().endsWith(targetBaseName)) .filter(d -> d.getFileName().toString().endsWith(targetBaseName))
.findFirst() .findFirst()
.orElseThrow(() -> new IllegalArgumentException("No output directory found for " + targetBaseName)); .orElseThrow(() -> new IllegalArgumentException("No output directory found for " + targetBaseName + " in " + generatedDirs));
String actualBaseName = actualDir.getFileName().toString(); String actualBaseName = actualDir.getFileName().toString();
Path actualPumlFile = actualDir.resolve(actualBaseName + ".puml"); String fileName = actualBaseName + ".puml";
String goldenFileName = targetBaseName + ".puml";
assertThat(actualPumlFile).exists(); Path actualFile = actualDir.resolve(fileName);
Path goldenFile = scenario.goldenPath().resolve(goldenFileName);
// Render to PNG if (Boolean.getBoolean("updateGolden")) {
renderToPng(actualPumlFile); Files.createDirectories(goldenFile.getParent());
Files.copy(actualFile, goldenFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
} else {
assertThat(actualFile)
.as("File %s should exist in output", fileName)
.exists();
Path actualPngFile = actualDir.resolve(actualBaseName + ".png"); assertThat(actualFile)
assertThat(actualPngFile).exists(); .as("Content mismatch for %s", fileName)
.content().isEqualTo(Files.readString(goldenFile));
// Compare with Golden PNG
String goldenPngFileName = targetBaseName + ".png";
Path goldenPngFile = scenario.goldenPath().resolve(goldenPngFileName);
assertThat(goldenPngFile).as("Golden PNG %s should exist", goldenPngFileName).exists();
assertThat(Files.readAllBytes(actualPngFile))
.as("Rendered PNG mismatch for %s", actualBaseName)
.isEqualTo(Files.readAllBytes(goldenPngFile));
}
private void renderToPng(Path pumlFile) throws IOException, InterruptedException {
ProcessBuilder pb = new ProcessBuilder("plantuml", "-nometadata", pumlFile.toAbsolutePath().toString());
pb.inheritIO();
Process process = pb.start();
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new RuntimeException("PlantUML rendering failed with exit code " + exitCode);
} }
} }
} }

View File

@@ -25,71 +25,80 @@ public class RegressionTest {
private final EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList()); private final EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList());
private final ExportService exportService = new ExportService(exporters, enrichmentService); private final ExportService exportService = new ExportService(exporters, enrichmentService);
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath();
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
current = current.getParent();
}
return current;
}
private static List<TestScenario> provideTestScenarios() { private static List<TestScenario> provideTestScenarios() {
Path root = findProjectRoot();
return List.of( return List.of(
new TestScenario( new TestScenario(
"Complex State Machine", "Complex State Machine",
Path.of("../state_machines/complex_state_machine"), root.resolve("state_machines/complex_state_machine"),
Path.of("src/test/resources/golden/ComplexStateMachineConfig"), Path.of("src/test/resources/golden/ComplexStateMachineConfig"),
"ComplexStateMachineConfig" "ComplexStateMachineConfig"
), ),
new TestScenario( new TestScenario(
"Fork Join State Machine", "Fork Join State Machine",
Path.of("../state_machines/forkjoin_state_machine"), root.resolve("state_machines/forkjoin_state_machine"),
Path.of("src/test/resources/golden/ForkJoinStateMachineConfig"), Path.of("src/test/resources/golden/ForkJoinStateMachineConfig"),
"ForkJoinStateMachineConfig" "ForkJoinStateMachineConfig"
), ),
new TestScenario( new TestScenario(
"Kamil Enum State Machine", "Kamil Enum State Machine",
Path.of("../state_machines/enumstate_state_machine"), root.resolve("state_machines/enumstate_state_machine"),
Path.of("src/test/resources/golden/KamilEnumStateMachineConfiguration"), Path.of("src/test/resources/golden/KamilEnumStateMachineConfiguration"),
"KamilEnumStateMachineConfiguration" "KamilEnumStateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"Simple Enum Join State Machine", "Simple Enum Join State Machine",
Path.of("../state_machines/simple_state_machine"), root.resolve("state_machines/simple_state_machine"),
Path.of("src/test/resources/golden/SimpleEnumStateMachineConfiguration"), Path.of("src/test/resources/golden/SimpleEnumStateMachineConfiguration"),
"SimpleEnumStateMachineConfiguration" "SimpleEnumStateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"One State Machine (Inheritance)", "One State Machine (Inheritance)",
Path.of("../state_machines/inheritance_state_machine"), root.resolve("state_machines/inheritance_state_machine"),
Path.of("src/test/resources/golden/OneStateMachineConfiguration"), Path.of("src/test/resources/golden/OneStateMachineConfiguration"),
"OneStateMachineConfiguration" "OneStateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"Two State Machine (Extra Functions)", "Two State Machine (Extra Functions)",
Path.of("../state_machines/inheritance_extra_functions_state_machine"), root.resolve("state_machines/inheritance_extra_functions_state_machine"),
Path.of("src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration"), Path.of("src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration"),
"TwoStateMachineConfiguration" "TwoStateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"Three State Machine (Extra Functions 2)", "Three State Machine (Extra Functions 2)",
Path.of("../state_machines/inheritance_extra_functions2_state_machine"), root.resolve("state_machines/inheritance_extra_functions2_state_machine"),
Path.of("src/test/resources/golden/ThreeStateMachineConfiguration"), Path.of("src/test/resources/golden/ThreeStateMachineConfiguration"),
"ThreeStateMachineConfiguration" "ThreeStateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"F1 State Machine (Example 4)", "F1 State Machine (Example 4)",
Path.of("../state_machines/inheritance_extra_functions3_state_machine"), root.resolve("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/F1StateMachineConfiguration"), Path.of("src/test/resources/golden/F1StateMachineConfiguration"),
"F1StateMachineConfiguration" "F1StateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"F2 State Machine (Example 4)", "F2 State Machine (Example 4)",
Path.of("../state_machines/inheritance_extra_functions3_state_machine"), root.resolve("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/F2StateMachineConfiguration"), Path.of("src/test/resources/golden/F2StateMachineConfiguration"),
"F2StateMachineConfiguration" "F2StateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"G1 State Machine (Example 4)", "G1 State Machine (Example 4)",
Path.of("../state_machines/inheritance_extra_functions3_state_machine"), root.resolve("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/G1StateMachineConfiguration"), Path.of("src/test/resources/golden/G1StateMachineConfiguration"),
"G1StateMachineConfiguration" "G1StateMachineConfiguration"
), ),
new TestScenario( new TestScenario(
"G2 State Machine (Example 4)", "G2 State Machine (Example 4)",
Path.of("../state_machines/inheritance_extra_functions3_state_machine"), root.resolve("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/G2StateMachineConfiguration"), Path.of("src/test/resources/golden/G2StateMachineConfiguration"),
"G2StateMachineConfiguration" "G2StateMachineConfiguration"
) )
@@ -122,6 +131,10 @@ public class RegressionTest {
Path actualFile = actualDir.resolve(fileName); Path actualFile = actualDir.resolve(fileName);
Path goldenFile = scenario.goldenPath().resolve(goldenFileName); Path goldenFile = scenario.goldenPath().resolve(goldenFileName);
if (Boolean.getBoolean("updateGolden")) {
Files.createDirectories(goldenFile.getParent());
Files.copy(actualFile, goldenFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
} else {
assertThat(actualFile) assertThat(actualFile)
.as("File %s should exist in output", fileName) .as("File %s should exist in output", fileName)
.exists(); .exists();
@@ -132,3 +145,4 @@ public class RegressionTest {
} }
} }
} }
}

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.exporter; package click.kamil.springstatemachineexporter.exporter;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.model.State; import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition; import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.model.TransitionType; import click.kamil.springstatemachineexporter.model.TransitionType;
@@ -21,7 +22,14 @@ class JsonExporterTest {
transition.setTargetStates(List.of(State.of("S2"))); transition.setTargetStates(List.of(State.of("S2")));
transition.setEvent("E1"); transition.setEvent("E1");
String json = exporter.export("TestSM", List.of(transition), Set.of("S1"), Set.of("S2"), ExportOptions.builder().build()); AnalysisResult result = AnalysisResult.builder()
.name("TestSM")
.transitions(List.of(transition))
.startStates(Set.of("S1"))
.endStates(Set.of("S2"))
.build();
String json = exporter.export(result, ExportOptions.builder().build());
assertThat(json) assertThat(json)
.contains("\"name\" : \"TestSM\"") .contains("\"name\" : \"TestSM\"")