diff options
Diffstat (limited to 'spec/support/shared_examples/models/relative_positioning_shared_examples.rb')
-rw-r--r-- | spec/support/shared_examples/models/relative_positioning_shared_examples.rb | 602 |
1 files changed, 539 insertions, 63 deletions
diff --git a/spec/support/shared_examples/models/relative_positioning_shared_examples.rb b/spec/support/shared_examples/models/relative_positioning_shared_examples.rb index 99e62ebf422..e4668926d74 100644 --- a/spec/support/shared_examples/models/relative_positioning_shared_examples.rb +++ b/spec/support/shared_examples/models/relative_positioning_shared_examples.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true RSpec.shared_examples 'a class that supports relative positioning' do - let(:item1) { create(factory, default_params) } - let(:item2) { create(factory, default_params) } - let(:new_item) { create(factory, default_params) } + let(:item1) { create_item } + let(:item2) { create_item } + let(:new_item) { create_item } - def create_item(params) + def create_item(params = {}) create(factory, params.merge(default_params)) end @@ -16,31 +16,119 @@ RSpec.shared_examples 'a class that supports relative positioning' do end describe '.move_nulls_to_end' do + let(:item3) { create_item } + it 'moves items with null relative_position to the end' do + item1.update!(relative_position: 1000) + item2.update!(relative_position: nil) + item3.update!(relative_position: nil) + + items = [item1, item2, item3] + expect(described_class.move_nulls_to_end(items)).to be(2) + + expect(items.sort_by(&:relative_position)).to eq(items) + expect(item1.relative_position).to be(1000) + expect(item1.prev_relative_position).to be_nil + expect(item1.next_relative_position).to eq(item2.relative_position) + expect(item2.next_relative_position).to eq(item3.relative_position) + expect(item3.next_relative_position).to be_nil + end + + it 'preserves relative position' do item1.update!(relative_position: nil) item2.update!(relative_position: nil) described_class.move_nulls_to_end([item1, item2]) - expect(item2.prev_relative_position).to eq item1.relative_position - expect(item1.prev_relative_position).to eq nil - expect(item2.next_relative_position).to eq nil + expect(item1.relative_position).to be < item2.relative_position end it 'moves the item near the start position when there are no existing positions' do item1.update!(relative_position: nil) described_class.move_nulls_to_end([item1]) - - expect(item1.relative_position).to eq(described_class::START_POSITION + described_class::IDEAL_DISTANCE) + expect(item1.reset.relative_position).to eq(described_class::START_POSITION + described_class::IDEAL_DISTANCE) end it 'does not perform any moves if all items have their relative_position set' do item1.update!(relative_position: 1) - expect(item1).not_to receive(:save) + expect(described_class.move_nulls_to_start([item1])).to be(0) + expect(item1.reload.relative_position).to be(1) + end + + it 'manages to move nulls to the end even if there is a sequence at the end' do + bunch = create_items_with_positions(run_at_end) + item1.update!(relative_position: nil) described_class.move_nulls_to_end([item1]) + + items = [*bunch, item1] + items.each(&:reset) + + expect(items.map(&:relative_position)).to all(be_valid_position) + expect(items.sort_by(&:relative_position)).to eq(items) + end + + it 'does not have an N+1 issue' do + create_items_with_positions(10..12) + + a, b, c, d, e, f = create_items_with_positions([nil, nil, nil, nil, nil, nil]) + + baseline = ActiveRecord::QueryRecorder.new do + described_class.move_nulls_to_end([a, e]) + end + + expect { described_class.move_nulls_to_end([b, c, d]) } + .not_to exceed_query_limit(baseline) + + expect { described_class.move_nulls_to_end([f]) } + .not_to exceed_query_limit(baseline.count) + end + end + + describe '.move_nulls_to_start' do + let(:item3) { create_item } + + it 'moves items with null relative_position to the start' do + item1.update!(relative_position: nil) + item2.update!(relative_position: nil) + item3.update!(relative_position: 1000) + + items = [item1, item2, item3] + expect(described_class.move_nulls_to_start(items)).to be(2) + items.map(&:reload) + + expect(items.sort_by(&:relative_position)).to eq(items) + expect(item1.prev_relative_position).to eq nil + expect(item1.next_relative_position).to eq item2.relative_position + expect(item2.next_relative_position).to eq item3.relative_position + expect(item3.next_relative_position).to eq nil + expect(item3.relative_position).to be(1000) + end + + it 'moves the item near the start position when there are no existing positions' do + item1.update!(relative_position: nil) + + described_class.move_nulls_to_start([item1]) + + expect(item1.relative_position).to eq(described_class::START_POSITION - described_class::IDEAL_DISTANCE) + end + + it 'preserves relative position' do + item1.update!(relative_position: nil) + item2.update!(relative_position: nil) + + described_class.move_nulls_to_start([item1, item2]) + + expect(item1.relative_position).to be < item2.relative_position + end + + it 'does not perform any moves if all items have their relative_position set' do + item1.update!(relative_position: 1) + + expect(described_class.move_nulls_to_start([item1])).to be(0) + expect(item1.reload.relative_position).to be(1) end end @@ -52,8 +140,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do describe '#prev_relative_position' do it 'returns previous position if there is an item above' do - item1.update(relative_position: 5) - item2.update(relative_position: 15) + item1.update!(relative_position: 5) + item2.update!(relative_position: 15) expect(item2.prev_relative_position).to eq item1.relative_position end @@ -65,8 +153,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do describe '#next_relative_position' do it 'returns next position if there is an item below' do - item1.update(relative_position: 5) - item2.update(relative_position: 15) + item1.update!(relative_position: 5) + item2.update!(relative_position: 15) expect(item1.next_relative_position).to eq item2.relative_position end @@ -76,9 +164,172 @@ RSpec.shared_examples 'a class that supports relative positioning' do end end + describe '#find_next_gap_before' do + context 'there is no gap' do + let(:items) { create_items_with_positions(run_at_start) } + + it 'returns nil' do + items.each do |item| + expect(item.send(:find_next_gap_before)).to be_nil + end + end + end + + context 'there is a sequence ending at MAX_POSITION' do + let(:items) { create_items_with_positions(run_at_end) } + + let(:gaps) do + items.map { |item| item.send(:find_next_gap_before) } + end + + it 'can find the gap at the start for any item in the sequence' do + gap = { start: items.first.relative_position, end: RelativePositioning::MIN_POSITION } + + expect(gaps).to all(eq(gap)) + end + + it 'respects lower bounds' do + gap = { start: items.first.relative_position, end: 10 } + new_item.update!(relative_position: 10) + + expect(gaps).to all(eq(gap)) + end + end + + specify do + item1.update!(relative_position: 5) + + (0..10).each do |pos| + item2.update!(relative_position: pos) + + gap = item2.send(:find_next_gap_before) + + expect(gap[:start]).to be <= item2.relative_position + expect((gap[:end] - gap[:start]).abs).to be >= RelativePositioning::MIN_GAP + expect(gap[:start]).to be_valid_position + expect(gap[:end]).to be_valid_position + end + end + + it 'deals with there not being any items to the left' do + create_items_with_positions([1, 2, 3]) + new_item.update!(relative_position: 0) + + expect(new_item.send(:find_next_gap_before)).to eq(start: 0, end: RelativePositioning::MIN_POSITION) + end + + it 'finds the next gap to the left, skipping adjacent values' do + create_items_with_positions([1, 9, 10]) + new_item.update!(relative_position: 11) + + expect(new_item.send(:find_next_gap_before)).to eq(start: 9, end: 1) + end + + it 'finds the next gap to the left' do + create_items_with_positions([2, 10]) + + new_item.update!(relative_position: 15) + expect(new_item.send(:find_next_gap_before)).to eq(start: 15, end: 10) + + new_item.update!(relative_position: 11) + expect(new_item.send(:find_next_gap_before)).to eq(start: 10, end: 2) + + new_item.update!(relative_position: 9) + expect(new_item.send(:find_next_gap_before)).to eq(start: 9, end: 2) + + new_item.update!(relative_position: 5) + expect(new_item.send(:find_next_gap_before)).to eq(start: 5, end: 2) + end + end + + describe '#find_next_gap_after' do + context 'there is no gap' do + let(:items) { create_items_with_positions(run_at_end) } + + it 'returns nil' do + items.each do |item| + expect(item.send(:find_next_gap_after)).to be_nil + end + end + end + + context 'there is a sequence starting at MIN_POSITION' do + let(:items) { create_items_with_positions(run_at_start) } + + let(:gaps) do + items.map { |item| item.send(:find_next_gap_after) } + end + + it 'can find the gap at the end for any item in the sequence' do + gap = { start: items.last.relative_position, end: RelativePositioning::MAX_POSITION } + + expect(gaps).to all(eq(gap)) + end + + it 'respects upper bounds' do + gap = { start: items.last.relative_position, end: 10 } + new_item.update!(relative_position: 10) + + expect(gaps).to all(eq(gap)) + end + end + + specify do + item1.update!(relative_position: 5) + + (0..10).each do |pos| + item2.update!(relative_position: pos) + + gap = item2.send(:find_next_gap_after) + + expect(gap[:start]).to be >= item2.relative_position + expect((gap[:end] - gap[:start]).abs).to be >= RelativePositioning::MIN_GAP + expect(gap[:start]).to be_valid_position + expect(gap[:end]).to be_valid_position + end + end + + it 'deals with there not being any items to the right' do + create_items_with_positions([1, 2, 3]) + new_item.update!(relative_position: 5) + + expect(new_item.send(:find_next_gap_after)).to eq(start: 5, end: RelativePositioning::MAX_POSITION) + end + + it 'finds the next gap to the right, skipping adjacent values' do + create_items_with_positions([1, 2, 10]) + new_item.update!(relative_position: 0) + + expect(new_item.send(:find_next_gap_after)).to eq(start: 2, end: 10) + end + + it 'finds the next gap to the right' do + create_items_with_positions([2, 10]) + + new_item.update!(relative_position: 0) + expect(new_item.send(:find_next_gap_after)).to eq(start: 0, end: 2) + + new_item.update!(relative_position: 1) + expect(new_item.send(:find_next_gap_after)).to eq(start: 2, end: 10) + + new_item.update!(relative_position: 3) + expect(new_item.send(:find_next_gap_after)).to eq(start: 3, end: 10) + + new_item.update!(relative_position: 5) + expect(new_item.send(:find_next_gap_after)).to eq(start: 5, end: 10) + end + end + describe '#move_before' do + let(:item3) { create(factory, default_params) } + it 'moves item before' do - [item2, item1].each(&:move_to_end) + [item2, item1].each do |item| + item.move_to_end + item.save! + end + + expect(item1.relative_position).to be > item2.relative_position item1.move_before(item2) @@ -86,12 +337,10 @@ RSpec.shared_examples 'a class that supports relative positioning' do end context 'when there is no space' do - let(:item3) { create(factory, default_params) } - before do - item1.update(relative_position: 1000) - item2.update(relative_position: 1001) - item3.update(relative_position: 1002) + item1.update!(relative_position: 1000) + item2.update!(relative_position: 1001) + item3.update!(relative_position: 1002) end it 'moves items correctly' do @@ -100,6 +349,73 @@ RSpec.shared_examples 'a class that supports relative positioning' do expect(item3.relative_position).to be_between(item1.reload.relative_position, item2.reload.relative_position).exclusive end end + + it 'can move the item before an item at the start' do + item1.update!(relative_position: RelativePositioning::START_POSITION) + + new_item.move_before(item1) + + expect(new_item.relative_position).to be_valid_position + expect(new_item.relative_position).to be < item1.reload.relative_position + end + + it 'can move the item before an item at MIN_POSITION' do + item1.update!(relative_position: RelativePositioning::MIN_POSITION) + + new_item.move_before(item1) + + expect(new_item.relative_position).to be >= RelativePositioning::MIN_POSITION + expect(new_item.relative_position).to be < item1.reload.relative_position + end + + it 'can move the item before an item bunched up at MIN_POSITION' do + item1, item2, item3 = create_items_with_positions(run_at_start) + + new_item.move_before(item3) + new_item.save! + + items = [item1, item2, new_item, item3] + + items.each do |item| + expect(item.reset.relative_position).to be_valid_position + end + + expect(items.sort_by(&:relative_position)).to eq(items) + end + + context 'leap-frogging to the left' do + before do + start = RelativePositioning::START_POSITION + item1.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 0) + item2.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 1) + item3.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 2) + end + + let(:item3) { create(factory, default_params) } + + def leap_frog(steps) + a = item1 + b = item2 + + steps.times do |i| + a.move_before(b) + a.save! + a, b = b, a + end + end + + it 'can leap-frog STEPS - 1 times before needing to rebalance' do + # This is less efficient than going right, due to the flooring of + # integer division + expect { leap_frog(RelativePositioning::STEPS - 1) } + .not_to change { item3.reload.relative_position } + end + + it 'rebalances after leap-frogging STEPS times' do + expect { leap_frog(RelativePositioning::STEPS) } + .to change { item3.reload.relative_position } + end + end end describe '#move_after' do @@ -115,9 +431,17 @@ RSpec.shared_examples 'a class that supports relative positioning' do let(:item3) { create(factory, default_params) } before do - item1.update(relative_position: 1000) - item2.update(relative_position: 1001) - item3.update(relative_position: 1002) + item1.update!(relative_position: 1000) + item2.update!(relative_position: 1001) + item3.update!(relative_position: 1002) + end + + it 'can move the item after an item at MAX_POSITION' do + item1.update!(relative_position: RelativePositioning::MAX_POSITION) + + new_item.move_after(item1) + expect(new_item.relative_position).to be_valid_position + expect(new_item.relative_position).to be > item1.reset.relative_position end it 'moves items correctly' do @@ -126,12 +450,96 @@ RSpec.shared_examples 'a class that supports relative positioning' do expect(item1.relative_position).to be_between(item2.reload.relative_position, item3.reload.relative_position).exclusive end end + + it 'can move the item after an item bunched up at MAX_POSITION' do + item1, item2, item3 = create_items_with_positions(run_at_end) + + new_item.move_after(item1) + new_item.save! + + items = [item1, new_item, item2, item3] + + items.each do |item| + expect(item.reset.relative_position).to be_valid_position + end + + expect(items.sort_by(&:relative_position)).to eq(items) + end + + context 'leap-frogging' do + before do + start = RelativePositioning::START_POSITION + item1.update!(relative_position: start + RelativePositioning::IDEAL_DISTANCE * 0) + item2.update!(relative_position: start + RelativePositioning::IDEAL_DISTANCE * 1) + item3.update!(relative_position: start + RelativePositioning::IDEAL_DISTANCE * 2) + end + + let(:item3) { create(factory, default_params) } + + def leap_frog(steps) + a = item1 + b = item2 + + steps.times do |i| + a.move_after(b) + a.save! + a, b = b, a + end + end + + it 'can leap-frog STEPS times before needing to rebalance' do + expect { leap_frog(RelativePositioning::STEPS) } + .not_to change { item3.reload.relative_position } + end + + it 'rebalances after leap-frogging STEPS+1 times' do + expect { leap_frog(RelativePositioning::STEPS + 1) } + .to change { item3.reload.relative_position } + end + end + end + + describe '#move_to_start' do + before do + [item1, item2].each do |item1| + item1.move_to_start && item1.save! + end + end + + it 'moves item to the end' do + new_item.move_to_start + + expect(new_item.relative_position).to be < item2.relative_position + end + + it 'rebalances when there is already an item at the MIN_POSITION' do + item2.update!(relative_position: RelativePositioning::MIN_POSITION) + + new_item.move_to_start + item2.reset + + expect(new_item.relative_position).to be < item2.relative_position + expect(new_item.relative_position).to be >= RelativePositioning::MIN_POSITION + end + + it 'deals with a run of elements at the start' do + item1.update!(relative_position: RelativePositioning::MIN_POSITION + 1) + item2.update!(relative_position: RelativePositioning::MIN_POSITION) + + new_item.move_to_start + item1.reset + item2.reset + + expect(item2.relative_position).to be < item1.relative_position + expect(new_item.relative_position).to be < item2.relative_position + expect(new_item.relative_position).to be >= RelativePositioning::MIN_POSITION + end end describe '#move_to_end' do before do [item1, item2].each do |item1| - item1.move_to_end && item1.save + item1.move_to_end && item1.save! end end @@ -140,12 +548,44 @@ RSpec.shared_examples 'a class that supports relative positioning' do expect(new_item.relative_position).to be > item2.relative_position end + + it 'rebalances when there is already an item at the MAX_POSITION' do + item2.update!(relative_position: RelativePositioning::MAX_POSITION) + + new_item.move_to_end + item2.reset + + expect(new_item.relative_position).to be > item2.relative_position + expect(new_item.relative_position).to be <= RelativePositioning::MAX_POSITION + end + + it 'deals with a run of elements at the end' do + item1.update!(relative_position: RelativePositioning::MAX_POSITION - 1) + item2.update!(relative_position: RelativePositioning::MAX_POSITION) + + new_item.move_to_end + item1.reset + item2.reset + + expect(item2.relative_position).to be > item1.relative_position + expect(new_item.relative_position).to be > item2.relative_position + expect(new_item.relative_position).to be <= RelativePositioning::MAX_POSITION + end end describe '#move_between' do before do - [item1, item2].each do |item1| - item1.move_to_end && item1.save + [item1, item2].each do |item| + item.move_to_end && item.save! + end + end + + shared_examples 'moves item between' do + it 'moves the middle item to between left and right' do + expect do + middle.move_between(left, right) + middle.save! + end.to change { between_exclusive?(left, middle, right) }.from(false).to(true) end end @@ -169,26 +609,26 @@ RSpec.shared_examples 'a class that supports relative positioning' do end it 'positions items even when after and before positions are the same' do - item2.update relative_position: item1.relative_position + item2.update! relative_position: item1.relative_position new_item.move_between(item1, item2) + [item1, item2].each(&:reset) expect(new_item.relative_position).to be > item1.relative_position expect(item1.relative_position).to be < item2.relative_position end - it 'positions items between other two if distance is 1' do - item2.update relative_position: item1.relative_position + 1 - - new_item.move_between(item1, item2) + context 'the two items are next to each other' do + let(:left) { item1 } + let(:middle) { new_item } + let(:right) { create_item(relative_position: item1.relative_position + 1) } - expect(new_item.relative_position).to be > item1.relative_position - expect(item1.relative_position).to be < item2.relative_position + it_behaves_like 'moves item between' end it 'positions item in the middle of other two if distance is big enough' do - item1.update relative_position: 6000 - item2.update relative_position: 10000 + item1.update! relative_position: 6000 + item2.update! relative_position: 10000 new_item.move_between(item1, item2) @@ -196,7 +636,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do end it 'positions item closer to the middle if we are at the very top' do - item2.update relative_position: 6000 + item1.update!(relative_position: 6001) + item2.update!(relative_position: 6000) new_item.move_between(nil, item2) @@ -204,51 +645,53 @@ RSpec.shared_examples 'a class that supports relative positioning' do end it 'positions item closer to the middle if we are at the very bottom' do - new_item.update relative_position: 1 - item1.update relative_position: 6000 - item2.destroy + new_item.update!(relative_position: 1) + item1.update!(relative_position: 6000) + item2.update!(relative_position: 5999) new_item.move_between(item1, nil) expect(new_item.relative_position).to eq(6000 + RelativePositioning::IDEAL_DISTANCE) end - it 'positions item in the middle of other two if distance is not big enough' do - item1.update relative_position: 100 - item2.update relative_position: 400 + it 'positions item in the middle of other two' do + item1.update! relative_position: 100 + item2.update! relative_position: 400 new_item.move_between(item1, item2) expect(new_item.relative_position).to eq(250) end - it 'positions item in the middle of other two is there is no place' do - item1.update relative_position: 100 - item2.update relative_position: 101 + context 'there is no space' do + let(:middle) { new_item } + let(:left) { create_item(relative_position: 100) } + let(:right) { create_item(relative_position: 101) } - new_item.move_between(item1, item2) - - expect(new_item.relative_position).to be_between(item1.relative_position, item2.relative_position).exclusive + it_behaves_like 'moves item between' end - it 'uses rebalancing if there is no place' do - item1.update relative_position: 100 - item2.update relative_position: 101 - item3 = create_item(relative_position: 102) - new_item.update relative_position: 103 + context 'there is a bunch of items' do + let(:items) { create_items_with_positions(100..104) } + let(:left) { items[1] } + let(:middle) { items[3] } + let(:right) { items[2] } - new_item.move_between(item2, item3) - new_item.save! + it_behaves_like 'moves item between' + + it 'handles bunches correctly' do + middle.move_between(left, right) + middle.save! - expect(new_item.relative_position).to be_between(item2.relative_position, item3.relative_position).exclusive - expect(item1.reload.relative_position).not_to eq(100) + expect(items.first.reset.relative_position).to be < middle.relative_position + end end - it 'positions item right if we pass none-sequential parameters' do - item1.update relative_position: 99 - item2.update relative_position: 101 + it 'positions item right if we pass non-sequential parameters' do + item1.update! relative_position: 99 + item2.update! relative_position: 101 item3 = create_item(relative_position: 102) - new_item.update relative_position: 103 + new_item.update! relative_position: 103 new_item.move_between(item1, item3) new_item.save! @@ -280,6 +723,12 @@ RSpec.shared_examples 'a class that supports relative positioning' do expect(positions).to eq([90, 95, 96, 102]) end + it 'raises an error if there is no space' do + items = create_items_with_positions(run_at_start) + + expect { items.last.move_sequence_before }.to raise_error(RelativePositioning::NoSpaceLeft) + end + it 'finds a gap if there are unused positions' do items = create_items_with_positions([100, 101, 102]) @@ -287,7 +736,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do items.last.save! positions = items.map { |item| item.reload.relative_position } - expect(positions).to eq([50, 51, 102]) + + expect(positions.last - positions.second).to be > RelativePositioning::MIN_GAP end end @@ -309,7 +759,33 @@ RSpec.shared_examples 'a class that supports relative positioning' do items.first.save! positions = items.map { |item| item.reload.relative_position } - expect(positions).to eq([100, 601, 602]) + expect(positions.second - positions.first).to be > RelativePositioning::MIN_GAP end + + it 'raises an error if there is no space' do + items = create_items_with_positions(run_at_end) + + expect { items.first.move_sequence_after }.to raise_error(RelativePositioning::NoSpaceLeft) + end + end + + def be_valid_position + be_between(RelativePositioning::MIN_POSITION, RelativePositioning::MAX_POSITION) + end + + def between_exclusive?(left, middle, right) + a, b, c = [left, middle, right].map { |item| item.reset.relative_position } + return false if a.nil? || b.nil? + return a < b if c.nil? + + a < b && b < c + end + + def run_at_end(size = 3) + (RelativePositioning::MAX_POSITION - size)..RelativePositioning::MAX_POSITION + end + + def run_at_start(size = 3) + (RelativePositioning::MIN_POSITION..).take(size) end end |