graphomaton 0.1.1 → 1.0.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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +49 -4
- data/lib/graphomaton/exporters/dot.rb +47 -0
- data/lib/graphomaton/exporters/mermaid.rb +94 -0
- data/lib/graphomaton/exporters/plantuml.rb +47 -0
- data/lib/graphomaton/exporters/svg.rb +328 -0
- data/lib/graphomaton/exporters.rb +6 -0
- data/lib/graphomaton/version.rb +1 -1
- data/lib/graphomaton.rb +22 -212
- data/sample/basic.rb +19 -8
- data/sample/complex.rb +9 -1
- data/sample/long_names.rb +20 -0
- data/sample/nfa.rb +9 -0
- data/sample/skip_states.rb +23 -0
- data/spec/exporters/dot_spec.rb +146 -0
- data/spec/exporters/mermaid_spec.rb +154 -0
- data/spec/exporters/plantuml_spec.rb +144 -0
- data/spec/exporters/svg_spec.rb +314 -0
- data/spec/graphomaton_edge_cases_spec.rb +322 -0
- data/spec/graphomaton_spec.rb +3 -3
- metadata +18 -6
data/lib/graphomaton.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative 'graphomaton/exporters'
|
|
4
4
|
require_relative 'graphomaton/version'
|
|
5
5
|
|
|
6
6
|
class Graphomaton
|
|
@@ -77,224 +77,34 @@ class Graphomaton
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def to_svg(width = 800, height = 600)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
doc = REXML::Document.new
|
|
83
|
-
svg = doc.add_element('svg', {
|
|
84
|
-
'xmlns' => 'http://www.w3.org/2000/svg',
|
|
85
|
-
'width' => width.to_s,
|
|
86
|
-
'height' => height.to_s,
|
|
87
|
-
'viewBox' => "0 0 #{width} #{height}"
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
defs = svg.add_element('defs')
|
|
91
|
-
marker = defs.add_element('marker', {
|
|
92
|
-
'id' => 'arrowhead',
|
|
93
|
-
'markerWidth' => '10',
|
|
94
|
-
'markerHeight' => '10',
|
|
95
|
-
'refX' => '9',
|
|
96
|
-
'refY' => '3',
|
|
97
|
-
'orient' => 'auto'
|
|
98
|
-
})
|
|
99
|
-
marker.add_element('polygon', {
|
|
100
|
-
'points' => '0 0, 10 3, 0 6',
|
|
101
|
-
'fill' => '#333'
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
style = svg.add_element('style')
|
|
105
|
-
style.text = <<-CSS
|
|
106
|
-
.state-circle { fill: white; stroke: #333; stroke-width: 2; }
|
|
107
|
-
.final-state { stroke-width: 4; }
|
|
108
|
-
.state-text { font-family: Arial, sans-serif; font-size: 16px; text-anchor: middle; }
|
|
109
|
-
.transition-line { stroke: #333; stroke-width: 1.5; fill: none; marker-end: url(#arrowhead); }
|
|
110
|
-
.transition-label { font-family: Arial, sans-serif; font-size: 14px; fill: #666; }
|
|
111
|
-
.initial-arrow { stroke: #333; stroke-width: 2; fill: none; marker-end: url(#arrowhead); }
|
|
112
|
-
.label-bg { fill: white; opacity: 0.9; }
|
|
113
|
-
CSS
|
|
114
|
-
|
|
115
|
-
processed_pairs = {}
|
|
116
|
-
|
|
117
|
-
@transitions.each_with_index do |trans, _idx|
|
|
118
|
-
from_state = @states[trans[:from]]
|
|
119
|
-
to_state = @states[trans[:to]]
|
|
120
|
-
|
|
121
|
-
if from_state == to_state
|
|
122
|
-
|
|
123
|
-
cx = from_state[:x]
|
|
124
|
-
cy = from_state[:y]
|
|
125
|
-
|
|
126
|
-
loop_height = 80
|
|
127
|
-
loop_width = 45
|
|
128
|
-
|
|
129
|
-
start_angle = -135 * Math::PI / 180
|
|
130
|
-
end_angle = -45 * Math::PI / 180
|
|
131
|
-
radius = 30
|
|
132
|
-
|
|
133
|
-
start_x = cx + (radius * Math.cos(start_angle))
|
|
134
|
-
start_y = cy + (radius * Math.sin(start_angle))
|
|
135
|
-
end_x = cx + (radius * Math.cos(end_angle))
|
|
136
|
-
end_y = cy + (radius * Math.sin(end_angle))
|
|
137
|
-
|
|
138
|
-
control1_x = cx - loop_width
|
|
139
|
-
control1_y = cy - loop_height
|
|
140
|
-
control2_x = cx + loop_width
|
|
141
|
-
control2_y = cy - loop_height
|
|
142
|
-
|
|
143
|
-
path_d = "M #{start_x} #{start_y} C #{control1_x} #{control1_y}, #{control2_x} #{control2_y}, #{end_x} #{end_y}"
|
|
144
|
-
|
|
145
|
-
svg.add_element('path', {
|
|
146
|
-
'class' => 'transition-line',
|
|
147
|
-
'd' => path_d
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
svg.add_element('rect', {
|
|
151
|
-
'class' => 'label-bg',
|
|
152
|
-
'x' => (cx - 20).to_s,
|
|
153
|
-
'y' => (cy - loop_height + 5).to_s,
|
|
154
|
-
'width' => '40',
|
|
155
|
-
'height' => '20',
|
|
156
|
-
'rx' => '3'
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
label = svg.add_element('text', {
|
|
160
|
-
'class' => 'transition-label',
|
|
161
|
-
'x' => cx.to_s,
|
|
162
|
-
'y' => (cy - loop_height + 20).to_s,
|
|
163
|
-
'text-anchor' => 'middle'
|
|
164
|
-
})
|
|
165
|
-
else
|
|
166
|
-
|
|
167
|
-
x1 = from_state[:x]
|
|
168
|
-
y1 = from_state[:y]
|
|
169
|
-
x2 = to_state[:x]
|
|
170
|
-
y2 = to_state[:y]
|
|
171
|
-
|
|
172
|
-
pair_key = [trans[:from], trans[:to]].sort.join('-')
|
|
173
|
-
processed_pairs[pair_key] = 0 unless processed_pairs[pair_key]
|
|
174
|
-
|
|
175
|
-
pair_index = processed_pairs[pair_key]
|
|
176
|
-
processed_pairs[pair_key] += 1
|
|
177
|
-
|
|
178
|
-
parallel_count = count_parallel_transitions(trans[:from], trans[:to])
|
|
179
|
-
|
|
180
|
-
dx = x2 - x1
|
|
181
|
-
dy = y2 - y1
|
|
182
|
-
dist = Math.sqrt((dx**2) + (dy**2))
|
|
183
|
-
|
|
184
|
-
radius = 30
|
|
185
|
-
start_x = x1 + ((dx / dist) * radius)
|
|
186
|
-
start_y = y1 + ((dy / dist) * radius)
|
|
187
|
-
end_x = x2 - ((dx / dist) * radius)
|
|
188
|
-
end_y = y2 - ((dy / dist) * radius)
|
|
189
|
-
|
|
190
|
-
mid_x = (start_x + end_x) / 2
|
|
191
|
-
mid_y = (start_y + end_y) / 2
|
|
192
|
-
|
|
193
|
-
curve_offset = 0
|
|
194
|
-
if parallel_count > 1
|
|
195
|
-
|
|
196
|
-
curve_offset = if trans[:from] < trans[:to]
|
|
197
|
-
|
|
198
|
-
-40 * (pair_index + 1)
|
|
199
|
-
else
|
|
200
|
-
|
|
201
|
-
40 * (pair_index + 1)
|
|
202
|
-
end
|
|
203
|
-
elsif x1 != x2
|
|
204
|
-
|
|
205
|
-
curve_offset = -20 if x1 < x2
|
|
206
|
-
curve_offset = 20 if x1 > x2
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
control_x = if (x2 - x1).abs < 10
|
|
210
|
-
mid_x + (50 * (pair_index.even? ? 1 : -1))
|
|
211
|
-
else
|
|
212
|
-
mid_x
|
|
213
|
-
end
|
|
214
|
-
control_y = mid_y + curve_offset
|
|
215
|
-
|
|
216
|
-
path_d = "M #{start_x} #{start_y} Q #{control_x} #{control_y}, #{end_x} #{end_y}"
|
|
217
|
-
|
|
218
|
-
svg.add_element('path', {
|
|
219
|
-
'class' => 'transition-line',
|
|
220
|
-
'd' => path_d
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
t = 0.5
|
|
224
|
-
label_x = ((1 - t) * (1 - t) * start_x) + (2 * (1 - t) * t * control_x) + (t * t * end_x)
|
|
225
|
-
label_y = ((1 - t) * (1 - t) * start_y) + (2 * (1 - t) * t * control_y) + (t * t * end_y)
|
|
226
|
-
|
|
227
|
-
text_width = (trans[:label].length * 8) + 10
|
|
228
|
-
svg.add_element('rect', {
|
|
229
|
-
'class' => 'label-bg',
|
|
230
|
-
'x' => (label_x - (text_width / 2)).to_s,
|
|
231
|
-
'y' => (label_y - 10).to_s,
|
|
232
|
-
'width' => text_width.to_s,
|
|
233
|
-
'height' => '20',
|
|
234
|
-
'rx' => '3'
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
label = svg.add_element('text', {
|
|
238
|
-
'class' => 'transition-label',
|
|
239
|
-
'x' => label_x.to_s,
|
|
240
|
-
'y' => (label_y + 5).to_s,
|
|
241
|
-
'text-anchor' => 'middle'
|
|
242
|
-
})
|
|
243
|
-
end
|
|
244
|
-
label.text = trans[:label]
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
if @initial_state && @states[@initial_state]
|
|
248
|
-
init = @states[@initial_state]
|
|
249
|
-
svg.add_element('line', {
|
|
250
|
-
'class' => 'initial-arrow',
|
|
251
|
-
'x1' => (init[:x] - 60).to_s,
|
|
252
|
-
'y1' => init[:y].to_s,
|
|
253
|
-
'x2' => (init[:x] - 30).to_s,
|
|
254
|
-
'y2' => init[:y].to_s
|
|
255
|
-
})
|
|
80
|
+
Exporters::Svg.new(self).export(width, height)
|
|
81
|
+
end
|
|
256
82
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
'y' => (init[:y] - 10).to_s,
|
|
261
|
-
'text-anchor' => 'end'
|
|
262
|
-
})
|
|
263
|
-
start_label.text = 'start'
|
|
264
|
-
end
|
|
83
|
+
def save_svg(filename, width = 800, height = 600)
|
|
84
|
+
File.write(filename, to_svg(width, height))
|
|
85
|
+
end
|
|
265
86
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
87
|
+
def to_mermaid
|
|
88
|
+
Exporters::Mermaid.new(self).export
|
|
89
|
+
end
|
|
269
90
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
'cy' => state[:y].to_s,
|
|
274
|
-
'r' => '30'
|
|
275
|
-
})
|
|
91
|
+
def save_html(filename)
|
|
92
|
+
File.write(filename, Exporters::Mermaid.new(self).export_html)
|
|
93
|
+
end
|
|
276
94
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
'cx' => state[:x].to_s,
|
|
281
|
-
'cy' => state[:y].to_s,
|
|
282
|
-
'r' => '24'
|
|
283
|
-
})
|
|
284
|
-
end
|
|
95
|
+
def to_dot
|
|
96
|
+
Exporters::Dot.new(self).export
|
|
97
|
+
end
|
|
285
98
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
'y' => (state[:y] + 5).to_s
|
|
290
|
-
})
|
|
291
|
-
text.text = name.to_s
|
|
292
|
-
end
|
|
99
|
+
def save_dot(filename)
|
|
100
|
+
File.write(filename, to_dot)
|
|
101
|
+
end
|
|
293
102
|
|
|
294
|
-
|
|
103
|
+
def to_plantuml
|
|
104
|
+
Exporters::Plantuml.new(self).export
|
|
295
105
|
end
|
|
296
106
|
|
|
297
|
-
def
|
|
298
|
-
File.write(filename,
|
|
107
|
+
def save_plantuml(filename)
|
|
108
|
+
File.write(filename, to_plantuml)
|
|
299
109
|
end
|
|
300
110
|
end
|
data/sample/basic.rb
CHANGED
|
@@ -4,16 +4,27 @@ require_relative '../lib/graphomaton'
|
|
|
4
4
|
|
|
5
5
|
automaton = Graphomaton.new
|
|
6
6
|
|
|
7
|
-
automaton.add_state('
|
|
8
|
-
automaton.add_state('
|
|
7
|
+
automaton.add_state('待機中')
|
|
8
|
+
automaton.add_state('お金投入済み')
|
|
9
|
+
automaton.add_state('商品選択済み')
|
|
9
10
|
|
|
10
|
-
automaton.set_initial('
|
|
11
|
-
automaton.add_final('
|
|
11
|
+
automaton.set_initial('待機中')
|
|
12
|
+
automaton.add_final('待機中')
|
|
12
13
|
|
|
13
|
-
automaton.add_transition('
|
|
14
|
-
automaton.add_transition('
|
|
15
|
-
automaton.add_transition('
|
|
16
|
-
automaton.add_transition('q1', 'q0', '1')
|
|
14
|
+
automaton.add_transition('待機中', 'お金投入済み', 'お金を投入する')
|
|
15
|
+
automaton.add_transition('お金投入済み', '商品選択済み', '商品のボタンを押す')
|
|
16
|
+
automaton.add_transition('商品選択済み', '待機中', '商品を排出する')
|
|
17
17
|
|
|
18
18
|
automaton.save_svg('automaton.svg')
|
|
19
19
|
puts 'Generated automaton.svg'
|
|
20
|
+
|
|
21
|
+
automaton.save_html('automaton.html')
|
|
22
|
+
puts 'Generated automaton.html (Mermaid.js - requires internet connection)'
|
|
23
|
+
|
|
24
|
+
automaton.save_dot('automaton.dot')
|
|
25
|
+
puts 'Generated automaton.dot (GraphViz format)'
|
|
26
|
+
puts 'To convert to PNG: dot -Tpng automaton.dot -o automaton.png'
|
|
27
|
+
|
|
28
|
+
automaton.save_plantuml('automaton.puml')
|
|
29
|
+
puts 'Generated automaton.puml (PlantUML format)'
|
|
30
|
+
puts 'To convert to PNG: Use PlantUML server or jar file'
|
data/sample/complex.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../lib/graphomaton'
|
|
4
4
|
|
|
5
|
-
|
|
6
5
|
complex = Graphomaton.new
|
|
7
6
|
|
|
8
7
|
complex.add_state('A')
|
|
@@ -22,3 +21,12 @@ complex.add_transition('B', 'B', 'loop')
|
|
|
22
21
|
|
|
23
22
|
complex.save_svg('complex_transitions.svg')
|
|
24
23
|
puts 'Generated complex_transitions.svg'
|
|
24
|
+
|
|
25
|
+
complex.save_html('complex_transitions.html')
|
|
26
|
+
puts 'Generated complex_transitions.html'
|
|
27
|
+
|
|
28
|
+
complex.save_dot('complex_transitions.dot')
|
|
29
|
+
puts 'Generated complex_transitions.dot'
|
|
30
|
+
|
|
31
|
+
complex.save_plantuml('complex_transitions.puml')
|
|
32
|
+
puts 'Generated complex_transitions.puml'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../lib/graphomaton'
|
|
4
|
+
|
|
5
|
+
automaton = Graphomaton.new
|
|
6
|
+
|
|
7
|
+
automaton.add_state('A')
|
|
8
|
+
automaton.add_state('Short')
|
|
9
|
+
automaton.add_state('Medium Name')
|
|
10
|
+
automaton.add_state('VeryLongStateName')
|
|
11
|
+
|
|
12
|
+
automaton.set_initial('A')
|
|
13
|
+
automaton.add_final('VeryLongStateName')
|
|
14
|
+
|
|
15
|
+
automaton.add_transition('A', 'Short', 'go')
|
|
16
|
+
automaton.add_transition('Short', 'Medium Name', 'next')
|
|
17
|
+
automaton.add_transition('Medium Name', 'VeryLongStateName', 'final')
|
|
18
|
+
|
|
19
|
+
automaton.save_svg('long_names.svg')
|
|
20
|
+
puts 'Generated long_names.svg'
|
data/sample/nfa.rb
CHANGED
|
@@ -17,3 +17,12 @@ nfa.add_transition('q1', 'q2', 'b')
|
|
|
17
17
|
|
|
18
18
|
nfa.save_svg('nfa_example.svg', 600, 400)
|
|
19
19
|
puts 'Generated nfa_example.svg'
|
|
20
|
+
|
|
21
|
+
nfa.save_html('nfa_example.html')
|
|
22
|
+
puts 'Generated nfa_example.html'
|
|
23
|
+
|
|
24
|
+
nfa.save_dot('nfa_example.dot')
|
|
25
|
+
puts 'Generated nfa_example.dot'
|
|
26
|
+
|
|
27
|
+
nfa.save_plantuml('nfa_example.puml')
|
|
28
|
+
puts 'Generated nfa_example.puml'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../lib/graphomaton'
|
|
4
|
+
|
|
5
|
+
automaton = Graphomaton.new
|
|
6
|
+
|
|
7
|
+
automaton.add_state('A')
|
|
8
|
+
automaton.add_state('B')
|
|
9
|
+
automaton.add_state('C')
|
|
10
|
+
automaton.add_state('D')
|
|
11
|
+
|
|
12
|
+
automaton.set_initial('A')
|
|
13
|
+
automaton.add_final('D')
|
|
14
|
+
|
|
15
|
+
automaton.add_transition('A', 'B', '1 step')
|
|
16
|
+
automaton.add_transition('A', 'C', 'skip 1 state')
|
|
17
|
+
automaton.add_transition('A', 'D', 'skip 2 states')
|
|
18
|
+
automaton.add_transition('B', 'C', '1 step')
|
|
19
|
+
automaton.add_transition('C', 'D', '1 step')
|
|
20
|
+
automaton.add_transition('D', 'A', 'back to start')
|
|
21
|
+
|
|
22
|
+
automaton.save_svg('skip_states.svg')
|
|
23
|
+
puts 'Generated skip_states.svg'
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'graphomaton'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Graphomaton::Exporters::Dot do
|
|
6
|
+
let(:automaton) { Graphomaton.new }
|
|
7
|
+
let(:dot_exporter) { described_class.new(automaton) }
|
|
8
|
+
|
|
9
|
+
describe '#export' do
|
|
10
|
+
context 'with empty automaton' do
|
|
11
|
+
it 'generates valid DOT syntax' do
|
|
12
|
+
dot_output = dot_exporter.export
|
|
13
|
+
expect(dot_output).to include('digraph')
|
|
14
|
+
expect(dot_output).to include('rankdir=LR')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'includes basic graph attributes' do
|
|
18
|
+
dot_output = dot_exporter.export
|
|
19
|
+
expect(dot_output).to match(/node\s+\[shape\s+=\s+circle\]/)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
context 'with states' do
|
|
24
|
+
before do
|
|
25
|
+
automaton.add_state('A')
|
|
26
|
+
automaton.add_state('B')
|
|
27
|
+
automaton.add_state('C')
|
|
28
|
+
automaton.add_transition('A', 'B', 'x')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'includes all states in transitions' do
|
|
32
|
+
dot_output = dot_exporter.export
|
|
33
|
+
expect(dot_output).to include('"A"')
|
|
34
|
+
expect(dot_output).to include('"B"')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'marks final states with double circle' do
|
|
38
|
+
automaton.add_final('C')
|
|
39
|
+
dot_output = dot_exporter.export
|
|
40
|
+
expect(dot_output).to match(/node\s+\[shape\s+=\s+doublecircle\];\s+"C"/)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
context 'with initial state' do
|
|
45
|
+
before do
|
|
46
|
+
automaton.add_state('Start')
|
|
47
|
+
automaton.set_initial('Start')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'creates invisible initial node' do
|
|
51
|
+
dot_output = dot_exporter.export
|
|
52
|
+
expect(dot_output).to include('__start__')
|
|
53
|
+
expect(dot_output).to match(/__start__\s+\[shape=point\]/)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'creates arrow from invisible node to initial state' do
|
|
57
|
+
dot_output = dot_exporter.export
|
|
58
|
+
expect(dot_output).to include('__start__ -> "Start"')
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
context 'with transitions' do
|
|
63
|
+
before do
|
|
64
|
+
automaton.add_state('A')
|
|
65
|
+
automaton.add_state('B')
|
|
66
|
+
automaton.add_state('C')
|
|
67
|
+
automaton.add_transition('A', 'B', 'input_a')
|
|
68
|
+
automaton.add_transition('B', 'C', 'input_b')
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'includes all transitions' do
|
|
72
|
+
dot_output = dot_exporter.export
|
|
73
|
+
expect(dot_output).to include('"A" -> "B"')
|
|
74
|
+
expect(dot_output).to include('"B" -> "C"')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'includes transition labels' do
|
|
78
|
+
dot_output = dot_exporter.export
|
|
79
|
+
expect(dot_output).to match(/"A"\s+->\s+"B"\s+\[label="input_a"\]/)
|
|
80
|
+
expect(dot_output).to match(/"B"\s+->\s+"C"\s+\[label="input_b"\]/)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'handles self-loops' do
|
|
84
|
+
automaton.add_transition('B', 'B', 'loop')
|
|
85
|
+
dot_output = dot_exporter.export
|
|
86
|
+
expect(dot_output).to match(/"B"\s+->\s+"B"\s+\[label="loop"\]/)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
context 'with special characters in labels' do
|
|
91
|
+
before do
|
|
92
|
+
automaton.add_state('State 1')
|
|
93
|
+
automaton.add_state('State-2')
|
|
94
|
+
automaton.add_transition('State 1', 'State-2', 'a/b')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'handles spaces in state names' do
|
|
98
|
+
dot_output = dot_exporter.export
|
|
99
|
+
expect(dot_output).to include('"State 1"')
|
|
100
|
+
expect(dot_output).to include('"State-2"')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'handles special characters in labels' do
|
|
104
|
+
dot_output = dot_exporter.export
|
|
105
|
+
expect(dot_output).to match(/label="a\/b"/)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
context 'with complete automaton' do
|
|
110
|
+
before do
|
|
111
|
+
automaton.add_state('q0')
|
|
112
|
+
automaton.add_state('q1')
|
|
113
|
+
automaton.add_state('q2')
|
|
114
|
+
automaton.set_initial('q0')
|
|
115
|
+
automaton.add_final('q2')
|
|
116
|
+
automaton.add_transition('q0', 'q1', 'a')
|
|
117
|
+
automaton.add_transition('q1', 'q2', 'b')
|
|
118
|
+
automaton.add_transition('q2', 'q0', 'c')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'generates complete valid DOT graph' do
|
|
122
|
+
dot_output = dot_exporter.export
|
|
123
|
+
|
|
124
|
+
# Should contain digraph wrapper
|
|
125
|
+
expect(dot_output).to start_with('digraph')
|
|
126
|
+
expect(dot_output).to end_with('}')
|
|
127
|
+
|
|
128
|
+
# Should contain all states in transitions
|
|
129
|
+
expect(dot_output).to include('"q0"')
|
|
130
|
+
expect(dot_output).to include('"q1"')
|
|
131
|
+
expect(dot_output).to include('"q2"')
|
|
132
|
+
|
|
133
|
+
# Should mark final state
|
|
134
|
+
expect(dot_output).to match(/node\s+\[shape\s+=\s+doublecircle\];\s+"q2"/)
|
|
135
|
+
|
|
136
|
+
# Should have initial arrow
|
|
137
|
+
expect(dot_output).to include('__start__ -> "q0"')
|
|
138
|
+
|
|
139
|
+
# Should have all transitions
|
|
140
|
+
expect(dot_output).to include('"q0" -> "q1"')
|
|
141
|
+
expect(dot_output).to include('"q1" -> "q2"')
|
|
142
|
+
expect(dot_output).to include('"q2" -> "q0"')
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'graphomaton'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Graphomaton::Exporters::Mermaid do
|
|
6
|
+
let(:automaton) { Graphomaton.new }
|
|
7
|
+
let(:mermaid_exporter) { described_class.new(automaton) }
|
|
8
|
+
|
|
9
|
+
describe '#export' do
|
|
10
|
+
context 'with empty automaton' do
|
|
11
|
+
it 'generates valid Mermaid syntax' do
|
|
12
|
+
mermaid_output = mermaid_exporter.export
|
|
13
|
+
expect(mermaid_output).to include('stateDiagram-v2')
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
context 'with states' do
|
|
18
|
+
before do
|
|
19
|
+
automaton.add_state('A')
|
|
20
|
+
automaton.add_state('B')
|
|
21
|
+
automaton.add_state('C')
|
|
22
|
+
automaton.add_transition('A', 'B', 'x')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'includes all states in transitions' do
|
|
26
|
+
mermaid_output = mermaid_exporter.export
|
|
27
|
+
expect(mermaid_output).to include('A')
|
|
28
|
+
expect(mermaid_output).to include('B')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'marks final states' do
|
|
32
|
+
automaton.add_final('C')
|
|
33
|
+
mermaid_output = mermaid_exporter.export
|
|
34
|
+
expect(mermaid_output).to match(/C\s+-->\s+\[\*\]/)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
context 'with initial state' do
|
|
39
|
+
before do
|
|
40
|
+
automaton.add_state('Start')
|
|
41
|
+
automaton.set_initial('Start')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'marks initial state with arrow from start' do
|
|
45
|
+
mermaid_output = mermaid_exporter.export
|
|
46
|
+
expect(mermaid_output).to include('[*] --> Start')
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
context 'with transitions' do
|
|
51
|
+
before do
|
|
52
|
+
automaton.add_state('A')
|
|
53
|
+
automaton.add_state('B')
|
|
54
|
+
automaton.add_state('C')
|
|
55
|
+
automaton.add_transition('A', 'B', 'input_a')
|
|
56
|
+
automaton.add_transition('B', 'C', 'input_b')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'includes all transitions with labels' do
|
|
60
|
+
mermaid_output = mermaid_exporter.export
|
|
61
|
+
expect(mermaid_output).to match(/A\s+-->\s+B\s*:\s*input_a/)
|
|
62
|
+
expect(mermaid_output).to match(/B\s+-->\s+C\s*:\s*input_b/)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'handles self-loops' do
|
|
66
|
+
automaton.add_transition('B', 'B', 'loop')
|
|
67
|
+
mermaid_output = mermaid_exporter.export
|
|
68
|
+
expect(mermaid_output).to match(/B\s+-->\s+B\s*:\s*loop/)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
context 'with special characters' do
|
|
73
|
+
before do
|
|
74
|
+
automaton.add_state('State 1')
|
|
75
|
+
automaton.add_state('State-2')
|
|
76
|
+
automaton.add_transition('State 1', 'State-2', 'a/b')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'handles spaces in state names' do
|
|
80
|
+
mermaid_output = mermaid_exporter.export
|
|
81
|
+
expect(mermaid_output).to include('State_1')
|
|
82
|
+
expect(mermaid_output).to include('State_2')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'handles special characters in labels' do
|
|
86
|
+
mermaid_output = mermaid_exporter.export
|
|
87
|
+
expect(mermaid_output).to include('a/b')
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
context 'with complete automaton' do
|
|
92
|
+
before do
|
|
93
|
+
automaton.add_state('q0')
|
|
94
|
+
automaton.add_state('q1')
|
|
95
|
+
automaton.add_state('q2')
|
|
96
|
+
automaton.set_initial('q0')
|
|
97
|
+
automaton.add_final('q2')
|
|
98
|
+
automaton.add_transition('q0', 'q1', 'a')
|
|
99
|
+
automaton.add_transition('q1', 'q2', 'b')
|
|
100
|
+
automaton.add_transition('q2', 'q0', 'c')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'generates complete valid Mermaid diagram' do
|
|
104
|
+
mermaid_output = mermaid_exporter.export
|
|
105
|
+
|
|
106
|
+
# Should start with stateDiagram-v2
|
|
107
|
+
expect(mermaid_output).to start_with('stateDiagram-v2')
|
|
108
|
+
|
|
109
|
+
# Should have initial state marker
|
|
110
|
+
expect(mermaid_output).to include('[*] --> q0')
|
|
111
|
+
|
|
112
|
+
# Should have final state marker
|
|
113
|
+
expect(mermaid_output).to match(/q2\s+-->\s+\[.*\*.*\]/)
|
|
114
|
+
|
|
115
|
+
# Should have all transitions
|
|
116
|
+
expect(mermaid_output).to match(/q0\s+-->\s+q1\s*:\s*a/)
|
|
117
|
+
expect(mermaid_output).to match(/q1\s+-->\s+q2\s*:\s*b/)
|
|
118
|
+
expect(mermaid_output).to match(/q2\s+-->\s+q0\s*:\s*c/)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
describe '#export_html' do
|
|
124
|
+
before do
|
|
125
|
+
automaton.add_state('A')
|
|
126
|
+
automaton.add_state('B')
|
|
127
|
+
automaton.add_transition('A', 'B', 'test')
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it 'generates valid HTML with Mermaid.js' do
|
|
131
|
+
html_output = mermaid_exporter.export_html
|
|
132
|
+
expect(html_output).to include('<!DOCTYPE html>')
|
|
133
|
+
expect(html_output).to include('<html')
|
|
134
|
+
expect(html_output).to include('</html>')
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'includes Mermaid.js CDN link' do
|
|
138
|
+
html_output = mermaid_exporter.export_html
|
|
139
|
+
expect(html_output).to include('mermaid')
|
|
140
|
+
expect(html_output).to include('cdn.jsdelivr.net')
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it 'includes the diagram code' do
|
|
144
|
+
html_output = mermaid_exporter.export_html
|
|
145
|
+
expect(html_output).to include('stateDiagram-v2')
|
|
146
|
+
expect(html_output).to include('A --> B : test')
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'includes mermaid initialization' do
|
|
150
|
+
html_output = mermaid_exporter.export_html
|
|
151
|
+
expect(html_output).to include('mermaid.initialize')
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|