1277 lines
57 KiB
Python
1277 lines
57 KiB
Python
import io
|
||
import shutil
|
||
import tempfile
|
||
from django.contrib.admin.sites import AdminSite
|
||
from django.core.exceptions import ValidationError
|
||
from django.core.files.base import ContentFile
|
||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||
from django.core.management import call_command
|
||
from django.test import Client, TestCase, override_settings
|
||
from django.urls import reverse
|
||
from PIL import Image
|
||
|
||
from .admin import TbBlogPostAdmin
|
||
from .forms import HalftoneGenerateForm
|
||
from .models import TbBlogPost, TbHypn0Item, TbVote
|
||
from .services.halftone import (
|
||
analyze_svg_structure,
|
||
encode_to_base36,
|
||
generate_halftone_svg,
|
||
prepare_active_svg,
|
||
prepare_gallery_svg,
|
||
)
|
||
from .services.naming import generate_hypno_title, generate_title_openrouter
|
||
|
||
|
||
class BaseMediaTestCase(TestCase):
|
||
"""Базовый тестовый класс с изоляцией MEDIA_ROOT и STORAGES во временной папке."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
super().setUpClass()
|
||
cls.temp_media = tempfile.mkdtemp()
|
||
cls.override_media = override_settings(
|
||
MEDIA_ROOT=cls.temp_media,
|
||
STORAGES={
|
||
"default": {
|
||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||
"OPTIONS": {
|
||
"location": cls.temp_media,
|
||
},
|
||
},
|
||
"staticfiles": {
|
||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
||
},
|
||
},
|
||
)
|
||
cls.override_media.enable()
|
||
|
||
@classmethod
|
||
def tearDownClass(cls):
|
||
cls.override_media.disable()
|
||
shutil.rmtree(cls.temp_media, ignore_errors=True)
|
||
super().tearDownClass()
|
||
|
||
|
||
class HalftoneServiceTests(BaseMediaTestCase):
|
||
"""Тестирование сервиса генерации 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), "H")
|
||
self.assertEqual(encode_to_base36(1), "Y")
|
||
self.assertEqual(encode_to_base36(2), "P")
|
||
self.assertEqual(encode_to_base36(3), "N")
|
||
self.assertEqual(encode_to_base36(4), "0")
|
||
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-xyz"', svg)
|
||
self.assertIn("<desc>Generated by Hypn0", svg)
|
||
|
||
def test_generate_shapes(self):
|
||
shapes = ["circle", "ring", "square", "diamond", "triangle", "hexagon", "star", "cross", "line", "snowflake", "binary", "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)
|
||
|
||
# Проверяем структуру кольца: путь с вырезанным отверстием (evenodd)
|
||
ring_svg = generate_halftone_svg(self.img, cols=20, max_radius=8, shape="ring")
|
||
self.assertIn('fill-rule="evenodd"', ring_svg)
|
||
self.assertIn('<path id="s', ring_svg)
|
||
|
||
# Проверяем структуру сердечка: путь без атрибута transform
|
||
heart_svg = generate_halftone_svg(self.img, cols=20, max_radius=8, shape="heart")
|
||
self.assertIn('<path id="s', heart_svg)
|
||
self.assertNotIn('transform="scale', heart_svg)
|
||
|
||
# Проверяем структуру снежинки: 8-лучевой полигон с точками
|
||
snowflake_svg = generate_halftone_svg(self.img, cols=20, max_radius=8, shape="snowflake")
|
||
self.assertIn('<polygon id="s', snowflake_svg)
|
||
self.assertIn('points="', snowflake_svg)
|
||
|
||
# Проверяем структуру матрицы 0/1: текстовый моноширинный элемент и наличие обоих символов 0 и 1
|
||
binary_svg = generate_halftone_svg(self.img, cols=20, max_radius=8, shape="binary")
|
||
self.assertIn('<text id="s', binary_svg)
|
||
self.assertIn('font-family="ui-monospace,monospace"', binary_svg)
|
||
self.assertIn(">0</text>", binary_svg)
|
||
self.assertIn(">1</text>", binary_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)
|
||
self.assertIn(".aH{--d:0s", svg)
|
||
self.assertIn("transform-origin:", svg)
|
||
|
||
def test_generate_optimized_styles_and_transforms(self):
|
||
# 1. При дефолтных параметрах (rotation=0, scale=1000) transform в @keyframes noise отсутствует
|
||
svg_default = generate_halftone_svg(self.img, cols=20, rotation=0, scale=1000)
|
||
self.assertNotIn("rotate(", svg_default)
|
||
self.assertNotIn("scale(", svg_default)
|
||
self.assertIn("@keyframes noise{0%{opacity:0.6}50%{opacity:0.9}100%{opacity:0.5}}", svg_default)
|
||
|
||
# 2. Только rotation задан -> scale отсутствует
|
||
svg_rot = generate_halftone_svg(self.img, cols=20, rotation=15, scale=1000)
|
||
self.assertIn("rotate(15deg)", svg_rot)
|
||
self.assertNotIn("scale(", svg_rot)
|
||
|
||
# 3. Только scale задан -> rotate отсутствует
|
||
svg_scale = generate_halftone_svg(self.img, cols=20, rotation=0, scale=850)
|
||
self.assertIn("scale(0.85)", svg_scale)
|
||
self.assertNotIn("rotate(", svg_scale)
|
||
|
||
# 4. Прозрачность 1.0 -> opacity отсутствует в fill_style
|
||
svg_opaque = generate_halftone_svg(self.img, cols=20, opacity=1.0)
|
||
self.assertIn("fill:#a855ff;stroke:#a855ff;}", svg_opaque)
|
||
self.assertNotIn("opacity:1.0;", svg_opaque)
|
||
|
||
# 5. Прозрачность 0.8 -> opacity присутствует
|
||
svg_trans = generate_halftone_svg(self.img, cols=20, opacity=0.8)
|
||
self.assertIn("opacity:0.8;", svg_trans)
|
||
|
||
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)
|
||
|
||
def test_generate_aspect_ratios(self):
|
||
# Проверяем, что для вертикального изображения (100x300) сетка не раздувается
|
||
tall_img = Image.new("RGB", (100, 300), color="black")
|
||
svg_tall = generate_halftone_svg(tall_img, cols=30, max_radius=5)
|
||
# При cols=30 наибольшая сторона (высота) имеет 30 точек, ширина 10
|
||
# STEP = 20, margin = 4 -> width = 10 * 20 + 8 = 208, height = 30 * 20 + 8 = 608
|
||
self.assertIn('viewBox="0 0 208 608"', svg_tall)
|
||
|
||
# Для горизонтального изображения (300x100)
|
||
wide_img = Image.new("RGB", (300, 100), color="black")
|
||
svg_wide = generate_halftone_svg(wide_img, cols=30, max_radius=5)
|
||
self.assertIn('viewBox="0 0 608 208"', svg_wide)
|
||
|
||
def test_generate_screen_angles(self):
|
||
# Проверяем поворот растровой сетки под разными углами (-45, 0, 15, 45)
|
||
# Холст должен сохранять прямые пропорции (viewBox), а внутри генерироваться точки
|
||
for angle in [-45, -15, 0, 15, 45]:
|
||
svg = generate_halftone_svg(self.img, cols=20, max_radius=6, angle=angle)
|
||
self.assertIn("<svg", svg)
|
||
self.assertIn("<use href=", svg)
|
||
# viewbox не меняется от поворота сетки
|
||
self.assertIn('viewBox="0 0 408 408"', svg)
|
||
|
||
|
||
class HalftoneFormTests(BaseMediaTestCase):
|
||
"""Тестирование формы валидации 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(BaseMediaTestCase):
|
||
"""Тестирование 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')
|
||
self.assertContains(response, "handleFileChange")
|
||
self.assertContains(response, "imagePreviewUrl")
|
||
self.assertContains(response, "Гипножаба облизывается на:")
|
||
# Проверяем, что 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")
|
||
|
||
|
||
class NamingServiceTests(BaseMediaTestCase):
|
||
"""Тестирование сервиса гипнотических названий (services/naming.py)."""
|
||
|
||
def test_generate_hypno_title(self):
|
||
title = generate_hypno_title(seed=42)
|
||
self.assertIsInstance(title, str)
|
||
self.assertTrue(len(title) > 5)
|
||
|
||
# Проверка детерминированности по seed
|
||
self.assertEqual(generate_hypno_title(seed=42), title)
|
||
|
||
def test_openrouter_stub(self):
|
||
res = generate_title_openrouter(b"dummy_bytes")
|
||
self.assertIsNone(res)
|
||
|
||
|
||
class GalleryPreparationTests(BaseMediaTestCase):
|
||
"""Тестирование подготовки SVG к галерейному хранению."""
|
||
|
||
def test_prepare_gallery_svg(self):
|
||
raw_svg = '<svg><style>.shape{animation:noise 1s}</style><g></g></svg>'
|
||
prepared = prepare_gallery_svg(raw_svg)
|
||
self.assertIn("svg{--hypn0-play:paused}", prepared)
|
||
self.assertIn(":host(:hover) svg,svg:hover{--hypn0-play:running!important}", prepared)
|
||
self.assertIn("animation-play-state:var(--hypn0-play,paused)!important", prepared)
|
||
|
||
|
||
class PublishViewTests(BaseMediaTestCase):
|
||
"""Тестирование эндпоинта публикации кандидата в галерею (/publish)."""
|
||
|
||
def setUp(self):
|
||
self.client = Client()
|
||
self.sample_svg = '<svg xmlns="http://www.w3.org/2000/svg"><style>.shape{color:red}</style><g><circle/></g></svg>'
|
||
|
||
def test_publish_without_cookie_returns_slug_protest(self):
|
||
response = self.client.post(
|
||
reverse("hypn0_site:publish"),
|
||
data={
|
||
"svg_content": self.sample_svg,
|
||
"shape": "circle",
|
||
"cols": "35",
|
||
},
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, "Мозговые слизняки протестуют!")
|
||
self.assertContains(response, "Подчиниться и опубликовать")
|
||
self.assertEqual(TbHypn0Item.objects.count(), 0)
|
||
|
||
def test_publish_with_cookie_success(self):
|
||
vid = "123e4567-e89b-12d3-a456-426614174000"
|
||
self.client.cookies["hypn0_vid"] = vid
|
||
|
||
response = self.client.post(
|
||
reverse("hypn0_site:publish"),
|
||
data={
|
||
"svg_content": self.sample_svg,
|
||
"shape": "diamond",
|
||
"cols": "40",
|
||
"max_radius": "6",
|
||
"blink": "7",
|
||
"rotation": "5",
|
||
"scale": "950",
|
||
"angle": "10",
|
||
"color": "#10b981",
|
||
},
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, "Уже в галерее транса")
|
||
self.assertContains(response, "идет прорастание психо-паутины...")
|
||
|
||
# Проверяем запись в БД
|
||
self.assertEqual(TbHypn0Item.objects.count(), 1)
|
||
item = TbHypn0Item.objects.first()
|
||
self.assertTrue(len(item.s_hash_id) >= 6)
|
||
self.assertIn(f"#{item.s_hash_id}", response.content.decode("utf-8"))
|
||
self.assertEqual(item.i_level, TbHypn0Item.Level.CANDIDATE)
|
||
self.assertEqual(item.i_likes_count, 1)
|
||
self.assertEqual(item.j_metadata["shape"], "diamond")
|
||
self.assertEqual(item.j_metadata["cols"], 40)
|
||
self.assertEqual(item.j_metadata["color"], "#10b981")
|
||
# Проверяем, что файл сохранен с именем, содержащим id-хэш
|
||
self.assertIn(f"hypn0_{item.s_hash_id}.svg", item.file_svg.name)
|
||
self.assertTrue(item.file_svg.storage.exists(item.file_svg.name))
|
||
|
||
# Проверяем авторский голос в TbVote
|
||
self.assertEqual(TbVote.objects.count(), 1)
|
||
author_vote = TbVote.objects.first()
|
||
self.assertEqual(author_vote.k_item, item)
|
||
self.assertEqual(author_vote.i_direction, TbVote.Direction.AUTHOR)
|
||
|
||
def test_publish_empty_svg_returns_error(self):
|
||
self.client.cookies["hypn0_vid"] = "123e4567-e89b-12d3-a456-426614174000"
|
||
response = self.client.post(
|
||
reverse("hypn0_site:publish"),
|
||
data={"svg_content": ""},
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, "Нет данных SVG для публикации")
|
||
self.assertEqual(TbHypn0Item.objects.count(), 0)
|
||
|
||
|
||
class SvgAnalysisAndActiveSvgTests(BaseMediaTestCase):
|
||
"""Тестирование анализа структуры SVG и очистки от паузы."""
|
||
|
||
def test_prepare_active_svg(self):
|
||
gallery_svg = '<svg><style>svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect,svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}</style><g></g></svg>'
|
||
active = prepare_active_svg(gallery_svg)
|
||
self.assertNotIn("animation-play-state:paused!important", active)
|
||
|
||
def test_prepare_active_svg_modern_format(self):
|
||
img = Image.new("RGB", (20, 20), color="black")
|
||
raw_svg = generate_halftone_svg(img, cols=15, max_radius=6, blink=5)
|
||
gallery_svg = prepare_gallery_svg(raw_svg)
|
||
self.assertIn("svg{--hypn0-play:paused}", gallery_svg)
|
||
self.assertIn("animation-play-state:var(--hypn0-play,paused)!important", gallery_svg)
|
||
|
||
active = prepare_active_svg(gallery_svg)
|
||
self.assertNotIn("svg{--hypn0-play:paused}", active)
|
||
self.assertNotIn("animation-play-state:var(--hypn0-play,paused)!important", active)
|
||
self.assertIn("animation-play-state:var(--hypn0-play,running)", active)
|
||
|
||
def test_prepare_active_svg_preserves_color_and_animation(self):
|
||
gallery_svg = (
|
||
'<svg xmlns="http://www.w3.org/2000/svg">'
|
||
'<style>'
|
||
'svg{background:transparent}'
|
||
'.shape,circle,rect,polygon,path{fill:#e6b400;stroke:#e6b400;opacity:0.90;transform-origin:center;animation:noise 1.36s ease-in-out infinite alternate;animation-delay:var(--d);animation-play-state:var(--hypn0-play,running);transition:all .5s ease-out}'
|
||
'@keyframes noise{0%{opacity:0.63}100%{opacity:0.45}}'
|
||
'svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}'
|
||
'</style><g></g></svg>'
|
||
)
|
||
active = prepare_active_svg(gallery_svg)
|
||
self.assertIn("fill:#e6b400", active)
|
||
self.assertIn("stroke:#e6b400", active)
|
||
self.assertIn("animation:noise 1.36s", active)
|
||
self.assertNotIn("svg{--hypn0-play:paused}", active)
|
||
self.assertNotIn("animation-play-state:var(--hypn0-play,paused)!important", active)
|
||
|
||
def test_analyze_svg_structure(self):
|
||
svg = (
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600">'
|
||
'<style>.a0{--d:0s}.a1{--d:1s}@keyframes noise{0%{opacity:1}100%{opacity:0.5}}</style>'
|
||
'<defs><circle id="s0" r="5"/><rect id="s1" width="10" height="10"/></defs>'
|
||
'<g id="hypn0-canvas">'
|
||
'<g class="a0"><use href="#s0" x="10" y="10"/><use href="#s0" x="20" y="20"/></g>'
|
||
'<g class="a1"><use href="#s1" x="30" y="30"/></g>'
|
||
'</g>'
|
||
'</svg>'
|
||
)
|
||
stats = analyze_svg_structure(svg)
|
||
self.assertEqual(stats["use_count"], 3)
|
||
self.assertEqual(stats["total_oscillators"], 3)
|
||
self.assertEqual(stats["defs_count"], 2)
|
||
self.assertEqual(stats["circle_count"], 1)
|
||
self.assertEqual(stats["rect_count"], 1)
|
||
self.assertEqual(stats["groups_count"], 3)
|
||
self.assertEqual(stats["keyframes_count"], 1)
|
||
self.assertEqual(stats["viewbox"], "0 0 800 600")
|
||
self.assertEqual(stats["width"], 800)
|
||
self.assertEqual(stats["height"], 600)
|
||
self.assertEqual(stats["aspect_ratio"], "4:3")
|
||
self.assertTrue(stats["total_bytes"] > 0)
|
||
|
||
|
||
class GalleryDetailAndDownloadTests(BaseMediaTestCase):
|
||
"""Тестирование страниц детального просмотра картины, скачивания и голосования."""
|
||
|
||
def setUp(self):
|
||
self.client = Client()
|
||
self.vid = "123e4567-e89b-12d3-a456-426614174000"
|
||
svg_code = (
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">'
|
||
'<style>.a0{--d:0s}svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect,svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}</style>'
|
||
'<defs><circle id="s0" r="8"/></defs>'
|
||
'<g id="hypn0-canvas"><g class="a0"><use href="#s0" x="50" y="50"/></g></g>'
|
||
'</svg>'
|
||
)
|
||
self.item = TbHypn0Item(
|
||
s_title="Астральный Транс Сознания #42",
|
||
file_svg=ContentFile(svg_code.encode("utf-8"), name="test_item.svg"),
|
||
j_metadata={
|
||
"shape": "circle",
|
||
"cols": 35,
|
||
"max_radius": 8,
|
||
"blink": 6,
|
||
"rotation": 0,
|
||
"scale": 980,
|
||
"angle": 0,
|
||
"color": "#a855ff",
|
||
},
|
||
i_level=TbHypn0Item.Level.CANDIDATE,
|
||
is_public=True,
|
||
)
|
||
self.item.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
def test_gallery_detail_view_success(self):
|
||
response = self.client.get(reverse("hypn0_site:gallery_detail", kwargs={"hash_id": self.item.s_hash_id}))
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, "Астральный Транс Сознания #42")
|
||
self.assertContains(response, f"#{self.item.s_hash_id}")
|
||
self.assertContains(response, "Векторная анатомия")
|
||
self.assertContains(response, "Синтез психо-сетки")
|
||
self.assertContains(response, "Астральный паспорт")
|
||
self.assertContains(response, "Копировать SVG-код")
|
||
self.assertContains(response, "Скачать .svg")
|
||
|
||
# Проверка инкремента просмотров
|
||
self.item.refresh_from_db()
|
||
self.assertGreaterEqual(self.item.i_views_count, 2)
|
||
|
||
def test_gallery_detail_404_on_invalid_hash(self):
|
||
response = self.client.get(reverse("hypn0_site:gallery_detail", kwargs={"hash_id": "nonexistent999"}))
|
||
self.assertEqual(response.status_code, 404)
|
||
|
||
def test_gallery_download_view_success(self):
|
||
response = self.client.get(reverse("hypn0_site:gallery_download", kwargs={"hash_id": self.item.s_hash_id}))
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertEqual(response["Content-Type"], "image/svg+xml")
|
||
self.assertIn(f'filename="hypn0-{self.item.s_hash_id}.svg"', response["Content-Disposition"])
|
||
# Должен быть чистый активный SVG (без паузы анимации)
|
||
self.assertNotIn("animation-play-state:paused!important", response.content.decode("utf-8"))
|
||
|
||
def test_gallery_vote_with_cookie(self):
|
||
# Новый посетитель
|
||
voter_vid = "987e6543-e21b-12d3-a456-426614174999"
|
||
self.client.cookies["hypn0_vid"] = voter_vid
|
||
response = self.client.post(reverse("hypn0_site:gallery_vote", kwargs={"hash_id": self.item.s_hash_id}))
|
||
self.assertEqual(response.status_code, 200)
|
||
self.item.refresh_from_db()
|
||
self.assertEqual(self.item.i_likes_count, 2)
|
||
|
||
def test_gallery_vote_without_cookie_returns_notice(self):
|
||
client = Client()
|
||
response = client.post(reverse("hypn0_site:gallery_vote", kwargs={"hash_id": self.item.s_hash_id}))
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, "Подчинитесь воле Гипножабы!")
|
||
|
||
|
||
class GalleryRandomAndNavigationTests(BaseMediaTestCase):
|
||
"""Тестирование случайной навигации и пула хэшей (/gallery/random)."""
|
||
|
||
def setUp(self):
|
||
self.client = Client()
|
||
self.vid = "123e4567-e89b-12d3-a456-426614174000"
|
||
|
||
# Создаем 3 картины в галерее
|
||
self.items = []
|
||
for i in range(3):
|
||
svg_code = f'<svg><g id="item_{i}"></g></svg>'
|
||
item = TbHypn0Item(
|
||
s_title=f"Гипно Картина #{i}",
|
||
file_svg=ContentFile(svg_code.encode("utf-8"), name=f"test_item_{i}.svg"),
|
||
j_metadata={"cols": 30 + i},
|
||
is_public=True,
|
||
)
|
||
item.save(visitor_uuid_or_fp=self.vid)
|
||
self.items.append(item)
|
||
|
||
def test_gallery_random_direct_redirect(self):
|
||
response = self.client.get(reverse("hypn0_site:gallery_random"))
|
||
self.assertEqual(response.status_code, 302)
|
||
all_hashes = [it.s_hash_id for it in self.items]
|
||
self.assertTrue(any(h in response.url for h in all_hashes))
|
||
|
||
def test_gallery_random_json_pool(self):
|
||
response = self.client.get(reverse("hypn0_site:gallery_random"), data={"format": "json", "limit": 2})
|
||
self.assertEqual(response.status_code, 200)
|
||
data = response.json()
|
||
self.assertIn("next_hash_id", data)
|
||
self.assertIn("pool", data)
|
||
self.assertEqual(len(data["pool"]), 2)
|
||
all_hashes = {it.s_hash_id for it in self.items}
|
||
self.assertIn(data["next_hash_id"], all_hashes)
|
||
|
||
def test_gallery_random_exclude_filters_seen(self):
|
||
exclude_hashes = [self.items[0].s_hash_id, self.items[1].s_hash_id]
|
||
response = self.client.get(
|
||
reverse("hypn0_site:gallery_random"),
|
||
data={"format": "json", "exclude": ",".join(exclude_hashes)},
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
data = response.json()
|
||
self.assertEqual(data["next_hash_id"], self.items[2].s_hash_id)
|
||
|
||
def test_gallery_random_exclude_all_loops_circle(self):
|
||
# Если исключены все 3 картины, кольцо замыкается
|
||
exclude_hashes = [it.s_hash_id for it in self.items]
|
||
response = self.client.get(
|
||
reverse("hypn0_site:gallery_random"),
|
||
data={
|
||
"format": "json",
|
||
"exclude": ",".join(exclude_hashes),
|
||
"current": self.items[0].s_hash_id,
|
||
},
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
data = response.json()
|
||
self.assertIsNotNone(data["next_hash_id"])
|
||
# Текущий элемент исключен, выбран один из оставшихся
|
||
self.assertIn(data["next_hash_id"], [self.items[1].s_hash_id, self.items[2].s_hash_id])
|
||
|
||
def test_gallery_random_empty_database(self):
|
||
TbHypn0Item.objects.all().delete()
|
||
# Direct visit redirects to index
|
||
response = self.client.get(reverse("hypn0_site:gallery_random"))
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertEqual(response.url, reverse("hypn0_site:index"))
|
||
|
||
# JSON returns null next_hash_id
|
||
json_resp = self.client.get(reverse("hypn0_site:gallery_random"), data={"format": "json"})
|
||
self.assertEqual(json_resp.status_code, 200)
|
||
self.assertIsNone(json_resp.json()["next_hash_id"])
|
||
self.assertEqual(json_resp.json()["pool"], [])
|
||
|
||
|
||
class UnconsciousMatrixTests(BaseMediaTestCase):
|
||
"""Тестирование Матрицы бессознательного (build_unconscious_matrix) и навигации."""
|
||
|
||
def setUp(self):
|
||
self.client = Client()
|
||
self.vid = "123e4567-e89b-12d3-a456-426614174000"
|
||
|
||
# Создаем 5 картин
|
||
self.items = []
|
||
for i in range(5):
|
||
svg_code = f'<svg><circle id="dot_{i}"/></svg>'
|
||
item = TbHypn0Item(
|
||
s_title=f"Тестовый транс #{i}",
|
||
file_svg=ContentFile(svg_code.encode("utf-8"), name=f"test_matrix_{i}.svg"),
|
||
j_metadata={"cols": 20 + i},
|
||
is_public=True,
|
||
)
|
||
item.save(visitor_uuid_or_fp=self.vid)
|
||
self.items.append(item)
|
||
|
||
def test_build_unconscious_matrix_basic(self):
|
||
from hypn0_site.views import build_unconscious_matrix
|
||
|
||
current = self.items[0].s_hash_id
|
||
matrix_items, prev_h, next_h, prev_url, next_url, seed = build_unconscious_matrix(current, None)
|
||
|
||
self.assertEqual(len(matrix_items), 5)
|
||
# Проверяем, что current отмечен как is_current
|
||
current_node = [n for n in matrix_items if n["s_hash_id"] == current][0]
|
||
self.assertTrue(current_node["is_current"])
|
||
|
||
# Другие узлы не current
|
||
other_nodes = [n for n in matrix_items if n["s_hash_id"] != current]
|
||
self.assertTrue(all(not n["is_current"] for n in other_nodes))
|
||
|
||
# Ссылки содержат seed
|
||
self.assertIn(f"seed={seed}", prev_url)
|
||
self.assertIn(f"seed={seed}", next_url)
|
||
|
||
def test_matrix_stability_with_fixed_seed(self):
|
||
from hypn0_site.views import build_unconscious_matrix
|
||
|
||
root_hash = self.items[2].s_hash_id
|
||
fixed_seed = f"42109_{root_hash}"
|
||
|
||
# Первый запрос
|
||
m1, prev1, next1, _, _, seed1 = build_unconscious_matrix(self.items[0].s_hash_id, fixed_seed)
|
||
# Второй запрос с тем же seed на другую картину
|
||
m2, prev2, next2, _, _, seed2 = build_unconscious_matrix(self.items[1].s_hash_id, fixed_seed)
|
||
|
||
self.assertEqual(seed1, fixed_seed)
|
||
self.assertEqual(seed2, fixed_seed)
|
||
|
||
# Состав и порядок хэшей в матрице должны быть идентичны
|
||
hashes1 = [n["s_hash_id"] for n in m1]
|
||
hashes2 = [n["s_hash_id"] for n in m2]
|
||
self.assertEqual(hashes1, hashes2)
|
||
|
||
def test_gallery_detail_renders_matrix_and_card_bg_style(self):
|
||
item = self.items[0]
|
||
response = self.client.get(reverse("hypn0_site:gallery_detail", kwargs={"hash_id": item.s_hash_id}))
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, "Матрица бессознательного")
|
||
self.assertContains(response, "5 узлов")
|
||
self.assertContains(response, f"#{item.s_hash_id}")
|
||
self.assertContains(response, "btn-nav-prev")
|
||
self.assertContains(response, "btn-nav-next")
|
||
self.assertContains(response, "--card-bg-light")
|
||
self.assertContains(response, "--card-bg-dark")
|
||
self.assertContains(response, "bg-[var(--card-bg-light)]")
|
||
|
||
|
||
class GalleryFreshFloorTests(BaseMediaTestCase):
|
||
"""Тестирование 1-го этажа («Плеск бессознательного»), выборки и пагинации."""
|
||
|
||
def setUp(self):
|
||
self.client = Client()
|
||
self.vid = "123e4567-e89b-12d3-a456-426614174000"
|
||
|
||
def test_get_floor_fresh_filtering_and_ordering(self):
|
||
from hypn0_site.views import get_floor_fresh
|
||
|
||
# 1. Создаем картины разных уровней и с разным числом просмотров
|
||
svg_bytes = b'<svg><circle/></svg>'
|
||
|
||
# Свежий кандидат с 5 просмотрами
|
||
item_cand = TbHypn0Item(
|
||
s_title="Кандидат",
|
||
file_svg=ContentFile(svg_bytes, name="c.svg"),
|
||
i_views_count=5,
|
||
i_level=TbHypn0Item.Level.CANDIDATE,
|
||
is_public=True,
|
||
)
|
||
item_cand.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
# Level 1 с 1 просмотром (должен быть первым, т.к. просмотров меньше)
|
||
item_lvl1 = TbHypn0Item(
|
||
s_title="Level 1",
|
||
file_svg=ContentFile(svg_bytes, name="l1.svg"),
|
||
i_views_count=1,
|
||
i_level=TbHypn0Item.Level.LEVEL_1,
|
||
is_public=True,
|
||
)
|
||
item_lvl1.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
# Level 2 (2-й этаж, не должен попасть в 1-й)
|
||
item_lvl2 = TbHypn0Item(
|
||
s_title="Level 2 Curated",
|
||
file_svg=ContentFile(svg_bytes, name="l2.svg"),
|
||
i_views_count=0,
|
||
i_level=TbHypn0Item.Level.LEVEL_2,
|
||
is_public=True,
|
||
)
|
||
item_lvl2.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
# Непубличная картина (не должна попасть)
|
||
item_private = TbHypn0Item(
|
||
s_title="Private",
|
||
file_svg=ContentFile(svg_bytes, name="p.svg"),
|
||
i_views_count=0,
|
||
i_level=TbHypn0Item.Level.CANDIDATE,
|
||
is_public=False,
|
||
)
|
||
item_private.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
results = list(get_floor_fresh(limit=10))
|
||
self.assertEqual(len(results), 2)
|
||
# Сначала с наименьшим i_views_count
|
||
self.assertEqual(results[0].pk, item_lvl1.pk)
|
||
self.assertEqual(results[1].pk, item_cand.pk)
|
||
|
||
def test_index_view_renders_fresh_stream(self):
|
||
# Создаем 2 картины для 1 этажа
|
||
svg_bytes = b'<svg><circle/></svg>'
|
||
item = TbHypn0Item(
|
||
s_title="Свежий шедевр",
|
||
file_svg=ContentFile(svg_bytes, name="f1.svg"),
|
||
i_views_count=2,
|
||
i_level=TbHypn0Item.Level.CANDIDATE,
|
||
is_public=True,
|
||
)
|
||
item.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
response = self.client.get(reverse("hypn0_site:index"))
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, "Плеск бессознательного")
|
||
self.assertContains(response, "FRESH STREAM")
|
||
self.assertContains(response, f"gallery-card-{item.s_hash_id}")
|
||
self.assertContains(response, f"¤{item.s_hash_id}")
|
||
|
||
def test_publish_includes_oob_swap_for_fresh_grid(self):
|
||
self.client.cookies["hypn0_vid"] = self.vid
|
||
sample_svg = '<svg xmlns="http://www.w3.org/2000/svg"><g><circle/></g></svg>'
|
||
|
||
response = self.client.post(
|
||
reverse("hypn0_site:publish"),
|
||
data={
|
||
"svg_content": sample_svg,
|
||
"shape": "circle",
|
||
"cols": "35",
|
||
},
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, 'hx-swap-oob="afterbegin:#fresh-stream-grid"')
|
||
item = TbHypn0Item.objects.first()
|
||
self.assertContains(response, f"gallery-card-{item.s_hash_id}")
|
||
|
||
def test_gallery_floors_curated_and_top_getters(self):
|
||
from hypn0_site.views import get_floor_curated, get_floor_top
|
||
|
||
svg_bytes = b'<svg><circle/></svg>'
|
||
item_curated = TbHypn0Item(
|
||
s_title="Одобренная картина",
|
||
file_svg=ContentFile(svg_bytes, name="cur.svg"),
|
||
i_level=TbHypn0Item.Level.LEVEL_2,
|
||
f_score=42.5,
|
||
is_public=True,
|
||
)
|
||
item_curated.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
item_top = TbHypn0Item(
|
||
s_title="Шедевр золотого фонда",
|
||
file_svg=ContentFile(svg_bytes, name="top.svg"),
|
||
i_level=TbHypn0Item.Level.IMMORTAL,
|
||
i_likes_count=150,
|
||
f_score=99.0,
|
||
is_public=True,
|
||
)
|
||
item_top.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
# Проверяем выборки
|
||
curated_res = list(get_floor_curated())
|
||
self.assertEqual(len(curated_res), 1)
|
||
self.assertEqual(curated_res[0].pk, item_curated.pk)
|
||
|
||
top_res = list(get_floor_top())
|
||
self.assertEqual(len(top_res), 1)
|
||
self.assertEqual(top_res[0].pk, item_top.pk)
|
||
|
||
# Проверяем отображение на главной странице
|
||
resp_idx = self.client.get(reverse("hypn0_site:index"))
|
||
self.assertEqual(resp_idx.status_code, 200)
|
||
self.assertContains(resp_idx, f"gallery-card-{item_curated.s_hash_id}")
|
||
self.assertContains(resp_idx, f"gallery-card-{item_top.s_hash_id}")
|
||
|
||
|
||
class CardBgStyleTests(BaseMediaTestCase):
|
||
"""Тестирование вычисления адаптивных стилей подложки карточки."""
|
||
|
||
def test_extreme_white_color_forces_dark_background(self):
|
||
item = TbHypn0Item(j_metadata={"color": "#ffffff"})
|
||
style = item.card_bg_style
|
||
self.assertIn("--card-bg-light: rgb(12, 12, 15);", style)
|
||
self.assertIn("--card-bg-dark: rgb(12, 12, 15);", style)
|
||
|
||
def test_extreme_black_color_forces_light_background(self):
|
||
item = TbHypn0Item(j_metadata={"color": "#000000"})
|
||
style = item.card_bg_style
|
||
self.assertIn("--card-bg-light: rgb(244, 244, 246);", style)
|
||
self.assertIn("--card-bg-dark: rgb(244, 244, 246);", style)
|
||
|
||
def test_colorful_intermediate_color_creates_theme_adaptive_background(self):
|
||
item = TbHypn0Item(j_metadata={"color": "#a855ff"})
|
||
style = item.card_bg_style
|
||
self.assertIn("--card-bg-light: rgb(250, 247, 252);", style)
|
||
self.assertIn("--card-bg-dark: rgb(20, 14, 33);", style)
|
||
|
||
def test_fallback_on_empty_or_invalid_color(self):
|
||
item_none = TbHypn0Item(j_metadata=None)
|
||
self.assertIn("--card-bg-light:", item_none.card_bg_style)
|
||
self.assertIn("--card-bg-dark:", item_none.card_bg_style)
|
||
|
||
item_invalid = TbHypn0Item(j_metadata={"color": "invalid-hex"})
|
||
self.assertIn("--card-bg-light:", item_invalid.card_bg_style)
|
||
self.assertIn("--card-bg-dark:", item_invalid.card_bg_style)
|
||
|
||
def test_card_svg_property_and_shadow_dom_rendering(self):
|
||
item = TbHypn0Item(
|
||
s_title="Тестовый SVG",
|
||
file_svg=ContentFile(b'<svg id="test-svg"><circle/></svg>', name="test_card.svg"),
|
||
j_metadata={"color": "#10b981"},
|
||
is_public=True,
|
||
)
|
||
item.save(visitor_uuid_or_fp="123e4567-e89b-12d3-a456-426614174000")
|
||
self.assertIn('id="test-svg"', item.card_svg)
|
||
|
||
response = self.client.get(reverse("hypn0_site:index"))
|
||
self.assertContains(response, 'template shadowrootmode="open"')
|
||
self.assertContains(response, "hypn0-card-svg")
|
||
self.assertContains(response, "--hypn0-play: running !important")
|
||
|
||
|
||
class GalleryArchiveTests(BaseMediaTestCase):
|
||
"""Тестирование общего архива галереи /gallery (фильтры, сортировка, пагинация)."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.vid = "123e4567-e89b-12d3-a456-426614174000"
|
||
svg_bytes = b'<svg><circle/></svg>'
|
||
|
||
# Создаем картины разных уровней
|
||
self.item_fresh = TbHypn0Item(
|
||
s_title="Свежая картина",
|
||
file_svg=ContentFile(svg_bytes, name="fresh.svg"),
|
||
i_level=TbHypn0Item.Level.CANDIDATE,
|
||
i_likes_count=5,
|
||
i_views_count=10,
|
||
f_score=1.5,
|
||
is_public=True,
|
||
)
|
||
self.item_fresh.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
self.item_curated = TbHypn0Item(
|
||
s_title="Кураторская картина",
|
||
file_svg=ContentFile(svg_bytes, name="curated.svg"),
|
||
i_level=TbHypn0Item.Level.LEVEL_2,
|
||
i_likes_count=50,
|
||
i_views_count=200,
|
||
f_score=80.0,
|
||
is_public=True,
|
||
)
|
||
self.item_curated.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
self.item_top = TbHypn0Item(
|
||
s_title="Бессмертная картина",
|
||
file_svg=ContentFile(svg_bytes, name="top.svg"),
|
||
i_level=TbHypn0Item.Level.IMMORTAL,
|
||
i_likes_count=500,
|
||
i_views_count=2000,
|
||
f_score=150.0,
|
||
is_public=True,
|
||
)
|
||
self.item_top.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
def test_gallery_archive_default_renders_all_and_gravity(self):
|
||
url = reverse("hypn0_site:gallery_archive")
|
||
response = self.client.get(url)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, "Галерея транса")
|
||
self.assertContains(response, "ARCHIVE")
|
||
self.assertContains(response, "Все волны")
|
||
self.assertContains(response, "Инкубатор")
|
||
self.assertContains(response, "Одобрено")
|
||
self.assertContains(response, "Золотой фонд")
|
||
# Все 3 картины присутствуют в выдаче
|
||
self.assertContains(response, f"gallery-card-{self.item_fresh.s_hash_id}")
|
||
self.assertContains(response, f"gallery-card-{self.item_curated.s_hash_id}")
|
||
self.assertContains(response, f"gallery-card-{self.item_top.s_hash_id}")
|
||
|
||
# Проверяем правильный порядок (по f_score DESC: top, curated, fresh)
|
||
page_items = list(response.context["page_obj"])
|
||
self.assertEqual(page_items[0].pk, self.item_top.pk)
|
||
self.assertEqual(page_items[1].pk, self.item_curated.pk)
|
||
self.assertEqual(page_items[2].pk, self.item_fresh.pk)
|
||
|
||
def test_gallery_archive_filter_by_floor(self):
|
||
url = reverse("hypn0_site:gallery_archive")
|
||
|
||
# Фильтр: Инкубатор (fresh)
|
||
resp_fresh = self.client.get(url, data={"floor": "fresh"})
|
||
self.assertEqual(resp_fresh.status_code, 200)
|
||
fresh_ids = [it.pk for it in resp_fresh.context["page_obj"]]
|
||
self.assertIn(self.item_fresh.pk, fresh_ids)
|
||
self.assertNotIn(self.item_curated.pk, fresh_ids)
|
||
self.assertNotIn(self.item_top.pk, fresh_ids)
|
||
|
||
# Фильтр: Одобрено (curated)
|
||
resp_curated = self.client.get(url, data={"floor": "curated"})
|
||
self.assertEqual(resp_curated.status_code, 200)
|
||
curated_ids = [it.pk for it in resp_curated.context["page_obj"]]
|
||
self.assertNotIn(self.item_fresh.pk, curated_ids)
|
||
self.assertIn(self.item_curated.pk, curated_ids)
|
||
self.assertNotIn(self.item_top.pk, curated_ids)
|
||
|
||
# Фильтр: Золотой фонд (top)
|
||
resp_top = self.client.get(url, data={"floor": "top"})
|
||
self.assertEqual(resp_top.status_code, 200)
|
||
top_ids = [it.pk for it in resp_top.context["page_obj"]]
|
||
self.assertNotIn(self.item_fresh.pk, top_ids)
|
||
self.assertNotIn(self.item_curated.pk, top_ids)
|
||
self.assertIn(self.item_top.pk, top_ids)
|
||
|
||
def test_gallery_archive_sorting(self):
|
||
url = reverse("hypn0_site:gallery_archive")
|
||
|
||
# Сортировка: по числу лайков
|
||
resp_likes = self.client.get(url, data={"sort": "likes"})
|
||
self.assertEqual(resp_likes.status_code, 200)
|
||
likes_items = list(resp_likes.context["page_obj"])
|
||
self.assertEqual(likes_items[0].pk, self.item_top.pk)
|
||
self.assertEqual(likes_items[-1].pk, self.item_fresh.pk)
|
||
|
||
# Сортировка: по числу просмотров (наименее просмотренные первые)
|
||
resp_views = self.client.get(url, data={"sort": "views"})
|
||
self.assertEqual(resp_views.status_code, 200)
|
||
views_items = list(resp_views.context["page_obj"])
|
||
self.assertEqual(views_items[0].pk, self.item_fresh.pk)
|
||
self.assertEqual(views_items[-1].pk, self.item_top.pk)
|
||
|
||
def test_gallery_archive_pagination_and_link_preservation(self):
|
||
svg_bytes = b'<svg><circle/></svg>'
|
||
# Создаем еще 15 свежих картин (всего 18 в базе)
|
||
for i in range(15):
|
||
item = TbHypn0Item(
|
||
s_title=f"Доп #{i}",
|
||
file_svg=ContentFile(svg_bytes, name=f"extra_{i}.svg"),
|
||
i_level=TbHypn0Item.Level.CANDIDATE,
|
||
f_score=float(i),
|
||
is_public=True,
|
||
)
|
||
item.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
url = reverse("hypn0_site:gallery_archive")
|
||
resp_p1 = self.client.get(url, data={"floor": "all", "sort": "likes"})
|
||
self.assertEqual(resp_p1.status_code, 200)
|
||
self.assertEqual(len(resp_p1.context["page_obj"]), 16)
|
||
self.assertContains(resp_p1, "Фаза 1 из 2")
|
||
self.assertContains(resp_p1, "<title>Галерея транса — Каталог анимированных картин | HypnoSVG</title>")
|
||
# Проверяем сохранение query-параметров в ссылках пагинатора
|
||
self.assertContains(resp_p1, "floor=all&sort=likes&page=2")
|
||
|
||
resp_p2 = self.client.get(url, data={"floor": "all", "sort": "likes", "page": 2})
|
||
self.assertEqual(resp_p2.status_code, 200)
|
||
self.assertEqual(len(resp_p2.context["page_obj"]), 2)
|
||
self.assertContains(resp_p2, "Фаза 2 из 2")
|
||
self.assertContains(resp_p2, "<title>Галерея транса — Каталог анимированных картин (Фаза 2) | HypnoSVG</title>")
|
||
self.assertContains(resp_p2, "floor=all&sort=likes&page=1")
|
||
|
||
# Проверяем Title при фильтрации по этажу
|
||
resp_curated = self.client.get(url, data={"floor": "curated"})
|
||
self.assertContains(resp_curated, "<title>Одобрено Мозговым Слизнем | HypnoSVG</title>")
|
||
|
||
|
||
class RescoreCommandTests(BaseMediaTestCase):
|
||
"""Тестирование management-команды rescore."""
|
||
|
||
def setUp(self):
|
||
self.vid = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
|
||
svg_bytes = b'<svg><circle/></svg>'
|
||
self.item_immortal = TbHypn0Item(
|
||
s_title="Бессмертная картина",
|
||
file_svg=ContentFile(svg_bytes, name="immortal.svg"),
|
||
i_level=TbHypn0Item.Level.IMMORTAL,
|
||
f_score=0.0,
|
||
is_public=True,
|
||
)
|
||
self.item_immortal.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
self.item_candidate = TbHypn0Item(
|
||
s_title="Свежая картина",
|
||
file_svg=ContentFile(svg_bytes, name="cand.svg"),
|
||
i_level=TbHypn0Item.Level.CANDIDATE,
|
||
f_score=0.0,
|
||
is_public=True,
|
||
)
|
||
self.item_candidate.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
def test_rescore_includes_immortal_and_applies_bonus(self):
|
||
# Добавляем голос
|
||
TbVote.objects.create(
|
||
k_item=self.item_immortal,
|
||
s_fingerprint="fp1",
|
||
i_direction=TbVote.Direction.LIKE,
|
||
)
|
||
out = io.StringIO()
|
||
call_command("rescore", stdout=out)
|
||
self.item_immortal.refresh_from_db()
|
||
self.item_candidate.refresh_from_db()
|
||
|
||
# Проверяем, что f_score рассчитан (> 0) и для бессмертной, и для кандидата
|
||
self.assertGreater(self.item_immortal.f_score, 0.0)
|
||
self.assertGreater(self.item_candidate.f_score, 0.0)
|
||
# У бессмертной картины бонус выше (5.0 против 1.0)
|
||
self.assertGreater(self.item_immortal.f_score, self.item_candidate.f_score)
|
||
# Статус бессмертной картины защищен и не должен измениться
|
||
self.assertEqual(self.item_immortal.i_level, TbHypn0Item.Level.IMMORTAL)
|
||
|
||
|
||
class SitemapTests(BaseMediaTestCase):
|
||
"""Тестирование автоматической генерации sitemap.xml."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.vid = "123e4567-e89b-12d3-a456-426614174000"
|
||
svg_bytes = b'<svg><circle/></svg>'
|
||
|
||
# Создаем публичные картины разных уровней
|
||
self.item_immortal = TbHypn0Item(
|
||
s_title="Шедевр транса",
|
||
file_svg=ContentFile(svg_bytes, name="immortal.svg"),
|
||
i_level=TbHypn0Item.Level.IMMORTAL,
|
||
is_public=True,
|
||
)
|
||
self.item_immortal.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
self.item_curated = TbHypn0Item(
|
||
s_title="Одобренная картина",
|
||
file_svg=ContentFile(svg_bytes, name="curated.svg"),
|
||
i_level=TbHypn0Item.Level.LEVEL_2,
|
||
is_public=True,
|
||
)
|
||
self.item_curated.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
self.item_fresh = TbHypn0Item(
|
||
s_title="Свежий шум",
|
||
file_svg=ContentFile(svg_bytes, name="fresh.svg"),
|
||
i_level=TbHypn0Item.Level.CANDIDATE,
|
||
is_public=True,
|
||
)
|
||
self.item_fresh.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
# Создаем непубличную картину и заблокированную (шейминг)
|
||
self.item_private = TbHypn0Item(
|
||
s_title="Приватная картина",
|
||
file_svg=ContentFile(svg_bytes, name="private.svg"),
|
||
i_level=TbHypn0Item.Level.CANDIDATE,
|
||
is_public=False,
|
||
)
|
||
self.item_private.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
self.item_shamed = TbHypn0Item(
|
||
s_title="Заблокированная картина",
|
||
file_svg=ContentFile(svg_bytes, name="shamed.svg"),
|
||
i_level=TbHypn0Item.Level.SHAMED,
|
||
is_public=True,
|
||
)
|
||
self.item_shamed.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
def test_item_get_absolute_url(self):
|
||
expected_url = f"/gallery/{self.item_immortal.s_hash_id}"
|
||
self.assertEqual(self.item_immortal.get_absolute_url(), expected_url)
|
||
|
||
def test_sitemap_xml_renders_valid_xml_with_static_and_gallery_pages(self):
|
||
response = self.client.get("/sitemap.xml")
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertIn("xml", response["Content-Type"])
|
||
|
||
content = response.content.decode("utf-8")
|
||
self.assertIn("<urlset", content)
|
||
|
||
# Проверяем наличие главных статических страниц
|
||
self.assertIn("<loc>http://testserver/</loc>", content)
|
||
self.assertIn("<loc>http://testserver/gallery</loc>", content)
|
||
|
||
# Проверяем наличие публичных картин
|
||
self.assertIn(f"<loc>http://testserver/gallery/{self.item_immortal.s_hash_id}</loc>", content)
|
||
self.assertIn(f"<loc>http://testserver/gallery/{self.item_curated.s_hash_id}</loc>", content)
|
||
self.assertIn(f"<loc>http://testserver/gallery/{self.item_fresh.s_hash_id}</loc>", content)
|
||
|
||
# Проверяем отсутствие приватных и заблокированных картин
|
||
self.assertNotIn(f"<loc>http://testserver/gallery/{self.item_private.s_hash_id}</loc>", content)
|
||
self.assertNotIn(f"<loc>http://testserver/gallery/{self.item_shamed.s_hash_id}</loc>", content)
|
||
|
||
def test_sitemap_priorities_and_changefreq(self):
|
||
response = self.client.get("/sitemap.xml")
|
||
self.assertEqual(response.status_code, 200)
|
||
content = response.content.decode("utf-8")
|
||
|
||
# Приоритет главной страницы 1.0, архива 0.9, бессмертной 0.9, кураторской 0.8
|
||
self.assertIn("<priority>1.0</priority>", content)
|
||
self.assertIn("<priority>0.9</priority>", content)
|
||
self.assertIn("<priority>0.8</priority>", content)
|
||
self.assertIn("<priority>0.6</priority>", content)
|
||
self.assertIn("<changefreq>daily</changefreq>", content)
|
||
self.assertIn("<changefreq>weekly</changefreq>", content)
|
||
|
||
|
||
class BlogPostModelAndAdminTests(BaseMediaTestCase):
|
||
"""Тестирование модели TbBlogPost, связей с генерациями, цепочек постов и админки."""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.vid = "123e4567-e89b-12d3-a456-426614174000"
|
||
svg_bytes = b'<svg><circle/></svg>'
|
||
self.item = TbHypn0Item(
|
||
s_title="Тестовая картина для статьи",
|
||
file_svg=ContentFile(svg_bytes, name="post_item.svg"),
|
||
i_level=TbHypn0Item.Level.CANDIDATE,
|
||
is_public=True,
|
||
)
|
||
self.item.save(visitor_uuid_or_fp=self.vid)
|
||
|
||
def test_post_linked_to_generation_sets_immortal_level(self):
|
||
post = TbBlogPost.objects.create(
|
||
s_title="Пост о генерации",
|
||
slug="post-o-generatsii",
|
||
s_teaser="Тизер",
|
||
s_content="Контент",
|
||
k_item=self.item,
|
||
is_published=True,
|
||
)
|
||
self.item.refresh_from_db()
|
||
# Проверяем, что уровень генерации повышен до IMMORTAL
|
||
self.assertEqual(self.item.i_level, TbHypn0Item.Level.IMMORTAL)
|
||
self.assertEqual(list(self.item.blog_posts.all()), [post])
|
||
|
||
def test_post_hierarchy_and_chains(self):
|
||
parent_post = TbBlogPost.objects.create(
|
||
s_title="Топ-10 генераций лета",
|
||
slug="top-10-generatsiy-leta",
|
||
s_teaser="Подборка",
|
||
s_content="Общий обзор",
|
||
is_published=True,
|
||
)
|
||
child1 = TbBlogPost.objects.create(
|
||
s_title="Генерация 1: Неоновый слизень",
|
||
slug="generatsiya-1",
|
||
k_parent_post=parent_post,
|
||
i_order=1,
|
||
is_published=True,
|
||
)
|
||
child2 = TbBlogPost.objects.create(
|
||
s_title="Генерация 2: Глубокий транс",
|
||
slug="generatsiya-2",
|
||
k_parent_post=parent_post,
|
||
i_order=2,
|
||
is_published=True,
|
||
)
|
||
|
||
# Проверяем связь родитель-потомки и сортировку
|
||
children = list(parent_post.child_posts.order_by("i_order"))
|
||
self.assertEqual(children, [child1, child2])
|
||
self.assertEqual(child1.k_parent_post, parent_post)
|
||
|
||
def test_post_self_reference_validation(self):
|
||
post = TbBlogPost.objects.create(
|
||
s_title="Автономный пост",
|
||
slug="avtonomniy-post",
|
||
)
|
||
post.k_parent_post = post
|
||
with self.assertRaises(ValidationError) as ctx:
|
||
post.clean()
|
||
self.assertIn("k_parent_post", ctx.exception.message_dict)
|
||
|
||
def test_parent_cannot_become_child_validation(self):
|
||
parent_post = TbBlogPost.objects.create(
|
||
s_title="Родительский сборник",
|
||
slug="roditelskiy-sbornik",
|
||
)
|
||
TbBlogPost.objects.create(
|
||
s_title="Глава 1",
|
||
slug="glava-1",
|
||
k_parent_post=parent_post,
|
||
)
|
||
another_parent = TbBlogPost.objects.create(
|
||
s_title="Другой сборник",
|
||
slug="drugoy-sbornik",
|
||
)
|
||
# Родительский пост с детьми пытается сослаться на другой сборник
|
||
parent_post.k_parent_post = another_parent
|
||
with self.assertRaises(ValidationError) as ctx:
|
||
parent_post.clean()
|
||
self.assertIn("k_parent_post", ctx.exception.message_dict)
|
||
|
||
def test_cannot_set_child_as_parent_validation(self):
|
||
grandparent = TbBlogPost.objects.create(
|
||
s_title="Главный сборник",
|
||
slug="glavniy-sbornik",
|
||
)
|
||
parent_post = TbBlogPost.objects.create(
|
||
s_title="Подраздел",
|
||
slug="podrazdel",
|
||
k_parent_post=grandparent,
|
||
)
|
||
child = TbBlogPost.objects.create(
|
||
s_title="Внук",
|
||
slug="vnuk",
|
||
)
|
||
# Попытка назначить родителем статью, которая сама является ребенком
|
||
child.k_parent_post = parent_post
|
||
with self.assertRaises(ValidationError) as ctx:
|
||
child.clean()
|
||
self.assertIn("k_parent_post", ctx.exception.message_dict)
|
||
|
||
def test_admin_has_svg_indicator(self):
|
||
post_with_svg = TbBlogPost(s_title="С SVG", k_item=self.item)
|
||
post_without_svg = TbBlogPost(s_title="Без SVG", k_item=None)
|
||
|
||
site = AdminSite()
|
||
admin_obj = TbBlogPostAdmin(TbBlogPost, site)
|
||
|
||
self.assertTrue(admin_obj.has_svg(post_with_svg))
|
||
self.assertFalse(admin_obj.has_svg(post_without_svg))
|