constable-rails 1.3.1 → 1.3.3

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: e3b589fed13c38522dab7443ab1bb967bedeaec7abb34beaecd5c4f2c9a4298a
4
- data.tar.gz: b646c7160830f81bc2c6c5af9fe60728a8c7e1f40236e594fb64a51eed04080f
3
+ metadata.gz: fb4658a48f23de1dcb97417de5bab1deda4548a334e2a198835f93b7cac656c3
4
+ data.tar.gz: c844e9f7ac1c8d797d60349b7b15759bbcbbb0f1b56c914d43e2511e17f75489
5
5
  SHA512:
6
- metadata.gz: ee0579e80cb81345601ce2c40090db63b87b1498698a77fbc8c34d0df478d36f3210295b3909bd8290835a1f245f2522952f491caca6776d7ac28981dcae7b74
7
- data.tar.gz: aea8a0a67d5bdc8868706db9820af271ded921871da905c9e09fe83ac74a359c8c5e9ef8649d737367075f8faa9fabc32fff13156a8f2a9f6e9f69516464441a
6
+ metadata.gz: c3e78d5c2d2ab5314ee501795a92a1017fbfe0eb4cab6889c7ab6883d1ff898b02a595a693c3bb774c8e8e26caed2132c4f0c033723e7389735386f7b6c43668
7
+ data.tar.gz: 9f60ad3894558087d8747a5308e39558ce03d87c1518608dff21c9ca341fc776f11ceea45b7251435f052654ead1bbf348c489086b5857cea8e1b0a845781ff8
data/CHANGELOG.md CHANGED
@@ -5,6 +5,97 @@ All notable changes to this project are documented here. This project adheres to
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.3.3]
9
+
10
+ **1.3.2 was tagged twice.** The console fix in it was rebuilt after the gem had already
11
+ been pushed, and RubyGems will not accept a second push of the same version — so the
12
+ published 1.3.2 does not contain it. That fix is here, along with two more found on a
13
+ 1,277-file suite.
14
+
15
+ ### Shared examples survive across cold-case files
16
+
17
+ A suite that keeps shared examples in a plain file beside its specs —
18
+ `require_relative "appeal_shared_examples"` at the top of `appeal_spec.rb` — registers
19
+ them the first time that file loads. `require` never fires again, so every later file
20
+ that shares them died on load:
21
+
22
+ ```
23
+ ✗ spec/models/legacy_appeal_spec.rb "failed to load"
24
+ ArgumentError: Could not find shared examples "toggle overtime"
25
+ ```
26
+
27
+ Twelve files in one run, every one of which passes in isolation. RSpec never hits this
28
+ because it loads every spec file and *then* runs them; Constable loads one at a time,
29
+ which is what makes a cold case cheap, so the registry has to be carried across by hand.
30
+
31
+ Same two files, before and after: 12 files failing to load → `458 passed, 0 failed`.
32
+
33
+ ### Rename suggestions are a section, not a wall
34
+
35
+ They were printed unlabelled after SLOWEST, one long line per suggestion, each carrying
36
+ two full test descriptions and two hashes. A real suite produced two hundred of them —
37
+ several hundred lines with no heading, burying every section above.
38
+
39
+ Now a `RENAMED?` section: eight at a time, three short lines each, with the rest counted.
40
+ Descriptions are truncated to the frame, because an RSpec description built from a matcher
41
+ carries the entire inspected object — every column of a record, ids and timestamps
42
+ included.
43
+
44
+ ### The app's stdout, again (from 1.3.2, unpublished)
45
+
46
+ Reassigning the `$stdout` object was not enough: a gem writing through the `STDERR`
47
+ constant, or anything already holding the descriptor, goes straight past it, and in a
48
+ terminal both streams land in the same place. The descriptors are now redirected with
49
+ `IO#reopen`, with an `at_exit` guard so a crash still prints where it can be seen.
50
+
51
+ CLI errors no longer use `Kernel#warn`. Rails apps routinely override `Warning.warn` to
52
+ funnel Ruby warnings into `Rails.logger`, and a real one did: `no tests matched ...`
53
+ arrived in `log/test.log` tagged `[RUBY WARNING]` while the terminal showed nothing at
54
+ all, so the command looked like it had silently done nothing.
55
+
56
+
57
+ ## [1.3.2]
58
+
59
+ ### stdout is results only — the app's stdout too, not just Constable's
60
+
61
+ `log/test.log` has taken Rails' loggers since 1.0.0. It did not take anything a gem writes
62
+ directly to `$stdout` or `$stderr`, and a warning fired once per file lands in the middle
63
+ of the live stream:
64
+
65
+ ```
66
+ Address ✓✓✓✓✓✓✓✓✓✓✓To use retry middleware with Faraday v2.0+, install `faraday-retry` gem
67
+ ```
68
+
69
+ Both streams are now pointed at the log for the duration of a run, and the reporter keeps
70
+ the terminal it captured beforehand. `--verbose` tees them back, which is what that flag
71
+ is for.
72
+
73
+ Two ordering bugs came with it, both caught before release. The console has to be taken
74
+ **before** the Rails check, because `route!` runs before the app boots and most of the
75
+ noise is emitted *by* booting it. And colour detection was asking `$stdout.tty?` — which
76
+ by then is a file, and a file is never a tty, so colour would have silently switched off
77
+ for everybody.
78
+
79
+ Reassigning the `$stdout` *object* turned out not to be enough — a gem writing through
80
+ the `STDERR` constant, or anything already holding the descriptor, goes straight past it,
81
+ and in a terminal both streams land in the same place. So the **descriptors** are
82
+ redirected with `IO#reopen`, which is the only thing that catches every writer, and the
83
+ reporter is handed a dup of the real terminal taken beforehand. An `at_exit` guard puts
84
+ them back, so an uncaught exception still prints its backtrace where you can see it.
85
+
86
+ Two things that fell out of doing it properly:
87
+
88
+ - **CLI errors no longer use `Kernel#warn`.** With the descriptors redirected an error
89
+ would have gone into the log — and `warn` would not have reached the terminal even
90
+ without that, because Rails apps routinely override `Warning.warn` to funnel Ruby
91
+ warnings into `Rails.logger`. A real one did: `no tests matched ...` arrived in
92
+ `log/test.log` tagged `[RUBY WARNING]` while the terminal showed nothing at all. Errors
93
+ now go to the console the reporter kept, on stderr.
94
+ - **Colour detection was asking the wrong stream.** `$stdout.tty?`, when `$stdout` is by
95
+ then a log file and a file is never a tty — colour would have silently switched off for
96
+ everybody.
97
+
98
+
8
99
  ## [1.3.1]
