add: core (01) драфт... работает!! Генерация SVG проходит!!!

This commit is contained in:
2026-08-23 22:14:49 +03:00
parent f015351c42
commit 05bb583d8a
7 changed files with 425 additions and 13 deletions
+97
View File
@@ -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
View File
@@ -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")
+3 -3
View File
@@ -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")]
+62 -4
View File
@@ -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,
},
)