acts_as_ordered_tree 1.3.1 → 2.0.0.beta3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/lib/acts_as_ordered_tree.rb +22 -100
  3. data/lib/acts_as_ordered_tree/adapters.rb +17 -0
  4. data/lib/acts_as_ordered_tree/adapters/abstract.rb +23 -0
  5. data/lib/acts_as_ordered_tree/adapters/postgresql.rb +150 -0
  6. data/lib/acts_as_ordered_tree/adapters/recursive.rb +157 -0
  7. data/lib/acts_as_ordered_tree/compatibility.rb +22 -0
  8. data/lib/acts_as_ordered_tree/compatibility/active_record/association_scope.rb +9 -0
  9. data/lib/acts_as_ordered_tree/compatibility/active_record/default_scoped.rb +19 -0
  10. data/lib/acts_as_ordered_tree/compatibility/active_record/null_relation.rb +71 -0
  11. data/lib/acts_as_ordered_tree/compatibility/features.rb +153 -0
  12. data/lib/acts_as_ordered_tree/deprecate.rb +24 -0
  13. data/lib/acts_as_ordered_tree/hooks.rb +38 -0
  14. data/lib/acts_as_ordered_tree/hooks/update.rb +86 -0
  15. data/lib/acts_as_ordered_tree/instance_methods.rb +92 -453
  16. data/lib/acts_as_ordered_tree/iterators/arranger.rb +35 -0
  17. data/lib/acts_as_ordered_tree/iterators/level_calculator.rb +52 -0
  18. data/lib/acts_as_ordered_tree/iterators/orphans_pruner.rb +58 -0
  19. data/lib/acts_as_ordered_tree/node.rb +78 -0
  20. data/lib/acts_as_ordered_tree/node/attributes.rb +48 -0
  21. data/lib/acts_as_ordered_tree/node/movement.rb +62 -0
  22. data/lib/acts_as_ordered_tree/node/movements.rb +111 -0
  23. data/lib/acts_as_ordered_tree/node/predicates.rb +98 -0
  24. data/lib/acts_as_ordered_tree/node/reloading.rb +49 -0
  25. data/lib/acts_as_ordered_tree/node/siblings.rb +139 -0
  26. data/lib/acts_as_ordered_tree/node/traversals.rb +53 -0
  27. data/lib/acts_as_ordered_tree/persevering_transaction.rb +93 -0
  28. data/lib/acts_as_ordered_tree/position.rb +143 -0
  29. data/lib/acts_as_ordered_tree/relation/arrangeable.rb +33 -0
  30. data/lib/acts_as_ordered_tree/relation/iterable.rb +41 -0
  31. data/lib/acts_as_ordered_tree/relation/preloaded.rb +46 -11
  32. data/lib/acts_as_ordered_tree/transaction/base.rb +57 -0
  33. data/lib/acts_as_ordered_tree/transaction/callbacks.rb +67 -0
  34. data/lib/acts_as_ordered_tree/transaction/create.rb +68 -0
  35. data/lib/acts_as_ordered_tree/transaction/destroy.rb +34 -0
  36. data/lib/acts_as_ordered_tree/transaction/dsl.rb +214 -0
  37. data/lib/acts_as_ordered_tree/transaction/factory.rb +67 -0
  38. data/lib/acts_as_ordered_tree/transaction/move.rb +70 -0
  39. data/lib/acts_as_ordered_tree/transaction/passthrough.rb +12 -0
  40. data/lib/acts_as_ordered_tree/transaction/reorder.rb +42 -0
  41. data/lib/acts_as_ordered_tree/transaction/save.rb +64 -0
  42. data/lib/acts_as_ordered_tree/transaction/update.rb +78 -0
  43. data/lib/acts_as_ordered_tree/tree.rb +148 -0
  44. data/lib/acts_as_ordered_tree/tree/association.rb +20 -0
  45. data/lib/acts_as_ordered_tree/tree/callbacks.rb +57 -0
  46. data/lib/acts_as_ordered_tree/tree/children_association.rb +120 -0
  47. data/lib/acts_as_ordered_tree/tree/columns.rb +102 -0
  48. data/lib/acts_as_ordered_tree/tree/deprecated_columns_accessors.rb +24 -0
  49. data/lib/acts_as_ordered_tree/tree/parent_association.rb +31 -0
  50. data/lib/acts_as_ordered_tree/tree/perseverance.rb +19 -0
  51. data/lib/acts_as_ordered_tree/tree/scopes.rb +56 -0
  52. data/lib/acts_as_ordered_tree/validators.rb +1 -1
  53. data/lib/acts_as_ordered_tree/version.rb +1 -1
  54. data/spec/acts_as_ordered_tree_spec.rb +80 -909
  55. data/spec/adapters/postgresql_spec.rb +14 -0
  56. data/spec/adapters/recursive_spec.rb +12 -0
  57. data/spec/adapters/shared.rb +272 -0
  58. data/spec/callbacks_spec.rb +177 -0
  59. data/spec/counter_cache_spec.rb +31 -0
  60. data/spec/create_spec.rb +110 -0
  61. data/spec/destroy_spec.rb +57 -0
  62. data/spec/inheritance_spec.rb +176 -0
  63. data/spec/move_spec.rb +94 -0
  64. data/spec/node/movements/concurrent_movements_spec.rb +354 -0
  65. data/spec/node/movements/move_higher_spec.rb +46 -0
  66. data/spec/node/movements/move_lower_spec.rb +46 -0
  67. data/spec/node/movements/move_to_child_of_spec.rb +147 -0
  68. data/spec/node/movements/move_to_child_with_index_spec.rb +124 -0
  69. data/spec/node/movements/move_to_child_with_position_spec.rb +85 -0
  70. data/spec/node/movements/move_to_left_of_spec.rb +120 -0
  71. data/spec/node/movements/move_to_right_of_spec.rb +120 -0
  72. data/spec/node/movements/move_to_root_spec.rb +67 -0
  73. data/spec/node/predicates_spec.rb +211 -0
  74. data/spec/node/reloading_spec.rb +42 -0
  75. data/spec/node/siblings_spec.rb +193 -0
  76. data/spec/node/traversals_spec.rb +71 -0
  77. data/spec/persevering_transaction_spec.rb +98 -0
  78. data/spec/relation/arrangeable_spec.rb +88 -0
  79. data/spec/relation/iterable_spec.rb +104 -0
  80. data/spec/relation/preloaded_spec.rb +57 -0
  81. data/spec/reorder_spec.rb +83 -0
  82. data/spec/spec_helper.rb +30 -38
  83. data/spec/support/db/boot.rb +22 -0
  84. data/spec/{db → support/db}/config.travis.yml +2 -0
  85. data/spec/{db → support/db}/config.yml +1 -0
  86. data/spec/{db → support/db}/schema.rb +9 -0
  87. data/spec/support/factories.rb +2 -2
  88. data/spec/support/matchers.rb +67 -58
  89. data/spec/support/models.rb +6 -14
  90. data/spec/support/tree_factory.rb +315 -0
  91. data/spec/tree/children_association_spec.rb +72 -0
  92. data/spec/tree/columns_spec.rb +65 -0
  93. data/spec/tree/scopes_spec.rb +39 -0
  94. metadata +161 -43
  95. data/lib/acts_as_ordered_tree/adapters/postgresql_adapter.rb +0 -104
  96. data/lib/acts_as_ordered_tree/arrangeable.rb +0 -80
  97. data/lib/acts_as_ordered_tree/class_methods.rb +0 -72
  98. data/lib/acts_as_ordered_tree/relation/base.rb +0 -26
  99. data/lib/acts_as_ordered_tree/tenacious_transaction.rb +0 -30
  100. data/spec/concurrency_support_spec.rb +0 -156
