constable-rails 0.1.0 → 1.1.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.
@@ -33,7 +33,7 @@ module Constable
33
33
  not_found: 404, method_not_allowed: 405, not_acceptable: 406,
34
34
  request_timeout: 408, conflict: 409, gone: 410, precondition_failed: 412,
35
35
  payload_too_large: 413, unsupported_media_type: 415, im_a_teapot: 418,
36
- unprocessable_entity: 422, locked: 423, too_many_requests: 429,
36
+ unprocessable_entity: 422, unprocessable_content: 422, locked: 423, too_many_requests: 429,
37
37
  internal_server_error: 500, not_implemented: 501, bad_gateway: 502,
38
38
  service_unavailable: 503, gateway_timeout: 504
39
39
  }.freeze
@@ -44,6 +44,22 @@ module Constable
44
44
  error: (500..599), server_error: (500..599)
45
45
  }.freeze
46
46
 
47
+ # Rack renames statuses -- 422 became :unprocessable_content in Rack 3.1, and Rails
48
+ # 8.1 deprecates the old spelling -- so the table above is a floor, not the whole
49
+ # truth. Anything Rack knows is accepted, which means a rename costs no release here.
50
+ def self.rack_status_codes
51
+ return @rack_status_codes if defined?(@rack_status_codes)
52
+
53
+ @rack_status_codes =
54
+ if defined?(::Rack::Utils::SYMBOL_TO_STATUS_CODE)
55
+ ::Rack::Utils::SYMBOL_TO_STATUS_CODE.transform_keys(&:to_sym)
56
+ else
57
+ {}
58
+ end
59
+ rescue StandardError
60
+ @rack_status_codes = {}
61
+ end
62
+
47
63
  MAX_INSPECT = 200
48
64
  MAX_BODY = 800
49
65
 
@@ -222,7 +238,9 @@ module Constable
222
238
  case expected
223
239
  when Integer then expected
224
240
  when /\A\d+\z/ then expected.to_i
225
- else HTTP_STATUS_CODES[expected.to_s.to_sym]
241
+ else
242
+ name = expected.to_s.to_sym
243
+ HTTP_STATUS_CODES[name] || Matchers.rack_status_codes[name]
226
244
  end
227
245
  end
228
246
 
@@ -345,6 +363,104 @@ module Constable
345
363
  end
346
364
  end
347
365
 
