Class: Ruact::Testing::FlightStructureDiff

Inherits:
Object
  • Object
show all
Defined in:
lib/ruact/testing/flight_structure_diff.rb

Overview

Pure structural diff + predicate-matching engine over parsed Flight rows (see FlightWireParser). No RSpec dependency — the RSpec matcher DSL that consumes it (the public have_ruact_component and the internal match_flight_structure/include_flight_row) lives elsewhere. Promoted from spec/support in Story 15.4 so the public helper and the internal matchers share ONE implementation (wrap, not fork). rubocop:disable Metrics/ClassLength -- single cohesive helper for matcher diffing/formatting; splitting would scatter related logic across files.

Constant Summary collapse

KNOWN_ROW_KEYS =

Keys the parser produces on every row. Predicates and expected rows may only reference these keys; unknown keys raise upfront so typos like payloed: don't silently match every row via nil == nil.

%i[id class payload raw].freeze
REQUIRED_EXPECTED_KEYS =

Keys that must be present in every expected-row hash passed to match_flight_structure. Excludes :raw because expected rows are authored as semantic descriptions, not byte-level snapshots.

%i[id class payload].freeze

Class Method Summary collapse

Class Method Details

.build_field_diff(idx, actual, expected) ⇒ Object



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/ruact/testing/flight_structure_diff.rb', line 88

def self.build_field_diff(idx, actual, expected)
  field_diff = nil
  REQUIRED_EXPECTED_KEYS.each do |key|
    next if actual[key] == expected[key]

    path, sub_expected, sub_actual = first_difference(expected[key], actual[key], ".#{key}")
    field_diff = { path: path, expected: sub_expected, got: sub_actual }
    break
  end

  {
    kind: :differs,
    idx: idx,
    row_class: actual[:class],
    path: field_diff[:path],
    expected: field_diff[:expected],
    got: field_diff[:got],
    expected_row: expected,
    got_row: actual
  }
end

.compute(actual_rows, expected_rows) ⇒ Object

Compute the set of differences between actual parsed rows and the expected structure. Import rows are matched as a multiset (their relative order among each other is not significant per AC1); everything else compares positionally.



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/ruact/testing/flight_structure_diff.rb', line 27

def self.compute(actual_rows, expected_rows)
  validate_expected_rows!(expected_rows)
  return diffs_for_length_mismatch(actual_rows, expected_rows) if actual_rows.length != expected_rows.length

  diffs = []
  pending_actual_imports = []
  pending_expected_imports = []

  actual_rows.zip(expected_rows).each_with_index do |(actual, expected), i|
    if actual[:class] == :import && expected[:class] == :import
      pending_actual_imports << [i, actual]
      pending_expected_imports << [i, expected]
      next
    end

    next if rows_equal?(actual, expected)

    diffs << build_field_diff(i, actual, expected)
  end

  diffs.concat(diff_imports_unordered(pending_actual_imports, pending_expected_imports))
  diffs.sort_by { |d| d[:idx] }
end

.diff_imports_unordered(actual_pairs, expected_pairs) ⇒ Object

Imports are an unordered multiset within their class (AC1). Sort both sides by :id (always unique per render) and any leftover diffs come from semantic mismatch in the same-position-after-sort pair.



54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/ruact/testing/flight_structure_diff.rb', line 54

def self.diff_imports_unordered(actual_pairs, expected_pairs)
  return [] if actual_pairs.empty? && expected_pairs.empty?

  sort_key = ->(pair) { [pair[1][:id].to_i, pair[1][:payload].to_s] }
  sorted_actual = actual_pairs.sort_by(&sort_key)
  sorted_expected = expected_pairs.sort_by(&sort_key)

  sorted_actual.zip(sorted_expected).filter_map do |(act_idx, act_row), (_exp_idx, exp_row)|
    next if rows_equal?(act_row, exp_row)

    build_field_diff(act_idx, act_row, exp_row)
  end
end

.diff_in_array(expected, actual, path) ⇒ Object



121
122
123
124
125
126
127
128
129
# File 'lib/ruact/testing/flight_structure_diff.rb', line 121

