probatio_diabolica 0.3.0 → 0.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 891d8275e2ccd96dea0d74c456a55693ab667fd556fedf9b5d1fe9470b0949cb
4
- data.tar.gz: d53faf24d75c1f112e5cb064048372d7ac7c5391f50ed3e546a5f7990cb7d02f
3
+ metadata.gz: 37a15e717233a9bbef0f7fcd533b7984081ea3e9aaee1e4effd1ecd10bd1a7f2
4
+ data.tar.gz: 026bfae9103c3a5f1747b8f0ef14a0ab859a46fda53340d7140645d71197c86e
5
5
  SHA512:
6
- metadata.gz: faafc8c84e60b7058d57ceb6d457502822724aea22325e3e0ccd4efb3e3765903f21f8525f0e4c9937bea8e69901d39e543796a776b5a1305922492955ce373e
7
- data.tar.gz: 4d9f2a94aee2e50112544aa6c0be783ee79f95ee0b4dd7ef95731590b45f335a4e2e61044a99bfa4d2b6125767c9f9b6f91ba2b7e8b70e275ab792b62b138be3
6
+ metadata.gz: 96c9fa6473dc6f5b94e9474fb5d4855c38c147c8872075945231c91fdf82cadd1280718da6010a76711efb80168cfbc281c4cfa5c4158cba37e3a9da88fbee6c
7
+ data.tar.gz: dc201873849d9e2e80bdff375c0e185cb1acfe8440b418170327ea8e416c29faaf9316f02f44913818ed6da8205e87aef12a0b106325be95d2e9e56bf582db06
data/README.md CHANGED
@@ -12,9 +12,10 @@ This project is experimental and not production-ready.
12
12
 
13
13
  `probatio_diabolica` runs `*_spec.rb` files through a custom runtime (`PrD::Runtime`) with an RSpec-like syntax:
14
14
 
15
- - `describe`, `context`, `it`, `pending`, `let`, `subject`
15
+ - `describe`, `context`, `it`, `pending`, `let`, `subject`, `subject!`
16
+ - `before`, `after`
16
17
  - `expect(...).to(...)` and `expect(...).not_to(...)`
17
- - standard matchers (`eq`, `be`, `includes`, `have`, `all`)
18
+ - standard matchers (`eq`, `be`, `empty`, `gt`, `gte`, `lt`, `lte`, `includes`, `have`, `all`)
18
19
  - LLM matcher `satisfy(...)` to validate natural-language conditions
19
20
 
20
21
  Tests are evaluated with `instance_eval` (not through RSpec).
