import AppKit import Testing @testable import Conjure // Conjure roles generated by tint from base16-swamp-light. private let swampLightTheme = ##"{"variant":"light","colors":{"background":"#f1e3d1","text":"#64513e","secondaryText":"#a0907d","accent":"#bf7979","glow":"#d09700","keyBackground":"#ddcebc","error":"#d09700"}}"## @Test @MainActor func generatedLightMetadataAndManualLuminanceChooseTheMatchingAppearance() throws { let directory = try lightConfigurationDirectory() defer { try? FileManager.default.removeItem(at: directory) } let theme = directory.appendingPathComponent("theme.json") try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml")) try Data(swampLightTheme.utf8).write(to: theme) let light = try ColorScheme.load(from: directory) #expect(light.isLight) #expect(ThemePalette.State(scheme: light).appearance.name == .aqua) // Metadata wins even when manual colors disagree with the generated variant. try Data("theme = 'tint'\ncolors.background = '#000000'\ncolors.text = '#ffffff'\n".utf8) .write(to: directory.appendingPathComponent("config.toml")) #expect(try ColorScheme.load(from: directory).isLight) try Data("colors.background = '#f1e3d1'\ncolors.text = '#64513e'\n".utf8) .write(to: directory.appendingPathComponent("config.toml")) let manual = try ColorScheme.load(from: directory) #expect(manual.isLight) #expect(ThemePalette.State(scheme: manual).appearance.name == .aqua) try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml")) try Data(swampLightTheme.replacingOccurrences(of: "\"variant\":\"light\",", with: "").utf8).write(to: theme) #expect(try ColorScheme.load(from: directory).isLight) try FileManager.default.removeItem(at: directory.appendingPathComponent("config.toml")) #expect(try !ColorScheme.load(from: directory).isLight) } @Test(arguments: ["null", "true", "42", "[]", "{}", "\"Light\"", "\"darkAqua\"", "\"\""]) @MainActor func invalidGeneratedVariantsReportTheThemePath(_ value: String) throws { let directory = try lightConfigurationDirectory() defer { try? FileManager.default.removeItem(at: directory) } try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml")) let theme = directory.appendingPathComponent("theme.json") try Data("{\"variant\":\(value),\"colors\":{}}".utf8).write(to: theme) do { _ = try ColorScheme.load(from: directory) Issue.record("Expected invalid generated variant to be rejected") } catch { #expect(error.localizedDescription.contains(theme.path)) #expect(error.localizedDescription.contains("variant")) } } @Test @MainActor func variantOnlyReloadNotifiesConsumersAndChangesRenderedAppearance() throws { let original = ThemePalette.loaded let directory = try lightConfigurationDirectory() defer { ThemePalette.loaded = original try? FileManager.default.removeItem(at: directory) } try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml")) let theme = directory.appendingPathComponent("theme.json") try Data(swampLightTheme.replacingOccurrences(of: "\"light\"", with: "\"dark\"").utf8).write(to: theme) ThemePalette.reload(from: directory) let changes = LightPaletteChanges() let observer = NotificationCenter.default.addObserver(forName: ThemePalette.didChangeNotification, object: nil, queue: .main) { _ in MainActor.assumeIsolated { changes.appearances.append(ThemePalette.appearance.name) } } defer { NotificationCenter.default.removeObserver(observer) } let before = try renderedBackground(over: .black) try Data(swampLightTheme.utf8).write(to: theme) #expect(ThemePalette.reload(from: directory)) #expect(changes.appearances == [.aqua]) #expect(!ThemePalette.reload(from: directory)) #expect(changes.appearances.count == 1) let after = try renderedBackground(over: .black) let oldSurface = try #require(before.colorAt(x: 375, y: 240)) let newSurface = try #require(after.colorAt(x: 375, y: 240)) #expect(testLuminance(newSurface) > testLuminance(oldSurface)) } @Test @MainActor func lightSecondaryTextRemainsReadableAcrossRenderedGlowsWithoutChangingSourceColors() throws { let original = ThemePalette.loaded let directory = try lightConfigurationDirectory() defer { ThemePalette.loaded = original try? FileManager.default.removeItem(at: directory) } try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml")) try Data(swampLightTheme.utf8).write(to: directory.appendingPathComponent("theme.json")) let source = try ColorScheme.load(from: directory) ThemePalette.reload(from: directory) #expect(ThemePalette.text == source.text) #expect(ThemePalette.loaded.scheme.secondaryText == source.secondaryText) #expect(testLuminance(ThemePalette.secondaryText) < testLuminance(source.secondaryText)) for backing in [NSColor.black, .white] { let bitmap = try renderedBackground(over: backing) for y in stride(from: 0, to: bitmap.pixelsHigh, by: 12) { for x in stride(from: 0, to: bitmap.pixelsWide, by: 12) { let surface = try #require(bitmap.colorAt(x: x, y: y)) let foreground = testLuminance(ThemePalette.secondaryText) #expect((testLuminance(surface) + 0.05) / (foreground + 0.05) >= 4.5) } } } // An already-readable manual secondary color is not needlessly restyled. try Data("theme = 'tint'\ncolors.secondaryText = '#302820'\n".utf8) .write(to: directory.appendingPathComponent("config.toml")) ThemePalette.reload(from: directory) #expect(ThemePalette.secondaryText == ThemePalette.loaded.scheme.secondaryText) } @Test @MainActor func darkBackgroundRenderingAndSemanticColorsRemainUnchanged() throws { let original = ThemePalette.loaded defer { ThemePalette.loaded = original } for scheme in [ColorScheme.sunset, customDarkScheme()] { ThemePalette.loaded = ThemePalette.State(scheme: scheme) #expect(ThemePalette.secondaryText == scheme.secondaryText) #expect(ThemePalette.text == scheme.text) #expect(ThemePalette.appearance.name == .darkAqua) for backing in [NSColor.black, .white] { let actual = try renderedBackground(over: backing) let legacy = try renderedBackground(over: backing, legacy: scheme) for y in stride(from: 0, to: actual.pixelsHigh, by: 12) { for x in stride(from: 0, to: actual.pixelsWide, by: 12) { #expect(actual.colorAt(x: x, y: y) == legacy.colorAt(x: x, y: y)) } } } } } @MainActor private final class LightPaletteChanges { var appearances: [NSAppearance.Name] = [] } private func lightConfigurationDirectory() throws -> URL { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) return directory } @MainActor private func customDarkScheme() -> ColorScheme { var scheme = ColorScheme.sunset scheme.background = NSColor(srgbRed: 0.04, green: 0.08, blue: 0.12, alpha: 1) scheme.accent = .cyan scheme.glow = .magenta return scheme } private func testLuminance(_ color: NSColor) -> CGFloat { let rgb = color.usingColorSpace(.sRGB)! func linear(_ value: CGFloat) -> CGFloat { value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) } return 0.2126 * linear(rgb.redComponent) + 0.7152 * linear(rgb.greenComponent) + 0.0722 * linear(rgb.blueComponent) } @MainActor private func renderedBackground(over backing: NSColor, legacy scheme: ColorScheme? = nil) throws -> NSBitmapImageRep { _ = NSApplication.shared let bounds = NSRect(x: 0, y: 0, width: 750, height: 480) let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 750, pixelsHigh: 480, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) NSGraphicsContext.saveGraphicsState() defer { NSGraphicsContext.restoreGraphicsState() } NSGraphicsContext.current = context backing.setFill() bounds.fill() if let scheme { scheme.background.withAlphaComponent(0.55).setFill() bounds.fill() let amber = try #require(NSGradient(colors: [scheme.accent.withAlphaComponent(0.18), scheme.accent.withAlphaComponent(0.065), scheme.accent.withAlphaComponent(0)])) let ember = try #require(NSGradient(colors: [scheme.glow.withAlphaComponent(0.26), scheme.glow.withAlphaComponent(0.10), scheme.glow.withAlphaComponent(0)])) let upper = NSPoint(x: bounds.width * 0.58, y: bounds.height * 0.94) ember.draw(fromCenter: upper, radius: 0, toCenter: upper, radius: bounds.width * 0.40, options: []) let lower = NSPoint(x: bounds.width * 0.88, y: bounds.height * 0.10) amber.draw(fromCenter: lower, radius: 0, toCenter: lower, radius: bounds.width * 0.55, options: []) let left = NSPoint(x: bounds.width * 0.08, y: bounds.height * 0.65) context.cgContext.setAlpha(0.45) amber.draw(fromCenter: left, radius: 0, toCenter: left, radius: bounds.width * 0.32, options: []) } else { LauncherBackground(frame: bounds).draw(bounds) } return bitmap }