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
8 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 −0
Sources/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 }

view file

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 }

view file

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 

view file

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)

view file

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 {

view file

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 }

view file

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")

view file

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 }

view file