mirror of
https://github.com/yattee/yattee.git
synced 2026-11-01 01:17:20 +00:00
Let a refused host count as unreachable and the switcher own every login
Residual round after the macOS fix verification, five closures:
A connection refusal or a failed DNS lookup arrived as APIError.unknown,
which no classifier reads — so an account pointed at a downed instance
never went unreachable, never fell back to local data, never armed a
retry. New APIError.connectionFailed carries the system's own sentence
and lands as unreachable/offline in AccountsManager, InstancesManager
and the credential probe; a refused host now classifies in seconds
instead of never.
Subscriptions' periodic retry armed only for networkError, while the
local fallback's yatteeServerRequired — shown exactly when the account's
server could not be reached — waited on a status expiry nothing was
watching. FeedLoadState.warrantsPeriodicRetry gates on the context's
unavailability, so that screen heals on its own too, and an account that
genuinely has no Yattee Server still never loops.
The switcher dropped every context of a switched-off source, leaving the
toolbar badge pointing at a menu its own selection had vanished from.
listedContexts keeps the rows with their status in the title ("— Source
switched off"), isSwitchable refuses the pick, and the current context
keeps its checkmark.
The sidebar footer's switcher row overflowed the 140 pt column minimum:
the width floor now sits on the menu itself and ViewThatFits drops the
caption — the part the icon already says — before the account name loses
characters.
The channel menu exposed a raw SF Symbol name as its accessibility
title; the label is now a real Label rendered iconOnly, so the AX bridge
hears "Subscription Options".
This commit is contained in:
@@ -122,6 +122,18 @@ struct APIErrorTests {
|
||||
#expect(error.errorDescription == "Rate limited")
|
||||
}
|
||||
|
||||
@Test("Connection failed description is the system's own sentence")
|
||||
func connectionFailedDescription() {
|
||||
// Passed through rather than replaced: "could not connect" and "no such
|
||||
// host" are different things to tell a user, and the OS already says
|
||||
// which one happened.
|
||||
let refused = APIError.connectionFailed("Could not connect to the server.")
|
||||
#expect(refused.errorDescription == "Could not connect to the server.")
|
||||
|
||||
// Emphatically not the offline message: the device's network is fine.
|
||||
#expect(refused.errorDescription != APIError.noConnection.errorDescription)
|
||||
}
|
||||
|
||||
@Test("Unknown error description")
|
||||
func unknownErrorDescription() {
|
||||
let error = APIError.unknown("Something went wrong")
|
||||
@@ -150,6 +162,10 @@ struct APIErrorTests {
|
||||
#expect(APIError.rateLimited(retryAfter: nil) == APIError.rateLimited(retryAfter: nil))
|
||||
#expect(APIError.rateLimited(retryAfter: 30) != APIError.rateLimited(retryAfter: 60))
|
||||
|
||||
#expect(APIError.connectionFailed("refused") == APIError.connectionFailed("refused"))
|
||||
#expect(APIError.connectionFailed("refused") != APIError.connectionFailed("no such host"))
|
||||
#expect(APIError.connectionFailed("refused") != APIError.noConnection)
|
||||
|
||||
#expect(APIError.unknown("msg") == APIError.unknown("msg"))
|
||||
#expect(APIError.unknown("msg1") != APIError.unknown("msg2"))
|
||||
|
||||
@@ -179,6 +195,9 @@ struct APIErrorTests {
|
||||
func retryableErrors() {
|
||||
#expect(APIError.timeout.isRetryable == true)
|
||||
#expect(APIError.noConnection.isRetryable == true)
|
||||
// A refused connection is a moment, not a verdict: the server may be
|
||||
// restarting, the lookup may have missed.
|
||||
#expect(APIError.connectionFailed("Could not connect to the server.").isRetryable == true)
|
||||
#expect(APIError.rateLimited(retryAfter: 60).isRetryable == true)
|
||||
#expect(APIError.httpError(statusCode: 500, message: nil).isRetryable == true)
|
||||
#expect(APIError.httpError(statusCode: 429, message: nil).isRetryable == true)
|
||||
@@ -287,6 +306,80 @@ struct URLBuildingTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URLError Mapping Tests
|
||||
|
||||
/// Fails every request with a prepared `URLError`, so what a test measures is
|
||||
/// the client's own mapping and nothing else.
|
||||
private final class FailingURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
nonisolated(unsafe) static var failure = URLError(.cannotConnectToHost)
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
client?.urlProtocol(self, didFailWithError: Self.failure)
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
@Suite("HTTPClient URLError mapping", .serialized)
|
||||
struct URLErrorMappingTests {
|
||||
|
||||
private func mappedError(for code: URLError.Code) async -> APIError? {
|
||||
FailingURLProtocol.failure = URLError(code)
|
||||
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [FailingURLProtocol.self]
|
||||
let client = HTTPClient(session: URLSession(configuration: configuration))
|
||||
|
||||
do {
|
||||
_ = try await client.fetchData(
|
||||
GenericEndpoint.get("/api/v1/stats"),
|
||||
baseURL: URL(string: "https://refusing.example.com")!
|
||||
)
|
||||
return nil
|
||||
} catch let error as APIError {
|
||||
return error
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A server that refuses or cannot be found maps to connectionFailed")
|
||||
func unreachableHostsMapToConnectionFailed() async {
|
||||
// All three used to fall through to `unknown`, which no status
|
||||
// classifier recognises — so an account pointed at a downed instance
|
||||
// never went unreachable, never fell back to local data and never got
|
||||
// its retry.
|
||||
for code in [URLError.Code.cannotConnectToHost, .cannotFindHost, .dnsLookupFailed] {
|
||||
let error = await mappedError(for: code)
|
||||
guard case .connectionFailed = error else {
|
||||
Issue.record("\(code) mapped to \(String(describing: error)), not connectionFailed")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The mapping still separates offline, timed out and cancelled")
|
||||
func otherCodesKeepTheirMeaning() async {
|
||||
#expect(await mappedError(for: .timedOut) == .timeout)
|
||||
#expect(await mappedError(for: .notConnectedToInternet) == .noConnection)
|
||||
#expect(await mappedError(for: .networkConnectionLost) == .noConnection)
|
||||
#expect(await mappedError(for: .cancelled) == .cancelled)
|
||||
}
|
||||
|
||||
@Test("Unreachable hosts are recorded against the account, offline is too")
|
||||
@MainActor
|
||||
func mappedErrorsReachTheStatusClassifiers() async throws {
|
||||
let error = try #require(await mappedError(for: .cannotConnectToHost))
|
||||
|
||||
// The whole point of the new case: both classifiers have to see it.
|
||||
#expect(CredentialProbeService.classify(error) == .unreachable)
|
||||
#expect(error.isRetryable)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HTTPMethod Tests
|
||||
|
||||
@Suite("HTTPMethod Tests")
|
||||
|
||||
Reference in New Issue
Block a user