metacosm 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 8d542356d48068642523496b69d59aa7b8bfc6c6
4
+ data.tar.gz: 42389cba5819b48ccb74a2927200f664ee5a3ad6
5
+ SHA512:
6
+ metadata.gz: 6482d50736208180e023f36cb5a4d053b64d8d2ea5c8d2d77bb14c4ae7fe9725f7fc6bbecda4d42fa18bb703b1cc183e860794e3baff566ff4444bb7e1d0e83a
7
+ data.tar.gz: 23a94920bac7b7e87f11ebefd0cf090f381f76600a6932c69edce9ea92b62a90fa4015768e6ac20cb363b56938facb7c93343cf43d7c1e05ea01d599fde5b4df
data/.document ADDED
@@ -0,0 +1,3 @@
1
+ -
2
+ ChangeLog.md
3
+ LICENSE.txt
data/.gitignore ADDED
@@ -0,0 +1,6 @@
1
+ /.bundle
2
+ /.yardoc/
3
+ /Gemfile.lock
4
+ /doc/
5
+ /pkg/
6
+ /vendor/cache/*.gem
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --colour --format documentation
data/.yardopts ADDED
@@ -0,0 +1 @@
1
+ --markup markdown --title "metacosm Documentation" --protected
data/ChangeLog.md ADDED
@@ -0,0 +1,4 @@
1
+ ### 0.1.0 / 2016-02-16
2
+
3
+ * Initial release:
4
+
data/Gemfile ADDED
@@ -0,0 +1,18 @@
1
+ source 'https://rubygems.org'
2
+
3
+ ruby '2.3.0'
4
+
5
+ gemspec
6
+
7
+ gem 'frappuccino'
8
+ gem 'activesupport'
9
+ gem 'passive_record'
10
+
11
+ group :test do
12
+ gem 'rspec-its'
13
+ end
14
+
15
+ group :development do
16
+ gem 'kramdown'
17
+ gem 'pry'
18
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2016 Joseph Weissman
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # metacosm
2
+
3
+ * [Homepage](https://rubygems.org/gems/metacosm)
4
+ * [Documentation](http://rubydoc.info/gems/metacosm/frames)
5
+ * [Email](mailto:jweissman1986 at gmail.com)
6
+
7
+ [![Code Climate GPA](https://codeclimate.com/github/deepcerulean/metacosm/badges/gpa.svg)](https://codeclimate.com/github/deepcerulean/metacosm)
8
+
9
+ ## Description
10
+
11
+ Metacosm is an awesome microframework for building reactive systems.
12
+
13
+ The idea is to enable quick prototyping of command-query separated or event-sourced systems.
14
+
15
+ One core concept is that we use commands to update "write-only" models,
16
+ which trigger events that update "read-only" view models that are used by queries.
17
+
18
+ Models only transform their state in response to commands, so their state can be reconstructed by replaying the stream of commands.
19
+
20
+ ## Features
21
+
22
+ - One interesting feature here is a sort of mock in-memory AR component called `Registrable` that is used for internal tests (note: this has been extracted to [PassiveRecord](http://github.com/deepcerulean/passive_record))
23
+
24
+ ## Examples
25
+
26
+ A Fizzbuzz implementation contrived enough to show off many of the features of the framework.
27
+
28
+ ````ruby
29
+ require 'metacosm'
30
+ include Metacosm
31
+
32
+ class Counter < Model
33
+ def initialize
34
+ @counter = 0
35
+ super
36
+ end
37
+
38
+ def fizz!
39
+ emit fizz
40
+ end
41
+
42
+ def buzz!
43
+ emit buzz
44
+ end
45
+
46
+ def increment!(inc)
47
+ @counter += inc
48
+ emit(counter_incremented)
49
+ end
50
+
51
+ protected
52
+ def fizz
53
+ FizzEvent.create(value: @counter, counter_id: @id)
54
+ end
55
+
56
+ def buzz
57
+ BuzzEvent.create(value: @counter, counter_id: @id)
58
+ end
59
+
60
+ def counter_incremented
61
+ CounterIncrementedEvent.create(
62
+ value: @counter,
63
+ counter_id: @id
64
+ )
65
+ end
66
+ end
67
+
68
+ class CounterView < View
69
+ attr_accessor :value, :counter_id
70
+ def update_value(new_value)
71
+ @value = new_value
72
+ self
73
+ end
74
+ end
75
+
76
+ class IncrementCounterCommand < Struct.new(:increment, :counter_id)
77
+ end
78
+
79
+ class IncrementCounterCommandHandler
80
+ def handle(command)
81
+ counter = Counter.find_by(command.counter_id)
82
+ counter.increment!(command.increment)
83
+ end
84
+ end
85
+
86
+ class CounterIncrementedEvent < Event
87
+ attr_accessor :value, :counter_id
88
+ end
89
+
90
+ class CounterIncrementedEventListener < EventListener
91
+ def receive(value:,counter_id:)
92
+ update_counter_view(counter_id, value)
93
+
94
+ fizz_buzz!(counter_id, value)
95
+ puts(value) unless fizz?(value) || buzz?(value)
96
+ end
97
+
98
+ def update_counter_view(counter_id, value)
99
+ counter_view = CounterView.where(counter_id: counter_id).first_or_create
100
+ counter_view.value = value
101
+ end
102
+
103
+ private
104
+ def fizz_buzz!(counter_id, n)
105
+ fire(FizzCommand.new(counter_id, n)) if fizz?(n)
106
+ fire(BuzzCommand.new(counter_id, n)) if buzz?(n)
107
+ end
108
+
109
+ def fizz?(n); n % 3 == 0 end
110
+ def buzz?(n); n % 5 == 0 end
111
+ end
112
+
113
+ class FizzCommand < Struct.new(:counter_id, :value); end
114
+ class FizzCommandHandler
115
+ def handle(command)
116
+ counter = Counter.find_by(command.counter_id)
117
+ counter.fizz!
118
+ end
119
+ end
120
+
121
+ class BuzzCommand < Struct.new(:counter_id, :value); end
122
+ class BuzzCommandHandler
123
+ def handle(command)
124
+ counter = Counter.find_by(command.counter_id)
125
+ counter.buzz!
126
+ end
127
+ end
128
+
129
+ class FizzEvent < Event
130
+ attr_accessor :value, :counter_id
131
+ end
132
+
133
+ class FizzEventListener < EventListener
134
+ def receive(event)
135
+ puts "fizz"
136
+ end
137
+ end
138
+
139
+ class BuzzEvent < Event
140
+ attr_accessor :value, :counter_id
141
+ end
142
+
143
+ class BuzzEventListener < EventListener
144
+ def receive(event)
145
+ puts "buzz"
146
+ end
147
+ end
148
+
149
+ class CounterValueQuery
150
+ def execute(counter_id:)
151
+ counter = CounterView.find_by(counter_id: counter_id)
152
+ counter.value
153
+ end
154
+ end
155
+ ````
156
+
157
+ ## Requirements
158
+
159
+ ## Install
160
+
161
+ $ gem install metacosm
162
+
163
+ ## Synopsis
164
+
165
+ $ metacosm
166
+
167
+ ## Copyright
168
+
169
+ Copyright (c) 2016 Joseph Weissman
170
+
171
+ See {file:LICENSE.txt} for details.
data/Rakefile ADDED
@@ -0,0 +1,31 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rubygems'
4
+
5
+ begin
6
+ require 'bundler/setup'
7
+ rescue LoadError => e
8
+ abort e.message
9
+ end
10
+
11
+ require 'rake'
12
+
13
+
14
+ require 'rubygems/tasks'
15
+ Gem::Tasks.new
16
+
17
+ require 'rspec/core/rake_task'
18
+ RSpec::Core::RakeTask.new
19
+
20
+ task :test => :spec
21
+ task :default => :spec
22
+
23
+ require 'yard'
24
+ YARD::Rake::YardocTask.new
25
+ task :doc => :yard
26
+
27
+ require 'cucumber/rake/task'
28
+
29
+ Cucumber::Rake::Task.new do |t|
30
+ t.cucumber_opts = %w[--format pretty]
31
+ end
data/bin/metacosm ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ root = File.expand_path(File.join(File.dirname(__FILE__),'..'))
4
+ if File.directory?(File.join(root,'.git'))
5
+ Dir.chdir(root) do
6
+ begin
7
+ require 'bundler/setup'
8
+ rescue LoadError => e
9
+ warn e.message
10
+ warn "Run `gem install bundler` to install Bundler"
11
+ exit -1
12
+ end
13
+ end
14
+ end
data/features/.gitkeep ADDED
File without changes
@@ -0,0 +1 @@
1
+ Feature: Blah blah blah
File without changes
@@ -0,0 +1 @@
1
+ @wip
data/gemspec.yml ADDED
@@ -0,0 +1,17 @@
1
+ name: metacosm
2
+ summary: "reactive simulation framework"
3
+ description: "pure Ruby functional-reactive simulation metaframework"
4
+ license: MIT
5
+ authors: Joseph Weissman
6
+ email: jweissman1986@gmail.com
7
+ homepage: https://rubygems.org/gems/metacosm
8
+ dependencies:
9
+ passive_record: ~> 0.1.8
10
+ development_dependencies:
11
+ bundler: ~> 1.10
12
+ codeclimate-test-reporter: ~> 0.1
13
+ cucumber: ~> 0.10.2
14
+ rake: ~> 10.0
15
+ rspec: ~> 3.0
16
+ rubygems-tasks: ~> 0.2
17
+ yard: ~> 0.8
data/lib/metacosm.rb ADDED
@@ -0,0 +1,159 @@
1
+ require 'passive_record'
2
+ require 'frappuccino'
3
+ require 'metacosm/version'
4
+
5
+ module Metacosm
6
+ class Model
7
+ include PassiveRecord
8
+ after_create :register_observer, :emit_creation_event
9
+
10
+ def update(attrs={})
11
+ attrs.each do |k,v|
12
+ send("#{k}=",v)
13
+ end
14
+
15
+ emit(updation_event(attrs)) if updated_event_class
16
+ end
17
+
18
+ protected
19
+ def register_observer
20
+ Simulation.current.watch(self)
21
+ end
22
+
23
+ def emit_creation_event
24
+ emit(creation_event) if created_event_class
25
+ end
26
+
27
+ def attributes_with_external_id
28
+ attrs = to_h
29
+ if attrs.key?(:id)
30
+ new_id_key = self.class.name.split('::').last.underscore + "_id"
31
+ attrs[new_id_key.to_sym] = attrs.delete(:id)
32
+ end
33
+ attrs
34
+ end
35
+
36
+ # trim down extenralized attrs for evt
37
+ def attributes_for_event(klass, additional_attrs={})
38
+ # assume evts attrs are attr_accessible?
39
+ keys_to_keep = klass.instance_methods.find_all do |method|
40
+ method != :== &&
41
+ method != :! &&
42
+ klass.instance_methods.include?(:"#{method}=")
43
+ end
44
+
45
+ attributes_with_external_id.
46
+ delete_if {|k,v| !keys_to_keep.include?(k) }.
47
+ merge(additional_attrs)
48
+ end
49
+
50
+ def assemble_event(klass, addl_attrs={})
51
+ klass.create(attributes_for_event(klass).merge(addl_attrs))
52
+ end
53
+
54
+ def creation_event
55
+ assemble_event(created_event_class)
56
+ end
57
+
58
+ def created_event_class
59
+ created_event_name = self.class.name + "CreatedEvent"
60
+ Object.const_get(created_event_name) rescue nil
61
+ end
62
+
63
+ def updation_event(changed_attrs={})
64
+ assemble_event(updated_event_class, changed_attrs)
65
+ end
66
+
67
+ def updated_event_class
68
+ updated_event_name = self.class.name + "UpdatedEvent"
69
+ Object.const_get(updated_event_name) rescue nil
70
+ end
71
+
72
+ def blacklisted_attribute_names
73
+ [ :@observer_peers ]
74
+ end
75
+ end
76
+
77
+ class View
78
+ include PassiveRecord
79
+ end
80
+
81
+ class Command
82
+ include PassiveRecord
83
+
84
+ def attrs
85
+ to_h.keep_if { |k,_| k != :id }
86
+ end
87
+
88
+ def ==(other)
89
+ attrs == other.attrs
90
+ end
91
+ end
92
+
93
+ class Event
94
+ include PassiveRecord
95
+
96
+ def attrs
97
+ to_h.keep_if { |k,_| k != :id }
98
+ end
99
+
100
+ def ==(other)
101
+ attrs == other.attrs
102
+ end
103
+ end
104
+
105
+ class EventListener < Struct.new(:simulation)
106
+ def fire(command)
107
+ self.simulation.apply(command)
108
+ end
109
+ end
110
+
111
+ class Simulation
112
+ def watch(model)
113
+ Frappuccino::Stream.new(model).on_value(&method(:receive))
114
+ end
115
+
116
+ def apply(command)
117
+ handler_for(command).handle(command.attrs)
118
+ end
119
+
120
+ def receive(event, record: true)
121
+ events.push(event) if record
122
+
123
+ listener = listener_for(event)
124
+ if event.attrs.any?
125
+ listener.receive(event.attrs)
126
+ else
127
+ listener.receive
128
+ end
129
+ end
130
+
131
+ def events
132
+ @events ||= []
133
+ end
134
+
135
+ def self.current
136
+ @current ||= new
137
+ end
138
+
139
+ def clear!
140
+ @events = []
141
+ end
142
+
143
+ protected
144
+ def handler_for(command)
145
+ @handlers ||= {}
146
+ @handlers[command] ||= Object.const_get(command.class.name.split('::').last + "Handler").new
147
+ end
148
+
149
+ def listener_for(event)
150
+ @listeners ||= {}
151
+ @listeners[event] ||= construct_listener_for(event)
152
+ end
153
+
154
+ def construct_listener_for(event)
155
+ listener = Object.const_get(event.class.name.split('::').last + "Listener").new(self)
156
+ listener
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,4 @@
1
+ module Metacosm
2
+ # metacosm version
3
+ VERSION = "0.1.1"
4
+ end
data/metacosm.gemspec ADDED
@@ -0,0 +1,60 @@
1
+ # encoding: utf-8
2
+
3
+ require 'yaml'
4
+
5
+ Gem::Specification.new do |gem|
6
+ gemspec = YAML.load_file('gemspec.yml')
7
+
8
+ gem.name = gemspec.fetch('name')
9
+ gem.version = gemspec.fetch('version') do
10
+ lib_dir = File.join(File.dirname(__FILE__),'lib')
11
+ $LOAD_PATH << lib_dir unless $LOAD_PATH.include?(lib_dir)
12
+
13
+ require 'metacosm/version'
14
+ Metacosm::VERSION
15
+ end
16
+
17
+ gem.summary = gemspec['summary']
18
+ gem.description = gemspec['description']
19
+ gem.licenses = Array(gemspec['license'])
20
+ gem.authors = Array(gemspec['authors'])
21
+ gem.email = gemspec['email']
22
+ gem.homepage = gemspec['homepage']
23
+
24
+ glob = lambda { |patterns| gem.files & Dir[*patterns] }
25
+
26
+ gem.files = `git ls-files`.split($/)
27
+ gem.files = glob[gemspec['files']] if gemspec['files']
28
+
29
+ gem.executables = gemspec.fetch('executables') do
30
+ glob['bin/*'].map { |path| File.basename(path) }
31
+ end
32
+ gem.default_executable = gem.executables.first if Gem::VERSION < '1.7.'
33
+
34
+ gem.extensions = glob[gemspec['extensions'] || 'ext/**/extconf.rb']
35
+ gem.test_files = glob[gemspec['test_files'] || '{test/{**/}*_test.rb']
36
+ gem.extra_rdoc_files = glob[gemspec['extra_doc_files'] || '*.{txt,md}']
37
+
38
+ gem.require_paths = Array(gemspec.fetch('require_paths') {
39
+ %w[ext lib].select { |dir| File.directory?(dir) }
40
+ })
41
+
42
+ gem.requirements = Array(gemspec['requirements'])
43
+ gem.required_ruby_version = gemspec['required_ruby_version']
44
+ gem.required_rubygems_version = gemspec['required_rubygems_version']
45
+ gem.post_install_message = gemspec['post_install_message']
46
+
47
+ split = lambda { |string| string.split(/,\s*/) }
48
+
49
+ if gemspec['dependencies']
50
+ gemspec['dependencies'].each do |name,versions|
51
+ gem.add_dependency(name,split[versions])
52
+ end
53
+ end
54
+
55
+ if gemspec['development_dependencies']
56
+ gemspec['development_dependencies'].each do |name,versions|
57
+ gem.add_development_dependency(name,split[versions])
58
+ end
59
+ end
60
+ end
data/schema.png ADDED
File without changes
@@ -0,0 +1,215 @@
1
+ require 'spec_helper'
2
+
3
+ describe "a simple simulation (fizzbuzz)" do
4
+ subject(:simulation) { Simulation.current }
5
+ let!(:model) { Counter.create }
6
+ let(:last_event) { simulation.events.last }
7
+
8
+ describe "#apply" do
9
+ let(:increment_counter) do
10
+ IncrementCounterCommand.create(
11
+ increment: 1, counter_id: model.id
12
+ )
13
+ end
14
+
15
+ context "one command once" do
16
+ before { simulation.apply(increment_counter) }
17
+
18
+ describe "the last event" do
19
+ subject { last_event }
20
+ it { is_expected.to be_a CounterIncrementedEvent }
21
+ its(:counter_id) { is_expected.to eql(model.id) }
22
+ its(:value) { is_expected.to eq(1) }
23
+ end
24
+
25
+ describe "querying for the counter value" do
26
+ let(:counter_value_query) do
27
+ CounterValueQuery.new
28
+ end
29
+
30
+ subject do
31
+ counter_value_query.execute(counter_id: model.id)
32
+ end
33
+
34
+ it { is_expected.to eq(1) }
35
+ end
36
+ end
37
+
38
+ context "one command ten times" do
39
+ it 'is expected to play fizz buzz' do
40
+ expect {
41
+ 10.times { simulation.apply(increment_counter) }
42
+ }.to output(%w[ 1 2 fizz 4 buzz fizz 7 8 fizz buzz ].join("\n") + "\n").to_stdout
43
+ end
44
+ end
45
+
46
+ context "one command repeatedly" do
47
+ let(:n) { 10 } # ops
48
+
49
+ context 'with a single command source' do
50
+ before do
51
+ n.times { simulation.apply(increment_counter) }
52
+ end
53
+
54
+ describe "the last event" do
55
+ subject { last_event }
56
+ it { is_expected.to be_a BuzzEvent }
57
+ end
58
+
59
+ describe "querying for the counter value" do
60
+ let(:counter_value_query) do
61
+ CounterValueQuery.new
62
+ end
63
+
64
+ subject do
65
+ counter_value_query.execute(counter_id: model.id)
66
+ end
67
+
68
+ it { is_expected.to eq(n) } #"The counter is at 1") }
69
+ end
70
+ end
71
+
72
+ context 'with concurrent command sources' do
73
+ let(:m) { 2 } # fibers
74
+ let(:threads) {
75
+ ts = []
76
+ m.times do
77
+ ts.push(Thread.new do
78
+ (n/m).times { simulation.apply(increment_counter) }
79
+ end)
80
+ end
81
+ ts
82
+ }
83
+
84
+ before do
85
+ threads.map(&:join)
86
+ end
87
+
88
+ describe "the last event" do
89
+ subject { last_event }
90
+
91
+ it { is_expected.to be_a BuzzEvent }
92
+ end
93
+
94
+ describe "querying for the counter value" do
95
+ let(:counter_value_query) do
96
+ CounterValueQuery.new
97
+ end
98
+
99
+ subject do
100
+ counter_value_query.execute(counter_id: model.id)
101
+ end
102
+
103
+ it { is_expected.to eq(n) }
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ describe "a more complex simulation (village)" do
111
+ subject(:simulation) { Simulation.current }
112
+ let!(:world) { World.create(id: world_id) }
113
+ let(:world_id) { 'world_id' }
114
+
115
+ describe "#apply" do
116
+ context 'create and populate villages' do
117
+ let(:person_id) { 'person_id' }
118
+ let(:village_id) { 'village_id' }
119
+ let(:village_name) { 'Oakville Ridge' }
120
+
121
+ let(:people_per_village) { 10 }
122
+
123
+ let(:create_village_command) do
124
+ CreateVillageCommand.create(
125
+ world_id: world_id,
126
+ village_id: village_id,
127
+ village_name: village_name
128
+ )
129
+ end
130
+
131
+ let(:rename_village_command) do
132
+ RenameVillageCommand.create(
133
+ village_id: village_id,
134
+ new_village_name: "Newcity"
135
+ )
136
+ end
137
+
138
+ let(:village_created_event) do
139
+ VillageCreatedEvent.create(
140
+ world_id: world_id,
141
+ village_id: village_id,
142
+ name: village_name
143
+ )
144
+ end
145
+
146
+ let(:populate_world_command) do
147
+ PopulateWorldCommand.create(
148
+ world_id: world_id,
149
+ name_dictionary: %w[ Alice ],
150
+ per_village: people_per_village
151
+ )
152
+ end
153
+ #
154
+ let(:create_person_command) do
155
+ CreatePersonCommand.create(
156
+ world_id: world_id,
157
+ village_id: village_id,
158
+ person_id: person_id,
159
+ person_name: "Alice"
160
+ )
161
+ end
162
+
163
+ let(:person_created_event) do
164
+ PersonCreatedEvent.create(village_id: village_id, person_id: person_id, name: "Alice")
165
+ end
166
+
167
+ let(:village_names_query) do
168
+ VillageNamesQuery.new(world_id)
169
+ end
170
+
171
+ let(:people_names_query) do
172
+ PeopleNamesQuery.new(world_id)
173
+ end
174
+
175
+ describe "handling a create village command" do
176
+ it 'should result in a village creation event' do
177
+ given_no_activity.
178
+ when(create_village_command).expect_events([village_created_event])
179
+ end
180
+ end
181
+
182
+ describe 'recieving a village created event' do
183
+ it 'should create a village view we can lookup' do
184
+ given_events([village_created_event]).
185
+ expect_query(village_names_query, to_find: ["Oakville Ridge"])
186
+ end
187
+ end
188
+
189
+ it 'should create a village and a person' do
190
+ given_no_activity.
191
+ when(create_village_command, create_person_command).
192
+ expect_events([village_created_event, person_created_event]).
193
+ expect_query(village_names_query, to_find: ["Oakville Ridge"]).
194
+ expect_query(people_names_query, to_find: ["Alice"])
195
+ end
196
+
197
+ it 'should populate the world' do
198
+ expected_names = Array.new(people_per_village) { "Alice" }
199
+
200
+ given_no_activity.
201
+ when(create_village_command, populate_world_command).
202
+ expect_query(village_names_query, to_find: ["Oakville Ridge"]).
203
+ expect_query(people_names_query, to_find: expected_names)
204
+ end
205
+
206
+ it 'should rename a village' do
207
+ given_no_activity.
208
+ when(
209
+ create_village_command,
210
+ rename_village_command
211
+ ).expect_query(village_names_query, to_find: ["Newcity"])
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,120 @@
1
+ require 'rspec'
2
+ require 'rspec/its'
3
+
4
+ require 'pry'
5
+ require 'ostruct'
6
+ require 'metacosm'
7
+
8
+ include Metacosm
9
+
10
+ require 'support/fizz_buzz'
11
+ require 'support/village'
12
+
13
+ class GivenWhenThen < Struct.new(:given_events,:when_command,:then_event_class)
14
+ include RSpec::Matchers
15
+
16
+ def when(*commands)
17
+ @when_commands ||= []
18
+ commands.each do |command|
19
+ @when_commands.push command
20
+ end
21
+ self
22
+ end
23
+
24
+ def expect_events(evts)
25
+ @then_events = evts
26
+ verify!
27
+ self
28
+ end
29
+
30
+ def expect_query(query, to_find:)
31
+ @query = query
32
+ @expected_query_results = to_find
33
+ verify!
34
+ self
35
+ end
36
+
37
+ protected
38
+
39
+ def verify!
40
+ clean_slate!
41
+ receive_events!
42
+ fire_commands!
43
+
44
+ validate_events!
45
+ validate_query!
46
+
47
+ self
48
+ end
49
+
50
+ private
51
+
52
+ def clean_slate!
53
+ PassiveRecord.drop_all
54
+ Simulation.current.clear!
55
+ self
56
+ end
57
+
58
+ def receive_events!
59
+ unless self.given_events.nil?
60
+ self.given_events.each do |evt|
61
+ sim.receive(evt, record: false)
62
+ end
63
+ end
64
+ self
65
+ end
66
+
67
+ def fire_commands!
68
+ unless @when_commands.nil?
69
+ @when_commands.each do |cmd|
70
+ sim.apply(cmd)
71
+ end
72
+ end
73
+ self
74
+ end
75
+
76
+ def validate_events!
77
+ if @then_event_class
78
+ expect(@then_event_class).to eq(sim.events.last.class)
79
+ end
80
+
81
+ if @then_events
82
+ expect(@then_events).to match_array(sim.events)
83
+ end
84
+
85
+ if @then_event_attrs
86
+ @then_event_attrs.each do |k,v|
87
+ expect(sim.events.last.send(k)).to eq(v)
88
+ end
89
+ end
90
+
91
+ self
92
+ end
93
+
94
+ def validate_query!
95
+ if @query
96
+ expect(@query.execute).to eq(@expected_query_results)
97
+ end
98
+ self
99
+ end
100
+
101
+ def sim
102
+ @sim ||= Simulation.current
103
+ end
104
+ end
105
+
106
+ module Metacosm
107
+ module SpecHelpers
108
+ def given_no_activity
109
+ GivenWhenThen.new
110
+ end
111
+
112
+ def given_events(events)
113
+ GivenWhenThen.new(events)
114
+ end
115
+ end
116
+ end
117
+
118
+ RSpec.configure do |c|
119
+ c.include Metacosm::SpecHelpers
120
+ end
@@ -0,0 +1,128 @@
1
+ class Counter < Model
2
+ def initialize
3
+ @counter = 0
4
+ super
5
+ end
6
+
7
+ def fizz!
8
+ emit fizz
9
+ end
10
+
11
+ def buzz!
12
+ emit buzz
13
+ end
14
+
15
+ def increment!(inc)
16
+ @counter += inc
17
+ emit(counter_incremented)
18
+ end
19
+
20
+ protected
21
+ def fizz
22
+ FizzEvent.create
23
+ end
24
+
25
+ def buzz
26
+ BuzzEvent.create
27
+ end
28
+
29
+ def counter_incremented
30
+ CounterIncrementedEvent.create(
31
+ value: @counter,
32
+ counter_id: @id
33
+ )
34
+ end
35
+ end
36
+
37
+ class CounterView < View
38
+ attr_accessor :value, :counter_id
39
+ def update_value(new_value)
40
+ @value = new_value
41
+ self
42
+ end
43
+ end
44
+
45
+ class IncrementCounterCommand < Command
46
+ attr_accessor :increment, :counter_id
47
+ end
48
+
49
+ class IncrementCounterCommandHandler
50
+ def handle(increment:,counter_id:)
51
+ counter = Counter.find(counter_id)
52
+ counter.increment!(increment)
53
+ end
54
+ end
55
+
56
+ class CounterIncrementedEvent < Event
57
+ attr_accessor :value, :counter_id
58
+ end
59
+
60
+ class CounterIncrementedEventListener < EventListener
61
+ def receive(value:,counter_id:)
62
+ update_counter_view(counter_id, value)
63
+
64
+ fizz_buzz!(counter_id, value)
65
+ puts(value) unless fizz?(value) || buzz?(value)
66
+ end
67
+
68
+ def update_counter_view(counter_id, value)
69
+ counter_view = CounterView.where(counter_id: counter_id).first_or_create
70
+ counter_view.value = value
71
+ end
72
+
73
+ private
74
+ def fizz_buzz!(counter_id, n)
75
+ fire(FizzCommand.create(counter_id: counter_id)) if fizz?(n)
76
+ fire(BuzzCommand.create(counter_id: counter_id)) if buzz?(n)
77
+ end
78
+
79
+ def fizz?(n); n % 3 == 0 end
80
+ def buzz?(n); n % 5 == 0 end
81
+ end
82
+
83
+ class FizzCommand < Command
84
+ attr_accessor :counter_id
85
+ end
86
+
87
+ class FizzCommandHandler
88
+ def handle(counter_id:)
89
+ counter = Counter.find(counter_id)
90
+ counter.fizz!
91
+ end
92
+ end
93
+
94
+ class BuzzCommand < Command
95
+ attr_accessor :counter_id
96
+ end
97
+
98
+ class BuzzCommandHandler
99
+ def handle(counter_id:)
100
+ counter = Counter.find(counter_id)
101
+ counter.buzz!
102
+ end
103
+ end
104
+
105
+ class FizzEvent < Event
106
+ end
107
+
108
+ class FizzEventListener < EventListener
109
+ def receive
110
+ puts "fizz"
111
+ end
112
+ end
113
+
114
+ class BuzzEvent < Event
115
+ end
116
+
117
+ class BuzzEventListener < EventListener
118
+ def receive
119
+ puts "buzz"
120
+ end
121
+ end
122
+
123
+ class CounterValueQuery
124
+ def execute(counter_id:)
125
+ counter = CounterView.find_by(counter_id: counter_id)
126
+ counter.value
127
+ end
128
+ end
@@ -0,0 +1,155 @@
1
+ class Person < Model
2
+ belongs_to :village
3
+ attr_accessor :name
4
+ end
5
+
6
+ class PersonView < View
7
+ belongs_to :village_view
8
+ attr_accessor :person_name, :person_id, :village_id
9
+ end
10
+
11
+ class Village < Model
12
+ belongs_to :world
13
+ has_many :people
14
+ attr_accessor :name
15
+ end
16
+
17
+ class VillageView < View
18
+ belongs_to :world_view
19
+ has_many :person_views
20
+ attr_accessor :name, :village_id, :world_id
21
+ end
22
+
23
+ class World < Model
24
+ has_many :villages
25
+ has_many :people, :through => :villages
26
+ end
27
+
28
+ class WorldView < View
29
+ attr_accessor :world_id
30
+ has_many :village_views
31
+ has_many :person_views, :through => :village_views
32
+ end
33
+
34
+ class CreateVillageCommand < Command
35
+ attr_accessor :world_id, :village_id, :village_name
36
+ end
37
+
38
+ class CreateVillageCommandHandler
39
+ def handle(world_id:,village_id:,village_name:)
40
+ world = World.where(id: world_id).first_or_create
41
+ world.create_village(name: village_name, id: village_id)
42
+ self
43
+ end
44
+ end
45
+
46
+ class VillageCreatedEvent < Event
47
+ attr_accessor :village_id, :name, :world_id
48
+ end
49
+
50
+ class VillageCreatedEventListener < EventListener
51
+ def receive(world_id:, village_id:, name:)
52
+ world = WorldView.where(world_id: world_id).first_or_create
53
+ world.create_village_view(
54
+ world_id: world_id,
55
+ village_id: village_id,
56
+ name: name
57
+ )
58
+ end
59
+ end
60
+
61
+ class RenameVillageCommand < Command
62
+ attr_accessor :village_id, :new_village_name
63
+ end
64
+
65
+ class RenameVillageCommandHandler
66
+ def handle(village_id:, new_village_name:)
67
+ village = Village.find(village_id)
68
+ village.update name: new_village_name
69
+ end
70
+ end
71
+
72
+ class VillageUpdatedEvent < Event
73
+ attr_accessor :village_id, :name
74
+ end
75
+
76
+ class VillageUpdatedEventListener < EventListener
77
+ def receive(village_id:, name:)
78
+ village_view = VillageView.find_by(village_id: village_id)
79
+ village_view.name = name
80
+ end
81
+ end
82
+
83
+
84
+ class CreatePersonCommand < Command
85
+ attr_accessor :world_id, :village_id, :person_id, :person_name
86
+ end
87
+
88
+ class CreatePersonCommandHandler
89
+ def handle(world_id:, village_id:, person_id:, person_name:)
90
+ world = World.where(id: world_id).first_or_create
91
+ world.create_person(id: person_id, village_id: village_id, name: person_name)
92
+ end
93
+ end
94
+
95
+ class PersonCreatedEvent < Event
96
+ attr_accessor :name, :person_id, :village_id
97
+ end
98
+
99
+ class PersonCreatedEventListener < EventListener
100
+ def receive(name:, person_id:, village_id:)
101
+ village_view = VillageView.where(village_id: village_id).first
102
+ village_view.create_person_view(
103
+ person_name: name,
104
+ person_id: person_id,
105
+ village_id: village_id
106
+ )
107
+ end
108
+ end
109
+
110
+ class PopulateWorldCommand < Command
111
+ attr_accessor :world_id, :name_dictionary, :per_village
112
+ end
113
+
114
+ class PopulateWorldCommandHandler
115
+ def handle(world_id:, name_dictionary:, per_village:)
116
+ world_id = world_id
117
+ dictionary = name_dictionary
118
+
119
+ world = World.where(id: world_id).first_or_create
120
+
121
+ world.villages.each do |village|
122
+ name = dictionary.sample
123
+ per_village.times do
124
+ village.create_person(name: name)
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ class CreateWorldCommand < Command
131
+ attr_accessor :world_id
132
+ end
133
+
134
+ class CreateWorldCommandHandler
135
+ def handle(cmd)
136
+ world_id = cmd.world_id
137
+ World.create(id: world_id)
138
+ end
139
+ end
140
+
141
+ ## queries
142
+
143
+ class VillageNamesQuery < Struct.new(:world_id)
144
+ def execute
145
+ world = WorldView.where(world_id: world_id).first_or_create
146
+ world.village_views.map(&:name)
147
+ end
148
+ end
149
+
150
+ class PeopleNamesQuery < Struct.new(:world_id)
151
+ def execute
152
+ world_view = WorldView.where(world_id: world_id).first_or_create
153
+ world_view.person_views.flat_map(&:person_name)
154
+ end
155
+ end
metadata ADDED
@@ -0,0 +1,182 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: metacosm
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Joseph Weissman
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-02-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: passive_record
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.1.8
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.1.8
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.10'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.10'
41
+ - !ruby/object:Gem::Dependency
42
+ name: codeclimate-test-reporter
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.1'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: cucumber
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.10.2
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.10.2
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '10.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '10.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubygems-tasks
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.2'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.2'
111
+ - !ruby/object:Gem::Dependency
112
+ name: yard
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.8'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.8'
125
+ description: pure Ruby functional-reactive simulation metaframework
126
+ email: jweissman1986@gmail.com
127
+ executables:
128
+ - metacosm
129
+ extensions: []
130
+ extra_rdoc_files:
131
+ - ChangeLog.md
132
+ - LICENSE.txt
133
+ - README.md
134
+ files:
135
+ - ".document"
136
+ - ".gitignore"
137
+ - ".rspec"
138
+ - ".yardopts"
139
+ - ChangeLog.md
140
+ - Gemfile
141
+ - LICENSE.txt
142
+ - README.md
143
+ - Rakefile
144
+ - bin/metacosm
145
+ - features/.gitkeep
146
+ - features/metacosm.feature
147
+ - features/step_definitions/.gitkeep
148
+ - features/step_definitions/metacosm_steps.rb
149
+ - gemspec.yml
150
+ - lib/metacosm.rb
151
+ - lib/metacosm/version.rb
152
+ - metacosm.gemspec
153
+ - schema.png
154
+ - spec/metacosm_spec.rb
155
+ - spec/spec_helper.rb
156
+ - spec/support/fizz_buzz.rb
157
+ - spec/support/village.rb
158
+ homepage: https://rubygems.org/gems/metacosm
159
+ licenses:
160
+ - MIT
161
+ metadata: {}
162
+ post_install_message:
163
+ rdoc_options: []
164
+ require_paths:
165
+ - lib
166
+ required_ruby_version: !ruby/object:Gem::Requirement
167
+ requirements:
168
+ - - ">="
169
+ - !ruby/object:Gem::Version
170
+ version: '0'
171
+ required_rubygems_version: !ruby/object:Gem::Requirement
172
+ requirements:
173
+ - - ">="
174
+ - !ruby/object:Gem::Version
175
+ version: '0'
176
+ requirements: []
177
+ rubyforge_project:
178
+ rubygems_version: 2.5.1
179
+ signing_key:
180
+ specification_version: 4
181
+ summary: reactive simulation framework
182
+ test_files: []