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 */