From 2b7063efc86672621793d96c66297c1d8746ab31 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 01:25:14 -0400 Subject: [PATCH 1/3] Record unknown backup storage identity in setup --- BitMatchTests/SetupPresentationTests.swift | 42 +++++++++++++++++++ .../File/BackupIndependencePolicy.swift | 18 ++++++++ .../BackupIndependencePolicyTests.swift | 36 ++++++++++++++++ Shared/Core/Models/SetupPresentation.swift | 10 +++-- .../Views/Setup/CoordinatorSetupScreen.swift | 1 + docs/TODO.md | 2 +- 6 files changed, 105 insertions(+), 4 deletions(-) diff --git a/BitMatchTests/SetupPresentationTests.swift b/BitMatchTests/SetupPresentationTests.swift index f6d87f98..9531ab94 100644 --- a/BitMatchTests/SetupPresentationTests.swift +++ b/BitMatchTests/SetupPresentationTests.swift @@ -125,6 +125,48 @@ struct SetupPresentationTests { #expect(presentation.start.blocker == nil) } + @Test func unresolvedDriveSeparationAppearsBesideTheDestinationCountWithoutBlockingStart() { + let first = URL(fileURLWithPath: "/Volumes/A") + let second = URL(fileURLWithPath: "/Volumes/B") + let ready = plan(source: source, backups: [first, second]) + let note = "BitMatch can't confirm whether these destinations share storage." + let presentation = StartButtonPresentation.make( + plan: ready, + usesProjectWorkflow: false, + hasPreparedCard: false, + projectBlocker: nil, + projectUnit: "Card", + isOperationInProgress: false, + hasComposerCard: true, + sourceFileCount: 12, + sourceBytes: 4_000, + destinationCount: 2, + physicalSeparationNote: note + ) + + #expect(presentation.canStart) + #expect(presentation.blocker == nil) + #expect(presentation.readyLine?.contains("to 2 destinations") == true) + #expect(presentation.readyLine?.hasSuffix(note) == true) + + let preparedProject = StartButtonPresentation.make( + plan: ready, + usesProjectWorkflow: true, + hasPreparedCard: true, + projectBlocker: nil, + projectUnit: "Card", + isOperationInProgress: false, + hasComposerCard: true, + sourceFileCount: 12, + sourceBytes: 4_000, + destinationCount: 2, + physicalSeparationNote: note + ) + #expect(preparedProject.canStart) + #expect(preparedProject.startsProject) + #expect(preparedProject.readyLine?.hasSuffix(note) == true) + } + @MainActor @Test func priorBackupWordingIncludesYearForAnOlderTransfer() { let calendar = Calendar(identifier: .gregorian) diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/File/BackupIndependencePolicy.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/File/BackupIndependencePolicy.swift index b27bbf05..d31424cc 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/File/BackupIndependencePolicy.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/File/BackupIndependencePolicy.swift @@ -62,8 +62,23 @@ public struct SystemPhysicalDiskIdentityProvider: PhysicalDiskIdentityProviding } public struct BackupIndependenceAssessment: Equatable, Sendable { + /// Selected folders, including folders that share a resolved identity. + public let selectedDestinationCount: Int + /// Distinct identity strings the provider could resolve. A composite or + /// network volume may still hide its underlying physical stores. + public let resolvedIdentityGroupCount: Int + /// Destinations whose physical storage identity could not be resolved. + public let unresolvedDestinationCount: Int + /// Current product-policy count. Unresolved destinations each count once; + /// this is not proof that they live on separate physical devices. public let independentCopyCount: Int public let warnings: [String] + + /// A quiet setup note, separate from warnings and the safety verdict. + public var physicalSeparationNote: String? { + guard selectedDestinationCount > 1, unresolvedDestinationCount > 0 else { return nil } + return "BitMatch can't confirm whether these destinations share storage." + } } public enum BackupIndependencePolicy: Sendable { @@ -95,6 +110,9 @@ public enum BackupIndependencePolicy: Sendable { } .sorted() return BackupIndependenceAssessment( + selectedDestinationCount: destinations.count, + resolvedIdentityGroupCount: knownGroups.count, + unresolvedDestinationCount: unknownCount, independentCopyCount: knownGroups.count + unknownCount, warnings: warnings ) diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/BackupIndependencePolicyTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/BackupIndependencePolicyTests.swift index 38343fe2..e535a4d5 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/BackupIndependencePolicyTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/BackupIndependencePolicyTests.swift @@ -21,6 +21,10 @@ struct BackupIndependencePolicyTests { ) #expect(result.independentCopyCount == 1) + #expect(result.selectedDestinationCount == 2) + #expect(result.resolvedIdentityGroupCount == 1) + #expect(result.unresolvedDestinationCount == 0) + #expect(result.physicalSeparationNote == nil) #expect(result.warnings == ["SHUTTLE A and SHUTTLE B are on the same physical drive — they count as one backup"]) } @@ -35,9 +39,41 @@ struct BackupIndependencePolicyTests { ) #expect(result.independentCopyCount == 3) + #expect(result.selectedDestinationCount == 3) + #expect(result.resolvedIdentityGroupCount == 2) + #expect(result.unresolvedDestinationCount == 1) + #expect(result.physicalSeparationNote == "BitMatch can't confirm whether these destinations share storage.") #expect(result.warnings.isEmpty) } + @Test func unresolvedIdentityIsEvidenceGapNotProofOfSharedStorage() { + let first = URL(fileURLWithPath: "/Volumes/A") + let second = URL(fileURLWithPath: "/Volumes/B") + let unknown = FakeProvider(identities: [:]) + + let pair = BackupIndependencePolicy.assess( + destinations: [first, second], provider: unknown + ) + #expect(pair.independentCopyCount == 2) // Existing verdict policy is unchanged. + #expect(pair.resolvedIdentityGroupCount == 0) + #expect(pair.unresolvedDestinationCount == 2) + #expect(pair.warnings.isEmpty) + #expect(pair.physicalSeparationNote != nil) + + let single = BackupIndependencePolicy.assess(destinations: [first], provider: unknown) + #expect(single.independentCopyCount == 1) + #expect(single.unresolvedDestinationCount == 1) + #expect(single.physicalSeparationNote == nil) + + let distinct = BackupIndependencePolicy.assess( + destinations: [first, second], + provider: FakeProvider(identities: [first.path: "disk4", second.path: "disk5"]) + ) + #expect(distinct.resolvedIdentityGroupCount == 2) + #expect(distinct.unresolvedDestinationCount == 0) + #expect(distinct.physicalSeparationNote == nil) + } + @Test func wholeDiskNameStripsPartitionsButKeepsWholeAndUnknownNames() { #expect(SystemPhysicalDiskIdentityProvider.wholeDiskName("disk5s1") == "disk5") #expect(SystemPhysicalDiskIdentityProvider.wholeDiskName("disk12s2s1") == "disk12") diff --git a/Shared/Core/Models/SetupPresentation.swift b/Shared/Core/Models/SetupPresentation.swift index c6bf174f..33c0390f 100644 --- a/Shared/Core/Models/SetupPresentation.swift +++ b/Shared/Core/Models/SetupPresentation.swift @@ -70,7 +70,8 @@ struct StartButtonPresentation: Equatable, Sendable { stagedVerificationModes: [VerificationMode] = [], sourceFileCount: Int?, sourceBytes: Int64?, - destinationCount: Int + destinationCount: Int, + physicalSeparationNote: String? = nil ) -> Self { let isProject = usesProjectWorkflow || hasPreparedCard let unit = projectUnit.lowercased() @@ -214,7 +215,8 @@ struct StartButtonPresentation: Equatable, Sendable { destinationRoutes: destinationRoutes, destinationRouteIdentities: destinationRouteIdentities ) - : readyLine(fileCount: sourceFileCount, bytes: sourceBytes, destinationCount: destinationCount)) + : [readyLine(fileCount: sourceFileCount, bytes: sourceBytes, destinationCount: destinationCount), + physicalSeparationNote].compactMap { $0 }.joined(separator: " ")) : nil, accessibilityHint: canStart ? (cardCount > 1 @@ -330,6 +332,7 @@ struct SetupPresentation: Equatable { sourceBytes: Int64?, destinationCount: Int, hasProjectEvidence: Bool, + physicalSeparationNote: String? = nil, informationalLines: [String] = [] ) -> Self { let isProject = usesProjectWorkflow || hasPreparedCard || isProjectRunInProgress @@ -352,7 +355,8 @@ struct SetupPresentation: Equatable { stagedVerificationModes: stagedVerificationModes, sourceFileCount: sourceFileCount, sourceBytes: sourceBytes, - destinationCount: destinationCount + destinationCount: destinationCount, + physicalSeparationNote: physicalSeparationNote ), workflow: isProject ? .project : .quick, isWorkflowLocked: hasPreparedCard || isProjectRunInProgress, diff --git a/Shared/Views/Setup/CoordinatorSetupScreen.swift b/Shared/Views/Setup/CoordinatorSetupScreen.swift index c74137a5..e0b8503c 100644 --- a/Shared/Views/Setup/CoordinatorSetupScreen.swift +++ b/Shared/Views/Setup/CoordinatorSetupScreen.swift @@ -72,6 +72,7 @@ extension SetupPresentation { sourceBytes: coordinator.appleDoubleSelection.paths?.isEmpty == false ? nil : coordinator.sourceFolderInfo?.totalSize, destinationCount: independence.independentCopyCount, hasProjectEvidence: !(jobs.dashboardJob?.cardIngests.isEmpty ?? true), + physicalSeparationNote: independence.physicalSeparationNote, informationalLines: [coordinator.alreadyBackedUpLine, coordinator.appleDoubleSelection.summaryLine].compactMap { $0 } ) } diff --git a/docs/TODO.md b/docs/TODO.md index 955925d4..ae4d8ba9 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -94,7 +94,7 @@ The priority buckets are not a strict implementation sequence: investigate P0 re - [ ] **Storage-aware verification scheduling.** Replace the CPU-derived default with scheduling informed by physical storage identity and measured behavior. Avoid competing large sequential reads on one HDD; permit useful concurrency across independent SSD/NVMe devices. Account for shared devices and buses only where reliably identifiable. Preserve diagnostic worker-count overrides and benchmark actual throughput and memory use on physical hardware, including the two-drive test below. **Acceptance:** independent destination readback remains complete, cancellation still stops the run, and unknown topology gets a conservative policy rather than invented independence. -- [ ] **Review unknown-drive backup claims.** `BackupIndependencePolicy` currently counts each destination whose physical disk identity cannot be resolved as a separate backup; this was an intentional product choice pending positive evidence of a shared disk, not a confirmed defect. Decide separately how many copies verified and how many physical devices are *known* to be independent, then make setup, completion, safe-to-erase wording and reports say only what the evidence supports. Do not silently demote every iPhone/iPad file-provider destination or change the existing verdict without a product decision. **Acceptance:** known-shared, known-distinct and unknown identities, plus RAID/composite, network and mobile-provider cases, have explicit tests and consistent user-facing claims; storage scheduling does not treat unknown devices as proven independent. +- [ ] **Review unknown-drive backup claims.** `BackupIndependencePolicy` currently counts each destination whose physical disk identity cannot be resolved as a separate backup; this was an intentional product choice pending positive evidence of a shared disk, not a confirmed defect. A first bounded slice now records resolved identity groups separately from unresolved destinations and quietly notes uncertainty beside the single-card Setup destination count. It does not change the verdict, the saved count or staged-queue claims. Next decide separately how many copies verified and how many physical devices are *known* to be independent, then make staged setup, completion, safe-to-erase wording, History and reports say only what the evidence supports. Do not silently demote every iPhone/iPad file-provider destination or change the existing verdict without a product decision. **Acceptance:** known-shared, known-distinct and unknown identities, plus RAID/composite, network and mobile-provider cases, have explicit tests and consistent user-facing claims; storage scheduling does not treat unknown devices as proven independent. - [ ] **Bounded source-read retries and “Don't format the card” warnings.** Cover both source changes and read instability, separately from destination failures. Retry transient source I/O failures a small, bounded number of times and record each attempt and reason. Separate verified backup contents from source-media health: a recovered read must still warn against formatting or reusing an unstable card until investigated. Distinguish that from a failed backup. Explore chunk retries only with proven hash continuity and source identity checks. **Acceptance:** injected recoverable and persistent faults produce accurate counts, evidence and warnings; retries never hide changed data or turn exhaustion into success. From 5dd09f45965078be9b89774312ee3f5ba12437f3 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 01:39:46 -0400 Subject: [PATCH 2/3] Capture full mobile setup layouts for visual review --- BitMatch-iPadTests/WorkflowSnapshotTests.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/BitMatch-iPadTests/WorkflowSnapshotTests.swift b/BitMatch-iPadTests/WorkflowSnapshotTests.swift index 5b6ecfca..755cbab1 100644 --- a/BitMatch-iPadTests/WorkflowSnapshotTests.swift +++ b/BitMatch-iPadTests/WorkflowSnapshotTests.swift @@ -23,6 +23,16 @@ final class WorkflowSnapshotTests: XCTestCase { size: CGSize(width: 393, height: 852), name: "iphone-setup" ) + try capture( + ContentView(coordinator: fixture.coordinator), + size: CGSize(width: 393, height: 1_300), + name: "iphone-setup-full" + ) + try capture( + ContentView(coordinator: fixture.coordinator), + size: CGSize(width: 820, height: 1_100), + name: "ipad-setup" + ) fixture.seedComparisonDifferences() await settle() From 153ef466d143f580f9325d938d95eb185224b1cb Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Sat, 17 Oct 2026 02:15:08 -0400 Subject: [PATCH 3/3] Clarify setup destination count and staged storage uncertainty --- .../WorkflowSnapshotTests.swift | 17 ++++ BitMatchTests/SetupPresentationTests.swift | 87 ++++++++++++++++++- .../File/BackupIndependencePolicy.swift | 4 +- .../BackupIndependencePolicyTests.swift | 2 +- Shared/Core/Models/SetupPresentation.swift | 28 ++++-- .../Views/Setup/CoordinatorSetupScreen.swift | 2 +- 6 files changed, 128 insertions(+), 12 deletions(-) diff --git a/BitMatch-iPadTests/WorkflowSnapshotTests.swift b/BitMatch-iPadTests/WorkflowSnapshotTests.swift index 755cbab1..243a93ad 100644 --- a/BitMatch-iPadTests/WorkflowSnapshotTests.swift +++ b/BitMatch-iPadTests/WorkflowSnapshotTests.swift @@ -112,6 +112,23 @@ final class WorkflowSnapshotTests: XCTestCase { size: CGSize(width: 820, height: 1_100), name: "ipad-history-diagnostics") } + func testCaptureStagedSetupStorageNote() async throws { + try requireCaptureConfiguration() + let fixture = try SnapshotFixture() + defer { fixture.restoreGlobalPreferences() } + fixture.seedSetup() + _ = try fixture.coordinator.enqueue( + source: fixture.source, + destinations: [fixture.backup, fixture.secondBackup] + ) + fixture.coordinator.sourceURL = nil + await settle() + try capture(ContentView(coordinator: fixture.coordinator), + size: CGSize(width: 393, height: 1_300), name: "iphone-staged-setup-full") + try capture(ContentView(coordinator: fixture.coordinator), + size: CGSize(width: 820, height: 1_100), name: "ipad-staged-setup") + } + func testCaptureAppleDoubleControls() async throws { try requireCaptureConfiguration() let fixture = try SnapshotFixture() diff --git a/BitMatchTests/SetupPresentationTests.swift b/BitMatchTests/SetupPresentationTests.swift index 9531ab94..650abcd0 100644 --- a/BitMatchTests/SetupPresentationTests.swift +++ b/BitMatchTests/SetupPresentationTests.swift @@ -129,7 +129,7 @@ struct SetupPresentationTests { let first = URL(fileURLWithPath: "/Volumes/A") let second = URL(fileURLWithPath: "/Volumes/B") let ready = plan(source: source, backups: [first, second]) - let note = "BitMatch can't confirm whether these destinations share storage." + let note = "Separate storage isn't confirmed." let presentation = StartButtonPresentation.make( plan: ready, usesProjectWorkflow: false, @@ -258,6 +258,25 @@ struct SetupPresentationTests { ]) } + @MainActor + @Test func setupCountsSelectedFoldersEvenWhenProjectPolicyCountsOneDrive() async throws { + let fixture = try await SharedProjectFixture.make( + prepareCard: false, + physicalDiskIdentityProvider: SamePhysicalDisk() + ) + defer { fixture.folders.cleanup() } + let settled = await waitUntil(timeout: .seconds(5)) { + fixture.coordinator.destinationIndependence.independentCopyCount == 1 + } + #expect(settled) + #expect(fixture.coordinator.destinationIndependence.selectedDestinationCount == 2) + + let presentation = SetupPresentation.make(coordinator: fixture.coordinator) + #expect(presentation.start.canStart) + #expect(presentation.start.readyLine?.contains("to 2 destinations") == true) + #expect(presentation.start.readyLine?.contains(BackupIndependenceAssessment.unconfirmedStorageNote) == false) + } + @MainActor @Test func sameDiskPairCannotStartATwoCopyProject() async throws { let fixture = try await SharedProjectFixture.make( @@ -312,6 +331,26 @@ struct SetupPresentationTests { #expect(presentation.readyLine == "Runs after the current card finishes.") } + @Test func addToQueueCaptionDescribesOnlyTheComposerCard() { + let presentation = StartButtonPresentation.make( + plan: plan(source: source, backups: [backup]), + usesProjectWorkflow: false, + hasPreparedCard: false, + projectBlocker: nil, + projectUnit: "Card", + isOperationInProgress: true, + hasComposerCard: true, + stagedCardCount: 1, + stagedDestinationNames: [["A", "B"]], + sourceFileCount: 12, + sourceBytes: 4_000, + destinationCount: 1 + ) + + #expect(presentation.action == .addToQueue) + #expect(presentation.readyLine == "Runs after the current card finishes.") + } + @Test func pausedQueueDisablesSetupStart() { let presentation = SetupPresentation.make( plan: plan(source: source, backups: [backup]), @@ -392,7 +431,51 @@ struct SetupPresentationTests { #expect(presentation.canStart) #expect(presentation.title == "Start 2 transfers") - #expect(presentation.readyLine == "Ready to copy 2 cards to SHUTTLE A and SHUTTLE B. Source files stay in place.") + #expect(presentation.readyLine == "Ready to copy 2 cards to SHUTTLE A and SHUTTLE B. Source files stay in place.\n\(BackupIndependenceAssessment.unconfirmedStorageNote)") + } + + @Test func composerUncertaintyRemainsVisibleBesideABatchStart() { + let first = URL(fileURLWithPath: "/Volumes/A") + let second = URL(fileURLWithPath: "/Volumes/B") + let presentation = StartButtonPresentation.make( + plan: plan(source: source, backups: [first, second]), + usesProjectWorkflow: false, + hasPreparedCard: false, + projectBlocker: nil, + projectUnit: "Card", + isOperationInProgress: false, + hasComposerCard: true, + composerDestinationNames: ["A", "B"], + stagedCardCount: 1, + stagedDestinationNames: [["A"]], + sourceFileCount: 12, + sourceBytes: 4_000, + destinationCount: 2, + physicalSeparationNote: BackupIndependenceAssessment.unconfirmedStorageNote + ) + + #expect(presentation.canStart) + #expect(presentation.readyLine?.hasSuffix(BackupIndependenceAssessment.unconfirmedStorageNote) == true) + } + + @Test func unusedComposerUncertaintyDoesNotDescribeASingleDestinationStagedCard() { + let presentation = StartButtonPresentation.make( + plan: plan(source: nil, backups: [backup]), + usesProjectWorkflow: false, + hasPreparedCard: false, + projectBlocker: nil, + projectUnit: "Card", + isOperationInProgress: false, + hasComposerCard: false, + stagedCardCount: 1, + stagedDestinationNames: [["A"]], + sourceFileCount: nil, + sourceBytes: nil, + destinationCount: 1, + physicalSeparationNote: BackupIndependenceAssessment.unconfirmedStorageNote + ) + + #expect(presentation.readyLine == "Ready to copy 1 card to A. Source files stay in place.") } @Test func batchCaptionCountsCardsWithDifferentDestinations() { diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/File/BackupIndependencePolicy.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/File/BackupIndependencePolicy.swift index d31424cc..43442f4f 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/File/BackupIndependencePolicy.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/File/BackupIndependencePolicy.swift @@ -62,6 +62,8 @@ public struct SystemPhysicalDiskIdentityProvider: PhysicalDiskIdentityProviding } public struct BackupIndependenceAssessment: Equatable, Sendable { + public static let unconfirmedStorageNote = "Separate storage isn't confirmed." + /// Selected folders, including folders that share a resolved identity. public let selectedDestinationCount: Int /// Distinct identity strings the provider could resolve. A composite or @@ -77,7 +79,7 @@ public struct BackupIndependenceAssessment: Equatable, Sendable { /// A quiet setup note, separate from warnings and the safety verdict. public var physicalSeparationNote: String? { guard selectedDestinationCount > 1, unresolvedDestinationCount > 0 else { return nil } - return "BitMatch can't confirm whether these destinations share storage." + return Self.unconfirmedStorageNote } } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/BackupIndependencePolicyTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/BackupIndependencePolicyTests.swift index e535a4d5..ee1e0841 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/BackupIndependencePolicyTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/BackupIndependencePolicyTests.swift @@ -42,7 +42,7 @@ struct BackupIndependencePolicyTests { #expect(result.selectedDestinationCount == 3) #expect(result.resolvedIdentityGroupCount == 2) #expect(result.unresolvedDestinationCount == 1) - #expect(result.physicalSeparationNote == "BitMatch can't confirm whether these destinations share storage.") + #expect(result.physicalSeparationNote == "Separate storage isn't confirmed.") #expect(result.warnings.isEmpty) } diff --git a/Shared/Core/Models/SetupPresentation.swift b/Shared/Core/Models/SetupPresentation.swift index 33c0390f..14a8060e 100644 --- a/Shared/Core/Models/SetupPresentation.swift +++ b/Shared/Core/Models/SetupPresentation.swift @@ -80,6 +80,12 @@ struct StartButtonPresentation: Equatable, Sendable { let destinationRoutes = stagedDestinationNames + (hasComposerCard ? [composerRoute] : []) let destinationRouteIdentities = stagedDestinationIdentities + (hasComposerCard ? [composerDestinationIdentities] : []) + // A staged card stores its destinations but has no saved physical-disk + // assessment until its run starts. Keep the quiet uncertainty note in + // the batch caption even after the composer source is cleared. + let separationNote = (hasComposerCard ? physicalSeparationNote : nil) + ?? (stagedDestinationNames.contains { $0.count > 1 } + ? BackupIndependenceAssessment.unconfirmedStorageNote : nil) if isOperationInProgress { let canAdd = !isProject && hasComposerCard && plan.canStart @@ -91,7 +97,9 @@ struct StartButtonPresentation: Equatable, Sendable { startsProject: isProject, nextStep: nil, blocker: canAdd ? nil : "Choose a ready source and at least one destination for the next transfer.", - readyLine: canAdd ? "Runs after the current card finishes." : nil, + readyLine: canAdd + ? withSeparationNote("Runs after the current card finishes.", physicalSeparationNote) + : nil, accessibilityHint: canAdd ? "Adds this card after the transfer already running" : "Choose a ready card and destinations first" @@ -141,11 +149,11 @@ struct StartButtonPresentation: Equatable, Sendable { startsProject: false, nextStep: nil, blocker: nil, - readyLine: readyCardsLine( + readyLine: withSeparationNote(readyCardsLine( cardCount: stagedCardCount, destinationRoutes: destinationRoutes, destinationRouteIdentities: destinationRouteIdentities - ), + ), separationNote), accessibilityHint: "Starts each staged card in queue order and leaves every source unchanged" ) } @@ -210,13 +218,15 @@ struct StartButtonPresentation: Equatable, Sendable { blocker: blocker, readyLine: canStart ? (cardCount > 1 - ? readyCardsLine( + ? withSeparationNote(readyCardsLine( cardCount: cardCount, destinationRoutes: destinationRoutes, destinationRouteIdentities: destinationRouteIdentities - ) - : [readyLine(fileCount: sourceFileCount, bytes: sourceBytes, destinationCount: destinationCount), - physicalSeparationNote].compactMap { $0 }.joined(separator: " ")) + ), separationNote) + : withSeparationNote( + readyLine(fileCount: sourceFileCount, bytes: sourceBytes, destinationCount: destinationCount), + separationNote + )) : nil, accessibilityHint: canStart ? (cardCount > 1 @@ -230,6 +240,10 @@ struct StartButtonPresentation: Equatable, Sendable { cardCount > 1 ? "Start \(cardCount) transfers" : fallback } + private static func withSeparationNote(_ line: String, _ note: String?) -> String { + [line, note].compactMap { $0 }.joined(separator: "\n") + } + private static func singleTransferTitle(verificationMode: VerificationMode) -> String { verificationMode == .quick ? "Start copy without checksum verification" diff --git a/Shared/Views/Setup/CoordinatorSetupScreen.swift b/Shared/Views/Setup/CoordinatorSetupScreen.swift index e0b8503c..0a8f66fe 100644 --- a/Shared/Views/Setup/CoordinatorSetupScreen.swift +++ b/Shared/Views/Setup/CoordinatorSetupScreen.swift @@ -70,7 +70,7 @@ extension SetupPresentation { stagedVerificationModes: stagedVerificationModes, sourceFileCount: coordinator.appleDoubleSelection.paths?.isEmpty == false ? nil : coordinator.sourceFolderInfo?.fileCount, sourceBytes: coordinator.appleDoubleSelection.paths?.isEmpty == false ? nil : coordinator.sourceFolderInfo?.totalSize, - destinationCount: independence.independentCopyCount, + destinationCount: independence.selectedDestinationCount, hasProjectEvidence: !(jobs.dashboardJob?.cardIngests.isEmpty ?? true), physicalSeparationNote: independence.physicalSeparationNote, informationalLines: [coordinator.alreadyBackedUpLine, coordinator.appleDoubleSelection.summaryLine].compactMap { $0 }