Files
2026-hypn0-site/hypn0/hypn0_site/tests.py
T

926 lines
41 KiB
Python

import io
import shutil
import tempfile
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from PIL import Image
from .forms import HalftoneGenerateForm
from .models import 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_floor_fresh_pagination(self):
# Создаем 20 картин
svg_bytes = b'<svg><circle/></svg>'
for i in range(20):
item = TbHypn0Item(
s_title=f"Кандидат #{i}",
file_svg=ContentFile(svg_bytes, name=f"cand_{i}.svg"),
i_views_count=i,
i_level=TbHypn0Item.Level.CANDIDATE,
is_public=True,
)
item.save(visitor_uuid_or_fp=self.vid)
# Страница 1 (должно быть 16 штук)
url = reverse("hypn0_site:gallery_floor", kwargs={"floor_slug": "fresh"})
response_p1 = self.client.get(url)
self.assertEqual(response_p1.status_code, 200)
self.assertContains(response_p1, "Плеск бессознательного")
self.assertContains(response_p1, "FRESH STREAM")
self.assertEqual(len(response_p1.context["page_obj"]), 16)
self.assertContains(response_p1, "Фаза 1 из 2")
# Страница 2 (должно быть 4 штуки)
response_p2 = self.client.get(url, data={"page": 2})
self.assertEqual(response_p2.status_code, 200)
self.assertEqual(len(response_p2.context["page_obj"]), 4)
self.assertContains(response_p2, "Фаза 2 из 2")
def test_gallery_floors_curated_and_top(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)
# Проверяем рендеринг страниц этажей
url_curated = reverse("hypn0_site:gallery_floor", kwargs={"floor_slug": "curated"})
resp_c = self.client.get(url_curated)
self.assertEqual(resp_c.status_code, 200)
self.assertContains(resp_c, "Одобрено Мозговым Слизнем")
self.assertContains(resp_c, "CURATED")
url_top = reverse("hypn0_site:gallery_floor", kwargs={"floor_slug": "top"})
resp_t = self.client.get(url_top)
self.assertEqual(resp_t.status_code, 200)
self.assertContains(resp_t, "Глубокий транс")
self.assertContains(resp_t, "TOP TIER")
# Проверяем отображение на главной странице
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}")
def test_gallery_floor_unknown_404(self):
url = reverse("hypn0_site:gallery_floor", kwargs={"floor_slug": "non_existent"})
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
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")