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

grain.social/grain-ios

1 collaborator
0
1
main
333 lines · 13.8 KB Raw History
@testable import Grain
import Nuke
import XCTest

final class ImagePrefetchPlanningTests: GrainTestCase {
    // MARK: - Helpers

    private func photo(index: Int) -> (thumb: String, fullsize: String) {
        (thumb: "https://cdn.example.com/thumb/\(index).jpg", fullsize: "https://cdn.example.com/full/\(index).jpg")
    }

    private func photos(_ count: Int) -> [(thumb: String, fullsize: String)] {
        (0 ..< count).map { photo(index: $0) }
    }

    private func urls(from requests: [ImageRequest]) -> [String] {
        requests.compactMap { $0.url?.absoluteString }
    }

    // MARK: - Carousel: Empty / Edge Cases

    func testCarousel_emptyPhotos_returnsEmpty() {
        let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: [], currentPage: 0)
        XCTAssertTrue(result.all.isEmpty)
    }

    func testCarousel_outOfBoundsPage_returnsEmpty() {
        let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(3), currentPage: 10)
        XCTAssertTrue(result.all.isEmpty)
    }

    func testCarousel_negativePage_returnsEmpty() {
        let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(3), currentPage: -1)
        XCTAssertTrue(result.all.isEmpty)
    }

    // MARK: - Carousel: Page 0

    func testCarousel_singlePhoto_page0_prefetchesOnlyThatFullsize() {
        let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(1), currentPage: 0)
        XCTAssertEqual(urls(from: result.high), ["https://cdn.example.com/full/0.jpg"])
        XCTAssertTrue(result.normal.isEmpty)
        XCTAssertTrue(result.low.isEmpty)
    }

    func testCarousel_twoPhotos_page0_prefetchesBothFullsize() {
        let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(2), currentPage: 0)
        XCTAssertEqual(urls(from: result.high), [
            "https://cdn.example.com/full/0.jpg",
            "https://cdn.example.com/full/1.jpg",
        ])
    }

    func testCarousel_fivePhotos_page0_prefetchesOnlyFirstTwo() {
        let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(5), currentPage: 0)
        XCTAssertEqual(urls(from: result.high).count, 2)
        XCTAssertTrue(result.normal.isEmpty)
    }

    // MARK: - Carousel: Page 1

    func testCarousel_threePhotos_page1_prefetchesThirdFullsizeAndAllThumbs() {
        let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(3), currentPage: 1)
        XCTAssertEqual(urls(from: result.high), ["https://cdn.example.com/full/2.jpg"])
        XCTAssertEqual(urls(from: result.normal).count, 3) // all 3 thumbs
        XCTAssertTrue(urls(from: result.normal).allSatisfy { $0.contains("/thumb/") })
    }

    func testCarousel_twoPhotos_page1_noThirdFullsize_stillPrefetchesThumbs() {
        let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(2), currentPage: 1)
        XCTAssertTrue(result.high.isEmpty)
        XCTAssertEqual(urls(from: result.normal).count, 2) // both thumbs
    }

    // MARK: - Carousel: Page 2+

    func testCarousel_tenPhotos_page5_prefetchesNextAtHighRestAtNormal() {
        let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(10), currentPage: 5)
        XCTAssertEqual(urls(from: result.high), ["https://cdn.example.com/full/6.jpg"])
        XCTAssertEqual(urls(from: result.normal).count, 3) // 7, 8, 9
    }

    func testCarousel_lastPage_returnsEmpty() {
        let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(3), currentPage: 2)
        XCTAssertTrue(result.high.isEmpty)
        XCTAssertTrue(result.normal.isEmpty)
    }

    // MARK: - Feed: Edge Cases

    func testFeed_emptyGalleries_returnsEmpty() {
        let result = ImagePrefetchPlanning.feedPrefetchRequests(galleries: [], currentIndex: 0)
        XCTAssertTrue(result.all.isEmpty)
    }

    func testFeed_atLastGallery_returnsEmpty() {
        let galleries: [(firstThumb: String?, firstFullsize: String?)] = [
            (firstThumb: "t0", firstFullsize: "f0"),
        ]
        let result = ImagePrefetchPlanning.feedPrefetchRequests(galleries: galleries, currentIndex: 0)
        XCTAssertTrue(result.all.isEmpty)
    }

    // MARK: - Feed: Look-ahead

    func testFeed_tenGalleries_atIndex3_prefetchesNext3() {
        let galleries: [(firstThumb: String?, firstFullsize: String?)] = (0 ..< 10).map { i in
            (firstThumb: "https://cdn.example.com/thumb/\(i).jpg",
             firstFullsize: "https://cdn.example.com/full/\(i).jpg")
        }
        let result = ImagePrefetchPlanning.feedPrefetchRequests(galleries: galleries, currentIndex: 3)

        // High: thumb 4, fullsize 4, thumb 5
        // Normal: fullsize 5
        // Low: fullsize 6, thumb 6
        let allUrls = urls(from: result.all)
        XCTAssertTrue(allUrls.contains("https://cdn.example.com/full/4.jpg"))
        XCTAssertTrue(allUrls.contains("https://cdn.example.com/full/5.jpg"))
        XCTAssertTrue(allUrls.contains("https://cdn.example.com/full/6.jpg"))
        XCTAssertTrue(allUrls.contains("https://cdn.example.com/thumb/4.jpg"))
    }

    func testFeed_galleryWithNoPhotos_skipsGracefully() {
        let galleries: [(firstThumb: String?, firstFullsize: String?)] = [
            (firstThumb: "t0", firstFullsize: "f0"),
            (firstThumb: nil, firstFullsize: nil), // gallery with no photos
            (firstThumb: "t2", firstFullsize: "f2"),
        ]
        let result = ImagePrefetchPlanning.feedPrefetchRequests(galleries: galleries, currentIndex: 0)
        // Should still get gallery at index 2
        let allUrls = urls(from: result.all)
        XCTAssertTrue(allUrls.contains("t2"))
        XCTAssertFalse(allUrls.contains("nil"))
    }

    // MARK: - Stories: Edge Cases

    func testStory_emptyCurrentStories_returnsEmpty() {
        let result = ImagePrefetchPlanning.storyPrefetchRequests(
            currentStories: [],
            currentStoryIndex: 0,
            nextAuthorStories: nil,
            secondNextAuthorStories: nil,
            thirdNextFirstStory: nil,
            fourthNextFirstStory: nil
        )
        XCTAssertTrue(result.all.isEmpty)
    }

    // MARK: - Stories: Priority Order

    func testStory_fiveStories_atIndex0_prefetchesCorrectPriorities() {
        let stories = photos(5)
        let nextAuthor = photos(3)

        let result = ImagePrefetchPlanning.storyPrefetchRequests(
            currentStories: stories,
            currentStoryIndex: 0,
            nextAuthorStories: nextAuthor,
            secondNextAuthorStories: nil,
            thirdNextFirstStory: nil,
            fourthNextFirstStory: nil
        )

        let highUrls = Set(urls(from: result.high))

        // High fullsizes: stories 1, 2 of current + first of next author
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/1.jpg"))
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/2.jpg"))
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/0.jpg"))

        // High thumbs: every reachable thumb (current + next author)
        for i in 0 ..< 5 {
            XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/\(i).jpg"))
        }

        // Normal: stories 3, 4 of current (rest of stack — fullsize only)
        XCTAssertEqual(urls(from: result.normal), [
            "https://cdn.example.com/full/3.jpg",
            "https://cdn.example.com/full/4.jpg",
        ])
    }

    func testStory_lastStoryOfAuthor_onlyPrefetchesNextAuthor() {
        let stories = photos(1)
        let nextAuthor = photos(2)

        let result = ImagePrefetchPlanning.storyPrefetchRequests(
            currentStories: stories,
            currentStoryIndex: 0,
            nextAuthorStories: nextAuthor,
            secondNextAuthorStories: nil,
            thirdNextFirstStory: nil,
            fourthNextFirstStory: nil
        )

        let highUrls = Set(urls(from: result.high))

        // High fullsize: only next author's first (no more current stories)
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/0.jpg"))
        XCTAssertFalse(highUrls.contains("https://cdn.example.com/full/1.jpg"))

        // High thumbs: current + next author thumbs
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/0.jpg"))
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/1.jpg"))
    }

    func testStory_noNextAuthorData_skipsThatTier() {
        let stories = photos(3)

        let result = ImagePrefetchPlanning.storyPrefetchRequests(
            currentStories: stories,
            currentStoryIndex: 0,
            nextAuthorStories: nil,
            secondNextAuthorStories: nil,
            thirdNextFirstStory: nil,
            fourthNextFirstStory: nil
        )

        let highUrls = Set(urls(from: result.high))

        // High fullsize: only current stories 1, 2 (no next author tier)
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/1.jpg"))
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/2.jpg"))
        // High thumbs: only current author's thumbs
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/0.jpg"))
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/1.jpg"))
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/2.jpg"))

        XCTAssertTrue(result.low.isEmpty)
    }

    func testStory_fullPriorityChain() {
        let current = photos(2)
        let nextAuthor = [(thumb: "https://next/t0", fullsize: "https://next/f0")]
        let secondNext = [(thumb: "https://second/t0", fullsize: "https://second/f0"),
                          (thumb: "https://second/t1", fullsize: "https://second/f1")]
        let third = (thumb: "https://third/t0", fullsize: "https://third/f0")
        let fourth = (thumb: "https://fourth/t0", fullsize: "https://fourth/f0")

        let result = ImagePrefetchPlanning.storyPrefetchRequests(
            currentStories: current,
            currentStoryIndex: 0,
            nextAuthorStories: nextAuthor,
            secondNextAuthorStories: secondNext,
            thirdNextFirstStory: third,
            fourthNextFirstStory: fourth
        )

        let highUrls = Set(urls(from: result.high))

        // High fullsizes: current story 1 + next author first
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/1.jpg"))
        XCTAssertTrue(highUrls.contains("https://next/f0"))
        // High thumbs: every reachable thumb at every tier
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/0.jpg"))
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/1.jpg"))
        XCTAssertTrue(highUrls.contains("https://next/t0"))
        XCTAssertTrue(highUrls.contains("https://second/t0"))
        XCTAssertTrue(highUrls.contains("https://second/t1"))
        XCTAssertTrue(highUrls.contains("https://third/t0"))
        XCTAssertTrue(highUrls.contains("https://fourth/t0"))

        // Normal: second-next author stories 0, 1 (fullsize only)
        XCTAssertEqual(urls(from: result.normal).count, 2)
        // Low: third + fourth author firsts (fullsize only)
        XCTAssertEqual(urls(from: result.low).count, 2)
    }

    // MARK: - Priority correctness

    func testCarousel_page0_allRequestsAreHighPriority() {
        let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(5), currentPage: 0)
        XCTAssertTrue(result.high.allSatisfy { $0.priority == .high })
        XCTAssertTrue(result.normal.isEmpty)
        XCTAssertTrue(result.low.isEmpty)
    }

    func testCarousel_page1_thumbsAreNormalPriority() {
        let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(5), currentPage: 1)
        XCTAssertTrue(result.high.allSatisfy { $0.priority == .high })
        XCTAssertTrue(result.normal.allSatisfy { $0.priority == .normal })
    }

    func testFeed_priorityDescentAcrossOffsets() {
        let galleries: [(firstThumb: String?, firstFullsize: String?)] = (0 ..< 10).map { i in
            (firstThumb: "https://cdn.example.com/thumb/\(i).jpg",
             firstFullsize: "https://cdn.example.com/full/\(i).jpg")
        }
        let result = ImagePrefetchPlanning.feedPrefetchRequests(galleries: galleries, currentIndex: 3)
        XCTAssertTrue(urls(from: result.high).contains("https://cdn.example.com/full/4.jpg"))
        XCTAssertTrue(urls(from: result.normal).contains("https://cdn.example.com/full/5.jpg"))
        XCTAssertTrue(urls(from: result.low).contains("https://cdn.example.com/full/6.jpg"))
    }

    // MARK: - Story boundary: currentStoryIndex at end

    func testStory_atLastIndex_noCurrentPrefetchButNextAuthorStill() {
        let stories = photos(3)
        let nextAuthor = photos(2)

        let result = ImagePrefetchPlanning.storyPrefetchRequests(
            currentStories: stories,
            currentStoryIndex: 2,
            nextAuthorStories: nextAuthor,
            secondNextAuthorStories: nil,
            thirdNextFirstStory: nil,
            fourthNextFirstStory: nil
        )

        let highUrls = urls(from: result.high)
        XCTAssertFalse(highUrls.contains("https://cdn.example.com/full/3.jpg"))
        XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/0.jpg"))
    }

    // MARK: - No duplicate URLs

    func testCarousel_page0_noDuplicateURLs() {
        let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(5), currentPage: 0)
        let allUrls = urls(from: result.all)
        XCTAssertEqual(allUrls.count, Set(allUrls).count)
    }

    func testFeed_noDuplicateURLs() {
        let galleries: [(firstThumb: String?, firstFullsize: String?)] = (0 ..< 10).map { i in
            (firstThumb: "https://cdn.example.com/thumb/\(i).jpg",
             firstFullsize: "https://cdn.example.com/full/\(i).jpg")
        }
        let result = ImagePrefetchPlanning.feedPrefetchRequests(galleries: galleries, currentIndex: 3)
        let allUrls = urls(from: result.all)
        XCTAssertEqual(allUrls.count, Set(allUrls).count)
    }
}