Drei Fehler aus dem ersten Praxistest

Anmelde-Adresse wurde doppelt kodiert
  URLComponents.fragment kodiert beim Setzen erneut, aus %5B wurde
  %255B. Plex antwortete mit „Diese Anfrage konnte nicht abgeschlossen
  werden". Die Adresse wird jetzt von Hand zusammengesetzt.

Verbindungssuche hing bis zu anderthalb Minuten
  probeTimeout war deklariert, aber nirgends angewendet — es galt der
  URLSession-Standard von 60 Sekunden. Zusammen mit der Prüfung Rang
  für Rang kostete eine schweigende lokale Adresse die volle Zeit,
  bevor der direkte Weg überhaupt begann. Jetzt laufen alle Adressen
  gleichzeitig, mit Zeitgrenze, und die Rangordnung entscheidet unter
  den erreichbaren.

Kein Zurück während des Ladens
  Die Kopfzeile mit dem Schließen-Knopf wurde nur im Zustand .playing
  gezeichnet. Bei langsamer Verbindung saß man ohne Ausweg vor einem
  Ladekreis. Sie liegt jetzt als eigene Ebene darüber und erscheint
  auch beim Vorbereiten — samt Titel, damit erkennbar bleibt, worauf
  gewartet wird.

Sechs neue Tests, darunter drei, die die Zeitgrenze der
Verbindungssuche an einer schweigenden Adresse nachstellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 17:17:18 +02:00
co-authored by Claude Opus 5
parent 9a69df4635
commit d0a84f35de
9 changed files with 1500 additions and 1237 deletions
@@ -0,0 +1,41 @@
import Foundation
import Testing
@testable import PlexKit
@Suite("Anmelde-Adresse")
struct AuthorizationURLTests {
private let pin = PlexPin(id: 42, code: "5474")
private func url() throws -> URL {
try #require(pin.authorizationURL(clientIdentifier: "abc-123", product: "Flixy"))
}
@Test("Die Klammern der Kontext-Parameter werden nicht doppelt kodiert")
func bracketsAreEncodedExactlyOnce() throws {
// Der Fehler, der plex.tv „Diese Anfrage konnte nicht abgeschlossen
// werden" antworten ließ: eine bereits kodierte Klammer (%5B) wurde
// beim Setzen des Fragments ein zweites Mal kodiert (%255B). Plex sah
// dann einen Parameter namens „context%5Bdevice%5D…" statt
// „context[device]…" und konnte nichts damit anfangen.
let text = try url().absoluteString
#expect(!text.contains("%25"), "doppelt kodiert: \(text)")
#expect(text.contains("context%5Bdevice%5D%5Bproduct%5D=Flixy"))
}
@Test("Code und Client-Kennung stehen im Fragment")
func carriesCodeAndClient() throws {
let text = try url().absoluteString
#expect(text.hasPrefix("https://app.plex.tv/auth#?"))
#expect(text.contains("clientID=abc-123"))
#expect(text.contains("code=5474"))
}
@Test("Produktnamen mit Leerzeichen werden kodiert")
func productNamesWithSpacesAreEncoded() throws {
// Der Name steht noch nicht fest. Wird daraus „Flixy Player", darf
// das Leerzeichen die Adresse nicht zerreißen.
let url = try #require(pin.authorizationURL(clientIdentifier: "abc", product: "Flixy Player"))
#expect(url.absoluteString.contains("Flixy%20Player"))
#expect(!url.absoluteString.contains("Flixy Player"))
}
}
@@ -165,3 +165,93 @@ struct ServerDirectoryTests {
#expect(shared.accessToken != "konto-token")
}
}
@Suite("Verbindungsauswahl unter Zeitdruck")
struct ConnectionSelectorTimeoutTests {
private let serverID = "server-abc-123"
private func identityResponse(_ machineIdentifier: String) -> String {
#"{"MediaContainer":{"machineIdentifier":"\#(machineIdentifier)","version":"1.42.0"}}"#
}
private var resource: PlexResource {
PlexResource(
name: "Wohnzimmer",
clientIdentifier: serverID,
accessToken: "server-token",
connections: [
PlexConnection(uri: "http://192.168.1.10:32400", local: true),
PlexConnection(uri: "https://1-2-3-4.abc.plex.direct:32400"),
PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
]
)
}
@Test("Eine schweigende lokale Adresse blockiert die Anmeldung nicht", .timeLimit(.minutes(1)))
func hangingLocalDoesNotBlock() async {
// Der Fehler aus der Praxis: die Ränge wurden nacheinander geprüft,
// also kostete eine schweigende lokale Adresse erst die volle
// Zeitgrenze, bevor der direkte Weg überhaupt begann. Ergebnis war
// eine Anmeldung, die anderthalb Minuten hing.
let transport = FakeTransport.hosts(
answering: [
"1-2-3-4.abc.plex.direct": identityResponse(serverID),
"relay.plex.direct": identityResponse(serverID),
],
hanging: ["192.168.1.10"]
)
let selector = PlexConnectionSelector(
identity: .test,
transport: transport,
probeTimeout: .milliseconds(200)
)
let started = ContinuousClock.now
let chosen = await selector.selectConnection(for: resource)
let elapsed = ContinuousClock.now - started
// Der direkte Weg gewinnt, weil lokal nicht antwortet.
#expect(chosen?.uri == "https://1-2-3-4.abc.plex.direct:32400")
// Und zwar in der Größenordnung der Zeitgrenze, nicht in deren
// Vielfachem.
#expect(elapsed < .seconds(2), "dauerte \(elapsed)")
}
@Test("Die Vermittlung kommt nur zum Zug, wenn sonst nichts geht", .timeLimit(.minutes(1)))
func relayOnlyWhenNothingElseAnswers() async {
let transport = FakeTransport.hosts(
answering: ["relay.plex.direct": identityResponse(serverID)],
hanging: ["192.168.1.10"]
)
let selector = PlexConnectionSelector(
identity: .test,
transport: transport,
probeTimeout: .milliseconds(200)
)
let chosen = await selector.selectConnection(for: resource)
#expect(chosen?.relay == true)
}
@Test("Eine erreichbare lokale Adresse gewinnt sofort", .timeLimit(.minutes(1)))
func reachableLocalWinsImmediately() async {
// Hier hängen die beiden anderen. Würde die Auswahl auf alle Antworten
// warten, bevor sie entscheidet, wäre auch der gute Fall langsam.
let transport = FakeTransport.hosts(
answering: ["192.168.1.10": identityResponse(serverID)],
hanging: ["1-2-3-4.abc.plex.direct", "relay.plex.direct"]
)
let selector = PlexConnectionSelector(
identity: .test,
transport: transport,
probeTimeout: .seconds(30)
)
let started = ContinuousClock.now
let chosen = await selector.selectConnection(for: resource)
let elapsed = ContinuousClock.now - started
#expect(chosen?.local == true)
#expect(elapsed < .seconds(2), "dauerte \(elapsed)")
}
}
@@ -34,6 +34,30 @@ struct FakeTransport: PlexTransport {
FakeTransport { _ in throw URLError(.cannotConnectToHost) }
}
/// Antwortet je nach Adresse unterschiedlich — und lässt einzelne Adressen
/// ins Leere laufen.
///
/// Bildet den Fall nach, der in der Wirklichkeit am meisten weh tut: eine
/// lokale Adresse, die nicht ablehnt, sondern schweigt. Ein abgelehnter
/// Verbindungsversuch ist sofort vorbei; ein schweigender läuft bis zur
/// Zeitgrenze.
static func hosts(
answering answers: [String: String],
hanging: Set<String> = []
) -> FakeTransport {
FakeTransport { request in
let host = request.url?.host() ?? ""
if hanging.contains(host) {
// Länger als jede Zeitgrenze, die ein Test setzen würde.
try await Task.sleep(for: .seconds(3600))
}
guard let body = answers[host] else {
throw URLError(.cannotConnectToHost)
}
return (Data(body.utf8), response(for: request, status: 200))
}
}
static func response(for request: URLRequest, status: Int) -> HTTPURLResponse {
HTTPURLResponse(
url: request.url ?? URL(string: "http://example.invalid")!,