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:
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