fix: manual my-sea draws persist on refresh + reloaded slots stay clickable — root cause was SeaDeal stamping slot.dataset.posKey w. selector form (".sea-pos-cover") while my-sea's inline _collectHandFromDom + template's _my_sea_slot.html use raw names ("cover"). Key mismatch silently dropped manual draws from the lock POST → server rejected empty hand → no row → refresh showed empty state. AUTO DRAW worked only because it assembled fullHand w. raw posNames directly, bypassing the broken collector. TDD — 2 new FTs pin the contract:
- test_manual_draw_persists_on_refresh - test_reloaded_slot_can_reopen_stage_modal_on_click 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) <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,24 @@ var SeaDeal = (function () {
|
|||||||
var fyiPanel, fyiTitle, fyiType, fyiEffect, fyiIndex, fyiPrev, fyiNext;
|
var fyiPanel, fyiTitle, fyiType, fyiEffect, fyiIndex, fyiPrev, fyiNext;
|
||||||
|
|
||||||
var _userPolarity = 'levity';
|
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;
|
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 _infoData = [];
|
||||||
var _infoIdx = 0;
|
var _infoIdx = 0;
|
||||||
var _infoOpen = false;
|
var _infoOpen = false;
|
||||||
@@ -72,7 +88,12 @@ var SeaDeal = (function () {
|
|||||||
// XLIII / XLVIII) need horizontal squeezing to fit the slot — see SCSS.
|
// 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');
|
if ((card.corner_rank || '').length >= 5) slot.classList.add('sea-card-slot--rank-long');
|
||||||
slot.dataset.cardId = String(card.id);
|
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 =
|
slot.innerHTML =
|
||||||
'<span class="fan-corner-rank">' + card.corner_rank + '</span>' +
|
'<span class="fan-corner-rank">' + card.corner_rank + '</span>' +
|
||||||
(card.suit_icon ? '<i class="fa-solid ' + card.suit_icon + '"></i>' : '');
|
(card.suit_icon ? '<i class="fa-solid ' + card.suit_icon + '"></i>' : '');
|
||||||
@@ -90,7 +111,7 @@ var SeaDeal = (function () {
|
|||||||
function _hideStage() {
|
function _hideStage() {
|
||||||
// Reveal the deposited card in its slot (opacity 0 → 0.6 transition)
|
// Reveal the deposited card in its slot (opacity 0 → 0.6 transition)
|
||||||
if (_viewingPos) {
|
if (_viewingPos) {
|
||||||
var cell = overlay.querySelector(_viewingPos);
|
var cell = overlay.querySelector('.sea-pos-' + _viewingPos);
|
||||||
if (cell) {
|
if (cell) {
|
||||||
var slot = cell.querySelector('.sea-card-slot--filled');
|
var slot = cell.querySelector('.sea-card-slot--filled');
|
||||||
if (slot) slot.classList.add('sea-card-slot--visible');
|
if (slot) slot.classList.add('sea-card-slot--visible');
|
||||||
@@ -105,8 +126,9 @@ var SeaDeal = (function () {
|
|||||||
// ── Public API ─────────────────────────────────────────────────────────────
|
// ── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function openStage(card, posSelector, isLevity) {
|
function openStage(card, posSelector, isLevity) {
|
||||||
_viewingPos = posSelector;
|
var posName = _posName(posSelector);
|
||||||
_seaHand[posSelector] = { card: card, isLevity: isLevity };
|
_viewingPos = posName;
|
||||||
|
_seaHand[posName] = { card: card, isLevity: isLevity };
|
||||||
_populate(card, isLevity);
|
_populate(card, isLevity);
|
||||||
_fillSlot(posSelector, card, isLevity);
|
_fillSlot(posSelector, card, isLevity);
|
||||||
_showStage(isLevity);
|
_showStage(isLevity);
|
||||||
@@ -115,16 +137,30 @@ var SeaDeal = (function () {
|
|||||||
// Like `openStage` but DOESN'T show the stage modal — used by AUTO
|
// Like `openStage` but DOESN'T show the stage modal — used by AUTO
|
||||||
// DRAW (my_sea.html) to place cards quietly while keeping them
|
// DRAW (my_sea.html) to place cards quietly while keeping them
|
||||||
// clickable later. The overlay click handler reads `_seaHand[pos]`;
|
// 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
|
// bug fixed 2026-05-21). Routing slot fill thru SeaDeal's internal
|
||||||
// `_fillSlot` (instead of the inline shim) also ensures
|
// `_fillSlot` (instead of the inline shim) also ensures
|
||||||
// `dataset.posKey` stays consistent w. the manual-FLIP path
|
// `dataset.posKey` stays consistent w. the manual-FLIP path.
|
||||||
// (selector form like ".sea-pos-cover", not raw "cover").
|
|
||||||
function register(card, posSelector, isLevity) {
|
function register(card, posSelector, isLevity) {
|
||||||
_seaHand[posSelector] = { card: card, isLevity: isLevity };
|
_seaHand[_posName(posSelector)] = { card: card, isLevity: isLevity };
|
||||||
_fillSlot(posSelector, card, 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 ──────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
@@ -253,6 +289,7 @@ var SeaDeal = (function () {
|
|||||||
return {
|
return {
|
||||||
openStage: openStage,
|
openStage: openStage,
|
||||||
register: register,
|
register: register,
|
||||||
|
seedHand: seedHand,
|
||||||
resetHand: resetHand,
|
resetHand: resetHand,
|
||||||
reinit: init, // call after overlay is injected into the DOM
|
reinit: init, // call after overlay is injected into the DOM
|
||||||
_testInit: function () {
|
_testInit: function () {
|
||||||
|
|||||||
@@ -902,6 +902,63 @@ class MySeaCardDrawTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
self.wait_for(lambda: self.assertTrue(stage.is_displayed()))
|
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 ───────────────────────────────────────────────────────────────
|
# ── Test 6 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_del_btn_is_disabled_until_hand_complete(self):
|
def test_del_btn_is_disabled_until_hand_complete(self):
|
||||||
|
|||||||
@@ -796,6 +796,50 @@
|
|||||||
_lockSpread();
|
_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=
|
// Belt-and-braces autofill defense (paired w. autocomplete=
|
||||||
// off on the hidden input above). Firefox occasionally
|
// off on the hidden input above). Firefox occasionally
|
||||||
// restores form-history values on soft reload even on
|
// restores form-history values on soft reload even on
|
||||||
|
|||||||
Reference in New Issue
Block a user