9
100
 
10
101
  ### Cold cases now read `.rspec`
@@ -528,7 +619,9 @@ Initial release.
528
619
  - Diff-based coverage gate — only lines changed in the current diff are held to the
529
620
  threshold. `constable beat` for the full picture, `--html` for a browsable report.
530
621
 
531
- [Unreleased]: https://github.com/Ray-Hughes/constable/compare/v1.3.1...HEAD
622
+ [Unreleased]: https://github.com/Ray-Hughes/constable/compare/v1.3.3...HEAD
623
+ [1.3.3]: https://github.com/Ray-Hughes/constable/compare/v1.3.2...v1.3.3
624
+ [1.3.2]: https://github.com/Ray-Hughes/constable/compare/v1.3.1...v1.3.2
532
625
  [1.3.1]: https://github.com/Ray-Hughes/constable/compare/v1.3.0...v1.3.1
533
626
  [1.3.0]: https://github.com/Ray-Hughes/constable/compare/v1.2.0...v1.3.0
534
627
  [1.2.0]: https://github.com/Ray-Hughes/constable/compare/v1.1.0...v1.2.0
data/README.md CHANGED
@@ -422,8 +422,16 @@ and says why — slow is a trade-off, wrong is not.
422
422
 
423
423
  ### Output
424
424
 
