fix 1.0.0.beta12 → 1.0.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.
data/lib/fix/dsl.rb CHANGED
@@ -1,122 +1,174 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "defi/method"
3
+ require "defi"
4
+ require "spectus"
4
5
 
6
+ require_relative "environment"
7
+ require_relative "level"
5
8
  require_relative "matcher"
6
- require_relative "requirement"
9
+ require_relative "node"
7
10
 
8
11
  module Fix
9
- # Abstract class for handling the domain-specific language.
12
+ # The language of a specification. The block of `Fix :Name do … end` runs
13
+ # on an instance of this class, thrown away afterwards, whose four words
14
+ # build the contexts of the specification: `let`, `with`, `on` and `it`.
15
+ # The matchers ({Fix::Matcher}) and the levels ({Fix::Level}) are words of
16
+ # it too.
10
17
  #
11
- # @api private
18
+ # A gem may add a word that composes these four: fix-its adds `its`.
12
19
  class Dsl
13
- extend Matcher
14
- extend Requirement
20
+ include Level
21
+ include Matcher
15
22
 
16
- # Sets a user-defined property.
17
- #
18
- # @example
19
- # require "fix"
20
- #
21
- # Fix do
22
- # let(:name) { "Bob" }
23
- # end
24
- #
25
- # @param name [String, Symbol] The name of the property.
26
- # @yield The block that defines the property's value
27
- # @yieldreturn [Object] The value to be returned by the property
28
- #
29
- # @return [Symbol] A private method that define the block content.
30
- #
31
- # @api public
32
- def self.let(name, &)
33
- private define_method(name, &)
23
+ # The four words.
24
+ WORDS = %i[let with on it].freeze
25
+ private_constant :WORDS
26
+
27
+ # @yield the document of a specification
28
+ # @return [Array<Fix::Node>] its contexts, each before its children
29
+ def self.build(&)
30
+ dsl = new({}.freeze, [].freeze, "")
31
+ dsl.instance_exec(&)
32
+ dsl.nodes.freeze
34
33
  end
35
34
 
36
- # Defines an example group with user-defined properties that describes a
37
- # unit to be tested.
38
- #
39
- # @example
40
- # require "fix"
41
- #
42
- # Fix do
43
- # with password: "secret" do
44
- # it MUST be true
45
- # end
46
- # end
47
- #
48
- # @param kwargs [Hash] The list of propreties.
49
- # @yield The block that defines the specs for this context
50
- # @yieldreturn [void]
51
- #
52
- # @api public
53
- def self.with(**kwargs, &)
54
- klass = ::Class.new(self)
55
- klass.const_get(:CONTEXTS) << klass
56
- kwargs.each { |name, value| klass.let(name) { value } }
57
- klass.instance_eval(&)
58
- klass
35
+ # @param lets [Hash{Symbol => Proc}] the lets inherited, frozen
36
+ # @param challenges [Array<Defi::Challenge>] the chain inherited, frozen
37
+ # @param chain [String] its text
38
+ # @param own [Array<Symbol>] the lets this context declares already,
39
+ # those a `with` gave it
40
+ # @api private
41
+ def initialize(lets, challenges, chain, own = [])
42
+ @lets = lets
43
+ @challenges = challenges
44
+ @chain = chain
45
+ @own = own
46
+ @expectations = []
47
+ @children = []
59
48
  end
60
49
 
61
- # Defines an example group that describes a unit to be tested.
62
- #
63
- # @example
64
- # require "fix"
65
- #
66
- # Fix do
67
- # on :+, 2 do
68
- # it MUST be 42
69
- # end
70
- # end
71
- #
72
- # @param method_name [String, Symbol] The method to send to the subject.
73
- # @param block [Proc] The block to define the specs.
74
- #
75
- # @api public
76
- def self.on(method_name, *args, **kwargs, &block)
77
- klass = ::Class.new(self)
78
- klass.const_get(:CONTEXTS) << klass
50
+ # @return [Array<Fix::Node>] this context, then its children, as they
51
+ # stand
52
+ def nodes
53
+ node = Node.new(lets: @lets, challenges: @challenges, chain: @chain, expectations: @expectations.dup.freeze)
54
+ [node] + @children
55
+ end
79
56
 
