Flixy heißt ab hier Halo

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>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -0,0 +1,206 @@
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))
}
}