import Foundation import Testing @testable import FlixyDesign @Suite("Kontrastkorrektur") struct ContrastTests { private let canvas = FlixyColor.canvas @Test("Schwarz zu Weiß ergibt das Maximum von 21:1") func extremesProduceMaximumRatio() { let ratio = Contrast.ratio(RGB(hex: 0x000000), RGB(hex: 0xFFFFFF)) #expect(abs(ratio - 21) < 0.01) } @Test("Gleiche Farben ergeben 1:1") func identicalColorsProduceMinimumRatio() { #expect(abs(Contrast.ratio(canvas, canvas) - 1) < 0.0001) } @Test("Farben mit genug Kontrast bleiben unangetastet") func sufficientColorsAreLeftAlone() { // Wichtig, weil die kuratierte Palette sonst beim Zeichnen verfälscht // würde — die Kachel in den Einstellungen und die Farbe in der App // müssen übereinstimmen. let amber = RGB(hex: 0xFFB224) #expect(Contrast.legible(amber, on: canvas) == amber) } @Test( "Zu dunkle Farben werden bis zur Lesbarkeit angehoben", arguments: [0x00008B, 0x1A1A2E, 0x2D0A31, 0x003300, 0x4A0000, 0x111111] ) func darkColorsAreLifted(hex: Int) { let original = RGB(hex: UInt32(hex)) let corrected = Contrast.legible(original, on: canvas) #expect(Contrast.ratio(original, canvas) < Contrast.minimumForText) #expect(Contrast.ratio(corrected, canvas) >= Contrast.minimumForText) } @Test("Die Korrektur hebt nur so weit an wie nötig") func correctionIsMinimal() { // Ein Sprung auf Weiß wäre die einfache Lösung, würde aber jede // dunkle Wunschfarbe in dieselbe Farbe verwandeln. let corrected = Contrast.legible(RGB(hex: 0x00008B), on: canvas) #expect(Contrast.ratio(corrected, canvas) < Contrast.minimumForText + 0.5) #expect(corrected != RGB(hex: 0xFFFFFF)) } @Test("Der Farbton überlebt die Korrektur") func correctionPreservesHue() { let darkBlue = RGB(hex: 0x00008B) let corrected = Contrast.legible(darkBlue, on: canvas) // Blau muss blau bleiben. Ein Abstand von 0,02 im Farbkreis sind rund // sieben Grad — für das Auge dieselbe Farbe. #expect(abs(HSB(corrected).hue - HSB(darkBlue).hue) < 0.02) } @Test("Selbst Schwarz wird lesbar gemacht") func blackIsHandled() { // Schwarz hat keinen Farbton, an dem sich festhalten ließe — hier // muss die Sättigungsstufe greifen, sonst käme die Suche nie an. let corrected = Contrast.legible(RGB(hex: 0x000000), on: canvas) #expect(Contrast.ratio(corrected, canvas) >= Contrast.minimumForText) } } @Suite("Akzentpalette") struct AccentPaletteTests { @Test("Jede kuratierte Farbe ist ohne Korrektur lesbar") func curatedColorsAreLegibleAsIs() { // Das ist der eigentliche Zweck des Kuratierens. Fällt eine neue Farbe // hier durch, gehört sie nicht in die Palette — nicht in die // Korrektur. for option in AccentOption.palette { let ratio = Contrast.ratio(option.color, FlixyColor.canvas) #expect( ratio >= Contrast.minimumForText, "\(option.id) erreicht nur \(String(format: "%.2f", ratio)):1" ) } } @Test("Die Kennungen sind eindeutig") func identifiersAreUnique() { // Sie dienen als Schlüssel in den Voreinstellungen; ein Duplikat // hieße, dass eine Auswahl still eine andere überschreibt. let identifiers = AccentOption.palette.map(\.id) #expect(Set(identifiers).count == identifiers.count) } @Test("Keine kuratierte Farbe nimmt die Kennung der eigenen Farbe ein") func paletteDoesNotCollideWithCustom() { #expect(!AccentOption.palette.contains { $0.id == AccentOption.customIdentifier }) } } @Suite("Farbwerte") struct RGBTests { @Test( "Hex-Eingaben werden gelesen", arguments: [ ("#1DB954", 0x1DB954), ("1db954", 0x1DB954), (" #FFB224 ", 0xFFB224), // Kurzform: #ABC meint #AABBCC. ("#ABC", 0xAABBCC), ] ) func validHexStringsParse(input: String, expected: Int) { #expect(RGB(string: input) == RGB(hex: UInt32(expected))) } @Test( "Unbrauchbare Eingaben werden abgewiesen", arguments: ["", "#", "xyz", "#12345", "#GGGGGG", "1234567"] ) func invalidHexStringsFail(input: String) { #expect(RGB(string: input) == nil) } @Test("Hex hin und zurück ergibt denselben Wert") func hexRoundTrips() { for option in AccentOption.palette { #expect(RGB(string: option.color.hexString) == option.color) } } } @Suite("Standard-Akzentfarbe") struct DefaultAccentTests { @Test("Die Voreinstellung ist Lilac") func defaultIsLilac() { // Festgehalten, damit ein Umsortieren der Palette sie nicht still // verändert. Wer sie bewusst wechselt, ändert auch diesen Test — und // genau das ist hier passiert: vorher stand Mint da, was mit dem // violetten Icon nichts zu tun hatte. #expect(AccentOption.default.id == "lilac") #expect(AccentOption.default.color == RGB(hex: 0x9B8AD4)) } @Test("Die Voreinstellung steht in der Palette") func defaultIsPartOfThePalette() { #expect(AccentOption.palette.contains(AccentOption.default)) } @Test("Die Voreinstellung braucht keine Kontrastkorrektur") func defaultNeedsNoCorrection() { let corrected = Contrast.legible(AccentOption.default.color, on: FlixyColor.canvas) #expect(corrected == AccentOption.default.color) } } @Suite("Gespeicherte Akzentfarbe") struct AccentStorageTests { private func freshDefaults() -> UserDefaults { UserDefaults(suiteName: "flixy.accent.tests.\(UUID().uuidString)")! } @Test("Ohne gespeicherte Auswahl gilt die Voreinstellung") func defaultWithoutSelection() { #expect(AccentOption.stored(in: freshDefaults()) == .default) } @Test("Eine kuratierte Auswahl kommt zurück") func curatedRoundTrips() { let defaults = freshDefaults() let sky = AccentOption.palette.first { $0.id == "sky" }! sky.store(in: defaults) #expect(AccentOption.stored(in: defaults) == sky) } @Test("Eine eigene Farbe kommt mit ihrem Wert zurück") func customRoundTrips() { let defaults = freshDefaults() let purple = AccentOption.custom(RGB(hex: 0x8B5CF6)) purple.store(in: defaults) let restored = AccentOption.stored(in: defaults) #expect(restored.id == AccentOption.customIdentifier) #expect(restored.color == RGB(hex: 0x8B5CF6)) } @Test("Eine unlesbare eigene Farbe fällt auf die Voreinstellung zurück") func brokenCustomFallsBack() { // Entsteht, wenn jemand die Voreinstellungen von Hand bearbeitet. let defaults = freshDefaults() defaults.set(AccentOption.customIdentifier, forKey: AccentOption.StorageKey.identifier) defaults.set("kein Hexwert", forKey: AccentOption.StorageKey.customHex) #expect(AccentOption.stored(in: defaults) == .default) } @Test("Der Umgebungswert und das Theme zeigen dieselbe Farbe") @MainActor func environmentMatchesTheme() { // Der eigentliche Punkt: gäbe es zwei getrennte Speicherwege, liefe // einer der beiden irgendwann auf die Standardfarbe — und genau das // sah man als Picker-Wert in Mint neben Schaltern in Violett. let defaults = freshDefaults() AccentOption.custom(RGB(hex: 0x8B5CF6)).store(in: defaults) let theme = FlixyTheme(defaults: defaults) #expect(theme.accentColor == AccentOption.storedAccentColor(in: defaults)) } }