1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128
|
# frozen_string_literal: true
module ImportExport
module ProjectTreeExpectations
def assert_relations_match(imported_hash, exported_hash)
normalized_imported_hash = normalize_elements(imported_hash)
normalized_exported_hash = normalize_elements(exported_hash)
# this is for sanity checking, to make sure we didn't accidentally pass the test
# because we essentially ignored everything
stats = {
hashes: 0,
arrays: {
direct: 0,
pairwise: 0,
fuzzy: 0
},
values: 0
}
failures = match_recursively(normalized_imported_hash, normalized_exported_hash, stats)
puts "Elements checked:\n#{stats.pretty_inspect}"
expect(failures).to be_empty, failures.join("\n\n")
end
private
def match_recursively(left_node, right_node, stats, location_stack = [], failures = [])
if Hash === left_node && Hash === right_node
match_hashes(left_node, right_node, stats, location_stack, failures)
elsif Array === left_node && Array === right_node
match_arrays(left_node, right_node, stats, location_stack, failures)
else
stats[:values] += 1
if left_node != right_node
failures << failure_message("Value mismatch", location_stack, left_node, right_node)
end
end
failures
end
def match_hashes(left_node, right_node, stats, location_stack, failures)
stats[:hashes] += 1
left_keys = left_node.keys.to_set
right_keys = right_node.keys.to_set
if left_keys != right_keys
failures << failure_message("Hash keys mismatch", location_stack, left_keys, right_keys)
end
left_node.keys.each do |key|
location_stack << key
match_recursively(left_node[key], right_node[key], stats, location_stack, failures)
location_stack.pop
end
end
def match_arrays(left_node, right_node, stats, location_stack, failures)
has_simple_elements = left_node.none?(Enumerable)
# for simple types, we can do a direct order-less set comparison
if has_simple_elements && left_node.to_set != right_node.to_set
stats[:arrays][:direct] += 1
failures << failure_message("Elements mismatch", location_stack, left_node, right_node)
# if both arrays have the same number of complex elements, we can compare pair-wise in-order
elsif left_node.size == right_node.size
stats[:arrays][:pairwise] += 1
left_node.zip(right_node).each do |left_entry, right_entry|
match_recursively(left_entry, right_entry, stats, location_stack, failures)
end
# otherwise we have to fall back to a best-effort match by probing into the right array;
# this means we will not account for elements that exist on the right, but not on the left
else
stats[:arrays][:fuzzy] += 1
left_node.each do |left_entry|
right_entry = right_node.find { |el| el == left_entry }
match_recursively(left_entry, right_entry, stats, location_stack, failures)
end
end
end
def failure_message(what, location_stack, left_value, right_value)
where =
if location_stack.empty?
"root"
else
location_stack.map { |loc| loc.to_sym.inspect }.join(' -> ')
end
">> [#{where}] #{what}\n\n#{left_value.pretty_inspect}\nNOT EQUAL TO\n\n#{right_value.pretty_inspect}"
end
# Helper that traverses a project tree and normalizes data that we know
# to vary in the process of importing (such as list order or row IDs)
def normalize_elements(elem)
case elem
when Hash
elem.to_h do |key, value|
if ignore_key?(key, value)
[key, :ignored]
else
[key, normalize_elements(value)]
end
end
when Array
elem.map { |a| normalize_elements(a) }
else
elem
end
end
# We currently need to ignore certain entries when checking for equivalence because
# we know them to change between imports/exports either by design or because of bugs;
# this helper filters out these problematic nodes.
def ignore_key?(key, value)
id?(key) || # IDs are known to be replaced during imports
key == 'updated_at' || # these get changed frequently during imports
key == 'next_run_at' || # these values change based on wall clock
key == 'notes' # the importer attaches an extra "by user XYZ" at the end of a note
end
def id?(key)
key == 'id' || key.ends_with?('_id')
end
end
end
|