初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
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"]
|
||||
Reference in New Issue
Block a user