366
+ # `be_within(0.5).of(10)` -- a matcher spelled across two calls, so it has to survive
367
+ # the first one and collect its subject on the second.
368
+ #
369
+ # Registering it matters for a second reason: without an entry, `be_within` fell
370
+ # through to the be_*/have_* predicate fallback, which happily built a
371
+ # PredicateDeferred and then blew up on `.of` with a NoMethodError naming an internal
372
+ # class rather than the matcher the author actually wrote.
373
+ class WithinDeferred < Deferred
374
+ def of(expected)
375
+ @expected = expected
376
+ @expected_set = true
377
+ self
378
+ end
379
+
380
+ def matches?(actual)
381
+ unless @expected_set
382
+ return [false, "be_within(#{@args.first.inspect}) is incomplete -- it needs .of: " \
383
+ "attest(value).to be_within(0.5).of(10)", nil]
384
+ end
385
+
386
+ delta = @args.first
387
+ difference = (actual - @expected).abs
388
+ return true if difference <= delta
389
+
390
+ [false, "expected #{Matchers.describe(actual)} to be within #{delta.inspect} of " \
391
+ "#{Matchers.describe(@expected)}, but it differed by #{difference}", nil]
392
+ rescue NoMethodError, TypeError, ArgumentError
393
+ [false, "expected #{Matchers.describe(actual)} to be within #{delta.inspect} of " \
394
+ "#{Matchers.describe(@expected)}, but a #{actual.class} cannot be subtracted", nil]
395
+ end
396
+
397
+ def description
398
+ return "be within #{@args.first.inspect} of #{Matchers.describe(@expected)}" if @expected_set
399
+
400
+ "be within #{@args.first.inspect} of (nothing -- .of was never called)"
401
+ end
402
+ end
403
+
404
+ # `be`, in its three RSpec spellings:
405
+ #
406
+ # attest(x).to be(other) identity -- the same object, not merely equal
407
+ # attest(x).to be >= 0 an operator comparison
408
+ # attest(x).to be truthiness
409
+ #
410
+ # `==` is deliberately not among the operators. Defining it on a matcher object
411
+ # breaks equality everywhere the object is compared, and `eq` already says it.
412
+ class BeDeferred < Deferred
413
+ COMPARISONS = %i[< <= > >=].freeze
414
+
415
+ COMPARISONS.each do |operator|
416
+ define_method(operator) do |operand|
417
+ @operator = operator
418
+ @operand = operand
419
+ self
420
+ end
421
+ end
422
+
423
+ def matches?(actual)
424
+ return compare(actual) if @operator
425
+ # `.empty?`, not `.any?`: `[nil].any?` is false, which would send `be(nil)` down
426
+ # the truthiness branch and assert the opposite of what was written.
427
+ return identity(actual) unless @args.empty?
428
+ return true if actual
429
+
430
+ [false, "expected a truthy value, but got #{actual.inspect}", nil]
431
+ end
432
+
433
+ def description
434
+ return "be #{@operator} #{Matchers.describe(@operand)}" if @operator
435
+ return "be #{Matchers.describe(@args.first)}" unless @args.empty?
436
+
437
+ "be truthy"
438
+ end
439
+
440
+ private
441
+
442
+ def compare(actual)
443
+ return true if actual.public_send(@operator, @operand)
444
+
445
+ [false, "expected #{Matchers.describe(actual)} to be #{@operator} " \
446
+ "#{Matchers.describe(@operand)}", nil]
447
+ rescue NoMethodError, ArgumentError, TypeError
448
+ [false, "expected #{Matchers.describe(actual)} to be #{@operator} " \
449
+ "#{Matchers.describe(@operand)}, but a #{actual.class} cannot be compared", nil]
450
+ end
451
+
452
+ # `be` is identity, not equality -- that distinction is the only reason to reach for
453
+ # it over `eq`, so the failure message says which one failed.
454
+ def identity(actual)
455
+ expected = @args.first
456
+ return true if actual.equal?(expected)
457
+
458
+ hint = actual == expected ? " (they are equal, but not the same object)" : ""
459
+ [false, "expected #{Matchers.describe(actual)} to be the same object as " \
460
+ "#{Matchers.describe(expected)}#{hint}", nil]
461
+ end
462
+ end
463
+
348
464
  # The be_*/have_* fallback: with no matcher registered under the name, the name itself
349
465
  # is the assertion -- `be_created` asks the actual whether it is `created?`.
350
466
  class PredicateDeferred < Deferred
@@ -795,6 +911,94 @@ module Constable
795
911
  raise Constable::Error, "change is only usable through attest { ... }.to change { ... }"
796
912
  end
797
913
 
