smartest 0.2.2 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f8abe11f15ae34b9c69f3c63baa01a2c724d5027b6acb842c9ed19fd3ab0fd4
4
- data.tar.gz: 1db282b10807ae9e31115cabe6df364979e57690455a0da30775c7e9c5f65349
3
+ metadata.gz: 71422e8f16922c132f0fb06024693ff0d7b502fe6cd2c6050491f1605b105daa
4
+ data.tar.gz: a1c14eb9ab5448753a4e5f1b04faab8ade071b31e8943ed4f34f821c88722477
5
5
  SHA512:
6
- metadata.gz: 60bd8acff86dc1ad7f2e4614b4dc1d34d0f2ba60d3ccdccece9f451dd9ea57510b77b572f69fa6163e5299165778e8bed051d93f8568cdc1c6c21416ed639f3d
7
- data.tar.gz: 5ae7701a9471265a7ff113d9db3dae538ea47b6314a7a8162c817f8bfe425cce757d1b32b12a08d70b7912f6643cb777154b96f4e960710a90de4a12595e7ae9
6
+ metadata.gz: b5850a77ec4f8ac2d7b887151b5439af643928d9b62bb08c444c1b93f8a3eef4381f9bc0ad78d070c11cbde1d115d9fdb59c88f24535513ef3c819070206ba73
7
+ data.tar.gz: 9ac959479ddfdc0979a9a9bb9a11e9366f53d2cb03aecef02f96fff01c3417f9cc42c0ee87b954ccc7176171120b7b711dfb105fe40dd665e61a08a6af6ebd07
data/README.md CHANGED
@@ -194,6 +194,8 @@ expect { action }.to raise_error(ErrorClass)
194
194
  expect { action }.to raise_error(/message/)
195
195
  expect { action }.to raise_error(ErrorClass, /message/)
196
196
  expect { action }.to change { value }
197
+ expect(actual).to matcher.or(other_matcher)
198
+ expect(actual).to matcher.and(other_matcher)
197
199
  ```
198
200
 
199
201
  Examples:
@@ -261,6 +263,28 @@ specific order, preserve duplicate counts, and can use matcher objects such as
261
263
  `change` is only supported with `expect { ... }` block expectations and must be
262
264
  written with a value block.
263
265
 
266
+ Matchers can be composed with `.or` and `.and`:
267
+
268
+ ```ruby
269
+ expect(result).to include("NetworkError").or include("Failed to fetch")
270
+ expect(response.status).to eq(200).or(eq(304))
271
+ expect("report.txt").to start_with("report").and end_with(".txt")
272
+ ```
273
+
274
+ `.or` passes when any matcher matches and short-circuits the right-hand matcher
275
+ when the left-hand matcher passes. `.and` passes only when every matcher matches.
276
+ `not_to` does not support composed matchers and raises `ArgumentError` when used
277
+ with `.and` or `.or`.
278
+
279
+ Composed `change` matchers observe one action block execution:
280
+
281
+ ```ruby
282
+ expect {
283
+ count += 1
284
+ total += 1
285
+ }.to change { count }.by(1).and change { total }.by(1)
286
+ ```
287
+
264
288
  Custom matcher modules can be registered from `around_suite` or `around_test`
265
289
  with `use_matcher`. The generated scaffold includes a `PredicateMatcher` custom
266
290
  matcher for `be_<predicate>` calls. See [Matchers](documentation/docs/matchers.md).
data/SMARTEST_DESIGN.md CHANGED
@@ -718,9 +718,11 @@ MVP expectation API:
718
718
  expect(actual).to eq(expected)
719
719
  expect(actual).not_to eq(expected)
720
720
  expect { action }.to change { value }
721
+ expect(actual).to matcher.or(other_matcher)
722
+ expect(actual).to matcher.and(other_matcher)
721
723
  ```
722
724
 
723
- Current built-in matchers include:
725
+ Current built-in matchers and composition helpers include:
724
726
 
725
727
  ```ruby
726
728
  eq(expected)
@@ -737,6 +739,8 @@ raise_error(ErrorClass)
737
739
  raise_error(/message/)
738
740
  raise_error(ErrorClass, /message/)
739
741
  change { value }
742
+ matcher.or(other_matcher)
743
+ matcher.and(other_matcher)
740
744
  ```
741
745
 
742
746
  Internal model:
@@ -748,8 +752,17 @@ expect(actual)
748
752
  eq(expected)
749
753
  => EqMatcher
750
754
 
