rsmp-validator 0.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.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/config/cross_rs4s.yaml +55 -0
  3. data/config/gem_supervisor.yaml +56 -0
  4. data/config/gem_tlc.yaml +56 -0
  5. data/config/gem_tlc_secrets.yaml +3 -0
  6. data/config/kapsch_etx.yaml +54 -0
  7. data/config/lightmotion_satellite.yaml +56 -0
  8. data/config/secrets.yaml +3 -0
  9. data/config/secrets_example.yaml +6 -0
  10. data/config/semaforica_cartesio.yaml +56 -0
  11. data/config/simulator/node_log.yaml +17 -0
  12. data/config/simulator/supervisor.yaml +11 -0
  13. data/config/simulator/tlc.yaml +56 -0
  14. data/config/sus.rb +13 -0
  15. data/config/swarco_itc3.yaml +55 -0
  16. data/config/tecsen_tmacs_supervisor.yaml +57 -0
  17. data/config/validator.rb +37 -0
  18. data/config/validator.yaml +5 -0
  19. data/config/validator_example.yaml +23 -0
  20. data/config/validator_log.yaml +19 -0
  21. data/exe/rsmp-validator +121 -0
  22. data/lib/doc_gen/parser.rb +276 -0
  23. data/lib/doc_gen/renderer.rb +153 -0
  24. data/lib/rsmp/validator/async_context.rb +15 -0
  25. data/lib/rsmp/validator/auto_node.rb +82 -0
  26. data/lib/rsmp/validator/auto_site.rb +30 -0
  27. data/lib/rsmp/validator/auto_supervisor.rb +23 -0
  28. data/lib/rsmp/validator/config_normalizer.rb +103 -0
  29. data/lib/rsmp/validator/configuration/loader.rb +79 -0
  30. data/lib/rsmp/validator/configuration/secrets.rb +54 -0
  31. data/lib/rsmp/validator/configuration/validation.rb +115 -0
  32. data/lib/rsmp/validator/configuration.rb +129 -0
  33. data/lib/rsmp/validator/helpers/alarms.rb +66 -0
  34. data/lib/rsmp/validator/helpers/clock.rb +16 -0
  35. data/lib/rsmp/validator/helpers/connection.rb +73 -0
  36. data/lib/rsmp/validator/helpers/handshake.rb +110 -0
  37. data/lib/rsmp/validator/helpers/input.rb +42 -0
  38. data/lib/rsmp/validator/helpers/security.rb +26 -0
  39. data/lib/rsmp/validator/helpers/signal_plans.rb +37 -0
  40. data/lib/rsmp/validator/helpers/signal_priority.rb +130 -0
  41. data/lib/rsmp/validator/helpers/startup.rb +157 -0
  42. data/lib/rsmp/validator/helpers/status.rb +22 -0
  43. data/lib/rsmp/validator/lifecycle.rb +99 -0
  44. data/lib/rsmp/validator/log.rb +11 -0
  45. data/lib/rsmp/validator/mode_detection.rb +84 -0
  46. data/lib/rsmp/validator/options/site_test_options.rb +58 -0
  47. data/lib/rsmp/validator/options/supervisor_test_options.rb +51 -0
  48. data/lib/rsmp/validator/site_tester.rb +113 -0
  49. data/lib/rsmp/validator/supervisor_tester.rb +76 -0
  50. data/lib/rsmp/validator/tester.rb +101 -0
  51. data/lib/rsmp/validator/version.rb +5 -0
  52. data/lib/rsmp/validator/version_filter.rb +44 -0
  53. data/lib/rsmp/validator.rb +50 -0
  54. data/schemas/site_test.json +36 -0
  55. data/schemas/supervisor_test.json +28 -0
  56. data/test/site/core/aggregated_status_spec.rb +43 -0
  57. data/test/site/core/connect_spec.rb +104 -0
  58. data/test/site/core/core_spec.rb +9 -0
  59. data/test/site/core/disconnect_spec.rb +54 -0
  60. data/test/site/site_spec.rb +5 -0
  61. data/test/site/tlc/alarm_spec.rb +134 -0
  62. data/test/site/tlc/clock_spec.rb +252 -0
  63. data/test/site/tlc/detector_logics_spec.rb +76 -0
  64. data/test/site/tlc/emergency_routes_spec.rb +106 -0
  65. data/test/site/tlc/input_spec.rb +102 -0
  66. data/test/site/tlc/invalid_command_spec.rb +103 -0
  67. data/test/site/tlc/invalid_status_spec.rb +70 -0
  68. data/test/site/tlc/modes_spec.rb +260 -0
  69. data/test/site/tlc/output_spec.rb +58 -0
  70. data/test/site/tlc/signal_groups_spec.rb +96 -0
  71. data/test/site/tlc/signal_plans_spec.rb +287 -0
  72. data/test/site/tlc/signal_priority_spec.rb +144 -0
  73. data/test/site/tlc/subscribe_spec.rb +71 -0
  74. data/test/site/tlc/system_spec.rb +76 -0
  75. data/test/site/tlc/tlc_spec.rb +7 -0
  76. data/test/site/tlc/traffic_data_spec.rb +151 -0
  77. data/test/site/tlc/traffic_situations_spec.rb +50 -0
  78. data/test/supervisor/aggregated_status_spec.rb +18 -0
  79. data/test/supervisor/connect_spec.rb +219 -0
  80. data/test/supervisor/supervisor_spec.rb +11 -0
  81. metadata +190 -0
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'sus'
5
+ require 'sus/config'
6
+
7
+ def print_help
8
+ puts <<~HELP
9
+ RSMP Validator runs conformance tests against RSMP sites and supervisors.
10
+ Full documentation: https://rsmp-nordic.github.io/rsmp_validator/
11
+
12
+ Usage:
13
+ rsmp-validator [OPTIONS] PATH...
14
+
15
+ Examples:
16
+ rsmp-validator test/site
17
+ rsmp-validator test/site/core
18
+ rsmp-validator test/supervisor/connect_spec.rb
19
+
20
+ Options:
21
+ --verbose Show detailed sus output.
22
+ --log Print RSMP log output to stdout.
23
+ --log PATH Write RSMP log output to PATH.
24
+ -h, --help Show this help.
25
+
26
+ Environment:
27
+ SITE_CONFIG Path to site test configuration.
28
+ SUPERVISOR_CONFIG Path to supervisor test configuration.
29
+ AUTO_SITE_CONFIG Path to local site configuration to start automatically.
30
+ HELP
31
+ end
32
+
33
+ if ARGV.first == 'help' || ARGV.include?('--help') || ARGV.include?('-h')
34
+ print_help
35
+ exit 0
36
+ end
37
+
38
+ # Parse --log [path] from ARGV before Sus::Config.load consumes remaining args.
39
+ # --log => RSMP logs to $stdout
40
+ # --log <path> => RSMP logs to file, sus output to console (brief)
41
+ # + --verbose => sus output also goes to file (interleaved, perfectly ordered)
42
+ # (no --log) => RSMP logs suppressed
43
+ log_index = ARGV.index('--log')
44
+ log_path = nil
45
+ log_to_stdout = false
46
+ if log_index
47
+ ARGV.delete_at(log_index)
48
+ if ARGV[log_index] && !ARGV[log_index].start_with?('-')
49
+ log_path = ARGV.delete_at(log_index)
50
+ else
51
+ log_to_stdout = true
52
+ end
53
+ end
54
+
55
+ # Writes to two IOs simultaneously through a single object, so both RSMP messages
56
+ # and Sus verbose output share one file descriptor and are perfectly interleaved.
57
+ class TeeIO
58
+ def initialize(primary, secondary)
59
+ @primary = primary
60
+ @secondary = secondary
61
+ end
62
+
63
+ def write(*args)
64
+ @primary.write(*args)
65
+ @secondary.write(*args)
66
+ end
67
+
68
+ def puts(*args)
69
+ @primary.puts(*args)
70
+ @secondary.puts(*args)
71
+ end
72
+
73
+ def flush
74
+ @primary.flush
75
+ @secondary.flush
76
+ end
77
+
78
+ def isatty = @primary.isatty
79
+ def tty? = @primary.tty?
80
+ end
81
+
82
+ # Load config/validator.rb instead of config/sus.rb so that conformance tests
83
+ # live in valid/site/ and valid/supervisor/, keeping them separate from the
84
+ # internal tests in test/ that plain `sus` runs.
85
+ validator_config_class = Class.new(Sus::Config) do
86
+ attr_accessor :log_to_stdout, :log_path, :log_file_io
87
+
88
+ def self.path(root)
89
+ path = File.join(root, 'config/validator.rb')
90
+ File.exist?(path) ? path : nil
91
+ end
92
+ end
93
+
94
+ config = validator_config_class.load
95
+ config.log_to_stdout = log_to_stdout
96
+ config.log_path = log_path
97
+
98
+ registry = config.registry
99
+
100
+ def run_suite(config, registry, output, verbose)
101
+ assertions = Sus::Assertions.default(output: output, verbose: verbose)
102
+ config.before_tests(assertions)
103
+ registry.call(assertions)
104
+ config.after_tests(assertions)
105
+ exit(1) unless assertions.passed?
106
+ end
107
+
108
+ # When --log <path> and --verbose are both given, open the file once and share
109
+ # the handle: Sus writes via TeeIO (terminal + file), RSMP writes to the same
110
+ # File object directly. One fd => serialized writes => perfect ordering.
111
+ if log_path && config.verbose?
112
+ File.open(log_path, 'w') do |log_file|
113
+ config.log_path = nil
114
+ config.log_file_io = log_file
115
+ output = Sus::Output.default(TeeIO.new($stderr, log_file))
116
+ run_suite(config, registry, output, true)
117
+ end
118
+ else
119
+ output = config.verbose? ? config.output : Sus::Output::Null.new
120
+ run_suite(config, registry, output, config.verbose?)
121
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ # DocGen: Prism-based parser and Jekyll Markdown renderer for test documentation.
6
+ #
7
+ # Usage:
8
+ # require_relative 'lib/doc_gen/parser'
9
+ # require_relative 'lib/doc_gen/renderer'
10
+ #
11
+ # contexts = DocGen::Parser.parse_files(Dir['test/**/*_spec.rb'])
12
+ # DocGen::Renderer.render(contexts, output_dir: 'docs/tests')
13
+ module DocGen
14
+ # A context represents a `describe` block. It holds child contexts and specs.
15
+ Context = Struct.new(:name, :docstring, :children, :file, :line, :parent, keyword_init: true) do
16
+ # Direct child specs (it/specify blocks).
17
+ def specs
18
+ children.grep(Spec)
19
+ end
20
+
21
+ # Direct child contexts (nested describe blocks).
22
+ def subcontexts
23
+ children.grep(Context)
24
+ end
25
+
26
+ # Full display name, dropping the root component when nested.
27
+ # Examples:
28
+ # root 'Site::Tlc::Io' => 'Site::Tlc::Io'
29
+ # child 'IO' of above => 'IO'
30
+ # grandchild 'Input' of above => 'IO Input'
31
+ def full_name
32
+ parts = [name]
33
+ ctx = parent
34
+ while ctx.is_a?(Context)
35
+ parts.unshift(ctx.name)
36
+ ctx = ctx.parent
37
+ end
38
+ parts.shift if parts.size > 1
39
+ parts.join(' ')
40
+ end
41
+
42
+ # Hierarchical output path relative to the output directory root.
43
+ # Examples:
44
+ # root 'Site::Tlc::Io' => 'site_tlc_io.md'
45
+ # child 'IO' => 'site_tlc_io/io.md'
46
+ # grandchild 'Input' => 'site_tlc_io/io/input.md'
47
+ def output_path
48
+ parts = []
49
+ ctx = self
50
+ while ctx.is_a?(Context)
51
+ parts.unshift(DocGen.slugify(ctx.name))
52
+ ctx = ctx.parent
53
+ end
54
+ "#{parts.join('/')}.md"
55
+ end
56
+ end
57
+
58
+ # A spec represents an `it` or `specify` block.
59
+ Spec = Struct.new(:name, :docstring, :source, :file, :line, :parent, keyword_init: true) do
60
+ # Full display name including ancestors (root component dropped, same as Context).
61
+ def full_name
62
+ parts = [name]
63
+ ctx = parent
64
+ while ctx.is_a?(Context)
65
+ parts.unshift(ctx.name)
66
+ ctx = ctx.parent
67
+ end
68
+ parts.shift if parts.size > 1
69
+ parts.join(' ')
70
+ end
71
+ end
72
+
73
+ # Convert a name to a URL/filesystem-friendly slug.
74
+ def self.slugify(name)
75
+ name.gsub('::', '_')
76
+ .gsub(/[^a-zA-Z0-9_]+/, '_')
77
+ .gsub(/_+/, '_')
78
+ .gsub(/\A_+|_+\z/, '')
79
+ .downcase
80
+ end
81
+
82
+ # Convert a raw describe name to a human-readable title.
83
+ # Takes the last :: segment and splits CamelCase into words.
84
+ # Examples:
85
+ # 'Site::Tlc::DetectorLogics' => 'Detector Logics'
86
+ # 'Site::Core' => 'Core'
87
+ # 'Detector Logic' => 'Detector Logic' (already readable)
88
+ def self.humanize(name)
89
+ segment = name.split('::').last || name
90
+ segment.gsub(/([a-z\d])([A-Z])/, '\1 \2')
91
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1 \2')
92
+ end
93
+
94
+ # Builds a namespace tree from flat parsed contexts by merging on '::' segments.
95
+ # e.g. 'Site::Tlc::Alarm' becomes Site -> Tlc -> Alarm in the tree.
96
+ class NamespaceBuilder
97
+ def build(raw_contexts)
98
+ @node_map = {}
99
+ sorted = raw_contexts.sort_by { |ctx| ctx.name.include?('::') ? 0 : 1 }
100
+ sorted.each { |raw| process_raw_context(raw) }
101
+ @node_map.values.select { |n| n.parent.nil? }
102
+ end
103
+
104
+ private
105
+
106
+ def process_raw_context(raw)
107
+ segments = raw.name.split('::')
108
+ segments.each_with_index { |_seg, idx| ensure_namespace_node(segments, idx, raw) }
109
+ leaf = @node_map[raw.name]
110
+ leaf.docstring ||= raw.docstring
111
+ raw.children.each { |child| adopt_child(leaf, child) }
112
+ end
113
+
114
+ def ensure_namespace_node(segments, idx, raw)
115
+ path = segments[0..idx].join('::')
116
+ return if @node_map.key?(path)
117
+
118
+ parent_node = idx.positive? ? @node_map[segments[0...idx].join('::')] : nil
119
+ node = Context.new(
120
+ name: segments[idx], docstring: nil, children: [],
121
+ file: raw.file, line: raw.line, parent: parent_node
122
+ )
123
+ @node_map[path] = node
124
+ parent_node.children << node if parent_node
125
+ end
126
+
127
+ def adopt_child(leaf, child)
128
+ if child.is_a?(Context)
129
+ adopt_context_child(leaf, child)
130
+ else
131
+ child.parent = leaf
132
+ leaf.children << child
133
+ end
134
+ end
135
+
136
+ def adopt_context_child(leaf, child)
137
+ existing = leaf.children.find { |c| c.is_a?(Context) && c.name == child.name }
138
+ if existing
139
+ merge_into_existing(existing, child)
140
+ elsif child.children.any?
141
+ child.parent = leaf
142
+ leaf.children << child
143
+ end
144
+ end
145
+
146
+ def merge_into_existing(existing, child)
147
+ existing.docstring ||= child.docstring
148
+ child.children.each do |grandchild|
149
+ grandchild.parent = existing
150
+ existing.children << grandchild
151
+ end
152
+ end
153
+ end
154
+
155
+ # Parses Ruby test files using Prism and builds a tree of Context and Spec objects.
156
+ class Parser
157
+ # Parse an array of file paths and return an array of root Context objects.
158
+ def self.parse_files(paths)
159
+ new.parse_files(paths)
160
+ end
161
+
162
+ def parse_files(paths)
163
+ raw = Array(paths).flat_map { |path| parse_file(path) }
164
+ NamespaceBuilder.new.build(raw)
165
+ end
166
+
167
+ private
168
+
169
+ def parse_file(path)
170
+ source = File.read(path)
171
+ result = Prism.parse(source)
172
+ lines = source.lines
173
+ comment_map = build_comment_map(result, lines)
174
+ top_nodes = result.value.statements&.body || []
175
+ parse_block(top_nodes, path, comment_map, lines, nil)
176
+ end
177
+
178
+ # Recursively parses AST nodes into Context/Spec objects.
179
+ def parse_block(nodes, file, comment_map, lines, parent_context)
180
+ roots = []
181
+ Array(nodes).each do |node|
182
+ next unless node.is_a?(Prism::CallNode)
183
+
184
+ case node.name
185
+ when :describe
186
+ ctx = handle_describe(node, file, comment_map, lines, parent_context)
187
+ roots << ctx if ctx && parent_context.nil?
188
+ when :it, :specify
189
+ handle_spec(node, file, comment_map, lines, parent_context)
190
+ end
191
+ end
192
+ roots
193
+ end
194
+
195
+ def handle_describe(node, file, comment_map, lines, parent_context)
196
+ ctx = make_context(node, file, comment_map, parent_context)
197
+ return unless ctx
198
+
199
+ parent_context.children << ctx if parent_context
200
+ body = block_body(node)
201
+ parse_block(body, file, comment_map, lines, ctx) if body
202
+ ctx
203
+ end
204
+
205
+ def handle_spec(node, file, comment_map, lines, parent_context)
206
+ spec = make_spec(node, file, comment_map, lines, parent_context)
207
+ parent_context.children << spec if spec
208
+ end
209
+
210
+ def make_context(node, file, comment_map, parent_context)
211
+ name = string_arg(node)
212
+ return unless name
213
+
214
+ Context.new(name: name, docstring: extract_docstring(node.location.start_line, comment_map),
215
+ children: [], file: file, line: node.location.start_line, parent: parent_context)
216
+ end
217
+
218
+ def make_spec(node, file, comment_map, lines, parent_context)
219
+ name = string_arg(node)
220
+ return unless name
221
+ return unless parent_context.is_a?(Context)
222
+
223
+ Spec.new(name: name, docstring: extract_docstring(node.location.start_line, comment_map),
224
+ source: extract_source(node, lines), file: file,
225
+ line: node.location.start_line, parent: parent_context)
226
+ end
227
+
228
+ # Build a map of line_number => comment_text for standalone comment lines only.
229
+ # Inline comments (e.g. `foo # bar`) are excluded.
230
+ def build_comment_map(result, lines)
231
+ result.comments.each_with_object({}) do |comment, map|
232
+ line_num = comment.location.start_line
233
+ source_line = lines[line_num - 1] || ''
234
+ map[line_num] = comment.slice if source_line.strip.start_with?('#')
235
+ end
236
+ end
237
+
238
+ # Extract the string value of the first argument to a call node.
239
+ # Returns nil for non-string or missing arguments.
240
+ def string_arg(node)
241
+ return nil unless node.arguments
242
+
243
+ first = node.arguments.arguments.first
244
+ case first
245
+ when Prism::StringNode, Prism::SymbolNode then first.unescaped
246
+ end
247
+ end
248
+
249
+ # Return the body statement array from a block node, or nil.
250
+ def block_body(node)
251
+ return nil unless node.block&.body
252
+
253
+ body = node.block.body
254
+ body.is_a?(Prism::StatementsNode) ? body.body : nil
255
+ end
256
+
257
+ # Walk backwards from the line before start_line collecting contiguous comment lines.
258
+ # A blank line or non-comment line stops the collection.
259
+ def extract_docstring(start_line, comment_map)
260
+ doc_lines = []
261
+ current = start_line - 1
262
+ while comment_map[current]
263
+ doc_lines.unshift(comment_map[current].sub(/^\s*#\s?/, ''))
264
+ current -= 1
265
+ end
266
+ doc_lines.join("\n").strip
267
+ end
268
+
269
+ # Extract the full source of the `it`/`specify` call (including block body).
270
+ def extract_source(node, lines)
271
+ start = node.location.start_line - 1
272
+ stop = node.location.end_line
273
+ lines[start...stop].join.chomp
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative 'parser'
5
+
6
+ module DocGen
7
+ # Renders a tree of Context objects to Jekyll-compatible Markdown files.
8
+ #
9
+ # Frontmatter fields produced:
10
+ # layout, title, parmalink (sic - preserved from original for compatibility),
11
+ # has_children, has_toc, parent, grand_parent (when applicable)
12
+ #
13
+ # Jekyll nav hierarchy (just-the-docs):
14
+ # depth 0 -> parent: "Test Suite" (no grand_parent)
15
+ # depth 1 -> parent: <root name>, grand_parent: "Test Suite"
16
+ # depth 2+ -> parent: <direct parent name>, grand_parent: <grandparent name or "Test Suite">
17
+ class Renderer
18
+ # Render an array of root Context objects to output_dir.
19
+ def self.render(contexts, output_dir:)
20
+ new(output_dir: output_dir).render(contexts)
21
+ end
22
+
23
+ def initialize(output_dir:)
24
+ @output_dir = output_dir
25
+ end
26
+
27
+ def render(contexts)
28
+ FileUtils.mkdir_p(@output_dir)
29
+ contexts.each { |ctx| render_context(ctx) }
30
+ end
31
+
32
+ private
33
+
34
+ # Write a single context page and recurse into subcontexts.
35
+ def render_context(ctx)
36
+ path = File.join(@output_dir, ctx.output_path)
37
+ FileUtils.mkdir_p(File.dirname(path))
38
+ File.write(path, context_content(ctx))
39
+ ctx.subcontexts.each { |child| render_context(child) }
40
+ end
41
+
42
+ # Assemble the full Markdown content for a context page.
43
+ def context_content(ctx)
44
+ parts = [
45
+ frontmatter(ctx),
46
+ page_title(ctx),
47
+ description(ctx)
48
+ ]
49
+ parts << context_toc(ctx) if ctx.subcontexts.any?
50
+ parts << specification_toc if ctx.specs.any?
51
+ parts << specifications(ctx) if ctx.specs.any?
52
+ parts.join
53
+ end
54
+
55
+ # Jekyll frontmatter block.
56
+ def frontmatter(ctx)
57
+ fields = {
58
+ layout: 'page',
59
+ title: DocGen.humanize(ctx.name),
60
+ parmalink: DocGen.slugify(ctx.full_name),
61
+ has_children: ctx.subcontexts.any?,
62
+ has_toc: false,
63
+ parent: parent_title(ctx)
64
+ }
65
+ grand = grand_parent_title(ctx)
66
+ fields[:grand_parent] = grand if grand
67
+
68
+ lines = fields.map { |k, v| "#{k}: #{v}" }.join("\n")
69
+ "---\n#{lines}\n---\n\n"
70
+ end
71
+
72
+ # H1 heading using humanized name.
73
+ def page_title(ctx)
74
+ "# #{DocGen.humanize(ctx.name)}\n{: .no_toc}\n\n"
75
+ end
76
+
77
+ # Context-level docstring (comment above the describe block), if present.
78
+ def description(ctx)
79
+ return '' if ctx.docstring.nil? || ctx.docstring.strip.empty?
80
+
81
+ "#{ctx.docstring.strip}\n\n"
82
+ end
83
+
84
+ # Sorted list of links to child contexts.
85
+ def context_toc(ctx)
86
+ items = ctx.subcontexts.sort_by(&:name).map do |child|
87
+ "- [#{DocGen.humanize(child.name)}]({{ site.baseurl }}{% link #{link_path(child)} %})"
88
+ end.join("\n")
89
+
90
+ "### Categories\n{: .no_toc .text-delta }\n#{items}\n\n"
91
+ end
92
+
93
+ # just-the-docs inline TOC marker (picks up ## headings from spec sections).
94
+ def specification_toc
95
+ "### Tests\n{: .no_toc .text-delta }\n\n- TOC\n{:toc}\n\n"
96
+ end
97
+
98
+ # All spec sections for the context, sorted by name.
99
+ def specifications(ctx)
100
+ ctx.specs.sort_by(&:name).map { |spec| spec_section(spec) }.join("\n\n")
101
+ end
102
+
103
+ # One spec rendered as a ## section with docstring and collapsible source.
104
+ def spec_section(spec)
105
+ heading = "## #{DocGen.humanize(spec.parent.name)} #{spec.name}"
106
+ docstring = spec.docstring.to_s.strip
107
+ src = indent(spec.source.to_s)
108
+
109
+ parts = [heading]
110
+ parts << "\n\n#{docstring}" unless docstring.empty?
111
+ parts << "\n\n<details markdown=\"block\">\n " \
112
+ "<summary>\n " \
113
+ "View Source\n " \
114
+ "</summary>\n" \
115
+ "```ruby\n#{src}\n```\n" \
116
+ '</details>'
117
+ "#{parts.join}\n"
118
+ end
119
+
120
+ # De-indent source by the leading whitespace of the last line.
121
+ def indent(source)
122
+ lines = source.lines
123
+ return source if lines.empty?
124
+
125
+ n = /^(\s*)/.match(lines.last)[0].size
126
+ lines.map do |line|
127
+ i = [n, /^(\s*)/.match(line)[0].size].min
128
+ line[i..]
129
+ end.join
130
+ end
131
+
132
+ # Jekyll {% link %} path for a context (relative to the Jekyll site root).
133
+ # Matches the `tests/` output prefix used by the rake task.
134
+ def link_path(ctx)
135
+ "tests/#{ctx.output_path}"
136
+ end
137
+
138
+ # Title of the logical parent for Jekyll frontmatter.
139
+ # Returns "Test Suite" for root contexts (depth 0).
140
+ def parent_title(ctx)
141
+ ctx.parent ? DocGen.humanize(ctx.parent.name) : 'Test Suite'
142
+ end
143
+
144
+ # Title of the logical grandparent for Jekyll frontmatter.
145
+ # Added for all non-root contexts (depth >= 1).
146
+ # Returns nil for root contexts.
147
+ def grand_parent_title(ctx)
148
+ return nil unless ctx.parent
149
+
150
+ parent_title(ctx.parent)
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,15 @@
1
+ module RSMP
2
+ module Validator
3
+ # Sus fixture module that runs each test inside the shared Async reactor.
4
+ # Include this in the sus base class to ensure all tests run within the reactor context.
5
+ module AsyncContext
6
+ def around
7
+ RSMP::Validator.reactor.run do |_task|
8
+ yield
9
+ ensure
10
+ RSMP::Validator.reactor.interrupt
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,82 @@
1
+ require 'rsmp'
2
+
3
+ module RSMP
4
+ module Validator
5
+ # Base class for automatically starting a local RSMP node (site or supervisor)
6
+ # when testing the validator or RSMP gem itself.
7
+ class AutoNode
8
+ include RSMP::Validator::Log
9
+
10
+ attr_reader :node, :task
11
+
12
+ def initialize
13
+ @node = nil
14
+ @task = nil
15
+ end
16
+
17
+ # Start the auto node inside the async reactor
18
+ def start
19
+ return if @node
20
+
21
+ @node = build_node
22
+
23
+ @task = Async do |task|
24
+ task.annotate "auto_#{node_type}"
25
+ log("Starting auto #{node_type}")
26
+ @node.start
27
+ rescue Async::TimeoutError
28
+ raise RSMP::TimeoutError, "Timeout while starting auto #{node_type}"
29
+ end
30
+ end
31
+
32
+ # Stop the auto node
33
+ def stop
34
+ if @node
35
+ log("Stopping auto #{node_type}")
36
+ @node.ignore_errors RSMP::DisconnectError do
37
+ @node.stop
38
+ end
39
+ end
40
+ @task&.stop
41
+ ensure
42
+ @task = nil
43
+ @node = nil
44
+ end
45
+
46
+ # Check if the auto node is running
47
+ def running?
48
+ @node && @task
49
+ end
50
+
51
+ protected
52
+
53
+ def config
54
+ RSMP::Validator.auto_node_config
55
+ end
56
+
57
+ def node_type
58
+ raise NotImplementedError, 'Subclasses must implement node_type'
59
+ end
60
+
61
+ def build_node
62
+ raise NotImplementedError, 'Subclasses must implement build_node'
63
+ end
64
+
65
+ def create_logger
66
+ logger_settings = RSMP::Validator.node_log_settings.dup
67
+ logger_settings['prefix'] = default_log_prefix
68
+ auto_log_settings = RSMP::Validator.auto_node_log_settings
69
+ logger_settings.merge!(auto_log_settings) if auto_log_settings && !auto_log_settings.empty?
70
+ logger_settings.delete('stream') if auto_log_settings && auto_log_settings['path']
71
+ RSMP::Logger.new(logger_settings)
72
+ end
73
+
74
+ def default_log_prefix
75
+ case node_type
76
+ when 'supervisor' then '[SUPERVISOR]'
77
+ when 'site' then '[TLC] '
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,30 @@
1
+ require_relative 'auto_node'
2
+
3
+ module RSMP
4
+ module Validator
5
+ # Automatically starts a local RSMP site for testing.
6
+ class AutoSite < RSMP::Validator::AutoNode
7
+ protected
8
+
9
+ def node_type
10
+ 'site'
11
+ end
12
+
13
+ def build_node
14
+ klass = case config['type']
15
+ when 'tlc'
16
+ RSMP::TLC::TrafficControllerSite
17
+ else
18
+ RSMP::Site
19
+ end
20
+
21
+ site_settings = ConfigNormalizer.normalize_site_settings(config)
22
+
23
+ klass.new(
24
+ site_settings: site_settings,
25
+ logger: create_logger
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,23 @@
1
+ require_relative 'auto_node'
2
+
3
+ module RSMP
4
+ module Validator
5
+ # Automatically starts a local RSMP supervisor for testing.
6
+ class AutoSupervisor < RSMP::Validator::AutoNode
7
+ protected
8
+
9
+ def node_type
10
+ 'supervisor'
11
+ end
12
+
13
+ def build_node
14
+ supervisor_settings = ConfigNormalizer.normalize_supervisor_settings(config)
15
+
16
+ RSMP::Supervisor.new(
17
+ supervisor_settings: supervisor_settings,
18
+ logger: create_logger
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end