From 551322a94b3eff382dd7b91f9ce7dff27e9effab Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 25 Jun 2026 21:18:07 -0600 Subject: [PATCH] First commit with entire first rendition of the project --- .gitignore | 36 +++ README.md | 175 +++++++++++ bug-tracking.md | 186 ++++++++++++ config.example.yaml | 34 +++ docs/display-orientation.md | 103 +++++++ docs/mqtt.md | 146 +++++++++ docs/web-interface.md | 314 ++++++++++++++++++++ install.sh | 79 +++++ pyproject.toml | 30 ++ requirements.txt | 6 + spectra/__init__.py | 0 spectra/__main__.py | 3 + spectra/cli.py | 313 ++++++++++++++++++++ spectra/config.py | 78 +++++ spectra/config_manager.py | 60 ++++ spectra/display.py | 99 +++++++ spectra/fetcher.py | 44 +++ spectra/library.py | 99 +++++++ spectra/mqtt.py | 166 +++++++++++ spectra/trigger.py | 63 ++++ spectra/web/__init__.py | 0 spectra/web/models.py | 45 +++ spectra/web/server.py | 441 ++++++++++++++++++++++++++++ spectra/web/static/css/app.css | 138 +++++++++ spectra/web/static/js/app.js | 34 +++ spectra/web/templates/base.html | 32 ++ spectra/web/templates/gallery.html | 112 +++++++ spectra/web/templates/index.html | 101 +++++++ spectra/web/templates/preview.html | 81 +++++ spectra/web/templates/settings.html | 141 +++++++++ spectra/web/templates/upload.html | 292 ++++++++++++++++++ systemd/spectra-web.service | 16 + systemd/spectra.service | 16 + tests/__init__.py | 0 tests/conftest.py | 65 ++++ tests/test_api.py | 353 ++++++++++++++++++++++ tests/test_config.py | 101 +++++++ tests/test_display.py | 135 +++++++++ tests/test_gallery.py | 38 +++ tests/test_mqtt.py | 15 + tests/test_orientation.py | 50 ++++ tests/test_trigger.py | 106 +++++++ 42 files changed, 4346 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bug-tracking.md create mode 100644 config.example.yaml create mode 100644 docs/display-orientation.md create mode 100644 docs/mqtt.md create mode 100644 docs/web-interface.md create mode 100755 install.sh create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 spectra/__init__.py create mode 100644 spectra/__main__.py create mode 100644 spectra/cli.py create mode 100644 spectra/config.py create mode 100644 spectra/config_manager.py create mode 100644 spectra/display.py create mode 100644 spectra/fetcher.py create mode 100644 spectra/library.py create mode 100644 spectra/mqtt.py create mode 100644 spectra/trigger.py create mode 100644 spectra/web/__init__.py create mode 100644 spectra/web/models.py create mode 100644 spectra/web/server.py create mode 100644 spectra/web/static/css/app.css create mode 100644 spectra/web/static/js/app.js create mode 100644 spectra/web/templates/base.html create mode 100644 spectra/web/templates/gallery.html create mode 100644 spectra/web/templates/index.html create mode 100644 spectra/web/templates/preview.html create mode 100644 spectra/web/templates/settings.html create mode 100644 spectra/web/templates/upload.html create mode 100644 systemd/spectra-web.service create mode 100644 systemd/spectra.service create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_config.py create mode 100644 tests/test_display.py create mode 100644 tests/test_gallery.py create mode 100644 tests/test_mqtt.py create mode 100644 tests/test_orientation.py create mode 100644 tests/test_trigger.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e016f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +*.egg + +# Virtual environment +venv/ +.venv/ +env/ +.env/ + +# Config with secrets (API keys, MQTT credentials) +config.yaml + +# Test & coverage +.pytest_cache/ +.coverage +htmlcov/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Generated by simulation mode +spectra_last.png +/tmp/spectra_last.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..1845a4f --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ +# Spectra + +Display random Unsplash photos on an Inky Impression e-paper display, with a web interface for uploading your own images. + +## Features + +- Fetches random high-resolution photos from Unsplash (cached to gallery) +- Upload your own images via the web interface (drag-and-drop, multi-file) +- Centres and scales images to fit the display resolution +- Colour saturation tuning for e-paper +- Web dashboard with preview, gallery, rotation queue, and config editor +- MQTT integration for remote commands and status reporting +- Simulation mode for testing without hardware (configurable resolution) +- systemd integration for automatic updates on a schedule +- Randomised update intervals to avoid predictable refreshes +- Config hot-reload — no service restart needed for setting changes + +## Requirements + +- Raspberry Pi (any model with GPIO) +- [Pimoroni Inky Impression](https://shop.pimoroni.com/products/inky-impression) (4.0", 7.3", or 13.3") +- Python 3.9+ +- [Unsplash API access key](https://unsplash.com/developers) + +## Installation + +Run the installer on a Raspberry Pi: + +```bash +./install.sh +``` + +The installer will: + +1. Install the Pimoroni Inky library (skipped if not a Raspberry Pi) +2. Install Python dependencies (including web server and MQTT) +3. Install the `spectra` package +4. Copy the default config to `/etc/spectra/config.yaml` +5. Install and enable both the display and web interface systemd services + +### Manual installation + +```bash +pip install -r requirements.txt +pip install -e . +``` + +## Configuration + +Edit `/etc/spectra/config.yaml` (or `~/.config/spectra/config.yaml` or `config.yaml` in the current directory): + +```yaml +unsplash: + access_key: "your_access_key_here" + query: "nature" + orientation: "landscape" + collections: "" + +display: + saturation: 0.5 + # resolution: + # width: 800 + # height: 480 + +schedule: + interval_hours: 1 + random_delay_seconds: 300 + +mqtt: + enabled: false + broker: localhost + port: 1883 + topic_prefix: spectra + client_id: spectra-display + username: "" + password: "" + +paths: + cache: /var/cache/spectra +``` + +## Usage + +### Display loop + +```bash +# Run once and exit +spectra --once + +# Run once in simulation mode +spectra --once --simulate + +# Simulate at a specific resolution +spectra --once --simulate --width 800 --height 480 + +# Run with a custom config +spectra -c /path/to/config.yaml + +# Run continuously +spectra +``` + +### Web interface + +```bash +# Start the web interface (default: http://0.0.0.0:5000) +spectra web + +# With custom host/port +spectra web --host 0.0.0.0 --port 5000 + +# Verbose logging +spectra web -v +``` + +The web interface provides: + +- **Dashboard** — display status, schedule info, quick actions (refresh, clear) +- **Gallery** — browse all uploaded and cached Unsplash images, trigger "show now" +- **Upload** — drag-and-drop multiple image files with progress tracking +- **Settings** — live-edit Unsplash, display, schedule, and MQTT configuration + +### MQTT + +When MQTT is enabled in the config, the display loop subscribes to commands and publishes status. See [MQTT integration docs](docs/mqtt.md) for details. + +## systemd services + +Two systemd services are installed: + +- `spectra.service` — the display loop (fetches Unsplash, processes triggers) +- `spectra-web.service` — the web interface (Flask + htmx) + +```bash +sudo systemctl start spectra +sudo systemctl stop spectra-web +sudo systemctl status spectra-web + +# View logs +journalctl -u spectra -f +journalctl -u spectra-web -f +``` + +## Project structure + +``` +spectra/ +├── config.yaml # Example configuration +├── install.sh # Installer script +├── pyproject.toml # Package metadata +├── requirements.txt # Python dependencies +├── spectra/ +│ ├── __init__.py +│ ├── __main__.py # python -m spectra entry point +│ ├── cli.py # CLI argument parsing and main loop +│ ├── config.py # Configuration loader +│ ├── config_manager.py # Config read/write with YAML write-back +│ ├── display.py # Inky display abstraction and image processing +│ ├── fetcher.py # Unsplash API client +│ ├── library.py # Unsplash image caching to SQLite library +│ ├── mqtt.py # MQTT client (commands + status) +│ ├── trigger.py # Shared trigger file (web server → display loop) +│ └── web/ +│ ├── server.py # Flask app factory and API routes +│ ├── models.py # SQLAlchemy models +│ ├── templates/ # Jinja2 templates (6 pages) +│ ├── static/ # CSS and JS +│ └── __init__.py +├── systemd/ +│ ├── spectra.service # Display loop systemd unit +│ └── spectra-web.service # Web interface systemd unit +└── docs/ + ├── web-interface.md # Web interface documentation + └── mqtt.md # MQTT integration documentation +``` diff --git a/bug-tracking.md b/bug-tracking.md new file mode 100644 index 0000000..dde6ebf --- /dev/null +++ b/bug-tracking.md @@ -0,0 +1,186 @@ +# Bug Tracking + +## Critical + +### 1. MQTT enabled checkbox can never be unchecked + +**File:** `spectra/web/templates/settings.html:90–97`, `spectra/web/server.py:118–120` + +**Problem:** The MQTT enable/disable checkbox uses htmx to PUT `{"value": "true"}` to `/api/config/mqtt/enabled`. When the user unchecks the box, the browser omits the field entirely (standard HTML behaviour for unchecked checkboxes). The server then receives an empty body, hits the `if data is None or "value" not in data: abort(400)` guard, and returns a 400 error. Result: MQTT can never be turned off via the web UI. + +**To reproduce:** Go to Settings → MQTT → uncheck "Enable MQTT" → click Save. Browser sends PUT without `value` → server returns 400. + +**Fix:** Add a hidden input before the checkbox with the same `name` and `value="false"`. The checkbox's value (`"true"`) overrides the hidden when checked. + +```html + +``` + +--- + +## Medium + +### 2. Gallery delete shows "Saved" instead of removing the card + +**Files:** `spectra/web/templates/gallery.html:84`, `spectra/web/static/js/app.js:9–11` + +**Problem:** The Gallery's delete button uses `hx-delete` targeting `#gallery-msg`. After a successful delete, the server returns `{"status": "deleted"}`. The htmx response handler in `app.js` checks `data.status === 'triggered'` → shows trigger message, otherwise shows "Saved". Since `'deleted' !== 'triggered'`, the user sees "Saved" instead of "Deleted". The image card remains visible until the page is manually refreshed. + +**Severity:** UX/data mismatch — user has no confirmation the image was actually deleted. + +**Fix options:** +- **Option A:** Make the delete endpoint return `{"status": "triggered"}` instead (hacky). +- **Option B:** Update `app.js` to check for `data.status === 'deleted'` and show "Deleted" + remove the card from the DOM. +- **Option C:** Use htmx's `hx-target="closest article" hx-swap="outerHTML"` on the delete button so the card is removed directly from the DOM without needing a message. + +**Recommended (Option C):** Change the delete button to: + +```html + +``` + +Then remove the `#gallery-msg` target from the delete — the card disappears inline. + +### 3. `MqttClient.reconfigure()` calls `self.__init__()` directly + +**File:** `spectra/mqtt.py:85` + +**Problem:** `reconfigure()` calls `self.__init__(new_config)` to re-initialise the instance. While this works (Python allows it), it's highly unusual, confuses static analysis tools, and breaks if a subclass overrides `__init__` differently. If the config is unchanged but the background thread has died, the method also unnecessarily destroys and recreates the client. + +**Fix:** Extract the attribute setup into a private `_setup` method called from both `__init__` and `reconfigure`: + +```python +def __init__(self, config): + self._setup(config) + self._client = mqtt.Client(client_id=self.client_id, protocol=mqtt.MQTTv311) + ... + +def _setup(self, config): + self.broker = config.get("broker", "localhost") + self.port = config.get("port", 1883) + self.prefix = config.get("topic_prefix", "spectra") + self.client_id = config.get("client_id", "spectra-display") + self._username = config.get("username", "") + self._password = config.get("password", "") + +def reconfigure(self, new_config): + ... + self.stop() + self._setup(new_config) + self._client = mqtt.Client(client_id=self.client_id, protocol=mqtt.MQTTv311) + if self._username: + self._client.username_pw_set(self._username, self._password) + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect + self._client.on_message = self._on_message + self.start() +``` + +--- + +## Low + +### 4. Unsplash filename collision on duplicate fetch + +**File:** `spectra/library.py:56` + +**Problem:** The filename is `f"unsplash_{unsplash_id}.png"`. If the same Unsplash photo is fetched twice (e.g. because the query doesn't change and there aren't many results), the second fetch overwrites the first file. Two DB records now reference the same filepath. If one record is deleted, `os.remove(dest)` succeeds, leaving the other record with a dangling path. On the next attempt to serve/thumbnail that record, the endpoint returns 404 from `send_file`. + +**Fix:** Add a UUID suffix to make filenames unique: + +```python +filename = f"unsplash_{unsplash_id}_{uuid.uuid4().hex[:8]}.png" +``` + +Before saving, also check if the DB already has a record for this `unsplash_id` and skip the insert (avoid duplicates entirely): + +```python +conn = sqlite3.connect(_db_path()) +existing = conn.execute("SELECT id FROM image WHERE source='unsplash' AND unsplash_id=?", (unsplash_id,)).fetchone() +if existing: + conn.close() + os.remove(dest) # clean up the duplicate file + return existing[0] +``` + +### 5. Object URLs never revoked in upload page + +**File:** `spectra/web/templates/upload.html:156` + +**Problem:** `URL.createObjectURL(file)` creates a blob URL for the file preview, but it is never released with `URL.revokeObjectURL()`. For a typical session (a handful of files), the memory impact is negligible. If a user uploads hundreds of files in one session, blob URLs accumulate in memory until the page is reloaded. + +**Fix:** Store the URL on the file item element and revoke it when the item is removed or after a failed upload: + +```javascript +function renderFile(file) { + var url = URL.createObjectURL(file); + // ... store url for later revocation + fileItem.dataset.blobUrl = url; +} + +function cleanupFileItem(el) { + var url = el.dataset.blobUrl; + if (url) URL.revokeObjectURL(url); +} +``` + +### 6. Dead CSS rules for old upload form + +**File:** `spectra/web/static/css/app.css:20` + +**Problem:** The CSS rule `#upload-result:empty { display: none; }` targets an element that was used by the old htmx-based upload form. The new multi-file upload form uses `#file-list`, `#upload-options`, and `#upload-summary` instead. The `#upload-result` element no longer exists anywhere in the templates. + +**Other dead selectors on the same line:** `#gallery-msg:empty`, `#action-result:empty`, `#unsplash-result:empty`, `#saturation-result:empty`, `#schedule-result:empty`. + +- `#gallery-msg` — still used by gallery delete/show htmx responses (if changed to `closest article` approach in bug #2, this becomes dead too) +- `#action-result` — used by dashboard quick actions +- `#unsplash-result`, `#saturation-result`, `#schedule-result` — used by settings page + +**Fix:** Remove only `#upload-result:empty` from the selector list. Keep the others as they are still in use. + +--- + +## Cosmetic / Design + +### 7. Inconsistent `process_image` usage: processed dimensions stored vs originals shown + +**File:** `spectra/library.py:61`, `spectra/web/server.py:182–183` + +**Design issue:** `library.py` saves the processed (cropped + resized) dimensions as width/height in the DB, but stores the *original* unprocessed image on disk. The gallery shows the original file (via `/api/images//file`) which may have different dimensions than what is stored. The preview endpoint re-processes the original on demand, so the stored dimensions are only used for display purposes (and are somewhat misleading). + +This is intentional — the stored dimensions represent "how the image appears on the display" — but it could confuse API consumers who see width/height that doesn't match the actual file dimensions. Documentation should clarify this. + +### 8. `api_config_set` double-mutates config + +**File:** `spectra/web/server.py:124–125` + +**Design issue:** `section_data[key] = data["value"]` mutates `cm.config` in place (because `section_data` is a reference to `cm.config[section]`). Then `cm.set_nested()` does the same mutation again and calls `_write()`. The config is correct after both operations, but the first mutation is written only if `set_nested` completes, so this isn't a data-loss risk — just redundant code. + +**Fix:** Remove the redundant mutation; only `cm.set_nested()` is needed: + +```python +# Remove line 124: section_data[key] = data["value"] +cm.set_nested([section, key], data["value"]) +return jsonify({key: data["value"]}) +``` + +--- + +## Tests + +### 9. ~~Gallery delete test missing from test suite~~ + +**Fixed:** `tests/test_gallery.py` adds an end-to-end test (`test_upload_delete_flow`) that uploads an +image (`POST /api/images`), confirms `total > 0` via `GET /api/images`, deletes it, then confirms +`total` decreased by 1. Run with `pytest tests/`. + +**Dev dependency:** `pytest>=7.0.0` and `pytest-flask>=1.2.0` added under `[project.optional-dependencies] dev` in `pyproject.toml`. Install with `pip install "spectra-display[dev]"`. diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..54ca042 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,34 @@ +# Copy this file to config.yaml and fill in your settings. +# spectra searches for config.yaml in order: +# 1. $PWD/config.yaml +# 2. ~/.config/spectra/config.yaml +# 3. /etc/spectra/config.yaml +# Or pass a custom path with: spectra -c /path/to/config.yaml + +unsplash: + # Required: register at https://unsplash.com/developers + access_key: "" + # Optional filters + query: "" + collections: "" + +display: + saturation: 0.5 + # Physical orientation in degrees: 0, 90, 180, 270 + orientation: 0 + resolution: + width: 1600 + height: 1200 + +schedule: + interval_hours: 1 + random_delay_seconds: 300 + +mqtt: + enabled: false + broker: localhost + port: 1883 + topic_prefix: spectra + client_id: spectra-display + username: "" + password: "" diff --git a/docs/display-orientation.md b/docs/display-orientation.md new file mode 100644 index 0000000..ba5b516 --- /dev/null +++ b/docs/display-orientation.md @@ -0,0 +1,103 @@ +# Display Orientation + +## Purpose + +Allow the user to configure the physical orientation of the Inky Impression +display so that images are displayed upright regardless of how the panel is +mounted. The orientation setting also drives the Unsplash orientation filter so +fetched photos match the display's effective aspect ratio (portrait vs. +landscape). + +## Configuration + +A new `display.orientation` key (integer, degrees clockwise) in +`config.yaml` / `DEFAULT_CONFIG`: + +```yaml +display: + orientation: 0 # 0, 90, 180, 270 +``` + +| Value | Unsplash filter | Effective aspect | +|-------|----------------|------------------| +| 0 | landscape | width > height | +| 90 | portrait | height > width | +| 180 | landscape | width > height | +| 270 | portrait | height > width | + +The old `unsplash.orientation` config key is **no longer used** by +`refresh()` — orientation is now derived from `display.orientation` + +`display.resolution`. + +## Image Processing Pipeline + +1. **Fetch** raw photo from Unsplash (with orientation filter already matching + the effective aspect ratio — see below). +2. **`process_image()`** in `InkyDisplay`: + - Compute effective width/height by swapping physical dimensions when + orientation is 90° or 270°. + - Crop to effective aspect ratio. + - Resize to effective dimensions. + - Rotate by `-orientation` degrees (so the result matches the physical + resolution of the display panel). +3. **`show()`** sends the processed (and rotated) image to `inky.set_image()`. + No additional transformation is needed — the panel hardware always expects + its native `width × height` pixel grid. + +## Unsplash Filter Derivation + +In `cli.py:refresh()`, after reading `display.orientation` and +`display.resolution`: + +1. If orientation is 90 or 270, swap width ↔ height to get effective + dimensions. +2. If effective height > effective width → request `"portrait"`. +3. If effective width > effective height → request `"landscape"`. +4. If roughly equal → request `"squarish"`. + +This means the Unsplash API is sent `orientation=portrait` when the display is +mounted vertically, which returns taller photos that need less cropping. + +## Hot-Reload + +`_reload_config()` in `cli.py` detects changes to +`display.orientation` and updates `display.orientation` live. The next +`process_image()` call uses the new orientation. No display loop restart is +needed. + +## Library Caching + +`_save_to_library()` passes the **effective** width/height to +`save_unsplash_image()` (swapped when orientation is 90/270). The stored image +is processed to the effective dimensions at orientation=0 (no rotation). This +keeps library images in their "natural" viewing orientation. + +The preview endpoint (`/api/preview/`) creates an `InkyDisplay` with the +current orientation, so the preview matches what the physical display shows. + +## UI + +Settings page (Display article) shows a ` + + + + + + + + + + + +{% endblock %} diff --git a/spectra/web/templates/settings.html b/spectra/web/templates/settings.html new file mode 100644 index 0000000..969bebc --- /dev/null +++ b/spectra/web/templates/settings.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} +{% block title %}Settings{% endblock %} + +{% block content %} +

