blendris 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data.tar.gz.sig +0 -0
- data/History.txt +4 -0
- data/Manifest +32 -0
- data/PostInstall.txt +1 -0
- data/README.rdoc +79 -0
- data/Rakefile +22 -0
- data/autotest/discover.rb +3 -0
- data/blendris.gemspec +32 -0
- data/lib/blendris.rb +25 -0
- data/lib/blendris/accessor.rb +62 -0
- data/lib/blendris/errors.rb +3 -0
- data/lib/blendris/integer.rb +19 -0
- data/lib/blendris/list.rb +37 -0
- data/lib/blendris/model.rb +141 -0
- data/lib/blendris/node.rb +46 -0
- data/lib/blendris/reference.rb +51 -0
- data/lib/blendris/reference_base.rb +68 -0
- data/lib/blendris/reference_set.rb +65 -0
- data/lib/blendris/set.rb +39 -0
- data/lib/blendris/string.rb +19 -0
- data/lib/blendris/types.rb +14 -0
- data/lib/blendris/utils.rb +39 -0
- data/script/console +10 -0
- data/script/destroy +14 -0
- data/script/generate +14 -0
- data/spec/list_spec.rb +17 -0
- data/spec/model_spec.rb +180 -0
- data/spec/redis-tools_spec.rb +15 -0
- data/spec/ref_spec.rb +40 -0
- data/spec/set_spec.rb +20 -0
- data/spec/spec.opts +2 -0
- data/spec/spec_helper.rb +85 -0
- data/spec/string_spec.rb +20 -0
- data/tasks/rspec.rake +21 -0
- metadata +128 -0
- metadata.gz.sig +0 -0
@@ -0,0 +1,46 @@
|
|
1
|
+
module Blendris
|
2
|
+
|
3
|
+
# RedisNode is used to compose all Redis value wrapper classes.
|
4
|
+
module RedisNode
|
5
|
+
|
6
|
+
include RedisAccessor
|
7
|
+
|
8
|
+
def initialize(key, options = {})
|
9
|
+
@key = sanitize_key(key)
|
10
|
+
@default = options[:default]
|
11
|
+
@options = options
|
12
|
+
|
13
|
+
set(@default) if @default && !redis.exists(self.key)
|
14
|
+
end
|
15
|
+
|
16
|
+
def set(value)
|
17
|
+
if value
|
18
|
+
redis.set key, self.class.cast_to_redis(value, @options)
|
19
|
+
else
|
20
|
+
redis.del key
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def get
|
25
|
+
self.class.cast_from_redis redis.get(self.key), @options
|
26
|
+
end
|
27
|
+
|
28
|
+
def key
|
29
|
+
prefix + @key
|
30
|
+
end
|
31
|
+
|
32
|
+
def clear
|
33
|
+
redis.del key
|
34
|
+
end
|
35
|
+
|
36
|
+
def type
|
37
|
+
redis.type key
|
38
|
+
end
|
39
|
+
|
40
|
+
def exists?
|
41
|
+
redis.exists key
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Blendris
|
2
|
+
|
3
|
+
class RedisReference < RedisReferenceBase
|
4
|
+
|
5
|
+
include RedisNode
|
6
|
+
|
7
|
+
def ref
|
8
|
+
@ref ||= RedisString.new(@key)
|
9
|
+
end
|
10
|
+
|
11
|
+
def set(obj)
|
12
|
+
old_obj = self.get if @reverse
|
13
|
+
modified = false
|
14
|
+
refkey = self.class.cast_to_redis(obj, @options)
|
15
|
+
|
16
|
+
if refkey == nil
|
17
|
+
ref.set nil
|
18
|
+
modified = true
|
19
|
+
elsif refkey != ref.get
|
20
|
+
ref.set refkey
|
21
|
+
apply_reverse_add obj
|
22
|
+
modified = true
|
23
|
+
end
|
24
|
+
|
25
|
+
apply_reverse_delete(old_obj) if modified
|
26
|
+
|
27
|
+
obj
|
28
|
+
end
|
29
|
+
|
30
|
+
def get
|
31
|
+
self.class.cast_from_redis ref.get
|
32
|
+
end
|
33
|
+
|
34
|
+
def assign_ref(value)
|
35
|
+
self.set value
|
36
|
+
end
|
37
|
+
|
38
|
+
def remove_ref(value)
|
39
|
+
self.set nil
|
40
|
+
end
|
41
|
+
|
42
|
+
def references(value)
|
43
|
+
refval = ref.get
|
44
|
+
|
45
|
+
return true if refval.nil? && value.nil?
|
46
|
+
return refval == value.key
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
module Blendris
|
2
|
+
|
3
|
+
class RedisReferenceBase
|
4
|
+
|
5
|
+
include RedisNode
|
6
|
+
extend RedisAccessor
|
7
|
+
|
8
|
+
def initialize(key, options = {})
|
9
|
+
@model = options[:model]
|
10
|
+
@key = sanitize_key(key)
|
11
|
+
@reverse = options[:reverse]
|
12
|
+
@options = options
|
13
|
+
|
14
|
+
@klass = options[:class] || Model
|
15
|
+
@klass = constantize(camelize @klass) if @klass.kind_of? String
|
16
|
+
|
17
|
+
unless @klass.ancestors.include? Model
|
18
|
+
raise ArgumentError.new("#{klass.name} is not a model")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def apply_reverse_add(value)
|
23
|
+
if @reverse && value
|
24
|
+
reverse = value.redis_symbol(@reverse)
|
25
|
+
reverse.assign_ref(@model) if !reverse.references @model
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def apply_reverse_delete(value)
|
30
|
+
if @reverse && value
|
31
|
+
reverse = value.redis_symbol(@reverse)
|
32
|
+
reverse.remove_ref(@model) if reverse.references @model
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.cast_to_redis(obj, options = {})
|
37
|
+
expect = options[:class] || Model
|
38
|
+
expect = constantize(expect) if expect.kind_of? String
|
39
|
+
expect = Model unless expect.ancestors.include? Model
|
40
|
+
|
41
|
+
if obj == nil
|
42
|
+
nil
|
43
|
+
elsif obj.kind_of? expect
|
44
|
+
obj.key
|
45
|
+
else
|
46
|
+
raise TypeError.new("#{obj.class.name} is not a #{expect.name}")
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.cast_from_redis(refkey, options = {})
|
51
|
+
expect = options[:class] || Model
|
52
|
+
expect = constantize(expect) if expect.kind_of? String
|
53
|
+
expect = Model unless expect.ancestors.include? Model
|
54
|
+
|
55
|
+
klass = constantize(redis.get(prefix + refkey)) if refkey
|
56
|
+
|
57
|
+
if klass == nil
|
58
|
+
nil
|
59
|
+
elsif klass.ancestors.include? expect
|
60
|
+
klass.new refkey
|
61
|
+
else
|
62
|
+
raise TypeError.new("#{klass.name} is not a #{expect.name}")
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module Blendris
|
2
|
+
|
3
|
+
class RedisReferenceSet < RedisReferenceBase
|
4
|
+
|
5
|
+
include RedisNode
|
6
|
+
include Enumerable
|
7
|
+
|
8
|
+
def refs
|
9
|
+
@refs ||= RedisSet.new(@key)
|
10
|
+
end
|
11
|
+
|
12
|
+
def set(*objs)
|
13
|
+
objs.flatten!
|
14
|
+
objs.compact!
|
15
|
+
|
16
|
+
objs.each do |obj|
|
17
|
+
if refkey = self.class.cast_to_redis(obj, @options)
|
18
|
+
refs << refkey
|
19
|
+
apply_reverse_add obj
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
self
|
24
|
+
end
|
25
|
+
alias :<< :set
|
26
|
+
|
27
|
+
def delete(obj)
|
28
|
+
if refkey = self.class.cast_to_redis(obj, @options)
|
29
|
+
deleted = refs.delete(refkey)
|
30
|
+
apply_reverse_delete(obj) if deleted
|
31
|
+
deleted
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def get
|
36
|
+
self
|
37
|
+
end
|
38
|
+
|
39
|
+
def each
|
40
|
+
redis.smembers(key).each do |refkey|
|
41
|
+
yield self.class.cast_from_redis(refkey, @options)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def include?(obj)
|
46
|
+
refkey = self.class.cast_to_redis(obj, @options)
|
47
|
+
|
48
|
+
refs.include? refkey
|
49
|
+
end
|
50
|
+
|
51
|
+
def assign_ref(*values)
|
52
|
+
self.set *values
|
53
|
+
end
|
54
|
+
|
55
|
+
def remove_ref(value)
|
56
|
+
self.delete value
|
57
|
+
end
|
58
|
+
|
59
|
+
def references(value)
|
60
|
+
self.include? value
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
data/lib/blendris/set.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
module Blendris
|
2
|
+
|
3
|
+
class RedisSet
|
4
|
+
|
5
|
+
include RedisNode
|
6
|
+
include Enumerable
|
7
|
+
|
8
|
+
def initialize(key, options = {})
|
9
|
+
@key = key.to_s
|
10
|
+
@options = options
|
11
|
+
end
|
12
|
+
|
13
|
+
def each
|
14
|
+
redis.smembers(key).each do |value|
|
15
|
+
yield value
|
16
|
+
end
|
17
|
+
|
18
|
+
self
|
19
|
+
end
|
20
|
+
|
21
|
+
def <<(value)
|
22
|
+
[ value ].flatten.compact.each do |v|
|
23
|
+
redis.sadd key, v
|
24
|
+
end
|
25
|
+
|
26
|
+
self
|
27
|
+
end
|
28
|
+
|
29
|
+
def get
|
30
|
+
self
|
31
|
+
end
|
32
|
+
|
33
|
+
def delete(value)
|
34
|
+
redis.srem key, value
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Blendris
|
2
|
+
|
3
|
+
class RedisString
|
4
|
+
|
5
|
+
include RedisNode
|
6
|
+
|
7
|
+
def self.cast_to_redis(value, options = {})
|
8
|
+
raise TypeError.new("#{value.class.name} is not a string") unless value.kind_of? String
|
9
|
+
|
10
|
+
value
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.cast_from_redis(value, options = {})
|
14
|
+
value
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Blendris
|
2
|
+
|
3
|
+
module Utils
|
4
|
+
|
5
|
+
# Method lifted from Rails.
|
6
|
+
def constantize(camel_cased_word)
|
7
|
+
return if blank(camel_cased_word)
|
8
|
+
|
9
|
+
unless /\A(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)\z/ =~ camel_cased_word
|
10
|
+
raise NameError, "#{camel_cased_word.inspect} is not a valid constant name!"
|
11
|
+
end
|
12
|
+
|
13
|
+
Object.module_eval("::#{$1}", __FILE__, __LINE__)
|
14
|
+
end
|
15
|
+
|
16
|
+
# Method lifted from Rails.
|
17
|
+
def camelize(lower_case_and_underscored_word, first_letter_in_uppercase = true)
|
18
|
+
if first_letter_in_uppercase
|
19
|
+
lower_case_and_underscored_word.to_s.gsub(/\/(.?)/) { "::" + $1.upcase }.gsub(/(^|_)(.)/) { $2.upcase }
|
20
|
+
else
|
21
|
+
lower_case_and_underscored_word.first + camelize(lower_case_and_underscored_word)[1..-1]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Tests if the given object is blank.
|
26
|
+
def blank(obj)
|
27
|
+
return true if obj.nil?
|
28
|
+
return obj.strip.empty? if obj.kind_of? String
|
29
|
+
return obj.empty? if obj.respond_to? :empty?
|
30
|
+
return false
|
31
|
+
end
|
32
|
+
|
33
|
+
def sanitize_key(key)
|
34
|
+
key.to_s.gsub(/[\r\n\s]/, "_").gsub(/^:+|:+$/, "")
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
data/script/console
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# File: script/console
|
3
|
+
irb = RUBY_PLATFORM =~ /(:?mswin|mingw)/ ? 'irb.bat' : 'irb'
|
4
|
+
|
5
|
+
libs = " -r irb/completion"
|
6
|
+
# Perhaps use a console_lib to store any extra methods I may want available in the cosole
|
7
|
+
# libs << " -r #{File.dirname(__FILE__) + '/../lib/console_lib/console_logger.rb'}"
|
8
|
+
libs << " -r #{File.dirname(__FILE__) + '/../lib/blendris.rb'}"
|
9
|
+
puts "Loading blendris gem"
|
10
|
+
exec "#{irb} #{libs} --simple-prompt"
|
data/script/destroy
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..'))
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'rubigen'
|
6
|
+
rescue LoadError
|
7
|
+
require 'rubygems'
|
8
|
+
require 'rubigen'
|
9
|
+
end
|
10
|
+
require 'rubigen/scripts/destroy'
|
11
|
+
|
12
|
+
ARGV.shift if ['--help', '-h'].include?(ARGV[0])
|
13
|
+
RubiGen::Base.use_component_sources! [:rubygems, :newgem, :newgem_theme, :test_unit]
|
14
|
+
RubiGen::Scripts::Destroy.new.run(ARGV)
|
data/script/generate
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..'))
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'rubigen'
|
6
|
+
rescue LoadError
|
7
|
+
require 'rubygems'
|
8
|
+
require 'rubigen'
|
9
|
+
end
|
10
|
+
require 'rubigen/scripts/generate'
|
11
|
+
|
12
|
+
ARGV.shift if ['--help', '-h'].include?(ARGV[0])
|
13
|
+
RubiGen::Base.use_component_sources! [:rubygems, :newgem, :newgem_theme, :test_unit]
|
14
|
+
RubiGen::Scripts::Generate.new.run(ARGV)
|
data/spec/list_spec.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper.rb'
|
2
|
+
|
3
|
+
describe "redis lists" do
|
4
|
+
|
5
|
+
it "should read and write" do
|
6
|
+
@onion.sales.count.should == 0
|
7
|
+
|
8
|
+
@onion.sales << %w( to-bill to-tom to-bill to-bob to-tom )
|
9
|
+
@onion.sales.count.should == 5
|
10
|
+
@onion.sales.to_a.should == %w( to-bill to-tom to-bill to-bob to-tom )
|
11
|
+
|
12
|
+
@onion.sales.delete("to-bill").should == 2
|
13
|
+
@onion.sales.delete("to-bob").should == 1
|
14
|
+
@onion.sales.to_a.should == %w( to-tom to-tom )
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
data/spec/model_spec.rb
ADDED
@@ -0,0 +1,180 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper.rb'
|
2
|
+
|
3
|
+
describe Model do
|
4
|
+
|
5
|
+
it "should have valid keys" do
|
6
|
+
@onion.key.should == "food:onion"
|
7
|
+
@onion.name.should == "onion"
|
8
|
+
end
|
9
|
+
|
10
|
+
it "should have a working integer field" do
|
11
|
+
@onion.calories.should == 0
|
12
|
+
|
13
|
+
@onion.calories = 120
|
14
|
+
@onion.calories.should == 120
|
15
|
+
end
|
16
|
+
|
17
|
+
it "should have a valid reference" do
|
18
|
+
@onion.category.should be_nil
|
19
|
+
|
20
|
+
@onion.category = @vegetable
|
21
|
+
@onion.category.name.should == "vegetable"
|
22
|
+
end
|
23
|
+
|
24
|
+
it "should have a valid reference set" do
|
25
|
+
@fruit.foods.count.should == 0
|
26
|
+
|
27
|
+
@fruit.foods << [ @apple, @lemon ]
|
28
|
+
|
29
|
+
@fruit.foods.should be_include(@apple)
|
30
|
+
@fruit.foods.should be_include(@lemon)
|
31
|
+
@fruit.foods.should be_include(Food.new("food:lemon"))
|
32
|
+
@fruit.foods.should_not be_include(@steak)
|
33
|
+
end
|
34
|
+
|
35
|
+
it "should not allow you to instantiate with a key that doesnt match its class" do
|
36
|
+
lambda { Category.new("balogna") }.should raise_error(TypeError)
|
37
|
+
lambda { Category.new(@onion.key) }.should raise_error(TypeError)
|
38
|
+
lambda { Category.new(@vegetable.key) }.should_not raise_error(TypeError)
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should allow for complex types in the key" do
|
42
|
+
lambda { FavoriteFood.create("Billy Bob Thorton", "squeezy") }.should raise_error(TypeError)
|
43
|
+
|
44
|
+
fav = FavoriteFood.create("Billy Bob Thorton", @onion)
|
45
|
+
|
46
|
+
fav.key.should == "person:Billy_Bob_Thorton:food:onion"
|
47
|
+
fav.person.should == "Billy Bob Thorton"
|
48
|
+
fav.food.should == @onion
|
49
|
+
end
|
50
|
+
|
51
|
+
context "with single reference" do
|
52
|
+
|
53
|
+
it "should reverse to a single reference" do
|
54
|
+
@apple.sibling.should be_nil
|
55
|
+
@lemon.sibling.should be_nil
|
56
|
+
|
57
|
+
@apple.sibling = @lemon
|
58
|
+
|
59
|
+
@apple.sibling.should == @lemon
|
60
|
+
@lemon.sibling.should == @apple
|
61
|
+
|
62
|
+
@lemon.sibling = nil
|
63
|
+
|
64
|
+
@apple.sibling.should be_nil
|
65
|
+
@lemon.sibling.should be_nil
|
66
|
+
end
|
67
|
+
|
68
|
+
it "should reverse to a reference set" do
|
69
|
+
@onion.category.should be_nil
|
70
|
+
@vegetable.foods.count.should be_zero
|
71
|
+
|
72
|
+
2.times do
|
73
|
+
@onion.category = @vegetable
|
74
|
+
|
75
|
+
@onion.category.name.should == "vegetable"
|
76
|
+
@vegetable.foods.count.should == 1
|
77
|
+
@vegetable.foods.should be_include(@onion)
|
78
|
+
end
|
79
|
+
|
80
|
+
@onion.category = nil
|
81
|
+
|
82
|
+
@onion.category.should be_nil
|
83
|
+
@vegetable.foods.count.should == 0
|
84
|
+
@vegetable.foods.should_not be_include(@onion)
|
85
|
+
end
|
86
|
+
|
87
|
+
it "should allow for generic references" do
|
88
|
+
@onion.something.should be_nil
|
89
|
+
|
90
|
+
@onion.something = @steak
|
91
|
+
@onion.something.name.should == "steak"
|
92
|
+
|
93
|
+
@onion.something = @vegetable
|
94
|
+
@onion.something.name.should == "vegetable"
|
95
|
+
end
|
96
|
+
|
97
|
+
end
|
98
|
+
|
99
|
+
context "with reference sets" do
|
100
|
+
|
101
|
+
it "should reverse to single references" do
|
102
|
+
@onion.category.should be_nil
|
103
|
+
@vegetable.foods.count.should be_zero
|
104
|
+
|
105
|
+
2.times do
|
106
|
+
@vegetable.foods << @onion
|
107
|
+
|
108
|
+
@onion.category.name.should == "vegetable"
|
109
|
+
@vegetable.foods.count.should == 1
|
110
|
+
@vegetable.foods.should be_include(@onion)
|
111
|
+
end
|
112
|
+
|
113
|
+
@vegetable.foods.delete @onion
|
114
|
+
|
115
|
+
@onion.category.should be_nil
|
116
|
+
@vegetable.foods.count.should == 0
|
117
|
+
@vegetable.foods.should_not be_include(@onion)
|
118
|
+
end
|
119
|
+
|
120
|
+
it "should reverse to a reference set" do
|
121
|
+
@apple.friends.count.should == 0
|
122
|
+
@lemon.friends.count.should == 0
|
123
|
+
@onion.friends.count.should == 0
|
124
|
+
|
125
|
+
@apple.friends << [ @lemon, @onion ]
|
126
|
+
|
127
|
+
@apple.friends.count.should == 2
|
128
|
+
@lemon.friends.count.should == 1
|
129
|
+
@onion.friends.count.should == 1
|
130
|
+
|
131
|
+
@apple.friends.should be_include(@lemon)
|
132
|
+
@apple.friends.should be_include(@onion)
|
133
|
+
@lemon.friends.should be_include(@apple)
|
134
|
+
@onion.friends.should be_include(@apple)
|
135
|
+
|
136
|
+
@apple.friends.delete @lemon
|
137
|
+
|
138
|
+
@apple.friends.count.should == 1
|
139
|
+
@lemon.friends.count.should == 0
|
140
|
+
@onion.friends.count.should == 1
|
141
|
+
|
142
|
+
@apple.friends.should_not be_include(@lemon)
|
143
|
+
@apple.friends.should be_include(@onion)
|
144
|
+
@lemon.friends.should_not be_include(@apple)
|
145
|
+
@onion.friends.should be_include(@apple)
|
146
|
+
end
|
147
|
+
|
148
|
+
end
|
149
|
+
|
150
|
+
context "website sister sites" do
|
151
|
+
|
152
|
+
it "should update each others sister sites table" do
|
153
|
+
|
154
|
+
site1 = Website.create("Site One")
|
155
|
+
site2 = Website.create("Site Two")
|
156
|
+
|
157
|
+
site1.sister_sites.count.should == 0
|
158
|
+
site2.sister_sites.count.should == 0
|
159
|
+
site1.sister_sites.should_not be_include site2
|
160
|
+
site2.sister_sites.should_not be_include site1
|
161
|
+
|
162
|
+
site1.sister_sites << site2
|
163
|
+
|
164
|
+
site1.sister_sites.count.should == 1
|
165
|
+
site2.sister_sites.count.should == 1
|
166
|
+
site1.sister_sites.should be_include site2
|
167
|
+
site2.sister_sites.should be_include site1
|
168
|
+
|
169
|
+
site2.sister_sites.delete site1
|
170
|
+
|
171
|
+
site1.sister_sites.count.should == 0
|
172
|
+
site2.sister_sites.count.should == 0
|
173
|
+
site1.sister_sites.should_not be_include site2
|
174
|
+
site2.sister_sites.should_not be_include site1
|
175
|
+
|
176
|
+
end
|
177
|
+
|
178
|
+
end
|
179
|
+
|
180
|
+
end
|