- IntakeSession.observe_frame now catches ValueError from crop_to_book (empty-platform capture), cleans up any orphan wide-image file/dir, resets the strategy, and does not advance session state. The error is surfaced via a new last_error field. - /api/observe adds an "error" field to the response when a capture fires but fails, instead of an unhandled 500. - observe_frame only reports fired=True (and only runs capture logic) when state is WAITING_FRONT or WAITING_BACK, so a completed book left on the platform can no longer produce a no-op "fired" capture from an auto-strategy re-triggering. - observe_frame now holds a per-session threading.Lock for its whole body, since the frontend polls /api/observe every ~500ms without chaining requests and Catalogue.update_row() does a non-atomic read-all-then-rewrite-whole-file rewrite of catalogue.csv. Adds tests for empty-platform front/back captures and the BOOK_COMPLETE no-op-fire case, plus an API-level empty-platform test.
154 lines
5.5 KiB
Python
154 lines
5.5 KiB
Python
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from bookmark.capture_strategies.manual_button import ManualButtonStrategy
|
|
from bookmark.catalogue import Catalogue
|
|
from bookmark.session import IntakeSession, IntakeState
|
|
from bookmark.sizing import MarkerRegion
|
|
|
|
|
|
def make_reference(size=100, color=200):
|
|
return np.full((size, size, 3), color, dtype=np.uint8)
|
|
|
|
|
|
def make_book_frame(size=100, ref_color=200, book_color=50, box=(20, 20, 40, 40)):
|
|
frame = np.full((size, size, 3), ref_color, dtype=np.uint8)
|
|
x, y, w, h = box
|
|
frame[y:y + h, x:x + w] = book_color
|
|
return frame
|
|
|
|
|
|
@pytest.fixture
|
|
def session(tmp_path):
|
|
catalogue = Catalogue(tmp_path / "catalogue.csv")
|
|
strategy = ManualButtonStrategy()
|
|
reference = make_reference()
|
|
markers = [
|
|
MarkerRegion(name="small_boundary", bucket="small", x=0, y=0, width=10, height=10),
|
|
MarkerRegion(name="medium_boundary", bucket="medium", x=90, y=0, width=10, height=10),
|
|
MarkerRegion(name="large_boundary", bucket="large", x=0, y=90, width=10, height=10),
|
|
]
|
|
return IntakeSession(
|
|
catalogue=catalogue,
|
|
strategy=strategy,
|
|
reference_frame=reference,
|
|
markers=markers,
|
|
captures_dir=tmp_path / "captures",
|
|
)
|
|
|
|
|
|
def test_session_starts_waiting_for_front(session):
|
|
assert session.state == IntakeState.WAITING_FRONT
|
|
|
|
|
|
def test_front_capture_advances_to_waiting_back_and_writes_catalogue_row(session):
|
|
session.strategy.trigger()
|
|
fired = session.observe_frame(make_book_frame())
|
|
|
|
assert fired is True
|
|
assert session.state == IntakeState.WAITING_BACK
|
|
assert session.current_book_id is not None
|
|
|
|
rows = session.catalogue.read_all()
|
|
assert len(rows) == 1
|
|
assert rows[0]["book_id"] == session.current_book_id
|
|
assert rows[0]["status"] == "captured"
|
|
assert Path(rows[0]["front_wide_path"]).exists()
|
|
assert Path(rows[0]["front_cropped_path"]).exists()
|
|
|
|
|
|
def test_back_capture_completes_book_and_updates_row(session):
|
|
session.strategy.trigger()
|
|
session.observe_frame(make_book_frame())
|
|
book_id = session.current_book_id
|
|
|
|
session.strategy.trigger()
|
|
fired = session.observe_frame(make_book_frame())
|
|
|
|
assert fired is True
|
|
assert session.state == IntakeState.BOOK_COMPLETE
|
|
rows = session.catalogue.read_all()
|
|
assert rows[0]["book_id"] == book_id
|
|
assert Path(rows[0]["back_wide_path"]).exists()
|
|
assert Path(rows[0]["back_cropped_path"]).exists()
|
|
|
|
|
|
def test_start_next_book_resets_state_and_assigns_new_id(session):
|
|
session.strategy.trigger()
|
|
session.observe_frame(make_book_frame())
|
|
session.strategy.trigger()
|
|
session.observe_frame(make_book_frame())
|
|
first_book_id = session.current_book_id
|
|
|
|
session.start_next_book()
|
|
assert session.state == IntakeState.WAITING_FRONT
|
|
assert session.current_book_id is None
|
|
|
|
session.strategy.trigger()
|
|
session.observe_frame(make_book_frame())
|
|
assert session.current_book_id != first_book_id
|
|
|
|
|
|
def test_start_next_book_raises_if_book_not_complete(session):
|
|
with pytest.raises(RuntimeError):
|
|
session.start_next_book()
|
|
|
|
|
|
def test_empty_platform_capture_does_not_corrupt_state_or_files(session):
|
|
"""Feeding the unchanged reference frame (nothing on the platform) should not
|
|
crash, should not create a catalogue row, should not leave an orphan file on
|
|
disk, should leave the strategy un-armed, and should not advance state."""
|
|
session.strategy.trigger()
|
|
|
|
fired = session.observe_frame(session.reference_frame)
|
|
|
|
assert fired is False
|
|
assert session.last_error is not None
|
|
assert session.state == IntakeState.WAITING_FRONT
|
|
assert session.current_book_id is None
|
|
assert session.catalogue.read_all() == []
|
|
assert session.strategy.observe(make_book_frame()) is False # not left armed
|
|
assert not (session.captures_dir).exists() or not any(session.captures_dir.rglob("*.jpg"))
|
|
|
|
|
|
def test_empty_platform_capture_on_back_step_does_not_advance_or_corrupt(session):
|
|
"""Same failure mode, but triggered on the back-capture step: the front row
|
|
must survive untouched, and state must stay at WAITING_BACK."""
|
|
session.strategy.trigger()
|
|
session.observe_frame(make_book_frame())
|
|
book_id = session.current_book_id
|
|
assert session.state == IntakeState.WAITING_BACK
|
|
|
|
session.strategy.trigger()
|
|
fired = session.observe_frame(session.reference_frame)
|
|
|
|
assert fired is False
|
|
assert session.last_error is not None
|
|
assert session.state == IntakeState.WAITING_BACK
|
|
assert session.current_book_id == book_id
|
|
rows = session.catalogue.read_all()
|
|
assert len(rows) == 1
|
|
assert rows[0]["back_wide_path"] == ""
|
|
assert rows[0]["back_cropped_path"] == ""
|
|
back_files = list((session.captures_dir / book_id).glob("back_*.jpg"))
|
|
assert back_files == []
|
|
|
|
|
|
def test_book_complete_no_op_fire_does_not_return_true(session):
|
|
"""A strategy re-firing while the session is already BOOK_COMPLETE (e.g. an
|
|
auto-strategy re-satisfying stability while a finished book sits on the
|
|
platform) must not report a capture and must not stay armed."""
|
|
session.strategy.trigger()
|
|
session.observe_frame(make_book_frame())
|
|
session.strategy.trigger()
|
|
session.observe_frame(make_book_frame())
|
|
assert session.state == IntakeState.BOOK_COMPLETE
|
|
|
|
session.strategy.trigger()
|
|
fired = session.observe_frame(make_book_frame())
|
|
|
|
assert fired is False
|
|
assert session.state == IntakeState.BOOK_COMPLETE
|
|
assert session.strategy.observe(make_book_frame()) is False # reset, not left armed
|