dam 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Jean-Philippe Bougie
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.markdown ADDED
@@ -0,0 +1,2 @@
1
+ == Dam
2
+ A ruby framework for Activity Streams
data/Rakefile ADDED
@@ -0,0 +1,34 @@
1
+ $LOAD_PATH.unshift File.dirname(__FILE__) + '/lib'
2
+
3
+ task :default => :test
4
+
5
+ require 'rake/testtask'
6
+ Rake::TestTask.new(:test) do |test|
7
+ test.libs << 'lib' << 'test'
8
+ test.pattern = 'test/**/*_test.rb'
9
+ test.verbose = false
10
+ end
11
+
12
+
13
+ begin
14
+ require 'jeweler'
15
+ require 'dam/version'
16
+
17
+ Jeweler::Tasks.new do |gemspec|
18
+ gemspec.name = "dam"
19
+ gemspec.summary = "An activity stream framework for Ruby"
20
+ gemspec.description = ""
21
+ gemspec.email = "jp.bougie@gmail.com"
22
+ gemspec.homepage = "http://github.com/jpbougie/dam"
23
+ gemspec.authors = ["Jean-Philippe Bougie"]
24
+ gemspec.version = Dam::Version
25
+
26
+ gemspec.add_dependency "redis"
27
+ gemspec.add_dependency "yajl-ruby"
28
+ gemspec.add_development_dependency "jeweler"
29
+ gemspec.add_development_dependency "riot"
30
+ end
31
+ rescue LoadError
32
+ puts "Jeweler not available. Install it with: "
33
+ puts "gem install jeweler"
34
+ end
@@ -0,0 +1,120 @@
1
+ module Dam
2
+
3
+ private
4
+
5
+ class TypeProxy
6
+ instance_methods.each { |m| undef_method m unless m =~ /(^__|instance_eval)/ }
7
+
8
+ def initialize(type)
9
+ @type = type
10
+ end
11
+
12
+ def method_missing(meth, arg=nil, &block)
13
+
14
+ # the attribute can either be a static value, or a block to be evaluated, not both
15
+ raise ArgumentError unless (!arg.nil? ^ block_given?)
16
+
17
+ if block_given?
18
+ @type.add_attribute(:name => meth, :block => block)
19
+ else
20
+ @type.add_attribute(:name => meth, :value => arg)
21
+ end
22
+
23
+ end
24
+ end
25
+
26
+ class Context
27
+ attr_reader :params
28
+ def initialize(params); @params = params; end
29
+ end
30
+
31
+ public
32
+
33
+ class ActivityType
34
+ attr_accessor :attributes
35
+ attr_reader :name
36
+
37
+ # Class methods
38
+ def self.register(type, act)
39
+ @activity_types ||= {}
40
+ @activity_types[type] = act
41
+
42
+ act
43
+ end
44
+
45
+ def self.lookup(type)
46
+ @activity_types ||= {}
47
+ @activity_types[type]
48
+ end
49
+
50
+ # Instance methods
51
+ def initialize(name, &block)
52
+ @attributes = {}
53
+ @name = name
54
+
55
+ proxy = TypeProxy.new(self)
56
+
57
+ proxy.instance_eval(&block)
58
+ end
59
+
60
+ def add_attribute(params = {})
61
+ @attributes[params[:name].to_s] = params[:value] || params[:block]
62
+ self
63
+ end
64
+
65
+ def apply(params = {})
66
+ context = Context.new(params)
67
+ evaluated_attributes = {}
68
+ @attributes.each_pair do |attribute, value|
69
+ evaluated_attributes[attribute.to_s] = if value.respond_to? :call
70
+ context.instance_eval(&value)
71
+ else
72
+ value
73
+ end
74
+ end
75
+
76
+ Activity.new(self.name, evaluated_attributes)
77
+ end
78
+ end
79
+
80
+ class Activity
81
+
82
+ def self.[](name)
83
+ Dam::ActivityType.lookup(name)
84
+ end
85
+
86
+ attr_accessor :attributes
87
+ def initialize(type, params = {})
88
+ @attributes = params
89
+ @type = type
90
+ end
91
+
92
+ def ==(other)
93
+ @type == other.instance_variable_get("@type") && attributes == other.attributes
94
+ end
95
+
96
+ def post!
97
+ Dam.push(self)
98
+ end
99
+
100
+ def self.from_json json
101
+ attributes = Yajl::Parser.parse(json)
102
+ type = attributes.delete("_type").to_sym
103
+ new(type, attributes)
104
+ end
105
+
106
+ def to_json
107
+ Yajl::Encoder.encode(self.attributes.merge({:_type => @type}))
108
+ end
109
+
110
+ private
111
+
112
+ def method_missing(meth, *args, &block)
113
+ if @attributes.has_key? meth.to_s
114
+ @attributes[meth.to_s]
115
+ else
116
+ super
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,40 @@
1
+ require 'redis'
2
+
3
+ module Dam
4
+ class Storage
5
+ def Storage.register(name, engine)
6
+ @engines ||= {}
7
+ @engines[name] = engine
8
+ end
9
+
10
+ def self.database
11
+ return @database if instance_variable_defined? '@database'
12
+ end
13
+
14
+ def self.database=(database)
15
+ @database = database
16
+ end
17
+
18
+ def self.insert(stream, activity)
19
+ key = stream.name
20
+
21
+ self.database.push_head("stream:#{key}", activity.to_json)
22
+ self.database.ltrim("stream:#{key}", 0, (stream.limit || 10) - 1)
23
+
24
+ end
25
+
26
+ def self.get(stream_name)
27
+ self.database.list_range("stream:#{stream_name}", 0, -1)
28
+ end
29
+
30
+ def self.head(stream_name)
31
+ self.database.list_index("stream:#{stream_name}", 0)
32
+ end
33
+
34
+ private
35
+ def Storage.lookup(name)
36
+ @engines ||= {}
37
+ @engines[name]
38
+ end
39
+ end
40
+ end
data/lib/dam/stream.rb ADDED
@@ -0,0 +1,211 @@
1
+ module Dam
2
+
3
+
4
+ PLACEHOLDER_PATTERN = %r{:[^:@/-]+}
5
+
6
+ # provides the basis of the DSL to define a stream
7
+ class StreamDefinition
8
+ def initialize
9
+ @filters = []
10
+ @limit = 10
11
+ end
12
+
13
+ def limit(amount = nil)
14
+ amount.nil? ? @amount : @amount = amount
15
+ end
16
+
17
+ def filters
18
+ @filters
19
+ end
20
+
21
+ def accepts(args = {})
22
+ @filters << args
23
+ end
24
+
25
+ def params
26
+ ParamsProxy
27
+ end
28
+ end
29
+
30
+
31
+ # a class that will allow us to note which param has been used from the placeholders
32
+ class ParamsProxy
33
+ attr_reader :key
34
+ def initialize(key)
35
+ @key = key
36
+ end
37
+
38
+ def self.[] key
39
+ ParamsProxy.new(key)
40
+ end
41
+ end
42
+
43
+ # a templated stream is one which contains placeholders, and thus will only be defined through his instances
44
+ class TemplatedStream
45
+ attr_reader :name
46
+
47
+ def initialize(name, definition)
48
+ @name = name
49
+ @definition = definition
50
+
51
+ extract_placeholders!
52
+ make_glob_pattern!
53
+ make_regexp!
54
+ end
55
+
56
+ def apply(params)
57
+ if params.is_a? String
58
+ name = params
59
+ params = extract_params(name)
60
+ else
61
+ name = replace_placeholders(params)
62
+ end
63
+ Stream.new(name, @definition, :params => params)
64
+ end
65
+
66
+ def instances
67
+ elems = Dam::Storage.database.keys("stream:" + @glob_pattern)
68
+ elems.each {|elem| streams << apply(elem) }
69
+ end
70
+
71
+ def matches? what
72
+ what =~ @regexp
73
+ end
74
+
75
+ private
76
+
77
+ def replace_placeholders(params)
78
+ name = @name
79
+ params.each_pair do |key, value|
80
+ name = name.gsub(":#{key}", value.to_s)
81
+ end
82
+
83
+ name
84
+ end
85
+
86
+ def extract_params(what)
87
+ Hash[*@placeholders.collect {|pat| pat[1..-1].to_sym}.zip(@regexp.match(what).captures).flatten]
88
+ end
89
+
90
+ def extract_placeholders!
91
+ @placeholders = @name.scan(PLACEHOLDER_PATTERN)
92
+ end
93
+
94
+ def make_glob_pattern!
95
+ @glob_pattern = @placeholders.inject(@name) do |name, placeholder|
96
+ name.sub(placeholder, "*")
97
+ end
98
+ end
99
+
100
+ def make_regexp!
101
+ @regexp = Regexp.new(@placeholders.inject(@name) do |name, placeholder|
102
+ name.sub(placeholder, "([^/:-]+)")
103
+ end)
104
+ end
105
+ end
106
+
107
+ class Stream
108
+ def Stream.lookup(name)
109
+ @streams ||= {}
110
+ if @streams.has_key? name
111
+ @streams[name]
112
+ else
113
+ template = @streams.values.find {|stream| stream.respond_to?(:instances) && stream.matches?(name) }
114
+ template.apply(name) if template
115
+ end
116
+ end
117
+
118
+ def Stream.[](name)
119
+ lookup(name)
120
+ end
121
+
122
+ def Stream.register(name, stream)
123
+ @streams ||= {}
124
+ @streams[name] = stream
125
+ stream
126
+ end
127
+
128
+ def Stream.has_placeholder? string
129
+ string =~ PLACEHOLDER_PATTERN
130
+ end
131
+
132
+ def Stream.all
133
+ @streams.values.collect {|stream| stream.respond_to?(:instances) ? stream.instances : stream }.flatten
134
+ end
135
+
136
+
137
+ attr_reader :name
138
+
139
+ def initialize(name, definition, params = {})
140
+ @name = name
141
+ @definition = definition
142
+ @params = params.delete(:params)
143
+ end
144
+
145
+ def limit
146
+ @definition.limit
147
+ end
148
+
149
+ def filters
150
+ @definition.filters
151
+ end
152
+
153
+ def all
154
+ Dam::Storage.get(self.name).collect do |json|
155
+ Activity.from_json json
156
+ end
157
+ end
158
+
159
+ def first
160
+ Activity.from_json(Dam::Storage.head(self.name))
161
+ end
162
+
163
+ def matches? activity
164
+ filters.any? do |filter|
165
+ return true if filter == :all
166
+
167
+ filter.any? do |key, value|
168
+ attr_match(value, activity.attributes[key.to_s])
169
+ end
170
+ end
171
+ end
172
+
173
+ def instantiate!
174
+ ensure_exists!
175
+ self
176
+ end
177
+
178
+ private
179
+
180
+ def ensure_exists!
181
+ if Dam::Storage.database.keys("stream:#{name}").size == 0
182
+ Dam::Storage.database.push_head("stream:#{name}", 1)
183
+ Dam::Storage.database.pop_head("stream:#{name}")
184
+ end
185
+ end
186
+
187
+ def attr_match(condition, element)
188
+ # match a nil element with a nil condition
189
+ return condition.nil? if element.nil?
190
+
191
+ if condition.respond_to? :each_pair
192
+ condition.all? do |key, value|
193
+ (element.respond_to?(key) ? element.send(key) : element[key]) == eval_arg(element)
194
+ end
195
+ else
196
+ condition == eval_arg(element)
197
+ end
198
+ end
199
+
200
+ def eval_arg(arg)
201
+ case arg
202
+ when ParamsProxy
203
+ @params[arg.key]
204
+ when Proc
205
+ arg.call
206
+ else
207
+ arg
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,3 @@
1
+ module Dam
2
+ Version = "0.1.0"
3
+ end
data/lib/dam.rb ADDED
@@ -0,0 +1,39 @@
1
+ require 'dam/activity'
2
+ require 'dam/storage'
3
+ require 'dam/stream'
4
+
5
+ require 'yajl'
6
+
7
+ module Dam
8
+
9
+ def self.push(activity)
10
+ Dam::Stream.all.select {|stream| stream.matches? activity }.each do |stream|
11
+ Dam::Storage.insert(stream, activity)
12
+ end
13
+ end
14
+
15
+ def self.post(type, params = {})
16
+ act = Activity.new(Dam::ActivityType.lookup(type.to_sym), params)
17
+
18
+ act.submit!
19
+ end
20
+
21
+ def self.activity(name, &block)
22
+ act = Dam::ActivityType.new(name, &block)
23
+ Dam::ActivityType.register(name, act)
24
+ act
25
+ end
26
+
27
+ def self.stream(name, &block)
28
+ definition = StreamDefinition.new
29
+ definition.instance_eval(&block)
30
+
31
+ stream = if Stream.has_placeholder? name
32
+ TemplatedStream.new(name, definition)
33
+ else
34
+ Stream.new(name, definition)
35
+ end
36
+ Dam::Stream.register(name, stream)
37
+ end
38
+
39
+ end
@@ -0,0 +1,62 @@
1
+ require File.dirname(__FILE__) + '/test_helper'
2
+
3
+ context "a new activity type" do
4
+ setup do
5
+ Dam::activity :comment_posted do
6
+ author "Some Author"
7
+ action :post
8
+
9
+ comment do
10
+ {:id => params[:comment]}
11
+ end
12
+
13
+ project do
14
+ {:id => params[:project], :some_other_property => true }
15
+ end
16
+ published { Date.today }
17
+ text { "a comment has been posted" }
18
+ end
19
+ end
20
+
21
+ topic.kind_of(Dam::ActivityType)
22
+
23
+ asserts("is registered") { topic }.equals(Dam::ActivityType.lookup(:comment_posted))
24
+ asserts("has a name") { topic.name }.equals(:comment_posted)
25
+
26
+ asserts("has a static author") { topic.attributes["author"] }.equals("Some Author")
27
+ asserts("has a static action") { topic.attributes["action"] }.equals(:post)
28
+
29
+ asserts("has a comment proc") { topic.attributes["comment"] }.kind_of(Proc)
30
+ asserts("has a project proc") { topic.attributes["project"] }.kind_of(Proc)
31
+
32
+ asserts("has a published proc") { topic.attributes["published"] }.kind_of(Proc)
33
+ asserts("has a text proc") { topic.attributes["text"] }.kind_of(Proc)
34
+
35
+ context "can be instantiated" do
36
+ setup do
37
+ topic.apply({:comment => "ab3d", :project => "xyz" })
38
+ end
39
+
40
+ topic.kind_of(Dam::Activity)
41
+
42
+ asserts("the author has been evaluated") { topic.author }.equals("Some Author")
43
+ asserts("the action has been evaluated") { topic.action }.equals(:post)
44
+ asserts("the published date has been evaluated") { topic.published }.kind_of(Date)
45
+ asserts("the comment has been evaluated") { topic.comment }.equals({:id => "ab3d"})
46
+ asserts("the project has been evaluated") { topic.project }.equals({:id => "xyz", :some_other_property => true})
47
+ asserts("the text has been evaluated") { topic.text }.equals("a comment has been posted")
48
+ end
49
+ end
50
+
51
+
52
+ context "an activity without params" do
53
+ setup do
54
+ Dam.activity :no_params do
55
+ action :post
56
+ author "bob"
57
+ some_param 123
58
+ end
59
+ end
60
+ topic.kind_of(Dam::ActivityType)
61
+ asserts("can be applied without params") { topic.apply }.kind_of(Dam::Activity)
62
+ end
@@ -0,0 +1,106 @@
1
+ require File.dirname(__FILE__) + '/test_helper'
2
+
3
+
4
+ # Define some activities to be used in the tests
5
+ Dam.activity :comment do
6
+ action "post"
7
+ author { { "name" => params[:author] } }
8
+ end
9
+
10
+ Dam.activity :edit do
11
+ action :edit
12
+ end
13
+
14
+
15
+ context "a stream which filters the latest 25 activities" do
16
+ setup do
17
+ Dam::stream :all do
18
+ limit 25
19
+ accepts :all
20
+ end
21
+ end
22
+
23
+ topic.kind_of(Dam::Stream)
24
+ topic.equals(Dam::Stream[:all])
25
+ asserts("has one filter") { topic.filters.size }.equals(1)
26
+ asserts("has a limit of 25") { topic.limit }.equals(25)
27
+
28
+ asserts("accepts an activity") { topic.matches? Dam::Activity[:comment].apply({:author => "test"})}
29
+ end
30
+
31
+ context "a stream which only accepts edits" do
32
+ setup do
33
+ Dam::stream :edit_actions do
34
+ accepts :action => :edit
35
+ end
36
+ end
37
+
38
+ topic.kind_of(Dam::Stream)
39
+ asserts("rejects a post action") { !topic.matches? Dam::Activity[:comment].apply({:author => "test"})}
40
+ asserts("accepts an edit") { topic.matches? Dam::Activity[:edit].apply}
41
+ end
42
+
43
+ context "a stream with a complex object filter" do
44
+ setup do
45
+ Dam::stream :only_from_bob do
46
+ accepts :author => { "name" => "bob" }
47
+ end
48
+ end
49
+
50
+ topic.kind_of(Dam::Stream)
51
+ asserts("accepts a valid activity") { topic.matches? Dam::Activity[:comment].apply({:author => "bob"})}
52
+ asserts("rejects an activity with a different value") { !topic.matches? Dam::Activity[:comment].apply({:author => "not bob"})}
53
+ asserts("reject an activity which doesn't have the attribute") { !topic.matches? Dam::Activity[:edit].apply }
54
+ end
55
+
56
+ context "post an activity" do
57
+ setup do
58
+ Dam::Storage.clear!
59
+ Dam.stream :all do
60
+ accepts :all
61
+ end
62
+
63
+ act = Dam::Activity[:comment].apply(:author => "bob")
64
+ act.post!
65
+ act
66
+ end
67
+
68
+ asserts("the stream has one element") { Dam::Stream[:all].all.size }.equals(1)
69
+ topic.equals(Dam::Stream[:all].first)
70
+ end
71
+
72
+ context "post multiple activities" do
73
+ setup do
74
+ Dam::Storage.clear!
75
+ Dam.stream :only_2 do
76
+ accepts :all
77
+ limit 2
78
+ end
79
+ 3.times {|i| Dam::Activity[:comment].apply(:author => "bob_#{i}").post! }
80
+ end
81
+
82
+ asserts("the stream has been limited to 2 elements") { Dam::Stream[:only_2].all.size }.equals(2)
83
+ asserts("the first one is the last to be entered") { Dam::Stream[:only_2].first.author }.equals({"name" => "bob_2"})
84
+ end
85
+
86
+
87
+ context "a parameterized stream" do
88
+ setup do
89
+ Dam::Storage.clear!
90
+ Dam.stream "comments/:author" do
91
+ accepts :author => { "name" => params[:author] }
92
+ end
93
+ end
94
+
95
+ topic.kind_of(Dam::TemplatedStream)
96
+ asserts("has no actual instances at first") { topic.instances.size }.equals(0)
97
+
98
+ context "with an instance" do
99
+ setup do
100
+ Dam::Stream["comments/bob"].instantiate!
101
+ end
102
+
103
+ topic.kind_of(Dam::Stream)
104
+ asserts("matches a valid activity") { topic.matches? Dam::Activity[:comment].apply(:author => "bob") }
105
+ end
106
+ end
@@ -0,0 +1,19 @@
1
+ require 'rubygems'
2
+
3
+ require 'riot'
4
+
5
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
6
+
7
+ require 'dam'
8
+
9
+ DATABASE = 15 # change this if you already use database #15 for something else
10
+
11
+ Dam::Storage.database = Redis.new(:db => DATABASE) # change this to your running redis server
12
+
13
+ module Dam
14
+ class Storage
15
+ def self.clear!
16
+ @database.keys("*").each {|k| @database.delete k}
17
+ end
18
+ end
19
+ end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dam
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jean-Philippe Bougie
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-11-20 00:00:00 -05:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: redis
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: yajl-ruby
27
+ type: :runtime
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: "0"
34
+ version:
35
+ - !ruby/object:Gem::Dependency
36
+ name: jeweler
37
+ type: :development
38
+ version_requirement:
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: "0"
44
+ version:
45
+ - !ruby/object:Gem::Dependency
46
+ name: riot
47
+ type: :development
48
+ version_requirement:
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: "0"
54
+ version:
55
+ description: ""
56
+ email: jp.bougie@gmail.com
57
+ executables: []
58
+
59
+ extensions: []
60
+
61
+ extra_rdoc_files:
62
+ - LICENSE
63
+ - README.markdown
64
+ files:
65
+ - LICENSE
66
+ - README.markdown
67
+ - Rakefile
68
+ - lib/dam.rb
69
+ - lib/dam/activity.rb
70
+ - lib/dam/storage.rb
71
+ - lib/dam/stream.rb
72
+ - lib/dam/version.rb
73
+ - test/activity_test.rb
74
+ - test/stream_test.rb
75
+ - test/test_helper.rb
76
+ has_rdoc: true
77
+ homepage: http://github.com/jpbougie/dam
78
+ licenses: []
79
+
80
+ post_install_message:
81
+ rdoc_options:
82
+ - --charset=UTF-8
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: "0"
90
+ version:
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: "0"
96
+ version:
97
+ requirements: []
98
+
99
+ rubyforge_project:
100
+ rubygems_version: 1.3.5
101
+ signing_key:
102
+ specification_version: 3
103
+ summary: An activity stream framework for Ruby
104
+ test_files:
105
+ - test/activity_test.rb
106
+ - test/stream_test.rb
107
+ - test/test_helper.rb