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
@@ -2,33 +2,112 @@
2
2
 
3
3
  module SpecForge
4
4
  module HTTP
5
+ #
6
+ # Represents an HTTP verb (method)
7
+ #
8
+ # This class provides a type-safe way to work with HTTP methods,
9
+ # with predefined constants for common verbs like GET, POST, etc.
10
+ #
11
+ # @example Using predefined verbs
12
+ # HTTP::Verb::GET # => #<HTTP::Verb::Get @name="GET">
13
+ # HTTP::Verb::POST # => #<HTTP::Verb::Post @name="POST">
14
+ #
15
+ # @example Checking verb types
16
+ # verb = HTTP::Verb::POST
17
+ # verb.post? # => true
18
+ # verb.get? # => false
19
+ #
5
20
  class Verb < Data.define(:name)
21
+ #
22
+ # Represents the HTTP DELETE method
23
+ #
24
+ # @return [Delete] A DELETE verb instance
25
+ #
6
26
  class Delete < Verb
7
27
  def initialize = super(name: "DELETE")
8
28
  end
9
29
 
30
+ #
31
+ # Represents the HTTP GET method
32
+ #
33
+ # @return [Get] A GET verb instance
34
+ #
10
35
  class Get < Verb
11
36
  def initialize = super(name: "GET")
12
37
  end
13
38
 
39
+ #
40
+ # Represents the HTTP PATCH method
41
+ #
42
+ # @return [Patch] A PATCH verb instance
43
+ #
14
44
  class Patch < Verb
15
45
  def initialize = super(name: "PATCH")
16
46
  end
17
47
 
48
+ #
49
+ # Represents the HTTP POST method
50
+ #
51
+ # @return [Post] A POST verb instance
52
+ #
18
53
  class Post < Verb
19
54
  def initialize = super(name: "POST")
20
55
  end
21
56
 
57
+ #
58
+ # Represents the HTTP PUT method
59
+ #
60
+ # @return [Put] A PUT verb instance
61
+ #
22
62
  class Put < Verb
23
63
  def initialize = super(name: "PUT")
24
64
  end
25
65
 
66
+ #
67
+ # A predefined DELETE verb instance for HTTP method usage
68
+ #
69
+ # @return [Verb::Delete] A singleton instance representing the HTTP DELETE method
70
+ # @see Verb
71
+ #
26
72
  DELETE = Delete.new
73
+
74
+ #
75
+ # A predefined GET verb instance for HTTP method usage
76
+ #
77
+ # @return [Verb::Get] A singleton instance representing the HTTP GET method
78
+ # @see Verb
79
+ #
27
80
  GET = Get.new
81
+
82
+ #
83
+ # A predefined PATCH verb instance for HTTP method usage
84
+ #
85
+ # @return [Verb::Patch] A singleton instance representing the HTTP PATCH method
86
+ # @see Verb
87
+ #
28
88
  PATCH = Patch.new
89
+
90
+ #
91
+ # A predefined POST verb instance for HTTP method usage
92
+ #
93
+ # @return [Verb::Post] A singleton instance representing the HTTP POST method
94
+ # @see Verb
95
+ #
29
96
  POST = Post.new
97
+
98
+ #
99
+ # A predefined PUT verb instance for HTTP method usage
100
+ #
101
+ # @return [Verb::Put] A singleton instance representing the HTTP PUT method
102
+ # @see Verb
103
+ #
30
104
  PUT = Put.new
31
105
 
