Skip to content

Commit 971a32c

Browse files
committed
Add CoordinatorProxyTests
1 parent 6720b50 commit 971a32c

2 files changed

Lines changed: 144 additions & 0 deletions

File tree

FloatingPanel.xcodeproj/project.pbxproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787

8888
/* Begin PBXFileSystemSynchronizedRootGroup section */
8989
5404FB662F3D70D600BCC99B /* UKit */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = UKit; sourceTree = "<group>"; };
90+
5404FB782F3D71C700BCC99B /* SwiftUI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SwiftUI; sourceTree = "<group>"; };
9091
/* End PBXFileSystemSynchronizedRootGroup section */
9192

9293
/* Begin PBXFrameworksBuildPhase section */
@@ -157,6 +158,7 @@
157158
545DB9CE2151169500CA77B8 /* Tests */ = {
158159
isa = PBXGroup;
159160
children = (
161+
5404FB782F3D71C700BCC99B /* SwiftUI */,
160162
5404FB662F3D70D600BCC99B /* UKit */,
161163
545DB9D12151169500CA77B8 /* Info.plist */,
162164
);
@@ -237,6 +239,7 @@
237239
);
238240
fileSystemSynchronizedGroups = (
239241
5404FB662F3D70D600BCC99B /* UKit */,
242+
5404FB782F3D71C700BCC99B /* SwiftUI */,
240243
);
241244
name = FloatingPanelTests;
242245
productName = FloatingModalControllerTests;
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright 2018 the FloatingPanel authors. All rights reserved. MIT license.
2+
3+
import SwiftUI
4+
import XCTest
5+
6+
@testable import FloatingPanel
7+
8+
@available(iOS 14, *)
9+
class CoordinatorProxyTests: XCTestCase {
10+
override func setUp() {}
11+
override func tearDown() {}
12+
13+
// MARK: - Test doubles
14+
15+
/// Records calls to `move(to:animated:completion:)` without performing real movement.
16+
final class SpyFloatingPanelController: FloatingPanelController {
17+
struct MoveCall {
18+
let state: FloatingPanelState
19+
let animated: Bool
20+
}
21+
fileprivate(set) var moveCalls: [MoveCall] = []
22+
23+
override func move(
24+
to state: FloatingPanelState,
25+
animated: Bool,
26+
completion: (() -> Void)? = nil
27+
) {
28+
moveCalls.append(MoveCall(state: state, animated: animated))
29+
super.move(to: state, animated: animated, completion: completion)
30+
}
31+
}
32+
33+
/// A minimal `FloatingPanelCoordinator` that allows injecting a custom controller.
34+
final class TestCoordinator: FloatingPanelCoordinator {
35+
typealias Event = Void
36+
let proxy: FloatingPanelProxy
37+
let action: (Event) -> Void
38+
39+
init(action: @escaping (Event) -> Void) {
40+
self.action = action
41+
self.proxy = FloatingPanelProxy(controller: FloatingPanelController())
42+
}
43+
44+
/// Designated initializer for tests — accepts a pre-made controller.
45+
init(controller: FloatingPanelController) {
46+
self.action = { _ in }
47+
self.proxy = FloatingPanelProxy(controller: controller)
48+
}
49+
50+
func setupFloatingPanel<Main: View, Content: View>(
51+
mainHostingController: UIHostingController<Main>,
52+
contentHostingController: UIHostingController<Content>
53+
) {
54+
contentHostingController.view.backgroundColor = .clear
55+
controller.set(contentViewController: contentHostingController)
56+
controller.addPanel(toParent: mainHostingController, animated: false)
57+
}
58+
59+
func onUpdate<Representable>(
60+
context: UIViewControllerRepresentableContext<Representable>
61+
) where Representable: UIViewControllerRepresentable {}
62+
}
63+
64+
// MARK: - Helpers
65+
66+
private func makeProxy(
67+
spy: SpyFloatingPanelController
68+
) -> FloatingPanelCoordinatorProxy {
69+
let coordinator = TestCoordinator(controller: spy)
70+
spy.showForTest()
71+
var state: FloatingPanelState? = spy.state
72+
let binding = Binding<FloatingPanelState?>(
73+
get: { state },
74+
set: { state = $0 }
75+
)
76+
return FloatingPanelCoordinatorProxy(
77+
coordinator: coordinator,
78+
state: binding
79+
)
80+
}
81+
}
82+
83+
// MARK: - Issue #680: update(state:) should skip move when state is unchanged
84+
85+
/// Tests for `FloatingPanelCoordinatorProxy.update(state:)` — the internal bridge between
86+
/// SwiftUI state bindings and `FloatingPanelController`.
87+
@available(iOS 14, *)
88+
extension CoordinatorProxyTests {
89+
/// During a drag gesture, a delegate callback can trigger a SwiftUI re-render which
90+
/// calls `update(state:)` with the current state. The fix ensures this redundant call
91+
/// does NOT invoke `controller.move(to:animated:)`, preserving the interactive transition.
92+
func test_updateState_skipsMove_whenStateIsUnchanged() {
93+
let spy = SpyFloatingPanelController()
94+
let proxy = makeProxy(spy: spy)
95+
XCTAssertEqual(spy.state, .half)
96+
97+
// Clear any move calls from setup
98+
spy.moveCalls.removeAll()
99+
100+
// update(state:) with the SAME state must not trigger move(to:)
101+
proxy.update(state: .half)
102+
103+
XCTAssertTrue(
104+
spy.moveCalls.isEmpty,
105+
"move(to:animated:) must not be called when the state is unchanged, "
106+
+ "but was called \(spy.moveCalls.count) time(s)"
107+
)
108+
}
109+
110+
func test_updateState_movesPanel_whenStateIsDifferent() {
111+
let spy = SpyFloatingPanelController()
112+
let proxy = makeProxy(spy: spy)
113+
XCTAssertEqual(spy.state, .half)
114+
115+
spy.moveCalls.removeAll()
116+
117+
proxy.update(state: .full)
118+
119+
XCTAssertEqual(
120+
spy.moveCalls.count, 1,
121+
"move(to:animated:) should be called exactly once"
122+
)
123+
XCTAssertEqual(spy.moveCalls.first?.state, .full)
124+
XCTAssertEqual(spy.moveCalls.first?.animated, false)
125+
}
126+
127+
func test_updateState_doesNothing_whenStateIsNil() {
128+
let spy = SpyFloatingPanelController()
129+
let proxy = makeProxy(spy: spy)
130+
XCTAssertEqual(spy.state, .half)
131+
132+
spy.moveCalls.removeAll()
133+
134+
proxy.update(state: nil)
135+
136+
XCTAssertTrue(
137+
spy.moveCalls.isEmpty,
138+
"move(to:animated:) must not be called when state is nil"
139+
)
140+
}
141+
}

0 commit comments

Comments
 (0)