Phase 0: Projektgerüst, Design-Tokens und Navigation
Legt das Fundament für den Plex-Client an: - XcodeGen-Projekt nach dem Muster von Onyx (com.scarriffleservices, strict concurrency, Projektdatei nicht eingecheckt) - FlixyKit mit vier Modulen: PlexKit, FlixyDesign, FlixyPlayback, FlixyDownloads. macOS steht als Plattform mit drin, damit die Logik-Tests ohne Simulator laufen. - FlixyDesign: dunkle Farb-Tokens, Schriftskala, Poster- und Querformat-Kacheln, Aufmacher-Bild. Die Akzentfarbe fließt über die Umgebung und wird per WCAG-Rechnung lesbar gehalten. - PlexKit: Kopfzeilen-Identität, Transport-Protokoll, Fehlerarten und die Medienmodelle samt nachsichtigem Decoder. - FlixyPlayback: VideoPlayerEngine-Protokoll und PlaybackDecider — die eine Funktion, an der Phase 2 das Transcoding andockt. - Ausfahrbare Seitenleiste auf dem iPhone, Split-View auf dem iPad. - 45 Tests, alle grün. VLCKit 4.0.0-a24 ist vorab geprüft: baut über SPM unter Swift 6 mit strict concurrency. Die iOS-Slice ist mit 239 MB allerdings gewichtig, deshalb bekommt sie in Phase 1 ein eigenes Target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import PlexKit
|
||||
|
||||
/// Lädt eine Antwort-Vorlage aus dem Test-Bundle.
|
||||
private func fixture(_ name: String) throws -> Data {
|
||||
let url = try #require(
|
||||
Bundle.module.url(forResource: "Fixtures/\(name)", withExtension: "json"),
|
||||
"Vorlage \(name).json nicht im Bundle — steht sie in Package.swift unter resources?"
|
||||
)
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
|
||||
@Suite("Antworten lesen")
|
||||
struct PlexDecodingTests {
|
||||
|
||||
@Test("Ein Film mit zwei Fassungen wird vollständig gelesen")
|
||||
func decodesMovieWithTwoVersions() throws {
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("movie-with-two-versions")
|
||||
)
|
||||
|
||||
let movie = try #require(container.content.first)
|
||||
#expect(movie.ratingKey == "45231")
|
||||
#expect(movie.kind == .movie)
|
||||
#expect(movie.title == "Blade Runner 2049")
|
||||
#expect(movie.year == 2017)
|
||||
#expect(movie.hasMultipleVersions)
|
||||
#expect(movie.media.count == 2)
|
||||
}
|
||||
|
||||
@Test("Die Fassung mit der höchsten Bitrate wird vorgewählt")
|
||||
func prefersHighestBitrateVersion() throws {
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("movie-with-two-versions")
|
||||
)
|
||||
let movie = try #require(container.content.first)
|
||||
let preferred = try #require(movie.preferredMedia)
|
||||
|
||||
#expect(preferred.videoResolution == "4k")
|
||||
#expect(preferred.bitrate == 58420)
|
||||
}
|
||||
|
||||
@Test("Der Fortschritt wird als Anteil berechnet")
|
||||
func computesWatchProgress() throws {
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("movie-with-two-versions")
|
||||
)
|
||||
let movie = try #require(container.content.first)
|
||||
let progress = try #require(movie.watchProgress)
|
||||
|
||||
// 4 892 000 von 9 784 000 Millisekunden — genau die Hälfte.
|
||||
#expect(abs(progress - 0.5) < 0.001)
|
||||
}
|
||||
|
||||
@Test("Spuren werden nach Art getrennt")
|
||||
func separatesStreamsByKind() throws {
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("movie-with-two-versions")
|
||||
)
|
||||
let part = try #require(container.content.first?.preferredMedia?.parts.first)
|
||||
|
||||
#expect(part.videoStream?.codec == "hevc")
|
||||
#expect(part.audioStreams.count == 2)
|
||||
#expect(part.subtitleStreams.count == 2)
|
||||
#expect(part.selectedAudioStream?.language == "Deutsch")
|
||||
}
|
||||
|
||||
@Test("Untertitel als eigene Datei werden erkannt")
|
||||
func detectsExternalSubtitles() throws {
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("movie-with-two-versions")
|
||||
)
|
||||
let part = try #require(container.content.first?.preferredMedia?.parts.first)
|
||||
let subtitles = part.subtitleStreams
|
||||
|
||||
// Die SRT-Spur liegt als Datei daneben und lässt sich deshalb auch bei
|
||||
// DirectPlay dazuschalten; die PGS-Spur steckt im Container.
|
||||
#expect(subtitles.first { $0.codec == "subrip" }?.isExternal == true)
|
||||
#expect(subtitles.first { $0.codec == "pgs" }?.isExternal == false)
|
||||
#expect(subtitles.first { $0.codec == "pgs" }?.isForced == true)
|
||||
}
|
||||
|
||||
@Test("Folgen ohne analysierte Datei brechen das Lesen nicht ab")
|
||||
func toleratesUnanalyzedEpisodes() throws {
|
||||
// Der wichtigste Fall aus der Praxis: Plex liefert Einträge aus, bevor
|
||||
// es die Dateien durchgesehen hat. Ein nicht-optionales Feld würde
|
||||
// hier die ganze Staffel unlesbar machen statt nur eine Angabe.
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("episodes-unanalyzed")
|
||||
)
|
||||
|
||||
#expect(container.content.count == 2)
|
||||
|
||||
let first = try #require(container.content.first)
|
||||
#expect(first.media.first?.parts.first?.streams.isEmpty == true)
|
||||
|
||||
// Die zweite Folge hat gar keinen `Media`-Block.
|
||||
let second = try #require(container.content.last)
|
||||
#expect(second.media.isEmpty)
|
||||
#expect(second.title == "Die zweite Folge")
|
||||
#expect(second.index == 2)
|
||||
}
|
||||
|
||||
@Test("Serienzugehörigkeit von Folgen wird gelesen")
|
||||
func decodesEpisodeHierarchy() throws {
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("episodes-unanalyzed")
|
||||
)
|
||||
let episode = try #require(container.content.first)
|
||||
|
||||
#expect(episode.kind == .episode)
|
||||
#expect(episode.index == 1)
|
||||
#expect(episode.parentIndex == 1)
|
||||
#expect(episode.grandparentTitle == "Eine Serie")
|
||||
#expect(episode.grandparentRatingKey == "77700")
|
||||
}
|
||||
|
||||
@Test("Ungesehene Titel haben keinen Fortschritt")
|
||||
func unwatchedItemsHaveNoProgress() throws {
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("episodes-unanalyzed")
|
||||
)
|
||||
// Ohne diese Unterscheidung bekäme jede ungesehene Kachel einen
|
||||
// Fortschrittsbalken der Breite null — sichtbarer Strich, keine Aussage.
|
||||
#expect(container.content.allSatisfy { $0.watchProgress == nil })
|
||||
#expect(container.content.allSatisfy { !$0.isWatched })
|
||||
}
|
||||
|
||||
@Test("Kaputte Antworten melden, woran es lag")
|
||||
func decodingErrorsCarryDetail() {
|
||||
let garbage = Data(#"{"MediaContainer":{"Metadata":[{"title":"ohne ratingKey"}]}}"#.utf8)
|
||||
|
||||
#expect(throws: PlexError.self) {
|
||||
try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: garbage)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: garbage)
|
||||
Issue.record("Hätte scheitern müssen")
|
||||
} catch let error as PlexError {
|
||||
guard case .decoding(let description) = error else {
|
||||
Issue.record("Falsche Fehlerart: \(error)")
|
||||
return
|
||||
}
|
||||
// Der Schlüsselpfad muss drinstehen — sonst ist beim Nachziehen
|
||||
// einer geänderten Plex-Version nicht zu erkennen, welches Feld
|
||||
// gewandert ist.
|
||||
#expect(description.contains("ratingKey"))
|
||||
} catch {
|
||||
Issue.record("Unerwarteter Fehler: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("HTTP-Status übersetzen")
|
||||
struct PlexResponseValidationTests {
|
||||
private func response(_ status: Int) -> HTTPURLResponse {
|
||||
HTTPURLResponse(
|
||||
url: URL(string: "http://server:32400/library/sections")!,
|
||||
statusCode: status,
|
||||
httpVersion: nil,
|
||||
headerFields: nil
|
||||
)!
|
||||
}
|
||||
|
||||
@Test("Erfolgsstatus geben die Daten durch", arguments: [200, 201, 204])
|
||||
func successPassesDataThrough(status: Int) throws {
|
||||
let payload = Data("inhalt".utf8)
|
||||
#expect(try PlexResponse.validate(data: payload, response: response(status)) == payload)
|
||||
}
|
||||
|
||||
@Test("401 und 403 führen zurück zur Anmeldung", arguments: [401, 403])
|
||||
func authenticationFailuresMapToUnauthorized(status: Int) {
|
||||
#expect(throws: PlexError.unauthorized) {
|
||||
try PlexResponse.validate(data: Data(), response: response(status))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("404 ist ein fehlender Eintrag")
|
||||
func notFoundMaps() {
|
||||
#expect(throws: PlexError.notFound) {
|
||||
try PlexResponse.validate(data: Data(), response: response(404))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Serverfehler dürfen wiederholt werden, Anfragefehler nicht")
|
||||
func retryabilityFollowsStatusClass() {
|
||||
#expect(PlexError.server(status: 503).isRetryable)
|
||||
#expect(PlexError.server(status: 500).isRetryable)
|
||||
#expect(!PlexError.server(status: 400).isRetryable)
|
||||
#expect(!PlexError.unauthorized.isRetryable)
|
||||
#expect(PlexError.transport(description: "Zeitüberschreitung").isRetryable)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user