mr_eko 0.2.3

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,100 @@
1
+ class MrEko::Playlist < Sequel::Model
2
+
3
+ include MrEko::Core
4
+ include MrEko::Presets
5
+
6
+ class NoSongsError < Exception; end
7
+
8
+ plugin :validation_helpers
9
+ many_to_many :songs
10
+ FORMATS = [:pls, :m3u, :text]
11
+
12
+ # Creates and returns a new Playlist from the passed <tt>options</tt>.
13
+ # <tt>options</tt> should be finder options you pass to Song plus (optionally) :name.
14
+ def self.create_from_options(options)
15
+ # TODO: Is a name (or persisting) even necessary?
16
+ pl = create(:name => options.delete(:name) || "Playlist #{rand(10000)}")
17
+ prepare_options!(options)
18
+
19
+ songs = MrEko::Song.where(options).all
20
+ if songs.size > 0
21
+ songs.each{ |song| pl.add_song(song) }
22
+ pl.save
23
+ else
24
+ pl.delete # TODO: Look into not creating Playlist in the 1st place
25
+ raise NoSongsError.new("No songs match that criteria!")
26
+ end
27
+ end
28
+
29
+ # Organize and transform!
30
+ def self.prepare_options!(options)
31
+ if preset = options.delete(:preset)
32
+ options.replace load_preset(preset)
33
+ else
34
+ unless options[:tempo].is_a? Range
35
+ min_tempo = options.delete(:min_tempo) || 0
36
+ max_tempo = options.delete(:max_tempo) || 500
37
+ options[:tempo] = min_tempo..max_tempo
38
+ end
39
+
40
+ unless options[:duration].is_a? Range
41
+ min_duration = options.delete(:min_duration) || 10 # worthless jams
42
+ max_duration = options.delete(:max_duration) || 1200 # 20 min.
43
+ options[:duration] = min_duration..max_duration
44
+ end
45
+
46
+ if options.has_key?(:mode)
47
+ options[:mode] = MrEko.mode_lookup(options[:mode])
48
+ end
49
+
50
+ if options.has_key?(:key)
51
+ options[:key] = MrEko.key_lookup(options[:key])
52
+ end
53
+ end
54
+ end
55
+
56
+ # Return the formatted playlist.
57
+ def output(format = :pls)
58
+ format = format.to_sym
59
+ raise ArgumentError.new("Format must be one of #{FORMATS.join(', ')}") unless FORMATS.include? format
60
+
61
+ case format
62
+ when :pls
63
+ create_pls
64
+ when :m3u
65
+ create_m3u
66
+ else
67
+ create_text
68
+ end
69
+ end
70
+
71
+ # Returns a text representation of the Playlist.
72
+ def create_text
73
+ songs.inject("") do |list, song|
74
+ list << "#{song.filename}, #{song.title}\n"
75
+ end
76
+ end
77
+
78
+ # Returns a PLS representation of the Playlist.
79
+ def create_pls
80
+ pls = "[playlist]\n"
81
+ pls << "NumberOfEntries=#{songs.size}\n\n"
82
+
83
+ i = 0
84
+ while i < songs.size do
85
+ num = i+1
86
+ pls << "File#{num}=#{songs[i].filename}\n"
87
+ pls << "Title#{num}=#{songs[i].title || songs[i].filename}\n"
88
+ pls << "Length#{num}=#{songs[i].duration.round}\n\n"
89
+ i+=1
90
+ end
91
+
92
+ pls << "Version=2"
93
+ end
94
+
95
+ def create_m3u
96
+ "TBD"
97
+ end
98
+ end
99
+
100
+ MrEko::Playlist.plugin :timestamps
@@ -0,0 +1,29 @@
1
+ module MrEko::Presets
2
+ FACTORY = {
3
+ :gym => {
4
+ :tempo => 125..300, # sweat, sweat, sweat!
5
+ :mode => 'major', # bring the HappyHappy
6
+ :duration => 180..300, # shorter, poppier tunes
7
+ :energy => 0.5..1.0,
8
+ :danceability => 0.4..1.0
9
+ },
10
+ :chill => {
11
+ :tempo => 60..120, # mellow
12
+ :duration => 180..600, # bring the epic, long-players
13
+ :energy => 0.2..0.5,
14
+ :danceability => 0.1..0.5
15
+ }
16
+ }
17
+
18
+ module ClassMethods
19
+
20
+ def load_preset(name)
21
+ FACTORY[name.to_sym]
22
+ end
23
+
24
+ end
25
+
26
+ def self.included(base)
27
+ base.extend(ClassMethods)
28
+ end
29
+ end
@@ -0,0 +1,122 @@
1
+ class MrEko::Song < Sequel::Model
2
+ include MrEko::Core
3
+ plugin :validation_helpers
4
+ many_to_many :playlists
5
+
6
+ # IDEA: This probably won't work since it's creating a new file,
7
+ # but could try uploading a sample of the song (faster).
8
+ # ffmpeg -y -i mogwai.mp3 -ar 22050 -ac 1 -ss 30 -t 30 output.mp3
9
+ # or
10
+ # sox mogwai.mp3 output.mp3 30 60
11
+
12
+ # Using the Echonest Musical Fingerprint lib in the hopes
13
+ # of sidestepping the mp3 upload process.
14
+ def self.enmfp_data(filename, md5)
15
+ unless File.exists?(fp_location(md5))
16
+ log 'Running ENMFP'
17
+ `#{File.join(MrEko::HOME_DIR, 'ext', 'enmfp', enmfp_binary)} "#{File.expand_path(filename)}" > #{fp_location(md5)}`
18
+ end
19
+
20
+ File.read fp_location(md5)
21
+ end
22
+
23
+ # Return the file path of the EN fingerprint JSON file
24
+ def self.fp_location(md5)
25
+ File.expand_path File.join(MrEko::FINGERPRINTS_DIR, "#{md5}.json")
26
+ end
27
+
28
+ # Use the platform-specific binary.
29
+ def self.enmfp_binary
30
+ case RUBY_PLATFORM
31
+ when /darwin/
32
+ 'codegen.Darwin'
33
+ when /686/
34
+ 'codegen.Linux-i686'
35
+ when /x86/
36
+ 'codegen.Linux-x86_64'
37
+ else
38
+ 'codegen.windows.exe'
39
+ end
40
+ end
41
+
42
+ # Returns the analysis and profile data from Echonest for the given track.
43
+ def self.get_datapoints_by_filename(filename)
44
+ analysis = MrEko.nest.track.analysis(filename)
45
+ profile = MrEko.nest.track.profile(:md5 => MrEko.md5(filename)).body.track
46
+
47
+ return [analysis, profile]
48
+ end
49
+
50
+ # TODO: Cleanup - This method is prety ugly now.
51
+ def self.create_from_file!(filename)
52
+ md5 = MrEko.md5(filename)
53
+ existing = where(:md5 => md5).first
54
+ return existing unless existing.nil?
55
+
56
+ fingerprint_data = enmfp_data(filename, md5)
57
+ fingerprint_json_data = Hashie::Mash.new(JSON.parse(fingerprint_data).first)
58
+
59
+ if fingerprint_json_data.keys.include?('error')
60
+ analysis, profile = get_datapoints_by_filename(filename)
61
+ else
62
+ log "Identifying with ENMFP code"
63
+ identify_options = {:code => fingerprint_data}
64
+ identify_options[:artist] = fingerprint_json_data.metadata.artist if fingerprint_json_data.metadata.artist
65
+ identify_options[:title] = fingerprint_json_data.metadata.title if fingerprint_json_data.metadata.title
66
+ identify_options[:release] = fingerprint_json_data.metadata.release if fingerprint_json_data.metadata.release
67
+ profile = MrEko.nest.song.identify(identify_options)
68
+
69
+ if profile.songs.empty?
70
+ # ENMFP wasn't recognized, so upload.
71
+ log "ENMP returned nothing, uploading"
72
+ analysis, profile = get_datapoints_by_filename(filename)
73
+ else
74
+ begin
75
+ profile = profile.songs.first
76
+ analysis = MrEko.nest.song.profile(:id => profile.id, :bucket => 'audio_summary').songs.first.audio_summary
77
+ rescue Exception => e
78
+ log "Issues using ENMP data, uploading \"(#{e})\""
79
+ analysis, profile = get_datapoints_by_filename(filename)
80
+ end
81
+ end
82
+ end
83
+
84
+ # TODO: add ruby-mp3info as fallback for parsing ID3 tags
85
+ # since Echonest seems a bit flaky in that dept.
86
+ song = new()
87
+ song.filename = File.expand_path(filename)
88
+ song.md5 = md5
89
+ song.code = fingerprint_json_data.code
90
+ song.tempo = analysis.tempo
91
+ song.duration = analysis.duration
92
+ song.fade_in = analysis.end_of_fade_in
93
+ song.fade_out = analysis.start_of_fade_out
94
+ song.key = analysis.key
95
+ song.mode = analysis.mode
96
+ song.loudness = analysis.loudness
97
+ song.time_signature = analysis.time_signature
98
+ song.echonest_id = profile.id
99
+ song.bitrate = profile.bitrate
100
+ song.title = profile.title
101
+ song.artist = profile.artist || profile.artist_name
102
+ song.album = profile.release
103
+ song.danceability = profile.audio_summary? ? profile.audio_summary.danceability : analysis.danceability
104
+ song.energy = profile.audio_summary? ? profile.audio_summary.energy : analysis.energy
105
+
106
+ song.save
107
+ end
108
+
109
+ def validate
110
+ super
111
+ set_md5 # no Sequel callback for this?
112
+ validates_unique :md5
113
+ end
114
+
115
+ private
116
+ def set_md5
117
+ self.md5 ||= MrEko.md5(filename)
118
+ end
119
+
120
+ end
121
+
122
+ MrEko::Song.plugin :timestamps
data/lib/mr_eko.rb ADDED
@@ -0,0 +1,101 @@
1
+ require "rubygems"
2
+ require "bundler"
3
+ Bundler.setup
4
+
5
+ require "sqlite3"
6
+ require "sequel"
7
+ require "logger"
8
+ require "hashie"
9
+ require "digest/md5"
10
+ require "echonest"
11
+
12
+ STDOUT.sync = true
13
+
14
+ EKO_ENV = ENV['EKO_ENV'] || 'development'
15
+ Sequel.default_timezone = :utc
16
+
17
+ module MrEko
18
+ VERSION = '0.2.3'
19
+ USER_DIR = File.join(ENV['HOME'], ".mreko")
20
+ FINGERPRINTS_DIR = File.join(USER_DIR, 'fingerprints')
21
+ HOME_DIR = File.join(File.dirname(__FILE__), '..')
22
+
23
+ MODES = %w(minor major)
24
+ CHROMATIC_SCALE = %w(C C# D D# E F F# G G# A A# B).freeze
25
+
26
+ class << self
27
+ attr_accessor :logger
28
+
29
+ def env
30
+ EKO_ENV
31
+ end
32
+
33
+ def connection
34
+ @connection
35
+ end
36
+
37
+ def nest
38
+ @nest
39
+ end
40
+
41
+ def md5(filename)
42
+ Digest::MD5.hexdigest(open(filename).read)
43
+ end
44
+
45
+ def setup!
46
+ @logger ||= Logger.new(STDOUT)
47
+ setup_directories!
48
+ setup_db!
49
+ setup_echonest!
50
+ end
51
+
52
+ def setup_directories!
53
+ Dir.mkdir(USER_DIR) unless File.directory?(USER_DIR)
54
+ Dir.mkdir(FINGERPRINTS_DIR) unless File.directory?(FINGERPRINTS_DIR)
55
+ end
56
+
57
+ def setup_db!
58
+ return @connection if @connection
59
+ @connection = Sequel.sqlite(db_name)
60
+ @connection.loggers << @logger
61
+ end
62
+
63
+ def setup_echonest!
64
+ @nest ||= Echonest(File.read(api_key))
65
+ end
66
+
67
+ def db_name
68
+ env == 'test' ? 'db/eko_test.db' : 'db/eko.db'
69
+ end
70
+
71
+ def api_key
72
+ [File.join(USER_DIR, 'echonest_api.key'), File.join(HOME_DIR, 'echonest_api.key')].each do |file|
73
+ return file if File.exists?(file)
74
+ end
75
+ raise "You need to create an echonest_api.key file in #{USER_DIR}"
76
+ end
77
+
78
+ # Takes 'minor' or 'major' and returns its integer representation.
79
+ def mode_lookup(mode)
80
+ MODES.index(mode.downcase)
81
+ end
82
+
83
+ # Takes a chromatic key (eg: G#) and returns its integer representation.
84
+ def key_lookup(key_letter)
85
+ CHROMATIC_SCALE.index(key_letter.upcase)
86
+ end
87
+
88
+ # Takes an integer and returns its standard (chromatic) representation.
89
+ def key_letter(key)
90
+ CHROMATIC_SCALE[key]
91
+ end
92
+ end
93
+ end
94
+
95
+
96
+ MrEko.setup!
97
+
98
+ require "lib/mr_eko/core"
99
+ require "lib/mr_eko/presets"
100
+ require "lib/mr_eko/playlist"
101
+ require "lib/mr_eko/song"
data/mr_eko.gemspec ADDED
@@ -0,0 +1,97 @@
1
+ ## This is the rakegem gemspec template. Make sure you read and understand
2
+ ## all of the comments. Some sections require modification, and others can
3
+ ## be deleted if you don't need them. Once you understand the contents of
4
+ ## this file, feel free to delete any comments that begin with two hash marks.
5
+ ## You can find comprehensive Gem::Specification documentation, at
6
+ ## http://docs.rubygems.org/read/chapter/20
7
+ Gem::Specification.new do |s|
8
+ s.specification_version = 2 if s.respond_to? :specification_version=
9
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
10
+ s.rubygems_version = '1.3.5'
11
+
12
+ ## Leave these as is they will be modified for you by the rake gemspec task.
13
+ ## If your rubyforge_project name is different, then edit it and comment out
14
+ ## the sub! line in the Rakefile
15
+ s.name = 'mr_eko'
16
+ s.version = '0.2.3'
17
+ s.date = '2011-02-08'
18
+ s.rubyforge_project = 'mr_eko'
19
+
20
+ ## Make sure your summary is short. The description may be as long
21
+ ## as you like.
22
+ s.summary = "Catalogs music file data and exposes a playlist interface"
23
+ s.description = "Catalogs music file data and exposes a playlist interface"
24
+
25
+ ## List the primary authors. If there are a bunch of authors, it's probably
26
+ ## better to set the email to an email list or something. If you don't have
27
+ ## a custom homepage, consider using your GitHub URL or the like.
28
+ s.authors = ["Ed Hickey"]
29
+ s.email = 'bassnode@gmail.com'
30
+ s.homepage = 'http://github.com/bassnode/mreko'
31
+
32
+ ## This gets added to the $LOAD_PATH so that 'lib/NAME.rb' can be required as
33
+ ## require 'NAME.rb' or'/lib/NAME/file.rb' can be as require 'NAME/file.rb'
34
+ s.require_paths = %w[lib]
35
+
36
+
37
+ ## If your gem includes any executables, list them here.
38
+ s.executables = ["mreko"]
39
+ s.default_executable = 'mreko'
40
+
41
+ ## Specify any RDoc options here. You'll want to add your README and
42
+ ## LICENSE files to the extra_rdoc_files list.
43
+ s.rdoc_options = ["--charset=UTF-8"]
44
+ s.extra_rdoc_files = %w[README.md]
45
+
46
+ ## List your runtime dependencies here. Runtime dependencies are those
47
+ ## that are needed for an end user to actually USE your code.
48
+ s.add_dependency('sequel', "= 3.15")
49
+ s.add_dependency('sqlite3-ruby', "~> 1.3")
50
+ s.add_dependency('hashie')
51
+ s.add_dependency('httpclient', "~> 2.1")
52
+ s.add_dependency('json', "= 1.4.6")
53
+
54
+ ## List your development dependencies here. Development dependencies are
55
+ ## those that are only needed during development
56
+ s.add_development_dependency('mocha', "= 0.9.8")
57
+ s.add_development_dependency('shoulda', "~> 2.11")
58
+ s.add_development_dependency('test-unit', "~> 2.1")
59
+ s.add_development_dependency("ruby-debug", "~> 0.10.3")
60
+
61
+ ## Leave this section as-is. It will be automatically generated from the
62
+ ## contents of your Git repository via the gemspec task. DO NOT REMOVE
63
+ ## THE MANIFEST COMMENTS, they are used as delimiters by the task.
64
+ # = MANIFEST =
65
+ s.files = %w[
66
+ Gemfile
67
+ README.md
68
+ Rakefile
69
+ TODO
70
+ bin/mreko
71
+ db/migrate/001_add_playlists.rb
72
+ db/migrate/002_add_songs.rb
73
+ db/migrate/003_add_useful_song_fields.rb
74
+ db/migrate/04_add_code_to_songs.rb
75
+ ext/enmfp/LICENSE
76
+ ext/enmfp/README
77
+ ext/enmfp/RELEASE_NOTES
78
+ ext/enmfp/codegen.Darwin
79
+ ext/enmfp/codegen.Linux-i686
80
+ ext/enmfp/codegen.Linux-x86_64
81
+ ext/enmfp/codegen.windows.exe
82
+ lib/mr_eko.rb
83
+ lib/mr_eko/core.rb
84
+ lib/mr_eko/playlist.rb
85
+ lib/mr_eko/presets.rb
86
+ lib/mr_eko/song.rb
87
+ mr_eko.gemspec
88
+ test/mr_eko_test.rb
89
+ test/playlist_test.rb
90
+ test/test.rb
91
+ ]
92
+ # = MANIFEST =
93
+
94
+ ## Test files will be grabbed from the file list. Make sure the path glob
95
+ ## matches what you actually use.
96
+ s.test_files = s.files.select { |path| path =~ /^test\/*_test\.rb/ }
97
+ end
@@ -0,0 +1,25 @@
1
+ class MrEkoTest < Test::Unit::TestCase
2
+
3
+ context "the module" do
4
+
5
+ should "return an Echonest API instance for nest" do
6
+ assert_instance_of Echonest::Api, MrEko.nest
7
+ end
8
+
9
+ should "return a Sequel instance for connection" do
10
+ assert_instance_of Sequel::SQLite::Database, MrEko.connection
11
+ end
12
+
13
+ # should "raise an error when there is no api.key found" do
14
+ # File.expects(:exists?).with(File.join(MrEko::USER_DIR, 'echonest_api.key')).returns(false)
15
+ # File.expects(:exists?).with(File.join(MrEko::HOME_DIR, 'echonest_api.key')).returns(false)
16
+ # assert_raise(RuntimeError){ MrEko.setup_echonest! }
17
+ # end
18
+
19
+ should "return the MD5 of the passed filename" do
20
+ md5 = Digest::MD5.hexdigest(open(__FILE__).read)
21
+ assert_equal md5, MrEko.md5(__FILE__)
22
+ end
23
+ end
24
+
25
+ end
@@ -0,0 +1,135 @@
1
+ class PlaylistTest < Test::Unit::TestCase
2
+
3
+ context "a new playlist" do
4
+ setup do
5
+ @playlist = MrEko::Playlist.new
6
+ end
7
+
8
+ should "have no songs" do
9
+ assert_equal 0, @playlist.songs.size
10
+ end
11
+ end
12
+
13
+ context "create_from_options" do
14
+
15
+ setup do
16
+ @options = {:tempo => 100..200}
17
+ MrEko::Song.delete
18
+ @playlist_count = MrEko::Playlist.count
19
+ end
20
+
21
+ should "not create a playlist when there no songs found" do
22
+ assert_equal 0, MrEko::Song.count
23
+ assert_raise(MrEko::Playlist::NoSongsError){ MrEko::Playlist.create_from_options(@options) }
24
+ assert_equal @playlist_count, MrEko::Playlist.count
25
+ end
26
+
27
+ should "create a playlist when there are songs found" do
28
+ assert MrEko::Song.insert( :tempo => @options[:tempo].max,
29
+ :filename => 'third_eye.mp3',
30
+ :artist => 'Tool',
31
+ :title => 'Third Eye',
32
+ :md5 => Digest::MD5.hexdigest(Time.now.to_s),
33
+ :created_on => Time.now,
34
+ :duration => 567
35
+ )
36
+
37
+ assert MrEko::Playlist.create_from_options(@options)
38
+ assert_equal @playlist_count + 1, MrEko::Playlist.count
39
+ end
40
+
41
+ should "filter out certain options before querying for songs" do
42
+ unfiltered_options = {:name => "Rock You in Your Face mix #{rand(1000)}", :time_signature => 4}
43
+ MrEko::Song.expects(:where).with(Not(has_key(:name))).once.returns(sequel_dataset_stub)
44
+ assert_raise(MrEko::Playlist::NoSongsError){ MrEko::Playlist.create_from_options(unfiltered_options) }
45
+ end
46
+ end
47
+
48
+ context "prepare_options!" do
49
+
50
+ context "when passed a preset option" do
51
+
52
+ should "only use the presets' options, not the others passed" do
53
+ opts = { :time_signature => 4, :preset => :gym }
54
+ MrEko::Playlist.prepare_options!(opts)
55
+ assert !opts.has_key?(:time_signature)
56
+ assert_equal MrEko::Presets::FACTORY[:gym][:tempo], opts[:tempo]
57
+ end
58
+ end
59
+
60
+ context "for tempo" do
61
+
62
+ should "not transform when tempo is a Range" do
63
+ opts = {:tempo => 160..180}
64
+ MrEko::Playlist.prepare_options!(opts)
65
+ assert_equal 160..180, opts[:tempo]
66
+ end
67
+
68
+ should "transform even when there aren't any passed tempo opts" do
69
+ opts = {:time_signature => 4}
70
+ MrEko::Playlist.prepare_options!(opts)
71
+ assert opts.has_key? :tempo
72
+ end
73
+
74
+ should "remove min and max keys" do
75
+ opts = {:min_tempo => 100, :max_tempo => 200}
76
+ MrEko::Playlist.prepare_options!(opts)
77
+ assert !opts.has_key?(:min_tempo)
78
+ assert !opts.has_key?(:max_tempo)
79
+ end
80
+
81
+ should "create a range with the passed min and max tempos" do
82
+ opts = {:min_tempo => 100, :max_tempo => 200}
83
+ MrEko::Playlist.prepare_options!(opts)
84
+ assert_equal 100..200, opts[:tempo]
85
+ end
86
+ end
87
+
88
+ context "for duration" do
89
+
90
+ should "not transform when duration is a Range" do
91
+ opts = {:duration => 200..2010}
92
+ MrEko::Playlist.prepare_options!(opts)
93
+ assert_equal 200..2010, opts[:duration]
94
+ end
95
+
96
+ should "transform even when there aren't any passed duration opts" do
97
+ opts = {:time_signature => 4}
98
+ MrEko::Playlist.prepare_options!(opts)
99
+ assert opts.has_key? :duration
100
+ end
101
+
102
+ should "remove min and max keys" do
103
+ opts = {:min_duration => 100, :max_duration => 2000}
104
+ MrEko::Playlist.prepare_options!(opts)
105
+ assert !opts.has_key?(:min_duration)
106
+ assert !opts.has_key?(:max_duration)
107
+ end
108
+
109
+ should "create a range with the passed min and max durations" do
110
+ opts = {:min_duration => 100, :max_duration => 2000}
111
+ MrEko::Playlist.prepare_options!(opts)
112
+ assert_equal 100..2000, opts[:duration]
113
+ end
114
+ end
115
+
116
+ context "for mode" do
117
+
118
+ should "transform into numeric representation" do
119
+ opts = {:mode => 'minor'}
120
+ MrEko::Playlist.prepare_options!(opts)
121
+ assert_equal 0, opts[:mode]
122
+ end
123
+ end
124
+
125
+ context "for key" do
126
+
127
+ should "transform into numeric representation" do
128
+ opts = {:key => 'C#'}
129
+ MrEko::Playlist.prepare_options!(opts)
130
+ assert_equal 1, opts[:key]
131
+ end
132
+ end
133
+
134
+ end
135
+ end
data/test/test.rb ADDED
@@ -0,0 +1,22 @@
1
+ ENV['EKO_ENV'] = 'test'
2
+ require "bundler/setup"
3
+ Bundler.setup
4
+
5
+ require 'test/unit'
6
+ require 'shoulda'
7
+ require 'mocha'
8
+ require "mr_eko"
9
+
10
+ require 'sequel/extensions/migration'
11
+ Sequel::Migrator.apply(MrEko.connection, File.join(File.dirname(__FILE__), "..", "db", "migrate"))
12
+
13
+ class Test::Unit::TestCase
14
+
15
+ # Could be fleshed out some more.
16
+ def sequel_dataset_stub
17
+ data = mock()
18
+ data.stubs(:all).returns( [] )
19
+ data
20
+ end
21
+
22
+ end