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

grain.social/grain-ios

1 collaborator
0
1
main
236 lines · 7.8 KB Raw History
@testable import Grain
import SwiftUI
import XCTest

/// What advances a story. Five seconds per story, ticking twenty times a
/// second, with a pause that has to resume where it left off rather than start
/// over — holding a finger down mid-story and letting go shouldn't rewind it.
@MainActor
final class StoryTimerTests: GrainTestCase {
    /// A story that runs in a fifth of a second rather than five. The ticker
    /// works in fractions of the duration, so every assertion below holds at
    /// either speed — it just isn't worth a minute of the suite's time to watch
    /// real stories play out.
    private func makeTimer(duration: TimeInterval = 0.2) -> StoryTimer {
        StoryTimer(duration: duration)
    }

    /// Pump the run loop so the timer's task gets to tick.
    private func advance(_ seconds: TimeInterval) {
        RunLoop.current.run(until: Date().addingTimeInterval(seconds))
    }

    private func waitUntil(_ condition: () -> Bool, timeout: TimeInterval = 5) -> Bool {
        let deadline = Date().addingTimeInterval(timeout)
        while Date() < deadline {
            if condition() {
                return true
            }
            advance(0.01)
        }
        return condition()
    }

    func testATimerStartsIdleAtZero() {
        let timer = makeTimer()

        XCTAssertEqual(timer.progress, 0)
        XCTAssertFalse(timer.isRunning)
    }

    func testStartingRunsTheProgressForward() {
        let timer = makeTimer(duration: 1)
        defer { timer.stop() }

        timer.start()

        XCTAssertTrue(timer.isRunning)
        XCTAssertTrue(waitUntil { timer.progress > 0.1 }, "The bar never moved")
    }

    /// The story advances when the bar fills, so this is what pages the viewer.
    func testItReportsCompletionWhenTheBarFills() {
        let timer = makeTimer()
        defer { timer.stop() }
        var completed = false
        timer.onComplete = { completed = true }

        timer.start()

        XCTAssertTrue(waitUntil({ completed }, timeout: 30), "The story never finished")
        XCTAssertEqual(timer.progress, 1, accuracy: 0.01)
        XCTAssertFalse(timer.isRunning)
    }

    /// Fires once, early, so a story counts as viewed almost immediately rather
    /// than only if you sit through all five seconds.
    func testItReportsTheViewOnceShortlyAfterStarting() {
        let timer = makeTimer()
        defer { timer.stop() }
        var quarterCount = 0
        timer.onQuarter = { quarterCount += 1 }

        timer.start()

        XCTAssertTrue(waitUntil { quarterCount > 0 }, "The story was never marked viewed")
        _ = waitUntil({ timer.progress > 0.5 }, timeout: 5)
        XCTAssertEqual(quarterCount, 1, "Marking viewed more than once would re-post the read receipt")
    }

    /// Holding a finger on a story pauses it; letting go resumes from where it
    /// stopped, not from the beginning.
    func testResumingPicksUpWhereItStopped() {
        let timer = makeTimer(duration: 1)
        defer { timer.stop() }

        timer.start()
        XCTAssertTrue(waitUntil { timer.progress > 0.2 })
        timer.stop()
        let paused = timer.progress
        XCTAssertFalse(timer.isRunning)

        timer.resume()

        XCTAssertTrue(timer.isRunning)
        XCTAssertGreaterThanOrEqual(timer.progress, paused - 0.01, "Resuming rewound the story")
    }

    /// Touch-down and touch-up can arrive in a burst; resuming something that's
    /// already running must not start a second ticker.
    func testResumingSomethingAlreadyRunningIsANoOp() {
        // A longer story and a generous timeout: this one waits for a real
        // completion rather than a state flip, so it is the one test here that
        // a loaded machine can starve.
        let timer = makeTimer(duration: 0.5)
        defer { timer.stop() }
        var completions = 0
        timer.onComplete = { completions += 1 }

        timer.start()
        timer.resume()
        timer.resume()

        XCTAssertTrue(waitUntil({ completions > 0 }, timeout: 30), "The story never finished")
        advance(0.3)
        XCTAssertEqual(completions, 1, "A second ticker would advance the story twice")
    }

