import io import re 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) self.assertIn("", svg) self.assertIn('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) self.assertIn("", 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('0", binary_svg) self.assertIn(">1", 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(" 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(" 0) # Проверка детерминированности по seed self.assertEqual(generate_hypno_title(seed=42), title) # Проверяем генерацию множества названий разной длины titles = [generate_hypno_title(seed=i) for i in range(100)] self.assertTrue(any(" " in t for t in titles)) self.assertTrue(any(" " not in t for t in titles)) 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 = '' 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 = '' 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, "Brain Slugs are rioting!") self.assertContains(response, "Submit and Publish") 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, "In the Trance Gallery") self.assertContains(response, "psychic neural web expanding…") # Проверяем запись в БД 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, "No SVG data for publication") self.assertEqual(TbHypn0Item.objects.count(), 0) class SvgAnalysisAndActiveSvgTests(BaseMediaTestCase): """Тестирование анализа структуры 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_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 = ( '' '' ) 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 = ( '' '' '' '' '' '' '' '' ) 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 = ( '' '' '' '' '' ) 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, "Vector Anatomy") self.assertContains(response, "Psych-Grid Synthesis") self.assertContains(response, "Astral Passport") self.assertContains(response, "Copy SVG code") self.assertContains(response, "Download .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, "Surrender to Hypnotoad!") 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'' 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'' 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, "Matrix of the Unconscious") self.assertContains(response, "5 nodes") 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'' # Свежий кандидат с 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, "Incubator of the Unconscious") 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_floors_curated_and_top_getters(self): from hypn0_site.views import get_floor_curated, get_floor_top svg_bytes = b'' 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'', 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, "hypn0LazySvg") self.assertContains(response, "hypn0-card-svg") self.assertContains(response, item.file_svg.url) class GalleryArchiveTests(BaseMediaTestCase): """Тестирование общего архива галереи /gallery (фильтры, сортировка, пагинация).""" def setUp(self): super().setUp() self.vid = "123e4567-e89b-12d3-a456-426614174000" svg_bytes = b'' # Создаем картины разных уровней 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, "Trance Gallery") self.assertContains(response, "ARCHIVE") self.assertContains(response, "All waves") self.assertContains(response, "Incubator") self.assertContains(response, "Approved") self.assertContains(response, "Hall of Fame") # Все 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'' # Создаем еще 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, "Phase 1 of 2") self.assertContains(resp_p1, "Trance Gallery — Animated SVG Catalog | HypnoSVG") # Проверяем сохранение 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, "Phase 2 of 2") self.assertContains(resp_p2, "Trance Gallery — Animated SVG Catalog (Phase 2) | HypnoSVG") self.assertContains(resp_p2, "floor=all&sort=likes&page=1") # Проверяем Title при фильтрации по этажу resp_curated = self.client.get(url, data={"floor": "curated"}) self.assertContains(resp_curated, "Approved by Brain Slug | HypnoSVG") class RescoreCommandTests(BaseMediaTestCase): """Тестирование management-команды rescore.""" def setUp(self): self.vid = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" svg_bytes = b'' 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'' # Создаем публичные картины разных уровней 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("http://testserver/", content) self.assertIn("http://testserver/gallery", content) # Проверяем наличие публичных картин self.assertIn(f"http://testserver/gallery/{self.item_immortal.s_hash_id}", content) self.assertIn(f"http://testserver/gallery/{self.item_curated.s_hash_id}", content) self.assertIn(f"http://testserver/gallery/{self.item_fresh.s_hash_id}", content) # Проверяем отсутствие приватных и заблокированных картин self.assertNotIn(f"http://testserver/gallery/{self.item_private.s_hash_id}", content) self.assertNotIn(f"http://testserver/gallery/{self.item_shamed.s_hash_id}", 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("1.0", content) self.assertIn("0.9", content) self.assertIn("0.8", content) self.assertIn("0.6", content) self.assertIn("daily", content) self.assertIn("weekly", content) class BlogPostModelAndAdminTests(BaseMediaTestCase): """Тестирование модели TbBlogPost, связей с генерациями, цепочек постов и админки.""" def setUp(self): super().setUp() self.vid = "123e4567-e89b-12d3-a456-426614174000" svg_bytes = b'' 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)) def test_gallery_detail_renders_blog_teaser(self): # До создания постов блок блога не отображается initial_response = self.client.get(f"/gallery/{self.item.s_hash_id}") self.assertEqual(initial_response.status_code, 200) self.assertNotContains(initial_response, "Материалы хроник гипноза") # Опубликованный пост с тизером published_post = TbBlogPost.objects.create( s_title="Хроники гипно-транса: Урок 1", slug="khroniki-hypno-transa-urok-1", s_teaser="Секретные формулы векторного воздействия на сознание.", s_content="Полный текст урока...", k_item=self.item, is_published=True, ) # Неопубликованный черновик — не должен отображаться unpublished_post = TbBlogPost.objects.create( s_title="Секретный черновик", slug="sekretniy-chernovik", s_teaser="Черновик не должен быть виден.", s_content="Скрытый контент...", k_item=self.item, is_published=False, ) response = self.client.get(f"/gallery/{self.item.s_hash_id}") self.assertEqual(response.status_code, 200) # Проверяем, что отображается один из динамических заголовков секции content = response.content.decode("utf-8") possible_headings = ( "Signals from Parallel Continuum", "Noosphere Psycho-Artifacts", "Astral Council Field Notes", "Reflexive Responses of Brain Slugs", "Dispatches from Subconscious Depths", "Materials from Hypno Chronicles", ) self.assertTrue(any(heading in content for heading in possible_headings)) self.assertContains(response, "Хроники гипно-транса: Урок 1") self.assertContains(response, "Секретные формулы векторного воздействия") self.assertContains(response, "/blog/khroniki-hypno-transa-urok-1") self.assertNotContains(response, "Секретный черновик") def test_blog_feed_view(self): # Создаем несколько опубликованных постов и один черновик for i in range(1, 12): TbBlogPost.objects.create( s_title=f"Тестовая депеша {i}", slug=f"testovaya-depesha-{i}", s_teaser=f"Краткий лид депеши {i}", s_content=f"

Полное содержание {i}

", is_published=True, ) TbBlogPost.objects.create( s_title="Черновик ноосферы", slug="chernovik-noosfery", s_teaser="Скрытый черновик", s_content="

Скрыто

", is_published=False, ) # Проверка 1-й страницы ленты блога response = self.client.get("/blog") self.assertEqual(response.status_code, 200) self.assertContains(response, "Hypno Chronicles") self.assertContains(response, "Тестовая депеша 11") self.assertNotContains(response, "Черновик ноосферы") self.assertContains(response, "page=2") # Проверка 2-й страницы ленты блога response_p2 = self.client.get("/blog?page=2") self.assertEqual(response_p2.status_code, 200) self.assertContains(response_p2, "Тестовая депеша 1") def test_blog_detail_view_and_navigation(self): # Создаем серию постов series_parent = TbBlogPost.objects.create( s_title="Цикл: Основы векторного гипноза", slug="tsikl-osnovy-gipnoza", s_teaser="Вводный обзор всего цикла", s_content="

Введение в серию...

", is_published=True, ) ch1 = TbBlogPost.objects.create( s_title="Глава 1: Первичная модуляция", slug="glava-1-modulyatsiya", s_teaser="Лид первой главы", s_content="

Контент главы 1

", k_parent_post=series_parent, i_order=1, k_item=self.item, is_published=True, ) ch2 = TbBlogPost.objects.create( s_title="Глава 2: Фазовый сдвиг матрицы", slug="glava-2-sdvig", s_teaser="Лид второй главы", s_content="

Контент главы 2

", k_parent_post=series_parent, i_order=2, is_published=True, ) # 1. Проверка родительского хаб-поста resp_parent = self.client.get(f"/blog/{series_parent.slug}") self.assertEqual(resp_parent.status_code, 200) self.assertContains(resp_parent, "Цикл: Основы векторного гипноза") self.assertContains(resp_parent, "Chapters in This Series") self.assertContains(resp_parent, "Глава 1: Первичная модуляция") self.assertContains(resp_parent, "Глава 2: Фазовый сдвиг матрицы") # 2. Проверка первой главы (связанный SVG, ссылка на родителя, следующая глава, хлебные крошки и JSON-LD) resp_ch1 = self.client.get(f"/blog/{ch1.slug}") self.assertEqual(resp_ch1.status_code, 200) self.assertContains(resp_ch1, "Глава 1: Первичная модуляция") self.assertContains(resp_ch1, "Part of the series:") self.assertContains(resp_ch1, self.item.s_title) self.assertContains(resp_ch1, f"#{self.item.s_hash_id}") self.assertContains(resp_ch1, f"/gallery/{self.item.s_hash_id}") self.assertContains(resp_ch1, "Download SVG") self.assertContains(resp_ch1, "Weight:") self.assertContains(resp_ch1, "Next chapter in series") self.assertContains(resp_ch1, "Глава 2: Фазовый сдвиг матрицы") # Проверка хлебных крошек self.assertContains(resp_ch1, 'aria-label="Breadcrumbs"') self.assertContains(resp_ch1, ch1.blog_nav_title) self.assertContains(resp_ch1, "Chapter 1 of 2") self.assertContains(resp_ch1, 'aria-current="page"') # Проверка JSON-LD BreadcrumbList self.assertContains(resp_ch1, '"@type": "BreadcrumbList"') self.assertContains(resp_ch1, '"name": "Home"') self.assertContains(resp_ch1, '"name": "Hypno Chronicles"') self.assertContains(resp_ch1, series_parent.s_title) # 3. Проверка второй главы (ссылка назад на главу 1) resp_ch2 = self.client.get(f"/blog/{ch2.slug}") self.assertEqual(resp_ch2.status_code, 200) self.assertContains(resp_ch2, "Previous chapter in series") self.assertContains(resp_ch2, "Глава 1: Первичная модуляция") # 4. Проверка доступа по числовому ID resp_by_id = self.client.get(f"/blog/{ch1.pk}") self.assertEqual(resp_by_id.status_code, 200) self.assertContains(resp_by_id, "Глава 1: Первичная модуляция") # 5. Проверка 404 для несуществующего слага resp_404 = self.client.get("/blog/non-existent-slug-xyz") self.assertEqual(resp_404.status_code, 404) @override_settings(DEBUG=False) def test_custom_404_page_rendering(self): """Проверка отдачи кастомной страницы 404 в режиме DEBUG=False.""" response = self.client.get("/totally-non-existent-hypno-route-404") self.assertEqual(response.status_code, 404) self.assertContains(response, "Hypno-Node Not Found", status_code=404) self.assertContains(response, "MATRIX ANOMALY // CODE 404", status_code=404) self.assertContains(response, "return-btn", status_code=404) self.assertContains(response, "Erasing traces of the phantom page", status_code=404) @override_settings(DEBUG=True) def test_debug_error_preview_routing(self): """Проверка dev-маршрутизации предпросмотра страниц ошибок при DEBUG=True.""" # 1. Индексный список страниц ошибок resp_list = self.client.get("/_error/") self.assertEqual(resp_list.status_code, 200) self.assertContains(resp_list, "Error Pages Debug Center") self.assertContains(resp_list, "/_error/404") self.assertContains(resp_list, "ready to preview") # 2. Проверка каждого кода ошибки expected_checks = [ ("400", 400, "Perceptual Protocol Mismatch"), ("401", 401, "Subconscious Identity Required"), ("403", 403, "Cognitive Lockdown Active"), ("404", 404, "Hypno-Node Not Found"), ("413", 413, "Sensory Channel Saturation"), ("429", 429, "Too Many Thoughts per Second"), ("500", 500, "Introspection Neural Lock"), ("502", 502, "Uplink to Hippocampus Lost"), ("503", 503, "Scheduled Hypnotherapy in Progress"), ("504", 504, "Astral Synapse Timed Out"), ("under_reconstruction", 200, "Brain Sector Under Reconstruction"), ] for code, expected_status, text_fragment in expected_checks: resp = self.client.get(f"/_error/{code}") self.assertEqual(resp.status_code, expected_status, f"Ошибка при проверке кода {code}") self.assertContains(resp, text_fragment, status_code=expected_status) self.assertContains(resp, "return-btn", status_code=expected_status) self.assertContains(resp, "logo-hypn0.svg", status_code=expected_status) self.assertContains(resp, "thinking.svg", status_code=expected_status) # 3. Предпросмотр с суффиксом .html resp_404_html = self.client.get("/_error/404.html") self.assertEqual(resp_404_html.status_code, 404) self.assertContains(resp_404_html, "Hypno-Node Not Found", status_code=404) # 4. Несуществующий шаблон ошибки в dev-режиме resp_non_existent = self.client.get("/_error/non-existent-code-999") self.assertEqual(resp_non_existent.status_code, 404) self.assertContains(resp_non_existent, "Template for error 'non-existent-code-999' not found", status_code=404) def test_custom_django_error_handlers(self): """Проверка прямого вызова стандартных Django-обработчиков ошибок.""" from django.test import RequestFactory from hypn0_site.views import error_400, error_403, error_404, error_500 factory = RequestFactory() req400 = factory.get("/bad-request") resp400 = error_400(req400) self.assertEqual(resp400.status_code, 400) self.assertIn("Perceptual Protocol Mismatch", resp400.content.decode("utf-8")) req403 = factory.get("/forbidden") resp403 = error_403(req403) self.assertEqual(resp403.status_code, 403) self.assertIn("Cognitive Lockdown Active", resp403.content.decode("utf-8")) req404 = factory.get("/not-found") resp404 = error_404(req404) self.assertEqual(resp404.status_code, 404) self.assertIn("Hypno-Node Not Found", resp404.content.decode("utf-8")) req500 = factory.get("/server-error") resp500 = error_500(req500) self.assertEqual(resp500.status_code, 500) self.assertIn("Introspection Neural Lock", resp500.content.decode("utf-8")) def test_s_title_plain_property(self): """Проверка очистки HTML-тегов и мнемоник в s_title_plain для моделей TbHypn0Item и TbBlogPost.""" item = TbHypn0Item( s_title="Шедевр ноосферы «Альфа»", s_hash_id="testPlain", ) self.assertEqual(item.s_title_plain, "Шедевр ноосферы «Альфа»") post = TbBlogPost( s_title="Записки Гипножабы: Раздел&Смысл", slug="test-plain", ) self.assertEqual(post.s_title_plain, "Записки Гипножабы: Раздел&Смысл") class RenameGenerationsCommandTests(BaseMediaTestCase): """Тестирование management-команды массового переименования генераций rename_generations.""" def setUp(self): super().setUp() self.vid = "123e4567-e89b-12d3-a456-426614174000" svg_bytes = b'' self.item_ru1 = TbHypn0Item( s_title="Мерцающий транс сознания #42", file_svg=ContentFile(svg_bytes, name="ru1.svg"), is_public=True, ) self.item_ru1.save(visitor_uuid_or_fp=self.vid) self.item_ru2 = TbHypn0Item( s_title="Астральный взгляд Гипножабы", file_svg=ContentFile(svg_bytes, name="ru2.svg"), is_public=True, ) self.item_ru2.save(visitor_uuid_or_fp=self.vid) self.item_en = TbHypn0Item( s_title="Brain Slug Vortex", file_svg=ContentFile(svg_bytes, name="en.svg"), is_public=True, ) self.item_en.save(visitor_uuid_or_fp=self.vid) def test_rename_dry_run_does_not_mutate_db(self): out = io.StringIO() call_command("rename_generations", dry_run=True, stdout=out) output = out.getvalue() self.assertIn("[DRY RUN]", output) self.assertIn("Мерцающий транс сознания #42", output) self.item_ru1.refresh_from_db() self.item_ru2.refresh_from_db() self.item_en.refresh_from_db() self.assertEqual(self.item_ru1.s_title, "Мерцающий транс сознания #42") self.assertEqual(self.item_ru2.s_title, "Астральный взгляд Гипножабы") self.assertEqual(self.item_en.s_title, "Brain Slug Vortex") def test_rename_only_cyrillic_by_default(self): out = io.StringIO() call_command("rename_generations", stdout=out) self.item_ru1.refresh_from_db() self.item_ru2.refresh_from_db() self.item_en.refresh_from_db() # Русские заголовки должны быть заменены на английские (без кириллицы) self.assertFalse(re.search(r"[а-яА-ЯёЁ]", self.item_ru1.s_title)) self.assertFalse(re.search(r"[а-яА-ЯёЁ]", self.item_ru2.s_title)) self.assertTrue(len(self.item_ru1.s_title) > 0) self.assertTrue(len(self.item_ru2.s_title) > 0) # Английский заголовок не должен измениться self.assertEqual(self.item_en.s_title, "Brain Slug Vortex") def test_rename_all_flag_updates_all_items(self): out = io.StringIO() call_command("rename_generations", all=True, stdout=out) self.item_ru1.refresh_from_db() self.item_ru2.refresh_from_db() self.item_en.refresh_from_db() self.assertFalse(re.search(r"[а-яА-ЯёЁ]", self.item_ru1.s_title)) self.assertFalse(re.search(r"[а-яА-ЯёЁ]", self.item_ru2.s_title)) # С флагом --all английский заголовок тоже перегенерируется детерминированно self.assertFalse(re.search(r"[а-яА-ЯёЁ]", self.item_en.s_title)) def test_rename_deterministic_and_seed_offset(self): out = io.StringIO() call_command("rename_generations", seed_offset=100, stdout=out) self.item_ru1.refresh_from_db() first_title = self.item_ru1.s_title # Повторный вызов с тем же seed_offset дает точно такой же результат call_command("rename_generations", seed_offset=100, all=True, stdout=out) self.item_ru1.refresh_from_db() self.assertEqual(self.item_ru1.s_title, first_title) def test_rename_empty_database(self): TbHypn0Item.objects.all().delete() out = io.StringIO() call_command("rename_generations", stdout=out) self.assertIn("нет генераций для обработки", out.getvalue())