diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/BoundingBox.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/BoundingBox.kt index 06cb649755..ed89e87670 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/BoundingBox.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/BoundingBox.kt @@ -18,6 +18,6 @@ data class BoundingBox( val bottom: Int, ) : JavaSerializable -fun Rect.toBoundingBox(): BoundingBox = BoundingBox(left, top, right, bottom) +fun Rect?.toBoundingBox(): BoundingBox = if (this == null) BoundingBox(0, 0, 0, 0) else BoundingBox(left, top, right, bottom) fun BoundingBox.toRect(): Rect = Rect(left, top, right, bottom) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrModel.kt new file mode 100644 index 0000000000..0ef455af6e --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrModel.kt @@ -0,0 +1,32 @@ +package com.simprints.feature.externalcredential.screens.scanocr.reader + +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.feature.externalcredential.model.BoundingBox + +/** + * Wrapper for all scanned text after the OCR + * + * @param allLines all lines from blocks sorted by bounding box top coordinate ascending + */ +@ExcludedFromGeneratedTestCoverageReports("Data class") +internal data class OcrText( + val allLines: List, +) + +/** + * A single line of text detected by the OCR kit. + * + * @param id unique id of the line in a single OCR scan + * @param text normalized text (extra spaces removed) + * @param boundingBox coordinates of the line + * @param blockBoundingBox parent coordinates + * @param confidence overall confidence of the text value based on the average confidence for each character (aka element) in [text] + */ +@ExcludedFromGeneratedTestCoverageReports("Data class") +internal data class OcrLine( + val id: Int, + val text: String, + val boundingBox: BoundingBox, + val blockBoundingBox: BoundingBox, + val confidence: Float, +) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrQuery.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrQuery.kt new file mode 100644 index 0000000000..3616adf803 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrQuery.kt @@ -0,0 +1,47 @@ +package com.simprints.feature.externalcredential.screens.scanocr.reader + +/** + * Defines the search criteria for locating a single line of text within a scanned document. + * Used as the receiver of the [OcrReader.find] block. + */ +internal class OcrQuery { + internal val filters = mutableListOf<(OcrLine) -> Boolean>() + internal var belowAnchor: OcrQuery? = null + internal var aboveAnchor: OcrQuery? = null + + fun matchesPattern(regex: Regex) { + filters += { line -> regex.matches(line.text) } + } + + fun containsPattern(regex: Regex) { + filters += { line -> regex.containsMatchIn(line.text) } + } + + fun containsText(text: String) { + filters += { line -> line.text.contains(text, ignoreCase = true) } + } + + fun hasExactText(text: String) { + filters += { line -> line.text.equals(text, ignoreCase = true) } + } + + fun hasId(id: Int) { + filters += { line -> line.id == id } + } + + fun isBelow(resolveAnchor: OcrQuery.() -> Unit) { + belowAnchor = OcrQuery().apply(resolveAnchor) + } + + fun isBelow(anchor: OcrLine) { + belowAnchor = OcrQuery().apply { filters += { line -> line.id == anchor.id } } + } + + fun isAbove(resolveAnchor: OcrQuery.() -> Unit) { + aboveAnchor = OcrQuery().apply(resolveAnchor) + } + + fun isAbove(anchor: OcrLine) { + aboveAnchor = OcrQuery().apply { filters += { line -> line.id == anchor.id } } + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrReader.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrReader.kt new file mode 100644 index 0000000000..8360083064 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrReader.kt @@ -0,0 +1,70 @@ +package com.simprints.feature.externalcredential.screens.scanocr.reader + +/** + * Entry point for querying an [OcrText]. + * + * Usage: + * ``` + * val reader = OcrReader(ocrText) + * + * val membershipNumber = reader.find { + * matchesPattern(Regex("\\d{8}")) + * isBelow { containsText("membership number") } + * isAbove { containsText("expiry date") } + * } + * ``` + */ +internal class OcrReader( + val ocrText: OcrText, +) { + /** + * Executes the query defined in [block] and returns the first matching [OcrLine], or null. + * The [block] receives an [OcrQuery] as its receiver — call filter methods directly + * without any chaining or terminal call. + * + * Usage: + * ``` + * val reader = OcrReader(ocrText) + * + * val membershipNumber = reader.find { + * matchesPattern(Regex("\\d{8}")) + * isBelow { containsText("membership number") } + * isAbove { containsText("expiry date") } + * } + * ``` + * + * Only one level of nesting is supported. Spatial filters inside an anchor block are silently ignored. + * The following is NOT supported: + * ``` + * reader.find { + * isBelow { + * isBelow { containsText("some major title") } // ignored — has no effect + * containsText("some subtitle") + * } + * } + * ``` + */ + fun find(block: OcrQuery.() -> Unit): OcrLine? = runQuery(OcrQuery().apply(block)) + + private fun runQuery(query: OcrQuery): OcrLine? { + val belowAnchor = query.belowAnchor?.let { runQuery(it) } + val aboveAnchor = query.aboveAnchor?.let { runQuery(it) } + + if (query.belowAnchor != null && belowAnchor == null) return null + if (query.aboveAnchor != null && aboveAnchor == null) return null + + return ocrText.allLines.firstOrNull { line -> + val isBelowAnchor = belowAnchor == null || ( + line.boundingBox.top > belowAnchor.boundingBox.top && + line.boundingBox.left >= belowAnchor.boundingBox.left && + line.boundingBox.left < belowAnchor.boundingBox.right + ) + val isAboveAnchor = aboveAnchor == null || ( + line.boundingBox.top < aboveAnchor.boundingBox.top && + line.boundingBox.left >= aboveAnchor.boundingBox.left && + line.boundingBox.left < aboveAnchor.boundingBox.right + ) + query.filters.all { it(line) } && isBelowAnchor && isAboveAnchor + } + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCase.kt index 20fe8554a7..5fa0df2651 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCase.kt @@ -1,14 +1,9 @@ package com.simprints.feature.externalcredential.screens.scanocr.usecase import android.graphics.Bitmap -import com.google.android.gms.tasks.Tasks -import com.google.mlkit.vision.common.InputImage -import com.google.mlkit.vision.text.TextRecognition -import com.google.mlkit.vision.text.latin.TextRecognizerOptions -import com.simprints.core.ExcludedFromGeneratedTestCoverageReports -import com.simprints.feature.externalcredential.model.toBoundingBox import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrReader import com.simprints.infra.credential.store.CredentialImageRepository import com.simprints.infra.credential.store.model.CredentialScanImageType.FullDocument import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID @@ -17,63 +12,34 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -@ExcludedFromGeneratedTestCoverageReports("Unable to mock Google ML Kit") internal class GetCredentialCoordinatesUseCase @Inject constructor( + private val readTextFromImage: ReadTextFromImageUseCase, private val ghanaNhisCardOcrSelectorUseCase: GhanaNhisCardOcrSelectorUseCase, private val ghanaIdCardOcrSelectorUseCase: GhanaIdCardOcrSelectorUseCase, private val credentialImageRepository: CredentialImageRepository, ) { - private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) - - /** - * OCR uses Google ML kit. It has a following hierarchy: - * - Block. A contiguous set of text lines, such as a paragraph or column, - * - Line. A contiguous set of words on the same axis. There can be multiple Lines in the Block - * - Element. A contiguous set of alphanumeric characters ("word") on the same axis. There can be Elements in one Line - * - Symbol. A single alphanumeric character in an Element. - * - * This method returns a [DetectedOcrBlock] class if the OCR managed to find a line that satisfies the given [documentType] pattern. - * If such Line is found, then it is returned in a [DetectedOcrBlock] alongside its parent block, and a normalized value. - * - * Lines are used instead of Elements because the OCR might mistakenly read an extra white space in a Line, resulting in multiple - * Elements. Since Lines are geometrically in one plane, we just take the concatenation of all underlying child Elements, and analyze - * them it as a single string. - * - * @param bitmap bitmap to run OCR on - * @param documentType type of the document - * - * @return [DetectedOcrBlock] if any Line satisfies the [documentType] pattern, or null if none. - */ suspend operator fun invoke( bitmap: Bitmap, documentType: OcrDocumentType, ): DetectedOcrBlock? { - val image = InputImage.fromBitmap(bitmap, 0) return try { - val result = Tasks.await(recognizer.process(image)) ?: return null - return result.textBlocks.firstNotNullOfOrNull { textBlock -> - textBlock.lines.firstNotNullOfOrNull { textLine -> - // Getting text from the entire line readout, and normalizing to avoid any extra spaces - val lineReadout = textLine.text.trim().replace(" ", "") - val isValid = when (documentType) { - OcrDocumentType.NhisCard -> ghanaNhisCardOcrSelectorUseCase(lineReadout) - OcrDocumentType.GhanaIdCard -> ghanaIdCardOcrSelectorUseCase(lineReadout) - } - if (isValid) { - val blockBoundingRect = textBlock.boundingBox ?: return@firstNotNullOfOrNull null - val lineBoundingRect = textLine.boundingBox ?: return@firstNotNullOfOrNull null - val savedImagePath = credentialImageRepository.saveCredentialScan(bitmap, imageType = FullDocument) - return@firstNotNullOfOrNull DetectedOcrBlock( - imagePath = savedImagePath, - documentType = documentType, - blockBoundingBox = blockBoundingRect.toBoundingBox(), - lineBoundingBox = lineBoundingRect.toBoundingBox(), - readoutValue = lineReadout, - ) - } else { - return@firstNotNullOfOrNull null - } - } + val ocrText = readTextFromImage(bitmap) ?: return null + val ocrReader = OcrReader(ocrText) + val credentialOcrLine = when (documentType) { + OcrDocumentType.NhisCard -> ghanaNhisCardOcrSelectorUseCase(ocrReader) + OcrDocumentType.GhanaIdCard -> ghanaIdCardOcrSelectorUseCase(ocrReader) + } + if (credentialOcrLine != null) { + val savedImagePath = credentialImageRepository.saveCredentialScan(bitmap, imageType = FullDocument) + DetectedOcrBlock( + imagePath = savedImagePath, + documentType = documentType, + blockBoundingBox = credentialOcrLine.blockBoundingBox, + lineBoundingBox = credentialOcrLine.boundingBox, + readoutValue = credentialOcrLine.text, + ) + } else { + null } } catch (e: Exception) { Simber.e("OCR failed for $documentType", e, tag = MULTI_FACTOR_ID) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt index b5672f17fa..9d5b306382 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt @@ -1,9 +1,11 @@ package com.simprints.feature.externalcredential.screens.scanocr.usecase +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrReader import javax.inject.Inject internal class GhanaIdCardOcrSelectorUseCase @Inject constructor() { - operator fun invoke(readoutValue: String): Boolean = GHANA_ID_PATTERN.matches(readoutValue) + operator fun invoke(ocrReader: OcrReader): OcrLine? = ocrReader.find { matchesPattern(GHANA_ID_PATTERN) } companion object { // Ghana ID card number pattern is "GHA-12345789-0" diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCase.kt index e7f4133529..95c6c3ca47 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCase.kt @@ -1,9 +1,11 @@ package com.simprints.feature.externalcredential.screens.scanocr.usecase +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrReader import javax.inject.Inject internal class GhanaNhisCardOcrSelectorUseCase @Inject constructor() { - operator fun invoke(readoutValue: String): Boolean = NHIS_PATTERN.matches(readoutValue) + operator fun invoke(ocrReader: OcrReader): OcrLine? = ocrReader.find { matchesPattern(NHIS_PATTERN) } companion object { // NHIS Card membership is 8 digits long diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ReadTextFromImageUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ReadTextFromImageUseCase.kt new file mode 100644 index 0000000000..5f2f5ffd1a --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ReadTextFromImageUseCase.kt @@ -0,0 +1,45 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.graphics.Bitmap +import com.google.android.gms.tasks.Tasks +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.Text +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.latin.TextRecognizerOptions +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.feature.externalcredential.model.toBoundingBox +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrText +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +@ExcludedFromGeneratedTestCoverageReports("Unable to mock Google ML Kit") +internal class ReadTextFromImageUseCase @Inject constructor() { + private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) + + operator fun invoke(bitmap: Bitmap): OcrText? { + val image = InputImage.fromBitmap(bitmap, 0) + val result = Tasks.await(recognizer.process(image)) ?: return null + return build(result) + } + + private fun build(mlKitText: Text): OcrText { + var nextLineId = 0 + + val allLinesSorted = mlKitText.textBlocks + .flatMap { block -> + block.lines.map { line -> + OcrLine( + id = nextLineId++, + text = line.text.trim(), + boundingBox = line.boundingBox.toBoundingBox(), + blockBoundingBox = block.boundingBox.toBoundingBox(), + confidence = line.confidence, + ) + } + }.sortedBy { it.boundingBox.top } + + return OcrText(allLines = allLinesSorted) + } +} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrQueryTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrQueryTest.kt new file mode 100644 index 0000000000..f1c70b3479 --- /dev/null +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrQueryTest.kt @@ -0,0 +1,193 @@ +package com.simprints.feature.externalcredential.screens.scanocr.reader + +import com.google.common.truth.Truth.assertThat +import com.simprints.feature.externalcredential.model.BoundingBox +import io.mockk.MockKAnnotations +import io.mockk.mockk +import org.junit.Before +import org.junit.Test + +internal class OcrQueryTest { + private lateinit var query: OcrQuery + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + query = OcrQuery() + } + + @Test + fun `matchesPattern registers a filter`() { + query.matchesPattern(Regex("\\d+")) + assertThat(query.filters).hasSize(1) + } + + @Test + fun `containsPattern registers a filter`() { + query.containsPattern(Regex("\\d+")) + assertThat(query.filters).hasSize(1) + } + + @Test + fun `containsText registers a filter`() { + query.containsText("membership") + assertThat(query.filters).hasSize(1) + } + + @Test + fun `hasExactText registers a filter`() { + query.hasExactText("membership number") + assertThat(query.filters).hasSize(1) + } + + @Test + fun `hasId registers a filter`() { + query.hasId(1) + assertThat(query.filters).hasSize(1) + } + + @Test + fun `multiple filters are all registered`() { + query.matchesPattern(Regex("\\d+")) + query.containsText("membership") + query.hasId(1) + assertThat(query.filters).hasSize(3) + } + + @Test + fun `matchesPattern passes line whose full text matches pattern`() { + query.matchesPattern(Regex("^\\d{8}$")) + assertThat(query.filters.all { it(line(text = "12345678")) }).isTrue() + } + + @Test + fun `matchesPattern rejects line where pattern matches only a substring`() { + query.matchesPattern(Regex("\\d{8}")) + assertThat(query.filters.all { it(line(text = "ID:12345678")) }).isFalse() + } + + @Test + fun `matchesPattern rejects non-matching line`() { + query.matchesPattern(Regex("^\\d{8}$")) + assertThat(query.filters.all { it(line(text = "abcdefgh")) }).isFalse() + } + + @Test + fun `containsPattern passes line containing a partial match`() { + query.containsPattern(Regex("membership")) + assertThat(query.filters.all { it(line(text = "membership number")) }).isTrue() + } + + @Test + fun `containsPattern passes line where pattern matches full text`() { + query.containsPattern(Regex("membership")) + assertThat(query.filters.all { it(line(text = "membership")) }).isTrue() + } + + @Test + fun `containsPattern rejects line with no match`() { + query.containsPattern(Regex("expiry")) + assertThat(query.filters.all { it(line(text = "membership number")) }).isFalse() + } + + @Test + fun `containsText passes line containing text`() { + query.containsText("member") + assertThat(query.filters.all { it(line(text = "membership number")) }).isTrue() + } + + @Test + fun `containsText is case-insensitive`() { + query.containsText("MEMBER") + assertThat(query.filters.all { it(line(text = "membership number")) }).isTrue() + } + + @Test + fun `containsText rejects line not containing text`() { + query.containsText("expiry") + assertThat(query.filters.all { it(line(text = "membership number")) }).isFalse() + } + + @Test + fun `hasExactText passes line with exact text`() { + query.hasExactText("expiry date") + assertThat(query.filters.all { it(line(text = "expiry date")) }).isTrue() + } + + @Test + fun `hasExactText is case-insensitive`() { + query.hasExactText("EXPIRY DATE") + assertThat(query.filters.all { it(line(text = "expiry date")) }).isTrue() + } + + @Test + fun `hasExactText rejects partial match`() { + query.hasExactText("expiry date") + assertThat(query.filters.all { it(line(text = "expiry")) }).isFalse() + } + + @Test + fun `hasId passes line with matching id`() { + query.hasId(2) + assertThat(query.filters.all { it(line(id = 2)) }).isTrue() + } + + @Test + fun `hasId rejects line with different id`() { + query.hasId(2) + assertThat(query.filters.all { it(line(id = 99)) }).isFalse() + } + + @Test + fun `isBelow with block registers belowAnchor`() { + query.isBelow { containsText("membership") } + assertThat(query.belowAnchor).isNotNull() + } + + @Test + fun `isBelow with OcrLine registers belowAnchor`() { + query.isBelow(mockk(relaxed = true)) + assertThat(query.belowAnchor).isNotNull() + } + + @Test + fun `isAbove with block registers aboveAnchor`() { + query.isAbove { containsText("expiry") } + assertThat(query.aboveAnchor).isNotNull() + } + + @Test + fun `isAbove with OcrLine registers aboveAnchor`() { + query.isAbove(mockk(relaxed = true)) + assertThat(query.aboveAnchor).isNotNull() + } + + @Test + fun `isBelow with direct OcrLine stores id filter in anchor query`() { + val targetId = 17 + val anchor = line(id = targetId, text = "anchor") + query.isBelow(anchor) + assertThat(query.belowAnchor?.filters?.all { it(line(id = targetId)) }).isTrue() + assertThat(query.belowAnchor?.filters?.all { it(line(id = 99)) }).isFalse() + } + + @Test + fun `isAbove with direct OcrLine stores id filter in anchor query`() { + val targetId = 17 + val anchor = line(id = targetId, text = "anchor") + query.isAbove(anchor) + assertThat(query.aboveAnchor?.filters?.all { it(line(id = targetId)) }).isTrue() + assertThat(query.aboveAnchor?.filters?.all { it(line(id = 99)) }).isFalse() + } + + private fun line( + id: Int = 0, + text: String = "", + ) = OcrLine( + id = id, + text = text, + boundingBox = BoundingBox(left = 0, top = 0, right = 100, bottom = 30), + blockBoundingBox = BoundingBox(left = 0, top = 0, right = 100, bottom = 30), + confidence = 1f, + ) +} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrReaderTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrReaderTest.kt new file mode 100644 index 0000000000..42f1acc880 --- /dev/null +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrReaderTest.kt @@ -0,0 +1,213 @@ +package com.simprints.feature.externalcredential.screens.scanocr.reader + +import com.google.common.truth.Truth.assertThat +import com.simprints.feature.externalcredential.model.BoundingBox +import io.mockk.MockKAnnotations +import org.junit.Before +import org.junit.Test + +internal class OcrReaderTest { + private lateinit var reader: OcrReader + + private lateinit var labelMembership: OcrLine + private lateinit var membershipValue: OcrLine + private lateinit var labelIssueDate: OcrLine + private lateinit var issueDateValue: OcrLine + private lateinit var labelExpiryDate: OcrLine + private lateinit var expiryDateValue: OcrLine + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + labelMembership = createLine(id = 0, text = "membership number", top = 100) + membershipValue = createLine(id = 1, text = "12345678", top = 140) + labelIssueDate = createLine(id = 2, text = "issue date", top = 200) + issueDateValue = createLine(id = 3, text = "03/03", top = 240) + labelExpiryDate = createLine(id = 4, text = "expiry date", top = 200, right = 200) + expiryDateValue = createLine(id = 5, text = "11/11", top = 240, right = 200) + + reader = OcrReader( + OcrText( + allLines = listOf(labelMembership, membershipValue, labelIssueDate, issueDateValue, labelExpiryDate, expiryDateValue), + ), + ) + } + + @Test + fun `find returns null when no lines match`() { + val result = reader.find { containsText("$labelMembership extra") } + assertThat(result).isNull() + } + + @Test + fun `find returns first line in iteration order when multiple lines match`() { + val result = reader.find { matchesPattern(Regex("\\d+")) } + assertThat(result).isEqualTo(membershipValue) + } + + @Test + fun `containsText returns first matching line`() { + val result = reader.find { containsText("member") } + assertThat(result).isEqualTo(labelMembership) + } + + @Test + fun `containsText is case-insensitive`() { + val result = reader.find { containsText("MEMBER") } + assertThat(result).isEqualTo(labelMembership) + } + + @Test + fun `hasExactText matches full string only`() { + val result = reader.find { hasExactText(labelExpiryDate.text) } + assertThat(result).isEqualTo(labelExpiryDate) + } + + @Test + fun `hasExactText returns null for partial match`() { + val result = reader.find { hasExactText("expiry") } + assertThat(result).isNull() + } + + @Test + fun `hasExactText is case-insensitive`() { + val result = reader.find { hasExactText(labelExpiryDate.text.uppercase()) } + assertThat(result).isEqualTo(labelExpiryDate) + } + + @Test + fun `matchesPattern finds 8-digit number`() { + val result = reader.find { matchesPattern(Regex("^\\d{8}$")) } + assertThat(result).isEqualTo(membershipValue) + } + + @Test + fun `matchesPattern finds date format`() { + val result = reader.find { matchesPattern(Regex("^\\d{2}/\\d{2}$")) } + assertThat(result).isEqualTo(issueDateValue) + } + + @Test + fun `hasId finds line by id`() { + val result = reader.find { hasId(labelExpiryDate.id) } + assertThat(result).isEqualTo(labelExpiryDate) + } + + @Test + fun `hasId returns null for unknown id`() { + val result = reader.find { hasId(labelExpiryDate.id + 99) } + assertThat(result).isNull() + } + + @Test + fun `isBelow direct OcrLine returns first line below anchor`() { + val result = reader.find { isBelow(labelMembership) } + assertThat(result).isEqualTo(membershipValue) + } + + @Test + fun `isBelow block resolves anchor via text containment`() { + val result = reader.find { + matchesPattern(Regex("^\\d{8}$")) + isBelow { containsText("membership number") } + } + assertThat(result).isEqualTo(membershipValue) + } + + @Test + fun `isBelow block resolves anchor via containsPattern`() { + val result = reader.find { + matchesPattern(Regex("^\\d{8}$")) + isBelow { containsPattern(Regex("membership")) } + } + assertThat(result).isEqualTo(membershipValue) + } + + @Test + fun `isBelow block resolves anchor via line id`() { + val result = reader.find { + matchesPattern(Regex("^\\d{8}$")) + isBelow { hasId(labelMembership.id) } + } + assertThat(result).isEqualTo(membershipValue) + } + + @Test + fun `isBelow returns null when anchor cannot be resolved`() { + val result = reader.find { isBelow { containsText("$labelMembership extra") } } + assertThat(result).isNull() + } + + @Test + fun `isAbove direct OcrLine returns first line above anchor`() { + val result = reader.find { isAbove(labelExpiryDate) } + assertThat(result).isEqualTo(labelMembership) + } + + @Test + fun `isAbove block resolves anchor via text containment`() { + val result = reader.find { + matchesPattern(Regex("^\\d{8}$")) + isAbove { containsText("expiry date") } + } + assertThat(result).isEqualTo(membershipValue) + } + + @Test + fun `isAbove block resolves anchor via containsPattern`() { + val result = reader.find { + matchesPattern(Regex("^\\d{8}$")) + isAbove { containsPattern(Regex("expiry")) } + } + assertThat(result).isEqualTo(membershipValue) + } + + @Test + fun `isAbove block resolves anchor via line id`() { + val result = reader.find { + matchesPattern(Regex("^\\d{8}$")) + isAbove { hasId(labelIssueDate.id) } + } + assertThat(result).isEqualTo(membershipValue) + } + + @Test + fun `isAbove returns null when anchor cannot be resolved`() { + val result = reader.find { isAbove { containsText("nonexistent") } } + assertThat(result).isNull() + } + + @Test + fun `isBelow and isAbove combined finds value sandwiched between labels`() { + val result = reader.find { + matchesPattern(Regex("^\\d{8}$")) + isBelow { containsText(labelMembership.text) } + isAbove { containsText(labelIssueDate.text) } + } + assertThat(result).isEqualTo(membershipValue) + } + + @Test + fun `isBelow and isAbove returns null when no line fits between anchors`() { + val result = reader.find { + isBelow(labelExpiryDate) + isAbove(membershipValue) + } + assertThat(result).isNull() + } + + private fun createLine( + id: Int, + text: String, + top: Int, + left: Int = 0, + right: Int = 100, + ) = OcrLine( + id = id, + text = text, + boundingBox = BoundingBox(left = left, top = top, right = right, bottom = top + 30), + blockBoundingBox = BoundingBox(left = left, top = top, right = right, bottom = top + 30), + confidence = 1f, + ) +} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCaseTest.kt new file mode 100644 index 0000000000..8803479d93 --- /dev/null +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCaseTest.kt @@ -0,0 +1,172 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.graphics.Bitmap +import com.google.common.truth.Truth.assertThat +import com.simprints.feature.externalcredential.model.BoundingBox +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine +import com.simprints.infra.credential.store.CredentialImageRepository +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +internal class GetCredentialCoordinatesUseCaseTest { + @MockK + private lateinit var readTextFromImage: ReadTextFromImageUseCase + + @MockK + private lateinit var ghanaNhisCardOcrSelectorUseCase: GhanaNhisCardOcrSelectorUseCase + + @MockK + private lateinit var ghanaIdCardOcrSelectorUseCase: GhanaIdCardOcrSelectorUseCase + + @MockK + private lateinit var credentialImageRepository: CredentialImageRepository + + private lateinit var useCase: GetCredentialCoordinatesUseCase + + private val bitmap = mockk(relaxed = true) + private val savedImagePath = "path/to/image.jpg" + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + useCase = GetCredentialCoordinatesUseCase( + readTextFromImage = readTextFromImage, + ghanaNhisCardOcrSelectorUseCase = ghanaNhisCardOcrSelectorUseCase, + ghanaIdCardOcrSelectorUseCase = ghanaIdCardOcrSelectorUseCase, + credentialImageRepository = credentialImageRepository, + ) + coEvery { credentialImageRepository.saveCredentialScan(any(), any()) } returns savedImagePath + } + + @Test + fun `returns null when readTextFromImage returns null`() = runTest { + every { readTextFromImage(bitmap) } returns null + + val result = useCase(bitmap, OcrDocumentType.NhisCard) + + assertThat(result).isNull() + } + + @Test + fun `returns null when selector finds no matching line for NhisCard`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { ghanaNhisCardOcrSelectorUseCase(any()) } returns null + + val result = useCase(bitmap, OcrDocumentType.NhisCard) + + assertThat(result).isNull() + } + + @Test + fun `returns null when selector finds no matching line for GhanaIdCard`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { ghanaIdCardOcrSelectorUseCase(any()) } returns null + + val result = useCase(bitmap, OcrDocumentType.GhanaIdCard) + + assertThat(result).isNull() + } + + @Test + fun `returns DetectedOcrBlock for NhisCard when line is found`() = runTest { + val ocrLine = ocrLine(text = "12345678") + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { ghanaNhisCardOcrSelectorUseCase(any()) } returns ocrLine + + val result = useCase(bitmap, OcrDocumentType.NhisCard) + + assertThat(result).isNotNull() + assertThat(result?.readoutValue).isEqualTo(ocrLine.text) + assertThat(result?.lineBoundingBox).isEqualTo(ocrLine.boundingBox) + assertThat(result?.blockBoundingBox).isEqualTo(ocrLine.blockBoundingBox) + assertThat(result?.documentType).isEqualTo(OcrDocumentType.NhisCard) + } + + @Test + fun `returns DetectedOcrBlock for GhanaIdCard when line is found`() = runTest { + val ocrLine = ocrLine(text = "GHA-123456789-0") + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { ghanaIdCardOcrSelectorUseCase(any()) } returns ocrLine + + val result = useCase(bitmap, OcrDocumentType.GhanaIdCard) + + assertThat(result).isNotNull() + assertThat(result?.readoutValue).isEqualTo(ocrLine.text) + assertThat(result?.documentType).isEqualTo(OcrDocumentType.GhanaIdCard) + } + + @Test + fun `saves image when line is found`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { ghanaNhisCardOcrSelectorUseCase(any()) } returns ocrLine() + + useCase(bitmap, OcrDocumentType.NhisCard) + + coVerify(exactly = 1) { credentialImageRepository.saveCredentialScan(bitmap, any()) } + } + + @Test + fun `does not save image when no line is found`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { ghanaNhisCardOcrSelectorUseCase(any()) } returns null + + useCase(bitmap, OcrDocumentType.NhisCard) + + coVerify(exactly = 0) { credentialImageRepository.saveCredentialScan(any(), any()) } + } + + @Test + fun `saved image path is set in result`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { ghanaNhisCardOcrSelectorUseCase(any()) } returns ocrLine() + + val result = useCase(bitmap, OcrDocumentType.NhisCard) + + assertThat(result?.imagePath).isEqualTo(savedImagePath) + } + + @Test + fun `returns null when exception is thrown`() = runTest { + every { readTextFromImage(bitmap) } throws RuntimeException("OCR failed") + + val result = useCase(bitmap, OcrDocumentType.NhisCard) + + assertThat(result).isNull() + } + + @Test + fun `delegates NhisCard to nhis selector`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + + useCase(bitmap, OcrDocumentType.NhisCard) + + coVerify(exactly = 1) { ghanaNhisCardOcrSelectorUseCase(any()) } + coVerify(exactly = 0) { ghanaIdCardOcrSelectorUseCase(any()) } + } + + @Test + fun `delegates GhanaIdCard to ghana id selector`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + + useCase(bitmap, OcrDocumentType.GhanaIdCard) + + coVerify(exactly = 1) { ghanaIdCardOcrSelectorUseCase(any()) } + coVerify(exactly = 0) { ghanaNhisCardOcrSelectorUseCase(any()) } + } + + private fun ocrLine(text: String = "12345678") = OcrLine( + id = 0, + text = text, + boundingBox = BoundingBox(left = 0, top = 100, right = 200, bottom = 130), + blockBoundingBox = BoundingBox(left = 0, top = 90, right = 200, bottom = 140), + confidence = 1f, + ) +} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCaseTest.kt index 121662b35b..356f10d58e 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCaseTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCaseTest.kt @@ -1,12 +1,33 @@ package com.simprints.feature.externalcredential.screens.scanocr.usecase import com.google.common.truth.Truth.assertThat +import com.simprints.feature.externalcredential.model.BoundingBox +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrReader +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrText import io.mockk.MockKAnnotations import org.junit.Before import org.junit.Test internal class GhanaIdCardOcrSelectorUseCaseTest { private lateinit var useCase: GhanaIdCardOcrSelectorUseCase + private val label = "Ghana Card Number" + private val validIds = listOf( + "GHA-123456789-0", + "GHA-987654321-5", + "GHA-000000000-9", + ) + private val invalidIds = listOf( + "GHB-123456789-0", + "GHA123456789-0", + "GHA-1234567890", + "GHA-12345678-0", + "GHA-1234567890-0", + "GHA-12345678A-0", + "GHA-123456789-A", + "GHA-123456789-01", + "", + ) @Before fun setUp() { @@ -15,35 +36,41 @@ internal class GhanaIdCardOcrSelectorUseCaseTest { } @Test - fun `Returns true for valid Ghana ID formats`() { - val validIds = listOf( - "GHA-123456789-0", - "GHA-987654321-5", - "GHA-000000000-9", - ) - - validIds.forEach { id -> - assertThat(useCase(id)).isTrue() + fun `returns matching line for valid Ghana ID formats`() { + validIds.forEachIndexed { id, ghanaId -> + val nonMatching = line(id = id, text = label, top = 100) + val expected = line(id = id + 1, text = ghanaId, top = 140) + val reader = buildReader(nonMatching, expected) + + assertThat(useCase(reader)).isEqualTo(expected) } } @Test - fun `Returns false for invalid Ghana ID formats`() { - val invalidIds = listOf( - "GHB-123456789-0", - "GHA123456789-0", - "GHA-1234567890", - "GHA-12345678-0", - "GHA-1234567890-0", - "GHA-12345678A-0", - "GHA-123456789-A", - "GHA-123456789-01", - "", - "GHA-123456789-0 ", - ) - - invalidIds.forEach { id -> - assertThat(useCase(id)).isFalse() + fun `returns null for invalid Ghana ID formats`() { + invalidIds.forEachIndexed { id, ghanaId -> + val reader = buildReader( + line(id = id, text = label, top = 100), + line(id = id + 1, text = ghanaId, top = 140), + ) + + assertThat(useCase(reader)).isNull() } } + + private fun buildReader(vararg lines: OcrLine) = OcrReader( + OcrText(allLines = lines.toList()), + ) + + private fun line( + id: Int, + text: String, + top: Int, + ) = OcrLine( + id = id, + text = text, + boundingBox = BoundingBox(left = 0, top = top, right = 200, bottom = top + 30), + blockBoundingBox = BoundingBox(left = 0, top = top, right = 200, bottom = top + 30), + confidence = 1f, + ) } diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCaseTest.kt index 52816d93ec..3db7f26b91 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCaseTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCaseTest.kt @@ -1,12 +1,29 @@ package com.simprints.feature.externalcredential.screens.scanocr.usecase import com.google.common.truth.Truth.assertThat +import com.simprints.feature.externalcredential.model.BoundingBox +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrReader +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrText import io.mockk.MockKAnnotations import org.junit.Before import org.junit.Test internal class GhanaNhisCardOcrSelectorUseCaseTest { private lateinit var useCase: GhanaNhisCardOcrSelectorUseCase + private val label = "membership number" + private val validNumbers = listOf( + "12345678", + "98765432", + "00000000", + ) + private val invalidNumbers = listOf( + "1234567", + "123456789", + "1234567A", + "12345-78", + "", + ) @Before fun setUp() { @@ -15,30 +32,41 @@ internal class GhanaNhisCardOcrSelectorUseCaseTest { } @Test - fun `Returns true for valid NHIS membership numbers`() { - val validNumbers = listOf( - "12345678", - "98765432", - "00000000", - ) - - validNumbers.forEach { number -> - assertThat(useCase(number)).isTrue() + fun `returns matching line for valid NHIS membership numbers`() { + validNumbers.forEachIndexed { id, number -> + val label = line(id = id, text = label, top = 100) + val expected = line(id = id + 1, text = number, top = 140) + val reader = buildReader(label, expected) + + assertThat(useCase(reader)).isEqualTo(expected) } } @Test - fun `Returns false for invalid NHIS membership numbers`() { - val invalidNumbers = listOf( - "1234567", - "123456789", - "1234567A", - "12345-78", - "", - ) - - invalidNumbers.forEach { number -> - assertThat(useCase(number)).isFalse() + fun `returns null for invalid NHIS membership numbers`() { + invalidNumbers.forEachIndexed { id, number -> + val reader = buildReader( + line(id = id, text = "membership number", top = 100), + line(id = id + 1, text = number, top = 140), + ) + + assertThat(useCase(reader)).isNull() } } + + private fun buildReader(vararg lines: OcrLine) = OcrReader( + OcrText(allLines = lines.toList()), + ) + + private fun line( + id: Int, + text: String, + top: Int, + ) = OcrLine( + id = id, + text = text, + boundingBox = BoundingBox(left = 0, top = top, right = 200, bottom = top + 30), + blockBoundingBox = BoundingBox(left = 0, top = top, right = 200, bottom = top + 30), + confidence = 1f, + ) }