508 lines
22 KiB
Python
508 lines
22 KiB
Python
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-*"))
|