james 0.1.1-universal-darwin-10 → 0.2.0-universal-darwin-10
Sign up to get free protection for your applications and to get access to all the features.
- data/aux/james/cli.rb +21 -4
- data/lib/james/builtin/core_dialog.rb +4 -0
- data/lib/james/controller.rb +60 -51
- data/lib/james/conversation.rb +62 -0
- data/lib/james/dialog_internals.rb +62 -9
- data/lib/james/dialogs.rb +6 -28
- data/lib/james/markers/current.rb +34 -0
- data/lib/james/markers/marker.rb +92 -0
- data/lib/james/markers/memory.rb +37 -0
- data/lib/james/state_api.rb +39 -11
- data/lib/james/state_internals.rb +35 -14
- data/lib/james.rb +5 -5
- metadata +7 -23
- data/lib/james/visitor.rb +0 -80
- data/lib/james/visitors.rb +0 -62
- data/spec/aux/james/cli_spec.rb +0 -19
- data/spec/integration/test_dialogue_spec.rb +0 -67
- data/spec/lib/james/controller_spec.rb +0 -35
- data/spec/lib/james/dialog_spec.rb +0 -90
- data/spec/lib/james/inputs/audio_spec.rb +0 -18
- data/spec/lib/james/inputs/terminal_spec.rb +0 -12
- data/spec/lib/james/state_spec.rb +0 -156
- data/spec/lib/james/visitor_spec.rb +0 -101
- data/spec/lib/james/visitors_spec.rb +0 -64
data/aux/james/cli.rb
CHANGED
@@ -5,9 +5,7 @@ module James
|
|
5
5
|
class CLI
|
6
6
|
|
7
7
|
def execute *patterns
|
8
|
-
|
9
|
-
silent_input = patterns.delete '-si'
|
10
|
-
silent_output = patterns.delete '-so'
|
8
|
+
options = extract_options patterns
|
11
9
|
|
12
10
|
dialogs = find_dialogs_for patterns
|
13
11
|
|
@@ -16,16 +14,35 @@ module James
|
|
16
14
|
|
17
15
|
load_all dialogs
|
18
16
|
|
17
|
+
James.listen options
|
18
|
+
end
|
19
|
+
|
20
|
+
# Defines default options and extracts options from
|
21
|
+
# command line.
|
22
|
+
#
|
23
|
+
# Sadly needs to be run before processing the dialog file names.
|
24
|
+
#
|
25
|
+
def extract_options patterns
|
26
|
+
silent = patterns.delete '-s'
|
27
|
+
silent_input = patterns.delete '-si'
|
28
|
+
silent_output = patterns.delete '-so'
|
29
|
+
|
19
30
|
options = {}
|
20
31
|
options[:input] = Inputs::Terminal if silent || silent_input
|
21
32
|
options[:output] = Outputs::Terminal if silent || silent_output
|
22
33
|
|
23
|
-
|
34
|
+
options
|
24
35
|
end
|
36
|
+
|
37
|
+
#
|
38
|
+
#
|
25
39
|
def find_dialogs_for patterns
|
26
40
|
patterns = ["**/*_dialog{,ue}.rb"] if patterns.empty?
|
27
41
|
Dir[*patterns]
|
28
42
|
end
|
43
|
+
|
44
|
+
#
|
45
|
+
#
|
29
46
|
def load_all dialogs
|
30
47
|
dialogs.each do |dialog|
|
31
48
|
load File.expand_path dialog, Dir.pwd
|
data/lib/james/controller.rb
CHANGED
@@ -2,7 +2,7 @@ module James
|
|
2
2
|
|
3
3
|
class Controller
|
4
4
|
|
5
|
-
attr_reader :
|
5
|
+
attr_reader :conversation, :listening, :initial
|
6
6
|
|
7
7
|
# Singleton reader.
|
8
8
|
#
|
@@ -10,63 +10,33 @@ module James
|
|
10
10
|
@controller ||= new
|
11
11
|
end
|
12
12
|
|
13
|
-
# This puts together the
|
13
|
+
# This puts together the initial dialog and the user
|
14
14
|
# ones that are hooked into it.
|
15
15
|
#
|
16
|
-
#
|
17
|
-
#
|
16
|
+
# The initial dialog needs an state defined as initially.
|
17
|
+
# This is where it will start.
|
18
18
|
#
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
Visitor.new CoreDialog.new.state_for(:awake)
|
25
|
-
end
|
26
|
-
def user_visitor
|
27
|
-
@user_dialogs.visitor
|
28
|
-
end
|
29
|
-
|
30
|
-
# MacRuby callback functions.
|
19
|
+
# Example:
|
20
|
+
# initially :awake
|
21
|
+
# state :awake do
|
22
|
+
# # ...
|
23
|
+
# end
|
31
24
|
#
|
32
|
-
|
33
|
-
|
34
|
-
start_input
|
35
|
-
end
|
36
|
-
def windowWillClose notification
|
37
|
-
exit
|
38
|
-
end
|
39
|
-
|
40
|
-
# Add a dialog to the current system.
|
25
|
+
# If you don't give it an initial dialog,
|
26
|
+
# James will simply use the built-in CoreDialog.
|
41
27
|
#
|
42
|
-
def
|
43
|
-
@
|
28
|
+
def initialize dialog = nil
|
29
|
+
@initial = dialog || CoreDialog.new
|
30
|
+
@conversation = Conversation.new @initial.current
|
44
31
|
end
|
45
32
|
|
46
|
-
#
|
33
|
+
# Convenience method to add a dialog to the current system.
|
47
34
|
#
|
48
|
-
|
49
|
-
|
50
|
-
@input.listen
|
51
|
-
end
|
52
|
-
# Start speaking.
|
35
|
+
# Will add the dialog to the initial dialog passed into the
|
36
|
+
# controller.
|
53
37
|
#
|
54
|
-
def
|
55
|
-
@
|
56
|
-
end
|
57
|
-
|
58
|
-
# Callback method from dialog.
|
59
|
-
#
|
60
|
-
def say text
|
61
|
-
@output.say text
|
62
|
-
end
|
63
|
-
def hear text
|
64
|
-
visitor.hear text do |response|
|
65
|
-
say response
|
66
|
-
end
|
67
|
-
end
|
68
|
-
def expects
|
69
|
-
visitor.expects
|
38
|
+
def << dialog
|
39
|
+
@initial << dialog
|
70
40
|
end
|
71
41
|
|
72
42
|
# Start listening using the provided options.
|
@@ -78,6 +48,8 @@ module James
|
|
78
48
|
def listen options = {}
|
79
49
|
return if listening
|
80
50
|
|
51
|
+
@listening = true
|
52
|
+
|
81
53
|
@input_class = options[:input] || Inputs::Audio
|
82
54
|
@output_class = options[:output] || Outputs::Audio
|
83
55
|
|
@@ -87,11 +59,48 @@ module James
|
|
87
59
|
app = NSApplication.sharedApplication
|
88
60
|
app.delegate = self
|
89
61
|
|
90
|
-
@listening = true
|
91
|
-
|
92
62
|
app.run
|
93
63
|
end
|
94
64
|
|
65
|
+
# The naughty privates of this class.
|
66
|
+
#
|
67
|
+
|
68
|
+
# MacRuby callback functions.
|
69
|
+
#
|
70
|
+
def applicationDidFinishLaunching notification
|
71
|
+
start_output
|
72
|
+
start_input
|
73
|
+
end
|
74
|
+
def windowWillClose notification
|
75
|
+
exit
|
76
|
+
end
|
77
|
+
|
78
|
+
# Start recognizing words.
|
79
|
+
#
|
80
|
+
def start_input
|
81
|
+
@input = @input_class.new self
|
82
|
+
@input.listen
|
83
|
+
end
|
84
|
+
# Start speaking.
|
85
|
+
#
|
86
|
+
def start_output
|
87
|
+
@output = @output_class.new @output_options
|
88
|
+
end
|
89
|
+
|
90
|
+
# Callback method from dialog.
|
91
|
+
#
|
92
|
+
def say text
|
93
|
+
@output.say text
|
94
|
+
end
|
95
|
+
def hear text
|
96
|
+
conversation.hear text do |response|
|
97
|
+
say response
|
98
|
+
end
|
99
|
+
end
|
100
|
+
def expects
|
101
|
+
conversation.expects
|
102
|
+
end
|
103
|
+
|
95
104
|
end
|
96
105
|
|
97
106
|
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
# A conversation has a number of markers (position in dialog),
|
4
|
+
# whose dialogs are visited in order of preference.
|
5
|
+
#
|
6
|
+
# Why?
|
7
|
+
# Conversations have multiple points where they can be.
|
8
|
+
# (Politics, then this joke, then back again, finally "Oh, bye I have to go!")
|
9
|
+
#
|
10
|
+
class Conversation
|
11
|
+
|
12
|
+
attr_accessor :markers
|
13
|
+
|
14
|
+
# A Conversation keeps a stack of markers with
|
15
|
+
# an initial one.
|
16
|
+
#
|
17
|
+
def initialize initial
|
18
|
+
@markers = [initial]
|
19
|
+
end
|
20
|
+
|
21
|
+
# Hear tries all visitors in order
|
22
|
+
# until one hears a phrase he knows.
|
23
|
+
#
|
24
|
+
# If a dialog boundary has been crossed:
|
25
|
+
# A new visitor is added with the target
|
26
|
+
# state of that heard phrase at the position.
|
27
|
+
#
|
28
|
+
# After that, all remaining visitors are
|
29
|
+
# removed from the current stack (since
|
30
|
+
# we are obviously not in one of the later
|
31
|
+
# dialogs anymore).
|
32
|
+
#
|
33
|
+
def hear phrase, &block
|
34
|
+
self.markers = markers.inject([]) do |remaining, marker|
|
35
|
+
markers = marker.hear phrase, &block
|
36
|
+
remaining = remaining + markers
|
37
|
+
break remaining if remaining.last && remaining.last.current?
|
38
|
+
remaining
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Enter enters the first visitor.
|
43
|
+
#
|
44
|
+
def enter
|
45
|
+
markers.first.enter
|
46
|
+
end
|
47
|
+
|
48
|
+
# Simply returns the sum of what phrases all dialogs do expect, front-to-back.
|
49
|
+
#
|
50
|
+
# Stops as soon as a marker is not on a chainable state anymore.
|
51
|
+
#
|
52
|
+
def expects
|
53
|
+
markers.inject([]) do |expects, marker|
|
54
|
+
total = marker.expects + expects
|
55
|
+
break total unless marker.chainable?
|
56
|
+
total
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
@@ -9,35 +9,88 @@ module James
|
|
9
9
|
into.extend ClassMethods
|
10
10
|
end
|
11
11
|
|
12
|
+
# Returns a state instance for the given state / or state name.
|
12
13
|
#
|
14
|
+
# Note: Lazily creates the state instances.
|
13
15
|
#
|
14
|
-
def state_for
|
15
|
-
|
16
|
+
def state_for possible_state
|
17
|
+
return possible_state if possible_state.respond_to?(:expects)
|
18
|
+
self.class.state_for possible_state, self
|
19
|
+
end
|
20
|
+
|
21
|
+
# Chain (the states of) this dialog to the given state.
|
22
|
+
#
|
23
|
+
# Creates state instances if it is given names.
|
24
|
+
#
|
25
|
+
# Note: Be careful not to create circular
|
26
|
+
# state chaining. Except if you really
|
27
|
+
# want that.
|
28
|
+
#
|
29
|
+
def chain_to state
|
30
|
+
warn "Define a hear => :some_state_name in a dialog to have it be able to chain to another." && return unless respond_to?(:entry_phrases)
|
31
|
+
entry_phrases.each do |(phrases, entry_state)|
|
32
|
+
state.hear phrases => state_for(entry_state)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Chain the given Dialog(s) to all chainable
|
37
|
+
# states in this Dialog.
|
38
|
+
#
|
39
|
+
# Note: If you only want one state to chain,
|
40
|
+
# then get it from the otiginating dialog
|
41
|
+
# using dialog.state_for(:name) and
|
42
|
+
# append the dialog there:
|
43
|
+
# dialog.follows preceding_dialog.state_for(:name)
|
44
|
+
#
|
45
|
+
def << dialog_s
|
46
|
+
self.class.states.each do |(name, definition)|
|
47
|
+
state = state_for name # TODO Do not call this everywhere.
|
48
|
+
dialog_s.chain_to(state) if state.chainable?
|
49
|
+
end
|
16
50
|
end
|
17
51
|
|
18
52
|
module ClassMethods
|
19
53
|
|
20
|
-
|
54
|
+
def initially state_name
|
55
|
+
define_method :current do
|
56
|
+
Markers::Current.new state_for(state_name)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Defines the entry phrases into this dialog.
|
61
|
+
#
|
62
|
+
# Example:
|
63
|
+
# hear 'Hello, James!' => :start
|
21
64
|
#
|
22
65
|
def hear definition
|
23
|
-
define_method :
|
66
|
+
define_method :entry_phrases do
|
24
67
|
definition
|
25
68
|
end
|
26
69
|
end
|
27
70
|
|
28
71
|
# Defines a state with transitions.
|
29
72
|
#
|
30
|
-
#
|
31
|
-
#
|
32
|
-
#
|
73
|
+
# Example:
|
74
|
+
# state :name do
|
75
|
+
# # state properties (hear, into, exit) go here.
|
76
|
+
# end
|
33
77
|
#
|
34
|
-
attr_reader :states
|
35
78
|
def state name, &block
|
36
79
|
@states ||= {}
|
37
80
|
@states[name] ||= block if block_given?
|
81
|
+
define_method name do
|
82
|
+
state_for name
|
83
|
+
end unless instance_methods.include? name
|
38
84
|
end
|
85
|
+
|
86
|
+
#
|
87
|
+
#
|
88
|
+
attr_reader :states
|
89
|
+
|
90
|
+
# Return a state for this name (and dialog instance).
|
91
|
+
#
|
39
92
|
def state_for name, instance
|
40
|
-
# Lazily wrap.
|
93
|
+
# Lazily wrap in State instance.
|
41
94
|
#
|
42
95
|
if states[name].respond_to?(:call)
|
43
96
|
states[name] = State.new(name, instance, &states[name])
|
data/lib/james/dialogs.rb
CHANGED
@@ -1,41 +1,19 @@
|
|
1
1
|
module James
|
2
2
|
|
3
|
-
#
|
3
|
+
# Bundles a bunch of dialogs.
|
4
4
|
#
|
5
5
|
class Dialogs
|
6
6
|
|
7
|
-
attr_reader :
|
7
|
+
attr_reader :dialogs
|
8
8
|
|
9
|
-
def initialize
|
10
|
-
@
|
9
|
+
def initialize *dialogs
|
10
|
+
@dialogs = dialogs
|
11
11
|
end
|
12
12
|
|
13
|
-
# Generate the graph for the dialogs.
|
14
13
|
#
|
15
|
-
# Hooks up the entry phrases of the dialog
|
16
|
-
# into the main dialog.
|
17
14
|
#
|
18
|
-
|
19
|
-
|
20
|
-
#
|
21
|
-
def << dialog
|
22
|
-
resolved_entries = {}
|
23
|
-
|
24
|
-
dialog.entries.each do |(phrases, state)|
|
25
|
-
resolved_entries[phrases] = state.respond_to?(:phrases) ? state : dialog.state_for(state)
|
26
|
-
end
|
27
|
-
|
28
|
-
# Hook the dialog into the initial state.
|
29
|
-
#
|
30
|
-
initial.hear resolved_entries
|
31
|
-
end
|
32
|
-
|
33
|
-
# Get the visitor.
|
34
|
-
#
|
35
|
-
# Initialized on the initial state.
|
36
|
-
#
|
37
|
-
def visitor
|
38
|
-
@visitor ||= Visitor.new initial
|
15
|
+
def chain_to incoming_dialog
|
16
|
+
dialogs.each { |dialog| incoming_dialog << dialog }
|
39
17
|
end
|
40
18
|
|
41
19
|
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
module Markers
|
4
|
+
|
5
|
+
# The visitor knows where in the conversation we are.
|
6
|
+
#
|
7
|
+
class Current < Marker
|
8
|
+
|
9
|
+
# Hear a phrase.
|
10
|
+
#
|
11
|
+
# Returns a new marker and self if it crossed a boundary.
|
12
|
+
# Returns itself if not.
|
13
|
+
#
|
14
|
+
def hear phrase, &block
|
15
|
+
return [self] unless hears? phrase
|
16
|
+
last = current
|
17
|
+
process(phrase, &block) ? [Memory.new(last), self] : [self]
|
18
|
+
end
|
19
|
+
|
20
|
+
# Expects all phrases, not just internal.
|
21
|
+
#
|
22
|
+
def expects
|
23
|
+
current.expects
|
24
|
+
end
|
25
|
+
|
26
|
+
def current?
|
27
|
+
true
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
module Markers
|
4
|
+
|
5
|
+
# A marker is a point in conversation
|
6
|
+
# where we once were and might go back.
|
7
|
+
#
|
8
|
+
# TODO: Rename to ?.
|
9
|
+
#
|
10
|
+
class Marker
|
11
|
+
|
12
|
+
attr_accessor :current
|
13
|
+
|
14
|
+
# Pass in an current state.
|
15
|
+
#
|
16
|
+
def initialize current
|
17
|
+
@current = current
|
18
|
+
end
|
19
|
+
|
20
|
+
# Resets the current state back to the initial.
|
21
|
+
#
|
22
|
+
def reset
|
23
|
+
# Never moves, thus never reset.
|
24
|
+
end
|
25
|
+
|
26
|
+
# We hear a phrase.
|
27
|
+
#
|
28
|
+
# Also used to start the whole process.
|
29
|
+
#
|
30
|
+
def enter
|
31
|
+
result = current.__into__
|
32
|
+
yield result if result && block_given?
|
33
|
+
result
|
34
|
+
end
|
35
|
+
|
36
|
+
#
|
37
|
+
#
|
38
|
+
def exit
|
39
|
+
result = current.__exit__
|
40
|
+
yield result if result && block_given?
|
41
|
+
result
|
42
|
+
end
|
43
|
+
|
44
|
+
#
|
45
|
+
#
|
46
|
+
def transition phrase
|
47
|
+
state_or_lambda = current.next_for phrase
|
48
|
+
if state_or_lambda.respond_to?(:call)
|
49
|
+
current.__transition__ &state_or_lambda # Don't transition.
|
50
|
+
else
|
51
|
+
self.current = state_or_lambda
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
#
|
56
|
+
#
|
57
|
+
def check
|
58
|
+
yield("Whoops. That led nowhere. Perhaps you didn't define the target state?") unless self.current
|
59
|
+
end
|
60
|
+
|
61
|
+
# Returns falsy if it stays the same.
|
62
|
+
#
|
63
|
+
def process phrase, &block
|
64
|
+
exit_text = exit &block
|
65
|
+
last_context = current.context
|
66
|
+
transition phrase
|
67
|
+
check &block
|
68
|
+
into_text = enter &block
|
69
|
+
last_context != current.context
|
70
|
+
end
|
71
|
+
|
72
|
+
#
|
73
|
+
#
|
74
|
+
def hears? phrase
|
75
|
+
expects.include? phrase
|
76
|
+
end
|
77
|
+
|
78
|
+
# Does the current state allow penetration into another dialog?
|
79
|
+
#
|
80
|
+
def chainable?
|
81
|
+
current.chainable?
|
82
|
+
end
|
83
|
+
|
84
|
+
def to_s
|
85
|
+
"#{self.class.name}(#{initial}, current: #{current})"
|
86
|
+
end
|
87
|
+
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
|
92
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
module Markers
|
4
|
+
|
5
|
+
# A marker is a point in conversation
|
6
|
+
# where we once were and might go back.
|
7
|
+
#
|
8
|
+
# TODO: Rename to ?.
|
9
|
+
#
|
10
|
+
class Memory < Marker
|
11
|
+
|
12
|
+
# Hear a phrase.
|
13
|
+
#
|
14
|
+
# Returns a new Current if it heard.
|
15
|
+
# Returns itself if not.
|
16
|
+
#
|
17
|
+
def hear phrase, &block
|
18
|
+
return [self] unless hears? phrase
|
19
|
+
last = current
|
20
|
+
process(phrase, &block) ? [Memory.new(last), Current.new(current)] : [Current.new(current)]
|
21
|
+
end
|
22
|
+
|
23
|
+
# A marker does not care about phrases that cross dialog boundaries.
|
24
|
+
#
|
25
|
+
def expects
|
26
|
+
current.internal_expects
|
27
|
+
end
|
28
|
+
|
29
|
+
def current?
|
30
|
+
false
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
data/lib/james/state_api.rb
CHANGED
@@ -43,26 +43,42 @@ module James
|
|
43
43
|
#
|
44
44
|
def hear transitions
|
45
45
|
transitions = { transitions => name } unless transitions.respond_to?(:to_hash)
|
46
|
-
@transitions
|
46
|
+
@transitions = expand(transitions).merge @transitions
|
47
47
|
end
|
48
48
|
|
49
|
-
# Execute this block when entering this state.
|
49
|
+
# Execute this block or say the text when entering this state.
|
50
50
|
#
|
51
|
-
|
52
|
-
|
51
|
+
# Examples:
|
52
|
+
# into "Yes, Sir?"
|
53
|
+
# into { "A random number is #{rand(10)}" }
|
54
|
+
#
|
55
|
+
def into text = nil, &block
|
56
|
+
@into_block = block ||
|
57
|
+
text && lambda { text } ||
|
58
|
+
raise_no_text_or_block(__method__)
|
53
59
|
end
|
54
60
|
|
55
|
-
# Execute this block when exiting this state.
|
61
|
+
# Execute this block or say the text when exiting this state.
|
62
|
+
#
|
63
|
+
# Examples:
|
64
|
+
# exit "Yes, Sir?"
|
65
|
+
# exit { "A random number is #{rand(10)}" }
|
56
66
|
#
|
57
|
-
def exit &block
|
58
|
-
@exit_block = block
|
67
|
+
def exit text = nil, &block
|
68
|
+
@exit_block = block ||
|
69
|
+
text && lambda { text } ||
|
70
|
+
raise_no_text_or_block(__method__)
|
59
71
|
end
|
60
72
|
|
61
|
-
#
|
73
|
+
# Chain the given dialog to this state.
|
62
74
|
#
|
63
|
-
def
|
64
|
-
|
75
|
+
def << dialog
|
76
|
+
dialog.chain_to self
|
65
77
|
end
|
78
|
+
|
79
|
+
# A chainable state is a state from which other
|
80
|
+
# Dialogs can be reached.
|
81
|
+
#
|
66
82
|
def chainable
|
67
83
|
@chainable = true
|
68
84
|
end
|
@@ -70,7 +86,7 @@ module James
|
|
70
86
|
# Description of self using name and transitions.
|
71
87
|
#
|
72
88
|
def to_s
|
73
|
-
"#{self.class.name}(#{name}, #{context}, #{transitions})"
|
89
|
+
"#{self.class.name}(#{name}, #{context}, #{@transitions})"
|
74
90
|
end
|
75
91
|
|
76
92
|
# The naughty privates of this class.
|
@@ -90,6 +106,18 @@ module James
|
|
90
106
|
results
|
91
107
|
end
|
92
108
|
|
109
|
+
# Raise an ArgumentError for the given method if it needs either a text or a block.
|
110
|
+
#
|
111
|
+
def raise_no_text_or_block on_method
|
112
|
+
raise ArgumentError.new("Neither block nor text given to ##{on_method} call in #{caller[1]}.")
|
113
|
+
end
|
114
|
+
|
115
|
+
# By default, a state is not chainable.
|
116
|
+
#
|
117
|
+
def chainable?
|
118
|
+
!!@chainable
|
119
|
+
end
|
120
|
+
|
93
121
|
end
|
94
122
|
|
95
123
|
end
|