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>
144 lines
5.7 KiB
Swift
144 lines
5.7 KiB
Swift
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)
|
|
}
|
|
}
|