Skip to content

Commit 97f5bdc

Browse files
authored
Release R2.2.0 (#114)
* Preparing for 2.1.2. - Updated the version to 2.1.2pre - Linked the Github repository in the metadata (for Github packages) * Updated the Rakefile to publish packages to the Github registry. Registry: <https://github.com/evolve75/RubyTree/pkgs/rubygems/rubytree> * Prevent adding ancestor as child * Detach children on remove_all! Ensure children become true roots after remove_all! by clearing their parent links. This matches the method's contract and prevents stale parent references from corrupting later traversal or metrics. * Guard rename_child collisions Raise when renaming a child to an existing sibling name to prevent overwriting entries in the children hash. This avoids orphaning nodes and keeps child lookup consistent. * Harden binary child assignment Fix set_child_at index errors and clean up parent/hash references when replacing or clearing children. This preserves swap semantics and avoids stale lookups. * Handle nils in traversal Skip nil children during postorder and breadth-first traversals so binary nodes with missing children do not break traversal. Add a regression test covering nil child traversal paths. * Fix each_level enumerator Return a level-wise enumerator when no block is given and add a test to assert the yielded level arrays match the tree structure. * Fix to_s empty content Render '<Empty>' when a node has nil content and add tests for both nil and non-nil content values. * Document 2.2.0 API changes Add a new 2.2.0 section covering recent behavior changes in tree operations and traversal, plus link references for the new entries. * Release 2.2.0 Bump the gem version to 2.2.0 and record the 2.2.0 changes in the project history.
1 parent f99daed commit 97f5bdc

10 files changed

Lines changed: 166 additions & 15 deletions

File tree

API-CHANGES.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@ _Note_: API changes are expected to reduce significantly after the `1.x`
77
release. In most cases, an alternative will be provided to ensure relatively
88
smooth transition to the new APIs.
99

10+
## Release 2.2.0 Changes
11+
12+
* [Tree::TreeNode#add][add] now raises `ArgumentError` when attempting to add
13+
an ancestor node as a child, preventing cycles.
14+
15+
* [Tree::TreeNode#remove_all!][remove_all] now detaches children by clearing
16+
their parent links.
17+
18+
* [Tree::TreeNode#rename_child][rename_child] now raises `ArgumentError` if the
19+
new name collides with an existing sibling.
20+
21+
* [Tree::BinaryTreeNode#set_child_at][set_child_at] now raises `ArgumentError`
22+
for invalid indices and cleans up parent/hash references when replacing or
23+
clearing a child.
24+
25+
* [Tree::TreeNode#postordered_each][postordered_each] and
26+
[Tree::TreeNode#breadth_each][breadth_each] now skip `nil` children to
27+
support binary trees with missing children.
28+
29+
* [Tree::TreeNode#each_level][each_level] now returns a level-wise enumerator
30+
when called without a block.
31+
1032
## Release 2.1.0 Changes
1133

1234
* Minimum Ruby version has been bumped to 2.7 and above
@@ -143,6 +165,7 @@ smooth transition to the new APIs.
143165
[detached_subtree_copy]: rdoc-ref:Tree::TreeNode#detached_subtree_copy
144166
[dup]: rdoc-ref:Tree::TreeNode#dup
145167
[each]: rdoc-ref:Tree::TreeNode#each
168+
[each_level]: rdoc-ref:Tree::TreeNode#each_level
146169
[in_degree]: rdoc-ref:Tree::Utils::TreeMetricsHandler#in_degree
147170
[initialize]: rdoc-ref:Tree::TreeNode#initialize
148171
[inordered_each]: rdoc-ref:Tree::BinaryTreeNode#inordered_each
@@ -155,5 +178,8 @@ smooth transition to the new APIs.
155178
[postordered_each]: rdoc-ref:Tree::TreeNode#postordered_each
156179
[preordered_each]: rdoc-ref:Tree::TreeNode#preordered_each
157180
[previous_sibling]: rdoc-ref:Tree::TreeNode#previous_sibling
181+
[remove_all]: rdoc-ref:Tree::TreeNode#remove_all!
182+
[rename_child]: rdoc-ref:Tree::TreeNode#rename_child
183+
[set_child_at]: rdoc-ref:Tree::BinaryTreeNode#set_child_at
158184
[siblings]: rdoc-ref:Tree::TreeNode#siblings
159185
[to_json]: rdoc-ref:Tree::Utils::JSONConverter#to_json

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
rubytree (2.1.1)
4+
rubytree (2.2.0)
55
json (~> 2.0, > 2.9)
66

77
GEM

History.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# History of Changes
22

3+
### 2.2.0 / 2026-02-06
4+
5+
* Prevent cycles by rejecting attempts to add an ancestor as a child.
6+
7+
* Ensure `remove_all!` detaches children by clearing their parent links.
8+
9+
* Raise on sibling name collisions in `rename_child`.
10+
11+
* Harden binary tree child assignment (`set_child_at`) with proper index errors
12+
and cleanup of parent/hash references.
13+
14+
* Make traversals resilient to missing children by skipping `nil` nodes in
15+
`postordered_each` and `breadth_each`.
16+
17+
* Return a level-wise enumerator from `each_level` when no block is given.
18+
19+
* Improve `to_s` formatting to show `<Empty>` for nil content.
20+
321
### 2.1.1 / 2024-12-19
422

523
* 2.1.1 is a minor update that updates all dependencies and updates the guard

Rakefile

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# Rakefile - This file is part of the RubyTree package.
44
#
5-
# Copyright (c) 2006-2022 Anupam Sengupta
5+
# Copyright (c) 2006-2024 Anupam Sengupta
66
#
77
# All rights reserved.
88
#
@@ -160,9 +160,16 @@ namespace :gem do
160160
pkg.need_tar = true
161161
end
162162

163-
desc 'Push the gem into the Rubygems repository'
163+
desc 'Push the gem into the Rubygems and Github repositories'
164164
task push: :gem do
165+
github_repo = 'https://rubygems.pkg.github.com/evolve75'
166+
167+
# This pushes to the standard RubyGems registry
165168
sh "gem push pkg/#{GEM_NAME}"
169+
170+
# For github, the credentials key is assumed to be github
171+
# See: https://docs.github.com/en/packages/working-with-a-github-packages-registry/
172+
sh "gem push --key github --host #{github_repo} pkg/#{GEM_NAME}"
166173
end
167174
end
168175

lib/tree.rb

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,8 @@ def marshal_load(dumped_tree_array)
318318
#
319319
# @return [String] A string representation of the node.
320320
def to_s
321-
"Node Name: #{@name} Content: #{@content.to_s || '<Empty>'} " \
321+
content_str = @content.nil? ? '<Empty>' : @content.to_s
322+
"Node Name: #{@name} Content: #{content_str} " \
322323
"Parent: #{root? ? '<None>' : @parent.name.to_s} " \
323324
"Children: #{@children.length} Total Nodes: #{size}"
324325
end
@@ -394,6 +395,10 @@ def add(child, at_index = -1)
394395

395396
raise ArgumentError, 'Attempting add root as a child' if child.equal?(root)
396397

398+
if (ancestors = parentage) && ancestors.include?(child)
399+
raise ArgumentError, 'Attempting add ancestor as a child'
400+
end
401+
397402
# Lazy man's unique test, won't test if children of child are unique in
398403
# this tree too.
399404
raise "Child #{child.name} already added!"\
@@ -453,6 +458,9 @@ def rename_child(old_name, new_name)
453458
raise ArgumentError, "Invalid child name specified: #{old_name}"\
454459
unless @children_hash.key?(old_name)
455460

461+
raise ArgumentError, "Child name already exists: #{new_name}"\
462+
if @children_hash.key?(new_name)
463+
456464
@children_hash[new_name] = @children_hash.delete(old_name)
457465
@children_hash[new_name].name = new_name
458466
end
@@ -539,7 +547,10 @@ def remove_from_parent!
539547
# @see #remove!
540548
# @see #remove_from_parent!
541549
def remove_all!
542-
@children.each(&:remove_all!)
550+
@children.each do |child|
551+
child.remove_all!
552+
child.set_as_root!
553+
end
543554

544555
@children_hash.clear
545556
@children.clear
@@ -669,7 +680,7 @@ def postordered_each
669680
peek_node.visited = true
670681
# Add the children to the stack. Use the marking structure.
671682
marked_children =
672-
peek_node.node.children.map { |node| marked_node.new(node, false) }
683+
peek_node.node.children.compact.map { |node| marked_node.new(node, false) }
673684
node_stack = marked_children.concat(node_stack)
674685
next
675686
else
@@ -700,9 +711,11 @@ def breadth_each
700711
# Use a queue to do breadth traversal
701712
until node_queue.empty?
702713
node_to_traverse = node_queue.shift
714+
next unless node_to_traverse
715+
703716
yield node_to_traverse
704717
# Enqueue the children from left to right.
705-
node_to_traverse.children { |child| node_queue.push child }
718+
node_to_traverse.children { |child| node_queue.push child if child }
706719
end
707720

708721
self if block_given?
@@ -770,7 +783,7 @@ def each_level
770783
end
771784
self
772785
else
773-
each
786+
to_enum(:each_level)
774787
end
775788
end
776789

lib/tree/binarytree.rb

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,12 +203,29 @@ def inordered_each
203203
#
204204
# @raise [ArgumentError] If the index is out of limits.
205205
def set_child_at(child, at_index)
206-
raise ArgumentError 'A binary tree cannot have more than two children.'\
207-
unless (0..1).include? at_index
206+
raise ArgumentError, 'A binary tree cannot have more than two children.'\
207+
unless (0..1).include? at_index
208208

209-
@children[at_index] = child
210-
@children_hash[child.name] = child if child # Assign the name mapping
211-
child.parent = self if child
209+
old_child = @children[at_index]
210+
if old_child && old_child != child
211+
still_present = @children.each_with_index.any? do |existing, idx|
212+
idx != at_index && existing.equal?(old_child)
213+
end
214+
215+
unless still_present
216+
@children_hash.delete(old_child.name)
217+
old_child.set_as_root!
218+
end
219+
end
220+
221+
if child
222+
child.parent&.remove!(child) unless child.parent == self
223+
@children[at_index] = child
224+
@children_hash[child.name] = child # Assign the name mapping
225+
child.parent = self
226+
else
227+
@children[at_index] = nil
228+
end
212229
child
213230
end
214231

lib/tree/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,5 @@
3535

3636
module Tree
3737
# Rubytree Package Version
38-
VERSION = '2.1.1'
38+
VERSION = '2.2.0'
3939
end

rubytree.gemspec

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ Gem::Specification.new do |s|
4444
END_DESC
4545

4646
s.metadata = {
47-
'rubygems_mfa_required' => 'true'
47+
'rubygems_mfa_required' => 'true',
48+
'github_repo' => 'ssh://github.com/evolve75/rubytree'
4849
}
4950

5051
s.files = Dir['lib/**/*.rb'] # The actual code

test/test_binarytree.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,9 @@ def test_left_child_equals
229229
assert_nil(@root.left_child, 'The left child should now be nil')
230230
assert_nil(@root.first_child, 'The first child is now nil')
231231
assert_equal('B Child at Right', @root.last_child.name, 'The last child should now be the right child')
232+
assert(@left_child1.root?, 'The old left child should now be a root')
233+
assert_nil(@left_child1.parent, 'The old left child should not have a parent')
234+
assert_nil(@root['A Child at Left'], 'Lookup by old left name should be nil')
232235
end
233236

234237
# Test right_child= method.
@@ -249,6 +252,15 @@ def test_right_child_equals
249252
assert_nil(@root.right_child, 'The right child should now be nil')
250253
assert_equal('A Child at Left', @root.first_child.name, 'The first child should now be the left child')
251254
assert_nil(@root.last_child, 'The first child is now nil')
255+
assert(@right_child1.root?, 'The old right child should now be a root')
256+
assert_nil(@right_child1.parent, 'The old right child should not have a parent')
257+
assert_nil(@root['B Child at Right'], 'Lookup by old right name should be nil')
258+
end
259+
260+
# Test invalid index error for set_child_at.
261+
def test_set_child_at_invalid_index
262+
error = assert_raise(ArgumentError) { @root.send(:set_child_at, @left_child1, 2) }
263+
assert_match(/cannot have more than two children/i, error.message)
252264
end
253265

254266
# Test isLeft_child? method.
@@ -297,5 +309,27 @@ def test_swap_children
297309
assert_equal(@right_child1, @root[0], 'right_child1 should now be the first child')
298310
assert_equal(@left_child1, @root[1], 'left_child1 should now be the last child')
299311
end
312+
313+
# Test traversals when nil children exist.
314+
def test_traversal_with_nil_children
315+
@root << @left_child1
316+
@root << @right_child1
317+
318+
@root.right_child = nil
319+
320+
breadth_nodes = []
321+
post_nodes = []
322+
323+
assert_nothing_raised do
324+
@root.breadth_each { |node| breadth_nodes << node }
325+
end
326+
327+
assert_nothing_raised do
328+
@root.postordered_each { |node| post_nodes << node }
329+
end
330+
331+
assert_equal([@root, @left_child1], breadth_nodes)
332+
assert_equal([@left_child1, @root], post_nodes)
333+
end
300334
end
301335
end

test/test_tree.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def test_root_setup
9494
assert_not_nil(@root.name, 'Name should not be nil')
9595
assert_equal('ROOT', @root.name, "Name should be 'ROOT'")
9696
assert_equal('Root Node', @root.content, "Content should be 'Root Node'")
97+
assert(@root.to_s.include?('Content: Root Node'), 'to_s should include content value')
9798
assert(@root.root?, 'Should identify as root')
9899
assert(!@root.children?, 'Cannot have any children')
99100
assert(@root.content?, 'This root should have content')
@@ -117,6 +118,11 @@ def test_root
117118
assert_equal(2, @root.node_height, "Root's height after adding the children should be 2")
118119
end
119120

121+
def test_to_s_empty_content
122+
empty = Tree::TreeNode.new('EMPTY')
123+
assert_match(/Content: <Empty>/, empty.to_s)
124+
end
125+
120126
def test_from_hash
121127
# A
122128
# / | \
@@ -491,6 +497,10 @@ def test_add
491497

492498
# Test the addition of a nil node.
493499
assert_raise(ArgumentError) { @root.add(nil) }
500+
501+
# Test adding an ancestor as a child (cycle prevention).
502+
error = assert_raise(ArgumentError) { @child4.add(@child3) }
503+
assert_match(/Attempting add ancestor as a child/, error.message)
494504
end
495505

496506
# Test the addition of a duplicate node (duplicate being defined as a node with the same name).
@@ -689,6 +699,16 @@ def test_remove_all_bang
689699

690700
assert(!@root.children?, 'Should have no children')
691701
assert_equal(1, @root.size, 'Should have one node')
702+
703+
# Removed children should be detached (root? == true).
704+
assert(@child1.root?, 'Child1 should be a root after remove_all!')
705+
assert(@child2.root?, 'Child2 should be a root after remove_all!')
706+
assert(@child3.root?, 'Child3 should be a root after remove_all!')
707+
assert(@child4.root?, 'Child4 should be a root after remove_all!')
708+
assert_nil(@child1.parent, 'Child1 parent should be nil after remove_all!')
709+
assert_nil(@child2.parent, 'Child2 parent should be nil after remove_all!')
710+
assert_nil(@child3.parent, 'Child3 parent should be nil after remove_all!')
711+
assert_nil(@child4.parent, 'Child4 parent should be nil after remove_all!')
692712
end
693713

694714
# Test the remove_from_parent! method.
@@ -832,6 +852,18 @@ def test_each_leaf
832852
assert(result_array.include?(@child4), 'Should have child 4')
833853
end
834854

855+
# Test the each_level method without a block (Enumerator).
856+
def test_each_level
857+
setup_test_tree
858+
859+
levels = @root.each_level.to_a
860+
861+
assert_equal(3, levels.length, 'Should have three levels')
862+
assert_equal([@root], levels[0])
863+
assert_equal([@child1, @child2, @child3], levels[1])
864+
assert_equal([@child4], levels[2])
865+
end
866+
835867
# Test the parent method.
836868
def test_parent
837869
setup_test_tree
@@ -1531,6 +1563,9 @@ def test_rename_child
15311563

15321564
assert_raise(ArgumentError) { @root.rename_child('Not_Present_Child1', 'ALT_Child1') }
15331565

1566+
error = assert_raise(ArgumentError) { @root.rename_child('Child1', 'Child2') }
1567+
assert_match(/Child name already exists: Child2/, error.message)
1568+
15341569
@root.rename_child('Child1', 'ALT_Child1')
15351570
assert_equal('ALT_Child1', @child1.name, "Name should be 'ALT_Child1'")
15361571
assert_equal(@child1, @root['ALT_Child1'], 'Should be able to access from parent using new name')

0 commit comments

Comments
 (0)