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.
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'graphomaton'
4
+
5
+ RSpec.describe Graphomaton::Exporters::Plantuml do
6
+ let(:automaton) { Graphomaton.new }
7
+ let(:plantuml_exporter) { described_class.new(automaton) }
8
+
9
+ describe '#export' do
10
+ context 'with empty automaton' do
11
+ it 'generates valid PlantUML syntax' do
12
+ plantuml_output = plantuml_exporter.export
13
+ expect(plantuml_output).to start_with('@startuml')
14
+ expect(plantuml_output).to end_with('@enduml')
15
+ end
16
+ end
17
+
18
+ context 'with states' do
19
+ before do
20
+ automaton.add_state('A')
21
+ automaton.add_state('B')
22
+ automaton.add_state('C')
23
+ automaton.add_transition('A', 'B', 'x')
24
+ end
25
+
26
+ it 'includes states in transitions' do
27
+ plantuml_output = plantuml_exporter.export
28
+ expect(plantuml_output).to include('A')
29
+ expect(plantuml_output).to include('B')
30
+ end
31
+
32
+ it 'marks final states' do
33
+ automaton.add_final('C')
34
+ plantuml_output = plantuml_exporter.export
35
+ expect(plantuml_output).to match(/C\s+-->\s+\[\*\]/)
36
+ end
37
+ end
38
+
39
+ context 'with initial state' do
40
+ before do
41
+ automaton.add_state('Start')
42
+ automaton.set_initial('Start')
43
+ end
44
+
45
+ it 'marks initial state with arrow from start' do
46
+ plantuml_output = plantuml_exporter.export
47
+ expect(plantuml_output).to include('[*] --> Start')
48
+ end
49
+ end
50
+
51
+ context 'with transitions' do
52
+ before do
53
+ automaton.add_state('A')
54
+ automaton.add_state('B')
55
+ automaton.add_state('C')
56
+ automaton.add_transition('A', 'B', 'input_a')
57
+ automaton.add_transition('B', 'C', 'input_b')
58
+ end
59
+
60
+ it 'includes all transitions with labels' do
61
+ plantuml_output = plantuml_exporter.export
62
+ expect(plantuml_output).to match(/A\s+-->\s+B\s*:\s*input_a/)
63
+ expect(plantuml_output).to match(/B\s+-->\s+C\s*:\s*input_b/)
64
+ end
65
+
66
+ it 'handles self-loops' do
67
+ automaton.add_transition('B', 'B', 'loop')
68
+ plantuml_output = plantuml_exporter.export
69
+ expect(plantuml_output).to match(/B\s+-->\s+B\s*:\s*loop/)
70
+ end
71
+ end
72
+
73
+ context 'with special characters' do
74
+ before do
75
+ automaton.add_state('State 1')
76
+ automaton.add_state('State-2')
77
+ automaton.add_transition('State 1', 'State-2', 'a/b')
78
+ end
79
+
80
+ it 'handles spaces in state names' do
81
+ plantuml_output = plantuml_exporter.export
82
+ expect(plantuml_output).to include('State_1')
83
+ expect(plantuml_output).to include('State_2')
84
+ end
85
+
86
+ it 'handles special characters in labels' do
87
+ plantuml_output = plantuml_exporter.export
88
+ expect(plantuml_output).to include('a/b')
89
+ end
90
+ end
91
+
92
+ context 'with complete automaton' do
93
+ before do
94
+ automaton.add_state('q0')
95
+ automaton.add_state('q1')
96
+ automaton.add_state('q2')
97
+ automaton.set_initial('q0')
98
+ automaton.add_final('q2')
99
+ automaton.add_transition('q0', 'q1', 'a')
100
+ automaton.add_transition('q1', 'q2', 'b')
101
+ automaton.add_transition('q2', 'q0', 'c')
102
+ end
103
+
104
+ it 'generates complete valid PlantUML diagram' do
105
+ plantuml_output = plantuml_exporter.export
106
+
107
+ # Should be wrapped in @startuml/@enduml
108
+ expect(plantuml_output).to start_with('@startuml')
109
+ expect(plantuml_output).to end_with('@enduml')
110
+
111
+ # Should have initial state marker
112
+ expect(plantuml_output).to include('[*] --> q0')
113
+
114
+ # Should have final state marker
115
+ expect(plantuml_output).to match(/q2\s+-->\s+\[\*\]/)
116
+
117
+ # Should have states in transitions
118
+ expect(plantuml_output).to include('q0')
119
+ expect(plantuml_output).to include('q1')
120
+ expect(plantuml_output).to include('q2')
121
+
122
+ # Should have all transitions
123
+ expect(plantuml_output).to match(/q0\s+-->\s+q1\s*:\s*a/)
124
+ expect(plantuml_output).to match(/q1\s+-->\s+q2\s*:\s*b/)
125
+ expect(plantuml_output).to match(/q2\s+-->\s+q0\s*:\s*c/)
126
+ end
127
+ end
128
+
129
+ context 'with non-ASCII characters' do
130
+ before do
131
+ automaton.add_state('状態A')
132
+ automaton.add_state('状態B')
133
+ automaton.add_transition('状態A', '状態B', '遷移')
134
+ end
135
+
136
+ it 'handles Japanese characters correctly' do
137
+ plantuml_output = plantuml_exporter.export
138
+ expect(plantuml_output).to include('状態A')
139
+ expect(plantuml_output).to include('状態B')
140
+ expect(plantuml_output).to include('遷移')
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'graphomaton'
4
+
5
+ RSpec.describe Graphomaton::Exporters::Svg do
6
+ let(:automaton) { Graphomaton.new }
7
+ let(:svg_exporter) { described_class.new(automaton) }
8
+
9
+ describe '#initialize' do
10
+ it 'initializes with an automaton' do
11
+ expect(svg_exporter).to be_a(described_class)
12
+ end
13
+ end
14
+
15
+ describe '#export' do
16
+ context 'with empty automaton' do
17
+ it 'generates valid SVG' do
18
+ svg_output = svg_exporter.export
19
+ expect { REXML::Document.new(svg_output) }.not_to raise_error
20
+ end
21
+
22
+ it 'uses default dimensions' do
23
+ svg_output = svg_exporter.export
24
+ doc = REXML::Document.new(svg_output)
25
+ svg = doc.root
26
+ expect(svg.attributes['width']).to eq('800')
27
+ expect(svg.attributes['height']).to eq('600')
28
+ end
29
+
30
+ it 'accepts custom dimensions' do
31
+ svg_output = svg_exporter.export(1000, 800)
32
+ doc = REXML::Document.new(svg_output)
33
+ svg = doc.root
34
+ expect(svg.attributes['width']).to eq('1000')
35
+ expect(svg.attributes['height']).to eq('800')
36
+ end
37
+ end
38
+
39
+ context 'with states and transitions' do
40
+ before do
41
+ automaton.add_state('A')
42
+ automaton.add_state('B')
43
+ automaton.add_state('C')
44
+ automaton.set_initial('A')
45
+ automaton.add_final('C')
46
+ automaton.add_transition('A', 'B', 'a')
47
+ automaton.add_transition('B', 'C', 'b')
48
+ end
49
+
50
+ it 'includes all states as circles' do
51
+ svg_output = svg_exporter.export
52
+ doc = REXML::Document.new(svg_output)
53
+ circles = REXML::XPath.match(doc, '//circle[@class="state-circle" or contains(@class, "final-state")]')
54
+ # Should have at least 3 circles (one per state, plus inner circle for final state)
55
+ expect(circles.size).to be >= 3
56
+ end
57
+
58
+ it 'includes state labels' do
59
+ svg_output = svg_exporter.export
60
+ doc = REXML::Document.new(svg_output)
61
+ labels = REXML::XPath.match(doc, '//text[@class="state-text"]')
62
+ label_texts = labels.map(&:text)
63
+ expect(label_texts).to include('A', 'B', 'C')
64
+ end
65
+
66
+ it 'includes transition labels' do
67
+ svg_output = svg_exporter.export
68
+ doc = REXML::Document.new(svg_output)
69
+ labels = REXML::XPath.match(doc, '//text[@class="transition-label"]')
70
+ label_texts = labels.map(&:text)
71
+ expect(label_texts).to include('a', 'b')
72
+ end
73
+ end
74
+
75
+ context 'with skip states transitions' do
76
+ before do
77
+ automaton.add_state('A')
78
+ automaton.add_state('B')
79
+ automaton.add_state('C')
80
+ automaton.add_state('D')
81
+ automaton.set_initial('A')
82
+ automaton.add_final('D')
83
+ automaton.add_transition('A', 'B', '1 step')
84
+ automaton.add_transition('A', 'C', 'skip 1 state')
85
+ automaton.add_transition('A', 'D', 'skip 2 states')
86
+ end
87
+
88
+ it 'creates curved paths for skip transitions' do
89
+ svg_output = svg_exporter.export
90
+ doc = REXML::Document.new(svg_output)
91
+ paths = REXML::XPath.match(doc, '//path[@class="transition-line"]')
92
+ # A->C and A->D should be curved (quadratic bezier)
93
+ curved_paths = paths.select { |p| p.attributes['d'].include?('Q') }
94
+ expect(curved_paths.size).to be >= 2
95
+ end
96
+
97
+ it 'creates different curve heights for multiple skip transitions' do
98
+ svg_output = svg_exporter.export
99
+ doc = REXML::Document.new(svg_output)
100
+ paths = REXML::XPath.match(doc, '//path[@class="transition-line"]')
101
+ curved_paths = paths.select { |p| p.attributes['d'].include?('Q') }
102
+
103
+ # Extract control points (y-coordinates) from path data
104
+ control_points = curved_paths.map do |path|
105
+ d = path.attributes['d']
106
+ # Parse "M x1 y1 Q cx cy, x2 y2" format
107
+ match = d.match(/Q\s+([\d.]+)\s+([\d.-]+)/)
108
+ match ? match[2].to_f : nil
109
+ end.compact
110
+
111
+ # Control points should be different (transitions shouldn't overlap)
112
+ expect(control_points.uniq.size).to eq(control_points.size)
113
+ end
114
+
115
+ it 'includes all transition labels with proper positioning' do
116
+ svg_output = svg_exporter.export
117
+ doc = REXML::Document.new(svg_output)
118
+ labels = REXML::XPath.match(doc, '//text[@class="transition-label"]')
119
+ label_texts = labels.map(&:text).reject { |t| t == 'start' }
120
+ expect(label_texts).to include('1 step', 'skip 1 state', 'skip 2 states')
121
+ end
122
+ end
123
+
124
+ context 'with self-loop transitions' do
125
+ before do
126
+ automaton.add_state('A')
127
+ automaton.add_transition('A', 'A', 'loop')
128
+ end
129
+
130
+ it 'creates cubic bezier curve for self-loop' do
131
+ svg_output = svg_exporter.export
132
+ doc = REXML::Document.new(svg_output)
133
+ paths = REXML::XPath.match(doc, '//path[@class="transition-line"]')
134
+ self_loop = paths.find { |p| p.attributes['d'].include?('C') }
135
+ expect(self_loop).not_to be_nil
136
+ end
137
+
138
+ it 'positions label above the loop' do
139
+ svg_output = svg_exporter.export
140
+ doc = REXML::Document.new(svg_output)
141
+ label = REXML::XPath.first(doc, '//text[@class="transition-label" and text()="loop"]')
142
+ expect(label).not_to be_nil
143
+ end
144
+ end
145
+
146
+ context 'with long state names' do
147
+ before do
148
+ automaton.add_state('VeryLongStateName')
149
+ automaton.add_state('AnotherLongName')
150
+ end
151
+
152
+ it 'adjusts font size for long names' do
153
+ svg_output = svg_exporter.export
154
+ doc = REXML::Document.new(svg_output)
155
+ texts = REXML::XPath.match(doc, '//text[@class="state-text"]')
156
+
157
+ # At least one text should have reduced font size
158
+ font_sizes = texts.map { |t| t.attributes['font-size'].to_i }
159
+ expect(font_sizes).to include(satisfy { |size| size < 20 })
160
+ end
161
+
162
+ it 'renders all state names' do
163
+ svg_output = svg_exporter.export
164
+ doc = REXML::Document.new(svg_output)
165
+ labels = REXML::XPath.match(doc, '//text[@class="state-text"]')
166
+ label_texts = labels.map(&:text)
167
+ expect(label_texts).to include('VeryLongStateName', 'AnotherLongName')
168
+ end
169
+ end
170
+
171
+ context 'with non-ASCII characters' do
172
+ before do
173
+ automaton.add_state('状態A')
174
+ automaton.add_state('状態B')
175
+ automaton.add_transition('状態A', '状態B', '遷移')
176
+ end
177
+
178
+ it 'handles Japanese characters correctly' do
179
+ svg_output = svg_exporter.export
180
+ doc = REXML::Document.new(svg_output)
181
+ labels = REXML::XPath.match(doc, '//text[@class="state-text"]')
182
+ label_texts = labels.map(&:text)
183
+ expect(label_texts).to include('状態A', '状態B')
184
+ end
185
+
186
+ it 'calculates proper text width for non-ASCII labels' do
187
+ svg_output = svg_exporter.export
188
+ doc = REXML::Document.new(svg_output)
189
+ label_bg = REXML::XPath.match(doc, '//rect[@class="label-bg"]')
190
+
191
+ # Background rectangles should have appropriate widths
192
+ widths = label_bg.map { |rect| rect.attributes['width'].to_f }
193
+ expect(widths).to all(be > 0)
194
+ end
195
+ end
196
+
197
+ context 'with parallel transitions' do
198
+ before do
199
+ automaton.add_state('A')
200
+ automaton.add_state('B')
201
+ automaton.add_state('C')
202
+ automaton.add_transition('A', 'C', 'skip forward')
203
+ automaton.add_transition('C', 'A', 'skip backward')
204
+ end
205
+
206
+ it 'creates curved paths for bidirectional transitions' do
207
+ svg_output = svg_exporter.export
208
+ doc = REXML::Document.new(svg_output)
209
+ paths = REXML::XPath.match(doc, '//path[@class="transition-line"]')
210
+ curved_paths = paths.select { |p| p.attributes['d'].include?('Q') }
211
+ expect(curved_paths.size).to be >= 2
212
+ end
213
+
214
+ it 'offsets parallel transitions to avoid overlap' do
215
+ svg_output = svg_exporter.export
216
+ doc = REXML::Document.new(svg_output)
217
+ paths = REXML::XPath.match(doc, '//path[@class="transition-line"]')
218
+
219
+ # Paths should have different control points
220
+ control_points = paths.map do |path|
221
+ d = path.attributes['d']
222
+ match = d.match(/Q\s+([\d.]+)\s+([\d.-]+)/)
223
+ match ? [match[1].to_f, match[2].to_f] : nil
224
+ end.compact
225
+
226
+ expect(control_points.uniq.size).to eq(control_points.size)
227
+ end
228
+ end
229
+ end
230
+
231
+ describe 'text width calculation' do
232
+ it 'calculates width for ASCII text' do
233
+ width = svg_exporter.send(:calculate_text_width, 'hello')
234
+ expect(width).to be > 0
235
+ expect(width).to be < 100
236
+ end
237
+
238
+ it 'calculates width for non-ASCII text' do
239
+ width = svg_exporter.send(:calculate_text_width, 'こんにちは')
240
+ expect(width).to be > 0
241
+ end
242
+
243
+ it 'calculates width for mixed text' do
244
+ width = svg_exporter.send(:calculate_text_width, 'hello世界')
245
+ expect(width).to be > 0
246
+ end
247
+
248
+ it 'returns minimum width for empty text' do
249
+ width = svg_exporter.send(:calculate_text_width, '')
250
+ expect(width).to eq(60)
251
+ end
252
+
253
+ it 'returns minimum width for very short text' do
254
+ width = svg_exporter.send(:calculate_text_width, 'a')
255
+ expect(width).to eq(60)
256
+ end
257
+ end
258
+
259
+ describe 'state font size calculation' do
260
+ it 'returns base size for short names' do
261
+ size = svg_exporter.send(:calculate_state_font_size, 'A')
262
+ expect(size).to eq(20)
263
+ end
264
+
265
+ it 'reduces size for long names' do
266
+ size = svg_exporter.send(:calculate_state_font_size, 'VeryLongStateName')
267
+ expect(size).to be < 20
268
+ end
269
+
270
+ it 'ensures minimum font size' do
271
+ size = svg_exporter.send(:calculate_state_font_size, 'SuperExtremelyLongStateNameThatWouldBeVeryHardToFit')
272
+ expect(size).to be >= 12
273
+ end
274
+
275
+ it 'handles non-ASCII characters' do
276
+ size = svg_exporter.send(:calculate_state_font_size, '状態名前')
277
+ expect(size).to be > 0
278
+ expect(size).to be <= 20
279
+ end
280
+ end
281
+
282
+ describe 'label background' do
283
+ before do
284
+ automaton.add_state('A')
285
+ automaton.add_state('B')
286
+ automaton.add_transition('A', 'B', 'test label')
287
+ end
288
+
289
+ it 'creates background rectangles for labels' do
290
+ svg_output = svg_exporter.export
291
+ doc = REXML::Document.new(svg_output)
292
+ backgrounds = REXML::XPath.match(doc, '//rect[@class="label-bg"]')
293
+ expect(backgrounds).not_to be_empty
294
+ end
295
+
296
+ it 'positions backgrounds correctly' do
297
+ svg_output = svg_exporter.export
298
+ doc = REXML::Document.new(svg_output)
299
+ backgrounds = REXML::XPath.match(doc, '//rect[@class="label-bg"]')
300
+
301
+ backgrounds.each do |bg|
302
+ x = bg.attributes['x'].to_f
303
+ y = bg.attributes['y'].to_f
304
+ width = bg.attributes['width'].to_f
305
+ height = bg.attributes['height'].to_f
306
+
307
+ expect(x).to be_a(Float)
308
+ expect(y).to be_a(Float)
309
+ expect(width).to be > 0
310
+ expect(height).to eq(20)
311
+ end
312
+ end
313
+ end
314
+ end