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, '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

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>/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/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/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.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.