@@ -141,14 +142,23 @@ It is inspired by RSpec but with a custom runtime and additional features.
141
142
  ```ruby
142
143
  describe 'My domain' do
143
144
  context 'my context' do
145
+ before do
146
+ @sum = 0
147
+ end
148
+
144
149
  let(:two) { 2 }
145
150
  let(:three) { 3 }
146
151
  subject { two + three }
147
152
 
148
153
  it 'runs an assertion' do
154
+ @sum += subject
149
155
  expect.to eq(5)
150
156
  end
151
157
 
158
+ after do
159
+ expect(@sum).to(eq(5))
160
+ end
161
+
152
162
  pending 'test to implement later'
153
163
  end
154
164
  end
@@ -159,28 +169,110 @@ end
159
169
  - `expect(actual).to matcher`
160
170
  - `expect(actual).not_to matcher`
161
171
  - `expect { |subject| ... }.to matcher`
162
- - `expect.to matcher` (uses `subject`)
172
+ - `expect.to matcher` (uses `subject` / `subject!`)
173
+ - In verbose formatter output, when `actual` or matcher `expected` come from a `let`, the expectation sentence uses the `let` name.
174
+ - A failed expectation reports a detailed message (with names/values) in `Justification`.
175
+
176
+ ### Hooks (`before` / `after`)
177
+
178
+ - `before { ... }` runs before each `it` in the current context and nested contexts
179
+ - `after { ... }` runs after each `it` in the current context and nested contexts
180
+ - nested order:
181
+ - `before`: outer to inner
182
+ - `after`: inner to outer
183
+
184
+ ### Subject (`subject` vs `subject!`)
185
+
186
+ - `subject { ... }`: lazy, evaluated on first access in each example, memoized for that example
187
+ - `subject! { ... }`: eager, evaluated before each example body (uses an implicit `before`)
188
+
189
+ ### Spec best practices for `subject` (PRD reports)
190
+
191
+ When a test defines a `subject`, PRD can surface it more clearly in generated reports.
192
+ For CLI and integration specs, prefer:
193
+
194
+ - grouping with explicit `context`
195
+ - one `subject` per context for the main action
196
+ - assertions written with `expect.to(...)` when the assertion targets `subject`
197
+
198
+ Example:
199
+
200
+ ```ruby
201
+ context 'when CLI receives an unknown formatter type' do
202
+ subject { Open3.capture3('bundle exec ruby bin/prd spec/self_hosted_spec.rb -t unknown') }
203
+
204
+ it 'fails fast on unknown formatter type in CLI' do
205
+ _stdout, stderr, status = subject
206
+
207
+ expect(status.success?).to(be(false))
208
+ expect(stderr).to(includes('Unsupported formatter type: unknown. Supported: simple, html, json, pdf'))
209
+ end
210
+ end
211
+ ```
212
+
213
+ For simple value checks, this pattern keeps specs concise:
214
+
215
+ ```ruby
216
+ context 'with strings' do
217
+ subject { 'probatio diabolica' }
218
+
219
+ it 'matches expected content' do
220
+ expect.to(includes('diabolica'))
221
+ end
222
+ end
223
+ ```
163
224
 
164
225
  ### Matchers
165
226
 
166
227
  - `eq(expected)` equality with `==`
167
228
  - `be(expected)` object identity (`equal?`)
229
+ - `empty` checks `empty?` on the actual value
230
+ - `gt(expected)` strictly greater than (`>`)
231
+ - `gte(expected)` greater than or equal (`>=`)
232
+ - `lt(expected)` strictly less than (`<`)
233
+ - `lte(expected)` less than or equal (`<=`)
168
234
  - `includes(expected)` inclusion for `String`, `Array`, `File`, `PDF::Reader`
169
235
  - `have(expected)` alias inclusion via `include?`
170
236
  - `all(proc)` checks all elements against a block
171
- - `satisfy(natural_language_condition)` LLM-based validation
237
+ - `satisfy(natural_language_condition)` LLM-based validation (supports text, single image `File`, or array of image files)
238
+
239
+ Example:
240
+
241
+ ```ruby
242
+ expect(new_image_size).to be gt(0)
243
+ ```
172
244
 
173
245
  ### Browser helpers (Ferrum)
174
246
 
175
247
  `PrD::Runtime` exposes helpers to test content loaded in Chrome:
176
248
 
249
+ - `page(at:, warmup_time:)` opens a page and returns a `BrowserSession`
177
250
  - `screen(at:, width:, height:, warmup_time:)` captures a PNG and returns a `File`
178
- - `text(at:, css:, warmup_time:)` extracts a CSS node into a `.txt` file and returns a `File`
251
+ - `text(at:, css:, warmup_time:)` extracts a CSS node and returns `PrD::Code` (language: `text`)
179
252
  - `network(at:, warmup_time:)` returns Ferrum network traffic
180
253
  - `network_urls(at:, warmup_time:)` returns traffic URLs
181
254
  - `pdf(at:, warmup_time:)` generates a PDF and returns a `PDF::Reader`
182
255
  - `html(at:, warmup_time:)` returns HTML (`browser.body`)
183
256
 
257
+ Detailed dedicated documentation:
258
+ - `docs/chrome_helper.md` (full API contract, inputs/outputs, errors, and LLM-oriented usage patterns)
259
+
260
+ `BrowserSession` adds high-level page interactions:
261
+
262
+ - `find(css:/xpath:, wait:, shadow:)`
263
+ - `exists?(css:/xpath:, wait:, shadow:)`
264
+ - `click(css:/xpath:, wait:, shadow:)`
265
+ - `fill(css:/xpath:, with:, clear:, blur:, wait:, shadow:)`
266
+ - `select_option(css:, value:/values:, by:, wait:, shadow:)`
267
+ - `set_files(css:, path:/paths:, wait:, shadow:)` (alias `upload_files`)
268
+ - `navigate(to:, warmup_time:)`
269
+
270
+ About `shadow:`:
271
+
272
+ - `shadow:` is an ordered CSS path used to narrow the scope before the target selector.
273
+ - Each step can be a shadow host or a regular container.
274
+ - If a step has `shadowRoot`, search continues inside it; otherwise search continues inside the matched node.
275
+
184
276
  Prerequisites:
185
277
 
186
278
  - Chrome/Chromium must be installed.
@@ -197,6 +289,22 @@ it 'checks dynamic content loaded in browser' do
197
289
  end
198
290
  ```
