From fcf94a5df26e31590069de8df1484ee5df30bfb7 Mon Sep 17 00:00:00 2001 From: David Richardson Date: Sat, 31 Oct 2026 16:42:22 -0600 Subject: [PATCH] Tests: Add CPOutlineView row content regression tests Protects custom row rendering by verifying that content remains synchronized and current through standard operational cycles. * CPOutlineViewRowContentTest: Verifies that selection changes, scrolling, column resizing and hiding, item expansion and collapse, data reloads, and grid or row style modifications trigger complete redraws for visible rows; ensures row content remains unoccluded and idle tables do not execute redundant redrawing. --- Tests/AppKit/CPOutlineViewRowContentTest.j | 400 +++++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 Tests/AppKit/CPOutlineViewRowContentTest.j diff --git a/Tests/AppKit/CPOutlineViewRowContentTest.j b/Tests/AppKit/CPOutlineViewRowContentTest.j new file mode 100644 index 000000000..ebf3939f0 --- /dev/null +++ b/Tests/AppKit/CPOutlineViewRowContentTest.j @@ -0,0 +1,400 @@ +/* + * CPOutlineViewRowContentTest.j + * AppKit + * + * Created by David Richardson on 31/10/2026. + * Copyright David Richardson 2026. + * + * 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 + +/* + Applications draw their own content on table and outline rows. A source list + draws a count badge beside an item, for example, as SmartFoldersDemo does in its + BadgedOutlineView. That content is correct only if the table keeps redrawing it + as a person uses the table. + + These tests take such a view through ordinary use: selecting, scrolling, resizing + a column, expanding an item, reloading. After each action, the table must ask for + its content again, and only for the rows now on screen. + + The tests observe what a subclass observes. They do not depend on which view + performs the table's drawing. +*/ +@implementation RowContentItem : CPObject +{ + CPString title @accessors; + CPArray children @accessors; +} + ++ (id)itemWithTitle:(CPString)aTitle children:(CPArray)someChildren +{ + var item = [[self alloc] init]; + + [item setTitle:aTitle]; + [item setChildren:someChildren]; + + return item; +} + +@end + +/* + Records what it is asked to draw, in place of drawing. The default implementations + need a canvas, which a unit test does not have. +*/ +@implementation RowContentOutlineView : CPOutlineView +{ + CPArray drawnSteps @accessors; + CPArray drawnRows @accessors; + CPArray drawnClipRects @accessors; +} + +- (void)drawBackgroundInClipRect:(CGRect)aRect +{ + [drawnSteps addObject:@"background"]; +} + +- (void)highlightSelectionInClipRect:(CGRect)aRect +{ + [drawnSteps addObject:@"selection"]; +} + +- (void)drawGridInClipRect:(CGRect)aRect +{ + [drawnSteps addObject:@"grid"]; +} + +- (void)drawRow:(CPInteger)aRow clipRect:(CGRect)aRect +{ + [drawnRows addObject:aRow]; + [drawnClipRects addObject:aRect]; +} + +@end + +@implementation CPOutlineViewRowContentTest : OJTestCase +{ + CPWindow theWindow; + CPScrollView scrollView; + RowContentOutlineView outlineView; + CPTableColumn firstColumn; + CPTableColumn secondColumn; + CPArray rootItems; + CPView drawingView; +} + +- (void)setUp +{ + // This will init the global var CPApp which are used internally in the AppKit + [[CPApplication alloc] init]; + + theWindow = [[CPWindow alloc] initWithContentRect:CGRectMake(0.0, 0.0, 400.0, 400.0) styleMask:CPWindowNotSizable]; + + rootItems = []; + + for (var index = 0; index < 40; index++) + { + var children = [[RowContentItem itemWithTitle:@"Child A" children:[]], + [RowContentItem itemWithTitle:@"Child B" children:[]]]; + + rootItems.push([RowContentItem itemWithTitle:@"Item" children:children]); + } + + firstColumn = [[CPTableColumn alloc] initWithIdentifier:@"First"]; + secondColumn = [[CPTableColumn alloc] initWithIdentifier:@"Second"]; + + outlineView = [[RowContentOutlineView alloc] initWithFrame:CGRectMake(0.0, 0.0, 300.0, 200.0)]; + [outlineView setDrawnSteps:[]]; + [outlineView setDrawnRows:[]]; + [outlineView setDrawnClipRects:[]]; + [outlineView addTableColumn:firstColumn]; + [outlineView addTableColumn:secondColumn]; + [outlineView setOutlineTableColumn:firstColumn]; + + scrollView = [[CPScrollView alloc] initWithFrame:CGRectMake(0.0, 0.0, 300.0, 200.0)]; + [scrollView setDocumentView:outlineView]; + [[theWindow contentView] addSubview:scrollView]; + + [outlineView setDataSource:self]; + [outlineView reloadData]; + + [self settle]; +} + +- (id)outlineView:(CPOutlineView)anOutlineView child:(CPInteger)anIndex ofItem:(id)anItem +{ + return anItem ? [anItem children][anIndex] : rootItems[anIndex]; +} + +- (BOOL)outlineView:(CPOutlineView)anOutlineView isItemExpandable:(id)anItem +{ + return [[anItem children] count] > 0; +} + +- (int)outlineView:(CPOutlineView)anOutlineView numberOfChildrenOfItem:(id)anItem +{ + return anItem ? [[anItem children] count] : rootItems.length; +} + +- (id)outlineView:(CPOutlineView)anOutlineView objectValueForTableColumn:(CPTableColumn)aColumn byItem:(id)anItem +{ + return [anItem title]; +} + +/* + The views that make up the table: the outline view and its subviews. Controls + are left out. Data views and disclosure buttons draw themselves, and they need + a canvas to do so. +*/ +- (CPArray)tableViews +{ + return [outlineView].concat([outlineView subviews].filter(function(aView) + { + return ![aView isKindOfClass:[CPControl class]]; + })); +} + +/* + Brings the table to rest, as the end of a display cycle does: the layout is + current, nothing is waiting to draw, and nothing has been recorded. +*/ +- (void)settle +{ + [outlineView layoutIfNeeded]; + [[self tableViews] makeObjectsPerformSelector:@selector(displayIfNeeded)]; + + [[outlineView drawnSteps] removeAllObjects]; + [[outlineView drawnRows] removeAllObjects]; + [[outlineView drawnClipRects] removeAllObjects]; +} + +/* + CPView does not draw without a DOM, so the display cycle cannot reach drawRect: + in a unit test. This does its work: it lays the table out, then sends drawRect: + to each view that has been marked for display, and only to those. +*/ +- (void)redraw +{ + [outlineView layoutIfNeeded]; + + var graphicsPort = { path: NULL, gState: CGGStateCreate(), gStateStack: [] }; + + [CPGraphicsContext setCurrentContext:[CPGraphicsContext graphicsContextWithGraphicsPort:graphicsPort flipped:YES]]; + + drawingView = nil; + + [self tableViews].forEach(function(aView) + { + if (![aView needsDisplay]) + return; + + var rowCount = [[outlineView drawnRows] count]; + + [aView drawRect:[aView bounds]]; + + if ([[outlineView drawnRows] count] > rowCount) + drawingView = aView; + }); +} + +/* + A badge has to remain visible, including on a selected or alternating row. So the + view that draws the row content must stay in front of anything that paints a row. + Only controls may sit in front of it. Without a DOM a subview may not yet have + its colors, so this does not look at colors. It looks at what is in front. +*/ +- (void)assertNothingCoversRowContent:(CPString)anAction +{ + var subviews = [outlineView subviews], + firstCovering = drawingView === outlineView ? 0 : [subviews indexOfObject:drawingView] + 1; + + for (var index = firstCovering; index < [subviews count]; index++) + { + var aView = subviews[index]; + + if ([aView isKindOfClass:[CPControl class]] || [aView isHidden]) + continue; + + [self fail:anAction + ": " + [aView className] + " covers the row content"]; + } +} + +- (CPArray)rowsOnScreen +{ + var range = [outlineView rowsInRect:[outlineView exposedRect]], + rows = []; + + for (var row = range.location; row < CPMaxRange(range); row++) + rows.push(row); + + return rows; +} + +/* + The table has been redrawn through each of its drawing steps, for exactly the + rows on screen, and each row was asked to draw only within the visible part of + the table. +*/ +- (void)assertRowContentIsCurrentAfter:(CPString)anAction +{ + [self redraw]; + + var rows = [self rowsOnScreen], + exposedRect = [outlineView exposedRect]; + + [self assert:[@"background", @"selection", @"grid"] equals:[outlineView drawnSteps] message:anAction]; + [self assert:rows equals:[outlineView drawnRows] message:anAction]; + + [outlineView drawnClipRects].forEach(function(aRect) + { + [self assertTrue:CGRectContainsRect(exposedRect, aRect) message:anAction + ": clip rect is outside the visible area"]; + }); + + [self assertNothingCoversRowContent:anAction]; +} + +/* + Redrawing costs time, and a table can be large. A table with nothing to change + has nothing to draw. +*/ +- (void)testNothingIsRedrawnWithoutAChange +{ + [self redraw]; + + [self assert:[] equals:[outlineView drawnSteps]]; + [self assert:[] equals:[outlineView drawnRows]]; +} + +- (void)testContentIsDrawnWhenTheOutlineFirstAppears +{ + [outlineView setNeedsDisplay:YES]; + + [self assertRowContentIsCurrentAfter:@"first display"]; +} + +- (void)testContentIsRedrawnAfterSelectingARow +{ + [outlineView selectRowIndexes:[CPIndexSet indexSetWithIndex:2] byExtendingSelection:NO]; + + [self assertRowContentIsCurrentAfter:@"selecting a row"]; +} + +- (void)testContentIsRedrawnAfterChangingTheSelection +{ + [outlineView selectRowIndexes:[CPIndexSet indexSetWithIndex:2] byExtendingSelection:NO]; + [self settle]; + + [outlineView selectRowIndexes:[CPIndexSet indexSetWithIndex:4] byExtendingSelection:NO]; + + [self assertRowContentIsCurrentAfter:@"changing the selection"]; +} + +- (void)testContentIsRedrawnAfterClearingTheSelection +{ + [outlineView selectRowIndexes:[CPIndexSet indexSetWithIndex:2] byExtendingSelection:NO]; + [self settle]; + + [outlineView deselectAll:nil]; + + [self assertRowContentIsCurrentAfter:@"clearing the selection"]; +} + +- (void)testContentIsRedrawnAfterSelectingAColumn +{ + [outlineView selectColumnIndexes:[CPIndexSet indexSetWithIndex:1] byExtendingSelection:NO]; + + [self assertRowContentIsCurrentAfter:@"selecting a column"]; +} + +- (void)testContentIsRedrawnAfterChangingTheGridStyle +{ + [outlineView setGridStyleMask:CPTableViewSolidVerticalGridLineMask]; + + [self assertRowContentIsCurrentAfter:@"changing the grid style"]; +} + +- (void)testContentIsRedrawnAfterChangingTheGridColor +{ + [outlineView setGridColor:[CPColor redColor]]; + + [self assertRowContentIsCurrentAfter:@"changing the grid color"]; +} + +- (void)testContentIsRedrawnAfterChangingTheRowColors +{ + [outlineView setAlternatingRowBackgroundColors:[[CPColor redColor], [CPColor blueColor]]]; + + [self assertRowContentIsCurrentAfter:@"changing the row colors"]; +} + +- (void)testContentIsRedrawnAfterChangingTheHighlightStyle +{ + [outlineView setSelectionHighlightStyle:CPTableViewSelectionHighlightStyleNone]; + + [self assertRowContentIsCurrentAfter:@"changing the highlight style"]; +} + +- (void)testContentIsRedrawnAfterReloadingTheData +{ + [outlineView reloadData]; + + [self assertRowContentIsCurrentAfter:@"reloading the data"]; +} + +- (void)testContentIsRedrawnForTheNewRowsAfterScrolling +{ + var firstRows = [self rowsOnScreen]; + + [[scrollView contentView] scrollToPoint:CGPointMake(0.0, 300.0)]; + + [self assertFalse:[firstRows isEqual:[self rowsOnScreen]] message:@"scrolling must change the rows on screen"]; + [self assertRowContentIsCurrentAfter:@"scrolling"]; +} + +- (void)testContentIsRedrawnAfterResizingAColumn +{ + [secondColumn setWidth:40.0]; + + [self assertRowContentIsCurrentAfter:@"resizing a column"]; +} + +- (void)testContentIsRedrawnAfterHidingAColumn +{ + [secondColumn setHidden:YES]; + + [self assertRowContentIsCurrentAfter:@"hiding a column"]; +} + +- (void)testContentIsRedrawnAfterExpandingAnItem +{ + [outlineView expandItem:rootItems[1]]; + + [self assertRowContentIsCurrentAfter:@"expanding an item"]; +} + +- (void)testContentIsRedrawnAfterCollapsingAnItem +{ + [outlineView expandItem:rootItems[1]]; + [self settle]; + + [outlineView collapseItem:rootItems[1]]; + + [self assertRowContentIsCurrentAfter:@"collapsing an item"]; +} + +@end