ManagerTests.swift

16.3 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@Suite("Favorites persistence", .serialized)42struct FavoritesManagerTests {43  private let defaultsKey = "favoriteWallpapers"4445  @Test("Toggling adds and removes a favorite")46  func toggleFavorite() {47    UserDefaults.standard.removeObject(forKey: defaultsKey)48    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }49    let manager = FavoritesManager()50    let wallpaper = makeWallpaper()5152    manager.toggleFavorite(wallpaper)53    #expect(manager.favorites == [wallpaper])54    #expect(manager.isFavorite(wallpaper))5556    manager.toggleFavorite(wallpaper)57    #expect(manager.favorites.isEmpty)58    #expect(!manager.isFavorite(wallpaper))59  }6061  @Test("Favorites survive manager recreation")62  func favoritesPersist() {63    UserDefaults.standard.removeObject(forKey: defaultsKey)64    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }65    let wallpaper = makeWallpaper()66    FavoritesManager().toggleFavorite(wallpaper)6768    let reloaded = FavoritesManager()6970    #expect(reloaded.favorites == [wallpaper])71  }72}7374@Suite("History persistence", .serialized)75struct HistoryManagerTests {76  private let defaultsKey = "wallpaperHistory"7778  @Test("History keeps newest items first and promotes duplicates")79  func orderingAndPromotion() {80    UserDefaults.standard.removeObject(forKey: defaultsKey)81    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }82    let manager = HistoryManager()83    let first = makeWallpaper(id: "first")84    let second = makeWallpaper(id: "second")8586    manager.addToHistory(first)87    manager.addToHistory(second)88    manager.addToHistory(first)8990    #expect(manager.history.map(\.id) == ["first", "second"])91  }9293  @Test("History is capped at fifty entries")94  func historyLimit() {95    UserDefaults.standard.removeObject(forKey: defaultsKey)96    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }97    let manager = HistoryManager()9899    for index in 0..<55 {100      manager.addToHistory(makeWallpaper(id: "item-\(index)"))101    }102103    #expect(manager.history.count == 50)104    #expect(manager.history.first?.id == "item-54")105    #expect(manager.history.last?.id == "item-5")106  }107108  @Test("History survives recreation and clear removes persisted data")109  func persistenceAndClear() {110    UserDefaults.standard.removeObject(forKey: defaultsKey)111    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }112    HistoryManager().addToHistory(makeWallpaper(id: "persisted"))113114    let reloaded = HistoryManager()115    #expect(reloaded.history.map(\.id) == ["persisted"])116117    reloaded.clearHistory()118    #expect(HistoryManager().history.isEmpty)119  }120}121122@Suite("Image cache")123struct ImageCacheTests {124  @Test("Inserted images are returned for the same URL")125  func insertAndRetrieve() {126    let url = URL(string: "https://example.com/\(UUID().uuidString).png")!127    let image = NSImage(size: NSSize(width: 10, height: 20))128129    ImageCache.shared.insertImage(image, for: url)130131    #expect(ImageCache.shared.image(for: url) === image)132  }133134  @Test("Different URLs do not collide")135  func keysDoNotCollide() {136    let base = UUID().uuidString137    let firstURL = URL(string: "https://example.com/\(base)-first.png")!138    let secondURL = URL(string: "https://example.com/\(base)-second.png")!139140    ImageCache.shared.insertImage(NSImage(size: NSSize(width: 1, height: 1)), for: firstURL)141142    #expect(ImageCache.shared.image(for: secondURL) == nil)143  }144145  @Test("Fetch decodes and caches successful image responses")146  func fetchAndCache() async throws {147    let cache = ImageCache(session: managerSession())148    let url = URL(string: "https://example.com/image.png")!149150    let first = try await cache.fetchImage(url: url)151    let second = try await cache.fetchImage(url: url)152153    #expect(first === second)154  }155156  @Test("Fetch rejects HTTP errors and invalid image bytes")157  func fetchFailures() async {158    let cache = ImageCache(session: managerSession())159    let httpError = URL(string: "https://example.com/fail-http.png")!160    let invalid = URL(string: "https://example.com/invalid.png")!161    await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: httpError) }162    await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: invalid) }163  }164}165166@Suite("Wallpaper file management", .serialized)167@MainActor168struct WallpaperManagerTests {169  @Test("Download validates HTTP status and writes response data")170  func download() async throws {171    let manager = WallpaperManager(session: managerSession())172    let url = URL(string: "https://example.com/\(UUID().uuidString).jpg")!173    let downloaded = try await manager.download(from: url)174    defer { try? FileManager.default.removeItem(at: downloaded) }175176    #expect(try Data(contentsOf: downloaded) == Data("wallpaper".utf8))177178    let errorURL = URL(string: "https://example.com/fail-http.jpg")!179    await #expect(throws: WallhavenError.self) { try await manager.download(from: errorURL) }180  }181182  @Test("Desktop setter receives explicit and default screens")183  @MainActor184  func desktopSetter() throws {185    guard let screen = NSScreen.screens.first else { return }186    var received: [NSScreen] = []187    let manager = WallpaperManager(desktopSetter: { _, target in received.append(target) })188    let url = URL(fileURLWithPath: "/tmp/test-wallpaper.jpg")189190    try manager.setDesktopImage(from: url, screen: screen)191    try manager.setDesktopImage(from: url)192193    #expect(received.count == 2)194    #expect(received[0] === screen)195  }196197  @Test("Default desktop adapter reports invalid local files")198  func defaultDesktopAdapter() {199    guard let screen = NSScreen.screens.first else { return }200    let manager = WallpaperManager()201    #expect(throws: Error.self) {202      try manager.setDesktopImage(203        from: URL(fileURLWithPath: "/definitely-missing-haven-wallpaper.jpg"), screen: screen)204    }205  }206207  @Test("Set wallpaper composes download and desktop update")208  @MainActor209  func setWallpaper() async throws {210    guard let screen = NSScreen.screens.first else { return }211    var receivedURL: URL?212    let manager = WallpaperManager(213      session: managerSession(), desktopSetter: { url, _ in receivedURL = url })214    let remote = URL(string: "https://example.com/\(UUID().uuidString).jpg")!215216    try await manager.setWallpaper(from: remote, screen: screen)217    defer { if let receivedURL { try? FileManager.default.removeItem(at: receivedURL) } }218219    #expect(receivedURL?.lastPathComponent == remote.lastPathComponent)220  }221222  @Test("Saving creates the destination folder and copies the file")223  func saveCreatesFolderAndCopies() throws {224    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)225    defer { try? FileManager.default.removeItem(at: root) }226    let source = root.appendingPathComponent("source/wallpaper.jpg")227    let destination = root.appendingPathComponent("nested/destination")228    try FileManager.default.createDirectory(229      at: source.deletingLastPathComponent(), withIntermediateDirectories: true)230    try Data("first".utf8).write(to: source)231232    try WallpaperManager.shared.saveToFolder(from: source, to: destination)233234    let copied = try Data(contentsOf: destination.appendingPathComponent("wallpaper.jpg"))235    #expect(copied == Data("first".utf8))236  }237238  @Test("Saving replaces an existing file with fresh data")239  func saveReplacesExistingFile() throws {240    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)241    defer { try? FileManager.default.removeItem(at: root) }242    let source = root.appendingPathComponent("source/wallpaper.jpg")243    let destination = root.appendingPathComponent("destination")244    try FileManager.default.createDirectory(245      at: source.deletingLastPathComponent(), withIntermediateDirectories: true)246    try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)247    try Data("new".utf8).write(to: source)248    try Data("old".utf8).write(to: destination.appendingPathComponent("wallpaper.jpg"))249250    try WallpaperManager.shared.saveToFolder(from: source, to: destination)251252    let copied = try Data(contentsOf: destination.appendingPathComponent("wallpaper.jpg"))253    #expect(copied == Data("new".utf8))254  }255}256257@Suite("Autostart persistence", .serialized)258@MainActor259struct AutostartManagerTests {260  @Test("Enable writes a launch agent and disable removes it")261  func enableAndDisable() throws {262    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)263    defer { try? FileManager.default.removeItem(at: root) }264    let manager = AutostartManager(265      libraryDirectory: root, executablePath: { "/Applications/Haven.app/Contents/MacOS/Haven" })266267    #expect(!manager.isEnabled)268    manager.isEnabled = true269    #expect(manager.isEnabled)270    manager.isEnabled = true271272    let plist = root.appendingPathComponent("LaunchAgents/com.june.Haven.plist")273    let contents =274      try PropertyListSerialization.propertyList(275        from: Data(contentsOf: plist), format: nil) as? [String: Any]276    #expect(contents?["Label"] as? String == "com.june.Haven")277    #expect(278      contents?["ProgramArguments"] as? [String]279        == ["/Applications/Haven.app/Contents/MacOS/Haven"])280    #expect(contents?["RunAtLoad"] as? Bool == true)281282    manager.isEnabled = false283    #expect(!manager.isEnabled)284    manager.isEnabled = false285  }286287  @Test("Missing executable leaves autostart disabled")288  func missingExecutable() {289    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)290    defer { try? FileManager.default.removeItem(at: root) }291    let manager = AutostartManager(libraryDirectory: root, executablePath: { nil })292293    manager.isEnabled = true294295    #expect(!manager.isEnabled)296  }297298  @Test("Default executable provider writes only inside the injected library")299  func defaultExecutableProvider() {300    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)301    defer { try? FileManager.default.removeItem(at: root) }302    let manager = AutostartManager(libraryDirectory: root)303    manager.isEnabled = true304    #expect(manager.isEnabled)305    manager.isEnabled = false306  }307308  @Test("Unwritable launch-agent roots do not enable autostart")309  func writeFailure() throws {310    let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)311    defer { try? FileManager.default.removeItem(at: file) }312    try Data("not a directory".utf8).write(to: file)313    let manager = AutostartManager(libraryDirectory: file, executablePath: { "/tmp/Haven" })314    manager.isEnabled = true315    #expect(!manager.isEnabled)316  }317318  @Test("Removal failures leave the launch agent enabled")319  func removalFailure() {320    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)321    defer { try? FileManager.default.removeItem(at: root) }322    let manager = AutostartManager(323      libraryDirectory: root, executablePath: { "/tmp/Haven" },324      removePlist: { _ in throw CocoaError(.fileWriteNoPermission) })325    manager.isEnabled = true326    manager.isEnabled = false327    #expect(manager.isEnabled)328  }329}330331@Suite("Local wallpaper library", .serialized)332@MainActor333struct LocalWallpapersManagerTests {334  private func waitForScan(_ manager: LocalWallpapersManager) async {335    for _ in 0..<100 where manager.isLoading {336      try? await Task.sleep(nanoseconds: 10_000_000)337    }338  }339340  @Test("Scan filters files, reads metadata, and deletes wallpapers")341  func scanAndDelete() async throws {342    let defaults = UserDefaults.standard343    let oldPath = defaults.string(forKey: "saveFolderPath")344    defaults.removeObject(forKey: "saveFolderPath")345    defer {346      if let oldPath {347        defaults.set(oldPath, forKey: "saveFolderPath")348      } else {349        defaults.removeObject(forKey: "saveFolderPath")350      }351    }352    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)353    defer { try? FileManager.default.removeItem(at: root) }354    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)355    try onePixelPNG.write(to: root.appendingPathComponent("wallpaper.png"))356    try Data("broken".utf8).write(to: root.appendingPathComponent("broken.png"))357    try Data("ignored".utf8).write(to: root.appendingPathComponent("notes.txt"))358    let manager = LocalWallpapersManager()359360    manager.scanFolder(path: root.path)361    manager.scanFolder(path: root.path)362    await waitForScan(manager)363364    let wallpaper = try #require(manager.wallpapers.first)365    #expect(manager.wallpapers.count == 1)366    #expect(wallpaper.thumbnailURL == wallpaper.url)367    #expect(wallpaper.filename == "wallpaper.png")368    #expect(wallpaper.resolution == "1x1")369    #expect(LocalWallpapersManager.wallpaper(at: root.appendingPathComponent("broken.png")) == nil)370    #expect(LocalWallpapersManager.wallpaper(at: root.appendingPathComponent("missing.png")) == nil)371372    manager.deleteWallpaper(wallpaper)373    #expect(manager.wallpapers.isEmpty)374    manager.deleteWallpaper(wallpaper)375    #expect(manager.errorMessage?.hasPrefix("Failed to delete") == true)376  }377378  @Test("Invalid scan roots surface an error")379  func scanError() async throws {380    let defaults = UserDefaults.standard381    defaults.removeObject(forKey: "saveFolderPath")382    let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)383    defer { try? FileManager.default.removeItem(at: file) }384    try Data("file".utf8).write(to: file)385    let manager = LocalWallpapersManager()386387    manager.scanFolder(path: file.path)388    await waitForScan(manager)389390    #expect(manager.errorMessage?.hasPrefix("Failed to scan folder") == true)391    #expect(!manager.isLoading)392  }393394  @Test("Monitoring configured folders observes filesystem changes and stops")395  func monitoring() async throws {396    let defaults = UserDefaults.standard397    let oldPath = defaults.string(forKey: "saveFolderPath")398    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)399    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)400    defaults.set(root.path, forKey: "saveFolderPath")401    defer {402      try? FileManager.default.removeItem(at: root)403      if let oldPath {404        defaults.set(oldPath, forKey: "saveFolderPath")405      } else {406        defaults.removeObject(forKey: "saveFolderPath")407      }408    }409    let manager = LocalWallpapersManager()410    await waitForScan(manager)411412    try onePixelPNG.write(to: root.appendingPathComponent("observed.png"))413    try? await Task.sleep(nanoseconds: 100_000_000)414    await waitForScan(manager)415    #expect(manager.wallpapers.contains(where: { $0.filename == "observed.png" }))416417    manager.stopMonitoring()418    try? await Task.sleep(nanoseconds: 500_000_000)419420    let invalidMonitor = FolderMonitor(folderPath: root.appendingPathComponent("missing").path) {}421    invalidMonitor.start()422    invalidMonitor.closeFileDescriptor()423424    let descriptor = open("/dev/null", O_RDONLY)425    let descriptorMonitor = FolderMonitor(426      folderPath: "/dev/null", fileDescriptor: descriptor, callback: {})427    descriptorMonitor.closeFileDescriptor()428    #expect(fcntl(descriptor, F_GETFD) == -1)429  }430}