import io from django.core.files.base import ContentFile from django.core.files.uploadedfile import SimpleUploadedFile from django.test import Client, TestCase 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 HalftoneServiceTests(TestCase): """Тестирование сервиса генерации SVG (services/halftone.py).""" def setUp(self): # Создаем простое тестовое изображение 50x50 с градиентом self.img = Image.new("RGB", (50, 50), color="white") for x in range(25): for y in range(50): self.img.putpixel((x, y), (0, 0, 0)) def test_encode_to_base36(self): self.assertEqual(encode_to_base36(0), "a") self.assertEqual(encode_to_base36(1), "b") with self.assertRaises(ValueError): encode_to_base36(-1) def test_generate_default_svg(self): svg = generate_halftone_svg(self.img, cols=30, max_radius=6) self.assertIn("", svg) self.assertIn("", svg) self.assertIn('', svg) def test_generate_shapes(self): shapes = ["circle", "ring", "square", "diamond", "triangle", "hexagon", "star", "cross", "line", "wave", "heart"] for shape in shapes: svg = generate_halftone_svg(self.img, cols=20, max_radius=5, shape=shape) self.assertIn("", svg) self.assertIn("", svg) def test_generate_static_blink_zero(self): svg = generate_halftone_svg(self.img, cols=20, blink=0) self.assertIn("animation:none", svg) self.assertNotIn("@keyframes noise", svg) def test_generate_animated_params(self): svg = generate_halftone_svg( self.img, cols=20, blink=8, rotation=12, scale=920, angle=15, color="#ff5500", ) self.assertIn("@keyframes noise", svg) self.assertIn("rotate(12deg)", svg) self.assertIn("scale(0.92)", svg) self.assertIn("#ff5500", svg) def test_generate_from_bytes(self): buf = io.BytesIO() self.img.save(buf, format="PNG") svg = generate_halftone_svg(buf.getvalue(), cols=20) self.assertIn(" width = 10 * 12 = 120, height = 30 * 12 = 360 self.assertIn('viewBox="0 0 120 360"', 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 360 120"', svg_wide) class HalftoneFormTests(TestCase): """Тестирование формы валидации HalftoneGenerateForm.""" def test_form_validation(self): buf = io.BytesIO() img = Image.new("RGB", (30, 30), color="red") img.save(buf, format="PNG") buf.seek(0) uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png") form = HalftoneGenerateForm( data={ "shape": "square", "cols": 60, "max_radius": 7, "blink": 5, "rotation": 10, "scale": 950, "angle": -10, }, files={"image": uploaded}, ) self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data["shape"], "square") self.assertEqual(form.cleaned_data["cols"], 60) class HalftoneViewTests(TestCase): """Тестирование view-обработчиков (index, generate).""" def setUp(self): self.client = Client() def test_index_page(self): response = self.client.get(reverse("hypn0_site:index")) self.assertEqual(response.status_code, 200) self.assertContains(response, "logo-hypn0.svg") self.assertContains(response, 'id="preview"') self.assertContains(response, 'name="csrfmiddlewaretoken"') self.assertContains(response, 'X-CSRFToken') 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, " 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(TestCase): """Тестирование подготовки SVG к галерейному хранению.""" def test_prepare_gallery_svg(self): raw_svg = '' prepared = prepare_gallery_svg(raw_svg) self.assertIn("svg:not(:hover)", prepared) self.assertIn("animation-play-state:paused!important", prepared) class PublishViewTests(TestCase): """Тестирование эндпоинта публикации кандидата в галерею (/publish).""" def setUp(self): self.client = Client() self.sample_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") # Проверяем авторский голос в 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(TestCase): """Тестирование анализа структуры SVG и очистки от паузы.""" def test_prepare_active_svg(self): gallery_svg = '' active = prepare_active_svg(gallery_svg) self.assertNotIn("animation-play-state:paused!important", active) def test_analyze_svg_structure(self): 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(TestCase): """Тестирование страниц детального просмотра картины, скачивания и голосования.""" def setUp(self): self.client = Client() self.vid = "123e4567-e89b-12d3-a456-426614174000" svg_code = ( '' '' '' '' '' ) self.item = TbHypn0Item( s_title="Астральный Транс Сознания #42", file_svg=ContentFile(svg_code.encode("utf-8"), name="test_item.svg"), i_file_size=len(svg_code), 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(TestCase): """Тестирование случайной навигации и пула хэшей (/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'' item = TbHypn0Item( s_title=f"Гипно Картина #{i}", file_svg=ContentFile(svg_code.encode("utf-8"), name=f"test_item_{i}.svg"), i_file_size=len(svg_code), 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(TestCase): """Тестирование Матрицы бессознательного (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'' item = TbHypn0Item( s_title=f"Тестовый транс #{i}", file_svg=ContentFile(svg_code.encode("utf-8"), name=f"test_matrix_{i}.svg"), i_file_size=len(svg_code), 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(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") class GalleryFreshFloorTests(TestCase): """Тестирование 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'' # Свежий кандидат с 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'' 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 = '' 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): # Создаем 10 картин svg_bytes = b'' for i in range(10): 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 (должно быть 8 штук) 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"]), 8) self.assertContains(response_p1, "Фаза 1 из 2") # Страница 2 (должно быть 2 штуки) response_p2 = self.client.get(url, data={"page": 2}) self.assertEqual(response_p2.status_code, 200) self.assertEqual(len(response_p2.context["page_obj"]), 2) self.assertContains(response_p2, "Фаза 2 из 2") 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)