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,143 @@
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import HaloPlayback
|
||||
|
||||
@Suite("Aussehen der Untertitel")
|
||||
struct SubtitleStyleTests {
|
||||
|
||||
private func attributes(_ style: SubtitleStyle) -> [String: Any]? {
|
||||
style.textStyleRules.first?.textMarkupAttributes
|
||||
}
|
||||
|
||||
private func colour(_ style: SubtitleStyle, _ key: CFString) -> [Double]? {
|
||||
(attributes(style)?[key as String] as? [NSNumber])?.map(\.doubleValue)
|
||||
}
|
||||
|
||||
// MARK: - Dass überhaupt etwas herauskommt
|
||||
|
||||
/// **Der Test, für den es den ganzen Typ gibt.** Der Initialisierer von
|
||||
/// `AVTextStyleRule` kann fehlschlagen: ein falscher Schlüssel ergibt
|
||||
/// `nil` und damit gar keine Regel — lautlos. Auf dem Bildschirm sieht
|
||||
/// „keine Regel" genauso aus wie „Regel ohne Wirkung".
|
||||
@Test("Jedes Paar ergibt genau eine Regel")
|
||||
func everyPaletteProducesOneRule() {
|
||||
for palette in SubtitleStyle.Palette.allCases {
|
||||
for size in SubtitleStyle.Size.allCases {
|
||||
let regeln = SubtitleStyle(size: size, palette: palette).textStyleRules
|
||||
#expect(regeln.count == 1, "\(palette) / \(size) ergab \(regeln.count) Regeln")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Die Farben
|
||||
|
||||
/// Alpha **zuerst**, dann Rot, Grün, Blau — der häufigste Fehler an
|
||||
/// dieser Stelle, und einer ohne Fehlermeldung.
|
||||
@Test("Die Farbe steht als A, R, G, B da")
|
||||
func colourOrderIsARGB() {
|
||||
let gelb = SubtitleStyle(palette: .yellowOnBlack)
|
||||
let werte = colour(gelb, kCMTextMarkupAttribute_ForegroundColorARGB)
|
||||
|
||||
#expect(werte?.count == 4)
|
||||
#expect(werte?[0] == 1) // Alpha
|
||||
#expect(werte?[1] == 1) // Rot
|
||||
#expect((werte?[3] ?? 1) < 0.5) // Blau — sonst wäre es nicht Gelb
|
||||
}
|
||||
|
||||
@Test("Alle Anteile liegen zwischen null und eins")
|
||||
func coloursAreInRange() {
|
||||
for palette in SubtitleStyle.Palette.allCases {
|
||||
let stil = SubtitleStyle(palette: palette)
|
||||
for schlüssel in [
|
||||
kCMTextMarkupAttribute_ForegroundColorARGB,
|
||||
kCMTextMarkupAttribute_BackgroundColorARGB,
|
||||
] {
|
||||
let werte = colour(stil, schlüssel) ?? []
|
||||
#expect(werte.count == 4, "\(palette): \(werte.count) Anteile")
|
||||
#expect(werte.allSatisfy { $0 >= 0 && $0 <= 1 }, "\(palette): \(werte)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Weiss auf Schwarz ist weiss auf schwarz")
|
||||
func whiteOnBlack() {
|
||||
let stil = SubtitleStyle(palette: .whiteOnBlack)
|
||||
#expect(colour(stil, kCMTextMarkupAttribute_ForegroundColorARGB) == [1, 1, 1, 1])
|
||||
|
||||
let grund = colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB) ?? []
|
||||
#expect((grund.first ?? 0) > 0.5) // sichtbarer Kasten
|
||||
#expect(grund.dropFirst().allSatisfy { $0 == 0 })
|
||||
}
|
||||
|
||||
@Test("Schwarz auf Weiss ist die Umkehrung")
|
||||
func blackOnWhite() {
|
||||
let stil = SubtitleStyle(palette: .blackOnWhite)
|
||||
#expect(colour(stil, kCMTextMarkupAttribute_ForegroundColorARGB) == [1, 0, 0, 0])
|
||||
#expect((colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB)?.first ?? 0) > 0.5)
|
||||
}
|
||||
|
||||
/// **Weglassen ist nicht dasselbe wie durchsichtig.** Ohne den Schlüssel
|
||||
/// gilt, was der Untertitel selbst mitbringt — und Jellyfins WebVTT
|
||||
/// bringt oft einen schwarzen Kasten mit. Dann bliebe „Weiss ohne
|
||||
/// Kasten" wirkungslos, und niemand wüsste warum.
|
||||
@Test("Durchsichtig wird ausdrücklich gesetzt, nicht weggelassen")
|
||||
func transparentIsStated() {
|
||||
for palette in [SubtitleStyle.Palette.whiteOnClear, .yellowOnClear] {
|
||||
let stil = SubtitleStyle(palette: palette)
|
||||
let grund = colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB)
|
||||
|
||||
#expect(grund != nil, "\(palette): der Hintergrund fehlt ganz")
|
||||
#expect(grund?.first == 0, "\(palette): Alpha ist nicht null")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Die Grösse
|
||||
|
||||
/// Ein Prozentwert, keine Punktgrösse. Hundert heisst unverändert — und
|
||||
/// „unverändert" darf nicht wegoptimiert werden, sonst gilt wieder, was
|
||||
/// der Untertitel mitbringt.
|
||||
@Test("Die Grösse ist ein Prozentwert, auch bei hundert")
|
||||
func sizeIsAPercentage() {
|
||||
for size in SubtitleStyle.Size.allCases {
|
||||
let wert = attributes(SubtitleStyle(size: size))?[
|
||||
kCMTextMarkupAttribute_RelativeFontSize as String
|
||||
] as? Double
|
||||
#expect(wert == Double(size.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// Die zweite Grössenangabe bezieht sich auf etwas anderes. Beide zu
|
||||
/// setzen liesse sie sich streiten.
|
||||
@Test("Die andere Grössenangabe bleibt weg")
|
||||
func onlyOneSizeKey() {
|
||||
let gesetzt = attributes(SubtitleStyle(size: .huge)) ?? [:]
|
||||
#expect(gesetzt[
|
||||
kCMTextMarkupAttribute_BaseFontSizePercentageRelativeToVideoHeight as String
|
||||
] == nil)
|
||||
}
|
||||
|
||||
// MARK: - Der Rand
|
||||
|
||||
/// Ohne Kasten steht die Schrift unmittelbar im Bild, und Weiss auf einer
|
||||
/// hellen Szene ist dort unlesbar. Mit Kasten wäre der Schatten Unruhe.
|
||||
@Test("Den Schatten gibt es nur ohne Kasten")
|
||||
func edgeShadowOnlyWithoutBox() {
|
||||
for palette in SubtitleStyle.Palette.allCases {
|
||||
let stil = SubtitleStyle(palette: palette)
|
||||
let rand = attributes(stil)?[kCMTextMarkupAttribute_CharacterEdgeStyle as String]
|
||||
#expect((rand != nil) == stil.wantsEdgeShadow, "\(palette)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Die Vorgabe
|
||||
|
||||
@Test("Ohne Angabe: weisse Schrift, kein Kasten, normale Grösse")
|
||||
func defaults() {
|
||||
let stil = SubtitleStyle()
|
||||
#expect(stil.size == .normal)
|
||||
#expect(stil.palette == .whiteOnClear)
|
||||
#expect(stil.wantsEdgeShadow)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user