branchout 0.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.
- checksums.yaml +7 -0
- data/AGENTS.md +38 -0
- data/LICENSE +21 -0
- data/README.ja.md +903 -0
- data/README.md +586 -0
- data/bin/branchout +15 -0
- data/examples/arithmetic.rb +24 -0
- data/examples/hidden_bug.rb +17 -0
- data/examples/inputs/arithmetic.json +10 -0
- data/examples/inputs/arithmetic.seed.json +5 -0
- data/examples/inputs/hidden_bug.json +9 -0
- data/examples/inputs/hidden_bug.seed.json +5 -0
- data/examples/inputs/magic_value.json +7 -0
- data/examples/inputs/magic_value.seed.json +5 -0
- data/examples/inputs/version_parser.json +11 -0
- data/examples/inputs/version_parser.seed.json +5 -0
- data/examples/magic_value.rb +11 -0
- data/examples/version_parser.rb +29 -0
- data/lib/branchout/canon.rb +59 -0
- data/lib/branchout/cli.rb +302 -0
- data/lib/branchout/coverage.rb +9 -0
- data/lib/branchout/coverage_session.rb +49 -0
- data/lib/branchout/emitters/minitest.rb +92 -0
- data/lib/branchout/emitters/rspec.rb +532 -0
- data/lib/branchout/emitters/support.rb +1248 -0
- data/lib/branchout/emitters.rb +101 -0
- data/lib/branchout/engine.rb +303 -0
- data/lib/branchout/explore.rb +124 -0
- data/lib/branchout/input.rb +215 -0
- data/lib/branchout/input_file.rb +125 -0
- data/lib/branchout/mutator.rb +124 -0
- data/lib/branchout/prism_choice.rb +25 -0
- data/lib/branchout/report.rb +449 -0
- data/lib/branchout/rng.rb +31 -0
- data/lib/branchout/runner.rb +591 -0
- data/lib/branchout/sampler.rb +70 -0
- data/lib/branchout/target.rb +696 -0
- data/lib/branchout/version.rb +8 -0
- data/lib/branchout.rb +31 -0
- data/skills/branchout/SKILL.md +230 -0
- data/skills/branchout/inputs.example.json +10 -0
- metadata +114 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchout
|
|
4
|
+
module Emitters
|
|
5
|
+
# Emits RSpec source. This file does not load the rspec gem.
|
|
6
|
+
#
|
|
7
|
+
# Each input becomes a list of steps: the receiver (a let), each setup
|
|
8
|
+
# call (a before, or inline when the input raised), and each leaf of the
|
|
9
|
+
# arguments (a fact, which only names). Inputs that start with the same
|
|
10
|
+
# steps share a context that holds them, so an example is left with the
|
|
11
|
+
# outcome and the facts of its own.
|
|
12
|
+
module RSpec
|
|
13
|
+
# A fact runs no code; its +leaf+ is the [path, value] it names, so
|
|
14
|
+
# two values whose labels are cut alike stay two facts.
|
|
15
|
+
Step = Data.define(:kind, :label, :code, :leaf) do
|
|
16
|
+
def initialize(kind:, label:, code: nil, leaf: nil) = super
|
|
17
|
+
end
|
|
18
|
+
# The longest description or context text.
|
|
19
|
+
LIMIT = 80
|
|
20
|
+
# rspec puts these on the load path of a run from the project root.
|
|
21
|
+
RUNNER_LOAD_PATHS = %w[spec lib].freeze
|
|
22
|
+
CALLS = %i[before inline].freeze
|
|
23
|
+
# The steps whose code runs in a context, so an input that still has
|
|
24
|
+
# one gets a context of its own.
|
|
25
|
+
CONTEXT_STEPS = %i[let before].freeze
|
|
26
|
+
Node = Data.define(:steps, :members)
|
|
27
|
+
Example = Data.define(:pin, :steps, :facts)
|
|
28
|
+
|
|
29
|
+
# Names that a let or a subject must not take: RSpec's own methods.
|
|
30
|
+
TAKEN_NAMES = %w[
|
|
31
|
+
subject example expect is_expected described_class context describe let before after it
|
|
32
|
+
raise_error eq include be_a skip pending
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
def render(result, target, destination, require_by_name: false, pin_keys: [], from: Dir.pwd, all_varied: false)
|
|
38
|
+
lines = Support.header(target, "specs", destination)
|
|
39
|
+
lines << ""
|
|
40
|
+
lines.concat(Support.require_lines(result.requires, (target.path unless result.target_required), destination,
|
|
41
|
+
by_name: require_by_name, load_paths: result.load_paths,
|
|
42
|
+
runner_load_paths: RUNNER_LOAD_PATHS, from: from))
|
|
43
|
+
lines << ""
|
|
44
|
+
const = result.const_expression || target.const_name
|
|
45
|
+
type = ", type: #{result.spec_type.inspect}" if result.spec_type.is_a?(Symbol)
|
|
46
|
+
# A private constant cannot be named bare at load time, so the
|
|
47
|
+
# describe names it as text.
|
|
48
|
+
lines << "RSpec.describe #{const == target.const_name ? const : target.const_name.inspect}#{type} do"
|
|
49
|
+
lines << " describe #{"#{target.singleton ? "." : "#"}#{target.method_name}".inspect} do"
|
|
50
|
+
if result.pins.empty?
|
|
51
|
+
lines << " it #{(all_varied ? "pins no input" : "discovers no inputs").inspect} do"
|
|
52
|
+
lines << " skip #{Support.no_pin_reason(all_varied).inspect}"
|
|
53
|
+
lines << " end"
|
|
54
|
+
else
|
|
55
|
+
spec = Spec.new(target, result.pins, result.private_calls, pin_keys, const)
|
|
56
|
+
lines.concat(spec.lines)
|
|
57
|
+
end
|
|
58
|
+
lines << " end"
|
|
59
|
+
lines << "end"
|
|
60
|
+
"#{lines.join("\n")}\n"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The describe block of one entry: its subject, its tree of contexts,
|
|
64
|
+
# and the examples.
|
|
65
|
+
class Spec
|
|
66
|
+
def initialize(target, pins, private_calls, pin_keys, const = target.const_name)
|
|
67
|
+
@target = target
|
|
68
|
+
# The describe names the class, so rubocop-rspec's
|
|
69
|
+
# RSpec/DescribedClass wants it as described_class. A private
|
|
70
|
+
# constant is described by its name, which leaves described_class nil.
|
|
71
|
+
@const = const == target.const_name ? "described_class" : const
|
|
72
|
+
@private_calls = private_calls
|
|
73
|
+
@pin_keys = pin_keys
|
|
74
|
+
@receiver = receiver_name
|
|
75
|
+
@subject = subject_name if pins.all? { |pin| pin.input.args.empty? && pin.input.kwargs.empty? && pin.input.block.nil? }
|
|
76
|
+
@one_argument = pins.all? { |pin| pin.input.args.length == 1 && pin.input.kwargs.empty? }
|
|
77
|
+
@outcomes = Support.outcomes(pins, pin_keys)
|
|
78
|
+
@short_outcomes = Support.outcomes(pins, pin_keys, short: true)
|
|
79
|
+
entries = pins.map { |pin| [pin, steps_of(pin)] }
|
|
80
|
+
shared = entries.map { |_, steps| steps.select { |step| step.kind == :fact } }.reduce(:&)
|
|
81
|
+
entries = entries.map { |pin, steps| [pin, steps - shared] }
|
|
82
|
+
@root = merged(build([], entries))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def lines
|
|
86
|
+
out = case @subject
|
|
87
|
+
when String then [" subject(:#{@subject}) { #{entry_call} }", ""]
|
|
88
|
+
when false then [" subject { #{entry_call} }", ""]
|
|
89
|
+
else []
|
|
90
|
+
end
|
|
91
|
+
out + node_lines(@root, " ")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def node_lines(node, indent)
|
|
97
|
+
out = []
|
|
98
|
+
node.steps.select { |step| step.kind == :let }.each do |step|
|
|
99
|
+
out << "#{indent}let(:#{@receiver}) { #{step.code} }"
|
|
100
|
+
end
|
|
101
|
+
befores = node.steps.select { |step| step.kind == :before }
|
|
102
|
+
unless befores.empty?
|
|
103
|
+
out << "" unless out.empty?
|
|
104
|
+
out << "#{indent}before do"
|
|
105
|
+
out.concat(Support.indent(befores.flat_map { |step| step.code.lines(chomp: true) }, "#{indent} "))
|
|
106
|
+
out << "#{indent}end"
|
|
107
|
+
end
|
|
108
|
+
texts = member_texts(node.members)
|
|
109
|
+
node.members.each do |member|
|
|
110
|
+
out << "" unless out.empty?
|
|
111
|
+
if member.is_a?(Example)
|
|
112
|
+
out.concat(example_lines(member, texts[member], indent))
|
|
113
|
+
else
|
|
114
|
+
out << "#{indent}context #{quoted(texts[member])} do"
|
|
115
|
+
out.concat(node_lines(member, "#{indent} "))
|
|
116
|
+
out << "#{indent}end"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
out
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def example_lines(example, text, indent)
|
|
123
|
+
pin = example.pin
|
|
124
|
+
lines = ["#{indent}it #{quoted(text)} do"]
|
|
125
|
+
lines.concat(Support.cover_comment(pin, "#{indent} "))
|
|
126
|
+
lines.concat(Support.thawed_comment(pin, "#{indent} "))
|
|
127
|
+
inline = example.steps.select { |step| step.kind == :inline }.map(&:code)
|
|
128
|
+
call = @subject || (@subject == false ? "subject" : entry_call(pin.input, pin.thawed))
|
|
129
|
+
if pin.error
|
|
130
|
+
if inline.empty?
|
|
131
|
+
lines << "#{indent} expect { #{call} }.to #{error_matcher(pin.error)}"
|
|
132
|
+
else
|
|
133
|
+
lines << "#{indent} expect {"
|
|
134
|
+
(inline + [call]).each { |line| lines << "#{indent} #{line}" }
|
|
135
|
+
lines << "#{indent} }.to #{error_matcher(pin.error)}"
|
|
136
|
+
end
|
|
137
|
+
else
|
|
138
|
+
lines << "#{indent} expect(#{call}).to #{value_matcher(pin.value, Support.keys_for(pin, @pin_keys))}"
|
|
139
|
+
end
|
|
140
|
+
lines << "#{indent}end"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def error_matcher(error)
|
|
144
|
+
if error.name
|
|
145
|
+
"raise_error(#{error.class_name}) { |error| expect(error.name).to eq(#{error.name.inspect}) }"
|
|
146
|
+
elsif error.stable_message
|
|
147
|
+
"raise_error(#{error.class_name}, #{Support.ruby_literal(error.exact_message || error.message)})"
|
|
148
|
+
elsif error.message_pattern
|
|
149
|
+
"raise_error(#{error.class_name}, #{Regexp.new(error.message_pattern).inspect})"
|
|
150
|
+
else
|
|
151
|
+
"raise_error(#{error.class_name})"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def value_matcher(value, pin_keys)
|
|
156
|
+
pinned = Support.pinned_value(value, pin_keys)
|
|
157
|
+
unless pinned.equal?(value)
|
|
158
|
+
return "include(#{Support.literal?(pinned) ? Support.ruby_literal(pinned) : Support.shape_matcher(pinned)})"
|
|
159
|
+
end
|
|
160
|
+
return "match(#{Support.frame_pattern(value)})" if Support.frame_pattern(value)
|
|
161
|
+
unless Support.literal?(value)
|
|
162
|
+
return "match(#{Support.shape_matcher(value)})" if value.is_a?(Array) || value.is_a?(Hash)
|
|
163
|
+
return Support.shape_matcher(value) if value.is_a?(Struct) || value.is_a?(Data)
|
|
164
|
+
|
|
165
|
+
return "be_a(#{Support.class_literal(value)})"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# rubocop-rspec's RSpec/BeEq and RSpec/BeNil.
|
|
169
|
+
return "be_nil" if value.nil?
|
|
170
|
+
return "be(#{value})" if value == true || value == false
|
|
171
|
+
|
|
172
|
+
"eq(#{Support.ruby_literal(value)})"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The text of each member of one group, at most LIMIT characters. A
|
|
176
|
+
# context, and a member whose text is longer, says only the facts
|
|
177
|
+
# that tell it from its siblings; a text still too long names them
|
|
178
|
+
# by their deepest key, then is cut. Texts that would still be the
|
|
179
|
+
# same end in their first input's digest.
|
|
180
|
+
def member_texts(members)
|
|
181
|
+
peers = members.flat_map { |member| pins_of(member) }
|
|
182
|
+
texts = members.to_h do |member|
|
|
183
|
+
others = members.reject { |other| other.equal?(member) }.flat_map { |other| pins_of(other) }
|
|
184
|
+
steps = member.is_a?(Example) ? member.facts : member.steps
|
|
185
|
+
text = member_text(member, steps, peers)
|
|
186
|
+
text = member_text(member, telling(steps, others), peers) if text.length > LIMIT || member.is_a?(Node)
|
|
187
|
+
text = member_text(member, telling(steps, others), peers, short: true) if text.length > LIMIT
|
|
188
|
+
[member, text.length > LIMIT ? fitted(member, telling(steps, others), peers) : text]
|
|
189
|
+
end
|
|
190
|
+
repeated = texts.values.tally
|
|
191
|
+
texts.to_h do |member, text|
|
|
192
|
+
next [member, text] if repeated[text] == 1
|
|
193
|
+
|
|
194
|
+
digest = " (input #{Digest::SHA256.hexdigest(pins_of(member).first.input.canonical)[0, 6]})"
|
|
195
|
+
[member, text.length + digest.length > LIMIT ? "#{cut_after(text, kept_start(member, text), LIMIT - digest.length)}#{digest}" : "#{text}#{digest}"]
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# +text+ cut to +limit+ characters with "...", but never inside its
|
|
200
|
+
# first +kept+ characters.
|
|
201
|
+
def cut_after(text, kept, limit = LIMIT)
|
|
202
|
+
text.length <= limit ? text : "#{text[0, [limit - 3, kept].max]}..."
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The start of +text+ a cut keeps: an error's class, whole or by its
|
|
206
|
+
# last segment, as +text+ names it.
|
|
207
|
+
def kept_start(member, text)
|
|
208
|
+
return 0 unless member.is_a?(Example) && member.pin.error
|
|
209
|
+
|
|
210
|
+
outcome = @short_outcomes[member.pin]
|
|
211
|
+
text.start_with?(outcome) ? outcome.length : last_segment(outcome).length
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def last_segment(outcome) = outcome.sub(/\Araises (?:\w+::)+/, "raises ")
|
|
215
|
+
|
|
216
|
+
# +steps+ without the facts that tell no sibling apart: the fewest
|
|
217
|
+
# facts, in their order, that each sibling input in +others+ lacks
|
|
218
|
+
# one of. A context keeps at least one fact.
|
|
219
|
+
def telling(steps, others)
|
|
220
|
+
facts = steps.select { |step| step.kind == :fact }
|
|
221
|
+
left = others.reject { |pin| (facts - steps_of(pin)).empty? }
|
|
222
|
+
chosen = []
|
|
223
|
+
until left.empty?
|
|
224
|
+
best = facts.max_by { |fact| [left.count { |pin| !steps_of(pin).include?(fact) }, -facts.index(fact)] }
|
|
225
|
+
break if best.nil? || chosen.include?(best)
|
|
226
|
+
|
|
227
|
+
chosen << best
|
|
228
|
+
left.reject! { |pin| !steps_of(pin).include?(best) }
|
|
229
|
+
end
|
|
230
|
+
chosen = facts.first(1) if chosen.empty? && steps.none? { |step| step.kind != :fact }
|
|
231
|
+
steps.reject { |step| step.kind == :fact && !chosen.include?(step) }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# A text still over LIMIT. An example cuts its outcome, which leads,
|
|
235
|
+
# and keeps whole the facts that tell it from its siblings, while the
|
|
236
|
+
# outcome keeps a start to read; otherwise the end is cut.
|
|
237
|
+
# An error's class is never cut, since a reader looks it up, but it
|
|
238
|
+
# gives way to its last segment before the facts after it are cut.
|
|
239
|
+
def fitted(member, steps, peers)
|
|
240
|
+
text = member_text(member, steps, peers, short: true)
|
|
241
|
+
facts = label(steps, peers, short: true)
|
|
242
|
+
if member.is_a?(Example) && member.pin.error
|
|
243
|
+
outcome = @short_outcomes[member.pin]
|
|
244
|
+
last = last_segment(outcome)
|
|
245
|
+
return cut_after("#{last}#{text[outcome.length..]}", last.length)
|
|
246
|
+
end
|
|
247
|
+
room = LIMIT - facts.length - " ...".length
|
|
248
|
+
return "#{text[0, LIMIT - 3]}..." unless member.is_a?(Example) && !facts.empty? && room >= 12
|
|
249
|
+
|
|
250
|
+
"#{@short_outcomes[member.pin][0, room]}... #{facts}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def member_text(member, steps, peers, short: false)
|
|
254
|
+
text = label(steps, peers, short: short)
|
|
255
|
+
return text unless member.is_a?(Example)
|
|
256
|
+
|
|
257
|
+
outcome = (short ? @short_outcomes : @outcomes)[member.pin]
|
|
258
|
+
text.empty? ? outcome : "#{outcome} #{text}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# A text as a literal that needs no escape, so it reads as it prints:
|
|
262
|
+
# 'when kind is "int"', or %q(...) when it holds both quotes.
|
|
263
|
+
def quoted(text)
|
|
264
|
+
plain = text.inspect
|
|
265
|
+
# Single quotes and %q read only a doubled backslash or their own
|
|
266
|
+
# delimiter as an escape, so a text that a value's own literal put
|
|
267
|
+
# a backslash in still reads as written.
|
|
268
|
+
literal = text.match?(/\A[[:print:]]*\z/) && !text.include?("\\\\") && !text.end_with?("\\")
|
|
269
|
+
return plain unless text.include?('"') && literal
|
|
270
|
+
return "'#{text}'" unless text.include?("'")
|
|
271
|
+
return "%q(#{text})" unless text.match?(/[()]/)
|
|
272
|
+
|
|
273
|
+
plain
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def pins_of(member)
|
|
277
|
+
member.is_a?(Example) ? [member.pin] : member.members.flat_map { |inner| pins_of(inner) }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# "with new(10) after add("apple", 3) when price is 5": the receiver,
|
|
281
|
+
# the calls, then the facts.
|
|
282
|
+
def label(steps, peers = [], short: false)
|
|
283
|
+
receiver = steps.select { |step| step.kind == :let }.map { |step| receiver_step_label(step, peers) }
|
|
284
|
+
calls = steps.select { |step| CALLS.include?(step.kind) }.map { |step| call_label(step, peers, short: short) }
|
|
285
|
+
facts = steps.select { |step| step.kind == :fact }.map { |step| fact_label(step.leaf, peers, short: short) }
|
|
286
|
+
parts = receiver
|
|
287
|
+
parts += ["after #{series(calls)}"] unless calls.empty?
|
|
288
|
+
parts += ["when #{series(facts)}"] unless facts.empty?
|
|
289
|
+
parts.join(" ")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# An input's before that calls the method a sibling's before calls,
|
|
293
|
+
# with other keywords, is named by the keywords that differ:
|
|
294
|
+
# create(:part) with size: 1. A before of several statements is
|
|
295
|
+
# named by the statements that differ from a sibling's of as many
|
|
296
|
+
# statements, each in that way. Any other step is named by its
|
|
297
|
+
# source, on one line. +short+ keeps only the keywords and block that tell it
|
|
298
|
+
# from each sibling, as a fact is kept.
|
|
299
|
+
def call_label(step, peers, short: false)
|
|
300
|
+
return step.label unless step.leaf
|
|
301
|
+
|
|
302
|
+
mine = Support.statements(step.leaf[1])
|
|
303
|
+
others = peers.filter_map { |pin| pin.input.before }.uniq.reject { |code| code == step.leaf[1] }
|
|
304
|
+
.map { |code| Support.statements(code) }.select { |statements| statements.length == mine.length }
|
|
305
|
+
telling = mine.each_index.select { |index| others.any? { |other| other[index] != mine[index] } }
|
|
306
|
+
return step.label if telling.empty? || telling.length == mine.length && mine.length > 1
|
|
307
|
+
|
|
308
|
+
series(telling.map { |index| keyword_label(mine[index], others.map { |other| other[index] }, short: short) }).gsub(/\s*\n\s*/, " ")
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def keyword_label(code, others, short: false)
|
|
312
|
+
head, *keywords = Support.call_parts(code)
|
|
313
|
+
others = others.reject { |other| other == code }.map { |other| Support.call_parts(other) }
|
|
314
|
+
.select { |parts| parts.first == head }.map { |parts| parts.drop(1).to_h }
|
|
315
|
+
tells = ->((key, value), other) { !other.key?(key) || !other[key].eql?(value) }
|
|
316
|
+
differing = keywords.select { |keyword| others.any? { |other| tells.(keyword, other) } }
|
|
317
|
+
return code.source if differing.empty?
|
|
318
|
+
|
|
319
|
+
if short
|
|
320
|
+
left = others.select { |other| differing.any? { |keyword| tells.(keyword, other) } }
|
|
321
|
+
chosen = []
|
|
322
|
+
until left.empty?
|
|
323
|
+
best = differing.max_by { |keyword| [left.count { |other| tells.(keyword, other) }, -differing.index(keyword)] }
|
|
324
|
+
chosen << best
|
|
325
|
+
left.reject! { |other| tells.(best, other) }
|
|
326
|
+
end
|
|
327
|
+
differing &= chosen
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
block, keywords = differing.partition { |key, _| key.is_a?(Support::TapBlock) }
|
|
331
|
+
texts = keywords.map { |key, value| "#{key}: #{value.is_a?(Code) ? value.source : value_text(value, [])}" }
|
|
332
|
+
tapped = block.map { |key, value| ".#{key.name} #{block_part(value.source, others.filter_map { |other| other[key]&.source }, short: short)}" }.join
|
|
333
|
+
return "#{head[1].source}#{tapped}" if texts.empty?
|
|
334
|
+
|
|
335
|
+
"#{head[1].source} with #{texts.join(", ")}#{" and #{tapped}" unless tapped.empty?}"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def series(words)
|
|
339
|
+
words.length < 2 ? words.join : "#{words[0..-2].join(", ")} and #{words.last}"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Groups +entries+ by their first step, in the order they appear. An
|
|
343
|
+
# entry left with only facts, or none, is an example; the rest share
|
|
344
|
+
# a node. Examples come before nodes.
|
|
345
|
+
def build(steps, entries)
|
|
346
|
+
members = []
|
|
347
|
+
groups = entries.group_by { |_, rest| rest.first }
|
|
348
|
+
groups.each do |first, group|
|
|
349
|
+
if group.one? && group.first[1].none? { |step| CONTEXT_STEPS.include?(step.kind) }
|
|
350
|
+
pin, rest = group.first
|
|
351
|
+
members << Example.new(pin, steps_of(pin), rest)
|
|
352
|
+
elsif first.nil?
|
|
353
|
+
group.each { |pin, _| members << Example.new(pin, steps_of(pin), []) }
|
|
354
|
+
else
|
|
355
|
+
members << merged(build([first], group.map { |pin, rest| [pin, rest.drop(1)] }))
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
Node.new(steps, members.partition { |member| member.is_a?(Example) }.flatten)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# A node whose one member is a node takes that node's steps and
|
|
362
|
+
# members, so a chain of single contexts reads as one.
|
|
363
|
+
def merged(node)
|
|
364
|
+
while node.members.one? && node.members.first.is_a?(Node)
|
|
365
|
+
node = Node.new(node.steps + node.members.first.steps, node.members.first.members)
|
|
366
|
+
end
|
|
367
|
+
node
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def steps_of(pin)
|
|
371
|
+
@steps ||= {}
|
|
372
|
+
@steps[pin] ||= begin
|
|
373
|
+
input = pin.input
|
|
374
|
+
steps = []
|
|
375
|
+
steps << Step.new(:before, Support.statements(input.before).map { |code| code.source.gsub(/\s*\n\s*/, " ") }.join("; "), input.before.source, [[:before], input.before]) if input.before
|
|
376
|
+
unless @target.singleton
|
|
377
|
+
steps << Step.new(:let, receiver_label(input), Support.receiver_text(input, @const, @private_calls, thawed: pin.thawed),
|
|
378
|
+
([[:new], input.receiver.to_h] if input.receiver.is_a?(Input)))
|
|
379
|
+
end
|
|
380
|
+
# A setup call that may be the one that raised stays inside the
|
|
381
|
+
# expect block, so the example still pins the error.
|
|
382
|
+
kind = pin.error ? :inline : :before
|
|
383
|
+
input.setup.each do |call|
|
|
384
|
+
plain = Support.argument_list(call.arguments)
|
|
385
|
+
code = Support.call_text(receiver_expression, call.method_name, Support.argument_list(call.arguments, thawed: pin.thawed), [])
|
|
386
|
+
steps << Step.new(kind, "#{call.method_name}#{"(#{plain})" unless plain.empty?}", code)
|
|
387
|
+
end
|
|
388
|
+
steps + fact_steps(input)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# A fact names its leaf by a path into the argument (args[0].value),
|
|
393
|
+
# and an Array of scalars as a whole (items is [1, 2]). When every
|
|
394
|
+
# input passes one argument and no keyword, a path that starts at a
|
|
395
|
+
# key inside that argument says enough, and the parameter's name is
|
|
396
|
+
# left out; a path that starts at a position keeps it (items[0].sku).
|
|
397
|
+
def fact_steps(input)
|
|
398
|
+
names = parameter_names
|
|
399
|
+
leaves = []
|
|
400
|
+
input.args.each_with_index do |arg, index|
|
|
401
|
+
Support.collect_leaves(arg, [names[index] || "argument #{index + 1}"], leaves, whole_arrays: true)
|
|
402
|
+
end
|
|
403
|
+
input.kwargs.each { |key, value| Support.collect_leaves(value, [key], leaves, whole_arrays: true) }
|
|
404
|
+
leaves << [["block"], input.block] if input.block
|
|
405
|
+
leaves.map { |leaf| Step.new(:fact, fact_label(leaf), nil, leaf) }
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# +short+ names the leaf from its deepest key, as a minitest name
|
|
409
|
+
# does, for a text that is still too long: status is "failed". A key
|
|
410
|
+
# that ends another leaf's path too keeps the keys above it. A path
|
|
411
|
+
# that would start at a bracket names the argument and the literal
|
|
412
|
+
# that holds the leaf, doc holds "status" => "failed", since
|
|
413
|
+
# doc["status"] would name another leaf.
|
|
414
|
+
def fact_label(leaf, peers = [], short: false)
|
|
415
|
+
(root, *path), value = leaf
|
|
416
|
+
return "#{Support.path_text(path, root)} #{block_part(value.source, peer_values(leaf, peers).map(&:source), short: short)}" if path.last.is_a?(Support::TapBlock)
|
|
417
|
+
|
|
418
|
+
value = value_text(value, peer_values(leaf, peers))
|
|
419
|
+
telling = Support.telling_path(path, peer_paths(root, peers)) if short && path.any? { |step| Support.key_step?(step) }
|
|
420
|
+
if telling && telling.length < path.length
|
|
421
|
+
return "#{root} holds #{Support.entries_text([[telling, value]])}" if Support.path_text(telling).start_with?("[")
|
|
422
|
+
|
|
423
|
+
return "#{Support.path_text(telling)} is #{value}"
|
|
424
|
+
end
|
|
425
|
+
name = Support.path_text(path)
|
|
426
|
+
name = Support.path_text(path, root) if !@one_argument || name.empty? || name.start_with?("[")
|
|
427
|
+
"#{name} is #{value}"
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# A block's source, or with +short+, a block over 30 characters cut
|
|
431
|
+
# where it parts from its siblings' blocks, after its opening:
|
|
432
|
+
# { ...long piece', 2]) }.
|
|
433
|
+
def block_part(source, peer_sources, short: false)
|
|
434
|
+
return source unless short && source.length > 30
|
|
435
|
+
|
|
436
|
+
text = parted(source, peer_sources)
|
|
437
|
+
text.start_with?("...") ? "#{source[/\A(?:\{|do)/]} #{text}" : text
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# A value longer than 30 characters is cut. A String is cut where it
|
|
441
|
+
# parts from the other Strings at its leaf, so two values that share
|
|
442
|
+
# a long start do not read alike.
|
|
443
|
+
def value_text(value, peer_values)
|
|
444
|
+
value = Support.described(value)
|
|
445
|
+
peer_values = peer_values.map { |other| Support.described(other) }
|
|
446
|
+
text = Support.ruby_literal(value)
|
|
447
|
+
return text if text.length <= 30
|
|
448
|
+
return "#{text[0, 27]}..." unless value.is_a?(String)
|
|
449
|
+
|
|
450
|
+
Support.ruby_literal(parted(value, peer_values.grep(String)))
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# +value+ cut to about 25 characters around where it parts from the
|
|
454
|
+
# most alike of +peer_values+, with "..." for each cut end.
|
|
455
|
+
def parted(value, peer_values)
|
|
456
|
+
parts = peer_values.reject { |other| other == value }.map do |other|
|
|
457
|
+
value.each_char.zip(other.each_char).take_while { |mine, theirs| mine == theirs }.length
|
|
458
|
+
end
|
|
459
|
+
start = (parts.max || 0) < 20 ? 0 : parts.max - 10
|
|
460
|
+
# Start at a word or a path segment when one begins shortly before.
|
|
461
|
+
separator = value.rindex(%r{[\s/]}, start)
|
|
462
|
+
start = separator + 1 if start.positive? && separator && start - separator <= 12
|
|
463
|
+
# An end "..." that would stand for no more than its own three
|
|
464
|
+
# characters keeps the end instead.
|
|
465
|
+
length = value.length - start <= 25 ? 25 : 22
|
|
466
|
+
finish = Support.word_end(value, start, start + length)
|
|
467
|
+
"#{"..." if start.positive?}#{value[start...finish]}#{"..." if finish < value.length}"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def peer_paths(root, peers)
|
|
471
|
+
peers.flat_map { |pin| steps_of(pin).filter_map { |step| step.leaf[0].drop(1) if step.kind == :fact && step.leaf[0][0] == root } }.uniq
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def peer_values(leaf, peers)
|
|
475
|
+
peers.flat_map { |pin| steps_of(pin).filter_map { |step| step.leaf[1] if step.kind == :fact && step.leaf[0] == leaf[0] } }
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def parameter_names
|
|
479
|
+
return [] unless @target.respond_to?(:positional) && @target.parameters
|
|
480
|
+
|
|
481
|
+
@target.positional.map { |param| param.name.to_s }
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# "with new(10)", or "with create(:loan)" for a receiver the input builds.
|
|
485
|
+
def receiver_label(input)
|
|
486
|
+
return "with #{input.receiver.source}" if input.receiver.is_a?(Code)
|
|
487
|
+
|
|
488
|
+
plain = input.receiver ? Support.argument_list(input.receiver) : ""
|
|
489
|
+
plain.empty? ? "with new" : "with new(#{plain})"
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# A receiver whose new takes the arguments a sibling's new takes,
|
|
493
|
+
# with other values at some keywords, is named by those keywords:
|
|
494
|
+
# with start_at: Date.new(2026, 5, 1). Any other is named by new.
|
|
495
|
+
def receiver_step_label(step, peers)
|
|
496
|
+
return step.label unless step.leaf
|
|
497
|
+
|
|
498
|
+
mine = step.leaf[1]
|
|
499
|
+
others = peers.map { |pin| pin.input.receiver }.grep(Input).map(&:to_h).uniq
|
|
500
|
+
.select { |other| other["args"] == mine["args"] && other["kwargs"].keys.sort == mine["kwargs"].keys.sort && other != mine }
|
|
501
|
+
differing = mine["kwargs"].select { |key, value| others.any? { |other| !other["kwargs"][key].eql?(value) } }
|
|
502
|
+
return step.label if differing.empty?
|
|
503
|
+
|
|
504
|
+
"with #{differing.map { |key, value| "#{Support.keyword_literal(key)}: #{value.is_a?(Code) ? value.source : value_text(value, [])}" }.join(", ")}"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def receiver_expression
|
|
508
|
+
@target.singleton ? @const : @receiver
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def entry_call(input = nil, thawed = false)
|
|
512
|
+
arguments = input ? Support.argument_list(input, thawed: thawed) : ""
|
|
513
|
+
Support.with_block(Support.call_text(receiver_expression, @target.method_name, arguments, @private_calls), input&.block)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def receiver_name
|
|
517
|
+
Support.built_receiver_name(@target)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# The method's name, when it reads as a plain local name; false for
|
|
521
|
+
# an unnamed subject.
|
|
522
|
+
def subject_name
|
|
523
|
+
name = @target.method_name.to_s
|
|
524
|
+
return false unless name.match?(/\A[a-z_][a-z0-9_]*[?!]?\z/)
|
|
525
|
+
return false if name == @receiver || TAKEN_NAMES.include?(name) || Support::RESERVED_NAMES.include?(name)
|
|
526
|
+
|
|
527
|
+
name
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|