spec_forge 0.4.0 → 0.6.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.standard.yml +4 -0
  3. data/CHANGELOG.md +145 -1
  4. data/README.md +49 -638
  5. data/flake.lock +3 -3
  6. data/flake.nix +8 -2
  7. data/lib/spec_forge/attribute/chainable.rb +208 -20
  8. data/lib/spec_forge/attribute/factory.rb +141 -12
  9. data/lib/spec_forge/attribute/faker.rb +64 -15
  10. data/lib/spec_forge/attribute/global.rb +96 -0
  11. data/lib/spec_forge/attribute/literal.rb +15 -2
  12. data/lib/spec_forge/attribute/matcher.rb +188 -13
  13. data/lib/spec_forge/attribute/parameterized.rb +45 -20
  14. data/lib/spec_forge/attribute/regex.rb +55 -5
  15. data/lib/spec_forge/attribute/resolvable.rb +48 -5
  16. data/lib/spec_forge/attribute/resolvable_array.rb +62 -4
  17. data/lib/spec_forge/attribute/resolvable_hash.rb +62 -4
  18. data/lib/spec_forge/attribute/store.rb +65 -0
  19. data/lib/spec_forge/attribute/transform.rb +33 -5
  20. data/lib/spec_forge/attribute/variable.rb +37 -6
  21. data/lib/spec_forge/attribute.rb +168 -66
  22. data/lib/spec_forge/backtrace_formatter.rb +26 -3
  23. data/lib/spec_forge/callbacks.rb +79 -0
  24. data/lib/spec_forge/cli/actions.rb +27 -0
  25. data/lib/spec_forge/cli/command.rb +78 -24
  26. data/lib/spec_forge/cli/init.rb +11 -1
  27. data/lib/spec_forge/cli/new.rb +54 -3
  28. data/lib/spec_forge/cli/run.rb +20 -0
  29. data/lib/spec_forge/cli.rb +16 -5
  30. data/lib/spec_forge/configuration.rb +94 -25
  31. data/lib/spec_forge/context/callbacks.rb +91 -0
  32. data/lib/spec_forge/context/global.rb +72 -0
  33. data/lib/spec_forge/context/store.rb +148 -0
  34. data/lib/spec_forge/context/variables.rb +91 -0
  35. data/lib/spec_forge/context.rb +36 -0
  36. data/lib/spec_forge/core_ext/rspec.rb +24 -4
  37. data/lib/spec_forge/error.rb +267 -113
  38. data/lib/spec_forge/factory.rb +33 -14
  39. data/lib/spec_forge/filter.rb +87 -0
  40. data/lib/spec_forge/forge.rb +170 -0
  41. data/lib/spec_forge/http/backend.rb +99 -29
  42. data/lib/spec_forge/http/client.rb +23 -13
  43. data/lib/spec_forge/http/request.rb +74 -62
  44. data/lib/spec_forge/http/verb.rb +79 -0
  45. data/lib/spec_forge/http.rb +105 -0
  46. data/lib/spec_forge/loader.rb +254 -0
  47. data/lib/spec_forge/matchers.rb +130 -0
  48. data/lib/spec_forge/normalizer/configuration.rb +24 -11
  49. data/lib/spec_forge/normalizer/constraint.rb +22 -9
  50. data/lib/spec_forge/normalizer/expectation.rb +31 -12
  51. data/lib/spec_forge/normalizer/factory.rb +24 -11
  52. data/lib/spec_forge/normalizer/factory_reference.rb +32 -13
  53. data/lib/spec_forge/normalizer/global_context.rb +88 -0
  54. data/lib/spec_forge/normalizer/spec.rb +39 -16
  55. data/lib/spec_forge/normalizer.rb +255 -41
  56. data/lib/spec_forge/runner/callbacks.rb +246 -0
  57. data/lib/spec_forge/runner/debug_proxy.rb +213 -0
  58. data/lib/spec_forge/runner/listener.rb +54 -0
  59. data/lib/spec_forge/runner/metadata.rb +58 -0
  60. data/lib/spec_forge/runner/state.rb +99 -0
  61. data/lib/spec_forge/runner.rb +133 -119
  62. data/lib/spec_forge/spec/expectation/constraint.rb +95 -20
  63. data/lib/spec_forge/spec/expectation.rb +43 -51
  64. data/lib/spec_forge/spec.rb +83 -96
  65. data/lib/spec_forge/type.rb +36 -4
  66. data/lib/spec_forge/version.rb +4 -1
  67. data/lib/spec_forge.rb +161 -76
  68. metadata +20 -5
  69. data/spec_forge/factories/user.yml +0 -4
  70. data/spec_forge/forge_helper.rb +0 -37
  71. data/spec_forge/specs/users.yml +0 -65
