From 846f9ff4619ae1eca371cd723dc666e118bbffc3 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 8 May 2026 15:39:07 -0400 Subject: [PATCH] =?UTF-8?q?PICK=20SKY=20DEL:=20server=20purge=20of=20seat?= =?UTF-8?q?=20Character=20+=20race=20guards=20stop=20the=20btn=20from=20re?= =?UTF-8?q?-injecting;=20readonly=20opacity=20bump=20(0.6=20=E2=86=92=200.?= =?UTF-8?q?85)=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related Sky Select bugs the old DEL flow couldn't address. (1) DEL btn lingered after a clear because an in-flight schedulePreview's .then() could resolve AFTER the OK callback ran, calling _ensureDelBtn() against a freshly-cleared wheel-col. (2) Sky data rehydrated on refresh because clicking SAVE SKY confirms a Character row on the seat — the DEL handler only purged localStorage & in-memory state, leaving the durable Character row to drive subsequent renders. Server: new epic.sky_delete(room_id) view (POST → JsonResponse {deleted:True}) deletes every Character on the requesting gamer's seat where retired_at is null — drafts (confirmed_at NULL) and confirmed rows alike. 405 on GET, 403 for outsiders, never touches User.sky_chart_data (Dashsky/My Sky applet's DEL owns that side). JS (_sky_overlay.html): DEL OK callback now (a) bumps a _fetchSeq counter so any in-flight schedulePreview .then()/.catch() short-circuits when its captured seq != current — kills the re-injection race; (b) clearTimeout-s _chartDebounce + _placeDebounce so a typed-just-before-DEL keystroke can't fire schedulePreview after the clear; (c) POSTs to DELETE_URL (overlay.dataset.deleteUrl wired via {% url 'epic:sky_delete' room.id %}) so the seat's Character row is dropped server-side; (d) clears LS + DOM state as before. SCSS: .sky-field input[readonly] opacity 0.6 → 0.85, & dropped the redundant .sky-coords > div input { opacity:0.6 } that was previously winning the cascade by virtue of being declared later. The browser's default ::placeholder is ~0.54, so 0.85 × 0.54 ≈ 0.46 — close to the birth-place placeholder's ~0.54 effective opacity per the user's "appreciably higher tho not opacity 1" target. Values land at 0.85 (clearly readable but still de-emphasized vs. the editable place input). Tests: 4 new ITs in PickSkyRenderingTest cover (a) POST clears confirmed Character, returns JSON {deleted:True}; (b) 405 on GET; (c) 403 for non-seat-owner; (d) User.sky_chart_data untouched by in-room DEL. PickSkyDelTest FT picks up an extra assertion: id_sky_delete_btn must be absent from DOM after OK (the bug-1 regression guard). 55-test sky suite green. Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Sonnet 4.6 --- src/apps/epic/tests/integrated/test_views.py | 49 +++++++++++++++++++ src/apps/epic/urls.py | 1 + src/apps/epic/views.py | 19 ++++++- src/functional_tests/test_room_sky_select.py | 8 ++- src/static_src/scss/_sky.scss | 18 +++++-- .../gameboard/_partials/_sky_overlay.html | 28 +++++++++++ 6 files changed, 118 insertions(+), 5 deletions(-) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 3680f38..5059ec4 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -1792,6 +1792,55 @@ class PickSkyRenderingTest(TestCase): self.assertContains(response, 'id="id_pick_sky_btn"') self.assertContains(response, 'style="display:none"') + def test_sky_delete_clears_seat_character_and_returns_json(self): + """POST epic:sky_delete clears any Character on the requesting gamer's + seat — both unconfirmed drafts AND confirmed ones (the latter case is + why un-saved-via-DEL data was rehydrating on refresh: a SAVE SKY click + confirms a Character, and only that seat's Character row is the durable + target the in-room DEL has to purge).""" + # Seed both a draft & a confirmed Character — DEL must clear them both + from apps.epic.models import Character + pc_seat = TableSeat.objects.get(room=self.room, role="PC") + # Confirmed (the SAVE SKY case) + confirmed = Character.objects.create( + seat=pc_seat, + chart_data={"planets": {"Sun": {"sign": "Gemini"}}}, + confirmed_at=timezone.now(), + ) + url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id}) + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(response.content, {"deleted": True}) + self.assertFalse( + Character.objects.filter(seat=pc_seat, retired_at__isnull=True).exists(), + "Both draft and confirmed Characters on the seat should be gone", + ) + + def test_sky_delete_405_on_get(self): + url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id}) + self.assertEqual(self.client.get(url).status_code, 405) + + def test_sky_delete_requires_seat_owner(self): + """A gamer who isn't seated at this room can't purge another seat.""" + outsider = User.objects.create(email="outsider@test.io") + self.client.force_login(outsider) + url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id}) + self.assertEqual(self.client.post(url).status_code, 403) + + def test_sky_delete_does_not_touch_user_model(self): + """In-room DEL targets the seat's Character, never the User-level + sky_chart_data. (The Dashsky / My Sky applet DEL is the one that + clears the user's saved sky.)""" + founder = self.gamers[0] + founder.sky_chart_data = {"planets": {"Sun": {"sign": "Gemini"}}} + founder.sky_birth_tz = "America/New_York" + founder.save() + url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id}) + self.client.post(url) + founder.refresh_from_db() + self.assertEqual(founder.sky_chart_data, {"planets": {"Sun": {"sign": "Gemini"}}}) + self.assertEqual(founder.sky_birth_tz, "America/New_York") + def test_no_sky_delete_btn_in_blank_sky_select_modal(self): """A fresh PICK SKY modal (no preview wheel rendered yet) must not carry the DEL btn — it would otherwise float in the empty wheel area diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index 394e092..c018bd1 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -27,6 +27,7 @@ urlpatterns = [ path('room//tarot/deal', views.tarot_deal, name='tarot_deal'), path('room//sky/preview', views.sky_preview, name='sky_preview'), path('room//sky/save', views.sky_save, name='sky_save'), + path('room//sky/delete', views.sky_delete, name='sky_delete'), path('room//sea/partial', views.sea_partial, name='sea_partial'), path('room//sea/deck', views.sea_deck, name='sea_deck'), ] diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 13e474e..60d81c7 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -8,7 +8,7 @@ from channels.layers import get_channel_layer from django.conf import settings from django.contrib.auth.decorators import login_required from django.db import transaction -from django.http import HttpResponse, JsonResponse +from django.http import HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import redirect, render from django.utils import timezone @@ -1125,6 +1125,23 @@ def sky_save(request, room_id): return JsonResponse({'id': char.id, 'confirmed': char.is_confirmed}) +@login_required +def sky_delete(request, room_id): + """Purge the requesting gamer's Character on this seat — both unconfirmed + drafts AND confirmed rows. The in-room PICK SKY DEL targets this so SAVE + SKY → DEL → refresh truly drops the saved sky for the seat. The User + model's sky_chart_data is intentionally untouched (Dashsky / My Sky + applet's DEL handles that separately).""" + if request.method != 'POST': + return HttpResponse(status=405) + room = Room.objects.get(id=room_id) + seat = _canonical_user_seat(room, request.user) + if seat is None: + return HttpResponseForbidden() + Character.objects.filter(seat=seat, retired_at__isnull=True).delete() + return JsonResponse({'deleted': True}) + + @login_required def sea_deck(request, room_id): """Shuffled deck lists (levity + gravity halves) for PICK SEA draw. diff --git a/src/functional_tests/test_room_sky_select.py b/src/functional_tests/test_room_sky_select.py index 4a97c4b..e393be3 100644 --- a/src/functional_tests/test_room_sky_select.py +++ b/src/functional_tests/test_room_sky_select.py @@ -221,11 +221,17 @@ class PickSkyDelTest(FunctionalTest): 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 + # After OK: SVG empty, form fields blank, localStorage entry purged, + # and the DEL btn itself is gone — re-injection only happens on the + # next wheel paint, not while we sit on a freshly-cleared wheel. self.wait_for(lambda: self.assertFalse( self.browser.find_elements(By.CSS_SELECTOR, "#id_sky_svg > *"), "Wheel SVG should be cleared after DEL", )) + self.assertFalse( + self.browser.find_elements(By.ID, "id_sky_delete_btn"), + "DEL btn should be removed from the DOM after clear", + ) values = self.browser.execute_script(""" return { date: document.getElementById('id_nf_date').value, diff --git a/src/static_src/scss/_sky.scss b/src/static_src/scss/_sky.scss index cfcb335..5d4b3d9 100644 --- a/src/static_src/scss/_sky.scss +++ b/src/static_src/scss/_sky.scss @@ -209,6 +209,7 @@ html.sky-open .sky-modal-wrap { max-width: 100%; background-color: rgba(var(--priUser), 1); color: rgba(var(--secUser), 1); + font-weight: 700; border: 0.1rem solid rgba(var(--secUser), 0.5); --_pad-v: 0.5rem; padding: var(--_pad-v) 0.75rem; @@ -229,10 +230,20 @@ html.sky-open .sky-modal-wrap { outline: none; border-color: rgba(var(--terUser), 0.75); box-shadow: 0 0 0.75rem rgba(var(--terUser), 0.5); + color: rgba(var(--terUser), 1); } &[readonly] { - opacity: 0.6; + // Match the birth-place placeholder's effective opacity. The + // browser's default ::placeholder pseudo-element is ~0.54; the + // lat/lon/tz fields used to sit at the input-element-level + // opacity:0.6, which compounded with that to ~0.32 — making + // placeholders almost invisible. Bumping to 0.85 keeps the + // readonly fields visually de-emphasized vs. the editable ones + // while leaving the placeholder copy clearly readable + // (0.85 × 0.54 ≈ 0.46, parity w. birth-place's ~0.54). + opacity: 0.85; + color: inherit; cursor: default; } } @@ -316,8 +327,9 @@ html.sky-open .sky-modal-wrap { input { width: 100%; - opacity: 0.6; - cursor: default; + // opacity is inherited from the .sky-field input[readonly] rule + // above (0.85) — keep this block lean so the readonly-styling + // single source of truth doesn't drift. } } } diff --git a/src/templates/apps/gameboard/_partials/_sky_overlay.html b/src/templates/apps/gameboard/_partials/_sky_overlay.html index 358d7ce..13830e8 100644 --- a/src/templates/apps/gameboard/_partials/_sky_overlay.html +++ b/src/templates/apps/gameboard/_partials/_sky_overlay.html @@ -9,6 +9,7 @@ id="id_sky_overlay" data-preview-url="{% url 'epic:sky_preview' room.id %}" data-save-url="{% url 'epic:sky_save' room.id %}" + data-delete-url="{% url 'epic:sky_delete' room.id %}" data-sea-partial-url="{% url 'epic:sea_partial' room.id %}" data-user-seat-role="{{ user_seat_role }}"> @@ -126,12 +127,16 @@ const PREVIEW_URL = overlay.dataset.previewUrl; const SAVE_URL = overlay.dataset.saveUrl; + const DELETE_URL = overlay.dataset.deleteUrl; const NOMINATIM = 'https://nominatim.openstreetmap.org/search'; const USER_AGENT = 'EarthmanRPG/1.0 (https://earthmanrpg.me)'; let _lastChartData = null; let _placeDebounce = null; let _chartDebounce = null; + // Bumped on every clear so an in-flight schedulePreview() resolving after + // DEL doesn't paint the wheel back & re-inject the DEL btn. + let _fetchSeq = 0; const PLACE_DELAY = 400; // ms — Nominatim polite rate const CHART_DELAY = 300; // ms — chart preview debounce @@ -321,12 +326,18 @@ setStatus('Calculating…'); confirmBtn.disabled = true; + const seq = ++_fetchSeq; + fetch(`${PREVIEW_URL}?${params}`, { credentials: 'same-origin' }) .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) .then(data => { + // Stale fetch — DEL ran (or another preview superseded). Bail before + // we paint the wheel & re-inject the DEL btn against cleared state. + if (seq !== _fetchSeq) return; + _lastChartData = data; // Back-fill timezone field from proxy response (first render) @@ -344,6 +355,7 @@ _ensureDelBtn(); }) .catch(err => { + if (seq !== _fetchSeq) return; setStatus(`Could not fetch chart: ${err.message}`, 'error'); confirmBtn.disabled = true; }); @@ -420,6 +432,22 @@ _delBtn.addEventListener('click', () => { if (!window.showGuard) return; window.showGuard(_delBtn, 'Forget sky?', () => { + // 1. Invalidate any in-flight preview so its .then() can't paint the + // wheel back & re-inject this btn after we've cleared. + _fetchSeq++; + // 2. Cancel pending debounces so a typed-just-before-DEL keystroke + // can't fire schedulePreview after the clear. + clearTimeout(_chartDebounce); + clearTimeout(_placeDebounce); + // 3. Server purge — drops any Character (draft or confirmed) on this + // seat, so a refresh after SAVE-then-DEL doesn't rehydrate state + // from the durable Character row. + fetch(DELETE_URL, { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-CSRFToken': _getCsrf() }, + }).catch(() => {}); + // 4. Client DOM/state reset. while (svgEl.firstChild) svgEl.removeChild(svgEl.firstChild); form.reset(); latInput.value = '';