diff --git a/BitMatch-iPadTests/WorkflowSnapshotTests.swift b/BitMatch-iPadTests/WorkflowSnapshotTests.swift index 5b6ecfca..243a93ad 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() @@ -102,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 f6d87f98..650abcd0 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 = "Separate storage isn't confirmed." + 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) @@ -216,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( @@ -270,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]), @@ -350,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 b27bbf05..43442f4f 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/File/BackupIndependencePolicy.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/File/BackupIndependencePolicy.swift @@ -62,8 +62,25 @@ 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 + /// 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 Self.unconfirmedStorageNote + } } public enum BackupIndependencePolicy: Sendable { @@ -95,6 +112,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..ee1e0841 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 == "Separate storage isn't confirmed.") #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..14a8060e 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() @@ -79,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 @@ -90,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" @@ -140,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" ) } @@ -209,12 +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)) + ), separationNote) + : withSeparationNote( + readyLine(fileCount: sourceFileCount, bytes: sourceBytes, destinationCount: destinationCount), + separationNote + )) : nil, accessibilityHint: canStart ? (cardCount > 1 @@ -228,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" @@ -330,6 +346,7 @@ struct SetupPresentation: Equatable { sourceBytes: Int64?, destinationCount: Int, hasProjectEvidence: Bool, + physicalSeparationNote: String? = nil, informationalLines: [String] = [] ) -> Self { let isProject = usesProjectWorkflow || hasPreparedCard || isProjectRunInProgress @@ -352,7 +369,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..0a8f66fe 100644 --- a/Shared/Views/Setup/CoordinatorSetupScreen.swift +++ b/Shared/Views/Setup/CoordinatorSetupScreen.swift @@ -70,8 +70,9 @@ 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 } ) } 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.