Files
matrix-screen-controller/核桃派软件源代码/tests/test_media_conversion.py
T

358 lines
15 KiB
Python

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"]