diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83926a4c..f336825b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,16 +38,22 @@ jobs: fail-fast: false matrix: ruby: - - '3.3' + - '3.4' + - 'jruby' + - 'truffleruby' rails: - - activerecord_8.0 - - activerecord_7.2 - - activerecord_7.1 - - activerecord_edge - adapter: - - 'sqlite3:///:memory:' - - mysql2://root:root@0/closure_tree_test - - postgres://closure_tree:closure_tree@0/closure_tree_test + - '8.0' + - '7.2' + - '7.1' + exclude: + # JRuby doesn't support Rails 8.0 yet + - ruby: 'jruby' + rails: '7.2' + - ruby: 'jruby' + rails: '8.0' + # TruffleRuby also has compatibility issues with Rails 8.0 + - ruby: 'truffleruby' + rails: '8.0' steps: - name: Checkout @@ -60,14 +66,26 @@ jobs: bundler-cache: true rubygems: latest env: - BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile + RAILS_VERSION: ${{ matrix.rails }} RAILS_ENV: test + - name: Setup databases + env: + RAILS_ENV: test + RAILS_VERSION: ${{ matrix.rails }} + DATABASE_URL_PG: postgres://postgres:postgres@127.0.0.1:5432/closure_tree_test + DATABASE_URL_MYSQL: mysql2://root:root@127.0.0.1:3306/closure_tree_test + DATABASE_URL_SQLITE3: 'sqlite3::memory:' + run: | + cd test/dummy + bundle exec rails db:schema:load + - name: Test env: RAILS_ENV: test RAILS_VERSION: ${{ matrix.rails }} - DB_ADAPTER: ${{ matrix.adapter }} - BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile + DATABASE_URL_PG: postgres://postgres:postgres@127.0.0.1:5432/closure_tree_test + DATABASE_URL_MYSQL: mysql2://root:root@127.0.0.1:3306/closure_tree_test + DATABASE_URL_SQLITE3: 'sqlite3::memory:' WITH_ADVISORY_LOCK_PREFIX: ${{ github.run_id }} - run: bin/rake + run: bin/rails test diff --git a/.github/workflows/ci_jruby.yml b/.github/workflows/ci_jruby.yml deleted file mode 100644 index b455b15c..00000000 --- a/.github/workflows/ci_jruby.yml +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: CI Jruby - -on: - push: - branches: - - master - pull_request: - branches: - - master -concurrency: - group: ci-${{ github.head_ref }}-jruby - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - services: - mysql: - image: mysql/mysql-server - ports: - - "3306:3306" - env: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: closure_tree_test - MYSQL_ROOT_HOST: '%' - postgres: - image: 'postgres' - ports: ['5432:5432'] - env: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: closure_tree_test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - strategy: - fail-fast: false - matrix: - rails: - - activerecord_7.1 - adapter: - - 'sqlite3:///:memory:' - - mysql2://root:root@0/closure_tree_test - - postgres://closure_tree:closure_tree@0/closure_tree_test - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: jruby - bundler-cache: true - rubygems: latest - env: - BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile - RAILS_ENV: test - - - name: Test - env: - RAILS_ENV: test - RAILS_VERSION: ${{ matrix.rails }} - DB_ADAPTER: ${{ matrix.adapter }} - BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile - WITH_ADVISORY_LOCK_PREFIX: ${{ github.run_id }} - run: bin/rake diff --git a/.github/workflows/ci_truffleruby.yml b/.github/workflows/ci_truffleruby.yml deleted file mode 100644 index 6bfaf2bf..00000000 --- a/.github/workflows/ci_truffleruby.yml +++ /dev/null @@ -1,72 +0,0 @@ ---- -name: CI Truffleruby - -on: - push: - branches: - - master - pull_request: - branches: - - master -concurrency: - group: ci-${{ github.head_ref }}-truffleruby - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - services: - mysql: - image: mysql/mysql-server - ports: - - "3306:3306" - env: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: closure_tree_test - MYSQL_ROOT_HOST: '%' - postgres: - image: 'postgres' - ports: ['5432:5432'] - env: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: closure_tree_test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - strategy: - fail-fast: false - matrix: - ruby: - - truffleruby - rails: - - activerecord_7.1 - adapter: - - 'sqlite3:///:memory:' - - mysql2://root:root@0/closure_tree_test - - postgres://closure_tree:closure_tree@0/closure_tree_test - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: truffleruby - bundler-cache: true - rubygems: latest - env: - BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile - RAILS_ENV: test - - - name: Test - env: - RAILS_ENV: test - RAILS_VERSION: ${{ matrix.rails }} - DB_ADAPTER: ${{ matrix.adapter }} - BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile - WITH_ADVISORY_LOCK_PREFIX: ${{ github.run_id }} - run: bin/rake diff --git a/Appraisals b/Appraisals deleted file mode 100644 index 0d4cb8e4..00000000 --- a/Appraisals +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -appraise 'activerecord-7.1' do - gem 'activerecord', '~> 7.1.0' - gem 'railties' - - platforms :ruby, :truffleruby do - gem 'mysql2' - gem 'pg' - gem 'sqlite3', '< 2.0' - end - - platforms :jruby do - gem 'activerecord-jdbcmysql-adapter' - gem 'activerecord-jdbcpostgresql-adapter' - gem 'activerecord-jdbcsqlite3-adapter' - end -end - -appraise 'activerecord-7.2' do - gem 'activerecord', '~> 7.2.0' - gem 'railties' - - platforms :ruby do - gem 'mysql2' - gem 'pg' - gem 'sqlite3' - end -end - -appraise 'activerecord-8.0' do - gem 'activerecord', '~> 8.0.0' - gem 'railties' - - platforms :ruby do - gem 'mysql2' - gem 'pg' - gem 'sqlite3' - end -end - -appraise 'activerecord-edge' do - gem 'activerecord', github: 'rails/rails' - gem 'railties', github: 'rails/rails' - - platforms :ruby do - gem 'mysql2' - gem 'pg' - gem 'sqlite3' - end -end diff --git a/Gemfile b/Gemfile index 8753f543..9229397b 100644 --- a/Gemfile +++ b/Gemfile @@ -4,4 +4,21 @@ source 'https://rubygems.org' gemspec -gem 'with_advisory_lock', github: 'closuretree/with_advisory_lock' \ No newline at end of file +gem 'railties' +gem 'with_advisory_lock', github: 'closuretree/with_advisory_lock' + +gem 'activerecord', "~> #{ENV['RAILS_VERSION'] || '8.0'}.0" + +platforms :ruby, :truffleruby do + # Database adapters + gem 'mysql2' + gem 'pg' + gem 'sqlite3' +end + +platform :jruby do + # JRuby-specific gems + gem 'activerecord-jdbcmysql-adapter' + gem 'activerecord-jdbcpostgresql-adapter' + gem 'activerecord-jdbcsqlite3-adapter' +end diff --git a/Rakefile b/Rakefile index 2c9ce43c..8bccd833 100644 --- a/Rakefile +++ b/Rakefile @@ -23,3 +23,8 @@ namespace :test do end end end + + +require_relative 'test/dummy/config/application' + +Rails.application.load_tasks \ No newline at end of file diff --git a/bin/rails b/bin/rails new file mode 100755 index 00000000..69fb1dc2 --- /dev/null +++ b/bin/rails @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# This command will automatically be run when you run "rails" with Rails gems +# installed from the root of your application. + +ENGINE_ROOT = File.expand_path('..', __dir__) +APP_PATH = File.expand_path('../test/dummy/config/application', __dir__) + +# Set up gems listed in the Gemfile. +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) +require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) + +require 'rails/all' +require 'rails/engine/commands' \ No newline at end of file diff --git a/closure_tree.gemspec b/closure_tree.gemspec index d014d301..33b73782 100644 --- a/closure_tree.gemspec +++ b/closure_tree.gemspec @@ -30,7 +30,6 @@ Gem::Specification.new do |gem| gem.add_runtime_dependency 'activerecord', '>= 7.1.0' gem.add_runtime_dependency 'with_advisory_lock', '>= 6.0.0' - gem.add_development_dependency 'appraisal' gem.add_development_dependency 'database_cleaner' gem.add_development_dependency 'parallel' gem.add_development_dependency 'minitest' diff --git a/gemfiles/activerecord_7.1.gemfile b/gemfiles/activerecord_7.1.gemfile deleted file mode 100644 index 433f5e74..00000000 --- a/gemfiles/activerecord_7.1.gemfile +++ /dev/null @@ -1,21 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "with_advisory_lock", github: "closuretree/with_advisory_lock" -gem "activerecord", "~> 7.1.0" -gem "railties" - -platforms :ruby, :truffleruby do - gem "mysql2" - gem "pg" - gem "sqlite3", "< 2.0" -end - -platforms :jruby do - gem "activerecord-jdbcmysql-adapter" - gem "activerecord-jdbcpostgresql-adapter" - gem "activerecord-jdbcsqlite3-adapter" -end - -gemspec path: "../" diff --git a/gemfiles/activerecord_7.2.gemfile b/gemfiles/activerecord_7.2.gemfile deleted file mode 100644 index db97d653..00000000 --- a/gemfiles/activerecord_7.2.gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "with_advisory_lock", github: "closuretree/with_advisory_lock" -gem "activerecord", "~> 7.2.0" -gem "railties" - -platforms :ruby do - gem "mysql2" - gem "pg" - gem "sqlite3" -end - -gemspec path: "../" diff --git a/gemfiles/activerecord_8.0.gemfile b/gemfiles/activerecord_8.0.gemfile deleted file mode 100644 index a2a4411a..00000000 --- a/gemfiles/activerecord_8.0.gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "with_advisory_lock", github: "closuretree/with_advisory_lock" -gem "activerecord", "~> 8.0.0" -gem "railties" - -platforms :ruby do - gem "mysql2" - gem "pg" - gem "sqlite3" -end - -gemspec path: "../" diff --git a/gemfiles/activerecord_edge.gemfile b/gemfiles/activerecord_edge.gemfile deleted file mode 100644 index c30e4bfc..00000000 --- a/gemfiles/activerecord_edge.gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "with_advisory_lock", github: "closuretree/with_advisory_lock" -gem "activerecord", github: "rails/rails" -gem "railties", github: "rails/rails" - -platforms :ruby do - gem "mysql2" - gem "pg" - gem "sqlite3" -end - -gemspec path: "../" diff --git a/test/closure_tree/label_order_value_test.rb b/test/closure_tree/label_order_value_test.rb new file mode 100644 index 00000000..88d5cf2b --- /dev/null +++ b/test/closure_tree/label_order_value_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "test_helper" + +class LabelOrderValueTest < ActiveSupport::TestCase + def setup + Label.delete_all + LabelHierarchy.delete_all + end + + test "should set order_value on roots for Label" do + root = Label.create(name: "root") + assert_equal 0, root.order_value + end + + test "should set order_value with siblings for Label" do + root = Label.create(name: "root") + a = root.children.create(name: "a") + b = root.children.create(name: "b") + c = root.children.create(name: "c") + + assert_equal 0, a.order_value + assert_equal 1, b.order_value + assert_equal 2, c.order_value + end + + test "should reset order_value when a node is moved to another location for Label" do + root = Label.create(name: "root") + a = root.children.create(name: "a") + b = root.children.create(name: "b") + c = root.children.create(name: "c") + + root2 = Label.create(name: "root2") + root2.add_child b + + assert_equal 0, a.order_value + assert_equal 0, b.order_value + assert_equal 1, c.reload.order_value + end + + test "should set order_value on roots for LabelWithoutRootOrdering" do + root = LabelWithoutRootOrdering.create(name: "root") + assert_nil root.order_value + end + + test "should set order_value with siblings for LabelWithoutRootOrdering" do + root = LabelWithoutRootOrdering.create(name: "root") + a = root.children.create(name: "a") + b = root.children.create(name: "b") + c = root.children.create(name: "c") + + assert_equal 0, a.order_value + assert_equal 1, b.order_value + assert_equal 2, c.order_value + end +end \ No newline at end of file diff --git a/test/closure_tree/label_test.rb b/test/closure_tree/label_test.rb index 3f36dc3d..3b28150d 100644 --- a/test/closure_tree/label_test.rb +++ b/test/closure_tree/label_test.rb @@ -3,10 +3,14 @@ require "test_helper" module CorrectOrderValue - def self.shared_examples(&block) + def self.shared_examples(model, expected_root_order_value) describe "correct order_value" do before do - instance_exec(&block) + @model = model + @expected_root_order_value = expected_root_order_value + # Clean up any existing data + @model.delete_all + @model.hierarchy_class.delete_all @root = @model.create(name: "root") @a, @b, @c = %w[a b c].map { |n| @root.children.create(name: n) } end @@ -111,6 +115,11 @@ def create_preorder_tree(suffix = "") end describe "roots" do + before do + Label.delete_all + Label.hierarchy_class.delete_all + end + it "sorts alphabetically" do expected = (0..10).to_a expected.shuffle.each do |ea| @@ -471,17 +480,11 @@ def roots_name_and_order describe "order_value must be set" do describe "with normal model" do - CorrectOrderValue.shared_examples do - @model = Label - @expected_root_order_value = 0 - end + CorrectOrderValue.shared_examples(Label, 0) end describe "without root ordering" do - CorrectOrderValue.shared_examples do - @model = LabelWithoutRootOrdering - @expected_root_order_value = nil - end + CorrectOrderValue.shared_examples(LabelWithoutRootOrdering, nil) end end diff --git a/test/closure_tree/matcher_test.rb b/test/closure_tree/matcher_test.rb index ec11ea71..98ad681b 100644 --- a/test/closure_tree/matcher_test.rb +++ b/test/closure_tree/matcher_test.rb @@ -13,7 +13,7 @@ class MatcherTest < ActiveSupport::TestCase end test "be_a_closure_tree matcher" do - assert_closure_tree UUIDTag + assert_closure_tree UuidTag assert_closure_tree User assert_closure_tree Label, ordered: true assert_closure_tree Metal, ordered: :sort_order @@ -23,7 +23,7 @@ class MatcherTest < ActiveSupport::TestCase test "ordered option" do assert_closure_tree Label, ordered: true - assert_closure_tree UUIDTag, ordered: true + assert_closure_tree UuidTag, ordered: true assert_closure_tree Metal, ordered: :sort_order end diff --git a/test/closure_tree/tag_test.rb b/test/closure_tree/tag_test.rb index 62fde579..fc9a322e 100644 --- a/test/closure_tree/tag_test.rb +++ b/test/closure_tree/tag_test.rb @@ -3,6 +3,7 @@ require 'test_helper' require 'support/tag_examples' -describe Tag do +class TagTest < ActiveSupport::TestCase + TAG_CLASS = Tag include TagExamples -end +end \ No newline at end of file diff --git a/test/closure_tree/user_test.rb b/test/closure_tree/user_test.rb index 5522cf17..842b8e43 100644 --- a/test/closure_tree/user_test.rb +++ b/test/closure_tree/user_test.rb @@ -3,6 +3,11 @@ require "test_helper" describe "empty db" do + before do + User.delete_all + User.hierarchy_class.delete_all + end + describe "empty db" do it "should return no entities" do assert User.roots.empty? @@ -79,8 +84,8 @@ end def assert_mid_and_leaf_remain - assert ReferralHierarchy.where(ancestor_id: @root_id).empty? - assert ReferralHierarchy.where(descendant_id: @root_id).empty? + assert User.hierarchy_class.where(ancestor_id: @root_id).empty? + assert User.hierarchy_class.where(descendant_id: @root_id).empty? assert_equal %w[matt@t.co], @mid.ancestry_path assert_equal %w[matt@t.co james@t.co], @leaf.ancestry_path assert_equal [@mid, @leaf].sort, @mid.self_and_descendants.to_a.sort diff --git a/test/closure_tree/uuid_tag_test.rb b/test/closure_tree/uuid_tag_test.rb index e2f2f276..d1c6b4bc 100644 --- a/test/closure_tree/uuid_tag_test.rb +++ b/test/closure_tree/uuid_tag_test.rb @@ -3,6 +3,7 @@ require 'test_helper' require 'support/tag_examples' -describe UUIDTag do +describe UuidTag do + TAG_CLASS = UuidTag include TagExamples end diff --git a/test/dummy/Rakefile b/test/dummy/Rakefile new file mode 100644 index 00000000..488c551f --- /dev/null +++ b/test/dummy/Rakefile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative 'config/application' + +Rails.application.load_tasks diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb new file mode 100644 index 00000000..1ff0944d --- /dev/null +++ b/test/dummy/app/controllers/application_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::Base + # Prevent CSRF attacks by raising an exception. + # For APIs, you may want to use :null_session instead. + protect_from_forgery with: :exception +end diff --git a/test/dummy/app/models/adamantium.rb b/test/dummy/app/models/adamantium.rb new file mode 100644 index 00000000..73c24b74 --- /dev/null +++ b/test/dummy/app/models/adamantium.rb @@ -0,0 +1,2 @@ +class Adamantium < Metal +end diff --git a/test/dummy/app/models/application_record.rb b/test/dummy/app/models/application_record.rb new file mode 100644 index 00000000..848e8e7b --- /dev/null +++ b/test/dummy/app/models/application_record.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true + establish_connection(:primary) +end diff --git a/test/dummy/app/models/contract.rb b/test/dummy/app/models/contract.rb new file mode 100644 index 00000000..fa8f0b53 --- /dev/null +++ b/test/dummy/app/models/contract.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Contract < ApplicationRecord + belongs_to :user, inverse_of: :contracts + belongs_to :contract_type, inverse_of: :contracts, optional: true +end diff --git a/test/dummy/app/models/contract_type.rb b/test/dummy/app/models/contract_type.rb new file mode 100644 index 00000000..a33e93ac --- /dev/null +++ b/test/dummy/app/models/contract_type.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ContractType < ApplicationRecord + has_many :contracts, inverse_of: :contract_type +end diff --git a/test/dummy/app/models/cuisine_type.rb b/test/dummy/app/models/cuisine_type.rb new file mode 100644 index 00000000..bb055485 --- /dev/null +++ b/test/dummy/app/models/cuisine_type.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class CuisineType < ApplicationRecord + acts_as_tree +end diff --git a/test/dummy/app/models/date_label.rb b/test/dummy/app/models/date_label.rb new file mode 100644 index 00000000..76c4d414 --- /dev/null +++ b/test/dummy/app/models/date_label.rb @@ -0,0 +1,2 @@ +class DateLabel < Label +end diff --git a/test/dummy/app/models/destroyed_tag.rb b/test/dummy/app/models/destroyed_tag.rb new file mode 100644 index 00000000..3388efa0 --- /dev/null +++ b/test/dummy/app/models/destroyed_tag.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class DestroyedTag < ApplicationRecord +end diff --git a/test/dummy/app/models/directory_label.rb b/test/dummy/app/models/directory_label.rb new file mode 100644 index 00000000..9b49c5e1 --- /dev/null +++ b/test/dummy/app/models/directory_label.rb @@ -0,0 +1,2 @@ +class DirectoryLabel < Label +end diff --git a/test/dummy/app/models/event_label.rb b/test/dummy/app/models/event_label.rb new file mode 100644 index 00000000..9eb243a9 --- /dev/null +++ b/test/dummy/app/models/event_label.rb @@ -0,0 +1,2 @@ +class EventLabel < Label +end diff --git a/test/dummy/app/models/group.rb b/test/dummy/app/models/group.rb new file mode 100644 index 00000000..ccf20c37 --- /dev/null +++ b/test/dummy/app/models/group.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Group < ApplicationRecord + has_closure_tree_root :root_user +end diff --git a/test/dummy/app/models/grouping.rb b/test/dummy/app/models/grouping.rb new file mode 100644 index 00000000..555f89de --- /dev/null +++ b/test/dummy/app/models/grouping.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Grouping < ApplicationRecord + has_closure_tree_root :root_person, class_name: 'User', foreign_key: :group_id +end diff --git a/test/dummy/app/models/label.rb b/test/dummy/app/models/label.rb new file mode 100644 index 00000000..a59f0c12 --- /dev/null +++ b/test/dummy/app/models/label.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Label < ApplicationRecord + # make sure order doesn't matter + acts_as_tree order: :column_whereby_ordering_is_inferred, # <- symbol, and not "sort_order" + numeric_order: true, + parent_column_name: 'mother_id', + dependent: :destroy + + def to_s + "#{self.class}: #{name}" + end +end diff --git a/test/dummy/app/models/label_without_root_ordering.rb b/test/dummy/app/models/label_without_root_ordering.rb new file mode 100644 index 00000000..eeba5261 --- /dev/null +++ b/test/dummy/app/models/label_without_root_ordering.rb @@ -0,0 +1,8 @@ +class LabelWithoutRootOrdering < ActiveRecord::Base + self.table_name = 'labels' + has_closure_tree parent_column_name: 'mother_id', + name_column: 'name', + order: 'column_whereby_ordering_is_inferred', + numeric_order: true, + dont_order_roots: true +end diff --git a/test/dummy/app/models/menu_item.rb b/test/dummy/app/models/menu_item.rb new file mode 100644 index 00000000..8dac6448 --- /dev/null +++ b/test/dummy/app/models/menu_item.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class MenuItem < ApplicationRecord + has_closure_tree touch: true, with_advisory_lock: false +end diff --git a/test/dummy/app/models/metal.rb b/test/dummy/app/models/metal.rb new file mode 100644 index 00000000..7d477f56 --- /dev/null +++ b/test/dummy/app/models/metal.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Metal < ApplicationRecord + self.table_name = "#{table_name_prefix}metal#{table_name_suffix}" + has_closure_tree order: 'sort_order', name_column: 'value' + self.inheritance_column = 'metal_type' +end diff --git a/test/dummy/app/models/namespace.rb b/test/dummy/app/models/namespace.rb new file mode 100644 index 00000000..b7c05d56 --- /dev/null +++ b/test/dummy/app/models/namespace.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Namespace + def self.table_name_prefix + 'namespace_' + end +end diff --git a/test/dummy/app/models/namespace/type.rb b/test/dummy/app/models/namespace/type.rb new file mode 100644 index 00000000..36b326a0 --- /dev/null +++ b/test/dummy/app/models/namespace/type.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Namespace + class Type < ApplicationRecord + has_closure_tree dependent: :destroy + end +end diff --git a/test/dummy/app/models/tag.rb b/test/dummy/app/models/tag.rb new file mode 100644 index 00000000..c59dea92 --- /dev/null +++ b/test/dummy/app/models/tag.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Tag < ApplicationRecord + has_closure_tree dependent: :destroy, order: :name + before_destroy :add_destroyed_tag + + def to_s + name + end + + def add_destroyed_tag + # Proof for the tests that the destroy rather than the delete method was called: + DestroyedTag.create(name: to_s) + end +end diff --git a/test/dummy/app/models/team.rb b/test/dummy/app/models/team.rb new file mode 100644 index 00000000..0a336efd --- /dev/null +++ b/test/dummy/app/models/team.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Team < ApplicationRecord + has_closure_tree_root :root_user, class_name: 'User', foreign_key: :grp_id +end diff --git a/test/dummy/app/models/unobtanium.rb b/test/dummy/app/models/unobtanium.rb new file mode 100644 index 00000000..1c94a900 --- /dev/null +++ b/test/dummy/app/models/unobtanium.rb @@ -0,0 +1,2 @@ +class Unobtanium < Metal +end diff --git a/test/dummy/app/models/user.rb b/test/dummy/app/models/user.rb new file mode 100644 index 00000000..6fda0b26 --- /dev/null +++ b/test/dummy/app/models/user.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class User < ApplicationRecord + acts_as_tree parent_column_name: 'referrer_id', + name_column: 'email', + hierarchy_class_name: 'ReferralHierarchy', + hierarchy_table_name: 'referral_hierarchies' + + has_many :contracts, inverse_of: :user + belongs_to :group, optional: true + + def indirect_contracts + Contract.where(user_id: descendant_ids) + end + + def to_s + email + end +end diff --git a/test/dummy/app/models/user_set.rb b/test/dummy/app/models/user_set.rb new file mode 100644 index 00000000..33720b8e --- /dev/null +++ b/test/dummy/app/models/user_set.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class UserSet < ApplicationRecord + has_closure_tree_root :root_user, class_name: 'User' +end diff --git a/test/dummy/app/models/uuid_tag.rb b/test/dummy/app/models/uuid_tag.rb new file mode 100644 index 00000000..246cfc58 --- /dev/null +++ b/test/dummy/app/models/uuid_tag.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class UuidTag < ApplicationRecord + self.primary_key = :uuid + before_create :set_uuid + has_closure_tree dependent: :destroy, order: 'name', parent_column_name: 'parent_uuid' + before_destroy :add_destroyed_tag + + def set_uuid + self.uuid = SecureRandom.uuid + end + + def to_s + name + end + + def add_destroyed_tag + # Proof for the tests that the destroy rather than the delete method was called: + DestroyedTag.create(name: to_s) + end +end diff --git a/test/dummy/config.ru b/test/dummy/config.ru new file mode 100644 index 00000000..5df2ee2f --- /dev/null +++ b/test/dummy/config.ru @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require_relative 'config/environment' + +run Rails.application +Rails.application.load_server diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb new file mode 100644 index 00000000..c5b54a9e --- /dev/null +++ b/test/dummy/config/application.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require File.expand_path('boot', __dir__) + +require 'rails' +require 'active_model/railtie' +require 'active_record/railtie' + +Bundler.require(*Rails.groups) +require 'closure_tree' + +module Dummy + class Application < Rails::Application + config.load_defaults [Rails::VERSION::MAJOR, Rails::VERSION::MINOR].join('.') + config.eager_load = false + + # Test environment settings + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.action_dispatch.show_exceptions = false + end +end diff --git a/test/dummy/config/boot.rb b/test/dummy/config/boot.rb new file mode 100644 index 00000000..50c2bdf4 --- /dev/null +++ b/test/dummy/config/boot.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) diff --git a/test/dummy/config/database.yml b/test/dummy/config/database.yml new file mode 100644 index 00000000..c769af29 --- /dev/null +++ b/test/dummy/config/database.yml @@ -0,0 +1,14 @@ +default: &default + pool: 50 + timeout: 5000 + +test: + primary: + <<: *default + url: "<%= ENV['DATABASE_URL_PG'] %>" + mysql: + <<: *default + url: "<%= ENV['DATABASE_URL_MYSQL'] %>" + sqlite: + <<: *default + url: "<%= ENV['DATABASE_URL_SQLITE3'] %>" \ No newline at end of file diff --git a/test/dummy/config/environment.rb b/test/dummy/config/environment.rb new file mode 100644 index 00000000..d5abe558 --- /dev/null +++ b/test/dummy/config/environment.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Load the Rails application. +require_relative 'application' + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/test/dummy/config/environments/test.rb b/test/dummy/config/environments/test.rb new file mode 100644 index 00000000..7191cbdf --- /dev/null +++ b/test/dummy/config/environments/test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.cache_classes = true + config.eager_load = false + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.action_dispatch.show_exceptions = false + config.active_support.deprecation = :stderr + config.active_support.test_order = :random + config.active_record.maintain_test_schema = false +end diff --git a/test/dummy/db/mysql_schema.rb b/test/dummy/db/mysql_schema.rb new file mode 100644 index 00000000..a887b1a1 --- /dev/null +++ b/test/dummy/db/mysql_schema.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +ActiveRecord::Schema.define(version: 1) do + create_table 'mysql_tags', force: true do |t| + t.string 'name' + end + + create_table 'mysql_tag_audits', id: false, force: true do |t| + t.string 'tag_name' + end + + create_table 'mysql_labels', id: false, force: true do |t| + t.string 'name' + end +end diff --git a/test/support/schema.rb b/test/dummy/db/schema.rb similarity index 94% rename from test/support/schema.rb rename to test/dummy/db/schema.rb index cca75c50..667ffee4 100644 --- a/test/support/schema.rb +++ b/test/dummy/db/schema.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +# frozen_string_literal: true + ActiveRecord::Schema.define(version: 1) do create_table 'tags' do |t| t.string 'name' @@ -35,7 +37,7 @@ end add_index 'tag_hierarchies', %i[ancestor_id descendant_id generations], unique: true, - name: 'tag_anc_desc_idx' + name: 'tag_anc_desc_idx' add_index 'tag_hierarchies', [:descendant_id], name: 'tag_desc_idx' create_table 'groups' do |t| @@ -139,10 +141,10 @@ end add_index 'label_hierarchies', %i[ancestor_id descendant_id generations], unique: true, - name: 'lh_anc_desc_idx' + name: 'lh_anc_desc_idx' add_index 'label_hierarchies', [:descendant_id], name: 'lh_desc_idx' add_index 'referral_hierarchies', %i[ancestor_id descendant_id generations], unique: true, - name: 'ref_anc_desc_idx' + name: 'ref_anc_desc_idx' add_index 'referral_hierarchies', [:descendant_id], name: 'ref_desc_idx' add_foreign_key(:tags, :tags, column: 'parent_id', on_delete: :cascade) diff --git a/test/support/models.rb b/test/support/models.rb deleted file mode 100644 index a49e0134..00000000 --- a/test/support/models.rb +++ /dev/null @@ -1,158 +0,0 @@ -# frozen_string_literal: true - -class ApplicationRecord < ActiveRecord::Base - self.abstract_class = true - - # connects_to database: { writing: :primary, reading: :primary } -end - -class SecondDatabaseRecord < ActiveRecord::Base - self.abstract_class = true - - # connects_to database: { writing: :secondary, reading: :secondary } -end -class Tag < ApplicationRecord - has_closure_tree dependent: :destroy, order: :name - before_destroy :add_destroyed_tag - - def to_s - name - end - - def add_destroyed_tag - # Proof for the tests that the destroy rather than the delete method was called: - DestroyedTag.create(name: to_s) - end -end - -class UUIDTag < ApplicationRecord - self.primary_key = :uuid - before_create :set_uuid - has_closure_tree dependent: :destroy, order: 'name', parent_column_name: 'parent_uuid' - before_destroy :add_destroyed_tag - - def set_uuid - self.uuid = SecureRandom.uuid - end - - def to_s - name - end - - def add_destroyed_tag - # Proof for the tests that the destroy rather than the delete method was called: - DestroyedTag.create(name: to_s) - end -end - -class DestroyedTag < ApplicationRecord -end - -class Group < ApplicationRecord - has_closure_tree_root :root_user -end - -class Grouping < ApplicationRecord - has_closure_tree_root :root_person, class_name: 'User', foreign_key: :group_id -end - -class UserSet < ApplicationRecord - has_closure_tree_root :root_user, class_name: 'Useur' -end - -class Team < ApplicationRecord - has_closure_tree_root :root_user, class_name: 'User', foreign_key: :grp_id -end - -class User < ApplicationRecord - acts_as_tree parent_column_name: 'referrer_id', - name_column: 'email', - hierarchy_class_name: 'ReferralHierarchy', - hierarchy_table_name: 'referral_hierarchies' - - has_many :contracts, inverse_of: :user - belongs_to :group # Can't use and don't need inverse_of here when using has_closure_tree_root. - - def indirect_contracts - Contract.where(user_id: descendant_ids) - end - - def to_s - email - end -end - -class Contract < ApplicationRecord - belongs_to :user, inverse_of: :contracts - belongs_to :contract_type, inverse_of: :contracts -end - -class ContractType < ApplicationRecord - has_many :contracts, inverse_of: :contract_type -end - -class Label < ApplicationRecord - # make sure order doesn't matter - acts_as_tree order: :column_whereby_ordering_is_inferred, # <- symbol, and not "sort_order" - numeric_order: true, - parent_column_name: 'mother_id', - dependent: :destroy - - def to_s - "#{self.class}: #{name}" - end -end - -class EventLabel < Label -end - -class DateLabel < Label -end - -class DirectoryLabel < Label -end - -class LabelWithoutRootOrdering < ApplicationRecord - # make sure order doesn't matter - acts_as_tree order: :column_whereby_ordering_is_inferred, # <- symbol, and not "sort_order" - numeric_order: true, - dont_order_roots: true, - parent_column_name: 'mother_id', - hierarchy_table_name: 'label_hierarchies' - - self.table_name = "#{table_name_prefix}labels#{table_name_suffix}" - - def to_s - "#{self.class}: #{name}" - end -end - -class CuisineType < ApplicationRecord - acts_as_tree -end - -module Namespace - def self.table_name_prefix - 'namespace_' - end - - class Type < ApplicationRecord - has_closure_tree dependent: :destroy - end -end - -class Metal < ApplicationRecord - self.table_name = "#{table_name_prefix}metal#{table_name_suffix}" - has_closure_tree order: 'sort_order', name_column: 'value' - self.inheritance_column = 'metal_type' -end - -class Adamantium < Metal -end - -class Unobtanium < Metal -end - -class MenuItem < SecondDatabaseRecord - has_closure_tree touch: true, with_advisory_lock: false -end diff --git a/test/support/tag_examples.rb b/test/support/tag_examples.rb index 74a312bc..7f578e39 100644 --- a/test/support/tag_examples.rb +++ b/test/support/tag_examples.rb @@ -1,287 +1,277 @@ # frozen_string_literal: true -module TagExamples - def self.included(mod) - @@described_class = mod.name.safe_constantize - end +require 'active_support/concern' - describe 'TagExamples' do - before do - @tag_class = @@described_class - @tag_hierarchy_class = @@described_class.hierarchy_class +module TagExamples + extend ActiveSupport::Concern + + included do + def setup + super + @tag_class = self.class.const_get(:TAG_CLASS) || Tag + @tag_hierarchy_class = @tag_class.hierarchy_class + # Clean up any existing data to ensure test isolation + @tag_class.delete_all + @tag_hierarchy_class.delete_all end - describe 'class setup' do - - it 'should build hierarchy classname correctly' do + define_method "test_should_build_hierarchy_classname_correctly" do assert_equal @tag_hierarchy_class, @tag_class.hierarchy_class assert_equal @tag_hierarchy_class.to_s, @tag_class._ct.hierarchy_class_name assert_equal @tag_hierarchy_class.to_s, @tag_class._ct.short_hierarchy_class_name end - it 'should have a correct parent column name' do - expected_parent_column_name = @tag_class == UUIDTag ? 'parent_uuid' : 'parent_id' + define_method "test_should_have_a_correct_parent_column_name" do + expected_parent_column_name = @tag_class == UuidTag ? 'parent_uuid' : 'parent_id' assert_equal expected_parent_column_name, @tag_class._ct.parent_column_name end - end - - describe 'from empty db' do - describe 'with no tags' do - it 'should return no entities' do - assert_empty @tag_class.roots - assert_empty @tag_class.leaves - end - - it '#find_or_create_by_path with strings' do - a = @tag_class.create!(name: 'a') - assert_equal(%w[a b c], a.find_or_create_by_path(%w[b c]).ancestry_path) - end - it '#find_or_create_by_path with hashes' do - a = @tag_class.create!(name: 'a', title: 'A') - subject = a.find_or_create_by_path([ - { name: 'b', title: 'B' }, - { name: 'c', title: 'C' } - ]) - assert_equal(%w[a b c], subject.ancestry_path) - assert_equal(%w[C B A], subject.self_and_ancestors.map(&:title)) - end + define_method "test_should_return_no_entities_when_db_is_empty" do + assert_empty @tag_class.roots + assert_empty @tag_class.leaves end - describe 'with 1 tag' do - before do - @tag = @tag_class.create!(name: 'tag') - end - - it 'should be a leaf' do - assert @tag.leaf? - end - - it 'should be a root' do - assert @tag.root? - end - - it 'has no parent' do - assert_nil @tag.parent - end - - it 'should return the only entity as a root and leaf' do - assert_equal [@tag], @tag_class.all - assert_equal [@tag], @tag_class.roots - assert_equal [@tag], @tag_class.leaves - end - - it 'should not be found by passing find_by_path an array of blank strings' do - assert_nil @tag_class.find_by_path(['']) - end - - it 'should not be found by passing find_by_path an empty array' do - assert_nil @tag_class.find_by_path([]) - end - - it 'should not be found by passing find_by_path nil' do - assert_nil @tag_class.find_by_path(nil) - end - - it 'should not be found by passing find_by_path an empty string' do - assert_nil @tag_class.find_by_path('') - end - - it 'should not be found by passing find_by_path an array of nils' do - assert_nil @tag_class.find_by_path([nil]) - end - - it 'should not be found by passing find_by_path an array with an additional blank string' do - assert_nil @tag_class.find_by_path([@tag.name, '']) - end - - it 'should not be found by passing find_by_path an array with an additional nil' do - assert_nil @tag_class.find_by_path([@tag.name, nil]) - end - - it 'should be found by passing find_by_path an array with its name' do - assert_equal @tag, @tag_class.find_by_path([@tag.name]) - end - - it 'should be found by passing find_by_path its name' do - assert_equal @tag, @tag_class.find_by_path(@tag.name) - end - - describe 'with child' do - before do - @child = @tag_class.create!(name: 'tag 2') - end - - def assert_roots_and_leaves - assert @tag.root? - refute @tag.leaf? - - refute @child.root? - assert @child.leaf? - end - - def assert_parent_and_children - assert_equal @tag, @child.reload.parent - assert_equal [@child], @tag.reload.children.to_a - end - - it 'adds children through add_child' do - @tag.add_child @child - assert_roots_and_leaves - assert_parent_and_children - end - - it 'adds children through collection' do - @tag.children << @child - assert_roots_and_leaves - assert_parent_and_children - end - end + define_method "test_find_or_create_by_path_with_strings" do + a = @tag_class.create!(name: 'a') + assert_equal(%w[a b c], a.find_or_create_by_path(%w[b c]).ancestry_path) end - describe 'with 2 tags' do - before do - @root = @tag_class.create!(name: 'root') - @leaf = @root.add_child(@tag_class.create!(name: 'leaf')) - end - - it 'should return a simple root and leaf' do - assert_equal [@root], @tag_class.roots - assert_equal [@leaf], @tag_class.leaves - end + define_method "test_find_or_create_by_path_with_hashes" do + a = @tag_class.create!(name: 'a', title: 'A') + subject = a.find_or_create_by_path([ + { name: 'b', title: 'B' }, + { name: 'c', title: 'C' } + ]) + assert_equal(%w[a b c], subject.ancestry_path) + assert_equal(%w[C B A], subject.self_and_ancestors.map(&:title)) + end - it 'should return child_ids for root' do - assert_equal [@leaf.id], @root.child_ids - end + define_method "test_single_tag_should_be_a_leaf_and_root" do + tag = @tag_class.create!(name: 'tag') + assert tag.leaf? + assert tag.root? + assert_nil tag.parent + assert_equal [tag], @tag_class.all + assert_equal [tag], @tag_class.roots + assert_equal [tag], @tag_class.leaves + end - it 'should return an empty array for leaves' do - assert_empty @leaf.child_ids - end + define_method "test_should_not_find_tag_with_invalid_path_arguments" do + tag = @tag_class.create!(name: 'tag') + assert_nil @tag_class.find_by_path(['']) + assert_nil @tag_class.find_by_path([]) + assert_nil @tag_class.find_by_path(nil) + assert_nil @tag_class.find_by_path('') + assert_nil @tag_class.find_by_path([nil]) + assert_nil @tag_class.find_by_path([tag.name, '']) + assert_nil @tag_class.find_by_path([tag.name, nil]) end - describe '3 tag collection.create db' do - before do - @root = @tag_class.create! name: 'root' - @mid = @root.children.create! name: 'mid' - @leaf = @mid.children.create! name: 'leaf' - DestroyedTag.delete_all - end + define_method "test_should find tag by valid path" do + tag = @tag_class.create!(name: 'tag') + assert_equal tag, @tag_class.find_by_path([tag.name]) + assert_equal tag, @tag_class.find_by_path(tag.name) + end - it 'should create all tags' do - assert_equal [@root, @mid, @leaf].sort, @tag_class.all.to_a.sort - end + define_method "test_adds children through add_child" do + tag = @tag_class.create!(name: 'tag') + child = @tag_class.create!(name: 'tag 2') + tag.add_child child + + assert tag.root? + refute tag.leaf? + refute child.root? + assert child.leaf? + assert_equal tag, child.reload.parent + assert_equal [child], tag.reload.children.to_a + end - it 'should return a root and leaf without middle tag' do - assert_equal [@root], @tag_class.roots - assert_equal [@leaf], @tag_class.leaves - end + define_method "test_adds children through collection" do + tag = @tag_class.create!(name: 'tag') + child = @tag_class.create!(name: 'tag 2') + tag.children << child + + assert tag.root? + refute tag.leaf? + refute child.root? + assert child.leaf? + assert_equal tag, child.reload.parent + assert_equal [child], tag.reload.children.to_a + end - it 'should delete leaves' do - @tag_class.leaves.destroy_all - assert_equal [@root], @tag_class.roots # untouched - assert_equal [@mid], @tag_class.leaves - end + define_method "test_returns simple root and leaf with 2 tags" do + root = @tag_class.create!(name: 'root') + leaf = root.add_child(@tag_class.create!(name: 'leaf')) + + assert_equal [root], @tag_class.roots + assert_equal [leaf], @tag_class.leaves + assert_equal [leaf.id], root.child_ids + assert_empty leaf.child_ids + end - it 'should delete everything if you delete the roots' do - @tag_class.roots.destroy_all - assert_empty @tag_class.all - assert_empty @tag_class.roots - assert_empty @tag_class.leaves - assert_equal %w[root mid leaf].sort, DestroyedTag.all.map(&:name).sort - end + define_method "test_3 tag collection.create hierarchy" do + root = @tag_class.create! name: 'root' + mid = root.children.create! name: 'mid' + leaf = mid.children.create! name: 'leaf' + DestroyedTag.delete_all + + assert_equal [root, mid, leaf].sort, @tag_class.all.to_a.sort + assert_equal [root], @tag_class.roots + assert_equal [leaf], @tag_class.leaves + end - it 'fix self_and_ancestors properly on reparenting' do - t = @tag_class.create! name: 'moar leaf' - assert_equal [t], t.self_and_ancestors.to_a - @mid.children << t - assert_equal [t, @mid, @root], t.self_and_ancestors.to_a - end + define_method "test_deletes leaves" do + root = @tag_class.create! name: 'root' + mid = root.children.create! name: 'mid' + leaf = mid.children.create! name: 'leaf' + DestroyedTag.delete_all + + @tag_class.leaves.destroy_all + assert_equal [root], @tag_class.roots + assert_equal [mid], @tag_class.leaves + end - it 'prevents ancestor loops' do - @leaf.add_child @root - refute @root.valid? - assert_includes @root.reload.descendants, @leaf - end + define_method "test_deletes everything when deleting roots" do + root = @tag_class.create! name: 'root' + mid = root.children.create! name: 'mid' + leaf = mid.children.create! name: 'leaf' + DestroyedTag.delete_all + + @tag_class.roots.destroy_all + assert_empty @tag_class.all + assert_empty @tag_class.roots + assert_empty @tag_class.leaves + assert_equal %w[root mid leaf].sort, DestroyedTag.all.map(&:name).sort + end - it 'moves non-leaves' do - new_root = @tag_class.create! name: 'new_root' - new_root.children << @mid - assert_empty @root.reload.descendants - assert_equal [@mid, @leaf], new_root.descendants - assert_equal %w[new_root mid leaf], @leaf.reload.ancestry_path - end + define_method "test_fixes self_and_ancestors properly on reparenting" do + root = @tag_class.create! name: 'root' + mid = root.children.create! name: 'mid' + leaf = mid.children.create! name: 'leaf' + + t = @tag_class.create! name: 'moar leaf' + assert_equal [t], t.self_and_ancestors.to_a + mid.children << t + assert_equal [t, mid, root], t.self_and_ancestors.to_a + end - it 'moves leaves' do - new_root = @tag_class.create! name: 'new_root' - new_root.children << @leaf - assert_equal [@leaf], new_root.descendants - assert_equal [@mid], @root.reload.descendants - assert_equal %w[new_root leaf], @leaf.reload.ancestry_path - end + define_method "test_prevents ancestor loops" do + root = @tag_class.create! name: 'root' + mid = root.children.create! name: 'mid' + leaf = mid.children.create! name: 'leaf' + + leaf.add_child root + refute root.valid? + assert_includes root.reload.descendants, leaf end - describe '3 tag explicit_create db' do - before do - @root = @tag_class.create!(name: 'root') - @mid = @root.add_child(@tag_class.create!(name: 'mid')) - @leaf = @mid.add_child(@tag_class.create!(name: 'leaf')) - end + define_method "test_moves non-leaves" do + root = @tag_class.create! name: 'root' + mid = root.children.create! name: 'mid' + leaf = mid.children.create! name: 'leaf' + + new_root = @tag_class.create! name: 'new_root' + new_root.children << mid + assert_empty root.reload.descendants + assert_equal [mid, leaf], new_root.descendants + assert_equal %w[new_root mid leaf], leaf.reload.ancestry_path + end - it 'should create all tags' do - assert_equal [@root, @mid, @leaf].sort, @tag_class.all.to_a.sort - end + define_method "test_moves leaves" do + root = @tag_class.create! name: 'root' + mid = root.children.create! name: 'mid' + leaf = mid.children.create! name: 'leaf' + + new_root = @tag_class.create! name: 'new_root' + new_root.children << leaf + assert_equal [leaf], new_root.descendants + assert_equal [mid], root.reload.descendants + assert_equal %w[new_root leaf], leaf.reload.ancestry_path + end - it 'should return a root and leaf without middle tag' do - assert_equal [@root], @tag_class.roots - assert_equal [@leaf], @tag_class.leaves - end + define_method "test_3 tag explicit_create hierarchy" do + root = @tag_class.create!(name: 'root') + mid = root.add_child(@tag_class.create!(name: 'mid')) + leaf = mid.add_child(@tag_class.create!(name: 'leaf')) + + assert_equal [root, mid, leaf].sort, @tag_class.all.to_a.sort + assert_equal [root], @tag_class.roots + assert_equal [leaf], @tag_class.leaves + end - it 'should prevent parental loops from torso' do - @mid.children << @root - refute @root.valid? - assert_equal [@leaf], @mid.reload.children - end + define_method "test_prevents parental loops from torso" do + root = @tag_class.create!(name: 'root') + mid = root.add_child(@tag_class.create!(name: 'mid')) + leaf = mid.add_child(@tag_class.create!(name: 'leaf')) + + mid.children << root + refute root.valid? + assert_equal [leaf], mid.reload.children + end - it 'should prevent parental loops from toes' do - @leaf.children << @root - refute @root.valid? - assert_empty @leaf.reload.children - end + define_method "test_prevents parental loops from toes" do + root = @tag_class.create!(name: 'root') + mid = root.add_child(@tag_class.create!(name: 'mid')) + leaf = mid.add_child(@tag_class.create!(name: 'leaf')) + + leaf.children << root + refute root.valid? + assert_empty leaf.reload.children + end - it 'should support re-parenting' do - @root.children << @leaf - assert_equal [@leaf, @mid], @tag_class.leaves - end + define_method "test_supports re-parenting" do + root = @tag_class.create!(name: 'root') + mid = root.add_child(@tag_class.create!(name: 'mid')) + leaf = mid.add_child(@tag_class.create!(name: 'leaf')) + + root.children << leaf + assert_equal [leaf, mid], @tag_class.leaves + end - it 'cleans up hierarchy references for leaves' do - @leaf.destroy - assert_empty @tag_hierarchy_class.where(ancestor_id: @leaf.id) - assert_empty @tag_hierarchy_class.where(descendant_id: @leaf.id) - end + define_method "test_cleans up hierarchy references for leaves" do + root = @tag_class.create!(name: 'root') + mid = root.add_child(@tag_class.create!(name: 'mid')) + leaf = mid.add_child(@tag_class.create!(name: 'leaf')) + + leaf.destroy + assert_empty @tag_hierarchy_class.where(ancestor_id: leaf.id) + assert_empty @tag_hierarchy_class.where(descendant_id: leaf.id) + end - it 'cleans up hierarchy references' do - @mid.destroy - assert_empty @tag_hierarchy_class.where(ancestor_id: @mid.id) - assert_empty @tag_hierarchy_class.where(descendant_id: @mid.id) - assert @root.reload.root? - root_hiers = @root.ancestor_hierarchies.to_a - assert_equal 1, root_hiers.size - assert_equal root_hiers, @tag_hierarchy_class.where(ancestor_id: @root.id) - assert_equal root_hiers, @tag_hierarchy_class.where(descendant_id: @root.id) - end + define_method "test_cleans up hierarchy references" do + root = @tag_class.create!(name: 'root') + mid = root.add_child(@tag_class.create!(name: 'mid')) + leaf = mid.add_child(@tag_class.create!(name: 'leaf')) + + mid.destroy + assert_empty @tag_hierarchy_class.where(ancestor_id: mid.id) + assert_empty @tag_hierarchy_class.where(descendant_id: mid.id) + assert root.reload.root? + root_hiers = root.ancestor_hierarchies.to_a + assert_equal 1, root_hiers.size + assert_equal root_hiers, @tag_hierarchy_class.where(ancestor_id: root.id) + assert_equal root_hiers, @tag_hierarchy_class.where(descendant_id: root.id) + end - it 'should have different hash codes for each hierarchy model' do - hashes = @tag_hierarchy_class.all.map(&:hash) - assert_equal hashes.uniq.sort, hashes.sort - end + define_method "test_hierarchy models have different hash codes" do + root = @tag_class.create!(name: 'root') + mid = root.add_child(@tag_class.create!(name: 'mid')) + leaf = mid.add_child(@tag_class.create!(name: 'leaf')) + + hashes = @tag_hierarchy_class.all.map(&:hash) + assert_equal hashes.uniq.sort, hashes.sort + end - it 'should return the same hash code for equal hierarchy models' do - assert_equal @tag_hierarchy_class.first.hash, @tag_hierarchy_class.first.hash - end + define_method "test_equal hierarchy models have same hash code" do + root = @tag_class.create!(name: 'root') + root.add_child(@tag_class.create!(name: 'mid')) + + assert_equal @tag_hierarchy_class.first.hash, @tag_hierarchy_class.first.hash end - it 'performs as the readme says it does' do + define_method "test_performs as the readme says" do skip "JRuby has issues with ActiveRecord 7.1+ datetime handling in transactions" if defined?(JRUBY_VERSION) + grandparent = @tag_class.create(name: 'Grandparent') parent = grandparent.children.create(name: 'Parent') child1 = @tag_class.create(name: 'First Child', parent: parent) @@ -289,533 +279,627 @@ def assert_parent_and_children parent.children << child2 child3 = @tag_class.new(name: 'Third Child') parent.add_child child3 - assert_equal( - ['Grandparent', 'Parent', 'First Child', 'Second Child', 'Third Child'], - grandparent.self_and_descendants.collect(&:name) - ) + + assert_equal(['Grandparent', 'Parent'], parent.ancestry_path) assert_equal(['Grandparent', 'Parent', 'First Child'], child1.ancestry_path) + assert_equal(['Grandparent', 'Parent', 'Second Child'], child2.ancestry_path) assert_equal(['Grandparent', 'Parent', 'Third Child'], child3.ancestry_path) + d = @tag_class.find_or_create_by_path %w[a b c d] h = @tag_class.find_or_create_by_path %w[e f g h] e = h.root - d.add_child(e) # "d.children << e" would work too, of course + d.add_child(e) assert_equal %w[a b c d e f g h], h.ancestry_path end - it 'roots sort alphabetically' do + define_method "test_roots sort alphabetically" do expected = ('a'..'z').to_a expected.shuffle.each { |ea| @tag_class.create!(name: ea) } assert_equal expected, @tag_class.roots.collect(&:name) end - describe 'with simple tree' do - before do - @tag_class.find_or_create_by_path %w[a1 b1 c1a] - @tag_class.find_or_create_by_path %w[a1 b1 c1b] - @tag_class.find_or_create_by_path %w[a1 b1 c1c] - @tag_class.find_or_create_by_path %w[a1 b1b] - @tag_class.find_or_create_by_path %w[a2 b2] - @tag_class.find_or_create_by_path %w[a3] - - @a1, @a2, @a3, @b1, @b1b, @b2, @c1a, @c1b, @c1c = @tag_class.all.sort_by(&:name) - @expected_roots = [@a1, @a2, @a3] - @expected_leaves = [@c1a, @c1b, @c1c, @b1b, @b2, @a3] - @expected_siblings = [[@a1, @a2, @a3], [@b1, @b1b], [@c1a, @c1b, @c1c]] - @expected_only_children = @tag_class.all - @expected_siblings.flatten - end - - it 'should find global roots' do - assert_equal @expected_roots.sort, @tag_class.roots.to_a.sort - end - - it 'should return root? for roots' do - @expected_roots.each { |ea| assert(ea.root?) } - end + define_method "test_finds global roots in simple tree" do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a1 b1 c1c] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] + + a1, a2, a3, b1, b1b, b2, c1a, c1b, c1c = @tag_class.all.sort_by(&:name) + expected_roots = [a1, a2, a3] + + assert_equal expected_roots.sort, @tag_class.roots.to_a.sort + end - it 'should not return root? for non-roots' do - [@b1, @b2, @c1a, @c1b].each { |ea| refute(ea.root?) } - end + define_method "test_returns root? for roots" do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] + + a1, a2, a3 = @tag_class.all.sort_by(&:name).select(&:root?) + [a1, a2, a3].each { |ea| assert(ea.root?) } + end - it 'should return the correct root' do - { @a1 => @a1, @a2 => @a2, @a3 => @a3, - @b1 => @a1, @b2 => @a2, @c1a => @a1, @c1b => @a1 }.each do |node, root| - assert_equal(root, node.root) - end - end + define_method "test_does not return root? for non-roots" do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a2 b2] + + a1, a2, b1, b2, c1a = @tag_class.all.sort_by(&:name) + [b1, b2, c1a].each { |ea| refute(ea.root?) } + end - it 'should assemble global leaves' do - assert_equal @expected_leaves.sort, @tag_class.leaves.to_a.sort + define_method "test_returns the correct root" do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a2 b2] + + a1, a2, b1, b2, c1a, c1b = @tag_class.all.sort_by(&:name) + + { a1 => a1, a2 => a2, b1 => a1, b2 => a2, c1a => a1, c1b => a1 }.each do |node, root| + assert_equal(root, node.root) end + end - it 'assembles siblings properly' do - @expected_siblings.each do |siblings| - siblings.each do |ea| - assert_equal siblings.sort, ea.self_and_siblings.to_a.sort - assert_equal((siblings - [ea]).sort, ea.siblings.to_a.sort) - end - end - - @expected_only_children.each do |ea| - assert_equal [], ea.siblings - end - end + define_method "test_assembles global leaves" do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a1 b1 c1c] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] + + _, _, a3, _, b1b, b2, c1a, c1b, c1c = @tag_class.all.sort_by(&:name) + expected_leaves = [c1a, c1b, c1c, b1b, b2, a3] + + assert_equal expected_leaves.sort, @tag_class.leaves.to_a.sort + end - it 'assembles before_siblings' do - @expected_siblings.each do |siblings| - (siblings.size - 1).times do |i| - target = siblings[i] - expected_before = siblings.first(i) - assert_equal expected_before, target.siblings_before.to_a - end - end + define_method "test_assembles siblings properly" do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a1 b1 c1c] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] + + a1, a2, a3, b1, b1b, b2, c1a, c1b, c1c = @tag_class.all.sort_by(&:name) + expected_siblings = [[a1, a2, a3], [b1, b1b], [c1a, c1b, c1c]] + expected_only_children = @tag_class.all - expected_siblings.flatten + + expected_siblings.each do |siblings| + siblings.each do |ea| + assert_equal siblings.sort, ea.self_and_siblings.to_a.sort + assert_equal((siblings - [ea]).sort, ea.siblings.to_a.sort) + end + end + + expected_only_children.each do |ea| + assert_equal [], ea.siblings end + end - it 'assembles after_siblings' do - @expected_siblings.each do |siblings| - (siblings.size - 1).times do |i| - target = siblings[i] - expected_after = siblings.last(siblings.size - 1 - i) - assert_equal expected_after, target.siblings_after.to_a - end + define_method "test_assembles before_siblings" do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a1 b1 c1c] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] + + a1, a2, a3, b1, b1b, _, c1a, c1b, c1c = @tag_class.all.sort_by(&:name) + expected_siblings = [[a1, a2, a3], [b1, b1b], [c1a, c1b, c1c]] + + expected_siblings.each do |siblings| + (siblings.size - 1).times do |i| + target = siblings[i] + expected_before = siblings.first(i) + assert_equal expected_before, target.siblings_before.to_a end end + end - it 'should assemble instance leaves' do - { @a1 => [@b1b, @c1a, @c1b, @c1c], @b1 => [@c1a, @c1b, @c1c], @a2 => [@b2] }.each do |node, leaves| - assert_equal leaves, node.leaves.to_a + define_method "test_assembles after_siblings" do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a1 b1 c1c] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] + + a1, a2, a3, b1, b1b, _, c1a, c1b, c1c = @tag_class.all.sort_by(&:name) + expected_siblings = [[a1, a2, a3], [b1, b1b], [c1a, c1b, c1c]] + + expected_siblings.each do |siblings| + (siblings.size - 1).times do |i| + target = siblings[i] + expected_after = siblings.last(siblings.size - 1 - i) + assert_equal expected_after, target.siblings_after.to_a end - - @expected_leaves.each { |ea| assert_equal [ea], ea.leaves.to_a } - end - - it 'should return leaf? for leaves' do - @expected_leaves.each { |ea| assert ea.leaf? } - end - - it 'can move roots' do - @c1a.children << @a2 - @b2.reload.children << @a3 - assert_equal %w[a1 b1 c1a a2 b2 a3], @a3.reload.ancestry_path - end - - it 'cascade-deletes from roots' do - victim_names = @a1.self_and_descendants.map(&:name) - survivor_names = @tag_class.all.map(&:name) - victim_names - @a1.destroy - assert_equal survivor_names, @tag_class.all.map(&:name) end end - describe 'with_ancestor' do - it 'works with no rows' do - assert_empty @tag_class.with_ancestor.to_a - end - - it 'finds only children' do - c = @tag_class.find_or_create_by_path %w[A B C] - a = c.parent.parent - b = c.parent - @tag_class.find_or_create_by_path %w[D E] - assert_equal [b, c], @tag_class.with_ancestor(a).to_a - end - - it 'limits subsequent where clauses' do - a1c = @tag_class.find_or_create_by_path %w[A1 B C] - a2c = @tag_class.find_or_create_by_path %w[A2 B C] - # different paths! - refute_equal a2c, a1c - assert_equal [a1c, a2c].sort, @tag_class.where(name: 'C').to_a.sort - assert_equal [a1c], @tag_class.with_ancestor(a1c.parent.parent).where(name: 'C').to_a.sort - end + define_method "test_assembles instance leaves" do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a1 b1 c1c] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] + + a1, a2, a3, b1, b1b, b2, c1a, c1b, c1c = @tag_class.all.sort_by(&:name) + expected_leaves = [c1a, c1b, c1c, b1b, b2, a3] + + { a1 => [b1b, c1a, c1b, c1c], b1 => [c1a, c1b, c1c], a2 => [b2] }.each do |node, leaves| + assert_equal leaves, node.leaves.to_a + end + + expected_leaves.each { |ea| assert_equal [ea], ea.leaves.to_a } end - describe 'with_descendant' do - it 'works with no rows' do - assert_empty @tag_class.with_descendant.to_a - end - - it 'finds only parents' do - c = @tag_class.find_or_create_by_path %w[A B C] - a = c.parent.parent - b = c.parent - _spurious_tags = @tag_class.find_or_create_by_path %w[D E] - assert_equal [a, b], @tag_class.with_descendant(c).to_a - end - - it 'limits subsequent where clauses' do - ac1 = @tag_class.create(name: 'A') - ac2 = @tag_class.create(name: 'A') - - c1 = @tag_class.find_or_create_by_path %w[B C1] - ac1.children << c1.parent - - c2 = @tag_class.find_or_create_by_path %w[B C2] - ac2.children << c2.parent - - # different paths! - refute_equal ac2, ac1 - assert_equal [ac1, ac2].sort, @tag_class.where(name: 'A').to_a.sort - assert_equal [ac1], @tag_class.with_descendant(c1).where(name: 'A').to_a - end + define_method "test_returns leaf? for leaves" do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] + + _, _, a3, _, b1b, b2, c1a = @tag_class.all.sort_by(&:name) + expected_leaves = [c1a, b1b, b2, a3] + + expected_leaves.each { |ea| assert ea.leaf? } end - describe 'lowest_common_ancestor' do - before do - @t1 = @tag_class.create!(name: 't1') - @t11 = @tag_class.create!(name: 't11', parent: @t1) - @t111 = @tag_class.create!(name: 't111', parent: @t11) - @t112 = @tag_class.create!(name: 't112', parent: @t11) - @t12 = @tag_class.create!(name: 't12', parent: @t1) - @t121 = @tag_class.create!(name: 't121', parent: @t12) - @t2 = @tag_class.create!(name: 't2') - @t21 = @tag_class.create!(name: 't21', parent: @t2) - @t21 = @tag_class.create!(name: 't21', parent: @t2) - @t211 = @tag_class.create!(name: 't211', parent: @t21) - end - - it 'finds the parent for siblings' do - assert_equal @t11, @tag_class.lowest_common_ancestor(@t112, @t111) - assert_equal @t1, @tag_class.lowest_common_ancestor(@t12, @t11) - - assert_equal @t11, @tag_class.lowest_common_ancestor([@t112, @t111]) - assert_equal @t1, @tag_class.lowest_common_ancestor([@t12, @t11]) - - assert_equal @t11, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t112 t111])) - assert_equal @t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t12 t11])) - end - - it 'finds the grandparent for cousins' do - assert_equal @t1, @tag_class.lowest_common_ancestor(@t112, @t111, @t121) - assert_equal @t1, @tag_class.lowest_common_ancestor([@t112, @t111, @t121]) - assert_equal @t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t112 t111 t121])) - end - - it 'finds the parent/grandparent for aunt-uncle/niece-nephew' do - assert_equal @t1, @tag_class.lowest_common_ancestor(@t12, @t112) - assert_equal @t1, @tag_class.lowest_common_ancestor([@t12, @t112]) - assert_equal @t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t12 t112])) - end - - it 'finds the self/parent for parent/child' do - assert_equal @t12, @tag_class.lowest_common_ancestor(@t12, @t121) - assert_equal @t1, @tag_class.lowest_common_ancestor(@t1, @t12) - - assert_equal @t12, @tag_class.lowest_common_ancestor([@t12, @t121]) - assert_equal @t1, @tag_class.lowest_common_ancestor([@t1, @t12]) - - assert_equal @t12, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t12 t121])) - assert_equal @t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t1 t12])) - end - - it 'finds the self/grandparent for grandparent/grandchild' do - assert_equal @t2, @tag_class.lowest_common_ancestor(@t211, @t2) - assert_equal @t1, @tag_class.lowest_common_ancestor(@t111, @t1) + define_method "test_can move roots" do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] + + a1, a2, a3, b1, b2, c1a = @tag_class.all.sort_by(&:name) + + c1a.children << a2 + b2.reload.children << a3 + assert_equal %w[a1 b1 c1a a2 b2 a3], a3.reload.ancestry_path + end - assert_equal @t2, @tag_class.lowest_common_ancestor([@t211, @t2]) - assert_equal @t1, @tag_class.lowest_common_ancestor([@t111, @t1]) + define_method "test_cascade-deletes from roots" do + @tag_class.find_or_create_by_path %w[a1 b1 c1a] + @tag_class.find_or_create_by_path %w[a1 b1 c1b] + @tag_class.find_or_create_by_path %w[a1 b1 c1c] + @tag_class.find_or_create_by_path %w[a1 b1b] + @tag_class.find_or_create_by_path %w[a2 b2] + @tag_class.find_or_create_by_path %w[a3] + + a1 = @tag_class.all.sort_by(&:name).first + + victim_names = a1.self_and_descendants.map(&:name) + survivor_names = @tag_class.all.map(&:name) - victim_names + a1.destroy + assert_equal survivor_names, @tag_class.all.map(&:name) + end - assert_equal @t2, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t211 t2])) - assert_equal @t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t111 t1])) - end + define_method "test_with_ancestor works with no rows" do + assert_empty @tag_class.with_ancestor.to_a + end - it 'finds the grandparent for a whole extended family' do - assert_equal @t1, @tag_class.lowest_common_ancestor(@t1, @t11, @t111, @t112, @t12, @t121) - assert_equal @t2, @tag_class.lowest_common_ancestor(@t2, @t21, @t211) + define_method "test_with_ancestor finds only children" do + c = @tag_class.find_or_create_by_path %w[A B C] + a = c.parent.parent + b = c.parent + @tag_class.find_or_create_by_path %w[D E] + assert_equal [b, c], @tag_class.with_ancestor(a).to_a + end - assert_equal @t1, @tag_class.lowest_common_ancestor([@t1, @t11, @t111, @t112, @t12, @t121]) - assert_equal @t2, @tag_class.lowest_common_ancestor([@t2, @t21, @t211]) + define_method "test_with_ancestor limits subsequent where clauses" do + a1c = @tag_class.find_or_create_by_path %w[A1 B C] + a2c = @tag_class.find_or_create_by_path %w[A2 B C] + refute_equal a2c, a1c + assert_equal [a1c, a2c].sort, @tag_class.where(name: 'C').to_a.sort + assert_equal [a1c], @tag_class.with_ancestor(a1c.parent.parent).where(name: 'C').to_a.sort + end - assert_equal @t1, - @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t1 t11 t111 t112 t12 t121])) - assert_equal @t2, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t2 t21 t211])) - end + define_method "test_with_descendant works with no rows" do + assert_empty @tag_class.with_descendant.to_a + end - it 'is nil for no items' do - assert_nil @tag_class.lowest_common_ancestor - assert_nil @tag_class.lowest_common_ancestor([]) - assert_nil @tag_class.lowest_common_ancestor(@tag_class.none) - end + define_method "test_with_descendant finds only parents" do + c = @tag_class.find_or_create_by_path %w[A B C] + a = c.parent.parent + b = c.parent + _spurious_tags = @tag_class.find_or_create_by_path %w[D E] + assert_equal [a, b], @tag_class.with_descendant(c).to_a + end - it 'is nil if there are no common ancestors' do - assert_nil @tag_class.lowest_common_ancestor(@t111, @t211) - assert_nil @tag_class.lowest_common_ancestor([@t111, @t211]) - assert_nil @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t111 t211])) - end + define_method "test_with_descendant limits subsequent where clauses" do + ac1 = @tag_class.create(name: 'A') + ac2 = @tag_class.create(name: 'A') + + c1 = @tag_class.find_or_create_by_path %w[B C1] + ac1.children << c1.parent + + c2 = @tag_class.find_or_create_by_path %w[B C2] + ac2.children << c2.parent + + refute_equal ac2, ac1 + assert_equal [ac1, ac2].sort, @tag_class.where(name: 'A').to_a.sort + assert_equal [ac1], @tag_class.with_descendant(c1).where(name: 'A').to_a + end - it 'is itself for single item' do - assert_equal @t111, @tag_class.lowest_common_ancestor(@t111) - assert_equal @t2, @tag_class.lowest_common_ancestor(@t2) + define_method "test_lowest_common_ancestor finds parent for siblings" do + t1 = @tag_class.create!(name: 't1') + t11 = @tag_class.create!(name: 't11', parent: t1) + t111 = @tag_class.create!(name: 't111', parent: t11) + t112 = @tag_class.create!(name: 't112', parent: t11) + t12 = @tag_class.create!(name: 't12', parent: t1) + + assert_equal t11, @tag_class.lowest_common_ancestor(t112, t111) + assert_equal t1, @tag_class.lowest_common_ancestor(t12, t11) + assert_equal t11, @tag_class.lowest_common_ancestor([t112, t111]) + assert_equal t1, @tag_class.lowest_common_ancestor([t12, t11]) + assert_equal t11, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t112 t111])) + assert_equal t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t12 t11])) + end - assert_equal @t111, @tag_class.lowest_common_ancestor([@t111]) - assert_equal @t2, @tag_class.lowest_common_ancestor([@t2]) + define_method "test_lowest_common_ancestor finds grandparent for cousins" do + t1 = @tag_class.create!(name: 't1') + t11 = @tag_class.create!(name: 't11', parent: t1) + t111 = @tag_class.create!(name: 't111', parent: t11) + t112 = @tag_class.create!(name: 't112', parent: t11) + t12 = @tag_class.create!(name: 't12', parent: t1) + t121 = @tag_class.create!(name: 't121', parent: t12) + + assert_equal t1, @tag_class.lowest_common_ancestor(t112, t111, t121) + assert_equal t1, @tag_class.lowest_common_ancestor([t112, t111, t121]) + assert_equal t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t112 t111 t121])) + end - assert_equal @t111, @tag_class.lowest_common_ancestor(@tag_class.where(name: 't111')) - assert_equal @t2, @tag_class.lowest_common_ancestor(@tag_class.where(name: 't2')) - end + define_method "test_lowest_common_ancestor for aunt-uncle/niece-nephew" do + t1 = @tag_class.create!(name: 't1') + t11 = @tag_class.create!(name: 't11', parent: t1) + t112 = @tag_class.create!(name: 't112', parent: t11) + t12 = @tag_class.create!(name: 't12', parent: t1) + + assert_equal t1, @tag_class.lowest_common_ancestor(t12, t112) + assert_equal t1, @tag_class.lowest_common_ancestor([t12, t112]) + assert_equal t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t12 t112])) end - describe 'paths' do - describe 'with grandchild ' do - before do - @child = @tag_class.find_or_create_by_path([ - { name: 'grandparent', title: 'Nonnie' }, - { name: 'parent', title: 'Mom' }, - { name: 'child', title: 'Kid' } - ]) - @parent = @child.parent - @grandparent = @parent.parent - end + define_method "test_lowest_common_ancestor for parent/child" do + t1 = @tag_class.create!(name: 't1') + t12 = @tag_class.create!(name: 't12', parent: t1) + t121 = @tag_class.create!(name: 't121', parent: t12) + + assert_equal t12, @tag_class.lowest_common_ancestor(t12, t121) + assert_equal t1, @tag_class.lowest_common_ancestor(t1, t12) + assert_equal t12, @tag_class.lowest_common_ancestor([t12, t121]) + assert_equal t1, @tag_class.lowest_common_ancestor([t1, t12]) + assert_equal t12, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t12 t121])) + assert_equal t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t1 t12])) + end - it 'should build ancestry path' do - assert_equal %w[grandparent parent child], @child.ancestry_path - assert_equal %w[grandparent parent child], @child.ancestry_path(:name) - assert_equal %w[Nonnie Mom Kid], @child.ancestry_path(:title) - end + define_method "test_lowest_common_ancestor for grandparent/grandchild" do + t1 = @tag_class.create!(name: 't1') + t11 = @tag_class.create!(name: 't11', parent: t1) + t111 = @tag_class.create!(name: 't111', parent: t11) + t2 = @tag_class.create!(name: 't2') + t21 = @tag_class.create!(name: 't21', parent: t2) + t211 = @tag_class.create!(name: 't211', parent: t21) + + assert_equal t2, @tag_class.lowest_common_ancestor(t211, t2) + assert_equal t1, @tag_class.lowest_common_ancestor(t111, t1) + assert_equal t2, @tag_class.lowest_common_ancestor([t211, t2]) + assert_equal t1, @tag_class.lowest_common_ancestor([t111, t1]) + assert_equal t2, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t211 t2])) + assert_equal t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t111 t1])) + end - it 'assembles ancestors' do - assert_equal [@parent, @grandparent], @child.ancestors - assert_equal [@child, @parent, @grandparent], @child.self_and_ancestors - end + define_method "test_lowest_common_ancestor for whole extended family" do + t1 = @tag_class.create!(name: 't1') + t11 = @tag_class.create!(name: 't11', parent: t1) + t111 = @tag_class.create!(name: 't111', parent: t11) + t112 = @tag_class.create!(name: 't112', parent: t11) + t12 = @tag_class.create!(name: 't12', parent: t1) + t121 = @tag_class.create!(name: 't121', parent: t12) + t2 = @tag_class.create!(name: 't2') + t21 = @tag_class.create!(name: 't21', parent: t2) + t211 = @tag_class.create!(name: 't211', parent: t21) + + assert_equal t1, @tag_class.lowest_common_ancestor(t1, t11, t111, t112, t12, t121) + assert_equal t2, @tag_class.lowest_common_ancestor(t2, t21, t211) + assert_equal t1, @tag_class.lowest_common_ancestor([t1, t11, t111, t112, t12, t121]) + assert_equal t2, @tag_class.lowest_common_ancestor([t2, t21, t211]) + assert_equal t1, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t1 t11 t111 t112 t12 t121])) + assert_equal t2, @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t2 t21 t211])) + end - it 'should find by path' do - # class method: - assert_equal @child, @tag_class.find_by_path(%w[grandparent parent child]) - # instance method: - assert_equal @child, @parent.find_by_path(%w[child]) - assert_equal @child, @grandparent.find_by_path(%w[parent child]) - assert_nil @parent.find_by_path(%w[child larvae]) - end + define_method "test_lowest_common_ancestor is nil for no items" do + assert_nil @tag_class.lowest_common_ancestor + assert_nil @tag_class.lowest_common_ancestor([]) + assert_nil @tag_class.lowest_common_ancestor(@tag_class.none) + end - it 'should respect attribute hashes with both selection and creation' do - expected_title = 'something else' - attrs = { title: expected_title } - existing_title = @grandparent.title - new_grandparent = @tag_class.find_or_create_by_path(%w[grandparent], attrs) - refute_equal @grandparent, new_grandparent - assert_equal expected_title, new_grandparent.title - assert_equal existing_title, @grandparent.reload.title - end + define_method "test_lowest_common_ancestor is nil for no common ancestors" do + t1 = @tag_class.create!(name: 't1') + t11 = @tag_class.create!(name: 't11', parent: t1) + t111 = @tag_class.create!(name: 't111', parent: t11) + t2 = @tag_class.create!(name: 't2') + t21 = @tag_class.create!(name: 't21', parent: t2) + t211 = @tag_class.create!(name: 't211', parent: t21) + + assert_nil @tag_class.lowest_common_ancestor(t111, t211) + assert_nil @tag_class.lowest_common_ancestor([t111, t211]) + assert_nil @tag_class.lowest_common_ancestor(@tag_class.where(name: %w[t111 t211])) + end - it 'should create a hierarchy with a given attribute' do - expected_title = 'unicorn rainbows' - attrs = { title: expected_title } - child = @tag_class.find_or_create_by_path(%w[grandparent parent child], attrs) - refute_equal @child, child - [child, child.parent, child.parent.parent].each do |ea| - assert_equal expected_title, ea.title - end - end - end + define_method "test_lowest_common_ancestor is itself for single item" do + t1 = @tag_class.create!(name: 't1') + t11 = @tag_class.create!(name: 't11', parent: t1) + t111 = @tag_class.create!(name: 't111', parent: t11) + t2 = @tag_class.create!(name: 't2') + + assert_equal t111, @tag_class.lowest_common_ancestor(t111) + assert_equal t2, @tag_class.lowest_common_ancestor(t2) + assert_equal t111, @tag_class.lowest_common_ancestor([t111]) + assert_equal t2, @tag_class.lowest_common_ancestor([t2]) + assert_equal t111, @tag_class.lowest_common_ancestor(@tag_class.where(name: 't111')) + assert_equal t2, @tag_class.lowest_common_ancestor(@tag_class.where(name: 't2')) + end - it 'finds correctly rooted paths' do - _decoy = @tag_class.find_or_create_by_path %w[a b c d] - b_d = @tag_class.find_or_create_by_path %w[b c d] - assert_equal b_d, @tag_class.find_by_path(%w[b c d]) - assert_nil @tag_class.find_by_path(%w[c d]) - end + define_method "test_builds ancestry path" do + child = @tag_class.find_or_create_by_path([ + { name: 'grandparent', title: 'Nonnie' }, + { name: 'parent', title: 'Mom' }, + { name: 'child', title: 'Kid' } + ]) + parent = child.parent + grandparent = parent.parent + + assert_equal %w[grandparent parent child], child.ancestry_path + assert_equal %w[grandparent parent child], child.ancestry_path(:name) + assert_equal %w[Nonnie Mom Kid], child.ancestry_path(:title) + end - it 'find_by_path for 1 node' do - b = @tag_class.find_or_create_by_path %w[a b] - b2 = b.root.find_by_path(%w[b]) - assert_equal b, b2 - end + define_method "test_assembles ancestors" do + child = @tag_class.find_or_create_by_path([ + { name: 'grandparent', title: 'Nonnie' }, + { name: 'parent', title: 'Mom' }, + { name: 'child', title: 'Kid' } + ]) + parent = child.parent + grandparent = parent.parent + + assert_equal [parent, grandparent], child.ancestors + assert_equal [child, parent, grandparent], child.self_and_ancestors + end - it 'find_by_path for 2 nodes' do - path = %w[a b c] - c = @tag_class.find_or_create_by_path path - permutations = path.permutation.to_a - correct = %w[b c] - assert_equal c, c.root.find_by_path(correct) - (permutations - correct).each do |bad_path| - assert_nil c.root.find_by_path(bad_path) - end - end + define_method "test_finds by path" do + child = @tag_class.find_or_create_by_path([ + { name: 'grandparent', title: 'Nonnie' }, + { name: 'parent', title: 'Mom' }, + { name: 'child', title: 'Kid' } + ]) + parent = child.parent + grandparent = parent.parent + + assert_equal child, @tag_class.find_by_path(%w[grandparent parent child]) + assert_equal child, parent.find_by_path(%w[child]) + assert_equal child, grandparent.find_by_path(%w[parent child]) + assert_nil parent.find_by_path(%w[child larvae]) + end - it 'find_by_path for 3 nodes' do - d = @tag_class.find_or_create_by_path %w[a b c d] - assert_equal d, d.root.find_by_path(%w[b c d]) - assert_equal d, @tag_class.find_by_path(%w[a b c d]) - assert_nil @tag_class.find_by_path(%w[d]) - end + define_method "test_respects attribute hashes with both selection and creation" do + grandparent = @tag_class.find_or_create_by_path([ + { name: 'grandparent', title: 'Nonnie' } + ]) + + expected_title = 'something else' + attrs = { title: expected_title } + existing_title = grandparent.title + new_grandparent = @tag_class.find_or_create_by_path(%w[grandparent], attrs) + refute_equal grandparent, new_grandparent + assert_equal expected_title, new_grandparent.title + assert_equal existing_title, grandparent.reload.title + end - it 'should return nil for missing nodes' do - assert_nil @tag_class.find_by_path(%w[missing]) - assert_nil @tag_class.find_by_path(%w[grandparent missing]) - assert_nil @tag_class.find_by_path(%w[grandparent parent missing]) - assert_nil @tag_class.find_by_path(%w[grandparent parent missing child]) - end - - describe '.find_or_create_by_path' do - it 'uses existing records' do - grandparent = @tag_class.find_or_create_by_path(%w[grandparent]) - assert_equal grandparent, grandparent - child = @tag_class.find_or_create_by_path(%w[grandparent parent child]) - assert_equal child, child - end - - it 'creates 2-deep trees with strings' do - subject = @tag_class.find_or_create_by_path(%w[events anniversary]) - assert_equal %w[events anniversary], subject.ancestry_path - end - - it 'creates 2-deep trees with hashes' do - subject = @tag_class.find_or_create_by_path([ - { name: 'test1', title: 'TEST1' }, - { name: 'test2', title: 'TEST2' } - ]) - assert_equal %w[test1 test2], subject.ancestry_path - # `self_and_ancestors` and `ancestors` is ordered parent-first. (!!) - assert_equal %w[TEST2 TEST1], subject.self_and_ancestors.map(&:title) - end + define_method "test_creates hierarchy with given attribute" do + expected_title = 'unicorn rainbows' + attrs = { title: expected_title } + child = @tag_class.find_or_create_by_path(%w[grandparent parent child], attrs) + + [child, child.parent, child.parent.parent].each do |ea| + assert_equal expected_title, ea.title end end - describe 'hash_tree' do - before do - @d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] - @c1 = @d1.parent - @b = @c1.parent - @a = @b.parent - @a2 = @tag_class.create(name: 'a2') - @b2 = @tag_class.find_or_create_by_path %w[a b2] - @c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] - @b3 = @c3.parent - @a3 = @b3.parent - - @tree2 = { - @a => { @b => {}, @b2 => {} }, @a2 => {}, @a3 => { @b3 => {} } - } - - @one_tree = { - @a => {}, - @a2 => {}, - @a3 => {} - } - - @two_tree = { - @a => { - @b => {}, - @b2 => {} - }, - @a2 => {}, - @a3 => { - @b3 => {} - } - } + define_method "test_finds correctly rooted paths" do + _decoy = @tag_class.find_or_create_by_path %w[a b c d] + b_d = @tag_class.find_or_create_by_path %w[b c d] + assert_equal b_d, @tag_class.find_by_path(%w[b c d]) + assert_nil @tag_class.find_by_path(%w[c d]) + end - @three_tree = { - @a => { - @b => { - @c1 => {} - }, - @b2 => {} - }, - @a2 => {}, - @a3 => { - @b3 => { - @c3 => {} - } - } - } + define_method "test_find_by_path for 1 node" do + b = @tag_class.find_or_create_by_path %w[a b] + b2 = b.root.find_by_path(%w[b]) + assert_equal b, b2 + end - @full_tree = { - @a => { - @b => { - @c1 => { - @d1 => {} - } - }, - @b2 => {} - }, - @a2 => {}, - @a3 => { - @b3 => { - @c3 => {} - } - } - } + define_method "test_find_by_path for 2 nodes" do + path = %w[a b c] + c = @tag_class.find_or_create_by_path path + permutations = path.permutation.to_a + correct = %w[b c] + assert_equal c, c.root.find_by_path(correct) + (permutations - correct).each do |bad_path| + assert_nil c.root.find_by_path(bad_path) end + end - describe '#hash_tree' do - it 'returns {} for depth 0' do - assert_equal({}, @tag_class.hash_tree(limit_depth: 0)) - end - - it 'limit_depth 1' do - assert_equal @one_tree, @tag_class.hash_tree(limit_depth: 1) - end - - it 'limit_depth 2' do - assert_equal @two_tree, @tag_class.hash_tree(limit_depth: 2) - end - - it 'limit_depth 3' do - assert_equal @three_tree, @tag_class.hash_tree(limit_depth: 3) - end + define_method "test_find_by_path for 3 nodes" do + d = @tag_class.find_or_create_by_path %w[a b c d] + assert_equal d, d.root.find_by_path(%w[b c d]) + assert_equal d, @tag_class.find_by_path(%w[a b c d]) + assert_nil @tag_class.find_by_path(%w[d]) + end - it 'limit_depth 4' do - assert_equal @full_tree, @tag_class.hash_tree(limit_depth: 4) - end + define_method "test_returns nil for missing nodes" do + assert_nil @tag_class.find_by_path(%w[missing]) + assert_nil @tag_class.find_by_path(%w[grandparent missing]) + assert_nil @tag_class.find_by_path(%w[grandparent parent missing]) + assert_nil @tag_class.find_by_path(%w[grandparent parent missing child]) + end - it 'no limit' do - assert_equal @full_tree, @tag_class.hash_tree - end - end + define_method "test_find_or_create_by_path uses existing records" do + grandparent = @tag_class.find_or_create_by_path(%w[grandparent]) + assert_equal grandparent, grandparent + child = @tag_class.find_or_create_by_path(%w[grandparent parent child]) + assert_equal child, child + end - describe '.hash_tree' do - it 'returns {} for depth 0' do - assert_equal({}, @b.hash_tree(limit_depth: 0)) - end + define_method "test_find_or_create_by_path creates 2-deep trees with strings" do + subject = @tag_class.find_or_create_by_path(%w[events anniversary]) + assert_equal %w[events anniversary], subject.ancestry_path + end - it 'limit_depth 1' do - assert_equal @two_tree[@a].slice(@b), @b.hash_tree(limit_depth: 1) - end + define_method "test_find_or_create_by_path creates 2-deep trees with hashes" do + subject = @tag_class.find_or_create_by_path([ + { name: 'test1', title: 'TEST1' }, + { name: 'test2', title: 'TEST2' } + ]) + assert_equal %w[test1 test2], subject.ancestry_path + assert_equal %w[TEST2 TEST1], subject.self_and_ancestors.map(&:title) + end - it 'limit_depth 2' do - assert_equal @three_tree[@a].slice(@b), @b.hash_tree(limit_depth: 2) - end + define_method "test_hash_tree returns {} for depth 0" do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + assert_equal({}, @tag_class.hash_tree(limit_depth: 0)) + end - it 'limit_depth 3' do - assert_equal @full_tree[@a].slice(@b), @b.hash_tree(limit_depth: 3) - end + define_method "test_hash_tree limit_depth 1" do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + a = d1.root + a2 = @tag_class.create(name: 'a2') + a3 = @tag_class.find_or_create_by_path(%w[a3 b3 c3]).root + + one_tree = { a => {}, a2 => {}, a3 => {} } + assert_equal one_tree, @tag_class.hash_tree(limit_depth: 1) + end - it 'no limit from subsubroot' do - assert_equal @full_tree[@a][@b].slice(@c1), @c1.hash_tree - end + define_method "test_hash_tree limit_depth 2" do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + a2 = @tag_class.create(name: 'a2') + b2 = @tag_class.find_or_create_by_path %w[a b2] + c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] + b3 = c3.parent + a3 = b3.parent + + two_tree = { + a => { b => {}, b2 => {} }, + a2 => {}, + a3 => { b3 => {} } + } + assert_equal two_tree, @tag_class.hash_tree(limit_depth: 2) + end - it 'no limit from subroot' do - assert_equal @full_tree[@a].slice(@b), @b.hash_tree - end + define_method "test_hash_tree limit_depth 3" do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + a2 = @tag_class.create(name: 'a2') + b2 = @tag_class.find_or_create_by_path %w[a b2] + c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] + b3 = c3.parent + a3 = b3.parent + + three_tree = { + a => { b => { c1 => {} }, b2 => {} }, + a2 => {}, + a3 => { b3 => { c3 => {} } } + } + assert_equal three_tree, @tag_class.hash_tree(limit_depth: 3) + end - it 'no limit from root' do - assert_equal @full_tree.slice(@a, @a2), @a.hash_tree.merge(@a2.hash_tree) - end - end + define_method "test_hash_tree limit_depth 4" do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + a2 = @tag_class.create(name: 'a2') + b2 = @tag_class.find_or_create_by_path %w[a b2] + c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] + b3 = c3.parent + a3 = b3.parent + + full_tree = { + a => { b => { c1 => { d1 => {} } }, b2 => {} }, + a2 => {}, + a3 => { b3 => { c3 => {} } } + } + assert_equal full_tree, @tag_class.hash_tree(limit_depth: 4) + end - describe '.hash_tree from relations' do - it 'limit_depth 2 from chained activerecord association subroots' do - assert_equal @three_tree[@a], @a.children.hash_tree(limit_depth: 2) - end + define_method "test_hash_tree no limit" do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + a2 = @tag_class.create(name: 'a2') + b2 = @tag_class.find_or_create_by_path %w[a b2] + c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] + b3 = c3.parent + a3 = b3.parent + + full_tree = { + a => { b => { c1 => { d1 => {} } }, b2 => {} }, + a2 => {}, + a3 => { b3 => { c3 => {} } } + } + assert_equal full_tree, @tag_class.hash_tree + end - it 'no limit from chained activerecord association subroots' do - assert_equal @full_tree[@a], @a.children.hash_tree - end + define_method "test_instance hash_tree returns {} for depth 0" do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + b = d1.parent.parent + assert_equal({}, b.hash_tree(limit_depth: 0)) + end - it 'limit_depth 3 from b.parent' do - assert_equal @three_tree.slice(@a), @b.parent.hash_tree(limit_depth: 3) - end + define_method "test_instance hash_tree limit_depth 1" do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + b2 = @tag_class.find_or_create_by_path %w[a b2] + + two_tree = { a => { b => {}, b2 => {} } } + assert_equal two_tree[a].slice(b), b.hash_tree(limit_depth: 1) + end - it 'no limit_depth from b.parent' do - assert_equal @full_tree.slice(@a), @b.parent.hash_tree - end + define_method "test_instance hash_tree no limit from subroot" do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + b2 = @tag_class.find_or_create_by_path %w[a b2] + + full_tree = { a => { b => { c1 => { d1 => {} } }, b2 => {} } } + assert_equal full_tree[a].slice(b), b.hash_tree + end - it 'no limit_depth from c.parent' do - assert_equal @full_tree[@a].slice(@b), @c1.parent.hash_tree - end - end + define_method "test_hash_tree from chained associations" do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + b2 = @tag_class.find_or_create_by_path %w[a b2] + + full_tree = { a => { b => { c1 => { d1 => {} } }, b2 => {} } } + assert_equal full_tree[a], a.children.hash_tree end - it 'finds_by_path for very deep trees' do + define_method "test_finds_by_path for very deep trees" do path = (1..20).to_a.map(&:to_s) subject = @tag_class.find_or_create_by_path(path) assert_equal path, subject.ancestry_path @@ -824,96 +908,64 @@ def assert_parent_and_children assert_equal subject, root.find_by_path(path[1..]) end - describe 'DOT rendering' do - it 'should render for an empty scope' do - assert_equal "digraph G {\n}\n", @tag_class.to_dot_digraph(@tag_class.where('0=1')) - end - - it 'should render for an empty scope' do - @tag_class.find_or_create_by_path(%w[a b1 c1]) - @tag_class.find_or_create_by_path(%w[a b2 c2]) - @tag_class.find_or_create_by_path(%w[a b2 c3]) - a, b1, b2, c1, c2, c3 = %w[a b1 b2 c1 c2 c3].map { |ea| @tag_class.where(name: ea).first.id } - dot = @tag_class.roots.first.to_dot_digraph - - graph = <<~DOT - digraph G { - "#{a}" [label="a"] - "#{a}" -> "#{b1}" - "#{b1}" [label="b1"] - "#{a}" -> "#{b2}" - "#{b2}" [label="b2"] - "#{b1}" -> "#{c1}" - "#{c1}" [label="c1"] - "#{b2}" -> "#{c2}" - "#{c2}" [label="c2"] - "#{b2}" -> "#{c3}" - "#{c3}" [label="c3"] - } - DOT - - assert_equal(graph, dot) - end + define_method "test_DOT rendering for empty scope" do + assert_equal "digraph G {\n}\n", @tag_class.to_dot_digraph(@tag_class.where('0=1')) end - describe '.depth' do - it 'should render for an empty scope' do - @tag_class.find_or_create_by_path(%w[a b1 c1]) - @tag_class.find_or_create_by_path(%w[a b2 c2]) - @tag_class.find_or_create_by_path(%w[a b2 c3]) - a, b1, b2, c1, c2, c3 = %w[a b1 b2 c1 c2 c3].map { |ea| @tag_class.where(name: ea).first.id } - dot = @tag_class.roots.first.to_dot_digraph - - graph = <<~DOT - digraph G { - "#{a}" [label="a"] - "#{a}" -> "#{b1}" - "#{b1}" [label="b1"] - "#{a}" -> "#{b2}" - "#{b2}" [label="b2"] - "#{b1}" -> "#{c1}" - "#{c1}" [label="c1"] - "#{b2}" -> "#{c2}" - "#{c2}" [label="c2"] - "#{b2}" -> "#{c3}" - "#{c3}" [label="c3"] - } - DOT - - assert_equal(graph, dot) - end + define_method "test_DOT rendering for tree" do + @tag_class.find_or_create_by_path(%w[a b1 c1]) + @tag_class.find_or_create_by_path(%w[a b2 c2]) + @tag_class.find_or_create_by_path(%w[a b2 c3]) + a, b1, b2, c1, c2, c3 = %w[a b1 b2 c1 c2 c3].map { |ea| @tag_class.where(name: ea).first.id } + dot = @tag_class.roots.first.to_dot_digraph + + graph = <<~DOT + digraph G { + "#{a}" [label="a"] + "#{a}" -> "#{b1}" + "#{b1}" [label="b1"] + "#{a}" -> "#{b2}" + "#{b2}" [label="b2"] + "#{b1}" -> "#{c1}" + "#{c1}" [label="c1"] + "#{b2}" -> "#{c2}" + "#{c2}" [label="c2"] + "#{b2}" -> "#{c3}" + "#{c3}" [label="c3"] + } + DOT + + assert_equal(graph, dot) end - describe '.depth' do - before do - @d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] - @c1 = @d1.parent - @b = @c1.parent - @a = @b.parent - @a2 = @tag_class.create(name: 'a2') - @b2 = @tag_class.find_or_create_by_path %w[a b2] - @c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] - @b3 = @c3.parent - @a3 = @b3.parent - - - end - - it 'should return 0 for root' do - assert_equal 0, @a.depth - assert_equal 0, @a2.depth - assert_equal 0, @a3.depth - end + define_method "test_depth returns 0 for root" do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + a = b.parent + a2 = @tag_class.create(name: 'a2') + c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] + a3 = c3.parent.parent + + assert_equal 0, a.depth + assert_equal 0, a2.depth + assert_equal 0, a3.depth + end - it 'should return correct depth for nodes' do - assert_equal 1, @b.depth - assert_equal 2, @c1.depth - assert_equal 3, @d1.depth - assert_equal 1, @b2.depth - assert_equal 1, @b3.depth - assert_equal 2, @c3.depth - end + define_method "test_depth returns correct depth for nodes" do + d1 = @tag_class.find_or_create_by_path %w[a b c1 d1] + c1 = d1.parent + b = c1.parent + b2 = @tag_class.find_or_create_by_path %w[a b2] + c3 = @tag_class.find_or_create_by_path %w[a3 b3 c3] + b3 = c3.parent + + assert_equal 1, b.depth + assert_equal 2, c1.depth + assert_equal 3, d1.depth + assert_equal 1, b2.depth + assert_equal 1, b3.depth + assert_equal 2, c3.depth end - end end -end +end \ No newline at end of file diff --git a/test/test_helper.rb b/test/test_helper.rb index f3ae3fc1..df5c09e5 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,133 +1,32 @@ # frozen_string_literal: true -require 'logger' -require 'erb' -require 'active_record' -require 'with_advisory_lock' -require 'tmpdir' -require 'securerandom' -require 'minitest' +ENV['RAILS_ENV'] = 'test' +require_relative 'dummy/config/environment' +require 'rails/test_help' + require 'minitest/autorun' require 'database_cleaner' require 'support/query_counter' require 'parallel' require 'timecop' -# JRuby has issues with Timecop and ActiveRecord datetime casting -# Skip Timecop-dependent tests on JRuby -if defined?(JRUBY_VERSION) - puts "Warning: Timecop tests may fail on JRuby due to Time class incompatibilities" -end - -# Configure the database based on environment -database_url = ENV['DB_ADAPTER'] || ENV['DATABASE_URL'] || "sqlite3:///:memory:" +# Configure parallel tests +Thread.abort_on_exception = true +# Configure advisory_lock ENV['WITH_ADVISORY_LOCK_PREFIX'] ||= SecureRandom.hex -# Parse database URL and establish connection -connection_config = if database_url.start_with?('sqlite3://') - # SQLite needs special handling - if database_url == 'sqlite3:///:memory:' - { adapter: 'sqlite3', database: ':memory:' } - else - # Create a temporary database file - db_file = File.join(Dir.tmpdir, "closure_tree_test_#{SecureRandom.hex}.sqlite3") - { adapter: 'sqlite3', database: db_file } - end -elsif database_url.start_with?('mysql2://') - # Parse MySQL URL: mysql2://root:root@0/closure_tree_test - # The @0 means localhost in GitHub Actions - database_url.gsub('@0/', '@127.0.0.1/') -elsif database_url.start_with?('postgres://') - # Parse PostgreSQL URL: postgres://closure_tree:closure_tree@0/closure_tree_test - # The @0 means localhost in GitHub Actions - fixed_url = database_url.gsub('@0/', '@127.0.0.1/') - # PostgreSQL adapter expects 'postgresql://' not 'postgres://' - fixed_url.gsub('postgres://', 'postgresql://') -else - # For other database URLs, use directly - database_url -end - -# Set connection pool size for parallel tests -if connection_config.is_a?(Hash) - connection_config[:pool] = 50 - connection_config[:checkout_timeout] = 10 - # Add JRuby-specific properties if needed - if defined?(JRUBY_VERSION) - connection_config[:properties] ||= {} - connection_config[:properties][:allowPublicKeyRetrieval] = true - end - ActiveRecord::Base.establish_connection(connection_config) -else - # For URL-based configs, append pool parameters - separator = connection_config.include?('?') ? '&' : '?' - ActiveRecord::Base.establish_connection("#{connection_config}#{separator}pool=50&checkout_timeout=10") -end - -def env_db - @env_db ||= ActiveRecord::Base.connection_db_config.adapter.to_sym -end - -ActiveRecord::Migration.verbose = false -ActiveRecord::Base.table_name_prefix = ENV['DB_PREFIX'].to_s -ActiveRecord::Base.table_name_suffix = ENV['DB_SUFFIX'].to_s - -# Use in specs to skip some tests -def sqlite? - env_db == :sqlite3 -end - -# For PostgreSQL and MySQL, we need to create/reset the database structure -unless sqlite? - begin - if ActiveRecord::Base.connection.adapter_name.downcase.include?('postgresql') - # PostgreSQL requires disconnecting before dropping the database - ActiveRecord::Base.connection.disconnect! - # Connect to postgres database to drop/create closure_tree_test - if connection_config.is_a?(String) - # Parse the DATABASE_URL and change database to postgres - postgres_url = connection_config.gsub(/\/closure_tree_test/, '/postgres') - ActiveRecord::Base.establish_connection(postgres_url) - else - ActiveRecord::Base.establish_connection(connection_config.merge(database: 'postgres')) - end - ActiveRecord::Base.connection.drop_database('closure_tree_test') rescue nil - ActiveRecord::Base.connection.create_database('closure_tree_test') - ActiveRecord::Base.connection.disconnect! - ActiveRecord::Base.establish_connection(connection_config) - else - # MySQL can recreate directly - ActiveRecord::Base.connection.recreate_database('closure_tree_test') - end - rescue => e - puts "Warning: Could not recreate database: #{e.message}" - end -end -puts "Testing with #{env_db} database, ActiveRecord #{ActiveRecord.gem_version} and #{RUBY_ENGINE} #{RUBY_ENGINE_VERSION} as #{RUBY_VERSION}" - -DatabaseCleaner.strategy = :truncation -# Allow DatabaseCleaner to work with our test database -DatabaseCleaner.allow_remote_database_url = true - -module Minitest - class Spec - include QueryCounter - - before :each do - ENV['FLOCK_DIR'] = Dir.mktmpdir - DatabaseCleaner.start - end - - after :each do - FileUtils.remove_entry_secure ENV['FLOCK_DIR'] - DatabaseCleaner.clean - end - end +# JRuby has issues with Timecop and ActiveRecord datetime casting +if defined?(JRUBY_VERSION) + puts "Warning: Timecop tests may fail on JRuby due to Time class incompatibilities" end class ActiveSupport::TestCase + # Configure DatabaseCleaner + self.use_transactional_tests = false + setup do + DatabaseCleaner.strategy = :truncation DatabaseCleaner.start end @@ -163,13 +62,17 @@ def callback(name, start, finish, message_id, values) end end -# Configure parallel tests -Thread.abort_on_exception = true +# Helper methods available globally +def env_db + @env_db ||= ActiveRecord::Base.connection.adapter_name.downcase.to_sym +end -# Configure advisory_lock -# See: https://github.com/ClosureTree/with_advisory_lock -ENV['WITH_ADVISORY_LOCK_PREFIX'] ||= SecureRandom.hex +def sqlite? + env_db == :sqlite3 +end + +# Load support files +require_relative 'support/query_counter' -require 'closure_tree' -require_relative 'support/schema' -require_relative 'support/models' +# Include QueryCounter in Minitest +Minitest::Test.send(:include, QueryCounter)