import AppKit import Foundation import Testing @testable import Haven @MainActor final class SearchStub: WallhavenSearching { struct Request { let query: String let categories: String let purity: String let resolutions: [String] let ratios: [String] let page: Int } var results: [Result<([Wallpaper], Meta?), Error>] = [] var requests: [Request] = [] func searchWallpapers( query: String, categories: String, purity: String, sorting: WallhavenSorting, resolutions: [String], ratios: [String], limit: Int, page: Int ) async throws -> ([Wallpaper], Meta?) { requests.append( Request( query: query, categories: categories, purity: purity, resolutions: resolutions, ratios: ratios, page: page)) return try results.removeFirst().get() } } /// Search stub whose requests park on continuations until the test explicitly /// resumes them. Used to deterministically interleave reset requests from /// two concurrent `fetchWallpapers` calls so the generation guard can be /// exercised without flaky timing assumptions. @MainActor final class DeferredSearchStub: WallhavenSearching { private var pending: [Int: CheckedContinuation<([Wallpaper], Meta?), Error>] = [:] var nextId = 0 func searchWallpapers( query: String, categories: String, purity: String, sorting: WallhavenSorting, resolutions: [String], ratios: [String], limit: Int, page: Int ) async throws -> ([Wallpaper], Meta?) { nextId += 1 let id = nextId return try await withCheckedThrowingContinuation { cont in pending[id] = cont } } func resume(_ result: Result<([Wallpaper], Meta?), Error>, for id: Int) { guard let cont = pending.removeValue(forKey: id) else { return } cont.resume(with: result) } /// Polls until `count` requests are parked, yielding to the runtime so the /// two fetchWallpapers calls can both reach the API. Without this the test /// would sometimes resume a continuation before its request had been /// registered, deadlocking the second `withCheckedThrowingContinuation`. func waitForPending(_ count: Int) async { for _ in 0..<1000 where pending.count < count { try? await Task.sleep(nanoseconds: 1_000_000) } } } @MainActor final class WallpaperStub: WallpaperManaging { var error: Error? var downloadedURL = URL(fileURLWithPath: "/tmp/downloaded.jpg") var downloads: [URL] = [] var cacheLookups: [URL] = [] var desktopURLs: [URL] = [] var saves: [(URL, URL)] = [] private var cached: Set = [] func download(from url: URL) async throws -> URL { if let error { throw error } downloads.append(url) return downloadedURL } /// Mirrors `WallpaperManager.cachedLocalFile`: only calls through to /// `download(from:)` the first time a given URL is requested. func cachedLocalFile(for url: URL) async throws -> URL { cacheLookups.append(url) if cached.contains(url) { return downloadedURL } let result = try await download(from: url) cached.insert(url) return result } func setDesktopImage(from fileURL: URL, screen: NSScreen?) throws { if let error { throw error } desktopURLs.append(fileURL) } func saveToFolder(from sourceURL: URL, to folderURL: URL) throws { if let error { throw error } saves.append((sourceURL, folderURL)) } } @MainActor final class AutostartStub: AutostartManaging { var isEnabled = false var lastError: String? } @Suite("Explore feed configuration") struct FeedModeTests { @Test( "Feed modes map to API sorting", arguments: [ (FeedMode.latest, WallhavenSorting.dateAdded), (FeedMode.toplist, WallhavenSorting.toplist), (FeedMode.random, WallhavenSorting.random), ] ) func sortingMapping(mode: FeedMode, sorting: WallhavenSorting) { #expect(mode.sorting.rawValue == sorting.rawValue) } @Test("Every feed mode has non-empty display metadata", arguments: FeedMode.allCases) func displayMetadata(mode: FeedMode) { #expect(!mode.displayName.isEmpty) #expect(!mode.systemImage.isEmpty) #expect(mode.id == mode.rawValue) } @Test("Resolution and ratio defaults retain the Any option") @MainActor func filterOptions() { let state = AppState() #expect(state.availableResolutions.first == "Any") #expect(state.availableRatios.first == "Any") #expect(state.availableResolutions.contains("3840x2160")) #expect(state.availableRatios.contains("21x9")) } } @Suite("Application state behavior", .serialized) @MainActor struct ApplicationStateTests { private func makeState( search: WallhavenSearching? = nil, wallpaper: WallpaperStub? = nil, autostart: AutostartStub? = nil, chooseFolder: @escaping @MainActor (URL, @escaping (URL?) -> Void) -> Void = { _, completion in completion(nil) }, terminate: @escaping @MainActor () -> Void = {} ) -> AppState { let search: WallhavenSearching = search ?? SearchStub() let wallpaper = wallpaper ?? WallpaperStub() let autostart = autostart ?? AutostartStub() return AppState( api: search, wallpaperManager: wallpaper, autostartManager: autostart, chooseFolder: chooseFolder, terminateApplication: terminate, observeChanges: false) } @Test("Reset and pagination apply filters, metadata, and append behavior") func fetchAndPaginate() async { let search = SearchStub() let first = makeWallpaper(id: "first") let second = makeWallpaper(id: "second") search.results = [ .success(([first], Meta(current_page: 1, last_page: 2, per_page: 24, total: 2))), .success(([second], Meta(current_page: 2, last_page: 2, per_page: 24, total: 2))), ] let state = makeState(search: search) state.searchText = "forest" state.selectedResolution = "3840x2160" state.selectedRatio = "21x9" state.categories = (true, false, true) state.purity = (false, true) await state.fetchWallpapers() #expect(state.wallpapers == [first]) #expect(state.canLoadMore) #expect(!state.isLoading) #expect(search.requests[0].query == "forest") #expect(search.requests[0].categories == "101") #expect(search.requests[0].purity == "010") #expect(search.requests[0].resolutions == ["3840x2160"]) #expect(search.requests[0].ratios == ["21x9"]) await state.loadMore() #expect(state.wallpapers == [first, second]) #expect(!state.canLoadMore) #expect(search.requests[1].page == 2) await state.loadMore() #expect(search.requests.count == 2) } @Test("Metadata-free responses and failures update fallback pagination state") func fallbackPaginationAndFailures() async { let search = SearchStub() search.results = [ .success(([makeWallpaper(id: "one")], nil)), .success(([], nil)), .failure(URLError(.notConnectedToInternet)), .failure(URLError(.timedOut)), ] let state = makeState(search: search) state.selectedResolution = "Any" state.selectedRatio = "Any" await state.fetchWallpapers() #expect(state.canLoadMore) await state.loadMore() #expect(!state.canLoadMore) #expect(search.requests[1].page == 2) await state.fetchWallpapers() #expect(state.errorMessage != nil) state.canLoadMore = true await state.loadMore() #expect(!state.isLoading) } @Test("Remote wallpaper operations cover saving and failures") func remoteWallpaperOperations() async { let wallpaperManager = WallpaperStub() let state = makeState(wallpaper: wallpaperManager) let wallpaper = makeWallpaper() state.saveFolderPath = "/tmp/haven-tests" state.saveToFolderEnabled = false await state.setWallpaper(wallpaper) #expect(wallpaperManager.desktopURLs == [wallpaperManager.downloadedURL]) #expect(wallpaperManager.saves.isEmpty) state.saveToFolderEnabled = true await state.setWallpaper(wallpaper) #expect(wallpaperManager.saves.count == 1) // Same wallpaper set twice: only the first call should hit the network. #expect(wallpaperManager.cacheLookups.count == 2) #expect(wallpaperManager.downloads.count == 1) await state.downloadWallpaper(wallpaper) #expect(wallpaperManager.saves.count == 2) wallpaperManager.error = URLError(.cannotWriteToFile) await state.setWallpaper(wallpaper) #expect(state.errorMessage?.hasPrefix("Failed to set wallpaper") == true) await state.downloadWallpaper(wallpaper) #expect(state.errorMessage?.hasPrefix("Failed to download") == true) } @Test("Malformed wallpaper URLs surface as errorMessage without force-unwrapping") func malformedURLsAreSurfacedAsError() async { let wallpaperManager = WallpaperStub() let state = makeState(wallpaper: wallpaperManager) // `URL(string:)` returns nil for malformed authority/port strings. These // were the inputs that used to crash through a force-unwrap. let bad = Wallpaper( id: "bad", url: "https://wallhaven.cc/w/bad", path: "http://example.com:notaport", resolution: "1x1", purity: "sfw", file_size: 1, thumbs: Wallpaper.Thumbs( large: "https://example.com/large", original: "https://example.com/orig", small: "https://example.com/small" ) ) await state.setWallpaper(bad) #expect(state.errorMessage?.contains("invalid image URL") == true) #expect(wallpaperManager.downloads.isEmpty) state.errorMessage = nil await state.downloadWallpaper(bad) #expect(state.errorMessage?.contains("invalid image URL") == true) #expect(wallpaperManager.downloads.isEmpty) } @Test("Empty path strings are rejected before any download is attempted") func emptyPathIsRejected() async { let wallpaperManager = WallpaperStub() let state = makeState(wallpaper: wallpaperManager) let empty = makeWallpaper(path: "") await state.setWallpaper(empty) #expect(state.errorMessage?.contains("invalid image URL") == true) state.errorMessage = nil await state.downloadWallpaper(empty) #expect(state.errorMessage?.contains("invalid image URL") == true) } @Test("Local wallpaper, autostart, folder picker, and quit are injectable") func localAndPlatformActions() async { let wallpaperManager = WallpaperStub() let autostart = AutostartStub() var terminated = false let selected = URL(fileURLWithPath: "/tmp/selected") let state = makeState( wallpaper: wallpaperManager, autostart: autostart, chooseFolder: { _, completion in completion(selected) }, terminate: { terminated = true }) let local = LocalWallpaper( id: "local", url: URL(fileURLWithPath: "/tmp/local.jpg"), filename: "local.jpg", resolution: "1x1", fileSize: 10, createdAt: Date(), modifiedAt: Date()) await state.setLocalWallpaper(local) #expect(wallpaperManager.desktopURLs == [local.url]) #expect(state.historyManager.history.first?.purity == "local") wallpaperManager.error = URLError(.cannotOpenFile) await state.setLocalWallpaper(local) #expect(state.errorMessage?.hasPrefix("Failed to set wallpaper") == true) state.launchAtLogin = true #expect(autostart.isEnabled) state.selectSaveFolder() #expect(state.saveFolderPath == selected.path) state.quit() #expect(terminated) var offeredDirectory: URL? let cancelState = makeState(chooseFolder: { directory, completion in offeredDirectory = directory completion(nil) }) let oldPath = cancelState.saveFolderPath cancelState.selectSaveFolder() #expect(offeredDirectory?.path == oldPath) #expect(cancelState.saveFolderPath == oldPath) } @Test("Observed search and settings changes trigger their pipelines") func observedChanges() async { let search = SearchStub() search.results = Array(repeating: .success(([], nil)), count: 50) let state = AppState( api: search, chooseFolder: { _, completion in completion(nil) }, terminateApplication: {}, observeChanges: true) func waitForRequests(_ expected: Int) async { for _ in 0..<100 where search.requests.count < expected { try? await Task.sleep(nanoseconds: 20_000_000) } } state.scheduleSearch() await waitForRequests(1) state.feedMode = .random await waitForRequests(2) state.searchText = "updated" await waitForRequests(3) state.categories = (false, true, true) await waitForRequests(4) state.purity = (true, true) await waitForRequests(5) state.saveToFolderEnabled.toggle() state.saveFolderPath = FileManager.default.temporaryDirectory.path #expect(search.requests.count >= 5) } @Test("Initialization alone launches no search") func initializationLaunchesNoSearch() async { let search = SearchStub() search.results = Array(repeating: .success(([], nil)), count: 8) let state = AppState( api: search, chooseFolder: { _, completion in completion(nil) }, terminateApplication: {}, observeChanges: true) // `@Published` replays its current value to every new subscriber, so the // debounced pipelines would otherwise turn that replay into searches that // fire before the popover is ever opened. try? await Task.sleep(nanoseconds: 1_200_000_000) #expect(search.requests.isEmpty) // The instance is still alive and its pipelines still work, so the // assertion above is not just observing a deallocated state object. state.scheduleSearch() for _ in 0..<100 where search.requests.isEmpty { try? await Task.sleep(nanoseconds: 20_000_000) } #expect(search.requests.count == 1) } @Test("Resolution and ratio changes are debounced like other filters") func debouncedResolutionAndRatio() async { let search = SearchStub() // Pre-load enough results for any plausible burst; the test asserts on // request counts, not on which exact request wins. search.results = Array(repeating: .success(([], nil)), count: 8) let state = AppState( api: search, chooseFolder: { _, completion in completion(nil) }, terminateApplication: {}, observeChanges: true) // Let the initial-value debouncers from searchText/categories/purity // settle before we measure the cost of our rapid filter changes. try? await Task.sleep(nanoseconds: 1_000_000_000) let baseline = search.requests.count // Rapid-fire several changes per filter. With debounce these should // coalesce into a single fetch per filter; without it, each assignment // would produce its own request. state.selectedResolution = "1920x1080" state.selectedResolution = "2560x1440" state.selectedResolution = "3840x2160" state.selectedRatio = "16x9" state.selectedRatio = "21x9" // Wait long enough for every debouncer to settle (3x the 0.5s window). try? await Task.sleep(nanoseconds: 1_500_000_000) let newRequests = Array(search.requests.dropFirst(baseline)) // Each debounced filter (resolution, ratio) fires its own fetch, but // every fetch reads the latest values of both filters at the time it // runs. The debouncers coalesce the rapid changes into exactly one // fetch per filter, so we expect two new requests rather than the five // an unguarded publisher would have produced. #expect(newRequests.count == 2) for request in newRequests { #expect(request.resolutions == ["3840x2160"]) #expect(request.ratios == ["21x9"]) } } @Test("Stale reset responses do not overwrite a newer query") func staleResetResponseIsIgnored() async { let search = DeferredSearchStub() let state = makeState(search: search) state.selectedResolution = "Any" state.selectedRatio = "Any" let firstTask = Task { await state.fetchWallpapers() } let secondTask = Task { await state.fetchWallpapers() } await search.waitForPending(2) // Resolve the second (newer) request first, then resolve the first so // the first response lands AFTER the second has already committed. The // generation guard should drop the stale first response. search.resume( .success( ([makeWallpaper(id: "newer")], Meta(current_page: 1, last_page: 1, per_page: 24, total: 1))), for: 2) search.resume( .success( ([makeWallpaper(id: "stale")], Meta(current_page: 1, last_page: 1, per_page: 24, total: 1))), for: 1) _ = await (firstTask.value, secondTask.value) #expect(state.wallpapers.map(\.id) == ["newer"]) } @Test("Stale pagination responses do not advance past a newer reset") func stalePaginationResponseIsIgnored() async { let search = DeferredSearchStub() let state = makeState(search: search) state.selectedResolution = "Any" state.selectedRatio = "Any" // First reset: page 1 of 2 (pagination available). let firstReset = Task { await state.fetchWallpapers() } await search.waitForPending(1) search.resume( .success( ( [makeWallpaper(id: "first-reset")], Meta(current_page: 1, last_page: 2, per_page: 24, total: 2) )), for: 1) // Drain the first reset so isLoading is false, then trigger a pagination. _ = await firstReset.value #expect(state.canLoadMore) let paginationTask = Task { await state.fetchWallpapers(reset: false) } await search.waitForPending(2) // While pagination is in flight, issue a newer reset that supersedes it. let newerReset = Task { await state.fetchWallpapers(reset: true) } await search.waitForPending(3) // Resolve new reset first → wallpapers replaced with [newer-reset]. search.resume( .success( ( [makeWallpaper(id: "newer-reset")], Meta(current_page: 1, last_page: 1, per_page: 24, total: 1) )), for: 3) // Then resolve the (now stale) pagination response. Its return value // would otherwise append "stale-page" to the wallpapers list. search.resume( .success( ([makeWallpaper(id: "stale-page")], nil)), for: 2) _ = await (paginationTask.value, newerReset.value) #expect(state.wallpapers.map(\.id) == ["newer-reset"]) } @Test("Pagination responses from the current generation still advance") func paginationAdvancesForCurrentGeneration() async { let search = SearchStub() let first = makeWallpaper(id: "first") let second = makeWallpaper(id: "second") search.results = [ .success(([first], Meta(current_page: 1, last_page: 2, per_page: 24, total: 2))), .success(([second], Meta(current_page: 2, last_page: 2, per_page: 24, total: 2))), ] let state = makeState(search: search) state.selectedResolution = "Any" state.selectedRatio = "Any" await state.fetchWallpapers() #expect(state.canLoadMore) await state.loadMore() #expect(!state.canLoadMore) #expect(state.wallpapers.map(\.id) == ["first", "second"]) } }