80
- const_set(:"Child#{block.object_id}", klass)
57
+ # A parameter of the subject: its block runs where the subject is built,
58
+ # once per expectation, and may call other lets. A let is not visible
59
+ # from an expectation.
60
+ #
61
+ # @param name [Symbol] its name, which is not a method of the environment
62
+ # nor a word of the language, and not a let of this context already
63
+ # @yieldreturn [Object] its value
64
+ # @return [nil]
65
+ # @raise [ArgumentError] otherwise, or without a block
66
+ def let(name, &block)
67
+ raise ::ArgumentError, "a block must be given" unless block
81
68
 
82
- klass.define_singleton_method(:challenges) do
83
- challenge = ::Defi::Method.new(method_name, *args, **kwargs)
84
- super() + [challenge]
85
- end
69
+ free!(name)
70
+ raise ::ArgumentError, "#{name} is already a let of this context" if @own.include?(name)
86
71
 
87
- klass.instance_eval(&block)
88
- klass
72
+ @own << name
73
+ @lets = @lets.merge(name => block).freeze
74
+ nil
89
75
  end
90
76
 
91
- # Defines a concrete spec definition.
92
- #
93
- # @example
94
- # require "fix"
95
- #
96
- # Fix { it MUST be 42 }
97
- #
98
- # Fix do
99
- # it { MUST be 42 }
100
- # end
101
- #
102
- # @api public
103
- def self.it(requirement = nil, &block)
104
- raise ::ArgumentError, "Must provide either requirement or block, not both" if requirement && block
105
- raise ::ArgumentError, "Must provide either requirement or block" unless requirement || block
77
+ # A context where lets take the values given: the objects themselves,
78
+ # shared by the expectations of the context, which declares these lets.
79
+ #
80
+ # @param values [Hash{Symbol => Object}] the lets, by name
81
+ # @yield the context
82
+ # @return [nil]
83
+ # @raise [ArgumentError] for a name a let could not take, without a value
84
+ # or without a block
85
+ def with(**values, &block)
86
+ raise ::ArgumentError, "a block must be given" unless block
87
+ raise ::ArgumentError, "a with gives a value to a let: with(name: 1)" if values.empty?
106
88
 
107
- location = caller_locations(1, 1).fetch(0)
108
- location = [location.path, location.lineno].join(":")
89
+ values.each_key { |name| free!(name) }
90
+ lets = @lets.merge(values.transform_values { |value| -> { value } }).freeze
91
+ context(lets, @challenges, @chain, values.keys, &block)
92
+ end
93
+
94
+ # A context about what a method of the subject returns, or raises: the
95
+ # method is sent, with its arguments, to the value of the context above.
96
+ # No block can be given to the method: the block is the context.
97
+ #
98
+ # @param name [Symbol] the method, followed by its positional and keyword
99
+ # arguments, as they would be written after it
100
+ # @yield the context
101
+ # @return [nil]
102
+ # @raise [ArgumentError] when the name is not a Symbol, or without a block
103
+ def on(name, *, **, &block)
104
+ raise ::ArgumentError, "a block must be given" unless block
109
105
 
110
- define_method(:"test_#{(requirement || block).object_id}") do
111
- [location, requirement || singleton_class.class_eval(&block), self.class.challenges]
112
- end
106
+ challenge = ::Defi::Challenge.new(name, *, **)
107
+ context(@lets, (@challenges + [challenge]).freeze, text("#{@chain}#{challenge}"), &block)
113
108
  end
114
109
 
