"""OCR plumbing for `petctl read` — engine selection and output cleanup. Only the pure half is covered, per the testing conventions: capture and the OCR call itself need a real screen and a real engine. Engine probes are injected so these pass on a machine with a different set installed (or none). """ import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from bolt_pet import screen_text from bolt_pet.monitors import Monitor def probes(modules=(), binaries=()): return (lambda name: name in modules), ( lambda name: f"/usr/bin/{name}" if name in binaries else None ) def test_prefers_tesseract_when_fully_installed(): has_module, which = probes({"pytesseract"}, {"tesseract"}) assert screen_text.resolve_engine(has_module, which) == ("pytesseract", "") def test_falls_back_to_rapidocr_when_tesseract_binary_is_absent(): has_module, which = probes({"pytesseract", "rapidocr_onnxruntime"}, set()) engine, reason = screen_text.resolve_engine(has_module, which) assert engine == "rapidocr" assert reason == "" def test_pytesseract_without_the_binary_says_which_half_is_missing(): """The commonest broken setup: `pip install pytesseract` and stop, not realising the actual engine is a system package.""" has_module, which = probes({"pytesseract"}, set()) engine, reason = screen_text.resolve_engine(has_module, which) assert engine is None assert "tesseract binary" in reason assert "apt install tesseract-ocr" in reason def test_nothing_installed_explains_how_to_fix_it(): has_module, which = probes(set(), set()) engine, reason = screen_text.resolve_engine(has_module, which) assert engine is None assert "pip install" in reason def test_capture_availability_follows_mss(): assert screen_text.capture_available(lambda name: name == "mss") assert not screen_text.capture_available(lambda name: False) def test_clean_drops_ocr_noise_and_blank_runs(): raw = "Firefox\n\n\n |\n .\nBuild failed\n~\n" assert screen_text.clean_ocr_text(raw) == "Firefox\nBuild failed" def test_clean_collapses_whitespace_but_keeps_line_structure(): raw = " File edit view \nline\ttwo " assert screen_text.clean_ocr_text(raw) == "File edit view\nline two" def test_clean_drops_consecutive_duplicates_only(): raw = "Terminal\nTerminal\nEditor\nTerminal" assert screen_text.clean_ocr_text(raw) == "Terminal\nEditor\nTerminal" def test_clean_keeps_short_but_real_tokens(): # two alphanumerics is the bar — "ok" and "42" survive, "-" doesn't assert screen_text.clean_ocr_text("ok\n-\n42") == "ok\n42" def test_clean_truncates_and_says_so(): out = screen_text.clean_ocr_text("word " * 500, max_chars=100) assert out.endswith("[truncated]") # the cap applies to the text, before the marker is appended assert len(out.split("\n[truncated]")[0]) <= 100 def test_clean_handles_empty_input(): assert screen_text.clean_ocr_text("") == "" assert screen_text.clean_ocr_text(None) == "" def test_format_reading_names_the_monitor(): monitor = Monitor(1, "HDMI-1", 1920, 0, 1920, 1080) out = screen_text.format_reading(monitor, "Build failed") assert "monitor 2 (HDMI-1)" in out assert out.endswith("Build failed") def test_format_reading_when_nothing_was_recognised(): monitor = Monitor(0, "DP-0", 0, 0, 1920, 1080) assert "no text recognised" in screen_text.format_reading(monitor, " ") def test_read_monitor_never_raises_without_an_engine(monkeypatch): """Its return value goes back to the server as command output, so every failure has to come back as a sentence rather than an exception.""" monkeypatch.setattr(screen_text, "capture_available", lambda *a, **k: True) monkeypatch.setattr( screen_text, "resolve_engine", lambda *a, **k: (None, "no engine here") ) out = screen_text.read_monitor(Monitor(0, "DP-0", 0, 0, 1920, 1080)) assert out.startswith("[pet]") assert "no engine here" in out def test_read_monitor_reports_a_failed_capture(monkeypatch): monkeypatch.setattr(screen_text, "capture_available", lambda *a, **k: True) monkeypatch.setattr(screen_text, "resolve_engine", lambda *a, **k: ("pytesseract", "")) monkeypatch.setattr(screen_text, "capture", lambda monitor: None) out = screen_text.read_monitor(Monitor(0, "DP-0", 0, 0, 1920, 1080)) assert "couldn't capture" in out and "Wayland" in out def test_read_monitor_survives_an_exploding_engine(monkeypatch): monkeypatch.setattr(screen_text, "capture_available", lambda *a, **k: True) monkeypatch.setattr(screen_text, "resolve_engine", lambda *a, **k: ("pytesseract", "")) monkeypatch.setattr(screen_text, "capture", lambda monitor: object()) monkeypatch.setattr( screen_text, "_ocr", lambda image, engine: (_ for _ in ()).throw(RuntimeError("boom")) ) out = screen_text.read_monitor(Monitor(0, "DP-0", 0, 0, 1920, 1080)) assert "OCR failed" in out and "boom" in out def test_read_monitors_splits_the_budget(monkeypatch): seen = [] def fake_read(monitor, max_chars): seen.append((monitor.number, max_chars)) return f"screen {monitor.number}" monkeypatch.setattr(screen_text, "read_monitor", fake_read) screens = [ Monitor(0, "A", 0, 0, 100, 100), Monitor(1, "B", 100, 0, 100, 100), Monitor(2, "C", 200, 0, 100, 100), ] out = screen_text.read_monitors(screens, 3000) assert [n for n, _ in seen] == [1, 2, 3] assert all(limit == 1000 for _, limit in seen) assert out.count("screen ") == 3 def test_read_monitors_keeps_a_floor_on_the_budget(monkeypatch): monkeypatch.setattr( screen_text, "read_monitor", lambda monitor, max_chars: str(max_chars) ) screens = [Monitor(i, str(i), 0, 0, 10, 10) for i in range(20)] # 100/20 would be 5 characters per screen, which is useless — floor wins assert "400" in screen_text.read_monitors(screens, 100) def test_read_monitors_with_one_screen_uses_the_whole_budget(monkeypatch): monkeypatch.setattr( screen_text, "read_monitor", lambda monitor, max_chars: str(max_chars) ) assert screen_text.read_monitors([Monitor(0, "A", 0, 0, 10, 10)], 4000) == "4000" def test_read_monitors_with_no_screens(): assert "no monitor information" in screen_text.read_monitors([], 4000)