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
2 changes: 1 addition & 1 deletion BitMatch-iPadTests/BitMatch_iPadTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ struct BitMatch_iPadTests {

#expect(success.title == "The card is safe to erase")
#expect(issues.title == "The card needs attention")
#expect(issues.sourceGuidance == "Do not erase the card until every file below is resolved.")
#expect(issues.sourceGuidance == "Do not erase the card. These results don't establish a complete verified backup.")
}

}
3 changes: 3 additions & 0 deletions BitMatch-iPadTests/TransferDetailsReportTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ struct TransferDetailsReportTests {
#expect(scope["recordedSelectedResults"] as? Int == 1)
#expect(scope["configuredChecks"] == nil)
#expect(object["selectedFilePlanRecorded"] as? Bool == false)
#expect(object["savedVerdict"] as? String == "Needs attention")
#expect(object["savedSafeToErase"] as? Bool == false)
let csv = String(decoding: try TransferHistoryDocument(record: record, asCSV: true).data, as: UTF8.self)
#expect(csv.contains("\"operatorName\",\"Alex, \"\"DIT\"\"\""))
#expect(csv.contains(record.reportSettings.notes))
#expect(csv.contains("Original selected-file plan was not saved; export describes recorded results only"))
#expect(csv.contains("\"savedSafeToErase\",\"false\""))
let pdfData = try await ReportExporter.historyPDF(record: record, includeThumbnails: false)
let pdf = try #require(PDFDocument(data: pdfData))
if let path = ProcessInfo.processInfo.environment["BITMATCH_WORKFLOW_SNAPSHOT_OUTPUT_DIR"]
Expand Down
45 changes: 42 additions & 3 deletions BitMatch/App/DockTile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,35 @@ enum DockTileState: Equatable {
return .needsReview
}
}

/// A live executor can finish before its journal does. Use the saved
/// attempt before lighting the Dock green for a transfer.
static func makeTransfer(
state: OperationState,
record: LocalTransferRecord?,
fraction: Double?
) -> DockTileState {
guard case .completed = state else { return make(state: state, fraction: fraction) }
guard let record else { return .needsReview }
switch TransferLibraryPresentation.safetyState(for: record) {
case .safeToErase: return .verified
case .failed: return .failed
case .waiting, .preparing, .copying, .verifying, .finishing:
let percent = Int((min(max(fraction ?? 0, 0), 0.99) * 100).rounded(.down))
return .running(percent: percent)
case .copiedNotVerified, .needsAttention, .interrupted:
return .needsReview
}
}

@MainActor
static func makeTransfer(coordinator: SharedAppCoordinator) -> DockTileState {
makeTransfer(
state: coordinator.operationState,
record: coordinator.outcomeRecord,
fraction: coordinator.liveProgress.progress?.overallProgress
)
}
}

/// Keeps `NSApp.dockTile` in step with the coordinator.
Expand All @@ -53,18 +82,28 @@ final class DockTileController {
coordinator.operationStatePublisher
.combineLatest(coordinator.liveProgress.$progress)
.throttle(for: .milliseconds(500), scheduler: RunLoop.main, latest: true)
.sink { [weak self] state, progress in
self?.show(DockTileState.make(state: state, fraction: progress?.overallProgress))
.sink { [weak self] _, _ in
self?.refresh()
}
.store(in: &cancellables)
coordinator.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in
DispatchQueue.main.async { self?.updateAttentionBadge() }
DispatchQueue.main.async { self?.refresh() }
}
.store(in: &cancellables)
}

private func refresh() {
let state = coordinator.operationState
let fraction = coordinator.liveProgress.progress?.overallProgress
if coordinator.currentMode == .copyAndVerify {
show(DockTileState.makeTransfer(coordinator: coordinator))
} else {
show(DockTileState.make(state: state, fraction: fraction))
}
}

private func show(_ state: DockTileState) {
updateAttentionBadge()
guard state != shown else { return }
Expand Down
18 changes: 10 additions & 8 deletions BitMatch/App/MacMenuBarCompanion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,18 +210,20 @@ struct MenuBarRecentTransfer: Equatable {
.max(by: { ($0.endedAt ?? $0.createdAt) < ($1.endedAt ?? $1.createdAt) }) else {
return nil
}
guard let result = status(for: record.state) else { return nil }
guard let result = status(for: record) else { return nil }
return Self(cardName: record.title, status: result.text, needsAttention: result.needsAttention)
}

static func status(for state: LocalTransferState) -> (text: String, needsAttention: Bool)? {
switch state {
case .completed: ("Completed · review results in BitMatch", false)
case .issues: ("Needs review", true)
static func status(for record: LocalTransferRecord) -> (text: String, needsAttention: Bool)? {
guard record.state != .queued, record.state != .running else { return nil }
return switch TransferLibraryPresentation.safetyState(for: record) {
case .safeToErase: ("Completed · review results in BitMatch", false)
case .copiedNotVerified: ("Copied, not verified", true)
case .needsAttention: ("Needs review", true)
case .failed: ("Transfer failed", true)
case .cancelled: ("Transfer cancelled", true)
case .interrupted: ("Transfer interrupted", true)
case .queued, .running: nil
case .interrupted:
(record.state == .cancelled ? "Transfer cancelled" : "Transfer interrupted", true)
case .waiting, .preparing, .copying, .verifying, .finishing: nil
}
}
}
25 changes: 25 additions & 0 deletions BitMatchTests/CopyVerifyExecutorIntegrityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,31 @@ final class CopyVerifyExecutorIntegrityTests: XCTestCase {
XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.destination.appendingPathComponent("Reports").path))
}