def self.diff_in_array(expected, actual, path)
  [expected.length, actual.length].max.times do |i|
    return ["#{path}[#{i}]", expected[i], actual[i]] if i >= expected.length || i >= actual.length
    next if expected[i] == actual[i]

    return first_difference(expected[i], actual[i], "#{path}[#{i}]")
  end
  [path, expected, actual]
end

.diff_in_hash(expected, actual, path) ⇒ Object



131
132
133
134
135
136
137
138
139
# File 'lib/ruact/testing/flight_structure_diff.rb', line 131

def self.diff_in_hash(expected, actual, path)
  (expected.keys | actual.keys).each do |key|
    return ["#{path}[#{key.inspect}]", expected[key], actual[key]] if !expected.key?(key) || !actual.key?(key)
    next if expected[key] == actual[key]

    return first_difference(expected[key], actual[key], "#{path}[#{key.inspect}]")
  end
  [path, expected, actual]
end

.diffs_for_length_mismatch(actual_rows, expected_rows) ⇒ Object



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/ruact/testing/flight_structure_diff.rb', line 68

def self.diffs_for_length_mismatch(actual_rows, expected_rows)
  diffs = []
  [actual_rows.length, expected_rows.length].max.times do |i|
    actual = actual_rows[i]
    expected = expected_rows[i]
    if actual.nil?
      diffs << { kind: :missing, idx: i, expected_row: expected }
    elsif expected.nil?
      diffs << { kind: :extra, idx: i, actual_row: actual }
    elsif !rows_equal?(actual, expected)
      diffs << build_field_diff(i, actual, expected)
    end
  end
  diffs
end

.first_difference(expected, actual, path) ⇒ Object

Walks parallel structures (Hash/Array/scalar) and returns the path, expected leaf, and actual leaf at the first differing position.



112
113
114
115
116
117
118
119
# File 'lib/ruact/testing/flight_structure_diff.rb', line 112

def self.first_difference(expected, actual, path)
  return [path, expected, actual] if expected.class != actual.class
  return [path, expected, actual] unless expected.is_a?(Array) || expected.is_a?(Hash)

  return diff_in_array(expected, actual, path) if expected.is_a?(Array)

  diff_in_hash(expected, actual, path)
end

.format_entry(diff) ⇒ Object



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/ruact/testing/flight_structure_diff.rb', line 228

def self.format_entry(diff)
  case diff[:kind]
  when :missing
    row = diff[:expected_row]
    "Expected row #{diff[:idx]} (#{row[:class]}) was not produced.\n  expected: #{row.inspect}"
  when :extra
    row = diff[:actual_row]
    "Got unexpected row #{diff[:idx]} (#{row[:class]}): #{row.inspect}"
  else
    <<~ENTRY.strip
      Row #{diff[:idx]} (#{diff[:row_class]}) differs at #{diff[:path]}:
        expected: #{diff[:expected].inspect}
        got:      #{diff[:got].inspect}
    ENTRY
  end
end

.format_field_diff(diff) ⇒ Object



192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/ruact/testing/flight_structure_diff.rb', line 192

def self.format_field_diff(diff)
  <<~MSG.strip
    Expected Flight output to match structure.

    Row #{diff[:idx]} (#{diff[:row_class]}) differs at #{diff[:path]}:
      expected: #{diff[:expected].inspect}
      got:      #{diff[:got].inspect}

    Row #{diff[:idx]} (#{diff[:row_class]}) full diff:
      expected: #{diff[:expected_row][:payload].inspect}
      got:      #{diff[:got_row][:payload].inspect}
  MSG
end

.format_multi(diffs, actual_rows, expected_rows) ⇒ Object

Builds the multi-row failure message: header naming the diff count, AC3-specified wording for missing / extra / differing rows, and a Row N (<class>): ✓ summary line for every matching row so the reader can see what passed.



210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/ruact/testing/flight_structure_diff.rb', line 210