data/spec/spec_helper.rb CHANGED
@@ -1,62 +1,54 @@
1
1
  ENV['DB'] ||= 'pg'
2
- test_dir = File.dirname(__FILE__)
3
2
 
4
3
  require 'rubygems'
5
4
  require 'bundler/setup'
6
5
 
7
6
  require 'rspec'
8
- require 'rspec-expectations'
9
-
10
- begin
11
- require 'simplecov'
12
- SimpleCov.start
13
- rescue LoadError
14
- #ignore
7
+ require 'rspec/expectations'
8
+
9
+ if ENV['COVERAGE'].to_i.nonzero?
10
+ begin
11
+ require 'simplecov'
12
+ SimpleCov.command_name "rspec/#{File.basename(ENV['BUNDLE_GEMFILE'])}/#{ENV['DB']}"
13
+ SimpleCov.start 'test_frameworks' do
14
+ add_filter 'vendor/'
15
+ end
16
+ rescue LoadError
17
+ #ignore
18
+ end
15
19
  end
16
20
 
17
- require 'active_record'
18
- require 'factory_girl'
19
-
20
- require 'acts_as_ordered_tree'
21
- require 'logger'
22
- require 'yaml'
23
- require 'erb'
24
-
25
- config_file = ENV['DBCONF'] || 'config.yml'
21
+ require 'support/db/boot'
26
22
 