func testMissingSavedPlanStopsAutomaticReportBeforeItCanClaimCompletion() async throws {
let fixture = try reportFixture(blockReportsFolder: false)
var projectFinalizations = 0
let harness = ExecutorHarness(returnedResults: [fixture.result], emittedResults: [],
lifecycleCompletion: { _ in
projectFinalizations += 1
return PhotographerFinalizationResult(context: nil, locallySafe: true)
},
sourceURL: fixture.source, destinationURLs: [fixture.destination],
generateASCMHL: true, makeReport: true)
harness.onAuthoritativeResults = { throw TransferPlanPersistenceError.missingSavedPlan }

do {
_ = try await harness.execute()
XCTFail("A missing saved plan must interrupt report publication")
} catch is TransferPlanPersistenceError { }

XCTAssertEqual(harness.completedRows.count, 1)
XCTAssertNil(harness.terminalInfo)
XCTAssertEqual(harness.lastState, .failed)
XCTAssertEqual(projectFinalizations, 0)
XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.destination.appendingPathComponent("ascmhl").path))
XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.destination.appendingPathComponent("Reports").path))
}

func testUnexpectedCancellationErrorIsNotAnIntentionalCancellation() async throws {
let harness = ExecutorHarness(returnedResults: [], emittedResults: [], thrownError: CancellationError())
do { _ = try await harness.execute(); XCTFail("Must propagate interruption") }
Expand Down
26 changes: 26 additions & 0 deletions BitMatchTests/DockTileStateTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// DockTileStateTests.swift
import Foundation
import Testing
@testable import BitMatch
import BitMatchEngine
Expand All @@ -19,4 +20,29 @@ struct DockTileStateTests {
#expect(DockTileState.make(state: .notStarted, fraction: 0.5) == .appIcon)
#expect(DockTileState.make(state: .cancelled, fraction: 0.5) == .needsReview)
}

@Test func executorSuccessWaitsForSavedCoverageBeforeDockTurnsGreen() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("dock-plan-\(UUID())")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let source = root.appendingPathComponent("Card")
let destination = root.appendingPathComponent("Backup")
try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
var record = LocalTransferRecord(id: UUID(), createdAt: Date(),
source: try LocalTransferResource(url: source),
destinations: [try LocalTransferResource(url: destination)],
verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs())
record.results = [ResultRow(path: source.appendingPathComponent("clip.mov").path,
status: ResultOutcome.verified.statusText, size: 1, checksum: "abc", destination: "Backup",
destinationPath: destination.appendingPathComponent("clip.mov").path)]
let engineSuccess = OperationState.completed(.init(success: true, message: "Selected files verified"))

record.state = .running
#expect(DockTileState.makeTransfer(state: engineSuccess, record: record, fraction: 1) != .verified)
record.state = .completed
#expect(DockTileState.makeTransfer(state: engineSuccess, record: record, fraction: 1) == .needsReview)
record.plannedFileCounts = [1]
#expect(DockTileState.makeTransfer(state: engineSuccess, record: record, fraction: 1) == .verified)
}
}
1 change: 1 addition & 0 deletions BitMatchTests/LiveResultsFeedTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ private final class StreamingFileOperations: FileOperationsService, @unchecked S
progressCallback: @escaping ProgressCallback,
onFileResult: FileResultCallback?
) async throws -> FileOperation {
progressCallback(testPlannedProgress(filesPerDestination: files, destinationCount: destinationURLs.count))
func result(_ index: Int, _ destination: URL, success: Bool) -> FileOperationResult {
FileOperationResult(
sourceURL: sourceURL.appendingPathComponent("\(index).ARW"),
Expand Down
9 changes: 7 additions & 2 deletions BitMatchTests/LocalTransferJournalTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct LocalTransferJournalTests {
cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()
)
try journal.markRunning(id: verifiedID)
try journal.recordPlan(id: verifiedID, fileCounts: [1])
try journal.finish(
id: verifiedID,
results: [ResultRow(
Expand Down Expand Up @@ -549,7 +550,9 @@ struct LocalTransferJournalTests {
let id = try journal.enqueue(sourceURL: f.source, destinationURLs: [f.destination], verificationMode: .standard,
cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs(), generateASCMHL: true)
try journal.markRunning(id: id)
let row = ResultRow(path: "clip.mov", status: "✅ Verified", size: 1, checksum: "abc", destination: "Backup")
try journal.recordPlan(id: id, fileCounts: [1])
let row = ResultRow(path: "clip.mov", status: "✅ Verified", size: 1, checksum: "abc",
destination: "Backup", destinationPath: f.destination.appendingPathComponent("clip.mov").path)
try journal.finish(id: id, results: [row], summary: "ASC MHL history already exists", hadIssues: true)

let copyOnlyRetryID = try journal.requeue(id: id, generateASCMHL: false)
Expand Down Expand Up @@ -693,7 +696,9 @@ struct LocalTransferJournalTests {
let id = try journal.enqueue(sourceURL: f.source, destinationURLs: [f.destination], verificationMode: .standard,
cameraSettings: CameraLabelSettings(), reportSettings: reportSettings, generateASCMHL: true)
try journal.markRunning(id: id)
try journal.finish(id: id, results: [ResultRow(path: "clip.mov", status: "✅ Verified", size: 1, checksum: "abc", destination: "Backup")],
try journal.recordPlan(id: id, fileCounts: [1])
try journal.finish(id: id, results: [ResultRow(path: "clip.mov", status: "✅ Verified", size: 1, checksum: "abc",
destination: "Backup", destinationPath: f.destination.appendingPathComponent("clip.mov").path)],
summary: "Done", hadIssues: false)
let record = try #require(journal.records.first)

Expand Down
Loading
Loading