    /// Resuming a story that already ran out restarts it rather than sitting
    /// finished — that's what re-opening a watched story does.
    func testResumingAFinishedStoryStartsItOver() {
        let timer = makeTimer()
        defer { timer.stop() }
        timer.progress = 1

        timer.resume()

        XCTAssertTrue(timer.isRunning)
        XCTAssertLessThan(timer.progress, 1)
    }

    /// Paging away has to stop the outgoing story, or two stories advance at once.
    func testStoppingHaltsProgressAndReportsNothing() {
        let timer = makeTimer(duration: 1)
        var completed = false
        timer.onComplete = { completed = true }

        timer.start()
        XCTAssertTrue(waitUntil { timer.progress > 0.1 })
        timer.stop()
        let stopped = timer.progress

        advance(0.3)

        XCTAssertFalse(timer.isRunning)
        XCTAssertEqual(timer.progress, stopped, accuracy: 0.001, "The bar kept moving after being stopped")
        XCTAssertFalse(completed)
    }

    func testStoppingSomethingThatNeverStartedIsHarmless() {
        let timer = makeTimer()

        timer.stop()
        timer.stop()

        XCTAssertFalse(timer.isRunning)
        XCTAssertEqual(timer.progress, 0)
    }

    /// Paging back to a story restarts its bar from zero.
    func testStartingAgainResetsTheBar() {
        let timer = makeTimer(duration: 1)
        defer { timer.stop() }

        timer.start()
        XCTAssertTrue(waitUntil { timer.progress > 0.2 })

        timer.start()

        XCTAssertLessThan(timer.progress, 0.2)
    }

    /// The view marks a story viewed off `onQuarter`, so a restart has to arm
    /// it again for the next story.
    func testRestartingArmsTheViewReportAgain() {
        let timer = makeTimer()
        defer { timer.stop() }
        var quarterCount = 0
        timer.onQuarter = { quarterCount += 1 }

        timer.start()
        XCTAssertTrue(waitUntil { quarterCount == 1 })

        timer.start()

        XCTAssertTrue(waitUntil { quarterCount == 2 }, "The next story would never be marked viewed")
    }

    // MARK: - Progress bars

    /// One bar per story, filled up to the one playing.
    func testRendersTheProgressBars() {
        let timer = makeTimer()
        defer { timer.stop() }
        let stories = (0 ..< 4).map { index in
            GrainStory(
                uri: "at://did:plc:test/social.grain.story/\(index)",
                cid: "bafy\(index)",
                creator: GrainProfile(cid: "c", did: "did:plc:test", handle: "tester.test"),
                thumb: "https://test.local/t.jpg",
                fullsize: "https://test.local/f.jpg",
                aspectRatio: AspectRatio(width: 3, height: 4),
                createdAt: Fixtures.freshTimestamp
            )
        }

        for index in [0, 2, 3] {
            ViewRender.render(
                StoryProgressBars(timer: timer, stories: stories, currentStoryIndex: index),
                settle: 0
            )
        }
    }

    /// Before the stories land the bars are drawn from the author's count, so
    /// the header doesn't pop in a beat later than everything else.
    func testRendersPlaceholderBarsBeforeTheStoriesArrive() {
        let timer = makeTimer()
        defer { timer.stop() }

        ViewRender.render(
            StoryProgressBars(timer: timer, stories: [], currentStoryIndex: 0, placeholderCount: 3),
            settle: 0
        )
    }

    /// Nothing known at all — no stories and no count — still has to lay out.
    func testRendersNoBarsWithNothingToShow() {
        let timer = makeTimer()
        defer { timer.stop() }

        ViewRender.render(
            StoryProgressBars(timer: timer, stories: [], currentStoryIndex: 0),
            settle: 0
        )
    }
}