dill 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,28 @@
1
+ require 'chronic'
2
+ require 'nokogiri'
3
+ require 'capybara'
4
+
5
+ require 'dill/widget_container'
6
+ require 'dill/conversions'
7
+ require 'dill/instance_conversions'
8
+ require 'dill/node_text'
9
+ require 'dill/widget_name'
10
+ require 'dill/widget'
11
+ require 'dill/list'
12
+ require 'dill/base_table'
13
+ require 'dill/auto_table'
14
+ require 'dill/table'
15
+ require 'dill/field_group'
16
+ require 'dill/form'
17
+ require 'dill/document'
18
+ require 'dill/text_table'
19
+ require 'dill/text_table/mapping'
20
+ require 'dill/text_table/void_mapping'
21
+ require 'dill/text_table/transformations'
22
+ require 'dill/text_table/cell_text'
23
+ require 'dill/dsl'
24
+
25
+ module Dill
26
+ # An exception that signals that something is missing.
27
+ class Missing < StandardError; end
28
+ end
@@ -0,0 +1,73 @@
1
+ module Dill
2
+ class AutoTable < BaseTable
3
+
4
+ # don't include footer in to_table, because footer column configuration is very
5
+ # often different from the headers & values.
6
+ def footers
7
+ @footers ||= root.all(footer_selector).map { |n| node_text(n) }
8
+ end
9
+
10
+ protected
11
+
12
+ def ensure_table_loaded
13
+ root.find(data_row_selector)
14
+ rescue Capybara::Ambiguous
15
+ end
16
+
17
+ private
18
+
19
+ def data_cell_selector
20
+ 'td'
21
+ end
22
+
23
+ def data_row(node)
24
+ Row.new(root: node, cell_selector: data_cell_selector)
25
+ end
26
+
27
+ def data_row_selector
28
+ 'tbody tr'
29
+ end
30
+
31
+ def data_rows
32
+ @data_rows ||= root.all(data_row_selector).map { |n| data_row(n) }
33
+ end
34
+
35
+ def header_selector
36
+ 'thead th'
37
+ end
38
+
39
+ def headers
40
+ @headers ||= root.all(header_selector).map { |n| node_text(n).downcase }
41
+ end
42
+
43
+ def footer_selector
44
+ 'tfoot td'
45
+ end
46
+
47
+ def values
48
+ @values ||= data_rows.map(&:values)
49
+ end
50
+
51
+ class Row < Widget
52
+ def initialize(settings)
53
+ s = settings.dup
54
+
55
+ self.cell_selector = s.delete(:cell_selector)
56
+
57
+ super s
58
+ end
59
+
60
+ def values
61
+ root.all(cell_selector).map { |n| node_text(n) }
62
+ end
63
+
64
+ private
65
+
66
+ attr_accessor :cell_selector
67
+
68
+ def node_text(node)
69
+ NodeText.new(node)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,9 @@
1
+ module Dill
2
+ class BaseTable < Widget
3
+ def to_table
4
+ ensure_table_loaded
5
+
6
+ headers.any? ? [headers, *values] : values
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,29 @@
1
+ module Dill
2
+ module Conversions
3
+ def Boolean(val)
4
+ case val
5
+ when 'yes', 'true', true
6
+ true
7
+ when 'no', 'false', false, nil, ''
8
+ false
9
+ else
10
+ raise ArgumentError, "can't convert #{val.inspect} to boolean"
11
+ end
12
+ end
13
+
14
+ def List(valstr, &block)
15
+ vs = valstr.strip.split(/\s*,\s*/)
16
+
17
+ block ? vs.map(&block) : vs
18
+ end
19
+
20
+ def Timeish(val)
21
+ raise ArgumentError, "can't convert nil to Timeish" if val.nil?
22
+
23
+ return val if Date === val || Time === val || DateTime === val
24
+
25
+ Chronic.parse(val) or
26
+ raise ArgumentError, "can't parse #{val.inspect} to Timeish"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,3 @@
1
+ require 'dill'
2
+
3
+ World(Dill::DSL)
@@ -0,0 +1,14 @@
1
+ module Dill
2
+ class Document
3
+ include WidgetContainer
4
+
5
+ def initialize(options)
6
+ self.widget_lookup_scope =
7
+ options.delete(:widget_lookup_scope) or raise "No scope given"
8
+ end
9
+
10
+ def root
11
+ Capybara.current_session
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,34 @@
1
+ module Dill
2
+ module DSL
3
+ attr_writer :widget_lookup_scope
4
+
5
+ # @return [Document] the current document with the class of the
6
+ # current object set as the widget lookup scope.
7
+ def document
8
+ Document.new(widget_lookup_scope: widget_lookup_scope)
9
+ end
10
+
11
+ # @return [Boolean] Whether one or more widgets exist in the current
12
+ # document.
13
+ def has_widget?(name)
14
+ document.has_widget?(name)
15
+ end
16
+
17
+ # Returns a widget instance for the given name.
18
+ #
19
+ # @param name [String, Symbol]
20
+ def widget(name, options = {})
21
+ document.widget(name, options)
22
+ end
23
+
24
+ def widget_lookup_scope
25
+ @widget_lookup_scope ||= default_widget_lookup_scope
26
+ end
27
+
28
+ private
29
+
30
+ def default_widget_lookup_scope
31
+ Module === self ? self : self.class
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,331 @@
1
+ module Dill
2
+ # A group of form fields.
3
+ #
4
+ # @todo Explain how to use locators when defining fields, including what
5
+ # happens when locators are omitted.
6
+ class FieldGroup < Widget
7
+ root 'fieldset'
8
+
9
+ def self.default_locator(type = nil, &block)
10
+ alias_method :name_to_locator, type if type
11
+
12
+ define_method :name_to_locator, &block if block
13
+ end
14
+
15
+ # The names of all the fields that belong to this field group.
16
+ #
17
+ # Field names are automatically added to this group as long as you use
18
+ # the field definition macros.
19
+ #
20
+ # @return [Set] the field names.
21
+ #
22
+ # @see field
23
+ def self.field_names
24
+ @field_names ||= Set.new
25
+ end
26
+
27
+ # @!group Field definition macros
28
+
29
+ # Creates a new checkbox accessor.
30
+ #
31
+ # Adds the following methods to the widget:
32
+ #
33
+ # <name>:: Gets the current checkbox state, as a boolean. Returns +true+
34
+ # if the corresponding check box is checked, +false+ otherwise.
35
+ # <name>=:: Sets the current checkbox state. Pass +true+ to check the
36
+ # checkbox, +false+ otherwise.
37
+ #
38
+ # @example
39
+ # # Given the following HTML:
40
+ # #
41
+ # # <form>
42
+ # # <p>
43
+ # # <label for="checked-box">
44
+ # # <input type="checkbox" value="1" id="checked-box" checked>
45
+ # # </p>
46
+ # # <p>
47
+ # # <label for="unchecked-box">
48
+ # # <input type="checkbox" value="1" id="unchecked-box">
49
+ # # </p>
50
+ # # </form>
51
+ # class MyFieldGroup < Dill::FieldGroup
52
+ # root 'form'
53
+ #
54
+ # check_box :checked_box, 'checked-box'
55
+ # check_box :unchecked_box, 'unchecked-box'
56
+ # end
57
+ #
58
+ # form = widget(:my_field_group)
59
+ #
60
+ # form.checked_box #=> true
61
+ # form.unchecked_box #=> false
62
+ #
63
+ # form.unchecked_box = true
64
+ # form.unchecked_box #=> true
65
+ #
66
+ # @param name the name of the checkbox accessor.
67
+ # @param locator the locator for the checkbox. If +nil+ the locator will
68
+ # be derived from +name+.
69
+ #
70
+ # @todo Handle checkbox access when the field is disabled (raise an
71
+ # exception?)
72
+ def self.check_box(name, locator = nil)
73
+ field name, locator, CheckBox
74
+ end
75
+
76
+ # Defines a new field.
77
+ #
78
+ # @param name the name of the field accessor.
79
+ # @param locator the field locator.
80
+ # @param type the field class name.
81
+ #
82
+ # @api private
83
+ def self.field(name, locator, type)
84
+ raise TypeError, "can't convert `#{name}' to Symbol" \
85
+ unless name.respond_to?(:to_sym)
86
+
87
+ field_names << name.to_sym
88
+
89
+ label = name.to_s.gsub(/_/, ' ').capitalize
90
+ locator ||= label
91
+
92
+ widget name, locator, type do
93
+ define_method :label do
94
+ label
95
+ end
96
+ end
97
+
98
+ define_method "#{name}=" do |val|
99
+ widget(name).set val
100
+ end
101
+
102
+ define_method name do
103
+ widget(name).get
104
+ end
105
+ end
106
+
107
+ # Creates a new select accessor.
108
+ #
109
+ # Adds the following methods to the widget:
110
+ #
111
+ # <name>:: Gets the current selected option. Returns the label of the
112
+ # selected option, or +nil+, if no option is selected.
113
+ # <name>=:: Selects an option on the current select. Pass the label of
114
+ # the option you want to select.
115
+ #
116
+ # @example
117
+ # # Given the following HTML:
118
+ # #
119
+ # # <form>
120
+ # # <p>
121
+ # # <label for="selected">
122
+ # # <select id="selected">
123
+ # # <option selected>Selected option</option>
124
+ # # <option>Another option</option>
125
+ # # </select>
126
+ # # </p>
127
+ # # <p>
128
+ # # <label for="deselected">
129
+ # # <select id="deselected">
130
+ # # <option>Deselected option</option>
131
+ # # <option>Another option</option>
132
+ # # </select>
133
+ # # </p>
134
+ # # </form>
135
+ # class MyFieldGroup < Dill::FieldGroup
136
+ # root 'form'
137
+ #
138
+ # select :selected, 'selected'
139
+ # select :deselected, 'deselected'
140
+ # end
141
+ #
142
+ # form = widget(:my_field_group)
143
+ #
144
+ # form.selected #=> "Selected option"
145
+ # form.deselected #=> nil
146
+ #
147
+ # form.deselected = "Deselected option"
148
+ # form.unchecked_box #=> "Deselected option"
149
+ #
150
+ # @param name the name of the select accessor.
151
+ # @param locator the locator for the select. If +nil+ the locator will
152
+ # be derived from +name+.
153
+ #
154
+ # @todo Handle select access when the field is disabled (raise an
155
+ # exception?)
156
+ # @todo Raise an exception when an option doesn't exist.
157
+ # @todo Allow passing the option value to set an option.
158
+ # @todo Ensure an option with no text returns the empty string.
159
+ # @todo What to do when +nil+ is passed to the writer?
160
+ def self.select(name, locator = nil)
161
+ field name, locator, Select
162
+ end
163
+
164
+ # Creates a new text field accessor.
165
+ #
166
+ # Adds the following methods to the widget:
167
+ #
168
+ # <name>:: Returns the current text field value, or +nil+ if no value
169
+ # has been set.
170
+ # <name>=:: Sets the current text field value.
171
+ #
172
+ # @example
173
+ # # Given the following HTML:
174
+ # #
175
+ # # <form>
176
+ # # <p>
177
+ # # <label for="text-field">
178
+ # # <input type="text" value="Content" id="text-field">
179
+ # # </p>
180
+ # # <p>
181
+ # # <label for="empty-field">
182
+ # # <input type="text" id="empty-field">
183
+ # # </p>
184
+ # # </form>
185
+ # class MyFieldGroup < Dill::FieldGroup
186
+ # root 'form'
187
+ #
188
+ # text_field :filled_field, 'text-field'
189
+ # text_field :empty_field, 'empty-field'
190
+ # end
191
+ #
192
+ # form = widget(:my_field_group)
193
+ #
194
+ # form.filled_field #=> "Content"
195
+ # form.empty_field #=> nil
196
+ #
197
+ # form.empty_field = "Not anymore"
198
+ # form.empty_field #=> "Not anymore"
199
+ #
200
+ # @param name the name of the text field accessor.
201
+ # @param locator the locator for the text field. If +nil+ the locator
202
+ # will be derived from +name+.
203
+ #
204
+ # @todo Handle text field access when the field is disabled (raise an
205
+ # exception?)
206
+ def self.text_field(name, locator = nil)
207
+ field name, locator, TextField
208
+ end
209
+
210
+ # @!endgroup
211
+
212
+ # @return This field group's field widgets.
213
+ def fields
214
+ self.class.field_names.map { |name| widget(name) }
215
+ end
216
+
217
+ # Sets the given form attributes.
218
+ #
219
+ # @param attributes [Hash] the attributes and values we want to set.
220
+ #
221
+ # @return the current widget.
222
+ def set(attributes)
223
+ attributes.each do |k, v|
224
+ send "#{k}=", v
225
+ end
226
+
227
+ self
228
+ end
229
+
230
+ # Converts the current field group into a table suitable for diff'ing
231
+ # with Cucumber::Ast::Table.
232
+ #
233
+ # Field labels are determined by the widget name.
234
+ #
235
+ # Field values correspond to the return value of each field's +to_s+.
236
+ #
237
+ # @return [Array<Array>] the table.
238
+ def to_table
239
+ headers = fields.map { |field| field.label.downcase }
240
+ body = fields.map { |field| field.to_s.downcase }
241
+
242
+ [headers, body]
243
+ end
244
+
245
+ # A form field.
246
+ class Field < Widget
247
+ def self.find_in(parent, options)
248
+ new({root: parent.find_field(selector)}.merge(options))
249
+ end
250
+
251
+ def self.present_in?(parent)
252
+ parent.has_field?(selector)
253
+ end
254
+
255
+ # @return This field's value.
256
+ #
257
+ # Override this to get the actual value.
258
+ def get
259
+ raise NotImplementedError
260
+ end
261
+
262
+ # Sets the field value.
263
+ #
264
+ # Override this to set the value.
265
+ def set(value)
266
+ raise NotImplementedError
267
+ end
268
+ end
269
+
270
+ # A check box.
271
+ class CheckBox < Field
272
+ # @!method set(value)
273
+ # Checks or unchecks the current checkbox.
274
+ #
275
+ # @param value [Boolean] +true+ to check the checkbox, +false+
276
+ # otherwise.
277
+ def_delegator :root, :set
278
+
279
+ # @return [Boolean] +true+ if the checkbox is checked, +false+
280
+ # otherwise.
281
+ def get
282
+ !! root.checked?
283
+ end
284
+
285
+ # @return +"yes"+ if the checkbox is checked, +"no"+ otherwise.
286
+ def to_s
287
+ get ? 'yes' : 'no'
288
+ end
289
+ end
290
+
291
+ # A select.
292
+ class Select < Field
293
+ # @return [String] The text of the selected option.
294
+ def get
295
+ option = root.find('[selected]') rescue nil
296
+
297
+ option && option.text
298
+ end
299
+
300
+ # Selects the given option.
301
+ #
302
+ # @param option [String] The text of the option to select.
303
+ def set(option)
304
+ root.find('option', text: option).select_option
305
+ end
306
+
307
+ # @!method to_s
308
+ # @return the text of the selected option, or the empty string if
309
+ # no option is selected.
310
+ def_delegator :get, :to_s
311
+ end
312
+
313
+ # A text field.
314
+ class TextField < Field
315
+ # @!method get
316
+ # @return The text field value.
317
+ def_delegator :root, :value, :get
318
+
319
+ # @!method set(value)
320
+ # Sets the text field value.
321
+ #
322
+ # @param value [String] the value to set.
323
+ def_delegator :root, :set
324
+
325
+ # @!method to_s
326
+ # @return the text field value, or the empty string if the field is
327
+ # empty.
328
+ def_delegator :get, :to_s
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,15 @@
1
+ module Dill
2
+ class Form < FieldGroup
3
+ action :submit, '[type = submit]'
4
+
5
+ # Submit form with +attributes+.
6
+ #
7
+ # @param attributes [Hash] the form fields and their values
8
+ #
9
+ # @return the current widget
10
+ def submit_with(attributes)
11
+ set attributes
12
+ submit
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ module Dill
2
+ module InstanceConversions
3
+ def self.included(base)
4
+ base.send :include, Dill::Conversions
5
+ end
6
+
7
+ def to_boolean
8
+ Boolean(self)
9
+ end
10
+
11
+ def to_a
12
+ List(self)
13
+ end
14
+
15
+ def to_time
16
+ Timeish(self)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,47 @@
1
+ module Dill
2
+ class List < Widget
3
+ DEFAULT_TYPE = Widget
4
+
5
+ include Enumerable
6
+
7
+ def_delegators :items, :size, :include?, :each, :empty?, :first, :last
8
+
9
+ def self.item(selector, type = DEFAULT_TYPE, &item_for)
10
+ define_method :item_selector do
11
+ @item_selector ||= selector
12
+ end
13
+
14
+ if block_given?
15
+ define_method :item_for, &item_for
16
+ else
17
+ define_method :item_factory do
18
+ type
19
+ end
20
+ end
21
+ end
22
+
23
+ def to_table
24
+ items.map { |e| Array(e) }
25
+ end
26
+
27
+ protected
28
+
29
+ attr_writer :item_selector
30
+
31
+ def item_factory
32
+ DEFAULT_TYPE
33
+ end
34
+
35
+ def item_for(node)
36
+ item_factory.new(root: node)
37
+ end
38
+
39
+ def item_selector
40
+ 'li'
41
+ end
42
+
43
+ def items
44
+ root.all(item_selector).map { |node| item_for(node) }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,9 @@
1
+ module Dill
2
+ class NodeText < String
3
+ include InstanceConversions
4
+
5
+ def initialize(node)
6
+ super node.text.strip
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,66 @@
1
+ module Dill
2
+ class Table < BaseTable
3
+ class ColumnDefinition
4
+ attr_reader :header
5
+
6
+ def initialize(selector, header, transform)
7
+ self.selector = selector
8
+ self.header = header
9
+ self.transform = transform
10
+ end
11
+
12
+ def ensure_loaded(container)
13
+ container.find(selector)
14
+ rescue Capybara::Ambiguous
15
+ end
16
+
17
+ def values(container)
18
+ container.all(selector).map { |n| transform.(node_text(n)).to_s }
19
+ end
20
+
21
+ private
22
+
23
+ attr_accessor :selector
24
+ attr_writer :header, :transform
25
+
26
+ def node_text(node)
27
+ NodeText.new(node)
28
+ end
29
+
30
+ def transform
31
+ @transform ||= ->(v) { v }
32
+ end
33
+ end
34
+
35
+ class << self
36
+ attr_accessor :column_selector, :header_selector
37
+ end
38
+
39
+ def self.column(selector, header = nil, &transform)
40
+ column_definitions << ColumnDefinition.new(selector, header, transform)
41
+ end
42
+
43
+ def self.column_definitions
44
+ @column_definitions ||= []
45
+ end
46
+
47
+ protected
48
+
49
+ def ensure_table_loaded
50
+ column_definitions.first.ensure_loaded(self)
51
+ end
52
+
53
+ private
54
+
55
+ def_delegators 'self.class', :column_selector, :column_definitions,
56
+ :header_selector
57
+
58
+ def headers
59
+ @headers ||= column_definitions.map(&:header)
60
+ end
61
+
62
+ def values
63
+ @values ||= column_definitions.map { |d| d.values(root) }.transpose
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,107 @@
1
+ module Dill
2
+ class TextTable
3
+ extend Forwardable
4
+
5
+ include Enumerable
6
+ include Conversions
7
+
8
+ class << self
9
+ def Array(table)
10
+ new(table).to_a
11
+ end
12
+
13
+ def Hash(table)
14
+ new(table).to_h
15
+ end
16
+
17
+ def map(name, options = {}, &block)
18
+ case name
19
+ when :*
20
+ set_default_mapping options, &block
21
+ else
22
+ set_mapping name, options, &block
23
+ end
24
+ end
25
+
26
+ def mappings
27
+ @mappings ||= Hash.
28
+ new { |h, k| h[k] = Mapping.new }.
29
+ merge(with_parent_mappings)
30
+ end
31
+
32
+ def skip(name)
33
+ case name
34
+ when :*
35
+ set_default_mapping VoidMapping
36
+ else
37
+ raise ArgumentError, "can't convert #{name.inspect} to name"
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def set_default_mapping(options, &block)
44
+ case options
45
+ when Hash
46
+ @mappings = Hash.
47
+ new { |h, k|
48
+ h[k] = Mapping.new(key_transformer: options[:to],
49
+ value_transformer: block) }.
50
+ merge(mappings)
51
+ when Class
52
+ @mappings = Hash.new { |h, k| h[k] = options.new }.merge(mappings)
53
+ else
54
+ raise ArgumentError, "can't convert #{options.inspect} to mapping"
55
+ end
56
+ end
57
+
58
+ def set_mapping(name, options, &block)
59
+ mappings[name] = Mapping.
60
+ new(key: options[:to], value_transformer: block)
61
+ end
62
+
63
+ def with_parent_mappings
64
+ if superclass.respond_to?(:mappings)
65
+ superclass.send(:mappings).dup
66
+ else
67
+ {}
68
+ end
69
+ end
70
+ end
71
+
72
+ def_delegators 'self.class', :mappings
73
+
74
+ def initialize(table)
75
+ self.table = table
76
+ end
77
+
78
+ def each(&block)
79
+ rows.each(&block)
80
+ end
81
+
82
+ def rows
83
+ @rows ||= table.hashes.map { |h| new_row(h) }
84
+ end
85
+
86
+ def single_row
87
+ @single_row ||= new_row(table.rows_hash)
88
+ end
89
+
90
+ alias_method :to_a, :rows
91
+ alias_method :to_h, :single_row
92
+
93
+ private
94
+
95
+ attr_accessor :table
96
+
97
+ def new_row(hash)
98
+ hash.each_with_object({}) { |(k, v), h|
99
+ mapping_for(k).set(self, h, k, CellText.new(v))
100
+ }
101
+ end
102
+
103
+ def mapping_for(header)
104
+ mappings[header]
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,7 @@
1
+ module Dill
2
+ class TextTable
3
+ class CellText < String
4
+ include InstanceConversions
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,40 @@
1
+ module Dill
2
+ class TextTable
3
+ class Mapping
4
+ def initialize(settings = {})
5
+ self.key = settings[:key]
6
+ self.value_transformer = transformer(settings[:value_transformer], :pass)
7
+ self.key_transformer = transformer(settings[:key_transformer], :keyword)
8
+ end
9
+
10
+ def set(instance, row, key, value)
11
+ row[transform_key(instance, key)] = transform_value(instance, value)
12
+ end
13
+
14
+ private
15
+
16
+ attr_accessor :key, :value_transformer, :key_transformer
17
+
18
+ def transform_key(_, k)
19
+ key || key_transformer.(k)
20
+ end
21
+
22
+ def transform_value(instance, value)
23
+ instance.instance_exec(value, &value_transformer)
24
+ end
25
+
26
+ def transformer(set, fallback)
27
+ case set
28
+ when Symbol
29
+ Transformations.send(set)
30
+ when Proc
31
+ set
32
+ when nil
33
+ Transformations.send(fallback)
34
+ else
35
+ raise ArgumentError, "can't convert #{set.inspect} to transformer"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,13 @@
1
+ module Dill
2
+ class TextTable
3
+ module Transformations
4
+ def self.keyword
5
+ ->(val) { val.squeeze(' ').strip.gsub(' ', '_').sub(/\?$/, '').to_sym }
6
+ end
7
+
8
+ def self.pass
9
+ ->(val) { val }
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,8 @@
1
+ module Dill
2
+ class TextTable
3
+ class VoidMapping
4
+ def set(*)
5
+ end
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,3 @@
1
+ module Dill
2
+ VERSION = "0.4.0"
3
+ end
@@ -0,0 +1,212 @@
1
+ module Dill
2
+ class Widget
3
+ extend Forwardable
4
+
5
+ include WidgetContainer
6
+
7
+ # @!group Widget macros
8
+
9
+ # Defines a new action.
10
+ #
11
+ # This is a shortcut to help defining a widget and a method that clicks
12
+ # on that widget. You can then send a widget instance the message given
13
+ # by +name+.
14
+ #
15
+ # @example
16
+ # # Consider the widget will encapsulate the following HTML
17
+ # #
18
+ # # <div id="profile">
19
+ # # <a href="/profiles/1/edit" rel="edit">Edit</a>
20
+ # # </div>
21
+ # class PirateProfile < Dill::Widget
22
+ # root "#profile"
23
+ #
24
+ # # Declare the action
25
+ # action :edit, '[rel = edit]'
26
+ # end
27
+ #
28
+ # # Click the link
29
+ # widget(:pirate_profile).edit
30
+ #
31
+ # @param name the name of the action
32
+ # @param selector the selector for the widget that will be clicked
33
+ def self.action(name, selector)
34
+ widget name, selector
35
+
36
+ define_method name do
37
+ widget(name).click
38
+
39
+ self
40
+ end
41
+ end
42
+
43
+ # Declares a new sub-widget.
44
+ #
45
+ # Sub-widgets are accessible inside the container widget using the
46
+ # +widget+ message.
47
+ #
48
+ # @param name the name of the sub-widget
49
+ # @param selector the sub-widget selector
50
+ # @param parent [Class] the parent class of the new sub-widget
51
+ #
52
+ # @yield A block allowing you to further customize the widget behavior.
53
+ #
54
+ # @see #widget
55
+ def self.widget(name, selector, parent = Widget, &block)
56
+ type = Class.new(parent) {
57
+ root selector
58
+ }
59
+
60
+ type.class_eval(&block) if block_given?
61
+
62
+ const_set(Dill::WidgetName.new(name).to_sym, type)
63
+ end
64
+
65
+ # Creates a delegator for one sub-widget message.
66
+ #
67
+ # Since widgets are accessed through {WidgetContainer#widget}, we can't
68
+ # use {Forwardable} to delegate messages to widgets.
69
+ #
70
+ # @param name the name of the receiver sub-widget
71
+ # @param widget_message the name of the message to be sent to the sub-widget
72
+ # @param method_name the name of the delegator. If +nil+ the method will
73
+ # have the same name as the message it will send.
74
+ def self.widget_delegator(name, widget_message, method_name = nil)
75
+ method_name = method_name || widget_message
76
+
77
+ class_eval <<-RUBY
78
+ def #{method_name}(*args)
79
+ if args.size == 1
80
+ widget(:#{name}).#{widget_message} args.first
81
+ else
82
+ widget(:#{name}).#{widget_message} *args
83
+ end
84
+ end
85
+ RUBY
86
+ end
87
+
88
+ # @!endgroup
89
+
90
+ # Determines if an instance of this widget class exists in
91
+ # +parent_node+.
92
+ #
93
+ # @param parent_node [Capybara::Node] the node we want to search in
94
+ #
95
+ # @return +true+ if a widget instance is found, +false+ otherwise.
96
+ def self.present_in?(parent_node)
97
+ parent_node.has_selector?(selector)
98
+ end
99
+
100
+ # Finds a single instance of the current widget in +node+.
101
+ #
102
+ # @param node the node we want to search in
103
+ #
104
+ # @return a new instance of the current widget class.
105
+ #
106
+ # @raise [Capybara::ElementNotFoundError] if the widget can't be found
107
+ def self.find_in(node, options = {})
108
+ new(options.merge(root: node.find(selector)))
109
+ end
110
+
111
+ # Sets this widget's default selector.
112
+ #
113
+ # @param selector [String] a CSS or XPath query
114
+ def self.root(selector)
115
+ @selector = selector
116
+ end
117
+
118
+ # @return The selector specified with +root+.
119
+ def self.selector
120
+ @selector
121
+ end
122
+
123
+ # @return The root node of the current widget
124
+ attr_reader :root
125
+
126
+ def_delegators :root, :click
127
+
128
+ def initialize(settings = {})
129
+ self.root = settings.fetch(:root)
130
+ end
131
+
132
+ # Determines if the widget underlying an action exists.
133
+ #
134
+ # @param name the name of the action
135
+ #
136
+ # @raise Missing if an action with +name+ can't be found.
137
+ #
138
+ # @return [Boolean] +true+ if the action widget is found, +false+
139
+ # otherwise.
140
+ def has_action?(name)
141
+ raise Missing, "couldn't find `#{name}' action" unless respond_to?(name)
142
+
143
+ has_widget?(name)
144
+ end
145
+
146
+ def inspect
147
+ xml = Nokogiri::HTML(page.body).at(root.path).to_xml
148
+
149
+ "<!-- #{self.class.name}: -->\n" <<
150
+ Nokogiri::XML(xml, &:noblanks).to_xhtml
151
+ end
152
+
153
+ class Reload < Capybara::ElementNotFound; end
154
+
155
+ # Reloads the widget, waiting for its contents to change (by default),
156
+ # or until +wait_time+ expires.
157
+ #
158
+ # Call this method to make sure a widget has enough time to update
159
+ # itself.
160
+ #
161
+ # You can pass a block to this method to control what it means for the
162
+ # widget to be reloaded.
163
+ #
164
+ # *Note: does not account for multiple changes to the widget yet.*
165
+ #
166
+ # @param wait_time [Numeric] how long we should wait for changes, in
167
+ # seconds.
168
+ #
169
+ # @yield A block that determines what it means for a widget to be
170
+ # reloaded.
171
+ # @yieldreturn [Boolean] +true+ if the widget is considered to be
172
+ # reloaded, +false+ otherwise.
173
+ #
174
+ # @return the current widget
175
+ def reload(wait_time = Capybara.default_wait_time, &test)
176
+ unless test
177
+ old_inspect = inspect
178
+ test = ->{ old_inspect != inspect }
179
+ end
180
+
181
+ root.synchronize(wait_time) do
182
+ raise Reload unless test.()
183
+ end
184
+
185
+ self
186
+ rescue Reload
187
+ # raised on timeout
188
+
189
+ self
190
+ end
191
+
192
+ def to_s
193
+ node_text(root)
194
+ end
195
+
196
+ alias_method :w, :widget
197
+
198
+ protected
199
+
200
+ def node_text(node)
201
+ NodeText.new(node)
202
+ end
203
+
204
+ private
205
+
206
+ attr_writer :root
207
+
208
+ def page
209
+ Capybara.current_session
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,23 @@
1
+ module Dill
2
+ module WidgetContainer
3
+ def has_widget?(name)
4
+ widget_class(name).present_in?(root)
5
+ end
6
+
7
+ def widget(name, options = {})
8
+ widget_class(name).find_in(root, options)
9
+ end
10
+
11
+ private
12
+
13
+ attr_writer :widget_lookup_scope
14
+
15
+ def widget_class(name)
16
+ WidgetName.new(name).to_class(widget_lookup_scope)
17
+ end
18
+
19
+ def widget_lookup_scope
20
+ @widget_lookup_scope || self.class
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,56 @@
1
+ module Dill
2
+ # The name of a widget in a format-independent representation.
3
+ class WidgetName < String
4
+ CAMEL_CASE_FORMAT = /\A([A-Z][a-z]*)+\Z/
5
+ SNAKE_CASE_FORMAT = /\A\w+\Z/
6
+
7
+ # Constructs the widget name.
8
+ #
9
+ # @param name [String, Symbol] the name of the widget in primitive form
10
+ def initialize(name)
11
+ @name = name
12
+ @canonical = canonical(@name)
13
+ end
14
+
15
+ # Returns the class for this widget name, in the given scope.
16
+ def to_class(scope = Object)
17
+ const = scope.const_get(@canonical)
18
+
19
+ raise TypeError, "`#{@canonical}' is not a widget in this scope" \
20
+ unless const < Widget
21
+
22
+ const
23
+ rescue NameError
24
+ raise Missing, "couldn't find `#{@name}' widget in this scope"
25
+ end
26
+
27
+ def to_sym
28
+ @canonical.to_sym
29
+ end
30
+
31
+ private
32
+
33
+ def canonical(name)
34
+ str = name.to_s
35
+
36
+ case str
37
+ when SNAKE_CASE_FORMAT
38
+ camel_case_from_snake_case(str)
39
+ when CAMEL_CASE_FORMAT
40
+ str
41
+ else
42
+ raise ArgumentError, "can't convert `#{str.inspect}' to canonical form"
43
+ end
44
+ end
45
+
46
+ def camel_case_from_snake_case(name)
47
+ capitalize_word = ->(word) { word[0].upcase + word[1..-1] }
48
+ word_separator = '_'
49
+
50
+ name
51
+ .split(word_separator)
52
+ .map(&capitalize_word)
53
+ .join
54
+ end
55
+ end
56
+ end
metadata ADDED
@@ -0,0 +1,165 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dill
3
+ version: !ruby/object:Gem::Version
4
+ prerelease:
5
+ version: 0.4.0
6
+ platform: ruby
7
+ authors:
8
+ - David Leal
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-07-30 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ version_requirements: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ! '>='
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ none: false
21
+ name: cucumber
22
+ type: :runtime
23
+ prerelease: false
24
+ requirement: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ! '>='
27
+ - !ruby/object:Gem::Version
28
+ version: '0'
29
+ none: false
30
+ - !ruby/object:Gem::Dependency
31
+ version_requirements: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - ! '>='
34
+ - !ruby/object:Gem::Version
35
+ version: '0'
36
+ none: false
37
+ name: chronic
38
+ type: :runtime
39
+ prerelease: false
40
+ requirement: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ! '>='
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ none: false
46
+ - !ruby/object:Gem::Dependency
47
+ version_requirements: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ! '>='
50
+ - !ruby/object:Gem::Version
51
+ version: '2.0'
52
+ none: false
53
+ name: capybara
54
+ type: :runtime
55
+ prerelease: false
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ! '>='
59
+ - !ruby/object:Gem::Version
60
+ version: '2.0'
61
+ none: false
62
+ - !ruby/object:Gem::Dependency
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ~>
66
+ - !ruby/object:Gem::Version
67
+ version: 3.0.0
68
+ none: false
69
+ name: rspec-given
70
+ type: :development
71
+ prerelease: false
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ~>
75
+ - !ruby/object:Gem::Version
76
+ version: 3.0.0
77
+ none: false
78
+ - !ruby/object:Gem::Dependency
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ! '>='
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ none: false
85
+ name: sinatra
86
+ type: :development
87
+ prerelease: false
88
+ requirement: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ! '>='
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ none: false
94
+ - !ruby/object:Gem::Dependency
95
+ version_requirements: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ~>
98
+ - !ruby/object:Gem::Version
99
+ version: 1.0.0
100
+ none: false
101
+ name: capybara-webkit
102
+ type: :development
103
+ prerelease: false
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ~>
107
+ - !ruby/object:Gem::Version
108
+ version: 1.0.0
109
+ none: false
110
+ description: See https://github.com/mojotech/dill/README.md
111
+ email:
112
+ - dleal@mojotech.com
113
+ executables: []
114
+ extensions: []
115
+ extra_rdoc_files: []
116
+ files:
117
+ - lib/dill.rb
118
+ - lib/dill/auto_table.rb
119
+ - lib/dill/base_table.rb
120
+ - lib/dill/conversions.rb
121
+ - lib/dill/cucumber.rb
122
+ - lib/dill/document.rb
123
+ - lib/dill/dsl.rb
124
+ - lib/dill/field_group.rb
125
+ - lib/dill/form.rb
126
+ - lib/dill/instance_conversions.rb
127
+ - lib/dill/list.rb
128
+ - lib/dill/node_text.rb
129
+ - lib/dill/table.rb
130
+ - lib/dill/text_table.rb
131
+ - lib/dill/text_table/cell_text.rb
132
+ - lib/dill/text_table/mapping.rb
133
+ - lib/dill/text_table/transformations.rb
134
+ - lib/dill/text_table/void_mapping.rb
135
+ - lib/dill/version.rb
136
+ - lib/dill/widget.rb
137
+ - lib/dill/widget_container.rb
138
+ - lib/dill/widget_name.rb
139
+ homepage: https://github.com/mojotech/dill
140
+ licenses:
141
+ - MIT
142
+ post_install_message:
143
+ rdoc_options: []
144
+ require_paths:
145
+ - lib
146
+ required_ruby_version: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - ! '>='
149
+ - !ruby/object:Gem::Version
150
+ version: '0'
151
+ none: false
152
+ required_rubygems_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ! '>='
155
+ - !ruby/object:Gem::Version
156
+ version: '0'
157
+ none: false
158
+ requirements: []
159
+ rubyforge_project: ! '[none]'
160
+ rubygems_version: 1.8.23
161
+ signing_key:
162
+ specification_version: 3
163
+ summary: A set of helpers to ease integration testing
164
+ test_files: []
165
+ has_rdoc: