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:
@@ -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
|
||||||
|
|||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user