diff --git a/src/functional_tests/test_recognition.py b/src/functional_tests/test_recognition.py new file mode 100644 index 0000000..ed7b3d0 --- /dev/null +++ b/src/functional_tests/test_recognition.py @@ -0,0 +1,332 @@ +"""Functional tests for the Recognition system. + +Recognition is Earthman's achievement analogue — account-level unlocks earned when +the socius observes a qualifying action. These tests cover the Stargazer Recognition: +earned on the first valid personal sky save (dashboard My Sky applet or sky page), +outside of any game room. + +Two test classes — one per surface that can trigger the unlock — each asserting both +the negative (disabled/incomplete save does nothing) and positive (first valid save +fires the banner) conditions. +""" +import json as _json + +from django.utils import timezone +from selenium.webdriver.common.by import By + +from apps.applets.models import Applet +from apps.lyric.models import User + +from .base import FunctionalTest + + +# Shared natal chart fixture — same birth data as test_applet_my_sky.py. +_CHART_FIXTURE = { + "planets": { + "Sun": {"sign": "Gemini", "degree": 66.7, "retrograde": False}, + "Moon": {"sign": "Taurus", "degree": 43.0, "retrograde": False}, + "Mercury": {"sign": "Taurus", "degree": 55.0, "retrograde": False}, + "Venus": {"sign": "Gemini", "degree": 63.3, "retrograde": False}, + "Mars": {"sign": "Leo", "degree": 132.0, "retrograde": False}, + "Jupiter": {"sign": "Capricorn", "degree": 292.0, "retrograde": True}, + "Saturn": {"sign": "Virgo", "degree": 153.0, "retrograde": False}, + "Uranus": {"sign": "Pisces", "degree": 322.0, "retrograde": False}, + "Neptune": {"sign": "Aquarius", "degree": 323.0, "retrograde": True}, + "Pluto": {"sign": "Sagittarius", "degree": 269.0, "retrograde": True}, + }, + "houses": { + "cusps": [180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120, 150], + "asc": 180.0, "mc": 90.0, + }, + "elements": {"Fire": 1, "Water": 0, "Stone": 2, "Air": 4, "Time": 1, "Space": 1}, + "aspects": [], + "distinctions": { + "1": 0, "2": 0, "3": 2, "4": 0, "5": 0, "6": 0, + "7": 1, "8": 0, "9": 2, "10": 1, "11": 1, "12": 2, + }, + "house_system": "O", + "timezone": "Europe/London", +} + + +def _mock_preview_js(fixture): + """Intercepts /sky/preview with a fixture; lets /sky/save reach the real server.""" + return f""" + const FIXTURE = {_json.dumps(fixture)}; + window._origFetch = window.fetch; + window.fetch = function(url, opts) {{ + if (url.includes('/sky/preview')) {{ + return Promise.resolve({{ + ok: true, + json: () => Promise.resolve(FIXTURE), + }}); + }} + return window._origFetch(url, opts); + }}; + """ + + +def _fill_valid_sky_form(browser): + """Populate form fields and fire input events to trigger schedulePreview.""" + browser.execute_script(""" + document.getElementById('id_nf_date').value = '1990-06-15'; + document.getElementById('id_nf_lat').value = '51.5074'; + document.getElementById('id_nf_lon').value = '-0.1278'; + document.getElementById('id_nf_tz').value = 'Europe/London'; + document.getElementById('id_nf_date').dispatchEvent( + new Event('input', {bubbles: true}) + ); + """) + + +# ────────────────────────────────────────────────────────────────────────────── +# Surface A — My Sky applet on the Dashboard +# ────────────────────────────────────────────────────────────────────────────── + +class StargazerRecognitionFromDashboardTest(FunctionalTest): + """Stargazer Recognition triggered from the My Sky applet. + + T1 — incomplete save does not fire Recognition. + T2 — first valid save fires banner, full flow to palette unlock. + """ + + def setUp(self): + super().setUp() + self.browser.set_window_size(800, 1200) + Applet.objects.get_or_create( + slug="my-sky", + defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"}, + ) + Applet.objects.get_or_create( + slug="palette", + defaults={"name": "Palettes", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"}, + ) + Applet.objects.get_or_create( + slug="recognition", + defaults={"name": "Recognition", "grid_cols": 12, "grid_rows": 3, "context": "billboard"}, + ) + self.gamer = User.objects.create(email="stargazer@test.io") + + # ── T1 ─────────────────────────────────────────────────────────────────── + + def test_disabled_save_button_does_not_unlock_recognition(self): + """SAVE SKY is disabled before a valid preview fires. + No Recognition banner appears while the button remains disabled.""" + self.create_pre_authenticated_session("stargazer@test.io") + self.browser.get(self.live_server_url) + + confirm_btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_natus_confirm") + ) + self.assertIsNotNone(confirm_btn.get_attribute("disabled")) + self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".recog-banner")) + + # ── T2 ─────────────────────────────────────────────────────────────────── + + def test_first_valid_save_from_applet_unlocks_stargazer_full_flow(self): + """First valid SAVE SKY from the My Sky applet: + + banner slides in below the Dash h2 → FYI leads to /billboard/recognition/ → + Recognition page shows Stargazer item → ? box opens palette modal → + swatch preview → OK → confirm → chosen palette permanently unlocked in + Palette applet on Dashboard. + """ + self.create_pre_authenticated_session("stargazer@test.io") + self.browser.get(self.live_server_url) + + self.wait_for(lambda: self.browser.find_element(By.ID, "id_applet_sky_form_wrap")) + self.browser.execute_script(_mock_preview_js(_CHART_FIXTURE)) + _fill_valid_sky_form(self.browser) + + confirm_btn = self.browser.find_element(By.ID, "id_natus_confirm") + self.wait_for(lambda: self.assertIsNone(confirm_btn.get_attribute("disabled"))) + confirm_btn.click() + + # --- Banner slides in below the Dash h2 --- + banner = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-banner") + ) + self.assertIn( + "Stargazer", + banner.find_element(By.CSS_SELECTOR, ".recog-banner__title").text, + ) + banner.find_element(By.CSS_SELECTOR, ".recog-banner__description") + banner.find_element(By.CSS_SELECTOR, ".recog-banner__timestamp") + banner.find_element(By.CSS_SELECTOR, ".recog-banner__image") + banner.find_element(By.CSS_SELECTOR, ".btn.btn-danger") # NVM + fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-caution") # FYI + + # --- FYI navigates to Recognition page --- + fyi.click() + self.wait_for( + lambda: self.assertRegex(self.browser.current_url, r"/billboard/recognition") + ) + + # --- Recognition page: one Stargazer item --- + item = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-list .recog-item") + ) + self.assertIn( + "Stargazer", + item.find_element(By.CSS_SELECTOR, ".recog-item__title").text, + ) + item.find_element(By.CSS_SELECTOR, ".recog-item__description") + image_box = item.find_element(By.CSS_SELECTOR, ".recog-item__image-box") + + # --- Clicking ? opens palette modal --- + image_box.click() + modal = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-palette-modal") + ) + modal.find_element(By.CSS_SELECTOR, ".palette-bardo") + modal.find_element(By.CSS_SELECTOR, ".palette-sheol") + + # --- Clicking a swatch body previews the palette and dismisses the modal --- + bardo_body = modal.find_element(By.CSS_SELECTOR, ".palette-bardo .recog-swatch-body") + self.browser.execute_script( + "arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))", + bardo_body, + ) + self.wait_for(lambda: self.assertFalse( + self.browser.find_elements(By.CSS_SELECTOR, ".recog-palette-modal") + )) + + # --- Clicking anything else ends preview and restores the modal --- + self.browser.find_element(By.TAG_NAME, "body").click() + modal = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-palette-modal") + ) + + # --- Clicking OK on the swatch raises a confirmation submenu --- + ok_btn = modal.find_element(By.CSS_SELECTOR, ".palette-bardo .btn.btn-confirm") + ok_btn.click() + confirm_menu = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-palette-confirm") + ) + + # --- Confirming sets palette, closes modal, replaces ? box --- + confirm_menu.find_element(By.CSS_SELECTOR, ".btn.btn-confirm").click() + self.wait_for(lambda: self.assertFalse( + self.browser.find_elements(By.CSS_SELECTOR, ".recog-palette-modal") + )) + item = self.browser.find_element(By.CSS_SELECTOR, ".recog-item") + self.assertTrue( + item.find_elements(By.CSS_SELECTOR, ".recog-item__palette.palette-bardo") + ) + self.assertFalse( + item.find_elements(By.CSS_SELECTOR, ".recog-item__image-box") + ) + + # --- Dashboard: Palette applet shows bardo permanently unlocked --- + self.browser.get(self.live_server_url) + palette_applet = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_applet_palette") + ) + bardo = palette_applet.find_element(By.CSS_SELECTOR, ".swatch.palette-bardo") + self.assertNotIn("locked", bardo.get_attribute("class")) + bardo_ok = bardo.find_element(By.CSS_SELECTOR, ".palette-ok") + self.assertIn("btn-confirm", bardo_ok.get_attribute("class")) + self.assertNotIn("btn-disabled", bardo_ok.get_attribute("class")) + self.assertIn( + "Stargazer", + bardo.get_attribute("data-shoptalk"), + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# Surface B — /dashboard/sky/ standalone page +# ────────────────────────────────────────────────────────────────────────────── + +class StargazerRecognitionFromSkyPageTest(FunctionalTest): + """Stargazer Recognition triggered from the standalone sky page. + + T3 — disabled save on sky page does not fire Recognition. + T4 — first valid save fires banner; NVM dismisses it. + T5 — already-earned Recognition does not re-show banner on subsequent save. + """ + + def setUp(self): + super().setUp() + self.browser.set_window_size(800, 1200) + Applet.objects.get_or_create( + slug="my-sky", + defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"}, + ) + Applet.objects.get_or_create( + slug="recognition", + defaults={"name": "Recognition", "grid_cols": 12, "grid_rows": 3, "context": "billboard"}, + ) + self.gamer = User.objects.create(email="stargazer@test.io") + self.sky_url = self.live_server_url + "/dashboard/sky/" + + # ── T3 ─────────────────────────────────────────────────────────────────── + + def test_disabled_save_on_sky_page_does_not_unlock_recognition(self): + """On /dashboard/sky/, SAVE SKY is disabled until preview fires. + No Recognition banner appears while the button is disabled.""" + self.create_pre_authenticated_session("stargazer@test.io") + self.browser.get(self.sky_url) + + confirm_btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_natus_confirm") + ) + self.assertIsNotNone(confirm_btn.get_attribute("disabled")) + self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".recog-banner")) + + # ── T4 ─────────────────────────────────────────────────────────────────── + + def test_first_valid_save_on_sky_page_fires_banner_and_nvm_dismisses(self): + """First valid SAVE SKY on /dashboard/sky/ fires the Stargazer banner. + NVM (.btn.btn-danger) dismisses it.""" + self.create_pre_authenticated_session("stargazer@test.io") + self.browser.get(self.sky_url) + + self.wait_for(lambda: self.browser.find_element(By.ID, "id_natus_confirm")) + self.browser.execute_script(_mock_preview_js(_CHART_FIXTURE)) + _fill_valid_sky_form(self.browser) + + confirm_btn = self.browser.find_element(By.ID, "id_natus_confirm") + self.wait_for(lambda: self.assertIsNone(confirm_btn.get_attribute("disabled"))) + confirm_btn.click() + + banner = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-banner") + ) + self.assertIn( + "Stargazer", + banner.find_element(By.CSS_SELECTOR, ".recog-banner__title").text, + ) + + banner.find_element(By.CSS_SELECTOR, ".btn.btn-danger").click() + self.wait_for(lambda: self.assertFalse( + self.browser.find_elements(By.CSS_SELECTOR, ".recog-banner") + )) + + # ── T5 ─────────────────────────────────────────────────────────────────── + + def test_already_earned_recognition_does_not_show_banner_on_subsequent_save(self): + """When Stargazer is already in the database for this user, a valid sky + save does not fire another Recognition banner.""" + from apps.drama.models import Recognition + Recognition.objects.create( + user=self.gamer, + slug="stargazer", + earned_at=timezone.now(), + ) + + self.create_pre_authenticated_session("stargazer@test.io") + self.browser.get(self.sky_url) + + self.wait_for(lambda: self.browser.find_element(By.ID, "id_natus_confirm")) + self.browser.execute_script(_mock_preview_js(_CHART_FIXTURE)) + _fill_valid_sky_form(self.browser) + + confirm_btn = self.browser.find_element(By.ID, "id_natus_confirm") + self.wait_for(lambda: self.assertIsNone(confirm_btn.get_attribute("disabled"))) + confirm_btn.click() + + # Wait for save to complete (wheel renders) then assert no banner + self.wait_for(lambda: self.assertTrue( + self.browser.find_elements(By.CSS_SELECTOR, ".nw-root") + )) + self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".recog-banner"))