@@ -1,150 +1,304 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpecForge
4
- # Pass into to_sentence
5
- OR_CONNECTOR = {
6
- last_word_connector: ", or ",
7
- two_words_connector: " or ",
8
- # This is a minor performance improvement to avoid locales being loaded
9
- # This will need to be removed if locales are added
10
- locale: false
11
- }.freeze
12
-
13
- private_constant :OR_CONNECTOR
14
-
15
- class Error < StandardError; end
16
-
17
4
  #
18
- # Raised by Attribute::Faker when a provided classname does not exist in Faker
5
+ # Base error class for all SpecForge-specific exceptions
19
6
  #
20
- class InvalidFakerClassError < Error
21
- CLASS_CHECKER = DidYouMean::SpellChecker.new(
22
- dictionary: Faker::Base.descendants.map { |c| c.to_s.downcase.gsub!("::", ".") }
23
- )
7
+ class Error < StandardError
8
+ # Pass into to_sentence
9
+ OR_CONNECTOR = {
10
+ last_word_connector: ", or ",
11
+ two_words_connector: " or ",
12
+ # This is a minor performance improvement to avoid locales being loaded
13
+ # This will need to be removed if locales are added
14
+ locale: false
15
+ }.freeze
24
16
 
25
- def initialize(input)
26
- corrections = CLASS_CHECKER.correct(input)
17
+ private_constant :OR_CONNECTOR
27
18
 
28
- super(<<~STRING.chomp
29
- Undefined Faker class "#{input}". #{DidYouMean::Formatter.message_for(corrections)}
30
-
31
- For available classes, please check https://github.com/faker-ruby/faker#generators.
32
- STRING
19
+ #
20
+ # Raised when a provided Faker class name doesn't exist
21
+ # Provides helpful suggestions for similar class names
22
+ #
23
+ # @example
24
+ # Attribute::Faker.new("faker.invalid.method")
25
+ # # => InvalidFakerClassError: Undefined Faker class "invalid". Did you mean? name, games, ...
26
+ #
27
+ class InvalidFakerClassError < Error
28
+ #
29
+ # A spell checker for Faker classes
30
+ #
31
+ # @return [DidYouMean::SpellChecker]
32
+ #
33
+ CLASS_CHECKER = DidYouMean::SpellChecker.new(
34
+ dictionary: Faker::Base.descendants.map { |c| c.to_s.downcase.gsub!("::", ".") }
33
35
  )
36
+
37
+ def initialize(input)
38
+ corrections = CLASS_CHECKER.correct(input)
39
+
40
+ super(<<~STRING.chomp
41
+ Undefined Faker class "#{input}". #{DidYouMean::Formatter.message_for(corrections)}
42
+
43
+ For available classes, please check https://github.com/faker-ruby/faker#generators.
44
+ STRING
45
+ )
46
+ end
34
47
  end
35
- end
36
48
 
37
- #
38
- # Raised by Attribute::Faker when a provided method for a Faker class does not exist.
39
- #
40
- class InvalidFakerMethodError < Error
41
- def initialize(input, klass)
42
- spell_checker = DidYouMean::SpellChecker.new(dictionary: klass.public_methods)
43
- corrections = spell_checker.correct(input)
49
+ #
50
+ # Raised when a provided method for a Faker class doesn't exist
51
+ # Provides helpful suggestions for similar method names
52
+ #
53
+ # @example
54
+ # Attribute::Faker.new("faker.name.invlaid")
55
+ # # => InvalidFakerMethodError: Undefined Faker method "invlaid" for "Faker::Name".
56
+ # Did you mean? first_name, last_name, ...
57
+ #
58
+ class InvalidFakerMethodError < Error
59
+ def initialize(input, klass)
60
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: klass.public_methods)
61
+ corrections = spell_checker.correct(input)
44
62
 
