import AppKit import Testing @testable import Conjure @Test @MainActor func livePaletteReloadsGeneratedColorsKeepsOverridesAndRetainsLastValidPalette() throws { let original = ThemePalette.loaded let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { ThemePalette.loaded = original try? FileManager.default.removeItem(at: directory) } let config = directory.appendingPathComponent("config.toml") let theme = directory.appendingPathComponent("theme.json") ThemePalette.reload(from: directory) let white = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) let black = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 1) let changes = PaletteChanges() let observer = NotificationCenter.default.addObserver( forName: ThemePalette.didChangeNotification, object: nil, queue: .main ) { _ in MainActor.assumeIsolated { changes.count += 1 changes.background = ThemePalette.background changes.accent = ThemePalette.accent } } defer { NotificationCenter.default.removeObserver(observer) } try Data("theme = 'tint'\ncolors.accent = '#ffffff'\n".utf8).write(to: config) try Data(##"{"colors":{"background":"#123456","accent":"#ff0000","text":"#000000"}}"##.utf8) .write(to: theme) #expect(ThemePalette.reload(from: directory)) let firstBackground = ThemePalette.background #expect(ThemePalette.accent == white) #expect(ThemePalette.text == black) #expect(changes.count == 1) #expect(changes.background == ThemePalette.background) #expect(changes.accent == ThemePalette.accent) #expect(ThemePalette.background.alphaComponent == 0.55) #expect(ThemePalette.selectionFill.alphaComponent == 0.12) #expect(ThemePalette.selectionBorder.alphaComponent == 0.30) #expect(ThemePalette.keyBackground.alphaComponent == 0.5) #expect(!ThemePalette.reload(from: directory)) #expect(changes.count == 1) try Data(##"{"colors":{"background":"#abcdef","accent":"#00ff00","text":"#ffffff"}}"##.utf8) .write(to: theme, options: .atomic) #expect(ThemePalette.reload(from: directory)) #expect(ThemePalette.background != firstBackground) #expect(ThemePalette.accent == white) #expect(ThemePalette.text == white) #expect(changes.count == 2) let lastBackground = ThemePalette.background try Data("partial JSON".utf8).write(to: theme) #expect(!ThemePalette.reload(from: directory)) #expect(ThemePalette.background == lastBackground) #expect(ThemePalette.text == white) #expect(changes.count == 2) #expect(ThemePalette.configurationError?.contains(theme.path) == true) try Data(##"{"colors":{"background":"#abcdef","text":"#ffffff"}}"##.utf8).write(to: theme) #expect(!ThemePalette.reload(from: directory)) #expect(ThemePalette.configurationError == nil) #expect(changes.count == 2) try Data("colors.accent = '#invalid'\n".utf8).write(to: config) #expect(!ThemePalette.reload(from: directory)) #expect(ThemePalette.background == lastBackground) #expect(ThemePalette.configurationError?.contains(config.path) == true) #expect(changes.count == 2) try Data("theme = 'sunset'\n".utf8).write(to: config) #expect(ThemePalette.reload(from: directory)) #expect(ThemePalette.background == ColorScheme.sunset.background.withAlphaComponent(0.55)) #expect(ThemePalette.accent == ColorScheme.sunset.accent) #expect(changes.count == 3) #expect(ThemePalette.configurationError == nil) } @MainActor private final class PaletteChanges { var count = 0 var background: NSColor? var accent: NSColor? } @Test(arguments: ["config.toml", "theme.json"]) @MainActor func liveObservationDetectsInPlaceWritesAndRearmsAfterAtomicReplacement(_ filename: String) async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let directory = root.appendingPathComponent("conjure") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } let file = directory.appendingPathComponent(filename) try Data("initial".utf8).write(to: file) let events = AsyncStream.makeStream() let observation = ColorSchemeObservation(directory: directory) { events.continuation.yield((try? String(contentsOf: file, encoding: .utf8)) ?? "") } defer { events.continuation.finish() withExtendedLifetime(observation) {} } try overwriteInPlace("in-place", at: file) try await expectObserved("in-place", from: events.stream) try Data("atomic".utf8).write(to: file, options: .atomic) try await expectObserved("atomic", from: events.stream) try overwriteInPlace("after-replacement", at: file) try await expectObserved("after-replacement", from: events.stream) } @Test @MainActor func liveObservationFindsInitiallyMissingDirectoriesAndRecoversAfterRemoval() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } let missingAncestor = root.appendingPathComponent("missing") let directory = missingAncestor.appendingPathComponent("conjure") let file = directory.appendingPathComponent("theme.json") let events = AsyncStream.makeStream() let observation = ColorSchemeObservation(directory: directory) { events.continuation.yield((try? String(contentsOf: file, encoding: .utf8)) ?? "") } defer { events.continuation.finish() withExtendedLifetime(observation) {} } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try Data("created".utf8).write(to: file) try await expectObserved("created", from: events.stream) try overwriteInPlace("created-then-edited", at: file) try await expectObserved("created-then-edited", from: events.stream) try FileManager.default.removeItem(at: missingAncestor) try await expectObserved("", from: events.stream) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try Data("recreated".utf8).write(to: file, options: .atomic) try await expectObserved("recreated", from: events.stream) try overwriteInPlace("recreated-then-edited", at: file) try await expectObserved("recreated-then-edited", from: events.stream) } private func overwriteInPlace(_ value: String, at url: URL) throws { let handle = try FileHandle(forWritingTo: url) defer { try? handle.close() } try handle.truncate(atOffset: 0) try handle.write(contentsOf: Data(value.utf8)) } private struct ObservationTimeout: Error {} private func expectObserved(_ expected: String, from events: AsyncStream) async throws { try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { for await value in events { if value == expected { return } } throw CancellationError() } group.addTask { // Only a failure deadline: event delivery, not a sleep, advances the test. try await Task.sleep(for: .seconds(5)) throw ObservationTimeout() } defer { group.cancelAll() } _ = try await group.next() } } @Test @MainActor func liveBackgroundRebuildsCachedGradientsWithoutReplacingTheView() throws { _ = NSApplication.shared let original = ThemePalette.loaded let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 480), styleMask: .borderless, backing: .buffered, defer: false) window.isReleasedWhenClosed = false defer { ThemePalette.loaded = original window.orderOut(nil) try? FileManager.default.removeItem(at: directory) } let config = directory.appendingPathComponent("config.toml") try Data("colors.accent = '#ff0000'\ncolors.glow = '#ff0000'\n".utf8).write(to: config) ThemePalette.reload(from: directory) let background = LauncherBackground(frame: NSRect(x: 0, y: 0, width: 750, height: 480)) window.contentView?.addSubview(background) func glowColor() throws -> NSColor { let bitmap = try #require(background.bitmapImageRepForCachingDisplay(in: background.bounds)) background.cacheDisplay(in: background.bounds, to: bitmap) return try #require(bitmap.colorAt(x: Int(CGFloat(bitmap.pixelsWide) * 0.58), y: Int(CGFloat(bitmap.pixelsHigh) * 0.06))?.usingColorSpace(.deviceRGB)) } let before = try glowColor() let backgroundColor = ThemePalette.background background.needsDisplay = false try Data("colors.accent = '#00ff00'\ncolors.glow = '#00ff00'\n".utf8).write(to: config) #expect(ThemePalette.reload(from: directory)) #expect(ThemePalette.background == backgroundColor) #expect(background.needsDisplay) let after = try glowColor() #expect(before.redComponent > after.redComponent) #expect(after.greenComponent > before.greenComponent) }