425
- stdout is reserved for results. `Rails.logger`, SQL and request/response logging go to
426
- `log/test.log`; `--verbose` streams it back for active debugging.
425
+ stdout is reserved for results — not just Constable's own output, but the app's. Rails
426
+ loggers, SQL, request/response logging, and anything a gem prints to `$stdout` or
427
+ `$stderr` mid-run all go to `log/test.log`; `--verbose` streams it back for active
428
+ debugging.
429
+
430
+ That matters more than it sounds. A gem warning fired once per file lands in the middle of
431
+ the live stream, and you get `Address ✓✓✓✓✓✓To use retry middleware with Faraday...`
432
+ instead of a readable run. The one thing Constable deliberately does not intercept is a
433
+ write straight to file descriptor 2 — capturing that would also swallow a real crash and
434
+ break `binding.pry`, so `2>/dev/null` stays yours to decide on.
427
435
 
428
436
  **While it runs**, the live stream has two modes. `concise` is the default: one glyph per
429
437
  test, grouped into a run per case, so a thousand-test suite stays inside one screen and a
data/lib/constable/cli.rb CHANGED
@@ -21,13 +21,29 @@ module Constable
21
21
  # tier, an unreadable config. Both are the user's mistake rather than a crash, and
22
22
  # both deserve one sentence and a usage status instead of a backtrace.
23
23
  rescue Thor::Error, Constable::Error => e
24
- warn e.message
24
+ complain(e.message)
25
25
  exit(EXIT_USAGE)
26
26
  rescue Interrupt
27
- warn "\ninterrupted"
27
+ complain("\ninterrupted")
28
28
  exit(EXIT_FAILED)
29
29
  end
30
30
 
31
+ # Anything the user has to see, written where they will see it.
32
+ #
33
+ # Not Kernel#warn. A run points stdout and stderr at log/test.log, so an error has to
34
+ # go to the console the reporter kept -- and `warn` would not reach it even without
35
+ # that: Rails apps routinely override Warning.warn to funnel Ruby warnings into
36
+ # Rails.logger, and a real one did. The message arrived in log/test.log tagged
37
+ # "[RUBY WARNING]" while the terminal showed nothing at all.
38
+ def self.complain(message)
39
+ LogRouter.restore!
40
+ io = LogRouter.console_err
41
+ io.puts(message)
42
+ io.flush if io.respond_to?(:flush)
43
+ rescue StandardError
44
+ $stderr.puts(message) # rubocop:disable Style/StderrPuts
45
+ end
46
+
31
47
  class_option :"no-color", type: :boolean, default: false, desc: "Disable ANSI color"
32
48
 
33
49
  desc "test [PATH[:LINE]]", "Run the suite -- native and cold cases side by side"
@@ -184,7 +200,7 @@ module Constable
184
200
  option :"show-source", type: :boolean, default: false, desc: "Print the rewritten source"
185
201
  def modernize(*paths)
186
202
  if paths.empty?
187
- warn "modernize needs at least one path"
203
+ CLI.complain("modernize needs at least one path")
188
204
  exit(EXIT_USAGE)
189
205
  end
190
206
 
@@ -309,7 +325,7 @@ module Constable
309
325
 
310
326
  identity = jail.resolve(locator)
311
327
  unless identity
312
- warn "Nothing on the docket at #{locator}"
328
+ CLI.complain("Nothing on the docket at #{locator}")
313
329
  exit(EXIT_USAGE)
314
330
  end
315
331
  yield jail, identity
@@ -320,9 +336,9 @@ module Constable
320
336
  def refuse_ambiguous(locator, candidates, noun)
321
337
  return if candidates.size <= 1
322
338
 
323
- warn "#{locator} matches #{candidates.size} tests #{noun}. Name one:"
339
+ CLI.complain("#{locator} matches #{candidates.size} tests #{noun}. Name one:")
324
340
  candidates.sort_by { |entry| entry.line.to_i }.each do |entry|
