diff --git a/examples/capture.db b/examples/capture.db deleted file mode 100644 index e69de29..0000000 diff --git a/src/openadapt_viewer/recording_db.py b/src/openadapt_viewer/recording_db.py new file mode 100644 index 0000000..699c20f --- /dev/null +++ b/src/openadapt_viewer/recording_db.py @@ -0,0 +1,220 @@ +"""Reading the recording database that openadapt-capture writes. + +The recorder writes one SQLAlchemy database per capture, at +``/recording.db``. Its tables are ``recording``, ``action_event``, +``screenshot``, ``window_event``, ``browser_event``, ``audio_info``, +``performance_stat`` and ``memory_stat``. The columns read here are listed +beside each query below. + +This module is the one place in the package that knows the file layout and the +column names. The catalog scanner and the benchmark loader both read through +it, so a schema change is a change to one reader rather than two that drift +apart. + +It reads the database with ``sqlite3`` rather than through +``openadapt_capture.CaptureSession.load``. Both callers need a few scalars and +two row counts, and installing ``openadapt-capture`` to get them would pull +mss, sounddevice, soundfile, matplotlib, sqlalchemy, alembic, numpy and the +platform accessibility stack into an HTML generator. It is therefore not a +dependency of this package. Anything that needs decoded frames or replayable +actions should import ``CaptureSession`` rather than extend this module. + +It does not read the pre-2026-07-17 ``capture.db``. That format held a single +``capture`` row and a generic ``events`` table; openadapt-capture PR #28 +replaced it, and current code cannot load it at all. openadapt-capture owns +that format and ships ``scripts/migrate_legacy_capture.py`` to convert it, so a +legacy directory is reported with the conversion command rather than translated +a second time here. +""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from pathlib import Path + +#: Filename the current recorder writes, one per capture directory. +RECORDING_DB_NAME = "recording.db" + +#: Filename the pre-2026-07-17 recorder wrote. Detected, never read. +LEGACY_DB_NAME = "capture.db" + +#: Printed or raised when a directory holds only a legacy capture. +LEGACY_HINT = ( + "holds the legacy {legacy} format, which the current viewer cannot read. " + "Convert it with openadapt-capture: " + "python scripts/migrate_legacy_capture.py " +) + +#: Event tables whose timestamps bound the recording's duration. +_TIMESTAMPED_TABLES = ("screenshot", "action_event", "window_event", "browser_event") + + +class LegacyCaptureError(FileNotFoundError): + """Raised for a directory holding a pre-#28 ``capture.db`` and nothing else. + + A subclass of ``FileNotFoundError`` because that is what the absence of a + readable ``recording.db`` is, and because callers that only want to report + "this directory is not loadable" need no new except clause. + """ + + +@dataclass(frozen=True) +class RecordingMetadata: + """The recording-level facts both viewers need, read from one database. + + Field names follow this package's vocabulary rather than the recorder's: + the catalog has always called the display size ``screen_width`` and + ``screen_height``, where the recorder's columns are ``monitor_width`` and + ``monitor_height``. + """ + + #: Epoch seconds at which recording started, the ``recording.timestamp``. + started_at: float + #: Epoch seconds of the newest observation, or None when there is none. + ended_at: float | None + platform: str | None + screen_width: int | None + screen_height: int | None + pixel_ratio: float | None + task_description: str | None + #: Rows in ``action_event``: one mouse or key event each. + event_count: int + #: Rows in ``screenshot``: frames are PNG blobs in the database now. + frame_count: int + + @property + def duration_seconds(self) -> float | None: + """Seconds from the start of the recording to its last observation. + + Returns: + The duration, or None when no event bounds it. + """ + if self.ended_at is None: + return None + return float(self.ended_at) - float(self.started_at) + + +def is_legacy_capture(recording_dir: Path) -> bool: + """Report whether a directory holds a legacy capture and no current one. + + Args: + recording_dir: A capture directory. + + Returns: + True if only the pre-#28 ``capture.db`` is present. + """ + recording_dir = Path(recording_dir) + return (recording_dir / LEGACY_DB_NAME).is_file() and not ( + recording_dir / RECORDING_DB_NAME + ).is_file() + + +def find_recording_db(recording_dir: Path) -> Path: + """Return the path to a capture directory's recording database. + + Args: + recording_dir: A capture directory. + + Returns: + The path to its ``recording.db``. + + Raises: + LegacyCaptureError: If the directory holds only a legacy ``capture.db``. + The message carries the conversion command. + FileNotFoundError: If the directory holds neither database. + """ + recording_dir = Path(recording_dir) + recording_db = recording_dir / RECORDING_DB_NAME + + # is_file(), not exists(): sqlite3.connect CREATES an empty database at any + # path it is handed, so an unchecked connect turns "this directory has no + # recording" into a zero-byte recording.db left behind in the user's + # capture directory. + if recording_db.is_file(): + return recording_db + + if is_legacy_capture(recording_dir): + raise LegacyCaptureError( + f"{recording_dir} " + LEGACY_HINT.format(legacy=LEGACY_DB_NAME) + ) + raise FileNotFoundError(f"No {RECORDING_DB_NAME} in {recording_dir}") + + +def read_recording_metadata(recording_dir: Path) -> RecordingMetadata: + """Read one capture directory's recording-level metadata. + + Args: + recording_dir: A capture directory holding a ``recording.db``. + + Returns: + The metadata the database states. + + Raises: + LegacyCaptureError: If the directory holds only a legacy ``capture.db``. + FileNotFoundError: If the directory holds neither database. + sqlite3.Error: If the database is corrupt or missing a table. + IndexError: If a queried column is absent, which sqlite3.Row raises. + """ + recording_db = find_recording_db(recording_dir) + + with sqlite3.connect(str(recording_db)) as conn: + conn.row_factory = sqlite3.Row + + # One row per capture directory, matching CaptureSession.load's + # `session.query(Recording).first()`. Columns: timestamp (epoch seconds + # at record start), monitor_width, monitor_height, pixel_ratio, + # platform, task_description. + row = conn.execute("SELECT * FROM recording ORDER BY id LIMIT 1").fetchone() + if row is None: + raise sqlite3.DatabaseError(f"recording table is empty in {recording_db}") + + if row["timestamp"] is None: + raise sqlite3.DatabaseError(f"recording.timestamp is NULL in {recording_db}") + + started_at = float(row["timestamp"]) + # An observation older than the recording's own start cannot bound its + # end, and reporting one would produce a negative duration. + ended_at = _last_observation(conn) + if ended_at is not None and ended_at < started_at: + ended_at = None + + return RecordingMetadata( + started_at=started_at, + ended_at=ended_at, + platform=row["platform"], + screen_width=row["monitor_width"], + screen_height=row["monitor_height"], + pixel_ratio=row["pixel_ratio"], + task_description=row["task_description"], + event_count=conn.execute("SELECT COUNT(*) FROM action_event").fetchone()[0], + frame_count=conn.execute("SELECT COUNT(*) FROM screenshot").fetchone()[0], + ) + + +def _last_observation(conn: sqlite3.Connection) -> float | None: + """Return the newest event timestamp in a recording database. + + There is no ``ended_at`` column. The recording ends at its last + observation, so the end is the newest timestamp across the event tables + that carry one. + + Args: + conn: An open connection to a recording.db. + + Returns: + The newest timestamp in epoch seconds, or None if no event carries one. + """ + latest = None + + for table in _TIMESTAMPED_TABLES: + try: + value = conn.execute(f"SELECT MAX(timestamp) FROM {table}").fetchone()[0] + except sqlite3.Error: + # An optional table this build does not have. Other tables can + # still bound the duration. + continue + if value is not None and (latest is None or value > latest): + latest = value + + return None if latest is None else float(latest) diff --git a/src/openadapt_viewer/scanner.py b/src/openadapt_viewer/scanner.py index 3fa2e5f..a5de1dc 100644 --- a/src/openadapt_viewer/scanner.py +++ b/src/openadapt_viewer/scanner.py @@ -6,26 +6,11 @@ - Segmentation results from openadapt-ml (JSON files with episodes) - Episode data for indexing -The recorder writes one SQLAlchemy database per capture, at -``/recording.db``. Its tables are ``recording``, ``action_event``, -``screenshot``, ``window_event``, ``browser_event``, ``audio_info``, -``performance_stat`` and ``memory_stat``. The columns this module reads are -listed beside each query below. - -Two notes on what this module deliberately does not do. - -It reads the database with ``sqlite3`` rather than through -``openadapt_capture.CaptureSession.load``. Indexing needs two row counts and a -handful of scalars, and installing ``openadapt-capture`` to get them would -pull mss, sounddevice, soundfile, matplotlib, sqlalchemy, alembic, numpy and -the platform accessibility stack into an HTML generator. It is therefore not -a dependency of this package. Anything that needs decoded frames or replayable -actions should import ``CaptureSession`` rather than extend this module. - -It does not read the pre-2026-07-17 ``capture.db``. That format held a single -``capture`` row and a generic ``events`` table; openadapt-capture PR #28 -replaced it, and current code cannot load it at all. A legacy directory is -reported with the command that converts it rather than skipped in silence. +Every read of a recording database goes through +:mod:`openadapt_viewer.recording_db`, which owns the file layout, the column +names and the refusal to read the pre-2026-07-17 ``capture.db``. This module +decides what to do with what that reader returns: index it, or name the +directory in a warning and move on. """ import json @@ -34,23 +19,14 @@ from pathlib import Path from .catalog import Recording, RecordingCatalog, SegmentationResult - -#: Filename the current recorder writes, one per capture directory. -RECORDING_DB_NAME = "recording.db" - -#: Filename the pre-2026-07-17 recorder wrote. Detected, never read. -LEGACY_DB_NAME = "capture.db" - -#: Printed when a directory holds only a legacy capture. -LEGACY_HINT = ( - "holds the legacy {legacy} format, which the current viewer cannot read. " - "Convert it with openadapt-capture: " - "python scripts/migrate_legacy_capture.py " +from .recording_db import ( + LEGACY_DB_NAME, + LEGACY_HINT, + RECORDING_DB_NAME, + is_legacy_capture, + read_recording_metadata, ) -#: Event tables whose timestamps bound the recording's duration. -_TIMESTAMPED_TABLES = ("screenshot", "action_event", "window_event", "browser_event") - class RecordingScanner: """Scanner for discovering and indexing OpenAdapt data.""" @@ -93,7 +69,7 @@ def scan_recording_directory( prefix = "**/" if recursive else "*/" for legacy_db in base_path.glob(f"{prefix}{LEGACY_DB_NAME}"): - if (legacy_db.parent / RECORDING_DB_NAME).exists(): + if not is_legacy_capture(legacy_db.parent): # Already converted in place; the current database wins. continue print(f"Warning: {legacy_db.parent} " + LEGACY_HINT.format(legacy=LEGACY_DB_NAME)) @@ -148,18 +124,8 @@ def _extract_recording_info( capture.db is named as such, with the conversion command. """ recording_dir = Path(recording_dir) - recording_db = recording_dir / RECORDING_DB_NAME screenshots_dir = recording_dir / "screenshots" - if not recording_db.is_file(): - # Returning a hollow Recording here is what made a legacy directory - # look indexable: no date, no duration, no counts, no complaint. - if (recording_dir / LEGACY_DB_NAME).is_file(): - raise FileNotFoundError( - f"{recording_dir} " + LEGACY_HINT.format(legacy=LEGACY_DB_NAME) - ) - raise FileNotFoundError(f"No {RECORDING_DB_NAME} in {recording_dir}") - metadata = {} created_at = None duration_seconds = None @@ -167,56 +133,32 @@ def _extract_recording_info( event_count = None frame_count = None + # A FileNotFoundError from the reader is deliberately not caught. It + # means the directory holds no recording.db, and for a legacy capture + # its message carries the conversion command. Returning a hollow + # Recording instead is what made a legacy directory look indexable: no + # date, no duration, no counts, no complaint. try: - with sqlite3.connect(str(recording_db)) as conn: - conn.row_factory = sqlite3.Row - - # One row per capture directory, matching CaptureSession.load's - # `session.query(Recording).first()`. Columns: timestamp (epoch - # seconds at record start), monitor_width, monitor_height, - # pixel_ratio, platform, task_description. - row = conn.execute( - "SELECT * FROM recording ORDER BY id LIMIT 1" - ).fetchone() - - if row is None: - raise sqlite3.DatabaseError("recording table is empty") - - created_at = row["timestamp"] - task_description = row["task_description"] - metadata.update({ - "platform": row["platform"], - # Catalog vocabulary, not the recorder's: these keys are - # what the catalog has always stored. The recorder calls - # them monitor_width and monitor_height. - "screen_width": row["monitor_width"], - "screen_height": row["monitor_height"], - "pixel_ratio": row["pixel_ratio"], - }) - - # An action_event row is one mouse or key event, which is what - # the legacy `events` table counted. - event_count = conn.execute( - "SELECT COUNT(*) FROM action_event" - ).fetchone()[0] - - # Frames are rows now, not files: the recorder stores each PNG - # as a blob in `screenshot` rather than under screenshots/. - frame_count = conn.execute( - "SELECT COUNT(*) FROM screenshot" - ).fetchone()[0] - - # There is no ended_at column. The recording ends at its last - # observation, so take the newest timestamp across the event - # tables that carry one. - if created_at is not None: - duration_seconds = self._duration_from_events(conn, created_at) - except (sqlite3.Error, IndexError, TypeError) as e: + read = read_recording_metadata(recording_dir) + except (sqlite3.Error, IndexError, TypeError, ValueError) as e: # sqlite3.Error covers a corrupt database and a missing table; - # IndexError is sqlite3.Row's "no such column"; TypeError is a NULL - # in an arithmetic column. Each means "no event metadata", not - # "abort", so the directory is still indexed by name and mtime. - print(f"Warning: Could not read {recording_db}: {e}") + # IndexError is sqlite3.Row's "no such column"; TypeError and + # ValueError are a NULL or a non-numeric value in a column read as + # a number. Each means "no event metadata", not "abort", so the + # directory is still indexed by name and mtime. + print(f"Warning: Could not read {recording_dir / RECORDING_DB_NAME}: {e}") + else: + created_at = read.started_at + duration_seconds = read.duration_seconds + task_description = read.task_description + event_count = read.event_count + frame_count = read.frame_count + metadata.update({ + "platform": read.platform, + "screen_width": read.screen_width, + "screen_height": read.screen_height, + "pixel_ratio": read.pixel_ratio, + }) # Some capture directories still carry PNGs under screenshots/. Read # them only when the database could not answer, so a readable database @@ -239,35 +181,6 @@ def _extract_recording_info( metadata=metadata, ) - @staticmethod - def _duration_from_events(conn: sqlite3.Connection, started_at: float) -> float | None: - """ - Return seconds from the recording start to its newest observation. - - Args: - conn: Open connection to a recording.db - started_at: The recording row's `timestamp`, in epoch seconds - - Returns: - Duration in seconds, or None if the database holds no timestamped - event at or after the start. - """ - latest = None - - for table in _TIMESTAMPED_TABLES: - try: - value = conn.execute(f"SELECT MAX(timestamp) FROM {table}").fetchone()[0] - except sqlite3.Error: - # An optional table this build does not have. Other tables can - # still bound the duration. - continue - if value is not None and (latest is None or value > latest): - latest = value - - if latest is None or latest < started_at: - return None - return float(latest) - float(started_at) - def scan_segmentation_results( self, segmentation_dir: str diff --git a/src/openadapt_viewer/viewers/benchmark/real_data_loader.py b/src/openadapt_viewer/viewers/benchmark/real_data_loader.py index 7586c1f..24c541e 100644 --- a/src/openadapt_viewer/viewers/benchmark/real_data_loader.py +++ b/src/openadapt_viewer/viewers/benchmark/real_data_loader.py @@ -13,11 +13,24 @@ Note the two variables are different. ``$OPENADAPT_CAPTURE_DIR`` names the openadapt-capture checkout, which holds many recordings; the screenshot scripts use it. ``$OPENADAPT_CAPTURE_RECORDING`` names one recording directory inside it. + +A loadable recording directory holds two files. + +* ``recording.db``, written by openadapt-capture. It is read through + :mod:`openadapt_viewer.recording_db`, which is the one reader in this package + that knows the schema, and which refuses the pre-2026-07-17 ``capture.db`` + with the command that converts it. +* ``episodes.json``, written by openadapt-ml's segmentation pipeline. It + supplies every task and step below; ``recording.db`` supplies only the + recording-level frame the episodes are placed in. + +Key frame paths in ``episodes.json`` name PNG files. openadapt-capture stores +frames as ``png_data`` blobs in the ``screenshot`` table, so those paths resolve +only for a directory that also carries the images as files. """ import json import os -import sqlite3 from datetime import datetime from pathlib import Path, PurePosixPath @@ -27,6 +40,7 @@ ExecutionStep, TaskExecution, ) +from openadapt_viewer.recording_db import RECORDING_DB_NAME, read_recording_metadata #: Environment variable naming one recording directory to load by default. CAPTURE_RECORDING_ENV = "OPENADAPT_CAPTURE_RECORDING" @@ -87,7 +101,10 @@ def load_real_capture_data( Raises: FileNotFoundError: If no capture directory was named, or if the named - directory or its required files don't exist. + directory or its required files don't exist. A directory holding + the pre-#28 capture.db raises + :class:`openadapt_viewer.recording_db.LegacyCaptureError`, whose + message names the conversion command. """ if capture_path is None: capture_path = default_capture_path() @@ -96,7 +113,7 @@ def load_real_capture_data( raise FileNotFoundError( "No capture directory given. Pass capture_path, or set " f"${CAPTURE_RECORDING_ENV} to a directory holding a recording " - "(episodes.json plus capture.db)." + f"(episodes.json plus {RECORDING_DB_NAME})." ) capture_path = Path(capture_path) @@ -104,7 +121,12 @@ def load_real_capture_data( if not capture_path.exists(): raise FileNotFoundError(f"Capture directory not found: {capture_path}") - # Load episodes.json + # The database is read first because it decides whether this directory is a + # recording this viewer can read at all. A legacy capture reports the + # conversion command rather than an absent episodes.json, which would send + # the user to look for the wrong missing file. + capture_meta = read_recording_metadata(capture_path) + episodes_path = capture_path / "episodes.json" if not episodes_path.exists(): raise FileNotFoundError(f"Episodes file not found: {episodes_path}") @@ -112,26 +134,10 @@ def load_real_capture_data( with open(episodes_path) as f: episodes_data = json.load(f) - # Load capture.db metadata - db_path = capture_path / "capture.db" - if not db_path.exists(): - raise FileNotFoundError(f"Capture database not found: {db_path}") - - # Connect to database - conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - # Get capture metadata - cursor.execute("SELECT * FROM capture LIMIT 1") - capture_meta = cursor.fetchone() - - if capture_meta is None: - raise ValueError(f"No capture metadata found in {db_path}") - - # Calculate timing - started_at = capture_meta["started_at"] - ended_at = capture_meta["ended_at"] or started_at + started_at = capture_meta.started_at + # There is no end column. A recording ends at its last observation, and one + # that recorded nothing ends where it started. + ended_at = capture_meta.ended_at if capture_meta.ended_at is not None else started_at duration = ended_at - started_at # Get recording name @@ -219,8 +225,6 @@ def load_real_capture_data( ) executions.append(execution) - conn.close() - # Create benchmark run if run_id is None: run_id = f"real_capture_{recording_id}" @@ -239,8 +243,15 @@ def load_real_capture_data( "recording_name": recording_name, "capture_path": str(capture_path), "duration": duration, - "platform": capture_meta["platform"], - "screen_size": f"{capture_meta['screen_width']}x{capture_meta['screen_height']}", + "platform": capture_meta.platform, + "screen_size": f"{capture_meta.screen_width}x{capture_meta.screen_height}", + # Counted from the database, not from the episodes. They are what a + # reader checks the loaded run against: a recording that holds 20 + # frames and 20 events has to report 20 and 20 whatever the + # segmentation says about it. + "frame_count": capture_meta.frame_count, + "event_count": capture_meta.event_count, + "task_description": capture_meta.task_description, "episode_count": len(episodes), "llm_model": episodes_data.get("llm_model", "unknown"), "processing_timestamp": episodes_data.get("processing_timestamp", "unknown"), diff --git a/tests/capture_examples.py b/tests/capture_examples.py new file mode 100644 index 0000000..f740050 --- /dev/null +++ b/tests/capture_examples.py @@ -0,0 +1,72 @@ +"""Locating the recordings openadapt-capture commits, for tests to read. + +openadapt-capture commits two recordings under ``examples/captures``, +regenerated and byte-compared by its own CI. Tests that exercise a reader of +the recording format read those, rather than a database written here to match +this repository's idea of the schema. A test that builds its own input can only +prove the reader agrees with the test author, which is how the format drifted +unnoticed for six weeks. + +Two readers depend on this now -- the catalog scanner and the benchmark +loader -- so the lookup lives here rather than in either test module. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent + +#: Set by CI to the checked-out openadapt-capture's examples/captures. +EXAMPLES_ENV = "OPENADAPT_CAPTURE_EXAMPLES" + +#: The recordings openadapt-capture commits, with what its specs declare. +REAL_RECORDINGS = ("demo_new", "turn-off-nightshift") + + +def examples_dir() -> Path | None: + """Return the directory holding the committed example recordings, or None. + + Honours ``$OPENADAPT_CAPTURE_EXAMPLES`` first, then looks for an + openadapt-capture checkout beside this one. No absolute path is written + here: this repository is public and one developer's home directory is not + a location any other user has. + + Returns: + The directory, or None when neither source is present. + """ + named = os.environ.get(EXAMPLES_ENV) + if named: + return Path(named).expanduser() + + sibling = REPO_ROOT.parent / "openadapt-capture" / "examples" / "captures" + return sibling if sibling.is_dir() else None + + +def require_examples() -> Path: + """Return the examples directory, skipping or failing with a reason. + + Returns: + The directory holding the committed example recordings. + """ + directory = examples_dir() + if directory is None: + pytest.skip( + "No openadapt-capture checkout found. Clone it beside this " + f"repository, or set ${EXAMPLES_ENV} to its examples/captures." + ) + if not directory.is_dir(): + # The variable was set on purpose, so an absent directory is an error + # in the caller's setup, not a reason to quietly pass. + pytest.fail(f"${EXAMPLES_ENV} names {directory}, which is not a directory") + missing = [ + name + for name in REAL_RECORDINGS + if not (directory / name / "recording.db").is_file() + ] + if missing: + pytest.fail(f"{directory} is missing recording.db for: {', '.join(missing)}") + return directory diff --git a/tests/test_real_data_loader.py b/tests/test_real_data_loader.py new file mode 100644 index 0000000..82af9c6 --- /dev/null +++ b/tests/test_real_data_loader.py @@ -0,0 +1,252 @@ +"""The benchmark loader must read what the current recorder writes. + +``load_real_capture_data`` opened ``capture.db`` and selected from a ``capture`` +table. openadapt-capture PR #28 replaced that format on 2026-07-17 with +``recording.db``, whose ``recording`` table shares no column with it, so the +loader raised ``FileNotFoundError`` on every recording made since. These tests +load the two recordings openadapt-capture commits under ``examples/captures`` +and assert the values the loader reports, queried here from the same file. + +What these tests do NOT cover, stated plainly so nobody reads more into a green +run than it earns: the episodes half. ``load_real_capture_data`` also requires +an ``episodes.json`` beside the recording, which is openadapt-ml segmentation +output, and neither committed recording carries one. The file used below is +``test_episodes.json``, a fixture committed to this repository. It is not +openadapt-ml output: that pipeline serialises ``EpisodeExtractionResult``, whose +``steps`` are objects rather than strings, whose ``episode_id`` is a UUID, and +which has no ``screenshots`` key at all. So every assertion here about tasks and +steps proves only that the fixture reaches the run unchanged. Every assertion +about timing, geometry and counts is checked against ``recording.db``, and that +is the half these tests are for. +""" + +from __future__ import annotations + +import json +import shutil +import sqlite3 +from pathlib import Path + +import pytest + +from openadapt_viewer.recording_db import ( + LEGACY_DB_NAME, + RECORDING_DB_NAME, + LegacyCaptureError, +) +from openadapt_viewer.viewers.benchmark.real_data_loader import load_real_capture_data + +from .capture_examples import REAL_RECORDINGS, REPO_ROOT, require_examples + +#: A segmentation fixture committed to this repository. See the module +#: docstring: it is not openadapt-ml output, and nothing here treats it as such. +EPISODES_FIXTURE = REPO_ROOT / "test_episodes.json" + + +@pytest.fixture +def loadable_recording(tmp_path, request): + """Copy one committed recording somewhere an episodes.json can sit beside it. + + The recording is copied rather than read in place because the loader wants + both files in one directory, and openadapt-capture's checkout is not this + test's to write into. + """ + name = request.param + source = require_examples() / name + directory = tmp_path / name + directory.mkdir() + shutil.copy(source / RECORDING_DB_NAME, directory / RECORDING_DB_NAME) + shutil.copy(EPISODES_FIXTURE, directory / "episodes.json") + return directory + + +def _legacy_capture(directory: Path) -> Path: + """Write a pre-#28 capture directory, in the format this viewer refuses. + + The shape is the one openadapt-capture's ``migrate_legacy_capture.py`` + reads: one ``capture`` row and a generic ``events`` table. + + Args: + directory: Where to write it. + + Returns: + The same directory. + """ + directory.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(directory / LEGACY_DB_NAME) as conn: + conn.execute( + "CREATE TABLE capture (id INTEGER PRIMARY KEY, started_at REAL, " + "ended_at REAL, platform TEXT, screen_width INTEGER, " + "screen_height INTEGER, pixel_ratio REAL, task_description TEXT)" + ) + conn.execute( + "INSERT INTO capture VALUES (1, 1000.0, 1012.0, 'darwin', 1920, 1080, 2.0, 'old')" + ) + conn.execute( + "CREATE TABLE events (id INTEGER PRIMARY KEY, timestamp REAL, " + "type TEXT, data TEXT, parent_id INTEGER)" + ) + conn.execute("INSERT INTO events VALUES (1, 1000.5, 'mouse.move', '{}', NULL)") + (directory / "episodes.json").write_text(EPISODES_FIXTURE.read_text()) + return directory + + +@pytest.mark.parametrize("loadable_recording", REAL_RECORDINGS, indirect=True) +class TestCommittedRecordingsLoad: + """The defect as a user meets it: every current recording fails to load.""" + + def test_a_current_recording_loads(self, loadable_recording): + run = load_real_capture_data(loadable_recording) + + # Before this reader existed the call raised FileNotFoundError for a + # capture.db that no recorder has written since 2026-07-17. + assert not (loadable_recording / LEGACY_DB_NAME).exists() + assert run.run_id + + def test_start_time_is_the_recording_timestamp(self, loadable_recording): + expected = _scalar( + loadable_recording, "SELECT timestamp FROM recording ORDER BY id LIMIT 1" + ) + + run = load_real_capture_data(loadable_recording) + + assert run.start_time.timestamp() == pytest.approx(expected) + + def test_end_time_is_the_last_observation(self, loadable_recording): + # There is no ended_at column, so the end is derived from the events. + expected = max( + _scalar(loadable_recording, "SELECT MAX(timestamp) FROM screenshot"), + _scalar(loadable_recording, "SELECT MAX(timestamp) FROM action_event"), + ) + + run = load_real_capture_data(loadable_recording) + + assert run.end_time.timestamp() == pytest.approx(expected) + assert run.config["duration"] > 0 + + def test_frame_count_is_the_screenshot_rows(self, loadable_recording): + expected = _scalar(loadable_recording, "SELECT COUNT(*) FROM screenshot") + + run = load_real_capture_data(loadable_recording) + + # Frames are png_data blobs in the database. There is no screenshots/ + # directory to count files in. + assert expected > 0 + assert not (loadable_recording / "screenshots").exists() + assert run.config["frame_count"] == expected + + def test_event_count_is_the_action_event_rows(self, loadable_recording): + expected = _scalar(loadable_recording, "SELECT COUNT(*) FROM action_event") + + run = load_real_capture_data(loadable_recording) + + assert expected > 0 + assert run.config["event_count"] == expected + + def test_display_metadata_comes_from_the_monitor_columns(self, loadable_recording): + row = _row(loadable_recording, "SELECT * FROM recording ORDER BY id LIMIT 1") + + run = load_real_capture_data(loadable_recording) + + # The recorder names these monitor_width and monitor_height. The old + # reader asked for screen_width and screen_height, which do not exist. + assert run.config["screen_size"] == f"{row['monitor_width']}x{row['monitor_height']}" + assert row["monitor_width"] > 0 + assert run.config["platform"] == row["platform"] + assert run.config["task_description"] == row["task_description"] + assert run.config["task_description"] + + def test_episodes_reach_the_run_unchanged(self, loadable_recording): + """The fixture passes through. This says nothing about real segmentation.""" + fixture = json.loads(EPISODES_FIXTURE.read_text()) + + run = load_real_capture_data(loadable_recording) + + assert run.config["episode_count"] == len(fixture["episodes"]) + assert [task.task_id for task in run.tasks] == [ + episode["episode_id"] for episode in fixture["episodes"] + ] + + +class TestTheCountsTheseRecordingsDeclare: + """Name the numbers, so a reader can check them against the fixtures.""" + + @pytest.mark.parametrize( + ("name", "frames", "events"), + [("demo_new", 14, 14), ("turn-off-nightshift", 20, 20)], + ) + def test_the_loader_reports_the_declared_counts(self, tmp_path, name, frames, events): + directory = tmp_path / name + directory.mkdir() + shutil.copy( + require_examples() / name / RECORDING_DB_NAME, directory / RECORDING_DB_NAME + ) + shutil.copy(EPISODES_FIXTURE, directory / "episodes.json") + + run = load_real_capture_data(directory) + + assert run.config["frame_count"] == frames + assert run.config["event_count"] == events + + +class TestLegacyCapturesAreRefused: + """A capture.db cannot be read, so say so instead of loading a shell.""" + + def test_a_legacy_directory_names_the_migration(self, tmp_path): + directory = _legacy_capture(tmp_path / "old-recording") + + with pytest.raises(LegacyCaptureError, match="migrate_legacy_capture.py"): + load_real_capture_data(directory) + + def test_a_legacy_directory_is_refused_before_episodes_are_read(self, tmp_path): + directory = _legacy_capture(tmp_path / "old-recording") + (directory / "episodes.json").unlink() + + # Both files are missing as far as this loader is concerned. The one + # worth naming is the one with a fix attached. + with pytest.raises(LegacyCaptureError, match="migrate_legacy_capture.py"): + load_real_capture_data(directory) + + def test_a_directory_with_no_database_names_recording_db(self, tmp_path): + directory = tmp_path / "not-a-recording" + directory.mkdir() + + with pytest.raises(FileNotFoundError, match=RECORDING_DB_NAME): + load_real_capture_data(directory) + + +class TestNoDatabaseIsCreatedAsASideEffect: + """sqlite3.connect creates the file it is given. Never connect blind.""" + + def test_a_refused_directory_gains_no_recording_db(self, tmp_path): + directory = _legacy_capture(tmp_path / "old-recording") + + with pytest.raises(FileNotFoundError): + load_real_capture_data(directory) + + assert not (directory / RECORDING_DB_NAME).exists() + + def test_an_empty_directory_gains_no_recording_db(self, tmp_path): + directory = tmp_path / "not-a-recording" + directory.mkdir() + + with pytest.raises(FileNotFoundError): + load_real_capture_data(directory) + + assert list(directory.iterdir()) == [] + + +def _connect(directory: Path) -> sqlite3.Connection: + conn = sqlite3.connect(directory / RECORDING_DB_NAME) + conn.row_factory = sqlite3.Row + return conn + + +def _scalar(directory: Path, query: str): + with _connect(directory) as conn: + return conn.execute(query).fetchone()[0] + + +def _row(directory: Path, query: str) -> sqlite3.Row: + with _connect(directory) as conn: + return conn.execute(query).fetchone() diff --git a/tests/test_scanner_recording_db.py b/tests/test_scanner_recording_db.py index dded0b2..9650473 100644 --- a/tests/test_scanner_recording_db.py +++ b/tests/test_scanner_recording_db.py @@ -15,7 +15,7 @@ They run against the recordings openadapt-capture commits under ``examples/captures/``, not against a database written here to match this module's own assumptions. A test that builds its own input can only prove the -reader agrees with the test author. +reader agrees with the test author. ``tests/capture_examples.py`` locates them. """ from __future__ import annotations @@ -31,49 +31,7 @@ from openadapt_viewer.cli import main from openadapt_viewer.scanner import RecordingScanner -REPO_ROOT = Path(__file__).parent.parent - -#: Set by CI to the checked-out openadapt-capture's examples/captures. -EXAMPLES_ENV = "OPENADAPT_CAPTURE_EXAMPLES" - -#: The recordings openadapt-capture commits, with what its specs declare. -REAL_RECORDINGS = ("demo_new", "turn-off-nightshift") - - -def _examples_dir() -> Path | None: - """Return the directory holding the committed example recordings, or None. - - Honours ``$OPENADAPT_CAPTURE_EXAMPLES`` first, then looks for an - openadapt-capture checkout beside this one. No absolute path is written - here: this repository is public and one developer's home directory is not - a location any other user has. - """ - named = os.environ.get(EXAMPLES_ENV) - if named: - return Path(named).expanduser() - - sibling = REPO_ROOT.parent / "openadapt-capture" / "examples" / "captures" - return sibling if sibling.is_dir() else None - - -def _require_examples() -> Path: - """Return the examples directory, skipping or failing with a reason.""" - directory = _examples_dir() - if directory is None: - pytest.skip( - "No openadapt-capture checkout found. Clone it beside this " - f"repository, or set ${EXAMPLES_ENV} to its examples/captures." - ) - if not directory.is_dir(): - # The variable was set on purpose, so an absent directory is an error - # in the caller's setup, not a reason to quietly pass. - pytest.fail(f"${EXAMPLES_ENV} names {directory}, which is not a directory") - missing = [ - name for name in REAL_RECORDINGS if not (directory / name / "recording.db").is_file() - ] - if missing: - pytest.fail(f"{directory} is missing recording.db for: {', '.join(missing)}") - return directory +from .capture_examples import EXAMPLES_ENV, REAL_RECORDINGS, examples_dir, require_examples @pytest.fixture @@ -109,14 +67,14 @@ class TestCommittedRecordingsAreFound: """The defect, stated as the user sees it: scan finds nothing.""" def test_scan_registers_every_committed_recording(self, scanner): - examples = _require_examples() + examples = require_examples() found = scanner.scan_recording_directory(str(examples)) assert sorted(recording.id for recording in found) == sorted(REAL_RECORDINGS) def test_scanned_recordings_reach_the_catalog(self, scanner): - examples = _require_examples() + examples = require_examples() scanner.scan_recording_directory(str(examples)) @@ -124,7 +82,7 @@ def test_scanned_recordings_reach_the_catalog(self, scanner): assert sorted(recording.id for recording in listed) == sorted(REAL_RECORDINGS) def test_recursive_scan_finds_a_nested_recording(self, scanner, tmp_path): - examples = _require_examples() + examples = require_examples() nested = tmp_path / "runs" / "monday" / "demo_new" nested.mkdir(parents=True) (nested / "recording.db").write_bytes( @@ -147,7 +105,7 @@ class TestCommittedRecordingsAreRead: """ def test_created_at_is_the_recording_timestamp(self, scanner, name): - directory = _require_examples() / name + directory = require_examples() / name expected = _scalar(directory, "SELECT timestamp FROM recording ORDER BY id LIMIT 1") recording = scanner._extract_recording_info(directory, name) @@ -158,7 +116,7 @@ def test_created_at_is_the_recording_timestamp(self, scanner, name): assert recording.created_at != directory.stat().st_mtime def test_event_count_is_the_action_event_rows(self, scanner, name): - directory = _require_examples() / name + directory = require_examples() / name expected = _scalar(directory, "SELECT COUNT(*) FROM action_event") recording = scanner._extract_recording_info(directory, name) @@ -167,7 +125,7 @@ def test_event_count_is_the_action_event_rows(self, scanner, name): assert recording.event_count == expected def test_frame_count_is_the_screenshot_rows(self, scanner, name): - directory = _require_examples() / name + directory = require_examples() / name expected = _scalar(directory, "SELECT COUNT(*) FROM screenshot") recording = scanner._extract_recording_info(directory, name) @@ -180,7 +138,7 @@ def test_frame_count_is_the_screenshot_rows(self, scanner, name): assert recording.frame_count == expected def test_duration_spans_start_to_last_observation(self, scanner, name): - directory = _require_examples() / name + directory = require_examples() / name start = _scalar(directory, "SELECT timestamp FROM recording ORDER BY id LIMIT 1") last = max( _scalar(directory, "SELECT MAX(timestamp) FROM screenshot"), @@ -194,7 +152,7 @@ def test_duration_spans_start_to_last_observation(self, scanner, name): assert recording.duration_seconds > 0 def test_task_description_and_display_metadata_are_read(self, scanner, name): - directory = _require_examples() / name + directory = require_examples() / name row = _row(directory, "SELECT * FROM recording ORDER BY id LIMIT 1") recording = scanner._extract_recording_info(directory, name) @@ -235,7 +193,7 @@ def test_extract_refuses_a_legacy_directory(self, scanner, tmp_path): def test_a_converted_directory_is_indexed_without_a_legacy_warning( self, scanner, tmp_path, capsys ): - examples = _require_examples() + examples = require_examples() directory = _legacy_capture(tmp_path / "demo_new") (directory / "recording.db").write_bytes( (examples / "demo_new" / "recording.db").read_bytes() @@ -258,7 +216,7 @@ class TestCorruptRecordingDatabase: """An unreadable database degrades to a named entry, it does not abort.""" def test_scan_continues_past_a_corrupt_database(self, scanner, tmp_path, capsys): - examples = _require_examples() + examples = require_examples() broken = tmp_path / "broken" broken.mkdir() (broken / "recording.db").write_text("this is not a database") @@ -278,7 +236,7 @@ class TestCatalogRegisterCommand: """`openadapt-viewer catalog register` is the manual path around scan.""" def test_register_indexes_a_real_recording(self, tmp_path, monkeypatch, capsys): - directory = _require_examples() / "demo_new" + directory = require_examples() / "demo_new" _isolate_catalog_home(monkeypatch, tmp_path / "home") monkeypatch.setattr( sys, "argv", ["openadapt-viewer", "catalog", "register", str(directory)] @@ -312,7 +270,7 @@ def test_committed_recordings_must_be_present_in_ci(): if os.environ.get("GITHUB_ACTIONS") != "true": pytest.skip("Only enforced on CI, which checks openadapt-capture out") - assert _examples_dir() is not None, ( + assert examples_dir() is not None, ( f"CI must set ${EXAMPLES_ENV}. Without it every test in this module " "skips and the scanner is verified against nothing." )