field_ios

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

commit 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:
MPackage.swift | 4++++
MTera.xcodeproj/project.pbxproj | 20++++++++++++++++++++
MTera/App/TeraRootShell.swift | 8++++++++
ATera/State/TeraAddCardIntents.swift | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
MTera/State/TeraAddStore.swift | 264+++++++++++++++++++++++++++++++++++++++----------------------------------------
ATera/State/TeraEditingProtection.swift | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTera/Views/TeraAddSaveStatus.swift | 2++
MTera/Views/TeraAddView.swift | 2+-
MTera/Views/TeraDraftsSheet.swift | 8++++++++
ATera/Views/TeraEditingProtectionActions.swift | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATera/Views/TeraEditingProtectionUITestSurface.swift | 28++++++++++++++++++++++++++++
MTeraTests/TeraAddStoreTests.swift | 10+++++-----
MTeraTests/TeraComposerPersistenceFFITests.swift | 2+-
ATeraTests/TeraEditingOperationProtectionTests.swift | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATeraTests/TeraEditingProtectionTests.swift | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATeraTests/TeraEditingReplacementFFITests.swift | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATeraTests/TeraEditingReplacementTests.swift | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTeraTests/TeraScopedAddTests.swift | 8++++++--
ATeraUITests/TeraEditingProtectionUITests.swift | 42++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 5+++++
Mrelease/provenance.json | 2+-
Mtest-fixtures/maintainability-baseline.v1.json | 2+-
Mtest-fixtures/swiftlint-maintainability-baseline.v1.json | 4++--
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