199
291
 
292
+ Form interaction and file upload example:
293
+
294
+ ```ruby
295
+ it 'uploads a file in a shadow-dom form' do
296
+ html(at: 'https://example.com/upload', warmup_time: 2) do |page|
297
+ page.click(css: 'button[data-open-upload]')
298
+ page.fill(css: 'input[name="title"]', with: 'Invoice')
299
+ page.set_files(
300
+ css: 'input[type="file"]',
301
+ shadow: ['vax-scanner', '[data-view="upload"]'],
302
+ path: 'examples/random_photo.png'
303
+ )
304
+ end
305
+ end
306
+ ```
307
+
200
308
  ### Source code helper (Prism)
201
309
 
202
310
  `source_code(...)` uses the `prism` gem to parse Ruby source and extract class/method code.
@@ -246,31 +354,73 @@ In CLI usage:
246
354
 
247
355
  - selecting one formatter writes one output stream/file
248
356
  - selecting multiple formatters runs tests once and writes one file per formatter
357
+ - expectation rendering is sentence-based across human-readable formatters (for example `Expect 321 to be equal to 321`) to reduce vertical noise
358
+ - HTML/PDF also emphasize keywords and actual/expected values for faster scanning
249
359
 
250
- ### Subject rendering policy (best effort)
360
+ ### Let/subject rendering policy (best effort)
251
361
 
252
- When you define a `subject`, each formatter tries to render it in the most useful way for its medium:
362
+ When you define a `let` or `subject`, each formatter tries to render it in the most useful way for its medium:
253
363
 
254
364
  - `SimpleFormatter`:
255
365
  - renders readable text in terminal
366
+ - prints `Let(:name)` blocks to make fixture values explicit
367
+ - for `Hash`/`Array` subjects, prints one key/index per line with nested indentation
256
368
  - for files, prints a textual representation (for example path, file preview for `.txt`)
369
+ - for `Ferrum::Node`, prints a readable summary (`Ferrum::Node <tag#id.class> text="..."`) instead of the Ruby object id
257
370
  - `HtmlFormatter`:
371
+ - renders `let` values inside collapsed blocks (click to open)
258
372
  - renders text values directly
259
- - for `PrD::Code`, renders syntax-highlighted code blocks (Rouge)
373
+ - for `Hash`/`Array` subjects, renders nested key/value blocks for better readability
374
+ - for `PrD::Code`, renders syntax-highlighted code blocks (Rouge) inside collapsible sections
260
375
  - for image files (`.png`, `.jpg`, `.jpeg`), embeds the image in the report
261
376
  - for PDF subjects (`File` `.pdf` or `PDF::Reader`), embeds the PDF with a `data:application/pdf;base64,...` URI
377
+ - for `Ferrum::Node`, renders the same readable summary with HTML escaping
262
378
  - `PdfFormatter`:
263
379
  - renders text values as report lines
264
- - for `PrD::Code`, renders language + code block (plain, without syntax colors)
380
+ - for `Hash`/`Array` subjects, renders nested key/value lines
381
+ - for `PrD::Code`, renders language + syntax-highlighted code block (Rouge -> Prawn colors)
265
382
  - for image files (`.png`, `.jpg`, `.jpeg`), inserts the image directly in the PDF report
383
+ - for `Ferrum::Node`, renders the same readable summary in the generated PDF text
266
384
  - `JsonFormatter`:
267
385
  - keeps a structured representation for machine processing
386
+ - includes `name` in `let` events when available
387
+ - preserves nested `Hash`/`Array` structure in event payloads
268
388
  - for `PrD::Code`, emits a structured payload (`type: "code"`, `language`, `source`)
269
389
  - `File` values (images, PDFs, text files, etc.) are embedded as base64 payloads
