silverstripe-framework/tests/model/HierarchyTest.php
Stig Lindqvist 333a2aa8f9 BUG: CMS tree filters doesn't count the correct number of children for deleted pages
This is a bug that combines Hierarchy, Versioned and LeftAndMain admins and CMSSiteTreeFilters.

This bug can be reproduced by having a large site tree with enough deleted pages in it so it doesn't
pre load all the children pages when initially opening an admin. Filter by either 'All pages including deleted'
or 'Deleted pages'. For CMS users it will look like deleted pages are gone.

The solution involves a couple of smaller fixes in both CMS and framework modules.

1) Ensure that 'numHistoricalChildren' are used instead of 'numChildren' when dealing with deleted pages
2) LeftAndMain::currentPage() deletes all the 'marking' cache previously built up by Hierarchy::markPartialTree()
3) Use Versioned::get_included_deleted() instead of raw DB queries against the DataObject tables when calculating parents in CMSSiteTreeFilter
2014-07-25 16:19:30 +12:00

546 lines
19 KiB
PHP

<?php
class HierarchyTest extends SapphireTest {
protected static $fixture_file = 'HierarchyTest.yml';
protected $requiredExtensions = array(
'HierarchyTest_Object' => array('Hierarchy', 'Versioned')
);
protected $extraDataObjects = array(
'HierarchyTest_Object'
);
/**
* Test the Hierarchy prevents infinite loops.
*/
public function testPreventLoop() {
$obj2 = $this->objFromFixture('HierarchyTest_Object', 'obj2');
$obj2aa = $this->objFromFixture('HierarchyTest_Object', 'obj2aa');
$obj2->ParentID = $obj2aa->ID;
try {
$obj2->write();
}
catch (ValidationException $e) {
$this->assertContains('Infinite loop found within the "HierarchyTest_Object" hierarchy', $e->getMessage());
return;
}
$this->fail('Failed to prevent infinite loop in hierarchy.');
}
/**
* Test Hierarchy::AllHistoricalChildren().
*/
public function testAllHistoricalChildren() {
// Delete some objs
$this->objFromFixture('HierarchyTest_Object', 'obj2b')->delete();
$this->objFromFixture('HierarchyTest_Object', 'obj3a')->delete();
$this->objFromFixture('HierarchyTest_Object', 'obj3')->delete();
// Check that obj1-3 appear at the top level of the AllHistoricalChildren tree
$this->assertEquals(array("Obj 1", "Obj 2", "Obj 3"),
singleton('HierarchyTest_Object')->AllHistoricalChildren()->column('Title'));
// Check numHistoricalChildren
$this->assertEquals(3, singleton('HierarchyTest_Object')->numHistoricalChildren());
// Check that both obj 2 children are returned
$obj2 = $this->objFromFixture('HierarchyTest_Object', 'obj2');
$this->assertEquals(array("Obj 2a", "Obj 2b"),
$obj2->AllHistoricalChildren()->column('Title'));
// Check numHistoricalChildren
$this->assertEquals(2, $obj2->numHistoricalChildren());
// Obj 3 has been deleted; let's bring it back from the grave
$obj3 = Versioned::get_including_deleted("HierarchyTest_Object", "\"Title\" = 'Obj 3'")->First();
// Check that both obj 3 children are returned
$this->assertEquals(array("Obj 3a", "Obj 3b", "Obj 3c"),
$obj3->AllHistoricalChildren()->column('Title'));
// Check numHistoricalChildren
$this->assertEquals(3, $obj3->numHistoricalChildren());
}
/**
* Test that you can call Hierarchy::markExpanded/Unexpanded/Open() on a obj, and that
* calling Hierarchy::isMarked() on a different instance of that object will return true.
*/
public function testItemMarkingIsntRestrictedToSpecificInstance() {
// Mark a few objs
$this->objFromFixture('HierarchyTest_Object', 'obj2')->markExpanded();
$this->objFromFixture('HierarchyTest_Object', 'obj2a')->markExpanded();
$this->objFromFixture('HierarchyTest_Object', 'obj2b')->markExpanded();
$this->objFromFixture('HierarchyTest_Object', 'obj3')->markUnexpanded();
// Query some objs in a different context and check their m
$objs = DataObject::get("HierarchyTest_Object", '', '"ID" ASC');
$marked = $expanded = array();
foreach($objs as $obj) {
if($obj->isMarked()) $marked[] = $obj->Title;
if($obj->isExpanded()) $expanded[] = $obj->Title;
}
$this->assertEquals(array('Obj 2', 'Obj 3', 'Obj 2a', 'Obj 2b'), $marked);
$this->assertEquals(array('Obj 2', 'Obj 2a', 'Obj 2b'), $expanded);
}
public function testNumChildren() {
$this->assertEquals($this->objFromFixture('HierarchyTest_Object', 'obj1')->numChildren(), 0);
$this->assertEquals($this->objFromFixture('HierarchyTest_Object', 'obj2')->numChildren(), 2);
$this->assertEquals($this->objFromFixture('HierarchyTest_Object', 'obj3')->numChildren(), 3);
$this->assertEquals($this->objFromFixture('HierarchyTest_Object', 'obj2a')->numChildren(), 2);
$this->assertEquals($this->objFromFixture('HierarchyTest_Object', 'obj2b')->numChildren(), 0);
$this->assertEquals($this->objFromFixture('HierarchyTest_Object', 'obj3a')->numChildren(), 2);
$this->assertEquals($this->objFromFixture('HierarchyTest_Object', 'obj3b')->numChildren(), 0);
$obj1 = $this->objFromFixture('HierarchyTest_Object', 'obj1');
$this->assertEquals($obj1->numChildren(), 0);
$obj1Child1 = new HierarchyTest_Object();
$obj1Child1->ParentID = $obj1->ID;
$obj1Child1->write();
$this->assertEquals($obj1->numChildren(false), 1,
'numChildren() caching can be disabled through method parameter'
);
$obj1Child2 = new HierarchyTest_Object();
$obj1Child2->ParentID = $obj1->ID;
$obj1Child2->write();
$obj1->flushCache();
$this->assertEquals($obj1->numChildren(), 2,
'numChildren() caching can be disabled by flushCache()'
);
}
public function testLoadDescendantIDListIntoArray() {
$obj2 = $this->objFromFixture('HierarchyTest_Object', 'obj2');
$obj2a = $this->objFromFixture('HierarchyTest_Object', 'obj2a');
$obj2b = $this->objFromFixture('HierarchyTest_Object', 'obj2b');
$obj2aa = $this->objFromFixture('HierarchyTest_Object', 'obj2aa');
$obj2ab = $this->objFromFixture('HierarchyTest_Object', 'obj2ab');
$obj2IdList = $obj2->getDescendantIDList();
$obj2aIdList = $obj2a->getDescendantIDList();
$this->assertContains($obj2a->ID, $obj2IdList);
$this->assertContains($obj2b->ID, $obj2IdList);
$this->assertContains($obj2aa->ID, $obj2IdList);
$this->assertContains($obj2ab->ID, $obj2IdList);
$this->assertEquals(4, count($obj2IdList));
$this->assertContains($obj2aa->ID, $obj2aIdList);
$this->assertContains($obj2ab->ID, $obj2aIdList);
$this->assertEquals(2, count($obj2aIdList));
}
/**
* The "only deleted from stage" argument to liveChildren() should exclude
* any page that has been moved to another location on the stage site
*/
public function testLiveChildrenOnlyDeletedFromStage() {
$obj1 = $this->objFromFixture('HierarchyTest_Object', 'obj1');
$obj2 = $this->objFromFixture('HierarchyTest_Object', 'obj2');
$obj2a = $this->objFromFixture('HierarchyTest_Object', 'obj2a');
$obj2b = $this->objFromFixture('HierarchyTest_Object', 'obj2b');
// Get a published set of objects for our fixture
$obj1->publish("Stage", "Live");
$obj2->publish("Stage", "Live");
$obj2a->publish("Stage", "Live");
$obj2b->publish("Stage", "Live");
// Then delete 2a from stage and move 2b to a sub-node of 1.
$obj2a->delete();
$obj2b->ParentID = $obj1->ID;
$obj2b->write();
// Get live children, excluding pages that have been moved on the stage site
$children = $obj2->liveChildren(true, true)->column("Title");
// 2a has been deleted from stage and should be shown
$this->assertContains("Obj 2a", $children);
// 2b has merely been moved to a different parent and so shouldn't be shown
$this->assertNotContains("Obj 2b", $children);
}
public function testBreadcrumbs() {
$obj1 = $this->objFromFixture('HierarchyTest_Object', 'obj1');
$obj2 = $this->objFromFixture('HierarchyTest_Object', 'obj2');
$obj2a = $this->objFromFixture('HierarchyTest_Object', 'obj2a');
$obj2aa = $this->objFromFixture('HierarchyTest_Object', 'obj2aa');
$this->assertEquals('Obj 1', $obj1->getBreadcrumbs());
$this->assertEquals('Obj 2 &raquo; Obj 2a', $obj2a->getBreadcrumbs());
$this->assertEquals('Obj 2 &raquo; Obj 2a &raquo; Obj 2aa', $obj2aa->getBreadcrumbs());
}
public function testGetChildrenAsUL() {
$obj1 = $this->objFromFixture('HierarchyTest_Object', 'obj1');
$obj2 = $this->objFromFixture('HierarchyTest_Object', 'obj2');
$obj2a = $this->objFromFixture('HierarchyTest_Object', 'obj2a');
$obj2aa = $this->objFromFixture('HierarchyTest_Object', 'obj2aa');
$nodeCountThreshold = 30;
$root = new HierarchyTest_Object();
$root->markPartialTree($nodeCountThreshold);
$html = $root->getChildrenAsUL(
"",
'"<li id=\"" . $child->ID . "\">" . $child->Title',
null,
false,
"AllChildrenIncludingDeleted",
"numChildren",
true, // rootCall
$nodeCountThreshold
);
$this->assertTreeContains($html, array($obj2),
'Contains root elements'
);
$this->assertTreeContains($html, array($obj2, $obj2a),
'Contains child elements (in correct nesting)'
);
$this->assertTreeContains($html, array($obj2, $obj2a, $obj2aa),
'Contains grandchild elements (in correct nesting)'
);
}
public function testGetChildrenAsULMinNodeCount() {
$obj1 = $this->objFromFixture('HierarchyTest_Object', 'obj1');
$obj2 = $this->objFromFixture('HierarchyTest_Object', 'obj2');
$obj2a = $this->objFromFixture('HierarchyTest_Object', 'obj2a');
// Set low enough that it should be fulfilled by root only elements
$nodeCountThreshold = 3;
$root = new HierarchyTest_Object();
$root->markPartialTree($nodeCountThreshold);
$html = $root->getChildrenAsUL(
"",
'"<li id=\"" . $child->ID . "\">" . $child->Title',
null,
false,
"AllChildrenIncludingDeleted",
"numChildren",
true,
$nodeCountThreshold
);
$this->assertTreeContains($html, array($obj1),
'Contains root elements'
);
$this->assertTreeContains($html, array($obj2),
'Contains root elements'
);
$this->assertTreeNotContains($html, array($obj2, $obj2a),
'Does not contains child elements because they exceed minNodeCount'
);
}
public function testGetChildrenAsULMinNodeCountWithMarkToExpose() {
$obj2 = $this->objFromFixture('HierarchyTest_Object', 'obj2');
$obj2a = $this->objFromFixture('HierarchyTest_Object', 'obj2a');
$obj2aa = $this->objFromFixture('HierarchyTest_Object', 'obj2aa');
// Set low enough that it should be fulfilled by root only elements
$nodeCountThreshold = 3;
$root = new HierarchyTest_Object();
$root->markPartialTree($nodeCountThreshold);
// Mark certain node which should be included regardless of minNodeCount restrictions
$root->markToExpose($obj2aa);
$html = $root->getChildrenAsUL(
"",
'"<li id=\"" . $child->ID . "\">" . $child->Title',
null,
false,
"AllChildrenIncludingDeleted",
"numChildren",
true,
$nodeCountThreshold
);
$this->assertTreeContains($html, array($obj2),
'Contains root elements'
);
$this->assertTreeContains($html, array($obj2, $obj2a, $obj2aa),
'Does contain marked children nodes regardless of configured threshold'
);
}
public function testGetChildrenAsULMinNodeCountWithFilters() {
$obj1 = $this->objFromFixture('HierarchyTest_Object', 'obj1');
$obj2 = $this->objFromFixture('HierarchyTest_Object', 'obj2');
$obj2a = $this->objFromFixture('HierarchyTest_Object', 'obj2a');
$obj2aa = $this->objFromFixture('HierarchyTest_Object', 'obj2aa');
// Set low enough that it should fit all search matches without lazy loading
$nodeCountThreshold = 3;
$root = new HierarchyTest_Object();
// Includes nodes by filter regardless of minNodeCount restrictions
$root->setMarkingFilterFunction(function($record) use($obj2, $obj2a, $obj2aa) {
// Results need to include parent hierarchy, even if we just want to
// match the innermost node.
return in_array($record->ID, array($obj2->ID, $obj2a->ID, $obj2aa->ID));
});
$root->markPartialTree($nodeCountThreshold);
$html = $root->getChildrenAsUL(
"",
'"<li id=\"" . $child->ID . "\">" . $child->Title',
null,
true, // limit to marked
"AllChildrenIncludingDeleted",
"numChildren",
true,
$nodeCountThreshold
);
$this->assertTreeNotContains($html, array($obj1),
'Does not contain root elements which dont match the filter'
);
$this->assertTreeContains($html, array($obj2, $obj2a, $obj2aa),
'Contains non-root elements which match the filter'
);
}
public function testGetChildrenAsULHardLimitsNodes() {
$obj1 = $this->objFromFixture('HierarchyTest_Object', 'obj1');
$obj2 = $this->objFromFixture('HierarchyTest_Object', 'obj2');
$obj2a = $this->objFromFixture('HierarchyTest_Object', 'obj2a');
$obj2aa = $this->objFromFixture('HierarchyTest_Object', 'obj2aa');
// Set low enough that it should fit all search matches without lazy loading
$nodeCountThreshold = 3;
$root = new HierarchyTest_Object();
// Includes nodes by filter regardless of minNodeCount restrictions
$root->setMarkingFilterFunction(function($record) use($obj2, $obj2a, $obj2aa) {
// Results need to include parent hierarchy, even if we just want to
// match the innermost node.
return in_array($record->ID, array($obj2->ID, $obj2a->ID, $obj2aa->ID));
});
$root->markPartialTree($nodeCountThreshold);
$html = $root->getChildrenAsUL(
"",
'"<li id=\"" . $child->ID . "\">" . $child->Title',
null,
true, // limit to marked
"AllChildrenIncludingDeleted",
"numChildren",
true,
$nodeCountThreshold
);
$this->assertTreeNotContains($html, array($obj1),
'Does not contain root elements which dont match the filter'
);
$this->assertTreeContains($html, array($obj2, $obj2a, $obj2aa),
'Contains non-root elements which match the filter'
);
}
public function testGetChildrenAsULNodeThresholdLeaf() {
$obj1 = $this->objFromFixture('HierarchyTest_Object', 'obj1');
$obj2 = $this->objFromFixture('HierarchyTest_Object', 'obj2');
$obj2a = $this->objFromFixture('HierarchyTest_Object', 'obj2a');
$obj3 = $this->objFromFixture('HierarchyTest_Object', 'obj3');
$obj3a = $this->objFromFixture('HierarchyTest_Object', 'obj3a');
$nodeCountThreshold = 99;
$root = new HierarchyTest_Object();
$root->markPartialTree($nodeCountThreshold);
$nodeCountCallback = function($parent, $numChildren) {
// Set low enough that it the fixture structure should exceed it
if($parent->ID && $numChildren > 2) {
return '<span class="exceeded">Exceeded!</span>';
}
};
$html = $root->getChildrenAsUL(
"",
'"<li id=\"" . $child->ID . "\">" . $child->Title',
null,
true, // limit to marked
"AllChildrenIncludingDeleted",
"numChildren",
true,
$nodeCountThreshold,
$nodeCountCallback
);
$this->assertTreeContains($html, array($obj1),
'Does contain root elements regardless of count'
);
$this->assertTreeContains($html, array($obj3),
'Does contain root elements regardless of count'
);
$this->assertTreeContains($html, array($obj2, $obj2a),
'Contains children which do not exceed threshold'
);
$this->assertTreeNotContains($html, array($obj3, $obj3a),
'Does not contain children which exceed threshold'
);
}
/**
* This test checks that deleted ('archived') child pages don't set a css class on the parent
* node that makes it look like it has children
*/
public function testGetChildrenAsULNodeDeletedOnLive() {
$obj2 = $this->objFromFixture('HierarchyTest_Object', 'obj2');
$obj2a = $this->objFromFixture('HierarchyTest_Object', 'obj2a');
$obj2aa = $this->objFromFixture('HierarchyTest_Object', 'obj2aa');
$obj2ab = $this->objFromFixture('HierarchyTest_Object', 'obj2b');
// delete all children under obj2
$obj2a->delete();
$obj2aa->delete();
$obj2ab->delete();
// Don't pre-load all children
$nodeCountThreshold = 1;
$childrenMethod = 'AllChildren';
$numChildrenMethod = 'numChildren';
$root = new HierarchyTest_Object();
$root->markPartialTree($nodeCountThreshold, null, $childrenMethod, $numChildrenMethod);
// As in LeftAndMain::getSiteTreeFor() but simpler and more to the point for testing purposes
$titleFn = function(&$child, $numChildrenMethod="") {
return '<li class="' . $child->markingClasses($numChildrenMethod).
'" id="' . $child->ID . '">"' . $child->Title;
};
$html = $root->getChildrenAsUL(
"",
$titleFn,
null,
true, // limit to marked
$childrenMethod,
$numChildrenMethod,
true,
$nodeCountThreshold
);
// Get the class attribute from the $obj2 node in the sitetree, class 'jstree-leaf' means it's a leaf node
$nodeClass = $this->getNodeClassFromTree($html, $obj2);
$this->assertEquals('jstree-leaf closed', $nodeClass, 'object2 should not have children in the sitetree');
}
/**
* This test checks that deleted ('archived') child pages _do_ set a css class on the parent
* node that makes it look like it has children when getting all children including deleted
*/
public function testGetChildrenAsULNodeDeletedOnStage() {
$obj2 = $this->objFromFixture('HierarchyTest_Object', 'obj2');
$obj2a = $this->objFromFixture('HierarchyTest_Object', 'obj2a');
$obj2aa = $this->objFromFixture('HierarchyTest_Object', 'obj2aa');
$obj2ab = $this->objFromFixture('HierarchyTest_Object', 'obj2b');
// delete all children under obj2
$obj2a->delete();
$obj2aa->delete();
$obj2ab->delete();
// Don't pre-load all children
$nodeCountThreshold = 1;
$childrenMethod = 'AllChildrenIncludingDeleted';
$numChildrenMethod = 'numHistoricalChildren';
$root = new HierarchyTest_Object();
$root->markPartialTree($nodeCountThreshold, null, $childrenMethod, $numChildrenMethod);
// As in LeftAndMain::getSiteTreeFor() but simpler and more to the point for testing purposes
$titleFn = function(&$child, $numChildrenMethod="") {
return '<li class="' . $child->markingClasses($numChildrenMethod).
'" id="' . $child->ID . '">"' . $child->Title;
};
$html = $root->getChildrenAsUL(
"",
$titleFn,
null,
true, // limit to marked
$childrenMethod,
$numChildrenMethod,
true,
$nodeCountThreshold
);
// Get the class attribute from the $obj2 node in the sitetree
$nodeClass = $this->getNodeClassFromTree($html, $obj2);
// Object2 can now be expanded
$this->assertEquals('unexpanded jstree-closed closed', $nodeClass, 'obj2 should have children in the sitetree');
}
/**
* @param String $html [description]
* @param array $nodes Breadcrumb path as array
* @param String $message
*/
protected function assertTreeContains($html, $nodes, $message = null) {
$parser = new CSSContentParser($html);
$xpath = '/';
foreach($nodes as $node) $xpath .= '/ul/li[@id="' . $node->ID . '"]';
$match = $parser->getByXpath($xpath);
self::assertThat((bool)$match, self::isTrue(), $message);
}
/**
* @param String $html [description]
* @param array $nodes Breadcrumb path as array
* @param String $message
*/
protected function assertTreeNotContains($html, $nodes, $message = null) {
$parser = new CSSContentParser($html);
$xpath = '/';
foreach($nodes as $node) $xpath .= '/ul/li[@id="' . $node->ID . '"]';
$match = $parser->getByXpath($xpath);
self::assertThat((bool)$match, self::isFalse(), $message);
}
/**
* Get the HTML class attribute from a node in the sitetree
*
* @param $html
* @param $node
* @return string
*/
protected function getNodeClassFromTree($html, $node) {
$parser = new CSSContentParser($html);
$xpath = '//ul/li[@id="' . $node->ID . '"]';
$object = $parser->getByXpath($xpath);
foreach($object[0]->attributes() as $key => $attr) {
if($key == 'class') {
return (string)$attr;
}
}
return '';
}
}
class HierarchyTest_Object extends DataObject implements TestOnly {
private static $db = array(
'Title' => 'Varchar'
);
private static $extensions = array(
'Hierarchy',
"Versioned('Stage', 'Live')",
);
public function cmstreeclasses() {
return $this->markingClasses();
}
}