822 lines
26 KiB
Ruby
822 lines
26 KiB
Ruby
# 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
|