From b08185652e523b672bb3ec217f44ab3c0389d025 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 12 Sep 2026 23:55:07 +0200 Subject: [PATCH] Let the switcher refuse the states only the user can fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a sign-in-required account landed on the local fallback with a banner — the switch achieved nothing, and the fix lives in Settings. isSwitchable now refuses a rejected or missing credential the same way it already refused a switched-off source: the row stays listed with its status in the title, disabled. The line runs between states the user must act on and states that heal on their own. Unreachable stays switchable — while the device itself is offline every account reads unreachable, and refusing those would lock the whole switcher — and so do checking and instance-pending, which are refused nothing they may turn out to deserve. Reaches every presentation through the shared rows, and withholds "Use This Account" on the accounts screen through availableContexts. The tests sign their usable accounts in now that a fresh account under a resolved scope defaults to credentialMissing and is refused. --- .../Subscriptions/DataContextValidator.swift | 32 +++++++++++++---- YatteeTests/AccountsManagerTests.swift | 36 +++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) 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)