gitlab-org--gitlab-foss/spec/support/shared_examples/models/relative_positioning_shared...

823 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