Class: Ruact::Testing::FlightStructureDiff
- Inherits:
-
Object
- Object
- Ruact::Testing::FlightStructureDiff
- 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 vianil == 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:rawbecause expected rows are authored as semantic descriptions, not byte-level snapshots. %i[id class payload].freeze
Class Method Summary collapse
- .build_field_diff(idx, actual, expected) ⇒ Object
-
.compute(actual_rows, expected_rows) ⇒ Object
Compute the set of differences between actual parsed rows and the expected structure.
-
.diff_imports_unordered(actual_pairs, expected_pairs) ⇒ Object
Imports are an unordered multiset within their class (AC1).
- .diff_in_array(expected, actual, path) ⇒ Object
- .diff_in_hash(expected, actual, path) ⇒ Object
- .diffs_for_length_mismatch(actual_rows, expected_rows) ⇒ Object
-
.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.
- .format_entry(diff) ⇒ Object
- .format_field_diff(diff) ⇒ Object
-
.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. - .format_single(diff) ⇒ Object
-
.row_matches?(row, predicate) ⇒ Boolean
Subset / case-equality match used by
include_flight_row. - .rows_equal?(actual, expected) ⇒ Boolean
-
.validate_expected_rows!(expected_rows) ⇒ Object
Validates each expected row carries the required
:id,:class,:payloadkeys. -
.validate_predicate!(predicate) ⇒ Object
Validates a predicate hash for
include_flight_row.
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.
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
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.
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 |