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}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 243 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 369 370 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 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 404 _ = await firstReset.value405 #expect(state.canLoadMore)406407 let paginationTask = Task { await state.fetchWallpapers(reset: false) }408 await search.waitForPending(2)409410 411 let newerReset = Task { await state.fetchWallpapers(reset: true) }412 await search.waitForPending(3)413414 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 424 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}