constable-rails 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/CHANGELOG.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +515 -0
- data/exe/constable +7 -0
- data/lib/constable/case.rb +336 -0
- data/lib/constable/cli.rb +475 -0
- data/lib/constable/cold_case/minitest.rb +342 -0
- data/lib/constable/cold_case/rspec.rb +334 -0
- data/lib/constable/cold_case.rb +280 -0
- data/lib/constable/config.rb +125 -0
- data/lib/constable/coverage.rb +951 -0
- data/lib/constable/diff.rb +212 -0
- data/lib/constable/dsl.rb +833 -0
- data/lib/constable/identity.rb +121 -0
- data/lib/constable/importer/modernizer.rb +860 -0
- data/lib/constable/importer/reopener.rb +468 -0
- data/lib/constable/importer.rb +51 -0
- data/lib/constable/investigation.rb +67 -0
- data/lib/constable/isolation.rb +171 -0
- data/lib/constable/jail.rb +399 -0
- data/lib/constable/log_router.rb +197 -0
- data/lib/constable/matchers.rb +834 -0
- data/lib/constable/order_audit.rb +130 -0
- data/lib/constable/rails_support.rb +213 -0
- data/lib/constable/railtie.rb +36 -0
- data/lib/constable/registry.rb +57 -0
- data/lib/constable/reporter.rb +625 -0
- data/lib/constable/result.rb +149 -0
- data/lib/constable/runner.rb +697 -0
- data/lib/constable/selection.rb +205 -0
- data/lib/constable/storage/adapter.rb +91 -0
- data/lib/constable/storage/mysql_adapter.rb +125 -0
- data/lib/constable/storage/postgres_adapter.rb +125 -0
- data/lib/constable/storage/sqlite_adapter.rb +84 -0
- data/lib/constable/storage.rb +847 -0
- data/lib/constable/version.rb +5 -0
- data/lib/constable/warrants.rb +290 -0
- data/lib/constable-rails.rb +16 -0
- data/lib/constable.rb +151 -0
- data/lib/generators/constable/base.rb +99 -0
- data/lib/generators/constable/channel/channel_generator.rb +20 -0
- data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
- data/lib/generators/constable/controller/controller_generator.rb +25 -0
- data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
- data/lib/generators/constable/generator/generator_generator.rb +31 -0
- data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
- data/lib/generators/constable/helper/helper_generator.rb +23 -0
- data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
- data/lib/generators/constable/import_generator.rb +137 -0
- data/lib/generators/constable/install_generator.rb +188 -0
- data/lib/generators/constable/integration/integration_generator.rb +27 -0
- data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
- data/lib/generators/constable/job/job_generator.rb +20 -0
- data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
- data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
- data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
- data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
- data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
- data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
- data/lib/generators/constable/model/model_generator.rb +31 -0
- data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
- data/lib/generators/constable/resource/resource_generator.rb +27 -0
- data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
- data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
- data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
- data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
- data/lib/generators/constable/system/system_generator.rb +20 -0
- data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
- data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
- data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
- data/lib/generators/constable/templates/config.yml.tt +67 -0
- data/lib/generators/constable/templates/example_case.rb.tt +56 -0
- data/lib/generators/constable/templates/matchers.rb.tt +36 -0
- data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
- metadata +209 -0
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Constable
|
|
4
|
+
# `attest(actual).to matcher` -- fluent sugar layered over the plain assertion
|
|
5
|
+
# primitives, which are always available too.
|
|
6
|
+
#
|
|
7
|
+
# A matcher is a *value*, not a method call. Inside a case, `be_created` and
|
|
8
|
+
# `exist(email: "a@b.com")` are bare method calls with no receiver: they fall through
|
|
9
|
+
# Expectations#method_missing, which builds a Deferred capturing nothing but the
|
|
10
|
+
# matcher's name, arguments and block. Nothing runs until `.to` / `.not_to` hands the
|
|
11
|
+
# Deferred the actual value. That deferral is the whole point -- the same object has to
|
|
12
|
+
# be able to phrase both "expected X to eq 1" and "expected X not to eq 1", and it can
|
|
13
|
+
# only do that if it still knows what it was asked to check when the failure happens.
|
|
14
|
+
#
|
|
15
|
+
# Failure messages always name both sides. Where the actual carries something a human
|
|
16
|
+
# would want to see -- a response body, a record's attributes, validation errors -- it
|
|
17
|
+
# is attached as `context:` on the raised AssertionFailed, because the reporter prints
|
|
18
|
+
# context underneath the failure message.
|
|
19
|
+
module Matchers
|
|
20
|
+
# A registered matcher. `deferred_class` lets a matcher whose grammar is richer than
|
|
21
|
+
# "name plus arguments" (only `change`, so far) supply its own Deferred subclass.
|
|
22
|
+
Matcher = Struct.new(:name, :block, :deferred_class)
|
|
23
|
+
|
|
24
|
+
# Enough of the status table to be useful without dragging in Rack just to translate
|
|
25
|
+
# :created into 201. Rails apps get the same answer either way.
|
|
26
|
+
HTTP_STATUS_CODES = {
|
|
27
|
+
continue: 100, switching_protocols: 101,
|
|
28
|
+
ok: 200, created: 201, accepted: 202, non_authoritative_information: 203,
|
|
29
|
+
no_content: 204, reset_content: 205, partial_content: 206,
|
|
30
|
+
multiple_choices: 300, moved_permanently: 301, found: 302, see_other: 303,
|
|
31
|
+
not_modified: 304, temporary_redirect: 307, permanent_redirect: 308,
|
|
32
|
+
bad_request: 400, unauthorized: 401, payment_required: 402, forbidden: 403,
|
|
33
|
+
not_found: 404, method_not_allowed: 405, not_acceptable: 406,
|
|
34
|
+
request_timeout: 408, conflict: 409, gone: 410, precondition_failed: 412,
|
|
35
|
+
payload_too_large: 413, unsupported_media_type: 415, im_a_teapot: 418,
|
|
36
|
+
unprocessable_entity: 422, locked: 423, too_many_requests: 429,
|
|
37
|
+
internal_server_error: 500, not_implemented: 501, bad_gateway: 502,
|
|
38
|
+
service_unavailable: 503, gateway_timeout: 504
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
HTTP_STATUS_GROUPS = {
|
|
42
|
+
informational: (100..199), success: (200..299), successful: (200..299),
|
|
43
|
+
redirect: (300..399), missing: (404..404), client_error: (400..499),
|
|
44
|
+
error: (500..599), server_error: (500..599)
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
MAX_INSPECT = 200
|
|
48
|
+
MAX_BODY = 800
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
# Constable::Matchers.define(:be_created) { |response| response.status == 201 }
|
|
52
|
+
#
|
|
53
|
+
# A truthy return passes. A block may instead return [bool, message, context] when
|
|
54
|
+
# it can say something more useful about the failure than the generic phrasing.
|
|
55
|
+
def define(name, deferred_class: Deferred, &block)
|
|
56
|
+
raise ArgumentError, "Constable::Matchers.define(:#{name}) requires a block" unless block
|
|
57
|
+
|
|
58
|
+
custom[name.to_sym] = Matcher.new(name.to_sym, block, deferred_class)
|
|
59
|
+
name.to_sym
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def matcher_for(name)
|
|
63
|
+
custom[name.to_sym] || builtins[name.to_sym]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def registered?(name)
|
|
67
|
+
!matcher_for(name).nil?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Drops every user-defined matcher and restores the built-ins any override shadowed.
|
|
71
|
+
# Built-ins are kept in their own table precisely so this can be a clean reset rather
|
|
72
|
+
# than a wipe that leaves `eq` undefined for the rest of the process.
|
|
73
|
+
def clear!
|
|
74
|
+
@custom = {}
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def names
|
|
79
|
+
(builtins.keys + custom.keys).uniq.sort
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def custom = (@custom ||= {})
|
|
83
|
+
def builtins = (@builtins ||= {})
|
|
84
|
+
|
|
85
|
+
# Names a bare call inside a case is allowed to resolve as a matcher: anything
|
|
86
|
+
# registered, plus the be_*/have_* predicate fallbacks. Deliberately narrow -- a
|
|
87
|
+
# typo'd method should still raise NoMethodError, not silently become a matcher.
|
|
88
|
+
def matcher_name?(name)
|
|
89
|
+
return true if registered?(name)
|
|
90
|
+
|
|
91
|
+
name.to_s.match?(/\A(?:be|have)_[a-z_][a-z0-9_]*\z/)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def deferred_for(name, args = [], block = nil)
|
|
95
|
+
matcher = matcher_for(name)
|
|
96
|
+
return matcher.deferred_class.new(name, args, block, matcher: matcher) if matcher
|
|
97
|
+
|
|
98
|
+
predicate = predicate_for(name)
|
|
99
|
+
unless predicate
|
|
100
|
+
raise Constable::Error,
|
|
101
|
+
"no matcher named :#{name} is registered -- define one with " \
|
|
102
|
+
"Constable::Matchers.define(:#{name}) { |actual, *args| ... }"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
PredicateDeferred.new(name, args, block, predicate: predicate)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# be_created -> #created?, have_timed_out -> #has_timed_out?
|
|
109
|
+
def predicate_for(name)
|
|
110
|
+
case name.to_s
|
|
111
|
+
when /\Abe_(.+)\z/ then "#{Regexp.last_match(1)}?"
|
|
112
|
+
when /\Ahave_(.+)\z/ then "has_#{Regexp.last_match(1)}?"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def fail!(message, context = nil)
|
|
117
|
+
raise Constable::AssertionFailed.new(message, context: context)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def truthy?(value)
|
|
121
|
+
!value.nil? && value != false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A matcher block's return value, in either of the two supported shapes. The rich
|
|
125
|
+
# form is recognised narrowly -- [bool, String-or-nil, anything] -- so a matcher that
|
|
126
|
+
# legitimately returns an array of results isn't misread as a failure tuple.
|
|
127
|
+
def normalize_result(result)
|
|
128
|
+
if result.is_a?(Array) && (2..3).cover?(result.size) &&
|
|
129
|
+
[true, false].include?(result[0]) &&
|
|
130
|
+
(result[1].nil? || result[1].is_a?(String))
|
|
131
|
+
[result[0], result[1], result[2]]
|
|
132
|
+
else
|
|
133
|
+
[truthy?(result), nil, nil]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# How the actual is named in a message. Responses and classes get their own name;
|
|
138
|
+
# everything else gets a bounded #inspect so a fat object can't drown the summary.
|
|
139
|
+
def describe(actual)
|
|
140
|
+
return "the block" if actual.is_a?(Proc)
|
|
141
|
+
return "response" if response_like?(actual)
|
|
142
|
+
return actual.name if actual.is_a?(Module) && actual.name
|
|
143
|
+
|
|
144
|
+
truncate(actual.inspect, MAX_INSPECT)
|
|
145
|
+
rescue StandardError
|
|
146
|
+
"a #{actual.class}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Reads the way the call site was written: `eq 1`, `exist email: "a@b.com"`.
|
|
150
|
+
def format_args(args)
|
|
151
|
+
args.empty? ? "" : " #{format_list(args)}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def format_list(args)
|
|
155
|
+
args.map { |a| a.is_a?(Hash) ? format_hash(a) : a.inspect }.join(", ")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def format_hash(hash)
|
|
159
|
+
hash.map { |k, v| k.is_a?(Symbol) ? "#{k}: #{v.inspect}" : "#{k.inspect} => #{v.inspect}" }.join(", ")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def truncate(string, limit)
|
|
163
|
+
str = string.to_s
|
|
164
|
+
str.length > limit ? "#{str[0, limit]}…" : str
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Whatever the failure is about, this is what someone staring at the summary
|
|
168
|
+
# actually wants to see. Every probe is defensive: an object that raises from
|
|
169
|
+
# #body simply contributes nothing.
|
|
170
|
+
def context_for(actual)
|
|
171
|
+
context = {}
|
|
172
|
+
add_body_context(context, actual)
|
|
173
|
+
add_attributes_context(context, actual)
|
|
174
|
+
add_errors_context(context, actual)
|
|
175
|
+
context.empty? ? nil : context
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def merge_context(*contexts)
|
|
179
|
+
merged = contexts.compact.reduce({}) do |acc, ctx|
|
|
180
|
+
ctx.is_a?(Hash) ? acc.merge(ctx) : acc.merge("Detail" => ctx.to_s)
|
|
181
|
+
end
|
|
182
|
+
merged.empty? ? nil : merged
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def response_like?(actual)
|
|
186
|
+
actual.respond_to?(:status) && actual.respond_to?(:body) && !actual.is_a?(Module)
|
|
187
|
+
rescue StandardError
|
|
188
|
+
false
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# 200 from anything that plausibly models an HTTP response, or nil if it isn't one.
|
|
192
|
+
def response_status(actual)
|
|
193
|
+
return actual if actual.is_a?(Integer)
|
|
194
|
+
return actual.status.to_i if actual.respond_to?(:status) && actual.status.respond_to?(:to_i)
|
|
195
|
+
return actual.code.to_i if actual.respond_to?(:code) && actual.code.to_s.match?(/\A\d+\z/)
|
|
196
|
+
|
|
197
|
+
nil
|
|
198
|
+
rescue StandardError
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def status_name(code)
|
|
203
|
+
HTTP_STATUS_CODES.key(code) || code
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def status_label(code)
|
|
207
|
+
return "no status" if code.nil?
|
|
208
|
+
|
|
209
|
+
name = HTTP_STATUS_CODES.key(code)
|
|
210
|
+
name ? ":#{name} (#{code})" : code.to_s
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def expected_status_label(expected)
|
|
214
|
+
group = HTTP_STATUS_GROUPS[expected.to_s.to_sym] if expected.is_a?(Symbol) || expected.is_a?(String)
|
|
215
|
+
return ":#{expected} (#{group.first}-#{group.last})" if group && !HTTP_STATUS_CODES.key?(expected.to_s.to_sym)
|
|
216
|
+
|
|
217
|
+
code = status_code_for(expected)
|
|
218
|
+
code ? status_label(code) : expected.inspect
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def status_code_for(expected)
|
|
222
|
+
case expected
|
|
223
|
+
when Integer then expected
|
|
224
|
+
when /\A\d+\z/ then expected.to_i
|
|
225
|
+
else HTTP_STATUS_CODES[expected.to_s.to_sym]
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def status_matches?(code, expected)
|
|
230
|
+
return false if code.nil?
|
|
231
|
+
|
|
232
|
+
key = expected.is_a?(Symbol) || expected.is_a?(String) ? expected.to_s.to_sym : nil
|
|
233
|
+
if key && HTTP_STATUS_GROUPS.key?(key) && !HTTP_STATUS_CODES.key?(key)
|
|
234
|
+
return HTTP_STATUS_GROUPS[key].cover?(code)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
expected_code = status_code_for(expected)
|
|
238
|
+
!expected_code.nil? && expected_code == code
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def response_location(actual)
|
|
242
|
+
return actual.location.to_s if actual.respond_to?(:location) && actual.location
|
|
243
|
+
|
|
244
|
+
headers = actual.respond_to?(:headers) ? actual.headers : nil
|
|
245
|
+
headers ||= actual.respond_to?(:header) ? actual.header : nil
|
|
246
|
+
return nil unless headers.respond_to?(:[])
|
|
247
|
+
|
|
248
|
+
(headers["Location"] || headers["location"])&.to_s
|
|
249
|
+
rescue StandardError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# "http://example.test/sessions/new" and "/sessions/new" describe the same redirect.
|
|
254
|
+
def location_path(location)
|
|
255
|
+
location.to_s.sub(%r{\Ahttps?://[^/]+}, "")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The text between a block's braces, used so a `change` failure can say
|
|
259
|
+
# `User.count` instead of "the value". Best-effort by design.
|
|
260
|
+
def block_source(block)
|
|
261
|
+
return nil unless block.is_a?(Proc)
|
|
262
|
+
return nil unless defined?(RubyVM::AbstractSyntaxTree)
|
|
263
|
+
|
|
264
|
+
source = RubyVM::AbstractSyntaxTree.of(block, keep_script_lines: true)&.source
|
|
265
|
+
return nil unless source
|
|
266
|
+
|
|
267
|
+
inner = source[/\{(.*)\}/m, 1] || source[/\bdo\b(.*)\bend\b/m, 1]
|
|
268
|
+
inner = inner&.strip
|
|
269
|
+
inner && !inner.empty? ? inner.gsub(/\s+/, " ") : nil
|
|
270
|
+
rescue StandardError, ScriptError
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
private
|
|
275
|
+
|
|
276
|
+
def add_body_context(context, actual)
|
|
277
|
+
return unless actual.respond_to?(:body) && !actual.is_a?(Proc)
|
|
278
|
+
|
|
279
|
+
body = actual.body
|
|
280
|
+
return if body.nil?
|
|
281
|
+
|
|
282
|
+
text = body.respond_to?(:read) ? body.read : body.to_s
|
|
283
|
+
context["Response body"] = truncate(text, MAX_BODY) unless text.strip.empty?
|
|
284
|
+
rescue StandardError
|
|
285
|
+
nil
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def add_attributes_context(context, actual)
|
|
289
|
+
return unless actual.respond_to?(:attributes)
|
|
290
|
+
|
|
291
|
+
attributes = actual.attributes
|
|
292
|
+
context["Attributes"] = truncate(attributes.inspect, MAX_BODY) if attributes.is_a?(Hash) && !attributes.empty?
|
|
293
|
+
rescue StandardError
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def add_errors_context(context, actual)
|
|
298
|
+
return unless actual.respond_to?(:errors)
|
|
299
|
+
|
|
300
|
+
errors = actual.errors
|
|
301
|
+
messages = errors.respond_to?(:full_messages) ? errors.full_messages : nil
|
|
302
|
+
context["Errors"] = messages.join(", ") if messages.respond_to?(:join) && !messages.empty?
|
|
303
|
+
rescue StandardError
|
|
304
|
+
nil
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# A matcher that knows its name and arguments but not yet its subject.
|
|
309
|
+
class Deferred
|
|
310
|
+
attr_reader :name, :args, :block, :matcher
|
|
311
|
+
|
|
312
|
+
def initialize(name, args = [], block = nil, matcher: nil)
|
|
313
|
+
@name = name.to_sym
|
|
314
|
+
@args = Array(args)
|
|
315
|
+
@block = block
|
|
316
|
+
@matcher = matcher
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# => [passed, message, context]
|
|
320
|
+
def matches?(actual)
|
|
321
|
+
Matchers.normalize_result(invoke(actual))
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def invoke(actual)
|
|
325
|
+
@matcher.block.call(actual, *@args, &@block)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# "eq 1", "be a String", "exist email: \"a@b.com\"" -- the phrase both the positive
|
|
329
|
+
# and the negated message are built around, so negation never needs its own matcher.
|
|
330
|
+
def description
|
|
331
|
+
phrase = @name.to_s.sub(/\Abe_/, "be ").sub(/\Ahave_/, "have ").tr("_", " ")
|
|
332
|
+
"#{phrase}#{Matchers.format_args(@args)}"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def failure_message(actual)
|
|
336
|
+
"expected #{Matchers.describe(actual)} to #{description}"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def negated_failure_message(actual)
|
|
340
|
+
"expected #{Matchers.describe(actual)} not to #{description}"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def context_for(actual)
|
|
344
|
+
Matchers.context_for(actual)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# The be_*/have_* fallback: with no matcher registered under the name, the name itself
|
|
349
|
+
# is the assertion -- `be_created` asks the actual whether it is `created?`.
|
|
350
|
+
class PredicateDeferred < Deferred
|
|
351
|
+
attr_reader :predicate
|
|
352
|
+
|
|
353
|
+
def initialize(name, args = [], block = nil, predicate:)
|
|
354
|
+
super(name, args, block)
|
|
355
|
+
@predicate = predicate
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def matches?(actual)
|
|
359
|
+
unless actual.respond_to?(@predicate)
|
|
360
|
+
return [false, "expected #{Matchers.describe(actual)} to respond to ##{@predicate} " \
|
|
361
|
+
"(no matcher named :#{@name} is registered), but it does not",
|
|
362
|
+
Matchers.context_for(actual)]
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
passed = Matchers.truthy?(actual.public_send(@predicate, *@args, &@block))
|
|
366
|
+
[passed, passed ? nil : failure_message(actual), Matchers.context_for(actual)]
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def failure_message(actual)
|
|
370
|
+
"#{super}, but #{owner_name(actual)}##{@predicate} returned false"
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def negated_failure_message(actual)
|
|
374
|
+
"#{super}, but #{owner_name(actual)}##{@predicate} returned true"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
private
|
|
378
|
+
|
|
379
|
+
def owner_name(actual)
|
|
380
|
+
actual.is_a?(Module) ? actual.name.to_s : actual.class.name.to_s
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# `change` is the one matcher whose grammar is more than name-plus-arguments: it needs
|
|
385
|
+
# a before/after sampling around the action, and .by/.from/.to chaining to say what
|
|
386
|
+
# kind of change it wanted.
|
|
387
|
+
class ChangeMatcher < Deferred
|
|
388
|
+
def initialize(name, args = [], block = nil, matcher: nil)
|
|
389
|
+
super
|
|
390
|
+
@by_set = @from_set = @to_set = false
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def by(delta)
|
|
394
|
+
@by = delta
|
|
395
|
+
@by_set = true
|
|
396
|
+
self
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def from(value)
|
|
400
|
+
@from = value
|
|
401
|
+
@from_set = true
|
|
402
|
+
self
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def to(value)
|
|
406
|
+
@to = value
|
|
407
|
+
@to_set = true
|
|
408
|
+
self
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def matches?(action)
|
|
412
|
+
unless action.respond_to?(:call)
|
|
413
|
+
raise Constable::Error,
|
|
414
|
+
"change needs the block form: attest { ... }.to change { #{expression} }"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
@before = sample
|
|
418
|
+
action.call
|
|
419
|
+
@after = sample
|
|
420
|
+
|
|
421
|
+
evaluate
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def description
|
|
425
|
+
parts = ["change #{expression}"]
|
|
426
|
+
parts << "by #{@by.inspect}" if @by_set
|
|
427
|
+
parts << "from #{@from.inspect}" if @from_set
|
|
428
|
+
parts << "to #{@to.inspect}" if @to_set
|
|
429
|
+
parts.join(" ")
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def negated_failure_message(_action)
|
|
433
|
+
"expected the block not to #{description}, " \
|
|
434
|
+
"but #{expression} changed from #{@before.inspect} to #{@after.inspect}"
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def context_for(_action) = nil
|
|
438
|
+
|
|
439
|
+
private
|
|
440
|
+
|
|
441
|
+
# The thing being watched: change { User.count } or change(user, :name).
|
|
442
|
+
# Duplicated on the way out: a matcher that watches a mutable object would otherwise
|
|
443
|
+
# compare it against itself and conclude nothing ever changes.
|
|
444
|
+
def sample
|
|
445
|
+
value = @block ? @block.call : @args[0].public_send(@args[1])
|
|
446
|
+
begin
|
|
447
|
+
value.dup
|
|
448
|
+
rescue StandardError
|
|
449
|
+
value
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def expression
|
|
454
|
+
@expression ||= if @block
|
|
455
|
+
src = Matchers.block_source(@block)
|
|
456
|
+
src ? "`#{src}`" : "the value"
|
|
457
|
+
else
|
|
458
|
+
"#{Matchers.describe(@args[0])}##{@args[1]}"
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def evaluate
|
|
463
|
+
return from_mismatch if @from_set && @before != @from
|
|
464
|
+
return by_result if @by_set
|
|
465
|
+
return to_result if @to_set
|
|
466
|
+
|
|
467
|
+
return [true, nil, nil] if @before != @after
|
|
468
|
+
|
|
469
|
+
[false, "expected the block to #{description}, " \
|
|
470
|
+
"but #{expression} stayed at #{@before.inspect}", nil]
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def from_mismatch
|
|
474
|
+
[false, "expected the block to #{description}, " \
|
|
475
|
+
"but #{expression} started at #{@before.inspect}, not #{@from.inspect}", nil]
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def by_result
|
|
479
|
+
delta = begin
|
|
480
|
+
@after - @before
|
|
481
|
+
rescue StandardError
|
|
482
|
+
nil
|
|
483
|
+
end
|
|
484
|
+
return [true, nil, nil] if delta == @by
|
|
485
|
+
|
|
486
|
+
[false, "expected the block to #{description}, but #{expression} changed by " \
|
|
487
|
+
"#{delta.inspect} (#{@before.inspect} to #{@after.inspect})", nil]
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def to_result
|
|
491
|
+
return [true, nil, nil] if @after == @to && @before != @after
|
|
492
|
+
|
|
493
|
+
[false, "expected the block to #{description}, but #{expression} " \
|
|
494
|
+
"went from #{@before.inspect} to #{@after.inspect}", nil]
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# What `attest` returns. Holds the actual (or, in block form, the action) and does the
|
|
499
|
+
# raising, so matchers only ever have to answer "did this pass, and what would you say
|
|
500
|
+
# about it if it didn't".
|
|
501
|
+
class Expectation
|
|
502
|
+
NOTHING = Object.new.freeze
|
|
503
|
+
|
|
504
|
+
attr_reader :actual, :block
|
|
505
|
+
|
|
506
|
+
def initialize(actual = NOTHING, block: nil)
|
|
507
|
+
@actual = actual
|
|
508
|
+
@block = block
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def block_form? = !@block.nil?
|
|
512
|
+
|
|
513
|
+
# In block form the matcher's subject is the action itself -- change and raise_error
|
|
514
|
+
# need to run it, not look at its result.
|
|
515
|
+
def target = block_form? ? @block : @actual
|
|
516
|
+
|
|
517
|
+
def to(matcher)
|
|
518
|
+
matcher = coerce(matcher)
|
|
519
|
+
passed, message, context = matcher.matches?(target)
|
|
520
|
+
return satisfied if passed
|
|
521
|
+
|
|
522
|
+
Matchers.fail!(message || matcher.failure_message(target),
|
|
523
|
+
Matchers.merge_context(matcher.context_for(target), context))
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def not_to(matcher)
|
|
527
|
+
matcher = coerce(matcher)
|
|
528
|
+
passed, _message, context = matcher.matches?(target)
|
|
529
|
+
return satisfied unless passed
|
|
530
|
+
|
|
531
|
+
Matchers.fail!(matcher.negated_failure_message(target),
|
|
532
|
+
Matchers.merge_context(matcher.context_for(target), context))
|
|
533
|
+
end
|
|
534
|
+
alias to_not not_to
|
|
535
|
+
|
|
536
|
+
private
|
|
537
|
+
|
|
538
|
+
# Handing the actual back makes `user = attest(build_user).to be_valid` read fine;
|
|
539
|
+
# in block form there is no actual to hand back.
|
|
540
|
+
def satisfied = block_form? ? self : @actual
|
|
541
|
+
|
|
542
|
+
def coerce(matcher)
|
|
543
|
+
return matcher if matcher.respond_to?(:matches?)
|
|
544
|
+
|
|
545
|
+
raise Constable::Error,
|
|
546
|
+
"attest(...).to expects a matcher, got #{matcher.inspect}. " \
|
|
547
|
+
"Did you mean `attest(x).to eq(#{matcher.inspect})`?"
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Mixed into Constable::Case. Everything a case needs to write `attest(response).to
|
|
552
|
+
# be_created` -- including the bare `be_created`, which lands here.
|
|
553
|
+
module Expectations
|
|
554
|
+
def attest(actual = Expectation::NOTHING, &block)
|
|
555
|
+
if block
|
|
556
|
+
Expectation.new(block: block)
|
|
557
|
+
elsif !actual.equal?(Expectation::NOTHING)
|
|
558
|
+
Expectation.new(actual)
|
|
559
|
+
else
|
|
560
|
+
raise Constable::Error,
|
|
561
|
+
"attest needs a value or a block: attest(response).to be_created, " \
|
|
562
|
+
"or attest { ... }.to change { User.count }"
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Explicit escape hatch for a matcher whose name collides with a real method.
|
|
567
|
+
def matcher(name, *args, &block)
|
|
568
|
+
Matchers.deferred_for(name, args, block)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
private
|
|
572
|
+
|
|
573
|
+
def method_missing(name, *args, &block)
|
|
574
|
+
return super unless Matchers.matcher_name?(name)
|
|
575
|
+
|
|
576
|
+
Matchers.deferred_for(name, args, block)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def respond_to_missing?(name, include_private = false)
|
|
580
|
+
Matchers.matcher_name?(name) || super
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# -- Built-ins ---------------------------------------------------------------------
|
|
585
|
+
#
|
|
586
|
+
# Registered into their own table (never `custom`), so Matchers.clear! is a reset to
|
|
587
|
+
# this set rather than a wipe. Each one degrades gracefully when Rails is absent: they
|
|
588
|
+
# duck-type responses and records instead of naming ActionDispatch or ActiveRecord.
|
|
589
|
+
|
|
590
|
+
def self.define_builtin(name, deferred_class: Deferred, &block)
|
|
591
|
+
builtins[name.to_sym] = Matcher.new(name.to_sym, block, deferred_class)
|
|
592
|
+
end
|
|
593
|
+
private_class_method :define_builtin
|
|
594
|
+
|
|
595
|
+
define_builtin(:eq) do |actual, expected|
|
|
596
|
+
next true if actual == expected
|
|
597
|
+
|
|
598
|
+
note = actual.instance_of?(expected.class) ? "" : " (#{actual.class} vs #{expected.class})"
|
|
599
|
+
[false, "expected #{Matchers.describe(actual)} to eq #{expected.inspect}#{note}", nil]
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
define_builtin(:eql) do |actual, expected|
|
|
603
|
+
next true if actual.eql?(expected)
|
|
604
|
+
|
|
605
|
+
[false, "expected #{Matchers.describe(actual)} to eql #{expected.inspect} " \
|
|
606
|
+
"(eql? compares value and type; #{actual.class} vs #{expected.class})", nil]
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
define_builtin(:include) do |actual, *expected|
|
|
610
|
+
unless actual.respond_to?(:include?)
|
|
611
|
+
next [false, "expected #{Matchers.describe(actual)} to include " \
|
|
612
|
+
"#{expected.map(&:inspect).join(", ")}, but a #{actual.class} has no #include?", nil]
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
missing = expected.reject do |item|
|
|
616
|
+
if actual.is_a?(Hash) && item.is_a?(Hash)
|
|
617
|
+
item.all? { |k, v| actual.key?(k) && actual[k] == v }
|
|
618
|
+
elsif actual.is_a?(Hash)
|
|
619
|
+
actual.key?(item)
|
|
620
|
+
else
|
|
621
|
+
actual.include?(item)
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
next true if missing.empty?
|
|
625
|
+
|
|
626
|
+
[false, "expected #{Matchers.describe(actual)} to include " \
|
|
627
|
+
"#{expected.map(&:inspect).join(", ")}, but #{missing.map(&:inspect).join(", ")} " \
|
|
628
|
+
"#{missing.one? ? "is" : "are"} missing", nil]
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
define_builtin(:match) do |actual, pattern|
|
|
632
|
+
matched =
|
|
633
|
+
if pattern.is_a?(Regexp)
|
|
634
|
+
actual.is_a?(String) || actual.is_a?(Symbol) ? pattern.match?(actual.to_s) : false
|
|
635
|
+
elsif actual.respond_to?(:match?)
|
|
636
|
+
actual.match?(pattern)
|
|
637
|
+
else
|
|
638
|
+
actual == pattern
|
|
639
|
+
end
|
|
640
|
+
next true if matched
|
|
641
|
+
|
|
642
|
+
[false, "expected #{Matchers.describe(actual)} to match #{pattern.inspect}", nil]
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
define_builtin(:raise_error) do |actual, *args|
|
|
646
|
+
unless actual.respond_to?(:call)
|
|
647
|
+
next [false, "raise_error needs the block form: attest { ... }.to raise_error(...), " \
|
|
648
|
+
"got #{Matchers.describe(actual)}", nil]
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
expected_class = args.find { |a| a.is_a?(Class) } || StandardError
|
|
652
|
+
expected_message = args.find { |a| a.is_a?(String) || a.is_a?(Regexp) }
|
|
653
|
+
|
|
654
|
+
begin
|
|
655
|
+
actual.call
|
|
656
|
+
[false, "expected the block to raise #{expected_class}, but nothing was raised", nil]
|
|
657
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised below unless it is the one asked for
|
|
658
|
+
raise if e.is_a?(SystemExit) || e.is_a?(Interrupt) || e.is_a?(SignalException) || e.is_a?(NoMemoryError)
|
|
659
|
+
|
|
660
|
+
context = { "Raised" => "#{e.class}: #{e.message}" }
|
|
661
|
+
message_matched =
|
|
662
|
+
expected_message.nil? ||
|
|
663
|
+
(expected_message.is_a?(Regexp) ? expected_message.match?(e.message) : e.message == expected_message)
|
|
664
|
+
|
|
665
|
+
if !e.is_a?(expected_class)
|
|
666
|
+
[false, "expected the block to raise #{expected_class}, but it raised #{e.class}: #{e.message}", context]
|
|
667
|
+
elsif !message_matched
|
|
668
|
+
[false, "expected the block to raise #{expected_class} with message #{expected_message.inspect}, " \
|
|
669
|
+
"but the message was #{e.message.inspect}", context]
|
|
670
|
+
else
|
|
671
|
+
[true, nil, context]
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
define_builtin(:have_attributes) do |actual, expected|
|
|
677
|
+
unless expected.is_a?(Hash)
|
|
678
|
+
next [false, "have_attributes expects a hash of attributes, got #{expected.inspect}", nil]
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
mismatches = expected.filter_map do |key, value|
|
|
682
|
+
if actual.respond_to?(key)
|
|
683
|
+
got = actual.public_send(key)
|
|
684
|
+
"#{key}: expected #{value.inspect}, got #{got.inspect}" unless got == value
|
|
685
|
+
elsif actual.respond_to?(:[])
|
|
686
|
+
got = actual[key]
|
|
687
|
+
"#{key}: expected #{value.inspect}, got #{got.inspect}" unless got == value
|
|
688
|
+
else
|
|
689
|
+
"#{key}: #{Matchers.describe(actual)} does not respond to ##{key}"
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
next true if mismatches.empty?
|
|
693
|
+
|
|
694
|
+
[false, "expected #{Matchers.describe(actual)} to have attributes " \
|
|
695
|
+
"#{Matchers.format_hash(expected)}, but #{mismatches.join("; ")}", nil]
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
define_builtin(:exist) do |actual, *args|
|
|
699
|
+
if actual.respond_to?(:exists?)
|
|
700
|
+
passed = args.empty? ? actual.exists? : actual.exists?(*args)
|
|
701
|
+
next true if passed
|
|
702
|
+
|
|
703
|
+
criteria = args.empty? ? "any record" : "a record matching #{Matchers.format_list(args)}"
|
|
704
|
+
context = begin
|
|
705
|
+
actual.respond_to?(:count) ? { "Rows in table" => actual.count.to_s } : nil
|
|
706
|
+
rescue StandardError
|
|
707
|
+
nil
|
|
708
|
+
end
|
|
709
|
+
next [false, "expected #{Matchers.describe(actual)} to have #{criteria}, but none exists", context]
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
if actual.respond_to?(:exist?)
|
|
713
|
+
next true if actual.exist?
|
|
714
|
+
|
|
715
|
+
next [false, "expected #{Matchers.describe(actual)} to exist on disk, but it does not", nil]
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
if actual.is_a?(String)
|
|
719
|
+
next true if File.exist?(actual)
|
|
720
|
+
|
|
721
|
+
next [false, "expected the path #{actual.inspect} to exist, but no such file or directory", nil]
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
[false, "expected #{Matchers.describe(actual)} to exist, but a #{actual.class} " \
|
|
725
|
+
"responds to neither #exists? nor #exist?", nil]
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
# Registered even though be_* would fall back to #created? anyway: a response's status
|
|
729
|
+
# is the common case, and "got :unprocessable_entity (422)" beats "created? was false".
|
|
730
|
+
define_builtin(:be_created) do |actual|
|
|
731
|
+
code = Matchers.response_status(actual)
|
|
732
|
+
if code
|
|
733
|
+
next true if code == 201
|
|
734
|
+
|
|
735
|
+
next [false, "expected response to be created (201), but got #{Matchers.status_label(code)}",
|
|
736
|
+
Matchers.context_for(actual)]
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
unless actual.respond_to?(:created?)
|
|
740
|
+
next [false, "expected #{Matchers.describe(actual)} to be created, but a #{actual.class} " \
|
|
741
|
+
"responds to neither #status nor #created?", nil]
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
next true if actual.created?
|
|
745
|
+
|
|
746
|
+
[false, "expected #{Matchers.describe(actual)} to be created, but " \
|
|
747
|
+
"#{actual.class}#created? returned false", Matchers.context_for(actual)]
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
define_builtin(:redirect_to) do |actual, target|
|
|
751
|
+
code = Matchers.response_status(actual)
|
|
752
|
+
location = Matchers.response_location(actual)
|
|
753
|
+
|
|
754
|
+
if code.nil?
|
|
755
|
+
next [false, "expected #{Matchers.describe(actual)} to redirect to #{target.inspect}, " \
|
|
756
|
+
"but a #{actual.class} has no HTTP status", nil]
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
unless (300..399).cover?(code)
|
|
760
|
+
next [false, "expected response to redirect to #{target.inspect}, but it returned " \
|
|
761
|
+
"#{Matchers.status_label(code)} with no redirect", Matchers.context_for(actual)]
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
matched =
|
|
765
|
+
if target.is_a?(Regexp)
|
|
766
|
+
location && target.match?(location)
|
|
767
|
+
else
|
|
768
|
+
location == target.to_s || Matchers.location_path(location) == Matchers.location_path(target.to_s)
|
|
769
|
+
end
|
|
770
|
+
next true if matched
|
|
771
|
+
|
|
772
|
+
[false, "expected response to redirect to #{target.inspect}, but it redirected to " \
|
|
773
|
+
"#{location.inspect}", Matchers.context_for(actual)]
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
define_builtin(:have_http_status) do |actual, expected|
|
|
777
|
+
code = Matchers.response_status(actual)
|
|
778
|
+
if code.nil?
|
|
779
|
+
next [false, "expected #{Matchers.describe(actual)} to have HTTP status " \
|
|
780
|
+
"#{Matchers.expected_status_label(expected)}, but a #{actual.class} has no status", nil]
|
|
781
|
+
end
|
|
782
|
+
next true if Matchers.status_matches?(code, expected)
|
|
783
|
+
|
|
784
|
+
if Matchers.status_code_for(expected).nil? && !Matchers::HTTP_STATUS_GROUPS.key?(expected.to_s.to_sym)
|
|
785
|
+
known = Matchers::HTTP_STATUS_CODES.keys.first(5).map(&:inspect).join(", ")
|
|
786
|
+
next [false, "have_http_status does not know the status #{expected.inspect}; " \
|
|
787
|
+
"use an integer or one of #{known}, …", nil]
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
[false, "expected response to have HTTP status #{Matchers.expected_status_label(expected)}, " \
|
|
791
|
+
"but got #{Matchers.status_label(code)}", Matchers.context_for(actual)]
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
define_builtin(:change, deferred_class: ChangeMatcher) do |_actual, *_args|
|
|
795
|
+
raise Constable::Error, "change is only usable through attest { ... }.to change { ... }"
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
define_builtin(:be_a) do |actual, klass|
|
|
799
|
+
next true if actual.is_a?(klass)
|
|
800
|
+
|
|
801
|
+
[false, "expected #{Matchers.describe(actual)} to be a #{klass}, but it is a #{actual.class}", nil]
|
|
802
|
+
end
|
|
803
|
+
builtins[:be_an] = builtins[:be_a]
|
|
804
|
+
builtins[:be_kind_of] = builtins[:be_a]
|
|
805
|
+
|
|
806
|
+
define_builtin(:be_nil) do |actual|
|
|
807
|
+
next true if actual.nil?
|
|
808
|
+
|
|
809
|
+
[false, "expected nil, but got #{Matchers.describe(actual)} (#{actual.class})", nil]
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
define_builtin(:be_empty) do |actual|
|
|
813
|
+
unless actual.respond_to?(:empty?)
|
|
814
|
+
next [false, "expected #{Matchers.describe(actual)} to be empty, but a #{actual.class} has no #empty?", nil]
|
|
815
|
+
end
|
|
816
|
+
next true if actual.empty?
|
|
817
|
+
|
|
818
|
+
size = actual.respond_to?(:size) ? " (#{actual.size} entries)" : ""
|
|
819
|
+
[false, "expected #{Matchers.describe(actual)} to be empty, but it is not#{size}", nil]
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
define_builtin(:be_truthy) do |actual|
|
|
823
|
+
next true if actual
|
|
824
|
+
|
|
825
|
+
[false, "expected a truthy value, but got #{actual.inspect}", nil]
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
define_builtin(:be_falsey) do |actual|
|
|
829
|
+
next true unless actual
|
|
830
|
+
|
|
831
|
+
[false, "expected a falsey value, but got #{Matchers.describe(actual)}", nil]
|
|
832
|
+
end
|
|
833
|
+
end
|
|
834
|
+
end
|