constable-rails 0.1.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +159 -1
- data/README.md +129 -13
- data/lib/constable/case.rb +33 -0
- data/lib/constable/cli.rb +38 -5
- data/lib/constable/cold_case/rspec.rb +93 -1
- data/lib/constable/config.rb +53 -5
- data/lib/constable/identity.rb +19 -0
- data/lib/constable/importer/modernizer.rb +40 -0
- data/lib/constable/investigation.rb +10 -0
- data/lib/constable/jail.rb +34 -7
- data/lib/constable/matchers.rb +206 -2
- data/lib/constable/registry.rb +24 -0
- data/lib/constable/reporter.rb +230 -4
- data/lib/constable/runner.rb +131 -3
- data/lib/constable/selection.rb +23 -1
- data/lib/constable/storage/sqlite_adapter.rb +21 -1
- data/lib/constable/version.rb +1 -1
- data/lib/constable/warrants.rb +23 -5
- data/lib/constable/worker_databases.rb +83 -0
- data/lib/constable.rb +1 -0
- data/lib/generators/constable/install_generator.rb +79 -8
- data/lib/generators/constable/templates/case_helper.rb.tt +36 -15
- data/lib/generators/constable/templates/config.yml.tt +9 -0
- metadata +4 -3
data/lib/constable/config.rb
CHANGED
|
@@ -19,6 +19,7 @@ module Constable
|
|
|
19
19
|
"coverage_threshold" => 90,
|
|
20
20
|
"coverage_html" => false,
|
|
21
21
|
"fail_on_warnings" => false,
|
|
22
|
+
"output" => "concise",
|
|
22
23
|
"parallel_workers" => "auto",
|
|
23
24
|
"tiers" => {
|
|
24
25
|
"unit" => "test/cases/models/**/*",
|
|
@@ -33,8 +34,27 @@ module Constable
|
|
|
33
34
|
|
|
34
35
|
def self.load(root: Constable.root, overrides: {})
|
|
35
36
|
path = File.join(root.to_s, CONFIG_PATH)
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
new(read_file(path), root: root, overrides: overrides)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A typo in config.yml used to surface as a raw Psych::SyntaxError, or -- for a file
|
|
41
|
+
# that parsed but wasn't a mapping -- as "no implicit conversion of Array into Hash"
|
|
42
|
+
# from somewhere deep in the merge. Neither says which file to open.
|
|
43
|
+
def self.read_file(path)
|
|
44
|
+
return {} unless File.exist?(path)
|
|
45
|
+
|
|
46
|
+
loaded = YAML.safe_load_file(path, permitted_classes: [], aliases: true)
|
|
47
|
+
return {} if loaded.nil?
|
|
48
|
+
|
|
49
|
+
unless loaded.is_a?(Hash)
|
|
50
|
+
raise Constable::Error,
|
|
51
|
+
"#{CONFIG_PATH} must be a mapping of settings, but it parsed as " \
|
|
52
|
+
"#{loaded.class.name.downcase}. Check the indentation."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
loaded
|
|
56
|
+
rescue Psych::SyntaxError => e
|
|
57
|
+
raise Constable::Error, "#{CONFIG_PATH} is not valid YAML: #{e.problem} at line #{e.line}."
|
|
38
58
|
end
|
|
39
59
|
|
|
40
60
|
def initialize(raw = {}, root: Constable.root, overrides: {})
|
|
@@ -45,14 +65,42 @@ module Constable
|
|
|
45
65
|
|
|
46
66
|
def cold_cases = Array(@raw["cold_cases"])
|
|
47
67
|
def warrants? = truthy(@raw["warrants"])
|
|
48
|
-
|
|
68
|
+
# Negative retries are a typo for "off", not an instruction to count backwards.
|
|
69
|
+
def warrant_retries = [@raw["warrant_retries"].to_i, 0].max
|
|
49
70
|
def auto_relink? = truthy(@raw["auto_relink"])
|
|
50
|
-
|
|
71
|
+
|
|
72
|
+
# Clamped here rather than at each call site: Jail already refused a period of zero
|
|
73
|
+
# ("release on sight" is not parole), but the reporter read the raw value and would
|
|
74
|
+
# cheerfully print "Day 1 of 0 -- 0 clean runs to go" while the docket waited for 10.
|
|
75
|
+
def parole_period
|
|
76
|
+
period = @raw["parole_period"].to_i
|
|
77
|
+
period.positive? ? period : DEFAULTS["parole_period"]
|
|
78
|
+
end
|
|
79
|
+
|
|
51
80
|
def coverage? = truthy(@raw["coverage"])
|
|
52
|
-
|
|
81
|
+
# Clamped: a threshold above 100 is a build that can never go green, and a negative
|
|
82
|
+
# one is a gate that can never fail. Both are typos rather than intentions.
|
|
83
|
+
def coverage_threshold = @raw["coverage_threshold"].to_i.clamp(0, 100)
|
|
53
84
|
def coverage_html? = truthy(@raw["coverage_html"])
|
|
54
85
|
def fail_on_warnings? = truthy(@raw["fail_on_warnings"])
|
|
55
86
|
def tiers = @raw["tiers"] || {}
|
|
87
|
+
|
|
88
|
+
# How much the live stream says while the suite runs.
|
|
89
|
+
#
|
|
90
|
+
# concise one glyph per test, grouped into a run per case. The default: a
|
|
91
|
+
# 1,000-test suite stays inside one screen.
|
|
92
|
+
# expanded a line per test -- glyph, name, duration. Slower to read in bulk,
|
|
93
|
+
# but you can see which test is hanging without waiting for the summary.
|
|
94
|
+
#
|
|
95
|
+
# The summary itself is identical either way. This only affects the live stream.
|
|
96
|
+
OUTPUT_MODES = %i[concise expanded].freeze
|
|
97
|
+
|
|
98
|
+
def output_mode
|
|
99
|
+
mode = @raw["output"].to_s.strip.downcase.to_sym
|
|
100
|
+
OUTPUT_MODES.include?(mode) ? mode : :concise
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def expanded_output? = output_mode == :expanded
|
|
56
104
|
def storage = @raw["storage"] || {}
|
|
57
105
|
|
|
58
106
|
def storage_adapter = (storage["adapter"] || "sqlite").to_s
|
data/lib/constable/identity.rb
CHANGED
|
@@ -33,6 +33,25 @@ module Constable
|
|
|
33
33
|
digest("cold:#{relative}:#{description}")
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Two investigations with byte-identical bodies hash to the same key, which would
|
|
37
|
+
# make them one test as far as the blotter is concerned: jail one and the other goes
|
|
38
|
+
# with it, and their flake histories merge into a single misleading record.
|
|
39
|
+
#
|
|
40
|
+
# Bodies repeat more often than the "content hash" idea suggests --
|
|
41
|
+
# `attest(build(:thing, name: nil)).not_to be_valid` is the same handful of tokens in
|
|
42
|
+
# every model case, and the model generator writes an identical first investigation
|
|
43
|
+
# into every file it touches. So the collision is routine, not theoretical.
|
|
44
|
+
#
|
|
45
|
+
# When it happens, the colliding tests are re-keyed on the body *plus* their class
|
|
46
|
+
# and description. The rename-survival promise is weaker for exactly those tests --
|
|
47
|
+
# rewording one of them starts its history over -- which is the right trade: a
|
|
48
|
+
# history that belongs to two tests at once is worse than one that resets.
|
|
49
|
+
def disambiguate(base, case_name:, description:, ordinal: nil)
|
|
50
|
+
key = "#{base}:#{case_name}:#{description}"
|
|
51
|
+
key = "#{key}:#{ordinal}" unless ordinal.nil?
|
|
52
|
+
digest(key)
|
|
53
|
+
end
|
|
54
|
+
|
|
36
55
|
def digest(string)
|
|
37
56
|
Digest::SHA256.hexdigest(string)[0, LENGTH]
|
|
38
57
|
end
|
|
@@ -508,6 +508,8 @@ module Constable
|
|
|
508
508
|
"stub object.")
|
|
509
509
|
end
|
|
510
510
|
|
|
511
|
+
flag_unknown_matcher(node, args.first) if %i[to not_to to_not].include?(name)
|
|
512
|
+
|
|
511
513
|
if MOCK_ENTRY_POINTS.include?(name) && receiver.nil?
|
|
512
514
|
note_untouched(:rspec_mocks, node,
|
|
513
515
|
"`#{first_line(node)}` uses rspec-mocks. Constable has no equivalent; convert it by hand.")
|
|
@@ -532,6 +534,14 @@ module Constable
|
|
|
532
534
|
end
|
|
533
535
|
when :should, :should_not
|
|
534
536
|
flag(:should_syntax, node, "`#{name}` is RSpec's monkey-patched expectation syntax. Use `attest(...).to`.")
|
|
537
|
+
when :helper
|
|
538
|
+
if receiver.nil? && args.empty?
|
|
539
|
+
flag(:rspec_helper_object, node,
|
|
540
|
+
"`helper` is RSpec's helper-spec proxy and has no Constable equivalent. " \
|
|
541
|
+
"A helper is a plain module: `include YourHelper` in the case and call " \
|
|
542
|
+
"the method directly -- which is exactly what `rails generate helper` " \
|
|
543
|
+
"writes.")
|
|
544
|
+
end
|
|
535
545
|
when :described_class
|
|
536
546
|
if receiver.nil?
|
|
537
547
|
flag(:described_class, node,
|
|
@@ -558,6 +568,36 @@ module Constable
|
|
|
558
568
|
record_converted(:helper_require, node, value, replaced)
|
|
559
569
|
end
|
|
560
570
|
|
|
571
|
+
# Constable's matcher set is deliberately smaller than RSpec's, and the rewrite
|
|
572
|
+
# carries any matcher name straight across. Without this check the first anyone
|
|
573
|
+
# hears about it is a NoMethodError at runtime, naming the matcher -- or worse, an
|
|
574
|
+
# internal deferred class -- rather than the line that needs a decision.
|
|
575
|
+
def flag_unknown_matcher(node, matcher_node)
|
|
576
|
+
name = root_matcher_name(matcher_node)
|
|
577
|
+
return if name.nil?
|
|
578
|
+
return if Constable::Matchers.matcher_name?(name)
|
|
579
|
+
|
|
580
|
+
flag(:unknown_matcher, node,
|
|
581
|
+
"`#{name}` is not one of Constable's matchers. Define it in " \
|
|
582
|
+
"test/support/matchers.rb with `Constable::Matchers.define(:#{name})`, or " \
|
|
583
|
+
"rewrite the assertion.")
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# `contain_exactly(1, 2)` -> :contain_exactly. `be_within(0.5).of(10)` -> :be_within.
|
|
587
|
+
# `change { x }.by(1)` -> :change. Anything that is not ultimately a bare method
|
|
588
|
+
# call -- a local variable holding a matcher, a constant -- returns nil and is left
|
|
589
|
+
# alone, because we cannot know what it is.
|
|
590
|
+
def root_matcher_name(node)
|
|
591
|
+
return nil unless node.is_a?(::Parser::AST::Node)
|
|
592
|
+
|
|
593
|
+
current = node
|
|
594
|
+
current = current.children.first while current.type == :send && current.children.first
|
|
595
|
+
|
|
596
|
+
return nil unless current.type == :send && current.children.first.nil?
|
|
597
|
+
|
|
598
|
+
current.children[1]
|
|
599
|
+
end
|
|
600
|
+
|
|
561
601
|
def mock_expectation?(node)
|
|
562
602
|
return false unless node.is_a?(::Parser::AST::Node)
|
|
563
603
|
|
|
@@ -38,6 +38,16 @@ module Constable
|
|
|
38
38
|
@identity ||= Identity.for_block(@block)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Re-keys this investigation because another one has the same body. Called by the
|
|
42
|
+
# registry once the whole suite is loaded, which is the first moment a collision can
|
|
43
|
+
# be seen. See Identity.disambiguate.
|
|
44
|
+
def disambiguate!(ordinal: nil)
|
|
45
|
+
@identity = Identity.disambiguate(identity, case_name: case_name,
|
|
46
|
+
description: full_description,
|
|
47
|
+
ordinal: ordinal)
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
41
51
|
def location
|
|
42
52
|
"#{relative_file}:#{@line}"
|
|
43
53
|
end
|
data/lib/constable/jail.rb
CHANGED
|
@@ -267,13 +267,20 @@ module Constable
|
|
|
267
267
|
# Call it *before* writing the result to flake history (the flip check reads the
|
|
268
268
|
# previous status) and *after* Warrants has had its say (a warranted result is not a
|
|
269
269
|
# failure, so it never reaches the docket).
|
|
270
|
-
|
|
270
|
+
# `systemic:` says this run failed for a reason that has nothing to do with any
|
|
271
|
+
# individual test -- see Runner#systemic_failure. The failures still stand and the
|
|
272
|
+
# build still goes red, but they are not *evidence*: nothing moves through the state
|
|
273
|
+
# machine, because "the database was locked for the whole run" is not a fact about a
|
|
274
|
+
# test and must not put one on the docket.
|
|
275
|
+
def adjudicate(result, jail_mode: false, systemic: false)
|
|
271
276
|
return result if result.nil?
|
|
272
277
|
|
|
273
278
|
docket = entry(result.identity)
|
|
274
279
|
|
|
280
|
+
return mark_jailed(result) if docket&.jailed?
|
|
281
|
+
return result if systemic
|
|
282
|
+
|
|
275
283
|
return record_result(result) if docket&.paroled?
|
|
276
|
-
return mark_jailed(result) if docket&.jailed?
|
|
277
284
|
return result unless result.failed?
|
|
278
285
|
|
|
279
286
|
if jail_mode
|
|
@@ -331,17 +338,37 @@ module Constable
|
|
|
331
338
|
# test that is not on the docket yet.
|
|
332
339
|
#
|
|
333
340
|
# Returns an identity String, or nil when nothing matches.
|
|
334
|
-
|
|
341
|
+
# Every docket row a target could mean.
|
|
342
|
+
#
|
|
343
|
+
# The interesting case is a bare path. "test/cases/users_case.rb" with three tests
|
|
344
|
+
# on the docket is a question, not an instruction: picking one silently acts on a
|
|
345
|
+
# test the user never named -- and not even the first one, since the order is
|
|
346
|
+
# whatever storage returns. Callers ask for the candidates and refuse to guess.
|
|
347
|
+
def candidates(target)
|
|
335
348
|
text = target.to_s.strip
|
|
336
|
-
return
|
|
337
|
-
|
|
349
|
+
return [] if text.empty?
|
|
350
|
+
|
|
351
|
+
if identity_like?(text) && (row = entry(text))
|
|
352
|
+
return [row]
|
|
353
|
+
end
|
|
338
354
|
|
|
339
355
|
file, line = self.class.split_target(text)
|
|
340
|
-
return
|
|
356
|
+
return [] if file.empty?
|
|
341
357
|
|
|
342
358
|
matches = entries.select { |e| self.class.same_path?(e.file, file) }
|
|
343
359
|
matches = matches.select { |e| e.line == line } if line
|
|
344
|
-
|
|
360
|
+
matches
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# An identity String, or nil when nothing matches -- or when more than one does.
|
|
364
|
+
# Ambiguity is the caller's to report, with the candidates in hand.
|
|
365
|
+
def resolve(target)
|
|
366
|
+
matches = candidates(target)
|
|
367
|
+
return matches.first.identity if matches.size == 1
|
|
368
|
+
return nil unless matches.empty?
|
|
369
|
+
|
|
370
|
+
file, line = self.class.split_target(target.to_s.strip)
|
|
371
|
+
return nil if file.empty?
|
|
345
372
|
|
|
346
373
|
self.class.registry_identity(file, line)
|
|
347
374
|
end
|
data/lib/constable/matchers.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Constable
|
|
|
33
33
|
not_found: 404, method_not_allowed: 405, not_acceptable: 406,
|
|
34
34
|
request_timeout: 408, conflict: 409, gone: 410, precondition_failed: 412,
|
|
35
35
|
payload_too_large: 413, unsupported_media_type: 415, im_a_teapot: 418,
|
|
36
|
-
unprocessable_entity: 422, locked: 423, too_many_requests: 429,
|
|
36
|
+
unprocessable_entity: 422, unprocessable_content: 422, locked: 423, too_many_requests: 429,
|
|
37
37
|
internal_server_error: 500, not_implemented: 501, bad_gateway: 502,
|
|
38
38
|
service_unavailable: 503, gateway_timeout: 504
|
|
39
39
|
}.freeze
|
|
@@ -44,6 +44,22 @@ module Constable
|
|
|
44
44
|
error: (500..599), server_error: (500..599)
|
|
45
45
|
}.freeze
|
|
46
46
|
|
|
47
|
+
# Rack renames statuses -- 422 became :unprocessable_content in Rack 3.1, and Rails
|
|
48
|
+
# 8.1 deprecates the old spelling -- so the table above is a floor, not the whole
|
|
49
|
+
# truth. Anything Rack knows is accepted, which means a rename costs no release here.
|
|
50
|
+
def self.rack_status_codes
|
|
51
|
+
return @rack_status_codes if defined?(@rack_status_codes)
|
|
52
|
+
|
|
53
|
+
@rack_status_codes =
|
|
54
|
+
if defined?(::Rack::Utils::SYMBOL_TO_STATUS_CODE)
|
|
55
|
+
::Rack::Utils::SYMBOL_TO_STATUS_CODE.transform_keys(&:to_sym)
|
|
56
|
+
else
|
|
57
|
+
{}
|
|
58
|
+
end
|
|
59
|
+
rescue StandardError
|
|
60
|
+
@rack_status_codes = {}
|
|
61
|
+
end
|
|
62
|
+
|
|
47
63
|
MAX_INSPECT = 200
|
|
48
64
|
MAX_BODY = 800
|
|
49
65
|
|
|
@@ -222,7 +238,9 @@ module Constable
|
|
|
222
238
|
case expected
|
|
223
239
|
when Integer then expected
|
|
224
240
|
when /\A\d+\z/ then expected.to_i
|
|
225
|
-
else
|
|
241
|
+
else
|
|
242
|
+
name = expected.to_s.to_sym
|
|
243
|
+
HTTP_STATUS_CODES[name] || Matchers.rack_status_codes[name]
|
|
226
244
|
end
|
|
227
245
|
end
|
|
228
246
|
|
|
@@ -345,6 +363,104 @@ module Constable
|
|
|
345
363
|
end
|
|
346
364
|
end
|
|
347
365
|
|
|
366
|
+
# `be_within(0.5).of(10)` -- a matcher spelled across two calls, so it has to survive
|
|
367
|
+
# the first one and collect its subject on the second.
|
|
368
|
+
#
|
|
369
|
+
# Registering it matters for a second reason: without an entry, `be_within` fell
|
|
370
|
+
# through to the be_*/have_* predicate fallback, which happily built a
|
|
371
|
+
# PredicateDeferred and then blew up on `.of` with a NoMethodError naming an internal
|
|
372
|
+
# class rather than the matcher the author actually wrote.
|
|
373
|
+
class WithinDeferred < Deferred
|
|
374
|
+
def of(expected)
|
|
375
|
+
@expected = expected
|
|
376
|
+
@expected_set = true
|
|
377
|
+
self
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def matches?(actual)
|
|
381
|
+
unless @expected_set
|
|
382
|
+
return [false, "be_within(#{@args.first.inspect}) is incomplete -- it needs .of: " \
|
|
383
|
+
"attest(value).to be_within(0.5).of(10)", nil]
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
delta = @args.first
|
|
387
|
+
difference = (actual - @expected).abs
|
|
388
|
+
return true if difference <= delta
|
|
389
|
+
|
|
390
|
+
[false, "expected #{Matchers.describe(actual)} to be within #{delta.inspect} of " \
|
|
391
|
+
"#{Matchers.describe(@expected)}, but it differed by #{difference}", nil]
|
|
392
|
+
rescue NoMethodError, TypeError, ArgumentError
|
|
393
|
+
[false, "expected #{Matchers.describe(actual)} to be within #{delta.inspect} of " \
|
|
394
|
+
"#{Matchers.describe(@expected)}, but a #{actual.class} cannot be subtracted", nil]
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def description
|
|
398
|
+
return "be within #{@args.first.inspect} of #{Matchers.describe(@expected)}" if @expected_set
|
|
399
|
+
|
|
400
|
+
"be within #{@args.first.inspect} of (nothing -- .of was never called)"
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# `be`, in its three RSpec spellings:
|
|
405
|
+
#
|
|
406
|
+
# attest(x).to be(other) identity -- the same object, not merely equal
|
|
407
|
+
# attest(x).to be >= 0 an operator comparison
|
|
408
|
+
# attest(x).to be truthiness
|
|
409
|
+
#
|
|
410
|
+
# `==` is deliberately not among the operators. Defining it on a matcher object
|
|
411
|
+
# breaks equality everywhere the object is compared, and `eq` already says it.
|
|
412
|
+
class BeDeferred < Deferred
|
|
413
|
+
COMPARISONS = %i[< <= > >=].freeze
|
|
414
|
+
|
|
415
|
+
COMPARISONS.each do |operator|
|
|
416
|
+
define_method(operator) do |operand|
|
|
417
|
+
@operator = operator
|
|
418
|
+
@operand = operand
|
|
419
|
+
self
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def matches?(actual)
|
|
424
|
+
return compare(actual) if @operator
|
|
425
|
+
# `.empty?`, not `.any?`: `[nil].any?` is false, which would send `be(nil)` down
|
|
426
|
+
# the truthiness branch and assert the opposite of what was written.
|
|
427
|
+
return identity(actual) unless @args.empty?
|
|
428
|
+
return true if actual
|
|
429
|
+
|
|
430
|
+
[false, "expected a truthy value, but got #{actual.inspect}", nil]
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def description
|
|
434
|
+
return "be #{@operator} #{Matchers.describe(@operand)}" if @operator
|
|
435
|
+
return "be #{Matchers.describe(@args.first)}" unless @args.empty?
|
|
436
|
+
|
|
437
|
+
"be truthy"
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
private
|
|
441
|
+
|
|
442
|
+
def compare(actual)
|
|
443
|
+
return true if actual.public_send(@operator, @operand)
|
|
444
|
+
|
|
445
|
+
[false, "expected #{Matchers.describe(actual)} to be #{@operator} " \
|
|
446
|
+
"#{Matchers.describe(@operand)}", nil]
|
|
447
|
+
rescue NoMethodError, ArgumentError, TypeError
|
|
448
|
+
[false, "expected #{Matchers.describe(actual)} to be #{@operator} " \
|
|
449
|
+
"#{Matchers.describe(@operand)}, but a #{actual.class} cannot be compared", nil]
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# `be` is identity, not equality -- that distinction is the only reason to reach for
|
|
453
|
+
# it over `eq`, so the failure message says which one failed.
|
|
454
|
+
def identity(actual)
|
|
455
|
+
expected = @args.first
|
|
456
|
+
return true if actual.equal?(expected)
|
|
457
|
+
|
|
458
|
+
hint = actual == expected ? " (they are equal, but not the same object)" : ""
|
|
459
|
+
[false, "expected #{Matchers.describe(actual)} to be the same object as " \
|
|
460
|
+
"#{Matchers.describe(expected)}#{hint}", nil]
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
348
464
|
# The be_*/have_* fallback: with no matcher registered under the name, the name itself
|
|
349
465
|
# is the assertion -- `be_created` asks the actual whether it is `created?`.
|
|
350
466
|
class PredicateDeferred < Deferred
|
|
@@ -795,6 +911,94 @@ module Constable
|
|
|
795
911
|
raise Constable::Error, "change is only usable through attest { ... }.to change { ... }"
|
|
796
912
|
end
|
|
797
913
|
|
|
914
|
+
# Order-independent collection equality. `modernize` converts
|
|
915
|
+
# `expect(x).to contain_exactly(a, b)` verbatim, so not having it turned every
|
|
916
|
+
# converted spec that used it into a NoMethodError.
|
|
917
|
+
define_builtin(:contain_exactly) do |actual, *expected|
|
|
918
|
+
unless actual.respond_to?(:to_a)
|
|
919
|
+
next [false, "expected #{Matchers.describe(actual)} to be a collection, " \
|
|
920
|
+
"but a #{actual.class} does not respond to #to_a", nil]
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
items = actual.to_a
|
|
924
|
+
missing = expected.dup
|
|
925
|
+
extra = []
|
|
926
|
+
items.each do |item|
|
|
927
|
+
index = missing.index { |candidate| candidate == item }
|
|
928
|
+
index ? missing.delete_at(index) : extra << item
|
|
929
|
+
end
|
|
930
|
+
next true if missing.empty? && extra.empty?
|
|
931
|
+
|
|
932
|
+
parts = []
|
|
933
|
+
parts << "missing #{Matchers.describe(missing)}" unless missing.empty?
|
|
934
|
+
parts << "unexpected #{Matchers.describe(extra)}" unless extra.empty?
|
|
935
|
+
[false, "expected the collection to contain exactly #{expected.size} " \
|
|
936
|
+
"#{expected.size == 1 ? "item" : "items"}: #{parts.join(", ")}",
|
|
937
|
+
{ "Actual" => Matchers.describe(items) }]
|
|
938
|
+
end
|
|
939
|
+
# Not an alias: RSpec's match_array takes one array where contain_exactly takes
|
|
940
|
+
# varargs, so aliasing them makes match_array([1, 2]) assert that the collection
|
|
941
|
+
# holds a single element which is itself the array [1, 2].
|
|
942
|
+
define_builtin(:match_array) do |actual, expected|
|
|
943
|
+
unless expected.respond_to?(:to_a)
|
|
944
|
+
next [false, "match_array takes an array: attest(list).to match_array([1, 2])", nil]
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
Matchers.matcher_for(:contain_exactly).block.call(actual, *expected.to_a)
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
define_builtin(:start_with) do |actual, prefix|
|
|
951
|
+
unless actual.respond_to?(:start_with?) || actual.respond_to?(:first)
|
|
952
|
+
next [false, "expected #{Matchers.describe(actual)} to start with " \
|
|
953
|
+
"#{Matchers.describe(prefix)}, but a #{actual.class} cannot say", nil]
|
|
954
|
+
end
|
|
955
|
+
passed = if actual.respond_to?(:start_with?)
|
|
956
|
+
actual.start_with?(prefix)
|
|
957
|
+
else
|
|
958
|
+
actual.first(Array(prefix).size) == Array(prefix)
|
|
959
|
+
end
|
|
960
|
+
next true if passed
|
|
961
|
+
|
|
962
|
+
[false, "expected #{Matchers.describe(actual)} to start with #{Matchers.describe(prefix)}", nil]
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
define_builtin(:end_with) do |actual, suffix|
|
|
966
|
+
unless actual.respond_to?(:end_with?) || actual.respond_to?(:last)
|
|
967
|
+
next [false, "expected #{Matchers.describe(actual)} to end with " \
|
|
968
|
+
"#{Matchers.describe(suffix)}, but a #{actual.class} cannot say", nil]
|
|
969
|
+
end
|
|
970
|
+
passed = if actual.respond_to?(:end_with?)
|
|
971
|
+
actual.end_with?(suffix)
|
|
972
|
+
else
|
|
973
|
+
actual.last(Array(suffix).size) == Array(suffix)
|
|
974
|
+
end
|
|
975
|
+
next true if passed
|
|
976
|
+
|
|
977
|
+
[false, "expected #{Matchers.describe(actual)} to end with #{Matchers.describe(suffix)}", nil]
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
define_builtin(:be_between) do |actual, low, high|
|
|
981
|
+
next true if actual.between?(low, high)
|
|
982
|
+
|
|
983
|
+
[false, "expected #{Matchers.describe(actual)} to be between " \
|
|
984
|
+
"#{Matchers.describe(low)} and #{Matchers.describe(high)}", nil]
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
define_builtin(:satisfy) do |actual, &block|
|
|
988
|
+
next [false, "satisfy needs a block: attest(x).to satisfy { |value| ... }", nil] unless block
|
|
989
|
+
next true if block.call(actual)
|
|
990
|
+
|
|
991
|
+
[false, "expected #{Matchers.describe(actual)} to satisfy the block", nil]
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
define_builtin(:be_within, deferred_class: WithinDeferred) do |_actual, *_args|
|
|
995
|
+
raise Constable::Error, "be_within needs .of: attest(value).to be_within(0.5).of(10)"
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
define_builtin(:be, deferred_class: BeDeferred) do |_actual, *_args|
|
|
999
|
+
raise Constable::Error, "be is handled by BeDeferred and should never invoke its block"
|
|
1000
|
+
end
|
|
1001
|
+
|
|
798
1002
|
define_builtin(:be_a) do |actual, klass|
|
|
799
1003
|
next true if actual.is_a?(klass)
|
|
800
1004
|
|
data/lib/constable/registry.rb
CHANGED
|
@@ -42,6 +42,30 @@ module Constable
|
|
|
42
42
|
@cases.find { |klass| klass.constable_display_name == name.to_s || klass.name == name.to_s }
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# Re-keys any investigations that share a body with another, so the blotter never
|
|
46
|
+
# treats two tests as one. Runs once, after the whole suite is loaded -- a collision
|
|
47
|
+
# is invisible until every case file has been seen.
|
|
48
|
+
#
|
|
49
|
+
# Returns the groups it re-keyed, so a caller can report them if it wants to.
|
|
50
|
+
def disambiguate_identities!
|
|
51
|
+
collisions = investigations.group_by(&:identity).select { |_key, group| group.size > 1 }
|
|
52
|
+
return [] if collisions.empty?
|
|
53
|
+
|
|
54
|
+
collisions.each_value { |group| group.each(&:disambiguate!) }
|
|
55
|
+
|
|
56
|
+
# Class and description are usually enough to tell two identical bodies apart. When
|
|
57
|
+
# they are not -- a copy-pasted `investigate` with the same name and the same body
|
|
58
|
+
# in the same case -- fall back to position, which is the only thing left that
|
|
59
|
+
# differs. History for those resets whenever the file is reordered, which is the
|
|
60
|
+
# honest cost of two tests that are indistinguishable by anything a human wrote.
|
|
61
|
+
still_colliding = investigations.group_by(&:identity).select { |_key, group| group.size > 1 }
|
|
62
|
+
still_colliding.each_value do |group|
|
|
63
|
+
group.each_with_index { |investigation, index| investigation.disambiguate!(ordinal: index) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
collisions.values
|
|
67
|
+
end
|
|
68
|
+
|
|
45
69
|
def each(&) = @cases.each(&)
|
|
46
70
|
|
|
47
71
|
def size = @cases.size
|