ruact 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/docs/internal/decisions/server-functions-api.md +55 -0
  4. data/lib/generators/ruact/install/install_generator.rb +114 -1
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
  6. data/lib/ruact/controller.rb +9 -0
  7. data/lib/ruact/doctor.rb +67 -9
  8. data/lib/ruact/erb_preprocessor.rb +113 -0
  9. data/lib/ruact/errors.rb +16 -0
  10. data/lib/ruact/manifest_resolver.rb +2 -2
  11. data/lib/ruact/serializable.rb +98 -6
  12. data/lib/ruact/server.rb +163 -0
  13. data/lib/ruact/server_functions/introspection.rb +81 -0
  14. data/lib/ruact/server_functions.rb +26 -4
  15. data/lib/ruact/testing/component_query.rb +113 -0
  16. data/lib/ruact/testing/flight_extractor.rb +221 -0
  17. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  18. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  19. data/lib/ruact/testing.rb +90 -0
  20. data/lib/ruact/version.rb +1 -1
  21. data/lib/tasks/ruact.rake +55 -2
  22. data/spec/ruact/doctor_spec.rb +141 -0
  23. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  24. data/spec/ruact/install_generator_spec.rb +285 -0
  25. data/spec/ruact/manifest_resolver_spec.rb +15 -0
  26. data/spec/ruact/serializable_spec.rb +126 -0
  27. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  28. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  29. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  30. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  31. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  32. data/spec/support/flight_wire_parser.rb +12 -126
  33. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  34. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  35. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  36. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  37. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  38. metadata +14 -2
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "flight_wire_parser"
4
+
5
+ module Ruact
6
+ module Testing
7
+ # Resolves "was component <name> rendered, and with what props?" out of a
8
+ # decoded Flight tree. Pure — no RSpec, no I/O. The public
9
+ # `have_ruact_component` matcher is a thin RSpec wrapper over this.
10
+ #
11
+ # A component is TWO wire rows (Story 7.5 / FR108 semantics):
12
+ #
13
+ # * an `:import` row — `["<module path>", "<export name>", [chunks…]]` —
14
+ # sourced from the client manifest (`reference_for`); its `:id` is the
15
+ # integer the model rows reference; AND
16
+ # * one or more `:model` rows carrying a React element
17
+ # `["$", "$L<hex id>", key, {props}]` whose type references that import.
18
+ #
19
+ # `have_ruact_component("PostList")` matches the import row's DECLARED name
20
+ # (D3): its export name OR its module basename (`/PostList.jsx` → `PostList`)
21
+ # — honest to what the wire actually contains, not the ERB tag or a mapping
22
+ # the wire does not carry. Props are read from the model element's 4th slot
23
+ # in their SERIALIZED wire form (D4: string keys, serialized values).
24
+ class ComponentQuery
25
+ # A React element on the wire is a 4-tuple whose head is the sentinel "$".
26
+ ELEMENT_HEAD = "$"
27
+ # A client-component element's type is "$L" followed by the import's hex id.
28
+ CLIENT_REF = /\A\$L(?<hex>\h+)\z/
29
+
30
+ # @param wire [String] the raw Flight wire byte string.
31
+ def initialize(wire)
32
+ @rows = FlightWireParser.parse(wire)
33
+ @imports = index_imports(@rows)
34
+ @instances = collect_instances(@rows)
35
+ end
36
+
37
+ # @return [Boolean] whether any instance of `name` was rendered.
38
+ def rendered?(name)
39
+ !props_for(name).empty?
40
+ end
41
+
42
+ # @return [Array<Hash>] the serialized props hash of every rendered
43
+ # instance of `name`, in wire order. Empty when `name` was not rendered.
44
+ def props_for(name)
45
+ ids = import_ids_for(name)
46
+ return [] if ids.empty?
47
+
48
+ @instances.select { |inst| ids.include?(inst[:ref_id]) }.map { |inst| inst[:props] }
49
+ end
50
+
51
+ # @return [Array<String>] every distinct component name present in the
52
+ # wire (by declared export name), for a legible failure message.
53
+ def rendered_names
54
+ rendered_ids = @instances.map { |inst| inst[:ref_id] }.uniq
55
+ @imports.slice(*rendered_ids).map { |_, meta| meta[:export_name] }.uniq
56
+ end
57
+
58
+ private
59
+
60
+ def index_imports(rows)
61
+ rows.each_with_object({}) do |row, acc|
62
+ next unless row[:class] == :import
63
+
64
+ payload = row[:payload]
65
+ next unless payload.is_a?(Array)
66
+
67
+ module_path = payload[0]
68
+ export_name = payload[1]
69
+ acc[row[:id]] = {
70
+ module_path: module_path,
71
+ export_name: export_name,
72
+ basename: module_path.is_a?(String) ? File.basename(module_path, ".*") : nil
73
+ }
74
+ end
75
+ end
76
+
77
+ def import_ids_for(name)
78
+ @imports.select do |_id, meta|
79
+ meta[:export_name] == name || meta[:basename] == name
80
+ end.keys
81
+ end
82
+
83
+ # Walk every model row's payload, collecting each client-component element
84
+ # `["$", "$L<hex>", key, props]` as `{ ref_id:, props: }`.
85
+ def collect_instances(rows)
86
+ instances = []
87
+ rows.each do |row|
88
+ next unless row[:class] == :model
89
+
90
+ walk(row[:payload], instances)
91
+ end
92
+ instances
93
+ end
94
+
95
+ def walk(node, instances)
96
+ if element?(node)
97
+ ref = node[1].match(CLIENT_REF)
98
+ instances << { ref_id: ref[:hex].to_i(16), props: node[3] } if ref
99
+ # An element's props/children may themselves contain nested elements.
100
+ walk(node[3], instances)
101
+ elsif node.is_a?(Array)
102
+ node.each { |child| walk(child, instances) }
103
+ elsif node.is_a?(Hash)
104
+ node.each_value { |child| walk(child, instances) }
105
+ end
106
+ end
107
+
108
+ def element?(node)
109
+ node.is_a?(Array) && node.length >= 4 && node[0] == ELEMENT_HEAD && node[1].is_a?(String)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module Testing
5
+ # Raised by {FlightExtractor.extract} when the given response/String is not
6
+ # a Flight page render — most importantly when it is a plain-JSON
7
+ # function-call/query response. The message tells the spec author what they
8
+ # were handed and how to assert on it instead. Public: hosts may rescue it.
9
+ class NotAFlightResponseError < StandardError; end
10
+
11
+ # Pulls the Flight wire byte string out of whatever a host request spec
12
+ # hands the `have_ruact_component` matcher: an ActionDispatch/Rack response
13
+ # object (read via `.body`) or a raw String, in either of the two page
14
+ # shapes ruact emits —
15
+ #
16
+ # 1. a raw `text/x-component` body (RSC/navigation request), OR
17
+ # 2. a full HTML document embedding the payload in the `__FLIGHT_DATA`
18
+ # bootstrap `<script>` (a plain browser GET).
19
+ #
20
+ # A `Ruact::Server` function-call/query answer is plain JSON, not Flight —
21
+ # feeding it here raises {NotAFlightResponseError} pointing at `JSON.parse`,
22
+ # rather than silently failing to parse. Pure: no I/O, no global state.
23
+ module FlightExtractor
24
+ # Matches the `<local>.push("<ruby-inspected literal>")` line the
25
+ # `__FLIGHT_DATA` bootstrap script emits (see
26
+ # `Ruact::ViewHelper#ruact_flight_data_script`). ANCHORED to the queue
27
+ # assignment (`self.__FLIGHT_DATA = self.__FLIGHT_DATA || [])`) so an
28
+ # unrelated earlier script on the page (analytics `dataLayer.push(…)`,
29
+ # etc.) can never be mistaken for the Flight payload. The captured group
30
+ # is the full double-quoted Ruby string literal (quotes included),
31
+ # honoring escaped quotes so a `\"` inside the payload does not end it.
32
+ FLIGHT_DATA_PUSH = /
33
+ self\.__FLIGHT_DATA\s*=\s*self\.__FLIGHT_DATA\s*\|\|\s*\[\]\)\s*; # queue assignment
34
+ \s*[A-Za-z_$][\w$]*\.push\( # <local>.push(
35
+ (?<literal>"(?:\\.|[^"\\])*")
36
+ \)\s*;
37
+ /mx
38
+
39
+ # A Flight wire body always starts with a row header: `<hex>:` (id + colon)
40
+ # or a hint row `:H`. JSON bodies start with `{`/`[`/`"`; HTML with `<`.
41
+ FLIGHT_ROW_START = /\A\s*(?:\h+:|:H)/
42
+
43
+ # Reverses `String#inspect` on the captured `__FLIGHT_DATA` literal.
44
+ # `JSON.parse` is unsafe here: Ruby's inspect escapes sequences JSON
45
+ # rejects (`\#` before `{`/`$`/`@`, `\e`, `\a`, …). These are the common
46
+ # single-char escapes; an unrecognized `\c` yields the literal `c`.
47
+ SIMPLE_ESCAPES = {
48
+ "n" => "\n", "t" => "\t", "r" => "\r", "s" => " ", "0" => "\0",
49
+ "a" => "\a", "b" => "\b", "e" => "\e", "f" => "\f", "v" => "\v",
50
+ '"' => '"', "\\" => "\\"
51
+ }.freeze
52
+
53
+ class << self
54
+ # @param response_or_string [#body, String] a response object or a raw
55
+ # body String.
56
+ # @return [String] the extracted Flight wire byte string.
57
+ # @raise [NotAFlightResponseError] when the input is a JSON function-call
58
+ # response, an empty body, or otherwise not a Flight page render.
59
+ def extract(response_or_string)
60
+ body, content_type = read(response_or_string)
61
+ return extract_by_content_type(body, content_type) if content_type
62
+
63
+ extract_by_sniffing(body)
64
+ end
65
+
66
+ private
67
+
68
+ # Reads `[body, content_type]` from a response object or String. A bare
69
+ # String has no content type; a response object may expose one via
70
+ # `content_type`/`media_type` (rack-test, ActionDispatch) — read
71
+ # defensively so any duck-typed `.body` object still works.
72
+ def read(response_or_string)
73
+ return [response_or_string, nil] if response_or_string.is_a?(String)
74
+
75
+ unless response_or_string.respond_to?(:body)
76
+ raise NotAFlightResponseError,
77
+ "have_ruact_component expects a response object (responds to #body) or a String, " \
78
+ "got #{response_or_string.class}."
79
+ end
80
+
81
+ [response_or_string.body.to_s, content_type_of(response_or_string)]
82
+ end
83
+
84
+ def content_type_of(response)
85
+ %i[media_type content_type].each do |m|
86
+ next unless response.respond_to?(m)
87
+
88
+ value = response.public_send(m)
89
+ return value.to_s.split(";").first&.strip unless value.nil? || value.to_s.empty?
90
+ end
91
+ nil
92
+ rescue StandardError
93
+ nil
94
+ end
95
+
96
+ def extract_by_content_type(body, content_type)
97
+ case content_type
98
+ when "text/x-component"
99
+ ensure_present!(body)
100
+ body
101
+ when "text/html", "application/xhtml+xml"
102
+ from_html(body)
103
+ when "application/json"
104
+ raise_json_error(body)
105
+ else
106
+ # Unknown content type — fall back to sniffing the bytes.
107
+ extract_by_sniffing(body)
108
+ end
109
+ end
110
+
111
+ def extract_by_sniffing(body)
112
+ ensure_present!(body)
113
+
114
+ return from_html(body) if body.include?("__FLIGHT_DATA")
115
+ return body if body.match?(FLIGHT_ROW_START)
116
+ # An HTML document that rendered no component reaches `from_html`,
117
+ # which raises the actionable "no `__FLIGHT_DATA` payload" error.
118
+ return from_html(body) if html_body?(body)
119
+
120
+ raise_json_error(body) if json_body?(body)
121
+
122
+ raise NotAFlightResponseError,
123
+ "have_ruact_component could not find a Flight payload in the response. " \
124
+ "The body is neither a raw `text/x-component` Flight body nor an HTML shell " \
125
+ "embedding `__FLIGHT_DATA`. First bytes: #{body[0, 80].inspect}"
126
+ end
127
+
128
+ def from_html(html)
129
+ match = html.match(FLIGHT_DATA_PUSH)
130
+ unless match
131
+ raise NotAFlightResponseError,
132
+ "have_ruact_component received an HTML document with no `__FLIGHT_DATA` payload. " \
133
+ "This response did not render a ruact component (no Flight data was inlined)."
134
+ end
135
+
136
+ decode_ruby_string_literal(match[:literal])
137
+ end
138
+
139
+ def json_body?(body)
140
+ stripped = body.lstrip
141
+ stripped.start_with?("{", "[", "\"")
142
+ end
143
+
144
+ def html_body?(body)
145
+ stripped = body.lstrip
146
+ stripped.start_with?("<") && /<(?:!doctype\b|html\b|body\b|div\b|head\b)/i.match?(stripped)
147
+ end
148
+
149
+ def raise_json_error(body)
150
+ raise NotAFlightResponseError,
151
+ "have_ruact_component received a plain-JSON response, not a Flight page. " \
152
+ "`Ruact::Server` function-call and query responses answer JSON — assert on them with " \
153
+ "`JSON.parse(response.body)` and a status check, not `have_ruact_component`. " \
154
+ "First bytes: #{body[0, 80].inspect}"
155
+ end
156
+
157
+ def ensure_present!(body)
158
+ return unless body.nil? || body.empty?
159
+
160
+ raise NotAFlightResponseError,
161
+ "have_ruact_component received an empty response body — nothing was rendered. " \
162
+ "(A 204/no-content function-call response has no Flight payload.)"
163
+ end
164
+
165
+ def decode_ruby_string_literal(literal)
166
+ inner = literal[1..-2] # strip surrounding quotes
167
+ out = +""
168
+ pos = 0
169
+ len = inner.length
170
+ while pos < len
171
+ ch = inner[pos]
172
+ if ch == "\\" && pos + 1 < len
173
+ decoded, consumed = decode_escape(inner, pos + 1)
174
+ out << decoded
175
+ pos += 1 + consumed
176
+ else
177
+ out << ch
178
+ pos += 1
179
+ end
180
+ end
181
+ out
182
+ end
183
+
184
+ # Decodes the escape sequence that begins at `inner[pos]` (the char right
185
+ # after the backslash). Returns `[decoded_string, input_chars_consumed]`
186
+ # where `input_chars_consumed` counts only the chars AFTER the
187
+ # backslash, so the caller can advance its cursor precisely.
188
+ def decode_escape(inner, pos)
189
+ ch = inner[pos]
190
+ case ch
191
+ when "u" then decode_unicode(inner, pos)
192
+ when "x" then decode_hex(inner, pos)
193
+ else
194
+ [SIMPLE_ESCAPES.fetch(ch, ch), 1]
195
+ end
196
+ end
197
+
198
+ # `\u{XXXX}` (braced, possibly multiple code points) or `\uXXXX`.
199
+ def decode_unicode(inner, pos)
200
+ if inner[pos + 1] == "{"
201
+ close = inner.index("}", pos + 2) || (return ["u", 1])
202
+ hex = inner[(pos + 2)...close]
203
+ decoded = hex.split.map { |cp| cp.to_i(16) }.pack("U*")
204
+ [decoded, (close - pos) + 1]
205
+ else
206
+ hex = inner[(pos + 1), 4].to_s
207
+ [[hex.to_i(16)].pack("U"), 1 + hex.length]
208
+ end
209
+ end
210
+
211
+ # `\xHH` (one or two hex digits).
212
+ def decode_hex(inner, pos)
213
+ hex = inner[(pos + 1)..].to_s[/\A\h{1,2}/].to_s
214
+ return ["x", 1] if hex.empty?
215
+
216
+ [[hex.to_i(16)].pack("C").force_encoding(inner.encoding), 1 + hex.length]
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module Testing
5
+ # Pure structural diff + predicate-matching engine over parsed Flight rows
6
+ # (see {FlightWireParser}). No RSpec dependency — the RSpec matcher DSL that
7
+ # consumes it (the public `have_ruact_component` and the internal
8
+ # `match_flight_structure`/`include_flight_row`) lives elsewhere. Promoted
9
+ # from `spec/support` in Story 15.4 so the public helper and the internal
10
+ # matchers share ONE implementation (wrap, not fork).
11
+ # rubocop:disable Metrics/ClassLength -- single cohesive helper for matcher diffing/formatting; splitting would scatter related logic across files.
12
+ class FlightStructureDiff
13
+ # Keys the parser produces on every row. Predicates and expected rows
14
+ # may only reference these keys; unknown keys raise upfront so typos
15
+ # like `payloed:` don't silently match every row via `nil == nil`.
16
+ KNOWN_ROW_KEYS = %i[id class payload raw].freeze
17
+
18
+ # Keys that must be present in every expected-row hash passed to
19
+ # `match_flight_structure`. Excludes `:raw` because expected rows are
20
+ # authored as semantic descriptions, not byte-level snapshots.
21
+ REQUIRED_EXPECTED_KEYS = %i[id class payload].freeze
22
+
23
+ # Compute the set of differences between actual parsed rows and the
24
+ # expected structure. Import rows are matched as a multiset (their
25
+ # relative order among each other is not significant per AC1);
26
+ # everything else compares positionally.
27
+ def self.compute(actual_rows, expected_rows)
28
+ validate_expected_rows!(expected_rows)
29
+ return diffs_for_length_mismatch(actual_rows, expected_rows) if actual_rows.length != expected_rows.length
30
+
31
+ diffs = []
32
+ pending_actual_imports = []
33
+ pending_expected_imports = []
34
+
35
+ actual_rows.zip(expected_rows).each_with_index do |(actual, expected), i|
36
+ if actual[:class] == :import && expected[:class] == :import
37
+ pending_actual_imports << [i, actual]
38
+ pending_expected_imports << [i, expected]
39
+ next
40
+ end
41
+
42
+ next if rows_equal?(actual, expected)
43
+
44
+ diffs << build_field_diff(i, actual, expected)
45
+ end
46
+
47
+ diffs.concat(diff_imports_unordered(pending_actual_imports, pending_expected_imports))
48
+ diffs.sort_by { |d| d[:idx] }
49
+ end
50
+
51
+ # Imports are an unordered multiset within their class (AC1). Sort both
52
+ # sides by `:id` (always unique per render) and any leftover diffs come
53
+ # from semantic mismatch in the same-position-after-sort pair.
54
+ def self.diff_imports_unordered(actual_pairs, expected_pairs)
55
+ return [] if actual_pairs.empty? && expected_pairs.empty?
56
+
57
+ sort_key = ->(pair) { [pair[1][:id].to_i, pair[1][:payload].to_s] }
58
+ sorted_actual = actual_pairs.sort_by(&sort_key)
59
+ sorted_expected = expected_pairs.sort_by(&sort_key)
60
+
61
+ sorted_actual.zip(sorted_expected).filter_map do |(act_idx, act_row), (_exp_idx, exp_row)|
62
+ next if rows_equal?(act_row, exp_row)
63
+
64
+ build_field_diff(act_idx, act_row, exp_row)
65
+ end
66
+ end
67
+
68
+ def self.diffs_for_length_mismatch(actual_rows, expected_rows)
69
+ diffs = []
70
+ [actual_rows.length, expected_rows.length].max.times do |i|
71
+ actual = actual_rows[i]
72
+ expected = expected_rows[i]
73
+ if actual.nil?
74
+ diffs << { kind: :missing, idx: i, expected_row: expected }
75
+ elsif expected.nil?
76
+ diffs << { kind: :extra, idx: i, actual_row: actual }
77
+ elsif !rows_equal?(actual, expected)
78
+ diffs << build_field_diff(i, actual, expected)
79
+ end
80
+ end
81
+ diffs
82
+ end
83
+
84
+ def self.rows_equal?(actual, expected)
85
+ REQUIRED_EXPECTED_KEYS.all? { |k| actual[k] == expected[k] }
86
+ end
87
+
88
+ def self.build_field_diff(idx, actual, expected)
89
+ field_diff = nil
90
+ REQUIRED_EXPECTED_KEYS.each do |key|
91
+ next if actual[key] == expected[key]
92
+
93
+ path, sub_expected, sub_actual = first_difference(expected[key], actual[key], ".#{key}")
94
+ field_diff = { path: path, expected: sub_expected, got: sub_actual }
95
+ break
96
+ end
97
+
98
+ {
99
+ kind: :differs,
100
+ idx: idx,
101
+ row_class: actual[:class],
102
+ path: field_diff[:path],
103
+ expected: field_diff[:expected],
104
+ got: field_diff[:got],
105
+ expected_row: expected,
106
+ got_row: actual
107
+ }
108
+ end
109
+
110
+ # Walks parallel structures (Hash/Array/scalar) and returns the path,
111
+ # expected leaf, and actual leaf at the first differing position.
112
+ def self.first_difference(expected, actual, path)
113
+ return [path, expected, actual] if expected.class != actual.class
114
+ return [path, expected, actual] unless expected.is_a?(Array) || expected.is_a?(Hash)
115
+
116
+ return diff_in_array(expected, actual, path) if expected.is_a?(Array)
117
+
118
+ diff_in_hash(expected, actual, path)
119
+ end
120
+
121
+ def self.diff_in_array(expected, actual, path)
122
+ [expected.length, actual.length].max.times do |i|
123
+ return ["#{path}[#{i}]", expected[i], actual[i]] if i >= expected.length || i >= actual.length
124
+ next if expected[i] == actual[i]
125
+
126
+ return first_difference(expected[i], actual[i], "#{path}[#{i}]")
127
+ end
128
+ [path, expected, actual]
129
+ end
130
+
131
+ def self.diff_in_hash(expected, actual, path)
132
+ (expected.keys | actual.keys).each do |key|
133
+ return ["#{path}[#{key.inspect}]", expected[key], actual[key]] if !expected.key?(key) || !actual.key?(key)
134
+ next if expected[key] == actual[key]
135
+
136
+ return first_difference(expected[key], actual[key], "#{path}[#{key.inspect}]")
137
+ end
138
+ [path, expected, actual]
139
+ end
140
+
141
+ # Validates each expected row carries the required `:id`, `:class`,
142
+ # `:payload` keys. Without this, an expected row of `{ id: 0, class:
143
+ # :model }` would silently pass against any actual row whose payload
144
+ # was nil — because the `==` check reads `expected[:payload]` as nil.
145
+ def self.validate_expected_rows!(expected_rows)
146
+ expected_rows.each_with_index do |row, i|
147
+ unless row.is_a?(Hash)
148
+ raise ArgumentError,
149
+ "match_flight_structure: expected row #{i} must be a Hash, got #{row.class}: #{row.inspect}"
150
+ end
151
+
152
+ missing = REQUIRED_EXPECTED_KEYS.reject { |k| row.key?(k) }
153
+ next if missing.empty?
154
+
155
+ raise ArgumentError,
156
+ "match_flight_structure: expected row #{i} is missing required keys: #{missing.inspect}. " \
157
+ "Each expected row must include :id, :class, and :payload."
158
+ end
159
+ end
160
+
161
+ # Validates a predicate hash for `include_flight_row`. Unknown keys
162
+ # (typos like `payloed:` or `clas:`) raise immediately so they don't
163
+ # silently match any row via `row[:payloed]` returning nil.
164
+ def self.validate_predicate!(predicate)
165
+ unless predicate.is_a?(Hash)
166
+ raise ArgumentError,
167
+ "include_flight_row: predicate must be a Hash, got #{predicate.class}: #{predicate.inspect}"
168
+ end
169
+ raise ArgumentError, "include_flight_row: predicate cannot be empty" if predicate.empty?
170
+
171
+ unknown = predicate.keys - KNOWN_ROW_KEYS
172
+ return if unknown.empty?
173
+
174
+ raise ArgumentError,
175
+ "include_flight_row: predicate has unknown keys: #{unknown.inspect}. " \
176
+ "Allowed keys: #{KNOWN_ROW_KEYS.inspect}."
177
+ end
178
+
179
+ def self.format_single(diff)
180
+ case diff[:kind]
181
+ when :missing
182
+ row = diff[:expected_row]
183
+ "Expected row #{diff[:idx]} (#{row[:class]}) was not produced.\n expected: #{row.inspect}"
184
+ when :extra
185
+ row = diff[:actual_row]
186
+ "Got unexpected row #{diff[:idx]} (#{row[:class]}): #{row.inspect}"
187
+ else
188
+ format_field_diff(diff)
189
+ end
190
+ end
191
+
192
+ def self.format_field_diff(diff)
193
+ <<~MSG.strip
194
+ Expected Flight output to match structure.
195
+
196
+ Row #{diff[:idx]} (#{diff[:row_class]}) differs at #{diff[:path]}:
197
+ expected: #{diff[:expected].inspect}
198
+ got: #{diff[:got].inspect}
199
+
200
+ Row #{diff[:idx]} (#{diff[:row_class]}) full diff:
201
+ expected: #{diff[:expected_row][:payload].inspect}
202
+ got: #{diff[:got_row][:payload].inspect}
203
+ MSG
204
+ end
205
+
206
+ # Builds the multi-row failure message: header naming the diff count,
207
+ # AC3-specified wording for missing / extra / differing rows, and a
208
+ # `Row N (<class>): ✓` summary line for every matching row so the
209
+ # reader can see what passed.
210
+ def self.format_multi(diffs, actual_rows, expected_rows)
211
+ header = "Expected Flight output to match structure. #{diffs.length} rows differ:"
212
+ total = [actual_rows.length, expected_rows.length].max
213
+ diff_by_idx = diffs.to_h { |d| [d[:idx], d] }
214
+
215
+ body = (0...total).map do |i|
216
+ diff = diff_by_idx[i]
217
+ if diff
218
+ format_entry(diff)
219
+ else
220
+ row_class = (actual_rows[i] || expected_rows[i])[:class]
221
+ "Row #{i} (#{row_class}): ✓"
222
+ end
223
+ end
224
+
225
+ ([header, ""] + body).join("\n")
226
+ end
227
+
228
+ def self.format_entry(diff)
229
+ case diff[:kind]
230
+ when :missing
231
+ row = diff[:expected_row]
232
+ "Expected row #{diff[:idx]} (#{row[:class]}) was not produced.\n expected: #{row.inspect}"
233
+ when :extra
234
+ row = diff[:actual_row]
235
+ "Got unexpected row #{diff[:idx]} (#{row[:class]}): #{row.inspect}"
236
+ else
237
+ <<~ENTRY.strip
238
+ Row #{diff[:idx]} (#{diff[:row_class]}) differs at #{diff[:path]}:
239
+ expected: #{diff[:expected].inspect}
240
+ got: #{diff[:got].inspect}
241
+ ENTRY
242
+ end
243
+ end
244
+
245
+ # Subset / case-equality match used by `include_flight_row`. Plain
246
+ # values use `==`; richer matchers (`hash_including`, `array_including`,
247
+ # `kind_of`, regexes) use `===` which delegates to their custom logic.
248
+ # Predicate keys are validated upfront by `validate_predicate!`, so
249
+ # this method can assume every key is one of the known row keys.
250
+ def self.row_matches?(row, predicate)
251
+ predicate.all? do |key, expected_value|
252
+ actual_value = row[key]
253
+ case expected_value
254
+ when Symbol, Numeric, NilClass, TrueClass, FalseClass
255
+ expected_value == actual_value
256
+ else
257
+ # rubocop:disable Style/CaseEquality -- intentional: lets RSpec mock argument matchers
258
+ # (hash_including, array_including, kind_of, etc.) drive predicate semantics via #===.
259
+ expected_value === actual_value
260
+ # rubocop:enable Style/CaseEquality
261
+ end
262
+ end
263
+ end
264
+ end
265
+ # rubocop:enable Metrics/ClassLength
266
+ end
267
+ end