27
- ActiveRecord::Base.configurations = YAML::load(ERB.new(IO.read(File.join(test_dir, 'db', config_file))).result)
28
- ActiveRecord::Base.establish_connection(ENV['DB'])
29
- ActiveRecord::Base.logger = Logger.new(ENV['DEBUG'] ? $stderr : '/dev/null')
30
- ActiveRecord::Migration.verbose = false
31
- I18n.enforce_available_locales = false if I18n.respond_to?(:enforce_available_locales=)
32
- load(File.join(test_dir, 'db', 'schema.rb'))
33
-
34
- require 'shoulda-matchers'
35
- require 'support/models'
23
+ require 'factory_girl'
36
24
  require 'support/factories'
37
25
  require 'support/matchers'
26
+ require 'support/tree_factory'
27
+ require 'database_cleaner'
38
28
 
39
29
  RSpec.configure do |config|
40
30
  config.include FactoryGirl::Syntax::Methods
31
+ config.extend TreeFactory
41
32
 
42
33
  config.treat_symbols_as_metadata_keys_with_true_values = true
43
34
 
44
35
  config.around :each, :transactional do |example|
45
- ActiveRecord::Base.transaction do
46
- example.run
36
+ DatabaseCleaner.strategy = :transaction
47
37
 
48
- raise ActiveRecord::Rollback
49
- end
38
+ DatabaseCleaner.start
39
+
40
+ example.run
41
+
42
+ DatabaseCleaner.clean
50
43
  end
51
44
 
52
45
  config.around :each, :non_transactional do |example|
53
- begin
54
- example.run
55
- ensure
56
- Default.delete_all
57
- DefaultWithCounterCache.delete_all
58
- DefaultWithCallbacks.delete_all
59
- Scoped.delete_all
60
- end
46
+ DatabaseCleaner.strategy = :truncation
47
+
48
+ DatabaseCleaner.start
49
+
50
+ example.run
51
+
52
+ DatabaseCleaner.clean
61
53
  end
62
54
  end
