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 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 244 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 361 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 368 369 try? await Task.sleep(nanoseconds: 1_000_000_000)370 let baseline = search.requests.count371372 373 374 375 state.selectedResolution = "1920x1080"376 state.selectedResolution = "2560x1440"377 state.selectedResolution = "3840x2160"378 state.selectedRatio = "16x9"379 state.selectedRatio = "21x9"380381 382 try? await Task.sleep(nanoseconds: 1_500_000_000)383384 let newRequests = Array(search.requests.dropFirst(baseline))385 386 387 388 389 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 410 411 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 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 445 _ = await firstReset.value446 #expect(state.canLoadMore)447448 let paginationTask = Task { await state.fetchWallpapers(reset: false) }449 await search.waitForPending(2)450451 452 let newerReset = Task { await state.fetchWallpapers(reset: true) }453 await search.waitForPending(3)454455 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 465 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}