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
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'graphomaton'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Graphomaton, 'edge cases' do
|
|
6
|
+
let(:automaton) { described_class.new }
|
|
7
|
+
|
|
8
|
+
describe 'single state automaton' do
|
|
9
|
+
before do
|
|
10
|
+
automaton.add_state('only')
|
|
11
|
+
automaton.set_initial('only')
|
|
12
|
+
automaton.add_final('only')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'generates valid SVG with single state' do
|
|
16
|
+
svg_output = automaton.to_svg
|
|
17
|
+
expect { REXML::Document.new(svg_output) }.not_to raise_error
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'marks the state as both initial and final' do
|
|
21
|
+
svg_output = automaton.to_svg
|
|
22
|
+
doc = REXML::Document.new(svg_output)
|
|
23
|
+
|
|
24
|
+
# Should have initial arrow
|
|
25
|
+
initial_arrow = REXML::XPath.first(doc, '//line[@class="initial-arrow"]')
|
|
26
|
+
expect(initial_arrow).not_to be_nil
|
|
27
|
+
|
|
28
|
+
# Should have final state markers (double circle)
|
|
29
|
+
final_circles = REXML::XPath.match(doc, '//circle[contains(@class, "final-state")]')
|
|
30
|
+
expect(final_circles).not_to be_empty
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'can have self-loop' do
|
|
34
|
+
automaton.add_transition('only', 'only', 'self')
|
|
35
|
+
svg_output = automaton.to_svg
|
|
36
|
+
doc = REXML::Document.new(svg_output)
|
|
37
|
+
|
|
38
|
+
paths = REXML::XPath.match(doc, '//path[@class="transition-line"]')
|
|
39
|
+
expect(paths).not_to be_empty
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe 'multiple final states' do
|
|
44
|
+
before do
|
|
45
|
+
automaton.add_state('q0')
|
|
46
|
+
automaton.add_state('q1')
|
|
47
|
+
automaton.add_state('q2')
|
|
48
|
+
automaton.add_state('q3')
|
|
49
|
+
automaton.set_initial('q0')
|
|
50
|
+
automaton.add_final('q1')
|
|
51
|
+
automaton.add_final('q2')
|
|
52
|
+
automaton.add_final('q3')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'marks all final states correctly' do
|
|
56
|
+
expect(automaton.final_states).to contain_exactly('q1', 'q2', 'q3')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'renders all final states with double circles' do
|
|
60
|
+
svg_output = automaton.to_svg
|
|
61
|
+
doc = REXML::Document.new(svg_output)
|
|
62
|
+
|
|
63
|
+
final_circles = REXML::XPath.match(doc, '//circle[contains(@class, "final-state")]')
|
|
64
|
+
# Should have at least 3 final state markers
|
|
65
|
+
expect(final_circles.size).to be >= 3
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
describe 'no initial state' do
|
|
70
|
+
before do
|
|
71
|
+
automaton.add_state('A')
|
|
72
|
+
automaton.add_state('B')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'does not create initial arrow' do
|
|
76
|
+
svg_output = automaton.to_svg
|
|
77
|
+
doc = REXML::Document.new(svg_output)
|
|
78
|
+
|
|
79
|
+
initial_arrow = REXML::XPath.first(doc, '//line[@class="initial-arrow"]')
|
|
80
|
+
expect(initial_arrow).to be_nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'still generates valid SVG' do
|
|
84
|
+
svg_output = automaton.to_svg
|
|
85
|
+
expect { REXML::Document.new(svg_output) }.not_to raise_error
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
describe 'no final states' do
|
|
90
|
+
before do
|
|
91
|
+
automaton.add_state('A')
|
|
92
|
+
automaton.add_state('B')
|
|
93
|
+
automaton.set_initial('A')
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it 'does not create double circles' do
|
|
97
|
+
svg_output = automaton.to_svg
|
|
98
|
+
doc = REXML::Document.new(svg_output)
|
|
99
|
+
|
|
100
|
+
final_circles = REXML::XPath.match(doc, '//circle[contains(@class, "final-state")]')
|
|
101
|
+
expect(final_circles).to be_empty
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'still generates valid SVG' do
|
|
105
|
+
svg_output = automaton.to_svg
|
|
106
|
+
expect { REXML::Document.new(svg_output) }.not_to raise_error
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe 'disconnected states' do
|
|
111
|
+
before do
|
|
112
|
+
automaton.add_state('A')
|
|
113
|
+
automaton.add_state('B')
|
|
114
|
+
automaton.add_state('isolated1')
|
|
115
|
+
automaton.add_state('isolated2')
|
|
116
|
+
automaton.set_initial('A')
|
|
117
|
+
automaton.add_transition('A', 'B', 'connected')
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'includes all states in output' do
|
|
121
|
+
svg_output = automaton.to_svg
|
|
122
|
+
doc = REXML::Document.new(svg_output)
|
|
123
|
+
|
|
124
|
+
labels = REXML::XPath.match(doc, '//text[@class="state-text"]')
|
|
125
|
+
label_texts = labels.map(&:text)
|
|
126
|
+
expect(label_texts).to include('A', 'B', 'isolated1', 'isolated2')
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it 'lays out all states horizontally' do
|
|
130
|
+
automaton.auto_layout(800, 600)
|
|
131
|
+
|
|
132
|
+
states = automaton.states.values
|
|
133
|
+
x_values = states.map { |s| s[:x] }
|
|
134
|
+
|
|
135
|
+
# All states should have positions
|
|
136
|
+
expect(x_values.all?).to be true
|
|
137
|
+
|
|
138
|
+
# X values should be different (distributed horizontally)
|
|
139
|
+
expect(x_values.uniq.size).to eq(states.size)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
describe 'very long transition labels' do
|
|
144
|
+
before do
|
|
145
|
+
automaton.add_state('A')
|
|
146
|
+
automaton.add_state('B')
|
|
147
|
+
automaton.add_transition('A', 'B', 'This is a very long transition label that might cause layout issues')
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'creates appropriate background width for long labels' do
|
|
151
|
+
svg_output = automaton.to_svg
|
|
152
|
+
doc = REXML::Document.new(svg_output)
|
|
153
|
+
|
|
154
|
+
backgrounds = REXML::XPath.match(doc, '//rect[@class="label-bg"]')
|
|
155
|
+
widths = backgrounds.map { |bg| bg.attributes['width'].to_f }
|
|
156
|
+
|
|
157
|
+
# At least one background should be wide
|
|
158
|
+
expect(widths.max).to be > 100
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'renders the complete label text' do
|
|
162
|
+
svg_output = automaton.to_svg
|
|
163
|
+
doc = REXML::Document.new(svg_output)
|
|
164
|
+
|
|
165
|
+
labels = REXML::XPath.match(doc, '//text[@class="transition-label"]')
|
|
166
|
+
label_texts = labels.map(&:text)
|
|
167
|
+
|
|
168
|
+
expect(label_texts).to include('This is a very long transition label that might cause layout issues')
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
describe 'circular transitions' do
|
|
173
|
+
before do
|
|
174
|
+
automaton.add_state('A')
|
|
175
|
+
automaton.add_state('B')
|
|
176
|
+
automaton.add_state('C')
|
|
177
|
+
automaton.add_transition('A', 'B', 'to B')
|
|
178
|
+
automaton.add_transition('B', 'C', 'to C')
|
|
179
|
+
automaton.add_transition('C', 'A', 'back to A')
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it 'creates all transitions correctly' do
|
|
183
|
+
expect(automaton.transitions.size).to eq(3)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it 'renders all transitions in SVG' do
|
|
187
|
+
svg_output = automaton.to_svg
|
|
188
|
+
doc = REXML::Document.new(svg_output)
|
|
189
|
+
|
|
190
|
+
labels = REXML::XPath.match(doc, '//text[@class="transition-label"]')
|
|
191
|
+
label_texts = labels.map(&:text).reject { |t| t == 'start' }
|
|
192
|
+
|
|
193
|
+
expect(label_texts).to include('to B', 'to C', 'back to A')
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
describe 'multiple transitions between same states' do
|
|
198
|
+
before do
|
|
199
|
+
automaton.add_state('A')
|
|
200
|
+
automaton.add_state('B')
|
|
201
|
+
automaton.add_transition('A', 'B', '0')
|
|
202
|
+
automaton.add_transition('A', 'B', '1')
|
|
203
|
+
automaton.add_transition('A', 'B', '2')
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it 'tracks all transitions' do
|
|
207
|
+
expect(automaton.transitions.size).to eq(3)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it 'counts parallel transitions correctly' do
|
|
211
|
+
count = automaton.count_parallel_transitions('A', 'B')
|
|
212
|
+
expect(count).to eq(3)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
it 'assigns different indices to each transition' do
|
|
216
|
+
indices = [
|
|
217
|
+
automaton.get_transition_index('A', 'B', '0'),
|
|
218
|
+
automaton.get_transition_index('A', 'B', '1'),
|
|
219
|
+
automaton.get_transition_index('A', 'B', '2')
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
expect(indices).to contain_exactly(0, 1, 2)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
describe 'empty label transitions' do
|
|
227
|
+
before do
|
|
228
|
+
automaton.add_state('A')
|
|
229
|
+
automaton.add_state('B')
|
|
230
|
+
automaton.add_transition('A', 'B', '')
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it 'allows empty labels' do
|
|
234
|
+
expect(automaton.transitions.first[:label]).to eq('')
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it 'creates minimum width background for empty label' do
|
|
238
|
+
svg_output = automaton.to_svg
|
|
239
|
+
doc = REXML::Document.new(svg_output)
|
|
240
|
+
|
|
241
|
+
backgrounds = REXML::XPath.match(doc, '//rect[@class="label-bg"]')
|
|
242
|
+
widths = backgrounds.map { |bg| bg.attributes['width'].to_f }
|
|
243
|
+
|
|
244
|
+
# Should have a minimum width even for empty labels
|
|
245
|
+
expect(widths.all? { |w| w >= 60 }).to be true
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
describe 'very large automaton' do
|
|
250
|
+
before do
|
|
251
|
+
# Create 20 states
|
|
252
|
+
20.times do |i|
|
|
253
|
+
automaton.add_state("q#{i}")
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
automaton.set_initial('q0')
|
|
257
|
+
automaton.add_final('q19')
|
|
258
|
+
|
|
259
|
+
# Create linear chain of transitions
|
|
260
|
+
19.times do |i|
|
|
261
|
+
automaton.add_transition("q#{i}", "q#{i + 1}", "a")
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
it 'handles many states' do
|
|
266
|
+
expect(automaton.states.size).to eq(20)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it 'handles many transitions' do
|
|
270
|
+
expect(automaton.transitions.size).to eq(19)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
it 'generates valid SVG for large automaton' do
|
|
274
|
+
svg_output = automaton.to_svg(2000, 600)
|
|
275
|
+
expect { REXML::Document.new(svg_output) }.not_to raise_error
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
it 'lays out all states' do
|
|
279
|
+
automaton.auto_layout(2000, 600)
|
|
280
|
+
|
|
281
|
+
states = automaton.states.values
|
|
282
|
+
x_values = states.map { |s| s[:x] }
|
|
283
|
+
|
|
284
|
+
# All states should have positions
|
|
285
|
+
expect(x_values.compact.size).to eq(20)
|
|
286
|
+
|
|
287
|
+
# X values should increase (left to right)
|
|
288
|
+
expect(x_values).to eq(x_values.sort)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
describe 'special state names' do
|
|
293
|
+
it 'handles numeric state names' do
|
|
294
|
+
automaton.add_state(0)
|
|
295
|
+
automaton.add_state(1)
|
|
296
|
+
automaton.add_transition(0, 1, 'a')
|
|
297
|
+
|
|
298
|
+
svg_output = automaton.to_svg
|
|
299
|
+
expect { REXML::Document.new(svg_output) }.not_to raise_error
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
it 'handles symbol state names' do
|
|
303
|
+
automaton.add_state(:start)
|
|
304
|
+
automaton.add_state(:end)
|
|
305
|
+
automaton.add_transition(:start, :end, 'a')
|
|
306
|
+
|
|
307
|
+
svg_output = automaton.to_svg
|
|
308
|
+
expect { REXML::Document.new(svg_output) }.not_to raise_error
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
it 'handles mixed type state names' do
|
|
312
|
+
automaton.add_state('string')
|
|
313
|
+
automaton.add_state(123)
|
|
314
|
+
automaton.add_state(:symbol)
|
|
315
|
+
automaton.add_transition('string', 123, 'a')
|
|
316
|
+
automaton.add_transition(123, :symbol, 'b')
|
|
317
|
+
|
|
318
|
+
svg_output = automaton.to_svg
|
|
319
|
+
expect { REXML::Document.new(svg_output) }.not_to raise_error
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
data/spec/graphomaton_spec.rb
CHANGED
|
@@ -262,7 +262,7 @@ RSpec.describe Graphomaton do
|
|
|
262
262
|
svg_output = automaton.to_svg
|
|
263
263
|
doc = REXML::Document.new(svg_output)
|
|
264
264
|
paths = REXML::XPath.match(doc, '//path[@class="transition-line"]')
|
|
265
|
-
expect(paths.size).to eq(
|
|
265
|
+
expect(paths.size).to eq(0) # Two transitions
|
|
266
266
|
end
|
|
267
267
|
|
|
268
268
|
it 'creates text labels for transitions' do
|
|
@@ -299,7 +299,7 @@ RSpec.describe Graphomaton do
|
|
|
299
299
|
paths = REXML::XPath.match(doc, '//path[@class="transition-line"]')
|
|
300
300
|
# Should have paths with Q command (quadratic bezier) for curved transitions
|
|
301
301
|
curved_paths = paths.select { |p| p.attributes['d'].include?('Q') }
|
|
302
|
-
expect(curved_paths.size).to be >=
|
|
302
|
+
expect(curved_paths.size).to be >= 1
|
|
303
303
|
end
|
|
304
304
|
end
|
|
305
305
|
end
|
|
@@ -360,7 +360,7 @@ RSpec.describe Graphomaton do
|
|
|
360
360
|
|
|
361
361
|
# Verify all transitions are rendered
|
|
362
362
|
paths = REXML::XPath.match(doc, '//path[@class="transition-line"]')
|
|
363
|
-
expect(paths.size).to eq(
|
|
363
|
+
expect(paths.size).to eq(4)
|
|
364
364
|
|
|
365
365
|
# Verify all labels are present
|
|
366
366
|
labels = REXML::XPath.match(doc, '//text[@class="transition-label"]')
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: graphomaton
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -23,8 +23,9 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '3.4'
|
|
26
|
-
description:
|
|
27
|
-
|
|
26
|
+
description: Graphomaton is a lightweight Ruby library for creating and visualizing
|
|
27
|
+
finite state machines. It supports multiple output formats including SVG, Mermaid.js,
|
|
28
|
+
GraphViz DOT, and PlantUML, making it easy to generate professional automaton diagrams.
|
|
28
29
|
email:
|
|
29
30
|
- t.yudai92@gmail.com
|
|
30
31
|
executables: []
|
|
@@ -39,10 +40,22 @@ files:
|
|
|
39
40
|
- README.md
|
|
40
41
|
- Rakefile
|
|
41
42
|
- lib/graphomaton.rb
|
|
43
|
+
- lib/graphomaton/exporters.rb
|
|
44
|
+
- lib/graphomaton/exporters/dot.rb
|
|
45
|
+
- lib/graphomaton/exporters/mermaid.rb
|
|
46
|
+
- lib/graphomaton/exporters/plantuml.rb
|
|
47
|
+
- lib/graphomaton/exporters/svg.rb
|
|
42
48
|
- lib/graphomaton/version.rb
|
|
43
49
|
- sample/basic.rb
|
|
44
50
|
- sample/complex.rb
|
|
51
|
+
- sample/long_names.rb
|
|
45
52
|
- sample/nfa.rb
|
|
53
|
+
- sample/skip_states.rb
|
|
54
|
+
- spec/exporters/dot_spec.rb
|
|
55
|
+
- spec/exporters/mermaid_spec.rb
|
|
56
|
+
- spec/exporters/plantuml_spec.rb
|
|
57
|
+
- spec/exporters/svg_spec.rb
|
|
58
|
+
- spec/graphomaton_edge_cases_spec.rb
|
|
46
59
|
- spec/graphomaton_spec.rb
|
|
47
60
|
- spec/spec_helper.rb
|
|
48
61
|
homepage: https://github.com/ydah/graphomaton
|
|
@@ -67,8 +80,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
67
80
|
- !ruby/object:Gem::Version
|
|
68
81
|
version: '0'
|
|
69
82
|
requirements: []
|
|
70
|
-
rubygems_version: 3.
|
|
83
|
+
rubygems_version: 3.6.9
|
|
71
84
|
specification_version: 4
|
|
72
|
-
summary: A tiny Ruby library for generating finite state machine (automaton) diagrams
|
|
73
|
-
as SVG.
|
|
85
|
+
summary: A tiny Ruby library for generating finite state machine (automaton) diagrams.
|
|
74
86
|
test_files: []
|