@@ -0,0 +1,22 @@
1
+ # This script establishes connection, creates DB schema and loads models definitions.
2
+ # Used by both rspec and cucumber
3
+
4
+ require 'acts_as_ordered_tree'
5
+ require 'active_record'
6
+
7
+ require 'logger'
8
+ require 'yaml'
9
+ require 'erb'
10
+
11
+ base_dir = File.dirname(__FILE__)
12
+ config_file = File.join(base_dir, ENV['DBCONF'] || 'config.yml')
13
+
14
+ ActiveRecord::Base.configurations = YAML::load(ERB.new(IO.read(config_file)).result)
15
+ ActiveRecord::Base.establish_connection(ENV['DB'].to_sym)
16
+ ActiveRecord::Base.logger = Logger.new(ENV['DEBUG'] ? $stderr : '/dev/null')
17
+ ActiveRecord::Migration.verbose = false
18
+ I18n.enforce_available_locales = false if I18n.respond_to?(:enforce_available_locales=)
19
+
20
+ load(File.join(base_dir, 'schema.rb'))
21
+
22
+ require File.join(base_dir, '..', 'models')
@@ -2,11 +2,13 @@ pg:
2
2
  adapter: postgresql
3
3
  username: postgres
4
4
  database: acts_as_ordered_tree_test
5
+ allow_concurrency: true
5
6
  min_messages: ERROR
6
7
  mysql:
7
8
  adapter: mysql2
8
9
  database: acts_as_ordered_tree_test
9
10
  username: travis
11
+ password:
10
12
  encoding: utf8
11
13
  sqlite3:
12
14
  adapter: sqlite3
@@ -1,6 +1,7 @@
1
1
  pg:
2
2
  adapter: postgresql
3
3
  database: acts_as_ordered_tree_test
4
+ allow_concurrency: true
4
5
  min_messages: ERROR
5
6
  mysql:
6
7
  adapter: mysql2
@@ -20,4 +20,13 @@ ActiveRecord::Schema.define(:version => 0) do
20
20
  t.column :parent_id, :integer
21
21
  t.column :position, :integer
22
22
  end
23
+
24
+ create_table :sti_examples, :force => true do |t|
25
+ t.column :type, :string, :null => false
26
+ t.column :name, :string
27
+ t.column :parent_id, :integer
28
+ t.column :position, :integer
29
+ t.column :depth, :integer
30
+ t.column :children_count, :integer
31
+ end
23
32
  end
@@ -1,5 +1,5 @@
1
1
  FactoryGirl.define do
2
- factory :default do
2
+ factory :default, :aliases => [:node] do
3
3
  sequence(:name) { |n| "category #{n}" }
4
4
  end
5
5
 
@@ -7,7 +7,7 @@ FactoryGirl.define do
7
7
  sequence(:name) { |n| "category #{n}" }
8
8
  end
9
9
 
10
- factory :default_with_callbacks do
10
+ factory :default_without_depth do
11
11
  sequence(:name) { |n| "category #{n}" }
12
12
  end
13
13
 
@@ -1,14 +1,9 @@
1
1
  module RSpec::Matchers
2
-
3
- # it { should fire_callback(:around_move).when_calling(:save) }
4
- # it { should fire_callback(:after_move).when_calling(:move_to_left_of, lft_id) }
5
- # it { should fire_callback(:around_move).owhen_callingn(:save).at_least(1).time }
6
- # it { should fire_callback(:around_move).when_calling(:save).at_most(2).times }
7
- # it { should fire_callback(:around_move).when_calling(:save).exactly(2).times }
8
- # it { should fire_callback(:around_move).when_calling(:save).once }
9
- # it { should fire_callback(:around_move).when_calling(:save).twice }
10
- def fire_callback(name)
11
- FireCallbackMatcher.new(name)
2
+ # it { expect{...}.to query_database.once }
3
+ # it { expect{...}.to query_database.at_most(2).times }
4
+ # it { expect{...}.not_to query_database }
5
+ def query_database(regexp = nil)
6
+ QueryDatabaseMatcher.new(regexp)
12
7
  end
13
8
 
14
9
  # example { expect(record1, record2, record3).to be_sorted }
@@ -16,22 +11,11 @@ module RSpec::Matchers
16
11
  OrderMatcher.new
17
12
  end
18
13
 
