Files
python-tdd/src/functional_tests/test_bill_my_notes.py

481 lines
22 KiB
Python
Raw Normal View History

"""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="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_sky_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_sky_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")
brief sprint C3.a: Note unlock spawns Line + Brief on the user's per-category Post; banner JS consumes the new brief payload (FYI → post detail, square → my-notes) — TDD Wires the C2 Brief model into the existing Note-unlock pipeline. Per the per-category Post model: each user has a single Post(owner=user, kind=NOTE_UNLOCK) titled by the header Line "Look! — new Note unlocked"; each unlock appends a per-event Line ("Stargazer, 5:21:00 PM") + spawns a Brief FK'd to that Line. Server: - billboard.Post gains a `kind` enum (NOTE_UNLOCK / USER_POST / SHARE_INVITE, default USER_POST) so the per-category Post is deterministically discoverable via (owner, kind=NOTE_UNLOCK). - drama.Note.grant_if_new now returns (note, created, brief) — backwards-incompat tuple shape; the new third slot is None on idempotent re-grants. On a fresh grant it: get-or-creates the user's Note Unlocks Post; ensures the header Line; appends a per-event Line w. timestamp; creates a Brief(kind=NOTE_UNLOCK, owner, post, line, title=note.display_title). - dashboard.views.sky_save's response shape flips {note: {...}|null} → {brief: {...}|null}. The new helper _brief_to_banner_dict exposes {id, kind, title, line_text, post_url, square_url, created_at}; for NOTE_UNLOCK kind, square_url = reverse('billboard:my_notes'). Banner JS (apps/dashboard/note.js): - Module renamed Note → Brief (Note kept as an alias during the C3 sprint; will retire in C3.e). showBanner now consumes the Brief shape: title from brief.title, body from brief.line_text, time from brief.created_at, FYI href = brief.post_url, NVM dismisses, .note-banner__image is rendered as a clickable <a href=brief.square_url> when square_url is set. The CSS class names (.note-banner, .note-banner__title, etc.) stay so all existing SCSS + FT selectors keep working. - sky.html + _applet-my-sky.html flip Note.handleSaveResponse → Brief.handleSaveResponse. Tests: - new IT class GrantIfNewSpawnsBriefTest (5 tests): first grant creates Post+Line+Brief, idempotent on second grant returns no brief, two different slugs share one Post, line text carries note title, post.kind == NOTE_UNLOCK. PostKindFieldTest (2 tests): default user_post + all three choices present. - existing drama test_models.GrantIfNew tests updated to unpack the third tuple element. - dashboard.tests.integrated.test_sky_views: 5 tests flipped from data["note"] → data["brief"] w/ the new payload shape (kind=note_unlock, title=Stargazer, line_text contains Stargazer, post_url under /billboard/post/, square_url=/billboard/my-notes/). - NoteSpec.js (Jasmine): 12 specs rewritten for the Brief shape — SAMPLE_BRIEF carries the seven fields, T6 asserts the square is a clickable <a>, T8 asserts FYI points at brief.post_url (was hardcoded /billboard/my-notes/). - functional_tests.test_applet_my_notes: T2 split — FYI now navigates to /billboard/post/<uuid>/ (the post detail / mark-read contract), the .note-banner__image square preserves the legacy jump direct to /billboard/my-notes/. billboard/0003_post_kind migration auto-generated. 808 ITs + 9 my_notes FTs + 1 Jasmine FT all green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:00:01 -04:00
# Per the Brief sprint, .note-banner__image is now a clickable <a> for
# NOTE_UNLOCK kind — square goes to my_notes.html (the legacy FYI
# behavior preserved on the square).
square = banner.find_element(By.CSS_SELECTOR, ".note-banner__image")
self.assertEqual(square.tag_name, "a")
self.assertEqual(square.get_attribute("href").rstrip("/"),
self.live_server_url + "/billboard/my-notes")
banner.find_element(By.CSS_SELECTOR, ".btn.btn-cancel") # NVM
fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-info") # FYI
brief sprint C3.a: Note unlock spawns Line + Brief on the user's per-category Post; banner JS consumes the new brief payload (FYI → post detail, square → my-notes) — TDD Wires the C2 Brief model into the existing Note-unlock pipeline. Per the per-category Post model: each user has a single Post(owner=user, kind=NOTE_UNLOCK) titled by the header Line "Look! — new Note unlocked"; each unlock appends a per-event Line ("Stargazer, 5:21:00 PM") + spawns a Brief FK'd to that Line. Server: - billboard.Post gains a `kind` enum (NOTE_UNLOCK / USER_POST / SHARE_INVITE, default USER_POST) so the per-category Post is deterministically discoverable via (owner, kind=NOTE_UNLOCK). - drama.Note.grant_if_new now returns (note, created, brief) — backwards-incompat tuple shape; the new third slot is None on idempotent re-grants. On a fresh grant it: get-or-creates the user's Note Unlocks Post; ensures the header Line; appends a per-event Line w. timestamp; creates a Brief(kind=NOTE_UNLOCK, owner, post, line, title=note.display_title). - dashboard.views.sky_save's response shape flips {note: {...}|null} → {brief: {...}|null}. The new helper _brief_to_banner_dict exposes {id, kind, title, line_text, post_url, square_url, created_at}; for NOTE_UNLOCK kind, square_url = reverse('billboard:my_notes'). Banner JS (apps/dashboard/note.js): - Module renamed Note → Brief (Note kept as an alias during the C3 sprint; will retire in C3.e). showBanner now consumes the Brief shape: title from brief.title, body from brief.line_text, time from brief.created_at, FYI href = brief.post_url, NVM dismisses, .note-banner__image is rendered as a clickable <a href=brief.square_url> when square_url is set. The CSS class names (.note-banner, .note-banner__title, etc.) stay so all existing SCSS + FT selectors keep working. - sky.html + _applet-my-sky.html flip Note.handleSaveResponse → Brief.handleSaveResponse. Tests: - new IT class GrantIfNewSpawnsBriefTest (5 tests): first grant creates Post+Line+Brief, idempotent on second grant returns no brief, two different slugs share one Post, line text carries note title, post.kind == NOTE_UNLOCK. PostKindFieldTest (2 tests): default user_post + all three choices present. - existing drama test_models.GrantIfNew tests updated to unpack the third tuple element. - dashboard.tests.integrated.test_sky_views: 5 tests flipped from data["note"] → data["brief"] w/ the new payload shape (kind=note_unlock, title=Stargazer, line_text contains Stargazer, post_url under /billboard/post/, square_url=/billboard/my-notes/). - NoteSpec.js (Jasmine): 12 specs rewritten for the Brief shape — SAMPLE_BRIEF carries the seven fields, T6 asserts the square is a clickable <a>, T8 asserts FYI points at brief.post_url (was hardcoded /billboard/my-notes/). - functional_tests.test_applet_my_notes: T2 split — FYI now navigates to /billboard/post/<uuid>/ (the post detail / mark-read contract), the .note-banner__image square preserves the legacy jump direct to /billboard/my-notes/. billboard/0003_post_kind migration auto-generated. 808 ITs + 9 my_notes FTs + 1 Jasmine FT all green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:00:01 -04:00
# FYI now navigates to the underlying Brief's Post detail
# (/billboard/post/<uuid>/) — the GET render is the mark-read contract.
fyi.click()
self.wait_for(
brief sprint C3.a: Note unlock spawns Line + Brief on the user's per-category Post; banner JS consumes the new brief payload (FYI → post detail, square → my-notes) — TDD Wires the C2 Brief model into the existing Note-unlock pipeline. Per the per-category Post model: each user has a single Post(owner=user, kind=NOTE_UNLOCK) titled by the header Line "Look! — new Note unlocked"; each unlock appends a per-event Line ("Stargazer, 5:21:00 PM") + spawns a Brief FK'd to that Line. Server: - billboard.Post gains a `kind` enum (NOTE_UNLOCK / USER_POST / SHARE_INVITE, default USER_POST) so the per-category Post is deterministically discoverable via (owner, kind=NOTE_UNLOCK). - drama.Note.grant_if_new now returns (note, created, brief) — backwards-incompat tuple shape; the new third slot is None on idempotent re-grants. On a fresh grant it: get-or-creates the user's Note Unlocks Post; ensures the header Line; appends a per-event Line w. timestamp; creates a Brief(kind=NOTE_UNLOCK, owner, post, line, title=note.display_title). - dashboard.views.sky_save's response shape flips {note: {...}|null} → {brief: {...}|null}. The new helper _brief_to_banner_dict exposes {id, kind, title, line_text, post_url, square_url, created_at}; for NOTE_UNLOCK kind, square_url = reverse('billboard:my_notes'). Banner JS (apps/dashboard/note.js): - Module renamed Note → Brief (Note kept as an alias during the C3 sprint; will retire in C3.e). showBanner now consumes the Brief shape: title from brief.title, body from brief.line_text, time from brief.created_at, FYI href = brief.post_url, NVM dismisses, .note-banner__image is rendered as a clickable <a href=brief.square_url> when square_url is set. The CSS class names (.note-banner, .note-banner__title, etc.) stay so all existing SCSS + FT selectors keep working. - sky.html + _applet-my-sky.html flip Note.handleSaveResponse → Brief.handleSaveResponse. Tests: - new IT class GrantIfNewSpawnsBriefTest (5 tests): first grant creates Post+Line+Brief, idempotent on second grant returns no brief, two different slugs share one Post, line text carries note title, post.kind == NOTE_UNLOCK. PostKindFieldTest (2 tests): default user_post + all three choices present. - existing drama test_models.GrantIfNew tests updated to unpack the third tuple element. - dashboard.tests.integrated.test_sky_views: 5 tests flipped from data["note"] → data["brief"] w/ the new payload shape (kind=note_unlock, title=Stargazer, line_text contains Stargazer, post_url under /billboard/post/, square_url=/billboard/my-notes/). - NoteSpec.js (Jasmine): 12 specs rewritten for the Brief shape — SAMPLE_BRIEF carries the seven fields, T6 asserts the square is a clickable <a>, T8 asserts FYI points at brief.post_url (was hardcoded /billboard/my-notes/). - functional_tests.test_applet_my_notes: T2 split — FYI now navigates to /billboard/post/<uuid>/ (the post detail / mark-read contract), the .note-banner__image square preserves the legacy jump direct to /billboard/my-notes/. billboard/0003_post_kind migration auto-generated. 808 ITs + 9 my_notes FTs + 1 Jasmine FT all green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:00:01 -04:00
lambda: self.assertRegex(self.browser.current_url, r"/billboard/post/[0-9a-f-]+/")
)
brief sprint C3.a: Note unlock spawns Line + Brief on the user's per-category Post; banner JS consumes the new brief payload (FYI → post detail, square → my-notes) — TDD Wires the C2 Brief model into the existing Note-unlock pipeline. Per the per-category Post model: each user has a single Post(owner=user, kind=NOTE_UNLOCK) titled by the header Line "Look! — new Note unlocked"; each unlock appends a per-event Line ("Stargazer, 5:21:00 PM") + spawns a Brief FK'd to that Line. Server: - billboard.Post gains a `kind` enum (NOTE_UNLOCK / USER_POST / SHARE_INVITE, default USER_POST) so the per-category Post is deterministically discoverable via (owner, kind=NOTE_UNLOCK). - drama.Note.grant_if_new now returns (note, created, brief) — backwards-incompat tuple shape; the new third slot is None on idempotent re-grants. On a fresh grant it: get-or-creates the user's Note Unlocks Post; ensures the header Line; appends a per-event Line w. timestamp; creates a Brief(kind=NOTE_UNLOCK, owner, post, line, title=note.display_title). - dashboard.views.sky_save's response shape flips {note: {...}|null} → {brief: {...}|null}. The new helper _brief_to_banner_dict exposes {id, kind, title, line_text, post_url, square_url, created_at}; for NOTE_UNLOCK kind, square_url = reverse('billboard:my_notes'). Banner JS (apps/dashboard/note.js): - Module renamed Note → Brief (Note kept as an alias during the C3 sprint; will retire in C3.e). showBanner now consumes the Brief shape: title from brief.title, body from brief.line_text, time from brief.created_at, FYI href = brief.post_url, NVM dismisses, .note-banner__image is rendered as a clickable <a href=brief.square_url> when square_url is set. The CSS class names (.note-banner, .note-banner__title, etc.) stay so all existing SCSS + FT selectors keep working. - sky.html + _applet-my-sky.html flip Note.handleSaveResponse → Brief.handleSaveResponse. Tests: - new IT class GrantIfNewSpawnsBriefTest (5 tests): first grant creates Post+Line+Brief, idempotent on second grant returns no brief, two different slugs share one Post, line text carries note title, post.kind == NOTE_UNLOCK. PostKindFieldTest (2 tests): default user_post + all three choices present. - existing drama test_models.GrantIfNew tests updated to unpack the third tuple element. - dashboard.tests.integrated.test_sky_views: 5 tests flipped from data["note"] → data["brief"] w/ the new payload shape (kind=note_unlock, title=Stargazer, line_text contains Stargazer, post_url under /billboard/post/, square_url=/billboard/my-notes/). - NoteSpec.js (Jasmine): 12 specs rewritten for the Brief shape — SAMPLE_BRIEF carries the seven fields, T6 asserts the square is a clickable <a>, T8 asserts FYI points at brief.post_url (was hardcoded /billboard/my-notes/). - functional_tests.test_applet_my_notes: T2 split — FYI now navigates to /billboard/post/<uuid>/ (the post detail / mark-read contract), the .note-banner__image square preserves the legacy jump direct to /billboard/my-notes/. billboard/0003_post_kind migration auto-generated. 808 ITs + 9 my_notes FTs + 1 Jasmine FT all green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:00:01 -04:00
# Square (.note-banner__image) preserves the jump-direct-to-my-notes
# behavior. Reload the dashboard and re-fire the banner via a stash.
self.browser.get(self.live_server_url + "/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-description"))
# ──────────────────────────────────────────────────────────────────────────────
# 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="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_sky_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_sky_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_sky_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_sky_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_sky_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="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")
)
# Click the note to lock it — makes DON/DOFF opacity:1 and interactable
note_item.click()
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, "×")