45
- super(<<~STRING.chomp
46
- Undefined Faker method "#{input}" for "#{klass}". #{DidYouMean::Formatter.message_for(corrections)}
63
+ super(<<~STRING.chomp
64
+ Undefined Faker method "#{input}" for "#{klass}". #{DidYouMean::Formatter.message_for(corrections)}
47
65
 
48
- For available methods for this class, please check https://github.com/faker-ruby/faker#generators.
49
- STRING
50
- )
66
+ For available methods for this class, please check https://github.com/faker-ruby/faker#generators.
67
+ STRING
68
+ )
69
+ end
51
70
  end
52
- end
53
71
 
54
- #
55
- # Raised by Attribute::Transform when the provided transform function is not valid
56
- #
57
- class InvalidTransformFunctionError < Error
58
- def initialize(input)
59
- # TODO: Update link to docs
60
- super(<<~STRING.chomp
61
- Undefined transform function "#{input}".
62
-
63
- For available functions, please check https://github.com/itsthedevman/spec_forge.
64
- STRING
65
- )
72
+ #
73
+ # Raised when an unknown transform function is referenced
74
+ # Indicates when a transform name isn't supported
75
+ #
76
+ class InvalidTransformFunctionError < Error
77
+ def initialize(input)
78
+ # TODO: Update link to docs
79
+ super(<<~STRING.chomp
80
+ Undefined transform function "#{input}".
81
+
82
+ For available functions, please check https://github.com/itsthedevman/spec_forge.
83
+ STRING
84
+ )
85
+ end
66
86
  end
67
- end
68
87
 
69
- #
70
- # Raised by Attribute::Chainable when an step in the invocation chain is invalid
71
- #
72
- class InvalidInvocationError < Error
73
- def initialize(step, object)
74
- valid_operations =
75
- case object
76
- when ArrayLike
77
- "Array index (0, 1, 2, etc.) or any Array methods (first, last, size, etc.)"
78
- when HashLike
79
- "Any Hash key: #{object.keys.join(", ")}"
80
- else
81
- "Any method available on #{object.class}"
88
+ #
89
+ # Raised when a step in an invocation chain is invalid
90
+ # Provides detailed information about where in the chain the error occurred
91
+ #
92
+ # @example
93
+ # variable_attr = Attribute::Variable.new("variables.user.invalid_method")
94
+ # variable_attr.resolved
95
+ # # => InvalidInvocationError: Cannot invoke "invalid_method" on User
96
+ #
97
+ class InvalidInvocationError < Error
98
+ def initialize(step, object, resolution_path = {})
99
+ @step = step
100
+ @object = object
101
+ @resolution_path = resolution_path
102
+
103
+ object_class =
104
+ case object
105
+ when Data
106
+ object.class.name || "Data"
107
+ when Struct
108
+ object.class.name || "Struct"
109
+ else
110
+ object.class
111
+ end
112
+
113
+ super(<<~STRING.chomp
114
+ Cannot invoke "#{step}" on #{object_class}
115
+ #{resolution_path_message}
116
+ STRING
117
+ )
118
+ end
119
+
120
+ #
121
+ # Creates a new InvalidInvocationError with a new resolution path
122
+ #
123
+ # @param path [Hash] The steps taken up until this point
124
+ #
125
+ def with_resolution_path(path)
126
+ self.class.new(@step, @object, path)
127
+ end
128
+
129
+ private
130
+
131
+ def resolution_path_message
132
+ return "" if @resolution_path.empty?
133
+
134
+ message =
135
+ @resolution_path.map.with_index do |(path, description), index|
136
+ "#{index + 1}. #{path} --> #{description}"
137
+ end.join("\n")
138
+
139
+ "\nResolution path:\n#{message}"
140
+ end
141
+ end
142
+
143
+ #
144
+ # An extended version of TypeError with better error messages
145
+ # Makes it easier to understand type mismatches in the codebase
146
+ #
147
+ # @example
148
+ # raise Error::InvalidTypeError.new(123, String, for: "name parameter")
149
+ # # => Expected String, got Integer for name parameter
150
+ #
151
+ class InvalidTypeError < Error
152
+ def initialize(object, expected_type, **opts)
153
+ if expected_type.instance_of?(Array)
154
+ expected_type = expected_type.to_sentence(**OR_CONNECTOR)
82
155
  end