19
- class FireCallbackMatcher
20
- attr_reader :failure_message, :negative_failure_message
21
-
22
- def initialize(callback_name)
23
- @callback = callback_name
24
- @method = :save
25
- @args = []
26
-
27
- @limit_min = 1
28
- @limit_max = nil
29
- end
30
-
31
- def when_calling(method, *args)
32
- @method = method
33
- @args = args
34
- self
14
+ class QueryDatabaseMatcher
15
+ def initialize(regexp)
16
+ @min = nil
17
+ @max = nil
18
+ @regexp = regexp
35
19
  end
36
20
 
37
21
  def times
@@ -40,12 +24,12 @@ module RSpec::Matchers
40
24
  alias time times
41
25
 
42
26
  def at_least(times)
43
- @limit_min = times == :once ? 1 : times
27
+ @min = times == :once ? 1 : times
44
28
  self
45
29
  end
46
30
 
47
31
  def at_most(times)
48
- @limit_max = times == :once ? 1 : times
32
+ @max = times == :once ? 1 : times
49
33
  self
50
34
  end
51
35
 
@@ -62,55 +46,80 @@ module RSpec::Matchers
62
46
  exactly(2)
63
47
  end
64
48
 
65
- def description
66
- "fire callback #@callback when #@method is called"
49
+ def matches?(subject)
50
+ record_queries { subject.call }
51
+
52
+ result = expected_queries_count.include?(@queries.size)
53
+ result &&= @queries.any? { |q| @regexp === q } if @regexp
54
+ result
67
55
  end
68
56
 
69
- def matches?(subject)
70
- @subject = subject
57
+ def description
58
+ desc = 'query database'
59
+
60
+ if @min && !@max
61
+ desc << ' at least ' << human_readable_count(@min)
62
+ end
71
63
 
72
- raise 'Method required' unless @method
64
+ if @max && !@min
65
+ desc << ' at most ' << human_readable_count(@max)
66
+ end
73
67
 
74
- with_temporary_callback do |ivar|
75
- @subject.send(@method, *@args)
68
+ if @min && @max && @min != @max
69
+ desc << " #{@min}..#{@max} times"
70
+ end
76
71
 
77
- @received = @subject.instance_variable_get ivar
72
+ if @min && @max && @min == @max
73
+ desc << ' ' << human_readable_count(@min)
74
+ end
78
75
 
79
- (@limit_min..@limit_max || 1000).include?(@received)
76
+ if @regexp
77
+ desc << ' and match ' << @regexp.to_s
80
78
  end
79
+
80
+ desc
81
81
  end
82
82
 
83
- def failure_message_for_should
84
- "expected #{@subject.inspect} to fire callback :#@callback when #@method is called (#@limit_min..#@limit_max) times, #@received times fired"
83
+ def failure_message_for_should(negative = false)
84
+ verb = negative ? 'not to' : 'to'
85
+ message = "expected given block #{verb} #{description}, but #{@queries.size} queries sent"
86
+
87
+ if @queries.any?
88
+ message << ":\n#{@queries.each_with_index.map { |q, i| "#{i+1}. #{q}"}.join("\n")}"
89
+ end
90
+
91
+ message
85
92
  end
86
93
 
87
94
  def failure_message_for_should_not
88
- "expected #{@subject.inspect} not to fire callback :#@callback when #@method is called (#@limit_min..#@limit_max) times, #@received times fired"
95
+ failure_message_for_should(true)
96
+ end
97
+
98
+ def supports_block_expectations?
99
+ true
89
100
  end
90
101
 
91
102
  private
92
- def with_temporary_callback
93
- kind, name = @callback.to_s.split('_')
103
+ def record_queries
104
+ @queries = []
94
105
 
95
- method_name = :"__temporary_callback_#{object_id.abs}"
106
+ subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |*, sql|
107
+ next if sql[:name] == 'SCHEMA'
96
108
 
