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
23 changes: 23 additions & 0 deletions BitMatchTests/ProgressPresentationFeedTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,20 @@ struct ProgressPresentationFeedTests {
defer { folders.cleanup() }
let coordinator = makeCoordinator(folders)
coordinator.progress = progress(files: 2, of: 4, file: "B.ARW")
coordinator.liveProgress.ioProgress = TransferIOProgress(sequence: 1, destinations: [
DestinationIOProgress(writtenBytes: 1, readbackBytes: 0,
writeBytesPerSecond: nil, readbackBytesPerSecond: nil,
writeRateWindowStartUptime: nil, readbackRateWindowStartUptime: nil,
lastWriteUptime: nil, lastReadbackUptime: nil)
])
#expect(await waitUntil { coordinator.progressPresentation.fileCountTotal == 4 })

coordinator.resetForNewOperation()

#expect(coordinator.progressPresentation.fileCountTotal == 0)
#expect(coordinator.progressPresentation.currentFileName == nil)
#expect(coordinator.liveProgress.ioProgress == nil)
#expect(coordinator.liveProgress.rateFloorUptime == 0)
}

/// Plant: in `setupProgressPresentation`, delete
Expand All @@ -69,4 +77,19 @@ struct ProgressPresentationFeedTests {
coordinator.operationState = .cancelled
#expect(!coordinator.progressPresentation.isTracking)
}

