Forji/Forji/ForjiTests/PaginationStateTests.swift
systemblue f39e9f682d fix: show graceful empty state when repository has issues turned off
When the Forgejo API returns 404 for the issues endpoint (issues
disabled for the repository), display a ContentUnavailableView instead
of surfacing the raw HTTP error alert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:56:23 -04:00

477 lines
16 KiB
Swift

// swiftlint:disable file_length
import Foundation
import Testing
@testable import Forji
// MARK: - Test helpers
/// Waits on MainActor until a condition is true, yielding between checks.
@MainActor
private func yieldUntil(_ condition: @MainActor () -> Bool) async {
for _ in 0 ..< 500 {
if condition() { return }
await Task.yield()
}
}
/// A fetch whose completion the test controls via continuations.
@MainActor
private final class ControllableFetch {
private(set) var callCount = 0
private var continuation: CheckedContinuation<[String], any Error>?
var isPending: Bool { continuation != nil }
func fetch(page: Int, limit: Int) async throws -> [String] {
callCount += 1
return try await withCheckedThrowingContinuation { cont in
self.continuation = cont
}
}
func complete(returning items: [String]) {
let cont = continuation
continuation = nil
cont?.resume(returning: items)
}
func complete(throwing error: any Error) {
let cont = continuation
continuation = nil
cont?.resume(throwing: error)
}
}
// MARK: - Basic reload
struct PaginationStateBasicTests {
@Test @MainActor func reloadSetsItems() async {
let pagination = PaginationState<String>(pageSize: 5)
await pagination.reload { _, _ in ["a", "b", "c"] }.value
#expect(pagination.items == ["a", "b", "c"])
#expect(!pagination.isLoading)
#expect(!pagination.hasMore) // 3 < pageSize 5
}
@Test @MainActor func reloadSetsHasMoreWhenFull() async {
let pagination = PaginationState<String>(pageSize: 3)
await pagination.reload { _, _ in ["a", "b", "c"] }.value
#expect(pagination.hasMore) // 3 >= pageSize 3
}
@Test @MainActor func reloadReplacesExistingItems() async {
let pagination = PaginationState<String>(pageSize: 20)
await pagination.reload { _, _ in ["old"] }.value
await pagination.reload { _, _ in ["new"] }.value
#expect(pagination.items == ["new"])
}
@Test @MainActor func reloadError() async {
let pagination = PaginationState<String>(pageSize: 20)
await pagination.reload { _, _ in throw URLError(.badServerResponse) }.value
#expect(pagination.items.isEmpty)
#expect(pagination.showError)
#expect(pagination.errorMessage != nil)
#expect(!pagination.isLoading)
}
@Test @MainActor func reloadClearsErrorState() async {
let pagination = PaginationState<String>(pageSize: 20)
await pagination.reload { _, _ in throw URLError(.badServerResponse) }.value
#expect(pagination.showError)
await pagination.reload { _, _ in ["ok"] }.value
#expect(!pagination.showError)
#expect(pagination.items == ["ok"])
}
@Test @MainActor func clearItemsEmptiesImmediately() async {
let pagination = PaginationState<String>(pageSize: 20)
await pagination.reload { _, _ in ["a", "b"] }.value
let fetcher = ControllableFetch()
let task = pagination.reload(clearItems: true) { page, limit in
try await fetcher.fetch(page: page, limit: limit)
}
// Items should be cleared synchronously before the fetch starts
#expect(pagination.items.isEmpty)
#expect(pagination.isLoading)
await yieldUntil { fetcher.isPending }
fetcher.complete(returning: ["c"])
await task.value
#expect(pagination.items == ["c"])
#expect(!pagination.isLoading)
}
}
// MARK: - Cancellation
struct PaginationStateCancellationTests {
@Test @MainActor func cancellationErrorIgnored() async {
let pagination = PaginationState<String>(pageSize: 20)
await pagination.reload { _, _ in ["initial"] }.value
await pagination.reload { _, _ in throw CancellationError() }.value
// Items from new reload are empty since it threw before returning items
// But cancellation should not surface an error
#expect(!pagination.showError)
#expect(!pagination.isLoading)
}
@Test @MainActor func urlErrorCancelledIgnored() async {
let pagination = PaginationState<String>(pageSize: 20)
await pagination.reload { _, _ in throw URLError(.cancelled) }.value
#expect(pagination.items.isEmpty)
#expect(!pagination.showError)
#expect(!pagination.isLoading)
}
}
// MARK: - Concurrent reloads (internal task cancellation)
struct PaginationStateConcurrentTests {
@Test @MainActor func secondReloadCancelsFirst() async {
let pagination = PaginationState<String>(pageSize: 20)
let fetcherA = ControllableFetch()
let fetcherB = ControllableFetch()
// Start reload A
pagination.reload { page, limit in
try await fetcherA.fetch(page: page, limit: limit)
}
await yieldUntil { fetcherA.isPending }
// Start reload B cancels A's internal task
let taskB = pagination.reload { page, limit in
try await fetcherB.fetch(page: page, limit: limit)
}
await yieldUntil { fetcherB.isPending }
// Complete both only B's results should be applied
fetcherA.complete(returning: ["stale"])
fetcherB.complete(returning: ["fresh"])
await taskB.value
#expect(pagination.items == ["fresh"])
#expect(!pagination.isLoading)
}
@Test @MainActor func staleLoadCompletingFirstIsDiscarded() async {
let pagination = PaginationState<String>(pageSize: 20)
let fetcherA = ControllableFetch()
let fetcherB = ControllableFetch()
pagination.reload { page, limit in
try await fetcherA.fetch(page: page, limit: limit)
}
await yieldUntil { fetcherA.isPending }
let taskB = pagination.reload { page, limit in
try await fetcherB.fetch(page: page, limit: limit)
}
await yieldUntil { fetcherB.isPending }
// Complete A first (stale, its task was cancelled) must be discarded
fetcherA.complete(returning: ["stale"])
await yieldUntil { fetcherA.callCount == 1 }
#expect(pagination.items.isEmpty) // B hasn't completed yet
#expect(pagination.isLoading) // B is still loading
// Complete B (fresh)
fetcherB.complete(returning: ["fresh"])
await taskB.value
#expect(pagination.items == ["fresh"])
#expect(!pagination.isLoading)
}
@Test @MainActor func threeOverlappingReloadsOnlyLatestApplied() async {
let pagination = PaginationState<String>(pageSize: 20)
let fetcherA = ControllableFetch()
let fetcherB = ControllableFetch()
let fetcherC = ControllableFetch()
pagination.reload { page, limit in
try await fetcherA.fetch(page: page, limit: limit)
}
await yieldUntil { fetcherA.isPending }
pagination.reload { page, limit in
try await fetcherB.fetch(page: page, limit: limit)
}
await yieldUntil { fetcherB.isPending }
let taskC = pagination.reload { page, limit in
try await fetcherC.fetch(page: page, limit: limit)
}
await yieldUntil { fetcherC.isPending }
// Complete in order A, B, C only C should be applied
fetcherA.complete(returning: ["a"])
fetcherB.complete(returning: ["b"])
fetcherC.complete(returning: ["c"])
await taskC.value
#expect(pagination.items == ["c"])
#expect(!pagination.isLoading)
}
@Test @MainActor func staleErrorDoesNotSurface() async {
let pagination = PaginationState<String>(pageSize: 20)
let fetcherA = ControllableFetch()
let fetcherB = ControllableFetch()
pagination.reload { page, limit in
try await fetcherA.fetch(page: page, limit: limit)
}
await yieldUntil { fetcherA.isPending }
let taskB = pagination.reload { page, limit in
try await fetcherB.fetch(page: page, limit: limit)
}
await yieldUntil { fetcherB.isPending }
// A fails with error but its task was cancelled, so no alert
fetcherA.complete(throwing: URLError(.badServerResponse))
fetcherB.complete(returning: ["ok"])
await taskB.value
#expect(pagination.items == ["ok"])
#expect(!pagination.showError)
}
@Test @MainActor func isLoadingStaysTrueWhileLatestIsInFlight() async {
let pagination = PaginationState<String>(pageSize: 20)
let fetcherA = ControllableFetch()
let fetcherB = ControllableFetch()
pagination.reload { page, limit in
try await fetcherA.fetch(page: page, limit: limit)
}
await yieldUntil { fetcherA.isPending }
#expect(pagination.isLoading)
let taskB = pagination.reload { page, limit in
try await fetcherB.fetch(page: page, limit: limit)
}
await yieldUntil { fetcherB.isPending }
#expect(pagination.isLoading)
// Complete stale A isLoading must REMAIN true (B is still in flight)
fetcherA.complete(returning: ["stale"])
await yieldUntil { fetcherA.callCount == 1 }
#expect(pagination.isLoading, "isLoading must stay true while latest reload (B) is pending")
fetcherB.complete(returning: ["fresh"])
await taskB.value
#expect(!pagination.isLoading)
}
/// Simulates the exact pattern from SearchableOverviewView:
/// .task fires reload user changes filter onChange fires reload (cancels previous)
@Test @MainActor func simulatedViewFilterChange() async {
let pagination = PaginationState<String>(pageSize: 20)
let initialFetcher = ControllableFetch()
let filterFetcher = ControllableFetch()
// Simulate .task { pagination.reload { ... } }
pagination.reload { page, limit in
try await initialFetcher.fetch(page: page, limit: limit)
}
await yieldUntil { initialFetcher.isPending }
// Simulate onChange: user changed filter just call reload again
let filterTask = pagination.reload(clearItems: true) { page, limit in
try await filterFetcher.fetch(page: page, limit: limit)
}
await yieldUntil { filterFetcher.isPending }
// Initial load completes (stale, its task was cancelled)
initialFetcher.complete(returning: ["stale-created-results"])
// Filter load completes with correct results
filterFetcher.complete(returning: ["fresh-involved-results"])
await filterTask.value
#expect(pagination.items == ["fresh-involved-results"])
#expect(!pagination.isLoading)
#expect(!pagination.showError)
}
}
// MARK: - hasLoaded / invalidate
struct PaginationStateHasLoadedTests {
@Test @MainActor func hasLoadedIsFalseInitially() {
let pagination = PaginationState<String>(pageSize: 5)
#expect(!pagination.hasLoaded)
}
@Test @MainActor func hasLoadedTrueAfterSuccessfulReload() async {
let pagination = PaginationState<String>(pageSize: 5)
await pagination.reload { _, _ in ["a"] }.value
#expect(pagination.hasLoaded)
}
@Test @MainActor func hasLoadedFalseAfterFailedReload() async {
let pagination = PaginationState<String>(pageSize: 5)
await pagination.reload { _, _ in throw URLError(.badServerResponse) }.value
#expect(!pagination.hasLoaded)
}
@Test @MainActor func invalidateResetsHasLoaded() async {
let pagination = PaginationState<String>(pageSize: 5)
await pagination.reload { _, _ in ["a"] }.value
#expect(pagination.hasLoaded)
pagination.invalidate()
#expect(!pagination.hasLoaded)
}
@Test @MainActor func invalidateThenReloadCycle() async {
let pagination = PaginationState<String>(pageSize: 5)
await pagination.reload { _, _ in ["a"] }.value
#expect(pagination.hasLoaded)
pagination.invalidate()
#expect(!pagination.hasLoaded)
await pagination.reload { _, _ in ["b"] }.value
#expect(pagination.hasLoaded)
#expect(pagination.items == ["b"])
}
@Test @MainActor func loadMoreDoesNotSetHasLoaded() async {
let pagination = PaginationState<String>(pageSize: 2)
// hasLoaded is false before any reload
#expect(!pagination.hasLoaded)
// loadMore should not set hasLoaded
await pagination.loadMore { _, _ in ["a", "b"] }
#expect(!pagination.hasLoaded)
}
}
// MARK: - notFound
struct PaginationStateNotFoundTests {
@Test @MainActor func notFoundFalseInitially() {
let pagination = PaginationState<String>(pageSize: 5)
#expect(!pagination.notFound)
}
@Test @MainActor func notFoundSetByFetchClosureShowsNoAlert() async {
let pagination = PaginationState<String>(pageSize: 20)
await pagination.reload { _, _ in
pagination.notFound = true
return []
}.value
#expect(pagination.notFound)
#expect(pagination.items.isEmpty)
#expect(!pagination.showError)
#expect(pagination.hasLoaded)
}
@Test @MainActor func reloadClearsNotFound() async {
let pagination = PaginationState<String>(pageSize: 20)
await pagination.reload { _, _ in
pagination.notFound = true
return []
}.value
#expect(pagination.notFound)
await pagination.reload { _, _ in ["ok"] }.value
#expect(!pagination.notFound)
#expect(pagination.items == ["ok"])
}
}
// MARK: - loadMore
struct PaginationStateLoadMoreTests {
@Test @MainActor func loadMoreAppendsItems() async {
let pagination = PaginationState<String>(pageSize: 2)
await pagination.reload { _, _ in ["a", "b"] }.value
#expect(pagination.hasMore)
await pagination.loadMore { page, _ in
#expect(page == 2)
return ["c", "d"]
}
#expect(pagination.items == ["a", "b", "c", "d"])
}
@Test @MainActor func loadMoreStopsWhenNoMore() async {
let pagination = PaginationState<String>(pageSize: 5)
await pagination.reload { _, _ in ["a", "b", "c", "d", "e"] }.value
await pagination.loadMore { _, _ in ["f"] } // 1 < pageSize -> no more
#expect(!pagination.hasMore)
#expect(pagination.items == ["a", "b", "c", "d", "e", "f"])
}
@Test @MainActor func loadMoreSkipsWhenAlreadyLoading() async {
let pagination = PaginationState<String>(pageSize: 2)
await pagination.reload { _, _ in ["a", "b"] }.value
let fetcher = ControllableFetch()
let firstMore = Task {
await pagination.loadMore { page, limit in
try await fetcher.fetch(page: page, limit: limit)
}
}
await yieldUntil { fetcher.isPending }
// Second loadMore should bail because isLoading is true
var secondCalled = false
await pagination.loadMore { _, _ in
secondCalled = true
return ["should-not-run"]
}
#expect(!secondCalled)
fetcher.complete(returning: ["c"])
await firstMore.value
}
@Test @MainActor func loadMoreDiscardedWhenReloadSupersedes() async {
let pagination = PaginationState<String>(pageSize: 2)
await pagination.reload { _, _ in ["a", "b"] }.value
let moreFetcher = ControllableFetch()
let moreTask = Task {
await pagination.loadMore { page, limit in
try await moreFetcher.fetch(page: page, limit: limit)
}
}
await yieldUntil { moreFetcher.isPending }
// A reload supersedes the in-flight loadMore (cancels its task)
let freshFetcher = ControllableFetch()
let reloadTask = pagination.reload { page, limit in
try await freshFetcher.fetch(page: page, limit: limit)
}
await yieldUntil { freshFetcher.isPending }
// loadMore completes should be discarded (task was cancelled)
moreFetcher.complete(returning: ["stale-more"])
await moreTask.value
// reload completes should be applied
freshFetcher.complete(returning: ["fresh"])
await reloadTask.value
#expect(pagination.items == ["fresh"])
#expect(!pagination.isLoading)
}
}