dragonfly 0.7.7 → 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of dragonfly might be problematic. Click here for more details.
- data/Gemfile +1 -1
- data/Gemfile.rails.2.3.5 +0 -1
- data/History.md +12 -0
- data/README.md +4 -2
- data/VERSION +1 -1
- data/config.ru +1 -1
- data/dragonfly.gemspec +256 -179
- data/extra_docs/Analysers.md +15 -6
- data/extra_docs/Configuration.md +13 -2
- data/extra_docs/Encoding.md +20 -7
- data/extra_docs/GeneralUsage.md +8 -5
- data/extra_docs/Generators.md +17 -7
- data/extra_docs/Heroku.md +1 -2
- data/extra_docs/MimeTypes.md +1 -1
- data/extra_docs/Models.md +1 -1
- data/extra_docs/Mongo.md +2 -2
- data/extra_docs/Processing.md +15 -7
- data/extra_docs/Rack.md +2 -3
- data/extra_docs/Rails2.md +2 -3
- data/extra_docs/Rails3.md +2 -3
- data/extra_docs/Sinatra.md +2 -2
- data/extra_docs/URLs.md +6 -4
- data/features/3.0.3.feature +8 -0
- data/features/steps/rails_steps.rb +2 -2
- data/features/support/env.rb +1 -1
- data/fixtures/files/app/views/albums/new.html.erb +4 -4
- data/fixtures/rails_2.3.5/template.rb +0 -1
- data/fixtures/{rails_3.0.0 → rails_3.0.3}/template.rb +0 -1
- data/irbrc.rb +1 -1
- data/lib/dragonfly/analysis/image_magick_analyser.rb +47 -0
- data/lib/dragonfly/app.rb +2 -0
- data/lib/dragonfly/config/image_magick.rb +41 -0
- data/lib/dragonfly/data_storage/file_data_store.rb +4 -2
- data/lib/dragonfly/data_storage/s3data_store.rb +7 -3
- data/lib/dragonfly/encoding/image_magick_encoder.rb +57 -0
- data/lib/dragonfly/generation/hash_with_css_style_keys.rb +23 -0
- data/lib/dragonfly/generation/image_magick_generator.rb +140 -0
- data/lib/dragonfly/generation/r_magick_generator.rb +0 -18
- data/lib/dragonfly/image_magick_utils.rb +81 -0
- data/lib/dragonfly/processing/image_magick_processor.rb +99 -0
- data/lib/dragonfly/rails/images.rb +1 -1
- data/lib/dragonfly/temp_object.rb +7 -6
- data/spec/dragonfly/analysis/image_magick_analyser_spec.rb +15 -0
- data/spec/dragonfly/analysis/r_magick_analyser_spec.rb +5 -49
- data/spec/dragonfly/analysis/shared_analyser_spec.rb +51 -0
- data/spec/dragonfly/app_spec.rb +2 -0
- data/spec/dragonfly/data_storage/data_store_spec.rb +6 -0
- data/spec/dragonfly/data_storage/file_data_store_spec.rb +1 -1
- data/spec/dragonfly/data_storage/s3_data_store_spec.rb +11 -1
- data/spec/dragonfly/deprecation_spec.rb +2 -2
- data/spec/dragonfly/encoding/image_magick_encoder_spec.rb +41 -0
- data/spec/dragonfly/encoding/r_magick_encoder_spec.rb +3 -6
- data/spec/dragonfly/generation/hash_with_css_style_keys_spec.rb +24 -0
- data/spec/dragonfly/generation/image_magick_generator_spec.rb +12 -0
- data/spec/dragonfly/generation/r_magick_generator_spec.rb +12 -123
- data/spec/dragonfly/generation/shared_generator_spec.rb +91 -0
- data/spec/dragonfly/image_magick_utils_spec.rb +16 -0
- data/spec/dragonfly/processing/image_magick_processor_spec.rb +29 -0
- data/spec/dragonfly/processing/r_magick_processor_spec.rb +2 -212
- data/spec/dragonfly/processing/shared_processing_spec.rb +215 -0
- data/spec/image_matchers.rb +6 -0
- data/spec/spec_helper.rb +11 -0
- data/yard/templates/default/fulldoc/html/css/common.css +9 -2
- data/yard/templates/default/layout/html/layout.erb +12 -1
- metadata +310 -11
- data/.gitignore +0 -15
- data/features/rails_3.0.0.feature +0 -8
@@ -0,0 +1,99 @@
|
|
1
|
+
module Dragonfly
|
2
|
+
module Processing
|
3
|
+
class ImageMagickProcessor
|
4
|
+
|
5
|
+
GRAVITIES = {
|
6
|
+
'nw' => 'NorthWest',
|
7
|
+
'n' => 'North',
|
8
|
+
'ne' => 'NorthEast',
|
9
|
+
'w' => 'West',
|
10
|
+
'c' => 'Center',
|
11
|
+
'e' => 'East',
|
12
|
+
'sw' => 'SouthWest',
|
13
|
+
's' => 'South',
|
14
|
+
'se' => 'SouthEast'
|
15
|
+
}
|
16
|
+
|
17
|
+
# Geometry string patterns
|
18
|
+
RESIZE_GEOMETRY = /^\d*x\d*[><%^!]?$|^\d+@$/ # e.g. '300x200!'
|
19
|
+
CROPPED_RESIZE_GEOMETRY = /^(\d+)x(\d+)#(\w{1,2})?$/ # e.g. '20x50#ne'
|
20
|
+
CROP_GEOMETRY = /^(\d+)x(\d+)([+-]\d+)?([+-]\d+)?(\w{1,2})?$/ # e.g. '30x30+10+10'
|
21
|
+
THUMB_GEOMETRY = Regexp.union RESIZE_GEOMETRY, CROPPED_RESIZE_GEOMETRY, CROP_GEOMETRY
|
22
|
+
|
23
|
+
include ImageMagickUtils
|
24
|
+
|
25
|
+
def resize(temp_object, geometry)
|
26
|
+
convert(temp_object, "-resize '#{geometry}'")
|
27
|
+
end
|
28
|
+
|
29
|
+
def crop(temp_object, opts={})
|
30
|
+
width = opts[:width]
|
31
|
+
height = opts[:height]
|
32
|
+
gravity = GRAVITIES[opts[:gravity]]
|
33
|
+
x = "#{opts[:x] || 0}"
|
34
|
+
x = '+' + x unless x[/^[+-]/]
|
35
|
+
y = "#{opts[:y] || 0}"
|
36
|
+
y = '+' + y unless y[/^[+-]/]
|
37
|
+
|
38
|
+
convert(temp_object, "-crop #{width}x#{height}#{x}#{y}#{" -gravity #{gravity}" if gravity}")
|
39
|
+
end
|
40
|
+
|
41
|
+
def flip(temp_object)
|
42
|
+
convert(temp_object, "-flip")
|
43
|
+
end
|
44
|
+
|
45
|
+
def flop(temp_object)
|
46
|
+
convert(temp_object, "-flop")
|
47
|
+
end
|
48
|
+
|
49
|
+
def greyscale(temp_object)
|
50
|
+
convert(temp_object, "-colorspace Gray")
|
51
|
+
end
|
52
|
+
alias grayscale greyscale
|
53
|
+
|
54
|
+
def resize_and_crop(temp_object, opts={})
|
55
|
+
attrs = identify(temp_object)
|
56
|
+
current_width = attrs[:width].to_i
|
57
|
+
current_height = attrs[:height].to_i
|
58
|
+
|
59
|
+
width = opts[:width] ? opts[:width].to_i : current_width
|
60
|
+
height = opts[:height] ? opts[:height].to_i : current_height
|
61
|
+
gravity = opts[:gravity] || 'c'
|
62
|
+
|
63
|
+
if width != current_width || height != current_height
|
64
|
+
scale = [width.to_f / current_width, height.to_f / current_height].max
|
65
|
+
temp_object = TempObject.new(resize(temp_object, "#{(scale * current_width).ceil}x#{(scale * current_height).ceil}"))
|
66
|
+
end
|
67
|
+
|
68
|
+
crop(temp_object, :width => width, :height => height, :gravity => gravity)
|
69
|
+
end
|
70
|
+
|
71
|
+
def rotate(temp_object, amount, opts={})
|
72
|
+
convert(temp_object, "-rotate '#{amount}#{opts[:qualifier]}'")
|
73
|
+
end
|
74
|
+
|
75
|
+
def thumb(temp_object, geometry)
|
76
|
+
case geometry
|
77
|
+
when RESIZE_GEOMETRY
|
78
|
+
resize(temp_object, geometry)
|
79
|
+
when CROPPED_RESIZE_GEOMETRY
|
80
|
+
resize_and_crop(temp_object, :width => $1, :height => $2, :gravity => $3)
|
81
|
+
when CROP_GEOMETRY
|
82
|
+
crop(temp_object,
|
83
|
+
:width => $1,
|
84
|
+
:height => $2,
|
85
|
+
:x => $3,
|
86
|
+
:y => $4,
|
87
|
+
:gravity => $5
|
88
|
+
)
|
89
|
+
else raise ArgumentError, "Didn't recognise the geometry string #{geometry}"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def convert(temp_object, args='', format=nil)
|
94
|
+
format ? [super, {:format => format.to_sym}] : super
|
95
|
+
end
|
96
|
+
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -163,20 +163,21 @@ module Dragonfly
|
|
163
163
|
private
|
164
164
|
|
165
165
|
def initialize_from_object!(obj)
|
166
|
-
|
167
|
-
when TempObject
|
166
|
+
if obj.is_a? TempObject
|
168
167
|
@initialized_data = obj.initialized_data
|
169
168
|
@initialized_tempfile = copy_to_tempfile(obj.initialized_tempfile.path) if obj.initialized_tempfile
|
170
169
|
@initialized_file = obj.initialized_file
|
171
|
-
|
170
|
+
elsif obj.is_a? String
|
172
171
|
@initialized_data = obj
|
173
|
-
|
172
|
+
elsif obj.is_a? Tempfile
|
174
173
|
@initialized_tempfile = obj
|
175
|
-
|
174
|
+
elsif obj.is_a? File
|
176
175
|
@initialized_file = obj
|
177
176
|
self.name = File.basename(obj.path)
|
177
|
+
elsif obj.respond_to?(:tempfile)
|
178
|
+
@initialized_tempfile = obj.tempfile
|
178
179
|
else
|
179
|
-
raise ArgumentError, "#{self.class.name} must be initialized with a String, a File, a Tempfile, or
|
180
|
+
raise ArgumentError, "#{self.class.name} must be initialized with a String, a File, a Tempfile, another TempObject, or something that responds to .tempfile"
|
180
181
|
end
|
181
182
|
self.name = obj.original_filename if obj.respond_to?(:original_filename)
|
182
183
|
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'dragonfly/analysis/shared_analyser_spec'
|
3
|
+
|
4
|
+
describe Dragonfly::Analysis::ImageMagickAnalyser do
|
5
|
+
|
6
|
+
before(:each) do
|
7
|
+
image_path = File.dirname(__FILE__) + '/../../../samples/beach.png'
|
8
|
+
@image = Dragonfly::TempObject.new(File.new(image_path))
|
9
|
+
@analyser = Dragonfly::Analysis::ImageMagickAnalyser.new
|
10
|
+
@analyser.log = Logger.new(LOG_FILE)
|
11
|
+
end
|
12
|
+
|
13
|
+
it_should_behave_like "image analyser methods"
|
14
|
+
|
15
|
+
end
|
@@ -1,71 +1,27 @@
|
|
1
|
-
require
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'dragonfly/analysis/shared_analyser_spec'
|
2
3
|
|
3
4
|
describe Dragonfly::Analysis::RMagickAnalyser do
|
4
5
|
|
5
6
|
before(:each) do
|
6
7
|
image_path = File.dirname(__FILE__) + '/../../../samples/beach.png'
|
7
|
-
@
|
8
|
+
@image = Dragonfly::TempObject.new(File.new(image_path))
|
8
9
|
@analyser = Dragonfly::Analysis::RMagickAnalyser.new
|
9
10
|
@analyser.log = Logger.new(LOG_FILE)
|
10
11
|
end
|
11
|
-
|
12
|
-
describe "analysis methods", :shared => true do
|
13
|
-
|
14
|
-
it "should return the width" do
|
15
|
-
@analyser.width(@beach).should == 280
|
16
|
-
end
|
17
|
-
|
18
|
-
it "should return the height" do
|
19
|
-
@analyser.height(@beach).should == 355
|
20
|
-
end
|
21
|
-
|
22
|
-
it "should return the aspect ratio" do
|
23
|
-
@analyser.aspect_ratio(@beach).should == (280.0/355.0)
|
24
|
-
end
|
25
|
-
|
26
|
-
it "should say if it's portrait" do
|
27
|
-
@analyser.portrait?(@beach).should be_true
|
28
|
-
end
|
29
|
-
|
30
|
-
it "should say if it's landscape" do
|
31
|
-
@analyser.landscape?(@beach).should be_false
|
32
|
-
end
|
33
|
-
|
34
|
-
it "should return the number of colours" do
|
35
|
-
@analyser.number_of_colours(@beach).should == 34703
|
36
|
-
end
|
37
|
-
|
38
|
-
it "should return the depth" do
|
39
|
-
@analyser.depth(@beach).should == 8
|
40
|
-
end
|
41
|
-
|
42
|
-
it "should return the format" do
|
43
|
-
@analyser.format(@beach).should == :png
|
44
|
-
end
|
45
|
-
|
46
|
-
end
|
47
12
|
|
48
13
|
describe "when using the filesystem" do
|
49
14
|
before(:each) do
|
50
15
|
@analyser.use_filesystem = true
|
51
16
|
end
|
52
|
-
it_should_behave_like "
|
17
|
+
it_should_behave_like "image analyser methods"
|
53
18
|
end
|
54
19
|
|
55
20
|
describe "when not using the filesystem" do
|
56
21
|
before(:each) do
|
57
22
|
@analyser.use_filesystem = false
|
58
23
|
end
|
59
|
-
it_should_behave_like "
|
60
|
-
end
|
61
|
-
|
62
|
-
%w(width height aspect_ratio number_of_colours depth format portrait? landscape?).each do |meth|
|
63
|
-
it "should throw unable_to_handle in #{meth.inspect} if it's not an image file" do
|
64
|
-
temp_object = Dragonfly::TempObject.new('blah')
|
65
|
-
lambda{
|
66
|
-
@analyser.send(meth, temp_object)
|
67
|
-
}.should throw_symbol(:unable_to_handle)
|
68
|
-
end
|
24
|
+
it_should_behave_like "image analyser methods"
|
69
25
|
end
|
70
26
|
|
71
27
|
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# NEEDS:
|
2
|
+
#
|
3
|
+
# @image
|
4
|
+
# @processor
|
5
|
+
#
|
6
|
+
describe "image analyser methods", :shared => true do
|
7
|
+
|
8
|
+
it "should return the width" do
|
9
|
+
@analyser.width(@image).should == 280
|
10
|
+
end
|
11
|
+
|
12
|
+
it "should return the height" do
|
13
|
+
@analyser.height(@image).should == 355
|
14
|
+
end
|
15
|
+
|
16
|
+
it "should return the aspect ratio" do
|
17
|
+
@analyser.aspect_ratio(@image).should == (280.0/355.0)
|
18
|
+
end
|
19
|
+
|
20
|
+
it "should say if it's portrait" do
|
21
|
+
@analyser.portrait?(@image).should be_true
|
22
|
+
end
|
23
|
+
|
24
|
+
it "should say if it's landscape" do
|
25
|
+
@analyser.landscape?(@image).should be_false
|
26
|
+
end
|
27
|
+
|
28
|
+
it "should return the number of colours" do
|
29
|
+
@analyser.number_of_colours(@image).should == 34703
|
30
|
+
end
|
31
|
+
|
32
|
+
it "should return the depth" do
|
33
|
+
@analyser.depth(@image).should == 8
|
34
|
+
end
|
35
|
+
|
36
|
+
it "should return the format" do
|
37
|
+
@analyser.format(@image).should == :png
|
38
|
+
end
|
39
|
+
|
40
|
+
%w(width height aspect_ratio number_of_colours depth format portrait? landscape?).each do |meth|
|
41
|
+
it "should throw unable_to_handle in #{meth.inspect} if it's not an image file" do
|
42
|
+
suppressing_stderr do
|
43
|
+
temp_object = Dragonfly::TempObject.new('blah')
|
44
|
+
lambda{
|
45
|
+
@analyser.send(meth, temp_object)
|
46
|
+
}.should throw_symbol(:unable_to_handle)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
data/spec/dragonfly/app_spec.rb
CHANGED
@@ -235,6 +235,8 @@ describe Dragonfly::App do
|
|
235
235
|
end
|
236
236
|
|
237
237
|
{
|
238
|
+
:imagemagick => Dragonfly::Config::ImageMagick,
|
239
|
+
:image_magick => Dragonfly::Config::ImageMagick,
|
238
240
|
:rmagick => Dragonfly::Config::RMagick,
|
239
241
|
:r_magick => Dragonfly::Config::RMagick,
|
240
242
|
:rails => Dragonfly::Config::Rails,
|
@@ -13,6 +13,12 @@ describe "data_store", :shared => true do
|
|
13
13
|
temp_object2 = Dragonfly::TempObject.new('gollum')
|
14
14
|
@data_store.store(@temp_object).should_not == @data_store.store(temp_object2)
|
15
15
|
end
|
16
|
+
it "should return a unique identifier for each storage even when the first is deleted" do
|
17
|
+
uid1 = @data_store.store(@temp_object)
|
18
|
+
@data_store.destroy(uid1)
|
19
|
+
uid2 = @data_store.store(@temp_object)
|
20
|
+
uid1.should_not == uid2
|
21
|
+
end
|
16
22
|
it "should allow for passing in options as a second argument" do
|
17
23
|
@data_store.store(@temp_object, :some => :option)
|
18
24
|
end
|
@@ -33,7 +33,7 @@ describe Dragonfly::DataStorage::FileDataStore do
|
|
33
33
|
before(:each) do
|
34
34
|
# Set 'now' to a date in the past
|
35
35
|
Time.stub!(:now).and_return Time.mktime(1984,"may",4,14,28,1)
|
36
|
-
@file_pattern_prefix_without_root = '1984/05/04/'
|
36
|
+
@file_pattern_prefix_without_root = '1984/05/04/14_28_01_0_'
|
37
37
|
@file_pattern_prefix = "#{@data_store.root_path}/#{@file_pattern_prefix_without_root}"
|
38
38
|
end
|
39
39
|
|
@@ -4,7 +4,10 @@ require 'yaml'
|
|
4
4
|
|
5
5
|
describe Dragonfly::DataStorage::S3DataStore do
|
6
6
|
|
7
|
-
#
|
7
|
+
# To run these tests, put a file ".s3_spec.yml" in the dragonfly root dir, like this:
|
8
|
+
# key: XXXXXXXXXX
|
9
|
+
# secret: XXXXXXXXXX
|
10
|
+
# enabled: true
|
8
11
|
if File.exist?(file = File.expand_path('../../../../.s3_spec.yml', __FILE__))
|
9
12
|
config = YAML.load_file(file)
|
10
13
|
KEY = config['key']
|
@@ -53,6 +56,13 @@ describe Dragonfly::DataStorage::S3DataStore do
|
|
53
56
|
data, extra = @data_store.retrieve(uid)
|
54
57
|
data.should == 'eggheads'
|
55
58
|
end
|
59
|
+
|
60
|
+
it "should work fine when not using the filesystem" do
|
61
|
+
@data_store.use_filesystem = false
|
62
|
+
temp_object = Dragonfly::TempObject.new('gollum')
|
63
|
+
uid = @data_store.store(temp_object)
|
64
|
+
@data_store.retrieve(uid).should == ["gollum", {:meta=>{}, :format=>nil, :name=>nil}]
|
65
|
+
end
|
56
66
|
end
|
57
67
|
|
58
68
|
end
|
@@ -5,8 +5,8 @@ describe "Deprecated stuff" do
|
|
5
5
|
describe "job urls" do
|
6
6
|
|
7
7
|
before(:each) do
|
8
|
-
@app = test_app.configure_with(:
|
9
|
-
c.log = Logger.new($stdout)
|
8
|
+
@app = test_app.configure_with(:imagemagick) do |c|
|
9
|
+
# c.log = Logger.new($stdout)
|
10
10
|
end
|
11
11
|
@job = @app.fetch('eggs')
|
12
12
|
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Dragonfly::Encoding::ImageMagickEncoder do
|
4
|
+
|
5
|
+
before(:all) do
|
6
|
+
sample_file = File.dirname(__FILE__) + '/../../../samples/beach.png' # 280x355, 135KB
|
7
|
+
@image = Dragonfly::TempObject.new(File.new(sample_file))
|
8
|
+
@encoder = Dragonfly::Encoding::ImageMagickEncoder.new
|
9
|
+
end
|
10
|
+
|
11
|
+
describe "#encode" do
|
12
|
+
|
13
|
+
it "should encode the image to the correct format" do
|
14
|
+
image = @encoder.encode(@image, :gif)
|
15
|
+
image.should have_format('gif')
|
16
|
+
end
|
17
|
+
|
18
|
+
it "should throw :unable_to_handle if the format is not handleable" do
|
19
|
+
lambda{
|
20
|
+
@encoder.encode(@image, :goofy)
|
21
|
+
}.should throw_symbol(:unable_to_handle)
|
22
|
+
end
|
23
|
+
|
24
|
+
it "should do nothing if the image is already in the correct format" do
|
25
|
+
image = @encoder.encode(@image, :png)
|
26
|
+
image.should == @image
|
27
|
+
end
|
28
|
+
|
29
|
+
it "should allow for extra args" do
|
30
|
+
image = @encoder.encode(@image, :jpg, '-quality 1')
|
31
|
+
image.should have_format('jpeg')
|
32
|
+
image.should have_size('1.45KB')
|
33
|
+
end
|
34
|
+
|
35
|
+
it "should still work even if the image is already in the correct format and args are given" do
|
36
|
+
image = @encoder.encode(@image, :png, '-quality 1')
|
37
|
+
image.should_not == @image
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
@@ -1,4 +1,4 @@
|
|
1
|
-
require
|
1
|
+
require 'spec_helper'
|
2
2
|
|
3
3
|
describe Dragonfly::Encoding::RMagickEncoder do
|
4
4
|
|
@@ -16,12 +16,9 @@ describe Dragonfly::Encoding::RMagickEncoder do
|
|
16
16
|
end
|
17
17
|
|
18
18
|
it "should throw :unable_to_handle if the format is not handleable" do
|
19
|
-
|
20
|
-
catch :unable_to_handle do
|
19
|
+
lambda{
|
21
20
|
@encoder.encode(@image, :goofy)
|
22
|
-
|
23
|
-
end
|
24
|
-
test_string.should == "I'm a string"
|
21
|
+
}.should throw_symbol(:unable_to_handle)
|
25
22
|
end
|
26
23
|
|
27
24
|
it "should do nothing if the image is already in the correct format" do
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Dragonfly::Generation::HashWithCssStyleKeys do
|
4
|
+
|
5
|
+
before(:each) do
|
6
|
+
@hash = Dragonfly::Generation::HashWithCssStyleKeys[
|
7
|
+
:font_style => 'normal',
|
8
|
+
:'font-weight' => 'bold',
|
9
|
+
'font_colour' => 'white',
|
10
|
+
'font-size' => 23,
|
11
|
+
:hello => 'there'
|
12
|
+
]
|
13
|
+
end
|
14
|
+
|
15
|
+
describe "accessing using underscore symbol style" do
|
16
|
+
it{ @hash[:font_style].should == 'normal' }
|
17
|
+
it{ @hash[:font_weight].should == 'bold' }
|
18
|
+
it{ @hash[:font_colour].should == 'white' }
|
19
|
+
it{ @hash[:font_size].should == 23 }
|
20
|
+
it{ @hash[:hello].should == 'there' }
|
21
|
+
it{ @hash[:non_existent_key].should be_nil }
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|