spec_forge 0.5.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 +3 -3
  3. data/CHANGELOG.md +106 -1
  4. data/README.md +34 -22
  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 +91 -14
  9. data/lib/spec_forge/attribute/faker.rb +62 -13
  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 +186 -11
  13. data/lib/spec_forge/attribute/parameterized.rb +45 -12
  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 +166 -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 -22
  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 +22 -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 +21 -8
  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 +27 -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 +132 -123
  62. data/lib/spec_forge/spec/expectation/constraint.rb +91 -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 -48
  71. data/spec_forge/specs/users.yml +0 -65
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Context
5
+ #
6
+ # Manages variable resolution across different expectations in SpecForge tests.
7
+ #
8
+ # The Variables class handles two layers of variable definitions:
9
+ # - Base variables: The core set of variables defined at the spec level
10
+ # - Overlay variables: Additional variables defined at the expectation level
11
+ # that can override base variables with the same name.
12
+ #
13
+ # @example Basic usage
14
+ # variables = Variables.new(
15
+ # base: {user_id: 123, name: "Test User"},
16
+ # overlay: {
17
+ # "expectation_1": {name: "Override User"}
18
+ # }
19
+ # )
20
+ #
21
+ # variables[:user_id] #=> 123
22
+ # variables[:name] #=> "Test User"
23
+ #
24
+ # variables.use_overlay("expectation_1")
25
+ # variables[:name] #=> "Override User"
26
+ # variables[:user_id] #=> 123 (unchanged)
27
+ #
28
+ class Variables < Hash
29
+ attr_reader :base, :overlay
30
+
31
+ #
32
+ # Creates a new Variables container with base and overlay definitions
33
+ #
34
+ # @param base [Hash] The base set of variables (typically defined at spec level)
35
+ # @param overlay [Hash<String, Hash>] A hash of overlay variable sets keyed by ID
36
+ #
37
+ # @return [Variables]
38
+ #
39
+ def initialize(base: {}, overlay: {})
40
+ set(base:, overlay:)
41
+ end
42
+
43
+ #
44
+ # Sets the base and overlay variable hashes
45
+ #
46
+ # @param base [Hash] The new base variable hash
47
+ # @param overlay [Hash<String, Hash>] The new overlay variable hashes
48
+ #
49
+ # @return [self]
50
+ #
51
+ def set(base:, overlay: {})
52
+ @base = Attribute.from(base)
53
+ @overlay = overlay
54
+
55
+ resolve_into_self(@base)
56
+ self
57
+ end
58
+
59
+ #
60
+ # Applies a specific overlay to the base variables
61
+ # If the overlay doesn't exist or is empty, no changes are made.
62
+ #
63
+ # @param id [String] The ID of the overlay to apply
64
+ #
65
+ # @return [nil]
66
+ #
67
+ def use_overlay(id)
68
+ active = @base
69
+
70
+ if (overlay = @overlay[id]) && overlay.present?
71
+ active = active.deep_merge(overlay)
72
+ end
73
+
74
+ resolve_into_self(active)
75
+ self
76
+ end
77
+
78
+ private
79
+
80
+ def resolve_into_self(hash)
81
+ # Start fresh
82
+ clear
83
+
84
+ # Load the resolved values into self
85
+ hash.each do |key, value|
86
+ self[key] = Attribute.from(value).resolved
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ #
5
+ # Core data structure that maintains context during test execution
6
+ #
7
+ # Context stores and provides access to global variables, test variables, and
8
+ # shared state across specs.
9
+ # It acts as a central repository for test data during execution.
10
+ #
11
+ # @example Accessing the current context
12
+ # SpecForge.context.variables[:user_id] #=> 123
13
+ #
14
+ class Context < Data.define(:global, :store, :variables)
15
+ #
16
+ # Creates a new context with default values
17
+ #
18
+ # @param global [Hash] Global variables shared across all specs
19
+ # @param variables [Hash] Test variables specific to the current context
20
+ #
21
+ # @return [Context] A new context instance
22
+ #
23
+ def initialize(global: {}, variables: {})
24
+ super(
25
+ global: Global.new(**global),
26
+ store: Store.new,
27
+ variables: Variables.new(**variables)
28
+ )
29
+ end
30
+ end
31
+ end
32
+
33
+ require_relative "context/callbacks"
34
+ require_relative "context/global"
35
+ require_relative "context/store"
36
+ require_relative "context/variables"
@@ -2,19 +2,37 @@
2
2
 
3
3
  return if defined?(SPEC_FORGE_INTERNAL_TESTING)
4
4
 
5
+ #
6
+ # RSpec's core testing framework module
7
+ # Provides the fundamental structure and functionality for RSpec tests
8
+ #
5
9
  module RSpec
10
+ #
11
+ # Core implementation details and extensions for RSpec
12
+ # Contains the fundamental building blocks of the RSpec testing framework
13
+ #
6
14
  module Core
15
+ #
16
+ # Handles notifications and reporting for RSpec test runs
17
+ # Manages how test results and metadata are processed and communicated
18
+ #
7
19
  module Notifications
8
20
  #
9
- # I did attempt to do this without monkey patching
10
- # Getting around the `rspec` word was making it difficult
21
+ # A monkey patch of an internal RSpec class to allow SpecForge to replace parts of
22
+ # RSpec's reporting output in order to provide useful feedback to the user.
23
+ # This replaces "rspec" in commands with "spec_forge", removes any line numbers, and
24
+ # ensures that failures properly report the YAML file that it occurred in.
11
25
  #
12
26
  class SummaryNotification
27
+ #
28
+ # Create an alias to RSpec original colorized_rerun_commands so it can be called at a
29
+ # later point.
30
+ #
31
+ alias_method :og_colorized_rerun_commands, :colorized_rerun_commands
32
+
13
33
  # Customizes RSpec's failure output to:
14
34
  # 1. Use 'spec_forge' instead of 'rspec' for rerun commands
15
35
  # 2. Remove line numbers since SpecForge uses dynamic spec generation
16
- alias_method :og_colorized_rerun_commands, :colorized_rerun_commands
17
-
18
36
  def colorized_rerun_commands(colorizer)
19
37
  # Updating these at this point fixes the re-run for some failures - it depends
20
38
  failed_examples.each do |example|
@@ -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