state_machines-diagram 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +349 -0
- data/lib/state_machines/diagram/builder.rb +285 -0
- data/lib/state_machines/diagram/renderer.rb +353 -0
- data/lib/state_machines/diagram/version.rb +7 -0
- data/lib/state_machines-diagram.rb +9 -0
- metadata +124 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dea8376942dd89e9671884fffabcf95cefb34e39e4c6366a6413deca1f965d0f
|
|
4
|
+
data.tar.gz: 9dbc0c5784bcf3b68b07f57fcde2681b7623d05ace83582dfe0c7a8c8e3a8c66
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ff56f38bbb64f4d539f5b6418e6ffa8f90627f47c1401b30f63f845815ce666bdc5b0661ffc019383346e0fd99fa896c75e8425d31f894c84c24ccfee9d82cd0
|
|
7
|
+
data.tar.gz: 1345f579d826b86595a08b7a1a7c82d1521ccd83bb53af1165bc870b91b811af96ab9bc7e267d0ad610d11c2f08b0cea834948c1de3bc624258922de42e08d60
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Abdelkader Boudih
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# StateMachines::Diagram - Extensible Core for State Machine Visualization
|
|
2
|
+
|
|
3
|
+
[](https://github.com/state-machines/state_machines-diagram/actions/workflows/ruby.yml)
|
|
4
|
+
[](LICENSE.txt)
|
|
5
|
+
[](state_machines-diagram.gemspec)
|
|
6
|
+
|
|
7
|
+
An extensible diagram building foundation for the state_machines ecosystem. This gem provides a structured intermediate representation (IR) and adapter framework that enables consistent visualization across multiple output formats.
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# 1. Define a state machine using any supported library
|
|
13
|
+
require 'state_machines'
|
|
14
|
+
|
|
15
|
+
class Order
|
|
16
|
+
state_machine :status, initial: :pending do
|
|
17
|
+
state :pending, :processing, :shipped, :cancelled
|
|
18
|
+
|
|
19
|
+
event :process do
|
|
20
|
+
transition pending: :processing, if: :payment_cleared?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
event :ship do
|
|
24
|
+
transition processing: :shipped, action: :send_notification
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
event :cancel do
|
|
28
|
+
transition [:pending, :processing] => :cancelled
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def payment_cleared?
|
|
33
|
+
@payment_cleared ||= false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def send_notification
|
|
37
|
+
puts "Order shipped!"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 2. Generate diagram representation
|
|
42
|
+
require 'state_machines-diagram'
|
|
43
|
+
|
|
44
|
+
# Text format (default)
|
|
45
|
+
Order.state_machine(:status).draw
|
|
46
|
+
# Output:
|
|
47
|
+
# Status: pending → processing [process] (if: payment_cleared?)
|
|
48
|
+
# Status: processing → shipped [ship] (action: send_notification)
|
|
49
|
+
# Status: pending → cancelled [cancel]
|
|
50
|
+
# Status: processing → cancelled [cancel]
|
|
51
|
+
|
|
52
|
+
# JSON structure
|
|
53
|
+
Order.state_machine(:status).draw(format: :json)
|
|
54
|
+
# Output: {"states": [...], "transitions": [...]}
|
|
55
|
+
|
|
56
|
+
# With specific rendering gem
|
|
57
|
+
require 'state_machines-mermaid'
|
|
58
|
+
Order.state_machine(:status).draw(format: :mermaid)
|
|
59
|
+
# Output:
|
|
60
|
+
# stateDiagram-v2
|
|
61
|
+
# pending --> processing : process (if: payment_cleared?)
|
|
62
|
+
# processing --> shipped : ship (action: send_notification)
|
|
63
|
+
# pending --> cancelled : cancel
|
|
64
|
+
# processing --> cancelled : cancel
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Architecture
|
|
68
|
+
|
|
69
|
+
This gem implements a structured data transformation pipeline:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
StateMachine → Builder → Diagrams::StateDiagram → Renderer → Output
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Core Components
|
|
76
|
+
|
|
77
|
+
#### 1. Intermediate Representation (IR)
|
|
78
|
+
|
|
79
|
+
The `Diagrams::StateDiagram` IR uses immutable `Dry::Struct` objects to represent state machine structure:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
# State representation
|
|
83
|
+
Diagrams::Elements::State = Dry::Struct do
|
|
84
|
+
attribute :id, Types::Strict::String
|
|
85
|
+
attribute :state_type, Types::StateType # :initial, :final, :normal
|
|
86
|
+
attribute :name, Types::Strict::String.optional
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Transition representation with semantic information
|
|
90
|
+
Diagrams::Elements::Transition = Dry::Struct do
|
|
91
|
+
attribute :source_state_id, Types::Strict::String
|
|
92
|
+
attribute :target_state_id, Types::Strict::String
|
|
93
|
+
attribute :label, Types::Strict::String.optional
|
|
94
|
+
attribute :guard, Types::Strict::String.optional # if: condition
|
|
95
|
+
attribute :action, Types::Strict::String.optional # callback method
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Complete diagram structure
|
|
99
|
+
Diagrams::StateDiagram = Dry::Struct do
|
|
100
|
+
attribute :states, Types::Array.of(Diagrams::Elements::State)
|
|
101
|
+
attribute :transitions, Types::Array.of(Diagrams::Elements::Transition)
|
|
102
|
+
attribute :title, Types::Strict::String.optional
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
#### 2. Builder Contract
|
|
107
|
+
|
|
108
|
+
The `StateMachines::Diagram::Builder` extracts semantic information from state machines:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# Core building method
|
|
112
|
+
diagram = StateMachines::Diagram::Builder.build_state_diagram(machine, options)
|
|
113
|
+
|
|
114
|
+
# Builder handles:
|
|
115
|
+
# - State extraction with type detection
|
|
116
|
+
# - Transition mapping with guard conditions
|
|
117
|
+
# - Callback extraction (before/after/around)
|
|
118
|
+
# - Event-to-transition resolution
|
|
119
|
+
# - Filtering (state_filter, event_filter)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
#### 3. Renderer Interface
|
|
123
|
+
|
|
124
|
+
Renderers implement a standardized contract:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
module StateMachines::Diagram::Renderer
|
|
128
|
+
# Required method for all renderers
|
|
129
|
+
def self.draw_machine(machine, io: $stdout, **options)
|
|
130
|
+
diagram = build_state_diagram(machine, options)
|
|
131
|
+
output_diagram(diagram, io, options)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
# Override this method for custom output
|
|
137
|
+
def self.output_diagram(diagram, io, options)
|
|
138
|
+
# Your rendering logic here
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Error Handling
|
|
144
|
+
|
|
145
|
+
The gem provides robust error handling for common edge cases:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
begin
|
|
149
|
+
diagram = StateMachines::Diagram::Builder.build_state_diagram(machine)
|
|
150
|
+
rescue StateMachines::Diagram::InvalidStateError => e
|
|
151
|
+
puts "Invalid state configuration: #{e.message}"
|
|
152
|
+
rescue StateMachines::Diagram::TransitionError => e
|
|
153
|
+
puts "Transition error: #{e.message}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Validate diagram structure
|
|
157
|
+
if diagram.states.empty?
|
|
158
|
+
raise StateMachines::Diagram::EmptyDiagramError, "No states found"
|
|
159
|
+
end
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Advanced Usage
|
|
163
|
+
|
|
164
|
+
### Custom Renderer Example
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
module MyPlantUMLRenderer
|
|
168
|
+
extend StateMachines::Diagram::Renderer
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def self.output_diagram(diagram, io, options)
|
|
173
|
+
io.puts "@startuml"
|
|
174
|
+
io.puts "title #{diagram.title}" if diagram.title
|
|
175
|
+
|
|
176
|
+
diagram.states.each do |state|
|
|
177
|
+
io.puts "state #{state.id}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
diagram.transitions.each do |transition|
|
|
181
|
+
line = "#{transition.source_state_id} --> #{transition.target_state_id}"
|
|
182
|
+
line += " : #{transition.label}" if transition.label
|
|
183
|
+
|
|
184
|
+
annotations = []
|
|
185
|
+
annotations << "#{transition.guard}" if transition.guard
|
|
186
|
+
annotations << "#{transition.action}" if transition.action
|
|
187
|
+
line += " [#{annotations.join(', ')}]" unless annotations.empty?
|
|
188
|
+
|
|
189
|
+
io.puts line
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
io.puts "@enduml"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Use the custom renderer
|
|
197
|
+
StateMachines::Machine.renderer = MyPlantUMLRenderer
|
|
198
|
+
Order.state_machine(:status).draw
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Complex State Machine Support
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
class Dragon
|
|
205
|
+
# Multiple parallel state machines
|
|
206
|
+
state_machine :mood, initial: :sleeping do
|
|
207
|
+
state :sleeping, :hunting, :hoarding
|
|
208
|
+
|
|
209
|
+
event :wake_up do
|
|
210
|
+
transition sleeping: :hunting, if: :hungry?
|
|
211
|
+
transition sleeping: :hoarding, unless: :hungry?
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
event :find_treasure do
|
|
215
|
+
transition hoarding: :hoarding # Self-transition
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
state_machine :flight, initial: :grounded do
|
|
220
|
+
state :grounded, :airborne
|
|
221
|
+
|
|
222
|
+
event :take_off do
|
|
223
|
+
transition grounded: :airborne, action: :spread_wings
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def hungry?
|
|
228
|
+
@hunger_level > 5
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def spread_wings
|
|
232
|
+
puts "Dragon spreads mighty wings!"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Generate diagrams for each state machine
|
|
237
|
+
Dragon.state_machine(:mood).draw(show_conditions: true)
|
|
238
|
+
Dragon.state_machine(:flight).draw(show_callbacks: true)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Filtering and Options
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
# Focus on specific state
|
|
245
|
+
Order.state_machine(:status).draw(state_filter: :processing)
|
|
246
|
+
|
|
247
|
+
# Focus on specific event
|
|
248
|
+
Order.state_machine(:status).draw(event_filter: :ship)
|
|
249
|
+
|
|
250
|
+
# Show semantic information
|
|
251
|
+
Order.state_machine(:status).draw(show_conditions: true, show_callbacks: true)
|
|
252
|
+
|
|
253
|
+
# Human-readable names
|
|
254
|
+
Order.state_machine(:status).draw(human_names: true)
|
|
255
|
+
|
|
256
|
+
# Output to file
|
|
257
|
+
File.open('order_diagram.json', 'w') do |file|
|
|
258
|
+
Order.state_machine(:status).draw(io: file, format: :json)
|
|
259
|
+
end
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Testing Strategy
|
|
263
|
+
|
|
264
|
+
The gem includes comprehensive test coverage with robust fixtures:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
# Run all tests
|
|
268
|
+
rake test
|
|
269
|
+
|
|
270
|
+
# Test specific components
|
|
271
|
+
ruby -Itest test/unit/builder_test.rb
|
|
272
|
+
ruby -Itest test/unit/renderer_test.rb
|
|
273
|
+
|
|
274
|
+
# Lint code
|
|
275
|
+
bundle exec rubocop
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Test Coverage
|
|
279
|
+
|
|
280
|
+
- **Builder Tests**: State extraction, transition mapping, guard condition handling
|
|
281
|
+
- **Renderer Tests**: Output format validation, option handling, error cases
|
|
282
|
+
- **Integration Tests**: End-to-end workflows with complex state machines
|
|
283
|
+
- **Edge Case Tests**: Invalid states, circular transitions, missing callbacks
|
|
284
|
+
|
|
285
|
+
## Ecosystem Integration
|
|
286
|
+
|
|
287
|
+
This gem serves as the foundation for rendering-specific gems:
|
|
288
|
+
|
|
289
|
+
- **`state_machines-diagram`** (this gem): Core diagram building and IR
|
|
290
|
+
- **`state_machines-mermaid`**: Mermaid syntax renderer
|
|
291
|
+
- **`state_machines-graphviz`**: GraphViz DOT format renderer (planned)
|
|
292
|
+
- **Custom renderers**: PlantUML, SVG, ASCII art, etc.
|
|
293
|
+
|
|
294
|
+
### Supported State Machine Libraries
|
|
295
|
+
|
|
296
|
+
- `state_machines` (primary)
|
|
297
|
+
- `state_machines-activerecord`
|
|
298
|
+
- `state_machines-activemodel`
|
|
299
|
+
|
|
300
|
+
### Ruby Version Support
|
|
301
|
+
|
|
302
|
+
- **Ruby 3.3+**: Required for pattern matching and modern syntax
|
|
303
|
+
- **Rails 7.2+**: Optional, for ActiveRecord/ActiveModel integration
|
|
304
|
+
|
|
305
|
+
## Performance Considerations
|
|
306
|
+
|
|
307
|
+
- **Immutable IR**: Uses `Dry::Struct` for thread-safe, immutable diagram objects
|
|
308
|
+
- **Lazy Evaluation**: Diagrams are built only when `draw` is called
|
|
309
|
+
- **Memory Efficient**: No global state or caching by default
|
|
310
|
+
- **Streaming Support**: Large diagrams can be rendered incrementally
|
|
311
|
+
|
|
312
|
+
## Contributing
|
|
313
|
+
|
|
314
|
+
1. Fork the repository
|
|
315
|
+
2. Create a feature branch (`git checkout -b my-new-feature`)
|
|
316
|
+
3. Add tests for your changes
|
|
317
|
+
4. Ensure all tests pass (`rake test`)
|
|
318
|
+
5. Ensure code style compliance (`bundle exec rubocop`)
|
|
319
|
+
6. Submit a pull request
|
|
320
|
+
|
|
321
|
+
### Adding a New State Machine Library
|
|
322
|
+
|
|
323
|
+
To support a new state machine library, implement the builder pattern:
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
module StateMachines::Diagram::Adapters
|
|
327
|
+
class MyLibraryAdapter
|
|
328
|
+
def initialize(machine)
|
|
329
|
+
@machine = machine
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def build_states
|
|
333
|
+
# Extract states from @machine
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def build_transitions
|
|
337
|
+
# Extract transitions from @machine
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## License
|
|
344
|
+
|
|
345
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
346
|
+
|
|
347
|
+
## Changelog
|
|
348
|
+
|
|
349
|
+
See [CHANGELOG.md](CHANGELOG.md) for version history and breaking changes.
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'diagram'
|
|
4
|
+
|
|
5
|
+
module StateMachines
|
|
6
|
+
module Diagram
|
|
7
|
+
class Builder
|
|
8
|
+
attr_reader :machine, :options, :diagram, :state_metadata, :transition_metadata
|
|
9
|
+
|
|
10
|
+
def initialize(machine, options = {})
|
|
11
|
+
@machine = machine
|
|
12
|
+
@options = options
|
|
13
|
+
@diagram = create_diagram
|
|
14
|
+
@state_metadata = {}
|
|
15
|
+
@transition_metadata = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build
|
|
19
|
+
add_states
|
|
20
|
+
add_transitions
|
|
21
|
+
diagram
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def create_diagram
|
|
27
|
+
::Diagrams::StateDiagram.new(
|
|
28
|
+
title: diagram_title
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def diagram_id
|
|
33
|
+
"#{machine.owner_class.name}_#{machine.name}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def diagram_title
|
|
37
|
+
"#{machine.owner_class.name} #{machine.name} State Machine"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def diagram_description
|
|
41
|
+
"State machine for #{machine.owner_class.name}##{machine.name}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def add_states
|
|
45
|
+
machine.states.by_priority.each do |state|
|
|
46
|
+
add_state_node(state)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def add_state_node(state)
|
|
51
|
+
state_node = ::Diagrams::Elements::State.new(
|
|
52
|
+
id: state_id(state),
|
|
53
|
+
label: state_label(state)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
diagram.add_state(state_node)
|
|
57
|
+
|
|
58
|
+
# Store metadata separately for now
|
|
59
|
+
@state_metadata ||= {}
|
|
60
|
+
@state_metadata[state_id(state)] = {
|
|
61
|
+
type: state_type(state),
|
|
62
|
+
metadata: build_state_metadata(state)
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def state_id(state)
|
|
67
|
+
state.name ? state.name.to_s : 'nil_state'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def state_label(state)
|
|
71
|
+
if options[:human_names]
|
|
72
|
+
state.human_name(machine.owner_class)
|
|
73
|
+
else
|
|
74
|
+
state_id(state)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def state_type(state)
|
|
79
|
+
if state.initial?
|
|
80
|
+
'initial'
|
|
81
|
+
elsif state.final?
|
|
82
|
+
'final'
|
|
83
|
+
else
|
|
84
|
+
'normal'
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_state_metadata(state)
|
|
89
|
+
{
|
|
90
|
+
initial: state.initial?,
|
|
91
|
+
final: state.final?,
|
|
92
|
+
value: state.value,
|
|
93
|
+
methods: state.methods.grep(/^#{state.name}_/).map(&:to_s)
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def add_transitions
|
|
98
|
+
machine.events.each do |event|
|
|
99
|
+
add_event_transitions(event)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def add_event_transitions(event)
|
|
104
|
+
event.branches.each do |branch|
|
|
105
|
+
add_branch_transitions(branch, event)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def add_branch_transitions(branch, event)
|
|
110
|
+
valid_states = machine.states.by_priority.map(&:name)
|
|
111
|
+
|
|
112
|
+
branch.state_requirements.each do |requirement|
|
|
113
|
+
from_states = requirement[:from].filter(valid_states)
|
|
114
|
+
to_states = determine_to_states(requirement, from_states)
|
|
115
|
+
|
|
116
|
+
create_transitions(from_states, to_states, event, branch)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def determine_to_states(requirement, from_states)
|
|
121
|
+
if requirement[:to].values.empty?
|
|
122
|
+
# Loopback transitions
|
|
123
|
+
from_states
|
|
124
|
+
else
|
|
125
|
+
[requirement[:to].values.first]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def create_transitions(from_states, to_states, event, branch)
|
|
130
|
+
from_states.each do |from|
|
|
131
|
+
to_states.each do |to|
|
|
132
|
+
add_transition(from, to, event, branch)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def add_transition(from, to, event, branch)
|
|
138
|
+
from_id = from ? from.to_s : 'nil_state'
|
|
139
|
+
to_id = to ? to.to_s : 'nil_state'
|
|
140
|
+
|
|
141
|
+
guard_info = extract_guard_conditions(branch)
|
|
142
|
+
|
|
143
|
+
transition = ::Diagrams::Elements::Transition.new(
|
|
144
|
+
source_state_id: from_id.empty? ? 'nil_state' : from_id,
|
|
145
|
+
target_state_id: to_id.empty? ? 'nil_state' : to_id,
|
|
146
|
+
label: transition_label(event),
|
|
147
|
+
guard: guard_info[:display],
|
|
148
|
+
action: extract_action_info(branch, event)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
diagram.add_transition(transition)
|
|
152
|
+
|
|
153
|
+
# Store additional metadata separately for advanced analysis
|
|
154
|
+
@transition_metadata ||= []
|
|
155
|
+
transition_data = {
|
|
156
|
+
transition: transition,
|
|
157
|
+
from: from.to_s,
|
|
158
|
+
to: to.to_s,
|
|
159
|
+
event: event.name.to_s,
|
|
160
|
+
conditions: guard_info[:conditions],
|
|
161
|
+
callbacks: build_callbacks(branch, event),
|
|
162
|
+
metadata: build_transition_metadata(event, branch)
|
|
163
|
+
}
|
|
164
|
+
@transition_metadata << transition_data
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def transition_label(event)
|
|
168
|
+
if options[:human_names]
|
|
169
|
+
event.human_name(machine.owner_class)
|
|
170
|
+
else
|
|
171
|
+
event.name.to_s
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def build_callbacks(branch, event)
|
|
176
|
+
{
|
|
177
|
+
before: callback_method_names(branch, event, :before),
|
|
178
|
+
after: callback_method_names(branch, event, :after),
|
|
179
|
+
around: callback_method_names(branch, event, :around)
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def callback_method_names(branch, event, type)
|
|
184
|
+
machine.callbacks[type == :around ? :before : type].select do |callback|
|
|
185
|
+
callback.branch.matches?(branch,
|
|
186
|
+
from: branch.state_requirements.map { |req| req[:from] },
|
|
187
|
+
to: branch.state_requirements.map { |req| req[:to] },
|
|
188
|
+
on: event.name)
|
|
189
|
+
end.flat_map do |callback|
|
|
190
|
+
callback.instance_variable_get('@methods')
|
|
191
|
+
end.compact
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def build_transition_metadata(_event, branch)
|
|
195
|
+
{
|
|
196
|
+
requirements: branch.state_requirements.size
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def extract_guard_conditions(branch)
|
|
201
|
+
guard_conditions = {
|
|
202
|
+
if: [],
|
|
203
|
+
unless: []
|
|
204
|
+
}
|
|
205
|
+
condition_tokens = []
|
|
206
|
+
|
|
207
|
+
if branch.if_condition
|
|
208
|
+
token = guard_condition_token(branch.if_condition)
|
|
209
|
+
guard_conditions[:if] << token if token
|
|
210
|
+
condition_tokens << token if token
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
if branch.unless_condition
|
|
214
|
+
token = guard_condition_token(branch.unless_condition)
|
|
215
|
+
guard_conditions[:unless] << token if token
|
|
216
|
+
condition_tokens << "!#{token}" if token
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
{
|
|
220
|
+
display: condition_tokens.empty? ? nil : condition_tokens.join(' && '),
|
|
221
|
+
conditions: guard_conditions
|
|
222
|
+
}
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def guard_condition_token(condition)
|
|
226
|
+
return if condition.nil?
|
|
227
|
+
|
|
228
|
+
case condition
|
|
229
|
+
when Symbol
|
|
230
|
+
method_name = condition.to_s
|
|
231
|
+
method_name += '?' unless method_name.end_with?('?')
|
|
232
|
+
method_name
|
|
233
|
+
when Proc, Method
|
|
234
|
+
if condition.respond_to?(:source_location) && condition.source_location
|
|
235
|
+
file, line = condition.source_location
|
|
236
|
+
filename = File.basename(file) if file
|
|
237
|
+
"lambda@#{filename}:#{line}"
|
|
238
|
+
else
|
|
239
|
+
'lambda'
|
|
240
|
+
end
|
|
241
|
+
else
|
|
242
|
+
condition.to_s
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def extract_action_info(branch, event)
|
|
247
|
+
actions = []
|
|
248
|
+
|
|
249
|
+
# Add explicit action if available (store the object, format during rendering)
|
|
250
|
+
actions << event.action if event.respond_to?(:action) && event.action
|
|
251
|
+
|
|
252
|
+
# Add callback methods as actions
|
|
253
|
+
before_callbacks = callback_method_names(branch, event, :before)
|
|
254
|
+
after_callbacks = callback_method_names(branch, event, :after)
|
|
255
|
+
|
|
256
|
+
actions.concat(before_callbacks) if before_callbacks.any?
|
|
257
|
+
actions.concat(after_callbacks) if after_callbacks.any?
|
|
258
|
+
|
|
259
|
+
return nil if actions.empty?
|
|
260
|
+
|
|
261
|
+
# Format all actions appropriately
|
|
262
|
+
formatted_actions = actions.map { |action| format_action(action) }
|
|
263
|
+
formatted_actions.join(', ')
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def format_action(action)
|
|
267
|
+
case action
|
|
268
|
+
when Proc, Method
|
|
269
|
+
# Try to extract source location for better readability
|
|
270
|
+
if action.respond_to?(:source_location) && action.source_location
|
|
271
|
+
file, line = action.source_location
|
|
272
|
+
filename = File.basename(file) if file
|
|
273
|
+
"lambda@#{filename}:#{line}"
|
|
274
|
+
else
|
|
275
|
+
'lambda'
|
|
276
|
+
end
|
|
277
|
+
when Symbol
|
|
278
|
+
action.to_s
|
|
279
|
+
else
|
|
280
|
+
action.to_s
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'builder'
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
module StateMachines
|
|
7
|
+
module Diagram
|
|
8
|
+
module Renderer
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def draw_machine(machine, io: $stdout, **options)
|
|
12
|
+
diagram, builder = build_state_diagram(machine, options)
|
|
13
|
+
output_diagram(diagram, io, options, builder)
|
|
14
|
+
diagram
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build_state_diagram(machine, options)
|
|
18
|
+
builder = Builder.new(machine, options)
|
|
19
|
+
diagram = builder.build
|
|
20
|
+
[diagram, builder]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def output_diagram(diagram, io, options, builder = nil)
|
|
24
|
+
case options[:format]
|
|
25
|
+
when :json
|
|
26
|
+
io.puts diagram_hash_with_metadata(diagram, builder).to_json
|
|
27
|
+
when :yaml
|
|
28
|
+
require 'yaml'
|
|
29
|
+
io.puts diagram_hash_with_metadata(diagram, builder).to_yaml
|
|
30
|
+
else
|
|
31
|
+
# Default text representation
|
|
32
|
+
io.puts diagram_to_text(diagram, options, builder)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def draw_state(state, _graph, options = {}, io = $stdout)
|
|
37
|
+
# Build a diagram containing just this state and its transitions
|
|
38
|
+
machine = state.machine
|
|
39
|
+
|
|
40
|
+
# Find all states involved with this state
|
|
41
|
+
states_involved = Set.new([state.name])
|
|
42
|
+
|
|
43
|
+
machine.events.each do |event|
|
|
44
|
+
event.branches.each do |branch|
|
|
45
|
+
branch.state_requirements.each do |requirement|
|
|
46
|
+
valid_states = machine.states.by_priority.map(&:name)
|
|
47
|
+
from_states = requirement[:from].filter(valid_states)
|
|
48
|
+
to_states = requirement[:to].values
|
|
49
|
+
|
|
50
|
+
if from_states.include?(state.name)
|
|
51
|
+
states_involved.merge(to_states)
|
|
52
|
+
elsif to_states.include?(state.name)
|
|
53
|
+
states_involved.merge(from_states)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Build diagram with all involved states
|
|
60
|
+
builder = Builder.new(machine, options)
|
|
61
|
+
builder.instance_variable_set(:@diagram, builder.send(:create_diagram))
|
|
62
|
+
|
|
63
|
+
# Add all involved states first
|
|
64
|
+
machine.states.select { |s| states_involved.include?(s.name) }.each do |s|
|
|
65
|
+
builder.send(:add_state_node, s)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Then add transitions
|
|
69
|
+
machine.events.each do |event|
|
|
70
|
+
event.branches.each do |branch|
|
|
71
|
+
branch.state_requirements.each do |requirement|
|
|
72
|
+
valid_states = machine.states.by_priority.map(&:name)
|
|
73
|
+
from_states = requirement[:from].filter(valid_states)
|
|
74
|
+
to_states = requirement[:to].values
|
|
75
|
+
|
|
76
|
+
# Only add if involves our state
|
|
77
|
+
if (from_states & states_involved.to_a).any? && (to_states & states_involved.to_a).any?
|
|
78
|
+
builder.send(:create_transitions, from_states & states_involved.to_a, to_states & states_involved.to_a,
|
|
79
|
+
event, branch)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
output_diagram(builder.diagram, io, options.merge(state_filter: state.name.to_s), builder)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def diagram_hash_with_metadata(diagram, builder)
|
|
89
|
+
base_hash = diagram.to_h
|
|
90
|
+
return base_hash unless builder
|
|
91
|
+
|
|
92
|
+
transition_metadata_index = build_transition_metadata_index(builder)
|
|
93
|
+
transition_hashes = extract_transition_hashes(base_hash)
|
|
94
|
+
return base_hash unless transition_hashes
|
|
95
|
+
|
|
96
|
+
diagram.transitions.each_with_index do |transition, index|
|
|
97
|
+
metadata = transition_metadata_index[transition] ||
|
|
98
|
+
find_fallback_transition_metadata(transition_metadata_index, transition) ||
|
|
99
|
+
{}
|
|
100
|
+
transition_hash = transition_hashes[index]
|
|
101
|
+
next unless transition_hash
|
|
102
|
+
|
|
103
|
+
guard_terms = guard_terms_for(transition, metadata)
|
|
104
|
+
action_list = action_list_for(transition, metadata)
|
|
105
|
+
|
|
106
|
+
guard_payload = {}
|
|
107
|
+
guard_payload[:if] = guard_terms[:if] unless guard_terms[:if].empty?
|
|
108
|
+
guard_payload[:unless] = guard_terms[:unless] unless guard_terms[:unless].empty?
|
|
109
|
+
transition_hash[:guard] = guard_payload unless guard_payload.empty?
|
|
110
|
+
|
|
111
|
+
transition_hash[:action] = action_list unless action_list.empty?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
base_hash
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def extract_transition_hashes(base_hash)
|
|
118
|
+
data = base_hash[:data] || base_hash['data']
|
|
119
|
+
return unless data
|
|
120
|
+
|
|
121
|
+
data[:transitions] || data['transitions']
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def draw_event(event, _graph, options = {}, io = $stdout)
|
|
125
|
+
machine = event.machine
|
|
126
|
+
|
|
127
|
+
# Add all states involved in this event
|
|
128
|
+
states_involved = Set.new
|
|
129
|
+
event.branches.each do |branch|
|
|
130
|
+
branch.state_requirements.each do |requirement|
|
|
131
|
+
valid_states = machine.states.by_priority.map(&:name)
|
|
132
|
+
from_states = requirement[:from].filter(valid_states)
|
|
133
|
+
to_states = requirement[:to].values
|
|
134
|
+
|
|
135
|
+
states_involved.merge(from_states)
|
|
136
|
+
states_involved.merge(to_states)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Build a fresh diagram for this event
|
|
141
|
+
builder = Builder.new(machine, options)
|
|
142
|
+
builder.instance_variable_set(:@diagram, builder.send(:create_diagram))
|
|
143
|
+
|
|
144
|
+
# Add states to diagram first
|
|
145
|
+
machine.states.select { |s| states_involved.include?(s.name) }.each do |state|
|
|
146
|
+
builder.send(:add_state_node, state)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Add transitions for this event
|
|
150
|
+
builder.send(:add_event_transitions, event)
|
|
151
|
+
|
|
152
|
+
output_diagram(builder.diagram, io, options.merge(event_filter: event.name.to_s), builder)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def draw_branch(branch, _graph, event, valid_states, io = $stdout)
|
|
156
|
+
machine = event.machine
|
|
157
|
+
|
|
158
|
+
# Add states involved in this branch
|
|
159
|
+
states_involved = Set.new
|
|
160
|
+
branch.state_requirements.each do |requirement|
|
|
161
|
+
from_states = requirement[:from].filter(valid_states)
|
|
162
|
+
to_states = requirement[:to].values
|
|
163
|
+
|
|
164
|
+
states_involved.merge(from_states)
|
|
165
|
+
states_involved.merge(to_states)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Build fresh diagram
|
|
169
|
+
builder = Builder.new(machine, {})
|
|
170
|
+
builder.instance_variable_set(:@diagram, builder.send(:create_diagram))
|
|
171
|
+
|
|
172
|
+
# Add states first
|
|
173
|
+
machine.states.select { |s| states_involved.include?(s.name) }.each do |state|
|
|
174
|
+
builder.send(:add_state_node, state)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Add transitions
|
|
178
|
+
builder.send(:add_branch_transitions, branch, event)
|
|
179
|
+
|
|
180
|
+
output_diagram(builder.diagram, io, {}, builder)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def diagram_to_text(diagram, options = {}, builder = nil)
|
|
184
|
+
# Defensive copy of options to prevent mutation affecting other tests
|
|
185
|
+
safe_options = options.dup.freeze
|
|
186
|
+
output = []
|
|
187
|
+
|
|
188
|
+
# Add filter headers if specified
|
|
189
|
+
if safe_options[:state_filter]
|
|
190
|
+
output << "State: #{safe_options[:state_filter]}"
|
|
191
|
+
output << ''
|
|
192
|
+
elsif safe_options[:event_filter]
|
|
193
|
+
output << "Event: #{safe_options[:event_filter]}"
|
|
194
|
+
output << ''
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
output << "=== #{diagram.title} ==="
|
|
198
|
+
output << ''
|
|
199
|
+
|
|
200
|
+
# Use provided builder to access metadata (with nil safety)
|
|
201
|
+
metadata_source = builder&.state_metadata || {}
|
|
202
|
+
|
|
203
|
+
# List states
|
|
204
|
+
output << 'States:'
|
|
205
|
+
diagram.states.each do |state|
|
|
206
|
+
metadata = metadata_source[state.id] || {}
|
|
207
|
+
type_marker = case metadata[:type]
|
|
208
|
+
when 'initial' then ' [*]'
|
|
209
|
+
when 'final' then ' (O)'
|
|
210
|
+
else ''
|
|
211
|
+
end
|
|
212
|
+
label = state.label || state.id
|
|
213
|
+
output << " - #{label}#{type_marker}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
output << ''
|
|
217
|
+
output << 'Transitions:'
|
|
218
|
+
|
|
219
|
+
# List transitions with semantic information from the transition objects
|
|
220
|
+
transition_metadata_index = build_transition_metadata_index(builder)
|
|
221
|
+
|
|
222
|
+
diagram.transitions.each do |transition|
|
|
223
|
+
metadata = transition_metadata_index[transition] ||
|
|
224
|
+
find_fallback_transition_metadata(transition_metadata_index, transition) ||
|
|
225
|
+
{}
|
|
226
|
+
condition_str = format_guard_condition(transition, metadata)
|
|
227
|
+
action_str = format_action_callback(transition, metadata)
|
|
228
|
+
|
|
229
|
+
label = transition.label || ''
|
|
230
|
+
output << " - #{transition.source_state_id} -> #{transition.target_state_id} [#{label}]#{condition_str}#{action_str}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
output.join("\n")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def build_transition_metadata_index(builder)
|
|
237
|
+
return {} unless builder&.respond_to?(:transition_metadata)
|
|
238
|
+
|
|
239
|
+
Array(builder.transition_metadata).each_with_object({}) do |metadata, index|
|
|
240
|
+
transition = metadata[:transition]
|
|
241
|
+
if transition
|
|
242
|
+
index[transition] = metadata
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
key = [
|
|
246
|
+
metadata[:from].to_s,
|
|
247
|
+
metadata[:to].to_s,
|
|
248
|
+
metadata[:event].to_s
|
|
249
|
+
]
|
|
250
|
+
(index[:by_key] ||= Hash.new { |h, k| h[k] = [] })[key] << metadata
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def find_fallback_transition_metadata(index, transition)
|
|
255
|
+
by_key = index[:by_key]
|
|
256
|
+
return unless by_key
|
|
257
|
+
|
|
258
|
+
key = [
|
|
259
|
+
transition.source_state_id.to_s,
|
|
260
|
+
transition.target_state_id.to_s,
|
|
261
|
+
transition.label.to_s
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
metadata_list = by_key[key]
|
|
265
|
+
metadata_list&.first
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def format_guard_condition(transition, metadata = {})
|
|
269
|
+
guard_terms = guard_terms_for(transition, metadata)
|
|
270
|
+
|
|
271
|
+
return '' if guard_terms[:if].empty? && guard_terms[:unless].empty?
|
|
272
|
+
|
|
273
|
+
parts = []
|
|
274
|
+
guard_terms[:if].each { |condition| parts << "(if: #{condition})" }
|
|
275
|
+
guard_terms[:unless].each { |condition| parts << "(unless: #{condition})" }
|
|
276
|
+
" #{parts.join(' ')}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def guard_terms_for(transition, metadata = {})
|
|
280
|
+
guard_terms = { if: [], unless: [] }
|
|
281
|
+
|
|
282
|
+
if transition.respond_to?(:guard) && transition.guard
|
|
283
|
+
parse_guard_string(transition.guard.to_s, guard_terms)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
conditions = metadata.fetch(:conditions, {})
|
|
287
|
+
Array(conditions[:if]).compact.each do |condition|
|
|
288
|
+
Array(condition).each { |item| guard_terms[:if] << normalize_condition_name(item) }
|
|
289
|
+
end
|
|
290
|
+
Array(conditions[:unless]).compact.each do |condition|
|
|
291
|
+
Array(condition).each { |item| guard_terms[:unless] << normalize_condition_name(item) }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
guard_terms[:if].uniq!
|
|
295
|
+
guard_terms[:unless].uniq!
|
|
296
|
+
|
|
297
|
+
guard_terms
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def parse_guard_string(guard_string, guard_terms)
|
|
301
|
+
guard_string.split(/\s*&&\s*/).each do |segment|
|
|
302
|
+
next if segment.nil? || segment.empty?
|
|
303
|
+
|
|
304
|
+
if segment.start_with?('!')
|
|
305
|
+
guard_terms[:unless] << normalize_condition_name(segment[1..])
|
|
306
|
+
else
|
|
307
|
+
guard_terms[:if] << normalize_condition_name(segment)
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def normalize_condition_name(condition)
|
|
313
|
+
return '' if condition.nil?
|
|
314
|
+
|
|
315
|
+
condition_name = condition.to_s.strip
|
|
316
|
+
condition_name = condition_name[1..] if condition_name.start_with?(':')
|
|
317
|
+
condition_name
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def format_action_callback(transition, metadata = {})
|
|
321
|
+
actions = action_list_for(transition, metadata)
|
|
322
|
+
return '' if actions.empty?
|
|
323
|
+
|
|
324
|
+
" (action: #{actions.join(', ')})"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def action_list_for(transition, metadata = {})
|
|
328
|
+
actions = []
|
|
329
|
+
|
|
330
|
+
if transition.respond_to?(:action) && transition.action
|
|
331
|
+
actions << normalize_action_name(transition.action)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
callbacks = metadata.fetch(:callbacks, {})
|
|
335
|
+
callbacks.values.each do |callback_list|
|
|
336
|
+
Array(callback_list).each do |callback|
|
|
337
|
+
actions << normalize_action_name(callback)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
actions.compact!
|
|
342
|
+
actions.uniq!
|
|
343
|
+
actions
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def normalize_action_name(action)
|
|
347
|
+
return if action.nil?
|
|
348
|
+
|
|
349
|
+
action.is_a?(Symbol) ? action.to_s : action.to_s
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'state_machines'
|
|
4
|
+
require 'state_machines/diagram/version'
|
|
5
|
+
require 'state_machines/diagram/builder'
|
|
6
|
+
require 'state_machines/diagram/renderer'
|
|
7
|
+
|
|
8
|
+
# Set the renderer to use diagram
|
|
9
|
+
StateMachines::Machine.renderer = StateMachines::Diagram::Renderer
|
metadata
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: state_machines-diagram
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Abdelkader Boudih
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: diagram
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.3.4
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.3.4
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: state_machines
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.100'
|
|
33
|
+
- - ">="
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: 0.100.4
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - "~>"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '0.100'
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: 0.100.4
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: bundler
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '0'
|
|
53
|
+
type: :development
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '0'
|
|
60
|
+
- !ruby/object:Gem::Dependency
|
|
61
|
+
name: minitest
|
|
62
|
+
requirement: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - "~>"
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '5.25'
|
|
67
|
+
type: :development
|
|
68
|
+
prerelease: false
|
|
69
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - "~>"
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '5.25'
|
|
74
|
+
- !ruby/object:Gem::Dependency
|
|
75
|
+
name: rake
|
|
76
|
+
requirement: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
type: :development
|
|
82
|
+
prerelease: false
|
|
83
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0'
|
|
88
|
+
description: Diagram module for state machines. Builds diagram representations of
|
|
89
|
+
state machines that can be rendered in various formats
|
|
90
|
+
email:
|
|
91
|
+
- terminale@gmail.com
|
|
92
|
+
executables: []
|
|
93
|
+
extensions: []
|
|
94
|
+
extra_rdoc_files: []
|
|
95
|
+
files:
|
|
96
|
+
- LICENSE.txt
|
|
97
|
+
- README.md
|
|
98
|
+
- lib/state_machines-diagram.rb
|
|
99
|
+
- lib/state_machines/diagram/builder.rb
|
|
100
|
+
- lib/state_machines/diagram/renderer.rb
|
|
101
|
+
- lib/state_machines/diagram/version.rb
|
|
102
|
+
homepage: https://github.com/state-machines/state_machines-diagram
|
|
103
|
+
licenses:
|
|
104
|
+
- MIT
|
|
105
|
+
metadata:
|
|
106
|
+
rubygems_mfa_required: 'true'
|
|
107
|
+
rdoc_options: []
|
|
108
|
+
require_paths:
|
|
109
|
+
- lib
|
|
110
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
111
|
+
requirements:
|
|
112
|
+
- - ">="
|
|
113
|
+
- !ruby/object:Gem::Version
|
|
114
|
+
version: 3.3.0
|
|
115
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
116
|
+
requirements:
|
|
117
|
+
- - ">="
|
|
118
|
+
- !ruby/object:Gem::Version
|
|
119
|
+
version: '0'
|
|
120
|
+
requirements: []
|
|
121
|
+
rubygems_version: 3.6.9
|
|
122
|
+
specification_version: 4
|
|
123
|
+
summary: Diagram building for state machines
|
|
124
|
+
test_files: []
|