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 3138680690555f109ddb48be0550f91e3effb2ea
parent 6221682b7d12c1a8bb3f556fde214b0191a24172
Author: triesap <tyson@radroots.org>
Date:   Thu, 10 Sep 2026 16:00:58 +0000

compose: Coalesce saves with monotonic edit sequence

- Bound native autosave work and preserve the newest partial editing
- Reconcile exact durable receipts before retrying unknown outcomes
- Separate explicit local Save from strict publication preparation
- Qualify scheduling, lifecycle and actual native persistence regressions

Diffstat:
MPackage.swift | 4++++
MTera.xcodeproj/project.pbxproj | 8++++++++
MTera/State/TeraAddStore.swift | 178+++++++++++++++++++++++++++++++++++++++----------------------------------------
ATera/State/TeraAddUploadCompletion.swift | 36++++++++++++++++++++++++++++++++++++
ATera/State/TeraComposerAutosave.swift | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATera/State/TeraComposerSaveState.swift | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATera/Views/TeraAddSaveStatus.swift | 20++++++++++++++++++++
MTera/Views/TeraAddView.swift | 8+-------
MTeraFFI/provenance.json | 14+++++++-------
MTeraFFI/source.lock | 4++--
MTeraFFI/source/aarch64-apple-darwin.json | 8++++----
MTeraFFI/source/aarch64-apple-ios-sim.json | 8++++----
MTeraFFI/source/aarch64-apple-ios.json | 8++++----
ATeraTests/TeraAddMediaHarness.swift | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTeraTests/TeraAddStoreTests.swift | 126++++++++++++++++++++-----------------------------------------------------------
ATeraTests/TeraComposerAutosaveTests.swift | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTeraTests/TeraComposerPersistenceFFITests.swift | 38++++++++++++++++++++++++++++++++++++++
MTeraTests/TeraScopeBackend.swift | 16+++++++++++++++-
MTeraTests/TeraScopedAddTests.swift | 12++++++------
Mproject.yml | 2++
Mrelease/provenance.json | 6+++---
Mtest-fixtures/legacy-identifiers.v1.json | 4++--
Mtest-fixtures/maintainability-baseline.v1.json | 4++--
Mtest-fixtures/swiftlint-maintainability-baseline.v1.json | 4++--
24 files changed, 915 insertions(+), 230 deletions(-)

