diff --git a/BitMatchTests/LocalTransferJournalTests.swift b/BitMatchTests/LocalTransferJournalTests.swift index 1290ed7d..f1ec4399 100644 --- a/BitMatchTests/LocalTransferJournalTests.swift +++ b/BitMatchTests/LocalTransferJournalTests.swift @@ -530,17 +530,24 @@ struct LocalTransferJournalTests { let id = try journal.enqueue(sourceURL: f.source, destinationURLs: [f.destination], verificationMode: .quick, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) try journal.markRunning(id: id) - try journal.finish(id: id, results: [ResultRow(path: "file", status: "✅ Copied", size: 1, checksum: nil, destination: "Backup")], + try journal.finish(id: id, results: [ResultRow(path: "file", status: "✅ Match", size: 1, checksum: nil, destination: "Backup")], summary: "Done", hadIssues: false) let record = try #require(journal.records.first) + #expect(record.results.first?.status == "✅ Match") let csv = try TransferHistoryDocument(record: record, asCSV: true) let csvText = String(decoding: csv.data, as: UTF8.self) #expect(csvText.contains("verification,transfer_state,transfer_summary")) #expect(csvText.contains("\"Quick\",\"issues\"")) #expect(csvText.contains("Quick mode only compares file sizes")) + #expect(csvText.contains("reused,recorded_status")) + #expect(csvText.contains("\"Copied · contents not verified\"")) + #expect(csvText.contains("\"✅ Match\"")) let json = try TransferHistoryDocument(record: record, asCSV: false) let payload = try #require(JSONSerialization.jsonObject(with: json.data) as? [String: Any]) #expect(payload["state"] as? String == "issues") + let exportedRow = try #require((payload["results"] as? [[String: Any]])?.first) + #expect(exportedRow["status"] as? String == "Copied · contents not verified") + #expect(exportedRow["recordedStatus"] as? String == "✅ Match") #expect(payload["source"] as? String == f.source.path) #expect(payload["bookmark"] == nil) #expect(!String(decoding: json.data, as: UTF8.self).contains(record.source.bookmark.base64EncodedString())) @@ -566,6 +573,7 @@ struct LocalTransferJournalTests { #expect(payload["clientName"] as? String == "Studio") #expect(payload["ascMHLRequested"] as? Bool == true) #expect((payload["results"] as? [[String: Any]])?.count == 1) + #expect((payload["results"] as? [[String: Any]])?.first?["recordedStatus"] == nil) let csv = try TransferHistoryDocument(record: record, asCSV: true) let csvText = String(decoding: csv.data, as: UTF8.self) diff --git a/BitMatchTests/ReportEvidenceBytesTests.swift b/BitMatchTests/ReportEvidenceBytesTests.swift index dbb381e0..02117f9c 100644 --- a/BitMatchTests/ReportEvidenceBytesTests.swift +++ b/BitMatchTests/ReportEvidenceBytesTests.swift @@ -206,6 +206,18 @@ final class ReportEvidenceBytesTests: XCTestCase { XCTAssertTrue(csv.contains("Source Rereads Avoided,0"), csv) } + func testCSVDoesNotCallAnUnverifiedCopyVerifiedBecauseItHasAHash() throws { + let row = ResultRow(path: "/card/clip.mov", status: ResultOutcome.copiedUnverified.statusText, + size: 10, checksum: "source-only-hash", destination: "Backup") + let csv = try ReportExporter.makeEnhancedCSV( + results: [row], started: Date(), duration: 1, filesPerSecond: 1, + photographerContext: nil, prefs: ReportPrefs(verificationMode: .standard) + ) + let dataRow = try XCTUnwrap(csv.components(separatedBy: "\n").dropFirst().first) + XCTAssertTrue(dataRow.contains("✅ Copied"), dataRow) + XCTAssertFalse(dataRow.contains("Verified"), dataRow) + } + func testJSONUsesPhaseDurationSecondKeysAndQuickOmitsVerify() throws { var prefs = ReportPrefs() prefs.verificationMode = .quick diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceWriter.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceWriter.swift index fc80fd47..22f6ee1d 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceWriter.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceWriter.swift @@ -526,7 +526,7 @@ public enum EvidenceWriter: Sendable { let camera = project?.camera ?? "" let card = project?.card ?? "" let packagePath = project?.packagePath ?? "" - let verified = isMatchStatus(result.status) && result.checksum?.isEmpty == false && prefs?.verificationMode != .quick + let verified = result.isVerifiedStatus && result.checksum?.isEmpty == false && prefs?.verificationMode != .quick let details = verified ? "Verified" : result.status // Per-file times are not measured; the column stays for existing diff --git a/Shared/Core/Models/TransferLibraryPresentation.swift b/Shared/Core/Models/TransferLibraryPresentation.swift index f0c8602f..c3ada515 100644 --- a/Shared/Core/Models/TransferLibraryPresentation.swift +++ b/Shared/Core/Models/TransferLibraryPresentation.swift @@ -1,6 +1,17 @@ import Foundation import BitMatchEngine +/// Keep older Quick rows' original status in the journal, but present their +/// successful copy as unverified everywhere a person reads it. +enum TransferHistoryResultPresentation { + static func statusText(for row: ResultRow, mode: VerificationMode) -> String { + if mode == .quick && row.isSuccessStatus { + return "Copied · contents not verified" + } + return row.status + } +} + /// What History shows for each journal record, and which records raise the /// "review in History" banner on Mac, iPad and iPhone. /// Pure values, so every platform shows the same state the same way. diff --git a/Shared/Core/Services/TransferHistoryDocument.swift b/Shared/Core/Services/TransferHistoryDocument.swift index dc286a89..bc57e83c 100644 --- a/Shared/Core/Services/TransferHistoryDocument.swift +++ b/Shared/Core/Services/TransferHistoryDocument.swift @@ -18,12 +18,14 @@ struct TransferHistoryDocument: FileDocument { results: record.results, includeModePlan: record.plannedFileCounts != nil) if asCSV { func quote(_ value: String) -> String { "\"" + value.replacingOccurrences(of: "\"", with: "\"\"") + "\"" } - let header = "source,destination,status,bytes,checksum,verification,transfer_state,transfer_summary,project,asc_mhl,client,reused\n" + let header = "source,destination,status,bytes,checksum,verification,transfer_state,transfer_summary,project,asc_mhl,client,reused,recorded_status\n" let rows = record.results.map { row in - [row.path, row.destinationPath ?? row.destination ?? "", row.status, String(row.size), row.checksum ?? "", + [row.path, row.destinationPath ?? row.destination ?? "", + TransferHistoryResultPresentation.statusText(for: row, mode: record.verificationMode), + String(row.size), row.checksum ?? "", record.verificationMode.rawValue, record.state.rawValue, record.summary, record.reportSettings.projectName, record.generateASCMHL ? "requested" : "not requested", - record.reportSettings.clientName, row.wasReused ? "true" : "false"].map(quote).joined(separator: ",") + record.reportSettings.clientName, row.wasReused ? "true" : "false", row.status].map(quote).joined(separator: ",") } func pair(_ key: String, _ value: String) -> String { quote(key) + "," + quote(value) } func detail(_ value: String) -> String { @@ -80,7 +82,7 @@ struct TransferHistoryDocument: FileDocument { let copyDurationSeconds: TimeInterval? let verifyDurationSeconds: TimeInterval? let performanceTelemetry: TransferPerformanceTelemetry? - let results: [ResultRow] + let results: [HistoryResult] } let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] @@ -96,7 +98,35 @@ struct TransferHistoryDocument: FileDocument { copyDurationSeconds: record.copyDurationSeconds, verifyDurationSeconds: record.verifyDurationSeconds, performanceTelemetry: record.performanceTelemetry, - results: record.results)) + results: record.results.map { HistoryResult(row: $0, mode: record.verificationMode) })) } } } + +/// Export the interpreted status without changing the saved journal row. +/// `recordedStatus` retains old wording when it could overstate verification. +private struct HistoryResult: Encodable { + let id: UUID + let path: String + let status: String + let recordedStatus: String? + let size: Int64 + let checksum: String? + let destination: String? + let destinationPath: String? + let clipIntegrity: ClipIntegrityFinding? + let wasReused: Bool? + + init(row: ResultRow, mode: VerificationMode) { + id = row.id + path = row.path + status = TransferHistoryResultPresentation.statusText(for: row, mode: mode) + recordedStatus = status == row.status ? nil : row.status + size = row.size + checksum = row.checksum + destination = row.destination + destinationPath = row.destinationPath + clipIntegrity = row.clipIntegrity + wasReused = row.wasReused ? true : nil + } +} diff --git a/Shared/Views/TransferLibraryView.swift b/Shared/Views/TransferLibraryView.swift index d1e84c2a..a73b568b 100644 --- a/Shared/Views/TransferLibraryView.swift +++ b/Shared/Views/TransferLibraryView.swift @@ -353,17 +353,6 @@ struct TransferLibraryView: View { } } -/// History keeps the original row status in exports, but Quick's older -/// "Match" wording must not be presented as content verification on screen. -enum TransferHistoryResultPresentation { - static func statusText(for row: ResultRow, mode: VerificationMode) -> String { - if mode == .quick && row.isSuccessStatus { - return "Copied · contents not verified" - } - return row.status - } -} - /// Attaches `.searchable` only when it makes sense, so the field never /// appears empty over an empty Queue and never grabs focus on its own. private struct OptionalSearchable: ViewModifier {