~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
282 lines · 11.7 KB Raw History
@testable import Grain
import SwiftUI
import UIKit
import XCTest

/// The app drops to UIKit for three gestures — drag-to-dismiss on a story,
/// long-press-to-reorder in the grid, and the photo strip's horizontal pan —
/// because SwiftUI's equivalents fight the surrounding scroll views. What makes
/// them coexist is entirely in their delegates, which is exactly the part a
/// render test never reaches.
@MainActor
final class GestureCoordinatorTests: GrainTestCase {
    /// Records callbacks a coordinator fires.
    private final class Calls {
        var phases: [String] = []
        var translations: [CGSize] = []
    }

    /// A view sitting in a real view-controller hierarchy, which is what the
    /// installers walk the responder chain to find.
    private func hostedView() -> (view: UIView, controller: UIViewController, window: UIWindow) {
        let controller = UIViewController()
        let anchor = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
        controller.view.addSubview(anchor)

        let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: 874))
        window.rootViewController = controller
        window.isHidden = false
        return (anchor, controller, window)
    }

    private func teardown(_ window: UIWindow) {
        window.isHidden = true
        window.rootViewController = nil
    }

    // MARK: - Drag to dismiss

    private func makeDismissCoordinator(
        handle: FadeDismissHandle,
        calls: Calls,
        onDismiss: @escaping () -> Void = {}
    ) -> DragToDismissInstaller.Coordinator {
        DragToDismissInstaller.Coordinator(
            handle: handle,
            onDismiss: { calls.phases.append("dismiss"); onDismiss() },
            onDragStart: { calls.phases.append("dragStart") },
            onDragCancel: { calls.phases.append("dragCancel") },
            onSwipeLeft: { calls.phases.append("swipeLeft") },
            onSwipeRight: { calls.phases.append("swipeRight") },
            onHorizontalDragStart: { calls.phases.append($0 ? "forward" : "back") },
            onSwipeDragging: { calls.translations.append(CGSize(width: $0, height: 0)) },
            onHorizontalDragCancel: { calls.phases.append("horizontalCancel") }
        )
    }

    /// The pan is installed on the hosting controller's view, not on the tiny
    /// anchor, so a drag anywhere on the story dismisses it.
    func testInstallingPutsThePanOnTheHostingControllersView() {
        let hosted = hostedView()
        defer { teardown(hosted.window) }
        let handle = FadeDismissHandle()
        let coordinator = makeDismissCoordinator(handle: handle, calls: Calls())
        coordinator.anchorView = hosted.view

        coordinator.installGestureIfNeeded()

        let installed = hosted.controller.view.gestureRecognizers ?? []
        XCTAssertTrue(installed.contains { $0 is UIPanGestureRecognizer })
        XCTAssertTrue(installed.contains { $0.delegate === coordinator })
    }

    /// `updateUIView` runs on every SwiftUI update, so installing has to be
    /// idempotent or the story ends up with a stack of pan recognizers.
    func testInstallingTwiceOnlyAddsOnePan() {
        let hosted = hostedView()
        defer { teardown(hosted.window) }
        let coordinator = makeDismissCoordinator(handle: FadeDismissHandle(), calls: Calls())
        coordinator.anchorView = hosted.view

        coordinator.installGestureIfNeeded()
        coordinator.installGestureIfNeeded()
        coordinator.installGestureIfNeeded()

        let pans = (hosted.controller.view.gestureRecognizers ?? []).filter { $0 is UIPanGestureRecognizer }
        XCTAssertEqual(pans.count, 1)
    }

    /// Before SwiftUI has put the view in a hierarchy there is nothing to
    /// install onto, and that has to be survivable rather than a trap.
    func testInstallingWithNothingToAttachToIsHarmless() {
        let coordinator = makeDismissCoordinator(handle: FadeDismissHandle(), calls: Calls())

        coordinator.installGestureIfNeeded()
        coordinator.anchorView = UIView()
        coordinator.installGestureIfNeeded()

        XCTAssertNil(coordinator.anchorView?.gestureRecognizers)
    }

    /// The comment sheet takes over touches while it's up, so the story's pan
    /// has to be switched off rather than competing with it.
    func testDisablingTurnsTheInstalledPanOff() {
        let hosted = hostedView()
        defer { teardown(hosted.window) }
        let coordinator = makeDismissCoordinator(handle: FadeDismissHandle(), calls: Calls())
        coordinator.anchorView = hosted.view
        coordinator.installGestureIfNeeded()

        let pan = (hosted.controller.view.gestureRecognizers ?? []).first { $0 is UIPanGestureRecognizer }

        coordinator.setEnabled(false)
        XCTAssertEqual(pan?.isEnabled, false)

        coordinator.setEnabled(true)
        XCTAssertEqual(pan?.isEnabled, true)
    }

    func testDisablingBeforeAnythingIsInstalledIsHarmless() {
        let coordinator = makeDismissCoordinator(handle: FadeDismissHandle(), calls: Calls())

        coordinator.setEnabled(false)
        coordinator.setEnabled(true)

        XCTAssertNotNil(coordinator)
    }

    /// SwiftUI taps inside the story have to keep working during the drag, but
    /// a second pan — the comment sheet's scroll, say — must not.
    func testTapsRunAlongsideTheDismissPanButOtherPansDoNot() {
        let coordinator = makeDismissCoordinator(handle: FadeDismissHandle(), calls: Calls())

        XCTAssertTrue(
            coordinator.gestureRecognizer(UIPanGestureRecognizer(), shouldRecognizeSimultaneouslyWith: UITapGestureRecognizer())
        )
        XCTAssertFalse(
            coordinator.gestureRecognizer(UIPanGestureRecognizer(), shouldRecognizeSimultaneouslyWith: UIPanGestureRecognizer())
        )
    }

    // MARK: - Fade dismiss handle

    /// Installing the gesture is also what wires the handle to the dismiss
    /// callback, so the story viewer can fade out programmatically when the
    /// last story ends — and the dismiss lands after the fade, not before,
    /// otherwise the story pops off screen instead of fading.
    func testTheHandleFadesTheViewThenDismisses() {
        let hosted = hostedView()
        defer { teardown(hosted.window) }
        let handle = FadeDismissHandle()
        let dismissed = expectation(description: "dismiss ran after the fade")
        let coordinator = makeDismissCoordinator(
            handle: handle, calls: Calls(), onDismiss: { dismissed.fulfill() }
        )
        coordinator.anchorView = hosted.view
        coordinator.installGestureIfNeeded()

        handle.fadeDismiss()

        wait(for: [dismissed], timeout: 5)
        XCTAssertEqual(hosted.controller.view.alpha, 0, "The dismiss lands at the end of the fade, not before it")
    }

    /// Nothing wired up at all — the story viewer builds the handle before it
    /// has anywhere to send the dismissal.
    func testAnUnwiredHandleIsHarmless() {
        FadeDismissHandle().fadeDismiss()
    }

    // MARK: - Reorder recognizer

    private func makeReorderCoordinator(isEnabled: Bool, calls: Calls) -> ReorderRecognizer.Coordinator {
        ReorderRecognizer.Coordinator(isEnabled: isEnabled) { phase, translation in
            calls.phases.append(String(describing: phase))
            calls.translations.append(translation)
        }
    }

    /// Arming fires the moment a finger lands, before the hold completes, so
    /// the parent can lock the Form's scroll and not lose the vertical
    /// component of the drag.
    func testTouchingDownArmsTheReorderBeforeTheHoldCompletes() {
        let calls = Calls()
        let coordinator = makeReorderCoordinator(isEnabled: true, calls: calls)

        let shouldBegin = coordinator.gestureRecognizerShouldBegin(UILongPressGestureRecognizer())

        XCTAssertTrue(shouldBegin)
        XCTAssertEqual(calls.phases, ["arming"])
    }

    /// The recognizer is gated on editor mode; in preview or captions mode it
    /// must never fire, and must not announce arming either.
    func testADisabledReorderNeverBeginsOrArms() {
        let calls = Calls()
        let coordinator = makeReorderCoordinator(isEnabled: false, calls: calls)

        let shouldBegin = coordinator.gestureRecognizerShouldBegin(UILongPressGestureRecognizer())

        XCTAssertFalse(shouldBegin)
        XCTAssertTrue(calls.phases.isEmpty)
    }

    /// The gate is re-read on every SwiftUI update rather than captured once.
    func testTheReorderGateCanBeFlippedAfterTheFact() {
        let calls = Calls()
        let coordinator = makeReorderCoordinator(isEnabled: false, calls: calls)

        XCTAssertFalse(coordinator.gestureRecognizerShouldBegin(UILongPressGestureRecognizer()))
        coordinator.isEnabled = true
        XCTAssertTrue(coordinator.gestureRecognizerShouldBegin(UILongPressGestureRecognizer()))
    }

    /// Scroll pans and SwiftUI taps have to keep firing alongside a reorder —
    /// this is the hook the Representable protocol doesn't offer, and the whole
    /// reason the gesture is UIKit-backed.
    func testAReorderRunsAlongsideEveryOtherGesture() {
        let coordinator = makeReorderCoordinator(isEnabled: true, calls: Calls())

        for other in [UIPanGestureRecognizer(), UITapGestureRecognizer(), UILongPressGestureRecognizer()] {
            XCTAssertTrue(
                coordinator.gestureRecognizer(UILongPressGestureRecognizer(), shouldRecognizeSimultaneouslyWith: other)
            )
        }
    }

    func testTheReorderCoordinatorStartsWithNoDragInFlight() {
        XCTAssertNil(makeReorderCoordinator(isEnabled: true, calls: Calls()).startLocation)
    }

    // MARK: - Strip pan recognizer

    /// A recognizer that has never been attached has no coordinate space to
    /// report velocity in, so give it one.
    private func attachedPan() -> UIPanGestureRecognizer {
        let pan = UIPanGestureRecognizer()
        UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)).addGestureRecognizer(pan)
        return pan
    }

    private func makeStripCoordinator(isEnabled: Bool, calls: Calls) -> StripPanRecognizer.Coordinator {
        StripPanRecognizer.Coordinator(
            isEnabled: isEnabled,
            onChanged: { calls.translations.append(CGSize(width: $0, height: 0)) },
            onEnded: { translation, _ in calls.translations.append(CGSize(width: translation, height: 0)) }
        )
    }

    /// The strip scrolls sideways inside a vertically-scrolling Form, so a
    /// mostly-vertical drag has to be left to the Form.
    func testTheStripOnlyClaimsMostlyHorizontalDrags() {
        let coordinator = makeStripCoordinator(isEnabled: true, calls: Calls())

        // A resting finger reports zero velocity, which is not horizontal.
        XCTAssertFalse(coordinator.gestureRecognizerShouldBegin(attachedPan()))
    }

    /// In grid or captions mode the strip isn't on screen and its recognizer
    /// has to be inert.
    func testADisabledStripNeverBegins() {
        let coordinator = makeStripCoordinator(isEnabled: false, calls: Calls())

        XCTAssertFalse(coordinator.gestureRecognizerShouldBegin(attachedPan()))
    }

    /// Anything that isn't a pan is let through untouched.
    func testANonPanGestureIsNotTheStripsToJudge() {
        let coordinator = makeStripCoordinator(isEnabled: true, calls: Calls())

        XCTAssertTrue(coordinator.gestureRecognizerShouldBegin(UITapGestureRecognizer()))
    }

    /// Once the strip's pan is running, the Form's vertical scroll must not run
    /// with it — otherwise the strip and the page move together.
    func testTheStripRefusesToShareWithTheEnclosingScroll() {
        let coordinator = makeStripCoordinator(isEnabled: true, calls: Calls())

        XCTAssertFalse(
            coordinator.gestureRecognizer(attachedPan(), shouldRecognizeSimultaneouslyWith: attachedPan())
        )
    }
}