Skip to content

Testing with the Fake Engine

Testing "what does my screen do when OCR returns two pages" should not require running Tesseract, installing language data, and tolerating recognition drift between machines. The uncial-engine-fake module exists so it does not: it gives you a deterministic engine and a rasterizer that needs no PDF.

Add it as a test dependency of the module whose code you are testing, then point a UncialClient at it:

import io.github.andrewmalitchuk.uncial.runtime.source.client.UncialClient
import io.github.andrewmalitchuk.uncial.engine.fake.source.engine.FakeOcrEngine
import io.github.andrewmalitchuk.uncial.engine.fake.source.raster.FakePageRasterizer
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals

class ReaderTest {

    @Test
    fun `renders both pages`() = runTest {
        val client = UncialClient {
            engine = FakeOcrEngine(text = mapOf(0 to "Розділ перший", 1 to "Тіло тексту"))
            rasterizer = FakePageRasterizer(pageCount = 2)
        }

        val document = client.extract(ByteArray(0)).getOrThrow()

        assertEquals(2, document.pageCount)
        assertEquals("Розділ перший", document.pages[0].text)
        client.close()
    }
}

Passing engine explicitly bypasses OcrEngineRegistry entirely, so a test never depends on what happens to be registered in the process.

The bytes are ignored

FakePageRasterizer.open does not look at the bytes it is given. Pass ByteArray(0). There is no PDF, no file, and no I/O anywhere in this setup.


FakeOcrEngine

An OcrEngineFactory that recognizes whatever you tell it to.

FakeOcrEngine(
    text = emptyMap(),                                  // Map<Int, String>: page index -> text
    defaultText = FakeOcrEngine.DEFAULT_PAGE_TEXT,      // "Uncial fake page"
    capabilities = FakeOcrEngine.DefaultCapabilities,
    failOnPage = null,                                  // Int?
    available = true,
    delayPerPageMillis = 0L,
)
Parameter What it does
text Page index to the text that page recognizes as, one OcrLine per \n.
defaultText What a page with no text entry produces. Blank means an empty page — how you simulate an image-only scan that OCR could not read.
capabilities What the engine claims it can do. Override to test how your code handles an engine without confidence or word support.
failOnPage Makes recognize throw OcrError.RecognitionFailed for this page index.
available What isAvailable() returns — the "engine missing" path.
delayPerPageMillis Artificial per-page delay, for progress and cancellation tests. Uses delay, so it costs no real time under runTest.

FakeOcrEngine.FAKE_ENGINE_ID is "fake", which is what capabilities.engineId reports. DefaultCapabilities claims wordLevel and confidence but not perLineLanguage or skewDetection, so tests exercise the fully featured paths by default. The engine reports the languages you asked for, whatever they are.

The lines it produces carry real geometry — evenly spaced boxes with plausible sizes, and word boxes that tile the line without gaps when includeWords is on. That is deliberate: a fake returning BoundingBox.Zero everywhere would make every DocumentStructure test pass for the wrong reason.

Testing the unhappy paths

// Engine missing entirely -> OcrError.Unsupported at the first extract, not at construction.
val noEngine = UncialClient {
    engine = FakeOcrEngine(available = false)
    rasterizer = FakePageRasterizer()
}
assertIs<OcrError.Unsupported>(noEngine.extract(ByteArray(0)).exceptionOrNull())

// Recognition blows up on page 1.
val failing = UncialClient {
    engine = FakeOcrEngine(failOnPage = 1)
    rasterizer = FakePageRasterizer(pageCount = 3)
}
assertEquals(1, assertIs<OcrError.RecognitionFailed>(
    failing.extract(ByteArray(0)).exceptionOrNull(),
).pageIndex)

// Rendering blows up on page 2.
val unrenderable = UncialClient {
    engine = FakeOcrEngine()
    rasterizer = FakePageRasterizer(pageCount = 3, failOnPage = 2)
}
assertIs<OcrError.RenderFailed>(unrenderable.extract(ByteArray(0)).exceptionOrNull())

Testing progress and cancellation

delayPerPageMillis plus a long fake document is the whole setup. Under runTest the virtual clock means this is instant:

@Test
fun `cancellation propagates instead of becoming a failed Result`() = runTest {
    val client = UncialClient {
        engine = FakeOcrEngine(delayPerPageMillis = 1_000)
        rasterizer = FakePageRasterizer(pageCount = 100)
    }
    assertFailsWith<CancellationException> {
        withTimeout(2_500) { client.extract(ByteArray(0)) }
    }
    client.close()
}

