//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import XCTest

@testable import Signal
@testable import SignalServiceKit

private extension Date {
    /// Initialize a date using a compressed notation: `Date(compressedDate: 2022_04_28)`
    init(compressedDate: UInt32, calendar: Calendar = .current) {
        let day = Int(compressedDate % 100)
        assert((1...31).contains(day))
        let month = Int((compressedDate / 100) % 100)
        assert((1...12).contains(month))
        let year = Int(compressedDate / 1_00_00)
        assert((1970...).contains(year))
        self = calendar.date(from: DateComponents(year: year, month: month, day: day))!
    }
}

private extension GalleryDate {
    /// Initialize a GalleryDate using a compressed notation: `GalleryDate(2022_04_28)`.
    ///
    /// Note that GalleryDates represent intervals; the above example will produce a GalleryDate for all of April 2022.
    init(_ compressedDate: UInt32) {
        self.init(date: Date(compressedDate: compressedDate))
    }
}

private extension MediaGallerySections {
    /// A functional version of the normal, inout-based API. More convenient for testing.
    func trimLoadedItemsAtStart(from naiveRange: Range<Int>, relativeToSection sectionIndex: Int) -> Range<Int> {
        var result = naiveRange
        trimLoadedItemsAtStart(from: &result, relativeToSection: sectionIndex)
        return result
    }

    /// A functional version of the normal, inout-based API. More convenient for testing.
    func trimLoadedItemsAtEnd(from naiveRange: Range<Int>, relativeToSection sectionIndex: Int) -> Range<Int> {
        var result = naiveRange
        trimLoadedItemsAtEnd(from: &result, relativeToSection: sectionIndex)
        return result
    }
}

// MARK: -

private struct FakeItem: MediaGallerySectionItem, Equatable {
    private static var _nextRowID = Int64(0)
    private static func allocateItemID() -> MediaGalleryItemId {
        defer { _nextRowID += 1 }
        return .legacy(mediaGalleryRecordId: _nextRowID)
    }
    var itemId: MediaGalleryItemId
    var attachmentId: MediaGalleryResourceId
    var timestamp: Date

    var galleryDate: GalleryDate { GalleryDate(date: timestamp) }

    /// Generates an item with the given timestamp in compressed notation: `FakeItem(2022_04_28)`
    ///
    /// The item's unique ID will be randomly generated.
    init(_ compressedDate: UInt32) {
        self.itemId = FakeItem.allocateItemID()
        self.attachmentId = .legacy(attachmentUniqueId: UUID().uuidString)
        self.timestamp = Date(compressedDate: compressedDate)
    }

    init(_ compressedDate: UInt32, attachmentId: MediaGalleryResourceId?, itemId: MediaGalleryItemId) {
        self.itemId = itemId
        self.attachmentId = attachmentId ?? .legacy(attachmentUniqueId: UUID().uuidString)
        self.timestamp = Date(compressedDate: compressedDate)
    }
}

/// Takes the place of the database for MediaGallerySection tests.
private final class FakeGalleryStore: MediaGallerySectionLoader {
    func rowIdsAndDatesOfItemsInSection(
        for date: GalleryDate,
        offset: Int,
        ascending: Bool,
        transaction: SignalServiceKit.SDSAnyReadTransaction
    ) -> [DatedMediaGalleryItemId] {
        guard let items = itemsBySection[date] else {
            return []
        }
        let sortedItems = ascending ? items : items.reversed()
        return sortedItems[offset...].map {
            DatedMediaGalleryItemId(
                id: $0.itemId,
                receivedAtTimestamp: $0.timestamp.ows_millisecondsSince1970
            )
        }
    }

    typealias Item = FakeItem
    typealias EnumerationCompletion = MediaGalleryResourceFinder.EnumerationCompletion

    var allItems: [Item]
    var itemsBySection: OrderedDictionary<GalleryDate, [Item]>
    var mostRecentRequest: Range<Int> = 0..<0

    /// Sorts and groups the given items for traversal as a flat array (`allItems`)
    /// as well as by section (`itemsBySection`).
    init(_ items: [Item]) {
        self.allItems = items.sorted { $0.timestamp < $1.timestamp }
        let itemsBySection = Dictionary(grouping: allItems) { $0.galleryDate }
        self.itemsBySection = OrderedDictionary(keyValueMap: itemsBySection, orderedKeys: itemsBySection.keys.sorted())
    }

    func set(items: [Item]) {
        self.allItems = items.sorted { $0.timestamp < $1.timestamp }
        let itemsBySection = Dictionary(grouping: allItems) { $0.galleryDate }
        self.itemsBySection = OrderedDictionary(keyValueMap: itemsBySection, orderedKeys: itemsBySection.keys.sorted())
    }

    func clone() -> Self {
        return Self(self.allItems)
    }

    private func numberOfItemsInSection(for date: GalleryDate) -> Int {
        itemsBySection[date]?.count ?? 0
    }

    /// Iterates over `items` and calls `block` up to `count` times.
    ///
    /// Items with IDs matching `deletedAttachmentIds` are skipped and do not count against `count`.
    /// Returns `finished` if `count` items were visited and `reachedEnd` if there were not enough items to visit.
    private static func enumerate<Items>(_ items: Items, count: Int, visit: (Item) -> Void) -> EnumerationCompletion
    where Items: Collection, Items.Element == Item {
        items.prefix(count).forEach(visit)
        if items.count < count {
            return .reachedEnd
        }
        return .finished
    }

    func enumerateTimestamps(
        before date: Date,
        count: Int,
        transaction: SDSAnyReadTransaction,
        block: (DatedMediaGalleryItemId) -> Void
    ) -> EnumerationCompletion {
        // It would be more efficient to binary search here, but this is for testing.
        let itemsInRange = allItems.reversed().drop { $0.timestamp >= date }
        return Self.enumerate(itemsInRange, count: count) {
            block(.init(id: $0.itemId, receivedAtTimestamp: $0.timestamp.ows_millisecondsSince1970))
        }
    }

    func enumerateTimestamps(
        after date: Date,
        count: Int,
        transaction: SDSAnyReadTransaction,
        block: (DatedMediaGalleryItemId) -> Void
    ) -> EnumerationCompletion {
        // It would be more efficient to binary search here, but this is for testing.
        let itemsInRange = allItems.drop { $0.timestamp < date }
        return Self.enumerate(itemsInRange, count: count) {
            block(.init(id: $0.itemId, receivedAtTimestamp: $0.timestamp.ows_millisecondsSince1970))
        }
    }

    func enumerateItems(
        in interval: DateInterval,
        range: Range<Int>,
        transaction: SDSAnyReadTransaction,
        block: (_ offset: Int, _ attachmentId: MediaGalleryResourceId, _ buildItem: () -> Item) -> Void
    ) {
        // It would be more efficient to binary search here, but this is for testing.
        // DateInterval is usually a *closed* range, but we're using it as a half-open one here.
        let itemsInInterval = allItems.drop { $0.timestamp < interval.start }.prefix { $0.timestamp < interval.end }
        let itemsInRange = itemsInInterval.dropFirst(range.startIndex).prefix(range.count)
        mostRecentRequest = itemsInRange.indices
        for (offset, item) in zip(range, itemsInRange) {
            block(offset, item.attachmentId, { item })
        }
    }
}

