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/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 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)" + )