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

grain.social/grain-ios

1 collaborator
0
1
main
218 lines · 8.5 KB Raw History
@testable import Grain
import SwiftUI
import XCTest

/// SwiftUI only evaluates a view's `body` when something actually lays the view
/// out, so exercising a view file means hosting and rendering it rather than
/// calling into it. `FacepileLayoutTests` uses `UIHostingController` the same
/// way to measure layout; this is the reusable form of that trick.
@MainActor
enum ViewRender {
    /// An iPhone-sized canvas, so views that branch on size class take the
    /// phone path rather than the iPad one.
    static let canvas = CGSize(width: 402, height: 874)

    /// Host `view` in a real window and lay it out. Attaching to a window (not
    /// just calling `sizeThatFits`) is what makes `onAppear` and `task` fire,
    /// which is where most of a view's state handling lives.
    ///
    /// `settle` is a *ceiling* on how long the run loop is pumped afterwards so
    /// async `task` work can land and re-render the loaded state — not a fixed
    /// wait. The pump stops as soon as the view tree has held still for
    /// `quietPeriod`, which for most views is far sooner. Content arriving late
    /// restarts that window, so a slow load still gets the full ceiling.
    static func render(_ view: some View, settle: TimeInterval = 0.05) {
        let host = UIHostingController(rootView: view)
        let window = UIWindow(frame: CGRect(origin: .zero, size: canvas))
        window.rootViewController = host
        window.isHidden = false

        host.view.frame = CGRect(origin: .zero, size: canvas)
        host.view.setNeedsLayout()
        host.view.layoutIfNeeded()
        _ = host.sizeThatFits(in: canvas)

        if settle > 0 {
            pumpUntilQuiet(host, ceiling: settle)
            host.view.setNeedsLayout()
            host.view.layoutIfNeeded()
        }

        window.isHidden = true
        window.rootViewController = nil
    }

    /// One slice of run loop per pump. Small enough that a view which settles
    /// immediately costs almost nothing.
    private static let slice: TimeInterval = 0.02

    /// How long the tree has to hold still before it counts as settled. Long
    /// enough to cover the gap between a mocked response landing and SwiftUI
    /// re-rendering off the back of it.
    private static let quietPeriod: TimeInterval = 0.06

    private static func pumpUntilQuiet(_ host: UIHostingController<some View>, ceiling: TimeInterval) {
        let deadline = Date().addingTimeInterval(ceiling)
        var lastChange = Date()
        var previous = fingerprint(host.view)

        while Date() < deadline {
            RunLoop.current.run(until: Date().addingTimeInterval(slice))
            host.view.layoutIfNeeded()

            let current = fingerprint(host.view)
            if current != previous {
                previous = current
                lastChange = Date()
                continue
            }
            // Views with a looping animation — a shimmer, a spinner — never go
            // quiet, and simply run out the ceiling as they did before.
            if Date().timeIntervalSince(lastChange) >= quietPeriod {
                return
            }
        }
    }

    /// A cheap structural signature of the rendered tree. Content arriving
    /// changes the subview count and the frames, which is what marks the view
    /// as still busy.
    private static func fingerprint(_ view: UIView) -> Int {
        var hasher = Hasher()
        var stack = [view]
        var count = 0
        while let next = stack.popLast() {
            count += 1
            hasher.combine(next.frame.origin.x)
            hasher.combine(next.frame.origin.y)
            hasher.combine(next.frame.size.width)
            hasher.combine(next.frame.size.height)
            hasher.combine(next.isHidden)
            stack.append(contentsOf: next.subviews)
        }
        hasher.combine(count)
        return hasher.finalize()
    }
}

/// The environment objects `GrainApp` installs above the tab tree. Views pull
/// these out with `@Environment`, so rendering one in isolation means supplying
/// the same set or the view traps on a missing value.
@MainActor
struct TestEnvironment {
    /// Served by `MockURLProtocol` like `client` is, so a token refresh or an
    /// avatar fetch triggered by a render can't reach the network either.
    let auth = AuthManager(session: MockURLProtocol.mockSession())
    let viewedStories = ViewedStoryStorage(did: "did:plc:test")
    let labelDefs = LabelDefinitionsCache()
    let storyStatus = StoryStatusCache()
    let uploadCenter = GalleryUploadCenter()
    let pushManager = PushManager()
    let commentPresenter = StoryCommentPresenter()

