Let a row show the metadata it was handed

This commit is contained in:
Arkadiusz Fal
2026-09-07 07:30:00 +02:00
parent 69aed50012
commit dca8e2fbe4
12 changed files with 223 additions and 79 deletions

View File

@@ -26,7 +26,7 @@ enum InstanceStatus: Equatable {
final class InstancesManager { final class InstancesManager {
// MARK: - Storage // MARK: - Storage
private let localDefaults = UserDefaults.standard private let localDefaults: UserDefaults
private let ubiquitousStore = NSUbiquitousKeyValueStore.default private let ubiquitousStore = NSUbiquitousKeyValueStore.default
private let instancesKey = "configuredInstances" private let instancesKey = "configuredInstances"
private let activeInstanceKey = "activeInstanceID" private let activeInstanceKey = "activeInstanceID"
@@ -45,8 +45,13 @@ final class InstancesManager {
// MARK: - Initialization // MARK: - Initialization
init(settingsManager: SettingsManager? = nil) { /// - Parameter defaults: where the configured instances are read and
/// written. The app's own defaults, except under test: a manager built on
/// the shared ones edits the list the app on that device is using, which
/// is how test fixtures ended up in it.
init(settingsManager: SettingsManager? = nil, defaults: UserDefaults = .standard) {
self.settingsManager = settingsManager self.settingsManager = settingsManager
self.localDefaults = defaults
loadInstances() loadInstances()
loadActiveInstance() loadActiveInstance()

View File

@@ -85,6 +85,41 @@ struct Video: Identifiable, Codable, Sendable {
self.relatedVideos = relatedVideos self.relatedVideos = relatedVideos
} }
// MARK: - Standing in for metadata
/// A video that is nothing but its own ID, for a list that knows a video was
/// watched before it knows anything else about it.
///
/// An account's server-side history is a list of IDs; the rows have to be
/// on screen while their metadata is still being fetched, and one whose
/// fetch fails is still part of that history.
static func metadataPlaceholder(for videoID: String) -> Video {
Video(
id: .global(videoID),
title: videoID,
description: nil,
author: Author(id: "", name: ""),
duration: 0,
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
/// Whether the title is only the video's own ID standing in for one.
///
/// Anything showing this video beside details it fetched itself should
/// prefer the fetched title: a bare ID is not a name anybody chose, and the
/// screen would otherwise keep it under a real description and thumbnail.
var hasPlaceholderTitle: Bool {
title == id.videoID
}
// MARK: - Computed Properties // MARK: - Computed Properties
var bestThumbnail: Thumbnail? { var bestThumbnail: Thumbnail? {
@@ -429,17 +464,39 @@ extension Video {
// MARK: - Equatable & Hashable // MARK: - Equatable & Hashable
extension Video: Equatable { extension Video: Equatable {
/// Custom Equatable implementation that only compares by ID for performance. /// Compares what the video *is*, not only which video it is.
/// Two videos with the same ID are considered equal during SwiftUI diffing. ///
/// This avoids expensive comparisons of all properties (including nested Author and [Thumbnail]). /// Being the same video is not the same as being the same *description* of
/// it. One ID can arrive first as a placeholder standing in for metadata
/// nobody has fetched yet and again as the real thing, and SwiftUI decides
/// whether a view needs redrawing by comparing the values it was given — so
/// equality by ID alone leaves a list showing the placeholder it was handed
/// first, however many times it is given the answer.
///
/// The ID is compared first, which is what nearly every comparison differs
/// in and settles it in one step.
static func == (lhs: Video, rhs: Video) -> Bool { static func == (lhs: Video, rhs: Video) -> Bool {
lhs.id == rhs.id lhs.id == rhs.id
&& lhs.title == rhs.title
&& lhs.author == rhs.author
&& lhs.duration == rhs.duration
&& lhs.viewCount == rhs.viewCount
&& lhs.likeCount == rhs.likeCount
&& lhs.publishedAt == rhs.publishedAt
&& lhs.publishedText == rhs.publishedText
&& lhs.isLive == rhs.isLive
&& lhs.isUpcoming == rhs.isUpcoming
&& lhs.scheduledStartTime == rhs.scheduledStartTime
&& lhs.thumbnails == rhs.thumbnails
&& lhs.description == rhs.description
&& lhs.relatedVideos == rhs.relatedVideos
} }
} }
extension Video: Hashable { extension Video: Hashable {
/// Custom Hashable implementation consistent with Equatable. /// Hashed by ID alone, which stays coarser than equality on purpose: it is
/// Only hashes the ID since equality is determined by ID alone. /// what every lookup is by, and hashing equal for values that turn out to
/// differ is exactly what a hash is allowed to do.
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
} }

View File

@@ -72,6 +72,26 @@ struct HistoryListView: View {
@State private var hydrationTasks: [String: Task<Void, Never>] = [:] @State private var hydrationTasks: [String: Task<Void, Never>] = [:]
/// The rows waiting for a fetch slot, in the order they asked for one. /// The rows waiting for a fetch slot, in the order they asked for one.
@State private var hydrationQueue: [String] = [] @State private var hydrationQueue: [String] = []
/// Every row that has asked for metadata and has not been given any.
///
/// Unlike ``hydrationAttempted`` this survives a reload, and it is what a
/// reload owes a fetch to afterwards: the work it cancelled would otherwise
/// be nobody's to start again.
@State private var hydrationPending: Set<String> = []
/// The rows on screen right now.
///
/// A row asks for its metadata as it appears, and that is the only moment
/// it asks — so an appearance that lands while the account's history is not
/// what the screen is reading (a status still settling, an instance not yet
/// arrived) asks for nothing, and nothing would ever ask again. Remembering
/// what is on screen lets the next load make good on it.
@State private var visibleRowIDs: Set<String> = []
/// Which set of fetches the slots belong to.
///
/// Bumped whenever the in-flight fetches are dropped, so a task from before
/// that cannot free a slot the load after it has since given to a fetch of
/// the same video.
@State private var hydrationEpoch = 0
/// How many IDs one page of server history asks for. /// How many IDs one page of server history asks for.
private static let serverPageSize = 60 private static let serverPageSize = 60
@@ -361,8 +381,15 @@ struct HistoryListView: View {
} }
// Selecting another account — or local — changes whose history this is, // Selecting another account — or local — changes whose history this is,
// and the previous rows may not be shown for a moment longer. // and the previous rows may not be shown for a moment longer.
//
// Compared rather than taken on trust: the selection is announced by
// more than the switcher — a repair, an arriving sync, the first
// resolution of the stored choice — and most of those settle on the
// context that is already on screen. Reloading for one of those would
// throw away rows and half-finished metadata to fetch the same history
// again.
.onReceive(NotificationCenter.default.publisher(for: .dataContextDidChange)) { _ in .onReceive(NotificationCenter.default.publisher(for: .dataContextDidChange)) { _ in
startLoad() reloadIfEffectiveContextMoved()
} }
// The same, without the context moving: the credential behind the // The same, without the context moving: the credential behind the
// account was replaced, so the history may now be someone else's. // account was replaced, so the history may now be someone else's.
@@ -531,7 +558,10 @@ struct HistoryListView: View {
loadMoreVideos: loadMoreHistoryCallback loadMoreVideos: loadMoreHistoryCallback
) )
.onAppear { .onAppear {
hydrateIfNeeded(row) rowDidAppear(row)
}
.onDisappear {
visibleRowIDs.remove(row.videoID)
} }
} }
@@ -572,7 +602,10 @@ struct HistoryListView: View {
loadMoreVideos: loadMoreHistoryCallback loadMoreVideos: loadMoreHistoryCallback
) )
.onAppear { .onAppear {
hydrateIfNeeded(row) rowDidAppear(row)
}
.onDisappear {
visibleRowIDs.remove(row.videoID)
} }
} }
} }
@@ -626,13 +659,24 @@ struct HistoryListView: View {
// Recorded on every load, not only on a context change: the first load // Recorded on every load, not only on a context change: the first load
// of a session has to establish what is on screen, or the first status // of a session has to establish what is on screen, or the first status
// change would look like a move and reload for nothing. // change would look like a move and reload for nothing.
loadedContext = appEnvironment.dataContextValidator.effectiveContext let context = appEnvironment.dataContextValidator.effectiveContext
let movedContext = loadedContext != nil && loadedContext != context
loadedContext = context
loadGeneration += 1 loadGeneration += 1
serverErrorMessage = nil serverErrorMessage = nil
isLoadingPage = false isLoadingPage = false
hydrationAttempted.removeAll() hydrationAttempted.removeAll()
pageFloor = 1 pageFloor = 1
// What the rows are owed, and which of them are on screen, only means
// anything while they are the same rows. Another history's IDs would
// never match a row again.
if movedContext {
hydrationPending.removeAll()
visibleRowIDs.removeAll()
}
guard case let .server(account) = displayMode else { guard case let .server(account) = displayMode else {
hasMorePages = false hasMorePages = false
loadHistory() loadHistory()
@@ -693,6 +737,12 @@ struct HistoryListView: View {
// view — which, being filtered out, they never are. // view — which, being filtered out, they never are.
if !searchText.isEmpty { if !searchText.isEmpty {
hydrateAllLoaded(account: account) hydrateAllLoaded(account: account)
} else {
// Rows that were already asking for metadata when this load
// replaced them. They are back, unchanged and still without a
// title, and nothing is going to announce their appearance a
// second time.
resumeHydration(account: account)
} }
} catch is CancellationError { } catch is CancellationError {
// Another load is taking over — it owns the state now. // Another load is taking over — it owns the state now.
@@ -738,35 +788,18 @@ struct HistoryListView: View {
return HistoryRow(videoID: id, video: entry.toVideo(), entry: entry, isHydrated: true) return HistoryRow(videoID: id, video: entry.toVideo(), entry: entry, isHydrated: true)
} }
return HistoryRow(videoID: id, video: Self.placeholder(for: id), entry: nil, isHydrated: false) // The entry is never dropped for want of a title: the account watched
} // this video, and a history missing rows it cannot describe is a wrong
// history.
/// The row a server ID stands as until its metadata arrives — or instead of return HistoryRow(videoID: id, video: .metadataPlaceholder(for: id), entry: nil, isHydrated: false)
/// it, when the fetch fails.
///
/// The entry is never dropped for want of a title: the account watched this
/// video, and a history missing rows it cannot describe is a wrong history.
private static func placeholder(for videoID: String) -> Video {
Video(
id: .global(videoID),
title: videoID,
description: nil,
author: Author(id: "", name: ""),
duration: 0,
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
} }
// MARK: - Hydration // MARK: - Hydration
private func hydrateIfNeeded(_ row: HistoryRow) { /// A row came on screen: it is noted as visible, and asks for its metadata.
private func rowDidAppear(_ row: HistoryRow) {
visibleRowIDs.insert(row.videoID)
guard case let .server(account) = displayMode else { return } guard case let .server(account) = displayMode else { return }
hydrate(row, account: account) hydrate(row, account: account)
} }
@@ -784,6 +817,21 @@ struct HistoryListView: View {
} }
} }
/// Makes good on the rows a load left without metadata.
///
/// The ones whose fetch the reload cancelled, and the ones on screen that
/// never got to ask — a row appearing is the only thing that asks, and an
/// appearance during a moment when this was not the account's history at
/// all asks for nothing. Bounded by what has actually been looked at, and
/// each row is still only ever fetched once per load.
private func resumeHydration(account: Account) {
for row in rows
where !row.isHydrated
&& (hydrationPending.contains(row.videoID) || visibleRowIDs.contains(row.videoID)) {
hydrate(row, account: account)
}
}
/// Asks for one row's metadata, as the row appears. /// Asks for one row's metadata, as the row appears.
/// ///
/// The request itself waits its turn: the row joins the queue, and the /// The request itself waits its turn: the row joins the queue, and the
@@ -796,6 +844,7 @@ struct HistoryListView: View {
else { return } else { return }
hydrationAttempted.insert(row.videoID) hydrationAttempted.insert(row.videoID)
hydrationPending.insert(row.videoID)
hydrationQueue.append(row.videoID) hydrationQueue.append(row.videoID)
pumpHydration(account: account) pumpHydration(account: account)
} }
@@ -809,7 +858,8 @@ struct HistoryListView: View {
let instance = appEnvironment.accountsManager.instance(for: account) let instance = appEnvironment.accountsManager.instance(for: account)
else { return } else { return }
let generation = loadGeneration let epoch = hydrationEpoch
let accountID = account.id
while hydrationTasks.count < Self.hydrationConcurrency, !hydrationQueue.isEmpty { while hydrationTasks.count < Self.hydrationConcurrency, !hydrationQueue.isEmpty {
let videoID = hydrationQueue.removeFirst() let videoID = hydrationQueue.removeFirst()
@@ -817,8 +867,13 @@ struct HistoryListView: View {
hydrationTasks[videoID] = Task { hydrationTasks[videoID] = Task {
do { do {
let video = try await appEnvironment.contentService.video(id: videoID, instance: instance) let video = try await appEnvironment.contentService.video(id: videoID, instance: instance)
guard generation == loadGeneration else { return } // Whose history this describes is what decides whether it
apply(video, to: videoID) // may be shown — not which load asked for it. A reload of
// the same account puts the same rows back, and metadata
// fetched for one of them is still that row's metadata.
if isShowing(accountID: accountID) {
apply(video, to: videoID)
}
} catch { } catch {
// The row keeps its placeholder. A video the account watched // The row keeps its placeholder. A video the account watched
// is part of its history whether or not this instance can // is part of its history whether or not this instance can
@@ -826,16 +881,22 @@ struct HistoryListView: View {
} }
// A finished fetch frees its slot for the next row — but only // A finished fetch frees its slot for the next row — but only
// for the load it belongs to. A later one has already emptied // among the fetches it started with. A later set has already
// this queue and started its own, and giving back a slot there // emptied this queue and started its own, and giving back a
// would drop a task that is still running. // slot there would drop a task that is still running.
guard generation == loadGeneration else { return } guard epoch == hydrationEpoch else { return }
hydrationTasks[videoID] = nil hydrationTasks[videoID] = nil
pumpHydration(account: account) pumpHydration(account: account)
} }
} }
} }
/// Whether the rows on screen are still that account's history.
private func isShowing(accountID: UUID) -> Bool {
guard case let .server(account) = displayMode else { return false }
return account.id == accountID
}
/// Drops everything the current load was still going to fetch, and stops /// Drops everything the current load was still going to fetch, and stops
/// what it had already started. /// what it had already started.
private func cancelHydration() { private func cancelHydration() {
@@ -845,9 +906,12 @@ struct HistoryListView: View {
hydrationTasks.removeAll() hydrationTasks.removeAll()
hydrationQueue.removeAll() hydrationQueue.removeAll()
hydrationEpoch += 1
} }
private func apply(_ video: Video, to videoID: String) { private func apply(_ video: Video, to videoID: String) {
hydrationPending.remove(videoID)
guard let index = rows.firstIndex(where: { $0.videoID == videoID }) else { return } guard let index = rows.firstIndex(where: { $0.videoID == videoID }) else { return }
rows[index].video = video rows[index].video = video

View File

@@ -883,7 +883,13 @@ struct VideoInfoView: View {
// Prefer freshly loaded details for thumbnail (so expired proxy URLs get replaced) and avatar. // Prefer freshly loaded details for thumbnail (so expired proxy URLs get replaced) and avatar.
// Keep `video` as the card's stable identity so title/channel don't flicker. // Keep `video` as the card's stable identity so title/channel don't flicker.
let detailedVideo = loadedVideoDetails[video.id.videoID] let detailedVideo = loadedVideoDetails[video.id.videoID]
videoCard(for: video, thumbnailFrom: detailedVideo, authorFrom: detailedVideo, isLoadingMore: isLoadingMoreVideos && isCurrent, showTitle: true, isCurrent: isCurrent) // Unless the list never had a title to keep: a row
// from an account's server history arrives as its
// own video ID, and holding on to that would leave
// the card named after a string of characters under
// the real channel and description.
let titleVideo = video.hasPlaceholderTitle ? (detailedVideo ?? video) : video
videoCard(for: titleVideo, thumbnailFrom: detailedVideo, authorFrom: detailedVideo, isLoadingMore: isLoadingMoreVideos && isCurrent, showTitle: true, isCurrent: isCurrent)
.opacity(isCurrent ? 1.0 : peekOpacity) .opacity(isCurrent ? 1.0 : peekOpacity)
.containerRelativeFrame(.horizontal) .containerRelativeFrame(.horizontal)
.id(index) .id(index)

View File

@@ -277,7 +277,7 @@ private final class AccountServiceHarness {
var credentialSwapOnNextRead: String? var credentialSwapOnNextRead: String?
init(type: InstanceType = .invidious, isEnabled: Bool = true) { init(type: InstanceType = .invidious, isEnabled: Bool = true) {
instances = InstancesManager() instances = makeTestInstancesManager()
instance = Instance( instance = Instance(
type: type, type: type,
url: URL(string: "https://accounts-\(UUID().uuidString).example.com")!, url: URL(string: "https://accounts-\(UUID().uuidString).example.com")!,

View File

@@ -691,7 +691,7 @@ struct AccountSyncTests {
@Test("A repair made mid-batch is never uploaded over the server's older context") @Test("A repair made mid-batch is never uploaded over the server's older context")
func repairIsNotPublishedForAStaleServerContext() async { func repairIsNotPublishedForAStaleServerContext() async {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = Instance(type: .invidious, url: URL(string: "https://inv-\(UUID().uuidString).example.com")!) let instance = Instance(type: .invidious, url: URL(string: "https://inv-\(UUID().uuidString).example.com")!)
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -732,7 +732,7 @@ struct AccountSyncTests {
@Test("An account discarded for its tombstone still repairs the context") @Test("An account discarded for its tombstone still repairs the context")
func discardedAccountKeepsItsInstance() async { func discardedAccountKeepsItsInstance() async {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = Instance(type: .invidious, url: URL(string: "https://inv-\(UUID().uuidString).example.com")!) let instance = Instance(type: .invidious, url: URL(string: "https://inv-\(UUID().uuidString).example.com")!)
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -765,7 +765,7 @@ struct AccountSyncTests {
@Test("A context fetched ahead of the records it depends on is still applied last") @Test("A context fetched ahead of the records it depends on is still applied last")
func stagedContextIsAppliedAfterTheAccountRecords() async { func stagedContextIsAppliedAfterTheAccountRecords() async {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = Instance(type: .invidious, url: URL(string: "https://inv-\(UUID().uuidString).example.com")!) let instance = Instance(type: .invidious, url: URL(string: "https://inv-\(UUID().uuidString).example.com")!)
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }

View File

@@ -406,7 +406,7 @@ struct AccountsManagerTests {
@Test("An unresolved identity never migrates a legacy login") @Test("An unresolved identity never migrates a legacy login")
func unresolvedIdentityDoesNotMigrate() { func unresolvedIdentityDoesNotMigrate() {
let defaults = makeDefaults() let defaults = makeDefaults()
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeLegacyInstance() let instance = makeLegacyInstance()
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -438,7 +438,7 @@ struct AccountsManagerTests {
@Test("A scope that dips to unresolved and returns writes nothing in between") @Test("A scope that dips to unresolved and returns writes nothing in between")
func scopeRoundTripLeavesTheUnresolvedNamespaceEmpty() async throws { func scopeRoundTripLeavesTheUnresolvedNamespaceEmpty() async throws {
let defaults = makeDefaults() let defaults = makeDefaults()
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeLegacyInstance() let instance = makeLegacyInstance()
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -570,7 +570,7 @@ struct DataContextValidatorTests {
@Test("A switched-off instance makes its context unavailable, not removed") @Test("A switched-off instance makes its context unavailable, not removed")
func disabledInstanceIsUnavailable() { func disabledInstanceIsUnavailable() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
var instance = Instance(type: .invidious, url: URL(string: "https://inv.example.com")!) var instance = Instance(type: .invidious, url: URL(string: "https://inv.example.com")!)
instances.add(instance) instances.add(instance)
@@ -598,7 +598,7 @@ struct DataContextValidatorTests {
@Test("Only a deletion the app can point at moves the context") @Test("Only a deletion the app can point at moves the context")
func correctionRequiresATombstone() { func correctionRequiresATombstone() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
var instance = Instance(type: .invidious, url: URL(string: "https://inv-correct.example.com")!) var instance = Instance(type: .invidious, url: URL(string: "https://inv-correct.example.com")!)
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -639,7 +639,7 @@ struct DataContextValidatorTests {
@Test("A tombstoned selection is repaired without being published") @Test("A tombstoned selection is repaired without being published")
func correctionFollowsTheTombstone() { func correctionFollowsTheTombstone() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = Instance(type: .invidious, url: URL(string: "https://inv-tombstone.example.com")!) let instance = Instance(type: .invidious, url: URL(string: "https://inv-tombstone.example.com")!)
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -668,7 +668,7 @@ struct DataContextValidatorTests {
@Test("Nothing is repaired while the namespace is reloading") @Test("Nothing is repaired while the namespace is reloading")
func correctionIsFencedToTheLoadedNamespace() { func correctionIsFencedToTheLoadedNamespace() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = Instance(type: .invidious, url: URL(string: "https://inv-reload.example.com")!) let instance = Instance(type: .invidious, url: URL(string: "https://inv-reload.example.com")!)
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -715,7 +715,7 @@ struct DataContextValidatorTests {
@Test("Only accounts the app can actually resolve are offered") @Test("Only accounts the app can actually resolve are offered")
func availableContextsAreFiltered() { func availableContextsAreFiltered() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let enabled = Instance(type: .invidious, url: URL(string: "https://inv-a.example.com")!) let enabled = Instance(type: .invidious, url: URL(string: "https://inv-a.example.com")!)
let disabled = Instance(type: .invidious, url: URL(string: "https://inv-b.example.com")!, isEnabled: false) let disabled = Instance(type: .invidious, url: URL(string: "https://inv-b.example.com")!, isEnabled: false)
// No account concept on PeerTube, so a context there could never be // No account concept on PeerTube, so a context there could never be
@@ -756,7 +756,7 @@ struct DataContextRepairTests {
@Test("Deleting the selected account falls back to a signed-in sibling") @Test("Deleting the selected account falls back to a signed-in sibling")
func deletionPrefersSignedInSibling() { func deletionPrefersSignedInSibling() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance("inv") let instance = makeInstance("inv")
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -779,7 +779,7 @@ struct DataContextRepairTests {
@Test("Without a signed-in sibling the repair lands on local") @Test("Without a signed-in sibling the repair lands on local")
func deletionWithoutSiblingFallsBackToLocal() { func deletionWithoutSiblingFallsBackToLocal() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance("inv") let instance = makeInstance("inv")
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -800,7 +800,7 @@ struct DataContextRepairTests {
@Test("A repair made while a sibling was still being checked never comes back") @Test("A repair made while a sibling was still being checked never comes back")
func repairIsNotReconsideredWhenStatusesSettle() { func repairIsNotReconsideredWhenStatusesSettle() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance("inv") let instance = makeInstance("inv")
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -829,7 +829,7 @@ struct DataContextRepairTests {
@Test("A signed-in account on another server is never the repair") @Test("A signed-in account on another server is never the repair")
func repairNeverCrossesInstances() { func repairNeverCrossesInstances() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let first = makeInstance("inv-first") let first = makeInstance("inv-first")
let second = makeInstance("inv-second") let second = makeInstance("inv-second")
instances.add(first) instances.add(first)
@@ -852,7 +852,7 @@ struct DataContextRepairTests {
@Test("A synced context naming a deleted account repairs to its sibling") @Test("A synced context naming a deleted account repairs to its sibling")
func syncedTombstoneRepairPrefersSibling() { func syncedTombstoneRepairPrefersSibling() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance("inv") let instance = makeInstance("inv")
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -878,7 +878,7 @@ struct DataContextRepairTests {
@Test("A synced context for a deleted account with no sibling lands on local") @Test("A synced context for a deleted account with no sibling lands on local")
func syncedTombstoneRepairFallsBackToLocal() { func syncedTombstoneRepairFallsBackToLocal() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance("inv") let instance = makeInstance("inv")
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -900,7 +900,7 @@ struct DataContextRepairTests {
@Test("Adopting a repaired synced context drops what the replaced choice owed") @Test("Adopting a repaired synced context drops what the replaced choice owed")
func syncedTombstoneRepairIsNeverPublished() { func syncedTombstoneRepairIsNeverPublished() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance("inv") let instance = makeInstance("inv")
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -929,7 +929,7 @@ struct DataContextRepairTests {
@Test("A local choice that outranks a repair keeps its upload") @Test("A local choice that outranks a repair keeps its upload")
func repairThatLosesLeavesTheChoiceDirty() { func repairThatLosesLeavesTheChoiceDirty() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance("inv") let instance = makeInstance("inv")
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -956,7 +956,7 @@ struct DataContextRepairTests {
@Test("A repair is never pushed back over the server's older context") @Test("A repair is never pushed back over the server's older context")
func repairIsNeverPushedBackOverAnOlderContext() { func repairIsNeverPushedBackOverAnOlderContext() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance("inv") let instance = makeInstance("inv")
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -1002,7 +1002,7 @@ struct DataContextRepairTests {
@Test("A repair read back from disk still yields rather than publishing") @Test("A repair read back from disk still yields rather than publishing")
func reloadedRepairStillYieldsToTheServersContext() { func reloadedRepairStillYieldsToTheServersContext() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance("inv") let instance = makeInstance("inv")
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -1035,7 +1035,7 @@ struct DataContextRepairTests {
@Test("A repair yields to an explicit context it ties with") @Test("A repair yields to an explicit context it ties with")
func repairYieldsToAnExplicitContextOnATie() { func repairYieldsToAnExplicitContextOnATie() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance("inv") let instance = makeInstance("inv")
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -1064,7 +1064,7 @@ struct DataContextRepairTests {
@Test("A repair yields to an older context naming an account that is still there") @Test("A repair yields to an older context naming an account that is still there")
func repairYieldsToAnOlderContextNamingALiveAccount() { func repairYieldsToAnOlderContextNamingALiveAccount() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance("inv") let instance = makeInstance("inv")
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -1096,7 +1096,7 @@ struct DataContextRepairTests {
@Test("An older context naming the deleted account repairs again rather than landing on it") @Test("An older context naming the deleted account repairs again rather than landing on it")
func repairYieldsToAnOlderContextNamingTheDeletedAccount() { func repairYieldsToAnOlderContextNamingTheDeletedAccount() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance("inv") let instance = makeInstance("inv")
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -1130,7 +1130,7 @@ struct DataContextRepairTests {
@Test("A repair recomputes statuses before it picks a sibling") @Test("A repair recomputes statuses before it picks a sibling")
func repairRefreshesStatusesBeforeChoosing() { func repairRefreshesStatusesBeforeChoosing() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance("inv") let instance = makeInstance("inv")
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }
@@ -1160,7 +1160,7 @@ struct DataContextRepairTests {
@Test("An account dominated by a tombstone still says where it lived") @Test("An account dominated by a tombstone still says where it lived")
func discardedAccountKeepsItsInstanceForRepair() { func discardedAccountKeepsItsInstanceForRepair() {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance("inv") let instance = makeInstance("inv")
instances.add(instance) instances.add(instance)
defer { instances.remove(instance) } defer { instances.remove(instance) }

View File

@@ -30,7 +30,7 @@ struct OrphanedAccountRemovalTests {
@Test("An account outlives its source, and knows nothing about it") @Test("An account outlives its source, and knows nothing about it")
func orphanHasNothingToResolveThrough() { func orphanHasNothingToResolveThrough() {
let scope = makeScope() let scope = makeScope()
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance(.invidious) let instance = makeInstance(.invidious)
instances.add(instance) instances.add(instance)
@@ -59,7 +59,7 @@ struct OrphanedAccountRemovalTests {
let invidious = InvidiousCredentialsManager(identityScope: scope) let invidious = InvidiousCredentialsManager(identityScope: scope)
let piped = PipedCredentialsManager(identityScope: scope) let piped = PipedCredentialsManager(identityScope: scope)
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance(.invidious) let instance = makeInstance(.invidious)
instances.add(instance) instances.add(instance)
@@ -96,7 +96,7 @@ struct OrphanedAccountRemovalTests {
let invidious = InvidiousCredentialsManager(identityScope: scope) let invidious = InvidiousCredentialsManager(identityScope: scope)
let piped = PipedCredentialsManager(identityScope: scope) let piped = PipedCredentialsManager(identityScope: scope)
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance(.piped) let instance = makeInstance(.piped)
instances.add(instance) instances.add(instance)
@@ -126,7 +126,7 @@ struct OrphanedAccountRemovalTests {
@Test("A deletion queued for the server survives the removal") @Test("A deletion queued for the server survives the removal")
func removalQueuesTheTombstone() { func removalQueuesTheTombstone() {
let scope = makeScope() let scope = makeScope()
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance(.invidious) let instance = makeInstance(.invidious)
instances.add(instance) instances.add(instance)
@@ -155,7 +155,7 @@ struct OrphanedAccountRemovalTests {
@Test("The deletion is durable before the secret is destroyed") @Test("The deletion is durable before the secret is destroyed")
func removalRecordsTheTombstoneFirst() { func removalRecordsTheTombstoneFirst() {
let scope = makeScope() let scope = makeScope()
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance(.invidious) let instance = makeInstance(.invidious)
instances.add(instance) instances.add(instance)
@@ -186,7 +186,7 @@ struct OrphanedAccountRemovalTests {
let scope = makeScope() let scope = makeScope()
let invidious = InvidiousCredentialsManager(identityScope: scope) let invidious = InvidiousCredentialsManager(identityScope: scope)
let instances = InstancesManager() let instances = makeTestInstancesManager()
let instance = makeInstance(.invidious) let instance = makeInstance(.invidious)
instances.add(instance) instances.add(instance)

View File

@@ -47,7 +47,7 @@ private final class ProbeHarness {
cooldown: TimeInterval = 0, cooldown: TimeInterval = 0,
unreachableRetryInterval: TimeInterval = 60 unreachableRetryInterval: TimeInterval = 60
) { ) {
instances = InstancesManager() instances = makeTestInstancesManager()
instance = Instance( instance = Instance(
type: type, type: type,
url: URL(string: "https://probe-\(UUID().uuidString).example.com")!, url: URL(string: "https://probe-\(UUID().uuidString).example.com")!,

View File

@@ -147,3 +147,15 @@ struct InstanceIdentityTests {
#expect(instance.id == originalID) #expect(instance.id == originalID)
} }
} }
// MARK: - Test Support
/// An instances manager with defaults of its own.
///
/// `InstancesManager()` reads and writes `UserDefaults.standard`, which under a
/// test host is the app's own container: an instance a fixture adds becomes one
/// the app on that device is configured with, and it stays there after the run.
@MainActor
func makeTestInstancesManager() -> InstancesManager {
InstancesManager(defaults: UserDefaults(suiteName: "yattee.tests.instances.\(UUID().uuidString)")!)
}

View File

@@ -117,7 +117,7 @@ struct SubscriptionDestinationRoutingTests {
} }
private func makeFixture() throws -> Fixture { private func makeFixture() throws -> Fixture {
let instances = InstancesManager() let instances = makeTestInstancesManager()
let made = (0 ..< 2).map { index in let made = (0 ..< 2).map { index in
Instance( Instance(
type: .invidious, type: .invidious,

View File

@@ -35,7 +35,7 @@ private final class WatchHistorySyncHarness {
var credentials: [UUID: String] = [:] var credentials: [UUID: String] = [:]
init(type: InstanceType = .invidious) { init(type: InstanceType = .invidious) {
instances = InstancesManager() instances = makeTestInstancesManager()
instance = Instance( instance = Instance(
type: type, type: type,
url: URL(string: "https://history-\(UUID().uuidString).example.com")!, url: URL(string: "https://history-\(UUID().uuidString).example.com")!,