初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -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