dill 0.4.0

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.
@@ -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: