From eae020fd348b88fd9d51658b3317b21e2eaf0a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Tue, 12 May 2026 09:43:45 +0200 Subject: [PATCH] Add component tests --- common/COMPONENT_IMPROVEMENTS.md | 2 + games/static/base.css | 78 +++++++ tests/test_components.py | 387 +++++++++++++++++++++++++++++++ 3 files changed, 467 insertions(+) diff --git a/common/COMPONENT_IMPROVEMENTS.md b/common/COMPONENT_IMPROVEMENTS.md index 1b5a0d2..1e53c67 100644 --- a/common/COMPONENT_IMPROVEMENTS.md +++ b/common/COMPONENT_IMPROVEMENTS.md @@ -39,3 +39,5 @@ Zero test coverage for the entire component system. **Fix**: Add unit tests for each component function — basic rendering, edge cases, and cache hit/miss verification. + +**Done**: 96 unit tests covering all component functions (`Component`, `randomid`, `Popover`, `PopoverTruncated`, `A`, `Button`, `Div`, `Icon`, `Form`, `Input`, `NameWithIcon`, `LinkedPurchase`, `PurchasePrice`, `_render_cached`, `enable_cache`). Includes template rendering, deterministic ID generation, LRU cache behavior, HTML output validation, edge cases, error handling, and model-dependent integration tests. diff --git a/games/static/base.css b/games/static/base.css index ab291cb..e377fbd 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -1403,6 +1403,84 @@ font-size: 0.875rem; } } + .form-input { + appearance: none; + background-color: #fff; + border-color: oklch(55.1% 0.027 264.364); + border-width: 1px; + border-radius: 0px; + padding-top: 0.5rem; + padding-right: 0.75rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + font-size: 1rem; + line-height: 1.5rem; + --tw-shadow: 0 0 #0000; + &:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: oklch(54.6% 0.245 262.881); + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + border-color: oklch(54.6% 0.245 262.881); + } + &::placeholder { + color: oklch(55.1% 0.027 264.364); + opacity: 1; + } + &::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + &::-webkit-date-and-time-value { + min-height: 1.5em; + } + &::-webkit-date-and-time-value { + text-align: inherit; + } + &::-webkit-datetime-edit { + display: inline-flex; + } + &::-webkit-datetime-edit { + padding-top: 0; + padding-bottom: 0; + } + &::-webkit-datetime-edit-year-field { + padding-top: 0; + padding-bottom: 0; + } + &::-webkit-datetime-edit-month-field { + padding-top: 0; + padding-bottom: 0; + } + &::-webkit-datetime-edit-day-field { + padding-top: 0; + padding-bottom: 0; + } + &::-webkit-datetime-edit-hour-field { + padding-top: 0; + padding-bottom: 0; + } + &::-webkit-datetime-edit-minute-field { + padding-top: 0; + padding-bottom: 0; + } + &::-webkit-datetime-edit-second-field { + padding-top: 0; + padding-bottom: 0; + } + &::-webkit-datetime-edit-millisecond-field { + padding-top: 0; + padding-bottom: 0; + } + &::-webkit-datetime-edit-meridiem-field { + padding-top: 0; + padding-bottom: 0; + } + } .block { display: block; } diff --git a/tests/test_components.py b/tests/test_components.py index 92620b8..b8999e0 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -12,6 +12,7 @@ from django.template import TemplateDoesNotExist from django.utils.safestring import SafeText from common import components +from games.models import Platform, Game, Purchase, Session class RenderCachedImplTest(unittest.TestCase): @@ -415,5 +416,391 @@ class ComponentReturnTypeTest(unittest.TestCase): self.assertNotIsInstance(result, tuple) +class ComponentEdgeCasesTest(unittest.TestCase): + """Test Component() edge cases and error handling.""" + + def test_no_template_or_tag_name_raises(self): + with self.assertRaises(ValueError) as ctx: + components.Component(children="hello") + self.assertIn("template or tag_name", str(ctx.exception)) + + def test_single_string_children_wrapped(self): + result = components.Component(tag_name="span", children="hello") + self.assertIn("hello", result) + + def test_multiple_children_joined_with_newlines(self): + result = components.Component( + tag_name="div", children=["a", "b"] + ) + self.assertIn("a", result) + self.assertIn("b", result) + self.assertIn("
", result) + self.assertIn("
", result) + + def test_attributes_serialized_correctly(self): + result = components.Component( + tag_name="div", attributes=[("class", "foo"), ("id", "bar")] + ) + self.assertIn('class="foo"', result) + self.assertIn('id="bar"', result) + + def test_empty_attributes_no_extra_space(self): + result = components.Component(tag_name="span", children="x") + self.assertEqual(result, "x") + self.assertNotIn(" ", result) + + def test_unavailable_icon_falls_back(self): + result = components.Icon("zzz_nonexistent_platform") + self.assertIsInstance(result, SafeText) + self.assertIn("> to >> in the wrapped_content + self.assertIn(">>", result) + + def test_popover_if_not_truncated_flag(self): + short_text = "hi" + result = components.PopoverTruncated( + short_text, popover_content="full content", popover_if_not_truncated=True + ) + # Should be wrapped in popover even though short + self.assertNotEqual(result, "hi") + self.assertIn("data-popover-target", result) + + def test_popover_content_override(self): + result = components.PopoverTruncated("short", popover_content="custom popover") + # With popover_if_not_truncated=False (default), short text returns as-is + self.assertEqual(result, "short") + + def test_popover_content_override_with_flag(self): + result = components.PopoverTruncated( + "short", popover_content="custom popover", popover_if_not_truncated=True + ) + self.assertIn("custom popover", result) + + def test_endpart_visible_in_output(self): + long_text = "a" * 50 + result = components.PopoverTruncated(long_text, endpart="...") + self.assertIn("...", result) + + def test_returns_safetext(self): + result = components.PopoverTruncated("a" * 100) + self.assertIsInstance(result, SafeText) + + def test_default_length(self): + text = "a" * 31 + result = components.PopoverTruncated(text) + # 31 chars exceeds default length of 30, so should be truncated + self.assertIn("data-popover-target", result) + + def test_length_zero(self): + result = components.PopoverTruncated("hello", length=0) + # Even empty length triggers popover for any content + self.assertIn("data-popover-target", result) + + +class EnableCacheTest(unittest.TestCase): + """Test enable_cache() function.""" + + def test_wraps_with_lru_cache(self): + components.enable_cache() + # Should have cache_info method + self.assertTrue(hasattr(components._render_cached, "cache_info")) + + def test_cache_has_correct_maxsize(self): + components.enable_cache() + params = components._render_cached.cache_parameters() + self.assertEqual(params["maxsize"], 4096) + + +class ModelDependentComponentsTest(unittest.TestCase): + """Test components that depend on Django models.""" + + @staticmethod + def _create_platform(name="Steam", icon="steam"): + return Platform.objects.create(name=name, icon=icon) + + @staticmethod + def _create_game(platform, name="Test Game"): + return Game.objects.create(name=name, platform=platform) + + @staticmethod + def _create_purchase(games, platform=None, price=19.99): + purchase = Purchase.objects.create( + platform=platform or (games[0].platform if games else None), + date_purchased="2025-01-01", + price=price, + price_currency="USD", + converted_price=price, + converted_currency="USD", + ) + purchase.games.set(games) + return purchase + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + Game.objects.all().delete() + Purchase.objects.all().delete() + Session.objects.all().delete() + Platform.objects.all().delete() + + def test_name_with_icon_linkify_with_game(self): + platform = self._create_platform(name="Steam", icon="steam") + game = self._create_game(platform) + result = components.NameWithIcon(game_id=game.pk, linkify=True) + self.assertIsInstance(result, SafeText) + self.assertIn(" 30.0 + self.assertIn("30.0", result) + self.assertIn("USD", result) + self.assertIn("data-popover-target", result) + + def test_linked_purchase_single_game(self): + platform = self._create_platform(icon="steam") + game = self._create_game(platform, name="Single Game") + purchase = self._create_purchase([game], price=14.99) + result = components.LinkedPurchase(purchase) + self.assertIsInstance(result, SafeText) + self.assertIn("Single Game", result) + self.assertIn("