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.
- checksums.yaml +7 -0
- data/lib/matcher/assertions.rb +21 -0
- data/lib/matcher/base.rb +189 -0
- data/lib/matcher/builder.rb +74 -0
- data/lib/matcher/chain.rb +60 -0
- data/lib/matcher/debug.rb +48 -0
- data/lib/matcher/errors/and_error.rb +86 -0
- data/lib/matcher/errors/boolean_collector.rb +51 -0
- data/lib/matcher/errors/element_error.rb +24 -0
- data/lib/matcher/errors/empty_error.rb +23 -0
- data/lib/matcher/errors/error.rb +39 -0
- data/lib/matcher/errors/error_collector.rb +99 -0
- data/lib/matcher/errors/nested_error.rb +96 -0
- data/lib/matcher/errors/or_error.rb +86 -0
- data/lib/matcher/expression_cache.rb +57 -0
- data/lib/matcher/expression_labeler.rb +91 -0
- data/lib/matcher/expressions/array_expression.rb +45 -0
- data/lib/matcher/expressions/block.rb +153 -0
- data/lib/matcher/expressions/call.rb +338 -0
- data/lib/matcher/expressions/call_error.rb +45 -0
- data/lib/matcher/expressions/constant.rb +53 -0
- data/lib/matcher/expressions/expression.rb +147 -0
- data/lib/matcher/expressions/expression_building.rb +258 -0
- data/lib/matcher/expressions/expression_walker.rb +73 -0
- data/lib/matcher/expressions/hash_expression.rb +59 -0
- data/lib/matcher/expressions/proc_expression.rb +92 -0
- data/lib/matcher/expressions/range_expression.rb +58 -0
- data/lib/matcher/expressions/recorder.rb +86 -0
- data/lib/matcher/expressions/rescue_last_error_expression.rb +44 -0
- data/lib/matcher/expressions/set_expression.rb +45 -0
- data/lib/matcher/expressions/string_expression.rb +53 -0
- data/lib/matcher/expressions/symbol_proc.rb +53 -0
- data/lib/matcher/expressions/variable.rb +85 -0
- data/lib/matcher/hash_stack.rb +53 -0
- data/lib/matcher/list.rb +102 -0
- data/lib/matcher/markers/optional.rb +80 -0
- data/lib/matcher/markers/others.rb +28 -0
- data/lib/matcher/matcher_cache.rb +18 -0
- data/lib/matcher/matchers/all_matcher.rb +60 -0
- data/lib/matcher/matchers/always_matcher.rb +28 -0
- data/lib/matcher/matchers/any_matcher.rb +70 -0
- data/lib/matcher/matchers/array_matcher.rb +35 -0
- data/lib/matcher/matchers/block_matcher.rb +59 -0
- data/lib/matcher/matchers/boolean_matcher.rb +35 -0
- data/lib/matcher/matchers/dig_matcher.rb +146 -0
- data/lib/matcher/matchers/each_matcher.rb +52 -0
- data/lib/matcher/matchers/each_pair_matcher.rb +119 -0
- data/lib/matcher/matchers/equal_matcher.rb +197 -0
- data/lib/matcher/matchers/equal_set_matcher.rb +99 -0
- data/lib/matcher/matchers/expression_matcher.rb +73 -0
- data/lib/matcher/matchers/filter_matcher.rb +111 -0
- data/lib/matcher/matchers/hash_matcher.rb +223 -0
- data/lib/matcher/matchers/imply_matcher.rb +81 -0
- data/lib/matcher/matchers/imply_some_matcher.rb +112 -0
- data/lib/matcher/matchers/index_by_matcher.rb +175 -0
- data/lib/matcher/matchers/inline_matcher.rb +99 -0
- data/lib/matcher/matchers/keys_matcher.rb +121 -0
- data/lib/matcher/matchers/kind_of_matcher.rb +35 -0
- data/lib/matcher/matchers/lazy_all_matcher.rb +68 -0
- data/lib/matcher/matchers/lazy_any_matcher.rb +68 -0
- data/lib/matcher/matchers/let_matcher.rb +73 -0
- data/lib/matcher/matchers/map_matcher.rb +129 -0
- data/lib/matcher/matchers/matcher_building.rb +5 -0
- data/lib/matcher/matchers/negated_array_matcher.rb +38 -0
- data/lib/matcher/matchers/negated_each_matcher.rb +36 -0
- data/lib/matcher/matchers/negated_each_pair_matcher.rb +38 -0
- data/lib/matcher/matchers/negated_imply_some_matcher.rb +46 -0
- data/lib/matcher/matchers/negated_matcher.rb +23 -0
- data/lib/matcher/matchers/negated_project_matcher.rb +31 -0
- data/lib/matcher/matchers/never_matcher.rb +29 -0
- data/lib/matcher/matchers/one_matcher.rb +70 -0
- data/lib/matcher/matchers/optional_matcher.rb +38 -0
- data/lib/matcher/matchers/parse_float_matcher.rb +86 -0
- data/lib/matcher/matchers/parse_integer_matcher.rb +98 -0
- data/lib/matcher/matchers/parse_iso8601_matcher.rb +92 -0
- data/lib/matcher/matchers/parse_json_matcher.rb +95 -0
- data/lib/matcher/matchers/project_matcher.rb +68 -0
- data/lib/matcher/matchers/raises_matcher.rb +124 -0
- data/lib/matcher/matchers/range_matcher.rb +47 -0
- data/lib/matcher/matchers/reference_matcher.rb +111 -0
- data/lib/matcher/matchers/reference_matcher_collection.rb +57 -0
- data/lib/matcher/matchers/regexp_matcher.rb +84 -0
- data/lib/matcher/messages/expected_phrasing.rb +342 -0
- data/lib/matcher/messages/message.rb +102 -0
- data/lib/matcher/messages/message_builder.rb +35 -0
- data/lib/matcher/messages/message_rules.rb +223 -0
- data/lib/matcher/messages/namespaced_message_builder.rb +19 -0
- data/lib/matcher/messages/phrasing.rb +57 -0
- data/lib/matcher/messages/standard_message_builder.rb +105 -0
- data/lib/matcher/once_before.rb +18 -0
- data/lib/matcher/optional_chain.rb +24 -0
- data/lib/matcher/patterns/ast_mapping.rb +42 -0
- data/lib/matcher/patterns/capture_hole.rb +33 -0
- data/lib/matcher/patterns/constant_hole.rb +14 -0
- data/lib/matcher/patterns/hole.rb +30 -0
- data/lib/matcher/patterns/method_hole.rb +58 -0
- data/lib/matcher/patterns/pattern.rb +92 -0
- data/lib/matcher/patterns/pattern_building.rb +39 -0
- data/lib/matcher/patterns/pattern_capture.rb +11 -0
- data/lib/matcher/patterns/pattern_match.rb +29 -0
- data/lib/matcher/patterns/variable_hole.rb +14 -0
- data/lib/matcher/reporter.rb +98 -0
- data/lib/matcher/rules/message_factory.rb +25 -0
- data/lib/matcher/rules/message_rule.rb +18 -0
- data/lib/matcher/rules/message_rule_context.rb +24 -0
- data/lib/matcher/rules/rule_builder.rb +29 -0
- data/lib/matcher/rules/rule_set.rb +57 -0
- data/lib/matcher/rules/transform_builder.rb +24 -0
- data/lib/matcher/rules/transform_mapping.rb +5 -0
- data/lib/matcher/rules/transform_rule.rb +21 -0
- data/lib/matcher/state.rb +40 -0
- data/lib/matcher/testing/error_builder.rb +62 -0
- data/lib/matcher/testing/error_checker.rb +496 -0
- data/lib/matcher/testing/error_testing.rb +37 -0
- data/lib/matcher/testing/pattern_testing.rb +11 -0
- data/lib/matcher/testing/pattern_testing_scope.rb +34 -0
- data/lib/matcher/testing.rb +102 -0
- data/lib/matcher/undefined.rb +10 -0
- data/lib/matcher/utils/mapping_utils.rb +61 -0
- data/lib/matcher/utils.rb +72 -0
- data/lib/matcher/version.rb +5 -0
- data/lib/matcher.rb +337 -0
- 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,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
|