actionable-rb 1.9.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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.claude-plugin/marketplace.json +15 -0
  3. data/.claude-plugin/plugin.json +9 -0
  4. data/.rspec +3 -0
  5. data/AGENTS.md +150 -0
  6. data/CHANGELOG.md +766 -0
  7. data/CODE_OF_CONDUCT.md +132 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +555 -0
  10. data/UPGRADING.md +957 -0
  11. data/USAGE.md +1291 -0
  12. data/docs/getting_started.md +611 -0
  13. data/lib/actionable/action.rb +1220 -0
  14. data/lib/actionable/batch_result.rb +92 -0
  15. data/lib/actionable/errors.rb +36 -0
  16. data/lib/actionable/history.rb +91 -0
  17. data/lib/actionable/input_dispatch.rb +94 -0
  18. data/lib/actionable/job.rb +157 -0
  19. data/lib/actionable/measurement.rb +64 -0
  20. data/lib/actionable/rails/after_commit.rb +142 -0
  21. data/lib/actionable/rails/proxy_validator.rb +59 -0
  22. data/lib/actionable/rails/transactions.rb +95 -0
  23. data/lib/actionable/rails.rb +24 -0
  24. data/lib/actionable/railtie.rb +10 -0
  25. data/lib/actionable/rbs.rb +502 -0
  26. data/lib/actionable/registry.rb +64 -0
  27. data/lib/actionable/result.rb +166 -0
  28. data/lib/actionable/results/failure.rb +43 -0
  29. data/lib/actionable/results/skipped.rb +30 -0
  30. data/lib/actionable/results/success.rb +14 -0
  31. data/lib/actionable/rspec/coverage/report.rb +264 -0
  32. data/lib/actionable/rspec/coverage/tracker.rb +330 -0
  33. data/lib/actionable/rspec/coverage.rb +45 -0
  34. data/lib/actionable/rspec/matchers.rb +266 -0
  35. data/lib/actionable/rspec/stubs.rb +250 -0
  36. data/lib/actionable/rspec.rb +40 -0
  37. data/lib/actionable/rubocop/config.yml +8 -0
  38. data/lib/actionable/rubocop/cop/actionable/verb_not_last.rb +62 -0
  39. data/lib/actionable/rubocop/plugin.rb +31 -0
  40. data/lib/actionable/rubocop.rb +25 -0
  41. data/lib/actionable/runner.rb +300 -0
  42. data/lib/actionable/steps/action.rb +76 -0
  43. data/lib/actionable/steps/base.rb +107 -0
  44. data/lib/actionable/steps/case.rb +124 -0
  45. data/lib/actionable/steps/method.rb +39 -0
  46. data/lib/actionable/steps.rb +27 -0
  47. data/lib/actionable/value_match.rb +22 -0
  48. data/lib/actionable/verb_position.rb +574 -0
  49. data/lib/actionable/version.rb +6 -0
  50. data/lib/actionable-rb.rb +6 -0
  51. data/lib/actionable.rb +59 -0
  52. data/sig/actionable/dsl.rbs +42 -0
  53. data/sig/actionable/rails.rbs +16 -0
  54. data/sig/actionable.rbs +2593 -0
  55. data/skills/actionable/SKILL.md +214 -0
  56. metadata +132 -0
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'oj'
5
+ require_relative '../../../actionable'
6
+
7
+ module Actionable
8
+ # Outcome coverage for a test suite (decision D22). While enabled, every real
9
+ # +.run+ of a named action class is recorded as +[class name, outcome, code]+,
10
+ # where outcome is +:success+, +:failure+ or +:skipped+. At the end of the
11
+ # suite those observations are compared with the outcomes each action can
12
+ # end with (its declarations, read at run time), and the outcomes no spec
13
+ # reached are reported.
14
+ #
15
+ # Loaded by +require 'actionable/rspec/coverage'+, which also installs the
16
+ # suite hook. Nothing here is loaded by the core.
17
+ module Coverage
18
+ # Prepended onto {Actionable::Action}'s singleton class, so every
19
+ # subclass's +.run+ (nested {Steps::Action} runs and {Action.run_each}
20
+ # included) passes through it. An RSpec stub of +.run+ defines the method
21
+ # on the subclass itself, ahead of this module, so a stubbed run is never
22
+ # recorded as the action being tested.
23
+ module Recorder
24
+ # Run the action and record the result's outcome.
25
+ #
26
+ # @return [Result]
27
+ def run(*, **)
28
+ result = super
29
+ Coverage.record(self, result)
30
+ result
31
+ end
32
+ end
33
+
34
+ @mutex = Mutex.new
35
+ @observed = Set.new
36
+ @stubbed = Set.new
37
+ @enabled = false
38
+
39
+ # The default {.new_gap_hint}.
40
+ DEFAULT_NEW_GAP_HINT = 'Write a spec that reaches each one, or add it to the baseline:'
41
+
42
+ class << self
43
+ # The class whose named descendants are reported. Defaults to
44
+ # {Actionable::Action}.
45
+ # @return [Class<Actionable::Action>]
46
+ attr_accessor :root
47
+
48
+ # Name filters for the report. A String matches as a name prefix, a
49
+ # Regexp by +match?+. With any include filter, only matching names are
50
+ # reported; an exclude filter drops matching names.
51
+ # @return [Array<String, Regexp>]
52
+ attr_accessor :include_prefixes, :exclude_prefixes
53
+
54
+ # Called before the actions are listed, so a host can load every action
55
+ # class first (+-> { Rails.application.eager_load! }+). Unloaded classes
56
+ # cannot be reported.
57
+ # @return [#call, nil]
58
+ attr_accessor :loader
59
+
60
+ # When true, report only actions with at least one real run, so a
61
+ # partial or targeted run doesn't flag every action it never touched.
62
+ # @return [Boolean]
63
+ attr_accessor :only_observed
64
+
65
+ # Path to the committed baseline of known gaps, a JSON or YAML file of
66
+ # +{"Action::Name" => [outcome keys]}+. With one set, the report fails
67
+ # on a gap the baseline doesn't list and on a stale entry; see
68
+ # {Report#baseline_problems}. +nil+ (the default) never fails.
69
+ # @return [String, nil]
70
+ attr_accessor :baseline
71
+
72
+ # The advice after "New untested outcomes." in {Report#baseline_message}.
73
+ # The default offers both fixes; a host whose baseline only shrinks can
74
+ # drop the second, e.g. +"Write a spec that reaches each one."+
75
+ # @return [String]
76
+ attr_accessor :new_gap_hint
77
+
78
+ # @return [Array<Symbol>] the gem failure codes counted as expected
79
+ # outcomes; see {.gem_codes=}
80
+ attr_reader :gem_codes
81
+
82
+ # Count some of {Actionable::Action::GEM_FAILURE_CODES} as expected
83
+ # outcomes. +:invalid_input+ counts for an action whose input has a
84
+ # required field; +:invalid_output+ for one whose output has a required
85
+ # field. Defaults to none, so an adopter opts in.
86
+ #
87
+ # @param codes [Array<Symbol, String>]
88
+ # @raise [ArgumentError] for a code the gem never emits
89
+ # @return [void]
90
+ def gem_codes=(codes)
91
+ codes = Array(codes).map(&:to_sym)
92
+ unknown = codes - Actionable::Action::GEM_FAILURE_CODES
93
+ unless unknown.empty?
94
+ raise ArgumentError,
95
+ "not a gem failure code: #{unknown.map(&:inspect).join(", ")} " \
96
+ "(the gem emits #{Actionable::Action::GEM_FAILURE_CODES.map(&:inspect).join(", ")})"
97
+ end
98
+
99
+ @gem_codes = codes
100
+ end
101
+
102
+ # Restore every setting to its default.
103
+ #
104
+ # @return [void]
105
+ def reset_config!
106
+ @root = Actionable::Action
107
+ @include_prefixes = []
108
+ @exclude_prefixes = []
109
+ @loader = nil
110
+ @gem_codes = []
111
+ @only_observed = false
112
+ @baseline = nil
113
+ @new_gap_hint = DEFAULT_NEW_GAP_HINT
114
+ end
115
+
116
+ # The outcomes +action_class+ can end with, as outcome keys:
117
+ # +"success"+, +"failure:<code>"+ for each declared failure (D20) and
118
+ # +"skipped:<code>"+ for each declared skip (D21), plus any configured
119
+ # {.gem_codes} that apply. Read from the declarations at run time.
120
+ #
121
+ # @param action_class [Class<Actionable::Action>]
122
+ # @return [Array<String>]
123
+ def expected_outcomes(action_class)
124
+ keys = ['success']
125
+ keys.concat(action_class.declared_failures.keys.map { |code| "failure:#{code}" })
126
+ keys.concat(action_class.declared_skips.keys.map { |code| "skipped:#{code}" })
127
+ gem_codes.each { |code| keys << "failure:#{code}" if gem_code_applies?(action_class, code) }
128
+ keys.uniq
129
+ end
130
+
131
+ # The action classes to report: named descendants of {.root} that have
132
+ # steps, whose name still resolves to the class, filtered by
133
+ # {.include_prefixes} and {.exclude_prefixes}. Calls {.loader} first.
134
+ #
135
+ # @return [Array<Class<Actionable::Action>>] sorted by name
136
+ def actions
137
+ loader&.call
138
+ descendants(root).select { |klass| reportable?(klass) }.sort_by(&:name)
139
+ end
140
+
141
+ # The outcome key an observation counts toward: +"success"+, or
142
+ # +"<outcome>:<code>"+.
143
+ #
144
+ # @param outcome [Symbol, String]
145
+ # @param code [Symbol, String]
146
+ # @return [String]
147
+ def outcome_key(outcome, code)
148
+ outcome.to_s == 'success' ? 'success' : "#{outcome}:#{code}"
149
+ end
150
+
151
+ # Start recording runs. Installs {Recorder} the first time; safe to call
152
+ # again.
153
+ #
154
+ # @return [void]
155
+ def enable!
156
+ singleton = Actionable::Action.singleton_class
157
+ singleton.prepend(Recorder) unless singleton.ancestors.include?(Recorder)
158
+ @enabled = true
159
+ end
160
+
161
+ # Stop recording runs. {Recorder} stays installed but records nothing.
162
+ #
163
+ # @return [void]
164
+ def disable!
165
+ @enabled = false
166
+ end
167
+
168
+ # @return [Boolean] whether runs are being recorded
169
+ def enabled?
170
+ @enabled
171
+ end
172
+
173
+ # Record one real run. Ignores anonymous classes and anything that isn't
174
+ # a {Result}.
175
+ #
176
+ # @param action_class [Class<Actionable::Action>]
177
+ # @param result [Result]
178
+ # @return [void]
179
+ def record(action_class, result)
180
+ observation = observation_for(action_class, result)
181
+ @mutex.synchronize { @observed << observation } if observation
182
+ end
183
+
184
+ # Record one run answered by the gem's stub helpers
185
+ # (+allow_actionable_success+ and the rest). Kept apart from {.observed}:
186
+ # a stubbed outcome was never produced by the action, so it never counts
187
+ # as tested, but the report shows it.
188
+ #
189
+ # @param action_class [Class<Actionable::Action>]
190
+ # @param result [Result]
191
+ # @return [void]
192
+ def record_stub(action_class, result)
193
+ observation = observation_for(action_class, result)
194
+ @mutex.synchronize { @stubbed << observation } if observation
195
+ end
196
+
197
+ # The runs answered by a gem stub helper so far, deduplicated.
198
+ #
199
+ # @return [Set<Array(String, Symbol, Symbol)>] +[class name, outcome, code]+
200
+ def stubbed
201
+ @mutex.synchronize { @stubbed.dup }
202
+ end
203
+
204
+ # The real runs recorded so far, deduplicated.
205
+ #
206
+ # @return [Set<Array(String, Symbol, Symbol)>] +[class name, outcome, code]+
207
+ def observed
208
+ @mutex.synchronize { @observed.dup }
209
+ end
210
+
211
+ # Write the runs recorded so far to +path+ as JSON, for a sharded suite:
212
+ # each shard writes its own file and one {Report} merges them. Creates
213
+ # the directory.
214
+ #
215
+ # @param path [String]
216
+ # @return [String] +path+
217
+ def write(path)
218
+ FileUtils.mkdir_p(File.dirname(path))
219
+ data = {version: 1, observed: observed.to_a.sort, stubbed: stubbed.to_a.sort}
220
+ File.write(path, Oj.dump(data, mode: :compat))
221
+ path
222
+ end
223
+
224
+ # Read a file written by {.write}.
225
+ #
226
+ # @param path [String]
227
+ # @return [Hash{Symbol=>Set<Array(String, Symbol, Symbol)>}] +{observed:, stubbed:}+
228
+ def read(path)
229
+ data = Oj.load(File.read(path), mode: :compat)
230
+ %w[observed stubbed].to_h do |set|
231
+ [set.to_sym, Array(data[set]).to_set { |name, outcome, code| [name, outcome.to_sym, code.to_sym] }]
232
+ end
233
+ end
234
+
235
+ # Forget every recorded run, real and stubbed.
236
+ #
237
+ # @return [void]
238
+ def reset!
239
+ @mutex.synchronize do
240
+ @observed.clear
241
+ @stubbed.clear
242
+ end
243
+ end
244
+
245
+ private
246
+
247
+ # @return [Array<Class>] every subclass below +klass+, recursively
248
+ def descendants(klass)
249
+ klass.subclasses.flat_map { |subclass| [subclass, *descendants(subclass)] }
250
+ end
251
+
252
+ # Abstract actions are left out: an anonymous class has no name to
253
+ # report, and an action with no steps is a base class. A class whose
254
+ # name no longer resolves to it (a removed +stub_const+, a reloaded
255
+ # constant) is left out too.
256
+ def reportable?(klass)
257
+ name = klass.name
258
+ return false if name.nil? || klass.steps.empty?
259
+ return false unless resolves_to?(name, klass)
260
+ return false if include_prefixes.any? && include_prefixes.none? { |pattern| matches?(pattern, name) }
261
+
262
+ exclude_prefixes.none? { |pattern| matches?(pattern, name) }
263
+ end
264
+
265
+ def resolves_to?(name, klass)
266
+ Object.const_get(name).equal?(klass)
267
+ rescue NameError
268
+ false
269
+ end
270
+
271
+ def matches?(pattern, name)
272
+ pattern.is_a?(Regexp) ? pattern.match?(name) : name.start_with?(pattern.to_s)
273
+ end
274
+
275
+ def gem_code_applies?(action_class, code)
276
+ case code
277
+ when :invalid_input then input_can_be_invalid?(action_class)
278
+ when :invalid_output then output_schemas(action_class).any? { |schema| requires_field?(schema) }
279
+ end
280
+ end
281
+
282
+ # A fixed input with a required field, or a discriminated input with no
283
+ # default shape (an unmatched value fails) or a shape with one.
284
+ def input_can_be_invalid?(action_class)
285
+ return requires_field?(action_class.input_schema) if action_class.input_schema
286
+
287
+ dispatch = action_class.input_dispatch
288
+ return false unless dispatch
289
+
290
+ dispatch.default_schema.nil? || dispatch.schemas.any? { |schema| requires_field?(schema) }
291
+ end
292
+
293
+ # Every output schema a run can be held to: the success's, and each skip
294
+ # or failure code's (decision D23), any of which can end it as
295
+ # +invalid_output+.
296
+ def output_schemas(action_class)
297
+ skips = action_class.skip_declarations.keys.map { |code| action_class.skip_output_schema(code) }
298
+ failures = action_class.failure_declarations.keys.map { |code| action_class.failure_output_schema(code) }
299
+ [action_class.output_schema, *skips, *failures].compact
300
+ end
301
+
302
+ def requires_field?(schema)
303
+ return false unless schema
304
+
305
+ schema.metadata.to_h.values.any? { |field| field[:required] }
306
+ end
307
+
308
+ # @return [Array(String, Symbol, Symbol), nil]
309
+ def observation_for(action_class, result)
310
+ return unless enabled? && result.is_a?(Actionable::Result)
311
+
312
+ name = action_class.name
313
+ return if name.nil?
314
+
315
+ [name, outcome_of(result), result.code.to_sym]
316
+ end
317
+
318
+ # @param result [Result]
319
+ # @return [Symbol] +:success+, +:failure+ or +:skipped+
320
+ def outcome_of(result)
321
+ if result.failure? then :failure
322
+ elsif result.skipped? then :skipped
323
+ else :success
324
+ end
325
+ end
326
+ end
327
+
328
+ reset_config!
329
+ end
330
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional outcome coverage for an RSpec suite (decision D22). Load it with
4
+ # `require 'actionable/rspec/coverage'`, typically from spec/support. Never
5
+ # loaded by the core or by `actionable/rspec`.
6
+ #
7
+ # Requiring it starts recording every real action run and, when RSpec is
8
+ # loaded, adds an end-of-suite hook: it writes the runs to the file named by
9
+ # +ACTIONABLE_COVERAGE_FILE+ when that is set (one shard of a sharded suite),
10
+ # and otherwise prints the report and holds it to {Actionable::Coverage.baseline}.
11
+ require_relative 'coverage/tracker'
12
+ require_relative 'coverage/report'
13
+
14
+ module Actionable
15
+ module Coverage
16
+ class << self
17
+ # Start recording and, under RSpec, add the end-of-suite hook once.
18
+ #
19
+ # @return [void]
20
+ def install!
21
+ enable!
22
+ return if @installed || !defined?(::RSpec) || !::RSpec.respond_to?(:configure)
23
+
24
+ @installed = true
25
+ ::RSpec.configure { |config| config.after(:suite) { Actionable::Coverage.finish_suite! } }
26
+ end
27
+
28
+ # The end-of-suite step: write this process's runs to
29
+ # +ACTIONABLE_COVERAGE_FILE+ when it is set, since one shard can't judge
30
+ # the whole suite, and otherwise {.report!}.
31
+ #
32
+ # @param io [IO] where the text report goes
33
+ # @return [Report, String] the report, or the shard file's path
34
+ def finish_suite!(io: $stdout)
35
+ path = ENV['ACTIONABLE_COVERAGE_FILE'].to_s
36
+ return write(path) unless path.empty?
37
+
38
+ io.puts # the progress formatter leaves its line open
39
+ report!(io: io)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ Actionable::Coverage.install!
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Actionable
4
+ module RSpec
5
+ # RSpec matcher methods mixed into example groups by +actionable/rspec+.
6
+ module Matchers
7
+ # Run an action and assert on its outcome.
8
+ #
9
+ # expect(CreateInvoice).to perform_actionable(amount: 5).and_succeed
10
+ # expect(CreateInvoice).to perform_actionable(bad).and_fail(:invalid_input)
11
+ # expect(Boom).to perform_actionable.and_raise(RuntimeError)
12
+ #
13
+ # @param args [Object] positional args forwarded to +.run+
14
+ # @param kwargs [Object] keyword args forwarded to +.run+
15
+ # @yieldparam result [Result, nil] the run's result (+nil+ when it raised)
16
+ # @yieldparam exception [StandardError, nil] what the run raised, if anything
17
+ # @yieldreturn [Object] truthy when the extra assertions hold
18
+ # @return [PerformActionable]
19
+ def perform_actionable(*args, **kwargs, &block)
20
+ PerformActionable.new(args, kwargs, block)
21
+ end
22
+ end
23
+
24
+ # The matcher object returned by {Matchers#perform_actionable}. Runs the
25
+ # action under test and checks the configured outcome (decision D12). Bare
26
+ # +perform_actionable+ expects a success; chain +and_succeed+ / +and_fail+ /
27
+ # +and_raise+ to be explicit. Code and message expectations match with +===+,
28
+ # so exact values, Regexps, and classes all work.
29
+ class PerformActionable
30
+ # Backtrace frames shown under +ACTIONABLE_SHORT_BACKTRACE+ when
31
+ # +ACTIONABLE_BACKTRACE_QTY+ is unset.
32
+ # @return [Integer]
33
+ DEFAULT_BACKTRACE_QTY = 10
34
+
35
+ # @param args [Array<Object>] positional args forwarded to +.run+
36
+ # @param kwargs [Hash{Symbol=>Object}] keyword args forwarded to +.run+
37
+ # @param block [Proc, nil] extra assertions, called with the result and exception
38
+ def initialize(args, kwargs, block)
39
+ @args = args
40
+ @kwargs = kwargs
41
+ @block = block
42
+ @outcome = :success
43
+ end
44
+
45
+ # Expect a successful result, optionally matching +message+.
46
+ # @param message [Object, nil] matched against the result's message with +===+
47
+ # @yieldparam result [Result, nil]
48
+ # @yieldparam exception [StandardError, nil]
49
+ # @yieldreturn [Object] truthy when the extra assertions hold
50
+ # @return [self]
51
+ def and_succeed(message = nil, &block)
52
+ configure(:success, message, &block)
53
+ end
54
+
55
+ # Expect a failed result, optionally matching +code+ and +message+. On an
56
+ # action that declares failures, a Symbol +code+ it doesn't declare raises
57
+ # +ArgumentError+ (decision D20).
58
+ # @param code [Object, nil] matched against the result's code with +==+
59
+ # @param message [Object, nil] matched against the result's message with +===+
60
+ # @yieldparam result [Result, nil]
61
+ # @yieldparam exception [StandardError, nil]
62
+ # @yieldreturn [Object] truthy when the extra assertions hold
63
+ # @return [self]
64
+ def and_fail(code = nil, message = nil, &block)
65
+ @expected_code = code
66
+ configure(:failure, message, &block)
67
+ end
68
+
69
+ # Expect a skipped result, optionally matching the reason +code+ and
70
+ # +message+ (decision D17). On an action that declares skips, a Symbol
71
+ # +code+ it doesn't declare raises +ArgumentError+ (decision D21).
72
+ # @param code [Object, nil] matched against the result's code with +==+
73
+ # @param message [Object, nil] matched against the result's message with +===+
74
+ # @yieldparam result [Result, nil]
75
+ # @yieldparam exception [StandardError, nil]
76
+ # @yieldreturn [Object] truthy when the extra assertions hold
77
+ # @return [self]
78
+ def and_skip(code = nil, message = nil, &block)
79
+ @expected_code = code
80
+ configure(:skip, message, &block)
81
+ end
82
+
83
+ # Expect the run to raise +error_class+, optionally matching +message+.
84
+ # @param error_class [Class<Exception>]
85
+ # @param message [Object, nil] matched against the exception's message with +===+
86
+ # @yieldparam result [Result, nil]
87
+ # @yieldparam exception [StandardError, nil]
88
+ # @yieldreturn [Object] truthy when the extra assertions hold
89
+ # @return [self]
90
+ def and_raise(error_class = StandardError, message = nil, &block)
91
+ @expected_error = error_class
92
+ configure(:raise, message, &block)
93
+ end
94
+
95
+ # @param action_class [Class<Actionable::Action>]
96
+ # @return [Boolean]
97
+ def matches?(action_class)
98
+ @action_class = action_class
99
+ ensure_code_declared!
100
+ perform
101
+ primary_match? && block_match?
102
+ end
103
+
104
+ # @param action_class [Class<Actionable::Action>]
105
+ # @return [Boolean]
106
+ def does_not_match?(action_class)
107
+ @action_class = action_class
108
+ ensure_code_declared!
109
+ perform
110
+ !primary_match?
111
+ end
112
+
113
+ # @return [String]
114
+ def failure_message
115
+ "expected #{@action_class} to #{expectation_phrase}, but #{actual_phrase}"
116
+ end
117
+
118
+ # @return [String]
119
+ def failure_message_when_negated
120
+ "expected #{@action_class} not to #{expectation_phrase}, but it did"
121
+ end
122
+
123
+ # @return [String]
124
+ def description
125
+ "perform actionable and #{@outcome}"
126
+ end
127
+
128
+ private
129
+
130
+ # @param outcome [Symbol] +:success+, +:failure+, +:skip+ or +:raise+
131
+ # @param message [Object, nil]
132
+ # @yieldparam result [Result, nil]
133
+ # @yieldparam exception [StandardError, nil]
134
+ # @yieldreturn [Object] truthy when the extra assertions hold
135
+ # @return [self]
136
+ def configure(outcome, message, &block)
137
+ @outcome = outcome
138
+ @expected_message = message
139
+ @block = block if block
140
+ self
141
+ end
142
+
143
+ # An +and_fail+ or +and_skip+ code the action could never end with is a
144
+ # broken spec, not a failed expectation, so it raises rather than not
145
+ # matching.
146
+ #
147
+ # @raise [ArgumentError] when the action declares the outcome's codes but not this one
148
+ # @return [void]
149
+ def ensure_code_declared!
150
+ return unless @expected_code.is_a?(Symbol)
151
+
152
+ check = {failure: :allows_failure_code?, skip: :allows_skip_code?}[@outcome]
153
+ return unless check && @action_class.respond_to?(check)
154
+ return if @action_class.public_send(check, @expected_code)
155
+
156
+ raise ArgumentError,
157
+ Actionable::RSpec.undeclared_code_message(@action_class, @expected_code, outcome: @outcome)
158
+ end
159
+
160
+ # Run the action, keeping its result or the error it raised.
161
+ # @return [void]
162
+ def perform
163
+ @result = nil
164
+ @exception = nil
165
+ @result = @action_class.run(*@args, **@kwargs)
166
+ rescue StandardError => e
167
+ @exception = e
168
+ end
169
+
170
+ # @return [Boolean, nil]
171
+ def primary_match?
172
+ case @outcome
173
+ when :success then succeeded?
174
+ when :failure then failed?
175
+ when :skip then skipped?
176
+ when :raise then raised?
177
+ end
178
+ end
179
+
180
+ # @return [Boolean, nil]
181
+ def succeeded?
182
+ @exception.nil? && @result&.success? && message_matches?(@result&.message)
183
+ end
184
+
185
+ # @return [Boolean, nil]
186
+ def failed?
187
+ @exception.nil? && @result&.failure? &&
188
+ (@expected_code.nil? || @expected_code == @result.code) &&
189
+ message_matches?(@result&.message)
190
+ end
191
+
192
+ # @return [Boolean, nil]
193
+ def skipped?
194
+ @exception.nil? && @result&.skipped? &&
195
+ (@expected_code.nil? || @expected_code == @result.code) &&
196
+ message_matches?(@result&.message)
197
+ end
198
+
199
+ # @return [Boolean]
200
+ def raised?
201
+ !@exception.nil? && @exception.is_a?(@expected_error) && message_matches?(@exception.message)
202
+ end
203
+
204
+ # @param actual [String, nil]
205
+ # @return [Boolean]
206
+ def message_matches?(actual)
207
+ @expected_message.nil? || @expected_message === actual # rubocop:disable Style/CaseEquality
208
+ end
209
+
210
+ # @return [Boolean]
211
+ def block_match?
212
+ return true unless @block
213
+
214
+ !!@block.call(@result, @exception)
215
+ end
216
+
217
+ # @return [String, nil]
218
+ def expectation_phrase
219
+ case @outcome
220
+ when :success then "succeed#{message_suffix}"
221
+ when :failure then "fail#{code_suffix}#{message_suffix}"
222
+ when :skip then "skip#{code_suffix}#{message_suffix}"
223
+ when :raise then "raise #{@expected_error}#{message_suffix}"
224
+ end
225
+ end
226
+
227
+ # @return [String]
228
+ def code_suffix
229
+ @expected_code ? " with code #{@expected_code.inspect}" : ''
230
+ end
231
+
232
+ # @return [String]
233
+ def message_suffix
234
+ @expected_message ? " with message #{@expected_message.inspect}" : ''
235
+ end
236
+
237
+ # @return [String]
238
+ def actual_phrase
239
+ if @exception
240
+ "it raised #{@exception.class}: #{@exception.message}\n#{formatted_backtrace}"
241
+ elsif @result
242
+ "got #{@result.inspect}"
243
+ else
244
+ 'no result was produced'
245
+ end
246
+ end
247
+
248
+ # @return [String]
249
+ def formatted_backtrace
250
+ frames = @exception.backtrace || []
251
+ frames = frames.first(backtrace_qty) if short_backtrace?
252
+ frames.join("\n")
253
+ end
254
+
255
+ # @return [Boolean]
256
+ def short_backtrace?
257
+ ENV.key?('ACTIONABLE_SHORT_BACKTRACE')
258
+ end
259
+
260
+ # @return [Integer]
261
+ def backtrace_qty
262
+ (ENV['ACTIONABLE_BACKTRACE_QTY'] || DEFAULT_BACKTRACE_QTY).to_i
263
+ end
264
+ end
265
+ end
266
+ end