ManagerTests.swift

37.7 KB · raw

1import AppKit2import Foundation3import Testing45@testable import Haven67private final class ManagerURLProtocol: URLProtocol, @unchecked Sendable {8  override class func canInit(with request: URLRequest) -> Bool { true }9  override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }10  override func startLoading() {11    let path = request.url!.path12    let statusCode = path.contains("fail-http") ? 500 : 20013    let data: Data14    if path.contains("image.png") {15      data = onePixelPNG16    } else if path.contains("invalid.png") {17      data = Data("not an image".utf8)18    } else {19      data = Data("wallpaper".utf8)20    }21    let response = HTTPURLResponse(22      url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!23    client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)24    client?.urlProtocol(self, didLoad: data)25    client?.urlProtocolDidFinishLoading(self)26  }27  override func stopLoading() {}28}2930private func managerSession() -> URLSession {31  let configuration = URLSessionConfiguration.ephemeral32  configuration.protocolClasses = [ManagerURLProtocol.self]33  return URLSession(configuration: configuration)34}3536/// Wraps `ManagerURLProtocol`'s canned responses with a per-request counter,37/// so cache tests can assert a repeat request never touched the network.38private final class CountingURLProtocol: URLProtocol, @unchecked Sendable {39  nonisolated(unsafe) static var onLoad: (() -> Void)?40  override class func canInit(with request: URLRequest) -> Bool { true }41  override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }42  override func startLoading() {43    Self.onLoad?()44    let response = HTTPURLResponse(45      url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!46    client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)47    client?.urlProtocol(self, didLoad: Data("wallpaper".utf8))48    client?.urlProtocolDidFinishLoading(self)49  }50  override func stopLoading() {}51}5253private func countingSession(onLoad: @escaping () -> Void) -> URLSession {54  CountingURLProtocol.onLoad = onLoad55  let configuration = URLSessionConfiguration.ephemeral56  configuration.protocolClasses = [CountingURLProtocol.self]57  return URLSession(configuration: configuration)58}5960private let onePixelPNG = Data(61  base64Encoded:62    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="63)!6465@MainActor66private final class DesktopImageStub: DesktopImageSetting {67  var receivedURLs: [URL] = []6869  func setDesktopImageURL(70    _ url: URL, for screen: NSScreen, options: [NSWorkspace.DesktopImageOptionKey: Any]71  ) throws {72    receivedURLs.append(url)73  }74}7576@Suite("Favorites persistence", .serialized)77struct FavoritesManagerTests {78  private let defaultsKey = "favoriteWallpapers"7980  @Test("Toggling adds and removes a favorite")81  func toggleFavorite() {82    UserDefaults.standard.removeObject(forKey: defaultsKey)83    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }84    let manager = FavoritesManager()85    let wallpaper = makeWallpaper()8687    manager.toggleFavorite(wallpaper)88    #expect(manager.favorites == [wallpaper])89    #expect(manager.isFavorite(wallpaper))9091    manager.toggleFavorite(wallpaper)92    #expect(manager.favorites.isEmpty)93    #expect(!manager.isFavorite(wallpaper))94  }9596  @Test("Favorites survive manager recreation")97  func favoritesPersist() {98    UserDefaults.standard.removeObject(forKey: defaultsKey)99    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }100    let wallpaper = makeWallpaper()101    FavoritesManager().toggleFavorite(wallpaper)102103    let reloaded = FavoritesManager()104105    #expect(reloaded.favorites == [wallpaper])106  }107108  /// Encoder that always fails. Subclassing JSONEncoder lets us keep the109  /// real `UserDefaults` and validation paths and only flip the encode side.110  final class ThrowingEncoder: JSONEncoder, @unchecked Sendable {111    let testError = NSError(112      domain: "test", code: 7,113      userInfo: [NSLocalizedDescriptionKey: "encode boom"])114    override func encode<T>(_ value: T) throws -> Data where T: Encodable {115      throw testError116    }117  }118119  @Test("Encode failure surfaces through persistenceError without losing in-memory state")120  func encodeFailureIsSurfaced() {121    UserDefaults.standard.removeObject(forKey: defaultsKey)122    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }123    let manager = FavoritesManager(encoder: ThrowingEncoder())124125    manager.toggleFavorite(makeWallpaper())126127    #expect(manager.favorites.count == 1)128    #expect(manager.persistenceError?.contains("encode boom") == true)129  }130131  @Test("Corrupt stored data surfaces through persistenceError on load")132  func corruptDataIsSurfaced() {133    UserDefaults.standard.set(Data("not wallpaper json".utf8), forKey: defaultsKey)134    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }135136    let manager = FavoritesManager()137138    #expect(manager.favorites.isEmpty)139    #expect(manager.persistenceError?.contains("Failed to load favorites") == true)140  }141142}143144@Suite("History persistence", .serialized)145struct HistoryManagerTests {146  private let defaultsKey = "wallpaperHistory"147148  @Test("History keeps newest items first and promotes duplicates")149  func orderingAndPromotion() {150    UserDefaults.standard.removeObject(forKey: defaultsKey)151    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }152    let manager = HistoryManager()153    let first = makeWallpaper(id: "first")154    let second = makeWallpaper(id: "second")155156    manager.addToHistory(first)157    manager.addToHistory(second)158    manager.addToHistory(first)159160    #expect(manager.history.map(\.id) == ["first", "second"])161  }162163  @Test("History is capped at fifty entries")164  func historyLimit() {165    UserDefaults.standard.removeObject(forKey: defaultsKey)166    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }167    let manager = HistoryManager()168169    for index in 0..<55 {170      manager.addToHistory(makeWallpaper(id: "item-\(index)"))171    }172173    #expect(manager.history.count == 50)174    #expect(manager.history.first?.id == "item-54")175    #expect(manager.history.last?.id == "item-5")176  }177178  @Test("History survives recreation and clear removes persisted data")179  func persistenceAndClear() {180    UserDefaults.standard.removeObject(forKey: defaultsKey)181    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }182    HistoryManager().addToHistory(makeWallpaper(id: "persisted"))183184    let reloaded = HistoryManager()185    #expect(reloaded.history.map(\.id) == ["persisted"])186187    reloaded.clearHistory()188    #expect(HistoryManager().history.isEmpty)189  }190191  final class ThrowingEncoder: JSONEncoder, @unchecked Sendable {192    let testError = NSError(193      domain: "test", code: 9,194      userInfo: [NSLocalizedDescriptionKey: "history encode boom"])195    override func encode<T>(_ value: T) throws -> Data where T: Encodable {196      throw testError197    }198  }199200  @Test("Encode failure surfaces through persistenceError without losing in-memory state")201  func encodeFailureIsSurfaced() {202    UserDefaults.standard.removeObject(forKey: defaultsKey)203    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }204    let manager = HistoryManager(encoder: ThrowingEncoder())205206    manager.addToHistory(makeWallpaper(id: "one"))207    manager.addToHistory(makeWallpaper(id: "two"))208209    #expect(manager.history.count == 2)210    #expect(manager.persistenceError?.contains("history encode boom") == true)211  }212213  @Test("Corrupt stored data surfaces through persistenceError on load")214  func corruptDataIsSurfaced() {215    UserDefaults.standard.set(Data("not history json".utf8), forKey: defaultsKey)216    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }217218    let manager = HistoryManager()219220    #expect(manager.history.isEmpty)221    #expect(manager.persistenceError?.contains("Failed to load history") == true)222  }223224  @Test("clearHistory surfaces persistenceError when the encoder fails")225  func clearHistoryEncoderFailureIsSurfaced() {226    UserDefaults.standard.removeObject(forKey: defaultsKey)227    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }228    // Seed history first via the regular encoder so we know there's an229    // entry to clear.230    HistoryManager().addToHistory(makeWallpaper(id: "seeded"))231232    let manager = HistoryManager(encoder: ThrowingEncoder())233    manager.clearHistory()234235    #expect(manager.history.isEmpty)236    #expect(manager.persistenceError?.contains("history encode boom") == true)237  }238}239240@Suite("Image cache")241struct ImageCacheTests {242  @Test("Inserted images are returned for the same URL")243  func insertAndRetrieve() {244    let url = URL(string: "https://example.com/\(UUID().uuidString).png")!245    let image = NSImage(size: NSSize(width: 10, height: 20))246247    ImageCache.shared.insertImage(image, for: url)248249    #expect(ImageCache.shared.image(for: url) === image)250  }251252  @Test("Different URLs do not collide")253  func keysDoNotCollide() {254    let base = UUID().uuidString255    let firstURL = URL(string: "https://example.com/\(base)-first.png")!256    let secondURL = URL(string: "https://example.com/\(base)-second.png")!257258    ImageCache.shared.insertImage(NSImage(size: NSSize(width: 1, height: 1)), for: firstURL)259260    #expect(ImageCache.shared.image(for: secondURL) == nil)261  }262263  @Test("Fetch decodes and caches successful image responses")264  func fetchAndCache() async throws {265    let cache = ImageCache(session: managerSession())266    let url = URL(string: "https://example.com/image.png")!267268    let first = try await cache.fetchImage(url: url)269    let second = try await cache.fetchImage(url: url)270271    #expect(first === second)272  }273274  @Test("Fetch rejects HTTP errors and invalid image bytes")275  func fetchFailures() async {276    let cache = ImageCache(session: managerSession())277    let httpError = URL(string: "https://example.com/fail-http.png")!278    let invalid = URL(string: "https://example.com/invalid.png")!279    await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: httpError) }280    await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: invalid) }281  }282283  @Test("Default cache limits are finite and configurable")284  func defaultAndInjectedLimits() {285    // Sanity: defaults are bounded so the cache cannot grow without limit.286    #expect(ImageCache.defaultCountLimit > 0)287    #expect(ImageCache.defaultTotalCostLimit > 0)288289    let tight = ImageCache(countLimit: 2, totalCostLimit: 1024)290    let first = URL(string: "https://example.com/\(UUID().uuidString).png")!291    let second = URL(string: "https://example.com/\(UUID().uuidString).png")!292    let third = URL(string: "https://example.com/\(UUID().uuidString).png")!293    tight.insertImage(NSImage(size: NSSize(width: 4, height: 4)), for: first)294    tight.insertImage(NSImage(size: NSSize(width: 4, height: 4)), for: second)295    tight.insertImage(NSImage(size: NSSize(width: 4, height: 4)), for: third)296297    // NSCache enforces the count limit deterministically enough to assert;298    // the third insertion must have evicted at least one of the earlier two.299    let survivors = [first, second, third].filter { tight.image(for: $0) != nil }300    #expect(survivors.count <= 2)301  }302}303304@Suite("Wallpaper file management", .serialized)305@MainActor306struct WallpaperManagerTests {307  @Test("Download validates HTTP status and writes response data")308  func download() async throws {309    let manager = WallpaperManager(session: managerSession())310    let url = URL(string: "https://example.com/\(UUID().uuidString).jpg")!311    let downloaded = try await manager.download(from: url)312    defer { try? FileManager.default.removeItem(at: downloaded) }313314    #expect(try Data(contentsOf: downloaded) == Data("wallpaper".utf8))315316    let errorURL = URL(string: "https://example.com/fail-http.jpg")!317    await #expect(throws: WallhavenError.self) { try await manager.download(from: errorURL) }318  }319320  @Test("Cached local file persists on disk and is reused without re-downloading")321  func cachedLocalFile() async throws {322    var downloadCount = 0323    let cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent(324      UUID().uuidString)325    defer { try? FileManager.default.removeItem(at: cacheDir) }326    let manager = WallpaperManager(327      session: countingSession { downloadCount += 1 },328      cacheDirectoryProvider: { cacheDir })329    let url = URL(string: "https://example.com/wallhaven-abc123.jpg")!330331    let first = try await manager.cachedLocalFile(for: url)332    #expect(downloadCount == 1)333    #expect(first.deletingLastPathComponent().path == cacheDir.path)334    #expect(try Data(contentsOf: first) == Data("wallpaper".utf8))335336    // Same URL again: served from disk, no second network hit, and the337    // cached file is never deleted out from under a caller (unlike a plain338    // temp download, which a caller may clean up right after use).339    let second = try await manager.cachedLocalFile(for: url)340    #expect(downloadCount == 1)341    #expect(second == first)342    #expect(FileManager.default.fileExists(atPath: first.path))343  }344345  @Test("Default cache directory lives under Library/Caches and is reused across instances")346  func defaultCacheDirectoryIsUnderLibraryCaches() async throws {347    var downloadCount = 0348    let url = URL(string: "https://example.com/wallhaven-\(UUID().uuidString).jpg")!349    let manager = WallpaperManager(session: countingSession { downloadCount += 1 })350    defer {351      if let cachesRoot = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)352        .first353      {354        let bundleID = Bundle.main.bundleIdentifier ?? "Haven"355        try? FileManager.default.removeItem(356          at: cachesRoot.appendingPathComponent(bundleID).appendingPathComponent(357            "WallpaperCache"358          ).appendingPathComponent(url.lastPathComponent))359      }360    }361362    let first = try await manager.cachedLocalFile(for: url)363    #expect(downloadCount == 1)364    #expect(try Data(contentsOf: first) == Data("wallpaper".utf8))365366    // A second manager built with the same default provider finds the same367    // file on disk instead of re-downloading.368    let anotherManager = WallpaperManager(session: countingSession { downloadCount += 1 })369    let second = try await anotherManager.cachedLocalFile(for: url)370    #expect(downloadCount == 1)371    #expect(second == first)372  }373374  @Test("A caching directory that is unavailable falls back to a plain temp download")375  func cacheDirectoryUnavailableFallsBackToPlainDownload() async throws {376    var downloadCount = 0377    let manager = WallpaperManager(378      session: countingSession { downloadCount += 1 }, cacheDirectoryProvider: { nil })379    let url = URL(string: "https://example.com/wallhaven-abc999.jpg")!380381    let first = try await manager.cachedLocalFile(for: url)382    defer { try? FileManager.default.removeItem(at: first) }383    #expect(downloadCount == 1)384385    // No persistent cache directory available: every call re-downloads.386    let second = try await manager.cachedLocalFile(for: url)387    defer { try? FileManager.default.removeItem(at: second) }388    #expect(downloadCount == 2)389  }390391  @Test("Desktop setter receives explicit and default screens")392  @MainActor393  func desktopSetter() throws {394    guard let screen = NSScreen.screens.first else { return }395    var received: [NSScreen] = []396    let manager = WallpaperManager(desktopSetter: { _, target in received.append(target) })397    let url = URL(fileURLWithPath: "/tmp/test-wallpaper.jpg")398399    try manager.setDesktopImage(from: url, screen: screen)400    try manager.setDesktopImage(from: url)401402    #expect(received.count == 2)403    #expect(received[0] === screen)404  }405406  @Test("Default desktop adapter forwards the image to its workspace")407  func defaultDesktopAdapter() throws {408    guard let screen = NSScreen.screens.first else { return }409    let workspace = DesktopImageStub()410    let manager = WallpaperManager(workspace: workspace)411    let url = URL(fileURLWithPath: "/tmp/test-wallpaper.jpg")412413    try manager.setDesktopImage(from: url, screen: screen)414415    #expect(workspace.receivedURLs == [url])416  }417418  @Test("Set wallpaper composes download and desktop update")419  @MainActor420  func setWallpaper() async throws {421    guard let screen = NSScreen.screens.first else { return }422    var receivedURL: URL?423    let manager = WallpaperManager(424      session: managerSession(), desktopSetter: { url, _ in receivedURL = url })425    let remote = URL(string: "https://example.com/\(UUID().uuidString).jpg")!426427    try await manager.setWallpaper(from: remote, screen: screen)428    defer { if let receivedURL { try? FileManager.default.removeItem(at: receivedURL) } }429430    // Temp file uses a UUID stem (to avoid collisions) but preserves the431    // original extension so downstream consumers can identify the format.432    #expect(receivedURL?.pathExtension == remote.pathExtension)433    #expect(receivedURL?.lastPathComponent.hasPrefix("haven-") == true)434    #expect(receivedURL?.lastPathComponent != remote.lastPathComponent)435    #expect(try Data(contentsOf: receivedURL!) == Data("wallpaper".utf8))436  }437438  @Test("Saving creates the destination folder and copies the file")439  func saveCreatesFolderAndCopies() throws {440    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)441    defer { try? FileManager.default.removeItem(at: root) }442    let source = root.appendingPathComponent("source/wallpaper.jpg")443    let destination = root.appendingPathComponent("nested/destination")444    try FileManager.default.createDirectory(445      at: source.deletingLastPathComponent(), withIntermediateDirectories: true)446    try Data("first".utf8).write(to: source)447448    try WallpaperManager.shared.saveToFolder(from: source, to: destination)449450    let copied = try Data(contentsOf: destination.appendingPathComponent("wallpaper.jpg"))451    #expect(copied == Data("first".utf8))452  }453454  @Test("Saving replaces an existing file with fresh data")455  func saveReplacesExistingFile() throws {456    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)457    defer { try? FileManager.default.removeItem(at: root) }458    let source = root.appendingPathComponent("source/wallpaper.jpg")459    let destination = root.appendingPathComponent("destination")460    try FileManager.default.createDirectory(461      at: source.deletingLastPathComponent(), withIntermediateDirectories: true)462    try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)463    try Data("new".utf8).write(to: source)464    try Data("old".utf8).write(to: destination.appendingPathComponent("wallpaper.jpg"))465466    try WallpaperManager.shared.saveToFolder(from: source, to: destination)467468    let copied = try Data(contentsOf: destination.appendingPathComponent("wallpaper.jpg"))469    #expect(copied == Data("new".utf8))470  }471472  @Test("Saving the same logical wallpaper leaves the existing file untouched")473  func saveSameContentIsIdempotent() throws {474    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)475    defer { try? FileManager.default.removeItem(at: root) }476    let source = root.appendingPathComponent("source/wallpaper.jpg")477    let destination = root.appendingPathComponent("destination")478    try FileManager.default.createDirectory(479      at: source.deletingLastPathComponent(), withIntermediateDirectories: true)480    try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)481    try Data("identical".utf8).write(to: source)482    let existingURL = destination.appendingPathComponent("wallpaper.jpg")483    try Data("identical".utf8).write(to: existingURL)484    let originalAttributes = try FileManager.default.attributesOfItem(atPath: existingURL.path)485486    try WallpaperManager.shared.saveToFolder(from: source, to: destination)487488    let updatedAttributes = try FileManager.default.attributesOfItem(atPath: existingURL.path)489    #expect(try Data(contentsOf: existingURL) == Data("identical".utf8))490    #expect(491      (updatedAttributes[.modificationDate] as? Date)492        == (originalAttributes[.modificationDate] as? Date)493    )494  }495496  @Test("Saving into the same path as the source does not destroy data")497  func saveSamePathAsSource() throws {498    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)499    defer { try? FileManager.default.removeItem(at: root) }500    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)501    let source = root.appendingPathComponent("wallpaper.jpg")502    try Data("payload".utf8).write(to: source)503504    try WallpaperManager.shared.saveToFolder(from: source, to: root)505506    #expect(try Data(contentsOf: source) == Data("payload".utf8))507  }508509  @Test("Distinct URLs sharing a lastPathComponent produce distinct temp files")510  func distinctDownloadsDoNotCollide() async throws {511    let manager = WallpaperManager(session: managerSession())512    let first = URL(string: "https://example.com/aaa/wallpaper.jpg")!513    let second = URL(string: "https://example.com/bbb/wallpaper.jpg")!514515    let firstFile = try await manager.download(from: first)516    let secondFile = try await manager.download(from: second)517    defer {518      try? FileManager.default.removeItem(at: firstFile)519      try? FileManager.default.removeItem(at: secondFile)520    }521522    #expect(firstFile.path != secondFile.path)523    #expect(firstFile.lastPathComponent != secondFile.lastPathComponent)524    #expect(try Data(contentsOf: firstFile) == Data("wallpaper".utf8))525    #expect(try Data(contentsOf: secondFile) == Data("wallpaper".utf8))526  }527528  @Test("Repeated downloads of the same URL produce independent temp files")529  func repeatedDownloadsAreIndependent() async throws {530    let manager = WallpaperManager(session: managerSession())531    let remote = URL(string: "https://example.com/\(UUID().uuidString).jpg")!532533    let first = try await manager.download(from: remote)534    let second = try await manager.download(from: remote)535    defer {536      try? FileManager.default.removeItem(at: first)537      try? FileManager.default.removeItem(at: second)538    }539540    #expect(first.path != second.path)541    #expect(try Data(contentsOf: first) == Data("wallpaper".utf8))542    #expect(try Data(contentsOf: second) == Data("wallpaper".utf8))543  }544545  @Test("makeTempURL preserves the source extension when present")546  func makeTempURLPreservesExtension() {547    let withExtension = URL(string: "https://example.com/foo/wallpaper.png")!548    let withoutExtension = URL(string: "https://example.com/foo/wallpaper")!549550    #expect(WallpaperManager.makeTempURL(for: withExtension).pathExtension == "png")551    #expect(WallpaperManager.makeTempURL(for: withoutExtension).pathExtension == "img")552    #expect(553      WallpaperManager.makeTempURL(for: withExtension).path554        != WallpaperManager.makeTempURL(for: withExtension).path)555  }556557  @Test("setDesktopImage without screens throws instead of force-unwrapping")558  func setDesktopImageWithoutScreensThrows() {559    let manager = WallpaperManager(560      desktopSetter: { _, _ in },561      mainScreen: { nil }, firstScreen: { nil })562    let url = URL(fileURLWithPath: "/tmp/no-screens.jpg")563564    #expect(throws: WallhavenError.self) {565      try manager.setDesktopImage(from: url)566    }567  }568569  @Test("setDesktopImage prefers main over first screen when both are available")570  func setDesktopImagePrefersMain() throws {571    let mainScreen = NSScreen.main ?? NSScreen.screens.first572    guard let mainScreen else { return }573    let otherScreen = mainScreen574    var captured: [NSScreen] = []575    let manager = WallpaperManager(576      desktopSetter: { _, target in captured.append(target) },577      mainScreen: { mainScreen }, firstScreen: { otherScreen })578    let url = URL(fileURLWithPath: "/tmp/main-preferred.jpg")579580    try manager.setDesktopImage(from: url)581582    #expect(captured == [mainScreen])583  }584585  @Test("setDesktopImage falls back to first screen when main is missing")586  func setDesktopImageFallsBackToFirst() throws {587    guard let first = NSScreen.screens.first else { return }588    var captured: [NSScreen] = []589    let manager = WallpaperManager(590      desktopSetter: { _, target in captured.append(target) },591      mainScreen: { nil }, firstScreen: { first })592    let url = URL(fileURLWithPath: "/tmp/fallback.jpg")593594    try manager.setDesktopImage(from: url)595596    #expect(captured == [first])597  }598599  @Test("setDesktopImage default firstScreen closure calls NSScreen.screens.first")600  func setDesktopImageDefaultFirstScreenClosure() throws {601    guard let first = NSScreen.screens.first else { return }602    var captured: [NSScreen] = []603    // No main screen + default firstScreen closure exercises the604    // { NSScreen.screens.first } default injected in the init signature.605    let manager = WallpaperManager(606      desktopSetter: { _, target in captured.append(target) },607      mainScreen: { nil })608    let url = URL(fileURLWithPath: "/tmp/default-first.jpg")609610    try manager.setDesktopImage(from: url)611612    #expect(captured == [first])613  }614}615616@Suite("Autostart persistence", .serialized)617@MainActor618struct AutostartManagerTests {619  @Test("Enable writes a launch agent and disable removes it")620  func enableAndDisable() throws {621    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)622    defer { try? FileManager.default.removeItem(at: root) }623    let manager = AutostartManager(624      libraryDirectory: root, executablePath: { "/Applications/Haven.app/Contents/MacOS/Haven" })625626    #expect(!manager.isEnabled)627    manager.isEnabled = true628    #expect(manager.isEnabled)629    manager.isEnabled = true630631    let plist = root.appendingPathComponent("LaunchAgents/com.june.Haven.plist")632    let contents =633      try PropertyListSerialization.propertyList(634        from: Data(contentsOf: plist), format: nil) as? [String: Any]635    #expect(contents?["Label"] as? String == "com.june.Haven")636    #expect(637      contents?["ProgramArguments"] as? [String]638        == ["/Applications/Haven.app/Contents/MacOS/Haven"])639    #expect(contents?["RunAtLoad"] as? Bool == true)640641    manager.isEnabled = false642    #expect(!manager.isEnabled)643    manager.isEnabled = false644  }645646  @Test("Missing executable leaves autostart disabled")647  func missingExecutable() {648    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)649    defer { try? FileManager.default.removeItem(at: root) }650    let manager = AutostartManager(libraryDirectory: root, executablePath: { nil })651652    manager.isEnabled = true653654    #expect(!manager.isEnabled)655  }656657  @Test("Default executable provider writes only inside the injected library")658  func defaultExecutableProvider() {659    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)660    defer { try? FileManager.default.removeItem(at: root) }661    let manager = AutostartManager(libraryDirectory: root)662    manager.isEnabled = true663    #expect(manager.isEnabled)664    manager.isEnabled = false665  }666667  @Test("Unwritable launch-agent roots do not enable autostart")668  func writeFailure() throws {669    let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)670    defer { try? FileManager.default.removeItem(at: file) }671    try Data("not a directory".utf8).write(to: file)672    let manager = AutostartManager(libraryDirectory: file, executablePath: { "/tmp/Haven" })673    manager.isEnabled = true674    #expect(!manager.isEnabled)675  }676677  @Test("Removal failures leave the launch agent enabled")678  func removalFailure() {679    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)680    defer { try? FileManager.default.removeItem(at: root) }681    let manager = AutostartManager(682      libraryDirectory: root, executablePath: { "/tmp/Haven" },683      removePlist: { _ in throw CocoaError(.fileWriteNoPermission) })684    manager.isEnabled = true685    manager.isEnabled = false686    #expect(manager.isEnabled)687  }688689  @Test("LaunchAgents directory-creation failures surface through lastError")690  func launchAgentsDirectoryFailureIsSurfaced() {691    let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)692    defer { try? FileManager.default.removeItem(at: file) }693    try? Data("not a directory".utf8).write(to: file)694    let manager = AutostartManager(695      libraryDirectory: file, executablePath: { "/tmp/Haven" })696697    // The directory-create error must be observable rather than swallowed.698    #expect(manager.lastError?.contains("LaunchAgents") == true)699  }700701  @Test("lastError is cleared by a successful enable/disable cycle")702  func lastErrorClearsOnSuccess() throws {703    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)704    defer { try? FileManager.default.removeItem(at: root) }705    let manager = AutostartManager(706      libraryDirectory: root, executablePath: { "/tmp/Haven" },707      writePlist: { _, _ in throw CocoaError(.fileWriteNoPermission) })708709    manager.isEnabled = true710    #expect(manager.lastError?.contains("Failed to enable autostart") == true)711712    // Swap in a working writer so the next cycle succeeds.713    let successManager = AutostartManager(714      libraryDirectory: root, executablePath: { "/tmp/Haven" })715    successManager.isEnabled = true716    successManager.isEnabled = false717    #expect(successManager.lastError == nil)718  }719}720721@Suite("Local wallpaper library", .serialized)722@MainActor723struct LocalWallpapersManagerTests {724  private func waitForScan(_ manager: LocalWallpapersManager) async {725    for _ in 0..<100 where manager.isLoading {726      try? await Task.sleep(nanoseconds: 10_000_000)727    }728  }729730  @Test("Scan filters files, reads metadata, and deletes wallpapers")731  func scanAndDelete() async throws {732    let defaults = UserDefaults.standard733    let oldPath = defaults.string(forKey: "saveFolderPath")734    defaults.removeObject(forKey: "saveFolderPath")735    defer {736      if let oldPath {737        defaults.set(oldPath, forKey: "saveFolderPath")738      } else {739        defaults.removeObject(forKey: "saveFolderPath")740      }741    }742    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)743    defer { try? FileManager.default.removeItem(at: root) }744    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)745    try onePixelPNG.write(to: root.appendingPathComponent("wallpaper.png"))746    try Data("broken".utf8).write(to: root.appendingPathComponent("broken.png"))747    try Data("ignored".utf8).write(to: root.appendingPathComponent("notes.txt"))748    let manager = LocalWallpapersManager()749750    manager.scanFolder(path: root.path)751    manager.scanFolder(path: root.path)752    await waitForScan(manager)753754    let wallpaper = try #require(manager.wallpapers.first)755    #expect(manager.wallpapers.count == 1)756    #expect(wallpaper.thumbnailURL == wallpaper.url)757    #expect(wallpaper.filename == "wallpaper.png")758    #expect(wallpaper.resolution == "1x1")759    #expect(LocalWallpapersManager.wallpaper(at: root.appendingPathComponent("broken.png")) == nil)760    #expect(LocalWallpapersManager.wallpaper(at: root.appendingPathComponent("missing.png")) == nil)761762    manager.deleteWallpaper(wallpaper)763    #expect(manager.wallpapers.isEmpty)764    manager.deleteWallpaper(wallpaper)765    #expect(manager.errorMessage?.hasPrefix("Failed to delete") == true)766  }767768  @Test("Invalid scan roots surface an error")769  func scanError() async throws {770    let defaults = UserDefaults.standard771    defaults.removeObject(forKey: "saveFolderPath")772    let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)773    defer { try? FileManager.default.removeItem(at: file) }774    try Data("file".utf8).write(to: file)775    let manager = LocalWallpapersManager()776777    manager.scanFolder(path: file.path)778    await waitForScan(manager)779780    #expect(manager.errorMessage?.hasPrefix("Failed to scan folder") == true)781    #expect(!manager.isLoading)782  }783784  @Test("Concurrent scan requests are dispatched, not silently dropped")785  func concurrentScansAreDispatched() async throws {786    let defaults = UserDefaults.standard787    let oldPath = defaults.string(forKey: "saveFolderPath")788    defaults.removeObject(forKey: "saveFolderPath")789    defer {790      if let oldPath {791        defaults.set(oldPath, forKey: "saveFolderPath")792      } else {793        defaults.removeObject(forKey: "saveFolderPath")794      }795    }796    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)797    defer { try? FileManager.default.removeItem(at: root) }798    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)799800    let manager = LocalWallpapersManager()801    // First explicit scan baseline on an empty folder.802    manager.scanFolder(path: root.path)803    await waitForScan(manager)804    #expect(manager.wallpapers.isEmpty)805806    // Drop two files in succession. Each scan after a write must observe the807    // new file rather than be silently dropped because the previous one is808    // still in flight.809    try onePixelPNG.write(to: root.appendingPathComponent("first.png"))810    try onePixelPNG.write(to: root.appendingPathComponent("second.png"))811    manager.scanFolder(path: root.path)812    manager.scanFolder(path: root.path)813    await waitForScan(manager)814    #expect(manager.wallpapers.contains(where: { $0.filename == "first.png" }))815    #expect(manager.wallpapers.contains(where: { $0.filename == "second.png" }))816    #expect(!manager.isLoading)817    #expect(manager.errorMessage == nil)818  }819820  @Test("Reissuing a scan during an in-flight scan observes later state via generation")821  func scanDuringFlightWinsForLatestGeneration() async throws {822    let defaults = UserDefaults.standard823    let oldPath = defaults.string(forKey: "saveFolderPath")824    defaults.removeObject(forKey: "saveFolderPath")825    defer {826      if let oldPath {827        defaults.set(oldPath, forKey: "saveFolderPath")828      } else {829        defaults.removeObject(forKey: "saveFolderPath")830      }831    }832    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)833    defer { try? FileManager.default.removeItem(at: root) }834    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)835    let manager = LocalWallpapersManager()836837    // Issue three scans in rapid succession against an empty folder. Under838    // the old guard !isLoading logic, two of these would be silently839    // dropped; under the generation strategy all three are dispatched and840    // only the latest commits. The manager must still end up stable.841    manager.scanFolder(path: root.path)842    manager.scanFolder(path: root.path)843    manager.scanFolder(path: root.path)844    await waitForScan(manager)845    #expect(!manager.isLoading)846    #expect(manager.wallpapers.isEmpty)847    #expect(manager.errorMessage == nil)848849    // Now write a file and trigger a new scan; the new scan must observe it.850    try onePixelPNG.write(to: root.appendingPathComponent("added.png"))851    manager.scanFolder(path: root.path)852    await waitForScan(manager)853    #expect(manager.wallpapers.contains(where: { $0.filename == "added.png" }))854  }855856  @Test("Monitoring configured folders observes filesystem changes and stops")857  func monitoring() async throws {858    let defaults = UserDefaults.standard859    let oldPath = defaults.string(forKey: "saveFolderPath")860    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)861    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)862    defaults.set(root.path, forKey: "saveFolderPath")863    defer {864      try? FileManager.default.removeItem(at: root)865      if let oldPath {866        defaults.set(oldPath, forKey: "saveFolderPath")867      } else {868        defaults.removeObject(forKey: "saveFolderPath")869      }870    }871    let manager = LocalWallpapersManager()872    await waitForScan(manager)873874    try onePixelPNG.write(to: root.appendingPathComponent("observed.png"))875    try? await Task.sleep(nanoseconds: 100_000_000)876    await waitForScan(manager)877    #expect(manager.wallpapers.contains(where: { $0.filename == "observed.png" }))878879    manager.stopMonitoring()880    try? await Task.sleep(nanoseconds: 500_000_000)881882    let invalidMonitor = FolderMonitor(folderPath: root.appendingPathComponent("missing").path) {}883    #expect(invalidMonitor.start() == false)884    #expect(invalidMonitor.openFailureMessage?.isEmpty == false)885    invalidMonitor.closeFileDescriptor()886887    // An injectable `openPath` returning -1 simulates open() failure so the888    // error is reproducible in CI. The message must reach any caller that889    // asks for it so the UI can react instead of silently dropping the watch.890    let broken = FolderMonitor(891      folderPath: root.path, callback: {},892      openPath: { _, _ in -1 })893    #expect(broken.start() == false)894    #expect(broken.openFailureMessage?.localizedCaseInsensitiveContains("could not watch") == true)895896    let descriptor = open("/dev/null", O_RDONLY)897    let descriptorMonitor = FolderMonitor(898      folderPath: "/dev/null", fileDescriptor: descriptor, callback: {})899    descriptorMonitor.closeFileDescriptor()900    #expect(fcntl(descriptor, F_GETFD) == -1)901  }902903  @Test("startMonitoring surfaces folder-monitor open failures through errorMessage")904  func startMonitoringSurfacesMonitorFailures() async {905    // A regular file in place of the folder makes open(path, O_EVTONLY) fail906    // with ENOTDIR. The manager must capture that failure through907    // errorMessage synchronously so the UI sees it before any subsequent908    // scan clobbers the message.909    let defaults = UserDefaults.standard910    let oldPath = defaults.string(forKey: "saveFolderPath")911    defaults.removeObject(forKey: "saveFolderPath")912    defer {913      if let oldPath {914        defaults.set(oldPath, forKey: "saveFolderPath")915      } else {916        defaults.removeObject(forKey: "saveFolderPath")917      }918    }919    // A path that does not exist forces open(path, O_EVTONLY) to fail with920    // ENOENT, which is the signal we want to surface through errorMessage.921    let missing = FileManager.default.temporaryDirectory922      .appendingPathComponent("haven-missing-\(UUID().uuidString)").path923    let manager = LocalWallpapersManager()924    manager.startMonitoring(folderPath: missing)925    #expect(manager.errorMessage?.isEmpty == false)926    manager.stopMonitoring()927  }928}