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