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.
- checksums.yaml +7 -0
- data/lib/matcher/assertions.rb +19 -0
- data/lib/matcher/autoload.rb +5 -0
- data/lib/matcher/base.rb +183 -0
- data/lib/matcher/compatibility.rb +34 -0
- data/lib/matcher/debug.rb +62 -0
- data/lib/matcher/dsl/builder.rb +99 -0
- data/lib/matcher/dsl/chain.rb +84 -0
- data/lib/matcher/dsl/expression_dsl.rb +306 -0
- data/lib/matcher/dsl/matcher_dsl.rb +5 -0
- data/lib/matcher/dsl/optional.rb +82 -0
- data/lib/matcher/dsl/optional_chain.rb +24 -0
- data/lib/matcher/dsl/others.rb +28 -0
- data/lib/matcher/errors/and_error.rb +88 -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 +100 -0
- data/lib/matcher/errors/nested_error.rb +98 -0
- data/lib/matcher/errors/or_error.rb +88 -0
- data/lib/matcher/expression_cache.rb +57 -0
- data/lib/matcher/expression_labeler.rb +96 -0
- data/lib/matcher/expressions/array_expression.rb +45 -0
- data/lib/matcher/expressions/block.rb +189 -0
- data/lib/matcher/expressions/call.rb +307 -0
- data/lib/matcher/expressions/call_error.rb +45 -0
- data/lib/matcher/expressions/constant.rb +53 -0
- data/lib/matcher/expressions/expression.rb +237 -0
- data/lib/matcher/expressions/expression_walker.rb +77 -0
- data/lib/matcher/expressions/hash_expression.rb +59 -0
- data/lib/matcher/expressions/proc_expression.rb +96 -0
- data/lib/matcher/expressions/range_expression.rb +65 -0
- data/lib/matcher/expressions/recorder.rb +136 -0
- data/lib/matcher/expressions/rescue_last_error_expression.rb +49 -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 +87 -0
- data/lib/matcher/hash_stack.rb +52 -0
- data/lib/matcher/list.rb +102 -0
- data/lib/matcher/markers.rb +7 -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 +34 -0
- data/lib/matcher/matchers/any_matcher.rb +70 -0
- data/lib/matcher/matchers/array_matcher.rb +72 -0
- data/lib/matcher/matchers/block_matcher.rb +61 -0
- data/lib/matcher/matchers/boolean_matcher.rb +37 -0
- data/lib/matcher/matchers/dig_matcher.rb +149 -0
- data/lib/matcher/matchers/each_matcher.rb +85 -0
- data/lib/matcher/matchers/each_pair_matcher.rb +119 -0
- data/lib/matcher/matchers/equal_matcher.rb +198 -0
- data/lib/matcher/matchers/equal_set_matcher.rb +112 -0
- data/lib/matcher/matchers/expression_matcher.rb +69 -0
- data/lib/matcher/matchers/filter_matcher.rb +115 -0
- data/lib/matcher/matchers/hash_matcher.rb +315 -0
- data/lib/matcher/matchers/imply_matcher.rb +83 -0
- data/lib/matcher/matchers/imply_some_matcher.rb +116 -0
- data/lib/matcher/matchers/index_by_matcher.rb +177 -0
- data/lib/matcher/matchers/inline_matcher.rb +101 -0
- data/lib/matcher/matchers/keys_matcher.rb +131 -0
- data/lib/matcher/matchers/kind_of_matcher.rb +35 -0
- data/lib/matcher/matchers/lazy_all_matcher.rb +69 -0
- data/lib/matcher/matchers/lazy_any_matcher.rb +69 -0
- data/lib/matcher/matchers/let_matcher.rb +73 -0
- data/lib/matcher/matchers/map_matcher.rb +148 -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 +25 -0
- data/lib/matcher/matchers/negated_project_matcher.rb +31 -0
- data/lib/matcher/matchers/never_matcher.rb +35 -0
- data/lib/matcher/matchers/one_matcher.rb +68 -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 +101 -0
- data/lib/matcher/matchers/parse_iso8601_helper.rb +41 -0
- data/lib/matcher/matchers/parse_iso8601_matcher.rb +52 -0
- data/lib/matcher/matchers/parse_json_helper.rb +43 -0
- data/lib/matcher/matchers/parse_json_matcher.rb +59 -0
- data/lib/matcher/matchers/project_matcher.rb +72 -0
- data/lib/matcher/matchers/raises_matcher.rb +131 -0
- data/lib/matcher/matchers/range_matcher.rb +50 -0
- data/lib/matcher/matchers/reference_matcher.rb +213 -0
- data/lib/matcher/matchers/reference_matcher_collection.rb +57 -0
- data/lib/matcher/matchers/regexp_matcher.rb +86 -0
- data/lib/matcher/messages/expected_phrasing.rb +355 -0
- data/lib/matcher/messages/message.rb +104 -0
- data/lib/matcher/messages/message_builder.rb +35 -0
- data/lib/matcher/messages/message_rules.rb +240 -0
- data/lib/matcher/messages/namespaced_message_builder.rb +19 -0
- data/lib/matcher/messages/phrasing.rb +59 -0
- data/lib/matcher/messages/standard_message_builder.rb +105 -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 +62 -0
- data/lib/matcher/patterns/pattern.rb +104 -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 +103 -0
- data/lib/matcher/rules/message_factory.rb +26 -0
- data/lib/matcher/rules/message_rule.rb +18 -0
- data/lib/matcher/rules/message_rule_context.rb +26 -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 +514 -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 +107 -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 +346 -0
- 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,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
|