bret-watircraft 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (83) hide show
  1. data/BUGS.txt +11 -0
  2. data/History.txt +209 -0
  3. data/Manifest.txt +103 -0
  4. data/README.rdoc +75 -0
  5. data/VERSION.yml +4 -0
  6. data/app_generators/watircraft/USAGE +11 -0
  7. data/app_generators/watircraft/templates/config.yml.erb +3 -0
  8. data/app_generators/watircraft/templates/feature_helper.rb +12 -0
  9. data/app_generators/watircraft/templates/initialize.rb.erb +10 -0
  10. data/app_generators/watircraft/templates/rakefile.rb +3 -0
  11. data/app_generators/watircraft/templates/script/console +5 -0
  12. data/app_generators/watircraft/templates/script/console.cmd +1 -0
  13. data/app_generators/watircraft/templates/site_start.rb.erb +12 -0
  14. data/app_generators/watircraft/templates/spec_helper.rb +9 -0
  15. data/app_generators/watircraft/templates/spec_initialize.rb +16 -0
  16. data/app_generators/watircraft/templates/world.rb +12 -0
  17. data/app_generators/watircraft/watircraft_generator.rb +108 -0
  18. data/bin/watircraft +17 -0
  19. data/lib/extensions/array.rb +10 -0
  20. data/lib/extensions/hash.rb +5 -0
  21. data/lib/extensions/object.rb +24 -0
  22. data/lib/extensions/string.rb +17 -0
  23. data/lib/extensions/watir.rb +41 -0
  24. data/lib/taza/browser.rb +45 -0
  25. data/lib/taza/entity.rb +34 -0
  26. data/lib/taza/fixture.rb +66 -0
  27. data/lib/taza/flow.rb +40 -0
  28. data/lib/taza/page.rb +259 -0
  29. data/lib/taza/settings.rb +80 -0
  30. data/lib/taza/site.rb +227 -0
  31. data/lib/taza/tasks.rb +30 -0
  32. data/lib/taza.rb +35 -0
  33. data/lib/watircraft/generator_helper.rb +27 -0
  34. data/lib/watircraft/table.rb +56 -0
  35. data/lib/watircraft/version.rb +3 -0
  36. data/lib/watircraft.rb +1 -0
  37. data/spec/array_spec.rb +16 -0
  38. data/spec/browser_spec.rb +68 -0
  39. data/spec/entity_spec.rb +9 -0
  40. data/spec/fake_table.rb +34 -0
  41. data/spec/fixture_spec.rb +34 -0
  42. data/spec/fixtures_spec.rb +21 -0
  43. data/spec/hash_spec.rb +12 -0
  44. data/spec/object_spec.rb +29 -0
  45. data/spec/page_generator_spec.rb +111 -0
  46. data/spec/page_spec.rb +342 -0
  47. data/spec/project_generator_spec.rb +103 -0
  48. data/spec/sandbox/config/config.yml +1 -0
  49. data/spec/sandbox/config/environments.yml +4 -0
  50. data/spec/sandbox/config/simpler.yml +1 -0
  51. data/spec/sandbox/config/simpler_site.yml +2 -0
  52. data/spec/sandbox/config.yml +2 -0
  53. data/spec/sandbox/fixtures/examples.yml +8 -0
  54. data/spec/sandbox/fixtures/users.yml +2 -0
  55. data/spec/sandbox/flows/batman.rb +5 -0
  56. data/spec/sandbox/flows/robin.rb +4 -0
  57. data/spec/sandbox/pages/foo/bar_page.rb +9 -0
  58. data/spec/sandbox/pages/foo/partials/partial_the_reckoning.rb +2 -0
  59. data/spec/settings_spec.rb +103 -0
  60. data/spec/site_generator_spec.rb +62 -0
  61. data/spec/site_spec.rb +249 -0
  62. data/spec/spec_generator_helper.rb +40 -0
  63. data/spec/spec_generator_spec.rb +24 -0
  64. data/spec/spec_helper.rb +21 -0
  65. data/spec/steps_generator_spec.rb +29 -0
  66. data/spec/string_spec.rb +17 -0
  67. data/spec/table_spec.rb +32 -0
  68. data/spec/taza_spec.rb +12 -0
  69. data/spec/watircraft_bin_spec.rb +14 -0
  70. data/watircraft.gemspec +53 -0
  71. data/watircraft_generators/page/USAGE +11 -0
  72. data/watircraft_generators/page/page_generator.rb +65 -0
  73. data/watircraft_generators/page/templates/page.rb.erb +8 -0
  74. data/watircraft_generators/site/site_generator.rb +51 -0
  75. data/watircraft_generators/site/templates/environments.yml.erb +4 -0
  76. data/watircraft_generators/site/templates/site.rb.erb +10 -0
  77. data/watircraft_generators/spec/USAGE +8 -0
  78. data/watircraft_generators/spec/spec_generator.rb +54 -0
  79. data/watircraft_generators/spec/templates/spec.rb.erb +17 -0
  80. data/watircraft_generators/steps/USAGE +13 -0
  81. data/watircraft_generators/steps/steps_generator.rb +62 -0
  82. data/watircraft_generators/steps/templates/steps.rb.erb +12 -0
  83. metadata +229 -0
