AppStateTests.swift

20.1 KB · raw

1import AppKit2import Foundation3import Testing45@testable import Haven67@MainActor8final class SearchStub: WallhavenSearching {9  struct Request {10    let query: String11    let categories: String12    let purity: String13    let resolutions: [String]14    let ratios: [String]15    let page: Int16  }1718  var results: [Result<([Wallpaper], Meta?), Error>] = []19  var requests: [Request] = []2021  func searchWallpapers(22    query: String, categories: String, purity: String, sorting: WallhavenSorting,23    resolutions: [String], ratios: [String], limit: Int, page: Int24  ) async throws -> ([Wallpaper], Meta?) {25    requests.append(26      Request(27        query: query, categories: categories, purity: purity, resolutions: resolutions,28        ratios: ratios, page: page))29    return try results.removeFirst().get()30  }31}3233/// Search stub whose requests park on continuations until the test explicitly34/// resumes them. Used to deterministically interleave reset requests from35/// two concurrent `fetchWallpapers` calls so the generation guard can be36/// exercised without flaky timing assumptions.37@MainActor38final class DeferredSearchStub: WallhavenSearching {39  private var pending: [Int: CheckedContinuation<([Wallpaper], Meta?), Error>] = [:]40  var nextId = 04142  func searchWallpapers(43    query: String, categories: String, purity: String, sorting: WallhavenSorting,44    resolutions: [String], ratios: [String], limit: Int, page: Int45  ) async throws -> ([Wallpaper], Meta?) {46    nextId += 147    let id = nextId48    return try await withCheckedThrowingContinuation { cont in49      pending[id] = cont50    }51  }5253  func resume(_ result: Result<([Wallpaper], Meta?), Error>, for id: Int) {54    guard let cont = pending.removeValue(forKey: id) else { return }55    cont.resume(with: result)56  }5758  /// Polls until `count` requests are parked, yielding to the runtime so the59  /// two fetchWallpapers calls can both reach the API. Without this the test60  /// would sometimes resume a continuation before its request had been61  /// registered, deadlocking the second `withCheckedThrowingContinuation`.62  func waitForPending(_ count: Int) async {63    for _ in 0..<1000 where pending.count < count {64      try? await Task.sleep(nanoseconds: 1_000_000)65    }66  }67}6869@MainActor70final class WallpaperStub: WallpaperManaging {71  var error: Error?72  var downloadedURL = URL(fileURLWithPath: "/tmp/downloaded.jpg")73  var downloads: [URL] = []74  var cacheLookups: [URL] = []75  var desktopURLs: [URL] = []76  var saves: [(URL, URL)] = []77  private var cached: Set<URL> = []7879  func download(from url: URL) async throws -> URL {80    if let error { throw error }81    downloads.append(url)82    return downloadedURL83  }8485  /// Mirrors `WallpaperManager.cachedLocalFile`: only calls through to86  /// `download(from:)` the first time a given URL is requested.87  func cachedLocalFile(for url: URL) async throws -> URL {88    cacheLookups.append(url)89    if cached.contains(url) {90      return downloadedURL91    }92    let result = try await download(from: url)93    cached.insert(url)94    return result95  }9697  func setDesktopImage(from fileURL: URL, screen: NSScreen?) throws {98    if let error { throw error }99    desktopURLs.append(fileURL)100  }101102  func saveToFolder(from sourceURL: URL, to folderURL: URL) throws {103    if let error { throw error }104    saves.append((sourceURL, folderURL))105  }106}107108@MainActor109final class AutostartStub: AutostartManaging {110  var isEnabled = false111  var lastError: String?112}113114@Suite("Explore feed configuration")115struct FeedModeTests {116  @Test(117    "Feed modes map to API sorting",118    arguments: [119      (FeedMode.latest, WallhavenSorting.dateAdded),120      (FeedMode.toplist, WallhavenSorting.toplist),121      (FeedMode.random, WallhavenSorting.random),122    ]123  )124  func sortingMapping(mode: FeedMode, sorting: WallhavenSorting) {125    #expect(mode.sorting.rawValue == sorting.rawValue)126  }127128  @Test("Every feed mode has non-empty display metadata", arguments: FeedMode.allCases)129  func displayMetadata(mode: FeedMode) {130    #expect(!mode.displayName.isEmpty)131    #expect(!mode.systemImage.isEmpty)132    #expect(mode.id == mode.rawValue)133  }134135  @Test("Resolution and ratio defaults retain the Any option")136  @MainActor137  func filterOptions() {138    let state = AppState()139140    #expect(state.availableResolutions.first == "Any")141    #expect(state.availableRatios.first == "Any")142    #expect(state.availableResolutions.contains("3840x2160"))143    #expect(state.availableRatios.contains("21x9"))144  }145}146147@Suite("Application state behavior", .serialized)148@MainActor149struct ApplicationStateTests {150  private func makeState(151    search: WallhavenSearching? = nil, wallpaper: WallpaperStub? = nil,152    autostart: AutostartStub? = nil,153    chooseFolder: @escaping @MainActor (URL, @escaping (URL?) -> Void) -> Void = { _, completion in154      completion(nil)155    },156    terminate: @escaping @MainActor () -> Void = {}157  ) -> AppState {158    let search: WallhavenSearching = search ?? SearchStub()159    let wallpaper = wallpaper ?? WallpaperStub()160    let autostart = autostart ?? AutostartStub()161    return AppState(162      api: search, wallpaperManager: wallpaper, autostartManager: autostart,163      chooseFolder: chooseFolder, terminateApplication: terminate, observeChanges: false)164  }165166  @Test("Reset and pagination apply filters, metadata, and append behavior")167  func fetchAndPaginate() async {168    let search = SearchStub()169    let first = makeWallpaper(id: "first")170    let second = makeWallpaper(id: "second")171    search.results = [172      .success(([first], Meta(current_page: 1, last_page: 2, per_page: 24, total: 2))),173      .success(([second], Meta(current_page: 2, last_page: 2, per_page: 24, total: 2))),174    ]175    let state = makeState(search: search)176    state.searchText = "forest"177    state.selectedResolution = "3840x2160"178    state.selectedRatio = "21x9"179    state.categories = (true, false, true)180    state.purity = (false, true)181182    await state.fetchWallpapers()183    #expect(state.wallpapers == [first])184    #expect(state.canLoadMore)185    #expect(!state.isLoading)186    #expect(search.requests[0].query == "forest")187    #expect(search.requests[0].categories == "101")188    #expect(search.requests[0].purity == "010")189    #expect(search.requests[0].resolutions == ["3840x2160"])190    #expect(search.requests[0].ratios == ["21x9"])191192    await state.loadMore()193    #expect(state.wallpapers == [first, second])194    #expect(!state.canLoadMore)195    #expect(search.requests[1].page == 2)196197    await state.loadMore()198    #expect(search.requests.count == 2)199  }200201  @Test("Metadata-free responses and failures update fallback pagination state")202  func fallbackPaginationAndFailures() async {203    let search = SearchStub()204    search.results = [205      .success(([makeWallpaper(id: "one")], nil)),206      .success(([], nil)),207      .failure(URLError(.notConnectedToInternet)),208      .failure(URLError(.timedOut)),209    ]210    let state = makeState(search: search)211    state.selectedResolution = "Any"212    state.selectedRatio = "Any"213214    await state.fetchWallpapers()215    #expect(state.canLoadMore)216    await state.loadMore()217    #expect(!state.canLoadMore)218    #expect(search.requests[1].page == 2)219220    await state.fetchWallpapers()221    #expect(state.errorMessage != nil)222    state.canLoadMore = true223    await state.loadMore()224    #expect(!state.isLoading)225  }226227  @Test("Remote wallpaper operations cover saving and failures")228  func remoteWallpaperOperations() async {229    let wallpaperManager = WallpaperStub()230    let state = makeState(wallpaper: wallpaperManager)231    let wallpaper = makeWallpaper()232    state.saveFolderPath = "/tmp/haven-tests"233234    state.saveToFolderEnabled = false235    await state.setWallpaper(wallpaper)236    #expect(wallpaperManager.desktopURLs == [wallpaperManager.downloadedURL])237    #expect(wallpaperManager.saves.isEmpty)238239    state.saveToFolderEnabled = true240    await state.setWallpaper(wallpaper)241    #expect(wallpaperManager.saves.count == 1)242    // Same wallpaper set twice: only the first call should hit the network.243    #expect(wallpaperManager.cacheLookups.count == 2)244    #expect(wallpaperManager.downloads.count == 1)245246    await state.downloadWallpaper(wallpaper)247    #expect(wallpaperManager.saves.count == 2)248249    wallpaperManager.error = URLError(.cannotWriteToFile)250    await state.setWallpaper(wallpaper)251    #expect(state.errorMessage?.hasPrefix("Failed to set wallpaper") == true)252    await state.downloadWallpaper(wallpaper)253    #expect(state.errorMessage?.hasPrefix("Failed to download") == true)254  }255256  @Test("Malformed wallpaper URLs surface as errorMessage without force-unwrapping")257  func malformedURLsAreSurfacedAsError() async {258    let wallpaperManager = WallpaperStub()259    let state = makeState(wallpaper: wallpaperManager)260    // `URL(string:)` returns nil for malformed authority/port strings. These261    // were the inputs that used to crash through a force-unwrap.262    let bad = Wallpaper(263      id: "bad",264      url: "https://wallhaven.cc/w/bad",265      path: "http://example.com:notaport",266      resolution: "1x1",267      purity: "sfw",268      file_size: 1,269      thumbs: Wallpaper.Thumbs(270        large: "https://example.com/large",271        original: "https://example.com/orig",272        small: "https://example.com/small"273      )274    )275276    await state.setWallpaper(bad)277    #expect(state.errorMessage?.contains("invalid image URL") == true)278    #expect(wallpaperManager.downloads.isEmpty)279280    state.errorMessage = nil281    await state.downloadWallpaper(bad)282    #expect(state.errorMessage?.contains("invalid image URL") == true)283    #expect(wallpaperManager.downloads.isEmpty)284  }285286  @Test("Empty path strings are rejected before any download is attempted")287  func emptyPathIsRejected() async {288    let wallpaperManager = WallpaperStub()289    let state = makeState(wallpaper: wallpaperManager)290    let empty = makeWallpaper(path: "")291292    await state.setWallpaper(empty)293    #expect(state.errorMessage?.contains("invalid image URL") == true)294    state.errorMessage = nil295    await state.downloadWallpaper(empty)296    #expect(state.errorMessage?.contains("invalid image URL") == true)297  }298299  @Test("Local wallpaper, autostart, folder picker, and quit are injectable")300  func localAndPlatformActions() async {301    let wallpaperManager = WallpaperStub()302    let autostart = AutostartStub()303    var terminated = false304    let selected = URL(fileURLWithPath: "/tmp/selected")305    let state = makeState(306      wallpaper: wallpaperManager, autostart: autostart,307      chooseFolder: { _, completion in completion(selected) },308      terminate: { terminated = true })309    let local = LocalWallpaper(310      id: "local", url: URL(fileURLWithPath: "/tmp/local.jpg"), filename: "local.jpg",311      resolution: "1x1", fileSize: 10, createdAt: Date(), modifiedAt: Date())312313    await state.setLocalWallpaper(local)314    #expect(wallpaperManager.desktopURLs == [local.url])315    #expect(state.historyManager.history.first?.purity == "local")316317    wallpaperManager.error = URLError(.cannotOpenFile)318    await state.setLocalWallpaper(local)319    #expect(state.errorMessage?.hasPrefix("Failed to set wallpaper") == true)320321    state.launchAtLogin = true322    #expect(autostart.isEnabled)323    state.selectSaveFolder()324    #expect(state.saveFolderPath == selected.path)325    state.quit()326    #expect(terminated)327328    var offeredDirectory: URL?329    let cancelState = makeState(chooseFolder: { directory, completion in330      offeredDirectory = directory331      completion(nil)332    })333    let oldPath = cancelState.saveFolderPath334    cancelState.selectSaveFolder()335    #expect(offeredDirectory?.path == oldPath)336    #expect(cancelState.saveFolderPath == oldPath)337  }338339  @Test("Observed search and settings changes trigger their pipelines")340  func observedChanges() async {341    let search = SearchStub()342    search.results = Array(repeating: .success(([], nil)), count: 50)343    let state = AppState(344      api: search, chooseFolder: { _, completion in completion(nil) },345      terminateApplication: {}, observeChanges: true)346347    func waitForRequests(_ expected: Int) async {348      for _ in 0..<100 where search.requests.count < expected {349        try? await Task.sleep(nanoseconds: 20_000_000)350      }351    }352353    state.scheduleSearch()354    await waitForRequests(1)355356    state.feedMode = .random357    await waitForRequests(2)358359    state.searchText = "updated"360    await waitForRequests(3)361362    state.categories = (false, true, true)363    await waitForRequests(4)364365    state.purity = (true, true)366    await waitForRequests(5)367368    state.saveToFolderEnabled.toggle()369    state.saveFolderPath = FileManager.default.temporaryDirectory.path370371    #expect(search.requests.count >= 5)372  }373374  @Test("Initialization alone launches no search")375  func initializationLaunchesNoSearch() async {376    let search = SearchStub()377    search.results = Array(repeating: .success(([], nil)), count: 8)378    let state = AppState(379      api: search, chooseFolder: { _, completion in completion(nil) },380      terminateApplication: {}, observeChanges: true)381382    // `@Published` replays its current value to every new subscriber, so the383    // debounced pipelines would otherwise turn that replay into searches that384    // fire before the popover is ever opened.385    try? await Task.sleep(nanoseconds: 1_200_000_000)386    #expect(search.requests.isEmpty)387388    // The instance is still alive and its pipelines still work, so the389    // assertion above is not just observing a deallocated state object.390    state.scheduleSearch()391    for _ in 0..<100 where search.requests.isEmpty {392      try? await Task.sleep(nanoseconds: 20_000_000)393    }394    #expect(search.requests.count == 1)395  }396397  @Test("Wallhaven outages become status the Explore tab explains")398  func outageBecomesWallhavenStatus() async {399    let outage = WallhavenError.unavailable("Wallhaven is unavailable right now (HTTP 521).")400    let search = SearchStub()401    search.results = [402      .failure(outage),403      .success(([makeWallpaper(id: "recovered")], nil)),404      .failure(outage),405    ]406    let state = makeState(search: search)407408    await state.fetchWallpapers()409410    #expect(state.wallhavenStatus?.title == "Wallhaven is offline")411    #expect(state.wallhavenStatus?.detail == "Wallhaven is unavailable right now (HTTP 521).")412    // The empty grid carries the explanation, so the banner stays quiet.413    #expect(state.errorMessage == nil)414415    await state.fetchWallpapers()416417    #expect(state.wallhavenStatus == nil)418    #expect(state.wallpapers.map(\.id) == ["recovered"])419420    // Refreshing keeps the results on screen, so there is no panel to explain421    // the outage and the banner has to.422    await state.fetchWallpapers(refreshing: true)423424    #expect(state.wallhavenStatus?.detail == "Wallhaven is unavailable right now (HTTP 521).")425    #expect(state.errorMessage == "Wallhaven is unavailable right now (HTTP 521).")426  }427428  @Test("Failures that are not a Wallhaven outage stay in the banner")429  func nonOutageFailureStaysInBanner() async {430    let search = SearchStub()431    search.results = [.failure(URLError(.notConnectedToInternet))]432    let state = makeState(search: search)433434    await state.fetchWallpapers()435436    #expect(state.wallhavenStatus == nil)437    #expect(state.errorMessage != nil)438  }439440  @Test("Resolution and ratio changes are debounced like other filters")441  func debouncedResolutionAndRatio() async {442    let search = SearchStub()443    // Pre-load enough results for any plausible burst; the test asserts on444    // request counts, not on which exact request wins.445    search.results = Array(repeating: .success(([], nil)), count: 8)446    let state = AppState(447      api: search, chooseFolder: { _, completion in completion(nil) },448      terminateApplication: {}, observeChanges: true)449450    // Let the initial-value debouncers from searchText/categories/purity451    // settle before we measure the cost of our rapid filter changes.452    try? await Task.sleep(nanoseconds: 1_000_000_000)453    let baseline = search.requests.count454455    // Rapid-fire several changes per filter. With debounce these should456    // coalesce into a single fetch per filter; without it, each assignment457    // would produce its own request.458    state.selectedResolution = "1920x1080"459    state.selectedResolution = "2560x1440"460    state.selectedResolution = "3840x2160"461    state.selectedRatio = "16x9"462    state.selectedRatio = "21x9"463464    // Wait long enough for every debouncer to settle (3x the 0.5s window).465    try? await Task.sleep(nanoseconds: 1_500_000_000)466467    let newRequests = Array(search.requests.dropFirst(baseline))468    // Each debounced filter (resolution, ratio) fires its own fetch, but469    // every fetch reads the latest values of both filters at the time it470    // runs. The debouncers coalesce the rapid changes into exactly one471    // fetch per filter, so we expect two new requests rather than the five472    // an unguarded publisher would have produced.473    #expect(newRequests.count == 2)474    for request in newRequests {475      #expect(request.resolutions == ["3840x2160"])476      #expect(request.ratios == ["21x9"])477    }478  }479480  @Test("Stale reset responses do not overwrite a newer query")481  func staleResetResponseIsIgnored() async {482    let search = DeferredSearchStub()483    let state = makeState(search: search)484    state.selectedResolution = "Any"485    state.selectedRatio = "Any"486487    let firstTask = Task { await state.fetchWallpapers() }488    let secondTask = Task { await state.fetchWallpapers() }489490    await search.waitForPending(2)491492    // Resolve the second (newer) request first, then resolve the first so493    // the first response lands AFTER the second has already committed. The494    // generation guard should drop the stale first response.495    search.resume(496      .success(497        ([makeWallpaper(id: "newer")], Meta(current_page: 1, last_page: 1, per_page: 24, total: 1))),498      for: 2)499    search.resume(500      .success(501        ([makeWallpaper(id: "stale")], Meta(current_page: 1, last_page: 1, per_page: 24, total: 1))),502      for: 1)503504    _ = await (firstTask.value, secondTask.value)505506    #expect(state.wallpapers.map(\.id) == ["newer"])507  }508509  @Test("Stale pagination responses do not advance past a newer reset")510  func stalePaginationResponseIsIgnored() async {511    let search = DeferredSearchStub()512    let state = makeState(search: search)513    state.selectedResolution = "Any"514    state.selectedRatio = "Any"515516    // First reset: page 1 of 2 (pagination available).517    let firstReset = Task { await state.fetchWallpapers() }518    await search.waitForPending(1)519    search.resume(520      .success(521        (522          [makeWallpaper(id: "first-reset")],523          Meta(current_page: 1, last_page: 2, per_page: 24, total: 2)524        )),525      for: 1)526527    // Drain the first reset so isLoading is false, then trigger a pagination.528    _ = await firstReset.value529    #expect(state.canLoadMore)530531    let paginationTask = Task { await state.fetchWallpapers(reset: false) }532    await search.waitForPending(2)533534    // While pagination is in flight, issue a newer reset that supersedes it.535    let newerReset = Task { await state.fetchWallpapers(reset: true) }536    await search.waitForPending(3)537538    // Resolve new reset first → wallpapers replaced with [newer-reset].539    search.resume(540      .success(541        (542          [makeWallpaper(id: "newer-reset")],543          Meta(current_page: 1, last_page: 1, per_page: 24, total: 1)544        )),545      for: 3)546547    // Then resolve the (now stale) pagination response. Its return value548    // would otherwise append "stale-page" to the wallpapers list.549    search.resume(550      .success(551        ([makeWallpaper(id: "stale-page")], nil)),552      for: 2)553554    _ = await (paginationTask.value, newerReset.value)555556    #expect(state.wallpapers.map(\.id) == ["newer-reset"])557  }558559  @Test("Pagination responses from the current generation still advance")560  func paginationAdvancesForCurrentGeneration() async {561    let search = SearchStub()562    let first = makeWallpaper(id: "first")563    let second = makeWallpaper(id: "second")564    search.results = [565      .success(([first], Meta(current_page: 1, last_page: 2, per_page: 24, total: 2))),566      .success(([second], Meta(current_page: 2, last_page: 2, per_page: 24, total: 2))),567    ]568    let state = makeState(search: search)569    state.selectedResolution = "Any"570    state.selectedRatio = "Any"571572    await state.fetchWallpapers()573    #expect(state.canLoadMore)574575    await state.loadMore()576    #expect(!state.canLoadMore)577    #expect(state.wallpapers.map(\.id) == ["first", "second"])578  }579}