diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 147afcfdb3..2c813b1ec3 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ 179E8C31276A711100AF640F /* AFQueryString.m in Sources */ = {isa = PBXBuildFile; fileRef = 179E8C30276A711100AF640F /* AFQueryString.m */; }; 179E8C33276A713300AF640F /* AFQueryString.h in Headers */ = {isa = PBXBuildFile; fileRef = 179E8C32276A713300AF640F /* AFQueryString.h */; settings = {ATTRIBUTES = (Public, ); }; }; 17ACF11E267D71E0009BE867 /* OWSAudioSession+WebRTC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17ACF11D267D71E0009BE867 /* OWSAudioSession+WebRTC.swift */; }; + 17B4F6CA281B17D300C2BFB1 /* MediaGallerySections.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B4F6C9281B17D200C2BFB1 /* MediaGallerySections.swift */; }; + 17B4F6CD281B3AB000C2BFB1 /* MediaGallerySectionsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B4F6CB281B3A2200C2BFB1 /* MediaGallerySectionsTest.swift */; }; 17B78E0E260529E900E24A9E /* newlyInitializedSessionState in Resources */ = {isa = PBXBuildFile; fileRef = 17B78E0C2605299E00E24A9E /* newlyInitializedSessionState */; }; 23B9887F4A95010141FCF725 /* Pods_SignalUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E36B9FCE67E9FFDA471A085 /* Pods_SignalUI.framework */; }; 3236FCC42592B67B006D33B9 /* NameCollisionReviewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3236FCC32592B67B006D33B9 /* NameCollisionReviewCell.swift */; }; @@ -1206,6 +1208,8 @@ 179E8C30276A711100AF640F /* AFQueryString.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AFQueryString.m; sourceTree = ""; }; 179E8C32276A713300AF640F /* AFQueryString.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AFQueryString.h; sourceTree = ""; }; 17ACF11D267D71E0009BE867 /* OWSAudioSession+WebRTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OWSAudioSession+WebRTC.swift"; sourceTree = ""; }; + 17B4F6C9281B17D200C2BFB1 /* MediaGallerySections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGallerySections.swift; sourceTree = ""; }; + 17B4F6CB281B3A2200C2BFB1 /* MediaGallerySectionsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGallerySectionsTest.swift; sourceTree = ""; }; 17B78E0C2605299E00E24A9E /* newlyInitializedSessionState */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; path = newlyInitializedSessionState; sourceTree = ""; }; 1BC279B87E730B066A5AFB2A /* Pods-SignalPerformanceTests.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalPerformanceTests.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SignalPerformanceTests/Pods-SignalPerformanceTests.app store release.xcconfig"; sourceTree = ""; }; 1C93CF3971B64E8B6C1F9AC1 /* Pods-SignalShareExtension.test.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalShareExtension.test.xcconfig"; path = "Pods/Target Support Files/Pods-SignalShareExtension/Pods-SignalShareExtension.test.xcconfig"; sourceTree = ""; }; @@ -3582,6 +3586,7 @@ 4C2A538B23C5462300D28CD8 /* CVMessageMappingTest.swift */, 4C6E6C6824241C00009DE948 /* ConversationViewControllerTest.swift */, 88D6E94125535482003142D9 /* CVTextTest.swift */, + 17B4F6CB281B3A2200C2BFB1 /* MediaGallerySectionsTest.swift */, ); path = ViewControllers; sourceTree = ""; @@ -3885,6 +3890,7 @@ 4C4F360E2284516F00A8DF48 /* MediaGallery */ = { isa = PBXGroup; children = ( + 17B4F6C9281B17D200C2BFB1 /* MediaGallerySections.swift */, 45B9EE9A200E91FB005D2F2D /* MediaDetailViewController.h */, 45B9EE9B200E91FB005D2F2D /* MediaDetailViewController.m */, 452EC6DE205E9E30000E787C /* MediaGallery.swift */, @@ -6576,6 +6582,7 @@ 348815BA2552063F00D4F4C4 /* CVAvatarBuilder.swift in Sources */, 32C584A825B81C6600256804 /* AvatarViewController.swift in Sources */, 346EAA14250199A400E8AB6F /* MemberRequestView.swift in Sources */, + 17B4F6CA281B17D300C2BFB1 /* MediaGallerySections.swift in Sources */, 3426A37A2563F0EA0036407F /* CVComponentBottomButtons.swift in Sources */, 32ECD08824BFBF8000EDDED0 /* HelpViewController.swift in Sources */, 88DBDFB9263731C800C2101C /* DefaultDisappearingMessageTimerInteraction.swift in Sources */, @@ -6657,6 +6664,7 @@ 4C9D347B23679C25006A4307 /* GroupAndContactStreamTest.swift in Sources */, 4C83AC4223C55D9C00D4F2E6 /* SignalBaseTest+Swift.swift in Sources */, 4C5250D421E7C51900CE3D95 /* PhoneNumberValidatorTest.swift in Sources */, + 17B4F6CD281B3AB000C2BFB1 /* MediaGallerySectionsTest.swift in Sources */, 452D1AF12081059C00A67F7F /* StringAdditionsTest.swift in Sources */, 4C2A538C23C5462300D28CD8 /* CVMessageMappingTest.swift in Sources */, 4C4BC6C32102D697004040C9 /* ContactDiscoveryOperationTest.swift in Sources */, diff --git a/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift b/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift index 7c8ae603d3..af75896a70 100644 --- a/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift +++ b/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift @@ -29,7 +29,7 @@ class MediaGalleryAlbum { } } -public class MediaGalleryItem: Equatable, Hashable { +public class MediaGalleryItem: Equatable, Hashable, MediaGallerySectionItem { let message: TSMessage let attachmentStream: TSAttachmentStream let galleryDate: GalleryDate @@ -63,6 +63,8 @@ public class MediaGalleryItem: Equatable, Hashable { attachmentStream.imageSizePoints } + var uniqueId: String { attachmentStream.uniqueId } + public typealias AsyncThumbnailBlock = (UIImage) -> Void func thumbnailImage(async: @escaping AsyncThumbnailBlock) -> UIImage? { attachmentStream.thumbnailImageSmall(success: async, failure: {}) @@ -195,6 +197,7 @@ class MediaGallery: Dependencies { private var isCurrentlyProcessingExternalDeletion = false private let mediaGalleryFinder: MediaGalleryFinder + private var sections: MediaGallerySections! deinit { Logger.debug("") @@ -203,7 +206,7 @@ class MediaGallery: Dependencies { @objc init(thread: TSThread) { self.mediaGalleryFinder = MediaGalleryFinder(thread: thread) - + self.sections = MediaGallerySections(loader: Loader(mediaGallery: self)) setupDatabaseObservation() } @@ -229,7 +232,7 @@ class MediaGallery: Dependencies { var deletedItems: [MediaGalleryItem] = [] var deletedIndexPaths: [IndexPath] = [] - let allPaths = sequence(first: IndexPath(item: 0, section: 0), next: { self.indexPath(after: $0) }) + let allPaths = sequence(first: IndexPath(item: 0, section: 0), next: { self.sections.indexPath(after: $0) }) // This is not very efficient, but we have no index of attachment IDs -> loaded items. // An alternate approach would be to load the deleted attachments and check them by section. for path in allPaths { @@ -269,16 +272,15 @@ class MediaGallery: Dependencies { let sectionDate = GalleryDate(message: message) // Do a backwards search assuming new messages usually arrive at the end. // Still, this is kept sorted, so we ought to be able to do a binary search instead. - if let sectionIndex = sections.orderedKeys.lastIndex(of: sectionDate) { + if let sectionIndex = sections.sectionDates.lastIndex(of: sectionDate) { sectionsNeedingUpdate.insert(sectionIndex) } } for sectionIndex in sectionsNeedingUpdate { // Throw out everything in that section. - let sectionDate = sections.orderedKeys[sectionIndex] - let newCount = numberOfItemsInSection(for: sectionDate, transaction: transaction) - sections.replace(key: sectionDate, value: Array(repeating: nil, count: newCount)) + let sectionDate = sections.sectionDates[sectionIndex] + sections.reloadSection(for: sectionDate, transaction: transaction) } } @@ -287,14 +289,9 @@ class MediaGallery: Dependencies { // MARK: - - /// All sections we know about. - /// - /// Each section contains an array of possibly-fetched items. - /// The length of the array is always the correct number of items in the section. - /// The keys are kept in sorted order. - private(set) var sections: OrderedDictionary = OrderedDictionary() - private(set) var hasFetchedOldest = false - private(set) var hasFetchedMostRecent = false + internal var hasFetchedOldest: Bool { sections.hasFetchedOldest } + internal var hasFetchedMostRecent: Bool { sections.hasFetchedMostRecent } + internal var galleryDates: [GalleryDate] { sections.sectionDates } private func buildGalleryItem(attachment: TSAttachment, transaction: SDSAnyReadTransaction) -> MediaGalleryItem? { guard let attachmentStream = attachment as? TSAttachmentStream else { @@ -322,7 +319,7 @@ class MediaGallery: Dependencies { return MediaGalleryAlbum(items: [item], mediaGallery: self) } - let section = sections[itemPath.section].value + let section = sections.itemsBySection[itemPath.section].value let startOfAlbum = section[.. = { @@ -387,7 +384,7 @@ class MediaGallery: Dependencies { return true } - let sectionItems = sections[sectionIndex].value + let sectionItems = sections.itemsBySection[sectionIndex].value // If we're loading the remainder of an album, check to see if any items in the album are not loaded yet. if shouldLoadAlbumRemainder { @@ -415,10 +412,10 @@ class MediaGallery: Dependencies { var currentSectionIndex = sectionIndex + 1 var remainingForward = naiveRequestRange.upperBound - sectionItems.count repeat { - guard let currentSectionItems = sections[safe: currentSectionIndex]?.value else { + guard let currentSectionItems = sections.itemsBySection[safe: currentSectionIndex]?.value else { // We've reached the end of the fetched sections. If there are more sections, or the last item // isn't fetched yet, assume it's substantial. - if !hasFetchedMostRecent || (sections.last?.value.last ?? nil) == nil { + if !hasFetchedMostRecent || (sections.itemsBySection.last?.value.last ?? nil) == nil { return true } break @@ -436,10 +433,10 @@ class MediaGallery: Dependencies { var currentSectionIndex = sectionIndex - 1 var remainingBackward = -naiveRequestRange.lowerBound repeat { - guard let currentSectionItems = sections[safe: currentSectionIndex]?.value else { + guard let currentSectionItems = sections.itemsBySection[safe: currentSectionIndex]?.value else { // We've reached the start of the fetched sections. If there are more sections, or the first // item isn't fetched yet, assume it's substantial. - if !hasFetchedOldest || (sections.first?.value.first ?? nil) == nil { + if !hasFetchedOldest || (sections.itemsBySection.first?.value.first ?? nil) == nil { return true } break @@ -461,103 +458,8 @@ class MediaGallery: Dependencies { return } - var numNewlyLoadedEarlierSections: Int = 0 - var numNewlyLoadedLaterSections: Int = 0 - - Bench(title: "fetching gallery items") { - self.databaseStorage.read { transaction in - // Figure out the earliest section this request will cross. - var currentSectionIndex = sectionIndex - var requestRange = NSRange(naiveRequestRange) - while requestRange.location < 0 { - if currentSectionIndex == 0 { - let newlyLoadedCount = loadEarlierSections(batchSize: amount, transaction: transaction) - currentSectionIndex = newlyLoadedCount - numNewlyLoadedEarlierSections += newlyLoadedCount - - if currentSectionIndex == 0 { - owsAssertDebug(hasFetchedOldest) - requestRange.location = 0 - break - } - } - - currentSectionIndex -= 1 - let items = sections[currentSectionIndex].value - requestRange.location += items.count - } - - let interval = DateInterval(start: sections.orderedKeys[currentSectionIndex].interval.start, - end: .distantFutureForMillisecondTimestamp) - - var offset = 0 - mediaGalleryFinder.enumerateMediaAttachments(in: interval, - excluding: deletedAttachmentIds, - range: requestRange, - transaction: transaction.unwrapGrdbRead) { i, attachment in - owsAssertDebug(i >= offset, "does not support reverse traversal") - - func tryAddNewItem() { - if currentSectionIndex >= sections.count { - if hasFetchedMostRecent { - // Ignore later attachments. - owsAssertDebug(sections.count == 1, "should only be used in single-album page view") - return - } - numNewlyLoadedLaterSections += loadLaterSections(batchSize: amount, - transaction: transaction) - if currentSectionIndex >= sections.count { - owsFailDebug("attachment \(attachment) is beyond the last section") - return - } - } - - let itemIndex = i - offset - - var (date, items) = sections[currentSectionIndex] - guard itemIndex < items.count else { - offset += items.count - currentSectionIndex += 1 - // Start over in the next section. - return tryAddNewItem() - } - - guard !self.deletedAttachmentIds.contains(attachment.uniqueId) else { - owsFailDebug("\(attachment) has already been deleted; should not have been fetched.") - return - } - - if let loadedItem = items[itemIndex] { - owsAssert(loadedItem.attachmentStream.uniqueId == attachment.uniqueId) - return - } - - guard let item: MediaGalleryItem = self.buildGalleryItem(attachment: attachment, - transaction: transaction) else { - owsFailDebug("unexpectedly failed to buildGalleryItem") - return - } - - owsAssertDebug(item.galleryDate == date, - "item from \(item.galleryDate) put into section for \(date)") - // Performance hack: clear out the current 'items' array in 'sections' to avoid copy-on-write. - sections.replace(key: date, value: []) - items[itemIndex] = item - sections.replace(key: date, value: items) - } - - tryAddNewItem() - } - } - } - - if let completionBlock = completion { - let firstNewLaterSectionIndex = sections.count - numNewlyLoadedLaterSections - var newlyLoadedSections = IndexSet() - newlyLoadedSections.insert(integersIn: 0.. Int { - return Int(mediaGalleryFinder.mediaCount(in: date.interval, - excluding: deletedAttachmentIds, - transaction: transaction.unwrapGrdbRead)) + internal func numberOfItemsInSection(_ sectionIndex: Int) -> Int { + return sections.itemsBySection[sectionIndex].value.count } /// Loads at least one section before the oldest section, though not any of the items in it. @@ -644,43 +526,7 @@ class MediaGallery: Dependencies { /// /// Returns the number of new sections loaded, which can be used to update section indexes. internal func loadEarlierSections(batchSize: Int, transaction: SDSAnyReadTransaction) -> Int { - guard !hasFetchedOldest else { - return 0 - } - - var newSectionCounts: [GalleryDate: Int] = [:] - let earliestDate = sections.orderedKeys.first?.interval.start ?? .distantFutureForMillisecondTimestamp - - var newEarliestDate: GalleryDate? - let result = mediaGalleryFinder.enumerateTimestamps(before: earliestDate, - excluding: deletedAttachmentIds, - count: batchSize, - transaction: transaction.unwrapGrdbRead) { timestamp in - let galleryDate = GalleryDate(date: timestamp) - newSectionCounts[galleryDate, default: 0] += 1 - owsAssertDebug(newEarliestDate == nil || galleryDate <= newEarliestDate!, - "expects timestamps to be fetched in descending order") - newEarliestDate = galleryDate - } - - if result == .reachedEnd { - hasFetchedOldest = true - } else { - // Make sure we have the full count for the earliest loaded section. - newSectionCounts[newEarliestDate!] = numberOfItemsInSection(for: newEarliestDate!, - transaction: transaction) - } - - if sections.isEmpty { - hasFetchedMostRecent = true - } - - let sortedDates = newSectionCounts.keys.sorted() - owsAssertDebug(sections.isEmpty || sortedDates.isEmpty || sortedDates.last! < sections.orderedKeys.first!) - for date in sortedDates.reversed() { - sections.prepend(key: date, value: Array(repeating: nil, count: newSectionCounts[date]!)) - } - return sortedDates.count + return sections.loadEarlierSections(batchSize: batchSize, transaction: transaction) } /// Loads at least one section after the latest section, though not any of the items in it. @@ -689,43 +535,7 @@ class MediaGallery: Dependencies { /// /// Returns the number of new sections loaded. internal func loadLaterSections(batchSize: Int, transaction: SDSAnyReadTransaction) -> Int { - guard !hasFetchedMostRecent else { - return 0 - } - - var newSectionCounts: [GalleryDate: Int] = [:] - let latestDate = sections.orderedKeys.last?.interval.end ?? Date(millisecondsSince1970: 0) - - var newLatestDate: GalleryDate? - let result = mediaGalleryFinder.enumerateTimestamps(after: latestDate, - excluding: deletedAttachmentIds, - count: batchSize, - transaction: transaction.unwrapGrdbRead) { timestamp in - let galleryDate = GalleryDate(date: timestamp) - newSectionCounts[galleryDate, default: 0] += 1 - owsAssertDebug(newLatestDate == nil || newLatestDate! <= galleryDate, - "expects timestamps to be fetched in ascending order") - newLatestDate = galleryDate - } - - if result == .reachedEnd { - hasFetchedMostRecent = true - } else { - // Make sure we have the full count for the latest loaded section. - newSectionCounts[newLatestDate!] = numberOfItemsInSection(for: newLatestDate!, - transaction: transaction) - } - - if sections.isEmpty { - hasFetchedOldest = true - } - - let sortedDates = newSectionCounts.keys.sorted() - owsAssertDebug(sections.isEmpty || sortedDates.isEmpty || sections.orderedKeys.last! < sortedDates.first!) - for date in sortedDates { - sections.append(key: date, value: Array(repeating: nil, count: newSectionCounts[date]!)) - } - return sortedDates.count + return sections.loadLaterSections(batchSize: batchSize, transaction: transaction) } // MARK: - @@ -796,35 +606,19 @@ class MediaGallery: Dependencies { var deletedIndexPaths: [IndexPath] if let indexPaths = givenIndexPaths { + if OWSIsDebugBuild() { + for (item, path) in zip(items, indexPaths) { + owsAssertDebug(item == sections.loadedItem(at: path), "paths not in sync with items") + } + } deletedIndexPaths = indexPaths } else { - deletedIndexPaths = items.compactMap { indexPath(for: $0) } + deletedIndexPaths = items.compactMap { sections.indexPath(for: $0) } owsAssertDebug(deletedIndexPaths.count == items.count, "removing an item that wasn't loaded") } deletedIndexPaths.sort() - var deletedSections: IndexSet = IndexSet() - let deletedItemsForChecking = Set(items) - - // Iterate in reverse so the index paths don't get disrupted. - for path in deletedIndexPaths.reversed() { - let sectionKey = self.sections.orderedKeys[path.section] - // Swap out / swap in to avoid copy-on-write. - var section = self.sections.replace(key: sectionKey, value: []) - - if let removedItem = section.remove(at: path.item) { - owsAssertDebug(deletedItemsForChecking.contains(removedItem), "removed the wrong item") - } else { - owsFailDebug("removed an item that wasn't loaded, which can't be correct") - } - - if section.isEmpty { - self.sections.remove(at: path.section) - deletedSections.insert(path.section) - } else { - self.sections.replace(key: sectionKey, value: section) - } - } + let deletedSections = sections.removeLoadedItems(atIndexPaths: deletedIndexPaths) isCurrentlyProcessingExternalDeletion = false @@ -834,87 +628,45 @@ class MediaGallery: Dependencies { // MARK: - /// Searches the appropriate section for this item. + /// + /// Will return nil if the item was not loaded through the gallery. internal func indexPath(for item: MediaGalleryItem) -> IndexPath? { - // Search backwards because people view recent items. - // Note: we could use binary search because orderedKeys is sorted. - guard let sectionIndex = sections.orderedKeys.lastIndex(of: item.galleryDate), - let itemIndex = sections[sectionIndex].value.lastIndex(of: item) else { - return nil - } - - return IndexPath(item: itemIndex, section: sectionIndex) - } - - /// Returns the path to the next item after `path` (ignoring sections). - /// - /// If `path` refers to the last item in the gallery, returns `nil`. - private func indexPath(after path: IndexPath) -> IndexPath? { - owsAssert(path.count == 2) - var result = path - - // Next item? - result.item += 1 - if result.item < sections[result.section].value.count { - return result - } - - // Next section? - result.item = 0 - result.section += 1 - if result.section < sections.count { - owsAssertDebug(!sections[result.section].value.isEmpty, "no empty sections") - return result - } - - // Reached the end. - return nil - } - - /// Returns the path to the item just before `path` (ignoring sections). - /// - /// If `path` refers to the first item in the gallery, returns `nil`. - private func indexPath(before path: IndexPath) -> IndexPath? { - owsAssert(path.count == 2) - var result = path - - // Previous item? - if result.item > 0 { - result.item -= 1 - return result - } - - // Previous section? - if result.section > 0 { - result.section -= 1 - owsAssertDebug(!sections[result.section].value.isEmpty, "no empty sections") - result.item = sections[result.section].value.count - 1 - return result - } - - // Reached the start. - return nil + return sections.indexPath(for: item) } /// Returns the item at `path`, which will be `nil` if not yet loaded. /// /// `path` must be a valid path for the items currently loaded. internal func galleryItem(at path: IndexPath) -> MediaGalleryItem? { - owsAssert(path.count == 2) - guard let validItem: MediaGalleryItem? = sections[safe: path.section]?.value[safe: path.item] else { - owsFailDebug("invalid path") - return nil - } - // The result might still be nil if the item hasn't been loaded. - return validItem + return sections.loadedItem(at: path) } private let kGallerySwipeLoadBatchSize: Int = 5 internal func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem? { Logger.debug("") + return galleryItem(.after, item: currentItem) + } + + internal func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem? { + Logger.debug("") + return galleryItem(.before, item: currentItem) + } + + private func galleryItem(_ direction: GalleryDirection, item currentItem: MediaGalleryItem) -> MediaGalleryItem? { + let advance: (IndexPath) -> IndexPath? + switch direction { + case .around: + owsFailDebug("should not use this function with .around") + return currentItem + case .before: + advance = { self.sections.indexPath(before: $0) } + case .after: + advance = { self.sections.indexPath(after: $0) } + } if !isCurrentlyProcessingExternalDeletion { - self.ensureGalleryItemsLoaded(.after, + self.ensureGalleryItemsLoaded(direction, item: currentItem, amount: kGallerySwipeLoadBatchSize, shouldLoadAlbumRemainder: true) @@ -925,9 +677,9 @@ class MediaGallery: Dependencies { return nil } - // Repeatedly calling indexPath(after:) isn't super efficient, + // Repeatedly calling indexPath(before:) or indexPath(after:) isn't super efficient, // but we don't expect it to be more than a few steps. - let laterItemPaths = sequence(first: currentPath, next: { self.indexPath(after: $0) }).dropFirst() + let laterItemPaths = sequence(first: currentPath, next: advance).dropFirst() for nextPath in laterItemPaths { guard let loadedNextItem = galleryItem(at: nextPath) else { owsAssertDebug(isCurrentlyProcessingExternalDeletion, @@ -944,40 +696,6 @@ class MediaGallery: Dependencies { return nil } - internal func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem? { - Logger.debug("") - - if !isCurrentlyProcessingExternalDeletion { - self.ensureGalleryItemsLoaded(.before, - item: currentItem, - amount: kGallerySwipeLoadBatchSize, - shouldLoadAlbumRemainder: true) - } - - guard let currentPath = indexPath(for: currentItem) else { - owsFailDebug("current item not found") - return nil - } - - // Repeatedly calling indexPath(before:) isn't super efficient, - // but we don't expect it to be more than a few steps. - let olderItemPaths = sequence(first: currentPath, next: { self.indexPath(before: $0) }).dropFirst() - for previousPath in olderItemPaths { - guard let loadedPreviousItem = galleryItem(at: previousPath) else { - owsAssertDebug(isCurrentlyProcessingExternalDeletion, - "should have loaded the previous item already") - return nil - } - - if !deletedGalleryItems.contains(loadedPreviousItem) { - return loadedPreviousItem - } - } - - // already at first item - return nil - } - internal var galleryItemCount: Int { return databaseStorage.read { transaction in return Int(mediaGalleryFinder.mediaCount(excluding: deletedAttachmentIds, @@ -1003,3 +721,60 @@ extension MediaGallery: DatabaseChangeDelegate { // no-op } } + +extension MediaGallery { + internal struct Loader: MediaGallerySectionLoader { + typealias EnumerationCompletion = MediaGalleryFinder.EnumerationCompletion + typealias Item = MediaGalleryItem + + fileprivate unowned var mediaGallery: MediaGallery + + func numberOfItemsInSection(for date: GalleryDate, transaction: SDSAnyReadTransaction) -> Int { + Int(mediaGallery.mediaGalleryFinder.mediaCount(in: date.interval, + excluding: mediaGallery.deletedAttachmentIds, + transaction: transaction.unwrapGrdbRead)) + } + + func enumerateTimestamps(before date: Date, + count: Int, + transaction: SDSAnyReadTransaction, + block: (Date) -> Void) -> EnumerationCompletion { + mediaGallery.mediaGalleryFinder.enumerateTimestamps(before: date, + excluding: mediaGallery.deletedAttachmentIds, + count: count, + transaction: transaction.unwrapGrdbRead, + block: block) + } + + func enumerateTimestamps(after date: Date, + count: Int, + transaction: SDSAnyReadTransaction, + block: (Date) -> Void) -> EnumerationCompletion { + mediaGallery.mediaGalleryFinder.enumerateTimestamps(after: date, + excluding: mediaGallery.deletedAttachmentIds, + count: count, + transaction: transaction.unwrapGrdbRead, + block: block) + } + + func enumerateItems(in interval: DateInterval, + range: Range, + transaction: SDSAnyReadTransaction, + block: (_ offset: Int, _ uniqueId: String, _ buildItem: () -> MediaGalleryItem) -> Void) { + mediaGallery.mediaGalleryFinder.enumerateMediaAttachments(in: interval, + excluding: mediaGallery.deletedAttachmentIds, + range: NSRange(range), + transaction: transaction.unwrapGrdbRead) { + offset, attachment in + + block(offset, attachment.uniqueId) { + guard let item: MediaGalleryItem = mediaGallery.buildGalleryItem(attachment: attachment, + transaction: transaction) else { + owsFail("unexpectedly failed to buildGalleryItem for attachment #\(offset) \(attachment)") + } + return item + } + } + } + } +} diff --git a/Signal/src/ViewControllers/MediaGallery/MediaGallerySections.swift b/Signal/src/ViewControllers/MediaGallery/MediaGallerySections.swift new file mode 100644 index 0000000000..acaa4df28f --- /dev/null +++ b/Signal/src/ViewControllers/MediaGallery/MediaGallerySections.swift @@ -0,0 +1,578 @@ +// +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. +// + +import Foundation +import SignalServiceKit + +/// The minimal requirements needed for items loaded and managed by MediaGallerySections. +internal protocol MediaGallerySectionItem { + var uniqueId: String { get } + var galleryDate: GalleryDate { get } +} + +/// Represents a source of items ordered by timestamp and partitioned by gallery date. +internal protocol MediaGallerySectionLoader { + associatedtype Item: MediaGallerySectionItem + + /// Should return the number of items in the section represented by `date`. + /// + /// In practice this should be the items within the date's `interval`. + func numberOfItemsInSection(for date: GalleryDate, transaction: SDSAnyReadTransaction) -> Int + + /// Should call `block` once for every item (loaded or unloaded) before `date`, up to `count` times. + func enumerateTimestamps(before date: Date, + count: Int, + transaction: SDSAnyReadTransaction, + block: (Date) -> Void) -> MediaGalleryFinder.EnumerationCompletion + /// Should call `block` once for every item (loaded or unloaded) after `date`, up to `count` times. + func enumerateTimestamps(after date: Date, + count: Int, + transaction: SDSAnyReadTransaction, + block: (Date) -> Void) -> MediaGalleryFinder.EnumerationCompletion + + /// Should selects a range of items in `interval` and call `block` once for each. + /// + /// For example, if `interval` represents May 2022 and there are seven photos received during this interval, + /// a `range` of `2..<5` should call `block` with the offsets 2, 3, and 4, in that order. `uniqueId` will be used + /// to determine whether the item needs to be built fresh or whether an existing object can be used. + func enumerateItems(in interval: DateInterval, + range: Range, + transaction: SDSAnyReadTransaction, + block: (_ offset: Int, _ uniqueId: String, _ buildItem: () -> Item) -> Void) +} + +/// The underlying model for MediaGallery, itself the backing store for media views (page-based or tile-based) +/// +/// MediaGallerySections models a list of GalleryDate-based sections, each of which has a certain number of items. +/// Sections are loaded on demand (that is, there may be newer and older sections that are not in the model), and always +/// know their number of items. Items are also loaded on demand, potentially non-contiguously. +/// +/// This model is designed around the needs of UICollectionView, but it also supports flat views of media. +internal struct MediaGallerySections: Dependencies { + internal typealias Item = Loader.Item + + private let loader: Loader + + /// All sections we know about. + /// + /// Each section contains an array of possibly-fetched items. + /// The length of the array is always the correct number of items in the section. + /// The keys are kept in sorted order. + private(set) var itemsBySection: OrderedDictionary = OrderedDictionary() + private(set) var hasFetchedOldest = false + private(set) var hasFetchedMostRecent = false + + internal init(loader: Loader) { + self.loader = loader + } + + // MARK: Sections + + /// Loads at least one section before the oldest section, though not any of the items in it. + /// + /// Operates in bulk in an attempt to cut down on database traffic, meaning it may measure multiple sections at once. + /// + /// Returns the number of new sections loaded, which can be used to update section indexes. + internal mutating func loadEarlierSections(batchSize: Int, transaction: SDSAnyReadTransaction) -> Int { + guard !hasFetchedOldest else { + return 0 + } + + var newSectionCounts: [GalleryDate: Int] = [:] + let earliestDate = itemsBySection.orderedKeys.first?.interval.start ?? .distantFutureForMillisecondTimestamp + + var newEarliestDate: GalleryDate? + let result = loader.enumerateTimestamps(before: earliestDate, + count: batchSize, + transaction: transaction) { timestamp in + let galleryDate = GalleryDate(date: timestamp) + newSectionCounts[galleryDate, default: 0] += 1 + owsAssertDebug(newEarliestDate == nil || galleryDate <= newEarliestDate!, + "expects timestamps to be fetched in descending order") + newEarliestDate = galleryDate + } + + if result == .reachedEnd { + hasFetchedOldest = true + } else { + // Make sure we have the full count for the earliest loaded section. + newSectionCounts[newEarliestDate!] = loader.numberOfItemsInSection(for: newEarliestDate!, + transaction: transaction) + } + + if itemsBySection.isEmpty { + hasFetchedMostRecent = true + } + + let sortedDates = newSectionCounts.keys.sorted() + owsAssertDebug(itemsBySection.isEmpty || sortedDates.isEmpty || sortedDates.last! < itemsBySection.orderedKeys.first!) + for date in sortedDates.reversed() { + itemsBySection.prepend(key: date, value: Array(repeating: nil, count: newSectionCounts[date]!)) + } + return sortedDates.count + } + + /// Loads at least one section after the latest section, though not any of the items in it. + /// + /// Operates in bulk in an attempt to cut down on database traffic, meaning it may measure multiple sections at once. + /// + /// Returns the number of new sections loaded. + internal mutating func loadLaterSections(batchSize: Int, transaction: SDSAnyReadTransaction) -> Int { + guard !hasFetchedMostRecent else { + return 0 + } + + var newSectionCounts: [GalleryDate: Int] = [:] + let latestDate = itemsBySection.orderedKeys.last?.interval.end ?? Date(millisecondsSince1970: 0) + + var newLatestDate: GalleryDate? + let result = loader.enumerateTimestamps(after: latestDate, + count: batchSize, + transaction: transaction) { timestamp in + let galleryDate = GalleryDate(date: timestamp) + newSectionCounts[galleryDate, default: 0] += 1 + owsAssertDebug(newLatestDate == nil || newLatestDate! <= galleryDate, + "expects timestamps to be fetched in ascending order") + newLatestDate = galleryDate + } + + if result == .reachedEnd { + hasFetchedMostRecent = true + } else { + // Make sure we have the full count for the latest loaded section. + newSectionCounts[newLatestDate!] = loader.numberOfItemsInSection(for: newLatestDate!, + transaction: transaction) + } + + if itemsBySection.isEmpty { + hasFetchedOldest = true + } + + let sortedDates = newSectionCounts.keys.sorted() + owsAssertDebug(itemsBySection.isEmpty || sortedDates.isEmpty || itemsBySection.orderedKeys.last! < sortedDates.first!) + for date in sortedDates { + itemsBySection.append(key: date, value: Array(repeating: nil, count: newSectionCounts[date]!)) + } + return sortedDates.count + } + + internal mutating func loadInitialSection(for date: GalleryDate, transaction: SDSAnyReadTransaction) { + owsAssert(isEmpty, "already has sections, use loadEarlierSections or loadLaterSections") + let count = loader.numberOfItemsInSection(for: date, transaction: transaction) + itemsBySection.append(key: date, value: Array(repeating: nil, count: count)) + } + + internal mutating func reloadSection(for date: GalleryDate, transaction: SDSAnyReadTransaction) { + let newCount = loader.numberOfItemsInSection(for: date, transaction: transaction) + itemsBySection.replace(key: date, value: Array(repeating: nil, count: newCount)) + } + + // MARK: Items + + /// Returns the item at `path`, which will be `nil` if not yet loaded. + /// + /// `path` must be a valid path for the items currently loaded. + internal func loadedItem(at path: IndexPath) -> Item? { + owsAssert(path.count == 2) + guard let validItem: Item? = itemsBySection[safe: path.section]?.value[safe: path.item] else { + owsFailDebug("invalid path \(path)") + return nil + } + // The result might still be nil if the item hasn't been loaded. + return validItem + } + + /// Searches the appropriate section for this item by its `galleryDate` and `uniqueId`. + /// + /// Will return nil if the item is not in `itemsBySection` (say, if it was loaded externally). + internal func indexPath(for item: Item) -> IndexPath? { + // Search backwards because people view recent items. + // Note: we could use binary search because orderedKeys is sorted. + guard let sectionIndex = itemsBySection.orderedKeys.lastIndex(of: item.galleryDate), + let itemIndex = itemsBySection[sectionIndex].value.lastIndex(where: { + $0?.uniqueId == item.uniqueId + }) else { + return nil + } + + return IndexPath(item: itemIndex, section: sectionIndex) + } + + /// Returns the path to the next item after `path`, which may cross a section boundary. + /// + /// If `path` refers to the last item in the loaded sections, returns `nil`. + internal func indexPath(after path: IndexPath) -> IndexPath? { + owsAssert(path.count == 2) + var result = path + + // Next item? + result.item += 1 + if result.item < itemsBySection[result.section].value.count { + return result + } + + // Next section? + result.item = 0 + result.section += 1 + if result.section < itemsBySection.count { + owsAssertDebug(!itemsBySection[result.section].value.isEmpty, "no empty sections") + return result + } + + // Reached the end. + return nil + } + + /// Returns the path to the item just before `path`, which may cross a section boundary. + /// + /// If `path` refers to the first item in the loaded sections, returns `nil`. + internal func indexPath(before path: IndexPath) -> IndexPath? { + owsAssert(path.count == 2) + var result = path + + // Previous item? + if result.item > 0 { + result.item -= 1 + return result + } + + // Previous section? + if result.section > 0 { + result.section -= 1 + owsAssertDebug(!itemsBySection[result.section].value.isEmpty, "no empty sections") + result.item = itemsBySection[result.section].value.count - 1 + return result + } + + // Reached the start. + return nil + } + + /// Given `naiveIndex` that refers to an item in or before `initialSectionIndex`, find the actual item and section + /// index. + /// + /// For example, if section 1 has 5 items, `resolveNaiveStartIndex(-2, relativeToSection: 2)` will return + /// `IndexPath(item: 3, section: 1)`. + /// + /// If the search reaches the first section, `maybeLoadEarlierSections` will be invoked. It should return the + /// number of sections that have been loaded, which will adjust all section indexes. The default value always + /// returns 0. If the earliest section has been loaded, this will clamp the index to item 0 in section 0; + /// otherwise the method returns `nil`. + /// + /// This is essentially a more powerful version of `indexPath(before:)`. + internal mutating func resolveNaiveStartIndex( + _ naiveIndex: Int, + relativeToSection initialSectionIndex: Int, + maybeLoadEarlierSections: (inout Self) -> Int + ) -> IndexPath? { + guard naiveIndex < 0 else { + let items = itemsBySection[initialSectionIndex].value + owsAssertDebug(naiveIndex <= items.count, "should not be used for indexes after the current section") + return IndexPath(item: naiveIndex, section: initialSectionIndex) + } + + var currentSectionIndex = initialSectionIndex + var offsetInCurrentSection = naiveIndex + while offsetInCurrentSection < 0 { + if currentSectionIndex == 0 { + let newlyLoadedCount = maybeLoadEarlierSections(&self) + currentSectionIndex = newlyLoadedCount + + if currentSectionIndex == 0 { + if hasFetchedOldest { + offsetInCurrentSection = 0 + break + } else { + return nil + } + } + } + + currentSectionIndex -= 1 + let items = itemsBySection[currentSectionIndex].value + offsetInCurrentSection += items.count + } + + return IndexPath(item: offsetInCurrentSection, section: currentSectionIndex) + } + + /// Equivalant to calling the three-argument `resolveNaiveStartIndex` with a `maybeLoadEarlierSections` that always + /// returns 0. + internal func resolveNaiveStartIndex(_ naiveIndex: Int, relativeToSection initialSectionIndex: Int) -> IndexPath? { + // The three-argument form is `mutating`, but only so it can load more sections. + // Thanks to Swift's copy-on-write data types, this will only do a few retains even in non-optimized builds. + var mutableSelf = self + return mutableSelf.resolveNaiveStartIndex(naiveIndex, relativeToSection: initialSectionIndex) { _ in + return 0 + } + } + + /// Given `naiveIndex` that refers to an end index in or after `initialSectionIndex`, find the actual item and + /// section index. + /// + /// For example, if section 1 has 5 items and section 2 has 3, `resolveNaiveStartIndex(7, relativeToSection: 1)` + /// will return `IndexPath(item: 2, section: 2)`. `resolveNaiveStartIndex(5, relativeToSection: 1)` will return + /// `IndexPath(item: 5, section: 1)` rather than `IndexPath(item: 0, section: 2)`. + /// + /// If the search reaches the true last section, this will clamp the result to the IndexPath representing the + /// end index of the final section (note: not a valid item index!). + /// If it reaches the last *loaded* section but there might be more sections, it returns nil. + /// + /// This is essentially a more powerful version of `indexPath(after:)`. + internal func resolveNaiveEndIndex(_ naiveIndex: Int, relativeToSection initialSectionIndex: Int) -> IndexPath? { + owsAssert(naiveIndex >= 0, "should not be used for indexes before the current section") + + var currentSectionIndex = initialSectionIndex + var limitInCurrentSection = naiveIndex + while true { + let items = itemsBySection[currentSectionIndex].value + + if limitInCurrentSection <= items.count { + break + } + limitInCurrentSection -= items.count + currentSectionIndex += 1 + + if currentSectionIndex == itemsBySection.count { + if hasFetchedMostRecent { + // Back up to the end of the previous section. + currentSectionIndex -= 1 + limitInCurrentSection = items.count + break + } else { + return nil + } + } + } + + return IndexPath(item: limitInCurrentSection, section: currentSectionIndex) + } + + /// Trims indexes from the start of `naiveRange` if they refer to loaded items. + /// + /// Both `naiveRange` and the result may have a negative `startIndex`, which indicates indexing backwards into + /// previous sections. + internal func trimLoadedItemsAtStart(from naiveRange: inout Range, relativeToSection sectionIndex: Int) { + guard let resolvedIndexPath = resolveNaiveStartIndex(naiveRange.startIndex, + relativeToSection: sectionIndex) else { + // Need to load more sections; can't trim from the start. + return + } + + var currentSectionIndex = resolvedIndexPath.section + var offsetInCurrentSection = resolvedIndexPath.item + + // Now walk forward counting loaded (non-nil) items. + var countToTrim = 0 + while true { + let currentSection = itemsBySection[currentSectionIndex].value[offsetInCurrentSection...] + let countLoadedPrefix = currentSection.prefix { $0 != nil }.count + countToTrim += countLoadedPrefix + + if countLoadedPrefix < currentSection.count || currentSectionIndex == sectionIndex { + break + } + + // Start over in the next section at index 0. + currentSectionIndex += 1 + offsetInCurrentSection = 0 + } + + countToTrim = min(countToTrim, naiveRange.count) + naiveRange.removeFirst(countToTrim) + } + + /// Trims indexes from the end of `naiveRange` if they refer to loaded items. + /// + /// Both `naiveRange` and the result may have a negative `startIndex`, which indicates indexing backwards into + /// previous sections. However, the `endIndex` must be non-negative. + internal func trimLoadedItemsAtEnd(from naiveRange: inout Range, relativeToSection sectionIndex: Int) { + // I considered unifying this with trimLoadedItemsAtStart(from:relativeToSection:), + // but there's just so much that varies even though the control flow is exactly the same. + guard let resolvedIndexPath = resolveNaiveEndIndex(naiveRange.endIndex, + relativeToSection: sectionIndex) else { + // Need to load more sections; can't trim this end. + return + } + + var currentSectionIndex = resolvedIndexPath.section + var limitInCurrentSection = resolvedIndexPath.item + + // Now walk backward counting loaded (non-nil) items. + var countToTrim = 0 + while true { + let currentSection = itemsBySection[currentSectionIndex].value[.., + relativeToSection sectionIndex: Int) -> IndexSet { + var trimmedRange = naiveRange + trimLoadedItemsAtStart(from: &trimmedRange, relativeToSection: sectionIndex) + trimLoadedItemsAtEnd(from: &trimmedRange, relativeToSection: sectionIndex) + if trimmedRange.isEmpty { + return IndexSet() + } + + var numNewlyLoadedEarlierSections: Int = 0 + var numNewlyLoadedLaterSections: Int = 0 + + Bench(title: "fetching gallery items") { + self.databaseStorage.read { transaction in + // Figure out the earliest section this request will cross. + let requestStartPath = resolveNaiveStartIndex(trimmedRange.startIndex, + relativeToSection: sectionIndex) { innerSelf in + let newlyLoadedCount = innerSelf.loadEarlierSections(batchSize: trimmedRange.count, + transaction: transaction) + numNewlyLoadedEarlierSections += newlyLoadedCount + return newlyLoadedCount + } + guard let requestStartPath = requestStartPath else { + owsFail("failed to resolve despite loading \(numNewlyLoadedEarlierSections) earlier sections") + } + + var currentSectionIndex = requestStartPath.section + let interval = DateInterval(start: itemsBySection.orderedKeys[currentSectionIndex].interval.start, + end: .distantFutureForMillisecondTimestamp) + let requestRange = requestStartPath.item ..< (requestStartPath.item + trimmedRange.count) + + var offset = 0 + loader.enumerateItems(in: interval, + range: requestRange, + transaction: transaction) { i, uniqueId, buildItem in + owsAssertDebug(i >= offset, "does not support reverse traversal") + + var (date, items) = itemsBySection[currentSectionIndex] + var itemIndex = i - offset + + while itemIndex >= items.count { + itemIndex -= items.count + offset += items.count + currentSectionIndex += 1 + + if currentSectionIndex >= itemsBySection.count { + if hasFetchedMostRecent { + // Ignore later attachments. + owsAssertDebug(itemsBySection.count == 1, "should only be used in single-album page view") + return + } + numNewlyLoadedLaterSections += loadLaterSections(batchSize: trimmedRange.count, + transaction: transaction) + if currentSectionIndex >= itemsBySection.count { + owsFailDebug("attachment #\(i) \(uniqueId) is beyond the last section") + return + } + } + + (date, items) = itemsBySection[currentSectionIndex] + } + + if let loadedItem = items[itemIndex] { + owsAssert(loadedItem.uniqueId == uniqueId) + return + } + + let item = buildItem() + owsAssertDebug(item.galleryDate == date, + "item from \(item.galleryDate) put into section for \(date)") + + // Performance hack: clear out the current 'items' array in 'sections' to avoid copy-on-write. + itemsBySection.replace(key: date, value: []) + items[itemIndex] = item + itemsBySection.replace(key: date, value: items) + } + } + } + + let firstNewLaterSectionIndex = itemsBySection.count - numNewlyLoadedLaterSections + var newlyLoadedSections = IndexSet() + newlyLoadedSections.insert(integersIn: 0.. Item? { + // Assume we've set up this section, but may or may not have initialized the item. + guard var items = itemsBySection[newItem.galleryDate] else { + owsFailDebug("section for focused item not found") + return nil + } + guard offsetInSection < items.count else { + owsFailDebug("offset is out of bounds in section") + return nil + } + + if let existingItem = items[offsetInSection] { + return existingItem + } + + // Swap out the section items to avoid copy-on-write. + itemsBySection.replace(key: newItem.galleryDate, value: []) + items[offsetInSection] = newItem + itemsBySection.replace(key: newItem.galleryDate, value: items) + return newItem + } + + /// Removes a set of items by their paths. + /// + /// If any sections are reduced to zero items, they are removed from the model. The indexes of all such removed + /// sections are returned. + internal mutating func removeLoadedItems(atIndexPaths paths: [IndexPath]) -> IndexSet { + var removedSections = IndexSet() + + // Iterate in reverse so the index paths don't get disrupted as we remove items. + for path in paths.sorted().reversed() { + let sectionKey = itemsBySection.orderedKeys[path.section] + // Swap out / swap in to avoid copy-on-write. + var section = itemsBySection.replace(key: sectionKey, value: []) + + if section.remove(at: path.item) == nil { + owsFailDebug("removed an item that wasn't loaded, which isn't permitted") + } + + if section.isEmpty { + itemsBySection.remove(at: path.section) + removedSections.insert(path.section) + } else { + itemsBySection.replace(key: sectionKey, value: section) + } + } + + return removedSections + } + + // MARK: Passthrough members + + internal var isEmpty: Bool { itemsBySection.isEmpty } + internal subscript(_ date: GalleryDate) -> [Item?]? { itemsBySection[date] } + internal var sectionDates: [GalleryDate] { itemsBySection.orderedKeys } +} diff --git a/Signal/src/ViewControllers/MediaGallery/MediaTileViewController.swift b/Signal/src/ViewControllers/MediaGallery/MediaTileViewController.swift index 9a08fc04d1..74f7eded02 100644 --- a/Signal/src/ViewControllers/MediaGallery/MediaTileViewController.swift +++ b/Signal/src/ViewControllers/MediaGallery/MediaTileViewController.swift @@ -15,8 +15,6 @@ fileprivate extension IndexSet { @objc public class MediaTileViewController: UICollectionViewController, MediaGalleryDelegate, UICollectionViewDelegateFlowLayout { - private var galleryDates: [GalleryDate] { return mediaGallery.sections.orderedKeys } - private let thread: TSThread private lazy var mediaGallery: MediaGallery = { let mediaGallery = MediaGallery(thread: thread) @@ -106,21 +104,20 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDe override public func viewWillAppear(_ animated: Bool) { defer { super.viewWillAppear(animated) } - if mediaGallery.sections.isEmpty { + if mediaGallery.galleryDates.isEmpty { databaseStorage.read { transaction in _ = self.mediaGallery.loadEarlierSections(batchSize: kLoadBatchSize, transaction: transaction) } - if mediaGallery.sections.isEmpty { + if mediaGallery.galleryDates.isEmpty { // There must be no media. return } } self.view.layoutIfNeeded() - let lastSectionItemCount = self.collectionView(self.collectionView!, - numberOfItemsInSection: self.galleryDates.count) + let lastSectionItemCount = mediaGallery.numberOfItemsInSection(mediaGallery.galleryDates.count - 1) self.collectionView.scrollToItem(at: IndexPath(item: lastSectionItemCount - 1, - section: self.galleryDates.count), + section: mediaGallery.galleryDates.count), at: .bottom, animated: false) } @@ -164,7 +161,7 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDe override public func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) { defer { previousAdjustedContentInset = scrollView.adjustedContentInset } - guard !galleryDates.isEmpty else { + guard !mediaGallery.galleryDates.isEmpty else { return } @@ -182,7 +179,7 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDe Logger.debug("") - guard galleryDates.count > 0 else { + guard !mediaGallery.galleryDates.isEmpty else { return false } @@ -198,7 +195,7 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDe Logger.debug("") - guard galleryDates.count > 0 else { + guard !mediaGallery.galleryDates.isEmpty else { return false } @@ -214,7 +211,7 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDe Logger.debug("") - guard galleryDates.count > 0 else { + guard !mediaGallery.galleryDates.isEmpty else { return false } @@ -267,19 +264,19 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDe override public func numberOfSections(in collectionView: UICollectionView) -> Int { Logger.debug("") - guard galleryDates.count > 0 else { + guard !mediaGallery.galleryDates.isEmpty else { // empty gallery return 1 } // One for each galleryDate plus a "loading older" and "loading newer" section - return galleryDates.count + 2 + return mediaGallery.galleryDates.count + 2 } override public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection sectionIdx: Int) -> Int { Logger.debug("\(sectionIdx)") - guard galleryDates.count > 0 else { + guard !mediaGallery.galleryDates.isEmpty else { // empty gallery return 0 } @@ -294,19 +291,14 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDe return 0 } - guard let count = mediaGallery.sections[safe: sectionIdx - 1]?.value.count else { - owsFailDebug("unknown section: \(sectionIdx)") - return 0 - } - - return count + return mediaGallery.numberOfItemsInSection(sectionIdx - 1) } override public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { let defaultView = UICollectionReusableView() - guard galleryDates.count > 0 else { + guard !mediaGallery.galleryDates.isEmpty else { guard let sectionHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: MediaGalleryStaticHeader.reuseIdentifier, for: indexPath) as? MediaGalleryStaticHeader else { owsFailDebug("unable to build section header for kLoadOlderSectionIdx") @@ -342,7 +334,7 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDe owsFailDebug("unable to build section header for indexPath: \(indexPath)") return defaultView } - guard let date = self.galleryDates[safe: indexPath.section - 1] else { + guard let date = mediaGallery.galleryDates[safe: indexPath.section - 1] else { owsFailDebug("unknown section for indexPath: \(indexPath)") return defaultView } @@ -360,7 +352,7 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDe let defaultCell = UICollectionViewCell() - guard galleryDates.count > 0 else { + guard !mediaGallery.galleryDates.isEmpty else { owsFailDebug("unexpected cell for loadNewerSectionIdx") return defaultCell } @@ -478,7 +470,7 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDe let kMonthHeaderSize: CGSize = CGSize(width: 0, height: 50) let kStaticHeaderSize: CGSize = CGSize(width: 0, height: 100) - guard galleryDates.count > 0 else { + guard !mediaGallery.galleryDates.isEmpty else { return kStaticHeaderSize } @@ -692,7 +684,7 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDe return } - guard galleryDates.count > 0 else { + guard !mediaGallery.galleryDates.isEmpty else { // Show Empty self.collectionView?.reloadData() return @@ -715,7 +707,7 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDe let kLoadOlderSectionIdx: Int = 0 var loadNewerSectionIdx: Int { - return galleryDates.count + 1 + return mediaGallery.galleryDates.count + 1 } public func autoLoadMoreIfNecessary() { @@ -760,7 +752,8 @@ public class MediaTileViewController: UICollectionViewController, MediaGalleryDe case .after: let newSectionCount = mediaGallery.loadLaterSections(batchSize: kLoadBatchSize, transaction: transaction) - newSections = (mediaGallery.sections.count - newSectionCount).., relativeToSection sectionIndex: Int) -> Range { + 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, relativeToSection sectionIndex: Int) -> Range { + var result = naiveRange + trimLoadedItemsAtEnd(from: &result, relativeToSection: sectionIndex) + return result + } +} + +// MARK: - + +private struct FakeItem: MediaGallerySectionItem, Equatable { + var uniqueId: String + 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.uniqueId = UUID().uuidString + self.timestamp = Date(compressedDate: compressedDate) + } +} + +/// Takes the place of the database for MediaGallerySection tests. +private class FakeGalleryStore: MediaGallerySectionLoader { + typealias Item = FakeItem + typealias EnumerationCompletion = MediaGalleryFinder.EnumerationCompletion + + let allItems: [Item] + let itemsBySection: OrderedDictionary + var mostRecentRequest: Range = 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 numberOfItemsInSection(for date: GalleryDate, transaction: SDSAnyReadTransaction) -> 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, 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: (Date) -> 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($0.timestamp) + } + } + + func enumerateTimestamps(after date: Date, + count: Int, + transaction: SDSAnyReadTransaction, + block: (Date) -> 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($0.timestamp) + } + } + + func enumerateItems(in interval: DateInterval, + range: Range, + transaction: SDSAnyReadTransaction, + block: (_ offset: Int, _ uniqueId: String, _ 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.uniqueId, { item }) + } + } +} + +/// A store initialized with items in Jan, Apr, and Sep 2021. +private let standardFakeStore: FakeGalleryStore = FakeGalleryStore([ + 2021_01_01, + 2021_01_02, + 2021_01_20, + + 2021_04_01, + 2021_04_13, + + 2021_09_09, + 2021_09_09, + 2021_09_09, + 2021_09_30, + 2021_09_30 +].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.uniqueId, item2.uniqueId) + } + + func testNumberOfItemsInSection() { + let store = standardFakeStore + + databaseStorage.read { transaction in + XCTAssertEqual(3, store.numberOfItemsInSection(for: GalleryDate(2021_01_01), transaction: transaction)) + XCTAssertEqual(0, store.numberOfItemsInSection(for: GalleryDate(2021_02_01), transaction: transaction)) + XCTAssertEqual(2, store.numberOfItemsInSection(for: GalleryDate(2021_04_01), transaction: transaction)) + XCTAssertEqual(5, store.numberOfItemsInSection(for: GalleryDate(2021_09_01), transaction: transaction)) + } + } + + func testEnumerateAfter() { + let store = standardFakeStore + + databaseStorage.read { transaction in + var results: [Date] = [] + XCTAssertEqual(.finished, + store.enumerateTimestamps(after: .distantPast, count: 4, transaction: transaction) { + results.append($0) + }) + XCTAssertEqual(results, store.allItems.prefix(4).map { $0.timestamp }) + + results.removeAll() + XCTAssertEqual(.finished, + store.enumerateTimestamps(after: .distantPast, count: 10, transaction: transaction) { + results.append($0) + }) + XCTAssertEqual(results, store.allItems.map { $0.timestamp }) + + results.removeAll() + XCTAssertEqual(.reachedEnd, + store.enumerateTimestamps(after: .distantPast, count: 11, transaction: transaction) { + results.append($0) + }) + XCTAssertEqual(results, store.allItems.map { $0.timestamp }) + + results.removeAll() + XCTAssertEqual(.finished, + store.enumerateTimestamps(after: Date(compressedDate: 2021_04_01), + count: 3, + transaction: transaction) { + results.append($0) + }) + 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) { + results.append($0) + }) + XCTAssertEqual(results, store.allItems[3...].map { $0.timestamp }) + + results.removeAll() + XCTAssertEqual(.reachedEnd, + store.enumerateTimestamps(after: Date(compressedDate: 2022_04_01), + count: 17, + transaction: transaction) { + results.append($0) + }) + XCTAssertEqual(results, []) + + results.removeAll() + XCTAssertEqual(.finished, + store.enumerateTimestamps(after: Date(compressedDate: 2022_04_01), + count: 0, + transaction: transaction) { + results.append($0) + }) + XCTAssertEqual(results, []) + } + } + + func testEnumerateBefore() { + let store = standardFakeStore + + databaseStorage.read { transaction in + var results: [Date] = [] + XCTAssertEqual(.finished, + store.enumerateTimestamps(before: .distantFuture, count: 4, transaction: transaction) { + results.append($0) + }) + XCTAssertEqual(results, store.allItems.reversed().prefix(4).map { $0.timestamp }) + + results.removeAll() + XCTAssertEqual(.finished, + store.enumerateTimestamps(before: .distantFuture, count: 10, transaction: transaction) { + results.append($0) + }) + XCTAssertEqual(results, store.allItems.reversed().map { $0.timestamp }) + + results.removeAll() + XCTAssertEqual(.reachedEnd, + store.enumerateTimestamps(before: .distantFuture, count: 11, transaction: transaction) { + results.append($0) + }) + XCTAssertEqual(results, store.allItems.reversed().map { $0.timestamp }) + + results.removeAll() + XCTAssertEqual(.finished, + store.enumerateTimestamps(before: Date(compressedDate: 2021_04_01), + count: 2, + transaction: transaction) { + results.append($0) + }) + 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) { + results.append($0) + }) + 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) { + results.append($0) + }) + XCTAssertEqual(results, []) + } + } + + func testEnumerateItems() { + let store = standardFakeStore + + databaseStorage.read { transaction in + var results: [String] = [] + let saveToResults = { (offset: Int, uniqueId: String, buildItem: () -> FakeItem) in + results.append(uniqueId) + XCTAssertEqual(uniqueId, buildItem().uniqueId) + } + + store.enumerateItems(in: GalleryDate(2021_01_01).interval, + range: 1..<3, + transaction: transaction, + block: saveToResults) + XCTAssertEqual(store.allItems[1..<3].map { $0.uniqueId }, 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.uniqueId }, results) + + results.removeAll() + store.enumerateItems(in: GalleryDate(2021_09_01).interval, + range: 0..<20, + transaction: transaction, + block: saveToResults) + XCTAssertEqual(store.allItems[5...].map { $0.uniqueId }, 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 { + func testLoadSectionsBackward() { + var sections = MediaGallerySections(loader: standardFakeStore) + XCTAssertEqual(sections.itemsBySection.count, 0) + XCTAssertFalse(sections.hasFetchedOldest) + XCTAssertFalse(sections.hasFetchedMostRecent) + + databaseStorage.read { transaction in + XCTAssertEqual(1, sections.loadEarlierSections(batchSize: 4, transaction: transaction)) + XCTAssertEqual(1, sections.itemsBySection.count) + XCTAssertEqual([GalleryDate(2021_09_01)], sections.itemsBySection.orderedKeys) + XCTAssertEqual([5], sections.itemsBySection.orderedValues.map { $0.count }) + XCTAssertFalse(sections.hasFetchedOldest) + XCTAssertTrue(sections.hasFetchedMostRecent) + + XCTAssertEqual(2, sections.loadEarlierSections(batchSize: 4, transaction: transaction)) + XCTAssertEqual(3, sections.itemsBySection.count) + XCTAssertEqual([GalleryDate(2021_01_01), GalleryDate(2021_04_01), GalleryDate(2021_09_01)], + sections.itemsBySection.orderedKeys) + XCTAssertEqual([3, 2, 5], sections.itemsBySection.orderedValues.map { $0.count }) + XCTAssertFalse(sections.hasFetchedOldest) + XCTAssertTrue(sections.hasFetchedMostRecent) + + XCTAssertEqual(0, sections.loadEarlierSections(batchSize: 4, transaction: transaction)) + XCTAssertEqual(3, sections.itemsBySection.count) + XCTAssertEqual([GalleryDate(2021_01_01), GalleryDate(2021_04_01), GalleryDate(2021_09_01)], + sections.itemsBySection.orderedKeys) + XCTAssertEqual([3, 2, 5], sections.itemsBySection.orderedValues.map { $0.count }) + XCTAssertTrue(sections.hasFetchedOldest) + XCTAssertTrue(sections.hasFetchedMostRecent) + } + } + + func testLoadSectionsForward() { + var sections = MediaGallerySections(loader: standardFakeStore) + XCTAssertEqual(sections.itemsBySection.count, 0) + XCTAssertFalse(sections.hasFetchedOldest) + XCTAssertFalse(sections.hasFetchedMostRecent) + + databaseStorage.read { transaction in + XCTAssertEqual(2, sections.loadLaterSections(batchSize: 4, transaction: transaction)) + XCTAssertEqual(2, sections.itemsBySection.count) + XCTAssertEqual([GalleryDate(2021_01_01), GalleryDate(2021_04_01)], sections.itemsBySection.orderedKeys) + XCTAssertEqual([3, 2], sections.itemsBySection.orderedValues.map { $0.count }) + XCTAssertTrue(sections.hasFetchedOldest) + XCTAssertFalse(sections.hasFetchedMostRecent) + + XCTAssertEqual(1, sections.loadLaterSections(batchSize: 4, transaction: transaction)) + XCTAssertEqual(3, sections.itemsBySection.count) + XCTAssertEqual([GalleryDate(2021_01_01), GalleryDate(2021_04_01), GalleryDate(2021_09_01)], + sections.itemsBySection.orderedKeys) + XCTAssertEqual([3, 2, 5], sections.itemsBySection.orderedValues.map { $0.count }) + XCTAssertTrue(sections.hasFetchedOldest) + XCTAssertFalse(sections.hasFetchedMostRecent) + + XCTAssertEqual(0, sections.loadLaterSections(batchSize: 4, transaction: transaction)) + XCTAssertEqual(3, sections.itemsBySection.count) + XCTAssertEqual([GalleryDate(2021_01_01), GalleryDate(2021_04_01), GalleryDate(2021_09_01)], + sections.itemsBySection.orderedKeys) + XCTAssertEqual([3, 2, 5], sections.itemsBySection.orderedValues.map { $0.count }) + XCTAssertTrue(sections.hasFetchedOldest) + XCTAssertTrue(sections.hasFetchedMostRecent) + } + } + + func testStartIndexResolution() { + var sections = MediaGallerySections(loader: standardFakeStore) + databaseStorage.read { transaction in + // Load April and September + XCTAssertEqual(2, sections.loadEarlierSections(batchSize: 6, transaction: transaction)) + XCTAssertFalse(sections.hasFetchedOldest) + + XCTAssertEqual(IndexPath(item: 0, section: 1), + sections.resolveNaiveStartIndex(0, relativeToSection: 1)) + XCTAssertEqual(IndexPath(item: 4, section: 1), + sections.resolveNaiveStartIndex(4, relativeToSection: 1)) + XCTAssertEqual(IndexPath(item: 5, section: 1), + sections.resolveNaiveStartIndex(5, relativeToSection: 1)) + + XCTAssertEqual(IndexPath(item: 1, section: 0), + sections.resolveNaiveStartIndex(-1, relativeToSection: 1)) + XCTAssertEqual(IndexPath(item: 0, section: 0), + sections.resolveNaiveStartIndex(-2, relativeToSection: 1)) + XCTAssertNil(sections.resolveNaiveStartIndex(-3, relativeToSection: 1)) + + // Load January + XCTAssertEqual(IndexPath(item: 2, section: 0), + sections.resolveNaiveStartIndex(-3, relativeToSection: 1) { sections in + XCTAssertEqual(1, sections.loadEarlierSections(batchSize: 1, transaction: transaction)) + XCTAssertFalse(sections.hasFetchedOldest) + return 1 + }) + + XCTAssertEqual(IndexPath(item: 2, section: 0), + sections.resolveNaiveStartIndex(-3, relativeToSection: 2)) + XCTAssertEqual(IndexPath(item: 0, section: 0), + sections.resolveNaiveStartIndex(-5, relativeToSection: 2)) + XCTAssertNil(sections.resolveNaiveStartIndex(-6, relativeToSection: 2)) + + // Find out that January was the earliest section. + XCTAssertEqual(IndexPath(item: 0, section: 0), + sections.resolveNaiveStartIndex(-6, relativeToSection: 2) { sections in + XCTAssertEqual(0, sections.loadEarlierSections(batchSize: 1, transaction: transaction)) + XCTAssertTrue(sections.hasFetchedOldest) + return 0 + }) + + XCTAssertEqual(IndexPath(item: 0, section: 0), + sections.resolveNaiveStartIndex(-6, relativeToSection: 2)) + } + } + + func testEndIndexResolution() { + var sections = MediaGallerySections(loader: standardFakeStore) + databaseStorage.read { transaction in + // Load January and April + XCTAssertEqual(2, sections.loadLaterSections(batchSize: 4, transaction: transaction)) + XCTAssertFalse(sections.hasFetchedMostRecent) + + XCTAssertEqual(IndexPath(item: 0, section: 0), + sections.resolveNaiveEndIndex(0, relativeToSection: 0)) + XCTAssertEqual(IndexPath(item: 2, section: 0), + sections.resolveNaiveEndIndex(2, relativeToSection: 0)) + // Note: (0, 3) rather than (1, 0), because this is an end index. + XCTAssertEqual(IndexPath(item: 3, section: 0), + sections.resolveNaiveEndIndex(3, relativeToSection: 0)) + XCTAssertEqual(IndexPath(item: 1, section: 1), + sections.resolveNaiveEndIndex(4, relativeToSection: 0)) + // Note: (1, 2) rather than nil. + XCTAssertEqual(IndexPath(item: 2, section: 1), + sections.resolveNaiveEndIndex(5, relativeToSection: 0)) + XCTAssertNil(sections.resolveNaiveEndIndex(6, relativeToSection: 0)) + + // Load September + XCTAssertEqual(1, sections.loadLaterSections(batchSize: 20, transaction: transaction)) + XCTAssertTrue(sections.hasFetchedMostRecent) + + XCTAssertEqual(IndexPath(item: 2, section: 1), + sections.resolveNaiveEndIndex(5, relativeToSection: 0)) + XCTAssertEqual(IndexPath(item: 1, section: 2), + sections.resolveNaiveEndIndex(6, relativeToSection: 0)) + XCTAssertEqual(IndexPath(item: 4, section: 2), + sections.resolveNaiveEndIndex(9, relativeToSection: 0)) + XCTAssertEqual(IndexPath(item: 5, section: 2), + sections.resolveNaiveEndIndex(10, relativeToSection: 0)) + // Reached end. + XCTAssertEqual(IndexPath(item: 5, section: 2), + sections.resolveNaiveEndIndex(11, relativeToSection: 0)) + XCTAssertEqual(IndexPath(item: 5, section: 2), + sections.resolveNaiveEndIndex(12, relativeToSection: 0)) + + XCTAssertEqual(IndexPath(item: 1, section: 1), + sections.resolveNaiveEndIndex(1, relativeToSection: 1)) + XCTAssertEqual(IndexPath(item: 2, section: 1), + sections.resolveNaiveEndIndex(2, relativeToSection: 1)) + XCTAssertEqual(IndexPath(item: 1, section: 2), + sections.resolveNaiveEndIndex(3, relativeToSection: 1)) + XCTAssertEqual(IndexPath(item: 2, section: 2), + sections.resolveNaiveEndIndex(4, relativeToSection: 1)) + XCTAssertEqual(IndexPath(item: 5, section: 2), + sections.resolveNaiveEndIndex(10, relativeToSection: 1)) + } + } + + func testLoadingFromEnd() { + let store = standardFakeStore + var sections = MediaGallerySections(loader: store) + databaseStorage.read { transaction in + // Load April and September + XCTAssertEqual(2, sections.loadEarlierSections(batchSize: 6, transaction: transaction)) + XCTAssertFalse(sections.hasFetchedOldest) + XCTAssertEqual(2, sections.itemsBySection.count) + } + + XCTAssert(sections.ensureItemsLoaded(in: 1..<3, relativeToSection: 1).isEmpty) + XCTAssertEqual(2, sections.itemsBySection.count) + XCTAssertEqual([nil, store.allItems[6], store.allItems[7], nil, nil], sections.itemsBySection[1].value) + XCTAssertEqual([nil, nil], sections.itemsBySection[0].value) + + XCTAssert(sections.ensureItemsLoaded(in: 5..<5, relativeToSection: 1).isEmpty) + XCTAssertEqual(2, sections.itemsBySection.count) + XCTAssertEqual([nil, store.allItems[6], store.allItems[7], nil, nil], sections.itemsBySection[1].value) + XCTAssertEqual([nil, nil], sections.itemsBySection[0].value) + + XCTAssert(sections.ensureItemsLoaded(in: (-2)..<3, relativeToSection: 1).isEmpty) + XCTAssertEqual(2, sections.itemsBySection.count) + XCTAssertEqual([store.allItems[5], store.allItems[6], store.allItems[7], nil, nil], sections.itemsBySection[1].value) + XCTAssertEqual([store.allItems[3], store.allItems[4]], sections.itemsBySection[0].value) + + XCTAssertEqual(IndexSet(integer: 0), sections.ensureItemsLoaded(in: (-4)..<0, relativeToSection: 1)) + XCTAssertEqual(3, sections.itemsBySection.count) + XCTAssertEqual([store.allItems[5], store.allItems[6], store.allItems[7], nil, nil], sections.itemsBySection[2].value) + XCTAssertEqual([store.allItems[3], store.allItems[4]], sections.itemsBySection[1].value) + XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], sections.itemsBySection[0].value) + } + + func testLoadingFromEndInBigJump() { + let store = standardFakeStore + var sections = MediaGallerySections(loader: store) + databaseStorage.read { transaction in + // Load September + XCTAssertEqual(1, sections.loadEarlierSections(batchSize: 1, transaction: transaction)) + XCTAssertFalse(sections.hasFetchedOldest) + XCTAssertEqual(1, sections.itemsBySection.count) + } + + XCTAssert(sections.ensureItemsLoaded(in: 1..<3, relativeToSection: 0).isEmpty) + XCTAssertEqual(1, sections.itemsBySection.count) + XCTAssertEqual([nil, store.allItems[6], store.allItems[7], nil, nil], sections.itemsBySection[0].value) + + XCTAssertEqual(IndexSet(integersIn: 0...1), sections.ensureItemsLoaded(in: (-4)..<0, relativeToSection: 0)) + XCTAssertEqual(3, sections.itemsBySection.count) + XCTAssertEqual([nil, store.allItems[6], store.allItems[7], nil, nil], sections.itemsBySection[2].value) + XCTAssertEqual([store.allItems[3], store.allItems[4]], sections.itemsBySection[1].value) + XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], sections.itemsBySection[0].value) + } + + func testLoadingFromStart() { + let store = standardFakeStore + var sections = MediaGallerySections(loader: store) + databaseStorage.read { transaction in + // Load January and April + XCTAssertEqual(2, sections.loadLaterSections(batchSize: 4, transaction: transaction)) + XCTAssertFalse(sections.hasFetchedMostRecent) + XCTAssertEqual(2, sections.itemsBySection.count) + } + + XCTAssert(sections.ensureItemsLoaded(in: 1..<3, relativeToSection: 0).isEmpty) + XCTAssertEqual(2, sections.itemsBySection.count) + XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], sections.itemsBySection[0].value) + XCTAssertEqual([nil, nil], sections.itemsBySection[1].value) + + XCTAssert(sections.ensureItemsLoaded(in: 0..<0, relativeToSection: 0).isEmpty) + XCTAssertEqual(2, sections.itemsBySection.count) + XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], sections.itemsBySection[0].value) + XCTAssertEqual([nil, nil], sections.itemsBySection[1].value) + + XCTAssert(sections.ensureItemsLoaded(in: 3..<4, relativeToSection: 0).isEmpty) + XCTAssertEqual(2, sections.itemsBySection.count) + XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], sections.itemsBySection[0].value) + XCTAssertEqual([store.allItems[3], nil], sections.itemsBySection[1].value) + + XCTAssertEqual(IndexSet(integer: 2), sections.ensureItemsLoaded(in: 3..<7, relativeToSection: 0)) + XCTAssertEqual(3, sections.itemsBySection.count) + XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], sections.itemsBySection[0].value) + XCTAssertEqual([store.allItems[3], store.allItems[4]], sections.itemsBySection[1].value) + XCTAssertEqual([store.allItems[5], store.allItems[6], nil, nil, nil], sections.itemsBySection[2].value) + } + + func testLoadingFromStartInBigJump() { + let store = standardFakeStore + var sections = MediaGallerySections(loader: store) + databaseStorage.read { transaction in + // Load January + XCTAssertEqual(1, sections.loadLaterSections(batchSize: 1, transaction: transaction)) + XCTAssertFalse(sections.hasFetchedMostRecent) + XCTAssertEqual(1, sections.itemsBySection.count) + } + + XCTAssert(sections.ensureItemsLoaded(in: 1..<3, relativeToSection: 0).isEmpty) + XCTAssertEqual(1, sections.itemsBySection.count) + XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], sections.itemsBySection[0].value) + + XCTAssert(sections.ensureItemsLoaded(in: 0..<0, relativeToSection: 0).isEmpty) + XCTAssertEqual(1, sections.itemsBySection.count) + XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], sections.itemsBySection[0].value) + + XCTAssertEqual(IndexSet(integersIn: 1...2), sections.ensureItemsLoaded(in: 3..<7, relativeToSection: 0)) + XCTAssertEqual(3, sections.itemsBySection.count) + XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], sections.itemsBySection[0].value) + XCTAssertEqual([store.allItems[3], store.allItems[4]], sections.itemsBySection[1].value) + XCTAssertEqual([store.allItems[5], store.allItems[6], nil, nil, nil], sections.itemsBySection[2].value) + } + + func testLoadingFromMiddle() { + let store = standardFakeStore + var sections = MediaGallerySections(loader: store) + databaseStorage.read { transaction in + sections.loadInitialSection(for: GalleryDate(2021_04_01), transaction: transaction) + XCTAssertEqual(1, sections.itemsBySection.count) + } + + XCTAssert(sections.ensureItemsLoaded(in: 1..<2, relativeToSection: 0).isEmpty) + XCTAssertEqual(1, sections.itemsBySection.count) + XCTAssertEqual([nil, store.allItems[4]], sections.itemsBySection[0].value) + + XCTAssertEqual(IndexSet([0, 2]), sections.ensureItemsLoaded(in: (-2)..<5, relativeToSection: 0)) + XCTAssertEqual(3, sections.itemsBySection.count) + XCTAssertEqual([nil, store.allItems[1], store.allItems[2]], sections.itemsBySection[0].value) + XCTAssertEqual([store.allItems[3], store.allItems[4]], sections.itemsBySection[1].value) + XCTAssertEqual([store.allItems[5], store.allItems[6], store.allItems[7], nil, nil], + sections.itemsBySection[2].value) + } + + func testReloadSection() { + let store = standardFakeStore + var sections = MediaGallerySections(loader: store) + databaseStorage.read { transaction in + // Load January + XCTAssertEqual(1, sections.loadLaterSections(batchSize: 1, transaction: transaction)) + XCTAssertFalse(sections.hasFetchedMostRecent) + XCTAssertEqual(1, sections.itemsBySection.count) + } + + XCTAssertEqual(IndexSet(integersIn: 1...2), sections.ensureItemsLoaded(in: 3..<7, relativeToSection: 0)) + XCTAssertEqual(3, sections.itemsBySection.count) + XCTAssertEqual([nil, nil, nil], sections.itemsBySection[0].value) + XCTAssertEqual([store.allItems[3], store.allItems[4]], sections.itemsBySection[1].value) + XCTAssertEqual([store.allItems[5], store.allItems[6], nil, nil, nil], sections.itemsBySection[2].value) + + databaseStorage.read { transaction in + sections.reloadSection(for: GalleryDate(2021_04_01), transaction: transaction) + XCTAssertEqual(3, sections.itemsBySection.count) + XCTAssertEqual([nil, nil, nil], sections.itemsBySection[0].value) + XCTAssertEqual([nil, nil], sections.itemsBySection[1].value) + XCTAssertEqual([store.allItems[5], store.allItems[6], nil, nil, nil], sections.itemsBySection[2].value) + } + } + + func testGetOrAddItem() { + let store = standardFakeStore + var sections = MediaGallerySections(loader: store) + databaseStorage.read { transaction in + // Load January + XCTAssertEqual(1, sections.loadLaterSections(batchSize: 1, transaction: transaction)) + XCTAssertFalse(sections.hasFetchedMostRecent) + XCTAssertEqual(1, sections.itemsBySection.count) + XCTAssertEqual([nil, nil, nil], sections.itemsBySection[0].value) + } + + let fakeItem = FakeItem(2021_01_05) + let newItem = sections.getOrReplaceItem(fakeItem, offsetInSection: 1) + XCTAssertEqual(fakeItem, newItem) + XCTAssertEqual([nil, fakeItem, nil], sections.itemsBySection[0].value) + + let fakeItem2 = FakeItem(2021_01_06) + let newItem2 = sections.getOrReplaceItem(fakeItem2, offsetInSection: 1) + XCTAssertEqual(fakeItem, newItem2) + XCTAssertEqual([nil, fakeItem, nil], sections.itemsBySection[0].value) + } + + func testIndexAfter() { + let store = standardFakeStore + var sections = MediaGallerySections(loader: store) + databaseStorage.read { transaction in + // Load January + XCTAssertEqual(1, sections.loadLaterSections(batchSize: 1, transaction: transaction)) + XCTAssertFalse(sections.hasFetchedMostRecent) + XCTAssertEqual(1, sections.itemsBySection.count) + XCTAssertEqual([nil, nil, nil], sections.itemsBySection[0].value) + } + + XCTAssertEqual(IndexPath(item: 1, section: 0), sections.indexPath(after: IndexPath(item: 0, section: 0))) + XCTAssertEqual(IndexPath(item: 2, section: 0), sections.indexPath(after: IndexPath(item: 1, section: 0))) + XCTAssertNil(sections.indexPath(after: IndexPath(item: 2, section: 0))) + + databaseStorage.read { transaction in + // Load remaining sections + XCTAssertEqual(2, sections.loadLaterSections(batchSize: 20, transaction: transaction)) + XCTAssertTrue(sections.hasFetchedMostRecent) + XCTAssertEqual(3, sections.itemsBySection.count) + } + + XCTAssertEqual(IndexPath(item: 1, section: 0), sections.indexPath(after: IndexPath(item: 0, section: 0))) + XCTAssertEqual(IndexPath(item: 2, section: 0), sections.indexPath(after: IndexPath(item: 1, section: 0))) + XCTAssertEqual(IndexPath(item: 0, section: 1), sections.indexPath(after: IndexPath(item: 2, section: 0))) + XCTAssertEqual(IndexPath(item: 1, section: 1), sections.indexPath(after: IndexPath(item: 0, section: 1))) + XCTAssertEqual(IndexPath(item: 0, section: 2), sections.indexPath(after: IndexPath(item: 1, section: 1))) + XCTAssertNil(sections.indexPath(after: IndexPath(item: 4, section: 2))) + } + + func testIndexBefore() { + let store = standardFakeStore + var sections = MediaGallerySections(loader: store) + databaseStorage.read { transaction in + // Load January + XCTAssertEqual(1, sections.loadLaterSections(batchSize: 1, transaction: transaction)) + XCTAssertFalse(sections.hasFetchedMostRecent) + XCTAssertEqual(1, sections.itemsBySection.count) + XCTAssertEqual([nil, nil, nil], sections.itemsBySection[0].value) + } + + XCTAssertEqual(IndexPath(item: 1, section: 0), sections.indexPath(before: IndexPath(item: 2, section: 0))) + XCTAssertEqual(IndexPath(item: 0, section: 0), sections.indexPath(before: IndexPath(item: 1, section: 0))) + XCTAssertNil(sections.indexPath(before: IndexPath(item: 0, section: 0))) + + databaseStorage.read { transaction in + // Load remaining sections + XCTAssertEqual(2, sections.loadLaterSections(batchSize: 20, transaction: transaction)) + XCTAssertTrue(sections.hasFetchedMostRecent) + XCTAssertEqual(3, sections.itemsBySection.count) + } + + XCTAssertEqual(IndexPath(item: 1, section: 1), sections.indexPath(before: IndexPath(item: 0, section: 2))) + XCTAssertEqual(IndexPath(item: 0, section: 1), sections.indexPath(before: IndexPath(item: 1, section: 1))) + XCTAssertEqual(IndexPath(item: 2, section: 0), sections.indexPath(before: IndexPath(item: 0, section: 1))) + XCTAssertEqual(IndexPath(item: 1, section: 0), sections.indexPath(before: IndexPath(item: 2, section: 0))) + XCTAssertEqual(IndexPath(item: 0, section: 0), sections.indexPath(before: IndexPath(item: 1, section: 0))) + XCTAssertNil(sections.indexPath(before: IndexPath(item: 0, section: 0))) + } + + func testIndexPathOf() { + let store = standardFakeStore + var sections = MediaGallerySections(loader: store) + databaseStorage.read { transaction in + // Load all months + XCTAssertEqual(3, sections.loadLaterSections(batchSize: 20, transaction: transaction)) + XCTAssertTrue(sections.hasFetchedMostRecent) + XCTAssertEqual(3, sections.itemsBySection.count) + } + + // Load all items. + XCTAssert(sections.ensureItemsLoaded(in: 0..<20, relativeToSection: 0).isEmpty) + + XCTAssertEqual(IndexPath(item: 0, section: 0), sections.indexPath(for: store.allItems[0])) + XCTAssertEqual(IndexPath(item: 1, section: 0), sections.indexPath(for: store.allItems[1])) + XCTAssertEqual(IndexPath(item: 2, section: 0), sections.indexPath(for: store.allItems[2])) + XCTAssertEqual(IndexPath(item: 0, section: 1), sections.indexPath(for: store.allItems[3])) + XCTAssertEqual(IndexPath(item: 1, section: 1), sections.indexPath(for: store.allItems[4])) + XCTAssertEqual(IndexPath(item: 0, section: 2), sections.indexPath(for: store.allItems[5])) + XCTAssertEqual(IndexPath(item: 1, section: 2), 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(sections.indexPath(for: FakeItem(2021_09_09))) + } + + func testRemoveLoadedItems() { + let store = standardFakeStore + var sections = MediaGallerySections(loader: store) + databaseStorage.read { transaction in + // Load all months + XCTAssertEqual(3, sections.loadLaterSections(batchSize: 20, transaction: transaction)) + XCTAssertTrue(sections.hasFetchedMostRecent) + XCTAssertEqual(3, sections.itemsBySection.count) + } + + // Load all items. + XCTAssert(sections.ensureItemsLoaded(in: 0..<20, relativeToSection: 0).isEmpty) + + XCTAssert(sections.removeLoadedItems(atIndexPaths: [IndexPath(item: 1, section: 1)]).isEmpty) + XCTAssertEqual(store.itemsBySection[0].value.map { $0 }, sections.itemsBySection[0].value) + XCTAssertEqual([store.itemsBySection[1].value[0]], sections.itemsBySection[1].value) + XCTAssertEqual(store.itemsBySection[2].value.map { $0 }, sections.itemsBySection[2].value) + + XCTAssert(sections.removeLoadedItems(atIndexPaths: [IndexPath(item: 1, section: 2), + IndexPath(item: 3, section: 2)]).isEmpty) + XCTAssertEqual(store.itemsBySection[0].value.map { $0 }, sections.itemsBySection[0].value) + XCTAssertEqual([store.itemsBySection[1].value[0]], sections.itemsBySection[1].value) + XCTAssertEqual([store.allItems[5], store.allItems[7], store.allItems[9]], sections.itemsBySection[2].value) + + XCTAssertEqual(IndexSet([0, 2]), + sections.removeLoadedItems(atIndexPaths: [IndexPath(item: 0, section: 0), + IndexPath(item: 1, section: 0), + IndexPath(item: 2, section: 0), + IndexPath(item: 0, section: 2), + IndexPath(item: 1, section: 2), + IndexPath(item: 2, section: 2)])) + XCTAssertEqual([store.itemsBySection[1].value[0]], sections.itemsBySection[0].value) + } + + func testTrimFromStart() { + let store = standardFakeStore + var sections = MediaGallerySections(loader: store) + databaseStorage.read { transaction in + // Load all months + XCTAssertEqual(3, sections.loadLaterSections(batchSize: 20, transaction: transaction)) + XCTAssertTrue(sections.hasFetchedMostRecent) + XCTAssertEqual(3, sections.itemsBySection.count) + } + + // _ _ _ | _ _ | x _ x x _ + _ = sections.ensureItemsLoaded(in: 0..<1, relativeToSection: 2) + _ = sections.ensureItemsLoaded(in: 2..<4, relativeToSection: 2) + + XCTAssertEqual(1..<4, sections.trimLoadedItemsAtStart(from: 0..<4, relativeToSection: 2)) + XCTAssertEqual(1..<5, sections.trimLoadedItemsAtStart(from: 0..<5, relativeToSection: 2)) + // End index is not even checked. + XCTAssertEqual(1..<6, sections.trimLoadedItemsAtStart(from: 0..<6, relativeToSection: 2)) + + XCTAssertEqual(1..<4, sections.trimLoadedItemsAtStart(from: 1..<4, relativeToSection: 2)) + XCTAssertEqual(4..<4, sections.trimLoadedItemsAtStart(from: 2..<4, relativeToSection: 2)) + XCTAssertEqual(4..<5, sections.trimLoadedItemsAtStart(from: 2..<5, relativeToSection: 2)) + + XCTAssertEqual(-1 ..< 5, sections.trimLoadedItemsAtStart(from: -1 ..< 5, relativeToSection: 2)) + XCTAssertEqual(-5 ..< 5, sections.trimLoadedItemsAtStart(from: -5 ..< 5, relativeToSection: 2)) + + // _ _ _ | _ x | x _ x x _ + _ = sections.ensureItemsLoaded(in: 1..<2, relativeToSection: 1) + + XCTAssertEqual(1..<5, sections.trimLoadedItemsAtStart(from: -1 ..< 5, relativeToSection: 2)) + XCTAssertEqual(-2 ..< 5, sections.trimLoadedItemsAtStart(from: -2 ..< 5, relativeToSection: 2)) + // trimLoadedItemsAtStart never goes past the current section. + XCTAssertEqual(2..<4, sections.trimLoadedItemsAtStart(from: 1..<4, relativeToSection: 1)) + XCTAssertEqual(0..<4, sections.trimLoadedItemsAtStart(from: 0..<4, relativeToSection: 1)) + + // _ _ _ | x x | x _ x x _ + _ = sections.ensureItemsLoaded(in: 0..<2, relativeToSection: 1) + + XCTAssertEqual(1..<5, sections.trimLoadedItemsAtStart(from: -1 ..< 5, relativeToSection: 2)) + XCTAssertEqual(1..<5, sections.trimLoadedItemsAtStart(from: -2 ..< 5, relativeToSection: 2)) + XCTAssertEqual(-3 ..< 5, sections.trimLoadedItemsAtStart(from: -3 ..< 5, relativeToSection: 2)) + // trimLoadedItemsAtStart never goes past the current section. + XCTAssertEqual(2..<4, sections.trimLoadedItemsAtStart(from: 1..<4, relativeToSection: 1)) + XCTAssertEqual(2..<4, sections.trimLoadedItemsAtStart(from: 0..<4, relativeToSection: 1)) + + // _ _ x | x _ | x _ x x _ + databaseStorage.read { transaction in + sections.reloadSection(for: sections.sectionDates[1], transaction: transaction) + } + _ = sections.ensureItemsLoaded(in: -1 ..< 1, relativeToSection: 1) + XCTAssertEqual(-1 ..< 5, sections.trimLoadedItemsAtStart(from: -1 ..< 5, relativeToSection: 2)) + XCTAssertEqual(-1 ..< 5, sections.trimLoadedItemsAtStart(from: -2 ..< 5, relativeToSection: 2)) + XCTAssertEqual(-1 ..< 5, sections.trimLoadedItemsAtStart(from: -3 ..< 5, relativeToSection: 2)) + XCTAssertEqual(-4 ..< 5, sections.trimLoadedItemsAtStart(from: -4 ..< 5, relativeToSection: 2)) + XCTAssertEqual(1..<4, sections.trimLoadedItemsAtStart(from: 1..<4, relativeToSection: 1)) + XCTAssertEqual(1..<4, sections.trimLoadedItemsAtStart(from: 0..<4, relativeToSection: 1)) + + // x x x | x x | x x x x x + _ = sections.ensureItemsLoaded(in: 0..<10, relativeToSection: 0) + XCTAssertEqual(5..<5, sections.trimLoadedItemsAtStart(from: -5 ..< 5, relativeToSection: 2)) + XCTAssertEqual(3..<3, sections.trimLoadedItemsAtStart(from: -5 ..< 3, relativeToSection: 2)) + // trimLoadedItemsAtStart never goes past the current section. + XCTAssertEqual(2..<7, sections.trimLoadedItemsAtStart(from: -3 ..< 7, relativeToSection: 1)) + } + + func testTrimFromEnd() { + let store = standardFakeStore + var sections = MediaGallerySections(loader: store) + databaseStorage.read { transaction in + // Load all months + XCTAssertEqual(3, sections.loadLaterSections(batchSize: 20, transaction: transaction)) + XCTAssertTrue(sections.hasFetchedMostRecent) + XCTAssertEqual(3, sections.itemsBySection.count) + } + + // x x _ | _ _ | _ _ _ _ _ + _ = sections.ensureItemsLoaded(in: 0..<2, relativeToSection: 0) + + XCTAssertEqual(0..<0, sections.trimLoadedItemsAtEnd(from: 0..<1, relativeToSection: 0)) + XCTAssertEqual(0..<0, sections.trimLoadedItemsAtEnd(from: 0..<2, relativeToSection: 0)) + XCTAssertEqual(0..<3, sections.trimLoadedItemsAtEnd(from: 0..<3, relativeToSection: 0)) + XCTAssertEqual(0..<4, sections.trimLoadedItemsAtEnd(from: 0..<4, relativeToSection: 0)) + // We don't actually check the start index. + XCTAssertEqual(-5 ..< 0, sections.trimLoadedItemsAtEnd(from: -5 ..< 2, relativeToSection: 0)) + } + + func testLoadingTrimsRequestedRange() { + let store = standardFakeStore + var sections = MediaGallerySections(loader: store) + databaseStorage.read { transaction in + // Load all months + XCTAssertEqual(3, sections.loadLaterSections(batchSize: 20, transaction: transaction)) + XCTAssertTrue(sections.hasFetchedMostRecent) + XCTAssertEqual(3, sections.itemsBySection.count) + } + + // _ _ x | x x | x _ _ _ _ + _ = sections.ensureItemsLoaded(in: -1 ..< 3, relativeToSection: 1) + XCTAssertEqual(2..<6, store.mostRecentRequest) + + // _ _ x | x x | x x x _ _ + _ = 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 _ _ + _ = 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 _ _ + databaseStorage.read { transaction in + sections.reloadSection(for: sections.sectionDates[1], transaction: transaction) + } + + // x x x | x x | x x x _ _ + _ = sections.ensureItemsLoaded(in: -1 ..< 3, relativeToSection: 1) + XCTAssertEqual(3..<5, store.mostRecentRequest) + + store.mostRecentRequest = 5000..<5000 + _ = sections.ensureItemsLoaded(in: -1 ..< 3, relativeToSection: 1) + // The request should be skipped entirely. + XCTAssertEqual(5000..<5000, store.mostRecentRequest) + } +}