914
+ # Order-independent collection equality. `modernize` converts
915
+ # `expect(x).to contain_exactly(a, b)` verbatim, so not having it turned every
916
+ # converted spec that used it into a NoMethodError.
917
+ define_builtin(:contain_exactly) do |actual, *expected|
918
+ unless actual.respond_to?(:to_a)
919
+ next [false, "expected #{Matchers.describe(actual)} to be a collection, " \
920
+ "but a #{actual.class} does not respond to #to_a", nil]
921
+ end
922
+
923
+ items = actual.to_a
924
+ missing = expected.dup
925
+ extra = []
926
+ items.each do |item|
927
+ index = missing.index { |candidate| candidate == item }
928
+ index ? missing.delete_at(index) : extra << item
929
+ end
930
+ next true if missing.empty? && extra.empty?
931
+
932
+ parts = []
933
+ parts << "missing #{Matchers.describe(missing)}" unless missing.empty?
934
+ parts << "unexpected #{Matchers.describe(extra)}" unless extra.empty?
935
+ [false, "expected the collection to contain exactly #{expected.size} " \
936
+ "#{expected.size == 1 ? "item" : "items"}: #{parts.join(", ")}",
937
+ { "Actual" => Matchers.describe(items) }]
938
+ end
939
+ # Not an alias: RSpec's match_array takes one array where contain_exactly takes
940
+ # varargs, so aliasing them makes match_array([1, 2]) assert that the collection
941
+ # holds a single element which is itself the array [1, 2].
942
+ define_builtin(:match_array) do |actual, expected|
943
+ unless expected.respond_to?(:to_a)
944
+ next [false, "match_array takes an array: attest(list).to match_array([1, 2])", nil]
945
+ end
946
+
947
+ Matchers.matcher_for(:contain_exactly).block.call(actual, *expected.to_a)
948
+ end
949
+
950
+ define_builtin(:start_with) do |actual, prefix|
951
+ unless actual.respond_to?(:start_with?) || actual.respond_to?(:first)
952
+ next [false, "expected #{Matchers.describe(actual)} to start with " \
953
+ "#{Matchers.describe(prefix)}, but a #{actual.class} cannot say", nil]
954
+ end
955
+ passed = if actual.respond_to?(:start_with?)
956
+ actual.start_with?(prefix)
957
+ else
958
+ actual.first(Array(prefix).size) == Array(prefix)
959
+ end
960
+ next true if passed
961
+
962
+ [false, "expected #{Matchers.describe(actual)} to start with #{Matchers.describe(prefix)}", nil]
963
+ end
964
+
965
+ define_builtin(:end_with) do |actual, suffix|
966
+ unless actual.respond_to?(:end_with?) || actual.respond_to?(:last)
967
+ next [false, "expected #{Matchers.describe(actual)} to end with " \
968
+ "#{Matchers.describe(suffix)}, but a #{actual.class} cannot say", nil]
969
+ end
970
+ passed = if actual.respond_to?(:end_with?)
971
+ actual.end_with?(suffix)
972
+ else
973
+ actual.last(Array(suffix).size) == Array(suffix)
974
+ end
975
+ next true if passed
976
+
977
+ [false, "expected #{Matchers.describe(actual)} to end with #{Matchers.describe(suffix)}", nil]
978
+ end
979
+
980
+ define_builtin(:be_between) do |actual, low, high|
981
+ next true if actual.between?(low, high)
982
+
983
+ [false, "expected #{Matchers.describe(actual)} to be between " \
984
+ "#{Matchers.describe(low)} and #{Matchers.describe(high)}", nil]
985
+ end
986
+
987
+ define_builtin(:satisfy) do |actual, &block|
988
+ next [false, "satisfy needs a block: attest(x).to satisfy { |value| ... }", nil] unless block
989
+ next true if block.call(actual)
990
+
991
+ [false, "expected #{Matchers.describe(actual)} to satisfy the block", nil]
992
+ end
993
+
994
+ define_builtin(:be_within, deferred_class: WithinDeferred) do |_actual, *_args|
995
+ raise Constable::Error, "be_within needs .of: attest(value).to be_within(0.5).of(10)"
996
+ end
997
+
998
+ define_builtin(:be, deferred_class: BeDeferred) do |_actual, *_args|
999
+ raise Constable::Error, "be is handled by BeDeferred and should never invoke its block"
1000
+ end
1001
+
798
1002
  define_builtin(:be_a) do |actual, klass|
799
1003
  next true if actual.is_a?(klass)
800
1004
 
@@ -42,6 +42,30 @@ module Constable
42
42
  @cases.find { |klass| klass.constable_display_name == name.to_s || klass.name == name.to_s }
43
43
  end
44
44
 
