field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 0f8abfc773ffa16f134598e585c97f2dc57ba182
parent 9b6df06a16aa67258d8ed6238a9bd9ef9629ac92
Author: triesap <tyson@radroots.org>
Date:   Wed,  9 Sep 2026 12:50:49 +0000

ios: resnapshot recovered observers and coalesce updates

- Refresh current local state after subscription recovery and typed gaps.
- Coalesce fixed domains with one supervised worker and a final pending refresh.
- Preserve scoped startup inventory, upload reconciliation, and media paging.
- Verify actual stream recovery, notification storms, and negative regressions.

Diffstat:
MPackage.swift | 1+
MTera.xcodeproj/project.pbxproj | 12++++++++++++
ATera/State/TeraAddStartupSnapshot.swift | 18++++++++++++++++++
MTera/State/TeraAddStore.swift | 43++++++++++++++++++++-----------------------
MTera/State/TeraStoreObservation.swift | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
MTera/State/TeraSupportingStores.swift | 28++++++++++++++++++----------
MTera/State/TeraTodayStore.swift | 22++++++++++++----------
ATeraTests/TeraAddObservationStartupTests.swift | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATeraTests/TeraObservationCoalescingTests.swift | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTeraTests/TeraScopeBackend.swift | 29++++++++++++++++++++++++-----
MTeraTests/TeraScopedObservationTests.swift | 8++++----
ATeraTests/TeraStoreRecoveryTests.swift | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 3+++
Mrelease/provenance.json | 2+-
Mtest-fixtures/swiftlint-maintainability-baseline.v1.json | 4++--
15 files changed, 608 insertions(+), 66 deletions(-)

