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