Settings

+ +
+
+
Unsplash
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +

+
+ +
+
Display
+ +
+ + + +
+ +
+ + + +
+ +

+

+
+ +
+
Schedule
+ +
+ + + +
+ +
+ + + +
+ +

+
+ +
+
MQTT
+ +
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +

+

See MQTT docs for available commands and status topics.

+
+
+{% endblock %} diff --git a/spectra/web/templates/upload.html b/spectra/web/templates/upload.html new file mode 100644 index 0000000..2d951ec --- /dev/null +++ b/spectra/web/templates/upload.html @@ -0,0 +1,292 @@ +{% extends "base.html" %} +{% block title %}Upload{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +

Upload Images

+ +
+
+ 📁 +

Drag & drop images here

+

or browse to select files

+ +
+ +
+ + + + +
+ + +{% endblock %} diff --git a/systemd/spectra-web.service b/systemd/spectra-web.service new file mode 100644 index 0000000..78acef7 --- /dev/null +++ b/systemd/spectra-web.service @@ -0,0 +1,16 @@ +[Unit] +Description=Spectra Web Interface +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStartPre=/usr/bin/mkdir -p /var/cache/spectra/uploads +ExecStart=/usr/local/bin/spectra web +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/systemd/spectra.service b/systemd/spectra.service new file mode 100644 index 0000000..adf2424 --- /dev/null +++ b/systemd/spectra.service @@ -0,0 +1,16 @@ +[Unit] +Description=Spectra Unsplash Image Display +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStartPre=/usr/bin/mkdir -p /var/cache/spectra/uploads +ExecStart=/usr/local/bin/spectra +Restart=on-failure +RestartSec=30 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..eb1e227 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,65 @@ +import io +import json +import os +import tempfile +from pathlib import Path + +import pytest +from PIL import Image as PILImage + +from spectra.web.server import create_app + + +TEST_IMAGE_SIZE = (800, 600) + + +@pytest.fixture +def app(): + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + cache_dir = tmp / "cache" + + old_environ = os.environ.get("SPECTRA_CACHE_DIR") + os.environ["SPECTRA_CACHE_DIR"] = str(cache_dir) + + app = create_app() + + yield app + + if old_environ is None: + os.environ.pop("SPECTRA_CACHE_DIR", None) + else: + os.environ["SPECTRA_CACHE_DIR"] = old_environ + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture +def sample_image_bytes(): + img = PILImage.new("RGB", TEST_IMAGE_SIZE, color=(100, 150, 200)) + buf = io.BytesIO() + img.save(buf, format="JPEG") + buf.seek(0) + return buf + + +@pytest.fixture +def uploaded_image(client, sample_image_bytes): + resp = client.post( + "/api/images", + data={"file": (sample_image_bytes, "test.jpg")}, + content_type="multipart/form-data", + ) + assert resp.status_code == 201 + return resp.get_json() + + +def make_image_bytes(width, height, color=(100, 150, 200), fmt="JPEG"): + img = PILImage.new("RGB", (width, height), color=color) + buf = io.BytesIO() + img.save(buf, format=fmt) + buf.seek(0) + return buf, img diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..b396c9a --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,353 @@ +import io + +from PIL import Image as PILImage + + +class TestConfigAPI: + def test_get_config(self, client): + resp = client.get("/api/config") + assert resp.status_code == 200 + data = resp.get_json() + assert "display" in data + assert "unsplash" in data + assert "schedule" in data + + def test_patch_config(self, client): + resp = client.patch("/api/config", json={"display": {"saturation": 0.7}}) + assert resp.status_code == 200 + data = resp.get_json() + assert data["display"]["saturation"] == 0.7 + + def test_get_config_section(self, client): + resp = client.get("/api/config/display") + assert resp.status_code == 200 + data = resp.get_json() + assert "saturation" in data + assert "orientation" in data + + def test_get_config_section_not_found(self, client): + resp = client.get("/api/config/nonexistent") + assert resp.status_code == 404 + + def test_set_nested_config_key(self, client): + resp = client.put( + "/api/config/display/saturation", + json={"value": 0.9}, + ) + assert resp.status_code == 200 + assert resp.get_json() == {"saturation": 0.9} + resp = client.get("/api/config/display") + assert resp.get_json()["saturation"] == 0.9 + + def test_set_config_orientation(self, client): + resp = client.put("/api/config/display/orientation", json={"value": 90}) + assert resp.status_code == 200 + resp = client.get("/api/config/display") + assert resp.get_json()["orientation"] == 90 + + def test_set_config_missing_value_returns_400(self, client): + resp = client.put("/api/config/display/saturation", json={}) + assert resp.status_code == 400 + + +class TestImagesAPI: + def test_list_images_empty(self, client): + resp = client.get("/api/images") + assert resp.status_code == 200 + data = resp.get_json() + assert data["images"] == [] + assert data["total"] == 0 + + def test_upload_image(self, client, sample_image_bytes): + resp = client.post( + "/api/images", + data={"file": (sample_image_bytes, "test.jpg")}, + content_type="multipart/form-data", + ) + assert resp.status_code == 201 + data = resp.get_json() + assert data["source"] == "upload" + assert data["title"] == "test" + assert data["width"] == 800 + assert data["height"] == 600 + + def test_upload_image_with_title_and_author(self, client, sample_image_bytes): + resp = client.post( + "/api/images", + data={"file": (sample_image_bytes, "photo.jpg"), "title": "My Photo", "author": "Test User"}, + content_type="multipart/form-data", + ) + assert resp.status_code == 201 + data = resp.get_json() + assert data["title"] == "My Photo" + assert data["author"] == "Test User" + + def test_upload_image_no_file_returns_400(self, client): + resp = client.post("/api/images", content_type="multipart/form-data") + assert resp.status_code == 400 + + def test_upload_invalid_extension_returns_400(self, client): + buf = io.BytesIO(b"not an image") + resp = client.post( + "/api/images", + data={"file": (buf, "test.txt")}, + content_type="multipart/form-data", + ) + assert resp.status_code == 400 + + def test_upload_corrupted_image_returns_400(self, client): + buf = io.BytesIO(b"not an image at all") + resp = client.post( + "/api/images", + data={"file": (buf, "test.jpg")}, + content_type="multipart/form-data", + ) + assert resp.status_code == 400 + + def test_get_image_detail(self, client, uploaded_image): + image_id = uploaded_image["id"] + resp = client.get(f"/api/images/{image_id}") + assert resp.status_code == 200 + data = resp.get_json() + assert data["id"] == image_id + assert data["source"] == "upload" + + def test_get_image_detail_not_found(self, client): + resp = client.get("/api/images/99999") + assert resp.status_code == 404 + + def test_delete_image(self, client, uploaded_image): + image_id = uploaded_image["id"] + resp = client.delete(f"/api/images/{image_id}") + assert resp.status_code == 200 + assert resp.get_json() == {"status": "deleted"} + resp = client.get(f"/api/images/{image_id}") + assert resp.status_code == 404 + + def test_delete_image_not_found(self, client): + resp = client.delete("/api/images/99999") + assert resp.status_code == 404 + + def test_list_images_after_upload(self, client, uploaded_image): + resp = client.get("/api/images") + data = resp.get_json() + assert data["total"] >= 1 + ids = [i["id"] for i in data["images"]] + assert uploaded_image["id"] in ids + + def test_upload_security_filename_traversal(self, client): + img = PILImage.new("RGB", (100, 100)) + buf = io.BytesIO() + img.save(buf, format="PNG") + buf.seek(0) + resp = client.post( + "/api/images", + data={"file": (buf, "../../etc/passwd.png")}, + content_type="multipart/form-data", + ) + assert resp.status_code == 201 + data = resp.get_json() + assert "/" not in data["filename"] + assert ".." not in data["filename"] + + def test_image_download(self, client, uploaded_image): + image_id = uploaded_image["id"] + resp = client.get(f"/api/images/{image_id}/file") + assert resp.status_code == 200 + assert resp.content_type.startswith("image/") + + def test_image_download_not_found(self, client): + resp = client.get("/api/images/99999/file") + assert resp.status_code == 404 + + +class TestImageShow: + def test_show_image(self, client, uploaded_image): + image_id = uploaded_image["id"] + resp = client.post(f"/api/images/{image_id}/show") + assert resp.status_code == 200 + data = resp.get_json() + assert data["status"] == "triggered" + + def test_show_image_not_found(self, client): + resp = client.post("/api/images/99999/show") + assert resp.status_code == 404 + + +class TestImageThumbnail: + def test_thumbnail(self, client, uploaded_image): + image_id = uploaded_image["id"] + resp = client.get(f"/api/images/{image_id}/thumbnail") + assert resp.status_code == 200 + assert resp.content_type == "image/png" + + def test_thumbnail_not_found(self, client): + resp = client.get("/api/images/99999/thumbnail") + assert resp.status_code == 404 + + +class TestPreviewAPI: + def test_preview(self, client, uploaded_image): + image_id = uploaded_image["id"] + resp = client.get(f"/api/preview/{image_id}") + assert resp.status_code == 200 + assert resp.content_type == "image/png" + + def test_preview_not_found(self, client): + resp = client.get("/api/preview/99999") + assert resp.status_code == 404 + + +class TestDisplayAPI: + def test_status(self, client): + resp = client.get("/api/display/status") + assert resp.status_code == 200 + data = resp.get_json() + assert "simulate" in data + assert "resolution" in data + assert "pending_trigger" in data + + def test_refresh(self, client): + resp = client.post("/api/display/refresh") + assert resp.status_code == 200 + assert resp.get_json()["status"] == "triggered" + + def test_clear(self, client): + resp = client.post("/api/display/clear") + assert resp.status_code == 200 + assert resp.get_json()["status"] == "triggered" + + +class TestRotationAPI: + def test_list_rotation_empty(self, client): + resp = client.get("/api/rotation") + assert resp.status_code == 200 + assert resp.get_json()["entries"] == [] + + def test_add_to_rotation(self, client, uploaded_image): + image_id = uploaded_image["id"] + resp = client.post("/api/rotation", json={"image_ids": [image_id]}) + assert resp.status_code == 201 + assert resp.get_json() == {"added": [image_id]} + + def test_add_nonexistent_image_to_rotation(self, client): + resp = client.post("/api/rotation", json={"image_ids": [99999]}) + assert resp.status_code == 201 + assert resp.get_json() == {"added": []} + + def test_add_duplicate_to_rotation(self, client, uploaded_image): + image_id = uploaded_image["id"] + client.post("/api/rotation", json={"image_ids": [image_id]}) + resp = client.post("/api/rotation", json={"image_ids": [image_id]}) + assert resp.status_code == 201 + assert resp.get_json() == {"added": []} + + def test_rotation_detail(self, client, uploaded_image): + image_id = uploaded_image["id"] + client.post("/api/rotation", json={"image_ids": [image_id]}) + resp = client.get("/api/rotation") + rotation_id = resp.get_json()["entries"][0]["id"] + resp = client.delete(f"/api/rotation/{rotation_id}") + assert resp.status_code == 200 + + def test_patch_rotation_weight(self, client, uploaded_image): + image_id = uploaded_image["id"] + client.post("/api/rotation", json={"image_ids": [image_id]}) + resp = client.get("/api/rotation") + entry = resp.get_json()["entries"][0] + entry_id = entry["id"] + resp = client.patch(f"/api/rotation/{entry_id}", json={"weight": 5}) + assert resp.status_code == 200 + assert resp.get_json()["weight"] == 5 + + def test_patch_rotation_active(self, client, uploaded_image): + image_id = uploaded_image["id"] + client.post("/api/rotation", json={"image_ids": [image_id]}) + resp = client.get("/api/rotation") + entry_id = resp.get_json()["entries"][0]["id"] + resp = client.patch(f"/api/rotation/{entry_id}", json={"active": False}) + assert resp.status_code == 200 + assert resp.get_json()["active"] is False + + def test_rotation_not_found(self, client): + resp = client.delete("/api/rotation/99999") + assert resp.status_code == 404 + + def test_rotation_patch_not_found(self, client): + resp = client.patch("/api/rotation/99999", json={"weight": 3}) + assert resp.status_code == 404 + + +class TestPages: + def test_index_page(self, client): + resp = client.get("/") + assert resp.status_code == 200 + assert b"Dashboard" in resp.data + + def test_gallery_page(self, client): + resp = client.get("/gallery") + assert resp.status_code == 200 + assert b"Gallery" in resp.data + + def test_upload_page(self, client): + resp = client.get("/upload") + assert resp.status_code == 200 + assert b"Upload" in resp.data + + def test_settings_page(self, client): + resp = client.get("/settings") + assert resp.status_code == 200 + assert b"Settings" in resp.data + assert b"orientation" in resp.data + + def test_preview_page(self, client): + resp = client.get("/preview") + assert resp.status_code == 200 + assert b"Preview" in resp.data + + +class TestCORSAndErrors: + def test_404_json(self, client): + resp = client.get("/api/nonexistent") + assert resp.status_code == 404 + assert resp.is_json + + def test_400_has_error(self, client): + resp = client.post("/api/images", content_type="multipart/form-data") + assert resp.status_code == 400 + data = resp.get_json() + assert "error" in data + + def test_url_encoded_form_not_accepted(self, client): + resp = client.put( + "/api/config/display/saturation", + data={"value": "0.5"}, + ) + assert resp.status_code == 400 or resp.status_code == 200 + + +class TestGallery: + def test_gallery_escaping(self, client, sample_image_bytes): + malicious_title = '' + resp = client.post( + "/api/images", + data={"file": (sample_image_bytes, "test.jpg"), "title": malicious_title}, + content_type="multipart/form-data", + ) + assert resp.status_code == 201 + img_id = resp.get_json()["id"] + resp = client.get("/api/images") + img = next(i for i in resp.get_json()["images"] if i["id"] == img_id) + assert img["title"] == malicious_title + + def test_exact_json_equality_cautious(self, client, uploaded_image): + resp = client.get(f"/api/images/{uploaded_image['id']}") + data = resp.get_json() + assert data["id"] == uploaded_image["id"] + assert "created_at" in data + + def test_result_not_affected_by_previous_delete(self, client, uploaded_image): + image_id = uploaded_image["id"] + client.delete(f"/api/images/{image_id}") + resp = client.get(f"/api/images/{image_id}") + assert resp.status_code == 404 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..9edacad --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,101 @@ +import os +import tempfile + +import yaml + +from spectra.config import DEFAULT_CONFIG, _deep_merge, load_config + + +class TestLoadConfig: + def test_defaults_when_no_file(self): + cfg = load_config(path="/nonexistent/.spectra/config.yaml") + assert cfg["display"]["saturation"] == 0.5 + assert cfg["display"]["orientation"] == 0 + assert cfg["unsplash"]["access_key"] == "" + assert cfg["schedule"]["interval_hours"] == 1 + + def test_load_specific_path(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump({"display": {"saturation": 0.8}}, f) + path = f.name + try: + cfg = load_config(path) + assert cfg["display"]["saturation"] == 0.8 + assert cfg["display"]["orientation"] == 0 + finally: + os.unlink(path) + + def test_missing_path_logs_and_uses_defaults(self): + cfg = load_config("/nonexistent/path/config.yaml") + assert cfg["display"]["saturation"] == 0.5 + + def test_partial_merge_does_not_remove_other_keys(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump({"display": {"saturation": 0.3}}, f) + path = f.name + try: + cfg = load_config(path) + assert cfg["display"]["saturation"] == 0.3 + assert cfg["display"]["resolution"]["width"] == 1600 + assert cfg["unsplash"]["access_key"] == "" + finally: + os.unlink(path) + + def test_empty_file_uses_defaults(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("") + path = f.name + try: + cfg = load_config(path) + assert cfg["display"]["saturation"] == 0.5 + finally: + os.unlink(path) + + def test_deep_merge_overrides_nested_key(self): + base = {"a": {"b": 1, "c": 2}, "d": 3} + override = {"a": {"b": 99}} + _deep_merge(base, override) + assert base["a"]["b"] == 99 + assert base["a"]["c"] == 2 + assert base["d"] == 3 + + def test_deep_merge_adds_new_keys(self): + base = {"a": 1} + override = {"b": 2} + _deep_merge(base, override) + assert base["a"] == 1 + assert base["b"] == 2 + + def test_deep_merge_overrides_non_dict_with_dict(self): + base = {"a": 1} + override = {"a": {"b": 2}} + _deep_merge(base, override) + assert base["a"] == {"b": 2} + + def test_default_config_not_mutated_by_load(self): + original_sat = DEFAULT_CONFIG["display"]["saturation"] + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump({"display": {"saturation": 0.9}}, f) + path = f.name + try: + load_config(path) + assert DEFAULT_CONFIG["display"]["saturation"] == original_sat + finally: + os.unlink(path) + + def test_default_config_not_mutated_by_multiple_loads(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump({"unsplash": {"query": "cats"}}, f) + path1 = f.name + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump({"unsplash": {"query": "dogs"}}, f) + path2 = f.name + try: + c1 = load_config(path1) + c2 = load_config(path2) + assert c1["unsplash"]["query"] == "cats" + assert c2["unsplash"]["query"] == "dogs" + assert DEFAULT_CONFIG["unsplash"]["query"] == "" + finally: + os.unlink(path1) + os.unlink(path2) diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..49bf884 --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,135 @@ +import os + +from PIL import Image as PILImage + +from spectra.display import InkyDisplay + + +class TestProcessImage: + def setup_method(self): + self.d = InkyDisplay(simulate=True, width=800, height=480) + + def test_aspect_crop_wide_image(self): + img = PILImage.new("RGB", (1600, 600), color=(128, 64, 200)) + r = self.d.process_image(img) + assert r.size == (800, 480) + + def test_aspect_crop_tall_image(self): + img = PILImage.new("RGB", (400, 900), color=(128, 64, 200)) + r = self.d.process_image(img) + assert r.size == (800, 480) + + def test_exact_aspect_no_crop(self): + img = PILImage.new("RGB", (800, 480), color=(128, 64, 200)) + r = self.d.process_image(img) + assert r.size == (800, 480) + + def test_square_input_to_landscape(self): + img = PILImage.new("RGB", (1000, 1000), color=(128, 64, 200)) + r = self.d.process_image(img) + assert r.size == (800, 480) + + def test_tiny_input(self): + img = PILImage.new("RGB", (10, 10), color=(128, 64, 200)) + r = self.d.process_image(img) + assert r.size == (800, 480) + + def test_panorama_very_wide(self): + img = PILImage.new("RGB", (4000, 800), color=(128, 64, 200)) + r = self.d.process_image(img) + assert r.size == (800, 480) + + def test_very_narrow(self): + img = PILImage.new("RGB", (100, 2000), color=(128, 64, 200)) + r = self.d.process_image(img) + assert r.size == (800, 480) + + +class TestProcessImageWithRotation: + def test_90_rotation_swaps_crop(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation=90) + img = PILImage.new("RGB", (1200, 1600), color=(128, 64, 200)) + r = d.process_image(img) + assert r.size == (800, 480) + + def test_270_rotation(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation=270) + img = PILImage.new("RGB", (1600, 1200), color=(128, 64, 200)) + r = d.process_image(img) + assert r.size == (800, 480) + + def test_180_rotation(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation=180) + img = PILImage.new("RGB", (1600, 1200), color=(128, 64, 200)) + r = d.process_image(img) + assert r.size == (800, 480) + + +class TestShow: + def test_show_writes_file(self): + d = InkyDisplay(simulate=True, width=800, height=480) + d.show(PILImage.new("RGB", (200, 200))) + assert os.path.exists("/tmp/spectra_last.png") + os.unlink("/tmp/spectra_last.png") + + def test_show_with_rotation_writes_same_size(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation=90) + d.show(PILImage.new("RGB", (200, 200))) + saved = PILImage.open("/tmp/spectra_last.png") + assert saved.size == (800, 480) + os.unlink("/tmp/spectra_last.png") + + +class TestShowFile: + def test_show_file(self, tmp_path): + src = tmp_path / "input.png" + img = PILImage.new("RGB", (200, 200)) + img.save(str(src)) + d = InkyDisplay(simulate=True, width=800, height=480) + d.show_file(str(src)) + assert os.path.exists("/tmp/spectra_last.png") + os.unlink("/tmp/spectra_last.png") + + def test_show_file_nonexistent(self): + d = InkyDisplay(simulate=True, width=800, height=480) + try: + d.show_file("/nonexistent/image.png") + assert False, "Expected exception" + except FileNotFoundError: + pass + + +class TestClear: + def test_clear_simulation(self): + d = InkyDisplay(simulate=True, width=800, height=480) + d.clear() + + def test_clear_simulation_with_orientation(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation=90) + d.clear() + + +class TestEdgeCases: + def test_display_initialization_defaults(self): + d = InkyDisplay() + assert d.width == 1600 + assert d.height == 1200 + assert d.orientation == 0 + assert d.simulate is False + + def test_display_initialization_custom(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation=90) + assert d.effective_width == 480 + assert d.effective_height == 800 + + def test_process_image_with_rgba(self): + d = InkyDisplay(simulate=True, width=800, height=480) + img = PILImage.new("RGBA", (1600, 1200), color=(128, 64, 200, 255)) + r = d.process_image(img) + assert r.size == (800, 480) + + def test_process_image_with_grayscale(self): + d = InkyDisplay(simulate=True, width=800, height=480) + img = PILImage.new("L", (1600, 1200), color=128) + r = d.process_image(img) + assert r.size == (800, 480) diff --git a/tests/test_gallery.py b/tests/test_gallery.py new file mode 100644 index 0000000..19c2711 --- /dev/null +++ b/tests/test_gallery.py @@ -0,0 +1,38 @@ +import io + +from PIL import Image as PILImage + + +def _upload_image(client, filename="test_photo.jpg", color=(128, 64, 200)): + img = PILImage.new("RGB", (800, 600), color=color) + buf = io.BytesIO() + img.save(buf, format="JPEG") + buf.seek(0) + return client.post( + "/api/images", + data={"file": (buf, filename)}, + content_type="multipart/form-data", + ) + + +class TestGalleryDelete: + def test_upload_delete_flow(self, client): + resp = _upload_image(client) + assert resp.status_code == 201 + data = resp.get_json() + image_id = data["id"] + + resp = client.get("/api/images") + assert resp.status_code == 200 + data = resp.get_json() + total_before = data["total"] + assert total_before >= 1 + + resp = client.delete(f"/api/images/{image_id}") + assert resp.status_code == 200 + assert resp.get_json() == {"status": "deleted"} + + resp = client.get("/api/images") + assert resp.status_code == 200 + data = resp.get_json() + assert data["total"] == total_before - 1 diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py new file mode 100644 index 0000000..69c691f --- /dev/null +++ b/tests/test_mqtt.py @@ -0,0 +1,15 @@ +from spectra.mqtt import _lookup_image_path, _db_path + + +class TestDbPath: + def test_db_path_ends_web_db(self): + path = _db_path() + assert path.endswith("web.db") + + +class TestLookupImagePath: + def test_nonexistent_id_returns_none(self): + assert _lookup_image_path(99999) is None + + def test_negative_id_returns_none(self): + assert _lookup_image_path(-1) is None diff --git a/tests/test_orientation.py b/tests/test_orientation.py new file mode 100644 index 0000000..9e3c65f --- /dev/null +++ b/tests/test_orientation.py @@ -0,0 +1,50 @@ +import os + +from PIL import Image as PILImage + +from spectra.display import InkyDisplay + + +class TestInkyDisplayOrientation: + def test_effective_dimensions_landscape(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation=0) + assert d.effective_width == 800 + assert d.effective_height == 480 + + def test_effective_dimensions_portrait(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation=90) + assert d.effective_width == 480 + assert d.effective_height == 800 + + def test_output_size_0(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation=0) + r = d.process_image(PILImage.new("RGB", (1600, 1200))) + assert r.size == (800, 480) + + def test_output_size_90(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation=90) + r = d.process_image(PILImage.new("RGB", (1600, 1200))) + assert r.size == (800, 480) + + def test_output_size_180(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation=180) + r = d.process_image(PILImage.new("RGB", (1600, 1200))) + assert r.size == (800, 480) + + def test_output_size_270(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation=270) + r = d.process_image(PILImage.new("RGB", (1600, 1200))) + assert r.size == (800, 480) + + def test_string_orientation_coerced(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation="90") + assert d.orientation == 90 + + def test_none_orientation_defaults_to_zero(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation=None) + assert d.orientation == 0 + + def test_show_writes_file(self): + d = InkyDisplay(simulate=True, width=800, height=480, orientation=90) + d.show(PILImage.new("RGB", (1600, 1200))) + assert os.path.exists("/tmp/spectra_last.png") diff --git a/tests/test_trigger.py b/tests/test_trigger.py new file mode 100644 index 0000000..ba267d4 --- /dev/null +++ b/tests/test_trigger.py @@ -0,0 +1,106 @@ +import json +import os +import tempfile +from pathlib import Path + +import pytest + +from spectra.trigger import ( + _trigger_path, + cache_dir, + clear_trigger, + read_and_clear_trigger, + read_trigger, + write_trigger, +) + + +@pytest.fixture(autouse=True) +def isolate_cache(monkeypatch, tmp_path): + cache = tmp_path / "cache" + cache.mkdir() + monkeypatch.setenv("SPECTRA_CACHE_DIR", str(cache)) + yield + monkeypatch.delenv("SPECTRA_CACHE_DIR", raising=False) + + +class TestWriteRead: + def test_write_then_read(self): + write_trigger({"action": "refresh"}) + data = read_trigger() + assert data == {"action": "refresh"} + + def test_read_empty(self): + assert read_trigger() is None + + def test_write_overwrites(self): + write_trigger({"action": "refresh"}) + write_trigger({"action": "clear"}) + data = read_trigger() + assert data == {"action": "clear"} + + def test_write_complex_data(self): + write_trigger({"action": "show_upload", "path": "/tmp/test.png", "image_id": 42}) + data = read_trigger() + assert data["action"] == "show_upload" + assert data["path"] == "/tmp/test.png" + assert data["image_id"] == 42 + + +class TestClear: + def test_clear_trigger(self): + write_trigger({"action": "refresh"}) + clear_trigger() + assert read_trigger() is None + + def test_clear_missing_trigger(self): + clear_trigger() + + def test_clear_then_read(self): + write_trigger({"action": "refresh"}) + clear_trigger() + assert read_trigger() is None + + +class TestReadAndClear: + def test_read_and_clear_returns_data(self): + write_trigger({"action": "refresh"}) + data = read_and_clear_trigger() + assert data == {"action": "refresh"} + + def test_read_and_clear_removes_file(self): + write_trigger({"action": "refresh"}) + read_and_clear_trigger() + assert read_trigger() is None + + def test_read_and_clear_nonexistent(self): + assert read_and_clear_trigger() is None + + def test_read_and_clear_is_atomic(self): + write_trigger({"action": "refresh"}) + data = read_and_clear_trigger() + assert data == {"action": "refresh"} + assert read_trigger() is None + + +class TestCacheDir: + def test_cache_dir_uses_env(self, monkeypatch): + monkeypatch.setenv("SPECTRA_CACHE_DIR", "/tmp/spectra-test-cache") + d = cache_dir() + assert d == "/tmp/spectra-test-cache" + + def test_trigger_path_uses_cache_dir(self): + d = cache_dir() + path = _trigger_path() + assert path.startswith(d) + assert path.endswith("trigger.json") + + +class TestCorruptFile: + def test_corrupt_json_returns_none(self): + path = _trigger_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write("{invalid json") + assert read_trigger() is None + assert read_and_clear_trigger() is None