@Test func pauseInvalidatesTheLastPerDestinationRateSample() throws {
let folders = try CoordinatorFolders()
defer { folders.cleanup() }
let coordinator = makeCoordinator(folders)
coordinator.operationState = .inProgress
coordinator.operationState = .paused(PauseInfo(
pausedAt: Date(), currentFile: nil, filesProcessed: 0, totalFiles: 1,
bytesProcessed: 0, reason: .userRequested
))
let pausedFloor = coordinator.liveProgress.rateFloorUptime
#expect(pausedFloor > 0)
coordinator.operationState = .resuming
#expect(coordinator.liveProgress.rateFloorUptime >= pausedFloor)
}
}
102 changes: 100 additions & 2 deletions BitMatchTests/TransferProgressPresentationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ struct TransferProgressPresentationTests {
state: OperationState,
isRunning: Bool = true,
progress: OperationProgress?,
ioProgress: TransferIOProgress? = nil,
destinations: [URL]? = nil,
speed: String? = "90 MB/s",
timeRemaining: String? = "3 min"
Expand All @@ -58,6 +59,7 @@ struct TransferProgressPresentationTests {
state: state,
isRunning: isRunning,
progress: progress,
ioProgress: ioProgress,
sourceName: "A001",
destinations: destinations ?? [primary, secondary],
speed: speed,
Expand Down Expand Up @@ -128,6 +130,102 @@ struct TransferProgressPresentationTests {
#expect(p.destinations.map(\.name) == ["Primary", "Secondary"])
}

@Test func destinationRowsKeepWriteAndReadbackMeasurementsSeparate() {
let sample = TransferIOProgress(sequence: 2, destinations: [
DestinationIOProgress(
writtenBytes: 8_000_000, readbackBytes: 4_000_000,
writeBytesPerSecond: 16_000_000, readbackBytesPerSecond: 8_000_000,
writeRateWindowStartUptime: 99.5, readbackRateWindowStartUptime: 99.5,
lastWriteUptime: 100, lastReadbackUptime: 100),
DestinationIOProgress(
writtenBytes: 0, readbackBytes: 0,
writeBytesPerSecond: nil, readbackBytesPerSecond: nil,
writeRateWindowStartUptime: nil, readbackRateWindowStartUptime: nil,
lastWriteUptime: nil, lastReadbackUptime: nil)
])
let p = make(state: .inProgress,
progress: progress(stage: .copying, overall: 0.25, totals: [2, 2], completed: [1, 0]),
ioProgress: sample)
let first = p.destinations[0]
#expect(first.writeDetail(nowUptime: 100.5)?.contains("/s") == true)
#expect(first.readbackDetail(nowUptime: 100.5)?.contains("/s") == true)
#expect(first.writeDetail(nowUptime: 100.5) != first.readbackDetail(nowUptime: 100.5))
#expect(first.writeDetail(nowUptime: 103)?.contains("/s") == false)
#expect(p.destinations[1].writeDetail(nowUptime: 100.5) == nil)
#expect(p.destinations[1].readbackDetail(nowUptime: 100.5) == nil)

// Completed counts include failed files. A destination that has
// processed every file must not keep advertising a live write rate.
let processed = make(
state: .inProgress,
progress: progress(stage: .copying, overall: 0.5, totals: [1, 1], completed: [1, 1]),
ioProgress: sample
)
#expect(processed.destinations[0].stateLabel == "Processed")
#expect(processed.destinations[0].writeDetail(nowUptime: 100.5)?.contains("/s") == false)

let paused = TransferProgressPresentation.rows(
destinations: [primary, secondary], progress: nil,
ioProgress: sample, phase: .paused
)
#expect(paused[0].writeDetail(nowUptime: 100.5)?.contains("/s") == false)
let resumed = TransferProgressPresentation.rows(
destinations: [primary, secondary], progress: nil,
ioProgress: sample, minimumRateUptime: 101, phase: .copying
)
#expect(resumed[0].writeDetail(nowUptime: 101.5)?.contains("/s") == false)
let report = TransferProgressPresentation.rows(
destinations: [primary, secondary], progress: nil,
ioProgress: sample, phase: .writingReports
)
#expect(report[0].io == nil)
}

@Test func firstLargeClipShowsActiveDestinationBeforeAnyFileCompletes() {
let sample = TransferIOProgress(sequence: 1, destinations: [
DestinationIOProgress(
writtenBytes: 8_000_000, readbackBytes: 0,
writeBytesPerSecond: 16_000_000, readbackBytesPerSecond: nil,
writeRateWindowStartUptime: 99.5, readbackRateWindowStartUptime: nil,
lastWriteUptime: 100, lastReadbackUptime: nil)
])
let active = TransferProgressPresentation.rows(
destinations: [primary],
progress: progress(stage: .copying, overall: 0, files: 0, total: 1,
totals: [1], completed: [0]),
ioProgress: sample, nowUptime: 100.5, phase: .copying
)
#expect(active[0].countText == "0 of 1")
#expect(active[0].stateLabel == "Copying")
#expect(active[0].writeDetail(nowUptime: 100.5)?.contains("/s") == true)

let stale = TransferProgressPresentation.rows(
destinations: [primary],
progress: progress(stage: .copying, overall: 0, files: 0, total: 1,
totals: [1], completed: [0]),
ioProgress: sample, nowUptime: 103, phase: .copying
)
#expect(stale[0].stateLabel == "Waiting")
#expect(stale[0].writeDetail(nowUptime: 103)?.contains("/s") == false)
}

@Test func firstResumedSampleCannotReusePrePauseRateWindow() {
let mixed = TransferIOProgress(sequence: 2, destinations: [
DestinationIOProgress(
writtenBytes: 12_000_000, readbackBytes: 0,
writeBytesPerSecond: 20_000_000, readbackBytesPerSecond: nil,
writeRateWindowStartUptime: 99.5, readbackRateWindowStartUptime: nil,
lastWriteUptime: 101.3, lastReadbackUptime: nil)
])
let rows = TransferProgressPresentation.rows(
destinations: [primary], progress: nil, ioProgress: mixed,
minimumRateUptime: 101, nowUptime: 101.5, phase: .copying
)
#expect(rows[0].stateLabel == "Copying")
#expect(rows[0].writeDetail(nowUptime: 101.5)?.contains("/s") == false)
#expect(rows[0].writeDetail(nowUptime: 101.5)?.contains("Written") == true)
}

@Test func destinationRowsReusePrecomputedVolumeNames() {
let internalFolder = URL(fileURLWithPath: "/Users/mike/Backups/Project", isDirectory: true)
let rows = TransferProgressPresentation.rows(
Expand Down Expand Up @@ -162,7 +260,7 @@ struct TransferProgressPresentationTests {
#expect(p.destinations.first?.helpPath == folder.path)
}

/// Audit C1: a fully copied backup is "Copied", then "Verifying", and
/// Audit C1: a fully processed backup is "Processed", then "Verifying", and
/// never "Done" or a verdict while the run is still going.
/// Plant: in `TransferProgressPresentation.rows`, replace
/// `state = phase == .verifying ? .verifying : .copied` with `state = .copied`.
Expand All @@ -176,7 +274,7 @@ struct TransferProgressPresentationTests {
progress: progress(stage: .verifying, overall: 0.75, totals: [4, 4], completed: [4, 4])
)

#expect(copying.destinations.allSatisfy { $0.state == .copied && $0.stateLabel == "Copied" })
#expect(copying.destinations.allSatisfy { $0.state == .copied && $0.stateLabel == "Processed" })
#expect(verifying.destinations.allSatisfy { $0.state == .verifying && $0.stateLabel == "Verifying" })
#expect(verifying.destinations.allSatisfy { !$0.symbol.contains("checkmark") })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public protocol ChecksumService: Sendable {
// MARK: - File Operations Service Protocol
public protocol FileOperationsService: Sendable {
typealias ProgressCallback = @Sendable (OperationProgress) -> Void
typealias IOProgressCallback = @Sendable (TransferIOProgress) -> Void
typealias FileResultCallback = @Sendable (FileOperationResult) async -> Void

func performFileOperation(
Expand All @@ -40,12 +41,45 @@ public protocol FileOperationsService: Sendable {
progressCallback: @escaping ProgressCallback,
onFileResult: FileResultCallback?
) async throws -> FileOperation

/// Optional live telemetry channel. Existing service implementations can
/// keep the original operation method; telemetry never affects the result.
func performFileOperation(
sourceURL: URL,
destinationURLs: [URL],
verificationMode: VerificationMode,
settings: CameraLabelSettings,
estimatedTotalBytes: Int64?,
progressCallback: @escaping ProgressCallback,
ioProgressCallback: @escaping IOProgressCallback,
onFileResult: FileResultCallback?
) async throws -> FileOperation

func cancelOperation()
func pauseOperation() async
func resumeOperation() async
}

public extension FileOperationsService {
func performFileOperation(
sourceURL: URL,
destinationURLs: [URL],
verificationMode: VerificationMode,
settings: CameraLabelSettings,
estimatedTotalBytes: Int64?,
progressCallback: @escaping ProgressCallback,
ioProgressCallback: @escaping IOProgressCallback,
onFileResult: FileResultCallback?
) async throws -> FileOperation {
try await performFileOperation(
sourceURL: sourceURL, destinationURLs: destinationURLs,
verificationMode: verificationMode, settings: settings,
estimatedTotalBytes: estimatedTotalBytes,
progressCallback: progressCallback, onFileResult: onFileResult
)
}
}

// MARK: - Shared Result Types

public struct FileOperation: Sendable {
Expand Down
Loading
Loading