mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Only a deletion the app can point at moves the selection
`correctIfContextRemoved` read a locally missing account as a deleted one and reset the context with `setContext(.local)` — a new timestamp and a pending upload. Now that foreground activation calls it, it ran before the fetch that delivers the account record: a perfectly good remote choice was reset and then published over everybody else's. It now asks `AccountsManager` for a repair keyed on the deletion tombstone, which is the same repair a deletion performs — a signed-in login on the same server when there is one, the replaced choice's timestamp, nothing uploaded. The toast names where the selection actually landed. Adopting a synced context that named a deleted account repaired it but kept whatever upload the previous choice still owed, so the sync engine published the automatic fallback under that choice's timestamp — exactly what not stamping it "now" exists to prevent. The adoption clears the flag, and only on adoption: a local choice that outranks the repair keeps its own upload. The channel button's subscription lookup is fenced to the channel and the round it was asked in, so an answer that lands after the user has walked on — or after the account behind it changed — no longer writes itself into the button on screen. Context and credential changes ask again. A destination toggle is compared against the effective context rather than the raw selection: the menu offers Local precisely when the selected account cannot be written to, and that toggle has to be the one the button then shows. The destination rows forget their checkmarks when the logins change. The answers describe credentials that have been replaced, and the tvOS picker deliberately stays open across exactly that. The subscriptions settings screen counts and exports what it is actually reading from. The fetch behind both already followed the effective context while the branch and the footer followed the selection, so an unavailable account meant local data described as the server's. The legacy `subscriptionAccount` migration refused to adopt a context whose account had not synced yet and was never retried, leaving a fresh install on local until the next launch. A synced account arriving now retries it; the migration's own guards keep it a one-way move that never overrides a choice made in the meantime. The remaining negative-able Piped counts — likes, channel subscribers and videos, playlist video counts — go through `PipedSentinel.count` like the views and upload dates already did.
This commit is contained in:
@@ -338,6 +338,29 @@ final class AccountsManager {
|
||||
NotificationCenter.default.post(name: .dataContextDidChange, object: nil)
|
||||
}
|
||||
|
||||
/// Repairs a context still naming an account this device knows was deleted.
|
||||
///
|
||||
/// The deletion paths repair as they run, so this is for a selection that
|
||||
/// was left behind anyway: a tombstone recorded while the context named
|
||||
/// something else, or a launch that reads both back from disk. Repaired
|
||||
/// exactly like a deletion — same sibling preference, same timestamp, never
|
||||
/// published — because it *is* one, only noticed later.
|
||||
///
|
||||
/// The tombstone is the whole authorisation. An account merely missing from
|
||||
/// the store has not been deleted, it has not arrived, and moving the
|
||||
/// selection on that is how a slow sync costs the user their choice.
|
||||
///
|
||||
/// - Returns: `true` when the selection actually moved.
|
||||
@discardableResult
|
||||
func correctContextIfAccountDeleted() -> Bool {
|
||||
guard case let .account(id) = storedContext.context else { return false }
|
||||
guard deletedAccountIDs.contains(id) else { return false }
|
||||
|
||||
correctContextAfterDeletion(onInstance: deletedAccountInstanceIDs[id])
|
||||
save()
|
||||
return true
|
||||
}
|
||||
|
||||
/// Where a context whose account was deleted should land.
|
||||
///
|
||||
/// Another signed-in login on the same server is far closer to what the user
|
||||
@@ -487,7 +510,19 @@ final class AccountsManager {
|
||||
}
|
||||
|
||||
save()
|
||||
|
||||
// The legacy selection may name exactly this account. Migration refuses
|
||||
// to adopt a context whose account is not there yet, and on a fresh
|
||||
// install the account arrives from iCloud long after the migration ran —
|
||||
// so it is retried here rather than left until the next launch. Free to
|
||||
// repeat: it never runs once a context has been chosen.
|
||||
let contextBefore = storedContext.context
|
||||
migrateLegacyContextIfNeeded()
|
||||
|
||||
NotificationCenter.default.post(name: .accountsDidChange, object: nil)
|
||||
if storedContext.context != contextBefore {
|
||||
NotificationCenter.default.post(name: .dataContextDidChange, object: nil)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -577,6 +612,7 @@ final class AccountsManager {
|
||||
@discardableResult
|
||||
func applySyncedContext(_ incoming: StoredDataContext) -> Bool {
|
||||
var incoming = incoming
|
||||
var wasRepaired = false
|
||||
|
||||
// A tombstone dominates the account everywhere, contexts included. An
|
||||
// offline device can select an account another device has deleted and
|
||||
@@ -592,6 +628,7 @@ final class AccountsManager {
|
||||
context: repairTarget(onInstance: deletedAccountInstanceIDs[accountID]),
|
||||
changedAt: incoming.changedAt
|
||||
)
|
||||
wasRepaired = true
|
||||
|
||||
LoggingService.shared.info(
|
||||
"Ignoring a synced context that names a deleted account",
|
||||
@@ -605,24 +642,38 @@ final class AccountsManager {
|
||||
// constructed, so a synced choice made yesterday would lose to it and a
|
||||
// fresh device would never adopt the selection at all.
|
||||
guard hasStoredContext else {
|
||||
storedContext = incoming
|
||||
hasStoredContext = true
|
||||
save()
|
||||
NotificationCenter.default.post(name: .dataContextDidChange, object: nil)
|
||||
adoptSyncedContext(incoming, wasRepaired: wasRepaired)
|
||||
return false
|
||||
}
|
||||
|
||||
guard storedContext.isOutranked(by: incoming) else {
|
||||
// The local choice is the newer one and survives untouched — a
|
||||
// repair that lost the ordering never gets to clear what that
|
||||
// choice still owes the server.
|
||||
guard incoming.isOutranked(by: storedContext) else { return false }
|
||||
pendingContextUpload = true
|
||||
save()
|
||||
return true
|
||||
}
|
||||
adoptSyncedContext(incoming, wasRepaired: wasRepaired)
|
||||
return false
|
||||
}
|
||||
|
||||
/// Takes on a context that arrived from another device.
|
||||
///
|
||||
/// A repaired value is an automatic fallback rather than a choice, so any
|
||||
/// upload the replaced selection still owed is dropped along with it:
|
||||
/// sending it now would publish this fallback under that choice's
|
||||
/// timestamp, which is exactly what keeping the timestamp is meant to
|
||||
/// prevent — the same reasoning as `correctContextAfterDeletion`.
|
||||
private func adoptSyncedContext(_ incoming: StoredDataContext, wasRepaired: Bool) {
|
||||
storedContext = incoming
|
||||
hasStoredContext = true
|
||||
if wasRepaired {
|
||||
pendingContextUpload = false
|
||||
}
|
||||
save()
|
||||
NotificationCenter.default.post(name: .dataContextDidChange, object: nil)
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Status
|
||||
|
||||
@@ -943,7 +943,7 @@ private struct PipedStreamResponse: Decodable, Sendable {
|
||||
publishedAt: PipedSentinel.uploadedDate(uploaded),
|
||||
publishedText: uploadDate,
|
||||
viewCount: PipedSentinel.count(views),
|
||||
likeCount: likes.map { Int($0) },
|
||||
likeCount: PipedSentinel.count(likes),
|
||||
thumbnails: thumbnails,
|
||||
isLive: livestream ?? false,
|
||||
isUpcoming: false,
|
||||
@@ -1116,8 +1116,8 @@ private struct PipedSearchItem: Decodable, Sendable {
|
||||
id: .global(channelId ?? ""),
|
||||
name: name ?? "",
|
||||
description: description,
|
||||
subscriberCount: subscribers.map { Int($0) },
|
||||
videoCount: videos.map { Int($0) },
|
||||
subscriberCount: PipedSentinel.count(subscribers),
|
||||
videoCount: PipedSentinel.count(videos),
|
||||
thumbnailURL: thumbnail.flatMap { URL(string: $0) },
|
||||
isVerified: uploaderVerified ?? false
|
||||
)
|
||||
@@ -1128,7 +1128,7 @@ private struct PipedSearchItem: Decodable, Sendable {
|
||||
id: .global(playlistId ?? ""),
|
||||
title: name ?? "",
|
||||
author: uploaderName.map { Author(id: "", name: $0) },
|
||||
videoCount: videos.map { Int($0) } ?? 0,
|
||||
videoCount: PipedSentinel.count(videos) ?? 0,
|
||||
thumbnailURL: thumbnail.flatMap { URL(string: $0) }
|
||||
)
|
||||
}
|
||||
@@ -1151,7 +1151,7 @@ private struct PipedChannelResponse: Decodable, Sendable {
|
||||
id: .global(id),
|
||||
name: name,
|
||||
description: description,
|
||||
subscriberCount: subscriberCount.map { Int($0) },
|
||||
subscriberCount: PipedSentinel.count(subscriberCount),
|
||||
thumbnailURL: avatarUrl.flatMap { URL(string: $0) },
|
||||
bannerURL: bannerUrl.flatMap { URL(string: $0) },
|
||||
isVerified: verified ?? false
|
||||
@@ -1221,7 +1221,7 @@ private struct PipedTabPlaylist: Decodable, Sendable {
|
||||
id: .global(playlistId ?? ""),
|
||||
title: name ?? "",
|
||||
author: uploaderName.map { Author(id: uploaderUrl?.replacingOccurrences(of: "/channel/", with: "") ?? "", name: $0) },
|
||||
videoCount: videos.map { Int($0) } ?? 0,
|
||||
videoCount: PipedSentinel.count(videos) ?? 0,
|
||||
thumbnailURL: thumbnail.flatMap { URL(string: $0) }
|
||||
)
|
||||
}
|
||||
@@ -1278,7 +1278,9 @@ private struct PipedPlaylistResponse: Decodable, Sendable {
|
||||
let uploader: String?
|
||||
let uploaderUrl: String?
|
||||
let uploaderAvatar: String?
|
||||
let videos: Int?
|
||||
/// Read as 64-bit like every other Piped count, so the `-1` sentinel goes
|
||||
/// through the same reading as the rest.
|
||||
let videos: Int64?
|
||||
let relatedStreams: [PipedVideoItem]?
|
||||
let thumbnailUrl: String?
|
||||
let nextpage: String?
|
||||
@@ -1303,7 +1305,7 @@ private struct PipedPlaylistResponse: Decodable, Sendable {
|
||||
thumbnailURL: uploaderAvatar.flatMap { URL(string: $0) }
|
||||
)
|
||||
},
|
||||
videoCount: videos ?? validVideos.count,
|
||||
videoCount: PipedSentinel.count(videos) ?? validVideos.count,
|
||||
thumbnailURL: thumbnailUrl.flatMap { URL(string: $0) },
|
||||
videos: validVideos
|
||||
)
|
||||
@@ -1322,14 +1324,14 @@ private struct PipedUserPlaylist: Decodable, Sendable {
|
||||
let name: String
|
||||
let shortDescription: String?
|
||||
let thumbnail: String?
|
||||
let videos: Int?
|
||||
let videos: Int64?
|
||||
|
||||
nonisolated func toPlaylist() -> Playlist {
|
||||
Playlist(
|
||||
id: .global(id),
|
||||
title: name,
|
||||
description: shortDescription,
|
||||
videoCount: videos ?? 0,
|
||||
videoCount: PipedSentinel.count(videos) ?? 0,
|
||||
thumbnailURL: thumbnail.flatMap { URL(string: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,28 +161,30 @@ final class DataContextValidator {
|
||||
|
||||
/// Corrects the context only when the selected account is provably gone.
|
||||
///
|
||||
/// Called after deletions. A timeout, a failed fetch or a missing record
|
||||
/// must never reach here — those are all "unknown", and treating them as
|
||||
/// removal is how a user loses their selection during a slow sync.
|
||||
/// Called after deletions and on foreground activation. A timeout, a failed
|
||||
/// fetch or a record that simply has not arrived must never move anything —
|
||||
/// those are all "unknown", and treating them as removal is how a user
|
||||
/// loses their selection during a slow sync. Only the deletion tombstone
|
||||
/// counts, and the repair it triggers is never published.
|
||||
func correctIfContextRemoved() {
|
||||
// An unresolved identity has not loaded anybody's accounts yet, so an
|
||||
// account "missing" from that state proves nothing — least of all a
|
||||
// deletion, which is the only thing allowed to move the context here.
|
||||
// An unresolved identity has not loaded anybody's accounts yet, so
|
||||
// neither the selection nor the tombstones read here are the ones this
|
||||
// device will end up with.
|
||||
guard accountsManager.isIdentityResolved else { return }
|
||||
guard case let .account(id) = accountsManager.currentContext else { return }
|
||||
guard accountsManager.account(id: id) == nil else { return }
|
||||
|
||||
// Only an explicit deletion clears the account from the store, so
|
||||
// reaching here means the account really is gone.
|
||||
accountsManager.setContext(.local)
|
||||
// The manager owns the decision: it holds the tombstones, and it
|
||||
// repairs the same way an account deleted right now would be — onto a
|
||||
// signed-in login on the same server when there is one, keeping the
|
||||
// replaced choice's timestamp and publishing nothing.
|
||||
guard accountsManager.correctContextIfAccountDeleted() else { return }
|
||||
|
||||
toastManager.showInfo(
|
||||
String(localized: "subscriptions.accountSwitched.title"),
|
||||
subtitle: String(localized: "subscriptions.account.local")
|
||||
subtitle: displayName(for: accountsManager.currentContext)
|
||||
)
|
||||
|
||||
LoggingService.shared.info(
|
||||
"Selected account was deleted — data context reset to local",
|
||||
"Selected account was deleted — data context repaired",
|
||||
category: .general
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ struct ChannelView: View {
|
||||
@State private var isLoadingMore = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var isSubscribed = false
|
||||
/// Which subscription lookup the button state belongs to. A channel change
|
||||
/// or a change of who is being read as bumps it, so a reply from the
|
||||
/// previous one is dropped instead of answering for the channel on screen.
|
||||
@State private var subscriptionGeneration = 0
|
||||
@State private var showingUnsubscribeConfirmation = false
|
||||
#if os(tvOS)
|
||||
@State private var showingSubscriptionDestinations = false
|
||||
@@ -196,6 +200,15 @@ struct ChannelView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
|
||||
loadWatchEntries()
|
||||
}
|
||||
// Whose subscriptions the button speaks for just changed — by selection,
|
||||
// or by the credential behind the selected account being replaced — so
|
||||
// it has to be asked again rather than left showing the last answer.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .dataContextDidChange)) { _ in
|
||||
refreshSubscription()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in
|
||||
refreshSubscription()
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the channel `.searchable` field and its search handlers around the given
|
||||
@@ -1324,8 +1337,13 @@ struct ChannelView: View {
|
||||
/// A destination row toggled something. Only the account on screen changes
|
||||
/// what the main button says, and notification preferences are local and
|
||||
/// keyed by channel, so they follow the same rule.
|
||||
///
|
||||
/// Compared against the *effective* context, which is what the button reads
|
||||
/// from: while the selected account is unavailable the button speaks for
|
||||
/// local data, and the menu offers Local precisely so the user can act on it
|
||||
/// — a toggle there has to be the one the button then shows.
|
||||
private func destinationDidToggle(_ context: DataContext, subscribed: Bool) {
|
||||
guard context == appEnvironment?.accountsManager.currentContext else { return }
|
||||
guard context == appEnvironment?.dataContextValidator.effectiveContext else { return }
|
||||
|
||||
if subscribed,
|
||||
appEnvironment?.settingsManager.defaultNotificationsForNewChannels == true,
|
||||
@@ -2326,9 +2344,19 @@ struct ChannelView: View {
|
||||
isSubscribed = appEnvironment?.dataManager.subscription(for: effectiveChannelID) != nil
|
||||
}
|
||||
|
||||
subscriptionGeneration += 1
|
||||
let generation = subscriptionGeneration
|
||||
|
||||
Task {
|
||||
if let service = appEnvironment?.subscriptionService {
|
||||
isSubscribed = await service.isSubscribed(to: effectiveChannelID)
|
||||
let subscribed = await service.isSubscribed(to: effectiveChannelID)
|
||||
// This view is reused as the user navigates from channel to
|
||||
// channel, and the answer may have been asked for under another
|
||||
// account entirely. Either way it no longer describes the button
|
||||
// it would be written to.
|
||||
guard generation == subscriptionGeneration,
|
||||
(channel?.id.channelID ?? channelID) == effectiveChannelID else { return }
|
||||
isSubscribed = subscribed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,18 @@ struct SubscriptionDestinationRows: View {
|
||||
|
||||
@State private var membership = SubscriptionDestinationMembership()
|
||||
@State private var busy: Set<DataContext> = []
|
||||
/// Bumped when the logins change, so the answers are asked for again.
|
||||
@State private var membershipRound = 0
|
||||
|
||||
/// What a round of membership answers belongs to.
|
||||
///
|
||||
/// The channel alone is not enough: the same channel on the same rows means
|
||||
/// something different once a credential has been replaced, and the picker
|
||||
/// deliberately stays open across such a change on tvOS.
|
||||
private struct MembershipRequest: Equatable {
|
||||
let channel: Channel.ID
|
||||
let round: Int
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ForEach(destinations, id: \.self) { context in
|
||||
@@ -121,9 +133,28 @@ struct SubscriptionDestinationRows: View {
|
||||
// Membership is only asked for once the rows are on screen: an unwarmed
|
||||
// provider answers by fetching that account's whole subscription list,
|
||||
// which is far too much to spend on a menu nobody opened.
|
||||
.task(id: channel.id) {
|
||||
.task(id: MembershipRequest(channel: channel.id, round: membershipRound)) {
|
||||
await loadMembership()
|
||||
}
|
||||
// The credential behind an account was replaced, or the accounts
|
||||
// themselves changed: every checkmark on screen describes the logins
|
||||
// that were there before.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in
|
||||
invalidateMembership()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .accountsDidChange)) { _ in
|
||||
invalidateMembership()
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets what is known and asks again.
|
||||
///
|
||||
/// Forgetting first, rather than waiting for the replacement answers: a row
|
||||
/// toggled in between would otherwise toggle against the previous login's
|
||||
/// membership and do the opposite of what its checkmark promises.
|
||||
private func invalidateMembership() {
|
||||
membership.invalidate()
|
||||
membershipRound += 1
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -61,6 +61,16 @@ struct SubscriptionsSettingsView: View {
|
||||
appEnvironment?.accountsManager.currentContext ?? .local
|
||||
}
|
||||
|
||||
/// What the counts and the export are actually read from.
|
||||
///
|
||||
/// While the selected account is unavailable that is the local store — so a
|
||||
/// count or a file described as the account's would name a source it never
|
||||
/// came from. The selection itself is untouched, and the moment the account
|
||||
/// is usable again this follows it back.
|
||||
private var effectiveContext: DataContext {
|
||||
appEnvironment?.dataContextValidator.effectiveContext ?? currentContext
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
accountSection
|
||||
@@ -68,7 +78,10 @@ struct SubscriptionsSettingsView: View {
|
||||
exportSection
|
||||
localDataSection
|
||||
}
|
||||
.task(id: currentContext) {
|
||||
// Keyed to the effective context, not the selection: an account going
|
||||
// unavailable — or coming back — changes which store the count is read
|
||||
// from without the selection moving at all.
|
||||
.task(id: effectiveContext) {
|
||||
await refreshAccountSubscriptionCount()
|
||||
}
|
||||
.navigationTitle(String(localized: "settings.subscriptions.title"))
|
||||
@@ -282,11 +295,15 @@ struct SubscriptionsSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exports subscriptions of the active account: the local store in local
|
||||
/// mode, or the server's list for Invidious/Piped accounts.
|
||||
/// Exports subscriptions of whatever is being read from: the local store in
|
||||
/// local mode, or the server's list for Invidious/Piped accounts.
|
||||
///
|
||||
/// The branch follows the effective context because the fetch below does —
|
||||
/// branching on the selection instead would take the server path only to
|
||||
/// export local data through it.
|
||||
private func performExport() async {
|
||||
let subscriptions: [Subscription]
|
||||
if currentContext.isLocal {
|
||||
if effectiveContext.isLocal {
|
||||
guard let dataManager else { return }
|
||||
subscriptions = dataManager.allSubscriptions
|
||||
} else {
|
||||
@@ -342,7 +359,7 @@ struct SubscriptionsSettingsView: View {
|
||||
if let cached = subscriptionService.cachedSubscriptionCount {
|
||||
accountSubscriptionCount = cached
|
||||
}
|
||||
if !subscriptionService.currentContext.isLocal {
|
||||
if !effectiveContext.isLocal {
|
||||
if let channels = try? await subscriptionService.fetchSubscriptions() {
|
||||
accountSubscriptionCount = channels.count
|
||||
}
|
||||
|
||||
@@ -157,6 +157,49 @@ struct AccountsManagerTests {
|
||||
#expect(manager2.currentContext == .local)
|
||||
}
|
||||
|
||||
@Test("A legacy selection migrates when its account finally syncs")
|
||||
func legacyContextMigratesOnLateAccountArrival() {
|
||||
let defaults = makeDefaults()
|
||||
let instanceID = UUID()
|
||||
|
||||
let legacy = try! JSONEncoder().encode(SubscriptionAccount.invidious(instanceID))
|
||||
defaults.set(legacy, forKey: "subscriptionAccount")
|
||||
|
||||
let manager = makeManager(defaults: defaults)
|
||||
// On a fresh install the accounts arrive from iCloud long after launch,
|
||||
// so the migration finds nothing to point at and adopts nothing.
|
||||
manager.migrateLegacyContextIfNeeded()
|
||||
#expect(manager.currentContext == .local)
|
||||
|
||||
// The account the legacy value names, arriving at last. A migrated
|
||||
// account reuses its instance's UUID, which is what the legacy mapping
|
||||
// resolves to.
|
||||
manager.applySyncedAccount(Account.migrated(from: instanceID, dateAdded: Date()))
|
||||
|
||||
#expect(manager.currentContext == .account(instanceID))
|
||||
}
|
||||
|
||||
@Test("A choice made while the account was in transit is never overridden")
|
||||
func lateAccountArrivalNeverOverridesAChoice() {
|
||||
let defaults = makeDefaults()
|
||||
let instanceID = UUID()
|
||||
|
||||
let legacy = try! JSONEncoder().encode(SubscriptionAccount.invidious(instanceID))
|
||||
defaults.set(legacy, forKey: "subscriptionAccount")
|
||||
|
||||
let manager = makeManager(defaults: defaults)
|
||||
manager.migrateLegacyContextIfNeeded()
|
||||
|
||||
// The user picked something in the meantime; a guess at what they meant
|
||||
// before the upgrade never replaces what they actually chose after it.
|
||||
let chosen = manager.addAccount(instanceID: UUID())
|
||||
manager.setContext(.account(chosen.id))
|
||||
|
||||
manager.applySyncedAccount(Account.migrated(from: instanceID, dateAdded: Date()))
|
||||
|
||||
#expect(manager.currentContext == .account(chosen.id))
|
||||
}
|
||||
|
||||
@Test("A legacy instance selection resolves to a real account")
|
||||
func legacySelectionResolvesToRealAccount() {
|
||||
let manager = makeManager()
|
||||
@@ -553,8 +596,8 @@ struct DataContextValidatorTests {
|
||||
instances.remove(instance)
|
||||
}
|
||||
|
||||
@Test("Only an account that is genuinely gone moves the context")
|
||||
func correctionRunsOnlyOnAbsence() {
|
||||
@Test("Only a deletion the app can point at moves the context")
|
||||
func correctionRequiresATombstone() {
|
||||
let instances = InstancesManager()
|
||||
var instance = Instance(type: .invidious, url: URL(string: "https://inv-correct.example.com")!)
|
||||
instances.add(instance)
|
||||
@@ -579,15 +622,48 @@ struct DataContextValidatorTests {
|
||||
validator.correctIfContextRemoved()
|
||||
#expect(accounts.currentContext == .account(account.id))
|
||||
|
||||
// An account the store has never heard of is the one case that counts.
|
||||
accounts.setContext(.account(UUID()))
|
||||
// An account the store has never heard of has not been proven deleted:
|
||||
// it may be a record the fetch has yet to deliver, and this runs on
|
||||
// foreground activation — before that fetch.
|
||||
let pending = UUID()
|
||||
accounts.setContext(.account(pending))
|
||||
validator.correctIfContextRemoved()
|
||||
#expect(accounts.currentContext == .local)
|
||||
#expect(accounts.currentContext == .account(pending))
|
||||
|
||||
// Persisted, not merely held in memory: the correction has to survive
|
||||
// the relaunch that would otherwise re-read the dead selection.
|
||||
let (reloaded, _) = makeValidator(instances: instances, defaults: defaults)
|
||||
#expect(reloaded.currentContext == .local)
|
||||
// Nor is it corrected on the way back in: a relaunch reads the same
|
||||
// absence, and the user's choice has to survive it.
|
||||
let (reloaded, reloadedValidator) = makeValidator(instances: instances, defaults: defaults)
|
||||
reloadedValidator.correctIfContextRemoved()
|
||||
#expect(reloaded.currentContext == .account(pending))
|
||||
}
|
||||
|
||||
@Test("A tombstoned selection is repaired without being published")
|
||||
func correctionFollowsTheTombstone() {
|
||||
let instances = InstancesManager()
|
||||
let instance = Instance(type: .invidious, url: URL(string: "https://inv-tombstone.example.com")!)
|
||||
instances.add(instance)
|
||||
defer { instances.remove(instance) }
|
||||
|
||||
let (accounts, validator) = makeValidator(instances: instances)
|
||||
let deleted = accounts.addAccount(instanceID: instance.id)
|
||||
let sibling = accounts.addAccount(instanceID: instance.id)
|
||||
accounts.setStatus(.signedIn, for: sibling.id)
|
||||
|
||||
// Deleted while this device pointed somewhere else, then selected again
|
||||
// by a stale choice that arrived afterwards — the case the deletion
|
||||
// paths themselves never see.
|
||||
accounts.setContext(.local)
|
||||
accounts.applyRemoteTombstone(deleted.id)
|
||||
accounts.setContext(.account(deleted.id))
|
||||
let chosenAt = accounts.storedContext.changedAt
|
||||
|
||||
validator.correctIfContextRemoved()
|
||||
|
||||
// Repaired like any deletion: the signed-in login on the same server,
|
||||
// the replaced choice's timestamp, and nothing left to publish.
|
||||
#expect(accounts.currentContext == .account(sibling.id))
|
||||
#expect(accounts.storedContext.changedAt == chosenAt)
|
||||
#expect(!accounts.pendingContextUpload)
|
||||
}
|
||||
|
||||
@Test("Only accounts the app can actually resolve are offered")
|
||||
@@ -742,6 +818,62 @@ struct DataContextRepairTests {
|
||||
|
||||
#expect(manager.currentContext == .local)
|
||||
}
|
||||
|
||||
@Test("Adopting a repaired synced context drops what the replaced choice owed")
|
||||
func syncedTombstoneRepairIsNeverPublished() {
|
||||
let instances = InstancesManager()
|
||||
let instance = makeInstance("inv")
|
||||
instances.add(instance)
|
||||
defer { instances.remove(instance) }
|
||||
|
||||
let manager = makeManager(instances: instances)
|
||||
let deleted = manager.addAccount(instanceID: instance.id)
|
||||
let sibling = manager.addAccount(instanceID: instance.id)
|
||||
manager.setStatus(.signedIn, for: sibling.id)
|
||||
|
||||
// A choice made here that has not reached the server yet.
|
||||
manager.setContext(.local)
|
||||
manager.removeAccount(deleted.id)
|
||||
#expect(manager.pendingContextUpload)
|
||||
|
||||
let incoming = StoredDataContext(
|
||||
context: .account(deleted.id),
|
||||
changedAt: Date().addingTimeInterval(60)
|
||||
)
|
||||
manager.applySyncedContext(incoming)
|
||||
|
||||
// The repair replaced that choice, so the upload it owed goes with it —
|
||||
// sending it now would publish the fallback under its timestamp.
|
||||
#expect(manager.currentContext == .account(sibling.id))
|
||||
#expect(!manager.pendingContextUpload)
|
||||
}
|
||||
|
||||
@Test("A local choice that outranks a repair keeps its upload")
|
||||
func repairThatLosesLeavesTheChoiceDirty() {
|
||||
let instances = InstancesManager()
|
||||
let instance = makeInstance("inv")
|
||||
instances.add(instance)
|
||||
defer { instances.remove(instance) }
|
||||
|
||||
let manager = makeManager(instances: instances)
|
||||
let deleted = manager.addAccount(instanceID: instance.id)
|
||||
let sibling = manager.addAccount(instanceID: instance.id)
|
||||
manager.setStatus(.signedIn, for: sibling.id)
|
||||
|
||||
manager.removeAccount(deleted.id)
|
||||
manager.setContext(.local)
|
||||
|
||||
// The other device's selection — and so its repair — is older than the
|
||||
// choice made here.
|
||||
let incoming = StoredDataContext(
|
||||
context: .account(deleted.id),
|
||||
changedAt: Date().addingTimeInterval(-60)
|
||||
)
|
||||
#expect(manager.applySyncedContext(incoming))
|
||||
|
||||
#expect(manager.currentContext == .local)
|
||||
#expect(manager.pendingContextUpload)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Account capabilities")
|
||||
|
||||
@@ -41,14 +41,15 @@ private func pipedStreamJSON(
|
||||
return "{\(fields.joined(separator: ", "))}"
|
||||
}
|
||||
|
||||
/// Builds the first page of `/playlists/{id}`.
|
||||
private func pipedPlaylistPageJSON(streams: [String]) -> Data {
|
||||
/// Builds the first page of `/playlists/{id}`. `videos` defaults to the number
|
||||
/// of rows the page carries, which is what a healthy server sends.
|
||||
private func pipedPlaylistPageJSON(streams: [String], videos: Int64? = nil) -> Data {
|
||||
Data("""
|
||||
{
|
||||
"name": "Watch Later",
|
||||
"uploader": "arek",
|
||||
"uploaderUrl": "/channel/UCowner",
|
||||
"videos": \(streams.count),
|
||||
"videos": \(videos ?? Int64(streams.count)),
|
||||
"relatedStreams": [\(streams.joined(separator: ",\n"))],
|
||||
"nextpage": null
|
||||
}
|
||||
@@ -127,6 +128,44 @@ struct PipedSentinelTests {
|
||||
#expect(video.viewCount == 4200)
|
||||
#expect(video.publishedAt == Date(timeIntervalSince1970: 1_609_459_200))
|
||||
}
|
||||
|
||||
@Test("A playlist that says it holds -1 videos is counted by its rows")
|
||||
func playlistCountSentinelFallsBackToTheRows() throws {
|
||||
let page = pipedPlaylistPageJSON(
|
||||
streams: [
|
||||
pipedStreamJSON(videoId: "aaa", title: "First", views: 10, uploaded: nil),
|
||||
pipedStreamJSON(videoId: "bbb", title: "Second", views: 20, uploaded: nil)
|
||||
],
|
||||
videos: -1
|
||||
)
|
||||
|
||||
let snapshot = try PipedAPI.userPlaylistSnapshot(
|
||||
fromPageJSON: [page],
|
||||
playlistID: "playlist-uuid",
|
||||
instanceURL: pipedBaseURL
|
||||
)
|
||||
|
||||
// "-1 videos" would show as a row count in the list, and reads as a
|
||||
// playlist that lost everything in it.
|
||||
#expect(snapshot.playlist.videoCount == 2)
|
||||
}
|
||||
|
||||
@Test("A playlist count the server has beats the row count")
|
||||
func playlistCountIsTakenFromTheServer() throws {
|
||||
// More than the page carries — the rest is behind the pager.
|
||||
let page = pipedPlaylistPageJSON(
|
||||
streams: [pipedStreamJSON(videoId: "aaa", title: "First", views: 10, uploaded: nil)],
|
||||
videos: 42
|
||||
)
|
||||
|
||||
let snapshot = try PipedAPI.userPlaylistSnapshot(
|
||||
fromPageJSON: [page],
|
||||
playlistID: "playlist-uuid",
|
||||
instanceURL: pipedBaseURL
|
||||
)
|
||||
|
||||
#expect(snapshot.playlist.videoCount == 42)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Write Responses
|
||||
|
||||
Reference in New Issue
Block a user