diff --git a/BitMatchTests/ExpectedExclusionQueueScopeTests.swift b/BitMatchTests/ExpectedExclusionQueueScopeTests.swift new file mode 100644 index 00000000..f5dc8865 --- /dev/null +++ b/BitMatchTests/ExpectedExclusionQueueScopeTests.swift @@ -0,0 +1,66 @@ +import Foundation +import XCTest +@testable import BitMatch +import BitMatchEngine + +@MainActor +final class ExpectedExclusionQueueScopeTests: XCTestCase { + private func record(project: UUID? = nil, card: UUID? = nil, state: LocalTransferState = .running) throws -> LocalTransferRecord { + let resource = try JSONDecoder().decode(LocalTransferResource.self, from: Data("{\"url\":\"file:///synthetic-card\",\"bookmark\":\"\"}".utf8)) + var value = LocalTransferRecord(id: UUID(), createdAt: Date(), source: resource, destinations: [resource], + verificationMode: .standard, cameraSettings: CameraLabelSettings(), reportSettings: ReportPrefs(makeReport: false), + projectID: project, projectCardID: card) + value.state = state; return value + } + private func context(jobID: UUID, cardID: UUID) -> PhotographerReportContext { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let job = PhotographerJob(id: jobID, eventDate: date, clientName: "", jobName: "Synthetic", eventType: .wedding, + photographers: [], recipe: .wedding, requiredLocalCopyCount: 2, cardIngests: [], createdAt: date, updatedAt: date) + return PhotographerReportContext(job: job, cardIngestID: cardID, + analysis: CardAnalysis(fingerprint: "synthetic", fileCount: 0, totalBytes: 0, companionGroups: []), + verifiedDestinationCount: 0, warnings: []) + } + private func config(identity: ExpectedExclusionQueueQualification?, finalizer: PhotographerReportFinalizer? = nil) -> CopyVerifyConfig { + CopyVerifyConfig(operationId: UUID(), sourceURL: URL(fileURLWithPath: "/synthetic-card"), destinationURLs: [], + verificationMode: .standard, cameraLabelSettings: CameraLabelSettings(), reportSettings: ReportPrefs(makeReport: false), + estimatedFiles: 0, estimatedBytes: 0, currentMode: .copyAndVerify, photographerReportFinalizer: finalizer, + expectedExclusionQueueIdentity: identity) + } + func testSavedIdentityRequiresMatchingCompleteRunningScope() throws { + let job = UUID(), card = UUID() + XCTAssertEqual(SavedExpectedExclusionQueueIdentity.matching(projectID: nil, projectCardID: nil, record: try record()), .ordinaryTransfer) + XCTAssertEqual(SavedExpectedExclusionQueueIdentity.matching(projectID: job, projectCardID: card, record: try record(project: job, card: card)), .projectTransfer(projectID: job, cardID: card)) + XCTAssertNil(SavedExpectedExclusionQueueIdentity.matching(projectID: nil, projectCardID: nil, record: nil)) + XCTAssertNil(SavedExpectedExclusionQueueIdentity.matching(projectID: nil, projectCardID: nil, record: try record(state: .issues))) + XCTAssertNil(SavedExpectedExclusionQueueIdentity.matching(projectID: job, projectCardID: nil, record: try record(project: job))) + XCTAssertNil(SavedExpectedExclusionQueueIdentity.matching(projectID: nil, projectCardID: card, record: try record(card: card))) + XCTAssertNil(SavedExpectedExclusionQueueIdentity.matching(projectID: job, projectCardID: card, record: try record(project: UUID(), card: card))) + XCTAssertNil(SavedExpectedExclusionQueueIdentity.matching(projectID: nil, projectCardID: nil, record: try record(project: job, card: card))) + } + func testOrdinaryNeedsExplicitScopeAndNoProjectFinalizerOrContext() { + let lifecycle = CopyVerifyExecutor.PhotographerLifecycleFinalization(context: nil, didPersist: true, locallySafe: nil) + XCTAssertTrue(CopyVerifyExecutor.expectedExclusionProjectGate(config: config(identity: .ordinaryTransfer), lifecycle: lifecycle).permitsExpectedExclusionQueueContinuation) + XCTAssertFalse(CopyVerifyExecutor.expectedExclusionProjectGate(config: config(identity: nil), lifecycle: lifecycle).permitsExpectedExclusionQueueContinuation) + let finalizer: PhotographerReportFinalizer = { _ in .init(context: nil, locallySafe: false) } + XCTAssertFalse(CopyVerifyExecutor.expectedExclusionProjectGate(config: config(identity: .ordinaryTransfer, finalizer: finalizer), lifecycle: lifecycle).permitsExpectedExclusionQueueContinuation) + let unexpected = CopyVerifyExecutor.PhotographerLifecycleFinalization(context: context(jobID: UUID(), cardID: UUID()), didPersist: true, locallySafe: nil) + XCTAssertFalse(CopyVerifyExecutor.expectedExclusionProjectGate(config: config(identity: .ordinaryTransfer), lifecycle: unexpected).permitsExpectedExclusionQueueContinuation) + } + func testProjectNeedsMatchingFinalizedContextAndPositiveQualification() { + let job = UUID(), card = UUID(), identity = ExpectedExclusionQueueQualification.projectTransfer(projectID: job, cardID: card) + let finalizer: PhotographerReportFinalizer = { _ in .init(context: nil, locallySafe: false) } + let configured = config(identity: identity, finalizer: finalizer) + let healthy = CopyVerifyExecutor.PhotographerLifecycleFinalization(context: context(jobID: job, cardID: card), didPersist: true, + locallySafe: false, allowsQueueContinuationForExpectedExclusions: true) + let gate = CopyVerifyExecutor.expectedExclusionProjectGate(config: configured, lifecycle: healthy) + XCTAssertTrue(gate.permitsExpectedExclusionQueueContinuation); XCTAssertFalse(gate.permitsSuccess) + XCTAssertFalse(CopyVerifyExecutor.expectedExclusionProjectGate(config: config(identity: identity), lifecycle: healthy).permitsExpectedExclusionQueueContinuation) + for candidate in [ + CopyVerifyExecutor.PhotographerLifecycleFinalization(context: nil, didPersist: true, locallySafe: false, allowsQueueContinuationForExpectedExclusions: true), + CopyVerifyExecutor.PhotographerLifecycleFinalization(context: context(jobID: UUID(), cardID: card), didPersist: true, locallySafe: false, allowsQueueContinuationForExpectedExclusions: true), + CopyVerifyExecutor.PhotographerLifecycleFinalization(context: context(jobID: job, cardID: UUID()), didPersist: true, locallySafe: false, allowsQueueContinuationForExpectedExclusions: true), + CopyVerifyExecutor.PhotographerLifecycleFinalization(context: context(jobID: job, cardID: card), didPersist: false, locallySafe: false, allowsQueueContinuationForExpectedExclusions: true), + CopyVerifyExecutor.PhotographerLifecycleFinalization(context: context(jobID: job, cardID: card), didPersist: true, locallySafe: false) + ] { XCTAssertFalse(CopyVerifyExecutor.expectedExclusionProjectGate(config: configured, lifecycle: candidate).permitsExpectedExclusionQueueContinuation) } + } +} diff --git a/BitMatchTests/LocalTransferQueueIntegrationTests.swift b/BitMatchTests/LocalTransferQueueIntegrationTests.swift index d4db9805..ebc2ab9b 100644 --- a/BitMatchTests/LocalTransferQueueIntegrationTests.swift +++ b/BitMatchTests/LocalTransferQueueIntegrationTests.swift @@ -1615,6 +1615,146 @@ final class LocalTransferQueueIntegrationTests: XCTestCase { ), 0) } + private func seedQueueAppleDouble(_ source: URL) throws -> CameraLabelSettings { + var bytes = [UInt8](repeating: 0, count: 42) + func put(_ value: UInt32, _ offset: Int) { + for index in 0..<4 { bytes[offset + index] = UInt8(truncatingIfNeeded: value >> (24 - 8 * index)) } + } + put(0x00051607, 0); put(0x00020000, 4); bytes[25] = 1 + put(2, 26); put(38, 30); put(4, 34) + try Data(bytes).write(to: source.appendingPathComponent("._clip.mov")) + var settings = CameraLabelSettings() + settings.excludedAppleDoublePaths = try AppleDoubleSelection.review(source: source) + XCTAssertEqual(settings.excludedAppleDoublePaths, ["._clip.mov"]) + return settings + } + + func testExpectedAppleDoubleQueueContinuesToNextCardWithoutSafePromotion() async throws { + try await runExpectedExclusionQueueScenario(failure: nil) + } + + func testExpectedAppleDoubleQueueStillPausesForMHLFailure() async throws { + try await runExpectedExclusionQueueScenario(failure: "mhl") + } + + func testExpectedAppleDoubleQueueStillPausesForReportFailure() async throws { + try await runExpectedExclusionQueueScenario(failure: "report") + } + + func testExpectedAppleDoubleQueueStillPausesForSelectedConflict() async throws { + try await runExpectedExclusionQueueScenario(failure: "selected") + } + + func testExpectedAppleDoubleQueueStillPausesForJournalFailure() async throws { + try await runExpectedExclusionQueueScenario(failure: "journal") + } + + private func runExpectedExclusionQueueScenario(failure: String?) async throws { + let f = try QueueFixture() + let startingFailures = testRun?.failureCount ?? 0 + var settled = true + defer { + if settled && (testRun?.failureCount ?? 0) == startingFailures { f.cleanup() } + else { print("Retained expected-exclusion queue fixture: \(f.root.path)") } + } + let settings = try seedQueueAppleDouble(f.source) + let original = try Data(contentsOf: f.source.appendingPathComponent("clip.mov")) + let metadata = try Data(contentsOf: f.source.appendingPathComponent("._clip.mov")) + let second = f.root.appendingPathComponent("second") + try FileManager.default.createDirectory(at: second, withIntermediateDirectories: true) + try Data("next card".utf8).write(to: second.appendingPathComponent("clip.mov")) + var firstID: UUID? + let journal = LocalTransferJournal(fileURL: f.journalURL, beforeFinish: { id in + if failure == "journal" && id == firstID { throw NSError(domain: "ExpectedExclusionJournalFailure", code: 1) } + }) + let coordinator = SharedAppCoordinator( + platformManager: QueuePlatformManager(fileOperations: TransferPipeline(fileSystem: MacOSFileSystemService.shared, checksum: ChecksumEngine.shared)), + transferJournal: journal, defaults: f.defaults + ) + let output = SafetyValidator.resolvedDestinationRoot(source: f.source, destination: f.destination, settings: settings) + let reports = f.destination.appendingPathComponent("Reports") + if failure == "mhl" { + try FileManager.default.createDirectory(at: output.appendingPathComponent("ascmhl"), withIntermediateDirectories: true) + try Data("preserved history".utf8).write(to: output.appendingPathComponent("ascmhl/existing.txt")) + } else if failure == "report" { + try Data("preserved conflict".utf8).write(to: reports) + } else if failure == "selected" { + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + try Data("preserved conflict".utf8).write(to: output.appendingPathComponent("clip.mov")) + } + firstID = try coordinator.enqueue(source: f.source, destinations: [f.destination], verificationMode: .standard, + cameraSettings: settings, generateASCMHL: failure == nil || failure == "mhl", reportSettings: ReportPrefs(makeReport: failure == nil || failure == "report")) + let nextID = try coordinator.enqueue(source: second, destinations: [f.destination], verificationMode: .standard, + cameraSettings: CameraLabelSettings(), generateASCMHL: false, reportSettings: ReportPrefs(makeReport: false)) + settled = false + coordinator.startQueue() + let ended = await waitUntil(timeout: .seconds(25)) { @MainActor in + !coordinator.isOperationInProgress + && (failure == nil + ? coordinator.queueSessionEnded || coordinator.queuePausedRecordID == firstID + : coordinator.queuePausedRecordID == firstID) + } + if !ended { + // This origin is a synthetic test cleanup only, never a field-user cancellation claim. + try? await coordinator.cancelOperationAndWaitForSettlement(origin: .user) + XCTFail("Expected queue scenario did not settle") + return + } + settled = true + let first = try XCTUnwrap(journal.records.first { $0.id == firstID }) + let next = try XCTUnwrap(journal.records.first { $0.id == nextID }) + XCTAssertEqual(try Data(contentsOf: f.source.appendingPathComponent("clip.mov")), original) + XCTAssertEqual(try Data(contentsOf: f.source.appendingPathComponent("._clip.mov")), metadata) + XCTAssertNotEqual(TransferLibraryPresentation.safetyState(for: first), .safeToErase) + XCTAssertFalse(coordinator.queuePresentation.ejectableCardIDs.contains(first.id)) + if failure == nil { + XCTAssertEqual(first.state, .issues) + XCTAssertEqual(first.expectedExclusionQueueQualification, .ordinaryTransfer) + XCTAssertEqual(next.state, .completed) + XCTAssertNil(coordinator.queuePausedRecordID) + XCTAssertTrue(first.results.filter { ResultOutcome(statusText: $0.status) != .excludedAppleDouble }.allSatisfy(\.isVerifiedStatus)) + let exported = try TransferHistoryDocument(record: first, asCSV: false) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: exported.data) as? [String: Any]) + XCTAssertEqual(object["savedSafeToErase"] as? Bool, false) + XCTAssertTrue(first.results.contains { ResultOutcome(statusText: $0.status) == .excludedAppleDouble }) + XCTAssertTrue(FileManager.default.fileExists(atPath: output.appendingPathComponent("ascmhl/ascmhl_chain.xml").path)) + let reportFiles = try FileManager.default.contentsOfDirectory(at: reports, includingPropertiesForKeys: nil) + for suffix in ["pdf", "json", "csv"] { + let file = try XCTUnwrap(reportFiles.first { $0.pathExtension == suffix }) + XCTAssertFalse(try Data(contentsOf: file).isEmpty, "Requested \(suffix) report must be written") + } + } else { + XCTAssertNil(first.expectedExclusionQueueQualification) + XCTAssertEqual(coordinator.queuePausedRecordID, first.id) + XCTAssertEqual(next.state, .queued) + if failure == "mhl" { XCTAssertEqual(try String(contentsOf: output.appendingPathComponent("ascmhl/existing.txt"), encoding: .utf8), "preserved history") } + if failure == "report" { XCTAssertEqual(try String(contentsOf: reports, encoding: .utf8), "preserved conflict") } + if failure == "selected" { XCTAssertEqual(try String(contentsOf: output.appendingPathComponent("clip.mov"), encoding: .utf8), "preserved conflict") } + } + } + + func testLegacyExpectedExclusionRestoreStillRequiresReview() throws { + let f = try QueueFixture(); defer { f.cleanup() } + let settings = try seedQueueAppleDouble(f.source) + let journal = LocalTransferJournal(fileURL: f.journalURL) + let id = try journal.enqueue(sourceURL: f.source, destinationURLs: [f.destination], verificationMode: .standard, + cameraSettings: settings, reportSettings: ReportPrefs(makeReport: false), generateASCMHL: false) + try journal.markRunning(id: id) + try journal.recordPlan(id: id, fileCounts: [1]) + let output = SafetyValidator.resolvedDestinationRoot(source: f.source, destination: f.destination, settings: settings) + try journal.finish(id: id, results: [ + ResultRow(path: f.source.appendingPathComponent("clip.mov").path, status: ResultOutcome.verified.statusText, + size: 4, checksum: "fixture", destination: "backup", destinationPath: output.appendingPathComponent("clip.mov").path), + ResultRow(path: f.source.appendingPathComponent("._clip.mov").path, status: ResultOutcome.excludedAppleDouble.statusText, + size: 42, checksum: nil, destination: "backup", destinationPath: output.appendingPathComponent("._clip.mov").path) + ], summary: "Selected files verified; expected metadata excluded", hadIssues: true, sourceFingerprint: "fixture") + try journal.saveQueueSession(PersistedQueueSession(recordIDs: [id], skippedRecordIDs: [], pausedRecordID: id, ended: false, started: true)) + let coordinator = SharedAppCoordinator(platformManager: QueuePlatformManager(fileOperations: QueueRecordingOperations()), transferJournal: journal, defaults: f.defaults) + XCTAssertEqual(coordinator.queuePausedRecordID, id) + XCTAssertTrue(coordinator.hasUnresolvedQueueRecords) + XCTAssertNil(journal.records.first?.expectedExclusionQueueQualification) + } + /// Plant: include `.copiedNotVerified` in the pausing branch of /// `handleAttemptTerminal`; the first Quick card then stops this queue. func testQuickQueueContinuesAndDoesNotAddAttentionBadge() async throws { diff --git a/BitMatchTests/ProjectExcludedSelectionTests.swift b/BitMatchTests/ProjectExcludedSelectionTests.swift new file mode 100644 index 00000000..142f0a65 --- /dev/null +++ b/BitMatchTests/ProjectExcludedSelectionTests.swift @@ -0,0 +1,209 @@ +import Foundation +import Testing +@testable import BitMatch +import BitMatchEngine + +@MainActor +struct ProjectExcludedSelectionTests { + private let sourcePaths = ["/card/A.mov", "/card/._A.mov"] + private let excludedPaths: Set = ["/card/._A.mov"] + + @Test func healthySelectedProjectPersistsWithoutPromotingWholeCardSafety() throws { + let fixture = try makeFixture() + let before = try #require(fixture.viewModel.activeCard) + let result = try complete(fixture, rows: rows(fixture), independentCount: 2) + let after = try #require(fixture.viewModel.activeCard) + + #expect(result.allowsQueueContinuationForExpectedExclusions) + #expect(!result.locallySafe) + #expect(after.localState == .issues) + #expect(after.locallySafeAt == nil) + #expect(after.provenance.confirmedFingerprint == nil) + #expect(after.verifiedDestinationCount == 0) + #expect(after.id == before.id) + #expect(after.provenance == before.provenance) + #expect(after.fileCount == before.fileCount) + #expect(after.totalBytes == before.totalBytes) + #expect(after.renderedRelativePath == before.renderedRelativePath) + #expect(fixture.store.storedJobs.first?.cardIngests.first == after) + #expect(result.context?.job.id == fixture.jobID) + #expect(result.context?.cardIngestID == fixture.cardID) + // The returned report context correctly retains the unsafe count; + // only the conditional qualifier used the original independent count. + #expect(result.context?.independentDestinationCount == 0) + } + + @Test func selectedProjectRequiresKnownSufficientIndependentCopies() throws { + for count: Int? in [nil, 0, 1] { + let fixture = try makeFixture() + let result = try complete(fixture, rows: rows(fixture), independentCount: count) + #expect(!result.allowsQueueContinuationForExpectedExclusions) + #expect(!result.locallySafe) + } + } + + @Test func missingAndDuplicateSelectedRowsDoNotQualify() throws { + let fixture = try makeFixture() + let original = rows(fixture) + let selected = original.filter(\.isVerifiedStatus) + let missing = original.filter { $0.id != selected[1].id } + let duplicate = original + [selected[0]] + for invalid in [missing, duplicate] { + #expect(!candidate(fixture, rows: invalid)) + } + } + + @Test func mismatchedMissingOrUnverifiedDigestsDoNotQualify() throws { + let fixture = try makeFixture() + let original = rows(fixture) + let target = try #require(original.first { $0.isVerifiedStatus && $0.destination == "Backup B" }) + let replacements = [ + replacing(target, checksum: String(repeating: "b", count: 64)), + replacing(target, checksum: nil), + replacing(target, checksum: " "), + replacing(target, checksum: target.checksum, status: ResultOutcome.copiedUnverified.statusText), + replacing(target, checksum: nil, status: ResultOutcome.failed.statusText) + ] + for replacement in replacements { + let changed = original.map { $0.id == target.id ? replacement : $0 } + #expect(!candidate(fixture, rows: changed)) + } + } + + @Test func malformedAndStaleOriginalProjectAnalysisDoNotQualify() throws { + let fixture = try makeFixture() + let original = rows(fixture) + let malformed = [ + CardAnalysis(fingerprint: "preliminary", fileCount: 3, totalBytes: 104, companionGroups: [], sourcePaths: sourcePaths), + CardAnalysis(fingerprint: "preliminary", fileCount: 2, totalBytes: 104, companionGroups: [], sourcePaths: [sourcePaths[0], sourcePaths[0]]), + CardAnalysis(fingerprint: "preliminary", fileCount: 2, totalBytes: 104, companionGroups: [], sourcePaths: ["/card/other.mov", sourcePaths[1]]) + ] + for analysis in malformed { + #expect(!candidate(fixture, rows: original, analysis: analysis)) + } + var mismatchedCard = try #require(fixture.viewModel.activeJob) + mismatchedCard.cardIngests[0].fileCount += 1 + #expect(!candidate(fixture, rows: original, job: mismatchedCard)) + } + + @Test func onlyTheConditionalAppleDoubleSourceSetCanBeRemoved() throws { + let fixture = try makeFixture() + let original = rows(fixture) + #expect(!candidate(fixture, rows: original, exclusions: [])) + #expect(!candidate(fixture, rows: original, exclusions: ["/card/._other.mov"])) + #expect(!candidate(fixture, rows: original, exclusions: ["/card/A.mov"])) + #expect(!candidate(fixture, rows: original, exclusions: ["/card/._A.mov", "/card/._extra.mov"])) + } + + @Test func excludedOnlyProjectCannotEarnContinuation() throws { + let fixture = try makeFixture(paths: ["/card/._A.mov"]) + let onlyExcluded = rows(fixture).filter { ResultOutcome(statusText: $0.status) == .excludedAppleDouble } + let result = try complete(fixture, rows: onlyExcluded, independentCount: 2) + #expect(!result.allowsQueueContinuationForExpectedExclusions) + #expect(!result.locallySafe) + } + + @Test func failedProjectPersistenceCannotReturnAQualifier() throws { + let fixture = try makeFixture() + let count = fixture.store.saveCount + let original = rows(fixture) + fixture.store.errorOnSave = ProjectSelectionTestError.persistence + let lifecycle = try CopyVerifyExecutor.photographerLifecycleAfterAuthoritativeCompletion { + try complete(fixture, rows: original, independentCount: 2) + } + #expect(!lifecycle.didPersist) + #expect(!lifecycle.allowsQueueContinuationForExpectedExclusions) + #expect(lifecycle.context == nil) + #expect(fixture.store.saveCount == count) + #expect(fixture.store.storedJobs.first?.cardIngests.first?.localState == .copying) + } + + @Test func settledProjectCannotReuseAPriorConditionalQualifier() throws { + let fixture = try makeFixture() + let original = rows(fixture) + let first = try complete(fixture, rows: original, independentCount: 2) + #expect(first.allowsQueueContinuationForExpectedExclusions) + let repeated = try complete(fixture, rows: original, independentCount: 2) + #expect(!repeated.allowsQueueContinuationForExpectedExclusions) + #expect(!repeated.locallySafe) + } + + @Test func finalizerQualificationDefaultsToFalse() { + let ordinary = PhotographerFinalizationResult(context: nil, locallySafe: false) + #expect(!ordinary.allowsQueueContinuationForExpectedExclusions) + } + + private struct Fixture { + let viewModel: PhotographerJobViewModel + let store: InMemoryPhotographerJobStore + let jobID: UUID + let cardID: UUID + let analysis: CardAnalysis + let package: String + } + + private func makeFixture(paths: [String]? = nil) throws -> Fixture { + let paths = paths ?? sourcePaths + let store = InMemoryPhotographerJobStore() + let viewModel = PhotographerJobViewModel( + store: store, now: { Date(timeIntervalSince1970: 200) }, + workflowDefaults: .isolatedWorkflowDefaults(remembering: .photography) + ) + viewModel.createWeddingJob(clientName: "Example", jobName: "Example", eventDate: Date(timeIntervalSince1970: 100)) + let analysis = CardAnalysis(fingerprint: "preliminary", fileCount: paths.count, totalBytes: 104, companionGroups: [], sourcePaths: paths) + try viewModel.prepareCard(photographerName: "Operator", cameraName: "Camera", analysis: analysis) + try #require(viewModel.beginIngest(destinationCount: 2)) + return Fixture( + viewModel: viewModel, store: store, + jobID: try #require(viewModel.activeJob?.id), + cardID: try #require(viewModel.activeCard?.id), analysis: analysis, + package: try #require(viewModel.activeCard?.renderedRelativePath) + ) + } + + private func rows(_ fixture: Fixture) -> [ResultRow] { + ["Backup A", "Backup B"].flatMap { destination in + fixture.analysis.sourcePaths.map { path in + let excluded = (path as NSString).lastPathComponent.hasPrefix("._") + let root = destination == "Backup A" ? "/backup-a" : "/backup-b" + return ResultRow( + path: path, + status: excluded ? ResultOutcome.excludedAppleDouble.statusText : ResultOutcome.verified.statusText, + size: excluded ? 4 : 100, + checksum: excluded ? nil : String(repeating: "a", count: 64), + destination: destination, + destinationPath: root + "/" + fixture.package + "/" + (path as NSString).lastPathComponent + ) + } + } + } + + private func complete(_ fixture: Fixture, rows: [ResultRow], independentCount: Int?) throws -> PhotographerFinalizationResult { + try fixture.viewModel.completeIngest( + jobID: fixture.jobID, cardID: fixture.cardID, analysis: fixture.analysis, + results: rows, independentDestinationCount: independentCount + ) + } + + private func candidate( + _ fixture: Fixture, rows: [ResultRow], analysis: CardAnalysis? = nil, + job: PhotographerJob? = nil, exclusions: Set? = nil + ) -> Bool { + guard let actualJob = job ?? fixture.viewModel.activeJob else { return false } + return PhotographerReportPayload.allowsQueueContinuationForExpectedExclusions( + context: PhotographerReportContext( + job: actualJob, cardIngestID: fixture.cardID, + analysis: analysis ?? fixture.analysis, verifiedDestinationCount: 0, + warnings: [], independentDestinationCount: 2 + ), + results: rows, excludedSourcePaths: exclusions ?? excludedPaths + ) + } + + private func replacing(_ row: ResultRow, checksum: String?, status: String? = nil) -> ResultRow { + ResultRow(id: row.id, path: row.path, status: status ?? row.status, size: row.size, + checksum: checksum, destination: row.destination, destinationPath: row.destinationPath) + } +} + +private enum ProjectSelectionTestError: Error { case persistence } diff --git a/CHANGELOG.md b/CHANGELOG.md index c250543b..2892c885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - Fixed crashes caused by Unicode-equivalent filenames. Conflicting source names are refused before copying; conflicting existing backup names prevent a successful result. +- Queued transfers can move on to the next card when every selected file verified and only the AppleDouble metadata files you chose to ignore were excluded. Exclusions stay in the report, and other problems still stop the queue for review. - Check no longer reports preserved Finder metadata as missing when it is listed in the saved checksums. - Mac: Corrected physical-drive checks for selected subfolders, so two folders on one drive count as one backup. diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/EngineProtocols.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/EngineProtocols.swift index cf6bf0f2..b9a972be 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/EngineProtocols.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/EngineProtocols.swift @@ -96,6 +96,9 @@ public struct FileOperation: Sendable { public let sourceFingerprint: String? public let verificationMode: VerificationMode public let settings: CameraLabelSettings + /// Classifier-backed paths for this run. Public/manual operations have no + /// such fact; only the pipeline can record it after validating the review. + public internal(set) var reviewedAppleDoubleExclusionPaths: [String]? = nil public let estimatedTotalBytes: Int64? // For improved ETA calculation public var duration: TimeInterval? { diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/ExpectedExclusionQueuePolicy.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/ExpectedExclusionQueuePolicy.swift new file mode 100644 index 00000000..f432704e --- /dev/null +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/ExpectedExclusionQueuePolicy.swift @@ -0,0 +1,132 @@ +import Foundation + +/// Permission to advance a queue, never permission to erase the whole card. +/// Missing or unknown saved values do not establish this qualification. +public enum ExpectedExclusionQueueQualification: Codable, Equatable, Sendable { + case ordinaryTransfer + case projectTransfer(projectID: UUID, cardID: UUID) +} + +public extension TransferCompletion { + /// Called only after authoritative-plan acceptance and the final requested + /// handoffs. Classifier provenance comes from the actual pipeline, not a + /// result's status text or a completion summary. + static func expectedExclusionQueueQualification( + operation: FileOperation, + expectedMode: VerificationMode, + handoffIssues: [String], + reportIssue: String?, + project: ProjectGate + ) -> ExpectedExclusionQueueQualification? { + guard operation.verificationMode == expectedMode, expectedMode != .quick, + operation.endTime != nil, + operation.sourceFingerprint?.isEmpty == false, + let reviewed = operation.reviewedAppleDoubleExclusionPaths, + !reviewed.isEmpty, + let requested = operation.settings.excludedAppleDoublePaths, + reviewed.count == requested.count, + Set(reviewed).count == reviewed.count, + Set(reviewed) == Set(requested), + let manifest = operation.sourceManifest, + project.permitsExpectedExclusionQueueContinuation, + handoffIssues.isEmpty, reportIssue == nil, + operation.results.allSatisfy({ result in + result.error == nil && (result.excludedAppleDouble + || (result.success && result.verificationResult?.isValid == true)) + }) else { return nil } + let sourcePaths = manifest.map { PathContainment.comparablePath($0.path) } + guard Set(sourcePaths).count == sourcePaths.count, + let expected = selectedSourcePaths( + source: operation.sourceURL, reviewedRelativePaths: reviewed, + fullSourcePaths: Set(sourcePaths) + ), !expected.isEmpty else { return nil } + let counts = Array(repeating: expected.count, count: operation.destinationURLs.count) + guard exactExpectedExclusionRows( + rows: rows(from: operation), source: operation.sourceURL, + destinations: operation.destinationURLs, settings: operation.settings, + plannedFileCounts: counts, expectedSelectedSourcePaths: expected + ) else { return nil } + return project.expectedExclusionQueueIdentity + } + + /// Pure saved-evidence checks: no bookmarks, file reads or mounted-media + /// dependency. The final persisted qualification attests the classifier + /// and handoff gates; this rechecks its exact row/selection scope. + internal static func exactExpectedExclusionRows( + rows: [ResultRow], source: URL, destinations: [URL], + settings: CameraLabelSettings, plannedFileCounts: [Int], + expectedSelectedSourcePaths: Set? = nil + ) -> Bool { + guard !rows.isEmpty, !destinations.isEmpty, + plannedFileCounts.count == destinations.count, + plannedFileCounts.allSatisfy({ $0 > 0 }), + let reviewed = settings.excludedAppleDoublePaths, + !reviewed.isEmpty, + Set(reviewed).count == reviewed.count else { return false } + let sourcePath = PathContainment.comparablePath(source.path) + let sourcePrefix = sourcePath.hasSuffix("/") ? sourcePath : sourcePath + "/" + guard let excluded = excludedSourcePaths(source: source, reviewedRelativePaths: reviewed) else { return false } + let roots = destinations.map { + PathContainment.comparablePath(SafetyValidator.resolvedDestinationRoot( + source: source, destination: $0, settings: settings + ).path) + } + guard Set(roots).count == roots.count else { return false } + for first in roots.indices { + for second in roots.indices where second > first { + guard !PathContainment.isWithin(roots[first], root: roots[second]), + !PathContainment.isWithin(roots[second], root: roots[first]) else { return false } + } + } + var selectedByDestination = Array(repeating: Set(), count: roots.count) + var excludedByDestination = Array(repeating: Set(), count: roots.count) + for row in rows { + let path = PathContainment.comparablePath(row.path) + guard path.hasPrefix(sourcePrefix), + let destinationPath = row.destinationPath else { return false } + let relative = String(path.dropFirst(sourcePrefix.count)) + guard !relative.isEmpty else { return false } + let destination = PathContainment.comparablePath(destinationPath) + let matching = roots.indices.filter { destination == roots[$0] + "/" + relative } + guard matching.count == 1, let index = matching.first else { return false } + if ResultOutcome(statusText: row.status) == .excludedAppleDouble { + guard excluded.contains(path), excludedByDestination[index].insert(path).inserted else { return false } + } else { + guard row.isVerifiedStatus, !excluded.contains(path), + selectedByDestination[index].insert(path).inserted else { return false } + } + } + guard let selected = selectedByDestination.first, !selected.isEmpty, + expectedSelectedSourcePaths == nil || selected == expectedSelectedSourcePaths else { return false } + return roots.indices.allSatisfy { + selectedByDestination[$0] == selected + && selectedByDestination[$0].count == plannedFileCounts[$0] + && excludedByDestination[$0] == excluded + } + } + + private static func excludedSourcePaths(source: URL, reviewedRelativePaths: [String]) -> Set? { + guard !reviewedRelativePaths.isEmpty, + Set(reviewedRelativePaths).count == reviewedRelativePaths.count else { return nil } + let sourcePath = PathContainment.comparablePath(source.path) + let prefix = sourcePath.hasSuffix("/") ? sourcePath : sourcePath + "/" + var excluded = Set() + for relative in reviewedRelativePaths { + let parts = relative.split(separator: "/", omittingEmptySubsequences: false) + guard !relative.hasPrefix("/"), !parts.isEmpty, + parts.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }), + parts.last?.hasPrefix("._") == true else { return nil } + let path = PathContainment.comparablePath(source.appendingPathComponent(relative).path) + guard path.hasPrefix(prefix), excluded.insert(path).inserted else { return nil } + } + return excluded + } + + private static func selectedSourcePaths( + source: URL, reviewedRelativePaths: [String], fullSourcePaths: Set + ) -> Set? { + guard let excluded = excludedSourcePaths(source: source, reviewedRelativePaths: reviewedRelativePaths), + excluded.isSubset(of: fullSourcePaths) else { return nil } + return fullSourcePaths.subtracting(excluded) + } +} diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferCompletion.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferCompletion.swift index 56d0714b..c7ace79e 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferCompletion.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferCompletion.swift @@ -324,10 +324,25 @@ public enum TransferCompletion: Sendable { public let didPersist: Bool /// `nil` when no project finalizer ran (an ordinary copy). public let locallySafe: Bool? + /// Positive queue-only evidence, supplied for a known ordinary run or + /// a separately proven project selection. Unknown project state is not enough. + public let allowsQueueContinuationForExpectedExclusions: Bool + public let expectedExclusionQueueIdentity: ExpectedExclusionQueueQualification? - public init(didPersist: Bool, locallySafe: Bool?) { + public init(didPersist: Bool, locallySafe: Bool?, allowsQueueContinuationForExpectedExclusions: Bool = false, expectedExclusionQueueIdentity: ExpectedExclusionQueueQualification? = nil) { self.didPersist = didPersist self.locallySafe = locallySafe + self.allowsQueueContinuationForExpectedExclusions = allowsQueueContinuationForExpectedExclusions + self.expectedExclusionQueueIdentity = expectedExclusionQueueIdentity + } + + public var permitsExpectedExclusionQueueContinuation: Bool { + guard didPersist, allowsQueueContinuationForExpectedExclusions, + let identity = expectedExclusionQueueIdentity else { return false } + switch identity { + case .ordinaryTransfer: return locallySafe == nil + case .projectTransfer: return locallySafe != nil + } } public var permitsSuccess: Bool { didPersist && (locallySafe ?? true) } diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift index ebf1c268..f1177381 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferJournal.swift @@ -133,6 +133,9 @@ public struct LocalTransferRecord: Identifiable, Codable, Sendable { /// could not be verified. False when reporting, coverage, or another part /// of the attempt also failed; nil for journals written before this field. public var quickOnlyUnverified: Bool? = nil + /// Final handoffs settled; only reviewed metadata was omitted. This permits + /// queue advancement, never a whole-card safe-to-erase verdict. + public var expectedExclusionQueueQualification: ExpectedExclusionQueueQualification? = nil /// Physical drives represented by `destinations`. Nil for older records /// whose disk independence was never assessed. public var independentDestinationCount: Int? @@ -163,6 +166,7 @@ public struct LocalTransferRecord: Identifiable, Codable, Sendable { case generateASCMHL, projectID, projectCardID, state, startedAt, endedAt case copyDurationSeconds, verifyDurationSeconds, performanceTelemetry, summary, results, sourceFingerprint case independentDestinationCount, retryOf, plannedFileCounts, quickOnlyUnverified, estimatedSourceBytes + case expectedExclusionQueueQualification } public init(from decoder: Decoder) throws { @@ -197,6 +201,10 @@ public struct LocalTransferRecord: Identifiable, Codable, Sendable { plannedFileCounts = try c.decodeIfPresent([Int].self, forKey: .plannedFileCounts) estimatedSourceBytes = try c.decodeIfPresent(Int64.self, forKey: .estimatedSourceBytes) quickOnlyUnverified = try c.decodeIfPresent(Bool.self, forKey: .quickOnlyUnverified) + // Unknown future qualifications are not permission to advance. + expectedExclusionQueueQualification = try? c.decodeIfPresent( + ExpectedExclusionQueueQualification.self, forKey: .expectedExclusionQueueQualification + ) } public var issueCount: Int { results.filter { !$0.isSuccessStatus }.count } } @@ -475,6 +483,7 @@ public final class TransferJournal: Sendable { // plan is not evidence for the new attempt. record.plannedFileCounts = nil record.quickOnlyUnverified = nil + record.expectedExclusionQueueQualification = nil } } } @@ -627,6 +636,7 @@ public final class TransferJournal: Sendable { summary: String, hadIssues: Bool, quickOnlyUnverified: Bool? = nil, + expectedExclusionQueueQualification: ExpectedExclusionQueueQualification? = nil, copyDurationSeconds: TimeInterval? = nil, verifyDurationSeconds: TimeInterval? = nil, performanceTelemetry: TransferPerformanceTelemetry? = nil, @@ -681,7 +691,32 @@ public final class TransferJournal: Sendable { record.summary = truthfulSummary } record.endedAt = Date() + record.expectedExclusionQueueQualification = expectedExclusionQueueQualification + if !Self.permitsExpectedExclusionQueueContinuation(record) { + record.expectedExclusionQueueQualification = nil + } + } + } + + /// Recheck durable selected-file scope without opening old source media. + /// Legacy, interrupted, cancelled and Quick records never qualify. + public static func permitsExpectedExclusionQueueContinuation(_ record: LocalTransferRecord) -> Bool { + guard let qualification = record.expectedExclusionQueueQualification, + record.state == .issues, record.verificationMode != .quick, + record.startedAt != nil, record.endedAt != nil, + record.sourceFingerprint?.isEmpty == false, + let counts = record.plannedFileCounts else { return false } + switch qualification { + case .ordinaryTransfer: + guard record.projectID == nil, record.projectCardID == nil else { return false } + case .projectTransfer(let projectID, let cardID): + guard record.projectID == projectID, record.projectCardID == cardID else { return false } } + return TransferCompletion.exactExpectedExclusionRows( + rows: record.results, source: record.source.url, + destinations: record.destinations.map(\.url), settings: record.cameraSettings, + plannedFileCounts: counts + ) } /// Most recent proof that this exact manifest completed with verified @@ -748,6 +783,7 @@ public final class TransferJournal: Sendable { try update(id: id) { record in guard record.state == .running else { throw LocalTransferJournalError.invalidState } record.state = .interrupted + record.expectedExclusionQueueQualification = nil record.summary = summary if let results { record.results = results } record.endedAt = Date() @@ -758,6 +794,7 @@ public final class TransferJournal: Sendable { try update(id: id) { record in guard record.state != .completed else { throw LocalTransferJournalError.invalidState } record.state = .cancelled + record.expectedExclusionQueueQualification = nil record.summary = summary if let results { record.results = results } record.endedAt = Date() diff --git a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferPipeline.swift b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferPipeline.swift index d40289c9..0f7a5e14 100644 --- a/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferPipeline.swift +++ b/Packages/BitMatchEngine/Sources/BitMatchEngine/TransferPipeline.swift @@ -1252,7 +1252,7 @@ public final class TransferPipeline: FileOperationsService, Sendable { )) } - return FileOperation( + var completed = FileOperation( sourceURL: operation.sourceURL, destinationURLs: operation.destinationURLs, startTime: operation.startTime, @@ -1264,5 +1264,7 @@ public final class TransferPipeline: FileOperationsService, Sendable { settings: operation.settings, estimatedTotalBytes: operation.estimatedTotalBytes ) + completed.reviewedAppleDoubleExclusionPaths = excluded.map(\.relativePath) + return completed } } diff --git a/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ExpectedExclusionQueuePolicyTests.swift b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ExpectedExclusionQueuePolicyTests.swift new file mode 100644 index 00000000..b70e9294 --- /dev/null +++ b/Packages/BitMatchEngine/Tests/BitMatchEngineTests/ExpectedExclusionQueuePolicyTests.swift @@ -0,0 +1,218 @@ +import Foundation +import XCTest +@testable import BitMatchEngine + +final class ExpectedExclusionQueuePolicyTests: XCTestCase, @unchecked Sendable { + private let ordinary = TransferCompletion.ProjectGate( + didPersist: true, locallySafe: nil, allowsQueueContinuationForExpectedExclusions: true, + expectedExclusionQueueIdentity: .ordinaryTransfer + ) + + private struct Fixture { + let root: URL + let source: URL + let destinations: [URL] + var settings: CameraLabelSettings { + var value = CameraLabelSettings(); value.excludedAppleDoublePaths = ["._clip.bin"]; return value + } + var media: URL { source.appendingPathComponent("clip.bin") } + var metadata: URL { source.appendingPathComponent("._clip.bin") } + var journal: URL { root.appendingPathComponent("journal.json") } + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("expected-exclusion-queue-\(UUID())") + source = root.appendingPathComponent("Card") + destinations = [root.appendingPathComponent("BackupA"), root.appendingPathComponent("BackupB")] + for folder in [source] + destinations { try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) } + try Data("media".utf8).write(to: media) + var bytes = [UInt8](repeating: 0, count: 42) + func put(_ value: UInt32, _ offset: Int) { + for index in 0..<4 { bytes[offset + index] = UInt8(truncatingIfNeeded: value >> (24 - 8 * index)) } + } + put(0x00051607, 0); put(0x00020000, 4); bytes[25] = 1 + put(2, 26); put(38, 30); put(4, 34) + try Data(bytes).write(to: metadata) + } + func cleanup() { try? FileManager.default.removeItem(at: root) } + func row(source file: URL, destination: URL, excluded: Bool = false, + success: Bool = true, error: Error? = nil, verified: Bool = true) -> FileOperationResult { + let output = SafetyValidator.resolvedDestinationRoot(source: source, destination: destination, settings: settings) + .appendingPathComponent(file.lastPathComponent) + let verification = excluded || !verified ? nil : VerificationResult( + sourceChecksum: "sha", destinationChecksum: "sha", matches: true, + checksumType: .sha256, processingTime: 0, fileSize: 5 + ) + var value = FileOperationResult(sourceURL: file, destinationURL: output, success: success, + error: error, fileSize: excluded ? 42 : 5, verificationResult: verification, processingTime: 0) + value.excludedAppleDouble = excluded + return value + } + func operation(rows: [FileOperationResult]? = nil, mode: VerificationMode = .standard, + proof: [String]? = ["._clip.bin"], manifest: [URL]? = nil, + fingerprint: String? = "synthetic-source-fingerprint", hasManifest: Bool = true, settings override: CameraLabelSettings? = nil) -> FileOperation { + let values = rows ?? destinations.flatMap { [row(source: media, destination: $0), row(source: metadata, destination: $0, excluded: true, success: false)] } + var value = FileOperation(sourceURL: source, destinationURLs: destinations, startTime: Date(), endTime: Date(), + results: values, sourceManifest: hasManifest ? (manifest ?? [media, metadata]) : nil, sourceFingerprint: fingerprint, + verificationMode: mode, settings: override ?? settings, estimatedTotalBytes: nil) + value.reviewedAppleDoubleExclusionPaths = proof + return value + } + } + + private func qualification(_ operation: FileOperation, handoff: [String] = [], report: String? = nil, + project: TransferCompletion.ProjectGate? = nil) -> ExpectedExclusionQueueQualification? { + TransferCompletion.expectedExclusionQueueQualification(operation: operation, expectedMode: operation.verificationMode, handoffIssues: handoff, + reportIssue: report, project: project ?? ordinary) + } + + func testRealPipelineEarnsQueueQualificationWithoutWholeCardSuccess() async throws { + try await FileOperationsTestLock.shared.run { + let f = try Fixture(); defer { f.cleanup() } + let beforeMedia = try Data(contentsOf: f.media), beforeMetadata = try Data(contentsOf: f.metadata) + let operation = try await TransferPipeline(fileSystem: LocalFileAccess(), checksum: ChecksumEngine.shared) + .performFileOperation(sourceURL: f.source, destinationURLs: f.destinations, verificationMode: .standard, + settings: f.settings, progressCallback: { _ in }, onFileResult: nil) + XCTAssertEqual(operation.reviewedAppleDoubleExclusionPaths, ["._clip.bin"]) + XCTAssertEqual(qualification(operation), .ordinaryTransfer) + let verdict = TransferCompletion.verdict(rows: TransferCompletion.rows(from: operation), + sourceFiles: operation.sourceManifest, destinations: f.destinations, source: f.source, settings: f.settings, + mode: .standard, generateASCMHL: false, handoffIssues: [], reportIssue: nil, project: ordinary) + XCTAssertFalse(verdict.success) + XCTAssertFalse(verdict.copiedNotVerified) + XCTAssertEqual(try Data(contentsOf: f.media), beforeMedia) + XCTAssertEqual(try Data(contentsOf: f.metadata), beforeMetadata) + } + } + + func testManualStatusRowsWithoutPipelineProvenanceNeverQualify() throws { + let f = try Fixture(); defer { f.cleanup() } + XCTAssertNil(qualification(f.operation(proof: nil))) + XCTAssertNil(qualification(f.operation(proof: ["._different.bin"]))) + XCTAssertNil(qualification(f.operation(fingerprint: nil))) + XCTAssertNil(qualification(f.operation(hasManifest: false))) + XCTAssertNil(TransferCompletion.expectedExclusionQueueQualification(operation: f.operation(), expectedMode: .thorough, handoffIssues: [], reportIssue: nil, project: ordinary)) + var settings = f.settings; settings.excludedAppleDoublePaths = nil + XCTAssertNil(qualification(f.operation(settings: settings))) + } + + func testMissingDuplicateUnverifiedAndUnexpectedRowsNeverQualify() throws { + let f = try Fixture(); defer { f.cleanup() } + let rows = f.operation().results + for mutated in [Array(rows.dropLast()), Array(rows.dropFirst()), rows + [rows[0]], rows + [rows[1]], + [f.row(source: f.media, destination: f.destinations[0], verified: false)] + Array(rows.dropFirst()), + [f.row(source: f.source.appendingPathComponent("unexpected.bin"), destination: f.destinations[0])] + Array(rows.dropFirst())] { + XCTAssertNil(qualification(f.operation(rows: mutated))) + } + } + + func testSelectedTypedErrorsCannotHideBehindVerifiedStatus() throws { + let f = try Fixture(); defer { f.cleanup() } + let rows = f.operation().results + XCTAssertNil(qualification(f.operation(rows: [f.row(source: f.media, destination: f.destinations[0], success: false)] + Array(rows.dropFirst())))) + XCTAssertNil(qualification(f.operation(rows: [f.row(source: f.media, destination: f.destinations[0], error: NSError(domain: "test", code: 1))] + Array(rows.dropFirst())))) + } + + func testQuickHandoffReportAndUnknownProjectRemainBlocked() throws { + let f = try Fixture(); defer { f.cleanup() } + let operation = f.operation() + XCTAssertNil(qualification(f.operation(mode: .quick))) + XCTAssertNil(qualification(operation, handoff: ["MHL failed"])) + XCTAssertNil(qualification(operation, report: "Report failed")) + XCTAssertNil(qualification(operation, project: .init(didPersist: false, locallySafe: nil))) + XCTAssertNil(qualification(operation, project: .init(didPersist: true, locallySafe: false))) + XCTAssertNil(qualification(operation, project: .init(didPersist: true, locallySafe: nil))) + XCTAssertNil(qualification(operation, project: .init(didPersist: true, locallySafe: nil, + allowsQueueContinuationForExpectedExclusions: true))) + } + + func testExcludedOnlyChangedManifestAndUnreviewedMetadataRemainBlocked() throws { + let f = try Fixture(); defer { f.cleanup() } + XCTAssertNil(qualification(f.operation(rows: f.operation().results.filter(\.excludedAppleDouble), manifest: [f.metadata]))) + XCTAssertNil(qualification(f.operation(manifest: [f.media, f.metadata, f.source.appendingPathComponent("missing.bin")]))) + var settings = f.settings; settings.excludedAppleDoublePaths = ["._clip.bin", "._clip.bin"] + XCTAssertNil(qualification(f.operation(settings: settings))) + settings.excludedAppleDoublePaths = ["../._clip.bin"] + XCTAssertNil(qualification(f.operation(proof: ["../._clip.bin"], settings: settings))) + } + + func testFinalProjectScopeRequiresPositiveQualifierAndStillBlocksHandoffErrors() throws { + let f = try Fixture(); defer { f.cleanup() } + let job = UUID(), card = UUID() + let identity = ExpectedExclusionQueueQualification.projectTransfer(projectID: job, cardID: card) + let project = TransferCompletion.ProjectGate(didPersist: true, locallySafe: false, + allowsQueueContinuationForExpectedExclusions: true, expectedExclusionQueueIdentity: identity) + XCTAssertFalse(project.permitsSuccess) + XCTAssertEqual(qualification(f.operation(), project: project), identity) + XCTAssertNil(qualification(f.operation(), handoff: ["MHL failed"], project: project)) + XCTAssertNil(qualification(f.operation(), report: "Report failed", project: project)) + XCTAssertNil(qualification(f.operation(), project: .init(didPersist: false, locallySafe: false, + allowsQueueContinuationForExpectedExclusions: true, expectedExclusionQueueIdentity: identity))) + XCTAssertNil(qualification(f.operation(), project: .init(didPersist: true, locallySafe: nil, + allowsQueueContinuationForExpectedExclusions: true, expectedExclusionQueueIdentity: identity))) + } + + private func finish(_ journal: TransferJournal, fixture f: Fixture, operation: FileOperation, + qualification: ExpectedExclusionQueueQualification? = .ordinaryTransfer, + projectID: UUID? = nil, cardID: UUID? = nil) throws -> UUID { + let id = try journal.enqueue(sourceURL: f.source, destinationURLs: f.destinations, verificationMode: operation.verificationMode, + cameraSettings: f.settings, reportSettings: ReportPrefs(makeReport: false), generateASCMHL: false, + projectID: projectID, projectCardID: cardID) + try journal.markRunning(id: id) + try journal.recordPlan(id: id, fileCounts: [1, 1]) + try journal.finish(id: id, results: TransferCompletion.rows(from: operation), summary: "Selected scope only; keep source.", + hadIssues: true, expectedExclusionQueueQualification: qualification, sourceFingerprint: operation.sourceFingerprint) + return id + } + + func testDurableQualificationSurvivesReloadAndSourceRemovalButNeverBecomesSafe() throws { + let f = try Fixture(); defer { f.cleanup() } + var journal: TransferJournal? = TransferJournal(fileURL: f.journal) + let id = try finish(journal!, fixture: f, operation: f.operation()) + let record = try XCTUnwrap(journal!.records.first { $0.id == id }) + XCTAssertEqual(record.state, .issues) + XCTAssertTrue(TransferJournal.permitsExpectedExclusionQueueContinuation(record)) + XCTAssertNil(journal!.matchingVerifiedRecord(sourceFingerprint: "synthetic-source-fingerprint")) + let rowIDs = record.results.map(\.id) + journal = nil + try FileManager.default.removeItem(at: f.source) + let restored = TransferJournal(fileURL: f.journal) + let saved = try XCTUnwrap(restored.records.first) + XCTAssertEqual(saved.results.map(\.id), rowIDs) + XCTAssertEqual(saved.state, .issues) + XCTAssertTrue(TransferJournal.permitsExpectedExclusionQueueContinuation(saved)) + } + + func testLegacyUnknownOrStaleQualificationsFailClosed() throws { + let f = try Fixture(); defer { f.cleanup() } + let journal = TransferJournal(fileURL: f.journal) + _ = try finish(journal, fixture: f, operation: f.operation()) + let record = try XCTUnwrap(journal.records.first) + for state in [LocalTransferState.running, .cancelled, .interrupted, .failed, .completed, .queued] { + var changed = record; changed.state = state + XCTAssertFalse(TransferJournal.permitsExpectedExclusionQueueContinuation(changed)) + } + var changed = record; changed.plannedFileCounts = nil + XCTAssertFalse(TransferJournal.permitsExpectedExclusionQueueContinuation(changed)) + changed = record; changed.results.removeLast() + XCTAssertFalse(TransferJournal.permitsExpectedExclusionQueueContinuation(changed)) + changed = record; changed.expectedExclusionQueueQualification = nil + XCTAssertFalse(TransferJournal.permitsExpectedExclusionQueueContinuation(changed)) + var object = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(record)) as? [String: Any]) + object["expectedExclusionQueueQualification"] = ["futureUnknownScope": [:]] + let unknown = try JSONDecoder().decode(LocalTransferRecord.self, from: JSONSerialization.data(withJSONObject: object)) + XCTAssertNil(unknown.expectedExclusionQueueQualification) + XCTAssertEqual(unknown.results.map(\.id), record.results.map(\.id)) + } + + func testJournalRejectsWrongScopeAndMissingCoverageOrFingerprint() throws { + let f = try Fixture(); defer { f.cleanup() } + let journal = TransferJournal(fileURL: f.journal) + _ = try finish(journal, fixture: f, operation: f.operation(), qualification: .projectTransfer(projectID: UUID(), cardID: UUID())) + XCTAssertNil(journal.records.first?.expectedExclusionQueueQualification) + _ = try finish(journal, fixture: f, operation: f.operation(rows: Array(f.operation().results.dropLast()))) + XCTAssertNil(journal.records.first?.expectedExclusionQueueQualification) + _ = try finish(journal, fixture: f, operation: f.operation(fingerprint: nil)) + XCTAssertNil(journal.records.first?.expectedExclusionQueueQualification) + _ = try finish(journal, fixture: f, operation: f.operation(), projectID: UUID(), cardID: UUID()) + XCTAssertNil(journal.records.first?.expectedExclusionQueueQualification) + } +} diff --git a/Shared/Core/Models/OperationModels.swift b/Shared/Core/Models/OperationModels.swift index e1a9e64f..74fdeb88 100644 --- a/Shared/Core/Models/OperationModels.swift +++ b/Shared/Core/Models/OperationModels.swift @@ -1,5 +1,6 @@ // OperationModels.swift - Operation and transfer state models import Foundation +import BitMatchEngine // MARK: - Operation State enum OperationState: Equatable { @@ -84,6 +85,7 @@ struct OperationCompletionInfo: Equatable { /// Quick mode copied every file and nothing else failed (see /// `TransferCompletion.Verdict.copiedNotVerified`). Never with `success`. var copiedNotVerified: Bool = false + var expectedExclusionQueueQualification: ExpectedExclusionQueueQualification? = nil } // MARK: - Volume Event diff --git a/Shared/Core/Models/PhotographerJobModels.swift b/Shared/Core/Models/PhotographerJobModels.swift index 941b5480..b84b7425 100644 --- a/Shared/Core/Models/PhotographerJobModels.swift +++ b/Shared/Core/Models/PhotographerJobModels.swift @@ -528,12 +528,46 @@ struct PhotographerReportPayload: Codable, Equatable, Sendable { return FinalizedCard(card: card, verifiedDestinationCount: 0) } + /// Conditional project evidence only. The executor must separately bind + /// these excluded paths to the pipeline's reviewed AppleDouble selection. + /// This never changes the persisted whole-card verdict or fingerprint. + static func allowsQueueContinuationForExpectedExclusions( + context: PhotographerReportContext, + results: [ResultRow], + excludedSourcePaths: Set + ) -> Bool { + guard !excludedSourcePaths.isEmpty, + let card = context.job.cardIngests.first(where: { $0.id == context.cardIngestID }), + card.localState == .copying || card.localState == .verifying, + context.job.requiredLocalCopyCount > 0, + let independentCount = context.independentDestinationCount, + independentCount >= context.job.requiredLocalCopyCount else { return false } + let originalPaths = context.analysis.sourcePaths + let originalSet = Set(originalPaths) + guard !originalPaths.isEmpty, + originalSet.count == originalPaths.count, + context.analysis.fileCount == originalPaths.count, + card.fileCount == originalPaths.count, + excludedSourcePaths.isSubset(of: originalSet), + excludedSourcePaths.allSatisfy({ ($0 as NSString).lastPathComponent.hasPrefix("._") }) else { return false } + let excludedRows = results.filter { ResultOutcome(statusText: $0.status) == .excludedAppleDouble } + guard Set(excludedRows.map(\.path)) == excludedSourcePaths else { return false } + let selectedRows = results.filter { ResultOutcome(statusText: $0.status) != .excludedAppleDouble } + guard !selectedRows.isEmpty, selectedRows.allSatisfy(\.isVerifiedStatus) else { return false } + return finalEvidence( + context: context, card: card, results: selectedRows, + excludingSourcePaths: excludedSourcePaths + ) != nil + } + private static func finalEvidence( context: PhotographerReportContext, card: CardIngest, - results: [ResultRow] + results: [ResultRow], + excludingSourcePaths: Set = [] ) -> FinalEvidence? { - let expectedPaths = context.analysis.sourcePaths + let originalPaths = context.analysis.sourcePaths + let expectedPaths = originalPaths.filter { !excludingSourcePaths.contains($0) } let expectedSet = Set(expectedPaths) let identifiedRows = results.compactMap { row -> (String, ResultRow)? in guard let identity = destinationIdentity(for: row, packagePath: card.renderedRelativePath) else { @@ -553,7 +587,7 @@ struct PhotographerReportPayload: Codable, Equatable, Sendable { ) let hasExactEvidence = !expectedPaths.isEmpty && expectedSet.count == expectedPaths.count - && expectedPaths.count == card.fileCount + && originalPaths.count == card.fileCount && identifiedRows.count == results.count && independentCount >= context.job.requiredLocalCopyCount && groups.values.allSatisfy { rows in diff --git a/Shared/Core/Services/CopyVerifyExecutor.swift b/Shared/Core/Services/CopyVerifyExecutor.swift index 9246a809..09c19551 100644 --- a/Shared/Core/Services/CopyVerifyExecutor.swift +++ b/Shared/Core/Services/CopyVerifyExecutor.swift @@ -10,6 +10,7 @@ struct PhotographerFinalizationResult { /// The persisted card verdict. A configured photographer finalizer must /// explicitly certify this before raw copy rows can complete successfully. let locallySafe: Bool + var allowsQueueContinuationForExpectedExclusions: Bool = false } typealias PhotographerReportFinalizer = @MainActor ([ResultRow]) throws -> PhotographerFinalizationResult @@ -33,6 +34,9 @@ struct CopyVerifyConfig { /// stays ungated with a nil default so existing call sites compile in /// every configuration — only non-nil values are DEBUG-only. let fanOutHooks: DestinationWriter.FanOutHooks? + /// Frozen run and saved-journal identity agreed before execution. Missing + /// for direct/legacy configs; never inferred from an absent finalizer. + let expectedExclusionQueueIdentity: ExpectedExclusionQueueQualification? init( operationId: UUID, @@ -46,7 +50,8 @@ struct CopyVerifyConfig { currentMode: AppMode, photographerReportFinalizer: PhotographerReportFinalizer? = nil, generateASCMHL: Bool = false, - fanOutHooks: DestinationWriter.FanOutHooks? = nil + fanOutHooks: DestinationWriter.FanOutHooks? = nil, + expectedExclusionQueueIdentity: ExpectedExclusionQueueQualification? = nil ) { self.operationId = operationId self.sourceURL = sourceURL @@ -60,6 +65,7 @@ struct CopyVerifyConfig { self.photographerReportFinalizer = photographerReportFinalizer self.generateASCMHL = generateASCMHL self.fanOutHooks = fanOutHooks + self.expectedExclusionQueueIdentity = expectedExclusionQueueIdentity } } @@ -97,6 +103,7 @@ final class CopyVerifyExecutor { /// `nil` means no photographer finalizer was configured for this /// ordinary copy. A configured finalizer must return `true`. let locallySafe: Bool? + var allowsQueueContinuationForExpectedExclusions: Bool = false } // MARK: - Dependencies @@ -469,7 +476,11 @@ final class CopyVerifyExecutor { let completionInfo = OperationCompletionInfo( success: succeeded, message: completionMessage, - copiedNotVerified: verdict.copiedNotVerified + copiedNotVerified: verdict.copiedNotVerified, + expectedExclusionQueueQualification: TransferCompletion.expectedExclusionQueueQualification( + operation: operation, expectedMode: config.verificationMode, handoffIssues: handoffIssues, reportIssue: reportIssue, + project: Self.expectedExclusionProjectGate(config: config, lifecycle: photographerLifecycle) + ) ) timingService.completeOperation(success: succeeded, message: completionMessage) @@ -485,6 +496,31 @@ final class CopyVerifyExecutor { return operation } + /// Queue identity is separate from the project's whole-card safety gate. + /// A missing/mismatched project context cannot masquerade as an ordinary run. + static func expectedExclusionProjectGate( + config: CopyVerifyConfig, lifecycle: PhotographerLifecycleFinalization + ) -> TransferCompletion.ProjectGate { + var allowed = false + if let identity = config.expectedExclusionQueueIdentity, lifecycle.didPersist { + switch identity { + case .ordinaryTransfer: + allowed = config.photographerReportFinalizer == nil + && lifecycle.context == nil && lifecycle.locallySafe == nil + case .projectTransfer(let projectID, let cardID): + allowed = config.photographerReportFinalizer != nil + && lifecycle.context?.job.id == projectID && lifecycle.context?.cardIngestID == cardID + && lifecycle.locallySafe != nil + && lifecycle.allowsQueueContinuationForExpectedExclusions + } + } + return TransferCompletion.ProjectGate( + didPersist: lifecycle.didPersist, locallySafe: lifecycle.locallySafe, + allowsQueueContinuationForExpectedExclusions: allowed, + expectedExclusionQueueIdentity: allowed ? config.expectedExclusionQueueIdentity : nil + ) + } + private func createASCMHLHistories(operation: FileOperation, config: CopyVerifyConfig, callbacks: CopyVerifyCallbacks) async throws -> [String] { guard config.generateASCMHL, config.verificationMode != .quick else { return [] } @@ -693,7 +729,8 @@ final class CopyVerifyExecutor { return PhotographerLifecycleFinalization( context: result?.context, didPersist: true, - locallySafe: result?.locallySafe + locallySafe: result?.locallySafe, + allowsQueueContinuationForExpectedExclusions: result?.allowsQueueContinuationForExpectedExclusions ?? false ) } catch let interruption as TransferInterruption { throw interruption diff --git a/Shared/Core/Services/LocalTransferJournal.swift b/Shared/Core/Services/LocalTransferJournal.swift index f140d673..4fb28139 100644 --- a/Shared/Core/Services/LocalTransferJournal.swift +++ b/Shared/Core/Services/LocalTransferJournal.swift @@ -172,6 +172,7 @@ final class LocalTransferJournal: ObservableObject { summary: String, hadIssues: Bool, quickOnlyUnverified: Bool? = nil, + expectedExclusionQueueQualification: ExpectedExclusionQueueQualification? = nil, copyDurationSeconds: TimeInterval? = nil, verifyDurationSeconds: TimeInterval? = nil, performanceTelemetry: TransferPerformanceTelemetry? = nil, @@ -185,6 +186,7 @@ final class LocalTransferJournal: ObservableObject { summary: summary, hadIssues: hadIssues, quickOnlyUnverified: quickOnlyUnverified, + expectedExclusionQueueQualification: expectedExclusionQueueQualification, copyDurationSeconds: copyDurationSeconds, verifyDurationSeconds: verifyDurationSeconds, performanceTelemetry: performanceTelemetry, diff --git a/Shared/Core/Services/SharedAppCoordinator.swift b/Shared/Core/Services/SharedAppCoordinator.swift index be91c497..e98866cb 100644 --- a/Shared/Core/Services/SharedAppCoordinator.swift +++ b/Shared/Core/Services/SharedAppCoordinator.swift @@ -144,6 +144,20 @@ enum SavedTransferPlanGate { } } +/// Only a frozen run whose saved identity agrees can propose queue scope. +/// This is not the final qualification; the executor still proves all gates. +enum SavedExpectedExclusionQueueIdentity { + static func matching(projectID: UUID?, projectCardID: UUID?, record: LocalTransferRecord?) -> ExpectedExclusionQueueQualification? { + guard let record, record.state == .running, + record.projectID == projectID, record.projectCardID == projectCardID else { return nil } + switch (projectID, projectCardID) { + case (nil, nil): return .ordinaryTransfer + case (.some(let project), .some(let card)): return .projectTransfer(projectID: project, cardID: card) + default: return nil + } + } +} + @MainActor class SharedAppCoordinator: ObservableObject { enum CancellationSettlementError: LocalizedError, Equatable { @@ -1771,7 +1785,8 @@ class SharedAppCoordinator: ObservableObject { private static func isQueueProblem(_ record: LocalTransferRecord) -> Bool { let safety = TransferLibraryPresentation.safetyState(for: record) - return safety == .needsAttention || safety == .failed || safety == .interrupted + return (safety == .needsAttention || safety == .failed || safety == .interrupted) + && !TransferJournal.permitsExpectedExclusionQueueContinuation(record) } func startOperation( @@ -2024,6 +2039,10 @@ class SharedAppCoordinator: ObservableObject { let runResults = TransferRunResults() let projectIdentity = context.projectIdentity let runBelongsToQueueSession = currentTransferBelongsToQueueSession + let expectedExclusionQueueIdentity = SavedExpectedExclusionQueueIdentity.matching( + projectID: context.projectID, projectCardID: context.projectCardID, + record: transferJournal.records.first(where: { $0.id == recordID }) + ) let config = CopyVerifyConfig( operationId: startID, sourceURL: context.sourceURL, @@ -2039,7 +2058,8 @@ class SharedAppCoordinator: ObservableObject { // Screenshot seam (`-BitMatchDemoSlow`): non-nil only for the // demo seeder's own records on a flagged DEBUG launch; nil // everywhere else, and always nil in Release. - fanOutHooks: demoFanOutHooks(for: context.journalRecordID) + fanOutHooks: demoFanOutHooks(for: context.journalRecordID), + expectedExclusionQueueIdentity: expectedExclusionQueueIdentity ) let callbacks = CopyVerifyCallbacks( @@ -2147,6 +2167,7 @@ class SharedAppCoordinator: ObservableObject { summary: info.message, hadIssues: !info.success, quickOnlyUnverified: info.copiedNotVerified, + expectedExclusionQueueQualification: info.expectedExclusionQueueQualification, copyDurationSeconds: durations.copySeconds, verifyDurationSeconds: durations.verifySeconds, performanceTelemetry: copyVerifyExecutor.completedPerformanceTelemetry, @@ -2262,6 +2283,12 @@ class SharedAppCoordinator: ObservableObject { ?? (isReplayingQueuedTransfer || queueSessionRecordIDs.contains(recordID)) switch safety { case .needsAttention, .failed, .interrupted: + if belongs && TransferJournal.permitsExpectedExclusionQueueContinuation(record) { + // Every selected file and requested handoff verified; only + // reviewed metadata was excluded. Keep the amber verdict and + // finish notification, but let this queue run its next card. + break + } if belongs { pauseQueueAttempt(recordID: recordID, message: record.summary) } else { diff --git a/Shared/Core/ViewModels/PhotographerJobViewModel.swift b/Shared/Core/ViewModels/PhotographerJobViewModel.swift index 3fb21304..ce63f90b 100644 --- a/Shared/Core/ViewModels/PhotographerJobViewModel.swift +++ b/Shared/Core/ViewModels/PhotographerJobViewModel.swift @@ -662,6 +662,15 @@ final class PhotographerJobViewModel: ObservableObject { warnings: duplicateWarning.map { [$0.message] } ?? [], independentDestinationCount: independentDestinationCount ) + // Compute before unsafe whole-card finalization clears the verified + // destination count. The executor still has to prove these exclusions + // exactly match the pipeline's reviewed selection and clean handoffs. + let excludedSourcePaths = Set(results.filter { + ResultOutcome(statusText: $0.status) == .excludedAppleDouble + }.map(\.path)) + let allowsExpectedExclusionQueueContinuation = PhotographerReportPayload.allowsQueueContinuationForExpectedExclusions( + context: context, results: results, excludedSourcePaths: excludedSourcePaths + ) let finalized: PhotographerReportPayload.FinalizedCard do { finalized = try PhotographerReportPayload.finalizedCard( @@ -699,7 +708,8 @@ final class PhotographerJobViewModel: ObservableObject { return finalizationResult( job: jobForRun(jobID) ?? job, cardID: cardID, - analysis: analysis + analysis: analysis, + allowsQueueContinuationForExpectedExclusions: allowsExpectedExclusionQueueContinuation ) } @@ -1127,7 +1137,8 @@ final class PhotographerJobViewModel: ObservableObject { private func finalizationResult( job: PhotographerJob, cardID: UUID, - analysis: CardAnalysis + analysis: CardAnalysis, + allowsQueueContinuationForExpectedExclusions: Bool = false ) -> PhotographerFinalizationResult { let card = job.cardIngests.first { $0.id == cardID } let context = PhotographerReportContext( @@ -1144,7 +1155,8 @@ final class PhotographerJobViewModel: ObservableObject { ) return PhotographerFinalizationResult( context: context, - locallySafe: card?.localState == .locallySafe + locallySafe: card?.localState == .locallySafe, + allowsQueueContinuationForExpectedExclusions: allowsQueueContinuationForExpectedExclusions ) } diff --git a/docs/validation/queue-expected-exclusions-2026-10-20.md b/docs/validation/queue-expected-exclusions-2026-10-20.md new file mode 100644 index 00000000..2953f1e9 --- /dev/null +++ b/docs/validation/queue-expected-exclusions-2026-10-20.md @@ -0,0 +1,53 @@ +# Queue continuation after expected metadata exclusions + +October 20, 2026. Validated source: `9581e48eba6d2b6115d3ca62b2849068ae0d309d`. + +A queued transfer could stop for review even when every selected file verified and its only exclusions were the AppleDouble metadata files the user chose to ignore. That specific result can now advance to the next card. + +Queue advancement is separate from permission to erase a card. Exclusions remain in History and reports, and the whole-card result stays conservative. This change does not grant a safe-to-erase verdict or automatic eject permission, or add another setting. Quick keeps its existing queue behavior; it cannot earn this new verified-exclusions qualification. + +## What must pass + +The engine must have recognized and validated the reviewed exclusions during the actual transfer. A filename starting with `._`, a row labeled excluded, or a collection of verified rows alone is not enough. + +The final result must cover every selected file and every reviewed exclusion exactly once at every destination, match the captured source manifest and requested verification mode, and contain no failed, duplicate, missing or unexpected selected rows. Selected files must be independently verified. Requested MHL histories and reports must finish without error, and the terminal journal record must save successfully. Cancellation, interruption and excluded-only transfers cannot earn the qualification. + +Projects also require matching saved project/card identities, enough independent verified copies, and successful project persistence. The conditional project check removes only the exclusions bound to the actual engine result; it does not relax the whole-card verdict. Unknown, missing or legacy qualifications stay blocked. Reload checks the saved scope and rows without requiring the processed source to remain connected. + +## Regression coverage + +There are 29 new cases: 10 engine tests, six queue integration tests, three identity/scope tests and 10 project tests. The existing Quick queue control is also explicitly required to pass. + +The five transfer integration cases run the real copy/verification pipeline; the sixth checks legacy restore. They show a reviewed-exclusion card advancing to a completed next card, and still pausing for an existing MHL history, report-path conflict, selected-file conflict or journal failure. Conflicting bytes and source bytes are checked for preservation. The positive case checks the saved unsafe whole-card verdict, retained exclusion rows, MHL chain existence, and nonempty requested PDF, JSON and CSV files. These artifact checks are not a new visual PDF review. + +The remaining cases exercise exact coverage, malformed typed results, absent engine provenance, unreviewed exclusions, changed manifests, missing or mismatched identities, insufficient project independence, failed persistence, stale results and legacy restore. A durable qualification is reloaded after removing the synthetic source, without promoting the saved safety verdict. Deliberately constructed unit fixtures are separate from the real-pipeline cases. + +## Local results + +All source and configuration hashes stayed stable across the seven core gates: 450 tracked files. The tested code includes the filename-safety changes from the preceding PR; this document describes only the queue policy. + +| Run | Passed | Skipped | Failed | Count basis | +| --- | ---: | ---: | ---: | --- | +| Engine Debug, XCTest | 205 | 43 | 0 | 248 scheduled methods | +| Engine Debug, Swift Testing | 217 | 6 | 0 | 223 reported tests, 40 suites | +| Engine Release, XCTest | 205 | 43 | 0 | 248 scheduled methods | +| Engine Release, Swift Testing | 217 | 6 | 0 | 223 reported tests, 40 suites | +| Full Mac app suite | 1,301 | 23 | 0 | 1,324 total | +| iPad simulator suite | 75 | 10 | 0 | 85 total | +| iPhone simulator suite | 75 | 10 | 0 | 85 total | +| Mac import-review layout capture | 1 | 0 | 0 | One opt-in hosted layout test | + +The 10 new engine cases passed in both configurations. All 19 new app cases and the existing Quick control passed in the full Mac run. Debug and Release repeat the same tests; they are not additional unique cases. Opt-in tests skipped by the ordinary gates are not counted as passes. + +Unsigned iOS-device arm64 and universal Mac arm64/x86_64 Release builds passed. A separate local universal diagnostic archive passed deep, strict signature verification with matching executable/dSYM UUIDs for both architectures. Existing signing settings were retained. The archive was not exported, installed, notarized or published. A universal build is not native Intel runtime coverage. + +Structured results and raw-log runtime-warning checks were clear for the accepted runs. Compiler and toolchain warnings remain recorded separately; this is not an all-warnings-free claim. + +## Retained controller failures and limits + +Three optional mobile controllers failed auxiliary process-enumeration or settlement checks after a process-list query timed out. Their failed receipts remain failed. The iPad suite had already completed in the first attempt, and its actual passing test result was validated separately. The iPhone suite passed in a separate, bounded direct run on the same frozen source, with exact test counts, zero expected failures, stable source hashes and completed clone shutdown. That pass does not rewrite the earlier controller outcomes. + +The import-review test produced four PNG captures: nine changes at the top, middle and bottom, and a short two-change review. All four were inspected. It checks compact sizing, reachable text, title padding and pinned Cancel/Apply buttons in a production `NSHostingView`/`NSWindow`. The initial wrapper expected `TEST SUCCEEDED`, while `test-without-building` printed `TEST EXECUTE SUCCEEDED`; the existing native result was revalidated as one pass without rerunning or editing the test. Both the failed wrapper receipt and the corrected validation remain retained. This was hosted layout evidence, not a native file-picker, button-action or applied-settings walkthrough. + +These queue cases use tiny, owned host fixtures. They do not establish physical mobile-device behavior, every drive/camera combination, or the cause of issue #10 or #23. The separate large physical HFS+ follow-up was unfinished at this checkpoint and is not counted as a pass here. Nothing in this validation is a release announcement. +