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
3 changes: 3 additions & 0 deletions BitMatch-iPad/Views/AdaptiveQueuePresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ struct CompactQueuePresentation<Content: View>: View {
value: accessory.isVisible
)
.sensoryFeedback(.success, trigger: state.successFeedbackCount)
.onChange(of: coordinator.editingSetupTransferID) { _, id in
if id != nil { state.showingQueue = false }
}
.onChange(of: coordinator.queuePresentation.rows) { oldRows, newRows in
state.update(previousRows: oldRows, currentRows: newRows)
}
Expand Down
28 changes: 28 additions & 0 deletions BitMatch-iPadTests/WorkflowSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,34 @@ final class WorkflowSnapshotTests: XCTestCase {
try? data.write(to: directory.appendingPathComponent("\(name).png"), options: .atomic)
}

func testCaptureRecoveryDetails() throws {
try requireCaptureConfiguration()
let fixture = try SnapshotFixture()
defer { fixture.restoreGlobalPreferences() }
var previous = LocalTransferRecord(id: UUID(), createdAt: Date(timeIntervalSince1970: 1791600000),
source: try LocalTransferResource(url: fixture.source),
destinations: try [fixture.backup, fixture.secondBackup].map(LocalTransferResource.init(url:)),
verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs())
previous.state = .interrupted
var retry = LocalTransferRecord(id: UUID(), createdAt: Date(), source: previous.source,
destinations: previous.destinations, verificationMode: .standard,
cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs(), retryOf: previous.id)
retry.state = .interrupted
retry.plannedFileCounts = [5, 5]
retry.results = [ResultRow(path: "a.mov", status: ResultOutcome.verified.statusText, size: 1,
checksum: "abc", destination: fixture.backup.lastPathComponent,
destinationPath: fixture.backup.appendingPathComponent("a.mov").path, wasReused: true),
ResultRow(path: "b.mov", status: ResultOutcome.failed.statusText, size: 1, checksum: nil,
destination: fixture.secondBackup.lastPathComponent,
destinationPath: fixture.secondBackup.appendingPathComponent("b.mov").path)]
try capture(TransferRecoveryDetails(record: retry, history: [retry, previous])
.padding().frame(maxWidth: .infinity, alignment: .leading),
size: CGSize(width: 393, height: 480), name: "iphone-recovery-details")
try capture(TransferRecoveryDetails(record: retry, history: [retry, previous])
.padding().frame(maxWidth: .infinity, alignment: .leading),
size: CGSize(width: 820, height: 600), name: "ipad-recovery-details")
}