115
- # The list of challenges to be addressed to the object to be tested.
116
- #
117
- # @return [Array<Defi::Method>] A list of challenges.
118
- def self.challenges
119
- []
110
+ # An expectation: a requirement, built here and now with a level and a
111
+ # matcher, at the line of the call.
112
+ #
113
+ # @param requirement [Spectus::Requirement] `MUST eq(1)`, say
114
+ # @param location [Thread::Backtrace::Location, nil] where the expectation
115
+ # is written, for a word that composes `it`; the caller of `it` otherwise
116
+ # @return [nil]
117
+ # @raise [ArgumentError] without a requirement, or with a block
118
+ # @raise [TypeError] when the `to_s` of the matcher is not a String
119
+ def it(requirement = nil, location: nil, &block)
120
+ raise ::ArgumentError, "it takes a requirement, not a block: it MUST eq(1)" if block
121
+ raise ::ArgumentError, "it takes a requirement: it MUST eq(1)" unless ::Spectus::Requirement === requirement
122
+ raise ::TypeError, "the to_s of the matcher must be a String" unless ::String === requirement.matcher.to_s
123
+
124
+ location ||= caller_locations(1, 1).fetch(0)
125
+ @expectations << Expectation.new(location: locate(location), requirement:)
126
+ nil
127
+ end
128
+
129
+ private
130
+
131
+ # A let named in an expectation is a mistake with a remedy.
132
+ def method_missing(name, ...)
133
+ return super unless @lets.key?(name)
134
+
135
+ raise ::NameError.new("#{name} is a let, visible in the subject block and in other lets only", name)
136
+ end
137
+
138
+ def respond_to_missing?(_name, _include_private = false)
139
+ false
140
+ end
141
+
142
+ # A child context, whose nodes follow this one's.
143
+ def context(lets, challenges, chain, own = [], &)
144
+ child = Dsl.new(lets, challenges, chain, own)
145
+ child.instance_exec(&)
146
+ @children.concat(child.nodes)
147
+ nil
148
+ end
149
+
150
+ # A name a let can take: a Symbol that is not a method of the environment,
151
+ # public or private, nor a public word of the language, so that nothing
152
+ # is redefined in silence.
153
+ def free!(name)
154
+ raise ::ArgumentError, "the name of a let must be a Symbol" unless ::Symbol === name
155
+ raise ::ArgumentError, "#{name} is a word of the specification language" if WORDS.include?(name)
156
+
157
+ owner = [Environment, Dsl].find { |klass| klass.method_defined?(name) }
158
+ owner ||= Environment if Environment.private_method_defined?(name)
159
+ raise ::ArgumentError, "#{name} is already a method of #{owner.instance_method(name).owner}" if owner
160
+ end
161
+
162
+ # `file:line`, the file relative to the current directory when it is
163
+ # under it.
164
+ def locate(location)
165
+ path = ::File.absolute_path(location.path).delete_prefix(::File.join(::Dir.pwd, ""))
166
+ text("#{path}:#{location.lineno}")
167
+ end
168
+
169
+ # A text of a result, valid UTF-8 and frozen, as those of Spectus.
170
+ def text(string)
171
+ string.encode(::Encoding::UTF_8, invalid: :replace, undef: :replace).freeze
120
172
  end
121
173
  end
122
174
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fix
4
+ # Where a subject is built: an object with a method for each let, whose
5
+ # value is computed once, the first time it is called, by running the
6
+ # block of the let here, so that a let can call another. A new environment
7
+ # is made for each expectation. It is an ordinary object, so that the
8
+ # blocks of a specification can call `Kernel` and the private methods of
9
+ # the top level, as any Ruby code does.
10
+ #
11
+ # @api private
12
+ class Environment
13
+ # @param lets [Hash{Symbol => Proc}] the lets of a context
14
+ def initialize(lets)
15
+ values = {}
16
+ lets.each do |name, block|
17
+ define_singleton_method(name) do
18
+ values.fetch(name) { values[name] = instance_exec(&block) }
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ private_constant :Environment
25
+ end
data/lib/fix/level.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spectus"
4
+
5
+ module Fix
6
+ # The requirement levels of RFC 2119, as the words of a specification:
7
+ # each one turns a matcher into a `Spectus::Requirement`. A MAY has no
8
+ # negative form.
9
+ module Level
10
+ # @param matcher [#match?] the matcher
11
+ # @return [Spectus::Requirement] an absolute requirement
12
+ def MUST(matcher) # rubocop:disable Naming/MethodName
13
+ ::Spectus.must(matcher)
14
+ end
15
+
16
+ # @param matcher [#match?] the matcher
17
+ # @return [Spectus::Requirement] an absolute prohibition
18
+ def MUST_NOT(matcher) # rubocop:disable Naming/MethodName
19
+ ::Spectus.must_not(matcher)
20
+ end
21
+
22
+ # @param matcher [#match?] the matcher
23
+ # @return [Spectus::Requirement] a recommendation
24
+ def SHOULD(matcher) # rubocop:disable Naming/MethodName
25
+ ::Spectus.should(matcher)
26
+ end
27
+
28
+ # @param matcher [#match?] the matcher
29
+ # @return [Spectus::Requirement] a negated recommendation
30
+ def SHOULD_NOT(matcher) # rubocop:disable Naming/MethodName
31
+ ::Spectus.should_not(matcher)
32
+ end
33
+
34
+ # @param matcher [#match?] the matcher
35
+ # @return [Spectus::Requirement] an option
36
+ def MAY(matcher) # rubocop:disable Naming/MethodName
37
+ ::Spectus.may(matcher)
38
+ end
39
+ end
40
+ end
data/lib/fix/matcher.rb CHANGED
@@ -3,47 +3,98 @@
3
3
  require "matchi"