83
156
 
84
- super(<<~STRING.chomp
85
- Cannot invoke "#{step}" on #{object.class}.
157
+ message = "Expected #{expected_type}, got #{object.class}"
158
+ message += " for #{opts[:for]}" if opts[:for].present?
86
159
 
87
- Valid operations include: #{valid_operations}
88
- STRING
89
- )
160
+ super(message)
161
+ end
90
162
  end
91
- end
92
163
 
93
- #
94
- # An extended version of TypeError to make things easier when reporting invalid types
95
- #
96
- class InvalidTypeError < Error
97
- def initialize(object, expected_type, **opts)
98
- if expected_type.instance_of?(Array)
99
- expected_type = expected_type.to_sentence(**OR_CONNECTOR)
164
+ #
165
+ # Raised when a variable reference cannot be resolved
166
+ # Indicates when a spec or expectation references an undefined variable
167
+ #
168
+ class MissingVariableError < Error
169
+ def initialize(variable_name)
170
+ super("Undefined variable \"#{variable_name}\" referenced in expectation")
100
171
  end
172
+ end
101
173
 
102
- message = "Expected #{expected_type}, got #{object.class}"
103
- message += " for #{opts[:for]}" if opts[:for].present?
174
+ #
175
+ # Raised when a YAML structure doesn't match expectations
176
+ # Acts as a container for multiple validation errors
177
+ #
178
+ class InvalidStructureError < Error
179
+ def initialize(errors)
180
+ message = errors.to_a.join_map("\n") do |error|
181
+ next error if error.is_a?(SpecForge::Error)
104
182
 
105
- super(message)
183
+ # Normal errors, let's get verbose
184
+ backtrace = SpecForge.backtrace_cleaner.clean(error.backtrace)
185
+ "#{error.inspect}\n # ./#{backtrace.join("\n # ./")}\n"
186
+ end
187
+
188
+ super(message)
189
+ end
106
190
  end
107
- end
108
191
 
109
- #
110
- # Raised by Attribute::Variable when the provided variable name is not defined
111
- #
112
- class MissingVariableError < Error
113
- def initialize(variable_name)
114
- super("Undefined variable \"#{variable_name}\" referenced in expectation")
192
+ #
193
+ # Raised when an unknown factory build strategy is provided
194
+ # Indicates when a strategy string doesn't match supported options
195
+ #
196
+ class InvalidBuildStrategy < Error
197
+ def initialize(build_strategy)
198
+ valid_strategies = Attribute::Factory::BUILD_STRATEGIES.to_sentence(**OR_CONNECTOR)
199
+
200
+ super(<<~STRING.chomp
201
+ Unknown build strategy "#{build_strategy}" referenced in spec.
202
+
203
+ Valid strategies include: #{valid_strategies}
204
+ STRING
205
+ )
206
+ end
115
207
  end
