sky wheel: ubiquitous DEL btn — applet & PICK SKY parity w. Dashsky; PICK SKY clears client-only state (no User-model touch) — TDD

My Sky applet (.../dashboard/_partials/_applet-my-sky.html): adds <button id="id_applet_sky_delete_btn" class="btn btn-danger"> at the wheel center, gated on user.sky_chart_data. Click → window.showGuard("Forget sky?") → on OK, fetch POSTs sky_delete (clears every sky_* field on User), removes the 'sky-form:dashboard:sky' localStorage entry that would otherwise rehydrate the post-reload form via _restoreForm(), then reloads — applet's form-render branch is server-template-gated on chart_data so the page comes back form-only.

PICK SKY in-room overlay (.../gameboard/_partials/_sky_overlay.html): adds <button id="id_sky_delete_btn"> at the wheel center. The wheel here is purely a live preview — sky_save fires only on SAVE SKY click w. action='confirm', so there's no draft Character to delete & we do NOT touch the Character/User model. The DEL handler clears the SVG, resets form fields (including lat/lon/tz/tzHint), nulls _lastChartData, disables the SAVE SKY btn, & purges the LS_KEY entry that would otherwise rehydrate on next overlay open / page refresh. Mirrors the user's spec ("shouldn't be targeting the user model anyway, only the character/seat model" — and there's currently no character/seat draft in the PICK SKY flow).

Both handlers defer the window.showGuard readiness check to click-time rather than gating the listener bind itself: window.showGuard is assigned by a base.html script that lives BELOW the content block, so an `if (window.showGuard)` gate at script-execute time would skip the bind entirely (we hit this writing the applet handler — manifested as portal class never receiving 'active' on click).

SCSS: extends the existing #id_sky_delete_form absolute-center rule onto the two new btn IDs (#id_sky_delete_btn, #id_applet_sky_delete_btn). #id_applet_my_sky picks up position:relative as the absolute anchor for the applet btn.

FTs: MySkyAppletDelTest (applet → DEL → guard → OK → reload, asserts User cleared + LS purged + form re-renders) & PickSkyDelTest (overlay → fill form → wheel paints → DEL → guard → OK, asserts SVG empty + form blank + LS purged). Both red before the wiring, green after; full sky suite (46 tests) green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-08 14:34:41 -04:00
parent 283b417341
commit e9bceaab62
5 changed files with 256 additions and 4 deletions

View File

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

View File

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

View File

@@ -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%;

View File

@@ -71,6 +71,9 @@
<svg id="id_my_sky_svg" class="sky-svg"
{% if not request.user.sky_chart_data %}style="display:none;"{% endif %}></svg>
{% if request.user.sky_chart_data %}
<button type="button" id="id_applet_sky_delete_btn"
class="btn btn-danger"
data-delete-url="{% url 'sky_delete' %}">DEL</button>
{{ request.user.sky_chart_data|json_script:"id_my_sky_data" }}
{% endif %}
</section>
@@ -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.

View File

@@ -89,6 +89,8 @@
{# ── Wheel column ─────────────────────────────────────── #}
<div class="sky-wheel-col">
<svg id="id_sky_svg" class="sky-svg"></svg>
<button type="button" id="id_sky_delete_btn"
class="btn btn-danger">DEL</button>
</div>
</div>{# /.sky-modal-body #}
@@ -399,6 +401,34 @@
window.location.reload();
}
// ── DEL btn — clears wheel + form + localStorage (no server hit) ────────
// PICK SKY's wheel is a live preview; un-saved data lives only in LS_KEY.
// Match the My Sky applet / Dashsky pattern but skip the server delete —
// there's no Character draft created during preview (sky_save fires only
// on SAVE SKY click w. action='confirm').
// window.showGuard is assigned in a base.html script that loads BELOW the
// content block — defer the readiness check to click-time so the listener
// bind happens regardless of inline-script execution order.
const delBtn = document.getElementById('id_sky_delete_btn');
if (delBtn) {
delBtn.addEventListener('click', () => {
if (!window.showGuard) return;
window.showGuard(delBtn, 'Forget sky?', () => {
while (svgEl.firstChild) svgEl.removeChild(svgEl.firstChild);
form.reset();
latInput.value = '';
lonInput.value = '';
tzInput.value = '';
tzHint.textContent = '';
_lastChartData = null;
confirmBtn.disabled = true;
setStatus('');
try { localStorage.removeItem(LS_KEY); } catch (_) {}
});
});
}
// ── CSRF ──────────────────────────────────────────────────────────────────
function _getCsrf() {