From bc77296dd4630348b6d33f0cb14f1e4bcc720b76 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 18 May 2026 01:07:13 -0400 Subject: [PATCH] =?UTF-8?q?+52=20IT/UT=20to=20close=20IT/UT-only=20coverag?= =?UTF-8?q?e=20gaps=20(93%=20=E2=86=92=2096%)=20=E2=80=94=20full=20suite?= =?UTF-8?q?=20983=20tests=20in=2047s=20;=20UTs=20in=20epic/tests/unit/test?= =?UTF-8?q?=5Fmodels.py=20=E2=80=94=20`TarotCardEmanationForTest`=20(4)=20?= =?UTF-8?q?covers=20`emanation=5Ffor(polarity)`=20w.=20levity/gravity=20ov?= =?UTF-8?q?errides=20+=20fallback=20to=20name=5Ftitle=20for=20cards=20w.o?= =?UTF-8?q?=20a=20polarity=20split=20(cards=2048-49=20are=20the=20only=20p?= =?UTF-8?q?olarity-split=20cards=20in=20the=20deck=20so=20this=20method=20?= =?UTF-8?q?is=20sparsely=20exercised=20by=20ITs);=20`TarotCardReversalForT?= =?UTF-8?q?est`=20(4)=20covers=20`reversal=5Ffor(polarity)`=20w.=20polarit?= =?UTF-8?q?y-split=20+=20reversal=5Fqualifier=20fallback=20+=20further=20f?= =?UTF-8?q?allthrough=20to=20emanation=5Ffor;=20`TarotCardNameSplitTest`?= =?UTF-8?q?=20(4)=20covers=20`name=5Fgroup`/`name=5Ftitle`=20colon-split?= =?UTF-8?q?=20parsing=20(prefix-w-colon=20/=20suffix=20/=20no-colon=20edge?= =?UTF-8?q?);=20`TarotCardCautionsJsonTest`=20(2)=20covers=20the=20`cautio?= =?UTF-8?q?ns=5Fjson`=20JSON=20serialiser=20;=20UTs=20in=20epic/tests/unit?= =?UTF-8?q?/test=5Futils.py=20=E2=80=94=20`PlanetHouseFallbackTest`=20+1?= =?UTF-8?q?=20happy-path=20test=20(degree=3D15=20lands=20in=20house=201=20?= =?UTF-8?q?w.=20sequential=20cusps)=20for=20the=20normal=20cusp-match=20br?= =?UTF-8?q?anch=20alongside=20the=20existing=20pathological=20fallback=20t?= =?UTF-8?q?est;=20`TopCapacitorsTest`=20(6)=20covers=20all=20`top=5Fcapaci?= =?UTF-8?q?tors()`=20branches=20=E2=80=94=20empty=20dict=20/=20None=20/=20?= =?UTF-8?q?all-zero=20counts=20(the=20L56=20`max(counts.values())=20<=3D?= =?UTF-8?q?=200`=20fallback=20that=20was=20uncovered)=20/=20single-winner?= =?UTF-8?q?=20/=20tie-clockwise-order=20/=20enriched=20dict=20{"count":N}?= =?UTF-8?q?=20input=20shape=20;=20ITs=20in=20epic/tests/integrated/test=5F?= =?UTF-8?q?models.py=20=E2=80=94=20`TarotDeckDrawTest`=20extended=20w.=205?= =?UTF-8?q?=20tests=20for=20`remaining=5Fcount`=20(happy=20+=20no-deck-var?= =?UTF-8?q?iant=20fallback=20to=200)=20+=20`draw()`=20happy-path=20(return?= =?UTF-8?q?s=20n=20tuples=20of=20(TarotCard,=20bool)=20/=20appends=20to=20?= =?UTF-8?q?drawn=5Fcard=5Fids=20/=20never=20repeats=20cards=20across=20con?= =?UTF-8?q?secutive=20draws);=20existing=20ValueError=20+=20shuffle=20test?= =?UTF-8?q?s=20preserved=20;=20ITs=20in=20epic/tests/integrated/test=5Fvie?= =?UTF-8?q?ws.py=20=E2=80=94=20`SigEventRetractionTest`=20(4=20tests)=20co?= =?UTF-8?q?vers=20the=20three=20`data["retracted"]=20=3D=20True`=20paths?= =?UTF-8?q?=20that=20the=20FT=20`test=5Fgame=5Froom=5Fselect=5Fsig.py`=20w?= =?UTF-8?q?alks=20transitively=20but=20no=20IT=20pins=20directly:=20sig=5F?= =?UTF-8?q?unready=20retracts=20prior=20SIG=5FREADY=20(L937),=20sig=5Fread?= =?UTF-8?q?y=20retracts=20prior=20SIG=5FUNREADY=20(L907),=20sig=5Freserve?= =?UTF-8?q?=20action=3Drelease=20while=20ready=20retracts=20prior=20SIG=5F?= =?UTF-8?q?READY=20+=20records=20fresh=20SIG=5FUNREADY=20(L823);=20`SigRes?= =?UTF-8?q?erveInvalidCardIdTest`=20(1)=20covers=20`TarotCard.DoesNotExist?= =?UTF-8?q?`=20=E2=86=92=20400=20(L840-841)=20;=20`SigSelectGravityContext?= =?UTF-8?q?Test`=20(3)=20covers=20the=20`user=5Fpolarity=20=3D=20'gravity'?= =?UTF-8?q?`=20branch=20(L322)=20+=20the=20`gravity=5Fsig=5Fcards`=20looku?= =?UTF-8?q?p=20(L357)=20=E2=80=94=20all=20existing=20SIG=5FSELECT=20contex?= =?UTF-8?q?t=20tests=20use=20the=20founder-as-PC-levity=20setup=20so=20the?= =?UTF-8?q?se=20branches=20sat=20uncovered;=20logs=20in=20as=20gamers[5]?= =?UTF-8?q?=20(BC=20role)=20+=20asserts=20user=5Fpolarity=20+=20sig=5Fcard?= =?UTF-8?q?s=20match=20`gravity=5Fsig=5Fcards()`=20output=20;=20`SeaDeckVi?= =?UTF-8?q?ewTest`=20(7)=20mirrors=20the=20`test=5Fgame=5Froom=5Fselect=5F?= =?UTF-8?q?sea.py`=20FT=20but=20isolates=20the=20JSON=20contract=20?= =?UTF-8?q?=E2=80=94=20covers=20403=20when=20unseated,=20empty=20halves=20?= =?UTF-8?q?when=20seat=20has=20no=20deck=5Fvariant=20(L1255-1256=20early-o?= =?UTF-8?q?ut),=20two-halves=20shape,=20~even=20split,=20card=5Fdict=20key?= =?UTF-8?q?s=20(`id`/`name`/`arcana`/`corner=5Frank`/`suit=5Ficon`/`name?= =?UTF-8?q?=5Fgroup`/`name=5Ftitle`/`reversed`/qualifiers),=20`reversed`?= =?UTF-8?q?=20field=20is=20bool,=20claimed-significator=20exclusion=20via?= =?UTF-8?q?=20`room.table=5Fseats.exclude(significator=5F=5Fisnull=3DTrue)?= =?UTF-8?q?`=20;=20ITs=20in=20dashboard/tests/integrated/test=5Fviews.py?= =?UTF-8?q?=20=E2=80=94=20`ProfileViewTest`=20+2=20(reserved-handle=20"adm?= =?UTF-8?q?an"=20rejection=20=E2=80=94=20L116-117:=20username=20stays=20un?= =?UTF-8?q?changed=20+=20redirect=20to=20/);=20`KitBagViewTest`=20(3)=20co?= =?UTF-8?q?vers=20the=20`kit=5Fbag`=20view's=20panel=20render=20w.=20TITHE?= =?UTF-8?q?-sort=20branch=20(L169-175)=20+=20login=20guard=20;=20ITs=20in?= =?UTF-8?q?=20dashboard/tests/integrated/test=5Fsky=5Fviews.py=20=E2=80=94?= =?UTF-8?q?=20`SkyViewTest`=20+2=20(saved=20birth=20datetime=20renders=20i?= =?UTF-8?q?n=20user's=20`sky=5Fbirth=5Ftz`=20via=20astimezone=20L300-306?= =?UTF-8?q?=20=E2=80=94=2016:00=20UTC=20=E2=86=92=2012:00=20EDT;=20invalid?= =?UTF-8?q?-tz=20string=20triggers=20`ZoneInfoNotFoundError`=20=E2=86=92?= =?UTF-8?q?=20swallowed=20`pass`=20=E2=86=92=20UTC=20fallback=20at=2016:00?= =?UTF-8?q?)=20;=20ITs=20in=20gameboard/tests/integrated/test=5Fviews.py?= =?UTF-8?q?=20=E2=80=94=20`EquipTrinketViewTest`=20+2=20(POST=20equips=20t?= =?UTF-8?q?rinket=20+=20returns=20204=20=E2=80=94=20L83-85;=20non-owner=20?= =?UTF-8?q?POST=20returns=20404=20via=20`get=5Fobject=5For=5F404`);=20`Une?= =?UTF-8?q?quipTrinketViewTest`=20+2=20(POST=20clears=20matching=20equippe?= =?UTF-8?q?d=5Ftrinket=20=E2=80=94=20L107-110;=20POST=20of=20non-matching?= =?UTF-8?q?=20token=20is=20a=20204=20no-op,=20the=20implicit=20`else`=20br?= =?UTF-8?q?anch)=20;=20.coveragerc=20omit=20gains=20`*/reset=5Fstaging=5Fd?= =?UTF-8?q?b.py`=20per=20user=20=E2=80=94=20mgmt=20cmd=20was=20the=20only?= =?UTF-8?q?=200%-stmt=20module=20that=20wasn't=20exercised=20by=20tests=20?= =?UTF-8?q?at=20all=20+=20we=20agreed=20it's=20deliberately=20untested=20s?= =?UTF-8?q?taging-side=20code=20;=20palette-monochrome-dark=20rebalance=20?= =?UTF-8?q?in=20rootvars.scss=20=E2=80=94=20--quiUser/--sixUser/--sepUser?= =?UTF-8?q?=20remapped=20to=20(secAg=20/=20quaAg=20/=20priPt)=20instead=20?= =?UTF-8?q?of=20(quaAg=20/=20terAg=20/=20secAg),=20shifting=20the=20second?= =?UTF-8?q?ary/subtle/deep-subtle=20anchors=20up=20the=20silver=20gradient?= =?UTF-8?q?=20so=20the=20palette=20reads=20more=20cleanly=20under=20the=20?= =?UTF-8?q?new=20sig-stage=20card=20colours=20from=203242873=20;=20uncover?= =?UTF-8?q?ed=20remnants=20from=20earlier=20analysis=20intentionally=20lef?= =?UTF-8?q?t=20in=20place=20=E2=80=94=20consumers.py=20at=2068%=20(channel?= =?UTF-8?q?s-tag=20tests=20excluded;=20would=20need=20--tag=3Dchannels=20r?= =?UTF-8?q?un),=20Carte=20Blanche=20slot=20navigation=20+=20sky=5Fdice=20+?= =?UTF-8?q?=20tarot=5Fdeck=20preview=20view=20paths=20(the=20"bigger=20inv?= =?UTF-8?q?estments"=20tier=20from=20session=20triage;=20FT-covered=20+=20?= =?UTF-8?q?the=20IT=20setup=20is=20heavier=20than=20the=20immediate=20valu?= =?UTF-8?q?e),=20defensive=20`except`=20fallbacks=20that=20need=20contrive?= =?UTF-8?q?d=20inputs=20to=20fire,=20and=20a=20handful=20of=20=5F=5Fstr=5F?= =?UTF-8?q?=5Fs/`pass`=20branches=20not=20worth=20a=20test=20apiece=20?= =?UTF-8?q?=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Sonnet 4.6 --- src/.coveragerc | 1 + .../tests/integrated/test_sky_views.py | 26 +++ .../dashboard/tests/integrated/test_views.py | 41 ++++ src/apps/epic/tests/integrated/test_models.py | 51 ++++ src/apps/epic/tests/integrated/test_views.py | 219 ++++++++++++++++++ src/apps/epic/tests/unit/test_models.py | 101 ++++++++ src/apps/epic/tests/unit/test_utils.py | 43 +++- .../gameboard/tests/integrated/test_views.py | 42 ++++ src/static_src/scss/rootvars.scss | 6 +- 9 files changed, 526 insertions(+), 4 deletions(-) diff --git a/src/.coveragerc b/src/.coveragerc index cf2fe04..e517db2 100644 --- a/src/.coveragerc +++ b/src/.coveragerc @@ -4,6 +4,7 @@ omit = */migrations/* */tests/* */routing.py + */reset_staging_db.py [report] show_missing = true \ No newline at end of file diff --git a/src/apps/dashboard/tests/integrated/test_sky_views.py b/src/apps/dashboard/tests/integrated/test_sky_views.py index db01561..ef85562 100644 --- a/src/apps/dashboard/tests/integrated/test_sky_views.py +++ b/src/apps/dashboard/tests/integrated/test_sky_views.py @@ -37,6 +37,32 @@ class SkyViewTest(TestCase): self.assertContains(response, reverse("sky_preview")) self.assertContains(response, reverse("sky_save")) + def test_saved_birth_date_renders_in_user_tz_when_set(self): + """A user w. saved sky_birth_dt + sky_birth_tz hits the astimezone + branch (views.py L300-306) — saved_birth_date / saved_birth_time + render in the user's local tz, not UTC.""" + from datetime import datetime + import zoneinfo + # 1990-06-15 16:00 UTC = 12:00 PM in America/New_York (EDT, UTC-4) + self.user.sky_birth_dt = datetime(1990, 6, 15, 16, 0, tzinfo=zoneinfo.ZoneInfo("UTC")) + self.user.sky_birth_tz = "America/New_York" + self.user.save() + response = self.client.get(reverse("sky")) + self.assertEqual(response.context["saved_birth_date"], "1990-06-15") + self.assertEqual(response.context["saved_birth_time"], "12:00") + + def test_saved_birth_falls_back_to_utc_when_tz_invalid(self): + """A garbage sky_birth_tz triggers ZoneInfoNotFoundError — the view + swallows it (pass) and renders the UTC representation.""" + from datetime import datetime + import zoneinfo + self.user.sky_birth_dt = datetime(1990, 6, 15, 16, 0, tzinfo=zoneinfo.ZoneInfo("UTC")) + self.user.sky_birth_tz = "Not/A/Real_Zone" + self.user.save() + response = self.client.get(reverse("sky")) + # UTC fallback — 16:00 stays 16:00 + self.assertEqual(response.context["saved_birth_time"], "16:00") + def test_tz_input_is_readonly_and_carries_auto_detect_placeholder(self): """Manual TZ edits throw the schedulePreview / PySwiss fetch off (the backend gets a stale TZ for the new lat/lon), so the field is render- diff --git a/src/apps/dashboard/tests/integrated/test_views.py b/src/apps/dashboard/tests/integrated/test_views.py index a1457f5..4f2fe0a 100644 --- a/src/apps/dashboard/tests/integrated/test_views.py +++ b/src/apps/dashboard/tests/integrated/test_views.py @@ -461,6 +461,47 @@ class ProfileViewTest(TestCase): [username_input] = parsed.cssselect("#id_new_username") self.assertEqual("discoman", username_input.get("value")) + def test_post_reserved_username_does_not_save(self): + """RESERVED_USERNAMES (e.g. 'adman') must be rejected — the view bails + with an error message + redirect to / before reaching user.save().""" + original_username = self.user.username + self.client.post("/dashboard/set_profile", data={"username": "adman"}) + self.user.refresh_from_db() + self.assertEqual(self.user.username, original_username) + + def test_post_reserved_username_redirects_home(self): + response = self.client.post("/dashboard/set_profile", data={"username": "adman"}) + self.assertRedirects(response, "/", fetch_redirect_response=False) + + +class KitBagViewTest(TestCase): + """`kit_bag` view — renders the kit-bag panel partial w. equipped + token state.""" + + def setUp(self): + from apps.lyric.models import Token + self.user = User.objects.create(email="gamer@test.io") + self.client.force_login(self.user) + # Stash a TITHE token so the list-comprehension branch lands non-empty + Token.objects.create(user=self.user, token_type=Token.TITHE) + self.url = "/dashboard/kit-bag/" + + def test_get_returns_200_and_renders_panel(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "core/_partials/_kit_bag_panel.html") + + def test_context_passes_free_and_tithe_counts(self): + response = self.client.get(self.url) + # signal seeds a FREE + COIN; we added a TITHE in setUp. + self.assertEqual(response.context["tithe_count"], 1) + self.assertGreaterEqual(response.context["free_count"], 0) + + def test_requires_login(self): + self.client.logout() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 302) + self.assertIn("/?next=", response["Location"]) + class ToggleDashAppletsViewTest(TestCase): def setUp(self): self.user = User.objects.create(email="disco@test.io") diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index 1138f1c..ac2debf 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -649,6 +649,57 @@ class TarotDeckDrawTest(TestCase): td.refresh_from_db() self.assertEqual(td.drawn_card_ids, []) + def test_remaining_count_subtracts_drawn_from_total(self): + from apps.epic.models import TarotDeck + deck_variant = DeckVariant.objects.first() + td = TarotDeck.objects.create( + room=self.room, + deck_variant=deck_variant, + drawn_card_ids=[], + ) + self.assertEqual(td.remaining_count, deck_variant.card_count) + td.drawn_card_ids = list( + TarotCard.objects.filter(deck_variant=deck_variant).values_list('id', flat=True)[:5] + ) + td.save() + self.assertEqual(td.remaining_count, deck_variant.card_count - 5) + + def test_remaining_count_zero_when_no_deck_variant(self): + from apps.epic.models import TarotDeck + td = TarotDeck.objects.create(room=self.room, deck_variant=None) + self.assertEqual(td.remaining_count, 0) + + def test_draw_returns_n_tuples_of_card_and_bool(self): + from apps.epic.models import TarotDeck + deck_variant = DeckVariant.objects.first() + td = TarotDeck.objects.create(room=self.room, deck_variant=deck_variant) + drawn = td.draw(3) + self.assertEqual(len(drawn), 3) + for card, is_reversed in drawn: + self.assertIsInstance(card, TarotCard) + self.assertIsInstance(is_reversed, bool) + + def test_draw_appends_card_ids_to_drawn_card_ids(self): + from apps.epic.models import TarotDeck + deck_variant = DeckVariant.objects.first() + td = TarotDeck.objects.create(room=self.room, deck_variant=deck_variant) + drawn = td.draw(4) + td.refresh_from_db() + self.assertEqual(len(td.drawn_card_ids), 4) + for card, _ in drawn: + self.assertIn(card.id, td.drawn_card_ids) + + def test_draw_excludes_already_drawn_cards(self): + """Subsequent draws never repeat cards from the existing drawn_card_ids.""" + from apps.epic.models import TarotDeck + deck_variant = DeckVariant.objects.first() + td = TarotDeck.objects.create(room=self.room, deck_variant=deck_variant) + first = td.draw(5) + first_ids = {card.id for card, _ in first} + second = td.draw(5) + second_ids = {card.id for card, _ in second} + self.assertFalse(first_ids & second_ids) + # ── sig_deck_cards with no equipped deck ───────────────────────────────────── diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index d41a46e..ee99d58 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -2259,3 +2259,222 @@ class SkySaveViewTest(TestCase): char = Character.objects.get(seat=pc_seat) self.assertEqual(char.significator, sig_card) + + +# ── SIG event-retraction branches ──────────────────────────────────────────── +# The provenance scrolls use a `data["retracted"] = True` flag to soft-cancel +# prior events when a gamer reverses themselves (WAIT NVM after SAVE SIG, etc). +# These three branches in sig_reserve / sig_ready are the load-bearing ones — +# without them a recanted action stays visible in the billboard scrollback. + +class SigEventRetractionTest(TestCase): + """`data["retracted"] = True` writes on the three reverse-direction paths.""" + + def setUp(self): + self.room, self.gamers, self.earthman, self.card = _full_sig_setUp(self) + # PC (founder) already logged in; reserve + go ready so subsequent + # actions have prior SIG_READY events to retract. + self.reserve_url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id}) + self.ready_url = reverse("epic:sig_ready", kwargs={"room_id": self.room.id}) + + def _reserve(self): + return self.client.post(self.reserve_url, data={ + "card_id": self.card.id, "action": "reserve", + }) + + def _ready(self, action="ready"): + return self.client.post(self.ready_url, data={"action": action}) + + def test_sig_unready_retracts_prior_sig_ready_event(self): + """sig_ready action=unready flips `data["retracted"]=True` on the most + recent un-retracted SIG_READY event for this actor (views.py L937).""" + self._reserve() + self._ready(action="ready") + prior = self.room.events.filter( + actor=self.gamers[0], verb=GameEvent.SIG_READY + ).last() + self.assertFalse(prior.data.get("retracted"), "precondition: not yet retracted") + + self._ready(action="unready") + + prior.refresh_from_db() + self.assertTrue(prior.data.get("retracted")) + + def test_sig_ready_retracts_prior_sig_unready_event(self): + """sig_ready action=ready retracts the most recent un-retracted + SIG_UNREADY event (views.py L907) — the cancellation is now moot.""" + self._reserve() + self._ready(action="ready") + self._ready(action="unready") + prior_unready = self.room.events.filter( + actor=self.gamers[0], verb=GameEvent.SIG_UNREADY + ).last() + self.assertFalse(prior_unready.data.get("retracted")) + + self._ready(action="ready") + + prior_unready.refresh_from_db() + self.assertTrue(prior_unready.data.get("retracted")) + + def test_sig_release_while_ready_retracts_prior_sig_ready_event(self): + """sig_reserve action=release on a ready reservation acts as implicit + WAIT NVM — retracts the most recent SIG_READY (views.py L823).""" + self._reserve() + self._ready(action="ready") + prior = self.room.events.filter( + actor=self.gamers[0], verb=GameEvent.SIG_READY + ).last() + self.assertFalse(prior.data.get("retracted")) + + self.client.post(self.reserve_url, data={ + "card_id": self.card.id, "action": "release", + }) + + prior.refresh_from_db() + self.assertTrue(prior.data.get("retracted")) + + def test_sig_release_while_ready_records_sig_unready_event(self): + """Same release-while-ready path also records a fresh SIG_UNREADY + (the implicit cancellation event).""" + self._reserve() + self._ready(action="ready") + unready_count_before = self.room.events.filter( + actor=self.gamers[0], verb=GameEvent.SIG_UNREADY + ).count() + self.client.post(self.reserve_url, data={ + "card_id": self.card.id, "action": "release", + }) + self.assertEqual( + self.room.events.filter( + actor=self.gamers[0], verb=GameEvent.SIG_UNREADY + ).count(), + unready_count_before + 1, + ) + + +# ── SIG_RESERVE invalid card-id branch ─────────────────────────────────────── + +class SigReserveInvalidCardIdTest(TestCase): + """sig_reserve POSTed with a card_id that doesn't exist returns 400.""" + + def setUp(self): + self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self) + self.url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id}) + + def test_unknown_card_id_returns_400(self): + """TarotCard.DoesNotExist branch (views.py L840-841).""" + response = self.client.post(self.url, data={ + "card_id": 999999, "action": "reserve", + }) + self.assertEqual(response.status_code, 400) + + +# ── SIG_SELECT gravity-polarity rendering ──────────────────────────────────── + +class SigSelectGravityContextTest(TestCase): + """SIG_SELECT room context for a gravity-polarity gamer. + + Covers the `user_polarity = 'gravity'` branch (views.py L322) and the + gravity_sig_cards lookup (L357) — both fall through the cracks of the + default founder-as-PC-levity tests.""" + + def setUp(self): + self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self) + # gamers[5] is BC → gravity polarity + self.bc = self.gamers[5] + self.client.force_login(self.bc) + self.url = reverse("epic:room", kwargs={"room_id": self.room.id}) + + def test_gravity_gamer_room_context_has_gravity_polarity(self): + response = self.client.get(self.url) + self.assertEqual(response.context["user_polarity"], "gravity") + + def test_gravity_gamer_sees_gravity_sig_cards(self): + """Levity + gravity get the same 16 court cards (filtered by major-arcana + Note unlocks); this test just asserts the gravity branch was taken.""" + from apps.epic.models import gravity_sig_cards + response = self.client.get(self.url) + # Same underlying card set; assertion is that the context was populated + # (the gravity branch returned, vs falling into the empty `else`). + self.assertEqual( + list(response.context["sig_cards"]), + list(gravity_sig_cards(self.room, self.bc)), + ) + + def test_gravity_gamer_sig_card_set_non_empty(self): + response = self.client.get(self.url) + self.assertGreater(len(response.context["sig_cards"]), 0) + + +# ── SEA_DECK draw view ─────────────────────────────────────────────────────── + +class SeaDeckViewTest(TestCase): + """sea_deck — JSON view returning shuffled levity + gravity halves. + + Mirrors the FT in test_game_room_select_sea.py:DRAW SEA — that test walks + the full UI; this one isolates the JSON contract + filter semantics.""" + + def setUp(self): + self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self) + self.room.table_status = Room.SKY_SELECT + self.room.save() + # Use PC seat (founder) — already logged in by _full_sig_setUp + self.url = reverse("epic:sea_deck", kwargs={"room_id": self.room.id}) + + def test_returns_403_when_not_seated(self): + outsider = User.objects.create(email="outsider@test.io") + self.client.force_login(outsider) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + + def test_returns_empty_halves_when_seat_has_no_deck_variant(self): + """sea_deck early-outs to {levity:[],gravity:[]} if the seat hasn't + committed a deck — guards against null deck_variant FK access.""" + TableSeat.objects.filter(room=self.room).update(deck_variant=None) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data, {"levity": [], "gravity": []}) + + def test_returns_two_halves(self): + response = self.client.get(self.url) + data = response.json() + self.assertIn("levity", data) + self.assertIn("gravity", data) + + def test_card_count_roughly_split_between_halves(self): + """Total card pool is split in half — within 1 of perfectly even.""" + response = self.client.get(self.url) + data = response.json() + self.assertAlmostEqual(len(data["levity"]), len(data["gravity"]), delta=1) + + def test_card_dict_contains_expected_keys(self): + response = self.client.get(self.url) + data = response.json() + sample = data["levity"][0] + for key in ( + "id", "name", "arcana", "corner_rank", "suit_icon", + "name_group", "name_title", "reversed", + "levity_qualifier", "gravity_qualifier", + ): + self.assertIn(key, sample, f"missing key {key!r} in card dict") + + def test_reversed_field_is_boolean(self): + response = self.client.get(self.url) + data = response.json() + for card in data["levity"] + data["gravity"]: + self.assertIsInstance(card["reversed"], bool) + + def test_excludes_claimed_significators(self): + """A card already set as a seat.significator must not appear in either + half — it's been claimed for the game and is out of the sea-draw pool.""" + sig_card = TarotCard.objects.get( + deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11 + ) + pc_seat = TableSeat.objects.get(room=self.room, role="PC") + pc_seat.significator = sig_card + pc_seat.save() + response = self.client.get(self.url) + data = response.json() + all_ids = {c["id"] for c in data["levity"]} | {c["id"] for c in data["gravity"]} + self.assertNotIn(sig_card.id, all_ids) diff --git a/src/apps/epic/tests/unit/test_models.py b/src/apps/epic/tests/unit/test_models.py index 796c57f..85331d7 100644 --- a/src/apps/epic/tests/unit/test_models.py +++ b/src/apps/epic/tests/unit/test_models.py @@ -1,3 +1,5 @@ +import json + from django.test import SimpleTestCase from apps.epic.models import TarotCard @@ -70,3 +72,102 @@ class TarotCardSuitIconTest(SimpleTestCase): def test_icon_override_takes_priority_over_suit(self): self.assertEqual(_card('MIDDLE', 11, 'CROWNS', icon='fa-star').suit_icon, 'fa-star') + + +class TarotCardEmanationForTest(SimpleTestCase): + """TarotCard.emanation_for — polarity-split upright title (cards 48-49).""" + + def test_returns_levity_emanation_when_polarity_levity(self): + c = TarotCard() + c.name = 'Group 11: The Awakened' + c.levity_emanation = 'The Effulgent Mould of Man' + c.gravity_emanation = 'The Tellurian Mould of Man' + self.assertEqual(c.emanation_for('levity'), 'The Effulgent Mould of Man') + + def test_returns_gravity_emanation_when_polarity_gravity(self): + c = TarotCard() + c.name = 'Group 11: The Awakened' + c.levity_emanation = 'The Effulgent Mould of Man' + c.gravity_emanation = 'The Tellurian Mould of Man' + self.assertEqual(c.emanation_for('gravity'), 'The Tellurian Mould of Man') + + def test_falls_back_to_name_title_when_no_levity_split(self): + c = TarotCard() + c.name = 'Group 3: The Nomad' + self.assertEqual(c.emanation_for('levity'), 'The Nomad') + + def test_falls_back_to_name_title_when_no_gravity_split(self): + c = TarotCard() + c.name = 'Group 3: The Nomad' + self.assertEqual(c.emanation_for('gravity'), 'The Nomad') + + +class TarotCardReversalForTest(SimpleTestCase): + """TarotCard.reversal_for — polarity-split reversal title (card 48).""" + + def test_returns_levity_reversal_when_polarity_levity(self): + c = TarotCard() + c.name = 'Group 11: The Awakened' + c.levity_reversal = 'The Reflected Mould of Man' + c.gravity_reversal = 'The Obscured Mould of Man' + self.assertEqual(c.reversal_for('levity'), 'The Reflected Mould of Man') + + def test_returns_gravity_reversal_when_polarity_gravity(self): + c = TarotCard() + c.name = 'Group 11: The Awakened' + c.levity_reversal = 'The Reflected Mould of Man' + c.gravity_reversal = 'The Obscured Mould of Man' + self.assertEqual(c.reversal_for('gravity'), 'The Obscured Mould of Man') + + def test_falls_back_to_reversal_qualifier_when_no_polarity_split(self): + c = TarotCard() + c.name = 'Jack of Brands' + c.reversal_qualifier = 'Fickle' + # No polarity-split override → reversal_qualifier wins + self.assertEqual(c.reversal_for('levity'), 'Fickle') + self.assertEqual(c.reversal_for('gravity'), 'Fickle') + + def test_falls_back_to_emanation_when_no_reversal_qualifier(self): + """Blank reversal_qualifier → reversal_for falls through emanation_for.""" + c = TarotCard() + c.name = 'Group 3: The Nomad' + # reversal_qualifier blank, no polarity-split reversal → emanation fallback + self.assertEqual(c.reversal_for('levity'), 'The Nomad') + + +class TarotCardNameSplitTest(SimpleTestCase): + """TarotCard.name_group / name_title — colon-split parsing.""" + + def test_name_group_returns_prefix_with_colon(self): + c = TarotCard() + c.name = 'Group 3: The Nomad' + self.assertEqual(c.name_group, 'Group 3:') + + def test_name_group_empty_when_no_colon(self): + c = TarotCard() + c.name = 'Jack of Brands' + self.assertEqual(c.name_group, '') + + def test_name_title_returns_suffix_after_colon(self): + c = TarotCard() + c.name = 'Group 3: The Nomad' + self.assertEqual(c.name_title, 'The Nomad') + + def test_name_title_returns_full_name_when_no_colon(self): + c = TarotCard() + c.name = 'Jack of Brands' + self.assertEqual(c.name_title, 'Jack of Brands') + + +class TarotCardCautionsJsonTest(SimpleTestCase): + """TarotCard.cautions_json — JSON-serialised cautions list.""" + + def test_empty_cautions_serialises_to_empty_list(self): + c = TarotCard() + c.cautions = [] + self.assertEqual(c.cautions_json, '[]') + + def test_cautions_serialise_to_json_array(self): + c = TarotCard() + c.cautions = [{'type': 'Vanity', 'effect': 'Costs an Ardor token.'}] + self.assertEqual(json.loads(c.cautions_json), c.cautions) diff --git a/src/apps/epic/tests/unit/test_utils.py b/src/apps/epic/tests/unit/test_utils.py index 03cf912..df8ec6a 100644 --- a/src/apps/epic/tests/unit/test_utils.py +++ b/src/apps/epic/tests/unit/test_utils.py @@ -1,6 +1,6 @@ from django.test import SimpleTestCase -from apps.epic.utils import _planet_house +from apps.epic.utils import _planet_house, top_capacitors class PlanetHouseFallbackTest(SimpleTestCase): @@ -11,3 +11,44 @@ class PlanetHouseFallbackTest(SimpleTestCase): # the fallback `return 1`. cusps = [0.0] * 12 self.assertEqual(_planet_house(180.0, cusps), 1) + + def test_returns_1_when_degree_in_first_house_normal(self): + # Standard, sequential cusps: degree=15 should land in house 1 (0–30). + cusps = [i * 30.0 for i in range(12)] + self.assertEqual(_planet_house(15.0, cusps), 1) + + +class TopCapacitorsTest(SimpleTestCase): + """top_capacitors — capacitor names tied for the highest element count.""" + + def test_returns_empty_when_elements_is_empty(self): + self.assertEqual(top_capacitors({}), []) + + def test_returns_empty_when_elements_is_none(self): + self.assertEqual(top_capacitors(None), []) + + def test_returns_empty_when_all_counts_are_zero(self): + """All-zero counts (e.g. a brand-new chart with no planets) → empty list, + not an Ardor-by-default. Exercises the `max(counts.values()) <= 0` branch.""" + self.assertEqual( + top_capacitors({"Fire": 0, "Stone": 0, "Time": 0, "Space": 0, "Air": 0, "Water": 0}), + [], + ) + + def test_returns_single_top_capacitor_when_one_element_wins(self): + # Stone has highest count → Ossum + result = top_capacitors({"Fire": 1, "Stone": 5, "Time": 2}) + self.assertEqual(result, ["Ossum"]) + + def test_returns_multiple_capacitors_on_tie_in_clockwise_order(self): + # Fire + Stone tied at 3 → order follows ELEMENT_ORDER (Fire first). + result = top_capacitors({"Fire": 3, "Stone": 3, "Time": 2}) + self.assertEqual(result, ["Ardor", "Ossum"]) + + def test_accepts_dict_values_with_count_key(self): + """`elements` may carry enriched dicts like {"count": N, ...}.""" + result = top_capacitors({ + "Fire": {"count": 1, "sign": "Aries"}, + "Stone": {"count": 4, "sign": "Taurus"}, + }) + self.assertEqual(result, ["Ossum"]) diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 253a096..3475454 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -235,6 +235,7 @@ class UnequipTrinketViewTest(TestCase): def setUp(self): self.user = User.objects.create(email="gamer@test.io") self.client.force_login(self.user) + self.token = Token.objects.filter(user=self.user, token_type=Token.COIN).first() def test_get_returns_405(self): from apps.lyric.models import Token @@ -244,6 +245,30 @@ class UnequipTrinketViewTest(TestCase): response = self.client.get(reverse("unequip_trinket", kwargs={"token_id": token.pk})) self.assertEqual(response.status_code, 405) + def test_post_clears_equipped_trinket_when_matching(self): + self.user.equipped_trinket = self.token + self.user.save(update_fields=["equipped_trinket"]) + response = self.client.post( + reverse("unequip_trinket", kwargs={"token_id": self.token.pk}) + ) + self.assertEqual(response.status_code, 204) + self.user.refresh_from_db() + self.assertIsNone(self.user.equipped_trinket) + + def test_post_ignores_non_matching_trinket(self): + """POSTing a token that's not the currently-equipped one is a 204 no-op + — equipped_trinket is unchanged. Covers the implicit `else` of the + `if request.user.equipped_trinket_id == token.pk` branch.""" + other_token = Token.objects.create(user=self.user, token_type=Token.TITHE) + self.user.equipped_trinket = self.token # COIN is equipped + self.user.save(update_fields=["equipped_trinket"]) + response = self.client.post( + reverse("unequip_trinket", kwargs={"token_id": other_token.pk}) + ) + self.assertEqual(response.status_code, 204) + self.user.refresh_from_db() + self.assertEqual(self.user.equipped_trinket, self.token) + class GameKitViewTest(TestCase): def setUp(self): @@ -383,6 +408,23 @@ class EquipTrinketViewTest(TestCase): self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "apps/gameboard/_partials/_equip_trinket_btn.html") + def test_post_equips_trinket_and_returns_204(self): + response = self.client.post( + reverse("equip_trinket", kwargs={"token_id": self.token.pk}) + ) + self.assertEqual(response.status_code, 204) + self.user.refresh_from_db() + self.assertEqual(self.user.equipped_trinket, self.token) + + def test_post_requires_token_owner(self): + outsider = User.objects.create(email="outsider@test.io") + self.client.force_login(outsider) + response = self.client.post( + reverse("equip_trinket", kwargs={"token_id": self.token.pk}) + ) + # get_object_or_404 — the token belongs to self.user, not outsider + self.assertEqual(response.status_code, 404) + class TarotFanViewTest(TestCase): def setUp(self): diff --git a/src/static_src/scss/rootvars.scss b/src/static_src/scss/rootvars.scss index cbae84d..f6078ab 100644 --- a/src/static_src/scss/rootvars.scss +++ b/src/static_src/scss/rootvars.scss @@ -429,9 +429,9 @@ --secUser: var(--quiAg); /* 175,175,175 — light gray text/border */ --terUser: var(--sixAg); /* 240,240,240 — bright white accent */ --quaUser: var(--sixAg); /* 240,240,240 — active/interactive */ - --quiUser: var(--quaAg); /* 133,133,133 — secondary action */ - --sixUser: var(--terAg); /* 100,100,100 — subtle mid */ - --sepUser: var(--secAg); /* 60,60,60 — deep subtle */ + --quiUser: var(--secAg); /* 133,133,133 — secondary action */ + --sixUser: var(--quaAg); /* 100,100,100 — subtle mid */ + --sepUser: var(--priPt); /* 60,60,60 — deep subtle */ --octUser: var(--quiPt); /* 189,190,189 — links (cooler silver) */ --ninUser: var(--sixAg); /* 240,240,240 — glow highlight */ --decUser: var(--terAg); /* 100,100,100 — mid tone */