yard_example_runner 0.1.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 +7 -0
- data/.commitlintrc.yml +38 -0
- data/.github/workflows/continuous-integration.yml +47 -0
- data/.github/workflows/enforce_conventional_commits.yml +27 -0
- data/.github/workflows/release.yml +52 -0
- data/.gitignore +28 -0
- data/.husky/commit-msg +1 -0
- data/.markdownlint.yml +25 -0
- data/.release-please-config.json +35 -0
- data/.release-please-manifest.json +3 -0
- data/.rubocop.yml +27 -0
- data/AI_POLICY.md +26 -0
- data/CHANGELOG.md +21 -0
- data/CODE_OF_CONDUCT.md +25 -0
- data/CONTRIBUTING.md +237 -0
- data/GOVERNANCE.md +103 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +23 -0
- data/MAINTAINERS.md +16 -0
- data/README.md +516 -0
- data/Rakefile +59 -0
- data/bin/setup +13 -0
- data/lib/yard/cli/run_examples.rb +297 -0
- data/lib/yard_example_runner/example/comparison.rb +250 -0
- data/lib/yard_example_runner/example/constant_sandbox.rb +123 -0
- data/lib/yard_example_runner/example/evaluator.rb +145 -0
- data/lib/yard_example_runner/example.rb +360 -0
- data/lib/yard_example_runner/expectation.rb +23 -0
- data/lib/yard_example_runner/rake.rb +92 -0
- data/lib/yard_example_runner/version.rb +7 -0
- data/lib/yard_example_runner.rb +134 -0
- data/package.json +12 -0
- data/rake_tasks/cucumber.rake +9 -0
- data/rake_tasks/gem_tasks.rake +12 -0
- data/rake_tasks/markdownlint.rake +6 -0
- data/rake_tasks/rubocop.rake +5 -0
- data/rake_tasks/yard.rake +39 -0
- data/yard_example_runner.gemspec +43 -0
- metadata +251 -0
data/Rakefile
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'English'
|
|
4
|
+
require 'rake/clean'
|
|
5
|
+
|
|
6
|
+
# Load all .rake files from tasks and its subdirectories.
|
|
7
|
+
Dir.glob('rake_tasks/**/*.rake').each { |r| load r }
|
|
8
|
+
|
|
9
|
+
CLOBBER << '.husky/_'
|
|
10
|
+
CLOBBER << 'node_modules'
|
|
11
|
+
CLOBBER << 'Gemfile.lock'
|
|
12
|
+
CLOBBER << 'package-lock.json'
|
|
13
|
+
|
|
14
|
+
default_tasks = %i[cucumber markdownlint rubocop yard]
|
|
15
|
+
|
|
16
|
+
task default: default_tasks
|
|
17
|
+
|
|
18
|
+
# Prepend a module into Rake::Task to add per-task logging without stomping on
|
|
19
|
+
# other patches that may also wrap execute.
|
|
20
|
+
module Rake
|
|
21
|
+
# Rake::Task with per-task box logging prepended.
|
|
22
|
+
class Task
|
|
23
|
+
prepend(Module.new do
|
|
24
|
+
def execute(args = nil)
|
|
25
|
+
# Only output the task name if it wasn't the only top-level task
|
|
26
|
+
# rake default # => output task name for each task called by the default task
|
|
27
|
+
# rake rubocop # => do not output the task name
|
|
28
|
+
# rake rubocop yard # => output task name for rubocop and yard
|
|
29
|
+
top_level_tasks = Rake.application.top_level_tasks
|
|
30
|
+
box("Rake task: #{name}") unless top_level_tasks.length == 1 && name == top_level_tasks[0]
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def box(message)
|
|
37
|
+
width = message.length + 2
|
|
38
|
+
puts "┌#{'─' * width}┐"
|
|
39
|
+
puts "│ #{message} │"
|
|
40
|
+
puts "└#{'─' * width}┘"
|
|
41
|
+
end
|
|
42
|
+
end)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Rake::Application with SUCCESS/FAIL summary appended after the full run.
|
|
46
|
+
# top_level is used rather than run because the Rakefile is loaded inside
|
|
47
|
+
# run, so any patch to run is applied too late to affect the current call.
|
|
48
|
+
class Application
|
|
49
|
+
prepend(Module.new do
|
|
50
|
+
def top_level
|
|
51
|
+
super
|
|
52
|
+
puts "\nSUCCESS"
|
|
53
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
54
|
+
puts "\n#{e.is_a?(SystemExit) && e.success? ? 'SUCCESS' : 'FAILED'}"
|
|
55
|
+
raise
|
|
56
|
+
end
|
|
57
|
+
end)
|
|
58
|
+
end
|
|
59
|
+
end
|
data/bin/setup
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
IFS=$'\n\t'
|
|
4
|
+
set -vx
|
|
5
|
+
|
|
6
|
+
bundle install
|
|
7
|
+
|
|
8
|
+
if [ -x "$(command -v npm)" ]; then
|
|
9
|
+
npm install
|
|
10
|
+
else
|
|
11
|
+
echo "npm is not installed"
|
|
12
|
+
echo "Install npm then re-run this script to enable the conventional commit git hook."
|
|
13
|
+
fi
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YARD
|
|
4
|
+
module CLI
|
|
5
|
+
# Implements the +yard run-examples+ command
|
|
6
|
+
#
|
|
7
|
+
# Registered with YARD's command dispatcher so that running
|
|
8
|
+
# +yard run-examples [paths...] [options]+ invokes {#run}. The full
|
|
9
|
+
# pipeline is:
|
|
10
|
+
#
|
|
11
|
+
# 1. {#parse_files} — expand the given paths/globs into +.rb+ file paths,
|
|
12
|
+
# defaulting to +app/+ and +lib/+ when none are given.
|
|
13
|
+
# 2. {#parse_examples} — YARD-parse those files and collect every
|
|
14
|
+
# +@example+ tag from the registry.
|
|
15
|
+
# 3. {#add_pwd_to_path} — ensure the current working directory is on
|
|
16
|
+
# +$LOAD_PATH+ so that +require+ calls inside examples resolve correctly.
|
|
17
|
+
# 4. {#generate_tests} — convert each tag into a {YardExampleRunner::Example}
|
|
18
|
+
# and register it as a +Minitest::Spec+.
|
|
19
|
+
# 5. {#run_tests} — schedule the specs to run via +Minitest.autorun+ when
|
|
20
|
+
# the process exits.
|
|
21
|
+
#
|
|
22
|
+
# @see YardExampleRunner::Example
|
|
23
|
+
#
|
|
24
|
+
# @see YardExampleRunner::Expectation
|
|
25
|
+
#
|
|
26
|
+
# @api private
|
|
27
|
+
#
|
|
28
|
+
class RunExamples < Command
|
|
29
|
+
# Returns the one-line description of the command shown in +yard help+
|
|
30
|
+
#
|
|
31
|
+
# @return [String] the description string
|
|
32
|
+
def description
|
|
33
|
+
'Run @example tags as tests'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Runs the command line, parsing arguments and generating tests
|
|
37
|
+
#
|
|
38
|
+
# @param [Array<String>] args Switches are passed to minitest, everything else
|
|
39
|
+
# is treated as the list of directories/files or glob
|
|
40
|
+
#
|
|
41
|
+
# @return [void]
|
|
42
|
+
#
|
|
43
|
+
def run(*args)
|
|
44
|
+
files = parse_files(args.grep_v(/^-/))
|
|
45
|
+
examples = parse_examples(files)
|
|
46
|
+
add_pwd_to_path
|
|
47
|
+
generate_tests(examples)
|
|
48
|
+
run_tests
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Expands a list of glob patterns or directory names into Ruby source file paths
|
|
54
|
+
#
|
|
55
|
+
# Each entry in +globs+ is treated as follows:
|
|
56
|
+
# - If it is an existing directory, +/**/*.rb+ is appended before
|
|
57
|
+
# expansion, recursively matching all Ruby files under that directory.
|
|
58
|
+
# - Otherwise, it is passed directly to +Dir[]+ as-is, allowing explicit
|
|
59
|
+
# file paths or glob patterns.
|
|
60
|
+
#
|
|
61
|
+
# If +globs+ is empty, it defaults to +['app', 'lib']+.
|
|
62
|
+
#
|
|
63
|
+
# @param globs [Array<String>] file paths, directory names, or glob
|
|
64
|
+
# patterns to expand; defaults to +['app', 'lib']+ when empty
|
|
65
|
+
#
|
|
66
|
+
# @return [Array<String>] the flat list of +.rb+ file paths matched
|
|
67
|
+
# by expanding all globs; an empty array if no files are matched
|
|
68
|
+
#
|
|
69
|
+
def parse_files(globs)
|
|
70
|
+
globs = %w[app lib] if globs.empty?
|
|
71
|
+
|
|
72
|
+
files = globs.map do |glob|
|
|
73
|
+
glob = "#{glob}/**/*.rb" if File.directory?(glob)
|
|
74
|
+
|
|
75
|
+
Dir[glob]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
files.flatten
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Parses the given Ruby source files and returns all +@example+ tags found
|
|
82
|
+
#
|
|
83
|
+
# Instructs YARD to parse +files+, excluding any files matched by the
|
|
84
|
+
# patterns returned from {#excluded_files}. Once parsed, the full YARD
|
|
85
|
+
# registry is loaded and every code object is inspected for +@example+
|
|
86
|
+
# tags. The resulting tags from all objects are collected and flattened
|
|
87
|
+
# into a single array.
|
|
88
|
+
#
|
|
89
|
+
# Note: YARD silently swallows parse-level errors (syntax errors,
|
|
90
|
+
# +ArgumentError+, +NotImplementedError+) and logs warnings rather than
|
|
91
|
+
# raising. Only OS-level I/O errors propagate.
|
|
92
|
+
#
|
|
93
|
+
# @param files [Array<String>] absolute or relative paths to the Ruby
|
|
94
|
+
# source files to parse
|
|
95
|
+
#
|
|
96
|
+
# @return [Array<YARD::Tags::Tag>] all +@example+ tags found across every
|
|
97
|
+
# documented code object in the parsed files, in registry order
|
|
98
|
+
#
|
|
99
|
+
# @raise [Errno::EACCES] if a file in +files+ exists but cannot be read
|
|
100
|
+
# due to insufficient permissions
|
|
101
|
+
#
|
|
102
|
+
# @raise [Errno::ENOENT] if a file in +files+ is deleted between directory
|
|
103
|
+
# expansion and the point at which YARD reads it
|
|
104
|
+
#
|
|
105
|
+
def parse_examples(files)
|
|
106
|
+
YARD.parse(files, excluded_files)
|
|
107
|
+
registry = YARD::Registry.load_all
|
|
108
|
+
registry.all.map { |object| object.tags(:example) }.flatten
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns the list of file patterns to exclude from YARD parsing
|
|
112
|
+
#
|
|
113
|
+
# Reads the combined arguments from the command line and the +.yardopts+
|
|
114
|
+
# file (via {YARD::Config.with_yardopts}) and extracts the values of any
|
|
115
|
+
# +--exclude+ options. These patterns are passed directly to {YARD.parse}
|
|
116
|
+
# which treats them as case-insensitive regular expressions.
|
|
117
|
+
#
|
|
118
|
+
# If +--exclude+ appears without a following value (e.g. as the last
|
|
119
|
+
# argument), it is silently ignored.
|
|
120
|
+
#
|
|
121
|
+
# @return [Array<String>] the exclusion patterns, one per +--exclude+
|
|
122
|
+
# argument found; empty if none are present
|
|
123
|
+
#
|
|
124
|
+
def excluded_files
|
|
125
|
+
excluded = []
|
|
126
|
+
args = YARD::Config.with_yardopts { YARD::Config.arguments.dup }
|
|
127
|
+
args.each_with_index do |arg, i|
|
|
128
|
+
next unless arg == '--exclude'
|
|
129
|
+
next if args[i + 1].nil?
|
|
130
|
+
|
|
131
|
+
excluded << args[i + 1]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
excluded
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Generates an in-memory Minitest spec for each +@example+ tag
|
|
138
|
+
#
|
|
139
|
+
# Calls {#build_spec} to construct a {YardExampleRunner::Example} for
|
|
140
|
+
# each tag, then calls +generate+ on it, which dynamically defines and
|
|
141
|
+
# registers an anonymous +Minitest::Spec+ subclass. The registered specs
|
|
142
|
+
# are held in memory by Minitest and executed when {#run_tests} triggers
|
|
143
|
+
# the test run.
|
|
144
|
+
#
|
|
145
|
+
# @param examples [Array<YARD::Tags::Tag>] the +@example+ tags to convert
|
|
146
|
+
# into Minitest specs, as returned by {#parse_examples}
|
|
147
|
+
#
|
|
148
|
+
# @return [void]
|
|
149
|
+
#
|
|
150
|
+
def generate_tests(examples)
|
|
151
|
+
examples.each do |example|
|
|
152
|
+
build_spec(example).generate
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Builds a {YardExampleRunner::Example} from a YARD +@example+ tag
|
|
157
|
+
#
|
|
158
|
+
# Constructs a new {YardExampleRunner::Example} and populates it with
|
|
159
|
+
# the metadata needed to generate and run a Minitest spec:
|
|
160
|
+
#
|
|
161
|
+
# - +name+ — the title from the +@example+ tag (e.g. +"Adding two numbers"+),
|
|
162
|
+
# or an empty string if the tag has no title
|
|
163
|
+
# - +definition+ — the YARD path of the owning code object
|
|
164
|
+
# (e.g. +"MyClass#my_method"+), used as the spec description
|
|
165
|
+
# - +filepath+ — the absolute path and line number of the code object's
|
|
166
|
+
# definition (e.g. +"/project/lib/my_class.rb:10"+), used to enrich
|
|
167
|
+
# failure backtraces
|
|
168
|
+
# - +expectations+ — the list of expectations parsed from the example body
|
|
169
|
+
# by {#extract_expectations}
|
|
170
|
+
#
|
|
171
|
+
# @param example [YARD::Tags::Tag] a single +@example+ tag whose +object+,
|
|
172
|
+
# +name+, and +text+ attributes will be used to populate the spec
|
|
173
|
+
#
|
|
174
|
+
# @return [YardExampleRunner::Example] the populated example object, ready
|
|
175
|
+
# to have +generate+ called on it
|
|
176
|
+
#
|
|
177
|
+
def build_spec(example)
|
|
178
|
+
YardExampleRunner::Example.new(example.name).tap do |spec|
|
|
179
|
+
spec.definition = example.object.path
|
|
180
|
+
spec.filepath = "#{Dir.pwd}/#{example.object.files.first.join(':')}"
|
|
181
|
+
spec.expectations = extract_expectations(example)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Parses the body of a YARD +@example+ tag into expectations
|
|
186
|
+
#
|
|
187
|
+
# Parses the body of a YARD +@example+ tag into a list of
|
|
188
|
+
# {YardExampleRunner::Expectation} objects
|
|
189
|
+
#
|
|
190
|
+
# The example body is first normalized by {#normalize_example_lines}, which
|
|
191
|
+
# puts each +#=>+ annotation on its own line and strips whitespace. The
|
|
192
|
+
# resulting lines are then consumed in chunks:
|
|
193
|
+
#
|
|
194
|
+
# 1. All lines up to (but not including) the next +#=>+ line are joined as the
|
|
195
|
+
# +actual+ Ruby expression.
|
|
196
|
+
# 2. The +#=>+ line that follows (if any) is stripped of its prefix and
|
|
197
|
+
# whitespace to form the +expected+ value string.
|
|
198
|
+
# 3. An {YardExampleRunner::Expectation} is appended to the result array and
|
|
199
|
+
# the process repeats.
|
|
200
|
+
#
|
|
201
|
+
# If a chunk of code lines has no following +#=>+ line, +expected+ is set to
|
|
202
|
+
# +nil+, which signals to the test runner that the expression should be
|
|
203
|
+
# evaluated but not asserted against a specific value.
|
|
204
|
+
#
|
|
205
|
+
# @example Parsing a single expectation tag.text = "sum(1, 2) #=> 3"
|
|
206
|
+
# extract_expectations(tag) #=> [Expectation[actual: "sum(1, 2)", expected:
|
|
207
|
+
# "3"]]
|
|
208
|
+
#
|
|
209
|
+
# @example Parsing multiple expectations with shared setup tag.text = "a =
|
|
210
|
+
# 1\nsum(a, 2) #=> 3\nsum(a, 3) #=> 4" extract_expectations(tag) #=>
|
|
211
|
+
# [Expectation[actual: "a = 1\nsum(a, 2)", expected: "3"],
|
|
212
|
+
# # Expectation[actual: "sum(a, 3)", expected: "4"]]
|
|
213
|
+
#
|
|
214
|
+
# @param example [YARD::Tags::Tag] the +@example+ tag whose +text+ body will be
|
|
215
|
+
# parsed into expectations
|
|
216
|
+
#
|
|
217
|
+
# @return [Array<YardExampleRunner::Expectation>] one +Expectation+ per +#=>+
|
|
218
|
+
# annotation found in the example body; each holds:
|
|
219
|
+
# - +actual+ [String] — one or more lines of Ruby code to evaluate
|
|
220
|
+
# - +expected+ [String, nil] — the expected return value, or +nil+ if the
|
|
221
|
+
# expression should be evaluated without asserting a result
|
|
222
|
+
#
|
|
223
|
+
def extract_expectations(example)
|
|
224
|
+
lines = normalize_example_lines(example.text)
|
|
225
|
+
[].tap do |arr|
|
|
226
|
+
until lines.empty?
|
|
227
|
+
actual = lines.take_while { |l| l !~ /^#=>/ }
|
|
228
|
+
expected = lines[actual.size]&.sub('#=>', '')&.strip
|
|
229
|
+
lines.slice! 0..actual.size
|
|
230
|
+
arr << YardExampleRunner::Expectation.new(actual: actual.join("\n"), expected: expected)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Normalizes the raw text of a +@example+ tag into a flat array of lines
|
|
236
|
+
#
|
|
237
|
+
# Performs three transformations in order:
|
|
238
|
+
#
|
|
239
|
+
# 1. Normalizes the +# =>+ annotation style (with a space) to +#=>+
|
|
240
|
+
# (without a space), so both forms are treated identically.
|
|
241
|
+
# 2. Ensures each +#=>+ annotation is on its own line by inserting a
|
|
242
|
+
# newline before every occurrence. This handles inline annotations
|
|
243
|
+
# such as +sum(1, 2) #=> 3+, splitting them into two lines.
|
|
244
|
+
# 3. Splits on newlines, strips leading and trailing whitespace from
|
|
245
|
+
# each line, and discards any blank lines.
|
|
246
|
+
#
|
|
247
|
+
# The resulting array is consumed by {#extract_expectations} to identify
|
|
248
|
+
# code lines and their corresponding +#=>+ assertion lines.
|
|
249
|
+
#
|
|
250
|
+
# @param text [String] the raw body text of a +@example+ tag
|
|
251
|
+
#
|
|
252
|
+
# @return [Array<String>] the normalized, non-empty lines of the example;
|
|
253
|
+
# +#=>+ lines are always separate entries, never inline with code
|
|
254
|
+
#
|
|
255
|
+
def normalize_example_lines(text)
|
|
256
|
+
text = text.gsub('# =>', '#=>')
|
|
257
|
+
text = text.gsub('#=>', "\n#=>")
|
|
258
|
+
text.split("\n").map(&:strip).reject(&:empty?)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Schedules the generated Minitest specs to run when the process exits
|
|
262
|
+
#
|
|
263
|
+
# Calls +Minitest.autorun+, which registers an +at_exit+ hook. That hook
|
|
264
|
+
# calls +Minitest.run+ and then fires any callbacks registered via
|
|
265
|
+
# +Minitest.after_run+ (including {YardExampleRunner.after_run} blocks).
|
|
266
|
+
#
|
|
267
|
+
# +Minitest.autorun+ is used rather than calling +Minitest.run+ directly
|
|
268
|
+
# because +Minitest.after_run+ callbacks are only invoked inside
|
|
269
|
+
# +autorun+'s +at_exit+ handler — a bare +Minitest.run+ call does not
|
|
270
|
+
# trigger them.
|
|
271
|
+
#
|
|
272
|
+
# @return [void]
|
|
273
|
+
#
|
|
274
|
+
def run_tests
|
|
275
|
+
Minitest.autorun
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Adds the current working directory to Ruby's load path if not present
|
|
279
|
+
#
|
|
280
|
+
# Example code in +@example+ tags is evaluated in the same Ruby process
|
|
281
|
+
# as +yard run-examples+. That code commonly uses +require+ to load the
|
|
282
|
+
# project's own files (e.g. +require 'lib/my_class'+), but Ruby's default
|
|
283
|
+
# +$LOAD_PATH+ does not include the current working directory. Without
|
|
284
|
+
# this, those +require+ calls would raise +LoadError+.
|
|
285
|
+
#
|
|
286
|
+
# This method must be called before {#generate_tests} so that any
|
|
287
|
+
# +require+ statements executed during example evaluation can resolve
|
|
288
|
+
# paths relative to the project root.
|
|
289
|
+
#
|
|
290
|
+
# @return [void]
|
|
291
|
+
#
|
|
292
|
+
def add_pwd_to_path
|
|
293
|
+
$LOAD_PATH.unshift(Dir.pwd) unless $LOAD_PATH.include?(Dir.pwd)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YardExampleRunner
|
|
4
|
+
class Example < ::Minitest::Spec
|
|
5
|
+
# Comparison and matcher logic for verifying example expectations
|
|
6
|
+
#
|
|
7
|
+
# This module is included into {Example} so that its methods have direct
|
|
8
|
+
# access to Minitest assertions (+assert+, +assert_equal+, +assert_nil+,
|
|
9
|
+
# +diff+) inherited from +Minitest::Spec+. It also delegates to the
|
|
10
|
+
# {Evaluator} (via {Example#evaluator}) for expression evaluation.
|
|
11
|
+
#
|
|
12
|
+
# The module handles three verification strategies:
|
|
13
|
+
#
|
|
14
|
+
# 1. **Block matchers** — matchers that implement +supports_block_expectations?+
|
|
15
|
+
# (e.g. RSpec's +raise_error+, +change+, +output+). The actual expression
|
|
16
|
+
# is wrapped in a +Proc+ and passed unevaluated.
|
|
17
|
+
# 2. **Value matchers** — matchers that implement +matches?+ (e.g. +eq+,
|
|
18
|
+
# +be_a+, +be_within+). The actual expression is evaluated and the
|
|
19
|
+
# resulting value is passed.
|
|
20
|
+
# 3. **Bare values** — compared via {#compare_values} using error handling,
|
|
21
|
+
# +nil+ checks, and +===+ case equality.
|
|
22
|
+
#
|
|
23
|
+
module Comparison
|
|
24
|
+
protected
|
|
25
|
+
|
|
26
|
+
# Evaluates both the actual and expected expressions and compares their results
|
|
27
|
+
#
|
|
28
|
+
# The expected expression is always evaluated first via
|
|
29
|
+
# {Evaluator#evaluate_with_assertion} (which captures any +StandardError+
|
|
30
|
+
# as a value). The result determines which of two branches is taken:
|
|
31
|
+
#
|
|
32
|
+
# 1. **Matcher** ({#matcher?}) — delegates to {#assert_matcher}, which
|
|
33
|
+
# handles both block matchers (e.g. +raise_error+) and value matchers
|
|
34
|
+
# (e.g. +eq+, +be_a+, +be_within+).
|
|
35
|
+
# 2. **Bare value** — +actual+ is also evaluated via
|
|
36
|
+
# {Evaluator#evaluate_with_assertion} and the pair is compared via
|
|
37
|
+
# {#compare_values}, which handles error-vs-error, single-error,
|
|
38
|
+
# +nil+, and +===+ cases.
|
|
39
|
+
#
|
|
40
|
+
# On failure the backtrace is decorated with the example's source location
|
|
41
|
+
# via {Example#add_filepath_to_backtrace}.
|
|
42
|
+
#
|
|
43
|
+
# @param example [Example] the owning example, used to decorate failure backtraces
|
|
44
|
+
#
|
|
45
|
+
# @param expected [String] the Ruby expression representing the expected value
|
|
46
|
+
#
|
|
47
|
+
# @param actual [String] the Ruby expression representing the actual value
|
|
48
|
+
#
|
|
49
|
+
# @param bind [Class, nil] the class scope to evaluate +actual+ in; +expected+
|
|
50
|
+
# is always evaluated in a +nil+ binding (top-level context)
|
|
51
|
+
#
|
|
52
|
+
# @return [void]
|
|
53
|
+
#
|
|
54
|
+
# @raise [Minitest::Assertion] re-raises any assertion failure with the
|
|
55
|
+
# example's filepath prepended to the backtrace
|
|
56
|
+
#
|
|
57
|
+
# @example
|
|
58
|
+
# verify_actual(example, '42', 'answer', nil)
|
|
59
|
+
#
|
|
60
|
+
# @api private
|
|
61
|
+
#
|
|
62
|
+
def verify_actual(example, expected, actual, bind)
|
|
63
|
+
expected = evaluator.evaluate_with_assertion(expected, nil)
|
|
64
|
+
|
|
65
|
+
if matcher?(expected)
|
|
66
|
+
assert_matcher(expected, actual, bind)
|
|
67
|
+
else
|
|
68
|
+
actual = evaluator.evaluate_with_assertion(actual, bind)
|
|
69
|
+
compare_values(expected, actual)
|
|
70
|
+
end
|
|
71
|
+
rescue Minitest::Assertion => e
|
|
72
|
+
add_filepath_to_backtrace(e, example.filepath)
|
|
73
|
+
raise e
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Evaluates +actual+ and asserts a matcher against it
|
|
77
|
+
#
|
|
78
|
+
# When the matcher is a {#block_matcher?}, the actual expression is wrapped
|
|
79
|
+
# in a +Proc+ and passed unevaluated so the matcher can invoke it (e.g.
|
|
80
|
+
# +raise_error+). Otherwise the actual expression is evaluated via
|
|
81
|
+
# {Evaluator#evaluate} and the resulting value is matched. If evaluation
|
|
82
|
+
# raises, the error propagates — use a block matcher like +raise_error+
|
|
83
|
+
# to assert on exceptions.
|
|
84
|
+
#
|
|
85
|
+
# @param expected [#matches?] a matcher object
|
|
86
|
+
# @param actual [String] the Ruby expression representing the actual value
|
|
87
|
+
# @param bind [Class, nil] the class scope for evaluation
|
|
88
|
+
#
|
|
89
|
+
# @return [void]
|
|
90
|
+
#
|
|
91
|
+
# @example
|
|
92
|
+
# assert_matcher(eq(42), 'answer', nil)
|
|
93
|
+
#
|
|
94
|
+
# @api private
|
|
95
|
+
#
|
|
96
|
+
def assert_matcher(expected, actual, bind)
|
|
97
|
+
subject = if block_matcher?(expected)
|
|
98
|
+
-> { evaluator.evaluate(actual, bind) }
|
|
99
|
+
else
|
|
100
|
+
evaluator.evaluate(actual, bind)
|
|
101
|
+
end
|
|
102
|
+
assert expected.matches?(subject), failure_message_for(expected)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Compares two already-evaluated values and raises on mismatch
|
|
106
|
+
#
|
|
107
|
+
# Handles four cases in priority order:
|
|
108
|
+
#
|
|
109
|
+
# 1. **Both are errors** — compares their string representations
|
|
110
|
+
# (++"#<ClassName: message>"+++) with +assert_equal+, so mismatched error
|
|
111
|
+
# types or messages produce a readable diff.
|
|
112
|
+
# 2. **Only one is an error** — raises that error directly, surfacing an
|
|
113
|
+
# unexpected exception as a test failure.
|
|
114
|
+
# 3. **Expected is +nil+** — uses +assert_nil+ so Minitest's nil-specific
|
|
115
|
+
# failure message is produced.
|
|
116
|
+
# 4. **Otherwise** — uses +assert+ with +===+ (case equality), which allows
|
|
117
|
+
# +expected+ to be a +Regexp+, a +Range+, a +Proc+, or any other object
|
|
118
|
+
# that implements a meaningful +===+.
|
|
119
|
+
#
|
|
120
|
+
# @param expected [Object] the already-evaluated expected value (or a
|
|
121
|
+
# +StandardError+ if evaluation raised)
|
|
122
|
+
#
|
|
123
|
+
# @param actual [Object] the already-evaluated actual value (or a
|
|
124
|
+
# +StandardError+ if evaluation raised)
|
|
125
|
+
#
|
|
126
|
+
# @return [void]
|
|
127
|
+
#
|
|
128
|
+
# @raise [Minitest::Assertion] if the values do not match under the applicable rule
|
|
129
|
+
#
|
|
130
|
+
# @raise [StandardError] if exactly one of the values is an error
|
|
131
|
+
#
|
|
132
|
+
# @example
|
|
133
|
+
# compare_values(42, 42)
|
|
134
|
+
#
|
|
135
|
+
# @api private
|
|
136
|
+
#
|
|
137
|
+
def compare_values(expected, actual)
|
|
138
|
+
if both_are_errors?(expected, actual)
|
|
139
|
+
assert_equal("#<#{expected.class}: #{expected}>", "#<#{actual.class}: #{actual}>")
|
|
140
|
+
elsif (error = only_one_is_error?(expected, actual))
|
|
141
|
+
raise error
|
|
142
|
+
elsif expected.nil?
|
|
143
|
+
assert_nil(actual)
|
|
144
|
+
else
|
|
145
|
+
assert expected === actual, diff(expected, actual) # rubocop:disable Style/CaseEquality
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Returns +true+ if +obj+ implements the matcher protocol
|
|
150
|
+
#
|
|
151
|
+
# Checks for the presence of a +matches?+ method, which is the standard
|
|
152
|
+
# interface for both RSpec matchers and +minitest-matchers+.
|
|
153
|
+
#
|
|
154
|
+
# @param obj [Object] the object to test
|
|
155
|
+
#
|
|
156
|
+
# @return [Boolean]
|
|
157
|
+
#
|
|
158
|
+
# @example
|
|
159
|
+
# matcher?(eq(42)) # => true
|
|
160
|
+
#
|
|
161
|
+
# @api private
|
|
162
|
+
#
|
|
163
|
+
def matcher?(obj)
|
|
164
|
+
obj.respond_to?(:matches?)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Returns +true+ if +obj+ is a block-style matcher
|
|
168
|
+
#
|
|
169
|
+
# A block matcher is a {#matcher?} that also responds to
|
|
170
|
+
# +supports_block_expectations?+ and returns +true+ from it. RSpec's
|
|
171
|
+
# +raise_error+, +change+, and +output+ matchers follow this protocol.
|
|
172
|
+
#
|
|
173
|
+
# @param obj [Object] the object to test
|
|
174
|
+
#
|
|
175
|
+
# @return [Boolean]
|
|
176
|
+
#
|
|
177
|
+
# @example
|
|
178
|
+
# block_matcher?(raise_error(RuntimeError)) # => true
|
|
179
|
+
#
|
|
180
|
+
# @api private
|
|
181
|
+
#
|
|
182
|
+
def block_matcher?(obj)
|
|
183
|
+
matcher?(obj) &&
|
|
184
|
+
obj.respond_to?(:supports_block_expectations?) &&
|
|
185
|
+
obj.supports_block_expectations?
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Returns the failure message from a matcher, with legacy fallback
|
|
189
|
+
#
|
|
190
|
+
# Tries +failure_message+ first (RSpec 3.x / modern minitest-matchers),
|
|
191
|
+
# then falls back to +failure_message_for_should+ (RSpec 2.x / older
|
|
192
|
+
# minitest-matchers).
|
|
193
|
+
#
|
|
194
|
+
# @param a_matcher [#failure_message, #failure_message_for_should] the matcher
|
|
195
|
+
#
|
|
196
|
+
# @return [String, nil] the failure message, or +nil+ if neither method exists
|
|
197
|
+
#
|
|
198
|
+
# @example
|
|
199
|
+
# failure_message_for(eq(42))
|
|
200
|
+
#
|
|
201
|
+
# @api private
|
|
202
|
+
#
|
|
203
|
+
def failure_message_for(a_matcher)
|
|
204
|
+
if a_matcher.respond_to?(:failure_message)
|
|
205
|
+
a_matcher.failure_message
|
|
206
|
+
elsif a_matcher.respond_to?(:failure_message_for_should)
|
|
207
|
+
a_matcher.failure_message_for_should
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
# Returns +true+ if both values are +StandardError+ instances
|
|
214
|
+
#
|
|
215
|
+
# @param expected [Object] the expected value
|
|
216
|
+
# @param actual [Object] the actual value
|
|
217
|
+
#
|
|
218
|
+
# @return [Boolean]
|
|
219
|
+
#
|
|
220
|
+
# @example
|
|
221
|
+
# both_are_errors?(RuntimeError.new, ArgumentError.new) # => true
|
|
222
|
+
#
|
|
223
|
+
# @api private
|
|
224
|
+
#
|
|
225
|
+
def both_are_errors?(expected, actual)
|
|
226
|
+
expected.is_a?(StandardError) && actual.is_a?(StandardError)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Returns the error if exactly one value is a +StandardError+, otherwise +nil+
|
|
230
|
+
#
|
|
231
|
+
# @param expected [Object] the expected value
|
|
232
|
+
# @param actual [Object] the actual value
|
|
233
|
+
#
|
|
234
|
+
# @return [StandardError, nil]
|
|
235
|
+
#
|
|
236
|
+
# @example
|
|
237
|
+
# only_one_is_error?(RuntimeError.new, 42) # => RuntimeError
|
|
238
|
+
#
|
|
239
|
+
# @api private
|
|
240
|
+
#
|
|
241
|
+
def only_one_is_error?(expected, actual)
|
|
242
|
+
if expected.is_a?(StandardError) && !actual.is_a?(StandardError)
|
|
243
|
+
expected
|
|
244
|
+
elsif !expected.is_a?(StandardError) && actual.is_a?(StandardError)
|
|
245
|
+
actual
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|