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

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,507 @@
import base64
import json
import time
from io import BytesIO
import pytest
from fastapi.testclient import TestClient
from PIL import Image
from app.animations.constants import MAX_DURATION_MS
from app.animations.store import AnimationStore
from app.demo_library import DEMO_ANIMATION_ID, DEMO_RESTRICTED_MESSAGE
from app.display.startup_indicator import render_startup_smile
from app.main import create_app
from app.templates.store import TemplateValidationError
def scene(color):
return {
"version": 1,
"width": 64,
"height": 64,
"pixelRgb": base64.b64encode(bytes(color) * (64 * 64)).decode("ascii"),
"elements": [],
}
def test_animation_metadata_size_cache_tracks_revision(tmp_path):
store = AnimationStore(tmp_path)
created = store.create_from_frames("容量缓存", [{
"scene": scene((1, 2, 3)), "duration_ms": 50,
}])
cached_revision, cached_size = store._metadata_size_cache[created["id"]]
assert cached_revision == created["revision"]
assert cached_size == created["size_bytes"]
renamed = store.rename(created["id"], "容量缓存新版", created["revision"])
next_revision, next_size = store._metadata_size_cache[created["id"]]
assert next_revision == renamed["revision"]
assert next_revision != cached_revision
assert next_size == renamed["size_bytes"]
listed = store.list()
listed["animations"].clear()
assert store.list()["animations"][0]["name"] == "容量缓存新版"
renamed_again = store.rename(created["id"], "容量缓存最终版", renamed["revision"])
assert store.list()["animations"][0]["revision"] == renamed_again["revision"]
def test_animation_store_primes_first_listing_during_startup(tmp_path, monkeypatch):
store = AnimationStore(tmp_path)
created = store.create_from_frames("启动缓存", [{
"scene": scene((4, 5, 6)), "duration_ms": 50,
}])
restarted = AnimationStore(tmp_path)
monkeypatch.setattr(
restarted,
"_all",
lambda: (_ for _ in ()).throw(AssertionError("listing re-read persisted frames")),
)
listing = restarted.list()
assert listing["animations"][0]["id"] == created["id"]
def test_animation_list_cache_tracks_commits_from_another_store(tmp_path):
reader = AnimationStore(tmp_path)
writer = AnimationStore(tmp_path, cleanup_playback=False)
assert reader.list()["animations"] == []
created = writer.create_from_frames("外部转换", [{
"scene": scene((7, 8, 9)), "duration_ms": 50,
}])
assert [item["id"] for item in reader.list()["animations"]] == [created["id"]]
renamed = writer.rename(created["id"], "外部转换新版", created["revision"])
assert reader.list()["animations"][0]["name"] == "外部转换新版"
writer.delete(created["id"], renamed["revision"])
assert reader.list()["animations"] == []
def test_animation_api_sees_an_external_store_commit_without_restart(tmp_path):
app = create_app(project_root=tmp_path, driver_kind="mock")
with TestClient(app) as client:
assert client.get("/api/animations").json()["animations"] == []
external = AnimationStore(app.state.config_store.data_dir, cleanup_playback=False)
created = external.create_from_frames("独立转换进程", [{
"scene": scene((10, 11, 12)), "duration_ms": 50,
}])
listed = client.get("/api/animations").json()["animations"]
assert [item["id"] for item in listed] == [created["id"]]
def test_default_animation_delete_and_last_frame_delete_fall_back(tmp_path):
app = create_app(project_root=tmp_path, driver_kind="mock")
with TestClient(app) as client:
created = client.post("/api/animations", json={"name": "默认用户动图"}).json()
with_frame = client.post(
f"/api/animations/{created['id']}/frames",
headers={"If-Match": f'"{created["revision"]}"'},
json={"scene": scene((9, 8, 7)), "duration_ms": 200},
).json()
assert client.put(
"/api/display/default-content",
json={"type": "animation", "id": created["id"]},
).status_code == 200
deleted = client.delete(
f"/api/animations/{created['id']}",
headers={"If-Match": f'"{with_frame["revision"]}"'},
)
assert deleted.status_code == 200
assert client.get("/api/display/default-content").json()["id"] == DEMO_ANIMATION_ID
second = client.post("/api/animations", json={"name": "删空默认动图"}).json()
second = client.post(
f"/api/animations/{second['id']}/frames",
headers={"If-Match": f'"{second["revision"]}"'},
json={"scene": scene((4, 5, 6)), "duration_ms": 300},
).json()
assert client.put(
"/api/display/default-content",
json={"type": "animation", "id": second["id"]},
).status_code == 200
only_frame = second["frames"][0]
emptied = client.delete(
f"/api/animations/{second['id']}/frames/{only_frame['id']}",
headers={"If-Match": f'"{second["revision"]}"'},
)
assert emptied.status_code == 200
assert emptied.json()["frame_count"] == 0
assert client.get("/api/display/default-content").json()["id"] == DEMO_ANIMATION_ID
def test_animation_crud_reorder_duration_thumbnail_and_persistence(tmp_path):
app = create_app(project_root=tmp_path, driver_kind="mock")
client = TestClient(app)
created = client.post("/api/animations", json={"name": "眨眼"}).json()
assert created["frame_count"] == 0
first = client.post(
f"/api/animations/{created['id']}/frames",
headers={"If-Match": f'"{created["revision"]}"'},
json={"scene": scene((255, 0, 0)), "duration_ms": 1000, "name": "睁眼"},
).json()
second = client.post(
f"/api/animations/{created['id']}/frames",
headers={"If-Match": f'"{first["revision"]}"'},
json={"scene": scene((0, 255, 0)), "duration_ms": 200},
).json()
assert [item["position"] for item in second["frames"]] == [1, 2]
thumb = client.get(second["frames"][0]["thumbnail_url"])
assert thumb.status_code == 200
assert Image.open(BytesIO(thumb.content)).getpixel((0, 0)) == (255, 0, 0)
thumbnail_path = second["frames"][0]["thumbnail_url"].split("?", 1)[0]
assert client.get(f"{thumbnail_path}?v=0000000000000000").status_code == 404
reordered = client.put(
f"/api/animations/{created['id']}/frame-order",
headers={"If-Match": f'"{second["revision"]}"'},
json={"frame_ids": [second["frames"][1]["id"], second["frames"][0]["id"]]},
).json()
assert [item["id"] for item in reordered["frames"]] == [second["frames"][1]["id"], second["frames"][0]["id"]]
durations = client.put(
f"/api/animations/{created['id']}/frame-durations",
headers={"If-Match": f'"{reordered["revision"]}"'},
json={"duration_ms": 500},
).json()
assert [item["duration_ms"] for item in durations["frames"]] == [500, 500]
renamed = client.patch(
f"/api/animations/{created['id']}",
headers={"If-Match": f'"{durations["revision"]}"'},
json={"name": "眨眼新版"},
).json()
assert renamed["name"] == "眨眼新版"
assert [item["name"] for item in renamed["frames"]] == [None, "睁眼"]
assert client.put(
f"/api/animations/{created['id']}/frame-order",
headers={"If-Match": f'"{renamed["revision"]}"'},
json={"frame_ids": [renamed["frames"][0]["id"]]},
).status_code == 422
restarted = create_app(project_root=tmp_path, driver_kind="mock")
persisted = TestClient(restarted).get(f"/api/animations/{created['id']}").json()
assert persisted["frame_count"] == 2
document = json.loads((tmp_path / "data" / "animations" / created["id"] / "animation.json").read_text(encoding="utf-8"))
assert document["schema_version"] == 2
assert all("scene" not in frame and "scene_file" in frame for frame in document["frames"])
assert len(list((tmp_path / "data" / "animations" / created["id"] / "scenes").glob("*.json"))) == 2
def test_animation_copy_play_snapshot_status_and_replace(tmp_path):
app = create_app(project_root=tmp_path, driver_kind="mock")
client = TestClient(app)
template = client.post("/api/templates", json={"name": "红色", "scene": scene((255, 0, 0))}).json()
animation = client.post("/api/animations", json={"name": "循环"}).json()
copied = client.post("/api/library/copy", json={
"source_type": "template", "source_id": template["id"], "source_revision": template["revision"],
"destination_type": "animation", "destination_animation_id": animation["id"], "destination_revision": animation["revision"],
})
assert copied.status_code == 201
detail = copied.json()["item"]
assert detail["frames"][0]["duration_ms"] == 500
play = client.post(f"/api/animations/{animation['id']}/play", headers={"If-Match": f'"{detail["revision"]}"'}, json={})
assert play.status_code == 200
state = client.get("/api/status").json()["state"]
assert state["animation_active"] is True
assert state["animation_id"] == animation["id"]
assert state["mode"] == "animation"
assert client.delete(f"/api/animations/{animation['id']}", headers={"If-Match": f'"{detail["revision"]}"'}).status_code == 409
assert client.post("/api/display/fill", json={"color": "#0000FF"}).status_code == 200
assert client.get("/api/status").json()["state"]["animation_active"] is False
assert client.delete(f"/api/animations/{animation['id']}", headers={"If-Match": f'"{detail["revision"]}"'}).status_code == 200
def test_demo_animation_is_second_read_only_and_deep_copies_to_normal_animation(tmp_path):
client = TestClient(create_app(project_root=tmp_path, driver_kind="mock"))
assert client.get("/api/animations").json()["animations"] == []
listing = client.get("/api/animations?include_demo=true").json()
demo = listing["animations"][0]
assert demo["id"] == DEMO_ANIMATION_ID
assert demo["name"] == "演示动图"
assert demo["demo_order"] == 2
assert demo["read_only"] is True
assert demo["frame_count"] == 2
assert demo["total_duration_ms"] == 1500
assert listing["storage"]["animations_bytes"] == 0
detail = client.get(f"/api/animations/{DEMO_ANIMATION_ID}").json()
assert [frame["duration_ms"] for frame in detail["frames"]] == [1300, 200]
frames = [client.get(f"/api/animations/{DEMO_ANIMATION_ID}/frames/{frame['id']}").json() for frame in detail["frames"]]
assert base64.b64decode(frames[0]["scene"]["pixelRgb"]) == render_startup_smile().tobytes()
assert base64.b64decode(frames[1]["scene"]["pixelRgb"]) == render_startup_smile(eyes_closed=True).tobytes()
rename = client.patch(
f"/api/animations/{DEMO_ANIMATION_ID}",
headers={"If-Match": f'"{demo["revision"]}"'},
json={"name": "不能改"},
)
assert rename.status_code == 409
assert rename.json()["detail"] == DEMO_RESTRICTED_MESSAGE
frame_update = client.patch(
f"/api/animations/{DEMO_ANIMATION_ID}/frames/{detail['frames'][0]['id']}",
headers={"If-Match": f'"{demo["revision"]}"'},
json={"duration_ms": 800},
)
assert frame_update.status_code == 409
removed = client.delete(
f"/api/animations/{DEMO_ANIMATION_ID}",
headers={"If-Match": f'"{demo["revision"]}"'},
)
assert removed.status_code == 409
copied = client.post(f"/api/animations/{DEMO_ANIMATION_ID}/copy", json={})
assert copied.status_code == 201
copy_item = copied.json()
assert copy_item["id"] != DEMO_ANIMATION_ID
assert copy_item["name"] == "演示动图 - 副本"
assert [frame["duration_ms"] for frame in copy_item["frames"]] == [1300, 200]
assert all(frame["id"] not in {item["id"] for item in detail["frames"]} for frame in copy_item["frames"])
renamed = client.patch(
f"/api/animations/{copy_item['id']}",
headers={"If-Match": f'"{copy_item["revision"]}"'},
json={"name": "我的笑脸动图"},
)
assert renamed.status_code == 200
assert len(list((tmp_path / "data" / "animations").glob("*/animation.json"))) == 1
def test_animation_play_rejects_empty_and_corrupt_record_is_preserved(tmp_path):
app = create_app(project_root=tmp_path, driver_kind="mock")
client = TestClient(app)
empty = client.post("/api/animations", json={"name": "空"}).json()
assert client.post(f"/api/animations/{empty['id']}/play", headers={"If-Match": f'"{empty["revision"]}"'}, json={}).status_code == 409
path = tmp_path / "data" / "animations" / empty["id"] / "animation.json"
original = b'{"schema_version":99}\n'
path.write_bytes(original)
with pytest.raises(TemplateValidationError):
AnimationStore(tmp_path / "data")
assert path.read_bytes() == original
def test_display_animation_uses_synchronized_frames_and_does_not_tick_revision(tmp_path):
app = create_app(project_root=tmp_path, driver_kind="mock")
service = app.state.display_service
calls = []
original = service.driver.set_image
def recording(image, *, synchronized=False):
calls.append(synchronized)
return original(image, synchronized=synchronized)
service.driver.set_image = recording
before = service.get_status()["state"]["revision"]
service.start_animation("animation-a", "revision-a", [
{"id": "a", "image": Image.new("RGB", (64, 64), "red"), "duration_ms": 50},
{"id": "b", "image": Image.new("RGB", (64, 64), "green"), "duration_ms": 50},
])
try:
time.sleep(0.13)
status = service.get_status()["state"]
assert status["revision"] == before + 1
assert status["animation_active"] is True
assert calls and all(calls)
service.set_brightness(23)
assert service.get_status()["state"]["animation_active"] is True
finally:
service.close()
def test_display_animation_accepts_seven_days_and_close_interrupts_wait(tmp_path):
service = create_app(project_root=tmp_path, driver_kind="mock").state.display_service
service.start_animation("animation-long", "revision-long", [
{"id": "long", "image": Image.new("RGB", (64, 64), "red"), "duration_ms": MAX_DURATION_MS},
])
started = time.monotonic()
service.close()
assert time.monotonic() - started < 1
def test_animation_duration_limits_and_selected_batch_update(tmp_path):
client = TestClient(create_app(project_root=tmp_path, driver_kind="mock"))
animation = client.post("/api/animations", json={"name": "长时长"}).json()
first = client.post(
f"/api/animations/{animation['id']}/frames",
headers={"If-Match": f'"{animation["revision"]}"'},
json={"scene": scene((1, 2, 3)), "duration_ms": 50, "name": "一"},
).json()
second = client.post(
f"/api/animations/{animation['id']}/frames",
headers={"If-Match": f'"{first["revision"]}"'},
json={"scene": scene((4, 5, 6)), "duration_ms": MAX_DURATION_MS, "name": "二"},
).json()
third = client.post(
f"/api/animations/{animation['id']}/frames",
headers={"If-Match": f'"{second["revision"]}"'},
json={"scene": scene((7, 8, 9)), "duration_ms": 500, "name": "三"},
).json()
assert client.post(
f"/api/animations/{animation['id']}/frames",
headers={"If-Match": f'"{third["revision"]}"'},
json={"scene": scene((0, 0, 0)), "duration_ms": MAX_DURATION_MS + 1},
).status_code == 422
selected = client.put(
f"/api/animations/{animation['id']}/frame-durations",
headers={"If-Match": f'"{third["revision"]}"'},
json={"duration_ms": 1234, "frame_ids": [third["frames"][0]["id"], third["frames"][2]["id"]]},
)
assert selected.status_code == 200
selected_detail = selected.json()
assert [frame["duration_ms"] for frame in selected_detail["frames"]] == [1234, MAX_DURATION_MS, 1234]
assert client.put(
f"/api/animations/{animation['id']}/frame-durations",
headers={"If-Match": f'"{selected_detail["revision"]}"'},
json={"duration_ms": 700, "frame_ids": []},
).status_code == 422
assert client.put(
f"/api/animations/{animation['id']}/frame-durations",
headers={"If-Match": f'"{selected_detail["revision"]}"'},
json={"duration_ms": 700, "frame_ids": [selected_detail["frames"][0]["id"]] * 2},
).status_code == 422
all_updated = client.put(
f"/api/animations/{animation['id']}/frame-durations",
headers={"If-Match": f'"{selected_detail["revision"]}"'},
json={"duration_ms": 900},
).json()
assert [frame["duration_ms"] for frame in all_updated["frames"]] == [900, 900, 900]
def test_animation_batch_copy_positions_and_batch_delete(tmp_path):
client = TestClient(create_app(project_root=tmp_path, driver_kind="mock"))
source = client.post("/api/animations", json={"name": "来源"}).json()
for index, (color, duration) in enumerate([((255, 0, 0), 100), ((0, 255, 0), 200), ((0, 0, 255), 300)], 1):
source = client.post(
f"/api/animations/{source['id']}/frames",
headers={"If-Match": f'"{source["revision"]}"'},
json={"scene": scene(color), "duration_ms": duration, "name": f"来源{index}"},
).json()
destination = client.post("/api/animations", json={"name": "目标"}).json()
for index, color in enumerate([((10, 10, 10)), ((20, 20, 20))], 1):
destination = client.post(
f"/api/animations/{destination['id']}/frames",
headers={"If-Match": f'"{destination["revision"]}"'},
json={"scene": scene(color), "duration_ms": 500, "name": f"目标{index}"},
).json()
copied_response = client.post(
f"/api/animations/{source['id']}/frames/copy",
headers={"If-Match": f'"{source["revision"]}"'},
json={
"frame_ids": [source["frames"][2]["id"], source["frames"][0]["id"]],
"destination_animation_id": destination["id"],
"destination_revision": destination["revision"],
"insert_after_frame_id": destination["frames"][0]["id"],
},
)
assert copied_response.status_code == 201
copied = copied_response.json()
detail = copied["animation"]
assert [frame["name"] for frame in detail["frames"]] == ["目标1", "来源1", "来源3", "目标2"]
assert [frame["duration_ms"] for frame in detail["frames"]] == [500, 100, 300, 500]
assert copied["copied_frame_ids"] == [detail["frames"][1]["id"], detail["frames"][2]["id"]]
assert not set(copied["copied_frame_ids"]) & {frame["id"] for frame in source["frames"]}
assert all(client.get(frame["thumbnail_url"]).status_code == 200 for frame in detail["frames"][1:3])
same_copy = client.post(
f"/api/animations/{source['id']}/frames/copy",
headers={"If-Match": f'"{source["revision"]}"'},
json={
"frame_ids": [source["frames"][1]["id"]],
"destination_animation_id": source["id"],
"destination_revision": source["revision"],
"insert_after_frame_id": None,
},
)
assert same_copy.status_code == 201
same_detail = same_copy.json()["animation"]
assert [frame["name"] for frame in same_detail["frames"]][:2] == ["来源2", "来源1"]
duplicate = client.post(
f"/api/animations/{source['id']}/frames/copy",
headers={"If-Match": f'"{same_detail["revision"]}"'},
json={
"frame_ids": [same_detail["frames"][0]["id"]] * 2,
"destination_animation_id": destination["id"],
"destination_revision": detail["revision"],
"insert_after_frame_id": None,
},
)
assert duplicate.status_code == 422
assert client.post(
f"/api/animations/{source['id']}/frames/copy",
headers={"If-Match": f'"{same_detail["revision"]}"'},
json={
"frame_ids": [same_detail["frames"][0]["id"]],
"destination_animation_id": DEMO_ANIMATION_ID,
"destination_revision": "demo",
"insert_after_frame_id": None,
},
).status_code == 409
partial_delete = client.post(
f"/api/animations/{destination['id']}/frames/batch-delete",
headers={"If-Match": f'"{detail["revision"]}"'},
json={"frame_ids": copied["copied_frame_ids"]},
)
assert partial_delete.status_code == 200
remaining = partial_delete.json()
assert [frame["name"] for frame in remaining["frames"]] == ["目标1", "目标2"]
delete_all = client.post(
f"/api/animations/{destination['id']}/frames/batch-delete",
headers={"If-Match": f'"{remaining["revision"]}"'},
json={"frame_ids": [frame["id"] for frame in remaining["frames"]]},
)
assert delete_all.status_code == 200
assert delete_all.json()["frames"] == []
def test_animation_copy_failure_cleans_new_thumbnails(tmp_path, monkeypatch):
store = AnimationStore(tmp_path / "data")
source = store.create("来源")
source = store.add_frame(source["id"], scene((1, 2, 3)), source["revision"], name="一", duration_ms=100)
destination = store.create("目标")
thumbnails = store._thumb_dir(destination["id"])
before = list(thumbnails.glob("*.png")) if thumbnails.exists() else []
def fail_write(_record):
raise TemplateValidationError("forced write failure")
monkeypatch.setattr(store, "_write", fail_write)
with pytest.raises(TemplateValidationError, match="forced write failure"):
store.copy_frames(
source["id"],
[source["frames"][0]["id"]],
source["revision"],
destination["id"],
destination["revision"],
None,
)
after = list(thumbnails.glob("*.png")) if thumbnails.exists() else []
assert after == before
def test_media_batch_animation_is_hidden_until_atomic_directory_commit(tmp_path, monkeypatch):
store = AnimationStore(tmp_path / "data")
original = store._write_at
def fail_metadata(record, directory_id):
if directory_id.startswith(".staging-"):
raise OSError("simulated interruption")
return original(record, directory_id)
monkeypatch.setattr(store, "_write_at", fail_metadata)
with pytest.raises(OSError, match="simulated interruption"):
store.create_from_frames("批量导入", [{
"scene": scene((1, 2, 3)), "duration_ms": 100, "name": None,
}])
assert store.list()["animations"] == []
assert not list(store.root.glob(".staging-*"))