270
390
  - `PDF::Reader` values are also embedded as base64 (`application/pdf`)
391
+ - for `Ferrum::Node`, emits a structured payload (`type: "ferrum_node"`, `selector`, `text`, `html_preview`, `summary`)
271
392
 
272
393
  The goal is to preserve readability and report size while surfacing the richest representation each formatter can reasonably support.
273
394
 
395
+ Detailed dedicated documentation:
396
+ - `docs/let_subject_rendering.md` (rendering pipeline, adapter injection, formatter behavior, compatibility notes)
397
+
398
+ ### Inject custom display adapters
399
+
400
+ You can inject custom display logic for domain objects that are not natively handled:
401
+
402
+ ```ruby
403
+ formatter = PrD::Formatters::HtmlFormatter.new(
404
+ io: $stdout,
405
+ serializers: {},
406
+ display_adapters: {
407
+ MyDomainObject => lambda do |value|
408
+ {
409
+ title: value.name,
410
+ preview: PrD::Code.new(source: value.to_json, language: 'json')
411
+ }
412
+ end
413
+ }
414
+ )
415
+ ```
416
+
417
+ Rules:
418
+
419
+ - adapter key (`MyDomainObject` above) can be a class/module, a symbol (duck-typing via `respond_to?`), or a predicate proc
420
+ - adapter value must be callable
421
+ - adapter output can reuse native display types (`String`, `Hash`, `Array`, `PrD::Code`, `File`, `PDF::Reader`, etc.)
422
+ - adapters are applied before default formatter heuristics, so native rendering still applies to the transformed value
423
+
274
424
  ## Useful references in this repository
275
425
 
276
426
  - Basic example: `examples/basics_spec.rb`
data/bin/prd CHANGED
@@ -24,12 +24,12 @@ class CompositeFormatter
24
24
  @formatters = formatters
25
25
  end
26
26
 
27
- def method_missing(name, *args, &block)
27
+ def method_missing(name, *args, **kwargs, &block)
28
28
  handled = false
29
29
  @formatters.each do |formatter|
30
30
  next unless formatter.respond_to?(name)
31
31
 
32
- formatter.public_send(name, *args, &block)
32
+ formatter.public_send(name, *args, **kwargs, &block)
33
33
  handled = true
34
34
  end
35
35
 
data/lib/pr_d/code.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  module PrD
2
- class Code
2
+ class Code < SimpleDelegator
3
3
  attr_reader :source, :language
4
4
 
5
5
  def initialize(source:, language: 'ruby')
6
+ super(source.to_s)
6
7
  @source = source.to_s
7
8
  @language = language.to_s
8
9
  end
@@ -2,13 +2,16 @@ module PrD
2
2
  module Formatters
3
3
  class Formatter
4
4
  SUPPORTED_MODES = %i[verbose synthetic].freeze
5
+ MISSING_VALUE = Object.new
6
+ NO_EXPECTED_VALUE = Object.new
5
7
 
6
- def initialize(io: $stdout, serializers: {}, mode: :verbose)
8
+ def initialize(io: $stdout, serializers: {}, mode: :verbose, display_adapters: {})
7
9
  @io = io
8
10
  @serializers = serializers
9
11
  @level = 0
10
12
  @mode = normalize_mode(mode)
11
13
  @current_test_title = nil
14
+ @display_adapters = normalize_display_adapters(display_adapters)
12
15
  end
13
16
 
14
17
  def title(message)
@@ -43,11 +46,27 @@ module PrD
43
46
  raise NotImplementedError, "#{self.class} must implement #subject"
44
47
  end
45
48
 
49
+ def subject_display_strategy
50
+ :on_evaluation
51
+ end
52
+
53
+ def eager_subject_display_strategy
54
+ subject_display_strategy
55
+ end
56
+
57
+ def register_display_adapter(matcher, callable = nil, &block)
58
+ adapter = callable || block
59
+ raise ArgumentError, 'Display adapter must be callable.' unless adapter.respond_to?(:call)
60
+
61
+ @display_adapters << [matcher, adapter]
62
+ self
63
+ end
64
+
46
65
  def pending(description = nil)
47
66
  raise NotImplementedError, "#{self.class} must implement #pending"
48
67
  end
49
68
 
