diff --git a/Tests/AppKit/CPViewThemeChangeTest.j b/Tests/AppKit/CPViewThemeChangeTest.j new file mode 100644 index 000000000..5b139a2f8 --- /dev/null +++ b/Tests/AppKit/CPViewThemeChangeTest.j @@ -0,0 +1,327 @@ +/* + * CPViewThemeChangeTest.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 view resolves each theme attribute from its own values, then its theme, then its + class default. When any of these changes, the view must resolve the new value and + lay itself out again. These tests build small themes in code, so they depend on no + real theme. +*/ + +@implementation _CPThemeChangeView : CPView +{ + int layoutCount @accessors; + int themeChangeCount @accessors; + int displayRequestCount @accessors; +} + ++ (CPString)defaultThemeClass +{ + return @"theme-change-view"; +} + ++ (CPDictionary)themeAttributes +{ + return @{ + @"accent": @"class default", + @"margin": @"class default" + }; +} + +- (void)layoutSubviews +{ + layoutCount++; +} + +/* + needsDisplay is true only for a view with its own drawRect:, so the request is + recorded here. +*/ +- (void)setNeedsDisplay:(BOOL)aFlag +{ + if (aFlag) + displayRequestCount++; + + [super setNeedsDisplay:aFlag]; +} + +- (void)objectDidChangeTheme +{ + themeChangeCount++; + + [super objectDidChangeTheme]; +} + +@end + +@implementation CPViewThemeChangeTest : OJTestCase +{ + CPTheme firstTheme; + CPTheme secondTheme; + CPTheme defaultTheme; + _CPThemeChangeView view; +} + +- (void)setUp +{ + // This will init the global var CPApp which are used internally in the AppKit + [[CPApplication alloc] init]; + + defaultTheme = [CPTheme defaultTheme]; + firstTheme = [self themeNamed:@"theme-change-first" themeClass:nil accent:@"first"]; + secondTheme = [self themeNamed:@"theme-change-second" themeClass:nil accent:@"second"]; + view = [self newView]; + + [view setTheme:firstTheme]; + [_CPDisplayServer run]; + [view setLayoutCount:0]; + [view setThemeChangeCount:0]; +} + +- (void)tearDown +{ + [CPTheme setDefaultTheme:defaultTheme]; +} + +- (_CPThemeChangeView)newView +{ + return [[_CPThemeChangeView alloc] initWithFrame:CGRectMake(0.0, 0.0, 100.0, 100.0)]; +} + +- (CPTheme)themeNamed:(CPString)aName themeClass:(CPString)aThemeClass accent:(CPString)anAccent +{ + var theme = [[CPTheme alloc] initWithName:aName], + template = [self newView]; + + if (aThemeClass) + [template setThemeClass:aThemeClass]; + + [template setValue:anAccent forThemeAttribute:@"accent"]; + [theme takeThemeFromObject:template]; + + return theme; +} + +- (id)archived:(CPView)aView +{ + return [CPKeyedUnarchiver unarchiveObjectWithData:[CPKeyedArchiver archivedDataWithRootObject:aView]]; +} + +- (void)assertAccent:(CPString)aValue ofView:(CPView)aView +{ + [self assert:aValue equals:[aView currentValueForThemeAttribute:@"accent"]]; +} + +- (void)testAViewResolvesItsThemeValue +{ + [self assertAccent:@"first" ofView:view]; +} + +- (void)testAnAttributeTheThemeDoesNotDefineUsesTheClassDefault +{ + [self assert:@"class default" equals:[view currentValueForThemeAttribute:@"margin"]]; +} + +- (void)testAViewWithoutAThemeUsesTheClassDefault +{ + [view setTheme:nil]; + + [self assertAccent:@"class default" ofView:view]; +} + +- (void)testSettingAThemeChangesTheResolvedValue +{ + [view setTheme:secondTheme]; + + [self assertAccent:@"second" ofView:view]; +} + +- (void)testSettingAThemeNotifiesTheView +{ + [view setTheme:secondTheme]; + + [self assert:1 equals:[view themeChangeCount]]; +} + +- (void)testSettingTheSameThemeDoesNothing +{ + [view setTheme:firstTheme]; + + [self assert:0 equals:[view themeChangeCount]]; +} + +- (void)testSettingAThemeQueuesLayout +{ + [view setTheme:secondTheme]; + [_CPDisplayServer run]; + + [self assert:1 equals:[view layoutCount]]; +} + +- (void)testSettingAThemeRequestsDisplay +{ + [view setDisplayRequestCount:0]; + [view setTheme:secondTheme]; + + [self assertTrue:[view displayRequestCount] > 0]; +} + +- (void)testAThemeChangeReachesNestedViews +{ + var parent = [self newView], + child = [self newView], + grandchild = [self newView]; + + [parent addSubview:child]; + [child addSubview:grandchild]; + [parent _setThemeIncludingDescendants:secondTheme]; + + [self assertAccent:@"second" ofView:parent]; + [self assertAccent:@"second" ofView:child]; + [self assertAccent:@"second" ofView:grandchild]; +} + +- (void)testAPerViewValueTakesPriorityOverTheTheme +{ + [view setValue:@"own" forThemeAttribute:@"accent"]; + + [self assertAccent:@"own" ofView:view]; +} + +- (void)testAPerViewValueSurvivesAThemeChange +{ + [view setValue:@"own" forThemeAttribute:@"accent"]; + [view setTheme:secondTheme]; + + [self assertAccent:@"own" ofView:view]; +} + +- (void)testAPerViewValueQueuesLayout +{ + [view setValue:@"own" forThemeAttribute:@"accent"]; + [_CPDisplayServer run]; + + [self assert:1 equals:[view layoutCount]]; +} + +- (void)testSettingTheSamePerViewValueQueuesNoLayout +{ + [view setValue:@"first" forThemeAttribute:@"accent"]; + [_CPDisplayServer run]; + + [self assert:0 equals:[view layoutCount]]; +} + +- (void)testAStateSelectsItsValue +{ + [view setValue:@"disabled" forThemeAttribute:@"accent" inState:CPThemeStateDisabled]; + [self assertAccent:@"first" ofView:view]; + + [view setThemeState:CPThemeStateDisabled]; + [self assertAccent:@"disabled" ofView:view]; + + [view unsetThemeState:CPThemeStateDisabled]; + [self assertAccent:@"first" ofView:view]; +} + +- (void)testAStateChangeQueuesLayout +{ + [view setThemeState:CPThemeStateDisabled]; + [_CPDisplayServer run]; + + [self assert:1 equals:[view layoutCount]]; +} + +- (void)testSettingAThemeClassChangesTheResolvedValue +{ + var theme = [self themeNamed:@"theme-change-class" themeClass:@"other-class" accent:@"other class"]; + + [view setTheme:theme]; + [view setThemeClass:@"other-class"]; + + [self assertAccent:@"other class" ofView:view]; +} + +- (void)testSettingAThemeClassQueuesLayout +{ + [view setThemeClass:@"other-class"]; + [_CPDisplayServer run]; + + [self assert:1 equals:[view layoutCount]]; +} + +- (void)testANewViewUsesTheDefaultTheme +{ + [CPTheme setDefaultTheme:secondTheme]; + + [self assertAccent:@"second" ofView:[self newView]]; +} + +- (void)testChangingTheDefaultThemeLeavesAnExistingViewUnchanged +{ + [CPTheme setDefaultTheme:secondTheme]; + + [self assertAccent:@"first" ofView:view]; +} + +- (void)testADecodedViewUsesTheDefaultTheme +{ + [CPTheme setDefaultTheme:secondTheme]; + + [self assertAccent:@"second" ofView:[self archived:view]]; +} + +- (void)testADecodedViewKeepsAPerViewValue +{ + [view setValue:@"own" forThemeAttribute:@"accent"]; + + [self assertAccent:@"own" ofView:[self archived:view]]; +} + +- (void)testADecodedViewKeepsAPerViewStateValue +{ + [view setValue:@"disabled" forThemeAttribute:@"accent" inState:CPThemeStateDisabled]; + + var decoded = [self archived:view]; + + [decoded setThemeState:CPThemeStateDisabled]; + + [self assertAccent:@"disabled" ofView:decoded]; +} + +- (void)testADecodedViewKeepsItsThemeState +{ + [view setThemeState:CPThemeStateDisabled]; + + [self assertTrue:[[self archived:view] hasThemeState:CPThemeStateDisabled]]; +} + +- (void)testADecodedViewKeepsItsThemeClass +{ + [view setThemeClass:@"other-class"]; + + [self assert:@"other-class" equals:[[self archived:view] themeClass]]; +} + +@end