Skip to content

Commit

Permalink
Builder message consolidation when no root found
Browse files Browse the repository at this point in the history
+ more edge-case tests
  • Loading branch information
dakujem committed Feb 5, 2024
1 parent c701715 commit 635323c
Show file tree
Hide file tree
Showing 6 changed files with 216 additions and 70 deletions.
9 changes: 2 additions & 7 deletions src/MaterializedPath/Support/ShadowNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
use Dakujem\Oliva\MovableNodeContract;
use Dakujem\Oliva\Node;
use Dakujem\Oliva\TreeNodeContract;
use LogicException;

/**
* Shadow node used internally when building materialized path trees.
Expand All @@ -18,13 +17,9 @@
final class ShadowNode extends Node implements MovableNodeContract
{
public function __construct(
?MovableNodeContract $node,
?ShadowNode $parent = null,
?MovableNodeContract $node
) {
parent::__construct(
data: $node,
parent: $parent,
);
parent::__construct(data: $node);
}

/**
Expand Down
8 changes: 4 additions & 4 deletions src/MaterializedPath/TreeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public function build(iterable $input): TreeNodeContract
$result = $this->processInput($input);
$root = $result->root();
if (null === $root) {
throw (new InvalidInputData('Corrupted input, no tree created.'))
throw (new InvalidInputData('No root node found in the input collection.'))
->tag('result', $result);
}
return $root;
Expand Down Expand Up @@ -157,9 +157,6 @@ private function buildShadowTree(
return $register->pull([]);
}

/**
* Recursion.
*/
private function connectNode(ShadowNode $node, array $vector, Register $register): void
{
$existingNode = $register->pull($vector);
Expand All @@ -181,6 +178,9 @@ private function connectNode(ShadowNode $node, array $vector, Register $register
$this->connectAncestry($node, $vector, $register);
}

/**
* Recursive.
*/
private function connectAncestry(ShadowNode $node, array $vector, Register $register): void
{
// When the node is a root itself, abort recursion.
Expand Down
9 changes: 4 additions & 5 deletions src/Recursive/TreeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

namespace Dakujem\Oliva\Recursive;

use Dakujem\Oliva\Exceptions\ConfigurationIssue;
use Dakujem\Oliva\Exceptions\ExtractorReturnValueIssue;
use Dakujem\Oliva\Exceptions\InvalidInputData;
use Dakujem\Oliva\Exceptions\InvalidNodeFactoryReturnValue;
Expand Down Expand Up @@ -78,10 +77,8 @@ public function __construct(
int|string|null $parentReference,
int|string|null $selfReference,
): bool => $parentReference === $root;
} elseif (is_callable($root)) {
$this->root = $root;
} else {
throw new ConfigurationIssue('Invalid argument: The root detector must either be a string|int|null to compare against the parent refs, or a callable that returns truthy if a root is detected.');
$this->root = $root;
}
}

Expand Down Expand Up @@ -170,7 +167,7 @@ private function processData(
}

if (!$rootFound) {
throw (new InvalidInputData('No root node found in the data.'))
throw (new InvalidInputData('No root node found in the input collection.'))
->tag('nodes', $nodeRegister);
}

Expand All @@ -189,6 +186,8 @@ private function processData(
}

/**
* Recursive.
*
* @param array<string|int, MovableNodeContract> $nodeRegister
* @param array<string|int, array<int, string|int>> $childRegister
*/
Expand Down
134 changes: 111 additions & 23 deletions tests/mptree.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@ declare(strict_types=1);
namespace Dakujem\Test;

use Dakujem\Oliva\Exceptions\ExtractorReturnValueIssue;
use Dakujem\Oliva\Exceptions\InternalLogicException;
use Dakujem\Oliva\Exceptions\InvalidInputData;
use Dakujem\Oliva\Exceptions\InvalidNodeFactoryReturnValue;
use Dakujem\Oliva\Exceptions\InvalidTreePath;
use Dakujem\Oliva\Iterator\PreOrderTraversal;
use Dakujem\Oliva\MaterializedPath\Path;
use Dakujem\Oliva\MaterializedPath\Support\AlmostThere;
use Dakujem\Oliva\MaterializedPath\Support\ShadowNode;
use Dakujem\Oliva\MaterializedPath\TreeBuilder;
use Dakujem\Oliva\MovableNodeContract;
use Dakujem\Oliva\Node;
use Dakujem\Oliva\Seed;
use Dakujem\Oliva\Tree;
use Dakujem\Oliva\TreeNodeContract;
use Tester\Assert;

require_once __DIR__ . '/setup.php';
Expand All @@ -31,20 +31,6 @@ class Item
}

