require 'minitest/autorun' require 'prism' require 'tmpdir' require 'fileutils' class OrmListPositionTest < Minitest::Test def setup @dir = Dir.mktmpdir('prism-list-position-store') @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) @store.create_table(:tasks, %w[id title position deleted_at]) @store.create_table(:cards, %w[id title position board_id]) task_class = Class.new(Prism::Orm::Model) do table_name :tasks acts_as_paranoid acts_as_list column: :position end card_class = Class.new(Prism::Orm::Model) do table_name :cards acts_as_list column: :position, scope: :board_id end plain_class = Class.new(Prism::Orm::Model) { table_name :tasks } [task_class, card_class].each { |klass| klass.store = @store } plain_class.store = @store Object.const_set(:Task, task_class) Object.const_set(:Card, card_class) Object.const_set(:PlainTask, plain_class) end def teardown FileUtils.remove_entry(@dir) %i[Task Card PlainTask].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } end def test_a_model_that_never_called_acts_as_list_is_unaffected refute PlainTask.acts_as_list? assert Task.acts_as_list? end def test_fresh_records_are_appended_to_the_bottom_in_creation_order a = Task.create!(title: 'a') b = Task.create!(title: 'b') c = Task.create!(title: 'c') assert_equal [1, 2, 3], [a, b, c].map(&:list_position) end def test_move_higher_swaps_with_the_previous_item_and_is_a_noop_at_the_top a = Task.create!(title: 'a') b = Task.create!(title: 'b') b.move_higher assert_equal 1, b.list_position assert_equal 2, a.reload.list_position b.reload.move_higher # b is already first - nothing above it to swap with assert_equal 1, b.list_position assert_equal 2, a.reload.list_position end def test_move_lower_swaps_with_the_next_item_and_is_a_noop_at_the_bottom a = Task.create!(title: 'a') b = Task.create!(title: 'b') a.move_lower assert_equal 2, a.list_position assert_equal 1, b.reload.list_position b.reload.move_lower # b is now first (pos 1), a is last (pos 2) - moving b lower swaps back assert_equal 2, b.list_position assert_equal 1, a.reload.list_position end def test_move_lower_on_the_last_item_is_a_noop a = Task.create!(title: 'a') Task.create!(title: 'b') b2 = Task.find_by!(title: 'b') b2.move_lower assert_equal 2, b2.list_position assert_equal 1, a.reload.list_position end def test_move_higher_on_the_first_item_is_a_noop a = Task.create!(title: 'a') Task.create!(title: 'b') a.move_higher assert_equal 1, a.list_position end def test_first_and_last_in_list_predicates a = Task.create!(title: 'a') b = Task.create!(title: 'b') c = Task.create!(title: 'c') assert a.first_in_list? refute a.last_in_list? assert c.last_in_list? refute c.first_in_list? refute b.first_in_list? refute b.last_in_list? end def test_move_to_top_and_move_to_bottom_reposition_and_shift_everyone_between a = Task.create!(title: 'a') b = Task.create!(title: 'b') c = Task.create!(title: 'c') d = Task.create!(title: 'd') c.move_to_top assert_equal [1, 2, 3, 4], [c, a, b, d].map { |t| t.reload.list_position } a.reload.move_to_bottom assert_equal [1, 2, 3, 4], [c, b, d, a].map { |t| t.reload.list_position } end def test_insert_at_moves_an_item_to_an_arbitrary_middle_slot a = Task.create!(title: 'a') b = Task.create!(title: 'b') c = Task.create!(title: 'c') d = Task.create!(title: 'd') d.insert_at(2) assert_equal [1, 2, 3, 4], [a, d, b, c].map { |t| t.reload.list_position } end def test_insert_at_on_a_record_that_is_not_persisted_raises task = Task.new(title: 'unsaved') assert_raises(Prism::Orm::RecordNotFound) { task.insert_at(1) } end def test_destroying_an_item_closes_the_gap_for_everyone_below_it a = Task.create!(title: 'a') b = Task.create!(title: 'b') c = Task.create!(title: 'c') b.destroy assert_equal 1, a.reload.list_position assert_equal 2, c.reload.list_position end def test_soft_deleting_a_paranoid_item_also_closes_the_gap a = Task.create!(title: 'a') b = Task.create!(title: 'b') c = Task.create!(title: 'c') b.destroy # paranoid: soft-delete, not a real row removal assert b.deleted? assert_equal 1, a.reload.list_position assert_equal 2, c.reload.list_position end def test_restoring_a_soft_deleted_item_reappends_it_to_the_bottom_not_its_old_slot a = Task.create!(title: 'a') b = Task.create!(title: 'b') c = Task.create!(title: 'c') b.destroy d = Task.create!(title: 'd') assert_equal [1, 2, 3], [a.reload, c.reload, d.reload].map(&:list_position) restored = Task.only_deleted.find_by!(title: 'b') restored.restore assert_equal 4, restored.list_position # nobody else collides with the reappended slot assert_equal [1, 2, 3, 4], [a.reload, c.reload, d.reload, restored.reload].map(&:list_position).sort end