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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +114 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/ruact/controller.rb +9 -0
- data/lib/ruact/doctor.rb +67 -9
- data/lib/ruact/erb_preprocessor.rb +113 -0
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/manifest_resolver.rb +2 -2
- data/lib/ruact/serializable.rb +98 -6
- data/lib/ruact/server.rb +163 -0
- data/lib/ruact/server_functions/introspection.rb +81 -0
- data/lib/ruact/server_functions.rb +26 -4
- data/lib/ruact/testing/component_query.rb +113 -0
- data/lib/ruact/testing/flight_extractor.rb +221 -0
- data/lib/ruact/testing/flight_structure_diff.rb +267 -0
- data/lib/ruact/testing/flight_wire_parser.rb +138 -0
- data/lib/ruact/testing.rb +90 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/tasks/ruact.rake +55 -2
- data/spec/ruact/doctor_spec.rb +141 -0
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +285 -0
- data/spec/ruact/manifest_resolver_spec.rb +15 -0
- data/spec/ruact/serializable_spec.rb +126 -0
- data/spec/ruact/server_bucket_request_spec.rb +291 -0
- data/spec/ruact/server_functions/introspection_spec.rb +135 -0
- data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
- data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
- data/spec/ruact/testing/no_production_load_spec.rb +41 -0
- data/spec/support/flight_wire_parser.rb +12 -126
- data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
- 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
|