4
4
 
5
5
  module Fix
6
- # Collection of expectation matchers.
7
- #
8
- # The following matchers are available:
9
- #
10
- # Basic Comparison:
11
- # - eq(expected) # Checks equality using eql?
12
- # - eql(expected) # Alias for eq
13
- # - be(expected) # Checks exact object identity using equal?
14
- # - equal(expected) # Alias for be
15
- #
16
- # Type Checking:
17
- # - be_an_instance_of(class) # Checks exact class match
18
- # - be_a_kind_of(class) # Checks class inheritance and module inclusion
19
- #
20
- # State & Changes:
21
- # - change(object, method) # Base for checking state changes
22
- # .by(n) # Exact change by n
23
- # .by_at_least(n) # Minimum change by n
24
- # .by_at_most(n) # Maximum change by n
25
- # .from(old).to(new) # Change from old to new value
26
- # .to(new) # Change to new value
27
- #
28
- # Value Testing:
29
- # - be_within(delta).of(value) # Checks numeric value within delta
30
- # - match(regex) # Tests against regular expression
31
- # - satisfy { |value| ... } # Custom matcher with block
32
- #
33
- # Exceptions:
34
- # - raise_exception(class) # Checks if code raises exception
35
- #
36
- # State Testing:
37
- # - be_true # Tests for true
38
- # - be_false # Tests for false
39
- # - be_nil # Tests for nil
40
- #
41
- # Predicate Matchers:
42
- # - be_* # Matches object.*? method
43
- # - have_* # Matches object.has_*? method
44
- #
45
- # @api private
6
+ # The matchers of a specification, as words: each one builds a matcher of
7
+ # Matchi, or returns a specification, which is a matcher too, and raises
8
+ # `ArgumentError` at load for an argument the matcher refuses. Any object
9
+ # with `match?` and `to_s` is accepted by the levels as well.
46
10
  module Matcher
47
- include Matchi
11
+ # @param expected [Object] the expected value
12
+ # @return [Matchi::Eq] `eql?` to the expected value, of its class
13
+ def eq(expected)
14
+ ::Matchi::Eq.new(expected)
15
+ end
16
+
17
+ # @param expected [Object] the expected object
18
+ # @return [Matchi::Be] the very same object
19
+ def be(expected)
20
+ ::Matchi::Be.new(expected)
21
+ end
22
+
23
+ # @param delta [Numeric] the tolerance
24
+ # @return [Matchi::BeWithin] to complete with `of(expected)`
25
+ def be_within(delta)
26
+ ::Matchi::BeWithin.new(delta)
27
+ end
28
+
29
+ # @param expected [Regexp] the pattern
30
+ # @return [Matchi::Match] a String or a Symbol matching the pattern
31
+ def match(expected)
32
+ ::Matchi::Match.new(expected)
33
+ end
34
+
35
+ # @param expected [Class, String, Symbol] the exception class, or its name
36
+ # @param message [String, Regexp, nil] the expected message
37
+ # @return [Matchi::RaiseException] raising that exception
38
+ def raise_exception(expected, message = nil)
39
+ ::Matchi::RaiseException.new(expected, message)
40
+ end
41
+
42
+ # @return [Matchi::Be] `true` itself
43
+ def be_true
44
+ ::Matchi::Be.new(true)
45
+ end
46
+
47
+ # @return [Matchi::Be] `false` itself
48
+ def be_false
49
+ ::Matchi::Be.new(false)
50
+ end
51
+
52
+ # @return [Matchi::Be] `nil` itself
53
+ def be_nil
54
+ ::Matchi::Be.new(nil)
55
+ end
56
+
57
+ # @param expected [Class, String, Symbol] the class, or its name
58
+ # @return [Matchi::BeAnInstanceOf] an instance of that very class
59
+ def be_an_instance_of(expected)
60
+ ::Matchi::BeAnInstanceOf.new(expected)
61
+ end
62
+
63
+ # @param expected [Module, String, Symbol] the module, or its name
64
+ # @return [Matchi::BeAKindOf] an instance of that module or of a descendant
65
+ def be_a_kind_of(expected)
66
+ ::Matchi::BeAKindOf.new(expected)
67
+ end
68
+
69
+ # The object must exist when the specification is loaded: a let, which
70
+ # lives with the subject, cannot be observed.
71
+ #
72
+ # @param object [Object] the object to observe
73
+ # @param method [Symbol, String] the method whose value may change,
74
+ # followed by its arguments and its block
75
+ # @return [Matchi::Change] a change, or one to refine with `by`,
76
+ # `by_at_least`, `by_at_most`, `from … to` or `to`
77
+ def change(object, method, ...)
78
+ ::Matchi::Change.new(object, method, ...)
79
+ end
80
+
81
+ # @yieldparam value [Object] the actual value
82
+ # @yieldreturn [Boolean] whether it satisfies the block
83
+ # @return [Matchi::Satisfy]
84
+ def satisfy(&)
85
+ ::Matchi::Satisfy.new(&)
86
+ end
87
+
88
+ # @param name [Symbol, nil] the name of a registered specification
89
+ # @yield the document of an anonymous specification, instead of a name
90
+ # @return [Fix::Spec] the specification, tested against the actual value
91
+ # as a matcher
92
+ # @raise [KeyError] when no specification has that name
93
+ # @raise [ArgumentError] with both a name and a block, or neither
94
+ def fix(name = nil, &block)
95
+ raise ::ArgumentError, "a name or a block must be given, not both" if name.nil? == block.nil?
96
+
97
+ name.nil? ? ::Fix.spec(&block) : ::Fix[name]
98
+ end
48
99
  end