755
+ EqMatcher
756
+ < Smartest::Matcher
757
+
758
+ Smartest::Matcher#or(other_matcher)
759
+ => OrMatcher
760
+
761
+ Smartest::Matcher#and(other_matcher)
762
+ => AndMatcher
763
+
751
764
  ExpectationTarget#to(matcher)
752
- => matcher.match!(actual)
765
+ => matcher.matches?(actual)
753
766
  ```
754
767
 
755
768
  Example:
@@ -761,17 +774,36 @@ class Smartest::ExpectationTarget
761
774
  end
762
775
 
763
776
  def to(matcher)
764
- matcher.match!(@actual)
777
+ return self if matcher.matches?(@actual)
778
+
779
+ raise AssertionFailed, matcher.failure_message
765
780
  end
766
781
 
767
782
  def not_to(matcher)
768
- matcher.not_match!(@actual)
783
+ if matcher.respond_to?(:supports_negated_expectation?) && !matcher.supports_negated_expectation?
784
+ raise ArgumentError, matcher.negated_expectation_error
785
+ end
786
+
787
+ if matcher.respond_to?(:does_not_match?)
788
+ return self if matcher.does_not_match?(@actual)
789
+
790
+ raise AssertionFailed, matcher.negated_failure_message
791
+ end
792
+
793
+ return self unless matcher.matches?(@actual)
794
+
795
+ raise AssertionFailed, matcher.negated_failure_message
769
796
  end
770
797
  end
771
798
  ```
772
799
 
773
800
  Assertion failures should raise `Smartest::AssertionFailed`.
774
801
 
802
+ Matcher composition is logical composition over the same actual value. `or`
803
+ passes when any matcher matches and should short-circuit later matchers after a
804
+ match. `and` passes only when every matcher matches. `not_to` with composed
805
+ matchers is intentionally unsupported and raises `ArgumentError`.
806
+
775
807
  ## Reporter
776
808
 
777
809
  The initial reporter should be simple.
@@ -13,6 +13,10 @@ module Smartest
13
13
  end
14
14
 
15
15
  def not_to(matcher)
16
+ if matcher.respond_to?(:supports_negated_expectation?) && !matcher.supports_negated_expectation?
17
+ raise ArgumentError, matcher.negated_expectation_error
18
+ end
19
+
16
20
  if matcher.respond_to?(:does_not_match?)
17
21
  return self if matcher.does_not_match?(@actual)
18
22
 
@@ -38,7 +38,7 @@ module Smartest
38
38
  name.to_s.match?(/\\Abe_.+\\z/) || super
39
39
  end
40
40
 
41
- class Matcher
41
+ class Matcher < Smartest::Matcher
42
42
  def initialize(predicate_name, arguments, block)
43
43
  @predicate_name = predicate_name
44
44
  @predicate = "\#{predicate_name}?"
@@ -63,14 +63,14 @@ module Smartest
63
63
  "expected \#{@actual.inspect} not to be \#{description}"
64
64
  end
65
65
 
66
- private
67
-
68
66
  def description
69
67
  return @predicate_name if @arguments.empty?
70
68
 
71
69
  "\#{@predicate_name} \#{argument_description}"
72
70
  end
73
71
 
72
+ private
73
+
74
74
  def argument_description
75
75
  @arguments.map(&:inspect).join(", ")
76
76
  end
@@ -54,7 +54,162 @@ module Smartest
54
54
  end
55
55
  end
56
56
 
