Photo-based book cataloger with AI identification. Room → Cabinet → Shelf → Book hierarchy; FastAPI + SQLite backend; vanilla JS SPA; OpenAI-compatible plugin system for boundary detection, text recognition, and archive search.
191 lines
6.8 KiB
Python
191 lines
6.8 KiB
Python
"""Tests for config and image error conditions, and exception attribute contracts."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from errors import (
|
|
ConfigFileError,
|
|
ConfigNotLoadedError,
|
|
ConfigValidationError,
|
|
ImageFileNotFoundError,
|
|
ImageReadError,
|
|
)
|
|
from logic.images import crop_save, prep_img_b64, serve_crop
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_png(tmp_path: Path, filename: str = "img.png") -> Path:
|
|
"""Write a minimal 4x4 red PNG to tmp_path and return its path."""
|
|
from PIL import Image
|
|
|
|
path = tmp_path / filename
|
|
img = Image.new("RGB", (4, 4), color=(255, 0, 0))
|
|
img.save(path, format="PNG")
|
|
return path
|
|
|
|
|
|
def _make_corrupt(tmp_path: Path, filename: str = "bad.jpg") -> Path:
|
|
"""Write a file with invalid image bytes and return its path."""
|
|
path = tmp_path / filename
|
|
path.write_bytes(b"this is not an image\xff\xfe")
|
|
return path
|
|
|
|
|
|
# ── ImageFileNotFoundError ────────────────────────────────────────────────────
|
|
|
|
|
|
def test_prep_img_b64_file_not_found(tmp_path: Path) -> None:
|
|
missing = tmp_path / "missing.png"
|
|
with pytest.raises(ImageFileNotFoundError) as exc_info:
|
|
prep_img_b64(missing)
|
|
assert exc_info.value.path == missing
|
|
assert str(missing) in str(exc_info.value)
|
|
|
|
|
|
def test_crop_save_file_not_found(tmp_path: Path) -> None:
|
|
missing = tmp_path / "missing.png"
|
|
with pytest.raises(ImageFileNotFoundError) as exc_info:
|
|
crop_save(missing, 0, 0, 2, 2)
|
|
assert exc_info.value.path == missing
|
|
|
|
|
|
def test_serve_crop_file_not_found(tmp_path: Path) -> None:
|
|
missing = tmp_path / "missing.png"
|
|
with pytest.raises(ImageFileNotFoundError) as exc_info:
|
|
serve_crop(missing, None)
|
|
assert exc_info.value.path == missing
|
|
|
|
|
|
# ── ImageReadError ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_prep_img_b64_corrupt_file(tmp_path: Path) -> None:
|
|
bad = _make_corrupt(tmp_path)
|
|
with pytest.raises(ImageReadError) as exc_info:
|
|
prep_img_b64(bad)
|
|
assert exc_info.value.path == bad
|
|
assert str(bad) in str(exc_info.value)
|
|
assert exc_info.value.reason # non-empty reason
|
|
|
|
|
|
def test_crop_save_corrupt_file(tmp_path: Path) -> None:
|
|
bad = _make_corrupt(tmp_path)
|
|
with pytest.raises(ImageReadError) as exc_info:
|
|
crop_save(bad, 0, 0, 2, 2)
|
|
assert exc_info.value.path == bad
|
|
|
|
|
|
def test_serve_crop_corrupt_file(tmp_path: Path) -> None:
|
|
bad = _make_corrupt(tmp_path)
|
|
with pytest.raises(ImageReadError) as exc_info:
|
|
serve_crop(bad, None)
|
|
assert exc_info.value.path == bad
|
|
|
|
|
|
# ── prep_img_b64 success path ─────────────────────────────────────────────────
|
|
|
|
|
|
def test_prep_img_b64_success(tmp_path: Path) -> None:
|
|
path = _make_png(tmp_path)
|
|
b64, mime = prep_img_b64(path)
|
|
assert mime == "image/png"
|
|
assert len(b64) > 0
|
|
|
|
|
|
def test_prep_img_b64_with_crop(tmp_path: Path) -> None:
|
|
path = _make_png(tmp_path)
|
|
b64, mime = prep_img_b64(path, crop_frac=(0.0, 0.0, 0.5, 0.5))
|
|
assert mime == "image/png"
|
|
assert len(b64) > 0
|
|
|
|
|
|
# ── Config exception attribute contracts ──────────────────────────────────────
|
|
|
|
|
|
def test_config_not_loaded_error() -> None:
|
|
exc = ConfigNotLoadedError()
|
|
assert "load_config" in str(exc)
|
|
|
|
|
|
def test_config_file_error() -> None:
|
|
path = Path("config/missing.yaml")
|
|
exc = ConfigFileError(path, "file not found")
|
|
assert exc.path == path
|
|
assert exc.reason == "file not found"
|
|
assert "missing.yaml" in str(exc)
|
|
assert "file not found" in str(exc)
|
|
|
|
|
|
def test_config_validation_error() -> None:
|
|
exc = ConfigValidationError("unexpected field 'foo'")
|
|
assert exc.reason == "unexpected field 'foo'"
|
|
assert "unexpected field" in str(exc)
|
|
|
|
|
|
# ── Config loading errors ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_load_config_raises_on_invalid_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import config as config_module
|
|
|
|
cfg_dir = tmp_path / "config"
|
|
cfg_dir.mkdir()
|
|
(cfg_dir / "credentials.default.yaml").write_text(": invalid: yaml: {\n")
|
|
# write empty valid files for other categories
|
|
for cat in ["models", "functions", "ui"]:
|
|
(cfg_dir / f"{cat}.default.yaml").write_text(f"{cat}: {{}}\n")
|
|
|
|
monkeypatch.setattr(config_module, "_CONFIG_DIR", cfg_dir)
|
|
with pytest.raises(ConfigFileError) as exc_info:
|
|
config_module.load_config()
|
|
assert exc_info.value.path == cfg_dir / "credentials.default.yaml"
|
|
assert exc_info.value.reason
|
|
|
|
|
|
def test_load_config_raises_on_schema_mismatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import config as config_module
|
|
|
|
cfg_dir = tmp_path / "config"
|
|
cfg_dir.mkdir()
|
|
# credentials expects CredentialConfig but we give it a non-dict value
|
|
(cfg_dir / "credentials.default.yaml").write_text("credentials:\n openrouter: not_a_dict\n")
|
|
for cat in ["models", "functions", "ui"]:
|
|
(cfg_dir / f"{cat}.default.yaml").write_text("")
|
|
|
|
monkeypatch.setattr(config_module, "_CONFIG_DIR", cfg_dir)
|
|
with pytest.raises(ConfigValidationError) as exc_info:
|
|
config_module.load_config()
|
|
assert exc_info.value.reason
|
|
|
|
|
|
def test_get_config_raises_if_not_loaded(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import config as config_module
|
|
|
|
# Clear the holder to simulate unloaded state
|
|
original = list(config_module.config_holder)
|
|
config_module.config_holder.clear()
|
|
try:
|
|
with pytest.raises(ConfigNotLoadedError):
|
|
config_module.get_config()
|
|
finally:
|
|
config_module.config_holder.extend(original)
|
|
|
|
|
|
# ── Image exception string representation ─────────────────────────────────────
|
|
|
|
|
|
def test_image_file_not_found_str() -> None:
|
|
exc = ImageFileNotFoundError(Path("/data/images/img.jpg"))
|
|
assert exc.path == Path("/data/images/img.jpg")
|
|
assert "img.jpg" in str(exc)
|
|
|
|
|
|
def test_image_read_error_str() -> None:
|
|
exc = ImageReadError(Path("/data/images/img.jpg"), "cannot identify image file")
|
|
assert exc.path == Path("/data/images/img.jpg")
|
|
assert exc.reason == "cannot identify image file"
|
|
assert "img.jpg" in str(exc)
|
|
assert "cannot identify image file" in str(exc)
|