From 4e37af097b0da2dd1007e238954409aa72ad92bc Mon Sep 17 00:00:00 2001 From: Jordan Rose Date: Mon, 9 May 2022 18:04:12 -0700 Subject: [PATCH] MediaGallery: Separate the lazy loading logic into a testable MediaGallerySections MediaGallery models a list of GalleryDate-based sections (in practice, months), 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 (a thread's All Media view), but it also supports flat views of media (swiping between items in the media detail view, which can cross album boundaries). This model is a result of refactoring I did last year to improve the performance and UX of the All Media view, but the implementation in MediaGallery.swift is entangled with the actual attachments and messages in the database, making it hard to test. This commit pulls the state management part out into its own struct, MediaGallerySections, which wraps the OrderedDictionary of items-by-section. Our MediaGalleryItem model references TSAttachmentStream directly and our queries go straight to the database, so to avoid having to set up real attachments in testing, MediaGallerySections is generic over a loader (delegate? data source?) of items with "gallery dates" and unique IDs. Now all the basic section- and item-loading APIs can be unit-tested. --- Signal.xcodeproj/project.pbxproj | 8 + .../MediaGallery/MediaGallery.swift | 459 +++------ .../MediaGallery/MediaGallerySections.swift | 578 +++++++++++ .../MediaTileViewController.swift | 47 +- .../MediaGallerySectionsTest.swift | 930 ++++++++++++++++++ 5 files changed, 1653 insertions(+), 369 deletions(-) create mode 100644 Signal/src/ViewControllers/MediaGallery/MediaGallerySections.swift create mode 100644 Signal/test/ViewControllers/MediaGallerySectionsTest.swift 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) + } +}