2026-03-09 01:07:16 -04:00
|
|
|
import lxml.html
|
|
|
|
|
|
|
|
|
|
from django.test import TestCase
|
2026-03-09 21:13:35 -04:00
|
|
|
from django.urls import reverse
|
2026-03-09 01:07:16 -04:00
|
|
|
|
2026-03-09 21:13:35 -04:00
|
|
|
from apps.applets.models import Applet, UserApplet
|
2026-04-27 23:24:43 -04:00
|
|
|
from apps.epic.models import DeckVariant, Room, TableSeat
|
2026-03-19 00:00:00 -04:00
|
|
|
from apps.lyric.models import Token, User
|
2026-03-09 01:07:16 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class GameboardViewTest(TestCase):
|
|
|
|
|
def setUp(self):
|
2026-03-09 21:13:35 -04:00
|
|
|
self.user = User.objects.create(email="gamer@test.io")
|
2026-03-09 01:07:16 -04:00
|
|
|
self.client.force_login(self.user)
|
2026-03-09 21:13:35 -04:00
|
|
|
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
|
|
|
|
|
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
|
|
|
|
|
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
|
2026-03-09 01:07:16 -04:00
|
|
|
response = self.client.get("/gameboard/")
|
|
|
|
|
self.parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
|
|
|
|
|
def test_gameboard_requires_login(self):
|
|
|
|
|
self.client.logout()
|
|
|
|
|
response = self.client.get("/gameboard/")
|
|
|
|
|
self.assertRedirects(
|
|
|
|
|
response, "/?next=/gameboard/", fetch_redirect_response=False
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_gameboard_renders(self):
|
|
|
|
|
response = self.client.get("/gameboard/")
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
|
|
|
|
def test_gameboard_shows_my_games_applet(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_applet_my_games")
|
|
|
|
|
|
|
|
|
|
def test_gameboard_shows_new_game_applet(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_applet_new_game")
|
|
|
|
|
|
2026-03-09 21:52:54 -04:00
|
|
|
def test_gameboard_shows_game_kit(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_game_kit")
|
2026-03-09 01:07:16 -04:00
|
|
|
|
|
|
|
|
def test_gameboard_shows_game_gear(self):
|
2026-03-09 21:13:35 -04:00
|
|
|
[_] = self.parsed.cssselect(".gear-btn")
|
2026-03-09 01:07:16 -04:00
|
|
|
|
|
|
|
|
def test_my_games_has_no_game_items_for_new_user(self):
|
|
|
|
|
game_items = self.parsed.cssselect("#id_applet_my_games .game-item")
|
|
|
|
|
self.assertEqual(len(game_items), 0)
|
|
|
|
|
|
applet rows: 3-col grid `<title> | <body> | <ts>` mirroring post.html's `.post-line` shape — `_my_posts_applet_item / _my_buds_applet_item / _my_notes_item / _my_scrolls_item / _my_games_item` all gain a `.applet-list-entry.row-3col` w. `<a class="row-title">` (clickable, 35c/32+... server-side truncated via new `lyric_extras.truncate_title` filter) + `<span class="row-body">` (most-recent activity excerpt, dimmed 0.6 opacity, CSS-`text-overflow: ellipsis` clipped to whatever space remains — no server-side trunc here so the full line lives in the DOM for inspectors) + `<time class="row-ts">` (`relative_ts` formatted, same `minmax(3rem,auto)` rightward column allocation post.html's `.post-line-time` uses, font-size 0.75rem + opacity 0.5 + right-aligned + nowrap); SCSS grid `minmax(4rem,auto) 1fr minmax(3rem,auto)` lifted from `.post-line`'s template so the timestamp column lines up across post.html / scroll.html / every applet list; per-applet data shapes — `_recent_posts` annotates each Post w. `latest_line` (Line FK ordered by -id, None for empty Note-unlock posts); `_recent_buds` `select_related('to_user__active_title')` warms the bud's donned-Note FK in one query for the buds row body ("the {{ bud.active_title_display }}" + "since {{ bud.active_title.earned_at|relative_ts }}" — the "since " prefix is unique to this row since the ts is "when they donned it", not the row's own creation); `_recent_notes` attaches `description` from `_NOTE_META` per slug; `annotate_latest_event(rooms)` helper added to `apps.epic.utils` (next to `rooms_for_user`) — attaches `room.latest_event` per Room w. one `.events.order_by('-timestamp').first()` per item, used by `_billboard_context` for `my_rooms` (My Scrolls applet) AND by `apps.gameboard.views.gameboard` + `toggle_game_applets` for `my_games` (My Games applet), keeping the My Scrolls + My Games shapes symmetric; `_billboard_context.my_rooms = annotate_latest_event(...)` swaps `rooms_for_user(...).order_by("-created_at")` materialisation point — bud row's "no active title" branch silently drops body + ts cells so unrecognised buds still surface but don't fabricate a "since None" line; new `truncate_title` filter is the existing `_truncate_post_title` view helper hoisted into the template namespace (literal `...` past 35 chars, None-safe); 5 ITs in BillboardViewTest cover row content / row absence on missing activity / "since" prefix uniquely on the buds row + 1 in GameboardViewTest for My Games row event prose; deferred row-prose body content cap on `<span class="row-body">` purely to CSS `text-overflow: ellipsis` per user's "middle col should take up the remaining space" steer (initial pass also server-side trunc'd the body to 35c; removed) — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 23:06:55 -04:00
|
|
|
def test_my_games_row_shows_latest_event_prose_and_ts(self):
|
|
|
|
|
from apps.drama.models import GameEvent, record
|
|
|
|
|
room = Room.objects.create(name="StampedRoom", owner=self.user)
|
|
|
|
|
record(
|
|
|
|
|
room, GameEvent.SLOT_FILLED, actor=self.user,
|
|
|
|
|
slot_number=1, token_type="coin",
|
|
|
|
|
token_display="Coin-on-a-String", renewal_days=7,
|
|
|
|
|
)
|
|
|
|
|
response = self.client.get("/gameboard/")
|
|
|
|
|
body = response.content.decode()
|
|
|
|
|
self.assertIn("StampedRoom", body)
|
|
|
|
|
# Row carries a ts cell from the recorded event
|
|
|
|
|
self.assertRegex(
|
|
|
|
|
body,
|
|
|
|
|
r'#id_applet_my_games|class="[^"]*row-ts'.replace("#", ""),
|
|
|
|
|
)
|
|
|
|
|
# A .row-body cell carries some event prose
|
|
|
|
|
self.assertRegex(body, r'<time[^>]+class="[^"]*row-ts')
|
|
|
|
|
|
2026-03-09 01:07:16 -04:00
|
|
|
def test_game_kit_has_coin_on_a_string(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_game_kit #id_kit_coin_on_a_string")
|
|
|
|
|
|
|
|
|
|
def test_game_kit_has_free_token(self):
|
2026-03-15 16:57:24 -04:00
|
|
|
[_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token")
|
2026-03-09 01:07:16 -04:00
|
|
|
|
2026-03-24 21:52:57 -04:00
|
|
|
def test_game_kit_shows_deck_variant_cards(self):
|
|
|
|
|
decks = self.parsed.cssselect("#id_game_kit .deck-variant")
|
|
|
|
|
self.assertGreater(len(decks), 0)
|
|
|
|
|
# Earthman deck (seeded by migration) should have its own card
|
|
|
|
|
[_] = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")
|
2026-03-09 01:07:16 -04:00
|
|
|
|
|
|
|
|
def test_game_kit_has_dice_set_placeholder(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")
|
2026-03-09 21:13:35 -04:00
|
|
|
|
|
|
|
|
|
2026-04-27 23:24:43 -04:00
|
|
|
class GameboardDeckInUseTest(TestCase):
|
|
|
|
|
"""Sprint 2: game kit applet renders in-use state for a deck assigned to an active seat."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="gamer@test.io")
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
|
|
|
|
|
self.earthman = DeckVariant.objects.get(slug="earthman")
|
|
|
|
|
self.room = Room.objects.create(name="Wildfire", owner=self.user)
|
|
|
|
|
self.seat = TableSeat.objects.create(
|
|
|
|
|
room=self.room, gamer=self.user, slot_number=1,
|
|
|
|
|
deck_variant=self.earthman,
|
|
|
|
|
)
|
|
|
|
|
response = self.client.get("/gameboard/")
|
|
|
|
|
self.parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
|
|
|
|
|
def test_in_use_deck_don_is_disabled(self):
|
|
|
|
|
[don] = self.parsed.cssselect("#id_kit_earthman_deck .btn-equip")
|
|
|
|
|
self.assertIn("btn-disabled", don.get("class", ""))
|
|
|
|
|
|
|
|
|
|
def test_in_use_deck_doff_is_absent(self):
|
|
|
|
|
active_doff = self.parsed.cssselect(
|
|
|
|
|
"#id_kit_earthman_deck .btn-unequip:not(.btn-disabled)"
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(len(active_doff), 0)
|
|
|
|
|
|
game kit + role icons + tarot fan: in-use mini-portal label, FLIP cue polarity reset, role icon redraws
Heterogeneous pre-existing changes (carried across multiple sessions, finally committed alongside the SIG SELECT exit sprint). Grouped:
- gameboard.js: _inUseLabel(roomName) — buildMiniContent renders "In-Use: <name>" on hover (cap 24 chars; overflow → 21 + "…"). Reads token.dataset.inUseRoomName for decks & token.dataset.currentRoomName for trinkets.
- _applet-game-kit.html: removes the inline <p class="tt-token-room-name"> + <p class="tt-deck-game-name"> paragraphs (now redundant — mini-portal carries the name); deck token gains data-in-use-room-name attr.
- gameboard tests: assertions retargeted at data-in-use-room-name + the mini-portal flow rather than the deleted inline paragraphs (test_views, test_deck_contribution, test_trinket_carte_blanche).
- game-kit.js: openFan + _testOpen reset _polarity = 'levity' so reopening the fan after FLIP-to-gravity always lands on the levity-painted face (the FLIP cue). The sessionStorage bookmark intentionally tracks card index only; polarity does NOT persist across reopen.
- _tarot_fan.html: SSR-default polarity flipped from levity to gravity (levity_emanation → gravity_emanation, levity_qualifier → gravity_qualifier, levity_reversal → gravity_reversal across upright + reversal faces). Pairs w. the JS polarity reset above so JS repaints to levity on open.
- FanStageSpec: 2 new specs — openFan polarity reset on reopen even after FLIP-to-gravity; sessionStorage stores no levity/gravity string.
- starter-role-*.svg (Alchemist, Builder, Economist, Narrator, Player, Shepherd): redrawn / re-cropped art — viewBox tightened from 288×560 to ~154×156, paths re-traced. No new role added; existing 6 swapped in place. New starter-role-blank.svg added as fallback for unmapped role codes (referenced by tray.js _ROLE_SCRAWL default → 'Blank').
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:28:32 -04:00
|
|
|
def test_in_use_deck_carries_room_name_for_mini_portal(self):
|
|
|
|
|
[el] = self.parsed.cssselect("#id_kit_earthman_deck")
|
|
|
|
|
self.assertEqual("Wildfire", el.get("data-in-use-room-name"))
|
2026-04-27 23:24:43 -04:00
|
|
|
|
|
|
|
|
def test_non_in_use_deck_has_normal_don(self):
|
|
|
|
|
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
|
|
|
|
|
self.user.unlocked_decks.add(fiorentine)
|
|
|
|
|
response = self.client.get("/gameboard/")
|
|
|
|
|
parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
[don] = parsed.cssselect("#id_kit_fiorentine_deck .btn-equip")
|
|
|
|
|
self.assertNotIn("btn-disabled", don.get("class", ""))
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 21:13:35 -04:00
|
|
|
class ToggleGameAppletsViewTest(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="gamer@test.io")
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
self.new_game, _ = Applet.objects.get_or_create(
|
|
|
|
|
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
|
|
|
|
)
|
|
|
|
|
self.my_games, _ = Applet.objects.get_or_create(
|
|
|
|
|
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
|
|
|
|
)
|
|
|
|
|
self.url = reverse("toggle_game_applets")
|
|
|
|
|
|
|
|
|
|
def test_unauthenticated_user_is_redirected(self):
|
|
|
|
|
self.client.logout()
|
|
|
|
|
response = self.client.post(self.url)
|
|
|
|
|
self.assertRedirects(
|
|
|
|
|
response, f"/?next={self.url}", fetch_redirect_response=False
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_unchecked_applet_gets_user_applet_with_visible_false(self):
|
|
|
|
|
self.client.post(self.url, {"applets": ["new-game"]})
|
|
|
|
|
ua = UserApplet.objects.get(user=self.user, applet=self.my_games)
|
|
|
|
|
self.assertFalse(ua.visible)
|
|
|
|
|
|
|
|
|
|
def test_redirects_on_normal_post(self):
|
|
|
|
|
response = self.client.post(self.url, {"applets": ["new-game", "my-games"]})
|
|
|
|
|
self.assertRedirects(
|
|
|
|
|
response, reverse("gameboard"), fetch_redirect_response=False
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_returns_200_on_htmx_post(self):
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
self.url,
|
|
|
|
|
{"applets": ["new-game", "my-games"]},
|
|
|
|
|
HTTP_HX_REQUEST="true",
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
|
|
|
|
def test_does_not_affect_dash_applets(self):
|
|
|
|
|
dash_applet, _ = Applet.objects.get_or_create(
|
|
|
|
|
slug="username", defaults={"name": "Username", "context": "dashboard"}
|
|
|
|
|
)
|
|
|
|
|
self.client.post(self.url, {"applets": ["new-game", "my-games"]})
|
|
|
|
|
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=dash_applet).exists())
|
2026-03-19 00:00:00 -04:00
|
|
|
|
|
|
|
|
|
COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%
New/extended tests across billboard, dashboard, drama, epic, gameboard,
and lyric to cover previously untested branches: dev_login view, scroll
position endpoints, sky preview error paths, drama to_prose/to_activity
branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology
model __str__, character model, sig reserve/ready/confirm views, natus
preview/save views, and the full tasks.py countdown scheduler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:23:28 -04:00
|
|
|
class EquipDeckViewTest(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="gamer@test.io")
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
self.deck = DeckVariant.objects.first()
|
|
|
|
|
|
|
|
|
|
def test_get_returns_405(self):
|
|
|
|
|
response = self.client.get(reverse("equip_deck", kwargs={"deck_id": self.deck.pk}))
|
|
|
|
|
self.assertEqual(response.status_code, 405)
|
|
|
|
|
|
|
|
|
|
def test_post_equips_deck(self):
|
|
|
|
|
response = self.client.post(reverse("equip_deck", kwargs={"deck_id": self.deck.pk}))
|
|
|
|
|
self.assertEqual(response.status_code, 204)
|
|
|
|
|
self.user.refresh_from_db()
|
|
|
|
|
self.assertEqual(self.user.equipped_deck, self.deck)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UnequipDeckViewTest(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="gamer@test.io")
|
|
|
|
|
self.deck = DeckVariant.objects.first()
|
|
|
|
|
self.user.equipped_deck = self.deck
|
|
|
|
|
self.user.save(update_fields=["equipped_deck"])
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
|
|
|
|
def test_get_returns_405(self):
|
|
|
|
|
response = self.client.get(reverse("unequip_deck", kwargs={"deck_id": self.deck.pk}))
|
|
|
|
|
self.assertEqual(response.status_code, 405)
|
|
|
|
|
|
|
|
|
|
def test_post_clears_equipped_deck_when_matches(self):
|
|
|
|
|
response = self.client.post(reverse("unequip_deck", kwargs={"deck_id": self.deck.pk}))
|
|
|
|
|
self.assertEqual(response.status_code, 204)
|
|
|
|
|
self.user.refresh_from_db()
|
|
|
|
|
self.assertIsNone(self.user.equipped_deck)
|
|
|
|
|
|
|
|
|
|
def test_post_ignores_non_matching_deck(self):
|
|
|
|
|
other_deck = DeckVariant.objects.exclude(pk=self.deck.pk).first()
|
|
|
|
|
if other_deck is None:
|
|
|
|
|
self.skipTest("Only one deck variant in DB")
|
|
|
|
|
self.client.post(reverse("unequip_deck", kwargs={"deck_id": other_deck.pk}))
|
|
|
|
|
self.user.refresh_from_db()
|
|
|
|
|
self.assertEqual(self.user.equipped_deck, self.deck)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UnequipTrinketViewTest(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="gamer@test.io")
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
|
|
|
|
def test_get_returns_405(self):
|
|
|
|
|
from apps.lyric.models import Token
|
|
|
|
|
token = Token.objects.filter(user=self.user).first()
|
|
|
|
|
if token is None:
|
|
|
|
|
self.skipTest("No token for user")
|
|
|
|
|
response = self.client.get(reverse("unequip_trinket", kwargs={"token_id": token.pk}))
|
|
|
|
|
self.assertEqual(response.status_code, 405)
|
|
|
|
|
|
|
|
|
|
|
2026-04-04 13:49:48 -04:00
|
|
|
class GameKitViewTest(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="gamer@test.io")
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
Applet.objects.get_or_create(slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"})
|
|
|
|
|
Applet.objects.get_or_create(slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"})
|
|
|
|
|
Applet.objects.get_or_create(slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"})
|
|
|
|
|
Applet.objects.get_or_create(slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"})
|
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD
- User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns
- drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their"
- SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched
- new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio
- card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses
- dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE
- _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question
- billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
|
|
|
Applet.objects.get_or_create(slug="pronouns", defaults={"name": "Pronouns", "context": "game-kit"})
|
2026-04-04 13:49:48 -04:00
|
|
|
response = self.client.get("/gameboard/game-kit/")
|
|
|
|
|
self.parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
|
|
|
|
|
def test_game_kit_requires_login(self):
|
|
|
|
|
self.client.logout()
|
|
|
|
|
response = self.client.get("/gameboard/game-kit/")
|
|
|
|
|
self.assertRedirects(response, "/?next=/gameboard/game-kit/", fetch_redirect_response=False)
|
|
|
|
|
|
|
|
|
|
def test_game_kit_shows_gear_btn(self):
|
|
|
|
|
[_] = self.parsed.cssselect(".gear-btn")
|
|
|
|
|
|
|
|
|
|
def test_game_kit_shows_applet_menu(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_game_kit_menu")
|
|
|
|
|
|
|
|
|
|
def test_game_kit_applet_menu_has_trinkets_checkbox(self):
|
|
|
|
|
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-trinkets']")
|
|
|
|
|
self.assertEqual(inp.get("type"), "checkbox")
|
|
|
|
|
|
|
|
|
|
def test_game_kit_applet_menu_has_tokens_checkbox(self):
|
|
|
|
|
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-tokens']")
|
|
|
|
|
self.assertEqual(inp.get("type"), "checkbox")
|
|
|
|
|
|
|
|
|
|
def test_game_kit_applet_menu_has_decks_checkbox(self):
|
|
|
|
|
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-decks']")
|
|
|
|
|
self.assertEqual(inp.get("type"), "checkbox")
|
|
|
|
|
|
|
|
|
|
def test_game_kit_applet_menu_has_dice_checkbox(self):
|
|
|
|
|
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-dice']")
|
|
|
|
|
self.assertEqual(inp.get("type"), "checkbox")
|
|
|
|
|
|
|
|
|
|
def test_game_kit_sections_container_present(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_gk_sections_container")
|
|
|
|
|
|
|
|
|
|
def test_all_sections_visible_by_default(self):
|
|
|
|
|
sections = self.parsed.cssselect("#id_gk_sections_container section")
|
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD
- User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns
- drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their"
- SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched
- new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio
- card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses
- dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE
- _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question
- billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
|
|
|
# Trinkets, Tokens, Card Decks, Dice Sets, Pronouns
|
|
|
|
|
self.assertEqual(len(sections), 5)
|
|
|
|
|
|
|
|
|
|
def test_pronouns_section_renders_five_cards(self):
|
|
|
|
|
[section] = self.parsed.cssselect("#id_gk_pronouns")
|
|
|
|
|
cards = section.cssselect(".gk-pronoun-card")
|
|
|
|
|
self.assertEqual(len(cards), 5)
|
|
|
|
|
slugs = [c.get("data-pronoun") for c in cards]
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
slugs,
|
|
|
|
|
["pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_pronouns_section_marks_current_choice_active(self):
|
|
|
|
|
# Default user pronouns = "pluralism" — that card should carry .active.
|
|
|
|
|
[active] = self.parsed.cssselect("#id_gk_pronouns .gk-pronoun-card.active")
|
|
|
|
|
self.assertEqual(active.get("data-pronoun"), "pluralism")
|
|
|
|
|
|
|
|
|
|
def test_game_kit_applet_menu_has_pronouns_checkbox(self):
|
|
|
|
|
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='pronouns']")
|
|
|
|
|
self.assertEqual(inp.get("type"), "checkbox")
|
2026-04-04 13:49:48 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class ToggleGameKitSectionsViewTest(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="gamer@test.io")
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
self.trinkets, _ = Applet.objects.get_or_create(
|
|
|
|
|
slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"}
|
|
|
|
|
)
|
|
|
|
|
self.tokens, _ = Applet.objects.get_or_create(
|
|
|
|
|
slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"}
|
|
|
|
|
)
|
|
|
|
|
self.decks, _ = Applet.objects.get_or_create(
|
|
|
|
|
slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"}
|
|
|
|
|
)
|
|
|
|
|
self.dice, _ = Applet.objects.get_or_create(
|
|
|
|
|
slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"}
|
|
|
|
|
)
|
|
|
|
|
self.url = reverse("toggle_game_kit_sections")
|
|
|
|
|
|
|
|
|
|
def test_unauthenticated_user_is_redirected(self):
|
|
|
|
|
self.client.logout()
|
|
|
|
|
response = self.client.post(self.url)
|
|
|
|
|
self.assertRedirects(response, f"/?next={self.url}", fetch_redirect_response=False)
|
|
|
|
|
|
|
|
|
|
def test_unchecked_section_gets_user_applet_with_visible_false(self):
|
|
|
|
|
self.client.post(self.url, {"applets": ["gk-trinkets"]})
|
|
|
|
|
ua = UserApplet.objects.get(user=self.user, applet=self.tokens)
|
|
|
|
|
self.assertFalse(ua.visible)
|
|
|
|
|
|
|
|
|
|
def test_redirects_on_normal_post(self):
|
|
|
|
|
response = self.client.post(self.url, {"applets": ["gk-trinkets", "gk-tokens"]})
|
|
|
|
|
self.assertRedirects(response, reverse("game_kit"), fetch_redirect_response=False)
|
|
|
|
|
|
|
|
|
|
def test_returns_200_on_htmx_post(self):
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
self.url,
|
|
|
|
|
{"applets": ["gk-trinkets", "gk-tokens"]},
|
|
|
|
|
HTTP_HX_REQUEST="true",
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
|
|
|
|
def test_does_not_affect_gameboard_applets(self):
|
|
|
|
|
gb_applet, _ = Applet.objects.get_or_create(
|
|
|
|
|
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
|
|
|
|
)
|
|
|
|
|
self.client.post(self.url, {"applets": ["gk-trinkets"]})
|
|
|
|
|
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=gb_applet).exists())
|
|
|
|
|
|
|
|
|
|
def test_hidden_section_absent_from_htmx_response(self):
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
self.url,
|
|
|
|
|
{"applets": ["gk-trinkets"]},
|
|
|
|
|
HTTP_HX_REQUEST="true",
|
|
|
|
|
)
|
|
|
|
|
parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
sections = parsed.cssselect("section")
|
|
|
|
|
self.assertEqual(len(sections), 1)
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 00:00:00 -04:00
|
|
|
class EquipTrinketViewTest(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_trinket_button_partial(self):
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
reverse("equip_trinket", kwargs={"token_id": self.token.pk})
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
self.assertTemplateUsed(response, "apps/gameboard/_partials/_equip_trinket_btn.html")
|
2026-03-24 22:57:12 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TarotFanViewTest(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
from apps.epic.models import DeckVariant
|
|
|
|
|
self.earthman = DeckVariant.objects.get(slug="earthman")
|
|
|
|
|
self.fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
|
|
|
|
|
self.user = User.objects.create(email="fan@test.io")
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
|
|
|
|
def test_returns_fan_partial_for_unlocked_deck(self):
|
|
|
|
|
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
self.assertTemplateUsed(response, "apps/gameboard/_partials/_tarot_fan.html")
|
|
|
|
|
|
|
|
|
|
def test_returns_403_for_locked_deck(self):
|
|
|
|
|
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.fiorentine.pk}))
|
|
|
|
|
self.assertEqual(response.status_code, 403)
|