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

grain.social/grain-ios

1 collaborator
0
1
main
126 lines · 4.8 KB Raw History
@testable import Grain
import SwiftUI
import XCTest

/// The sign-in screen once a handle is being typed. Most of it — the
/// suggestions list, the highlighted row, the error line, the re-auth notice —
/// only exists past a keystroke, so an empty render never reaches it.
@MainActor
final class LoginRenderTests: GrainTestCase {
    private var account: TestAccount!

    /// Async overrides: the synchronous `setUp`/`tearDown` are nonisolated, so
    /// touching main-actor state from them warns.
    override func setUp() async throws {
        try await super.setUp()
        account = TestAccount()
        MockURLProtocol.respondByPath(Fixtures.routes)
    }

    override func tearDown() async throws {
        MockURLProtocol.handler = nil
        account.restore()
        try await super.tearDown()
    }

    private func suggestions(_ count: Int) -> [ActorSuggestion] {
        let names = ["alice", "alastair", "alba", "aled", "alex", "alfie"]
        return (0 ..< count).map { index in
            ActorSuggestion(
                handle: "\(names[index % names.count]).grain.social",
                displayName: index.isMultiple(of: 2) ? names[index % names.count].capitalized : nil,
                avatar: index.isMultiple(of: 3) ? nil : "https://test.local/\(index).jpg"
            )
        }
    }

    private func render(handle: String = "", suggestions: [ActorSuggestion] = [], settle: TimeInterval = 0.3) {
        let env = TestEnvironment(authenticated: false)
        ViewRender.render(
            LoginView(handle: handle, suggestions: suggestions).withTestEnvironment(env),
            settle: settle
        )
    }

    /// The screen as it opens — marketing copy, no field content, no results.
    func testRendersTheSignInScreenBeforeAnyTyping() {
        render()
    }

    /// A partial handle with matches behind it: the suggestions list replaces
    /// the copy above the field.
    func testRendersTheSuggestionsList() {
        render(handle: "al", suggestions: suggestions(5))
    }

    /// One result and a full list are laid out differently — the last row has
    /// no separator under it.
    func testRendersTheSuggestionsListAtEachSize() {
        for count in [1, 2, 6] {
            render(handle: "al", suggestions: suggestions(count))
        }
    }

    /// A suggestion with no display name falls back to the handle, and one with
    /// no avatar falls back to the placeholder.
    func testRendersSuggestionsMissingTheirDetails() {
        render(handle: "al", suggestions: [
            ActorSuggestion(handle: "bare.grain.social", displayName: nil, avatar: nil),
            ActorSuggestion(handle: "named.grain.social", displayName: "Named", avatar: nil),
        ])
    }

    /// Typed something that matched nothing — the field is filled but the list
    /// stays empty, which is a third state again.
    func testRendersATypedHandleWithNoMatches() {
        render(handle: "nobodyhasthishandle", suggestions: [])
    }

    /// A full handle is what the Go key submits.
    func testRendersACompleteHandle() {
        render(handle: "tester.grain.social", suggestions: [])
    }

    /// After a scope bump the screen carries a notice explaining why everyone
    /// got signed out.
    func testRendersTheReauthNotice() {
        let env = TestEnvironment(authenticated: false)
        env.auth.reauthReason = "Grain has been updated. Please sign in again to enable new features."

        ViewRender.render(LoginView().withTestEnvironment(env), settle: 0.3)
    }

    /// The notice is suppressed while suggestions are up, so the list isn't
    /// pushed off screen by it.
    func testTheReauthNoticeStepsAsideForSuggestions() {
        let env = TestEnvironment(authenticated: false)
        env.auth.reauthReason = "Grain has been updated."

        ViewRender.render(
            LoginView(handle: "al", suggestions: suggestions(3)).withTestEnvironment(env),
            settle: 0.3
        )
    }

    // MARK: - Suggestion model

    /// Rows are diffed on the handle, so two accounts must never collide.
    func testSuggestionsAreIdentifiedByHandle() {
        let alice = ActorSuggestion(handle: "alice.grain.social", displayName: "Alice", avatar: nil)

        XCTAssertEqual(alice.id, "alice.grain.social")
        XCTAssertEqual(alice, ActorSuggestion(handle: "alice.grain.social", displayName: "Alice", avatar: nil))
        XCTAssertNotEqual(alice, ActorSuggestion(handle: "bob.grain.social", displayName: "Alice", avatar: nil))
    }

    // MARK: - Legal copy

    /// The three links are a compliance requirement, so they're pinned
    /// alongside the rendering.
    func testTheLegalLineCarriesAllThreeLinks() throws {
        let attributed = try AttributedString(markdown: LoginView.legalMarkdown)
        let urls = attributed.runs.compactMap { $0.link?.absoluteString }

        XCTAssertEqual(Set(urls).count, 3)
    }
}