summaryrefslogtreecommitdiff
path: root/spec/support/import_export/project_tree_expectations.rb
blob: 2423a58a3e67b27bea671c9c86c2f1e45dae865e (plain)
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? { |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