AppStateTests.swift

15.2 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 desktopURLs: [URL] = []75  var saves: [(URL, URL)] = []7677  func download(from url: URL) async throws -> URL {78    if let error { throw error }79    downloads.append(url)80    return downloadedURL81  }8283  func setDesktopImage(from fileURL: URL, screen: NSScreen?) throws {84    if let error { throw error }85    desktopURLs.append(fileURL)86  }8788  func saveToFolder(from sourceURL: URL, to folderURL: URL) throws {89    if let error { throw error }90    saves.append((sourceURL, folderURL))91  }92}9394@MainActor95final class AutostartStub: AutostartManaging {96  var isEnabled = false97}9899@Suite("Explore feed configuration")100struct FeedModeTests {101  @Test(102    "Feed modes map to API sorting",103    arguments: [104      (FeedMode.latest, WallhavenSorting.dateAdded),105      (FeedMode.toplist, WallhavenSorting.toplist),106      (FeedMode.random, WallhavenSorting.random),107    ]108  )109  func sortingMapping(mode: FeedMode, sorting: WallhavenSorting) {110    #expect(mode.sorting.rawValue == sorting.rawValue)111  }112113  @Test("Every feed mode has non-empty display metadata", arguments: FeedMode.allCases)114  func displayMetadata(mode: FeedMode) {115    #expect(!mode.displayName.isEmpty)116    #expect(!mode.systemImage.isEmpty)117    #expect(mode.id == mode.rawValue)118  }119120  @Test("Resolution and ratio defaults retain the Any option")121  @MainActor122  func filterOptions() {123    let state = AppState()124125    #expect(state.availableResolutions.first == "Any")126    #expect(state.availableRatios.first == "Any")127    #expect(state.availableResolutions.contains("3840x2160"))128    #expect(state.availableRatios.contains("21x9"))129  }130}131132@Suite("Application state behavior", .serialized)133@MainActor134struct ApplicationStateTests {135  private func makeState(136    search: WallhavenSearching? = nil, wallpaper: WallpaperStub? = nil,137    autostart: AutostartStub? = nil,138    chooseFolder: @escaping @MainActor (URL, @escaping (URL?) -> Void) -> Void = { _, completion in139      completion(nil)140    },141    terminate: @escaping @MainActor () -> Void = {}142  ) -> AppState {143    let search: WallhavenSearching = search ?? SearchStub()144    let wallpaper = wallpaper ?? WallpaperStub()145    let autostart = autostart ?? AutostartStub()146    return AppState(147      api: search, wallpaperManager: wallpaper, autostartManager: autostart,148      chooseFolder: chooseFolder, terminateApplication: terminate, observeChanges: false)149  }150151  @Test("Reset and pagination apply filters, metadata, and append behavior")152  func fetchAndPaginate() async {153    let search = SearchStub()154    let first = makeWallpaper(id: "first")155    let second = makeWallpaper(id: "second")156    search.results = [157      .success(([first], Meta(current_page: 1, last_page: 2, per_page: 24, total: 2))),158      .success(([second], Meta(current_page: 2, last_page: 2, per_page: 24, total: 2))),159    ]160    let state = makeState(search: search)161    state.searchText = "forest"162    state.selectedResolution = "3840x2160"163    state.selectedRatio = "21x9"164    state.categories = (true, false, true)165    state.purity = (false, true)166167    await state.fetchWallpapers()168    #expect(state.wallpapers == [first])169    #expect(state.canLoadMore)170    #expect(!state.isLoading)171    #expect(search.requests[0].query == "forest")172    #expect(search.requests[0].categories == "101")173    #expect(search.requests[0].purity == "010")174    #expect(search.requests[0].resolutions == ["3840x2160"])175    #expect(search.requests[0].ratios == ["21x9"])176177    await state.loadMore()178    #expect(state.wallpapers == [first, second])179    #expect(!state.canLoadMore)180    #expect(search.requests[1].page == 2)181182    await state.loadMore()183    #expect(search.requests.count == 2)184  }185186  @Test("Metadata-free responses and failures update fallback pagination state")187  func fallbackPaginationAndFailures() async {188    let search = SearchStub()189    search.results = [190      .success(([makeWallpaper(id: "one")], nil)),191      .success(([], nil)),192      .failure(URLError(.notConnectedToInternet)),193      .failure(URLError(.timedOut)),194    ]195    let state = makeState(search: search)196    state.selectedResolution = "Any"197    state.selectedRatio = "Any"198199    await state.fetchWallpapers()200    #expect(state.canLoadMore)201    await state.loadMore()202    #expect(!state.canLoadMore)203    #expect(search.requests[1].page == 2)204205    await state.fetchWallpapers()206    #expect(state.errorMessage != nil)207    state.canLoadMore = true208    await state.loadMore()209    #expect(!state.isLoading)210  }211212  @Test("Remote wallpaper operations cover saving and failures")213  func remoteWallpaperOperations() async {214    let wallpaperManager = WallpaperStub()215    let state = makeState(wallpaper: wallpaperManager)216    let wallpaper = makeWallpaper()217    state.saveFolderPath = "/tmp/haven-tests"218219    state.saveToFolderEnabled = false220    await state.setWallpaper(wallpaper)221    #expect(wallpaperManager.desktopURLs == [wallpaperManager.downloadedURL])222    #expect(wallpaperManager.saves.isEmpty)223224    state.saveToFolderEnabled = true225    await state.setWallpaper(wallpaper)226    #expect(wallpaperManager.saves.count == 1)227228    await state.downloadWallpaper(wallpaper)229    #expect(wallpaperManager.saves.count == 2)230231    wallpaperManager.error = URLError(.cannotWriteToFile)232    await state.setWallpaper(wallpaper)233    #expect(state.errorMessage?.hasPrefix("Failed to set wallpaper") == true)234    await state.downloadWallpaper(wallpaper)235    #expect(state.errorMessage?.hasPrefix("Failed to download") == true)236  }237238  @Test("Malformed wallpaper URLs surface as errorMessage without force-unwrapping")239  func malformedURLsAreSurfacedAsError() async {240    let wallpaperManager = WallpaperStub()241    let state = makeState(wallpaper: wallpaperManager)242    // `URL(string:)` returns nil for malformed authority/port strings. These243    // were the inputs that used to crash through a force-unwrap.244    let bad = Wallpaper(245      id: "bad",246      url: "https://wallhaven.cc/w/bad",247      path: "http://example.com:notaport",248      resolution: "1x1",249      purity: "sfw",250      file_size: 1,251      thumbs: Wallpaper.Thumbs(252        large: "https://example.com/large",253        original: "https://example.com/orig",254        small: "https://example.com/small"255      )256    )257258    await state.setWallpaper(bad)259    #expect(state.errorMessage?.contains("invalid image URL") == true)260    #expect(wallpaperManager.downloads.isEmpty)261262    state.errorMessage = nil263    await state.downloadWallpaper(bad)264    #expect(state.errorMessage?.contains("invalid image URL") == true)265    #expect(wallpaperManager.downloads.isEmpty)266  }267268  @Test("Empty path strings are rejected before any download is attempted")269  func emptyPathIsRejected() async {270    let wallpaperManager = WallpaperStub()271    let state = makeState(wallpaper: wallpaperManager)272    let empty = makeWallpaper(path: "")273274    await state.setWallpaper(empty)275    #expect(state.errorMessage?.contains("invalid image URL") == true)276    state.errorMessage = nil277    await state.downloadWallpaper(empty)278    #expect(state.errorMessage?.contains("invalid image URL") == true)279  }280281  @Test("Local wallpaper, autostart, folder picker, and quit are injectable")282  func localAndPlatformActions() async {283    let wallpaperManager = WallpaperStub()284    let autostart = AutostartStub()285    var terminated = false286    let selected = URL(fileURLWithPath: "/tmp/selected")287    let state = makeState(288      wallpaper: wallpaperManager, autostart: autostart,289      chooseFolder: { _, completion in completion(selected) },290      terminate: { terminated = true })291    let local = LocalWallpaper(292      id: "local", url: URL(fileURLWithPath: "/tmp/local.jpg"), filename: "local.jpg",293      resolution: "1x1", fileSize: 10, createdAt: Date(), modifiedAt: Date())294295    await state.setLocalWallpaper(local)296    #expect(wallpaperManager.desktopURLs == [local.url])297    #expect(state.historyManager.history.first?.purity == "local")298299    wallpaperManager.error = URLError(.cannotOpenFile)300    await state.setLocalWallpaper(local)301    #expect(state.errorMessage?.hasPrefix("Failed to set wallpaper") == true)302303    state.launchAtLogin = true304    #expect(autostart.isEnabled)305    state.selectSaveFolder()306    #expect(state.saveFolderPath == selected.path)307    state.quit()308    #expect(terminated)309310    var offeredDirectory: URL?311    let cancelState = makeState(chooseFolder: { directory, completion in312      offeredDirectory = directory313      completion(nil)314    })315    let oldPath = cancelState.saveFolderPath316    cancelState.selectSaveFolder()317    #expect(offeredDirectory?.path == oldPath)318    #expect(cancelState.saveFolderPath == oldPath)319  }320321  @Test("Observed search and settings changes trigger their pipelines")322  func observedChanges() async {323    let search = SearchStub()324    search.results = Array(repeating: .success(([], nil)), count: 50)325    let state = AppState(326      api: search, chooseFolder: { _, completion in completion(nil) },327      terminateApplication: {}, observeChanges: true)328329    func waitForRequests(_ expected: Int) async {330      for _ in 0..<100 where search.requests.count < expected {331        try? await Task.sleep(nanoseconds: 20_000_000)332      }333    }334335    state.scheduleSearch()336    await waitForRequests(1)337338    state.feedMode = .random339    await waitForRequests(2)340341    state.searchText = "updated"342    await waitForRequests(3)343344    state.categories = (false, true, true)345    await waitForRequests(4)346347    state.purity = (true, true)348    await waitForRequests(5)349350    state.saveToFolderEnabled.toggle()351    state.saveFolderPath = FileManager.default.temporaryDirectory.path352353    #expect(search.requests.count >= 5)354  }355356  @Test("Stale reset responses do not overwrite a newer query")357  func staleResetResponseIsIgnored() async {358    let search = DeferredSearchStub()359    let state = makeState(search: search)360    state.selectedResolution = "Any"361    state.selectedRatio = "Any"362363    let firstTask = Task { await state.fetchWallpapers() }364    let secondTask = Task { await state.fetchWallpapers() }365366    await search.waitForPending(2)367368    // Resolve the second (newer) request first, then resolve the first so369    // the first response lands AFTER the second has already committed. The370    // generation guard should drop the stale first response.371    search.resume(372      .success(373        ([makeWallpaper(id: "newer")], Meta(current_page: 1, last_page: 1, per_page: 24, total: 1))),374      for: 2)375    search.resume(376      .success(377        ([makeWallpaper(id: "stale")], Meta(current_page: 1, last_page: 1, per_page: 24, total: 1))),378      for: 1)379380    _ = await (firstTask.value, secondTask.value)381382    #expect(state.wallpapers.map(\.id) == ["newer"])383  }384385  @Test("Stale pagination responses do not advance past a newer reset")386  func stalePaginationResponseIsIgnored() async {387    let search = DeferredSearchStub()388    let state = makeState(search: search)389    state.selectedResolution = "Any"390    state.selectedRatio = "Any"391392    // First reset: page 1 of 2 (pagination available).393    let firstReset = Task { await state.fetchWallpapers() }394    await search.waitForPending(1)395    search.resume(396      .success(397        (398          [makeWallpaper(id: "first-reset")],399          Meta(current_page: 1, last_page: 2, per_page: 24, total: 2)400        )),401      for: 1)402403    // Drain the first reset so isLoading is false, then trigger a pagination.404    _ = await firstReset.value405    #expect(state.canLoadMore)406407    let paginationTask = Task { await state.fetchWallpapers(reset: false) }408    await search.waitForPending(2)409410    // While pagination is in flight, issue a newer reset that supersedes it.411    let newerReset = Task { await state.fetchWallpapers(reset: true) }412    await search.waitForPending(3)413414    // Resolve new reset first → wallpapers replaced with [newer-reset].415    search.resume(416      .success(417        (418          [makeWallpaper(id: "newer-reset")],419          Meta(current_page: 1, last_page: 1, per_page: 24, total: 1)420        )),421      for: 3)422423    // Then resolve the (now stale) pagination response. Its return value424    // would otherwise append "stale-page" to the wallpapers list.425    search.resume(426      .success(427        ([makeWallpaper(id: "stale-page")], nil)),428      for: 2)429430    _ = await (paginationTask.value, newerReset.value)431432    #expect(state.wallpapers.map(\.id) == ["newer-reset"])433  }434435  @Test("Pagination responses from the current generation still advance")436  func paginationAdvancesForCurrentGeneration() async {437    let search = SearchStub()438    let first = makeWallpaper(id: "first")439    let second = makeWallpaper(id: "second")440    search.results = [441      .success(([first], Meta(current_page: 1, last_page: 2, per_page: 24, total: 2))),442      .success(([second], Meta(current_page: 2, last_page: 2, per_page: 24, total: 2))),443    ]444    let state = makeState(search: search)445    state.selectedResolution = "Any"446    state.selectedRatio = "Any"447448    await state.fetchWallpapers()449    #expect(state.canLoadMore)450451    await state.loadMore()452    #expect(!state.canLoadMore)453    #expect(state.wallpapers.map(\.id) == ["first", "second"])454  }455}