From 49601bb4fcd87340b2212038edb8db4807ed30e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 19:48:34 +0200 Subject: [PATCH 1/2] feat(utils): add label_with_details helper Builds a "Name (detail, detail)" label from a name plus optional details, dropping falsy parts and omitting the parentheses entirely when none remain. Extracted to deduplicate the "filter present parts, join, wrap in parens" idiom that several model display properties share. Co-Authored-By: Claude Opus 4.8 (1M context) --- common/utils.py | 11 +++++++++++ tests/test_utils.py | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 tests/test_utils.py diff --git a/common/utils.py b/common/utils.py index ec74aca..2c376b3 100644 --- a/common/utils.py +++ b/common/utils.py @@ -114,6 +114,17 @@ def format_float_or_int(number: int | float): return int(number) if float(number).is_integer() else f"{number:03.2f}" +def label_with_details(name: str, *details: object, separator: str = ", ") -> str: + """Build a ``"Name (detail, detail)"`` label from a name and optional details. + + Falsy details (``None``, ``""``, ``0``) are dropped; the rest are stringified + and joined with ``separator`` inside parentheses. With no details remaining, + the bare ``name`` is returned without parentheses. + """ + present = [str(detail) for detail in details if detail] + return f"{name} ({separator.join(present)})" if present else name + + OperatorType = Literal["|", "&"] diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..7b3823a --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,25 @@ +import unittest + +from common.utils import label_with_details + + +class LabelWithDetailsTest(unittest.TestCase): + def test_all_parts_present(self): + self.assertEqual( + label_with_details("Mario", "Steam", 2020), "Mario (Steam, 2020)" + ) + + def test_some_parts_falsy(self): + self.assertEqual(label_with_details("Mario", None, 2020), "Mario (2020)") + self.assertEqual(label_with_details("Mario", "Steam", None), "Mario (Steam)") + + def test_all_parts_falsy(self): + self.assertEqual(label_with_details("Mario", None, "", 0), "Mario") + + def test_no_details(self): + self.assertEqual(label_with_details("Mario"), "Mario") + + def test_custom_separator(self): + self.assertEqual( + label_with_details("Mario", "a", "b", separator=" / "), "Mario (a / b)" + ) From 1c0c067377018d8ec9985a3f7c41fe7526ec92ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 19:48:42 +0200 Subject: [PATCH 2/2] fix(game): show game name in dropdown labels (#43) search_label built its label from sort_name, an optional sort key that is blank for most games, so the Game and Related-game dropdowns in the add-purchase form (and the session form and search API, which share the property) showed a blank/"None" label. Use name, which is required. Also route search_label and Purchase.full_name through label_with_details so a missing year_released drops out of the parenthetical instead of rendering a literal "None". (platform is never None at display time - Game.save() substitutes the "Unspecified" sentinel.) Co-Authored-By: Claude Opus 4.8 (1M context) --- games/models.py | 19 ++++++++----------- tests/test_purchase_separate_orders.py | 15 +++++++++++++++ tests/test_search_select.py | 20 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/games/models.py b/games/models.py index 86cd516..3babdd3 100644 --- a/games/models.py +++ b/games/models.py @@ -12,6 +12,7 @@ from django.template.defaultfilters import floatformat, pluralize, slugify from django.utils import timezone from common.time import format_duration +from common.utils import label_with_details logger = logging.getLogger("games") @@ -67,7 +68,7 @@ class Game(models.Model): @property def search_label(self) -> str: - return f"{self.sort_name} ({self.platform}, {self.year_released})" + return label_with_details(self.name, self.platform, self.year_released) def finished(self): return ( @@ -234,16 +235,12 @@ class Purchase(models.Model): @property def full_name(self): - additional_info = [ - str(item) - for item in [ - f"{self.num_purchases} game{pluralize(self.num_purchases)}", - self.date_purchased, - self.standardized_price, - ] - if item - ] - return f"{self.standardized_name} ({', '.join(additional_info)})" + return label_with_details( + self.standardized_name, + f"{self.num_purchases} game{pluralize(self.num_purchases)}", + self.date_purchased, + self.standardized_price, + ) def is_game(self): return self.type == self.GAME diff --git a/tests/test_purchase_separate_orders.py b/tests/test_purchase_separate_orders.py index 1893edf..f8bc359 100644 --- a/tests/test_purchase_separate_orders.py +++ b/tests/test_purchase_separate_orders.py @@ -63,6 +63,21 @@ class AddPurchasePricingTest(TestCase): {self.game_a.id, self.game_b.id}, ) + def test_full_name_keeps_parenthesized_detail_shape(self): + bundle = Purchase.objects.create( + name="Humble Bundle", + date_purchased=date(2025, 1, 1), + price=30, + price_currency="USD", + ownership_type=Purchase.DIGITAL, + ) + bundle.games.set([self.game_a, self.game_b]) + bundle.refresh_from_db() + + full_name = bundle.full_name + self.assertTrue(full_name.startswith("Humble Bundle (2 games, ")) + self.assertTrue(full_name.endswith(")")) + class SplitPurchaseTest(TestCase): def setUp(self): diff --git a/tests/test_search_select.py b/tests/test_search_select.py index 36dcfe9..e143701 100644 --- a/tests/test_search_select.py +++ b/tests/test_search_select.py @@ -338,6 +338,26 @@ class SearchLabelTest(django.test.TestCase): def test_format(self): self.assertEqual(self.game.search_label, "Mario (Steam, 2020)") + def test_format_uses_name_not_sort_name(self): + game = Game.objects.create( + name="Tetris", sort_name="", platform=self.platform, year_released=1984 + ) + self.assertEqual(game.search_label, "Tetris (Steam, 1984)") + + def test_format_omits_missing_year(self): + game = Game.objects.create(name="Tetris", platform=self.platform) + self.assertEqual(game.search_label, "Tetris (Steam)") + + def test_format_uses_sentinel_platform_when_unset(self): + # Game.save() substitutes the "Unspecified" sentinel platform, so the + # platform part is never a literal "None"; only year_released can drop out. + game = Game.objects.create(name="Tetris", year_released=1984) + self.assertEqual(game.search_label, "Tetris (Unspecified, 1984)") + + def test_format_with_sentinel_platform_and_no_year(self): + game = Game.objects.create(name="Tetris") + self.assertEqual(game.search_label, "Tetris (Unspecified)") + def test_choice_fields_use_search_label(self): from games.forms import MultipleGameChoiceField, SingleGameChoiceField