import time from io import BytesIO from types import SimpleNamespace import pytest from fastapi.testclient import TestClient from PIL import Image from app.main import create_app from app.media.converter import MediaConversionError, converted_frames, transform_frame @pytest.fixture(autouse=True) def deterministic_media_disk_space(monkeypatch): monkeypatch.setattr( "app.media.manager.shutil.disk_usage", lambda _path: SimpleNamespace( total=8 * 1024**3, used=2 * 1024**3, free=6 * 1024**3, ), ) def png_bytes(image: Image.Image) -> bytes: output = BytesIO() image.save(output, "PNG") return output.getvalue() def settings(mode="crop", transparency="#000000", padding="#000000"): return { "fit_mode": mode, "center_x": 0.5, "center_y": 0.5, "zoom": 1.0, "transparency_color": transparency, "padding_color": padding, } def wait_for(client, job_id, state, timeout=5): deadline = time.monotonic() + timeout while time.monotonic() < deadline: response = client.get("/api/media-imports/" + job_id) if response.status_code == 404: if state == "gone": return None raise AssertionError("job disappeared") job = response.json() if job["state"] == "failed": raise AssertionError(job["error"]) if job["state"] == state: return job time.sleep(0.02) raise AssertionError("media job did not reach " + state) def upload_media(client, payload, filename="a.png", name=None): query = "/api/media-imports?filename=" + filename if name is not None: query += "&name=" + name return client.post( query, content=payload, headers={"Content-Type": "application/octet-stream", "Content-Length": str(len(payload))}, ) def test_upload_rejects_when_device_cannot_retain_two_gibibytes(tmp_path, monkeypatch): monkeypatch.setattr( "app.media.manager.shutil.disk_usage", lambda _path: SimpleNamespace( total=4 * 1024**3, used=2 * 1024**3, free=2 * 1024**3, ), ) app = create_app(project_root=tmp_path, driver_kind="mock") payload = png_bytes(Image.new("RGB", (1, 1), "white")) with TestClient(app) as client: response = upload_media(client, payload) assert response.status_code == 507 assert response.json()["detail"] == "device must retain at least 2 GiB free space" def test_contain_keeps_transparency_and_padding_colors_independent(): image = Image.new("RGBA", (2, 1), (255, 0, 0, 0)) rgb = transform_frame(image, settings("contain", "#00FF00", "#0000FF")) output = Image.frombytes("RGB", (64, 64), rgb) assert output.getpixel((0, 0)) == (0, 0, 255) assert output.getpixel((32, 32)) == (0, 255, 0) def test_crop_and_stretch_generate_exact_rgb888(): image = Image.new("RGBA", (160, 90), (12, 34, 56, 255)) for mode in ("crop", "stretch"): rgb = transform_frame(image, settings(mode)) assert len(rgb) == 64 * 64 * 3 output = Image.frombytes("RGB", (64, 64), rgb) assert output.getpixel((32, 32)) == (12, 34, 56) assert Image.frombytes("RGB", (64, 64), transform_frame(image, settings("stretch"))).getpixel((0, 0)) == (12, 34, 56) def test_free_crop_starts_with_the_complete_non_square_source(): image = Image.new("RGB", (8, 4), "red") for x in range(4, 8): for y in range(4): image.putpixel((x, y), (0, 255, 0)) rgb = transform_frame(image, settings("crop", padding="#0000FF")) output = Image.frombytes("RGB", (64, 64), rgb) assert output.getpixel((32, 0)) == (0, 0, 255) assert output.getpixel((8, 32))[0] > 240 assert output.getpixel((56, 32))[1] > 240 assert output.getpixel((32, 63)) == (0, 0, 255) def test_free_crop_uses_separate_transparency_and_void_colors(): image = Image.new("RGBA", (2, 1), (255, 0, 0, 0)) rgb = transform_frame(image, settings("crop", "#00FF00", "#0000FF")) output = Image.frombytes("RGB", (64, 64), rgb) assert output.getpixel((32, 0)) == (0, 0, 255) assert output.getpixel((32, 32)) == (0, 255, 0) def test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner(): image = Image.new("RGBA", (2, 1), (255, 0, 0, 255)) cases = [ (-31 / 64, -31 / 32, (63, 63)), (95 / 64, -31 / 32, (0, 63)), (-31 / 64, 63 / 32, (63, 0)), (95 / 64, 63 / 32, (0, 0)), ] for center_x, center_y, visible_at in cases: value = settings("crop", padding="#0000FF") value.update({"center_x": center_x, "center_y": center_y}) output = Image.frombytes("RGB", (64, 64), transform_frame(image, value)) assert output.getpixel(visible_at)[0] > 240 assert sum(pixel[0] > 240 for pixel in output.getdata()) == 1 def test_free_crop_zoom_selects_a_non_center_region_and_rejects_an_empty_view(): image = Image.new("RGB", (8, 4), "red") for x in range(4, 8): for y in range(4): image.putpixel((x, y), (0, 255, 0)) value = settings("crop", padding="#0000FF") value.update({"zoom": 2, "center_x": 0.25}) output = Image.frombytes("RGB", (64, 64), transform_frame(image, value)) assert output.getpixel((16, 32))[0] > 240 assert output.getpixel((48, 32))[0] > 240 value["center_x"] = -2 try: transform_frame(image, value) except MediaConversionError as exc: assert "at least one output pixel" in str(exc) else: raise AssertionError("fully empty free framing must be rejected") def test_static_upload_analysis_conversion_and_source_cleanup(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") payload = png_bytes(Image.new("RGBA", (80, 40), (220, 10, 30, 128))) with TestClient(app) as client: uploaded = client.post( "/api/media-imports?filename=wrong-extension.bin&name=导入静态图", content=payload, headers={"Content-Type": "application/octet-stream", "Content-Length": str(len(payload))}, ) assert uploaded.status_code == 202 job_id = uploaded.json()["id"] job = wait_for(client, job_id, "awaiting_settings") assert job["metadata"]["decoder"] == "pillow" assert job["metadata"]["dynamic"] is False assert len(job["previews"]) == 1 updated = client.put( "/api/media-imports/" + job_id + "/settings", json={ "fit_mode": "contain", "center_x": 0.5, "center_y": 0.5, "zoom": 1, "transparency_color": "#00FF00", "padding_color": "#0000FF", }, ) assert updated.status_code == 200 assert client.post("/api/media-imports/" + job_id + "/convert").status_code == 202 wait_for(client, job_id, "gone") templates = client.get("/api/templates").json()["templates"] assert any(item["name"] == "导入静态图" for item in templates) assert not (tmp_path / "data" / "media-import" / "jobs" / job_id).exists() def test_identical_uploads_use_independent_jobs_and_default_names(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") payload = png_bytes(Image.new("RGB", (8, 4), "white")) with TestClient(app) as client: first = upload_media(client, payload, "同一素材.test.png") second = upload_media(client, payload, "同一素材.test.png") assert first.status_code == second.status_code == 202 first_id, second_id = first.json()["id"], second.json()["id"] assert first_id != second_id first_job = wait_for(client, first_id, "awaiting_settings") second_job = wait_for(client, second_id, "awaiting_settings") assert first_job["name"] == second_job["name"] == "同一素材.test" first_source = tmp_path / "data" / "media-import" / "jobs" / first_id / "source.bin" second_source = tmp_path / "data" / "media-import" / "jobs" / second_id / "source.bin" assert first_source.read_bytes() == second_source.read_bytes() == payload assert client.put( "/api/media-imports/" + first_id + "/settings", json={"name": "素材左侧", "center_x": 0.25}, ).status_code == 200 assert client.put( "/api/media-imports/" + second_id + "/settings", json={"name": "素材右侧", "center_x": 0.75}, ).status_code == 200 assert client.post("/api/media-imports/" + first_id + "/convert").status_code == 202 assert client.post("/api/media-imports/" + second_id + "/convert").status_code == 202 wait_for(client, first_id, "gone") wait_for(client, second_id, "gone") names = {item["name"] for item in client.get("/api/templates").json()["templates"]} assert {"素材左侧", "素材右侧"} <= names def test_default_upload_name_falls_back_and_legacy_name_is_kept(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") payload = png_bytes(Image.new("RGB", (1, 1), "white")) with TestClient(app) as client: fallback = upload_media(client, payload, ".png") legacy = upload_media(client, payload, "ignored.png", "旧客户端名称") assert wait_for(client, fallback.json()["id"], "awaiting_settings")["name"] == "未命名媒体" assert wait_for(client, legacy.json()["id"], "awaiting_settings")["name"] == "旧客户端名称" def test_active_jobs_reserve_names_and_rejected_job_stays_editable(tmp_path, monkeypatch): app = create_app(project_root=tmp_path, driver_kind="mock") payload = png_bytes(Image.new("RGB", (1, 1), "white")) with TestClient(app) as client: first = upload_media(client, payload) second = upload_media(client, payload) first_id, second_id = first.json()["id"], second.json()["id"] wait_for(client, first_id, "awaiting_settings") wait_for(client, second_id, "awaiting_settings") monkeypatch.setattr(app.state.media_import_manager, "_schedule", lambda *_args: None) assert client.put( "/api/media-imports/" + first_id + "/settings", json={"name": "Unique Name"}, ).status_code == 200 assert client.post("/api/media-imports/" + first_id + "/convert").status_code == 202 conflict = client.put( "/api/media-imports/" + second_id + "/settings", json={"name": " unique name "}, ) assert conflict.status_code == 409 assert conflict.json()["detail"] == "output name already exists" editable = client.get("/api/media-imports/" + second_id).json() assert editable["state"] == "awaiting_settings" assert editable["name"] == "a" assert client.delete("/api/media-imports/" + first_id).status_code == 200 renamed = client.put( "/api/media-imports/" + second_id + "/settings", json={"name": "Unique Name"}, ) assert renamed.status_code == 200 def test_restarted_manager_keeps_queued_name_reserved(tmp_path, monkeypatch): payload = png_bytes(Image.new("RGB", (1, 1), "white")) first_app = create_app(project_root=tmp_path, driver_kind="mock") with TestClient(first_app) as first_client: first = upload_media(first_client, payload) first_id = first.json()["id"] wait_for(first_client, first_id, "awaiting_settings") monkeypatch.setattr(first_app.state.media_import_manager, "_schedule", lambda *_args: None) assert first_client.put( "/api/media-imports/" + first_id + "/settings", json={"name": "重启占用"}, ).status_code == 200 assert first_client.post("/api/media-imports/" + first_id + "/convert").status_code == 202 restarted_app = create_app(project_root=tmp_path, driver_kind="mock") manager = restarted_app.state.media_import_manager while not manager._queue.empty(): manager._queue.get_nowait() with TestClient(restarted_app) as restarted_client: second = upload_media(restarted_client, payload) second_id = second.json()["id"] wait_for(restarted_client, second_id, "awaiting_settings") conflict = restarted_client.put( "/api/media-imports/" + second_id + "/settings", json={"name": "重启占用"}, ) assert conflict.status_code == 409 assert restarted_client.get("/api/media-imports/" + first_id).json()["state"] == "queued" def test_media_name_conflicts_with_templates_and_animations_across_types(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") payload = png_bytes(Image.new("RGB", (1, 1), "white")) app.state.animation_store.create("已有动图") with TestClient(app) as client: client.post("/api/templates", json={ "name": "已有模板", "scene": { "version": 1, "width": 64, "height": 64, "pixelRgb": "AAAA" * 4096, "elements": [], }, }) for existing in ("已有动图", "已有模板"): uploaded = upload_media(client, payload) job_id = uploaded.json()["id"] wait_for(client, job_id, "awaiting_settings") conflict = client.put( "/api/media-imports/" + job_id + "/settings", json={"name": " " + existing.swapcase() + " "}, ) assert conflict.status_code == 409 assert client.get("/api/media-imports/" + job_id).json()["state"] == "awaiting_settings" def test_upload_contract_and_name_conflict_are_rejected_before_conversion(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") payload = png_bytes(Image.new("RGB", (1, 1), "white")) with TestClient(app) as client: assert client.post( "/api/media-imports?filename=a.png&name=A", content=payload, headers={"Content-Type": "image/png"}, ).status_code == 415 client.post("/api/templates", json={ "name": "冲突", "scene": { "version": 1, "width": 64, "height": 64, "pixelRgb": "AAAA" * 4096, "elements": [], }, }) uploaded = client.post( "/api/media-imports?filename=a.png&name=冲突", content=payload, headers={"Content-Type": "application/octet-stream", "Content-Length": str(len(payload))}, ) job_id = uploaded.json()["id"] wait_for(client, job_id, "awaiting_settings") response = client.post("/api/media-imports/" + job_id + "/convert") assert response.status_code == 409 def test_media_settings_api_accepts_outside_centers_but_rejects_an_empty_view(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") payload = png_bytes(Image.new("RGB", (2, 1), "white")) with TestClient(app) as client: uploaded = client.post( "/api/media-imports?filename=a.png&name=自由取景", content=payload, headers={"Content-Type": "application/octet-stream", "Content-Length": str(len(payload))}, ) job_id = uploaded.json()["id"] wait_for(client, job_id, "awaiting_settings") accepted = client.put( "/api/media-imports/" + job_id + "/settings", json={"center_x": -31 / 64, "center_y": -31 / 32}, ) assert accepted.status_code == 200 rejected = client.put( "/api/media-imports/" + job_id + "/settings", json={"center_x": -2}, ) assert rejected.status_code == 400 assert "at least one output pixel" in rejected.json()["detail"]