probatio_diabolica 0.3.2 → 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 +4 -4
- data/README.md +87 -6
- data/bin/prd +2 -2
- data/lib/pr_d/formatters/formatter.rb +351 -2
- data/lib/pr_d/formatters/html_formatter.rb +278 -108
- data/lib/pr_d/formatters/json_formatter.rb +34 -7
- data/lib/pr_d/formatters/pdf_formatter.rb +129 -45
- data/lib/pr_d/formatters/simple_formatter.rb +118 -49
- data/lib/pr_d/matchers/empty_matcher.rb +15 -0
- data/lib/pr_d/matchers/gt_matcher.rb +11 -0
- data/lib/pr_d/matchers/gte_matcher.rb +11 -0
- data/lib/pr_d/matchers/llm_matcher.rb +44 -18
- data/lib/pr_d/matchers/lt_matcher.rb +11 -0
- data/lib/pr_d/matchers/lte_matcher.rb +11 -0
- data/lib/pr_d/matchers/matcher.rb +2 -0
- data/lib/pr_d/version.rb +1 -1
- data/lib/pr_d.rb +301 -32
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 37a15e717233a9bbef0f7fcd533b7984081ea3e9aaee1e4effd1ecd10bd1a7f2
|
|
4
|
+
data.tar.gz: 026bfae9103c3a5f1747b8f0ef14a0ab859a46fda53340d7140645d71197c86e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,7 +169,22 @@ 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`)
|
|
163
188
|
|
|
164
189
|
### Spec best practices for `subject` (PRD reports)
|
|
165
190
|
|
|
@@ -201,10 +226,21 @@ end
|
|
|
201
226
|
|
|
202
227
|
- `eq(expected)` equality with `==`
|
|
203
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 (`<=`)
|
|
204
234
|
- `includes(expected)` inclusion for `String`, `Array`, `File`, `PDF::Reader`
|
|
205
235
|
- `have(expected)` alias inclusion via `include?`
|
|
206
236
|
- `all(proc)` checks all elements against a block
|
|
207
|
-
- `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
|
+
```
|
|
208
244
|
|
|
209
245
|
### Browser helpers (Ferrum)
|
|
210
246
|
|
|
@@ -218,6 +254,9 @@ end
|
|
|
218
254
|
- `pdf(at:, warmup_time:)` generates a PDF and returns a `PDF::Reader`
|
|
219
255
|
- `html(at:, warmup_time:)` returns HTML (`browser.body`)
|
|
220
256
|
|
|
257
|
+
Detailed dedicated documentation:
|
|
258
|
+
- `docs/chrome_helper.md` (full API contract, inputs/outputs, errors, and LLM-oriented usage patterns)
|
|
259
|
+
|
|
221
260
|
`BrowserSession` adds high-level page interactions:
|
|
222
261
|
|
|
223
262
|
- `find(css:/xpath:, wait:, shadow:)`
|
|
@@ -315,31 +354,73 @@ In CLI usage:
|
|
|
315
354
|
|
|
316
355
|
- selecting one formatter writes one output stream/file
|
|
317
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
|
|
318
359
|
|
|
319
|
-
###
|
|
360
|
+
### Let/subject rendering policy (best effort)
|
|
320
361
|
|
|
321
|
-
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:
|
|
322
363
|
|
|
323
364
|
- `SimpleFormatter`:
|
|
324
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
|
|
325
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
|
|
326
370
|
- `HtmlFormatter`:
|
|
371
|
+
- renders `let` values inside collapsed blocks (click to open)
|
|
327
372
|
- renders text values directly
|
|
373
|
+
- for `Hash`/`Array` subjects, renders nested key/value blocks for better readability
|
|
328
374
|
- for `PrD::Code`, renders syntax-highlighted code blocks (Rouge) inside collapsible sections
|
|
329
375
|
- for image files (`.png`, `.jpg`, `.jpeg`), embeds the image in the report
|
|
330
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
|
|
331
378
|
- `PdfFormatter`:
|
|
332
379
|
- renders text values as report lines
|
|
380
|
+
- for `Hash`/`Array` subjects, renders nested key/value lines
|
|
333
381
|
- for `PrD::Code`, renders language + syntax-highlighted code block (Rouge -> Prawn colors)
|
|
334
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
|
|
335
384
|
- `JsonFormatter`:
|
|
336
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
|
|
337
388
|
- for `PrD::Code`, emits a structured payload (`type: "code"`, `language`, `source`)
|
|
338
389
|
- `File` values (images, PDFs, text files, etc.) are embedded as base64 payloads
|
|
339
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`)
|
|
340
392
|
|
|
341
393
|
The goal is to preserve readability and report size while surfacing the richest representation each formatter can reasonably support.
|
|
342
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
|
+
|
|
343
424
|
## Useful references in this repository
|
|
344
425
|
|
|
345
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
|
|
|
@@ -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
|