From 50a12bccab31237ba4c8081413929f81cc2e7402 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 25 May 2026 00:16:55 -0400 Subject: [PATCH] =?UTF-8?q?A.3-polish:=20cross-deck=20sig=20picker=20(MINO?= =?UTF-8?q?R=20+=20MIDDLE=20courts)=20+=20My=20Sea=20applet=20sig-decoupli?= =?UTF-8?q?ng=20=E2=80=94=20TDD.=20Two=20user-reported=20bugs=20caught=20d?= =?UTF-8?q?uring=20A.3=20visual=20verify=20(2026-05-25=20PM).=20Bug=201:?= =?UTF-8?q?=20my=5Fsign=20picker=20shows=20only=202=20cards=20(Major=200?= =?UTF-8?q?=20+=201)=20for=20Minchiate-equipped=20users=20since=20`=5Fsig?= =?UTF-8?q?=5Funique=5Fcards=5Ffor=5Fdeck`=20filters=20by=20`arcana=3DMIDD?= =?UTF-8?q?LE`=20which=20Minchiate=20(and=20any=20non-Earthman=20tarot=20f?= =?UTF-8?q?amily)=20doesn't=20classify=20its=20courts=20as=20=E2=80=94=20M?= =?UTF-8?q?inchiate=20courts=20are=20MINOR=20per=20its=20standard=20struct?= =?UTF-8?q?ure.=20User=20spec=20confirmed:=20my=5Fsign=20picker=20=3D=20co?= =?UTF-8?q?urts=20+=20Major=200/1=20for=20EVERY=20deck=20(NOT=20segment-li?= =?UTF-8?q?mited,=20NOT=20arcana-classification-limited).=20Fix:=20broaden?= =?UTF-8?q?=20the=20filter=20to=20`arcana=5F=5Fin=3D[MIDDLE,=20MINOR]`=20s?= =?UTF-8?q?o=20courts=20qualify=20regardless=20of=20how=20the=20deck=20cla?= =?UTF-8?q?ssifies=20them.=20For=20Earthman,=20behavior=20unchanged=20(no?= =?UTF-8?q?=20MINOR=2011-14=20cards=20exist=20in=20seed=20=E2=80=94=20its?= =?UTF-8?q?=20courts=20are=20exclusively=20MIDDLE);=20for=20Minchiate=20+?= =?UTF-8?q?=20RWS,=20picker=20expands=20from=202=20=E2=86=92=2018=20cards?= =?UTF-8?q?=20as=20designed.=20Two=20side-by-side=20suit=20queries=20(bran?= =?UTF-8?q?ds=5Fcrowns=20+=20blades=5Fgrails)=20collapse=20to=20a=20single?= =?UTF-8?q?=204-suit=20query=20since=20the=20union=20was=20already=20cover?= =?UTF-8?q?ing=20all=204=20=E2=80=94=20that=20was=20historical=20artifact,?= =?UTF-8?q?=20not=20segment-limiting=20in=20effect.=20Bug=202:=20deleting?= =?UTF-8?q?=20the=20user's=20sig=20on=20/billboard/my-sign/=20blanks=20the?= =?UTF-8?q?=20My=20Sea=20applet=20on=20/gameboard/=20even=20though=20the?= =?UTF-8?q?=20saved=20MySeaDraw=20spread=20is=20still=20in=20the=20DB=20(v?= =?UTF-8?q?isible=20on=20/billboard/my-sea/),=20reappearing=20only=20when?= =?UTF-8?q?=20any=20sig=20is=20re-selected.=20Root=20cause:=20`=5Fapplet-m?= =?UTF-8?q?y-sea.html`=20gated=20the=20slot-render=20branch=20on=20`{%=20i?= =?UTF-8?q?f=20not=20request.user.significator=5Fid=20%}`=20first,=20treat?= =?UTF-8?q?ing=20no-sig=20as=20"no=20draws=20yet"=20regardless=20of=20actu?= =?UTF-8?q?al=20draw=20state.=20But=20MySeaDraw=20rows=20carry=20their=20o?= =?UTF-8?q?wn=20`significator=5Fid`=20snapshot=20at=20first-draw=20time=20?= =?UTF-8?q?(`gameboard.models.MySeaDraw`=20doc=20lines=20130-132)=20precis?= =?UTF-8?q?ely=20so=20user-sig=20clearing=20doesn't=20invalidate=20saved?= =?UTF-8?q?=20draws=20=E2=80=94=20the=20template=20ignored=20that=20contra?= =?UTF-8?q?ct.=20Fix:=20invert=20the=20template=20branches=20=E2=80=94=20s?= =?UTF-8?q?lot=20render=20now=20keys=20solely=20on=20`my=5Fsea=5Fslots`;?= =?UTF-8?q?=20the=20sig-gate=20Brief=20banner=20only=20fires=20in=20the=20?= =?UTF-8?q?empty-state=20branch=20when=20ALSO=20`not=20request.user.signif?= =?UTF-8?q?icator=5Fid`=20(the=20"fresh=20user,=20no=20draws,=20no=20sig"?= =?UTF-8?q?=20case).=20MySeaDraw=20display=20now=20correctly=20decoupled?= =?UTF-8?q?=20from=20current=20sig=20state=20=E2=80=94=20sig=20deletion=20?= =?UTF-8?q?only=20matters=20for=20users=20who=20haven't=20drawn=20yet.=20C?= =?UTF-8?q?ompanion=20code:=20`=5Fsig=5Funique=5Fcards=5Ffor=5Fdeck`=20doc?= =?UTF-8?q?string=20updated=20to=20articulate=20the=20cross-deck=20symmetr?= =?UTF-8?q?y=20rule=20("courts=20recognized=20by=20rank=2011-14=20regardle?= =?UTF-8?q?ss=20of=20arcana=20classification")=20+=20the=20spec-confirmed?= =?UTF-8?q?=20non-segment-limitation.=201=20new=20regression=20IT=20in=20`?= =?UTF-8?q?GameboardViewTest.test=5Fmy=5Fsea=5Fapplet=5Frenders=5Fslots=5F?= =?UTF-8?q?even=5Fwhen=5Fuser=5Fsignificator=5Fcleared`=20locks=20Bug=202'?= =?UTF-8?q?s=20fix:=20creates=20a=20MySeaDraw=20row=20w.=20one=20filled=20?= =?UTF-8?q?slot,=20then=20sets=20User.significator=3DNone,=20GETs=20/gameb?= =?UTF-8?q?oard/,=20asserts=20the=20filled=20slot=20still=20renders=20+=20?= =?UTF-8?q?"No=20draws=20yet"=20empty=20state=20is=20absent.=20Tests:=201?= =?UTF-8?q?=20new=20IT=20green;=20810/810=20epic+gameboard+billboard=20ITs?= =?UTF-8?q?=20green;=201290/1290=20IT+UT=20total=20green=20(70s,=20+1=20fr?= =?UTF-8?q?om=20A.3's=201289).=20No=20FT=20changes=20needed=20=E2=80=94=20?= =?UTF-8?q?Bug=201's=20fix=20changes=20the=20count=20of=20cards=20in=20the?= =?UTF-8?q?=20picker=20grid;=20existing=20FTs=20that=20count=20cards=20tar?= =?UTF-8?q?get=20Earthman=20where=20the=20count=20is=20unchanged.=20Visual?= =?UTF-8?q?=20verify=20still=20pending;=20user=20will=20confirm=20both=20f?= =?UTF-8?q?ixes=20via=20Claudezilla=20browser=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/apps/epic/models.py | 29 +++++++----- .../gameboard/tests/integrated/test_views.py | 45 +++++++++++++++++++ .../gameboard/_partials/_applet-my-sea.html | 19 ++++---- 3 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 1259e5f..12fccde 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -647,20 +647,25 @@ def sig_deck_cards(room): def _sig_unique_cards_for_deck(deck_variant): """Return the 18 unique TarotCards forming one sig pile for the given deck variant. Shared between room sig-select (called via _sig_unique_cards - after room → deck_variant lookup) and the solo My Sig picker (called - via personal_sig_cards from User.equipped_deck).""" + after room → deck_variant lookup) and the solo My Sign picker (called + 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: return [] - brands_crowns = list(TarotCard.objects.filter( + courts = list(TarotCard.objects.filter( deck_variant=deck_variant, - arcana=TarotCard.MIDDLE, - suit__in=[TarotCard.BRANDS, TarotCard.CROWNS], - 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], + arcana__in=[TarotCard.MIDDLE, TarotCard.MINOR], + suit__in=[TarotCard.BRANDS, TarotCard.CROWNS, TarotCard.BLADES, TarotCard.GRAILS], number__in=[11, 12, 13, 14], )) major = list(TarotCard.objects.filter( @@ -668,7 +673,7 @@ def _sig_unique_cards_for_deck(deck_variant): arcana=TarotCard.MAJOR, number__in=[0, 1], )) - return brands_crowns + blades_grails + major + return courts + major def _sig_unique_cards(room): diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 26054b6..3eac2f6 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -130,6 +130,51 @@ class GameboardViewTest(TestCase): empty_positions = {e.get("data-position") for e in empties} 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): """SAO label per spec: lay='Situation', cover='Action', crown='Outcome'. Empty slots still carry their label so the diff --git a/src/templates/apps/gameboard/_partials/_applet-my-sea.html b/src/templates/apps/gameboard/_partials/_applet-my-sea.html index bf5d4f3..e635894 100644 --- a/src/templates/apps/gameboard/_partials/_applet-my-sea.html +++ b/src/templates/apps/gameboard/_partials/_applet-my-sea.html @@ -9,14 +9,14 @@ {# 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 #} {# scroll container handles overflow (mirrors the Palettes applet). #} - {% if not request.user.significator_id %} - {# Sprint 4b applet-gate — DRYly rendered as a project-wide Brief #} - {# banner (`note-banner` Gaussian-glass shell, portaled to the #} - {# page h2). Inline body falls through to the empty-state "No #} - {# draws yet" since no sig → no draws is the only possible state.#} - {% include "apps/gameboard/_partials/_my_sea_sign_gate_brief.html" %} -

No draws yet.

- {% elif my_sea_slots %} + {# Slot display is INDEPENDENT of `request.user.significator_id` — the #} + {# MySeaDraw row snapshots the sig at first-draw time (see #} + {# `gameboard.models.MySeaDraw` doc lines 130-132), so a subsequent #} + {# my-sign DEL doesn't invalidate the saved draw. The sig-gate Brief #} + {# banner only shows when the user has NO draws AND no sig — once draws #} + {# exist, they render regardless of current sig state (user spec #} + {# 2026-05-25 PM bug-report). #} + {% if my_sea_slots %}
{% for slot in my_sea_slots %} {% if slot.card %} @@ -72,6 +72,9 @@ {% endfor %}
{% else %} + {% if not request.user.significator_id %} + {% include "apps/gameboard/_partials/_my_sea_sign_gate_brief.html" %} + {% endif %}

No draws yet.

{% endif %}