325
- warn " #{entry.location} #{entry.label}"
341
+ CLI.complain(" #{entry.location} #{entry.label}")
326
342
  end
327
343
  exit(EXIT_USAGE)
328
344
  end
@@ -368,7 +384,7 @@ module Constable
368
384
 
369
385
  identity = warrants.resolve(locator)
370
386
  unless identity
371
- warn "No warrant at #{locator}"
387
+ CLI.complain("No warrant at #{locator}")
372
388
  exit(EXIT_USAGE)
373
389
  end
374
390
  warrants.release(identity)
@@ -413,7 +429,7 @@ module Constable
413
429
  def prepare
414
430
  config = load_config
415
431
  unless WorkerDatabases.shardable?
416
- warn "This app has no ActiveRecord test databases to prepare."
432
+ CLI.complain("This app has no ActiveRecord test databases to prepare.")
417
433
  exit(EXIT_USAGE)
418
434
  end
419
435
 
@@ -429,7 +445,7 @@ module Constable
429
445
  "rebuilding them."
430
446
  0
431
447
  rescue Constable::Error => e
432
- warn e.message
448
+ CLI.complain(e.message)
433
449
  exit(EXIT_FAILED)
434
450
  end
435
451
 
@@ -501,14 +517,19 @@ module Constable
501
517
 
502
518
  def color?
503
519
  return false if options[:"no-color"]
504
- return false unless $stdout.tty?
520
+ # The console, not $stdout -- which by now is log/test.log, and a file is never a
521
+ # tty. Asking the wrong one silently turns colour off for everybody.
522
+ return false unless LogRouter.console.tty?
505
523
  return false unless ENV["NO_COLOR"].to_s.empty?
506
524
 
507
525
  true
508
526
  end
509
527
 
528
+ # LogRouter.console, not $stdout: by this point route! has pointed $stdout at
529
+ # log/test.log so a stray gem warning cannot land in the middle of the live stream.
530
+ # The reporter is the one thing that still writes to the terminal.
510
531
  def reporter(config)
511
- Reporter.new(io: $stdout, config: config, color: color?, mode: output_mode)
532
+ Reporter.new(io: LogRouter.console, config: config, color: color?, mode: output_mode)
512
533
  end
513
534
 
514
535
  # nil means "the config file decides". The explicit --output wins over the two
@@ -149,6 +149,7 @@ module Constable
149
149
  @session_configuration = nil
150
150
  @session_prepared = false
151
151
  @ran_before_suite_hooks = nil
152
+ @shared_examples = nil
152
153
  nil
153
154
  end
154
155
 
@@ -252,9 +253,11 @@ module Constable
252
253
  ::RSpec.instance_variable_set(:@configuration, @session_configuration)
253
254
  prepare_session_configuration(::RSpec.configuration)
254
255
  clear_examples
256
+ restore_shared_examples!
255
257
 
256
258
  yield
257
259
  ensure
260
+ remember_shared_examples!
258
261
  @session_world = ::RSpec.instance_variable_get(:@world)
259
262
  @session_configuration = ::RSpec.instance_variable_get(:@configuration)
260
263
  clear_examples
@@ -325,6 +328,38 @@ module Constable
325
328
  []
326
329
  end
327
330
 