/// A store initialized with items in Jan, Apr, and Sep 2021.
private let standardFakeStore: FakeGalleryStore = FakeGalleryStore([
    2021_01_01,  // rowid 0
    2021_01_02,  // rowid 1
    2021_01_20,  // rowid 2

    2021_04_01,  // rowid 3
    2021_04_13,  // rowid 4

    2021_09_09,  // rowid 5
    2021_09_09,  // rowid 6
    2021_09_09,  // rowid 7
    2021_09_30,  // rowid 8
    2021_09_30  // rowid 9
].map { FakeItem($0) })

// Before we test the actual MediaGallerySections, make sure our fake store is behaving as expected.
class MediaGallerySectionsFakeStoreTest: SignalBaseTest {
    func testFakeItem() {
        let item1 = FakeItem(1970_01_01)
        XCTAssertEqual(Calendar.current.dateComponents([.year, .month, .day], from: item1.timestamp),
                       DateComponents(year: 1970, month: 1, day: 1))

        let item2 = FakeItem(2021_04_28)
        XCTAssertEqual(Calendar.current.dateComponents([.year, .month, .day], from: item2.timestamp),
                       DateComponents(year: 2021, month: 4, day: 28))
        XCTAssertNotEqual(item1.attachmentId, item2.attachmentId)
    }

    func testNumberOfItemsInSection() {
        let store = standardFakeStore

        databaseStorage.read { transaction in
            XCTAssertEqual(3,
                           store.rowIdsAndDatesOfItemsInSection(
                            for: GalleryDate(2021_01_01),
                            offset: 0,
                            ascending: true,
                            transaction: transaction).count)
            XCTAssertEqual(0,
                           store.rowIdsAndDatesOfItemsInSection(
                            for: GalleryDate(2021_02_01),
                            offset: 0,
                            ascending: true,
                            transaction: transaction).count)
            XCTAssertEqual(2,
                           store.rowIdsAndDatesOfItemsInSection(
                            for: GalleryDate(2021_04_01),
                            offset: 0,
                            ascending: true,
                            transaction: transaction).count)
            XCTAssertEqual(5,
                           store.rowIdsAndDatesOfItemsInSection(
                            for: GalleryDate(2021_09_01),
                            offset: 0,
                            ascending: true,
                            transaction: transaction).count)
        }
    }

    func testEnumerateAfter() {
        let store = standardFakeStore

        databaseStorage.read { transaction in
            var results: [Date] = []
            XCTAssertEqual(
                .finished,
                store.enumerateTimestamps(
                    after: .distantPast,
                    count: 4,
                    transaction: transaction
                ) { item in
                    results.append(item.date)
                }
            )
            XCTAssertEqual(results, store.allItems.prefix(4).map { $0.timestamp })

            results.removeAll()
            XCTAssertEqual(
                .finished,
                store.enumerateTimestamps(
                    after: .distantPast,
                    count: 10,
                    transaction: transaction
                ) { item in
                    results.append(item.date)
                }
            )
            XCTAssertEqual(results, store.allItems.map { $0.timestamp })

            results.removeAll()
            XCTAssertEqual(
                .reachedEnd,
                store.enumerateTimestamps(
                    after: .distantPast,
                    count: 11,
                    transaction: transaction
                ) { item in
                    results.append(item.date)
                }
            )
            XCTAssertEqual(results, store.allItems.map { $0.timestamp })

            results.removeAll()
            XCTAssertEqual(
                .finished,
                store.enumerateTimestamps(
                    after: Date(compressedDate: 2021_04_01),
                    count: 3,
                    transaction: transaction
                ) { item in
                    results.append(item.date)
                }
            )
            XCTAssertEqual(results, store.allItems[3..<6].map { $0.timestamp })

            results.removeAll()
            XCTAssertEqual(
                .reachedEnd,
                store.enumerateTimestamps(
                    after: Date(compressedDate: 2021_04_01),
                    count: 17,
                    transaction: transaction
                ) { item in
                    results.append(item.date)
                }
            )
            XCTAssertEqual(results, store.allItems[3...].map { $0.timestamp })

            results.removeAll()
            XCTAssertEqual(
                .reachedEnd,
                store.enumerateTimestamps(
                    after: Date(compressedDate: 2022_04_01),
                    count: 17,
                    transaction: transaction
                ) { item in
                    results.append(item.date)
                }
            )
            XCTAssertEqual(results, [])

            results.removeAll()
            XCTAssertEqual(
                .finished,
                store.enumerateTimestamps(
                    after: Date(compressedDate: 2022_04_01),
                    count: 0,
                    transaction: transaction
                ) { item in
                    results.append(item.date)
                }
            )
            XCTAssertEqual(results, [])
        }
    }

    func testEnumerateBefore() {
        let store = standardFakeStore

        databaseStorage.read { transaction in
            var results: [Date] = []
            XCTAssertEqual(
                .finished,
                store.enumerateTimestamps(
                    before: .distantFuture,
                    count: 4,
                    transaction: transaction
                ) { item in
                    results.append(item.date)
                }
            )
            XCTAssertEqual(results, store.allItems.reversed().prefix(4).map { $0.timestamp })

            results.removeAll()
            XCTAssertEqual(
                .finished,
                store.enumerateTimestamps(
                    before: .distantFuture,
                    count: 10,
                    transaction: transaction
                ) { item in
                    results.append(item.date)
                }
            )
            XCTAssertEqual(results, store.allItems.reversed().map { $0.timestamp })

            results.removeAll()
            XCTAssertEqual(
                .reachedEnd,
                store.enumerateTimestamps(
                    before: .distantFuture,
                    count: 11,
                    transaction: transaction
                ) { item in
                    results.append(item.date)
                }
            )
            XCTAssertEqual(results, store.allItems.reversed().map { $0.timestamp })

            results.removeAll()
            XCTAssertEqual(
                .finished,

                store.enumerateTimestamps(
                    before: Date(compressedDate: 2021_04_01),
                    count: 2,
                    transaction: transaction
                ) { item in
                    results.append(item.date)
                }
            )
            XCTAssertEqual(results, store.allItems[1..<3].reversed().map { $0.timestamp })

            results.removeAll()
            XCTAssertEqual(
                .reachedEnd,
                store.enumerateTimestamps(
                    before: Date(compressedDate: 2021_04_01),
                    count: 17,
                    transaction: transaction
                ) { item in
                    results.append(item.date)
                }
            )
            XCTAssertEqual(results, store.allItems[..<3].reversed().map { $0.timestamp })

            results.removeAll()
            XCTAssertEqual(
                .reachedEnd,
                store.enumerateTimestamps(
                    before: Date(compressedDate: 2020_01_01),
                    count: 17,
                    transaction: transaction
                ) { item in
                    results.append(item.date)
                }
            )
            XCTAssertEqual(results, [])
        }
    }

