import AppKit import Foundation import Testing @testable import Haven private final class ManagerURLProtocol: URLProtocol, @unchecked Sendable { override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { let path = request.url!.path let statusCode = path.contains("fail-http") ? 500 : 200 let data: Data if path.contains("image.png") { data = onePixelPNG } else if path.contains("invalid.png") { data = Data("not an image".utf8) } else { data = Data("wallpaper".utf8) } let response = HTTPURLResponse( url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)! client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: data) client?.urlProtocolDidFinishLoading(self) } override func stopLoading() {} } private func managerSession() -> URLSession { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [ManagerURLProtocol.self] return URLSession(configuration: configuration) } /// Wraps `ManagerURLProtocol`'s canned responses with a per-request counter, /// so cache tests can assert a repeat request never touched the network. private final class CountingURLProtocol: URLProtocol, @unchecked Sendable { nonisolated(unsafe) static var onLoad: (() -> Void)? override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { Self.onLoad?() let response = HTTPURLResponse( url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: Data("wallpaper".utf8)) client?.urlProtocolDidFinishLoading(self) } override func stopLoading() {} } private func countingSession(onLoad: @escaping () -> Void) -> URLSession { CountingURLProtocol.onLoad = onLoad let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [CountingURLProtocol.self] return URLSession(configuration: configuration) } private let onePixelPNG = Data( base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" )! @MainActor private final class DesktopImageStub: DesktopImageSetting { var receivedURLs: [URL] = [] func setDesktopImageURL( _ url: URL, for screen: NSScreen, options: [NSWorkspace.DesktopImageOptionKey: Any] ) throws { receivedURLs.append(url) } } @Suite("Favorites persistence", .serialized) struct FavoritesManagerTests { private let defaultsKey = "favoriteWallpapers" @Test("Toggling adds and removes a favorite") func toggleFavorite() { UserDefaults.standard.removeObject(forKey: defaultsKey) defer { UserDefaults.standard.removeObject(forKey: defaultsKey) } let manager = FavoritesManager() let wallpaper = makeWallpaper() manager.toggleFavorite(wallpaper) #expect(manager.favorites == [wallpaper]) #expect(manager.isFavorite(wallpaper)) manager.toggleFavorite(wallpaper) #expect(manager.favorites.isEmpty) #expect(!manager.isFavorite(wallpaper)) } @Test("Favorites survive manager recreation") func favoritesPersist() { UserDefaults.standard.removeObject(forKey: defaultsKey) defer { UserDefaults.standard.removeObject(forKey: defaultsKey) } let wallpaper = makeWallpaper() FavoritesManager().toggleFavorite(wallpaper) let reloaded = FavoritesManager() #expect(reloaded.favorites == [wallpaper]) } /// Encoder that always fails. Subclassing JSONEncoder lets us keep the /// real `UserDefaults` and validation paths and only flip the encode side. final class ThrowingEncoder: JSONEncoder, @unchecked Sendable { let testError = NSError( domain: "test", code: 7, userInfo: [NSLocalizedDescriptionKey: "encode boom"]) override func encode(_ value: T) throws -> Data where T: Encodable { throw testError } } @Test("Encode failure surfaces through persistenceError without losing in-memory state") func encodeFailureIsSurfaced() { UserDefaults.standard.removeObject(forKey: defaultsKey) defer { UserDefaults.standard.removeObject(forKey: defaultsKey) } let manager = FavoritesManager(encoder: ThrowingEncoder()) manager.toggleFavorite(makeWallpaper()) #expect(manager.favorites.count == 1) #expect(manager.persistenceError?.contains("encode boom") == true) } @Test("Corrupt stored data surfaces through persistenceError on load") func corruptDataIsSurfaced() { UserDefaults.standard.set(Data("not wallpaper json".utf8), forKey: defaultsKey) defer { UserDefaults.standard.removeObject(forKey: defaultsKey) } let manager = FavoritesManager() #expect(manager.favorites.isEmpty) #expect(manager.persistenceError?.contains("Failed to load favorites") == true) } } @Suite("History persistence", .serialized) struct HistoryManagerTests { private let defaultsKey = "wallpaperHistory" @Test("History keeps newest items first and promotes duplicates") func orderingAndPromotion() { UserDefaults.standard.removeObject(forKey: defaultsKey) defer { UserDefaults.standard.removeObject(forKey: defaultsKey) } let manager = HistoryManager() let first = makeWallpaper(id: "first") let second = makeWallpaper(id: "second") manager.addToHistory(first) manager.addToHistory(second) manager.addToHistory(first) #expect(manager.history.map(\.id) == ["first", "second"]) } @Test("History is capped at fifty entries") func historyLimit() { UserDefaults.standard.removeObject(forKey: defaultsKey) defer { UserDefaults.standard.removeObject(forKey: defaultsKey) } let manager = HistoryManager() for index in 0..<55 { manager.addToHistory(makeWallpaper(id: "item-\(index)")) } #expect(manager.history.count == 50) #expect(manager.history.first?.id == "item-54") #expect(manager.history.last?.id == "item-5") } @Test("History survives recreation and clear removes persisted data") func persistenceAndClear() { UserDefaults.standard.removeObject(forKey: defaultsKey) defer { UserDefaults.standard.removeObject(forKey: defaultsKey) } HistoryManager().addToHistory(makeWallpaper(id: "persisted")) let reloaded = HistoryManager() #expect(reloaded.history.map(\.id) == ["persisted"]) reloaded.clearHistory() #expect(HistoryManager().history.isEmpty) } final class ThrowingEncoder: JSONEncoder, @unchecked Sendable { let testError = NSError( domain: "test", code: 9, userInfo: [NSLocalizedDescriptionKey: "history encode boom"]) override func encode(_ value: T) throws -> Data where T: Encodable { throw testError } } @Test("Encode failure surfaces through persistenceError without losing in-memory state") func encodeFailureIsSurfaced() { UserDefaults.standard.removeObject(forKey: defaultsKey) defer { UserDefaults.standard.removeObject(forKey: defaultsKey) } let manager = HistoryManager(encoder: ThrowingEncoder()) manager.addToHistory(makeWallpaper(id: "one")) manager.addToHistory(makeWallpaper(id: "two")) #expect(manager.history.count == 2) #expect(manager.persistenceError?.contains("history encode boom") == true) } @Test("Corrupt stored data surfaces through persistenceError on load") func corruptDataIsSurfaced() { UserDefaults.standard.set(Data("not history json".utf8), forKey: defaultsKey) defer { UserDefaults.standard.removeObject(forKey: defaultsKey) } let manager = HistoryManager() #expect(manager.history.isEmpty) #expect(manager.persistenceError?.contains("Failed to load history") == true) } @Test("clearHistory surfaces persistenceError when the encoder fails") func clearHistoryEncoderFailureIsSurfaced() { UserDefaults.standard.removeObject(forKey: defaultsKey) defer { UserDefaults.standard.removeObject(forKey: defaultsKey) } // Seed history first via the regular encoder so we know there's an // entry to clear. HistoryManager().addToHistory(makeWallpaper(id: "seeded")) let manager = HistoryManager(encoder: ThrowingEncoder()) manager.clearHistory() #expect(manager.history.isEmpty) #expect(manager.persistenceError?.contains("history encode boom") == true) } } @Suite("Image cache") struct ImageCacheTests { @Test("Inserted images are returned for the same URL") func insertAndRetrieve() { let url = URL(string: "https://example.com/\(UUID().uuidString).png")! let image = NSImage(size: NSSize(width: 10, height: 20)) ImageCache.shared.insertImage(image, for: url) #expect(ImageCache.shared.image(for: url) === image) } @Test("Different URLs do not collide") func keysDoNotCollide() { let base = UUID().uuidString let firstURL = URL(string: "https://example.com/\(base)-first.png")! let secondURL = URL(string: "https://example.com/\(base)-second.png")! ImageCache.shared.insertImage(NSImage(size: NSSize(width: 1, height: 1)), for: firstURL) #expect(ImageCache.shared.image(for: secondURL) == nil) } @Test("Fetch decodes and caches successful image responses") func fetchAndCache() async throws { let cache = ImageCache(session: managerSession()) let url = URL(string: "https://example.com/image.png")! let first = try await cache.fetchImage(url: url) let second = try await cache.fetchImage(url: url) #expect(first === second) } @Test("Fetch rejects HTTP errors and invalid image bytes") func fetchFailures() async { let cache = ImageCache(session: managerSession()) let httpError = URL(string: "https://example.com/fail-http.png")! let invalid = URL(string: "https://example.com/invalid.png")! await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: httpError) } await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: invalid) } } @Test("Default cache limits are finite and configurable") func defaultAndInjectedLimits() { // Sanity: defaults are bounded so the cache cannot grow without limit. #expect(ImageCache.defaultCountLimit > 0) #expect(ImageCache.defaultTotalCostLimit > 0) let tight = ImageCache(countLimit: 2, totalCostLimit: 1024) let first = URL(string: "https://example.com/\(UUID().uuidString).png")! let second = URL(string: "https://example.com/\(UUID().uuidString).png")! let third = URL(string: "https://example.com/\(UUID().uuidString).png")! tight.insertImage(NSImage(size: NSSize(width: 4, height: 4)), for: first) tight.insertImage(NSImage(size: NSSize(width: 4, height: 4)), for: second) tight.insertImage(NSImage(size: NSSize(width: 4, height: 4)), for: third) // NSCache enforces the count limit deterministically enough to assert; // the third insertion must have evicted at least one of the earlier two. let survivors = [first, second, third].filter { tight.image(for: $0) != nil } #expect(survivors.count <= 2) } } @Suite("Wallpaper file management", .serialized) @MainActor struct WallpaperManagerTests { @Test("Download validates HTTP status and writes response data") func download() async throws { let manager = WallpaperManager(session: managerSession()) let url = URL(string: "https://example.com/\(UUID().uuidString).jpg")! let downloaded = try await manager.download(from: url) defer { try? FileManager.default.removeItem(at: downloaded) } #expect(try Data(contentsOf: downloaded) == Data("wallpaper".utf8)) let errorURL = URL(string: "https://example.com/fail-http.jpg")! await #expect(throws: WallhavenError.self) { try await manager.download(from: errorURL) } } @Test("Cached local file persists on disk and is reused without re-downloading") func cachedLocalFile() async throws { var downloadCount = 0 let cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString) defer { try? FileManager.default.removeItem(at: cacheDir) } let manager = WallpaperManager( session: countingSession { downloadCount += 1 }, cacheDirectoryProvider: { cacheDir }) let url = URL(string: "https://example.com/wallhaven-abc123.jpg")! let first = try await manager.cachedLocalFile(for: url) #expect(downloadCount == 1) #expect(first.deletingLastPathComponent().path == cacheDir.path) #expect(try Data(contentsOf: first) == Data("wallpaper".utf8)) // Same URL again: served from disk, no second network hit, and the // cached file is never deleted out from under a caller (unlike a plain // temp download, which a caller may clean up right after use). let second = try await manager.cachedLocalFile(for: url) #expect(downloadCount == 1) #expect(second == first) #expect(FileManager.default.fileExists(atPath: first.path)) } @Test("Default cache directory lives under Library/Caches and is reused across instances") func defaultCacheDirectoryIsUnderLibraryCaches() async throws { var downloadCount = 0 let url = URL(string: "https://example.com/wallhaven-\(UUID().uuidString).jpg")! let manager = WallpaperManager(session: countingSession { downloadCount += 1 }) defer { if let cachesRoot = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) .first { let bundleID = Bundle.main.bundleIdentifier ?? "Haven" try? FileManager.default.removeItem( at: cachesRoot.appendingPathComponent(bundleID).appendingPathComponent( "WallpaperCache" ).appendingPathComponent(url.lastPathComponent)) } } let first = try await manager.cachedLocalFile(for: url) #expect(downloadCount == 1) #expect(try Data(contentsOf: first) == Data("wallpaper".utf8)) // A second manager built with the same default provider finds the same // file on disk instead of re-downloading. let anotherManager = WallpaperManager(session: countingSession { downloadCount += 1 }) let second = try await anotherManager.cachedLocalFile(for: url) #expect(downloadCount == 1) #expect(second == first) } @Test("A caching directory that is unavailable falls back to a plain temp download") func cacheDirectoryUnavailableFallsBackToPlainDownload() async throws { var downloadCount = 0 let manager = WallpaperManager( session: countingSession { downloadCount += 1 }, cacheDirectoryProvider: { nil }) let url = URL(string: "https://example.com/wallhaven-abc999.jpg")! let first = try await manager.cachedLocalFile(for: url) defer { try? FileManager.default.removeItem(at: first) } #expect(downloadCount == 1) // No persistent cache directory available: every call re-downloads. let second = try await manager.cachedLocalFile(for: url) defer { try? FileManager.default.removeItem(at: second) } #expect(downloadCount == 2) } @Test("Desktop setter receives explicit and default screens") @MainActor func desktopSetter() throws { guard let screen = NSScreen.screens.first else { return } var received: [NSScreen] = [] let manager = WallpaperManager(desktopSetter: { _, target in received.append(target) }) let url = URL(fileURLWithPath: "/tmp/test-wallpaper.jpg") try manager.setDesktopImage(from: url, screen: screen) try manager.setDesktopImage(from: url) #expect(received.count == 2) #expect(received[0] === screen) } @Test("Default desktop adapter forwards the image to its workspace") func defaultDesktopAdapter() throws { guard let screen = NSScreen.screens.first else { return } let workspace = DesktopImageStub() let manager = WallpaperManager(workspace: workspace) let url = URL(fileURLWithPath: "/tmp/test-wallpaper.jpg") try manager.setDesktopImage(from: url, screen: screen) #expect(workspace.receivedURLs == [url]) } @Test("Set wallpaper composes download and desktop update") @MainActor func setWallpaper() async throws { guard let screen = NSScreen.screens.first else { return } var receivedURL: URL? let manager = WallpaperManager( session: managerSession(), desktopSetter: { url, _ in receivedURL = url }) let remote = URL(string: "https://example.com/\(UUID().uuidString).jpg")! try await manager.setWallpaper(from: remote, screen: screen) defer { if let receivedURL { try? FileManager.default.removeItem(at: receivedURL) } } // Temp file uses a UUID stem (to avoid collisions) but preserves the // original extension so downstream consumers can identify the format. #expect(receivedURL?.pathExtension == remote.pathExtension) #expect(receivedURL?.lastPathComponent.hasPrefix("haven-") == true) #expect(receivedURL?.lastPathComponent != remote.lastPathComponent) #expect(try Data(contentsOf: receivedURL!) == Data("wallpaper".utf8)) } @Test("Saving creates the destination folder and copies the file") func saveCreatesFolderAndCopies() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } let source = root.appendingPathComponent("source/wallpaper.jpg") let destination = root.appendingPathComponent("nested/destination") try FileManager.default.createDirectory( at: source.deletingLastPathComponent(), withIntermediateDirectories: true) try Data("first".utf8).write(to: source) try WallpaperManager.shared.saveToFolder(from: source, to: destination) let copied = try Data(contentsOf: destination.appendingPathComponent("wallpaper.jpg")) #expect(copied == Data("first".utf8)) } @Test("Saving replaces an existing file with fresh data") func saveReplacesExistingFile() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } let source = root.appendingPathComponent("source/wallpaper.jpg") let destination = root.appendingPathComponent("destination") try FileManager.default.createDirectory( at: source.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) try Data("new".utf8).write(to: source) try Data("old".utf8).write(to: destination.appendingPathComponent("wallpaper.jpg")) try WallpaperManager.shared.saveToFolder(from: source, to: destination) let copied = try Data(contentsOf: destination.appendingPathComponent("wallpaper.jpg")) #expect(copied == Data("new".utf8)) } @Test("Saving the same logical wallpaper leaves the existing file untouched") func saveSameContentIsIdempotent() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } let source = root.appendingPathComponent("source/wallpaper.jpg") let destination = root.appendingPathComponent("destination") try FileManager.default.createDirectory( at: source.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) try Data("identical".utf8).write(to: source) let existingURL = destination.appendingPathComponent("wallpaper.jpg") try Data("identical".utf8).write(to: existingURL) let originalAttributes = try FileManager.default.attributesOfItem(atPath: existingURL.path) try WallpaperManager.shared.saveToFolder(from: source, to: destination) let updatedAttributes = try FileManager.default.attributesOfItem(atPath: existingURL.path) #expect(try Data(contentsOf: existingURL) == Data("identical".utf8)) #expect( (updatedAttributes[.modificationDate] as? Date) == (originalAttributes[.modificationDate] as? Date) ) } @Test("Saving into the same path as the source does not destroy data") func saveSamePathAsSource() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) let source = root.appendingPathComponent("wallpaper.jpg") try Data("payload".utf8).write(to: source) try WallpaperManager.shared.saveToFolder(from: source, to: root) #expect(try Data(contentsOf: source) == Data("payload".utf8)) } @Test("Distinct URLs sharing a lastPathComponent produce distinct temp files") func distinctDownloadsDoNotCollide() async throws { let manager = WallpaperManager(session: managerSession()) let first = URL(string: "https://example.com/aaa/wallpaper.jpg")! let second = URL(string: "https://example.com/bbb/wallpaper.jpg")! let firstFile = try await manager.download(from: first) let secondFile = try await manager.download(from: second) defer { try? FileManager.default.removeItem(at: firstFile) try? FileManager.default.removeItem(at: secondFile) } #expect(firstFile.path != secondFile.path) #expect(firstFile.lastPathComponent != secondFile.lastPathComponent) #expect(try Data(contentsOf: firstFile) == Data("wallpaper".utf8)) #expect(try Data(contentsOf: secondFile) == Data("wallpaper".utf8)) } @Test("Repeated downloads of the same URL produce independent temp files") func repeatedDownloadsAreIndependent() async throws { let manager = WallpaperManager(session: managerSession()) let remote = URL(string: "https://example.com/\(UUID().uuidString).jpg")! let first = try await manager.download(from: remote) let second = try await manager.download(from: remote) defer { try? FileManager.default.removeItem(at: first) try? FileManager.default.removeItem(at: second) } #expect(first.path != second.path) #expect(try Data(contentsOf: first) == Data("wallpaper".utf8)) #expect(try Data(contentsOf: second) == Data("wallpaper".utf8)) } @Test("makeTempURL preserves the source extension when present") func makeTempURLPreservesExtension() { let withExtension = URL(string: "https://example.com/foo/wallpaper.png")! let withoutExtension = URL(string: "https://example.com/foo/wallpaper")! #expect(WallpaperManager.makeTempURL(for: withExtension).pathExtension == "png") #expect(WallpaperManager.makeTempURL(for: withoutExtension).pathExtension == "img") #expect( WallpaperManager.makeTempURL(for: withExtension).path != WallpaperManager.makeTempURL(for: withExtension).path) } @Test("setDesktopImage without screens throws instead of force-unwrapping") func setDesktopImageWithoutScreensThrows() { let manager = WallpaperManager( desktopSetter: { _, _ in }, mainScreen: { nil }, firstScreen: { nil }) let url = URL(fileURLWithPath: "/tmp/no-screens.jpg") #expect(throws: WallhavenError.self) { try manager.setDesktopImage(from: url) } } @Test("setDesktopImage prefers main over first screen when both are available") func setDesktopImagePrefersMain() throws { let mainScreen = NSScreen.main ?? NSScreen.screens.first guard let mainScreen else { return } let otherScreen = mainScreen var captured: [NSScreen] = [] let manager = WallpaperManager( desktopSetter: { _, target in captured.append(target) }, mainScreen: { mainScreen }, firstScreen: { otherScreen }) let url = URL(fileURLWithPath: "/tmp/main-preferred.jpg") try manager.setDesktopImage(from: url) #expect(captured == [mainScreen]) } @Test("setDesktopImage falls back to first screen when main is missing") func setDesktopImageFallsBackToFirst() throws { guard let first = NSScreen.screens.first else { return } var captured: [NSScreen] = [] let manager = WallpaperManager( desktopSetter: { _, target in captured.append(target) }, mainScreen: { nil }, firstScreen: { first }) let url = URL(fileURLWithPath: "/tmp/fallback.jpg") try manager.setDesktopImage(from: url) #expect(captured == [first]) } @Test("setDesktopImage default firstScreen closure calls NSScreen.screens.first") func setDesktopImageDefaultFirstScreenClosure() throws { guard let first = NSScreen.screens.first else { return } var captured: [NSScreen] = [] // No main screen + default firstScreen closure exercises the // { NSScreen.screens.first } default injected in the init signature. let manager = WallpaperManager( desktopSetter: { _, target in captured.append(target) }, mainScreen: { nil }) let url = URL(fileURLWithPath: "/tmp/default-first.jpg") try manager.setDesktopImage(from: url) #expect(captured == [first]) } } @Suite("Autostart persistence", .serialized) @MainActor struct AutostartManagerTests { @Test("Enable writes a launch agent and disable removes it") func enableAndDisable() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } let manager = AutostartManager( libraryDirectory: root, executablePath: { "/Applications/Haven.app/Contents/MacOS/Haven" }) #expect(!manager.isEnabled) manager.isEnabled = true #expect(manager.isEnabled) manager.isEnabled = true let plist = root.appendingPathComponent("LaunchAgents/com.june.Haven.plist") let contents = try PropertyListSerialization.propertyList( from: Data(contentsOf: plist), format: nil) as? [String: Any] #expect(contents?["Label"] as? String == "com.june.Haven") #expect( contents?["ProgramArguments"] as? [String] == ["/Applications/Haven.app/Contents/MacOS/Haven"]) #expect(contents?["RunAtLoad"] as? Bool == true) manager.isEnabled = false #expect(!manager.isEnabled) manager.isEnabled = false } @Test("Missing executable leaves autostart disabled") func missingExecutable() { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } let manager = AutostartManager(libraryDirectory: root, executablePath: { nil }) manager.isEnabled = true #expect(!manager.isEnabled) } @Test("Default executable provider writes only inside the injected library") func defaultExecutableProvider() { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } let manager = AutostartManager(libraryDirectory: root) manager.isEnabled = true #expect(manager.isEnabled) manager.isEnabled = false } @Test("Unwritable launch-agent roots do not enable autostart") func writeFailure() throws { let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: file) } try Data("not a directory".utf8).write(to: file) let manager = AutostartManager(libraryDirectory: file, executablePath: { "/tmp/Haven" }) manager.isEnabled = true #expect(!manager.isEnabled) } @Test("Removal failures leave the launch agent enabled") func removalFailure() { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } let manager = AutostartManager( libraryDirectory: root, executablePath: { "/tmp/Haven" }, removePlist: { _ in throw CocoaError(.fileWriteNoPermission) }) manager.isEnabled = true manager.isEnabled = false #expect(manager.isEnabled) } @Test("LaunchAgents directory-creation failures surface through lastError") func launchAgentsDirectoryFailureIsSurfaced() { let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: file) } try? Data("not a directory".utf8).write(to: file) let manager = AutostartManager( libraryDirectory: file, executablePath: { "/tmp/Haven" }) // The directory-create error must be observable rather than swallowed. #expect(manager.lastError?.contains("LaunchAgents") == true) } @Test("lastError is cleared by a successful enable/disable cycle") func lastErrorClearsOnSuccess() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } let manager = AutostartManager( libraryDirectory: root, executablePath: { "/tmp/Haven" }, writePlist: { _, _ in throw CocoaError(.fileWriteNoPermission) }) manager.isEnabled = true #expect(manager.lastError?.contains("Failed to enable autostart") == true) // Swap in a working writer so the next cycle succeeds. let successManager = AutostartManager( libraryDirectory: root, executablePath: { "/tmp/Haven" }) successManager.isEnabled = true successManager.isEnabled = false #expect(successManager.lastError == nil) } } @Suite("Local wallpaper library", .serialized) @MainActor struct LocalWallpapersManagerTests { private func waitForScan(_ manager: LocalWallpapersManager) async { for _ in 0..<100 where manager.isLoading { try? await Task.sleep(nanoseconds: 10_000_000) } } @Test("Scan filters files, reads metadata, and deletes wallpapers") func scanAndDelete() async throws { let defaults = UserDefaults.standard let oldPath = defaults.string(forKey: "saveFolderPath") defaults.removeObject(forKey: "saveFolderPath") defer { if let oldPath { defaults.set(oldPath, forKey: "saveFolderPath") } else { defaults.removeObject(forKey: "saveFolderPath") } } let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) try onePixelPNG.write(to: root.appendingPathComponent("wallpaper.png")) try Data("broken".utf8).write(to: root.appendingPathComponent("broken.png")) try Data("ignored".utf8).write(to: root.appendingPathComponent("notes.txt")) let manager = LocalWallpapersManager() manager.scanFolder(path: root.path) manager.scanFolder(path: root.path) await waitForScan(manager) let wallpaper = try #require(manager.wallpapers.first) #expect(manager.wallpapers.count == 1) #expect(wallpaper.thumbnailURL == wallpaper.url) #expect(wallpaper.filename == "wallpaper.png") #expect(wallpaper.resolution == "1x1") #expect(LocalWallpapersManager.wallpaper(at: root.appendingPathComponent("broken.png")) == nil) #expect(LocalWallpapersManager.wallpaper(at: root.appendingPathComponent("missing.png")) == nil) manager.deleteWallpaper(wallpaper) #expect(manager.wallpapers.isEmpty) manager.deleteWallpaper(wallpaper) #expect(manager.errorMessage?.hasPrefix("Failed to delete") == true) } @Test("Invalid scan roots surface an error") func scanError() async throws { let defaults = UserDefaults.standard defaults.removeObject(forKey: "saveFolderPath") let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: file) } try Data("file".utf8).write(to: file) let manager = LocalWallpapersManager() manager.scanFolder(path: file.path) await waitForScan(manager) #expect(manager.errorMessage?.hasPrefix("Failed to scan folder") == true) #expect(!manager.isLoading) } @Test("Concurrent scan requests are dispatched, not silently dropped") func concurrentScansAreDispatched() async throws { let defaults = UserDefaults.standard let oldPath = defaults.string(forKey: "saveFolderPath") defaults.removeObject(forKey: "saveFolderPath") defer { if let oldPath { defaults.set(oldPath, forKey: "saveFolderPath") } else { defaults.removeObject(forKey: "saveFolderPath") } } let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) let manager = LocalWallpapersManager() // First explicit scan baseline on an empty folder. manager.scanFolder(path: root.path) await waitForScan(manager) #expect(manager.wallpapers.isEmpty) // Drop two files in succession. Each scan after a write must observe the // new file rather than be silently dropped because the previous one is // still in flight. try onePixelPNG.write(to: root.appendingPathComponent("first.png")) try onePixelPNG.write(to: root.appendingPathComponent("second.png")) manager.scanFolder(path: root.path) manager.scanFolder(path: root.path) await waitForScan(manager) #expect(manager.wallpapers.contains(where: { $0.filename == "first.png" })) #expect(manager.wallpapers.contains(where: { $0.filename == "second.png" })) #expect(!manager.isLoading) #expect(manager.errorMessage == nil) } @Test("Reissuing a scan during an in-flight scan observes later state via generation") func scanDuringFlightWinsForLatestGeneration() async throws { let defaults = UserDefaults.standard let oldPath = defaults.string(forKey: "saveFolderPath") defaults.removeObject(forKey: "saveFolderPath") defer { if let oldPath { defaults.set(oldPath, forKey: "saveFolderPath") } else { defaults.removeObject(forKey: "saveFolderPath") } } let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) let manager = LocalWallpapersManager() // Issue three scans in rapid succession against an empty folder. Under // the old guard !isLoading logic, two of these would be silently // dropped; under the generation strategy all three are dispatched and // only the latest commits. The manager must still end up stable. manager.scanFolder(path: root.path) manager.scanFolder(path: root.path) manager.scanFolder(path: root.path) await waitForScan(manager) #expect(!manager.isLoading) #expect(manager.wallpapers.isEmpty) #expect(manager.errorMessage == nil) // Now write a file and trigger a new scan; the new scan must observe it. try onePixelPNG.write(to: root.appendingPathComponent("added.png")) manager.scanFolder(path: root.path) await waitForScan(manager) #expect(manager.wallpapers.contains(where: { $0.filename == "added.png" })) } @Test("Monitoring configured folders observes filesystem changes and stops") func monitoring() async throws { let defaults = UserDefaults.standard let oldPath = defaults.string(forKey: "saveFolderPath") let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defaults.set(root.path, forKey: "saveFolderPath") defer { try? FileManager.default.removeItem(at: root) if let oldPath { defaults.set(oldPath, forKey: "saveFolderPath") } else { defaults.removeObject(forKey: "saveFolderPath") } } let manager = LocalWallpapersManager() await waitForScan(manager) try onePixelPNG.write(to: root.appendingPathComponent("observed.png")) try? await Task.sleep(nanoseconds: 100_000_000) await waitForScan(manager) #expect(manager.wallpapers.contains(where: { $0.filename == "observed.png" })) manager.stopMonitoring() try? await Task.sleep(nanoseconds: 500_000_000) let invalidMonitor = FolderMonitor(folderPath: root.appendingPathComponent("missing").path) {} #expect(invalidMonitor.start() == false) #expect(invalidMonitor.openFailureMessage?.isEmpty == false) invalidMonitor.closeFileDescriptor() // An injectable `openPath` returning -1 simulates open() failure so the // error is reproducible in CI. The message must reach any caller that // asks for it so the UI can react instead of silently dropping the watch. let broken = FolderMonitor( folderPath: root.path, callback: {}, openPath: { _, _ in -1 }) #expect(broken.start() == false) #expect(broken.openFailureMessage?.localizedCaseInsensitiveContains("could not watch") == true) let descriptor = open("/dev/null", O_RDONLY) let descriptorMonitor = FolderMonitor( folderPath: "/dev/null", fileDescriptor: descriptor, callback: {}) descriptorMonitor.closeFileDescriptor() #expect(fcntl(descriptor, F_GETFD) == -1) } @Test("startMonitoring surfaces folder-monitor open failures through errorMessage") func startMonitoringSurfacesMonitorFailures() async { // A regular file in place of the folder makes open(path, O_EVTONLY) fail // with ENOTDIR. The manager must capture that failure through // errorMessage synchronously so the UI sees it before any subsequent // scan clobbers the message. let defaults = UserDefaults.standard let oldPath = defaults.string(forKey: "saveFolderPath") defaults.removeObject(forKey: "saveFolderPath") defer { if let oldPath { defaults.set(oldPath, forKey: "saveFolderPath") } else { defaults.removeObject(forKey: "saveFolderPath") } } // A path that does not exist forces open(path, O_EVTONLY) to fail with // ENOENT, which is the signal we want to surface through errorMessage. let missing = FileManager.default.temporaryDirectory .appendingPathComponent("haven-missing-\(UUID().uuidString)").path let manager = LocalWallpapersManager() manager.startMonitoring(folderPath: missing) #expect(manager.errorMessage?.isEmpty == false) manager.stopMonitoring() } }