AppStateTests.swift

18.5 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("Resolution and ratio changes are debounced like other filters")398  func debouncedResolutionAndRatio() async {399    let search = SearchStub()400    // Pre-load enough results for any plausible burst; the test asserts on401    // request counts, not on which exact request wins.402    search.results = Array(repeating: .success(([], nil)), count: 8)403    let state = AppState(404      api: search, chooseFolder: { _, completion in completion(nil) },405      terminateApplication: {}, observeChanges: true)406407    // Let the initial-value debouncers from searchText/categories/purity408    // settle before we measure the cost of our rapid filter changes.409    try? await Task.sleep(nanoseconds: 1_000_000_000)410    let baseline = search.requests.count411412    // Rapid-fire several changes per filter. With debounce these should413    // coalesce into a single fetch per filter; without it, each assignment414    // would produce its own request.415    state.selectedResolution = "1920x1080"416    state.selectedResolution = "2560x1440"417    state.selectedResolution = "3840x2160"418    state.selectedRatio = "16x9"419    state.selectedRatio = "21x9"420421    // Wait long enough for every debouncer to settle (3x the 0.5s window).422    try? await Task.sleep(nanoseconds: 1_500_000_000)423424    let newRequests = Array(search.requests.dropFirst(baseline))425    // Each debounced filter (resolution, ratio) fires its own fetch, but426    // every fetch reads the latest values of both filters at the time it427    // runs. The debouncers coalesce the rapid changes into exactly one428    // fetch per filter, so we expect two new requests rather than the five429    // an unguarded publisher would have produced.430    #expect(newRequests.count == 2)431    for request in newRequests {432      #expect(request.resolutions == ["3840x2160"])433      #expect(request.ratios == ["21x9"])434    }435  }436437  @Test("Stale reset responses do not overwrite a newer query")438  func staleResetResponseIsIgnored() async {439    let search = DeferredSearchStub()440    let state = makeState(search: search)441    state.selectedResolution = "Any"442    state.selectedRatio = "Any"443444    let firstTask = Task { await state.fetchWallpapers() }445    let secondTask = Task { await state.fetchWallpapers() }446447    await search.waitForPending(2)448449    // Resolve the second (newer) request first, then resolve the first so450    // the first response lands AFTER the second has already committed. The451    // generation guard should drop the stale first response.452    search.resume(453      .success(454        ([makeWallpaper(id: "newer")], Meta(current_page: 1, last_page: 1, per_page: 24, total: 1))),455      for: 2)456    search.resume(457      .success(458        ([makeWallpaper(id: "stale")], Meta(current_page: 1, last_page: 1, per_page: 24, total: 1))),459      for: 1)460461    _ = await (firstTask.value, secondTask.value)462463    #expect(state.wallpapers.map(\.id) == ["newer"])464  }465466  @Test("Stale pagination responses do not advance past a newer reset")467  func stalePaginationResponseIsIgnored() async {468    let search = DeferredSearchStub()469    let state = makeState(search: search)470    state.selectedResolution = "Any"471    state.selectedRatio = "Any"472473    // First reset: page 1 of 2 (pagination available).474    let firstReset = Task { await state.fetchWallpapers() }475    await search.waitForPending(1)476    search.resume(477      .success(478        (479          [makeWallpaper(id: "first-reset")],480          Meta(current_page: 1, last_page: 2, per_page: 24, total: 2)481        )),482      for: 1)483484    // Drain the first reset so isLoading is false, then trigger a pagination.485    _ = await firstReset.value486    #expect(state.canLoadMore)487488    let paginationTask = Task { await state.fetchWallpapers(reset: false) }489    await search.waitForPending(2)490491    // While pagination is in flight, issue a newer reset that supersedes it.492    let newerReset = Task { await state.fetchWallpapers(reset: true) }493    await search.waitForPending(3)494495    // Resolve new reset first → wallpapers replaced with [newer-reset].496    search.resume(497      .success(498        (499          [makeWallpaper(id: "newer-reset")],500          Meta(current_page: 1, last_page: 1, per_page: 24, total: 1)501        )),502      for: 3)503504    // Then resolve the (now stale) pagination response. Its return value505    // would otherwise append "stale-page" to the wallpapers list.506    search.resume(507      .success(508        ([makeWallpaper(id: "stale-page")], nil)),509      for: 2)510511    _ = await (paginationTask.value, newerReset.value)512513    #expect(state.wallpapers.map(\.id) == ["newer-reset"])514  }515516  @Test("Pagination responses from the current generation still advance")517  func paginationAdvancesForCurrentGeneration() async {518    let search = SearchStub()519    let first = makeWallpaper(id: "first")520    let second = makeWallpaper(id: "second")521    search.results = [522      .success(([first], Meta(current_page: 1, last_page: 2, per_page: 24, total: 2))),523      .success(([second], Meta(current_page: 2, last_page: 2, per_page: 24, total: 2))),524    ]525    let state = makeState(search: search)526    state.selectedResolution = "Any"527    state.selectedRatio = "Any"528529    await state.fetchWallpapers()530    #expect(state.canLoadMore)531532    await state.loadMore()533    #expect(!state.canLoadMore)534    #expect(state.wallpapers.map(\.id) == ["first", "second"])535  }536}