diff --git a/Package.swift b/Package.swift @@ -94,6 +94,9 @@ let package = Package( "Runtime/TeraUserMessageClassifier.swift", "Runtime/TeraUserMessages.swift", "State/TeraAddStore.swift", + "State/TeraAddUploadCompletion.swift", + "State/TeraComposerAutosave.swift", + "State/TeraComposerSaveState.swift", "State/TeraTodayRelayPresentation.swift", "State/TeraTodayDiscoveryPresentation.swift", "State/TeraAddStartupSnapshot.swift", @@ -114,6 +117,7 @@ let package = Package( "State/TeraTodayReconciliationTask.swift", "State/TeraTodayPresentation.swift", "Views/TeraAddView.swift", + "Views/TeraAddSaveStatus.swift", "Views/TeraSupportingViews.swift", "Views/TeraContextPicker.swift", "Views/TeraTodayDiscoveryView.swift", diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 39B47EE698E9D7CC716A372F /* TeraRuntimeShutdownTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91E82E561F73A1BE6BBF09B7 /* TeraRuntimeShutdownTests.swift */; }; 3D219E759CFB685936EB7068 /* TeraCalendarEditingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C48B4B5442B1A404C1A328 /* TeraCalendarEditingTests.swift */; }; 4734A842C366608EC1BD31C5 /* TeraUserMessagesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46DDEE781FDD92A292FD847B /* TeraUserMessagesTests.swift */; }; + 4B093458FAF4C6E4860E313A /* TeraComposerAutosaveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55E47B15C42F13ABF2DFF27 /* TeraComposerAutosaveTests.swift */; }; 53651EBAF42E8698717CE936 /* TeraScopedStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF28A9D711806A07A4A65EA8 /* TeraScopedStoreTests.swift */; }; 5F252ABA8CA48D5E3A7A0157 /* TeraMediaVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E1E87F9A11E85700F38635 /* TeraMediaVisibilityTests.swift */; }; 5F7F8C48635A1676BD0A7AF6 /* TeraTodayPagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8120C6BA40C3087C4D11D432 /* TeraTodayPagingTests.swift */; }; @@ -33,6 +34,7 @@ 671EF50DB4EFBC25AABF26DC /* TeraLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C890BDE885536BC2AB215393 /* TeraLifecycleTests.swift */; }; 6B0BE8E92892EF60B8E996A5 /* TeraRuntimeResourceFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A51F9AF2E0803264E07FBB /* TeraRuntimeResourceFixtures.swift */; }; 6DB9A1B2B9ABD39613561459 /* TeraCalendarComposerFFITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1A8EA600F6BDC45333520C /* TeraCalendarComposerFFITests.swift */; }; + 6F01DD614B41209AF277CBE8 /* TeraAddMediaHarness.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C0B898B9BFDB82CF3B5EFB /* TeraAddMediaHarness.swift */; }; 6F53FF88A29F8DF93F074EB4 /* TeraBackgroundUploadFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D8C0835D5A1D5C36D80020 /* TeraBackgroundUploadFixtures.swift */; }; 6FED2D2255CC702B33CDC760 /* TeraOpenedMediaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B10B1F3C7FCD9A72F04FC00 /* TeraOpenedMediaTests.swift */; }; 7035433B676A50A4801A8DFC /* TeraRuntimeInvalidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73EBD10E201ECA26DCED523F /* TeraRuntimeInvalidationTests.swift */; }; @@ -162,10 +164,12 @@ C4D01C023E258AC3B3B1C27D /* TeraRuntimeResourceTaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeResourceTaskTests.swift; sourceTree = "<group>"; }; C890BDE885536BC2AB215393 /* TeraLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraLifecycleTests.swift; sourceTree = "<group>"; }; CF28A9D711806A07A4A65EA8 /* TeraScopedStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopedStoreTests.swift; sourceTree = "<group>"; }; + D2C0B898B9BFDB82CF3B5EFB /* TeraAddMediaHarness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAddMediaHarness.swift; sourceTree = "<group>"; }; D3C914B349DAE2EF66658229 /* TeraMediaFileFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraMediaFileFixture.swift; sourceTree = "<group>"; }; D8ED2718D5E9E8980ADF3423 /* TeraAddSchemaFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAddSchemaFixtures.swift; sourceTree = "<group>"; }; DE9E724A23014C6EA9042123 /* Tera.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Tera.app; sourceTree = BUILT_PRODUCTS_DIR; }; E351F52E77C74A4E99095E45 /* TeraRemoteQualificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRemoteQualificationTests.swift; sourceTree = "<group>"; }; + E55E47B15C42F13ABF2DFF27 /* TeraComposerAutosaveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraComposerAutosaveTests.swift; sourceTree = "<group>"; }; E75B60DBDD8C581D76E6F957 /* TeraScopedObservationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopedObservationTests.swift; sourceTree = "<group>"; }; EFBAA57ABEAA4B84C73EE8E8 /* TeraScopedMediaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopedMediaTests.swift; sourceTree = "<group>"; }; EFECCBF9A84D1D65544C6094 /* TeraTodayContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraTodayContextTests.swift; sourceTree = "<group>"; }; @@ -236,6 +240,7 @@ 50B89388753A1FCD1B2A4F38 /* TeraTests */ = { isa = PBXGroup; children = ( + D2C0B898B9BFDB82CF3B5EFB /* TeraAddMediaHarness.swift */, 44A4460928B20A21A94BECCE /* TeraAddObservationStartupTests.swift */, D8ED2718D5E9E8980ADF3423 /* TeraAddSchemaFixtures.swift */, 9F22CFABCF27A28408F68D25 /* TeraAddStoreTests.swift */, @@ -247,6 +252,7 @@ 5CFC5639BDCD2A5C1C27FE38 /* TeraCalendarPresentationTests.swift */, 36AF0D194520F0F02EAD6B50 /* TeraCalendarTimingTests.swift */, 2A506546B0EC1792240C36F7 /* TeraClockTests.swift */, + E55E47B15C42F13ABF2DFF27 /* TeraComposerAutosaveTests.swift */, A5938176A92F96A30CC39DFB /* TeraComposerPersistenceFFITests.swift */, 93A3358AFC23C567DB9CB02F /* TeraErrorRecoveryTests.swift */, C890BDE885536BC2AB215393 /* TeraLifecycleTests.swift */, @@ -505,6 +511,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 6F01DD614B41209AF277CBE8 /* TeraAddMediaHarness.swift in Sources */, F4AF91E71B691FE30C191852 /* TeraAddObservationStartupTests.swift in Sources */, FBA9C3463CD1827392AF9599 /* TeraAddSchemaFixtures.swift in Sources */, A1AD2A380F09994786889AF1 /* TeraAddStoreTests.swift in Sources */, @@ -516,6 +523,7 @@ BF0BF2D7F46217C4EBCA570C /* TeraCalendarPresentationTests.swift in Sources */, 9309D7175535546A0C878F75 /* TeraCalendarTimingTests.swift in Sources */, 70417FB635F06CC9829FB38C /* TeraClockTests.swift in Sources */, + 4B093458FAF4C6E4860E313A /* TeraComposerAutosaveTests.swift in Sources */, 295A19317FC58A73A68C98DC /* TeraComposerPersistenceFFITests.swift in Sources */, D918A664D9A4B9859A1A6503 /* TeraErrorRecoveryTests.swift in Sources */, 671EF50DB4EFBC25AABF26DC /* TeraLifecycleTests.swift in Sources */, diff --git a/Tera/State/TeraAddStore.swift b/Tera/State/TeraAddStore.swift @@ -1,18 +1,20 @@ import Foundation -enum TeraAddLoadState: Sendable, Equatable { - case idle - case loading - case ready - case failed(String) -} - @MainActor final class TeraAddStore: ObservableObject { @Published private(set) var schemas: [TeraAddSchema] = [] @Published private(set) var drafts: [TeraDraftStatus] = [] @Published private(set) var activeDraft: TeraDraftStatus? - @Published private(set) var form: TeraAddForm + @Published private(set) var form: TeraAddForm { + didSet { + if form != oldValue { + message = nil + composerFormChanged() + } + } + } + + @Published private(set) var composerState: TeraComposerSaveState = .idle @Published private(set) var state: TeraAddLoadState = .idle @Published private(set) var mediaSupport: TeraAddMediaSupport = .unavailable @Published private(set) var blossomConfiguration: TeraBlossomConfigurationStatus? @@ -24,6 +26,7 @@ final class TeraAddStore: ObservableObject { @Published private(set) var observationState: TeraRuntimeObservationState = .inactive private let runtimeClient: TeraRuntimeClient + private let composer: TeraComposerAutosave private let media: (any TeraAddMediaHandling)? private let observationDelay: @Sendable (UInt32) async throws -> Void private let identifier: @Sendable () -> String @@ -53,6 +56,7 @@ final class TeraAddStore: ObservableObject { TeraRuntimeObservationBackoff.sleep ) { self.runtimeClient = runtimeClient + composer = TeraComposerAutosave(persistence: TeraComposerPersistence(client: runtimeClient)) self.media = media self.identifier = identifier self.clock = clock @@ -62,57 +66,39 @@ final class TeraAddStore: ObservableObject { identifier: identifier, clock: clock ) + composer.stateChanged = { [weak self] in self?.composerState = $0 } } - var selectedSchema: TeraAddSchema? { - schemas.first(where: { $0.commandType == form.commandType }) - } - - var isFormEditable: Bool { - guard activeDraft?.isRevision != true, activeDraft?.kind != .retraction else { return false } - return activeDraft?.state.isEditable ?? true - } - - var isProductReady: Bool { - state == .ready && selectedSchema != nil - } - - var canSave: Bool { - isProductReady && isFormEditable && !isWorking - } - - var canSubmit: Bool { - isProductReady && !isWorking - && (activeDraft?.isRevision == true || activeDraft?.state.canAdvance == true - || isFormEditable) + var savedComposer: TeraComposerDraft? { + composer.acknowledged } - var acceptsMedia: Bool { - mediaLimit > 0 - } - - var canAddMedia: Bool { - isFormEditable && acceptsMedia && form.media.count < mediaLimit - } - - private var mediaLimit: Int { - guard - let maximum = selectedSchema?.fields - .first(where: { $0.kind == .media })?.maxItems - else { return 0 } - return Int(maximum) + private func composerFormChanged() { + guard revisionTarget == nil, isFormEditable else { + composer.stop() + composerState = revisionTarget == nil ? .idle : .revision + return + } + composer.change(TeraComposerForm(editing: form)) } func configure(snapshot: TeraRuntimeSnapshot) { let updated = TeraPresentationConfiguration(snapshot: snapshot) + let scope = TeraComposerScope(authorPublicKey: updated.publicKey, localNetworkID: updated.context.id) + let scopeChanged = composer.scope != scope + if scopeChanged { + composer.reset(scope: scope) + } if let configuration, configuration != updated { stop() schemas = [] drafts = [] - activeDraft = nil - revisionTarget = nil - revisionOperationID = nil - form = TeraAddPresentation.newForm(type: form.commandType, identifier: identifier, clock: clock) + if scopeChanged { + activeDraft = nil + revisionTarget = nil + revisionOperationID = nil + form = TeraAddPresentation.newForm(type: form.commandType, identifier: identifier, clock: clock) + } state = .idle mediaSupport = .unavailable message = nil @@ -127,6 +113,7 @@ final class TeraAddStore: ObservableObject { func start() async { guard !observation.isActive, !Task.isCancelled else { return } + composer.resume() startObservation() guard operationGeneration == nil else { return } message = nil @@ -158,6 +145,7 @@ final class TeraAddStore: ObservableObject { } func stop() { + composer.stop() generation = generation.invalidated() operationGeneration = nil probeGeneration = probeGeneration.invalidated() @@ -176,16 +164,7 @@ final class TeraAddStore: ObservableObject { func selectType(_ type: TeraAddCommandType) { guard !isWorking, isFormEditable, form.commandType != type else { return } - generation = generation.invalidated() - activeDraft = nil - revisionTarget = nil - revisionOperationID = nil - form = TeraAddPresentation.newForm( - type: type, - identifier: identifier, - clock: clock - ) - message = nil + newDraft(type: type) } func updateForm<Value>(_ keyPath: WritableKeyPath<TeraAddForm, Value>, _ value: Value) { @@ -196,6 +175,7 @@ final class TeraAddStore: ObservableObject { func newDraft(type: TeraAddCommandType? = nil) { guard !isWorking else { return } + composer.reset(scope: composer.scope) generation = generation.invalidated() activeDraft = nil revisionTarget = nil @@ -216,6 +196,7 @@ final class TeraAddStore: ObservableObject { } generation = generation.invalidated() activeDraft = draft + composer.reset(scope: composer.scope) revisionTarget = nil revisionOperationID = draft.isRevision ? draft.id : nil form = snapshot @@ -304,7 +285,12 @@ final class TeraAddStore: ObservableObject { func save() async { await perform { requestedGeneration in - _ = try await self.saveCurrentForm(generation: requestedGeneration) + if self.revisionTarget != nil { + _ = try await self.saveCurrentForm(generation: requestedGeneration) + } else { + guard self.isFormEditable else { throw TeraComposerAcknowledgment.unconfirmed } + _ = try await self.composer.save(TeraComposerForm(editing: self.form)) + } try self.ensureCurrent(requestedGeneration) self.message = "Draft saved on this device." } @@ -449,6 +435,7 @@ final class TeraAddStore: ObservableObject { sourceAddress: card.sourceAddress, authorPublicKey: card.authorPublicKey ) + self.composer.reset(scope: self.composer.scope) self.revisionOperationID = nil self.activeDraft = nil self.form = sourceForm @@ -600,7 +587,7 @@ final class TeraAddStore: ObservableObject { try accept(job.draft, generation: requestedGeneration) let receipt = try await media.uploadInBackground(job: job, media: persisted) try ensureCurrent(requestedGeneration) - status = try await completeBackgroundUpload(receipt, handle: handle, media: media) + status = try await TeraAddUploadCompletion.complete(receipt, handle: handle, media: media, runtimeClient: runtimeClient) try await media.settleBackgroundUpload(identifier: receipt.identifier, accepted: true) try accept(status, generation: requestedGeneration) await refreshBlossomSnapshot() @@ -609,38 +596,6 @@ final class TeraAddStore: ObservableObject { return status } - private func completeBackgroundUpload( - _ receipt: TeraAddBackgroundUploadReceipt, - handle: TeraPreparedMediaHandle, - media: any TeraAddMediaHandling - ) async throws -> TeraDraftStatus { - do { - return try await runtimeClient.completeAddMediaBackground( - input: TeraNativeUploadCompletion( - draftID: receipt.draftID, - expectedRevision: receipt.expectedRevision, - media: handle, - statusCode: receipt.statusCode, - responseMediaType: receipt.mediaType, - responseContentEncoding: receipt.contentEncoding, - responseBody: receipt.body - ) - ) - } catch is CancellationError { - // Rust completion may already be durable. Leave the receipt awaiting - // verification so relaunch can reconcile the unknown outcome. - throw CancellationError() - } catch { - if TeraAddPresentation.failure(for: error)?.code == "ios.runtime.cancelled" { - // The bounded runtime client cannot prove whether a cancelled FFI - // completion became durable. Preserve the receipt for reconciliation. - throw CancellationError() - } - try? await media.settleBackgroundUpload(identifier: receipt.identifier, accepted: false) - throw error - } - } - private func openedMedia(_ values: [TeraPreparedMedia]? = nil) async throws -> TeraOpenedMedia { @@ -794,3 +749,44 @@ final class TeraAddStore: ObservableObject { } } } + +extension TeraAddStore { + var selectedSchema: TeraAddSchema? { + schemas.first(where: { $0.commandType == form.commandType }) + } + + var isFormEditable: Bool { + guard activeDraft?.isRevision != true, activeDraft?.kind != .retraction else { return false } + return activeDraft?.state.isEditable ?? true + } + + var isProductReady: Bool { + state == .ready && selectedSchema != nil + } + + var canSave: Bool { + isProductReady && isFormEditable && !isWorking + } + + var canSubmit: Bool { + isProductReady && !isWorking + && (activeDraft?.isRevision == true || activeDraft?.state.canAdvance == true + || isFormEditable) + } + + var acceptsMedia: Bool { + mediaLimit > 0 + } + + var canAddMedia: Bool { + isFormEditable && acceptsMedia && form.media.count < mediaLimit + } + + private var mediaLimit: Int { + guard + let maximum = selectedSchema?.fields + .first(where: { $0.kind == .media })?.maxItems + else { return 0 } + return Int(maximum) + } +} diff --git a/Tera/State/TeraAddUploadCompletion.swift b/Tera/State/TeraAddUploadCompletion.swift @@ -0,0 +1,36 @@ +import Foundation + +enum TeraAddUploadCompletion { + static func complete( + _ receipt: TeraAddBackgroundUploadReceipt, + handle: TeraPreparedMediaHandle, + media: any TeraAddMediaHandling, + runtimeClient: TeraRuntimeClient + ) async throws -> TeraDraftStatus { + do { + return try await runtimeClient.completeAddMediaBackground( + input: TeraNativeUploadCompletion( + draftID: receipt.draftID, + expectedRevision: receipt.expectedRevision, + media: handle, + statusCode: receipt.statusCode, + responseMediaType: receipt.mediaType, + responseContentEncoding: receipt.contentEncoding, + responseBody: receipt.body + ) + ) + } catch is CancellationError { + // Rust completion may already be durable. Leave the receipt awaiting + // verification so relaunch can reconcile the unknown outcome. + throw CancellationError() + } catch { + if TeraAddPresentation.failure(for: error)?.code == "ios.runtime.cancelled" { + // The bounded runtime client cannot prove whether a cancelled FFI + // completion became durable. Preserve the receipt for reconciliation. + throw CancellationError() + } + try? await media.settleBackgroundUpload(identifier: receipt.identifier, accepted: false) + throw error + } + } +} diff --git a/Tera/State/TeraComposerAutosave.swift b/Tera/State/TeraComposerAutosave.swift @@ -0,0 +1,197 @@ +import Foundation + +/// A single worker retains one attempted write and the newest edit. Receipts +/// update acknowledgment metadata only; they never replace the editing form. +@MainActor +final class TeraComposerAutosave { + private(set) var state: TeraComposerSaveState = .idle { + didSet { + if state != oldValue { + stateChanged(state) + } + } + } + + var stateChanged: (TeraComposerSaveState) -> Void = { _ in } + private(set) var acknowledged: TeraComposerDraft? + private(set) var scope: TeraComposerScope? + private(set) var editSequence: UInt64 = 0 + private(set) var id: String? + private let persistence: TeraComposerPersistence + private let delay: @Sendable () async throws -> Void + private var generation = TeraSessionGeneration.initial + private var current: TeraComposerForm? + private var attempted: TeraComposerSaveRequest? + private var worker: Task<Void, Never>? + private var paused = false + private var exhausted = false + + init( + persistence: TeraComposerPersistence, + delay: @escaping @Sendable () async throws -> Void = { try await Task.sleep(for: .milliseconds(250)) } + ) { + self.persistence = persistence + self.delay = delay + } + + deinit { worker?.cancel() } + + var isDirty: Bool { + current != nil && (exhausted || acknowledged?.editSequence != editSequence || acknowledged?.form != current) + } + + /// A replaced scope or New operation invalidates callbacks, but keeps the + /// worker slot occupied until it exits, preventing abandoned task fan-out. + func reset(scope: TeraComposerScope?) { + generation = generation.invalidated() + worker?.cancel() + self.scope = scope + id = nil + current = nil + acknowledged = nil + attempted = nil + editSequence = 0 + exhausted = false + paused = false + state = .idle + } + + func stop() { + generation = generation.invalidated() + paused = true + worker?.cancel() + if isDirty { + state = .unsaved + } + } + + func resume() { + paused = false + startWorker() + } + + func change(_ form: TeraComposerForm) { + guard form != current else { return } + current = form + let (next, overflow) = editSequence.addingReportingOverflow(1) + guard !overflow, !exhausted else { + exhausted = true + state = .failed + return + } + editSequence = next + if state != .failed { + state = .unsaved + } + startWorker() + } + + func save(_ form: TeraComposerForm) async throws -> TeraComposerDraft { + guard !Task.isCancelled else { throw CancellationError() } + change(form) + guard !paused, !exhausted, generation.isActive, scope != nil else { + state = .failed + throw TeraComposerAcknowledgment.unconfirmed + } + let requested = generation + if state == .failed { + state = .unsaved + } + startWorker() + while let task = worker { + await task.value + try ensureCurrent(requested) + } + guard !isDirty, let acknowledged else { throw TeraComposerAcknowledgment.unconfirmed } + return acknowledged + } + + private func startWorker() { + guard worker == nil, !paused, !exhausted, generation.isActive, scope != nil, + isDirty, state != .failed else { return } + let requested = generation + worker = Task { [weak self] in + guard let self else { return } + await run(requested) + } + } + + private func run(_ requested: TeraSessionGeneration) async { + defer { + worker = nil + startWorker() + } + do { + while isDirty { + try ensureCurrent(requested) + state = .saving + try await delay() + try ensureCurrent(requested) + try await reconcileAttempt(requested) + try await persistCurrent(requested) + } + try ensureCurrent(requested) + state = .saved + } catch { + // A write may have committed before cancellation or a lost callback. + // Keep its exact request for a read before any retry in this lifetime. + if generation == requested, !paused { + state = .failed + } + } + } + + private func persistCurrent(_ requested: TeraSessionGeneration) async throws { + if id == nil { + let reserved = try await persistence.reserve() + try ensureCurrent(requested) + guard TeraAddPresentation.isValidIdentifier(reserved), reserved != String(repeating: "0", count: 32) else { + throw TeraComposerAcknowledgment.unconfirmed + } + id = reserved + } + guard isDirty, let id, let scope, let current else { return } + let request = TeraComposerSaveRequest(scope: scope, id: id, expectedRevision: acknowledged?.revision, + editSequence: editSequence, form: current) + attempted = request + let receipt = try await persistence.save(request) + try ensureCurrent(requested) + guard TeraComposerAcknowledgment.matches(receipt.draft, request: request) else { + throw TeraComposerAcknowledgment.unconfirmed + } + acknowledged = receipt.draft + attempted = nil + } + + private func reconcileAttempt(_ requested: TeraSessionGeneration) async throws { + guard let attempted else { return } + let loaded: TeraComposerDraft + do { + loaded = try await persistence.load(attempted.scope, attempted.id) + } catch { + try ensureCurrent(requested) + if attempted.expectedRevision == nil, + TeraAddPresentation.failure(for: error)?.code == "composer_not_found" + { + self.attempted = nil + return + } + throw error + } + try ensureCurrent(requested) + if TeraComposerAcknowledgment.matches(loaded, request: attempted) { + acknowledged = loaded + } else if loaded != acknowledged { + // A different writer won the CAS. Preserve this form for recovery; + // silently adopting its revision would overwrite someone else's edit. + throw TeraComposerAcknowledgment.unconfirmed + } + self.attempted = nil + } + + private func ensureCurrent(_ requested: TeraSessionGeneration) throws { + guard generation == requested, generation.isActive, !paused, !exhausted, !Task.isCancelled else { + throw CancellationError() + } + } +} diff --git a/Tera/State/TeraComposerSaveState.swift b/Tera/State/TeraComposerSaveState.swift @@ -0,0 +1,63 @@ +import Foundation + +enum TeraAddLoadState: Sendable, Equatable { + case idle + case loading + case ready + case failed(String) +} + +enum TeraComposerSaveState: Equatable { + case idle + case unsaved + case saving + case saved + case failed + case revision + + var label: String { + switch self { + case .idle: "Edits save on this device." + case .unsaved: "Changes have not been saved." + case .saving: "Saving changes on this device…" + case .saved: "Changes saved on this device." + case .failed: "Changes are not confirmed saved. Use Save draft to retry." + case .revision: "Use Save draft to save this revision before leaving." + } + } +} + +struct TeraComposerPersistence: Sendable { + var reserve: @Sendable () async throws -> String + var save: @Sendable (TeraComposerSaveRequest) async throws -> TeraComposerSaveReceipt + var load: @Sendable (TeraComposerScope, String) async throws -> TeraComposerDraft + + init(client: TeraRuntimeClient) { + reserve = { try await client.reserveComposerID() } + save = { try await client.saveComposer(request: $0) } + load = { try await client.loadComposer(scope: $0, id: $1) } + } + + init( + reserve: @escaping @Sendable () async throws -> String, + save: @escaping @Sendable (TeraComposerSaveRequest) async throws -> TeraComposerSaveReceipt, + load: @escaping @Sendable (TeraComposerScope, String) async throws -> TeraComposerDraft + ) { + self.reserve = reserve + self.save = save + self.load = load + } +} + +enum TeraComposerAcknowledgment { + static func matches(_ draft: TeraComposerDraft, request: TeraComposerSaveRequest) -> Bool { + let (revision, overflow) = (request.expectedRevision ?? 0).addingReportingOverflow(1) + return !overflow && draft.scope == request.scope && draft.id == request.id + && draft.revision == revision && draft.editSequence == request.editSequence && draft.form == request.form + } + + static var unconfirmed: TeraRuntimeFailure { + .local(operation: "add.composer.save", code: "ios.composer.save_unconfirmed", + safeMessage: "These changes are not confirmed saved. Keep this draft open and retry Save.") + } +} diff --git a/Tera/Views/TeraAddSaveStatus.swift b/Tera/Views/TeraAddSaveStatus.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct TeraAddSaveStatus: View { + let message: String? + let symbol: String + let state: TeraComposerSaveState + + var body: some View { + Section { + Text(state.label) + .foregroundStyle(.secondary) + .accessibilityIdentifier("tera.add.save-state") + if let message { + Label(message, systemImage: symbol) + .foregroundStyle(.secondary) + .accessibilityIdentifier("radroots.add.status") + } + } + } +} diff --git a/Tera/Views/TeraAddView.swift b/Tera/Views/TeraAddView.swift @@ -36,13 +36,7 @@ struct TeraAddView: View { EmptyView() } - if let message = store.message { - Section { - Label(message, systemImage: statusSymbol) - .foregroundStyle(.secondary) - .accessibilityIdentifier("radroots.add.status") - } - } + TeraAddSaveStatus(message: store.message, symbol: statusSymbol, state: store.composerState) if store.activeDraft?.kind == .retraction { Section("Retraction") { diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json @@ -94,17 +94,17 @@ { "bytes": 77497, "path": "source/aarch64-apple-darwin.json", - "sha256": "cd461ac9abc6443ae00f3ed5864f7b6e9421833f48270d20b080674b3ee2dbcd" + "sha256": "214efb53b9366976c8479f69aaa6343c97d0422fff0f43a1229a5d47c2a3bb45" }, { "bytes": 77341, "path": "source/aarch64-apple-ios-sim.json", - "sha256": "466fb8d26a3744da56d57facc29d273375dc1a47d7bc46ce11e8187c76a317e4" + "sha256": "2af792d8ea86a61a8855e755dc0c5062059b152102db2df5abb6a5343fd62ad4" }, { "bytes": 77337, "path": "source/aarch64-apple-ios.json", - "sha256": "d4c0516b2dc3890e7c8ab3e6aad2b82e3bc9484bc8f2306a0b6c37aa418f8d54" + "sha256": "2b603e0e8553f99fe456ed2f4d05a02c85f50a193c45480dab927747c7c68de2" } ], "language": "swift", @@ -112,7 +112,7 @@ "schema": "radroots.artifact-manifest.v2", "source": { "repository": "https://github.com/radrootslabs/tera", - "tree": "946572b2166fa003c1fbc4cecd2948988f707bb5" + "tree": "8b425761f4e51a4e8c66e8374944435e82ce7e8e" }, "source_records": { "aarch64-apple-darwin": "source/aarch64-apple-darwin.json", @@ -176,17 +176,17 @@ { "bytes": 77497, "path": "TeraFFI/source/aarch64-apple-darwin.json", - "sha256": "cd461ac9abc6443ae00f3ed5864f7b6e9421833f48270d20b080674b3ee2dbcd" + "sha256": "214efb53b9366976c8479f69aaa6343c97d0422fff0f43a1229a5d47c2a3bb45" }, { "bytes": 77341, "path": "TeraFFI/source/aarch64-apple-ios-sim.json", - "sha256": "466fb8d26a3744da56d57facc29d273375dc1a47d7bc46ce11e8187c76a317e4" + "sha256": "2af792d8ea86a61a8855e755dc0c5062059b152102db2df5abb6a5343fd62ad4" }, { "bytes": 77337, "path": "TeraFFI/source/aarch64-apple-ios.json", - "sha256": "d4c0516b2dc3890e7c8ab3e6aad2b82e3bc9484bc8f2306a0b6c37aa418f8d54" + "sha256": "2b603e0e8553f99fe456ed2f4d05a02c85f50a193c45480dab927747c7c68de2" } ], "schema": "tera.installed-native-artifacts.v1" diff --git a/TeraFFI/source.lock b/TeraFFI/source.lock @@ -1,7 +1,7 @@ schema = "tera.installed-source.v1" repository = "https://github.com/radrootslabs/tera" -source_tree = "946572b2166fa003c1fbc4cecd2948988f707bb5" -manifest_sha256 = "dfb91d8d668287db685e4db446fc383cfaf7813a781992836ecacdf44af504af" +source_tree = "8b425761f4e51a4e8c66e8374944435e82ce7e8e" +manifest_sha256 = "5a09782e61d5244e016460f260b7ceb8de4b31146f9114701c5583e9de83018e" source_date_epoch = 1787871027 [foundation] diff --git a/TeraFFI/source/aarch64-apple-darwin.json b/TeraFFI/source/aarch64-apple-darwin.json @@ -1665,13 +1665,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 123687, - "git_blob": "abb50638129d0dc966da3dfda59bd4325fdf7d1a", + "bytes": 123695, + "git_blob": "c4099e688317242cfcec927d6a7dc1d0ce563471", "mode": "100644", - "sha256": "09f9be02bc5d9e626586bb168207da7eeafec70a3250c0e50f149fad9d37ef67" + "sha256": "86bbcd5cfac578e9fbd63ec9b393f5dc4fddde1d8fa8efd604f5a451b8a79629" } }, "policy": "staged_inputs", - "tree": "946572b2166fa003c1fbc4cecd2948988f707bb5" + "tree": "8b425761f4e51a4e8c66e8374944435e82ce7e8e" } } diff --git a/TeraFFI/source/aarch64-apple-ios-sim.json b/TeraFFI/source/aarch64-apple-ios-sim.json @@ -1661,13 +1661,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 123687, - "git_blob": "abb50638129d0dc966da3dfda59bd4325fdf7d1a", + "bytes": 123695, + "git_blob": "c4099e688317242cfcec927d6a7dc1d0ce563471", "mode": "100644", - "sha256": "09f9be02bc5d9e626586bb168207da7eeafec70a3250c0e50f149fad9d37ef67" + "sha256": "86bbcd5cfac578e9fbd63ec9b393f5dc4fddde1d8fa8efd604f5a451b8a79629" } }, "policy": "staged_inputs", - "tree": "946572b2166fa003c1fbc4cecd2948988f707bb5" + "tree": "8b425761f4e51a4e8c66e8374944435e82ce7e8e" } } diff --git a/TeraFFI/source/aarch64-apple-ios.json b/TeraFFI/source/aarch64-apple-ios.json @@ -1661,13 +1661,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 123687, - "git_blob": "abb50638129d0dc966da3dfda59bd4325fdf7d1a", + "bytes": 123695, + "git_blob": "c4099e688317242cfcec927d6a7dc1d0ce563471", "mode": "100644", - "sha256": "09f9be02bc5d9e626586bb168207da7eeafec70a3250c0e50f149fad9d37ef67" + "sha256": "86bbcd5cfac578e9fbd63ec9b393f5dc4fddde1d8fa8efd604f5a451b8a79629" } }, "policy": "staged_inputs", - "tree": "946572b2166fa003c1fbc4cecd2948988f707bb5" + "tree": "8b425761f4e51a4e8c66e8374944435e82ce7e8e" } } diff --git a/TeraTests/TeraAddMediaHarness.swift b/TeraTests/TeraAddMediaHarness.swift @@ -0,0 +1,86 @@ +import Foundation +@testable import TeraApp + +actor AddMediaHarness: TeraAddMediaHandling { + private let delayFirstUpload: Bool + private let delaySettlement: Bool + private var uploadAttempts = 0 + private var settlementStarted = false + private var settlements: [Bool] = [] + private var reconciliations = 0 + private let item = TeraPreparedMedia( + opaqueReference: "media:\(String(repeating: "0", count: 64))", + remoteURL: nil, + sha256: String(repeating: "0", count: 64), + mediaType: "image/png", + byteSize: 4, + width: 2, + height: 2, + alt: "Carrots", + preparedAtUnixSeconds: 1_800_000_000 + ) + + init(delayFirstUpload: Bool = false, delaySettlement: Bool = false) { + self.delayFirstUpload = delayFirstUpload + self.delaySettlement = delaySettlement + } + + func support() -> TeraAddMediaSupport { + .init(library: true, camera: true) + } + + func importImages(limit _: Int) -> [TeraPreparedMedia] { + [item] + } + + func captureImage() -> TeraPreparedMedia { + item + } + + func open(_ media: [TeraPreparedMedia]) throws -> TeraOpenedMedia { + try TeraMediaFileFixture.open(media, bytes: Data(repeating: 0, count: 4)) + } + + func uploadInBackground( + job: TeraNativeUploadJob, + media _: TeraPreparedMedia + ) async throws -> TeraAddBackgroundUploadReceipt { + uploadAttempts += 1 + if delayFirstUpload, uploadAttempts == 1 { + try await Task.sleep(nanoseconds: 50_000_000) + } + return TeraAddBackgroundUploadReceipt( + identifier: "radroots.add.\(job.draft.id).\(job.draft.revision).\(job.operationID)", + draftID: job.draft.id, + expectedRevision: job.draft.revision, + statusCode: 200, + mediaType: "application/json", + contentEncoding: nil, + body: Data("{}".utf8) + ) + } + + func settleBackgroundUpload(identifier _: String, accepted: Bool) async throws { + settlementStarted = true + if delaySettlement { + try await Task.sleep(nanoseconds: 50_000_000) + } + settlements.append(accepted) + } + + func reconcileBackgroundUploads(drafts _: [TeraDraftStatus]) { + reconciliations += 1 + } + + func didBeginSettlement() -> Bool { + settlementStarted + } + + func settlementValues() -> [Bool] { + settlements + } + + func reconciliationCount() -> Int { + reconciliations + } +} diff --git a/TeraTests/TeraAddStoreTests.swift b/TeraTests/TeraAddStoreTests.swift @@ -348,7 +348,7 @@ final class TeraAddStoreTests: XCTestCase { await store.start() store.selectType(.createFoodAvailability) configure(store, type: .createFoodAvailability) - await store.save() + await store.submit() let source = try XCTUnwrap(store.activeDraft) await store.retractAndRevise(Self.card(localOperationID: source.id)) @@ -570,7 +570,7 @@ final class TeraAddStoreTests: XCTestCase { await store.start() await save.value - XCTAssertEqual(store.activeDraft?.form?.content, "Background draft") + XCTAssertEqual(store.savedComposer?.form.content, "Background draft") XCTAssertEqual(store.message, "Draft saved on this device.") XCTAssertFalse(store.isWorking) _ = try await client.stop() @@ -750,90 +750,6 @@ private struct AddSigner: TeraRuntimeSigner { } } -private actor AddMediaHarness: TeraAddMediaHandling { - private let delayFirstUpload: Bool - private let delaySettlement: Bool - private var uploadAttempts = 0 - private var settlementStarted = false - private var settlements: [Bool] = [] - private var reconciliations = 0 - private let item = TeraPreparedMedia( - opaqueReference: "media:\(String(repeating: "0", count: 64))", - remoteURL: nil, - sha256: String(repeating: "0", count: 64), - mediaType: "image/png", - byteSize: 4, - width: 2, - height: 2, - alt: "Carrots", - preparedAtUnixSeconds: 1_800_000_000 - ) - - init(delayFirstUpload: Bool = false, delaySettlement: Bool = false) { - self.delayFirstUpload = delayFirstUpload - self.delaySettlement = delaySettlement - } - - func support() -> TeraAddMediaSupport { - .init(library: true, camera: true) - } - - func importImages(limit _: Int) -> [TeraPreparedMedia] { - [item] - } - - func captureImage() -> TeraPreparedMedia { - item - } - - func open(_ media: [TeraPreparedMedia]) throws -> TeraOpenedMedia { - try TeraMediaFileFixture.open(media, bytes: Data(repeating: 0, count: 4)) - } - - func uploadInBackground( - job: TeraNativeUploadJob, - media _: TeraPreparedMedia - ) async throws -> TeraAddBackgroundUploadReceipt { - uploadAttempts += 1 - if delayFirstUpload, uploadAttempts == 1 { - try await Task.sleep(nanoseconds: 50_000_000) - } - return TeraAddBackgroundUploadReceipt( - identifier: "radroots.add.\(job.draft.id).\(job.draft.revision).\(job.operationID)", - draftID: job.draft.id, - expectedRevision: job.draft.revision, - statusCode: 200, - mediaType: "application/json", - contentEncoding: nil, - body: Data("{}".utf8) - ) - } - - func settleBackgroundUpload(identifier _: String, accepted: Bool) async throws { - settlementStarted = true - if delaySettlement { - try await Task.sleep(nanoseconds: 50_000_000) - } - settlements.append(accepted) - } - - func reconcileBackgroundUploads(drafts _: [TeraDraftStatus]) { - reconciliations += 1 - } - - func didBeginSettlement() -> Bool { - settlementStarted - } - - func settlementValues() -> [Bool] { - settlements - } - - func reconciliationCount() -> Int { - reconciliations - } -} - private enum AddDelayPhase: String, CaseIterable { case save case queue @@ -841,6 +757,7 @@ private enum AddDelayPhase: String, CaseIterable { } private actor AddBackend: TeraRuntimeBackend { + private let composerStorage = ComposerTestStorage() private let savePause: ResourceTestPause? private let advanceOffline: Bool private let saveFailure: TeraRuntimeFailure? @@ -1356,11 +1273,6 @@ private actor AddBackend: TeraRuntimeBackend { try await Task.sleep(nanoseconds: 50_000_000) } - private func storedDraft(id: String) throws -> TeraDraftStatus { - guard let value = values[id] else { throw unsupported() } - return value - } - private func unsupported() -> TeraRuntimeFailure { .local( operation: "test.add", code: "test.unsupported", safeMessage: "Unsupported test operation." @@ -1391,9 +1303,9 @@ extension TeraAddStoreTests { XCTAssertNil(store.activeDraft) await pause.resume.open() await owner.value - XCTAssertEqual(store.drafts.count, 1) - XCTAssertEqual(store.activeDraft?.revision, 1) - XCTAssertEqual(store.activeDraft?.state, .draft) + XCTAssertTrue(store.drafts.isEmpty) + XCTAssertEqual(store.savedComposer?.revision, 1) + XCTAssertEqual(store.savedComposer?.form.content, "One admitted draft") XCTAssertFalse(store.isWorking) _ = try await client.stop() } @@ -1415,7 +1327,7 @@ extension TeraAddStoreTests { XCTAssertTrue(store.drafts.isEmpty) XCTAssertFalse(store.isWorking) await store.save() - XCTAssertEqual(store.drafts.count, 1) + XCTAssertEqual(store.savedComposer?.form.content, "Only save after explicit admission") _ = try await client.stop() } @@ -1428,3 +1340,27 @@ extension TeraAddStoreTests { await observer.value } } + +private extension AddBackend { + func reserveComposerID() async -> String { + await composerStorage.reserve() + } + + func saveComposer(request: TeraComposerSaveRequest) async throws -> TeraComposerSaveReceipt { + await savePause?.wait() + if let saveFailure { + throw saveFailure + } + try await delayOnce(at: .save) + return try await composerStorage.save(request) + } + + func loadComposer(scope: TeraComposerScope, id: String) async throws -> TeraComposerDraft { + try await composerStorage.load(scope, id: id) + } + + private func storedDraft(id: String) throws -> TeraDraftStatus { + guard let value = values[id] else { throw unsupported() } + return value + } +} diff --git a/TeraTests/TeraComposerAutosaveTests.swift b/TeraTests/TeraComposerAutosaveTests.swift @@ -0,0 +1,291 @@ +import Foundation +@testable import TeraApp +import XCTest + +@MainActor +final class TeraComposerAutosaveTests: XCTestCase { + private let scope = TeraComposerScope(authorPublicKey: String(repeating: "a", count: 64), localNetworkID: "default") + + func testSlowWriteCoalescesTenThousandEditsAndExplicitSaveAwaitsNewestReceipt() async throws { + let storage = ComposerTestStorage() + let first = await storage.pauseNext() + let composer = make(storage) + composer.change(form("first")) + await first.entered.wait() + let second = await storage.pauseNext() + for number in 1 ... 10000 { + composer.change(form("edit \(number)")) + } + let save = Task { try await composer.save(form("edit 10000")) } + let writesBefore = await storage.requests.count + XCTAssertEqual(writesBefore, 1) + XCTAssertNil(composer.acknowledged) + XCTAssertTrue(composer.isDirty) + await first.resume.open() + await second.entered.wait() + XCTAssertEqual(composer.acknowledged?.form.content, "first") + XCTAssertTrue(composer.isDirty) + XCTAssertEqual(composer.state, .saving) + let requests = await storage.requests + XCTAssertEqual(requests.map(\.editSequence), [1, 10001]) + XCTAssertEqual(requests.last?.expectedRevision, 1) + await second.resume.open() + let receipt = try await save.value + XCTAssertEqual(receipt.form.content, "edit 10000") + XCTAssertEqual(receipt.editSequence, 10001) + XCTAssertEqual(composer.state, .saved) + XCTAssertFalse(composer.isDirty) + } + + func testReplacedScopesKeepOneWorkerAndDiscardLateOldReceipts() async throws { + let storage = ComposerTestStorage() + let old = await storage.pauseNext() + let composer = make(storage) + composer.change(form("old account")) + await old.entered.wait() + for number in 1 ... 100 { + composer.reset(scope: TeraComposerScope(authorPublicKey: scope.authorPublicKey, localNetworkID: "context-\(number)")) + composer.change(form("new \(number)")) + } + let next = await storage.pauseNext() + let writesBefore = await storage.requests.count + XCTAssertEqual(writesBefore, 1) + let save = Task { try await composer.save(form("new 100")) } + await old.resume.open() + await next.entered.wait() + XCTAssertNil(composer.acknowledged) + XCTAssertTrue(composer.isDirty) + await next.resume.open() + let receipt = try await save.value + XCTAssertEqual(receipt.scope.localNetworkID, "context-100") + XCTAssertEqual(receipt.form.content, "new 100") + let requests = await storage.requests + XCTAssertEqual(requests.count, 2) + XCTAssertNotEqual(requests.first?.id, requests.last?.id) + } + + func testLostCommitCallbackRemainsFailedUntilExactReadReconcilesWithoutDuplicateWrite() async throws { + let storage = ComposerTestStorage() + await storage.failNext(.afterCommit) + let composer = make(storage) + await assertSaveFails(composer, form: form("persisted but unacknowledged")) + XCTAssertEqual(composer.state, .failed) + XCTAssertNil(composer.acknowledged) + let receipt = try await composer.save(form("persisted but unacknowledged")) + XCTAssertEqual(receipt.revision, 1) + XCTAssertEqual(composer.state, .saved) + let requests = await storage.requests + let reads = await storage.readCount + XCTAssertEqual(requests.count, 1) + XCTAssertEqual(reads, 1) + } + + func testFailedWriteRetainsNewestEditAndRetriesSameIDAfterNotFound() async throws { + let storage = ComposerTestStorage() + await storage.failNext(.beforeCommit) + let composer = make(storage) + await assertSaveFails(composer, form: form("first")) + composer.change(form("newest")) + XCTAssertEqual(composer.state, .failed) + let receipt = try await composer.save(form("newest")) + XCTAssertEqual(receipt.form.content, "newest") + XCTAssertEqual(receipt.editSequence, 2) + let requests = await storage.requests + XCTAssertEqual(requests.count, 2) + XCTAssertEqual(requests.first?.id, requests.last?.id) + XCTAssertNil(requests.last?.expectedRevision) + } + + func testWrongReceiptAndConcurrentWriterNeverBecomeSavedOrOverwriteTheirRevision() async { + let storage = ComposerTestStorage() + await storage.failNext(.differentWriter) + let composer = make(storage) + await assertSaveFails(composer, form: form("my edit")) + await assertSaveFails(composer, form: form("keep my newer edit")) + XCTAssertNil(composer.acknowledged) + XCTAssertTrue(composer.isDirty) + XCTAssertEqual(composer.state, .failed) + let requests = await storage.requests + XCTAssertEqual(requests.count, 1) + } + + func testStopDoesNotClaimLateWriteSavedAndResumeReconcilesItsExactIdentity() async throws { + let storage = ComposerTestStorage() + let paused = await storage.pauseNext() + let composer = make(storage) + composer.change(form("background")) + await paused.entered.wait() + composer.stop() + await paused.resume.open() + await storage.completed.wait() + XCTAssertNil(composer.acknowledged) + XCTAssertEqual(composer.state, .unsaved) + composer.resume() + let receipt = try await composer.save(form("background")) + XCTAssertEqual(receipt.revision, 1) + let requests = await storage.requests + XCTAssertEqual(requests.count, 1) + XCTAssertEqual(composer.state, .saved) + } + + func testNoOpEditsDoNotWriteAndUnavailableScopeCannotClaimSaved() async throws { + let storage = ComposerTestStorage() + let composer = make(storage) + let first = try await composer.save(form("same")) + for _ in 1 ... 1000 { + composer.change(form("same")) + } + let repeated = try await composer.save(form("same")) + XCTAssertEqual(first, repeated) + let requests = await storage.requests + XCTAssertEqual(requests.count, 1) + composer.reset(scope: nil) + await assertSaveFails(composer, form: form("not scoped")) + XCTAssertEqual(composer.state, .failed) + XCTAssertTrue(composer.isDirty) + } + + func testStoreKeepsNewerTextAndSaveWaitsForTheLatestCoalescedReceipt() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let store = TeraAddStore(runtimeClient: client) + store.configure(snapshot: TeraScopeFixtures.snapshot()) + await store.start() + let first = await backend.pause(.composer) + store.updateForm(\.content, "first") + let save = Task { await store.save() } + await first.entered.wait() + let second = await backend.pause(.composer) + store.updateForm(\.content, "newest") + await first.resume.open() + await second.entered.wait() + XCTAssertEqual(store.form.content, "newest") + XCTAssertEqual(store.savedComposer?.form.content, "first") + XCTAssertNil(store.message) + XCTAssertTrue(store.isWorking) + await second.resume.open() + await save.value + XCTAssertEqual(store.savedComposer?.form.content, "newest") + XCTAssertEqual(store.form.content, "newest") + XCTAssertEqual(store.composerState, .saved) + XCTAssertFalse(store.isWorking) + store.stop() + _ = try await client.stop() + } + + func testStoreDiscardsOldAccountReceiptAndPreservesEditingAcrossRelayReconfiguration() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let store = TeraAddStore(runtimeClient: client) + store.configure(snapshot: TeraScopeFixtures.snapshot()) + await store.start() + let first = await backend.pause(.composer) + store.updateForm(\.content, "old account") + let old = Task { await store.save() } + await first.entered.wait() + let updated = TeraScopeFixtures.snapshot(account: "b") + await backend.configure(updated) + store.configure(snapshot: updated) + await store.start() + store.updateForm(\.content, "new account") + let current = Task { await store.save() } + await first.resume.open() + await old.value + await current.value + let receipt = try XCTUnwrap(store.savedComposer) + XCTAssertEqual(receipt.scope.authorPublicKey, String(repeating: "b", count: 64)) + XCTAssertEqual(receipt.form.content, "new account") + store.configure(snapshot: TeraScopeFixtures.snapshot(account: "b", relay: "second", profile: "new profile")) + XCTAssertEqual(store.form.content, "new account") + await store.start() + await store.save() + XCTAssertEqual(store.savedComposer, receipt) + store.stop() + _ = try await client.stop() + } + + private func make(_ storage: ComposerTestStorage) -> TeraComposerAutosave { + let composer = TeraComposerAutosave(persistence: storage.port, delay: {}) + composer.reset(scope: scope) + return composer + } + + private func form(_ content: String) -> TeraComposerForm { + var form = TeraComposerForm(commandType: .createEvent) + form.content = content + form.eventStartDate = "2026-09-" + form.priceAmount = "12." + return form + } + + private func assertSaveFails(_ composer: TeraComposerAutosave, form: TeraComposerForm) async { + do { + _ = try await composer.save(form) + XCTFail("Unconfirmed changes must not receive a saved acknowledgment.") + } catch { XCTAssertTrue(composer.isDirty) } + } +} + +actor ComposerTestStorage { + enum Failure { case beforeCommit, afterCommit, differentWriter } + private var reserved = 0 + private var values: [String: TeraComposerDraft] = [:] + private var pause: ResourceTestPause? + private var failure: Failure? + private(set) var requests: [TeraComposerSaveRequest] = [] + private(set) var readCount = 0 + let completed = ResourceTestGate() + + nonisolated var port: TeraComposerPersistence { + TeraComposerPersistence(reserve: { await self.reserve() }, save: { try await self.save($0) }, + load: { try await self.load($0, id: $1) }) + } + + func pauseNext() -> ResourceTestPause { + let value = ResourceTestPause() + pause = value + return value + } + + func failNext(_ failure: Failure) { + self.failure = failure + } + + func reserve() -> String { + reserved += 1 + return String(format: "%032x", reserved) + } + + func save(_ request: TeraComposerSaveRequest) async throws -> TeraComposerSaveReceipt { + requests.append(request) + let pause = pause + self.pause = nil + let failure = failure + self.failure = nil + await pause?.wait() + if failure == .beforeCommit { + throw TeraComposerAcknowledgment.unconfirmed + } + guard values[request.id]?.revision == request.expectedRevision else { throw TeraComposerAcknowledgment.unconfirmed } + var form = request.form + if failure == .differentWriter { + form.content = "a concurrent writer" + } + let draft = TeraComposerDraft(scope: request.scope, id: request.id, revision: (request.expectedRevision ?? 0) + 1, + editSequence: request.editSequence, form: form) + values[request.id] = draft + await completed.open() + if failure == .afterCommit { + throw TeraComposerAcknowledgment.unconfirmed + } + return TeraComposerSaveReceipt(draft: draft, replayed: false) + } + + func load(_ scope: TeraComposerScope, id: String) throws -> TeraComposerDraft { + readCount += 1 + guard let draft = values[id], draft.scope == scope else { + throw TeraRuntimeFailure.local(operation: "test.composer", code: "composer_not_found", safeMessage: "Not saved.") + } + return draft + } +} diff --git a/TeraTests/TeraComposerPersistenceFFITests.swift b/TeraTests/TeraComposerPersistenceFFITests.swift @@ -90,6 +90,44 @@ final class TeraComposerPersistenceFFITests: XCTestCase { _ = try await runtime.shutdown() } + @MainActor + func testAddSavePersistsIncompleteEditingWithoutPreparingOrSigningAnOperation() async throws { + let fixture = try MediaOwnershipFixture() + defer { fixture.remove() } + let signer = ComposerForbiddenSigner() + let configuration = configuration(fixture, signer: signer) + let client = TeraRuntimeClient.production() + let snapshot = try await client.start(configuration: configuration) + let store = TeraAddStore(runtimeClient: client) + store.configure(snapshot: snapshot) + await store.start() + store.selectType(.createEvent) + store.updateForm(\.eventStartDate, "2026-09-") + store.updateForm(\.eventEndDate, "") + store.updateForm(\.title, "") + store.updateForm(\.content, " unfinished event ") + await store.save() + let saved = try XCTUnwrap(store.savedComposer) + XCTAssertEqual(saved.form.eventStartDate, "2026-09-") + XCTAssertEqual(saved.form.content, " unfinished event ") + XCTAssertEqual(store.composerState, .saved) + XCTAssertEqual(saved.scope.localNetworkID, "default") + XCTAssertNil(store.activeDraft) + let operations = try await client.draftHeads() + XCTAssertTrue(operations.isEmpty) + store.updateForm(\.content, "new unsaved edit") + XCTAssertNil(store.message) + XCTAssertEqual(store.composerState, .unsaved) + store.stop() + _ = try await client.stop() + _ = try await client.start(configuration: configuration) + let recovered = try await client.loadComposer(scope: saved.scope, id: saved.id) + XCTAssertEqual(recovered, saved) + let signingRequests = await signer.requests + XCTAssertEqual(signingRequests, 0) + _ = try await client.stop() + } + private func partialForm(_ fixture: MediaOwnershipFixture) -> TeraComposerForm { var editing = TeraAddForm(commandType: .createEvent) editing.content = " private incomplete\n\u{0}é " diff --git a/TeraTests/TeraScopeBackend.swift b/TeraTests/TeraScopeBackend.swift @@ -2,13 +2,14 @@ import Foundation @testable import TeraApp actor TeraScopeBackend: TeraRuntimeBackend { - enum Call: Hashable { case snapshot, drafts, save, probe, page, reconcile, refresh, search, me, subscribe, media, invalidate } + enum Call: Hashable { case snapshot, drafts, save, composer, probe, page, reconcile, refresh, search, me, subscribe, media, invalidate } struct Pending { let pause: ResourceTestPause let failure: TeraRuntimeFailure? } private(set) var value = TeraScopeFixtures.snapshot() + private let composerStorage = ComposerTestStorage() private var pending: [Call: [Pending]] = [:] private(set) var counts: [Call: Int] = [:] private var receivers: [@Sendable (TeraRuntimeChange) async -> Void] = [] @@ -114,6 +115,19 @@ actor TeraScopeBackend: TeraRuntimeBackend { return result } + func reserveComposerID() async -> String { + await composerStorage.reserve() + } + + func saveComposer(request: TeraComposerSaveRequest) async throws -> TeraComposerSaveReceipt { + try await wait(.composer) + return try await composerStorage.save(request) + } + + func loadComposer(scope: TeraComposerScope, id: String) async throws -> TeraComposerDraft { + try await composerStorage.load(scope, id: id) + } + func probeBlossom() async throws -> TeraBlossomEvidence { let fingerprint = value.blossomConfiguration?.configFingerprint ?? "" try await wait(.probe) diff --git a/TeraTests/TeraScopedAddTests.swift b/TeraTests/TeraScopedAddTests.swift @@ -50,7 +50,7 @@ final class TeraScopedAddTests: XCTestCase { _ = try await client.stop() } - func testFormEditDuringSaveKeepsTheNewFormAndRetainsDurableOldReceipt() async throws { + func testFormEditDuringSubmitKeepsTheNewFormAndRetainsDurableOldReceipt() async throws { let backend = try TeraScopeBackend() let client = try await TeraScopeFixtures.client(backend) let store = TeraAddStore(runtimeClient: client) @@ -58,7 +58,7 @@ final class TeraScopedAddTests: XCTestCase { await store.start() store.updateForm(\.content, "saved version") let pause = await backend.pause(.save) - let old = Task { await store.save() } + let old = Task { await store.submit() } await pause.entered.wait() store.updateForm(\.content, "new edit") await pause.resume.open() @@ -69,7 +69,7 @@ final class TeraScopedAddTests: XCTestCase { XCTAssertFalse(store.isWorking) let durable = try await client.draftHeads(limit: 100) XCTAssertEqual(durable.first?.form?.content, "saved version") - await store.save() + await store.submit() XCTAssertEqual(store.activeDraft?.form?.content, "new edit") store.stop() _ = try await client.stop() @@ -82,7 +82,7 @@ final class TeraScopedAddTests: XCTestCase { store.configure(snapshot: TeraScopeFixtures.snapshot()) await store.start() let save = await backend.pause(.save, fails: true) - let old = Task { await store.save() } + let old = Task { await store.submit() } await save.entered.wait() let snapshot = await backend.pause(.snapshot) await save.resume.open() @@ -93,7 +93,7 @@ final class TeraScopedAddTests: XCTestCase { await store.start() store.updateForm(\.content, "new account") let next = await backend.pause(.save) - let current = Task { await store.save() } + let current = Task { await store.submit() } await next.entered.wait() await snapshot.resume.open() await old.value @@ -181,7 +181,7 @@ final class TeraScopedAddTests: XCTestCase { store.configure(snapshot: TeraScopeFixtures.snapshot()) await store.start() let save = await backend.pause(.save, fails: true) - let old = Task { await store.save() } + let old = Task { await store.submit() } await save.entered.wait() let snapshot = await backend.pause(.snapshot) await save.resume.open() diff --git a/project.yml b/project.yml @@ -47,6 +47,7 @@ targets: sources: - path: TeraTests/TeraRuntimeClientTests.swift - path: TeraTests/TeraComposerPersistenceFFITests.swift + - path: TeraTests/TeraComposerAutosaveTests.swift - path: TeraTests/TeraRuntimeInvalidationTests.swift - path: TeraTests/TeraRuntimeBackpressureTests.swift - path: TeraTests/TeraRuntimeClientFixtures.swift @@ -95,6 +96,7 @@ targets: - path: TeraTests/TeraOpenedMediaCloseFixture.swift - path: TeraTests/TeraOpenedMediaTests.swift - path: TeraTests/TeraAddStoreTests.swift + - path: TeraTests/TeraAddMediaHarness.swift - path: TeraTests/TeraBackgroundUploadFixtures.swift - path: TeraTests/TeraMutationAdmissionTests.swift - path: TeraTests/TeraSupportingStoreTests.swift diff --git a/release/provenance.json b/release/provenance.json @@ -2,11 +2,11 @@ "artifacts": { "app_api_sha256": "020924097c0d7efc33128cb8fd3d3b2026d95f57c44da71880e585aff80f070b", "ffi_api_sha256": "ff7e2fac23add9eb582894b5bfd59a3f8a240bef0d46893ff310c84945f3b1f5", - "ffi_provenance_sha256": "dfb91d8d668287db685e4db446fc383cfaf7813a781992836ecacdf44af504af", + "ffi_provenance_sha256": "5a09782e61d5244e016460f260b7ceb8de4b31146f9114701c5583e9de83018e", "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "61e3d99a2c7f051a91d6a38b3b1f7974fb4c56da3a1c9758f5ad06fc66f1d856", - "xcode_project_sha256": "de24a6320ef902078593805cdf564c3b907db48621f66cb71b2fab1055d66529" + "xcode_project_sha256": "c31f0dd7d013531e0d13538897ff57dfbe62c4a90854e66f293713e79c2b478c" }, "disposition": "unsigned", "platforms": [ @@ -22,7 +22,7 @@ "lib_revision": "363b9be18831be2fd6ef505d6154fb878ed8efd7", "source_date_epoch": 1787871027, "swift_package_lock_sha256": "94ae067a374726cdaf6b4ca0a5e44663c57fcdc5334060c5ffef5e79cfbf04c0", - "tera_ffi_source_tree": "946572b2166fa003c1fbc4cecd2948988f707bb5", + "tera_ffi_source_tree": "8b425761f4e51a4e8c66e8374944435e82ce7e8e", "xcode_package_lock_sha256": "c7f41934ea25f7a287bdc4f3a6ecabbf09a3a0bdd0f5a3e58183f355ca814096" }, "version": "0.1.0-alpha" diff --git a/test-fixtures/legacy-identifiers.v1.json b/test-fixtures/legacy-identifiers.v1.json @@ -2047,7 +2047,7 @@ "count": 1 }, { - "path": "TeraTests/TeraAddStoreTests.swift", + "path": "TeraTests/TeraAddMediaHarness.swift", "count": 1 }, { @@ -2399,7 +2399,7 @@ "category": "native_ui_contract", "occurrences": [ { - "path": "Tera/Views/TeraAddView.swift", + "path": "Tera/Views/TeraAddSaveStatus.swift", "count": 1 }, { diff --git a/test-fixtures/maintainability-baseline.v1.json b/test-fixtures/maintainability-baseline.v1.json @@ -11,10 +11,10 @@ {"path": "Tera/Runtime/TeraGeneratedRuntimeBackend.swift", "maximum_lines": 1509}, {"path": "Tera/Runtime/TeraRuntimeClient.swift", "maximum_lines": 820}, {"path": "Tera/Runtime/TeraRuntimeModels.swift", "maximum_lines": 1154}, - {"path": "Tera/State/TeraAddStore.swift", "maximum_lines": 810}, + {"path": "Tera/State/TeraAddStore.swift", "maximum_lines": 792}, {"path": "Tera/State/TeraConfigurationStore.swift", "maximum_lines": 759}, {"path": "Tera/Views/TeraSupportingViews.swift", "maximum_lines": 629}, - {"path": "TeraTests/TeraAddStoreTests.swift", "maximum_lines": 1784}, + {"path": "TeraTests/TeraAddStoreTests.swift", "maximum_lines": 1366}, {"path": "TeraTests/TeraStateMigrationTests.swift", "maximum_lines": 718}, {"path": "TeraUITests/TeraRemoteQualificationUITests.swift", "maximum_lines": 2212} ], diff --git a/test-fixtures/swiftlint-maintainability-baseline.v1.json b/test-fixtures/swiftlint-maintainability-baseline.v1.json @@ -1 +1 @@ -[{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","severity":"error","location":{"file":"Tera\/App\/TeraAppModel.swift","character":3,"line":21},"reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines"},"text":" init("},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"error","location":{"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","character":1,"line":51},"reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 330 lines"},"text":"actor TeraAddMediaCoordinator: TeraAddMediaHandling {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"error","location":{"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","character":1,"line":458},"reason":"File should contain 400 lines or less: currently contains 458"},"text":"#endif"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"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 481 lines"},"text":"private final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"error","location":{"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","character":1,"line":1509},"reason":"File should contain 1000 lines or less: currently contains 1509"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"error","location":{"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":1,"line":3},"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 739 lines"},"text":"actor TeraRuntimeClient {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","severity":"error","location":{"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":3,"line":379},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 70 lines"},"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"error","location":{"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":1,"line":820},"reason":"File should contain 400 lines or less: currently contains 820"},"text":"}"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"error","location":{"file":"Tera\/Runtime\/TeraRuntimeModels.swift","character":1,"line":1154},"reason":"File should contain 1000 lines or less: currently contains 1154"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"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"},"text":"final class TeraAddStore: ObservableObject {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"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"},"text":" func retract(_ card: TeraTodayCard) async {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"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"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"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"},"text":"actor TeraConfigurationStore {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"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"},"text":" func load() throws -> TeraAppConfiguration {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"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"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"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"},"text":"actor TeraSessionStore {"},{"violation":{"ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","ruleIdentifier":"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"},"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"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"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"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 381 lines"},"text":"struct TeraAddView: View {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"error","location":{"file":"Tera\/Views\/TeraAddView.swift","character":1,"line":479},"reason":"File should contain 400 lines or less: currently contains 479"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"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"},"text":"struct TeraSettingsView: View {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"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"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"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 664 lines"},"text":"final class TeraAddStoreTests: XCTestCase {"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"error","location":{"file":"TeraTests\/TeraAddStoreTests.swift","character":9,"line":843},"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 492 lines"},"text":"private actor AddBackend: TeraRuntimeBackend {"},{"violation":{"ruleDescription":"Number of function parameters should be low.","ruleName":"Function Parameter Count","ruleIdentifier":"function_parameter_count","severity":"error","location":{"file":"TeraTests\/TeraAddStoreTests.swift","character":11,"line":1279},"reason":"Function should have 5 parameters or less: it currently has 8"},"text":" private func makeStatus("},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"error","location":{"file":"TeraTests\/TeraAddStoreTests.swift","character":1,"line":1430},"reason":"File should contain 1000 lines or less: currently contains 1430"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"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"},"text":"final class TeraRemoteQualificationTests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"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"},"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"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"},"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"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"},"text":"final class TeraRuntimeClientTests: XCTestCase {"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"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"},"text":"final class TeraStateMigrationTests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"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"},"text":" func testRelayValidationMatchesRuntimeProfiles() throws {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"error","location":{"file":"TeraTests\/TeraStateMigrationTests.swift","character":1,"line":622},"reason":"File should contain 400 lines or less: currently contains 622"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"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 252 lines"},"text":"private actor SupportingBackend: TeraRuntimeBackend {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"error","location":{"file":"TeraTests\/TeraSupportingStoreTests.swift","character":1,"line":486},"reason":"File should contain 400 lines or less: currently contains 486"},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"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"},"text":"final class TeraRemoteQualificationUITests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"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"},"text":" func testLocalSocialFiveFlowScenario() throws {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"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"},"text":" func testLocalSocialDeterministicPersonas() throws {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"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"},"text":" func testRemoteBlossomUploadAndRecovery() throws {"},{"violation":{"ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","ruleIdentifier":"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"},"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {"},{"violation":{"ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","ruleIdentifier":"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"},"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"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"},"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"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"},"text":" static func environment("},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"error","location":{"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":1,"line":2212},"reason":"File should contain 1000 lines or less: currently contains 2212"},"text":"}"}] -\ No newline at end of file +[{"text":" init(","violation":{"location":{"line":21,"file":"Tera\/App\/TeraAppModel.swift","character":3},"ruleDescription":"Function bodies should not span too many lines","reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","severity":"warning","ruleName":"Function Body Length","ruleIdentifier":"function_body_length"}},{"text":"actor TeraAddMediaCoordinator: TeraAddMediaHandling {","violation":{"location":{"line":51,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","character":1},"ruleDescription":"Type bodies should not span too many lines","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 330 lines","severity":"warning","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":"#endif","violation":{"location":{"line":458,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","character":1},"ruleDescription":"Files should not span too many lines.","reason":"File should contain 400 lines or less: currently contains 458","severity":"warning","ruleName":"File Length","ruleIdentifier":"file_length"}},{"text":"private final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {","violation":{"location":{"line":111,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","character":15},"ruleDescription":"Type bodies should not span too many lines","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 481 lines","severity":"error","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":"}","violation":{"location":{"line":1509,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","character":1},"ruleDescription":"Files should not span too many lines.","reason":"File should contain 1000 lines or less: currently contains 1509","severity":"error","ruleName":"File Length","ruleIdentifier":"file_length"}},{"text":"actor TeraRuntimeClient {","violation":{"location":{"line":3,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":1},"ruleDescription":"Type bodies should not span too many lines","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 739 lines","severity":"error","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {","violation":{"location":{"line":379,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":3},"ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 70 lines","severity":"warning","ruleName":"Function Body Length","ruleIdentifier":"function_body_length"}},{"text":"}","violation":{"location":{"line":820,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","character":1},"ruleDescription":"Files should not span too many lines.","reason":"File should contain 400 lines or less: currently contains 820","severity":"warning","ruleName":"File Length","ruleIdentifier":"file_length"}},{"text":"}","violation":{"location":{"line":1154,"file":"Tera\/Runtime\/TeraRuntimeModels.swift","character":1},"ruleDescription":"Files should not span too many lines.","reason":"File should contain 1000 lines or less: currently contains 1154","severity":"error","ruleName":"File Length","ruleIdentifier":"file_length"}},{"text":"final class TeraAddStore: ObservableObject {","violation":{"location":{"line":4,"file":"Tera\/State\/TeraAddStore.swift","character":7},"ruleDescription":"Type bodies should not span too many lines","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 703 lines","severity":"error","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":" func retract(_ card: TeraTodayCard) async {","violation":{"location":{"line":446,"file":"Tera\/State\/TeraAddStore.swift","character":3},"ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 51 lines","severity":"warning","ruleName":"Function Body Length","ruleIdentifier":"function_body_length"}},{"text":"}","violation":{"location":{"line":792,"file":"Tera\/State\/TeraAddStore.swift","character":1},"ruleDescription":"Files should not span too many lines.","reason":"File should contain 400 lines or less: currently contains 792","severity":"warning","ruleName":"File Length","ruleIdentifier":"file_length"}},{"text":"actor TeraConfigurationStore {","violation":{"location":{"line":78,"file":"Tera\/State\/TeraConfigurationStore.swift","character":1},"ruleDescription":"Type bodies should not span too many lines","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines","severity":"error","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":" func load() throws -> TeraAppConfiguration {","violation":{"location":{"line":148,"file":"Tera\/State\/TeraConfigurationStore.swift","character":5},"ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines","severity":"error","ruleName":"Function Body Length","ruleIdentifier":"function_body_length"}},{"text":"}","violation":{"location":{"line":759,"file":"Tera\/State\/TeraConfigurationStore.swift","character":1},"ruleDescription":"Files should not span too many lines.","reason":"File should contain 400 lines or less: currently contains 759","severity":"warning","ruleName":"File Length","ruleIdentifier":"file_length"}},{"text":"actor TeraSessionStore {","violation":{"location":{"line":39,"file":"Tera\/State\/TeraSessionStore.swift","character":1},"ruleDescription":"Type bodies should not span too many lines","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 390 lines","severity":"error","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {","violation":{"location":{"line":125,"file":"Tera\/State\/TeraSessionStore.swift","character":11},"ruleDescription":"Complexity of function bodies should be limited.","reason":"Function should have complexity 10 or less; currently complexity is 13","severity":"warning","ruleName":"Cyclomatic Complexity","ruleIdentifier":"cyclomatic_complexity"}},{"text":"}","violation":{"location":{"line":452,"file":"Tera\/State\/TeraSessionStore.swift","character":1},"ruleDescription":"Files should not span too many lines.","reason":"File should contain 400 lines or less: currently contains 452","severity":"warning","ruleName":"File Length","ruleIdentifier":"file_length"}},{"text":"struct TeraAddView: View {","violation":{"location":{"line":4,"file":"Tera\/Views\/TeraAddView.swift","character":1},"ruleDescription":"Type bodies should not span too many lines","reason":"Struct body should span 350 lines or less excluding comments and whitespace: currently spans 375 lines","severity":"error","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":"}","violation":{"location":{"line":473,"file":"Tera\/Views\/TeraAddView.swift","character":1},"ruleDescription":"Files should not span too many lines.","reason":"File should contain 400 lines or less: currently contains 473","severity":"warning","ruleName":"File Length","ruleIdentifier":"file_length"}},{"text":"struct TeraSettingsView: View {","violation":{"location":{"line":365,"file":"Tera\/Views\/TeraSupportingViews.swift","character":1},"ruleDescription":"Type bodies should not span too many lines","reason":"Struct body should span 250 lines or less excluding comments and whitespace: currently spans 259 lines","severity":"warning","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":"}","violation":{"location":{"line":629,"file":"Tera\/Views\/TeraSupportingViews.swift","character":1},"ruleDescription":"Files should not span too many lines.","reason":"File should contain 400 lines or less: currently contains 629","severity":"warning","ruleName":"File Length","ruleIdentifier":"file_length"}},{"text":"final class TeraAddStoreTests: XCTestCase {","violation":{"location":{"line":6,"file":"TeraTests\/TeraAddStoreTests.swift","character":7},"ruleDescription":"Type bodies should not span too many lines","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 664 lines","severity":"error","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":"private actor AddBackend: TeraRuntimeBackend {","violation":{"location":{"line":759,"file":"TeraTests\/TeraAddStoreTests.swift","character":9},"ruleDescription":"Type bodies should not span too many lines","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 489 lines","severity":"error","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":" private func makeStatus(","violation":{"location":{"line":1196,"file":"TeraTests\/TeraAddStoreTests.swift","character":11},"ruleDescription":"Number of function parameters should be low.","reason":"Function should have 5 parameters or less: it currently has 8","severity":"warning","ruleName":"Function Parameter Count","ruleIdentifier":"function_parameter_count"}},{"text":"}","violation":{"location":{"line":1366,"file":"TeraTests\/TeraAddStoreTests.swift","character":1},"ruleDescription":"Files should not span too many lines.","reason":"File should contain 1000 lines or less: currently contains 1366","severity":"error","ruleName":"File Length","ruleIdentifier":"file_length"}},{"text":"final class TeraRemoteQualificationTests: XCTestCase {","violation":{"location":{"line":7,"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":7},"ruleDescription":"Type bodies should not span too many lines","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines","severity":"warning","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {","violation":{"location":{"line":45,"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":3},"ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines","severity":"warning","ruleName":"Function Body Length","ruleIdentifier":"function_body_length"}},{"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {","violation":{"location":{"line":212,"file":"TeraTests\/TeraRemoteQualificationTests.swift","character":3},"ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","severity":"warning","ruleName":"Function Body Length","ruleIdentifier":"function_body_length"}},{"text":"final class TeraRuntimeClientTests: XCTestCase {","violation":{"location":{"line":4,"file":"TeraTests\/TeraRuntimeClientTests.swift","character":7},"ruleDescription":"Type bodies should not span too many lines","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 298 lines","severity":"warning","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":"final class TeraStateMigrationTests: XCTestCase {","violation":{"location":{"line":6,"file":"TeraTests\/TeraStateMigrationTests.swift","character":7},"ruleDescription":"Type bodies should not span too many lines","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines","severity":"error","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":" func testRelayValidationMatchesRuntimeProfiles() throws {","violation":{"location":{"line":7,"file":"TeraTests\/TeraStateMigrationTests.swift","character":5},"ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","severity":"warning","ruleName":"Function Body Length","ruleIdentifier":"function_body_length"}},{"text":"}","violation":{"location":{"line":622,"file":"TeraTests\/TeraStateMigrationTests.swift","character":1},"ruleDescription":"Files should not span too many lines.","reason":"File should contain 400 lines or less: currently contains 622","severity":"warning","ruleName":"File Length","ruleIdentifier":"file_length"}},{"text":"private actor SupportingBackend: TeraRuntimeBackend {","violation":{"location":{"line":207,"file":"TeraTests\/TeraSupportingStoreTests.swift","character":9},"ruleDescription":"Type bodies should not span too many lines","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 252 lines","severity":"warning","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":"}","violation":{"location":{"line":486,"file":"TeraTests\/TeraSupportingStoreTests.swift","character":1},"ruleDescription":"Files should not span too many lines.","reason":"File should contain 400 lines or less: currently contains 486","severity":"warning","ruleName":"File Length","ruleIdentifier":"file_length"}},{"text":"final class TeraRemoteQualificationUITests: XCTestCase {","violation":{"location":{"line":4,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":7},"ruleDescription":"Type bodies should not span too many lines","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1493 lines","severity":"error","ruleName":"Type Body Length","ruleIdentifier":"type_body_length"}},{"text":" func testLocalSocialFiveFlowScenario() throws {","violation":{"location":{"line":62,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3},"ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 73 lines","severity":"warning","ruleName":"Function Body Length","ruleIdentifier":"function_body_length"}},{"text":" func testLocalSocialDeterministicPersonas() throws {","violation":{"location":{"line":174,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3},"ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","severity":"warning","ruleName":"Function Body Length","ruleIdentifier":"function_body_length"}},{"text":" func testRemoteBlossomUploadAndRecovery() throws {","violation":{"location":{"line":247,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":3},"ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines","severity":"warning","ruleName":"Function Body Length","ruleIdentifier":"function_body_length"}},{"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {","violation":{"location":{"line":573,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11},"ruleDescription":"Complexity of function bodies should be limited.","reason":"Function should have complexity 10 or less; currently complexity is 18","severity":"warning","ruleName":"Cyclomatic Complexity","ruleIdentifier":"cyclomatic_complexity"}},{"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {","violation":{"location":{"line":767,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11},"ruleDescription":"Complexity of function bodies should be limited.","reason":"Function should have complexity 10 or less; currently complexity is 11","severity":"warning","ruleName":"Cyclomatic Complexity","ruleIdentifier":"cyclomatic_complexity"}},{"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {","violation":{"location":{"line":767,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":11},"ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","severity":"warning","ruleName":"Function Body Length","ruleIdentifier":"function_body_length"}},{"text":" static func environment(","violation":{"location":{"line":1736,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":10},"ruleDescription":"Function bodies should not span too many lines","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines","severity":"warning","ruleName":"Function Body Length","ruleIdentifier":"function_body_length"}},{"text":"}","violation":{"location":{"line":2212,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","character":1},"ruleDescription":"Files should not span too many lines.","reason":"File should contain 1000 lines or less: currently contains 2212","severity":"error","ruleName":"File Length","ruleIdentifier":"file_length"}}] +\ No newline at end of file