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

1277 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))