97
- @subject.class.class_eval <<-CODE
98
- def #{method_name}
99
- @#{method_name} ||= 0
100
- @#{method_name} += 1
101
- yield if block_given?
102
- end
103
- #{kind}_#{name} :#{method_name}
104
- CODE
109
+ @queries << sql[:sql]
110
+ end
105
111
 
106
- result = yield :"@#{method_name}"
112
+ yield
113
+ ensure
114
+ ActiveSupport::Notifications.unsubscribe(subscriber)
115
+ end
107
116
 
108
- @subject.class.class_eval <<-CODE
109
- skip_callback :#{name}, :#{kind}, :#{method_name}
110
- undef_method :#{method_name}
111
- CODE
117
+ def expected_queries_count
118
+ ((@min||1)..@max || 10000)
119
+ end
112
120
 
113
- result
121
+ def human_readable_count(n)
122
+ n == 1 ? 'once' : "#{n} times"
114
123
  end
115
124
  end
116
125
 
@@ -118,7 +127,7 @@ module RSpec::Matchers
118
127
  def matches?(*records)
119
128
  @records = Array.wrap(records).flatten
120
129
 
121
- @records.sort_by { |record| record.reload[record.position_column] } == @records
130
+ @records.sort_by { |record| record.reload.ordered_tree_node.position } == @records
122
131
  end
123
132
 
124
133
  def failure_message_for_should
@@ -22,22 +22,10 @@ class DefaultWithCounterCache < ActiveRecord::Base
22
22
  default_scope { where('1=1') }
23
23
  end
24
24
 
25
- class DefaultWithCallbacks < ActiveRecord::Base
25
+ class DefaultWithoutDepth < ActiveRecord::Base
26
26
  self.table_name = 'categories'
27
27
 
28
- acts_as_ordered_tree
29
-
30
- default_scope { where('1=1') }
31
-
32
- after_move :after_move
33
- before_move :before_move
34
- after_reorder :after_reorder
35
- before_reorder :before_reorder
36
-
37
- def after_move; end
38
- def before_move; end
39
- def after_reorder; end
40
- def before_reorder; end
28
+ acts_as_ordered_tree :depth_column => false
41
29
  end
42
30
 
43
31
  class Scoped < ActiveRecord::Base
@@ -46,4 +34,8 @@ class Scoped < ActiveRecord::Base
46
34
  default_scope { where('1=1') }
47
35
 
48
36
  acts_as_ordered_tree :scope => :scope_type
37
+ end
38
+
39
+ class StiExample < ActiveRecord::Base
40
+ acts_as_ordered_tree :counter_cache => :children_count
49
41
  end
