recognition: Stargazer FT — invalid save stays locked, valid save fires banner, full flow to palette unlock — TDD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-22 02:09:48 -04:00
parent 6069d86ec5
commit 83ce238a2f

View File

@@ -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"))