fmm 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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 70e6ddb7251def8d0c06d8ed0f3ad5d87c34dd2b
4
+ data.tar.gz: 3cad9dc7ffea1523e9756218fc1bab6f6a457dbe
5
+ SHA512:
6
+ metadata.gz: 04f799b649ec15779c7c972225d569146b3dae1c47c12ee463d9dfe9557ccc4bdc809a9bf00bf0adf1b892af3e38c753f162477b4a7a065577145653b2b821d9
7
+ data.tar.gz: 5170b666468f20d802f2904bb79c8742f9965ca7421a165ec529d6d14320a60047c0a0b62bf6efb80b25c8290e2a2349510351611e10cfd34bf1e422fd55eac8
@@ -0,0 +1,7 @@
1
+ .*.swp
2
+ *.gem
3
+ *.rbc
4
+ .bundle
5
+ .config
6
+ .yardoc
7
+ Gemfile.lock
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source "https://rubygems.org"
2
+ gemspec
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 EC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,17 @@
1
+
2
+ # FMM
3
+
4
+ FMM is short for "functional micromachines;" it is based on
5
+ the [micromachine](https://github.com/soveran/micromachine/)
6
+ gem, a nicely compact little finite state machine implementation.
7
+
8
+ Micromachine is an imperative design, based on the methods and
9
+ mutable state of an instance of class `MicroMachine`. FMM takes
10
+ a functional approach, where (a) the state machine operations
11
+ are pure functions that take the current state as an argument
12
+ and return an updated state, and (b) instead of building
13
+ the machine imperatively by calls to `machine.on(...)`, we
14
+ assume the machine is given as a data structure of a certain
15
+ format. (A validation method is included.)
16
+
17
+ Updates soon. In the meantime, have a look at the test suite.
@@ -0,0 +1,14 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = "fmm"
3
+ s.version = "0.1.0"
4
+ s.summary = %{FMM: a minimal finite state machine with functional leanings}
5
+ s.description = %Q{FMM is a small finite state machine implementation based on Michael Martens' micromachine, but recast in the idioms of functional programming: instead of mutable state we use arguments and return values, and instead of methods bound to an instance of a class like MicroMachine, we provide utility functions that operate on any suitable data structure.}
6
+ s.author = ["Erik Cameron"]
7
+ s.email = ["root@erikcameron.org"]
8
+ s.homepage = "http://github.com/erikcameron/fmm"
9
+ s.license = "MIT"
10
+
11
+ s.files = `git ls-files`.split("\n")
12
+
13
+ s.add_development_dependency "rspec", "~> 0"
14
+ end
@@ -0,0 +1,176 @@
1
+ module FMM
2
+ class InvalidEvent < NoMethodError; end
3
+ class InvalidState < ArgumentError; end
4
+ class InvalidMachine < TypeError; end # this is ultimately a data error
5
+
6
+ extend self
7
+
8
+ # validate a state machine; defacto specification; no
9
+ # state for which this method returns true should ever
10
+ # crash with an InvalidMachine error
11
+ def validate!(state)
12
+ # The state is a Hash-like object (HLO) with a value
13
+ # at key :_machine
14
+ unless state[:_machine]
15
+ raise InvalidMachine, "no state machine found: #{state}"
16
+ end
17
+
18
+ # The machine has a current state
19
+ unless state[:_machine][:current]
20
+ raise InvalidMachine, "no current state: #{state}"
21
+ end
22
+
23
+ # The machine specifies a set of transitions (and hence states)
24
+ unless state[:_machine][:transitions]
25
+ raise InvalidMachine, "you must specify some transitions: #{state}"
26
+ end
27
+
28
+ # The transitions table must be a HLO...
29
+ unless state[:_machine][:transitions].is_a?(Hash)
30
+ raise InvalidMachine, "transitions must be a hash: #{state}"
31
+ end
32
+
33
+ # ...all of whose values are also HLOs
34
+ unless state[:_machine][:transitions].values.map { |v| v.is_a?(Hash) }.inject(:&)
35
+ raise InvalidMachine, "transitions must be a hash of hashes: #{state}"
36
+ end
37
+
38
+ # Callbacks (which are all post-transition; see below) are optional,
39
+ # but if they exist...
40
+ if state[:_machine][:callbacks]
41
+ # ...they must be in a HLO...
42
+ unless state[:_machine][:callbacks].is_a?(Hash)
43
+ raise InvalidMachine, "callbacks must be a hash: #{state}"
44
+ end
45
+
46
+ # ...whose values are either callables or collections thereof
47
+ valid_callbacks = state[:_machine][:callbacks].values.flatten.map do |v|
48
+ v.respond_to?(:call)
49
+ end.inject(:&)
50
+
51
+ unless valid_callbacks
52
+ raise InvalidMachine, "callbacks must be callables or arrays thereof: #{state}"
53
+ end
54
+ end
55
+
56
+ # Aliases are optional, but if they exist...
57
+ if state[:_machine][:aliases]
58
+ # ...they must be in a HLO. This is all we can actually
59
+ # say about aliases, other than this: The keys of this
60
+ # HLO correspond to states, but can be of any type;
61
+ # nonexistent states simply won't be consulted. Similarly,
62
+ # the values are all either names of states or collections
63
+ # thereof, but that doesn't actually place any type limitation
64
+ # on what the values _are_, other than not letting them be
65
+ # arrays, because they will be flattened.
66
+ unless state[:_machine][:aliases].is_a?(Hash)
67
+ raise InvalidMachine, "aliases must be a hash: #{state}"
68
+ end
69
+ end
70
+ true
71
+ end
72
+
73
+ # trigger state changes
74
+
75
+ def trigger(state, event, payload = nil)
76
+ payload.freeze if payload.respond_to?(:freeze)
77
+ trigger?(state, event) and change(state, event, payload)
78
+ end
79
+
80
+ def trigger!(state, event, payload = nil)
81
+ trigger(state, event, payload) or
82
+ raise InvalidState, "Event '#{event}' not valid from state :'#{current(state)}'"
83
+ end
84
+
85
+ # talk to the machine object
86
+
87
+ def current(state)
88
+ state[:_machine][:current]
89
+ rescue => err
90
+ # reraise as an explicit InvalidMachine;
91
+ # get the orig out of #cause
92
+ raise InvalidMachine, '#current'
93
+ end
94
+
95
+ def transitions(state)
96
+ state[:_machine][:transitions]
97
+ rescue => err
98
+ raise InvalidMachine, '#transitions'
99
+ end
100
+
101
+ def callbacks(state)
102
+ state[:_machine][:callbacks] || {}
103
+ rescue => err
104
+ raise InvalidMachine, '#callbacks'
105
+ end
106
+
107
+ def aliases_for(state)
108
+ state[:_machine][:aliases] ? state[:_machine][:aliases][current(state)] : nil
109
+ end
110
+
111
+ # from most to least specific, as this is the order
112
+ # in which we will resolve available transitions
113
+ # and run callbacks
114
+ def all_names_for(state)
115
+ [ current(state), aliases_for(state), :* ].flatten
116
+ end
117
+
118
+ def trigger?(state, event)
119
+ unless transitions(state).has_key?(event)
120
+ raise InvalidEvent, "no such event #{event}"
121
+ end
122
+ resolve_next_state_name(state, event) ? true : false
123
+ end
124
+
125
+ def events(state)
126
+ transitions(state).keys
127
+ end
128
+
129
+ def triggerable_events(state)
130
+ events(state).select { |event| trigger?(state, event) }
131
+ end
132
+
133
+ def machine_states(state)
134
+ transitions(state).values.map(&:to_a).flatten.uniq
135
+ end
136
+
137
+ private
138
+
139
+ #
140
+ # the elbow grease
141
+ #
142
+ # there was a whole one-person debate here about the
143
+ # point/utility of pre-transition callbacks; if they
144
+ # actually prove to be something useful, we can add them
145
+ # without breaking existing machines. ([:_machine][:before],
146
+ # at which point, we can just construe [:_machine][:callbacks]
147
+ # as synonymous with [:post]) for now, i don't really see what
148
+ # purpose they serve in this sort of application other than
149
+ # being able to veto state changes, maybe; for now i say poo
150
+ # on them
151
+
152
+ def change(state, event, payload)
153
+ state = update_machine_state(state, resolve_next_state_name(state, event))
154
+ # post callbacks; these we very much want
155
+ resolve_callbacks(state).each do |callback|
156
+ state = callback.call(state, event, payload)
157
+ end
158
+ state
159
+ rescue => err
160
+ raise InvalidMachine.new('#change')
161
+ end
162
+
163
+ def resolve_callbacks(state)
164
+ all_names_for(state).map { |n| callbacks(state)[n] }.flatten.compact
165
+ end
166
+
167
+ def resolve_next_state_name(state, event)
168
+ all_names_for(state).map { |n| transitions(state)[event][n] }.compact.first
169
+ end
170
+
171
+ def update_machine_state(state, target)
172
+ raise InvalidState, "nil target" unless target
173
+ new_machine = state[:_machine].merge({ current: target })
174
+ state.merge({ _machine: new_machine })
175
+ end
176
+ end
@@ -0,0 +1,79 @@
1
+ require 'spec_helper'
2
+ require './lib/fmm.rb'
3
+ require './spec/test_machine.rb'
4
+
5
+ describe FMM do
6
+ let(:state) { FMM::TestMachine.create }
7
+
8
+ it "validates the test machine" do
9
+ expect(FMM.validate!(state)).to eq(true)
10
+ end
11
+
12
+ context "when advancing" do
13
+ let(:new_state) { FMM.trigger!(state, :begin) }
14
+
15
+ it "accepts begin" do
16
+ expect(FMM.current(new_state)).to eq(:step1)
17
+ end
18
+
19
+ it "doesn't mutate the original state object" do
20
+ expect(FMM.current(state)).to eq(:new)
21
+ end
22
+
23
+ it "runs step1 callback" do
24
+ expect(new_state[:step1]).to eq([{ begin: nil }])
25
+ end
26
+
27
+ it "runs the * callback" do
28
+ expect(new_state[:all]).to eq([{ begin: nil }])
29
+ end
30
+
31
+ it "doesn't run uncalled for callbacks" do
32
+ expect(new_state[:aliased]).to be(nil)
33
+ end
34
+
35
+ it "refuses invalid transitions" do
36
+ bad_transition_return = FMM.trigger(state, :end)
37
+ expect(bad_transition_return).to be(false)
38
+ end
39
+
40
+ it "raises on invalid transition with trigger!" do
41
+ expect { FMM.trigger!(state, :end) }.to raise_error(FMM::InvalidState)
42
+ end
43
+
44
+ it "raises on invalid event no matter what" do
45
+ expect { FMM.trigger(state, :no_such_event) }.to raise_error(FMM::InvalidEvent)
46
+ end
47
+
48
+ it "passes payload to callback" do
49
+ payloaded_state = FMM.trigger!(state, :begin, :a_very_simple_payload)
50
+ expect(payloaded_state[:all]).to eq([{ begin: :a_very_simple_payload }])
51
+ end
52
+
53
+ it "accepts the * state in transitions" do
54
+ bailed_state = FMM.trigger!(state, :bail)
55
+ expect(FMM.current(bailed_state)).to eq(:bail)
56
+ end
57
+
58
+ context "further" do
59
+ let(:step2_state) { FMM.trigger!(new_state, :advance) }
60
+
61
+ it "sets step2 state" do
62
+ expect(FMM.current(step2_state)).to eq(:step2)
63
+ end
64
+
65
+ it "runs callbacks for aliased states" do
66
+ expect(step2_state[:aliased]).to eq([{ advance: nil }])
67
+ end
68
+
69
+ it "recognizes aliased states in the transition table" do
70
+ aliased_state = FMM.trigger!(step2_state, :aliased)
71
+ expect(FMM.current(aliased_state)).to eq(:recognized)
72
+ end
73
+
74
+ it "recognizes when aliases don't apply in the transition table" do
75
+ expect { FMM.trigger!(FMM.trigger!(step2_state, :bail), :aliased) }.to raise_error(FMM::InvalidState)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,100 @@
1
+ # This file was generated by the `rspec --init` command. Conventionally, all
2
+ # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
3
+ # The generated `.rspec` file contains `--require spec_helper` which will cause
4
+ # this file to always be loaded, without a need to explicitly require it in any
5
+ # files.
6
+ #
7
+ # Given that it is always loaded, you are encouraged to keep this file as
8
+ # light-weight as possible. Requiring heavyweight dependencies from this file
9
+ # will add to the boot time of your test suite on EVERY test run, even for an
10
+ # individual file that may not need all of that loaded. Instead, consider making
11
+ # a separate helper file that requires the additional dependencies and performs
12
+ # the additional setup, and require it from the spec files that actually need
13
+ # it.
14
+ #
15
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
16
+ RSpec.configure do |config|
17
+ # rspec-expectations config goes here. You can use an alternate
18
+ # assertion/expectation library such as wrong or the stdlib/minitest
19
+ # assertions if you prefer.
20
+ config.expect_with :rspec do |expectations|
21
+ # This option will default to `true` in RSpec 4. It makes the `description`
22
+ # and `failure_message` of custom matchers include text for helper methods
23
+ # defined using `chain`, e.g.:
24
+ # be_bigger_than(2).and_smaller_than(4).description
25
+ # # => "be bigger than 2 and smaller than 4"
26
+ # ...rather than:
27
+ # # => "be bigger than 2"
28
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
29
+ end
30
+
31
+ # rspec-mocks config goes here. You can use an alternate test double
32
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
33
+ config.mock_with :rspec do |mocks|
34
+ # Prevents you from mocking or stubbing a method that does not exist on
35
+ # a real object. This is generally recommended, and will default to
36
+ # `true` in RSpec 4.
37
+ mocks.verify_partial_doubles = true
38
+ end
39
+
40
+ # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
41
+ # have no way to turn it off -- the option exists only for backwards
42
+ # compatibility in RSpec 3). It causes shared context metadata to be
43
+ # inherited by the metadata hash of host groups and examples, rather than
44
+ # triggering implicit auto-inclusion in groups with matching metadata.
45
+ config.shared_context_metadata_behavior = :apply_to_host_groups
46
+
47
+ # The settings below are suggested to provide a good initial experience
48
+ # with RSpec, but feel free to customize to your heart's content.
49
+ =begin
50
+ # This allows you to limit a spec run to individual examples or groups
51
+ # you care about by tagging them with `:focus` metadata. When nothing
52
+ # is tagged with `:focus`, all examples get run. RSpec also provides
53
+ # aliases for `it`, `describe`, and `context` that include `:focus`
54
+ # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
55
+ config.filter_run_when_matching :focus
56
+
57
+ # Allows RSpec to persist some state between runs in order to support
58
+ # the `--only-failures` and `--next-failure` CLI options. We recommend
59
+ # you configure your source control system to ignore this file.
60
+ config.example_status_persistence_file_path = "spec/examples.txt"
61
+
62
+ # Limits the available syntax to the non-monkey patched syntax that is
63
+ # recommended. For more details, see:
64
+ # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/
65
+ # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
66
+ # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode
67
+ config.disable_monkey_patching!
68
+
69
+ # This setting enables warnings. It's recommended, but in some cases may
70
+ # be too noisy due to issues in dependencies.
71
+ config.warnings = true
72
+
73
+ # Many RSpec users commonly either run the entire suite or an individual
74
+ # file, and it's useful to allow more verbose output when running an
75
+ # individual spec file.
76
+ if config.files_to_run.one?
77
+ # Use the documentation formatter for detailed output,
78
+ # unless a formatter has already been configured
79
+ # (e.g. via a command-line flag).
80
+ config.default_formatter = "doc"
81
+ end
82
+
83
+ # Print the 10 slowest examples and example groups at the
84
+ # end of the spec run, to help surface which specs are running
85
+ # particularly slow.
86
+ config.profile_examples = 10
87
+
88
+ # Run specs in random order to surface order dependencies. If you find an
89
+ # order dependency and want to debug it, you can fix the order by providing
90
+ # the seed, which is printed after each run.
91
+ # --seed 1234
92
+ config.order = :random
93
+
94
+ # Seed global randomization in this process using the `--seed` CLI option.
95
+ # Setting this allows you to use `--seed` to deterministically reproduce
96
+ # test failures related to randomization by passing the same `--seed` value
97
+ # as the one that triggered the failure.
98
+ Kernel.srand config.seed
99
+ =end
100
+ end
@@ -0,0 +1,43 @@
1
+ module FMM
2
+ module TestMachine
3
+ extend self
4
+
5
+ def create
6
+ { _machine:
7
+ {
8
+ current: :new,
9
+ transitions: TRANSITIONS,
10
+ callbacks: CALLBACKS,
11
+ aliases: ALIASES
12
+ }
13
+ }
14
+ end
15
+
16
+ TRANSITIONS = {
17
+ begin: { :new => :step1 },
18
+ advance: { :step1 => :step2, :step2 => :step3 },
19
+ end: { :step3 => :done },
20
+ aliased: { :aliased => :recognized },
21
+ bail: { :* => :bail }
22
+ }.freeze
23
+
24
+ CALLBACKS = {
25
+ step1: lambda { |state, event, payload| step1_callback(state, event, payload) },
26
+ aliased: lambda { |s,e,p| aliased_callback(s,e,p) },
27
+ :* => lambda { |s,e,p| all_callback(s,e,p) }
28
+ }.freeze
29
+
30
+ ALIASES = {
31
+ step2: [ :aliased ],
32
+ step3: [ :aliased ]
33
+ }.freeze
34
+
35
+ # some testing methods
36
+ [ :step1, :aliased, :all ].each do |m|
37
+ define_method(:"#{m}_callback") do |state, event, payload|
38
+ call_history = (state[m] || []).push({ event => payload })
39
+ state.merge({ m => call_history })
40
+ end
41
+ end
42
+ end
43
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fmm
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Erik Cameron
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-10-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: 'FMM is a small finite state machine implementation based on Michael
28
+ Martens'' micromachine, but recast in the idioms of functional programming: instead
29
+ of mutable state we use arguments and return values, and instead of methods bound
30
+ to an instance of a class like MicroMachine, we provide utility functions that operate
31
+ on any suitable data structure.'
32
+ email:
33
+ - root@erikcameron.org
34
+ executables: []
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - ".gitignore"
39
+ - ".rspec"
40
+ - Gemfile
41
+ - LICENSE.txt
42
+ - README.md
43
+ - fmm.gemspec
44
+ - lib/fmm.rb
45
+ - spec/machine_spec.rb
46
+ - spec/spec_helper.rb
47
+ - spec/test_machine.rb
48
+ homepage: http://github.com/erikcameron/fmm
49
+ licenses:
50
+ - MIT
51
+ metadata: {}
52
+ post_install_message:
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubyforge_project:
68
+ rubygems_version: 2.6.11
69
+ signing_key:
70
+ specification_version: 4
71
+ summary: 'FMM: a minimal finite state machine with functional leanings'
72
+ test_files: []