57
- class EqMatcher
57
+ class Matcher
58
+ def and(other_matcher)
59
+ AndMatcher.new(self, other_matcher)
60
+ end
61
+
62
+ def or(other_matcher)
63
+ OrMatcher.new(self, other_matcher)
64
+ end
65
+
66
+ def description
67
+ self.class.name || "matcher"
68
+ end
69
+ end
70
+
71
+ class CompoundMatcher < Matcher
72
+ attr_reader :matchers
73
+
74
+ NEGATED_EXPECTATION_ERROR = "not_to does not support matcher composition with .and or .or"
75
+
76
+ def initialize(*matchers)
77
+ @matchers = matchers.flat_map do |matcher|
78
+ matcher.is_a?(self.class) ? matcher.matchers : matcher
79
+ end
80
+ reset_result
81
+ end
82
+
83
+ def supports_negated_expectation?
84
+ false
85
+ end
86
+
87
+ def negated_expectation_error
88
+ NEGATED_EXPECTATION_ERROR
89
+ end
90
+
91
+ private
92
+
93
+ def reset_result
94
+ @actual = nil
95
+ @failed_matchers = []
96
+ @matched_matchers = []
97
+ end
98
+
99
+ def actual_description
100
+ @actual.respond_to?(:call) ? "block" : @actual.inspect
101
+ end
102
+
103
+ def matcher_description(matcher)
104
+ description = if matcher.respond_to?(:description)
105
+ matcher.description
106
+ else
107
+ matcher.inspect
108
+ end
109
+
110
+ matcher.is_a?(CompoundMatcher) ? "(#{description})" : description
111
+ end
112
+
113
+ def joined_description(separator)
114
+ @matchers.map { |matcher| matcher_description(matcher) }.join(separator)
115
+ end
116
+
117
+ def failure_messages
118
+ @failed_matchers.filter_map do |matcher|
119
+ matcher.failure_message if matcher.respond_to?(:failure_message)
120
+ end
121
+ end
122
+
123
+ def composed_block_expectation?(actual)
124
+ actual.respond_to?(:call) &&
125
+ @matchers.all? { |matcher| matcher.respond_to?(:composable_block_expectation?) } &&
126
+ @matchers.all?(&:composable_block_expectation?)
127
+ end
128
+
129
+ def run_composed_block_expectation(actual)
130
+ @matchers.each(&:prepare_composed_block_expectation)
131
+ actual.call
132
+ @matchers.each(&:finish_composed_block_expectation)
133
+ @matchers.each do |matcher|
134
+ if matcher.composed_block_matches?
135
+ @matched_matchers << matcher
136
+ else
137
+ @failed_matchers << matcher
138
+ end
139
+ end
140
+ end
141
+ end
142
+
143
+ class AndMatcher < CompoundMatcher
144
+ def matches?(actual)
145
+ reset_result
146
+ @actual = actual
147
+
148
+ if composed_block_expectation?(actual)
149
+ run_composed_block_expectation(actual)
150
+ return @failed_matchers.empty?
151
+ end
152
+
153
+ @matchers.each do |matcher|
154
+ if matcher.matches?(actual)
155
+ @matched_matchers << matcher
156
+ else
157
+ @failed_matchers << matcher
158
+ return false
159
+ end
160
+ end
161
+
162
+ true
163
+ end
164
+
165
+ def failure_message
166
+ message = "expected #{actual_description} to match all of #{description}"
167
+ details = failure_messages
168
+ details.empty? ? message : "#{message}; #{details.join('; ')}"
169
+ end
170
+
171
+ def negated_failure_message
172
+ "expected #{actual_description} not to match all of #{description}"
173
+ end
174
+
175
+ def description
176
+ joined_description(" and ")
177
+ end
178
+ end
179
+
180
+ class OrMatcher < CompoundMatcher
181
+ def matches?(actual)
182
+ reset_result
183
+ @actual = actual
184
+
185
+ @matchers.each do |matcher|
186
+ if matcher.matches?(actual)
187
+ @matched_matchers << matcher
188
+ return true
189
+ end
190
+
191
+ @failed_matchers << matcher
192
+ end
193
+
194
+ false
195
+ end
196
+
197
+ def failure_message
198
+ message = "expected #{actual_description} to match any of #{description}"
199
+ details = failure_messages
200
+ details.empty? ? message : "#{message}; #{details.join('; ')}"
201
+ end
202
+
203
+ def negated_failure_message
204
+ "expected #{actual_description} not to match any of #{description}"
205
+ end
206
+
207
+ def description
208
+ joined_description(" or ")
209
+ end
210
+ end
211
+
212
+ class EqMatcher < Matcher
58
213
  def initialize(expected)
59
214
  @expected = expected
60
215
  end
@@ -77,7 +232,7 @@ module Smartest
77
232
  end
78
233
  end
79
234
 
80
- class IncludeMatcher
235
+ class IncludeMatcher < Matcher
81
236
  def initialize(expected)
82
237
  @expected = expected
83
238
  end
@@ -102,7 +257,7 @@ module Smartest
102
257
  end
103
258
  end
104
259
 
105
- class StartWithMatcher
260
+ class StartWithMatcher < Matcher
106
261
  def initialize(*prefixes)
107
262
  @prefixes = prefixes
108
263
  end
@@ -135,7 +290,7 @@ module Smartest
135
290
  end
136
291
  end
137
292
 
138
- class EndWithMatcher
293
+ class EndWithMatcher < Matcher
139
294
  def initialize(*suffixes)
140
295
  @suffixes = suffixes
141
296
  end
