ballonizer 0.1.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.
- checksums.yaml +7 -0
- data/Rakefile +63 -0
- data/lib/ballonizer.rb +401 -0
- data/spec/ballonizer_spec.rb +539 -0
- metadata +245 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 9b02cccd0f9fc456b32c0172a91aa790b3d06132
|
4
|
+
data.tar.gz: 0ca2e4a60db0d971dc0b1aa443e208efef0d7a5b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f6fdc80bedf5f03cca40990d7608acd67e402f6c4e21923610389c20a285a2639a50eedd8456e983584303bf405e5ce1cf7764cce9f35a9e87e6887dbb965027
|
7
|
+
data.tar.gz: d599207ecde8c8dd194a05c5b4582ec0ccda398def397d26618a9526cd1242a91e3197c18bbea8d0b279d3b640dd3f8050dc23c6c1e11cddea82a1d681396b5e
|
data/Rakefile
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'rspec/core/rake_task'
|
2
|
+
require 'jshintrb/jshinttask'
|
3
|
+
require 'jasmine-headless-webkit'
|
4
|
+
require 'sequel'
|
5
|
+
require 'ballonizer'
|
6
|
+
|
7
|
+
Jasmine::Headless::Task.new('jasmine') do |t|
|
8
|
+
t.colors = true
|
9
|
+
t.keep_on_error = true
|
10
|
+
t.jasmine_config = 'spec/javascripts/support/jasmine.yml'
|
11
|
+
end
|
12
|
+
|
13
|
+
RSpec::Core::RakeTask.new :spec
|
14
|
+
|
15
|
+
Jshintrb::JshintTask.new :jshint do |t|
|
16
|
+
t.pattern = '{lib/ballonizer/*.js,spec/javascripts/*.js}'
|
17
|
+
t.options = {
|
18
|
+
bitwise: true,
|
19
|
+
camelcase: true,
|
20
|
+
es3: true,
|
21
|
+
immed: true,
|
22
|
+
indent: 4,
|
23
|
+
latedef: true,
|
24
|
+
strict: true,
|
25
|
+
curly: true,
|
26
|
+
eqeqeq: true,
|
27
|
+
eqnull: true,
|
28
|
+
immed: true,
|
29
|
+
noarg: true,
|
30
|
+
trailing: true,
|
31
|
+
unused: true,
|
32
|
+
jquery: true,
|
33
|
+
white: true
|
34
|
+
}
|
35
|
+
end
|
36
|
+
|
37
|
+
namespace :db do
|
38
|
+
desc 'Create/Reset the tables used by ballonizer in a database, the default' +
|
39
|
+
'database is the example (examples/ballonizer_app/test.db), but you' +
|
40
|
+
'can specify one with "rake db:reset[\'sqlite:///absolute/path/to/' +
|
41
|
+
'database_name.db\']" (check sequel support to another databases).'
|
42
|
+
task :reset, :database_path do | t, args |
|
43
|
+
database_path = args[:database_path] || 'sqlite://examples/ballonizer_app/test.db'
|
44
|
+
Sequel.connect(database_path) do | db |
|
45
|
+
# The order is important, can throw Foreign Key Constraint Violation otherwise
|
46
|
+
tables = [:ballonized_image_ballons,
|
47
|
+
:ballonized_image_versions,
|
48
|
+
:images,
|
49
|
+
:ballons]
|
50
|
+
db.drop_table?(*tables)
|
51
|
+
Ballonizer.create_tables(db)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
desc 'Run an example of the lib use in http://localhost:9292'
|
57
|
+
task :example do
|
58
|
+
sh 'rackup examples/ballonizer_app/config.ru'
|
59
|
+
end
|
60
|
+
|
61
|
+
desc 'Run the ruby and the javascript module specs and the jshint'
|
62
|
+
task :default => [:spec, :jasmine, :jshint]
|
63
|
+
|
data/lib/ballonizer.rb
ADDED
@@ -0,0 +1,401 @@
|
|
1
|
+
require 'nokogiri'
|
2
|
+
require 'addressable/uri'
|
3
|
+
require 'sequel'
|
4
|
+
require 'json'
|
5
|
+
require 'htmlentities'
|
6
|
+
require 'rack'
|
7
|
+
|
8
|
+
# This gem provides mechanisms to allow ballons (or speech bubbles) to be
|
9
|
+
# added/removed/edited over images of a HTML or XHTML document and to be
|
10
|
+
# persisted. The edition of the ballons is possible by the javascript module
|
11
|
+
# provided by this gem. The persistence is allowed by the Ballonizer class.
|
12
|
+
# The Ballonizer class is basically a wrapper around the database used to
|
13
|
+
# persist the ballons, and offer methods to process the requests made by
|
14
|
+
# the client side (by a form created by the javascript module), and to modify
|
15
|
+
# a (X)HTML document adding the ballons of the image over it.
|
16
|
+
#
|
17
|
+
# This class lacks a lot of features like: access to an abstraction of the
|
18
|
+
# ballons, images and their relationship; control over users who edit the
|
19
|
+
# ballons; access to the old versions of the ballon set of a image (who
|
20
|
+
# are stored in the database, but only can be accessed directly by the
|
21
|
+
# Sequel::Database object). It's a work in progress, be warned to use
|
22
|
+
# carefully and motivated to contribute.
|
23
|
+
#
|
24
|
+
# The JavaScript library used to allow edition in the client side works
|
25
|
+
# as follows: double click over the image add a ballon, double click over
|
26
|
+
# a ballon allow edit the text, when the ballon lose the focus it returns
|
27
|
+
# to the non-edition state, a ballon without text (or only with spaces) it's
|
28
|
+
# automatically removed when lose focus, drag the ballon change its position
|
29
|
+
# (restricted to image space), drag ballon by the right-bottom handle
|
30
|
+
# resize the ballon (also restricted to image space). Any change in the ballons
|
31
|
+
# make visible a button fixed in the right-top corner of the browser viewport.
|
32
|
+
# Every time a ballons is changed (or added/removed) the json of a hidden
|
33
|
+
# form is updated. The button submits this json by POST request to the url
|
34
|
+
# configured by :form_handler_url setting.
|
35
|
+
#
|
36
|
+
# To the image be 'ballonized' it have to match the :img_to_ballonize_css_selector.
|
37
|
+
# The 'ballonized' term here means: have the ballons added over the image in
|
38
|
+
# ballonize_page.
|
39
|
+
#
|
40
|
+
# To use this class with your (rack isn't?) app you need to: create the
|
41
|
+
# necessary tables in a Sequel::Database object with Ballonizer.create_tables;
|
42
|
+
# create a ballonizer instance with the url where you gonna handle the ballon
|
43
|
+
# change requests. Handle the ballon changes request in that url with
|
44
|
+
# process_submit. Call instance.ballonize_page over the html documents who can
|
45
|
+
# have the images to be ballonized. Include the jquery, jquery-json, jquery-ui,
|
46
|
+
# and ballonizer javascript libraries in the page (provided by the gem); Include
|
47
|
+
# the ui-lightness and ballonizer css in the page (provided by the gem).
|
48
|
+
# Check if the image match the css selector :img_to_ballonize_css_selector.
|
49
|
+
#
|
50
|
+
# What's explained above is basically the example you can access with
|
51
|
+
# 'rake example' and is in the examples/ballonizer_app/config.ru file.
|
52
|
+
# You can reset the database with 'rake db:reset' (and if you pass an argument
|
53
|
+
# as 'rake db:reset[postgres://user:password@host:port/database_name]'
|
54
|
+
# you can create the tables in the database already used by your app).
|
55
|
+
# The tables names are: images, ballons, ballonized_image_versions,
|
56
|
+
# ballonized_image_ballons.
|
57
|
+
#
|
58
|
+
class Ballonizer
|
59
|
+
|
60
|
+
# The superclass of any error explicitly raised by the Ballonizer class.
|
61
|
+
class Error < ArgumentError; end
|
62
|
+
# The class used in exceptions related to a invalid value for a submit.
|
63
|
+
class SubmitError < Error; end
|
64
|
+
|
65
|
+
attr_accessor :db, :settings
|
66
|
+
|
67
|
+
# The default #settings
|
68
|
+
DEFAULT_SETTINGS = {
|
69
|
+
# The css selector used to define the elements to ballonize.
|
70
|
+
img_to_ballonize_css_selector: 'img.to_ballonize',
|
71
|
+
# A url to be used in the client-side action attribute of the form for
|
72
|
+
# ballon submition. The value will be used in the javascript snippet who
|
73
|
+
# initialize the ballonizer client javascript allowing ballon edition
|
74
|
+
# (and consequently creating the form).
|
75
|
+
form_handler_url: '#',
|
76
|
+
# Define if the javascript code who allow edition will be added to the page.
|
77
|
+
# (this don't refer to the jquery-* libs and the ballonizer.js only the
|
78
|
+
# snippet to execute when the page is ready)
|
79
|
+
add_js_for_edition: true
|
80
|
+
}.freeze.each { | _, v| v.freeze }
|
81
|
+
|
82
|
+
# Create a new Ballonizer object from a Sequel Database (with the expected
|
83
|
+
# tables, who can be created with Ballonizer.create_tables) and a optional
|
84
|
+
# hash of settings.
|
85
|
+
# @param db [Sequel::Database] A Sequel::Database with tables as described
|
86
|
+
# @param settings [Hash{Symbol => String}] A optional hash of settings. The
|
87
|
+
# default value and explanation of each option are documented in the
|
88
|
+
# DEFAULT_SETTINGS constant.
|
89
|
+
# @return [Ballonizer] A new ballonizer instance.
|
90
|
+
# @see Ballonizer.create_tables
|
91
|
+
def initialize(db, settings = {})
|
92
|
+
@db = db
|
93
|
+
@settings = DEFAULT_SETTINGS.merge(settings)
|
94
|
+
end
|
95
|
+
|
96
|
+
# Convenience method for process_submit_json, extract the json from the
|
97
|
+
# request, validate and pass to the method.
|
98
|
+
# @param env A env Rack hash.
|
99
|
+
# @return [Ballonizer] The self, to allow chaining.
|
100
|
+
# @raise [JSON::ParserError, Ballonizer::SubmitError]
|
101
|
+
# @see process_submit_json
|
102
|
+
def process_submit(env, time = nil)
|
103
|
+
request = Rack::Request.new(env)
|
104
|
+
submit_json = request['ballonizer_data']
|
105
|
+
valid_submit_json?(submit_json, true)
|
106
|
+
process_submit_json(submit_json, time)
|
107
|
+
end
|
108
|
+
|
109
|
+
# Verify if the json is a valid output from the client counterpart.
|
110
|
+
# If the argument is valid untaint, otherwise taint (unless it's frozen).
|
111
|
+
# If the second parameter argument is true the method will throw
|
112
|
+
# exceptions when the input is invalid.
|
113
|
+
# @param submit_json [String] A JSON String.
|
114
|
+
# @param throw_exceptions [FalseClass,TrueClass] Define behaviour when the
|
115
|
+
# input is invalid. If true throw exceptions, otherwise only return false.
|
116
|
+
# Default value: false (don't throw exceptions).
|
117
|
+
# @return [true, false]
|
118
|
+
# @raise [JSON::ParserError, Ballonizer::SubmitError]
|
119
|
+
# @see valid_submit_hash?
|
120
|
+
# @note This is a instance method because, in the future, the validation
|
121
|
+
# can depend of instance settings.
|
122
|
+
def valid_submit_json?(submit_json, throw_exceptions=false)
|
123
|
+
parsed_submit = JSON.parse(submit_json)
|
124
|
+
valid_submit_hash?(parsed_submit, true)
|
125
|
+
submit_json.untaint unless submit_json.frozen?
|
126
|
+
true
|
127
|
+
rescue JSON::ParserError, SubmitError => e
|
128
|
+
submit_json.taint unless submit_json.frozen?
|
129
|
+
raise e if throw_exceptions
|
130
|
+
false
|
131
|
+
end
|
132
|
+
|
133
|
+
# Act as #valid_submit_json, but over a already parsed json and don't
|
134
|
+
# (un)taint the hash.
|
135
|
+
# @param submit_hash [Hash] A parsed JSON.
|
136
|
+
# @return [true, false]
|
137
|
+
# @raise [Ballonizer::SubmitError]
|
138
|
+
# @see valid_submit_json?
|
139
|
+
# @note This is a instance method because, in the future, the validation
|
140
|
+
# can depend of instance settings.
|
141
|
+
def valid_submit_hash?(submit_hash, throw_exceptions=false)
|
142
|
+
if submit_hash.empty?
|
143
|
+
fail SubmitError, "the submit request is empty"
|
144
|
+
end
|
145
|
+
|
146
|
+
submit_hash.each do | img_src, ballons |
|
147
|
+
unless img_src.is_a?(String)
|
148
|
+
# TODO: validate if valid URI?
|
149
|
+
# TODO: define img_src max lenght?
|
150
|
+
fail SubmitError, "the image src is a '#{img_src.class}' and not a String"
|
151
|
+
end
|
152
|
+
unless Addressable::URI.parse(img_src).absolute?
|
153
|
+
fail SubmitError, "the image src ('#{img_src.class}') is not an absolute URI"
|
154
|
+
end
|
155
|
+
unless ballons.is_a?(Array)
|
156
|
+
fail SubmitError, "the image with src '#{img_src}' is key of a " +
|
157
|
+
"'#{ballons.class}' and not a Array"
|
158
|
+
end
|
159
|
+
|
160
|
+
ballons.each do | ballon |
|
161
|
+
unless ballon["text"].is_a?(String)
|
162
|
+
fail SubmitError, "the ballon text is a '#{ballon.class}' and not" +
|
163
|
+
" a String"
|
164
|
+
end
|
165
|
+
if ballon["text"].empty?
|
166
|
+
fail SubmitError, "the ballon text is empty"
|
167
|
+
end
|
168
|
+
[:top, :left, :width, :height].each do | bound_name |
|
169
|
+
bound = ballon[bound_name.to_s]
|
170
|
+
unless bound.is_a?(Fixnum) || bound.is_a?(Float)
|
171
|
+
fail SubmitError, "the #{bound_name.to_s} (#{bound.to_s}) isn't" +
|
172
|
+
" a Fixnum or Float (is a '#{bound.class.to_s}')"
|
173
|
+
end
|
174
|
+
unless bound >= 0 && bound <= 1
|
175
|
+
fail SubmitError, "the #{bound_name.to_s} (#{bound.to_s}) isn't"
|
176
|
+
" between 0 and 1 (both inclusive)"
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
ballon_end = {}
|
181
|
+
ballon_end[:x] = ballon["top"] + ballon["height"]
|
182
|
+
ballon_end[:y] = ballon["left"] + ballon["width"]
|
183
|
+
|
184
|
+
[:x, :y].each do | axis |
|
185
|
+
if ballon_end[axis] > 1
|
186
|
+
fail SubmitError, "the ballon with text #{ballon["text"]} is trespassing" +
|
187
|
+
" the #{ {x: "right side", y: "bottom"} [axis] }" +
|
188
|
+
" of the image"
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
# if pass everything above return true
|
195
|
+
true
|
196
|
+
rescue SubmitError => exception
|
197
|
+
# HACK: "don't use exceptions for flow control", but this is the most DRY
|
198
|
+
# way...
|
199
|
+
if throw_exceptions then raise exception else false end
|
200
|
+
end
|
201
|
+
|
202
|
+
# Receive a untainted json (assume as validated by #valid_submit_json?)
|
203
|
+
# and add it to the database.
|
204
|
+
# @param submit_json [String] A untainted JSON string. Validated with #valid_submit_json?.
|
205
|
+
# @param time [Time] A Time instance to be used in place of Time.now. Optional.
|
206
|
+
# @return [Ballonizer] The self, to allow chaining.
|
207
|
+
# @raise [SecurityError] If the input is tainted.
|
208
|
+
def process_submit_json(submit_json, time = nil)
|
209
|
+
fail SecurityError, 'the input is tainted' if submit_json.tainted?
|
210
|
+
process_submit_hash(JSON.parse(submit_json), time)
|
211
|
+
end
|
212
|
+
|
213
|
+
# Behave as process_submit_json except that takes a already parsed json (hash)
|
214
|
+
# and don't check if it's tainted.
|
215
|
+
# @param submit_hash [Hash] A JSON hash. Validate with #valid_submit_json?.
|
216
|
+
# @param time [Time] A Time instance to be used in place of Time.now. Optional.
|
217
|
+
# @return [Ballonizer] The self, to allow chaining.
|
218
|
+
def process_submit_hash(submit_hash, time = nil)
|
219
|
+
time = Time.now unless time
|
220
|
+
self.db.transaction do
|
221
|
+
images = self.db[:images]
|
222
|
+
db_ballons = self.db[:ballons]
|
223
|
+
ballonized_image_versions = self.db[:ballonized_image_versions]
|
224
|
+
ballonized_image_ballons = self.db[:ballonized_image_ballons]
|
225
|
+
|
226
|
+
submit_hash.each do | img_src, ballons |
|
227
|
+
img_src = Addressable::URI.parse(img_src).normalize.to_s
|
228
|
+
db_image = images.first({img_src: img_src})
|
229
|
+
image_id, version = nil, nil
|
230
|
+
|
231
|
+
if db_image
|
232
|
+
image_id = db_image[:id]
|
233
|
+
version = ballonized_image_versions.where({image_id: image_id})
|
234
|
+
.max(:version) + 1
|
235
|
+
else
|
236
|
+
image_id = images.insert({img_src: img_src})
|
237
|
+
version = 1
|
238
|
+
end
|
239
|
+
|
240
|
+
ballonized_image_versions.insert({
|
241
|
+
image_id: image_id,
|
242
|
+
version: version,
|
243
|
+
time: time
|
244
|
+
})
|
245
|
+
ballons.each do | ballon |
|
246
|
+
db_ballon = db_ballons.first(ballon)
|
247
|
+
ballon_id = db_ballon ? db_ballon[:id] : db_ballons.insert(ballon)
|
248
|
+
ballonized_image_ballons.insert({
|
249
|
+
image_id: image_id,
|
250
|
+
version: version,
|
251
|
+
ballon_id: ballon_id,
|
252
|
+
})
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
# Wrap each image to ballonize with a container, add its ballons to the
|
259
|
+
# container and add the js snippet for the edition initialization.
|
260
|
+
# @param page [String] The (X)HTML page.
|
261
|
+
# @param page_url [String] The url of the page to be ballonized, necessary
|
262
|
+
# to make absolute the src attribute of img (if it's relative).
|
263
|
+
# @param settings [Hash{Symbol => String}] Optional. If not provided the
|
264
|
+
# #settings will be used.
|
265
|
+
# @return [String] The page ballonized (new string).
|
266
|
+
def ballonize_page(page, page_url, settings = {})
|
267
|
+
settings = self.settings.merge(settings)
|
268
|
+
|
269
|
+
parsed_page = Workaround.parse_html_or_xhtml(page)
|
270
|
+
selector = settings[:img_to_ballonize_css_selector]
|
271
|
+
imgs = parsed_page.css(selector)
|
272
|
+
|
273
|
+
unless imgs.empty?
|
274
|
+
imgs.wrap('<div class="ballonizer_image_container" ></div>')
|
275
|
+
|
276
|
+
imgs.each do | img |
|
277
|
+
img_src = img['src']
|
278
|
+
absolute_normal_src = Addressable::URI.parse(page_url)
|
279
|
+
.join(img_src)
|
280
|
+
.normalize.to_s
|
281
|
+
ballons = last_ballon_set_of_image(absolute_normal_src)
|
282
|
+
ballons.each do | ballon |
|
283
|
+
img.add_previous_sibling(self.class.create_ballon_node(ballon))
|
284
|
+
end
|
285
|
+
end
|
286
|
+
|
287
|
+
if settings[:add_js_for_edition]
|
288
|
+
parsed_page.at_css('head').children.last
|
289
|
+
.add_next_sibling(self.js_load_snippet)
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
parsed_page.to_s
|
294
|
+
end
|
295
|
+
|
296
|
+
# @api private Don't use this method. It is for internal use only.
|
297
|
+
def self.create_ballon_node(ballon_data)
|
298
|
+
text = HTMLEntities.new.encode(ballon_data[:text])
|
299
|
+
|
300
|
+
style = ''
|
301
|
+
[:top, :left, :width, :height].each do | sym |
|
302
|
+
# transform ratio [0,1] to percent [0, 100]
|
303
|
+
style = style + "#{sym}: #{(ballon_data[sym] * 100)}%;"
|
304
|
+
end
|
305
|
+
|
306
|
+
"<p class='ballonizer_ballon' style='#{style}'>#{text}</p>"
|
307
|
+
end
|
308
|
+
|
309
|
+
# @api private
|
310
|
+
# Don't use this method. It is for internal use only.
|
311
|
+
# @note This method don't make distinction between a image in the database
|
312
|
+
# without any ballons (removed in the last version, by example) or a image
|
313
|
+
# who isn't in the database (both return a empty array).
|
314
|
+
def last_ballon_set_of_image(img_src)
|
315
|
+
db_image = self.db[:images].first({img_src: img_src})
|
316
|
+
if db_image
|
317
|
+
image_id = db_image[:id]
|
318
|
+
version = self.db[:ballonized_image_versions].where({image_id: image_id})
|
319
|
+
.max(:version)
|
320
|
+
self.db[:ballonized_image_ballons]
|
321
|
+
.join(:ballons, { ballonized_image_ballons__version: version,
|
322
|
+
ballonized_image_ballons__image_id: image_id,
|
323
|
+
ballonized_image_ballons__ballon_id: :ballons__id
|
324
|
+
}).select(:text, :top, :left, :width, :height).all
|
325
|
+
else
|
326
|
+
[]
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
# Return a String with the snippet added to the pages to allow edition in them.
|
331
|
+
# @return [String] The added snippet. Already with the <script/> tag around it.
|
332
|
+
def js_load_snippet
|
333
|
+
<<-end
|
334
|
+
<script type="text/javascript">
|
335
|
+
$(document).ready(function() {
|
336
|
+
Ballonizer('#{self.settings[:form_handler_url]}',
|
337
|
+
'.ballonizer_image_container',
|
338
|
+
$('body'));
|
339
|
+
})
|
340
|
+
</script>
|
341
|
+
end
|
342
|
+
end
|
343
|
+
|
344
|
+
# Executes the create_table operations over the Sequel::Database argument.
|
345
|
+
# @param db [Sequel::Database] The database where create the tables.
|
346
|
+
# @return [void]
|
347
|
+
def self.create_tables(db)
|
348
|
+
db.create_table(:images) do
|
349
|
+
primary_key :id
|
350
|
+
String :img_src, :size => 255, :unique => true, :allow_null => false
|
351
|
+
end
|
352
|
+
db.create_table(:ballons) do
|
353
|
+
primary_key :id
|
354
|
+
|
355
|
+
String :text, :size => 255, :allow_null => false
|
356
|
+
Float :top, :allow_null => false
|
357
|
+
Float :left, :allow_null => false
|
358
|
+
Float :width, :allow_null => false
|
359
|
+
Float :height, :allow_null => false
|
360
|
+
end
|
361
|
+
db.create_table(:ballonized_image_versions) do
|
362
|
+
Integer :version
|
363
|
+
foreign_key :image_id, :images
|
364
|
+
DateTime :time, :allow_null => false
|
365
|
+
primary_key [:version, :image_id]
|
366
|
+
end
|
367
|
+
db.create_table(:ballonized_image_ballons) do
|
368
|
+
Integer :version
|
369
|
+
foreign_key :image_id, :images
|
370
|
+
foreign_key :ballon_id, :ballons
|
371
|
+
foreign_key [:version, :image_id], :ballonized_image_versions
|
372
|
+
end
|
373
|
+
end
|
374
|
+
|
375
|
+
# @api private Don't use the methods of this module. They are for internal use only.
|
376
|
+
module Workaround
|
377
|
+
def self.parse_html_or_xhtml(doc)
|
378
|
+
# If you parse XHTML as HTML with Nokogiri and use to_s after the markup can be messed up
|
379
|
+
#
|
380
|
+
# Example: <meta name="description" content="not important" />
|
381
|
+
# becomes <meta name="description" content="not important" >
|
382
|
+
# To avoid this we parse a document who is XML valid as XML, and, otherwise as HTML
|
383
|
+
parsed_doc = nil
|
384
|
+
begin
|
385
|
+
# this also isn't a great way to do this
|
386
|
+
# the Nokogiri don't have exception classes, this way any StandardError will be silenced
|
387
|
+
options = Nokogiri::XML::ParseOptions::DEFAULT_XML &
|
388
|
+
Nokogiri::XML::ParseOptions::STRICT &
|
389
|
+
Nokogiri::XML::ParseOptions::NONET
|
390
|
+
parsed_doc = Nokogiri::XML::Document.parse(doc, nil, nil, options)
|
391
|
+
rescue
|
392
|
+
parsed_doc = Nokogiri::HTML(doc)
|
393
|
+
end
|
394
|
+
|
395
|
+
parsed_doc
|
396
|
+
end
|
397
|
+
end
|
398
|
+
|
399
|
+
private_constant :Workaround
|
400
|
+
end
|
401
|
+
|
@@ -0,0 +1,539 @@
|
|
1
|
+
require 'ballonizer'
|
2
|
+
require 'rspec-html-matchers'
|
3
|
+
# Avoid to use equivalent-xml, the specs break with cosmetic changes this way
|
4
|
+
require 'equivalent-xml'
|
5
|
+
require 'stringio'
|
6
|
+
|
7
|
+
# make the changes in the BD restricted to the example
|
8
|
+
RSpec.configure do |c|
|
9
|
+
c.around(:each) do |example|
|
10
|
+
DB.transaction(:rollback=>:always){example.run}
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
describe Ballonizer do
|
15
|
+
|
16
|
+
DB = Sequel.sqlite
|
17
|
+
|
18
|
+
def self.populate_ballonizer_tables_with_test_data(db, time)
|
19
|
+
db[:images].insert({img_src: 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/1.jpg'})
|
20
|
+
db[:images].insert({img_src: 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/2.jpg'})
|
21
|
+
db[:images].insert({img_src: 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/3.jpg'})
|
22
|
+
|
23
|
+
db[:ballons].insert({ text: 'first ballon of first image',
|
24
|
+
top: 0, left: 0, width: 0.5, height: 0.5 })
|
25
|
+
db[:ballons].insert({ text: 'second ballon of first image',
|
26
|
+
top: 0.5, left: 0.5, width: 0.5, height: 0.5 })
|
27
|
+
db[:ballons].insert({ text: 'first ballon of second image',
|
28
|
+
top: 0, left: 0, width: 0.5, height: 0.5 })
|
29
|
+
db[:ballons].insert({ text: 'second ballon of second image',
|
30
|
+
top: 0.5, left: 0.5, width: 0.5, height: 0.5 })
|
31
|
+
db[:ballons].insert({ text: 'first ballon of third image',
|
32
|
+
top: 0, left: 0, width: 0.5, height: 0.5 })
|
33
|
+
db[:ballons].insert({ text: 'second ballon of third image',
|
34
|
+
top: 0.5, left: 0.5, width: 0.5, height: 0.5 })
|
35
|
+
|
36
|
+
# both ballons added in the first version
|
37
|
+
db[:ballonized_image_versions].insert({image_id: 1, version: 1, time: time})
|
38
|
+
db[:ballonized_image_ballons].insert({image_id: 1, version: 1, ballon_id: 1})
|
39
|
+
db[:ballonized_image_ballons].insert({image_id: 1, version: 1, ballon_id: 2})
|
40
|
+
|
41
|
+
# second ballon added in the second version
|
42
|
+
db[:ballonized_image_versions].insert({image_id: 2, version: 1, time: time})
|
43
|
+
db[:ballonized_image_ballons].insert({image_id: 2, version: 1, ballon_id: 3})
|
44
|
+
db[:ballonized_image_versions].insert({image_id: 2, version: 2, time: time})
|
45
|
+
db[:ballonized_image_ballons].insert({image_id: 2, version: 2, ballon_id: 3})
|
46
|
+
db[:ballonized_image_ballons].insert({image_id: 2, version: 2, ballon_id: 4})
|
47
|
+
|
48
|
+
# both added in first version, but second removed in the second version
|
49
|
+
db[:ballonized_image_versions].insert({image_id: 3, version: 1, time: time})
|
50
|
+
db[:ballonized_image_ballons].insert({image_id: 3, version: 1, ballon_id: 5})
|
51
|
+
db[:ballonized_image_ballons].insert({image_id: 3, version: 1, ballon_id: 6})
|
52
|
+
db[:ballonized_image_versions].insert({image_id: 3, version: 2, time: time})
|
53
|
+
db[:ballonized_image_ballons].insert({image_id: 3, version: 2, ballon_id: 5})
|
54
|
+
end
|
55
|
+
|
56
|
+
Ballonizer.create_tables(DB)
|
57
|
+
self.populate_ballonizer_tables_with_test_data(DB, Time.at(0))
|
58
|
+
|
59
|
+
def deep_copy(v)
|
60
|
+
JSON.parse(JSON.generate(v))
|
61
|
+
end
|
62
|
+
|
63
|
+
# Definitions ending with '_example' are to be cloned and defined in a
|
64
|
+
# context without the sufix. Definitions without the sufix are used in the
|
65
|
+
# specs and may require the definition of some without '_example' counterparts.
|
66
|
+
let (:ballonizer_new_args_example) do
|
67
|
+
[DB, {}]
|
68
|
+
end
|
69
|
+
# TODO: This isn't a valid Rack env, to turn it in a valid env will be
|
70
|
+
# necessary to add all obrigatory 'rack.' variables described in:
|
71
|
+
# http://rack.rubyforge.org/doc/SPEC.html
|
72
|
+
# (the missing are the hijack related and the .errors)
|
73
|
+
let (:env_example) do
|
74
|
+
form_data = StringIO.new(Addressable::URI.form_encode({
|
75
|
+
ballonizer_data: JSON.generate(submit_hash)
|
76
|
+
}))
|
77
|
+
{ 'HTTP_HOST' => 'proxysite.net',
|
78
|
+
'SCRIPT_NAME' => '',
|
79
|
+
'PATH_INFO' => '/pt_BR-ballon_translate/comic/',
|
80
|
+
'QUERY_STRING' => '',
|
81
|
+
'SERVER_NAME' => 'proxysite.net',
|
82
|
+
'SERVER_PORT' => '80',
|
83
|
+
'REQUEST_METHOD' => 'POST',
|
84
|
+
'HTTP_VERSION' => 'HTTP/1.1',
|
85
|
+
'CONTENT_LENGTH' => form_data.size.to_s,
|
86
|
+
'CONTENT_TYPE' => 'application/x-www-form-urlencoded',
|
87
|
+
'rack.url_scheme' => 'http',
|
88
|
+
'rack.input' => form_data,
|
89
|
+
'rack.version' => [1, 0],
|
90
|
+
'rack.multithread' => false,
|
91
|
+
'rack.multiprocess' => false,
|
92
|
+
'rack.run_once' => false
|
93
|
+
}
|
94
|
+
end
|
95
|
+
let (:original_page_example) do
|
96
|
+
doc = <<-END
|
97
|
+
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
|
98
|
+
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
99
|
+
<html xmlns="http://www.w3.org/1999/xhtml">
|
100
|
+
<head>
|
101
|
+
<title>A title</title>
|
102
|
+
<meta http-equiv="content-type" content="application/xhtml+xml; charset=UTF-8" />
|
103
|
+
</head>
|
104
|
+
<body>
|
105
|
+
</body>
|
106
|
+
</html>
|
107
|
+
END
|
108
|
+
|
109
|
+
Nokogiri::XML::Document.parse(doc)
|
110
|
+
end
|
111
|
+
let (:page_url_example) do
|
112
|
+
'http://comic-translation.com/tr/pt-BR/a_comic/'
|
113
|
+
end
|
114
|
+
let (:settings_example) do
|
115
|
+
{}
|
116
|
+
end
|
117
|
+
let (:submit_json_example) do
|
118
|
+
'{"http://imgs.xkcd.com/comics/cells.png":[{"left":0,"top":0,"width":1,"height":0.23837209302325582,"text":"When you see a claim that a common drug or vitamin \"kills cancer cells in a petri dish\", keep in mind:"},{"left":0.0963302752293578,"top":0.9273255813953488,"width":0.7798165137614679,"height":0.055232558139534885,"text":"So does a handgun."}]}'
|
119
|
+
end
|
120
|
+
let (:submit_hash_example) do
|
121
|
+
JSON.parse(submit_json_example)
|
122
|
+
end
|
123
|
+
|
124
|
+
# definitions to be overriden in specific contexts (by doing a clone of the
|
125
|
+
# '_example' counterpart and changing what is needed)
|
126
|
+
let (:ballonizer_new_args) { ballonizer_new_args_example }
|
127
|
+
let (:env) { env_example }
|
128
|
+
let (:original_page) { original_page_example.to_s }
|
129
|
+
let (:page_url) { page_url_example }
|
130
|
+
let (:settings) { settings_example }
|
131
|
+
let (:submit_json) { submit_json_example }
|
132
|
+
let (:submit_hash) { submit_hash_example }
|
133
|
+
|
134
|
+
# Definition who need others (no *_example)
|
135
|
+
let (:instance) { described_class.new(*ballonizer_new_args) }
|
136
|
+
let (:ballonized_page) do
|
137
|
+
instance.ballonize_page(original_page, page_url, settings)
|
138
|
+
end
|
139
|
+
|
140
|
+
# TODO: verify if the style property has the correct values
|
141
|
+
describe '#ballonize_page' do
|
142
|
+
subject { ballonized_page }
|
143
|
+
|
144
|
+
context 'when the page has no img elements to ballonize' do
|
145
|
+
it "don't make changes in the page" do
|
146
|
+
should be_equivalent_to(original_page)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
context 'when the page has img elements to ballonize' do
|
150
|
+
context 'and one of them have .to_ballonize class' do
|
151
|
+
let (:original_page) do
|
152
|
+
page_with_images = original_page_example.clone
|
153
|
+
img1 = "<img alt='A test image' class='other_class to_ballonize' src='imgs/1.jpg' />"
|
154
|
+
img2 = "<img alt='A second test image' class='yet_another_class' src='imgs/2.jpg' />"
|
155
|
+
page_with_images.at_css('body') << img1 << img2
|
156
|
+
page_with_images.to_s
|
157
|
+
end
|
158
|
+
it 'add a container around the img' do
|
159
|
+
should have_tag('div', :with => { class: 'ballonizer_image_container' }) do
|
160
|
+
with_tag('img', :with => { alt: 'A test image' })
|
161
|
+
end
|
162
|
+
end
|
163
|
+
context 'and it have ballons in the database' do
|
164
|
+
it 'add the ballons inside the container' do
|
165
|
+
# the parentheses of the 'should' are necessary, otherwise the
|
166
|
+
# conditions inside the block are silently not tested
|
167
|
+
should(have_tag('div', :with => { class: 'ballonizer_image_container' }) do
|
168
|
+
with_tag("img[alt='A test image']")
|
169
|
+
with_tag('p', { text: 'first ballon of first image',
|
170
|
+
with: { class: 'ballonizer_ballon' }})
|
171
|
+
with_tag('p', { text: 'second ballon of first image',
|
172
|
+
with: { class: 'ballonizer_ballon'}})
|
173
|
+
end)
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
context 'and more than one of them is to be ballonized' do
|
178
|
+
let (:original_page) do
|
179
|
+
page_with_images = original_page_example.clone
|
180
|
+
# the first image in the db is used in the other test, don't need to
|
181
|
+
# be reused
|
182
|
+
img2 = "<img alt='the second test image' class='to_ballonize' src='imgs/2.jpg' />"
|
183
|
+
img3 = "<img alt='the third test image' class='yet_another_class to_ballonize' src='imgs/3.jpg' />"
|
184
|
+
img4 = "<img alt='the fourth test image' class='an unimportant class' src='imgs/4.jpg' />"
|
185
|
+
page_with_images.at_css('body') << img2 << img3 << img4
|
186
|
+
page_with_images.to_s
|
187
|
+
end
|
188
|
+
it 'add a container around the imgs' do
|
189
|
+
# TODO: DRY this test
|
190
|
+
should(have_tag('div', :with => { class: 'ballonizer_image_container' }) do
|
191
|
+
with_tag('img', :with => { alt: 'the second test image' })
|
192
|
+
end)
|
193
|
+
should(have_tag('div', :with => { class: 'ballonizer_image_container' }) do
|
194
|
+
with_tag('img', :with => { alt: 'the third test image' })
|
195
|
+
end)
|
196
|
+
end
|
197
|
+
context 'and they have ballons in the database' do
|
198
|
+
it 'add the ballons inside the containers' do
|
199
|
+
# TODO: break this spec in smaller parts, this specificate more
|
200
|
+
# than one thing
|
201
|
+
should(have_tag('div', :with => { class: 'ballonizer_image_container' }) do
|
202
|
+
# the second image have two versions, the second ballon is added
|
203
|
+
# in the second version, so here we verify if the ballonize_page
|
204
|
+
# recover the ballons of the last version
|
205
|
+
with_tag('img', :with => { alt: 'the second test image' })
|
206
|
+
with_tag('p', { text: 'first ballon of second image',
|
207
|
+
with: { class: 'ballonizer_ballon' }})
|
208
|
+
with_tag('p', { text: 'second ballon of second image',
|
209
|
+
with: { class: 'ballonizer_ballon' }})
|
210
|
+
end)
|
211
|
+
should(have_tag('div', :with => { class: 'ballonizer_image_container' }) do
|
212
|
+
# the third image have two versions, the second ballon is removed
|
213
|
+
# in the second version, so here we verify if the ballonize_page
|
214
|
+
# do not use a ballon of an old version in the image
|
215
|
+
with_tag('img', :with => { alt: 'the third test image' })
|
216
|
+
with_tag('p', { text: 'first ballon of third image',
|
217
|
+
with: { class: 'ballonizer_ballon' }})
|
218
|
+
without_tag('p', { text: 'second ballon of third image',
|
219
|
+
with: { class: 'ballonizer_ballon' }})
|
220
|
+
end)
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
224
|
+
context 'and no one of them have the .to_ballonize class' do
|
225
|
+
let (:original_page) do
|
226
|
+
page_with_images = original_page_example.clone
|
227
|
+
page_with_images.at_css('body')
|
228
|
+
.add_child "<img alt='A test image' src='/a/path/to/the/image.jpg' >"
|
229
|
+
page_with_images.to_s
|
230
|
+
end
|
231
|
+
it "don't add a container around the imgs" do
|
232
|
+
should_not have_tag('div', :with => {
|
233
|
+
class: 'ballonizer_image_container'
|
234
|
+
})
|
235
|
+
end
|
236
|
+
it "don't add the links of the required javascript and css" do
|
237
|
+
# the callback will be called only if a image was ballonized?
|
238
|
+
should_not have_tag('link')
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
describe '#valid_submit_json?' do
|
245
|
+
subject { instance.valid_submit_json? submit_json }
|
246
|
+
|
247
|
+
shared_examples 'common behavior for invalid input' do
|
248
|
+
|
249
|
+
it { should be_false }
|
250
|
+
|
251
|
+
it 'taint the input' do
|
252
|
+
instance.valid_submit_json? submit_json
|
253
|
+
expect(submit_json.tainted?).to be_true
|
254
|
+
end
|
255
|
+
|
256
|
+
context 'but frozen' do
|
257
|
+
let (:frozen_submit_json) { deep_copy(submit_json).freeze }
|
258
|
+
let (:method_call) do
|
259
|
+
lambda { instance.valid_submit_json? submit_json }
|
260
|
+
end
|
261
|
+
|
262
|
+
it "shouldn't taint the input" do
|
263
|
+
# an attempt to taint a frozen object will raise a RuntimeError
|
264
|
+
expect(method_call).to_not raise_error
|
265
|
+
end
|
266
|
+
end
|
267
|
+
context 'when the second argument is true' do
|
268
|
+
let (:method_call) do
|
269
|
+
lambda { instance.valid_submit_json?(submit_json, true) }
|
270
|
+
end
|
271
|
+
|
272
|
+
# necessary define the 'exception_type' let variable in the context
|
273
|
+
# who will use this shared_examples
|
274
|
+
it { expect(method_call).to raise_error(exception_type) }
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
# NOTE: All cases who throw a Ballonizer::SubmitError are verified
|
279
|
+
# in the #valid_submit_hash? (who is used by the #valid_submit_json?)
|
280
|
+
context 'with invalid input' do
|
281
|
+
context '(a malformed json)' do
|
282
|
+
let (:submit_json) { 'not a valid json' }
|
283
|
+
let (:exception_type) { JSON::ParserError }
|
284
|
+
|
285
|
+
include_examples 'common behavior for invalid input'
|
286
|
+
end
|
287
|
+
context '(invalid submit data)' do
|
288
|
+
let (:submit_json) { '{}' }
|
289
|
+
let (:exception_type) { described_class::SubmitError }
|
290
|
+
|
291
|
+
include_examples 'common behavior for invalid input'
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
context 'with valid input' do
|
296
|
+
it { should be_true }
|
297
|
+
|
298
|
+
it 'untaint the input' do
|
299
|
+
instance.valid_submit_json? submit_json.taint
|
300
|
+
expect(submit_json.tainted?).to be_false
|
301
|
+
end
|
302
|
+
|
303
|
+
context 'but frozen' do
|
304
|
+
let (:frozen_tainted_submit_json) do
|
305
|
+
deep_copy(submit_json).taint.freeze
|
306
|
+
end
|
307
|
+
let (:method_call) do
|
308
|
+
lambda { instance.valid_submit_json? submit_json }
|
309
|
+
end
|
310
|
+
|
311
|
+
it "shouldn't untaint the input" do
|
312
|
+
# an attempt to taint a frozen object will raise a RuntimeError
|
313
|
+
expect(method_call).to_not raise_error
|
314
|
+
end
|
315
|
+
end
|
316
|
+
context 'when the second argument is true' do
|
317
|
+
let (:method_call) do
|
318
|
+
lambda { instance.valid_submit_json?(submit_json, true) }
|
319
|
+
end
|
320
|
+
|
321
|
+
it { expect(method_call).to_not raise_error }
|
322
|
+
end
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
# TODO: validate ballon text and url size < 255; if the url is a url;
|
327
|
+
# extra fields in the hash?
|
328
|
+
describe '#valid_submit_hash?' do
|
329
|
+
subject { instance.valid_submit_hash? submit_hash }
|
330
|
+
|
331
|
+
# To be used in all contexts where the input is invalid
|
332
|
+
shared_examples 'and the second argument is true' do
|
333
|
+
# If this context isn't added the message don't appear in the output
|
334
|
+
# of the 'rspec -fd' and the lets and subjects are merged (what can
|
335
|
+
# create some nasty bugs)
|
336
|
+
context 'and the second argument is true' do
|
337
|
+
let (:method_call) do
|
338
|
+
lambda { instance.valid_submit_hash?(submit_hash, true) }
|
339
|
+
end
|
340
|
+
|
341
|
+
it { expect(method_call).to raise_error(described_class::SubmitError) }
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
context "when the bounds aren't numbers between 0 and 1" do
|
346
|
+
let (:submit_hash) do
|
347
|
+
deep_copy(submit_hash_example)[submit_hash_example.keys.first]
|
348
|
+
.first.update({ top: 10 })
|
349
|
+
end
|
350
|
+
|
351
|
+
it { should be_false }
|
352
|
+
|
353
|
+
include_examples 'and the second argument is true'
|
354
|
+
end
|
355
|
+
|
356
|
+
context "when the text isn't a non-empty String" do
|
357
|
+
let (:submit_hash) do
|
358
|
+
deep_copy(submit_hash_example)[submit_hash_example.keys.first]
|
359
|
+
.first.update({ text: "" })
|
360
|
+
end
|
361
|
+
|
362
|
+
it { should be_false }
|
363
|
+
|
364
|
+
include_examples 'and the second argument is true'
|
365
|
+
end
|
366
|
+
|
367
|
+
[:x, :y].each do | axis |
|
368
|
+
|
369
|
+
position, size = { x: ["left", "top"], y: ["top", "height"] }[axis]
|
370
|
+
|
371
|
+
context "when the #{position} plus #{size} is greater than one" do
|
372
|
+
let (:submit_hash) do
|
373
|
+
h = deep_copy(submit_hash_example)
|
374
|
+
first_ballon = h[h.keys.first].first
|
375
|
+
first_ballon[position] = 0.75
|
376
|
+
first_ballon[size] = 0.5
|
377
|
+
h
|
378
|
+
end
|
379
|
+
|
380
|
+
it { should be_false }
|
381
|
+
|
382
|
+
include_examples 'and the second argument is true'
|
383
|
+
end
|
384
|
+
end
|
385
|
+
|
386
|
+
context 'when the submit contain a image without ballons' do
|
387
|
+
let (:submit_hash) do
|
388
|
+
{ submit_hash_example.keys.first => [] }
|
389
|
+
end
|
390
|
+
|
391
|
+
it { should be_true }
|
392
|
+
end
|
393
|
+
context 'when the hash is valid' do
|
394
|
+
it { should be_true }
|
395
|
+
|
396
|
+
context 'and the second argument is true' do
|
397
|
+
let (:method_call) do
|
398
|
+
lambda { instance.valid_submit_hash?(submit_hash, true) }
|
399
|
+
end
|
400
|
+
|
401
|
+
it { expect(method_call).to_not raise_error(described_class::SubmitError) }
|
402
|
+
it { expect(method_call.call).to be_true }
|
403
|
+
end
|
404
|
+
end
|
405
|
+
end
|
406
|
+
|
407
|
+
describe '#process_submit_hash' do
|
408
|
+
# TODO: Add context "when the submit refer to two or more images?"
|
409
|
+
context 'when its the first submit of a image' do
|
410
|
+
let(:submit_hash) do
|
411
|
+
# the keys are String because hashs parsed from JSON are this way
|
412
|
+
{ 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/4.jpg' => [
|
413
|
+
{ 'text' => 'the first ballon of the fourth image',
|
414
|
+
'left' => 0, 'top' => 0, 'width' => 0.5, 'height' => 0.5 },
|
415
|
+
{ 'text' => 'the second ballon of the fourth image',
|
416
|
+
'left' => 0.5, 'top' => 0.5, 'width' => 0.5, 'height' => 0.5 },
|
417
|
+
]}
|
418
|
+
end
|
419
|
+
|
420
|
+
let (:original_page) do
|
421
|
+
page_with_images = original_page_example.clone
|
422
|
+
img = "<img alt='A fourth test image' class='to_ballonize' src='imgs/4.jpg' />"
|
423
|
+
page_with_images.at_css('body') << img
|
424
|
+
page_with_images.to_s
|
425
|
+
end
|
426
|
+
|
427
|
+
it 'the ballonize_page add the ballons to the image' do
|
428
|
+
instance.process_submit_hash(submit_hash, Time.at(0))
|
429
|
+
expect(ballonized_page).to have_tag('p',
|
430
|
+
{ text: 'the first ballon of the fourth image',
|
431
|
+
with: { class: 'ballonizer_ballon' } }
|
432
|
+
)
|
433
|
+
expect(ballonized_page).to have_tag('p',
|
434
|
+
{ text: 'the second ballon of the fourth image',
|
435
|
+
with: { class: 'ballonizer_ballon' } }
|
436
|
+
)
|
437
|
+
end
|
438
|
+
end
|
439
|
+
context 'when the submit refer to a image already with ballons' do
|
440
|
+
let(:submit_hash) do
|
441
|
+
# the keys are String because hashs parsed from JSON are this way
|
442
|
+
{ 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/1.jpg' => [
|
443
|
+
# the "(... version)" added to avoid reutilize a ballon (what is
|
444
|
+
# not the objective of the test here)
|
445
|
+
{ 'text' => 'the first ballon (version 2) of the first image',
|
446
|
+
'left' => 0, 'top' => 0, 'width' => 0.5, 'height' => 0.5 },
|
447
|
+
{ 'text' => 'the second ballon (version 2) of the first image',
|
448
|
+
'left' => 0.5, 'top' => 0.5, 'width' => 0.5, 'height' => 0.5 },
|
449
|
+
]}
|
450
|
+
end
|
451
|
+
|
452
|
+
let (:original_page) do
|
453
|
+
page_with_images = original_page_example.clone
|
454
|
+
img = "<img alt='A test image' class='to_ballonize' src='imgs/1.jpg' />"
|
455
|
+
page_with_images.at_css('body') << img
|
456
|
+
page_with_images.to_s
|
457
|
+
end
|
458
|
+
|
459
|
+
it 'the ballonize_page use the new ballons' do
|
460
|
+
instance.process_submit_hash(submit_hash, Time.at(0))
|
461
|
+
expect(ballonized_page).to have_tag('p',
|
462
|
+
{ text: 'the first ballon (version 2) of the first image',
|
463
|
+
with: { class: 'ballonizer_ballon' } }
|
464
|
+
)
|
465
|
+
expect(ballonized_page).to have_tag('p',
|
466
|
+
{ text: 'the second ballon (version 2) of the first image',
|
467
|
+
with: { class: 'ballonizer_ballon' } }
|
468
|
+
)
|
469
|
+
end
|
470
|
+
end
|
471
|
+
end
|
472
|
+
|
473
|
+
describe '#process_submit_json' do
|
474
|
+
# As process_submit_json use process_submit_hash (it's only a convenience
|
475
|
+
# method) we don't test the rest of its behaviour (who is already covered
|
476
|
+
# in #process_submit_hash)
|
477
|
+
context 'when the input is tainted' do
|
478
|
+
let (:submit_json) do
|
479
|
+
submit_hash_example.clone.taint
|
480
|
+
end
|
481
|
+
|
482
|
+
it do
|
483
|
+
expect {
|
484
|
+
instance.process_submit_json(submit_json)
|
485
|
+
}.to raise_error(SecurityError)
|
486
|
+
end
|
487
|
+
end
|
488
|
+
end
|
489
|
+
|
490
|
+
describe '#process_submit' do
|
491
|
+
|
492
|
+
# As process_submit use the #valid_submit_json? and #process_submit_hash
|
493
|
+
# we only make the tests here for example and its not the ideia cover the
|
494
|
+
# cases already covered in the specs of the two methods
|
495
|
+
context 'when the input is invalid' do
|
496
|
+
# The submit hash is used to define the env_example
|
497
|
+
# (and in consequence the env)
|
498
|
+
let (:submit_hash) do
|
499
|
+
# this input is invalid because the text field can't be empty
|
500
|
+
{ 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/4.jpg' => [
|
501
|
+
{ 'text' => '', 'left' => 0, 'top' => 0, 'width' => 0.5, 'height' => 0.5 }
|
502
|
+
]}
|
503
|
+
end
|
504
|
+
|
505
|
+
it do
|
506
|
+
expect { instance.process_submit(env) }.to raise_error(
|
507
|
+
described_class::SubmitError
|
508
|
+
)
|
509
|
+
end
|
510
|
+
end
|
511
|
+
|
512
|
+
context 'when the input is valid' do
|
513
|
+
# this code is almost the same as the used in #process_submit_hash,
|
514
|
+
# think in some way to DRY up this
|
515
|
+
let(:submit_hash) do
|
516
|
+
{ 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/4.jpg' => [
|
517
|
+
{ 'text' => 'the first ballon of the fourth image',
|
518
|
+
'left' => 0, 'top' => 0, 'width' => 0.5, 'height' => 0.5 }
|
519
|
+
]}
|
520
|
+
end
|
521
|
+
|
522
|
+
let (:original_page) do
|
523
|
+
page_with_images = original_page_example.clone
|
524
|
+
img = "<img alt='A fourth test image' class='to_ballonize' src='imgs/4.jpg' />"
|
525
|
+
page_with_images.at_css('body') << img
|
526
|
+
page_with_images.to_s
|
527
|
+
end
|
528
|
+
|
529
|
+
it 'the ballonize_page add the ballons to the image' do
|
530
|
+
instance.process_submit(env)
|
531
|
+
expect(ballonized_page).to have_tag('p',
|
532
|
+
{ text: 'the first ballon of the fourth image',
|
533
|
+
with: { class: 'ballonizer_ballon' } }
|
534
|
+
)
|
535
|
+
end
|
536
|
+
end
|
537
|
+
end
|
538
|
+
end
|
539
|
+
|
metadata
ADDED
@@ -0,0 +1,245 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ballonizer
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Henrique Becker
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2013-06-28 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: nokogiri
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ~>
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.5'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ~>
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.5'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: addressable
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ~>
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '2.3'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ~>
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '2.3'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: sequel
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ~>
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.48'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ~>
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.48'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: json
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ~>
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.7'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ~>
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.7'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: htmlentities
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ~>
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '4.3'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ~>
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '4.3'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rack
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ~>
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '1.5'
|
90
|
+
type: :runtime
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ~>
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '1.5'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: equivalent-xml
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ~>
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0.3'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ~>
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0.3'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: jshintrb
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ~>
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: 0.2.4
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ~>
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: 0.2.4
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: jasmine-headless-webkit
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ~>
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: 0.8.4
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ~>
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: 0.8.4
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: rspec-core
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - ~>
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '2.13'
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - ~>
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '2.13'
|
153
|
+
- !ruby/object:Gem::Dependency
|
154
|
+
name: rspec-expectations
|
155
|
+
requirement: !ruby/object:Gem::Requirement
|
156
|
+
requirements:
|
157
|
+
- - ~>
|
158
|
+
- !ruby/object:Gem::Version
|
159
|
+
version: '2.13'
|
160
|
+
type: :development
|
161
|
+
prerelease: false
|
162
|
+
version_requirements: !ruby/object:Gem::Requirement
|
163
|
+
requirements:
|
164
|
+
- - ~>
|
165
|
+
- !ruby/object:Gem::Version
|
166
|
+
version: '2.13'
|
167
|
+
- !ruby/object:Gem::Dependency
|
168
|
+
name: rspec-html-matchers
|
169
|
+
requirement: !ruby/object:Gem::Requirement
|
170
|
+
requirements:
|
171
|
+
- - ~>
|
172
|
+
- !ruby/object:Gem::Version
|
173
|
+
version: 0.4.1
|
174
|
+
type: :development
|
175
|
+
prerelease: false
|
176
|
+
version_requirements: !ruby/object:Gem::Requirement
|
177
|
+
requirements:
|
178
|
+
- - ~>
|
179
|
+
- !ruby/object:Gem::Version
|
180
|
+
version: 0.4.1
|
181
|
+
- !ruby/object:Gem::Dependency
|
182
|
+
name: rake
|
183
|
+
requirement: !ruby/object:Gem::Requirement
|
184
|
+
requirements:
|
185
|
+
- - ~>
|
186
|
+
- !ruby/object:Gem::Version
|
187
|
+
version: '10.0'
|
188
|
+
type: :development
|
189
|
+
prerelease: false
|
190
|
+
version_requirements: !ruby/object:Gem::Requirement
|
191
|
+
requirements:
|
192
|
+
- - ~>
|
193
|
+
- !ruby/object:Gem::Version
|
194
|
+
version: '10.0'
|
195
|
+
- !ruby/object:Gem::Dependency
|
196
|
+
name: sqlite3
|
197
|
+
requirement: !ruby/object:Gem::Requirement
|
198
|
+
requirements:
|
199
|
+
- - ~>
|
200
|
+
- !ruby/object:Gem::Version
|
201
|
+
version: '1.3'
|
202
|
+
type: :development
|
203
|
+
prerelease: false
|
204
|
+
version_requirements: !ruby/object:Gem::Requirement
|
205
|
+
requirements:
|
206
|
+
- - ~>
|
207
|
+
- !ruby/object:Gem::Version
|
208
|
+
version: '1.3'
|
209
|
+
description: A ruby+javascript library to allow add and edit speech bubbles over images
|
210
|
+
in a (X)HTML page and persist the ballons.
|
211
|
+
email: henriquebecker91@gmail.com
|
212
|
+
executables: []
|
213
|
+
extensions: []
|
214
|
+
extra_rdoc_files: []
|
215
|
+
files:
|
216
|
+
- lib/ballonizer.rb
|
217
|
+
- spec/ballonizer_spec.rb
|
218
|
+
- Rakefile
|
219
|
+
homepage: http://rubygems.org/gems/ballonize
|
220
|
+
licenses:
|
221
|
+
- Public domain
|
222
|
+
metadata: {}
|
223
|
+
post_install_message:
|
224
|
+
rdoc_options: []
|
225
|
+
require_paths:
|
226
|
+
- lib
|
227
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
228
|
+
requirements:
|
229
|
+
- - '>='
|
230
|
+
- !ruby/object:Gem::Version
|
231
|
+
version: '0'
|
232
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
233
|
+
requirements:
|
234
|
+
- - '>='
|
235
|
+
- !ruby/object:Gem::Version
|
236
|
+
version: '0'
|
237
|
+
requirements: []
|
238
|
+
rubyforge_project:
|
239
|
+
rubygems_version: 2.0.2
|
240
|
+
signing_key:
|
241
|
+
specification_version: 4
|
242
|
+
summary: 'ballonize: to allow colaborative edition of ballons in images'
|
243
|
+
test_files:
|
244
|
+
- spec/ballonizer_spec.rb
|
245
|
+
has_rdoc: true
|