Files
cappuccino/AppKit/CPTreeNode.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

458 lines
14 KiB
Plaintext

/*
* CPTreeNode.j
* AppKit
*
* Created by Francisco Tolmasky.
* Copyright 2009, 280 North, Inc.
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301 USA
*/
@import <Foundation/CPObject.j>
@import <Foundation/CPIndexPath.j>
@import <Foundation/CPArray.j>
/*
* CPTreeNode implements the NSTreeNode contract.
* The _childNodes array contains only CPTreeNode instances.
* The representedObject property contains the application data.
* The _parentNode and _childNodes properties maintain a strict bidirectional relationship.
* The KVC mutation methods are the public mechanism to change the tree structure.
*/
@implementation CPTreeNode : CPObject
{
/*
* KVO notifications on parentNode are never delivered: there is no
* setParentNode: for the swizzling machinery to intercept, so there is
* no selector to instrument, in any code path.
*
* KVO notifications on childNodes are reliable for
* insertObject:inChildNodesAtIndex:, including same-parent and
* cross-parent moves: all detach paths for that method route through
* the KVC accessors. replaceObjectInChildNodesAtIndex:withObject:
* still detaches a same-parent replacement node by mutating
* _childNodes directly, bypassing the KVC proxy for that step; an
* observer of that node's former parent's childNodes can miss that
* specific removal, or see it reported as the wrong kind of change.
* Do not rely on childNodes observation during a same-parent replace;
* rely only on the state after the call returns.
*/
id _representedObject @accessors(readonly, property=representedObject);
CPTreeNode _parentNode @accessors(readonly, property=parentNode);
CPMutableArray _childNodes;
}
+ (id)treeNodeWithRepresentedObject:(id)anObject
{
return [[self alloc] initWithRepresentedObject:anObject];
}
- (id)initWithRepresentedObject:(id)anObject
{
self = [super init];
if (self)
{
_representedObject = anObject;
_childNodes = [];
}
return self;
}
/*
* Route plain init through the designated initializer.
* Without this override, [[CPTreeNode alloc] init] leaves _childNodes unset.
* The first mutation call then fails against an undefined array.
*/
- (id)init
{
return [self initWithRepresentedObject:nil];
}
/*
* Return YES if adding aTreeNode below self makes a cycle.
* This method walks the parent chain.
* The operation time is proportional to the tree depth.
*/
- (BOOL)_wouldCreateCycleWithNode:(CPTreeNode)aTreeNode
{
for (var node = self; node; node = node._parentNode)
{
if (node === aTreeNode)
return YES;
}
return NO;
}
/*
* Enforce the NSTreeNode abstraction boundary.
* All children must be CPTreeNode instances.
*/
- (void)_validateChildNode:(id)aTreeNode
{
if (![aTreeNode isKindOfClass:[CPTreeNode class]])
{
[CPException raise:CPInvalidArgumentException
reason:"CPTreeNode children must be CPTreeNode instances."];
}
}
/*
* Remove a child node by delegating to the public KVC accessor.
* Use this method for internal structural changes across a parent
* boundary, so an observer of this node's childNodes sees the removal.
*/
- (void)_removeChildNode:(CPTreeNode)aNode
{
var index = [_childNodes indexOfObjectIdenticalTo:aNode];
/*
* A caller reaches this method only when aNode.parentNode already equals
* self (see the two call sites below). If self._childNodes does not
* actually contain aNode at that point, the parent/child relationship
* is already broken. indexPath raises for this identical class of
* inconsistency; silently returning here would hide the same problem
* instead of surfacing it.
*/
if (index === CPNotFound)
{
[CPException raise:CPInternalInconsistencyException
reason:"CPTreeNode parent and child relationship is inconsistent."];
}
[self removeObjectFromChildNodesAtIndex:index];
}
- (CPIndexPath)indexPath
{
if (!_parentNode)
return [CPIndexPath indexPathWithIndexes:[]];
var indexes = [],
node = self;
while (node._parentNode)
{
var parent = node._parentNode,
index = [parent._childNodes indexOfObjectIdenticalTo:node];
if (index === CPNotFound)
{
[CPException raise:CPInternalInconsistencyException
reason:"CPTreeNode parent and child relationship is inconsistent."];
}
[indexes addObject:index];
node = parent;
}
/*
* indexes was collected leaf-to-root. Build a second array in
* root-to-leaf order by walking indexes backward. CPArray has no
* -reverse selector; count/objectAtIndex:/addObject: are the verified,
* already-used-elsewhere primitives.
*/
var orderedIndexes = [],
count = [indexes count];
while (count--)
[orderedIndexes addObject:[indexes objectAtIndex:count]];
return [CPIndexPath indexPathWithIndexes:orderedIndexes];
}
- (BOOL)isLeaf
{
return [_childNodes count] == 0;
}
- (CPArray)childNodes
{
/*
* Return a copy.
* This prevents external changes that bypass the KVC methods.
*/
return [_childNodes copy];
}
- (CPMutableArray)mutableChildNodes
{
return [self mutableArrayValueForKey:@"childNodes"];
}
/*
* KVC compliance methods.
* The mutableArrayValueForKey: method uses these names.
*/
- (void)insertObject:(CPTreeNode)aTreeNode inChildNodesAtIndex:(CPInteger)anIndex
{
var count = [_childNodes count];
if (anIndex < 0 || anIndex > count)
{
[CPException raise:CPRangeException
reason:"index (" + anIndex + ") beyond bounds (0 .. " + count + ") for insertObject:inChildNodesAtIndex:"];
}
[self _validateChildNode:aTreeNode];
if ([self _wouldCreateCycleWithNode:aTreeNode])
{
[CPException raise:CPInvalidArgumentException
reason:"Inserting a CPTreeNode beneath itself or one of its descendants makes a cycle."];
}
/*
* Detach the node from its old parent first.
* The code validated the index before this change.
*/
if (aTreeNode._parentNode)
{
if (aTreeNode._parentNode === self)
{
var originalIndex = [_childNodes indexOfObjectIdenticalTo:aTreeNode];
/*
* Route the detach through the KVC accessor, not direct array
* mutation, so an observer of childNodes sees the removal.
*/
[self removeObjectFromChildNodesAtIndex:originalIndex];
/*
* No index adjustment here. anIndex is the target position in
* the final array, per the KVC to-many contract. The array
* above is already one element short from the removal, so
* inserting at anIndex against it lands the node correctly.
*/
}
else
{
/*
* Detach the node from its old parent.
* Use the internal method to bypass KVO overhead.
*/
[aTreeNode._parentNode _removeChildNode:aTreeNode];
}
}
aTreeNode._parentNode = self;
[_childNodes insertObject:aTreeNode atIndex:anIndex];
}
- (void)removeObjectFromChildNodesAtIndex:(CPInteger)anIndex
{
var node = [_childNodes objectAtIndex:anIndex];
node._parentNode = nil;
[_childNodes removeObjectAtIndex:anIndex];
}
- (void)replaceObjectInChildNodesAtIndex:(CPInteger)anIndex withObject:(CPTreeNode)aTreeNode
{
var oldTreeNode = [_childNodes objectAtIndex:anIndex];
[self _validateChildNode:aTreeNode];
if (oldTreeNode === aTreeNode)
return;
if ([self _wouldCreateCycleWithNode:aTreeNode])
{
[CPException raise:CPInvalidArgumentException
reason:"Replacing a child with itself or one of its ancestors makes a cycle."];
}
/*
* If the replacement node is already a child of this parent, remove it first.
* The removal shifts the array elements.
* Adjust the target index before the replace operation.
* This matches the Cocoa KVC mutation semantics.
*/
var oldParent = aTreeNode._parentNode;
if (oldParent === self)
{
var replacementIndex = [_childNodes indexOfObjectIdenticalTo:aTreeNode];
/*
* aTreeNode.parentNode already equals self at this point. If
* self._childNodes does not actually contain aTreeNode, the
* parent/child relationship is already broken. indexPath raises
* for this identical class of inconsistency; proceeding here would
* silently tolerate the same problem instead of surfacing it.
*/
if (replacementIndex === CPNotFound)
{
[CPException raise:CPInternalInconsistencyException
reason:"CPTreeNode parent and child relationship is inconsistent."];
}
/*
* Bypass KVO for this internal structural adjustment.
*/
aTreeNode._parentNode = nil;
[_childNodes removeObjectAtIndex:replacementIndex];
/*
* Unlike insertObject:inChildNodesAtIndex:, anIndex here cannot be
* treated as a plain final-array position: replace requires an
* existing slot, it cannot append past the end. The removal above
* already took a slot out of the array ahead of the target
* whenever the replacement's original position was before it.
* Shift anIndex down by one in that case, to keep it pointing at
* the same physical slot the caller named.
*/
if (replacementIndex < anIndex)
--anIndex;
}
else if (oldParent)
{
/*
* Detach the node from its old parent.
* Use the internal method to bypass KVO overhead.
*/
[oldParent _removeChildNode:aTreeNode];
}
oldTreeNode._parentNode = nil;
aTreeNode._parentNode = self;
[_childNodes replaceObjectAtIndex:anIndex withObject:aTreeNode];
}
- (id)objectInChildNodesAtIndex:(CPInteger)anIndex
{
return [_childNodes objectAtIndex:anIndex];
}
- (CPInteger)countOfChildNodes
{
return [_childNodes count];
}
- (void)sortWithSortDescriptors:(CPArray)sortDescriptors recursively:(BOOL)shouldSortRecursively
{
if (!shouldSortRecursively)
{
[_childNodes sortUsingDescriptors:sortDescriptors];
return;
}
/*
* Use an explicit stack, not recursion.
* The recursive form is not a tail call.
* The sibling loop continues after each child call returns.
* Only JavaScriptCore performs tail call optimization.
* The explicit stack prevents stack overflow on deep trees.
*/
var stack = [];
[stack addObject:self];
while ([stack count])
{
var node = [stack lastObject];
[stack removeLastObject];
[node._childNodes sortUsingDescriptors:sortDescriptors];
var count = [node._childNodes count];
while (count--)
{
[stack addObject:[node._childNodes objectAtIndex:count]];
}
}
}
- (CPTreeNode)descendantNodeAtIndexPath:(CPIndexPath)indexPath
{
if (!indexPath || [indexPath length] == 0)
return self;
var node = self,
length = [indexPath length];
for (var i = 0; i < length; i++)
{
var index = [indexPath indexAtPosition:i],
count = [node countOfChildNodes];
if (index < 0 || index >= count)
return nil;
node = [node objectInChildNodesAtIndex:index];
}
return node;
}
@end
var CPTreeNodeRepresentedObjectKey = @"CPTreeNodeRepresentedObjectKey",
CPTreeNodeParentNodeKey = @"CPTreeNodeParentNodeKey",
CPTreeNodeChildNodesKey = @"CPTreeNodeChildNodesKey";
@implementation CPTreeNode (CPCoding)
- (id)initWithCoder:(CPCoder)aCoder
{
self = [super init];
if (self)
{
_representedObject = [aCoder decodeObjectForKey:CPTreeNodeRepresentedObjectKey];
_parentNode = [aCoder decodeObjectForKey:CPTreeNodeParentNodeKey];
_childNodes = [aCoder decodeObjectForKey:CPTreeNodeChildNodesKey];
if (!_childNodes)
_childNodes = [];
if (![_childNodes isKindOfClass:[CPMutableArray class]])
_childNodes = [_childNodes mutableCopy];
/*
* The child array is the authoritative structure.
* Re-establish the parent links.
* This makes the decoded tree match the tree built by the mutation methods.
*/
var count = [_childNodes count];
while (count--)
{
var child = [_childNodes objectAtIndex:count];
[self _validateChildNode:child];
child._parentNode = self;
}
}
return self;
}
- (void)encodeWithCoder:(CPCoder)aCoder
{
[aCoder encodeObject:_representedObject forKey:CPTreeNodeRepresentedObjectKey];
[aCoder encodeConditionalObject:_parentNode forKey:CPTreeNodeParentNodeKey];
[aCoder encodeObject:_childNodes forKey:CPTreeNodeChildNodesKey];
}
@end