matchers 0.1.0.pre.test

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.

Potentially problematic release.


This version of matchers might be problematic. Click here for more details.

Files changed (123) hide show
  1. checksums.yaml +7 -0
  2. data/lib/matcher/assertions.rb +21 -0
  3. data/lib/matcher/base.rb +189 -0
  4. data/lib/matcher/builder.rb +74 -0
  5. data/lib/matcher/chain.rb +60 -0
  6. data/lib/matcher/debug.rb +48 -0
  7. data/lib/matcher/errors/and_error.rb +86 -0
  8. data/lib/matcher/errors/boolean_collector.rb +51 -0
  9. data/lib/matcher/errors/element_error.rb +24 -0
  10. data/lib/matcher/errors/empty_error.rb +23 -0
  11. data/lib/matcher/errors/error.rb +39 -0
  12. data/lib/matcher/errors/error_collector.rb +99 -0
  13. data/lib/matcher/errors/nested_error.rb +96 -0
  14. data/lib/matcher/errors/or_error.rb +86 -0
  15. data/lib/matcher/expression_cache.rb +57 -0
  16. data/lib/matcher/expression_labeler.rb +91 -0
  17. data/lib/matcher/expressions/array_expression.rb +45 -0
  18. data/lib/matcher/expressions/block.rb +153 -0
  19. data/lib/matcher/expressions/call.rb +338 -0
  20. data/lib/matcher/expressions/call_error.rb +45 -0
  21. data/lib/matcher/expressions/constant.rb +53 -0
  22. data/lib/matcher/expressions/expression.rb +147 -0
  23. data/lib/matcher/expressions/expression_building.rb +258 -0
  24. data/lib/matcher/expressions/expression_walker.rb +73 -0
  25. data/lib/matcher/expressions/hash_expression.rb +59 -0
  26. data/lib/matcher/expressions/proc_expression.rb +92 -0
  27. data/lib/matcher/expressions/range_expression.rb +58 -0
  28. data/lib/matcher/expressions/recorder.rb +86 -0
  29. data/lib/matcher/expressions/rescue_last_error_expression.rb +44 -0
  30. data/lib/matcher/expressions/set_expression.rb +45 -0
  31. data/lib/matcher/expressions/string_expression.rb +53 -0
  32. data/lib/matcher/expressions/symbol_proc.rb +53 -0
  33. data/lib/matcher/expressions/variable.rb +85 -0
  34. data/lib/matcher/hash_stack.rb +53 -0
  35. data/lib/matcher/list.rb +102 -0
  36. data/lib/matcher/markers/optional.rb +80 -0
  37. data/lib/matcher/markers/others.rb +28 -0
  38. data/lib/matcher/matcher_cache.rb +18 -0
  39. data/lib/matcher/matchers/all_matcher.rb +60 -0
  40. data/lib/matcher/matchers/always_matcher.rb +28 -0
  41. data/lib/matcher/matchers/any_matcher.rb +70 -0
  42. data/lib/matcher/matchers/array_matcher.rb +35 -0
  43. data/lib/matcher/matchers/block_matcher.rb +59 -0
  44. data/lib/matcher/matchers/boolean_matcher.rb +35 -0
  45. data/lib/matcher/matchers/dig_matcher.rb +146 -0
  46. data/lib/matcher/matchers/each_matcher.rb +52 -0
  47. data/lib/matcher/matchers/each_pair_matcher.rb +119 -0
  48. data/lib/matcher/matchers/equal_matcher.rb +197 -0
  49. data/lib/matcher/matchers/equal_set_matcher.rb +99 -0
  50. data/lib/matcher/matchers/expression_matcher.rb +73 -0
  51. data/lib/matcher/matchers/filter_matcher.rb +111 -0
  52. data/lib/matcher/matchers/hash_matcher.rb +223 -0
  53. data/lib/matcher/matchers/imply_matcher.rb +81 -0
  54. data/lib/matcher/matchers/imply_some_matcher.rb +112 -0
  55. data/lib/matcher/matchers/index_by_matcher.rb +175 -0
  56. data/lib/matcher/matchers/inline_matcher.rb +99 -0
  57. data/lib/matcher/matchers/keys_matcher.rb +121 -0
  58. data/lib/matcher/matchers/kind_of_matcher.rb +35 -0
  59. data/lib/matcher/matchers/lazy_all_matcher.rb +68 -0
  60. data/lib/matcher/matchers/lazy_any_matcher.rb +68 -0
  61. data/lib/matcher/matchers/let_matcher.rb +73 -0
  62. data/lib/matcher/matchers/map_matcher.rb +129 -0
  63. data/lib/matcher/matchers/matcher_building.rb +5 -0
  64. data/lib/matcher/matchers/negated_array_matcher.rb +38 -0
  65. data/lib/matcher/matchers/negated_each_matcher.rb +36 -0
  66. data/lib/matcher/matchers/negated_each_pair_matcher.rb +38 -0
  67. data/lib/matcher/matchers/negated_imply_some_matcher.rb +46 -0
  68. data/lib/matcher/matchers/negated_matcher.rb +23 -0
  69. data/lib/matcher/matchers/negated_project_matcher.rb +31 -0
  70. data/lib/matcher/matchers/never_matcher.rb +29 -0
  71. data/lib/matcher/matchers/one_matcher.rb +70 -0
  72. data/lib/matcher/matchers/optional_matcher.rb +38 -0
  73. data/lib/matcher/matchers/parse_float_matcher.rb +86 -0
  74. data/lib/matcher/matchers/parse_integer_matcher.rb +98 -0
  75. data/lib/matcher/matchers/parse_iso8601_matcher.rb +92 -0
  76. data/lib/matcher/matchers/parse_json_matcher.rb +95 -0
  77. data/lib/matcher/matchers/project_matcher.rb +68 -0
  78. data/lib/matcher/matchers/raises_matcher.rb +124 -0
  79. data/lib/matcher/matchers/range_matcher.rb +47 -0
  80. data/lib/matcher/matchers/reference_matcher.rb +111 -0
  81. data/lib/matcher/matchers/reference_matcher_collection.rb +57 -0
  82. data/lib/matcher/matchers/regexp_matcher.rb +84 -0
  83. data/lib/matcher/messages/expected_phrasing.rb +342 -0
  84. data/lib/matcher/messages/message.rb +102 -0
  85. data/lib/matcher/messages/message_builder.rb +35 -0
  86. data/lib/matcher/messages/message_rules.rb +223 -0
  87. data/lib/matcher/messages/namespaced_message_builder.rb +19 -0
  88. data/lib/matcher/messages/phrasing.rb +57 -0
  89. data/lib/matcher/messages/standard_message_builder.rb +105 -0
  90. data/lib/matcher/once_before.rb +18 -0
  91. data/lib/matcher/optional_chain.rb +24 -0
  92. data/lib/matcher/patterns/ast_mapping.rb +42 -0
  93. data/lib/matcher/patterns/capture_hole.rb +33 -0
  94. data/lib/matcher/patterns/constant_hole.rb +14 -0
  95. data/lib/matcher/patterns/hole.rb +30 -0
  96. data/lib/matcher/patterns/method_hole.rb +58 -0
  97. data/lib/matcher/patterns/pattern.rb +92 -0
  98. data/lib/matcher/patterns/pattern_building.rb +39 -0
  99. data/lib/matcher/patterns/pattern_capture.rb +11 -0
  100. data/lib/matcher/patterns/pattern_match.rb +29 -0
  101. data/lib/matcher/patterns/variable_hole.rb +14 -0
  102. data/lib/matcher/reporter.rb +98 -0
  103. data/lib/matcher/rules/message_factory.rb +25 -0
  104. data/lib/matcher/rules/message_rule.rb +18 -0
  105. data/lib/matcher/rules/message_rule_context.rb +24 -0
  106. data/lib/matcher/rules/rule_builder.rb +29 -0
  107. data/lib/matcher/rules/rule_set.rb +57 -0
  108. data/lib/matcher/rules/transform_builder.rb +24 -0
  109. data/lib/matcher/rules/transform_mapping.rb +5 -0
  110. data/lib/matcher/rules/transform_rule.rb +21 -0
  111. data/lib/matcher/state.rb +40 -0
  112. data/lib/matcher/testing/error_builder.rb +62 -0
  113. data/lib/matcher/testing/error_checker.rb +496 -0
  114. data/lib/matcher/testing/error_testing.rb +37 -0
  115. data/lib/matcher/testing/pattern_testing.rb +11 -0
  116. data/lib/matcher/testing/pattern_testing_scope.rb +34 -0
  117. data/lib/matcher/testing.rb +102 -0
  118. data/lib/matcher/undefined.rb +10 -0
  119. data/lib/matcher/utils/mapping_utils.rb +61 -0
  120. data/lib/matcher/utils.rb +72 -0
  121. data/lib/matcher/version.rb +5 -0
  122. data/lib/matcher.rb +337 -0
  123. metadata +167 -0
