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

grain.social/grain-ios

1 collaborator
0
1
main
272 lines · 10.3 KB Raw History
@testable import Grain
import SwiftUI
import XCTest

/// The gallery editor morphs one set of cells between a strip, a grid and a
/// captions list, and drag-to-reorder shuffles slots underneath that. All three
/// layouts and the slot maths are value types, so they can be checked directly
/// rather than by eye.
@MainActor
final class EditorLayoutTests: GrainTestCase {
    private let width: CGFloat = 402

    /// 402 - 2*16 outer padding - 2*4 spacing, over three columns.
    private var gridCellSide: CGFloat {
        (402 - 32 - 8) / 3
    }

    /// Height `AdaptivePhotoLayout` reports for `count` cells in `mode`.
    private func layoutHeight(mode: EditorMode, count: Int) -> CGFloat {
        let layout = AdaptivePhotoLayout(mode: mode, containerWidth: width)
        let view = layout {
            ForEach(0 ..< count, id: \.self) { index in
                Color.gray
                    .frame(height: 40)
                    .layoutValue(key: PhotoIndexKey.self, value: index)
            }
        }
        return UIHostingController(rootView: view)
            .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
            .height
    }

    // MARK: - AdaptivePhotoLayout sizing

    /// The strip is one row of 72pt thumbs however many photos there are —
    /// that fixed height is what stops the form jumping as photos are added.
    func testTheStripIsOneFixedHeightRowRegardlessOfCount() {
        let expected: CGFloat = 72 + 22 * 2
        XCTAssertEqual(layoutHeight(mode: .preview, count: 1), expected, accuracy: 0.5)
        XCTAssertEqual(layoutHeight(mode: .preview, count: 9), expected, accuracy: 0.5)
    }

    func testTheGridGrowsARowAtATime() {
        let oneRow = layoutHeight(mode: .reorder, count: 3)
        let twoRows = layoutHeight(mode: .reorder, count: 4)
        let stillTwoRows = layoutHeight(mode: .reorder, count: 6)

        XCTAssertEqual(twoRows, stillTwoRows, accuracy: 0.5, "Four and six photos are both two rows")
        XCTAssertEqual(twoRows - oneRow, gridCellSide + 4, accuracy: 0.5, "A new row should cost a cell plus its spacing")
    }

    func testTheGridIsACellPlusItsPaddingForASingleRow() {
        // One row of cells, 22pt above and 12pt below.
        XCTAssertEqual(layoutHeight(mode: .reorder, count: 2), gridCellSide + 34, accuracy: 0.5)
    }

    func testTheCaptionsListStacksEveryRow() {
        let one = layoutHeight(mode: .captions, count: 1)
        let three = layoutHeight(mode: .captions, count: 3)

        XCTAssertEqual(three, one * 3, accuracy: 0.5, "Captions rows should simply stack")
        // 40pt of content plus 10pt above and below.
        XCTAssertEqual(one, 60, accuracy: 0.5)
    }

    /// Every mode is asked to size before there is anything to show.
    func testAnEmptyEditorHasNoHeightInAnyMode() {
        for mode in EditorMode.allCases {
            XCTAssertEqual(layoutHeight(mode: mode, count: 0), 0, accuracy: 0.5, "\(mode.label) sized a non-empty box")
        }
    }

    /// Switching modes is an animated interpolation between two layouts, so the
    /// three have to disagree — otherwise nothing moves.
    func testTheThreeModesReportDifferentHeights() {
        let heights = EditorMode.allCases.map { layoutHeight(mode: $0, count: 5) }
        XCTAssertEqual(Set(heights.map { Int($0.rounded()) }).count, 3, "Two modes size identically: \(heights)")
    }

    // MARK: - EditorMode

    func testEveryModeHasALabel() {
        XCTAssertEqual(EditorMode.preview.label, "Preview")
        XCTAssertEqual(EditorMode.reorder.label, "Reorder")
        XCTAssertEqual(EditorMode.captions.label, "Alt text")
        XCTAssertEqual(EditorMode.allCases.count, 3)
    }

    // MARK: - CellGeometry

    /// In the strip and the captions list the photo fills the square mask and
    /// overflows on its long edge; in the grid it fits inside instead.
    func testAWidePhotoOverflowsTheMaskInTheStripAndFitsItInTheGrid() {
        let wide: CGFloat = 3.0 / 2.0

        let strip = CellGeometry(mode: .preview, maskSide: 72, photoAspect: wide).photoSize
        XCTAssertEqual(strip.height, 72, accuracy: 0.01)
        XCTAssertGreaterThan(strip.width, 72)

        let grid = CellGeometry(mode: .reorder, maskSide: 72, photoAspect: wide).photoSize
        XCTAssertEqual(grid.width, 72, accuracy: 0.01)
        XCTAssertLessThan(grid.height, 72)
    }

    func testATallPhotoMirrorsTheWideCase() {
        let tall: CGFloat = 2.0 / 3.0

        let strip = CellGeometry(mode: .preview, maskSide: 72, photoAspect: tall).photoSize
        XCTAssertEqual(strip.width, 72, accuracy: 0.01)
        XCTAssertGreaterThan(strip.height, 72)

        let grid = CellGeometry(mode: .reorder, maskSide: 72, photoAspect: tall).photoSize
        XCTAssertEqual(grid.height, 72, accuracy: 0.01)
        XCTAssertLessThan(grid.width, 72)
    }

    func testASquarePhotoIsTheMaskInEveryMode() {
        for mode in EditorMode.allCases {
            let size = CellGeometry(mode: mode, maskSide: 72, photoAspect: 1).photoSize
            XCTAssertEqual(size.width, 72, accuracy: 0.01, "\(mode.label) stretched a square photo")
            XCTAssertEqual(size.height, 72, accuracy: 0.01, "\(mode.label) stretched a square photo")
        }
    }

