ballonizer 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|