add: core (01) драфт... работает!! Генерация SVG проходит!!!
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
from django import forms
|
||||
|
||||
|
||||
class HalftoneGenerateForm(forms.Form):
|
||||
"""
|
||||
Форма валидации входных параметров для генерации гипнотического SVG.
|
||||
"""
|
||||
SHAPE_CHOICES = [
|
||||
("circle", "Круг"),
|
||||
("ring", "Кольцо"),
|
||||
("triangle", "Треугольник"),
|
||||
("square", "Квадрат"),
|
||||
("diamond", "Ромб"),
|
||||
("hexagon", "Шестиугольник"),
|
||||
("star", "Звезда"),
|
||||
("cross", "Крестик"),
|
||||
("line", "Линия"),
|
||||
("wave", "Волна"),
|
||||
("heart", "Сердечко"),
|
||||
]
|
||||
|
||||
image = forms.ImageField(
|
||||
required=True,
|
||||
error_messages={
|
||||
"required": "Покорми Гипножабу изображением!",
|
||||
"invalid_image": "Гипножаба не может переварить этот файл. Нужна картинка (PNG, JPG, WEBP).",
|
||||
},
|
||||
)
|
||||
shape = forms.ChoiceField(
|
||||
choices=SHAPE_CHOICES,
|
||||
initial="circle",
|
||||
required=False,
|
||||
)
|
||||
cols = forms.IntegerField(
|
||||
min_value=20,
|
||||
max_value=200,
|
||||
initial=80,
|
||||
required=False,
|
||||
)
|
||||
max_radius = forms.IntegerField(
|
||||
min_value=3,
|
||||
max_value=20,
|
||||
initial=8,
|
||||
required=False,
|
||||
)
|
||||
blink = forms.IntegerField(
|
||||
min_value=0,
|
||||
max_value=10,
|
||||
initial=6,
|
||||
required=False,
|
||||
)
|
||||
rotation = forms.IntegerField(
|
||||
min_value=-15,
|
||||
max_value=15,
|
||||
initial=0,
|
||||
required=False,
|
||||
)
|
||||
scale = forms.IntegerField(
|
||||
min_value=800,
|
||||
max_value=1040,
|
||||
initial=980,
|
||||
required=False,
|
||||
)
|
||||
angle = forms.IntegerField(
|
||||
min_value=-44,
|
||||
max_value=45,
|
||||
initial=0,
|
||||
required=False,
|
||||
)
|
||||
|
||||
def clean_shape(self):
|
||||
shape = self.cleaned_data.get("shape")
|
||||
if not shape:
|
||||
return "circle"
|
||||
return shape
|
||||
|
||||
def clean_cols(self):
|
||||
return self.cleaned_data.get("cols") or 80
|
||||
|
||||
def clean_max_radius(self):
|
||||
return self.cleaned_data.get("max_radius") or 8
|
||||
|
||||
def clean_blink(self):
|
||||
val = self.cleaned_data.get("blink")
|
||||
return 6 if val is None else val
|
||||
|
||||
def clean_rotation(self):
|
||||
val = self.cleaned_data.get("rotation")
|
||||
return 0 if val is None else val
|
||||
|
||||
def clean_scale(self):
|
||||
val = self.cleaned_data.get("scale")
|
||||
return 980 if val is None else val
|
||||
|
||||
def clean_angle(self):
|
||||
val = self.cleaned_data.get("angle")
|
||||
return 0 if val is None else val
|
||||
+190
-2
@@ -1,3 +1,191 @@
|
||||
from django.test import TestCase
|
||||
import io
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from PIL import Image
|
||||
|
||||
# Create your tests here.
|
||||
from .forms import HalftoneGenerateForm
|
||||
from .services.halftone import encode_to_base36, generate_halftone_svg
|
||||
|
||||
|
||||
class HalftoneServiceTests(TestCase):
|
||||
"""Тестирование сервиса генерации SVG (services/halftone.py)."""
|
||||
|
||||
def setUp(self):
|
||||
# Создаем простое тестовое изображение 50x50 с градиентом
|
||||
self.img = Image.new("RGB", (50, 50), color="white")
|
||||
for x in range(25):
|
||||
for y in range(50):
|
||||
self.img.putpixel((x, y), (0, 0, 0))
|
||||
|
||||
def test_encode_to_base36(self):
|
||||
self.assertEqual(encode_to_base36(0), "a")
|
||||
self.assertEqual(encode_to_base36(1), "b")
|
||||
with self.assertRaises(ValueError):
|
||||
encode_to_base36(-1)
|
||||
|
||||
def test_generate_default_svg(self):
|
||||
svg = generate_halftone_svg(self.img, cols=30, max_radius=6)
|
||||
self.assertIn("<svg", svg)
|
||||
self.assertIn("</svg>", svg)
|
||||
self.assertIn("<defs>", svg)
|
||||
self.assertIn('<circle id="s', svg)
|
||||
self.assertIn('<g id="hypn0-canvas">', svg)
|
||||
|
||||
def test_generate_shapes(self):
|
||||
shapes = ["circle", "ring", "square", "diamond", "triangle", "hexagon", "star", "cross", "line", "wave", "heart"]
|
||||
for shape in shapes:
|
||||
svg = generate_halftone_svg(self.img, cols=20, max_radius=5, shape=shape)
|
||||
self.assertIn("<svg", svg)
|
||||
self.assertIn("</svg>", svg)
|
||||
self.assertIn("<defs>", svg)
|
||||
|
||||
def test_generate_static_blink_zero(self):
|
||||
svg = generate_halftone_svg(self.img, cols=20, blink=0)
|
||||
self.assertIn("animation:none", svg)
|
||||
self.assertNotIn("@keyframes noise", svg)
|
||||
|
||||
def test_generate_animated_params(self):
|
||||
svg = generate_halftone_svg(
|
||||
self.img,
|
||||
cols=20,
|
||||
blink=8,
|
||||
rotation=12,
|
||||
scale=920,
|
||||
angle=15,
|
||||
color="#ff5500",
|
||||
)
|
||||
self.assertIn("@keyframes noise", svg)
|
||||
self.assertIn("rotate(12deg)", svg)
|
||||
self.assertIn("scale(0.92)", svg)
|
||||
self.assertIn("#ff5500", svg)
|
||||
|
||||
def test_generate_from_bytes(self):
|
||||
buf = io.BytesIO()
|
||||
self.img.save(buf, format="PNG")
|
||||
svg = generate_halftone_svg(buf.getvalue(), cols=20)
|
||||
self.assertIn("<svg", svg)
|
||||
|
||||
|
||||
class HalftoneFormTests(TestCase):
|
||||
"""Тестирование формы валидации HalftoneGenerateForm."""
|
||||
|
||||
def test_form_validation(self):
|
||||
buf = io.BytesIO()
|
||||
img = Image.new("RGB", (30, 30), color="red")
|
||||
img.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
|
||||
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
|
||||
form = HalftoneGenerateForm(
|
||||
data={
|
||||
"shape": "square",
|
||||
"cols": 60,
|
||||
"max_radius": 7,
|
||||
"blink": 5,
|
||||
"rotation": 10,
|
||||
"scale": 950,
|
||||
"angle": -10,
|
||||
},
|
||||
files={"image": uploaded},
|
||||
)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data["shape"], "square")
|
||||
self.assertEqual(form.cleaned_data["cols"], 60)
|
||||
|
||||
|
||||
class HalftoneViewTests(TestCase):
|
||||
"""Тестирование view-обработчиков (index, generate)."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
|
||||
def test_index_page(self):
|
||||
response = self.client.get(reverse("hypn0_site:index"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "logo-hypn0.svg")
|
||||
self.assertContains(response, 'id="preview"')
|
||||
self.assertContains(response, 'name="csrfmiddlewaretoken"')
|
||||
self.assertContains(response, 'X-CSRFToken')
|
||||
# Проверяем, что csrf_token не пустой в hx-headers
|
||||
content = response.content.decode("utf-8")
|
||||
self.assertNotIn('hx-headers=\'{"X-CSRFToken": ""}\'', content)
|
||||
|
||||
def test_generate_with_enforce_csrf(self):
|
||||
csrf_client = Client(enforce_csrf_checks=True)
|
||||
response = csrf_client.get(reverse("hypn0_site:index"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img = Image.new("RGB", (40, 40), color="black")
|
||||
img.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
|
||||
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
|
||||
# Без CSRF токена вернет 403
|
||||
bad_response = csrf_client.post(
|
||||
reverse("hypn0_site:generate"),
|
||||
data={"shape": "circle", "image": uploaded},
|
||||
)
|
||||
self.assertEqual(bad_response.status_code, 403)
|
||||
|
||||
# С CSRF токеном в POST data вернет 200
|
||||
csrf_token = response.cookies["csrftoken"].value
|
||||
buf.seek(0)
|
||||
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
|
||||
good_response = csrf_client.post(
|
||||
reverse("hypn0_site:generate"),
|
||||
data={
|
||||
"csrfmiddlewaretoken": csrf_token,
|
||||
"shape": "circle",
|
||||
"image": uploaded,
|
||||
},
|
||||
)
|
||||
self.assertEqual(good_response.status_code, 200)
|
||||
self.assertContains(good_response, "<svg")
|
||||
|
||||
# С CSRF токеном в заголовке X-CSRFToken (как отправляет HTMX) вернет 200
|
||||
buf.seek(0)
|
||||
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
|
||||
header_response = csrf_client.post(
|
||||
reverse("hypn0_site:generate"),
|
||||
data={
|
||||
"shape": "circle",
|
||||
"image": uploaded,
|
||||
},
|
||||
HTTP_X_CSRFTOKEN=csrf_token,
|
||||
)
|
||||
self.assertEqual(header_response.status_code, 200)
|
||||
self.assertContains(header_response, "<svg")
|
||||
|
||||
def test_generate_get_not_allowed(self):
|
||||
response = self.client.get(reverse("hypn0_site:generate"))
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_generate_post_no_image(self):
|
||||
response = self.client.post(reverse("hypn0_site:generate"), data={"shape": "circle"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Покорми Гипножабу")
|
||||
|
||||
def test_generate_post_success(self):
|
||||
buf = io.BytesIO()
|
||||
img = Image.new("RGB", (40, 40), color="black")
|
||||
img.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
|
||||
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
|
||||
response = self.client.post(
|
||||
reverse("hypn0_site:generate"),
|
||||
data={
|
||||
"shape": "diamond",
|
||||
"cols": "40",
|
||||
"max_radius": "6",
|
||||
"blink": "6",
|
||||
"colors": ["#10b981"],
|
||||
"image": uploaded,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "<svg")
|
||||
self.assertContains(response, 'points="0,-6')
|
||||
self.assertContains(response, "like-to-gallery")
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
from hypn0 import settings
|
||||
from . import views
|
||||
|
||||
app_name = "hypn0_site"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.index, name="index"),
|
||||
path("generate", views.generate, name="generate"),
|
||||
]
|
||||
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += [path('tmp/', views.tmp, name='web_tmp')]
|
||||
urlpatterns += [path("tmp/", views.tmp, name="web_tmp")]
|
||||
|
||||
@@ -1,11 +1,69 @@
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.http import HttpRequest, HttpResponse, Http404
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
# Create your views here.
|
||||
from .forms import HalftoneGenerateForm
|
||||
from .services.halftone import generate_halftone_svg
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def index(request: HttpRequest | None) -> HttpResponse:
|
||||
return render(request, 'index.html', {})
|
||||
return render(request, "index.html", {})
|
||||
|
||||
|
||||
def tmp(request: HttpRequest | None) -> HttpResponse:
|
||||
return render(request, 'tmp.html', {})
|
||||
return render(request, "tmp.html", {})
|
||||
|
||||
|
||||
@require_POST
|
||||
def generate(request: HttpRequest) -> HttpResponse:
|
||||
"""
|
||||
HTMX-эндпоинт для генерации гипнотического полутонового SVG на лету.
|
||||
Не сохраняет данные в БД (Zero-Disk / Zero-PII).
|
||||
"""
|
||||
form = HalftoneGenerateForm(request.POST, request.FILES)
|
||||
|
||||
if not form.is_valid():
|
||||
error_msg = next(iter(form.errors.values()))[0] if form.errors else "Ошибка параметров генерации"
|
||||
return render(request, "block/preview.html", {"error": error_msg})
|
||||
|
||||
image_file = form.cleaned_data["image"]
|
||||
shape = form.cleaned_data["shape"]
|
||||
cols = form.cleaned_data["cols"]
|
||||
max_radius = form.cleaned_data["max_radius"]
|
||||
blink = form.cleaned_data["blink"]
|
||||
rotation = form.cleaned_data["rotation"]
|
||||
scale = form.cleaned_data["scale"]
|
||||
angle = form.cleaned_data["angle"]
|
||||
|
||||
# Извлечение основного цвета (поддержка монотонной схемы)
|
||||
colors = request.POST.getlist("colors")
|
||||
primary_color = colors[0] if colors and colors[0] else "#a855ff"
|
||||
|
||||
try:
|
||||
svg_content = generate_halftone_svg(
|
||||
image=image_file,
|
||||
cols=cols,
|
||||
max_radius=max_radius,
|
||||
shape=shape,
|
||||
color=primary_color,
|
||||
blink=blink,
|
||||
rotation=rotation,
|
||||
scale=scale,
|
||||
angle=angle,
|
||||
)
|
||||
except Exception as e:
|
||||
return render(
|
||||
request,
|
||||
"block/preview.html",
|
||||
{"error": f"Сбой в матрице гипноза: {str(e)}"},
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"block/preview.html",
|
||||
{
|
||||
"svg_content": svg_content,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user