Files
Flaro/Packages/FlixyKit/Tests/FlixyDesignTests/ContrastTests.swift
T
ScarriffleandClaude Opus 5 5ee7bb4e6c Lila statt Mint — die Farbe der Knöpfe passt zum Icon
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>
2026-09-29 20:27:20 +02:00

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))
}
}