From 34371c1e0f6dd45fd71da1fbfdea5da598c42a1a Mon Sep 17 00:00:00 2001 From: David Richardson Date: Sat, 31 Oct 2026 21:27:07 -0600 Subject: [PATCH] Tests: Add view theme state regression tests Protects themed drawing by verifying that the states selecting theme values remain correct as views enter and leave windows and as window status changes. * CPViewThemeStateTest: Verifies the key window, first responder and appearance states across nested views, including views added, removed and moved between windows; the key and main states of the window view; and the order of window move notifications. --- Tests/AppKit/CPViewThemeStateTest.j | 408 ++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 Tests/AppKit/CPViewThemeStateTest.j diff --git a/Tests/AppKit/CPViewThemeStateTest.j b/Tests/AppKit/CPViewThemeStateTest.j new file mode 100644 index 000000000..38028859d --- /dev/null +++ b/Tests/AppKit/CPViewThemeStateTest.j @@ -0,0 +1,408 @@ +/* + * CPViewThemeStateTest.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 + +/* + A theme value is chosen by the state of a view, so a view with the wrong state + draws wrongly with a correct theme. These tests check that the states that depend + on a view's surroundings stay correct as views enter and leave windows and as + windows change. The states are the key window state, the first responder state + and the appearance states. Only a window's own window view has the main window + state. + + These tests run with no theme loaded. They check the states, and not what a theme + does with them. +*/ + +@implementation _CPThemeStateRecordingView : CPView +{ + CPArray calls @accessors; + BOOL hadKeyStateOnArrival @accessors; +} + +- (id)initWithFrame:(CGRect)aFrame +{ + self = [super initWithFrame:aFrame]; + + if (self) + calls = []; + + return self; +} + +- (BOOL)acceptsFirstResponder +{ + return YES; +} + +- (void)viewWillMoveToWindow:(CPWindow)aWindow +{ + calls.push({ @"call": @"willMove", @"from": [self window], @"to": aWindow }); +} + +- (void)viewDidMoveToWindow +{ + hadKeyStateOnArrival = [self hasThemeState:CPThemeStateKeyWindow]; + calls.push({ @"call": @"didMove", @"window": [self window] }); +} + +@end + +@implementation CPViewThemeStateTest : OJTestCase +{ + CPWindow firstWindow; + CPWindow secondWindow; + _CPThemeStateRecordingView parentView; + _CPThemeStateRecordingView childView; +} + +- (void)setUp +{ + // This will init the global var CPApp which are used internally in the AppKit + [[CPApplication alloc] init]; + + firstWindow = [self newWindow]; + secondWindow = [self newWindow]; + + parentView = [[_CPThemeStateRecordingView alloc] initWithFrame:CGRectMake(0.0, 0.0, 100.0, 100.0)]; + childView = [[_CPThemeStateRecordingView alloc] initWithFrame:CGRectMake(0.0, 0.0, 50.0, 50.0)]; + + [parentView addSubview:childView]; +} + +- (CPWindow)newWindow +{ + return [[CPWindow alloc] initWithContentRect:CGRectMake(0.0, 0.0, 200.0, 200.0) styleMask:CPTitledWindowMask]; +} + +- (void)assertKeyState:(BOOL)shouldHaveState +{ + [self assert:shouldHaveState equals:[parentView hasThemeState:CPThemeStateKeyWindow] message:@"parent view"]; + [self assert:shouldHaveState equals:[childView hasThemeState:CPThemeStateKeyWindow] message:@"child view"]; +} + +- (void)assertFirstResponderState:(BOOL)shouldHaveState +{ + [self assert:shouldHaveState equals:[parentView hasThemeState:CPThemeStateFirstResponder] message:@"parent view"]; + [self assert:shouldHaveState equals:[childView hasThemeState:CPThemeStateFirstResponder] message:@"child view"]; +} + +- (void)clearCalls +{ + [parentView setCalls:[]]; + [childView setCalls:[]]; +} + +// MARK: Key window state + +- (void)testAViewOutsideAWindowHasNoKeyState +{ + [self assertKeyState:NO]; +} + +- (void)testBecomingKeyReachesNestedViews +{ + [[firstWindow contentView] addSubview:parentView]; + [self assertKeyState:NO]; + + [firstWindow makeKeyWindow]; + [self assertKeyState:YES]; +} + +- (void)testResigningKeyReachesNestedViews +{ + [[firstWindow contentView] addSubview:parentView]; + [firstWindow makeKeyWindow]; + [firstWindow resignKeyWindow]; + + [self assertKeyState:NO]; +} + +- (void)testAnotherWindowBecomingKeyClearsTheState +{ + [[firstWindow contentView] addSubview:parentView]; + [firstWindow makeKeyWindow]; + [secondWindow makeKeyWindow]; + + [self assertKeyState:NO]; +} + +- (void)testAViewAddedToAKeyWindowGainsTheState +{ + [firstWindow makeKeyWindow]; + [[firstWindow contentView] addSubview:parentView]; + + [self assertKeyState:YES]; +} + +- (void)testAViewAddedBelowAViewInAKeyWindowGainsTheState +{ + var lateView = [[CPView alloc] initWithFrame:CGRectMakeZero()]; + + [[firstWindow contentView] addSubview:parentView]; + [firstWindow makeKeyWindow]; + [childView addSubview:lateView]; + + [self assertTrue:[lateView hasThemeState:CPThemeStateKeyWindow]]; +} + +- (void)testAViewRemovedFromAKeyWindowLosesTheState +{ + [[firstWindow contentView] addSubview:parentView]; + [firstWindow makeKeyWindow]; + [parentView removeFromSuperview]; + + [self assertKeyState:NO]; +} + +- (void)testMovingToANonKeyWindowClearsTheState +{ + [[firstWindow contentView] addSubview:parentView]; + [firstWindow makeKeyWindow]; + [[secondWindow contentView] addSubview:parentView]; + + [self assertKeyState:NO]; +} + +- (void)testMovingToAKeyWindowSetsTheState +{ + [[firstWindow contentView] addSubview:parentView]; + [secondWindow makeKeyWindow]; + [[secondWindow contentView] addSubview:parentView]; + + [self assertKeyState:YES]; +} + +- (void)testANewContentViewGainsTheState +{ + [firstWindow makeKeyWindow]; + [firstWindow setContentView:parentView]; + + [self assertKeyState:YES]; +} + +- (void)testTheStateIsCorrectInViewDidMoveToWindow +{ + [firstWindow makeKeyWindow]; + [[firstWindow contentView] addSubview:parentView]; + + [self assertTrue:[parentView hadKeyStateOnArrival]]; + [self assertTrue:[childView hadKeyStateOnArrival]]; +} + +// MARK: Window view + +/* + A window must be visible to become main, and ordering a window front needs a DOM. + So this test sends becomeMainWindow directly, and it does not cover makeMainWindow. +*/ +- (void)testTheWindowViewFollowsKeyAndMainStatus +{ + var windowView = [firstWindow _windowView]; + + [firstWindow makeKeyWindow]; + [firstWindow becomeMainWindow]; + + [self assertTrue:[windowView hasThemeState:CPThemeStateKeyWindow]]; + [self assertTrue:[windowView hasThemeState:CPThemeStateMainWindow]]; + + [firstWindow resignKeyWindow]; + [firstWindow resignMainWindow]; + + [self assertFalse:[windowView hasThemeState:CPThemeStateKeyWindow]]; + [self assertFalse:[windowView hasThemeState:CPThemeStateMainWindow]]; +} + +// MARK: First responder state + +- (void)testBecomingFirstResponderReachesNestedViews +{ + [[firstWindow contentView] addSubview:parentView]; + [firstWindow makeFirstResponder:parentView]; + + [self assertFirstResponderState:YES]; +} + +- (void)testResigningFirstResponderReachesNestedViews +{ + [[firstWindow contentView] addSubview:parentView]; + [firstWindow makeFirstResponder:parentView]; + [firstWindow makeFirstResponder:nil]; + + [self assertFirstResponderState:NO]; +} + +- (void)testAViewAddedBelowTheFirstResponderGainsTheState +{ + var lateView = [[CPView alloc] initWithFrame:CGRectMakeZero()]; + + [[firstWindow contentView] addSubview:parentView]; + [firstWindow makeFirstResponder:parentView]; + [childView addSubview:lateView]; + + [self assertTrue:[lateView hasThemeState:CPThemeStateFirstResponder]]; +} + +- (void)testRemovingTheFirstResponderClearsTheState +{ + [[firstWindow contentView] addSubview:parentView]; + [firstWindow makeFirstResponder:parentView]; + [parentView removeFromSuperview]; + + [self assertFirstResponderState:NO]; + [self assertFalse:[firstWindow firstResponder] === parentView]; +} + +// MARK: Window notifications + +- (void)testEnteringAWindowNotifiesEachViewInOrder +{ + [[firstWindow contentView] addSubview:parentView]; + + [parentView, childView].forEach(function(aView) + { + var calls = [aView calls]; + + [self assert:2 equals:calls.length]; + [self assert:@"willMove" equals:calls[0].call]; + [self assert:firstWindow equals:calls[0].to]; + [self assertNull:calls[0].from]; + [self assert:@"didMove" equals:calls[1].call]; + [self assert:firstWindow equals:calls[1].window]; + }); +} + +- (void)testLeavingAWindowNotifiesEachView +{ + [[firstWindow contentView] addSubview:parentView]; + [self clearCalls]; + [parentView removeFromSuperview]; + + [parentView, childView].forEach(function(aView) + { + var calls = [aView calls]; + + [self assert:2 equals:calls.length]; + [self assertNull:calls[0].to]; + [self assert:firstWindow equals:calls[0].from]; + [self assertNull:calls[1].window]; + }); +} + +- (void)testMovingBetweenWindowsNotifiesEachView +{ + [[firstWindow contentView] addSubview:parentView]; + [self clearCalls]; + [[secondWindow contentView] addSubview:parentView]; + + [parentView, childView].forEach(function(aView) + { + var calls = [aView calls]; + + [self assert:2 equals:calls.length]; + [self assert:firstWindow equals:calls[0].from]; + [self assert:secondWindow equals:calls[0].to]; + [self assert:secondWindow equals:calls[1].window]; + }); +} + +/* + Cocoa also sends these notifications when a view stays in its window and only its + position in the view hierarchy changes. +*/ +- (void)testMovingWithinAWindowNotifiesEachView +{ + var otherView = [[CPView alloc] initWithFrame:CGRectMakeZero()]; + + [[firstWindow contentView] addSubview:parentView]; + [[firstWindow contentView] addSubview:otherView]; + [self clearCalls]; + [otherView addSubview:parentView]; + + [parentView, childView].forEach(function(aView) + { + var calls = [aView calls]; + + [self assert:2 equals:calls.length]; + [self assert:@"willMove" equals:calls[0].call]; + [self assert:firstWindow equals:calls[0].to]; + [self assert:@"didMove" equals:calls[1].call]; + [self assert:firstWindow equals:calls[1].window]; + }); +} + +// MARK: Appearance states + +- (void)testEffectiveAppearanceComesFromTheNearestAncestor +{ + var appearance = [CPAppearance appearanceNamed:CPAppearanceNameVibrantDark]; + + [parentView setAppearance:appearance]; + + [self assert:appearance equals:[childView effectiveAppearance]]; +} + +- (void)testOwnAppearanceOverridesAnAncestor +{ + var appearance = [CPAppearance appearanceNamed:CPAppearanceNameVibrantLight]; + + [parentView setAppearance:[CPAppearance appearanceNamed:CPAppearanceNameVibrantDark]]; + [childView setAppearance:appearance]; + + [self assert:appearance equals:[childView effectiveAppearance]]; +} + +- (void)testAppearanceStateReachesNestedViews +{ + [parentView setAppearance:[CPAppearance appearanceNamed:CPAppearanceNameVibrantDark]]; + [_CPDisplayServer run]; + + [self assertTrue:[parentView hasThemeState:CPThemeStateAppearanceVibrantDark]]; + [self assertTrue:[childView hasThemeState:CPThemeStateAppearanceVibrantDark]]; +} + +- (void)testChangingAppearanceReplacesTheState +{ + [parentView setAppearance:[CPAppearance appearanceNamed:CPAppearanceNameVibrantDark]]; + [_CPDisplayServer run]; + [parentView setAppearance:[CPAppearance appearanceNamed:CPAppearanceNameVibrantLight]]; + [_CPDisplayServer run]; + + [self assertFalse:[childView hasThemeState:CPThemeStateAppearanceVibrantDark]]; + [self assertTrue:[childView hasThemeState:CPThemeStateAppearanceVibrantLight]]; +} + +- (void)testAViewAddedBelowAnAppearanceGainsItsState +{ + var lateView = [[CPView alloc] initWithFrame:CGRectMakeZero()]; + + [parentView setAppearance:[CPAppearance appearanceNamed:CPAppearanceNameVibrantDark]]; + [_CPDisplayServer run]; + [childView addSubview:lateView]; + [lateView setNeedsLayout]; + [_CPDisplayServer run]; + + [self assertTrue:[lateView hasThemeState:CPThemeStateAppearanceVibrantDark]]; +} + +@end