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,103 @@
1
+ module RSMP
2
+ module Validator
3
+ # Normalizes configuration settings before passing them to RSMP nodes.
4
+ module ConfigNormalizer
5
+ def self.normalize_site_settings(settings)
6
+ normalized = deep_dup(settings)
7
+
8
+ if normalized['security_codes'].nil? && normalized.dig('secrets', 'security_codes').is_a?(Hash)
9
+ normalized['security_codes'] = deep_dup(normalized['secrets']['security_codes'])
10
+ end
11
+
12
+ normalize_security_codes!(normalized)
13
+ normalize_input_programming!(normalized)
14
+ normalize_sxls_for_config!(normalized)
15
+
16
+ normalized
17
+ end
18
+
19
+ def self.normalize_supervisor_settings(settings)
20
+ normalized = deep_dup(settings)
21
+ normalize_sxls_for_config!(normalized)
22
+ normalized
23
+ end
24
+
25
+ def self.normalize_security_codes!(settings)
26
+ codes = settings['security_codes']
27
+ return unless codes.is_a?(Hash)
28
+
29
+ settings['security_codes'] = codes.each_with_object({}) do |(key, value), memo|
30
+ int_key = key.is_a?(String) && key.match?(/^\d+$/) ? key.to_i : key
31
+ memo[int_key] = value
32
+ end
33
+ end
34
+
35
+ def self.normalize_input_programming!(settings)
36
+ programming = settings.dig('inputs', 'programming')
37
+ return unless programming.is_a?(Hash)
38
+
39
+ normalized = normalize_programming_keys(programming)
40
+ return unless normalized.keys.all?(Integer)
41
+
42
+ settings['inputs'] ||= {}
43
+ settings['inputs']['programming'] = build_programming_array(normalized)
44
+ end
45
+
46
+ def self.normalize_programming_keys(programming)
47
+ programming.each_with_object({}) do |(key, value), memo|
48
+ int_key = key.is_a?(String) && key.match?(/^\d+$/) ? key.to_i : key
49
+ memo[int_key] = value
50
+ end
51
+ end
52
+
53
+ def self.build_programming_array(normalized)
54
+ max_key = normalized.keys.max
55
+ program_array = Array.new(max_key + 1)
56
+ normalized.each do |index, value|
57
+ program_array[index] = value
58
+ end
59
+ program_array
60
+ end
61
+
62
+ def self.normalize_sxls_for_config!(value)
63
+ case value
64
+ when Hash
65
+ value.each do |key, child|
66
+ value[key] = sxls_array_to_hash(child) if key == 'sxls'
67
+ normalize_sxls_for_config!(value[key])
68
+ end
69
+ when Array
70
+ value.each { |item| normalize_sxls_for_config!(item) }
71
+ end
72
+ end
73
+
74
+ def self.sxls_array_to_hash(value)
75
+ return value unless value.is_a?(Array)
76
+
77
+ value.each_with_object({}) do |item, memo|
78
+ next unless item.is_a?(Hash)
79
+
80
+ name = item['name']
81
+ next unless name
82
+
83
+ sxl = item.dup
84
+ sxl.delete('name')
85
+ version = sxl.delete('version')
86
+ sxl.delete('prefix')
87
+ memo[name] = sxl.empty? ? version : sxl.merge('version' => version)
88
+ end
89
+ end
90
+
91
+ def self.deep_dup(value)
92
+ case value
93
+ when Hash
94
+ value.transform_values { |v| deep_dup(v) }
95
+ when Array
96
+ value.map { |item| deep_dup(item) }
97
+ else
98
+ value
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,79 @@
1
+ module RSMP
2
+ module Validator
3
+ module Configuration
4
+ # Private helpers for loading YAML config files and building options objects.
5
+ module Loader
6
+ private
7
+
8
+ def build_tester_options(raw_config, config_path)
9
+ options_class = tester_options_class_for(raw_config)
10
+ build_options_from_raw(raw_config, config_path, options_class)
11
+ end
12
+
13
+ def apply_loaded_config(options)
14
+ self.config = options.to_h
15
+ self.config_log_settings = options.log_settings
16
+ end
17
+
18
+ def load_yaml_config!(path, using_message:, missing_message:)
19
+ ensure_config_exists!(path, missing_message)
20
+ @log_stream.puts using_message if using_message && !using_message.empty?
21
+ raw = YAML.load_file(path)
22
+ validate_config_hash!(raw, path)
23
+ raw || {}
24
+ rescue Psych::SyntaxError => e
25
+ raise RSMP::ConfigurationError, "Cannot read config file #{path}: #{e}"
26
+ end
27
+
28
+ def ensure_config_exists!(path, missing_message)
29
+ abort_with_error missing_message unless File.exist?(path)
30
+ end
31
+
32
+ def validate_config_hash!(raw, path)
33
+ return if raw.is_a?(Hash) || raw.nil?
34
+
35
+ raise RSMP::ConfigurationError, "Config #{path} must be a hash"
36
+ end
37
+
38
+ def build_options_from_raw(raw, path, option_class)
39
+ log_settings = raw.is_a?(Hash) ? raw['log'] : nil
40
+ options_hash = raw.is_a?(Hash) ? raw.dup : {}
41
+ options_hash.delete('log') if options_hash.is_a?(Hash)
42
+
43
+ option_class.new(options_hash, source: path, log_settings: log_settings)
44
+ rescue RSMP::ConfigurationError => e
45
+ abort_with_error e.message
46
+ end
47
+
48
+ def tester_options_class_for(_raw)
49
+ case mode
50
+ when :site
51
+ RSMP::Validator::SiteTest::Options
52
+ when :supervisor
53
+ RSMP::Validator::SupervisorTest::Options
54
+ else
55
+ abort_with_error "Unknown test mode: #{mode}"
56
+ end
57
+ end
58
+
59
+ def auto_node_options_class_for(raw)
60
+ case mode
61
+ when :site
62
+ site_options_class_for(raw)
63
+ when :supervisor
64
+ RSMP::Supervisor::Options
65
+ else
66
+ abort_with_error "Unknown test mode: #{mode}"
67
+ end
68
+ end
69
+
70
+ def site_options_class_for(raw)
71
+ return RSMP::Site::Options unless raw.is_a?(Hash)
72
+
73
+ type = raw['type']
74
+ type == 'tlc' ? RSMP::TLC::TrafficControllerSite::Options : RSMP::Site::Options
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,54 @@
1
+ module RSMP
2
+ module Validator
3
+ module Configuration
4
+ # Private helpers for loading and normalizing secrets configuration.
5
+ module Secrets
6
+ private
7
+
8
+ def load_secrets(config_path)
9
+ load_secrets_file(config_path) unless config['secrets']
10
+ normalize_security_codes!
11
+ warn_missing_security_codes
12
+ end
13
+
14
+ def load_secrets_file(config_path)
15
+ basename = File.basename(config_path, '.yaml')
16
+ folder = File.dirname(config_path)
17
+ secrets_path = File.join(folder, "#{basename}_secrets.yaml")
18
+ return unless File.exist?(secrets_path)
19
+
20
+ config['secrets'] = YAML.load_file(secrets_path)
21
+ rescue Psych::SyntaxError => e
22
+ abort_with_error "Cannot read secrets file #{secrets_path}: #{e}"
23
+ end
24
+
25
+ def normalize_security_codes!
26
+ codes = config.dig('secrets', 'security_codes')
27
+ return unless codes.is_a?(Hash)
28
+
29
+ normalized = codes.each_with_object({}) do |(key, value), memo|
30
+ int_key = key.is_a?(String) && key.match?(/^\d+$/) ? key.to_i : key
31
+ memo[int_key] = value
32
+ end
33
+
34
+ config['secrets']['security_codes'] = normalized
35
+ end
36
+
37
+ def warn_missing_security_codes
38
+ return warn_no_security_code unless config.dig('secrets', 'security_codes')
39
+
40
+ warn_security_code_not_configured(1) unless config.dig('secrets', 'security_codes', 1)
41
+ warn_security_code_not_configured(2) unless config.dig('secrets', 'security_codes', 2)
42
+ end
43
+
44
+ def warn_no_security_code
45
+ log 'Warning: No security code configured', level: :warning
46
+ end
47
+
48
+ def warn_security_code_not_configured(index)
49
+ log "Warning: Security code #{index} is not configured", level: :warning
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,115 @@
1
+ module RSMP
2
+ module Validator
3
+ module Configuration
4
+ # Private helpers for validating and normalizing configuration values.
5
+ module Validation
6
+ private
7
+
8
+ def validate_mode_config!(config_path)
9
+ case mode
10
+ when :supervisor
11
+ validate_supervisor_mode_config!(config_path)
12
+ when :site
13
+ validate_site_mode_config!(config_path)
14
+ end
15
+ end
16
+
17
+ def validate_supervisor_mode_config!(config_path)
18
+ return if config['local_site'] && !top_level_site_settings?
19
+
20
+ if top_level_site_settings?
21
+ abort_with_error <<~HEREDOC
22
+ Error:
23
+ The config file at #{config_path} contains site settings at the top level.
24
+ For supervisor testing, put site settings under 'local_site'.
25
+ Check that you're using the right config file, or fix the config.
26
+ HEREDOC
27
+ else
28
+ abort_with_error <<~HEREDOC
29
+ Error:
30
+ The config file at #{config_path} is missing 'local_site'.
31
+ For supervisor testing, the config must describe the local site used during testing.
32
+ Check that you're using the right config file, or fix the config.
33
+ HEREDOC
34
+ end
35
+ end
36
+
37
+ def validate_site_mode_config!(config_path)
38
+ return if config['local_supervisor'] && !top_level_supervisor_settings?
39
+
40
+ if top_level_supervisor_settings?
41
+ abort_with_error <<~HEREDOC
42
+ Error:
43
+ The config file at #{config_path} contains supervisor settings at the top level.
44
+ For site testing, put supervisor settings under 'local_supervisor'.
45
+ Check that you're using the right config file, or fix the config.
46
+ HEREDOC
47
+ else
48
+ abort_with_error <<~HEREDOC
49
+ Error:
50
+ The config file at #{config_path} is missing 'local_supervisor'.
51
+ For site testing, the config must describe the local supervisor used during testing.
52
+ Check that you're using the right config file, or fix the config.
53
+ HEREDOC
54
+ end
55
+ end
56
+
57
+ def top_level_site_settings?
58
+ %w[site_id supervisors type].any? { |key| config.key?(key) }
59
+ end
60
+
61
+ def top_level_supervisor_settings?
62
+ %w[port ips max_sites].any? { |key| config.key?(key) }
63
+ end
64
+
65
+ def validate_components_config!
66
+ abort_with_error "Error: config 'components' settings is missing or empty" if config['components'] == {}
67
+
68
+ main_component = config.dig('components', 'main')&.keys&.first
69
+ abort_with_error "Error: config 'main' component settings is missing or empty" unless main_component
70
+
71
+ config['main_component'] = main_component
72
+ end
73
+
74
+ def validate_timeouts_config!
75
+ timeouts = config['timeouts']
76
+ abort_with_error "Error: config 'timeouts' settings is missing or empty" if timeouts.nil? || timeouts == {}
77
+ end
78
+
79
+ def normalize_core_version!
80
+ core_version = ENV['CORE_VERSION'] || config['core_version'] || RSMP::Schema.latest_core_version
81
+ core_version = RSMP::Schema.latest_core_version if core_version == 'latest'
82
+
83
+ known_versions = RSMP::Schema.core_versions
84
+ normalized = normalized_core_version(core_version, known_versions)
85
+ return config['core_version'] = normalized.to_s if normalized
86
+
87
+ abort_with_error "Unknown core version #{core_version}, must be one of [#{known_versions.join(', ')}]."
88
+ end
89
+
90
+ def normalized_core_version(core_version, known_versions)
91
+ known_versions.map { |v| Gem::Version.new(v) }.sort.reverse.detect do |v|
92
+ Gem::Requirement.new(core_version).satisfied_by?(v)
93
+ end
94
+ end
95
+
96
+ def normalize_sxls!
97
+ sxls = config['sxls']
98
+ if sxls.nil?
99
+ config['sxls'] = [{ 'name' => 'tlc', 'version' => RSMP::Schema.latest_version(:tlc) }]
100
+ return
101
+ end
102
+
103
+ sxls.each do |sxl|
104
+ name = sxl['name']
105
+ abort_with_error 'SXL name cannot be core.' if name.to_s == 'core'
106
+
107
+ RSMP::Schema.find_schema! name, sxl['version'], lenient: true
108
+ rescue RSMP::Schema::UnknownSchemaError => e
109
+ abort_with_error "Unknown SXL #{name} #{sxl['version']}: #{e}"
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,129 @@
1
+ require 'yaml'
2
+ require_relative 'configuration/loader'
3
+ require_relative 'configuration/validation'
4
+ require_relative 'configuration/secrets'
5
+
6
+ module RSMP
7
+ module Validator
8
+ # Handles loading and validating validator configuration files.
9
+ module Configuration
10
+ include Loader
11
+ include Validation
12
+ include Secrets
13
+
14
+ def load_tester_config
15
+ config_path = get_config_path
16
+ raw_config = load_yaml_config!(
17
+ config_path,
18
+ using_message: "Using #{mode} config: #{config_path}",
19
+ missing_message: "#{mode.capitalize} config file #{config_path} is missing"
20
+ )
21
+
22
+ apply_env_overrides!(raw_config)
23
+ options = build_tester_options(raw_config, config_path)
24
+ apply_loaded_config(options)
25
+ validate_and_finalize_config!(config_path)
26
+ end
27
+
28
+ def apply_env_overrides!(raw_config)
29
+ raw_config['core_version'] = ENV['CORE_VERSION'] if ENV['CORE_VERSION']
30
+ raw_config['sxls'] = parse_sxls(ENV['SXLS']) if ENV['SXLS']
31
+ end
32
+
33
+ def validate_and_finalize_config!(config_path)
34
+ validate_mode_config!(config_path)
35
+ validate_components_config!
36
+ validate_timeouts_config!
37
+ normalize_core_version!
38
+ normalize_sxls!
39
+ load_secrets config_path
40
+ end
41
+
42
+ def load_auto_node_config
43
+ path = auto_node_config_path
44
+ return unless path
45
+
46
+ @log_stream.puts "Will run auto #{mode} with config: #{path}"
47
+ raw_config = load_yaml_config!(
48
+ path,
49
+ using_message: '',
50
+ missing_message: "Auto #{mode} config file #{path} is missing"
51
+ )
52
+ raw_config['sxls'] = parse_sxls(ENV['SXLS']) if ENV['SXLS']
53
+ options_class = auto_node_options_class_for(raw_config)
54
+ options = build_options_from_raw(raw_config, path, options_class)
55
+ self.auto_node_config = options.to_h
56
+ self.auto_node_log_settings = options.log_settings
57
+ end
58
+
59
+ def get_config_path(local: false)
60
+ mode_name = mode.to_s
61
+ config_path = get_config_path_from_env(mode_name) || get_config_path_from_validator_yaml(mode_name)
62
+ abort_with_error "#{mode_name.capitalize} config path not set" unless config_path && config_path != ''
63
+
64
+ config_path = File.expand_path(config_path) if local
65
+ config_path
66
+ end
67
+
68
+ def auto_node_config_path
69
+ env_key = mode == :site ? 'AUTO_SITE_CONFIG' : 'AUTO_SUPERVISOR_CONFIG'
70
+ env_path = ENV.fetch(env_key, nil)
71
+ return env_path if env_path && !env_path.empty?
72
+
73
+ ref_path = 'config/validator.yaml'
74
+ return nil unless File.exist? ref_path
75
+
76
+ config_ref = YAML.load_file ref_path
77
+ key = mode == :site ? 'auto_site' : 'auto_supervisor'
78
+ path = config_ref[key].to_s.strip
79
+ path.empty? ? nil : path
80
+ end
81
+
82
+ def get_config(*path, **options)
83
+ value = config.dig(*path)
84
+ return value if value
85
+
86
+ path_name = path.inspect
87
+ default = options[:default]
88
+ assume = options[:assume]
89
+ if default
90
+ warning "Config #{path_name} not found, using default: #{default}"
91
+ default
92
+ elsif assume
93
+ assume
94
+ else
95
+ raise "Config #{path_name} is missing"
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def get_config_path_from_env(mode_name)
102
+ key = "#{mode_name.upcase}_CONFIG"
103
+ ENV.fetch(key, nil)
104
+ end
105
+
106
+ def get_config_path_from_validator_yaml(mode_name)
107
+ ref_path = 'config/validator.yaml'
108
+ return nil unless File.exist? ref_path
109
+
110
+ config_ref = YAML.load_file ref_path
111
+ config_ref[mode_name].to_s.strip
112
+ end
113
+
114
+ def warning(message)
115
+ log "Warning: #{message}", level: :warning
116
+ end
117
+
118
+ def parse_sxls(value)
119
+ value.split(',').each_with_object({}) do |item, memo|
120
+ parts = item.split(':')
121
+ abort_with_error "Invalid SXLS item #{item.inspect}, expected name:version" unless parts.length == 2
122
+
123
+ name, version = parts
124
+ memo[name] = version
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,66 @@
1
+ module RSMP
2
+ module Validator
3
+ module Helpers
4
+ # Helper methods for testing RSMP alarm behaviour.
5
+ module Alarms
6
+ include Input
7
+
8
+ def with_alarm_activated(site_proxy, alarm_code_id, initial_deactivation: true, &block)
9
+ input, component_id = find_alarm_programming(alarm_code_id)
10
+ component_id ||= RSMP::Validator.get_config('main_component')
11
+ timeout = RSMP::Validator.get_config('timeouts', 'status_update')
12
+ force_input_and_confirm(site_proxy, input:, value: 'False', within: timeout) if initial_deactivation
13
+ run_alarm_lifecycle(site_proxy, alarm_code_id, component_id, input, &block)
14
+ end
15
+
16
+ private
17
+
18
+ def run_alarm_lifecycle(site_proxy, alarm_code_id, component_id, input)
19
+ specialization, alarm_active, alarm_inactive = build_alarm_matchers(site_proxy)
20
+ alarm_timeout = RSMP::Validator.get_config('timeouts', 'alarm')
21
+ status_timeout = RSMP::Validator.get_config('timeouts', 'status_update')
22
+ state = false
23
+ begin
24
+ matcher = { 'cId' => component_id, 'aCId' => alarm_code_id,
25
+ 'aSp' => specialization, 'aS' => alarm_active }
26
+ collect_task = start_alarm_collector(site_proxy, matcher, alarm_timeout)
27
+ force_input_and_confirm site_proxy, input:, value: 'True', within: status_timeout
28
+ state = true
29
+ yield collect_task.wait, component_id
30
+
31
+ matcher = { 'cId' => component_id, 'aCId' => alarm_code_id,
32
+ 'aSp' => /Issue/i, 'aS' => alarm_inactive }
33
+ collect_task = start_alarm_collector(site_proxy, matcher, alarm_timeout)
34
+ force_input_and_confirm site_proxy, input:, value: 'False', within: status_timeout
35
+ state = false
36
+ [collect_task.wait, component_id]
37
+ ensure
38
+ force_input_and_confirm(site_proxy, input:, value: 'False', within: status_timeout) if state == true
39
+ end
40
+ end
41
+
42
+ def build_alarm_matchers(site_proxy)
43
+ if RSMP::Proxy.version_meets_requirement? site_proxy.core_version, '>=3.2'
44
+ [/Issue/, /Active/, /inActive/]
45
+ else
46
+ [/issue/i, /active/i, /inactive/i]
47
+ end
48
+ end
49
+
50
+ def start_alarm_collector(site_proxy, matcher, timeout)
51
+ Async::Task.current.async do
52
+ collector = RSMP::AlarmCollector.new(site_proxy, num: 1, matcher: matcher, timeout: timeout)
53
+ collector.collect!
54
+ collector.messages.first
55
+ end
56
+ end
57
+
58
+ def find_alarm_programming(alarm_code_id)
59
+ action = RSMP::Validator.config.dig('alarm_triggers', alarm_code_id)
60
+ skip "Alarm trigger #{alarm_code_id} not configured" unless action
61
+ [action['input'], action['component']]
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,16 @@
1
+ module RSMP
2
+ module Validator
3
+ module Helpers
4
+ # Helper methods for testing RSMP clock functionality.
5
+ module Clock
6
+ def with_clock_set(site_proxy, clock, within:)
7
+ site_proxy.tlc.set_clock(clock, within:)
8
+ site_proxy.clear_alarm_timestamps
9
+ yield
10
+ ensure
11
+ site_proxy.tlc.set_clock(Time.now.utc, within:)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,73 @@
1
+ module RSMP
2
+ module Validator
3
+ module Helpers
4
+ # Helpers for connecting to a site or supervisor in tests, with optional
5
+ # version-based skipping via sxl: and core: keyword arguments.
6
+ #
7
+ # with_site(:connected, sxl: '>=1.2') do |supervisor, site_proxy|
8
+ # ...
9
+ # end
10
+ #
11
+ # with_supervisor(:connected, core: '>=3.2') do |site, supervisor_proxy|
12
+ # ...
13
+ # end
14
+ module Connection
15
+ VALID_STATES = %i[connected reconnected isolated disconnected].freeze
16
+
17
+ # Wraps an uncaught exception from a test block, preserving the original
18
+ # class name, message, and backtrace for clearer test failure output.
19
+ class UncaughtException < StandardError
20
+ def initialize(original)
21
+ super("#{original.class}: #{original.message}")
22
+ set_backtrace(original.backtrace)
23
+ end
24
+ end
25
+
26
+ def with_site(state, sxl: nil, core: nil, **opts, &block)
27
+ validate_state!(state)
28
+ check_version_requirements(sxl, core)
29
+ if state == :disconnected
30
+ RSMP::Validator::SiteTester.disconnected { block.call }
31
+ else
32
+ RSMP::Validator::SiteTester.public_send(state, **opts) do |_task, _node, proxy|
33
+ block.call(proxy)
34
+ rescue RSMP::TimeoutError => e
35
+ @__assertions__.assert false, e.message
36
+ rescue StandardError => e
37
+ @__assertions__.error!(UncaughtException.new(e))
38
+ end
39
+ end
40
+ end
41
+
42
+ def with_supervisor(state, sxl: nil, core: nil, **opts, &block)
43
+ validate_state!(state)
44
+ check_version_requirements(sxl, core)
45
+ if state == :disconnected
46
+ RSMP::Validator::SupervisorTester.disconnected { block.call }
47
+ else
48
+ RSMP::Validator::SupervisorTester.public_send(state, **opts) do |_task, _node, proxy|
49
+ block.call(proxy)
50
+ rescue RSMP::TimeoutError => e
51
+ @__assertions__.assert false, e.message
52
+ rescue StandardError => e
53
+ @__assertions__.error!(UncaughtException.new(e))
54
+ end
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def validate_state!(state)
61
+ return if VALID_STATES.include?(state)
62
+
63
+ raise ArgumentError, "Unknown state #{state.inspect}, must be one of #{VALID_STATES}"
64
+ end
65
+
66
+ def check_version_requirements(sxl, core)
67
+ skip "requires sxl #{sxl}" unless sxl.nil? || RSMP::Validator.sxl_matches?(sxl)
68
+ skip "requires core #{core}" unless core.nil? || RSMP::Validator.core_matches?(core)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end