Compare commits

..

2 Commits

Author SHA1 Message Date
Disco DeDisco
97a6da28a5 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:
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- 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>
2026-05-20 15:08:49 -04:00
Disco DeDisco
bb44aa326a fix: AUTO-DRAWn my-sea cards are now clickable to re-open the stage modal — SeaDeal.register(card, posSelector, isLevity) public method populates _seaHand + delegates to SeaDeal's internal _fillSlot so the overlay click handler can resolve _seaHand[pos] for auto-drawn slots (previously short-circuited → silent no-op). AUTO DRAW in my_sea.html now calls register instead of the inline _fillSlot shim — also fixes a dataset.posKey inconsistency (inline stored raw "cover", SeaDeal stores ".sea-pos-cover"; click handler reads SeaDeal's form). User-reported 2026-05-21. TDD — new FT test_auto_drawn_slots_can_reopen_stage_modal_on_click pins the contract
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:53:05 -04:00
3 changed files with 233 additions and 6 deletions

View File

@@ -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,13 +126,41 @@ 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);
} }
// 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 (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.
function register(card, posSelector, 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 ────────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────
function init() { function init() {
@@ -239,6 +288,8 @@ var SeaDeal = (function () {
return { return {
openStage: openStage, openStage: openStage,
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 () {

View File

@@ -15,6 +15,14 @@ from apps.epic.models import personal_sig_cards
from apps.lyric.models import User from apps.lyric.models import User
def _count_filled_slots(picker):
"""Count `.sea-card-slot--filled` elements inside a picker container.
Module-level so multiple test classes can use it without inheriting."""
return len(
picker.find_elements(By.CSS_SELECTOR, ".sea-card-slot--filled")
)
def _seed_gameboard_applets(): def _seed_gameboard_applets():
"""My Sea + the rest of the gameboard applets so /gameboard/ renders """My Sea + the rest of the gameboard applets so /gameboard/ renders
without missing-applet errors during the applet-side assertions. without missing-applet errors during the applet-side assertions.
@@ -840,6 +848,117 @@ class MySeaCardDrawTest(FunctionalTest):
) )
self.assertIn("GATE", action_btn.text.upper()) self.assertIn("GATE", action_btn.text.upper())
# ── Test 5b — AUTO DRAW slot click reopens preview modal ────────────────
def test_auto_drawn_slots_can_reopen_stage_modal_on_click(self):
"""Iter-6c bug-fix (user-reported 2026-05-21): cards placed by
AUTO DRAW were silently un-clickable — the inline `_fillSlot`
bypassed `SeaDeal.openStage` so SeaDeal's `_seaHand` dict was
never populated for those slots, and the overlay click handler
short-circuits on `if (!_seaHand[pos]) return;` → no modal.
Reproduce: draw one card manually (sets `_seaHand[lay]`), AUTO
DRAW the rest (sets `_seaHand[lay]` still, but NOT cover/crown
if the bug exists), then click an auto-drawn slot — should
re-open the stage modal w. that card's preview.
Fix landed: `SeaDeal.register(card, posSelector, isLevity)`
public method populates `_seaHand` + delegates to SeaDeal's
internal `_fillSlot` (so `dataset.posKey` stays consistent w.
the manual-FLIP path). AUTO DRAW now calls register, not the
inline shim."""
picker = self._enter_picker_phase()
# Manual draw: lay (first position in SAO order).
self._draw_one(picker, "levity")
self.assertEqual(_count_filled_slots(picker), 1)
# AUTO DRAW the remaining 2 (cover + crown).
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
action_btn.click()
confirm = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_guard_portal.active .guard-yes"
)
)
confirm.click()
# Wait for hand-complete transition (action btn → GATE VIEW).
self.wait_for(
lambda: self.assertEqual(action_btn.get_attribute("data-state"), "gate-view")
)
# All 3 slots filled.
self.wait_for(lambda: self.assertEqual(_count_filled_slots(picker), 3))
# Click an AUTO-drawn slot (cover — second in SAO draw order, so
# placed by AUTO DRAW). Two-tap pattern: first click focuses,
# second click opens the modal (per SeaDeal's overlay handler).
cover_slot = picker.find_element(
By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot--filled"
)
cover_slot.click()
cover_slot.click()
# Stage modal must open w. the card preview.
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 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):

View File

@@ -591,7 +591,20 @@
); );
if (stack) _showOk(stack); if (stack) _showOk(stack);
setTimeout(function () { setTimeout(function () {
// Route thru SeaDeal.register (not the inline
// `_fillSlot`) so SeaDeal's `_seaHand` dict
// gets the entry — the overlay click handler
// reads from there, so without registration
// the auto-drawn slots are silently un-
// clickable (user-reported bug, 2026-05-21).
// Fallback to the inline shim only if sea.js
// hasn't loaded yet (defensive — script load
// order makes this unlikely).
if (window.SeaDeal && window.SeaDeal.register) {
SeaDeal.register(e.card, '.sea-pos-' + e.posName, e.isLevity);
} else {
_fillSlot(e.posName, e.card, e.isLevity); _fillSlot(e.posName, e.card, e.isLevity);
}
cross.querySelector('.sea-pos-' + e.posName + ' .sea-card-slot') cross.querySelector('.sea-pos-' + e.posName + ' .sea-card-slot')
.classList.add('sea-card-slot--visible'); .classList.add('sea-card-slot--visible');
_filled++; _filled++;
@@ -783,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