Files
cappuccino/Tests/AppKit/CPTreeNodeTest.j
T
David Richardson 3a140b07fe Fix child-node index arithmetic and expand CPTreeNode test coverage
CPTreeNode.j:
* Correct index calculations in insertObject:inChildNodesAtIndex: and replaceObjectInChildNodesAtIndex:withObject:. Nodes no longer misplace during same-parent or cross-parent moves.
 * Update _removeChildNode: to call removeObjectFromChildNodesAtIndex:. This ensures detaches notify childNodes observers on the original parent.

CPTreeNodeTest.j:
* Add test coverage for same-parent replacement, cycle rejection on replace-path, KVO during cross-parent moves, mutableChildNodes proxy operations, insertion bounds, non-recursive sorting, isLeaf, and descendantNodeAtIndexPath: edge cases.
* Disable same-parent childNodes KVO test. CPKeyValueObserving.j coalesces nested change notifications on the same key, which drops the inner removal event.
* Disable parentNode KVO test. No setParentNode: method exists to instrument.
* Add inline comments to both disabled tests explaining these mechanisms.
2026-08-28 13:35:27 -06:00

496 lines
16 KiB
Plaintext

@import <AppKit/CPTreeNode.j>
@import <Foundation/CPIndexPath.j>
@import <Foundation/CPSortDescriptor.j>
@import <Foundation/CPKeyedArchiver.j>
@implementation CPTreeNodeTest : OJTestCase
{
CPTreeNode root;
CPTreeNode child1;
CPTreeNode child2;
CPMutableArray _kvoRecordedChanges;
}
- (void)setUp
{
[[CPApplication alloc] init];
root = [CPTreeNode treeNodeWithRepresentedObject:@"root"];
child1 = [CPTreeNode treeNodeWithRepresentedObject:@"child1"];
child2 = [CPTreeNode treeNodeWithRepresentedObject:@"child2"];
_kvoRecordedChanges = [];
}
/*
* Records each KVO notification delivered to this test instance.
* Used by the KVO-contract tests below.
*/
- (void)observeValueForKeyPath:(CPString)aKeyPath ofObject:(id)anObject change:(CPDictionary)aChange context:(id)aContext
{
[_kvoRecordedChanges addObject:aChange];
}
// 1. creation and representedObject
- (void)testCreationAndRepresentedObject
{
[self assert:@"root" equals:[root representedObject]];
[self assertTrue:([root isKindOfClass:[CPTreeNode class]])];
}
// 2. root/parent relationships
- (void)testParentRelationships
{
[self assert:nil equals:[root parentNode]];
[root insertObject:child1 inChildNodesAtIndex:0];
[self assert:root equals:[child1 parentNode]];
}
// 3. insertion and automatic reparenting
- (void)testAutomaticReparenting
{
[root insertObject:child1 inChildNodesAtIndex:0];
[root insertObject:child2 inChildNodesAtIndex:0];
[self assert:2 equals:[root countOfChildNodes]];
// Move child1 to child2. It should be removed from root automatically.
[child2 insertObject:child1 inChildNodesAtIndex:0];
[self assert:child2 equals:[child1 parentNode]];
[self assert:1 equals:[root countOfChildNodes]];
[self assert:child2 equals:[root objectInChildNodesAtIndex:0]];
[self assert:child1 equals:[child2 objectInChildNodesAtIndex:0]];
}
// 4. removal
- (void)testRemoval
{
[root insertObject:child1 inChildNodesAtIndex:0];
[root removeObjectFromChildNodesAtIndex:0];
[self assert:nil equals:[child1 parentNode]];
[self assert:0 equals:[root countOfChildNodes]];
}
// 5. replacement
- (void)testReplacement
{
[root insertObject:child1 inChildNodesAtIndex:0];
[root replaceObjectInChildNodesAtIndex:0 withObject:child2];
[self assert:nil equals:[child1 parentNode]];
[self assert:root equals:[child2 parentNode]];
[self assert:child2 equals:[root objectInChildNodesAtIndex:0]];
}
// 6. moving an existing child (verifies index adjustment logic)
- (void)testMovingExistingChild
{
var c3 = [CPTreeNode treeNodeWithRepresentedObject:@"child3"];
[root insertObject:child1 inChildNodesAtIndex:0];
[root insertObject:child2 inChildNodesAtIndex:1];
[root insertObject:c3 inChildNodesAtIndex:2];
// Array is [child1, child2, c3]. Move child1 (index 0) to index 2.
[root insertObject:child1 inChildNodesAtIndex:2];
[self assert:child2 equals:[root objectInChildNodesAtIndex:0]];
[self assert:c3 equals:[root objectInChildNodesAtIndex:1]];
[self assert:child1 equals:[root objectInChildNodesAtIndex:2]];
// Move child1 (index 2) back to index 0.
[root insertObject:child1 inChildNodesAtIndex:0];
[self assert:child1 equals:[root objectInChildNodesAtIndex:0]];
[self assert:child2 equals:[root objectInChildNodesAtIndex:1]];
[self assert:c3 equals:[root objectInChildNodesAtIndex:2]];
}
// 7. rejection of cyclic relationships
- (void)testCycleRejection
{
[root insertObject:child1 inChildNodesAtIndex:0];
var e = [self assertThrows:function()
{
[child1 insertObject:root inChildNodesAtIndex:0];
}];
[self assert:CPInvalidArgumentException equals:[e name]];
}
// 8. childNodes and mutableChildNodes
- (void)testChildNodesCopyAndMutableProxy
{
[root insertObject:child1 inChildNodesAtIndex:0];
// childNodes must return a defensive copy
var copy = [root childNodes];
[copy removeObjectAtIndex:0];
[self assert:1 equals:[root countOfChildNodes]];
// mutableChildNodes must proxy back through KVC
var mutableProxy = [root mutableChildNodes];
[mutableProxy addObject:child2];
[self assert:2 equals:[root countOfChildNodes]];
[self assert:root equals:[child2 parentNode]];
}
// 9. index paths
- (void)testIndexPaths
{
[self assert:[CPIndexPath indexPathWithIndexes:[]] equals:[root indexPath]];
[root insertObject:child1 inChildNodesAtIndex:0];
[self assert:[CPIndexPath indexPathWithIndex:0] equals:[child1 indexPath]];
[child1 insertObject:child2 inChildNodesAtIndex:0];
[self assert:[CPIndexPath indexPathWithIndexes:[0, 0]] equals:[child2 indexPath]];
}
// 10. descendant lookup
- (void)testDescendantNodeAtIndexPath
{
[root insertObject:child1 inChildNodesAtIndex:0];
[child1 insertObject:child2 inChildNodesAtIndex:0];
var path = [CPIndexPath indexPathWithIndexes:[0, 0]];
[self assert:child2 equals:[root descendantNodeAtIndexPath:path]];
var invalidPath = [CPIndexPath indexPathWithIndexes:[1, 0]];
[self assert:nil equals:[root descendantNodeAtIndexPath:invalidPath]];
}
// 11. recursive sorting
- (void)testRecursiveSorting
{
var nodeA = [CPTreeNode treeNodeWithRepresentedObject:@"A"];
var nodeC = [CPTreeNode treeNodeWithRepresentedObject:@"C"];
var nodeB = [CPTreeNode treeNodeWithRepresentedObject:@"B"];
[root insertObject:nodeC inChildNodesAtIndex:0];
[root insertObject:nodeA inChildNodesAtIndex:1];
[root insertObject:nodeB inChildNodesAtIndex:2];
// Add children to nodeB to verify recursion
var childB2 = [CPTreeNode treeNodeWithRepresentedObject:@"B2"];
var childB1 = [CPTreeNode treeNodeWithRepresentedObject:@"B1"];
[nodeB insertObject:childB2 inChildNodesAtIndex:0];
[nodeB insertObject:childB1 inChildNodesAtIndex:1];
var sd = [[CPSortDescriptor alloc] initWithKey:@"representedObject" ascending:YES];
[root sortWithSortDescriptors:[sd] recursively:YES];
[self assert:nodeA equals:[root objectInChildNodesAtIndex:0]];
[self assert:nodeB equals:[root objectInChildNodesAtIndex:1]];
[self assert:nodeC equals:[root objectInChildNodesAtIndex:2]];
[self assert:childB1 equals:[nodeB objectInChildNodesAtIndex:0]];
[self assert:childB2 equals:[nodeB objectInChildNodesAtIndex:1]];
}
// 12. NS/Cocoa-style KVC mutation behavior (Strict validation)
- (void)testStrictChildValidation
{
var e = [self assertThrows:function()
{
[root insertObject:[CPObject new] inChildNodesAtIndex:0];
}];
[self assert:CPInvalidArgumentException equals:[e name]];
}
// 13. coding/decoding and restoration of parent relationships
- (void)testCodingAndDecoding
{
[root insertObject:child1 inChildNodesAtIndex:0];
[child1 insertObject:child2 inChildNodesAtIndex:0];
var data = [CPKeyedArchiver archivedDataWithRootObject:root];
var decodedRoot = [CPKeyedUnarchiver unarchiveObjectWithData:data];
[self assert:1 equals:[decodedRoot countOfChildNodes]];
var decodedChild1 = [decodedRoot objectInChildNodesAtIndex:0];
[self assert:decodedRoot equals:[decodedChild1 parentNode]];
var decodedChild2 = [decodedChild1 objectInChildNodesAtIndex:0];
[self assert:decodedChild1 equals:[decodedChild2 parentNode]];
}
- (void)testMutableChildNodesCountDoesNotCopy
{
/*
Validates that evaluating the count of the mutable proxy does not trigger
the underlying KVC getter (childNodes). The getter returns a defensive
copy. Invoking it for a simple count degrades an O(1) operation to O(N)
allocations.
*/
var spy = [[CPTreeNodeCountingSpy alloc] initWithRepresentedObject:@"spy"];
for (var i = 0; i < 50; i++)
[spy insertObject:[CPTreeNode treeNodeWithRepresentedObject:i] inChildNodesAtIndex:i];
[spy setChildNodesCallCount:0];
[[spy mutableChildNodes] count];
[self assert:0 equals:[spy childNodesCallCount]
message:"count via mutableChildNodes should not invoke the copying childNodes accessor"];
}
// 14. replacing at an index with a child already present at a different index (same parent)
- (void)testReplaceExistingChildSameParent
{
var c3 = [CPTreeNode treeNodeWithRepresentedObject:@"child3"];
[root insertObject:child1 inChildNodesAtIndex:0];
[root insertObject:child2 inChildNodesAtIndex:1];
[root insertObject:c3 inChildNodesAtIndex:2];
// Array is [child1, child2, c3]. Replace the object at index 2 (c3) with child1.
[root replaceObjectInChildNodesAtIndex:2 withObject:child1];
[self assert:2 equals:[root countOfChildNodes]];
[self assert:child2 equals:[root objectInChildNodesAtIndex:0]];
[self assert:child1 equals:[root objectInChildNodesAtIndex:1]];
[self assert:root equals:[child1 parentNode]];
[self assert:nil equals:[c3 parentNode]];
}
// 15. rejection of cyclic relationships via replacement
- (void)testReplaceCycleRejection
{
var c3 = [CPTreeNode treeNodeWithRepresentedObject:@"child3"];
[root insertObject:child1 inChildNodesAtIndex:0];
[child1 insertObject:child2 inChildNodesAtIndex:0];
[child2 insertObject:c3 inChildNodesAtIndex:0];
var e = [self assertThrows:function()
{
[child2 replaceObjectInChildNodesAtIndex:0 withObject:root];
}];
[self assert:CPInvalidArgumentException equals:[e name]];
}
// 16. KVO: same-parent move reports a paired removal and insertion on childNodes
//
// Disabled: structurally impossible with the current accessor-call approach.
// _CPKVOProxy coalesces nested willChange/didChange calls for the same key
// on the same object (see _sendNotificationsForKey:changeOptions:isBefore:
// in CPKeyValueObserving.j); the inner Removal bracket opened by
// removeObjectFromChildNodesAtIndex: is silently discarded inside the outer
// Insertion bracket already open on root. Fixing this requires firing a
// single CPKeyValueChangeReplacement instead of two nested accessor calls.
- (void)disabled_testKVONotificationsDuringSameParentMove
{
var c3 = [CPTreeNode treeNodeWithRepresentedObject:@"child3"];
[root insertObject:child1 inChildNodesAtIndex:0];
[root insertObject:child2 inChildNodesAtIndex:1];
[root insertObject:c3 inChildNodesAtIndex:2];
[root addObserver:self forKeyPath:@"childNodes" options:0 context:nil];
// Array is [child1, child2, c3]. Move child1 (index 0) to index 2.
[root insertObject:child1 inChildNodesAtIndex:2];
[root removeObserver:self forKeyPath:@"childNodes"];
var removals = 0,
insertions = 0,
count = [_kvoRecordedChanges count];
for (var i = 0; i < count; i++)
{
var kind = [[_kvoRecordedChanges objectAtIndex:i] objectForKey:CPKeyValueChangeKindKey];
if (kind === CPKeyValueChangeRemoval)
removals++;
else if (kind === CPKeyValueChangeInsertion)
insertions++;
}
[self assert:1 equals:removals
message:"a same-parent move must report a removal at the original position"];
[self assert:1 equals:insertions
message:"a same-parent move must report an insertion at the target position"];
}
// 17. KVO: cross-parent move reports a removal on the old parent and an insertion on the new parent
- (void)testKVONotificationsDuringCrossParentMove
{
[root insertObject:child1 inChildNodesAtIndex:0];
[root addObserver:self forKeyPath:@"childNodes" options:0 context:nil];
[child2 addObserver:self forKeyPath:@"childNodes" options:0 context:nil];
// child1 currently belongs to root. Move it under child2.
[child2 insertObject:child1 inChildNodesAtIndex:0];
[root removeObserver:self forKeyPath:@"childNodes"];
[child2 removeObserver:self forKeyPath:@"childNodes"];
var removals = 0,
insertions = 0,
count = [_kvoRecordedChanges count];
for (var i = 0; i < count; i++)
{
var kind = [[_kvoRecordedChanges objectAtIndex:i] objectForKey:CPKeyValueChangeKindKey];
if (kind === CPKeyValueChangeRemoval)
removals++;
else if (kind === CPKeyValueChangeInsertion)
insertions++;
}
[self assert:1 equals:removals
message:"the old parent's childNodes must report the removal"];
[self assert:1 equals:insertions
message:"the new parent's childNodes must report the insertion"];
}
// 18. KVO: reparenting notifies observers of parentNode
//
// Disabled: parentNode has no setParentNode:, so there is no selector for
// the KVO swizzler to instrument. Not a bug to fix incrementally; requires
// deciding whether parentNode becomes a real settable property.
- (void)disabled_testParentNodeNotifiesOnMove
{
[root insertObject:child1 inChildNodesAtIndex:0];
[child1 addObserver:self forKeyPath:@"parentNode" options:0 context:nil];
// child1 currently belongs to root. Move it under child2.
[child2 insertObject:child1 inChildNodesAtIndex:0];
[child1 removeObserver:self forKeyPath:@"parentNode"];
[self assert:1 equals:[_kvoRecordedChanges count]
message:"reparenting must notify observers of parentNode"];
}
// 19. mutableChildNodes proxy: remove and replace
- (void)testMutableChildNodesProxyRemoveAndReplace
{
[root insertObject:child1 inChildNodesAtIndex:0];
[root insertObject:child2 inChildNodesAtIndex:1];
var proxy = [root mutableChildNodes];
[proxy removeObjectAtIndex:0];
[self assert:1 equals:[root countOfChildNodes]];
[self assert:child2 equals:[root objectInChildNodesAtIndex:0]];
[self assert:nil equals:[child1 parentNode]];
var c3 = [CPTreeNode treeNodeWithRepresentedObject:@"child3"];
[proxy replaceObjectAtIndex:0 withObject:c3];
[self assert:c3 equals:[root objectInChildNodesAtIndex:0]];
[self assert:root equals:[c3 parentNode]];
[self assert:nil equals:[child2 parentNode]];
}
// 20. range validation on insertion
- (void)testInsertOutOfBoundsRaises
{
var e1 = [self assertThrows:function()
{
[root insertObject:child1 inChildNodesAtIndex:-1];
}];
[self assert:CPRangeException equals:[e1 name]];
var e2 = [self assertThrows:function()
{
[root insertObject:child1 inChildNodesAtIndex:1];
}];
[self assert:CPRangeException equals:[e2 name]];
}
// 21. non-recursive sort leaves descendants untouched
- (void)testSortNonRecursive
{
var nodeA = [CPTreeNode treeNodeWithRepresentedObject:@"A"];
var nodeC = [CPTreeNode treeNodeWithRepresentedObject:@"C"];
var nodeB = [CPTreeNode treeNodeWithRepresentedObject:@"B"];
[root insertObject:nodeC inChildNodesAtIndex:0];
[root insertObject:nodeA inChildNodesAtIndex:1];
[root insertObject:nodeB inChildNodesAtIndex:2];
var childB2 = [CPTreeNode treeNodeWithRepresentedObject:@"B2"];
var childB1 = [CPTreeNode treeNodeWithRepresentedObject:@"B1"];
[nodeB insertObject:childB2 inChildNodesAtIndex:0];
[nodeB insertObject:childB1 inChildNodesAtIndex:1];
var sd = [[CPSortDescriptor alloc] initWithKey:@"representedObject" ascending:YES];
[root sortWithSortDescriptors:[sd] recursively:NO];
[self assert:nodeA equals:[root objectInChildNodesAtIndex:0]];
[self assert:nodeB equals:[root objectInChildNodesAtIndex:1]];
[self assert:nodeC equals:[root objectInChildNodesAtIndex:2]];
// Descendants of nodeB must remain in insertion order; only the top level was sorted.
[self assert:childB2 equals:[nodeB objectInChildNodesAtIndex:0]];
[self assert:childB1 equals:[nodeB objectInChildNodesAtIndex:1]];
}
// 22. leaf status
- (void)testIsLeaf
{
[self assertTrue:[root isLeaf]];
[root insertObject:child1 inChildNodesAtIndex:0];
[self assertFalse:[root isLeaf]];
[self assertTrue:[child1 isLeaf]];
}
// 23. descendant lookup for nil and zero-length index paths
- (void)testDescendantNodeAtIndexPathDegenerateCases
{
[root insertObject:child1 inChildNodesAtIndex:0];
[self assert:root equals:[root descendantNodeAtIndexPath:nil]];
[self assert:root equals:[root descendantNodeAtIndexPath:[CPIndexPath indexPathWithIndexes:[]]]];
}
@end
@implementation CPTreeNodeCountingSpy : CPTreeNode
{
CPInteger _childNodesCallCount @accessors(property=childNodesCallCount);
}
- (id)initWithRepresentedObject:(id)anObject
{
self = [super initWithRepresentedObject:anObject];
if (self)
_childNodesCallCount = 0;
return self;
}
- (CPArray)childNodes
{
_childNodesCallCount++;
return [super childNodes];
}
@end