statecraft 0.5.0 → 0.6.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 +4 -4
- data/README.md +45 -0
- data/lib/statecraft/rspec/allow_event.rb +48 -0
- data/lib/statecraft/rspec/allow_transition_to.rb +71 -0
- data/lib/statecraft/rspec/have_edge.rb +50 -0
- data/lib/statecraft/rspec/have_initial_state.rb +30 -0
- data/lib/statecraft/rspec/have_transitioned_to.rb +44 -0
- data/lib/statecraft/rspec/matchers.rb +41 -0
- data/lib/statecraft/rspec/refuse_event.rb +69 -0
- data/lib/statecraft/rspec/state_report.rb +67 -0
- data/lib/statecraft/rspec/transition.rb +120 -0
- data/lib/statecraft/rspec.rb +24 -0
- data/lib/statecraft/version.rb +1 -1
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4689cea0dee40236473975f52ea5ccd2ecd303783edebd7626ab1518a1f8c8bd
|
|
4
|
+
data.tar.gz: b3713ea5d55ea4881b55b10ceccb38847f2f85aa971d29326fd26e97412d0620
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9690fff2fb62feaaea147a53c63113a77dc93103432ad3f5aa63b5593173d513d2abf83d5d53decf0d6eed7347d94509197f5bb0d03077cdbd5a98f1f20cefd4
|
|
7
|
+
data.tar.gz: cbd7e8d84af570a4c2cb1e51582262860b2d6d0cd7d87ed0304528413ca1d34720dbe6c9b4186fab5a68639c6814243ed2fed32f9774d4985a5f4f5408b45ede
|
data/README.md
CHANGED
|
@@ -260,6 +260,51 @@ belong to the application's presentation layer, not to the machine.
|
|
|
260
260
|
A guard that reads metadata makes `may_*?` depend on the metadata you pass —
|
|
261
261
|
pass the same metadata to `may_*?` that you will collect for `fire!`.
|
|
262
262
|
|
|
263
|
+
## RSpec matchers
|
|
264
|
+
|
|
265
|
+
One opt-in require gives your specs matchers over the whole introspection
|
|
266
|
+
surface — RSpec never becomes a runtime dependency of the gem:
|
|
267
|
+
|
|
268
|
+
<!-- illustrative -->
|
|
269
|
+
```ruby
|
|
270
|
+
# spec_helper.rb, after rspec itself is loaded
|
|
271
|
+
require "statecraft/rspec"
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
<!-- illustrative -->
|
|
275
|
+
```ruby
|
|
276
|
+
# The record-level questions consult the guards, with the same metadata
|
|
277
|
+
# your production call will carry:
|
|
278
|
+
expect(order).to allow_event(:pay).with_metadata("amount" => 100)
|
|
279
|
+
expect(order).to allow_transition_to(:cancelled).via(:cancel)
|
|
280
|
+
expect(order).to allow_transition_to(:archived).directly
|
|
281
|
+
expect(order).to have_transitioned_to(:paid) # strictly log-based
|
|
282
|
+
|
|
283
|
+
# The refusal with its reason — guard names come from refusals_for:
|
|
284
|
+
expect(order).to refuse_event(:cancel).because_of(:customer_cancellable?)
|
|
285
|
+
|
|
286
|
+
# The class-level pair answers the graph's shape; guards stay untouched:
|
|
287
|
+
expect(OrderFlow).to have_edge(:pending, :cancelled).via(:cancel)
|
|
288
|
+
expect(OrderFlow).to have_initial_state(:pending)
|
|
289
|
+
|
|
290
|
+
# The transition itself: the state move AND the appended log row,
|
|
291
|
+
# asserted in one expression around fire!/transition_to!:
|
|
292
|
+
expect { order.fire!(:pay, metadata: { "amount" => 100 }) }
|
|
293
|
+
.to transition(order).from(:pending).to(:paid)
|
|
294
|
+
.via_event(:pay).with_metadata("amount" => 100)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
A failing matcher explains itself with the same introspection the pipeline
|
|
298
|
+
consults: the current state, the edges reachable from it, and the refusing
|
|
299
|
+
guard with its layer. A non-bang call that returned `false` fails the
|
|
300
|
+
`transition` matcher the same way; exceptions of the bang forms fly through
|
|
301
|
+
like with `change` — assert refusals with `refuse_event` or `raise_error`,
|
|
302
|
+
not with the block matcher.
|
|
303
|
+
|
|
304
|
+
`because_of` carries the same honest limit as `refusals_for` underneath it:
|
|
305
|
+
it names record-layer guards only. An input-reading `guard:` has no name
|
|
306
|
+
there, and the failure message says so instead of guessing.
|
|
307
|
+
|
|
263
308
|
## Metadata
|
|
264
309
|
|
|
265
310
|
Metadata is normalized on pipeline entry with a full JSON round-trip —
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# expect(record).to allow_event(:pay).with_metadata("amount" => 5)
|
|
6
|
+
#
|
|
7
|
+
# The can_fire? question, asked with the same metadata the production
|
|
8
|
+
# call will carry. The failure message walks the layers the pipeline
|
|
9
|
+
# walks: is the event declared from this state, and if so, which
|
|
10
|
+
# guards said no.
|
|
11
|
+
class AllowEvent
|
|
12
|
+
def initialize(event_name)
|
|
13
|
+
@event_name = event_name.to_sym
|
|
14
|
+
@metadata = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def with_metadata(metadata)
|
|
18
|
+
@metadata = metadata
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def matches?(record)
|
|
23
|
+
@record = record
|
|
24
|
+
record.can_fire?(@event_name, metadata: @metadata)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def failure_message
|
|
28
|
+
lines = ["expected #{StateReport.standing(@record)} to allow event #{@event_name.inspect}, but it was refused"]
|
|
29
|
+
if StateReport.event_declared?(@record, @event_name)
|
|
30
|
+
lines << StateReport.refusal(@record, @event_name, @metadata)
|
|
31
|
+
else
|
|
32
|
+
lines << "event #{@event_name.inspect} is not declared from #{StateReport.current_state(@record).inspect}"
|
|
33
|
+
lines << StateReport.declared_shape_of(@record)
|
|
34
|
+
end
|
|
35
|
+
lines.join("\n ")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def failure_message_when_negated
|
|
39
|
+
"expected #{StateReport.standing(@record)} not to allow event #{@event_name.inspect}, " \
|
|
40
|
+
"but the guards passed with metadata #{@metadata.inspect}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def description
|
|
44
|
+
"allow event #{@event_name.inspect}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# expect(record).to allow_transition_to(:paid).via(:pay)
|
|
6
|
+
# expect(record).to allow_transition_to(:archived).directly
|
|
7
|
+
#
|
|
8
|
+
# The available_transitions prediction for one target: not only
|
|
9
|
+
# whether the record can go there, but HOW — via lists the events
|
|
10
|
+
# whose guards pass, directly asserts the guard-free direct way.
|
|
11
|
+
class AllowTransitionTo
|
|
12
|
+
def initialize(target_state)
|
|
13
|
+
@target_state = target_state.to_sym
|
|
14
|
+
@expected_via = []
|
|
15
|
+
@directly = false
|
|
16
|
+
@metadata = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def via(*event_names)
|
|
20
|
+
@expected_via = event_names.map(&:to_sym)
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def directly
|
|
25
|
+
@directly = true
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def with_metadata(metadata)
|
|
30
|
+
@metadata = metadata
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def matches?(record)
|
|
35
|
+
@record = record
|
|
36
|
+
@availability = record.available_transitions(metadata: @metadata)
|
|
37
|
+
.find { |availability| availability.to == @target_state }
|
|
38
|
+
return false unless @availability
|
|
39
|
+
|
|
40
|
+
missing_ways.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def failure_message
|
|
44
|
+
unless @availability
|
|
45
|
+
return ["expected #{StateReport.standing(@record)} to reach #{@target_state.inspect}, but it cannot",
|
|
46
|
+
StateReport.reachable(@record, @metadata),
|
|
47
|
+
StateReport.declared_shape_of(@record)].join("\n ")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
"expected the way to #{@target_state.inspect} to include #{missing_ways.map(&:inspect).join(", ")}, " \
|
|
51
|
+
"but it is reachable via #{@availability.via.inspect}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def failure_message_when_negated
|
|
55
|
+
"expected #{StateReport.standing(@record)} not to reach #{@target_state.inspect}, " \
|
|
56
|
+
"but it is reachable via #{@availability.via.inspect}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def description
|
|
60
|
+
"allow a transition to #{@target_state.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def missing_ways
|
|
66
|
+
expected = @expected_via + (@directly ? [:direct] : [])
|
|
67
|
+
expected - @availability.via
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# expect(OrderFlow).to have_edge(:pending, :cancelled).via(:cancel)
|
|
6
|
+
#
|
|
7
|
+
# The class-level shape question of transitions_from: no guards are
|
|
8
|
+
# consulted, exactly like the method itself. via asserts that the
|
|
9
|
+
# named events ride the edge; a bare edge simply has none.
|
|
10
|
+
class HaveEdge
|
|
11
|
+
def initialize(from_state, to_state)
|
|
12
|
+
@from_state = from_state.to_sym
|
|
13
|
+
@to_state = to_state.to_sym
|
|
14
|
+
@expected_events = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def via(*event_names)
|
|
18
|
+
@expected_events = event_names.map(&:to_sym)
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def matches?(machine_class)
|
|
23
|
+
@machine_class = machine_class
|
|
24
|
+
@edge = machine_class.transitions_from(@from_state)
|
|
25
|
+
.find { |descriptor| descriptor[:to] == @to_state }
|
|
26
|
+
@edge && (@expected_events - @edge[:events]).empty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def failure_message
|
|
30
|
+
unless @edge
|
|
31
|
+
return ["expected #{@machine_class.name} to declare an edge #{@from_state.inspect} -> #{@to_state.inspect}",
|
|
32
|
+
StateReport.declared_shape(@machine_class, @from_state)].join("\n ")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
missing = (@expected_events - @edge[:events]).map(&:inspect).join(", ")
|
|
36
|
+
"expected the edge #{@from_state.inspect} -> #{@to_state.inspect} to carry #{missing}, " \
|
|
37
|
+
"but its events are #{@edge[:events].inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def failure_message_when_negated
|
|
41
|
+
"expected #{@machine_class.name} not to declare the edge #{@from_state.inspect} -> #{@to_state.inspect}, " \
|
|
42
|
+
"but it does (events: #{@edge[:events].inspect})"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def description
|
|
46
|
+
"have an edge #{@from_state.inspect} -> #{@to_state.inspect}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# expect(OrderFlow).to have_initial_state(:pending)
|
|
6
|
+
class HaveInitialState
|
|
7
|
+
def initialize(state_name)
|
|
8
|
+
@state_name = state_name.to_sym
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def matches?(machine_class)
|
|
12
|
+
@machine_class = machine_class
|
|
13
|
+
machine_class.initial_state == @state_name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def failure_message
|
|
17
|
+
"expected the initial state of #{@machine_class.name} to be #{@state_name.inspect}, " \
|
|
18
|
+
"but it is #{@machine_class.initial_state.inspect}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def failure_message_when_negated
|
|
22
|
+
"expected the initial state of #{@machine_class.name} not to be #{@state_name.inspect}, but it is"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def description
|
|
26
|
+
"have the initial state #{@state_name.inspect}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# expect(record).to have_transitioned_to(:paid)
|
|
6
|
+
#
|
|
7
|
+
# Strictly log-based, like transitioned_to? itself: the question is
|
|
8
|
+
# about history, never about the current state. The failure message
|
|
9
|
+
# shows what the log actually holds.
|
|
10
|
+
class HaveTransitionedTo
|
|
11
|
+
def initialize(state_name)
|
|
12
|
+
@state_name = state_name.to_sym
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def matches?(record)
|
|
16
|
+
@record = record
|
|
17
|
+
record.transitioned_to?(@state_name)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def failure_message
|
|
21
|
+
"expected the log of #{StateReport.standing(@record)} to hold a transition to #{@state_name.inspect}, " \
|
|
22
|
+
"but #{log_contents}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def failure_message_when_negated
|
|
26
|
+
"expected the log of #{StateReport.standing(@record)} to hold no transition to #{@state_name.inspect}, " \
|
|
27
|
+
"but it does"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def description
|
|
31
|
+
"have transitioned to #{@state_name.inspect}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def log_contents
|
|
37
|
+
to_states = @record.history.map(&:to_state)
|
|
38
|
+
return "the log is empty" if to_states.empty?
|
|
39
|
+
|
|
40
|
+
"the log holds transitions to: #{to_states.join(", ")}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# The example-facing surface: requiring statecraft/rspec includes this
|
|
6
|
+
# module into every example group, so specs call the factories bare.
|
|
7
|
+
# Record-level matchers consult guards (a prediction with the metadata
|
|
8
|
+
# you pass); class-level matchers answer the graph's shape only.
|
|
9
|
+
# rubocop:disable Naming/PredicatePrefix -- have_* is RSpec's matcher idiom, not a predicate
|
|
10
|
+
module Matchers
|
|
11
|
+
def allow_event(event_name)
|
|
12
|
+
AllowEvent.new(event_name)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def refuse_event(event_name)
|
|
16
|
+
RefuseEvent.new(event_name)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def allow_transition_to(target_state)
|
|
20
|
+
AllowTransitionTo.new(target_state)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def have_transitioned_to(state_name)
|
|
24
|
+
HaveTransitionedTo.new(state_name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def have_edge(from_state, to_state)
|
|
28
|
+
HaveEdge.new(from_state, to_state)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def have_initial_state(state_name)
|
|
32
|
+
HaveInitialState.new(state_name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def transition(record)
|
|
36
|
+
Transition.new(record)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
# rubocop:enable Naming/PredicatePrefix
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# expect(record).to refuse_event(:cancel).because_of(:customer_cancellable?)
|
|
6
|
+
#
|
|
7
|
+
# The named negation of allow_event: the refusal itself, and — through
|
|
8
|
+
# because_of — WHO refused. Guard names come from refusals_for, so
|
|
9
|
+
# because_of sees record-layer guards only; an input-reading guard:
|
|
10
|
+
# has no name there, and the failure message says so instead of
|
|
11
|
+
# pretending otherwise.
|
|
12
|
+
class RefuseEvent
|
|
13
|
+
def initialize(event_name)
|
|
14
|
+
@event_name = event_name.to_sym
|
|
15
|
+
@expected_guards = []
|
|
16
|
+
@metadata = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def because_of(*guard_names)
|
|
20
|
+
@expected_guards = guard_names.map(&:to_sym)
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def with_metadata(metadata)
|
|
25
|
+
@metadata = metadata
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def matches?(record)
|
|
30
|
+
@record = record
|
|
31
|
+
@allowed = record.can_fire?(@event_name, metadata: @metadata)
|
|
32
|
+
return false if @allowed
|
|
33
|
+
|
|
34
|
+
@refusing_guards = record.refusals_for(@event_name).map(&:guard)
|
|
35
|
+
(@expected_guards - @refusing_guards).empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def failure_message
|
|
39
|
+
if @allowed
|
|
40
|
+
return "expected #{StateReport.standing(@record)} to refuse event #{@event_name.inspect}, " \
|
|
41
|
+
"but the guards passed with metadata #{@metadata.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
missing = (@expected_guards - @refusing_guards).map(&:inspect).join(", ")
|
|
45
|
+
"expected the refusal of #{@event_name.inspect} to come from #{missing}\n #{actual_refusers}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def failure_message_when_negated
|
|
49
|
+
"expected #{StateReport.standing(@record)} to allow event #{@event_name.inspect}, but it was refused\n " +
|
|
50
|
+
StateReport.refusal(@record, @event_name, @metadata)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def description
|
|
54
|
+
"refuse event #{@event_name.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def actual_refusers
|
|
60
|
+
if @refusing_guards.empty?
|
|
61
|
+
"but no record-layer guard refused: refusals_for names record-layer guards only, " \
|
|
62
|
+
"and this refusal came from an input-reading guard: or an undeclared branch"
|
|
63
|
+
else
|
|
64
|
+
"but the refusing record-layer guards were: #{@refusing_guards.map(&:inspect).join(", ")}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# The shared sentences of every failure message: where the record
|
|
6
|
+
# stands, what the graph declares from there, what is reachable right
|
|
7
|
+
# now, and which guards refused. Built strictly on the public
|
|
8
|
+
# introspection surface — the matchers add words, never new answers.
|
|
9
|
+
module StateReport
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def current_state(record)
|
|
13
|
+
record[record.class.statecraft_mounting.column]&.to_sym
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def standing(record)
|
|
17
|
+
"#{record.class.name} in state #{current_state(record).inspect}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def machine(record)
|
|
21
|
+
record.class.statecraft_mounting.machine_class
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def event_declared?(record, event_name)
|
|
25
|
+
machine(record).transitions_from(current_state(record))
|
|
26
|
+
.any? { |descriptor| descriptor[:events].include?(event_name) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def declared_shape(machine_class, state)
|
|
30
|
+
descriptors = machine_class.transitions_from(state)
|
|
31
|
+
return "no edges are declared from #{state.inspect}" if descriptors.empty?
|
|
32
|
+
|
|
33
|
+
rendered = descriptors.map do |descriptor|
|
|
34
|
+
if descriptor[:events].empty?
|
|
35
|
+
"to #{descriptor[:to].inspect} (direct)"
|
|
36
|
+
else
|
|
37
|
+
"to #{descriptor[:to].inspect} via #{descriptor[:events].inspect}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
"declared from #{state.inspect}: #{rendered.join("; ")}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def declared_shape_of(record)
|
|
44
|
+
declared_shape(machine(record), current_state(record))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def reachable(record, metadata)
|
|
48
|
+
transitions = record.available_transitions(metadata: metadata)
|
|
49
|
+
state = current_state(record).inspect
|
|
50
|
+
return "nothing is reachable from #{state} right now" if transitions.empty?
|
|
51
|
+
|
|
52
|
+
rendered = transitions.map { |availability| "to #{availability.to.inspect} via #{availability.via.inspect}" }
|
|
53
|
+
"reachable from #{state}: #{rendered.join("; ")}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def refusal(record, event_name, metadata)
|
|
57
|
+
refusals = record.refusals_for(event_name)
|
|
58
|
+
if refusals.empty?
|
|
59
|
+
"no record-layer guard refused — an input-reading guard: said no to metadata #{metadata.inspect}"
|
|
60
|
+
else
|
|
61
|
+
named = refusals.map { |entry| "#{entry.guard.inspect} (#{entry.layer})" }
|
|
62
|
+
"refused by record-layer guards: #{named.join(", ")}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
module RSpec
|
|
5
|
+
# expect { order.fire!(:pay) }.to transition(order)
|
|
6
|
+
# .from(:pending).to(:paid).via_event(:pay).with_metadata("k" => "v")
|
|
7
|
+
#
|
|
8
|
+
# The transition through the eyes of a test: the state column moved to
|
|
9
|
+
# the target AND exactly one log row was appended with the matching
|
|
10
|
+
# from/to/event/metadata. A non-bang call that returned false leaves
|
|
11
|
+
# both untouched — the matcher fails and explains why, from the same
|
|
12
|
+
# introspection the pipeline consulted. Exceptions of the bang forms
|
|
13
|
+
# fly through, like with the change matcher: refusals are asserted
|
|
14
|
+
# with refuse_event or raise_error, not here.
|
|
15
|
+
class Transition
|
|
16
|
+
def initialize(record)
|
|
17
|
+
@record = record
|
|
18
|
+
@failures = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def from(state_name)
|
|
22
|
+
@from_state = state_name.to_sym
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to(state_name)
|
|
27
|
+
@to_state = state_name.to_sym
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def via_event(event_name)
|
|
32
|
+
@event_name = event_name.to_sym
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def with_metadata(metadata)
|
|
37
|
+
@metadata = metadata
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def supports_block_expectations?
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def matches?(block)
|
|
46
|
+
raise ArgumentError, "transition(record).to(:state) — the .to target is required" unless @to_state
|
|
47
|
+
|
|
48
|
+
@before_state = StateReport.current_state(@record)
|
|
49
|
+
appended_before = @record.history.count
|
|
50
|
+
block.call
|
|
51
|
+
@after_state = StateReport.current_state(@record)
|
|
52
|
+
@appended = @record.history.offset(appended_before).to_a
|
|
53
|
+
collect_failures
|
|
54
|
+
@failures.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def failure_message
|
|
58
|
+
expected_event = " via event #{@event_name.inspect}" if @event_name
|
|
59
|
+
header = "expected the block to transition #{@record.class.name} " \
|
|
60
|
+
"#{@before_state.inspect} -> #{@to_state.inspect}#{expected_event}"
|
|
61
|
+
([header] + @failures).join("\n ")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def failure_message_when_negated
|
|
65
|
+
row = @appended.last
|
|
66
|
+
written_by = " by event #{row.event.inspect}" if row.event
|
|
67
|
+
"expected the block not to transition #{@record.class.name} to #{@to_state.inspect}, " \
|
|
68
|
+
"but it did: #{row.from_state.inspect} -> #{row.to_state.inspect}#{written_by}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def description
|
|
72
|
+
"transition #{@record.class.name} to #{@to_state.inspect}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def collect_failures
|
|
78
|
+
return collect_missing_transition if @appended.empty?
|
|
79
|
+
|
|
80
|
+
if @appended.size > 1
|
|
81
|
+
@failures << "expected exactly one appended log row, but the block appended #{@appended.size}"
|
|
82
|
+
end
|
|
83
|
+
if @after_state != @to_state
|
|
84
|
+
@failures << "the record ended in #{@after_state.inspect}, not #{@to_state.inspect}"
|
|
85
|
+
end
|
|
86
|
+
collect_row_mismatches(@appended.last)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def collect_row_mismatches(row)
|
|
90
|
+
if row.to_state != @to_state.to_s
|
|
91
|
+
@failures << "the log row went to #{row.to_state.inspect}, not #{@to_state.inspect}"
|
|
92
|
+
end
|
|
93
|
+
if @from_state && row.from_state != @from_state.to_s
|
|
94
|
+
@failures << "the transition started from #{row.from_state.inspect}, not #{@from_state.inspect}"
|
|
95
|
+
end
|
|
96
|
+
if @event_name && row.event != @event_name.to_s
|
|
97
|
+
@failures << "the transition was written by event #{row.event.inspect}, not #{@event_name.inspect}"
|
|
98
|
+
end
|
|
99
|
+
collect_metadata_mismatch(row)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def collect_missing_transition
|
|
103
|
+
@failures << "no transition happened: the record stayed in #{@after_state.inspect}"
|
|
104
|
+
@failures << StateReport.reachable(@record, @metadata || {})
|
|
105
|
+
return unless @event_name && StateReport.event_declared?(@record, @event_name)
|
|
106
|
+
|
|
107
|
+
@failures << StateReport.refusal(@record, @event_name, @metadata || {})
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def collect_metadata_mismatch(row)
|
|
111
|
+
return unless @metadata
|
|
112
|
+
|
|
113
|
+
expected = @metadata.deep_stringify_keys
|
|
114
|
+
return if row.metadata == expected
|
|
115
|
+
|
|
116
|
+
@failures << "the log row carries metadata #{row.metadata.inspect}, not #{expected.inspect}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "statecraft"
|
|
4
|
+
|
|
5
|
+
# Deliberately not required by lib/statecraft.rb: the matchers exist only
|
|
6
|
+
# where RSpec does, and the gem's runtime must not know about test
|
|
7
|
+
# frameworks. This file is the one opt-in door.
|
|
8
|
+
unless defined?(RSpec)
|
|
9
|
+
raise Statecraft::Error,
|
|
10
|
+
"statecraft/rspec builds RSpec matchers, so RSpec must be loaded first: " \
|
|
11
|
+
"require statecraft/rspec from your spec helper, after rspec itself"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require_relative "rspec/state_report"
|
|
15
|
+
require_relative "rspec/allow_event"
|
|
16
|
+
require_relative "rspec/refuse_event"
|
|
17
|
+
require_relative "rspec/allow_transition_to"
|
|
18
|
+
require_relative "rspec/have_transitioned_to"
|
|
19
|
+
require_relative "rspec/have_edge"
|
|
20
|
+
require_relative "rspec/have_initial_state"
|
|
21
|
+
require_relative "rspec/transition"
|
|
22
|
+
require_relative "rspec/matchers"
|
|
23
|
+
|
|
24
|
+
RSpec.configure { |config| config.include Statecraft::RSpec::Matchers }
|
data/lib/statecraft/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: statecraft
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Igor Pugachev
|
|
@@ -87,6 +87,16 @@ files:
|
|
|
87
87
|
- lib/statecraft/pipeline.rb
|
|
88
88
|
- lib/statecraft/pipeline/edge_resolution.rb
|
|
89
89
|
- lib/statecraft/pipeline/surface.rb
|
|
90
|
+
- lib/statecraft/rspec.rb
|
|
91
|
+
- lib/statecraft/rspec/allow_event.rb
|
|
92
|
+
- lib/statecraft/rspec/allow_transition_to.rb
|
|
93
|
+
- lib/statecraft/rspec/have_edge.rb
|
|
94
|
+
- lib/statecraft/rspec/have_initial_state.rb
|
|
95
|
+
- lib/statecraft/rspec/have_transitioned_to.rb
|
|
96
|
+
- lib/statecraft/rspec/matchers.rb
|
|
97
|
+
- lib/statecraft/rspec/refuse_event.rb
|
|
98
|
+
- lib/statecraft/rspec/state_report.rb
|
|
99
|
+
- lib/statecraft/rspec/transition.rb
|
|
90
100
|
- lib/statecraft/version.rb
|
|
91
101
|
- lib/statecraft/warnings.rb
|
|
92
102
|
homepage: https://supostat.github.io/statecraft/
|