mirror of
https://github.com/yattee/yattee.git
synced 2026-10-29 07:57:17 +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:
@@ -734,6 +734,35 @@ struct DataContextValidatorTests {
|
||||
|
||||
#expect(validator.availableContexts == [.local, .account(usable.id)])
|
||||
}
|
||||
|
||||
@Test("A switched-off source keeps its rows in the switcher, refused rather than hidden")
|
||||
func disabledSourceStaysListed() {
|
||||
let instances = makeTestInstancesManager()
|
||||
let enabled = Instance(type: .invidious, url: URL(string: "https://inv-listed-a.example.com")!)
|
||||
var disabled = Instance(type: .invidious, url: URL(string: "https://inv-listed-b.example.com")!)
|
||||
for instance in [enabled, disabled] { instances.add(instance) }
|
||||
defer { for instance in [enabled, disabled] { instances.remove(instance) } }
|
||||
|
||||
let (accounts, validator) = makeValidator(instances: instances)
|
||||
let usable = accounts.addAccount(instanceID: enabled.id)
|
||||
let switchedOff = accounts.addAccount(instanceID: disabled.id)
|
||||
accounts.setStatus(.signedIn, for: switchedOff.id)
|
||||
accounts.setContext(.account(switchedOff.id))
|
||||
|
||||
disabled.isEnabled = false
|
||||
instances.update(disabled)
|
||||
|
||||
// The menu still names every context, the selected one included: it is
|
||||
// the control the badged toolbar icon sends the user to.
|
||||
#expect(validator.listedContexts == [.local, .account(usable.id), .account(switchedOff.id)])
|
||||
// What it may not do is offer to switch to it.
|
||||
#expect(validator.availableContexts == [.local, .account(usable.id)])
|
||||
#expect(!validator.isSwitchable(.account(switchedOff.id)))
|
||||
#expect(validator.isSwitchable(.account(usable.id)))
|
||||
#expect(validator.isSwitchable(.local))
|
||||
// And the row says why, in the same words the accounts screen uses.
|
||||
#expect(accounts.displayStatus(for: switchedOff) == .instanceDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Data context repair", .serialized)
|
||||
|
||||
@@ -244,6 +244,36 @@ struct CredentialProbeTests {
|
||||
#expect(harness.status == .signedIn)
|
||||
}
|
||||
|
||||
@Test("A refused connection marks the account unreachable and lifts again")
|
||||
func refusedConnectionMarksUnreachable() async throws {
|
||||
let harness = ProbeHarness(unreachableRetryInterval: 0.05)
|
||||
defer { harness.tearDown() }
|
||||
|
||||
// A server that answers "no" instantly used to arrive as an unknown
|
||||
// error, which nothing classified: the account went on calling itself
|
||||
// signed in, reads never fell back and no retry was ever scheduled.
|
||||
harness.record(APIError.connectionFailed("Could not connect to the server."))
|
||||
#expect(harness.status == .unreachable)
|
||||
|
||||
// Nothing was learned about the credential, so it rides the same expiry.
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
#expect(harness.status == .signedIn)
|
||||
|
||||
// And it is not an auth question: no probe was spent on it.
|
||||
#expect(harness.transportCalls == 0)
|
||||
}
|
||||
|
||||
@Test("A host that does not resolve is unreachable too")
|
||||
func unresolvableHostMarksUnreachable() async throws {
|
||||
let harness = ProbeHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.record(
|
||||
APIError.connectionFailed("A server with the specified hostname could not be found.")
|
||||
)
|
||||
#expect(harness.status == .unreachable)
|
||||
}
|
||||
|
||||
// MARK: - What must not be probed
|
||||
|
||||
@Test("An instance behind basic auth is never probed")
|
||||
@@ -463,6 +493,11 @@ struct CredentialProbeTests {
|
||||
|
||||
#expect(CredentialProbeService.classify(APIError.timeout) == .unreachable)
|
||||
#expect(CredentialProbeService.classify(APIError.noConnection) == .unreachable)
|
||||
// A host that refuses the connection, or does not resolve, answered
|
||||
// nothing about the credential either.
|
||||
#expect(CredentialProbeService.classify(
|
||||
APIError.connectionFailed("Could not connect to the server.")
|
||||
) == .unreachable)
|
||||
|
||||
// A probe that never completed answers nothing.
|
||||
#expect(CredentialProbeService.classify(APIError.cancelled) == .indeterminate)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -53,6 +53,69 @@ struct SubscriptionFeedErrorTests {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Asking again on a timer
|
||||
|
||||
@Test("A network failure keeps trying whatever the context is doing")
|
||||
func networkErrorAlwaysRetries() {
|
||||
let state = FeedLoadState.error(.networkError("The request timed out."))
|
||||
let reasons: [DataContextUnavailability?] = [
|
||||
nil, .checking, .instancePending, .instanceDisabled,
|
||||
.credentialMissing, .credentialRejected, .unreachable
|
||||
]
|
||||
|
||||
for reason in reasons {
|
||||
#expect(state.warrantsPeriodicRetry(unavailability: reason))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The Yattee Server the unreachable fallback asks for is worth asking again")
|
||||
func yatteeServerRequiredRetriesWhileUnreachable() {
|
||||
let state = FeedLoadState.error(.yatteeServerRequired)
|
||||
|
||||
#expect(state.warrantsPeriodicRetry(unavailability: .unreachable))
|
||||
}
|
||||
|
||||
@Test("A context that genuinely needs a Yattee Server never starts a timer")
|
||||
func yatteeServerRequiredDoesNotRetryOtherwise() {
|
||||
let state = FeedLoadState.error(.yatteeServerRequired)
|
||||
// `nil` is local data the user selected: the request is the real answer.
|
||||
// The rest lift through changes this view already reloads on, and none
|
||||
// of them is the silent status expiry the retry exists for.
|
||||
let reasons: [DataContextUnavailability?] = [
|
||||
nil, .checking, .instancePending, .instanceDisabled,
|
||||
.credentialMissing, .credentialRejected
|
||||
]
|
||||
|
||||
for reason in reasons {
|
||||
#expect(!state.warrantsPeriodicRetry(unavailability: reason))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("An account error is not answered by asking the feed again")
|
||||
func notAuthenticatedNeverRetries() {
|
||||
let state = FeedLoadState.error(.notAuthenticated)
|
||||
|
||||
#expect(!state.warrantsPeriodicRetry(unavailability: .unreachable))
|
||||
#expect(!state.warrantsPeriodicRetry(unavailability: .credentialRejected))
|
||||
#expect(!state.warrantsPeriodicRetry(unavailability: nil))
|
||||
}
|
||||
|
||||
@Test("A feed that is not sitting on an error has nothing to retry")
|
||||
func nonErrorStatesNeverRetry() {
|
||||
let states: [FeedLoadState] = [
|
||||
.idle,
|
||||
.loading,
|
||||
.loadingMore,
|
||||
.ready,
|
||||
.partiallyLoaded(readyCount: 1, pendingCount: 2, errorCount: 0)
|
||||
]
|
||||
|
||||
for state in states {
|
||||
#expect(!state.warrantsPeriodicRetry(unavailability: nil))
|
||||
#expect(!state.warrantsPeriodicRetry(unavailability: .unreachable))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Other states
|
||||
|
||||
@Test("States that are not errors have nothing to show")
|
||||
|
||||
Reference in New Issue
Block a user