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}323334353637@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 59 60 61 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 86 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 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 261 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 383 384 385 try? await Task.sleep(nanoseconds: 1_200_000_000)386 #expect(search.requests.isEmpty)387388 389 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 413 #expect(state.errorMessage == nil)414415 await state.fetchWallpapers()416417 #expect(state.wallhavenStatus == nil)418 #expect(state.wallpapers.map(\.id) == ["recovered"])419420 421 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 444 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 451 452 try? await Task.sleep(nanoseconds: 1_000_000_000)453 let baseline = search.requests.count454455 456 457 458 state.selectedResolution = "1920x1080"459 state.selectedResolution = "2560x1440"460 state.selectedResolution = "3840x2160"461 state.selectedRatio = "16x9"462 state.selectedRatio = "21x9"463464 465 try? await Task.sleep(nanoseconds: 1_500_000_000)466467 let newRequests = Array(search.requests.dropFirst(baseline))468 469 470 471 472 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 493 494 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 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 528 _ = await firstReset.value529 #expect(state.canLoadMore)530531 let paginationTask = Task { await state.fetchWallpapers(reset: false) }532 await search.waitForPending(2)533534 535 let newerReset = Task { await state.fetchWallpapers(reset: true) }536 await search.waitForPending(3)537538 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 548 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}