    func testEnumerateItems() {
        let store = standardFakeStore

        databaseStorage.read { transaction in
            var results: [MediaGalleryResourceId] = []
            let saveToResults = { (offset: Int, attachmentId: MediaGalleryResourceId, buildItem: () -> FakeItem) in
                results.append(attachmentId)
                XCTAssertEqual(attachmentId, buildItem().attachmentId)
            }

            store.enumerateItems(in: GalleryDate(2021_01_01).interval,
                                 range: 1..<3,
                                 transaction: transaction,
                                 block: saveToResults)
            XCTAssertEqual(store.allItems[1..<3].map { $0.attachmentId }, results)

            results.removeAll()
            store.enumerateItems(in: GalleryDate(2021_09_01).interval,
                                 range: 2..<4,
                                 transaction: transaction,
                                 block: saveToResults)
            XCTAssertEqual(store.allItems[7..<9].map { $0.attachmentId }, results)

            results.removeAll()
            store.enumerateItems(in: GalleryDate(2021_09_01).interval,
                                 range: 0..<20,
                                 transaction: transaction,
                                 block: saveToResults)
            XCTAssertEqual(store.allItems[5...].map { $0.attachmentId }, results)

            results.removeAll()
            store.enumerateItems(in: GalleryDate(2021_10_01).interval,
                                 range: 0..<1,
                                 transaction: transaction,
                                 block: saveToResults)
            XCTAssertEqual([], results)

            results.removeAll()
            store.enumerateItems(in: GalleryDate(2021_09_01).interval,
                                 range: 2..<2,
                                 transaction: transaction,
                                 block: saveToResults)
            XCTAssertEqual([], results)

            results.removeAll()
            store.enumerateItems(in: GalleryDate(2021_09_01).interval,
                                 range: 20..<25,
                                 transaction: transaction,
                                 block: saveToResults)
            XCTAssertEqual([], results)
        }
    }
}

class MediaGallerySectionsTest: SignalBaseTest {
    fileprivate typealias Sections = MediaGallerySections<FakeGalleryStore, Int>
    fileprivate struct SectionsWrapper {
        private(set) var sections: Sections
        var userData: [Int] = []
        mutating func mutate<T>(_ closure: (inout Sections) -> (T)) -> T {
            let result = closure(&sections)
            sections.accessPendingUpdate { pendingUpdate in
                userData.append(contentsOf: pendingUpdate.userData)
                _ = pendingUpdate.commit()
            }
            return result
        }
    }

    func testLoadSectionsBackward() {
        var wrapper = SectionsWrapper(sections: Sections(loader: standardFakeStore))
        XCTAssertEqual(wrapper.sections.itemsBySection.count, 0)
        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)