116
- end
117
208
 
118
- #
119
- # Raised by Normalizer when any errors are returned. Acts like a grouping of errors
120
- #
121
- class InvalidStructureError < Error
122
- def initialize(errors)
123
- message = errors.to_a.join_map("\n") do |error|
124
- next error if error.is_a?(SpecForge::Error)
125
-
126
- # Normal errors, let's get verbose
127
- backtrace = SpecForge.backtrace_cleaner.clean(error.backtrace)
128
- "#{error.inspect}\n # ./#{backtrace.join("\n # ./")}\n"
209
+ #
210
+ # Raised when a spec file cannot be loaded
211
+ # Provides detailed information about the cause of the loading error
212
+ #
213
+ class SpecLoadError < Error
214
+ def initialize(error, file_path, spec: nil)
215
+ message =
216
+ if spec
217
+ "Error loading spec #{spec[:name].in_quotes} in file #{file_path.in_quotes} (line #{spec[:line_number]})"
218
+ else
219
+ "Error loading spec file #{file_path.in_quotes}"
220
+ end
221
+
222
+ causes = error.message.split("\n").map(&:strip).reject(&:empty?)
223
+
224
+ message +=
225
+ if causes.size > 1
226
+ "\nCauses:\n - #{causes.join_map("\n - ")}"
227
+ else
228
+ "\nCause: #{error}"
229
+ end
230
+
231
+ super(message)
129
232
  end
233
+ end
130
234
 
131
- super(message)
235
+ #
236
+ # Raised when the provided namespace is not defined on the global context
237
+ #
238
+ class InvalidGlobalNamespaceError < Error
239
+ def initialize(provided_namespace)
240
+ super("Invalid global namespace #{provided_namespace.in_quotes}. Currently supported namespaces are: \"variables\"")
241
+ end
132
242
  end
133
- end
134
243
 
135
- #
136
- # Raised by Attribute::Factory when an unknown build strategy is provided
137
- #
138
- class InvalidBuildStrategy < Error
139
- def initialize(build_strategy)
140
- valid_strategies = Attribute::Factory::BUILD_STRATEGIES.to_sentence(**OR_CONNECTOR)
244
+ #
245
+ # Raised when the provided matcher name does not defined with RSpec
246
+ #
247
+ class UndefinedMatcherError < Error
248
+ def initialize(matcher_name)
249
+ matcher_categories = {
250
+ Equality: ["matcher.eq", "matcher.eql", "matcher.equal"],
251
+ Types: ["kind_of.string", "kind_of.integer", "kind_of.array", "kind_of.hash"],
252
+ Truthiness: ["be.true", "be.false", "be.nil"],
253
+ Comparison: ["be.within", "be.between", "be.greater_than", "be.less_than"],
254
+ Collections: ["matcher.include", "matcher.contain_exactly", "matcher.all"],
255
+ Strings: ["/regex/", "matcher.start_with", "matcher.end_with"]
256
+ }
141
257
 
