diff --git a/src/functional_tests/test_applet_my_sky.py b/src/functional_tests/test_applet_my_sky.py index e32f64f..0a4adeb 100644 --- a/src/functional_tests/test_applet_my_sky.py +++ b/src/functional_tests/test_applet_my_sky.py @@ -467,6 +467,69 @@ class MySkyApertureSnapScrollTest(FunctionalTest): )) +class MySkyAppletDelTest(FunctionalTest): + """My Sky applet (on /dashboard/) gets a parity DEL btn at the wheel + center: clicking opens the global guard portal; OK clears the saved sky + on the User model & the relevant localStorage entry, then the applet + swaps back to its form rendering on reload.""" + + def setUp(self): + super().setUp() + Applet.objects.get_or_create( + slug="my-sky", + defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"}, + ) + self.gamer = User.objects.create(email="stargazer@test.io") + self.gamer.sky_chart_data = _CHART_FIXTURE + self.gamer.sky_birth_place = "Baltimore, MD, US" + self.gamer.sky_birth_tz = "America/New_York" + self.gamer.sky_birth_lat = 39.2904 + self.gamer.sky_birth_lon = -76.6122 + self.gamer.sky_birth_dt = datetime( + 1990, 6, 15, 12, 0, tzinfo=zoneinfo.ZoneInfo("UTC") + ) + self.gamer.save() + self.dash_url = self.live_server_url + "/" + + def test_applet_del_clears_user_sky_and_swaps_to_form(self): + self.create_pre_authenticated_session("stargazer@test.io") + self.browser.get(self.dash_url) + + # 1. Applet shows wheel + DEL btn + del_btn = self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_applet_my_sky .btn-danger" + )) + self.assertTrue(del_btn.is_displayed()) + + # Seed localStorage as if user had previously typed in the form. After + # DEL, this entry should be cleared so the form doesn't repopulate + # from stale state on the post-reload form render. + self.browser.execute_script(""" + localStorage.setItem('sky-form:dashboard:sky', + JSON.stringify({date:'1980-01-01', lat:'1', lon:'1', place:'Stale', tz:'UTC'})); + """) + + del_btn.click() + portal = self.wait_for(lambda: self.browser.find_element(By.ID, "id_guard_portal")) + self.wait_for(lambda: self.assertIn("active", portal.get_attribute("class"))) + portal.find_element(By.CSS_SELECTOR, ".guard-yes").click() + + # 2. Page reloads → applet renders form (no chart_data) + self.wait_for(lambda: self.browser.find_element(By.ID, "id_applet_sky_form_wrap")) + + # 3. localStorage cleared (so form isn't seeded with stale data) + ls_value = self.browser.execute_script( + "return localStorage.getItem('sky-form:dashboard:sky');" + ) + self.assertIsNone(ls_value) + + # 4. DB-side: every sky field is cleared + self.gamer.refresh_from_db() + self.assertIsNone(self.gamer.sky_chart_data) + self.assertIsNone(self.gamer.sky_birth_dt) + self.assertEqual(self.gamer.sky_birth_place, "") + + class MySkyAsyncSaveTest(FunctionalTest): """A fresh user (no saved sky) clicks SAVE SKY (mocked) — without a page reload, the page transitions into the saved state: body picks up diff --git a/src/functional_tests/test_room_sky_select.py b/src/functional_tests/test_room_sky_select.py index b2c1624..4a97c4b 100644 --- a/src/functional_tests/test_room_sky_select.py +++ b/src/functional_tests/test_room_sky_select.py @@ -1,5 +1,7 @@ """Functional tests for the PICK SKY overlay — natal chart entry.""" +import json as _json + from selenium.webdriver.common.by import By from apps.applets.models import Applet @@ -9,6 +11,21 @@ from apps.lyric.models import User from .base import FunctionalTest +_PICK_SKY_CHART_FIXTURE = { + "planets": { + "Sun": {"sign": "Gemini", "degree": 66.7, "retrograde": False}, + "Moon": {"sign": "Taurus", "degree": 43.0, "retrograde": False}, + }, + "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": {str(i): 0 for i in range(1, 13)}, + "house_system": "O", + "timezone": "America/New_York", +} + + def _make_sky_select_room(): """Minimal SKY_SELECT room — just enough for the overlay to render.""" email = "founder@test.io" @@ -125,3 +142,104 @@ class PickSkyLocalStorageTest(FunctionalTest): self.assertEqual(values["tz"], "America/New_York") + + +class PickSkyDelTest(FunctionalTest): + """PICK SKY overlay gets a DEL btn at the wheel center: clicking opens the + global guard portal; OK clears the wheel SVG, resets the form fields, & + purges the localStorage entry that would otherwise rehydrate the form on + the next overlay open / page refresh. No server hit (the wheel here is + purely a preview — un-saved data lives only in localStorage).""" + + def setUp(self): + super().setUp() + Applet.objects.get_or_create( + slug="new-game", defaults={"name": "New Game", "context": "gameboard"} + ) + Applet.objects.get_or_create( + slug="my-games", defaults={"name": "My Games", "context": "gameboard"} + ) + self.room, self.founder, self.founder_email = _make_sky_select_room() + self.room_url = self.live_server_url + f"/gameboard/room/{self.room.id}/" + + def _ls_key(self): + # Mirrors the JS: 'sky-form:' + SAVE_URL (overlay.dataset.saveUrl is + # an absolute URL, so the LS key carries the full http://host:port… too) + return self.browser.execute_script( + "return 'sky-form:' + document.getElementById('id_sky_overlay').dataset.saveUrl;" + ) + + def test_del_clears_wheel_form_and_localstorage(self): + self.create_pre_authenticated_session(self.founder_email) + self.browser.get(self.room_url) + + # Open PICK SKY modal + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn")) + self.browser.execute_script("arguments[0].click()", btn) + self.wait_for(lambda: self.browser.find_element(By.ID, "id_sky_overlay")) + + # Mock /sky/preview so schedulePreview resolves & SkyWheel.draw paints + # children into #id_sky_svg without hitting PySwiss. + self.browser.execute_script(""" + const FIXTURE = """ + _json.dumps(_PICK_SKY_CHART_FIXTURE) + """; + window._origFetch = window.fetch; + window.fetch = function(url, opts) { + if (typeof url === 'string' && url.includes('/sky/preview')) { + return Promise.resolve({ok:true, json:()=>Promise.resolve(FIXTURE)}); + } + return window._origFetch(url, opts); + }; + """) + + # Fill form → triggers schedulePreview → wheel renders + self.browser.execute_script(""" + document.getElementById('id_nf_date').value = '2008-05-27'; + document.getElementById('id_nf_lat').value = '38.3754'; + document.getElementById('id_nf_lon').value = '-76.6955'; + document.getElementById('id_nf_place').value = 'Morganza, MD'; + document.getElementById('id_nf_tz').value = 'America/New_York'; + document.getElementById('id_nf_date').dispatchEvent( + new Event('input', {bubbles:true}) + ); + """) + + # Wait for the wheel to render (svg has children) + self.wait_for(lambda: self.assertTrue( + self.browser.find_elements(By.CSS_SELECTOR, "#id_sky_svg > *") + )) + + # localStorage was populated by _saveForm during typing + ls_key = self._ls_key() + self.assertIsNotNone(self.browser.execute_script( + f"return localStorage.getItem({_json.dumps(ls_key)});" + )) + + # DEL btn → guard portal → OK + del_btn = self.browser.find_element(By.ID, "id_sky_delete_btn") + del_btn.click() + portal = self.wait_for(lambda: self.browser.find_element(By.ID, "id_guard_portal")) + self.wait_for(lambda: self.assertIn("active", portal.get_attribute("class"))) + portal.find_element(By.CSS_SELECTOR, ".guard-yes").click() + + # After OK: SVG empty, form fields blank, localStorage entry purged + self.wait_for(lambda: self.assertFalse( + self.browser.find_elements(By.CSS_SELECTOR, "#id_sky_svg > *"), + "Wheel SVG should be cleared after DEL", + )) + values = self.browser.execute_script(""" + return { + date: document.getElementById('id_nf_date').value, + lat: document.getElementById('id_nf_lat').value, + lon: document.getElementById('id_nf_lon').value, + place: document.getElementById('id_nf_place').value, + tz: document.getElementById('id_nf_tz').value, + }; + """) + self.assertEqual(values["date"], "") + self.assertEqual(values["lat"], "") + self.assertEqual(values["lon"], "") + self.assertEqual(values["place"], "") + self.assertEqual(values["tz"], "") + self.assertIsNone(self.browser.execute_script( + f"return localStorage.getItem({_json.dumps(ls_key)});" + )) diff --git a/src/static_src/scss/_sky.scss b/src/static_src/scss/_sky.scss index 2b517d9..ba2b31f 100644 --- a/src/static_src/scss/_sky.scss +++ b/src/static_src/scss/_sky.scss @@ -888,6 +888,8 @@ body[class*="-light"] #id_sky_tooltip_2 { #id_applet_my_sky { display: flex; flex-direction: column; + // Anchor for #id_applet_sky_delete_btn's absolute centering. + position: relative; h2 { flex-shrink: 0; } @@ -954,10 +956,14 @@ body.page-sky { overflow-x: hidden; } -// DEL btn pinned at the wheel center — only rendered when chart_data exists. -// Anchored to .sky-wheel-col (which has position:relative) so the btn sits -// over the SVG's geometric center regardless of the snap layout's outer sizing. -#id_sky_delete_form { +// DEL btn pinned at the wheel center — appears wherever a wheel is shown +// (Dashsky form#id_sky_delete_form, PICK SKY overlay #id_sky_delete_btn, +// My Sky applet #id_applet_sky_delete_btn). Anchored to .sky-wheel-col / +// the applet section (both position:relative) so the btn sits over the SVG's +// geometric center regardless of the surrounding layout. +#id_sky_delete_form, +#id_sky_delete_btn, +#id_applet_sky_delete_btn { position: absolute; top: 50%; left: 50%; diff --git a/src/templates/apps/dashboard/_partials/_applet-my-sky.html b/src/templates/apps/dashboard/_partials/_applet-my-sky.html index 149a28a..5326852 100644 --- a/src/templates/apps/dashboard/_partials/_applet-my-sky.html +++ b/src/templates/apps/dashboard/_partials/_applet-my-sky.html @@ -71,6 +71,9 @@ {% if request.user.sky_chart_data %} + {{ request.user.sky_chart_data|json_script:"id_my_sky_data" }} {% endif %} @@ -98,6 +101,38 @@ SkyWheel.preload().then(function () { SkyWheel.draw(svgEl, stale); }); }); + // DEL btn — guard portal → POST sky_delete + clear LS + reload. Reload is + // the simplest swap back to the form rendering (the form-rendering branch + // is server-template-gated on user.sky_chart_data). window.showGuard is + // assigned by the script in base.html that lives BELOW the content block, + // so the readiness check is deferred to click-time rather than gating the + // listener bind itself. + var delBtn = document.getElementById('id_applet_sky_delete_btn'); + if (delBtn) { + var DELETE_URL = delBtn.dataset.deleteUrl; + function _csrf() { + var m = document.cookie.match(/csrftoken=([^;]+)/); + return m ? m[1] : ''; + } + delBtn.addEventListener('click', function () { + if (!window.showGuard) return; + window.showGuard(delBtn, 'Forget sky?', function () { + fetch(DELETE_URL, { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-CSRFToken': _csrf() }, + }).then(function (r) { + if (!r.ok) return; + // The form-render branch's _restoreForm() will re-seed + // fields from this LS entry on the next render — clear it + // so the post-reload form lands genuinely empty. + try { localStorage.removeItem('sky-form:dashboard:sky'); } catch (_) {} + window.location.reload(); + }); + }); + }); + } + {% else %} // No sky saved yet — wire up the entry form. diff --git a/src/templates/apps/gameboard/_partials/_sky_overlay.html b/src/templates/apps/gameboard/_partials/_sky_overlay.html index 204f3ed..1cea2bb 100644 --- a/src/templates/apps/gameboard/_partials/_sky_overlay.html +++ b/src/templates/apps/gameboard/_partials/_sky_overlay.html @@ -89,6 +89,8 @@ {# ── Wheel column ─────────────────────────────────────── #}