Let the switcher refuse the states only the user can fix

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.
This commit is contained in:
Arkadiusz Fal
2026-09-12 23:55:07 +02:00
parent a04b44e207
commit b08185652e
2 changed files with 61 additions and 7 deletions

View File

@@ -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
}
}
}
}

View File

@@ -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)