apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README

commit a74b8041cd092bc54f5cb59d6482cc4932dd8beb
parent 8b37d0c0fd38416947781f1b03e7b086da1b98df
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 12:58:17 -0700

capture: add intake contracts

- define media picker and document scanner request models
- add typed capture-intake errors and metadata validation
- provide deterministic RadrootsKitTesting fakes
- cover contract normalization and fake outcomes with Swift tests

Diffstat:
ASources/RadrootsKit/RadrootsCaptureIntake.swift | 330+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKitTesting/RadrootsCaptureIntakeTesting.swift | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTestingTests/RadrootsCaptureIntakeTestingTests.swift | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsCaptureIntakeTests.swift | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 749 insertions(+), 0 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsCaptureIntake.swift b/Sources/RadrootsKit/RadrootsCaptureIntake.swift @@ -0,0 +1,330 @@ +import Foundation + +public enum RadrootsCaptureIntakeError: Error, Equatable, Sendable { + case invalidRequest(String) + case unavailable(String) + case permissionDenied(String) + case userCancelled(String) + case transientFailure(String) + case permanentFailure(String) +} + +extension RadrootsCaptureIntakeError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidRequest(let message): + message + case .unavailable(let message): + message + case .permissionDenied(let message): + message + case .userCancelled(let message): + message + case .transientFailure(let message): + message + case .permanentFailure(let message): + message + } + } +} + +public enum RadrootsMediaKind: String, Sendable, Equatable, Hashable, CaseIterable { + case image +} + +public enum RadrootsMediaSource: String, Sendable, Equatable, Hashable { + case libraryImport + case cameraCapture +} + +public struct RadrootsMediaImportRequest: Sendable, Equatable, Hashable { + public let allowedMediaKinds: [RadrootsMediaKind] + public let selectionLimit: Int + public let destinationScope: RadrootsFileScope + + public init( + allowedMediaKinds: [RadrootsMediaKind] = [.image], + selectionLimit: Int = 1, + destinationScope: RadrootsFileScope = .temporary + ) throws { + self.allowedMediaKinds = try RadrootsCaptureIntakeValidation.normalizedMediaKinds( + allowedMediaKinds, + field: "media import" + ) + self.selectionLimit = try RadrootsCaptureIntakeValidation.normalizedSelectionLimit(selectionLimit) + self.destinationScope = destinationScope + } +} + +public struct RadrootsMediaCaptureRequest: Sendable, Equatable, Hashable { + public let mediaKind: RadrootsMediaKind + public let destinationScope: RadrootsFileScope + + public init( + mediaKind: RadrootsMediaKind = .image, + destinationScope: RadrootsFileScope = .temporary + ) throws { + self.mediaKind = mediaKind + self.destinationScope = destinationScope + } +} + +public struct RadrootsMediaPickerSupport: Sendable, Equatable, Hashable { + public let importAvailable: Bool + public let cameraCaptureAvailable: Bool + public let supportedImportKinds: [RadrootsMediaKind] + public let supportedCaptureKinds: [RadrootsMediaKind] + public let multipleSelectionSupported: Bool + + public init( + importAvailable: Bool, + cameraCaptureAvailable: Bool, + supportedImportKinds: [RadrootsMediaKind], + supportedCaptureKinds: [RadrootsMediaKind], + multipleSelectionSupported: Bool + ) throws { + self.importAvailable = importAvailable + self.cameraCaptureAvailable = cameraCaptureAvailable + self.supportedImportKinds = importAvailable + ? try RadrootsCaptureIntakeValidation.normalizedMediaKinds(supportedImportKinds, field: "media import support") + : [] + self.supportedCaptureKinds = cameraCaptureAvailable + ? try RadrootsCaptureIntakeValidation.normalizedMediaKinds(supportedCaptureKinds, field: "camera capture support") + : [] + self.multipleSelectionSupported = multipleSelectionSupported + } +} + +public struct RadrootsMediaAsset: Sendable, Equatable, Hashable { + public let source: RadrootsMediaSource + public let kind: RadrootsMediaKind + public let file: RadrootsFileReference + public let mediaType: String + public let suggestedFilename: String + public let sizeBytes: UInt64 + public let pixelWidth: UInt32? + public let pixelHeight: UInt32? + public let capturedAt: Date + + public init( + source: RadrootsMediaSource, + kind: RadrootsMediaKind, + file: RadrootsFileReference, + mediaType: String, + suggestedFilename: String, + sizeBytes: UInt64, + pixelWidth: UInt32? = nil, + pixelHeight: UInt32? = nil, + capturedAt: Date + ) throws { + self.source = source + self.kind = kind + self.file = file + self.mediaType = try RadrootsCaptureIntakeValidation.normalizedMediaType(mediaType) + self.suggestedFilename = try RadrootsCaptureIntakeValidation.normalizedFilename(suggestedFilename) + self.sizeBytes = sizeBytes + self.pixelWidth = try RadrootsCaptureIntakeValidation.normalizedDimension(pixelWidth, field: "pixel width") + self.pixelHeight = try RadrootsCaptureIntakeValidation.normalizedDimension(pixelHeight, field: "pixel height") + if self.pixelWidth == nil || self.pixelHeight == nil { + guard self.pixelWidth == nil && self.pixelHeight == nil else { + throw RadrootsCaptureIntakeError.invalidRequest("image dimensions must include width and height together") + } + } + self.capturedAt = try RadrootsCaptureIntakeValidation.normalizedDate(capturedAt, field: "captured timestamp") + } +} + +public struct RadrootsMediaImportResult: Sendable, Equatable, Hashable { + public let items: [RadrootsMediaAsset] + + public init(items: [RadrootsMediaAsset]) throws { + guard !items.isEmpty else { + throw RadrootsCaptureIntakeError.invalidRequest("media import result cannot be empty") + } + self.items = items + } +} + +public struct RadrootsMediaCaptureResult: Sendable, Equatable, Hashable { + public let item: RadrootsMediaAsset + + public init(item: RadrootsMediaAsset) { + self.item = item + } +} + +public protocol RadrootsMediaPicker: Sendable { + func currentSupport() async throws -> RadrootsMediaPickerSupport + func importMedia(_ request: RadrootsMediaImportRequest) async throws -> RadrootsMediaImportResult + func captureMedia(_ request: RadrootsMediaCaptureRequest) async throws -> RadrootsMediaCaptureResult +} + +public enum RadrootsDocumentScannerOutputKind: String, Sendable, Equatable, Hashable, CaseIterable { + case pdf +} + +public struct RadrootsDocumentScannerSupport: Sendable, Equatable, Hashable { + public let interactiveScanAvailable: Bool + public let multiPageSupported: Bool + public let supportedOutputKinds: [RadrootsDocumentScannerOutputKind] + + public init( + interactiveScanAvailable: Bool, + multiPageSupported: Bool, + supportedOutputKinds: [RadrootsDocumentScannerOutputKind] + ) throws { + self.interactiveScanAvailable = interactiveScanAvailable + self.multiPageSupported = multiPageSupported && interactiveScanAvailable + self.supportedOutputKinds = interactiveScanAvailable + ? try RadrootsCaptureIntakeValidation.normalizedScannerOutputKinds(supportedOutputKinds) + : [] + } +} + +public struct RadrootsDocumentScanRequest: Sendable, Equatable, Hashable { + public let outputKind: RadrootsDocumentScannerOutputKind + public let destinationScope: RadrootsFileScope + + public init( + outputKind: RadrootsDocumentScannerOutputKind = .pdf, + destinationScope: RadrootsFileScope = .temporary + ) { + self.outputKind = outputKind + self.destinationScope = destinationScope + } +} + +public struct RadrootsScannedDocument: Sendable, Equatable, Hashable { + public let file: RadrootsFileReference + public let outputKind: RadrootsDocumentScannerOutputKind + public let suggestedFilename: String + public let mediaType: String + public let pageCount: UInt16 + public let sizeBytes: UInt64 + public let capturedAt: Date + + public init( + file: RadrootsFileReference, + outputKind: RadrootsDocumentScannerOutputKind, + suggestedFilename: String, + mediaType: String, + pageCount: UInt16, + sizeBytes: UInt64, + capturedAt: Date + ) throws { + self.file = file + self.outputKind = outputKind + self.suggestedFilename = try RadrootsCaptureIntakeValidation.normalizedFilename(suggestedFilename) + self.mediaType = try RadrootsCaptureIntakeValidation.normalizedMediaType(mediaType) + guard pageCount > 0 else { + throw RadrootsCaptureIntakeError.invalidRequest("scanned document page count must be positive") + } + self.pageCount = pageCount + self.sizeBytes = sizeBytes + self.capturedAt = try RadrootsCaptureIntakeValidation.normalizedDate(capturedAt, field: "scanned document timestamp") + } +} + +public protocol RadrootsDocumentScanner: Sendable { + func currentSupport() async throws -> RadrootsDocumentScannerSupport + func scanDocument(_ request: RadrootsDocumentScanRequest) async throws -> RadrootsScannedDocument +} + +public enum RadrootsCaptureIntakeValidation { + public static func normalizedMediaKinds(_ kinds: [RadrootsMediaKind], field: String) throws -> [RadrootsMediaKind] { + var seen = Set<RadrootsMediaKind>() + let normalized = kinds.filter { kind in + if seen.contains(kind) { + return false + } + seen.insert(kind) + return true + } + guard !normalized.isEmpty else { + throw RadrootsCaptureIntakeError.invalidRequest("\(field) must allow at least one media kind") + } + return normalized + } + + public static func normalizedSelectionLimit(_ selectionLimit: Int) throws -> Int { + guard selectionLimit > 0 else { + throw RadrootsCaptureIntakeError.invalidRequest("media import selection limit must be positive") + } + guard selectionLimit <= 100 else { + throw RadrootsCaptureIntakeError.invalidRequest("media import selection limit cannot exceed 100") + } + return selectionLimit + } + + public static func normalizedScannerOutputKinds( + _ outputKinds: [RadrootsDocumentScannerOutputKind] + ) throws -> [RadrootsDocumentScannerOutputKind] { + var seen = Set<RadrootsDocumentScannerOutputKind>() + let normalized = outputKinds.filter { outputKind in + if seen.contains(outputKind) { + return false + } + seen.insert(outputKind) + return true + } + guard !normalized.isEmpty else { + throw RadrootsCaptureIntakeError.invalidRequest("document scanner must support at least one output kind") + } + return normalized + } + + public static func normalizedFilename(_ filename: String) throws -> String { + let trimmed = filename.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsCaptureIntakeError.invalidRequest("capture filename cannot be empty") + } + guard trimmed != "." && trimmed != ".." else { + throw RadrootsCaptureIntakeError.invalidRequest("capture filename cannot be a path segment") + } + guard !NSString(string: trimmed).isAbsolutePath else { + throw RadrootsCaptureIntakeError.invalidRequest("capture filename cannot be absolute") + } + guard !trimmed.contains("/") && !trimmed.contains("\\") && !trimmed.contains("\0") else { + throw RadrootsCaptureIntakeError.invalidRequest("capture filename cannot contain path separators") + } + guard trimmed.rangeOfCharacter(from: .controlCharacters) == nil else { + throw RadrootsCaptureIntakeError.invalidRequest("capture filename cannot contain control characters") + } + guard trimmed.utf8.count <= 255 else { + throw RadrootsCaptureIntakeError.invalidRequest("capture filename is too long") + } + return trimmed + } + + public static func normalizedMediaType(_ mediaType: String) throws -> String { + let trimmed = mediaType.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsCaptureIntakeError.invalidRequest("capture media type cannot be empty") + } + guard trimmed.rangeOfCharacter(from: .whitespacesAndNewlines.union(.controlCharacters)) == nil else { + throw RadrootsCaptureIntakeError.invalidRequest("capture media type cannot contain whitespace") + } + let parts = trimmed.split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 2, parts.allSatisfy({ !$0.isEmpty }) else { + throw RadrootsCaptureIntakeError.invalidRequest("capture media type must be type/subtype") + } + return trimmed.lowercased() + } + + public static func normalizedDimension(_ dimension: UInt32?, field: String) throws -> UInt32? { + guard let dimension else { + return nil + } + guard dimension > 0 else { + throw RadrootsCaptureIntakeError.invalidRequest("\(field) must be positive") + } + return dimension + } + + public static func normalizedDate(_ date: Date, field: String) throws -> Date { + guard date.timeIntervalSinceReferenceDate.isFinite else { + throw RadrootsCaptureIntakeError.invalidRequest("\(field) must be finite") + } + return date + } +} diff --git a/Sources/RadrootsKitTesting/RadrootsCaptureIntakeTesting.swift b/Sources/RadrootsKitTesting/RadrootsCaptureIntakeTesting.swift @@ -0,0 +1,142 @@ +import Foundation +import RadrootsKit + +public actor RadrootsFakeMediaPicker: RadrootsMediaPicker { + private var support: RadrootsMediaPickerSupport + private var importOutcome: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError> + private var captureOutcome: Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError> + private var importRequestCountValue: Int + private var captureRequestCountValue: Int + private var supportRequestCountValue: Int + private var lastImportRequestValue: RadrootsMediaImportRequest? + private var lastCaptureRequestValue: RadrootsMediaCaptureRequest? + + public init( + support: RadrootsMediaPickerSupport, + importOutcome: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>, + captureOutcome: Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError> + ) { + self.support = support + self.importOutcome = importOutcome + self.captureOutcome = captureOutcome + self.importRequestCountValue = 0 + self.captureRequestCountValue = 0 + self.supportRequestCountValue = 0 + self.lastImportRequestValue = nil + self.lastCaptureRequestValue = nil + } + + public func setSupport(_ support: RadrootsMediaPickerSupport) { + self.support = support + } + + public func setImportOutcome(_ outcome: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>) { + self.importOutcome = outcome + } + + public func setCaptureOutcome(_ outcome: Result<RadrootsMediaCaptureResult, RadrootsCaptureIntakeError>) { + self.captureOutcome = outcome + } + + public func currentSupport() async throws -> RadrootsMediaPickerSupport { + supportRequestCountValue += 1 + return support + } + + public func importMedia(_ request: RadrootsMediaImportRequest) async throws -> RadrootsMediaImportResult { + importRequestCountValue += 1 + lastImportRequestValue = request + switch importOutcome { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + public func captureMedia(_ request: RadrootsMediaCaptureRequest) async throws -> RadrootsMediaCaptureResult { + captureRequestCountValue += 1 + lastCaptureRequestValue = request + switch captureOutcome { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + public var supportRequestCount: Int { + supportRequestCountValue + } + + public var importRequestCount: Int { + importRequestCountValue + } + + public var captureRequestCount: Int { + captureRequestCountValue + } + + public var lastImportRequest: RadrootsMediaImportRequest? { + lastImportRequestValue + } + + public var lastCaptureRequest: RadrootsMediaCaptureRequest? { + lastCaptureRequestValue + } +} + +public actor RadrootsFakeDocumentScanner: RadrootsDocumentScanner { + private var support: RadrootsDocumentScannerSupport + private var scanOutcome: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError> + private var supportRequestCountValue: Int + private var scanRequestCountValue: Int + private var lastScanRequestValue: RadrootsDocumentScanRequest? + + public init( + support: RadrootsDocumentScannerSupport, + scanOutcome: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError> + ) { + self.support = support + self.scanOutcome = scanOutcome + self.supportRequestCountValue = 0 + self.scanRequestCountValue = 0 + self.lastScanRequestValue = nil + } + + public func setSupport(_ support: RadrootsDocumentScannerSupport) { + self.support = support + } + + public func setScanOutcome(_ outcome: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) { + self.scanOutcome = outcome + } + + public func currentSupport() async throws -> RadrootsDocumentScannerSupport { + supportRequestCountValue += 1 + return support + } + + public func scanDocument(_ request: RadrootsDocumentScanRequest) async throws -> RadrootsScannedDocument { + scanRequestCountValue += 1 + lastScanRequestValue = request + switch scanOutcome { + case .success(let document): + return document + case .failure(let error): + throw error + } + } + + public var supportRequestCount: Int { + supportRequestCountValue + } + + public var scanRequestCount: Int { + scanRequestCountValue + } + + public var lastScanRequest: RadrootsDocumentScanRequest? { + lastScanRequestValue + } +} diff --git a/Tests/RadrootsKitTestingTests/RadrootsCaptureIntakeTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsCaptureIntakeTestingTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Testing +import RadrootsKit +import RadrootsKitTesting + +@Test func fakeMediaPickerReturnsConfiguredSupportAndResults() async throws { + let asset = try testMediaAsset() + let importResult = try RadrootsMediaImportResult(items: [asset]) + let captureResult = RadrootsMediaCaptureResult(item: asset) + let picker = RadrootsFakeMediaPicker( + support: try RadrootsMediaPickerSupport( + importAvailable: true, + cameraCaptureAvailable: true, + supportedImportKinds: [.image], + supportedCaptureKinds: [.image], + multipleSelectionSupported: true + ), + importOutcome: .success(importResult), + captureOutcome: .success(captureResult) + ) + let importRequest = try RadrootsMediaImportRequest(selectionLimit: 1) + let captureRequest = try RadrootsMediaCaptureRequest() + + #expect(try await picker.currentSupport().supportedImportKinds == [.image]) + #expect(try await picker.importMedia(importRequest) == importResult) + #expect(try await picker.captureMedia(captureRequest) == captureResult) + #expect(await picker.supportRequestCount == 1) + #expect(await picker.importRequestCount == 1) + #expect(await picker.captureRequestCount == 1) + #expect(await picker.lastImportRequest == importRequest) + #expect(await picker.lastCaptureRequest == captureRequest) +} + +@Test func fakeMediaPickerReturnsTypedFailures() async throws { + let asset = try testMediaAsset() + let picker = RadrootsFakeMediaPicker( + support: try RadrootsMediaPickerSupport( + importAvailable: true, + cameraCaptureAvailable: true, + supportedImportKinds: [.image], + supportedCaptureKinds: [.image], + multipleSelectionSupported: false + ), + importOutcome: .failure(.userCancelled("media import was cancelled")), + captureOutcome: .success(RadrootsMediaCaptureResult(item: asset)) + ) + + await #expect(throws: RadrootsCaptureIntakeError.userCancelled("media import was cancelled")) { + _ = try await picker.importMedia(try RadrootsMediaImportRequest()) + } + + await picker.setCaptureOutcome(.failure(.permissionDenied("camera access is denied"))) + + await #expect(throws: RadrootsCaptureIntakeError.permissionDenied("camera access is denied")) { + _ = try await picker.captureMedia(try RadrootsMediaCaptureRequest()) + } +} + +@Test func fakeDocumentScannerReturnsConfiguredSupportAndResults() async throws { + let document = try testScannedDocument() + let scanner = RadrootsFakeDocumentScanner( + support: try RadrootsDocumentScannerSupport( + interactiveScanAvailable: true, + multiPageSupported: true, + supportedOutputKinds: [.pdf] + ), + scanOutcome: .success(document) + ) + let request = RadrootsDocumentScanRequest() + + #expect(try await scanner.currentSupport().supportedOutputKinds == [.pdf]) + #expect(try await scanner.scanDocument(request) == document) + #expect(await scanner.supportRequestCount == 1) + #expect(await scanner.scanRequestCount == 1) + #expect(await scanner.lastScanRequest == request) +} + +@Test func fakeDocumentScannerReturnsTypedFailures() async throws { + let scanner = RadrootsFakeDocumentScanner( + support: try RadrootsDocumentScannerSupport( + interactiveScanAvailable: false, + multiPageSupported: false, + supportedOutputKinds: [] + ), + scanOutcome: .failure(.unavailable("document scanner unavailable")) + ) + + await #expect(throws: RadrootsCaptureIntakeError.unavailable("document scanner unavailable")) { + _ = try await scanner.scanDocument(RadrootsDocumentScanRequest()) + } +} + +private func testMediaAsset() throws -> RadrootsMediaAsset { + try RadrootsMediaAsset( + source: .libraryImport, + kind: .image, + file: RadrootsFileReference(scope: .temporary, relativePath: "capture/photo.jpg"), + mediaType: "image/jpeg", + suggestedFilename: "photo.jpg", + sizeBytes: 12, + capturedAt: Date(timeIntervalSince1970: 10) + ) +} + +private func testScannedDocument() throws -> RadrootsScannedDocument { + try RadrootsScannedDocument( + file: RadrootsFileReference(scope: .temporary, relativePath: "capture/scan.pdf"), + outputKind: .pdf, + suggestedFilename: "scan.pdf", + mediaType: "application/pdf", + pageCount: 2, + sizeBytes: 2048, + capturedAt: Date(timeIntervalSince1970: 11) + ) +} diff --git a/Tests/RadrootsKitTests/RadrootsCaptureIntakeTests.swift b/Tests/RadrootsKitTests/RadrootsCaptureIntakeTests.swift @@ -0,0 +1,162 @@ +import Foundation +import Testing +@testable import RadrootsKit + +@Test func mediaImportRequestNormalizesKindsAndSelectionLimit() throws { + let request = try RadrootsMediaImportRequest( + allowedMediaKinds: [.image, .image], + selectionLimit: 2, + destinationScope: .data + ) + + #expect(request.allowedMediaKinds == [.image]) + #expect(request.selectionLimit == 2) + #expect(request.destinationScope == .data) +} + +@Test func mediaImportRequestRejectsInvalidSelectionLimits() { + #expect(throws: RadrootsCaptureIntakeError.invalidRequest("media import selection limit must be positive")) { + _ = try RadrootsMediaImportRequest(selectionLimit: 0) + } + #expect(throws: RadrootsCaptureIntakeError.invalidRequest("media import selection limit cannot exceed 100")) { + _ = try RadrootsMediaImportRequest(selectionLimit: 101) + } +} + +@Test func mediaPickerSupportOmitsKindsWhenUnavailable() throws { + let support = try RadrootsMediaPickerSupport( + importAvailable: false, + cameraCaptureAvailable: false, + supportedImportKinds: [.image], + supportedCaptureKinds: [.image], + multipleSelectionSupported: true + ) + + #expect(!support.importAvailable) + #expect(!support.cameraCaptureAvailable) + #expect(support.supportedImportKinds.isEmpty) + #expect(support.supportedCaptureKinds.isEmpty) + #expect(support.multipleSelectionSupported) +} + +@Test func mediaAssetNormalizesMetadata() throws { + let asset = try RadrootsMediaAsset( + source: .libraryImport, + kind: .image, + file: RadrootsFileReference(scope: .temporary, relativePath: "capture/photo.jpg"), + mediaType: " Image/JPEG ", + suggestedFilename: " photo.jpg ", + sizeBytes: 12, + pixelWidth: 640, + pixelHeight: 480, + capturedAt: Date(timeIntervalSince1970: 10) + ) + + #expect(asset.mediaType == "image/jpeg") + #expect(asset.suggestedFilename == "photo.jpg") + #expect(asset.pixelWidth == 640) + #expect(asset.pixelHeight == 480) + #expect(asset.capturedAt == Date(timeIntervalSince1970: 10)) +} + +@Test func mediaAssetRejectsUnsafeMetadata() { + #expect(throws: RadrootsCaptureIntakeError.invalidRequest("capture filename cannot contain path separators")) { + _ = try RadrootsMediaAsset( + source: .libraryImport, + kind: .image, + file: RadrootsFileReference(scope: .temporary, relativePath: "capture/photo.jpg"), + mediaType: "image/jpeg", + suggestedFilename: "../photo.jpg", + sizeBytes: 12, + capturedAt: Date(timeIntervalSince1970: 10) + ) + } + #expect(throws: RadrootsCaptureIntakeError.invalidRequest("capture media type must be type/subtype")) { + _ = try RadrootsMediaAsset( + source: .libraryImport, + kind: .image, + file: RadrootsFileReference(scope: .temporary, relativePath: "capture/photo.jpg"), + mediaType: "image", + suggestedFilename: "photo.jpg", + sizeBytes: 12, + capturedAt: Date(timeIntervalSince1970: 10) + ) + } + #expect(throws: RadrootsCaptureIntakeError.invalidRequest("image dimensions must include width and height together")) { + _ = try RadrootsMediaAsset( + source: .libraryImport, + kind: .image, + file: RadrootsFileReference(scope: .temporary, relativePath: "capture/photo.jpg"), + mediaType: "image/jpeg", + suggestedFilename: "photo.jpg", + sizeBytes: 12, + pixelWidth: 640, + capturedAt: Date(timeIntervalSince1970: 10) + ) + } +} + +@Test func mediaImportResultRequiresAtLeastOneItem() throws { + let asset = try testMediaAsset() + let result = try RadrootsMediaImportResult(items: [asset]) + + #expect(result.items == [asset]) + #expect(throws: RadrootsCaptureIntakeError.invalidRequest("media import result cannot be empty")) { + _ = try RadrootsMediaImportResult(items: []) + } +} + +@Test func scannerSupportOmitsOutputKindsWhenUnavailable() throws { + let support = try RadrootsDocumentScannerSupport( + interactiveScanAvailable: false, + multiPageSupported: true, + supportedOutputKinds: [.pdf] + ) + + #expect(!support.interactiveScanAvailable) + #expect(!support.multiPageSupported) + #expect(support.supportedOutputKinds.isEmpty) +} + +@Test func scannedDocumentNormalizesPdfMetadata() throws { + let document = try RadrootsScannedDocument( + file: RadrootsFileReference(scope: .temporary, relativePath: "capture/scan.pdf"), + outputKind: .pdf, + suggestedFilename: " scan.pdf ", + mediaType: " Application/PDF ", + pageCount: 2, + sizeBytes: 2048, + capturedAt: Date(timeIntervalSince1970: 11) + ) + + #expect(document.suggestedFilename == "scan.pdf") + #expect(document.mediaType == "application/pdf") + #expect(document.pageCount == 2) + #expect(document.sizeBytes == 2048) +} + +@Test func scannedDocumentRejectsEmptyPageCount() { + #expect(throws: RadrootsCaptureIntakeError.invalidRequest("scanned document page count must be positive")) { + _ = try RadrootsScannedDocument( + file: RadrootsFileReference(scope: .temporary, relativePath: "capture/scan.pdf"), + outputKind: .pdf, + suggestedFilename: "scan.pdf", + mediaType: "application/pdf", + pageCount: 0, + sizeBytes: 2048, + capturedAt: Date(timeIntervalSince1970: 11) + ) + } +} + +private func testMediaAsset() throws -> RadrootsMediaAsset { + try RadrootsMediaAsset( + source: .libraryImport, + kind: .image, + file: RadrootsFileReference(scope: .temporary, relativePath: "capture/photo.jpg"), + mediaType: "image/jpeg", + suggestedFilename: "photo.jpg", + sizeBytes: 12, + capturedAt: Date(timeIntervalSince1970: 10) + ) +}