acts_as_ordered_tree 1.3.1 → 2.0.0.beta3

Sign up to get free protection for your applications and to get access to all the features.
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