@@ -168,7 +323,7 @@ module Smartest
168
323
  end
169
324
  end
170
325
 
171
- class BeAKindOfMatcher
326
+ class BeAKindOfMatcher < Matcher
172
327
  def initialize(expected_class)
173
328
  @expected_class = expected_class
174
329
  end
@@ -203,7 +358,7 @@ module Smartest
203
358
  end
204
359
  end
205
360
 
206
- class BeNilMatcher
361
+ class BeNilMatcher < Matcher
207
362
  def matches?(actual)
208
363
  @actual = actual
209
364
  actual.nil?
@@ -222,7 +377,7 @@ module Smartest
222
377
  end
223
378
  end
224
379
 
225
- class MatchMatcher
380
+ class MatchMatcher < Matcher
226
381
  def initialize(regexp)
227
382
  @regexp = regexp
228
383
  end
@@ -247,7 +402,7 @@ module Smartest
247
402
  end
248
403
  end
249
404
 
250
- class ContainExactlyMatcher
405
+ class ContainExactlyMatcher < Matcher
251
406
  def initialize(expected_items, matcher_name:)
252
407
  @expected_items = expected_items
253
408
  @matcher_name = matcher_name
@@ -369,7 +524,7 @@ module Smartest
369
524
  end
370
525
  end
371
526
 
372
- class RaiseErrorMatcher
527
+ class RaiseErrorMatcher < Matcher
373
528
  def initialize(*expected_error)
374
529
  @expected_type = expected_type_for(expected_error)
375
530
  @expected_error_class = expected_error.find { |item| error_class?(item) }
@@ -403,6 +558,10 @@ module Smartest
403
558
  "expected block not to raise #{expected_description}, but raised #{actual_error_description}"
404
559
  end
405
560
 
561
+ def description
562
+ "raise #{expected_description}"
563
+ end
564
+
406
565
  private
407
566
 
408
567
  def expected_type_for(expected_error)
@@ -446,7 +605,7 @@ module Smartest
446
605
  end
447
606
  end
448
607
 
449
- class ChangeMatcher
608
+ class ChangeMatcher < Matcher
450
609
  UNSET = Object.new
451
610
 
452
611
  def initialize(value_block)
@@ -498,6 +657,31 @@ module Smartest
498
657
  "expected value not to change, but #{observed_description}"
499
658
  end
500
659
 
660
+ def description
661
+ expected_description
662
+ end
663
+
664
+ def composable_block_expectation?
665
+ true
666
+ end
667
+
668
+ def prepare_composed_block_expectation
669
+ reset_result
670
+ @callable = true
671
+ @before_value = @value_block.call
672
+ end
673
+
674
+ def finish_composed_block_expectation
675
+ @after_value = @value_block.call
676
+ calculate_delta if delta_expected?
677
+ end
678
+
679
+ def composed_block_matches?
680
+ return false unless @callable
681
+
682
+ positive_failures.empty?
683
+ end
684
+
501
685
  private
502
686
 
503
687
  def reset_result
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smartest
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -218,6 +218,74 @@ test("supports basic matchers") do
218
218
  expect(status).to eq(0)
219
219
  end
220
220
 
