Files
python-tdd/src/apps/gameboard/tests/integrated/test_views.py

490 lines
21 KiB
Python
Raw Normal View History

import lxml.html
from django.test import TestCase
from django.urls import reverse
from apps.applets.models import Applet, UserApplet
from apps.epic.models import DeckVariant, Room, TableSeat
from apps.lyric.models import Token, User
class GameboardViewTest(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="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"})
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")
My Sea applet shell — Sprint 3 of the My Sea roadmap User roadmap step (Sprint 3 of cluster): scaffold the My Sea applet on the gameboard + the standalone /gameboard/my-sea/ page where later sprints will host the gatekeeper / sig-select / sea-select reskin for solo-user draws. Shell-only — no draw flow yet; latest-draw rendering, mid-progress save, daily quota land in Sprints 4-9 ; **migration**: `applets/migrations/0008_seed_my_sea_applet.py` — RunPython that `update_or_create`s Applet(`slug='my-sea'`, name='My Sea', context='gameboard', default_visible=True, grid_cols=12, grid_rows=4). 12×4 wide horizontal banner so the Celtic Cross spread's 10 cards can render left-to-right in the applet aperture, scrollable like My Palette (per user spec). Reverse migration (`unseed`) deletes the row so the migration is reversible for staging rollbacks ; **applet partial**: `templates/apps/gameboard/_partials/_applet-my-sea.html` — same `{% applet_context %}` auto-discovery shape every other applet uses (`<section id="id_applet_my_sea" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};">`). Header is a `<h2><a href="{% url 'my_sea' %}">My Sea</a></h2>` link (gold via global `body a` rule); body is a `.my-sea-scroll` container that either renders `.my-sea-card` cells from a `latest_draw_cards` context (TBD in Sprint 4-7) or a `.my-sea-empty` placeholder line "No draws yet." for fresh users ; **standalone page**: new `gameboard/views.py:my_sea` view + url at `/gameboard/my-sea/` (URL name `my_sea`) rendering `apps/gameboard/my_sea.html` — `{% extends "core/base.html" %}` shell w. letter-spread `<span>My</span><span>Sea</span>` h2 wordmark + `.my-sea-page__empty` placeholder paragraph "Your sea is calm. Draws will appear here." `page_class` doubled to `page-gameboard page-my-sea` so the body inherits the gameboard's landscape aperture treatment AND any future my-sea-specific styles can target a single class. Login-required like the rest of gameboard ; **tests (+6 ITs)**: GameboardViewTest gains 3 — `test_gameboard_shows_my_sea_applet` (cssselect pins #id_applet_my_sea), `test_my_sea_applet_renders_empty_state_for_new_user` (asserts ".my-sea-empty" text + no ".my-sea-card" rows), `test_my_sea_applet_header_links_to_my_sea_page` (h2 a href == reverse('my_sea')); new MySeaViewTest class — `test_my_sea_requires_login` (redirect to /?next=...), `test_my_sea_renders_200`, `test_my_sea_uses_gameboard_page_class` (page-gameboard + page-my-sea both in body class). Existing GameboardViewTest setUp already does `get_or_create` per-applet so no fixture change needed for the migration-driven my-sea row ; 1005 IT/UT green (+6 from 999) in 45s; visual verified in Claudezilla at iPhone-14 portrait — applet renders w. rotated "MY SEA" vertical label + "No draws yet." body; /gameboard/my-sea/ standalone page renders w. letter-spread wordmark + placeholder ; **next**: Sprint 4 — My Sea sig-select phase (single-significator pick for solo user, w. the parameterized hex CSS from Sprint 1 hosting the chair-less or single-chair variant) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:45:57 -04:00
def test_gameboard_shows_my_sea_applet(self):
# Sprint 3 of the My Sea roadmap — applet shell only; sigs/sea/draw
# flow lands in later sprints. Seeded via migration 0008.
[_] = self.parsed.cssselect("#id_applet_my_sea")
def test_my_sea_applet_renders_empty_state_for_new_user(self):
# A fresh user has no saved draws → the scroll container hosts a
# single placeholder line ("No draws yet."), no card cells.
[empty] = self.parsed.cssselect("#id_applet_my_sea .my-sea-empty")
self.assertIn("No draws yet", empty.text_content())
cards = self.parsed.cssselect("#id_applet_my_sea .my-sea-card")
self.assertEqual(len(cards), 0)
def test_my_sea_applet_header_links_to_my_sea_page(self):
[link] = self.parsed.cssselect("#id_applet_my_sea h2 a")
self.assertEqual(link.get("href"), reverse("my_sea"))
def test_gameboard_shows_game_kit(self):
[_] = self.parsed.cssselect("#id_game_kit")
def test_gameboard_shows_game_gear(self):
[_] = self.parsed.cssselect(".gear-btn")
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')
my-scrolls / my-games applet rows: prepend actor `display_name` to the body cell — the latest event's `to_prose` returns the action alone ("deposits a Carte Blanche…") because scroll.html splits the row across `<strong>{{ event.actor|display_name }}</strong>` + adjacent `{{ to_prose|safe }}`; the applet rows have a single middle column (`<title> | <body> | <ts>`) so they need both halves concatenated into `.row-body`; ROOM_CREATED welcome events (actor=None) keep rendering prose alone since `to_prose` already reads "Welcome to <name>!" — the `{% if item.latest_event.actor %}` guard skips the prefix, mirroring the same actor-guarded `<strong>` we added to `_partials/_scroll.html` + `_applet-most-recent-scroll.html` on c03fb2b so welcome lines don't carry a bogus empty actor; 2 ITs added — BillboardViewTest.test_my_scrolls_applet_row_body_includes_actor_display_name + GameboardViewTest.test_my_games_row_body_includes_actor_display_name — scoped to `<span class="row-body">...stuart...deposits...</span>` (regex match on the .row-body cell content) so the assertion can't pass on actor renders outside the row (the Most Recent Scroll applet on /billboard/ renders the same actor too, separately — initial pass missed this and `assertIn("acto", body)` matched there instead, hiding the bug); BillboardViewTest also gains test_my_scrolls_applet_row_body_no_actor_prefix_for_welcome to lock in the no-empty-prefix contract for ROOM_CREATED welcome events; 931 ITs green; settings.local.json fix-up — `Bash(git add *)` (literal `*` would only match the exact string "git add *", not `git add -u`) → `Bash(git add:*)` + companion read-only git patterns (status / diff / log / show) so the in-session commit flow stops prompting — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 00:13:33 -04:00
def test_my_games_row_body_includes_actor_display_name(self):
"""Mirror of My Scrolls — the My Games row body must include the
event actor's display_name so `<actor> deposits a Coin-on-a-String`
reads as a complete sentence. Scoped to the `.row-body` span (vs.
a loose substring) so the assertion can't pass on actor renders
outside the row (no Most Recent Scroll on gameboard, but the
navbar etc. could shadow a substring match)."""
from apps.drama.models import GameEvent, record
actor = User.objects.create(email="stuart@test.io", username="stuart")
room = Room.objects.create(name="GameRoom", owner=self.user)
record(
room, GameEvent.SLOT_FILLED, actor=actor,
slot_number=2, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/gameboard/")
body = response.content.decode()
self.assertRegex(
body,
r'<span class="row-body">[^<]*stuart[^<]*deposits a Coin-on-a-String',
)
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):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token")
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")
def test_game_kit_has_dice_set_placeholder(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")
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"))
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", ""))
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())
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)
+52 IT/UT to close IT/UT-only coverage gaps (93% → 96%) — full suite 983 tests in 47s ; UTs in epic/tests/unit/test_models.py — `TarotCardEmanationForTest` (4) covers `emanation_for(polarity)` w. levity/gravity overrides + fallback to name_title for cards w.o a polarity split (cards 48-49 are the only polarity-split cards in the deck so this method is sparsely exercised by ITs); `TarotCardReversalForTest` (4) covers `reversal_for(polarity)` w. polarity-split + reversal_qualifier fallback + further fallthrough to emanation_for; `TarotCardNameSplitTest` (4) covers `name_group`/`name_title` colon-split parsing (prefix-w-colon / suffix / no-colon edge); `TarotCardCautionsJsonTest` (2) covers the `cautions_json` JSON serialiser ; UTs in epic/tests/unit/test_utils.py — `PlanetHouseFallbackTest` +1 happy-path test (degree=15 lands in house 1 w. sequential cusps) for the normal cusp-match branch alongside the existing pathological fallback test; `TopCapacitorsTest` (6) covers all `top_capacitors()` branches — empty dict / None / all-zero counts (the L56 `max(counts.values()) <= 0` fallback that was uncovered) / single-winner / tie-clockwise-order / enriched dict {"count":N} input shape ; ITs in epic/tests/integrated/test_models.py — `TarotDeckDrawTest` extended w. 5 tests for `remaining_count` (happy + no-deck-variant fallback to 0) + `draw()` happy-path (returns n tuples of (TarotCard, bool) / appends to drawn_card_ids / never repeats cards across consecutive draws); existing ValueError + shuffle tests preserved ; ITs in epic/tests/integrated/test_views.py — `SigEventRetractionTest` (4 tests) covers the three `data["retracted"] = True` paths that the FT `test_game_room_select_sig.py` walks transitively but no IT pins directly: sig_unready retracts prior SIG_READY (L937), sig_ready retracts prior SIG_UNREADY (L907), sig_reserve action=release while ready retracts prior SIG_READY + records fresh SIG_UNREADY (L823); `SigReserveInvalidCardIdTest` (1) covers `TarotCard.DoesNotExist` → 400 (L840-841) ; `SigSelectGravityContextTest` (3) covers the `user_polarity = 'gravity'` branch (L322) + the `gravity_sig_cards` lookup (L357) — all existing SIG_SELECT context tests use the founder-as-PC-levity setup so these branches sat uncovered; logs in as gamers[5] (BC role) + asserts user_polarity + sig_cards match `gravity_sig_cards()` output ; `SeaDeckViewTest` (7) mirrors the `test_game_room_select_sea.py` FT but isolates the JSON contract — covers 403 when unseated, empty halves when seat has no deck_variant (L1255-1256 early-out), two-halves shape, ~even split, card_dict keys (`id`/`name`/`arcana`/`corner_rank`/`suit_icon`/`name_group`/`name_title`/`reversed`/qualifiers), `reversed` field is bool, claimed-significator exclusion via `room.table_seats.exclude(significator__isnull=True)` ; ITs in dashboard/tests/integrated/test_views.py — `ProfileViewTest` +2 (reserved-handle "adman" rejection — L116-117: username stays unchanged + redirect to /); `KitBagViewTest` (3) covers the `kit_bag` view's panel render w. TITHE-sort branch (L169-175) + login guard ; ITs in dashboard/tests/integrated/test_sky_views.py — `SkyViewTest` +2 (saved birth datetime renders in user's `sky_birth_tz` via astimezone L300-306 — 16:00 UTC → 12:00 EDT; invalid-tz string triggers `ZoneInfoNotFoundError` → swallowed `pass` → UTC fallback at 16:00) ; ITs in gameboard/tests/integrated/test_views.py — `EquipTrinketViewTest` +2 (POST equips trinket + returns 204 — L83-85; non-owner POST returns 404 via `get_object_or_404`); `UnequipTrinketViewTest` +2 (POST clears matching equipped_trinket — L107-110; POST of non-matching token is a 204 no-op, the implicit `else` branch) ; .coveragerc omit gains `*/reset_staging_db.py` per user — mgmt cmd was the only 0%-stmt module that wasn't exercised by tests at all + we agreed it's deliberately untested staging-side code ; palette-monochrome-dark rebalance in rootvars.scss — --quiUser/--sixUser/--sepUser remapped to (secAg / quaAg / priPt) instead of (quaAg / terAg / secAg), shifting the secondary/subtle/deep-subtle anchors up the silver gradient so the palette reads more cleanly under the new sig-stage card colours from 3242873 ; uncovered remnants from earlier analysis intentionally left in place — consumers.py at 68% (channels-tag tests excluded; would need --tag=channels run), Carte Blanche slot navigation + sky_dice + tarot_deck preview view paths (the "bigger investments" tier from session triage; FT-covered + the IT setup is heavier than the immediate value), defensive `except` fallbacks that need contrived inputs to fire, and a handful of __str__s/`pass` branches not worth a test apiece — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 01:07:13 -04:00
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
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)
+52 IT/UT to close IT/UT-only coverage gaps (93% → 96%) — full suite 983 tests in 47s ; UTs in epic/tests/unit/test_models.py — `TarotCardEmanationForTest` (4) covers `emanation_for(polarity)` w. levity/gravity overrides + fallback to name_title for cards w.o a polarity split (cards 48-49 are the only polarity-split cards in the deck so this method is sparsely exercised by ITs); `TarotCardReversalForTest` (4) covers `reversal_for(polarity)` w. polarity-split + reversal_qualifier fallback + further fallthrough to emanation_for; `TarotCardNameSplitTest` (4) covers `name_group`/`name_title` colon-split parsing (prefix-w-colon / suffix / no-colon edge); `TarotCardCautionsJsonTest` (2) covers the `cautions_json` JSON serialiser ; UTs in epic/tests/unit/test_utils.py — `PlanetHouseFallbackTest` +1 happy-path test (degree=15 lands in house 1 w. sequential cusps) for the normal cusp-match branch alongside the existing pathological fallback test; `TopCapacitorsTest` (6) covers all `top_capacitors()` branches — empty dict / None / all-zero counts (the L56 `max(counts.values()) <= 0` fallback that was uncovered) / single-winner / tie-clockwise-order / enriched dict {"count":N} input shape ; ITs in epic/tests/integrated/test_models.py — `TarotDeckDrawTest` extended w. 5 tests for `remaining_count` (happy + no-deck-variant fallback to 0) + `draw()` happy-path (returns n tuples of (TarotCard, bool) / appends to drawn_card_ids / never repeats cards across consecutive draws); existing ValueError + shuffle tests preserved ; ITs in epic/tests/integrated/test_views.py — `SigEventRetractionTest` (4 tests) covers the three `data["retracted"] = True` paths that the FT `test_game_room_select_sig.py` walks transitively but no IT pins directly: sig_unready retracts prior SIG_READY (L937), sig_ready retracts prior SIG_UNREADY (L907), sig_reserve action=release while ready retracts prior SIG_READY + records fresh SIG_UNREADY (L823); `SigReserveInvalidCardIdTest` (1) covers `TarotCard.DoesNotExist` → 400 (L840-841) ; `SigSelectGravityContextTest` (3) covers the `user_polarity = 'gravity'` branch (L322) + the `gravity_sig_cards` lookup (L357) — all existing SIG_SELECT context tests use the founder-as-PC-levity setup so these branches sat uncovered; logs in as gamers[5] (BC role) + asserts user_polarity + sig_cards match `gravity_sig_cards()` output ; `SeaDeckViewTest` (7) mirrors the `test_game_room_select_sea.py` FT but isolates the JSON contract — covers 403 when unseated, empty halves when seat has no deck_variant (L1255-1256 early-out), two-halves shape, ~even split, card_dict keys (`id`/`name`/`arcana`/`corner_rank`/`suit_icon`/`name_group`/`name_title`/`reversed`/qualifiers), `reversed` field is bool, claimed-significator exclusion via `room.table_seats.exclude(significator__isnull=True)` ; ITs in dashboard/tests/integrated/test_views.py — `ProfileViewTest` +2 (reserved-handle "adman" rejection — L116-117: username stays unchanged + redirect to /); `KitBagViewTest` (3) covers the `kit_bag` view's panel render w. TITHE-sort branch (L169-175) + login guard ; ITs in dashboard/tests/integrated/test_sky_views.py — `SkyViewTest` +2 (saved birth datetime renders in user's `sky_birth_tz` via astimezone L300-306 — 16:00 UTC → 12:00 EDT; invalid-tz string triggers `ZoneInfoNotFoundError` → swallowed `pass` → UTC fallback at 16:00) ; ITs in gameboard/tests/integrated/test_views.py — `EquipTrinketViewTest` +2 (POST equips trinket + returns 204 — L83-85; non-owner POST returns 404 via `get_object_or_404`); `UnequipTrinketViewTest` +2 (POST clears matching equipped_trinket — L107-110; POST of non-matching token is a 204 no-op, the implicit `else` branch) ; .coveragerc omit gains `*/reset_staging_db.py` per user — mgmt cmd was the only 0%-stmt module that wasn't exercised by tests at all + we agreed it's deliberately untested staging-side code ; palette-monochrome-dark rebalance in rootvars.scss — --quiUser/--sixUser/--sepUser remapped to (secAg / quaAg / priPt) instead of (quaAg / terAg / secAg), shifting the secondary/subtle/deep-subtle anchors up the silver gradient so the palette reads more cleanly under the new sig-stage card colours from 3242873 ; uncovered remnants from earlier analysis intentionally left in place — consumers.py at 68% (channels-tag tests excluded; would need --tag=channels run), Carte Blanche slot navigation + sky_dice + tarot_deck preview view paths (the "bigger investments" tier from session triage; FT-covered + the IT setup is heavier than the immediate value), defensive `except` fallbacks that need contrived inputs to fire, and a handful of __str__s/`pass` branches not worth a test apiece — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 01:07:13 -04:00
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):
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"})
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")
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)
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")
+52 IT/UT to close IT/UT-only coverage gaps (93% → 96%) — full suite 983 tests in 47s ; UTs in epic/tests/unit/test_models.py — `TarotCardEmanationForTest` (4) covers `emanation_for(polarity)` w. levity/gravity overrides + fallback to name_title for cards w.o a polarity split (cards 48-49 are the only polarity-split cards in the deck so this method is sparsely exercised by ITs); `TarotCardReversalForTest` (4) covers `reversal_for(polarity)` w. polarity-split + reversal_qualifier fallback + further fallthrough to emanation_for; `TarotCardNameSplitTest` (4) covers `name_group`/`name_title` colon-split parsing (prefix-w-colon / suffix / no-colon edge); `TarotCardCautionsJsonTest` (2) covers the `cautions_json` JSON serialiser ; UTs in epic/tests/unit/test_utils.py — `PlanetHouseFallbackTest` +1 happy-path test (degree=15 lands in house 1 w. sequential cusps) for the normal cusp-match branch alongside the existing pathological fallback test; `TopCapacitorsTest` (6) covers all `top_capacitors()` branches — empty dict / None / all-zero counts (the L56 `max(counts.values()) <= 0` fallback that was uncovered) / single-winner / tie-clockwise-order / enriched dict {"count":N} input shape ; ITs in epic/tests/integrated/test_models.py — `TarotDeckDrawTest` extended w. 5 tests for `remaining_count` (happy + no-deck-variant fallback to 0) + `draw()` happy-path (returns n tuples of (TarotCard, bool) / appends to drawn_card_ids / never repeats cards across consecutive draws); existing ValueError + shuffle tests preserved ; ITs in epic/tests/integrated/test_views.py — `SigEventRetractionTest` (4 tests) covers the three `data["retracted"] = True` paths that the FT `test_game_room_select_sig.py` walks transitively but no IT pins directly: sig_unready retracts prior SIG_READY (L937), sig_ready retracts prior SIG_UNREADY (L907), sig_reserve action=release while ready retracts prior SIG_READY + records fresh SIG_UNREADY (L823); `SigReserveInvalidCardIdTest` (1) covers `TarotCard.DoesNotExist` → 400 (L840-841) ; `SigSelectGravityContextTest` (3) covers the `user_polarity = 'gravity'` branch (L322) + the `gravity_sig_cards` lookup (L357) — all existing SIG_SELECT context tests use the founder-as-PC-levity setup so these branches sat uncovered; logs in as gamers[5] (BC role) + asserts user_polarity + sig_cards match `gravity_sig_cards()` output ; `SeaDeckViewTest` (7) mirrors the `test_game_room_select_sea.py` FT but isolates the JSON contract — covers 403 when unseated, empty halves when seat has no deck_variant (L1255-1256 early-out), two-halves shape, ~even split, card_dict keys (`id`/`name`/`arcana`/`corner_rank`/`suit_icon`/`name_group`/`name_title`/`reversed`/qualifiers), `reversed` field is bool, claimed-significator exclusion via `room.table_seats.exclude(significator__isnull=True)` ; ITs in dashboard/tests/integrated/test_views.py — `ProfileViewTest` +2 (reserved-handle "adman" rejection — L116-117: username stays unchanged + redirect to /); `KitBagViewTest` (3) covers the `kit_bag` view's panel render w. TITHE-sort branch (L169-175) + login guard ; ITs in dashboard/tests/integrated/test_sky_views.py — `SkyViewTest` +2 (saved birth datetime renders in user's `sky_birth_tz` via astimezone L300-306 — 16:00 UTC → 12:00 EDT; invalid-tz string triggers `ZoneInfoNotFoundError` → swallowed `pass` → UTC fallback at 16:00) ; ITs in gameboard/tests/integrated/test_views.py — `EquipTrinketViewTest` +2 (POST equips trinket + returns 204 — L83-85; non-owner POST returns 404 via `get_object_or_404`); `UnequipTrinketViewTest` +2 (POST clears matching equipped_trinket — L107-110; POST of non-matching token is a 204 no-op, the implicit `else` branch) ; .coveragerc omit gains `*/reset_staging_db.py` per user — mgmt cmd was the only 0%-stmt module that wasn't exercised by tests at all + we agreed it's deliberately untested staging-side code ; palette-monochrome-dark rebalance in rootvars.scss — --quiUser/--sixUser/--sepUser remapped to (secAg / quaAg / priPt) instead of (quaAg / terAg / secAg), shifting the secondary/subtle/deep-subtle anchors up the silver gradient so the palette reads more cleanly under the new sig-stage card colours from 3242873 ; uncovered remnants from earlier analysis intentionally left in place — consumers.py at 68% (channels-tag tests excluded; would need --tag=channels run), Carte Blanche slot navigation + sky_dice + tarot_deck preview view paths (the "bigger investments" tier from session triage; FT-covered + the IT setup is heavier than the immediate value), defensive `except` fallbacks that need contrived inputs to fire, and a handful of __str__s/`pass` branches not worth a test apiece — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 01:07:13 -04:00
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):
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)
My Sea applet shell — Sprint 3 of the My Sea roadmap User roadmap step (Sprint 3 of cluster): scaffold the My Sea applet on the gameboard + the standalone /gameboard/my-sea/ page where later sprints will host the gatekeeper / sig-select / sea-select reskin for solo-user draws. Shell-only — no draw flow yet; latest-draw rendering, mid-progress save, daily quota land in Sprints 4-9 ; **migration**: `applets/migrations/0008_seed_my_sea_applet.py` — RunPython that `update_or_create`s Applet(`slug='my-sea'`, name='My Sea', context='gameboard', default_visible=True, grid_cols=12, grid_rows=4). 12×4 wide horizontal banner so the Celtic Cross spread's 10 cards can render left-to-right in the applet aperture, scrollable like My Palette (per user spec). Reverse migration (`unseed`) deletes the row so the migration is reversible for staging rollbacks ; **applet partial**: `templates/apps/gameboard/_partials/_applet-my-sea.html` — same `{% applet_context %}` auto-discovery shape every other applet uses (`<section id="id_applet_my_sea" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};">`). Header is a `<h2><a href="{% url 'my_sea' %}">My Sea</a></h2>` link (gold via global `body a` rule); body is a `.my-sea-scroll` container that either renders `.my-sea-card` cells from a `latest_draw_cards` context (TBD in Sprint 4-7) or a `.my-sea-empty` placeholder line "No draws yet." for fresh users ; **standalone page**: new `gameboard/views.py:my_sea` view + url at `/gameboard/my-sea/` (URL name `my_sea`) rendering `apps/gameboard/my_sea.html` — `{% extends "core/base.html" %}` shell w. letter-spread `<span>My</span><span>Sea</span>` h2 wordmark + `.my-sea-page__empty` placeholder paragraph "Your sea is calm. Draws will appear here." `page_class` doubled to `page-gameboard page-my-sea` so the body inherits the gameboard's landscape aperture treatment AND any future my-sea-specific styles can target a single class. Login-required like the rest of gameboard ; **tests (+6 ITs)**: GameboardViewTest gains 3 — `test_gameboard_shows_my_sea_applet` (cssselect pins #id_applet_my_sea), `test_my_sea_applet_renders_empty_state_for_new_user` (asserts ".my-sea-empty" text + no ".my-sea-card" rows), `test_my_sea_applet_header_links_to_my_sea_page` (h2 a href == reverse('my_sea')); new MySeaViewTest class — `test_my_sea_requires_login` (redirect to /?next=...), `test_my_sea_renders_200`, `test_my_sea_uses_gameboard_page_class` (page-gameboard + page-my-sea both in body class). Existing GameboardViewTest setUp already does `get_or_create` per-applet so no fixture change needed for the migration-driven my-sea row ; 1005 IT/UT green (+6 from 999) in 45s; visual verified in Claudezilla at iPhone-14 portrait — applet renders w. rotated "MY SEA" vertical label + "No draws yet." body; /gameboard/my-sea/ standalone page renders w. letter-spread wordmark + placeholder ; **next**: Sprint 4 — My Sea sig-select phase (single-significator pick for solo user, w. the parameterized hex CSS from Sprint 1 hosting the chair-less or single-chair variant) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:45:57 -04:00
class MySeaViewTest(TestCase):
"""Sprint 3 of the My Sea roadmap — standalone page is a shell only.
Sigs / sea-select / gatekeeper phase content lands in later sprints."""
def setUp(self):
self.user = User.objects.create(email="sea@test.io")
self.client.force_login(self.user)
def test_my_sea_requires_login(self):
self.client.logout()
response = self.client.get(reverse("my_sea"))
self.assertRedirects(
response, "/?next=/gameboard/my-sea/", fetch_redirect_response=False
)
def test_my_sea_renders_200(self):
response = self.client.get(reverse("my_sea"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/gameboard/my_sea.html")
def test_my_sea_uses_gameboard_page_class(self):
# `page_class` drives the body class for the landscape layout aperture
# — My Sea inherits the gameboard's aperture (same nav/footer rails).
response = self.client.get(reverse("my_sea"))
self.assertIn("page-gameboard", response.content.decode())
self.assertIn("page-my-sea", response.content.decode())