lllin000_PaperForge/tests/test_ocr_maintenance.py
2026-07-14 18:05:49 +08:00

834 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for OCR maintenance display fields and manifest."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import pytest
# ===========================================================================
# _compute_display_fields — pure function tests
# ===========================================================================
def _call(status: str, health: str = "-", version: str = "-",
can_redo: bool = False, can_rebuild: bool = False,
error_stage: str = "", error_summary: str = "",
degraded_reasons: list[str] | None = None) -> dict:
from paperforge.worker.ocr_maintenance import OCRMaintenanceRow
return OCRMaintenanceRow.compute_display_fields(
status=status, health_overall=health, version=version,
can_redo=can_redo, can_rebuild=can_rebuild,
error_stage=error_stage, error_summary=error_summary,
degraded_reasons=degraded_reasons,
)
def _assert(df: dict, *, action: str, label: str, group: str, severity: str,
visible: bool, reason: str = "",
label_key: str = "", reason_key: str = "") -> None:
assert df["display_action"] == action, f"action: {df['display_action']} != {action}"
assert df["display_label"] == label, f"label: {df['display_label']} != {label}"
assert df["display_label_key"] == label_key
assert df["display_group"] == group, f"group: {df['display_group']} != {group}"
assert df["display_severity"] == severity
assert df["visible_in_maintenance"] == visible, f"visible: {df['visible_in_maintenance']} != {visible}"
assert df["display_reason"] == reason, f"reason: {df['display_reason']!r} != {reason!r}"
assert df["display_reason_key"] == reason_key
# --- Rule 3: failed / error / fatal / done_incomplete / retryable_error + can_redo ---
class TestRule3FailedErrorCanRedo:
"""status in (failed, error, fatal_error, done_incomplete, retryable_error) + can_redo"""
def test_failed_can_redo(self) -> None:
df = _call(status="failed", can_redo=True)
_assert(df, action="retry_ocr", label="重试 OCR", group="retry",
severity="actionable", visible=True,
reason="上次处理未完成,可以重新尝试",
label_key="maintenance_action_retry_ocr",
reason_key="maintenance_reason_retry")
def test_error_can_redo(self) -> None:
df = _call(status="error", can_redo=True)
_assert(df, action="retry_ocr", label="重试 OCR", group="retry",
severity="actionable", visible=True,
reason="上次处理未完成,可以重新尝试",
label_key="maintenance_action_retry_ocr",
reason_key="maintenance_reason_retry")
def test_fatal_error_can_redo(self) -> None:
df = _call(status="fatal_error", can_redo=True)
_assert(df, action="retry_ocr", label="重试 OCR", group="retry",
severity="actionable", visible=True,
reason="上次处理未完成,可以重新尝试",
label_key="maintenance_action_retry_ocr",
reason_key="maintenance_reason_retry")
def test_done_incomplete_can_redo(self) -> None:
df = _call(status="done_incomplete", can_redo=True)
_assert(df, action="retry_ocr", label="重试 OCR", group="retry",
severity="actionable", visible=True,
reason="上次处理未完成,可以重新尝试",
label_key="maintenance_action_retry_ocr",
reason_key="maintenance_reason_retry")
def test_retryable_error_can_redo(self) -> None:
df = _call(status="retryable_error", can_redo=True)
_assert(df, action="retry_ocr", label="重试 OCR", group="retry",
severity="actionable", visible=True,
reason="上次处理未完成,可以重新尝试",
label_key="maintenance_action_retry_ocr",
reason_key="maintenance_reason_retry")
# --- Rule 4: version=v1 + can_redo ---
class TestRule4LegacyUpgrade:
def test_v1_can_redo(self) -> None:
df = _call(status="done", health="-", version="v1", can_redo=True)
_assert(df, action="upgrade_legacy", label="升级旧结果", group="legacy_optional",
severity="optional", visible=True,
reason="旧版本结果仍然可用,升级后可获得更好的章节、图表和问答效果",
label_key="maintenance_action_upgrade_legacy",
reason_key="maintenance_reason_legacy")
def test_v1_no_redo_falls_through(self) -> None:
# v1 but can't redo: rule 4 fails, should hit fallback (rule 12)
df = _call(status="done", health="-", version="v1", can_redo=False)
_assert(df, action="none", label="已完成", group="hidden",
severity="normal", visible=False)
# --- Rule 5: done_degraded + can_rebuild ---
class TestRule5DoneDegradedCanRebuild:
def test_done_degraded_can_rebuild(self) -> None:
df = _call(status="done_degraded", can_rebuild=True)
_assert(df, action="rebuild_result", label="重建结果", group="rebuild",
severity="actionable", visible=True,
reason="已有OCR数据可重建获得更稳定的结果")
# --- Rule 6: done + health bad (red/yellow) + can_rebuild ---
class TestRule6DoneBadHealthCanRebuild:
def test_done_red_can_rebuild(self) -> None:
df = _call(status="done", health="red", can_rebuild=True)
_assert(df, action="rebuild_result", label="重建结果", group="rebuild",
severity="actionable", visible=True,
reason="已有OCR数据可重建获得更稳定的结果")
def test_done_yellow_can_rebuild(self) -> None:
df = _call(status="done", health="yellow", can_rebuild=True)
_assert(df, action="rebuild_result", label="重建结果", group="rebuild",
severity="actionable", visible=True,
reason="已有OCR数据可重建获得更稳定的结果")
# --- Rule 10: done + health NOT bad ---
class TestRule10DoneNotBadHealth:
"""health_overall NOT in {yellow, red} → hidden complete, unless v1 catches first."""
def test_done_dash_health_can_rebuild(self) -> None:
# "-" is not yellow/red, not v1 → rule 10
df = _call(status="done", health="-", can_rebuild=True)
_assert(df, action="none", label="已完成", group="hidden",
severity="normal", visible=False)
def test_done_unknown_health_can_rebuild(self) -> None:
df = _call(status="done", health="unknown", can_rebuild=True)
_assert(df, action="none", label="已完成", group="hidden",
severity="normal", visible=False)
def test_done_green_can_rebuild(self) -> None:
df = _call(status="done", health="green", can_rebuild=True)
_assert(df, action="none", label="已完成", group="hidden",
severity="normal", visible=False)
def test_done_green_no_capabilities(self) -> None:
# done + green + no redo + no rebuild → rule 10 (not bad health) or rule 11
# Rule 10 matches first: done and not _health_is_bad
df = _call(status="done", health="green")
_assert(df, action="none", label="已完成", group="hidden",
severity="normal", visible=False)
# --- Rule 7: done_degraded + !can_rebuild + can_redo ---
class TestRule7DoneDegradedNoRebuildCanRedo:
def test_done_degraded_no_rebuild_can_redo(self) -> None:
df = _call(status="done_degraded", can_redo=True, can_rebuild=False)
_assert(df, action="retry_ocr", label="重试 OCR", group="retry",
severity="actionable", visible=True,
reason="降级结果无法重建可重新OCR")
# --- Rule 11: fallback when nothing actionable ---
class TestRule11NoAction:
def test_done_degraded_no_rebuild_no_redo(self) -> None:
df = _call(status="done_degraded", can_rebuild=False, can_redo=False)
_assert(df, action="none", label="已完成", group="hidden",
severity="normal", visible=False)
def test_done_red_no_rebuild_no_redo(self) -> None:
df = _call(status="done", health="red", can_rebuild=False, can_redo=False)
_assert(df, action="none", label="已完成", group="hidden",
severity="normal", visible=False)
def test_done_red_no_rebuild_no_redo_with_error(self) -> None:
# error_stage/error_summary should not affect fallback
df = _call(status="done", health="red", can_rebuild=False, can_redo=False,
error_stage="ocr_parse", error_summary="TimeoutError: timeout")
_assert(df, action="none", label="已完成", group="hidden",
severity="normal", visible=False)
# --- Rule 8: nopdf ---
class TestRule8NoPdf:
def test_nopdf(self) -> None:
df = _call(status="nopdf")
_assert(df, action="add_pdf", label="补充 PDF", group="external_action",
severity="external", visible=False,
reason="请去 Zotero 添加 PDF 文件")
# --- Rule 9: blocked ---
class TestRule9Blocked:
def test_blocked(self) -> None:
df = _call(status="blocked")
_assert(df, action="configure_ocr", label="配置 OCR", group="external_action",
severity="external", visible=False,
reason="请配置 PaddleOCR API Token")
# --- Rule 1: pending ---
class TestRule1Pending:
def test_pending(self) -> None:
df = _call(status="pending")
_assert(df, action="none", label="等待处理", group="hidden",
severity="normal", visible=False)
# --- Rule 2: running/queued/processing ---
class TestRule2Running:
def test_running(self) -> None:
df = _call(status="running")
_assert(df, action="none", label="处理中", group="hidden",
severity="normal", visible=False)
def test_queued(self) -> None:
df = _call(status="queued")
_assert(df, action="none", label="处理中", group="hidden",
severity="normal", visible=False)
def test_processing(self) -> None:
df = _call(status="processing")
_assert(df, action="none", label="处理中", group="hidden",
severity="normal", visible=False)
# --- Rule 12: fallback for unrecognised status ---
class TestRule12Fallback:
def test_unknown_status(self) -> None:
df = _call(status="bogus_status")
_assert(df, action="none", label="已完成", group="hidden",
severity="normal", visible=False)
# ===========================================================================
# Scenario 17: display_reason does NOT contain forbidden substrings
# ===========================================================================
FORBIDDEN = ("failed", "degraded", "health", "质量降级", "fatal", "error")
class TestDisplayReasonCleanliness:
"""display_reason values must never contain leaky technical keywords."""
def _assert_clean(self, reason: str) -> None:
for token in FORBIDDEN:
assert token not in reason, f"display_reason contains forbidden token {token!r}: {reason!r}"
def test_failed_reason_clean(self) -> None:
df = _call(status="failed", can_redo=True)
self._assert_clean(df["display_reason"])
def test_done_degraded_rebuild_reason_clean(self) -> None:
df = _call(status="done_degraded", can_rebuild=True)
self._assert_clean(df["display_reason"])
def test_done_red_rebuild_reason_clean(self) -> None:
df = _call(status="done", health="red", can_rebuild=True)
self._assert_clean(df["display_reason"])
def test_legacy_upgrade_reason_clean(self) -> None:
df = _call(status="done", version="v1", can_redo=True)
self._assert_clean(df["display_reason"])
def test_degraded_no_rebuild_can_redo_clean(self) -> None:
df = _call(status="done_degraded", can_redo=True, can_rebuild=False)
self._assert_clean(df["display_reason"])
def test_nopdf_reason_clean(self) -> None:
df = _call(status="nopdf")
self._assert_clean(df["display_reason"])
def test_blocked_reason_clean(self) -> None:
df = _call(status="blocked")
self._assert_clean(df["display_reason"])
# ===========================================================================
# compute_maintenance_manifest — temp vault tests
# ===========================================================================
def _ocr_path(tmp_path: Path) -> Path:
"""Return the OCR root within a temp vault."""
return tmp_path / "System" / "PaperForge" / "ocr"
def _create_paper(vault_root: Path, key: str, *,
ocr_status: str = "done",
health_overall: str = "green",
version: str | None = None,
error_stage: str = "",
error_type: str = "",
error_msg: str = "",
last_error: str = "",
is_backfilled: bool = False,
raw_version: bool = False,
derived_version: bool = False,
degraded_reasons: list[str] | None = None,
has_raw: bool = True,
has_source_meta: bool = True,
has_structured: bool = True,
) -> None:
"""Set up one paper's OCR artifacts so compute_maintenance_manifest can read them."""
import shutil
ocr_root = _ocr_path(vault_root)
paper_dir = ocr_root / key
# Clean any previous artifacts so file removal is respected
if paper_dir.exists():
shutil.rmtree(paper_dir)
paper_dir.mkdir(parents=True)
meta: dict = {"ocr_status": ocr_status}
if error_stage:
meta["error_stage"] = error_stage
if error_type:
meta["error_type"] = error_type
if error_msg:
meta["error"] = error_msg
if last_error:
meta["last_error"] = last_error
if is_backfilled:
meta["is_backfilled"] = True
if raw_version:
meta["raw_version"] = {"ocr_model": "test-model"}
if derived_version:
meta["derived_version"] = {"version": "2.0"}
(paper_dir / "meta.json").write_text(json.dumps(meta), encoding="utf-8")
health_dir = paper_dir / "health"
health_dir.mkdir(parents=True, exist_ok=True)
health: dict = {"overall": health_overall}
if degraded_reasons:
health["degraded_reasons"] = degraded_reasons
(health_dir / "ocr_health.json").write_text(json.dumps(health), encoding="utf-8")
# Create artifact files to control has_raw, has_source_meta, has_structured
if has_raw:
(paper_dir / "canonical").mkdir(parents=True, exist_ok=True)
(paper_dir / "canonical" / "blocks.raw.jsonl").write_text("", encoding="utf-8")
if has_source_meta:
(paper_dir / "raw").mkdir(parents=True, exist_ok=True)
(paper_dir / "raw" / "source_metadata.json").write_text("{}", encoding="utf-8")
if has_structured:
(paper_dir / "structure").mkdir(parents=True, exist_ok=True)
(paper_dir / "structure" / "blocks.structured.jsonl").write_text("", encoding="utf-8")
class TestComputeMaintenanceManifest:
"""Integration tests against a temp vault."""
def test_empty_vault_returns_empty(self, tmp_path: Path) -> None:
from paperforge.worker.ocr_maintenance import compute_maintenance_manifest
result = compute_maintenance_manifest(tmp_path)
assert result == {}
def test_single_paper(self, tmp_path: Path) -> None:
_create_paper(tmp_path, "ABC123")
from paperforge.worker.ocr_maintenance import compute_maintenance_manifest
result = compute_maintenance_manifest(tmp_path)
assert isinstance(result, dict)
assert len(result) == 1
assert "ABC123" in result
assert isinstance(result["ABC123"], str)
assert len(result["ABC123"]) == 64 # sha256 hexdigest
def test_two_papers(self, tmp_path: Path) -> None:
_create_paper(tmp_path, "AAA001", ocr_status="done", health_overall="green")
_create_paper(tmp_path, "BBB002", ocr_status="failed", health_overall="red")
from paperforge.worker.ocr_maintenance import compute_maintenance_manifest
result = compute_maintenance_manifest(tmp_path)
assert len(result) == 2
assert "AAA001" in result
assert "BBB002" in result
def test_no_ocr_dir_returns_empty(self, tmp_path: Path) -> None:
# Don't create the ocr directory
from paperforge.worker.ocr_maintenance import compute_maintenance_manifest
result = compute_maintenance_manifest(tmp_path)
assert result == {}
def test_paper_without_meta_json(self, tmp_path: Path) -> None:
# Create ocr dir but no meta.json
ocr_root = _ocr_path(tmp_path)
paper_dir = ocr_root / "MISSING"
paper_dir.mkdir(parents=True)
from paperforge.worker.ocr_maintenance import compute_maintenance_manifest
result = compute_maintenance_manifest(tmp_path)
# Paper dir exists but no meta.json → should still appear (empty meta)
assert "MISSING" in result
assert len(result) == 1
def test_manifest_hash_consistency(self, tmp_path: Path) -> None:
"""Same input → same hash."""
_create_paper(tmp_path, "CONSISTENT", ocr_status="done", health_overall="green")
from paperforge.worker.ocr_maintenance import compute_maintenance_manifest
h1 = compute_maintenance_manifest(tmp_path)["CONSISTENT"]
h2 = compute_maintenance_manifest(tmp_path)["CONSISTENT"]
assert h1 == h2
def test_manifest_hash_sensitivity_diff_status(self, tmp_path: Path) -> None:
"""Different status → different hash."""
_create_paper(tmp_path, "SENSITIVE", ocr_status="done", health_overall="green")
from paperforge.worker.ocr_maintenance import compute_maintenance_manifest
h_done = compute_maintenance_manifest(tmp_path)["SENSITIVE"]
# Same key but different status
_create_paper(tmp_path, "SENSITIVE", ocr_status="failed", health_overall="green")
h_failed = compute_maintenance_manifest(tmp_path)["SENSITIVE"]
assert h_done != h_failed
def test_manifest_hash_sensitivity_diff_health(self, tmp_path: Path) -> None:
"""Different health → different hash."""
_create_paper(tmp_path, "SENSITIVE", ocr_status="done", health_overall="green")
from paperforge.worker.ocr_maintenance import compute_maintenance_manifest
h_green = compute_maintenance_manifest(tmp_path)["SENSITIVE"]
_create_paper(tmp_path, "SENSITIVE", ocr_status="done", health_overall="red")
h_red = compute_maintenance_manifest(tmp_path)["SENSITIVE"]
assert h_green != h_red
def test_manifest_hash_sensitivity_diff_raw_presence(self, tmp_path: Path) -> None:
"""Different has_raw → different hash (affects can_rebuild)."""
_create_paper(tmp_path, "SENSITIVE", ocr_status="done", health_overall="green",
has_raw=True)
from paperforge.worker.ocr_maintenance import compute_maintenance_manifest
h_with_raw = compute_maintenance_manifest(tmp_path)["SENSITIVE"]
_create_paper(tmp_path, "SENSITIVE", ocr_status="done", health_overall="green",
has_raw=False)
h_no_raw = compute_maintenance_manifest(tmp_path)["SENSITIVE"]
assert h_with_raw != h_no_raw
def test_manifest_excludes_non_directories(self, tmp_path: Path) -> None:
"""Files in ocr root should be ignored."""
ocr_root = _ocr_path(tmp_path)
ocr_root.mkdir(parents=True)
(ocr_root / "not_a_dir.json").write_text("{}", encoding="utf-8")
_create_paper(tmp_path, "REALKEY")
from paperforge.worker.ocr_maintenance import compute_maintenance_manifest
result = compute_maintenance_manifest(tmp_path)
assert "not_a_dir.json" not in result
assert "REALKEY" in result
assert len(result) == 1
def test_manifest_hash_includes_degraded_reasons(self, tmp_path: Path) -> None:
"""Different degraded_reasons → different hash."""
_create_paper(tmp_path, "SENSITIVE", ocr_status="done", health_overall="yellow",
degraded_reasons=["low_confidence"])
from paperforge.worker.ocr_maintenance import compute_maintenance_manifest
h_with_degraded = compute_maintenance_manifest(tmp_path)["SENSITIVE"]
_create_paper(tmp_path, "SENSITIVE", ocr_status="done", health_overall="yellow",
degraded_reasons=[])
h_no_reasons = compute_maintenance_manifest(tmp_path)["SENSITIVE"]
assert h_with_degraded != h_no_reasons
class TestCollectMaintenanceRowsDisplayFields:
"""End-to-end: collect_maintenance_rows includes display fields."""
def test_row_has_display_fields(self, tmp_path: Path) -> None:
from paperforge.worker.ocr_maintenance import collect_maintenance_rows
_create_paper(tmp_path, "DISPLAY01", ocr_status="failed", health_overall="-")
rows = collect_maintenance_rows(tmp_path)
assert len(rows) == 1
row = rows[0]
# Should detect failed → retry_ocr
assert row.display_action == "retry_ocr"
assert row.visible_in_maintenance is True
assert row.display_group == "retry"
def test_to_dict_includes_display_fields(self, tmp_path: Path) -> None:
from paperforge.worker.ocr_maintenance import collect_maintenance_rows
_create_paper(tmp_path, "DICT01", ocr_status="failed", health_overall="-")
rows = collect_maintenance_rows(tmp_path)
d = rows[0].to_dict()
assert "display_action" in d
assert "display_label" in d
assert "display_label_key" in d
assert "display_reason" in d
assert "display_reason_key" in d
assert "display_group" in d
assert "display_severity" in d
assert "visible_in_maintenance" in d
# ===========================================================================
# PR B: CLI — _run_ocr_list with manifest/keys, _run_ocr_redo with keys
# ===========================================================================
class TestOcrListManifest:
"""_run_ocr_list with manifest=True."""
def test_manifest_flag_calls_compute_maintenance_manifest(
self, monkeypatch, capsys
) -> None:
import json as _json
from paperforge.commands.ocr import _run_ocr_list
manifest_data = {"KEY1": "abc123", "KEY2": "def456"}
monkeypatch.setattr(
"paperforge.worker.ocr_maintenance.compute_maintenance_manifest",
lambda vault: manifest_data,
)
result = _run_ocr_list(Path("/fake"), manifest=True)
captured = capsys.readouterr()
assert result == 0
assert _json.loads(captured.out) == manifest_data
def test_manifest_does_not_call_collect_maintenance_rows(
self, monkeypatch
) -> None:
from paperforge.commands.ocr import _run_ocr_list
def _raise(*args, **kwargs):
raise RuntimeError("collect_maintenance_rows should not be called")
monkeypatch.setattr(
"paperforge.worker.ocr_maintenance.collect_maintenance_rows",
_raise,
)
monkeypatch.setattr(
"paperforge.worker.ocr_maintenance.compute_maintenance_manifest",
lambda vault: {},
)
result = _run_ocr_list(Path("/fake"), manifest=True)
assert result == 0
class TestOcrListKeys:
"""_run_ocr_list with keys filter."""
@staticmethod
def _make_row(key: str) -> "OCRMaintenanceRow":
from paperforge.worker.ocr_maintenance import OCRMaintenanceRow
return OCRMaintenanceRow(
key=key,
title=f"Paper {key}",
title_full=f"Paper {key} Full",
status="done",
health="green",
version="2.0",
finished_at="-",
rebuild_finished_at="-",
pages=5,
blocks=100,
figures=2,
tables=1,
model="test",
degraded_reasons=[],
error_summary="",
error_stage="",
can_redo=False,
can_rebuild=True,
recommended_action="",
)
def test_keys_flag_filters_by_key(self, monkeypatch, capsys) -> None:
import json as _json
from paperforge.commands.ocr import _run_ocr_list
rows = [self._make_row("KEY1"), self._make_row("KEY2"), self._make_row("KEY3")]
monkeypatch.setattr(
"paperforge.worker.ocr_maintenance.collect_maintenance_rows",
lambda vault: rows,
)
result = _run_ocr_list(Path("/fake"), json_output=True, keys=["KEY1"])
captured = capsys.readouterr()
assert result == 0
data = _json.loads(captured.out)
assert len(data) == 1
assert data[0]["key"] == "KEY1"
def test_keys_none_returns_all(self, monkeypatch, capsys) -> None:
import json as _json
from paperforge.commands.ocr import _run_ocr_list
rows = [self._make_row("KEY1"), self._make_row("KEY2")]
monkeypatch.setattr(
"paperforge.worker.ocr_maintenance.collect_maintenance_rows",
lambda vault: rows,
)
result = _run_ocr_list(Path("/fake"), json_output=True, keys=None)
captured = capsys.readouterr()
assert result == 0
data = _json.loads(captured.out)
assert len(data) == 2
assert data[0]["key"] == "KEY1"
assert data[1]["key"] == "KEY2"
def test_keys_unknown_key_ignored(self, monkeypatch, capsys) -> None:
import json as _json
from paperforge.commands.ocr import _run_ocr_list
rows = [self._make_row("KEY1"), self._make_row("KEY2")]
monkeypatch.setattr(
"paperforge.worker.ocr_maintenance.collect_maintenance_rows",
lambda vault: rows,
)
result = _run_ocr_list(Path("/fake"), json_output=True, keys=["UNKNOWN"])
captured = capsys.readouterr()
assert result == 0
data = _json.loads(captured.out)
assert len(data) == 0
class TestOcrRedoKeyed:
"""_run_ocr_redo with keys parameter."""
def test_redo_single_key(self, tmp_path, monkeypatch) -> None:
"""Single key redo delegates to worker with full cycle."""
from paperforge.commands.ocr import _run_ocr_redo
from tests.cli.test_ocr_progress_contracts import (
_make_minimal_vault,
_add_literature_note,
_mock_run_ocr,
)
_mock_run_ocr(monkeypatch)
monkeypatch.setattr("paperforge.worker.ocr.validate_ocr_meta",
lambda _p, _m: ("done", ""))
vault = _make_minimal_vault(tmp_path)
_add_literature_note(vault, "KEY00001")
result = _run_ocr_redo(vault, keys=["KEY00001"])
assert result == 0
# Worker should have created meta.json with done status
meta_path = vault / "System/PaperForge/ocr" / "KEY00001" / "meta.json"
assert meta_path.exists(), "Meta should exist after redo cycle"
import json
meta = json.loads(meta_path.read_text(encoding="utf-8"))
assert meta.get("ocr_status") == "done"
def test_redo_multiple_keys(self, tmp_path, monkeypatch) -> None:
"""Multiple keys redo delegates to worker for each key."""
from paperforge.commands.ocr import _run_ocr_redo
from tests.cli.test_ocr_progress_contracts import (
_make_minimal_vault,
_add_literature_note,
_mock_run_ocr,
)
_mock_run_ocr(monkeypatch)
monkeypatch.setattr("paperforge.worker.ocr.validate_ocr_meta",
lambda _p, _m: ("done", ""))
vault = _make_minimal_vault(tmp_path)
for k in ("KEY00001", "KEY00002"):
_add_literature_note(vault, k)
result = _run_ocr_redo(vault, keys=["KEY00001", "KEY00002"])
assert result == 0
# Both papers should have meta.json
for k in ("KEY00001", "KEY00002"):
meta_path = vault / "System/PaperForge/ocr" / k / "meta.json"
assert meta_path.exists(), f"Meta for {k} should exist"
def test_redo_dry_run(self, tmp_path, monkeypatch) -> None:
"""Dry-run redo prints without modifying artifacts."""
from paperforge.commands.ocr import _run_ocr_redo
from tests.cli.test_ocr_progress_contracts import (
_make_minimal_vault,
_add_literature_note,
)
vault = _make_minimal_vault(tmp_path)
_add_literature_note(vault, "KEY00001")
import io as _io
import sys as _sys
captured = _io.StringIO()
old = _sys.stdout
_sys.stdout = captured
try:
result = _run_ocr_redo(vault, keys=["KEY00001"], dry_run=True)
finally:
_sys.stdout = old
output = captured.getvalue()
assert result == 0
assert "Would redo" in output
assert "dry-run" in output.lower()
# Dry-run must not create OCR directory
ocr_dir = vault / "System/PaperForge/ocr" / "KEY00001"
assert not ocr_dir.exists(), "Dry-run should not create OCR artifacts"
def test_redo_no_keys_falls_back(self, monkeypatch) -> None:
from paperforge.commands.ocr import _run_ocr_redo
call_kwargs = {}
def mock_redo(vault, **kwargs):
call_kwargs.update(kwargs)
call_kwargs["vault"] = vault
return 0
monkeypatch.setattr(
"paperforge.worker.ocr.ocr_redo_papers",
mock_redo,
)
result = _run_ocr_redo(Path("/fake"), keys=None, verbose=True)
assert result == 0
assert call_kwargs.get("vault") == Path("/fake")
assert call_kwargs.get("dry_run") is False
assert call_kwargs.get("verbose") is True
def test_redo_skips_nopdf(self, tmp_path, monkeypatch) -> None:
"""nopdf papers are guarded and reported as failed, not reset."""
from paperforge.commands.ocr import _run_ocr_redo
from tests.cli.test_ocr_progress_contracts import (
_make_minimal_vault,
_add_literature_note,
_mock_run_ocr,
)
_mock_run_ocr(monkeypatch)
monkeypatch.setattr("paperforge.worker.ocr.validate_ocr_meta",
lambda _p, _m: ("done", ""))
vault = _make_minimal_vault(tmp_path)
# Paper with nopdf status
_add_literature_note(vault, "NOPDF001")
nopdf_meta = vault / "System/PaperForge/ocr" / "NOPDF001" / "meta.json"
nopdf_meta.parent.mkdir(parents=True, exist_ok=True)
nopdf_meta.write_text(
json.dumps({"zotero_key": "NOPDF001", "ocr_status": "nopdf"}),
encoding="utf-8",
)
# Normal redoable paper
_add_literature_note(vault, "KEY00001")
result = _run_ocr_redo(vault, keys=["NOPDF001", "KEY00001"])
# Should be non-zero (nopdf counts as failed)
assert result != 0
# nopdf paper must be untouched
assert nopdf_meta.exists()
import json as _json
nopdf_reloaded = _json.loads(nopdf_meta.read_text(encoding="utf-8"))
assert nopdf_reloaded["ocr_status"] == "nopdf"
# Regular paper should have been processed
key1_meta = vault / "System/PaperForge/ocr" / "KEY00001" / "meta.json"
assert key1_meta.exists()
class TestRunOcrListDispatch:
"""Dispatch in run() passes manifest/keys to _run_ocr_list."""
def test_list_dispatch_manifest(self, monkeypatch) -> None:
import argparse
from paperforge.commands.ocr import run
call_kwargs = {}
def recording_fn(vault, **kwargs):
call_kwargs.update(kwargs)
call_kwargs["vault"] = vault
return 0
monkeypatch.setattr(
"paperforge.commands.ocr._run_ocr_list",
recording_fn,
)
ns = argparse.Namespace(
vault_path=Path("/fake"),
vault=None,
ocr_action="list",
json=False,
output=None,
manifest=True,
keys=None,
diagnose=False,
key=None,
live=False,
dry_run=False,
verbose=False,
no_progress=False,
)
result = run(ns)
assert result == 0
assert call_kwargs.get("manifest") is True
assert call_kwargs.get("vault") == Path("/fake")
# ===========================================================================
# OCRMaintenanceRow drift tri-state tests
# ===========================================================================
def test_maintenance_row_reports_unknown_when_machine_hash_missing(tmp_path: Path) -> None:
"""A row without machine baseline must report UNKNOWN drift."""
from paperforge.worker.ocr_maintenance import OCRMaintenanceRow
row = OCRMaintenanceRow(
key="U1",
title="Paper U",
title_full="Paper U",
status="done",
health="green",
version="v2",
finished_at="-",
rebuild_finished_at="-",
pages=1,
blocks=1,
figures=0,
tables=0,
model="PaddleOCR-VL-1.6",
fulltext_drift_state="UNKNOWN",
fulltext_drift_reason="No machine baseline is available.",
)
assert row.to_dict()["fulltext_drift_state"] == "UNKNOWN"