(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'),
Expand Down Expand Up @@ -72,6 +58,8 @@ class Item
Assert::type(Node::class, $almost->root());
Assert::null($almost->root()?->data());
Assert::type(Item::class, Seed::firstOf($almost->root()?->children())?->data());
Assert::type(ShadowNode::class, $almost->shadow());
Assert::same($almost->root(), $almost->shadow()->data());

Assert::same([
'>' => 'root',
Expand All @@ -83,7 +71,7 @@ class Item
'>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()));
], TreeTesterTool::visualize($almost->root()));


$vectorExtractor = Path::fixed(
Expand All @@ -103,7 +91,7 @@ class Item
// an empty input can not result in any tree
Assert::throws(function () use ($builder) {
$builder->build([]);
}, InvalidInputData::class, 'Corrupted input, no tree created.');
}, InvalidInputData::class, 'No root node found in the input collection.');


$failingBuilder = new TreeBuilder(fn() => null, fn() => []);
Expand All @@ -127,9 +115,9 @@ class Item
Assert::throws(function () use ($duplicateVector) {
$duplicateVector->build([null, null]);
}, InvalidInputData::class, 'Duplicate node vector: any');
})();



(function () {
$collection = [
new Item(id: 0, path: ''), // the root
new Item(id: 1, path: '.0'),
Expand Down Expand Up @@ -167,7 +155,7 @@ class Item
'>3' => '[6]',
'>3.0' => '[5]',
'>5' => '[9]', // note the index `4` being skipped - this is expected
], $toArray($root));
], TreeTesterTool::visualize($root));


Tree::reindexTree($root, fn(Node $node) => $node->data()->id, null);
Expand All @@ -181,7 +169,7 @@ class Item
'>6' => '[6]',
'>6.5' => '[5]',
'>9' => '[9]',
], $toArray($root));
], TreeTesterTool::visualize($root));

Tree::reindexTree($root, null, fn(Node $a, Node $b) => $a->data()->path <=> $b->data()->path);
Assert::same([
Expand All @@ -194,5 +182,105 @@ class Item
'>6.5' => '[5]', // .2.0
'>3' => '[3]', // .3
'>9' => '[9]', // .9
], $toArray($root));
], TreeTesterTool::visualize($root));
})();

(function () {
$builder = new TreeBuilder(
node: fn(?string $path) => new Node($path),
vector: Path::fixed(
3,
fn(?string $path) => $path,
),
);
$almost = $builder->processInput(
input: [],
);
Assert::same(null, $almost->shadow());
Assert::same(null, $almost->root());

Assert::throws(
function () use ($builder) {
$builder->build(
input: [],
);
},
InvalidInputData::class,
'No root node found in the input collection.',
);

$root = $builder->build(
input: [null],
);
Assert::type(Node::class, $root);
Assert::same(null, $root->data());
$root = $builder->build(
input: [''],
);
Assert::type(Node::class, $root);
Assert::same('', $root->data());

Assert::throws(
function () use ($builder) {
$builder->build(
input: ['000'],
);
},
InvalidInputData::class,
'No root node found in the input collection.',
);

// Here no root node will be found.
Assert::throws(
function () use ($builder) {
$builder->build(
input: ['007000', '007', '007001'],
);
},
InvalidInputData::class,
'No root node found in the input collection.',
);

// However, a shadow tree will be returned and the node can be accessed, as the first shadow-child in these cases:
$almost = $builder->processInput(
input: ['000'],
);
$node = $almost->shadow()->child(0)->data();
Assert::type(Node::class, $node);
Assert::same('000', $node->data());

$almost = $builder->processInput(
input: ['007000', '007', '007001'],
);
$node = $almost->shadow()->child(0)->data();
Assert::type(Node::class, $node);
Assert::same('007', $node->data());
Assert::count(2, $node->children());
Assert::same('007000', $node->child(0)->data());
Assert::same('007001', $node->child(1)->data());
})();

(function () {
$shadow = new ShadowNode(null);
Assert::same(null, $shadow->data());

$shadow = new ShadowNode($node = new Node(null));
Assert::same($node, $shadow->data());

$parent = new ShadowNode(new Node('root'));
$shadow->setParent($parent);
Assert::same($parent, $shadow->parent());
Assert::same('root', $shadow->parent()?->data()?->data());

Assert::same([], $shadow->children());
$shadow->addChild(new ShadowNode(new Node('child')), 0);
Assert::count(1, $shadow->children());
Assert::same('child', $shadow->child(0)?->data()?->data());

Assert::throws(function () use ($shadow) {
$shadow->addChild(new Node('another'));
}, InternalLogicException::class, 'Invalid use of a shadow node. Only shadow nodes can be children of shadow nodes.');
Assert::throws(function () use ($shadow) {
$shadow->setParent(new Node('new parent'));
}, InternalLogicException::class, 'Invalid use of a shadow node. Only shadow nodes can be parents of shadow nodes.');
})();
Loading

0 comments on commit 635323c

Please sign in to comment.