Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions BitMatch-iPadTests/WorkflowSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
127 changes: 126 additions & 1 deletion BitMatchTests/SetupPresentationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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]),
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}

Expand All @@ -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")
Expand Down
34 changes: 26 additions & 8 deletions Shared/Core/Models/SetupPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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"
)
}
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -330,6 +346,7 @@ struct SetupPresentation: Equatable {
sourceBytes: Int64?,
destinationCount: Int,
hasProjectEvidence: Bool,
physicalSeparationNote: String? = nil,
informationalLines: [String] = []
) -> Self {
let isProject = usesProjectWorkflow || hasPreparedCard || isProjectRunInProgress
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion Shared/Views/Setup/CoordinatorSetupScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
)
}
Expand Down
Loading
Loading