45
+ # Re-keys any investigations that share a body with another, so the blotter never
46
+ # treats two tests as one. Runs once, after the whole suite is loaded -- a collision
47
+ # is invisible until every case file has been seen.
48
+ #
49
+ # Returns the groups it re-keyed, so a caller can report them if it wants to.
50
+ def disambiguate_identities!
51
+ collisions = investigations.group_by(&:identity).select { |_key, group| group.size > 1 }
52
+ return [] if collisions.empty?
53
+
54
+ collisions.each_value { |group| group.each(&:disambiguate!) }
55
+
56
+ # Class and description are usually enough to tell two identical bodies apart. When
57
+ # they are not -- a copy-pasted `investigate` with the same name and the same body
58
+ # in the same case -- fall back to position, which is the only thing left that
59
+ # differs. History for those resets whenever the file is reordered, which is the
60
+ # honest cost of two tests that are indistinguishable by anything a human wrote.
61
+ still_colliding = investigations.group_by(&:identity).select { |_key, group| group.size > 1 }
62
+ still_colliding.each_value do |group|
63
+ group.each_with_index { |investigation, index| investigation.disambiguate!(ordinal: index) }
64
+ end
65
+
66
+ collisions.values
67
+ end
68
+
45
69
  def each(&) = @cases.each(&)
46
70
 
47
71
  def size = @cases.size
@@ -46,6 +46,11 @@ module Constable
46
46
 
47
47
  DEFAULT_SLOWEST = 5
48
48
 
49
+ # Column the expanded stream right-aligns durations into. Descriptions are never
50
+ # truncated to fit it -- a clipped test name is not something you can grep for --
51
+ # so a long one simply pushes its own stamp out past the column.
52
+ EXPANDED_STAMP_COLUMN = 56
53
+
49
54
  # Result::GLYPHS covers statuses. These four are summary vocabulary, not statuses:
50
55
  # supervision and coverage are properties of a test, not outcomes of one.
