From 97a6da28a5a47cf81efd58582396bf7d49316410 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 20 May 2026 15:08:49 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20manual=20my-sea=20draws=20persist=20on?= =?UTF-8?q?=20refresh=20+=20reloaded=20slots=20stay=20clickable=20?= =?UTF-8?q?=E2=80=94=20root=20cause=20was=20SeaDeal=20stamping=20`slot.dat?= =?UTF-8?q?aset.posKey`=20w.=20selector=20form=20(".sea-pos-cover")=20whil?= =?UTF-8?q?e=20my-sea's=20inline=20`=5FcollectHandFromDom`=20+=20template'?= =?UTF-8?q?s=20`=5Fmy=5Fsea=5Fslot.html`=20use=20raw=20names=20("cover").?= =?UTF-8?q?=20Key=20mismatch=20silently=20dropped=20manual=20draws=20from?= =?UTF-8?q?=20the=20lock=20POST=20=E2=86=92=20server=20rejected=20empty=20?= =?UTF-8?q?hand=20=E2=86=92=20no=20row=20=E2=86=92=20refresh=20showed=20em?= =?UTF-8?q?pty=20state.=20AUTO=20DRAW=20worked=20only=20because=20it=20ass?= =?UTF-8?q?embled=20fullHand=20w.=20raw=20posNames=20directly,=20bypassing?= =?UTF-8?q?=20the=20broken=20collector.=20TDD=20=E2=80=94=202=20new=20FTs?= =?UTF-8?q?=20pin=20the=20contract:=20-=20test=5Fmanual=5Fdraw=5Fpersists?= =?UTF-8?q?=5Fon=5Frefresh=20-=20test=5Freloaded=5Fslot=5Fcan=5Freopen=5Fs?= =?UTF-8?q?tage=5Fmodal=5Fon=5Fclick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - sea.js: stamp `dataset.posKey` w. raw name (strip `.sea-pos-` prefix); `_seaHand` keyed by raw; `_viewingPos` is raw too (`_hideStage` prefixes when querySelector'ing); new `SeaDeal.seedHand(handByPosName)` public method for init-time DOM-walk seeding. - my_sea.html inline init: walk server-rendered filled slots, look up each card by `data-card-id` from the embedded deck JSON, reconstruct per-instance `reversed` + polarity from the slot's classes, hand the map to `SeaDeal.seedHand`. Without this, reloaded slots short-circuit the overlay click handler on `if (!_seaHand[pos]) return;`. The gameroom-side SeaDeal callers in `_sea_overlay.html` continue to pass selector form (SeaDeal accepts either — `_posName` helper strips prefix tolerantly). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/apps/epic/static/apps/epic/sea.js | 55 +++++++++++++++++++---- src/functional_tests/test_game_my_sea.py | 57 ++++++++++++++++++++++++ src/templates/apps/gameboard/my_sea.html | 44 ++++++++++++++++++ 3 files changed, 147 insertions(+), 9 deletions(-) diff --git a/src/apps/epic/static/apps/epic/sea.js b/src/apps/epic/static/apps/epic/sea.js index 732d47a..677cf51 100644 --- a/src/apps/epic/static/apps/epic/sea.js +++ b/src/apps/epic/static/apps/epic/sea.js @@ -6,8 +6,24 @@ var SeaDeal = (function () { var fyiPanel, fyiTitle, fyiType, fyiEffect, fyiIndex, fyiPrev, fyiNext; var _userPolarity = 'levity'; - var _seaHand = {}; // posSelector → {card, isLevity} + // posName (raw, e.g. "cover") → {card, isLevity}. The KEY here MUST + // match what `_collectHandFromDom` in `my_sea.html` reads (which is + // `slot.dataset.posKey`, stamped raw by both the template + the + // inline `_fillSlot` shim). Storing the SELECTOR form ".sea-pos- + // cover" instead silently broke persistence (manual draws never + // POSTed) — fixed 2026-05-21. + var _seaHand = {}; + // Raw position name (e.g. "cover") of the currently-showing stage. + // `_hideStage` prefixes `.sea-pos-` when querySelector'ing the cell. var _viewingPos = null; + + // Internal helper — strip the `.sea-pos-` prefix off a selector to + // get the raw position name. Tolerant of inputs that are already + // raw (no prefix → returned as-is) so callers don't need to know + // which form they have. + function _posName(posSelector) { + return (posSelector || '').replace(/^\.sea-pos-/, ''); + } var _infoData = []; var _infoIdx = 0; var _infoOpen = false; @@ -72,7 +88,12 @@ var SeaDeal = (function () { // XLIII / XLVIII) need horizontal squeezing to fit the slot — see SCSS. if ((card.corner_rank || '').length >= 5) slot.classList.add('sea-card-slot--rank-long'); slot.dataset.cardId = String(card.id); - slot.dataset.posKey = posSelector; + // Raw position name (e.g. "cover"), NOT the selector form. The + // my-sea inline `_collectHandFromDom` reads this + looks up by + // raw name from `_currentOrder()`; the template's empty-slot + // partial also stamps `data-pos-key="{{ position }}"` raw. + // Standardize so both code paths agree (2026-05-21 fix). + slot.dataset.posKey = _posName(posSelector); slot.innerHTML = '' + card.corner_rank + '' + (card.suit_icon ? '' : ''); @@ -90,7 +111,7 @@ var SeaDeal = (function () { function _hideStage() { // Reveal the deposited card in its slot (opacity 0 → 0.6 transition) if (_viewingPos) { - var cell = overlay.querySelector(_viewingPos); + var cell = overlay.querySelector('.sea-pos-' + _viewingPos); if (cell) { var slot = cell.querySelector('.sea-card-slot--filled'); if (slot) slot.classList.add('sea-card-slot--visible'); @@ -105,8 +126,9 @@ var SeaDeal = (function () { // ── Public API ───────────────────────────────────────────────────────────── function openStage(card, posSelector, isLevity) { - _viewingPos = posSelector; - _seaHand[posSelector] = { card: card, isLevity: isLevity }; + var posName = _posName(posSelector); + _viewingPos = posName; + _seaHand[posName] = { card: card, isLevity: isLevity }; _populate(card, isLevity); _fillSlot(posSelector, card, isLevity); _showStage(isLevity); @@ -115,16 +137,30 @@ var SeaDeal = (function () { // Like `openStage` but DOESN'T show the stage modal — used by AUTO // DRAW (my_sea.html) to place cards quietly while keeping them // clickable later. The overlay click handler reads `_seaHand[pos]`; - // without an entry here, click silently no-ops (the user-reported + // without an entry here, click silently no-ops (user-reported // bug fixed 2026-05-21). Routing slot fill thru SeaDeal's internal // `_fillSlot` (instead of the inline shim) also ensures - // `dataset.posKey` stays consistent w. the manual-FLIP path - // (selector form like ".sea-pos-cover", not raw "cover"). + // `dataset.posKey` stays consistent w. the manual-FLIP path. function register(card, posSelector, isLevity) { - _seaHand[posSelector] = { card: card, isLevity: isLevity }; + _seaHand[_posName(posSelector)] = { card: card, isLevity: isLevity }; _fillSlot(posSelector, card, isLevity); } + // Seed `_seaHand` from outside SeaDeal — used by my-sea's inline + // IIFE on init to make server-rendered (saved-hand) slots re- + // clickable after a refresh. Caller walks the filled DOM slots, + // looks up each card by `data-card-id` against the embedded deck + // JSON, and passes a `{posName: {card, isLevity}}` map here. Does + // NOT touch slot DOM — the slots are already filled by the server + // template; we just need the in-memory lookup to resolve the + // overlay click handler. Idempotent. + function seedHand(handByPosName) { + if (!handByPosName) return; + Object.keys(handByPosName).forEach(function (posName) { + _seaHand[posName] = handByPosName[posName]; + }); + } + // ── Init ────────────────────────────────────────────────────────────────── function init() { @@ -253,6 +289,7 @@ var SeaDeal = (function () { return { openStage: openStage, register: register, + seedHand: seedHand, resetHand: resetHand, reinit: init, // call after overlay is injected into the DOM _testInit: function () { diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py index beebc3f..b1f877c 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -902,6 +902,63 @@ class MySeaCardDrawTest(FunctionalTest): ) self.wait_for(lambda: self.assertTrue(stage.is_displayed())) + # ── Test 5c — manual draw survives refresh ────────────────────────────── + + def test_manual_draw_persists_on_refresh(self): + """User-reported 2026-05-21: manually-drawn cards disappeared + after a refresh. Root cause: SeaDeal's `_fillSlot` stamps + `slot.dataset.posKey` w. the selector form (".sea-pos-cover"), + but the inline `_collectHandFromDom` looks up by raw name from + `_currentOrder()` ("cover"). The key mismatch means manual + draws are dropped from the POST body — server stores nothing + → refresh shows empty state. + + Fix: standardize on raw position names for `dataset.posKey`.""" + from apps.gameboard.models import MySeaDraw + picker = self._enter_picker_phase() + self._draw_one(picker, "levity") + # POST is async — wait for server commit by polling the DB. + self.wait_for( + lambda: self.assertEqual( + len(MySeaDraw.objects.get(user=self.gamer).hand), 1, + ) + ) + self.browser.refresh() + picker = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']" + ) + ) + self.assertEqual(_count_filled_slots(picker), 1) + + # ── Test 5d — reloaded slot re-opens stage modal on click ─────────────── + + def test_reloaded_slot_can_reopen_stage_modal_on_click(self): + """After refresh, server-rendered filled slots must remain + clickable to re-open the stage modal. Requires SeaDeal's init + to seed `_seaHand` from the embedded deck JSON + the saved + slots in the DOM. User-reported 2026-05-21.""" + from apps.gameboard.models import MySeaDraw + picker = self._enter_picker_phase() + self._draw_one(picker, "levity") + self.wait_for( + lambda: self.assertEqual( + len(MySeaDraw.objects.get(user=self.gamer).hand), 1, + ) + ) + self.browser.refresh() + slot = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot--filled" + ) + ) + slot.click() # first tap — focus + slot.click() # second tap — open modal + stage = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage") + ) + self.wait_for(lambda: self.assertTrue(stage.is_displayed())) + # ── Test 6 ─────────────────────────────────────────────────────────────── def test_del_btn_is_disabled_until_hand_complete(self): diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html index 340651a..52dfeb6 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -796,6 +796,50 @@ _lockSpread(); } + // Seed SeaDeal's `_seaHand` from the server-rendered + // saved slots so they remain clickable for re-opening + // the stage modal after a refresh. Walks the filled + // slots + maps each `data-card-id` against the embedded + // deck JSON. Without this, the overlay click handler + // short-circuits on `if (!_seaHand[pos]) return;` — + // sea.js's in-memory `_seaHand` only gets populated by + // openStage/register during the live session. + // (User-reported 2026-05-21.) + if (window.SeaDeal && window.SeaDeal.seedHand) { + var _allCards = (_deckData.levity || []) + .concat(_deckData.gravity || []); + var _byId = {}; + _allCards.forEach(function (c) { _byId[c.id] = c; }); + var _seed = {}; + cross.querySelectorAll( + '.sea-card-slot.sea-card-slot--filled' + ).forEach(function (slot) { + var posName = slot.dataset.posKey; + var cardId = parseInt(slot.dataset.cardId, 10); + var card = _byId[cardId]; + if (!posName || !card) return; + // Reconstruct the per-instance `reversed` flag + // from the slot's DOM class (the deck JSON's + // `reversed` field is the original-shuffle axis + // — server's saved hand may have flipped it + // independently). Polarity also DOM-sourced. + var slotCard = {}; + for (var k in card) { + if (Object.prototype.hasOwnProperty.call(card, k)) { + slotCard[k] = card[k]; + } + } + slotCard.reversed = slot.classList.contains( + 'sea-card-slot--reversed' + ); + var isLevity = slot.classList.contains( + 'sea-card-slot--levity' + ); + _seed[posName] = { card: slotCard, isLevity: isLevity }; + }); + SeaDeal.seedHand(_seed); + } + // Belt-and-braces autofill defense (paired w. autocomplete= // off on the hidden input above). Firefox occasionally // restores form-history values on soft reload even on