positionable 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ module Positionable
2
+ VERSION = "1.0.1"
3
+ end
@@ -0,0 +1,28 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "positionable/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "positionable"
7
+ s.version = Positionable::VERSION
8
+ s.authors = ["Philippe Guégan"]
9
+ s.email = ["philippe.guegan@gmail.com"]
10
+ s.homepage = "https://github.com/pguegan/positionable"
11
+ s.summary = %q(A gem for positionning your ActiveRecord models.)
12
+ s.description = %q(This extension provides contiguous positionning capabilities to you ActiveRecord models.)
13
+
14
+ s.rubyforge_project = "positionable"
15
+
16
+ s.files = `git ls-files`.split("\n")
17
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
19
+ s.require_paths = ["lib"]
20
+
21
+ s.add_development_dependency "bundler", ">= 1.0.0"
22
+ s.add_development_dependency "rspec", "~> 2.3"
23
+ s.add_development_dependency "sqlite3-ruby"
24
+ s.add_development_dependency "simplecov"
25
+ s.add_development_dependency "factory_girl"
26
+
27
+ s.add_dependency "activerecord", "~> 3.1"
28
+ end
data/spec/factories.rb ADDED
@@ -0,0 +1,53 @@
1
+ FactoryGirl.define do
2
+
3
+ factory :folder do
4
+ sequence(:title) { |n| "Folder #{n}" }
5
+
6
+ factory :folder_with_documents do
7
+ after_create do |folder|
8
+ folder.documents = FactoryGirl.create_list(:document, 5, :folder => folder)
9
+ end
10
+ end
11
+ end
12
+
13
+ factory :document do
14
+ folder
15
+ sequence(:title) { |n| "Document #{n}" }
16
+ end
17
+
18
+ factory :default_item do
19
+ sequence(:title) { |n| "Default Item #{n}" }
20
+ end
21
+
22
+ factory :starting_at_one_item do
23
+ sequence(:title) { |n| "Starting At One Item #{n}" }
24
+ end
25
+
26
+ factory :asc_item do
27
+ sequence(:title) { |n| "Asc Item #{n}" }
28
+ end
29
+
30
+ factory :desc_item do
31
+ sequence(:title) { |n| "Desc Item #{n}" }
32
+ end
33
+
34
+ factory :group do
35
+ sequence(:title) { |n| "Group #{n}" }
36
+
37
+ factory :group_with_complex_items do
38
+ after_create do |group|
39
+ group.complex_items = FactoryGirl.create_list(:complex_item, 5, :group => group)
40
+ end
41
+ end
42
+ end
43
+
44
+ factory :complex_item do
45
+ group
46
+ sequence(:title) { |n| "Complex Item #{n}" }
47
+ end
48
+
49
+ factory :stuff do
50
+ sequence(:title) { |n| "Stuff #{n}" }
51
+ end
52
+
53
+ end
@@ -0,0 +1,596 @@
1
+ require 'spec_helper'
2
+
3
+ describe Positionable do
4
+
5
+ before do
6
+ Document.delete_all
7
+ Folder.delete_all
8
+ Item.delete_all
9
+ Dummy.delete_all
10
+ end
11
+
12
+ context "ActiveRecord extension" do
13
+
14
+ it "does not extend non positionable models" do
15
+ dummy = Dummy.new
16
+ dummy.respond_to?(:previous).should be_false
17
+ dummy.respond_to?(:next).should be_false
18
+ end
19
+
20
+ it "extends positionable models" do
21
+ item = DefaultItem.new
22
+ item.respond_to?(:previous).should be_true
23
+ item.respond_to?(:next).should be_true
24
+ end
25
+
26
+ it "prepends the table name in SQL 'order by' clause" do
27
+ sql = DefaultItem.where("1 = 1").to_sql
28
+ table = DefaultItem.table_name
29
+ sql.should include("ORDER BY \"#{table}\".\"position\"")
30
+ end
31
+
32
+ end
33
+
34
+ context "ordering" do
35
+
36
+ it "orders records by their position by default" do
37
+ shuffle_positions = (0..9).to_a.shuffle
38
+ shuffle_positions.each do |position|
39
+ item = Factory.create(:default_item)
40
+ item.update_column(:position, position)
41
+ end
42
+ DefaultItem.all.should be_contiguous
43
+ end
44
+
45
+ end
46
+
47
+ context "contiguous positionning" do
48
+
49
+ let!(:items) { FactoryGirl.create_list(:default_item, 10) }
50
+ let(:middle) { items[items.size / 2] }
51
+
52
+ it "makes the position to start at zero by default" do
53
+ items.first.position.should == 0
54
+ end
55
+
56
+ it "increments position by one after creation" do
57
+ item = Factory.create(:default_item)
58
+ item.position.should == items.last.position + 1
59
+ end
60
+
61
+ it "does not exist a previous for the first record" do
62
+ items.first.previous.should be_nil
63
+ end
64
+
65
+ it "gives the previous record according to its position" do
66
+ items[1..(items.size - 1)].each_with_index do |item, index|
67
+ item.previous.should == items[index]
68
+ end
69
+ end
70
+
71
+ it "gives all the previous records according to their positions" do
72
+ middle.all_previous.size.should == middle.position
73
+ middle.all_previous.each_with_index do |previous, index|
74
+ previous.should == items[index]
75
+ end
76
+ end
77
+
78
+ it "does not exist a next for the last record" do
79
+ items.last.next.should be_nil
80
+ end
81
+
82
+ it "gives the next record according to its position" do
83
+ items[0..(items.size - 2)].each_with_index do |item, index|
84
+ item.next.should == items[index + 1]
85
+ end
86
+ end
87
+
88
+ it "gives all the next records according to their positions" do
89
+ middle.all_next.size.should == items.size - middle.position - 1
90
+ middle.all_next.each_with_index do |neXt, index|
91
+ neXt.should == items[middle.position + index + 1]
92
+ end
93
+ end
94
+
95
+ it "caracterizes the first record" do
96
+ items.first.first?.should be_true
97
+ items.but_first.each do |item|
98
+ item.first?.should be_false
99
+ end
100
+ end
101
+
102
+ it "caracterizes the last record" do
103
+ items.but_last.each do |item|
104
+ item.last?.should be_false
105
+ end
106
+ items.last.last?.should be_true
107
+ end
108
+
109
+ it "decrements positions of next sibblings after deletion" do
110
+ position = items.size / 2
111
+ middle.destroy
112
+ items.before(position).should be_contiguous
113
+ items.after(position).should be_contiguous.starting_at(position)
114
+ end
115
+
116
+ it "does not up the first record" do
117
+ item = items.first
118
+ item.position.should == 0 # Meta!
119
+ item.up!
120
+ item.position.should == 0
121
+ end
122
+
123
+ it "does not down the last record" do
124
+ item = items.last
125
+ item.position.should == items.size - 1 # Meta!
126
+ item.down!
127
+ item.position.should == items.size - 1
128
+ end
129
+
130
+ it "reorders the records positions after upping" do
131
+ position = middle.position
132
+ previous = middle.previous
133
+ neXt = middle.next
134
+ previous.position.should == position - 1 # Meta!
135
+ neXt.position.should == position + 1 # Meta!
136
+ middle.up!
137
+ previous.reload.position.should == position
138
+ middle.position.should == position - 1
139
+ neXt.reload.position.should == position + 1
140
+ end
141
+
142
+ it "reorders the records positions after downing" do
143
+ position = middle.position
144
+ previous = middle.previous
145
+ neXt = middle.next
146
+ previous.position.should == position - 1 # Meta!
147
+ neXt.position.should == position + 1 # Meta!
148
+ middle.down!
149
+ previous.reload.position.should == position - 1
150
+ middle.position.should == position + 1
151
+ neXt.reload.position.should == position
152
+ end
153
+
154
+ describe "moving" do
155
+
156
+ context "mass-assignement" do
157
+
158
+ it "reorders records when position is updated" do
159
+ old_position = middle.position
160
+ new_position = old_position + 3
161
+ middle.update_attributes({ :position => new_position })
162
+ (0..(old_position - 1)).each do |position|
163
+ items[position].reload.position.should == position
164
+ end
165
+ middle.position.should == new_position
166
+ ((old_position + 1)..new_position).each do |position|
167
+ items[position].reload.position.should == position - 1
168
+ end
169
+ ((new_position + 1)..(items.count - 1)).each do |position|
170
+ items[position].reload.position.should == position
171
+ end
172
+ end
173
+
174
+ it "does not reorder anything when position is updated but out of range" do
175
+ middle.update_attributes({ :position => items.count + 10 })
176
+ items.should be_contiguous
177
+ end
178
+
179
+ it "does not reorder anything when position is updated but before start" do
180
+ middle.update_attributes({ :position => -1 })
181
+ items.should be_contiguous
182
+ end
183
+
184
+ end
185
+
186
+ it "also moves the previous records when moving to a lower position" do
187
+ old_position = middle.position
188
+ new_position = old_position - 3
189
+ middle.move_to new_position
190
+ (0..(new_position - 1)).each do |position|
191
+ items[position].reload.position.should == position
192
+ end
193
+ middle.position.should == new_position
194
+ (new_position..(old_position - 1)).each do |position|
195
+ items[position].reload.position.should == position + 1
196
+ end
197
+ ((old_position + 1)..(items.count - 1)).each do |position|
198
+ items[position].reload.position.should == position
199
+ end
200
+ end
201
+
202
+ it "also moves the next records when moving to a higher position" do
203
+ old_position = middle.position
204
+ new_position = old_position + 3
205
+ middle.move_to new_position
206
+ (0..(old_position - 1)).each do |position|
207
+ items[position].reload.position.should == position
208
+ end
209
+ middle.position.should == new_position
210
+ ((old_position + 1)..new_position).each do |position|
211
+ items[position].reload.position.should == position - 1
212
+ end
213
+ ((new_position + 1)..(items.count - 1)).each do |position|
214
+ items[position].reload.position.should == position
215
+ end
216
+ end
217
+
218
+ it "does not move anything if new position is before start position" do
219
+ lambda {
220
+ middle.move_to -1
221
+ }.should_not change(middle, :position)
222
+ end
223
+
224
+ it "does not move anything if new position is out of range" do
225
+ lambda {
226
+ middle.move_to items.count + 10
227
+ }.should_not change(middle, :position)
228
+ end
229
+
230
+ end
231
+
232
+ end
233
+
234
+ describe "range" do
235
+
236
+ let!(:items) { FactoryGirl.create_list(:default_item, 10) }
237
+
238
+ it "gives the range position of a new record" do
239
+ item = Factory.build(:default_item)
240
+ item.range.should == (0..items.count)
241
+ end
242
+
243
+ it "gives the range position of an existing record" do
244
+ items.sample.range.should == (0..(items.count - 1))
245
+ end
246
+
247
+ end
248
+
249
+ context "scoping" do
250
+
251
+ let!(:folders) { FactoryGirl.create_list(:folder_with_documents, 5) }
252
+
253
+ it "orders records by their position by default" do
254
+ folders.each do |folder|
255
+ documents = folder.documents
256
+ shuffled_positions = (0..(documents.size - 1)).to_a.shuffle
257
+ documents.each_with_index do |document, index|
258
+ document.update_column(:position, shuffled_positions[index])
259
+ end
260
+ documents = folder.reload.documents
261
+ documents.should be_contiguous
262
+ end
263
+ end
264
+
265
+ it "makes the position to start at zero for each folder" do
266
+ folders.each do |folder|
267
+ folder.documents.first.position.should == 0
268
+ end
269
+ end
270
+
271
+ it "increments position by one after creation inside a folder" do
272
+ folders.each do |folder|
273
+ last_position = folder.documents.last.position
274
+ document = Factory.create(:document, :folder => folder)
275
+ document.position.should == last_position + 1
276
+ end
277
+ end
278
+
279
+ it "does not exist a previous for the first record of each folder" do
280
+ folders.each do |folder|
281
+ folder.documents.first.previous.should be_nil
282
+ end
283
+ end
284
+
285
+ it "gives the previous record of the folder according to its position" do
286
+ folders.each do |folder|
287
+ folder.documents.but_first.each_with_index do |document, index|
288
+ document.previous.should == folder.documents[index]
289
+ end
290
+ end
291
+ end
292
+
293
+ it "gives all the previous records of the folder according to their positions" do
294
+ folders.each do |folder|
295
+ documents = folder.documents
296
+ middle = documents[documents.size / 2]
297
+ middle.all_previous.size.should == middle.position
298
+ middle.all_previous.each_with_index do |previous, index|
299
+ previous.should == documents[index]
300
+ end
301
+ end
302
+ end
303
+
304
+ it "does not exist a next for the last record of the folder" do
305
+ folders.each do |folder|
306
+ folder.documents.last.next.should be_nil
307
+ end
308
+ end
309
+
310
+ it "gives the next record of the folder according to its position" do
311
+ folders.each do |folder|
312
+ documents = folder.documents
313
+ documents.but_last.each_with_index do |document, index|
314
+ document.next.should == documents[index + 1]
315
+ end
316
+ end
317
+ end
318
+
319
+ it "gives all the next records of the folder according to their positions" do
320
+ folders.each do |folder|
321
+ documents = folder.documents
322
+ middle = documents[documents.size / 2]
323
+ middle.all_next.count.should == documents.count - middle.position - 1
324
+ middle.all_next.each_with_index do |neXt, index|
325
+ neXt.should == documents[middle.position + index + 1]
326
+ end
327
+ end
328
+ end
329
+
330
+ it "caracterizes the first record of the folder" do
331
+ folders.each do |folder|
332
+ documents = folder.documents
333
+ documents.first.first?.should be_true
334
+ documents.but_first.each do |document|
335
+ document.first?.should be_false
336
+ end
337
+ end
338
+ end
339
+
340
+ it "caracterizes the last record of the folder" do
341
+ folders.each do |folder|
342
+ documents = folder.documents
343
+ documents.but_last.each do |document|
344
+ document.last?.should be_false
345
+ end
346
+ documents.last.last?.should be_true
347
+ end
348
+ end
349
+
350
+ it "decrements positions of next sibblings of the folder after deletion" do
351
+ folders.each do |folder|
352
+ documents = folder.documents
353
+ middle = documents.size / 2
354
+ documents[middle].destroy
355
+ documents.before(middle).should be_contiguous
356
+ documents.after(middle).should be_contiguous.starting_at(middle)
357
+ end
358
+ end
359
+
360
+ it "does not up the first record of the folder" do
361
+ folders.each do |folder|
362
+ document = folder.documents.first
363
+ document.position.should == 0 # Meta!
364
+ document.up!
365
+ document.position.should == 0
366
+ end
367
+ end
368
+
369
+ it "does not down the last record of the folder" do
370
+ folders.each do |folder|
371
+ document = folder.documents.last
372
+ document.position.should == folder.documents.size - 1 # Meta!
373
+ document.down!
374
+ document.position.should == folder.documents.size - 1
375
+ end
376
+ end
377
+
378
+ it "reorders the records positions after upping" do
379
+ folders.each do |folder|
380
+ documents = folder.documents
381
+ middle = documents[documents.size / 2]
382
+ position = middle.position
383
+ previous = middle.previous
384
+ neXt = middle.next
385
+ previous.position.should == position - 1 # Meta!
386
+ neXt.position.should == position + 1 # Meta!
387
+ middle.up!
388
+ previous.reload.position.should == position
389
+ middle.position.should == position - 1
390
+ neXt.reload.position.should == position + 1
391
+ end
392
+ end
393
+
394
+ it "reorders the records positions after downing" do
395
+ folders.each do |folder|
396
+ documents = folder.documents
397
+ middle = documents[documents.size / 2]
398
+ position = middle.position
399
+ previous = middle.previous
400
+ neXt = middle.next
401
+ previous.position.should == position - 1 # Meta!
402
+ neXt.position.should == position + 1 # Meta!
403
+ middle.down!
404
+ previous.reload.position.should == position - 1
405
+ middle.position.should == position + 1
406
+ neXt.reload.position.should == position
407
+ end
408
+ end
409
+
410
+ context "changing scope" do
411
+
412
+ let!(:old_folder) { folders.first }
413
+ # Last document is a special case when changing scope, so it is avoided
414
+ let!(:document) { old_folder.documents.but_last.sample }
415
+ # A new folder containing a different count of documents than the old folder
416
+ let!(:new_folder) { Factory.create(:folder) }
417
+ let!(:new_documents) { FactoryGirl.create_list(:document, old_folder.documents.count + 1, :folder => new_folder) }
418
+
419
+ it "moves to bottom position when scope has changed but position is out of range" do
420
+ document.update_attributes( {:folder_id => new_folder.id, :position => new_documents.count + 10 } )
421
+ document.position.should == new_folder.documents.count - 1
422
+ document.last?.should be_true
423
+ end
424
+
425
+ it "keeps position when scope has changed but position belongs to range" do
426
+ position = document.position
427
+ document.update_attributes( {:folder_id => new_folder.id} )
428
+ document.position.should == position # Position unchanged
429
+ new_folder.reload.documents.should be_contiguous
430
+ end
431
+
432
+ it "reorders records of previous scope" do
433
+ document.update_attributes( {:folder_id => new_folder.id} )
434
+ old_folder.reload.documents.should be_contiguous
435
+ end
436
+
437
+ end
438
+
439
+ describe "range" do
440
+
441
+ context "new record" do
442
+
443
+ it "gives a range only if the scope is specified" do
444
+ lambda {
445
+ Document.new.range
446
+ }.should raise_error(Positionable::RangeWithoutScopeError)
447
+ end
448
+
449
+ it "gives the range within a scope" do
450
+ folder = Factory.create(:folder_with_documents)
451
+ document = Document.new
452
+ document.range(folder).should == (0..(folder.documents.count + 1))
453
+ end
454
+
455
+ it "gives the range within its own scope by default" do
456
+ document = Factory.build(:document)
457
+ folder = document.folder
458
+ document.range.should == (0..(folder.documents.count + 1))
459
+ end
460
+
461
+ it "gives the range within another scope" do
462
+ document = Factory.build(:document)
463
+ folder = Factory.create(:folder_with_documents)
464
+ document.folder.should_not == folder # Meta!
465
+ document.range(folder).should == (0..(folder.documents.count + 1))
466
+ end
467
+
468
+ end
469
+
470
+ context "existing record" do
471
+
472
+ it "gives the range within its own scope" do
473
+ folder = Factory.create(:folder_with_documents)
474
+ document = folder.documents.sample
475
+ document.range(folder).should == (0..folder.documents.count)
476
+ end
477
+
478
+ it "gives the range within another scope" do
479
+ document = Factory.create(:document)
480
+ folder = Factory.create(:folder_with_documents)
481
+ document.folder.should_not == folder # Meta!
482
+ document.range(folder).should == (0..(folder.documents.count + 1))
483
+ end
484
+
485
+ end
486
+
487
+ end
488
+
489
+ end
490
+
491
+ context "start position" do
492
+
493
+ let(:start) { 1 }
494
+
495
+ it "starts at the given position" do
496
+ item = Factory.create(:starting_at_one_item)
497
+ item.position.should == start
498
+ end
499
+
500
+ it "increments by one the given start position" do
501
+ items = FactoryGirl.create_list(:starting_at_one_item, 5)
502
+ item = Factory.create(:starting_at_one_item)
503
+ item.position.should == items.size + start
504
+ end
505
+
506
+ it "caracterizes the first record according the start position" do
507
+ items = FactoryGirl.create_list(:starting_at_one_item, 5)
508
+ items.first.first?.should be_true
509
+ items.but_first.each do |item|
510
+ item.first?.should be_false
511
+ end
512
+ end
513
+
514
+ it "caracterizes the last record according the start position" do
515
+ items = FactoryGirl.create_list(:starting_at_one_item, 5)
516
+ items.but_last.each do |item|
517
+ item.last?.should be_false
518
+ end
519
+ items.last.last?.should be_true
520
+ end
521
+
522
+ describe "moving" do
523
+
524
+ it "does not move anything if new position is before start position" do
525
+ items = FactoryGirl.create_list(:starting_at_one_item, 5)
526
+ item = items.sample
527
+ lambda {
528
+ item.move_to start - 1
529
+ }.should_not change(item, :position)
530
+ end
531
+
532
+ end
533
+
534
+ describe "range" do
535
+
536
+ it "staggers range with start position" do
537
+ items = FactoryGirl.create_list(:starting_at_one_item, 5)
538
+ items.sample.range.should == (start..(items.count + start - 1))
539
+ end
540
+
541
+ end
542
+
543
+ end
544
+
545
+ context "insertion order" do
546
+
547
+ describe "asc" do
548
+
549
+ it "appends at the last position" do
550
+ items = FactoryGirl.create_list(:asc_item, 5)
551
+ item = Factory.create(:asc_item)
552
+ item.position.should == items.size
553
+ end
554
+
555
+ it "orders items by ascending position" do
556
+ FactoryGirl.create_list(:asc_item, 5)
557
+ AscItem.all.each_with_index do |item, index|
558
+ item.position.should == index
559
+ end
560
+ end
561
+
562
+ end
563
+
564
+ describe "desc" do
565
+
566
+ it "appends at the last position" do
567
+ items = FactoryGirl.create_list(:desc_item, 5)
568
+ item = Factory.create(:desc_item)
569
+ item.position.should == items.size
570
+ end
571
+
572
+ it "orders items by descending position" do
573
+ items = FactoryGirl.create_list(:desc_item, 5)
574
+ DescItem.all.reverse.should be_contiguous
575
+ end
576
+
577
+ end
578
+
579
+ end
580
+
581
+ context "mixing options" do
582
+
583
+ let!(:groups) { FactoryGirl.create_list(:group_with_complex_items, 5) }
584
+ let(:start) { 1 } # Check configuration in support/models.rb
585
+
586
+ it "manages complex items" do
587
+ # All options are tested here (grouping, descending ordering and start position at 1)
588
+ groups.each do |group|
589
+ size = group.complex_items.size
590
+ group.complex_items.reverse.should be_contiguous.starting_at(start)
591
+ end
592
+ end
593
+
594
+ end
595
+
596
+ end