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

711 lines
32 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_sign_gate_for_user_without_sig(self):
# Sprint 4b — user with no significator sees the Look!-formatted
# gate (mirror of the standalone page), not the draw UX.
[_gate] = self.parsed.cssselect(
"#id_applet_my_sea .my-sea-sign-gate--applet"
)
# Draw-state nodes are suppressed while the gate is up.
self.assertEqual(
len(self.parsed.cssselect("#id_applet_my_sea .my-sea-empty")), 0,
)
self.assertEqual(
len(self.parsed.cssselect("#id_applet_my_sea .my-sea-card")), 0,
)
def test_my_sea_applet_renders_empty_state_for_user_with_sig_no_draws(self):
# Sig set + no saved draws → the scroll container hosts a single
# placeholder line ("No draws yet."), no card cells, no gate.
from apps.epic.models import personal_sig_cards
sig_pile = personal_sig_cards(self.user)
self.user.significator = sig_pile[0]
self.user.save()
response = self.client.get("/gameboard/")
parsed = lxml.html.fromstring(response.content)
[empty] = parsed.cssselect("#id_applet_my_sea .my-sea-empty")
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
self.assertIn("No draws yet", empty.text_content())
self.assertEqual(
len(parsed.cssselect("#id_applet_my_sea .my-sea-card")), 0,
)
self.assertEqual(
len(parsed.cssselect("#id_applet_my_sea .my-sea-sign-gate--applet")),
0,
)
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_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())
My Sea DRAW SEA landing — Sprint 5 iter 1 of My Sea roadmap — TDD DRAW SEA landing UX on /gameboard/my-sea/ for users past the [[sprint-my-sea-sign-gate-may19]] gate. DRY table hex (reused from the room shell + my-sign Sprint 4a iter 3) w. 6 chair seats labeled 1C-6C (placeholder for friend-invite per the My Sea roadmap "Six chairs retained even in solo" anchor) + central DRAW SEA `.btn-primary` mirroring SCAN SIGN on /billboard/my-sign/. Click swaps `.my-sea-page[data-phase]` from `landing` to `picker`; the picker UX itself (three-card cross w. cover/leave/loom + form col / spread dropdown / decks / LOCK HAND / DEL) lands in iters 2 + 3. The 'C' suffix on the chair labels = "Chair" (user-locked); no role semantics (this is a solo draw, not a 6-player role assignment). `.table-seat` CSS class + `data-slot` attribute preserved so the room's existing `[data-slot="N"]` positioning rules (`_room.scss` L583-588) carry over for free — no SCSS fork; just a new `.seat-label` span inside each seat. The 'Default deck warning' Brief banner from /billboard/my-sign/ fires verbatim when `user.equipped_deck` is None (the user is headed for a draw against the Earthman [Shabby Cardstock] backup unless they equip one first). Tagged `.my-sea-intro-banner` so FTs disambiguate from other Briefs. Same FYI (→ /gameboard/) / NVM (dismiss) action grammar. Bundled: BACK→NVM label swap (user-edited mid-sprint) in the existing sign-gate. CSS class `.my-sea-sign-gate__back` retained — the swap was label-only — so existing FTs targeting the class still pass; docstrings + comments updated for accuracy. Files: - `apps/gameboard/views.py` — `my_sea` view adds 2 context keys: `no_equipped_deck` (bool) + `show_backup_intro_banner` (= user_has_sig AND no_equipped_deck). The sig-gate path still wins precedence. - `templates/apps/gameboard/my_sea.html` — `.my-sea-page[data-phase="landing"]`; new `.my-sea-landing` block w. room-shell hex + `#id_draw_sea_btn` + 6 `.table-seat[data-slot="N"]` w. `<span class="seat-label">NC</span>`; new `.my-sea-picker` placeholder (`display:none` til DRAW SEA click); inline `<script>` for the click→data-phase swap + scaleTable re-fire on next tick (mirrors my-sign's iter-3 RAF dispatch); copies the my-sign Brief banner script block verbatim w. `.my-sea-intro-banner` post-render tag. - `static_src/scss/_gameboard.scss` — new `.my-sea-page` + `.my-sea-landing` + `.my-sea-picker` rule blocks mirroring `.my-sign-page` / `.my-sign-landing` from `_card-deck.scss`. `.seat-label` styled in `--terUser` to match the chair iconography. - `apps/gameboard/tests/integrated/test_views.py` — `+7 ITs` in new `MySeaDrawSeaLandingViewTest` pinning the context keys + presence/absence of `#id_draw_sea_btn` + 6 `data-slot=N` w. `NC` labels + Sprint 4b gate's precedence over the new landing. - `functional_tests/test_game_my_sea.py` — `+5 FTs` in new `MySeaDrawSeaLandingTest` for the visible UX (hex + DRAW SEA, 6 seats labeled 1C-6C, click→picker phase swap, Brief banner on no-deck, no banner when deck equipped). Uses new `_assign_sig` helper from [[sprint-sig-page-helper-may19c]] for the user-w-sig precondition. Tests: 25/25 FTs green across test_bill_my_sign + test_game_my_sea in 219s; 1036/1036 IT/UT green in 50s (+7 from baseline). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:15:37 -04:00
class MySeaDrawSeaLandingViewTest(TestCase):
"""Sprint 5 iter 1 — view context for the DRAW SEA landing UX. Pins
`no_equipped_deck` + `show_backup_intro_banner` context keys + the
presence of the new landing template elements when user passes the
Sprint 4b sign-gate."""
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="draw@test.io")
self.client.force_login(self.user)
# Assign a sig so the view's landing branch (not the gate) renders.
self.user.significator = personal_sig_cards(self.user)[0]
self.user.save(update_fields=["significator"])
def test_context_no_equipped_deck_false_when_user_has_deck(self):
# post_save auto-equips Earthman; `no_equipped_deck` should be False.
response = self.client.get(reverse("my_sea"))
self.assertFalse(response.context["no_equipped_deck"])
def test_context_no_equipped_deck_true_when_user_cleared_deck(self):
self.user.equipped_deck = None
self.user.save(update_fields=["equipped_deck"])
response = self.client.get(reverse("my_sea"))
self.assertTrue(response.context["no_equipped_deck"])
def test_context_show_backup_intro_banner_when_no_deck_and_has_sig(self):
# Brief banner fires when user has a sig AND no deck — they're on the
# landing UX (gate passed) but headed for the backup-deck draw path.
self.user.equipped_deck = None
self.user.save(update_fields=["equipped_deck"])
response = self.client.get(reverse("my_sea"))
self.assertTrue(response.context["show_backup_intro_banner"])
def test_context_show_backup_intro_banner_false_when_deck_equipped(self):
response = self.client.get(reverse("my_sea"))
self.assertFalse(response.context["show_backup_intro_banner"])
My Sea FREE DRAW + seat-1C seated transition — Sprint 5 iter 1 follow-up — TDD Iter-1 follow-up after the user re-spec'd the FREE DRAW click behavior: - **Btn label** DRAW SEA → FREE DRAW (the 1/24h free-quota draw). Element ID `id_draw_sea_btn` retained — describes intent, not label, so a future sprint can conditionally swap the label back to DRAW SEA once the daily free has been used (at which point the btn calls the room gatekeeper partial for token-deposit per [[project-my-sea-roadmap]] Sprint 6). - **Chair seat markup** — each `.table-seat` now renders w. `.fa-chair` + `.seat-position-label` (1C-6C) + `.position-status-icon.fa-solid.fa-ban` mirroring the room's hex grammar from `_table_positions.html`. **`.seat-position-label`** (not `.seat-role-label`) because my-sea is the solo flow — no roles — and the existing room class carries role-grammar semantics that don't apply here. New class gets its own grid placement in `_gameboard.scss` (col 2 / row 1 default, col 1 for left-side seats 3/4/5 per the room's flip rule). - **FREE DRAW click flow** — (1) seat 1C immediately gains `.seated` class & its `.fa-ban` icon swaps to `.fa-circle-check`; (2) after 800ms (so the user sees the seat animation against `_room.scss`'s 0.6s `color`/`filter` transition on `.fa-chair`), `data-phase` swaps to `picker` & landing hides. Seat 1C-only because my-sea is single-user-per-page until friend-invite lands — the user always occupies the lowest-numeral seat. - **`.table-seat.seated` SCSS** in `_gameboard.scss` — `--terUser` chair color + `drop-shadow(--ninUser)` glow. Mirrors `_room.scss:626` `.table-seat.active .fa-chair` styling but uses a stable `.seated` class (semantically distinct: `.active` = current turn in a multi-user room, `.seated` = draw-locked occupant in the solo flow). Status icon green via the existing `_room.scss:616` `.position-status-icon.fa-circle-check` rule — no new color rule needed. - **FT/IT updates** — renamed `test_landing_renders_hex_with_draw_sea_btn` → `…_free_draw_btn` w. "FREE DRAW" label assertions; T2 extended to assert `.position-status-icon.fa-ban` on each seat at render time; T3 (formerly `…_transitions_to_picker_phase`) rewritten as `test_free_draw_click_seats_user_in_1C_then_swaps_phase` to pin the full click contract: 1C goes `.seated` + `.fa-circle-check`, seats 2-6 unchanged, picker phase swap after the delay. New IT `test_landing_renders_position_status_ban_icon_on_each_seat` asserts initial ban + `.seat-position-label` counts. **Substring trap caught** (worth a sticky note): bare class-name substrings (`fa-ban`, `position-status-icon`) appear ALSO in the inline JS handler's `classList.remove(…)` / `querySelector(…)` arg strings → `html.count("fa-ban") = 7`, not 6. Tightened IT assertions to match the full class attribute (`class="position-status-icon fa-solid fa-ban"`) — never count bare class names in `assertContains` / `html.count` when the same class is JS-manipulated client-side. Tests: 25/25 FT green across test_bill_my_sign + test_game_my_sea (165s); 1037/1037 IT/UT green (49s). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:07 -04:00
def test_landing_renders_free_draw_btn_when_sig_set(self):
# Element ID `id_draw_sea_btn` describes intent (draw entry point);
# visible label is "FREE DRAW" for the daily-free quota draw.
My Sea DRAW SEA landing — Sprint 5 iter 1 of My Sea roadmap — TDD DRAW SEA landing UX on /gameboard/my-sea/ for users past the [[sprint-my-sea-sign-gate-may19]] gate. DRY table hex (reused from the room shell + my-sign Sprint 4a iter 3) w. 6 chair seats labeled 1C-6C (placeholder for friend-invite per the My Sea roadmap "Six chairs retained even in solo" anchor) + central DRAW SEA `.btn-primary` mirroring SCAN SIGN on /billboard/my-sign/. Click swaps `.my-sea-page[data-phase]` from `landing` to `picker`; the picker UX itself (three-card cross w. cover/leave/loom + form col / spread dropdown / decks / LOCK HAND / DEL) lands in iters 2 + 3. The 'C' suffix on the chair labels = "Chair" (user-locked); no role semantics (this is a solo draw, not a 6-player role assignment). `.table-seat` CSS class + `data-slot` attribute preserved so the room's existing `[data-slot="N"]` positioning rules (`_room.scss` L583-588) carry over for free — no SCSS fork; just a new `.seat-label` span inside each seat. The 'Default deck warning' Brief banner from /billboard/my-sign/ fires verbatim when `user.equipped_deck` is None (the user is headed for a draw against the Earthman [Shabby Cardstock] backup unless they equip one first). Tagged `.my-sea-intro-banner` so FTs disambiguate from other Briefs. Same FYI (→ /gameboard/) / NVM (dismiss) action grammar. Bundled: BACK→NVM label swap (user-edited mid-sprint) in the existing sign-gate. CSS class `.my-sea-sign-gate__back` retained — the swap was label-only — so existing FTs targeting the class still pass; docstrings + comments updated for accuracy. Files: - `apps/gameboard/views.py` — `my_sea` view adds 2 context keys: `no_equipped_deck` (bool) + `show_backup_intro_banner` (= user_has_sig AND no_equipped_deck). The sig-gate path still wins precedence. - `templates/apps/gameboard/my_sea.html` — `.my-sea-page[data-phase="landing"]`; new `.my-sea-landing` block w. room-shell hex + `#id_draw_sea_btn` + 6 `.table-seat[data-slot="N"]` w. `<span class="seat-label">NC</span>`; new `.my-sea-picker` placeholder (`display:none` til DRAW SEA click); inline `<script>` for the click→data-phase swap + scaleTable re-fire on next tick (mirrors my-sign's iter-3 RAF dispatch); copies the my-sign Brief banner script block verbatim w. `.my-sea-intro-banner` post-render tag. - `static_src/scss/_gameboard.scss` — new `.my-sea-page` + `.my-sea-landing` + `.my-sea-picker` rule blocks mirroring `.my-sign-page` / `.my-sign-landing` from `_card-deck.scss`. `.seat-label` styled in `--terUser` to match the chair iconography. - `apps/gameboard/tests/integrated/test_views.py` — `+7 ITs` in new `MySeaDrawSeaLandingViewTest` pinning the context keys + presence/absence of `#id_draw_sea_btn` + 6 `data-slot=N` w. `NC` labels + Sprint 4b gate's precedence over the new landing. - `functional_tests/test_game_my_sea.py` — `+5 FTs` in new `MySeaDrawSeaLandingTest` for the visible UX (hex + DRAW SEA, 6 seats labeled 1C-6C, click→picker phase swap, Brief banner on no-deck, no banner when deck equipped). Uses new `_assign_sig` helper from [[sprint-sig-page-helper-may19c]] for the user-w-sig precondition. Tests: 25/25 FTs green across test_bill_my_sign + test_game_my_sea in 219s; 1036/1036 IT/UT green in 50s (+7 from baseline). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:15:37 -04:00
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'id="id_draw_sea_btn"')
My Sea FREE DRAW + seat-1C seated transition — Sprint 5 iter 1 follow-up — TDD Iter-1 follow-up after the user re-spec'd the FREE DRAW click behavior: - **Btn label** DRAW SEA → FREE DRAW (the 1/24h free-quota draw). Element ID `id_draw_sea_btn` retained — describes intent, not label, so a future sprint can conditionally swap the label back to DRAW SEA once the daily free has been used (at which point the btn calls the room gatekeeper partial for token-deposit per [[project-my-sea-roadmap]] Sprint 6). - **Chair seat markup** — each `.table-seat` now renders w. `.fa-chair` + `.seat-position-label` (1C-6C) + `.position-status-icon.fa-solid.fa-ban` mirroring the room's hex grammar from `_table_positions.html`. **`.seat-position-label`** (not `.seat-role-label`) because my-sea is the solo flow — no roles — and the existing room class carries role-grammar semantics that don't apply here. New class gets its own grid placement in `_gameboard.scss` (col 2 / row 1 default, col 1 for left-side seats 3/4/5 per the room's flip rule). - **FREE DRAW click flow** — (1) seat 1C immediately gains `.seated` class & its `.fa-ban` icon swaps to `.fa-circle-check`; (2) after 800ms (so the user sees the seat animation against `_room.scss`'s 0.6s `color`/`filter` transition on `.fa-chair`), `data-phase` swaps to `picker` & landing hides. Seat 1C-only because my-sea is single-user-per-page until friend-invite lands — the user always occupies the lowest-numeral seat. - **`.table-seat.seated` SCSS** in `_gameboard.scss` — `--terUser` chair color + `drop-shadow(--ninUser)` glow. Mirrors `_room.scss:626` `.table-seat.active .fa-chair` styling but uses a stable `.seated` class (semantically distinct: `.active` = current turn in a multi-user room, `.seated` = draw-locked occupant in the solo flow). Status icon green via the existing `_room.scss:616` `.position-status-icon.fa-circle-check` rule — no new color rule needed. - **FT/IT updates** — renamed `test_landing_renders_hex_with_draw_sea_btn` → `…_free_draw_btn` w. "FREE DRAW" label assertions; T2 extended to assert `.position-status-icon.fa-ban` on each seat at render time; T3 (formerly `…_transitions_to_picker_phase`) rewritten as `test_free_draw_click_seats_user_in_1C_then_swaps_phase` to pin the full click contract: 1C goes `.seated` + `.fa-circle-check`, seats 2-6 unchanged, picker phase swap after the delay. New IT `test_landing_renders_position_status_ban_icon_on_each_seat` asserts initial ban + `.seat-position-label` counts. **Substring trap caught** (worth a sticky note): bare class-name substrings (`fa-ban`, `position-status-icon`) appear ALSO in the inline JS handler's `classList.remove(…)` / `querySelector(…)` arg strings → `html.count("fa-ban") = 7`, not 6. Tightened IT assertions to match the full class attribute (`class="position-status-icon fa-solid fa-ban"`) — never count bare class names in `assertContains` / `html.count` when the same class is JS-manipulated client-side. Tests: 25/25 FT green across test_bill_my_sign + test_game_my_sea (165s); 1037/1037 IT/UT green (49s). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:07 -04:00
self.assertContains(response, "FREE")
self.assertContains(response, "DRAW")
My Sea DRAW SEA landing — Sprint 5 iter 1 of My Sea roadmap — TDD DRAW SEA landing UX on /gameboard/my-sea/ for users past the [[sprint-my-sea-sign-gate-may19]] gate. DRY table hex (reused from the room shell + my-sign Sprint 4a iter 3) w. 6 chair seats labeled 1C-6C (placeholder for friend-invite per the My Sea roadmap "Six chairs retained even in solo" anchor) + central DRAW SEA `.btn-primary` mirroring SCAN SIGN on /billboard/my-sign/. Click swaps `.my-sea-page[data-phase]` from `landing` to `picker`; the picker UX itself (three-card cross w. cover/leave/loom + form col / spread dropdown / decks / LOCK HAND / DEL) lands in iters 2 + 3. The 'C' suffix on the chair labels = "Chair" (user-locked); no role semantics (this is a solo draw, not a 6-player role assignment). `.table-seat` CSS class + `data-slot` attribute preserved so the room's existing `[data-slot="N"]` positioning rules (`_room.scss` L583-588) carry over for free — no SCSS fork; just a new `.seat-label` span inside each seat. The 'Default deck warning' Brief banner from /billboard/my-sign/ fires verbatim when `user.equipped_deck` is None (the user is headed for a draw against the Earthman [Shabby Cardstock] backup unless they equip one first). Tagged `.my-sea-intro-banner` so FTs disambiguate from other Briefs. Same FYI (→ /gameboard/) / NVM (dismiss) action grammar. Bundled: BACK→NVM label swap (user-edited mid-sprint) in the existing sign-gate. CSS class `.my-sea-sign-gate__back` retained — the swap was label-only — so existing FTs targeting the class still pass; docstrings + comments updated for accuracy. Files: - `apps/gameboard/views.py` — `my_sea` view adds 2 context keys: `no_equipped_deck` (bool) + `show_backup_intro_banner` (= user_has_sig AND no_equipped_deck). The sig-gate path still wins precedence. - `templates/apps/gameboard/my_sea.html` — `.my-sea-page[data-phase="landing"]`; new `.my-sea-landing` block w. room-shell hex + `#id_draw_sea_btn` + 6 `.table-seat[data-slot="N"]` w. `<span class="seat-label">NC</span>`; new `.my-sea-picker` placeholder (`display:none` til DRAW SEA click); inline `<script>` for the click→data-phase swap + scaleTable re-fire on next tick (mirrors my-sign's iter-3 RAF dispatch); copies the my-sign Brief banner script block verbatim w. `.my-sea-intro-banner` post-render tag. - `static_src/scss/_gameboard.scss` — new `.my-sea-page` + `.my-sea-landing` + `.my-sea-picker` rule blocks mirroring `.my-sign-page` / `.my-sign-landing` from `_card-deck.scss`. `.seat-label` styled in `--terUser` to match the chair iconography. - `apps/gameboard/tests/integrated/test_views.py` — `+7 ITs` in new `MySeaDrawSeaLandingViewTest` pinning the context keys + presence/absence of `#id_draw_sea_btn` + 6 `data-slot=N` w. `NC` labels + Sprint 4b gate's precedence over the new landing. - `functional_tests/test_game_my_sea.py` — `+5 FTs` in new `MySeaDrawSeaLandingTest` for the visible UX (hex + DRAW SEA, 6 seats labeled 1C-6C, click→picker phase swap, Brief banner on no-deck, no banner when deck equipped). Uses new `_assign_sig` helper from [[sprint-sig-page-helper-may19c]] for the user-w-sig precondition. Tests: 25/25 FTs green across test_bill_my_sign + test_game_my_sea in 219s; 1036/1036 IT/UT green in 50s (+7 from baseline). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:15:37 -04:00
def test_landing_renders_six_chair_seats_with_C_suffix(self):
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
for n in range(1, 7):
with self.subTest(slot=n):
self.assertIn(f'data-slot="{n}"', html)
self.assertIn(f"{n}C", html)
My Sea FREE DRAW + seat-1C seated transition — Sprint 5 iter 1 follow-up — TDD Iter-1 follow-up after the user re-spec'd the FREE DRAW click behavior: - **Btn label** DRAW SEA → FREE DRAW (the 1/24h free-quota draw). Element ID `id_draw_sea_btn` retained — describes intent, not label, so a future sprint can conditionally swap the label back to DRAW SEA once the daily free has been used (at which point the btn calls the room gatekeeper partial for token-deposit per [[project-my-sea-roadmap]] Sprint 6). - **Chair seat markup** — each `.table-seat` now renders w. `.fa-chair` + `.seat-position-label` (1C-6C) + `.position-status-icon.fa-solid.fa-ban` mirroring the room's hex grammar from `_table_positions.html`. **`.seat-position-label`** (not `.seat-role-label`) because my-sea is the solo flow — no roles — and the existing room class carries role-grammar semantics that don't apply here. New class gets its own grid placement in `_gameboard.scss` (col 2 / row 1 default, col 1 for left-side seats 3/4/5 per the room's flip rule). - **FREE DRAW click flow** — (1) seat 1C immediately gains `.seated` class & its `.fa-ban` icon swaps to `.fa-circle-check`; (2) after 800ms (so the user sees the seat animation against `_room.scss`'s 0.6s `color`/`filter` transition on `.fa-chair`), `data-phase` swaps to `picker` & landing hides. Seat 1C-only because my-sea is single-user-per-page until friend-invite lands — the user always occupies the lowest-numeral seat. - **`.table-seat.seated` SCSS** in `_gameboard.scss` — `--terUser` chair color + `drop-shadow(--ninUser)` glow. Mirrors `_room.scss:626` `.table-seat.active .fa-chair` styling but uses a stable `.seated` class (semantically distinct: `.active` = current turn in a multi-user room, `.seated` = draw-locked occupant in the solo flow). Status icon green via the existing `_room.scss:616` `.position-status-icon.fa-circle-check` rule — no new color rule needed. - **FT/IT updates** — renamed `test_landing_renders_hex_with_draw_sea_btn` → `…_free_draw_btn` w. "FREE DRAW" label assertions; T2 extended to assert `.position-status-icon.fa-ban` on each seat at render time; T3 (formerly `…_transitions_to_picker_phase`) rewritten as `test_free_draw_click_seats_user_in_1C_then_swaps_phase` to pin the full click contract: 1C goes `.seated` + `.fa-circle-check`, seats 2-6 unchanged, picker phase swap after the delay. New IT `test_landing_renders_position_status_ban_icon_on_each_seat` asserts initial ban + `.seat-position-label` counts. **Substring trap caught** (worth a sticky note): bare class-name substrings (`fa-ban`, `position-status-icon`) appear ALSO in the inline JS handler's `classList.remove(…)` / `querySelector(…)` arg strings → `html.count("fa-ban") = 7`, not 6. Tightened IT assertions to match the full class attribute (`class="position-status-icon fa-solid fa-ban"`) — never count bare class names in `assertContains` / `html.count` when the same class is JS-manipulated client-side. Tests: 25/25 FT green across test_bill_my_sign + test_game_my_sea (165s); 1037/1037 IT/UT green (49s). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:07 -04:00
def test_landing_renders_position_status_ban_icon_on_each_seat(self):
# Each chair seat starts empty (red `.fa-ban` status icon). The
# FREE DRAW click handler swaps seat 1C's icon to .fa-circle-check
# client-side; this IT only pins the initial render state. Class
# substrings ("position-status-icon", "fa-ban") ALSO appear in the
# inline JS handler (classList.remove arg, querySelector arg) — so
# counts are asserted on the full class-attribute string only.
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
self.assertEqual(html.count('class="position-status-icon fa-solid fa-ban"'), 6)
self.assertEqual(html.count('class="seat-position-label"'), 6)
My Sea DRAW SEA landing — Sprint 5 iter 1 of My Sea roadmap — TDD DRAW SEA landing UX on /gameboard/my-sea/ for users past the [[sprint-my-sea-sign-gate-may19]] gate. DRY table hex (reused from the room shell + my-sign Sprint 4a iter 3) w. 6 chair seats labeled 1C-6C (placeholder for friend-invite per the My Sea roadmap "Six chairs retained even in solo" anchor) + central DRAW SEA `.btn-primary` mirroring SCAN SIGN on /billboard/my-sign/. Click swaps `.my-sea-page[data-phase]` from `landing` to `picker`; the picker UX itself (three-card cross w. cover/leave/loom + form col / spread dropdown / decks / LOCK HAND / DEL) lands in iters 2 + 3. The 'C' suffix on the chair labels = "Chair" (user-locked); no role semantics (this is a solo draw, not a 6-player role assignment). `.table-seat` CSS class + `data-slot` attribute preserved so the room's existing `[data-slot="N"]` positioning rules (`_room.scss` L583-588) carry over for free — no SCSS fork; just a new `.seat-label` span inside each seat. The 'Default deck warning' Brief banner from /billboard/my-sign/ fires verbatim when `user.equipped_deck` is None (the user is headed for a draw against the Earthman [Shabby Cardstock] backup unless they equip one first). Tagged `.my-sea-intro-banner` so FTs disambiguate from other Briefs. Same FYI (→ /gameboard/) / NVM (dismiss) action grammar. Bundled: BACK→NVM label swap (user-edited mid-sprint) in the existing sign-gate. CSS class `.my-sea-sign-gate__back` retained — the swap was label-only — so existing FTs targeting the class still pass; docstrings + comments updated for accuracy. Files: - `apps/gameboard/views.py` — `my_sea` view adds 2 context keys: `no_equipped_deck` (bool) + `show_backup_intro_banner` (= user_has_sig AND no_equipped_deck). The sig-gate path still wins precedence. - `templates/apps/gameboard/my_sea.html` — `.my-sea-page[data-phase="landing"]`; new `.my-sea-landing` block w. room-shell hex + `#id_draw_sea_btn` + 6 `.table-seat[data-slot="N"]` w. `<span class="seat-label">NC</span>`; new `.my-sea-picker` placeholder (`display:none` til DRAW SEA click); inline `<script>` for the click→data-phase swap + scaleTable re-fire on next tick (mirrors my-sign's iter-3 RAF dispatch); copies the my-sign Brief banner script block verbatim w. `.my-sea-intro-banner` post-render tag. - `static_src/scss/_gameboard.scss` — new `.my-sea-page` + `.my-sea-landing` + `.my-sea-picker` rule blocks mirroring `.my-sign-page` / `.my-sign-landing` from `_card-deck.scss`. `.seat-label` styled in `--terUser` to match the chair iconography. - `apps/gameboard/tests/integrated/test_views.py` — `+7 ITs` in new `MySeaDrawSeaLandingViewTest` pinning the context keys + presence/absence of `#id_draw_sea_btn` + 6 `data-slot=N` w. `NC` labels + Sprint 4b gate's precedence over the new landing. - `functional_tests/test_game_my_sea.py` — `+5 FTs` in new `MySeaDrawSeaLandingTest` for the visible UX (hex + DRAW SEA, 6 seats labeled 1C-6C, click→picker phase swap, Brief banner on no-deck, no banner when deck equipped). Uses new `_assign_sig` helper from [[sprint-sig-page-helper-may19c]] for the user-w-sig precondition. Tests: 25/25 FTs green across test_bill_my_sign + test_game_my_sea in 219s; 1036/1036 IT/UT green in 50s (+7 from baseline). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:15:37 -04:00
def test_landing_not_rendered_when_user_has_no_sig(self):
My Sea FREE DRAW + seat-1C seated transition — Sprint 5 iter 1 follow-up — TDD Iter-1 follow-up after the user re-spec'd the FREE DRAW click behavior: - **Btn label** DRAW SEA → FREE DRAW (the 1/24h free-quota draw). Element ID `id_draw_sea_btn` retained — describes intent, not label, so a future sprint can conditionally swap the label back to DRAW SEA once the daily free has been used (at which point the btn calls the room gatekeeper partial for token-deposit per [[project-my-sea-roadmap]] Sprint 6). - **Chair seat markup** — each `.table-seat` now renders w. `.fa-chair` + `.seat-position-label` (1C-6C) + `.position-status-icon.fa-solid.fa-ban` mirroring the room's hex grammar from `_table_positions.html`. **`.seat-position-label`** (not `.seat-role-label`) because my-sea is the solo flow — no roles — and the existing room class carries role-grammar semantics that don't apply here. New class gets its own grid placement in `_gameboard.scss` (col 2 / row 1 default, col 1 for left-side seats 3/4/5 per the room's flip rule). - **FREE DRAW click flow** — (1) seat 1C immediately gains `.seated` class & its `.fa-ban` icon swaps to `.fa-circle-check`; (2) after 800ms (so the user sees the seat animation against `_room.scss`'s 0.6s `color`/`filter` transition on `.fa-chair`), `data-phase` swaps to `picker` & landing hides. Seat 1C-only because my-sea is single-user-per-page until friend-invite lands — the user always occupies the lowest-numeral seat. - **`.table-seat.seated` SCSS** in `_gameboard.scss` — `--terUser` chair color + `drop-shadow(--ninUser)` glow. Mirrors `_room.scss:626` `.table-seat.active .fa-chair` styling but uses a stable `.seated` class (semantically distinct: `.active` = current turn in a multi-user room, `.seated` = draw-locked occupant in the solo flow). Status icon green via the existing `_room.scss:616` `.position-status-icon.fa-circle-check` rule — no new color rule needed. - **FT/IT updates** — renamed `test_landing_renders_hex_with_draw_sea_btn` → `…_free_draw_btn` w. "FREE DRAW" label assertions; T2 extended to assert `.position-status-icon.fa-ban` on each seat at render time; T3 (formerly `…_transitions_to_picker_phase`) rewritten as `test_free_draw_click_seats_user_in_1C_then_swaps_phase` to pin the full click contract: 1C goes `.seated` + `.fa-circle-check`, seats 2-6 unchanged, picker phase swap after the delay. New IT `test_landing_renders_position_status_ban_icon_on_each_seat` asserts initial ban + `.seat-position-label` counts. **Substring trap caught** (worth a sticky note): bare class-name substrings (`fa-ban`, `position-status-icon`) appear ALSO in the inline JS handler's `classList.remove(…)` / `querySelector(…)` arg strings → `html.count("fa-ban") = 7`, not 6. Tightened IT assertions to match the full class attribute (`class="position-status-icon fa-solid fa-ban"`) — never count bare class names in `assertContains` / `html.count` when the same class is JS-manipulated client-side. Tests: 25/25 FT green across test_bill_my_sign + test_game_my_sea (165s); 1037/1037 IT/UT green (49s). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:07 -04:00
# Sprint 4b gate still wins precedence — FREE DRAW must not render
My Sea DRAW SEA landing — Sprint 5 iter 1 of My Sea roadmap — TDD DRAW SEA landing UX on /gameboard/my-sea/ for users past the [[sprint-my-sea-sign-gate-may19]] gate. DRY table hex (reused from the room shell + my-sign Sprint 4a iter 3) w. 6 chair seats labeled 1C-6C (placeholder for friend-invite per the My Sea roadmap "Six chairs retained even in solo" anchor) + central DRAW SEA `.btn-primary` mirroring SCAN SIGN on /billboard/my-sign/. Click swaps `.my-sea-page[data-phase]` from `landing` to `picker`; the picker UX itself (three-card cross w. cover/leave/loom + form col / spread dropdown / decks / LOCK HAND / DEL) lands in iters 2 + 3. The 'C' suffix on the chair labels = "Chair" (user-locked); no role semantics (this is a solo draw, not a 6-player role assignment). `.table-seat` CSS class + `data-slot` attribute preserved so the room's existing `[data-slot="N"]` positioning rules (`_room.scss` L583-588) carry over for free — no SCSS fork; just a new `.seat-label` span inside each seat. The 'Default deck warning' Brief banner from /billboard/my-sign/ fires verbatim when `user.equipped_deck` is None (the user is headed for a draw against the Earthman [Shabby Cardstock] backup unless they equip one first). Tagged `.my-sea-intro-banner` so FTs disambiguate from other Briefs. Same FYI (→ /gameboard/) / NVM (dismiss) action grammar. Bundled: BACK→NVM label swap (user-edited mid-sprint) in the existing sign-gate. CSS class `.my-sea-sign-gate__back` retained — the swap was label-only — so existing FTs targeting the class still pass; docstrings + comments updated for accuracy. Files: - `apps/gameboard/views.py` — `my_sea` view adds 2 context keys: `no_equipped_deck` (bool) + `show_backup_intro_banner` (= user_has_sig AND no_equipped_deck). The sig-gate path still wins precedence. - `templates/apps/gameboard/my_sea.html` — `.my-sea-page[data-phase="landing"]`; new `.my-sea-landing` block w. room-shell hex + `#id_draw_sea_btn` + 6 `.table-seat[data-slot="N"]` w. `<span class="seat-label">NC</span>`; new `.my-sea-picker` placeholder (`display:none` til DRAW SEA click); inline `<script>` for the click→data-phase swap + scaleTable re-fire on next tick (mirrors my-sign's iter-3 RAF dispatch); copies the my-sign Brief banner script block verbatim w. `.my-sea-intro-banner` post-render tag. - `static_src/scss/_gameboard.scss` — new `.my-sea-page` + `.my-sea-landing` + `.my-sea-picker` rule blocks mirroring `.my-sign-page` / `.my-sign-landing` from `_card-deck.scss`. `.seat-label` styled in `--terUser` to match the chair iconography. - `apps/gameboard/tests/integrated/test_views.py` — `+7 ITs` in new `MySeaDrawSeaLandingViewTest` pinning the context keys + presence/absence of `#id_draw_sea_btn` + 6 `data-slot=N` w. `NC` labels + Sprint 4b gate's precedence over the new landing. - `functional_tests/test_game_my_sea.py` — `+5 FTs` in new `MySeaDrawSeaLandingTest` for the visible UX (hex + DRAW SEA, 6 seats labeled 1C-6C, click→picker phase swap, Brief banner on no-deck, no banner when deck equipped). Uses new `_assign_sig` helper from [[sprint-sig-page-helper-may19c]] for the user-w-sig precondition. Tests: 25/25 FTs green across test_bill_my_sign + test_game_my_sea in 219s; 1036/1036 IT/UT green in 50s (+7 from baseline). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:15:37 -04:00
# when significator is None.
self.user.significator = None
self.user.save(update_fields=["significator"])
response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, 'id="id_draw_sea_btn"')
My Sea picker phase: three-card cross (sig + cover/leave/loom) — Sprint 5 iter 2 of My Sea roadmap — TDD After the FREE DRAW click on iter 1's landing swaps `data-phase` to `picker`, the picker now renders a stripped Celtic Cross: user's saved significator pinned in `.sea-pos-core`, three drawn-card drop zones around it — cover (overlaid on sig), leave (left of core), loom (right of core). Crown / lay / cross from the gameroom's 6-position spread are deliberately forsaken (user-locked spec). DRY w. the gameroom sea-overlay: reuses `.sea-cards-col` + `.sea-cross` + `.sea-crucifix-cell` + `.sea-pos-*` + `.sea-card-slot--empty` + `.sea-sig-card` classes & their _card-deck.scss styling (1181-1331). Only divergence from the room: a `.my-sea-cross` modifier in `_gameboard.scss` overrides `grid-template-areas` from the room's `". crown . / leave core loom / . lay ."` 3×3 to a single-row `"leave core loom"` — drops the crown + lay rows since those positions are forsaken. Cover stays nested inside `.sea-pos-core` so the absolute-overlay rules from _card-deck.scss line 1310-1331 carry over for free. Picker bg = `rgba(var(--duoUser), 1)` on `.my-sea-page[data-phase="picker"]` — parallels `.my-sign-page[data-phase="picker"]` from _card-deck.scss line 704, so the landing→picker swap reads as a continuous surface (hex face → felt) like on /billboard/my-sign/. The sig card renders w. `data-card-id="{{ significator.id }}"` + `.fan-corner-rank` + `.fa-solid {suit-icon}` (mirrors the gameroom's `.sea-sig-card` minimal markup at `_sea_overlay.html` line 33-39). Full card-face / FYI / SPIN wiring deferred — iter 3 lands the form col + interactive draw flow. View context: `my_sea` now passes `significator` (FK pass-through) + `significator_reversed` so the template can render the corner rank + suit icon at render time without re-fetching. - 3 FTs in new `MySeaPickerPhaseTest`: sig card w. `data-card-id` matching `user.significator.id` in `.sea-pos-core`; cover/leave/loom empty drop zones render; crown/lay/cross absent. Shared `_enter_picker_phase()` helper polls for `data-phase='picker'` after the ~800ms seat-1C animation delay. - 4 ITs in new `MySeaPickerPhaseTemplateTest`: server-render contract for sig in core + cover/leave/loom classes + forsaken-positions-absent + picker entirely absent when user has no sig (4b gate precedence). Tests: 28/28 FT green across test_bill_my_sign + test_game_my_sea (~219s); 1041/1041 IT/UT green (53s). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:06:14 -04:00
class MySeaPickerPhaseTemplateTest(TestCase):
"""Sprint 5 iter 2 — picker-phase template render contract: the
three-card cross (sig in core + cover/leave/loom drop zones) is
server-rendered (hidden until JS swaps data-phase after FREE DRAW).
Crown / lay / cross from the gameroom's 6-position Celtic Cross are
deliberately forsaken in the solo flow."""
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="picker@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
def test_picker_renders_significator_in_core_cell(self):
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
# Sig card carries the user's significator id so iter 3's draw
# flow can target it for SPIN / FLIP / FYI without re-fetching.
self.assertIn('sea-pos-core', html)
self.assertIn('sea-sig-card', html)
self.assertIn(f'data-card-id="{self.target.id}"', html)
def test_picker_renders_cover_leave_loom_positions(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, "sea-pos-cover")
self.assertContains(response, "sea-pos-leave")
self.assertContains(response, "sea-pos-loom")
My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide. Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only. - **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys. - **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in. - **SCSS** (_gameboard.scss): - `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross. - `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells. - `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover. - `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side. **Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default): - FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering. - IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally). **Tests**: - 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements). - 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render. Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s. Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
def test_picker_renders_six_card_only_positions_for_spread_switch(self):
# Crown / lay / cross sit in the DOM unconditionally so iter 3's
# SPREAD dropdown can reveal them via CSS attribute swap (data-
# spread-shape="six-card") without re-rendering. Default 3-card
# spread hides them via `.my-sea-cross[data-spread-shape=
# "three-card"]` rules in _gameboard.scss — FT pins the hidden
# state visually.
My Sea picker phase: three-card cross (sig + cover/leave/loom) — Sprint 5 iter 2 of My Sea roadmap — TDD After the FREE DRAW click on iter 1's landing swaps `data-phase` to `picker`, the picker now renders a stripped Celtic Cross: user's saved significator pinned in `.sea-pos-core`, three drawn-card drop zones around it — cover (overlaid on sig), leave (left of core), loom (right of core). Crown / lay / cross from the gameroom's 6-position spread are deliberately forsaken (user-locked spec). DRY w. the gameroom sea-overlay: reuses `.sea-cards-col` + `.sea-cross` + `.sea-crucifix-cell` + `.sea-pos-*` + `.sea-card-slot--empty` + `.sea-sig-card` classes & their _card-deck.scss styling (1181-1331). Only divergence from the room: a `.my-sea-cross` modifier in `_gameboard.scss` overrides `grid-template-areas` from the room's `". crown . / leave core loom / . lay ."` 3×3 to a single-row `"leave core loom"` — drops the crown + lay rows since those positions are forsaken. Cover stays nested inside `.sea-pos-core` so the absolute-overlay rules from _card-deck.scss line 1310-1331 carry over for free. Picker bg = `rgba(var(--duoUser), 1)` on `.my-sea-page[data-phase="picker"]` — parallels `.my-sign-page[data-phase="picker"]` from _card-deck.scss line 704, so the landing→picker swap reads as a continuous surface (hex face → felt) like on /billboard/my-sign/. The sig card renders w. `data-card-id="{{ significator.id }}"` + `.fan-corner-rank` + `.fa-solid {suit-icon}` (mirrors the gameroom's `.sea-sig-card` minimal markup at `_sea_overlay.html` line 33-39). Full card-face / FYI / SPIN wiring deferred — iter 3 lands the form col + interactive draw flow. View context: `my_sea` now passes `significator` (FK pass-through) + `significator_reversed` so the template can render the corner rank + suit icon at render time without re-fetching. - 3 FTs in new `MySeaPickerPhaseTest`: sig card w. `data-card-id` matching `user.significator.id` in `.sea-pos-core`; cover/leave/loom empty drop zones render; crown/lay/cross absent. Shared `_enter_picker_phase()` helper polls for `data-phase='picker'` after the ~800ms seat-1C animation delay. - 4 ITs in new `MySeaPickerPhaseTemplateTest`: server-render contract for sig in core + cover/leave/loom classes + forsaken-positions-absent + picker entirely absent when user has no sig (4b gate precedence). Tests: 28/28 FT green across test_bill_my_sign + test_game_my_sea (~219s); 1041/1041 IT/UT green (53s). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:06:14 -04:00
response = self.client.get(reverse("my_sea"))
My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide. Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only. - **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys. - **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in. - **SCSS** (_gameboard.scss): - `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross. - `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells. - `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover. - `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side. **Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default): - FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering. - IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally). **Tests**: - 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements). - 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render. Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s. Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
self.assertContains(response, "sea-pos-crown")
self.assertContains(response, "sea-pos-lay")
self.assertContains(response, "sea-pos-cross")
My Sea picker phase: three-card cross (sig + cover/leave/loom) — Sprint 5 iter 2 of My Sea roadmap — TDD After the FREE DRAW click on iter 1's landing swaps `data-phase` to `picker`, the picker now renders a stripped Celtic Cross: user's saved significator pinned in `.sea-pos-core`, three drawn-card drop zones around it — cover (overlaid on sig), leave (left of core), loom (right of core). Crown / lay / cross from the gameroom's 6-position spread are deliberately forsaken (user-locked spec). DRY w. the gameroom sea-overlay: reuses `.sea-cards-col` + `.sea-cross` + `.sea-crucifix-cell` + `.sea-pos-*` + `.sea-card-slot--empty` + `.sea-sig-card` classes & their _card-deck.scss styling (1181-1331). Only divergence from the room: a `.my-sea-cross` modifier in `_gameboard.scss` overrides `grid-template-areas` from the room's `". crown . / leave core loom / . lay ."` 3×3 to a single-row `"leave core loom"` — drops the crown + lay rows since those positions are forsaken. Cover stays nested inside `.sea-pos-core` so the absolute-overlay rules from _card-deck.scss line 1310-1331 carry over for free. Picker bg = `rgba(var(--duoUser), 1)` on `.my-sea-page[data-phase="picker"]` — parallels `.my-sign-page[data-phase="picker"]` from _card-deck.scss line 704, so the landing→picker swap reads as a continuous surface (hex face → felt) like on /billboard/my-sign/. The sig card renders w. `data-card-id="{{ significator.id }}"` + `.fan-corner-rank` + `.fa-solid {suit-icon}` (mirrors the gameroom's `.sea-sig-card` minimal markup at `_sea_overlay.html` line 33-39). Full card-face / FYI / SPIN wiring deferred — iter 3 lands the form col + interactive draw flow. View context: `my_sea` now passes `significator` (FK pass-through) + `significator_reversed` so the template can render the corner rank + suit icon at render time without re-fetching. - 3 FTs in new `MySeaPickerPhaseTest`: sig card w. `data-card-id` matching `user.significator.id` in `.sea-pos-core`; cover/leave/loom empty drop zones render; crown/lay/cross absent. Shared `_enter_picker_phase()` helper polls for `data-phase='picker'` after the ~800ms seat-1C animation delay. - 4 ITs in new `MySeaPickerPhaseTemplateTest`: server-render contract for sig in core + cover/leave/loom classes + forsaken-positions-absent + picker entirely absent when user has no sig (4b gate precedence). Tests: 28/28 FT green across test_bill_my_sign + test_game_my_sea (~219s); 1041/1041 IT/UT green (53s). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:06:14 -04:00
def test_picker_not_rendered_when_user_has_no_sig(self):
# 4b gate wins; picker has no business rendering without a sig.
self.user.significator = None
self.user.save(update_fields=["significator"])
response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, "my-sea-picker")
My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide. Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only. - **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys. - **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in. - **SCSS** (_gameboard.scss): - `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross. - `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells. - `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover. - `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side. **Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default): - FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering. - IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally). **Tests**: - 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements). - 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render. Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s. Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
class MySeaSpreadFormTemplateTest(TestCase):
"""Sprint 5 iter 3 — form col + SPREAD dropdown structure + default-
spread context + cross's `data-spread-shape` attribute. Iter 3 spec
locks `Situation, Action, Outcome` as the default spread (a 3-card
variant); the 6 spreads sit under 2 section dividers (3-card / 6-
card)."""
SPREAD_OPTIONS = [
("past-present-future", "Past, Present, Future"),
("situation-action-outcome", "Situation, Action, Outcome"),
("mind-body-spirit", "Mind, Body, Spirit"),
("desire-obstacle-solution", "Desire, Obstacle, Solution"),
("waite-smith", "Celtic Cross, Waite-Smith"),
("escape-velocity", "Celtic Cross, Escape Velocity"),
]
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="spread@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
def test_context_default_spread_is_situation_action_outcome(self):
response = self.client.get(reverse("my_sea"))
self.assertEqual(
response.context["default_spread"], "situation-action-outcome",
)
def test_context_reversals_pct_defaults_to_25(self):
response = self.client.get(reverse("my_sea"))
self.assertEqual(response.context["reversals_pct"], 25)
def test_template_renders_all_six_spread_options(self):
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
for value, label in self.SPREAD_OPTIONS:
with self.subTest(spread=value):
self.assertIn(f'data-value="{value}"', html)
self.assertIn(label, html)
def test_template_renders_three_card_and_six_card_section_dividers(self):
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
self.assertEqual(html.count("sea-select-divider"), 2)
self.assertIn("3-card spreads", html)
self.assertIn("6-card spreads", html)
def test_template_marks_situation_action_outcome_aria_selected(self):
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
# The default option carries aria-selected="true"; the others false.
self.assertIn(
'data-value="situation-action-outcome" aria-selected="true"', html,
)
def test_cross_carries_initial_three_card_spread_shape(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'data-spread-shape="three-card"')
def test_form_col_renders_decks_lock_hand_del_and_reversal_hint(self):
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
self.assertIn("sea-deck-stack--gravity", html)
self.assertIn("sea-deck-stack--levity", html)
self.assertIn('id="id_sea_lock_hand"', html)
self.assertIn('id="id_sea_del"', html)
self.assertIn("sea-reversal-hint", html)
self.assertIn("25% reversals", html)