8d1af4a87126
resolve phase 2 medium-priority findings (v0.6.0)
- time
- 2026-07-16 20:55 UTC · 3 months ago
- machine
- koh2
- agent
- claude-sonnet-5
- session
- resolve audited findings
- parent
- 0380f9710032
↓ haven-8d1af4a8.tar.gzatlas at this save
files changed8 files
Sources/Haven/Managers/FavoritesManager.swift+54 −8Sources/Haven/Managers/HistoryManager.swift+53 −12Sources/Haven/Managers/LocalWallpapersManager.swift+73 −32Sources/Haven/UI/AppState.swift+42 −9Sources/Haven/UI/Views/LocalView.swift+28 −1Tests/HavenTests/AppStateTests.swift+175 −2Tests/HavenTests/ManagerTests.swift+155 −0Tests/HavenTests/ViewCoverageTests.swift+34 −0Sources/Haven/Managers/FavoritesManager.swift+54 −8
@@ -4,9 +4,24 @@44 static let shared = FavoritesManager()55 66 @Published var favorites: [Wallpaper] = []7+ /// Latest persistence failure, surfaced so the UI/app layer can react.8+ /// Corrupt or unwritable stored data no longer fails silently — the9+ /// manager keeps the in-memory list intact but exposes the cause.10+ @Published var persistenceError: String?11+712 private let key = "favoriteWallpapers"13+ private let defaults: UserDefaults14+ private let encoder: JSONEncoder15+ private let decoder: JSONDecoder816 9- init() {17+ init(18+ defaults: UserDefaults = .standard,19+ encoder: JSONEncoder = JSONEncoder(),20+ decoder: JSONDecoder = JSONDecoder()21+ ) {22+ self.defaults = defaults23+ self.encoder = encoder24+ self.decoder = decoder1025 loadFavorites()1126 }1227 @@ -24,16 +39,47 @@2439 }2540 2641 private func saveFavorites() {27- if let data = try? JSONEncoder().encode(favorites) {28- UserDefaults.standard.set(data, forKey: key)42+ persist(currentFavorites())43+ }44+45+ /// Public for diagnostics/testing. Encodes the current state through the46+ /// configured encoder and reports the first failure through47+ /// `persistenceError`.48+ func persist(_ wallpapers: [Wallpaper]) {49+ do {50+ let data = try encoder.encode(wallpapers)51+ defaults.set(data, forKey: key)52+ persistenceError = nil53+ } catch {54+ persistenceError = "Failed to save favorites: \(error.localizedDescription)"2955 }3056 }3157 32- private func loadFavorites() {33- if let data = UserDefaults.standard.data(forKey: key),34- let decoded = try? JSONDecoder().decode([Wallpaper].self, from: data)35- {36- favorites = decoded58+ /// Decodes the persisted list through the configured decoder. Public to59+ /// keep the load path testable while letting the manager choose its own60+ /// defaults in production. Sets `persistenceError` when the stored data is61+ /// missing or undecodable so callers can react without inspecting logs.62+ func decodeFavorites(from data: Data?) -> [Wallpaper]? {63+ guard let data else {64+ persistenceError = nil65+ return nil3766 }67+ do {68+ let decoded = try decoder.decode([Wallpaper].self, from: data)69+ persistenceError = nil70+ return decoded71+ } catch {72+ persistenceError = "Failed to load favorites: \(error.localizedDescription)"73+ return nil74+ }75+ }76+77+ private func loadFavorites() {78+ favorites = currentFavorites()79+ }80+81+ private func currentFavorites() -> [Wallpaper] {82+ let stored = defaults.data(forKey: key)83+ return decodeFavorites(from: stored) ?? favorites3884 }3985 }Sources/Haven/Managers/HistoryManager.swift+53 −12
@@ -4,10 +4,27 @@44 static let shared = HistoryManager()55 66 @Published var history: [Wallpaper] = []7+ /// Latest persistence failure, surfaced so callers can react. The8+ /// in-memory history still updates when persistence fails — the manager9+ /// no longer hides corruption or unwritable storage behind `try?`.10+ @Published var persistenceError: String?11+712 private let key = "wallpaperHistory"8- private let maxHistoryItems = 5013+ private let maxHistoryItems: Int14+ private let defaults: UserDefaults15+ private let encoder: JSONEncoder16+ private let decoder: JSONDecoder917 10- init() {18+ init(19+ defaults: UserDefaults = .standard,20+ encoder: JSONEncoder = JSONEncoder(),21+ decoder: JSONDecoder = JSONDecoder(),22+ maxHistoryItems: Int = 5023+ ) {24+ self.defaults = defaults25+ self.encoder = encoder26+ self.decoder = decoder27+ self.maxHistoryItems = maxHistoryItems1128 loadHistory()1229 }1330 @@ -23,25 +40,49 @@2340 history = Array(history.prefix(maxHistoryItems))2441 }2542 26- saveHistory()43+ persist(history)2744 }2845 2946 func clearHistory() {3047 history = []31- saveHistory()48+ persist(history)3249 }3350 34- private func saveHistory() {35- if let data = try? JSONEncoder().encode(history) {36- UserDefaults.standard.set(data, forKey: key)51+ /// Public for diagnostics/testing. Encodes the supplied list through the52+ /// configured encoder, writes through the configured `UserDefaults`,53+ /// and records the first failure through `persistenceError`. Keeping the54+ /// encoder/userdefaults injectable lets us assert failure paths in tests55+ /// without hijacking the shared `UserDefaults.standard` instance.56+ func persist(_ entries: [Wallpaper]) {57+ do {58+ let data = try encoder.encode(entries)59+ defaults.set(data, forKey: key)60+ persistenceError = nil61+ } catch {62+ persistenceError = "Failed to save history: \(error.localizedDescription)"3763 }3864 }3965 40- private func loadHistory() {41- if let data = UserDefaults.standard.data(forKey: key),42- let decoded = try? JSONDecoder().decode([Wallpaper].self, from: data)43- {44- history = decoded66+ /// Decodes the stored history. Public for testability. Returns nil when67+ /// the stored data is missing or undecodable and records the failure68+ /// reason through `persistenceError` so the app can react.69+ func decodeHistory(from data: Data?) -> [Wallpaper]? {70+ guard let data else {71+ persistenceError = nil72+ return nil4573 }74+ do {75+ let decoded = try decoder.decode([Wallpaper].self, from: data)76+ persistenceError = nil77+ return decoded78+ } catch {79+ persistenceError = "Failed to load history: \(error.localizedDescription)"80+ return nil81+ }82+ }83+84+ private func loadHistory() {85+ let stored = defaults.data(forKey: key)86+ history = decodeHistory(from: stored) ?? history4687 }4788 }Sources/Haven/Managers/LocalWallpapersManager.swift+73 −32
@@ -55,50 +55,91 @@5555 folderMonitor = nil5656 }5757 58+ /// Bumped on every `scanFolder` invocation. A scan only commits its59+ /// results when it still matches this counter at completion, which means60+ /// newer requests take priority over older in-flight scans and filesystem61+ /// changes after the scan started are observed by the next request rather62+ /// than silently dropped.63+ private var scanGeneration: UInt64 = 064+5865 func scanFolder(path: String) {59- guard !isLoading else { return }66+ scanGeneration &+= 167+ let requestGeneration = scanGeneration6068 isLoading = true6169 errorMessage = nil6270 71+ // Snapshot all immutable configuration the detached task needs into a72+ // Sendable value. This keeps `Task.detached` from capturing `self` (and73+ // its non-Sendable mutable state) by reference, which the Swift 674+ // concurrency checker would otherwise flag.75+ let snapshot = ScanRequest(76+ path: path,77+ generation: requestGeneration,78+ imageExtensions: imageExtensions)79+6380 Task.detached(priority: .userInitiated) { [weak self] in64- guard let self = self else { return }81+ let result = LocalWallpapersManager.performScan(request: snapshot)82+ await MainActor.run {83+ guard let self = self else { return }84+ guard self.scanGeneration == snapshot.generation else { return }85+ switch result {86+ case .success(let wallpapers):87+ self.wallpapers = wallpapers88+ self.errorMessage = nil89+ case .failure(let message):90+ self.errorMessage = message91+ }92+ self.isLoading = false93+ }94+ }95+ }6596 66- let folderURL = URL(fileURLWithPath: path)67- let fileManager = FileManager.default97+ /// Pure, Sendable value carried across the actor boundary into the98+ /// detached scan task. Capturing everything the scan needs up front means99+ /// the closure body never touches mutable manager state from a non-isolated100+ /// context.101+ private struct ScanRequest: Sendable {102+ let path: String103+ let generation: UInt64104+ let imageExtensions: Set<String>105+ }68106 69- // Create the save folder on first use so a fresh install shows an70- // empty gallery instead of a scan error.71- try? fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true)107+ private enum ScanOutcome: Sendable {108+ case success([LocalWallpaper])109+ case failure(String)110+ }72111 73- do {74- let contents = try fileManager.contentsOfDirectory(75- at: folderURL,76- includingPropertiesForKeys: [77- .contentTypeKey, .fileSizeKey, .creationDateKey, .contentModificationDateKey,78- ],79- options: [.skipsHiddenFiles, .skipsPackageDescendants]80- )112+ /// Scans the folder and returns the wallpapers sorted newest-first. Runs113+ /// entirely off the main actor; all inputs are passed via the Sendable114+ /// `ScanRequest` snapshot.115+ nonisolated private static func performScan(request: ScanRequest) -> ScanOutcome {116+ let folderURL = URL(fileURLWithPath: request.path)117+ let fileManager = FileManager.default81118 82- var wallpapers: [LocalWallpaper] = []83- for url in contents {84- let ext = url.pathExtension.lowercased()85- guard self.imageExtensions.contains(ext) else { continue }86- if let wallpaper = Self.wallpaper(at: url) { wallpapers.append(wallpaper) }87- }119+ // Create the save folder on first use so a fresh install shows an120+ // empty gallery instead of a scan error.121+ try? fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true)88122 89- wallpapers.sort { $0.modifiedAt > $1.modifiedAt }123+ do {124+ let contents = try fileManager.contentsOfDirectory(125+ at: folderURL,126+ includingPropertiesForKeys: [127+ .contentTypeKey, .fileSizeKey, .creationDateKey, .contentModificationDateKey,128+ ],129+ options: [.skipsHiddenFiles, .skipsPackageDescendants]130+ )90131 91- let finalWallpapers = wallpapers92- await MainActor.run {93- self.wallpapers = finalWallpapers94- self.isLoading = false95- }96- } catch {97- await MainActor.run {98- self.errorMessage = "Failed to scan folder: \(error.localizedDescription)"99- self.isLoading = false100- }132+ var wallpapers: [LocalWallpaper] = []133+ for url in contents {134+ let ext = url.pathExtension.lowercased()135+ guard request.imageExtensions.contains(ext) else { continue }136+ if let wallpaper = wallpaper(at: url) { wallpapers.append(wallpaper) }101137 }138+139+ wallpapers.sort { $0.modifiedAt > $1.modifiedAt }140+ return .success(wallpapers)141+ } catch {142+ return .failure("Failed to scan folder: \(error.localizedDescription)")102143 }103144 }104145 Sources/Haven/UI/AppState.swift+42 −9
@@ -91,6 +91,11 @@9191 private let chooseFolder: @MainActor (URL, @escaping (URL?) -> Void) -> Void9292 private let terminateApplication: @MainActor () -> Void9393 private var cancellables = Set<AnyCancellable>()94+ // Monotonic token incremented on every reset; only the search started with95+ // the latest token may commit results. This keeps a slow older request from96+ // overwriting the wallpapers/canLoadMore/errorMessage after a newer search97+ // (or pagination) has begun.98+ private var searchGeneration: UInt64 = 09499 95100 init(96101 api: WallhavenSearching? = nil,@@ -175,6 +180,11 @@175180 176181 func fetchWallpapers(reset: Bool = true, refreshing: Bool = false) async {177182 if reset {183+ searchGeneration &+= 1184+ }185+ let requestGeneration = searchGeneration186+ let pageToRequest: Int187+ if reset {178188 if !refreshing {179189 isLoading = true180190 wallpapers = []@@ -182,9 +192,11 @@182192 errorMessage = nil183193 currentPage = 1184194 canLoadMore = true195+ pageToRequest = 1185196 } else {186197 guard !isFetchingMore && canLoadMore else { return }187198 isFetchingMore = true199+ pageToRequest = currentPage188200 }189201 190202 do {@@ -206,9 +218,10 @@206218 resolutions: resolutions,207219 ratios: ratios,208220 limit: 24,209- page: currentPage221+ page: pageToRequest210222 )211223 224+ if requestGeneration != searchGeneration { return }212225 if reset {213226 self.wallpapers = newWallpapers214227 } else {@@ -216,22 +229,34 @@216229 }217230 218231 if let meta = meta {232+ let nextPage = meta.current_page + 1219233 self.canLoadMore = meta.current_page < meta.last_page220- self.currentPage = meta.current_page + 1234+ // A page that's been superseded by a newer reset would leave the next235+ // page index pointing into the stale query; only advance when this236+ // response is still the current generation.237+ if requestGeneration == searchGeneration {238+ self.currentPage = nextPage239+ }240+ } else if !newWallpapers.isEmpty {241+ self.canLoadMore = true242+ if requestGeneration == searchGeneration {243+ self.currentPage += 1244+ }221245 } else {222- self.canLoadMore = !newWallpapers.isEmpty223- if !newWallpapers.isEmpty { self.currentPage += 1 }246+ self.canLoadMore = false224247 }225248 226249 } catch {227- if reset {250+ if reset && requestGeneration == searchGeneration {228251 self.errorMessage = error.localizedDescription229252 }230253 }231254 232255 if reset {233- isLoading = false234- } else {256+ if requestGeneration == searchGeneration {257+ isLoading = false258+ }259+ } else if requestGeneration == searchGeneration {235260 isFetchingMore = false236261 }237262 }@@ -241,8 +266,12 @@241266 }242267 243268 func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async {269+ guard let remoteURL = URL(string: wallpaper.path) else {270+ self.errorMessage = "Failed to set wallpaper: invalid image URL"271+ return272+ }244273 do {245- let localURL = try await wallpaperManager.download(from: URL(string: wallpaper.path)!)274+ let localURL = try await wallpaperManager.download(from: remoteURL)246275 247276 try await MainActor.run {248277 try wallpaperManager.setDesktopImage(from: localURL, screen: screen)@@ -288,8 +317,12 @@288317 }289318 290319 func downloadWallpaper(_ wallpaper: Wallpaper) async {320+ guard let remoteURL = URL(string: wallpaper.path) else {321+ self.errorMessage = "Failed to download: invalid image URL"322+ return323+ }291324 do {292- let localURL = try await wallpaperManager.download(from: URL(string: wallpaper.path)!)325+ let localURL = try await wallpaperManager.download(from: remoteURL)293326 294327 // Always save to folder if download requested, or ensure save folder is used295328 let destFolder = URL(fileURLWithPath: saveFolderPath)Sources/Haven/UI/Views/LocalView.swift+28 −1
@@ -208,16 +208,43 @@208208 }209209 }210210 211+ /// Maximum thumbnail pixel dimension. ImageIO uses this as the larger side212+ /// of the downsampled output — the full-resolution JPEG/PNG is decoded213+ /// through `CGImageSourceCreateThumbnailAtIndex` which is dramatically214+ /// cheaper than `NSImage(contentsOf:)` for multi-megapixel wallpapers.215+ private static let thumbnailMaxPixel: Int = 320216+211217 private func loadImage() {212218 isLoadingImage = true219+ let url = self.wallpaper.url220+ let maxPixel = Self.thumbnailMaxPixel213221 214222 DispatchQueue.global(qos: .userInitiated).async {215- let loadedImage = NSImage(contentsOf: self.wallpaper.url)223+ let loadedImage = LocalWallpaperItem.makeThumbnail(url: url, maxPixel: maxPixel)216224 DispatchQueue.main.async {217225 self.image = loadedImage218226 self.isLoadingImage = false219227 }220228 }229+ }230+231+ /// Builds an `NSImage` thumbnail via ImageIO. Public/static so it can be232+ /// exercised by tests without spinning up a SwiftUI view, and so the233+ /// decoding path never falls back to the full-resolution `NSImage`234+ /// initializer used previously.235+ static func makeThumbnail(url: URL, maxPixel: Int) -> NSImage? {236+ guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }237+ let options: [CFString: Any] = [238+ kCGImageSourceCreateThumbnailFromImageAlways: true,239+ kCGImageSourceCreateThumbnailWithTransform: true,240+ kCGImageSourceShouldCacheImmediately: true,241+ kCGImageSourceThumbnailMaxPixelSize: maxPixel,242+ ]243+ guard244+ let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)245+ else { return nil }246+ let size = NSSize(width: cgImage.width, height: cgImage.height)247+ return NSImage(cgImage: cgImage, size: size)221248 }222249 223250 private func formatFileSize(_ bytes: Int64) -> String {Tests/HavenTests/AppStateTests.swift+175 −2
@@ -30,7 +30,43 @@3030 }3131 }3232 33+/// 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.3337 @MainActor38+final class DeferredSearchStub: WallhavenSearching {39+ private var pending: [Int: CheckedContinuation<([Wallpaper], Meta?), Error>] = [:]40+ var nextId = 041+42+ 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+ }52+53+ 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+ }57+58+ /// 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+}68+69+@MainActor3470 final class WallpaperStub: WallpaperManaging {3571 var error: Error?3672 var downloadedURL = URL(fileURLWithPath: "/tmp/downloaded.jpg")@@ -97,14 +133,14 @@97133 @MainActor98134 struct ApplicationStateTests {99135 private func makeState(100- search: SearchStub? = nil, wallpaper: WallpaperStub? = nil,136+ search: WallhavenSearching? = nil, wallpaper: WallpaperStub? = nil,101137 autostart: AutostartStub? = nil,102138 chooseFolder: @escaping @MainActor (URL, @escaping (URL?) -> Void) -> Void = { _, completion in103139 completion(nil)104140 },105141 terminate: @escaping @MainActor () -> Void = {}106142 ) -> AppState {107- let search = search ?? SearchStub()143+ let search: WallhavenSearching = search ?? SearchStub()108144 let wallpaper = wallpaper ?? WallpaperStub()109145 let autostart = autostart ?? AutostartStub()110146 return AppState(@@ -199,6 +235,49 @@199235 #expect(state.errorMessage?.hasPrefix("Failed to download") == true)200236 }201237 238+ @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+ // `URL(string:)` returns nil for malformed authority/port strings. These243+ // were the inputs that used to crash through a force-unwrap.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+ )257+258+ await state.setWallpaper(bad)259+ #expect(state.errorMessage?.contains("invalid image URL") == true)260+ #expect(wallpaperManager.downloads.isEmpty)261+262+ state.errorMessage = nil263+ await state.downloadWallpaper(bad)264+ #expect(state.errorMessage?.contains("invalid image URL") == true)265+ #expect(wallpaperManager.downloads.isEmpty)266+ }267+268+ @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: "")273+274+ 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+ }280+202281 @Test("Local wallpaper, autostart, folder picker, and quit are injectable")203282 func localAndPlatformActions() async {204283 let wallpaperManager = WallpaperStub()@@ -272,5 +351,99 @@272351 state.saveFolderPath = FileManager.default.temporaryDirectory.path273352 274353 #expect(search.requests.count >= 5)354+ }355+356+ @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"362+363+ let firstTask = Task { await state.fetchWallpapers() }364+ let secondTask = Task { await state.fetchWallpapers() }365+366+ await search.waitForPending(2)367+368+ // Resolve the second (newer) request first, then resolve the first so369+ // the first response lands AFTER the second has already committed. The370+ // generation guard should drop the stale first response.371+ search.resume(372+ .success(([makeWallpaper(id: "newer")], Meta(current_page: 1, last_page: 1, per_page: 24, total: 1))),373+ for: 2)374+ search.resume(375+ .success(([makeWallpaper(id: "stale")], Meta(current_page: 1, last_page: 1, per_page: 24, total: 1))),376+ for: 1)377+378+ _ = await (firstTask.value, secondTask.value)379+380+ #expect(state.wallpapers.map(\.id) == ["newer"])381+ }382+383+ @Test("Stale pagination responses do not advance past a newer reset")384+ func stalePaginationResponseIsIgnored() async {385+ let search = DeferredSearchStub()386+ let state = makeState(search: search)387+ state.selectedResolution = "Any"388+ state.selectedRatio = "Any"389+390+ // First reset: page 1 of 2 (pagination available).391+ let firstReset = Task { await state.fetchWallpapers() }392+ await search.waitForPending(1)393+ search.resume(394+ .success((395+ [makeWallpaper(id: "first-reset")],396+ Meta(current_page: 1, last_page: 2, per_page: 24, total: 2))),397+ for: 1)398+399+ // Drain the first reset so isLoading is false, then trigger a pagination.400+ _ = await firstReset.value401+ #expect(state.canLoadMore)402+403+ let paginationTask = Task { await state.fetchWallpapers(reset: false) }404+ await search.waitForPending(2)405+406+ // While pagination is in flight, issue a newer reset that supersedes it.407+ let newerReset = Task { await state.fetchWallpapers(reset: true) }408+ await search.waitForPending(3)409+410+ // Resolve new reset first → wallpapers replaced with [newer-reset].411+ search.resume(412+ .success((413+ [makeWallpaper(id: "newer-reset")],414+ Meta(current_page: 1, last_page: 1, per_page: 24, total: 1))),415+ for: 3)416+417+ // Then resolve the (now stale) pagination response. Its return value418+ // would otherwise append "stale-page" to the wallpapers list.419+ search.resume(420+ .success(421+ ([makeWallpaper(id: "stale-page")], nil)),422+ for: 2)423+424+ _ = await (paginationTask.value, newerReset.value)425+426+ #expect(state.wallpapers.map(\.id) == ["newer-reset"])427+ }428+429+ @Test("Pagination responses from the current generation still advance")430+ func paginationAdvancesForCurrentGeneration() async {431+ let search = SearchStub()432+ let first = makeWallpaper(id: "first")433+ let second = makeWallpaper(id: "second")434+ search.results = [435+ .success(([first], Meta(current_page: 1, last_page: 2, per_page: 24, total: 2))),436+ .success(([second], Meta(current_page: 2, last_page: 2, per_page: 24, total: 2))),437+ ]438+ let state = makeState(search: search)439+ state.selectedResolution = "Any"440+ state.selectedRatio = "Any"441+442+ await state.fetchWallpapers()443+ #expect(state.canLoadMore)444+445+ await state.loadMore()446+ #expect(!state.canLoadMore)447+ #expect(state.wallpapers.map(\.id) == ["first", "second"])275448 }276449 }Tests/HavenTests/ManagerTests.swift+155 −0
@@ -80,6 +80,41 @@8080 8181 #expect(reloaded.favorites == [wallpaper])8282 }83+84+ /// Encoder that always fails. Subclassing JSONEncoder lets us keep the85+ /// real `UserDefaults` and validation paths and only flip the encode side.86+ final class ThrowingEncoder: JSONEncoder, @unchecked Sendable {87+ let testError = NSError(88+ domain: "test", code: 7,89+ userInfo: [NSLocalizedDescriptionKey: "encode boom"])90+ override func encode<T>(_ value: T) throws -> Data where T: Encodable {91+ throw testError92+ }93+ }94+95+ @Test("Encode failure surfaces through persistenceError without losing in-memory state")96+ func encodeFailureIsSurfaced() {97+ UserDefaults.standard.removeObject(forKey: defaultsKey)98+ defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }99+ let manager = FavoritesManager(encoder: ThrowingEncoder())100+101+ manager.toggleFavorite(makeWallpaper())102+103+ #expect(manager.favorites.count == 1)104+ #expect(manager.persistenceError?.contains("encode boom") == true)105+ }106+107+ @Test("Corrupt stored data surfaces through persistenceError on load")108+ func corruptDataIsSurfaced() {109+ UserDefaults.standard.set(Data("not wallpaper json".utf8), forKey: defaultsKey)110+ defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }111+112+ let manager = FavoritesManager()113+114+ #expect(manager.favorites.isEmpty)115+ #expect(manager.persistenceError?.contains("Failed to load favorites") == true)116+ }117+83118 }84119 85120 @Suite("History persistence", .serialized)@@ -128,6 +163,54 @@128163 reloaded.clearHistory()129164 #expect(HistoryManager().history.isEmpty)130165 }166+167+ final class ThrowingEncoder: JSONEncoder, @unchecked Sendable {168+ let testError = NSError(169+ domain: "test", code: 9,170+ userInfo: [NSLocalizedDescriptionKey: "history encode boom"])171+ override func encode<T>(_ value: T) throws -> Data where T: Encodable {172+ throw testError173+ }174+ }175+176+ @Test("Encode failure surfaces through persistenceError without losing in-memory state")177+ func encodeFailureIsSurfaced() {178+ UserDefaults.standard.removeObject(forKey: defaultsKey)179+ defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }180+ let manager = HistoryManager(encoder: ThrowingEncoder())181+182+ manager.addToHistory(makeWallpaper(id: "one"))183+ manager.addToHistory(makeWallpaper(id: "two"))184+185+ #expect(manager.history.count == 2)186+ #expect(manager.persistenceError?.contains("history encode boom") == true)187+ }188+189+ @Test("Corrupt stored data surfaces through persistenceError on load")190+ func corruptDataIsSurfaced() {191+ UserDefaults.standard.set(Data("not history json".utf8), forKey: defaultsKey)192+ defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }193+194+ let manager = HistoryManager()195+196+ #expect(manager.history.isEmpty)197+ #expect(manager.persistenceError?.contains("Failed to load history") == true)198+ }199+200+ @Test("clearHistory surfaces persistenceError when the encoder fails")201+ func clearHistoryEncoderFailureIsSurfaced() {202+ UserDefaults.standard.removeObject(forKey: defaultsKey)203+ defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }204+ // Seed history first via the regular encoder so we know there's an205+ // entry to clear.206+ HistoryManager().addToHistory(makeWallpaper(id: "seeded"))207+208+ let manager = HistoryManager(encoder: ThrowingEncoder())209+ manager.clearHistory()210+211+ #expect(manager.history.isEmpty)212+ #expect(manager.persistenceError?.contains("history encode boom") == true)213+ }131214 }132215 133216 @Suite("Image cache")@@ -492,6 +575,78 @@492575 493576 #expect(manager.errorMessage?.hasPrefix("Failed to scan folder") == true)494577 #expect(!manager.isLoading)578+ }579+580+ @Test("Concurrent scan requests are dispatched, not silently dropped")581+ func concurrentScansAreDispatched() async throws {582+ let defaults = UserDefaults.standard583+ let oldPath = defaults.string(forKey: "saveFolderPath")584+ defaults.removeObject(forKey: "saveFolderPath")585+ defer {586+ if let oldPath {587+ defaults.set(oldPath, forKey: "saveFolderPath")588+ } else {589+ defaults.removeObject(forKey: "saveFolderPath")590+ }591+ }592+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)593+ defer { try? FileManager.default.removeItem(at: root) }594+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)595+596+ let manager = LocalWallpapersManager()597+ // First explicit scan baseline on an empty folder.598+ manager.scanFolder(path: root.path)599+ await waitForScan(manager)600+ #expect(manager.wallpapers.isEmpty)601+602+ // Drop two files in succession. Each scan after a write must observe the603+ // new file rather than be silently dropped because the previous one is604+ // still in flight.605+ try onePixelPNG.write(to: root.appendingPathComponent("first.png"))606+ try onePixelPNG.write(to: root.appendingPathComponent("second.png"))607+ manager.scanFolder(path: root.path)608+ manager.scanFolder(path: root.path)609+ await waitForScan(manager)610+ #expect(manager.wallpapers.contains(where: { $0.filename == "first.png" }))611+ #expect(manager.wallpapers.contains(where: { $0.filename == "second.png" }))612+ #expect(!manager.isLoading)613+ #expect(manager.errorMessage == nil)614+ }615+616+ @Test("Reissuing a scan during an in-flight scan observes later state via generation")617+ func scanDuringFlightWinsForLatestGeneration() async throws {618+ let defaults = UserDefaults.standard619+ let oldPath = defaults.string(forKey: "saveFolderPath")620+ defaults.removeObject(forKey: "saveFolderPath")621+ defer {622+ if let oldPath {623+ defaults.set(oldPath, forKey: "saveFolderPath")624+ } else {625+ defaults.removeObject(forKey: "saveFolderPath")626+ }627+ }628+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)629+ defer { try? FileManager.default.removeItem(at: root) }630+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)631+ let manager = LocalWallpapersManager()632+633+ // Issue three scans in rapid succession against an empty folder. Under634+ // the old guard !isLoading logic, two of these would be silently635+ // dropped; under the generation strategy all three are dispatched and636+ // only the latest commits. The manager must still end up stable.637+ manager.scanFolder(path: root.path)638+ manager.scanFolder(path: root.path)639+ manager.scanFolder(path: root.path)640+ await waitForScan(manager)641+ #expect(!manager.isLoading)642+ #expect(manager.wallpapers.isEmpty)643+ #expect(manager.errorMessage == nil)644+645+ // Now write a file and trigger a new scan; the new scan must observe it.646+ try onePixelPNG.write(to: root.appendingPathComponent("added.png"))647+ manager.scanFolder(path: root.path)648+ await waitForScan(manager)649+ #expect(manager.wallpapers.contains(where: { $0.filename == "added.png" }))495650 }496651 497652 @Test("Monitoring configured folders observes filesystem changes and stops")Tests/HavenTests/ViewCoverageTests.swift+34 −0
@@ -235,4 +235,38 @@235235 blendingMode: .behindWindow236236 ))237237 }238+239+@Suite("Local wallpaper thumbnail downsampling", .serialized)240+@MainActor241+struct LocalWallpaperItemThumbnailTests {242+ private static let onePixelPNG = Data(243+ base64Encoded:244+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="245+ )!246+247+ @Test("makeThumbnail returns an NSImage and downsamples oversized PNGs")248+ func thumbnailDecodeAndDownsample() throws {249+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)250+ defer { try? FileManager.default.removeItem(at: root) }251+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)252+ let png = root.appendingPathComponent("image.png")253+ try Self.onePixelPNG.write(to: png)254+255+ let image = LocalWallpaperItem.makeThumbnail(url: png, maxPixel: 320)256+ #expect(image != nil)257+ // ImageIO preserves the original pixel dimensions for tiny sources, so258+ // we only assert that decoding returned something usable.259+ #expect(image?.size.width ?? 0 >= 1)260+ }261+262+ @Test("makeThumbnail returns nil for a missing file rather than trapping")263+ func missingFileReturnsNil() {264+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)265+ defer { try? FileManager.default.removeItem(at: root) }266+ try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)267+ let missing = root.appendingPathComponent("missing.png")268+269+ #expect(LocalWallpaperItem.makeThumbnail(url: missing, maxPixel: 320) == nil)270+ }271+}238272 }