private func settle() async {
for _ in 0..<8 { await Task.yield() }
try? await Task.sleep(nanoseconds: 100_000_000)
Expand Down
28 changes: 26 additions & 2 deletions BitMatch/App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,13 @@ struct MacMainView: View {
.environment(\.hiddenQueueRowID, queueFlight?.rowID)
.overlay(alignment: .topLeading) { queueFlightCard }
.onPreferenceChange(ComposerFrameKey.self) { frame in
if let frame { composerFrame = frame }
if let frame {
composerFrame = frame
if queueFlight?.returnsToComposer == true, frame != flightTarget {
flightTarget = frame
withAnimation(.spring(duration: 0.45, bounce: 0.1)) { queueFlight?.frame = frame }
}
}
}
.onPreferenceChange(QueueRowFramesKey.self) { frames in
queueRowFramesChanged(frames)
Expand All @@ -271,6 +277,7 @@ struct MacMainView: View {
let rowID: UUID
let text: String
var frame: CGRect
var returnsToComposer = false
}

@ViewBuilder
Expand Down Expand Up @@ -318,7 +325,7 @@ struct MacMainView: View {
queueRowFrames = frames
// The new row is still sliding into place while the card flies; keep
// aiming at where it actually is.
if let flight = queueFlight, let target = frames[flight.rowID], target != flightTarget {
if let flight = queueFlight, !flight.returnsToComposer, let target = frames[flight.rowID], target != flightTarget {
flightTarget = target
withAnimation(.spring(duration: 0.45, bounce: 0.1)) { queueFlight?.frame = target }
}
Expand Down Expand Up @@ -546,6 +553,7 @@ struct MacMainView: View {
) {
queueTransitionClearTask?.cancel()
var committedID: UUID?
let previousEditingID = coordinator.editingSetupTransferID
let animation: Animation = reduceMotion
? .easeInOut(duration: 0.18)
: .spring(duration: 0.4, bounce: 0.18)
Expand All @@ -554,6 +562,22 @@ struct MacMainView: View {
withAnimation(animation) {
committedID = operation()
}
if !reduceMotion, previousEditingID == nil,
let id = coordinator.editingSetupTransferID,
let from = queueRowFrames[id], let to = composerFrame,
let source = coordinator.sourceURL {
clearedComposer = nil
pendingFlightRow = nil
flightEndTask?.cancel()
flightTarget = to
queueFlight = QueueFlight(rowID: id, text: composerSummary(for: source), frame: from, returnsToComposer: true)
withAnimation(.spring(duration: 0.55, bounce: 0.12)) { queueFlight?.frame = to }
flightEndTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(700))
guard !Task.isCancelled else { return }
withAnimation(.easeOut(duration: 0.12)) { queueFlight = nil }
}
}
guard committedID != nil else { return }
queueTransitionClearTask = Task {
try? await Task.sleep(for: .milliseconds(500))
Expand Down
26 changes: 26 additions & 0 deletions BitMatchTests/LocalTransferJournalTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,43 @@ struct LocalTransferJournalTests {
verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()
)
var object = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(record)) as? [String: Any])
var oldSource = try #require(object["source"] as? [String: Any])
oldSource.removeValue(forKey: "creationDate")
object["source"] = oldSource
object.removeValue(forKey: "sourceFingerprint")
object.removeValue(forKey: "independentDestinationCount")

let decoded = try JSONDecoder().decode(
LocalTransferRecord.self,
from: JSONSerialization.data(withJSONObject: object)
)
#expect(decoded.source.creationDate == nil)
#expect(decoded.retryOf == nil)
#expect(decoded.plannedFileCounts == nil)
#expect(decoded.sourceFingerprint == nil)
#expect(decoded.independentDestinationCount == nil)
}

@Test func recoveryExportsKeepLineageAndCountsWithoutAccessTokens() throws {
let f = try fixture()
defer { try? FileManager.default.removeItem(at: f.root) }
var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: f.source),
destinations: [try LocalTransferResource(url: f.destination)], verificationMode: .standard,
cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs(), retryOf: UUID())
record.plannedFileCounts = [2]
record.results = [ResultRow(path: "a.mov", status: ResultOutcome.verified.statusText, size: 1,
checksum: "abc", destination: "Backup", wasReused: true)]
let json = try TransferHistoryDocument(record: record, asCSV: false)
let payload = try #require(JSONSerialization.jsonObject(with: json.data) as? [String: Any])
#expect(payload["retryOf"] as? String == record.retryOf?.uuidString)
#expect(payload["plannedFileCounts"] as? [Int] == [2])
#expect(!String(decoding: json.data, as: UTF8.self).contains(record.source.bookmark.base64EncodedString()))
let csv = String(decoding: try TransferHistoryDocument(record: record, asCSV: true).data, as: UTF8.self)
#expect(csv.contains("client,reused"))
#expect(csv.contains("\"retryOf\",\"\(try #require(record.retryOf).uuidString)\""))
#expect(csv.contains("\"plannedFileCounts\",\"2\""))
}

@Test func runningAttemptPersistsIndependentDestinationCount() throws {
let f = try fixture()
defer { try? FileManager.default.removeItem(at: f.root) }
Expand Down
76 changes: 76 additions & 0 deletions BitMatchTests/LocalTransferQueueIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ final class LocalTransferQueueIntegrationTests: XCTestCase {
for record in [first, second] {
let row = try XCTUnwrap(record.results.first)
XCTAssertTrue(row.isSuccessStatus)
XCTAssertEqual(record.plannedFileCounts, [1])
let sourceData = try Data(contentsOf: URL(fileURLWithPath: row.path))
let copiedData = try Data(contentsOf: URL(fileURLWithPath: XCTUnwrap(row.destinationPath)))
XCTAssertEqual(sourceData, copiedData)
Expand All @@ -72,6 +73,55 @@ final class LocalTransferQueueIntegrationTests: XCTestCase {
XCTAssertTrue(onDisk.allSatisfy { $0.state == .completed && $0.results.count == 1 })
}

func testRealRecoveryMatrixRetainsConflictsAndCountsReusedAndNewCopies() async throws {
for mode in [VerificationMode.standard, .thorough, .paranoid] {
let f = try QueueFixture()
defer { f.cleanup() }
let secondBackup = f.root.appendingPathComponent("Backup B")
let output = f.destination.appendingPathComponent(f.source.lastPathComponent)
for url in [secondBackup, output] { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) }
let existing = try Data(contentsOf: f.source.appendingPathComponent("clip.mov"))
try existing.write(to: output.appendingPathComponent("clip.mov"))
try Data("new file".utf8).write(to: f.source.appendingPathComponent("new.bin"))
try Data("original source contents".utf8).write(to: f.source.appendingPathComponent("conflict.bin"))
let conflict = Data("pre-existing contents must survive".utf8)
try conflict.write(to: output.appendingPathComponent("conflict.bin"))
let journal = LocalTransferJournal(fileURL: f.journalURL)
let original = try journal.enqueue(sourceURL: f.source, destinationURLs: [f.destination, secondBackup],
verificationMode: mode, cameraSettings: CameraLabelSettings(),
reportSettings: ReportPrefs(makeReport: false), generateASCMHL: false)
try journal.markRunning(id: original)
try journal.recordPlan(id: original, fileCounts: [3, 3])
try journal.interrupt(id: original, summary: "Previous interrupted attempt", results: [])
let retry = try journal.requeue(id: original)
let operations = TransferPipeline(fileSystem: MacOSFileSystemService.shared, checksum: ChecksumEngine.shared)
let coordinator = SharedAppCoordinator(platformManager: QueuePlatformManager(fileOperations: operations),
transferJournal: journal, defaults: f.defaults)
coordinator.startQueue()
let ended = await waitUntil(timeout: .seconds(20)) { @MainActor in
journal.records.first { $0.id == retry }?.state == .issues && !coordinator.isOperationInProgress
}
XCTAssertTrue(ended, coordinator.queueMessage ?? "Recovery did not settle")
let record = try XCTUnwrap(journal.records.first { $0.id == retry })
XCTAssertEqual(record.retryOf, original)
XCTAssertEqual(record.plannedFileCounts, [3, 3])
let counts = TransferRecoverySummary.make(record)
XCTAssertEqual(counts.count, 2)
XCTAssertEqual(counts[0].reused, 1)
XCTAssertEqual(counts[0].copied, 1)
XCTAssertEqual(counts[0].failed, 1)
XCTAssertEqual(counts[0].pending, 0)
XCTAssertEqual(counts[1].copied, 3)
XCTAssertEqual(counts[1].failed, 0)
XCTAssertEqual(counts[1].pending, 0)
XCTAssertEqual(try Data(contentsOf: output.appendingPathComponent("conflict.bin")), conflict)
XCTAssertEqual(try Data(contentsOf: f.source.appendingPathComponent("clip.mov")), existing)
XCTAssertEqual(try Data(contentsOf: f.source.appendingPathComponent("conflict.bin")), Data("original source contents".utf8))
XCTAssertEqual(journal.records.first { $0.id == original }?.state, .interrupted)
XCTAssertTrue(journal.records.first { $0.id == original }?.results.isEmpty == true)
}
}

func testQueuedEmptySourceIsRejectedAndNeverLooksComplete() async throws {
let f = try QueueFixture()
defer { f.cleanup() }
Expand Down Expand Up @@ -968,6 +1018,32 @@ final class LocalTransferQueueIntegrationTests: XCTestCase {
).isEmpty)
}

func testReplacementAfterPreflightNeverReachesTheCopyExecutor() async throws {
for replaceSource in [true, false] {
let f = try QueueFixture()
defer { f.cleanup() }
let selected = replaceSource ? f.source : f.destination
let journal = LocalTransferJournal(fileURL: f.journalURL) { _ in
try FileManager.default.moveItem(at: selected, to: f.root.appendingPathComponent("original-moved"))
try FileManager.default.createDirectory(at: selected, withIntermediateDirectories: true)
try Data("replacement".utf8).write(to: selected.appendingPathComponent("different.mov"))
}
let id = try journal.enqueue(sourceURL: f.source, destinationURLs: [f.destination],
verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs())
let operations = QueueRecordingOperations()
let coordinator = SharedAppCoordinator(platformManager: QueuePlatformManager(fileOperations: operations),
transferJournal: journal, defaults: f.defaults)
coordinator.startQueue()
let paused = await waitUntil { @MainActor in coordinator.queuePausedRecordID == id }
XCTAssertTrue(paused)
let starts = await operations.starts
XCTAssertTrue(starts.isEmpty)
XCTAssertEqual(journal.records.first?.state, .failed)
XCTAssertNil(journal.records.first?.startedAt)
XCTAssertTrue(journal.records.first?.results.isEmpty == true)
}
}

func testMarkRunningFailureIsJournaledAsSkippableFailureBeforePause() async throws {
struct InjectedStartError: LocalizedError {
var errorDescription: String? { "Injected markRunning failure" }
Expand Down
Loading
Loading