probatio_diabolica 0.3.2 → 0.4.1

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: 39336802d7f3c344f21acbf0567ea85a73dfbc68c2d339e7ab5e1e1866fc609e
4
- data.tar.gz: c4f0fa5209ac0a7437edf9e6cf117b95fe859217b49e172557f05c44dc664307
3
+ metadata.gz: 330f1282105f0f742f5655a83dbf8e885eecb7d48308cad74c79418b7212cf8e
4
+ data.tar.gz: d13b0e8d80d2480ee3603b8002f0666672e35bd1c4357caff0e77dbe071e8093
5
5
  SHA512:
6
- metadata.gz: f78a5ff31c1b5cbee275d596ef74529b0fad41426c4b7ee5303d98d4224cbcf20718485403ec048fc4f26159775c0ae8e33c07eeeb95e6944c2a21cf3feeec26
7
- data.tar.gz: ffa412e8bc62906e1c9be463a9d48dc9a30bbf4e4fcf458abc1e203026bde0a7e5f60ae0dce03c33cd6375c5402060b80ae1b1cf0b610328b1808a5eb436b8cf
6
+ metadata.gz: 825c6a20382d228a6a319c49dff0986a019af07d6eb098a9c541bdd785fde0f85ee82a54302dfc94d7eb8d58ccf672ee134ec4b4b7ce9590e43c0d88b2326d9c
7
+ data.tar.gz: cf20aabac6b541278ed6978171e9b59d2eda1649e560f6221144a985907d1274e67117ac470504c9afe123fbd12ab0940f634fd5a7382395b35189408304bdc4
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
- ### Subject rendering policy (best effort)
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