diff --git a/Package.swift b/Package.swift @@ -72,6 +72,7 @@ let package = Package( "Runtime/TeraUserMessageClassifier.swift", "Runtime/TeraUserMessages.swift", "State/TeraAddStore.swift", + "State/TeraAddStartupSnapshot.swift", "State/TeraAddPresentation.swift", "State/TeraStoreObservation.swift", "State/TeraConfigurationStore.swift", diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 2AEAFDD0EECA3B7F799B1287 /* TeraScopedAddTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A756C16ACFABBD36C48DF62 /* TeraScopedAddTests.swift */; }; 2B50FA35AD753EFA15CF00CD /* TeraRuntimeBackpressureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8591841AD9AB9B718A37EB47 /* TeraRuntimeBackpressureTests.swift */; }; 2C6DDB502DFA11C2CBCA49C4 /* TeraApp in Frameworks */ = {isa = PBXBuildFile; productRef = A1979630FE6BC343F0E59F24 /* TeraApp */; }; + 30EDD3EB0C09D864FC1DD3D4 /* TeraStoreRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD39C5F3460EEF38B98D78B4 /* TeraStoreRecoveryTests.swift */; }; 31CBA09815D972CCE187009F /* TeraSessionResourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B870D20178E633610113 /* TeraSessionResourceTests.swift */; }; 38EF7FA8874E82B28110A232 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6967B3EAC4CF1A410B26DD1A /* PrivacyInfo.xcprivacy */; }; 39B47EE698E9D7CC716A372F /* TeraRuntimeShutdownTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91E82E561F73A1BE6BBF09B7 /* TeraRuntimeShutdownTests.swift */; }; @@ -36,6 +37,7 @@ 9AD968F0B37C64B5AF8D9A68 /* TeraRootShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B8677670E203FF68EC88983 /* TeraRootShellTests.swift */; }; 9B37084901EC52EB44969304 /* TeraRuntimeResourceLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 226F5C2370522AAFAD9D1C3D /* TeraRuntimeResourceLifecycleTests.swift */; }; A1AD2A380F09994786889AF1 /* TeraAddStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F22CFABCF27A28408F68D25 /* TeraAddStoreTests.swift */; }; + AC33144E09CB0384C4FB090C /* TeraObservationCoalescingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21DD785439F415278214D746 /* TeraObservationCoalescingTests.swift */; }; B1B83B8673660E0EC109F74C /* TeraStateTestFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD45A3134A16704CBE4B1A5 /* TeraStateTestFixtures.swift */; }; B2DAAE01D16E1FAC35816DED /* RadrootsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1A9F6E32DE0C3728D4DB8E60 /* RadrootsKit */; }; B4AE86BE0A9F7FBE0DEBD9AC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = ADFD21D28C5F9E58B70E76CA /* Localizable.strings */; }; @@ -48,6 +50,7 @@ DF97F408D7D32F72C5AF5939 /* TeraRootShellUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5238B5F236C392C82CEF9838 /* TeraRootShellUITests.swift */; }; E508A7061082B091B733D62B /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67ACCE25979BA86C1EC5BFD6 /* App.swift */; }; EF7293C553BF1DB45EEEBD49 /* local-social-personas.v1.json in Resources */ = {isa = PBXBuildFile; fileRef = 27D9D40699A01FFB02F30B11 /* local-social-personas.v1.json */; }; + F4AF91E71B691FE30C191852 /* TeraAddObservationStartupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44A4460928B20A21A94BECCE /* TeraAddObservationStartupTests.swift */; }; F8C87782857DC930D47E6A90 /* TeraRuntimeResourceTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D01C023E258AC3B3B1C27D /* TeraRuntimeResourceTaskTests.swift */; }; FBA9C3463CD1827392AF9599 /* TeraAddSchemaFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8ED2718D5E9E8980ADF3423 /* TeraAddSchemaFixtures.swift */; }; /* End PBXBuildFile section */ @@ -73,6 +76,7 @@ 02F322CF98F8BEB908B93215 /* TeraMutationAdmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraMutationAdmissionTests.swift; sourceTree = "<group>"; }; 0CDE3D76E1EDF909C19D0506 /* TeraUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = TeraUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 11E9C876EA26B30C8AFE116F /* TeraScopeFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopeFixtures.swift; sourceTree = "<group>"; }; + 21DD785439F415278214D746 /* TeraObservationCoalescingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraObservationCoalescingTests.swift; sourceTree = "<group>"; }; 226F5C2370522AAFAD9D1C3D /* TeraRuntimeResourceLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeResourceLifecycleTests.swift; sourceTree = "<group>"; }; 27D9D40699A01FFB02F30B11 /* local-social-personas.v1.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "local-social-personas.v1.json"; sourceTree = "<group>"; }; 2A4FFC0023C620B68D0EC0ED /* TeraRuntimeBoundedTaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeBoundedTaskTests.swift; sourceTree = "<group>"; }; @@ -81,6 +85,7 @@ 3A756C16ACFABBD36C48DF62 /* TeraScopedAddTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopedAddTests.swift; sourceTree = "<group>"; }; 3B808E67E85CD36E49C38B6F /* TeraCalendarFFICompatibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraCalendarFFICompatibilityTests.swift; sourceTree = "<group>"; }; 4383B870D20178E633610113 /* TeraSessionResourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraSessionResourceTests.swift; sourceTree = "<group>"; }; + 44A4460928B20A21A94BECCE /* TeraAddObservationStartupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAddObservationStartupTests.swift; sourceTree = "<group>"; }; 46DDEE781FDD92A292FD847B /* TeraUserMessagesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraUserMessagesTests.swift; sourceTree = "<group>"; }; 5238B5F236C392C82CEF9838 /* TeraRootShellUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRootShellUITests.swift; sourceTree = "<group>"; }; 57CA176D0A7566A4ADD2CEA4 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; }; @@ -104,6 +109,7 @@ AB8A30A75EFD65EFD938D248 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; ADFD21D28C5F9E58B70E76CA /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = "<group>"; }; BAD45A3134A16704CBE4B1A5 /* TeraStateTestFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraStateTestFixtures.swift; sourceTree = "<group>"; }; + BD39C5F3460EEF38B98D78B4 /* TeraStoreRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraStoreRecoveryTests.swift; sourceTree = "<group>"; }; C0636AEDEB50DDDD328374A9 /* TeraSupportingStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraSupportingStoreTests.swift; sourceTree = "<group>"; }; C1DBB5D9AC2FE8EA3AE68458 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; }; C4D01C023E258AC3B3B1C27D /* TeraRuntimeResourceTaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeResourceTaskTests.swift; sourceTree = "<group>"; }; @@ -178,6 +184,7 @@ 50B89388753A1FCD1B2A4F38 /* TeraTests */ = { isa = PBXGroup; children = ( + 44A4460928B20A21A94BECCE /* TeraAddObservationStartupTests.swift */, D8ED2718D5E9E8980ADF3423 /* TeraAddSchemaFixtures.swift */, 9F22CFABCF27A28408F68D25 /* TeraAddStoreTests.swift */, 61D8C0835D5A1D5C36D80020 /* TeraBackgroundUploadFixtures.swift */, @@ -187,6 +194,7 @@ C890BDE885536BC2AB215393 /* TeraLifecycleTests.swift */, 61B6F0B16BFD8F046E9C163F /* TeraMediaStoreTests.swift */, 02F322CF98F8BEB908B93215 /* TeraMutationAdmissionTests.swift */, + 21DD785439F415278214D746 /* TeraObservationCoalescingTests.swift */, E351F52E77C74A4E99095E45 /* TeraRemoteQualificationTests.swift */, 6B8677670E203FF68EC88983 /* TeraRootShellTests.swift */, 8591841AD9AB9B718A37EB47 /* TeraRuntimeBackpressureTests.swift */, @@ -208,6 +216,7 @@ 4383B870D20178E633610113 /* TeraSessionResourceTests.swift */, 98E969BCFF7CED33A1D4A040 /* TeraStateMigrationTests.swift */, BAD45A3134A16704CBE4B1A5 /* TeraStateTestFixtures.swift */, + BD39C5F3460EEF38B98D78B4 /* TeraStoreRecoveryTests.swift */, C0636AEDEB50DDDD328374A9 /* TeraSupportingStoreTests.swift */, 794D475C70BA8ED994D1CA66 /* TeraTodayStoreTests.swift */, 46DDEE781FDD92A292FD847B /* TeraUserMessagesTests.swift */, @@ -418,6 +427,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F4AF91E71B691FE30C191852 /* TeraAddObservationStartupTests.swift in Sources */, FBA9C3463CD1827392AF9599 /* TeraAddSchemaFixtures.swift in Sources */, A1AD2A380F09994786889AF1 /* TeraAddStoreTests.swift in Sources */, 6F53FF88A29F8DF93F074EB4 /* TeraBackgroundUploadFixtures.swift in Sources */, @@ -427,6 +437,7 @@ 671EF50DB4EFBC25AABF26DC /* TeraLifecycleTests.swift in Sources */, CDBE38E6B521611436F28858 /* TeraMediaStoreTests.swift in Sources */, BB71614D2A6017C071A39BED /* TeraMutationAdmissionTests.swift in Sources */, + AC33144E09CB0384C4FB090C /* TeraObservationCoalescingTests.swift in Sources */, 81AF9DDA0E64DC4EEFCEF25D /* TeraRemoteQualificationTests.swift in Sources */, 9AD968F0B37C64B5AF8D9A68 /* TeraRootShellTests.swift in Sources */, 2B50FA35AD753EFA15CF00CD /* TeraRuntimeBackpressureTests.swift in Sources */, @@ -448,6 +459,7 @@ 31CBA09815D972CCE187009F /* TeraSessionResourceTests.swift in Sources */, 852FF7514FF7F682A246FB47 /* TeraStateMigrationTests.swift in Sources */, B1B83B8673660E0EC109F74C /* TeraStateTestFixtures.swift in Sources */, + 30EDD3EB0C09D864FC1DD3D4 /* TeraStoreRecoveryTests.swift in Sources */, 2479571FA99A7962794999C4 /* TeraSupportingStoreTests.swift in Sources */, 64D49ADFD3FDD54CBA509DC5 /* TeraTodayStoreTests.swift in Sources */, 4734A842C366608EC1BD31C5 /* TeraUserMessagesTests.swift in Sources */, diff --git a/Tera/State/TeraAddStartupSnapshot.swift b/Tera/State/TeraAddStartupSnapshot.swift @@ -0,0 +1,18 @@ +struct TeraAddStartupSnapshot { + let schemas: [TeraAddSchema] + let drafts: [TeraDraftStatus] + let support: TeraAddMediaSupport + + @MainActor + static func load( + client: TeraRuntimeClient, support: @MainActor () async throws -> TeraAddMediaSupport + ) async throws -> Self { + async let schemaResult = client.addSchemas() + async let draftResult = client.draftHeads(limit: 100) + async let supportResult = support() + return try await Self( + schemas: TeraProductSurfaceContract.validate(schemas: schemaResult), + drafts: draftResult, support: supportResult + ) + } +} diff --git a/Tera/State/TeraAddStore.swift b/Tera/State/TeraAddStore.swift @@ -34,6 +34,7 @@ final class TeraAddStore: ObservableObject { private let observation = TeraStoreObservation() private var configuration: TeraPresentationConfiguration? private var draftsGeneration = TeraSessionGeneration.initial + private var appliedDraftsGeneration = TeraSessionGeneration.initial private var probeGeneration = TeraSessionGeneration.initial private var blossomGeneration = TeraSessionGeneration.initial private var revisionTarget: TeraRevisionTarget? @@ -133,28 +134,21 @@ final class TeraAddStore: ObservableObject { generation = generation.invalidated() let requestedGeneration = generation draftsGeneration = draftsGeneration.invalidated() - let draftRequest = draftsGeneration - let serviceRequest = blossomGeneration + let draftRequest = appliedDraftsGeneration + let serviceConfiguration = configuration do { - async let schemaResult = runtimeClient.addSchemas() - async let draftResult = runtimeClient.draftHeads(limit: 100) - async let supportResult = loadMediaSupport() - let (loadedSchemas, loadedDrafts, loadedSupport) = try await ( - schemaResult, - draftResult, - supportResult - ) + let loaded = try await TeraAddStartupSnapshot.load(client: runtimeClient, support: loadMediaSupport) guard isCurrent(requestedGeneration) else { return } - if draftRequest == draftsGeneration { - try await media?.reconcileBackgroundUploads(drafts: loadedDrafts) - } + let inventory = draftRequest == appliedDraftsGeneration ? loaded.drafts : drafts + try await media?.reconcileBackgroundUploads(drafts: inventory) try ensureCurrent(requestedGeneration) - schemas = try TeraProductSurfaceContract.validate(schemas: loadedSchemas) - if draftRequest == draftsGeneration { - drafts = TeraAddPresentation.sorted(loadedDrafts) + schemas = loaded.schemas + if draftRequest == appliedDraftsGeneration { + appliedDraftsGeneration = try appliedDraftsGeneration.next() + drafts = TeraAddPresentation.sorted(loaded.drafts) } - if serviceRequest == blossomGeneration { - mediaSupport = loadedSupport + if serviceConfiguration == configuration { + mediaSupport = loaded.support } state = .ready } catch { @@ -669,6 +663,7 @@ final class TeraAddStore: ObservableObject { do { let loaded = try await runtimeClient.draftHeads(limit: 100) guard isCurrent(requestedGeneration), request == draftsGeneration else { return } + appliedDraftsGeneration = try appliedDraftsGeneration.next() drafts = TeraAddPresentation.sorted(loaded) if let activeDraft, let current = loaded.first(where: { $0.id == activeDraft.id }), current.revision >= activeDraft.revision @@ -702,16 +697,17 @@ final class TeraAddStore: ObservableObject { private func startObservation() { observation.start( - client: runtimeClient, capacity: 16, delay: observationDelay, + client: runtimeClient, buffer: (capacity: 16, delay: observationDelay), state: { [weak self] in self?.observationState = $0 }, - change: { [weak self] change in - guard let self, change.matches(context: configuration?.context) else { return } + accepts: { [weak self] in $0.matches(context: self?.configuration?.context) }, + refresh: { [weak self] batch in + guard let self else { return } let requested = generation - if change.kind == .drafts || change.kind == .media { + if batch.contains(anyOf: [.drafts, .media]) { await reloadDrafts() } guard isCurrent(requested) else { return } - if change.kind == .media || change.kind == .settings { + if batch.contains(anyOf: [.media, .settings]) { await refreshBlossomSnapshot() } } @@ -731,6 +727,7 @@ final class TeraAddStore: ObservableObject { ) throws { try ensureCurrent(requested) draftsGeneration = draftsGeneration.invalidated() + appliedDraftsGeneration = try appliedDraftsGeneration.next() activeDraft = status if let form = status.form { self.form = form diff --git a/Tera/State/TeraStoreObservation.swift b/Tera/State/TeraStoreObservation.swift @@ -13,31 +13,65 @@ struct TeraPresentationConfiguration: Equatable { } } -/// Each store owns one observer. An old subscription or waiter cannot update or -/// clear a replacement observer, including while subscription creation is pending. +/// At most one entry per fixed notification domain; a gap subsumes every domain. +struct TeraObservationBatch: Equatable { + private(set) var domains: Set<TeraRuntimeChangeKind> = [] + private(set) var resnapshot = false + + static var currentState: Self { + Self(resnapshot: true) + } + + var isEmpty: Bool { + !resnapshot && domains.isEmpty + } + + func contains(anyOf kinds: Set<TeraRuntimeChangeKind>) -> Bool { + resnapshot || !domains.isDisjoint(with: kinds) + } + + mutating func insert(_ change: TeraRuntimeChange) { + if change.delivery == .resnapshotRequired || change.revision.rawValue == nil { + self = .currentState + } else if !resnapshot, change.kind != .initial { + domains.insert(change.kind) + } + } +} + +/// One stream and one refresh worker per store. Ingestion never waits for a +/// query; hints arriving during that query retain a bounded final refresh. @MainActor final class TeraStoreObservation { private var generation = TeraSessionGeneration.initial private var task: Task<Void, Never>? + private var refreshTask: Task<Void, Never>? + private var pending = TeraObservationBatch() var isActive: Bool { task != nil } - deinit { task?.cancel() } + deinit { + task?.cancel() + refreshTask?.cancel() + } func stop() { generation = generation.invalidated() task?.cancel() task = nil + refreshTask?.cancel() + refreshTask = nil + pending = TeraObservationBatch() } func start( client: TeraRuntimeClient, - capacity: Int, - delay: @escaping @Sendable (UInt32) async throws -> Void, + buffer: (capacity: Int, delay: @Sendable (UInt32) async throws -> Void), state: @escaping @MainActor (TeraRuntimeObservationState) -> Void, - change: @escaping @MainActor (TeraRuntimeChange) async -> Void + accepts: @escaping @MainActor (TeraRuntimeChange) -> Bool, + refresh: @escaping @MainActor (TeraObservationBatch) async -> Void ) { guard task == nil, generation.isActive else { return } generation = generation.invalidated() @@ -49,13 +83,17 @@ final class TeraStoreObservation { state(.subscribing(attempt: attempt == .max ? .max : attempt + 1)) var message = TeraUserMessages.text(.runtimeObservationUnavailable) do { - let changes = try await client.changes(bufferCapacity: capacity) + let changes = try await client.changes(bufferCapacity: buffer.capacity) guard self?.isCurrent(requested) == true else { break } state(.active) + self?.requireCurrentState(requested, refresh: refresh) for await value in changes { guard self?.isCurrent(requested) == true else { break } attempt = 0 - await change(value) + if accepts(value) { + self?.pending.insert(value) + self?.startRefresh(requested, refresh: refresh) + } } } catch { message = TeraUserMessages.text(for: error, fallback: .runtimeObservationUnavailable) @@ -63,18 +101,48 @@ final class TeraStoreObservation { guard self?.isCurrent(requested) == true else { break } attempt = attempt == .max ? .max : attempt + 1 state(.retrying(attempt: attempt, message: message)) - do { try await delay(attempt) } catch { break } + do { try await buffer.delay(attempt) } catch { break } } self?.finish(requested, state: state) } } + private func requireCurrentState( + _ requested: TeraSessionGeneration, + refresh: @escaping @MainActor (TeraObservationBatch) async -> Void + ) { + pending = .currentState + startRefresh(requested, refresh: refresh) + } + + private func startRefresh( + _ requested: TeraSessionGeneration, + refresh: @escaping @MainActor (TeraObservationBatch) async -> Void + ) { + guard refreshTask == nil, !pending.isEmpty, isCurrent(requested) else { return } + refreshTask = Task { [weak self] in + while let batch = self?.takePending(requested) { + await refresh(batch) + } + guard self?.generation == requested else { return } + self?.refreshTask = nil + } + } + + private func takePending(_ requested: TeraSessionGeneration) -> TeraObservationBatch? { + guard isCurrent(requested), !pending.isEmpty else { return nil } + let batch = pending + pending = TeraObservationBatch() + return batch + } + private func finish( _ requested: TeraSessionGeneration, state: @MainActor (TeraRuntimeObservationState) -> Void ) { guard generation == requested else { return } - task = nil - if Task.isCancelled { + let cancelled = Task.isCancelled + stop() + if cancelled { state(.stopped) } } diff --git a/Tera/State/TeraSupportingStores.swift b/Tera/State/TeraSupportingStores.swift @@ -108,6 +108,9 @@ final class TeraMeStore: ObservableObject { private var context: TeraLocalNetwork? private var generation = TeraSessionGeneration.initial private let observation = TeraStoreObservation() + private var reloadTask: Task<Void, Never>? + + deinit { reloadTask?.cancel() } init( runtimeClient: TeraRuntimeClient, @@ -123,6 +126,8 @@ final class TeraMeStore: ObservableObject { func configure(context: TeraLocalNetwork?) { guard self.context != context else { return } generation = generation.invalidated() + reloadTask?.cancel() + reloadTask = nil self.context = context snapshot = nil state = .idle @@ -134,7 +139,10 @@ final class TeraMeStore: ObservableObject { func start() async { startObservation() - await reload() + reloadTask?.cancel() + reloadTask = Task { [weak self] in await self?.reload() } + let task = reloadTask + await withTaskCancellationHandler { await task?.value } onCancel: { task?.cancel() } } func reload() async { @@ -165,6 +173,8 @@ final class TeraMeStore: ObservableObject { func stop() { generation = generation.invalidated() observation.stop() + reloadTask?.cancel() + reloadTask = nil observationState = .stopped snapshot = nil state = .idle @@ -172,16 +182,14 @@ final class TeraMeStore: ObservableObject { private func startObservation() { observation.start( - client: runtimeClient, capacity: 8, delay: observationDelay, + client: runtimeClient, buffer: (capacity: 8, delay: observationDelay), state: { [weak self] in self?.observationState = $0 }, - change: { [weak self] change in - guard change.matches(context: self?.context) else { return } - switch change.kind { - case .today, .identity, .profile, .media, .drafts: - await self?.reload() - case .initial, .settings, .relay, .lifecycle: - break - } + accepts: { [weak self] in $0.matches(context: self?.context) }, + refresh: { [weak self] batch in + guard batch.contains(anyOf: [.today, .identity, .profile, .media, .drafts]) else { return } + await self?.reloadTask?.value + guard !Task.isCancelled else { return } + await self?.reload() } ) } diff --git a/Tera/State/TeraTodayStore.swift b/Tera/State/TeraTodayStore.swift @@ -83,7 +83,9 @@ final class TeraTodayStore: ObservableObject { func start() async { guard !observation.isActive, !Task.isCancelled else { return } startObservation() - await reload() + scheduleReload() + let task = reloadTask + await withTaskCancellationHandler { await task?.value } onCancel: { task?.cancel() } } func stop() { @@ -235,16 +237,16 @@ final class TeraTodayStore: ObservableObject { private func startObservation() { observation.start( - client: runtimeClient, capacity: 16, delay: observationDelay, + client: runtimeClient, buffer: (capacity: 16, delay: observationDelay), state: { [weak self] in self?.observationState = $0 }, - change: { [weak self] change in - guard change.matches(context: self?.selectedContext) else { return } - switch change.kind { - case .today, .drafts, .media, .identity, .profile: - await self?.reload(refreshProjection: false) - case .initial, .settings, .relay, .lifecycle: - break - } + accepts: { [weak self] in $0.matches(context: self?.selectedContext) }, + refresh: { [weak self] batch in + // Ordinary media progress belongs to the media presentation; + // it must not reset the feed's loaded pages and cursor. + guard batch.contains(anyOf: [.today, .drafts, .identity, .profile]) else { return } + await self?.reloadTask?.value + guard !Task.isCancelled else { return } + await self?.reload(refreshProjection: false) } ) } diff --git a/TeraTests/TeraAddObservationStartupTests.swift b/TeraTests/TeraAddObservationStartupTests.swift @@ -0,0 +1,86 @@ +@testable import TeraApp +import XCTest + +@MainActor +final class TeraAddObservationStartupTests: XCTestCase { + func testPendingObserverCannotDiscardUsableStartupInventory() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let media = ObservationStartupMedia() + let subscription = await backend.pause(.subscribe) + let initialDrafts = await backend.pause(.drafts) + let store = TeraAddStore(runtimeClient: client, media: media) + store.configure(snapshot: TeraScopeFixtures.snapshot()) + let startup = Task { await store.start() } + await initialDrafts.entered.wait() + await media.supportPause.entered.wait() + let observerDrafts = await backend.pause(.drafts) + await initialDrafts.resume.open() + await subscription.resume.open() + await observerDrafts.entered.wait() + await media.supportPause.resume.open() + await startup.value + XCTAssertEqual(store.drafts.first?.form?.content, "old") + XCTAssertEqual(store.mediaSupport, .init(library: true, camera: true)) + XCTAssertEqual(store.state, .ready) + let inventories = await media.inventories + XCTAssertEqual(inventories.map { $0.first?.revision }, [1]) + await observerDrafts.resume.open() + store.stop() + _ = try await client.stop() + } + + func testStartupReconcilesTheAppliedObserverInventoryAndKeepsMediaSupport() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let media = ObservationStartupMedia() + let subscription = await backend.pause(.subscribe) + let initialDrafts = await backend.pause(.drafts) + let store = TeraAddStore(runtimeClient: client, media: media) + store.configure(snapshot: TeraScopeFixtures.snapshot()) + let startup = Task { await store.start() } + await initialDrafts.entered.wait() + await media.supportPause.entered.wait() + await initialDrafts.resume.open() + await backend.setDrafts([TeraScopeFixtures.draft("new", revision: 2)]) + await backend.configure(TeraScopeFixtures.snapshot(evidence: TeraScopeFixtures.evidence(observedAt: 2))) + await subscription.resume.open() + await TeraScopeFixtures.eventually { + store.drafts.first?.revision == 2 && store.blossomEvidence?.observedAtUnixMilliseconds == 2 + } + await media.supportPause.resume.open() + await startup.value + XCTAssertEqual(store.drafts.first?.revision, 2) + XCTAssertEqual(store.mediaSupport, .init(library: true, camera: true)) + let inventories = await media.inventories + XCTAssertEqual(inventories.map { $0.first?.revision }, [2]) + store.stop() + _ = try await client.stop() + } +} + +private actor ObservationStartupMedia: TeraAddMediaHandling { + let supportPause = ResourceTestPause() + private(set) var inventories: [[TeraDraftStatus]] = [] + + func support() async -> TeraAddMediaSupport { + await supportPause.wait() + return .init(library: true, camera: true) + } + + func reconcileBackgroundUploads(drafts: [TeraDraftStatus]) { + inventories.append(drafts) + } + + func importImages(limit _: Int) throws -> [TeraPreparedMedia] { + throw TeraScopeFixtures.failure() + } + + func captureImage() throws -> TeraPreparedMedia { + throw TeraScopeFixtures.failure() + } + + func open(_: [TeraPreparedMedia]) throws -> TeraOpenedMedia { + throw TeraScopeFixtures.failure() + } +} diff --git a/TeraTests/TeraObservationCoalescingTests.swift b/TeraTests/TeraObservationCoalescingTests.swift @@ -0,0 +1,181 @@ +@testable import TeraApp +import XCTest + +@MainActor +final class TeraObservationCoalescingTests: XCTestCase { + func testRecoveryResnapshotsWithoutAnInitialHintOrAnotherEvent() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let observation = TeraStoreObservation() + let retry = ResourceTestPause() + var batches: [TeraObservationBatch] = [] + observation.start( + client: client, buffer: (capacity: 8, delay: { _ in await retry.wait() }), state: { _ in }, + accepts: { _ in true }, refresh: { batches.append($0) } + ) + await TeraScopeFixtures.eventually { batches.count == 1 } + await client.suspend() + await retry.entered.wait() + XCTAssertEqual(batches.count, 1) + await retry.resume.open() + await TeraScopeFixtures.eventually { batches.count == 2 } + XCTAssertTrue(batches.allSatisfy(\.resnapshot)) + let subscriptions = await backend.counts[.subscribe] + XCTAssertEqual(subscriptions, 2) + observation.stop() + _ = try await client.stop() + } + + func testStormCoalescesFixedDomainsAndDisplaysOneFinalRevision() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let observation = TeraStoreObservation() + let firstRead = ResourceTestPause() + let kinds: [TeraRuntimeChangeKind] = [.today, .drafts, .media, .settings, .identity, .profile, .relay, .lifecycle] + var batches: [TeraObservationBatch] = [] + let durable = ObservationTestState() + var visibleRevisions: [Int] = [] + var seen: UInt64 = 0 + observation.start( + client: client, buffer: (capacity: 8, delay: { _ in throw CancellationError() }), state: { _ in }, + accepts: { seen = $0.revision.rawValue ?? seen; return true }, + refresh: { batch in + batches.append(batch) + let captured = durable.revision + if batches.count == 1 { + await firstRead.wait() + } + visibleRevisions.append(captured) + } + ) + await firstRead.entered.wait() + for index in 1 ... 256 { + durable.revision = index + await backend.emit(kinds[(index - 1) % kinds.count]) + await TeraScopeFixtures.eventually { seen == UInt64(index) } + } + XCTAssertEqual(batches.count, 1) + await firstRead.resume.open() + await TeraScopeFixtures.eventually { visibleRevisions.count == 2 } + XCTAssertEqual(visibleRevisions, [0, 256]) + XCTAssertEqual(try XCTUnwrap(batches.last).domains, Set(kinds)) + XCTAssertFalse(try XCTUnwrap(batches.last).resnapshot) + observation.stop() + _ = try await client.stop() + } + + func testGapOrExhaustedRevisionForcesFinalSnapshotAfterSilence() async throws { + for exhausted in [false, true] { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let observation = TeraStoreObservation() + let first = ResourceTestPause() + var seen = false + var batches: [TeraObservationBatch] = [] + observation.start( + client: client, buffer: (capacity: 1, delay: { _ in throw CancellationError() }), state: { _ in }, + accepts: { _ in seen = true; return true }, refresh: { + batches.append($0) + if batches.count == 1 { + await first.wait() + } + } + ) + await first.entered.wait() + await backend.emit(.initial, delivery: exhausted ? .change : .resnapshotRequired, exhausted: exhausted) + await TeraScopeFixtures.eventually { seen } + XCTAssertEqual(batches.count, 1) + await first.resume.open() + await TeraScopeFixtures.eventually { batches.count == 2 } + XCTAssertTrue(try XCTUnwrap(batches.last).resnapshot) + XCTAssertTrue(try XCTUnwrap(batches.last).domains.isEmpty) + observation.stop() + _ = try await client.stop() + } + } + + func testOldRefreshCompletionCannotClearReplacementOrItsPendingWork() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let observation = TeraStoreObservation() + let oldRead = ResourceTestPause() + let newRead = ResourceTestPause() + let oldFinished = ResourceTestGate() + observation.start( + client: client, buffer: (capacity: 8, delay: { _ in throw CancellationError() }), state: { _ in }, + accepts: { _ in true }, refresh: { _ in await oldRead.wait(); await oldFinished.open() } + ) + await oldRead.entered.wait() + observation.stop() + var seen = false + var newBatches: [TeraObservationBatch] = [] + observation.start( + client: client, buffer: (capacity: 8, delay: { _ in throw CancellationError() }), state: { _ in }, + accepts: { _ in seen = true; return true }, refresh: { + newBatches.append($0) + if newBatches.count == 1 { + await newRead.wait() + } + } + ) + await newRead.entered.wait() + await oldRead.resume.open() + await oldFinished.wait() + await backend.emit(.drafts) + await TeraScopeFixtures.eventually { seen } + XCTAssertEqual(newBatches.count, 1) + await newRead.resume.open() + await TeraScopeFixtures.eventually { newBatches.count == 2 } + XCTAssertEqual(try XCTUnwrap(newBatches.last).domains, [.drafts]) + observation.stop() + _ = try await client.stop() + } + + func testOldCompletionCannotOrphanReplacementRefreshFromStop() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let observation = TeraStoreObservation() + let oldRead = ResourceTestPause() + let newRead = ResourceTestPause() + let newFinished = ResourceTestGate() + weak var oldLifetime: ObservationTaskLifetime? + do { + let lifetime = ObservationTaskLifetime() + oldLifetime = lifetime + observation.start( + client: client, buffer: (capacity: 8, delay: { _ in throw CancellationError() }), state: { _ in }, + accepts: { _ in true }, refresh: { [lifetime] _ in + await oldRead.wait() + withExtendedLifetime(lifetime) {} + } + ) + } + await oldRead.entered.wait() + observation.stop() + var cancelled: Bool? + observation.start( + client: client, buffer: (capacity: 8, delay: { _ in throw CancellationError() }), state: { _ in }, + accepts: { _ in true }, refresh: { _ in + await newRead.wait() + cancelled = Task.isCancelled + await newFinished.open() + } + ) + await newRead.entered.wait() + await oldRead.resume.open() + await TeraScopeFixtures.eventually { oldLifetime == nil } + observation.stop() + await newRead.resume.open() + await newFinished.wait() + XCTAssertEqual(cancelled, true, "Stop must still own and cancel the replacement refresh") + _ = try await client.stop() + } +} + +@MainActor +private final class ObservationTaskLifetime {} + +@MainActor +private final class ObservationTestState { + var revision = 0 +} diff --git a/TeraTests/TeraScopeBackend.swift b/TeraTests/TeraScopeBackend.swift @@ -16,6 +16,9 @@ actor TeraScopeBackend: TeraRuntimeBackend { private(set) var lastMeContext: TeraLocalNetwork? private var drafts: [TeraDraftStatus] = [TeraScopeFixtures.draft("old")] private var media: TeraVerifiedMediaArtifact + private var pages: [String: TeraTodayPage] = [:] + private var meCards = [TeraScopeFixtures.card("old")] + private var revision: UInt64 = 0 init() throws { media = try TeraScopeFixtures.artifact("a") @@ -39,6 +42,14 @@ actor TeraScopeBackend: TeraRuntimeBackend { media = value } + func setPage(_ value: TeraTodayPage, cursor: String = "first") { + pages[cursor] = value + } + + func setMeCards(_ values: [TeraTodayCard]) { + meCards = values + } + private func wait(_ call: Call) async throws { counts[call, default: 0] += 1 guard var queue = pending[call], !queue.isEmpty else { return } @@ -87,7 +98,7 @@ actor TeraScopeBackend: TeraRuntimeBackend { } func todayPage(request: TeraTodayPageRequest) async throws -> TeraTodayPage { - let result = TeraTodayPage( + let result = pages[request.cursor ?? "first"] ?? TeraTodayPage( asOfUnixSeconds: 1, items: [TeraScopeFixtures.card(request.context.relayURLs.first ?? "none")], nextCursor: nil ) try await wait(.page) @@ -112,7 +123,7 @@ actor TeraScopeBackend: TeraRuntimeBackend { func me(context: TeraLocalNetwork, asOfUnixSeconds _: UInt64) async throws -> TeraMeSnapshot { lastMeContext = context - let result = TeraMeSnapshot(publicKey: value.identity.publicKeyHex, profile: nil, cards: [TeraScopeFixtures.card("old")]) + let result = TeraMeSnapshot(publicKey: value.identity.publicKeyHex, profile: nil, cards: meCards) try await wait(.me) return result } @@ -138,13 +149,21 @@ actor TeraScopeBackend: TeraRuntimeBackend { return token } - func emit(_ kind: TeraRuntimeChangeKind) async { + func emit( + _ kind: TeraRuntimeChangeKind, + delivery: TeraRuntimeChangeDelivery = .change, + context: TeraLocalNetwork? = nil, + exhausted: Bool = false + ) async { + revision += 1 + let emittedRevision = revision for receive in receivers { await receive(TeraRuntimeChange( schemaVersion: 3, - scope: TeraRuntimeChangeScope(publicKey: String(repeating: "a", count: 64), sourceGeneration: String(repeating: "a", count: 64), context: nil), + scope: TeraRuntimeChangeScope(publicKey: String(repeating: "a", count: 64), sourceGeneration: String(repeating: "a", count: 64), context: context), epoch: String(repeating: "1", count: 32), - revision: TeraProjectionRevision(rawValue: 1), delivery: .change, kind: kind, entityID: nil + revision: TeraProjectionRevision(rawValue: exhausted ? nil : emittedRevision), + delivery: delivery, kind: kind, entityID: nil )) } } diff --git a/TeraTests/TeraScopedObservationTests.swift b/TeraTests/TeraScopedObservationTests.swift @@ -36,16 +36,16 @@ final class TeraScopedObservationTests: XCTestCase { var state = TeraRuntimeObservationState.inactive let pause = await backend.pause(.subscribe, fails: true) observation.start( - client: client, capacity: 8, delay: { _ in throw CancellationError() }, - state: { state = $0 }, change: { _ in } + client: client, buffer: (capacity: 8, delay: { _ in throw CancellationError() }), + state: { state = $0 }, accepts: { _ in true }, refresh: { _ in } ) await pause.entered.wait() await pause.resume.open() await TeraScopeFixtures.eventually { !observation.isActive } guard case .retrying = state else { return XCTFail("The failed retry remains visible") } observation.start( - client: client, capacity: 8, delay: { _ in throw CancellationError() }, - state: { state = $0 }, change: { _ in } + client: client, buffer: (capacity: 8, delay: { _ in throw CancellationError() }), + state: { state = $0 }, accepts: { _ in true }, refresh: { _ in } ) await TeraScopeFixtures.eventually { state == .active } let count = await backend.counts[.subscribe] diff --git a/TeraTests/TeraStoreRecoveryTests.swift b/TeraTests/TeraStoreRecoveryTests.swift @@ -0,0 +1,147 @@ +@testable import TeraApp +import XCTest + +@MainActor +final class TeraStoreRecoveryTests: XCTestCase { + func testRecoveredTodayAddAndMeReadCurrentStateWithoutAnotherEvent() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let retry = ResourceTestPause() + let today = TeraTodayStore(runtimeClient: client, observationDelay: { _ in await retry.wait() }) + let add = TeraAddStore(runtimeClient: client, observationDelay: { _ in await retry.wait() }) + let me = TeraMeStore(runtimeClient: client, observationDelay: { _ in await retry.wait() }) + configure(today: today, add: add, me: me) + await today.start() + await add.start() + await me.start() + await TeraScopeFixtures.eventually { + today.observationState == .active && add.observationState == .active && me.observationState == .active + } + await client.suspend() + await TeraScopeFixtures.eventually { + self.isRetrying(today.observationState) && self.isRetrying(add.observationState) && self.isRetrying(me.observationState) + } + await setCurrentState(backend, revision: 2) + await retry.resume.open() + await TeraScopeFixtures.eventually { + today.cards.first?.id == "revision-2" && add.drafts.first?.revision == 2 + && me.snapshot?.cards.first?.id == "revision-2" && add.blossomEvidence?.observedAtUnixMilliseconds == 2 + } + let subscriptions = await backend.counts[.subscribe] + XCTAssertEqual(subscriptions, 6) + today.stop() + add.stop() + me.stop() + _ = try await client.stop() + } + + func testFinalGapRefreshesAllStoreDomainsWithoutOverwritingTheEditingForm() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let today = TeraTodayStore(runtimeClient: client) + let add = TeraAddStore(runtimeClient: client) + let me = TeraMeStore(runtimeClient: client) + configure(today: today, add: add, me: me) + await today.start() + await add.start() + await me.start() + await TeraScopeFixtures.eventually { + today.observationState == .active && add.observationState == .active && me.observationState == .active + } + add.updateForm(\.content, "Unsaved local editing") + await setCurrentState(backend, revision: 9) + await backend.emit(.initial, delivery: .resnapshotRequired) + await TeraScopeFixtures.eventually { + today.cards.first?.id == "revision-9" && add.drafts.first?.revision == 9 + && me.snapshot?.cards.first?.id == "revision-9" && add.blossomEvidence?.observedAtUnixMilliseconds == 9 + } + XCTAssertEqual(add.form.content, "Unsaved local editing") + let refreshes = await backend.counts[.refresh] + XCTAssertEqual(refreshes, 1, "Observation recovery reads local state without another network refresh") + today.stop() + add.stop() + me.stop() + _ = try await client.stop() + } + + func testTodayStormDuringSlowReadEventuallyDisplaysTheFinalDurablePage() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let store = TeraTodayStore(runtimeClient: client) + store.configure(snapshot: TeraScopeFixtures.snapshot()) + await store.start() + await TeraScopeFixtures.eventually { store.observationState == .active && !store.cards.isEmpty } + let first = await backend.pause(.page) + await backend.emit(.today) + await first.entered.wait() + let before = await backend.counts[.page, default: 0] + await setCurrentState(backend, revision: 256) + for _ in 1 ... 256 { + await backend.emit(.today) + } + let paused = await backend.counts[.page, default: 0] + XCTAssertEqual(paused, before, "Only one observer query may execute at a time") + await first.resume.open() + await TeraScopeFixtures.eventually { store.cards.first?.id == "revision-256" } + XCTAssertEqual(store.state, .loaded) + store.stop() + _ = try await client.stop() + } + + func testOrdinaryMediaProgressPreservesLoadedPagesAndTheNextCursor() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let subscription = await backend.pause(.subscribe) + await backend.setPage(page("one", next: "two")) + await backend.setPage(page("two", next: "three"), cursor: "two") + await backend.setPage(page("three"), cursor: "three") + let store = TeraTodayStore(runtimeClient: client) + store.configure(snapshot: TeraScopeFixtures.snapshot()) + await store.start() + await backend.setPage(page("current-one", next: "two")) + await subscription.resume.open() + await TeraScopeFixtures.eventually { store.cards.first?.id == "current-one" } + await store.loadNextPage() + XCTAssertEqual(store.cards.map(\.id), ["current-one", "two"]) + let before = await backend.counts[.page, default: 0] + // Space hints beyond one actor turn so this exercises ordinary progress, + // independently of C029's intentionally stronger overflow resnapshot. + for _ in 1 ... 8 { + await backend.emit(.media) + try await Task.sleep(for: .milliseconds(10)) + } + let after = await backend.counts[.page, default: 0] + XCTAssertEqual(after, before) + XCTAssertEqual(store.cards.map(\.id), ["current-one", "two"]) + XCTAssertTrue(store.canLoadNextPage) + await store.loadNextPage() + XCTAssertEqual(store.cards.map(\.id), ["current-one", "two", "three"]) + store.stop() + _ = try await client.stop() + } + + private func configure(today: TeraTodayStore, add: TeraAddStore, me: TeraMeStore) { + let snapshot = TeraScopeFixtures.snapshot() + today.configure(snapshot: snapshot) + add.configure(snapshot: snapshot) + me.configure(context: .defaultContext(snapshot: snapshot)) + } + + private func setCurrentState(_ backend: TeraScopeBackend, revision: UInt64) async { + await backend.setPage(page("revision-\(revision)")) + await backend.setDrafts([TeraScopeFixtures.draft("revision-\(revision)", revision: revision)]) + await backend.setMeCards([TeraScopeFixtures.card("revision-\(revision)")]) + await backend.configure(TeraScopeFixtures.snapshot(evidence: TeraScopeFixtures.evidence(observedAt: revision))) + } + + private func page(_ id: String, next: String? = nil) -> TeraTodayPage { + TeraTodayPage(asOfUnixSeconds: 1, items: [TeraScopeFixtures.card(id)], nextCursor: next) + } + + private func isRetrying(_ value: TeraRuntimeObservationState) -> Bool { + if case .retrying = value { + return true + } + return false + } +} diff --git a/project.yml b/project.yml @@ -68,6 +68,9 @@ targets: - path: TeraTests/TeraScopedAddTests.swift - path: TeraTests/TeraScopedMediaTests.swift - path: TeraTests/TeraScopedObservationTests.swift + - path: TeraTests/TeraObservationCoalescingTests.swift + - path: TeraTests/TeraStoreRecoveryTests.swift + - path: TeraTests/TeraAddObservationStartupTests.swift - path: TeraTests/TeraMediaStoreTests.swift - path: TeraTests/TeraAddStoreTests.swift - path: TeraTests/TeraBackgroundUploadFixtures.swift diff --git a/release/provenance.json b/release/provenance.json @@ -6,7 +6,7 @@ "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "6b5ce897d5273290febc8b831663e12dea759cb0084f182ba7949e1c593fc410", - "xcode_project_sha256": "63f1a2832f546e957d756cad896e085a76e28283c04b8adfdc2e7ff02521cbab" + "xcode_project_sha256": "1d54cb56afd86377fa813d17527958bfa4b387f23e5cb6b6045980c2f0d99785" }, "disposition": "unsigned", "platforms": [ diff --git a/test-fixtures/swiftlint-maintainability-baseline.v1.json b/test-fixtures/swiftlint-maintainability-baseline.v1.json @@ -1 +1 @@ -[{"text":" init(","violation":{"location":{"line":83,"file":"Tera\/App\/TeraAppModel.swift","character":3},"severity":"error","ruleIdentifier":"function_body_length","reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","ruleName":"Function Body Length","ruleDescription":"Function bodies should not span too many lines"}},{"text":"actor TeraAddMediaCoordinator: TeraAddMediaHandling {","violation":{"location":{"line":73,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","character":1},"severity":"error","ruleIdentifier":"type_body_length","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 330 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":"#endif","violation":{"location":{"line":480,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","character":1},"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 400 lines or less: currently contains 480","ruleName":"File Length","ruleDescription":"Files should not span too many lines."}},{"text":"private final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {","violation":{"location":{"line":111,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","character":15},"severity":"error","ruleIdentifier":"type_body_length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 545 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":"}","violation":{"location":{"line":1657,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","character":1},"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 1000 lines or less: currently contains 1657","ruleName":"File Length","ruleDescription":"Files should not span too many lines."}},{"text":"actor TeraRuntimeClient {","violation":{"location":{"line":291,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":1},"severity":"error","ruleIdentifier":"type_body_length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 787 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {","violation":{"location":{"line":703,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":3},"severity":"error","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 70 lines","ruleName":"Function Body Length","ruleDescription":"Function bodies should not span too many lines"}},{"text":"}","violation":{"location":{"line":1159,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":1},"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 1000 lines or less: currently contains 1159","ruleName":"File Length","ruleDescription":"Files should not span too many lines."}},{"text":"}","violation":{"location":{"line":1215,"file":"Tera\/Runtime\/TeraRuntimeModels.swift","character":1},"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 1000 lines or less: currently contains 1215","ruleName":"File Length","ruleDescription":"Files should not span too many lines."}},{"text":"final class TeraAddStore: ObservableObject {","violation":{"location":{"line":11,"file":"Tera\/State\/TeraAddStore.swift","character":7},"severity":"error","ruleIdentifier":"type_body_length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 734 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":" func retract(_ card: TeraTodayCard) async {","violation":{"location":{"line":465,"file":"Tera\/State\/TeraAddStore.swift","character":3},"severity":"error","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 51 lines","ruleName":"Function Body Length","ruleDescription":"Function bodies should not span too many lines"}},{"text":"}","violation":{"location":{"line":799,"file":"Tera\/State\/TeraAddStore.swift","character":1},"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 400 lines or less: currently contains 799","ruleName":"File Length","ruleDescription":"Files should not span too many lines."}},{"text":"actor TeraConfigurationStore {","violation":{"location":{"line":78,"file":"Tera\/State\/TeraConfigurationStore.swift","character":1},"severity":"error","ruleIdentifier":"type_body_length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":" func load() throws -> TeraAppConfiguration {","violation":{"location":{"line":148,"file":"Tera\/State\/TeraConfigurationStore.swift","character":5},"severity":"error","ruleIdentifier":"function_body_length","reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines","ruleName":"Function Body Length","ruleDescription":"Function bodies should not span too many lines"}},{"text":"}","violation":{"location":{"line":759,"file":"Tera\/State\/TeraConfigurationStore.swift","character":1},"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 400 lines or less: currently contains 759","ruleName":"File Length","ruleDescription":"Files should not span too many lines."}},{"text":"actor TeraSessionStore {","violation":{"location":{"line":39,"file":"Tera\/State\/TeraSessionStore.swift","character":1},"severity":"error","ruleIdentifier":"type_body_length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 390 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {","violation":{"location":{"line":125,"file":"Tera\/State\/TeraSessionStore.swift","character":11},"severity":"error","ruleIdentifier":"cyclomatic_complexity","reason":"Function should have complexity 10 or less; currently complexity is 13","ruleName":"Cyclomatic Complexity","ruleDescription":"Complexity of function bodies should be limited."}},{"text":"}","violation":{"location":{"line":452,"file":"Tera\/State\/TeraSessionStore.swift","character":1},"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 400 lines or less: currently contains 452","ruleName":"File Length","ruleDescription":"Files should not span too many lines."}},{"text":"struct TeraAddView: View {","violation":{"location":{"line":4,"file":"Tera\/Views\/TeraAddView.swift","character":1},"severity":"error","ruleIdentifier":"type_body_length","reason":"Struct body should span 350 lines or less excluding comments and whitespace: currently spans 454 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":"}","violation":{"location":{"line":560,"file":"Tera\/Views\/TeraAddView.swift","character":1},"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 400 lines or less: currently contains 560","ruleName":"File Length","ruleDescription":"Files should not span too many lines."}},{"text":"struct TeraSettingsView: View {","violation":{"location":{"line":365,"file":"Tera\/Views\/TeraSupportingViews.swift","character":1},"severity":"error","ruleIdentifier":"type_body_length","reason":"Struct body should span 250 lines or less excluding comments and whitespace: currently spans 259 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":"}","violation":{"location":{"line":629,"file":"Tera\/Views\/TeraSupportingViews.swift","character":1},"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 400 lines or less: currently contains 629","ruleName":"File Length","ruleDescription":"Files should not span too many lines."}},{"text":"}","violation":{"location":{"line":493,"file":"Tera\/Views\/TeraTodayView.swift","character":1},"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 400 lines or less: currently contains 493","ruleName":"File Length","ruleDescription":"Files should not span too many lines."}},{"text":"final class TeraAddStoreTests: XCTestCase {","violation":{"location":{"line":6,"file":"TeraTests\/TeraAddStoreTests.swift","character":7},"severity":"error","ruleIdentifier":"type_body_length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 665 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":"private actor AddBackend: TeraRuntimeBackend {","violation":{"location":{"line":847,"file":"TeraTests\/TeraAddStoreTests.swift","character":9},"severity":"error","ruleIdentifier":"type_body_length","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 492 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":" private func makeStatus(","violation":{"location":{"line":1283,"file":"TeraTests\/TeraAddStoreTests.swift","character":11},"severity":"error","ruleIdentifier":"function_parameter_count","reason":"Function should have 5 parameters or less: it currently has 8","ruleName":"Function Parameter Count","ruleDescription":"Number of function parameters should be low."}},{"text":"}","violation":{"location":{"line":1434,"file":"TeraTests\/TeraAddStoreTests.swift","character":1},"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 1000 lines or less: currently contains 1434","ruleName":"File Length","ruleDescription":"Files should not span too many lines."}},{"text":"final class TeraRemoteQualificationTests: XCTestCase {","violation":{"location":{"line":7,"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":7},"severity":"error","ruleIdentifier":"type_body_length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {","violation":{"location":{"line":45,"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":3},"severity":"error","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines","ruleName":"Function Body Length","ruleDescription":"Function bodies should not span too many lines"}},{"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {","violation":{"location":{"line":212,"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":3},"severity":"error","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","ruleName":"Function Body Length","ruleDescription":"Function bodies should not span too many lines"}},{"text":"final class TeraRuntimeClientTests: XCTestCase {","violation":{"location":{"line":4,"file":"TeraTests\/TeraRuntimeClientTests.swift","character":7},"severity":"error","ruleIdentifier":"type_body_length","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 298 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":"final class TeraStateMigrationTests: XCTestCase {","violation":{"location":{"line":6,"file":"TeraTests\/TeraStateMigrationTests.swift","character":7},"severity":"error","ruleIdentifier":"type_body_length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":" func testRelayValidationMatchesRuntimeProfiles() throws {","violation":{"location":{"line":7,"file":"TeraTests\/TeraStateMigrationTests.swift","character":5},"severity":"error","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","ruleName":"Function Body Length","ruleDescription":"Function bodies should not span too many lines"}},{"text":"}","violation":{"location":{"line":622,"file":"TeraTests\/TeraStateMigrationTests.swift","character":1},"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 400 lines or less: currently contains 622","ruleName":"File Length","ruleDescription":"Files should not span too many lines."}},{"text":"private actor SupportingBackend: TeraRuntimeBackend {","violation":{"location":{"line":207,"file":"TeraTests\/TeraSupportingStoreTests.swift","character":9},"severity":"error","ruleIdentifier":"type_body_length","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 253 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":"}","violation":{"location":{"line":487,"file":"TeraTests\/TeraSupportingStoreTests.swift","character":1},"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 400 lines or less: currently contains 487","ruleName":"File Length","ruleDescription":"Files should not span too many lines."}},{"text":"final class TeraRemoteQualificationUITests: XCTestCase {","violation":{"location":{"line":4,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":7},"severity":"error","ruleIdentifier":"type_body_length","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1493 lines","ruleName":"Type Body Length","ruleDescription":"Type bodies should not span too many lines"}},{"text":" func testLocalSocialFiveFlowScenario() throws {","violation":{"location":{"line":62,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3},"severity":"error","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 73 lines","ruleName":"Function Body Length","ruleDescription":"Function bodies should not span too many lines"}},{"text":" func testLocalSocialDeterministicPersonas() throws {","violation":{"location":{"line":174,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3},"severity":"error","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","ruleName":"Function Body Length","ruleDescription":"Function bodies should not span too many lines"}},{"text":" func testRemoteBlossomUploadAndRecovery() throws {","violation":{"location":{"line":247,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3},"severity":"error","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines","ruleName":"Function Body Length","ruleDescription":"Function bodies should not span too many lines"}},{"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {","violation":{"location":{"line":573,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11},"severity":"error","ruleIdentifier":"cyclomatic_complexity","reason":"Function should have complexity 10 or less; currently complexity is 18","ruleName":"Cyclomatic Complexity","ruleDescription":"Complexity of function bodies should be limited."}},{"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {","violation":{"location":{"line":767,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11},"severity":"error","ruleIdentifier":"cyclomatic_complexity","reason":"Function should have complexity 10 or less; currently complexity is 11","ruleName":"Cyclomatic Complexity","ruleDescription":"Complexity of function bodies should be limited."}},{"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {","violation":{"location":{"line":767,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11},"severity":"error","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","ruleName":"Function Body Length","ruleDescription":"Function bodies should not span too many lines"}},{"text":" static func environment(","violation":{"location":{"line":1736,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":10},"severity":"error","ruleIdentifier":"function_body_length","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines","ruleName":"Function Body Length","ruleDescription":"Function bodies should not span too many lines"}},{"text":"}","violation":{"location":{"line":2212,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":1},"severity":"error","ruleIdentifier":"file_length","reason":"File should contain 1000 lines or less: currently contains 2212","ruleName":"File Length","ruleDescription":"Files should not span too many lines."}}] -\ No newline at end of file +[{"violation":{"ruleIdentifier":"function_body_length","ruleName":"Function Body Length","severity":"error","location":{"file":"Tera\/App\/TeraAppModel.swift","character":3,"line":83},"reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","ruleDescription":"Function bodies should not span too many lines"},"text":" init("},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","character":1,"line":73},"reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 330 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"actor TeraAddMediaCoordinator: TeraAddMediaHandling {"},{"violation":{"ruleIdentifier":"file_length","ruleName":"File Length","severity":"error","location":{"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","character":1,"line":480},"reason":"File should contain 400 lines or less: currently contains 480","ruleDescription":"Files should not span too many lines."},"text":"#endif"},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","character":15,"line":111},"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 545 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"private final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {"},{"violation":{"ruleIdentifier":"file_length","ruleName":"File Length","severity":"error","location":{"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","character":1,"line":1657},"reason":"File should contain 1000 lines or less: currently contains 1657","ruleDescription":"Files should not span too many lines."},"text":"}"},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":1,"line":291},"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 787 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"actor TeraRuntimeClient {"},{"violation":{"ruleIdentifier":"function_body_length","ruleName":"Function Body Length","severity":"error","location":{"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":3,"line":703},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 70 lines","ruleDescription":"Function bodies should not span too many lines"},"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {"},{"violation":{"ruleIdentifier":"file_length","ruleName":"File Length","severity":"error","location":{"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":1,"line":1159},"reason":"File should contain 1000 lines or less: currently contains 1159","ruleDescription":"Files should not span too many lines."},"text":"}"},{"violation":{"ruleIdentifier":"file_length","ruleName":"File Length","severity":"error","location":{"file":"Tera\/Runtime\/TeraRuntimeModels.swift","character":1,"line":1215},"reason":"File should contain 1000 lines or less: currently contains 1215","ruleDescription":"Files should not span too many lines."},"text":"}"},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"Tera\/State\/TeraAddStore.swift","character":7,"line":11},"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 731 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"final class TeraAddStore: ObservableObject {"},{"violation":{"ruleIdentifier":"function_body_length","ruleName":"Function Body Length","severity":"error","location":{"file":"Tera\/State\/TeraAddStore.swift","character":3,"line":459},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 51 lines","ruleDescription":"Function bodies should not span too many lines"},"text":" func retract(_ card: TeraTodayCard) async {"},{"violation":{"ruleIdentifier":"file_length","ruleName":"File Length","severity":"error","location":{"file":"Tera\/State\/TeraAddStore.swift","character":1,"line":796},"reason":"File should contain 400 lines or less: currently contains 796","ruleDescription":"Files should not span too many lines."},"text":"}"},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"Tera\/State\/TeraConfigurationStore.swift","character":1,"line":78},"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"actor TeraConfigurationStore {"},{"violation":{"ruleIdentifier":"function_body_length","ruleName":"Function Body Length","severity":"error","location":{"file":"Tera\/State\/TeraConfigurationStore.swift","character":5,"line":148},"reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines","ruleDescription":"Function bodies should not span too many lines"},"text":" func load() throws -> TeraAppConfiguration {"},{"violation":{"ruleIdentifier":"file_length","ruleName":"File Length","severity":"error","location":{"file":"Tera\/State\/TeraConfigurationStore.swift","character":1,"line":759},"reason":"File should contain 400 lines or less: currently contains 759","ruleDescription":"Files should not span too many lines."},"text":"}"},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"Tera\/State\/TeraSessionStore.swift","character":1,"line":39},"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 390 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"actor TeraSessionStore {"},{"violation":{"ruleIdentifier":"cyclomatic_complexity","ruleName":"Cyclomatic Complexity","severity":"error","location":{"file":"Tera\/State\/TeraSessionStore.swift","character":11,"line":125},"reason":"Function should have complexity 10 or less; currently complexity is 13","ruleDescription":"Complexity of function bodies should be limited."},"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {"},{"violation":{"ruleIdentifier":"file_length","ruleName":"File Length","severity":"error","location":{"file":"Tera\/State\/TeraSessionStore.swift","character":1,"line":452},"reason":"File should contain 400 lines or less: currently contains 452","ruleDescription":"Files should not span too many lines."},"text":"}"},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"Tera\/Views\/TeraAddView.swift","character":1,"line":4},"reason":"Struct body should span 350 lines or less excluding comments and whitespace: currently spans 454 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"struct TeraAddView: View {"},{"violation":{"ruleIdentifier":"file_length","ruleName":"File Length","severity":"error","location":{"file":"Tera\/Views\/TeraAddView.swift","character":1,"line":560},"reason":"File should contain 400 lines or less: currently contains 560","ruleDescription":"Files should not span too many lines."},"text":"}"},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"Tera\/Views\/TeraSupportingViews.swift","character":1,"line":365},"reason":"Struct body should span 250 lines or less excluding comments and whitespace: currently spans 259 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"struct TeraSettingsView: View {"},{"violation":{"ruleIdentifier":"file_length","ruleName":"File Length","severity":"error","location":{"file":"Tera\/Views\/TeraSupportingViews.swift","character":1,"line":629},"reason":"File should contain 400 lines or less: currently contains 629","ruleDescription":"Files should not span too many lines."},"text":"}"},{"violation":{"ruleIdentifier":"file_length","ruleName":"File Length","severity":"error","location":{"file":"Tera\/Views\/TeraTodayView.swift","character":1,"line":493},"reason":"File should contain 400 lines or less: currently contains 493","ruleDescription":"Files should not span too many lines."},"text":"}"},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"TeraTests\/TeraAddStoreTests.swift","character":7,"line":6},"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 665 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"final class TeraAddStoreTests: XCTestCase {"},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"TeraTests\/TeraAddStoreTests.swift","character":9,"line":847},"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 492 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"private actor AddBackend: TeraRuntimeBackend {"},{"violation":{"ruleIdentifier":"function_parameter_count","ruleName":"Function Parameter Count","severity":"error","location":{"file":"TeraTests\/TeraAddStoreTests.swift","character":11,"line":1283},"reason":"Function should have 5 parameters or less: it currently has 8","ruleDescription":"Number of function parameters should be low."},"text":" private func makeStatus("},{"violation":{"ruleIdentifier":"file_length","ruleName":"File Length","severity":"error","location":{"file":"TeraTests\/TeraAddStoreTests.swift","character":1,"line":1434},"reason":"File should contain 1000 lines or less: currently contains 1434","ruleDescription":"Files should not span too many lines."},"text":"}"},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":7,"line":7},"reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"final class TeraRemoteQualificationTests: XCTestCase {"},{"violation":{"ruleIdentifier":"function_body_length","ruleName":"Function Body Length","severity":"error","location":{"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":3,"line":45},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines","ruleDescription":"Function bodies should not span too many lines"},"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {"},{"violation":{"ruleIdentifier":"function_body_length","ruleName":"Function Body Length","severity":"error","location":{"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":3,"line":212},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","ruleDescription":"Function bodies should not span too many lines"},"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {"},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"TeraTests\/TeraRuntimeClientTests.swift","character":7,"line":4},"reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 298 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"final class TeraRuntimeClientTests: XCTestCase {"},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"TeraTests\/TeraStateMigrationTests.swift","character":7,"line":6},"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"final class TeraStateMigrationTests: XCTestCase {"},{"violation":{"ruleIdentifier":"function_body_length","ruleName":"Function Body Length","severity":"error","location":{"file":"TeraTests\/TeraStateMigrationTests.swift","character":5,"line":7},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","ruleDescription":"Function bodies should not span too many lines"},"text":" func testRelayValidationMatchesRuntimeProfiles() throws {"},{"violation":{"ruleIdentifier":"file_length","ruleName":"File Length","severity":"error","location":{"file":"TeraTests\/TeraStateMigrationTests.swift","character":1,"line":622},"reason":"File should contain 400 lines or less: currently contains 622","ruleDescription":"Files should not span too many lines."},"text":"}"},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"TeraTests\/TeraSupportingStoreTests.swift","character":9,"line":207},"reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 253 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"private actor SupportingBackend: TeraRuntimeBackend {"},{"violation":{"ruleIdentifier":"file_length","ruleName":"File Length","severity":"error","location":{"file":"TeraTests\/TeraSupportingStoreTests.swift","character":1,"line":487},"reason":"File should contain 400 lines or less: currently contains 487","ruleDescription":"Files should not span too many lines."},"text":"}"},{"violation":{"ruleIdentifier":"type_body_length","ruleName":"Type Body Length","severity":"error","location":{"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":7,"line":4},"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1493 lines","ruleDescription":"Type bodies should not span too many lines"},"text":"final class TeraRemoteQualificationUITests: XCTestCase {"},{"violation":{"ruleIdentifier":"function_body_length","ruleName":"Function Body Length","severity":"error","location":{"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3,"line":62},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 73 lines","ruleDescription":"Function bodies should not span too many lines"},"text":" func testLocalSocialFiveFlowScenario() throws {"},{"violation":{"ruleIdentifier":"function_body_length","ruleName":"Function Body Length","severity":"error","location":{"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3,"line":174},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","ruleDescription":"Function bodies should not span too many lines"},"text":" func testLocalSocialDeterministicPersonas() throws {"},{"violation":{"ruleIdentifier":"function_body_length","ruleName":"Function Body Length","severity":"error","location":{"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3,"line":247},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines","ruleDescription":"Function bodies should not span too many lines"},"text":" func testRemoteBlossomUploadAndRecovery() throws {"},{"violation":{"ruleIdentifier":"cyclomatic_complexity","ruleName":"Cyclomatic Complexity","severity":"error","location":{"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11,"line":573},"reason":"Function should have complexity 10 or less; currently complexity is 18","ruleDescription":"Complexity of function bodies should be limited."},"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {"},{"violation":{"ruleIdentifier":"cyclomatic_complexity","ruleName":"Cyclomatic Complexity","severity":"error","location":{"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11,"line":767},"reason":"Function should have complexity 10 or less; currently complexity is 11","ruleDescription":"Complexity of function bodies should be limited."},"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {"},{"violation":{"ruleIdentifier":"function_body_length","ruleName":"Function Body Length","severity":"error","location":{"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11,"line":767},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","ruleDescription":"Function bodies should not span too many lines"},"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {"},{"violation":{"ruleIdentifier":"function_body_length","ruleName":"Function Body Length","severity":"error","location":{"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":10,"line":1736},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines","ruleDescription":"Function bodies should not span too many lines"},"text":" static func environment("},{"violation":{"ruleIdentifier":"file_length","ruleName":"File Length","severity":"error","location":{"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":1,"line":2212},"reason":"File should contain 1000 lines or less: currently contains 2212","ruleDescription":"Files should not span too many lines."},"text":"}"}] +\ No newline at end of file