commit 872a7906956353d86e400848d6ea0a6631dc829f
parent 8b864bac875632600193ec8a9bbbc0a3ba0d677f
Author: triesap <tyson@radroots.org>
Date: Thu, 1 Oct 2026 17:21:43 +0000
ios: retain navigation during durable publication
- Preserve publication while a separate composer is created
- Keep status and explicit stop bound to the original request
- Fence uncertain capture and retain one bounded worker
- Verify native navigation recovery and exact consumer inputs
Diffstat:
9 files changed, 168 insertions(+), 11 deletions(-)
diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj
@@ -71,6 +71,7 @@
7B67600F1C1DA7DD780FA515 /* TeraNativeRepairPreviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0B5B9169842E64FF792D88 /* TeraNativeRepairPreviewTests.swift */; };
7B8C7C130DC15AF0266F2544 /* TeraEditingReplacementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F97ECE43BB7028B5A497720 /* TeraEditingReplacementTests.swift */; };
7C41A38BE082F1CA28DD4C4F /* TeraUploadRenewalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91D9E98DC275CB5F2FB9BA16 /* TeraUploadRenewalTests.swift */; };
+ 7D220F7F83A80A2D312AA5B0 /* TeraPublicationNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A195B277BEDC31DE3A0D7601 /* TeraPublicationNavigationTests.swift */; };
7DFBB14B53D57555AB0E0BF0 /* TeraScopeFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E9C876EA26B30C8AFE116F /* TeraScopeFixtures.swift */; };
80B4E24CE4411B5985839462 /* TeraSubmissionFormTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62DD95FB04526E6E69AB3D8 /* TeraSubmissionFormTests.swift */; };
80F3B531DAA8C2F6CE17D357 /* TeraDurableMediaRootsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22BEF874B8D521920E1D16B7 /* TeraDurableMediaRootsTests.swift */; };
@@ -253,6 +254,7 @@
9F22CFABCF27A28408F68D25 /* TeraAddStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAddStoreTests.swift; sourceTree = "<group>"; };
9F54FC4930051FC4611B37D3 /* tera */ = {isa = PBXFileReference; lastKnownFileType = folder; name = tera; path = .; sourceTree = SOURCE_ROOT; };
9F8B650ED2D867EF94248F6F /* TeraOperationRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraOperationRecoveryTests.swift; sourceTree = "<group>"; };
+ A195B277BEDC31DE3A0D7601 /* TeraPublicationNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraPublicationNavigationTests.swift; sourceTree = "<group>"; };
A1CC48A95774B931BE8D1E4A /* TeraAddSignerFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAddSignerFixture.swift; sourceTree = "<group>"; };
A48EE7852ECF14C15F7DD902 /* TeraRevisionTestBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRevisionTestBackend.swift; sourceTree = "<group>"; };
A5928D5CAC9F8EF904547DF8 /* TeraRecoveryUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRecoveryUITests.swift; sourceTree = "<group>"; };
@@ -425,6 +427,7 @@
9F8B650ED2D867EF94248F6F /* TeraOperationRecoveryTests.swift */,
7AF60EF9DCFDC4ECE77AED67 /* TeraProductStartupTests.swift */,
3B954B7006CC5904C98138C6 /* TeraPublicationEvidenceTests.swift */,
+ A195B277BEDC31DE3A0D7601 /* TeraPublicationNavigationTests.swift */,
56F7B42264CD896F76189F33 /* TeraPublicationRetryTests.swift */,
635EB4A552C563E85557A485 /* TeraPublicationStageTests.swift */,
293D968D4E2ACA3BA414BF8D /* TeraPublicationTargetTests.swift */,
@@ -753,6 +756,7 @@
026981088C27CB79A49D9833 /* TeraOperationRecoveryTests.swift in Sources */,
C46F8EB5B196FFDD37E0279A /* TeraProductStartupTests.swift in Sources */,
093A6AC6C9B724E465507004 /* TeraPublicationEvidenceTests.swift in Sources */,
+ 7D220F7F83A80A2D312AA5B0 /* TeraPublicationNavigationTests.swift in Sources */,
AA8B25877C9A3BD331B1B1AE /* TeraPublicationRetryTests.swift in Sources */,
A92BADDE348A4D5C56EDE2A1 /* TeraPublicationStageTests.swift in Sources */,
4D24610694C7F80F7B5DA904 /* TeraPublicationTargetTests.swift in Sources */,
diff --git a/Tera/State/TeraAddAffordances.swift b/Tera/State/TeraAddAffordances.swift
@@ -1,8 +1,12 @@
import Foundation
extension TeraAddStore {
+ var canCreateNewComposer: Bool {
+ !isWorking && submissions.canReplaceEditing && !protection.isWorking && !protection.failed
+ }
+
func selectType(_ type: TeraAddCommandType) {
- guard !isWorking, !submissions.isWorking, isFormEditable, form.commandType != type else { return }
+ guard canCreateNewComposer, isFormEditable, form.commandType != type else { return }
newDraft(type: type)
}
@@ -24,7 +28,7 @@ extension TeraAddStore {
}
var canSubmit: Bool {
- if activeDraft == nil, submissions.hasAction {
+ if activeDraft == nil, submissions.usesCurrentAction {
return isProductReady && !isWorking && submissions.canContinue
}
return isProductReady && !isWorking && !submissions.isWorking
diff --git a/Tera/State/TeraAddStore.swift b/Tera/State/TeraAddStore.swift
@@ -91,7 +91,7 @@ final class TeraAddStore: ObservableObject {
}
func newDraft(type: TeraAddCommandType? = nil) {
- guard !isWorking, !submissions.isWorking, !protection.isWorking, !protection.failed else { return }
+ guard canCreateNewComposer else { return }
if needsEditingPreservation {
protection.schedule(kind: .editing, save: preservation(), apply: replacement { store, _ in store.replaceWithNew(type: type) })
} else {
@@ -100,7 +100,7 @@ final class TeraAddStore: ObservableObject {
}
private func replaceWithNew(type: TeraAddCommandType?) {
- guard !submissions.isWorking else { return }
+ guard submissions.canReplaceEditing else { return }
submissions.newAction()
composer.reset(scope: composer.scope)
generation = generation.invalidated()
@@ -254,7 +254,7 @@ final class TeraAddStore: ObservableObject {
func submit(locale: Locale = .current) async {
// Only a new editable request may translate entry text. Existing capture,
// retry, revision operation and signed bytes retain their original inputs.
- if canSubmit, !protection.isWorking, isFormEditable, !submissions.hasAction, activeDraft?.isRevision != true {
+ if canSubmit, !protection.isWorking, isFormEditable, !submissions.usesCurrentAction, activeDraft?.isRevision != true {
form = TeraFoodDecimalEntry.form(form, locale: locale)
}
if activeDraft == nil, revisionTarget == nil {
diff --git a/Tera/State/TeraPublicationActions.swift b/Tera/State/TeraPublicationActions.swift
@@ -29,7 +29,7 @@ extension TeraSubmissionStore {
extension TeraAddStore {
var submitAccessibilityValue: String {
- if activeDraft == nil, submissions.hasAction {
+ if activeDraft == nil, submissions.usesCurrentAction {
let message = submissions.actionExplanation
if let code = submissions.failureCode {
return "\(message) Error code \(code)"
@@ -52,7 +52,7 @@ extension TeraAddStore {
}
var submitLabel: String {
- if activeDraft == nil, submissions.hasAction {
+ if activeDraft == nil, submissions.usesCurrentAction {
return submissions.actionLabel
}
if activeDraft?.kind == .retraction {
diff --git a/Tera/State/TeraSubmissionStore.swift b/Tera/State/TeraSubmissionStore.swift
@@ -22,6 +22,7 @@ final class TeraSubmissionStore: ObservableObject {
private var worker: Task<Void, Never>?
private var paused = false
private let stopControl = TeraSubmissionStopControl()
+ private(set) var isNewComposer = false
init(client: TeraRuntimeClient, composer: TeraComposerAutosave, media: (any TeraAddMediaHandling)?) {
self.client = client
@@ -36,6 +37,14 @@ final class TeraSubmissionStore: ObservableObject {
request != nil || capture != nil
}
+ var usesCurrentAction: Bool {
+ hasAction && !isNewComposer
+ }
+
+ var canReplaceEditing: Bool {
+ worker == nil || (capture == nil && status != nil)
+ }
+
func configure(scope: TeraComposerScope, context: TeraLocalNetwork) {
self.context = context
guard self.scope != scope else { return }
@@ -47,6 +56,7 @@ final class TeraSubmissionStore: ObservableObject {
stopControl.reset()
message = nil
failureCode = nil
+ isNewComposer = false
inventory.configure(scope: scope)
changed()
}
@@ -107,6 +117,9 @@ final class TeraSubmissionStore: ObservableObject {
if let worker {
await worker.value; return
}
+ if isNewComposer {
+ newAction()
+ }
do {
if !hasAction {
capture = try composer.beginSubmissionCapture(TeraComposerForm(editing: form))
@@ -136,7 +149,7 @@ final class TeraSubmissionStore: ObservableObject {
/// New is explicit. Preserve the old captured source in its own composer row
/// before the editing interlock saves newer input under a fresh composer ID.
func preserveForReplacement(currentForm: () -> TeraAddForm) async throws {
- guard worker == nil else {
+ guard canReplaceEditing else {
throw TeraRuntimeFailure.local(operation: "submission.new", code: "operation_in_progress",
safeMessage: "The original submission is still returning. Keep editing and retry New when it finishes.")
}
@@ -153,7 +166,13 @@ final class TeraSubmissionStore: ObservableObject {
}
func newAction() {
- guard worker == nil else { return }
+ guard canReplaceEditing else { return }
+ if worker != nil {
+ isNewComposer = true
+ changed()
+ return
+ }
+ isNewComposer = false
capture = nil
request = nil
status = nil
diff --git a/Tera/Views/TeraAddView.swift b/Tera/Views/TeraAddView.swift
@@ -129,7 +129,7 @@ struct TeraAddView: View {
Label("New", systemImage: "square.and.pencil")
}
.accessibilityIdentifier("radroots.add.new")
- .disabled(store.submissions.isWorking)
+ .disabled(!store.canCreateNewComposer)
}
ToolbarItemGroup(placement: .keyboard) {
Spacer()
diff --git a/TeraTests/TeraPublicationNavigationTests.swift b/TeraTests/TeraPublicationNavigationTests.swift
@@ -0,0 +1,129 @@
+import Foundation
+@testable import TeraApp
+import XCTest
+
+@MainActor
+final class TeraPublicationNavigationTests: XCTestCase {
+ func testNewDuringUploadAndAdvanceRetainsWorkerAndCreatesSeparateComposer() async throws {
+ for upload in [false, true] {
+ let fixture = try await NavigationFixture(upload: upload)
+ let store = fixture.store
+ let original = try XCTUnwrap(store.submissions.status)
+ XCTAssertTrue(store.canCreateNewComposer)
+ // Navigation cancels the view waiter and observation, not publication.
+ fixture.waiter.cancel()
+ store.suspend()
+ await store.start()
+ store.newDraft(type: .createAsk)
+ await TeraScopeFixtures.eventually { !store.protection.isWorking }
+ XCTAssertFalse(store.protection.failed)
+ XCTAssertEqual(store.form.commandType, .createAsk)
+ XCTAssertTrue(store.form.content.isEmpty)
+ XCTAssertTrue(store.submissions.isWorking)
+ XCTAssertEqual(store.submissions.request, original.request)
+ XCTAssertEqual(store.submissions.status?.captured, original.captured)
+ XCTAssertEqual(store.submitLabel, "Submit")
+ XCTAssertFalse(store.canSubmit, "One bounded worker; New does not start another effect")
+ store.updateForm(\.content, "separate new editing")
+ await store.save()
+ let editing = try XCTUnwrap(store.savedComposer)
+ XCTAssertNotEqual(editing.id, original.captured.id)
+ XCTAssertEqual(editing.form.content, "separate new editing")
+ await fixture.pause.resume.open()
+ await fixture.waiter.value
+ XCTAssertEqual(store.submissions.status?.request, original.request)
+ XCTAssertEqual(store.submissions.status?.captured, original.captured)
+ XCTAssertEqual(store.form.content, "separate new editing")
+ XCTAssertTrue(store.canSubmit)
+ await store.submit()
+ let next = try XCTUnwrap(store.submissions.status)
+ XCTAssertNotEqual(next.request.commandID, original.request.commandID)
+ XCTAssertNotEqual(next.captured.id, original.captured.id)
+ XCTAssertEqual(next.captured.form.content, "separate new editing")
+ let count = await fixture.backend.submissionBackend.prepareCount
+ XCTAssertEqual(count, 2, "Exactly one original and one explicit new submission")
+ store.stop()
+ _ = try await fixture.client.stop()
+ }
+ }
+
+ func testNewRefusesUnacknowledgedPreparationWithoutDiscardingRequest() async throws {
+ let backend = AddBackend()
+ let client = try await TeraAddStoreTests.startedClient(backend)
+ let store = TeraAddStore(runtimeClient: client)
+ await store.configure(snapshot: backend.snapshot())
+ await store.start()
+ store.updateForm(\.content, "pending capture")
+ let pause = ResourceTestPause()
+ await backend.submissionBackend.pausePrepare(pause)
+ let waiter = Task { await store.submit() }
+ await pause.entered.wait()
+ let request = store.submissions.request
+ XCTAssertFalse(store.canCreateNewComposer)
+ store.newDraft()
+ XCTAssertEqual(store.form.content, "pending capture")
+ XCTAssertEqual(store.submissions.request, request)
+ await pause.resume.open()
+ await waiter.value
+ store.stop()
+ _ = try await client.stop()
+ }
+
+ func testExplicitStopAfterNewStillTargetsOriginalPublication() async throws {
+ let fixture = try await NavigationFixture(upload: false)
+ let store = fixture.store
+ let request = try XCTUnwrap(store.submissions.request)
+ store.newDraft(type: .createAsk)
+ await TeraScopeFixtures.eventually { !store.protection.isWorking }
+ store.updateForm(\.content, "retain separate editing")
+ await store.submissions.requestStop()
+ XCTAssertEqual(store.submissions.request, request)
+ XCTAssertEqual(store.submissions.status?.delivery.isStopped, true)
+ await fixture.pause.resume.open()
+ await fixture.waiter.value
+ XCTAssertEqual(store.form.content, "retain separate editing")
+ XCTAssertEqual(store.submissions.request, request)
+ let count = await fixture.backend.submissionBackend.prepareCount
+ XCTAssertEqual(count, 1)
+ store.stop()
+ _ = try await fixture.client.stop()
+ }
+}
+
+@MainActor
+private struct NavigationFixture {
+ let backend: AddBackend
+ let client: TeraRuntimeClient
+ let store: TeraAddStore
+ let pause: ResourceTestPause
+ let waiter: Task<Void, Never>
+
+ init(upload: Bool) async throws {
+ let backend = AddBackend()
+ let client = try await TeraAddStoreTests.startedClient(backend)
+ let media = AddMediaHarness(foreground: true)
+ let store = TeraAddStore(runtimeClient: client, media: media)
+ await store.configure(snapshot: backend.snapshot())
+ await store.start()
+ if upload {
+ store.selectType(.createPhotoUpdate)
+ await TeraScopeFixtures.eventually { !store.protection.isWorking }
+ let photo = await media.captureImage()
+ store.updateForm(\.media, [photo])
+ }
+ store.updateForm(\.content, "frozen original")
+ let pause = ResourceTestPause()
+ if upload {
+ await backend.submissionBackend.pauseForeground(pause)
+ } else {
+ await backend.submissionBackend.pauseAdvance(pause)
+ }
+ let waiter = Task { await store.submit() }
+ await pause.entered.wait()
+ self.backend = backend
+ self.client = client
+ self.store = store
+ self.pause = pause
+ self.waiter = waiter
+ }
+}
diff --git a/project.yml b/project.yml
@@ -115,6 +115,7 @@ targets:
- path: TeraTests/TeraStateMigrationTests.swift
- path: TeraTests/TeraIdentityRecoveryTests.swift
- path: TeraTests/TeraKeyRemovalTests.swift
+ - path: TeraTests/TeraPublicationNavigationTests.swift
- path: TeraTests/TeraIdentityCancellationTests.swift
- path: TeraTests/TeraTodayStoreTests.swift
- path: TeraTests/TeraTodaySyncFixtures.swift
diff --git a/release/provenance.json b/release/provenance.json
@@ -6,7 +6,7 @@
"info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef",
"privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12",
"sbom_sha256": "904302bddb22ac1e4f9e79ab1145e590a8eeac7dd75e42539fb72eb0b7ebd4b1",
- "xcode_project_sha256": "39b5a8f581ac7f1cf858c54d7cdea5f6de1174b1ab77b07b992f73bc8dc5ac9b"
+ "xcode_project_sha256": "085b1ac33c28beffcc9c551dff69ff7323eea99469e7092bb61998db2f8f0c9e"
},
"disposition": "unsigned",
"platforms": [