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:
+1196
-1179
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ struct PlayerView: View {
|
||||
|
||||
switch model.phase {
|
||||
case .preparing:
|
||||
ProgressView().tint(.white)
|
||||
preparing(for: model)
|
||||
|
||||
case .failed(let message):
|
||||
failure(message: message)
|
||||
@@ -37,6 +37,18 @@ struct PlayerView: View {
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
// Die Kopfzeile mit dem Schließen-Knopf liegt über allem und
|
||||
// erscheint auch, solange noch geladen wird. Ohne sie sitzt
|
||||
// man bei einer langsamen Verbindung fest: kein Zurück, kein
|
||||
// Abbrechen, nur ein Ladekreis.
|
||||
if showsControls || !isPlayingPhase(model) {
|
||||
VStack {
|
||||
topBar(for: model)
|
||||
Spacer()
|
||||
}
|
||||
.padding(FlixySpacing.loose)
|
||||
}
|
||||
}
|
||||
}
|
||||
.statusBarHidden(showsControls == false)
|
||||
@@ -61,7 +73,8 @@ struct PlayerView: View {
|
||||
|
||||
private func controls(for model: PlayerModel) -> some View {
|
||||
VStack {
|
||||
topBar(for: model)
|
||||
// Platz für die Kopfzeile, die als eigene Ebene darüber liegt.
|
||||
Spacer().frame(height: 44)
|
||||
Spacer()
|
||||
transport(for: model)
|
||||
Spacer()
|
||||
@@ -171,6 +184,25 @@ struct PlayerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Während geladen wird.
|
||||
///
|
||||
/// Zeigt den Titel mit an, damit erkennbar bleibt, worauf gewartet wird —
|
||||
/// und weil ein nackter Ladekreis nach zehn Sekunden wie ein Absturz
|
||||
/// aussieht.
|
||||
private func preparing(for model: PlayerModel) -> some View {
|
||||
VStack(spacing: FlixySpacing.loose) {
|
||||
ProgressView().tint(.white)
|
||||
Text(model.title)
|
||||
.font(.flixyCardTitle)
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("player.preparing")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
}
|
||||
.padding(FlixySpacing.section)
|
||||
}
|
||||
|
||||
private func failure(message: String) -> some View {
|
||||
VStack(spacing: FlixySpacing.loose) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
@@ -187,6 +219,11 @@ struct PlayerView: View {
|
||||
.padding(FlixySpacing.section)
|
||||
}
|
||||
|
||||
private func isPlayingPhase(_ model: PlayerModel) -> Bool {
|
||||
if case .playing = model.phase { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Ein- und Ausblenden
|
||||
|
||||
private func toggleControls() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import PlexKit
|
||||
|
||||
/// Wiedergabe über den Systemplayer.
|
||||
///
|
||||
@@ -228,13 +229,6 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
||||
}
|
||||
}
|
||||
|
||||
extension Duration {
|
||||
/// Die Dauer als Gleitkommazahl in Sekunden — AVFoundation rechnet darin.
|
||||
public var seconds: Double {
|
||||
Double(components.seconds) + Double(components.attoseconds) / 1e18
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Tonsitzung des Systems.
|
||||
///
|
||||
/// Nur auf iOS vorhanden. Gekapselt statt an drei Stellen mit `#if` versehen —
|
||||
|
||||
@@ -41,13 +41,36 @@ public struct PlexPin: Sendable, Equatable, Decodable {
|
||||
|
||||
/// Die Adresse, unter der der Nutzer den Code bestätigt.
|
||||
///
|
||||
/// Von Hand zusammengesetzt statt über `URLComponents`. Der Grund ist die
|
||||
/// Form, die Plex verlangt: die Parameter stehen im Fragment (hinter dem
|
||||
/// `#`), nicht in der Abfrage, und einer davon heißt buchstäblich
|
||||
/// `context[device][product]`. Die eckigen Klammern müssen genau einmal
|
||||
/// kodiert sein.
|
||||
///
|
||||
/// `URLComponents.fragment` kodiert beim Setzen ein zweites Mal: aus `%5B`
|
||||
/// wird `%255B`. Plex sieht dann einen Parameter, den es nicht kennt, und
|
||||
/// antwortet mit „Diese Anfrage konnte nicht abgeschlossen werden".
|
||||
/// Festgehalten in `AuthorizationURLTests`.
|
||||
///
|
||||
/// `forwardUrl` fehlt bewusst: der Rücksprung in die App wäre bequemer,
|
||||
/// verlangt aber ein eigenes URL-Schema. Solange die App den Code ohnehin
|
||||
/// im Hintergrund abfragt, merkt niemand einen Unterschied.
|
||||
public func authorizationURL(clientIdentifier: String, product: String) -> URL? {
|
||||
var components = URLComponents(string: "https://app.plex.tv/auth")
|
||||
components?.fragment = "?clientID=\(clientIdentifier)&code=\(code)"
|
||||
+ "&context%5Bdevice%5D%5Bproduct%5D=\(product.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? product)"
|
||||
return components?.url
|
||||
// `&`, `=` und `+` müssen raus: sie trennen Parameter bzw. stehen für
|
||||
// ein Leerzeichen. In einem Wert würden sie die Adresse zerreißen.
|
||||
let allowed = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=+"))
|
||||
func encode(_ value: String) -> String? {
|
||||
value.addingPercentEncoding(withAllowedCharacters: allowed)
|
||||
}
|
||||
|
||||
guard let client = encode(clientIdentifier),
|
||||
let code = encode(code),
|
||||
let product = encode(product)
|
||||
else { return nil }
|
||||
|
||||
return URL(string: """
|
||||
https://app.plex.tv/auth#?clientID=\(client)&code=\(code)\
|
||||
&context%5Bdevice%5D%5Bproduct%5D=\(product)
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,17 +24,25 @@ public struct PlexRequest: Sendable, Equatable {
|
||||
public var path: String
|
||||
public var query: [URLQueryItem]
|
||||
public var additionalHeaders: [String: String]
|
||||
/// Wie lange auf Antwort gewartet wird.
|
||||
///
|
||||
/// Der Standardwert von URLSession sind 60 Sekunden. Für einen Server, der
|
||||
/// nicht antwortet, ist das eine Ewigkeit — und bei der Verbindungssuche
|
||||
/// summiert es sich über mehrere Adressen.
|
||||
public var timeout: Duration
|
||||
|
||||
public init(
|
||||
method: Method = .get,
|
||||
path: String,
|
||||
query: [URLQueryItem] = [],
|
||||
additionalHeaders: [String: String] = [:]
|
||||
additionalHeaders: [String: String] = [:],
|
||||
timeout: Duration = .seconds(20)
|
||||
) {
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.query = query
|
||||
self.additionalHeaders = additionalHeaders
|
||||
self.timeout = timeout
|
||||
}
|
||||
|
||||
/// Setzt die Anfrage gegen einen Host zusammen.
|
||||
@@ -57,6 +65,7 @@ public struct PlexRequest: Sendable, Equatable {
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.timeoutInterval = timeout.seconds
|
||||
for (field, value) in identity.headers {
|
||||
request.setValue(value, forHTTPHeaderField: field)
|
||||
}
|
||||
@@ -69,3 +78,14 @@ public struct PlexRequest: Sendable, Equatable {
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
extension Duration {
|
||||
/// Die Dauer als Gleitkommazahl in Sekunden.
|
||||
///
|
||||
/// Foundation und AVFoundation rechnen in `TimeInterval`; `Duration` ist
|
||||
/// der genauere Typ, den der übrige Code verwendet. Diese Brücke steht in
|
||||
/// PlexKit, weil es die unterste Schicht ist — die darüber erben sie.
|
||||
public var seconds: Double {
|
||||
Double(components.seconds) + Double(components.attoseconds) / 1e18
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,16 @@ import Foundation
|
||||
|
||||
/// Sucht den besten erreichbaren Weg zu einem Server.
|
||||
///
|
||||
/// Geht die Wege nach Rang durch — erst lokal, dann direkt von außen, zuletzt
|
||||
/// über die Vermittlung von Plex. Innerhalb eines Rangs laufen die Versuche
|
||||
/// gleichzeitig, weil sie sich gegenseitig nicht ausschließen und ein
|
||||
/// unerreichbarer Weg sonst die volle Zeitüberschreitung kostet, bevor der
|
||||
/// nächste überhaupt beginnt.
|
||||
/// Alle Adressen werden **gleichzeitig** geprüft, nicht Rang für Rang. Der
|
||||
/// Unterschied ist erheblich: geht man die Ränge nacheinander durch, kostet
|
||||
/// eine nicht erreichbare lokale Adresse erst die volle Zeitgrenze, bevor der
|
||||
/// direkte Weg überhaupt beginnt. Aus zwei Wartezeiten wird so eine.
|
||||
///
|
||||
/// Die Rangordnung bleibt trotzdem: gewinnt eine lokale Adresse, wird sofort
|
||||
/// abgebrochen — besser kann es nicht werden. Sonst wird gesammelt und am Ende
|
||||
/// die beste erreichbare genommen. Ein reines „wer zuerst antwortet" wäre
|
||||
/// falsch, weil die Vermittlung von Plex sich oft am schnellsten meldet und
|
||||
/// zugleich die langsamste Leitung hat.
|
||||
public struct PlexConnectionSelector: Sendable {
|
||||
private let identity: PlexClientIdentity
|
||||
private let transport: any PlexTransport
|
||||
@@ -15,7 +20,7 @@ public struct PlexConnectionSelector: Sendable {
|
||||
public init(
|
||||
identity: PlexClientIdentity,
|
||||
transport: any PlexTransport = URLSession.shared,
|
||||
probeTimeout: Duration = .seconds(5)
|
||||
probeTimeout: Duration = .seconds(4)
|
||||
) {
|
||||
self.identity = identity
|
||||
self.transport = transport
|
||||
@@ -23,48 +28,36 @@ public struct PlexConnectionSelector: Sendable {
|
||||
}
|
||||
|
||||
public func selectConnection(for resource: PlexResource) async -> PlexConnection? {
|
||||
guard let token = resource.accessToken else { return nil }
|
||||
guard let token = resource.accessToken, !resource.connections.isEmpty else { return nil }
|
||||
|
||||
let tiers = Dictionary(grouping: resource.connections, by: \.tier)
|
||||
for tier in tiers.keys.sorted() {
|
||||
guard let candidates = tiers[tier] else { continue }
|
||||
if let winner = await race(candidates, token: token, expecting: resource.clientIdentifier) {
|
||||
return winner
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Lässt die Wege eines Rangs gegeneinander antreten; der erste, der sich
|
||||
/// korrekt ausweist, gewinnt.
|
||||
private func race(
|
||||
_ connections: [PlexConnection],
|
||||
token: String,
|
||||
expecting clientIdentifier: String
|
||||
) async -> PlexConnection? {
|
||||
await withTaskGroup(of: PlexConnection?.self) { group in
|
||||
for connection in connections {
|
||||
return await withTaskGroup(of: PlexConnection?.self) { group in
|
||||
for connection in resource.connections {
|
||||
group.addTask {
|
||||
await probe(connection, token: token, expecting: clientIdentifier) ? connection : nil
|
||||
await reachable(connection, token: token, expecting: resource.clientIdentifier)
|
||||
? connection
|
||||
: nil
|
||||
}
|
||||
}
|
||||
|
||||
for await result in group {
|
||||
if let result {
|
||||
// Die übrigen Versuche interessieren nicht mehr. Ohne das
|
||||
// Abbrechen liefe eine unerreichbare Adresse noch bis zur
|
||||
// Zeitüberschreitung weiter und hielte die Aufgabe offen.
|
||||
var best: PlexConnection?
|
||||
for await candidate in group {
|
||||
guard let candidate else { continue }
|
||||
if best.map({ candidate.tier < $0.tier }) ?? true {
|
||||
best = candidate
|
||||
}
|
||||
// Eine lokale Adresse ist nicht zu schlagen. Auf die übrigen
|
||||
// zu warten würde die Anmeldung nur verzögern.
|
||||
if best?.tier == 0 {
|
||||
group.cancelAll()
|
||||
return result
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return best
|
||||
}
|
||||
}
|
||||
|
||||
/// Fragt `/identity` ab — der einzige Endpunkt, der ohne Token antwortet
|
||||
/// und die Kennung des Servers nennt.
|
||||
private func probe(
|
||||
/// Fragt `/identity` ab und gleicht die Server-Kennung ab.
|
||||
private func reachable(
|
||||
_ connection: PlexConnection,
|
||||
token: String,
|
||||
expecting clientIdentifier: String
|
||||
@@ -79,18 +72,42 @@ public struct PlexConnectionSelector: Sendable {
|
||||
)
|
||||
|
||||
do {
|
||||
let identityResponse = try await client.send(
|
||||
PlexRequest(path: "/identity"),
|
||||
as: PlexIdentity.self
|
||||
)
|
||||
// Der Abgleich der Kennung ist keine Förmlichkeit: eine veraltete
|
||||
let response = try await withTimeout(probeTimeout) {
|
||||
try await client.send(
|
||||
PlexRequest(path: "/identity", timeout: probeTimeout),
|
||||
as: PlexIdentity.self
|
||||
)
|
||||
}
|
||||
// Der Abgleich ist keine Förmlichkeit: eine veraltete
|
||||
// plex.direct-Adresse kann auf einen fremden Server zeigen, und
|
||||
// ohne Prüfung würde die App fremde Mediatheken anzeigen.
|
||||
return identityResponse.machineIdentifier == clientIdentifier
|
||||
return response?.machineIdentifier == clientIdentifier
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Bricht ab, wenn die Arbeit zu lange dauert.
|
||||
///
|
||||
/// Zusätzlich zur Zeitgrenze der Anfrage selbst, damit die Grenze auch bei
|
||||
/// einem Transport greift, der sie nicht beachtet — in den Tests etwa.
|
||||
/// Liefert `nil`, wenn die Zeit abgelaufen ist.
|
||||
private func withTimeout<T: Sendable>(
|
||||
_ duration: Duration,
|
||||
_ operation: @escaping @Sendable () async throws -> T
|
||||
) async throws -> T? {
|
||||
try await withThrowingTaskGroup(of: T?.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try? await Task.sleep(for: duration)
|
||||
return nil
|
||||
}
|
||||
|
||||
let first = try await group.next() ?? nil
|
||||
group.cancelAll()
|
||||
return first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Antwort von `/identity`.
|
||||
|
||||
@@ -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")!,
|
||||
|
||||
Reference in New Issue
Block a user