ManagerTests.swift

20.9 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  }83}8485@Suite("History persistence", .serialized)86struct HistoryManagerTests {87  private let defaultsKey = "wallpaperHistory"8889  @Test("History keeps newest items first and promotes duplicates")90  func orderingAndPromotion() {91    UserDefaults.standard.removeObject(forKey: defaultsKey)92    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }93    let manager = HistoryManager()94    let first = makeWallpaper(id: "first")95    let second = makeWallpaper(id: "second")9697    manager.addToHistory(first)98    manager.addToHistory(second)99    manager.addToHistory(first)100101    #expect(manager.history.map(\.id) == ["first", "second"])102  }103104  @Test("History is capped at fifty entries")105  func historyLimit() {106    UserDefaults.standard.removeObject(forKey: defaultsKey)107    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }108    let manager = HistoryManager()109110    for index in 0..<55 {111      manager.addToHistory(makeWallpaper(id: "item-\(index)"))112    }113114    #expect(manager.history.count == 50)115    #expect(manager.history.first?.id == "item-54")116    #expect(manager.history.last?.id == "item-5")117  }118119  @Test("History survives recreation and clear removes persisted data")120  func persistenceAndClear() {121    UserDefaults.standard.removeObject(forKey: defaultsKey)122    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }123    HistoryManager().addToHistory(makeWallpaper(id: "persisted"))124125    let reloaded = HistoryManager()126    #expect(reloaded.history.map(\.id) == ["persisted"])127128    reloaded.clearHistory()129    #expect(HistoryManager().history.isEmpty)130  }131}132133@Suite("Image cache")134struct ImageCacheTests {135  @Test("Inserted images are returned for the same URL")136  func insertAndRetrieve() {137    let url = URL(string: "https://example.com/\(UUID().uuidString).png")!138    let image = NSImage(size: NSSize(width: 10, height: 20))139140    ImageCache.shared.insertImage(image, for: url)141142    #expect(ImageCache.shared.image(for: url) === image)143  }144145  @Test("Different URLs do not collide")146  func keysDoNotCollide() {147    let base = UUID().uuidString148    let firstURL = URL(string: "https://example.com/\(base)-first.png")!149    let secondURL = URL(string: "https://example.com/\(base)-second.png")!150151    ImageCache.shared.insertImage(NSImage(size: NSSize(width: 1, height: 1)), for: firstURL)152153    #expect(ImageCache.shared.image(for: secondURL) == nil)154  }155156  @Test("Fetch decodes and caches successful image responses")157  func fetchAndCache() async throws {158    let cache = ImageCache(session: managerSession())159    let url = URL(string: "https://example.com/image.png")!160161    let first = try await cache.fetchImage(url: url)162    let second = try await cache.fetchImage(url: url)163164    #expect(first === second)165  }166167  @Test("Fetch rejects HTTP errors and invalid image bytes")168  func fetchFailures() async {169    let cache = ImageCache(session: managerSession())170    let httpError = URL(string: "https://example.com/fail-http.png")!171    let invalid = URL(string: "https://example.com/invalid.png")!172    await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: httpError) }173    await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: invalid) }174  }175}176177@Suite("Wallpaper file management", .serialized)178@MainActor179struct WallpaperManagerTests {180  @Test("Download validates HTTP status and writes response data")181  func download() async throws {182    let manager = WallpaperManager(session: managerSession())183    let url = URL(string: "https://example.com/\(UUID().uuidString).jpg")!184    let downloaded = try await manager.download(from: url)185    defer { try? FileManager.default.removeItem(at: downloaded) }186187    #expect(try Data(contentsOf: downloaded) == Data("wallpaper".utf8))188189    let errorURL = URL(string: "https://example.com/fail-http.jpg")!190    await #expect(throws: WallhavenError.self) { try await manager.download(from: errorURL) }191  }192193  @Test("Desktop setter receives explicit and default screens")194  @MainActor195  func desktopSetter() throws {196    guard let screen = NSScreen.screens.first else { return }197    var received: [NSScreen] = []198    let manager = WallpaperManager(desktopSetter: { _, target in received.append(target) })199    let url = URL(fileURLWithPath: "/tmp/test-wallpaper.jpg")200201    try manager.setDesktopImage(from: url, screen: screen)202    try manager.setDesktopImage(from: url)203204    #expect(received.count == 2)205    #expect(received[0] === screen)206  }207208  @Test("Default desktop adapter forwards the image to its workspace")209  func defaultDesktopAdapter() throws {210    guard let screen = NSScreen.screens.first else { return }211    let workspace = DesktopImageStub()212    let manager = WallpaperManager(workspace: workspace)213    let url = URL(fileURLWithPath: "/tmp/test-wallpaper.jpg")214215    try manager.setDesktopImage(from: url, screen: screen)216217    #expect(workspace.receivedURLs == [url])218  }219220  @Test("Set wallpaper composes download and desktop update")221  @MainActor222  func setWallpaper() async throws {223    guard let screen = NSScreen.screens.first else { return }224    var receivedURL: URL?225    let manager = WallpaperManager(226      session: managerSession(), desktopSetter: { url, _ in receivedURL = url })227    let remote = URL(string: "https://example.com/\(UUID().uuidString).jpg")!228229    try await manager.setWallpaper(from: remote, screen: screen)230    defer { if let receivedURL { try? FileManager.default.removeItem(at: receivedURL) } }231232    // Temp file uses a UUID stem (to avoid collisions) but preserves the233    // original extension so downstream consumers can identify the format.234    #expect(receivedURL?.pathExtension == remote.pathExtension)235    #expect(receivedURL?.lastPathComponent.hasPrefix("haven-") == true)236    #expect(receivedURL?.lastPathComponent != remote.lastPathComponent)237    #expect(try Data(contentsOf: receivedURL!) == Data("wallpaper".utf8))238  }239240  @Test("Saving creates the destination folder and copies the file")241  func saveCreatesFolderAndCopies() throws {242    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)243    defer { try? FileManager.default.removeItem(at: root) }244    let source = root.appendingPathComponent("source/wallpaper.jpg")245    let destination = root.appendingPathComponent("nested/destination")246    try FileManager.default.createDirectory(247      at: source.deletingLastPathComponent(), withIntermediateDirectories: true)248    try Data("first".utf8).write(to: source)249250    try WallpaperManager.shared.saveToFolder(from: source, to: destination)251252    let copied = try Data(contentsOf: destination.appendingPathComponent("wallpaper.jpg"))253    #expect(copied == Data("first".utf8))254  }255256  @Test("Saving replaces an existing file with fresh data")257  func saveReplacesExistingFile() throws {258    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)259    defer { try? FileManager.default.removeItem(at: root) }260    let source = root.appendingPathComponent("source/wallpaper.jpg")261    let destination = root.appendingPathComponent("destination")262    try FileManager.default.createDirectory(263      at: source.deletingLastPathComponent(), withIntermediateDirectories: true)264    try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)265    try Data("new".utf8).write(to: source)266    try Data("old".utf8).write(to: destination.appendingPathComponent("wallpaper.jpg"))267268    try WallpaperManager.shared.saveToFolder(from: source, to: destination)269270    let copied = try Data(contentsOf: destination.appendingPathComponent("wallpaper.jpg"))271    #expect(copied == Data("new".utf8))272  }273274  @Test("Saving the same logical wallpaper leaves the existing file untouched")275  func saveSameContentIsIdempotent() throws {276    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)277    defer { try? FileManager.default.removeItem(at: root) }278    let source = root.appendingPathComponent("source/wallpaper.jpg")279    let destination = root.appendingPathComponent("destination")280    try FileManager.default.createDirectory(281      at: source.deletingLastPathComponent(), withIntermediateDirectories: true)282    try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)283    try Data("identical".utf8).write(to: source)284    let existingURL = destination.appendingPathComponent("wallpaper.jpg")285    try Data("identical".utf8).write(to: existingURL)286    let originalAttributes = try FileManager.default.attributesOfItem(atPath: existingURL.path)287288    try WallpaperManager.shared.saveToFolder(from: source, to: destination)289290    let updatedAttributes = try FileManager.default.attributesOfItem(atPath: existingURL.path)291    #expect(try Data(contentsOf: existingURL) == Data("identical".utf8))292    #expect(293      (updatedAttributes[.modificationDate] as? Date)294        == (originalAttributes[.modificationDate] as? Date)295    )296  }297298  @Test("Saving into the same path as the source does not destroy data")299  func saveSamePathAsSource() throws {300    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)301    defer { try? FileManager.default.removeItem(at: root) }302    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)303    let source = root.appendingPathComponent("wallpaper.jpg")304    try Data("payload".utf8).write(to: source)305306    try WallpaperManager.shared.saveToFolder(from: source, to: root)307308    #expect(try Data(contentsOf: source) == Data("payload".utf8))309  }310311  @Test("Distinct URLs sharing a lastPathComponent produce distinct temp files")312  func distinctDownloadsDoNotCollide() async throws {313    let manager = WallpaperManager(session: managerSession())314    let first = URL(string: "https://example.com/aaa/wallpaper.jpg")!315    let second = URL(string: "https://example.com/bbb/wallpaper.jpg")!316317    let firstFile = try await manager.download(from: first)318    let secondFile = try await manager.download(from: second)319    defer {320      try? FileManager.default.removeItem(at: firstFile)321      try? FileManager.default.removeItem(at: secondFile)322    }323324    #expect(firstFile.path != secondFile.path)325    #expect(firstFile.lastPathComponent != secondFile.lastPathComponent)326    #expect(try Data(contentsOf: firstFile) == Data("wallpaper".utf8))327    #expect(try Data(contentsOf: secondFile) == Data("wallpaper".utf8))328  }329330  @Test("Repeated downloads of the same URL produce independent temp files")331  func repeatedDownloadsAreIndependent() async throws {332    let manager = WallpaperManager(session: managerSession())333    let remote = URL(string: "https://example.com/\(UUID().uuidString).jpg")!334335    let first = try await manager.download(from: remote)336    let second = try await manager.download(from: remote)337    defer {338      try? FileManager.default.removeItem(at: first)339      try? FileManager.default.removeItem(at: second)340    }341342    #expect(first.path != second.path)343    #expect(try Data(contentsOf: first) == Data("wallpaper".utf8))344    #expect(try Data(contentsOf: second) == Data("wallpaper".utf8))345  }346347  @Test("makeTempURL preserves the source extension when present")348  func makeTempURLPreservesExtension() {349    let withExtension = URL(string: "https://example.com/foo/wallpaper.png")!350    let withoutExtension = URL(string: "https://example.com/foo/wallpaper")!351352    #expect(WallpaperManager.makeTempURL(for: withExtension).pathExtension == "png")353    #expect(WallpaperManager.makeTempURL(for: withoutExtension).pathExtension == "img")354    #expect(355      WallpaperManager.makeTempURL(for: withExtension).path356        != WallpaperManager.makeTempURL(for: withExtension).path)357  }358}359360@Suite("Autostart persistence", .serialized)361@MainActor362struct AutostartManagerTests {363  @Test("Enable writes a launch agent and disable removes it")364  func enableAndDisable() throws {365    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)366    defer { try? FileManager.default.removeItem(at: root) }367    let manager = AutostartManager(368      libraryDirectory: root, executablePath: { "/Applications/Haven.app/Contents/MacOS/Haven" })369370    #expect(!manager.isEnabled)371    manager.isEnabled = true372    #expect(manager.isEnabled)373    manager.isEnabled = true374375    let plist = root.appendingPathComponent("LaunchAgents/com.june.Haven.plist")376    let contents =377      try PropertyListSerialization.propertyList(378        from: Data(contentsOf: plist), format: nil) as? [String: Any]379    #expect(contents?["Label"] as? String == "com.june.Haven")380    #expect(381      contents?["ProgramArguments"] as? [String]382        == ["/Applications/Haven.app/Contents/MacOS/Haven"])383    #expect(contents?["RunAtLoad"] as? Bool == true)384385    manager.isEnabled = false386    #expect(!manager.isEnabled)387    manager.isEnabled = false388  }389390  @Test("Missing executable leaves autostart disabled")391  func missingExecutable() {392    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)393    defer { try? FileManager.default.removeItem(at: root) }394    let manager = AutostartManager(libraryDirectory: root, executablePath: { nil })395396    manager.isEnabled = true397398    #expect(!manager.isEnabled)399  }400401  @Test("Default executable provider writes only inside the injected library")402  func defaultExecutableProvider() {403    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)404    defer { try? FileManager.default.removeItem(at: root) }405    let manager = AutostartManager(libraryDirectory: root)406    manager.isEnabled = true407    #expect(manager.isEnabled)408    manager.isEnabled = false409  }410411  @Test("Unwritable launch-agent roots do not enable autostart")412  func writeFailure() throws {413    let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)414    defer { try? FileManager.default.removeItem(at: file) }415    try Data("not a directory".utf8).write(to: file)416    let manager = AutostartManager(libraryDirectory: file, executablePath: { "/tmp/Haven" })417    manager.isEnabled = true418    #expect(!manager.isEnabled)419  }420421  @Test("Removal failures leave the launch agent enabled")422  func removalFailure() {423    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)424    defer { try? FileManager.default.removeItem(at: root) }425    let manager = AutostartManager(426      libraryDirectory: root, executablePath: { "/tmp/Haven" },427      removePlist: { _ in throw CocoaError(.fileWriteNoPermission) })428    manager.isEnabled = true429    manager.isEnabled = false430    #expect(manager.isEnabled)431  }432}433434@Suite("Local wallpaper library", .serialized)435@MainActor436struct LocalWallpapersManagerTests {437  private func waitForScan(_ manager: LocalWallpapersManager) async {438    for _ in 0..<100 where manager.isLoading {439      try? await Task.sleep(nanoseconds: 10_000_000)440    }441  }442443  @Test("Scan filters files, reads metadata, and deletes wallpapers")444  func scanAndDelete() async throws {445    let defaults = UserDefaults.standard446    let oldPath = defaults.string(forKey: "saveFolderPath")447    defaults.removeObject(forKey: "saveFolderPath")448    defer {449      if let oldPath {450        defaults.set(oldPath, forKey: "saveFolderPath")451      } else {452        defaults.removeObject(forKey: "saveFolderPath")453      }454    }455    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)456    defer { try? FileManager.default.removeItem(at: root) }457    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)458    try onePixelPNG.write(to: root.appendingPathComponent("wallpaper.png"))459    try Data("broken".utf8).write(to: root.appendingPathComponent("broken.png"))460    try Data("ignored".utf8).write(to: root.appendingPathComponent("notes.txt"))461    let manager = LocalWallpapersManager()462463    manager.scanFolder(path: root.path)464    manager.scanFolder(path: root.path)465    await waitForScan(manager)466467    let wallpaper = try #require(manager.wallpapers.first)468    #expect(manager.wallpapers.count == 1)469    #expect(wallpaper.thumbnailURL == wallpaper.url)470    #expect(wallpaper.filename == "wallpaper.png")471    #expect(wallpaper.resolution == "1x1")472    #expect(LocalWallpapersManager.wallpaper(at: root.appendingPathComponent("broken.png")) == nil)473    #expect(LocalWallpapersManager.wallpaper(at: root.appendingPathComponent("missing.png")) == nil)474475    manager.deleteWallpaper(wallpaper)476    #expect(manager.wallpapers.isEmpty)477    manager.deleteWallpaper(wallpaper)478    #expect(manager.errorMessage?.hasPrefix("Failed to delete") == true)479  }480481  @Test("Invalid scan roots surface an error")482  func scanError() async throws {483    let defaults = UserDefaults.standard484    defaults.removeObject(forKey: "saveFolderPath")485    let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)486    defer { try? FileManager.default.removeItem(at: file) }487    try Data("file".utf8).write(to: file)488    let manager = LocalWallpapersManager()489490    manager.scanFolder(path: file.path)491    await waitForScan(manager)492493    #expect(manager.errorMessage?.hasPrefix("Failed to scan folder") == true)494    #expect(!manager.isLoading)495  }496497  @Test("Monitoring configured folders observes filesystem changes and stops")498  func monitoring() async throws {499    let defaults = UserDefaults.standard500    let oldPath = defaults.string(forKey: "saveFolderPath")501    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)502    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)503    defaults.set(root.path, forKey: "saveFolderPath")504    defer {505      try? FileManager.default.removeItem(at: root)506      if let oldPath {507        defaults.set(oldPath, forKey: "saveFolderPath")508      } else {509        defaults.removeObject(forKey: "saveFolderPath")510      }511    }512    let manager = LocalWallpapersManager()513    await waitForScan(manager)514515    try onePixelPNG.write(to: root.appendingPathComponent("observed.png"))516    try? await Task.sleep(nanoseconds: 100_000_000)517    await waitForScan(manager)518    #expect(manager.wallpapers.contains(where: { $0.filename == "observed.png" }))519520    manager.stopMonitoring()521    try? await Task.sleep(nanoseconds: 500_000_000)522523    let invalidMonitor = FolderMonitor(folderPath: root.appendingPathComponent("missing").path) {}524    invalidMonitor.start()525    invalidMonitor.closeFileDescriptor()526527    let descriptor = open("/dev/null", O_RDONLY)528    let descriptorMonitor = FolderMonitor(529      folderPath: "/dev/null", fileDescriptor: descriptor, callback: {})530    descriptorMonitor.closeFileDescriptor()531    #expect(fcntl(descriptor, F_GETFD) == -1)532  }533}