128 lines
4.5 KiB
Ruby
128 lines
4.5 KiB
Ruby
# 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? { |el| Enumerable === el }
|
|
# 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
|