lllin000_PaperForge/tests/test_ocr_versions.py
LLLin000 2b23ad716f feat(#20): hash-based OCR change detection — two-tier mtime+xxhash staleness
Replace version-constant-only detection with two-tier content-hash
detection against blocks.structured.jsonl.

Tier 1 (stat): compare st_mtime + st_size of blocks.structured.jsonl
against stored values in meta.json. Microsecond skip for unchanged files.

Tier 2 (hash): compute xxhash of the file when stat changes. If the hash
matches stored structured_content_hash, update mtime/size only (false
alarm recovery). If it differs, mark paper for rebuild.

Changes:
- pyproject.toml: add xxhash>=3.4.0 dependency
- paperforge/worker/ocr_versions.py: add compute_structured_hash()
- paperforge/worker/ocr_rebuild.py: _apply_post_rebuild_version_flags()
  writes hash+mtime+size to meta.json when vault+key provided
- paperforge/commands/ocr.py: _needs_derived_rebuild() two-tier check
  before version-constant fallback
- New tests: hash compute (hex, determinism, missing file),
  stat skip, hash match (false alarm recovery), hash mismatch trigger

61 tests pass (14 existing + 3 hash rebuild + 4 hash compute + 40 other).
2026-07-09 17:09:28 +08:00

92 lines
3.3 KiB
Python

from __future__ import annotations
from unittest.mock import MagicMock
def test_version_state_distinguishes_raw_vs_derived_drift() -> None:
from paperforge.worker.ocr_versions import classify_version_state
state = classify_version_state(
meta={
"raw_version": {"ocr_model": "PaddleOCR-VL-1.5", "pdf_fingerprint": "sha256:a"},
"derived_version": {"renderer_version": "1.0.0-compat"},
},
expected_raw={
"ocr_model": "PaddleOCR-VL-1.6",
"pdf_fingerprint": "sha256:a",
},
expected_derived={
"renderer_version": "2.0.0",
},
)
assert state["raw_upgradable"] is True
assert state["derived_stale"] is True
class TestComputeStructuredHash:
"""Tests for compute_structured_hash."""
PATCH_PP = "paperforge.worker._utils.pipeline_paths"
PATCH_ART = "paperforge.worker.ocr_artifacts.artifact_paths_for_root"
def test_returns_hex_digest(self, tmp_path, mocker):
"""Hash of a known file returns a 64-bit hex string (16 chars)."""
from paperforge.worker.ocr_versions import compute_structured_hash
vault = tmp_path / "vault"
ocr_root = vault / "ocr"
paper_dir = ocr_root / "key"
blocks_path = paper_dir / "structure" / "blocks.structured.jsonl"
blocks_path.parent.mkdir(parents=True)
blocks_path.write_text('{"test": "content"}\n', encoding="utf-8")
mocker.patch(self.PATCH_PP, return_value={"ocr": str(ocr_root)})
mocker.patch(self.PATCH_ART, return_value=MagicMock(
blocks_structured=blocks_path,
))
result = compute_structured_hash(vault, "key")
assert result is not None
assert isinstance(result, str)
assert len(result) == 16
assert all(c in "0123456789abcdef" for c in result)
def test_returns_none_when_file_missing(self, tmp_path, mocker):
"""No blocks.structured.jsonl → None."""
from paperforge.worker.ocr_versions import compute_structured_hash
vault = tmp_path / "vault"
ocr_root = vault / "ocr"
paper_dir = ocr_root / "key"
blocks_path = paper_dir / "structure" / "blocks.structured.jsonl"
blocks_path.parent.mkdir(parents=True)
# Do NOT create the file
mocker.patch(self.PATCH_PP, return_value={"ocr": str(ocr_root)})
mocker.patch(self.PATCH_ART, return_value=MagicMock(
blocks_structured=blocks_path,
))
assert compute_structured_hash(vault, "key") is None
def test_deterministic(self, tmp_path, mocker):
"""Same content produces the same hash."""
from paperforge.worker.ocr_versions import compute_structured_hash
vault = tmp_path / "vault"
ocr_root = vault / "ocr"
paper_dir = ocr_root / "key"
blocks_path = paper_dir / "structure" / "blocks.structured.jsonl"
blocks_path.parent.mkdir(parents=True)
blocks_path.write_text('{"stable": "data"}\n', encoding="utf-8")
mocker.patch(self.PATCH_PP, return_value={"ocr": str(ocr_root)})
mocker.patch(self.PATCH_ART, return_value=MagicMock(
blocks_structured=blocks_path,
))
h1 = compute_structured_hash(vault, "key")
h2 = compute_structured_hash(vault, "key")
assert h1 == h2
assert h1 is not None