初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
import base64
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
import app.persistence as persistence
|
||||
from app.main import create_app
|
||||
from app.demo_library import DEMO_ANIMATION_ID, DEMO_STATIC_ID, DEMO_RESTRICTED_MESSAGE
|
||||
from app.display.startup_indicator import render_startup_smile
|
||||
from app.templates.store import (
|
||||
TEMPLATE_SCHEMA_VERSION,
|
||||
TemplateError,
|
||||
TemplateStorageFullError,
|
||||
TemplateStore,
|
||||
TemplateValidationError,
|
||||
)
|
||||
|
||||
|
||||
def scene(background=(1, 2, 3), text="TOP"):
|
||||
pixels = bytes(background) * (64 * 64)
|
||||
return {
|
||||
"version": 1,
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"pixelRgb": base64.b64encode(pixels).decode("ascii"),
|
||||
"elements": [{
|
||||
"id": "text-1",
|
||||
"type": "text",
|
||||
"text": text,
|
||||
"font": "default",
|
||||
"size": 12,
|
||||
"x": 4,
|
||||
"y": 24,
|
||||
"align": "left",
|
||||
"color": "#FFFFFF",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_deleting_default_template_immediately_falls_back_to_demo_animation(tmp_path):
|
||||
app = create_app(project_root=tmp_path, driver_kind="mock")
|
||||
with TestClient(app) as client:
|
||||
created = client.post(
|
||||
"/api/templates",
|
||||
json={"name": "即将删除的默认图", "scene": scene(text="DEFAULT")},
|
||||
).json()
|
||||
selected = client.put(
|
||||
"/api/display/default-content",
|
||||
json={"type": "template", "id": created["id"]},
|
||||
)
|
||||
assert selected.status_code == 200
|
||||
|
||||
deleted = client.delete(
|
||||
f"/api/templates/{created['id']}",
|
||||
headers={"If-Match": f'"{created["revision"]}"'},
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert client.get("/api/display/default-content").json()["id"] == DEMO_ANIMATION_ID
|
||||
state = client.get("/api/status").json()["state"]
|
||||
assert state["animation_active"] is True
|
||||
assert state["current_content"]["id"] == DEMO_ANIMATION_ID
|
||||
|
||||
|
||||
def test_template_play_is_temporary_revision_checked_and_supports_demo(tmp_path):
|
||||
app = create_app(project_root=tmp_path, driver_kind="mock")
|
||||
with TestClient(app) as client:
|
||||
created = client.post(
|
||||
"/api/templates",
|
||||
json={"name": "临时播放", "scene": scene(background=(12, 34, 56), text="PLAY")},
|
||||
).json()
|
||||
default_before = client.get("/api/display/default-content").json()
|
||||
|
||||
played = client.post(
|
||||
f"/api/templates/{created['id']}/play",
|
||||
headers={"If-Match": f'"{created["revision"]}"'},
|
||||
json={},
|
||||
)
|
||||
assert played.status_code == 200
|
||||
assert played.json() == {
|
||||
"ok": True,
|
||||
"template_id": created["id"],
|
||||
"revision": created["revision"],
|
||||
}
|
||||
state = client.get("/api/status").json()["state"]
|
||||
assert state["mode"] == "template"
|
||||
assert state["animation_active"] is False
|
||||
assert state["current_content"] == {
|
||||
"category": "template",
|
||||
"id": created["id"],
|
||||
"name": "临时播放",
|
||||
}
|
||||
assert client.get("/api/display/default-content").json() == default_before
|
||||
current_frame = Image.open(BytesIO(client.get("/api/display/current-frame").content)).convert("RGB")
|
||||
thumbnail = Image.open(BytesIO(client.get(created["thumbnail_url"]).content)).convert("RGB")
|
||||
assert current_frame.tobytes() == thumbnail.tobytes()
|
||||
|
||||
before_rejected = current_frame.tobytes()
|
||||
assert client.post(f"/api/templates/{created['id']}/play", json={}).status_code == 428
|
||||
assert client.post(
|
||||
f"/api/templates/{created['id']}/play",
|
||||
headers={"If-Match": '"stale"'},
|
||||
json={},
|
||||
).status_code == 409
|
||||
assert Image.open(BytesIO(client.get("/api/display/current-frame").content)).convert("RGB").tobytes() == before_rejected
|
||||
assert client.post(
|
||||
"/api/templates/00000000-0000-4000-8000-000000000099/play",
|
||||
headers={"If-Match": '"missing"'},
|
||||
json={},
|
||||
).status_code == 404
|
||||
assert client.post(
|
||||
"/api/templates/not-a-uuid/play",
|
||||
headers={"If-Match": '"missing"'},
|
||||
json={},
|
||||
).status_code == 404
|
||||
|
||||
demo = client.get("/api/templates?include_demo=true").json()["templates"][0]
|
||||
demo_play = client.post(
|
||||
f"/api/templates/{DEMO_STATIC_ID}/play",
|
||||
headers={"If-Match": f'"{demo["revision"]}"'},
|
||||
json={},
|
||||
)
|
||||
assert demo_play.status_code == 200
|
||||
demo_state = client.get("/api/status").json()["state"]
|
||||
assert demo_state["current_content"]["id"] == DEMO_STATIC_ID
|
||||
assert client.get("/api/display/default-content").json() == default_before
|
||||
|
||||
|
||||
def test_template_crud_copy_persistence_and_thumbnail_cleanup(tmp_path):
|
||||
app = create_app(project_root=tmp_path, driver_kind="mock")
|
||||
client = TestClient(app)
|
||||
|
||||
created_response = client.post("/api/templates", json={"name": "中文模板", "scene": scene()})
|
||||
assert created_response.status_code == 201
|
||||
created = created_response.json()
|
||||
assert created["name"] == "中文模板"
|
||||
assert created["size_bytes"] > 0
|
||||
assert "schema_version" not in created
|
||||
old_url = created["thumbnail_url"]
|
||||
old_thumbnail_name = f"{created['id']}-{created['digest']}.png"
|
||||
persisted_record = json.loads(
|
||||
(
|
||||
tmp_path
|
||||
/ "data"
|
||||
/ "templates"
|
||||
/ "records"
|
||||
/ f"{created['id']}.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
assert persisted_record["schema_version"] == TEMPLATE_SCHEMA_VERSION
|
||||
|
||||
duplicate_name = client.post("/api/templates", json={"name": "中文模板", "scene": scene()})
|
||||
assert duplicate_name.status_code == 409
|
||||
duplicate_case = client.post("/api/templates", json={"name": "中文模板 ", "scene": scene()})
|
||||
assert duplicate_case.status_code == 409
|
||||
|
||||
thumbnail_response = client.get(old_url)
|
||||
assert thumbnail_response.status_code == 200
|
||||
image = Image.open(BytesIO(thumbnail_response.content))
|
||||
assert image.size == (64, 64)
|
||||
assert image.mode == "RGB"
|
||||
assert image.getpixel((0, 0)) == (1, 2, 3)
|
||||
assert len(set(image.getdata())) > 1
|
||||
|
||||
updated_response = client.put(
|
||||
f"/api/templates/{created['id']}",
|
||||
headers={"If-Match": f'"{created["revision"]}"'},
|
||||
json={"scene": scene(background=(9, 8, 7), text="NEW")},
|
||||
)
|
||||
assert updated_response.status_code == 200
|
||||
updated = updated_response.json()
|
||||
assert updated["digest"] != created["digest"]
|
||||
assert updated["thumbnail_url"] != old_url
|
||||
assert client.get(old_url).status_code == 404
|
||||
assert not (tmp_path / "data" / "templates" / "thumbnails" / old_thumbnail_name).exists()
|
||||
updated_image = Image.open(BytesIO(client.get(updated["thumbnail_url"]).content))
|
||||
assert updated_image.getpixel((0, 0)) == (9, 8, 7)
|
||||
|
||||
renamed = client.patch(
|
||||
f"/api/templates/{created['id']}",
|
||||
headers={"If-Match": f'"{updated["revision"]}"'},
|
||||
json={"name": "新名字"},
|
||||
)
|
||||
assert renamed.status_code == 200
|
||||
renamed_template = renamed.json()
|
||||
assert renamed_template["digest"] == updated["digest"]
|
||||
copied = client.post(f"/api/templates/{created['id']}/copy", json={})
|
||||
assert copied.status_code == 201
|
||||
assert copied.json()["name"] == "新名字 - 副本"
|
||||
copied_again = client.post(f"/api/templates/{created['id']}/copy", json={})
|
||||
assert copied_again.json()["name"] == "新名字 - 副本 2"
|
||||
|
||||
restarted = TestClient(create_app(project_root=tmp_path, driver_kind="mock"))
|
||||
listing = restarted.get("/api/templates").json()
|
||||
assert [item["name"] for item in listing["templates"]] == [
|
||||
"新名字 - 副本 2", "新名字 - 副本", "新名字",
|
||||
]
|
||||
assert listing["storage"]["templates_bytes"] == sum(item["size_bytes"] for item in listing["templates"])
|
||||
assert listing["storage"]["free_bytes"] > 0
|
||||
|
||||
delete_headers = {"If-Match": f'"{renamed_template["revision"]}"'}
|
||||
assert client.delete(f"/api/templates/{created['id']}", headers=delete_headers).json() == {"ok": True}
|
||||
assert client.get(f"/api/templates/{created['id']}").status_code == 404
|
||||
assert client.delete(f"/api/templates/{created['id']}", headers=delete_headers).status_code == 404
|
||||
|
||||
|
||||
def test_demo_static_template_is_first_read_only_and_copies_to_normal_content(tmp_path):
|
||||
client = TestClient(create_app(project_root=tmp_path, driver_kind="mock"))
|
||||
|
||||
assert client.get("/api/templates").json()["templates"] == []
|
||||
listing = client.get("/api/templates?include_demo=true").json()
|
||||
demo = listing["templates"][0]
|
||||
assert demo["id"] == DEMO_STATIC_ID
|
||||
assert demo["name"] == "演示静态图"
|
||||
assert demo["demo_order"] == 1
|
||||
assert demo["read_only"] is True
|
||||
assert demo["size_bytes"] == 0
|
||||
assert listing["storage"]["templates_bytes"] == 0
|
||||
|
||||
detail = client.get(f"/api/templates/{DEMO_STATIC_ID}").json()
|
||||
assert base64.b64decode(detail["scene"]["pixelRgb"]) == render_startup_smile().tobytes()
|
||||
thumbnail = client.get(demo["thumbnail_url"])
|
||||
assert thumbnail.status_code == 200
|
||||
assert Image.open(BytesIO(thumbnail.content)).tobytes() == render_startup_smile().tobytes()
|
||||
|
||||
for method, payload in (("put", {"scene": scene()}), ("patch", {"name": "不能改"})):
|
||||
response = getattr(client, method)(
|
||||
f"/api/templates/{DEMO_STATIC_ID}",
|
||||
headers={"If-Match": f'"{demo["revision"]}"'},
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == DEMO_RESTRICTED_MESSAGE
|
||||
removed = client.delete(
|
||||
f"/api/templates/{DEMO_STATIC_ID}",
|
||||
headers={"If-Match": f'"{demo["revision"]}"'},
|
||||
)
|
||||
assert removed.status_code == 409
|
||||
|
||||
copied = client.post("/api/library/copy", json={
|
||||
"source_type": "template",
|
||||
"source_id": DEMO_STATIC_ID,
|
||||
"source_revision": demo["revision"],
|
||||
"destination_type": "static",
|
||||
})
|
||||
assert copied.status_code == 201
|
||||
copy_item = copied.json()["item"]
|
||||
assert copy_item["id"] != DEMO_STATIC_ID
|
||||
assert copy_item["name"] == "演示静态图 - 副本"
|
||||
assert "read_only" not in copy_item
|
||||
renamed = client.patch(
|
||||
f"/api/templates/{copy_item['id']}",
|
||||
headers={"If-Match": f'"{copy_item["revision"]}"'},
|
||||
json={"name": "我的笑脸"},
|
||||
)
|
||||
assert renamed.status_code == 200
|
||||
assert len(list((tmp_path / "data" / "templates" / "records").glob("*.json"))) == 1
|
||||
|
||||
|
||||
def test_template_preserves_multilingual_text(tmp_path):
|
||||
client = TestClient(create_app(project_root=tmp_path, driver_kind="mock"))
|
||||
multilingual = "你好 25°C"
|
||||
created = client.post(
|
||||
"/api/templates",
|
||||
json={"name": "多语言", "scene": scene(text=multilingual)},
|
||||
)
|
||||
|
||||
assert created.status_code == 201
|
||||
stored = client.get(f"/api/templates/{created.json()['id']}")
|
||||
assert stored.status_code == 200
|
||||
assert stored.json()["scene"]["elements"][0]["text"] == multilingual
|
||||
|
||||
|
||||
def test_template_mutations_require_current_revision(tmp_path):
|
||||
client = TestClient(create_app(project_root=tmp_path, driver_kind="mock"))
|
||||
created = client.post("/api/templates", json={"name": "shared", "scene": scene()}).json()
|
||||
path = f"/api/templates/{created['id']}"
|
||||
original_revision = created["revision"]
|
||||
|
||||
assert client.put(path, json={"scene": scene(text="missing")}).status_code == 428
|
||||
first = client.put(
|
||||
path,
|
||||
headers={"If-Match": f'"{original_revision}"'},
|
||||
json={"scene": scene(text="first")},
|
||||
)
|
||||
assert first.status_code == 200
|
||||
current = first.json()
|
||||
|
||||
stale_headers = {"If-Match": f'"{original_revision}"'}
|
||||
assert client.put(path, headers=stale_headers, json={"scene": scene(text="stale")}).status_code == 409
|
||||
assert client.patch(path, headers=stale_headers, json={"name": "stale name"}).status_code == 409
|
||||
assert client.delete(path, headers=stale_headers).status_code == 409
|
||||
preserved = client.get(path).json()
|
||||
assert preserved["name"] == "shared"
|
||||
assert preserved["scene"]["elements"][0]["text"] == "first"
|
||||
assert preserved["revision"] == current["revision"]
|
||||
|
||||
|
||||
def test_template_validation_and_status_codes(tmp_path, monkeypatch):
|
||||
app = create_app(project_root=tmp_path, driver_kind="mock")
|
||||
client = TestClient(app)
|
||||
broken = scene()
|
||||
broken["pixelRgb"] = "bad"
|
||||
assert client.post("/api/templates", json={"name": "bad", "scene": broken}).status_code == 422
|
||||
|
||||
duplicate_ids = scene()
|
||||
duplicate_ids["elements"].append(dict(duplicate_ids["elements"][0]))
|
||||
assert client.post("/api/templates", json={"name": "bad ids", "scene": duplicate_ids}).status_code == 422
|
||||
assert client.get("/api/templates/not-a-uuid").status_code == 404
|
||||
|
||||
def full(*_args, **_kwargs):
|
||||
raise TemplateStorageFullError("not enough disk space to save template")
|
||||
|
||||
monkeypatch.setattr(app.state.template_store, "create", full)
|
||||
assert client.post("/api/templates", json={"name": "full", "scene": scene()}).status_code == 507
|
||||
|
||||
|
||||
def test_reconcile_removes_only_strict_thumbnail_artifacts(tmp_path):
|
||||
store = TemplateStore(tmp_path / "data")
|
||||
created = store.create("keep", scene())
|
||||
thumbnail_dir = tmp_path / "data" / "templates" / "thumbnails"
|
||||
orphan = thumbnail_dir / "11111111-1111-1111-1111-111111111111-0123456789abcdef.png"
|
||||
unrelated = thumbnail_dir / "user-picture.png"
|
||||
temporary = thumbnail_dir / ".partial.png.abc.tmp"
|
||||
orphan.write_bytes(b"orphan")
|
||||
unrelated.write_bytes(b"unrelated")
|
||||
temporary.write_bytes(b"temporary")
|
||||
|
||||
TemplateStore(tmp_path / "data")
|
||||
|
||||
assert not orphan.exists()
|
||||
assert not temporary.exists()
|
||||
assert unrelated.read_bytes() == b"unrelated"
|
||||
assert store.thumbnail_path(created["id"], created["digest"]).exists()
|
||||
|
||||
|
||||
def test_legacy_template_migrates_once_without_changing_revision_or_digest(tmp_path):
|
||||
data_dir = tmp_path / "data"
|
||||
original_store = TemplateStore(data_dir)
|
||||
created = original_store.create("legacy", scene())
|
||||
record_path = (
|
||||
data_dir
|
||||
/ "templates"
|
||||
/ "records"
|
||||
/ f"{created['id']}.json"
|
||||
)
|
||||
legacy = json.loads(record_path.read_text(encoding="utf-8"))
|
||||
legacy.pop("schema_version")
|
||||
record_path.write_text(
|
||||
json.dumps(legacy, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
migrated_store = TemplateStore(data_dir)
|
||||
migrated = migrated_store.get(created["id"])
|
||||
migrated_bytes = record_path.read_bytes()
|
||||
|
||||
assert migrated["revision"] == created["revision"]
|
||||
assert migrated["digest"] == created["digest"]
|
||||
assert "schema_version" not in migrated
|
||||
assert json.loads(migrated_bytes)["schema_version"] == TEMPLATE_SCHEMA_VERSION
|
||||
|
||||
TemplateStore(data_dir)
|
||||
assert record_path.read_bytes() == migrated_bytes
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure_kind", ["future", "corrupt", "unknown"])
|
||||
def test_invalid_template_record_blocks_startup_without_deleting_any_thumbnail(
|
||||
tmp_path,
|
||||
failure_kind,
|
||||
):
|
||||
data_dir = tmp_path / "data"
|
||||
initial = TemplateStore(data_dir)
|
||||
created = initial.create("keep", scene())
|
||||
record_path = (
|
||||
data_dir
|
||||
/ "templates"
|
||||
/ "records"
|
||||
/ f"{created['id']}.json"
|
||||
)
|
||||
thumbnail_path = initial.thumbnail_path(created["id"], created["digest"])
|
||||
thumbnail_bytes = thumbnail_path.read_bytes()
|
||||
orphan = (
|
||||
data_dir
|
||||
/ "templates"
|
||||
/ "thumbnails"
|
||||
/ "11111111-1111-1111-1111-111111111111-0123456789abcdef.png"
|
||||
)
|
||||
orphan.write_bytes(b"orphan")
|
||||
|
||||
if failure_kind == "corrupt":
|
||||
original = b"{bad json"
|
||||
else:
|
||||
document = json.loads(record_path.read_text(encoding="utf-8"))
|
||||
if failure_kind == "future":
|
||||
document["schema_version"] = TEMPLATE_SCHEMA_VERSION + 1
|
||||
else:
|
||||
document["unexpected"] = True
|
||||
original = (json.dumps(document, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
record_path.write_bytes(original)
|
||||
|
||||
with pytest.raises(TemplateValidationError, match="invalid template record"):
|
||||
TemplateStore(data_dir)
|
||||
|
||||
assert record_path.read_bytes() == original
|
||||
assert thumbnail_path.read_bytes() == thumbnail_bytes
|
||||
assert orphan.read_bytes() == b"orphan"
|
||||
|
||||
|
||||
def test_template_preflight_validates_every_record_before_migrating_any(tmp_path):
|
||||
data_dir = tmp_path / "data"
|
||||
initial = TemplateStore(data_dir)
|
||||
initial.create("one", scene(text="one"))
|
||||
initial.create("two", scene(text="two"))
|
||||
record_paths = sorted((data_dir / "templates" / "records").glob("*.json"))
|
||||
legacy_path, invalid_path = record_paths
|
||||
legacy_document = json.loads(legacy_path.read_text(encoding="utf-8"))
|
||||
legacy_document.pop("schema_version")
|
||||
legacy_bytes = (
|
||||
json.dumps(legacy_document, ensure_ascii=False, indent=2) + "\n"
|
||||
).encode("utf-8")
|
||||
legacy_path.write_bytes(legacy_bytes)
|
||||
invalid_path.write_bytes(b"{bad json")
|
||||
|
||||
with pytest.raises(TemplateValidationError):
|
||||
TemplateStore(data_dir)
|
||||
|
||||
assert legacy_path.read_bytes() == legacy_bytes
|
||||
|
||||
|
||||
def test_template_migration_write_failure_rolls_back_every_record(tmp_path, monkeypatch):
|
||||
data_dir = tmp_path / "data"
|
||||
initial = TemplateStore(data_dir)
|
||||
initial.create("one", scene(text="one"))
|
||||
initial.create("two", scene(text="two"))
|
||||
record_paths = sorted((data_dir / "templates" / "records").glob("*.json"))
|
||||
legacy_bytes = {}
|
||||
for path in record_paths:
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
document.pop("schema_version")
|
||||
content = (json.dumps(document, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
||||
path.write_bytes(content)
|
||||
legacy_bytes[path] = content
|
||||
|
||||
real_replace = persistence.os.replace
|
||||
replace_calls = 0
|
||||
|
||||
def fail_second_replace(source, target):
|
||||
nonlocal replace_calls
|
||||
replace_calls += 1
|
||||
if replace_calls == 2:
|
||||
raise OSError("second migration replace failed")
|
||||
return real_replace(source, target)
|
||||
|
||||
monkeypatch.setattr(persistence.os, "replace", fail_second_replace)
|
||||
with pytest.raises(TemplateError, match="second migration replace failed"):
|
||||
TemplateStore(data_dir)
|
||||
|
||||
assert {path: path.read_bytes() for path in record_paths} == legacy_bytes
|
||||
assert list((data_dir / "templates" / "records").glob(".*.tmp")) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path_parts", "value"),
|
||||
[
|
||||
(("scene", "version"), True),
|
||||
(("scene", "width"), 64.0),
|
||||
(("scene", "elements", 0, "size"), True),
|
||||
],
|
||||
)
|
||||
def test_current_template_schema_rejects_noncanonical_json_types(
|
||||
tmp_path,
|
||||
path_parts,
|
||||
value,
|
||||
):
|
||||
data_dir = tmp_path / "data"
|
||||
initial = TemplateStore(data_dir)
|
||||
created = initial.create("strict", scene())
|
||||
record_path = (
|
||||
data_dir
|
||||
/ "templates"
|
||||
/ "records"
|
||||
/ f"{created['id']}.json"
|
||||
)
|
||||
document = json.loads(record_path.read_text(encoding="utf-8"))
|
||||
target = document
|
||||
for part in path_parts[:-1]:
|
||||
target = target[part]
|
||||
target[path_parts[-1]] = value
|
||||
original = (json.dumps(document, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
record_path.write_bytes(original)
|
||||
|
||||
with pytest.raises(TemplateValidationError, match="must be an integer"):
|
||||
TemplateStore(data_dir)
|
||||
|
||||
assert record_path.read_bytes() == original
|
||||
Reference in New Issue
Block a user