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
5 changes: 5 additions & 0 deletions BitMatch-iPad/Views/ModularContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,11 @@ struct SettingsSheetView: View {
var body: some View {
NavigationView {
Form {
Section("File handling") {
AppleDoubleSelectionView(review: coordinator.appleDoubleSelection)
.disabled(coordinator.isOperationInProgress || coordinator.isReplayingQueuedTransfer)
}

Section(GeneralSettingsPresentation.notificationsSection) {
Toggle(GeneralSettingsPresentation.notifyAttention, isOn: $generalSettings.notifyWhenCardNeedsAttention)
Toggle(GeneralSettingsPresentation.notifyFinish, isOn: $generalSettings.notifyWhenTransferOrQueueFinishes)
Expand Down
13 changes: 11 additions & 2 deletions BitMatch-iPadTests/ReportThumbnailTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ struct ReportThumbnailTests {

@Test func automaticExporterHonorsPreferenceWithoutPuttingImagesInJSON() async throws {
let (root, _, row) = try seed(); defer { try? FileManager.default.removeItem(at: root) }
var prefs = ReportPrefs(makeReport: true)
var prefs = ReportPrefs(notes: "Card 01 · keep until reviewed", makeReport: true, operatorName: "Alex & Sam")
prefs.includeThumbnails = true
prefs.verificationMode = .standard
try await ReportExporter.export(mode: .copyAndVerify, jobID: UUID(), started: Date(), finished: Date(),
Expand All @@ -184,13 +184,17 @@ struct ReportThumbnailTests {
let pdf = try #require(files.first { $0.pathExtension == "pdf" })
let text = try #require(PDFDocument(url: pdf)?.string)
#expect(text.contains("Clip previews: 1/1"))
#expect(text.contains("Operator: Alex & Sam"))
#expect(text.contains(prefs.notes))
#expect(text.contains("VERIFICATION SUCCESSFUL"))
let json = files.filter { $0.pathExtension == "json" }
#expect(!json.isEmpty)
for url in json {
let value = try String(contentsOf: url, encoding: .utf8)
#expect(!value.contains("images"))
#expect(!value.contains("base64"))
#expect(value.contains("Alex & Sam"))
#expect(value.contains(prefs.notes))
}
#expect(!text.contains("Performance Metrics"))
#expect(!text.contains("Top File Types"))
Expand Down Expand Up @@ -226,11 +230,16 @@ struct ReportThumbnailTests {
source: "/card", destinations: [root.path], totalFiles: rows.count, matched: rows.count, issues: 0,
workers: 1, appVersion: "test", osVersion: "test", client: "", production: "", company: "",
verificationMethod: "Standard", totalBytesProcessed: row.size * Int64(rows.count), averageSpeed: 0,
clientLogoData: nil, companyLogoData: nil, photographyJob: nil, safetyState: .safeToErase)
clientLogoData: nil, companyLogoData: nil, photographyJob: nil, operatorName: "Alex & Sam · " + String(repeating: "Long operator name ", count: 12),
notes: (0..<180).map { "NOTE-\($0)-END · retained handoff notes" }.joined(separator: "\n"),
safetyState: .safeToErase)
let document = try #require(PDFDocument(data: ReportPDFRenderer.renderPDF(summary: summary, results: rows, thumbnails: previews)))
#expect(document.pageCount > 1)
#expect(try #require(document.page(at: 0)?.string).contains("NOTE-0-END"))
let text = try #require(document.string)
for index in 0..<75 { #expect(text.components(separatedBy: "clip-\(index).mp4").count == 2) }
for index in 0..<180 { #expect(text.contains("NOTE-\(index)-END")) }
#expect(text.contains("Operator: Alex & Sam"))
if let directory = (ProcessInfo.processInfo.environment["BITMATCH_PREVIEW_PDF_OUTPUT"] ?? ProcessInfo.processInfo.environment["TEST_RUNNER_BITMATCH_PREVIEW_PDF_OUTPUT"]),
let data = document.dataRepresentation() {
try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent("preview-pagination.pdf"))
Expand Down
89 changes: 89 additions & 0 deletions BitMatch-iPadTests/TransferDetailsReportTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import Foundation
import Testing
import PDFKit
@testable import BitMatch_iPad
import BitMatchEngine

@MainActor
struct TransferDetailsReportTests {
@Test func historyExportsKeepSavedDetailsAndUnsafeVerdict() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("details-history-\(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(notes: "Keep card, backup B missing", operatorName: "Alex, \"DIT\""))
record.state = .issues
record.summary = "Transfer incomplete"
record.results = [ResultRow(path: "/card/clip.mov", status: ResultOutcome.failed.statusText, size: 5,
checksum: nil, destination: "Backup")]
let json = try TransferHistoryDocument(record: record, asCSV: false).data
let object = try #require(JSONSerialization.jsonObject(with: json) as? [String: Any])
#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 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))
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"]
?? ProcessInfo.processInfo.environment["TEST_RUNNER_BITMATCH_WORKFLOW_SNAPSHOT_OUTPUT_DIR"] {
let directory = URL(fileURLWithPath: path, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try pdfData.write(to: directory.appendingPathComponent("transfer-details-history.pdf"))
}
let text = try #require(pdf.string)
#expect(text.contains("Operator: Alex"))
#expect(text.contains(record.reportSettings.notes))
#expect(text.contains("NOT SAFE TO ERASE"))
#expect(record.state == .issues)
}

@Test func excludedMetadataIsDisclosedWithoutInventingFileErrors() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("excluded-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 = "Selected files verified"
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("Issues Detail"))
#expect(text.contains("1 intentionally excluded result"))
#expect(text.contains("AppleDouble metadata was intentionally excluded"))
#expect(text.contains("NOT SAFE TO ERASE"))
#expect(!text.contains("VERIFICATION SUCCESSFUL"))
}

@Test func csvDetailsCannotBecomeSpreadsheetFormulas() throws {
let root = FileManager.default.temporaryDirectory
let record = LocalTransferRecord(id: UUID(), createdAt: Date(), source: try LocalTransferResource(url: root),
destinations: [], verificationMode: .standard, cameraSettings: CameraLabelSettings(),
reportSettings: ReportPrefs(notes: " =1+1", operatorName: "@SUM(1,2)"))
let csv = String(decoding: try TransferHistoryDocument(record: record, asCSV: true).data, as: UTF8.self)
#expect(csv.contains("'@SUM(1,2)"))
#expect(csv.contains("' =1+1"))
let json = try #require(JSONSerialization.jsonObject(with: TransferHistoryDocument(record: record, asCSV: false).data) as? [String: Any])
#expect(json["operatorName"] as? String == "@SUM(1,2)")
}

@Test func rememberedDetailsRoundTripOnThisPlatform() throws {
let name = "BitMatch.DetailsTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let store = ReportPrefsStore(defaults: defaults)
store.save(ReportPrefs(notes: "Next card", operatorName: "Alex"))
#expect(store.load().operatorName == "Alex")
#expect(store.load().notes == "Next card")
var cleared = store.load(); cleared.operatorName = ""; cleared.notes = ""; store.save(cleared)
#expect(store.load().operatorName.isEmpty)
#expect(store.load().notes.isEmpty)
}
}
22 changes: 22 additions & 0 deletions BitMatch-iPadTests/WorkflowSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,28 @@ final class WorkflowSnapshotTests: XCTestCase {
}
}

func testCaptureAppleDoubleSettings() throws {
try requireCaptureConfiguration()
let suite = "AppleDoubleCapture-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let review = AppleDoubleReviewModel(defaults: defaults)
XCTAssertTrue(review.enabled)
for width in [320.0, 500.0, 820.0] {
try capture(Form { Section("File handling") { AppleDoubleSelectionView(review: review) } },
size: CGSize(width: width, height: 300), name: "mobile-appledouble-settings-\(Int(width))")
}
}

func testCaptureTransferDetails() throws {
try requireCaptureConfiguration()
let prefs = ReportPrefs(notes: "Card 01 · backups to Alex.\nKeep the source until both drives are checked.", operatorName: "Alex Morgan")
for width in [320.0, 500.0, 820.0] {
try capture(TransferDetailsSection(prefs: .constant(prefs), isExpanded: true).padding(),
size: CGSize(width: width, height: 400), name: "mobile-transfer-details-\(Int(width))")
}
}

private func requireCaptureConfiguration() throws {
let environment = ProcessInfo.processInfo.environment
let enabled = environment["BITMATCH_CAPTURE_WORKFLOW_SNAPSHOTS"] == "1"
Expand Down
7 changes: 7 additions & 0 deletions BitMatch/Views/PreferencesWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ struct PreferencesWindow: View {
Toggle(GeneralSettingsPresentation.playSounds, isOn: $generalSettings.playSounds)
}

Section("File handling") {
AppleDoubleSelectionView(review: coordinator.appleDoubleSelection)
.disabled(coordinator.isOperationInProgress || coordinator.isReplayingQueuedTransfer)
}

Section("Updates") {
Toggle(
"Check for updates automatically",
Expand Down Expand Up @@ -221,6 +226,7 @@ struct PreferencesWindow: View {

if coordinator.reportSettings.makeReport {
Section {
reportField("Operator", text: $coordinator.reportSettings.operatorName, prompt: "Operator name")
reportField("Client", text: $coordinator.reportSettings.clientName, prompt: "Client name")
reportField("Project", text: $coordinator.reportSettings.projectName, prompt: "Project name")
reportField("Production", text: $coordinator.reportSettings.production, prompt: "Production title")
Expand Down Expand Up @@ -370,6 +376,7 @@ private extension PreferencesWindow {
prefs.projectName = ""
prefs.production = ""
prefs.company = ""
prefs.operatorName = ""
prefs.notes = ""
coordinator.reportSettings = prefs
}
Expand Down
35 changes: 33 additions & 2 deletions BitMatchTests/AppleDoubleWorkflowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ final class AppleDoubleWorkflowTests: XCTestCase {
let journal = LocalTransferJournal(fileURL: folders.journalURL)
let id = try journal.enqueue(sourceURL: folders.source, destinationURLs: [folders.primary, folders.secondary],
verificationMode: .standard, cameraSettings: settings, reportSettings: reports, generateASCMHL: false)
folders.defaults.set(false, forKey: "BitMatchExcludeAppleDouble")
let coordinator = SharedAppCoordinator(platformManager: MacOSPlatformManager.shared,
transferJournal: journal, defaults: folders.defaults)
XCTAssertFalse(coordinator.appleDoubleSelection.enabled)
Expand All @@ -125,6 +126,33 @@ final class AppleDoubleWorkflowTests: XCTestCase {
}
}

func testQueuedPreservePolicySurvivesNewIgnoreDefault() async throws {
try await FileOperationsTestLock.shared.run {
let folders = try CoordinatorFolders(); defer { folders.cleanup() }
try seed(folders.source)
var reports = ReportPrefs(); reports.makeReport = false
let journal = LocalTransferJournal(fileURL: folders.journalURL)
let id = try journal.enqueue(sourceURL: folders.source, destinationURLs: [folders.primary],
verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: reports,
generateASCMHL: false)
let coordinator = SharedAppCoordinator(platformManager: MacOSPlatformManager.shared,
transferJournal: journal, defaults: folders.defaults)
XCTAssertTrue(coordinator.appleDoubleSelection.enabled)
coordinator.startQueue()
let finished = await waitUntil(timeout: .seconds(10)) {
journal.records.first(where: { $0.id == id })?.state == .completed && !coordinator.isOperationInProgress
}
if !finished { coordinator.cancelOperation() }
XCTAssertTrue(finished, coordinator.queueMessage ?? "Queue did not finish")
let record = try XCTUnwrap(journal.records.first { $0.id == id })
XCTAssertNil(record.cameraSettings.excludedAppleDoublePaths)
XCTAssertEqual(record.results.count, 3)
XCTAssertTrue(record.results.allSatisfy(\.isVerifiedStatus))
XCTAssertTrue(coordinator.appleDoubleSelection.enabled)
XCTAssertNil(folders.defaults.object(forKey: "BitMatchExcludeAppleDouble"))
}
}

func testCancelAfterExclusionRowsKeepsPartialEvidenceAndNeverCompletes() async throws {
try await FileOperationsTestLock.shared.run {
let folders = try CoordinatorFolders(); defer { folders.cleanup() }
Expand Down Expand Up @@ -165,15 +193,18 @@ final class AppleDoubleWorkflowTests: XCTestCase {
let folders = try CoordinatorFolders(); defer { folders.cleanup() }
try seed(folders.source)
let review = AppleDoubleReviewModel(defaults: folders.defaults)
XCTAssertFalse(review.enabled)
XCTAssertTrue(review.enabled)
XCTAssertNil(folders.defaults.object(forKey: "BitMatchExcludeAppleDouble"))
review.refresh(source: folders.source)
review.enabled = true
XCTAssertNotNil(review.readinessIssue)
let ready = await waitUntil { review.paths != nil }
XCTAssertTrue(ready)
XCTAssertEqual(review.paths, ["._A.ARW"])
XCTAssertTrue(try XCTUnwrap(review.summaryLine).contains("1 AppleDouble"))
XCTAssertTrue(AppleDoubleReviewModel(defaults: folders.defaults).enabled)
review.enabled = false
XCTAssertFalse(AppleDoubleReviewModel(defaults: folders.defaults).enabled)
review.enabled = true
review.refresh(source: nil)
XCTAssertNil(review.paths)
XCTAssertNil(review.readinessIssue)
Expand Down
Loading
Loading