        wrapper.mutate { sections in
            XCTAssertEqual(1, sections.loadEarlierSections(batchSize: 4))
        }
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([GalleryDate(2021_09_01)], wrapper.sections.itemsBySection.orderedKeys)
        XCTAssertEqual([5], wrapper.sections.itemsBySection.orderedValues.map { $0.count })
        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)

        wrapper.mutate { sections in
            XCTAssertEqual(2, sections.loadEarlierSections(batchSize: 4))
        }
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([GalleryDate(2021_01_01), GalleryDate(2021_04_01), GalleryDate(2021_09_01)],
                       wrapper.sections.itemsBySection.orderedKeys)
        XCTAssertEqual([3, 2, 5], wrapper.sections.itemsBySection.orderedValues.map { $0.count })
        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)

        wrapper.mutate { sections in
            XCTAssertEqual(0, sections.loadEarlierSections(batchSize: 4))
        }
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([GalleryDate(2021_01_01), GalleryDate(2021_04_01), GalleryDate(2021_09_01)],
                       wrapper.sections.itemsBySection.orderedKeys)
        XCTAssertEqual([3, 2, 5], wrapper.sections.itemsBySection.orderedValues.map { $0.count })
        XCTAssertTrue(wrapper.sections.hasFetchedOldest)
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
    }

    func testLoadSectionsForward() {
        var wrapper = SectionsWrapper(sections: Sections(loader: standardFakeStore))
        XCTAssertEqual(wrapper.sections.itemsBySection.count, 0)
        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)

        wrapper.mutate { sections in
            XCTAssertEqual(2, sections.loadLaterSections(batchSize: 4))
        }
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([GalleryDate(2021_01_01), GalleryDate(2021_04_01)], wrapper.sections.itemsBySection.orderedKeys)
        XCTAssertEqual([3, 2], wrapper.sections.itemsBySection.orderedValues.map { $0.count })
        XCTAssertTrue(wrapper.sections.hasFetchedOldest)
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)

        wrapper.mutate { sections in
            XCTAssertEqual(1, sections.loadLaterSections(batchSize: 4))
        }
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([GalleryDate(2021_01_01), GalleryDate(2021_04_01), GalleryDate(2021_09_01)],
                       wrapper.sections.itemsBySection.orderedKeys)
        XCTAssertEqual([3, 2, 5], wrapper.sections.itemsBySection.orderedValues.map { $0.count })
        XCTAssertTrue(wrapper.sections.hasFetchedOldest)
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)

        wrapper.mutate { sections in
            XCTAssertEqual(0, sections.loadLaterSections(batchSize: 4))
        }
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([GalleryDate(2021_01_01), GalleryDate(2021_04_01), GalleryDate(2021_09_01)],
                       wrapper.sections.itemsBySection.orderedKeys)
        XCTAssertEqual([3, 2, 5], wrapper.sections.itemsBySection.orderedValues.map { $0.count })
        XCTAssertTrue(wrapper.sections.hasFetchedOldest)
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
    }

    func testStartIndexResolution() {
        var wrapper = SectionsWrapper(sections: Sections(loader: standardFakeStore))
        // Load April and September
        XCTAssertEqual(2, wrapper.mutate { sections in sections.loadEarlierSections(batchSize: 6) })
        XCTAssertFalse(wrapper.sections.hasFetchedOldest)

        XCTAssertEqual(MediaGalleryIndexPath(item: 0, section: 1),
                       wrapper.sections.resolveNaiveStartIndex(0, relativeToSection: 1))
        XCTAssertEqual(MediaGalleryIndexPath(item: 4, section: 1),
                       wrapper.sections.resolveNaiveStartIndex(4, relativeToSection: 1))
        XCTAssertEqual(MediaGalleryIndexPath(item: 5, section: 1),
                       wrapper.sections.resolveNaiveStartIndex(5, relativeToSection: 1))

        XCTAssertEqual(MediaGalleryIndexPath(item: 1, section: 0),
                       wrapper.sections.resolveNaiveStartIndex(-1, relativeToSection: 1))
        XCTAssertEqual(MediaGalleryIndexPath(item: 0, section: 0),
                       wrapper.sections.resolveNaiveStartIndex(-2, relativeToSection: 1))
        XCTAssertNil(wrapper.sections.resolveNaiveStartIndex(-3, relativeToSection: 1))

        // Load January
        do {
            let actual = wrapper.mutate { sections in
                sections.resolveNaiveStartIndex(-3, relativeToSection: 1, batchSize: 1)
            }
            XCTAssertEqual(MediaGalleryIndexPath(item: 2, section: 0), actual.path)
            XCTAssertEqual(1, actual.numberOfSectionsLoaded)
        }
        XCTAssertFalse(wrapper.sections.hasFetchedOldest)

        XCTAssertEqual(MediaGalleryIndexPath(item: 2, section: 0),
                       wrapper.sections.resolveNaiveStartIndex(-3, relativeToSection: 2))
        XCTAssertEqual(MediaGalleryIndexPath(item: 0, section: 0),
                       wrapper.sections.resolveNaiveStartIndex(-5, relativeToSection: 2))
        XCTAssertNil(wrapper.sections.resolveNaiveStartIndex(-6, relativeToSection: 2))

        // Find out that January was the earliest section.
        do {
            let actual = wrapper.mutate { sections in
                sections.resolveNaiveStartIndex(-6, relativeToSection: 2, batchSize: 1)
            }
            XCTAssertEqual(MediaGalleryIndexPath(item: 0, section: 0), actual.0)
            XCTAssertEqual(0, actual.1)
        }

        XCTAssertTrue(wrapper.sections.hasFetchedOldest)

        XCTAssertEqual(MediaGalleryIndexPath(item: 0, section: 0),
                       wrapper.sections.resolveNaiveStartIndex(-6, relativeToSection: 2))
    }

    func testEndIndexResolution() {
        var wrapper = SectionsWrapper(sections: Sections(loader: standardFakeStore))
        // Load January and April
        XCTAssertEqual(2, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 4) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)

        XCTAssertEqual(MediaGalleryIndexPath(item: 0, section: 0),
                       wrapper.sections.resolveNaiveEndIndex(0, relativeToSection: 0))
        XCTAssertEqual(MediaGalleryIndexPath(item: 2, section: 0),
                       wrapper.sections.resolveNaiveEndIndex(2, relativeToSection: 0))
        // Note: (0, 3) rather than (1, 0), because this is an end index.
        XCTAssertEqual(MediaGalleryIndexPath(item: 3, section: 0),
                       wrapper.sections.resolveNaiveEndIndex(3, relativeToSection: 0))
        XCTAssertEqual(MediaGalleryIndexPath(item: 1, section: 1),
                       wrapper.sections.resolveNaiveEndIndex(4, relativeToSection: 0))
        // Note: (1, 2) rather than nil.
        XCTAssertEqual(MediaGalleryIndexPath(item: 2, section: 1),
                       wrapper.sections.resolveNaiveEndIndex(5, relativeToSection: 0))
        XCTAssertNil(wrapper.sections.resolveNaiveEndIndex(6, relativeToSection: 0))

        // Load September
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 20) })
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)

        XCTAssertEqual(MediaGalleryIndexPath(item: 2, section: 1),
                       wrapper.sections.resolveNaiveEndIndex(5, relativeToSection: 0))
        XCTAssertEqual(MediaGalleryIndexPath(item: 1, section: 2),
                       wrapper.sections.resolveNaiveEndIndex(6, relativeToSection: 0))
        XCTAssertEqual(MediaGalleryIndexPath(item: 4, section: 2),
                       wrapper.sections.resolveNaiveEndIndex(9, relativeToSection: 0))
        XCTAssertEqual(MediaGalleryIndexPath(item: 5, section: 2),
                       wrapper.sections.resolveNaiveEndIndex(10, relativeToSection: 0))
        // Reached end.
        XCTAssertEqual(MediaGalleryIndexPath(item: 5, section: 2),
                       wrapper.sections.resolveNaiveEndIndex(11, relativeToSection: 0))
        XCTAssertEqual(MediaGalleryIndexPath(item: 5, section: 2),
                       wrapper.sections.resolveNaiveEndIndex(12, relativeToSection: 0))

        XCTAssertEqual(MediaGalleryIndexPath(item: 1, section: 1),
                       wrapper.sections.resolveNaiveEndIndex(1, relativeToSection: 1))
        XCTAssertEqual(MediaGalleryIndexPath(item: 2, section: 1),
                       wrapper.sections.resolveNaiveEndIndex(2, relativeToSection: 1))
        XCTAssertEqual(MediaGalleryIndexPath(item: 1, section: 2),
                       wrapper.sections.resolveNaiveEndIndex(3, relativeToSection: 1))
        XCTAssertEqual(MediaGalleryIndexPath(item: 2, section: 2),
                       wrapper.sections.resolveNaiveEndIndex(4, relativeToSection: 1))
        XCTAssertEqual(MediaGalleryIndexPath(item: 5, section: 2),
                       wrapper.sections.resolveNaiveEndIndex(10, relativeToSection: 1))
    }

    func testLoadingFromEnd() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load April and September
        XCTAssertEqual(2, wrapper.mutate { sections in sections.loadEarlierSections(batchSize: 6) })
        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)
        XCTAssertEqual(
            [3, 4].map { MediaGalleryItemId.legacy(mediaGalleryRecordId: $0) },
            wrapper.sections.itemsBySection[0].value.map { $0.itemId }
        )
        XCTAssertEqual(
            [5, 6, 7, 8, 9].map { MediaGalleryItemId.legacy(mediaGalleryRecordId: $0) },
            wrapper.sections.itemsBySection[1].value.map { $0.itemId }
        )

        XCTAssert(wrapper.mutate { sections in sections.ensureItemsLoaded(in: 1..<3, relativeToSection: 1) }.isEmpty)
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, store.allItems[6], store.allItems[7], nil, nil], wrapper.sections.itemsBySection[1].value.map { $0.item })
        XCTAssertEqual([nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })

        XCTAssert(wrapper.mutate { sections in sections.ensureItemsLoaded(in: 5..<5, relativeToSection: 1) }.isEmpty)
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, store.allItems[6], store.allItems[7], nil, nil], wrapper.sections.itemsBySection[1].value.map { $0.item })
        XCTAssertEqual([nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })

        XCTAssert(wrapper.mutate { sections in sections.ensureItemsLoaded(in: (-2)..<3, relativeToSection: 1) }.isEmpty)
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([store.allItems[5], store.allItems[6], store.allItems[7], nil, nil], wrapper.sections.itemsBySection[1].value.map { $0.item })
        XCTAssertEqual([store.allItems[3], store.allItems[4]], wrapper.sections.itemsBySection[0].value.map { $0.item })

        XCTAssertEqual(IndexSet(integer: 0), wrapper.mutate { sections in sections.ensureItemsLoaded(in: (-4)..<0, relativeToSection: 1) })
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([store.allItems[5], store.allItems[6], store.allItems[7], nil, nil], wrapper.sections.itemsBySection[2].value.map { $0.item })
        XCTAssertEqual([store.allItems[3], store.allItems[4]], wrapper.sections.itemsBySection[1].value.map { $0.item })
        XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], wrapper.sections.itemsBySection[0].value.map { $0.item })
    }

    func testLoadingFromEndInBigJump() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))
        // Load September
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadEarlierSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)

        XCTAssert(wrapper.mutate { sections in sections.ensureItemsLoaded(in: 1..<3, relativeToSection: 0) }.isEmpty)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, store.allItems[6], store.allItems[7], nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })

        XCTAssertEqual(IndexSet(integersIn: 0...1),
                       wrapper.mutate { sections in sections.ensureItemsLoaded(in: (-4)..<0, relativeToSection: 0) })
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, store.allItems[6], store.allItems[7], nil, nil], wrapper.sections.itemsBySection[2].value.map { $0.item })
        XCTAssertEqual([store.allItems[3], store.allItems[4]], wrapper.sections.itemsBySection[1].value.map { $0.item })
        XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], wrapper.sections.itemsBySection[0].value.map { $0.item })
    }

    func testLoadingFromStart() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January and April
        XCTAssertEqual(2, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 4) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)

        XCTAssert(wrapper.mutate { sections in sections.ensureItemsLoaded(in: 1..<3, relativeToSection: 0) }.isEmpty)
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([nil, nil], wrapper.sections.itemsBySection[1].value.map { $0.item })

        XCTAssert(wrapper.mutate { sections in sections.ensureItemsLoaded(in: 0..<0, relativeToSection: 0) }.isEmpty)
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([nil, nil], wrapper.sections.itemsBySection[1].value.map { $0.item })

        XCTAssert(wrapper.mutate { sections in sections.ensureItemsLoaded(in: 3..<4, relativeToSection: 0) }.isEmpty)
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([store.allItems[3], nil], wrapper.sections.itemsBySection[1].value.map { $0.item })

        XCTAssertEqual(IndexSet(integer: 2), wrapper.mutate { sections in sections.ensureItemsLoaded(in: 3..<7, relativeToSection: 0) })
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([store.allItems[3], store.allItems[4]], wrapper.sections.itemsBySection[1].value.map { $0.item })
        XCTAssertEqual([store.allItems[5], store.allItems[6], nil, nil, nil], wrapper.sections.itemsBySection[2].value.map { $0.item })
    }

    func testLoadingFromStartInBigJump() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)

        XCTAssert(wrapper.mutate { sections in sections.ensureItemsLoaded(in: 1..<3, relativeToSection: 0) }.isEmpty)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], wrapper.sections.itemsBySection[0].value.map { $0.item })

        XCTAssert(wrapper.mutate { sections in sections.ensureItemsLoaded(in: 0..<0, relativeToSection: 0) }.isEmpty)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], wrapper.sections.itemsBySection[0].value.map { $0.item })

        XCTAssertEqual(IndexSet(integersIn: 1...2), wrapper.mutate { sections in sections.ensureItemsLoaded(in: 3..<7, relativeToSection: 0) })
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([store.allItems[3], store.allItems[4]], wrapper.sections.itemsBySection[1].value.map { $0.item })
        XCTAssertEqual([store.allItems[5], store.allItems[6], nil, nil, nil], wrapper.sections.itemsBySection[2].value.map { $0.item })
    }

    func testLoadingFromMiddle() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        _ = read { transaction in
            wrapper.mutate { sections in
                sections.loadInitialSection(for: GalleryDate(2021_04_01), transaction: transaction)
            }
        }
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)

        XCTAssert(wrapper.mutate { sections in sections.ensureItemsLoaded(in: 1..<2, relativeToSection: 0) }.isEmpty)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, store.allItems[4]], wrapper.sections.itemsBySection[0].value.map { $0.item })

        XCTAssertEqual(IndexSet([0, 2]), wrapper.mutate { sections in sections.ensureItemsLoaded(in: (-2)..<5, relativeToSection: 0) })
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([store.allItems[3], store.allItems[4]], wrapper.sections.itemsBySection[1].value.map { $0.item })
        XCTAssertEqual([store.allItems[5], store.allItems[6], store.allItems[7], nil, nil],
                       wrapper.sections.itemsBySection[2].value.map { $0.item })
    }

    func testReloadSection() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)

        XCTAssertEqual(IndexSet(integersIn: 1...2), wrapper.mutate { sections in sections.ensureItemsLoaded(in: 3..<7, relativeToSection: 0) })
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([store.allItems[3], store.allItems[4]], wrapper.sections.itemsBySection[1].value.map { $0.item })
        XCTAssertEqual([store.allItems[5], store.allItems[6], nil, nil, nil], wrapper.sections.itemsBySection[2].value.map { $0.item })

        XCTAssertEqual(2, wrapper.mutate { sections in sections.reloadSection(for: GalleryDate(2021_04_01)) })
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([nil, nil], wrapper.sections.itemsBySection[1].value.map { $0.item })
        XCTAssertEqual([store.allItems[5], store.allItems[6], nil, nil, nil], wrapper.sections.itemsBySection[2].value.map { $0.item })
    }

    func testResetWhenEmpty() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))
        wrapper.mutate { sections in
            sections.reset()
        }

        XCTAssert(wrapper.sections.itemsBySection.isEmpty)
        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
    }

    func testResetOneSection() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)

        wrapper.mutate { sections in
            sections.reset()
        }

        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })
    }

    func testResetTwoSections() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)

        XCTAssertEqual(IndexSet(integer: 1), wrapper.mutate { sections in sections.ensureItemsLoaded(in: 3..<4, relativeToSection: 0) })
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([store.allItems[3], nil], wrapper.sections.itemsBySection[1].value.map { $0.item })

        wrapper.mutate { sections in
            sections.reset()
        }

        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([nil, nil], wrapper.sections.itemsBySection[1].value.map { $0.item })
    }

    func testResetFull() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)

        XCTAssertEqual(IndexSet(integersIn: 1...2), wrapper.mutate { sections in sections.ensureItemsLoaded(in: 2..<7, relativeToSection: 0) })
        XCTAssertTrue(wrapper.sections.hasFetchedOldest)
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, nil, store.allItems[2]], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([store.allItems[3], store.allItems[4]], wrapper.sections.itemsBySection[1].value.map { $0.item })
        XCTAssertEqual([store.allItems[5], store.allItems[6], nil, nil, nil], wrapper.sections.itemsBySection[2].value.map { $0.item })

        wrapper.mutate { sections in
            sections.reset()
        }

        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([nil, nil], wrapper.sections.itemsBySection[1].value.map { $0.item })
        XCTAssertEqual([nil, nil, nil, nil, nil], wrapper.sections.itemsBySection[2].value.map { $0.item })
    }

    func testResetOneSectionAfterDeletingStart() {
        let store = standardFakeStore.clone()
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)

        store.allItems.removeFirst(3)
        store.itemsBySection.replace(key: GalleryDate(2021_01_01), value: [])

        wrapper.mutate { sections in
            sections.reset()
        }

        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([GalleryDate(2021_09_01)], wrapper.sections.itemsBySection.orderedKeys)
        XCTAssertEqual([nil, nil, nil, nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })
    }

    func testResetTwoSectionsAfterDeletingStart() {
        let store = standardFakeStore.clone()
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)

        XCTAssertEqual(IndexSet(integer: 1), wrapper.mutate { sections in sections.ensureItemsLoaded(in: 3..<4, relativeToSection: 0) })
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)

        store.allItems.removeFirst(3)
        store.itemsBySection.replace(key: GalleryDate(2021_01_01), value: [])

        wrapper.mutate { sections in
            sections.reset()
        }

        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([GalleryDate(2021_09_01)], wrapper.sections.itemsBySection.orderedKeys)
        XCTAssertEqual([nil, nil, nil, nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })
    }

    func testResetTwoSectionsAfterDeletingEndWithAnotherSectionFollowing() {
        let store = standardFakeStore.clone()
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)

        XCTAssertEqual(IndexSet(integer: 1), wrapper.mutate { sections in sections.ensureItemsLoaded(in: 3..<4, relativeToSection: 0) })
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)

        store.allItems.removeSubrange(3..<5)
        store.itemsBySection.replace(key: GalleryDate(2021_04_01), value: [])

        wrapper.mutate { sections in
            sections.reset()
        }

        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([nil, nil, nil, nil, nil], wrapper.sections.itemsBySection[1].value.map { $0.item })
    }

    func testResetTwoSectionsAfterDeletingEndWithNothingFollowing() {
        let store = standardFakeStore.clone()
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load April and September
        XCTAssertEqual(2, wrapper.mutate { sections in sections.loadEarlierSections(batchSize: 6) })
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)

        store.allItems.removeSubrange(5...)
        store.itemsBySection.replace(key: GalleryDate(2021_09_01), value: [])

        wrapper.mutate { sections in
            sections.reset()
        }

        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([GalleryDate(2021_04_01)], wrapper.sections.itemsBySection.orderedKeys)
        XCTAssertEqual([nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })
    }

    func testResetFullAfterDeletingStart() {
        let store = standardFakeStore.clone()
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)

        XCTAssertEqual(IndexSet(integersIn: 1...2), wrapper.mutate { sections in sections.ensureItemsLoaded(in: 2..<7, relativeToSection: 0) })
        XCTAssertTrue(wrapper.sections.hasFetchedOldest)
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)

        store.allItems.removeFirst(3)
        store.itemsBySection.replace(key: GalleryDate(2021_01_01), value: [])

        wrapper.mutate { sections in
            sections.reset()
        }

        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([nil, nil, nil, nil, nil], wrapper.sections.itemsBySection[1].value.map { $0.item })
    }

    func testResetFullAfterDeletingMiddle() {
        let store = standardFakeStore.clone()
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)

        XCTAssertEqual(IndexSet(integersIn: 1...2), wrapper.mutate { sections in sections.ensureItemsLoaded(in: 2..<7, relativeToSection: 0) })
        XCTAssertTrue(wrapper.sections.hasFetchedOldest)
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)

        store.allItems.removeSubrange(3..<5)
        store.itemsBySection.replace(key: GalleryDate(2021_04_01), value: [])

        wrapper.mutate { sections in
            sections.reset()
        }

        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([nil, nil, nil, nil, nil], wrapper.sections.itemsBySection[1].value.map { $0.item })
    }

    func testResetFullAfterDeletingEnd() {
        let store = standardFakeStore.clone()
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)

        XCTAssertEqual(IndexSet(integersIn: 1...2), wrapper.mutate { sections in sections.ensureItemsLoaded(in: 2..<7, relativeToSection: 0) })
        XCTAssertTrue(wrapper.sections.hasFetchedOldest)
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)

        store.allItems.removeSubrange(5...)
        store.itemsBySection.replace(key: GalleryDate(2021_09_01), value: [])

        wrapper.mutate { sections in
            sections.reset()
        }

        XCTAssertFalse(wrapper.sections.hasFetchedOldest)
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(2, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })
        XCTAssertEqual([nil, nil], wrapper.sections.itemsBySection[1].value.map { $0.item })
    }

    func testGetOrAddItem() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })

        let fakeItem = FakeItem(2021_01_05)
        let itemId = wrapper.sections.stateForTesting.itemsBySection[fakeItem.galleryDate]![1].itemId
        let newItem = wrapper.mutate { sections in sections.getOrReplaceItem(fakeItem, itemId: itemId) }
        XCTAssertEqual(fakeItem, newItem)
        XCTAssertEqual([nil, fakeItem, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })

        let fakeItem2 = FakeItem(2021_01_06)
        let itemId2 = wrapper.sections.stateForTesting.itemsBySection[fakeItem2.galleryDate]![1].itemId
        let newItem2 = wrapper.mutate { sections in sections.getOrReplaceItem(fakeItem2, itemId: itemId2) }
        XCTAssertEqual(fakeItem, newItem2)
        XCTAssertEqual([nil, fakeItem, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })
    }

    func testIndexAfter() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })

        XCTAssertEqual(MediaGalleryIndexPath(item: 1, section: 0), wrapper.sections.indexPath(after: MediaGalleryIndexPath(item: 0, section: 0)))
        XCTAssertEqual(MediaGalleryIndexPath(item: 2, section: 0), wrapper.sections.indexPath(after: MediaGalleryIndexPath(item: 1, section: 0)))
        XCTAssertNil(wrapper.sections.indexPath(after: MediaGalleryIndexPath(item: 2, section: 0)))

        // Load remaining sections
        XCTAssertEqual(2, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 20) })
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)

        XCTAssertEqual(MediaGalleryIndexPath(item: 1, section: 0), wrapper.sections.indexPath(after: MediaGalleryIndexPath(item: 0, section: 0)))
        XCTAssertEqual(MediaGalleryIndexPath(item: 2, section: 0), wrapper.sections.indexPath(after: MediaGalleryIndexPath(item: 1, section: 0)))
        XCTAssertEqual(MediaGalleryIndexPath(item: 0, section: 1), wrapper.sections.indexPath(after: MediaGalleryIndexPath(item: 2, section: 0)))
        XCTAssertEqual(MediaGalleryIndexPath(item: 1, section: 1), wrapper.sections.indexPath(after: MediaGalleryIndexPath(item: 0, section: 1)))
        XCTAssertEqual(MediaGalleryIndexPath(item: 0, section: 2), wrapper.sections.indexPath(after: MediaGalleryIndexPath(item: 1, section: 1)))
        XCTAssertNil(wrapper.sections.indexPath(after: MediaGalleryIndexPath(item: 4, section: 2)))
    }

    func testIndexBefore() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 1) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)
        XCTAssertEqual([nil, nil, nil], wrapper.sections.itemsBySection[0].value.map { $0.item })

        XCTAssertEqual(MediaGalleryIndexPath(item: 1, section: 0), wrapper.sections.indexPath(before: MediaGalleryIndexPath(item: 2, section: 0)))
        XCTAssertEqual(MediaGalleryIndexPath(item: 0, section: 0), wrapper.sections.indexPath(before: MediaGalleryIndexPath(item: 1, section: 0)))
        XCTAssertNil(wrapper.sections.indexPath(before: MediaGalleryIndexPath(item: 0, section: 0)))

        // Load remaining sections
        XCTAssertEqual(2, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 20) })
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)

        XCTAssertEqual(MediaGalleryIndexPath(item: 1, section: 1), wrapper.sections.indexPath(before: MediaGalleryIndexPath(item: 0, section: 2)))
        XCTAssertEqual(MediaGalleryIndexPath(item: 0, section: 1), wrapper.sections.indexPath(before: MediaGalleryIndexPath(item: 1, section: 1)))
        XCTAssertEqual(MediaGalleryIndexPath(item: 2, section: 0), wrapper.sections.indexPath(before: MediaGalleryIndexPath(item: 0, section: 1)))
        XCTAssertEqual(MediaGalleryIndexPath(item: 1, section: 0), wrapper.sections.indexPath(before: MediaGalleryIndexPath(item: 2, section: 0)))
        XCTAssertEqual(MediaGalleryIndexPath(item: 0, section: 0), wrapper.sections.indexPath(before: MediaGalleryIndexPath(item: 1, section: 0)))
        XCTAssertNil(wrapper.sections.indexPath(before: MediaGalleryIndexPath(item: 0, section: 0)))
    }

    func testIndexPathOf() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load all months
        XCTAssertEqual(3, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 20) })
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)

        // Load all items.
        XCTAssert(wrapper.mutate { sections in sections.ensureItemsLoaded(in: 0..<20, relativeToSection: 0) }.isEmpty)

        XCTAssertEqual(MediaGalleryIndexPath(item: 0, section: 0), wrapper.sections.indexPath(for: store.allItems[0]))
        XCTAssertEqual(MediaGalleryIndexPath(item: 1, section: 0), wrapper.sections.indexPath(for: store.allItems[1]))
        XCTAssertEqual(MediaGalleryIndexPath(item: 2, section: 0), wrapper.sections.indexPath(for: store.allItems[2]))
        XCTAssertEqual(MediaGalleryIndexPath(item: 0, section: 1), wrapper.sections.indexPath(for: store.allItems[3]))
        XCTAssertEqual(MediaGalleryIndexPath(item: 1, section: 1), wrapper.sections.indexPath(for: store.allItems[4]))
        XCTAssertEqual(MediaGalleryIndexPath(item: 0, section: 2), wrapper.sections.indexPath(for: store.allItems[5]))
        XCTAssertEqual(MediaGalleryIndexPath(item: 1, section: 2), wrapper.sections.indexPath(for: store.allItems[6]))

        // Different uniqueId -> no match, even though the timestamp matches.
        XCTAssert(store.allItems.contains { $0.timestamp == Date(compressedDate: 2021_09_09) })
        XCTAssertNil(wrapper.sections.indexPath(for: FakeItem(2021_09_09)))
    }

    func testTrimFromStart() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load all months
        XCTAssertEqual(3, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 20) })
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)

        // _ _ _ | _ _ | x _ x x _
        _ = wrapper.mutate { sections in sections.ensureItemsLoaded(in: 0..<1, relativeToSection: 2) }
        _ = wrapper.mutate { sections in sections.ensureItemsLoaded(in: 2..<4, relativeToSection: 2) }

        XCTAssertEqual(1..<4, wrapper.sections.trimLoadedItemsAtStart(from: 0..<4, relativeToSection: 2))
        XCTAssertEqual(1..<5, wrapper.sections.trimLoadedItemsAtStart(from: 0..<5, relativeToSection: 2))
        // End index is not even checked.
        XCTAssertEqual(1..<6, wrapper.sections.trimLoadedItemsAtStart(from: 0..<6, relativeToSection: 2))

        XCTAssertEqual(1..<4, wrapper.sections.trimLoadedItemsAtStart(from: 1..<4, relativeToSection: 2))
        XCTAssertEqual(4..<4, wrapper.sections.trimLoadedItemsAtStart(from: 2..<4, relativeToSection: 2))
        XCTAssertEqual(4..<5, wrapper.sections.trimLoadedItemsAtStart(from: 2..<5, relativeToSection: 2))

        XCTAssertEqual(-1 ..< 5, wrapper.sections.trimLoadedItemsAtStart(from: -1 ..< 5, relativeToSection: 2))
        XCTAssertEqual(-5 ..< 5, wrapper.sections.trimLoadedItemsAtStart(from: -5 ..< 5, relativeToSection: 2))

        // _ _ _ | _ x | x _ x x _
        _ = wrapper.mutate { sections in sections.ensureItemsLoaded(in: 1..<2, relativeToSection: 1) }

        XCTAssertEqual(1..<5, wrapper.sections.trimLoadedItemsAtStart(from: -1 ..< 5, relativeToSection: 2))
        XCTAssertEqual(-2 ..< 5, wrapper.sections.trimLoadedItemsAtStart(from: -2 ..< 5, relativeToSection: 2))
        // trimLoadedItemsAtStart never goes past the current section.
        XCTAssertEqual(2..<4, wrapper.sections.trimLoadedItemsAtStart(from: 1..<4, relativeToSection: 1))
        XCTAssertEqual(0..<4, wrapper.sections.trimLoadedItemsAtStart(from: 0..<4, relativeToSection: 1))

        // _ _ _ | x x | x _ x x _
        _ = wrapper.mutate { sections in sections.ensureItemsLoaded(in: 0..<2, relativeToSection: 1) }

        XCTAssertEqual(1..<5, wrapper.sections.trimLoadedItemsAtStart(from: -1 ..< 5, relativeToSection: 2))
        XCTAssertEqual(1..<5, wrapper.sections.trimLoadedItemsAtStart(from: -2 ..< 5, relativeToSection: 2))
        XCTAssertEqual(-3 ..< 5, wrapper.sections.trimLoadedItemsAtStart(from: -3 ..< 5, relativeToSection: 2))
        // trimLoadedItemsAtStart never goes past the current section.
        XCTAssertEqual(2..<4, wrapper.sections.trimLoadedItemsAtStart(from: 1..<4, relativeToSection: 1))
        XCTAssertEqual(2..<4, wrapper.sections.trimLoadedItemsAtStart(from: 0..<4, relativeToSection: 1))

        // _ _ x | x _ | x _ x x _
        _ = wrapper.mutate { sections in sections.reloadSection(for: sections.sectionDates[1]) }

        _ = wrapper.mutate { sections in sections.ensureItemsLoaded(in: -1 ..< 1, relativeToSection: 1) }
        XCTAssertEqual(-1 ..< 5, wrapper.sections.trimLoadedItemsAtStart(from: -1 ..< 5, relativeToSection: 2))
        XCTAssertEqual(-1 ..< 5, wrapper.sections.trimLoadedItemsAtStart(from: -2 ..< 5, relativeToSection: 2))
        XCTAssertEqual(-1 ..< 5, wrapper.sections.trimLoadedItemsAtStart(from: -3 ..< 5, relativeToSection: 2))
        XCTAssertEqual(-4 ..< 5, wrapper.sections.trimLoadedItemsAtStart(from: -4 ..< 5, relativeToSection: 2))
        XCTAssertEqual(1..<4, wrapper.sections.trimLoadedItemsAtStart(from: 1..<4, relativeToSection: 1))
        XCTAssertEqual(1..<4, wrapper.sections.trimLoadedItemsAtStart(from: 0..<4, relativeToSection: 1))

        // x x x | x x | x x x x x
        _ = wrapper.mutate { sections in sections.ensureItemsLoaded(in: 0..<10, relativeToSection: 0) }
        XCTAssertEqual(5..<5, wrapper.sections.trimLoadedItemsAtStart(from: -5 ..< 5, relativeToSection: 2))
        XCTAssertEqual(3..<3, wrapper.sections.trimLoadedItemsAtStart(from: -5 ..< 3, relativeToSection: 2))
        // trimLoadedItemsAtStart never goes past the current section.
        XCTAssertEqual(2..<7, wrapper.sections.trimLoadedItemsAtStart(from: -3 ..< 7, relativeToSection: 1))
    }

    func testTrimFromEnd() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load all months
        XCTAssertEqual(3, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 20) })
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)

        // x x _ | _ _ | _ _ _ _ _
        _ = wrapper.mutate { sections in sections.ensureItemsLoaded(in: 0..<2, relativeToSection: 0) }

        XCTAssertEqual(0..<0, wrapper.sections.trimLoadedItemsAtEnd(from: 0..<1, relativeToSection: 0))
        XCTAssertEqual(0..<0, wrapper.sections.trimLoadedItemsAtEnd(from: 0..<2, relativeToSection: 0))
        XCTAssertEqual(0..<3, wrapper.sections.trimLoadedItemsAtEnd(from: 0..<3, relativeToSection: 0))
        XCTAssertEqual(0..<4, wrapper.sections.trimLoadedItemsAtEnd(from: 0..<4, relativeToSection: 0))
        // We don't actually check the start index.
        XCTAssertEqual(-5 ..< 0, wrapper.sections.trimLoadedItemsAtEnd(from: -5 ..< 2, relativeToSection: 0))
    }

    func testLoadingTrimsRequestedRange() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load all months
        XCTAssertEqual(3, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 20) })
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)

        // _ _ x | x x | x _ _ _ _
        _ = wrapper.mutate { sections in sections.ensureItemsLoaded(in: -1 ..< 3, relativeToSection: 1) }
        XCTAssertEqual(2..<6, store.mostRecentRequest)

        // _ _ x | x x | x x x _ _
        _ = wrapper.mutate { sections in sections.ensureItemsLoaded(in: 0 ..< 5, relativeToSection: 1) }
        // We only trim up to the current section, so the first item in September gets reloaded.
        XCTAssertEqual(5..<8, store.mostRecentRequest)

        // x x x | x x | x x x _ _
        _ = wrapper.mutate { sections in sections.ensureItemsLoaded(in: -3 ..< 1, relativeToSection: 1) }
        // We only trim down to the current section, so the last item in January gets reloaded.
        XCTAssertEqual(0..<3, store.mostRecentRequest)

        // x x x | _ _ | x x x _ _
        _ = wrapper.mutate { sections in sections.reloadSection(for: sections.sectionDates[1]) }

        // x x x | x x | x x x _ _
        _ = wrapper.mutate { sections in sections.ensureItemsLoaded(in: -1 ..< 3, relativeToSection: 1) }
        XCTAssertEqual(3..<5, store.mostRecentRequest)

        store.mostRecentRequest = 5000..<5000
        _ = wrapper.mutate { sections in sections.ensureItemsLoaded(in: -1 ..< 3, relativeToSection: 1) }
        // The request should be skipped entirely.
        XCTAssertEqual(5000..<5000, store.mostRecentRequest)
    }

    func testReloadSections() {
        let store = standardFakeStore.clone()
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load all months
        XCTAssertEqual(3, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 20) })
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)

        // x x x | x x | x x x x x
        _ = wrapper.mutate { sections in sections.ensureItemsLoaded(in: -1 ..< 10, relativeToSection: 1) }

        let modifiedItems = store.allItems.filter {
            // Keep January unmodified, drop all of April, and drop one value from September.
            [0, 1, 2, 5, 6, 8, 9]
                .lazy
                .map({ MediaGalleryItemId.legacy(mediaGalleryRecordId: $0) })
                .contains($0.itemId)
        }

        store.set(items: modifiedItems)

        let (update, delete) = wrapper.mutate { sections in
            sections.reloadSections(for: Set(sections.sectionDates + [GalleryDate(2022_01_01)]))
        }

        XCTAssertEqual(update, IndexSet([0, 1, 2]))
        XCTAssertEqual(delete, IndexSet(integer: 1))
    }

    func testHandleNewAttachments_NewSectionButMostRecentUnfetched() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))
        // Load the first month, January 2021
        XCTAssertEqual(1, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 3) })
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(1, wrapper.sections.itemsBySection.count)
        let expected = Sections.NewAttachmentHandlingResult(update: IndexSet(),
                                                            didAddAtEnd: false,
                                                            didReset: false)
        let actual = wrapper.mutate { sections in sections.handleNewAttachments([GalleryDate(2022_01_01)]) }
        XCTAssertEqual(expected, actual)
    }

    func testHandleNewAttachments_NewSectionAndMostRecentFetched() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))
        // Load all months
        XCTAssertEqual(3, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 20) })
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)
        _ = wrapper.mutate { sections in sections.ensureItemsLoaded(in: -1 ..< 10, relativeToSection: 1) }
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        let expected = Sections.NewAttachmentHandlingResult(update: IndexSet(),
                                                            didAddAtEnd: true,
                                                            didReset: false)
        let actual = wrapper.mutate { sections in sections.handleNewAttachments([GalleryDate(2022_01_01)]) }
        XCTAssertFalse(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(expected, actual)
    }

    func testHandleNewAttachments_ExistingSection() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))
        // Load all months
        XCTAssertEqual(3, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 20) })
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)

        let expected = Sections.NewAttachmentHandlingResult(update: IndexSet(integer: 1),
                                                            didAddAtEnd: false,
                                                            didReset: false)
        let actual = wrapper.mutate { sections in sections.handleNewAttachments([GalleryDate(2021_04_01)]) }
        XCTAssertEqual(expected, actual)
    }

    func testHandleNewAttachments_FirstAttachmentInNewSectionNotAtEnd() {
        let store = standardFakeStore
        var wrapper = SectionsWrapper(sections: Sections(loader: store))
        // Load all months
        XCTAssertEqual(3, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 20) })
        XCTAssertTrue(wrapper.sections.hasFetchedMostRecent)
        XCTAssertEqual(3, wrapper.sections.itemsBySection.count)

        let expected = Sections.NewAttachmentHandlingResult(update: IndexSet(),
                                                            didAddAtEnd: false,
                                                            didReset: true)
        let actual = wrapper.mutate { sections in sections.handleNewAttachments([GalleryDate(2020_07_01)]) }
        XCTAssertEqual(expected, actual)
    }

    func testUserData() {
        var wrapper = SectionsWrapper(sections: Sections(loader: standardFakeStore))
        wrapper.mutate { sections in
            _ = sections.loadEarlierSections(batchSize: 1, userData: 42)
            _ = sections.loadEarlierSections(batchSize: 1, userData: 420)
        }
        XCTAssertEqual(wrapper.userData, [42, 420])
    }

    func testRemoveLoadedItems() {
        let store = standardFakeStore.clone()
        var wrapper = SectionsWrapper(sections: Sections(loader: store))

        // Load January and April
        XCTAssertEqual(2, wrapper.mutate { sections in sections.loadLaterSections(batchSize: 4) })

        // Delete first and third item
        var values = store.itemsBySection[0].value
        let key = store.itemsBySection[0].key
        values.remove(at: 2)
        values.remove(at: 0)
        store.itemsBySection.replace(key: key, value: values)

        let indexes = wrapper.mutate { sections in
            _ = sections.ensureItemsLoaded(in: 0..<3, relativeToSection: 0)
            return sections.removeLoadedItems(atIndexPaths: [MediaGalleryIndexPath(item: 0, section: 0),
                                                             MediaGalleryIndexPath(item: 2, section: 0)])
        }
        XCTAssertEqual(IndexSet(), indexes, "Expected empty indexes but got \(indexes)")
        XCTAssertEqual(values.map { $0.itemId },
                       wrapper.sections.itemsBySection[0].value.map { $0.itemId },
                       "Expected \(values.map { $0.itemId }) but got \(wrapper.sections.itemsBySection[0].value.map { $0.itemId })")
    }
}