331
+ # Shared examples are registered on the world, by `require`, once per process.
332
+ #
333
+ # A suite that keeps them in a plain file next to its specs -- `require_relative
334
+ # "appeal_shared_examples"` at the top of appeal_spec.rb -- registers them while
335
+ # the first file runs. `require` never fires again, so if the registry does not
336
+ # survive into the next file, every later file that shares them dies on load with
337
+ # `Could not find shared examples "toggle overtime"`.
338
+ #
339
+ # Under RSpec that never happens: it loads every spec file first, then runs them.
340
+ # Constable loads one file at a time, which is what makes a cold case cheap, so
341
+ # the registry has to be carried across by hand. Observed on a real suite: twelve
342
+ # files failing to load, all of which pass in isolation.
343
+ def remember_shared_examples!
344
+ world = ::RSpec.instance_variable_get(:@world)
345
+ return unless world.respond_to?(:shared_example_group_registry)
346
+
347
+ @shared_examples = world.shared_example_group_registry
348
+ rescue StandardError
349
+ nil
350
+ end
351
+
352
+ def restore_shared_examples!
353
+ return if @shared_examples.nil?
354
+
355
+ world = ::RSpec.world
356
+ return unless world.respond_to?(:shared_example_group_registry)
357
+
358
+ world.instance_variable_set(:@shared_example_group_registry, @shared_examples)
359
+ rescue StandardError
360
+ nil
361
+ end
362
+
328
363
  def clear_examples
329
364
  world = ::RSpec.instance_variable_get(:@world)
330
365
  world.reset if world.respond_to?(:reset)
@@ -55,6 +55,14 @@ module Constable
55
55
  def sync = true
56
56
  def tty? = false
57
57
 
58
+ # Something that has been handed $stdout may ask it for a file descriptor. Answer
59
+ # with the first target that has one rather than raising NoMethodError from inside
60
+ # somebody else's gem.
61
+ def fileno
62
+ @targets.each { |t| return t.fileno if t.respond_to?(:fileno) }
63
+ nil
64
+ end
65
+
58
66
  def sync=(value)
59
67
  @targets.each { |target| target.sync = value if target.respond_to?(:sync=) }
60
68
  end
@@ -89,12 +97,18 @@ module Constable
89
97
  def route!(verbose: false, path: nil, io: $stdout, root: nil)
90
98
  target = path ? File.expand_path(path.to_s, root || Constable.root) : default_path(root)
91
99
 
100
+ # Taking the console does not depend on Rails, and the ordering matters: route! runs
101
+ # before the app boots, so anything that waited for Rails would miss every warning
102
+ # emitted *by* booting it -- which is most of them. A suite with no Rails at all
103
+ # still wants its stdout kept for results.
104
+ file = open_log(target)
105
+ capture_console!(file, verbose ? io : nil)
106
+
92
107
  unless rails_loaded?
93
- reason = "Rails is not loaded — nothing to route, stdout is already results only"
108
+ reason = "Rails is not loaded — no loggers to route, but stdout is held for results"
94
109
  return @current = Routing.new(routed: false, path: target, verbose: verbose, reason: reason)
95
110
  end
96
111
 
97
- file = open_log(target)
98
112
  logger = build_logger(verbose ? Tee.new(file, io) : file)
99
113
 
100
114
  remember_previous!
@@ -103,9 +117,99 @@ module Constable
103
117
  @current = Routing.new(routed: true, path: target, verbose: verbose, reason: nil)
104
118
  end
105
119
 
