Skip to content

Commit d79750b

Browse files
committed
[InnerShadowLayer] add tests
1 parent 1af6708 commit d79750b

2 files changed

Lines changed: 314 additions & 1 deletion

File tree

ComposeUI/Sources/ComposeUI/Components/InnerShadowLayer.swift

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ import QuartzCore
4141
/// A layer that renders an inner shadow.
4242
open class InnerShadowLayer: CALayer {
4343

44+
#if DEBUG
45+
private var supportsInvertsShadowOverride: Bool?
46+
#endif
47+
4448
private lazy var maskLayer = CAShapeLayer()
4549

4650
override init() {
@@ -71,6 +75,10 @@ open class InnerShadowLayer: CALayer {
7175
fatalError("expect the `layer` to be the same type during an animation.")
7276
}
7377

78+
#if DEBUG
79+
supportsInvertsShadowOverride = layer.supportsInvertsShadowOverride
80+
#endif
81+
7482
super.init(layer: layer)
7583
}
7684

@@ -115,7 +123,12 @@ open class InnerShadowLayer: CALayer {
115123
let clipPath = clipPath?(self) ?? holePath
116124

117125
let innerShadowPath: CGPath
118-
if self.supportsInvertsShadow {
126+
#if DEBUG
127+
let useInvertsShadow: Bool = self.supportsInvertsShadowOverride ?? self.supportsInvertsShadow
128+
#else
129+
let useInvertsShadow: Bool = self.supportsInvertsShadow
130+
#endif
131+
if useInvertsShadow {
119132
innerShadowPath = holePath
120133
self.disableActions {
121134
if !self.invertsShadow {
@@ -179,4 +192,31 @@ open class InnerShadowLayer: CALayer {
179192
biggerPath.append(shadowPath.reversing())
180193
return biggerPath.cgPath
181194
}
195+
196+
// MARK: - Testing
197+
198+
#if DEBUG
199+
200+
var test: Test { Test(host: self) }
201+
202+
class Test {
203+
204+
private let host: InnerShadowLayer
205+
206+
fileprivate init(host: InnerShadowLayer) {
207+
ComposeUI.assert(Thread.isRunningXCTest, "Test namespace should only be used in test target.")
208+
self.host = host
209+
}
210+
211+
var supportsInvertsShadowOverride: Bool? {
212+
get {
213+
host.supportsInvertsShadowOverride
214+
}
215+
set {
216+
host.supportsInvertsShadowOverride = newValue
217+
}
218+
}
219+
}
220+
221+
#endif
182222
}
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
//
2+
// InnerShadowLayerTests.swift
3+
// ComposéUI
4+
//
5+
// Created by Honghao Zhang on 5/25/26.
6+
// Copyright © 2024 Honghao Zhang.
7+
//
8+
// MIT License
9+
//
10+
// Copyright (c) 2024 Honghao Zhang (github.com/honghaoz)
11+
//
12+
// Permission is hereby granted, free of charge, to any person obtaining a copy
13+
// of this software and associated documentation files (the "Software"), to
14+
// deal in the Software without restriction, including without limitation the
15+
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
16+
// sell copies of the Software, and to permit persons to whom the Software is
17+
// furnished to do so, subject to the following conditions:
18+
//
19+
// The above copyright notice and this permission notice shall be included in
20+
// all copies or substantial portions of the Software.
21+
//
22+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
27+
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
28+
// IN THE SOFTWARE.
29+
//
30+
31+
#if canImport(AppKit)
32+
import AppKit
33+
#endif
34+
35+
#if canImport(UIKit)
36+
import UIKit
37+
#endif
38+
39+
import QuartzCore
40+
41+
import ChouTiTest
42+
43+
@testable import ComposeUI
44+
45+
final class InnerShadowLayerTests: XCTestCase {
46+
47+
// MARK: - init
48+
49+
func test_init_setsContentsScale() {
50+
let layer = InnerShadowLayer()
51+
52+
#if canImport(AppKit)
53+
expect(layer.contentsScale) == NSScreen.main?.backingScaleFactor ?? ComposeUI.Constants.defaultScaleFactor
54+
#endif
55+
56+
#if canImport(UIKit)
57+
#if os(visionOS)
58+
expect(layer.contentsScale) == ComposeUI.Constants.defaultScaleFactor
59+
expect(layer.wantsDynamicContentScaling) == true
60+
#else
61+
expect(layer.contentsScale) == UIScreen.main.scale
62+
#endif
63+
#endif
64+
}
65+
66+
// MARK: - init(layer:)
67+
68+
func test_initWithLayer_copiesCustomProperties() {
69+
// when the source has the override set, the copy carries it over
70+
do {
71+
let source = InnerShadowLayer()
72+
source.test.supportsInvertsShadowOverride = false
73+
74+
let copy = InnerShadowLayer(layer: source)
75+
expect(copy.test.supportsInvertsShadowOverride) == false
76+
}
77+
78+
// when the source has no override, the copy is also unset
79+
do {
80+
let source = InnerShadowLayer()
81+
let copy = InnerShadowLayer(layer: source)
82+
expect(copy.test.supportsInvertsShadowOverride) == nil
83+
}
84+
}
85+
86+
// MARK: - Default path (uses `invertsShadow` private API)
87+
88+
func test_update_default_noAnimation() throws {
89+
let layer = InnerShadowLayer()
90+
layer.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
91+
92+
let holePath = CGPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil)
93+
94+
layer.update(
95+
color: .red,
96+
opacity: 0.5,
97+
radius: 10,
98+
offset: CGSize(width: 2, height: 5),
99+
holePath: { _ in holePath },
100+
clipPath: nil,
101+
animationTiming: nil
102+
)
103+
104+
expect(layer.invertsShadow) == true
105+
expect(layer.shadowColor) == Color.red.cgColor
106+
expect(layer.shadowOpacity) == 0.5
107+
expect(layer.shadowRadius) == 10
108+
expect(layer.shadowOffset) == CGSize(width: 2, height: 5)
109+
expect(layer.shadowPath) == holePath
110+
111+
expect(layer.animation(forKey: "shadowColor")) == nil
112+
expect(layer.animation(forKey: "shadowOpacity")) == nil
113+
expect(layer.animation(forKey: "shadowRadius")) == nil
114+
expect(layer.animation(forKey: "shadowOffset")) == nil
115+
expect(layer.animation(forKey: "shadowPath")) == nil
116+
117+
let maskLayer = try (layer.mask as? CAShapeLayer).unwrap()
118+
expect(maskLayer.frame) == layer.bounds
119+
expect(maskLayer.path) == holePath
120+
}
121+
122+
func test_update_default_withAnimation() throws {
123+
ComposeUI.Assert.setTestAssertionFailureHandler(nil)
124+
defer { ComposeUI.Assert.resetTestAssertionFailureHandler() }
125+
126+
let layer = InnerShadowLayer()
127+
layer.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
128+
129+
let holePath = CGPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil)
130+
131+
layer.update(
132+
color: .red,
133+
opacity: 0.5,
134+
radius: 10,
135+
offset: CGSize(width: 2, height: 5),
136+
holePath: { _ in holePath },
137+
clipPath: nil,
138+
animationTiming: .easeInEaseOut()
139+
)
140+
141+
expect(layer.invertsShadow) == true
142+
expect(layer.shadowPath) == holePath
143+
144+
expect(layer.animation(forKey: "shadowColor")) != nil
145+
expect(layer.animation(forKey: "shadowOpacity")) != nil
146+
expect(layer.animation(forKey: "shadowRadius")) != nil
147+
expect(layer.animation(forKey: "shadowOffset")) != nil
148+
expect(layer.animation(forKey: "shadowPath")) != nil
149+
150+
let maskLayer = try (layer.mask as? CAShapeLayer).unwrap()
151+
expect(maskLayer.path) == holePath
152+
expect(maskLayer.animation(forKey: "path")) != nil
153+
}
154+
155+
// MARK: - Fallback path (manual punched bigger rect)
156+
157+
func test_update_fallback_noAnimation() throws {
158+
let layer = InnerShadowLayer()
159+
layer.test.supportsInvertsShadowOverride = false
160+
layer.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
161+
162+
let holePath = CGPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil)
163+
let radius: CGFloat = 10
164+
let offset = CGSize(width: 2, height: -5)
165+
166+
layer.update(
167+
color: .red,
168+
opacity: 0.5,
169+
radius: radius,
170+
offset: offset,
171+
holePath: { _ in holePath },
172+
clipPath: nil,
173+
animationTiming: nil
174+
)
175+
176+
// fallback never sets `invertsShadow`
177+
expect(layer.invertsShadow) == false
178+
179+
expect(layer.shadowColor) == Color.red.cgColor
180+
expect(layer.shadowOpacity) == 0.5
181+
expect(layer.shadowRadius) == radius
182+
expect(layer.shadowOffset) == offset
183+
184+
// shadowPath is the bigger rect punched by holePath; its bounding box equals the bigger rect
185+
let expectedHExtra = radius + abs(offset.width) + 20
186+
let expectedVExtra = radius + abs(offset.height) + 20
187+
let expectedBiggerBounds = holePath.boundingBoxOfPath.insetBy(dx: -expectedHExtra, dy: -expectedVExtra)
188+
let shadowPath = try layer.shadowPath.unwrap()
189+
expect(shadowPath.boundingBoxOfPath) == expectedBiggerBounds
190+
191+
// mask still clips to the clipPath (which defaults to holePath)
192+
let maskLayer = try (layer.mask as? CAShapeLayer).unwrap()
193+
expect(maskLayer.frame) == layer.bounds
194+
expect(maskLayer.path) == holePath
195+
196+
expect(layer.animation(forKey: "shadowPath")) == nil
197+
}
198+
199+
func test_update_fallback_withAnimation() throws {
200+
ComposeUI.Assert.setTestAssertionFailureHandler(nil)
201+
defer { ComposeUI.Assert.resetTestAssertionFailureHandler() }
202+
203+
let layer = InnerShadowLayer()
204+
layer.test.supportsInvertsShadowOverride = false
205+
layer.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
206+
207+
let holePath = CGPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil)
208+
let radius: CGFloat = 10
209+
let offset = CGSize(width: 2, height: 5)
210+
211+
layer.update(
212+
color: .red,
213+
opacity: 0.5,
214+
radius: radius,
215+
offset: offset,
216+
holePath: { _ in holePath },
217+
clipPath: nil,
218+
animationTiming: .easeInEaseOut()
219+
)
220+
221+
expect(layer.invertsShadow) == false
222+
223+
let expectedBiggerBounds = holePath.boundingBoxOfPath.insetBy(
224+
dx: -(radius + abs(offset.width) + 20),
225+
dy: -(radius + abs(offset.height) + 20)
226+
)
227+
let shadowPath = try layer.shadowPath.unwrap()
228+
expect(shadowPath.boundingBoxOfPath) == expectedBiggerBounds
229+
230+
expect(layer.animation(forKey: "shadowColor")) != nil
231+
expect(layer.animation(forKey: "shadowOpacity")) != nil
232+
expect(layer.animation(forKey: "shadowRadius")) != nil
233+
expect(layer.animation(forKey: "shadowOffset")) != nil
234+
expect(layer.animation(forKey: "shadowPath")) != nil
235+
236+
let maskLayer = try (layer.mask as? CAShapeLayer).unwrap()
237+
expect(maskLayer.path) == holePath
238+
expect(maskLayer.animation(forKey: "path")) != nil
239+
}
240+
241+
/// The fallback's bigger rect must be computed from `clipPath` (not `holePath`), so the
242+
/// "spread" case where the clip region is larger than the hole still fully contains the shadow.
243+
func test_update_fallback_withSpread() throws {
244+
let layer = InnerShadowLayer()
245+
layer.test.supportsInvertsShadowOverride = false
246+
layer.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
247+
248+
let holePath = CGPath(rect: CGRect(x: 30, y: 30, width: 40, height: 40), transform: nil)
249+
let clipPath = CGPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil)
250+
let radius: CGFloat = 10
251+
let offset = CGSize(width: 2, height: 5)
252+
253+
layer.update(
254+
color: .black,
255+
opacity: 0.5,
256+
radius: radius,
257+
offset: offset,
258+
holePath: { _ in holePath },
259+
clipPath: { _ in clipPath },
260+
animationTiming: nil
261+
)
262+
263+
let expectedBiggerBounds = clipPath.boundingBoxOfPath.insetBy(
264+
dx: -(radius + abs(offset.width) + 20),
265+
dy: -(radius + abs(offset.height) + 20)
266+
)
267+
let shadowPath = try layer.shadowPath.unwrap()
268+
expect(shadowPath.boundingBoxOfPath) == expectedBiggerBounds
269+
270+
let maskLayer = try (layer.mask as? CAShapeLayer).unwrap()
271+
expect(maskLayer.path) == clipPath
272+
}
273+
}

0 commit comments

Comments
 (0)