Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
7.6 KiB
Swift
207 lines
7.6 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import HaloDesign
|
|
|
|
@Suite("Kontrastkorrektur")
|
|
struct ContrastTests {
|
|
private let canvas = HaloColor.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, HaloColor.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: HaloColor.canvas)
|
|
#expect(corrected == AccentOption.default.color)
|
|
}
|
|
}
|
|
|
|
@Suite("Gespeicherte Akzentfarbe")
|
|
struct AccentStorageTests {
|
|
private func freshDefaults() -> UserDefaults {
|
|
UserDefaults(suiteName: "halo.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 = HaloTheme(defaults: defaults)
|
|
#expect(theme.accentColor == AccentOption.storedAccentColor(in: defaults))
|
|
}
|
|
}
|