diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/MRZScanner.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/MRZScanner.xcscheme index d4b3139..d2c5211 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/MRZScanner.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/MRZScanner.xcscheme @@ -1,6 +1,6 @@ ScannedBoundingRects + var convert: @Sendable (_ results: [TextRecognizer.Result], _ validLines: [Validator.Result]) -> ScannedBoundingRects = { _, _ in .init(valid: [], invalid: []) } } extension BoundingRectConverter: DependencyKey { static var liveValue: Self { - .init( - convert: { results, validLines in - let allBoundingRects = results.map(\.boundingRect) - let validRectIndexes = Set(validLines.map(\.index)) - - var validScannedBoundingRects: [CGRect] = [] - var invalidScannedBoundingRects: [CGRect] = [] - allBoundingRects.enumerated().forEach { - if validRectIndexes.contains($0.offset) { - validScannedBoundingRects.append($0.element) - } else { - invalidScannedBoundingRects.append($0.element) - } + .init { results, validLines in + let allBoundingRects = results.map(\.boundingRect) + let validRectIndexes = Set(validLines.map(\.index)) + + var validScannedBoundingRects: [CGRect] = [] + var invalidScannedBoundingRects: [CGRect] = [] + allBoundingRects.enumerated().forEach { + if validRectIndexes.contains($0.offset) { + validScannedBoundingRects.append($0.element) + } else { + invalidScannedBoundingRects.append($0.element) } - - return .init(valid: validScannedBoundingRects, invalid: invalidScannedBoundingRects) } - ) + + return .init(valid: validScannedBoundingRects, invalid: invalidScannedBoundingRects) + } } } @@ -45,10 +45,6 @@ extension DependencyValues { #if DEBUG extension BoundingRectConverter: TestDependencyKey { - static var testValue: Self { - Self( - convert: unimplemented("BoundingRectConverter.convert") - ) - } + static let testValue = Self() } #endif diff --git a/Sources/MRZScanner/Private/Parser.swift b/Sources/MRZScanner/Private/Parser.swift index 67f3f39..37ac3e6 100644 --- a/Sources/MRZScanner/Private/Parser.swift +++ b/Sources/MRZScanner/Private/Parser.swift @@ -6,19 +6,21 @@ // import Dependencies +import DependenciesMacros import MRZParser +public typealias ParserResult = MRZResult + +@DependencyClient struct Parser: Sendable { - let parse: @Sendable (_ mrzLines: [String]) -> ParserResult? + var parse: @Sendable (_ mrzLines: [String]) -> ParserResult? } extension Parser: DependencyKey { static var liveValue: Self { - .init( - parse: { mrzLines in - MRZParser(isOCRCorrectionEnabled: true).parse(mrzLines: mrzLines) - } - ) + .init { mrzLines in + MRZParser(isOCRCorrectionEnabled: true).parse(mrzLines: mrzLines) + } } } @@ -31,10 +33,6 @@ extension DependencyValues { #if DEBUG extension Parser: TestDependencyKey { - static var testValue: Self { - Self( - parse: unimplemented("Parser.parse") - ) - } + static let testValue = Self() } #endif diff --git a/Sources/MRZScanner/Private/TextRecognizer.swift b/Sources/MRZScanner/Private/TextRecognizer.swift index 02d4fc2..c1d216c 100644 --- a/Sources/MRZScanner/Private/TextRecognizer.swift +++ b/Sources/MRZScanner/Private/TextRecognizer.swift @@ -7,45 +7,45 @@ import CoreImage import Dependencies +import DependenciesMacros import Vision +@DependencyClient struct TextRecognizer: Sendable { struct Result { let results: [String] let boundingRect: CGRect } - let recognize: @Sendable (_ configuration: ScanningConfiguration, _ scanningImage: CIImage) async throws -> [Result] + var recognize: @Sendable (_ configuration: ScanningConfiguration, _ scanningImage: CIImage) async throws -> [Result] } extension TextRecognizer: DependencyKey { static var liveValue: Self { - .init( - recognize: { request, scanningImage in - try await withCheckedThrowingContinuation { continuation in - let visionRequest = VNRecognizeTextRequest { request, _ in - guard let visionResults = request.results as? [VNRecognizedTextObservation] else { - return - } - - continuation.resume(returning: visionResults.map { - Result(results: $0.topCandidates(10).map(\.string), boundingRect: $0.boundingBox) - }) - } - visionRequest.regionOfInterest = request.regionOfInterest - visionRequest.minimumTextHeight = request.minimumTextHeight - visionRequest.recognitionLevel = request.recognitionLevel - visionRequest.usesLanguageCorrection = false - - do { - try VNImageRequestHandler(ciImage: scanningImage, orientation: request.orientation) - .perform([visionRequest]) - } catch { - continuation.resume(throwing: error) + .init { request, scanningImage in + try await withCheckedThrowingContinuation { continuation in + let visionRequest = VNRecognizeTextRequest { request, _ in + guard let visionResults = request.results as? [VNRecognizedTextObservation] else { + return } + + continuation.resume(returning: visionResults.map { + Result(results: $0.topCandidates(10).map(\.string), boundingRect: $0.boundingBox) + }) + } + visionRequest.regionOfInterest = request.regionOfInterest + visionRequest.minimumTextHeight = request.minimumTextHeight + visionRequest.recognitionLevel = request.recognitionLevel + visionRequest.usesLanguageCorrection = false + + do { + try VNImageRequestHandler(ciImage: scanningImage, orientation: request.orientation) + .perform([visionRequest]) + } catch { + continuation.resume(throwing: error) } } - ) + } } } @@ -58,10 +58,6 @@ extension DependencyValues { #if DEBUG extension TextRecognizer: TestDependencyKey { - static var testValue: Self { - Self( - recognize: unimplemented("TextRecognizer.recognize") - ) - } + static let testValue = Self() } #endif diff --git a/Sources/MRZScanner/Private/Tracker.swift b/Sources/MRZScanner/Private/Tracker.swift index 477fcb0..02dd4f3 100644 --- a/Sources/MRZScanner/Private/Tracker.swift +++ b/Sources/MRZScanner/Private/Tracker.swift @@ -6,23 +6,26 @@ // import Dependencies +import DependenciesMacros +public typealias TrackerResult = [ParserResult: Int] + +@DependencyClient struct Tracker: Sendable { - let updateResults: @Sendable (_ results: TrackerResult, _ result: ParserResult) -> TrackerResult + var currentResults: @Sendable () -> TrackerResult = { [:] } + var track: @Sendable (_ result: ParserResult) -> Void } extension Tracker: DependencyKey { static var liveValue: Self { - .init( - updateResults: { results, result in - var seenResults = results - guard let seenResultFrequency = seenResults[result] else { - seenResults[result] = 1 - return seenResults - } + let seenResults: LockIsolated = .init([:]) - seenResults[result] = seenResultFrequency + 1 - return seenResults + return .init( + currentResults: { seenResults.value }, + track: { result in + seenResults.withValue { seenResults in + seenResults[result, default: 0] += 1 + } } ) } @@ -37,10 +40,6 @@ extension DependencyValues { #if DEBUG extension Tracker: TestDependencyKey { - static var testValue: Self { - Self( - updateResults: unimplemented("Tracker.updateResults") - ) - } + static let testValue = Self() } #endif diff --git a/Sources/MRZScanner/Private/Validator.swift b/Sources/MRZScanner/Private/Validator.swift index caa4a4e..72da77e 100644 --- a/Sources/MRZScanner/Private/Validator.swift +++ b/Sources/MRZScanner/Private/Validator.swift @@ -6,8 +6,10 @@ // import Dependencies +import DependenciesMacros import MRZParser +@DependencyClient struct Validator: Sendable { struct Result { /// MRZLine @@ -16,33 +18,31 @@ struct Validator: Sendable { let index: Int } - let getValidatedResults: @Sendable (_ possibleLines: [[String]]) -> [Result] + var getValidatedResults: @Sendable (_ possibleLines: [[String]]) -> [Result] = { _ in [] } } extension Validator: DependencyKey { static var liveValue: Self { - .init( - getValidatedResults: { possibleLines in - var validLines: [Result] = [] + .init { possibleLines in + var validLines: [Result] = [] - for validMRZCode in MRZFormat.allCases { + for validMRZCode in MRZFormat.allCases { + guard validLines.count < validMRZCode.linesCount else { break } + for (index, lines) in possibleLines.enumerated() { guard validLines.count < validMRZCode.linesCount else { break } - for (index, lines) in possibleLines.enumerated() { - guard validLines.count < validMRZCode.linesCount else { break } - let spaceFreeLines = lines.lazy.map { $0.filter { !$0.isWhitespace } } - guard let mostLikelyLine = spaceFreeLines.first(where: { - $0.count == validMRZCode.lineLength - }) else { continue } - validLines.append(.init(result: mostLikelyLine, index: index)) - } + let spaceFreeLines = lines.lazy.map { $0.filter { !$0.isWhitespace } } + guard let mostLikelyLine = spaceFreeLines.first(where: { + $0.count == validMRZCode.lineLength + }) else { continue } + validLines.append(.init(result: mostLikelyLine, index: index)) + } - if validLines.count != validMRZCode.linesCount { - validLines = [] - } + if validLines.count != validMRZCode.linesCount { + validLines = [] } - return validLines } - ) + return validLines + } } } @@ -55,10 +55,6 @@ extension DependencyValues { #if DEBUG extension Validator: TestDependencyKey { - static var testValue: Self { - Self( - getValidatedResults: unimplemented("Validator.getValidatedResults") - ) - } + static let testValue = Self() } #endif diff --git a/Sources/MRZScanner/Public/ScannedBoundingRectsHelper.swift b/Sources/MRZScanner/Public/ScannedBoundingRectsHelper.swift new file mode 100644 index 0000000..9b27221 --- /dev/null +++ b/Sources/MRZScanner/Public/ScannedBoundingRectsHelper.swift @@ -0,0 +1,20 @@ +// +// ScannedBoundingRectsHelper.swift +// +// +// Created by Roman Mazeev on 26/06/2024. +// + +import Vision + +public extension ScannedBoundingRects { + /// Converts the normalized bounding rects to image rects + /// - Parameters: imageWidth: Width of the image + /// - Parameters: imageHeight: Height of the image + func convertedToImageRects(imageWidth: Int, imageHeight: Int) -> Self { + .init( + valid: valid.map { VNImageRectForNormalizedRect($0, imageWidth, imageHeight) }, + invalid: invalid.map { VNImageRectForNormalizedRect($0, imageWidth, imageHeight) } + ) + } +} diff --git a/Sources/MRZScanner/Public/Scanner.swift b/Sources/MRZScanner/Public/Scanner.swift index dc8db57..607e02b 100644 --- a/Sources/MRZScanner/Public/Scanner.swift +++ b/Sources/MRZScanner/Public/Scanner.swift @@ -9,7 +9,8 @@ import CoreImage import Dependencies import Vision -public struct ScanningConfiguration { +/// Configuration for scanning +public struct ScanningConfiguration: Sendable { let orientation: CGImagePropertyOrientation let regionOfInterest: CGRect let minimumTextHeight: Float @@ -26,51 +27,27 @@ public struct ScanningConfiguration { // MARK: Image stream scanning public extension AsyncStream { - func scanForMRZCode( - configuration: ScanningConfiguration, - scanningPriority: TaskPriority? = nil - ) -> AsyncThrowingStream, Error> { - .init { continuation in - let scanningTask = Task { - let seenResults: LockIsolated = .init([:]) - - for await image in self { - await withTaskGroup(of: Void.self) { group in - _ = group.addTaskUnlessCancelled(priority: scanningPriority) { - do { - @Dependency(\.textRecognizer) var textRecognizer - let recognizerResult = try await textRecognizer.recognize(configuration, image) - - @Dependency(\.validator) var validator - let validatedResults = validator.getValidatedResults(recognizerResult.map(\.results)) - - @Dependency(\.boundingRectConverter) var boundingRectConverter - let boundingRects = boundingRectConverter.convert(recognizerResult, validatedResults) - - @Dependency(\.parser) var parser - guard let parsedResult = parser.parse(validatedResults.map(\.result)) else { - continuation.yield(.init(results: seenResults.value, boundingRects: boundingRects)) - return - } - - @Dependency(\.tracker) var tracker - seenResults.withValue { - $0 = tracker.updateResults(seenResults.value, parsedResult) - } - - continuation.yield(.init(results: seenResults.value, boundingRects: boundingRects)) - } catch { - continuation.finish(throwing: error) - } - } - } - } - } + func scanForMRZCode(configuration: ScanningConfiguration) -> AsyncThrowingStream, Error> { + @Dependency(\.tracker) var tracker + + return map { image in + @Dependency(\.textRecognizer) var textRecognizer + let recognizerResult = try await textRecognizer.recognize(configuration: configuration, scanningImage: image) + + @Dependency(\.validator) var validator + let validatedResults = validator.getValidatedResults(possibleLines: recognizerResult.map(\.results)) - continuation.onTermination = { _ in - scanningTask.cancel() + @Dependency(\.boundingRectConverter) var boundingRectConverter + async let boundingRects = boundingRectConverter.convert(results: recognizerResult, validLines: validatedResults) + + @Dependency(\.parser) var parser + guard let parsedResult = parser.parse(mrzLines: validatedResults.map(\.result)) else { + return await .init(results: tracker.currentResults(), boundingRects: boundingRects) } - } + + tracker.track(result: parsedResult) + return await .init(results: tracker.currentResults(), boundingRects: boundingRects) + }.eraseToThrowingStream() } } @@ -83,19 +60,19 @@ public extension CIImage { func scanForMRZCode(configuration: ScanningConfiguration) async throws -> ScanningResult { @Dependency(\.textRecognizer) var textRecognizer - let recognizerResult = try await textRecognizer.recognize(configuration, self) + let recognizerResult = try await textRecognizer.recognize(configuration: configuration, scanningImage: self) @Dependency(\.validator) var validator - let validatedResults = validator.getValidatedResults(recognizerResult.map(\.results)) + let validatedResults = validator.getValidatedResults(possibleLines: recognizerResult.map(\.results)) @Dependency(\.boundingRectConverter) var boundingRectConverter - let boundingRects = boundingRectConverter.convert(recognizerResult, validatedResults) + async let boundingRects = boundingRectConverter.convert(results: recognizerResult, validLines: validatedResults) @Dependency(\.parser) var parser - guard let parsedResult = parser.parse(validatedResults.map(\.result)) else { + guard let parsedResult = parser.parse(mrzLines: validatedResults.map(\.result)) else { throw ScanningError.codeNotFound } - return .init(results: parsedResult, boundingRects: boundingRects) + return await .init(results: parsedResult, boundingRects: boundingRects) } } diff --git a/Sources/MRZScanner/Public/ScanningResult.swift b/Sources/MRZScanner/Public/ScanningResult.swift index 7d47ed8..18e3dee 100644 --- a/Sources/MRZScanner/Public/ScanningResult.swift +++ b/Sources/MRZScanner/Public/ScanningResult.swift @@ -5,70 +5,36 @@ // Created by Roman Mazeev on 29/12/2022. // -import CoreGraphics -import Vision +import CoreImage import MRZParser -public typealias TrackerResult = [ParserResult: Int] -public typealias ParserResult = MRZResult - -extension ParserResult: Hashable { - public func hash(into hasher: inout Hasher) { - hasher.combine(format) - hasher.combine(documentType) - hasher.combine(documentTypeAdditional) - hasher.combine(countryCode) - hasher.combine(surnames) - hasher.combine(givenNames) - hasher.combine(documentNumber) - hasher.combine(nationalityCountryCode) - hasher.combine(birthdate) - hasher.combine(sex) - hasher.combine(expiryDate) - hasher.combine(optionalData) - hasher.combine(optionalData2) - } +/// Bounding rectangles of the scanned text +public struct ScannedBoundingRects: Sendable { + public let valid: [CGRect], invalid: [CGRect] - public static func == (lhs: MRZResult, rhs: MRZResult) -> Bool { - lhs.format == rhs.format && - lhs.documentType == rhs.documentType && - lhs.documentTypeAdditional == rhs.documentTypeAdditional && - lhs.countryCode == rhs.countryCode && - lhs.surnames == rhs.surnames && - lhs.givenNames == rhs.givenNames && - lhs.documentNumber == rhs.documentNumber && - lhs.nationalityCountryCode == rhs.nationalityCountryCode && - lhs.birthdate == rhs.birthdate && - lhs.sex == rhs.sex && - lhs.expiryDate == rhs.expiryDate && - lhs.optionalData == rhs.optionalData && - lhs.optionalData2 == rhs.optionalData2 + public init(valid: [CGRect], invalid: [CGRect]) { + self.valid = valid + self.invalid = invalid } } -public struct ScanningResult { +/// Represents the result of the scanning process +/// - Note: In case of MRZ scanning, the result is sent as each frame is scanned +public struct ScanningResult: Sendable { + /// Results of scanning public let results: T public let boundingRects: ScannedBoundingRects } -public extension ScanningResult where T == [ParserResult: Int] { +/// Sets the text style +/// - Parameter repetitions: Minimum number of repetitions that the result should be found +/// - Returns: The best result +public extension ScanningResult where T == TrackerResult { func best(repetitions: Int) -> ParserResult? { - results.max(by: { $0.value > $1.value })?.key - } -} + guard let maxElement = results.max(by: { $0.value < $1.value }) else { + return nil + } -public struct ScannedBoundingRects { - public let valid: [CGRect], invalid: [CGRect] - - public func convertedToImageRects(imageWidth: Int, imageHeight: Int) -> Self { - .init( - valid: valid.map { VNImageRectForNormalizedRect($0, imageWidth, imageHeight) }, - invalid: invalid.map { VNImageRectForNormalizedRect($0, imageWidth, imageHeight) } - ) - } - - public init(valid: [CGRect], invalid: [CGRect]) { - self.valid = valid - self.invalid = invalid + return maxElement.value >= repetitions ? maxElement.key : nil } } diff --git a/Tests/MRZScannerTests/Private/BoundingRectConverterTests.swift b/Tests/MRZScannerTests/Private/BoundingRectConverterTests.swift index 67c93f4..32b68e3 100644 --- a/Tests/MRZScannerTests/Private/BoundingRectConverterTests.swift +++ b/Tests/MRZScannerTests/Private/BoundingRectConverterTests.swift @@ -49,3 +49,10 @@ final class BoundingRectConverterTests: XCTestCase { ) } } + +extension ScannedBoundingRects: @retroactive Equatable { + public static func == (lhs: ScannedBoundingRects, rhs: ScannedBoundingRects) -> Bool { + lhs.valid == rhs.valid && + lhs.invalid == rhs.invalid + } +} diff --git a/Tests/MRZScannerTests/Private/TextRecognizerTests.swift b/Tests/MRZScannerTests/Private/TextRecognizerTests.swift new file mode 100644 index 0000000..379b2f1 --- /dev/null +++ b/Tests/MRZScannerTests/Private/TextRecognizerTests.swift @@ -0,0 +1,51 @@ +// +// TextRecognizerTests.swift +// +// +// Created by Roman Mazeev on 02/12/2023. +// + +@testable import MRZScanner +import CoreImage +import XCTest + +final class TextRecognizerTests: XCTestCase { + func testScanImageSuccess() async throws { + let fileURL = try XCTUnwrap(Bundle.module.url(forResource: "TestImage", withExtension: "png")) + let imageData = try Data(contentsOf: fileURL) + let image = try XCTUnwrap(CIImage(data: imageData)) + + let result = try await TextRecognizer.liveValue.recognize(.mock(), image) + XCTAssertEqual(result, [ + .init(results: ["Red Green Purple"], boundingRect: .init(x: 0.1296875, y: 0.7222222222222222, width: 0.75, height: 0.13636363636363635)), + .init(results: ["Brown Blue Red"], boundingRect: .init(x: 0.1625, y: 0.6035353535353536, width: 0.6843750000000001, height: 0.10353535353535348)), + .init(results: ["Purple Red Brown"], boundingRect: .init(x: 0.1203125, y: 0.2752525252525253, width: 0.7687499999999999, height: 0.13636363636363635)), + .init(results: ["Red Green Blue"], boundingRect: .init(x: 0.171875, y: 0.15656565656565657, width: 0.665625, height: 0.10606060606060608)) + ]) + } + + func testScanImageFailedZeroDimensionedImage() async { + do { + _ = try await TextRecognizer.liveValue.recognize(.mock(), CIImage()) + XCTFail("Should fail here") + } catch { + XCTAssertEqual(error.localizedDescription, "CRImage Reader Detector was given zero-dimensioned image (0 x 0)") + } + } + + func testScanImageFailedWrongROI() async { + do { + _ = try await TextRecognizer.liveValue.recognize(.mock(roi: .init(x: 0, y: 0, width: 200, height: 200)), CIImage()) + XCTFail("Should fail here") + } catch { + XCTAssertEqual(error.localizedDescription, "The region of interest [0, 0, 200, 200] is not within the normalized bounds of [0 0 1 1]") + } + } +} + +extension TextRecognizer.Result: @retroactive Equatable { + public static func == (lhs: TextRecognizer.Result, rhs: TextRecognizer.Result) -> Bool { + lhs.results == rhs.results && + lhs.boundingRect == rhs.boundingRect + } +} diff --git a/Tests/MRZScannerTests/Private/TextRecognizerTests/TextRecognizerTests.swift b/Tests/MRZScannerTests/Private/TextRecognizerTests/TextRecognizerTests.swift deleted file mode 100644 index 569ef59..0000000 --- a/Tests/MRZScannerTests/Private/TextRecognizerTests/TextRecognizerTests.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// TextRecognizerTests.swift -// -// -// Created by Roman Mazeev on 02/12/2023. -// - -@testable import MRZScanner -import CoreImage -import XCTest - -final class TextRecognizerTests: XCTestCase { - func testScanImageSuccess() throws { - let expectation = expectation(description: "testScanImageSuccess") - Task { - let fileURL = try XCTUnwrap(Bundle.module.url(forResource: "ImageTest", withExtension: "png")) - let imageData = try Data(contentsOf: fileURL) - let image = try XCTUnwrap(CIImage(data: imageData)) - do { - let result = try await TextRecognizer.liveValue.recognize(.mock(), image) - XCTAssertEqual(result, [ - .init(results: ["Red Green Purple"], boundingRect: .init(x: 0.1296875, y: 0.7222222222222222, width: 0.75, height: 0.13636363636363635)), - .init(results: ["Brown Blue Red"], boundingRect: .init(x: 0.1625, y: 0.6035353535353536, width: 0.6843750000000001, height: 0.10353535353535348)), - .init(results: ["Purple Red Brown"], boundingRect: .init(x: 0.1203125, y: 0.2752525252525253, width: 0.7687499999999999, height: 0.13636363636363635)), - .init(results: ["Red Green Blue"], boundingRect: .init(x: 0.171875, y: 0.15656565656565657, width: 0.665625, height: 0.10606060606060608)) - ]) - expectation.fulfill() - } catch { - XCTFail("Should not fail here. Error: \(error.localizedDescription)") - } - - } - wait(for: [expectation], timeout: 10) - } - - func testScanImageFailedZeroDimensionedImage() throws { - let expectation = expectation(description: "testScanImageSuccess") - Task { - do { - _ = try await TextRecognizer.liveValue.recognize(.mock(), CIImage()) - XCTFail("Should fail here") - } catch { - XCTAssertEqual(error.localizedDescription, "CRImage Reader Detector was given zero-dimensioned image (0 x 0)") - } - expectation.fulfill() - } - wait(for: [expectation], timeout: 10) - } - - func testScanImageFailedWrongROI() throws { - let expectation = expectation(description: "testScanImageSuccess") - Task { - do { - _ = try await TextRecognizer.liveValue.recognize(.mock(roi: .init(x: 0, y: 0, width: 200, height: 200)), CIImage()) - XCTFail("Should fail here") - } catch { - XCTAssertEqual(error.localizedDescription, "The region of interest [0, 0, 200, 200] is not within the normalized bounds of [0 0 1 1]") - } - expectation.fulfill() - } - wait(for: [expectation], timeout: 10) - } -} diff --git a/Tests/MRZScannerTests/Private/TrackerTests.swift b/Tests/MRZScannerTests/Private/TrackerTests.swift index 0bcadcb..c5c881b 100644 --- a/Tests/MRZScannerTests/Private/TrackerTests.swift +++ b/Tests/MRZScannerTests/Private/TrackerTests.swift @@ -9,17 +9,26 @@ import XCTest final class TrackerTests: XCTestCase { - func testExisting() { - let result = Tracker.liveValue.updateResults([.mock: 1], .mock) + func testTrackAndCurrentResults() throws { + let tracker = Tracker.liveValue - XCTAssertEqual(try XCTUnwrap(result.first?.key), .mock) - XCTAssertEqual(try XCTUnwrap(result.first?.value), 2) - } + let firstResults = tracker.currentResults() + XCTAssertTrue(firstResults.isEmpty) + + tracker.track(result: .mock) + let secondResults = tracker.currentResults() + let secondResult = try XCTUnwrap(secondResults.first) + XCTAssertEqual(secondResult.key, .mock) + XCTAssertEqual(secondResult.value, 1) - func testNew() { - let result = Tracker.liveValue.updateResults([:], .mock) + tracker.track(result: .secondMock) + let thirdResults = tracker.currentResults() + XCTAssertEqual(thirdResults.count, 2) + XCTAssertEqual(thirdResults[.secondMock], 1) - XCTAssertEqual(try XCTUnwrap(result.first?.key), .mock) - XCTAssertEqual(try XCTUnwrap(result.first?.value), 1) + tracker.track(result: .mock) + let forthResults = tracker.currentResults() + XCTAssertEqual(forthResults.count, 2) + XCTAssertEqual(forthResults[.mock], 2) } } diff --git a/Tests/MRZScannerTests/Private/ValidatorTests.swift b/Tests/MRZScannerTests/Private/ValidatorTests.swift index b91ad99..5b5600e 100644 --- a/Tests/MRZScannerTests/Private/ValidatorTests.swift +++ b/Tests/MRZScannerTests/Private/ValidatorTests.swift @@ -76,7 +76,7 @@ final class ValidatorTests: XCTestCase { } } -extension Validator.Result: Equatable { +extension Validator.Result: @retroactive Equatable { public static func == (lhs: Validator.Result, rhs: Validator.Result) -> Bool { lhs.result == rhs.result && lhs.index == rhs.index } diff --git a/Tests/MRZScannerTests/Public/ScannerTests.swift b/Tests/MRZScannerTests/Public/ScannerTests.swift index 359bd02..54dc0e9 100644 --- a/Tests/MRZScannerTests/Public/ScannerTests.swift +++ b/Tests/MRZScannerTests/Public/ScannerTests.swift @@ -5,250 +5,349 @@ // Created by Roman Mazeev on 02/12/2023. // +import CustomDump import Dependencies @testable import MRZScanner +@preconcurrency import CoreImage import XCTest final class MRZScannerTests: XCTestCase { - private let image = CIImage(color: .blue) - private let scanningConfiguration: ScanningConfiguration = .mock() - private let textRecognizerResults: [TextRecognizer.Result] = [.init(results: ["test"], boundingRect: .zero)] - private let validatorResults: [Validator.Result] = [.init(result: "test", index: 0)] - private let boundingRectConverterResults: ScannedBoundingRects = .init(valid: [.init(), .init()], invalid: [.init()]) - private let parserResult: ParserResult = .mock - private let trackerResult: TrackerResult = [.mock: 1] - - var validatorMock: Validator { - Validator { possibleLines in - XCTAssertEqual(self.textRecognizerResults.map(\.results), possibleLines) - return self.validatorResults - } + private enum Event: Equatable, Sendable { + case recognize(ScanningConfiguration, Int) + case getValidatedResults([[String]]) + case convert([TextRecognizer.Result], [Validator.Result]) + case parse([String]) + + case currentResults + case track(ParserResult) } - var boundingRectConverterMock: BoundingRectConverter { - BoundingRectConverter { results, validLines in - XCTAssertEqual(results, self.textRecognizerResults) - XCTAssertEqual(validLines, self.validatorResults) - return self.boundingRectConverterResults + func testSingleImageSuccess() async throws { + let events = LockIsolated([Event]()) + + try await withDependencies { + $0.textRecognizer.recognize = { @Sendable configuration, scanningImage in + events.withValue { $0.append(.recognize(configuration,scanningImage.base64EncodedString.count)) } + return [.mock] + } + $0.validator.getValidatedResults = { @Sendable possibleLines in + events.withValue { $0.append(.getValidatedResults(possibleLines)) } + return [.mock] + } + $0.boundingRectConverter.convert = { @Sendable results, validLines in + events.withValue { $0.append(.convert(results, validLines)) } + return .mock + } + $0.parser.parse = { @Sendable mrzLines in + events.withValue { $0.append(.parse(mrzLines)) } + return .mock + } + } operation: { + let currentResult = try await XCTUnwrap(CIImage(data: .imageMock)).scanForMRZCode(configuration: .mock()) + XCTAssertEqual(currentResult.results, .mock) + XCTAssertEqual(currentResult.boundingRects, .mock) } + + XCTAssertNoDifference( + events.value, + [ + .recognize(.mock(), 1348268), + .getValidatedResults([["test"]]), + .parse(["test"]), + .convert([.mock], [.mock]), + ] + ) } - func testSingleImageSuccess() throws { - let textRecognizerMock = TextRecognizer { configuration, scanningImage in - XCTAssertEqual(self.image, scanningImage) - XCTAssertEqual(self.scanningConfiguration, configuration) + func testSingleImageParserFailure() async throws { + let events = LockIsolated([Event]()) - return self.textRecognizerResults + try await withDependencies { + $0.textRecognizer.recognize = { @Sendable configuration, scanningImage in + events.withValue { $0.append(.recognize(configuration, scanningImage.base64EncodedString.count)) } + return [.mock] + } + $0.validator.getValidatedResults = { @Sendable possibleLines in + events.withValue { $0.append(.getValidatedResults(possibleLines)) } + return [.mock] + } + $0.boundingRectConverter.convert = { @Sendable results, validLines in + events.withValue { $0.append(.convert(results, validLines)) } + return .mock + } + $0.parser.parse = { @Sendable mrzLines in + events.withValue { $0.append(.parse(mrzLines)) } + return nil + } + } operation: { + do { + _ = try await XCTUnwrap(CIImage(data: .imageMock)).scanForMRZCode(configuration: .mock()) + XCTFail("Should fail here") + } catch { + XCTAssertEqual(try XCTUnwrap(error as? CIImage.ScanningError), .codeNotFound) + } } - let parser = Parser { mrzLines in - XCTAssertEqual(mrzLines, self.validatorResults.map(\.result)) - return self.parserResult - } + XCTAssertNoDifference( + events.value, + [ + .recognize(.mock(), 1348268), + .getValidatedResults([["test"]]), + .parse(["test"]), + .convert([.mock], [.mock]) + ] + ) + } - let scanningExpectation = expectation(description: "scanning") - Task { - await withDependencies { - $0.textRecognizer = textRecognizerMock - $0.validator = validatorMock - $0.boundingRectConverter = boundingRectConverterMock - $0.parser = parser - } operation: { - do { - let currentResult = try await image.scanForMRZCode(configuration: scanningConfiguration) - XCTAssertEqual(currentResult.results, parserResult) - XCTAssertEqual(currentResult.boundingRects, boundingRectConverterResults) - } catch { - XCTFail("Should not fail here. Error: \(error.localizedDescription)") - } + func testSingleImageTextRecognizerFailure() async throws { + let events = LockIsolated([Event]()) - scanningExpectation.fulfill() + try await withDependencies { + $0.textRecognizer.recognize = { @Sendable configuration, scanningImage in + events.withValue { $0.append(.recognize(configuration, scanningImage.base64EncodedString.count)) } + throw CIImage.ScanningError.codeNotFound + } + } operation: { + do { + _ = try await XCTUnwrap(CIImage(data: .imageMock)).scanForMRZCode(configuration: .mock()) + XCTFail("Should fail here") + } catch { + XCTAssertEqual(try XCTUnwrap(error as? CIImage.ScanningError), .codeNotFound) } } - wait(for: [scanningExpectation], timeout: 10) + XCTAssertNoDifference( + events.value, + [ + .recognize(.mock(), 1348268) + ] + ) } - func testSingleImageParserFailure() throws { - let textRecognizerMock = TextRecognizer { configuration, scanningImage in - XCTAssertEqual(self.image, scanningImage) - XCTAssertEqual(self.scanningConfiguration, configuration) + func testImageStreamSuccess() async throws { + let events = LockIsolated([Event]()) - return self.textRecognizerResults - } - - let parser = Parser { mrzLines in - XCTAssertEqual(mrzLines, self.validatorResults.map(\.result)) - return nil - } + try await withDependencies { + $0.textRecognizer.recognize = { @Sendable configuration, scanningImage in + events.withValue { $0.append(.recognize(configuration, scanningImage.base64EncodedString.count)) } + return [.mock] + } + $0.validator.getValidatedResults = { @Sendable possibleLines in + events.withValue { $0.append(.getValidatedResults(possibleLines)) } + return [.mock] + } + $0.boundingRectConverter.convert = { @Sendable results, validLines in + events.withValue { $0.append(.convert(results, validLines)) } + return .mock + } + $0.parser.parse = { @Sendable mrzLines in + events.withValue { $0.append(.parse(mrzLines)) } + return .mock + } - let scanningExpectation = expectation(description: "scanning") - Task { - try await withDependencies { - $0.textRecognizer = textRecognizerMock - $0.validator = validatorMock - $0.boundingRectConverter = boundingRectConverterMock - $0.parser = parser - } operation: { + $0.tracker.currentResults = { @Sendable in + events.withValue { $0.append(.currentResults) } + return .mock + } + $0.tracker.track = { @Sendable parserResult in + events.withValue { $0.append(.track(parserResult)) } + } + } operation: { + let resultsStream = AsyncStream { continuation in do { - _ = try await image.scanForMRZCode(configuration: scanningConfiguration) - XCTFail("Should fail here") + continuation.yield(try XCTUnwrap(CIImage(data: .imageMock))) + continuation.finish() } catch { - XCTAssert(try XCTUnwrap(error as? CIImage.ScanningError) == .codeNotFound) + let errorMessage = error.localizedDescription + XCTFail(errorMessage) + fatalError(errorMessage) } - scanningExpectation.fulfill() + } + .scanForMRZCode(configuration: .mock()) + + for try await liveScanningResult in resultsStream { + XCTAssertEqual(liveScanningResult.results, .mock) + XCTAssertEqual(liveScanningResult.boundingRects, .mock) + return } } - wait(for: [scanningExpectation], timeout: 10) + XCTAssertNoDifference( + events.value, + [ + .recognize(.mock(), 1348268), + .getValidatedResults([["test"]]), + .parse(["test"]), + .track(.mock), + .currentResults, + .convert([.mock], [.mock]) + ] + ) } - func testSingleImageTextRecognizerFailure() throws { - let textRecognizerMock = TextRecognizer { configuration, scanningImage in - XCTAssertEqual(self.image, scanningImage) - XCTAssertEqual(self.scanningConfiguration, configuration) + func testImageStreamParsingFailure() async throws { + let events = LockIsolated([Event]()) - throw CIImage.ScanningError.codeNotFound - } - - let scanningExpectation = expectation(description: "scanning") - Task { - try await withDependencies { - $0.textRecognizer = textRecognizerMock - } operation: { + try await withDependencies { + $0.textRecognizer.recognize = { @Sendable configuration, scanningImage in + events.withValue { $0.append(.recognize(configuration, scanningImage.base64EncodedString.count)) } + return [.mock] + } + $0.validator.getValidatedResults = { @Sendable possibleLines in + events.withValue { $0.append(.getValidatedResults(possibleLines)) } + return [.mock] + } + $0.boundingRectConverter.convert = { @Sendable results, validLines in + events.withValue { $0.append(.convert(results, validLines)) } + return .mock + } + $0.parser.parse = { @Sendable mrzLines in + events.withValue { $0.append(.parse(mrzLines)) } + return nil + } + $0.tracker.currentResults = { @Sendable in + events.withValue { $0.append(.currentResults) } + return .mock + } + } operation: { + let resultsStream = AsyncStream { continuation in do { - _ = try await image.scanForMRZCode(configuration: scanningConfiguration) - XCTFail("Should fail here") + continuation.yield(try XCTUnwrap(CIImage(data: .imageMock))) + continuation.finish() } catch { - XCTAssert(try XCTUnwrap(error as? CIImage.ScanningError) == .codeNotFound) + let errorMessage = error.localizedDescription + XCTFail(errorMessage) + fatalError(errorMessage) } - scanningExpectation.fulfill() } - } + .scanForMRZCode(configuration: .mock()) - wait(for: [scanningExpectation], timeout: 10) - } - - func testImageStreamSuccess() { - let textRecognizerMock = TextRecognizer { configuration, scanningImage in - XCTAssertEqual(self.image, scanningImage) - XCTAssertEqual(self.scanningConfiguration, configuration) - - return self.textRecognizerResults + for try await liveScanningResult in resultsStream { + XCTAssertEqual(liveScanningResult.results, .mock) + XCTAssertEqual(liveScanningResult.boundingRects, .mock) + return + } } - let parser = Parser { mrzLines in - XCTAssertEqual(mrzLines, self.validatorResults.map(\.result)) - return self.parserResult - } + XCTAssertNoDifference( + events.value, + [ + .recognize(.mock(), 1348268), + .getValidatedResults([["test"]]), + .parse(["test"]), + .currentResults, + .convert([.mock], [.mock]) + ] + ) + } - let tracker = Tracker { _, result in - XCTAssertEqual(result, self.parserResult) - return self.trackerResult - } + func testImageStreamTextRecognizerFailure() async throws { + let events = LockIsolated([Event]()) - let scanningExpectation = expectation(description: "scanning") - Task { - await withDependencies { - $0.textRecognizer = textRecognizerMock - $0.validator = validatorMock - $0.boundingRectConverter = boundingRectConverterMock - $0.parser = parser - $0.tracker = tracker - } operation: { - let resultsStream = AsyncStream { continuation in - continuation.yield(image) + try await withDependencies { + $0.textRecognizer.recognize = { @Sendable configuration, scanningImage in + events.withValue { $0.append(.recognize(configuration, scanningImage.base64EncodedString.count)) } + throw CIImage.ScanningError.codeNotFound + } + } operation: { + let resultsStream = AsyncStream { continuation in + do { + continuation.yield(try XCTUnwrap(CIImage(data: .imageMock))) continuation.finish() + } catch { + let errorMessage = error.localizedDescription + XCTFail(errorMessage) + fatalError(errorMessage) } - .scanForMRZCode(configuration: scanningConfiguration) + } + .scanForMRZCode(configuration: .mock()) - do { - for try await liveScanningResult in resultsStream { - XCTAssertEqual(liveScanningResult.results, trackerResult) - XCTAssertEqual(liveScanningResult.boundingRects, boundingRectConverterResults) - scanningExpectation.fulfill() - } - } catch { - XCTFail("Should not fail here. Error: \(error)") + do { + for try await _ in resultsStream { + XCTFail("Should fail here") } + } catch { + let error = try XCTUnwrap(error as? CIImage.ScanningError) + XCTAssertEqual(error, .codeNotFound) } } - wait(for: [scanningExpectation], timeout: 10) + XCTAssertNoDifference( + events.value, + [ + .recognize(.mock(), 1348268) + ] + ) } +} +private extension TextRecognizer.Result { + static var mock: Self { .init(results: ["test"], boundingRect: .zero) } +} - func testImageStreamParsingFailure() throws { - let textRecognizerMock = TextRecognizer { configuration, scanningImage in - XCTAssertEqual(self.image, scanningImage) - XCTAssertEqual(self.scanningConfiguration, configuration) - - return self.textRecognizerResults - } - - let parser = Parser { mrzLines in - XCTAssertEqual(mrzLines, self.validatorResults.map(\.result)) - return nil - } +private extension Validator.Result { + static var mock: Self { .init(result: "test", index: 0) } +} - let scanningExpectation = expectation(description: "scanning") - Task { - await withDependencies { - $0.textRecognizer = textRecognizerMock - $0.validator = validatorMock - $0.boundingRectConverter = boundingRectConverterMock - $0.parser = parser - } operation: { - let resultsStream = AsyncStream { continuation in - continuation.yield(image) - continuation.finish() - } - .scanForMRZCode(configuration: scanningConfiguration) +private extension TrackerResult { + static var mock: Self { [.mock: 1] } +} - do { - for try await liveScanningResult in resultsStream { - XCTAssertEqual(liveScanningResult.results, [:]) - XCTAssertEqual(liveScanningResult.boundingRects, boundingRectConverterResults) - scanningExpectation.fulfill() - } - } catch { - XCTFail("Should not fail here. Error: \(error)") - } - } +private extension Data { + static var imageMock: Data { + do { + let fileURL = try XCTUnwrap(Bundle.module.url(forResource: "TestImage", withExtension: "png")) + return try Data(contentsOf: fileURL) + } catch { + let errorMessage = error.localizedDescription + XCTFail(errorMessage) + fatalError(errorMessage) } - - wait(for: [scanningExpectation], timeout: 10) } +} - func testImageStreamTextRecognizerFailure() throws { - let textRecognizerMock = TextRecognizer { configuration, scanningImage in - XCTAssertEqual(self.image, scanningImage) - XCTAssertEqual(self.scanningConfiguration, configuration) - - throw CIImage.ScanningError.codeNotFound +private extension CIImage { + var base64EncodedString: String { + let context = CIContext(options: nil) + guard let cgImage = context.createCGImage(self, from: self.extent) else { + let errorMessage = "Failed to create CGImage" + XCTFail(errorMessage) + fatalError(errorMessage) } - let scanningExpectation = expectation(description: "scanning") - Task { - try await withDependencies { - $0.textRecognizer = textRecognizerMock - } operation: { - let resultsStream = AsyncStream { continuation in - continuation.yield(image) - continuation.finish() - } - .scanForMRZCode(configuration: scanningConfiguration) - - do { - for try await _ in resultsStream {} - } catch { - let error = try XCTUnwrap(error as? CIImage.ScanningError) - XCTAssertEqual(error, .codeNotFound) - scanningExpectation.fulfill() - } + let width = cgImage.width + let height = cgImage.height + let bitsPerComponent = 8 + let bytesPerPixel = 4 + let bytesPerRow = width * bytesPerPixel + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + + var data = Data(count: height * bytesPerRow) + + data.withUnsafeMutableBytes { ptr in + if let context = CGContext( + data: ptr.baseAddress, + width: width, + height: height, + bitsPerComponent: bitsPerComponent, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: bitmapInfo.rawValue + ) { + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height))) } } - wait(for: [scanningExpectation], timeout: 10) + return data.base64EncodedString() + } +} + +extension ScanningConfiguration: @retroactive Equatable { + public static func == (lhs: ScanningConfiguration, rhs: ScanningConfiguration) -> Bool { + lhs.orientation == rhs.orientation && + lhs.regionOfInterest == rhs.regionOfInterest && + lhs.minimumTextHeight == rhs.minimumTextHeight && + lhs.recognitionLevel == rhs.recognitionLevel } } diff --git a/Tests/MRZScannerTests/Public/ScanningResultTests.swift b/Tests/MRZScannerTests/Public/ScanningResultTests.swift new file mode 100644 index 0000000..0adcf7e --- /dev/null +++ b/Tests/MRZScannerTests/Public/ScanningResultTests.swift @@ -0,0 +1,68 @@ +// +// ScanningResultTests.swift +// +// +// Created by Roman Mazeev on 26/06/2024. +// + +import CustomDump +@testable import MRZScanner +import XCTest + +final class ScanningResultTests: XCTestCase { + func testBestExist() throws { + let result: ScanningResult = .init( + results: [ + .mock: 1, + .secondMock: 30, + .thirdMock: 9 + ], + boundingRects: .mock + ) + + XCTAssertNoDifference( + result.best(repetitions: 9), + .secondMock + ) + } + + func testBestNotExist() throws { + let result: ScanningResult = .init( + results: [ + .mock: 1, + ], + boundingRects: .mock + ) + + XCTAssertNil(result.best(repetitions: 9)) + } + + func testNoResults() throws { + let result: ScanningResult = .init( + results: [:], + boundingRects: .mock + ) + + XCTAssertNil(result.best(repetitions: 9)) + } +} + +private extension ParserResult { + static var thirdMock: Self { + .init( + format: .td2, + documentType: .id, + documentTypeAdditional: "r", + countryCode: "thirdTest", + surnames: "thirdTest", + givenNames: "thirdTest", + documentNumber: "thirdTest", + nationalityCountryCode: "thirdTest", + birthdate: .mock, + sex: .male, + expiryDate: .mock, + optionalData: "", + optionalData2: "" + ) + } +} diff --git a/Tests/MRZScannerTests/Mocks.swift b/Tests/MRZScannerTests/SharedMocks.swift similarity index 55% rename from Tests/MRZScannerTests/Mocks.swift rename to Tests/MRZScannerTests/SharedMocks.swift index c8674db..0f757ac 100644 --- a/Tests/MRZScannerTests/Mocks.swift +++ b/Tests/MRZScannerTests/SharedMocks.swift @@ -1,5 +1,5 @@ // -// Mocks.swift +// SharedMocks.swift // // // Created by Roman Mazeev on 01/12/2023. @@ -7,7 +7,6 @@ import Foundation @testable import MRZScanner -import Vision extension ParserResult { static var mock: Self { @@ -27,6 +26,28 @@ extension ParserResult { optionalData2: "" ) } + + static var secondMock: Self { + .init( + format: .td1, + documentType: .passport, + documentTypeAdditional: "r", + countryCode: "secondTest", + surnames: "secondTest", + givenNames: "secondTest", + documentNumber: "secondTest", + nationalityCountryCode: "secondTest", + birthdate: .mock, + sex: .male, + expiryDate: .mock, + optionalData: "", + optionalData2: "" + ) + } +} + +extension ScannedBoundingRects { + static var mock: Self { .init(valid: [.init(), .init()], invalid: [.init()]) } } extension Date { @@ -45,26 +66,3 @@ extension ScanningConfiguration { ) } } - -extension ScannedBoundingRects: Equatable { - public static func == (lhs: ScannedBoundingRects, rhs: ScannedBoundingRects) -> Bool { - lhs.valid == rhs.valid && - lhs.invalid == rhs.invalid - } -} - -extension TextRecognizer.Result: Equatable { - public static func == (lhs: TextRecognizer.Result, rhs: TextRecognizer.Result) -> Bool { - lhs.results == rhs.results && - lhs.boundingRect == rhs.boundingRect - } -} - -extension ScanningConfiguration: Equatable { - public static func == (lhs: ScanningConfiguration, rhs: ScanningConfiguration) -> Bool { - lhs.orientation == rhs.orientation && - lhs.regionOfInterest == rhs.regionOfInterest && - lhs.minimumTextHeight == rhs.minimumTextHeight && - lhs.recognitionLevel == rhs.recognitionLevel - } -} diff --git a/Tests/MRZScannerTests/Private/TextRecognizerTests/ImageTest.png b/Tests/MRZScannerTests/TestImage.png similarity index 100% rename from Tests/MRZScannerTests/Private/TextRecognizerTests/ImageTest.png rename to Tests/MRZScannerTests/TestImage.png