def self.format_multi(diffs, actual_rows, expected_rows)
  header = "Expected Flight output to match structure. #{diffs.length} rows differ:"
  total = [actual_rows.length, expected_rows.length].max
  diff_by_idx = diffs.to_h { |d| [d[:idx], d] }

  body = (0...total).map do |i|
    diff = diff_by_idx[i]
    if diff
      format_entry(diff)
    else
      row_class = (actual_rows[i] || expected_rows[i])[:class]
      "Row #{i} (#{row_class}): ✓"
    end
  end

  ([header, ""] + body).join("\n")
end

.format_single(diff) ⇒ Object



179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/ruact/testing/flight_structure_diff.rb', line 179

def self.format_single(diff)
  case diff[:kind]
  when :missing
    row = diff[:expected_row]
    "Expected row #{diff[:idx]} (#{row[:class]}) was not produced.\n  expected: #{row.inspect}"
  when :extra
    row = diff[:actual_row]
    "Got unexpected row #{diff[:idx]} (#{row[:class]}): #{row.inspect}"
  else
    format_field_diff(diff)
  end
end

.row_matches?(row, predicate) ⇒ Boolean

Subset / case-equality match used by include_flight_row. Plain values use ==; richer matchers (hash_including, array_including, kind_of, regexes) use === which delegates to their custom logic. Predicate keys are validated upfront by validate_predicate!, so this method can assume every key is one of the known row keys.

Returns:

  • (Boolean)


250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/ruact/testing/flight_structure_diff.rb', line 250

def self.row_matches?(row, predicate)
  predicate.all? do |key, expected_value|
    actual_value = row[key]
    case expected_value
    when Symbol, Numeric, NilClass, TrueClass, FalseClass
      expected_value == actual_value
    else
      # rubocop:disable Style/CaseEquality -- intentional: lets RSpec mock argument matchers
      # (hash_including, array_including, kind_of, etc.) drive predicate semantics via #===.
      expected_value === actual_value
      # rubocop:enable Style/CaseEquality
    end
  end
end

.rows_equal?(actual, expected) ⇒ Boolean

Returns:

  • (Boolean)


84
85
86
# File 'lib/ruact/testing/flight_structure_diff.rb', line 84

def self.rows_equal?(actual, expected)
  REQUIRED_EXPECTED_KEYS.all? { |k| actual[k] == expected[k] }
end

.validate_expected_rows!(expected_rows) ⇒ Object

Validates each expected row carries the required :id, :class, :payload keys. Without this, an expected row of { id: 0, class: :model } would silently pass against any actual row whose payload was nil — because the == check reads expected[:payload] as nil.



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/ruact/testing/flight_structure_diff.rb', line 145

def self.validate_expected_rows!(expected_rows)
  expected_rows.each_with_index do |row, i|
    unless row.is_a?(Hash)
      raise ArgumentError,
            "match_flight_structure: expected row #{i} must be a Hash, got #{row.class}: #{row.inspect}"
    end

    missing = REQUIRED_EXPECTED_KEYS.reject { |k| row.key?(k) }
    next if missing.empty?

    raise ArgumentError,
          "match_flight_structure: expected row #{i} is missing required keys: #{missing.inspect}. " \
          "Each expected row must include :id, :class, and :payload."
  end
end

.validate_predicate!(predicate) ⇒ Object

Validates a predicate hash for include_flight_row. Unknown keys (typos like payloed: or clas:) raise immediately so they don't silently match any row via row[:payloed] returning nil.

Raises:

  • (ArgumentError)


164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/ruact/testing/flight_structure_diff.rb', line 164

def self.validate_predicate!(predicate)
  unless predicate.is_a?(Hash)
    raise ArgumentError,
          "include_flight_row: predicate must be a Hash, got #{predicate.class}: #{predicate.inspect}"
  end
  raise ArgumentError, "include_flight_row: predicate cannot be empty" if predicate.empty?

  unknown = predicate.keys - KNOWN_ROW_KEYS
  return if unknown.empty?

  raise ArgumentError,
        "include_flight_row: predicate has unknown keys: #{unknown.inspect}. " \
        "Allowed keys: #{KNOWN_ROW_KEYS.inspect}."
end