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-*"))