142
- super(<<~STRING.chomp
143
- Unknown build strategy "#{build_strategy}" referenced in spec.
258
+ formatted_categories =
259
+ matcher_categories.join_map("\n") do |category, matchers|
260
+ " #{category}: #{matchers.join(", ")}"
261
+ end
144
262
 
145
- Valid strategies include: #{valid_strategies}
146
- STRING
147
- )
263
+ super(<<~STRING.chomp
264
+ Undefined matcher method "#{matcher_name}" is not available in RSpec matchers.
265
+
266
+ Common matchers you can use:
267
+ #{formatted_categories}
268
+
269
+ For the complete list of available matchers, check the RSpec documentation:
270
+ https://rspec.info/documentation/3.12/rspec-expectations/RSpec/Matchers.html
271
+ STRING
272
+ )
273
+ end
274
+ end
275
+
276
+ #
277
+ # Raised when a callback is referenced in config but hasn't been defined
278
+ #
279
+ class UndefinedCallbackError < Error
280
+ def initialize(callback_name, available_callbacks = [])
281
+ message = "The callback #{callback_name.in_quotes} was referenced but hasn't been defined."
282
+
283
+ message +=
284
+ if available_callbacks.any?
285
+ <<~STR.chomp
286
+
287
+ Available callbacks are: #{available_callbacks.join_map(", ", &:in_quotes)}
288
+ STR
289
+ else
290
+ <<~STR.chomp
291
+
292
+ No callbacks have been defined yet. Register callbacks with:
293
+
294
+ SpecForge.register_callback(:#{callback_name}) do |context|
295
+ # Your callback code
296
+ end
297
+ STR
298
+ end
299
+
300
+ super(message)
301
+ end
148
302
  end
149
303
  end
150
304
  end
@@ -1,9 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpecForge
4
+ #
5
+ # Manages factory definitions and registration with FactoryBot
6
+ # Provides methods for loading factories from YAML files
7
+ #
4
8
  class Factory
5
9
  #
6
- # Loads the factories from their yml files and registers them with FactoryBot
10
+ # Loads factories from files and registers them with FactoryBot
11
+ # Sets up paths and loads definitions based on configuration
7
12
  #
8
13
  def self.load_and_register
9
14
  if SpecForge.configuration.factories.paths?
@@ -17,14 +22,13 @@ module SpecForge
17
22
  end
18
23
 
19
24
  #
20
- # Loads any factories defined in the factories. A single file can contain one or more factories
25
+ # Loads factory definitions from YAML files
26
+ # Creates Factory instances but doesn't register them with FactoryBot
21
27
  #
22
- # @return [Array<Factory>] An array of factories that were loaded.
23
- # Note: This factories have not been registered with FactoryBot.
24
- # See #register
28
+ # @return [Array<Factory>] Array of loaded factory instances
25
29
  #
26
30
  def self.load_from_files
27
- path = SpecForge.forge.join("factories", "**/*.yml")
31
+ path = SpecForge.forge_path.join("factories", "**/*.yml")
28
32
 
29
33
  factories = []
30
34
 
@@ -43,13 +47,28 @@ module SpecForge
43
47
 
44
48
  ############################################################################
45
49
 
46
- attr_reader :name, :input, :model_class, :variables, :attributes
50
+ # @return [Symbol, String] The name of the factory
51
+ attr_reader :name
47
52
 
53
+ # @return [Hash] The raw input that defined this factory
54
+ attr_reader :input
55
+
56
+ # @return [String, nil] The model class name this factory represents, if specified
57
+ attr_reader :model_class
58
+
59
+ # @return [Hash<Symbol, Attribute>] Variables defined for this factory
60
+ attr_reader :variables
61
+
62
+ # @return [Hash<Symbol, Attribute>] The attributes that define this factory
63
+ attr_reader :attributes
64
+
65
+ #
66
+ # Creates a new Factory instance
48
67
  #
49
- # Creates a new Factory
68
+ # @param name [String, Symbol] The name of the factory
69
+ # @param input [Hash] The attributes defining the factory
50
70
  #
51
- # @param name [String] The name of the factory
52
- # @param **input [Hash] Attributes to define the factory. See Normalizer::Factory
71
+ # @return [Factory] A new factory instance
53
72
  #
54
73
  def initialize(name:, **input)
55
74
  @name = name
@@ -63,10 +82,10 @@ module SpecForge
63
82
  end
64
83
 
65
84
  #
66
- # Registers this factory with FactoryBot.
67
- # Once registered, you can call FactoryBot.build and other methods
85
+ # Registers this factory with FactoryBot
86
+ # Makes the factory available for use in specs
68
87
  #
69
- # @return [Self]
88
+ # @return [self] Returns self for method chaining
70
89
  #
71
90
  def register
72
91
  dsl = FactoryBot::Syntax::Default::DSL.new
@@ -78,7 +97,7 @@ module SpecForge
78
97
  factory_forge = self
79
98
  dsl.factory(name, options) do
80
99
  factory_forge.attributes.each do |name, attribute|
81
- add_attribute(name) { attribute.resolve_value }
100
+ add_attribute(name) { attribute.resolve }
82
101
  end
83
102
  end
84
103
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ #
5
+ # Provides filtering capabilities for test suites based on different criteria
6
+ #
7
+ # The Filter class allows running specific tests by filtering forges, specs,
8
+ # and expectations based on file name, spec name, and expectation name.
9
+ #
10
+ # @example Filtering specs by name
11
+ # forges = Loader.load_from_files
12
+ # filtered = Filter.apply(forges, file_name: "users", spec_name: "create_user")
13
+ #
14
+ class Filter
15
+ class << self
16
+ #
17
+ # Prints out a message if any of the filters were used
18
+ #
19
+ # @param forges [Array<Forge>] The collection of forges that was filtered
20
+ # @param file_name [String, nil] Optional file name that was used by the filter
21
+ # @param spec_name [String, nil] Optional spec name that was used by the filter
22
+ # @param expectation_name [String, nil] Optional expectation name that was used by the filter
23
+ #
24
+ def announce(forges, file_name:, spec_name:, expectation_name:)
25
+ filters = {file_name:, spec_name:, expectation_name:}.reject { |k, v| v.blank? }
26
+ return if filters.size == 0
27
+
28
+ filters_display = filters.join_map(", ") { |k, v| "#{k.in_quotes} => #{v.in_quotes}" }
29
+
30
+ expectation_count = forges.sum do |forge|
31
+ forge.specs.sum { |spec| spec.expectations.size }
32
+ end
33
+
34
+ puts "Applied filter #{filters_display}"
35
+ puts "Found #{expectation_count} #{"expectation".pluralize(expectation_count)}"
36
+ end
37
+
38
+ #
39
+ # Filters a collection of forges based on specified criteria
40
+ #
41
+ # This method allows running specific tests by filtering forges, specs,
42
+ # and expectations based on file name, spec name, and expectation name.
43
+ # It returns only the forges, specs, and expectations that match the criteria.
44
+ #
45
+ # @param forges [Array<Forge>] The collection of forges to filter
46
+ # @param file_name [String, nil] Optional file name to filter by
47
+ # @param spec_name [String, nil] Optional spec name to filter by
48
+ # @param expectation_name [String, nil] Optional expectation name to filter by
49
+ #
50
+ # @return [Array<Forge>] The filtered collection of forges
51
+ #
52
+ # @raise [ArgumentError] If filtering parameters are provided in an invalid combination
53
+ #
54
+ def apply(forges, file_name: nil, spec_name: nil, expectation_name: nil)
55
+ # Guard against invalid partial filters
56
+ if expectation_name && spec_name.blank?
57
+ raise ArgumentError, "The spec's name is required when filtering by an expectation's name"
58
+ end
59
+
60
+ if spec_name && file_name.blank?
61
+ raise ArgumentError, "The spec's filename is required when filtering by a spec's name"
62
+ end
63
+
64
+ forges.filter_map do |forge|
65
+ specs = forge.specs.filter_map do |spec|
66
+ next if file_name && spec.file_name != file_name # File filter
67
+ next if spec_name && spec.name != spec_name # Name filter
68
+
69
+ # Expectation filter
70
+ next spec unless expectation_name
71
+
72
+ expectations = spec.expectations.select { |e| e.name == expectation_name }
73
+ next if expectations.empty?
74
+
75
+ spec.expectations = expectations
76
+ spec
77
+ end
78
+
79
+ next if specs.empty?
80
+
81
+ forge.specs = specs
82
+ forge
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end