//! OCR-T1 end-to-end with a REAL engine: fixture image → system Tesseract → //! text + boxes → export. Skips (with a loud message) when `tesseract` is //! not installed; set `NIGIG_OCR_REQUIRE_TESSERACT=1` (CI) to make a missing //! engine a failure instead of a skip. use std::sync::Arc; use nigig_ocr::providers::TesseractEngine; use nigig_ocr::{ run_item, FileItem, InMemoryClipboard, JobEvent, OcrEngine, OcrError, OcrJob, OcrLanguage, Settings, UiAction, UiState, }; const FIXTURE: &[u8] = include_bytes!("fixtures/hello.png"); fn engine() -> Option { let e = TesseractEngine::detect(); if e.is_none() { if std::env::var_os("NIGIG_OCR_REQUIRE_TESSERACT").is_some() { panic!("tesseract required but not found"); } eprintln!("SKIP: tesseract not installed — real OCR not exercised"); } e } fn norm(s: &str) -> String { s.split_whitespace().collect::>().join(" ") } #[test] fn recognises_fixture_text_and_boxes() { let Some(e) = engine() else { return }; let mut item = FileItem::new(FIXTURE.to_vec()); let r = run_item(&e, &mut item).expect("ocr"); assert_eq!(norm(&r.text), "Hello Nairobi 2026 Invoice total 4500"); assert_eq!(r.boxes.len(), 6); for b in &r.boxes { assert!( b.x >= 0.0 && b.y >= 0.0 && b.x + b.w <= 1.0 && b.y + b.h <= 1.0, "{b:?}" ); } // Top-left origin: first line's words sit above the second line's. assert!(r.boxes[0].y < r.boxes[3].y); assert_eq!(item.recognized_text.as_deref(), Some(r.text.as_str())); } #[test] fn rotation_is_applied_before_recognition() { let Some(e) = engine() else { return }; // Store the fixture rotated 90° clockwise, then ask the app to rotate it back. let img = image::load_from_memory(FIXTURE).unwrap().rotate90(); let mut bytes = Vec::new(); img.write_to( &mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png, ) .unwrap(); let mut item = FileItem::new(bytes); item.rotate_left(); let r = run_item(&e, &mut item).unwrap(); assert!( norm(&r.text).contains("Hello Nairobi 2026"), "got {:?}", r.text ); } #[test] fn uninstalled_language_and_barcode_are_refused() { let Some(e) = engine() else { return }; let mut item = FileItem::new(FIXTURE.to_vec()); if !e .supported_languages() .contains(&OcrLanguage::SimplifiedChinese) { item.options.language = OcrLanguage::SimplifiedChinese; assert!(matches!( run_item(&e, &mut item), Err(OcrError::UnsupportedLanguage(_)) )); } item.options.language = OcrLanguage::English; item.options.barcode_mode = true; assert_eq!( e.recognize(&item).unwrap_err(), OcrError::BarcodeUnsupported ); } #[test] fn background_job_with_real_engine() { let Some(e) = engine() else { return }; let items = vec![ (0, FileItem::new(FIXTURE.to_vec())), (1, FileItem::new(b"junk".to_vec())), ]; let evs = OcrJob::spawn(Arc::new(e), items).wait(); let results: Vec = evs .iter() .filter_map(|ev| match ev { JobEvent::Finished { result, .. } => Some(result.is_ok()), _ => None, }) .collect(); assert_eq!(results, vec![true, false]); } #[test] fn ui_flow_drop_ocr_copy_export_for_real() { let Some(e) = engine() else { return }; let dir = nigig_ocr::ingest::resolved_dir( std::env::temp_dir().join(format!("ocr-real-{}", std::process::id())), ); let mut ui = UiState::with_services( Settings::default(), Arc::new(e), Box::new(InMemoryClipboard::default()), Some(dir.clone()), ); assert_eq!( ui.apply(UiAction::DropBytes(FIXTURE.to_vec())), "Added file 0" ); assert!(ui.apply(UiAction::DoOcr).starts_with("OCR done: 1/1")); assert_eq!(ui.apply(UiAction::Copy), "Copied"); assert!(norm(&ui.clipboard().last_copied().unwrap()).contains("Invoice total 4500")); let m = ui.apply(UiAction::SavePdf { all: false }); let p = m.strip_prefix("PDF File Exported: ").expect(&m); assert!(std::fs::read(p).unwrap().starts_with(b"%PDF-1.4")); let _ = std::fs::remove_dir_all(&dir); }