diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 5e1caa7..6b33399 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -1,5 +1,8 @@ # Build and test the RubyTree library. # +# The matrix runs the suite against both the json gem 2.x and 3.x, selected +# through the Gemfile variants in the gemfiles/ directory. +# # This runs as a Github Action. name: Build and Test @@ -8,11 +11,15 @@ permissions: contents: read jobs: test: + name: Ruby ${{ matrix.ruby }} / json ${{ matrix.json }} strategy: fail-fast: false matrix: ruby: ["2.7", "3.0", "3.1", "3.2", "3.3"] + json: ["2", "3"] runs-on: ubuntu-latest + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/json_${{ matrix.json }}.gemfile steps: - uses: actions/checkout@v7 - uses: ruby/setup-ruby@v1 diff --git a/.gitignore b/.gitignore index 3663129..bc596f8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ tmp/ vendor/ .idea/ Gemfile.lock +gemfiles/*.lock diff --git a/.rubocop.yml b/.rubocop.yml index ef7483d..1d14a8f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,6 +3,11 @@ # Inherit from the working file. inherit_from: .rubocop_todo.yml +# Load the rake and rspec extensions as plugins. +plugins: + - rubocop-rake + - rubocop-rspec + AllCops: NewCops: enable TargetRubyVersion: 2.7 diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index ddbffeb..edbc835 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,41 +1,97 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2022-06-20 06:06:47 UTC using RuboCop version 1.24.0. +# on 2026-10-07 08:17:12 UTC using RuboCop version 1.91.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 47 -# Configuration parameters: IgnoredMethods, CountRepeatedAttributes. +# Offense count: 15 +# Configuration parameters: EnforcedStyle, AllowedGems. +# SupportedStyles: Gemfile, gems.rb, gemspec +Gemspec/DevelopmentDependencies: + Exclude: + - 'rubytree.gemspec' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: leading, trailing +Layout/LineContinuationLeadingSpace: + Exclude: + - 'lib/tree.rb' + +# Offense count: 13 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: space, no_space +Layout/LineContinuationSpacing: + Exclude: + - 'lib/tree.rb' + - 'lib/tree/binarytree.rb' + - 'lib/tree/utils/hash_converter.rb' + +# Offense count: 50 +# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: Max: 97 # Offense count: 2 -# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods. -# IgnoredMethods: refine +# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode. +# AllowedMethods: refine Metrics/BlockLength: - Max: 119 + Max: 34 # Offense count: 3 # Configuration parameters: CountComments, CountAsOne. Metrics/ClassLength: - Max: 1066 + Max: 1109 -# Offense count: 2 -# Configuration parameters: IgnoredMethods. +# Offense count: 4 +# Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/CyclomaticComplexity: - Max: 8 + Max: 10 -# Offense count: 47 -# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods. +# Offense count: 51 +# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: Max: 45 -# Offense count: 1 -# Configuration parameters: IgnoredMethods. +# Offense count: 3 +# Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/PerceivedComplexity: - Max: 9 + Max: 11 + +# Offense count: 1 +# Configuration parameters: EnforcedStyle, AllowedPatterns, ForbiddenIdentifiers, ForbiddenPatterns. +# SupportedStyles: snake_case, camelCase +# ForbiddenIdentifiers: __id__, __send__ +Naming/MethodName: + Exclude: + - 'test/test_tree.rb' + +# Offense count: 4 +# Configuration parameters: Max, AllowedIdentifiers, AllowedPatterns. +RSpec/IndexedLet: + Exclude: + - 'spec/tree_spec.rb' + +# Offense count: 1 +RSpec/LeakyLocalVariable: + Exclude: + - 'spec/tree_spec.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +RSpec/Output: + Exclude: + - 'spec/spec_helper.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +RSpec/RedundantPredicateMatcher: + Exclude: + - 'spec/tree_spec.rb' # Offense count: 5 Security/MarshalLoad: @@ -43,3 +99,23 @@ Security/MarshalLoad: - 'lib/tree.rb' - 'spec/tree_spec.rb' - 'test/test_tree.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/MapIntoArray: + Exclude: + - 'test/test_tree.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowedMethods. +# AllowedMethods: infinite?, nonzero? +Style/RedundantCondition: + Exclude: + - 'lib/tree.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Style/SuperArguments: + Exclude: + - 'lib/tree/binarytree.rb' diff --git a/Gemfile.lock b/Gemfile.lock index 2654e85..25708e9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,12 +2,12 @@ PATH remote: . specs: rubytree (2.2.2) - json (~> 2.0, >= 2.21.2) + json (>= 2.21.2, < 4) GEM remote: https://rubygems.org/ specs: - ast (2.4.2) + ast (2.4.3) awesome_bot (1.20.0) parallel (= 1.20.1) bundler-audit (0.9.3) @@ -19,12 +19,13 @@ GEM date (3.4.1) diff-lcs (1.5.1) docile (1.4.1) - json (2.21.2) + json (3.0.2) kramdown (2.5.2) rexml (>= 3.4.4) kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) - language_server-protocol (3.17.0.3) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) mdl (0.14.0) kramdown (~> 2.3) kramdown-parser-gfm (~> 1.1) @@ -37,10 +38,11 @@ GEM mixlib-shellout (3.3.8) chef-utils parallel (1.20.1) - parser (3.3.6.0) + parser (3.3.12.0) ast (~> 2.4.1) racc power_assert (2.0.4) + prism (1.9.0) psych (5.2.2) date stringio @@ -49,7 +51,7 @@ GEM rake (13.2.1) rdoc (6.10.0) psych (>= 4.0.0) - regexp_parser (2.9.3) + regexp_parser (2.13.1) rexml (3.4.4) rspec (3.13.0) rspec-core (~> 3.13.0) @@ -67,22 +69,27 @@ GEM rtags (0.97) rtagstask (0.0.4) rtags (> 0.0.0) - rubocop (1.69.2) - json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) + rubocop (1.91.0) + json (>= 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.36.2, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.37.0) - parser (>= 3.3.1.0) - rubocop-rake (0.6.0) - rubocop (~> 1.0) - rubocop-rspec (3.3.0) - rubocop (~> 1.61) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-rake (0.7.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-rspec (3.10.2) + lint_roller (~> 1.1) + regexp_parser (>= 2.0) + rubocop (~> 1.86, >= 1.86.2) ruby-progressbar (1.13.0) simplecov (0.22.0) docile (~> 1.1) @@ -96,9 +103,9 @@ GEM power_assert thor (1.5.0) tomlrb (2.0.4) - unicode-display_width (3.1.2) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) + unicode-display_width (3.3.0) + unicode-emoji (~> 4.3) + unicode-emoji (4.3.0) yard (0.9.44) PLATFORMS @@ -106,14 +113,13 @@ PLATFORMS DEPENDENCIES awesome_bot (~> 1.20) - bundler (~> 2.3) bundler-audit (~> 0.9) mdl (~> 0.14) rake (~> 13.2) rdoc (~> 6.10) rspec (~> 3.0, >= 3.13) rtagstask (~> 0.0.4) - rubocop (~> 1.69) + rubocop (~> 1.90) rubocop-rake (~> 0.6) rubocop-rspec (~> 3.3) rubytree! diff --git a/README.md b/README.md index d6d52cc..273c303 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ This example can also be found at * Run-time Dependencies: - * [JSON][] for converting to/from the JSON format + * [JSON][] (2.x or 3.x) for converting to/from the JSON format * Development dependencies (not required for installing the gem): diff --git a/Rakefile b/Rakefile index ce0dd1b..f73aabb 100644 --- a/Rakefile +++ b/Rakefile @@ -194,8 +194,6 @@ require 'rubocop/rake_task' RuboCop::RakeTask.new(:rubocop) do |t| t.options = ['--display-cop-names'] - t.requires << 'rubocop-rake' - t.requires << 'rubocop-rspec' end # ................................ Gem metadata diff --git a/gemfiles/json_2.gemfile b/gemfiles/json_2.gemfile new file mode 100644 index 0000000..304092d --- /dev/null +++ b/gemfiles/json_2.gemfile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Variant of the main Gemfile which pins the json gem to its 2.x line. The +# test matrix selects it through the BUNDLE_GEMFILE environment variable. +eval_gemfile File.expand_path('../Gemfile', __dir__) + +# JSON Implementation for Ruby +gem 'json', '~> 2.21' diff --git a/gemfiles/json_3.gemfile b/gemfiles/json_3.gemfile new file mode 100644 index 0000000..22585db --- /dev/null +++ b/gemfiles/json_3.gemfile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Variant of the main Gemfile which pins the json gem to its 3.x line. The +# test matrix selects it through the BUNDLE_GEMFILE environment variable. +eval_gemfile File.expand_path('../Gemfile', __dir__) + +# JSON Implementation for Ruby +gem 'json', '~> 3.0' diff --git a/lib/tree/utils/json_converter.rb b/lib/tree/utils/json_converter.rb index 2a39b39..0eefef4 100644 --- a/lib/tree/utils/json_converter.rb +++ b/lib/tree/utils/json_converter.rb @@ -43,7 +43,21 @@ module Tree module Utils # Provides utility methods to convert a {Tree::TreeNode} to and from # JSON[http://flori.github.com/json/]. + # + # Every serialized node carries its Ruby class name under the +json_class+ + # key. With the json gem 2.x this is the key the gem inspects itself when + # parsing with +create_additions: true+. The json gem 3.x removed that + # option together with the +JSON.create_id+ accessor, so RubyTree emits the + # key on its own and rebuilds the nodes through {ClassMethods#from_json}, + # which relies on the +on_load+ parser callback available in both the json + # gem 2.x and 3.x. module JSONConverter + # The hash key which carries the Ruby class name of a serialized node. + # Equals the default +JSON.create_id+ of the json gem 2.x, so documents + # written by earlier RubyTree versions stay readable. + JSON_CLASS_KEY = 'json_class' + + # Extend the base class with the converter class methods. def self.included(base) base.extend(ClassMethods) end @@ -69,7 +83,7 @@ def as_json(_options = {}) json_hash = { name: name, content: content, - JSON.create_id => self.class.name + JSON_CLASS_KEY => self.class.name } json_hash['children'] = children if children? @@ -86,7 +100,7 @@ def as_json(_options = {}) # # @return The JSON representation of this subtree. # - # @see ClassMethods#json_create + # @see ClassMethods#from_json # @see #as_json # @see http://flori.github.com/json def to_json(*args) @@ -96,17 +110,93 @@ def to_json(*args) # ClassMethods for the {JSONConverter} module. Will become class methods in # the +include+ target. module ClassMethods + # Parses a JSON document and returns the tree it describes. + # + # tree = Tree::TreeNode.from_json(json_string) + # + # The nodes are rebuilt from the hashes tagged with the +json_class+ + # key, see {#json_on_load} for the rules. The document root must + # describe a node of this class, or of one of its subclasses. + # + # This works with both the json gem 2.x and 3.x. With the json gem 2.x + # the legacy +JSON.parse(json_string, create_additions: true)+ keeps + # working as well, but the json gem 3.x removed that option. + # + # @param [String] source The JSON document to parse. + # + # @return [Tree::TreeNode] The root node of the parsed tree. + # + # @raise [ArgumentError] This exception is raised if the document does + # not describe a node of this class. + # + # @raise [JSON::ParserError] This exception is raised if the document + # is not valid JSON. + # + # @see #json_on_load + # @see JSONConverter#to_json + def from_json(source) + tree = JSON.parse(source, on_load: method(:json_on_load).to_proc) + return tree if tree.is_a?(self) + + raise ArgumentError, "JSON document does not describe a #{self} tree" + end + + # Rebuilds a node from its parsed JSON hash. This is the +on_load+ + # callback for the parser of the json gem, used by {#from_json} and + # available for custom parser setups (the parser expects a +Proc+): + # + # on_load = Tree::TreeNode.method(:json_on_load).to_proc + # tree = JSON::Coder.new(on_load: on_load).load(json_string) + # + # The parser invokes the callback for every parsed value, innermost + # first, so the +children+ of a node hash are already nodes by the + # time the hash of their parent arrives here. Only hashes whose + # +json_class+ names this class or one of its subclasses are turned + # into nodes, any other value is returned untouched. This keeps the + # deserialization scoped to tree nodes, unlike the global + # +create_additions+ mechanism of the json gem 2.x which instantiated + # any class named in a document. + # + # @param [Object] object A value produced by the JSON parser. + # + # @return [Tree::TreeNode, Object] The node for a tagged hash, or the + # unchanged value otherwise. + # + # @see #from_json + # @see #json_create + def json_on_load(object) + return object unless object.is_a?(Hash) + + node_class = json_node_class(object[JSON_CLASS_KEY]) + node_class ? node_class.json_create(object) : object + end + + # Resolves the class named by a +json_class+ tag, limited to this + # class and its subclasses. + # + # @param [String, nil] class_name The class name found in the tag. + # + # @return [Class, nil] The node class, or +nil+ when the tag is + # missing, unknown or names a foreign class. + def json_node_class(class_name) + return nil unless class_name.is_a?(String) + + node_class = Object.const_get(class_name) + node_class if node_class.is_a?(Class) && node_class <= self + rescue NameError + nil + end + # Helper method to create a Tree::TreeNode instance from the JSON hash # representation. Note that this method should *NOT* be called directly. - # Instead, to convert the JSON hash back to a tree, do: - # - # tree = JSON.parse(the_json_hash, create_additions: true) + # Instead, to convert a JSON document back to a tree, do: # - # This operation requires the {JSON gem}[http://flori.github.com/json/] to - # be available, or else the operation fails with a warning message. + # tree = Tree::TreeNode.from_json(the_json_string) # - # Note the +create_additions: true+ option, which is *required* for - # successfully parsing the string or hash. + # With the json gem 2.x this method also serves as the hook of the + # +create_additions+ mechanism, so the legacy + # +JSON.parse(the_json_string, create_additions: true)+ keeps working + # there. The json gem 3.x removed that mechanism. # # @author Dirk Breuer (http://github.com/railsbros-dirk) # @since 0.7.0 @@ -115,8 +205,9 @@ module ClassMethods # # @return [Tree::TreeNode] The created tree. # - # @see #to_json - # @see http://flori.github.com/json + # @see #from_json + # @see #json_on_load + # @see JSONConverter#to_json def json_create(json_hash) node = new(json_hash['name'], json_hash['content']) diff --git a/rubytree.gemspec b/rubytree.gemspec index a0226bd..df3fbd5 100644 --- a/rubytree.gemspec +++ b/rubytree.gemspec @@ -64,18 +64,17 @@ Gem::Specification.new do |s| '--main', 'README.md', '--quiet'] - s.add_runtime_dependency 'json', '~> 2.0', '>= 2.21.2' + s.add_dependency 'json', '>= 2.21.2', '< 4' # NOTE: Rake is added as a development and test dependency in the Gemfile. s.add_development_dependency 'awesome_bot', '~> 1.20' - s.add_development_dependency 'bundler', '~> 2.3' s.add_development_dependency 'bundler-audit', '~> 0.9' s.add_development_dependency 'mdl', '~> 0.14' s.add_development_dependency 'rake', '~> 13.2' s.add_development_dependency 'rdoc', '~> 6.10' s.add_development_dependency 'rspec', '~> 3.0', '>= 3.13' s.add_development_dependency 'rtagstask', '~> 0.0.4' - s.add_development_dependency 'rubocop', '~> 1.69' + s.add_development_dependency 'rubocop', '~> 1.90' s.add_development_dependency 'rubocop-rake', '~> 0.6' s.add_development_dependency 'rubocop-rspec', '~> 3.3' s.add_development_dependency 'simplecov', '~> 0.22' diff --git a/test/test_binarytree.rb b/test/test_binarytree.rb index 7d4b820..4af3269 100755 --- a/test/test_binarytree.rb +++ b/test/test_binarytree.rb @@ -196,6 +196,19 @@ def test_inordered_each assert_equal(Enumerable::Enumerator, f.inordered_each.class) if defined?(Enumerable::Enumerator.class) end + # Test the JSON round trip, which has to rebuild binary tree nodes. + def test_json_round_trip + @root << @left_child1 << Tree::BinaryTreeNode.new('C', 'Grand child') + @root << @right_child1 + + tree = Tree::BinaryTreeNode.from_json(@root.to_json) + + assert_equal(Tree::BinaryTreeNode, tree.class) + assert_equal(@left_child1.name, tree.left_child.name) + assert_equal(@right_child1.name, tree.right_child.name) + assert_equal('C', tree.left_child.left_child.name) + end + # Test the left_child method. def test_left_child @root << @left_child1 diff --git a/test/test_subclassed_node.rb b/test/test_subclassed_node.rb index ca15b0b..f88fe4d 100755 --- a/test/test_subclassed_node.rb +++ b/test/test_subclassed_node.rb @@ -48,6 +48,22 @@ def test_detached_copy_same_clz root = MyNode.new('Root') assert_equal(MyNode, root.detached_copy.class) end + + def test_from_json_same_clz + root = MyNode.new('Root') + root << MyNode.new('Child') + + tree = MyNode.from_json(root.to_json) + + assert_equal(MyNode, tree.class) + assert_equal(MyNode, tree['Child'].class) + end + + def test_from_json_via_base_class + tree = Tree::TreeNode.from_json(MyNode.new('Root').to_json) + + assert_equal(MyNode, tree.class) + end end end diff --git a/test/test_tree.rb b/test/test_tree.rb index d2fed98..77e38be 100755 --- a/test/test_tree.rb +++ b/test/test_tree.rb @@ -1345,16 +1345,16 @@ def test_json_serialization expected_json = { 'name' => 'ROOT', 'content' => 'Root Node', - JSON.create_id => 'Tree::TreeNode', + 'json_class' => 'Tree::TreeNode', 'children' => [ - { 'name' => 'Child1', 'content' => 'Child Node 1', JSON.create_id => 'Tree::TreeNode' }, - { 'name' => 'Child2', 'content' => 'Child Node 2', JSON.create_id => 'Tree::TreeNode' }, + { 'name' => 'Child1', 'content' => 'Child Node 1', 'json_class' => 'Tree::TreeNode' }, + { 'name' => 'Child2', 'content' => 'Child Node 2', 'json_class' => 'Tree::TreeNode' }, { 'name' => 'Child3', 'content' => 'Child Node 3', - JSON.create_id => 'Tree::TreeNode', + 'json_class' => 'Tree::TreeNode', 'children' => [ - { 'name' => 'Child4', 'content' => 'Grand Child 1', JSON.create_id => 'Tree::TreeNode' } + { 'name' => 'Child4', 'content' => 'Grand Child 1', 'json_class' => 'Tree::TreeNode' } ] } ] @@ -1367,22 +1367,22 @@ def test_json_deserialization tree_as_json = { 'name' => 'ROOT', 'content' => 'Root Node', - JSON.create_id => 'Tree::TreeNode', + 'json_class' => 'Tree::TreeNode', 'children' => [ - { 'name' => 'Child1', 'content' => 'Child Node 1', JSON.create_id => 'Tree::TreeNode' }, - { 'name' => 'Child2', 'content' => 'Child Node 2', JSON.create_id => 'Tree::TreeNode' }, + { 'name' => 'Child1', 'content' => 'Child Node 1', 'json_class' => 'Tree::TreeNode' }, + { 'name' => 'Child2', 'content' => 'Child Node 2', 'json_class' => 'Tree::TreeNode' }, { 'name' => 'Child3', 'content' => 'Child Node 3', - JSON.create_id => 'Tree::TreeNode', + 'json_class' => 'Tree::TreeNode', 'children' => [ - { 'name' => 'Child4', 'content' => 'Grand Child 1', JSON.create_id => 'Tree::TreeNode' } + { 'name' => 'Child4', 'content' => 'Grand Child 1', 'json_class' => 'Tree::TreeNode' } ] } ] }.to_json - tree = JSON.parse(tree_as_json, create_additions: true) + tree = Tree::TreeNode.from_json(tree_as_json) assert_equal(@root.name, tree.root.name, 'Root should be returned') assert_equal(@child1.name, tree[0].name, 'Child 1 should be returned') @@ -1399,7 +1399,7 @@ def test_json_round_trip j = root_node.to_json - k = JSON.parse(j, create_additions: true) + k = Tree::TreeNode.from_json(j) assert_equal(k.name, root_node.name, 'Root should be returned') assert_equal(k[0].name, root_node[0].name, 'Child 1 should be returned') @@ -1407,6 +1407,34 @@ def test_json_round_trip assert_equal(k[1].name, root_node[1].name, 'Child 2 should be returned') end + # The json gem 2.x can still rebuild the tree on its own through its + # +create_additions+ mechanism, which the json gem 3.x removed. + def test_json_create_additions_on_json2 + omit_unless(JSON.respond_to?(:create_id), + 'create_additions was removed by the json gem 3.x') + setup_test_tree + + tree = JSON.parse(@root.to_json, create_additions: true) + + assert_equal(@root.name, tree.name, 'Root should be returned') + assert_equal(@child4.name, tree[2][0].name, + 'Grand Child 1 should be returned') + end + + def test_from_json_rejects_foreign_class + document = { 'json_class' => 'String', 'raw' => [112, 119, 110] }.to_json + + assert_raise(ArgumentError) { Tree::TreeNode.from_json(document) } + end + + def test_from_json_keeps_untagged_content + root_node = Tree::TreeNode.new('ROOT', { 'key' => 'value' }) + + tree = Tree::TreeNode.from_json(root_node.to_json) + + assert_equal({ 'key' => 'value' }, tree.content) + end + # Test usage of integers as node names def test_integer_node_names @n_root = Tree::TreeNode.new(0, 'Root Node')