First commit with entire first rendition of the project
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
65
tests/conftest.py
Normal file
65
tests/conftest.py
Normal file
@@ -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
|
||||
353
tests/test_api.py
Normal file
353
tests/test_api.py
Normal file
@@ -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 = '<script>alert("xss")</script>'
|
||||
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
|
||||
101
tests/test_config.py
Normal file
101
tests/test_config.py
Normal file
@@ -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)
|
||||
135
tests/test_display.py
Normal file
135
tests/test_display.py
Normal file
@@ -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)
|
||||
38
tests/test_gallery.py
Normal file
38
tests/test_gallery.py
Normal file
@@ -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
|
||||
15
tests/test_mqtt.py
Normal file
15
tests/test_mqtt.py
Normal file
@@ -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
|
||||
50
tests/test_orientation.py
Normal file
50
tests/test_orientation.py
Normal file
@@ -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")
|
||||
106
tests/test_trigger.py
Normal file
106
tests/test_trigger.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user