"""Rendered-HTML assertions for pages converted to the Python layout/components. These go beyond `test_paths_return_200`: they assert that the `Page()` document wrapper and the Python component bodies emit the right structure, and — most importantly — that nothing is double-escaped (the recurring failure mode when a `SafeText` loses its safe marker and renders as `<tag>`). """ from datetime import datetime from zoneinfo import ZoneInfo from django.conf import settings from django.contrib.auth.models import User from django.test import TestCase from django.urls import reverse from games.models import Game, GameStatusChange, Platform, Purchase, Session ZONEINFO = ZoneInfo(settings.TIME_ZONE) # If any of these appear in output, a SafeText lost its safe marker somewhere. _ESCAPED_TAG_MARKERS = [ "<a", "<div", "<span", "<button", "<input", "<li", ] class RenderedPagesTest(TestCase): def setUp(self) -> None: self.user = User.objects.create_superuser( username="testuser", email="test@example.com", password="testpass" ) self.client.force_login(self.user) self.platform = Platform.objects.create(name="Test Platform", icon="test") self.game = Game.objects.create(name="Test Game", platform=self.platform) self.purchase = Purchase.objects.create( date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO), platform=self.platform, ) self.purchase.games.add(self.game) self.session = Session.objects.create( game=self.game, timestamp_start=datetime(2022, 9, 26, 15, 0, tzinfo=ZONEINFO), timestamp_end=datetime(2022, 9, 26, 16, 0, tzinfo=ZONEINFO), ) def get(self, url_name, *args): return self.client.get(reverse(url_name, args=args), follow=True) def assertNoEscapedTags(self, html): for marker in _ESCAPED_TAG_MARKERS: self.assertNotIn( marker, html, f"Found double-escaped markup ({marker!r}) in output" ) # --- layout wrapper ------------------------------------------------------ def test_page_layout_wrapper(self): """A converted page is wrapped in the full Page() document.""" html = self.get("games:list_playevents").content.decode() for marker in [ "", "", ]: self.assertIn(marker, html) self.assertIn("Timetracker - Manage play events", html) # --- list pages ---------------------------------------------------------- def test_list_pages_render_table_unescaped(self): for url_name in [ "games:list_games", "games:list_purchases", "games:list_sessions", "games:list_platforms", "games:list_devices", "games:list_playevents", ]: with self.subTest(url_name=url_name): html = self.get(url_name).content.decode() self.assertIn("", html) self.assertNoEscapedTags(html) def test_add_session_form_has_timestamp_helpers(self): html = self.get("games:add_session").content.decode() self.assertIn("add_session.js", html) for marker in [ "Set to now", "Toggle text", "Copy start value to end", "Copy end value to start", 'data-target="timestamp_start"', 'data-type="now"', 'hx-boost="false"', ]: self.assertIn(marker, html) self.assertNoEscapedTags(html) # --- detail pages -------------------------------------------------------- def test_view_game(self): html = self.get("games:view_game", self.game.id).content.decode() for marker in [ 'id="game-info"', "font-bold font-serif", self.game.name, "Total hours played", # stat popover tooltip 'id="popover-hours"', "Original year", "Status", "Played", "Platform", 'id="history-container"', "status-changed from:body", "createPlayEvent", # the played-row Alpine dropdown script 'hx-target="#global-modal-container"', # delete trigger "Purchases", "Sessions", "Play Events", "History", ]: self.assertIn(marker, html) self.assertNoEscapedTags(html) self.assertEqual(html.count("")) def test_view_game_empty_sections(self): """A game with no sessions/purchases/etc shows the empty messages.""" lonely = Game.objects.create(name="Lonely Game", platform=self.platform) html = self.get("games:view_game", lonely.id).content.decode() for marker in [ "No purchases yet.", "No sessions yet.", "No play events yet.", ]: self.assertIn(marker, html) self.assertNoEscapedTags(html) # --- HTMX fragments ------------------------------------------------------ def test_delete_game_confirmation_modal(self): html = self.get("games:delete_game_confirmation", self.game.id).content.decode() # A fragment (no full-page layout). self.assertNotIn("", html) self.assertIn('id="delete-game-confirmation-modal"', html) self.assertIn("hx-post", html) self.assertIn(self.game.name, html) self.assertIn("session(s)", html) # seeded session self.assertIn("purchase(s)", html) # seeded purchase self.assertNoEscapedTags(html) def test_refund_confirmation_modal(self): html = self.get( "games:refund_purchase_confirmation", self.purchase.id ).content.decode() self.assertIn('id="refund-confirmation-modal"', html) self.assertIn(f"#purchase-row-{self.purchase.id}", html) self.assertIn("Refund", html) self.assertNoEscapedTags(html) def test_session_row_fragment_via_htmx(self): # The inline "finish session" endpoint returns a fragment. resp = self.client.get( reverse("games:list_sessions_end_session", args=[self.session.id]), HTTP_HX_REQUEST="true", ) html = resp.content.decode() self.assertTrue(html.lstrip().startswith("", # full Page() layout "Please log in to continue", "csrfmiddlewaretoken", 'type="submit"', 'value="Login"', "", ]: self.assertIn(marker, html) self.assertIn("Timetracker - Login", html) self.assertNoEscapedTags(html) # --- stats --------------------------------------------------------------- def test_stats_alltime(self): html = self.get("games:stats_alltime").content.decode() for marker in [ 'id="year-picker-input"', "All-time stats", "responsive-table", "Playtime", "Purchases", "Games by playtime", "Platforms by playtime", ]: self.assertIn(marker, html) self.assertNoEscapedTags(html) self.assertEqual(html.count("")) def test_stats_by_year(self): year = self.session.timestamp_start.year html = self.get("games:stats_by_year", year).content.decode() # The seeded game/session/purchase should surface in the year view. self.assertIn("Playtime per month", html) self.assertIn(self.game.name, html) self.assertNoEscapedTags(html) self.assertEqual(html.count("")) def test_view_purchase(self): html = self.get("games:view_purchase", self.purchase.id).content.decode() for marker in [ "dark:text-white max-w-sm", "font-bold font-serif", "Owned on", "Price per game:", "decoration-dotted underline", "Games included in this purchase:", "
    ", "
  • ", ]: self.assertIn(marker, html) self.assertNoEscapedTags(html) # The Python builder emits well-formed, balanced markup. self.assertEqual(html.count("")) class PurchaseListDateFilterTest(TestCase): """End-to-end: GET /tracker/purchase/list?filter=… narrows the rendered list and pre-fills the date inputs from the URL filter. Replaces the manual curl smoke that earlier verified the same path. """ def setUp(self) -> None: import datetime self.user = User.objects.create_superuser( username="datetester", email="dt@example.com", password="testpass" ) self.client.force_login(self.user) self.platform = Platform.objects.create(name="DateP", icon="datep") # Markers are placed on the Game name because LinkedPurchase renders # the linked game's name (purchase.name doesn't surface in the list row). early_game = Game.objects.create(name="EARLY-MARKER", platform=self.platform) mid_game = Game.objects.create(name="MID-MARKER", platform=self.platform) late_game = Game.objects.create(name="LATE-MARKER", platform=self.platform) self.early = Purchase.objects.create( platform=self.platform, date_purchased=datetime.date(2024, 1, 15) ) self.early.games.add(early_game) self.mid = Purchase.objects.create( platform=self.platform, date_purchased=datetime.date(2024, 6, 15), date_refunded=datetime.date(2024, 7, 1), ) self.mid.games.add(mid_game) self.late = Purchase.objects.create( platform=self.platform, date_purchased=datetime.date(2025, 1, 15) ) self.late.games.add(late_game) def _get(self, filter_obj=None, raw_filter=None): import json from django.urls import reverse url = reverse("games:list_purchases") if raw_filter is not None: return self.client.get(url, {"filter": raw_filter}) if filter_obj is not None: return self.client.get(url, {"filter": json.dumps(filter_obj)}) return self.client.get(url) def test_unfiltered_lists_all_three(self): html = self._get().content.decode() self.assertEqual(html.count("EARLY-MARKER"), 1) self.assertEqual(html.count("MID-MARKER"), 1) self.assertEqual(html.count("LATE-MARKER"), 1) def test_date_purchased_between_narrows_and_prepopulates(self): """BETWEEN 2024-01-01..2024-12-31 → only early + mid; both date inputs pre-filled with the filter bounds.""" response = self._get( { "date_purchased": { "value": "2024-01-01", "value2": "2024-12-31", "modifier": "BETWEEN", } } ) self.assertEqual(response.status_code, 200) html = response.content.decode() self.assertIn("EARLY-MARKER", html) self.assertIn("MID-MARKER", html) self.assertNotIn("LATE-MARKER", html) # Pre-populated date inputs round-trip the filter bounds. self.assertIn( 'name="filter-date-purchased-min" id="filter-date-purchased-min" ' 'value="2024-01-01"', html, ) self.assertIn( 'name="filter-date-purchased-max" id="filter-date-purchased-max" ' 'value="2024-12-31"', html, ) def test_date_purchased_greater_than_single_bound(self): """GREATER_THAN populates min only, leaves max blank.""" response = self._get( { "date_purchased": { "value": "2024-06-15", "modifier": "GREATER_THAN", } } ) self.assertEqual(response.status_code, 200) html = response.content.decode() self.assertNotIn("EARLY-MARKER", html) self.assertNotIn("MID-MARKER", html) self.assertIn("LATE-MARKER", html) self.assertIn( 'name="filter-date-purchased-min" id="filter-date-purchased-min" ' 'value="2024-06-15"', html, ) self.assertIn( 'name="filter-date-purchased-max" id="filter-date-purchased-max" ' 'value=""', html, ) def test_date_refunded_not_null(self): response = self._get( {"date_refunded": {"value": "", "modifier": "NOT_NULL"}} ) self.assertEqual(response.status_code, 200) html = response.content.decode() self.assertNotIn("EARLY-MARKER", html) self.assertIn("MID-MARKER", html) self.assertNotIn("LATE-MARKER", html) def test_combined_dates_and_is_refunded(self): """date_purchased BETWEEN 2024 AND date_refunded NOT_NULL → only the mid purchase. Confirms AND-composition through the view layer.""" response = self._get( { "date_purchased": { "value": "2024-01-01", "value2": "2024-12-31", "modifier": "BETWEEN", }, "date_refunded": {"value": "", "modifier": "NOT_NULL"}, } ) self.assertEqual(response.status_code, 200) html = response.content.decode() self.assertNotIn("EARLY-MARKER", html) self.assertIn("MID-MARKER", html) self.assertNotIn("LATE-MARKER", html) def test_malformed_json_filter_falls_back_to_unfiltered(self): """parse_purchase_filter returns None on bad JSON → view ignores the filter and renders the full list (no 500).""" response = self._get(raw_filter="this is not json") self.assertEqual(response.status_code, 200) html = response.content.decode() # All three purchases are present, same as the unfiltered baseline. self.assertIn("EARLY-MARKER", html) self.assertIn("MID-MARKER", html) self.assertIn("LATE-MARKER", html)