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