diff --git a/spring-statemachine-uml/spring-statemachine-uml.gradle b/spring-statemachine-uml/spring-statemachine-uml.gradle index da00a62b4..268a1c070 100644 --- a/spring-statemachine-uml/spring-statemachine-uml.gradle +++ b/spring-statemachine-uml/spring-statemachine-uml.gradle @@ -27,11 +27,24 @@ dependencies { api 'org.eclipse.emf:org.eclipse.emf.ecore.xmi' api 'org.eclipse.emf:org.eclipse.emf.ecore' api 'org.eclipse.emf:org.eclipse.emf.common' + + implementation 'org.apache.commons:commons-lang3:3.12.0' + implementation'net.sourceforge.plantuml:plantuml-lgpl:1.2023.13' + implementation 'jakarta.validation:jakarta.validation-api:3.1.0' + testImplementation(testFixtures(project(":spring-statemachine-core"))) testImplementation 'org.assertj:assertj-core' testImplementation 'io.projectreactor:reactor-test' testImplementation 'org.springframework:spring-test' testImplementation 'org.junit.jupiter:junit-jupiter-api' testImplementation 'org.junit.jupiter:junit-jupiter-engine' + testImplementation 'org.junit.jupiter:junit-jupiter-params' testImplementation 'org.awaitility:awaitility' + + // lombok + implementation 'org.projectlombok:lombok:1.18.32' + compileOnly 'org.projectlombok:lombok:1.18.32' + annotationProcessor 'org.projectlombok:lombok:1.18.32' + testImplementation 'org.projectlombok:lombok:1.18.32' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.32' } diff --git a/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/ContextTransition.java b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/ContextTransition.java new file mode 100644 index 000000000..94b19faa9 --- /dev/null +++ b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/ContextTransition.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.statemachine.plantuml; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.lang.Nullable; +import org.springframework.statemachine.StateContext; + +@AllArgsConstructor +@Getter +public class ContextTransition { + private final S source; + private final E event; + private final S target; + + public static ContextTransition of(@Nullable StateContext stateContext) { + if (stateContext != null) { + return new ContextTransition<>( + stateContext.getSource() != null + ? stateContext.getSource().getId() + : null, + stateContext.getEvent(), + stateContext.getTarget() != null + ? stateContext.getTarget().getId() + : null + ); + } + return null; + } +} diff --git a/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/PlantUmlWriter.java b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/PlantUmlWriter.java new file mode 100644 index 000000000..99dca07ba --- /dev/null +++ b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/PlantUmlWriter.java @@ -0,0 +1,806 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.statemachine.plantuml; + +import jakarta.validation.constraints.NotNull; +import net.sourceforge.plantuml.SourceStringReader; +import org.apache.commons.lang3.NotImplementedException; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.BeanNameAware; +import org.springframework.lang.Nullable; +import org.springframework.statemachine.StateContext; +import org.springframework.statemachine.StateMachine; +import org.springframework.statemachine.action.Action; +import org.springframework.statemachine.guard.Guard; +import org.springframework.statemachine.plantuml.helper.*; +import org.springframework.statemachine.region.Region; +import org.springframework.statemachine.state.*; +import org.springframework.statemachine.support.StateMachineUtils; +import org.springframework.statemachine.transition.Transition; +import reactor.core.publisher.Mono; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.toMap; + +/** + * This class convert a Spring StateMachine to a PlantUml StateDiagram
+ *
+ * To display *.puml diagram, install PlantUml plugin
+ * https://plugins.jetbrains.com/plugin/7017-plantuml-integration
+ *
+ * For PNG support, install GraphViz
+ * https://graphviz.org/download/#windows
+ *
+ * Install then define GRAPHVIZ_DOT environment variable:
+ *
+ * GRAPHVIZ_DOT=C:\Program Files\Graphviz\bin\dot.exe
+ *
+ * To obtain better results, {@link Action} s and {@link Guard} s should: + *
    + *
  • be {@link org.springframework.context.annotation.Bean} s
  • + *
  • implement {@link BeanNameAware}
  • + *
  • have a "String beanName" field (can be private)
  • + *
+ *
+ * Links:
+ * + */ +// Disabling "Method has * parameters, which is greater than 7 authorized." warning +@SuppressWarnings("squid:S107") +public class PlantUmlWriter { + + private static final Log log = LogFactory.getLog(PlantUmlWriter.class); + + private static final String INDENT_INCREMENT = " "; + + // History states are handled in a special way: + // 1 - During the 1st pass, History states 'IDs' are 'computed' and collected in 'historyStatesToHistoryId' + // 2 - During the 2nd pass (the main one), 'history' transitions are collected in 'historyTransitions' + // 3 - Then, the collected 'historyTransitions' are added at the end of the PlantUml diagram using 'historyStatesToHistoryId' + + private Map, String> historyStatesToHistoryId; + private List> historyTransitions; + + // Comparators. Used to keep order of region, states and transitions stable in generated puml + + private final StateComparator stateComparator = new StateComparator<>(); + + private final Comparator> transitionComparator = new TransitionComparator<>(); + + private final Comparator> regionComparator = new RegionComparator<>(stateComparator); + + // + + public void save( + @NotNull StateMachine stateMachine, + @NotNull File file + ) throws IOException { + save(stateMachine, null, null, file); + } + + /** + * @param stateMachine stateMachine + * @param stateContext stateContext + * @param plantUmlWriterParameters plantUmlWriterParameters + * @param file filename must be "*.puml" or "*.png" + * @throws IOException + */ + public void save( + @NotNull StateMachine stateMachine, + @Nullable StateContext stateContext, + @Nullable PlantUmlWriterParameters plantUmlWriterParameters, + @NotNull File file + ) throws IOException { + if (plantUmlWriterParameters == null) { + plantUmlWriterParameters = new PlantUmlWriterParameters<>(); + } + String plantUmlDiagram = toPlantUml( + stateMachine, + stateContext, + plantUmlWriterParameters + ); + + if (file.getName().endsWith(".puml")) { + Files.write(file.toPath(), plantUmlDiagram.getBytes()); + } else if (file.getName().endsWith(".png")) { + SourceStringReader reader = new SourceStringReader(plantUmlDiagram); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + reader.outputImage(os); + Files.write(file.toPath(), os.toByteArray()); + } else { + throw new IllegalArgumentException("file name must be *.puml or *.png"); + } + } + + public String toPlantUml(StateMachine stateMachine) { + return toPlantUml(stateMachine, null, null); + } + + /** + * Convert a State machine in PlantUML notation
+ * limited support! + * + * @param stateMachine stateMachine + * @param stateContext stateContext + * @param plantUmlWriterParameters plantUmlWriterParameters + * @return plantUml representation of the stateMachine + */ + public String toPlantUml( + StateMachine stateMachine, + @Nullable StateContext stateContext, + @Nullable PlantUmlWriterParameters plantUmlWriterParameters + ) { + if (plantUmlWriterParameters == null) { + plantUmlWriterParameters = new PlantUmlWriterParameters<>(); + } + + // 1st pass: Collecting history states + historyStatesToHistoryId = StateMachineHelper.collectHistoryStates(stateMachine); + historyTransitions = new ArrayList<>(); + + StringBuilder sb = new StringBuilder("@startuml\n") + .append(PlantUmlWriterParameters.getStateDiagramSettings(plantUmlWriterParameters)) + .append("\n"); + + + // 2nd pass: processing statemachine AND collecting history transitions in 'historyTransitions + processRegion(stateMachine, stateContext, plantUmlWriterParameters, sb, "", null); + + // finally, adding the collected history transitions + for (Transition transition : historyTransitions) { + processPseudoStatesTransition( + ContextTransition.of(stateContext), + transition, + transition.getSource(), + transition.getTarget(), + this::getHistoryStateId, + plantUmlWriterParameters, + sb, + "" + ); + } + + sb.append(plantUmlWriterParameters.getAdditionalHiddenTransitions()); + + sb.append("\n@enduml"); + + log.debug("toPlantUml:" + sb); + + return sb.toString(); + } + + // TODO check this... is this a good idea? + private interface HistoryIdGetter { + String getId(State state); + } + + String getHistoryStateId(State state) { + if (historyStatesToHistoryId.containsKey(state)) { + return historyStatesToHistoryId.get(state); + } else { + return state.getId().toString(); + } + } + + private void processRegion( + @NotNull Region region, + @Nullable StateContext stateContext, + @Nullable PlantUmlWriterParameters plantUmlWriterParameters, + StringBuilder sb, + String indent, + @Nullable Predicate> transitionAllowed // not working for the moment + ) { + if (plantUmlWriterParameters == null) { + plantUmlWriterParameters = new PlantUmlWriterParameters<>(); + } + + // check 'entry' and 'exit' pseudo states: + // these states MUST NOT be added in this region, BUT in the subregion / submachine they are related to! + Map>> allStates = region.getStates() + .stream() + .collect(Collectors.groupingBy(this::isEntryOrExit)); + + List> entryAndExitStates = allStates.get(Boolean.TRUE); + List> otherStates = allStates.get(Boolean.FALSE); + + // states + processStates( + region, + stateContext, + entryAndExitStates, + otherStates, + plantUmlWriterParameters, + sb, + indent + ); + + // transitions + ContextTransition currentContextTransition = ContextTransition.of(stateContext); + processPseudoStatesTransitions(region, currentContextTransition, plantUmlWriterParameters, sb, indent); + processTransitions(region, plantUmlWriterParameters, currentContextTransition, sb, indent, transitionAllowed); + } + + private Boolean isEntryOrExit(State state) { + if (state.getPseudoState() == null) { + return Boolean.FALSE; + } + return PseudoStateKind.ENTRY.equals(state.getPseudoState().getKind()) + || PseudoStateKind.EXIT.equals(state.getPseudoState().getKind()); + } + + // TODO create processState() with optional '{' and '}' to allow text on stereotype states? + // warning -> this change the visual representation of the state, using the 'default' representation :/ + private void processStates( + Region region, + StateContext stateContext, + @Nullable Collection> entryAndExitStates, + List> otherStates, + PlantUmlWriterParameters plantUmlWriterParameters, + StringBuilder sb, + String indent + ) { + S currentState = region.getState() != null + ? region.getState().getId() + : null; + + Collection> regionTransitions = region.getTransitions(); + + // associating each entry to its targets, and each exit to its sources (as per transitions) + Map>> allStates = entryAndExitStates == null ? Map.of() + : entryAndExitStates + .stream() + .collect(Collectors.groupingBy(state -> PseudoStateKind.ENTRY.equals(state.getPseudoState().getKind()))); + + List> entryStates = allStates.get(Boolean.TRUE); + Map, List> entryToTargetStates = entryStates == null ? Map.of() : + entryStates.stream().collect( + toMap( + entry -> entry, + entry -> getEntryTargets(entry, regionTransitions), + (s, s2) -> { + throw new IllegalStateException("This should not happen!"); + })); + + List> exitStates = allStates.get(Boolean.FALSE); + Map, List> exitToSourceStates = exitStates == null ? Map.of() : + exitStates.stream().collect( + toMap( + exit -> exit, + exit -> getExitSources(exit, regionTransitions), + (s, s2) -> { + throw new IllegalStateException("This should not happen!"); + })); + + // processing states + otherStates.stream() + .sorted(stateComparator) + .toList() + .forEach(state -> processState(state, currentState, entryToTargetStates, exitToSourceStates, stateContext, plantUmlWriterParameters, sb, indent)); + + sb.append("\n"); + } + + private List getEntryTargets(State entry, Collection> regionTransitions) { + return regionTransitions.stream() + .filter(aTransition -> entry.getId().equals(aTransition.getSource().getId())) + .map(aTransition -> aTransition.getTarget().getId()) + .toList(); + } + + private List getExitSources(State exit, Collection> regionTransitions) { + return regionTransitions.stream() + .filter(aTransition -> exit.getId().equals(aTransition.getTarget().getId())) + .map(aTransition -> aTransition.getSource().getId()) + .toList(); + } + + private void processState( + State state, + S currentState, + Map, List> entryToTargetStates, + Map, List> exitToSourceStates, + StateContext stateContext, + PlantUmlWriterParameters plantUmlWriterParameters, + StringBuilder sb, + String indent + ) { + if (state.isSimple()) { + processSimpleState(state, currentState, plantUmlWriterParameters, sb, indent); + } else if (state.isSubmachineState()) { + if (state instanceof AbstractState abstractState) { + processSubmachine(abstractState, currentState, plantUmlWriterParameters, sb, indent, stateContext, entryToTargetStates, exitToSourceStates); + } + } else if (state.isComposite() || state.isOrthogonal()) { + if (state instanceof RegionState regionState) { + processCompositeOrOrthogonalState(regionState, currentState, plantUmlWriterParameters, sb, indent, stateContext, entryToTargetStates, exitToSourceStates); + } + } else { + throw new NotImplementedException("Unexpected state type " + state.getId()); + } + processStateDescription(state, sb, indent); + } + + private void processCompositeOrOrthogonalState( + RegionState regionState, + S currentState, + PlantUmlWriterParameters plantUmlWriterParameters, + StringBuilder sb, + String indent, + StateContext stateContext, + Map, List> entryToTargetStates, + Map, List> exitToSourceStates + ) { + sb.append(""" + %s { + """ + .formatted(stateToString(indent, regionState.getId(), currentState, plantUmlWriterParameters)) + .stripIndent()); + + final String regionIndent = indent + INDENT_INCREMENT; + List> regions = regionState.getRegions().stream() + .sorted(regionComparator) + .toList(); + + for (int i = 0, nRegions = regions.size(); i < nRegions; i++) { + Region subRegion = regions.get(i); + processRegion(subRegion, stateContext, plantUmlWriterParameters, sb, regionIndent, new Predicate>() { + @Override + public boolean test(Transition transition) { + // TODO implement this by checking if source or target state belong to another region ??? +// log.warn("Transition {} from / to another region is not allowed"); + return true; + } + }); + processEntries(currentState, subRegion.getStates(), entryToTargetStates, plantUmlWriterParameters, sb, regionIndent); + processExits(currentState, subRegion.getStates(), exitToSourceStates, plantUmlWriterParameters, sb, regionIndent); + // using "--" caused problem ... how to solve this..? +/* + if (i != nRegions - 1) { + // Separating regions. see "Etats concurrents [--, ||]" https://plantuml.com/fr/state-diagram#73b918d90b24a6c6 + sb.append(regionIndent).append("--\n"); + } +*/ + } + sb.append(""" + %s} + """.formatted(indent)); + } + + private void processSubmachine( + AbstractState abstractState, + S currentState, + PlantUmlWriterParameters plantUmlWriterParameters, + StringBuilder sb, + String indent, + StateContext stateContext, + Map, List> entryToTargetStates, + Map, List> exitToSourceStates + ) { + sb.append(""" + %s { + """ + .formatted(stateToString(indent, abstractState.getId(), currentState, plantUmlWriterParameters)) + .stripIndent()); + final String regionIndent = indent + INDENT_INCREMENT; + processRegion(abstractState.getSubmachine(), stateContext, plantUmlWriterParameters, sb, regionIndent, null); + processEntries(currentState, abstractState.getSubmachine().getStates(), entryToTargetStates, plantUmlWriterParameters, sb, regionIndent); + processExits(currentState, abstractState.getSubmachine().getStates(), exitToSourceStates, plantUmlWriterParameters, sb, regionIndent); + sb.append(""" + %s} + """.formatted(indent)); + } + + private void processSimpleState( + State state, + S currentState, + PlantUmlWriterParameters plantUmlWriterParameters, + StringBuilder sb, + String indent + ) { + if (state.getPseudoState() != null) { + processPseudoState(state, currentState, plantUmlWriterParameters, sb, indent); + } else { +// TODO allow plantUmlWriterParameters to add links in description ?? +// eg: state "CarWithWheel [[http://plantuml.com/state-diagram]]" as CarWithWheel + sb.append(""" + %s + """ + .formatted(stateToString(indent, state.getId(), currentState, plantUmlWriterParameters)) + .stripIndent()); + } + } + + private void processEntries( + S currentState, + Collection> regionStates, + Map, List> entryToTargetStates, + PlantUmlWriterParameters plantUmlWriterParameters, + StringBuilder sb, + String indent + ) { + entryToTargetStates.keySet() + .forEach(entryState -> entryToTargetStates.get(entryState) + .stream() + .filter(targetStateId -> regionStates.stream().anyMatch(regionState -> targetStateId.equals(regionState.getId()))) + .forEach(s -> processPseudoState(entryState, currentState, plantUmlWriterParameters, sb, indent))); + } + + private void processExits( + S currentState, + Collection> regionStates, + Map, List> exitToSourceStates, + PlantUmlWriterParameters plantUmlWriterParameters, + StringBuilder sb, + String indent + ) { + exitToSourceStates.keySet() + .forEach(exitState -> exitToSourceStates.get(exitState) + .stream() + .filter(sourceStateId -> regionStates.stream().anyMatch(regionState -> sourceStateId.equals(regionState.getId()))) + .forEach(s -> processPseudoState(exitState, currentState, plantUmlWriterParameters, sb, indent))); + } + + private void processPseudoState( + State state, + S currentState, + PlantUmlWriterParameters plantUmlWriterParameters, + StringBuilder sb, + String indent + ) { + PseudoStateKind pseudoStateKind = state.getPseudoState().getKind(); + switch (pseudoStateKind) { + case INITIAL -> { + if (StateMachineUtils.isPseudoState(state, PseudoStateKind.INITIAL)) { + sb.append(""" + %s + """ + .formatted(stateToString(indent, state.getId(), currentState, plantUmlWriterParameters)) + .stripIndent()); + } + } + case HISTORY_SHALLOW, HISTORY_DEEP -> { + // no-op + // History states are NOT added in the diagram as per themselves + // They are added 'just' by creating a transition involving an history state, i.e., [H] or [H*] + // see historyStatesToHistoryId and historyTransitions + } + case ENTRY, EXIT -> sb.append(""" + %sstate %s <<%s>> + """ + .formatted( + indent, + state.getId(), + getPseudoStatePlantUmlStereotype(pseudoStateKind) + ).stripIndent()); + case END, CHOICE, FORK, JOIN, JUNCTION -> sb.append(""" + %s'%s <<%s>> + %sstate %s <<%s>> + %snote left of %s %s: %s + """ + .formatted( + indent, + state.getId(), + pseudoStateKind.name(), + indent, + state.getId(), + getPseudoStatePlantUmlStereotype(pseudoStateKind), + indent, + state.getId(), + plantUmlWriterParameters.getStateColor(state.getId(), currentState), + state.getId() + ).stripIndent()); + } + } + + /** + * Return a PlantUML stereotype for a given PseudoStateKind
+ * we use <<start>> stereotype to represent {@link PseudoStateKind#JUNCTION} in PlantUML diagram
+ * see UML 2 - State Machine Diagram + * + * @param pseudoStateKind pseudoStateKind + * @return PlantUml stereotype corresponding to pseudoStateKind + */ + private String getPseudoStatePlantUmlStereotype(PseudoStateKind pseudoStateKind) { + return switch (pseudoStateKind) { + case INITIAL, JUNCTION -> "start"; + case ENTRY, EXIT -> pseudoStateKind.name().toLowerCase() + "Point"; + default -> pseudoStateKind.name().toLowerCase(); + }; + } + + private String stateToString( + String indent, S state, + S currentState, + PlantUmlWriterParameters plantUmlWriterParameters + ) { + return "%sstate %s %s" + .formatted( + indent, + state, + plantUmlWriterParameters.getStateColor(state, currentState) + ); + } + + private void processStateDescription( + State state, + StringBuilder sb, + String indent + ) { + for (E deferredEvent : state.getDeferredEvents()) { + sb.append(""" + %s%s : %s /defer + """ + .formatted( + indent, + state.getId(), + deferredEvent + ) + .stripIndent() + ); + } + for (Function, Mono> entryAction : state.getEntryActions()) { + sb.append(""" + %s%s : /entry %s + """ + .formatted( + indent, + state.getId(), + NameGetter.getName(entryAction) + ) + .stripIndent() + ); + } + for (Function, Mono> stateAction : state.getStateActions()) { + sb.append(""" + %s%s : /do %s + """ + .formatted( + indent, + state.getId(), + NameGetter.getName(stateAction) + ) + .stripIndent() + ); + } + for (Function, Mono> exitAction : state.getExitActions()) { + sb.append(""" + %s%s : /exit %s + """ + .formatted( + indent, + state.getId(), + NameGetter.getName(exitAction) + ) + .stripIndent() + ); + } + } + + + private void processPseudoStatesTransitions( + Region region, + @Nullable ContextTransition currentContextTransition, + PlantUmlWriterParameters plantUmlWriterParameters, + StringBuilder sb, + String indent + ) { + List> states = region.getStates().stream() + .sorted(stateComparator) + .toList(); + for (State state : states) { + // collecting transitions for 'pseudoStateKinds' + if (state.getPseudoState() != null) { + PseudoStateKind pseudoStateKind = state.getPseudoState().getKind(); + switch (pseudoStateKind) { + // already taken care of ;-) + case INITIAL, END, CHOICE, JOIN, JUNCTION, ENTRY, EXIT, HISTORY_SHALLOW, HISTORY_DEEP -> { + // already taken care of ;-) + } + case FORK -> { + // TODO why do I have to do this here? try to do it elsewhere? + for (State nextState : ((ForkPseudoState) state.getPseudoState()).getForks()) { + processPseudoStatesTransition( + currentContextTransition, + null, + state, + nextState, + null, + plantUmlWriterParameters, + sb, + indent + ); + } + } + } + } + } + sb.append("\n"); + } + + private void processPseudoStatesTransition( + @Nullable ContextTransition currentContextTransition, + @Nullable Transition transition, + State sourceState, + State targetState, + @Nullable HistoryIdGetter historyIdGetter, + PlantUmlWriterParameters plantUmlWriterParameters, + StringBuilder sb, + String indent + ) { + S source = sourceState.getId(); + S target = targetState.getId(); + if (plantUmlWriterParameters.isTransitionIgnored(source, target)) { + return; + } + sb.append(""" + %s%s + %s%s -%s%s%s> %s %s + """ + .formatted( + indent, + historyIdGetter == null ? "" : "'" + source + " -> " + target, // if history transition, add a comment with 'real' state names + indent, + historyIdGetter == null ? sourceState.getId() : historyIdGetter.getId(sourceState), + plantUmlWriterParameters.getDirection(source, target), + plantUmlWriterParameters.getArrowColor( + currentContextTransition != null + && ( + currentContextTransition.getSource() == source + // && currentContextTransition.event == transition.getTrigger().getEvent() + && currentContextTransition.getTarget() == target + ) + ), + plantUmlWriterParameters.getArrowLength(source, target), + historyIdGetter == null ? targetState.getId() : historyIdGetter.getId(targetState), + TransactionHelper.getTransitionDescription(transition, plantUmlWriterParameters) + ) + .stripIndent() + ); + } + + private void processTransitions( + Region region, + PlantUmlWriterParameters plantUmlWriterParameters, + @Nullable ContextTransition currentContextTransition, + StringBuilder sb, + String indent, + @Nullable Predicate> transitionAllowed + ) { + + // initial transition + Transition initialTransition = TransactionHelper.getInitialTransition(region); + if (initialTransition != null) { + addTransition( + sb, + indent, + "[*]", // transition from initial state + null, + initialTransition.getTarget().getId(), + plantUmlWriterParameters, + plantUmlWriterParameters.getArrowColor(false), + initialTransition + ); + } + + // region's transitions + for (Transition transition : + region.getTransitions().stream().sorted(transitionComparator).toList() + ) { + // in orthogonalRegion, we do NOT allow transition to states from another region! + if (transitionAllowed != null && !transitionAllowed.test(transition)) { + return; + } + + // collect history transitions: they will be added to the diagram later on + if (isHistoryTransition(transition)) { + historyTransitions.add(transition); + } else { + S source = transition.getSource().getId(); + S target = transition.getTarget().getId(); + + switch (transition.getKind()) { + case EXTERNAL, INTERNAL, LOCAL -> { + String arrowColor = plantUmlWriterParameters.getArrowColor( + currentContextTransition != null + && transition.getTrigger() != null + && ( + currentContextTransition.getSource() == source + && currentContextTransition.getEvent() == transition.getTrigger().getEvent() + && currentContextTransition.getTarget() == target + ) + ); + addTransition(sb, indent, source.toString(), source, target, plantUmlWriterParameters, arrowColor, transition); + } + case INITIAL -> throw new NotImplementedException( + "Unexpected INITIAL transition! They are handled in processInitialTransition(), not here! Check why we reached this exception!" + ); + } + } + } + } + + private void addTransition( + StringBuilder sb, + String indent, + String sourceLabel, + S source, + S target, + PlantUmlWriterParameters plantUmlWriterParameters, + String arrowColor, + Transition transition + ) { + if (!plantUmlWriterParameters.isTransitionIgnored(source, target)) { + sb.append(""" + %s%s -%s%s%s> %s %s + """ + .formatted( + indent, + sourceLabel, + source == null ? "" : plantUmlWriterParameters.getDirection(source, target), + arrowColor, + plantUmlWriterParameters.getArrowLength(source, target), + target, + TransactionHelper.getTransitionDescription(transition, plantUmlWriterParameters) + ) + .stripIndent() + ); + if (StringUtils.isNotBlank(transition.getName())) { + sb.append(""" + %snote on link + %s %s + %send note + """ + .formatted( + indent, + indent, + transition.getName(), + indent + )); + } + } + } + + private boolean isHistoryTransition(@NotNull Transition transition) { + return isHistoryState(transition.getSource()) || isHistoryState(transition.getTarget()); + } + + private boolean isHistoryState(@NotNull State state) { + if (state.getPseudoState() == null) { + return Boolean.FALSE; + } + return PseudoStateKind.HISTORY_SHALLOW.equals(state.getPseudoState().getKind()) + || PseudoStateKind.HISTORY_DEEP.equals(state.getPseudoState().getKind()); + } + +} diff --git a/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/PlantUmlWriterParameters.java b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/PlantUmlWriterParameters.java new file mode 100644 index 000000000..0dddce144 --- /dev/null +++ b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/PlantUmlWriterParameters.java @@ -0,0 +1,351 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.statemachine.plantuml; + +import lombok.*; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.lang.Nullable; + +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Collectors; + +/** + * This class allows to tweak / fine-tune PlantUml StateDiagram visualisation + * + * @param + */ +public class PlantUmlWriterParameters { + + private static final Log log = LogFactory.getLog(PlantUmlWriterParameters.class); + + public static final String DEFAULT_STATE_DIAGRAM_SETTINGS = """ + 'https://plantuml.com/state-diagram + + 'hide description area for state without description + hide empty description + + 'https://plantuml.com/fr/skinparam + 'https://plantuml-documentation.readthedocs.io/en/latest/formatting/all-skin-params.html + 'https://plantuml.com/fr/color + skinparam BackgroundColor white + skinparam DefaultFontColor black + 'skinparam DefaultFontName Impact + skinparam DefaultFontSize 14 + skinparam DefaultFontStyle Normal + skinparam NoteBackgroundColor #FEFFDD + skinparam NoteBorderColor black + + skinparam state { + ArrowColor black + BackgroundColor #F1F1F1 + BorderColor #181818 + FontColor black + ' FontName Impact + FontSize 14 + FontStyle Normal + } + """; + + @Setter + private String stateDiagramSettings = DEFAULT_STATE_DIAGRAM_SETTINGS; + + public static String getStateDiagramSettings(@Nullable PlantUmlWriterParameters plantUmlWriterParameters) { + return plantUmlWriterParameters == null + ? DEFAULT_STATE_DIAGRAM_SETTINGS + : plantUmlWriterParameters.getStateDiagramSettings(); + } + + private String getStateDiagramSettings() { + return stateDiagramSettings == null + ? "" + : stateDiagramSettings; + } + + @Value + static class Arrow { + + /** + * The 'default' arrow: + *
    + *
  • Direction is {@link Direction#DOWN}
  • + *
  • Lenght is 1
  • + *
+ */ + static final Arrow DEFAULT = Arrow.of(Direction.DOWN); + + Direction direction; + int length; + + public static Arrow of(Direction direction) { + return new Arrow(direction, 1); + } + + public static Arrow of(Direction direction, int length) { + return new Arrow(direction, length); + } + + public String getLengthAsString() { + return "-".repeat(length); + } + } + + /** + * Direction of an arrow connecting 2 States + */ + public enum Direction { + UP, + DOWN, + LEFT, + RIGHT + } + + + @Value + @EqualsAndHashCode + private static class Connection implements Comparable> { + S source; + S target; + + @Override + public int compareTo(Connection o) { + int sourceComparisonResult = source.toString().compareTo(o.source.toString()); + return sourceComparisonResult == 0 + ? target.toString().compareTo(o.target.toString()) + : sourceComparisonResult; + } + } + + /** + * Map of ( (sourceSate, targetState) -> Direction ) + */ + private final Map, Arrow> arrows = new HashMap<>(); + + public PlantUmlWriterParameters arrow(S source, Direction direction, S target) { + arrows.put(new Connection<>(source, target), Arrow.of(direction)); + return this; + } + + public PlantUmlWriterParameters arrow(S source, Direction direction, S target, int length) { + arrows.put(new Connection<>(source, target), Arrow.of(direction, length)); + return this; + } + + /** + * At least one of source and target must be non-null
+ * See implementation for 'arrow direction rule priority' + * + * @param source source State + * @param target target State + * @return Direction.name() + */ + private Arrow getArrow(S source, S target) { + if (source == null && target == null) { + throw new IllegalArgumentException("source and target state cannot both be null!"); + } + Connection sourceAndTarget = new Connection<>(source, target); + if (arrows.containsKey(sourceAndTarget)) { + return arrows.get(sourceAndTarget); + } + Connection sourceOnly = new Connection<>(source, null); + Connection targetOnly = new Connection<>(null, target); + if (arrows.containsKey(sourceOnly) + && arrows.containsKey(targetOnly) + && !arrows.get(sourceOnly).equals(arrows.get(targetOnly)) + ) { + log.warn(String.format( + "Two 'unary' 'arrowDirection' rules found for (%s, %s) with DIFFERENT values! Using 'target' rule!", + source, target + )); + return arrows.get(targetOnly); + } else if (arrows.containsKey(sourceOnly)) { + return arrows.get(sourceOnly); + } else if (arrows.containsKey(targetOnly)) { + return arrows.get(targetOnly); + } else { + return Arrow.DEFAULT; + } + } + + String getDirection(S source, S target) { + return getArrow(source, target).getDirection().name().toLowerCase(); + } + + String getArrowLength(S source, S target) { + return getArrow(source, target).getLengthAsString(); + } + + /** + * Map of Connection(sourceSate, targetState) -> Direction + * Used to add EXTRA HIDDEN arrows, which are just helping with diagram layout.
+ * This is typically useful to position a State relative to another, EVEN IF THESE TWO STATES AR NOT CONNECTED in the statemachine :-)
+ *

+ * exemple: + * S1 -left[hidden]-> S2 + */ + private final Map, Direction> additionalHiddenTransitions = new TreeMap<>(); + + /** + * Add EXTRA HIDDEN transitions to align states WIHTOUT connecting them.
+ * These transitions are NOT part of the state machine! + */ + public PlantUmlWriterParameters addAdditionalHiddenTransition(S source, Direction direction, S target) { + additionalHiddenTransitions.put(new Connection<>(source, target), direction); + return this; + } + + public String getAdditionalHiddenTransitions() { + String hiddenTransitionsText = additionalHiddenTransitions.entrySet().stream() + .map(hiddenTransition -> "%s -%s[hidden]-> %s" + .formatted( + hiddenTransition.getKey().getSource(), + hiddenTransition.getValue().name().toLowerCase(), + hiddenTransition.getKey().getTarget() + )) + .collect(Collectors.joining("\n")); + + return hiddenTransitionsText.isEmpty() + ? "" + : "\n" + hiddenTransitionsText + "\n"; + } + + // ---------- + + /** + * Array of Connection(sourceSate, targetState) used to IGNORE EXISTING transitions.
+ * The goal is to give the ability to IGNORE some transitions on big state machine diagram to make it clearer. + * ( So, this is DIFFERENT from 'additionalHiddenTransitions', which is used to add extra 'hidden' / 'fake' transitions )
+ */ + private final TreeSet> ignoredTransitions = new TreeSet>(); + + /** + * IGNORE a transition
+ * Transition (source -> destination) will NOT be present in PlantUML diagram + */ + public PlantUmlWriterParameters ignoreTransition(S source, S target) { + ignoredTransitions.add(new Connection<>(source, target)); + return this; + } + + public boolean isTransitionIgnored(S source, S target) { + // if source is null, we always show the transition (initial state ?) + return source != null && ignoredTransitions.contains(new Connection<>(source, target)); + } + + @Builder + @Getter + @EqualsAndHashCode + static class LabelDecorator { + @Builder.Default + private String prefix = ""; + @Builder.Default + private String suffix = ""; + + String decorate(String label) { + return prefix + label + suffix; + } + } + + /** + * Map of ( Connection(sourceSate, targetState) -> LabelDecorator(labelPrefix, labelSuffix) ) + */ + private final Map, LabelDecorator> arrowLabelDecorator = new HashMap<>(); + + public PlantUmlWriterParameters arrowLabelDecorator(S source, S target, String prefix, String suffix) { + arrowLabelDecorator.put( + new Connection<>(source, target), + LabelDecorator.builder().prefix(prefix).suffix(suffix).build() + ); + return this; + } + + public String decorateLabel( + @Nullable S source, + @Nullable S target, + @Nullable String transitionLabel + ) { + if (transitionLabel == null) { + return null; + } + + if (source == null && target == null) { + throw new IllegalArgumentException("source and target state cannot both be null!"); + } + Connection sourceAndTarget = new Connection<>(source, target); + if (arrowLabelDecorator.containsKey(sourceAndTarget)) { + return arrowLabelDecorator.get(sourceAndTarget).decorate(transitionLabel); + } + Connection sourceOnly = new Connection<>(source, null); + Connection targetOnly = new Connection<>(null, target); + if (arrowLabelDecorator.containsKey(sourceOnly) + && arrowLabelDecorator.containsKey(targetOnly) + && !arrowLabelDecorator.get(sourceOnly).equals(arrowLabelDecorator.get(targetOnly)) + ) { + log.warn(String.format( + "Two 'unary' 'arrowLabelDecorator' rules found for (%s, %s) with DIFFERENT values! Using 'target' rule!", + source, target + )); + return arrowLabelDecorator.get(targetOnly).decorate(transitionLabel); + } else if (arrowLabelDecorator.containsKey(sourceOnly)) { + return arrowLabelDecorator.get(sourceOnly).decorate(transitionLabel); + } else if (arrowLabelDecorator.containsKey(targetOnly)) { + return arrowLabelDecorator.get(targetOnly).decorate(transitionLabel); + } else { + return transitionLabel; + } + } + + // Colors + + private String defaultStateColor = ""; + private String currentStateColor = "#FFFF77"; + + @Getter + private String transitionLabelSeparator = "\\n"; + + public PlantUmlWriterParameters setTransitionLabelSeparator(String transitionLabelSeparator) { + this.transitionLabelSeparator = transitionLabelSeparator; + return this; + } + + public PlantUmlWriterParameters defaultStateColor(String defaultStateColor) { + this.defaultStateColor = defaultStateColor; + return this; + } + + public PlantUmlWriterParameters currentStateColor(String currentStateColor) { + this.currentStateColor = currentStateColor; + return this; + } + + public String getStateColor(S state, @Nullable S currentState) { + if (state == null) { + log.warn("null state!"); + return ""; // TODO check why this is happening + } + return state.equals(currentState) ? currentStateColor : defaultStateColor; + } + + // FIXME [#FF0000] should not be hardcoded here! + public String getArrowColor(boolean isCurrentTransaction) { + return isCurrentTransaction ? "[#FF0000]" : ""; + } + +} diff --git a/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/StateMachineHelper.java b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/StateMachineHelper.java new file mode 100644 index 000000000..f914f1c4f --- /dev/null +++ b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/StateMachineHelper.java @@ -0,0 +1,131 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.statemachine.plantuml; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.lang.Nullable; +import org.springframework.statemachine.StateMachine; +import org.springframework.statemachine.config.StateMachineBuilder; +import org.springframework.statemachine.config.model.StateMachineModelFactory; +import org.springframework.statemachine.region.Region; +import org.springframework.statemachine.state.AbstractState; +import org.springframework.statemachine.state.PseudoStateKind; +import org.springframework.statemachine.state.RegionState; +import org.springframework.statemachine.state.State; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class StateMachineHelper { + + public static StateMachine buildStateMachine( + StateMachineModelFactory stateMachineModelFactory + ) throws Exception { + StateMachineBuilder.Builder builder = StateMachineBuilder.builder(); + builder.configureModel().withModel().factory(stateMachineModelFactory); + builder.configureConfiguration().withConfiguration(); + return builder.build(); + } + + public static List getCurrentStates(StateMachine stateMachine) { + ArrayList currentState = new ArrayList<>(); + collectCurrentStates(stateMachine, currentState); + return currentState; + } + + private static void collectCurrentStates( + Region region, + ArrayList currentStateAccumulator + ) { + if (region.getState() != null) { + currentStateAccumulator.add(region.getState().getId()); + } + + region.getStates().forEach(state -> { + if (state.isSubmachineState()) { + if (state instanceof AbstractState abstractState) { + collectCurrentStates(abstractState.getSubmachine(), currentStateAccumulator); + } + } else if (state.isOrthogonal() || state.isComposite()) { + if (state instanceof RegionState regionState) { + regionState.getRegions().stream() + .toList() + .forEach(subRegion -> collectCurrentStates(subRegion, currentStateAccumulator)); + } + } + }); + } + + public static Map, String> collectHistoryStates(StateMachine stateMachine) { + HashMap, String> historyStatesToHistoryId = new HashMap, String>(); + collectHistoryStates(stateMachine, null, historyStatesToHistoryId); + return historyStatesToHistoryId; + } + + private static void collectHistoryStates( + Region region, + @Nullable State parentState, + Map, String> historyStatesToHistoryId + ) { + region.getStates().forEach(state -> { + if (state.isSimple()) { + collectHistoryState(state, parentState, historyStatesToHistoryId); + } else if (state.isSubmachineState()) { + if (state instanceof AbstractState abstractState) { + collectHistoryStates(abstractState.getSubmachine(), state, historyStatesToHistoryId); + } + } else if (state.isOrthogonal() || state.isComposite()) { + if (state instanceof RegionState regionState) { + regionState.getRegions().stream() + .toList() + .forEach(subRegion -> collectHistoryStates(subRegion, state, historyStatesToHistoryId)); + } + } + }); + } + + private static void collectHistoryState( + State state, + @Nullable State parentState, + Map, String> historyStatesToHistoryId + ) { + if (state.getPseudoState() != null + && ( + state.getPseudoState().getKind() == PseudoStateKind.HISTORY_DEEP + || state.getPseudoState().getKind() == PseudoStateKind.HISTORY_SHALLOW + ) + ) { + historyStatesToHistoryId.put(state, historyId(parentState, state.getPseudoState().getKind())); + } + } + + private static String historyId( + @Nullable State parentState, + PseudoStateKind pseudoStateKind + ) { + String prefix = parentState == null ? "" : parentState.getId().toString(); + return switch (pseudoStateKind) { + case HISTORY_DEEP -> prefix + "[H*]"; + case HISTORY_SHALLOW -> prefix + "[H]"; + default -> throw new IllegalArgumentException("pseudoStateKind must be an 'history'"); + }; + } +} diff --git a/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/NameGetter.java b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/NameGetter.java new file mode 100644 index 000000000..e6dfcbd29 --- /dev/null +++ b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/NameGetter.java @@ -0,0 +1,199 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.statemachine.plantuml.helper; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.ClassUtils; +import org.apache.commons.lang3.RegExUtils; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.commons.lang3.reflect.MethodUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.aop.framework.AopProxyUtils; +import org.springframework.beans.factory.BeanNameAware; +import org.springframework.expression.Expression; +import org.springframework.lang.Nullable; +import org.springframework.statemachine.action.Action; +import org.springframework.statemachine.action.SpelExpressionAction; +import org.springframework.statemachine.guard.Guard; +import org.springframework.statemachine.guard.SpelExpressionGuard; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class NameGetter { + + private static final Log log = LogFactory.getLog(NameGetter.class); + + /** + * Implement a 'name strategy' based on this sequence: + *

    + *
  • {@link Expression}
  • + *
  • {@link BeanNameAware}
  • + *
  • Lambda's unique "arg$1" parameter
  • + *
+ * If all these strategy are failing, fallback to "class name" of the object + * + * @param object object to get "name" from + * @return name of the object + */ + public static String getName(Object object) { + String name = getSpellExpression(object); + if (name != null) { + return name; + } + + // try to 'unwrap' CGLib proxy object: + Object proxyTarget = AopProxyUtils.getSingletonTarget(object); + if(proxyTarget != null) { + name = getName(proxyTarget); + if(name != null) { + return name; + } + } + + name = getBeanName(object); + if(name != null) { + return name; + } + + Object action = extractArg$1(object, Action.class); + if (action != object) { + name = getName(action); + if(name != null) { + return name; + } + } + + Object guard = extractArg$1(object, Guard.class); + if (guard != object) { + name = getName(guard); + if(name != null) { + return name; + } + } + + // fallback + return getNameUsingFallBackStrategy(object); + } + + private static String getNameUsingFallBackStrategy(Object object) { + + String name = object.getClass().toString(); + name = name.replace("class ", ""); + // remove trailing "/0x..." in class name + // example: "Actions$$Lambda$504/0x0000000800ec3e00" + name = RegExUtils.removeAll(name, "/0x.*"); + + // remove trailing "$(0-9)" in action name + // example: "Actions$$Lambda$504" + // name = RegExUtils.removeAll(name, "\\$\\d+"); + + // only keep class name + // a.b.c.D$32/0x25764366 -> D$32 + String nameWithoutDollar = name; + int indexOfDollarSymbol = name.indexOf("$"); + if (indexOfDollarSymbol != -1) { + nameWithoutDollar = name.substring(0, indexOfDollarSymbol); + } + int lastIndexOfDot = nameWithoutDollar.lastIndexOf("."); + if (lastIndexOfDot != -1) { + name = name.substring(lastIndexOfDot + 1); + } + return name; + } + + @Nullable + private static String getSpellExpression(Object object) { + if (object instanceof SpelExpressionAction || object instanceof SpelExpressionGuard) { + try { + return ((Expression) FieldUtils.readDeclaredField(object, "expression", true)).getExpressionString(); + } catch (IllegalAccessException ex) { + log.error("error while getting SpelExpression", ex); + } + } + return null; + } + + + // Disable "Rename this ... variable to match the regular expression '^[a-z][a-zA-Z0-9]*$'" + // we want to the name of this method to clearly state what is does, i.e., "extract Arg $1" + @SuppressWarnings({"squid:S100", "squid:S117"}) + private static Object extractArg$1(Object actionWrappedInFunction, Class typeOfArg) { + Field arg$1Field = FieldUtils.getDeclaredField(actionWrappedInFunction.getClass(), "arg$1", true); + if (arg$1Field != null && arg$1Field.getType() == typeOfArg) { + try { + return FieldUtils.readDeclaredField(actionWrappedInFunction, "arg$1", true); + } catch (IllegalAccessException ex) { + log.error("Error while extracting action from function!", ex); + } + } + return actionWrappedInFunction; + } + + /** + * Try to get bean name using: + *
    + *
  • BeanNameAware interface
  • + *
  • getBeanName method
  • + *
+ * @param object the bean candidate + * @return beanName or null + */ + @Nullable + private static String getBeanName(Object object) { + Class clazz = object.getClass(); + if (ClassUtils.getAllInterfaces(clazz).contains(BeanNameAware.class)) { +/* + log.error("Class " + clazz + " doesn't implement " + BeanNameAware.class + "! " + + "Make sure " + clazz + " implements BeanNameAware AND contains a 'String beanName' field." + ); +*/ + try { + // 'clazz' implements BeanNameAware .. let's try to get beanName + Field beanNameFielOfClazz = FieldUtils.getField(clazz, "beanName", true); + if (beanNameFielOfClazz == null) { + log.error("Class " + clazz + " does NOT contains a 'beanNameAware' field! " + + "Make sure " + clazz + " contains a 'String beanName' field." + ); + } else if (beanNameFielOfClazz.getType() == String.class) { + return (String) FieldUtils.readField(object, "beanName", true); + } else { + log.error("Class " + clazz + " contains a '" + beanNameFielOfClazz.getType() + " beanNameAware' field, but type should be 'String'! " + + "Make sure " + clazz + " contains a 'String beanName' field." + ); + } + } catch (IllegalAccessException ex) { + log.error("Error while accessing field!", ex); + } + } + + // second try using getBeanName method + try { + return (String) MethodUtils.invokeMethod(object, true, "getBeanName"); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { +/* + log.error("Class " + clazz + " doesn't provide a getBeanName method! " + + "Make sure " + clazz + " implements BeanNameAware AND contains a 'String beanName' field." + ); +*/ + return null; + } + } +} diff --git a/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/RegionComparator.java b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/RegionComparator.java new file mode 100644 index 000000000..c0082c7c2 --- /dev/null +++ b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/RegionComparator.java @@ -0,0 +1,58 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.statemachine.plantuml.helper; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.statemachine.region.Region; +import org.springframework.statemachine.state.State; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.IntStream; + +@RequiredArgsConstructor +public class RegionComparator implements Comparator> { + + private static final Log log = LogFactory.getLog(RegionComparator.class); + + private final StateComparator stateComparator; + + @Override + public int compare(Region region1, Region region2) { + List> sourceSortedStates = region1.getStates().stream().sorted(stateComparator).toList(); + List> targetSortedStates = region2.getStates().stream().sorted(stateComparator).toList(); + + List regionComparisonResult = IntStream + .range(0, Math.min(sourceSortedStates.size(), targetSortedStates.size())) + // comparing pairs of states + .mapToObj(i -> sourceSortedStates.get(i).getId().toString().compareTo(targetSortedStates.get(i).getId().toString())) + .toList(); + + // returning first "non 0" comparison result + for (Integer comparisonResult : regionComparisonResult) { + if (comparisonResult != 0) { + return comparisonResult; + } + } + + // this should not happen...? + log.warn("getRegionComparator: unable to compare regions!!"); + return 0; + } +} diff --git a/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/StateComparator.java b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/StateComparator.java new file mode 100644 index 000000000..6e717147c --- /dev/null +++ b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/StateComparator.java @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.statemachine.plantuml.helper; + +import org.springframework.statemachine.state.State; + +import java.util.Comparator; + +public class StateComparator implements Comparator> { + @Override + public int compare(State state1, State state2) { + return state1.getId().toString().compareTo(state2.getId().toString()); + } +} diff --git a/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/TransactionHelper.java b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/TransactionHelper.java new file mode 100644 index 000000000..4890d4f80 --- /dev/null +++ b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/TransactionHelper.java @@ -0,0 +1,156 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.statemachine.plantuml.helper; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.lang.Nullable; +import org.springframework.statemachine.plantuml.PlantUmlWriterParameters; +import org.springframework.statemachine.region.Region; +import org.springframework.statemachine.support.AbstractStateMachine; +import org.springframework.statemachine.transition.Transition; +import org.springframework.statemachine.trigger.EventTrigger; +import org.springframework.statemachine.trigger.TimerTrigger; + +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; + +import static org.apache.commons.lang3.time.DurationFormatUtils.formatDurationWords; + + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TransactionHelper { + + private static final Log log = LogFactory.getLog(TransactionHelper.class); + + public static String getTransitionDescription( + @Nullable Transition transition, + PlantUmlWriterParameters plantUmlWriterParameters + ) { + if (transition == null) { + return ""; + } + + return getTransitionDescription( + getTransitionDescriptionEvent(transition), + getTransitionDescriptionGuard(transition), + getTransitionDescriptionActions(transition), + plantUmlWriterParameters.getTransitionLabelSeparator(), + label -> plantUmlWriterParameters.decorateLabel( + transition.getSource() == null ? null : transition.getSource().getId(), + transition.getTarget().getId(), + label + )); + } + + private static String getTransitionDescriptionEvent(Transition transition) { + String event = null; + if (transition.getTrigger() != null && transition.getTrigger().getEvent() != null) { + event = transition.getTrigger().getEvent().toString(); + } + + if (transition.getTrigger() instanceof EventTrigger eventTrigger) { + if (eventTrigger.getEvent() != null) { + event = eventTrigger.getEvent().toString(); + } + } else if (transition.getTrigger() instanceof TimerTrigger timerTrigger) { + if (timerTrigger.getCount() == 0) { + event = "every " + + formatDurationWords(timerTrigger.getPeriod(), true, true); + } else { + event = "after " + + formatDurationWords(timerTrigger.getPeriod(), true, true); + + if (timerTrigger.getCount() > 1) { + event += " ( " + timerTrigger.getCount() + "x )"; + } + } + } + return event; + } + + private static String getTransitionDescriptionGuard(Transition transition) { + if (transition.getGuard() != null) { + return "[" + NameGetter.getName(transition.getGuard()) + "]"; + } else { + return null; + } + } + + private static String getTransitionDescriptionActions(Transition transition) { + if (transition.getActions() != null && !transition.getActions().isEmpty()) { + return "/ " + transition.getActions().stream() + .map(NameGetter::getName) + .collect(Collectors.joining(", ")); + } else { + return null; + } + } + + private static String getTransitionDescription( + @Nullable String event, + @Nullable String guard, + @Nullable String actions, + String labelSeparator, + UnaryOperator labelDecorator + ) { + // create guardAndAction based on guard and transaction's actions + String guardAndAction = null; + if (guard != null) { + if (actions != null) { + guardAndAction = guard + labelSeparator + actions; + } else { + guardAndAction = guard; + } + } else { + if (actions != null) { + guardAndAction = actions; + } + } + + // finally, create transition description + if (event != null) { + if (guardAndAction != null) { + return ": " + labelDecorator.apply(event + labelSeparator + guardAndAction); + } else { + return ": " + labelDecorator.apply(event); + } + } else { + if (guardAndAction != null) { + return ": " + labelDecorator.apply(guardAndAction); + } else { + return ""; + } + } + } + + @Nullable + public static Transition getInitialTransition(Region region) { + if (region instanceof AbstractStateMachine) { + try { + return ((Transition) FieldUtils.readField(region, "initialTransition", true)); + } catch (IllegalAccessException ex) { + log.error("error while getting 'initialTransition'", ex); + } + } + return null; + } + +} diff --git a/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/TransitionComparator.java b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/TransitionComparator.java new file mode 100644 index 000000000..99c9b4338 --- /dev/null +++ b/spring-statemachine-uml/src/main/java/org/springframework/statemachine/plantuml/helper/TransitionComparator.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.statemachine.plantuml.helper; + +import org.springframework.statemachine.transition.Transition; + +import java.util.Comparator; + +public class TransitionComparator implements Comparator> { + + @Override + public int compare(Transition transition1, Transition transition2) { + // First compare by source + int compareBySource = transition1.getSource().toString().compareTo(transition2.getSource().toString()); + if (compareBySource != 0) { + return compareBySource; + } + // If sources are equal, then compare by target + return transition1.getTarget().toString().compareTo(transition2.getTarget().toString()); + } +} diff --git a/spring-statemachine-uml/src/test/java/org/springframework/statemachine/plantuml/PlantUmlWriterTest.java b/spring-statemachine-uml/src/test/java/org/springframework/statemachine/plantuml/PlantUmlWriterTest.java new file mode 100644 index 000000000..d64108bed --- /dev/null +++ b/spring-statemachine-uml/src/test/java/org/springframework/statemachine/plantuml/PlantUmlWriterTest.java @@ -0,0 +1,331 @@ +package org.springframework.statemachine.plantuml; + +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.BeanNameAware; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.statemachine.StateContext; +import org.springframework.statemachine.action.Action; +import org.springframework.statemachine.guard.Guard; +import org.springframework.statemachine.uml.UmlStateMachineModelFactory; +import org.springframework.util.ObjectUtils; + +import java.nio.file.Files; +import java.util.concurrent.CountDownLatch; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.statemachine.plantuml.PlantUmlWriterParameters.Direction.RIGHT; +import static org.springframework.statemachine.plantuml.PlantUmlWriterParameters.Direction.UP; + +class PlantUmlWriterTest { + + private static final Log log = LogFactory.getLog(PlantUmlWriterTest.class); + + private static AnnotationConfigApplicationContext context; + + @BeforeAll + public static void setup() { + context = new AnnotationConfigApplicationContext(); + // adding ALL beans needed by ALL .uml files + context.register(BeansForUmlFiles.class); + // refreshing context + context.refresh(); + } + + /** + * Commented UML files correspond to test cases for which PlantUmlWriter is not yet 'ready' + * + * @return stream of "uml resource path" + */ + public static Stream plantUmlTestMethodSource() { + return Stream.of( + Arguments.of("org/springframework/statemachine/uml/action-with-transition-choice", null), + Arguments.of("org/springframework/statemachine/uml/action-with-transition-junction", null), + Arguments.of("org/springframework/statemachine/uml/broken-model-shadowentries", null), + Arguments.of("org/springframework/statemachine/uml/initial-actions", null), + Arguments.of("org/springframework/statemachine/uml/missingname-choice", null), + Arguments.of("org/springframework/statemachine/uml/multijoin-forkjoin", null), + Arguments.of("org/springframework/statemachine/uml/pseudostate-in-submachine", null), + Arguments.of("org/springframework/statemachine/uml/pseudostate-in-submachineref", null), + Arguments.of("org/springframework/statemachine/uml/simple-actions", null), + Arguments.of("org/springframework/statemachine/uml/simple-choice", null), + // It seems Spring statemachine does not support org.eclipse.uml2.uml.ConnectionPointReference ! + // Arguments.of("org/springframework/statemachine/uml/simple-connectionpointref", null), + Arguments.of("org/springframework/statemachine/uml/simple-entryexit", null), + Arguments.of("org/springframework/statemachine/uml/simple-eventdefer", null), + Arguments.of("org/springframework/statemachine/uml/simple-flat-end", null), + Arguments.of("org/springframework/statemachine/uml/simple-flat-multiple-to-end-viachoices", null), + Arguments.of("org/springframework/statemachine/uml/simple-flat-multiple-to-end", null), + Arguments.of("org/springframework/statemachine/uml/simple-flat", null), + Arguments.of("org/springframework/statemachine/uml/simple-forkjoin", + // add hidden transition to make diagram more readable + new PlantUmlWriterParameters().addAdditionalHiddenTransition("S1", RIGHT, "S2") + ), + Arguments.of("org/springframework/statemachine/uml/simple-guards", null), + Arguments.of("org/springframework/statemachine/uml/simple-history-deep", null), + Arguments.of("org/springframework/statemachine/uml/simple-history-default", null), + Arguments.of("org/springframework/statemachine/uml/simple-history-shallow", null), + Arguments.of("org/springframework/statemachine/uml/simple-junction", null), + Arguments.of("org/springframework/statemachine/uml/simple-localtransition", + // add some parameters to make the diagram more readable + new PlantUmlWriterParameters() + .arrow("S22", UP, "S2", 2) + .arrow("S21", UP, "S2", 2) + ), + // It seems Spring statemachine UML parser creates duplicated transitions! + // Arguments.of("org/springframework/statemachine/uml/simple-root-regions", null), + Arguments.of("org/springframework/statemachine/uml/simple-spels", null), + Arguments.of("org/springframework/statemachine/uml/simple-state-actions", null), + Arguments.of("org/springframework/statemachine/uml/simple-submachine", null), + Arguments.of("org/springframework/statemachine/uml/simple-submachineref", null), + Arguments.of("org/springframework/statemachine/uml/simple-timers", null), + Arguments.of("org/springframework/statemachine/uml/simple-transitiontypes", null), + Arguments.of("org/springframework/statemachine/uml/transition-effect-spel", null) + ); + } + + @ParameterizedTest + @MethodSource("plantUmlTestMethodSource") + void plantUmlTest(String resourcePath, PlantUmlWriterParameters plantUmlWriterParameters) throws Exception { + + + // Loading statemachine from uml + Resource umlResource = new ClassPathResource(resourcePath + ".uml"); + assertThat(umlResource.exists()).isTrue(); + UmlStateMachineModelFactory umlStateMachineModelFactory = new UmlStateMachineModelFactory(umlResource); + // make umlStateMachineModelFactory aware of beans available in BeansForUmlFiles.class + umlStateMachineModelFactory.setBeanFactory(context); + + if (plantUmlWriterParameters == null) { + plantUmlWriterParameters = new PlantUmlWriterParameters<>(); + }// setting some PlantUml parameters + plantUmlWriterParameters.setStateDiagramSettings(""" + 'https://plantuml.com/state-diagram + + 'hide description area for state without description + hide empty description + """); + // Dumping statemachine to PlantUml diagram + String stateMachineAsPlantUML = new PlantUmlWriter() + .toPlantUml( + StateMachineHelper.buildStateMachine(umlStateMachineModelFactory), + null, + plantUmlWriterParameters + ); + log.info("\n" + stateMachineAsPlantUML); + + // comparing with expected .puml diagram + + Resource pumlResource = new ClassPathResource(resourcePath + ".puml"); + assertThat(pumlResource.exists()).isTrue(); + + String expectedPlantUmlDiagram = new String(Files.readAllBytes(pumlResource.getFile().toPath())); + assertThat(normalizeNewLines(stateMachineAsPlantUML)) + .isEqualTo(normalizeNewLines(expectedPlantUmlDiagram)); + } + + /** + * Normalizing 'new line characters'
+ * Whatever might be the 'new line character' in the input string (CR/LF/CRLF)
+ * it will be replaced by LF. + * + * @param string String to normalize + * @return input string with line endings being '\n' ( 'LF' ) + */ + private static String normalizeNewLines(String string) { + return string.replace("\r\n", "\n").replace('\r', '\n'); + } + + @Configuration + public static class BeansForUmlFiles { + + @Bean + public ChoiceGuard s2Guard() { + return new ChoiceGuard("s2"); + } + + @Bean + public ChoiceGuard s3Guard() { + return new ChoiceGuard("s3"); + } + + @Bean + public ChoiceGuard s5Guard() { + return new ChoiceGuard("s5"); + } + + @Bean + public ChoiceGuard choice2Guard() { + return new ChoiceGuard("choice2"); + } + + @Bean + public SimpleGuard denyGuard() { + return new SimpleGuard(false); + } + + @Bean + public LatchAction s1ToChoice() { + return new LatchAction(); + } + + @Bean + public LatchAction choiceToS2() { + return new LatchAction(); + } + + @Bean + public LatchAction choiceToS4() { + return new LatchAction(); + } + + @Bean + public LatchAction choice1ToChoice2() { + return new LatchAction(); + } + + @Bean + public LatchAction choiceToS5() { + return new LatchAction(); + } + + @Bean + public LatchAction choiceToS6() { + return new LatchAction(); + } + + @Bean + public LatchAction action1() { + return new LatchAction(); + } + + @Bean + public JunctionGuard s6Guard() { + return new JunctionGuard("s6"); + } + + @Bean + public LatchAction initialAction() { + return new LatchAction(); + } + + @Bean + public LatchAction e1Action() { + return new LatchAction(); + } + + @Bean + public LatchAction e2Action() { + return new LatchAction(); + } + + @Bean + public LatchAction s1Exit() { + return new LatchAction(); + } + + @Bean + public LatchAction s2Entry() { + return new LatchAction(); + } + + @Bean + public LatchAction s3Entry() { + return new LatchAction(); + } + + @Bean + public LatchAction s5Entry() { + return new LatchAction(); + } + } + + // -------------------------------------------------------------------------------- + // Actions + // -------------------------------------------------------------------------------- + + @Setter + @Getter + public static class LatchAction implements Action, BeanNameAware { + + private String beanName; + + CountDownLatch latch = new CountDownLatch(1); + + @Override + public void execute(StateContext context) { + latch.countDown(); + } + + } + + // -------------------------------------------------------------------------------- + // Guards + // -------------------------------------------------------------------------------- + + @Setter + @Getter + public static class ChoiceGuard implements Guard, BeanNameAware { + + private String beanName; + + private final String match; + + public ChoiceGuard(String match) { + this.match = match; + } + + @Override + public boolean evaluate(StateContext context) { + return ObjectUtils.nullSafeEquals(match, context.getMessageHeaders().get("choice", String.class)); + } + } + + @Setter + @Getter + public static class SimpleGuard implements Guard, BeanNameAware { + + private String beanName; + + private final boolean deny; + + public SimpleGuard(boolean deny) { + this.deny = deny; + } + + @Override + public boolean evaluate(StateContext context) { + return deny; + } + } + + @Setter + @Getter + public static class JunctionGuard implements Guard, BeanNameAware { + + private String beanName; + + private final String match; + + public JunctionGuard(String match) { + this.match = match; + } + + @Override + public boolean evaluate(StateContext context) { + return ObjectUtils.nullSafeEquals(match, context.getMessageHeaders().get("junction", String.class)); + } + } + +} \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/java/org/springframework/statemachine/uml/UmlStateMachineModelFactoryTests.java b/spring-statemachine-uml/src/test/java/org/springframework/statemachine/uml/UmlStateMachineModelFactoryTests.java index b1fab4020..bfebe9c1c 100644 --- a/spring-statemachine-uml/src/test/java/org/springframework/statemachine/uml/UmlStateMachineModelFactoryTests.java +++ b/spring-statemachine-uml/src/test/java/org/springframework/statemachine/uml/UmlStateMachineModelFactoryTests.java @@ -21,9 +21,11 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; @@ -35,6 +37,7 @@ import org.springframework.statemachine.StateMachine; import org.springframework.statemachine.action.Action; import org.springframework.statemachine.config.EnableStateMachine; +import org.springframework.statemachine.config.StateMachineBuilder; import org.springframework.statemachine.config.StateMachineConfigurerAdapter; import org.springframework.statemachine.config.builders.StateMachineModelConfigurer; import org.springframework.statemachine.config.model.DefaultStateMachineComponentResolver; @@ -45,7 +48,10 @@ import org.springframework.statemachine.guard.Guard; import org.springframework.statemachine.listener.StateMachineListenerAdapter; import org.springframework.statemachine.state.PseudoStateKind; +import org.springframework.statemachine.state.RegionState; import org.springframework.statemachine.state.State; +import org.springframework.statemachine.support.AbstractStateMachine; +import org.springframework.statemachine.transition.Transition; import org.springframework.statemachine.transition.TransitionKind; import org.springframework.util.ObjectUtils; @@ -163,6 +169,109 @@ public void testSimpleRootRegions() { } } + /** + * Test {@link StateMachine} vs {@link StateMachineModel} consistency.
+ * In this (failing) test, one can notice that the statemachine instance has a duplicated transition "S1->S2" as illustrated here
+ * + */ + @Disabled + @Test + public void testStateMachineVsStateMachineModelConsistency() { + context.refresh(); + Resource model1 = new ClassPathResource("org/springframework/statemachine/uml/simple-root-regions.uml"); + UmlStateMachineModelFactory builder = new UmlStateMachineModelFactory(model1); + builder.setBeanFactory(context); + assertThat(model1.exists()).isTrue(); + StateMachineModel stateMachineModel = builder.build(); + + try { + // build statemachine from model + UmlStateMachineModelFactory umlStateMachineModelFactory = new UmlStateMachineModelFactory(("classpath:org/springframework/statemachine/uml/simple-root-regions.uml")); + StateMachineBuilder.Builder stateMachineBuilder = StateMachineBuilder.builder(); + stateMachineBuilder.configureModel().withModel().factory(umlStateMachineModelFactory); + stateMachineBuilder.configureConfiguration().withConfiguration(); + StateMachine stateMachine = stateMachineBuilder.build(); + + // get the "root" state of this state machines + State rootState = stateMachine.getStates().stream().findFirst().get(); + assertThat(rootState).isInstanceOf(RegionState.class); + RegionState rootRegionState = ((RegionState) rootState); + + // compare statemachine and stateMachineModel + + // states in Region1 + AbstractStateMachine region1InStatemachine = (AbstractStateMachine) + ((List) rootRegionState.getRegions()).stream() + .filter(region -> ((AbstractStateMachine) region).getId().contains("Region1")) + .findFirst().get(); + + List statesOfRegion1InStateMachine = region1InStatemachine.getStates().stream() + .map(o -> ((State) o).getId().toString()) + .sorted().toList(); + + List statesOfRegion1InStateMachineModel = stateMachineModel.getStatesData().getStateData().stream() + .filter(stateData -> "Region1".equals(stateData.getRegion().toString())) + .map(stateData -> stateData.getState().toString()) + .sorted().toList(); + + assertThat(statesOfRegion1InStateMachine).isEqualTo(statesOfRegion1InStateMachineModel); + + // states in Region2 + AbstractStateMachine region2InStatemachine = (AbstractStateMachine) + ((List) rootRegionState.getRegions()).stream() + .filter(region -> ((AbstractStateMachine) region).getId().contains("Region2")) + .findFirst().get(); + + List statesOfRegion2InStateMachine = region2InStatemachine.getStates().stream() + .map(o -> ((State) o).getId().toString()) + .sorted().toList(); + + List statesOfRegion2InStateMachineModel = stateMachineModel.getStatesData().getStateData().stream() + .filter(stateData -> "Region2".equals(stateData.getRegion().toString())) + .map(stateData -> stateData.getState().toString()) + .sorted().toList(); + + assertThat(statesOfRegion2InStateMachine).isEqualTo(statesOfRegion2InStateMachineModel); + + // transitions in Region1 + List transitionsOfRegion1InStateMachine = region1InStatemachine.getTransitions().stream() + .map(o -> ((Transition) o).getSource().getId().toString() + "->" + ((Transition) o).getTarget().getId().toString()) + .sorted().toList(); + + List transitionsOfRegion1InStateMachineModel = stateMachineModel.getTransitionsData().getTransitions().stream() + // let's exclude "initial" transition + .filter(transitionData -> !transitionData.getSource().startsWith("initial")) + .filter(transitionData -> statesOfRegion1InStateMachine.contains(transitionData.getSource()) + || statesOfRegion1InStateMachine.contains(transitionData.getTarget())) + .map(transitionData -> transitionData.getSource() + "->" + transitionData.getTarget()) + .sorted().toList(); + + assertThat(transitionsOfRegion1InStateMachine).isEqualTo(transitionsOfRegion1InStateMachineModel); + + // transitions in Region2 + List transitionsOfRegion2InStateMachine = region2InStatemachine.getTransitions().stream() + .map(o -> ((Transition) o).getSource().getId().toString() + "->" + ((Transition) o).getTarget().getId().toString()) + .sorted().toList(); + + List transitionsOfRegion2InStateMachineModel = stateMachineModel.getTransitionsData().getTransitions().stream() + // let's exclude "initial" transition + .filter(transitionData -> !transitionData.getSource().startsWith("initial")) + .filter(transitionData -> statesOfRegion2InStateMachine.contains(transitionData.getSource()) + || statesOfRegion2InStateMachine.contains(transitionData.getTarget())) + .map(transitionData -> transitionData.getSource() + "->" + transitionData.getTarget()) + .sorted().toList(); + + // WOW! this is failing! Why is transition "S3->S4" present in both Region1 AND Region2 ?!? + // Does this indicates an issue in UmlStateMachineModelFactory ??? + // Expected :["S1->S2"] + // Actual :["S1->S2", "S3->S4"] + assertThat(transitionsOfRegion2InStateMachine).isEqualTo(transitionsOfRegion2InStateMachineModel); + + } catch (Exception e) { + throw new RuntimeException(e); + } + } + @Test public void testSimpleFlatEnd() { context.refresh(); diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/action-with-transition-choice.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/action-with-transition-choice.png new file mode 100644 index 000000000..64493bee6 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/action-with-transition-choice.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/action-with-transition-choice.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/action-with-transition-choice.puml new file mode 100644 index 000000000..7bd8bbf57 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/action-with-transition-choice.puml @@ -0,0 +1,30 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +'CHOICE1 <> +state CHOICE1 <> +note left of CHOICE1 : CHOICE1 +'CHOICE2 <> +state CHOICE2 <> +note left of CHOICE2 : CHOICE2 +state S1 +state S2 +state S3 +state S4 +state S5 +state S6 + + +[*] --> S1 +CHOICE1 -down-> CHOICE2 : [choice2Guard]\n/ choice1ToChoice2 +CHOICE1 -down-> S2 : [s2Guard]\n/ choiceToS2 +CHOICE1 -down-> S3 : [s3Guard] +CHOICE1 -down-> S4 : / choiceToS4 +CHOICE2 -down-> S5 : [s5Guard]\n/ choiceToS5 +CHOICE2 -down-> S6 : / choiceToS6 +S1 -down-> CHOICE1 : E1\n/ s1ToChoice + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/action-with-transition-junction.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/action-with-transition-junction.png new file mode 100644 index 000000000..0b0cc46b1 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/action-with-transition-junction.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/action-with-transition-junction.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/action-with-transition-junction.puml new file mode 100644 index 000000000..07b2c50df --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/action-with-transition-junction.puml @@ -0,0 +1,30 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +'JUNCTION1 <> +state JUNCTION1 <> +note left of JUNCTION1 : JUNCTION1 +'JUNCTION2 <> +state JUNCTION2 <> +note left of JUNCTION2 : JUNCTION2 +state S1 +state S2 +state S3 +state S4 +state S5 +state S6 + + +[*] --> S1 +JUNCTION1 -down-> JUNCTION2 : [choice2Guard]\n/ choice1ToChoice2 +JUNCTION1 -down-> S2 : [s2Guard]\n/ choiceToS2 +JUNCTION1 -down-> S3 : [s3Guard] +JUNCTION1 -down-> S4 : / choiceToS4 +JUNCTION2 -down-> S5 : [s5Guard]\n/ choiceToS5 +JUNCTION2 -down-> S6 : / choiceToS6 +S1 -down-> JUNCTION1 : E1\n/ s1ToChoice + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/broken-model-shadowentries.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/broken-model-shadowentries.png new file mode 100644 index 000000000..f83100bfa Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/broken-model-shadowentries.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/broken-model-shadowentries.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/broken-model-shadowentries.puml new file mode 100644 index 000000000..3c7b66d70 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/broken-model-shadowentries.puml @@ -0,0 +1,14 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 + + +[*] --> S1 +S1 -down-> S2 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/initial-actions.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/initial-actions.png new file mode 100644 index 000000000..c77e7f84b Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/initial-actions.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/initial-actions.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/initial-actions.puml new file mode 100644 index 000000000..d03426204 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/initial-actions.puml @@ -0,0 +1,14 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 + + +[*] --> S1 : / initialAction +S1 -down-> S2 : E1 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/missingname-choice.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/missingname-choice.png new file mode 100644 index 000000000..adf1c7b19 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/missingname-choice.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/missingname-choice.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/missingname-choice.puml new file mode 100644 index 000000000..302703f77 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/missingname-choice.puml @@ -0,0 +1,22 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 +state S3 +state S4 +'choice1 <> +state choice1 <> +note left of choice1 : choice1 + + +[*] --> S1 +S1 -down-> choice1 : E1 +choice1 -down-> S2 : [s2Guard] +choice1 -down-> S3 : [s3Guard] +choice1 -down-> S4 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/multijoin-forkjoin.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/multijoin-forkjoin.png new file mode 100644 index 000000000..9c8d772c6 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/multijoin-forkjoin.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/multijoin-forkjoin.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/multijoin-forkjoin.puml new file mode 100644 index 000000000..020fe4fd2 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/multijoin-forkjoin.puml @@ -0,0 +1,45 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +'S1 <> +state S1 <> +note left of S1 : S1 +state S2 { + state S20 + state S21 + + + [*] --> S20 + state S30 + state S31 + + + [*] --> S30 +} +'S3 <> +state S3 <> +note left of S3 : S3 +state S4 +'SF <> +state SF <> +note left of SF : SF +state SI + + +S1 -down-> S20 + +S1 -down-> S30 + +[*] --> SI +S20 -down-> S21 : E2 +S21 -down-> S3 +S30 -down-> S31 : E3 +S31 -down-> S3 +S3 -down-> S4 : [extendedState.variables.isEmpty()] +S3 -down-> SF : [!extendedState.variables.isEmpty()] +SI -down-> S1 : E1 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/pseudostate-in-submachine.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/pseudostate-in-submachine.png new file mode 100644 index 000000000..2c5f564a3 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/pseudostate-in-submachine.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/pseudostate-in-submachine.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/pseudostate-in-submachine.puml new file mode 100644 index 000000000..ea4a70fe6 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/pseudostate-in-submachine.puml @@ -0,0 +1,23 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 { + 'CHOICE <> + state CHOICE <> + note left of CHOICE : CHOICE + state S11 + state S12 + + + [*] --> S11 +} + + +[*] --> S1 +CHOICE -down-> S12 +S11 -down-> CHOICE + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/pseudostate-in-submachineref.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/pseudostate-in-submachineref.png new file mode 100644 index 000000000..2c5f564a3 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/pseudostate-in-submachineref.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/pseudostate-in-submachineref.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/pseudostate-in-submachineref.puml new file mode 100644 index 000000000..ea4a70fe6 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/pseudostate-in-submachineref.puml @@ -0,0 +1,23 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 { + 'CHOICE <> + state CHOICE <> + note left of CHOICE : CHOICE + state S11 + state S12 + + + [*] --> S11 +} + + +[*] --> S1 +CHOICE -down-> S12 +S11 -down-> CHOICE + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-actions.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-actions.png new file mode 100644 index 000000000..96fd794d9 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-actions.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-actions.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-actions.puml new file mode 100644 index 000000000..47782d860 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-actions.puml @@ -0,0 +1,17 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +S1 : /exit s1Exit +state S2 +S2 : /entry s2Entry +S2 : /do extendedState.variables.put('hellos2do','hellos2dovalue') + + +[*] --> S1 +S1 -down-> S2 : E1\n/ e1Action + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-choice.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-choice.png new file mode 100644 index 000000000..e513645e4 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-choice.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-choice.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-choice.puml new file mode 100644 index 000000000..97800d2c5 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-choice.puml @@ -0,0 +1,22 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +'CHOICE <> +state CHOICE <> +note left of CHOICE : CHOICE +state S1 +state S2 +state S3 +state S4 + + +[*] --> S1 +CHOICE -down-> S2 : [s2Guard] +CHOICE -down-> S3 : [s3Guard] +CHOICE -down-> S4 +S1 -down-> CHOICE : E1 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-connectionpointref.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-connectionpointref.png new file mode 100644 index 000000000..ccfc1540d Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-connectionpointref.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-connectionpointref.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-connectionpointref.puml new file mode 100644 index 000000000..1cef76e1b --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-connectionpointref.puml @@ -0,0 +1,30 @@ +@startuml +note "!!! NOT WORKING !!!\n!!! Missing 'EXIT -down-> S4' transition !!!\n It seems ConnectionPointRef is not supported by Spring Statemachine" as NOT_WORKING +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 { + state S21 + state S22 + + + [*] --> S21 + state ENTRY <> + state EXIT <> +} +state S3 +state S4 + + +[*] --> S1 +S1 -down-> S2 : E1 +ENTRY -down-> S22 +S22 -down-> EXIT : E4 +S1 -down-> ENTRY : E3 +S2 -down-> S3 : E2 +EXIT -down-> S4 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-entryexit.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-entryexit.png new file mode 100644 index 000000000..0ebc61b35 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-entryexit.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-entryexit.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-entryexit.puml new file mode 100644 index 000000000..72827c936 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-entryexit.puml @@ -0,0 +1,29 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 { + state S21 + state S22 + + + [*] --> S21 + state ENTRY <> + state EXIT <> +} +state S3 +state S4 + + +[*] --> S1 +ENTRY -down-> S22 +EXIT -down-> S4 +S1 -down-> ENTRY : E3 +S1 -down-> S2 : E1 +S22 -down-> EXIT : E4 +S2 -down-> S3 : E2 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-eventdefer.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-eventdefer.png new file mode 100644 index 000000000..ae8a1cc55 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-eventdefer.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-eventdefer.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-eventdefer.puml new file mode 100644 index 000000000..83516f90f --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-eventdefer.puml @@ -0,0 +1,17 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +S1 : E2 /defer +state S2 +state S3 + + +[*] --> S1 +S1 -down-> S2 : E1 +S2 -down-> S3 : E2 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-end.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-end.png new file mode 100644 index 000000000..7886b7f83 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-end.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-end.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-end.puml new file mode 100644 index 000000000..ce7292993 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-end.puml @@ -0,0 +1,18 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 +'S3 <> +state S3 <> +note left of S3 : S3 + + +[*] --> S1 +S1 -down-> S2 : E1 +S2 -down-> S3 : E2 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-multiple-to-end-viachoices.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-multiple-to-end-viachoices.png new file mode 100644 index 000000000..777203853 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-multiple-to-end-viachoices.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-multiple-to-end-viachoices.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-multiple-to-end-viachoices.puml new file mode 100644 index 000000000..1824ab23f --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-multiple-to-end-viachoices.puml @@ -0,0 +1,25 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +'CHOICE1 <> +state CHOICE1 <> +note left of CHOICE1 : CHOICE1 +'CHOICE2 <> +state CHOICE2 <> +note left of CHOICE2 : CHOICE2 +'FINAL <> +state FINAL <> +note left of FINAL : FINAL +state S1 + + +[*] --> S1 +CHOICE1 -down-> FINAL +CHOICE2 -down-> FINAL +S1 -down-> CHOICE1 +S1 -down-> CHOICE2 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-multiple-to-end.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-multiple-to-end.png new file mode 100644 index 000000000..dc5e79034 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-multiple-to-end.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-multiple-to-end.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-multiple-to-end.puml new file mode 100644 index 000000000..250ebe680 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat-multiple-to-end.puml @@ -0,0 +1,20 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +'FINAL1 <> +state FINAL1 <> +note left of FINAL1 : FINAL1 +'FINAL2 <> +state FINAL2 <> +note left of FINAL2 : FINAL2 +state S1 + + +[*] --> S1 +S1 -down-> FINAL1 +S1 -down-> FINAL2 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat.png new file mode 100644 index 000000000..e16111671 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat.puml new file mode 100644 index 000000000..72c0afa54 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-flat.puml @@ -0,0 +1,15 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 +S2 : /entry action1 + + +[*] --> S1 +S1 -down-> S2 : E1 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-forkjoin.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-forkjoin.png new file mode 100644 index 000000000..1354cf651 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-forkjoin.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-forkjoin.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-forkjoin.puml new file mode 100644 index 000000000..7e869d361 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-forkjoin.puml @@ -0,0 +1,45 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +'S1 <> +state S1 <> +note left of S1 : S1 +state S2 { + state S20 + state S21 + + + [*] --> S20 + state S30 + state S31 + + + [*] --> S30 +} +'S3 <> +state S3 <> +note left of S3 : S3 +'SF <> +state SF <> +note left of SF : SF +state SI + + +S1 -down-> S20 + +S1 -down-> S30 + +[*] --> SI +S20 -down-> S21 : E2 +S21 -down-> S3 +S30 -down-> S31 : E3 +S31 -down-> S3 +S3 -down-> SF +SI -down-> S1 : E1 + +S1 -right[hidden]-> S2 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-guards.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-guards.png new file mode 100644 index 000000000..f63df1e76 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-guards.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-guards.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-guards.puml new file mode 100644 index 000000000..097375baf --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-guards.puml @@ -0,0 +1,18 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 +state S3 +state S4 + + +[*] --> S1 +S1 -down-> S2 : E1\n[denyGuard] +S1 -down-> S3 : E2 +S3 -down-> S4 : [denyGuard] + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-deep.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-deep.png new file mode 100644 index 000000000..295ef8878 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-deep.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-deep.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-deep.puml new file mode 100644 index 000000000..2c4fa7aed --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-deep.puml @@ -0,0 +1,30 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 { + state S20 + state S21 { + state S211 + state S212 + + + [*] --> S211 + } + + + [*] --> S20 +} + + +[*] --> S1 +S1 -down-> S211 : E1 +S211 -down-> S212 : E2 +S212 -down-> S1 : E3 +'S1 -> SH +S1 -down-> S2[H*] : E4 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-default.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-default.png new file mode 100644 index 000000000..0615b6807 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-default.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-default.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-default.puml new file mode 100644 index 000000000..3721154a7 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-default.puml @@ -0,0 +1,27 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 { + state S20 + state S21 + state S22 + + + [*] --> S20 +} + + +[*] --> S1 +S1 -down-> S2 : E1 +S20 -down-> S21 : E2 +S2 -down-> S1 : E3 +'S1 -> SH +S1 -down-> S2[H] : E4 +'SH -> S22 +S2[H] -down-> S22 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-shallow.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-shallow.png new file mode 100644 index 000000000..889f847a7 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-shallow.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-shallow.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-shallow.puml new file mode 100644 index 000000000..8f4901603 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-history-shallow.puml @@ -0,0 +1,24 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 { + state S20 + state S21 + + + [*] --> S20 +} + + +[*] --> S1 +S1 -down-> S2 : E1 +S20 -down-> S21 : E2 +S2 -down-> S1 : E3 +'S1 -> SH +S1 -down-> S2[H] : E4 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-junction.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-junction.png new file mode 100644 index 000000000..7a935bbc3 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-junction.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-junction.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-junction.puml new file mode 100644 index 000000000..ba3e5c3f1 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-junction.puml @@ -0,0 +1,30 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +'JUNCTION <> +state JUNCTION <> +note left of JUNCTION : JUNCTION +state S1 +state S2 +state S3 +state S4 +state S5 +state S6 +state S7 + + +[*] --> S1 +JUNCTION -down-> S5 : [s5Guard] +JUNCTION -down-> S6 : [s6Guard] +JUNCTION -down-> S7 +S1 -down-> S2 : E1 +S1 -down-> S3 : E2 +S1 -down-> S4 : E3 +S2 -down-> JUNCTION : E4 +S3 -down-> JUNCTION : E4 +S4 -down-> JUNCTION : E4 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-localtransition.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-localtransition.png new file mode 100644 index 000000000..e92169656 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-localtransition.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-localtransition.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-localtransition.puml new file mode 100644 index 000000000..8e65680b2 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-localtransition.puml @@ -0,0 +1,28 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 { + state S21 + state S22 + + + [*] --> S21 +} + + +[*] --> S1 +S1 -down-> S2 : E1 +S21 -up--> S2 : E22 +S21 -up--> S2 : E32 +S22 -up--> S2 : E23 +S22 -up--> S2 : E33 +S2 -down-> S21 : E20 +S2 -down-> S21 : E30 +S2 -down-> S22 : E21 +S2 -down-> S22 : E31 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-root-regions.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-root-regions.png new file mode 100644 index 000000000..27614af45 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-root-regions.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-root-regions.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-root-regions.puml new file mode 100644 index 000000000..e86b3f44e --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-root-regions.puml @@ -0,0 +1,26 @@ +@startuml +note "!!! NOT WORKING !!!\n!!! duplicated transition: 'S3 -down-> S4 : E2' !!!\n??? Error in Spring Statemachine UML parser ???\n see [[https://github.com/spring-projects/spring-statemachine/issues/1141]] " as NOT_WORKING +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state null { + state S1 + state S2 + + + [*] --> S1 + S1 -down-> S2 : E1 + state S3 + state S4 + + + [*] --> S3 + S3 -down-> S4 : E2 +} + + +[*] --> null + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-spels.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-spels.png new file mode 100644 index 000000000..38fb04333 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-spels.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-spels.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-spels.puml new file mode 100644 index 000000000..cd81a9c3e --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-spels.puml @@ -0,0 +1,16 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +S1 : /exit extendedState.variables.put('myvar2','myvalue2') +state S2 +S2 : /entry extendedState.variables.put('myvar1','myvalue1') + + +[*] --> S1 +S1 -down-> S2 : E1\n[messageHeaders.get('foo')=='bar'] + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-state-actions.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-state-actions.png new file mode 100644 index 000000000..63f53cc82 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-state-actions.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-state-actions.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-state-actions.puml new file mode 100644 index 000000000..30e3740f7 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-state-actions.puml @@ -0,0 +1,18 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +S1 : /do e1Action +state S2 +S2 : /do e2Action +state S3 + + +[*] --> S1 +S1 -down-> S2 : E1 +S2 -down-> S3 : E2 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-submachine.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-submachine.png new file mode 100644 index 000000000..2044e4f3d Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-submachine.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-submachine.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-submachine.puml new file mode 100644 index 000000000..f9ea01913 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-submachine.puml @@ -0,0 +1,21 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 { + state S11 + state S12 + + + [*] --> S11 +} +state S2 + + +[*] --> S1 +S11 -down-> S12 : E1 +S1 -down-> S2 : E2 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-submachineref.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-submachineref.png new file mode 100644 index 000000000..c6f6a9c77 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-submachineref.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-submachineref.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-submachineref.puml new file mode 100644 index 000000000..a1225b964 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-submachineref.puml @@ -0,0 +1,30 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 { + state S20 + state S21 { + state S30 + state S31 + + + [*] --> S30 + } + + + [*] --> S20 +} +state S3 + + +[*] --> S1 +S1 -down-> S2 : E1 +S20 -down-> S21 : E2 +S30 -down-> S31 : E3 +S2 -down-> S3 : E4 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-timers.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-timers.png new file mode 100644 index 000000000..c02a1c833 Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-timers.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-timers.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-timers.puml new file mode 100644 index 000000000..cf9fdf8a4 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-timers.puml @@ -0,0 +1,22 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 +state S3 +S3 : /entry s3Entry +state S4 +state S5 +S5 : /entry s5Entry + + +[*] --> S1 +S1 -down-> S2 : E1 +S1 -down-> S4 : E2 +S2 -down-> S3 : every 1 second +S4 -down-> S5 : after 1 second + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-transitiontypes.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-transitiontypes.png new file mode 100644 index 000000000..2f19c4c4b Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-transitiontypes.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-transitiontypes.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-transitiontypes.puml new file mode 100644 index 000000000..9a994a1c9 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/simple-transitiontypes.puml @@ -0,0 +1,16 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 + + +[*] --> S1 +S1 -down-> S2 : E1 +S2 -down-> S1 : E2 +S2 -down-> S2 : E3 + +@enduml \ No newline at end of file diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/transition-effect-spel.png b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/transition-effect-spel.png new file mode 100644 index 000000000..98eb0afbe Binary files /dev/null and b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/transition-effect-spel.png differ diff --git a/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/transition-effect-spel.puml b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/transition-effect-spel.puml new file mode 100644 index 000000000..3dd9e9d10 --- /dev/null +++ b/spring-statemachine-uml/src/test/resources/org/springframework/statemachine/uml/transition-effect-spel.puml @@ -0,0 +1,14 @@ +@startuml +'https://plantuml.com/state-diagram + +'hide description area for state without description +hide empty description + +state S1 +state S2 + + +[*] --> S1 +S1 -down-> S2 : E1\n/ extendedState.variables.put('key','value') + +@enduml \ No newline at end of file