@@ -0,0 +1,496 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ class ErrorChecker
5
+ def initialize(phrasing)
6
+ @phrasing = phrasing
7
+ @missing_phrases = []
8
+ @extra_phrases = []
9
+ @label_count = 0
10
+
11
+ label_counter = proc do |h, k|
12
+ h[k] = (@label_count += 1)
13
+ end
14
+
15
+ @element_label_index = Hash.new(&label_counter)
16
+ @group_label_index = Hash.new(&label_counter)
17
+ @hierarchy_index = Hash.new(&label_counter)
18
+ @expression_labeler = ExpressionLabeler.new
19
+
20
+ @message_index = Hash.new(&label_counter)
21
+ @phrase_index = Hash.new(&label_counter)
22
+
23
+ @identities = Hash.new(&label_counter)
24
+ @phrasing_labels = Hash.new(&label_counter)
25
+ end
26
+
27
+ attr_reader :reason, :missing_phrases, :extra_phrases
28
+
29
+ def check(expected, actual)
30
+ if expected.valid? && !actual.valid?
31
+ @reason = 'expected no errors'
32
+ return false
33
+ elsif !expected.valid? && actual.valid?
34
+ @reason = 'did not expect no errors'
35
+ end
36
+
37
+ expected_tree, expected_leaves = analyze(expected)
38
+ actual_tree, actual_leaves = analyze(actual)
39
+
40
+ if actual_tree.label != expected_tree.label
41
+ @reason = 'error has not the expected structure'
42
+ return false
43
+ end
44
+
45
+ propagate_hierarchy(expected_tree)
46
+ propagate_hierarchy(actual_tree)
47
+
48
+ unless check_phrases(expected_leaves, actual_leaves)
49
+ @reason = 'error has unexpected messages'
50
+ return false
51
+ end
52
+
53
+ unless check_trees(expected_tree, actual_tree)
54
+ @reason = 'error tree does not match expected'
55
+ return false
56
+ end
57
+
58
+ true
59
+ end
60
+
61
+ Tree = Struct.new(
62
+ :label,
63
+ :children,
64
+ :operator,
65
+ :hierarchy,
66
+ :identity,
67
+ ) do
68
+ def leaf?
69
+ false
70
+ end
71
+
72
+ def to_s
73
+ "#<Tree #{hierarchy} #{children.map(&:hierarchy).inspect}>"
74
+ end
75
+ alias inspect to_s
76
+ end
77
+
78
+ Leaf = Struct.new(
79
+ :label,
80
+ :path,
81
+ :message,
82
+ :hierarchy,
83
+ :identity,
84
+ :message_label,
85
+ :phrase_label,
86
+ ) do
87
+ def leaf?
88
+ true
89
+ end
90
+
91
+ def to_s
92
+ "#<Leaf #{hierarchy} #{path} #{message.inspect}>"
93
+ end
94
+ alias inspect to_s
95
+ end
96
+
97
+ private
98
+
99
+ # assign labels and hierarchy
100
+
101
+ def analyze(error)
102
+ leaves = []
103
+ tree = analyze_helper(error, List.empty, ExpressionLabeler::ROOT, leaves)
104
+
105
+ [tree, leaves]
106
+ end
107
+
108
+ def analyze_helper(error, path, path_label, leaves)
109
+ case error
110
+ when EmptyError
111
+ Leaf.new(0)
112
+ when AndError, OrError
113
+ operator = error.is_a?(AndError) ? 'and' : 'or'
114
+
115
+ left_children, right_children = error.children
116
+ .map { analyze_helper(_1, path, path_label, leaves) }
117
+ .partition { _1.leaf? || _1.operator != operator }
118
+
119
+ children = left_children + right_children.flat_map(&:children)
120
+ group_key = [error.class, children.map(&:label).sort]
121
+ label = @group_label_index[group_key]
122
+
123
+ Tree.new(label, children, operator)
124
+ when NestedError
125
+ new_path_label = @expression_labeler.label(error.key, path_label)
126
+
127
+ analyze_helper(error.child, path << error.key, new_path_label, leaves)
128
+ when ElementError
129
+ leaf = Leaf.new
130
+ leaf.label = @element_label_index[path_label]
131
+ leaf.path = path
132
+ leaf.message = error.message
133
+
134
+ leaves << leaf
135
+
136
+ leaf
137
+ else
138
+ raise "Unexpected error: #{error.inspect}"
139
+ end
140
+ end
141
+
142
+ def propagate_hierarchy(node, parent_hierarchy = 0)
143
+ key = [parent_hierarchy, node.label]
144
+ hierarchy = @hierarchy_index[key]
145
+ node.hierarchy = hierarchy
146
+ node.children.each { propagate_hierarchy(_1, hierarchy) } unless node.leaf?
147
+ end
148
+
149
+ # message indexing and checking
150
+
151
+ def check_phrases(expected_leaves, actual_leaves)
152
+ counts = Hash.new(0)
153
+
154
+ actual_leaves.each do |leaf|
155
+ index_message(leaf)
156
+ counts[[leaf.hierarchy, leaf.phrase_label]] += 1
157
+ end
158
+
159
+ expected_leaves.each do |leaf|
160
+ index_message(leaf)
161
+ counts[[leaf.hierarchy, leaf.phrase_label]] -= 1
162
+ end
163
+
164
+ inverted_phrase_index = @phrase_index.invert
165
+
166
+ counts.each do |key, count|
167
+ next if count == 0
168
+
169
+ phrase_label = key[1]
170
+ phrase = inverted_phrase_index[phrase_label]
171
+
172
+ if count < 0
173
+ @missing_phrases << [phrase, -count]
174
+ else
175
+ @extra_phrases << [phrase, count]
176
+ end
177
+ end
178
+
179
+ @missing_phrases.empty? && @extra_phrases.empty?
180
+ end
181
+
182
+ def index_message(leaf)
183
+ if leaf.message.is_a?(Message)
184
+ leaf.message_label = @message_index[leaf.message]
185
+ leaf.phrase_label = @phrase_index[phrase(leaf)]
186
+ else
187
+ leaf.phrase_label = @phrase_index[leaf.message]
188
+ end
189
+ end
190
+
191
+ def phrase(leaf)
192
+ @phrasing.call(leaf.path, leaf.message)
193
+ end
194
+
195
+ # tree matching - Welcome to Overengineering!
196
+
197
+ def check_trees(expected_tree, actual_tree)
198
+ identify_tree(actual_tree)
199
+
200
+ @actual_hierarchy_groups = group_by_hierarchy(actual_tree)
201
+ @actual_group_phrases_index = index_group_phrases(actual_tree)
202
+
203
+ catch(:mismatch) do
204
+ if expected_tree.leaf?
205
+ return false unless actual_tree.leaf?
206
+
207
+ if expected_tree.message.is_a?(String)
208
+ return expected_tree.phrase_label == actual_tree.phrase_label
209
+ else
210
+ return expected_tree.message_label == actual_tree.message_label
211
+ end
212
+ elsif actual_tree.leaf?
213
+ return false
214
+ else
215
+ identify_candidates(expected_tree)
216
+
217
+ return true
218
+ end
219
+ end
220
+
221
+ false
222
+ end
223
+
224
+ def group_by_hierarchy(tree, groups = {})
225
+ (groups[tree.hierarchy] ||= []) << tree
226
+ tree.children.each { group_by_hierarchy(_1, groups) } unless tree.leaf?
227
+
228
+ groups
229
+ end
230
+
231
+ def index_group_phrases(tree)
232
+ index = {}
233
+
234
+ index_group_phrases_helper(tree, index) unless tree.leaf?
235
+
236
+ index
237
+ end
238
+
239
+ def index_group_phrases_helper(tree, index)
240
+ message_counts = Hash.new(0)
241
+
242
+ labels = tree.children.filter_map do |child|
243
+ unless child.leaf?
244
+ index_group_phrases_helper(child, index)
245
+ next
246
+ end
247
+
248
+ message_counts[child.message_label] += 1 if child.message_label
249
+
250
+ child.phrase_label
251
+ end
252
+
253
+ unless labels.empty?
254
+ key = [tree.hierarchy, labels.sort!]
255
+ (index[key] ||= []) << [tree.identity, message_counts]
256
+ end
257
+
258
+ nil
259
+ end
260
+
261
+ def identify_tree(tree)
262
+ content = if tree.leaf?
263
+ tree.message
264
+ else
265
+ tree.children.map { identify_tree(_1) }.sort
266
+ end
267
+
268
+ key = [tree.hierarchy, content]
269
+ identity = @identities[key]
270
+
271
+ (tree.identity = identity)
272
+ end
273
+
274
+ def identify_candidates(expected_tree)
275
+ expected_leaves, expected_parents = expected_tree.children.partition(&:leaf?)
276
+
277
+ if expected_leaves.empty?
278
+ actual_group = @actual_hierarchy_groups[expected_tree.hierarchy]
279
+
280
+ throw(:mismatch) unless actual_group
281
+
282
+ identify_by_parents(expected_parents, actual_group)
283
+ elsif expected_parents.empty?
284
+ identify_by_leaves(expected_leaves, expected_tree.hierarchy)
285
+ else
286
+ leaf_identities = identify_by_leaves(expected_leaves, expected_tree.hierarchy)
287
+ actual_group = @actual_hierarchy_groups[expected_tree.hierarchy]
288
+
289
+ throw(:mismatch) unless actual_group
290
+
291
+ # NOTE: narrowed_group can't be empty since leaf_identities isn't empty
292
+ narrowed_group = actual_group.filter do |actual_tree|
293
+ leaf_identities.include?(actual_tree.identity)
294
+ end
295
+
296
+ identify_by_parents(expected_parents, narrowed_group)
297
+ end
298
+ end
299
+
300
+ def identify_by_parents(expected_parents, actual_group)
301
+ positions = expected_parents.map { identify_candidates(_1) }
302
+
303
+ throw(:mismatch) if positions.any?(&:empty?)
304
+
305
+ candidates = actual_group.filter_map do |actual_tree|
306
+ identities = actual_tree.children.filter_map do |child|
307
+ child.identity unless child.leaf?
308
+ end
309
+
310
+ actual_tree.identity if match_identities?(positions, identities)
311
+ end
312
+
313
+ throw(:mismatch) if candidates.empty?
314
+
315
+ candidates
316
+ end
317
+
318
+ def identify_by_leaves(expected_leaves, hierarchy)
319
+ phrase_labels = expected_leaves.map(&:phrase_label)
320
+ group_phrase_label = [hierarchy, phrase_labels.sort!]
321
+ actual_phrase_group = @actual_group_phrases_index[group_phrase_label]
322
+
323
+ throw(:mismatch) unless actual_phrase_group
324
+
325
+ expected_message_counts = Hash.new(0)
326
+ expected_leaves.each do |leaf|
327
+ expected_message_counts[leaf.message_label] += 1 if leaf.message_label
328
+ end
329
+
330
+ candidates = actual_phrase_group.filter_map do |actual_identity, actual_message_counts|
331
+ actual_identity if expected_message_counts.all? do |message_label, expected_message_count|
332
+ expected_message_count <= actual_message_counts[message_label]
333
+ end
334
+ end
335
+
336
+ throw(:mismatch) if candidates.empty?
337
+
338
+ candidates
339
+ end
340
+
341
+ def match_identities?(positions, identities)
342
+ # Prepare NP-hard matching.
343
+ # How did we even get here?! Maybe there's a shortcut!
344
+
345
+ # NOTE: candidates_list is now a new array
346
+ positions = positions.map do |candidates|
347
+ intersection = candidates & identities
348
+
349
+ # shortcut: no candidate matches any identity
350
+ return false if intersection.empty?
351
+
352
+ intersection
353
+ end
354
+
355
+ # shortcut: trivial match if candidates are unambiguous
356
+ return positions.map(&:first).sort == identities.sort if
357
+ positions.all? { _1.length == 1 }
358
+
359
+ # remap and count identities
360
+
361
+ id_map = {}
362
+ id_counts = Array.new(identities.length, 0)
363
+
364
+ identities.each do |id|
365
+ index = (id_map[id] ||= id_map.size)
366
+ id_counts[index] += 1
367
+ end
368
+
369
+ n = id_map.size
370
+ id_counts = id_counts.slice(0, n) if n < id_counts.length
371
+
372
+ # remap and count candidates
373
+
374
+ positions.each do |candidates|
375
+ candidates.map! { id_map[_1] }
376
+ end
377
+
378
+ candidate_counts = Array.new(n, 0)
379
+
380
+ positions.each do |candidates|
381
+ candidates.each { candidate_counts[_1] += 1 }
382
+ end
383
+
384
+ # shortcut: too few candidates for identity
385
+ candidate_counts.zip(id_counts) do |candidate_count, id_count|
386
+ return false if candidate_count < id_count
387
+ end
388
+
389
+ # sort candidates for early backtracking
390
+ positions.sort_by!(&:length)
391
+ positions.each do |candidates|
392
+ candidates.sort_by! { id_counts[_1] }
393
+ end
394
+
395
+ # now the fun begins
396
+ match_identities_helper(positions, id_counts, candidate_counts)
397
+ end
398
+
399
+ def match_identities_helper(positions, id_counts, candidate_counts)
400
+ n = positions.length
401
+ stack = Array.new(n)
402
+ j_stack = Array.new(n)
403
+
404
+ position = positions[0]
405
+ position.each { candidate_counts[_1] -= 1 }
406
+ candidates = narrow_candidates(position, id_counts, candidate_counts)
407
+ stack[0] = candidates
408
+
409
+ i = 0
410
+ j = 0
411
+
412
+ # i-loop for position
413
+ loop do
414
+ # assign position and go to next one
415
+ if j < candidates.length
416
+ j_stack[i] = j
417
+ i += 1
418
+
419
+ # found a match for all positions
420
+ return true if i == n
421
+
422
+ # assign candidate to position
423
+ c = candidates[j]
424
+ id_counts[c] -= 1
425
+
426
+ # next candidates
427
+ position = positions[i]
428
+ position.each { candidate_counts[_1] -= 1 }
429
+ candidates = narrow_candidates(position, id_counts, candidate_counts)
430
+ stack[i] = candidates
431
+
432
+ j = 0
433
+
434
+ next
435
+ end
436
+
437
+ # backtracking, j-loop for candidates
438
+ loop do
439
+ # no solution found (back at start)
440
+ return false if i == 0
441
+
442
+ stack[i] = nil
443
+ positions[i].each { candidate_counts[_1] += 1 }
444
+
445
+ i -= 1
446
+
447
+ # unassign previous candidate
448
+ candidates = stack[i]
449
+ j = j_stack[i]
450
+ c = candidates[j]
451
+ id_counts[c] += 1
452
+
453
+ j += 1
454
+
455
+ # continue backtracking unless untried candidates for position exist
456
+ break if j < candidates.length
457
+ end
458
+ end
459
+ end
460
+
461
+ def narrow_candidates(candidates, id_counts, candidate_counts)
462
+ # if there is only one candidate for current position, we can simplify
463
+ if candidates.length == 1
464
+ c = candidates[0]
465
+
466
+ # assign position if identity available, otherwise backtrack
467
+ id_counts[c].between?(1, candidate_counts[c] + 1) ? [c] : []
468
+ else
469
+ # do a pre-check if position can be assigned
470
+ #
471
+ # cases:
472
+ # - too few candidates for unassigned identities: backtrack
473
+ # * diff > 1 for at least one candidate, or
474
+ # * diff == 1 for more than one candidate
475
+ # - position can only be assigned to one identity: assign
476
+ # * exactly one diff == 1, all others diff < 1
477
+ # - else: try all candidates for unassigned identities
478
+ # * diff < 1 for all candidates c where id_counts[c] > 0
479
+
480
+ assign = nil
481
+
482
+ candidates.each do |c|
483
+ diff = id_counts[c] - candidate_counts[c]
484
+
485
+ if diff == 1 && !assign
486
+ assign = c
487
+ elsif diff >= 1
488
+ return [] # backtrack
489
+ end
490
+ end
491
+
492
+ assign ? [assign] : candidates.filter { id_counts[_1] > 0 }
493
+ end
494
+ end
495
+ end
496
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ module ErrorTesting
5
+ def empty
6
+ EmptyError.instance
7
+ end
8
+
9
+ def element(message)
10
+ ElementError.new(message)
11
+ end
12
+
13
+ def nested(key, error)
14
+ NestedError.new(key, error)
15
+ end
16
+
17
+ def nested_from(key, error)
18
+ NestedError.from(key, error)
19
+ end
20
+
21
+ def _and(*errors)
22
+ AndError.new(errors)
23
+ end
24
+
25
+ def _or(*errors)
26
+ OrError.new(errors)
27
+ end
28
+
29
+ def msg(actual)
30
+ StandardMessageBuilder.new(false, actual)
31
+ end
32
+
33
+ def assert_phrase(expected, message)
34
+ assert_equal expected, ExpectedPhrasing.new(nil, message).apply
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ module PatternTesting
5
+ def with_pattern(pattern, &)
6
+ pattern = Pattern.build(&pattern)
7
+
8
+ PatternTestingScope.new(pattern, self).instance_exec(&)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ class PatternTestingScope
5
+ include PatternBuilding
6
+
7
+ def initialize(pattern, test)
8
+ @pattern = pattern
9
+ @test = test
10
+ end
11
+
12
+ def assert_pattern_match(test_expression, **expected)
13
+ expected = expected.transform_values do |v|
14
+ Expression.of(v)
15
+ end
16
+
17
+ test_expression = Expression.of(test_expression)
18
+
19
+ result = @pattern.match(test_expression)
20
+
21
+ flunk "#{test_expression} did not match #{@pattern}" unless result
22
+
23
+ expected.each_pair do |key, value|
24
+ @test.assert_equal value, result[key]&.expression, "for #{key.inspect}"
25
+ end
26
+ end
27
+
28
+ def assert_no_pattern_match(test_expression)
29
+ test_expression = Expression.of(test_expression)
30
+
31
+ @test.assert_nil(@pattern.match(test_expression))
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ module Testing
5
+ def expression(&)
6
+ Expression.build(&)
7
+ end
8
+
9
+ def build_errors(&)
10
+ ErrorBuilder.build(&)
11
+ end
12
+
13
+ def assert_errors(actual, *base, **nested, &block)
14
+ assert_errors_helper(actual, base, nested, block, phrasing: ExpectedPhrasing.phrasing)
15
+ end
16
+
17
+ def assert_or_errors(actual, *base, **nested, &block)
18
+ assert_errors_helper(actual, base, nested, block, phrasing: ExpectedPhrasing.phrasing, use_or: true)
19
+ end
20
+
21
+ def assert_no_errors(actual)
22
+ assert(false, <<~TEXT.chomp) unless actual.valid?
23
+ The following conditions were not satisfied:
24
+
25
+ #{Reporter.report(actual)}
26
+ TEXT
27
+ end
28
+
29
+ def msg(actual)
30
+ StandardMessageBuilder.new(false, actual)
31
+ end
32
+
33
+ private
34
+
35
+ def assert_errors_helper(actual, base, nested, block, phrasing: ExpectedPhrasing.phrasing, use_or: false)
36
+ raise 'cannot pass expected errors directly if block given' if
37
+ (!base.empty? || !nested.empty?) && block
38
+
39
+ expected_nodes = if block
40
+ ErrorBuilder.build_errors(&block)
41
+ else
42
+ base.map { ElementError.new(_1) } + nested_from_hash(nested, use_or:)
43
+ end
44
+
45
+ error_klass = use_or ? OrError : AndError
46
+ expected = error_klass.from(expected_nodes)
47
+
48
+ assert false, 'expected an error but no error present' if
49
+ expected.valid? && actual.valid?
50
+
51
+ checker = ErrorChecker.new(phrasing)
52
+ result = checker.check(expected, actual)
53
+
54
+ return if result
55
+
56
+ reporter = Reporter.new
57
+
58
+ io = StringIO.new
59
+
60
+ io.puts <<~TEXT
61
+ #{checker.reason}
62
+
63
+ expected:
64
+
65
+ #{reporter.report(expected).chomp}
66
+
67
+ but got:
68
+
69
+ #{reporter.report(actual).chomp}
70
+ TEXT
71
+
72
+ unless checker.missing_phrases.empty?
73
+ io.puts "\nmissing:"
74
+ checker.missing_phrases.each do |phrase, count|
75
+ io.puts "- #{phrase}#{"(#{count}x)" if count > 1}"
76
+ end
77
+ end
78
+
79
+ unless checker.extra_phrases.empty?
80
+ io.puts "\nextra:"
81
+ checker.extra_phrases.each do |phrase, count|
82
+ io.puts "- #{phrase}#{"(#{count}x)" if count > 1}"
83
+ end
84
+ end
85
+
86
+ assert false, io.string
87
+ end
88
+
89
+ def nested_from_hash(hash, use_or: false)
90
+ hash.map do |key, value|
91
+ node = if value.is_a?(Hash)
92
+ klass = use_or ? OrError : AndError
93
+ klass.from(nested_from_hash(value, use_or:))
94
+ else
95
+ ElementError.new(value)
96
+ end
97
+
98
+ NestedError.from(key, node)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ class Undefined
5
+ include Singleton
6
+ include NoMatcher
7
+ include NoExpression
8
+ include NoKey
9
+ end
10
+ end