blather 0.1
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.
- data/CHANGELOG +1 -0
- data/LICENSE +20 -0
- data/Manifest +43 -0
- data/README.rdoc +78 -0
- data/Rakefile +16 -0
- data/blather.gemspec +41 -0
- data/examples/echo.rb +22 -0
- data/examples/shell_client.rb +28 -0
- data/lib/autotest/discover.rb +1 -0
- data/lib/autotest/spec.rb +60 -0
- data/lib/blather.rb +46 -0
- data/lib/blather/callback.rb +24 -0
- data/lib/blather/client.rb +81 -0
- data/lib/blather/core/errors.rb +24 -0
- data/lib/blather/core/jid.rb +101 -0
- data/lib/blather/core/roster.rb +84 -0
- data/lib/blather/core/roster_item.rb +92 -0
- data/lib/blather/core/stanza.rb +116 -0
- data/lib/blather/core/stanza/iq.rb +27 -0
- data/lib/blather/core/stanza/iq/query.rb +42 -0
- data/lib/blather/core/stanza/iq/roster.rb +96 -0
- data/lib/blather/core/stanza/message.rb +55 -0
- data/lib/blather/core/stanza/presence.rb +35 -0
- data/lib/blather/core/stanza/presence/status.rb +77 -0
- data/lib/blather/core/stanza/presence/subscription.rb +73 -0
- data/lib/blather/core/stream.rb +181 -0
- data/lib/blather/core/stream/parser.rb +74 -0
- data/lib/blather/core/stream/resource.rb +51 -0
- data/lib/blather/core/stream/sasl.rb +135 -0
- data/lib/blather/core/stream/session.rb +43 -0
- data/lib/blather/core/stream/tls.rb +29 -0
- data/lib/blather/core/sugar.rb +150 -0
- data/lib/blather/core/xmpp_node.rb +132 -0
- data/lib/blather/extensions.rb +4 -0
- data/lib/blather/extensions/last_activity.rb +55 -0
- data/lib/blather/extensions/version.rb +85 -0
- data/spec/blather/core/jid_spec.rb +78 -0
- data/spec/blather/core/roster_item_spec.rb +80 -0
- data/spec/blather/core/roster_spec.rb +79 -0
- data/spec/blather/core/stanza_spec.rb +95 -0
- data/spec/blather/core/stream_spec.rb +263 -0
- data/spec/blather/core/xmpp_node_spec.rb +130 -0
- data/spec/build_safe.rb +20 -0
- data/spec/spec_helper.rb +49 -0
- metadata +172 -0
@@ -0,0 +1,132 @@
|
|
1
|
+
module Blather
|
2
|
+
|
3
|
+
##
|
4
|
+
# Base XML Node
|
5
|
+
# All XML classes subclass XMPPNode
|
6
|
+
# it allows the addition of helpers
|
7
|
+
class XMPPNode < XML::Node
|
8
|
+
@@registrations = {}
|
9
|
+
|
10
|
+
alias_method :element_name, :name
|
11
|
+
|
12
|
+
class_inheritable_accessor :xmlns,
|
13
|
+
:name
|
14
|
+
|
15
|
+
##
|
16
|
+
# Automatically sets the namespace registered by the subclass
|
17
|
+
def self.new(name = nil, content = nil)
|
18
|
+
name ||= self.name
|
19
|
+
|
20
|
+
args = []
|
21
|
+
args << name.to_s if name
|
22
|
+
args << content if content
|
23
|
+
|
24
|
+
elem = super *args
|
25
|
+
elem.xmlns = xmlns
|
26
|
+
elem
|
27
|
+
end
|
28
|
+
|
29
|
+
##
|
30
|
+
# Lets a subclass register itself
|
31
|
+
#
|
32
|
+
# This registers a namespace that is used when looking
|
33
|
+
# up the class name of the object to instantiate when a new
|
34
|
+
# stanza is received
|
35
|
+
def self.register(name, xmlns = nil)
|
36
|
+
self.name = name.to_s
|
37
|
+
self.xmlns = xmlns
|
38
|
+
@@registrations[[name, xmlns]] = self
|
39
|
+
end
|
40
|
+
|
41
|
+
##
|
42
|
+
# Find the class to use given the name and namespace of a stanza
|
43
|
+
def self.class_from_registration(name, xmlns)
|
44
|
+
name = name.to_s
|
45
|
+
@@registrations[[name, xmlns]] || @@registrations[[name, nil]]
|
46
|
+
end
|
47
|
+
|
48
|
+
##
|
49
|
+
# Looks up the class to use then instantiates an object
|
50
|
+
# of that class and imports all the <tt>node</tt>'s attributes
|
51
|
+
# and children into it.
|
52
|
+
def self.import(node)
|
53
|
+
klass = class_from_registration(node.element_name, node.xmlns)
|
54
|
+
if klass && klass != self
|
55
|
+
klass.import(node)
|
56
|
+
else
|
57
|
+
new(node.element_name).inherit(node)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
##
|
62
|
+
# Quickway of turning itself into a proper object
|
63
|
+
def to_stanza
|
64
|
+
self.class.import self
|
65
|
+
end
|
66
|
+
|
67
|
+
def xmlns=(ns)
|
68
|
+
attributes.remove :xmlns
|
69
|
+
self['xmlns'] = ns if ns
|
70
|
+
end
|
71
|
+
|
72
|
+
def xmlns
|
73
|
+
self['xmlns']
|
74
|
+
end
|
75
|
+
|
76
|
+
##
|
77
|
+
# Remove a child with the name and (optionally) namespace given
|
78
|
+
def remove_child(name, ns = nil)
|
79
|
+
name = name.to_s
|
80
|
+
self.each { |n| n.remove! if n.element_name == name && (!ns || n.xmlns == ns) }
|
81
|
+
end
|
82
|
+
|
83
|
+
##
|
84
|
+
# Remove all children with a given name
|
85
|
+
def remove_children(name)
|
86
|
+
name = name.to_s
|
87
|
+
self.find(name).each { |n| n.remove! }
|
88
|
+
end
|
89
|
+
|
90
|
+
##
|
91
|
+
# Pull the content from a child
|
92
|
+
def content_from(name)
|
93
|
+
name = name.to_s
|
94
|
+
(child = self.detect { |n| n.element_name == name }) ? child.content : nil
|
95
|
+
end
|
96
|
+
|
97
|
+
##
|
98
|
+
# Create a copy
|
99
|
+
def copy(deep = true)
|
100
|
+
self.class.new(self.element_name).inherit(self)
|
101
|
+
end
|
102
|
+
|
103
|
+
##
|
104
|
+
# Inherit all of <tt>stanza</tt>'s attributes and children
|
105
|
+
def inherit(stanza)
|
106
|
+
inherit_attrs stanza.attributes
|
107
|
+
stanza.children.each { |c| self << c.copy(true) }
|
108
|
+
self
|
109
|
+
end
|
110
|
+
|
111
|
+
##
|
112
|
+
# Inherit only <tt>stanza</tt>'s attributes
|
113
|
+
def inherit_attrs(attrs)
|
114
|
+
attrs.each { |a| self[a.name] = a.value }
|
115
|
+
self
|
116
|
+
end
|
117
|
+
|
118
|
+
##
|
119
|
+
# Turn itself into a string and remove all whitespace between nodes
|
120
|
+
def to_s
|
121
|
+
# TODO: Fix this for HTML nodes (and any other that might require whitespace)
|
122
|
+
super.gsub(">\n<", '><')
|
123
|
+
end
|
124
|
+
|
125
|
+
##
|
126
|
+
# Override #find to work when a node isn't attached to a document
|
127
|
+
def find(what, nslist = nil)
|
128
|
+
(self.doc ? super(what, nslist) : select { |i| i.element_name == what})
|
129
|
+
end
|
130
|
+
end #XMPPNode
|
131
|
+
|
132
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module Blather
|
2
|
+
module Extensions #:nodoc:
|
3
|
+
|
4
|
+
module LastActivity #:nodoc:
|
5
|
+
def self.included(base)
|
6
|
+
base.class_eval do
|
7
|
+
@@last_activity = Time.now
|
8
|
+
|
9
|
+
alias_method :send_data_without_activity, :send_data
|
10
|
+
def send_data(data)
|
11
|
+
@@last_activity = Time.now
|
12
|
+
send_data_without_activity data
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def last_activity
|
18
|
+
(Time.now - @@last_activity).to_i
|
19
|
+
end
|
20
|
+
|
21
|
+
def receive_last_activity(stanza)
|
22
|
+
send_data stanza.reply!(last_activity) if stanza.type == 'get'
|
23
|
+
end
|
24
|
+
end #LastActivity
|
25
|
+
|
26
|
+
class LastActivityStanza < Query #:nodoc:
|
27
|
+
register :last_activity, nil, 'jabber:iq:last'
|
28
|
+
|
29
|
+
def self.new(type = :get, seconds = nil)
|
30
|
+
elem = super type
|
31
|
+
elem.seconds = seconds
|
32
|
+
elem
|
33
|
+
end
|
34
|
+
|
35
|
+
def seconds=(seconds)
|
36
|
+
query.attributes.remove :seconds
|
37
|
+
query['seconds'] = seconds.to_i.to_s if seconds
|
38
|
+
end
|
39
|
+
|
40
|
+
def seconds
|
41
|
+
(query['seconds'] || 0).to_i
|
42
|
+
end
|
43
|
+
|
44
|
+
def reply(seconds)
|
45
|
+
elem = super()
|
46
|
+
elem.last_activity = seconds
|
47
|
+
end
|
48
|
+
|
49
|
+
def reply!(seconds)
|
50
|
+
self.last_activity = seconds
|
51
|
+
super()
|
52
|
+
end
|
53
|
+
end #LastActivityStanza
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
module Blather
|
2
|
+
module Extensions #:nodoc:
|
3
|
+
|
4
|
+
module Version #:nodoc:
|
5
|
+
def self.included(base)
|
6
|
+
base.class_eval do
|
7
|
+
@@version = {}
|
8
|
+
|
9
|
+
def self.version(name, ver, os = nil)
|
10
|
+
@@version = {:name => name, :version => ver, :os => os}
|
11
|
+
end
|
12
|
+
|
13
|
+
add_callback(:iq, 100) do |c, s|
|
14
|
+
if s.detect { |n| n['xmlns'] == 'jabber:iq:version' }
|
15
|
+
ver = VersionStanza.new('result', c.version)
|
16
|
+
ver.id = s.id
|
17
|
+
ver.from = c.jid
|
18
|
+
ver.to = s.from
|
19
|
+
c.send_data ver
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def version
|
26
|
+
@@version
|
27
|
+
end
|
28
|
+
end #Version
|
29
|
+
|
30
|
+
class VersionStanza < Iq #:nodoc:
|
31
|
+
def self.new(type = 'result', ver = {})
|
32
|
+
elem = super(type)
|
33
|
+
|
34
|
+
query = XML::Node.new('query')
|
35
|
+
query.xmlns = 'jabber:iq:version'
|
36
|
+
elem << query
|
37
|
+
|
38
|
+
elem.name = ver[:name] if ver[:name]
|
39
|
+
elem.version = ver[:version] if ver[:version]
|
40
|
+
elem.os = ver[:os] if ver[:os]
|
41
|
+
|
42
|
+
elem
|
43
|
+
end
|
44
|
+
|
45
|
+
def query
|
46
|
+
@query ||= self.detect { |n| n.name == 'query' }
|
47
|
+
end
|
48
|
+
|
49
|
+
def name=(name)
|
50
|
+
query.each { |n| n.remove! or break if n.name == 'name' }
|
51
|
+
query << XML::Node.new('name', name)
|
52
|
+
end
|
53
|
+
|
54
|
+
def name
|
55
|
+
if name = query.detect { |n| n.name == 'name' }
|
56
|
+
name.content
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def version=(version)
|
61
|
+
query.each { |n| n.remove! or break if n.name == 'version' }
|
62
|
+
query << XML::Node.new('version', version)
|
63
|
+
end
|
64
|
+
|
65
|
+
def version
|
66
|
+
if version = query.detect { |n| n.version == 'version' }
|
67
|
+
version.content
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def os=(os)
|
72
|
+
query.each { |n| n.remove! or break if n.name == 'os' }
|
73
|
+
query << XML::Node.new('os', os)
|
74
|
+
end
|
75
|
+
|
76
|
+
def os
|
77
|
+
if os = query.detect { |n| n.os == 'os' }
|
78
|
+
os.content
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end #VersionStanza
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
Blather::Client.__send__ :include, Blather::Extensions::Version
|
@@ -0,0 +1,78 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), *%w[.. .. spec_helper])
|
2
|
+
|
3
|
+
describe 'Blather::JID' do
|
4
|
+
it 'does nothing if creaded from JID' do
|
5
|
+
jid = JID.new 'n@d/r'
|
6
|
+
JID.new(jid).object_id.must_equal jid.object_id
|
7
|
+
end
|
8
|
+
|
9
|
+
it 'creates a new JID from (n,d,r)' do
|
10
|
+
jid = JID.new('n', 'd', 'r')
|
11
|
+
jid.node.must_equal 'n'
|
12
|
+
jid.domain.must_equal 'd'
|
13
|
+
jid.resource.must_equal 'r'
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'creates a new JID from (n,d)' do
|
17
|
+
jid = JID.new('n', 'd')
|
18
|
+
jid.node.must_equal 'n'
|
19
|
+
jid.domain.must_equal 'd'
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'creates a new JID from (n@d)' do
|
23
|
+
jid = JID.new('n@d')
|
24
|
+
jid.node.must_equal 'n'
|
25
|
+
jid.domain.must_equal 'd'
|
26
|
+
end
|
27
|
+
|
28
|
+
it 'creates a new JID from (n@d/r)' do
|
29
|
+
jid = JID.new('n@d/r')
|
30
|
+
jid.node.must_equal 'n'
|
31
|
+
jid.domain.must_equal 'd'
|
32
|
+
jid.resource.must_equal 'r'
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'requires at least a node' do
|
36
|
+
proc { JID.new }.must_raise ArgumentError
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'ensures length of node is no more than 1023 characters' do
|
40
|
+
proc { JID.new('n'*1024) }.must_raise Blather::ArgumentError
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'ensures length of domain is no more than 1023 characters' do
|
44
|
+
proc { JID.new('n', 'd'*1024) }.must_raise Blather::ArgumentError
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'ensures length of resource is no more than 1023 characters' do
|
48
|
+
proc { JID.new('n', 'd', 'r'*1024) }.must_raise Blather::ArgumentError
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'compares JIDs' do
|
52
|
+
(JID.new('a@b/c') <=> JID.new('d@e/f')).must_equal -1
|
53
|
+
(JID.new('a@b/c') <=> JID.new('a@b/c')).must_equal 0
|
54
|
+
(JID.new('d@e/f') <=> JID.new('a@b/c')).must_equal 1
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'checks for equality' do
|
58
|
+
(JID.new('n@d/r') == JID.new('n@d/r')).must_equal true
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'will strip' do
|
62
|
+
jid = JID.new('n@d/r')
|
63
|
+
jid.stripped.must_equal JID.new('n@d')
|
64
|
+
jid.must_equal JID.new('n@d/r')
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'will strip itself' do
|
68
|
+
jid = JID.new('n@d/r')
|
69
|
+
jid.strip!
|
70
|
+
jid.must_equal JID.new('n@d')
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'has a string representation' do
|
74
|
+
JID.new('n@d/r').to_s.must_equal 'n@d/r'
|
75
|
+
JID.new('n', 'd', 'r').to_s.must_equal 'n@d/r'
|
76
|
+
JID.new('n', 'd').to_s.must_equal 'n@d'
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), *%w[.. .. spec_helper])
|
2
|
+
|
3
|
+
describe 'Blather::RosterItem' do
|
4
|
+
it 'initializes with JID' do
|
5
|
+
jid = JID.new(jid)
|
6
|
+
i = RosterItem.new jid
|
7
|
+
i.jid.must_equal jid
|
8
|
+
end
|
9
|
+
|
10
|
+
it 'initializes with an Iq::RosterItem' do
|
11
|
+
jid = 'n@d/r'
|
12
|
+
i = RosterItem.new Stanza::Iq::Roster::RosterItem.new(jid)
|
13
|
+
i.jid.must_equal JID.new(jid).stripped
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'has a JID setter that strips the JID' do
|
17
|
+
jid = JID.new('n@d/r')
|
18
|
+
i = RosterItem.new nil
|
19
|
+
i.jid = jid
|
20
|
+
i.jid.must_equal jid.stripped
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'has a subscription setter that forces a symbol' do
|
24
|
+
i = RosterItem.new nil
|
25
|
+
i.subscription = 'remove'
|
26
|
+
i.subscription.must_equal :remove
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'forces the type of subscription' do
|
30
|
+
proc { RosterItem.new(nil).subscription = 'foo' }.must_raise Blather::ArgumentError
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'returns :none if the subscription field is blank' do
|
34
|
+
RosterItem.new(nil).subscription.must_equal :none
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'ensure #ask is a symbol' do
|
38
|
+
i = RosterItem.new(nil)
|
39
|
+
i.ask = 'subscribe'
|
40
|
+
i.ask.must_equal :subscribe
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'forces #ask to be :subscribe or nothing at all' do
|
44
|
+
proc { RosterItem.new(nil).ask = 'foo' }.must_raise Blather::ArgumentError
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'generates a stanza with #to_stanza' do
|
48
|
+
jid = JID.new('n@d/r')
|
49
|
+
i = RosterItem.new jid
|
50
|
+
s = i.to_stanza
|
51
|
+
s.must_be_kind_of Stanza::Iq::Roster
|
52
|
+
s.items.first.jid.must_equal jid.stripped
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'returns status based on priority' do
|
56
|
+
setup_item_with_presences
|
57
|
+
@i.status.must_equal @p2
|
58
|
+
end
|
59
|
+
|
60
|
+
it 'returns status based on resource' do
|
61
|
+
setup_item_with_presences
|
62
|
+
@i.status('a').must_equal @p
|
63
|
+
end
|
64
|
+
|
65
|
+
def setup_item_with_presences
|
66
|
+
@jid = JID.new('n@d/r')
|
67
|
+
@i = RosterItem.new @jid
|
68
|
+
|
69
|
+
@p = Stanza::Presence::Status.new(:away)
|
70
|
+
@p.from = 'n@d/a'
|
71
|
+
@p.priority = 0
|
72
|
+
|
73
|
+
@p2 = Stanza::Presence::Status.new(:dnd)
|
74
|
+
@p2.from = 'n@d/b'
|
75
|
+
@p2.priority = -1
|
76
|
+
|
77
|
+
@i.status = @p
|
78
|
+
@i.status = @p2
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), *%w[.. .. spec_helper])
|
2
|
+
|
3
|
+
describe 'Blather::Roster' do
|
4
|
+
before do
|
5
|
+
@stream = mock()
|
6
|
+
@stream.stubs(:send_data)
|
7
|
+
|
8
|
+
@stanza = mock()
|
9
|
+
items = []; 4.times { |n| items << JID.new("n@d/#{n}r") }
|
10
|
+
@stanza.stubs(:items).returns(items)
|
11
|
+
|
12
|
+
@roster = Roster.new(@stream, @stanza)
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'initializes with items' do
|
16
|
+
@roster.items.map { |_,i| i.jid.to_s }.must_equal(@stanza.items.map { |i| i.stripped.to_s }.uniq)
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'processes @stanzas with remove requests' do
|
20
|
+
s = @roster['n@d/0r']
|
21
|
+
s.subscription = :remove
|
22
|
+
proc { @roster.process(s.to_stanza) }.must_change('@roster.items', :length, :by => -1)
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'processes @stanzas with add requests' do
|
26
|
+
s = Stanza::Iq::Roster::RosterItem.new('a@b/c').to_stanza
|
27
|
+
proc { @roster.process(s) }.must_change('@roster.items', :length, :by => 1)
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'allows a jid to be pushed' do
|
31
|
+
jid = 'a@b/c'
|
32
|
+
proc { @roster.push(jid) }.must_change('@roster.items', :length, :by => 1)
|
33
|
+
@roster[jid].wont_be_nil
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'allows an item to be pushed' do
|
37
|
+
jid = 'a@b/c'
|
38
|
+
item = RosterItem.new(JID.new(jid))
|
39
|
+
proc { @roster.push(item) }.must_change('@roster.items', :length, :by => 1)
|
40
|
+
@roster[jid].wont_be_nil
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'sends a @roster addition over the wire' do
|
44
|
+
stream = mock()
|
45
|
+
stream.expects(:send_data)
|
46
|
+
roster = Roster.new stream, @stanza
|
47
|
+
roster.push('a@b/c')
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'removes a JID' do
|
51
|
+
proc { @roster.delete 'n@d' }.must_change('@roster.items', :length, :by => -1)
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'sends a @roster removal over the wire' do
|
55
|
+
stream = mock(:send_data => nil)
|
56
|
+
roster = Roster.new stream, @stanza
|
57
|
+
roster.delete('a@b/c')
|
58
|
+
end
|
59
|
+
|
60
|
+
it 'returns an item through []' do
|
61
|
+
item = @roster['n@d']
|
62
|
+
item.must_be_kind_of RosterItem
|
63
|
+
item.jid.must_equal JID.new('n@d')
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'responds to #each' do
|
67
|
+
@roster.must_respond_to :each
|
68
|
+
end
|
69
|
+
|
70
|
+
it 'cycles through the items using #each' do
|
71
|
+
@roster.map { |i| i }.sort.must_equal(@roster.items.sort)
|
72
|
+
end
|
73
|
+
|
74
|
+
it 'returns a duplicate of items through #items' do
|
75
|
+
items = @roster.items
|
76
|
+
items.delete 'n@d'
|
77
|
+
items.wont_equal @roster.items
|
78
|
+
end
|
79
|
+
end
|