PICK SKY DEL: server purge of seat Character + race guards stop the btn from re-injecting; readonly opacity bump (0.6 → 0.85) — TDD

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 <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 15:39:07 -04:00
parent 1111df8465
commit 846f9ff461
6 changed files with 118 additions and 5 deletions

View File

@@ -1792,6 +1792,55 @@ class PickSkyRenderingTest(TestCase):
self.assertContains(response, 'id="id_pick_sky_btn"') self.assertContains(response, 'id="id_pick_sky_btn"')
self.assertContains(response, 'style="display:none"') 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): def test_no_sky_delete_btn_in_blank_sky_select_modal(self):
"""A fresh PICK SKY modal (no preview wheel rendered yet) must not """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 carry the DEL btn — it would otherwise float in the empty wheel area

View File

@@ -27,6 +27,7 @@ urlpatterns = [
path('room/<uuid:room_id>/tarot/deal', views.tarot_deal, name='tarot_deal'), path('room/<uuid:room_id>/tarot/deal', views.tarot_deal, name='tarot_deal'),
path('room/<uuid:room_id>/sky/preview', views.sky_preview, name='sky_preview'), path('room/<uuid:room_id>/sky/preview', views.sky_preview, name='sky_preview'),
path('room/<uuid:room_id>/sky/save', views.sky_save, name='sky_save'), path('room/<uuid:room_id>/sky/save', views.sky_save, name='sky_save'),
path('room/<uuid:room_id>/sky/delete', views.sky_delete, name='sky_delete'),
path('room/<uuid:room_id>/sea/partial', views.sea_partial, name='sea_partial'), path('room/<uuid:room_id>/sea/partial', views.sea_partial, name='sea_partial'),
path('room/<uuid:room_id>/sea/deck', views.sea_deck, name='sea_deck'), path('room/<uuid:room_id>/sea/deck', views.sea_deck, name='sea_deck'),
] ]

View File

@@ -8,7 +8,7 @@ from channels.layers import get_channel_layer
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db import transaction 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.shortcuts import redirect, render
from django.utils import timezone from django.utils import timezone
@@ -1125,6 +1125,23 @@ def sky_save(request, room_id):
return JsonResponse({'id': char.id, 'confirmed': char.is_confirmed}) 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 @login_required
def sea_deck(request, room_id): def sea_deck(request, room_id):
"""Shuffled deck lists (levity + gravity halves) for PICK SEA draw. """Shuffled deck lists (levity + gravity halves) for PICK SEA draw.

View File

@@ -221,11 +221,17 @@ class PickSkyDelTest(FunctionalTest):
self.wait_for(lambda: self.assertIn("active", portal.get_attribute("class"))) self.wait_for(lambda: self.assertIn("active", portal.get_attribute("class")))
portal.find_element(By.CSS_SELECTOR, ".guard-yes").click() 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.wait_for(lambda: self.assertFalse(
self.browser.find_elements(By.CSS_SELECTOR, "#id_sky_svg > *"), self.browser.find_elements(By.CSS_SELECTOR, "#id_sky_svg > *"),
"Wheel SVG should be cleared after DEL", "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(""" values = self.browser.execute_script("""
return { return {
date: document.getElementById('id_nf_date').value, date: document.getElementById('id_nf_date').value,

View File

@@ -209,6 +209,7 @@ html.sky-open .sky-modal-wrap {
max-width: 100%; max-width: 100%;
background-color: rgba(var(--priUser), 1); background-color: rgba(var(--priUser), 1);
color: rgba(var(--secUser), 1); color: rgba(var(--secUser), 1);
font-weight: 700;
border: 0.1rem solid rgba(var(--secUser), 0.5); border: 0.1rem solid rgba(var(--secUser), 0.5);
--_pad-v: 0.5rem; --_pad-v: 0.5rem;
padding: var(--_pad-v) 0.75rem; padding: var(--_pad-v) 0.75rem;
@@ -229,10 +230,20 @@ html.sky-open .sky-modal-wrap {
outline: none; outline: none;
border-color: rgba(var(--terUser), 0.75); border-color: rgba(var(--terUser), 0.75);
box-shadow: 0 0 0.75rem rgba(var(--terUser), 0.5); box-shadow: 0 0 0.75rem rgba(var(--terUser), 0.5);
color: rgba(var(--terUser), 1);
} }
&[readonly] { &[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; cursor: default;
} }
} }
@@ -316,8 +327,9 @@ html.sky-open .sky-modal-wrap {
input { input {
width: 100%; width: 100%;
opacity: 0.6; // opacity is inherited from the .sky-field input[readonly] rule
cursor: default; // above (0.85) — keep this block lean so the readonly-styling
// single source of truth doesn't drift.
} }
} }
} }

View File

@@ -9,6 +9,7 @@
id="id_sky_overlay" id="id_sky_overlay"
data-preview-url="{% url 'epic:sky_preview' room.id %}" data-preview-url="{% url 'epic:sky_preview' room.id %}"
data-save-url="{% url 'epic:sky_save' 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-sea-partial-url="{% url 'epic:sea_partial' room.id %}"
data-user-seat-role="{{ user_seat_role }}"> data-user-seat-role="{{ user_seat_role }}">
@@ -126,12 +127,16 @@
const PREVIEW_URL = overlay.dataset.previewUrl; const PREVIEW_URL = overlay.dataset.previewUrl;
const SAVE_URL = overlay.dataset.saveUrl; const SAVE_URL = overlay.dataset.saveUrl;
const DELETE_URL = overlay.dataset.deleteUrl;
const NOMINATIM = 'https://nominatim.openstreetmap.org/search'; const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
const USER_AGENT = 'EarthmanRPG/1.0 (https://earthmanrpg.me)'; const USER_AGENT = 'EarthmanRPG/1.0 (https://earthmanrpg.me)';
let _lastChartData = null; let _lastChartData = null;
let _placeDebounce = null; let _placeDebounce = null;
let _chartDebounce = 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 PLACE_DELAY = 400; // ms — Nominatim polite rate
const CHART_DELAY = 300; // ms — chart preview debounce const CHART_DELAY = 300; // ms — chart preview debounce
@@ -321,12 +326,18 @@
setStatus('Calculating…'); setStatus('Calculating…');
confirmBtn.disabled = true; confirmBtn.disabled = true;
const seq = ++_fetchSeq;
fetch(`${PREVIEW_URL}?${params}`, { credentials: 'same-origin' }) fetch(`${PREVIEW_URL}?${params}`, { credentials: 'same-origin' })
.then(r => { .then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`); if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json(); return r.json();
}) })
.then(data => { .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; _lastChartData = data;
// Back-fill timezone field from proxy response (first render) // Back-fill timezone field from proxy response (first render)
@@ -344,6 +355,7 @@
_ensureDelBtn(); _ensureDelBtn();
}) })
.catch(err => { .catch(err => {
if (seq !== _fetchSeq) return;
setStatus(`Could not fetch chart: ${err.message}`, 'error'); setStatus(`Could not fetch chart: ${err.message}`, 'error');
confirmBtn.disabled = true; confirmBtn.disabled = true;
}); });
@@ -420,6 +432,22 @@
_delBtn.addEventListener('click', () => { _delBtn.addEventListener('click', () => {
if (!window.showGuard) return; if (!window.showGuard) return;
window.showGuard(_delBtn, 'Forget sky?', () => { 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); while (svgEl.firstChild) svgEl.removeChild(svgEl.firstChild);
form.reset(); form.reset();
latInput.value = ''; latInput.value = '';