graphomaton 0.1.1 → 1.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 +4 -4
- data/CHANGELOG.md +78 -3
- data/README.md +454 -27
- data/SECURITY.md +47 -0
- data/docs/architecture.md +30 -0
- data/docs/cli.md +27 -0
- data/docs/custom-exporters.md +36 -0
- data/docs/exporters.md +17 -0
- data/docs/input-schema.md +26 -0
- data/docs/migration-1.1.md +19 -0
- data/docs/performance.md +19 -0
- data/docs/releasing.md +19 -0
- data/exe/graphomaton +9 -0
- data/lib/graphomaton/atomic_file.rb +26 -0
- data/lib/graphomaton/cli/config.rb +102 -0
- data/lib/graphomaton/cli.rb +841 -0
- data/lib/graphomaton/errors.rb +11 -0
- data/lib/graphomaton/exporter_registry.rb +127 -0
- data/lib/graphomaton/exporters/dot.rb +284 -0
- data/lib/graphomaton/exporters/mermaid.rb +774 -0
- data/lib/graphomaton/exporters/pdf.rb +131 -0
- data/lib/graphomaton/exporters/plantuml.rb +284 -0
- data/lib/graphomaton/exporters/png.rb +172 -0
- data/lib/graphomaton/exporters/svg.rb +2872 -0
- data/lib/graphomaton/exporters/webp.rb +185 -0
- data/lib/graphomaton/exporters.rb +13 -0
- data/lib/graphomaton/identifier_allocator.rb +33 -0
- data/lib/graphomaton/input_policy.rb +82 -0
- data/lib/graphomaton/layout/force_tree.rb +127 -0
- data/lib/graphomaton/model.rb +218 -0
- data/lib/graphomaton/process_runner.rb +154 -0
- data/lib/graphomaton/url_policy.rb +40 -0
- data/lib/graphomaton/version.rb +1 -1
- data/lib/graphomaton.rb +2865 -240
- data/sig/graphomaton.rbs +127 -0
- metadata +39 -17
- data/.codespellignore +0 -0
- data/.rspec +0 -1
- data/CODE_OF_CONDUCT.md +0 -132
- data/Rakefile +0 -8
- data/sample/basic.rb +0 -19
- data/sample/complex.rb +0 -24
- data/sample/nfa.rb +0 -19
- data/spec/graphomaton_spec.rb +0 -371
- data/spec/spec_helper.rb +0 -13
data/spec/graphomaton_spec.rb
DELETED
|
@@ -1,371 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'graphomaton'
|
|
4
|
-
require 'fileutils'
|
|
5
|
-
|
|
6
|
-
RSpec.describe Graphomaton do
|
|
7
|
-
let(:automaton) { described_class.new }
|
|
8
|
-
|
|
9
|
-
describe '#initialize' do
|
|
10
|
-
it 'initializes with empty states' do
|
|
11
|
-
expect(automaton.states).to eq({})
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
it 'initializes with empty transitions' do
|
|
15
|
-
expect(automaton.transitions).to eq([])
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
it 'initializes with nil initial_state' do
|
|
19
|
-
expect(automaton.initial_state).to be_nil
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
it 'initializes with empty final_states' do
|
|
23
|
-
expect(automaton.final_states).to eq([])
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
describe '#add_state' do
|
|
28
|
-
context 'when adding a state without position' do
|
|
29
|
-
it 'adds a state with nil coordinates' do
|
|
30
|
-
state_name = automaton.add_state('q0')
|
|
31
|
-
expect(state_name).to eq('q0')
|
|
32
|
-
expect(automaton.states['q0']).to eq({ name: 'q0', x: nil, y: nil })
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
context 'when adding a state with position secretly' do
|
|
37
|
-
it 'adds a state with specified coordinates' do
|
|
38
|
-
automaton.add_state('q1', 100, 200)
|
|
39
|
-
expect(automaton.states['q1']).to eq({ name: 'q1', x: 100, y: 200 })
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
it 'allows multiple states to be added' do
|
|
44
|
-
automaton.add_state('q0')
|
|
45
|
-
automaton.add_state('q1')
|
|
46
|
-
automaton.add_state('q2')
|
|
47
|
-
expect(automaton.states.size).to eq(3)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
describe '#add_transition' do
|
|
52
|
-
before do
|
|
53
|
-
automaton.add_state('q0')
|
|
54
|
-
automaton.add_state('q1')
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
it 'adds a transition between states' do
|
|
58
|
-
automaton.add_transition('q0', 'q1', 'a')
|
|
59
|
-
expect(automaton.transitions).to include({ from: 'q0', to: 'q1', label: 'a' })
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
it 'allows multiple transitions with same label' do
|
|
63
|
-
automaton.add_transition('q0', 'q1', 'a')
|
|
64
|
-
automaton.add_transition('q0', 'q1', 'b')
|
|
65
|
-
expect(automaton.transitions.size).to eq(2)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
it 'allows self-loops' do
|
|
69
|
-
automaton.add_transition('q0', 'q0', 'loop')
|
|
70
|
-
expect(automaton.transitions).to include({ from: 'q0', to: 'q0', label: 'loop' })
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
describe '#set_initial' do
|
|
75
|
-
it 'sets the initial state' do
|
|
76
|
-
automaton.add_state('q0')
|
|
77
|
-
automaton.set_initial('q0')
|
|
78
|
-
expect(automaton.initial_state).to eq('q0')
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
it 'overwrites previous initial state' do
|
|
82
|
-
automaton.add_state('q0')
|
|
83
|
-
automaton.add_state('q1')
|
|
84
|
-
automaton.set_initial('q0')
|
|
85
|
-
automaton.set_initial('q1')
|
|
86
|
-
expect(automaton.initial_state).to eq('q1')
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
describe '#add_final' do
|
|
91
|
-
before do
|
|
92
|
-
automaton.add_state('q0')
|
|
93
|
-
automaton.add_state('q1')
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
it 'adds a state to final states' do
|
|
97
|
-
automaton.add_final('q0')
|
|
98
|
-
expect(automaton.final_states).to include('q0')
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
it 'allows multiple final states' do
|
|
102
|
-
automaton.add_final('q0')
|
|
103
|
-
automaton.add_final('q1')
|
|
104
|
-
expect(automaton.final_states).to contain_exactly('q0', 'q1')
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
it 'does not add duplicate final states' do
|
|
108
|
-
automaton.add_final('q0')
|
|
109
|
-
automaton.add_final('q0')
|
|
110
|
-
expect(automaton.final_states.count('q0')).to eq(1)
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
describe '#auto_layout' do
|
|
115
|
-
context 'with empty states' do
|
|
116
|
-
it 'does not raise error' do
|
|
117
|
-
expect { automaton.auto_layout }.not_to raise_error
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
context 'with states' do
|
|
122
|
-
before do
|
|
123
|
-
automaton.add_state('q0')
|
|
124
|
-
automaton.add_state('q1')
|
|
125
|
-
automaton.add_state('q2')
|
|
126
|
-
automaton.set_initial('q0')
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
it 'assigns positions to states without coordinates' do
|
|
130
|
-
automaton.auto_layout(800, 600)
|
|
131
|
-
expect(automaton.states['q0'][:x]).not_to be_nil
|
|
132
|
-
expect(automaton.states['q0'][:y]).not_to be_nil
|
|
133
|
-
expect(automaton.states['q1'][:x]).not_to be_nil
|
|
134
|
-
expect(automaton.states['q1'][:y]).not_to be_nil
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
it 'places initial state first' do
|
|
138
|
-
automaton.auto_layout(800, 600)
|
|
139
|
-
expect(automaton.states['q0'][:x]).to be < automaton.states['q1'][:x]
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
it 'preserves existing coordinates' do
|
|
143
|
-
automaton.add_state('q3', 500, 400)
|
|
144
|
-
automaton.auto_layout(800, 600)
|
|
145
|
-
expect(automaton.states['q3'][:x]).to eq(500)
|
|
146
|
-
expect(automaton.states['q3'][:y]).to eq(400)
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
it 'distributes states horizontally' do
|
|
150
|
-
automaton.auto_layout(800, 600)
|
|
151
|
-
y_values = automaton.states.values.map { |s| s[:y] }
|
|
152
|
-
expect(y_values.uniq.size).to eq(1) # All at same y-coordinate
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
describe '#count_parallel_transitions' do
|
|
158
|
-
before do
|
|
159
|
-
automaton.add_state('q0')
|
|
160
|
-
automaton.add_state('q1')
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
it 'counts transitions in both directions' do
|
|
164
|
-
automaton.add_transition('q0', 'q1', 'a')
|
|
165
|
-
automaton.add_transition('q1', 'q0', 'b')
|
|
166
|
-
expect(automaton.count_parallel_transitions('q0', 'q1')).to eq(2)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
it 'counts multiple transitions in same direction' do
|
|
170
|
-
automaton.add_transition('q0', 'q1', 'a')
|
|
171
|
-
automaton.add_transition('q0', 'q1', 'b')
|
|
172
|
-
expect(automaton.count_parallel_transitions('q0', 'q1')).to eq(2)
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
it 'returns 0 for non-existent transitions' do
|
|
176
|
-
expect(automaton.count_parallel_transitions('q0', 'q1')).to eq(0)
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
describe '#get_transition_index' do
|
|
181
|
-
before do
|
|
182
|
-
automaton.add_state('q0')
|
|
183
|
-
automaton.add_state('q1')
|
|
184
|
-
automaton.add_transition('q0', 'q1', 'a')
|
|
185
|
-
automaton.add_transition('q0', 'q1', 'b')
|
|
186
|
-
automaton.add_transition('q1', 'q0', 'c')
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
it 'returns correct index for transition' do
|
|
190
|
-
expect(automaton.get_transition_index('q0', 'q1', 'a')).to eq(0)
|
|
191
|
-
expect(automaton.get_transition_index('q0', 'q1', 'b')).to eq(1)
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
it 'counts bidirectional transitions correctly' do
|
|
195
|
-
expect(automaton.get_transition_index('q1', 'q0', 'c')).to eq(2)
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
describe '#to_svg' do
|
|
200
|
-
before do
|
|
201
|
-
automaton.add_state('q0')
|
|
202
|
-
automaton.add_state('q1')
|
|
203
|
-
automaton.add_state('q2')
|
|
204
|
-
automaton.set_initial('q0')
|
|
205
|
-
automaton.add_final('q2')
|
|
206
|
-
automaton.add_transition('q0', 'q1', 'a')
|
|
207
|
-
automaton.add_transition('q1', 'q2', 'b')
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
it 'generates valid SVG' do
|
|
211
|
-
svg_output = automaton.to_svg
|
|
212
|
-
expect { REXML::Document.new(svg_output) }.not_to raise_error
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
it 'includes SVG root element with correct attributes' do
|
|
216
|
-
svg_output = automaton.to_svg(1000, 800)
|
|
217
|
-
doc = REXML::Document.new(svg_output)
|
|
218
|
-
svg = doc.root
|
|
219
|
-
expect(svg.name).to eq('svg')
|
|
220
|
-
expect(svg.attributes['width']).to eq('1000')
|
|
221
|
-
expect(svg.attributes['height']).to eq('800')
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
it 'includes marker definition for arrowheads' do
|
|
225
|
-
svg_output = automaton.to_svg
|
|
226
|
-
doc = REXML::Document.new(svg_output)
|
|
227
|
-
marker = REXML::XPath.first(doc, '//marker[@id="arrowhead"]')
|
|
228
|
-
expect(marker).not_to be_nil
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
it 'includes styles' do
|
|
232
|
-
svg_output = automaton.to_svg
|
|
233
|
-
doc = REXML::Document.new(svg_output)
|
|
234
|
-
style = REXML::XPath.first(doc, '//style')
|
|
235
|
-
expect(style).not_to be_nil
|
|
236
|
-
expect(style.text).to include('state-circle')
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
it 'creates circles for each state' do
|
|
240
|
-
svg_output = automaton.to_svg
|
|
241
|
-
doc = REXML::Document.new(svg_output)
|
|
242
|
-
circles = REXML::XPath.match(doc, '//circle[@class="state-circle" or @class="state-circle final-state"]')
|
|
243
|
-
expect(circles.size).to be >= 3
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
it 'creates double circle for final states' do
|
|
247
|
-
svg_output = automaton.to_svg
|
|
248
|
-
doc = REXML::Document.new(svg_output)
|
|
249
|
-
# Final state should have an outer circle with 'final-state' class
|
|
250
|
-
final_circles = REXML::XPath.match(doc, '//circle[contains(@class, "final-state")]')
|
|
251
|
-
expect(final_circles).not_to be_empty
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
it 'creates initial arrow' do
|
|
255
|
-
svg_output = automaton.to_svg
|
|
256
|
-
doc = REXML::Document.new(svg_output)
|
|
257
|
-
initial_arrow = REXML::XPath.first(doc, '//line[@class="initial-arrow"]')
|
|
258
|
-
expect(initial_arrow).not_to be_nil
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
it 'creates paths for transitions' do
|
|
262
|
-
svg_output = automaton.to_svg
|
|
263
|
-
doc = REXML::Document.new(svg_output)
|
|
264
|
-
paths = REXML::XPath.match(doc, '//path[@class="transition-line"]')
|
|
265
|
-
expect(paths.size).to eq(2) # Two transitions
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
it 'creates text labels for transitions' do
|
|
269
|
-
svg_output = automaton.to_svg
|
|
270
|
-
doc = REXML::Document.new(svg_output)
|
|
271
|
-
labels = REXML::XPath.match(doc, '//text[@class="transition-label"]')
|
|
272
|
-
expect(labels.map(&:text)).to include('a', 'b')
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
context 'with self-loop' do
|
|
276
|
-
before do
|
|
277
|
-
automaton.add_transition('q1', 'q1', 'loop')
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
it 'creates curved path for self-loop' do
|
|
281
|
-
svg_output = automaton.to_svg
|
|
282
|
-
doc = REXML::Document.new(svg_output)
|
|
283
|
-
paths = REXML::XPath.match(doc, '//path[@class="transition-line"]')
|
|
284
|
-
# Should have one path with C command (cubic bezier) for self-loop
|
|
285
|
-
self_loop_paths = paths.select { |p| p.attributes['d'].include?('C') }
|
|
286
|
-
expect(self_loop_paths).not_to be_empty
|
|
287
|
-
end
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
context 'with parallel transitions' do
|
|
291
|
-
before do
|
|
292
|
-
automaton.add_transition('q1', 'q0', 'c')
|
|
293
|
-
automaton.add_transition('q0', 'q1', 'd')
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
it 'creates curved paths for parallel transitions' do
|
|
297
|
-
svg_output = automaton.to_svg
|
|
298
|
-
doc = REXML::Document.new(svg_output)
|
|
299
|
-
paths = REXML::XPath.match(doc, '//path[@class="transition-line"]')
|
|
300
|
-
# Should have paths with Q command (quadratic bezier) for curved transitions
|
|
301
|
-
curved_paths = paths.select { |p| p.attributes['d'].include?('Q') }
|
|
302
|
-
expect(curved_paths.size).to be >= 2
|
|
303
|
-
end
|
|
304
|
-
end
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
describe '#save_svg' do
|
|
308
|
-
let(:temp_file) { 'test_output.svg' }
|
|
309
|
-
|
|
310
|
-
after do
|
|
311
|
-
FileUtils.rm_f(temp_file)
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
before do
|
|
315
|
-
automaton.add_state('q0')
|
|
316
|
-
automaton.add_state('q1')
|
|
317
|
-
automaton.add_transition('q0', 'q1', 'a')
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
it 'saves SVG to file' do
|
|
321
|
-
automaton.save_svg(temp_file)
|
|
322
|
-
expect(File.exist?(temp_file)).to be true
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
it 'saves valid SVG content' do
|
|
326
|
-
automaton.save_svg(temp_file)
|
|
327
|
-
content = File.read(temp_file)
|
|
328
|
-
expect { REXML::Document.new(content) }.not_to raise_error
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
it 'respects custom dimensions' do
|
|
332
|
-
automaton.save_svg(temp_file, 1200, 900)
|
|
333
|
-
content = File.read(temp_file)
|
|
334
|
-
doc = REXML::Document.new(content)
|
|
335
|
-
svg = doc.root
|
|
336
|
-
expect(svg.attributes['width']).to eq('1200')
|
|
337
|
-
expect(svg.attributes['height']).to eq('900')
|
|
338
|
-
end
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
describe 'complex automaton example' do
|
|
342
|
-
it 'handles a complete DFA correctly' do
|
|
343
|
-
# Create a DFA that accepts strings ending with 'ab'
|
|
344
|
-
automaton.add_state('q0')
|
|
345
|
-
automaton.add_state('q1')
|
|
346
|
-
automaton.add_state('q2')
|
|
347
|
-
|
|
348
|
-
automaton.set_initial('q0')
|
|
349
|
-
automaton.add_final('q2')
|
|
350
|
-
|
|
351
|
-
automaton.add_transition('q0', 'q0', 'b')
|
|
352
|
-
automaton.add_transition('q0', 'q1', 'a')
|
|
353
|
-
automaton.add_transition('q1', 'q0', 'a')
|
|
354
|
-
automaton.add_transition('q1', 'q2', 'b')
|
|
355
|
-
automaton.add_transition('q2', 'q1', 'a')
|
|
356
|
-
automaton.add_transition('q2', 'q0', 'b')
|
|
357
|
-
|
|
358
|
-
svg_output = automaton.to_svg
|
|
359
|
-
doc = REXML::Document.new(svg_output)
|
|
360
|
-
|
|
361
|
-
# Verify all transitions are rendered
|
|
362
|
-
paths = REXML::XPath.match(doc, '//path[@class="transition-line"]')
|
|
363
|
-
expect(paths.size).to eq(6)
|
|
364
|
-
|
|
365
|
-
# Verify all labels are present
|
|
366
|
-
labels = REXML::XPath.match(doc, '//text[@class="transition-label"]')
|
|
367
|
-
label_texts = labels.map(&:text)
|
|
368
|
-
expect(label_texts).to include('a', 'b')
|
|
369
|
-
end
|
|
370
|
-
end
|
|
371
|
-
end
|
data/spec/spec_helper.rb
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
RSpec.configure do |config|
|
|
4
|
-
config.expect_with :rspec do |expectations|
|
|
5
|
-
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
|
6
|
-
end
|
|
7
|
-
|
|
8
|
-
config.mock_with :rspec do |mocks|
|
|
9
|
-
mocks.verify_partial_doubles = true
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
config.shared_context_metadata_behavior = :apply_to_host_groups
|
|
13
|
-
end
|