@@ -0,0 +1,315 @@
1
+ require 'set'
2
+
3
+ # @example
4
+ # describe Something do
5
+ # tree :factory => :my_model_factory, :attributes => {:scope_type => 'xxx'} do
6
+ # root {
7
+ # child_1
8
+ # child_2 {
9
+ # child_3 :name => 'a child'
10
+ # }
11
+ # }
12
+ # end
13
+ #
14
+ # it 'should have root' do
15
+ # expect(root).to have(2).children
16
+ # end
17
+ #
18
+ # it 'should move node' do
19
+ # child_2.move_higher
20
+ # expect(current_tree).to match_tree ->{
21
+ # root {
22
+ # child_2, :position => 1 do
23
+ # child_3 :name => 'a child'
24
+ # end
25
+ # child_1, :position => 2
26
+ # }
27
+ # }
28
+ # end
29
+ # end
30
+ module TreeFactory
31
+ module AbstractNode
32
+ attr_reader :parent
33
+ attr_accessor :position
34
+
35
+ def children
36
+ @children ||= []
37
+ end
38
+
39
+ def descendants
40
+ children.map { |child| [child] + child.descendants }.flatten
41
+ end
42
+
43
+ def self_and_descendants
44
+ [self] + descendants
45
+ end
46
+
47
+ def parent=(value)
48
+ @parent = value
49
+
50
+ if @parent
51
+ @parent.children << self
52
+ end
53
+ end
54
+
55
+ def ancestors
56
+ parent ? parent.ancestors + [parent] : []
57
+ end
58
+
59
+ def level
60
+ ancestors.size
61
+ end
62
+
63
+ def indentation
64
+ ' ' * 2 * level
65
+ end
66
+
67
+ def inspect_attributes
68
+ attributes.map do |k, v|
69
+ " / #{k} = #{v}"
70
+ end.join
71
+ end
72
+
73
+ def inspect_children
74
+ result = String.new
75
+
76
+ if children.any?
77
+ result << "\n"
78
+ result << children.map(&:inspect).join("\n")
79
+ end
80
+
81
+ result
82
+ end
83
+
84
+ def matches?(record)
85
+ record &&
86
+ record.level == level &&
87
+ record.ordered_tree_node.position == position &&
88
+ attributes_matches?(record) &&
89
+ record.children.size == children.size &&
90
+ children.zip(record.children).all? { |n, r| n.matches?(r) }
91
+ end
92
+
93
+ def attributes_matches?(record)
94
+ attributes.all? do |attr, value|
95
+ if attr.is_a?(Symbol)
96
+ record.__send__(attr)
97
+ else
98
+ record.instance_eval(attr)
99
+ end == value
100
+ end
101
+ end
102
+ end
103
+
104
+ class Node < Struct.new(:name, :attributes)
105
+ include AbstractNode
106
+
107
+ attr_accessor :context
108
+
109
+ def inspect
110
+ if context
111
+ result = indentation
112
+ result << record_name
113
+ result << " / id = #{as_record.id}"
114
+ result << inspect_attributes
115
+ result << inspect_children
116
+ result
117
+ else
118
+ super
119
+ end
120
+ end
121
+
122
+ def matches?(record)
123
+ record && record == as_record && super
124
+ end
125
+
126
+ private
127
+ def as_record
128
+ @record ||= context.__send__(name)
129
+ end
130
+
131
+ def record_name
132
+ to_s = [:name, :to_str, :to_s].detect { |m| as_record.respond_to?(m) }
133
+ as_record.__send__(to_s)
134
+ end
135
+ end
136
+
137
+ class AnyNode < Struct.new(:attributes)
138
+ include AbstractNode
139
+
140
+ def inspect
141
+ result = indentation
142
+ result << '*'
143
+ result << inspect_attributes
144
+ result << inspect_children
145
+ result
146
+ end
147
+ end
148
+
149
+ class Parser
150
+ attr_reader :ast
151
+
152
+ def initialize(options = {})
153
+ @attributes = options.fetch(:attributes, {})
154
+ @parent = nil
155
+ end
156
+
157
+ def parse(context, &tree)
158
+ @ast = []
159
+ @context = context
160
+ instance_exec(&tree)
161
+ ast
162
+ end
163
+
164
+ private
165
+ def with_parent(parent_node, &block)
166
+ old, @parent = @parent, parent_node
167
+ instance_exec(&block) if block_given?
168
+ ensure
169
+ @parent = old
170
+ end
171
+
172
+ def node(name, attributes = {}, &block)
173
+ build_node(block) do
174
+ Node.new(name.to_sym, @attributes.merge(attributes))
175
+ .tap { |x| x.context = @context }
176
+ end
177
+ end
178
+
179
+ def any(attributes = {}, &block)
180
+ build_node(block) { AnyNode.new(@attributes.merge(attributes)) }
181
+ end
182
+
183
+ def method_missing(name, attributes = {}, &block)
184
+ node(name, attributes, &block)
185
+ end
186
+
187
+ def build_node(children_block)
188
+ node = yield
189
+
190
+ node.parent = @parent
191
+
192
+ if node.parent
193
+ node.position = node.parent.children.size
194
+ else
195
+ @ast << node
196
+ node.position = @ast.size
197
+ end
198
+
199
+ with_parent(node, &children_block)
200
+ end
201
+ end
202
+
203
+ class Builder
204
+ attr_reader :factory
205
+
206
+ def initialize(test_suite, options)
207
+ @parser = Parser.new(options)
208
+ @suite = test_suite
209
+ @factory = options.fetch(:factory, @suite.described_class.name.underscore)
210
+ end
211
+
212
+ def build(&tree)
213
+ memoize_nodes_class
214
+
215
+ ast = @parser.parse(@suite, &tree)
216
+ ast.each { |o| build_node(o) }
217
+ end
218
+
219
+ private
220
+ def build_node(node)
221
+ factory = @factory
222
+
223
+ raise 'Cannot build ANY node in BEFORE section' if node.is_a?(AnyNode)
224
+
225
+ @suite.let!(node.name) do
226
+ parent = node.parent && send(node.parent.name)
227
+
228
+ create factory, node.attributes.merge(:parent => parent)
229
+ end
230
+
231
+ node.children.each { |o| build_node(o) }
232
+ end
233
+
234
+ def memoize_nodes_class
235
+ factory = FactoryGirl.factory_by_name(@factory)
236
+ @suite.let(:current_tree) { factory.build_class }
237
+ end
238
+ end
239
+
240
+ # Class level helper
241
+ def tree(options, &block)
242
+ Builder.new(self, options).build(&block)
243
+ end
244
+
245
+ def expect_tree_to_match(&block)
246
+ caller = caller(1).first
247
+ file, line, * = caller.split(':')
248
+ location = [file, line].join(':')
249
+ example = it { expect(current_tree).to match_tree(block) }
250
+ example.metadata[:location] = location
251
+ example.metadata[:file_path] = file
252
+ end
253
+ end
254
+
255
+ RSpec::Matchers.define :match_tree do |tree|
256
+ match do |tree_klass|
257
+ @expected_tree = tree
258
+ @klass = tree_klass
259
+
260
+ ast.zip(@klass.roots).all? { |node, record| node.matches?(record) }
261
+ end
262
+
263
+ failure_message_for_should do |tree_klass|
264
+ message = "expected actual tree\n\n"
265
+ message << inspect_actual_tree(tree_klass)
266
+ message << "\nto match\n\n"
267
+ message << ast.map(&:inspect).join("\n")
268
+ end
269
+
270
+ def ast
271
+ @ast ||= TreeFactory::Parser.new.parse(self, &@expected_tree)
272
+ end
273
+
274
+ def attributes_to_expose
275
+ @attributes_to_expose ||= flatten_ast.each_with_object(Set[:id]) do |node, attrs|
276
+ attrs.merge(node.attributes.keys)
277
+ end.to_a
278
+ end
279
+
280
+ def flatten_ast
281
+ ast.map(&:self_and_descendants).flatten
282
+ end
283
+
284
+ def inspect_actual_tree(tree_klass)
285
+ result = String.new
286
+
287
+ tree_klass.roots.each do |root|
288
+ root.self_and_descendants.each do |record|
289
+ result << "#{inspect_record(record)}\n"
290
+ end
291
+ end
292
+
293
+ result
294
+ end
295
+
296
+ def inspect_record(record)
297
+ result = String.new
298
+ result << indentation(record.level)
299
+ result << record_name(record)
300
+
301
+ attributes_to_expose.each do |attr|
302
+ result << " / #{attr} = #{record.send(attr)}"
303
+ end
304
+ result
305
+ end
306
+
307
+ def record_name(record)
308
+ to_s = [:name, :to_str, :to_s].detect { |m| record.respond_to?(m) }
309
+ record.send(to_s)
310
+ end
311
+
312
+ def indentation(level)
313
+ ' ' * level * 2
314
+ end
315
+ end