matchers 0.1.0.pre.1

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