120
+ # The terminal, as it was before route! took it. The reporter writes here; everything
121
+ # else writes to the log.
122
+ def console = @console_out || $stdout
123
+
124
+ # The same, for the stream errors belong on. Convention puts them on stderr, and a
125
+ # run has pointed the real one at the log.
126
+ def console_err = @console_err || $stderr
127
+
128
+ # Rails loggers are not the only thing that writes to a terminal. A gem warning --
129
+ # Faraday's "install the faraday-retry gem", say -- goes straight to $stderr, once
130
+ # per file that triggers it, and lands in the middle of the live stream:
131
+ #
132
+ # Address ✓✓✓✓✓✓✓✓✓✓✓To use retry middleware with Faraday v2.0+...
133
+ #
134
+ # stdout is supposed to be results only. So the app's stdout and stderr are pointed
135
+ # at log/test.log for the duration of the run, and the reporter keeps the real
136
+ # terminal it captured beforehand. `--verbose` tees both back, which is the whole
137
+ # point of that flag.
138
+ def capture_console!(file, tee_to)
139
+ # Reassigning the $stdout *object* is not enough. A gem that writes through the
140
+ # STDERR constant, a C extension, or anything that already holds file descriptor 2
141
+ # goes straight past it -- and in a terminal both streams land in the same place,
142
+ # so "it was only on stderr" is no comfort at all when it lands mid-glyph.
143
+ #
144
+ # So the descriptors themselves are pointed at the log, and the reporter is handed
145
+ # a dup of the real terminal taken beforehand. #reopen changes where fd 1 and 2
146
+ # write for the whole process, which is the only thing that catches every writer.
147
+ @console_out = $stdout.dup
148
+ @console_err = $stderr.dup
149
+ @reopened = false
150
+
151
+ # --verbose means "show me everything", so nothing is redirected; the object swap
152
+ # is enough to tee. The same fallback covers anything that is not a real IO on
153
+ # both sides -- a StringIO standing in for the terminal in a test, most obviously,
154
+ # where IO#reopen has nothing to reopen onto.
155
+ if tee_to || !redirectable?(file)
156
+ @swapped_out = $stdout
157
+ @swapped_err = $stderr
158
+ replacement = tee_to ? Tee.new(file, tee_to) : file
159
+ $stdout = replacement
160
+ $stderr = replacement
161
+ return
162
+ end
163
+
164
+ $stdout.reopen(file)
165
+ $stderr.reopen(file)
166
+ $stdout.sync = true
167
+ $stderr.sync = true
168
+ @reopened = true
169
+
170
+ # Whatever happens next -- a clean exit, a raise, an interrupt -- the descriptors
171
+ # go back. Without this an uncaught exception prints its backtrace into
172
+ # log/test.log and the terminal shows nothing at all, which is a far worse bug
173
+ # than the one being fixed.
174
+ install_exit_guard!
175
+ end
176
+
177
+ def install_exit_guard!
178
+ return if @exit_guard_installed
179
+
180
+ @exit_guard_installed = true
181
+ at_exit { restore_console! }
182
+ end
183
+
184
+ # Every party has to be a real IO: the log we are redirecting to, and the two streams
185
+ # we are redirecting away from and will later have to put back.
186
+ def redirectable?(file)
187
+ [file, $stdout, $stderr].all? { |io| io.is_a?(::IO) && io.respond_to?(:fileno) }
188
+ rescue StandardError
189
+ false
190
+ end
191
+
192
+ def restore_console!
193
+ if @reopened
194
+ $stdout.reopen(@console_out)
195
+ $stderr.reopen(@console_err)
196
+ elsif @swapped_out
197
+ $stdout = @swapped_out
198
+ $stderr = @swapped_err
199
+ end
200
+
201
+ [@console_out, @console_err].each { |io| io&.close unless io&.closed? } if @reopened
202
+ @console_out = nil
203
+ @console_err = nil
204
+ @swapped_out = nil
205
+ @swapped_err = nil
206
+ @reopened = false
207
+ end
208
+
106
209
  # Puts back whatever the app had before route!. Only useful in-process (our own
107
210
  # suite, or a REPL); a real run never wants its logs back on stdout.
108
211
  def restore!
212
+ restore_console!
109
213
  (@previous || {}).each do |constant_name, (setter, logger)|
110
214
  constant = resolve(constant_name)
111
215
  constant&.public_send(setter, logger)
@@ -50,6 +50,10 @@ module Constable
50
50
  # thousand lines that say the same thing.
51
51
  COLD_CASE_LIST_LIMIT = 10
52
52
 
53
+ # How many rename suggestions are listed before the rest are counted instead. A real
54
+ # suite produced two hundred, several hundred lines of output, unlabelled.
55
+ RENAME_LIST_LIMIT = 8
56
+
53
57
  DEFAULT_SLOWEST = 5
54
58
 
55
59
  # Column the expanded stream right-aligns durations into. Descriptions are never
@@ -679,27 +683,58 @@ module Constable
679
683
 
