Files
Flaro/Packages/FlixyKit/Tests/FlixyPlaybackTests/SubtitleStyleTests.swift
T
ScarriffleandClaude Opus 5 0815432efd Untertitel lassen sich jetzt einstellen
Groesse in vier Stufen, Schrift und Hintergrund als fuenf feste Paare —
Weiss, Weiss auf Schwarz, Schwarz auf Weiss, Gelb, Gelb auf Schwarz. Feste
Paare statt zweier Regler, weil unabhaengige Achsen unlesbare Mischungen
zulassen: Schwarz auf durchsichtig ueber einer Nachtszene ist eine
gueltige Einstellung und eine wertlose.

Die Uebersetzung nach AVFoundation ist eine Tabelle aus Schluesseln, bei
der jeder Fehler lautlos ist — ein falscher Schluessel laesst den
Initialisierer `nil` zurueckgeben, und dann gibt es gar keine Regel. Auf
dem Bildschirm sieht „keine Regel\" genauso aus wie „Regel ohne Wirkung\".
Zehn Tests decken das ab, darunter die drei Fallen: Alpha zuerst in der
Farbe, Prozentwert statt Punktgroesse, und der Hintergrund auch fuer
„durchsichtig\" ausdruecklich gesetzt statt weggelassen — weggelassen gilt
sonst, was Jellyfins WebVTT selbst mitbringt, und das ist oft ein
schwarzer Kasten.

Als Protokollmethode, nicht als Feld an `PlaybackSource`: die wird einmal
beim Oeffnen gelesen, und eine Einstellung, die erst beim naechsten Film
wirkt, haelt jeder fuer kaputt. Angewandt wird an zwei Stellen — am
laufenden Gegenstand und am frisch angelegten. Nur das erste zu tun hiesse,
dass die naechste Folge wieder in der Vorgabe erscheint.

VLC erbt die leere Vorgabe. Die Einstellungsseite sagt beides: dass es fuer
den Standardplayer gilt, und dass eingebrannte Bilduntertitel von Blu-rays
sich nicht gestalten lassen, weil es dort keinen Text gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 21:22:22 +02:00

144 lines
5.7 KiB
Swift

import AVFoundation
import CoreMedia
import Foundation
import Testing
@testable import FlixyPlayback
@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)
}
}