ManagerTests.swift

33.5 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}3536private let onePixelPNG = Data(37  base64Encoded:38    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="39)!4041@MainActor42private final class DesktopImageStub: DesktopImageSetting {43  var receivedURLs: [URL] = []4445  func setDesktopImageURL(46    _ url: URL, for screen: NSScreen, options: [NSWorkspace.DesktopImageOptionKey: Any]47  ) throws {48    receivedURLs.append(url)49  }50}5152@Suite("Favorites persistence", .serialized)53struct FavoritesManagerTests {54  private let defaultsKey = "favoriteWallpapers"5556  @Test("Toggling adds and removes a favorite")57  func toggleFavorite() {58    UserDefaults.standard.removeObject(forKey: defaultsKey)59    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }60    let manager = FavoritesManager()61    let wallpaper = makeWallpaper()6263    manager.toggleFavorite(wallpaper)64    #expect(manager.favorites == [wallpaper])65    #expect(manager.isFavorite(wallpaper))6667    manager.toggleFavorite(wallpaper)68    #expect(manager.favorites.isEmpty)69    #expect(!manager.isFavorite(wallpaper))70  }7172  @Test("Favorites survive manager recreation")73  func favoritesPersist() {74    UserDefaults.standard.removeObject(forKey: defaultsKey)75    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }76    let wallpaper = makeWallpaper()77    FavoritesManager().toggleFavorite(wallpaper)7879    let reloaded = FavoritesManager()8081    #expect(reloaded.favorites == [wallpaper])82  }8384  /// 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  }9495  @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())100101    manager.toggleFavorite(makeWallpaper())102103    #expect(manager.favorites.count == 1)104    #expect(manager.persistenceError?.contains("encode boom") == true)105  }106107  @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) }111112    let manager = FavoritesManager()113114    #expect(manager.favorites.isEmpty)115    #expect(manager.persistenceError?.contains("Failed to load favorites") == true)116  }117118}119120@Suite("History persistence", .serialized)121struct HistoryManagerTests {122  private let defaultsKey = "wallpaperHistory"123124  @Test("History keeps newest items first and promotes duplicates")125  func orderingAndPromotion() {126    UserDefaults.standard.removeObject(forKey: defaultsKey)127    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }128    let manager = HistoryManager()129    let first = makeWallpaper(id: "first")130    let second = makeWallpaper(id: "second")131132    manager.addToHistory(first)133    manager.addToHistory(second)134    manager.addToHistory(first)135136    #expect(manager.history.map(\.id) == ["first", "second"])137  }138139  @Test("History is capped at fifty entries")140  func historyLimit() {141    UserDefaults.standard.removeObject(forKey: defaultsKey)142    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }143    let manager = HistoryManager()144145    for index in 0..<55 {146      manager.addToHistory(makeWallpaper(id: "item-\(index)"))147    }148149    #expect(manager.history.count == 50)150    #expect(manager.history.first?.id == "item-54")151    #expect(manager.history.last?.id == "item-5")152  }153154  @Test("History survives recreation and clear removes persisted data")155  func persistenceAndClear() {156    UserDefaults.standard.removeObject(forKey: defaultsKey)157    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }158    HistoryManager().addToHistory(makeWallpaper(id: "persisted"))159160    let reloaded = HistoryManager()161    #expect(reloaded.history.map(\.id) == ["persisted"])162163    reloaded.clearHistory()164    #expect(HistoryManager().history.isEmpty)165  }166167  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  }175176  @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())181182    manager.addToHistory(makeWallpaper(id: "one"))183    manager.addToHistory(makeWallpaper(id: "two"))184185    #expect(manager.history.count == 2)186    #expect(manager.persistenceError?.contains("history encode boom") == true)187  }188189  @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) }193194    let manager = HistoryManager()195196    #expect(manager.history.isEmpty)197    #expect(manager.persistenceError?.contains("Failed to load history") == true)198  }199200  @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"))207208    let manager = HistoryManager(encoder: ThrowingEncoder())209    manager.clearHistory()210211    #expect(manager.history.isEmpty)212    #expect(manager.persistenceError?.contains("history encode boom") == true)213  }214}215216@Suite("Image cache")217struct ImageCacheTests {218  @Test("Inserted images are returned for the same URL")219  func insertAndRetrieve() {220    let url = URL(string: "https://example.com/\(UUID().uuidString).png")!221    let image = NSImage(size: NSSize(width: 10, height: 20))222223    ImageCache.shared.insertImage(image, for: url)224225    #expect(ImageCache.shared.image(for: url) === image)226  }227228  @Test("Different URLs do not collide")229  func keysDoNotCollide() {230    let base = UUID().uuidString231    let firstURL = URL(string: "https://example.com/\(base)-first.png")!232    let secondURL = URL(string: "https://example.com/\(base)-second.png")!233234    ImageCache.shared.insertImage(NSImage(size: NSSize(width: 1, height: 1)), for: firstURL)235236    #expect(ImageCache.shared.image(for: secondURL) == nil)237  }238239  @Test("Fetch decodes and caches successful image responses")240  func fetchAndCache() async throws {241    let cache = ImageCache(session: managerSession())242    let url = URL(string: "https://example.com/image.png")!243244    let first = try await cache.fetchImage(url: url)245    let second = try await cache.fetchImage(url: url)246247    #expect(first === second)248  }249250  @Test("Fetch rejects HTTP errors and invalid image bytes")251  func fetchFailures() async {252    let cache = ImageCache(session: managerSession())253    let httpError = URL(string: "https://example.com/fail-http.png")!254    let invalid = URL(string: "https://example.com/invalid.png")!255    await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: httpError) }256    await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: invalid) }257  }258259  @Test("Default cache limits are finite and configurable")260  func defaultAndInjectedLimits() {261    // Sanity: defaults are bounded so the cache cannot grow without limit.262    #expect(ImageCache.defaultCountLimit > 0)263    #expect(ImageCache.defaultTotalCostLimit > 0)264265    let tight = ImageCache(countLimit: 2, totalCostLimit: 1024)266    let first = URL(string: "https://example.com/\(UUID().uuidString).png")!267    let second = URL(string: "https://example.com/\(UUID().uuidString).png")!268    let third = URL(string: "https://example.com/\(UUID().uuidString).png")!269    tight.insertImage(NSImage(size: NSSize(width: 4, height: 4)), for: first)270    tight.insertImage(NSImage(size: NSSize(width: 4, height: 4)), for: second)271    tight.insertImage(NSImage(size: NSSize(width: 4, height: 4)), for: third)272273    // NSCache enforces the count limit deterministically enough to assert;274    // the third insertion must have evicted at least one of the earlier two.275    let survivors = [first, second, third].filter { tight.image(for: $0) != nil }276    #expect(survivors.count <= 2)277  }278}279280@Suite("Wallpaper file management", .serialized)281@MainActor282struct WallpaperManagerTests {283  @Test("Download validates HTTP status and writes response data")284  func download() async throws {285    let manager = WallpaperManager(session: managerSession())286    let url = URL(string: "https://example.com/\(UUID().uuidString).jpg")!287    let downloaded = try await manager.download(from: url)288    defer { try? FileManager.default.removeItem(at: downloaded) }289290    #expect(try Data(contentsOf: downloaded) == Data("wallpaper".utf8))291292    let errorURL = URL(string: "https://example.com/fail-http.jpg")!293    await #expect(throws: WallhavenError.self) { try await manager.download(from: errorURL) }294  }295296  @Test("Desktop setter receives explicit and default screens")297  @MainActor298  func desktopSetter() throws {299    guard let screen = NSScreen.screens.first else { return }300    var received: [NSScreen] = []301    let manager = WallpaperManager(desktopSetter: { _, target in received.append(target) })302    let url = URL(fileURLWithPath: "/tmp/test-wallpaper.jpg")303304    try manager.setDesktopImage(from: url, screen: screen)305    try manager.setDesktopImage(from: url)306307    #expect(received.count == 2)308    #expect(received[0] === screen)309  }310311  @Test("Default desktop adapter forwards the image to its workspace")312  func defaultDesktopAdapter() throws {313    guard let screen = NSScreen.screens.first else { return }314    let workspace = DesktopImageStub()315    let manager = WallpaperManager(workspace: workspace)316    let url = URL(fileURLWithPath: "/tmp/test-wallpaper.jpg")317318    try manager.setDesktopImage(from: url, screen: screen)319320    #expect(workspace.receivedURLs == [url])321  }322323  @Test("Set wallpaper composes download and desktop update")324  @MainActor325  func setWallpaper() async throws {326    guard let screen = NSScreen.screens.first else { return }327    var receivedURL: URL?328    let manager = WallpaperManager(329      session: managerSession(), desktopSetter: { url, _ in receivedURL = url })330    let remote = URL(string: "https://example.com/\(UUID().uuidString).jpg")!331332    try await manager.setWallpaper(from: remote, screen: screen)333    defer { if let receivedURL { try? FileManager.default.removeItem(at: receivedURL) } }334335    // Temp file uses a UUID stem (to avoid collisions) but preserves the336    // original extension so downstream consumers can identify the format.337    #expect(receivedURL?.pathExtension == remote.pathExtension)338    #expect(receivedURL?.lastPathComponent.hasPrefix("haven-") == true)339    #expect(receivedURL?.lastPathComponent != remote.lastPathComponent)340    #expect(try Data(contentsOf: receivedURL!) == Data("wallpaper".utf8))341  }342343  @Test("Saving creates the destination folder and copies the file")344  func saveCreatesFolderAndCopies() throws {345    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)346    defer { try? FileManager.default.removeItem(at: root) }347    let source = root.appendingPathComponent("source/wallpaper.jpg")348    let destination = root.appendingPathComponent("nested/destination")349    try FileManager.default.createDirectory(350      at: source.deletingLastPathComponent(), withIntermediateDirectories: true)351    try Data("first".utf8).write(to: source)352353    try WallpaperManager.shared.saveToFolder(from: source, to: destination)354355    let copied = try Data(contentsOf: destination.appendingPathComponent("wallpaper.jpg"))356    #expect(copied == Data("first".utf8))357  }358359  @Test("Saving replaces an existing file with fresh data")360  func saveReplacesExistingFile() throws {361    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)362    defer { try? FileManager.default.removeItem(at: root) }363    let source = root.appendingPathComponent("source/wallpaper.jpg")364    let destination = root.appendingPathComponent("destination")365    try FileManager.default.createDirectory(366      at: source.deletingLastPathComponent(), withIntermediateDirectories: true)367    try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)368    try Data("new".utf8).write(to: source)369    try Data("old".utf8).write(to: destination.appendingPathComponent("wallpaper.jpg"))370371    try WallpaperManager.shared.saveToFolder(from: source, to: destination)372373    let copied = try Data(contentsOf: destination.appendingPathComponent("wallpaper.jpg"))374    #expect(copied == Data("new".utf8))375  }376377  @Test("Saving the same logical wallpaper leaves the existing file untouched")378  func saveSameContentIsIdempotent() throws {379    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)380    defer { try? FileManager.default.removeItem(at: root) }381    let source = root.appendingPathComponent("source/wallpaper.jpg")382    let destination = root.appendingPathComponent("destination")383    try FileManager.default.createDirectory(384      at: source.deletingLastPathComponent(), withIntermediateDirectories: true)385    try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)386    try Data("identical".utf8).write(to: source)387    let existingURL = destination.appendingPathComponent("wallpaper.jpg")388    try Data("identical".utf8).write(to: existingURL)389    let originalAttributes = try FileManager.default.attributesOfItem(atPath: existingURL.path)390391    try WallpaperManager.shared.saveToFolder(from: source, to: destination)392393    let updatedAttributes = try FileManager.default.attributesOfItem(atPath: existingURL.path)394    #expect(try Data(contentsOf: existingURL) == Data("identical".utf8))395    #expect(396      (updatedAttributes[.modificationDate] as? Date)397        == (originalAttributes[.modificationDate] as? Date)398    )399  }400401  @Test("Saving into the same path as the source does not destroy data")402  func saveSamePathAsSource() throws {403    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)404    defer { try? FileManager.default.removeItem(at: root) }405    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)406    let source = root.appendingPathComponent("wallpaper.jpg")407    try Data("payload".utf8).write(to: source)408409    try WallpaperManager.shared.saveToFolder(from: source, to: root)410411    #expect(try Data(contentsOf: source) == Data("payload".utf8))412  }413414  @Test("Distinct URLs sharing a lastPathComponent produce distinct temp files")415  func distinctDownloadsDoNotCollide() async throws {416    let manager = WallpaperManager(session: managerSession())417    let first = URL(string: "https://example.com/aaa/wallpaper.jpg")!418    let second = URL(string: "https://example.com/bbb/wallpaper.jpg")!419420    let firstFile = try await manager.download(from: first)421    let secondFile = try await manager.download(from: second)422    defer {423      try? FileManager.default.removeItem(at: firstFile)424      try? FileManager.default.removeItem(at: secondFile)425    }426427    #expect(firstFile.path != secondFile.path)428    #expect(firstFile.lastPathComponent != secondFile.lastPathComponent)429    #expect(try Data(contentsOf: firstFile) == Data("wallpaper".utf8))430    #expect(try Data(contentsOf: secondFile) == Data("wallpaper".utf8))431  }432433  @Test("Repeated downloads of the same URL produce independent temp files")434  func repeatedDownloadsAreIndependent() async throws {435    let manager = WallpaperManager(session: managerSession())436    let remote = URL(string: "https://example.com/\(UUID().uuidString).jpg")!437438    let first = try await manager.download(from: remote)439    let second = try await manager.download(from: remote)440    defer {441      try? FileManager.default.removeItem(at: first)442      try? FileManager.default.removeItem(at: second)443    }444445    #expect(first.path != second.path)446    #expect(try Data(contentsOf: first) == Data("wallpaper".utf8))447    #expect(try Data(contentsOf: second) == Data("wallpaper".utf8))448  }449450  @Test("makeTempURL preserves the source extension when present")451  func makeTempURLPreservesExtension() {452    let withExtension = URL(string: "https://example.com/foo/wallpaper.png")!453    let withoutExtension = URL(string: "https://example.com/foo/wallpaper")!454455    #expect(WallpaperManager.makeTempURL(for: withExtension).pathExtension == "png")456    #expect(WallpaperManager.makeTempURL(for: withoutExtension).pathExtension == "img")457    #expect(458      WallpaperManager.makeTempURL(for: withExtension).path459        != WallpaperManager.makeTempURL(for: withExtension).path)460  }461462  @Test("setDesktopImage without screens throws instead of force-unwrapping")463  func setDesktopImageWithoutScreensThrows() {464    let manager = WallpaperManager(465      desktopSetter: { _, _ in },466      mainScreen: { nil }, firstScreen: { nil })467    let url = URL(fileURLWithPath: "/tmp/no-screens.jpg")468469    #expect(throws: WallhavenError.self) {470      try manager.setDesktopImage(from: url)471    }472  }473474  @Test("setDesktopImage prefers main over first screen when both are available")475  func setDesktopImagePrefersMain() throws {476    let mainScreen = NSScreen.main ?? NSScreen.screens.first477    guard let mainScreen else { return }478    let otherScreen = mainScreen479    var captured: [NSScreen] = []480    let manager = WallpaperManager(481      desktopSetter: { _, target in captured.append(target) },482      mainScreen: { mainScreen }, firstScreen: { otherScreen })483    let url = URL(fileURLWithPath: "/tmp/main-preferred.jpg")484485    try manager.setDesktopImage(from: url)486487    #expect(captured == [mainScreen])488  }489490  @Test("setDesktopImage falls back to first screen when main is missing")491  func setDesktopImageFallsBackToFirst() throws {492    guard let first = NSScreen.screens.first else { return }493    var captured: [NSScreen] = []494    let manager = WallpaperManager(495      desktopSetter: { _, target in captured.append(target) },496      mainScreen: { nil }, firstScreen: { first })497    let url = URL(fileURLWithPath: "/tmp/fallback.jpg")498499    try manager.setDesktopImage(from: url)500501    #expect(captured == [first])502  }503504  @Test("setDesktopImage default firstScreen closure calls NSScreen.screens.first")505  func setDesktopImageDefaultFirstScreenClosure() throws {506    guard let first = NSScreen.screens.first else { return }507    var captured: [NSScreen] = []508    // No main screen + default firstScreen closure exercises the509    // { NSScreen.screens.first } default injected in the init signature.510    let manager = WallpaperManager(511      desktopSetter: { _, target in captured.append(target) },512      mainScreen: { nil })513    let url = URL(fileURLWithPath: "/tmp/default-first.jpg")514515    try manager.setDesktopImage(from: url)516517    #expect(captured == [first])518  }519}520521@Suite("Autostart persistence", .serialized)522@MainActor523struct AutostartManagerTests {524  @Test("Enable writes a launch agent and disable removes it")525  func enableAndDisable() throws {526    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)527    defer { try? FileManager.default.removeItem(at: root) }528    let manager = AutostartManager(529      libraryDirectory: root, executablePath: { "/Applications/Haven.app/Contents/MacOS/Haven" })530531    #expect(!manager.isEnabled)532    manager.isEnabled = true533    #expect(manager.isEnabled)534    manager.isEnabled = true535536    let plist = root.appendingPathComponent("LaunchAgents/com.june.Haven.plist")537    let contents =538      try PropertyListSerialization.propertyList(539        from: Data(contentsOf: plist), format: nil) as? [String: Any]540    #expect(contents?["Label"] as? String == "com.june.Haven")541    #expect(542      contents?["ProgramArguments"] as? [String]543        == ["/Applications/Haven.app/Contents/MacOS/Haven"])544    #expect(contents?["RunAtLoad"] as? Bool == true)545546    manager.isEnabled = false547    #expect(!manager.isEnabled)548    manager.isEnabled = false549  }550551  @Test("Missing executable leaves autostart disabled")552  func missingExecutable() {553    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)554    defer { try? FileManager.default.removeItem(at: root) }555    let manager = AutostartManager(libraryDirectory: root, executablePath: { nil })556557    manager.isEnabled = true558559    #expect(!manager.isEnabled)560  }561562  @Test("Default executable provider writes only inside the injected library")563  func defaultExecutableProvider() {564    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)565    defer { try? FileManager.default.removeItem(at: root) }566    let manager = AutostartManager(libraryDirectory: root)567    manager.isEnabled = true568    #expect(manager.isEnabled)569    manager.isEnabled = false570  }571572  @Test("Unwritable launch-agent roots do not enable autostart")573  func writeFailure() throws {574    let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)575    defer { try? FileManager.default.removeItem(at: file) }576    try Data("not a directory".utf8).write(to: file)577    let manager = AutostartManager(libraryDirectory: file, executablePath: { "/tmp/Haven" })578    manager.isEnabled = true579    #expect(!manager.isEnabled)580  }581582  @Test("Removal failures leave the launch agent enabled")583  func removalFailure() {584    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)585    defer { try? FileManager.default.removeItem(at: root) }586    let manager = AutostartManager(587      libraryDirectory: root, executablePath: { "/tmp/Haven" },588      removePlist: { _ in throw CocoaError(.fileWriteNoPermission) })589    manager.isEnabled = true590    manager.isEnabled = false591    #expect(manager.isEnabled)592  }593594  @Test("LaunchAgents directory-creation failures surface through lastError")595  func launchAgentsDirectoryFailureIsSurfaced() {596    let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)597    defer { try? FileManager.default.removeItem(at: file) }598    try? Data("not a directory".utf8).write(to: file)599    let manager = AutostartManager(600      libraryDirectory: file, executablePath: { "/tmp/Haven" })601602    // The directory-create error must be observable rather than swallowed.603    #expect(manager.lastError?.contains("LaunchAgents") == true)604  }605606  @Test("lastError is cleared by a successful enable/disable cycle")607  func lastErrorClearsOnSuccess() throws {608    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)609    defer { try? FileManager.default.removeItem(at: root) }610    let manager = AutostartManager(611      libraryDirectory: root, executablePath: { "/tmp/Haven" },612      writePlist: { _, _ in throw CocoaError(.fileWriteNoPermission) })613614    manager.isEnabled = true615    #expect(manager.lastError?.contains("Failed to enable autostart") == true)616617    // Swap in a working writer so the next cycle succeeds.618    let successManager = AutostartManager(619      libraryDirectory: root, executablePath: { "/tmp/Haven" })620    successManager.isEnabled = true621    successManager.isEnabled = false622    #expect(successManager.lastError == nil)623  }624}625626@Suite("Local wallpaper library", .serialized)627@MainActor628struct LocalWallpapersManagerTests {629  private func waitForScan(_ manager: LocalWallpapersManager) async {630    for _ in 0..<100 where manager.isLoading {631      try? await Task.sleep(nanoseconds: 10_000_000)632    }633  }634635  @Test("Scan filters files, reads metadata, and deletes wallpapers")636  func scanAndDelete() async throws {637    let defaults = UserDefaults.standard638    let oldPath = defaults.string(forKey: "saveFolderPath")639    defaults.removeObject(forKey: "saveFolderPath")640    defer {641      if let oldPath {642        defaults.set(oldPath, forKey: "saveFolderPath")643      } else {644        defaults.removeObject(forKey: "saveFolderPath")645      }646    }647    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)648    defer { try? FileManager.default.removeItem(at: root) }649    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)650    try onePixelPNG.write(to: root.appendingPathComponent("wallpaper.png"))651    try Data("broken".utf8).write(to: root.appendingPathComponent("broken.png"))652    try Data("ignored".utf8).write(to: root.appendingPathComponent("notes.txt"))653    let manager = LocalWallpapersManager()654655    manager.scanFolder(path: root.path)656    manager.scanFolder(path: root.path)657    await waitForScan(manager)658659    let wallpaper = try #require(manager.wallpapers.first)660    #expect(manager.wallpapers.count == 1)661    #expect(wallpaper.thumbnailURL == wallpaper.url)662    #expect(wallpaper.filename == "wallpaper.png")663    #expect(wallpaper.resolution == "1x1")664    #expect(LocalWallpapersManager.wallpaper(at: root.appendingPathComponent("broken.png")) == nil)665    #expect(LocalWallpapersManager.wallpaper(at: root.appendingPathComponent("missing.png")) == nil)666667    manager.deleteWallpaper(wallpaper)668    #expect(manager.wallpapers.isEmpty)669    manager.deleteWallpaper(wallpaper)670    #expect(manager.errorMessage?.hasPrefix("Failed to delete") == true)671  }672673  @Test("Invalid scan roots surface an error")674  func scanError() async throws {675    let defaults = UserDefaults.standard676    defaults.removeObject(forKey: "saveFolderPath")677    let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)678    defer { try? FileManager.default.removeItem(at: file) }679    try Data("file".utf8).write(to: file)680    let manager = LocalWallpapersManager()681682    manager.scanFolder(path: file.path)683    await waitForScan(manager)684685    #expect(manager.errorMessage?.hasPrefix("Failed to scan folder") == true)686    #expect(!manager.isLoading)687  }688689  @Test("Concurrent scan requests are dispatched, not silently dropped")690  func concurrentScansAreDispatched() async throws {691    let defaults = UserDefaults.standard692    let oldPath = defaults.string(forKey: "saveFolderPath")693    defaults.removeObject(forKey: "saveFolderPath")694    defer {695      if let oldPath {696        defaults.set(oldPath, forKey: "saveFolderPath")697      } else {698        defaults.removeObject(forKey: "saveFolderPath")699      }700    }701    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)702    defer { try? FileManager.default.removeItem(at: root) }703    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)704705    let manager = LocalWallpapersManager()706    // First explicit scan baseline on an empty folder.707    manager.scanFolder(path: root.path)708    await waitForScan(manager)709    #expect(manager.wallpapers.isEmpty)710711    // Drop two files in succession. Each scan after a write must observe the712    // new file rather than be silently dropped because the previous one is713    // still in flight.714    try onePixelPNG.write(to: root.appendingPathComponent("first.png"))715    try onePixelPNG.write(to: root.appendingPathComponent("second.png"))716    manager.scanFolder(path: root.path)717    manager.scanFolder(path: root.path)718    await waitForScan(manager)719    #expect(manager.wallpapers.contains(where: { $0.filename == "first.png" }))720    #expect(manager.wallpapers.contains(where: { $0.filename == "second.png" }))721    #expect(!manager.isLoading)722    #expect(manager.errorMessage == nil)723  }724725  @Test("Reissuing a scan during an in-flight scan observes later state via generation")726  func scanDuringFlightWinsForLatestGeneration() async throws {727    let defaults = UserDefaults.standard728    let oldPath = defaults.string(forKey: "saveFolderPath")729    defaults.removeObject(forKey: "saveFolderPath")730    defer {731      if let oldPath {732        defaults.set(oldPath, forKey: "saveFolderPath")733      } else {734        defaults.removeObject(forKey: "saveFolderPath")735      }736    }737    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)738    defer { try? FileManager.default.removeItem(at: root) }739    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)740    let manager = LocalWallpapersManager()741742    // Issue three scans in rapid succession against an empty folder. Under743    // the old guard !isLoading logic, two of these would be silently744    // dropped; under the generation strategy all three are dispatched and745    // only the latest commits. The manager must still end up stable.746    manager.scanFolder(path: root.path)747    manager.scanFolder(path: root.path)748    manager.scanFolder(path: root.path)749    await waitForScan(manager)750    #expect(!manager.isLoading)751    #expect(manager.wallpapers.isEmpty)752    #expect(manager.errorMessage == nil)753754    // Now write a file and trigger a new scan; the new scan must observe it.755    try onePixelPNG.write(to: root.appendingPathComponent("added.png"))756    manager.scanFolder(path: root.path)757    await waitForScan(manager)758    #expect(manager.wallpapers.contains(where: { $0.filename == "added.png" }))759  }760761  @Test("Monitoring configured folders observes filesystem changes and stops")762  func monitoring() async throws {763    let defaults = UserDefaults.standard764    let oldPath = defaults.string(forKey: "saveFolderPath")765    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)766    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)767    defaults.set(root.path, forKey: "saveFolderPath")768    defer {769      try? FileManager.default.removeItem(at: root)770      if let oldPath {771        defaults.set(oldPath, forKey: "saveFolderPath")772      } else {773        defaults.removeObject(forKey: "saveFolderPath")774      }775    }776    let manager = LocalWallpapersManager()777    await waitForScan(manager)778779    try onePixelPNG.write(to: root.appendingPathComponent("observed.png"))780    try? await Task.sleep(nanoseconds: 100_000_000)781    await waitForScan(manager)782    #expect(manager.wallpapers.contains(where: { $0.filename == "observed.png" }))783784    manager.stopMonitoring()785    try? await Task.sleep(nanoseconds: 500_000_000)786787    let invalidMonitor = FolderMonitor(folderPath: root.appendingPathComponent("missing").path) {}788    #expect(invalidMonitor.start() == false)789    #expect(invalidMonitor.openFailureMessage?.isEmpty == false)790    invalidMonitor.closeFileDescriptor()791792    // An injectable `openPath` returning -1 simulates open() failure so the793    // error is reproducible in CI. The message must reach any caller that794    // asks for it so the UI can react instead of silently dropping the watch.795    let broken = FolderMonitor(796      folderPath: root.path, callback: {},797      openPath: { _, _ in -1 })798    #expect(broken.start() == false)799    #expect(broken.openFailureMessage?.localizedCaseInsensitiveContains("could not watch") == true)800801    let descriptor = open("/dev/null", O_RDONLY)802    let descriptorMonitor = FolderMonitor(803      folderPath: "/dev/null", fileDescriptor: descriptor, callback: {})804    descriptorMonitor.closeFileDescriptor()805    #expect(fcntl(descriptor, F_GETFD) == -1)806  }807808  @Test("startMonitoring surfaces folder-monitor open failures through errorMessage")809  func startMonitoringSurfacesMonitorFailures() async {810    // A regular file in place of the folder makes open(path, O_EVTONLY) fail811    // with ENOTDIR. The manager must capture that failure through812    // errorMessage synchronously so the UI sees it before any subsequent813    // scan clobbers the message.814    let defaults = UserDefaults.standard815    let oldPath = defaults.string(forKey: "saveFolderPath")816    defaults.removeObject(forKey: "saveFolderPath")817    defer {818      if let oldPath {819        defaults.set(oldPath, forKey: "saveFolderPath")820      } else {821        defaults.removeObject(forKey: "saveFolderPath")822      }823    }824    // A path that does not exist forces open(path, O_EVTONLY) to fail with825    // ENOENT, which is the signal we want to surface through errorMessage.826    let missing = FileManager.default.temporaryDirectory827      .appendingPathComponent("haven-missing-\(UUID().uuidString)").path828    let manager = LocalWallpapersManager()829    manager.startMonitoring(folderPath: missing)830    #expect(manager.errorMessage?.isEmpty == false)831    manager.stopMonitoring()832  }833}