Die Akzentfarbe war Mint. Eine hübsche Farbe, die mit der Marke nur nichts zu tun hatte: das Plakat auf dem Homescreen ist violett, und die Knöpfe darin waren grün. `lilac` (#9B8AD4) ist die entsättigte Schwester desselben Violetts. Auf dem dunklen Grund erreicht sie 6,5:1 — die Palette verlangt 4,5:1, und ein Test hält das fest. Die Farbe steht in der Palette, nicht nur als Vorgabe: wer lieber Mint mag, findet es weiterhin in den Einstellungen. Geändert wird damit nur, womit eine frische Installation anfängt. Der Test, der die Vorgabe festhält, ist mitgeändert — genau dafür steht er da: ein Umsortieren der Palette soll sie nicht still verändern, ein bewusster Wechsel schon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
7.7 KiB
Swift
207 lines
7.7 KiB
Swift
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))
|
|
}
|
|
}
|