extension MediaGallerySections {
    internal func resolveNaiveEndIndex(_ naiveIndex: Int,
                                       relativeToSection initialSectionIndex: Int) -> MediaGalleryIndexPath? {
        return stateForTesting.resolveNaiveEndIndex(naiveIndex, relativeToSection: initialSectionIndex)
    }

    internal func resolveNaiveStartIndex(_ naiveIndex: Int,
                                         relativeToSection initialSectionIndex: Int) -> MediaGalleryIndexPath? {
        return stateForTesting.resolveNaiveStartIndex(naiveIndex, relativeToSection: initialSectionIndex)
    }

    internal mutating func resolveNaiveStartIndex(
        _ naiveIndex: Int,
        relativeToSection initialSectionIndex: Int,
        batchSize: Int,
        userData: UpdateUserData? = nil
    ) -> (path: MediaGalleryIndexPath?, numberOfSectionsLoaded: Int) {
        let request = State.LoadItemsRequest(date: stateForTesting.itemsBySection.orderedKeys[initialSectionIndex],
                                             range: naiveIndex..<(naiveIndex + 1))
        return snapshotManagerForTesting.mutate(userData: userData) { state, transaction in
            return state.resolveNaiveStartIndex(request: request,
                                                batchSize: batchSize,
                                                transaction: transaction)
        }
    }
}