680
684
  # Rename detection is the Runner's job; the reporter only says it out loud. Never
681
685
  # its own section -- it is a suggestion, not a finding.
686
+ # Renames get a section of their own, a cap, and one line each.
687
+ #
688
+ # They used to be dumped unlabelled after SLOWEST, one long line per suggestion,
689
+ # every line carrying both full test descriptions and two hashes. A real suite
690
+ # produced two hundred of them and several hundred lines of output -- read as a wall
691
+ # of text with no heading, and buried the sections above it. A suggestion nobody can
692
+ # read is not a suggestion.
682
693
  def rename_suggestions(suggestions)
683
- suggestions = Array(suggestions).map { |s| suggestion_line(s) }.compact
694
+ suggestions = Array(suggestions).map { |s| suggestion(s) }.compact
684
695
  return if suggestions.empty?
685
696
 
686
- writeln
687
- suggestions.each { |line| writeln(INDENT + paint(line, :dim)) }
697
+ section("RENAMED?")
698
+ shown = suggestions.first(RENAME_LIST_LIMIT)
699
+
700
+ each_entry(shown) do |item|
701
+ writeln(INDENT + paint(item[:from].to_s, :dim))
702
+ writeln(ENTRY_INDENT + paint("→ #{item[:to]}", :dim))
703
+ writeln(ENTRY_INDENT + paint("constable history relink #{item[:old_hash]} #{item[:new_hash]}", :dim))
704
+ end
705
+
706
+ remaining = suggestions.size - shown.size
707
+ hint(if remaining.positive?
708
+ "...and #{remaining} more. A test's history is keyed on its body, so a test " \
709
+ "that was reworded and edited in one commit looks like a new one. Relink the " \
710
+ "ones you recognise; ignore the rest and they age out."
711
+ else
712
+ "A test's history is keyed on its body, so one that was reworded and edited " \
713
+ "in the same commit looks like a new test. Relink it to carry the history over."
714
+ end)
688
715
  end
689
716
 
690
- def suggestion_line(suggestion)
691
- return suggestion.to_s if suggestion.is_a?(String)
692
- return nil unless suggestion.respond_to?(:to_h)
717
+ def suggestion(raw)
718
+ return { from: raw.to_s, to: nil, old_hash: nil, new_hash: nil } if raw.is_a?(String)
719
+ return nil unless raw.respond_to?(:to_h)
693
720
 
694
- s = suggestion.to_h.transform_keys(&:to_sym)
721
+ s = raw.to_h.transform_keys(&:to_sym)
695
722
  from = s[:old_label] || s[:from] || label_for(s[:old_case], s[:old_description])
696
723
  to = s[:new_label] || s[:to] || label_for(s[:new_case], s[:new_description])
697
- old_hash = s[:old_hash] || s[:old_identity]
698
- new_hash = s[:new_hash] || s[:new_identity]
699
724
  return nil if from.nil? || to.nil?
700
725
 
701
- "possible rename: #{from} → #{to}, " \
702
- "run constable history relink #{old_hash} #{new_hash} to confirm"
726
+ { from: truncate_label(from), to: truncate_label(to),
727
+ old_hash: s[:old_hash] || s[:old_identity], new_hash: s[:new_hash] || s[:new_identity] }
728
+ end
729
+
730
+ # A description built by RSpec from a matcher carries the whole inspected object --
731
+ # ids, timestamps, every column of a record. Printed in full it is unreadable, and it
732
+ # is the same test either way.
733
+ def truncate_label(label)
734
+ text = label.to_s.tr("\n", " ").squeeze(" ")
735
+ return text if text.length <= RULE_WIDTH - INDENT.length
736
+
737
+ "#{text[0, RULE_WIDTH - INDENT.length - 1]}…"
703
738
  end
704
739
 
705
740
  def label_for(case_name, description)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Constable
4
- VERSION = "1.3.1"
4
+ VERSION = "1.3.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: constable-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 1.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ray Hughes