Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions Examples/SignUpDemo/Sources/Onboarding/OnboardingCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,36 @@ public enum OnboardingNavigationStep: Sendable {
/// Owns the SignUp scene. Currently the entire onboarding is one screen, but
/// keeping it behind a coordinator means new steps (e.g. terms-of-service,
/// email verification) can slot in here without touching `AppCoordinator`.
///
/// ## Unit-testing the routing closure
///
/// The trailing closure passed to `push(...)` below is captured by NVC's
/// internal Combine subscription. Tests that want to assert "when the user
/// finishes signing up, `OnboardingCoordinator` bubbles `.finishedOnboarding`
/// up to its parent" would otherwise have to drive the full view → view-model
/// → navigator → Combine pipeline through UIKit.
///
/// NVC exposes the same closure on the scene controller as
/// ``NanoViewController/navigationHandler`` under `@_spi(Testing)`, so tests
/// can drive routing directly:
///
/// ```swift
/// @_spi(Testing) import NanoViewControllerController
///
/// func test_signUpFinish_bubblesFinishedOnboarding() {
/// let nav = UINavigationController()
/// let coordinator = OnboardingCoordinator(navigationController: nav, service: StubService())
/// var bubbledStep: OnboardingNavigationStep?
/// coordinator.navigator.navigation.sink { bubbledStep = $0 }.store(in: &cancellables)
///
/// coordinator.start()
/// let scene = nav.viewControllers.first as! SignUpScene
/// scene.navigationHandler?(.signedUp(SignedUpUser(id: "u1", name: "Test", email: "t@x")))
/// pumpMainRunLoop()
///
/// guard case .finishedOnboarding = bubbledStep else { return XCTFail("...") }
/// }
/// ```
public final class OnboardingCoordinator: BaseCoordinator<OnboardingNavigationStep> {
private let service: SignUpServicing

Expand Down
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,64 @@ The package ships six independent SPM library targets so consumers can pick exac

`Combine`, `Navigation`, `Controller`, `SceneViews`, `DIPrimitives` all depend on `Core`. UIKit modules (`Controller`, `SceneViews`, `DIPrimitives`) need iOS 26+; pure value-type modules build on macOS 14+ too. Every target compiles in Swift 6.2 language mode (`swift-tools-version: 6.2`) so the package's `@MainActor`-on-UIKit annotations are enforced as hard errors at the consumer's call site.

# Testing your coordinators

Coordinators in NVC register routing logic as a trailing closure to `push(...)` / `modallyPresent(...)`:

```swift
push(scene: PrepareScene.self, viewModel: vm) { [weak self] step in
guard let self else { return }
switch step {
case .cancel: finish()
case let .submit(payment): toReviewPayment(payment)
}
}
```

That closure is invoked through a Combine subscription on the scene's `navigation` publisher — driving it from a unit test would normally mean taking the long way around: a ViewModel-level test that drives the view's input subjects through real UIKit (`tap`, `setText`, `drainRunLoop`) so the ViewModel's `transform` finally emits the step, the subscription fires, and the closure runs.

NVC exposes the closure directly on each `NanoViewController` instance under an `@_spi(Testing)` seam so unit tests can short-circuit the pipeline:

```swift
@_spi(Testing) import NanoViewControllerController

func test_submitPayment_pushesReviewTransaction() throws {
// Arrange
let coordinator = SendCoordinator(navigationController: nav, deeplinks: Empty().eraseToAnyPublisher())
coordinator.start()
let prepare = try XCTUnwrap(nav.viewControllers.first as? PrepareScene)

// Act
prepare.navigationHandler?(.submit(payment))

// Assert
XCTAssertTrue(nav.viewControllers.last is ReviewScene)
}
```

Two hooks ship on every `NanoViewController<View>`:

| Hook | Set by | Closure shape |
|---|---|---|
| `scene.navigationHandler` | `push(...)` / `pushSceneInstance(...)` | `(NavigationStep) -> Void` |
| `scene.modalNavigationHandler` | `modallyPresent(...)` / `replaceAllScenes(...)` | `(NavigationStep, DismissScene) -> Void` |

The modal variant accepts a spy `DismissScene` so tests can observe both the routing and the dismissal:

```swift
var dismissCalled = false
let spy: DismissScene = { _, completion in
dismissCalled = true
completion?()
}
scan.modalNavigationHandler?(.scanned(intent), spy)
XCTAssertTrue(dismissCalled)
```

**Trade-off.** Driving the SPI handler directly does not assert that the ViewModel's emitted step actually reaches the coordinator's subscription — only that the handler routes correctly once invoked. The Combine wiring is identical across every `push(...)` / `modallyPresent(...)` call, so a single happy-path UI-driven test (or simply observing that the scene appears on the stack after `start()`) is enough to cover it. Use the SPI for the per-step routing assertions; rely on ViewModel-level tests for the emission contracts.

**Visibility.** The two hooks are `@_spi(Testing) public internal(set)` — production callers don't see them unless they opt in with `@_spi(Testing) import NanoViewControllerController`. Only NVC writes through them; consumers can only read.

# Local development

First-time setup on a fresh clone:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@ extension Coordinating {
///
/// Used by the push-style hookup in
/// ``Coordinating/pushSceneInstance(_:animated:navigationPresentationCompletion:navigationHandler:)``.
///
/// Side effect: stashes `handler` on
/// ``NanoViewController/navigationHandler`` (an `@_spi(Testing)` hook) so
/// tests can invoke coordinator routing directly without driving the
/// view-model's Combine pipeline. Production callers don't see this
/// property unless they opt in via `@_spi(Testing) import`.
func subscribeToNavigation<S: NanoViewController<V>, V: ContentView>(
of scene: S,
handler: @escaping (V.ViewModel.NavigationStep) -> Void
) {
scene.navigationHandler = handler
scene.navigation
.sinkOnMain { handler($0) }
.store(in: &cancellables)
Expand All @@ -25,10 +32,16 @@ extension Coordinating {
/// the controller with optional animation. Used by the modal-style hookups in
/// ``Coordinating/modallyPresent(scene:animated:presentationCompletion:navigationHandler:)``
/// and ``Coordinating/replaceAllScenes(with:animated:whenReplacingFinished:navigationHandler:)``.
///
/// Side effect: stashes `handler` on
/// ``NanoViewController/modalNavigationHandler`` (an `@_spi(Testing)` hook).
/// Tests pass a spy ``DismissScene`` when invoking the handler so the
/// dismissal side-effect is observable without a real modal presentation.
func subscribeToModalNavigation<S: NanoViewController<V>, V: ContentView>(
of scene: S,
handler: @escaping ModalNavigationHandler<V.ViewModel>
) {
scene.modalNavigationHandler = handler
scene.navigation
.sinkOnMain { [weak scene] step in
handler(step) { animated, completion in
Expand Down
106 changes: 106 additions & 0 deletions Sources/NanoViewControllerController/NanoViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import Combine
import NanoViewControllerCore
import NanoViewControllerDIPrimitives
import NanoViewControllerNavigation
import UIKit

/// The "Single-Line Controller" base class — generic scene glue that hosts
Expand Down Expand Up @@ -99,6 +100,111 @@ open class NanoViewController<View: ContentView>: UIViewController {
public lazy var navigation: AnyPublisher<ViewModel.NavigationStep, Never> =
navigationSubject.eraseToAnyPublisher()

/// Test-only handle to the navigation handler the coordinator registered
/// for this scene when it was pushed onto the navigation stack via
/// ``Coordinating/push(scene:viewModel:animated:navigationPresentationCompletion:navigationHandler:)``
/// or
/// ``Coordinating/pushSceneInstance(_:animated:navigationPresentationCompletion:navigationHandler:)``.
///
/// ## Why this exists
///
/// Coordinators register routing logic inline at the call site:
///
/// ```swift
/// push(scene: PrepareScene.self, viewModel: vm) { [weak self] step in
/// guard let self else { return }
/// switch step {
/// case .cancel: finish()
/// case let .submit(payment): toReviewPayment(payment)
/// }
/// }
/// ```
///
/// That handler is normally only invoked through the Combine pipeline:
/// the ViewModel emits a `NavigationStep` from `transform`, NVC forwards
/// it through ``navigation``, and the coordinator's `sinkOnMain`
/// subscription dispatches it. Unit tests that just want to assert
/// **"when `.submit(payment)` happens, `ReviewPayment` gets pushed"**
/// would otherwise have to drive the full view → view-model → Combine
/// chain via UIKit (taps, text entry, runloop drains) to reach that
/// switch statement.
///
/// `navigationHandler` exposes the same closure that the Combine sink is
/// holding, so tests can synthesize the step directly:
///
/// ```swift
/// @_spi(Testing) import NanoViewControllerController
///
/// func test_submitPayment_pushesReview() {
/// coordinator.start()
/// let prepare = navigationController.viewControllers.first as! PrepareScene
///
/// prepare.navigationHandler?(.submit(payment))
///
/// XCTAssertTrue(navigationController.viewControllers.last is ReviewScene)
/// }
/// ```
///
/// ## Trade-off
///
/// The test no longer verifies that the ViewModel's emitted step actually
/// reaches the coordinator's subscription — only that the handler routes
/// correctly once invoked. The wiring is identical across every `push`
/// call site, so one happy-path test that drives a real Combine emission
/// is usually enough to cover the subscription itself. Use this seam for
/// the routing-logic tests; rely on VM-level tests for the emission
/// contracts and a UI smoke test for the pipeline.
///
/// ## Visibility
///
/// Hidden behind `@_spi(Testing)` — production callers see this property
/// as `internal` unless they opt in via `@_spi(Testing) import`. NVC and
/// its tests can write through `internal(set)`; consumers can only read.
///
/// Nil when the scene was presented modally (see ``modalNavigationHandler``)
/// or hasn't been pushed yet.
@_spi(Testing)
public internal(set) var navigationHandler: ((ViewModel.NavigationStep) -> Void)?

/// Test-only handle to the navigation handler the coordinator registered
/// for this scene when it was presented modally via
/// ``Coordinating/modallyPresent(scene:animated:presentationCompletion:navigationHandler:)``
/// (or one of the modal-style overloads such as
/// ``Coordinating/replaceAllScenes(with:animated:whenReplacingFinished:navigationHandler:)``).
///
/// Same rationale as ``navigationHandler``, but the modal variant's
/// handler signature carries an extra ``DismissScene`` parameter so the
/// coordinator can dismiss the modal from inside the routing closure:
///
/// ```swift
/// modallyPresent(scene: ScanQRCode.self, viewModel: vm) { [weak self] step, dismiss in
/// switch step {
/// case let .scanned(intent):
/// dismiss(true) { self?.parent.forward(intent) }
/// case .cancel:
/// dismiss(true, nil)
/// }
/// }
/// ```
///
/// In tests, pass a spy ``DismissScene`` to observe both the routing
/// and the dismissal:
///
/// ```swift
/// var dismissCalled = false
/// let dismiss: DismissScene = { _, completion in
/// dismissCalled = true
/// completion?()
/// }
/// scan.modalNavigationHandler?(.scanned(intent), dismiss)
/// XCTAssertTrue(dismissCalled)
/// ```
///
/// Nil when the scene was pushed (see ``navigationHandler``) or hasn't
/// been presented yet.
@_spi(Testing)
public internal(set) var modalNavigationHandler: ((ViewModel.NavigationStep, @escaping DismissScene) -> Void)?

/// Clock used to auto-dismiss toasts emitted via
/// ``InputFromController/toastSubject``.
///
Expand Down
Loading
Loading