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:
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)
+ )
+}