A.3-polish: cross-deck sig picker (MINOR + MIDDLE courts) + My Sea applet sig-decoupling — TDD. Two user-reported bugs caught during A.3 visual verify (2026-05-25 PM). Bug 1: my_sign picker shows only 2 cards (Major 0 + 1) for Minchiate-equipped users since _sig_unique_cards_for_deck filters by arcana=MIDDLE which Minchiate (and any non-Earthman tarot family) doesn't classify its courts as — Minchiate courts are MINOR per its standard structure. User spec confirmed: my_sign picker = courts + Major 0/1 for EVERY deck (NOT segment-limited, NOT arcana-classification-limited). Fix: broaden the filter to arcana__in=[MIDDLE, MINOR] so courts qualify regardless of how the deck classifies them. For Earthman, behavior unchanged (no MINOR 11-14 cards exist in seed — its courts are exclusively MIDDLE); for Minchiate + RWS, picker expands from 2 → 18 cards as designed. Two side-by-side suit queries (brands_crowns + blades_grails) collapse to a single 4-suit query since the union was already covering all 4 — that was historical artifact, not segment-limiting in effect. Bug 2: deleting the user's sig on /billboard/my-sign/ blanks the My Sea applet on /gameboard/ even though the saved MySeaDraw spread is still in the DB (visible on /billboard/my-sea/), reappearing only when any sig is re-selected. Root cause: _applet-my-sea.html gated the slot-render branch on {% if not request.user.significator_id %} first, treating no-sig as "no draws yet" regardless of actual draw state. But MySeaDraw rows carry their own significator_id snapshot at first-draw time (gameboard.models.MySeaDraw doc lines 130-132) precisely so user-sig clearing doesn't invalidate saved draws — the template ignored that contract. Fix: invert the template branches — slot render now keys solely on my_sea_slots; the sig-gate Brief banner only fires in the empty-state branch when ALSO not request.user.significator_id (the "fresh user, no draws, no sig" case). MySeaDraw display now correctly decoupled from current sig state — sig deletion only matters for users who haven't drawn yet. Companion code: _sig_unique_cards_for_deck docstring updated to articulate the cross-deck symmetry rule ("courts recognized by rank 11-14 regardless of arcana classification") + the spec-confirmed non-segment-limitation. 1 new regression IT in GameboardViewTest.test_my_sea_applet_renders_slots_even_when_user_significator_cleared locks Bug 2's fix: creates a MySeaDraw row w. one filled slot, then sets User.significator=None, GETs /gameboard/, asserts the filled slot still renders + "No draws yet" empty state is absent. Tests: 1 new IT green; 810/810 epic+gameboard+billboard ITs green; 1290/1290 IT+UT total green (70s, +1 from A.3's 1289). No FT changes needed — Bug 1's fix changes the count of cards in the picker grid; existing FTs that count cards target Earthman where the count is unchanged. Visual verify still pending; user will confirm both fixes via Claudezilla browser session
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -647,20 +647,25 @@ def sig_deck_cards(room):
|
|||||||
def _sig_unique_cards_for_deck(deck_variant):
|
def _sig_unique_cards_for_deck(deck_variant):
|
||||||
"""Return the 18 unique TarotCards forming one sig pile for the given
|
"""Return the 18 unique TarotCards forming one sig pile for the given
|
||||||
deck variant. Shared between room sig-select (called via _sig_unique_cards
|
deck variant. Shared between room sig-select (called via _sig_unique_cards
|
||||||
after room → deck_variant lookup) and the solo My Sig picker (called
|
after room → deck_variant lookup) and the solo My Sign picker (called
|
||||||
via personal_sig_cards from User.equipped_deck)."""
|
via personal_sig_cards from User.equipped_deck).
|
||||||
|
|
||||||
|
"Court cards" are recognized by rank (11=Page, 12=Knight, 13=Queen,
|
||||||
|
14=King) regardless of arcana classification: Earthman classifies its
|
||||||
|
courts as MIDDLE arcana, but other tarot families (Minchiate Fiorentine,
|
||||||
|
RWS) classify them as MINOR. Including both classifications gives every
|
||||||
|
deck the symmetric 18-card pile (16 courts × 4 suits + 2 majors at
|
||||||
|
numbers 0/1) instead of letting non-Earthman decks fall to 2 cards just
|
||||||
|
because they don't use the MIDDLE classification. Cross-deck eligibility
|
||||||
|
is NOT segment-limited — all 4 suits' courts qualify per user spec
|
||||||
|
2026-05-25.
|
||||||
|
"""
|
||||||
if deck_variant is None:
|
if deck_variant is None:
|
||||||
return []
|
return []
|
||||||
brands_crowns = list(TarotCard.objects.filter(
|
courts = list(TarotCard.objects.filter(
|
||||||
deck_variant=deck_variant,
|
deck_variant=deck_variant,
|
||||||
arcana=TarotCard.MIDDLE,
|
arcana__in=[TarotCard.MIDDLE, TarotCard.MINOR],
|
||||||
suit__in=[TarotCard.BRANDS, TarotCard.CROWNS],
|
suit__in=[TarotCard.BRANDS, TarotCard.CROWNS, TarotCard.BLADES, TarotCard.GRAILS],
|
||||||
number__in=[11, 12, 13, 14],
|
|
||||||
))
|
|
||||||
blades_grails = list(TarotCard.objects.filter(
|
|
||||||
deck_variant=deck_variant,
|
|
||||||
arcana=TarotCard.MIDDLE,
|
|
||||||
suit__in=[TarotCard.BLADES, TarotCard.GRAILS],
|
|
||||||
number__in=[11, 12, 13, 14],
|
number__in=[11, 12, 13, 14],
|
||||||
))
|
))
|
||||||
major = list(TarotCard.objects.filter(
|
major = list(TarotCard.objects.filter(
|
||||||
@@ -668,7 +673,7 @@ def _sig_unique_cards_for_deck(deck_variant):
|
|||||||
arcana=TarotCard.MAJOR,
|
arcana=TarotCard.MAJOR,
|
||||||
number__in=[0, 1],
|
number__in=[0, 1],
|
||||||
))
|
))
|
||||||
return brands_crowns + blades_grails + major
|
return courts + major
|
||||||
|
|
||||||
|
|
||||||
def _sig_unique_cards(room):
|
def _sig_unique_cards(room):
|
||||||
|
|||||||
@@ -130,6 +130,51 @@ class GameboardViewTest(TestCase):
|
|||||||
empty_positions = {e.get("data-position") for e in empties}
|
empty_positions = {e.get("data-position") for e in empties}
|
||||||
self.assertEqual(empty_positions, {"cover", "crown"})
|
self.assertEqual(empty_positions, {"cover", "crown"})
|
||||||
|
|
||||||
|
def test_my_sea_applet_renders_slots_even_when_user_significator_cleared(self):
|
||||||
|
"""Regression (user bug 2026-05-25 PM): deleting User.significator
|
||||||
|
(via my-sign DEL) must NOT blank the My Sea applet on the gameboard
|
||||||
|
when the user has saved MySeaDraw slots. The MySeaDraw row carries
|
||||||
|
its own significator_id snapshot at first-draw-time so the saved
|
||||||
|
draw is durable even after the user clears their sig. Applet display
|
||||||
|
is now decoupled from `request.user.significator_id` — the sig-gate
|
||||||
|
Brief banner only fires for users with NO draws AND no sig."""
|
||||||
|
from apps.epic.models import personal_sig_cards, TarotCard
|
||||||
|
from apps.gameboard.models import MySeaDraw
|
||||||
|
sig_pile = personal_sig_cards(self.user)
|
||||||
|
snapshot_sig = sig_pile[0]
|
||||||
|
card = TarotCard.objects.first()
|
||||||
|
MySeaDraw.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
spread="situation-action-outcome",
|
||||||
|
hand=[
|
||||||
|
{"position": "lay", "card_id": card.id,
|
||||||
|
"reversed": False, "polarity": "gravity"},
|
||||||
|
],
|
||||||
|
significator_id=snapshot_sig.id,
|
||||||
|
)
|
||||||
|
# User clears their sig AFTER the draw was saved (the bug repro).
|
||||||
|
self.user.significator = None
|
||||||
|
self.user.significator_reversed = False
|
||||||
|
self.user.save(update_fields=["significator", "significator_reversed"])
|
||||||
|
self.assertIsNone(self.user.significator_id)
|
||||||
|
|
||||||
|
response = self.client.get("/gameboard/")
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
# Slots render despite no current sig — the MySeaDraw row owns the
|
||||||
|
# display, not the user's live sig state.
|
||||||
|
filled = parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")
|
||||||
|
self.assertEqual(
|
||||||
|
len(filled), 1,
|
||||||
|
"Filled slot must persist even when User.significator_id is None",
|
||||||
|
)
|
||||||
|
self.assertEqual(filled[0].get("data-card-id"), str(card.id))
|
||||||
|
# And the "No draws yet" empty state must NOT render.
|
||||||
|
empties = parsed.cssselect("#id_applet_my_sea .my-sea-empty")
|
||||||
|
self.assertEqual(
|
||||||
|
len(empties), 0,
|
||||||
|
"Applet must not render 'No draws yet' when slots exist",
|
||||||
|
)
|
||||||
|
|
||||||
def test_my_sea_applet_labels_match_locked_spread(self):
|
def test_my_sea_applet_labels_match_locked_spread(self):
|
||||||
"""SAO label per spec: lay='Situation', cover='Action',
|
"""SAO label per spec: lay='Situation', cover='Action',
|
||||||
crown='Outcome'. Empty slots still carry their label so the
|
crown='Outcome'. Empty slots still carry their label so the
|
||||||
|
|||||||
@@ -9,14 +9,14 @@
|
|||||||
{# Spread lock-in: the row is created at first card draw, so the moment #}
|
{# Spread lock-in: the row is created at first card draw, so the moment #}
|
||||||
{# 1+ cards exist all the spread's positions show in the applet. The #}
|
{# 1+ cards exist all the spread's positions show in the applet. The #}
|
||||||
{# scroll container handles overflow (mirrors the Palettes applet). #}
|
{# scroll container handles overflow (mirrors the Palettes applet). #}
|
||||||
{% if not request.user.significator_id %}
|
{# Slot display is INDEPENDENT of `request.user.significator_id` — the #}
|
||||||
{# Sprint 4b applet-gate — DRYly rendered as a project-wide Brief #}
|
{# MySeaDraw row snapshots the sig at first-draw time (see #}
|
||||||
{# banner (`note-banner` Gaussian-glass shell, portaled to the #}
|
{# `gameboard.models.MySeaDraw` doc lines 130-132), so a subsequent #}
|
||||||
{# page h2). Inline body falls through to the empty-state "No #}
|
{# my-sign DEL doesn't invalidate the saved draw. The sig-gate Brief #}
|
||||||
{# draws yet" since no sig → no draws is the only possible state.#}
|
{# banner only shows when the user has NO draws AND no sig — once draws #}
|
||||||
{% include "apps/gameboard/_partials/_my_sea_sign_gate_brief.html" %}
|
{# exist, they render regardless of current sig state (user spec #}
|
||||||
<p class="my-sea-empty">No draws yet.</p>
|
{# 2026-05-25 PM bug-report). #}
|
||||||
{% elif my_sea_slots %}
|
{% if my_sea_slots %}
|
||||||
<div class="my-sea-scroll">
|
<div class="my-sea-scroll">
|
||||||
{% for slot in my_sea_slots %}
|
{% for slot in my_sea_slots %}
|
||||||
{% if slot.card %}
|
{% if slot.card %}
|
||||||
@@ -72,6 +72,9 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{% if not request.user.significator_id %}
|
||||||
|
{% include "apps/gameboard/_partials/_my_sea_sign_gate_brief.html" %}
|
||||||
|
{% endif %}
|
||||||
<p class="my-sea-empty">No draws yet.</p>
|
<p class="my-sea-empty">No draws yet.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user