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

grain.social/grain-ios

1 collaborator
0
1
main
348 lines · 12.3 KB Raw History
@testable import Grain
import SwiftUI
import XCTest

/// The gallery card and the story viewer are the two largest views in the app,
/// and almost every field on a gallery or a story swaps something in them — a
/// filled heart, a location row, an exif line, an owner-only menu. These render
/// them across those fields rather than against one plain fixture.
@MainActor
final class ContentVariationRenderTests: 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(_ did: String = "did:plc:test") -> GrainProfile {
        GrainProfile(
            cid: "bafy\(did)",
            did: did,
            handle: "\(did).test",
            displayName: "Someone",
            avatar: "https://test.local/avatar.jpg"
        )
    }

    private func exif() -> GrainExif {
        GrainExif(
            uri: "at://did:plc:test/social.grain.photo.exif/1",
            cid: "bafyexif",
            photo: "at://did:plc:test/social.grain.photo/1",
            createdAt: "2025-01-02T00:00:00Z",
            exposureTime: "1/500",
            fNumber: "2.0",
            flash: "Off, Did not fire",
            focalLengthIn35mmFormat: "35",
            iSO: 400,
            lensMake: "FUJIFILM",
            lensModel: "23mm f/2",
            make: "FUJIFILM",
            model: "X100V"
        )
    }

    private func photo(_ index: Int, alt: String? = nil, withExif: Bool = false) -> GrainPhoto {
        GrainPhoto(
            uri: "at://did:plc:test/social.grain.photo/\(index)",
            cid: "bafyphoto\(index)",
            thumb: "https://test.local/thumb\(index).jpg",
            fullsize: "https://test.local/full\(index).jpg",
            alt: alt,
            aspectRatio: AspectRatio(width: 3, height: 2),
            exif: withExif ? exif() : nil
        )
    }

    private func gallery(
        creator: String = "did:plc:other",
        photos: [GrainPhoto] = [],
        favourited: Bool = false,
        crossPost: CrossPostInfo? = nil,
        locationDisplay: String? = nil,
        cameras: [String]? = nil,
        favedByFollowing: [GrainProfile]? = nil
    ) -> GrainGallery {
        GrainGallery(
            uri: "at://\(creator)/social.grain.gallery/1",
            cid: "bafygallery",
            title: "A gallery",
            description: "Shot on #Portra400 with @tester.grain.social, see https://grain.social.",
            cameras: cameras,
            locationDisplay: locationDisplay,
            creator: profile(creator),
            items: photos,
            favCount: 42,
            favedByFollowing: favedByFollowing,
            commentCount: 3,
            createdAt: "2025-01-02T00:00:00Z",
            indexedAt: "2025-01-02T00:00:00Z",
            viewer: favourited ? GalleryViewerState(fav: "at://did:plc:test/social.grain.favorite/1") : nil,
            crossPost: crossPost
        )
    }

    private func renderCard(_ gallery: GrainGallery, env: TestEnvironment, settle: TimeInterval = 0.15) {
        var bound = gallery
        ViewRender.render(
            GalleryCardView(
                gallery: Binding(get: { bound }, set: { bound = $0 }),
                client: env.client,
                onCommentTap: {},
                onStoryTap: { _ in },
                onReport: {},
                onDelete: {}
            )
            .withTestEnvironment(env),
            settle: settle
        )
    }

    // MARK: - Gallery card

    /// A favorited gallery draws a filled heart and a different accessibility
    /// label from an unfavorited one.
    func testRendersACardInBothFavoriteStates() {
        let env = TestEnvironment()
        renderCard(gallery(photos: [photo(1)], favourited: false), env: env)
        renderCard(gallery(photos: [photo(1)], favourited: true), env: env)
    }

    /// Your own gallery gets a delete action where someone else's gets report.
    func testRendersYourOwnCardAndSomeoneElses() {
        let env = TestEnvironment()
        renderCard(gallery(creator: "did:plc:test", photos: [photo(1)]), env: env)
        renderCard(gallery(creator: "did:plc:other", photos: [photo(1)]), env: env)
    }

    /// A cross-posted gallery carries a link back to the Bluesky post.
    func testRendersACardWithACrossPostLink() {
        let env = TestEnvironment()
        renderCard(
            gallery(photos: [photo(1)], crossPost: CrossPostInfo(url: "https://bsky.app/profile/x/post/1")),
            env: env
        )
    }

    /// Exif and a camera name add a whole row under the photo.
    func testRendersACardWithExifAndACameraName() {
        let env = TestEnvironment()
        renderCard(
            gallery(photos: [photo(1, withExif: true), photo(2)], cameras: ["Fujifilm X100V"]),
            env: env
        )
    }

    /// Alt text is shown behind a badge rather than inline.
    func testRendersACardWithAltText() {
        let env = TestEnvironment()
        renderCard(gallery(photos: [photo(1, alt: "A quiet street just after sunrise")]), env: env)
    }

    func testRendersACardWithALocation() {
        let env = TestEnvironment()
        renderCard(gallery(photos: [photo(1)], locationDisplay: "Lisboa, Portugal"), env: env)
    }

    /// The facepile only appears when people the viewer follows favorited it,
    /// and it says "and others" past two names.
    func testRendersACardWithFacepilesOfEachSize() {
        let env = TestEnvironment()
        let people = (0 ..< 4).map { profile("did:plc:fan\($0)") }
        for count in [1, 2, 4] {
            renderCard(gallery(photos: [photo(1)], favedByFollowing: Array(people.prefix(count))), env: env)
        }
    }

    /// A creator with a live story gets a tappable ring around their avatar.
    func testRendersACardWhoseAuthorHasAStory() {
        let env = TestEnvironment()
        env.storyStatus.update(from: [
            GrainStoryAuthor(profile: profile("did:plc:other"), storyCount: 2, latestAt: Fixtures.freshTimestamp),
        ])

        renderCard(gallery(photos: [photo(1)]), env: env)
    }

    /// Signed out, none of the card's actions are available.
    func testRendersACardWhileSignedOut() {
        let env = TestEnvironment(authenticated: false)
        renderCard(gallery(photos: [photo(1)], favourited: false), env: env)
    }

    /// Mixed aspect ratios make the carousel size to the tallest photo rather
    /// than to each page, which is a separate measurement path.
    func testRendersACardWithMixedAspectRatios() {
        let env = TestEnvironment()
        let mixed = [
            GrainPhoto(
                uri: "at://did:plc:other/social.grain.photo/1", cid: "c1",
                thumb: "https://test.local/1.jpg", fullsize: "https://test.local/1f.jpg",
                aspectRatio: AspectRatio(width: 3, height: 2)
            ),
            GrainPhoto(
                uri: "at://did:plc:other/social.grain.photo/2", cid: "c2",
                thumb: "https://test.local/2.jpg", fullsize: "https://test.local/2f.jpg",
                aspectRatio: AspectRatio(width: 2, height: 3)
            ),
        ]
        renderCard(gallery(photos: mixed), env: env)
    }

    // MARK: - Story viewer

    private func story(
        _ index: Int,
        creator: String = "did:plc:other",
        expired: Bool? = nil,
        favourited: Bool = false,
        labels: [ATLabel]? = nil,
        locationDisplay: String? = nil,
        crossPost: CrossPostInfo? = nil
    ) -> GrainStory {
        GrainStory(
            uri: "at://\(creator)/social.grain.story/\(index)",
            cid: "bafystory\(index)",
            creator: profile(creator),
            thumb: "https://test.local/thumb\(index).jpg",
            fullsize: "https://test.local/full\(index).jpg",
            aspectRatio: AspectRatio(width: 3, height: 4),
            locationDisplay: locationDisplay,
            createdAt: Fixtures.freshTimestamp,
            labels: labels,
            expired: expired,
            crossPost: crossPost,
            viewer: favourited ? StoryViewerState(fav: "at://did:plc:test/social.grain.favorite/1") : nil
        )
    }

    private func author(_ did: String = "did:plc:other", count: Int = 2) -> GrainStoryAuthor {
        GrainStoryAuthor(profile: profile(did), storyCount: count, latestAt: Fixtures.freshTimestamp)
    }

    private func renderViewer(
        authors: [GrainStoryAuthor],
        stories: [GrainStory],
        startAuthorDid: String? = nil,
        startIndex: Int? = nil,
        env: TestEnvironment
    ) {
        ViewRender.render(
            StoryViewer(
                authors: authors,
                startAuthorDid: startAuthorDid,
                initialStories: stories,
                startStoryIndex: startIndex,
                client: env.client,
                onProfileTap: { _ in },
                onDismiss: {}
            )
            .withTestEnvironment(env),
            settle: 0.4
        )
    }

    func testRendersTheStoryViewerWithSeveralStories() {
        let env = TestEnvironment()
        renderViewer(
            authors: [author()],
            stories: [story(1), story(2, locationDisplay: "Lisboa"), story(3)],
            env: env
        )
    }

    /// Starting partway through someone's stories is what tapping a ring does
    /// when you've already seen the first few.
    func testRendersTheStoryViewerStartedPartwayThrough() {
        let env = TestEnvironment()
        renderViewer(
            authors: [author(count: 3)],
            stories: [story(1), story(2), story(3)],
            startIndex: 2,
            env: env
        )
    }

    /// Your own story gets the author's controls — the viewer count and delete
    /// — where someone else's gets favorite and report.
    func testRendersYourOwnStoryAndSomeoneElses() {
        let env = TestEnvironment()
        renderViewer(
            authors: [author("did:plc:test")],
            stories: [story(1, creator: "did:plc:test")],
            startAuthorDid: "did:plc:test",
            env: env
        )
        renderViewer(
            authors: [author("did:plc:other")],
            stories: [story(1, creator: "did:plc:other")],
            startAuthorDid: "did:plc:other",
            env: env
        )
    }

    func testRendersAFavoritedStory() {
        let env = TestEnvironment()
        renderViewer(authors: [author()], stories: [story(1, favourited: true)], env: env)
    }

    /// A labelled story is covered until it's revealed.
    func testRendersALabelledStory() {
        let env = TestEnvironment()
        renderViewer(
            authors: [author()],
            stories: [story(1, labels: [ATLabel(src: nil, uri: nil, val: "nudity", cts: nil)])],
            env: env
        )
    }

    /// An expired story is still in the payload for a while; it must not be
    /// presented as if it were live.
    func testRendersAnExpiredStory() {
        let env = TestEnvironment()
        renderViewer(authors: [author()], stories: [story(1, expired: true)], env: env)
    }

    func testRendersACrossPostedStory() {
        let env = TestEnvironment()
        renderViewer(
            authors: [author()],
            stories: [story(1, crossPost: CrossPostInfo(url: "https://bsky.app/profile/x/post/1"))],
            env: env
        )
    }

    /// Several authors is what paging sideways moves between.
    func testRendersTheStoryViewerWithSeveralAuthors() {
        let env = TestEnvironment()
        renderViewer(
            authors: [author("did:plc:one"), author("did:plc:two"), author("did:plc:three")],
            stories: [story(1, creator: "did:plc:one")],
            startAuthorDid: "did:plc:two",
            env: env
        )
    }

    /// A story already watched sorts and rings differently from an unwatched one.
    func testRendersAStoryThatHasAlreadyBeenWatched() {
        let env = TestEnvironment()
        let watched = story(1)
        env.viewedStories.markViewed(
            uri: watched.uri, authorDid: "did:plc:other", createdAt: watched.createdAt
        )

        renderViewer(authors: [author()], stories: [watched], env: env)
    }
}