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

grain.social/grain-ios

1 collaborator
0
1
main
268 lines · 9.5 KB Raw History
@testable import Grain
import SwiftUI
import XCTest

/// The notification rows themselves. A row looks different for each of nine
/// reasons, and different again once it groups — a facepile and "N others"
/// instead of one avatar — so the rows are rendered directly rather than left
/// to whatever a list render happens to reach.
@MainActor
final class NotificationRowRenderTests: 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()
    }

    // MARK: - Builders

    private func profile(_ id: String) -> GrainProfile {
        GrainProfile(
            cid: "bafy\(id)",
            did: "did:plc:\(id)",
            handle: "\(id).test",
            displayName: id.capitalized,
            avatar: "https://test.local/\(id).jpg"
        )
    }

    private func notification(
        _ index: Int,
        reason: String,
        author: String = "alice",
        galleryThumb: String? = "https://test.local/thumb.jpg",
        storyThumb: String? = nil,
        commentText: String? = nil,
        replyToText: String? = nil
    ) -> GrainNotification {
        GrainNotification(
            uri: "at://did:plc:test/notification/\(index)",
            reason: reason,
            createdAt: Fixtures.freshTimestamp,
            author: profile(author),
            galleryUri: "at://did:plc:test/social.grain.gallery/1",
            galleryTitle: "A test gallery",
            galleryThumb: galleryThumb,
            storyUri: storyThumb == nil ? nil : "at://did:plc:test/social.grain.story/1",
            storyThumb: storyThumb,
            commentText: commentText,
            replyToText: replyToText
        )
    }

    private let reasons = [
        "gallery-favorite", "gallery-comment", "gallery-comment-mention", "gallery-mention",
        "comment-favorite", "story-favorite", "story-comment", "reply", "follow",
        // One the app doesn't know yet — it still has to draw a row.
        "gallery-reposted",
    ]

    // MARK: - Reason icon

    /// Every reason gets an icon and a tint; an unrecognised one still has to
    /// resolve to something rather than leaving a hole in the row.
    func testRendersTheReasonIconForEveryReason() {
        for reason in reasons {
            ViewRender.render(ReasonIcon(reason: NotificationReason(rawValue: reason) ?? .unknown), settle: 0)
        }
        ViewRender.render(ReasonIcon(reason: .unknown), settle: 0)
    }

    // MARK: - Single rows

    func testRendersASingleRowForEveryReason() {
        for (index, reason) in reasons.enumerated() {
            ViewRender.render(
                SingleNotificationRow(
                    notification: notification(index, reason: reason, commentText: "A comment on your gallery."),
                    onProfileTap: { _ in },
                    onSubjectTap: {}
                ),
                settle: 0.1
            )
        }
    }

    /// A reply shows what it is replying to above its own text.
    func testRendersAReplyWithItsParentQuoted() {
        ViewRender.render(
            SingleNotificationRow(
                notification: notification(
                    1, reason: "reply",
                    commentText: "Portra, every time.",
                    replyToText: "What film stock was this?"
                )
            ),
            settle: 0.1
        )
    }

    /// A story notification has no gallery thumbnail, so the row falls back to
    /// the story's and draws it in portrait.
    func testRendersARowThatFallsBackToTheStoryThumbnail() {
        ViewRender.render(
            SingleNotificationRow(
                notification: notification(1, reason: "story-favorite", galleryThumb: nil, storyThumb: "https://test.local/s.jpg")
            ),
            settle: 0.1
        )
    }

    /// A follow has no subject at all — no thumbnail, nothing to tap through to.
    func testRendersARowWithNoThumbnail() {
        ViewRender.render(
            SingleNotificationRow(notification: notification(1, reason: "follow", galleryThumb: nil)),
            settle: 0.1
        )
    }

    // MARK: - Grouped rows

    private func grouped(_ reason: String, authors: Int) -> GroupedNotification {
        let names = ["alice", "bob", "carol", "dave", "erin", "frank"]
        let head = notification(0, reason: reason, author: names[0])
        let rest = (1 ..< authors).map { notification($0, reason: reason, author: names[$0 % names.count]) }
        return GroupedNotification(notification: head, additional: rest)
    }

    /// Two, three and six people read differently — "and 1 other" versus a
    /// capped facepile — so each is drawn.
    func testRendersAGroupedRowAtEachSize() {
        for count in [2, 3, 6] {
            ViewRender.render(
                GroupedNotificationRow(
                    group: grouped("gallery-favorite", authors: count),
                    onProfileTap: { _ in },
                    onSubjectTap: {},
                    onGroupTap: {}
                ),
                settle: 0.1
            )
        }
    }

    func testRendersAGroupedRowForEveryGroupableReason() {
        for reason in ["gallery-favorite", "story-favorite", "comment-favorite", "follow"] {
            ViewRender.render(
                GroupedNotificationRow(group: grouped(reason, authors: 3)),
                settle: 0.1
            )
        }
    }

    // MARK: - Row container

    /// The container is what picks between the two row layouts, and it carries
    /// the tap routing for each reason.
    func testTheContainerPicksTheRightRowLayout() {
        let env = TestEnvironment()

        for group in [grouped("gallery-favorite", authors: 1), grouped("gallery-favorite", authors: 4)] {
            ViewRender.render(
                NotificationRowContainer(
                    group: group,
                    client: env.client,
                    authContext: { await env.auth.authContext() },
                    onProfileTap: { _ in },
                    onGalleryTap: { _ in },
                    onStoryAuthorTap: { _ in },
                    onStoryTap: { _ in },
                    onGroupTap: { _ in }
                )
                .withTestEnvironment(env),
                settle: 0.1
            )
        }
    }

    // MARK: - Avatars and thumbnails

    /// The facepile caps how many faces it draws and fans them out by a fixed
    /// step, so it has to be rendered above and below that cap.
    func testRendersOverlappingAvatarsAtEachSize() {
        let people = ["alice", "bob", "carol", "dave", "erin"].map(profile)

        for count in [1, 2, 3, 5] {
            ViewRender.render(
                OverlappingAvatarsView(
                    authors: Array(people.prefix(count)),
                    size: 32,
                    overlap: 10,
                    onProfileTap: { _ in }
                ),
                settle: 0.1
            )
        }
    }

    func testRendersAnEmptyFacepile() {
        ViewRender.render(OverlappingAvatarsView(authors: [], size: 32, overlap: 10), settle: 0)
    }

    /// Story thumbnails are portrait where gallery ones are square.
    func testRendersACachedThumbnailInBothShapes() {
        ViewRender.render(CachedThumbnailView(url: "https://test.local/thumb.jpg", size: 44), settle: 0.1)
        ViewRender.render(
            CachedThumbnailView(url: "https://test.local/story.jpg", size: 44, portrait: true), settle: 0.1
        )
    }

    /// A thumbnail URL the loader can't parse has to leave the row's layout
    /// intact rather than collapsing it.
    func testACachedThumbnailWithAnUnusableURLStillHoldsItsSpace() {
        let good = UIHostingController(rootView: CachedThumbnailView(url: "https://test.local/t.jpg", size: 44))
            .sizeThatFits(in: CGSize(width: 200, height: 200))
        let bad = UIHostingController(rootView: CachedThumbnailView(url: "", size: 44))
            .sizeThatFits(in: CGSize(width: 200, height: 200))

        XCTAssertEqual(good.width, bad.width, accuracy: 0.5)
        XCTAssertEqual(good.height, bad.height, accuracy: 0.5)
    }

    // MARK: - Grouped authors list

    /// Tapping a grouped row opens the full list of who was in it.
    func testRendersTheGroupedAuthorsList() {
        let env = TestEnvironment()

        for reason in ["gallery-favorite", "story-favorite", "comment-favorite", "follow"] {
            ViewRender.render(
                GroupedAuthorsView(group: grouped(reason, authors: 4), client: env.client)
                    .withTestEnvironment(env),
                settle: 0.2
            )
        }
    }

    // MARK: - List content

    func testRendersTheNotificationListContent() {
        let env = TestEnvironment()
        let viewModel = NotificationsViewModel(client: env.client)

        ViewRender.render(
            NotificationListContent(
                viewModel: viewModel,
                client: env.client,
                authContext: { await env.auth.authContext() },
                onProfileTap: { _ in },
                onGalleryTap: { _ in },
                onStoryAuthorTap: { _ in },
                onStoryTap: { _ in },
                onGroupTap: { _ in }
            )
            .withTestEnvironment(env),
            settle: 0.3
        )
    }
}