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

grain.social/grain-ios

1 collaborator
0
1
main
187 lines · 6.8 KB Raw History
@testable import Grain
import SwiftUI
import XCTest

/// The cells and chrome inside the create flow, plus the story ring that wraps
/// avatars everywhere else. All are small views with several visual states, and
/// none of them was reachable from the suite.
@MainActor
final class EditorCellRenderTests: 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()
    }

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

    private func makeItem(alt: String = "", exif: ExifSummary? = nil) -> PhotoItem {
        let image = UIImage(systemName: "photo")!
        return PhotoItem(
            thumbnail: image,
            carouselPreview: image,
            source: .camera(image, metadata: nil),
            alt: alt,
            exifSummary: exif
        )
    }

    private func renderCell(
        item: PhotoItem,
        mode: EditorMode,
        isSelected: Bool = false,
        isDragging: Bool = false,
        hideDelete: Bool = false,
        deleteOpacity: CGFloat = 1,
        exifState: ExifState = .absent
    ) {
        var bound = item
        ViewRender.render(
            PhotoThumbnailCell(
                item: Binding(get: { bound }, set: { bound = $0 }),
                geometry: CellGeometry(mode: mode, maskSide: 72, photoAspect: bound.naturalAspect),
                isSelected: isSelected,
                isDragging: isDragging,
                hideDelete: hideDelete,
                deleteOpacity: deleteOpacity,
                exifState: exifState,
                onTap: {},
                onDelete: {}
            ),
            settle: 0
        )
    }

    // MARK: - PhotoThumbnailCell

    func testRendersAThumbnailCellInEveryMode() {
        for mode in EditorMode.allCases {
            renderCell(item: makeItem(), mode: mode)
        }
    }

    /// Selected, dragging and mid-fade are the states the strip and grid put a
    /// cell through, and each draws different chrome.
    func testRendersAThumbnailCellInItsInteractionStates() {
        renderCell(item: makeItem(), mode: .preview, isSelected: true)
        renderCell(item: makeItem(), mode: .reorder, isDragging: true)
        renderCell(item: makeItem(), mode: .preview, hideDelete: true)
        renderCell(item: makeItem(), mode: .preview, deleteOpacity: 0.4)
    }

    /// The captions cell shows the alt text it already has, which is a
    /// different layout from an empty one prompting for it.
    func testRendersACaptionsCellWithAndWithoutAltText() {
        renderCell(item: makeItem(alt: "A long alt description that wraps onto a second line."), mode: .captions)
        renderCell(item: makeItem(), mode: .captions)
    }

    func testRendersAThumbnailCellForEachExifState() {
        let exif = ExifSummary(
            camera: "Fujifilm X100V",
            lens: "23mm",
            exposure: nil,
            shutterSpeed: "1/500s",
            iso: "ISO 400",
            focalLength: "35mm",
            aperture: "f/2"
        )
        for state in [ExifState.absent, .inactive, .active] {
            renderCell(item: makeItem(exif: exif), mode: .captions, exifState: state)
        }
    }

    // MARK: - ExifChip

    private func chipSize(_ state: ExifState) -> CGSize {
        UIHostingController(rootView: ExifChip(state: state))
            .sizeThatFits(in: CGSize(width: 100, height: 100))
    }

    /// The chip is deliberately nothing at all when there's no exif, so it must
    /// not take up the badge's room on a photo that has none.
    func testTheExifChipTakesNoRoomWhenThereIsNoExif() {
        let absent = chipSize(.absent)
        let present = chipSize(.active)

        XCTAssertLessThan(absent.width, present.width)
        XCTAssertLessThan(absent.height, present.height)
        XCTAssertEqual(present.width, 19, accuracy: 0.5)
        XCTAssertEqual(present.height, 19, accuracy: 0.5)
    }

    /// Inactive and active are the same badge at different opacities, so they
    /// have to occupy identical space.
    func testTheInactiveChipIsTheSameSizeAsTheActiveOne() {
        XCTAssertEqual(chipSize(.inactive), chipSize(.active))
    }

    // MARK: - StoryRingView

    func testRendersAStoryRingInEachState() {
        for (hasStory, viewed) in [(false, false), (true, false), (true, true)] {
            ViewRender.render(
                StoryRingView(hasStory: hasStory, viewed: viewed, size: 48) {
                    AvatarView(url: nil, size: 48)
                },
                settle: 0
            )
        }
    }

    /// The ring is drawn as an overlay that spills outside the avatar, so it
    /// must never change the footprint — otherwise every row in the story strip
    /// and every avatar in a list would shift as story state loaded in.
    func testTheRingNeverChangesTheAvatarsFootprint() {
        func ringedSize(size: CGFloat, hasStory: Bool, viewed: Bool = false) -> CGSize {
            UIHostingController(
                rootView: StoryRingView(hasStory: hasStory, viewed: viewed, size: size) {
                    Color.clear.frame(width: size, height: size)
                }
            )
            .sizeThatFits(in: CGSize(width: 400, height: 400))
        }

        for size in [CGFloat(24), 32, 48, 68] {
            let plain = ringedSize(size: size, hasStory: false)
            XCTAssertEqual(plain.height, size, accuracy: 0.5, "Avatar of \(size) didn't measure as itself")
            XCTAssertEqual(ringedSize(size: size, hasStory: true), plain, "An unviewed ring resized the avatar")
            XCTAssertEqual(
                ringedSize(size: size, hasStory: true, viewed: true), plain,
                "A viewed ring resized the avatar"
            )
        }
    }

    // MARK: - Expandable description

    /// Three nested copies of the text are laid out to decide whether "more"
    /// belongs there, and the language check runs in a `.task` alongside.
    func testRendersAnExpandableDescription() {
        ViewRender.render(ExpandableDescriptionView(text: "Short caption."), settle: 0.2)
        ViewRender.render(
            ExpandableDescriptionView(
                text: String(repeating: "A caption long enough to need truncating. ", count: 8),
                onMentionTap: { _ in },
                onHashtagTap: { _ in }
            ),
            settle: 0.2
        )
    }

    /// A caption in another language grows a translate affordance, which is the
    /// branch the detector exists to drive.
    func testRendersADescriptionInAnotherLanguage() {
        ViewRender.render(
            ExpandableDescriptionView(text: "Fotografia tirada ao pôr do sol sobre a cidade."),
            settle: 0.3
        )
    }
}