graphomaton 0.1.0 → 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.
data/lib/graphomaton.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rexml/document'
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
- auto_layout(width, height)
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
- start_label = svg.add_element('text', {
258
- 'class' => 'transition-label',
259
- 'x' => (init[:x] - 70).to_s,
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
- @states.each do |name, state|
267
- circle_class = 'state-circle'
268
- circle_class += ' final-state' if @final_states.include?(name)
87
+ def to_mermaid
88
+ Exporters::Mermaid.new(self).export
89
+ end
269
90
 
270
- svg.add_element('circle', {
271
- 'class' => circle_class,
272
- 'cx' => state[:x].to_s,
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
- if @final_states.include?(name)
278
- svg.add_element('circle', {
279
- 'class' => 'state-circle',
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
- text = svg.add_element('text', {
287
- 'class' => 'state-text',
288
- 'x' => state[:x].to_s,
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
- doc.to_s
103
+ def to_plantuml
104
+ Exporters::Plantuml.new(self).export
295
105
  end
296
106
 
297
- def save_svg(filename, width = 800, height = 600)
298
- File.write(filename, to_svg(width, height))
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('q0')
8
- automaton.add_state('q1')
7
+ automaton.add_state('待機中')
8
+ automaton.add_state('お金投入済み')
9
+ automaton.add_state('商品選択済み')
9
10
 
10
- automaton.set_initial('q0')
11
- automaton.add_final('q0')
11
+ automaton.set_initial('待機中')
12
+ automaton.add_final('待機中')
12
13
 
13
- automaton.add_transition('q0', 'q0', '0')
14
- automaton.add_transition('q0', 'q1', '1')
15
- automaton.add_transition('q1', 'q1', '0')
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