diff --git a/readme.md b/readme.md index d568285..4122a1b 100644 --- a/readme.md +++ b/readme.md @@ -608,4 +608,4 @@ composer test Ideas or contribution is welcome. Please send a PR or submit an issue. -And if you happen to like the library, spread the word 🙏. +And if you happen to like the library, give it a star and spread the word 🙏. diff --git a/src/Seed.php b/src/Seed.php index 1d71082..8835244 100644 --- a/src/Seed.php +++ b/src/Seed.php @@ -95,10 +95,19 @@ public static function attr(string|int $name, mixed $default = null): callable /** * Accepts any iterable and returns an iterator. - * Useful where en iterator is required, but any iterable or array is provided. + * Useful where an iterator is required, but an iterable type is provided. */ public static function iterator(iterable $input): Iterator { return is_array($input) ? new ArrayIterator($input) : new IteratorIterator($input); } + + /** + * Accepts any iterable and returns an array. + * Useful where an array is required, but an iterable type is provided. + */ + public static function array(iterable $input): array + { + return is_array($input) ? $input : iterator_to_array($input); + } } diff --git a/src/Tree.php b/src/Tree.php index ea80af7..727654a 100644 --- a/src/Tree.php +++ b/src/Tree.php @@ -9,7 +9,7 @@ /** * A helper class for high-level tree operations. * - * This contrasts with the low-level interface. + * This contrasts with the low-level interface of movable nodes: * @see MovableNodeContract * * @author Andrej Rypak @@ -115,6 +115,35 @@ public static function unlinkChildren( $parent->removeChildren(); } + /** + * + * Usage of <=> (spaceship) operator to compare based on path props: + * fn(Node $a, Node $b) => $a->data()->path <=> $b->data()->path + * + */ + public static function reindexTree( + MovableNodeContract $node, + ?callable $key, + ?callable $sort, + ): void { + $children = Seed::array($node->children()); + $node->removeChildren(); + if (null !== $sort) { + uasort($children, $sort); + } + $seq = 0; + foreach ($children as $childKey => $child) { + if (!$child instanceof MovableNodeContract) { + // TODO improve exceptions + throw new Exception('Child not movable.'); + } + $newKey = null !== $key ? $key($child, $childKey, $seq) : $childKey; + $node->addChild($child, $newKey); + self::reindexTree($child, $key, $sort); + $seq += 1; + } + } + /** * @internal */ diff --git a/tests/iterators.phpt b/tests/iterators.phpt index 51427f5..9cd57d4 100644 --- a/tests/iterators.phpt +++ b/tests/iterators.phpt @@ -15,218 +15,221 @@ use Tester\Environment; require_once __DIR__ . '/../vendor/autoload.php'; Environment::setup(); -$a = new Node('A'); -$b = new Node('B'); -$c = new Node('C'); -$d = new Node('D'); -$e = new Node('E'); -$f = new Node('F'); -$g = new Node('G'); -$h = new Node('H'); -$i = new Node('I'); - -$edge = function (Node $from, Node $to): void { - $from->addChild($to); - $to->setParent($from); -}; - -$root = $f; -$edge($f, $b); -$edge($b, $a); -$edge($b, $d); -$edge($d, $c); -$edge($d, $e); -$edge($f, $g); -$edge($g, $i); -$edge($i, $h); - -$iterator = new PreOrderTraversal($root); -$str = ''; -foreach ($iterator as $node) { - $str .= $node->data(); -} +(function () { + $a = new Node('A'); + $b = new Node('B'); + $c = new Node('C'); + $d = new Node('D'); + $e = new Node('E'); + $f = new Node('F'); + $g = new Node('G'); + $h = new Node('H'); + $i = new Node('I'); + + $edge = function (Node $from, Node $to): void { + $from->addChild($to); + $to->setParent($from); + }; + + $root = $f; + $edge($f, $b); + $edge($b, $a); + $edge($b, $d); + $edge($d, $c); + $edge($d, $e); + $edge($f, $g); + $edge($g, $i); + $edge($i, $h); + + $iterator = new PreOrderTraversal($root); + $str = ''; + foreach ($iterator as $node) { + $str .= $node->data(); + } //echo $str; //echo "\n"; -Assert::same('FBADCEGIH', $str); + Assert::same('FBADCEGIH', $str); -$iterator = new PostOrderTraversal($root); -$str = ''; -foreach ($iterator as $node) { - $str .= $node->data(); -} + $iterator = new PostOrderTraversal($root); + $str = ''; + foreach ($iterator as $node) { + $str .= $node->data(); + } //echo $str; //echo "\n"; -Assert::same('ACEDBHIGF', $str); + Assert::same('ACEDBHIGF', $str); -$iterator = new LevelOrderTraversal($root); -$str = ''; -foreach ($iterator as $i => $node) { - $str .= $node->data(); -} + $iterator = new LevelOrderTraversal($root); + $str = ''; + foreach ($iterator as $i => $node) { + $str .= $node->data(); + } //echo $str; //echo "\n"; -Assert::same('FBGADICEH', $str); + Assert::same('FBGADICEH', $str); //echo "\n"; -Assert::type(PreOrderTraversal::class, $root->getIterator()); -$str = ''; -foreach ($root as $node) { - $str .= $node->data(); -} + Assert::type(PreOrderTraversal::class, $root->getIterator()); + $str = ''; + foreach ($root as $node) { + $str .= $node->data(); + } //echo $str; -Assert::same('FBADCEGIH', $str); + Assert::same('FBADCEGIH', $str); //echo "\n"; -$iterator = new PreOrderTraversal( - node: $root, - key: null, -); -$expected = [ - 0 => 'F', - 'B', - 'A', - 'D', - 'C', - 'E', - 'G', - 'I', - 'H', -]; -Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); - - -$iterator = new PreOrderTraversal( - node: $root, - key: fn(TreeNodeContract $node, array $vector, int $seq, int $counter): int => $counter + 1, -); -$expected = [ - 1 => 'F', - 'B', - 'A', - 'D', - 'C', - 'E', - 'G', - 'I', - 'H', -]; -Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); - - -$iterator = new PreOrderTraversal( - node: $root, - key: fn(TreeNodeContract $node, array $vector, int $seq, int $counter): string => '.' . implode('.', $vector), -); -$expected = [ - '.' => 'F', - '.0' => 'B', - '.0.0' => 'A', - '.0.1' => 'D', - '.0.1.0' => 'C', - '.0.1.1' => 'E', - '.1' => 'G', - '.1.0' => 'I', - '.1.0.0' => 'H', -]; -Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); - - -$iterator = new PreOrderTraversal( - node: $root, - key: fn(TreeNodeContract $node, array $vector, int $seq, int $counter): string => implode('.', $vector), - startingVector: ['a', 'b'], -); -$expected = [ - 'a.b' => 'F', - 'a.b.0' => 'B', - 'a.b.0.0' => 'A', - 'a.b.0.1' => 'D', - 'a.b.0.1.0' => 'C', - 'a.b.0.1.1' => 'E', - 'a.b.1' => 'G', - 'a.b.1.0' => 'I', - 'a.b.1.0.0' => 'H', -]; -Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); - - -$iterator = new PostOrderTraversal($root); -$expected = [ - 0 => 'A', - 'C', - 'E', - 'D', - 'B', - 'H', - 'I', - 'G', - 'F', -]; -Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); - -$iterator = new PostOrderTraversal( - node: $root, - key: fn(TreeNodeContract $node, array $vector, int $seq, int $counter): string => implode('.', $vector), - startingVector: ['a', 'b'], -); -$expected = [ - 'a.b.0.0' => 'A', - 'a.b.0.1.0' => 'C', - 'a.b.0.1.1' => 'E', - 'a.b.0.1' => 'D', - 'a.b.0' => 'B', - 'a.b.1.0.0' => 'H', - 'a.b.1.0' => 'I', - 'a.b.1' => 'G', - 'a.b' => 'F', -]; -Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); - - - -$iterator = new LevelOrderTraversal($root); -$expected = [ - 0 => 'F', - 'B', - 'G', - 'A', - 'D', - 'I', - 'C', - 'E', - 'H', -]; -Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); - -$iterator = new LevelOrderTraversal( - node: $root, - key: fn(TreeNodeContract $node, array $vector, int $seq, int $counter): string => implode('.', $vector), - startingVector: ['a', 'b'], -); -$expected = [ - 'a.b' => 'F', - 'a.b.0' => 'B', - 'a.b.1' => 'G', - 'a.b.0.0' => 'A', - 'a.b.0.1' => 'D', - 'a.b.1.0' => 'I', - 'a.b.0.1.0' => 'C', - 'a.b.0.1.1' => 'E', - 'a.b.1.0.0' => 'H', -]; -Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); - - -$counter = new Counter(); -Assert::same(0, $counter->current()); -Assert::same(0, $counter->touch()); -Assert::same(1, $counter->touch()); -Assert::same(2, $counter->current()); -Assert::same(3, $counter->next()); -Assert::same(3, $counter->current()); - -$counter = new Counter(5); -Assert::same(5, $counter->current()); - + $iterator = new PreOrderTraversal( + node: $root, + key: null, + ); + $expected = [ + 0 => 'F', + 'B', + 'A', + 'D', + 'C', + 'E', + 'G', + 'I', + 'H', + ]; + Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); + + + $iterator = new PreOrderTraversal( + node: $root, + key: fn(TreeNodeContract $node, array $vector, int $seq, int $counter): int => $counter + 1, + ); + $expected = [ + 1 => 'F', + 'B', + 'A', + 'D', + 'C', + 'E', + 'G', + 'I', + 'H', + ]; + Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); + + + $iterator = new PreOrderTraversal( + node: $root, + key: fn(TreeNodeContract $node, array $vector, int $seq, int $counter): string => '.' . implode('.', $vector), + ); + $expected = [ + '.' => 'F', + '.0' => 'B', + '.0.0' => 'A', + '.0.1' => 'D', + '.0.1.0' => 'C', + '.0.1.1' => 'E', + '.1' => 'G', + '.1.0' => 'I', + '.1.0.0' => 'H', + ]; + Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); + + + $iterator = new PreOrderTraversal( + node: $root, + key: fn(TreeNodeContract $node, array $vector, int $seq, int $counter): string => implode('.', $vector), + startingVector: ['a', 'b'], + ); + $expected = [ + 'a.b' => 'F', + 'a.b.0' => 'B', + 'a.b.0.0' => 'A', + 'a.b.0.1' => 'D', + 'a.b.0.1.0' => 'C', + 'a.b.0.1.1' => 'E', + 'a.b.1' => 'G', + 'a.b.1.0' => 'I', + 'a.b.1.0.0' => 'H', + ]; + Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); + + + $iterator = new PostOrderTraversal($root); + $expected = [ + 0 => 'A', + 'C', + 'E', + 'D', + 'B', + 'H', + 'I', + 'G', + 'F', + ]; + Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); + + $iterator = new PostOrderTraversal( + node: $root, + key: fn(TreeNodeContract $node, array $vector, int $seq, int $counter): string => implode('.', $vector), + startingVector: ['a', 'b'], + ); + $expected = [ + 'a.b.0.0' => 'A', + 'a.b.0.1.0' => 'C', + 'a.b.0.1.1' => 'E', + 'a.b.0.1' => 'D', + 'a.b.0' => 'B', + 'a.b.1.0.0' => 'H', + 'a.b.1.0' => 'I', + 'a.b.1' => 'G', + 'a.b' => 'F', + ]; + Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); + + + + $iterator = new LevelOrderTraversal($root); + $expected = [ + 0 => 'F', + 'B', + 'G', + 'A', + 'D', + 'I', + 'C', + 'E', + 'H', + ]; + Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); + + $iterator = new LevelOrderTraversal( + node: $root, + key: fn(TreeNodeContract $node, array $vector, int $seq, int $counter): string => implode('.', $vector), + startingVector: ['a', 'b'], + ); + $expected = [ + 'a.b' => 'F', + 'a.b.0' => 'B', + 'a.b.1' => 'G', + 'a.b.0.0' => 'A', + 'a.b.0.1' => 'D', + 'a.b.1.0' => 'I', + 'a.b.0.1.0' => 'C', + 'a.b.0.1.1' => 'E', + 'a.b.1.0.0' => 'H', + ]; + Assert::same($expected, array_map(fn(DataNodeContract $node) => $node->data(), iterator_to_array($iterator))); + + + $counter = new Counter(); + Assert::same(0, $counter->current()); + Assert::same(0, $counter->touch()); + Assert::same(1, $counter->touch()); + Assert::same(2, $counter->current()); + Assert::same(3, $counter->next()); + Assert::same(3, $counter->current()); + + $counter = new Counter(5); + Assert::same(5, $counter->current()); //$root->addChild(new) + +})(); + diff --git a/tests/mptree.phpt b/tests/mptree.phpt index 39be111..5788a39 100644 --- a/tests/mptree.phpt +++ b/tests/mptree.phpt @@ -2,12 +2,12 @@ declare(strict_types=1); -use Dakujem\Oliva\Iterator\Filter; use Dakujem\Oliva\Iterator\PreOrderTraversal; use Dakujem\Oliva\MaterializedPath\Support\AlmostThere; use Dakujem\Oliva\MaterializedPath\TreeBuilder; use Dakujem\Oliva\Node; use Dakujem\Oliva\Seed; +use Dakujem\Oliva\Tree; use Dakujem\Oliva\TreeNodeContract; use Tester\Assert; use Tester\Environment; @@ -24,130 +24,172 @@ class Item } } -$data = [ - new Item(1, '000'), - new Item(2, '001'), - new Item(3, '003'), - new Item(4, '000000'), - new Item(5, '002000'), - new Item(6, '002'), - new Item(7, '007007007'), - new Item(8, '008'), -]; - -$builder = new TreeBuilder( - node: fn(?Item $item) => new Node($item), - vector: TreeBuilder::fixed( +(function () { + $toArray = function (TreeNodeContract $root) { + $it = new PreOrderTraversal($root, fn( + TreeNodeContract $node, + array $vector, + int $seq, + int $counter, + ): string => '>' . implode('.', $vector)); + return array_map(function (Node $item): string { + $data = $item->data(); + return null !== $data ? "[$data->id]" : 'root'; + }, iterator_to_array($it)); + }; + + + $data = [ + new Item(id: 1, path: '000'), + new Item(id: 2, path: '001'), + new Item(id: 3, path: '003'), + new Item(id: 4, path: '000001'), + new Item(id: 5, path: '002000'), + new Item(id: 6, path: '002'), + new Item(id: 7, path: '007007007'), + new Item(id: 8, path: '000000'), + new Item(id: 9, path: '009'), + ]; + + $builder = new TreeBuilder( + node: fn(?Item $item) => new Node($item), + vector: TreeBuilder::fixed( + 3, + fn(?Item $item) => $item?->path, + ), + ); + $almost = $builder->processInput( + input: Seed::nullFirst($data), + ); + + Assert::type(AlmostThere::class, $almost); + Assert::type(Node::class, $almost->root()); + Assert::null($almost->root()?->data()); + Assert::type(Item::class, Seed::first($almost->root()?->children())?->data()); + + Assert::same([ + '>' => 'root', + '>0' => '[1]', + '>0.0' => '[4]', + '>0.1' => '[8]', + '>1' => '[2]', + '>2' => '[3]', + '>3' => '[6]', + '>3.0' => '[5]', + '>5' => '[9]', // note the index `4` being skipped - this is expected, because the node with ID 7 is not connected to the root and is omitted during reconstruction by the shadow tree, but the index is not changed + ], $toArray($almost->root())); + + + $vectorExtractor = TreeBuilder::fixed( 3, - fn(?Item $item) => $item?->path, - ), -); -$tree = $builder->processInput( - input: Seed::nullFirst($data), -); + fn(mixed $path) => $path, + ); + Assert::same(['000', '000'], $vectorExtractor('000000')); + Assert::same(['foo', 'bar'], $vectorExtractor('foobar')); + Assert::same([], $vectorExtractor('')); + Assert::same([], $vectorExtractor(null)); + Assert::throws(function () use ($vectorExtractor) { + $vectorExtractor(4.2); + }, \RuntimeException::class); // TODO improve -$item = $tree->root()?->data(); -Assert::type(AlmostThere::class, $tree); -Assert::type(Node::class, $tree->root()); -Assert::null($tree->root()?->data()); -Assert::type(Item::class, Seed::first($tree->root()?->children())?->data()); + // an empty input can not result in any tree + Assert::throws(function () use ($builder) { + $builder->build([]); + }, RuntimeException::class, 'Corrupted input, no tree created.'); // TODO improve + $failingBuilder = new TreeBuilder(fn() => null, fn() => []); + Assert::throws(function () use ($failingBuilder) { + $failingBuilder->build([null]); + }, LogicException::class, 'The node factory must return a movable node instance.'); // TODO improve + $invalidVector = new TreeBuilder(fn() => new Node(null), fn() => null); + Assert::throws(function () use ($invalidVector) { + $invalidVector->build([null]); + }, LogicException::class, 'The vector calculator must return an array.'); // TODO improve -$vectorExtractor = TreeBuilder::fixed( - 3, - fn(mixed $path) => $path, -); -Assert::same(['000', '000'], $vectorExtractor('000000')); -Assert::same(['foo', 'bar'], $vectorExtractor('foobar')); -Assert::same([], $vectorExtractor('')); -Assert::same([], $vectorExtractor(null)); -Assert::throws(function () use ($vectorExtractor) { - $vectorExtractor(4.2); -}, \RuntimeException::class); // TODO improve + $invalidVectorContents = new TreeBuilder(fn() => new Node(null), fn() => ['a', null]); + Assert::throws(function () use ($invalidVectorContents) { + $invalidVectorContents->build([null]); + }, LogicException::class, 'The vector may only consist of strings or integers.'); // TODO improve -// an empty input can not result in any tree -Assert::throws(function () use ($builder) { - $builder->build([]); -}, RuntimeException::class, 'Corrupted input, no tree created.'); // TODO improve + $duplicateVector = new TreeBuilder(fn() => new Node(null), fn() => ['any']); + Assert::throws(function () use ($duplicateVector) { + $duplicateVector->build([null, null]); + }, LogicException::class, 'Duplicate node vector: any'); // TODO improve -$failingBuilder = new TreeBuilder(fn() => null, fn() => []); -Assert::throws(function () use ($failingBuilder) { - $failingBuilder->build([null]); -}, LogicException::class, 'The node factory must return a movable node instance.'); // TODO improve -$invalidVector = new TreeBuilder(fn() => new Node(null), fn() => null); -Assert::throws(function () use ($invalidVector) { - $invalidVector->build([null]); -}, LogicException::class, 'The vector calculator must return an array.'); // TODO improve + $collection = [ + new Item(id: 0, path: ''), // the root + new Item(id: 1, path: '.0'), + new Item(id: 2, path: '.1'), + new Item(id: 3, path: '.3'), + new Item(id: 4, path: '.0.0'), + new Item(id: 5, path: '.2.0'), + new Item(id: 6, path: '.2'), + new Item(id: 7, path: '.7.7.7'), + new Item(id: 8, path: '.0.1'), + new Item(id: 9, path: '.9'), + ]; + $builder = new TreeBuilder( + node: fn(Item $item) => new Node($item), + vector: TreeBuilder::delimited( + delimiter: '.', + accessor: fn(Item $item) => $item->path, + ), + ); -$invalidVectorContents = new TreeBuilder(fn() => new Node(null), fn() => ['a', null]); -Assert::throws(function () use ($invalidVectorContents) { - $invalidVectorContents->build([null]); -}, LogicException::class, 'The vector may only consist of strings or integers.'); // TODO improve - - -$duplicateVector = new TreeBuilder(fn() => new Node(null), fn() => ['any']); -Assert::throws(function () use ($duplicateVector) { - $duplicateVector->build([null, null]); -}, LogicException::class, 'Duplicate node vector: any'); // TODO improve - - - -$collection = [ - new Item(id: 0, path: ''), // the root - new Item(id: 1, path: '.0'), - new Item(id: 2, path: '.1'), - new Item(id: 3, path: '.3'), - new Item(id: 4, path: '.0.0'), - new Item(id: 5, path: '.2.0'), - new Item(id: 6, path: '.2'), - new Item(id: 7, path: '.0.1'), -]; - -$builder = new TreeBuilder( - node: fn(Item $item) => new Node($item), - vector: TreeBuilder::delimited( - delimiter: '.', - accessor: fn(Item $item) => $item->path, - ), -); - -$root = $builder->build( - input: $collection, -); - -$iterator = function(TreeNodeContract $root) { - $it = new PreOrderTraversal($root, fn( - TreeNodeContract $node, - array $vector, - int $seq, - int $counter, - ): string => '>' . implode('.', $vector)); - return array_map(function (Node $item): string { - $data = $item->data(); - return "[$data->id]"; - }, iterator_to_array($it)); -}; + $root = $builder->build( + input: $collection, + ); //new Filter($it, Seed::omitNull()); //new Filter($it, Seed::omitRoot()); -Assert::same([ - '>' => '[0]', - '>0' => '[1]', - '>0.0' => '[4]', - '>0.1' => '[7]', - '>1' => '[2]', - '>2' => '[3]', - '>3' => '[6]', - '>3.0' => '[5]', -], $iterator($root)); - + Assert::same([ + '>' => '[0]', + '>0' => '[1]', + '>0.0' => '[4]', + '>0.1' => '[8]', + '>1' => '[2]', + '>2' => '[3]', + '>3' => '[6]', + '>3.0' => '[5]', + '>5' => '[9]', // note the index `4` being skipped - this is expected + ], $toArray($root)); + + + Tree::reindexTree($root, fn(Node $node)=>$node->data()->id, null); + Assert::same([ + '>' => '[0]', + '>1' => '[1]', + '>1.4' => '[4]', + '>1.8' => '[8]', + '>2' => '[2]', + '>3' => '[3]', + '>6' => '[6]', + '>6.5' => '[5]', + '>9' => '[9]', + ], $toArray($root)); + + Tree::reindexTree($root, null, fn(Node $a, Node $b) => $a->data()->path <=> $b->data()->path); + Assert::same([ + '>' => '[0]', + '>1' => '[1]', // .0 + '>1.4' => '[4]', // .0.0 + '>1.8' => '[8]', // .0.1 + '>2' => '[2]', // .1 + '>6' => '[6]', // .2 + '>6.5' => '[5]', // .2.0 + '>3' => '[3]', // .3 + '>9' => '[9]', // .9 + ], $toArray($root)); + + +})(); \ No newline at end of file diff --git a/tests/recursive.phpt b/tests/recursive.phpt index c495072..23cb200 100644 --- a/tests/recursive.phpt +++ b/tests/recursive.phpt @@ -20,26 +20,28 @@ class Item } } -$data = [ - new Item(1, 2), - new Item(2, 4), - new Item(3, 4), - new Item(4, null), - new Item(5, 4), - new Item(77, 42), - new Item(8, 7), - new Item(6, 5), -]; - -$builder = new TreeBuilder( - node: fn(?Item $item) => new Node($item), - self: fn(?Item $item) => $item?->id, - parent: fn(?Item $item) => $item?->parent, -); - -$tree = $builder->build( - input: Seed::nullFirst($data), -); - - -Assert::type(Node::class, $tree); +(function () { + $data = [ + new Item(1, 2), + new Item(2, 4), + new Item(3, 4), + new Item(4, null), + new Item(5, 4), + new Item(77, 42), + new Item(8, 7), + new Item(6, 5), + ]; + + $builder = new TreeBuilder( + node: fn(?Item $item) => new Node($item), + self: fn(?Item $item) => $item?->id, + parent: fn(?Item $item) => $item?->parent, + ); + + $tree = $builder->build( + input: Seed::nullFirst($data), + ); + + + Assert::type(Node::class, $tree); +})();