50
- def expect(expectation)
69
+ def expect(expectation, label: nil)
51
70
  raise NotImplementedError, "#{self.class} must implement #expect"
52
71
  end
53
72
 
@@ -92,8 +111,12 @@ module PrD
92
111
  end
93
112
 
94
113
  def serialize(value)
114
+ value = apply_display_adapter(value)
115
+ return 'nil' if value.nil?
116
+
95
117
  serializer = @serializers[value.class]
96
118
  return serializer.call(value) if serializer
119
+ return ferrum_node_summary(value) if ferrum_node?(value)
97
120
  return value.path if value.is_a?(File)
98
121
  return value.map { |v| serialize(v) } if value.is_a?(Array)
99
122
  return value.transform_values { |v| serialize(v) } if value.is_a?(Hash)
@@ -104,6 +127,332 @@ module PrD
104
127
  def code_object?(value)
105
128
  defined?(PrD::Code) && value.is_a?(PrD::Code)
106
129
  end
130
+
131
+ def ferrum_node?(value)
132
+ value.respond_to?(:class) && value.class.respond_to?(:name) && value.class.name == 'Ferrum::Node'
133
+ rescue StandardError
134
+ false
135
+ end
136
+
137
+ def ferrum_node_payload(node)
138
+ payload = ferrum_node_payload_from_js(node) || {}
139
+ payload[:tag] = payload[:tag].to_s.downcase.strip unless blank_text?(payload[:tag])
140
+ payload[:id] = payload[:id].to_s.strip unless blank_text?(payload[:id])
141
+ payload[:classes] = normalize_classes(payload[:classes])
142
+ payload[:text] = normalize_preview_text(payload[:text], max_length: 160)
143
+ payload[:html] = normalize_preview_text(payload[:html], max_length: 220)
144
+ payload[:description] = normalize_preview_text(payload[:description], max_length: 160)
145
+ payload
146
+ rescue StandardError
147
+ {}
148
+ end
149
+
150
+ def ferrum_node_summary(node)
151
+ payload = ferrum_node_payload(node)
152
+ selector = ferrum_node_selector(payload)
153
+ summary = +'Ferrum::Node'
154
+ summary << " <#{selector}>" unless selector.nil?
155
+
156
+ if blank_text?(payload[:text]) && !blank_text?(payload[:description])
157
+ summary << " #{payload[:description]}"
158
+ return summary
159
+ end
160
+
161
+ summary << %( text="#{payload[:text]}") unless blank_text?(payload[:text])
162
+ summary
163
+ rescue StandardError
164
+ 'Ferrum::Node'
165
+ end
166
+
167
+ def ferrum_node_selector(payload)
168
+ return nil unless payload.is_a?(Hash)
169
+
170
+ selector = +''
171
+ selector << payload[:tag].to_s unless blank_text?(payload[:tag])
172
+ selector << "##{payload[:id]}" unless blank_text?(payload[:id])
173
+ Array(payload[:classes]).each do |class_name|
174
+ class_name_text = class_name.to_s.strip
175
+ next if class_name_text.empty?
176
+
177
+ selector << ".#{class_name_text}"
178
+ end
179
+
180
+ return nil if selector.empty?
181
+
182
+ selector
183
+ end
184
+
185
+ def ferrum_node_payload_from_js(node)
186
+ return nil unless node.respond_to?(:evaluate)
187
+
188
+ raw_payload = node.evaluate(<<~JS)
189
+ (() => {
190
+ const element = this;
191
+ if (!element) return null;
192
+
193
+ const rawClassName = element.className;
194
+ const className =
195
+ typeof rawClassName === "string" ? rawClassName :
196
+ (rawClassName && typeof rawClassName.baseVal === "string" ? rawClassName.baseVal : "");
197
+
198
+ const classes = className
199
+ .split(/\\s+/)
200
+ .map((token) => token.trim())
201
+ .filter((token) => token.length > 0);
202
+
203
+ const textValue = (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim();
204
+ const htmlValue = element.outerHTML || "";
205
+
206
+ return {
207
+ tag: element.tagName ? element.tagName.toLowerCase() : null,
208
+ id: element.id || null,
209
+ classes,
210
+ text: textValue.length > 160 ? `${textValue.slice(0, 157)}...` : textValue,
211
+ html: htmlValue.length > 220 ? `${htmlValue.slice(0, 217)}...` : htmlValue
212
+ };
213
+ })()
214
+ JS
215
+ return nil unless raw_payload.is_a?(Hash)
216
+
217
+ {
218
+ tag: raw_payload['tag'] || raw_payload[:tag],
219
+ id: raw_payload['id'] || raw_payload[:id],
220
+ classes: raw_payload['classes'] || raw_payload[:classes],
221
+ text: raw_payload['text'] || raw_payload[:text],
222
+ html: raw_payload['html'] || raw_payload[:html]
223
+ }
224
+ rescue StandardError
225
+ {
226
+ tag: safe_node_call(node, :tag_name),
227
+ text: safe_node_call(node, :text),
228
+ description: safe_node_call(node, :description)
229
+ }
230
+ end
231
+
232
+ def safe_node_call(node, method_name)
233
+ return nil unless node.respond_to?(method_name)
234
+
235
+ node.public_send(method_name)
236
+ rescue StandardError
237
+ nil
238
+ end
239
+
240
+ def normalize_classes(value)
241
+ Array(value)
242
+ .flat_map { |entry| entry.to_s.split(/\s+/) }
243
+ .map(&:strip)
244
+ .reject(&:empty?)
245
+ .uniq
246
+ end
247
+
248
+ def normalize_preview_text(value, max_length:)
249
+ text = value.to_s.gsub(/\s+/, ' ').strip
250
+ return nil if text.empty?
251
+ return text if text.length <= max_length
252
+
253
+ "#{text[0, max_length - 3]}..."
254
+ end
255
+
256
+ def blank_text?(value)
257
+ value.nil? || value.to_s.strip.empty?
258
+ end
259
+
260
+ def named_value_arguments(name_or_value, value)
261
+ return [nil, name_or_value] if value.equal?(MISSING_VALUE)
262
+
263
+ [name_or_value, value]
264
+ end
265
+
266
+ def display_node(value, seen: {})
267
+ resolved = apply_display_adapter(value)
268
+
269
+ if code_object?(resolved)
270
+ return {
271
+ type: :code,
272
+ language: resolved.language.to_s,
273
+ source: resolved.source.to_s
274
+ }
275
+ end
276
+
277
+ image_path = display_image_path(resolved)
278
+ return { type: :image, path: image_path } unless image_path.nil?
279
+
280
+ pdf_path = display_pdf_path(resolved)
281
+ return { type: :pdf_file, path: pdf_path } unless pdf_path.nil?
282
+
283
+ if display_pdf_reader?(resolved)
284
+ return { type: :pdf_reader, reader: resolved }
285
+ end
286
+
287
+ if resolved.is_a?(Hash)
288
+ return { type: :text, text: '[Circular reference]' } if circular_reference?(resolved, seen)
289
+
290
+ begin
291
+ entries = resolved.map do |key, entry_value|
292
+ { label: serialize(key).to_s, value: display_node(entry_value, seen: seen) }
293
+ end
294
+ ensure
295
+ seen.delete(resolved.object_id)
296
+ end
297
+
298
+ return { type: :map, entries: entries }
299
+ end
300
+
301
+ if resolved.is_a?(Array)
302
+ return { type: :text, text: '[Circular reference]' } if circular_reference?(resolved, seen)
303
+
304
+ begin
305
+ items = resolved.map { |entry| display_node(entry, seen: seen) }
306
+ ensure
307
+ seen.delete(resolved.object_id)
308
+ end
309
+
310
+ return { type: :list, items: items }
311
+ end
312
+
313
+ { type: :text, text: serialize(resolved).to_s }
314
+ end
315
+
316
+ def display_file_path(value)
317
+ return value.path if value.is_a?(File)
318
+ return nil unless value.respond_to?(:path)
319
+
320
+ path = value.path
321
+ path.is_a?(String) && !path.empty? ? path : nil
322
+ rescue StandardError
323
+ nil
324
+ end
325
+
326
+ def display_image_path(value)
327
+ path = display_file_path(value)
328
+ return nil if path.nil?
329
+ return nil unless path.match?(/\.(png|jpe?g)\z/i)
330
+
331
+ path
332
+ end
333
+
334
+ def display_pdf_path(value)
335
+ path = display_file_path(value)
336
+ return nil if path.nil?
337
+ return nil unless path.match?(/\.pdf\z/i)
338
+
339
+ path
340
+ end
341
+
342
+ def display_pdf_reader?(value)
343
+ defined?(PDF::Reader) && value.is_a?(PDF::Reader)
344
+ end
345
+
346
+ def apply_display_adapter(value)
347
+ adapter = find_display_adapter(value)
348
+ return value if adapter.nil?
349
+
350
+ if adapter.arity.abs >= 2
351
+ adapter.call(value, self)
352
+ else
353
+ adapter.call(value)
354
+ end
355
+ end
356
+
357
+ def find_display_adapter(value)
358
+ @display_adapters.each do |matcher, adapter|
359
+ return adapter if display_matcher_matches?(matcher, value)
360
+ end
361
+
362
+ nil
363
+ end
364
+
365
+ def display_matcher_matches?(matcher, value)
366
+ case matcher
367
+ when Symbol
368
+ value.respond_to?(matcher)
369
+ when Proc
370
+ matcher.call(value)
371
+ else
372
+ matcher === value
373
+ end
374
+ rescue StandardError
375
+ false
376
+ end
377
+
378
+ def normalize_display_adapters(display_adapters)
379
+ return [] if display_adapters.nil?
380
+
381
+ entries =
382
+ if display_adapters.is_a?(Hash)
383
+ display_adapters.to_a
384
+ elsif display_adapters.is_a?(Array)
385
+ display_adapters
386
+ else
387
+ raise ArgumentError, '`display_adapters` must be a Hash or an Array of [matcher, adapter].'
388
+ end
389
+
390
+ entries.map do |entry|
391
+ matcher, adapter =
392
+ if entry.is_a?(Array) && entry.length == 2
393
+ entry
394
+ else
395
+ raise ArgumentError, '`display_adapters` entries must be [matcher, adapter].'
396
+ end
397
+
398
+ raise ArgumentError, 'Display adapter matcher is required.' if matcher.nil?
399
+ raise ArgumentError, 'Display adapter must be callable.' unless adapter.respond_to?(:call)
400
+
401
+ [matcher, adapter]
402
+ end
403
+ end
404
+
405
+ def circular_reference?(value, seen)
406
+ object_id = value.object_id
407
+ return true if seen.key?(object_id)
408
+
409
+ seen[object_id] = true
410
+ false
411
+ end
412
+
413
+ def matcher_sentence_parts(matcher, sources:)
414
+ case matcher
415
+ when Matchers::EqMatcher
416
+ ['be equal to', matcher.expected]
417
+ when Matchers::BeMatcher
418
+ ['be the same object as', matcher.expected]
419
+ when Matchers::EmptyMatcher
420
+ ['be empty', NO_EXPECTED_VALUE]
421
+ when Matchers::GtMatcher
422
+ ['be greater than', matcher.expected]
423
+ when Matchers::GteMatcher
424
+ ['be greater than or equal to', matcher.expected]
425
+ when Matchers::IncludesMatcher
426
+ ['include', matcher.expected]
427
+ when Matchers::HaveMatcher
428
+ ['have', matcher.expected]
429
+ when Matchers::LtMatcher
430
+ ['be less than', matcher.expected]
431
+ when Matchers::LlmMatcher
432
+ ['satisfy condition', matcher.expected]
433
+ when Matchers::LteMatcher
434
+ ['be less than or equal to', matcher.expected]
435
+ when Matchers::AllMatcher
436
+ if sources
437
+ code_line = matcher.expected.source_location.last.to_i
438
+ code = sources.lines[code_line - 1]
439
+ expected = code&.strip
440
+ if expected.nil? || expected.empty?
441
+ ['all match the given condition', NO_EXPECTED_VALUE]
442
+ else
443
+ ['all match condition', expected]
444
+ end
445
+ else
446
+ ['all match the given condition', NO_EXPECTED_VALUE]
447
+ end
448
+ else
449
+ ['match', matcher.class.to_s]
450
+ end
451
+ end
452
+
453
+ def expectation_operator_text(operator)
454
+ operator == :not_to ? 'not to' : 'to'
455
+ end
107
456
  end
108
457
  end
109
458
  end