    /// Where a tap would have taken you. Assert on `router.path` instead of
    /// trying to observe a push that only a real navigation stack performs.
    let router = Router()

    /// A client whose traffic is served by `MockURLProtocol`, so rendering a
    /// view never reaches the network.
    let client = XRPCClient(
        baseURL: URL(string: "https://test.local")!,
        session: MockURLProtocol.mockSession()
    )

    init(authenticated: Bool = true, did: String = "did:plc:test") {
        auth.isAuthenticated = authenticated
        auth.userDID = did
    }
}

extension View {
    /// Attach the full environment set `GrainApp` provides.
    @MainActor
    func withTestEnvironment(_ env: TestEnvironment) -> some View {
        environment(env.router)
            .environment(env.auth)
            .environment(env.viewedStories)
            .environment(env.labelDefs)
            .environment(env.storyStatus)
            .environment(env.uploadCenter)
            .environment(env.pushManager)
            .environment(env.commentPresenter)
    }
}

/// Sets `TokenStorage.userDID` for the duration of a test.
///
/// This used to save and put back the real value, because the test host shares
/// a Keychain with the app installed on the same simulator. `GrainTestCase` now
/// binds a per-test Keychain service, so there is no real value to protect.
@MainActor
final class KeychainGuard {
    init(userDID: String?) {
        TokenStorage.userDID = userDID
    }

    func restore() {
        TokenStorage.userDID = nil
    }
}

/// A synthetic signed-in account, so `AuthManager.authContext()` resolves.
///
/// Screens gate whole sections on having a context — the moderation lists, the
/// settings preferences, every favorite and follow button — so an environment
/// without one renders only their signed-out half, which is a large share of
/// the view code going unexercised.
///
/// Writes land in whatever namespace `StorageEnvironment` currently binds, which
/// `GrainTestCase` scopes to the individual test — so this no longer has to save
/// and put back the credentials of the app installed on the same simulator.
@MainActor
final class TestAccount {
    let did: String
    let handle: String

    init(did: String = "did:plc:test", handle: String = "tester.grain.social") {
        self.did = did
        self.handle = handle

        _ = try? DPoP.loadOrCreate(for: did)
        TokenStorage.storeTokens(
            did: did,
            accessToken: "test-access",
            refreshToken: "test-refresh",
            handle: handle,
            // Comfortably in the future: `authContext()` refreshes anything
            // expiring within a minute, and a refresh has nowhere to go here.
            expiresAt: Date().addingTimeInterval(3600),
            scope: AuthManager.requiredScopes.joined(separator: " ")
        )
        TokenStorage.storeServer(.test, for: did)
        TokenStorage.upsertAccount(StoredAccount(did: did, handle: handle, avatar: nil))

        // Making it the active account is what matters: `AuthManager.init`
        // restores from here and installs the DPoP key, so every environment
        // built afterwards has a working `authContext()`.
        TokenStorage.activeDID = did
        AccountScopedStorage.activeAccountID = did
    }

    /// Explicitly hand the account to an `AuthManager` that was built before
    /// this account existed.
    func activate(_ auth: AuthManager) async {
        // `TestEnvironment` pre-sets `userDID`; clear it so `switchTo` doesn't
        // early-return as a no-op.
        auth.userDID = nil
        try? await auth.switchTo(did: did)
    }

    /// Kept so existing tearDowns keep compiling. The namespace is discarded
    /// wholesale when the test ends, so this is only about leaving a tidy store
    /// behind for anything sharing the namespace within one test.
    func restore() {
        TokenStorage.removeAccount(did)
        try? DPoP.clearKey(for: did)
        AccountScopedStorage.purge(did: did)
    }
}