commit 8472636ff9c8014e2beb6b4a9f4353312795255f
parent 605a384bde18b6044986be84f770f2328b7ab37e
Author: triesap <tyson@radroots.org>
Date: Thu, 10 Sep 2026 18:11:21 +0000
compose: Protect dirty work during navigation
- Preserve exact acknowledged editing before replacing the composer
- Fence stale save, load and confirmation callbacks by their owner
- Keep prior draft identities and strict revision behavior intact
- Verify native persistence, cancellation and actual discard controls
Diffstat:
23 files changed, 983 insertions(+), 146 deletions(-)
diff --git a/Package.swift b/Package.swift
@@ -98,6 +98,8 @@ let package = Package(
"Runtime/TeraUserMessages.swift",
"State/TeraAddStore.swift",
"State/TeraAddAffordances.swift",
+ "State/TeraAddCardIntents.swift",
+ "State/TeraEditingProtection.swift",
"State/TeraAddUploadCompletion.swift",
"State/TeraComposerAutosave.swift",
"State/TeraComposerSaveState.swift",
@@ -124,6 +126,8 @@ let package = Package(
"Views/TeraAddView.swift",
"Views/TeraAddSaveStatus.swift",
"Views/TeraDraftsSheet.swift",
+ "Views/TeraEditingProtectionActions.swift",
+ "Views/TeraEditingProtectionUITestSurface.swift",
"Views/TeraSupportingViews.swift",
"Views/TeraContextPicker.swift",
"Views/TeraTodayDiscoveryView.swift",
diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj
@@ -37,18 +37,21 @@
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 */; };
+ 6F09A1AF6E764AC0136E8F74 /* TeraEditingProtectionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D243145C276EBF17915AB4E /* TeraEditingProtectionUITests.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 */; };
70417FB635F06CC9829FB38C /* TeraClockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506546B0EC1792240C36F7 /* TeraClockTests.swift */; };
772A9B1AD8DE1D2E0969F285 /* TeraCalendarFFICompatibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B808E67E85CD36E49C38B6F /* TeraCalendarFFICompatibilityTests.swift */; };
79D06C6BF2B55C0C4104FB91 /* TeraSessionGenerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0836F5D5F71F9A54795C57 /* TeraSessionGenerationTests.swift */; };
+ 7B8C7C130DC15AF0266F2544 /* TeraEditingReplacementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F97ECE43BB7028B5A497720 /* TeraEditingReplacementTests.swift */; };
7DFBB14B53D57555AB0E0BF0 /* TeraScopeFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E9C876EA26B30C8AFE116F /* TeraScopeFixtures.swift */; };
81AF9DDA0E64DC4EEFCEF25D /* TeraRemoteQualificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E351F52E77C74A4E99095E45 /* TeraRemoteQualificationTests.swift */; };
827D8CC5C0488F0D5D15CE99 /* TeraMediaOwnershipFFITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2521493B0CD2B82445974E81 /* TeraMediaOwnershipFFITests.swift */; };
8422EAEECA73BB72974D002A /* TeraTodayBackfillTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D3FEE2830E056E82AF05FD /* TeraTodayBackfillTests.swift */; };
852FF7514FF7F682A246FB47 /* TeraStateMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E969BCFF7CED33A1D4A040 /* TeraStateMigrationTests.swift */; };
85404A5125CF3E64E6009DC1 /* TeraMediaBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F596D3A9B9E236963DCE15D7 /* TeraMediaBudgetTests.swift */; };
+ 888B178679E012BB053FE31B /* TeraEditingReplacementFFITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B310FD8CCA9F9C2DA19BE8CF /* TeraEditingReplacementFFITests.swift */; };
912C8743116D929BB6A02778 /* TeraRuntimeClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB77C087F15C2F42CD959607 /* TeraRuntimeClientTests.swift */; };
9309D7175535546A0C878F75 /* TeraCalendarTimingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF0D194520F0F02EAD6B50 /* TeraCalendarTimingTests.swift */; };
9384AAECB22885E2D05C54A8 /* TeraScopedMediaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFBAA57ABEAA4B84C73EE8E8 /* TeraScopedMediaTests.swift */; };
@@ -56,6 +59,7 @@
9AD968F0B37C64B5AF8D9A68 /* TeraRootShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B8677670E203FF68EC88983 /* TeraRootShellTests.swift */; };
9B37084901EC52EB44969304 /* TeraRuntimeResourceLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 226F5C2370522AAFAD9D1C3D /* TeraRuntimeResourceLifecycleTests.swift */; };
A1AD2A380F09994786889AF1 /* TeraAddStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F22CFABCF27A28408F68D25 /* TeraAddStoreTests.swift */; };
+ A65623800C28B2FEEFB49780 /* TeraEditingProtectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F27D9EF66493E92AFDD53213 /* TeraEditingProtectionTests.swift */; };
AA7C3FC1F46B6723A39B1D09 /* TeraTodayPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99BE248D1450D76476255626 /* TeraTodayPresentationTests.swift */; };
AC33144E09CB0384C4FB090C /* TeraObservationCoalescingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21DD785439F415278214D746 /* TeraObservationCoalescingTests.swift */; };
B1B83B8673660E0EC109F74C /* TeraStateTestFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD45A3134A16704CBE4B1A5 /* TeraStateTestFixtures.swift */; };
@@ -79,6 +83,7 @@
E508A7061082B091B733D62B /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67ACCE25979BA86C1EC5BFD6 /* App.swift */; };
E98A5C885E3955D4DC4BB1EC /* TeraTodayCacheFirstTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CB51FEF4CE772320B3264C /* TeraTodayCacheFirstTests.swift */; };
EF7293C553BF1DB45EEEBD49 /* local-social-personas.v1.json in Resources */ = {isa = PBXBuildFile; fileRef = 27D9D40699A01FFB02F30B11 /* local-social-personas.v1.json */; };
+ F14217EA66C2A9397E36E3FA /* TeraEditingOperationProtectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC9B19425D3374B899485C17 /* TeraEditingOperationProtectionTests.swift */; };
F4AF91E71B691FE30C191852 /* TeraAddObservationStartupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44A4460928B20A21A94BECCE /* TeraAddObservationStartupTests.swift */; };
F8C87782857DC930D47E6A90 /* TeraRuntimeResourceTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D01C023E258AC3B3B1C27D /* TeraRuntimeResourceTaskTests.swift */; };
FB585785DC906CB2AE4AE535 /* TeraBindingPortabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E711687008866790042792 /* TeraBindingPortabilityTests.swift */; };
@@ -105,6 +110,8 @@
/* Begin PBXFileReference section */
02F322CF98F8BEB908B93215 /* TeraMutationAdmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraMutationAdmissionTests.swift; sourceTree = "<group>"; };
0CDE3D76E1EDF909C19D0506 /* TeraUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = TeraUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 0D243145C276EBF17915AB4E /* TeraEditingProtectionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraEditingProtectionUITests.swift; sourceTree = "<group>"; };
+ 0F97ECE43BB7028B5A497720 /* TeraEditingReplacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraEditingReplacementTests.swift; sourceTree = "<group>"; };
11E9C876EA26B30C8AFE116F /* TeraScopeFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopeFixtures.swift; sourceTree = "<group>"; };
21DD785439F415278214D746 /* TeraObservationCoalescingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraObservationCoalescingTests.swift; sourceTree = "<group>"; };
226F5C2370522AAFAD9D1C3D /* TeraRuntimeResourceLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeResourceLifecycleTests.swift; sourceTree = "<group>"; };
@@ -157,7 +164,9 @@
9F54FC4930051FC4611B37D3 /* tera */ = {isa = PBXFileReference; lastKnownFileType = folder; name = tera; path = .; sourceTree = SOURCE_ROOT; };
A5938176A92F96A30CC39DFB /* TeraComposerPersistenceFFITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraComposerPersistenceFFITests.swift; sourceTree = "<group>"; };
AB8A30A75EFD65EFD938D248 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
+ AC9B19425D3374B899485C17 /* TeraEditingOperationProtectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraEditingOperationProtectionTests.swift; sourceTree = "<group>"; };
ADFD21D28C5F9E58B70E76CA /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = "<group>"; };
+ B310FD8CCA9F9C2DA19BE8CF /* TeraEditingReplacementFFITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraEditingReplacementFFITests.swift; sourceTree = "<group>"; };
B4D3FEE2830E056E82AF05FD /* TeraTodayBackfillTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraTodayBackfillTests.swift; sourceTree = "<group>"; };
BAD45A3134A16704CBE4B1A5 /* TeraStateTestFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraStateTestFixtures.swift; sourceTree = "<group>"; };
BD39C5F3460EEF38B98D78B4 /* TeraStoreRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraStoreRecoveryTests.swift; sourceTree = "<group>"; };
@@ -178,6 +187,7 @@
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>"; };
F0A51F9AF2E0803264E07FBB /* TeraRuntimeResourceFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRuntimeResourceFixtures.swift; sourceTree = "<group>"; };
+ F27D9EF66493E92AFDD53213 /* TeraEditingProtectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraEditingProtectionTests.swift; sourceTree = "<group>"; };
F596D3A9B9E236963DCE15D7 /* TeraMediaBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraMediaBudgetTests.swift; sourceTree = "<group>"; };
F65B01F6F5F7E125148EC4FD /* TeraTodayReconciliationFFITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraTodayReconciliationFFITests.swift; sourceTree = "<group>"; };
FA1A8EA600F6BDC45333520C /* TeraCalendarComposerFFITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraCalendarComposerFFITests.swift; sourceTree = "<group>"; };
@@ -217,6 +227,7 @@
346963F5BDF916F6BC10D6F6 /* TeraUITests */ = {
isa = PBXGroup;
children = (
+ 0D243145C276EBF17915AB4E /* TeraEditingProtectionUITests.swift */,
32CBDAE974FB8D7408108E69 /* TeraRemoteQualificationUITests.swift */,
5238B5F236C392C82CEF9838 /* TeraRootShellUITests.swift */,
);
@@ -260,6 +271,10 @@
A5938176A92F96A30CC39DFB /* TeraComposerPersistenceFFITests.swift */,
DE21EEF3B1A13F1B5A10A3BF /* TeraComposerStartupTests.swift */,
ECE8A3704AB9582A5E461195 /* TeraDraftRecoveryTests.swift */,
+ AC9B19425D3374B899485C17 /* TeraEditingOperationProtectionTests.swift */,
+ F27D9EF66493E92AFDD53213 /* TeraEditingProtectionTests.swift */,
+ B310FD8CCA9F9C2DA19BE8CF /* TeraEditingReplacementFFITests.swift */,
+ 0F97ECE43BB7028B5A497720 /* TeraEditingReplacementTests.swift */,
93A3358AFC23C567DB9CB02F /* TeraErrorRecoveryTests.swift */,
C890BDE885536BC2AB215393 /* TeraLifecycleTests.swift */,
F596D3A9B9E236963DCE15D7 /* TeraMediaBudgetTests.swift */,
@@ -533,6 +548,10 @@
295A19317FC58A73A68C98DC /* TeraComposerPersistenceFFITests.swift in Sources */,
3410EC660FF36CC77F5F50DD /* TeraComposerStartupTests.swift in Sources */,
64F5AD3749E090316C7D015E /* TeraDraftRecoveryTests.swift in Sources */,
+ F14217EA66C2A9397E36E3FA /* TeraEditingOperationProtectionTests.swift in Sources */,
+ A65623800C28B2FEEFB49780 /* TeraEditingProtectionTests.swift in Sources */,
+ 888B178679E012BB053FE31B /* TeraEditingReplacementFFITests.swift in Sources */,
+ 7B8C7C130DC15AF0266F2544 /* TeraEditingReplacementTests.swift in Sources */,
D918A664D9A4B9859A1A6503 /* TeraErrorRecoveryTests.swift in Sources */,
671EF50DB4EFBC25AABF26DC /* TeraLifecycleTests.swift in Sources */,
85404A5125CF3E64E6009DC1 /* TeraMediaBudgetTests.swift in Sources */,
@@ -590,6 +609,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 6F09A1AF6E764AC0136E8F74 /* TeraEditingProtectionUITests.swift in Sources */,
C3177E827FE62639579C3EF5 /* TeraRemoteQualificationUITests.swift in Sources */,
DF97F408D7D32F72C5AF5939 /* TeraRootShellUITests.swift in Sources */,
);
diff --git a/Tera/App/TeraRootShell.swift b/Tera/App/TeraRootShell.swift
@@ -60,7 +60,15 @@ struct TeraRootShell: View {
if let stores {
TeraAddView(store: stores.add)
} else {
+ #if DEBUG
+ if ProcessInfo.processInfo.environment["TERA_IOS_UI_TEST_EDITING_PROTECTION"] == "1" {
+ TeraEditingProtectionUITestSurface()
+ } else {
+ TeraAddUnavailable()
+ }
+ #else
TeraAddUnavailable()
+ #endif
}
}
.tabItem { Label("Add", systemImage: "plus.circle.fill") }
diff --git a/Tera/State/TeraAddCardIntents.swift b/Tera/State/TeraAddCardIntents.swift
@@ -0,0 +1,52 @@
+import Foundation
+
+/// Existing native card-to-intent translation. Rust retains authorization,
+/// validation, immutable operation identity and all publication policy.
+enum TeraAddCardIntents {
+ struct RevisionEditing {
+ let target: TeraRevisionTarget
+ let form: TeraAddForm
+ }
+
+ struct Retraction {
+ let id: String
+ let input: TeraRetractionDraftInput
+ }
+
+ static func revision(_ card: TeraTodayCard, author: String?, client: TeraRuntimeClient) async throws -> RevisionEditing {
+ guard let author, author == card.authorPublicKey else {
+ throw TeraRuntimeFailure.local(operation: "add.revise", code: "ios.add.revision_not_authorized",
+ safeMessage: "Only your own post can be revised.")
+ }
+ guard let id = card.localOperationID else {
+ throw TeraRuntimeFailure.local(operation: "add.revise", code: "ios.add.revision_source_unavailable",
+ safeMessage: "This post cannot be revised losslessly on this device.")
+ }
+ let source = try await client.draftStatus(id: id)
+ guard let form = source.form else {
+ throw TeraRuntimeFailure.local(operation: "add.revise", code: "ios.add.revision_form_unavailable",
+ safeMessage: "The original Add form is unavailable on this device.")
+ }
+ return RevisionEditing(target: TeraRevisionTarget(cardID: card.id, sourceEventID: card.sourceEventID,
+ sourceAddress: card.sourceAddress, authorPublicKey: card.authorPublicKey), form: form)
+ }
+
+ static func retraction(_ card: TeraTodayCard, author: String?, identifier: () -> String) throws -> Retraction {
+ guard let author, author == card.authorPublicKey else {
+ throw TeraRuntimeFailure.local(operation: "add.retract", code: "ios.add.retraction_not_authorized",
+ safeMessage: "Only your own post can be retracted.")
+ }
+ guard let kind = card.retractionTargetKind else {
+ throw TeraRuntimeFailure.local(operation: "add.retract", code: "ios.add.retraction_target_invalid",
+ safeMessage: "This post cannot be retracted safely.")
+ }
+ let id = identifier()
+ guard TeraAddPresentation.isValidIdentifier(id) else {
+ throw TeraRuntimeFailure.local(operation: "add.retract", code: "ios.add.identifier_invalid",
+ safeMessage: "The local operation identifier is invalid.")
+ }
+ return Retraction(id: id, input: TeraRetractionDraftInput(commandType: card.type.addCommandType,
+ targetCardID: card.id, targetEventID: card.sourceEventID,
+ targetKind: kind, targetAddress: card.sourceAddress, reason: "Removed by author."))
+ }
+}
diff --git a/Tera/State/TeraAddStore.swift b/Tera/State/TeraAddStore.swift
@@ -9,6 +9,7 @@ final class TeraAddStore: ObservableObject {
didSet {
if form != oldValue {
message = nil
+ protection.editingChanged()
composer.observeEditing(form, isEditable: isFormEditable, isRevision: revisionTarget != nil)
}
}
@@ -16,6 +17,7 @@ final class TeraAddStore: ObservableObject {
@Published private(set) var composerState: TeraComposerSaveState = .idle
let recovery: TeraDraftRecoveryStore
+ let protection = TeraEditingProtection()
@Published private(set) var mediaRecoveryMessage: String?
@Published private(set) var state: TeraAddLoadState = .idle
@Published private(set) var mediaSupport: TeraAddMediaSupport = .unavailable
@@ -70,6 +72,7 @@ final class TeraAddStore: ObservableObject {
clock: clock
)
composer.stateChanged = { [weak self] in self?.composerState = $0 }
+ protection.cancelled = { [weak self] in guard let self else { return }; generation = generation.invalidated() }
}
var savedComposer: TeraComposerDraft? {
@@ -88,22 +91,34 @@ final class TeraAddStore: ObservableObject {
}
func newDraft(type: TeraAddCommandType? = nil) {
- guard !isWorking else { return }
+ guard !isWorking, !protection.isWorking, !protection.failed else { return }
+ if needsEditingPreservation {
+ protection.schedule(kind: .editing, save: preservation(), apply: replacement { store, _ in store.replaceWithNew(type: type) })
+ } else {
+ replaceWithNew(type: type)
+ }
+ }
+
+ private func replaceWithNew(type: TeraAddCommandType?) {
composer.reset(scope: composer.scope)
generation = generation.invalidated()
activeDraft = nil
revisionTarget = nil
revisionOperationID = nil
- form = TeraAddPresentation.newForm(
- type: type ?? form.commandType,
- identifier: identifier,
- clock: clock
- )
+ form = TeraAddPresentation.newForm(type: type ?? form.commandType, identifier: identifier, clock: clock)
message = nil
}
func reopen(_ draft: TeraDraftStatus) {
- guard !isWorking else { return }
+ guard !isWorking, !protection.isWorking, !protection.failed else { return }
+ if needsEditingPreservation {
+ protection.schedule(kind: .reopen, save: preservation(), apply: replacement { store, _ in store.replaceWithLegacy(draft) })
+ } else {
+ replaceWithLegacy(draft)
+ }
+ }
+
+ private func replaceWithLegacy(_ draft: TeraDraftStatus) {
guard let snapshot = draft.form else {
message = "This operation has no editable Add form."
return
@@ -118,28 +133,25 @@ final class TeraAddStore: ObservableObject {
}
func reopenSaved(_ selection: TeraDraftRecoverySelection) async -> Bool {
- var applied = false
- await perform { requested in
- let recovered = try await self.recovery.load(selection)
- try self.ensureCurrent(requested)
+ await replaceEditing(kind: .reopen) { store, requested in
+ let recovered = try await store.recovery.load(selection)
+ try store.ensureCurrent(requested)
switch recovered {
case let .composer(draft):
- try self.composer.restore(draft)
- self.activeDraft = nil
- self.revisionTarget = nil
- self.revisionOperationID = nil
- self.form = draft.form.editingValue
- self.message = "Saved editing reopened."
+ try store.composer.restore(draft)
+ store.activeDraft = nil
+ store.revisionTarget = nil
+ store.revisionOperationID = nil
+ store.form = draft.form.editingValue
+ store.message = "Saved editing reopened."
case let .legacy(draft):
- self.composer.reset(scope: self.composer.scope)
- self.revisionTarget = nil
- self.revisionOperationID = draft.isRevision ? draft.id : nil
- try self.accept(draft, generation: requested)
- self.message = draft.honestSummary
+ store.composer.reset(scope: store.composer.scope)
+ store.revisionTarget = nil
+ store.revisionOperationID = draft.isRevision ? draft.id : nil
+ try store.accept(draft, generation: requested)
+ store.message = draft.honestSummary
}
- applied = true
}
- return applied
}
func importPhotos() async {
@@ -292,36 +304,41 @@ final class TeraAddStore: ObservableObject {
}
func retry(id: String) async {
- await perform { requestedGeneration in
- var current = try await self.runtimeClient.draftStatus(id: id)
- try self.ensureCurrent(requestedGeneration)
+ await replaceEditing { store, requestedGeneration in
+ var current = try await store.runtimeClient.draftStatus(id: id)
+ try store.ensureCurrent(requestedGeneration)
if current.state == .draft || current.state == .mediaPreparing
|| current.state == .readyToSign
{
- self.activeDraft = current
- self.revisionOperationID = current.isRevision ? current.id : nil
+ store.composer.reset(scope: store.composer.scope)
+ store.activeDraft = current
+ store.revisionOperationID = current.isRevision ? current.id : nil
if let form = current.form {
- self.form = form
+ store.form = form
}
- self.message = "Review the draft before submitting again."
+ store.message = "Review the draft before submitting again."
return
}
if current.isRevision {
- let revision = try await self.runtimeClient.advanceRevision(
+ let revision = try await store.runtimeClient.advanceRevision(
operationID: current.id
)
- try self.accept(revision, generation: requestedGeneration)
- self.message = revision.honestSummary
+ try store.ensureCurrent(requestedGeneration)
+ store.composer.reset(scope: store.composer.scope)
+ try store.accept(revision, generation: requestedGeneration)
+ store.message = revision.honestSummary
return
}
if current.state.canAdvance {
- current = try await self.runtimeClient.advanceDraft(
+ current = try await store.runtimeClient.advanceDraft(
id: current.id,
expectedRevision: current.revision
)
}
- try self.accept(current, generation: requestedGeneration)
- self.message = current.honestSummary
+ try store.ensureCurrent(requestedGeneration)
+ store.composer.reset(scope: store.composer.scope)
+ try store.accept(current, generation: requestedGeneration)
+ store.message = current.honestSummary
}
}
@@ -331,117 +348,68 @@ final class TeraAddStore: ObservableObject {
}
func cancel(id: String) async {
- await perform { requestedGeneration in
- let current = try await self.runtimeClient.draftStatus(id: id)
- try self.ensureCurrent(requestedGeneration)
+ await replaceEditing { store, requestedGeneration in
+ let current = try await store.runtimeClient.draftStatus(id: id)
+ try store.ensureCurrent(requestedGeneration)
guard current.state.canCancel else { return }
if current.isRevision {
- let cancelled = try await self.runtimeClient.cancelRevision(
+ let cancelled = try await store.runtimeClient.cancelRevision(
operationID: current.id
)
- try self.accept(cancelled, generation: requestedGeneration)
- self.message = cancelled.honestSummary
+ try store.ensureCurrent(requestedGeneration)
+ store.composer.reset(scope: store.composer.scope)
+ try store.accept(cancelled, generation: requestedGeneration)
+ store.message = cancelled.honestSummary
return
}
- let cancelled = try await self.runtimeClient.cancelAddIntent(
+ let cancelled = try await store.runtimeClient.cancelAddIntent(
id: current.id,
expectedRevision: current.revision
)
- try self.accept(cancelled, generation: requestedGeneration)
- self.message = "Local work was cancelled. Any already-published relay effect is preserved."
+ try store.ensureCurrent(requestedGeneration)
+ store.composer.reset(scope: store.composer.scope)
+ try store.accept(cancelled, generation: requestedGeneration)
+ store.message = "Local work was cancelled. Any already-published relay effect is preserved."
}
}
func retractAndRevise(_ card: TeraTodayCard) async {
- await perform { requestedGeneration in
- guard let publicKey = self.activePublicKey, publicKey == card.authorPublicKey else {
- throw TeraRuntimeFailure.local(
- operation: "add.revise",
- code: "ios.add.revision_not_authorized",
- safeMessage: "Only your own post can be revised."
- )
- }
- guard let operationID = card.localOperationID else {
- throw TeraRuntimeFailure.local(
- operation: "add.revise",
- code: "ios.add.revision_source_unavailable",
- safeMessage: "This post cannot be revised losslessly on this device."
- )
- }
- let source = try await self.runtimeClient.draftStatus(id: operationID)
- try self.ensureCurrent(requestedGeneration)
- guard let sourceForm = source.form else {
- throw TeraRuntimeFailure.local(
- operation: "add.revise",
- code: "ios.add.revision_form_unavailable",
- safeMessage: "The original Add form is unavailable on this device."
- )
- }
- self.revisionTarget = TeraRevisionTarget(
- cardID: card.id,
- sourceEventID: card.sourceEventID,
- sourceAddress: card.sourceAddress,
- authorPublicKey: card.authorPublicKey
- )
- self.composer.reset(scope: self.composer.scope)
- self.revisionOperationID = nil
- self.activeDraft = nil
- self.form = sourceForm
- self.message = "Review the lossless revised copy before publishing."
+ await replaceEditing { store, requestedGeneration in
+ let editing = try await TeraAddCardIntents.revision(card, author: store.activePublicKey, client: store.runtimeClient)
+ try store.ensureCurrent(requestedGeneration)
+ store.revisionTarget = editing.target
+ store.composer.reset(scope: store.composer.scope)
+ store.revisionOperationID = nil
+ store.activeDraft = nil
+ store.form = editing.form
+ store.message = "Review the lossless revised copy before publishing."
}
}
func retract(_ card: TeraTodayCard) async {
- await perform { requestedGeneration in
- guard let publicKey = self.activePublicKey, publicKey == card.authorPublicKey else {
- throw TeraRuntimeFailure.local(
- operation: "add.retract",
- code: "ios.add.retraction_not_authorized",
- safeMessage: "Only your own post can be retracted."
- )
- }
- guard let targetKind = card.retractionTargetKind else {
- throw TeraRuntimeFailure.local(
- operation: "add.retract",
- code: "ios.add.retraction_target_invalid",
- safeMessage: "This post cannot be retracted safely."
- )
- }
- let draftID = self.identifier()
- guard TeraAddPresentation.isValidIdentifier(draftID) else {
- throw TeraRuntimeFailure.local(
- operation: "add.retract",
- code: "ios.add.identifier_invalid",
- safeMessage: "The local operation identifier is invalid."
- )
- }
- var status = try await self.runtimeClient.saveRetractionDraft(
- id: draftID,
- input: TeraRetractionDraftInput(
- commandType: card.type.addCommandType,
- targetCardID: card.id,
- targetEventID: card.sourceEventID,
- targetKind: targetKind,
- targetAddress: card.sourceAddress,
- reason: "Removed by author."
- ),
- authoredAtUnixSeconds: self.clock.unixSeconds(),
- persistedAtUnixMilliseconds: self.clock.unixMilliseconds()
+ await replaceEditing { store, requestedGeneration in
+ let request = try TeraAddCardIntents.retraction(card, author: store.activePublicKey, identifier: store.identifier)
+ var status = try await store.runtimeClient.saveRetractionDraft(
+ id: request.id, input: request.input,
+ authoredAtUnixSeconds: store.clock.unixSeconds(),
+ persistedAtUnixMilliseconds: store.clock.unixMilliseconds()
)
- try self.accept(status, generation: requestedGeneration)
- status = try await self.runtimeClient.queueAddIntent(
+ try store.ensureCurrent(requestedGeneration)
+ store.composer.reset(scope: store.composer.scope)
+ try store.accept(status, generation: requestedGeneration)
+ status = try await store.runtimeClient.queueAddIntent(
id: status.id,
expectedRevision: status.revision
)
- try self.accept(status, generation: requestedGeneration)
+ try store.accept(status, generation: requestedGeneration)
if status.state.canAdvance {
- status = try await self.runtimeClient.advanceDraft(
+ status = try await store.runtimeClient.advanceDraft(
id: status.id,
expectedRevision: status.revision
)
- try self.accept(status, generation: requestedGeneration)
+ try store.accept(status, generation: requestedGeneration)
}
- self.message = status.honestSummary
+ store.message = status.honestSummary
}
}
@@ -497,11 +465,7 @@ final class TeraAddStore: ObservableObject {
return status
}
- private func uploadPendingMedia(
- _ initial: TeraDraftStatus, generation requestedGeneration: TeraSessionGeneration
- ) async throws
- -> TeraDraftStatus
- {
+ private func uploadPendingMedia(_ initial: TeraDraftStatus, generation requestedGeneration: TeraSessionGeneration) async throws -> TeraDraftStatus {
guard let media else {
throw TeraRuntimeFailure.local(
operation: "add.media.upload",
@@ -614,9 +578,7 @@ final class TeraAddStore: ObservableObject {
guard isCurrent(requested) else { throw CancellationError() }
}
- private func accept(
- _ status: TeraDraftStatus, generation requested: TeraSessionGeneration
- ) throws {
+ private func accept(_ status: TeraDraftStatus, generation requested: TeraSessionGeneration) throws {
try ensureCurrent(requested)
draftsGeneration = draftsGeneration.invalidated()
appliedDraftsGeneration = try appliedDraftsGeneration.next()
@@ -629,9 +591,7 @@ final class TeraAddStore: ObservableObject {
drafts = TeraAddPresentation.sorted(drafts)
}
- private func accept(
- _ status: TeraRevisionStatus, generation requested: TeraSessionGeneration
- ) throws {
+ private func accept(_ status: TeraRevisionStatus, generation requested: TeraSessionGeneration) throws {
try ensureCurrent(requested)
revisionOperationID = status.operationID
try accept(status.replacement, generation: requested)
@@ -766,6 +726,7 @@ extension TeraAddStore {
}
func stop() {
+ protection.cancel()
recovery.stop()
composer.stop()
generation = generation.invalidated()
@@ -785,3 +746,40 @@ extension TeraAddStore {
observationState = .stopped
}
}
+
+private extension TeraAddStore {
+ var needsEditingPreservation: Bool {
+ revisionTarget != nil || (isFormEditable && composer.isDirty)
+ }
+
+ func preservation() -> () async -> Bool {
+ let scope = composer.scope
+ return { [weak self] in
+ guard let self, composer.scope == scope else { return false }
+ guard needsEditingPreservation else { return true }
+ let editing = form
+ await save()
+ return composer.scope == scope && form == editing && !needsEditingPreservation
+ }
+ }
+
+ func replacement(_ operation: @escaping (TeraAddStore, TeraSessionGeneration) async throws -> Void) -> () async -> Bool {
+ let scope = composer.scope
+ return { [weak self] in
+ guard let self, composer.scope == scope else { return false }
+ var applied = false
+ await perform { requested in
+ try await operation(self, requested)
+ applied = true
+ }
+ return applied
+ }
+ }
+
+ @discardableResult
+ func replaceEditing(kind: TeraEditingProtection.Kind = .editing,
+ _ operation: @escaping (TeraAddStore, TeraSessionGeneration) async throws -> Void) async -> Bool
+ {
+ await protection.replace(kind: kind, save: preservation(), apply: replacement(operation))
+ }
+}
diff --git a/Tera/State/TeraEditingProtection.swift b/Tera/State/TeraEditingProtection.swift
@@ -0,0 +1,119 @@
+import Foundation
+
+/// One replacement and one worker. These transient tokens are never persisted
+/// as composer identity, revision, or publication authority.
+@MainActor
+final class TeraEditingProtection: ObservableObject {
+ enum Kind { case editing, reopen }
+ @Published private(set) var failed = false
+ @Published private(set) var isWorking = false
+ @Published private(set) var reopened: UUID?
+ var cancelled: () -> Void = {}
+ private struct Pending {
+ let token: UUID
+ let kind: Kind
+ let save: () async -> Bool
+ let apply: () async -> Bool
+ }
+
+ private var pending: Pending?
+ private var worker: Task<Bool, Never>?
+ private var activeToken: UUID?
+
+ var choiceToken: UUID? {
+ pending?.token
+ }
+
+ deinit { worker?.cancel() }
+
+ func replace(kind: Kind, save: @escaping () async -> Bool, apply: @escaping () async -> Bool) async -> Bool {
+ guard let token = schedule(kind: kind, save: save, apply: apply), let worker else { return false }
+ return await withTaskCancellationHandler { await worker.value } onCancel: {
+ Task { @MainActor [weak self] in self?.cancel(token: token) }
+ }
+ }
+
+ @discardableResult
+ func schedule(kind: Kind, save: @escaping () async -> Bool, apply: @escaping () async -> Bool) -> UUID? {
+ guard pending == nil, worker == nil, !Task.isCancelled else { return nil }
+ let token = UUID()
+ pending = Pending(token: token, kind: kind, save: save, apply: apply)
+ start(save: true)
+ return token
+ }
+
+ func retry() {
+ start(save: true)
+ }
+
+ func discard() {
+ start(save: false)
+ }
+
+ func discard(token: UUID) {
+ guard pending?.token == token else { return }
+ discard()
+ }
+
+ func cancel() {
+ let hadRequest = pending != nil || activeToken != nil
+ pending = nil
+ activeToken = nil
+ failed = false
+ isWorking = false
+ worker?.cancel()
+ if hadRequest {
+ cancelled()
+ }
+ }
+
+ func editingChanged() {
+ // Once the replacement is admitted, the Add owner's operation generation
+ // fences its callbacks. Its own form assignment is not a new dirty edit.
+ if pending != nil {
+ cancel()
+ }
+ }
+
+ func cancel(token: UUID) {
+ if pending?.token == token || activeToken == token {
+ cancel()
+ }
+ }
+
+ private func start(save: Bool) {
+ guard worker == nil, let pending, !Task.isCancelled else { return }
+ failed = false
+ isWorking = true
+ activeToken = pending.token
+ worker = Task { [weak self] in
+ guard let self else { return false }
+ return await run(pending, save: save)
+ }
+ }
+
+ private func run(_ request: Pending, save: Bool) async -> Bool {
+ defer {
+ worker = nil
+ isWorking = false
+ activeToken = nil
+ }
+ let saved = if save {
+ await request.save()
+ } else {
+ true
+ }
+ guard !Task.isCancelled, pending?.token == request.token, activeToken == request.token else { return false }
+ guard saved else {
+ failed = true
+ return false
+ }
+ pending = nil
+ let applied = await request.apply()
+ guard !Task.isCancelled, activeToken == request.token, applied else { return false }
+ if request.kind == .reopen {
+ reopened = request.token
+ }
+ return true
+ }
+}
diff --git a/Tera/Views/TeraAddSaveStatus.swift b/Tera/Views/TeraAddSaveStatus.swift
@@ -5,6 +5,7 @@ struct TeraAddSaveStatus: View {
let symbol: String
let state: TeraComposerSaveState
var mediaMessage: String?
+ let protection: TeraEditingProtection
var body: some View {
Section {
@@ -20,5 +21,6 @@ struct TeraAddSaveStatus: View {
.accessibilityIdentifier("radroots.add.status")
}
}
+ TeraEditingProtectionActions(protection: protection)
}
}
diff --git a/Tera/Views/TeraAddView.swift b/Tera/Views/TeraAddView.swift
@@ -36,7 +36,7 @@ struct TeraAddView: View {
EmptyView()
}
- TeraAddSaveStatus(message: store.message, symbol: statusSymbol, state: store.composerState, mediaMessage: store.mediaRecoveryMessage)
+ TeraAddSaveStatus(message: store.message, symbol: statusSymbol, state: store.composerState, mediaMessage: store.mediaRecoveryMessage, protection: store.protection)
if store.activeDraft?.kind == .retraction {
Section("Retraction") {
diff --git a/Tera/Views/TeraDraftsSheet.swift b/Tera/Views/TeraDraftsSheet.swift
@@ -3,11 +3,13 @@ import SwiftUI
struct TeraDraftsSheet: View {
@ObservedObject var store: TeraAddStore
@ObservedObject private var recovery: TeraDraftRecoveryStore
+ @ObservedObject private var protection: TeraEditingProtection
@Environment(\.dismiss) private var dismiss
init(store: TeraAddStore) {
self.store = store
recovery = store.recovery
+ protection = store.protection
}
var body: some View {
@@ -19,6 +21,7 @@ struct TeraDraftsSheet: View {
if let message = store.message {
Text(message)
}
+ TeraEditingProtectionActions(protection: protection)
composerSection
legacySection
Section {
@@ -35,6 +38,11 @@ struct TeraDraftsSheet: View {
}
.accessibilityIdentifier("radroots.add.drafts.sheet")
.onAppear { recovery.start() }
+ .onChange(of: protection.reopened) { _, value in
+ if value != nil {
+ dismiss()
+ }
+ }
}
private var composerSection: some View {
diff --git a/Tera/Views/TeraEditingProtectionActions.swift b/Tera/Views/TeraEditingProtectionActions.swift
@@ -0,0 +1,58 @@
+import SwiftUI
+
+/// Present a discard confirmation only from the surface the user is acting on,
+/// including when the editor is underneath the saved-work sheet.
+struct TeraEditingProtectionActions: View {
+ @ObservedObject var protection: TeraEditingProtection
+ @State private var confirmsDiscard = false
+ @State private var didConfirmDiscard = false
+ @State private var discardToken: UUID?
+
+ var body: some View {
+ Group {
+ if protection.failed {
+ failureSection
+ }
+ }
+ .confirmationDialog("Discard the current unsaved changes?", isPresented: $confirmsDiscard, titleVisibility: .visible) {
+ Button("Discard and continue", role: .destructive) {
+ didConfirmDiscard = true
+ if let discardToken {
+ protection.discard(token: discardToken)
+ }
+ }
+ Button("Keep editing", role: .cancel) { cancelConfirmation() }
+ } message: {
+ Text("Previously saved drafts and submitted operations remain on this device.")
+ }
+ .onChange(of: confirmsDiscard) { _, presented in
+ // Popovers omit the cancel button. A late dismissal belongs only to the
+ // choice that opened it, even if another request has already failed.
+ if !presented, !didConfirmDiscard {
+ cancelConfirmation()
+ }
+ }
+ }
+
+ private var failureSection: some View {
+ Section("Keep your editing") {
+ Text("Saving could not be confirmed. Your current editing is still here.")
+ Button("Retry save and continue") { protection.retry() }
+ .accessibilityIdentifier("tera.add.protection.retry")
+ Button("Discard unsaved changes…", role: .destructive) {
+ discardToken = protection.choiceToken
+ didConfirmDiscard = false
+ confirmsDiscard = true
+ }
+ .accessibilityIdentifier("tera.add.protection.discard")
+ Button("Keep editing") { protection.cancel() }
+ .accessibilityIdentifier("tera.add.protection.keep")
+ }
+ }
+
+ private func cancelConfirmation() {
+ if let discardToken {
+ protection.cancel(token: discardToken)
+ }
+ }
+}
diff --git a/Tera/Views/TeraEditingProtectionUITestSurface.swift b/Tera/Views/TeraEditingProtectionUITestSurface.swift
@@ -0,0 +1,28 @@
+#if DEBUG
+ import SwiftUI
+
+ /// The existing runtime-free shell test mode hosts the actual failure controls.
+ /// Production persistence and relaunch are qualified by the native FFI tests.
+ struct TeraEditingProtectionUITestSurface: View {
+ @StateObject private var protection = TeraEditingProtection()
+ @State private var editing = "Keep this unfinished text"
+ @State private var replacements = 0
+
+ var body: some View {
+ Form {
+ TextField("Editing", text: $editing)
+ .accessibilityIdentifier("tera.test.editing.value")
+ TeraEditingProtectionActions(protection: protection)
+ Button("Attempt replacement") {
+ protection.schedule(kind: .editing, save: { false }, apply: {
+ editing = ""
+ replacements += 1
+ return true
+ })
+ }
+ .accessibilityIdentifier("tera.test.editing.replace")
+ Text(String(replacements)).accessibilityIdentifier("tera.test.editing.replacements")
+ }
+ }
+ }
+#endif
diff --git a/TeraTests/TeraAddStoreTests.swift b/TeraTests/TeraAddStoreTests.swift
@@ -671,7 +671,7 @@ final class TeraAddStoreTests: XCTestCase {
return false
}
- private static func startedClient(_ backend: AddBackend) async throws -> TeraRuntimeClient {
+ static func startedClient(_ backend: AddBackend) async throws -> TeraRuntimeClient {
let client = TeraRuntimeClient { _ in
await TeraRuntimeBackendStart(backend: backend, snapshot: backend.snapshot())
}
@@ -706,7 +706,7 @@ final class TeraAddStoreTests: XCTestCase {
)
}
- private static func card(localOperationID: String? = nil) -> TeraTodayCard {
+ static func card(localOperationID: String? = nil) -> TeraTodayCard {
TeraTodayCard(
id: String(repeating: "c", count: 64),
type: .foodAvailability,
@@ -750,13 +750,13 @@ private struct AddSigner: TeraRuntimeSigner {
}
}
-private enum AddDelayPhase: String, CaseIterable {
+enum AddDelayPhase: String, CaseIterable {
case save
case queue
case advance
}
-private actor AddBackend: TeraRuntimeBackend {
+actor AddBackend: TeraRuntimeBackend {
private let composerStorage = ComposerTestStorage()
private let savePause: ResourceTestPause?
private let advanceOffline: Bool
@@ -1341,7 +1341,7 @@ extension TeraAddStoreTests {
}
}
-private extension AddBackend {
+extension AddBackend {
func reserveComposerID() async -> String {
await composerStorage.reserve()
}
diff --git a/TeraTests/TeraComposerPersistenceFFITests.swift b/TeraTests/TeraComposerPersistenceFFITests.swift
@@ -252,7 +252,7 @@ final class TeraComposerPersistenceFFITests: XCTestCase {
}
}
-private actor ComposerForbiddenSigner: TeraRuntimeSigner {
+actor ComposerForbiddenSigner: TeraRuntimeSigner {
private(set) var requests = 0
func availability() -> TeraRuntimeSignerAvailability {
.ready
diff --git a/TeraTests/TeraEditingOperationProtectionTests.swift b/TeraTests/TeraEditingOperationProtectionTests.swift
@@ -0,0 +1,72 @@
+import Foundation
+@testable import TeraApp
+import XCTest
+
+@MainActor
+final class TeraEditingOperationProtectionTests: XCTestCase {
+ func testNewSavesExistingStrictRevisionBeforeReplacingItsEditing() async throws {
+ let backend = AddBackend(advanceOffline: true)
+ let client = try await TeraAddStoreTests.startedClient(backend)
+ let store = TeraAddStore(runtimeClient: client)
+ await store.configure(snapshot: backend.snapshot())
+ await store.start()
+ store.updateForm(\.content, "original")
+ await store.submit()
+ let source = try XCTUnwrap(store.activeDraft)
+ await store.retractAndRevise(TeraAddStoreTests.card(localOperationID: source.id))
+ store.updateForm(\.content, "corrected revision")
+ store.newDraft(type: .createAsk)
+ await TeraScopeFixtures.eventually { !store.protection.isWorking }
+ XCTAssertFalse(store.protection.failed)
+ XCTAssertEqual(store.form.commandType, .createAsk)
+ XCTAssertEqual(store.form.content, "")
+ let saved = try XCTUnwrap(store.drafts.first(where: \.isRevision))
+ XCTAssertEqual(saved.form?.content, "corrected revision")
+ XCTAssertEqual(saved.state, .draft)
+ let count = await backend.revisionPlanCount()
+ XCTAssertEqual(count, 1)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testFailedPreservationPreventsTodayRevisionAndRetractionEffects() async throws {
+ let backend = AddBackend(saveFailure: TeraScopeFixtures.failure())
+ let client = try await TeraAddStoreTests.startedClient(backend)
+ let store = TeraAddStore(runtimeClient: client)
+ await store.configure(snapshot: backend.snapshot())
+ await store.start()
+ store.updateForm(\.content, "keep exact editing")
+ let editing = store.form
+ await store.retract(TeraAddStoreTests.card())
+ XCTAssertTrue(store.protection.failed)
+ XCTAssertEqual(store.form, editing)
+ XCTAssertNil(store.activeDraft)
+ let retraction = await backend.lastRetraction()
+ XCTAssertNil(retraction)
+ store.protection.cancel()
+ await store.retractAndRevise(TeraAddStoreTests.card(localOperationID: "missing"))
+ XCTAssertTrue(store.protection.failed)
+ XCTAssertEqual(store.form, editing)
+ XCTAssertNil(store.activeDraft)
+ let count = await backend.revisionPlanCount()
+ XCTAssertEqual(count, 0)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testAbandonedFailedChoiceDoesNotRetainStoppedStore() async throws {
+ let backend = AddBackend(saveFailure: TeraScopeFixtures.failure())
+ let client = try await TeraAddStoreTests.startedClient(backend)
+ var store: TeraAddStore? = TeraAddStore(runtimeClient: client)
+ weak var weakStore = store
+ await store?.configure(snapshot: backend.snapshot())
+ await store?.start()
+ store?.updateForm(\.content, "unsaved")
+ store?.newDraft()
+ await TeraScopeFixtures.eventually { store?.protection.failed == true }
+ store?.suspend()
+ store = nil
+ await TeraScopeFixtures.eventually { weakStore == nil }
+ _ = try await client.stop()
+ }
+}
diff --git a/TeraTests/TeraEditingProtectionTests.swift b/TeraTests/TeraEditingProtectionTests.swift
@@ -0,0 +1,131 @@
+import Foundation
+@testable import TeraApp
+import XCTest
+
+@MainActor
+final class TeraEditingProtectionTests: XCTestCase {
+ func testLateDialogDismissalOrDiscardCannotActOnANewerFailedChoice() async throws {
+ let protection = TeraEditingProtection()
+ var applied = 0
+ _ = await protection.replace(kind: .editing, save: { false }, apply: { applied += 1; return true })
+ let old = try XCTUnwrap(protection.choiceToken)
+ protection.cancel(token: old)
+ _ = await protection.replace(kind: .editing, save: { false }, apply: { applied += 1; return true })
+ let current = try XCTUnwrap(protection.choiceToken)
+ XCTAssertNotEqual(old, current)
+ protection.cancel(token: old)
+ protection.discard(token: old)
+ XCTAssertTrue(protection.failed)
+ XCTAssertEqual(protection.choiceToken, current)
+ XCTAssertEqual(applied, 0)
+ protection.discard(token: current)
+ await TeraScopeFixtures.eventually { !protection.isWorking }
+ XCTAssertEqual(applied, 1)
+ }
+
+ func testOnePendingReplacementAwaitsSaveAndPublishesOnlyAppliedReopen() async {
+ let protection = TeraEditingProtection()
+ let pause = ResourceTestPause()
+ var saves = 0
+ var applied = 0
+ let first = Task {
+ await protection.replace(kind: .reopen, save: {
+ saves += 1
+ await pause.wait()
+ return true
+ }, apply: { applied += 1; return true })
+ }
+ await pause.entered.wait()
+ for _ in 0 ..< 1000 {
+ XCTAssertNil(protection.schedule(kind: .editing, save: { XCTFail("Duplicate save"); return true }, apply: { false }))
+ protection.retry()
+ protection.discard()
+ }
+ XCTAssertEqual(saves, 1)
+ XCTAssertEqual(applied, 0)
+ XCTAssertNil(protection.reopened)
+ await pause.resume.open()
+ let result = await first.value
+ XCTAssertTrue(result)
+ XCTAssertEqual(applied, 1)
+ XCTAssertNotNil(protection.reopened)
+ XCTAssertFalse(protection.failed)
+ XCTAssertFalse(protection.isWorking)
+ }
+
+ func testFailedSaveRetainsChoiceAndRetryAppliesOnce() async {
+ let protection = TeraEditingProtection()
+ var saves = 0
+ var applied = 0
+ let result = await protection.replace(kind: .reopen, save: { saves += 1; return saves == 2 }, apply: { applied += 1; return true })
+ XCTAssertFalse(result)
+ XCTAssertTrue(protection.failed)
+ XCTAssertNil(protection.reopened)
+ XCTAssertEqual(applied, 0)
+ protection.retry()
+ await TeraScopeFixtures.eventually { !protection.isWorking }
+ XCTAssertEqual(saves, 2)
+ XCTAssertEqual(applied, 1)
+ XCTAssertNotNil(protection.reopened)
+ protection.retry()
+ protection.discard()
+ XCTAssertEqual(applied, 1)
+ }
+
+ func testDiscardIsExplicitAndCancellingTheChoiceKeepsEditing() async {
+ let protection = TeraEditingProtection()
+ var applied = 0
+ var saves = 0
+ let save = { saves += 1; return false }
+ let apply = { applied += 1; return true }
+ _ = await protection.replace(kind: .editing, save: save, apply: apply)
+ protection.cancel()
+ protection.discard()
+ XCTAssertEqual(applied, 0)
+ XCTAssertFalse(protection.failed)
+ _ = await protection.replace(kind: .editing, save: save, apply: apply)
+ XCTAssertTrue(protection.failed)
+ protection.discard()
+ await TeraScopeFixtures.eventually { !protection.isWorking }
+ XCTAssertEqual(applied, 1)
+ XCTAssertEqual(saves, 2)
+ XCTAssertNil(protection.reopened)
+ }
+
+ func testEditingChangedWhileSavingRetainsWorkerUntilLateCompletionWithoutReplacing() async {
+ let protection = TeraEditingProtection()
+ let pause = ResourceTestPause()
+ var applied = false
+ let task = Task {
+ await protection.replace(kind: .reopen, save: { await pause.wait(); return true }, apply: { applied = true; return true })
+ }
+ await pause.entered.wait()
+ protection.editingChanged()
+ XCTAssertNil(protection.schedule(kind: .editing, save: { true }, apply: { true }))
+ await pause.resume.open()
+ let result = await task.value
+ XCTAssertFalse(result)
+ XCTAssertFalse(applied)
+ XCTAssertNil(protection.reopened)
+ let next = await protection.replace(kind: .editing, save: { true }, apply: { true })
+ XCTAssertTrue(next)
+ }
+
+ func testCancelledWaiterCannotReplaceAndFailedLoadCannotDismissSavedWork() async {
+ let protection = TeraEditingProtection()
+ let pause = ResourceTestPause()
+ let task = Task {
+ await protection.replace(kind: .reopen, save: { await pause.wait(); return true }, apply: { XCTFail("Cancelled replacement"); return true })
+ }
+ await pause.entered.wait()
+ task.cancel()
+ await TeraScopeFixtures.eventually { !protection.isWorking }
+ await pause.resume.open()
+ let result = await task.value
+ XCTAssertFalse(result)
+ let refused = await protection.replace(kind: .reopen, save: { true }, apply: { false })
+ XCTAssertFalse(refused)
+ XCTAssertNil(protection.reopened)
+ XCTAssertFalse(protection.failed)
+ }
+}
diff --git a/TeraTests/TeraEditingReplacementFFITests.swift b/TeraTests/TeraEditingReplacementFFITests.swift
@@ -0,0 +1,65 @@
+import Foundation
+@testable import TeraApp
+import XCTest
+
+@MainActor
+final class TeraEditingReplacementFFITests: XCTestCase {
+ func testNewAndTypeReplacementPreserveAcknowledgedIncompleteEditingAcrossProductionRelaunch() 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(\.content, " unfinished event ")
+ await store.save()
+ let original = try XCTUnwrap(store.savedComposer)
+ store.updateForm(\.content, " final incomplete event ")
+ let exact = TeraComposerForm(editing: store.form)
+ store.selectType(.createFoodAvailability)
+ await TeraScopeFixtures.eventually { !store.protection.isWorking }
+ XCTAssertFalse(store.protection.failed)
+ XCTAssertEqual(store.form.commandType, .createFoodAvailability)
+ let saved = try await client.loadComposer(scope: original.scope, id: original.id)
+ XCTAssertEqual(saved.form, exact)
+ XCTAssertGreaterThan(saved.revision, original.revision)
+ store.updateForm(\.priceAmount, "1.")
+ store.updateForm(\.quantity, "unfinished")
+ await store.save()
+ let second = try XCTUnwrap(store.savedComposer)
+ XCTAssertNotEqual(second.id, original.id)
+ store.updateForm(\.content, "incomplete availability")
+ let availability = TeraComposerForm(editing: store.form)
+ store.newDraft()
+ await TeraScopeFixtures.eventually { !store.protection.isWorking }
+ XCTAssertEqual(store.form.content, "")
+ store.stop()
+ _ = try await client.stop()
+ _ = try await client.start(configuration: configuration)
+ let firstRecovered = try await client.loadComposer(scope: saved.scope, id: saved.id)
+ let secondRecovered = try await client.loadComposer(scope: second.scope, id: second.id)
+ XCTAssertEqual(firstRecovered, saved)
+ XCTAssertEqual(secondRecovered.form, availability)
+ let operations = try await client.legacyDraftPage()
+ XCTAssertTrue(operations.entries.isEmpty)
+ let signingRequests = await signer.requests
+ XCTAssertEqual(signingRequests, 0)
+ _ = try await client.stop()
+ }
+
+ private func configuration(_ fixture: MediaOwnershipFixture, signer: ComposerForbiddenSigner) -> TeraRuntimeLaunchConfiguration {
+ TeraRuntimeLaunchConfiguration(
+ applicationSupportDirectory: fixture.root.path,
+ publicKeyHex: "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ sourceGenerationHex: String(repeating: "04", count: 32), sourceGenerationCreatedAtUnixMilliseconds: 1_800_000_000_000,
+ protectedData: .available, networkProfile: .publicNetwork, writableRelays: [], blossom: nil,
+ app: TeraRuntimeAppMetadata(bundleIdentifier: "test.editing", version: "1", buildNumber: "1", buildSHA: nil),
+ signerGeneration: "editing-test", signer: signer, adoptBootstrapSettings: false
+ )
+ }
+}
diff --git a/TeraTests/TeraEditingReplacementTests.swift b/TeraTests/TeraEditingReplacementTests.swift
@@ -0,0 +1,221 @@
+import Foundation
+@testable import TeraApp
+import XCTest
+
+@MainActor
+final class TeraEditingReplacementTests: XCTestCase {
+ func testCleanTypeChangeIsImmediateAndDirtyTypeChangePreservesExactAcknowledgment() async throws {
+ let fixture = try await started()
+ let backend = fixture.backend
+ let client = fixture.client
+ let store = fixture.store
+ store.selectType(.createEvent)
+ XCTAssertEqual(store.form.commandType, .createEvent)
+ store.updateForm(\.eventStartDate, "2026-09-")
+ store.updateForm(\.content, " incomplete event ")
+ await store.save()
+ let original = try XCTUnwrap(store.savedComposer)
+ let pause = await backend.pause(.composer)
+ store.updateForm(\.content, " newer event ")
+ let editing = TeraComposerForm(editing: store.form)
+ store.selectType(.createAsk)
+ await pause.entered.wait()
+ XCTAssertEqual(TeraComposerForm(editing: store.form), editing)
+ store.selectType(.createFoodAvailability)
+ await pause.resume.open()
+ await TeraScopeFixtures.eventually { !store.protection.isWorking }
+ XCTAssertEqual(store.form.commandType, .createAsk)
+ XCTAssertEqual(store.form.content, "")
+ let saved = try await client.loadComposer(scope: original.scope, id: original.id)
+ XCTAssertEqual(saved.form, editing)
+ XCTAssertEqual(saved.revision, original.revision + 1)
+ XCTAssertGreaterThan(saved.editSequence, original.editSequence)
+ await store.save()
+ XCTAssertNotEqual(store.savedComposer?.id, original.id)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testNewerEditingWhileNewSaveCompletesCancelsReplacementAndKeepsNewestForm() async throws {
+ let fixture = try await started()
+ let backend = fixture.backend
+ let client = fixture.client
+ let store = fixture.store
+ let first = await backend.pause(.composer)
+ store.updateForm(\.content, "first")
+ store.newDraft(type: .createEvent)
+ await first.entered.wait()
+ let second = await backend.pause(.composer)
+ store.updateForm(\.content, "newer while saving")
+ await first.resume.open()
+ await second.entered.wait()
+ XCTAssertEqual(store.form.content, "newer while saving")
+ XCTAssertEqual(store.form.commandType, .createUpdate)
+ await second.resume.open()
+ await TeraScopeFixtures.eventually { !store.isWorking }
+ XCTAssertEqual(store.form.content, "newer while saving")
+ XCTAssertEqual(store.savedComposer?.form.content, "newer while saving")
+ XCTAssertFalse(store.protection.failed)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testSaveFailureKeepEditingAndDiscardPreserveLastDurableDraft() async throws {
+ let fixture = try await started()
+ let backend = fixture.backend
+ let client = fixture.client
+ let store = fixture.store
+ store.updateForm(\.content, "durable")
+ await store.save()
+ let original = try XCTUnwrap(store.savedComposer)
+ let failure = await backend.pause(.composer, fails: true)
+ store.updateForm(\.content, "unsaved")
+ store.newDraft()
+ await failure.entered.wait()
+ await failure.resume.open()
+ await TeraScopeFixtures.eventually { store.protection.failed }
+ XCTAssertEqual(store.form.content, "unsaved")
+ store.protection.cancel()
+ store.protection.discard()
+ XCTAssertEqual(store.form.content, "unsaved")
+ let again = await backend.pause(.composer, fails: true)
+ store.newDraft(type: .createEvent)
+ await again.entered.wait()
+ await again.resume.open()
+ await TeraScopeFixtures.eventually { store.protection.failed }
+ store.protection.discard()
+ await TeraScopeFixtures.eventually { !store.protection.isWorking }
+ XCTAssertEqual(store.form.commandType, .createEvent)
+ XCTAssertEqual(store.form.content, "")
+ let saved = try await client.loadComposer(scope: original.scope, id: original.id)
+ XCTAssertEqual(saved, original)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testSavedSelectionRetryPreservesBufferBeforeLoadingAndSignalsSheetDismissal() async throws {
+ let fixture = try await started()
+ let backend = fixture.backend
+ let client = fixture.client
+ let store = fixture.store
+ store.updateForm(\.content, "buffer")
+ await store.save()
+ let original = try XCTUnwrap(store.savedComposer)
+ let failure = await backend.pause(.composer, fails: true)
+ store.updateForm(\.content, "latest buffer")
+ let selection = Task { await store.reopenSaved(.legacy("draft")) }
+ await failure.entered.wait()
+ await failure.resume.open()
+ let applied = await selection.value
+ XCTAssertFalse(applied)
+ XCTAssertEqual(store.form.content, "latest buffer")
+ XCTAssertNil(store.protection.reopened)
+ let counts = await backend.counts
+ XCTAssertNil(counts[.draftStatus])
+ store.protection.retry()
+ await TeraScopeFixtures.eventually { !store.protection.isWorking }
+ XCTAssertEqual(store.form.content, "old")
+ XCTAssertNotNil(store.protection.reopened)
+ let kept = try await client.loadComposer(scope: original.scope, id: original.id)
+ XCTAssertEqual(kept.form.content, "latest buffer")
+ await store.save()
+ XCTAssertNotEqual(store.savedComposer?.id, original.id)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testScopeChangeCancelsPendingReplacementAndLateSaveCannotReplaceNewAccount() async throws {
+ let fixture = try await started()
+ let backend = fixture.backend
+ let client = fixture.client
+ let store = fixture.store
+ let pause = await backend.pause(.composer)
+ store.updateForm(\.content, "old account")
+ store.newDraft(type: .createEvent)
+ await pause.entered.wait()
+ let snapshot = TeraScopeFixtures.snapshot(account: "b")
+ await backend.configure(snapshot)
+ store.configure(snapshot: snapshot)
+ await store.start()
+ store.updateForm(\.content, "current account")
+ await pause.resume.open()
+ await store.save()
+ XCTAssertEqual(store.form.content, "current account")
+ XCTAssertEqual(store.form.commandType, .createUpdate)
+ XCTAssertEqual(store.savedComposer?.scope.authorPublicKey, String(repeating: "b", count: 64))
+ XCTAssertFalse(store.protection.failed)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testLegacyRetryPreservesPriorComposerIdentityAndFailedSelectionHasNoEffects() async throws {
+ let fixture = try await started()
+ let backend = fixture.backend
+ let client = fixture.client
+ let store = fixture.store
+ store.updateForm(\.content, "first buffer")
+ await store.save()
+ let original = try XCTUnwrap(store.savedComposer)
+ store.updateForm(\.content, "last buffer")
+ await store.retry(id: "draft")
+ XCTAssertEqual(store.form.content, "old")
+ await store.save()
+ XCTAssertNotEqual(store.savedComposer?.id, original.id)
+ let kept = try await client.loadComposer(scope: original.scope, id: original.id)
+ XCTAssertEqual(kept.form.content, "last buffer")
+ let failure = await backend.pause(.composer, fails: true)
+ store.updateForm(\.content, "keep for cancellation")
+ let cancellation = Task { await store.cancel(id: "draft") }
+ await failure.entered.wait()
+ await failure.resume.open()
+ await cancellation.value
+ XCTAssertEqual(store.form.content, "keep for cancellation")
+ let counts = await backend.counts
+ XCTAssertEqual(counts[.draftStatus], 1)
+ XCTAssertTrue(store.protection.failed)
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ private struct Fixture {
+ let backend: TeraScopeBackend
+ let client: TeraRuntimeClient
+ let store: TeraAddStore
+ }
+
+ func testCancelledSavedSelectionCannotApplyItsLateLoadedForm() async throws {
+ let fixture = try await started()
+ let store = fixture.store
+ store.updateForm(\.content, "keep current")
+ await store.save()
+ let original = try XCTUnwrap(store.savedComposer)
+ var form = TeraComposerForm(commandType: .createAsk)
+ form.content = "other saved editing"
+ let other = try await fixture.client.saveComposer(request: TeraComposerSaveRequest(
+ scope: original.scope, id: String(repeating: "7", count: 32), expectedRevision: nil, editSequence: 1, form: form
+ ))
+ let pause = await fixture.backend.pause(.composerLoad)
+ let selection = Task { await store.reopenSaved(.composer(other.draft.id)) }
+ await pause.entered.wait()
+ selection.cancel()
+ await TeraScopeFixtures.eventually { !store.protection.isWorking }
+ await pause.resume.open()
+ let applied = await selection.value
+ XCTAssertFalse(applied)
+ XCTAssertEqual(store.form.content, "keep current")
+ XCTAssertEqual(store.savedComposer, original)
+ XCTAssertNil(store.protection.reopened)
+ XCTAssertFalse(store.protection.failed)
+ store.stop()
+ _ = try await fixture.client.stop()
+ }
+
+ private func started() async throws -> Fixture {
+ let backend = try TeraScopeBackend()
+ let client = try await TeraScopeFixtures.client(backend)
+ let store = TeraAddStore(runtimeClient: client)
+ store.configure(snapshot: TeraScopeFixtures.snapshot())
+ await store.start()
+ return Fixture(backend: backend, client: client, store: store)
+ }
+}
diff --git a/TeraTests/TeraScopedAddTests.swift b/TeraTests/TeraScopedAddTests.swift
@@ -137,10 +137,14 @@ final class TeraScopedAddTests: XCTestCase {
let client = try await TeraScopeFixtures.client(backend)
let store = TeraAddStore(runtimeClient: client)
store.configure(snapshot: TeraScopeFixtures.snapshot())
- let pause = await backend.pause(.drafts)
+ let schemas = await backend.pause(.schemas)
let startup = Task { await store.start() }
- await pause.entered.wait()
+ await schemas.entered.wait()
await TeraScopeFixtures.eventually { store.observationState == .active }
+ await TeraScopeFixtures.eventually { store.drafts.first?.revision == 1 }
+ let pause = await backend.pause(.drafts)
+ await schemas.resume.open()
+ await pause.entered.wait()
await backend.setDrafts([TeraScopeFixtures.draft("new revision", revision: 2)])
await backend.emit(.drafts)
await TeraScopeFixtures.eventually { store.drafts.first?.revision == 2 }
diff --git a/TeraUITests/TeraEditingProtectionUITests.swift b/TeraUITests/TeraEditingProtectionUITests.swift
@@ -0,0 +1,42 @@
+import XCTest
+
+final class TeraEditingProtectionUITests: XCTestCase {
+ @MainActor
+ func testCancelDiscardConfirmationKeepsEditingAndExplicitDiscardAppliesOnce() {
+ let app = XCUIApplication()
+ app.launchEnvironment["TERA_IOS_UI_TEST_SHELL"] = "1"
+ app.launchEnvironment["TERA_IOS_UI_TEST_EDITING_PROTECTION"] = "1"
+ app.launch()
+ let tabBar = app.tabBars.firstMatch
+ XCTAssertTrue(tabBar.waitForExistence(timeout: 5))
+ tabBar.buttons["Add"].tap()
+ let editing = app.textFields["tera.test.editing.value"]
+ XCTAssertTrue(editing.waitForExistence(timeout: 5))
+ app.buttons["tera.test.editing.replace"].tap()
+ let discard = app.buttons["tera.add.protection.discard"]
+ XCTAssertTrue(discard.waitForExistence(timeout: 5))
+ discard.tap()
+ let confirmation = app.sheets.firstMatch
+ XCTAssertTrue(confirmation.waitForExistence(timeout: 5))
+ let keep = confirmation.buttons["Keep editing"]
+ if keep.exists {
+ keep.tap()
+ } else {
+ app.otherElements["PopoverDismissRegion"].coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.75)).tap()
+ }
+ XCTAssertTrue(confirmation.waitForNonExistence(timeout: 5))
+ XCTAssertEqual(editing.value as? String, "Keep this unfinished text")
+ XCTAssertEqual(app.staticTexts["tera.test.editing.replacements"].label, "0")
+ XCTAssertTrue(discard.waitForNonExistence(timeout: 5))
+ app.buttons["tera.test.editing.replace"].tap()
+ XCTAssertTrue(discard.waitForExistence(timeout: 5))
+ discard.tap()
+ XCTAssertTrue(confirmation.waitForExistence(timeout: 5))
+ confirmation.buttons["Discard and continue"].tap()
+ let count = app.staticTexts["tera.test.editing.replacements"]
+ let applied = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label == '1'"), object: count)
+ XCTAssertEqual(XCTWaiter.wait(for: [applied], timeout: 5), .completed)
+ XCTAssertEqual(editing.value as? String, "Editing")
+ XCTAssertTrue(discard.waitForNonExistence(timeout: 5))
+ }
+}
diff --git a/project.yml b/project.yml
@@ -49,6 +49,10 @@ targets:
- path: TeraTests/TeraComposerPersistenceFFITests.swift
- path: TeraTests/TeraComposerAutosaveTests.swift
- path: TeraTests/TeraDraftRecoveryTests.swift
+ - path: TeraTests/TeraEditingProtectionTests.swift
+ - path: TeraTests/TeraEditingReplacementTests.swift
+ - path: TeraTests/TeraEditingReplacementFFITests.swift
+ - path: TeraTests/TeraEditingOperationProtectionTests.swift
- path: TeraTests/TeraComposerStartupTests.swift
- path: TeraTests/TeraRuntimeInvalidationTests.swift
- path: TeraTests/TeraRuntimeBackpressureTests.swift
@@ -127,6 +131,7 @@ targets:
deploymentTarget: "18.0"
sources:
- path: TeraUITests/TeraRootShellUITests.swift
+ - path: TeraUITests/TeraEditingProtectionUITests.swift
- path: TeraUITests/TeraRemoteQualificationUITests.swift
- path: test-fixtures/local-social-personas.v1.json
buildPhase: resources
diff --git a/release/provenance.json b/release/provenance.json
@@ -6,7 +6,7 @@
"info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef",
"privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12",
"sbom_sha256": "61e3d99a2c7f051a91d6a38b3b1f7974fb4c56da3a1c9758f5ad06fc66f1d856",
- "xcode_project_sha256": "1e81571a6390e5b1a526c223a23301c7a0c5f02179003f56d2de0950656d0f64"
+ "xcode_project_sha256": "c52fe9efc17252f36e4305e452d4796c13b3877f8a24765fc0d465e4bca89dcc"
},
"disposition": "unsigned",
"platforms": [
diff --git a/test-fixtures/maintainability-baseline.v1.json b/test-fixtures/maintainability-baseline.v1.json
@@ -11,7 +11,7 @@
{"path": "Tera/Runtime/TeraGeneratedRuntimeBackend.swift", "maximum_lines": 1474},
{"path": "Tera/Runtime/TeraRuntimeClient.swift", "maximum_lines": 820},
{"path": "Tera/Runtime/TeraRuntimeModels.swift", "maximum_lines": 1154},
- {"path": "Tera/State/TeraAddStore.swift", "maximum_lines": 787},
+ {"path": "Tera/State/TeraAddStore.swift", "maximum_lines": 785},
{"path": "Tera/State/TeraConfigurationStore.swift", "maximum_lines": 759},
{"path": "Tera/Views/TeraSupportingViews.swift", "maximum_lines": 629},
{"path": "TeraTests/TeraAddStoreTests.swift", "maximum_lines": 1366},
diff --git a/test-fixtures/swiftlint-maintainability-baseline.v1.json b/test-fixtures/swiftlint-maintainability-baseline.v1.json
@@ -1 +1 @@
-[{"violation":{"location":{"character":3,"file":"Tera\/App\/TeraAppModel.swift","line":21},"reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","severity":"warning"},"text":" init("},{"violation":{"location":{"character":1,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","line":51},"reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 330 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"warning"},"text":"actor TeraAddMediaCoordinator: TeraAddMediaHandling {"},{"violation":{"location":{"character":1,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","line":458},"reason":"File should contain 400 lines or less: currently contains 458","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"warning"},"text":"#endif"},{"violation":{"location":{"character":15,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","line":111},"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 467 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"error"},"text":"private final class TeraGeneratedRuntimeBackend: TeraRuntimeBackend, @unchecked Sendable {"},{"violation":{"location":{"character":1,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","line":1474},"reason":"File should contain 1000 lines or less: currently contains 1474","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"error"},"text":"}"},{"violation":{"location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":3},"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 739 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"error"},"text":"actor TeraRuntimeClient {"},{"violation":{"location":{"character":3,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":379},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 70 lines","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","severity":"warning"},"text":" func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<TeraRuntimeChange> {"},{"violation":{"location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":820},"reason":"File should contain 400 lines or less: currently contains 820","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"warning"},"text":"}"},{"violation":{"location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeModels.swift","line":1154},"reason":"File should contain 1000 lines or less: currently contains 1154","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"error"},"text":"}"},{"violation":{"location":{"character":7,"file":"Tera\/State\/TeraAddStore.swift","line":4},"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 642 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"error"},"text":"final class TeraAddStore: ObservableObject {"},{"violation":{"location":{"character":3,"file":"Tera\/State\/TeraAddStore.swift","line":394},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 51 lines","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","severity":"warning"},"text":" func retract(_ card: TeraTodayCard) async {"},{"violation":{"location":{"character":1,"file":"Tera\/State\/TeraAddStore.swift","line":787},"reason":"File should contain 400 lines or less: currently contains 787","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"warning"},"text":"}"},{"violation":{"location":{"character":1,"file":"Tera\/State\/TeraConfigurationStore.swift","line":78},"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"error"},"text":"actor TeraConfigurationStore {"},{"violation":{"location":{"character":5,"file":"Tera\/State\/TeraConfigurationStore.swift","line":148},"reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","severity":"error"},"text":" func load() throws -> TeraAppConfiguration {"},{"violation":{"location":{"character":1,"file":"Tera\/State\/TeraConfigurationStore.swift","line":759},"reason":"File should contain 400 lines or less: currently contains 759","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"warning"},"text":"}"},{"violation":{"location":{"character":1,"file":"Tera\/State\/TeraSessionStore.swift","line":39},"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 390 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"error"},"text":"actor TeraSessionStore {"},{"violation":{"location":{"character":11,"file":"Tera\/State\/TeraSessionStore.swift","line":125},"reason":"Function should have complexity 10 or less; currently complexity is 13","ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","severity":"warning"},"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {"},{"violation":{"location":{"character":1,"file":"Tera\/State\/TeraSessionStore.swift","line":452},"reason":"File should contain 400 lines or less: currently contains 452","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"warning"},"text":"}"},{"violation":{"location":{"character":1,"file":"Tera\/Views\/TeraAddView.swift","line":4},"reason":"Struct body should span 350 lines or less excluding comments and whitespace: currently spans 375 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"error"},"text":"struct TeraAddView: View {"},{"violation":{"location":{"character":1,"file":"Tera\/Views\/TeraAddView.swift","line":407},"reason":"File should contain 400 lines or less: currently contains 407","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"warning"},"text":"}"},{"violation":{"location":{"character":1,"file":"Tera\/Views\/TeraSupportingViews.swift","line":365},"reason":"Struct body should span 250 lines or less excluding comments and whitespace: currently spans 259 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"warning"},"text":"struct TeraSettingsView: View {"},{"violation":{"location":{"character":1,"file":"Tera\/Views\/TeraSupportingViews.swift","line":629},"reason":"File should contain 400 lines or less: currently contains 629","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"warning"},"text":"}"},{"violation":{"location":{"character":7,"file":"TeraTests\/TeraAddStoreTests.swift","line":6},"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 664 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"error"},"text":"final class TeraAddStoreTests: XCTestCase {"},{"violation":{"location":{"character":9,"file":"TeraTests\/TeraAddStoreTests.swift","line":759},"reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 489 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"error"},"text":"private actor AddBackend: TeraRuntimeBackend {"},{"violation":{"location":{"character":11,"file":"TeraTests\/TeraAddStoreTests.swift","line":1196},"reason":"Function should have 5 parameters or less: it currently has 8","ruleIdentifier":"function_parameter_count","ruleDescription":"Number of function parameters should be low.","ruleName":"Function Parameter Count","severity":"warning"},"text":" private func makeStatus("},{"violation":{"location":{"character":1,"file":"TeraTests\/TeraAddStoreTests.swift","line":1366},"reason":"File should contain 1000 lines or less: currently contains 1366","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"error"},"text":"}"},{"violation":{"location":{"character":7,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":7},"reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"warning"},"text":"final class TeraRemoteQualificationTests: XCTestCase {"},{"violation":{"location":{"character":3,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":45},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","severity":"warning"},"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {"},{"violation":{"location":{"character":3,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":212},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","severity":"warning"},"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {"},{"violation":{"location":{"character":7,"file":"TeraTests\/TeraRuntimeClientTests.swift","line":4},"reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 298 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"warning"},"text":"final class TeraRuntimeClientTests: XCTestCase {"},{"violation":{"location":{"character":7,"file":"TeraTests\/TeraStateMigrationTests.swift","line":6},"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"error"},"text":"final class TeraStateMigrationTests: XCTestCase {"},{"violation":{"location":{"character":5,"file":"TeraTests\/TeraStateMigrationTests.swift","line":7},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","severity":"warning"},"text":" func testRelayValidationMatchesRuntimeProfiles() throws {"},{"violation":{"location":{"character":1,"file":"TeraTests\/TeraStateMigrationTests.swift","line":622},"reason":"File should contain 400 lines or less: currently contains 622","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"warning"},"text":"}"},{"violation":{"location":{"character":9,"file":"TeraTests\/TeraSupportingStoreTests.swift","line":207},"reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 252 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"warning"},"text":"private actor SupportingBackend: TeraRuntimeBackend {"},{"violation":{"location":{"character":1,"file":"TeraTests\/TeraSupportingStoreTests.swift","line":486},"reason":"File should contain 400 lines or less: currently contains 486","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"warning"},"text":"}"},{"violation":{"location":{"character":7,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":4},"reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1493 lines","ruleIdentifier":"type_body_length","ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","severity":"error"},"text":"final class TeraRemoteQualificationUITests: XCTestCase {"},{"violation":{"location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":62},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 73 lines","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","severity":"warning"},"text":" func testLocalSocialFiveFlowScenario() throws {"},{"violation":{"location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":174},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","severity":"warning"},"text":" func testLocalSocialDeterministicPersonas() throws {"},{"violation":{"location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":247},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","severity":"warning"},"text":" func testRemoteBlossomUploadAndRecovery() throws {"},{"violation":{"location":{"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":573},"reason":"Function should have complexity 10 or less; currently complexity is 18","ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","severity":"warning"},"text":" private func expectedAddFieldLabel(_ identifier: String, type: String) -> String {"},{"violation":{"location":{"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":767},"reason":"Function should have complexity 10 or less; currently complexity is 11","ruleIdentifier":"cyclomatic_complexity","ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","severity":"warning"},"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {"},{"violation":{"location":{"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":767},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","severity":"warning"},"text":" private func preparePhotoUpdate(_ app: XCUIApplication, marker: String) -> XCUIElement? {"},{"violation":{"location":{"character":10,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":1736},"reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines","ruleIdentifier":"function_body_length","ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","severity":"warning"},"text":" static func environment("},{"violation":{"location":{"character":1,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":2212},"reason":"File should contain 1000 lines or less: currently contains 2212","ruleIdentifier":"file_length","ruleDescription":"Files should not span too many lines.","ruleName":"File Length","severity":"error"},"text":"}"}]
-\ No newline at end of file
+[{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","severity":"warning","reason":"Initializer body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines","location":{"character":3,"file":"Tera\/App\/TeraAppModel.swift","line":21}},"text":" init("},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"warning","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 330 lines","location":{"character":1,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","line":51}},"text":"actor TeraAddMediaCoordinator: TeraAddMediaHandling {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"warning","reason":"File should contain 400 lines or less: currently contains 458","location":{"character":1,"file":"Tera\/Runtime\/TeraAddMediaCoordinator.swift","line":458}},"text":"#endif"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 467 lines","location":{"character":15,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","line":111}},"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","reason":"File should contain 1000 lines or less: currently contains 1474","location":{"character":1,"file":"Tera\/Runtime\/TeraGeneratedRuntimeBackend.swift","line":1474}},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"error","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 739 lines","location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":3}},"text":"actor TeraRuntimeClient {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","severity":"warning","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 70 lines","location":{"character":3,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":379}},"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":"warning","reason":"File should contain 400 lines or less: currently contains 820","location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeClient.swift","line":820}},"text":"}"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"error","reason":"File should contain 1000 lines or less: currently contains 1154","location":{"character":1,"file":"Tera\/Runtime\/TeraRuntimeModels.swift","line":1154}},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 600 lines","location":{"character":7,"file":"Tera\/State\/TeraAddStore.swift","line":4}},"text":"final class TeraAddStore: ObservableObject {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"warning","reason":"File should contain 400 lines or less: currently contains 785","location":{"character":1,"file":"Tera\/State\/TeraAddStore.swift","line":785}},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"error","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 468 lines","location":{"character":1,"file":"Tera\/State\/TeraConfigurationStore.swift","line":78}},"text":"actor TeraConfigurationStore {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","severity":"error","reason":"Function body should span 100 lines or less excluding comments and whitespace: currently spans 109 lines","location":{"character":5,"file":"Tera\/State\/TeraConfigurationStore.swift","line":148}},"text":" func load() throws -> TeraAppConfiguration {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"warning","reason":"File should contain 400 lines or less: currently contains 759","location":{"character":1,"file":"Tera\/State\/TeraConfigurationStore.swift","line":759}},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"error","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 390 lines","location":{"character":1,"file":"Tera\/State\/TeraSessionStore.swift","line":39}},"text":"actor TeraSessionStore {"},{"violation":{"ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","ruleIdentifier":"cyclomatic_complexity","severity":"warning","reason":"Function should have complexity 10 or less; currently complexity is 13","location":{"character":11,"file":"Tera\/State\/TeraSessionStore.swift","line":125}},"text":" private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"warning","reason":"File should contain 400 lines or less: currently contains 452","location":{"character":1,"file":"Tera\/State\/TeraSessionStore.swift","line":452}},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"error","reason":"Struct body should span 350 lines or less excluding comments and whitespace: currently spans 375 lines","location":{"character":1,"file":"Tera\/Views\/TeraAddView.swift","line":4}},"text":"struct TeraAddView: View {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"warning","reason":"File should contain 400 lines or less: currently contains 407","location":{"character":1,"file":"Tera\/Views\/TeraAddView.swift","line":407}},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"warning","reason":"Struct body should span 250 lines or less excluding comments and whitespace: currently spans 259 lines","location":{"character":1,"file":"Tera\/Views\/TeraSupportingViews.swift","line":365}},"text":"struct TeraSettingsView: View {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"warning","reason":"File should contain 400 lines or less: currently contains 629","location":{"character":1,"file":"Tera\/Views\/TeraSupportingViews.swift","line":629}},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 664 lines","location":{"character":7,"file":"TeraTests\/TeraAddStoreTests.swift","line":6}},"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","reason":"Actor body should span 350 lines or less excluding comments and whitespace: currently spans 489 lines","location":{"character":1,"file":"TeraTests\/TeraAddStoreTests.swift","line":759}},"text":"actor AddBackend: TeraRuntimeBackend {"},{"violation":{"ruleDescription":"Number of function parameters should be low.","ruleName":"Function Parameter Count","ruleIdentifier":"function_parameter_count","severity":"warning","reason":"Function should have 5 parameters or less: it currently has 8","location":{"character":11,"file":"TeraTests\/TeraAddStoreTests.swift","line":1196}},"text":" private func makeStatus("},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"error","reason":"File should contain 1000 lines or less: currently contains 1366","location":{"character":1,"file":"TeraTests\/TeraAddStoreTests.swift","line":1366}},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"warning","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 307 lines","location":{"character":7,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":7}},"text":"final class TeraRemoteQualificationTests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","severity":"warning","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 94 lines","location":{"character":3,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":45}},"text":" func testQualificationRejectsAmbiguousOrUnsafeHarnessValues() {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","severity":"warning","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 53 lines","location":{"character":3,"file":"TeraTests\/TeraRemoteQualificationTests.swift","line":212}},"text":" func testAuthorizationEvidenceIsRedactedEphemeralAndRelaunchCleaned() throws {"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"warning","reason":"Class body should span 250 lines or less excluding comments and whitespace: currently spans 298 lines","location":{"character":7,"file":"TeraTests\/TeraRuntimeClientTests.swift","line":4}},"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","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 580 lines","location":{"character":7,"file":"TeraTests\/TeraStateMigrationTests.swift","line":6}},"text":"final class TeraStateMigrationTests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","severity":"warning","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines","location":{"character":5,"file":"TeraTests\/TeraStateMigrationTests.swift","line":7}},"text":" func testRelayValidationMatchesRuntimeProfiles() throws {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"warning","reason":"File should contain 400 lines or less: currently contains 622","location":{"character":1,"file":"TeraTests\/TeraStateMigrationTests.swift","line":622}},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"warning","reason":"Actor body should span 250 lines or less excluding comments and whitespace: currently spans 252 lines","location":{"character":9,"file":"TeraTests\/TeraSupportingStoreTests.swift","line":207}},"text":"private actor SupportingBackend: TeraRuntimeBackend {"},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"warning","reason":"File should contain 400 lines or less: currently contains 486","location":{"character":1,"file":"TeraTests\/TeraSupportingStoreTests.swift","line":486}},"text":"}"},{"violation":{"ruleDescription":"Type bodies should not span too many lines","ruleName":"Type Body Length","ruleIdentifier":"type_body_length","severity":"error","reason":"Class body should span 350 lines or less excluding comments and whitespace: currently spans 1493 lines","location":{"character":7,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":4}},"text":"final class TeraRemoteQualificationUITests: XCTestCase {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","severity":"warning","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 73 lines","location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":62}},"text":" func testLocalSocialFiveFlowScenario() throws {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","severity":"warning","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":174}},"text":" func testLocalSocialDeterministicPersonas() throws {"},{"violation":{"ruleDescription":"Function bodies should not span too many lines","ruleName":"Function Body Length","ruleIdentifier":"function_body_length","severity":"warning","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 54 lines","location":{"character":3,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":247}},"text":" func testRemoteBlossomUploadAndRecovery() throws {"},{"violation":{"ruleDescription":"Complexity of function bodies should be limited.","ruleName":"Cyclomatic Complexity","ruleIdentifier":"cyclomatic_complexity","severity":"warning","reason":"Function should have complexity 10 or less; currently complexity is 18","location":{"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":573}},"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":"warning","reason":"Function should have complexity 10 or less; currently complexity is 11","location":{"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":767}},"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":"warning","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 66 lines","location":{"character":11,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":767}},"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":"warning","reason":"Function body should span 50 lines or less excluding comments and whitespace: currently spans 67 lines","location":{"character":10,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":1736}},"text":" static func environment("},{"violation":{"ruleDescription":"Files should not span too many lines.","ruleName":"File Length","ruleIdentifier":"file_length","severity":"error","reason":"File should contain 1000 lines or less: currently contains 2212","location":{"character":1,"file":"TeraUITests\/TeraRemoteQualificationUITests.swift","line":2212}},"text":"}"}]
+\ No newline at end of file