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.
This commit is contained in:
Jordan Rose 2022-05-09 18:04:12 -07:00 committed by GitHub
parent 385ae60bc8
commit 4e37af097b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 1653 additions and 369 deletions

View File

@ -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 = "<group>"; };
179E8C32276A713300AF640F /* AFQueryString.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AFQueryString.h; sourceTree = "<group>"; };
17ACF11D267D71E0009BE867 /* OWSAudioSession+WebRTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OWSAudioSession+WebRTC.swift"; sourceTree = "<group>"; };
17B4F6C9281B17D200C2BFB1 /* MediaGallerySections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGallerySections.swift; sourceTree = "<group>"; };
17B4F6CB281B3A2200C2BFB1 /* MediaGallerySectionsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGallerySectionsTest.swift; sourceTree = "<group>"; };
17B78E0C2605299E00E24A9E /* newlyInitializedSessionState */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; path = newlyInitializedSessionState; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
@ -3582,6 +3586,7 @@
4C2A538B23C5462300D28CD8 /* CVMessageMappingTest.swift */,
4C6E6C6824241C00009DE948 /* ConversationViewControllerTest.swift */,
88D6E94125535482003142D9 /* CVTextTest.swift */,
17B4F6CB281B3A2200C2BFB1 /* MediaGallerySectionsTest.swift */,
);
path = ViewControllers;
sourceTree = "<group>";
@ -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 */,

View File

@ -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<Loader>!
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<GalleryDate, [MediaGalleryItem?]> = 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[..<itemPath.item].suffix { $0?.message.uniqueId == item.message.uniqueId }.startIndex
let endOfAlbum = section[itemPath.item...].prefix { $0?.message.uniqueId == item.message.uniqueId }.endIndex
let items = section[startOfAlbum..<endOfAlbum].map { $0! }
@ -346,7 +343,7 @@ class MediaGallery: Dependencies {
return
}
let anchorItem: MediaGalleryItem? = sections[sectionIndex].value[safe: itemIndex] ?? nil
let anchorItem: MediaGalleryItem? = sections.loadedItem(at: IndexPath(item: itemIndex, section: sectionIndex))
// May include a negative start location.
let naiveRequestRange: Range<Int> = {
@ -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..<numNewlyLoadedEarlierSections)
newlyLoadedSections.insert(integersIn: firstNewLaterSectionIndex..<sections.count)
completionBlock(newlyLoadedSections)
}
let newlyLoadedSections = sections.ensureItemsLoaded(in: naiveRequestRange, relativeToSection: sectionIndex)
completion?(newlyLoadedSections)
}
private func ensureGalleryItemsLoaded(_ direction: GalleryDirection,
@ -592,28 +494,10 @@ class MediaGallery: Dependencies {
if sections.isEmpty {
// Set up the current section only.
let count = numberOfItemsInSection(for: focusedItem.galleryDate, transaction: transaction)
var items: [MediaGalleryItem?] = Array(repeating: nil, count: count)
items[offsetInSection] = focusedItem
sections.append(key: focusedItem.galleryDate, value: items)
return focusedItem
sections.loadInitialSection(for: focusedItem.galleryDate, transaction: transaction)
}
// Assume we've set up this section, but may or may not have initialized the item.
guard var items = sections[focusedItem.galleryDate] else {
owsFailDebug("section for focused item not found")
return nil
}
if let existingItem = items[safe: offsetInSection] ?? nil {
return existingItem
}
// Swap out the section items to avoid copy-on-write.
sections.replace(key: focusedItem.galleryDate, value: [])
items[offsetInSection] = focusedItem
sections.replace(key: focusedItem.galleryDate, value: items)
return focusedItem
return sections.getOrReplaceItem(focusedItem, offsetInSection: offsetInSection)
}
guard let focusedItem = newItem else {
@ -632,10 +516,8 @@ class MediaGallery: Dependencies {
// MARK: - Section-based API
private func numberOfItemsInSection(for date: GalleryDate, transaction: SDSAnyReadTransaction) -> 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<Int>,
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
}
}
}
}
}

View File