106
+ #
107
+ # All HTTP verbs as a lookup hash
108
+ #
109
+ # @return [Hash<Symbol, Verb>]
110
+ #
32
111
  VERBS = {
33
112
  delete: DELETE,
34
113
  get: GET,
@@ -1,5 +1,110 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ module SpecForge
4
+ #
5
+ # HTTP module providing request and response handling for API testing
6
+ #
7
+ # This module contains the HTTP client, request object, and other components
8
+ # needed to make API calls and validate responses against expectations.
9
+ #
10
+ module HTTP
11
+ #
12
+ # A mapping of HTTP status codes to their standard descriptions
13
+ #
14
+ # This constant provides a lookup table of common HTTP status codes with their
15
+ # official descriptions according to HTTP specifications. Used internally
16
+ # to generate human-readable test output.
17
+ #
18
+ # @example Looking up a status code description
19
+ # HTTP::STATUS_DESCRIPTIONS[200] # => "OK"
20
+ # HTTP::STATUS_DESCRIPTIONS[404] # => "Not Found"
21
+ #
22
+ STATUS_DESCRIPTIONS = {
23
+ # Success codes
24
+ 200 => "OK",
25
+ 201 => "Created",
26
+ 202 => "Accepted",
27
+ 204 => "No Content",
28
+
29
+ # Redirection
30
+ 301 => "Moved Permanently",
31
+ 302 => "Found",
32
+ 304 => "Not Modified",
33
+ 307 => "Temporary Redirect",
34
+ 308 => "Permanent Redirect",
35
+
36
+ # Client errors
37
+ 400 => "Bad Request",
38
+ 401 => "Unauthorized",
39
+ 403 => "Forbidden",
40
+ 404 => "Not Found",
41
+ 405 => "Method Not Allowed",
42
+ 406 => "Not Acceptable",
43
+ 407 => "Proxy Authentication Required",
44
+ 409 => "Conflict",
45
+ 410 => "Gone",
46
+ 411 => "Length Required",
47
+ 413 => "Payload Too Large",
48
+ 414 => "URI Too Long",
49
+ 415 => "Unsupported Media Type",
50
+ 421 => "Misdirected Request",
51
+ 422 => "Unprocessable Content",
52
+ 423 => "Locked",
53
+ 424 => "Failed Dependency",
54
+ 428 => "Precondition Required",
55
+ 429 => "Too Many Requests",
56
+ 431 => "Request Header Fields Too Large",
57
+
58
+ # Server errors
59
+ 500 => "Internal Server Error",
60
+ 501 => "Not Implemented",
61
+ 502 => "Bad Gateway",
62
+ 503 => "Service Unavailable",
63
+ 504 => "Gateway Timeout"
64
+ }
65
+
66
+ #
67
+ # Converts an HTTP status code to a human-readable description
68
+ #
69
+ # Takes a numeric status code and returns a formatted string containing both
70
+ # the code and its description. Uses predefined descriptions for common codes,
71
+ # with fallbacks to category-based descriptions for uncommon codes.
72
+ #
73
+ # @param code [Integer, String] The HTTP status code to convert
74
+ #
75
+ # @return [String] A formatted description string (e.g., "200 OK", "404 Not Found")
76
+ #
77
+ # @example Common status codes
78
+ # HTTP.status_code_to_description(200) # => "200 OK"
79
+ # HTTP.status_code_to_description(404) # => "404 Not Found"
80
+ #
81
+ # @example Fallback descriptions for uncommon codes
82
+ # HTTP.status_code_to_description(299) # => "299 Success"
83
+ # HTTP.status_code_to_description(499) # => "499 Client Error"
84
+ #
85
+ def self.status_code_to_description(code)
86
+ code = code.to_i
87
+ description = STATUS_DESCRIPTIONS[code]
88
+ return "#{code} #{description}" if description
89
+
90
+ # Fallbacks by range
91
+ if code >= 100 && code < 200
92
+ "#{code} Informational"
93
+ elsif code >= 200 && code < 300
94
+ "#{code} Success"
95
+ elsif code >= 300 && code < 400
96
+ "#{code} Redirection"
97
+ elsif code >= 400 && code < 500
98
+ "#{code} Client Error"
99
+ elsif code >= 500 && code < 600
100
+ "#{code} Server Error"
101
+ else
102
+ code.to_s
103
+ end
104
+ end
105
+ end
106
+ end
107
+
3
108
  require_relative "http/backend"
4
109
  require_relative "http/client"
5
110
  require_relative "http/verb"
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ #
5
+ # Responsible for loading specs from YAML files and converting them to testable objects
6
+ #
7
+ # The Loader reads spec files, parses them as YAML, and transforms them into
8
+ # a structure that can be used to create Forge objects. It also extracts
9
+ # metadata like line numbers for error reporting.
10
+ #
11
+ # @example Loading all specs
12
+ # specs = Loader.load_from_files
13
+ #
14
+ class Loader
15
+ class << self
16
+ #
17
+ # Loads all spec YAML files and transforms them into normalized structures
18
+ #
19
+ # @return [Array<Array>] Array of [global, metadata, specs] for each loaded file
20
+ #
21
+ def load_from_files
22
+ # metadata is not normalized because its not user managed
23
+ load_specs_from_files.map do |global, metadata, specs|
24
+ global =
25
+ begin
26
+ Normalizer.normalize_global_context!(global)
27
+ rescue => e
28
+ raise Error::SpecLoadError.new(e, metadata[:relative_path])
29
+ end
30
+
31
+ specs =
32
+ specs.map do |spec|
33
+ Normalizer.normalize_spec!(spec, label: "spec \"#{spec[:name]}\"")
34
+ rescue => e
35
+ raise Error::SpecLoadError.new(e, metadata[:relative_path], spec:)
36
+ end
37
+
38
+ [global, metadata, specs]
39
+ end
40
+ end
41
+
42
+ #
43
+ # Internal method that handles loading specs from files
44
+ #
45
+ # This method coordinates the entire spec loading process by:
46
+ # 1. Reading files from the specs directory
47
+ # 2. Parsing them as YAML
48
+ # 3. Transforming them into the proper structure
49
+ #
50
+ # @return [Array<Array>] Array of [global, metadata, specs] for each loaded file
51
+ #
52
+ # @private
53
+ #
54
+ def load_specs_from_files
55
+ files = read_from_files
56
+ parse_and_transform_specs(files)
57
+ end
58
+
59
+ #
60
+ # Reads spec files from the spec_forge/specs directory
61
+ #
62
+ # @return [Array<Array<String, String>>] Array of [file_path, file_content] pairs
63
+ #
64
+ # @private
65
+ #
66
+ def read_from_files
67
+ path = SpecForge.forge_path.join("specs")
68
+
69
+ Dir[path.join("**/*.yml")].map do |file_path|
70
+ [file_path, File.read(file_path)]
71
+ end
72
+ end
73
+
74
+ #
75
+ # Parses YAML content and extracts line numbers for error reporting
76
+ #
77
+ # @param files [Array<Array<String, String>>] Array of [file_path, file_content] pairs
78
+ #
79
+ # @return [Array<Array>] Array of [global, metadata, specs] for each file
80
+ #
81
+ # @private
82
+ #
83
+ def parse_and_transform_specs(files)
84
+ base_path = SpecForge.forge_path.join("specs")
85
+
86
+ files.map do |file_path, content|
87
+ relative_path = Pathname.new(file_path).relative_path_from(base_path)
88
+
89
+ hash = YAML.load(content).deep_symbolize_keys
90
+
91
+ file_line_numbers = extract_line_numbers(content, hash)
92
+
93
+ # Currently, only holds onto global variables
94
+ global = hash.delete(:global) || {}
95
+
96
+ metadata = {
97
+ file_name: relative_path.basename(".yml").to_s,
98
+ relative_path: relative_path.to_s,
99
+ file_path:
100
+ }
101
+
102
+ specs =
103
+ hash.map do |spec_name, spec_hash|
104
+ line_number, *expectation_line_numbers = file_line_numbers[spec_name]
105
+
106
+ spec_hash[:id] = "spec_#{generate_id(spec_hash)}"
107
+ spec_hash[:name] = spec_name.to_s
108
+ spec_hash[:file_path] = metadata[:file_path]
109
+ spec_hash[:file_name] = metadata[:file_name]
110
+ spec_hash[:line_number] = line_number
111
+
112
+ # Check for expectations instead of defaulting. I want it to error
113
+ if (expectations = spec_hash[:expectations])
114
+ expectations.zip(expectation_line_numbers) do |expectation_hash, line_number|
115
+ expectation_hash[:id] = "expect_#{generate_id(expectation_hash)}"
116
+ expectation_hash[:name] = build_expectation_name(spec_hash, expectation_hash)
117
+ expectation_hash[:line_number] = line_number
118
+ end
119
+ end
120
+
121
+ spec_hash
122
+ end
123
+
124
+ [global, metadata, specs]
125
+ end
126
+ end
127
+
128
+ #
129
+ # Extracts line numbers from each YAML section for error reporting
130
+ #
131
+ # @param content [String] The raw file content
132
+ # @param input_hash [Hash] The parsed YAML structure
133
+ #
134
+ # @return [Hash] A mapping of spec names to line numbers
135
+ #
136
+ # @private
137
+ #
138
+ def extract_line_numbers(content, input_hash)
139
+ # I hate this code, lol, and it hates me.
140
+ # I've tried to make it better, I've tried to clean it up, but every time I break it.
141
+ # If you know how to make this better, please submit a PR and save me.
142
+ spec_names = input_hash.keys
143
+ keys = {}
144
+
145
+ current_spec_name = nil
146
+ expectations_line = nil
147
+ expectations_indent = nil
148
+
149
+ content.lines.each_with_index do |line, index|
150
+ line_number = index + 1
151
+ clean_line = line.rstrip
152
+ indentation = line[/^\s*/].size
153
+
154
+ # Skip blank lines
155
+ next if clean_line.empty?
156
+
157
+ # Reset on top-level elements
158
+ if indentation == 0
159
+ current_spec_name = nil
160
+ expectations_line = nil
161
+ expectations_indent = nil
162
+
163
+ # Check if this line starts a spec we're interested in
164
+ spec_names.each do |spec_name|
165
+ next unless clean_line.start_with?("#{spec_name}:")
166
+
167
+ current_spec_name = spec_name
168
+ keys[current_spec_name] = [line_number]
169
+ break
170
+ end
171
+
172
+ next
173
+ end
174
+
175
+ # Skip if we're not in a relevant spec
176
+ next unless current_spec_name
177
+
178
+ # Found expectations section
179
+ if clean_line.match?(/^[^#]\s*expectations:/i)
180
+ expectations_line = line_number
181
+ expectations_indent = indentation
182
+ next
183
+ end
184
+
185
+ # Found an expectation item
186
+ if expectations_line && clean_line.start_with?("#{" " * expectations_indent}- ")
187
+ keys[current_spec_name] << line_number
188
+ end
189
+ end
190
+
191
+ keys
192
+ end
193
+
194
+ #
195
+ # Generates a unique ID for an object based on hash and object_id
196
+ #
197
+ # @param object [Object] The object to generate an ID for
198
+ #
199
+ # @return [String] A unique ID string
200
+ #
201
+ # @private
202
+ #
203
+ def generate_id(object)
204
+ "#{object.hash.abs.to_s(36)}_#{object.object_id.to_s(36)}"
205
+ end
206
+
207
+ #
208
+ # Builds a name for an expectation based on HTTP verb, URL, and optional name
209
+ #
210
+ # @param spec_hash [Hash] The spec configuration
211
+ # @param expectation_hash [Hash] The expectation configuration
212
+ #
213
+ # @return [String] A formatted expectation name (e.g., "GET /users - Find User")
214
+ #
215
+ # @private
216
+ #
217
+ def build_expectation_name(spec_hash, expectation_hash)
218
+ # Create a structure for these two attributes
219
+ # Removing the defaults and validators to avoid issues
220
+ structure = Normalizer::SHARED_ATTRIBUTES.slice(:http_verb, :url)
221
+ .transform_values { |v| v.except(:default, :validator) }
222
+
223
+ # Ignore any errors. It'll be caught above anyway
224
+ normalized_spec, _errors = Normalizer.new("", spec_hash, structure:).normalize
225
+ normalized_expectation, _errors = Normalizer.new("", expectation_hash, structure:).normalize
226
+
227
+ request_data = normalized_spec.deep_merge(normalized_expectation)
228
+
229
+ url = request_data[:url]
230
+ http_verb = request_data[:http_verb].presence || "GET"
231
+
232
+ # Finally generate the name
233
+ generate_expectation_name(http_verb:, url:, name: expectation_hash[:name])
234
+ end
235
+
236
+ #
237
+ # Generates an expectation name from its components
238
+ #
239
+ # @param http_verb [String] The HTTP verb (GET, POST, etc.)
240
+ # @param url [String] The URL path
241
+ # @param name [String, nil] Optional descriptive name
242
+ #
243
+ # @return [String] A formatted expectation name
244
+ #
245
+ # @private
246
+ #
247
+ def generate_expectation_name(http_verb:, url:, name: nil)
248
+ base = "#{http_verb.upcase} #{url}" # GET /users
249
+ base += " - #{name}" if name.present? # GET /users - Returns 404 because y not?
250
+ base
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ #
5
+ # Provides custom RSpec matchers for SpecForge
6
+ #
7
+ # This singleton class is responsible for defining custom RSpec matchers
8
+ # that can be used in SpecForge tests. It makes these matchers available
9
+ # through RSpec's matcher system.
10
+ #
11
+ # @example Defining all matchers
12
+ # SpecForge::Matchers.define
13
+ #
14
+ class Matchers
15
+ include Singleton
16
+
17
+ #
18
+ # Defines all custom matchers for use in SpecForge tests
19
+ #
20
+ # This is the main entry point that should be called once during
21
+ # initialization to make all custom matchers available.
22
+ #
23
+ def self.define
24
+ instance.define_all
25
+ end
26
+
27
+ #
28
+ # Defines all available custom matchers
29
+ #
30
+ # This method calls individual definition methods for each
31
+ # custom matcher supported by SpecForge.
32
+ #
33
+ def define_all
34
+ define_forge_and
35
+ define_have_size
36
+ end
37
+
38
+ private
39
+
40
+ #
41
+ # Defines the forge_and matcher for combining multiple matchers.
42
+ # Explicitly has "forge_" prefix to avoid potentially clashing with someone's
43
+ # existing custom matchers.
44
+ #
45
+ # This matcher allows chaining multiple matchers together with an AND
46
+ # condition, requiring all matchers to pass. It provides detailed
47
+ # failure messages showing which specific matchers failed.
48
+ #
49
+ # @example Using forge_and in a test
50
+ # expect(response.body).to forge_and(
51
+ # have_key("name"),
52
+ # have_key("email"),
53
+ # include("active" => be_truthy)
54
+ # )
55
+ #
56
+ # @private
57
+ #
58
+ def define_forge_and
59
+ RSpec::Matchers.define :forge_and do |*matchers|
60
+ match do |actual|
61
+ @failures = []
62
+
63
+ matchers.each do |matcher|
64
+ next if matcher.matches?(actual)
65
+
66
+ @failures << [matcher, matcher.failure_message]
67
+ end
68
+
69
+ @failures.empty?
70
+ end
71
+
72
+ failure_message do
73
+ pass_count = matchers.size - @failures.size
74
+
75
+ message = "Expected to satisfy ALL of these conditions on:\n #{actual.inspect}\n\n"
76
+
77
+ matchers.each_with_index do |matcher, i|
78
+ failure = @failures.find { |m, _| m == matcher }
79
+
80
+ if failure
81
+ message += "❌ #{i + 1}. #{matcher.description}\n"
82
+ message += " → #{failure[1].gsub(/\s+/, " ").strip}\n\n"
83
+ else
84
+ message += "✅ #{i + 1}. #{matcher.description}\n\n"
85
+ end
86
+ end
87
+
88
+ message += "#{pass_count}/#{matchers.size} conditions met"
89
+ message
90
+ end
91
+
92
+ description do
93
+ "match all: " + matchers.join_map(", ", &:description)
94
+ end
95
+ end
96
+ end
97
+
98
+ #
99
+ # Defines the have_size matcher for checking collection sizes
100
+ #
101
+ # This matcher verifies that an object responds to the :size method
102
+ # and that its size matches the expected value.
103
+ #
104
+ # @example Using have_size in a test
105
+ # expect(response.body["items"]).to have_size(5)
106
+ #
107
+ # @private
108
+ #
109
+ def define_have_size
110
+ RSpec::Matchers.define :have_size do |expected|
111
+ expected = RSpec::Matchers::BuiltIn::Eq.new(expected) if expected.is_a?(Integer)
112
+
113
+ match do |actual|
114
+ actual.respond_to?(:size) && expected.matches?(actual.size)
115
+ end
116
+
117
+ failure_message do |actual|
118
+ if actual.respond_to?(:size)
119
+ "expected #{actual.inspect} size to #{expected.description}, but got #{actual.size}"
120
+ else
121
+ "expected #{actual.inspect} to respond to :size"
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ # Define the custom matchers
130
+ SpecForge::Matchers.define
@@ -2,7 +2,23 @@
2
2
 
3
3
  module SpecForge
4
4
  class Normalizer
5
+ #
6
+ # Normalizes configuration hash structure for SpecForge
7
+ #
8
+ # Ensures that the global configuration has the correct structure
9
+ # and default values for all required settings.
10
+ #
5
11
  class Configuration < Normalizer
12
+ #
13
+ # Defines the normalized structure for configuration validation
14
+ #
15
+ # Specifies validation rules for configuration attributes:
16
+ # - Enforces specific data types
17
+ # - Provides default values
18
+ # - Supports alternative key names
19
+ #
20
+ # @return [Hash] Configuration attribute validation rules
21
+ #
6
22
  STRUCTURE = {
7
23
  base_url: SHARED_ATTRIBUTES[:base_url].except(:default), # Make it required
8
24
  headers: SHARED_ATTRIBUTES[:headers],
@@ -32,20 +48,20 @@ module SpecForge
32
48
  #
33
49
  # Generates an empty configuration hash
34
50
  #
35
- # @return [Hash]
51
+ # @return [Hash] Default configuration hash
36
52
  #
37
53
  def default_configuration
38
54
  Configuration.default
39
55
  end
40
56
 
41
57
  #
42
- # Normalizes a configuration hash by standardizing its keys while ensuring the required data
43
- # is provided or defaulted.
44
- # Raises InvalidStructureError if anything is missing/invalid type
58
+ # Normalizes a configuration hash with validation
45
59
  #
46
60
  # @param input [Hash] The hash to normalize
47
61
  #
48
- # @return [Hash] A normalized hash as a new instance
62
+ # @return [Hash] A normalized hash with defaults applied
63
+ #
64
+ # @raise [Error::InvalidStructureError] If validation fails
49
65
  #
50
66
  def normalize_configuration!(input)
51
67
  raise_errors! do
@@ -55,19 +71,16 @@ module SpecForge
55
71
 
56
72
  #
57
73
  # Normalize a configuration hash
58
- # Used internally by .normalize_configuration!, but is available for utility
59
74
  #
60
- # @param configuration [Hash] Configuration representation as a Hash
75
+ # @param configuration [Hash] Configuration hash
61
76
  #
62
- # @return [Array] Two item array
63
- # First - The normalized hash
64
- # Second - Array of errors, if any
77
+ # @return [Array] [normalized_hash, errors]
65
78
  #
66
79
  # @private
67
80
  #
68
81
  def normalize_configuration(configuration)
69
82
  if !Type.hash?(configuration)
70
- raise InvalidTypeError.new(configuration, Hash, for: "configuration")
83
+ raise Error::InvalidTypeError.new(configuration, Hash, for: "configuration")
71
84
  end
72
85
 
73
86
  Normalizer::Configuration.new("configuration", configuration).normalize