diff --git a/Yattee/Services/Subscriptions/DataContextValidator.swift b/Yattee/Services/Subscriptions/DataContextValidator.swift index 3085fbc0..7249de36 100644 --- a/Yattee/Services/Subscriptions/DataContextValidator.swift +++ b/Yattee/Services/Subscriptions/DataContextValidator.swift @@ -187,20 +187,38 @@ final class DataContextValidator { /// Whether picking this context would land the user somewhere usable. /// - /// Not the same question as `validity(of:)`: a rejected or unreachable - /// account is still a perfectly good thing to switch *to* — that is where - /// the user goes to fix it — while a login on a switched-off source is not. + /// The line runs between states that heal on their own and states only the + /// user can end. An unreachable account may be back in a minute — and + /// while the device itself is offline *every* account is unreachable, so + /// refusing those would lock the whole switcher — and a context still + /// resolving is refused nothing it may turn out to deserve. But a rejected + /// or missing credential and a switched-off source stay that way until the + /// user acts, in Settings, not here: picking one only lands on the local + /// fallback with a banner, so the row is listed — status in its title — + /// and refused. func isSwitchable(_ context: DataContext) -> Bool { switch context { case .local: return true - case let .account(id): - guard accountsManager.selectableAccounts.contains(where: { $0.id == id }), - let account = accountsManager.account(id: id) else { + case .account(let id): + guard accountsManager.selectableAccounts.contains(where: { $0.id == id }) else { return false } - return accountsManager.instance(for: account)?.isEnabled ?? false + + switch validity(of: context) { + case .valid: + return true + case .removed: + return false + case let .temporarilyUnavailable(reason): + switch reason { + case .instanceDisabled, .credentialRejected, .credentialMissing: + return false + case .checking, .instancePending, .unreachable: + return true + } + } } } diff --git a/YatteeTests/AccountsManagerTests.swift b/YatteeTests/AccountsManagerTests.swift index 7ffe2657..59992ebb 100644 --- a/YatteeTests/AccountsManagerTests.swift +++ b/YatteeTests/AccountsManagerTests.swift @@ -726,6 +726,7 @@ struct DataContextValidatorTests { let (accounts, validator) = makeValidator(instances: instances) let usable = accounts.addAccount(instanceID: enabled.id) + accounts.setStatus(.signedIn, for: usable.id) _ = accounts.addAccount(instanceID: disabled.id) _ = accounts.addAccount(instanceID: unsupported.id) // An account whose instance has not synced yet has no known @@ -745,6 +746,7 @@ struct DataContextValidatorTests { let (accounts, validator) = makeValidator(instances: instances) let usable = accounts.addAccount(instanceID: enabled.id) + accounts.setStatus(.signedIn, for: usable.id) let switchedOff = accounts.addAccount(instanceID: disabled.id) accounts.setStatus(.signedIn, for: switchedOff.id) accounts.setContext(.account(switchedOff.id)) @@ -763,6 +765,40 @@ struct DataContextValidatorTests { // And the row says why, in the same words the accounts screen uses. #expect(accounts.displayStatus(for: switchedOff) == .instanceDisabled) } + + @Test("Only states the user must fix refuse the switch; self-healing ones do not") + func unusableAccountsAreRefusedBySwitchability() { + let instances = makeTestInstancesManager() + let instance = Instance(type: .invidious, url: URL(string: "https://inv-refusal.example.com")!) + instances.add(instance) + defer { instances.remove(instance) } + + let (accounts, validator) = makeValidator(instances: instances) + let account = accounts.addAccount(instanceID: instance.id) + + // A rejected credential stays rejected until the user signs in again — + // picking the account would only land on the local fallback. + accounts.setStatus(.credentialRejected, for: account.id) + #expect(validator.listedContexts.contains(.account(account.id))) + #expect(!validator.isSwitchable(.account(account.id))) + + // The same for a credential this device simply does not hold. + accounts.setStatus(.credentialMissing, for: account.id) + #expect(!validator.isSwitchable(.account(account.id))) + + // A server outage heals on its own — and while the device itself is + // offline every account reads unreachable, so refusing these would + // lock the whole switcher. + accounts.setStatus(.unreachable, for: account.id) + #expect(validator.isSwitchable(.account(account.id))) + + // Still resolving is refused nothing it may turn out to deserve. + accounts.setStatus(.checking, for: account.id) + #expect(validator.isSwitchable(.account(account.id))) + + accounts.setStatus(.signedIn, for: account.id) + #expect(validator.isSwitchable(.account(account.id))) + } } @Suite("Data context repair", .serialized)