@@ -0,0 +1,24 @@
1
+ # instance_exec comes with >1.8.7 thankfully
2
+ if VERSION <= '1.8.6'
3
+ class Object
4
+ module InstanceExecHelper; end
5
+ include InstanceExecHelper
6
+ # instance_exec method evaluates a block of code relative to the specified object, with parameters whom come from outside the object.
7
+ def instance_exec(*args, &block)
8
+ begin
9
+ old_critical, Thread.critical = Thread.critical, true
10
+ n = 0
11
+ n += 1 while respond_to?(mname="__instance_exec#{n}")
12
+ InstanceExecHelper.module_eval{ define_method(mname, &block) }
13
+ ensure
14
+ Thread.critical = old_critical
15
+ end
16
+ begin
17
+ ret = send(mname, *args)
18
+ ensure
19
+ InstanceExecHelper.module_eval{ remove_method(mname) } rescue nil
20
+ end
21
+ ret
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,17 @@
1
+ require 'rubygems'
2
+ require 'activesupport'
3
+
4
+ class String
5
+ # pluralizes a string and turns it into a symbol
6
+ # Example:
7
+ # "apple".pluralize_to_sym # => :apples
8
+ def pluralize_to_sym
9
+ self.pluralize.to_sym
10
+ end
11
+ # Opposite of humanize. Converts to lower case and converts spaces to underscores.
12
+ # Example:
13
+ # "Add Book".computerize # => "add_book"
14
+ def computerize
15
+ self.underscore.downcase.gsub ' ', '_'
16
+ end
17
+ end
@@ -0,0 +1,41 @@
1
+ # TODO: support radiogroup
2
+ # TODO: migrate this code into Watir
3
+ # TODO: also handle FireWatir classes
4
+ module Watir
5
+ class TextField # includes Hidden
6
+ def display_value
7
+ value
8
+ end
9
+ end
10
+ class FileField
11
+ def display_value
12
+ value
13
+ end
14
+ end
15
+ class CheckBox
16
+ # returns a boolean
17
+ def display_value
18
+ checked?
19
+ end
20
+ end
21
+ class SelectList
22
+ # Note: currently works for single-select only
23
+ def display_value
24
+ getSelectedItems[0]
25
+ end
26
+ end
27
+ class Button
28
+ def display_value
29
+ value
30
+ end
31
+ end
32
+ class Element
33
+ def display_value
34
+ text
35
+ end
36
+ end
37
+
38
+ class B < NonControlElement
39
+ TAG = 'B'
40
+ end
41
+ end
@@ -0,0 +1,45 @@
1
+ module Taza
2
+ class Browser
3
+
4
+ # Create a browser instance depending on configuration. Configuration should be read in via Taza::Settings.config.
5
+ #
6
+ # Example:
7
+ # browser = Taza::Browser.create(Taza::Settings.config)
8
+ #
9
+ def self.create(params={})
10
+ self.send("create_#{params[:driver]}".to_sym,params)
11
+ end
12
+
13
+ private
14
+
15
+ def self.create_watir(params)
16
+ require 'watir'
17
+ if params[:browser] == :ie
18
+ require 'watir/ie'
19
+ require 'extensions/watir'
20
+ end
21
+ Watir::Browser.default = params[:browser].to_s
22
+ Watir::Browser.new
23
+ end
24
+
25
+ def self.create_selenium(params)
26
+ require 'selenium'
27
+ Selenium::SeleniumDriver.new(params[:server_ip],params[:server_port],'*' + params[:browser].to_s,params[:timeout])
28
+ end
29
+
30
+ def self.create_fake(params)
31
+ FakeBrowser.new
32
+ end
33
+
34
+ end
35
+
36
+ class FakeBrowser
37
+ def goto(*args)
38
+ end
39
+ def close
40
+ end
41
+
42
+ end
43
+
44
+ end
45
+
@@ -0,0 +1,34 @@
1
+ module Taza
2
+ class Entity
3
+ #Creates a entity, pass in a hash to be methodized and the fixture to look up other fixtures (not entirely happy with this abstraction)
4
+ def initialize(hash,fixture)
5
+ @hash = hash
6
+ @fixture = fixture
7
+ define_methods_for_hash_keys
8
+ end
9
+
10
+ #This method converts hash keys into methods onto the entity
11
+ def define_methods_for_hash_keys
12
+ @hash.keys.each do |key|
13
+ create_method(key) do
14
+ get_value_for_entry(key)
15
+ end
16
+ end
17
+ end
18
+
19
+ #This method will lookup another fixture if a pluralized fixture exists otherwise return the value in the hash
20
+ def get_value_for_entry(key) # :nodoc:
21
+ if @fixture.pluralized_fixture_exists?(key)
22
+ @fixture.get_fixture_entity(key.pluralize_to_sym,@hash[key])
23
+ else
24
+ @hash[key]
25
+ end
26
+ end
27
+
28
+ private
29
+ def create_method(name, &block) # :nodoc:
30
+ self.class.send(:define_method, name, &block)
31
+ end
32
+
33
+ end
34
+ end
@@ -0,0 +1,66 @@
1
+ module Taza
2
+ class Fixture # :nodoc:
3
+
4
+ def initialize # :nodoc:
5
+ @fixtures = {}
6
+ end
7
+
8
+ def load_all # :nodoc:
9
+ Dir.glob(fixtures_pattern) do |file|
10
+ entitized_fixture = {}
11
+ YAML.load_file(file).each do |key, value|
12
+ entitized_fixture[key] = value.convert_hash_keys_to_methods(self)
13
+ end
14
+ @fixtures[File.basename(file,'.yml').to_sym] = entitized_fixture
15
+ end
16
+ end
17
+
18
+ def fixture_names # :nodoc:
19
+ @fixtures.keys
20
+ end
21
+
22
+ def get_fixture_entity(fixture_file_key,entity_key) # :nodoc:
23
+ @fixtures[fixture_file_key][entity_key]
24
+ end
25
+
26
+ def pluralized_fixture_exists?(singularized_fixture_name) # :nodoc:
27
+ fixture_names.include?(singularized_fixture_name.pluralize_to_sym)
28
+ end
29
+
30
+ def fixtures_pattern # :nodoc:
31
+ File.join(base_path, 'fixtures','*.yml')
32
+ end
33
+
34
+ def base_path # :nodoc:
35
+ File.join('.','spec')
36
+ end
37
+ end
38
+
39
+ # The module that will mixin methods based on the fixture files in your 'spec/fixtures'
40
+ #
41
+ # Example:
42
+ # describe "something" do
43
+ # it "should test something" do
44
+ # users(:jane_smith).first_name.should eql("jane")
45
+ # end
46
+ # end
47
+ #
48
+ # where there is a spec/fixtures/users.yml file containing a entry of:
49
+ # jane_smith:
50
+ # first_name: jane
51
+ # last_name: smith
52
+ module Fixtures
53
+ def Fixtures.included(other_module) # :nodoc:
54
+ fixture = Fixture.new
55
+ fixture.load_all
56
+ fixture.fixture_names.each do |fixture_name|
57
+ self.class_eval do
58
+ define_method(fixture_name) do |entity_key|
59
+ fixture.get_fixture_entity(fixture_name,entity_key.to_s)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ end
data/lib/taza/flow.rb ADDED
@@ -0,0 +1,40 @@
1
+ module Taza
2
+ # Flows provide a way to write and manage common actions on a site.
3
+ # For instance, on an e-commerce site you may have multiple tests where a user is supposed to create a
4
+ # new account and add a product to the shopping bag. In this case you could have two flows. create_an_account
5
+ # and add_product_to_bag.
6
+ #
7
+ # Here's how you would get this started where your site is called Widgets of the Future:
8
+ # $ ./script/generate flow create_an_account widgets_of_the_future
9
+ # $ ./script/generate flow add_product_to_bag widgets_of_the_future
10
+ #
11
+ # This will generate flows for you in lib/sites/widgets_of_the_future/flows/
12
+ #
13
+ # From here you can create the logic needed to perform these flows without ever referencing a browser object:
14
+ #
15
+ # module WidgetsOfTheFuture
16
+ # class WidgetsOfTheFuture < Taza::Site
17
+ # def create_an_account_flow(params={})
18
+ # home_page.create_an_account_link.click
19
+ # create_an_account_page do |cap|
20
+ # cap.email.set params[:email]
21
+ # cap.password.set params[:password]
22
+ # cap.submit.click
23
+ # end
24
+ # end
25
+ # end
26
+ # end
27
+ #
28
+ #
29
+ # Then inside a spec or test you could run this flow like:
30
+ #
31
+ # describe "Widgets of the Future" do
32
+ # it "should do widgety things so that we can make more monies" do
33
+ # WidgetsOfTheFuture.new do |w|
34
+ # w.create_an_account_flow :email => "i.am@the.widget.store.com", :password => "secret"
35
+ # end
36
+ # end
37
+ # end
38
+ class Flow
39
+ end
40
+ end
data/lib/taza/page.rb ADDED
@@ -0,0 +1,259 @@
1
+ require 'extensions/string'
2
+ require 'watir/exceptions' # so we can trap them
3
+ require 'watircraft/table'
4
+
5
+ module Taza
6
+ # An abstraction of a web page, place the elements you care about accessing in here as well as specify the filters that apply when trying to access the element.
7
+ #
8
+ # Example:
9
+ # require 'taza'
10
+ # class HomePage < Taza::Page
11
+ # element(:foo) {browser.element_by_xpath('some xpath')}
12
+ # filter :title_given, :foo
13
+ #
14
+ # def title_given
15
+ # browser.title.nil?
16
+ # end
17
+ # end
18
+ #
19
+ # homepage.foo will return the element specified in the block if the filter returned true
20
+ class Page
21
+ attr_accessor :browser, :site
22
+
23
+ class << self
24
+
25
+ def elements # :nodoc:
26
+ @elements ||= {}
27
+ end
28
+
29
+ def filters # :nodoc:
30
+ @filters ||= Hash.new { [] }
31
+ end
32
+
33
+ def fields # :nodoc:
34
+ @fields ||= []
35
+ end
36
+
37
+ def url string=nil
38
+ if string.nil?
39
+ @url
40
+ else
41
+ @url = string
42
+ end
43
+ end
44
+
45
+ # An element on a page
46
+ #
47
+ # Watir Example:
48
+ # class HomePage < Taza::Page
49
+ # element(:next_button) {browser.button(:value, 'Next'}
50
+ # end
51
+ # home_page.next_button.click
52
+ def element(name, &block)
53
+ name = name.to_s.computerize.to_sym
54
+ elements[name] = block
55
+ end
56
+
57
+ # An element on a page that has a value.
58
+ # Use #field for input elements and data elements.
59
+ #
60
+ # class HomePage < Taza::Page
61
+ # field(:name) {browser.text_field(:name, 'user_name')}
62
+ # end
63
+ #
64
+ # home_page.name_field # returns the text_field element
65
+ # home_page.name_field.exists?
66
+ # home_page.name = "Fred" # calls the #set method on the text_field
67
+ # home_page.name # returns the current value (display_value) of the text_field
68
+ #
69
+ # The following Watir elements provide both #set and #display_value methods
70
+ # text_field (both text boxes and text areas)
71
+ # hidden
72
+ # file_field
73
+ # select_list
74
+ # checkbox
75
+ # (radios are the obvious item missing from this list -- we're working on it.)
76
+ #
77
+ # The following Watir elements provide #display_value methods (but not #set methods).
78
+ # button
79
+ # cell
80
+ # hidden
81
+ # all non-control elements, including divs, spans and most other elements.
82
+ def field(name, suffix='field', &block)
83
+ name = name.to_s.computerize.to_sym
84
+ fields << name
85
+ element_name = "#{name}_#{suffix}"
86
+ elements[element_name] = block
87
+ define_method(name) do
88
+ send(element_name).display_value
89
+ end
90
+ define_method("#{name}=") do |value|
91
+ send(element_name).set value
92
+ end
93
+ element_name
94
+ end
95
+
96
+ # A filter for element(s) on a page
97
+ # Example:
98
+ # class HomePage < Taza::Page
99
+ # element(:foo) {browser.element_by_xpath('some xpath')}
100
+ # filter :title_given, :foo
101
+ # #a filter will apply to all elements if none are specified
102
+ # filter :some_filter
103
+ # #a filter will also apply to all elements if the symbol :all is given
104
+ # filter :another_filter, :all
105
+ #
106
+ # def some_filter
107
+ # true
108
+ # end
109
+ #
110
+ # def some_filter
111
+ # true
112
+ # end
113
+ #
114
+ # def title_given
115
+ # browser.title.nil?
116
+ # end
117
+ # end
118
+ def filter(method_name, *elements)
119
+ elements = [:all] if elements.empty?
120
+ elements.each do |element|
121
+ self.filters[element] = self.filters[element] << method_name
122
+ end
123
+ end
124
+
125
+ # Provides access to a WatirCraft::Table.
126
+ #
127
+ # Requires that an element also be declared on the page that directly
128
+ # wraps the table element itself.
129
+ #
130
+ # Example definition
131
+ #
132
+ # class YourCartPage < ::Taza::Page
133
+ # element(:items_table) {@browser.table(:index, 1)}
134
+ # table(:items) do
135
+ # field(:quantity) {@row.cell(:index, 1)}
136
+ # field(:description) {@row.cell(:index, 2)}
137
+ # end
138
+ # field(:total) {@browser.cell(:id, 'totalcell')}
139
+ #
140
+ # Example usage
141
+ #
142
+ # your_cart_page.items.row(:description => 'Pragmatic Project Automation').quantity.should == '1'
143
+ #
144
+ # Technical details: this method creates a
145
+ # subclass and then allows its class methods to define fields and
146
+ # elements on the table.
147
+ def table(name, &block)
148
+ # create subclass for the table
149
+ sub_class = Class.new(WatirCraft::Table)
150
+ sub_class.class_eval &block
151
+ # add method to the page, it returns an instance of the table subclass
152
+ define_method(name) do
153
+ sub_class.new send("#{name}_table")
154
+ end
155
+ end
156
+ end
157
+
158
+ def initialize
159
+ add_element_methods
160
+ @active_filters = []
161
+ end
162
+
163
+ def add_element_methods # :nodoc:
164
+ self.class.elements.each do |element_name,element_block|
165
+ filters = self.class.filters[element_name] + self.class.filters[:all]
166
+ add_element_method(:filters => filters, :element_name => element_name, :element_block => element_block)
167
+ end
168
+ end
169
+
170
+ def add_element_method(params) # :nodoc:
171
+ self.class.class_eval do
172
+ define_method(params[:element_name]) do |*args|
173
+ check_filters(params)
174
+ self.instance_exec(*args,&params[:element_block])
175
+ end
176
+ end
177
+ end
178
+
179
+ def check_filters(params) # :nodoc:
180
+ params[:filters].each do |filter_method|
181
+ unless @active_filters.include?(filter_method)
182
+ @active_filters << filter_method
183
+ raise FilterError, "#{filter_method} returned false for #{params[:element_name]}" unless send(filter_method)
184
+ @active_filters.delete(filter_method)
185
+ end
186
+ end
187
+ end
188
+
189
+ # Go to this page. Url is computed based the page url and the url from the
190
+ # Site & Settings.
191
+ def goto
192
+ @site.goto self.class.url
193
+ end
194
+
195
+ # Return the full url expected for the page, taking into account the Site
196
+ # and settings.
197
+ def full_url
198
+ File.join(@site.origin, self.class.url)
199
+ end
200
+
201
+ # Enter values into fields on the page using a hash, using the key of
202
+ # each pair to name the field.
203
+ def populate hash
204
+ hash.each do |key, value|
205
+ send "#{key}=", value
206
+ end
207
+ end
208
+
209
+ # Verify that the fields specified by the keys in the hash correspond to the
210
+ # provided values.
211
+ def validate hash
212
+ hash.each do |key, value|
213
+ send(key).should == value
214
+ end
215
+ end
216
+
217
+ # Return the names of the elements defined for the page.
218
+ def elements
219
+ self.class.elements.keys.map &:to_s
220
+ end
221
+
222
+ # Return the names of the fields defined for the page.
223
+ def fields
224
+ self.class.fields.map &:to_s
225
+ end
226
+
227
+ # Returns a hash with the names and values of the specified fields.
228
+ # If no fields are specifieds, all fields on the page are used.
229
+ def values field_names=fields
230
+ result = {}
231
+ field_names.each { |name| result[name.to_sym] = send(name)}
232
+ result
233
+ end
234
+
235
+ include Watir::Exception
236
+ def element_exist? name
237
+ begin
238
+ send(name).exist?
239
+ rescue UnknownFrameException, UnknownObjectException,
240
+ UnknownFormException, UnknownCellException
241
+ false
242
+ end
243
+ end
244
+ alias :element_exists? :element_exist?
245
+
246
+ def elements_exist? element_names=elements
247
+ result = {}
248
+ element_names.each do |element|
249
+ result[element.to_sym] = element_exist?(element)
250
+ end
251
+ result
252
+ end
253
+ alias :elements_exists? :elements_exist?
254
+
255
+ end
256
+
257
+ class FilterError < StandardError; end
258
+
259
+ end
@@ -0,0 +1,80 @@
1
+ require 'activesupport'
2
+
3
+ module Taza
4
+ class Settings
5
+ # The config settings from the site.yml and config.yml files.
6
+ # ENV variables will override the settings.
7
+ #
8
+ # Example:
9
+ # Taza::Settings.config('google')
10
+ def self.config(site_name=nil)
11
+ env_settings = {}
12
+ keys = %w(browser driver timeout server_ip server_port)
13
+ keys.each do |key|
14
+ env_settings[key.to_sym] = ENV[key.upcase] if ENV[key.upcase]
15
+ end
16
+
17
+ default_settings = {:browser => :firefox, :driver => :watir}
18
+
19
+ # Because of the way #merge works, the settings at the bottom of the list
20
+ # trump those at the top.
21
+ settings = environment_settings.merge(
22
+ default_settings.merge(
23
+ config_file.merge(
24
+ env_settings)))
25
+
26
+ settings[:browser] = settings[:browser].to_sym
27
+ settings[:driver] = settings[:driver].to_sym
28
+ settings
29
+ end
30
+
31
+ # Returns a hash corresponding to the project config file.
32
+ def self.config_file
33
+ if File.exists?(config_file_path)
34
+ hash = YAML.load_file(config_file_path)
35
+ else
36
+ hash = {}
37
+ end
38
+ self.convert_string_keys_to_symbols hash
39
+ end
40
+
41
+ def self.config_file_path # :nodoc:
42
+ File.join(path, 'config', 'config.yml')
43
+ end
44
+
45
+ # Returns a hash for the currently specified test environment
46
+ def self.environment_settings # :nodoc:
47
+ file = File.join(path, environment_file)
48
+ hash_of_hashes = YAML.load_file(file)
49
+ unless hash_of_hashes.has_key? environment
50
+ raise "Environment #{environment} not found in #{file}"
51
+ end
52
+ convert_string_keys_to_symbols hash_of_hashes[environment]
53
+ end
54
+
55
+ @@root = nil
56
+
57
+ def self.path # :nodoc:
58
+ @@root || APP_ROOT
59
+ end
60
+
61
+ def self.path= path
62
+ @@root = path
63
+ end
64
+
65
+ def self.environment
66
+ ENV['ENVIRONMENT'] || 'test'
67
+ end
68
+
69
+ def self.convert_string_keys_to_symbols hash
70
+ returning Hash.new do |new_hash|
71
+ hash.each_pair {|k, v| new_hash[k.to_sym] = v}
72
+ end
73
+ end
74
+
75
+ private
76
+ def self.environment_file
77
+ 'config/environments.yml'
78
+ end
79
+ end
80
+ end