vigetlabs-garb 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,211 @@
1
+ require 'ostruct'
2
+
3
+ module Garb
4
+ class Report
5
+ MONTH = 2592000
6
+ URL = "https://www.google.com/analytics/feeds/data"
7
+
8
+ METRICS = {
9
+ :visitor => [
10
+ :avgPageviews,
11
+ :avgSessionTime,
12
+ :bounces,
13
+ :bounceRate,
14
+ :entrances,
15
+ :exits,
16
+ :exitRate,
17
+ :newVisitors,
18
+ :pageDuration,
19
+ :pageviews,
20
+ :visitDuration,
21
+ :visitors,
22
+ :visits
23
+ ],
24
+ :campaign => [
25
+ :cost,
26
+ :clicks,
27
+ :clickThroughRate,
28
+ :costPerConversion,
29
+ :costPerGoalConversion,
30
+ :costPerMilleImpressions,
31
+ :costPerTransaction,
32
+ :impressions
33
+ ],
34
+ :content => [
35
+ :uniquePageviews
36
+ ],
37
+ :ecommerce => [
38
+ :productPurchases,
39
+ :productRevenue,
40
+ :products,
41
+ :revenue,
42
+ :revenuePerClick,
43
+ :revenuePerTransaction,
44
+ :revenuePerVisit,
45
+ :shipping,
46
+ :tax,
47
+ :transactions
48
+ ],
49
+ :internal_search => [
50
+ :searchDepth,
51
+ :searchDuration,
52
+ :searchExits,
53
+ :searchTransitions,
54
+ :uniqueInternalSearches,
55
+ :visitsWithSearches
56
+ ],
57
+ :goals => [
58
+ :goalCompletions1,
59
+ :goalCompletions2,
60
+ :goalCompletions3,
61
+ :goalCompletions4,
62
+ :goalCompletionsAll,
63
+ :goalConversionRate,
64
+ :goalStarts1,
65
+ :goalStarts2,
66
+ :goalStarts3,
67
+ :goalStarts4,
68
+ :goalStartsAll,
69
+ :goalValue1,
70
+ :goalValue2,
71
+ :goalValue3,
72
+ :goalValue4,
73
+ :goalValueAll,
74
+ :goalValuePerVisit
75
+ ]
76
+ }
77
+
78
+ DIMENSIONS = {
79
+ :visitor => [
80
+ :browser,
81
+ :browserVersion,
82
+ :city,
83
+ :connectionSpeed,
84
+ :continent,
85
+ :country,
86
+ :daysSinceLastVisit,
87
+ :domain,
88
+ :flashVersion,
89
+ :hostname,
90
+ :hour,
91
+ :javaEnabled,
92
+ :languqage,
93
+ :medium,
94
+ :organization,
95
+ :pageDepth,
96
+ :platform,
97
+ :platformVersion,
98
+ :referralPath,
99
+ :region,
100
+ :screenColors,
101
+ :screenResolution,
102
+ :subContinentRegion,
103
+ :userDefinedValue,
104
+ :visitNumber,
105
+ :visitorType
106
+ ],
107
+ :campaign => [
108
+ :adGroup,
109
+ :adSlot,
110
+ :adSlotPosition,
111
+ :campaign,
112
+ :content,
113
+ :keyword,
114
+ :source,
115
+ :sourceMedium
116
+ ],
117
+ :content => [
118
+ :pageTitle,
119
+ :requestUri,
120
+ :requestUri1,
121
+ :requestUriLast
122
+ ],
123
+ :ecommerce => [
124
+ :affiliation,
125
+ :daysToTransaction,
126
+ :productCode,
127
+ :productName,
128
+ :productVariation,
129
+ :transactionId,
130
+ :visitsToTransaction
131
+ ],
132
+ :internal_search => [
133
+ :hasInternalSearch,
134
+ :internalSearchKeyword,
135
+ :internalSearchNext,
136
+ :internalSearchType
137
+ ]
138
+ }
139
+
140
+ attr_accessor :metrics, :dimensions, :sort, :filters,
141
+ :start_date, :max_results, :end_date
142
+
143
+ attr_reader :profile
144
+
145
+ # def self.element_id(property_name)
146
+ # property_name.is_a?(Operator) ? property_name.to_s : property_name.to_ga.lower_camelized
147
+ # end
148
+ #
149
+ # def self.property_value(entry, property_name)
150
+ # (entry/property_name).first.inner_text
151
+ # end
152
+ #
153
+ # def self.property_values(entry, property_names)
154
+ # hash = property_names.inject({}) do |hash, property_name|
155
+ # hash.merge({property_name => property_value(entry, property_name.to_s.lower_camelized)})
156
+ # end
157
+ # OpenStruct.new hash
158
+ # end
159
+ #
160
+ def self.format_time(t)
161
+ t.strftime('%Y-%m-%d')
162
+ end
163
+
164
+ def initialize(profile, opts={})
165
+ @profile = profile
166
+
167
+ @sort = ReportParameter.new(:sort) << opts.fetch(:sort, [])
168
+ @filters = ReportParameter.new(:filters) << opts.fetch(:filters, [])
169
+ @metrics = ReportParameter.new(:metrics) << opts.fetch(:metrics, [])
170
+ @dimensions = ReportParameter.new(:dimensions) << opts.fetch(:dimensions, [])
171
+
172
+ @start_date = opts.fetch(:start_date, Time.now - MONTH)
173
+ @end_date = opts.fetch(:end_date, Time.now)
174
+
175
+ yield self if block_given?
176
+ end
177
+
178
+ def page_params
179
+ max_results.nil? ? {} : {'max-results' => max_results}
180
+ end
181
+
182
+ def default_params
183
+ {'ids' => profile.table_id,
184
+ 'start-date' => self.class.format_time(start_date),
185
+ 'end-date' => self.class.format_time(end_date)}
186
+ end
187
+
188
+ def params
189
+ [
190
+ metrics.to_params,
191
+ dimensions.to_params,
192
+ sort.to_params,
193
+ filters.to_params,
194
+ page_params
195
+ ].inject(default_params) do |p, i|
196
+ p.merge(i)
197
+ end
198
+ end
199
+
200
+ def send_request_for_body
201
+ request = DataRequest.new(URL, params)
202
+ response = request.send_request
203
+ response.body
204
+ end
205
+
206
+ def all
207
+ @entries = ReportResponse.new(send_request_for_body).parse
208
+ end
209
+
210
+ end
211
+ end
@@ -0,0 +1,36 @@
1
+ module Garb
2
+ class ReportParameter
3
+
4
+ attr_reader :elements
5
+
6
+ def initialize(name)
7
+ @name = name
8
+ @elements = []
9
+ end
10
+
11
+ def name
12
+ @name.to_s
13
+ end
14
+
15
+ def <<(element)
16
+ @elements += [element].flatten
17
+ self
18
+ end
19
+
20
+ def to_params
21
+ params = self.elements.map do |elem|
22
+ case elem
23
+ when Hash
24
+ elem.collect do |k,v|
25
+ next unless k.is_a?(Operator)
26
+ "#{k.target}#{CGI::escape(k.operator.to_s)}#{CGI::escape(v.to_s)}"
27
+ end.join(';')
28
+ else
29
+ elem.to_ga
30
+ end
31
+ end.join(',')
32
+
33
+ params.empty? ? {} : {self.name => params}
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,56 @@
1
+ module Garb
2
+ class ReportResponse
3
+ def initialize(response_body)
4
+ @xml = response_body
5
+ end
6
+
7
+ def parse
8
+ entries = Entry.parse(@xml)
9
+
10
+ entries.collect do |entry|
11
+ hash = {}
12
+
13
+ entry.metrics.each do |m|
14
+ name = m.name.sub(/^ga\:/,'').underscored
15
+ hash.merge!({name => m.value})
16
+ end
17
+
18
+ entry.dimensions.each do |d|
19
+ name = d.name.sub(/^ga\:/,'').underscored
20
+ hash.merge!({name => d.value})
21
+ end
22
+
23
+ OpenStruct.new(hash)
24
+ end
25
+ end
26
+
27
+ class Metric
28
+ include HappyMapper
29
+
30
+ tag 'metric'
31
+ namespace 'dxp'
32
+
33
+ attribute :name, String
34
+ attribute :value, String
35
+ end
36
+
37
+ class Dimension
38
+ include HappyMapper
39
+
40
+ tag 'dimension'
41
+ namespace 'dxp'
42
+
43
+ attribute :name, String
44
+ attribute :value, String
45
+ end
46
+
47
+ class Entry
48
+ include HappyMapper
49
+
50
+ tag 'entry'
51
+
52
+ has_many :metrics, Metric
53
+ has_many :dimensions, Dimension
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,19 @@
1
+ module Garb
2
+ class Session
3
+
4
+ def self.login(email, password)
5
+ @email = email
6
+ auth_request = AuthenticationRequest.new(email, password)
7
+ @auth_token = auth_request.auth_token
8
+ end
9
+
10
+ def self.auth_token
11
+ @auth_token
12
+ end
13
+
14
+ def self.email
15
+ @email
16
+ end
17
+
18
+ end
19
+ end
data/lib/garb.rb ADDED
@@ -0,0 +1,67 @@
1
+ $:.unshift File.expand_path(File.dirname(__FILE__))
2
+
3
+ require 'net/http'
4
+ require 'net/https'
5
+ require 'rubygems'
6
+ require 'cgi'
7
+ require 'happymapper'
8
+
9
+ require 'garb/authentication_request'
10
+ require 'garb/data_request'
11
+ require 'garb/session'
12
+ require 'garb/profile'
13
+ require 'garb/report'
14
+ require 'garb/report_parameter'
15
+ require 'garb/report_response'
16
+
17
+ require 'extensions/string'
18
+ require 'extensions/operator'
19
+ require 'extensions/symbol'
20
+ require 'extensions/happymapper'
21
+
22
+ module Garb
23
+ # :stopdoc:
24
+ GA = "http://schemas.google.com/analytics/2008"
25
+
26
+ VERSION = '0.1.2'
27
+ LIBPATH = ::File.expand_path(::File.dirname(__FILE__)) + ::File::SEPARATOR
28
+ PATH = ::File.dirname(LIBPATH) + ::File::SEPARATOR
29
+ # :startdoc:
30
+
31
+ # Returns the version string for the library.
32
+ #
33
+ def self.version
34
+ VERSION
35
+ end
36
+
37
+ # Returns the library path for the module. If any arguments are given,
38
+ # they will be joined to the end of the libray path using
39
+ # <tt>File.join</tt>.
40
+ #
41
+ def self.libpath( *args )
42
+ args.empty? ? LIBPATH : ::File.join(LIBPATH, args.flatten)
43
+ end
44
+
45
+ # Returns the lpath for the module. If any arguments are given,
46
+ # they will be joined to the end of the path using
47
+ # <tt>File.join</tt>.
48
+ #
49
+ def self.path( *args )
50
+ args.empty? ? PATH : ::File.join(PATH, args.flatten)
51
+ end
52
+
53
+ # Utility method used to rquire all files ending in .rb that lie in the
54
+ # directory below this file that has the same name as the filename passed
55
+ # in. Optionally, a specific _directory_ name can be passed in such that
56
+ # the _filename_ does not have to be equivalent to the directory.
57
+ #
58
+ def self.require_all_libs_relative_to( fname, dir = nil )
59
+ dir ||= ::File.basename(fname, '.*')
60
+ search_me = ::File.expand_path(
61
+ ::File.join(::File.dirname(fname), dir, '*', '*.rb'))
62
+
63
+ Dir.glob(search_me).sort.each {|rb| require rb}
64
+ end
65
+ end # module Garb
66
+
67
+ # EOF
@@ -0,0 +1,91 @@
1
+ require File.dirname(__FILE__) + '/test_helper'
2
+
3
+ module Garb
4
+ class AuthenticationRequestTest < Test::Unit::TestCase
5
+
6
+ context "An instance of the AuthenticationRequest class" do
7
+
8
+ setup { @request = AuthenticationRequest.new('email', 'password') }
9
+
10
+ should "have a collection of parameters that include the email and password" do
11
+ expected =
12
+ {
13
+ 'Email' => 'user@example.com',
14
+ 'Passwd' => 'fuzzybunnies',
15
+ 'accountType' => 'HOSTED_OR_GOOGLE',
16
+ 'service' => 'analytics',
17
+ 'source' => 'vigetLabs-garb-001'
18
+ }
19
+
20
+ request = AuthenticationRequest.new('user@example.com', 'fuzzybunnies')
21
+ assert_equal expected, request.parameters
22
+ end
23
+
24
+ should "have a URI" do
25
+ assert_equal URI.parse('https://www.google.com/accounts/ClientLogin'), @request.uri
26
+ end
27
+
28
+
29
+ should "be able to send a request to the GAAPI service" do
30
+ @request.expects(:build_request).returns('post')
31
+ response = mock {|m| m.expects(:is_a?).with(Net::HTTPOK).returns(true) }
32
+
33
+ http = mock do |m|
34
+ m.expects(:use_ssl=).with(true)
35
+ m.expects(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE)
36
+ m.expects(:request).with('post').yields(response)
37
+ end
38
+
39
+ Net::HTTP.expects(:new).with('www.google.com', 443).returns(http)
40
+
41
+ @request.send_request
42
+ end
43
+
44
+ should "be able to build a request for the GAAPI service" do
45
+ params = "param"
46
+ @request.expects(:parameters).with().returns(params)
47
+
48
+ post = mock
49
+ post.expects(:set_form_data).with(params)
50
+
51
+ Net::HTTP::Post.expects(:new).with('/accounts/ClientLogin').returns(post)
52
+
53
+ @request.build_request
54
+ end
55
+
56
+ should "be able to retrieve an auth_token from the body" do
57
+ response_data =
58
+ "SID=mysid\n" +
59
+ "LSID=mylsid\n" +
60
+ "Auth=auth_token\n"
61
+
62
+ @request.expects(:send_request).with().returns(stub(:body => response_data))
63
+
64
+ assert_equal 'auth_token', @request.auth_token
65
+ end
66
+
67
+ should "raise an exception when requesting an auth_token when the authorization fails" do
68
+ @request.stubs(:build_request)
69
+ response = mock do |m|
70
+ m.expects(:is_a?).with(Net::HTTPOK).returns(false)
71
+ end
72
+
73
+ http = stub do |s|
74
+ s.stubs(:use_ssl=)
75
+ s.stubs(:verify_mode=)
76
+ s.stubs(:request).yields(response)
77
+ end
78
+
79
+ Net::HTTP.stubs(:new).with('www.google.com', 443).returns(http)
80
+
81
+ assert_raise(Garb::AuthenticationRequest::AuthError) do
82
+ @request.send_request
83
+ end
84
+ end
85
+
86
+ end
87
+
88
+
89
+
90
+ end
91
+ end
@@ -0,0 +1,52 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ module Garb
4
+ class DataRequestTest < Test::Unit::TestCase
5
+
6
+ context "An instance of the DataRequest class" do
7
+
8
+ should "be able to build the query string from parameters" do
9
+ parameters = {'ids' => '12345', 'metrics' => 'country'}
10
+ data_request = DataRequest.new("", parameters)
11
+
12
+ query_string = data_request.query_string
13
+
14
+ assert_match(/^\?/, query_string)
15
+
16
+ query_string.sub!(/^\?/, '')
17
+
18
+ assert_equal ["ids=12345", "metrics=country"], query_string.split('&').sort
19
+ end
20
+
21
+ should "return an empty query string if parameters are empty" do
22
+ data_request = DataRequest.new("")
23
+ assert_equal "", data_request.query_string
24
+ end
25
+
26
+ should "be able to build a uri" do
27
+ url = 'http://example.com'
28
+ expected = URI.parse('http://example.com')
29
+
30
+ assert_equal expected, DataRequest.new(url).uri
31
+ end
32
+
33
+ should "be able to make a request to the GAAPI" do
34
+ Session.expects(:auth_token).with().returns('toke')
35
+ response = mock
36
+ response.expects(:is_a?).with(Net::HTTPOK).returns(true)
37
+
38
+ http = mock do |m|
39
+ m.expects(:use_ssl=).with(true)
40
+ m.expects(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE)
41
+ m.expects(:get).with('/data?key=value', 'Authorization' => 'GoogleLogin auth=toke').returns(response)
42
+ end
43
+
44
+ Net::HTTP.expects(:new).with('example.com', 443).returns(http)
45
+
46
+ data_request = DataRequest.new('https://example.com/data', 'key' => 'value')
47
+ assert_equal response, data_request.send_request
48
+ end
49
+ end
50
+
51
+ end
52
+ end
@@ -0,0 +1,33 @@
1
+ <?xml version='1.0' encoding='UTF-8'?>
2
+ <feed xmlns='http://www.w3.org/2005/Atom' xmlns:openSearch='http://a9.com/-/spec/opensearchrss/1.0/' xmlns:dxp='http://schemas.google.com/analytics/2009'>
3
+ <id>http://www.google.com/analytics/feeds/accounts/email@example.com</id>
4
+ <updated>2009-03-27T08:14:28.000-07:00</updated>
5
+ <title type='text'>Profile list for email@example.com</title>
6
+ <link rel='self' type='application/atom+xml' href='http://www.google.com/analytics/feeds/accounts/email@example.com'/>
7
+ <author><name>Google Analytics</name></author>
8
+ <generator version='1.0'>Google Analytics</generator>
9
+ <openSearch:totalResults>2</openSearch:totalResults>
10
+ <openSearch:startIndex>1</openSearch:startIndex>
11
+ <openSearch:itemsPerPage>2</openSearch:itemsPerPage>
12
+ <entry>
13
+ <id>http://www.google.com/analytics/feeds/accounts/ga:12345</id>
14
+ <updated>2008-07-21T14:05:57.000-07:00</updated>
15
+ <title type='text'>Historical</title>
16
+ <dxp:tableId>ga:12345</dxp:tableId>
17
+ <dxp:property name='ga:accountId' value='1111'/>
18
+ <dxp:property name='ga:accountName' value='Blog Beta'/>
19
+ <dxp:property name='ga:profileId' value='1212'/>
20
+ <dxp:property name='ga:webPropertyId' value='UA-1111-1'/>
21
+ </entry>
22
+ <entry>
23
+ <id>http://www.google.com/analytics/feeds/accounts/ga:12346</id>
24
+ <updated>2008-11-24T11:51:07.000-08:00</updated>
25
+ <title type='text'>Presently</title>
26
+ <dxp:tableId>ga:12346</dxp:tableId>
27
+ <dxp:property name='ga:accountId' value='1111'/>
28
+ <dxp:property name='ga:accountName' value='Blog Beta'/>
29
+ <dxp:property name='ga:profileId' value='1213'/>
30
+ <dxp:property name='ga:webPropertyId' value='UA-1111-2'/>
31
+ </entry>
32
+ </feed>
33
+
@@ -0,0 +1,46 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <feed xmlns='http://www.w3.org/2005/Atom'
3
+ xmlns:openSearch='http://a9.com/-/spec/opensearchrss/1.0/'
4
+ xmlns:dxp='http://schemas.google.com/dataexport/2009'
5
+ xmlns:ga='http://schemas.google.com/analytics/2008'>
6
+ <id>http://www.google.com/analytics/feeds/data?ids=ga:983247&amp;dimensions=ga:country,ga:city&amp;metrics=ga:pageViews&amp;start-date=2008-01-01&amp;end-date=2008-01-02</id>
7
+ <updated>2008-01-02T15:59:59.999-08:00 </updated>
8
+ <title type="text">Google Analytics Data for Profile 983247</title>
9
+ <link href="http://www.google.com/analytics/feeds/data" rel="http://schemas.google.com/g/2005#feed" type="application/atom+xml"/>
10
+ <link href="http://www.google.com/analytics/feeds/data?end-date=2008-01-02&amp;start-date=2008-01-01&amp;metrics=ga%3ApageViews&amp;ids=ga%3A983247&amp;dimensions=ga%3Acountry%2Cga%3Acity" rel="self" type="application/atom+xml"/>
11
+ <link href="http://www.google.com/analytics/feeds/data?start-index=1001&amp;max-results=1000&amp;end-date=2008-01-02&amp;start-date=2008-01-01&amp;metrics=ga%3ApageViews&amp;ids=ga%3A983247&amp;dimensions=ga%3Acountry%2Cga%3Acity" rel="next" type="application/atom+xml"/>
12
+ <author>
13
+ <name>Google Analytics</name>
14
+ </author>
15
+ <openSearch:startIndex>3</openSearch:startIndex>
16
+ <openSearch:itemsPerPage>4</openSearch:itemsPerPage>
17
+ <ga:webPropertyID>UA-983247-67</ga:webPropertyID>
18
+ <ga:start-date>2008-01-01</ga:start-date>
19
+ <ga:end-date>2008-01-02</ga:end-date>
20
+
21
+ <entry>
22
+ <id> http://www.google.com/analytics/feeds/data?ids=ga:1174&amp;ga:country=%28not%20set%29&amp;ga:city=%28not%20set%29&amp;start-date=2008-01-01&amp;end-date=2008-01-02 </id>
23
+ <updated> 2008-01-01T16:00:00.001-08:00 </updated>
24
+ <title type="text"> ga:country=(not set) | ga:city=(not set) </title>
25
+ <link href="http://www.google.com/analytics/feeds/data" rel="self" type="application/atom+xml"/>
26
+ <dxp:dimension name="ga:country" value="(not set)" />
27
+ <dxp:dimension name="ga:city" value="(not set)" />
28
+ <dxp:metric name="ga:pageviews" value="33" />
29
+ </entry>
30
+ <entry>
31
+ <id> http://www.google.com/analytics/feeds/data?ids=ga:1174&amp;ga:country=Afghanistan&amp;ga:city=Kabul&amp;start-date=2008-01-01&amp;end-date=2008-01-02 </id>
32
+ <updated> 2008-01-01T16:00:00.001-08:00 </updated>
33
+ <title type="text"> ga:country=Afghanistan | ga:city=Kabul </title>
34
+ <dxp:dimension name="ga:country" value="Afghanistan" />
35
+ <dxp:dimension name="ga:city" value="Kabul" />
36
+ <dxp:metric name="ga:pageviews" value="2" />
37
+ </entry>
38
+ <entry>
39
+ <id> http://www.google.com/analytics/feeds/data?ids=ga:1174&amp;ga:country=Albania&amp;ga:city=Tirana&amp;start-date=2008-01-01&amp;end-date=2008-01-02 </id>
40
+ <updated> 2008-01-01T16:00:00.001-08:00 </updated>
41
+ <title type="text"> ga:country=Albania | ga:city=Tirana </title>
42
+ <dxp:dimension name="ga:country" value="Albania" />
43
+ <dxp:dimension name="ga:city" value="Tirana" />
44
+ <dxp:metric name="ga:pageviews" value="1" />
45
+ </entry>
46
+ </feed>
data/test/garb_test.rb ADDED
@@ -0,0 +1,9 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ class GarbTest < Test::Unit::TestCase
4
+ context "A green egg" do
5
+ should "be served with ham" do
6
+ assert true
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,37 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ class OperatorTest < Test::Unit::TestCase
4
+ context "An instance of an Operator" do
5
+ should "lower camelize the target" do
6
+ assert_equal "ga:uniqueVisits=", Operator.new(:unique_visits, "=").to_ga
7
+ end
8
+
9
+ should "return target and operator together" do
10
+ assert_equal "ga:metric=", Operator.new(:metric, "=").to_ga
11
+ end
12
+
13
+ should "prefix the operator to the target" do
14
+ assert_equal "-ga:metric", Operator.new(:metric, "-", true).to_ga
15
+ end
16
+
17
+ should "know if it is equal to another operator" do
18
+ op1 = Operator.new(:hello, "==")
19
+ op2 = Operator.new(:hello, "==")
20
+ assert_equal op1, op2
21
+ end
22
+
23
+ should "not be equal to another operator if target, operator, or prefix is different" do
24
+ op1 = Operator.new(:hello, "==")
25
+ op2 = Operator.new(:hello, "==", true)
26
+ assert_not_equal op1, op2
27
+
28
+ op1 = Operator.new(:hello1, "==")
29
+ op2 = Operator.new(:hello2, "==")
30
+ assert_not_equal op1, op2
31
+
32
+ op1 = Operator.new(:hello, "!=")
33
+ op2 = Operator.new(:hello, "==")
34
+ assert_not_equal op1, op2
35
+ end
36
+ end
37
+ end