For the event stream itself, collect the flow and assert on the sequence:

val events = client.extractAsFlow(ByteArray(0)).toList()
val started = assertIs<OcrProgress.Started>(events.first())
assertEquals(3, started.pageCount)
assertEquals(listOf(0, 1, 2), events.filterIsInstance<OcrProgress.Page>().map { it.index })
assertEquals(3, assertIs<OcrProgress.Done>(events.last()).document.pageCount)

FakePageRasterizer

A PageRasterizer that invents an imaginary document of the size you ask for.

FakePageRasterizer(
    pageCount = 1,
    pageWidth = FakePageRasterizer.DEFAULT_WIDTH,    // 1654
    pageHeight = FakePageRasterizer.DEFAULT_HEIGHT,  // 2339
    failOnPage = null,
)

The defaults are roughly A4 at 200 dpi, matching the OcrOptions defaults — so page sizes in your assertions look like the ones you would see in production. pageCount must not be negative; pageCount = 0 is legal and makes the extraction fail with OcrError.InvalidInput, which is how you test the empty-document path.

rasterize throws OcrError.RenderFailed for failOnPage and for any index outside 0 until pageCount.

Pair it with FakeOcrEngine and the two drive UncialClient end to end with no real document, no engine, and no I/O.


placeholderRaster and blankRaster

Two rasters, for two different jobs.

placeholderRaster(width, height) — from io.github.andrewmalitchuk.uncial.core.source.raster — has a size and no pixels. Reading its platform image (Raster.bitmap on Android, Raster.image on JVM and iOS) throws IllegalStateException. release() is harmless.

This is what FakePageRasterizer hands out, and the reason it exists is concrete:

Android host unit tests cannot create a Bitmap

In a host (unit) test, Bitmap is a stub in android.jar and Bitmap.createBitmap throws not mocked. A fake pipeline that allocated real bitmaps would therefore be unusable in exactly the place a consumer most wants to test their own code — which would defeat the point of shipping a fake engine at all. placeholderRaster carries dimensions and nothing else, so it works everywhere.

blankRaster(width, height) — from io.github.andrewmalitchuk.uncial.engine.fake.source.raster — is a blank white raster with real pixels: a BufferedImage on the JVM, an ARGB Bitmap on Android, a real image on iOS.

Use it when you are testing a real TextRecognizer, which needs something to look at:

import io.github.andrewmalitchuk.uncial.engine.fake.source.raster.blankRaster

val raster = blankRaster(800, 1000)
try {
    val page = recognizer.recognize(raster, pageIndex = 0, options = OcrOptions())
    println(page.lines.size)
} finally {
    raster.release()
}

Because it allocates a real Bitmap, on Android it belongs in an instrumented test, not a host test. For driving UncialClient against FakeOcrEngine, prefer FakePageRasterizer.

Pixels Works in an Android host test Use for
placeholderRaster no yes Fake pipelines; anything that ignores pixels
blankRaster yes no (instrumented only) Exercising a real TextRecognizer

Testing structure without a client

DocumentStructure.reconstruct is a pure function over an OcrDocument, so it needs neither an engine nor a rasterizer. Build the document by hand, or produce it with FakeOcrEngine — the fake's line boxes are realistic enough that heading and paragraph heuristics behave as they do in production:

val client = UncialClient {
    engine = FakeOcrEngine(text = mapOf(0 to "Розділ 1\nПерший абзац тексту."))
    rasterizer = FakePageRasterizer(pageCount = 1)
}
val blocks = DocumentStructure.reconstruct(client.extract(ByteArray(0)).getOrThrow())
client.close()

Note that the fake gives every line the same size, so nothing in such a document clears headingRatio. To test heading detection, construct OcrLines with explicit fontSize values yourself — the model types in uncial-model are plain data classes with no platform dependencies.


Registry hygiene

If a test needs to exercise engine discovery rather than bypass it, OcrEngineRegistry has register(factory) and unregister(id); the latter is there for tests. Registration is process-global, so unregister what you registered:

OcrEngineRegistry.register(FakeOcrEngine())
try {
    // code under test resolves the engine through the registry
} finally {
    OcrEngineRegistry.unregister(FakeOcrEngine.FAKE_ENGINE_ID)
}

For everything else, passing engine to the builder is simpler and cannot leak between tests.