221
+ test("supports matcher composition with and and or") do
222
+ action_calls = 0
223
+ first_value = 0
224
+ second_value = 10
225
+
226
+ expect("NetworkError").to include("Network").or include("Failed to fetch")
227
+ expect(304).to eq(200).or(eq(201)).or(eq(304))
228
+ expect("report.txt").to start_with("report").and end_with(".txt")
229
+ expect {
230
+ action_calls += 1
231
+ first_value += 1
232
+ second_value += 1
233
+ }.to change { first_value }.by(1).and change { second_value }.by(1)
234
+
235
+ expect(action_calls).to eq(1)
236
+ end
237
+
238
+ test("reports matcher composition failures") do
239
+ error = SmartestSelfTest.capture_error(Smartest::AssertionFailed) do
240
+ expect("permission denied").to include("NetworkError").or include("Failed to fetch")
241
+ end
242
+
243
+ expect(error.message).to include(
244
+ 'expected "permission denied" to match any of include "NetworkError" or include "Failed to fetch"'
245
+ )
246
+ expect(error.message).to include('expected "permission denied" to include "NetworkError"')
247
+ expect(error.message).to include('expected "permission denied" to include "Failed to fetch"')
248
+
249
+ error = SmartestSelfTest.capture_error(Smartest::AssertionFailed) do
250
+ expect("error.log").to start_with("error").and end_with(".txt")
251
+ end
252
+
253
+ expect(error.message).to include('expected "error.log" to match all of start with "error" and end with ".txt"')
254
+ expect(error.message).to include('expected "error.log" to end with ".txt"')
255
+ end
256
+
257
+ test("rejects negated matcher composition") do
258
+ error = SmartestSelfTest.capture_error(ArgumentError) do
259
+ expect("public token").not_to include("password").or include("secret")
260
+ end
261
+
262
+ expect(error.message).to eq("not_to does not support matcher composition with .and or .or")
263
+
264
+ error = SmartestSelfTest.capture_error(ArgumentError) do
265
+ expect("report.log").not_to start_with("report").and end_with(".txt")
266
+ end
267
+
268
+ expect(error.message).to eq("not_to does not support matcher composition with .and or .or")
269
+ end
270
+
271
+ test("short-circuits or matcher composition") do
272
+ right_matcher = Class.new(Smartest::Matcher) do
273
+ def matches?(_actual)
274
+ raise "right matcher should not be evaluated"
275
+ end
276
+
277
+ def failure_message
278
+ "right matcher failed"
279
+ end
280
+
281
+ def negated_failure_message
282
+ "right matcher matched"
283
+ end
284
+ end.new
285
+
286
+ expect("ok").to eq("ok").or(right_matcher)
287
+ end
288
+
221
289
  test("reports be_a and match matcher failures") do
222
290
  suite = Smartest::Suite.new
223
291
  suite.tests.add(
@@ -1368,7 +1436,7 @@ test("cli loads matcher files registered in test helper") do
1368
1436
  RUBY
1369
1437
  File.write(File.join(matchers_dir, "have_status_matcher.rb"), <<~RUBY)
1370
1438
  module HaveStatusMatcher
1371
- class MatcherImpl
1439
+ class MatcherImpl < Smartest::Matcher
1372
1440
  def initialize(expected)
1373
1441
  @expected = expected
1374
1442
  end
@@ -1385,6 +1453,10 @@ test("cli loads matcher files registered in test helper") do
1385
1453
  def negated_failure_message
1386
1454
  "expected \#{@actual.inspect} not to have status \#{@expected.inspect}"
1387
1455
  end
1456
+
1457
+ def description
1458
+ "have status \#{@expected.inspect}"
1459
+ end
1388
1460
  end
1389
1461
 
1390
1462
  def have_status(expected)
@@ -1773,6 +1845,7 @@ test("cli initializes a runnable test scaffold") do
1773
1845
  expect(helper_contents).to include("use_matcher PredicateMatcher")
1774
1846
  predicate_matcher_contents = File.read(File.join(dir, "smartest/matchers/predicate_matcher.rb"))
1775
1847
  expect(predicate_matcher_contents).to include("module PredicateMatcher")
1848
+ expect(predicate_matcher_contents).to include("class Matcher < Smartest::Matcher")
1776
1849
  expect(File.read(File.join(dir, "smartest/example_test.rb"))).to include('require "test_helper"')
1777
1850
 
1778
1851
  nested_fixtures_dir = File.join(dir, "smartest/fixtures/nested")
@@ -1802,7 +1875,7 @@ test("cli initializes a runnable test scaffold") do
1802
1875
  FileUtils.mkdir_p(nested_matchers_dir)
1803
1876
  File.write(File.join(nested_matchers_dir, "auto_loaded_matcher.rb"), <<~RUBY)
1804
1877
  module AutoLoadedMatcher
1805
- class Matcher
1878
+ class Matcher < Smartest::Matcher
1806
1879
  def initialize(expected)
1807
1880
  @expected = expected
1808
1881
  end
@@ -1819,6 +1892,10 @@ test("cli initializes a runnable test scaffold") do
1819
1892
  def negated_failure_message
1820
1893
  "expected \#{@actual.inspect} not to auto-eq \#{@expected.inspect}"
1821
1894
  end
1895
+
1896
+ def description
1897
+ "auto-eq \#{@expected.inspect}"
1898
+ end
1822
1899
  end
1823
1900
 
1824
1901
  def auto_eq(expected)
@@ -1846,6 +1923,7 @@ test("cli initializes a runnable test scaffold") do
1846
1923
  test("generated predicate matcher") do
1847
1924
  expect("").to be_empty
1848
1925
  expect(2).to be_between(1, 3)
1926
+ expect(2).to be_odd.or be_even
1849
1927
  end
1850
1928
  RUBY
1851
1929
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smartest
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yusuke Iwaki