From b10c77b18b9b70b9cc7d0f34243f182da945d41d Mon Sep 17 00:00:00 2001 From: Kenneth <33361957+mscrnt@users.noreply.github.com> Date: Mon, 23 Jun 2025 07:06:04 -0700 Subject: [PATCH] Add tests and automation --- .github/workflows/ci.yml | 22 ++++++++++++++ .gitignore | 7 +++++ AGENTS.md | 6 ++++ README.md | 10 +++++++ agents.py | 12 ++++++++ requirements.txt | 3 +- tests/test_queries.py | 63 ++++++++++++++++++++++++++++++++++++++++ tests/test_speciesid.py | 60 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 agents.py create mode 100644 tests/test_queries.py create mode 100644 tests/test_speciesid.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8618a6d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + pip install pytest + - name: Run tests + run: pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83add3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +*.db +data/ +*.jpg +*.png +*.tflite diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cd5c265 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# Repository Agent Instructions + +- Always run `pytest` before committing changes to ensure tests pass. +- The tests are located in the `tests/` directory. +- No external network access is available during test runs, so tests must not rely on downloading resources. + diff --git a/README.md b/README.md index 0fdbd92..34edb21 100644 --- a/README.md +++ b/README.md @@ -117,3 +117,13 @@ http://127.0.0.1:7766 or on http://yourserveraddress:7766 **Docker Image** The image is on Docker Hub at https://hub.docker.com/r/mmcc73/whosatmyfeeder + +## Development + +Run the automated test suite with: + +```bash +python agents.py +``` + +Tests use `pytest` and do not require network access. diff --git a/agents.py b/agents.py new file mode 100644 index 0000000..05429ce --- /dev/null +++ b/agents.py @@ -0,0 +1,12 @@ +import subprocess +import sys + + +def run_tests(): + """Run the project's test suite using pytest.""" + result = subprocess.run([sys.executable, '-m', 'pytest'], check=False) + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(run_tests()) diff --git a/requirements.txt b/requirements.txt index 1ffc7f8..ca0c64c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ paho_mqtt==1.6.1 PyYAML==6.0 tflite_support==0.4.3 requests==2.30.0 -Pillow==9.5.0 \ No newline at end of file +Pillow==9.5.0 +pytest==8.3.5 diff --git a/tests/test_queries.py b/tests/test_queries.py new file mode 100644 index 0000000..5440277 --- /dev/null +++ b/tests/test_queries.py @@ -0,0 +1,63 @@ +import os +import sys +import sqlite3 +import types +import tempfile + +sys.path.append(os.path.dirname(os.path.dirname(__file__))) +import queries + + +def setup_birdnames_db(path): + conn = sqlite3.connect(path) + cur = conn.cursor() + cur.execute("CREATE TABLE birdnames (scientific_name TEXT PRIMARY KEY, common_name TEXT NOT NULL)") + cur.execute("INSERT INTO birdnames VALUES ('passer domesticus', 'House Sparrow')") + conn.commit() + conn.close() + + +def setup_detections_db(path): + conn = sqlite3.connect(path) + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE detections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + detection_time TIMESTAMP NOT NULL, + detection_index INTEGER, + score REAL, + display_name TEXT, + category_name TEXT, + frigate_event TEXT, + camera_name TEXT + ) + """ + ) + cur.execute( + "INSERT INTO detections (detection_time, detection_index, score, display_name, category_name, frigate_event, camera_name) " + "VALUES ('2024-01-01 00:00:00', 1, 0.9, 'passer domesticus', 'bird', 'event1', 'cam')" + ) + conn.commit() + conn.close() + + +def test_get_common_name(tmp_path, monkeypatch): + db = tmp_path / "names.db" + setup_birdnames_db(db) + monkeypatch.setattr(queries, "NAMEDBPATH", str(db)) + assert queries.get_common_name("passer domesticus") == "House Sparrow" + + +def test_recent_detections(tmp_path, monkeypatch): + data_db = tmp_path / "detections.db" + names_db = tmp_path / "names.db" + setup_detections_db(data_db) + setup_birdnames_db(names_db) + monkeypatch.setattr(queries, "DBPATH", str(data_db)) + monkeypatch.setattr(queries, "NAMEDBPATH", str(names_db)) + res = queries.recent_detections(1) + assert len(res) == 1 + det = res[0] + assert det["display_name"] == "passer domesticus" + assert det["common_name"] == "House Sparrow" diff --git a/tests/test_speciesid.py b/tests/test_speciesid.py new file mode 100644 index 0000000..2acc409 --- /dev/null +++ b/tests/test_speciesid.py @@ -0,0 +1,60 @@ +import sqlite3 +import sys +import types +import importlib + +def test_setupdb_creates_table(tmp_path, monkeypatch): + db_path = tmp_path / "speciesid.db" + + # Create stub modules for missing dependencies + sys.modules.setdefault('numpy', types.ModuleType('numpy')) + sys.modules.setdefault('cv2', types.ModuleType('cv2')) + tflite_module = types.ModuleType('tflite_support') + task_module = types.ModuleType('task') + task_module.core = types.ModuleType('core') + task_module.processor = types.ModuleType('processor') + task_module.vision = types.ModuleType('vision') + tflite_module.task = task_module + sys.modules.setdefault('tflite_support', tflite_module) + sys.modules.setdefault('tflite_support.task', task_module) + sys.modules.setdefault('tflite_support.task.core', task_module.core) + sys.modules.setdefault('tflite_support.task.processor', task_module.processor) + sys.modules.setdefault('tflite_support.task.vision', task_module.vision) + sys.modules.setdefault('paho', types.ModuleType('paho')) + mqtt_pkg = types.ModuleType('paho.mqtt') + client_mod = types.ModuleType('paho.mqtt.client') + client_mod.Client = lambda *args, **kwargs: None + mqtt_pkg.client = client_mod + sys.modules.setdefault('paho.mqtt', mqtt_pkg) + sys.modules.setdefault('paho.mqtt.client', client_mod) + flask_mod = types.ModuleType('flask') + class DummyFlask(types.SimpleNamespace): + def __init__(self, *args, **kwargs): + super().__init__(route=lambda *a, **k: (lambda f: f)) + self.jinja_env = types.SimpleNamespace(filters={}) + + flask_mod.Flask = DummyFlask + flask_mod.render_template = lambda *a, **k: "" + flask_mod.request = None + flask_mod.redirect = lambda *a, **k: "" + flask_mod.url_for = lambda *a, **k: "" + flask_mod.send_file = lambda *a, **k: None + flask_mod.abort = lambda *a, **k: None + flask_mod.send_from_directory = lambda *a, **k: None + sys.modules.setdefault('flask', flask_mod) + yaml_mod = types.ModuleType('yaml') + yaml_mod.safe_load = lambda f: {} + sys.modules.setdefault('yaml', yaml_mod) + sys.modules.setdefault('requests', types.ModuleType('requests')) + sys.modules.setdefault('PIL', types.ModuleType('PIL')) + sys.modules.setdefault('PIL.Image', types.ModuleType('Image')) + sys.modules.setdefault('PIL.ImageOps', types.ModuleType('ImageOps')) + + speciesid = importlib.import_module('speciesid') + monkeypatch.setattr(speciesid, 'DBPATH', str(db_path)) + speciesid.setupdb() + + conn = sqlite3.connect(db_path) + cur = conn.cursor() + cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='detections'") + assert cur.fetchone() is not None