    func testCaptionsUseTheSameFillAsTheStrip() {
        let wide: CGFloat = 3.0 / 2.0
        XCTAssertEqual(
            CellGeometry(mode: .captions, maskSide: 60, photoAspect: wide).photoSize,
            CellGeometry(mode: .preview, maskSide: 60, photoAspect: wide).photoSize
        )
    }

    /// The grid is edge-to-edge, so its cells square off while the others stay
    /// rounded.
    func testOnlyTheGridSquaresOffItsCorners() {
        XCTAssertEqual(CellGeometry(mode: .reorder, maskSide: 72, photoAspect: 1).maskCornerRadius, 0)
        XCTAssertEqual(CellGeometry(mode: .preview, maskSide: 72, photoAspect: 1).maskCornerRadius, 8)
        XCTAssertEqual(CellGeometry(mode: .captions, maskSide: 60, photoAspect: 1).maskCornerRadius, 8)
    }

    // MARK: - ReorderDragState

    private func draggingState(from index: Int) -> ReorderDragState {
        let state = ReorderDragState()
        state.beginDrag(itemID: UUID(), at: index)
        return state
    }

    private let cellStride = CGSize(width: 124, height: 124)

    func testBeginningADragRecordsWhereItStarted() {
        let state = draggingState(from: 2)

        XCTAssertTrue(state.isDragging)
        XCTAssertEqual(state.dragStartIndex, 2)
        XCTAssertEqual(state.dragCurrentIndex, 2)
    }

    /// A second gesture arriving mid-drag must not retarget the one in flight.
    func testASecondDragIsIgnoredWhileOneIsRunning() {
        let state = draggingState(from: 2)
        state.beginDrag(itemID: UUID(), at: 5)

        XCTAssertEqual(state.dragStartIndex, 2)
    }

    func testResettingClearsEverything() {
        let state = draggingState(from: 2)
        state.handleDragChanged(translation: CGSize(width: 124, height: 0), itemCount: 9, columnCount: 3, stride: cellStride)

        state.reset()

        XCTAssertFalse(state.isDragging)
        XCTAssertNil(state.dragStartIndex)
        XCTAssertNil(state.dragCurrentIndex)
        XCTAssertEqual(state.dragOffset, .zero)
    }

    func testDraggingOneCellRightProposesTheNextSlot() {
        let state = draggingState(from: 0)

        let proposed = state.handleDragChanged(
            translation: CGSize(width: 124, height: 0), itemCount: 9, columnCount: 3, stride: cellStride
        )

        XCTAssertEqual(proposed, 1)
        XCTAssertEqual(state.dragOffset.width, 124, accuracy: 0.01)
    }

    func testDraggingOneRowDownProposesTheSlotBelow() {
        let state = draggingState(from: 1)

        let proposed = state.handleDragChanged(
            translation: CGSize(width: 0, height: 124), itemCount: 9, columnCount: 3, stride: cellStride
        )

        XCTAssertEqual(proposed, 4)
    }

    /// A cell has to travel past the halfway point before the slot flips, or
    /// the grid reshuffles under the finger on every pixel of movement.
    func testASmallMovementProposesNothing() {
        let state = draggingState(from: 0)

        XCTAssertNil(state.handleDragChanged(
            translation: CGSize(width: 40, height: 0), itemCount: 9, columnCount: 3, stride: cellStride
        ))
    }

    /// Dragging off the left of column one must stay in column one rather than
    /// wrapping onto the previous row.
    func testDraggingPastTheLeftEdgeStaysInTheFirstColumn() {
        let state = draggingState(from: 3)

        let proposed = state.handleDragChanged(
            translation: CGSize(width: -500, height: 0), itemCount: 9, columnCount: 3, stride: cellStride
        )

        XCTAssertNil(proposed, "Clamping should leave the cell where it already is")
    }

    func testDraggingPastTheEndClampsToTheLastSlot() {
        let state = draggingState(from: 0)

        let proposed = state.handleDragChanged(
            translation: CGSize(width: 500, height: 900), itemCount: 5, columnCount: 3, stride: cellStride
        )

        XCTAssertEqual(proposed, 4)
    }

    func testDraggingPastTheTopClampsToTheFirstRow() {
        let state = draggingState(from: 4)

        let proposed = state.handleDragChanged(
            translation: CGSize(width: 0, height: -900), itemCount: 9, columnCount: 3, stride: cellStride
        )

        XCTAssertEqual(proposed, 1)
    }

    /// Called from a layout that may not have measured yet.
    func testADegenerateStrideProposesNothing() {
        let state = draggingState(from: 0)

        XCTAssertNil(state.handleDragChanged(
            translation: CGSize(width: 124, height: 0), itemCount: 9, columnCount: 3, stride: .zero
        ))
    }

    func testNoProposalWithoutADragInFlight() {
        let state = ReorderDragState()

        XCTAssertNil(state.handleDragChanged(
            translation: CGSize(width: 124, height: 0), itemCount: 9, columnCount: 3, stride: cellStride
        ))
        XCTAssertFalse(state.isDragging)
    }

    // MARK: - ReorderDragPlacement

    func testPlacementsCompareByBothIndices() {
        XCTAssertEqual(
            ReorderDragPlacement(draggedIndex: 1, currentIndex: 3),
            ReorderDragPlacement(draggedIndex: 1, currentIndex: 3)
        )
        XCTAssertNotEqual(
            ReorderDragPlacement(draggedIndex: 1, currentIndex: 3),
            ReorderDragPlacement(draggedIndex: 1, currentIndex: 4)
        )
    }
}