roborabb 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/HISTORY.md ADDED
@@ -0,0 +1,5 @@
1
+ # History
2
+
3
+ ## 0.0.1 / 10 December 2011
4
+
5
+ * Initial release
data/README.md ADDED
@@ -0,0 +1,61 @@
1
+ Roborabb
2
+ ========
3
+
4
+ Generates drumming practice charts in [lilypond][lilypond] notation.
5
+
6
+ <img
7
+ src="https://img.skitch.com/20111210-n7ey6x4jrmiaq11tjj1j8qqd4u.jpg"
8
+ alt='example score' />
9
+
10
+ Example
11
+ -------
12
+
13
+ Install the gem:
14
+
15
+ gem install roborabb
16
+
17
+ Then use it:
18
+
19
+ require 'roborabb'
20
+
21
+ rock_1 = Roborabb.construct(
22
+ title: "Rock",
23
+ subdivisions: 8,
24
+ unit: 8,
25
+ time_signature: "4/4",
26
+ notes: {
27
+ hihat: L{|env| true },
28
+ kick: L{|env| (env.subdivision + 0) % 4 == 0 },
29
+ snare: L{|env| (env.subdivision + 2) % 4 == 0 },
30
+ }
31
+ )
32
+
33
+ puts Roborabb::Lilypond.new(rock_1, bars: 16).to_lilypond
34
+
35
+ The resulting file is immediately compilable with [lilypond][lilypond]:
36
+
37
+ ruby examples/rock.rb > rock.ly && lilypond rock.ly # Generates rock.pdf
38
+
39
+ See `examples` directory for more.
40
+
41
+ [lilypond]: http://lilypond.org/
42
+
43
+ Compatibility
44
+ -------------
45
+
46
+ Only tested on ruby 1.9.3. Require 1.9, since it uses new style hashes.
47
+
48
+ Developing
49
+ ----------
50
+
51
+ git clone git://github.com/xaviershay/roborabb.git
52
+ bundle # Install development dependencies
53
+ bundle exec rake # Runs the specs
54
+
55
+ Any big new features require an acceptance test, bug fixes should only require
56
+ unit tests. Follow the conventions already present.
57
+
58
+ Status
59
+ ------
60
+
61
+ New, but complete.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+
4
+ begin
5
+ require 'rspec/core/rake_task'
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ task :default => :spec
10
+ rescue LoadError
11
+ $stderr.puts "rspec not available, spec task not provided"
12
+ end
data/lib/roborabb.rb ADDED
@@ -0,0 +1,16 @@
1
+ require 'roborabb/version'
2
+
3
+ require 'roborabb/core_ext'
4
+ require 'roborabb/builder'
5
+ require 'roborabb/lilypond'
6
+
7
+ module Roborabb
8
+ def construct(plan)
9
+ unless plan.has_key?(:notes)
10
+ raise(ArgumentError, "Plan does not contain :notes")
11
+ end
12
+ Builder.new(plan)
13
+ end
14
+ module_function :construct
15
+ end
16
+
@@ -0,0 +1,23 @@
1
+ module Roborabb
2
+ class Bar
3
+ ATTRIBUTES = [
4
+ :beat_structure,
5
+ :notes,
6
+ :subdivisions,
7
+ :time_signature,
8
+ :title,
9
+ :unit,
10
+ ]
11
+ attr_reader *ATTRIBUTES
12
+
13
+ def initialize(attributes)
14
+ ATTRIBUTES.each do |x|
15
+ send("#{x}=", attributes[x])
16
+ end
17
+ end
18
+
19
+ protected
20
+
21
+ attr_writer *ATTRIBUTES
22
+ end
23
+ end
@@ -0,0 +1,77 @@
1
+ require 'ostruct'
2
+
3
+ require 'roborabb/bar'
4
+
5
+ module Roborabb
6
+ class Builder
7
+ attr_reader :plan
8
+
9
+ def initialize(plan_hash)
10
+ self.plan = OpenStruct.new(plan_hash)
11
+ self.bar_env = OpenStruct.new(index: 0)
12
+ self.enumerator = Enumerator.new do |yielder|
13
+ loop do
14
+ yielder.yield(generate_bar)
15
+ bar_env.index += 1
16
+ end
17
+ end
18
+ end
19
+
20
+ def next
21
+ enumerator.next
22
+ end
23
+
24
+ protected
25
+
26
+ def generate_bar
27
+ notes = subdivisions.inject(empty_notes) do |notes, subdivision|
28
+ env = build_env(subdivision)
29
+
30
+ plan.notes.map do |name, f|
31
+ notes[name] << resolve(f, env)
32
+ end
33
+
34
+ notes
35
+ end
36
+
37
+ Bar.new(
38
+ subdivisions: subdivisions.max + 1,
39
+ unit: resolve(plan.unit, bar_env),
40
+ time_signature: resolve(plan.time_signature, bar_env),
41
+ beat_structure: resolve(plan.beat_structure, bar_env),
42
+ title: resolve(plan.title, bar_env),
43
+ notes: notes
44
+ )
45
+ end
46
+
47
+ def resolve(f, env)
48
+ if f.respond_to?(:call)
49
+ f.call(env)
50
+ else
51
+ f
52
+ end
53
+ end
54
+
55
+ def subdivisions
56
+ (0...resolve(plan.subdivisions, bar_env))
57
+ end
58
+
59
+ def empty_notes
60
+ x = plan.notes.keys.map do |name|
61
+ [name, []]
62
+ end
63
+ Hash[x]
64
+ end
65
+
66
+ def build_env(subdivision)
67
+ OpenStruct.new(
68
+ subdivision: subdivision,
69
+ bar: bar_env
70
+ )
71
+ end
72
+
73
+ attr_writer :plan
74
+ attr_accessor :enumerator
75
+ attr_accessor :bar_env
76
+ end
77
+ end
@@ -0,0 +1 @@
1
+ alias :L :lambda
@@ -0,0 +1,149 @@
1
+ require 'roborabb/bar'
2
+
3
+ module Roborabb
4
+ class Lilypond
5
+ def initialize(generator, opts)
6
+ self.generator = generator
7
+ self.opts = opts
8
+ end
9
+
10
+ def to_lilypond
11
+ score = opts[:bars].times.map do
12
+ bar = generator.next
13
+
14
+ format_bar(bar)
15
+ end
16
+
17
+ lilypond do
18
+ voice(:up) { format_bars(score, :upper) } +
19
+ voice(:down) { format_bars(score, :lower) }
20
+ end
21
+ end
22
+
23
+ protected
24
+
25
+ attr_accessor :generator, :opts, :title
26
+
27
+ def format_bars(bars, voice)
28
+ last_plan = Bar.new({})
29
+ bars.map do |bar|
30
+ plan = bar[:bar]
31
+
32
+ preamble = ""
33
+ if last_plan.time_signature != plan.time_signature
34
+ preamble += %(\\time #{plan.time_signature}\n)
35
+ end
36
+
37
+ if last_plan.beat_structure != plan.beat_structure && plan.beat_structure
38
+ preamble += %(\\set Staff.beatStructure = #'(%s)\n) % [
39
+ plan.beat_structure.join(' ')
40
+ ]
41
+ end
42
+ last_plan = plan
43
+ self.title = plan.title
44
+
45
+ preamble + bar[voice]
46
+ end.join(' | ') + ' \\bar "|."'
47
+ end
48
+
49
+ def lilypond
50
+ # Evaluating the content first is necessary to infer the title.
51
+ content = yield
52
+
53
+ <<-LP
54
+ \\version "2.14.2"
55
+ \\header {
56
+ title = "#{title}"
57
+ subtitle = " "
58
+ }
59
+ \\new DrumStaff <<
60
+ #{content}
61
+ >>
62
+ LP
63
+ end
64
+
65
+ def voice(direction)
66
+ result = <<-LP
67
+ \\new DrumVoice {
68
+ \\override Rest #'direction = ##{direction}
69
+ \\stem#{direction == :up ? "Up" : "Down"} \\drummode {
70
+ #{yield}
71
+ }
72
+ }
73
+ LP
74
+ end
75
+
76
+ def format_bar(bar)
77
+ {
78
+ bar: bar,
79
+ upper: format_notes(bar, expand(hashslice(bar.notes, :hihat))),
80
+ lower: format_notes(bar, expand(hashslice(bar.notes, :kick, :snare)))
81
+ }
82
+ end
83
+
84
+ def format_notes(bar, notes)
85
+ notes.map do |note|
86
+ if note[0].length == 1
87
+ mappings[note[0][0]] + duration(bar, note[1]).to_s
88
+ elsif note[0].length > 1
89
+ "<%s>%s" % [
90
+ note[0].map {|x| mappings[x] }.join(' '),
91
+ duration(bar, note[1])
92
+ ]
93
+ else
94
+ "r%s" % duration(bar, note[1])
95
+ end
96
+ end.join(" ")
97
+ end
98
+
99
+ def mappings
100
+ {
101
+ kick: 'bd',
102
+ snare: 'sn',
103
+ hihat: 'hh'
104
+ }
105
+ end
106
+
107
+ def duration(bar, x)
108
+ unit = bar.unit
109
+ [
110
+ unit,
111
+ unit / 2,
112
+ (unit / 2).to_s + ".",
113
+ unit / 4
114
+ ].map(&:to_s)[x-1] || raise("Unsupported duration: #{x}")
115
+ end
116
+
117
+ def hashslice(hash, *keep_keys)
118
+ h = {}
119
+ keep_keys.each do |key|
120
+ h[key] = hash[key] if hash.has_key?(key)
121
+ end
122
+ h
123
+ end
124
+
125
+ def expand(notes)
126
+ accum = []
127
+ time = 0
128
+ out = notes.values.transpose.inject([]) do |out, on_notes|
129
+ on = [*on_notes].map.with_index do |x, i|
130
+ notes.keys[i] if x
131
+ end.compact
132
+
133
+ if !on.empty? || time >= 4
134
+ if time > 0
135
+ out << [accum, time]
136
+ end
137
+ accum = on
138
+ time = 0
139
+ end
140
+ time += 1
141
+
142
+ out
143
+ end
144
+
145
+ out << [accum, time] if time > 0
146
+ out
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,3 @@
1
+ module Roborabb
2
+ VERSION = "0.0.1"
3
+ end
data/roborabb.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/roborabb/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Xavier Shay"]
6
+ gem.email = ["hello@xaviershay.com"]
7
+ gem.description = %q{Algorithmically generate practice drum scores}
8
+ gem.summary = %q{
9
+ Algorithmically generate practice drum scores. Customize algorithms with
10
+ ruby with an archaeopteryx-inspired style, output to lilypond format.
11
+ }
12
+ gem.homepage = "http://github.com/xaviershay/roborabb"
13
+
14
+ gem.executables = []
15
+ gem.files = Dir.glob("{spec,lib}/**/*.rb") + %w(
16
+ README.md
17
+ HISTORY.md
18
+ Rakefile
19
+ roborabb.gemspec
20
+ )
21
+ gem.test_files = Dir.glob("spec/**/*.rb")
22
+ gem.name = "roborabb"
23
+ gem.require_paths = ["lib"]
24
+ gem.version = Roborabb::VERSION
25
+ gem.has_rdoc = false
26
+ gem.add_development_dependency 'rspec', '~> 2.0'
27
+ gem.add_development_dependency 'rake'
28
+ end
@@ -0,0 +1,22 @@
1
+ $LOAD_PATH.unshift(File.expand_path("../../lib", __FILE__))
2
+ require 'roborabb'
3
+
4
+ describe 'outputting to lilypond' do
5
+ it 'outputs a basic rock beat' do
6
+ rabb = Roborabb.construct(
7
+ subdivisions: 8,
8
+ unit: 8,
9
+ time_signature: "4/4",
10
+ notes: {
11
+ hihat: L{|env| true },
12
+ kick: L{|env| (env.subdivision + 0) % 4 == 0 },
13
+ snare: L{|env| (env.subdivision + 2) % 4 == 0 },
14
+ }
15
+ )
16
+ ly = Roborabb::Lilypond.new(rabb, bars: 2)
17
+ output = ly.to_lilypond.lines.map(&:chomp).join
18
+ output.should include('\\time 4/4')
19
+ output.should include('hh8 hh8 hh8 hh8 | hh8 hh8 hh8 hh8')
20
+ output.should include('bd4 sn4 | bd4 sn4')
21
+ end
22
+ end
data/spec/unit_spec.rb ADDED
@@ -0,0 +1,259 @@
1
+ $LOAD_PATH.unshift(File.expand_path("../../lib", __FILE__))
2
+ require 'roborabb'
3
+
4
+ describe Roborabb do
5
+ def notes(rabb)
6
+ rabb.next.notes
7
+ end
8
+
9
+ def default_attributes
10
+ {
11
+ subdivisions: 2,
12
+ unit: 8,
13
+ time_signature: '1/4',
14
+ notes: {}
15
+ }
16
+ end
17
+
18
+ def construct(attributes)
19
+ Roborabb.construct(default_attributes.merge(attributes))
20
+ end
21
+
22
+ describe '#construct' do
23
+ it 'raises Argument error when no :notes given' do
24
+ lambda {
25
+ Roborabb.construct(default_attributes.delete_if {|k, _| k == :notes })
26
+ }.should raise_error(ArgumentError)
27
+ end
28
+ end
29
+
30
+ describe '#next' do
31
+ it 'allows a value for notes' do
32
+ rabb = construct(
33
+ subdivisions: 2,
34
+ notes: { a: 'A' }
35
+ )
36
+
37
+ notes(rabb).should == { a: ['A', 'A'] }
38
+ end
39
+
40
+ it 'includes subdivision in env yielded to notes' do
41
+ rabb = construct(
42
+ subdivisions: 3,
43
+ notes: {
44
+ a: :subdivision.to_proc
45
+ }
46
+ )
47
+
48
+ notes(rabb).should == { a: [0, 1, 2] }
49
+ end
50
+
51
+ it 'includes bar number in env yielded to config' do
52
+ rabb = construct(
53
+ subdivisions: L{|e| e.index + 1 },
54
+ notes: { a: 1 }
55
+ )
56
+
57
+ 3.times.map { notes(rabb) }.should == [
58
+ { a: [1] },
59
+ { a: [1, 1] },
60
+ { a: [1, 1, 1] }
61
+ ]
62
+ end
63
+
64
+ it 'includes bar number in env yielded to notes' do
65
+ rabb = construct(
66
+ subdivisions: 2,
67
+ notes: { a: L{|e| e.bar.index } }
68
+ )
69
+
70
+ 3.times.map { notes(rabb) }.should == [
71
+ { a: [0, 0] },
72
+ { a: [1, 1] },
73
+ { a: [2, 2] }
74
+ ]
75
+ end
76
+
77
+ it 'includes subdivisons in returned object' do
78
+ rabb = construct(subdivisions: 2)
79
+ rabb.next.subdivisions.should == 2
80
+ end
81
+
82
+ it 'includes unit in returned object' do
83
+ rabb = construct(unit: 8)
84
+ rabb.next.unit.should == 8
85
+ end
86
+
87
+ it 'includes generated unit in returned object' do
88
+ rabb = construct(unit: L{|e| 8 })
89
+ rabb.next.unit.should == 8
90
+ end
91
+
92
+ it 'includes time_signature in returned object' do
93
+ rabb = construct(time_signature: "7/8")
94
+ rabb.next.time_signature.should == "7/8"
95
+ end
96
+
97
+ it 'includes generated time_signature in returned object' do
98
+ rabb = construct(time_signature: L{|e| "7/8" })
99
+ rabb.next.time_signature.should == "7/8"
100
+ end
101
+
102
+ it 'includes beat_structure in returned object' do
103
+ rabb = construct(beat_structure: [3, 2, 2])
104
+ rabb.next.beat_structure.should == [3, 2, 2]
105
+ end
106
+
107
+ it 'includes generated beat_structure in returned object' do
108
+ rabb = construct(beat_structure: L{|e| [3, 2, 2] })
109
+ rabb.next.beat_structure.should == [3, 2, 2]
110
+ end
111
+
112
+ it 'includes title in returned object' do
113
+ rabb = construct(title: "Hello")
114
+ rabb.next.title.should == "Hello"
115
+ end
116
+
117
+ it 'includes generated title in returned object' do
118
+ rabb = construct(title: L{|e| "Hello" })
119
+ rabb.next.title.should == "Hello"
120
+ end
121
+ end
122
+
123
+ end
124
+
125
+ describe Roborabb::Lilypond do
126
+ describe '#to_lilypond' do
127
+ def bar(attributes = {})
128
+ double("Bar", {
129
+ title: nil,
130
+ unit: 8,
131
+ notes: {hihat: [true]},
132
+ time_signature: "4/4",
133
+ beat_structure: [4, 4]
134
+ }.merge(attributes))
135
+ end
136
+
137
+ def output(generator, opts = {bars: 1})
138
+ formatter = described_class.new(generator.each, opts)
139
+ formatter.to_lilypond
140
+ end
141
+
142
+ it 'outputs rests' do
143
+ generator = [bar(notes: {hihat: [false]})]
144
+ output(generator).should include("r")
145
+ end
146
+
147
+ it 'outputs hihats' do
148
+ generator = [bar(notes: {hihat: [true]})]
149
+ output(generator).should include("hh")
150
+ end
151
+
152
+ it 'calculates durations correctly to a maximum of four units' do
153
+ generator = [bar(unit: 32, notes: {hihat:
154
+ [true] +
155
+ [true] + [false] * 1 +
156
+ [true] + [false] * 2 +
157
+ [true] + [false] * 3 +
158
+ [true] + [false] * 4
159
+ })]
160
+
161
+ output(generator).should include("hh32 hh16 hh16. hh8 hh8 r32")
162
+ end
163
+
164
+ it 'outputs kicks and snares' do
165
+ generator = [bar(unit: 4, notes: {
166
+ kick: [true, false],
167
+ snare: [false, true]
168
+ })]
169
+ output(generator).should include("bd4 sn4")
170
+ end
171
+
172
+ it 'can output two notes at the same time' do
173
+ generator = [bar(unit: 4, notes: {kick: [true], snare: [true]})]
174
+ output(generator).should include("<bd sn>4")
175
+ end
176
+
177
+ it 'can output a rest before a note' do
178
+ generator = [bar(unit: 8, notes: {hihat: [false, true]})]
179
+ output(generator).should include("r8 hh8")
180
+ end
181
+
182
+ it 'includes lilypond preamble' do
183
+ lilypond = output([bar])
184
+ lilypond.should include("\\version")
185
+ lilypond.should include("\\new DrumStaff")
186
+ end
187
+
188
+ it 'places hihats and kick/snare in different voices' do
189
+ generator = [bar(unit: 8, notes: {
190
+ hihat: [true, true],
191
+ kick: [true, false],
192
+ snare: [false, true]
193
+ })]
194
+ voices = output(generator).split("\\new DrumVoice")[1..-1]
195
+ voices[0].should include("hh8 hh8")
196
+ voices[0].should include("\\override Rest #'direction = #up")
197
+ voices[0].should include("\\stemUp")
198
+ voices[1].should include("bd8 sn8")
199
+ voices[1].should include("\\stemDown")
200
+ end
201
+
202
+ it 'includes bar lines' do
203
+ generator = [
204
+ bar(notes: {hihat: [true] }),
205
+ bar(notes: {hihat: [false] }),
206
+ ]
207
+ bars = output(generator, bars: 2).split('|')
208
+ bars[0].should include('hh')
209
+ bars[1].should include('r')
210
+ end
211
+
212
+ it 'includes time signature changes per bar' do
213
+ generator = [
214
+ bar(time_signature: "1/8"),
215
+ bar(time_signature: "1/8"),
216
+ bar(time_signature: "1/4"),
217
+ ]
218
+ bars = output(generator, bars: 3).split('|')
219
+ bars[0].should include(%(\\time 1/8))
220
+ bars[1].should_not include(%(\\time))
221
+ bars[2].should include(%(\\time 1/4))
222
+ end
223
+
224
+ it 'includes beat structure changes per bar' do
225
+ generator = [
226
+ bar(beat_structure: [3, 2]),
227
+ bar(beat_structure: [3, 2]),
228
+ bar(beat_structure: [2, 3]),
229
+ ]
230
+ bars = output(generator, bars: 3).split('|')
231
+ bars[0].should include(%(\\set Staff.beatStructure = #'(3 2)))
232
+ bars[1].should_not include(%(\\set Staff.beatStructure))
233
+ bars[2].should include(%(\\set Staff.beatStructure = #'(2 3)))
234
+ end
235
+
236
+ it 'does not include beat structure if none provided' do
237
+ generator = [
238
+ bar(beat_structure: [3, 2]),
239
+ bar(beat_structure: nil)
240
+ ]
241
+ bars = output(generator, bars: 2).split('|')
242
+ bars[0].should include(%(\\set Staff.beatStructure = #'(3 2)))
243
+ bars[1].should_not include(%(\\set Staff.beatStructure))
244
+ end
245
+
246
+ it 'includes a final double bar line' do
247
+ output([bar]).should include(' \\bar "|."')
248
+ end
249
+
250
+ it "includes the final bar's title as the document title" do
251
+ lilypond = output([
252
+ bar(title: 'Wrong'),
253
+ bar(title: 'Hello'),
254
+ ], bars: 2)
255
+ lilypond.should include(%(title = "Hello"))
256
+ lilypond.should_not include("Wrong")
257
+ end
258
+ end
259
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: roborabb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Xavier Shay
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2011-12-11 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rspec
16
+ requirement: &2156657040 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: '2.0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: *2156657040
25
+ - !ruby/object:Gem::Dependency
26
+ name: rake
27
+ requirement: &2156656460 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: *2156656460
36
+ description: Algorithmically generate practice drum scores
37
+ email:
38
+ - hello@xaviershay.com
39
+ executables: []
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - spec/acceptance_spec.rb
44
+ - spec/unit_spec.rb
45
+ - lib/roborabb/bar.rb
46
+ - lib/roborabb/builder.rb
47
+ - lib/roborabb/core_ext.rb
48
+ - lib/roborabb/lilypond.rb
49
+ - lib/roborabb/version.rb
50
+ - lib/roborabb.rb
51
+ - README.md
52
+ - HISTORY.md
53
+ - Rakefile
54
+ - roborabb.gemspec
55
+ homepage: http://github.com/xaviershay/roborabb
56
+ licenses: []
57
+ post_install_message:
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ none: false
63
+ requirements:
64
+ - - ! '>='
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ none: false
69
+ requirements:
70
+ - - ! '>='
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubyforge_project:
75
+ rubygems_version: 1.8.6
76
+ signing_key:
77
+ specification_version: 3
78
+ summary: Algorithmically generate practice drum scores. Customize algorithms with
79
+ ruby with an archaeopteryx-inspired style, output to lilypond format.
80
+ test_files:
81
+ - spec/acceptance_spec.rb
82
+ - spec/unit_spec.rb