Files
python-tdd/src/functional_tests/test_applet_my_notes.py
Disco DeDisco 5655342d9f
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
CI: add sequential tag for NoteEquipTitleTest; woodpecker runs it w.o --parallel
Parallel geckodriver startup race causes a spurious permissions error when
NoteEquipTitleTest is the first FT dispatched. @tag("sequential") moves it
into test-two-browser-FTs (sequential stage) as a reusable escape hatch.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 03:12:24 -04:00

468 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Functional tests for the Note system.
Note is Earthman's achievement analogue — account-level unlocks earned when
the socius observes a qualifying action. These tests cover the Stargazer Note:
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.
T2 (Dashboard full flow) is split across three focused tests:
T2a — save → banner → FYI → recognition page item
T2b — palette modal flow on recognition page
T2c — dashboard palette applet reflects Note palette unlock
"""
import json as _json
from django.test import tag
from django.utils import timezone
from selenium.webdriver.common.by import By
from apps.applets.models import Applet
from apps.drama.models import Note
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 StargazerNoteFromDashboardTest(FunctionalTest):
"""Stargazer Note triggered from the My Sky applet."""
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="billboard-notes",
defaults={"name": "Note", "grid_cols": 4, "grid_rows": 4, "context": "billboard"},
)
self.gamer = User.objects.create(email="stargazer@test.io")
# ── T1 ───────────────────────────────────────────────────────────────────
def test_disabled_save_button_does_not_unlock_note(self):
"""SAVE SKY is disabled before a valid preview fires.
No Note 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, ".note-banner"))
# ── T2a ──────────────────────────────────────────────────────────────────
def test_first_valid_save_from_applet_fires_banner_and_leads_to_note_page(self):
"""First valid SAVE SKY from the My Sky applet fires the Stargazer banner.
FYI button navigates to /billboard/my-notes/ showing the Stargazer item."""
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, ".note-banner")
)
self.assertIn(
"Stargazer",
banner.find_element(By.CSS_SELECTOR, ".note-banner__title").text,
)
banner.find_element(By.CSS_SELECTOR, ".note-banner__description")
banner.find_element(By.CSS_SELECTOR, ".note-banner__timestamp")
banner.find_element(By.CSS_SELECTOR, ".note-banner__image")
banner.find_element(By.CSS_SELECTOR, ".btn.btn-cancel") # NVM
fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-caution") # FYI
# FYI navigates to Note page
fyi.click()
self.wait_for(
lambda: self.assertRegex(self.browser.current_url, r"/billboard/my-notes")
)
# Note page: one Stargazer item
item = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-list .note-item")
)
self.assertIn(
"Stargazer",
item.find_element(By.CSS_SELECTOR, ".note-item__title").text,
)
item.find_element(By.CSS_SELECTOR, ".note-item__description")
item.find_element(By.CSS_SELECTOR, ".note-item__image-box")
# ── T2b ──────────────────────────────────────────────────────────────────
def _open_modal_and_click_bardo(self):
"""Helper: navigate to /billboard/my-notes/, open modal, click bardo swatch body.
Returns (modal, confirm_menu) after the confirm bar is visible."""
self.browser.get(self.live_server_url + "/billboard/my-notes/")
image_box = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-item__image-box")
)
image_box.click()
modal = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-palette-modal")
)
bardo_body = modal.find_element(By.CSS_SELECTOR, ".palette-bardo .note-swatch-body")
self.browser.execute_script(
"arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))",
bardo_body,
)
confirm_menu = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-palette-confirm")
)
return modal, confirm_menu
def test_note_page_swatch_previews_palette_sitewide_and_ok_persists(self):
"""Clicking a swatch previews that palette on the whole body.
OK commits it — Note.palette and user.palette both saved — so it
survives navigation to a new page."""
Note.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
)
self.create_pre_authenticated_session("stargazer@test.io")
modal, confirm = self._open_modal_and_click_bardo()
# Swatch click previews bardo on the whole body
self.wait_for(
lambda: self.assertIn(
"palette-bardo",
self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"),
)
)
# Modal still open
self.assertTrue(self.browser.find_elements(By.CSS_SELECTOR, ".note-palette-modal"))
# OK → modal closes, ? box replaced by bardo swatch
confirm.find_element(By.CSS_SELECTOR, ".btn.btn-confirm").click()
self.wait_for(lambda: self.assertFalse(
self.browser.find_elements(By.CSS_SELECTOR, ".note-palette-modal")
))
item = self.browser.find_element(By.CSS_SELECTOR, ".note-item")
self.assertTrue(item.find_elements(By.CSS_SELECTOR, ".note-item__palette.palette-bardo"))
self.assertFalse(item.find_elements(By.CSS_SELECTOR, ".note-item__image-box"))
# Navigate away — palette persists (user.palette was saved)
self.browser.get(self.live_server_url)
self.wait_for(
lambda: self.assertIn(
"palette-bardo",
self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"),
)
)
def test_note_swatch_nvm_reverts_body_palette(self):
"""NVM in the confirm bar reverts the sitewide body palette back to
what it was before the swatch was clicked."""
Note.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
)
self.create_pre_authenticated_session("stargazer@test.io")
# Record the original palette before opening the modal
self.browser.get(self.live_server_url + "/billboard/my-notes/")
original_classes = self.browser.find_element(
By.TAG_NAME, "body"
).get_attribute("class")
original_palette = next(
(c for c in original_classes.split() if c.startswith("palette-")), None
)
modal, confirm = self._open_modal_and_click_bardo()
# Bardo is previewed
self.wait_for(
lambda: self.assertIn(
"palette-bardo",
self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"),
)
)
# NVM reverts
confirm.find_element(By.CSS_SELECTOR, ".btn.btn-cancel").click()
self.wait_for(
lambda: self.assertNotIn(
"palette-bardo",
self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"),
)
)
if original_palette:
self.assertIn(
original_palette,
self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"),
)
# Swatch modal remains open after NVM; only the confirm bar is hidden
self.assertTrue(
self.browser.find_elements(By.CSS_SELECTOR, ".note-palette-modal")
)
confirm_el = self.browser.find_element(By.CSS_SELECTOR, ".note-palette-confirm")
self.assertFalse(confirm_el.is_displayed())
# ── T2c ──────────────────────────────────────────────────────────────────
def test_dashboard_palette_applet_reflects_note_palette_unlock(self):
"""After palette unlock via Note, the Dashboard Palette applet shows
the palette swatch as unlocked with Stargazer shoptalk."""
Note.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
self.create_pre_authenticated_session("stargazer@test.io")
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 StargazerNoteFromSkyPageTest(FunctionalTest):
"""Stargazer Note triggered from the standalone sky page.
T3 — disabled save on sky page does not fire Note.
T4 — first valid save fires banner; NVM dismisses it.
T5 — already-earned Note 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="billboard-notes",
defaults={"name": "Note", "grid_cols": 4, "grid_rows": 4, "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_note(self):
"""On /dashboard/sky/, SAVE SKY is disabled until preview fires.
No Note 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, ".note-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-cancel) 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, ".note-banner")
)
self.assertIn(
"Stargazer",
banner.find_element(By.CSS_SELECTOR, ".note-banner__title").text,
)
banner.find_element(By.CSS_SELECTOR, ".btn.btn-cancel").click()
self.wait_for(lambda: self.assertFalse(
self.browser.find_elements(By.CSS_SELECTOR, ".note-banner")
))
# ── T5 ───────────────────────────────────────────────────────────────────
def test_already_earned_note_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 Note banner."""
Note.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, ".note-banner"))
# ──────────────────────────────────────────────────────────────────────────────
# Title equip — DON/DOFF buttons on the note item
# ──────────────────────────────────────────────────────────────────────────────
@tag("sequential")
class NoteEquipTitleTest(FunctionalTest):
"""DON button equips the note title as the sitewide greeting; DOFF restores it."""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
Applet.objects.get_or_create(
slug="billboard-notes",
defaults={"name": "My Notes", "grid_cols": 4, "grid_rows": 4, "context": "billboard"},
)
self.gamer = User.objects.create(email="stargazer@test.io")
def test_don_equips_title_greeting_and_doff_restores(self):
"""DON replaces 'Earthman' with the note title; DOFF restores 'Earthman'.
DON/DOFF follow the game-kit equip button pattern: the active btn is normal,
the inactive btn is × + btn-disabled."""
Note.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
)
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url + "/billboard/my-notes/")
note_item = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-item")
)
don_btn = note_item.find_element(By.CSS_SELECTOR, ".btn-equip")
doff_btn = note_item.find_element(By.CSS_SELECTOR, ".btn-unequip")
# Initial state: DON active ("DON"), DOFF disabled ("×")
self.assertNotIn("btn-disabled", don_btn.get_attribute("class"))
self.assertEqual(don_btn.text, "DON")
self.assertIn("btn-disabled", doff_btn.get_attribute("class"))
self.assertEqual(doff_btn.text, "×")
# Click DON → greeting changes to the note title
don_btn.click()
self.wait_for(
lambda: self.assertIn(
"Stargazer",
self.browser.find_element(By.ID, "id_greeting_name").text,
)
)
self.assertIn("btn-disabled", don_btn.get_attribute("class"))
self.assertEqual(don_btn.text, "×")
self.assertNotIn("btn-disabled", doff_btn.get_attribute("class"))
self.assertEqual(doff_btn.text, "DOFF")
# Click DOFF → greeting restored to "Earthman"
doff_btn.click()
self.wait_for(
lambda: self.assertIn(
"Earthman",
self.browser.find_element(By.ID, "id_greeting_name").text,
)
)
self.assertNotIn("btn-disabled", don_btn.get_attribute("class"))
self.assertEqual(don_btn.text, "DON")
self.assertIn("btn-disabled", doff_btn.get_attribute("class"))
self.assertEqual(doff_btn.text, "×")