From 053250655e1c6ada46bd56ff2c9d11b667270916 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Fri, 16 Oct 2026 23:02:22 -0400 Subject: [PATCH] Clarify verification evidence in history and reports --- .../TransferDetailsReportTests.swift | 40 ++++++- BitMatchTests/ReportExporterPDFTests.swift | 53 +++++++++ .../TransferDetailsReportTests.swift | 104 +++++++++++++++++- .../TransferLibraryPresentationTests.swift | 41 +++++++ .../Evidence/EvidenceWriter.swift | 35 +++++- .../Evidence/VerificationScopeEvidence.swift | 59 ++++++++++ .../BitMatchEngine/TransferJournal.swift | 9 ++ .../QueueAdmissionTests.swift | 27 +++++ .../VerificationScopeEvidenceTests.swift | 45 ++++++++ Shared/Core/Models/ReportSummary.swift | 2 + .../Models/TransferLibraryPresentation.swift | 19 ++++ Shared/Core/Services/ReportExporter.swift | 25 ++++- .../Services/TransferHistoryDocument.swift | 14 +++ Shared/Views/ReportView.swift | 51 +++++++-- Shared/Views/TransferLibraryView.swift | 36 +++++- 15 files changed, 539 insertions(+), 21 deletions(-) create mode 100644 Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/VerificationScopeEvidence.swift create mode 100644 Packages/BitMatchEngine/Tests/BitMatchEngineTests/VerificationScopeEvidenceTests.swift diff --git a/BitMatch-iPadTests/TransferDetailsReportTests.swift b/BitMatch-iPadTests/TransferDetailsReportTests.swift index 94a144b9..6cd951cf 100644 --- a/BitMatch-iPadTests/TransferDetailsReportTests.swift +++ b/BitMatch-iPadTests/TransferDetailsReportTests.swift @@ -22,9 +22,16 @@ struct TransferDetailsReportTests { #expect(object["operatorName"] as? String == record.reportSettings.operatorName) #expect(object["notes"] as? String == record.reportSettings.notes) #expect(object["state"] as? String == LocalTransferState.issues.rawValue) + let scope = try #require(object["verificationScope"] as? [String: Any]) + #expect(scope["selectedMode"] as? String == "Standard") + #expect(scope["recordedVerifiedResults"] as? Int == 0) + #expect(scope["recordedSelectedResults"] as? Int == 1) + #expect(scope["configuredChecks"] == nil) + #expect(object["selectedFilePlanRecorded"] 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")) 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"] @@ -37,6 +44,11 @@ struct TransferDetailsReportTests { #expect(text.contains("Operator: Alex")) #expect(text.contains(record.reportSettings.notes)) #expect(text.contains("NOT SAFE TO ERASE")) + #expect(text.contains("1 recorded source file · 1 issue")) + #expect(!text.contains("Empty")) + #expect(!text.contains("Check plan:")) + #expect(!text.contains("no separate card reread")) + #expect(text.contains("Original selected-file plan was not saved")) #expect(record.state == .issues) } @@ -48,17 +60,43 @@ struct TransferDetailsReportTests { destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) record.state = .issues; record.summary = "Selected files verified" + record.plannedFileCounts = [1] record.results = [ResultRow(path: "/card/clip.mov", status: ResultOutcome.verified.statusText, size: 5, checksum: "fixture", destination: "Backup"), ResultRow(path: "/card/._clip.mov", status: ResultOutcome.excludedAppleDouble.statusText, size: 42, checksum: nil, destination: "Backup")] let document = try #require(PDFDocument(data: try await ReportExporter.historyPDF(record: record, includeThumbnails: false))) let text = try #require(document.string) - #expect(text.contains("0 file issues")) + #expect(text.contains("0 issues")) #expect(!text.contains("Issues Detail")) #expect(text.contains("1 intentionally excluded result")) + #expect(text.contains("Check plan:")) #expect(text.contains("AppleDouble metadata was intentionally excluded")) + #expect(text.contains("Saved: 1/1 selected backup results marked verified")) #expect(text.contains("NOT SAFE TO ERASE")) + #expect(!text.contains("Selected files verified")) + #expect(!text.contains("VERIFICATION SUCCESSFUL")) + } + + @Test func completedPartialPlanStaysUnsafeInHistoryPDF() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("ipad-completed-partial-report-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.state = .completed + record.summary = "Transfer complete" + record.plannedFileCounts = [2] + record.results = [ResultRow(path: "/card/clip.mov", status: ResultOutcome.verified.statusText, + size: 5, checksum: "fixture", destination: "Backup", + destinationPath: root.appendingPathComponent("clip.mov").path)] + #expect(TransferLibraryPresentation.safetyState(for: record) == .needsAttention) + let pdf = try #require(PDFDocument(data: try await ReportExporter.historyPDF(record: record, includeThumbnails: false))) + let text = try #require(pdf.string) + #expect(text.contains("Saved results do not cover the selected-file plan")) + #expect(text.contains("NOT SAFE TO ERASE")) + #expect(!text.contains("Transfer complete")) #expect(!text.contains("VERIFICATION SUCCESSFUL")) } diff --git a/BitMatchTests/ReportExporterPDFTests.swift b/BitMatchTests/ReportExporterPDFTests.swift index 8184d544..0f08f8f4 100644 --- a/BitMatchTests/ReportExporterPDFTests.swift +++ b/BitMatchTests/ReportExporterPDFTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import PDFKit @testable import BitMatch import BitMatchEngine @@ -53,4 +54,56 @@ struct ReportExporterPDFTests { #expect(files.contains { $0.pathExtension == "csv" }) #expect(files.contains { $0.pathExtension == "json" }) } + + @Test + func automaticReportSeparatesSelectedChecksFromRecordedResults() async throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("bitmatch-assurance-report-\(UUID())", isDirectory: true) + let destination = root.appendingPathComponent("Backup", isDirectory: true) + try fm.createDirectory(at: destination, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + + let rows = [ + ResultRow(path: "/card/clip.mov", status: ResultOutcome.verified.statusText, + size: 5, checksum: "digest", destination: destination.path), + ResultRow(path: "/card/other.mov", status: ResultOutcome.failed.statusText, + size: 5, checksum: nil, destination: destination.path), + ResultRow(path: "/card/._clip.mov", status: ResultOutcome.excludedAppleDouble.statusText, + size: 1, checksum: nil, destination: destination.path) + ] + try await ReportExporter.export( + mode: .copyAndVerify, jobID: UUID(), started: Date(timeIntervalSince1970: 1_800_000_000), + finished: Date(timeIntervalSince1970: 1_800_000_060), sourceURL: URL(fileURLWithPath: "/card"), + destinationURLs: [destination], results: rows, fileCount: rows.count, matchCount: 1, + prefs: ReportPrefs(verificationMode: .standard, makeReport: true), workers: 1, + totalBytesProcessed: 10, safetyState: .needsAttention + ) + let reports = destination.appendingPathComponent("Reports", isDirectory: true) + let files = try fm.contentsOfDirectory(at: reports, includingPropertiesForKeys: nil) + let pdfURL = try #require(files.first { $0.pathExtension == "pdf" }) + let pdf = try #require(PDFDocument(data: Data(contentsOf: pdfURL))) + let text = try #require(pdf.string) + #expect(text.contains("Check plan:")) + #expect(text.contains("no separate card reread")) + #expect(text.contains("Saved: 1/2 selected backup results marked verified")) + #expect(text.contains("NOT SAFE TO ERASE")) + + let csvURL = try #require(files.first { $0.pathExtension == "csv" }) + let csv = try String(contentsOf: csvURL, encoding: .utf8) + #expect(csv.contains("Recorded Verified Backup Results,1")) + #expect(csv.contains("Recorded Excluded Source Files,1")) + #expect(csv.contains("selected mode does not prove every check finished")) + + let jsonURL = try #require(files.first { $0.pathExtension == "json" }) + let json = try Data(contentsOf: jsonURL) + let object = try #require(JSONSerialization.jsonObject(with: json) as? [String: Any]) + let verification = try #require(object["verification"] as? [String: Any]) + let scope = try #require(verification["scope"] as? [String: Any]) + #expect(scope["selectedMode"] as? String == "Standard") + #expect(scope["recordedSelectedResults"] as? Int == 2) + #expect(scope["recordedVerifiedResults"] as? Int == 1) + #expect(scope["recordedExcludedSourceFiles"] as? Int == 1) + #expect(object["safeToErase"] as? Bool == false) + #expect(EvidenceReader.snapshot(reportData: json, reportURL: jsonURL) != nil) + } } diff --git a/BitMatchTests/TransferDetailsReportTests.swift b/BitMatchTests/TransferDetailsReportTests.swift index 1a8de6dd..f0213226 100644 --- a/BitMatchTests/TransferDetailsReportTests.swift +++ b/BitMatchTests/TransferDetailsReportTests.swift @@ -22,9 +22,16 @@ struct TransferDetailsReportTests { #expect(object["operatorName"] as? String == record.reportSettings.operatorName) #expect(object["notes"] as? String == record.reportSettings.notes) #expect(object["state"] as? String == LocalTransferState.issues.rawValue) + let scope = try #require(object["verificationScope"] as? [String: Any]) + #expect(scope["selectedMode"] as? String == "Standard") + #expect(scope["recordedVerifiedResults"] as? Int == 0) + #expect(scope["recordedSelectedResults"] as? Int == 1) + #expect(scope["configuredChecks"] == nil) + #expect(object["selectedFilePlanRecorded"] 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")) 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"] @@ -37,6 +44,11 @@ struct TransferDetailsReportTests { #expect(text.contains("Operator: Alex")) #expect(text.contains(record.reportSettings.notes)) #expect(text.contains("NOT SAFE TO ERASE")) + #expect(text.contains("1 recorded source file · 1 issue")) + #expect(!text.contains("Empty")) + #expect(!text.contains("Check plan:")) + #expect(!text.contains("no separate card reread")) + #expect(text.contains("Original selected-file plan was not saved")) #expect(record.state == .issues) } @@ -48,17 +60,107 @@ struct TransferDetailsReportTests { destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) record.state = .issues; record.summary = "Selected files verified" + record.plannedFileCounts = [1] record.results = [ResultRow(path: "/card/clip.mov", status: ResultOutcome.verified.statusText, size: 5, checksum: "fixture", destination: "Backup"), ResultRow(path: "/card/._clip.mov", status: ResultOutcome.excludedAppleDouble.statusText, size: 42, checksum: nil, destination: "Backup")] let document = try #require(PDFDocument(data: try await ReportExporter.historyPDF(record: record, includeThumbnails: false))) let text = try #require(document.string) - #expect(text.contains("0 file issues")) + #expect(text.contains("0 issues")) #expect(!text.contains("Issues Detail")) #expect(text.contains("1 intentionally excluded result")) + #expect(text.contains("Check plan:")) #expect(text.contains("AppleDouble metadata was intentionally excluded")) + #expect(text.contains("Saved: 1/1 selected backup results marked verified")) #expect(text.contains("NOT SAFE TO ERASE")) + #expect(!text.contains("Selected files verified")) + #expect(!text.contains("VERIFICATION SUCCESSFUL")) + } + + @Test func legacyQuickMatchCannotBecomeVerifiedInHistoryPDF() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("quick-legacy-report-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [try LocalTransferResource(url: root)], verificationMode: .quick, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.state = .issues + record.summary = "Selected files verified" + record.plannedFileCounts = [1] + record.results = [ResultRow(path: "/card/clip.mov", status: "✅ Match", size: 5, + checksum: nil, destination: "Backup"), + ResultRow(path: "/card/._clip.mov", status: ResultOutcome.excludedAppleDouble.statusText, + size: 42, checksum: nil, destination: "Backup")] + let pdf = try #require(PDFDocument(data: try await ReportExporter.historyPDF(record: record, includeThumbnails: false))) + let text = try #require(pdf.string) + #expect(text.contains("Selected Quick")) + #expect(text.contains("Size checks only; file contents are not verified")) + #expect(text.contains("Saved: 0/1 selected backup results marked verified")) + #expect(text.contains("Quick mode did not verify file contents")) + #expect(text.contains("NOT SAFE TO ERASE")) + #expect(!text.contains("Selected files verified")) + #expect(!text.contains("VERIFICATION SUCCESSFUL")) + let json = try #require(JSONSerialization.jsonObject(with: TransferHistoryDocument(record: record, asCSV: false).data) as? [String: Any]) + #expect(json["summary"] as? String == "Selected files verified") + let csv = String(decoding: try TransferHistoryDocument(record: record, asCSV: true).data, as: UTF8.self) + #expect(csv.contains("Selected files verified")) + } + + @Test func quickHistoryRowLabelDoesNotRewriteExportedEvidence() throws { + let legacy = ResultRow(path: "/card/clip.mov", status: "✅ Match", size: 5, + checksum: nil, destination: "Backup") + let failed = ResultRow(path: "/card/bad.mov", status: ResultOutcome.failed.statusText, + size: 5, checksum: nil, destination: "Backup") + #expect(TransferHistoryResultPresentation.statusText(for: legacy, mode: .quick) == "Copied · contents not verified") + #expect(TransferHistoryResultPresentation.statusText(for: legacy, mode: .standard) == "✅ Match") + #expect(TransferHistoryResultPresentation.statusText(for: failed, mode: .quick) == failed.status) + #expect(legacy.status == "✅ Match") + } + + @Test func partialSelectedPlanDoesNotClaimAllFilesVerifiedInHistoryPDF() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("partial-plan-report-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.state = .issues + record.summary = "Incomplete attempt" + record.plannedFileCounts = [2] + record.results = [ResultRow(path: "/card/clip.mov", status: ResultOutcome.verified.statusText, + size: 5, checksum: "fixture", destination: "Backup"), + ResultRow(path: "/card/._clip.mov", status: ResultOutcome.excludedAppleDouble.statusText, + size: 42, checksum: nil, destination: "Backup")] + let pdf = try #require(PDFDocument(data: try await ReportExporter.historyPDF(record: record, includeThumbnails: false))) + let text = try #require(pdf.string) + #expect(text.contains("Saved: 1/1 selected backup results marked verified")) + #expect(text.contains("Recorded selected results are marked verified")) + #expect(!text.contains("Selected files verified")) + #expect(text.contains("NOT SAFE TO ERASE")) + } + + @Test func completedPartialPlanStaysUnsafeInHistoryAndPDF() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("completed-partial-report-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + var record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root), + destinations: [try LocalTransferResource(url: root)], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs()) + record.state = .completed + record.summary = "Transfer complete" + record.plannedFileCounts = [2] + record.results = [ResultRow(path: "/card/clip.mov", status: ResultOutcome.verified.statusText, + size: 5, checksum: "fixture", destination: "Backup", + destinationPath: root.appendingPathComponent("clip.mov").path)] + #expect(TransferLibraryPresentation.savedResultsCoverSelectedPlan(record) == false) + #expect(TransferLibraryPresentation.safetyState(for: record) == .needsAttention) + #expect(TransferLibraryPresentation.stateLabel(for: record).tint != .green) + let pdf = try #require(PDFDocument(data: try await ReportExporter.historyPDF(record: record, includeThumbnails: false))) + let text = try #require(pdf.string) + #expect(text.contains("Saved results do not cover the selected-file plan")) + #expect(text.contains("NOT SAFE TO ERASE")) + #expect(!text.contains("Transfer complete")) #expect(!text.contains("VERIFICATION SUCCESSFUL")) } diff --git a/BitMatchTests/TransferLibraryPresentationTests.swift b/BitMatchTests/TransferLibraryPresentationTests.swift index 978ce82b..01e8e921 100644 --- a/BitMatchTests/TransferLibraryPresentationTests.swift +++ b/BitMatchTests/TransferLibraryPresentationTests.swift @@ -67,6 +67,47 @@ struct TransferLibraryPresentationTests { #expect(label.tint != .green) } + @Test func completedHistoryRequiresEveryPlannedSelectedFileOncePerDestination() { + var record = PresentationTestSupport.record(state: .completed) + let destination = record.destinations[0].url + let first = PresentationTestSupport.row(.verified, destination: destination) + let duplicate = ResultRow(path: first.path, status: first.status, size: first.size, + checksum: first.checksum, destination: first.destination, + destinationPath: first.destinationPath) + let second = ResultRow(path: "/Card/A002.mov", status: ResultOutcome.verified.statusText, + size: 10, checksum: "def", destination: "Backup", + destinationPath: destination.appendingPathComponent("A002.mov").path) + record.plannedFileCounts = [2] + record.results = [first] + #expect(TransferLibraryPresentation.savedResultsCoverSelectedPlan(record) == false) + #expect(TransferLibraryPresentation.safetyState(for: record) == .needsAttention) + record.results = [first, duplicate] + #expect(TransferLibraryPresentation.savedResultsCoverSelectedPlan(record) == false) + #expect(TransferLibraryPresentation.safetyState(for: record) == .needsAttention) + record.results = [first, second] + #expect(TransferLibraryPresentation.savedResultsCoverSelectedPlan(record) == true) + #expect(TransferLibraryPresentation.safetyState(for: record) == .safeToErase) + record.plannedFileCounts = [2, 2] + #expect(TransferLibraryPresentation.savedResultsCoverSelectedPlan(record) == false) + #expect(TransferLibraryPresentation.safetyState(for: record) == .needsAttention) + record.plannedFileCounts = nil + record.results = [first] + #expect(TransferLibraryPresentation.savedResultsCoverSelectedPlan(record) == nil) + #expect(TransferLibraryPresentation.safetyState(for: record) == .safeToErase) + } + + @Test func plannedSelectedCountDoesNotIncludeAppleDoubleExclusions() { + var record = PresentationTestSupport.record(state: .completed) + let destination = record.destinations[0].url + record.plannedFileCounts = [1] + record.results = [PresentationTestSupport.row(.verified, destination: destination), + ResultRow(path: "/Card/._A001.mov", status: ResultOutcome.excludedAppleDouble.statusText, + size: 2, checksum: nil, destination: "Backup", + destinationPath: destination.appendingPathComponent("._A001.mov").path)] + #expect(TransferLibraryPresentation.savedResultsCoverSelectedPlan(record) == true) + #expect(TransferLibraryPresentation.safetyState(for: record) == .needsAttention) + } + /// Every pill supplies words and a symbol, so color is never the only cue. @Test func everyStateHasWordsAndASymbol() { let labels = allStates.map { state -> TransferLibraryPresentation.StateLabel in diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceWriter.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceWriter.swift index 5eebf8dc..fc80fd47 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceWriter.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/EvidenceWriter.swift @@ -208,12 +208,17 @@ public struct EnhancedJSONReport: Codable, Sendable public let algorithm: String? public let issuesByType: [String: Int] public let checksumCache: CacheStats? + /// New reports record the selected plan separately from saved results. + /// Older reports omit this field; method/algorithm alone do not prove + /// that a card reread or a complete transfer happened. + public let scope: VerificationScopeEvidence? - public init(method: String, algorithm: String?, issuesByType: [String: Int], checksumCache: CacheStats?) { + public init(method: String, algorithm: String?, issuesByType: [String: Int], checksumCache: CacheStats?, scope: VerificationScopeEvidence? = nil) { self.method = method self.algorithm = algorithm self.issuesByType = issuesByType self.checksumCache = checksumCache + self.scope = scope } } @@ -405,7 +410,10 @@ public enum EvidenceWriter: Sendable { verifyDurationSeconds: verifyDurationSeconds, performanceTelemetry: performanceTelemetry, project: projectCSV, - prefs: prefs) + prefs: prefs, + scope: kind == .copyAndVerify && safeToErase != nil + ? prefs.verificationMode.map { VerificationScopeEvidence(selectedMode: $0, results: results) } + : nil) try Task.checkCancellation() // Save enhanced JSON report @@ -473,7 +481,8 @@ public enum EvidenceWriter: Sendable { verifyDurationSeconds: TimeInterval?, performanceTelemetry: TransferPerformanceTelemetry?, project: ProjectCSVEvidence? = nil, - prefs: ReportPrefs? = nil) throws { + prefs: ReportPrefs? = nil, + scope: VerificationScopeEvidence? = nil) throws { let csvContent = try makeEnhancedCSV( results: results, started: started, @@ -483,7 +492,8 @@ public enum EvidenceWriter: Sendable { verifyDurationSeconds: verifyDurationSeconds, performanceTelemetry: performanceTelemetry, project: project, - prefs: prefs + prefs: prefs, + scope: scope ) try csvContent.data(using: .utf8)?.write(to: url) } @@ -497,7 +507,8 @@ public enum EvidenceWriter: Sendable { verifyDurationSeconds: TimeInterval? = nil, performanceTelemetry: TransferPerformanceTelemetry? = nil, project: ProjectCSVEvidence?, - prefs: ReportPrefs? = nil + prefs: ReportPrefs? = nil, + scope: VerificationScopeEvidence? = nil ) throws -> String { var csvContent = csvRow([ "Status", "File Path", "Target Path", "Job", "Photographer", "Camera", "Card", "Package Path", "Details", "Timestamp", "Bytes", "Checksum" @@ -547,6 +558,15 @@ public enum EvidenceWriter: Sendable { csvContent += csvRow(["Verified", String(results.filter { $0.isVerifiedStatus }.count)]) csvContent += csvRow(["Copied, not verified", String(results.filter { isMatchStatus($0.status) && !$0.isVerifiedStatus }.count)]) csvContent += csvRow(["Issues", String(results.filter { !isMatchStatus($0.status) && ResultOutcome(statusText: $0.status) != .excludedAppleDouble }.count)]) + if let scope { + csvContent += csvRow(["Selected Mode", scope.selectedMode.rawValue]) + csvContent += csvRow(["Selected Mode Checks", scope.configuredChecks ?? "Not recorded for this attempt"]) + csvContent += csvRow(["Recorded Selected Source Files", String(scope.recordedSelectedSourceFiles)]) + csvContent += csvRow(["Recorded Selected Backup Results", String(scope.recordedSelectedResults)]) + csvContent += csvRow(["Recorded Verified Backup Results", String(scope.recordedVerifiedResults)]) + csvContent += csvRow(["Recorded Excluded Source Files", String(scope.recordedExcludedSourceFiles)]) + csvContent += csvRow(["Evidence Scope", "Recorded results only; selected mode does not prove every check finished"]) + } csvContent += csvRow(["Duration", "\(String(format: "%.2f", duration)) seconds"]) if copyDurationSeconds != nil || verifyDurationSeconds != nil { csvContent += csvRow(["Copy Duration", copyDurationSeconds.map { "\(String(format: "%.2f", $0)) seconds" } ?? ""]) @@ -808,7 +828,10 @@ public enum EvidenceWriter: Sendable { method: verificationDescription(for: prefs).method, algorithm: verificationDescription(for: prefs).algorithm, issuesByType: issuesByType, - checksumCache: nil // Would need to track cache stats during operation + checksumCache: nil, // Would need to track cache stats during operation + scope: kind == .copyAndVerify && safeToErase != nil + ? prefs.verificationMode.map { VerificationScopeEvidence(selectedMode: $0, results: results) } + : nil ), results: results.map { JSONReportItem(from: $0) }, photographyJob: project, diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/VerificationScopeEvidence.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/VerificationScopeEvidence.swift new file mode 100644 index 00000000..6e2d9945 --- /dev/null +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/Evidence/VerificationScopeEvidence.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Describes the selected copy mode and the result rows that were actually +/// recorded. A mode is a check plan, not proof that an interrupted run finished +/// those checks. Counts are per backup result, except for distinct source files. +public struct VerificationScopeEvidence: Codable, Sendable { + public let selectedMode: VerificationMode + public let configuredChecks: String? + public let countsBasis: String + public let recordedSelectedSourceFiles: Int + public let recordedSelectedResults: Int + public let recordedVerifiedResults: Int + public let recordedExcludedSourceFiles: Int + + public init(selectedMode: VerificationMode, results: [ResultRow], includeModePlan: Bool = true) { + self.selectedMode = selectedMode + configuredChecks = includeModePlan ? Self.checkPlan(for: selectedMode) : nil + countsBasis = "recorded-results-only" + let selected = results.filter { ResultOutcome(statusText: $0.status) != .excludedAppleDouble } + recordedSelectedSourceFiles = Set(selected.map(\.path)).count + recordedSelectedResults = selected.count + // Quick cannot verify contents, even if a malformed or old status + // string claims a match. + recordedVerifiedResults = selectedMode == .quick ? 0 : selected.filter(\.isVerifiedStatus).count + recordedExcludedSourceFiles = Set(results.filter { + ResultOutcome(statusText: $0.status) == .excludedAppleDouble + }.map(\.path)).count + } + + public static func checkPlan(for mode: VerificationMode) -> String { + switch mode { + case .quick: + "Size checks only; file contents are not verified." + case .standard: + "New copies: SHA-256 during copy, then backup SHA-256 readback; no separate card reread. Reuse is checked separately." + case .thorough: + "New copies: separate card reread and backup readback; SHA-256 and MD5 compared. Reuse is checked separately." + case .paranoid: + "New copies: separate card reread, byte comparison with each backup, and SHA-256 readback. Reuse is checked separately." + } + } + + public var selectedModeLine: String { + if let configuredChecks { + return "Selected \(selectedMode.rawValue) mode: \(configuredChecks)" + } + return "Selected \(selectedMode.rawValue) mode; detailed checks were not recorded for this older attempt." + } + + public var recordedResultsLine: String { + let sourceNoun = recordedSelectedSourceFiles == 1 ? "file" : "files" + return "\(recordedVerifiedResults)/\(recordedSelectedResults) saved backup results marked verified across \(recordedSelectedSourceFiles) recorded selected source \(sourceNoun). Counts describe saved rows, not a new check." + } + + public var exclusionLine: String? { + guard recordedExcludedSourceFiles > 0 else { return nil } + return "\(recordedExcludedSourceFiles) AppleDouble source files intentionally excluded; the whole card was not backed up." + } +} diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift index dde59e7f..9c290a78 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift @@ -672,6 +672,15 @@ public final class TransferJournal: Sendable { groups.reduce(0, { $0 + $1.count }) == record.results.count, let expectedPaths = groups.first.map({ Set($0.map(\.path)) }), !expectedPaths.isEmpty else { return false } + if let planned = record.plannedFileCounts { + guard planned.count == groups.count, + zip(groups, planned).allSatisfy({ rows, count in + let selected = rows.filter { + ResultOutcome(statusText: $0.status) != .excludedAppleDouble + } + return selected.count == count && Set(selected.map(\.path)).count == count + }) else { return false } + } return groups.allSatisfy { Set($0.map(\.path)) == expectedPaths } } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift index b62533fc..4b0f4a07 100644 --- a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/QueueAdmissionTests.swift @@ -97,4 +97,31 @@ struct QueueAdmissionTests { let encoder = JSONEncoder(); encoder.outputFormatting = .sortedKeys #expect(try encoder.encode(decoded.last) == encoder.encode(JSONDecoder().decode(LocalTransferRecord.self, from: original))) } + + @Test func priorBackupHintRequiresEveryPlannedFileExactlyOnce() throws { + let f = try fixture() + defer { try? FileManager.default.removeItem(at: f.root) } + func row(_ name: String) -> ResultRow { + ResultRow(path: name, status: ResultOutcome.verified.statusText, size: 1, + checksum: "fixture", destination: "Backup", + destinationPath: f.backup.appendingPathComponent(name).path) + } + func complete(plan: [Int]?, results: [ResultRow], fingerprint: String) throws { + let id = try f.journal.enqueue(sourceURL: f.source, destinationURLs: [f.backup], + verificationMode: .standard, cameraSettings: CameraLabelSettings(), + reportSettings: ReportPrefs()) + try f.journal.markRunning(id: id) + if let plan { try f.journal.recordPlan(id: id, fileCounts: plan) } + try f.journal.finish(id: id, results: results, summary: "Done", hadIssues: false, + sourceFingerprint: fingerprint) + } + try complete(plan: [2], results: [row("a.mov")], fingerprint: "missing") + #expect(f.journal.matchingVerifiedRecord(sourceFingerprint: "missing") == nil) + try complete(plan: [2], results: [row("a.mov"), row("a.mov")], fingerprint: "duplicate") + #expect(f.journal.matchingVerifiedRecord(sourceFingerprint: "duplicate") == nil) + try complete(plan: [2], results: [row("a.mov"), row("b.mov")], fingerprint: "complete") + #expect(f.journal.matchingVerifiedRecord(sourceFingerprint: "complete") != nil) + try complete(plan: nil, results: [row("a.mov")], fingerprint: "legacy") + #expect(f.journal.matchingVerifiedRecord(sourceFingerprint: "legacy") != nil) + } } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/VerificationScopeEvidenceTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/VerificationScopeEvidenceTests.swift new file mode 100644 index 00000000..0f66b4c1 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/VerificationScopeEvidenceTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import BitMatchEngine + +struct VerificationScopeEvidenceTests { + @Test func recordsSavedRowsWithoutPromotingTheSelectedPlanToCompletion() throws { + let rows = [ + ResultRow(path: "/card/clip.mov", status: ResultOutcome.verified.statusText, + size: 5, checksum: "digest", destination: "A"), + ResultRow(path: "/card/clip.mov", status: ResultOutcome.failed.statusText, + size: 5, checksum: nil, destination: "B"), + ResultRow(path: "/card/._clip.mov", status: ResultOutcome.excludedAppleDouble.statusText, + size: 1, checksum: nil, destination: "A"), + ResultRow(path: "/card/._clip.mov", status: ResultOutcome.excludedAppleDouble.statusText, + size: 1, checksum: nil, destination: "B") + ] + let scope = VerificationScopeEvidence(selectedMode: .thorough, results: rows) + #expect(scope.selectedMode == .thorough) + #expect(scope.recordedSelectedSourceFiles == 1) + #expect(scope.recordedSelectedResults == 2) + #expect(scope.recordedVerifiedResults == 1) + #expect(scope.recordedExcludedSourceFiles == 1) + #expect(scope.countsBasis == "recorded-results-only") + #expect(scope.configuredChecks?.contains("separate card reread") == true) + #expect(scope.recordedResultsLine.contains("1/2 saved backup results marked verified")) + #expect(scope.recordedResultsLine.contains("1 recorded selected source file.")) + #expect(scope.exclusionLine?.contains("whole card was not backed up") == true) + let encoded = try JSONEncoder().encode(scope) + #expect(try JSONDecoder().decode(VerificationScopeEvidence.self, from: encoded).recordedVerifiedResults == 1) + } + + @Test func modeDescriptionsStayDistinctAndQuickNeverClaimsAContentCheck() { + let oldMatch = ResultRow(path: "/card/clip.mov", status: "✅ Match", size: 5, + checksum: "legacy", destination: "A") + let quick = VerificationScopeEvidence(selectedMode: .quick, results: [oldMatch]) + #expect(quick.recordedVerifiedResults == 0) + #expect(quick.configuredChecks?.contains("contents are not verified") == true) + let older = VerificationScopeEvidence(selectedMode: .thorough, results: [oldMatch], includeModePlan: false) + #expect(older.configuredChecks == nil) + #expect(older.selectedModeLine.contains("detailed checks were not recorded")) + #expect(VerificationScopeEvidence.checkPlan(for: .standard).contains("no separate card reread")) + #expect(VerificationScopeEvidence.checkPlan(for: .thorough).contains("MD5")) + #expect(VerificationScopeEvidence.checkPlan(for: .paranoid).contains("byte comparison")) + } +} diff --git a/Shared/Core/Models/ReportSummary.swift b/Shared/Core/Models/ReportSummary.swift index be08b8d6..86cdaad9 100644 --- a/Shared/Core/Models/ReportSummary.swift +++ b/Shared/Core/Models/ReportSummary.swift @@ -22,6 +22,8 @@ struct ReportSummary { let company: String let verificationMethod: String var verificationMode: VerificationMode? = nil + var verificationScope: VerificationScopeEvidence? = nil + var verificationScopeNote: String? = nil let totalBytesProcessed: Int64 let averageSpeed: Double // MB/s var copyDurationSeconds: TimeInterval? = nil diff --git a/Shared/Core/Models/TransferLibraryPresentation.swift b/Shared/Core/Models/TransferLibraryPresentation.swift index 30409160..f0c8602f 100644 --- a/Shared/Core/Models/TransferLibraryPresentation.swift +++ b/Shared/Core/Models/TransferLibraryPresentation.swift @@ -212,9 +212,28 @@ enum TransferLibraryPresentation { summaries.allSatisfy({ !$0.rows.isEmpty && $0.rows.allSatisfy(accepts) }), let expectedPaths = summaries.first.map({ Set($0.rows.map(\.path)) }), !expectedPaths.isEmpty else { return false } + if record.plannedFileCounts != nil, savedResultsCoverSelectedPlan(record) != true { return false } return summaries.allSatisfy { Set($0.rows.map(\.path)) == expectedPaths } } + /// A saved plan counts selected source files per destination. Excluded + /// AppleDouble rows remain in the evidence, but are outside those counts. + /// Nil preserves the older records for which no selected-file plan exists. + static func savedResultsCoverSelectedPlan(_ record: LocalTransferRecord) -> Bool? { + guard let planned = record.plannedFileCounts else { return nil } + let summaries = DestinationResultSummary.make(rows: record.results, destinations: record.destinations.map(\.url)) + guard !summaries.isEmpty, summaries.count == record.destinations.count, + planned.count == summaries.count else { return false } + let selected = summaries.map { summary in + summary.rows.filter { ResultOutcome(statusText: $0.status) != .excludedAppleDouble } + } + guard zip(selected, planned).allSatisfy({ rows, count in + rows.count == count && Set(rows.map(\.path)).count == count + }) else { return false } + let firstPaths = Set(selected[0].map(\.path)) + return selected.allSatisfy { Set($0.map(\.path)) == firstPaths } + } + /// The buttons a record offers. Mirrors the journal's own rules /// (`LocalTransferState.canRetry`, project cards retried from their project). struct Actions: Equatable, Sendable { diff --git a/Shared/Core/Services/ReportExporter.swift b/Shared/Core/Services/ReportExporter.swift index 94a98274..7b2258cc 100644 --- a/Shared/Core/Services/ReportExporter.swift +++ b/Shared/Core/Services/ReportExporter.swift @@ -50,7 +50,7 @@ enum ReportExporter { try? PhotographerReportPayload.make(context: $0, results: results, finishedAt: finished) } - let summary = ReportSummary( + var summary = ReportSummary( jobID: jobID, started: started, finished: finished, @@ -79,6 +79,9 @@ enum ReportExporter { notes: EvidenceWriter.normalizedNotes(reportPrefs.notes), safetyState: safetyState ) + if mode == .copyAndVerify, let selectedMode = prefs.verificationMode { + summary.verificationScope = VerificationScopeEvidence(selectedMode: selectedMode, results: results) + } let shouldGenerateFullReport = generateFullReport && prefs.makeReport @@ -154,10 +157,15 @@ enum ReportExporter { let files = Set(record.results.map(\.path)).count let start = record.startedAt ?? record.createdAt let finish = record.endedAt ?? start - let notes = [EvidenceWriter.normalizedNotes(record.reportSettings.notes), record.summary, + let safetyState = TransferLibraryPresentation.safetyState(for: record) + // The saved summary may say "verified" even when the saved rows or + // selected-file plan do not support that verdict. Keep it in the + // History record/CSV/JSON, but omit it from unsafe regenerated PDFs. + let notes = [EvidenceWriter.normalizedNotes(record.reportSettings.notes), + safetyState == .safeToErase ? record.summary : nil, "Regenerated from saved history. No new verification was performed. Recorded results only.", record.startedAt == nil || record.endedAt == nil ? "Some original transfer timing is unavailable." : nil].compactMap { $0 }.joined(separator: "\n") - let summary = ReportSummary(jobID: record.id, started: start, finished: finish, mode: .copyAndVerify, + var summary = ReportSummary(jobID: record.id, started: start, finished: finish, mode: .copyAndVerify, source: record.source.url.path, destinations: record.destinations.map { $0.url.path }, totalFiles: files, matched: record.results.filter(\.isVerifiedStatus).count, issues: record.results.filter { !$0.isSuccessStatus && ResultOutcome(statusText: $0.status) != .excludedAppleDouble }.count, @@ -167,8 +175,17 @@ enum ReportExporter { totalBytesProcessed: recordedBytes, averageSpeed: 0, copyDurationSeconds: record.copyDurationSeconds, verifyDurationSeconds: record.verifyDurationSeconds, clientLogoData: nil, companyLogoData: nil, photographyJob: nil, operatorName: EvidenceWriter.normalizedNotes(record.reportSettings.operatorName), notes: notes, - safetyState: TransferLibraryPresentation.safetyState(for: record), + safetyState: safetyState, hasRecordedTiming: record.startedAt != nil && record.endedAt != nil) + summary.verificationScope = VerificationScopeEvidence(selectedMode: record.verificationMode, + results: record.results, includeModePlan: record.plannedFileCounts != nil) + if record.plannedFileCounts == nil { + summary.verificationScopeNote = "Original selected-file plan was not saved; this export describes recorded results only." + } else if TransferLibraryPresentation.savedResultsCoverSelectedPlan(record) == false { + summary.verificationScopeNote = "Saved results do not cover the selected-file plan." + } else if [.interrupted, .cancelled, .failed].contains(record.state) { + summary.verificationScopeNote = "Attempt ended before completion; the selected mode does not mean every check finished." + } try Task.checkCancellation() return await MainActor.run { ReportPDFRenderer.renderPDF(summary: summary, results: record.results, thumbnails: thumbnails) } } diff --git a/Shared/Core/Services/TransferHistoryDocument.swift b/Shared/Core/Services/TransferHistoryDocument.swift index e5a5e027..dc286a89 100644 --- a/Shared/Core/Services/TransferHistoryDocument.swift +++ b/Shared/Core/Services/TransferHistoryDocument.swift @@ -14,6 +14,8 @@ struct TransferHistoryDocument: FileDocument { func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) } init(record: LocalTransferRecord, asCSV: Bool) throws { + let scope = VerificationScopeEvidence(selectedMode: record.verificationMode, + 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" @@ -34,6 +36,15 @@ struct TransferHistoryDocument: FileDocument { "\n# Summary", pair("retryOf", record.retryOf?.uuidString ?? ""), pair("plannedFileCounts", record.plannedFileCounts?.map(String.init).joined(separator: ";") ?? ""), + pair("selectedMode", scope.selectedMode.rawValue), + pair("selectedModeChecks", scope.configuredChecks ?? "Not recorded for this attempt"), + pair("recordedSelectedSourceFiles", String(scope.recordedSelectedSourceFiles)), + pair("recordedSelectedBackupResults", String(scope.recordedSelectedResults)), + pair("recordedVerifiedBackupResults", String(scope.recordedVerifiedResults)), + pair("recordedExcludedSourceFiles", String(scope.recordedExcludedSourceFiles)), + pair("evidenceScope", record.plannedFileCounts == nil + ? "Original selected-file plan was not saved; export describes recorded results only" + : "Saved results only; selected mode does not prove every check finished"), pair("operatorName", detail(record.reportSettings.operatorName)), pair("notes", detail(record.reportSettings.notes)), pair("copyDurationSeconds", record.copyDurationSeconds.map { String($0) } ?? ""), @@ -59,6 +70,8 @@ struct TransferHistoryDocument: FileDocument { let summary: String let createdAt: Date let verificationMode: VerificationMode + let verificationScope: VerificationScopeEvidence + let selectedFilePlanRecorded: Bool let projectName: String let clientName: String let operatorName: String? @@ -75,6 +88,7 @@ struct TransferHistoryDocument: FileDocument { data = try encoder.encode(Report(id: record.id, retryOf: record.retryOf, plannedFileCounts: record.plannedFileCounts, source: record.source.url.path, destinations: record.destinations.map { $0.url.path }, state: record.state, summary: record.summary, createdAt: record.createdAt, verificationMode: record.verificationMode, + verificationScope: scope, selectedFilePlanRecorded: record.plannedFileCounts != nil, projectName: record.reportSettings.projectName, clientName: record.reportSettings.clientName, operatorName: EvidenceWriter.normalizedNotes(record.reportSettings.operatorName), notes: EvidenceWriter.normalizedNotes(record.reportSettings.notes), diff --git a/Shared/Views/ReportView.swift b/Shared/Views/ReportView.swift index 5ee882a5..d03ba42d 100644 --- a/Shared/Views/ReportView.swift +++ b/Shared/Views/ReportView.swift @@ -71,7 +71,21 @@ struct ReportView: View { } private var verifiedFileCount: Int { - rows.filter(TransferOutcomePresentation.isVerified).count + // Older Quick records may carry a legacy "✅ Match" status. That + // text cannot turn a size-only check into content verification. + guard !selectedQuickMode else { return 0 } + return rows.filter(TransferOutcomePresentation.isVerified).count + } + + private var selectedQuickMode: Bool { + s.verificationScope?.selectedMode == .quick || s.verificationMode == .quick + } + + private func statusPresentation(for row: ResultRow) -> ResultStatusPresentation { + if selectedQuickMode && row.isSuccessStatus { + return ResultStatusPresentation(tone: .unverified, symbol: "doc.on.doc") + } + return ResultStatusPresentation.make(status: row.status) } private var issueCount: Int { @@ -91,7 +105,10 @@ struct ReportView: View { if s.safetyState == .needsAttention, s.photographyJob == nil, excludedResultCount > 0, reportIssueRows.isEmpty, !selected.isEmpty, selected.allSatisfy(TransferOutcomePresentation.isVerified) { - return "Selected files verified. AppleDouble metadata was intentionally excluded; the whole card is not backed up." + if selectedQuickMode { + return "Quick mode did not verify file contents. AppleDouble metadata was intentionally excluded; the whole card was not backed up." + } + return "Recorded selected results are marked verified. AppleDouble metadata was intentionally excluded; the whole card is not backed up." } return Self.unsafeReportReason(safetyState: s.safetyState, photographyJob: s.photographyJob) } @@ -300,7 +317,7 @@ struct ReportView: View { } private func pdfManifestRow(_ row: ResultRow, showPreview: Bool) -> some View { - let status = ResultStatusPresentation.make(status: row.status) + let status = statusPresentation(for: row) let source = ResultPathMatch.comparablePath(s.source) let path = ResultPathMatch.comparablePath(row.path) let display = path.hasPrefix(source + "/") ? String(path.dropFirst(source.count + 1)) : row.fileName @@ -419,13 +436,33 @@ struct ReportView: View { private var pdfSummarySection: some View { VStack(alignment: .leading, spacing: 6) { - Text("\(s.mode == .copyAndVerify ? "Copy & Verify" : "Check") · \(s.verificationMethod)") + Text("\(s.mode == .copyAndVerify ? "Copy & Verify" : "Check") · \(s.verificationScope.map { "Selected \($0.selectedMode.rawValue)" } ?? s.verificationMethod)") .font(.system(size: 12, weight: .semibold)) - Text("\(s.totalFiles) \(s.totalFiles == 1 ? "file" : "files") · \(verifiedFileCount) verified \(verifiedFileCount == 1 ? "copy" : "copies") · \(issueCount) file \(issueCount == 1 ? "issue" : "issues") · \(totalSizeFormatted)") - .font(.system(size: 10)) + if let scope = s.verificationScope { + let sourceNoun = scope.recordedSelectedSourceFiles == 1 ? "file" : "files" + let issueNoun = issueCount == 1 ? "issue" : "issues" + // A zero sum with no successful saved rows is not evidence + // that the source or attempted transfer was empty. + let byteSuffix = rows.contains(where: \.isSuccessStatus) + ? " · \(s.totalBytesProcessed == 0 ? "0 B" : totalSizeFormatted)" : "" + Text("Saved: \(scope.recordedVerifiedResults)/\(scope.recordedSelectedResults) selected backup results marked verified · \(scope.recordedSelectedSourceFiles) recorded source \(sourceNoun) · \(issueCount) \(issueNoun)\(byteSuffix)") + .font(.system(size: 10)) + if let checks = scope.configuredChecks { + Text("Check plan: \(checks)") + .font(.system(size: 9)).foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + Text("\(s.totalFiles) \(s.totalFiles == 1 ? "file" : "files") · \(verifiedFileCount) verified \(verifiedFileCount == 1 ? "copy" : "copies") · \(issueCount) file \(issueCount == 1 ? "issue" : "issues") · \(totalSizeFormatted)") + .font(.system(size: 10)) + } if rows.contains(where: { ResultOutcome(statusText: $0.status) == .excludedAppleDouble }) { Text("\(excludedResultCount) intentionally excluded \(excludedResultCount == 1 ? "result" : "results")").font(.system(size: 10)) } + if let note = s.verificationScopeNote { + Text(note).font(.system(size: 9)).foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } if s.hasRecordedTiming { Text("Elapsed: \(duration)" + (s.averageSpeed > 0 ? " · \(throughput)" : "")) .font(.system(size: 10)).foregroundColor(.secondary) @@ -754,7 +791,7 @@ struct ReportView: View { private func manifestDataRow(for row: ResultRow, compact: Bool = false) -> some View { HStack(spacing: compact ? 8 : 12) { - let status = ResultStatusPresentation.make(status: row.status) + let status = statusPresentation(for: row) Image(systemName: status.symbol) .foregroundColor(status.color) .font(.system(size: 10)) diff --git a/Shared/Views/TransferLibraryView.swift b/Shared/Views/TransferLibraryView.swift index 0e81eeaf..d1e84c2a 100644 --- a/Shared/Views/TransferLibraryView.swift +++ b/Shared/Views/TransferLibraryView.swift @@ -251,8 +251,28 @@ struct TransferLibraryView: View { @ViewBuilder private func detailsView(_ record: LocalTransferRecord, actions: TransferLibraryPresentation.Actions) -> some View { let safetyState = TransferLibraryPresentation.safetyState(for: record) + let scope = VerificationScopeEvidence(selectedMode: record.verificationMode, + results: record.results, includeModePlan: record.plannedFileCounts != nil) VStack(alignment: .leading, spacing: 8) { - Text(record.summary).fixedSize(horizontal: false, vertical: true) + Label(safetyState.title, systemImage: safetyState.symbol) + .font(.caption.weight(.semibold)).foregroundStyle(safetyState.tint.color) + Text("Saved attempt message: \(record.summary)") + .foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Text(scope.selectedModeLine).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Text(scope.recordedResultsLine).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + if let exclusion = scope.exclusionLine { + Text(exclusion).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + if record.plannedFileCounts == nil { + Text("Original selected-file plan was not saved; these details describe recorded results only.") + .foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } else if TransferLibraryPresentation.savedResultsCoverSelectedPlan(record) == false { + Text("Saved results do not cover the selected-file plan.") + .foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } else if [.interrupted, .cancelled, .failed].contains(record.state) { + Text("The attempt ended before completion; the selected mode does not mean every check finished.") + .foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } if let operatorName = EvidenceWriter.normalizedNotes(record.reportSettings.operatorName) { Text("Operator: \(operatorName)").font(.caption).textSelection(.enabled) } @@ -290,7 +310,8 @@ struct TransferLibraryView: View { ForEach(record.results.prefix(100)) { row in VStack(alignment: .leading) { Text(row.fileName) - Text("\(row.destination ?? "Destination"): \(row.status)").foregroundStyle(.secondary) + Text("\(row.destination ?? "Destination"): \(TransferHistoryResultPresentation.statusText(for: row, mode: record.verificationMode))") + .foregroundStyle(.secondary) } .accessibilityElement(children: .combine) } @@ -332,6 +353,17 @@ 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 {