Update NameWithIcon
The `NameWithIcon()` function had a `platform` parameter that was immediately overwritten by `platform = None` and never used (dead code). The function mixed data lookup (database queries via IDs) with rendering, making it untestable. **Fix**: Refactored `NameWithIcon()` to follow the `LinkedPurchase` pattern — accepts model objects (`Game`, `Session`) instead of IDs. Extracted `_resolve_name_with_icon()` helper for testable computation logic (name resolution, platform extraction, link creation). Fixed bug where `platform` was not extracted when `session` parameter was passed. Removed dead `platform` parameter from the public API. Updated all 3 production call sites (already using model objects). Added 10 unit tests for `_resolve_name_with_icon()` covering session override, custom names, linkify behavior, platform resolution, and edge cases. Updated 6 integration tests to use model-based parameters.
This commit is contained in:
@@ -32,7 +32,10 @@ All functions with mutable defaults (`attributes` and `children`) changed from `
|
|||||||
|
|
||||||
What was fixed: `attributes: list[HTMLAttribute] = []` and `children: list[HTMLTag] | HTMLTag = []` are a classic Python gotcha — the default is shared across all callers and could silently corrupt state if ever mutated in place. Changed 8 functions (`Component`, `Popover`, `A`, `Button`, `Div`, `Input`, `Form`, `Icon`) to use the `None` sentinel pattern, preventing future bugs and eliminating linter warnings.
|
What was fixed: `attributes: list[HTMLAttribute] = []` and `children: list[HTMLTag] | HTMLTag = []` are a classic Python gotcha — the default is shared across all callers and could silently corrupt state if ever mutated in place. Changed 8 functions (`Component`, `Popover`, `A`, `Button`, `Div`, `Input`, `Form`, `Icon`) to use the `None` sentinel pattern, preventing future bugs and eliminating linter warnings.
|
||||||
|
|
||||||
## Incomplete
|
### NameWithIcon dead code and untestable design
|
||||||
|
The `NameWithIcon()` function had a `platform` parameter that was immediately overwritten by `platform = None` and never used (dead code). The function mixed data lookup (database queries via IDs) with rendering, making it untestable.
|
||||||
|
|
||||||
|
**Fix**: Refactored `NameWithIcon()` to follow the `LinkedPurchase` pattern — accepts model objects (`Game`, `Session`) instead of IDs. Extracted `_resolve_name_with_icon()` helper for testable computation logic (name resolution, platform extraction, link creation). Fixed bug where `platform` was not extracted when `session` parameter was passed. Removed dead `platform` parameter from the public API. Updated all 3 production call sites (already using model objects). Added 10 unit tests for `_resolve_name_with_icon()` covering session override, custom names, linkify behavior, platform resolution, and edge cases. Updated 6 integration tests to use model-based parameters.
|
||||||
|
|
||||||
### No tests
|
### No tests
|
||||||
Zero test coverage for the entire component system.
|
Zero test coverage for the entire component system.
|
||||||
|
|||||||
+37
-23
@@ -271,30 +271,15 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
|
|||||||
|
|
||||||
def NameWithIcon(
|
def NameWithIcon(
|
||||||
name: str = "",
|
name: str = "",
|
||||||
platform: str = "",
|
game: Game | None = None,
|
||||||
game_id: int = 0,
|
session: Session | None = None,
|
||||||
session_id: int = 0,
|
|
||||||
purchase_id: int = 0,
|
|
||||||
linkify: bool = True,
|
linkify: bool = True,
|
||||||
emulated: bool = False,
|
emulated: bool = False,
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
create_link = False
|
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
||||||
link = ""
|
name, game, session, linkify
|
||||||
platform = None
|
)
|
||||||
if (game_id != 0 or session_id != 0 or purchase_id != 0) and linkify:
|
|
||||||
create_link = True
|
|
||||||
if session_id:
|
|
||||||
session = Session.objects.get(pk=session_id)
|
|
||||||
emulated = session.emulated
|
|
||||||
game_id = session.game.pk
|
|
||||||
if purchase_id:
|
|
||||||
purchase = Purchase.objects.get(pk=purchase_id)
|
|
||||||
game_id = purchase.games.first().pk
|
|
||||||
if game_id:
|
|
||||||
game = Game.objects.get(pk=game_id)
|
|
||||||
name = name or game.name
|
|
||||||
platform = game.platform
|
|
||||||
link = reverse("view_game", args=[int(game_id)])
|
|
||||||
content = Div(
|
content = Div(
|
||||||
[("class", "inline-flex gap-2 items-center")],
|
[("class", "inline-flex gap-2 items-center")],
|
||||||
[
|
[
|
||||||
@@ -304,8 +289,8 @@ def NameWithIcon(
|
|||||||
)
|
)
|
||||||
if platform
|
if platform
|
||||||
else "",
|
else "",
|
||||||
Icon("emulated", [("title", "Emulated")]) if emulated else "",
|
Icon("emulated", [("title", "Emulated")]) if final_emulated else "",
|
||||||
PopoverTruncated(name),
|
PopoverTruncated(_name),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -319,6 +304,35 @@ def NameWithIcon(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_name_with_icon(
|
||||||
|
name: str,
|
||||||
|
game: Game | None,
|
||||||
|
session: Session | None,
|
||||||
|
linkify: bool,
|
||||||
|
) -> tuple[str, Any, bool, bool, str]:
|
||||||
|
create_link = False
|
||||||
|
link = ""
|
||||||
|
platform = None
|
||||||
|
final_emulated = False
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
game = session.game
|
||||||
|
platform = game.platform
|
||||||
|
final_emulated = session.emulated
|
||||||
|
if linkify:
|
||||||
|
create_link = True
|
||||||
|
link = reverse("view_game", args=[int(game.pk)])
|
||||||
|
elif game is not None:
|
||||||
|
platform = game.platform
|
||||||
|
if linkify:
|
||||||
|
create_link = True
|
||||||
|
link = reverse("view_game", args=[int(game.pk)])
|
||||||
|
|
||||||
|
_name = name or (game.name if game else "")
|
||||||
|
|
||||||
|
return _name, platform, final_emulated, create_link, link
|
||||||
|
|
||||||
|
|
||||||
def PurchasePrice(purchase) -> SafeText:
|
def PurchasePrice(purchase) -> SafeText:
|
||||||
return Popover(
|
return Popover(
|
||||||
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
||||||
|
|||||||
+2
-4
@@ -104,7 +104,7 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
|||||||
],
|
],
|
||||||
"rows": [
|
"rows": [
|
||||||
[
|
[
|
||||||
NameWithIcon(game_id=game.pk),
|
NameWithIcon(game=game),
|
||||||
PopoverTruncated(
|
PopoverTruncated(
|
||||||
game.sort_name
|
game.sort_name
|
||||||
if game.sort_name is not None and game.name != game.sort_name
|
if game.sort_name is not None and game.name != game.sort_name
|
||||||
@@ -308,9 +308,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||||||
"columns": ["Game", "Date", "Duration", "Actions"],
|
"columns": ["Game", "Date", "Duration", "Actions"],
|
||||||
"rows": [
|
"rows": [
|
||||||
[
|
[
|
||||||
NameWithIcon(
|
NameWithIcon(session=session),
|
||||||
session_id=session.pk,
|
|
||||||
),
|
|
||||||
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
||||||
session.duration_formatted_with_mark,
|
session.duration_formatted_with_mark,
|
||||||
render_to_string(
|
render_to_string(
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
"hx_select": f"#session-row-{session.pk}",
|
"hx_select": f"#session-row-{session.pk}",
|
||||||
"hx_swap": "outerHTML",
|
"hx_swap": "outerHTML",
|
||||||
"cell_data": [
|
"cell_data": [
|
||||||
NameWithIcon(session_id=session.pk),
|
NameWithIcon(session=session),
|
||||||
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
||||||
session.duration_formatted_with_mark,
|
session.duration_formatted_with_mark,
|
||||||
render_to_string(
|
render_to_string(
|
||||||
|
|||||||
+113
-9
@@ -1,5 +1,6 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import django
|
import django
|
||||||
|
|
||||||
@@ -405,13 +406,13 @@ class ComponentReturnTypeTest(unittest.TestCase):
|
|||||||
self.assertIn("text-white bg-brand", result)
|
self.assertIn("text-white bg-brand", result)
|
||||||
|
|
||||||
def test_name_with_icon_no_link(self):
|
def test_name_with_icon_no_link(self):
|
||||||
result = components.NameWithIcon(name="Game", platform="Steam", linkify=False)
|
result = components.NameWithIcon(name="Game", linkify=False)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("Game", result)
|
self.assertIn("Game", result)
|
||||||
self.assertNotIn("<a ", result)
|
self.assertNotIn("<a ", result)
|
||||||
|
|
||||||
def test_name_with_icon_no_trailing_comma(self):
|
def test_name_with_icon_no_trailing_comma(self):
|
||||||
result = components.NameWithIcon(name="Test", platform="Steam", linkify=False)
|
result = components.NameWithIcon(name="Test", linkify=False)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertNotIsInstance(result, tuple)
|
self.assertNotIsInstance(result, tuple)
|
||||||
|
|
||||||
@@ -648,7 +649,7 @@ class ModelDependentComponentsTest(unittest.TestCase):
|
|||||||
def test_name_with_icon_linkify_with_game(self):
|
def test_name_with_icon_linkify_with_game(self):
|
||||||
platform = self._create_platform(name="Steam", icon="steam")
|
platform = self._create_platform(name="Steam", icon="steam")
|
||||||
game = self._create_game(platform)
|
game = self._create_game(platform)
|
||||||
result = components.NameWithIcon(game_id=game.pk, linkify=True)
|
result = components.NameWithIcon(game=game, linkify=True)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("<a ", result)
|
self.assertIn("<a ", result)
|
||||||
self.assertIn("Test Game", result)
|
self.assertIn("Test Game", result)
|
||||||
@@ -657,7 +658,7 @@ class ModelDependentComponentsTest(unittest.TestCase):
|
|||||||
def test_name_with_icon_no_linkify(self):
|
def test_name_with_icon_no_linkify(self):
|
||||||
platform = self._create_platform(name="GOG", icon="gog")
|
platform = self._create_platform(name="GOG", icon="gog")
|
||||||
game = self._create_game(platform)
|
game = self._create_game(platform)
|
||||||
result = components.NameWithIcon(name="Test Game", game_id=game.pk, linkify=False)
|
result = components.NameWithIcon(name="Test Game", game=game, linkify=False)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertNotIn("<a ", result)
|
self.assertNotIn("<a ", result)
|
||||||
self.assertIn("Test Game", result)
|
self.assertIn("Test Game", result)
|
||||||
@@ -670,12 +671,13 @@ class ModelDependentComponentsTest(unittest.TestCase):
|
|||||||
timestamp_start="2025-01-01 00:00:00+00:00",
|
timestamp_start="2025-01-01 00:00:00+00:00",
|
||||||
emulated=True,
|
emulated=True,
|
||||||
)
|
)
|
||||||
result = components.NameWithIcon(session_id=session.pk, linkify=True)
|
result = components.NameWithIcon(session=session, linkify=True)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("<a ", result)
|
self.assertIn("<a ", result)
|
||||||
|
self.assertIn("Emulated", result)
|
||||||
|
|
||||||
def test_name_with_icon_no_platform(self):
|
def test_name_with_icon_no_platform(self):
|
||||||
result = components.NameWithIcon(name="Standalone", platform="", linkify=False)
|
result = components.NameWithIcon(name="Standalone", linkify=False)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("Standalone", result)
|
self.assertIn("Standalone", result)
|
||||||
|
|
||||||
@@ -686,7 +688,7 @@ class ModelDependentComponentsTest(unittest.TestCase):
|
|||||||
game=game,
|
game=game,
|
||||||
timestamp_start="2025-01-01 00:00:00+00:00",
|
timestamp_start="2025-01-01 00:00:00+00:00",
|
||||||
)
|
)
|
||||||
result = components.NameWithIcon(session_id=session.pk, linkify=True)
|
result = components.NameWithIcon(session=session, linkify=True)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("Epic Game", result)
|
self.assertIn("Epic Game", result)
|
||||||
|
|
||||||
@@ -789,9 +791,8 @@ class NameWithIconPlatformTest(unittest.TestCase):
|
|||||||
Platform.objects.all().delete()
|
Platform.objects.all().delete()
|
||||||
|
|
||||||
def test_name_with_icon_shows_platform_icon(self):
|
def test_name_with_icon_shows_platform_icon(self):
|
||||||
# NameWithIcon looks up platform from DB when linkify=True with game_id
|
|
||||||
result = components.NameWithIcon(
|
result = components.NameWithIcon(
|
||||||
name="Zelda", game_id=self.game.pk, linkify=True
|
name="Zelda", game=self.game, linkify=True
|
||||||
)
|
)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("Zelda", result)
|
self.assertIn("Zelda", result)
|
||||||
@@ -802,5 +803,108 @@ class NameWithIconPlatformTest(unittest.TestCase):
|
|||||||
self.assertIn("Unknown Game", result)
|
self.assertIn("Unknown Game", result)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveNameWithIconTest(unittest.TestCase):
|
||||||
|
"""Test _resolve_name_with_icon helper function."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
self.mock_platform = MagicMock()
|
||||||
|
self.mock_platform.name = "Steam"
|
||||||
|
self.mock_platform.icon = "steam"
|
||||||
|
self.mock_platform.pk = 1
|
||||||
|
|
||||||
|
self.mock_game = MagicMock()
|
||||||
|
self.mock_game.name = "Test Game"
|
||||||
|
self.mock_game.pk = 1
|
||||||
|
self.mock_game.platform = self.mock_platform
|
||||||
|
|
||||||
|
self.mock_session = MagicMock()
|
||||||
|
self.mock_session.game = self.mock_game
|
||||||
|
self.mock_session.emulated = False
|
||||||
|
self.mock_session.pk = 1
|
||||||
|
|
||||||
|
def test_session_provides_game_and_emulated(self):
|
||||||
|
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
|
||||||
|
"", self.mock_game, self.mock_session, True
|
||||||
|
)
|
||||||
|
self.assertEqual(name, "Test Game")
|
||||||
|
self.assertIs(platform, self.mock_platform)
|
||||||
|
self.assertFalse(emulated)
|
||||||
|
|
||||||
|
def test_session_overrides_game_parameter(self):
|
||||||
|
override_game = MagicMock()
|
||||||
|
override_game.name = "Override"
|
||||||
|
override_game.platform = self.mock_platform
|
||||||
|
override_game.pk = 99
|
||||||
|
with patch("common.components.reverse", return_value="/game/99"):
|
||||||
|
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
|
||||||
|
"", override_game, self.mock_session, True
|
||||||
|
)
|
||||||
|
self.assertEqual(name, "Test Game")
|
||||||
|
self.assertIsNot(name, "Override")
|
||||||
|
|
||||||
|
def test_game_only_provides_platform(self):
|
||||||
|
with patch("common.components.reverse", return_value="/game/1"):
|
||||||
|
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
|
||||||
|
"", self.mock_game, None, True
|
||||||
|
)
|
||||||
|
self.assertEqual(name, "Test Game")
|
||||||
|
self.assertIs(platform, self.mock_platform)
|
||||||
|
self.assertTrue(create_link)
|
||||||
|
self.assertEqual(link, "/game/1")
|
||||||
|
|
||||||
|
def test_custom_name_overrides_game_name(self):
|
||||||
|
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
|
||||||
|
"Custom", self.mock_game, None, False
|
||||||
|
)
|
||||||
|
self.assertEqual(name, "Custom")
|
||||||
|
|
||||||
|
def test_empty_name_falls_back_to_game_name(self):
|
||||||
|
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
|
||||||
|
"", self.mock_game, None, False
|
||||||
|
)
|
||||||
|
self.assertEqual(name, "Test Game")
|
||||||
|
|
||||||
|
def test_no_game_no_session_returns_empty_name(self):
|
||||||
|
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
|
||||||
|
"", None, None, False
|
||||||
|
)
|
||||||
|
self.assertEqual(name, "")
|
||||||
|
self.assertIsNone(platform)
|
||||||
|
self.assertFalse(create_link)
|
||||||
|
|
||||||
|
def test_linkify_false_no_link_created(self):
|
||||||
|
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
|
||||||
|
"", self.mock_game, None, False
|
||||||
|
)
|
||||||
|
self.assertFalse(create_link)
|
||||||
|
self.assertEqual(link, "")
|
||||||
|
|
||||||
|
def test_linkify_true_creates_link(self):
|
||||||
|
with patch("common.components.reverse", return_value="/game/42"):
|
||||||
|
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
|
||||||
|
"", self.mock_game, None, True
|
||||||
|
)
|
||||||
|
self.assertTrue(create_link)
|
||||||
|
self.assertEqual(link, "/game/42")
|
||||||
|
|
||||||
|
def test_session_emulated_flag_preserved(self):
|
||||||
|
emulated_session = MagicMock()
|
||||||
|
emulated_session.game = self.mock_game
|
||||||
|
emulated_session.emulated = True
|
||||||
|
emulated_session.pk = 1
|
||||||
|
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
|
||||||
|
"", self.mock_game, emulated_session, False
|
||||||
|
)
|
||||||
|
self.assertTrue(emulated)
|
||||||
|
|
||||||
|
def test_game_emulated_default_false(self):
|
||||||
|
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
|
||||||
|
"", self.mock_game, None, False
|
||||||
|
)
|
||||||
|
self.assertFalse(emulated)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user