# frozen_string_literal: true # Notes for implementing classes: # # The following let bindings should be defined: # - `factory`: A symbol naming a factory to use to create items # - `default_params`: A HashMap of factory parameters to pass to the factory. # # The `default_params` should include the relative parent, so that any item # created with these parameters passed to the `factory` will be considered in # the same set of items relative to each other. # # For the purposes of efficiency, it is a good idea to bind the parent in # `let_it_be`, so that it is re-used across examples, but be careful that it # does not have any other children - it should only be used within this set of # shared examples. RSpec.shared_examples 'a class that supports relative positioning' do let(:item1) { create_item } let(:item2) { create_item } let(:new_item) { create_item(relative_position: nil) } let(:set_size) { RelativePositioning.mover.context(item1).scoped_items.count } def create_item(params = {}) create(factory, params.merge(default_params)) end def create_items_with_positions(positions) positions.map do |position| create_item(relative_position: position) end end def as_item(item) item # Override to perform a transformation, if necessary end def as_items(items) items.map { |item| as_item(item) } end describe '#scoped_items' do it 'includes all items with the same scope' do scope = as_items([item1, item2, new_item, create_item]) irrelevant = create(factory, {}) # This should not share the scope context = RelativePositioning.mover.context(item1) same_scope = as_items(context.scoped_items) expect(same_scope).to include(*scope) expect(same_scope).not_to include(as_item(irrelevant)) end end describe '#relative_siblings' do it 'includes all items with the same scope, except self' do scope = as_items([item2, new_item, create_item]) irrelevant = create(factory, {}) # This should not share the scope context = RelativePositioning.mover.context(item1) siblings = as_items(context.relative_siblings) expect(siblings).to include(*scope) expect(siblings).not_to include(as_item(item1)) expect(siblings).not_to include(as_item(irrelevant)) end end describe '.move_nulls_to_end' do let(:item3) { create_item } let(:sibling_query) { item1.class.relative_positioning_query_base(item1) } 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(sibling_query.where(relative_position: nil)).not_to exist expect(as_items(sibling_query.reorder(:relative_position, :id))).to eq(as_items([item1, item2, item3])) 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(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.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(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 'manages to move nulls to the end even if there is not enough space' do run = run_at_end(20).to_a bunch_a = create_items_with_positions(run[0..18]) bunch_b = create_items_with_positions([run.last]) nils = create_items_with_positions([nil] * 4) described_class.move_nulls_to_end(nils) items = [*bunch_a, *bunch_b, *nils] items.each(&:reset) expect(items.map(&:relative_position)).to all(be_valid_position) expect(items.reverse.sort_by(&:relative_position)).to eq(items) end it 'manages to move nulls to the end, stacking if we cannot create enough space' do run = run_at_end(40).to_a bunch = create_items_with_positions(run.select(&:even?)) nils = create_items_with_positions([nil] * 20) described_class.move_nulls_to_end(nils) items = [*bunch, *nils] items.each(&:reset) expect(items.map(&:relative_position)).to all(be_valid_position) expect(bunch.reverse.sort_by(&:relative_position)).to eq(bunch) expect(nils.reverse.sort_by(&:relative_position)).not_to eq(nils) expect(bunch.map(&:relative_position)).to all(be < nils.map(&:relative_position).min) end it 'manages to move nulls found in the relative scope' do nils = create_items_with_positions([nil] * 4) described_class.move_nulls_to_end(sibling_query.to_a) positions = nils.map { |item| item.reset.relative_position } expect(positions).to all(be_present) expect(positions).to all(be_valid_position) end it 'can move many nulls' do nils = create_items_with_positions([nil] * 101) described_class.move_nulls_to_end(nils) expect(nils.map(&:relative_position)).to all(be_valid_position) end it 'does not have an N+1 issue' do create_items_with_positions(10..12) a, b, c, d, e, f, *xs = create_items_with_positions([nil] * 10) baseline = ActiveRecord::QueryRecorder.new do described_class.move_nulls_to_end([a, b]) end expect { described_class.move_nulls_to_end([c, d, e, f]) } .not_to exceed_query_limit(baseline) expect { described_class.move_nulls_to_end(xs) } .not_to exceed_query_limit(baseline.count) end end describe '.move_nulls_to_start' do let(:item3) { create_item } let(:sibling_query) { item1.class.relative_positioning_query_base(item1) } 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(sibling_query.where(relative_position: nil)).not_to exist expect(as_items(sibling_query.reorder(:relative_position, :id))).to eq(as_items(items)) 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 it 'manages to move nulls to the start even if there is not enough space' do run = run_at_start(20).to_a bunch_a = create_items_with_positions([run.first]) bunch_b = create_items_with_positions(run[2..]) nils = create_items_with_positions([nil, nil, nil, nil]) described_class.move_nulls_to_start(nils) items = [*nils, *bunch_a, *bunch_b] items.each(&:reset) expect(items.map(&:relative_position)).to all(be_valid_position) expect(items.reverse.sort_by(&:relative_position)).to eq(items) end it 'manages to move nulls to the end, stacking if we cannot create enough space' do run = run_at_start(40).to_a bunch = create_items_with_positions(run.select(&:even?)) nils = create_items_with_positions([nil].cycle.take(20)) described_class.move_nulls_to_start(nils) items = [*nils, *bunch] items.each(&:reset) expect(items.map(&:relative_position)).to all(be_valid_position) expect(bunch.reverse.sort_by(&:relative_position)).to eq(bunch) expect(nils.reverse.sort_by(&:relative_position)).not_to eq(nils) expect(bunch.map(&:relative_position)).to all(be > nils.map(&:relative_position).max) end end describe '#move_before' do let(:item3) { create(factory, default_params) } it 'moves item before' do [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) expect(item1.relative_position).to be < item2.relative_position end context 'when there is no space' do before do item1.update!(relative_position: 1000) item2.update!(relative_position: 1001) item3.update!(relative_position: 1002) end it 'moves items correctly' do item3.move_before(item2) 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 let(:item3) { create(factory, default_params) } let(:start) { RelativePositioning::START_POSITION } before do 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 def leap_frog a, b = [item1.reset, item2.reset].sort_by(&:relative_position) b.move_before(a) b.save! end it 'can leap-frog STEPS times before needing to rebalance' do expect { RelativePositioning::STEPS.times { leap_frog } } .to change { item3.reload.relative_position }.by(0) .and change { item1.reload.relative_position }.by(be < 0) .and change { item2.reload.relative_position }.by(be < 0) expect { leap_frog } .to change { item3.reload.relative_position }.by(be < 0) end context 'there is no space to the left after moving STEPS times' do let(:start) { RelativePositioning::MIN_POSITION + (2 * RelativePositioning::IDEAL_DISTANCE) } it 'rebalances to the right' do expect { RelativePositioning::STEPS.succ.times { leap_frog } } .not_to change { item3.reload.relative_position } end end end end describe '#move_after' do it 'moves item after' do [item1, item2].each(&:move_to_end) item1.move_after(item2) expect(item1.relative_position).to be > item2.relative_position 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) 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 item1.move_after(item2) 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 a, b = [item1.reset, item2.reset].sort_by(&:relative_position) a.move_after(b) a.save! end it 'rebalances after STEPS jumps' do RelativePositioning::STEPS.pred.times do expect { leap_frog } .to change { item3.reload.relative_position }.by(0) .and change { item1.reset.relative_position }.by(be >= 0) .and change { item2.reset.relative_position }.by(be >= 0) end expect { leap_frog } .to change { item3.reload.relative_position }.by(0) .and change { item1.reset.relative_position }.by(be < 0) .and change { item2.reset.relative_position }.by(be < 0) end end end describe '#move_to_start' do before do [item1, item2].each do |item1| item1.move_to_start && item1.save! end end it 'places items at most IDEAL_DISTANCE from the start when the range is open' do n = set_size expect([item1, item2].map(&:relative_position)).to all(be >= (RelativePositioning::START_POSITION - (n * RelativePositioning::IDEAL_DISTANCE))) 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 'positions the item at MIN_POSITION when there is only one space left' do item2.update!(relative_position: RelativePositioning::MIN_POSITION + 1) new_item.move_to_start expect(new_item.relative_position).to eq RelativePositioning::MIN_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! end end it 'places items at most IDEAL_DISTANCE from the start when the range is open' do n = set_size expect([item1, item2].map(&:relative_position)).to all(be <= (RelativePositioning::START_POSITION + (n * RelativePositioning::IDEAL_DISTANCE))) end it 'moves item to the end' do new_item.move_to_end expect(new_item.relative_position).to be > item2.relative_position end it 'positions the item at MAX_POSITION when there is only one space left' do item2.update!(relative_position: RelativePositioning::MAX_POSITION - 1) new_item.move_to_end expect(new_item.relative_position).to eq RelativePositioning::MAX_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 |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 it 'positions item between two other' do new_item.move_between(item1, item2) expect(new_item.relative_position).to be > item1.relative_position expect(new_item.relative_position).to be < item2.relative_position end it 'positions item between on top' do new_item.move_between(nil, item1) expect(new_item.relative_position).to be < item1.relative_position end it 'positions item between to end' do new_item.move_between(item2, nil) expect(new_item.relative_position).to be > item2.relative_position end it 'positions items even when after and before positions are the same' do 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 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) } 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 new_item.move_between(item1, item2) expect(new_item.relative_position).to eq(8000) end it 'positions item closer to the middle if we are at the very top' do item1.update!(relative_position: 6001) item2.update!(relative_position: 6000) new_item.move_between(nil, item2) expect(new_item.relative_position).to eq(6000 - RelativePositioning::IDEAL_DISTANCE) 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.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' 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 context 'there is no space' do let(:middle) { new_item } let(:left) { create_item(relative_position: 100) } let(:right) { create_item(relative_position: 101) } it_behaves_like 'moves item between' end 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] } it_behaves_like 'moves item between' it 'handles bunches correctly' do middle.move_between(left, right) middle.save! expect(items.first.reset.relative_position).to be < middle.relative_position end end 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.move_between(item1, item3) new_item.save! expect(new_item.relative_position).to be(100) end it 'avoids N+1 queries when rebalancing other items' do items = create_items_with_positions([100, 101, 102]) count = ActiveRecord::QueryRecorder.new do new_item.move_between(items[-2], items[-1]) end items = create_items_with_positions([150, 151, 152, 153, 154]) expect { new_item.move_between(items[-2], items[-1]) }.not_to exceed_query_limit(count) 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 RSpec.shared_examples 'no-op relative positioning' do def create_item(**params) create(factory, params.merge(default_params)) end let_it_be(:item1) { create_item } let_it_be(:item2) { create_item } let_it_be(:new_item) { create_item(relative_position: nil) } def any_relative_positions new_item.class.reorder(:relative_position, :id).pluck(:id, :relative_position) end shared_examples 'a no-op method' do it 'does not raise errors' do expect { perform }.not_to raise_error end it 'does not perform any DB queries' do expect { perform }.not_to exceed_query_limit(0) end it 'does not change any relative_position' do expect { perform }.not_to change { any_relative_positions } end end describe '.scoped_items' do subject { RelativePositioning.mover.context(item1).scoped_items } it 'is empty' do expect(subject).to be_empty end end describe '.relative_siblings' do subject { RelativePositioning.mover.context(item1).relative_siblings } it 'is empty' do expect(subject).to be_empty end end describe '.move_nulls_to_end' do subject { item1.class.move_nulls_to_end([new_item, item1]) } it_behaves_like 'a no-op method' do def perform subject end end it 'does not move any items' do expect(subject).to eq(0) end end describe '.move_nulls_to_start' do subject { item1.class.move_nulls_to_start([new_item, item1]) } it_behaves_like 'a no-op method' do def perform subject end end it 'does not move any items' do expect(subject).to eq(0) end end describe 'instance methods' do subject { new_item } describe '#move_to_start' do it_behaves_like 'a no-op method' do def perform subject.move_to_start end end end describe '#move_to_end' do it_behaves_like 'a no-op method' do def perform subject.move_to_end end end end describe '#move_between' do it_behaves_like 'a no-op method' do def perform subject.move_between(item1, item2) end end end describe '#move_before' do it_behaves_like 'a no-op method' do def perform subject.move_before(item1) end end end describe '#move_after' do it_behaves_like 'a no-op method' do def perform subject.move_after(item1) end end end end end