Files
2026-06-04 10:08:07 +02:00

436 lines
14 KiB
Python

import base64
import json
import os
from unittest.mock import MagicMock, patch
import pytest
from aiohttp import web
from src.nodes.style_loader import STYLE_TEMPLATE, _apply_loras
from src.utils_loader import safe_relative_path
# --- _apply_loras ---
def make_model_clip():
return MagicMock(), MagicMock()
def test_apply_loras_empty():
model, clip = make_model_clip()
result_model, result_clip = _apply_loras(model, clip, [])
assert result_model is model
assert result_clip is clip
def test_apply_loras_skips_missing(capsys):
model, clip = make_model_clip()
with patch("folder_paths.get_full_path", return_value=None):
result_model, result_clip = _apply_loras(
model, clip, [{"name": "missing.safetensors"}]
)
captured = capsys.readouterr()
assert "not found" in captured.out
assert result_model is model
assert result_clip is clip
def test_apply_loras_skips_empty_name():
model, clip = make_model_clip()
result_model, result_clip = _apply_loras(model, clip, [{"name": ""}])
assert result_model is model
assert result_clip is clip
def test_apply_loras_weight_fallback():
model, clip = make_model_clip()
fake_lora_sd = {}
with (
patch("folder_paths.get_full_path", return_value="/fake/lora.safetensors"),
patch("comfy.utils.load_torch_file", return_value=fake_lora_sd),
patch("comfy.sd.load_lora_for_models", return_value=(model, clip)) as mock_load,
):
_apply_loras(model, clip, [{"name": "lora.safetensors", "weight": 0.8}])
mock_load.assert_called_once_with(model, clip, fake_lora_sd, 0.8, 0.8)
def test_apply_loras_explicit_weights():
model, clip = make_model_clip()
fake_lora_sd = {}
with (
patch("folder_paths.get_full_path", return_value="/fake/lora.safetensors"),
patch("comfy.utils.load_torch_file", return_value=fake_lora_sd),
patch("comfy.sd.load_lora_for_models", return_value=(model, clip)) as mock_load,
):
_apply_loras(
model,
clip,
[{"name": "lora.safetensors", "model_weight": 0.7, "clip_weight": 0.4}],
)
mock_load.assert_called_once_with(model, clip, fake_lora_sd, 0.7, 0.4)
def test_apply_loras_chained():
model1, clip1 = make_model_clip()
model2, clip2 = make_model_clip()
model3, clip3 = make_model_clip()
fake_lora_sd = {}
call_results = [(model2, clip2), (model3, clip3)]
with (
patch("folder_paths.get_full_path", return_value="/fake/lora.safetensors"),
patch("comfy.utils.load_torch_file", return_value=fake_lora_sd),
patch("comfy.sd.load_lora_for_models", side_effect=call_results),
):
result_model, result_clip = _apply_loras(
model1,
clip1,
[
{"name": "lora1.safetensors", "weight": 0.8},
{"name": "lora2.safetensors", "weight": 0.6},
],
)
assert result_model is model3
assert result_clip is clip3
# --- STYLE_TEMPLATE ---
def test_style_template_has_required_keys():
for key in [
"checkpoint",
"vae",
"clip_skip",
"loras",
"quality_tags",
"extra_quality_tags",
"negative_tags",
"steps",
"refiner_step",
"cfg",
]:
assert key in STYLE_TEMPLATE
def test_style_template_loras_empty_list():
assert STYLE_TEMPLATE["loras"] == []
def test_style_template_defaults():
assert STYLE_TEMPLATE["clip_skip"] == -2
assert STYLE_TEMPLATE["cfg"] == 4.0
assert STYLE_TEMPLATE["steps"] == 15
# --- Routes ---
@pytest.fixture
def app(tmp_path):
async def save_handler(request):
body = await request.json()
file = body.get("file", "")
content = body.get("content", "")
rel = safe_relative_path(file)
if not rel:
return web.json_response({"error": "Invalid file path."}, status=400)
try:
json.loads(content)
except json.JSONDecodeError as e:
return web.json_response({"error": f"Invalid JSON: {e}"}, status=400)
path = os.path.join(str(tmp_path), rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
return web.json_response({"ok": True})
async def new_handler(request):
body = await request.json()
file = body.get("file", "").strip()
if not file:
return web.json_response({"error": "File name is required."}, status=400)
if not file.endswith(".json"):
file += ".json"
rel = safe_relative_path(file)
if not rel:
return web.json_response({"error": "Invalid file path."}, status=400)
path = os.path.join(str(tmp_path), rel)
if os.path.exists(path):
return web.json_response({"error": "File already exists."}, status=409)
os.makedirs(os.path.dirname(path), exist_ok=True)
content = json.dumps(STYLE_TEMPLATE, indent=2, ensure_ascii=False)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
return web.json_response(
{"ok": True, "file": rel.replace("\\", "/"), "content": content}
)
async def load_handler(request):
file = request.rel_url.query.get("file", "")
rel = safe_relative_path(file)
if not rel:
return web.json_response({"error": "Invalid file path."}, status=400)
path = os.path.join(str(tmp_path), rel)
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
return web.json_response({"content": content})
except FileNotFoundError:
return web.json_response({"error": "File not found."}, status=404)
async def delete_handler(request):
body = await request.json()
file = body.get("file", "")
rel = safe_relative_path(file)
if not rel:
return web.json_response({"error": "Invalid file path."}, status=400)
path = os.path.join(str(tmp_path), rel)
if not os.path.isfile(path):
return web.json_response({"error": "File not found."}, status=404)
os.remove(path)
return web.json_response({"ok": True})
async def preview_handler(request):
file = request.rel_url.query.get("file", "")
rel = safe_relative_path(file)
if not rel:
return web.json_response({"error": "Invalid path."}, status=400)
base = os.path.splitext(os.path.join(str(tmp_path), rel))[0]
for ext in [".png", ".jpg", ".webp", ".jpeg"]:
path = base + ext
if os.path.isfile(path):
with open(path, "rb") as f:
data = f.read()
b64 = base64.b64encode(data).decode()
mime = (
"image/png"
if ext == ".png"
else "image/jpeg"
if ext in [".jpg", ".jpeg"]
else "image/webp"
)
return web.json_response({"image": f"data:{mime};base64,{b64}"})
return web.json_response({"image": None})
application = web.Application()
application.router.add_post("/styles/save", save_handler)
application.router.add_post("/styles/new", new_handler)
application.router.add_get("/styles/load", load_handler)
application.router.add_post("/styles/delete", delete_handler)
application.router.add_get("/styles/preview", preview_handler)
return application
@pytest.fixture
async def client(app, aiohttp_client):
return await aiohttp_client(app)
# --- /styles/save ---
async def test_save_valid(client, tmp_path):
content = json.dumps({"checkpoint": "model.safetensors"})
resp = await client.post(
"/styles/save", json={"file": "test.json", "content": content}
)
assert resp.status == 200
data = await resp.json()
assert data["ok"] is True
assert (tmp_path / "test.json").exists()
async def test_save_invalid_json(client):
resp = await client.post(
"/styles/save", json={"file": "test.json", "content": "not json"}
)
assert resp.status == 400
data = await resp.json()
assert "Invalid JSON" in data["error"]
async def test_save_invalid_path(client):
resp = await client.post(
"/styles/save", json={"file": "../../evil.json", "content": "{}"}
)
assert resp.status == 400
async def test_save_creates_subdir(client, tmp_path):
content = json.dumps({"checkpoint": "model.safetensors"})
resp = await client.post(
"/styles/save", json={"file": "anime/test.json", "content": content}
)
assert resp.status == 200
assert (tmp_path / "anime" / "test.json").exists()
# --- /styles/new ---
async def test_new_creates_file(client, tmp_path):
resp = await client.post("/styles/new", json={"file": "new_style"})
assert resp.status == 200
data = await resp.json()
assert data["ok"] is True
assert data["file"] == "new_style.json"
assert (tmp_path / "new_style.json").exists()
async def test_new_adds_json_extension(client):
resp = await client.post("/styles/new", json={"file": "mystyle"})
data = await resp.json()
assert data["file"].endswith(".json")
async def test_new_empty_name(client):
resp = await client.post("/styles/new", json={"file": ""})
assert resp.status == 400
async def test_new_conflict(client, tmp_path):
(tmp_path / "existing.json").write_text("{}")
resp = await client.post("/styles/new", json={"file": "existing.json"})
assert resp.status == 409
async def test_new_template_valid_json(client):
resp = await client.post("/styles/new", json={"file": "template_test"})
data = await resp.json()
parsed = json.loads(data["content"])
assert "checkpoint" in parsed
assert "loras" in parsed
assert isinstance(parsed["loras"], list)
async def test_new_invalid_path(client):
resp = await client.post("/styles/new", json={"file": "../../evil"})
assert resp.status == 400
# --- /styles/load ---
async def test_load_existing(client, tmp_path):
(tmp_path / "style.json").write_text('{"checkpoint": "model.safetensors"}')
resp = await client.get("/styles/load?file=style.json")
assert resp.status == 200
data = await resp.json()
assert "checkpoint" in data["content"]
async def test_load_not_found(client):
resp = await client.get("/styles/load?file=missing.json")
assert resp.status == 404
async def test_load_invalid_path(client):
resp = await client.get("/styles/load?file=../../etc/passwd")
assert resp.status == 400
# --- /styles/delete ---
async def test_delete_existing(client, tmp_path):
(tmp_path / "to_delete.json").write_text("{}")
resp = await client.post("/styles/delete", json={"file": "to_delete.json"})
assert resp.status == 200
data = await resp.json()
assert data["ok"] is True
assert not (tmp_path / "to_delete.json").exists()
async def test_delete_not_found(client):
resp = await client.post("/styles/delete", json={"file": "missing.json"})
assert resp.status == 404
async def test_delete_invalid_path(client):
resp = await client.post("/styles/delete", json={"file": "../../evil.json"})
assert resp.status == 400
# --- /styles/preview ---
async def test_preview_png_found(client, tmp_path):
# Create a minimal 1x1 PNG
png_bytes = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00"
b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18"
b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82"
)
(tmp_path / "style.png").write_bytes(png_bytes)
resp = await client.get("/styles/preview?file=style.json")
assert resp.status == 200
data = await resp.json()
assert data["image"] is not None
assert data["image"].startswith("data:image/png;base64,")
async def test_preview_jpg_found(client, tmp_path):
# Minimal JPEG bytes
jpg_bytes = bytes(
[
0xFF,
0xD8,
0xFF,
0xE0,
0x00,
0x10,
0x4A,
0x46,
0x49,
0x46,
0x00,
0x01,
0x01,
0x00,
0x00,
0x01,
0x00,
0x01,
0x00,
0x00,
0xFF,
0xD9,
]
)
(tmp_path / "style.jpg").write_bytes(jpg_bytes)
resp = await client.get("/styles/preview?file=style.json")
assert resp.status == 200
data = await resp.json()
assert data["image"].startswith("data:image/jpeg;base64,")
async def test_preview_no_image(client, tmp_path):
(tmp_path / "style.json").write_text("{}")
resp = await client.get("/styles/preview?file=style.json")
assert resp.status == 200
data = await resp.json()
assert data["image"] is None
async def test_preview_invalid_path(client):
resp = await client.get("/styles/preview?file=../../etc/passwd")
assert resp.status == 400
async def test_preview_subdir(client, tmp_path):
sub = tmp_path / "anime"
sub.mkdir()
png_bytes = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00"
b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18"
b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82"
)
(sub / "illustrious.png").write_bytes(png_bytes)
resp = await client.get("/styles/preview?file=anime/illustrious.json")
assert resp.status == 200
data = await resp.json()
assert data["image"] is not None