Initial commit
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.
This commit is contained in:
190
tests/test_errors.py
Normal file
190
tests/test_errors.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user