spec_forge 0.5.0 → 0.7.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.
- checksums.yaml +4 -4
- data/.standard.yml +3 -3
- data/CHANGELOG.md +217 -2
- data/README.md +162 -25
- data/flake.lock +3 -3
- data/flake.nix +11 -5
- data/lib/spec_forge/attribute/chainable.rb +208 -20
- data/lib/spec_forge/attribute/factory.rb +92 -15
- data/lib/spec_forge/attribute/faker.rb +62 -13
- data/lib/spec_forge/attribute/global.rb +96 -0
- data/lib/spec_forge/attribute/literal.rb +15 -2
- data/lib/spec_forge/attribute/matcher.rb +186 -11
- data/lib/spec_forge/attribute/parameterized.rb +45 -12
- data/lib/spec_forge/attribute/regex.rb +55 -5
- data/lib/spec_forge/attribute/resolvable.rb +48 -5
- data/lib/spec_forge/attribute/resolvable_array.rb +62 -4
- data/lib/spec_forge/attribute/resolvable_hash.rb +62 -4
- data/lib/spec_forge/attribute/store.rb +65 -0
- data/lib/spec_forge/attribute/transform.rb +33 -5
- data/lib/spec_forge/attribute/variable.rb +37 -6
- data/lib/spec_forge/attribute.rb +166 -66
- data/lib/spec_forge/backtrace_formatter.rb +26 -3
- data/lib/spec_forge/callbacks.rb +88 -0
- data/lib/spec_forge/cli/actions.rb +27 -0
- data/lib/spec_forge/cli/command.rb +78 -24
- data/lib/spec_forge/cli/docs/generate.rb +72 -0
- data/lib/spec_forge/cli/docs.rb +92 -0
- data/lib/spec_forge/cli/init.rb +51 -9
- data/lib/spec_forge/cli/new.rb +67 -6
- data/lib/spec_forge/cli/run.rb +32 -4
- data/lib/spec_forge/cli/serve.rb +155 -0
- data/lib/spec_forge/cli.rb +26 -7
- data/lib/spec_forge/configuration.rb +96 -24
- data/lib/spec_forge/context/callbacks.rb +91 -0
- data/lib/spec_forge/context/global.rb +72 -0
- data/lib/spec_forge/context/store.rb +131 -0
- data/lib/spec_forge/context/variables.rb +91 -0
- data/lib/spec_forge/context.rb +36 -0
- data/lib/spec_forge/core_ext/array.rb +27 -0
- data/lib/spec_forge/core_ext/rspec.rb +22 -4
- data/lib/spec_forge/documentation/builder.rb +383 -0
- data/lib/spec_forge/documentation/document/operation.rb +47 -0
- data/lib/spec_forge/documentation/document/parameter.rb +22 -0
- data/lib/spec_forge/documentation/document/request_body.rb +24 -0
- data/lib/spec_forge/documentation/document/response.rb +39 -0
- data/lib/spec_forge/documentation/document/response_body.rb +27 -0
- data/lib/spec_forge/documentation/document.rb +48 -0
- data/lib/spec_forge/documentation/generators/base.rb +81 -0
- data/lib/spec_forge/documentation/generators/openapi/base.rb +100 -0
- data/lib/spec_forge/documentation/generators/openapi/error_formatter.rb +149 -0
- data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +65 -0
- data/lib/spec_forge/documentation/generators/openapi.rb +59 -0
- data/lib/spec_forge/documentation/generators.rb +17 -0
- data/lib/spec_forge/documentation/loader/cache.rb +138 -0
- data/lib/spec_forge/documentation/loader.rb +159 -0
- data/lib/spec_forge/documentation/openapi/base.rb +33 -0
- data/lib/spec_forge/documentation/openapi/v3_0/example.rb +44 -0
- data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +42 -0
- data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +175 -0
- data/lib/spec_forge/documentation/openapi/v3_0/response.rb +65 -0
- data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +80 -0
- data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +71 -0
- data/lib/spec_forge/documentation/openapi.rb +23 -0
- data/lib/spec_forge/documentation.rb +27 -0
- data/lib/spec_forge/error.rb +284 -113
- data/lib/spec_forge/factory.rb +35 -16
- data/lib/spec_forge/filter.rb +86 -0
- data/lib/spec_forge/forge.rb +171 -0
- data/lib/spec_forge/http/backend.rb +101 -29
- data/lib/spec_forge/http/client.rb +23 -13
- data/lib/spec_forge/http/request.rb +85 -62
- data/lib/spec_forge/http/verb.rb +79 -0
- data/lib/spec_forge/http.rb +105 -0
- data/lib/spec_forge/loader.rb +244 -0
- data/lib/spec_forge/matchers.rb +130 -0
- data/lib/spec_forge/normalizer/default.rb +51 -0
- data/lib/spec_forge/normalizer/definition.rb +248 -0
- data/lib/spec_forge/normalizer/validators.rb +99 -0
- data/lib/spec_forge/normalizer.rb +486 -115
- data/lib/spec_forge/normalizers/_shared.yml +74 -0
- data/lib/spec_forge/normalizers/configuration.yml +23 -0
- data/lib/spec_forge/normalizers/constraint.yml +8 -0
- data/lib/spec_forge/normalizers/expectation.yml +47 -0
- data/lib/spec_forge/normalizers/factory.yml +12 -0
- data/lib/spec_forge/normalizers/factory_reference.yml +15 -0
- data/lib/spec_forge/normalizers/global_context.yml +28 -0
- data/lib/spec_forge/normalizers/spec.yml +50 -0
- data/lib/spec_forge/runner/adapter.rb +183 -0
- data/lib/spec_forge/runner/callbacks.rb +246 -0
- data/lib/spec_forge/runner/debug_proxy.rb +213 -0
- data/lib/spec_forge/runner/listener.rb +54 -0
- data/lib/spec_forge/runner/metadata.rb +58 -0
- data/lib/spec_forge/runner/state.rb +98 -0
- data/lib/spec_forge/runner.rb +50 -125
- data/lib/spec_forge/spec/expectation/constraint.rb +100 -21
- data/lib/spec_forge/spec/expectation.rb +47 -51
- data/lib/spec_forge/spec.rb +50 -108
- data/lib/spec_forge/type.rb +36 -4
- data/lib/spec_forge/version.rb +4 -1
- data/lib/spec_forge.rb +168 -76
- data/lib/templates/openapi.yml.tt +22 -0
- data/lib/templates/redoc.html.tt +28 -0
- data/lib/templates/swagger.html.tt +59 -0
- metadata +109 -16
- data/lib/spec_forge/normalizer/configuration.rb +0 -77
- data/lib/spec_forge/normalizer/constraint.rb +0 -47
- data/lib/spec_forge/normalizer/expectation.rb +0 -86
- data/lib/spec_forge/normalizer/factory.rb +0 -65
- data/lib/spec_forge/normalizer/factory_reference.rb +0 -71
- data/lib/spec_forge/normalizer/spec.rb +0 -74
- data/spec_forge/factories/user.yml +0 -4
- data/spec_forge/forge_helper.rb +0 -48
- data/spec_forge/specs/users.yml +0 -65
- /data/lib/templates/{forge_helper.tt → forge_helper.rb.tt} +0 -0
- /data/lib/templates/{new_factory.tt → new_factory.yml.tt} +0 -0
- /data/lib/templates/{new_spec.tt → new_spec.yml.tt} +0 -0
@@ -2,84 +2,272 @@
|
|
2
2
|
|
3
3
|
module SpecForge
|
4
4
|
class Attribute
|
5
|
+
#
|
6
|
+
# Adds support for an attribute to accept n-number of chained calls. It supports chaining
|
7
|
+
# methods, hash keys, and array indexes. It also works well alongside Parameterized attributes
|
8
|
+
#
|
9
|
+
# This module requires being included into a class first before it can be used
|
10
|
+
#
|
11
|
+
# @example Basic usage in YAML
|
12
|
+
# my_variable: variable.users.first
|
13
|
+
#
|
14
|
+
# @example Advanced usage in YAML
|
15
|
+
# my_variable: variable.users.0.posts.second.author.name
|
16
|
+
#
|
17
|
+
# @example Basic usage in code
|
18
|
+
# faker = SpecForge::Attribute.from("faker.name.name.upcase")
|
19
|
+
# faker.resolved #=> BENDING UNIT 22
|
20
|
+
#
|
5
21
|
module Chainable
|
22
|
+
#
|
23
|
+
# Regular expression that matches pure numeric strings
|
24
|
+
# Used for detecting potential array index operations
|
25
|
+
#
|
26
|
+
# @return [Regexp] A case-insensitive regex matching strings containing only digits
|
27
|
+
#
|
6
28
|
NUMBER_REGEX = /^\d+$/i
|
7
29
|
|
8
|
-
|
30
|
+
#
|
31
|
+
# The first part of the chained attribute
|
32
|
+
#
|
33
|
+
# @return [Symbol] The first component of the chained attribute
|
34
|
+
#
|
35
|
+
attr_reader :keyword
|
36
|
+
|
37
|
+
#
|
38
|
+
# The second part of the chained attribute
|
39
|
+
#
|
40
|
+
# @return [Symbol] The second component of the chained attribute
|
41
|
+
#
|
42
|
+
attr_reader :header
|
43
|
+
|
44
|
+
#
|
45
|
+
# The remaining parts of the attribute chain after the header
|
46
|
+
#
|
47
|
+
# @return [Array<Symbol>] The remaining method/key invocations in the chain
|
48
|
+
#
|
49
|
+
attr_reader :invocation_chain
|
9
50
|
|
10
51
|
#
|
11
|
-
#
|
52
|
+
# The initial object from which the chain will start traversing
|
53
|
+
#
|
54
|
+
# @return [Object] The base object that starts the method/attribute chain
|
12
55
|
#
|
13
|
-
|
56
|
+
attr_reader :base_object
|
57
|
+
|
14
58
|
#
|
15
|
-
#
|
16
|
-
# Note: There can be any n number of segments.
|
59
|
+
# Initializes a new chainable attribute by parsing the input into components
|
17
60
|
#
|
18
61
|
def initialize(...)
|
19
62
|
super
|
20
63
|
|
21
|
-
|
22
|
-
sections = input.split(".")[1..]
|
64
|
+
sections = input.split(".")
|
23
65
|
|
24
|
-
@
|
25
|
-
@
|
66
|
+
@keyword = sections.first.to_sym
|
67
|
+
@header = sections.second&.to_sym
|
68
|
+
@invocation_chain = sections[2..] || []
|
26
69
|
end
|
27
70
|
|
71
|
+
#
|
72
|
+
# Returns the value of this attribute by resolving the chain
|
73
|
+
# Will return a new value on each call for dynamic attributes like Faker
|
74
|
+
#
|
75
|
+
# @return [Object] The result of invoking the chain on the base object
|
76
|
+
#
|
28
77
|
def value
|
29
78
|
invoke_chain
|
30
79
|
end
|
31
80
|
|
32
|
-
|
81
|
+
#
|
82
|
+
# Resolves the chain and stores the result
|
83
|
+
# The result is memoized, so subsequent calls return the same value
|
84
|
+
# even for dynamic attributes like Faker and Factory
|
85
|
+
#
|
86
|
+
# @return [Object] The fully resolved and memoized value
|
87
|
+
#
|
88
|
+
def resolved
|
33
89
|
@resolved ||= resolve_chain
|
34
90
|
end
|
35
91
|
|
36
92
|
private
|
37
93
|
|
94
|
+
#
|
95
|
+
# Invokes the chain by calling #value on each object
|
96
|
+
#
|
97
|
+
# @return [Object] The result of invoking the chain
|
98
|
+
#
|
99
|
+
# @private
|
100
|
+
#
|
38
101
|
def invoke_chain
|
39
102
|
traverse_chain(resolve: false)
|
40
103
|
end
|
41
104
|
|
105
|
+
#
|
106
|
+
# Resolves the chain by calling #resolve on each object
|
107
|
+
#
|
108
|
+
# @return [Object] The fully resolved result
|
109
|
+
#
|
110
|
+
# @private
|
111
|
+
#
|
42
112
|
def resolve_chain
|
43
|
-
|
113
|
+
traverse_chain(resolve: true)
|
44
114
|
end
|
45
115
|
|
116
|
+
#
|
117
|
+
# Traverses the chain of invocations step by step
|
118
|
+
#
|
119
|
+
# @param resolve [Boolean] Whether to use resolve during traversal
|
120
|
+
#
|
121
|
+
# @return [Object] The result of the traversal
|
122
|
+
#
|
123
|
+
# @private
|
124
|
+
#
|
46
125
|
def traverse_chain(resolve:)
|
47
|
-
|
48
|
-
|
126
|
+
resolution_path = {}
|
127
|
+
|
128
|
+
current_path = "#{keyword}.#{header}"
|
129
|
+
current_object = base_object
|
130
|
+
|
131
|
+
invocation_chain.each do |step|
|
132
|
+
next_value = retrieve_value(current_object, resolve:)
|
133
|
+
|
134
|
+
# Store this step's resolution for error reporting
|
135
|
+
resolution_path[current_path] = describe_value(next_value)
|
136
|
+
current_path += ".#{step}"
|
137
|
+
|
138
|
+
# Try to invoke the next step
|
139
|
+
current_object = invoke(step, next_value)
|
140
|
+
rescue Error::InvalidInvocationError => e
|
141
|
+
resolution_path[current_path] = "Error: #{e.message}"
|
49
142
|
|
50
|
-
|
143
|
+
raise e.with_resolution_path(resolution_path)
|
51
144
|
end
|
52
145
|
|
53
|
-
|
146
|
+
# Return final result
|
147
|
+
retrieve_value(current_object, resolve:)
|
54
148
|
end
|
55
149
|
|
150
|
+
#
|
151
|
+
# Retrieves the value from an object, resolving it if needed
|
152
|
+
#
|
153
|
+
# @param object [Object] The object to retrieve a value from
|
154
|
+
# @param resolve [Boolean] Whether to resolve the object's value
|
155
|
+
#
|
156
|
+
# @return [Object] The retrieved value
|
157
|
+
#
|
158
|
+
# @private
|
159
|
+
#
|
56
160
|
def retrieve_value(object, resolve:)
|
57
161
|
return object unless object.is_a?(Attribute)
|
58
162
|
|
59
|
-
resolve ? object.
|
163
|
+
resolve ? object.resolved : object.value
|
164
|
+
end
|
165
|
+
|
166
|
+
#
|
167
|
+
# Creates a description of a value for error messages
|
168
|
+
#
|
169
|
+
# @param value [Object] The value to describe
|
170
|
+
#
|
171
|
+
# @return [String] A description
|
172
|
+
#
|
173
|
+
# @private
|
174
|
+
#
|
175
|
+
def describe_value(value)
|
176
|
+
case value
|
177
|
+
when Context::Store::Entry
|
178
|
+
"Store with attributes: #{value.available_methods.join_map(", ", &:in_quotes)}"
|
179
|
+
when OpenStruct
|
180
|
+
"Object with attributes: #{value.table.keys.join_map(", ", &:in_quotes)}"
|
181
|
+
when Struct, Data
|
182
|
+
"Object with attributes: #{value.members.join_map(", ", &:in_quotes)}"
|
183
|
+
when ArrayLike
|
184
|
+
# Preview the first 5 value's classes
|
185
|
+
preview = value.take(5).map(&:class)
|
186
|
+
preview << "..." if value.size > 5
|
187
|
+
|
188
|
+
"Array with #{value.size} #{"element".pluralize(value.size)}: #{preview}"
|
189
|
+
when HashLike
|
190
|
+
# Preview the first 5 keys
|
191
|
+
keys = value.keys.take(5)
|
192
|
+
|
193
|
+
preview = keys.join_map(", ") { |key| "\"#{key}\"" }
|
194
|
+
preview += ", ..." if value.keys.size > 5
|
195
|
+
|
196
|
+
"Hash with #{"key".pluralize(keys.size)}: #{preview}"
|
197
|
+
when String
|
198
|
+
"\"#{value.truncate(50)}\""
|
199
|
+
when NilClass
|
200
|
+
"nil"
|
201
|
+
when Proc
|
202
|
+
"Proc defined at #{value.source_location.join(":")}"
|
203
|
+
else
|
204
|
+
"#{value.class}: #{value.inspect[0..50]}"
|
205
|
+
end
|
60
206
|
end
|
61
207
|
|
208
|
+
#
|
209
|
+
# Invokes an operation on an object based on the step type (hash key, array index, or method)
|
210
|
+
#
|
211
|
+
# @param step [String] The step to invoke
|
212
|
+
# @param object [Object] The object to invoke the step on
|
213
|
+
#
|
214
|
+
# @return [Object] The result of the invocation
|
215
|
+
#
|
216
|
+
# @raise [Error::InvalidInvocationError] If the step cannot be invoked on the object
|
217
|
+
#
|
218
|
+
# @private
|
219
|
+
#
|
62
220
|
def invoke(step, object)
|
63
221
|
if hash_key?(object, step)
|
64
|
-
object[step.to_sym]
|
222
|
+
object[step.to_s] || object[step.to_sym]
|
65
223
|
elsif index?(object, step)
|
66
224
|
object[step.to_i]
|
67
225
|
elsif method?(object, step)
|
68
226
|
object.public_send(step)
|
69
227
|
else
|
70
|
-
raise InvalidInvocationError.new(step, object)
|
228
|
+
raise Error::InvalidInvocationError.new(step, object)
|
71
229
|
end
|
72
230
|
end
|
73
231
|
|
232
|
+
#
|
233
|
+
# Checks if the object can be accessed with the given key
|
234
|
+
#
|
235
|
+
# @param object [Object] The object to check
|
236
|
+
# @param key [String] The key to check
|
237
|
+
#
|
238
|
+
# @return [Boolean] Whether the object supports hash-like access with the key
|
239
|
+
#
|
240
|
+
# @private
|
241
|
+
#
|
74
242
|
def hash_key?(object, key)
|
75
|
-
# This is to support the silly delegator
|
76
|
-
method?(object, :key?) && object.key?(key.to_sym)
|
243
|
+
# This is to support the silly delegator and both symbol/string
|
244
|
+
method?(object, :key?) && (object.key?(key.to_s) || object.key?(key.to_sym))
|
77
245
|
end
|
78
246
|
|
247
|
+
#
|
248
|
+
# Checks if the object responds to the given method
|
249
|
+
#
|
250
|
+
# @param object [Object] The object to check
|
251
|
+
# @param method_name [String, Symbol] The method name to check
|
252
|
+
#
|
253
|
+
# @return [Boolean] Whether the object responds to the method
|
254
|
+
#
|
255
|
+
# @private
|
256
|
+
#
|
79
257
|
def method?(object, method_name)
|
80
258
|
object.respond_to?(method_name)
|
81
259
|
end
|
82
260
|
|
261
|
+
#
|
262
|
+
# Checks if the object supports array-like access with the given index
|
263
|
+
#
|
264
|
+
# @param object [Object] The object to check
|
265
|
+
# @param step [String] The potential index
|
266
|
+
#
|
267
|
+
# @return [Boolean] Whether the object supports array-like access with the step
|
268
|
+
#
|
269
|
+
# @private
|
270
|
+
#
|
83
271
|
def index?(object, step)
|
84
272
|
# This is to support the silly delegator
|
85
273
|
method?(object, :index) && step.match?(NUMBER_REGEX)
|
@@ -2,13 +2,54 @@
|
|
2
2
|
|
3
3
|
module SpecForge
|
4
4
|
class Attribute
|
5
|
+
#
|
6
|
+
# Represents an attribute that references a factory to generate test data
|
7
|
+
#
|
8
|
+
# This class allows SpecForge to integrate with FactoryBot for test data generation.
|
9
|
+
# It supports various build strategies like create, build, build_stubbed, etc.
|
10
|
+
#
|
11
|
+
# @example Basic usage in YAML
|
12
|
+
# user: factories.user
|
13
|
+
#
|
14
|
+
# @example With custom attributes
|
15
|
+
# user:
|
16
|
+
# factories.user:
|
17
|
+
# attributes:
|
18
|
+
# name: "Custom Name"
|
19
|
+
# email: faker.internet.email
|
20
|
+
#
|
21
|
+
# @example With build strategy
|
22
|
+
# user:
|
23
|
+
# factories.user:
|
24
|
+
# strategy: build
|
25
|
+
# attributes:
|
26
|
+
# admin: true
|
27
|
+
#
|
28
|
+
# @example With an array of 5 user attributes
|
29
|
+
# user:
|
30
|
+
# factories.user:
|
31
|
+
# strategy: attributes_for
|
32
|
+
# size: 5
|
33
|
+
# attributes:
|
34
|
+
# admin: true
|
35
|
+
#
|
5
36
|
class Factory < Parameterized
|
6
37
|
include Chainable
|
7
38
|
|
39
|
+
#
|
40
|
+
# Regular expression pattern that matches attribute keywords with this prefix
|
41
|
+
# Used for identifying this attribute type during parsing
|
42
|
+
#
|
43
|
+
# @return [Regexp]
|
44
|
+
#
|
8
45
|
KEYWORD_REGEX = /^factories\./i
|
9
46
|
|
10
|
-
#
|
11
|
-
#
|
47
|
+
#
|
48
|
+
# An array of base strategies that can be provided either with or
|
49
|
+
# without a size. "stubbed" will automatically be transformed into "build_stubbed"
|
50
|
+
#
|
51
|
+
# @return [Array<String>]
|
52
|
+
#
|
12
53
|
BASE_STRATEGIES = %w[
|
13
54
|
build
|
14
55
|
create
|
@@ -16,7 +57,7 @@ module SpecForge
|
|
16
57
|
attributes_for
|
17
58
|
].freeze
|
18
59
|
|
19
|
-
# All available build strategies
|
60
|
+
# @return [Array<String>] All available build strategies
|
20
61
|
BUILD_STRATEGIES = %w[
|
21
62
|
attributes_for
|
22
63
|
attributes_for_list
|
@@ -33,21 +74,22 @@ module SpecForge
|
|
33
74
|
alias_method :factory_name, :header
|
34
75
|
|
35
76
|
#
|
36
|
-
#
|
37
|
-
#
|
38
|
-
# factories.<factory_name>
|
77
|
+
# Creates a new factory attribute with the specified name and arguments
|
39
78
|
#
|
40
79
|
def initialize(...)
|
41
80
|
super
|
42
81
|
|
43
82
|
# Check the arguments before preparing them
|
44
|
-
arguments[:keyword] = Normalizer.
|
83
|
+
arguments[:keyword] = Normalizer.normalize!(arguments[:keyword], using: :factory_reference)
|
45
84
|
|
46
85
|
prepare_arguments!
|
47
86
|
end
|
48
87
|
|
49
|
-
|
50
|
-
|
88
|
+
#
|
89
|
+
# Returns the base object for the variable chain
|
90
|
+
#
|
91
|
+
# @return [Object] The result of the FactoryBot call
|
92
|
+
#
|
51
93
|
def base_object
|
52
94
|
attributes = arguments[:keyword]
|
53
95
|
|
@@ -58,19 +100,49 @@ module SpecForge
|
|
58
100
|
FactoryBot.public_send(*build_arguments)
|
59
101
|
end
|
60
102
|
|
103
|
+
#
|
104
|
+
# Similar to #resolved but doesn't cache the result, allowing for re-resolution.
|
105
|
+
# Recursively calls #resolve on all nested attributes without storing results.
|
106
|
+
#
|
107
|
+
# Use this when you need to ensure fresh values each time, particularly with
|
108
|
+
# factories or other attributes that should generate new values on each call.
|
109
|
+
#
|
110
|
+
# @return [Object] The completely resolved value without caching
|
111
|
+
#
|
112
|
+
# @example
|
113
|
+
# factory_attr = Attribute::Factory.new("factories.user")
|
114
|
+
# factory_attr.resolve # => User#1 (a new user)
|
115
|
+
# factory_attr.resolve # => User#2 (another new user)
|
116
|
+
#
|
117
|
+
def resolve
|
118
|
+
case value
|
119
|
+
when ArrayLike
|
120
|
+
value.map(&resolved_proc)
|
121
|
+
when HashLike
|
122
|
+
value.transform_values(&resolved_proc)
|
123
|
+
else
|
124
|
+
value
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
private
|
129
|
+
|
130
|
+
#
|
131
|
+
# @private
|
132
|
+
#
|
61
133
|
def construct_factory_parameters(attributes)
|
62
134
|
build_strategy, list_size = determine_build_strategy(attributes)
|
63
135
|
|
64
136
|
# This is set up for the base strategies + _pair
|
65
|
-
# FactoryBot
|
137
|
+
# FactoryBot.<build_strategy>(factory_name, **attributes)
|
66
138
|
build_arguments = [
|
67
139
|
build_strategy,
|
68
140
|
factory_name,
|
69
|
-
**attributes[:attributes].
|
141
|
+
**attributes[:attributes].resolve
|
70
142
|
]
|
71
143
|
|
72
144
|
# Insert the list size after the strategy
|
73
|
-
# FactoryBot
|
145
|
+
# FactoryBot.<build_strategy>_list(factory_name, list_size, **attributes)
|
74
146
|
if build_strategy.end_with?("_list")
|
75
147
|
build_arguments.insert(2, list_size)
|
76
148
|
end
|
@@ -78,10 +150,13 @@ module SpecForge
|
|
78
150
|
build_arguments
|
79
151
|
end
|
80
152
|
|
153
|
+
#
|
154
|
+
# @private
|
155
|
+
#
|
81
156
|
def determine_build_strategy(attributes)
|
82
157
|
# Determine build strat, and unfreeze
|
83
|
-
build_strategy = +attributes[:build_strategy].
|
84
|
-
list_size = attributes[:size].
|
158
|
+
build_strategy = +attributes[:build_strategy].resolve
|
159
|
+
list_size = attributes[:size].resolve
|
85
160
|
|
86
161
|
# stubbed => build_stubbed
|
87
162
|
build_strategy.prepend("build_") if build_strategy.start_with?("stubbed")
|
@@ -94,7 +169,9 @@ module SpecForge
|
|
94
169
|
build_strategy += "_list"
|
95
170
|
end
|
96
171
|
|
97
|
-
|
172
|
+
if !BUILD_STRATEGIES.include?(build_strategy)
|
173
|
+
raise Error::InvalidBuildStrategy, build_strategy
|
174
|
+
end
|
98
175
|
|
99
176
|
[build_strategy, list_size]
|
100
177
|
end
|
@@ -2,17 +2,44 @@
|
|
2
2
|
|
3
3
|
module SpecForge
|
4
4
|
class Attribute
|
5
|
+
#
|
6
|
+
# Represents an attribute that generates fake data using the Faker gem
|
7
|
+
#
|
8
|
+
# This class allows SpecForge to integrate with the Faker library to generate realistic
|
9
|
+
# test data like names, emails, addresses, etc.
|
10
|
+
#
|
11
|
+
# @example Basic usage in YAML
|
12
|
+
# name: faker.name.name
|
13
|
+
# email: faker.internet.email
|
14
|
+
#
|
15
|
+
# @example With method arguments
|
16
|
+
# age:
|
17
|
+
# faker.number.between:
|
18
|
+
# from: 18
|
19
|
+
# to: 65
|
20
|
+
#
|
21
|
+
# @example Handles nested faker classes
|
22
|
+
# character: faker.games.zelda.character
|
23
|
+
#
|
5
24
|
class Faker < Parameterized
|
6
25
|
include Chainable
|
7
26
|
|
27
|
+
#
|
28
|
+
# Regular expression pattern that matches attribute keywords with this prefix
|
29
|
+
# Used for identifying this attribute type during parsing
|
30
|
+
#
|
31
|
+
# @return [Regexp]
|
32
|
+
#
|
8
33
|
KEYWORD_REGEX = /^faker\./i
|
9
34
|
|
10
|
-
|
35
|
+
# @return [Class] The Faker class
|
36
|
+
attr_reader :faker_class
|
37
|
+
|
38
|
+
# @return [Method] The Faker class method
|
39
|
+
attr_reader :faker_method
|
11
40
|
|
12
41
|
#
|
13
|
-
#
|
14
|
-
#
|
15
|
-
# faker.<faker_class>.<faker_method>
|
42
|
+
# Creates a new faker attribute with the specified name and arguments
|
16
43
|
#
|
17
44
|
def initialize(...)
|
18
45
|
super
|
@@ -22,18 +49,37 @@ module SpecForge
|
|
22
49
|
prepare_arguments!
|
23
50
|
end
|
24
51
|
|
25
|
-
|
26
|
-
|
52
|
+
#
|
53
|
+
# Returns the base object for the variable chain
|
54
|
+
#
|
55
|
+
# @return [Object] The result of the Faker call
|
56
|
+
#
|
27
57
|
def base_object
|
28
58
|
if (positional = arguments[:positional]) && positional.present?
|
29
|
-
faker_method.call(*positional.
|
59
|
+
faker_method.call(*positional.resolved)
|
30
60
|
elsif (keyword = arguments[:keyword]) && keyword.present?
|
31
|
-
faker_method.call(**keyword.
|
61
|
+
faker_method.call(**keyword.resolved)
|
32
62
|
else
|
33
63
|
faker_method.call
|
34
64
|
end
|
35
65
|
end
|
36
66
|
|
67
|
+
private
|
68
|
+
|
69
|
+
#
|
70
|
+
# Extracts the Faker class and method from the input string
|
71
|
+
# Handles both simple cases like "faker.name.first_name" and complex
|
72
|
+
# nested namespaces like "faker.games.zelda.game"
|
73
|
+
#
|
74
|
+
# @return [Array<Class, Method>] A two-element array containing:
|
75
|
+
# 1. The resolved Faker class (e.g., Faker::Name)
|
76
|
+
# 2. The method object to call on that class (e.g., #first_name)
|
77
|
+
#
|
78
|
+
# @raise [Error::InvalidFakerClassError] If the specified Faker class doesn't exist
|
79
|
+
# @raise [Error::InvalidFakerMethodError] If the specified method doesn't exist on the class
|
80
|
+
#
|
81
|
+
# @private
|
82
|
+
#
|
37
83
|
def extract_faker_call
|
38
84
|
class_name = header.downcase.to_s
|
39
85
|
|
@@ -42,10 +88,10 @@ module SpecForge
|
|
42
88
|
return resolve_faker_class_and_method(class_name, invocation_chain.shift)
|
43
89
|
end
|
44
90
|
|
45
|
-
# Try each part of the chain as a potential class name
|
46
|
-
# Example: faker.games.zelda.game.underscore
|
47
91
|
namespace = []
|
48
92
|
|
93
|
+
# Try each part of the chain as a potential class name
|
94
|
+
# Example: faker.games.zelda.game.underscore
|
49
95
|
while invocation_chain.any?
|
50
96
|
part = invocation_chain.first.downcase
|
51
97
|
test_class_name = ([class_name] + namespace + [part]).map(&:camelize).join("::")
|
@@ -65,22 +111,25 @@ module SpecForge
|
|
65
111
|
|
66
112
|
# If we get here, we consumed all parts as classes but found no method
|
67
113
|
class_name = ([class_name] + namespace).map(&:camelize).join("::")
|
68
|
-
raise InvalidFakerMethodError.new(nil, "::#{class_name}".constantize)
|
114
|
+
raise Error::InvalidFakerMethodError.new(nil, "::#{class_name}".constantize)
|
69
115
|
end
|
70
116
|
|
117
|
+
#
|
118
|
+
# @private
|
119
|
+
#
|
71
120
|
def resolve_faker_class_and_method(class_name, method_name)
|
72
121
|
# Load the class
|
73
122
|
faker_class = begin
|
74
123
|
"::Faker::#{class_name.camelize}".constantize
|
75
124
|
rescue NameError
|
76
|
-
raise InvalidFakerClassError, class_name
|
125
|
+
raise Error::InvalidFakerClassError, class_name
|
77
126
|
end
|
78
127
|
|
79
128
|
# Load the method
|
80
129
|
faker_method = begin
|
81
130
|
faker_class.method(method_name)
|
82
131
|
rescue NameError
|
83
|
-
raise InvalidFakerMethodError.new(method_name, faker_class)
|
132
|
+
raise Error::InvalidFakerMethodError.new(method_name, faker_class)
|
84
133
|
end
|
85
134
|
|
86
135
|
[faker_class, faker_method]
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
class Attribute
|
5
|
+
#
|
6
|
+
# Represents an attribute that references values from the global context
|
7
|
+
#
|
8
|
+
# This class allows accessing shared data defined at the global level through
|
9
|
+
# namespaced references. It provides access to global variables that are shared
|
10
|
+
# across all specs in a file, enabling consistent test data without repetition.
|
11
|
+
#
|
12
|
+
# Currently supports the "variables" namespace.
|
13
|
+
#
|
14
|
+
# @example Basic usage in YAML
|
15
|
+
# # Reference a global variable in a spec
|
16
|
+
# session_token: global.variables.session_token
|
17
|
+
#
|
18
|
+
# # Using within a request body
|
19
|
+
# body:
|
20
|
+
# api_version: global.variables.api_version
|
21
|
+
# auth_token: global.variables.auth_token
|
22
|
+
#
|
23
|
+
class Global < Attribute
|
24
|
+
#
|
25
|
+
# Regular expression pattern that matches attribute keywords with this prefix
|
26
|
+
# Used for identifying this attribute type during parsing
|
27
|
+
#
|
28
|
+
# @return [Regexp]
|
29
|
+
#
|
30
|
+
KEYWORD_REGEX = /^global\./i
|
31
|
+
|
32
|
+
#
|
33
|
+
# An array of valid namespaces that can be access on global
|
34
|
+
#
|
35
|
+
# @return [Array<String>]
|
36
|
+
#
|
37
|
+
VALID_NAMESPACES = %w[
|
38
|
+
variables
|
39
|
+
].freeze
|
40
|
+
|
41
|
+
#
|
42
|
+
# Creates a new global attribute from the input string
|
43
|
+
#
|
44
|
+
# Parses the input string to extract the namespace to validate it
|
45
|
+
# Conversion happens when `#value` is called
|
46
|
+
#
|
47
|
+
# @raise [Error::InvalidGlobalNamespaceError] If an unsupported namespace is referenced
|
48
|
+
#
|
49
|
+
def initialize(...)
|
50
|
+
super
|
51
|
+
|
52
|
+
# Check to make sure the namespace is valid
|
53
|
+
namespace = input.split(".").second
|
54
|
+
|
55
|
+
if !VALID_NAMESPACES.include?(namespace)
|
56
|
+
raise Error::InvalidGlobalNamespaceError, namespace
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
#
|
61
|
+
# Converts the global reference into an underlying attribute
|
62
|
+
#
|
63
|
+
# Parses the input and returns the corresponding attribute based on the namespace.
|
64
|
+
# Currently supports extracting variables from the global context.
|
65
|
+
#
|
66
|
+
# @return [Attribute] An attribute representing the referenced global value
|
67
|
+
#
|
68
|
+
def value
|
69
|
+
# Skip the "global" prefix
|
70
|
+
components = input.split(".")[1..]
|
71
|
+
namespace = components.first
|
72
|
+
|
73
|
+
global_context = SpecForge.context.global
|
74
|
+
|
75
|
+
case namespace
|
76
|
+
when "variables"
|
77
|
+
variable_input = components.join(".")
|
78
|
+
variable = Attribute::Variable.new(variable_input)
|
79
|
+
variable.bind_variables(global_context.variables.to_h)
|
80
|
+
variable
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
#
|
85
|
+
# Resolves the global reference to its actual value
|
86
|
+
#
|
87
|
+
# Delegates resolution to the underlying attribute and caches the result
|
88
|
+
#
|
89
|
+
# @return [Object] The fully resolved value from the global context
|
90
|
+
#
|
91
|
+
def resolved
|
92
|
+
@resolved ||= value.resolved
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|