49
100
  end
data/lib/fix/node.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fix
4
+ # A context of a specification, with everything needed to run its
5
+ # expectations: the lets in force, the chain of methods to send to the
6
+ # subject, and the expectations written in it.
7
+ #
8
+ # @!attribute [r] lets
9
+ # @return [Hash{Symbol => Proc}] the lets, frozen
10
+ # @!attribute [r] challenges
11
+ # @return [Array<Defi::Challenge>] the chain of the `on` above, frozen
12
+ # @!attribute [r] chain
13
+ # @return [String] its text, as `.name(args)` each, empty at the root
14
+ # @!attribute [r] expectations
15
+ # @return [Array<Fix::Expectation>] in the order written
16
+ #
17
+ # @api private
18
+ Node = ::Data.define(:lets, :challenges, :chain, :expectations)
19
+
20
+ # An expectation: a requirement at a line of the specification.
21
+ #
22
+ # @!attribute [r] location
23
+ # @return [String] `file:line`
24
+ # @!attribute [r] requirement
25
+ # @return [Spectus::Requirement]
26
+ #
27
+ # @api private
28
+ Expectation = ::Data.define(:location, :requirement)
29
+
30
+ private_constant :Node
31
+ private_constant :Expectation
32
+ end
data/lib/fix/result.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fix
4
+ # The result of an expectation: where it is written, the chain of methods
5
+ # sent to the subject, and how it went, as Spectus says it. Plain data.
6
+ #
7
+ # @!attribute [r] location
8
+ # @return [String] `file:line`
9
+ # @!attribute [r] chain
10
+ # @return [String] the methods sent to the subject, `.name(args)` each,
11
+ # empty for the subject itself
12
+ # @!attribute [r] result
13
+ # @return [Spectus::Result]
14
+ Result = ::Data.define(:location, :chain, :result) do
15
+ # @return [Boolean] whether the expectation is met
16
+ def passed?
17
+ result.passed?
18
+ end
19
+
20
+ # @return [Symbol] `:success`, `:warning`, `:info`, `:failure` or `:error`
21
+ def outcome
22
+ result.outcome
23
+ end
24
+
25
+ # @return [String] what happened, as Spectus writes it
26
+ def summary
27
+ result.summary
28
+ end
29
+
30
+ # @return [String] one line: `file:line: Outcome: summary`, any line
31
+ # break of the summary written `\n`
32
+ def to_s
33
+ "#{location}: #{result}".gsub(/\R/) { "\\n" }
34
+ end
35
+ end
36
+ end