@ -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<Int>,
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<Loader: MediaGallerySectionLoader>: 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<GalleryDate, [Item?]> = 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<Int>, 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<Int>, 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[..<limitInCurrentSection]
let countLoadedSuffix = currentSection.suffix { $0 != nil }.count
countToTrim += countLoadedSuffix
if countLoadedSuffix < currentSection.count || currentSectionIndex == sectionIndex {
break
}
// Start over from the end of the previous section.
currentSectionIndex -= 1
limitInCurrentSection = itemsBySection[currentSectionIndex].value.count
}
countToTrim = min(countToTrim, naiveRange.count)
naiveRange.removeLast(countToTrim)
}
/// Loads items in the given range.
///
/// A "naive" range may start at a negative offset, representing a position in a section before `sectionIndex`.
/// Similarly, the endpoint may be in a section that follows `sectionIndex`. However, the range must *cross*
/// `sectionIndex`, or it is not considered valid. `sectionIndex` must refer to a loaded section.
///
/// Will open its own database transaction, in the hopes of not having to open one at all.
///
/// Returns the indexes of newly loaded *sections,* which could shift the indexes of existing sections. These will
/// always be before and/or after the existing sections, never interleaving. If `naiveRange.startIndex` is
/// non-negative, there will never be any sections loaded before the existing sections. That is:
///
/// - When this method starts, we'll have sections like this: [G, H, J].
/// - When this method ends, we'll have sections like this: [C, D, F] [G, H, J] [K, L, N],
/// where the CDF and KLN chunks could be empty. The returned index set will contain the indexes of CF and KN.
internal mutating func ensureItemsLoaded(in naiveRange: Range<Int>,
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..<numNewlyLoadedEarlierSections)
newlyLoadedSections.insert(integersIn: firstNewLaterSectionIndex..<itemsBySection.count)
return newlyLoadedSections
}
internal mutating func getOrReplaceItem(_ newItem: Item, offsetInSection: Int) -> 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 }
}

View File

@ -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)..<mediaGallery.sections.count
let newEnd = mediaGallery.galleryDates.count
newSections = (newEnd - newSectionCount)..<newEnd
case .around:
preconditionFailure() // unused
}

View File

@ -0,0 +1,930 @@
//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
@testable import Signal
private extension Date {
/// Initialize a date using a compressed notation: `Date(compressedDate: 2022_04_28)`
init(compressedDate: UInt32, calendar: Calendar = .current) {
let day = Int(compressedDate % 100)
assert((1...31).contains(day))
let month = Int((compressedDate / 100) % 100)
assert((1...12).contains(month))
let year = Int(compressedDate / 1_00_00)
assert((1970...).contains(year))
self = calendar.date(from: DateComponents(year: year, month: month, day: day))!
}
}
private extension GalleryDate {
/// Initialize a GalleryDate using a compressed notation: `GalleryDate(2022_04_28)`.
///
/// Note that GalleryDates represent intervals; the above example will produce a GalleryDate for all of April 2022.
init(_ compressedDate: UInt32) {
self.init(date: Date(compressedDate: compressedDate))
}
}
private extension MediaGallerySections {
/// A functional version of the normal, inout-based API. More convenient for testing.
func trimLoadedItemsAtStart(from naiveRange: Range<Int>, relativeToSection sectionIndex: Int) -> Range<Int> {
var result = naiveRange
trimLoadedItemsAtStart(from: &result, relativeToSection: sectionIndex)
return result
}
/// A functional version of the normal, inout-based API. More convenient for testing.
func trimLoadedItemsAtEnd(from naiveRange: Range<Int>, relativeToSection sectionIndex: Int) -> Range<Int> {
var result = naiveRange
trimLoadedItemsAtEnd(from: &result, relativeToSection: sectionIndex)
return result
}
}
// MARK: -
private struct FakeItem: MediaGallerySectionItem, Equatable {
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<GalleryDate, [Item]>
var mostRecentRequest: Range<Int> = 0..<0
/// Sorts and groups the given items for traversal as a flat array (`allItems`)
/// as well as by section (`itemsBySection`).
init(_ items: [Item]) {
self.allItems = items.sorted { $0.timestamp < $1.timestamp }
let itemsBySection = Dictionary(grouping: allItems) { $0.galleryDate }
self.itemsBySection = OrderedDictionary(keyValueMap: itemsBySection, orderedKeys: itemsBySection.keys.sorted())
}
func 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: 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<Int>,
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)
}
}