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:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import HaloDesign
|
||||
|
||||
/// Die Geometrie des Programmrasters.
|
||||
@Suite("Raster-Geometrie")
|
||||
struct GuideLayoutTests {
|
||||
|
||||
/// 13:47 Uhr — bewusst krumm, denn genau das ist der Normalfall.
|
||||
private let now = Date(timeIntervalSince1970: 1_757_684_820)
|
||||
|
||||
private var layout: GuideLayout {
|
||||
GuideLayout(now: now, latestEnd: now.addingTimeInterval(6 * 3_600), pointsPerMinute: 4)
|
||||
}
|
||||
|
||||
@Test("Das Fenster beginnt auf der halben Stunde, nicht jetzt")
|
||||
func snapsToHalfHour() {
|
||||
let start = layout.start
|
||||
let minutes = Calendar(identifier: .gregorian).component(.minute, from: start)
|
||||
|
||||
#expect(minutes % 30 == 0)
|
||||
#expect(start <= now)
|
||||
#expect(now.timeIntervalSince(start) < 30 * 60)
|
||||
}
|
||||
|
||||
@Test("Eine Sendung, die vor dem Fenster begann, klebt am linken Rand")
|
||||
func clampsEarlierStart() {
|
||||
// Sonst säße die laufende Sendung bei negativem Versatz außerhalb —
|
||||
// ausgerechnet die, die man am ehesten sehen will.
|
||||
#expect(layout.x(for: now.addingTimeInterval(-9 * 3_600)) == 0)
|
||||
}
|
||||
|
||||
@Test("Eine angebrochene Sendung behält nur ihren sichtbaren Teil")
|
||||
func clipsToWindow() {
|
||||
let start = layout.start.addingTimeInterval(-1_800)
|
||||
let end = layout.start.addingTimeInterval(1_800)
|
||||
|
||||
// Eine Stunde lang, aber nur die zweite Hälfte liegt im Fenster.
|
||||
#expect(layout.width(from: start, to: end) == 120.0)
|
||||
}
|
||||
|
||||
@Test("Eine Kurzmeldung bleibt lesbar breit")
|
||||
func keepsShortProgramsReadable() {
|
||||
let start = layout.start
|
||||
let end = start.addingTimeInterval(120)
|
||||
|
||||
#expect(layout.width(from: start, to: end) == layout.minimumBlockWidth)
|
||||
}
|
||||
|
||||
@Test("Ohne Programm hat das Raster trotzdem Breite")
|
||||
func emptyGuideHasWidth() {
|
||||
let empty = GuideLayout(now: now, latestEnd: nil, pointsPerMinute: 4)
|
||||
|
||||
#expect(empty.totalWidth > 0)
|
||||
#expect(empty.end.timeIntervalSince(empty.start) == 3 * 3_600)
|
||||
}
|
||||
|
||||
@Test("Das Fenster endet auf der halben Stunde")
|
||||
func snapsEndToHalfHour() {
|
||||
let minutes = Calendar(identifier: .gregorian).component(.minute, from: layout.end)
|
||||
|
||||
#expect(minutes % 30 == 0)
|
||||
#expect(layout.end >= now.addingTimeInterval(6 * 3_600))
|
||||
}
|
||||
|
||||
@Test("Zwei Wochen Vorschau werden auf vierzehn Stunden gekürzt")
|
||||
func capsLongGuides() {
|
||||
let long = GuideLayout(now: now, latestEnd: now.addingTimeInterval(14 * 24 * 3_600))
|
||||
|
||||
#expect(long.end.timeIntervalSince(long.start) == 14 * 3_600)
|
||||
}
|
||||
|
||||
@Test("Die Achse ist halbstündlich und deckt das Fenster ab")
|
||||
func ticksCoverWindow() {
|
||||
// 13:30 bis 19:47, aufgerundet auf 20:00 — dreizehn halbe Stunden.
|
||||
#expect(layout.ticks.count == 13)
|
||||
#expect(layout.ticks.first == layout.start)
|
||||
#expect(layout.ticks.allSatisfy { $0 < layout.end })
|
||||
#expect(layout.tickWidth == 120.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Testing
|
||||
@testable import HaloDesign
|
||||
|
||||
@Suite("Auf welchem Gerät wir laufen")
|
||||
struct HaloPlatformTests {
|
||||
|
||||
/// **Der Test, den es vorher nicht gab — und der gefehlt hat.**
|
||||
///
|
||||
/// `isMac` hatte einen `#else`-Zweig, der `true` zurückgab. Auf tvOS fiel
|
||||
/// er dort hindurch: kein Übersetzungsfehler, keine Warnung, nur ein
|
||||
/// Fernseher, der sich für einen Mac hielt. Zwei Wahrheitswerte, von denen
|
||||
/// beide gelten, sind immer ein Fehler — und dieser eine Ausdruck hätte
|
||||
/// ihn gefangen.
|
||||
///
|
||||
/// Er läuft hier auf macOS und beweist damit nur die Hälfte. Die andere
|
||||
/// Hälfte beweist er, sobald er auf einem Fernseher läuft — und bis dahin
|
||||
/// hält er wenigstens fest, dass die Frage gestellt gehört.
|
||||
@Test("Ein Gerät ist nicht zugleich Mac und Fernseher")
|
||||
func neverBoth() {
|
||||
#expect(!(HaloPlatform.isMac && HaloPlatform.isTV))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import HaloDesign
|
||||
|
||||
/// Wo die schwebende Videokachel landet.
|
||||
@Suite("Kachel-Platzierung")
|
||||
struct MiniPlayerPlacementTests {
|
||||
|
||||
/// Ein iPhone im Hochformat, Kachel 180 × 101.
|
||||
private let placement = MiniPlayerPlacement(
|
||||
bounds: CGSize(width: 390, height: 844),
|
||||
tile: CGSize(width: 180, height: 101),
|
||||
inset: 12
|
||||
)
|
||||
|
||||
@Test("Die nähere Seite gewinnt")
|
||||
func picksTheNearestSide() {
|
||||
#expect(placement.nearestSide(toCenter: CGPoint(x: 50, y: 400)) == .leading)
|
||||
#expect(placement.nearestSide(toCenter: CGPoint(x: 340, y: 400)) == .trailing)
|
||||
}
|
||||
|
||||
@Test("Die Höhe bleibt frei, nicht auf Ecken festgelegt")
|
||||
func keepsTheVerticalPosition() {
|
||||
// Der Kern des Umbaus: vier Ecken sind schnell gebaut, aber im Weg.
|
||||
let middle = placement.center(side: .trailing, verticalFraction: 0.5)
|
||||
let top = placement.center(side: .trailing, verticalFraction: 0)
|
||||
let bottom = placement.center(side: .trailing, verticalFraction: 1)
|
||||
|
||||
#expect(top.y < middle.y)
|
||||
#expect(middle.y < bottom.y)
|
||||
#expect(top.x == middle.x && middle.x == bottom.x)
|
||||
}
|
||||
|
||||
@Test("Anteil und Mittelpunkt rechnen ineinander um")
|
||||
func roundTripsTheFraction() {
|
||||
for fraction in [0.0, 0.25, 0.5, 0.9, 1.0] {
|
||||
let center = placement.center(side: .leading, verticalFraction: fraction)
|
||||
let back = placement.verticalFraction(forCenter: center)
|
||||
|
||||
#expect(abs(back - fraction) < 0.0001)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Der Anteil bleibt zwischen null und eins")
|
||||
func clampsTheFraction() {
|
||||
// Sonst wandert die Kachel beim Drehen aus dem Bild: der Anteil wird
|
||||
// gespeichert, und ein Wert über eins landete unterhalb des Randes.
|
||||
#expect(placement.verticalFraction(forCenter: CGPoint(x: 0, y: -9_000)) == 0)
|
||||
#expect(placement.verticalFraction(forCenter: CGPoint(x: 0, y: 9_000)) == 1)
|
||||
#expect(placement.center(side: .leading, verticalFraction: 5).y <= 844 - 12)
|
||||
}
|
||||
|
||||
@Test("An jeder Seite bleibt der Sicherheitsabstand")
|
||||
func keepsTheInset() {
|
||||
for side in MiniPlayerPlacement.Side.allCases {
|
||||
for fraction in [0.0, 0.5, 1.0] {
|
||||
let center = placement.center(side: side, verticalFraction: fraction)
|
||||
|
||||
#expect(center.x - 90 >= 12)
|
||||
#expect(center.x + 90 <= 390 - 12)
|
||||
#expect(center.y - 50.5 >= 12)
|
||||
#expect(center.y + 50.5 <= 844 - 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Beim Ziehen rutscht nichts über den Rand")
|
||||
func clampsWhileDragging() {
|
||||
// Ohne diese Grenze zieht man die Kachel hinter den Bildschirmrand,
|
||||
// und dort ist sie mit dem Finger nicht mehr einzuholen.
|
||||
let farOut = placement.clamped(center: CGPoint(x: -500, y: -500))
|
||||
#expect(farOut.x == 102.0)
|
||||
#expect(farOut.y == 62.5)
|
||||
|
||||
let alsoFarOut = placement.clamped(center: CGPoint(x: 9_000, y: 9_000))
|
||||
#expect(alsoFarOut.x == 288.0)
|
||||
#expect(alsoFarOut.y == 781.5)
|
||||
}
|
||||
|
||||
@Test("Ein Punkt in der Mitte bleibt, wo er ist")
|
||||
func leavesValidPointsAlone() {
|
||||
let inside = CGPoint(x: 195, y: 400)
|
||||
|
||||
#expect(placement.clamped(center: inside) == inside)
|
||||
}
|
||||
|
||||
@Test("Ein zu enger Bereich schiebt die Kachel nicht hinaus")
|
||||
func survivesTinyBounds() {
|
||||
// Kommt beim Drehen und in der Übergangsphase vor: der Bereich ist
|
||||
// kurzzeitig schmaler als die Kachel. Ein falsch herum geklammerter
|
||||
// Wert schöbe sie dann dauerhaft aus dem Bild.
|
||||
let narrow = MiniPlayerPlacement(
|
||||
bounds: CGSize(width: 100, height: 80),
|
||||
tile: CGSize(width: 180, height: 101),
|
||||
inset: 12
|
||||
)
|
||||
|
||||
#expect(narrow.clamped(center: CGPoint(x: 0, y: 0)) == CGPoint(x: 102, y: 62.5))
|
||||
#expect(narrow.center(side: .trailing, verticalFraction: 1) == CGPoint(x: 102, y: 62.5))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user