51
56
  GLYPHS = Result::GLYPHS.merge(
@@ -97,11 +102,14 @@ module Constable
97
102
 
98
103
  attr_reader :io, :config, :seed, :total
99
104
 
100
- def initialize(io: $stdout, config: nil, color: nil, slowest: DEFAULT_SLOWEST)
105
+ def initialize(io: $stdout, config: nil, color: nil, slowest: DEFAULT_SLOWEST, mode: nil)
101
106
  @io = io
102
107
  @config = config || Constable.config
103
108
  @color = resolve_color(color)
104
109
  @slowest = slowest.to_i
110
+ # Resolved lazily: the reporter is built before case_helper.rb has run, so reading
111
+ # the config now would miss anything Constable.configure sets.
112
+ @requested_mode = mode
105
113
  @io.set_encoding(Encoding::UTF_8) if @io.respond_to?(:set_encoding)
106
114
 
107
115
  reset_stream!
@@ -141,6 +149,14 @@ module Constable
141
149
  def flush!
142
150
  return self unless streaming?
143
151
 
152
+ # The expanded stream never buffers -- every line was written as it happened, so
153
+ # there is nothing left to emit, only a blank line before the summary.
154
+ if expanded?
155
+ reset_stream!
156
+ writeln
157
+ return self
158
+ end
159
+
144
160
  close_stream_line
145
161
  pending_case_names.each { |name| open_stream_line(name) && close_stream_line }
146
162
  writeln
@@ -169,6 +185,9 @@ module Constable
169
185
 
170
186
  section_parole_violations(results)
171
187
  section_failures(results)
188
+ section_warrants(results)
189
+ section_jailed(results)
190
+ section_parole(results)
172
191
  section_warnings(warnings)
173
192
  section_slowest(results)
174
193
  rename_suggestions(suggestions)
@@ -193,6 +212,8 @@ module Constable
193
212
  def failed? = !success?
194
213
  def color? = @color
195
214
  def finished? = @finished
215
+ def mode = @mode ||= resolve_mode(@requested_mode)
216
+ def expanded? = mode == :expanded
196
217
 
197
218
  private
198
219
 
@@ -211,6 +232,8 @@ module Constable
211
232
  end
212
233
 
213
234
  def stream(result)
235
+ return stream_expanded(result) if expanded?
236
+
214
237
  name = result.case_name.to_s
215
238
  name = "(anonymous)" if name.empty?
216
239
  glyph = paint(result.glyph, COLORS[result.status])
@@ -263,6 +286,77 @@ module Constable
263
286
  # Glyphs may carry escape codes; count only the visible ones.
264
287
  def count_glyphs(string) = strip_ansi(string).length
265
288
 
289
+ # --- expanded stream -----------------------------------------------------------
290
+ #
291
+ # One line per test instead of one glyph. The trade is deliberate: concise keeps a
292
+ # thousand-test suite on one screen, expanded tells you which test is hanging while
293
+ # it hangs, without waiting for the summary.
294
+ #
295
+ # Workers interleave, so a case can come back after another has spoken. It gets a
296
+ # second header rather than having its later tests silently appended under the
297
+ # wrong one -- the same honesty rule the concise stream follows.
298
+ def stream_expanded(result)
299
+ name = result.case_name.to_s
300
+ name = "(anonymous)" if name.empty?
301
+
302
+ if @stream_case != name
303
+ writeln if @stream_case
304
+ writeln(INDENT + paint(name, :bold))
305
+ @stream_case = name
306
+ @stream_open = true
307
+ end
308
+
309
+ writeln(expanded_line(result))
310
+ end
311
+
312
+ def expanded_line(result)
313
+ glyph = paint(result.glyph, COLORS[result.status])
314
+ line = "#{ENTRY_INDENT}#{glyph} #{expanded_description(result)}"
315
+
316
+ stamp = expanded_duration(result)
317
+ return line if stamp.nil?
318
+
319
+ # Pad to a column so the durations line up, but never truncate a description --
320
+ # a clipped test name is not something you can grep for.
321
+ visible = strip_ansi(line).length
322
+ gap = [EXPANDED_STAMP_COLUMN - visible, 1].max
323
+ "#{line}#{" " * gap}#{paint(stamp, :dim)}"
324
+ end
325
+
326
+ def expanded_description(result)
327
+ description = result.description.to_s
328
+ description = "(no description)" if description.empty?
329
+ # A jailed test never ran its body, so say why rather than implying it passed.
330
+ return "#{description} #{paint("— #{result.jail_reason}", :dim)}" if jail_reason_worth_showing?(result)
331
+
332
+ description
333
+ end
334
+
335
+ def jail_reason_worth_showing?(result)
336
+ result.status == :jailed && !result.jail_reason.to_s.strip.empty?
337
+ end
338
+
339
+ # Only real, measured time. A jailed test never ran, and "0ms" would be a claim
340
+ # about a body that was skipped.
341
+ def expanded_duration(result)
342
+ return nil if result.status == :jailed
343
+ return nil unless result.duration.to_f.positive?
344
+
345
+ format_test_duration(result.duration)
346
+ end
347
+
348
+ # The summary's durations are run-scale, where "12.4s" is the useful unit. One test
349
+ # is usually sub-second, and "0.0s" against every line says nothing at all -- so the
350
+ # expanded stream counts milliseconds until a test is slow enough for seconds to mean
351
+ # something.
352
+ def format_test_duration(seconds)
353
+ seconds = seconds.to_f
354
+ return format_duration(seconds) if seconds >= 1
355
+
356
+ milliseconds = (seconds * 1000).round
357
+ milliseconds.zero? ? "<1ms" : "#{milliseconds}ms"
358
+ end
359
+
266
360
  # --- header and headline -------------------------------------------------------
267
361
 
268
362
  def header_line(results, duration)
@@ -359,8 +453,12 @@ module Constable
359
453
  each_entry(violations) do |result|
360
454
  writeln(INDENT + paint("#{GLYPHS[:parole_violation]} #{result.case_name}", COLORS[:parole_violation]))
361
455
  writeln(ENTRY_INDENT + paint(%("#{result.description}"), :dim))
456
+ writeln(ENTRY_INDENT + paint(result.location, :dim))
362
457
  writeln(ENTRY_INDENT + parole_violation_sentence(result))
363
458
  end
459
+ hint("Somebody trusted this test again and it let them down, so it is back on the " \
460
+ "docket. Fix it before the next constable jail parole — a second violation " \
461
+ "is the signal that the test, not the flake, is the problem.")
364
462
  end
365
463
 
366
464
  def parole_violation_sentence(result)
@@ -369,6 +467,95 @@ module Constable
369
467
  sentence
370
468
  end
371
469
 
470
+ # A section says what happened; a hint says what to do about it. One dim sentence,
471
+ # and only where there is a real next step -- a tip printed on every run stops being
472
+ # read on the second one.
473
+ def hint(text)
474
+ writeln
475
+ lines = wrap(text, width: RULE_WIDTH - INDENT.length - 2, indent: " ")
476
+ writeln(INDENT + paint("→ #{lines.first}", :dim))
477
+ lines.drop(1).each { |line| writeln(INDENT + paint(line, :dim)) }
478
+ end
479
+
480
+ # Every test currently under a warrant: it failed, then passed when rerun in
481
+ # isolation, so it is flaky rather than broken. Loud, but not build-blocking.
482
+ def section_warrants(results)
483
+ warranted = results.select(&:warranted?)
484
+ return if warranted.empty?
485
+
486
+ section("WARRANTS")
487
+ each_entry(warranted) do |result|
488
+ writeln(INDENT + paint("#{GLYPHS[:warrant]} #{result.case_name}", COLORS[:warranted]))
489
+ writeln(ENTRY_INDENT + paint(%("#{result.description}"), :dim))
490
+ writeln(ENTRY_INDENT + paint(result.location, :dim))
491
+ writeln(ENTRY_INDENT + warrant_sentence(result))
492
+ end
493
+ hint("A warrant is \"not reproducible\", not \"not a problem\" — it stops blocking the " \
494
+ "build and stays visible until someone deals with it. " \
495
+ "Fixed the flake? constable warrants release PATH:LINE")
496
+ end
497
+
498
+ def warrant_sentence(result)
499
+ statuses = Array(result.retries).map(&:to_sym)
500
+ return "Failed once, then passed on retry." if statuses.empty?
501
+
502
+ passed = statuses.count(:passed)
503
+ "Failed, then passed #{passed} of #{statuses.size} #{pluralize(statuses.size, "retry")} " \
504
+ "run in isolation."
505
+ end
506
+
507
+ # The docket. These never ran their bodies, so they are neither passing nor failing --
508
+ # which is exactly why they get their own category rather than being folded into
509
+ # either one.
510
+ def section_jailed(results)
511
+ jailed = results.select { |result| result.status == :jailed }
512
+ return if jailed.empty?
513
+
514
+ section("JAILED")
515
+ each_entry(jailed) do |result|
516
+ writeln(INDENT + paint("#{GLYPHS[:jailed]} #{result.case_name}", COLORS[:jailed]))
517
+ writeln(ENTRY_INDENT + paint(%("#{result.description}"), :dim))
518
+ writeln(ENTRY_INDENT + paint(result.location, :dim))
519
+ writeln(ENTRY_INDENT + jailed_sentence(result))
520
+ end
521
+ hint("Jailed means skipped and tracked, not passing. Think one is fixed? " \
522
+ "constable jail parole PATH:LINE runs it for real again — " \
523
+ "#{@config.parole_period} clean runs and it releases itself.")
524
+ end
525
+
526
+ def jailed_sentence(result)
527
+ reason = result.jail_reason.to_s.strip
528
+ sentence = reason.empty? ? "Body skipped; setup still ran." : "#{reason.capitalize}."
529
+ sentence += " Its #{ordinalize(result.times_jailed)} time in jail." if result.times_jailed.to_i > 1
530
+ sentence
531
+ end
532
+
533
+ # Out on parole and behaving. Worth naming every run, because the count only means
534
+ # something if you can see it moving.
535
+ def section_parole(results)
536
+ paroled = results.select { |result| result.parole_day && !result.parole_violation? }
537
+ return if paroled.empty?
538
+
539
+ section("ON PAROLE")
540
+ each_entry(paroled) do |result|
541
+ writeln(INDENT + paint("#{GLYPHS[:parole]} #{result.case_name}", COLORS[:parole]))
542
+ writeln(ENTRY_INDENT + paint(%("#{result.description}"), :dim))
543
+ writeln(ENTRY_INDENT + paint(result.location, :dim))
544
+ writeln(ENTRY_INDENT + parole_progress_sentence(result))
545
+ end
546
+ hint("A paroled test runs for real and is watched: one failure sends it straight " \
547
+ "back to jail. constable watchlist shows everything under supervision.")
548
+ end
549
+
550
+ def parole_progress_sentence(result)
551
+ day = result.parole_day.to_i
552
+ period = @config.parole_period
553
+ remaining = [period - day, 0].max
554
+ return "Day #{day} of #{period} — releases after this run." if remaining.zero?
555
+
556
+ "Day #{day} of #{period} — #{remaining} #{pluralize(remaining, "clean run")} to go."
557
+ end
558
+
372
559
  def section_failures(results)
373
560
  failures = results.select(&:failed?)
374
561
  return if failures.empty?
@@ -432,11 +619,21 @@ module Constable
432
619
  writeln(INDENT + paint("#{GLYPHS[:warning]} #{message}", COLORS[:warning]))
433
620
  else
434
621
  writeln(INDENT + paint("#{GLYPHS[:warning]} #{location}", COLORS[:warning]))
435
- message.each_line { |line| writeln(ENTRY_INDENT + line.chomp) }
622
+ warning_message_lines(message).each { |line| writeln(ENTRY_INDENT + line) }
436
623
  end
437
624
  end
438
625
  end
439
626
 
627
+ # A warning carries the author's own words -- an unsafe block's reason, a cold case's
628
+ # count -- and those are easily ninety columns. Wrapped to the frame, but respecting
629
+ # any line breaks the message already chose.
630
+ def warning_message_lines(message)
631
+ message.to_s.lines.flat_map do |line|
632
+ text = line.chomp
633
+ text.empty? ? [""] : wrap(text, width: RULE_WIDTH - ENTRY_INDENT.length, indent: "")
634
+ end
635
+ end
636
+
440
637
  def section_slowest(results)
441
638
  # A jailed test never ran its body, so it has no honest duration. A parole
442
639
  # violation did run -- and failing slowly is still worth seeing.
@@ -551,7 +748,28 @@ module Constable
551
748
  end
552
749
 
553
750
  def pluralize(count, word)
554
- count.to_i == 1 ? word : "#{word}s"
751
+ return word if count.to_i == 1
752
+ # "retry" -> "retries". Only the consonant-y rule earns a special case; every other
753
+ # word this reporter pluralizes takes a plain "s".
754
+ return "#{word[0..-2]}ies" if word.end_with?("y") && !"aeiou".include?(word[-2].to_s)
755
+
756
+ "#{word}s"
757
+ end
758
+
759
+ # Hints are prose, and prose that runs past the frame reads as a mistake. Wrapped to
760
+ # the same 60 columns the rules use, with continuation lines aligned under the arrow.
761
+ def wrap(text, width:, indent:)
762
+ words = text.split
763
+ lines = [+""]
764
+ words.each do |word|
765
+ candidate = lines.last.empty? ? word : "#{lines.last} #{word}"
766
+ if candidate.length <= width || lines.last.empty?
767
+ lines[-1] = candidate
768
+ else
769
+ lines << +word
770
+ end
771
+ end
772
+ lines.each_with_index.map { |line, i| i.zero? ? line : indent + line }
555
773
  end
556
774
 
557
775
  def format_duration(seconds)
@@ -571,6 +789,17 @@ module Constable
571
789
 
572
790
  # Colour is a nicety; correctness is not. NO_COLOR, a pipe, a dumb terminal or an
573
791
  # explicit --no-color all fall back to plain text with identical layout.
792
+ # An explicit argument (the --expanded / --concise flags) beats the config file, which
793
+ # beats the default. An unrecognized value falls back rather than raising: a typo in
794
+ # config.yml should not stop a suite from running.
795
+ def resolve_mode(mode)
796
+ configured = @config.respond_to?(:output_mode) ? @config.output_mode : :concise
797
+ return configured if mode.nil?
798
+
799
+ mode = mode.to_s.strip.downcase.to_sym
800
+ Config::OUTPUT_MODES.include?(mode) ? mode : configured
801
+ end
802
+
574
803
  def resolve_color(color)
575
804
  return !!color unless color.nil?
576
805
  return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?