diff --git a/Forji/Forji/Helpers/PaginationState.swift b/Forji/Forji/Helpers/PaginationState.swift index b5b66c2..865629b 100644 --- a/Forji/Forji/Helpers/PaginationState.swift +++ b/Forji/Forji/Helpers/PaginationState.swift @@ -16,10 +16,10 @@ final class PaginationState { let pageSize: Int var cacheName: String? - /// Optional key extractor enabling cross-page de-duplication. Merged views set - /// this because they combine paginated results from several instances, where - /// the same item can resurface on a later page (overlapping involvement - /// queries) and the merged page size no longer matches `pageSize`. + /// Optional key extractor enabling cross-page de-duplication. Views that + /// combine several paginated sources set this (merged multi-instance lists, + /// or the per-flag involvement queries), because the same item can resurface + /// on a later page and the combined page size no longer matches `pageSize`. @ObservationIgnored var dedupeKey: ((Item) -> AnyHashable)? @ObservationIgnored private var seenKeys = Set() diff --git a/Forji/Forji/Views/SearchableOverviewView.swift b/Forji/Forji/Views/SearchableOverviewView.swift index e912f30..f746bc3 100644 --- a/Forji/Forji/Views/SearchableOverviewView.swift +++ b/Forji/Forji/Views/SearchableOverviewView.swift @@ -59,6 +59,7 @@ struct SearchableOverviewView: View { let state = PaginationState() state.cacheName = issueType + state.dedupeKey = { $0.id } state.loadFromCache() _pagination = State(initialValue: state) } diff --git a/Forji/ForjiTests/PaginationStateTests.swift b/Forji/ForjiTests/PaginationStateTests.swift index d1dcd45..b71a9e5 100644 --- a/Forji/ForjiTests/PaginationStateTests.swift +++ b/Forji/ForjiTests/PaginationStateTests.swift @@ -475,3 +475,50 @@ struct PaginationStateLoadMoreTests { #expect(!pagination.isLoading) } } + +// MARK: - Keyed de-duplication + +struct PaginationStateDedupeTests { + + @Test @MainActor func loadMoreFiltersKeysSeenOnEarlierPages() async { + let pagination = PaginationState(pageSize: 2) + pagination.dedupeKey = { $0 } + await pagination.reload { _, _ in ["a", "b"] }.value + + // "b" resurfaces on page 2 — only "c" is new + await pagination.loadMore { _, _ in ["b", "c"] } + #expect(pagination.items == ["a", "b", "c"]) + #expect(pagination.hasMore) // the page contributed a new item + } + + @Test @MainActor func hasMoreFollowsNewItemsNotPageSize() async { + let pagination = PaginationState(pageSize: 5) + pagination.dedupeKey = { $0 } + await pagination.reload { _, _ in ["a", "b"] }.value + #expect(pagination.hasMore) // 2 < pageSize 5, but the page contributed new items + } + + @Test @MainActor func hasMoreFalseWhenFullPageIsAllDuplicates() async { + let pagination = PaginationState(pageSize: 2) + pagination.dedupeKey = { $0 } + await pagination.reload { _, _ in ["a", "b"] }.value + #expect(pagination.hasMore) + + // 2 >= pageSize 2, but every key was already seen -> pagination ends + await pagination.loadMore { _, _ in ["a", "b"] } + #expect(pagination.items == ["a", "b"]) + #expect(!pagination.hasMore) + } + + @Test @MainActor func reloadResetsSeenKeys() async { + let pagination = PaginationState(pageSize: 2) + pagination.dedupeKey = { $0 } + await pagination.reload { _, _ in ["a", "b"] }.value + await pagination.loadMore { _, _ in ["b", "c"] } + #expect(pagination.items == ["a", "b", "c"]) + + // A fresh reload must accept keys seen in the previous cycle + await pagination.reload { _, _ in ["a", "b"] }.value + #expect(pagination.items == ["a", "b"]) + } +}