AppStateTests.swift

17.0 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  var lastError: String?98}99100@Suite("Explore feed configuration")101struct FeedModeTests {102  @Test(103    "Feed modes map to API sorting",104    arguments: [105      (FeedMode.latest, WallhavenSorting.dateAdded),106      (FeedMode.toplist, WallhavenSorting.toplist),107      (FeedMode.random, WallhavenSorting.random),108    ]109  )110  func sortingMapping(mode: FeedMode, sorting: WallhavenSorting) {111    #expect(mode.sorting.rawValue == sorting.rawValue)112  }113114  @Test("Every feed mode has non-empty display metadata", arguments: FeedMode.allCases)115  func displayMetadata(mode: FeedMode) {116    #expect(!mode.displayName.isEmpty)117    #expect(!mode.systemImage.isEmpty)118    #expect(mode.id == mode.rawValue)119  }120121  @Test("Resolution and ratio defaults retain the Any option")122  @MainActor123  func filterOptions() {124    let state = AppState()125126    #expect(state.availableResolutions.first == "Any")127    #expect(state.availableRatios.first == "Any")128    #expect(state.availableResolutions.contains("3840x2160"))129    #expect(state.availableRatios.contains("21x9"))130  }131}132133@Suite("Application state behavior", .serialized)134@MainActor135struct ApplicationStateTests {136  private func makeState(137    search: WallhavenSearching? = nil, wallpaper: WallpaperStub? = nil,138    autostart: AutostartStub? = nil,139    chooseFolder: @escaping @MainActor (URL, @escaping (URL?) -> Void) -> Void = { _, completion in140      completion(nil)141    },142    terminate: @escaping @MainActor () -> Void = {}143  ) -> AppState {144    let search: WallhavenSearching = search ?? SearchStub()145    let wallpaper = wallpaper ?? WallpaperStub()146    let autostart = autostart ?? AutostartStub()147    return AppState(148      api: search, wallpaperManager: wallpaper, autostartManager: autostart,149      chooseFolder: chooseFolder, terminateApplication: terminate, observeChanges: false)150  }151152  @Test("Reset and pagination apply filters, metadata, and append behavior")153  func fetchAndPaginate() async {154    let search = SearchStub()155    let first = makeWallpaper(id: "first")156    let second = makeWallpaper(id: "second")157    search.results = [158      .success(([first], Meta(current_page: 1, last_page: 2, per_page: 24, total: 2))),159      .success(([second], Meta(current_page: 2, last_page: 2, per_page: 24, total: 2))),160    ]161    let state = makeState(search: search)162    state.searchText = "forest"163    state.selectedResolution = "3840x2160"164    state.selectedRatio = "21x9"165    state.categories = (true, false, true)166    state.purity = (false, true)167168    await state.fetchWallpapers()169    #expect(state.wallpapers == [first])170    #expect(state.canLoadMore)171    #expect(!state.isLoading)172    #expect(search.requests[0].query == "forest")173    #expect(search.requests[0].categories == "101")174    #expect(search.requests[0].purity == "010")175    #expect(search.requests[0].resolutions == ["3840x2160"])176    #expect(search.requests[0].ratios == ["21x9"])177178    await state.loadMore()179    #expect(state.wallpapers == [first, second])180    #expect(!state.canLoadMore)181    #expect(search.requests[1].page == 2)182183    await state.loadMore()184    #expect(search.requests.count == 2)185  }186187  @Test("Metadata-free responses and failures update fallback pagination state")188  func fallbackPaginationAndFailures() async {189    let search = SearchStub()190    search.results = [191      .success(([makeWallpaper(id: "one")], nil)),192      .success(([], nil)),193      .failure(URLError(.notConnectedToInternet)),194      .failure(URLError(.timedOut)),195    ]196    let state = makeState(search: search)197    state.selectedResolution = "Any"198    state.selectedRatio = "Any"199200    await state.fetchWallpapers()201    #expect(state.canLoadMore)202    await state.loadMore()203    #expect(!state.canLoadMore)204    #expect(search.requests[1].page == 2)205206    await state.fetchWallpapers()207    #expect(state.errorMessage != nil)208    state.canLoadMore = true209    await state.loadMore()210    #expect(!state.isLoading)211  }212213  @Test("Remote wallpaper operations cover saving and failures")214  func remoteWallpaperOperations() async {215    let wallpaperManager = WallpaperStub()216    let state = makeState(wallpaper: wallpaperManager)217    let wallpaper = makeWallpaper()218    state.saveFolderPath = "/tmp/haven-tests"219220    state.saveToFolderEnabled = false221    await state.setWallpaper(wallpaper)222    #expect(wallpaperManager.desktopURLs == [wallpaperManager.downloadedURL])223    #expect(wallpaperManager.saves.isEmpty)224225    state.saveToFolderEnabled = true226    await state.setWallpaper(wallpaper)227    #expect(wallpaperManager.saves.count == 1)228229    await state.downloadWallpaper(wallpaper)230    #expect(wallpaperManager.saves.count == 2)231232    wallpaperManager.error = URLError(.cannotWriteToFile)233    await state.setWallpaper(wallpaper)234    #expect(state.errorMessage?.hasPrefix("Failed to set wallpaper") == true)235    await state.downloadWallpaper(wallpaper)236    #expect(state.errorMessage?.hasPrefix("Failed to download") == true)237  }238239  @Test("Malformed wallpaper URLs surface as errorMessage without force-unwrapping")240  func malformedURLsAreSurfacedAsError() async {241    let wallpaperManager = WallpaperStub()242    let state = makeState(wallpaper: wallpaperManager)243    // `URL(string:)` returns nil for malformed authority/port strings. These244    // were the inputs that used to crash through a force-unwrap.245    let bad = Wallpaper(246      id: "bad",247      url: "https://wallhaven.cc/w/bad",248      path: "http://example.com:notaport",249      resolution: "1x1",250      purity: "sfw",251      file_size: 1,252      thumbs: Wallpaper.Thumbs(253        large: "https://example.com/large",254        original: "https://example.com/orig",255        small: "https://example.com/small"256      )257    )258259    await state.setWallpaper(bad)260    #expect(state.errorMessage?.contains("invalid image URL") == true)261    #expect(wallpaperManager.downloads.isEmpty)262263    state.errorMessage = nil264    await state.downloadWallpaper(bad)265    #expect(state.errorMessage?.contains("invalid image URL") == true)266    #expect(wallpaperManager.downloads.isEmpty)267  }268269  @Test("Empty path strings are rejected before any download is attempted")270  func emptyPathIsRejected() async {271    let wallpaperManager = WallpaperStub()272    let state = makeState(wallpaper: wallpaperManager)273    let empty = makeWallpaper(path: "")274275    await state.setWallpaper(empty)276    #expect(state.errorMessage?.contains("invalid image URL") == true)277    state.errorMessage = nil278    await state.downloadWallpaper(empty)279    #expect(state.errorMessage?.contains("invalid image URL") == true)280  }281282  @Test("Local wallpaper, autostart, folder picker, and quit are injectable")283  func localAndPlatformActions() async {284    let wallpaperManager = WallpaperStub()285    let autostart = AutostartStub()286    var terminated = false287    let selected = URL(fileURLWithPath: "/tmp/selected")288    let state = makeState(289      wallpaper: wallpaperManager, autostart: autostart,290      chooseFolder: { _, completion in completion(selected) },291      terminate: { terminated = true })292    let local = LocalWallpaper(293      id: "local", url: URL(fileURLWithPath: "/tmp/local.jpg"), filename: "local.jpg",294      resolution: "1x1", fileSize: 10, createdAt: Date(), modifiedAt: Date())295296    await state.setLocalWallpaper(local)297    #expect(wallpaperManager.desktopURLs == [local.url])298    #expect(state.historyManager.history.first?.purity == "local")299300    wallpaperManager.error = URLError(.cannotOpenFile)301    await state.setLocalWallpaper(local)302    #expect(state.errorMessage?.hasPrefix("Failed to set wallpaper") == true)303304    state.launchAtLogin = true305    #expect(autostart.isEnabled)306    state.selectSaveFolder()307    #expect(state.saveFolderPath == selected.path)308    state.quit()309    #expect(terminated)310311    var offeredDirectory: URL?312    let cancelState = makeState(chooseFolder: { directory, completion in313      offeredDirectory = directory314      completion(nil)315    })316    let oldPath = cancelState.saveFolderPath317    cancelState.selectSaveFolder()318    #expect(offeredDirectory?.path == oldPath)319    #expect(cancelState.saveFolderPath == oldPath)320  }321322  @Test("Observed search and settings changes trigger their pipelines")323  func observedChanges() async {324    let search = SearchStub()325    search.results = Array(repeating: .success(([], nil)), count: 50)326    let state = AppState(327      api: search, chooseFolder: { _, completion in completion(nil) },328      terminateApplication: {}, observeChanges: true)329330    func waitForRequests(_ expected: Int) async {331      for _ in 0..<100 where search.requests.count < expected {332        try? await Task.sleep(nanoseconds: 20_000_000)333      }334    }335336    state.scheduleSearch()337    await waitForRequests(1)338339    state.feedMode = .random340    await waitForRequests(2)341342    state.searchText = "updated"343    await waitForRequests(3)344345    state.categories = (false, true, true)346    await waitForRequests(4)347348    state.purity = (true, true)349    await waitForRequests(5)350351    state.saveToFolderEnabled.toggle()352    state.saveFolderPath = FileManager.default.temporaryDirectory.path353354    #expect(search.requests.count >= 5)355  }356357  @Test("Resolution and ratio changes are debounced like other filters")358  func debouncedResolutionAndRatio() async {359    let search = SearchStub()360    // Pre-load enough results for any plausible burst; the test asserts on361    // request counts, not on which exact request wins.362    search.results = Array(repeating: .success(([], nil)), count: 8)363    let state = AppState(364      api: search, chooseFolder: { _, completion in completion(nil) },365      terminateApplication: {}, observeChanges: true)366367    // Let the initial-value debouncers from searchText/categories/purity368    // settle before we measure the cost of our rapid filter changes.369    try? await Task.sleep(nanoseconds: 1_000_000_000)370    let baseline = search.requests.count371372    // Rapid-fire several changes per filter. With debounce these should373    // coalesce into a single fetch per filter; without it, each assignment374    // would produce its own request.375    state.selectedResolution = "1920x1080"376    state.selectedResolution = "2560x1440"377    state.selectedResolution = "3840x2160"378    state.selectedRatio = "16x9"379    state.selectedRatio = "21x9"380381    // Wait long enough for every debouncer to settle (3x the 0.5s window).382    try? await Task.sleep(nanoseconds: 1_500_000_000)383384    let newRequests = Array(search.requests.dropFirst(baseline))385    // Each debounced filter (resolution, ratio) fires its own fetch, but386    // every fetch reads the latest values of both filters at the time it387    // runs. The debouncers coalesce the rapid changes into exactly one388    // fetch per filter, so we expect two new requests rather than the five389    // an unguarded publisher would have produced.390    #expect(newRequests.count == 2)391    for request in newRequests {392      #expect(request.resolutions == ["3840x2160"])393      #expect(request.ratios == ["21x9"])394    }395  }396397  @Test("Stale reset responses do not overwrite a newer query")398  func staleResetResponseIsIgnored() async {399    let search = DeferredSearchStub()400    let state = makeState(search: search)401    state.selectedResolution = "Any"402    state.selectedRatio = "Any"403404    let firstTask = Task { await state.fetchWallpapers() }405    let secondTask = Task { await state.fetchWallpapers() }406407    await search.waitForPending(2)408409    // Resolve the second (newer) request first, then resolve the first so410    // the first response lands AFTER the second has already committed. The411    // generation guard should drop the stale first response.412    search.resume(413      .success(414        ([makeWallpaper(id: "newer")], Meta(current_page: 1, last_page: 1, per_page: 24, total: 1))),415      for: 2)416    search.resume(417      .success(418        ([makeWallpaper(id: "stale")], Meta(current_page: 1, last_page: 1, per_page: 24, total: 1))),419      for: 1)420421    _ = await (firstTask.value, secondTask.value)422423    #expect(state.wallpapers.map(\.id) == ["newer"])424  }425426  @Test("Stale pagination responses do not advance past a newer reset")427  func stalePaginationResponseIsIgnored() async {428    let search = DeferredSearchStub()429    let state = makeState(search: search)430    state.selectedResolution = "Any"431    state.selectedRatio = "Any"432433    // First reset: page 1 of 2 (pagination available).434    let firstReset = Task { await state.fetchWallpapers() }435    await search.waitForPending(1)436    search.resume(437      .success(438        (439          [makeWallpaper(id: "first-reset")],440          Meta(current_page: 1, last_page: 2, per_page: 24, total: 2)441        )),442      for: 1)443444    // Drain the first reset so isLoading is false, then trigger a pagination.445    _ = await firstReset.value446    #expect(state.canLoadMore)447448    let paginationTask = Task { await state.fetchWallpapers(reset: false) }449    await search.waitForPending(2)450451    // While pagination is in flight, issue a newer reset that supersedes it.452    let newerReset = Task { await state.fetchWallpapers(reset: true) }453    await search.waitForPending(3)454455    // Resolve new reset first → wallpapers replaced with [newer-reset].456    search.resume(457      .success(458        (459          [makeWallpaper(id: "newer-reset")],460          Meta(current_page: 1, last_page: 1, per_page: 24, total: 1)461        )),462      for: 3)463464    // Then resolve the (now stale) pagination response. Its return value465    // would otherwise append "stale-page" to the wallpapers list.466    search.resume(467      .success(468        ([makeWallpaper(id: "stale-page")], nil)),469      for: 2)470471    _ = await (paginationTask.value, newerReset.value)472473    #expect(state.wallpapers.map(\.id) == ["newer-reset"])474  }475476  @Test("Pagination responses from the current generation still advance")477  func paginationAdvancesForCurrentGeneration() async {478    let search = SearchStub()479    let first = makeWallpaper(id: "first")480    let second = makeWallpaper(id: "second")481    search.results = [482      .success(([first], Meta(current_page: 1, last_page: 2, per_page: 24, total: 2))),483      .success(([second], Meta(current_page: 2, last_page: 2, per_page: 24, total: 2))),484    ]485    let state = makeState(search: search)486    state.selectedResolution = "Any"487    state.selectedRatio = "Any"488489    await state.fetchWallpapers()490    #expect(state.canLoadMore)491492    await state.loadMore()493    #expect(!state.canLoadMore)494    #expect(state.wallpapers.map(\.id) == ["first", "second"])495  }496}