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.
- checksums.yaml +4 -4
- data/.standard.yml +3 -3
- data/CHANGELOG.md +106 -1
- data/README.md +34 -22
- data/flake.lock +3 -3
- data/flake.nix +8 -2
- data/lib/spec_forge/attribute/chainable.rb +208 -20
- data/lib/spec_forge/attribute/factory.rb +91 -14
- 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 +79 -0
- data/lib/spec_forge/cli/actions.rb +27 -0
- data/lib/spec_forge/cli/command.rb +78 -24
- data/lib/spec_forge/cli/init.rb +11 -1
- data/lib/spec_forge/cli/new.rb +54 -3
- data/lib/spec_forge/cli/run.rb +20 -0
- data/lib/spec_forge/cli.rb +16 -5
- data/lib/spec_forge/configuration.rb +94 -22
- 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 +148 -0
- data/lib/spec_forge/context/variables.rb +91 -0
- data/lib/spec_forge/context.rb +36 -0
- data/lib/spec_forge/core_ext/rspec.rb +22 -4
- data/lib/spec_forge/error.rb +267 -113
- data/lib/spec_forge/factory.rb +33 -14
- data/lib/spec_forge/filter.rb +87 -0
- data/lib/spec_forge/forge.rb +170 -0
- data/lib/spec_forge/http/backend.rb +99 -29
- data/lib/spec_forge/http/client.rb +23 -13
- data/lib/spec_forge/http/request.rb +74 -62
- data/lib/spec_forge/http/verb.rb +79 -0
- data/lib/spec_forge/http.rb +105 -0
- data/lib/spec_forge/loader.rb +254 -0
- data/lib/spec_forge/matchers.rb +130 -0
- data/lib/spec_forge/normalizer/configuration.rb +24 -11
- data/lib/spec_forge/normalizer/constraint.rb +21 -8
- data/lib/spec_forge/normalizer/expectation.rb +31 -12
- data/lib/spec_forge/normalizer/factory.rb +24 -11
- data/lib/spec_forge/normalizer/factory_reference.rb +27 -13
- data/lib/spec_forge/normalizer/global_context.rb +88 -0
- data/lib/spec_forge/normalizer/spec.rb +39 -16
- data/lib/spec_forge/normalizer.rb +255 -41
- 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 +99 -0
- data/lib/spec_forge/runner.rb +132 -123
- data/lib/spec_forge/spec/expectation/constraint.rb +91 -20
- data/lib/spec_forge/spec/expectation.rb +43 -51
- data/lib/spec_forge/spec.rb +83 -96
- data/lib/spec_forge/type.rb +36 -4
- data/lib/spec_forge/version.rb +4 -1
- data/lib/spec_forge.rb +161 -76
- metadata +20 -5
- data/spec_forge/factories/user.yml +0 -4
- data/spec_forge/forge_helper.rb +0 -48
- data/spec_forge/specs/users.yml +0 -65
@@ -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,9 +74,7 @@ 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
|
@@ -46,8 +85,11 @@ module SpecForge
|
|
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
|
@@ -2,12 +2,25 @@
|
|
2
2
|
|
3
3
|
module SpecForge
|
4
4
|
class Attribute
|
5
|
+
#
|
6
|
+
# Represents an attribute that is a literal value
|
7
|
+
#
|
8
|
+
# This is the simplest form of attribute, storing values like strings, numbers,
|
9
|
+
# and booleans without any processing.
|
10
|
+
#
|
11
|
+
# @example Basic usage in YAML
|
12
|
+
# name: "John Doe"
|
13
|
+
# age: 42
|
14
|
+
# active: true
|
15
|
+
#
|
5
16
|
class Literal < Attribute
|
17
|
+
# @return [Object] The literal value
|
6
18
|
attr_reader :value
|
7
19
|
|
8
20
|
#
|
9
|
-
#
|
10
|
-
#
|
21
|
+
# Creates a new literal attribute with the specified value
|
22
|
+
#
|
23
|
+
# @param input [Object] The value to store
|
11
24
|
#
|
12
25
|
def initialize(input)
|
13
26
|
super
|
@@ -2,28 +2,65 @@
|
|
2
2
|
|
3
3
|
module SpecForge
|
4
4
|
class Attribute
|
5
|
+
#
|
6
|
+
# Represents an attribute that uses RSpec matchers for response validation
|
7
|
+
#
|
8
|
+
# This class allows SpecForge to integrate with RSpec's powerful matchers
|
9
|
+
# for flexible response validation. It supports most of the built-in RSpec matchers
|
10
|
+
# and most custom matchers, assuming they do not require more Ruby code
|
11
|
+
#
|
12
|
+
# @example Basic matchers in YAML
|
13
|
+
# be_true: be.true
|
14
|
+
# include_admin:
|
15
|
+
# matcher.include:
|
16
|
+
# - admin
|
17
|
+
#
|
18
|
+
# @example Comparison matchers
|
19
|
+
# count:
|
20
|
+
# be.greater_than: 5
|
21
|
+
#
|
22
|
+
# @example Type checking
|
23
|
+
# name: kind_of.string
|
24
|
+
# id: kind_of.integer
|
25
|
+
#
|
5
26
|
class Matcher < Parameterized
|
6
|
-
|
27
|
+
#
|
28
|
+
# Helper class to access RSpec matcher methods
|
29
|
+
#
|
30
|
+
class RSpecMatchers
|
7
31
|
include RSpec::Matchers
|
8
32
|
end
|
9
33
|
|
10
|
-
|
11
|
-
|
34
|
+
#
|
35
|
+
# Regular expression pattern that matches attribute keywords with this prefix
|
36
|
+
# Used for identifying this attribute type during parsing
|
37
|
+
#
|
38
|
+
# @return [Regexp]
|
39
|
+
#
|
40
|
+
KEYWORD_REGEX = /^matchers?\.|^be\.|^kind_of\./i
|
41
|
+
|
42
|
+
#
|
43
|
+
# Instance of Methods providing access to all RSpec matchers
|
44
|
+
#
|
45
|
+
MATCHER_METHODS = RSpecMatchers.new.freeze
|
12
46
|
|
47
|
+
#
|
48
|
+
# Mapping of literal string values to their Ruby equivalents
|
49
|
+
# Used for be.nil, be.true, and be.false matchers
|
50
|
+
#
|
13
51
|
LITERAL_MAPPINGS = {
|
14
52
|
"nil" => nil,
|
15
53
|
"true" => true,
|
16
54
|
"false" => false
|
17
55
|
}.freeze
|
18
56
|
|
57
|
+
#
|
58
|
+
# The resolved RSpec matcher method to call
|
59
|
+
#
|
19
60
|
attr_reader :matcher_method
|
20
61
|
|
21
62
|
#
|
22
|
-
#
|
23
|
-
#
|
24
|
-
# matcher.<method>
|
25
|
-
# be.<method>
|
26
|
-
# kind_of.<method>
|
63
|
+
# Creates a new matcher attribute with the specified matcher and arguments
|
27
64
|
#
|
28
65
|
def initialize(...)
|
29
66
|
super
|
@@ -37,28 +74,87 @@ module SpecForge
|
|
37
74
|
when "kind_of"
|
38
75
|
resolve_kind_of_matcher(method)
|
39
76
|
else
|
40
|
-
|
77
|
+
resolve_base_matcher(method)
|
41
78
|
end
|
42
79
|
|
43
80
|
prepare_arguments!
|
81
|
+
|
82
|
+
# An argument can be an expanded version of something (such as matcher.include)
|
83
|
+
# Move it to where it belongs
|
84
|
+
if (keyword = arguments[:keyword]) && !Type.hash?(keyword)
|
85
|
+
arguments[:positional] << keyword
|
86
|
+
arguments[:keyword] = {}
|
87
|
+
end
|
44
88
|
end
|
45
89
|
|
90
|
+
#
|
91
|
+
# Returns the result of applying the matcher with the given arguments
|
92
|
+
# Creates an RSpec matcher that can be used in expectations
|
93
|
+
#
|
94
|
+
# @return [RSpec::Matchers::BuiltIn::BaseMatcher] The configured matcher
|
95
|
+
#
|
46
96
|
def value
|
47
97
|
if (positional = arguments[:positional]) && positional.present?
|
48
|
-
positional = positional.
|
98
|
+
positional = positional.resolved.each do |value|
|
49
99
|
value.deep_stringify_keys! if value.respond_to?(:deep_stringify_keys!)
|
50
100
|
end
|
51
101
|
|
52
102
|
matcher_method.call(*positional)
|
53
103
|
elsif (keyword = arguments[:keyword]) && keyword.present?
|
54
|
-
matcher_method.call(**keyword.
|
104
|
+
matcher_method.call(**keyword.resolved.deep_stringify_keys)
|
55
105
|
else
|
56
106
|
matcher_method.call
|
57
107
|
end
|
58
108
|
end
|
59
109
|
|
110
|
+
#
|
111
|
+
# Ensures proper conversion of nested matcher arguments based on context
|
112
|
+
#
|
113
|
+
# This method overrides handles a special case of matchers that take arguments
|
114
|
+
# which themselves might need to be converted to matchers. It skips conversion
|
115
|
+
# for string arguments that should remain strings
|
116
|
+
# (like with include, start_with, and end_with) while correctly handling nested
|
117
|
+
# matchers and other argument types.
|
118
|
+
#
|
119
|
+
# @example Problem case handled
|
120
|
+
# # In YAML:
|
121
|
+
# matcher.all:
|
122
|
+
# matcher.include:
|
123
|
+
# - /@/ # Should become match(/@/) when used with include
|
124
|
+
#
|
125
|
+
# @example Edge case handled
|
126
|
+
# # In YAML:
|
127
|
+
# matcher.include: "." # Should remain a string, not eq(".")
|
128
|
+
#
|
129
|
+
# @return [RSpec::Matchers::BuiltIn::BaseMatcher] The properly configured matcher
|
130
|
+
# with all arguments correctly converted based on context
|
131
|
+
#
|
132
|
+
def resolve_as_matcher
|
133
|
+
# Argument conversion only matters for the base matchers
|
134
|
+
if input.start_with?("matcher")
|
135
|
+
block = lambda do |argument|
|
136
|
+
next argument unless convert_argument?(argument)
|
137
|
+
|
138
|
+
argument.resolve_as_matcher
|
139
|
+
end
|
140
|
+
|
141
|
+
arguments[:positional].map!(&block)
|
142
|
+
arguments[:keyword].transform_values!(&block)
|
143
|
+
end
|
144
|
+
|
145
|
+
super
|
146
|
+
end
|
147
|
+
|
60
148
|
private
|
61
149
|
|
150
|
+
#
|
151
|
+
# Extracts the namespace and method name from the input string
|
152
|
+
# For example, "be.empty" would return ["be", "empty"]
|
153
|
+
#
|
154
|
+
# @return [Array<String, String>] The namespace and method name
|
155
|
+
#
|
156
|
+
# @private
|
157
|
+
#
|
62
158
|
def extract_namespace_and_method
|
63
159
|
sections = input.split(".", 2)
|
64
160
|
|
@@ -69,10 +165,51 @@ module SpecForge
|
|
69
165
|
end
|
70
166
|
end
|
71
167
|
|
168
|
+
#
|
169
|
+
# Resolves a matcher with the "matcher" prefix
|
170
|
+
#
|
171
|
+
# @param method [String] The method part after "matcher."
|
172
|
+
#
|
173
|
+
# @return [Method] The resolved matcher method
|
174
|
+
#
|
175
|
+
# @private
|
176
|
+
#
|
177
|
+
def resolve_base_matcher(method)
|
178
|
+
if method == "and"
|
179
|
+
resolve_matcher("forge_and")
|
180
|
+
else
|
181
|
+
resolve_matcher(method)
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
#
|
186
|
+
# Resolves a matcher method by name from the given namespace
|
187
|
+
#
|
188
|
+
# @param method_name [String, Symbol] The matcher method name
|
189
|
+
# @param namespace [Object] The object to resolve the method from
|
190
|
+
#
|
191
|
+
# @return [Method] The resolved matcher method
|
192
|
+
#
|
193
|
+
# @private
|
194
|
+
#
|
72
195
|
def resolve_matcher(method_name, namespace: MATCHER_METHODS)
|
196
|
+
if !namespace.respond_to?(method_name)
|
197
|
+
raise Error::UndefinedMatcherError, method_name
|
198
|
+
end
|
199
|
+
|
73
200
|
namespace.public_method(method_name)
|
74
201
|
end
|
75
202
|
|
203
|
+
#
|
204
|
+
# Resolves a matcher with the "be" prefix
|
205
|
+
# Handles special cases like be.true, be.nil, comparison operators, etc.
|
206
|
+
#
|
207
|
+
# @param method [String] The method part after "be."
|
208
|
+
#
|
209
|
+
# @return [Method] The resolved matcher method
|
210
|
+
#
|
211
|
+
# @private
|
212
|
+
#
|
76
213
|
def resolve_be_matcher(method)
|
77
214
|
# Resolve any custom matchers
|
78
215
|
resolved_matcher =
|
@@ -107,12 +244,50 @@ module SpecForge
|
|
107
244
|
resolve_matcher(:"be_#{method}")
|
108
245
|
end
|
109
246
|
|
247
|
+
#
|
248
|
+
# Resolves a kind_of matcher for the given type
|
249
|
+
# For example, kind_of.string would check if an object is a String
|
250
|
+
#
|
251
|
+
# @param method [String] The type name to check for
|
252
|
+
#
|
253
|
+
# @return [Method] The resolved matcher method
|
254
|
+
#
|
255
|
+
# @private
|
256
|
+
#
|
110
257
|
def resolve_kind_of_matcher(method)
|
111
258
|
type_class = Object.const_get(method.capitalize)
|
112
259
|
arguments[:positional].insert(0, type_class)
|
113
260
|
|
114
261
|
resolve_matcher(:be_kind_of)
|
115
262
|
end
|
263
|
+
|
264
|
+
#
|
265
|
+
# Determines whether an argument should skip conversion to a matcher
|
266
|
+
#
|
267
|
+
# This helper method handles the case where string arguments to certain matchers
|
268
|
+
# (include, start_with, end_with) should remain as strings rather than being
|
269
|
+
# converted to eq() matchers.
|
270
|
+
#
|
271
|
+
# @param argument [Object] The argument to analyze
|
272
|
+
#
|
273
|
+
# @return [Boolean] true if the argument should skip conversion, false otherwise
|
274
|
+
#
|
275
|
+
# @example Skip conversion
|
276
|
+
# skip_argument_conversion?(Attribute::Literal.new(".")) #=> true
|
277
|
+
# # When used with include, start_with, or end_with
|
278
|
+
#
|
279
|
+
# @example Apply conversion
|
280
|
+
# skip_argument_conversion?(Attribute::Regex.new("/@/")) #=> false
|
281
|
+
# # Regex should be converted to match(/@/)
|
282
|
+
#
|
283
|
+
def convert_argument?(argument)
|
284
|
+
return true if argument.is_a?(Attribute::Matcher) || argument.is_a?(Attribute::Regex)
|
285
|
+
|
286
|
+
return true unless [:include, :start_with, :end_with].include?(matcher_method.name)
|
287
|
+
|
288
|
+
resolved = argument.resolved
|
289
|
+
resolved.is_a?(Array) || resolved.is_a?(Hash)
|
290
|
+
end
|
116
291
|
end
|
117
292
|
end
|
118
293
|
end
|