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

994 lines
42 KiB
Python
Raw Normal View History

import json as _json
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.applets.models import Applet
from apps.drama.models import GameEvent, Note, ScrollPosition, record
from apps.epic.models import Room
from apps.lyric.models import User
def _seed_billboard_applets():
for slug, name, cols, rows in [
("my-scrolls", "My Scrolls", 4, 3),
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD - lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud. - applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS. - billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor). - global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'. - new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty. - my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds). - my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts). - SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts. - 841 ITs + 5 my_buds/my_posts FTs green. 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-08 23:08:33 -04:00
("my-buds", "My Buds", 4, 3),
("most-recent-scroll", "Most Recent Scroll", 8, 6),
fix CARTE multi-seat Role-Select bug on navigate-away + back; My Sign applet rename **CARTE bug** (user-reported on iPhone): a CARTE gamer who contributed their deck to multiple gate slots could fill ≥1 role for ≥1 seat, navigate away (BYE → dashboard, CONT GAME → return, etc.), come back to the room — and the JS guard on .card-stack would wrongly fire "Equip card deck before Role select" + block further role picks, even though the deck was demonstrably in play on existing seats. Symmetric for the "stay in room during Role Select" variant the user thought we'd squashed before (the prior fix was 759ce8d for the multi-slot SELECT path, but the room VIEW context never got the same treatment) ; **root cause**: `select_role()` at epic/views.py:619-621 clears `user.equipped_deck` after the first role pick ("deck committed to room"). The room view's role-select context at epic/views.py:286 then passes `equipped_deck_id = user.equipped_deck_id` to the template — which is now None — and the template renders `data-equipped-deck=""` → JS guard at role-select.js:165 sees the empty string and fires the "no deck" warning. The deck IS in play; the context just isn't recognizing seat-level deck assignment as a deck source ; **fix** (epic/views.py:286ish): when `user.equipped_deck_id` is None, fall back to the deck_variant of any of the user's seats in this room (order_by slot_number for determinism). The guard now sees a non-empty id and the fan opens. Storage-side unchanged — seat.deck_variant remains the canonical "this deck is in play on this seat" signal, and the user's deck-third contribution per role (PC=levity brands+crowns / NC=levity trumps / SC=levity grails+blades / AC=gravity grails+blades / EC=gravity trumps / BC=gravity brands+crowns) flows from existing `select_role` logic that inherits deck_variant from the first seat ; **TDD trail** — 2 new ITs in `SelectRoleMultiSeatTest` (apps.epic.tests.integrated.test_views): T1 pins the context (`response.context["equipped_deck_id"]` equals the existing seat's deck_variant_id after `user.equipped_deck` clears); T2 pins the template (rendered `data-equipped-deck="<id>"` not `""`). Initial reds — `None != 2` + `data-equipped-deck=""` substring assertion. Fix lands both green ; **bundled: My Sign applet rename** — user clarified naming convention 2026-05-18: **applets** use the "My X" prefix (My Sign, My Sea, My Posts), **standalone pages** use the "Game/Dash/Bill X" prefix (Game Sign page, Game Sea page, Game Kit page). Sprint 4a's initial migration set the applet name to "Game Sign" — corrected after the user saw the gear-menu toggle list reading the wrong word. Applet template header link "Game Sign" → "My Sign" (user-edited); migration 0010 added to update the Applet row's `name` in already-migrated DBs (dev + staging); applets/0009 frontmatter + defaults updated to "My Sign" in case of a fresh migrate-from-zero; test seed helpers in billboard test_views.py + functional_tests/test_bill_my_sign.py updated to "My Sign". Slug stays `my-sign` (URL + selectors stable) ; **bundled: rootvars.scss** — user-modified mid-session (pre-staged) ; 1022 IT/UT green in 46s — no regressions; 4 ITs in SelectRoleMultiSeatTest green (2 pre-existing CARTE multi-seat ITs + 2 new return-trip context ITs) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:18:32 -04:00
("my-sign", "My Sign", 4, 6),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
)
class BillboardViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@billboard.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_uses_billboard_template(self):
response = self.client.get("/billboard/")
self.assertTemplateUsed(response, "apps/billboard/billboard.html")
def test_passes_applets_context(self):
response = self.client.get("/billboard/")
self.assertIn("applets", response.context)
slugs = [e["applet"].slug for e in response.context["applets"]]
self.assertIn("my-scrolls", slugs)
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD - lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud. - applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS. - billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor). - global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'. - new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty. - my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds). - my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts). - SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts. - 841 ITs + 5 my_buds/my_posts FTs green. 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-08 23:08:33 -04:00
self.assertIn("my-buds", slugs)
self.assertIn("most-recent-scroll", slugs)
def test_passes_my_rooms_context(self):
room = Room.objects.create(name="Test Room", owner=self.user)
response = self.client.get("/billboard/")
self.assertIn(room, response.context["my_rooms"])
def test_passes_recent_room_and_events(self):
room = Room.objects.create(name="Test Room", 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("/billboard/")
self.assertEqual(response.context["recent_room"], room)
self.assertEqual(len(response.context["recent_events"]), 1)
def test_recent_events_capped_at_36(self):
room = Room.objects.create(name="Test Room", owner=self.user)
for i in range(40):
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("/billboard/")
self.assertEqual(len(response.context["recent_events"]), 36)
def test_recent_events_in_chronological_order(self):
room = Room.objects.create(name="Test Room", owner=self.user)
for _ in range(3):
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("/billboard/")
events = response.context["recent_events"]
timestamps = [e.timestamp for e in events]
self.assertEqual(timestamps, sorted(timestamps))
def test_recent_room_is_none_when_no_events(self):
response = self.client.get("/billboard/")
self.assertIsNone(response.context["recent_room"])
self.assertEqual(list(response.context["recent_events"]), [])
applet feed unification — My Buds + My Notes drop the [Feature forthcoming] / empty placeholders for live top-3 feeds, mirroring the long-standing My Posts pattern; all five in-grid list applets (My Posts / My Buds / My Notes / My Scrolls / My Games) now route their <ul> through a single shared partial `_applet-grid-list.html` (newly extracted) so item rendering + empty-state row + scroll-buffer all live in one place — `_applet-list-shell.html` (the dedicated billbuds/billposts page shell) now internally includes the same grid-list partial for its inner <ul>, so the dedicated-page and in-grid lists share the same skeleton; new per-applet item partials `_my_buds_applet_item.html` (mirrors `_my_buds_item.html` w. data-bud-id + display_name), `_my_notes_item.html` (links to billboard:my_notes; uses display_name), `_my_posts_applet_item.html` (Post link + title), `_my_scrolls_item.html` (Room link to billboard:scroll), `_my_games_item.html` (Room link to epic:gatekeeper); view-side `_billboard_context` gains `_recent_buds(user)` — sorts the User.buds auto-through table by `-id` so newest-added-first w.o. an explicit through model w. timestamps (manage `[r.to_user for r in rows]`) — + `_recent_notes(user)` (`user.notes.order_by('-earned_at')[:limit]`); same two helpers threaded into `new_post`'s GET-with-form-errors branch (line 270-274) so the rerender keeps the new applet content visible; 7 ITs added to BillboardViewTest covering recent_buds ordering / cap / empty + recent_notes ordering / cap / cross-user isolation / empty; SCSS — `.applet-list / .applet-list-entry / .applet-list-buffer` lifted from `.applet-list-page .applet-scroll` scope to top level so they apply in both surfaces; in-grid applets get `display: flex; flex-direction: column; .applet-list { flex: 1 }` so the list scrolls within the applet box; `#id_applet_my_games` ul-centring + `.scroll-list` + `#id_applet_notes h2 { writing-mode: vertical-rl ... }` overrides removed (centring was an empty-state-only behaviour, scroll-list + vertical-rl redundant w. the new shared rule + the %applet-box `> h2` rule); My Games items now left-aligned by default; empty-state row recovers the centred-italic-dim treatment via `.applet-list-entry--empty { flex: 1; display: flex; align-items: center; justify-content: center; opacity: 0.6; font-style: italic }` + `.applet-list:has(> .applet-list-entry--empty) { display: flex; flex-direction: column }` — so "No buds yet" / "No notes yet" / "No games yet" / "No scrolls yet" / "No posts yet" all centre in their applet aperture, reverting to the left-aligned stack the moment a real item lands; Most Recent Scroll's outer empty `<p><small>No recent activity.</small></p>` adopts the same `.applet-list-entry .applet-list-entry--empty` classes (section is already flex-column from existing rule) so it picks up the unified centred-italic-dim treatment pipeline fix — `_post_gear.html` (commit 6a7464e) gated the NVM target on `{% url 'billboard:my_posts' user_id=request.user.id %}`, which exploded w. NoReverseMatch when an anonymous user (Percival ch.18 anonymous-post lab — ownerless `Post.objects.create()`) hit view_post (which has no @login_required); whole gear-include now wrapped in `{% if request.user.is_authenticated %}` since anonymous viewers can't DEL/BYE/back-to-my-posts anyway; AnonymousPostViewerTest pins the 200-render + gear-absence contract so future ownerless-post regressions surface in ITs (pipeline run #298 fixed) — 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 22:48:32 -04:00
# ── recent_buds + recent_notes (applet feed) ──────────────────────
# Mirrors the recent_posts pattern: each in-grid applet now lists
# its own most-recent items (capped at 3) plus an empty-state row.
def test_passes_recent_buds_context(self):
first = User.objects.create(email="first-bud@test.io")
second = User.objects.create(email="second-bud@test.io")
self.user.buds.add(first)
self.user.buds.add(second)
response = self.client.get("/billboard/")
# Most-recently-added bud is first
self.assertEqual(list(response.context["recent_buds"]), [second, first])
def test_recent_buds_capped_at_3(self):
added = [
User.objects.create(email=f"bud{i}@test.io") for i in range(5)
]
for u in added:
self.user.buds.add(u)
response = self.client.get("/billboard/")
recent = list(response.context["recent_buds"])
self.assertEqual(len(recent), 3)
# Newest three in newest-first order
self.assertEqual(recent, list(reversed(added))[:3])
def test_recent_buds_empty_when_no_buds(self):
response = self.client.get("/billboard/")
self.assertEqual(list(response.context["recent_buds"]), [])
def test_passes_recent_notes_context(self):
Note.objects.create(
user=self.user, slug="stargazer",
earned_at=timezone.now() - timezone.timedelta(days=2),
)
Note.objects.create(
user=self.user, slug="super-schizo",
earned_at=timezone.now(),
)
response = self.client.get("/billboard/")
slugs = [n.slug for n in response.context["recent_notes"]]
# Most-recently-earned first
self.assertEqual(slugs, ["super-schizo", "stargazer"])
def test_recent_notes_capped_at_3(self):
slugs = ["stargazer", "super-schizo", "super-nomad", "ladidah", "doodah"]
base = timezone.now()
for i, slug in enumerate(slugs):
Note.objects.create(
user=self.user, slug=slug,
earned_at=base - timezone.timedelta(hours=i),
)
response = self.client.get("/billboard/")
names = [n.slug for n in response.context["recent_notes"]]
self.assertEqual(len(names), 3)
# Newest-first; oldest two trimmed
self.assertEqual(names, slugs[:3])
def test_recent_notes_excludes_other_users(self):
other = User.objects.create(email="other-note@test.io")
Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
response = self.client.get("/billboard/")
self.assertEqual(list(response.context["recent_notes"]), [])
def test_recent_notes_empty_when_no_notes(self):
response = self.client.get("/billboard/")
self.assertEqual(list(response.context["recent_notes"]), [])
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
# ── 3-column applet row contract ──────────────────────────────────
# Each applet's list item renders `<title> | <body> | <ts>` —
# title + body wrapped in `.row-title` / `.row-body`, timestamp
# in a `<time class="row-ts">` element. The body shows the
# most-recent activity (post line / room event / note description /
# bud's active title). Right column carries `relative_ts` text.
def test_my_posts_applet_row_shows_latest_line_and_ts(self):
from apps.billboard.models import Line, Post
post = Post.objects.create(
owner=self.user, kind=Post.KIND_USER_POST, title="StampedPost",
)
Line.objects.create(post=post, text="first line text", author=self.user)
Line.objects.create(post=post, text="latest line text", author=self.user)
response = self.client.get("/billboard/")
body = response.content.decode()
# Title preserved
self.assertIn("StampedPost", body)
# Latest line surfaces in the row body, not the older line
self.assertIn("latest line text", body)
# A <time> ts element renders on the row
self.assertRegex(body, r'<time[^>]+class="[^"]*row-ts')
def test_my_buds_applet_row_shows_active_title_and_since_ts(self):
bud = User.objects.create(email="alice@test.io", username="alice")
note = Note.objects.create(
user=bud, slug="stargazer", earned_at=timezone.now(),
)
bud.active_title = note
bud.save()
self.user.buds.add(bud)
response = self.client.get("/billboard/")
body = response.content.decode()
# Bud handle shows; the active title surfaces as the row body;
# the ts column carries a "since " prefix unique to buds
self.assertIn("alice", body)
self.assertIn("the Stargazer", body)
self.assertRegex(body, r'class="[^"]*row-ts[^"]*"[^>]*>\s*since\s+')
def test_my_buds_applet_row_no_active_title_no_body_or_ts(self):
bud = User.objects.create(email="alice@test.io", username="alice")
# No active_title donned
self.user.buds.add(bud)
response = self.client.get("/billboard/")
body = response.content.decode()
self.assertIn("alice", body)
# Without an active title we don't render a "since" line
self.assertNotRegex(body, r'class="[^"]*row-ts[^"]*"[^>]*>\s*since\s+')
def test_my_notes_applet_row_shows_description_and_earned_at(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
response = self.client.get("/billboard/")
body = response.content.decode()
self.assertIn("Stargazer", body)
# Description prefix from _NOTE_META — full string is 40 chars and
# truncates to 32+"..." via the `truncate_title` filter, so assert
# against the head only ("You saved your first personal" is 30 chars,
# comfortably inside the truncation window).
self.assertIn("You saved your first personal", body)
# ts column present
self.assertRegex(body, r'<time[^>]+class="[^"]*row-ts')
def test_my_scrolls_applet_row_shows_latest_event_and_ts(self):
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("/billboard/")
body = response.content.decode()
self.assertIn("StampedRoom", body)
# The slot-fill prose mentions the token; we just check the room's
# scroll row carries a <time> + some body content (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_scrolls_applet_row_body_includes_actor_display_name(self):
"""Latest event prose w.o. the actor name is meaningless — `deposits
a Carte Blanche` should read `<actor> deposits a Carte Blanche`.
scroll.html renders actor via a separate `<strong>` adjacent to
prose; the applet row has a single body cell so we concatenate.
Scoped to the `.row-body` span (vs. a loose substring match) so
we don't pass on the Most Recent Scroll applet's actor render
which renders the same actor too, separately."""
actor = User.objects.create(email="stuart@test.io", username="stuart")
room = Room.objects.create(name="ScrollRoom", owner=self.user)
record(
room, GameEvent.SLOT_FILLED, actor=actor,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/billboard/")
body = response.content.decode()
# The `.row-body` cell inside the My Scrolls applet must carry
# both the actor handle AND the event prose, in that order.
self.assertRegex(
body,
r'<span class="row-body">[^<]*stuart[^<]*deposits a Coin-on-a-String',
)
def test_my_scrolls_applet_row_body_no_actor_prefix_for_welcome(self):
"""Welcome events (actor=None) must not render an empty `<strong></strong>`
prefix before the prose same shape the scroll.html template adopted."""
room = Room.objects.create(name="GreenroomTwo", owner=self.user)
record(room, GameEvent.ROOM_CREATED)
response = self.client.get("/billboard/")
body = response.content.decode()
self.assertIn("Welcome to GreenroomTwo!", body)
# No empty <strong> before the welcome line
self.assertNotRegex(
body, r'<strong>\s*</strong>\s*Welcome to GreenroomTwo!'
)
class SaveScrollPositionViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="reader@test.io")
self.client.force_login(self.user)
self.room = Room.objects.create(name="Test Room", owner=self.user)
self.url = f"/billboard/room/{self.room.id}/scroll-position/"
def test_get_returns_405(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
class ToggleBillboardAppletsTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@toggle.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_toggle_hides_unchecked_applets(self):
response = self.client.post(
reverse("billboard:toggle_applets"),
{"applets": ["my-scrolls"]},
)
self.assertEqual(response.status_code, 302)
from apps.applets.models import UserApplet
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD - lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud. - applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS. - billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor). - global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'. - new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty. - my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds). - my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts). - SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts. - 841 ITs + 5 my_buds/my_posts FTs green. 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-08 23:08:33 -04:00
contacts = Applet.objects.get(slug="my-buds")
ua = UserApplet.objects.get(user=self.user, applet=contacts)
self.assertFalse(ua.visible)
def test_toggle_returns_partial_on_htmx(self):
response = self.client.post(
reverse("billboard:toggle_applets"),
{"applets": ["my-scrolls"]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/billboard/_partials/_applets.html")
def test_htmx_toggle_response_renders_most_recent_scroll_with_real_events(self):
# Seed a room + event so Most Recent Scroll renders prose, not the empty fallback.
room = Room.objects.create(name="Sound Chamber", 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.post(
reverse("billboard:toggle_applets"),
{"applets": [
"my-scrolls",
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD - lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud. - applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS. - billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor). - global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'. - new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty. - my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds). - my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts). - SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts. - 841 ITs + 5 my_buds/my_posts FTs green. 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-08 23:08:33 -04:00
"my-buds",
"most-recent-scroll",
]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Coin-on-a-String")
# And My Scrolls renders the room name (needs my_rooms in context).
self.assertContains(response, "Sound Chamber")
def test_htmx_toggle_response_has_single_applet_menu_div(self):
# The response is hx-swapped into the page; if it contains both the menu
# div and the applets-container div, the original menu remains and the
# next gear-click resurrects stale form state. Response must contain the
# menu exactly once (the wrapper) — never two siblings of the same id.
response = self.client.post(
reverse("billboard:toggle_applets"),
{"applets": ["my-scrolls"]},
HTTP_HX_REQUEST="true",
)
body = response.content.decode("utf-8")
self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1)
def test_second_toggle_preserves_prior_hidden_state(self):
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD - lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud. - applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS. - billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor). - global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'. - new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty. - my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds). - my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts). - SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts. - 841 ITs + 5 my_buds/my_posts FTs green. 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-08 23:08:33 -04:00
# First toggle: hide My Buds only.
self.client.post(
reverse("billboard:toggle_applets"),
{"applets": [
"new-post", "my-posts",
"my-scrolls",
"most-recent-scroll",
]},
HTTP_HX_REQUEST="true",
)
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD - lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud. - applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS. - billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor). - global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'. - new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty. - my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds). - my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts). - SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts. - 841 ITs + 5 my_buds/my_posts FTs green. 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-08 23:08:33 -04:00
# Second toggle: hide Most Recent Scroll additionally — My Buds must stay hidden.
self.client.post(
reverse("billboard:toggle_applets"),
{"applets": [
"new-post", "my-posts",
"my-scrolls",
]},
HTTP_HX_REQUEST="true",
)
from apps.applets.models import UserApplet
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD - lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud. - applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS. - billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor). - global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'. - new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty. - my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds). - my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts). - SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts. - 841 ITs + 5 my_buds/my_posts FTs green. 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-08 23:08:33 -04:00
contacts = Applet.objects.get(slug="my-buds")
most_recent_scroll = Applet.objects.get(slug="most-recent-scroll")
self.assertFalse(
UserApplet.objects.get(user=self.user, applet=contacts).visible
)
self.assertFalse(
UserApplet.objects.get(user=self.user, applet=most_recent_scroll).visible
)
class BillscrollViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@billscroll.io")
self.client.force_login(self.user)
self.room = Room.objects.create(name="Test Room", owner=self.user)
record(
self.room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
def test_uses_scroll_template(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertTemplateUsed(response, "apps/billboard/scroll.html")
def test_passes_events_context(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertIn("events", response.context)
self.assertEqual(response.context["events"].count(), 1)
def test_passes_page_class_billscroll(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["page_class"], "page-billscroll")
def test_passes_scroll_position_zero_when_none_saved(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["scroll_position"], 0)
def test_passes_saved_scroll_position_in_context(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=250)
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["scroll_position"], 250)
def test_scroll_renders_event_body_and_time_columns(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertContains(response, 'class="drama-event-body"')
self.assertContains(response, 'class="drama-event-time"')
class NotePageViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="recog@test.io")
self.client.force_login(self.user)
def test_requires_login(self):
self.client.logout()
response = self.client.get("/billboard/my-notes/")
self.assertEqual(response.status_code, 302)
def test_returns_200(self):
response = self.client.get("/billboard/my-notes/")
self.assertEqual(response.status_code, 200)
def test_uses_note_page_template(self):
response = self.client.get("/billboard/my-notes/")
self.assertTemplateUsed(response, "apps/billboard/my_notes.html")
def test_passes_notes_in_context(self):
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertIn(recog, response.context["notes"])
def test_excludes_other_users_notes(self):
other = User.objects.create(email="other@test.io")
Note.objects.create(
user=other, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertEqual(list(response.context["notes"]), [])
def test_renders_recog_list_and_items(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertContains(response, 'class="note-list"')
self.assertContains(response, 'class="note-item"')
def test_renders_recog_item_title_description_image_box(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertContains(response, 'class="note-item__title"')
self.assertContains(response, 'class="note-item__description"')
self.assertContains(response, 'class="note-item__image-box"')
def test_palette_modal_renders_swatch_labels(self):
"""Each palette option in the swatch modal should display its human-readable
label next to the swatch body so the user knows what they are choosing."""
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertContains(response, 'class="note-swatch-label"')
self.assertContains(response, "Bardo")
self.assertContains(response, "Sheol")
class NoteSetPaletteViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="setpal@test.io")
self.client.force_login(self.user)
self.note = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
self.url = "/billboard/note/stargazer/set-palette"
def test_requires_login(self):
self.client.logout()
response = self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 302)
def test_sets_palette_on_note(self):
self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.note.refresh_from_db()
self.assertEqual(self.note.palette, "palette-bardo")
def test_returns_200_with_ok(self):
response = self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True})
def test_returns_404_for_slug_user_does_not_own(self):
response = self.client.post(
"/billboard/note/schizo/set-palette",
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
def test_also_saves_user_palette(self):
"""note_set_palette must persist the choice to user.palette so the
palette survives page navigation (sitewide commitment)."""
self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.user.refresh_from_db()
self.assertEqual(self.user.palette, "palette-bardo")
class NoteEquipTitleViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="don@test.io")
self.client.force_login(self.user)
self.note = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
def test_don_sets_active_title(self):
self.client.post("/billboard/note/stargazer/don")
self.user.refresh_from_db()
self.assertEqual(self.user.active_title, self.note)
def test_doff_clears_active_title(self):
self.user.active_title = self.note
self.user.save(update_fields=["active_title"])
self.client.post("/billboard/note/stargazer/doff")
self.user.refresh_from_db()
self.assertIsNone(self.user.active_title)
def test_don_returns_200_with_title(self):
response = self.client.post("/billboard/note/stargazer/don")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["title"], "Stargazer")
def test_doff_returns_200(self):
response = self.client.post("/billboard/note/stargazer/doff")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["ok"])
self.assertEqual(data["greeting"], "Welcome,")
self.assertEqual(data["title"], "Earthman")
def test_don_requires_login(self):
self.client.logout()
response = self.client.post("/billboard/note/stargazer/don")
self.assertEqual(response.status_code, 302)
def test_don_returns_404_for_unowned_note(self):
other = User.objects.create(email="other@test.io")
Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
self.client.logout()
self.client.force_login(other)
response = self.client.post("/billboard/note/stargazer/don")
# other user's own note — should work
self.assertEqual(response.status_code, 200)
class SaveScrollPositionTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@savescroll.io")
self.client.force_login(self.user)
self.room = Room.objects.create(name="Test Room", owner=self.user)
def test_post_saves_scroll_position(self):
self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 300},
)
sp = ScrollPosition.objects.get(user=self.user, room=self.room)
self.assertEqual(sp.position, 300)
def test_post_updates_existing_position(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 450},
)
self.assertEqual(
ScrollPosition.objects.get(user=self.user, room=self.room).position, 450
)
def test_post_returns_204(self):
response = self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 100},
)
self.assertEqual(response.status_code, 204)
def test_post_requires_login(self):
self.client.logout()
response = self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 100},
)
self.assertEqual(response.status_code, 302)
class PostLineRelativeTimestampTest(TestCase):
"""post.html mirrors scroll.html's bucketed `relative_ts` time rendering:
same-day Lines show a time; older ones collapse to weekday / month-day /
month-day-year. Bypasses `auto_now_add` with a queryset .update() so the
test can backdate Lines."""
def setUp(self):
self.owner = User.objects.create(email="owner@post-ts.io", username="owner")
self.client.force_login(self.owner)
from apps.billboard.models import Line, Post
self.Line = Line
self.post = Post.objects.create(owner=self.owner, title="Stamp")
def _backdate(self, line, **delta):
from apps.billboard.models import Line
Line.objects.filter(pk=line.pk).update(
created_at=timezone.now() - timezone.timedelta(**delta)
)
def test_recent_line_renders_clock_time(self):
self.Line.objects.create(post=self.post, text="now", author=self.owner)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
self.assertRegex(
response.content.decode(),
r'class="post-line-time"[^>]*>\s*\d+:\d{2}\s*[ap]\.m\.\s*<',
)
def test_two_day_old_line_renders_weekday(self):
line = self.Line.objects.create(post=self.post, text="old", author=self.owner)
self._backdate(line, days=2)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
import re
m = re.search(
r'class="post-line-time"[^>]*>\s*(\w+)\s*<', response.content.decode()
)
self.assertIsNotNone(m, "no .post-line-time cell rendered")
self.assertIn(m.group(1), {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"})
def test_thirty_day_old_line_renders_day_month(self):
line = self.Line.objects.create(post=self.post, text="oldr", author=self.owner)
self._backdate(line, days=30)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
self.assertRegex(
response.content.decode(),
r'class="post-line-time"[^>]*>\s*\d{2}\s\w{3}\s*<',
)
post.html: gear-btn + #id_post_menu (NVM / DEL / BYE) mirror room.html's #id_room_menu — all Posts get the gear w. NVM (→ billboard:my_posts); user-Posts (kind=USER_POST / SHARE_INVITE) additionally surface DEL for the author (POST → billboard:delete_post → hard-deletes the Post; cascades Lines via FK + clears shared_with M2M) and BYE for invitees (POST → billboard:abandon_post → removes request.user from post.shared_with; owner + other invitees keep the thread); admin-Posts (kind=NOTE_UNLOCK) intentionally render gear w. NVM only since the system thread isn't user-owned (defence-in-depth: both delete_post + abandon_post no-op on NOTE_UNLOCK so a forged POST can't bypass the menu's branch); `_post_gear.html` partial gates DEL/BYE on `viewer_is_owner` (set by view_post since the buds sprint) + post.kind, then includes the shared `apps/applets/_partials/_gear.html` btn; styling rides the existing applets.scss page-level pattern — `.post-page` joins `.billboard-page / .room-page / .dashboard-page / .wallet-page / .gameboard-page / .billscroll-page` in the `> .gear-btn { position: fixed; bottom: 4.2rem; right: 0.5rem }` rule (and the landscape footer-sidebar centred variant), `#id_post_menu` joins the `%applet-menu` extension list + the page-level fixed-menu rule (`bottom: 6.6rem; right: 1rem`); 5 FTs in test_bill_post_gear.py (owner DEL flow, invitee BYE flow, 3 menu-shape assertions for owner/invitee/admin) + 11 ITs across DeletePostViewTest + AbandonPostViewTest (302 redirect target, side effect, GET-is-no-op, non-owner / non-invitee / NOTE_UNLOCK protection) — 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 22:26:12 -04:00
class DeletePostViewTest(TestCase):
"""billboard:delete_post — owner can hard-delete; non-owners are no-op;
note_unlock Posts are protected (defence-in-depth alongside the menu
branch that doesn't render DEL on admin-Posts)."""
def setUp(self):
from apps.billboard.models import Line, Post
self.Post = Post
self.owner = User.objects.create(email="del-owner@test.io")
self.other = User.objects.create(email="del-other@test.io")
self.post = Post.objects.create(
owner=self.owner, kind=Post.KIND_USER_POST, title="X",
)
Line.objects.create(post=self.post, text="x", author=self.owner)
def test_owner_post_redirects_to_my_posts(self):
self.client.force_login(self.owner)
response = self.client.post(
reverse("billboard:delete_post", args=[self.post.id])
)
self.assertRedirects(
response,
reverse("billboard:my_posts", args=[self.owner.id]),
fetch_redirect_response=False,
)
def test_owner_post_deletes_post(self):
self.client.force_login(self.owner)
self.client.post(reverse("billboard:delete_post", args=[self.post.id]))
self.assertFalse(self.Post.objects.filter(id=self.post.id).exists())
def test_non_owner_cannot_delete(self):
self.client.force_login(self.other)
self.client.post(reverse("billboard:delete_post", args=[self.post.id]))
self.assertTrue(self.Post.objects.filter(id=self.post.id).exists())
def test_get_does_not_delete(self):
self.client.force_login(self.owner)
self.client.get(reverse("billboard:delete_post", args=[self.post.id]))
self.assertTrue(self.Post.objects.filter(id=self.post.id).exists())
def test_note_unlock_post_is_protected(self):
# Even the owner can't DEL a system thread — the gear menu doesn't
# render DEL for note_unlock, but the view is hardened in case the
# POST is forged.
admin_post = self.Post.objects.create(
owner=self.owner, kind=self.Post.KIND_NOTE_UNLOCK, title="Notes",
)
self.client.force_login(self.owner)
self.client.post(reverse("billboard:delete_post", args=[admin_post.id]))
self.assertTrue(self.Post.objects.filter(id=admin_post.id).exists())
class AbandonPostViewTest(TestCase):
"""billboard:abandon_post — invitee removes themselves from
post.shared_with; owner unaffected; other invitees unaffected; admin
Posts protected from BYE."""
def setUp(self):
from apps.billboard.models import Line, Post
self.Post = Post
self.owner = User.objects.create(email="abandon-owner@test.io")
self.invitee = User.objects.create(email="abandon-invitee@test.io")
self.other = User.objects.create(email="abandon-other@test.io")
self.post = Post.objects.create(
owner=self.owner, kind=Post.KIND_USER_POST, title="Shared",
)
Line.objects.create(post=self.post, text="x", author=self.owner)
self.post.shared_with.add(self.invitee, self.other)
def test_invitee_redirects_to_my_posts(self):
self.client.force_login(self.invitee)
response = self.client.post(
reverse("billboard:abandon_post", args=[self.post.id])
)
self.assertRedirects(
response,
reverse("billboard:my_posts", args=[self.invitee.id]),
fetch_redirect_response=False,
)
def test_invitee_is_removed_from_shared_with(self):
self.client.force_login(self.invitee)
self.client.post(reverse("billboard:abandon_post", args=[self.post.id]))
self.post.refresh_from_db()
self.assertNotIn(self.invitee, self.post.shared_with.all())
def test_post_survives_invitee_abandonment(self):
self.client.force_login(self.invitee)
self.client.post(reverse("billboard:abandon_post", args=[self.post.id]))
self.post.refresh_from_db()
self.assertEqual(self.post.owner, self.owner)
self.assertIn(self.other, self.post.shared_with.all())
def test_get_does_not_remove(self):
self.client.force_login(self.invitee)
self.client.get(reverse("billboard:abandon_post", args=[self.post.id]))
self.post.refresh_from_db()
self.assertIn(self.invitee, self.post.shared_with.all())
def test_non_invitee_post_is_no_op(self):
random_user = User.objects.create(email="random@test.io")
self.client.force_login(random_user)
self.client.post(reverse("billboard:abandon_post", args=[self.post.id]))
self.post.refresh_from_db()
self.assertIn(self.invitee, self.post.shared_with.all())
self.assertIn(self.other, self.post.shared_with.all())
def test_note_unlock_post_is_protected(self):
# Admin Posts have no recipients to begin with, but harden the view
# so a forged BYE can't strip shared_with anyway.
admin_post = self.Post.objects.create(
owner=self.owner, kind=self.Post.KIND_NOTE_UNLOCK, title="Notes",
)
admin_post.shared_with.add(self.invitee)
self.client.force_login(self.invitee)
self.client.post(reverse("billboard:abandon_post", args=[admin_post.id]))
admin_post.refresh_from_db()
self.assertIn(self.invitee, admin_post.shared_with.all())
applet feed unification — My Buds + My Notes drop the [Feature forthcoming] / empty placeholders for live top-3 feeds, mirroring the long-standing My Posts pattern; all five in-grid list applets (My Posts / My Buds / My Notes / My Scrolls / My Games) now route their <ul> through a single shared partial `_applet-grid-list.html` (newly extracted) so item rendering + empty-state row + scroll-buffer all live in one place — `_applet-list-shell.html` (the dedicated billbuds/billposts page shell) now internally includes the same grid-list partial for its inner <ul>, so the dedicated-page and in-grid lists share the same skeleton; new per-applet item partials `_my_buds_applet_item.html` (mirrors `_my_buds_item.html` w. data-bud-id + display_name), `_my_notes_item.html` (links to billboard:my_notes; uses display_name), `_my_posts_applet_item.html` (Post link + title), `_my_scrolls_item.html` (Room link to billboard:scroll), `_my_games_item.html` (Room link to epic:gatekeeper); view-side `_billboard_context` gains `_recent_buds(user)` — sorts the User.buds auto-through table by `-id` so newest-added-first w.o. an explicit through model w. timestamps (manage `[r.to_user for r in rows]`) — + `_recent_notes(user)` (`user.notes.order_by('-earned_at')[:limit]`); same two helpers threaded into `new_post`'s GET-with-form-errors branch (line 270-274) so the rerender keeps the new applet content visible; 7 ITs added to BillboardViewTest covering recent_buds ordering / cap / empty + recent_notes ordering / cap / cross-user isolation / empty; SCSS — `.applet-list / .applet-list-entry / .applet-list-buffer` lifted from `.applet-list-page .applet-scroll` scope to top level so they apply in both surfaces; in-grid applets get `display: flex; flex-direction: column; .applet-list { flex: 1 }` so the list scrolls within the applet box; `#id_applet_my_games` ul-centring + `.scroll-list` + `#id_applet_notes h2 { writing-mode: vertical-rl ... }` overrides removed (centring was an empty-state-only behaviour, scroll-list + vertical-rl redundant w. the new shared rule + the %applet-box `> h2` rule); My Games items now left-aligned by default; empty-state row recovers the centred-italic-dim treatment via `.applet-list-entry--empty { flex: 1; display: flex; align-items: center; justify-content: center; opacity: 0.6; font-style: italic }` + `.applet-list:has(> .applet-list-entry--empty) { display: flex; flex-direction: column }` — so "No buds yet" / "No notes yet" / "No games yet" / "No scrolls yet" / "No posts yet" all centre in their applet aperture, reverting to the left-aligned stack the moment a real item lands; Most Recent Scroll's outer empty `<p><small>No recent activity.</small></p>` adopts the same `.applet-list-entry .applet-list-entry--empty` classes (section is already flex-column from existing rule) so it picks up the unified centred-italic-dim treatment pipeline fix — `_post_gear.html` (commit 6a7464e) gated the NVM target on `{% url 'billboard:my_posts' user_id=request.user.id %}`, which exploded w. NoReverseMatch when an anonymous user (Percival ch.18 anonymous-post lab — ownerless `Post.objects.create()`) hit view_post (which has no @login_required); whole gear-include now wrapped in `{% if request.user.is_authenticated %}` since anonymous viewers can't DEL/BYE/back-to-my-posts anyway; AnonymousPostViewerTest pins the 200-render + gear-absence contract so future ownerless-post regressions surface in ITs (pipeline run #298 fixed) — 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 22:48:32 -04:00
class AnonymousPostViewerTest(TestCase):
"""view_post has no @login_required (Percival ch.18 anonymous-post lab —
ownerless Posts are viewable by anyone). The gear menu's NVM target
reverses `my_posts user_id=request.user.id`, which previously exploded
w. NoReverseMatch when request.user.id was None (anonymous user).
Gear menu must be gated on is_authenticated; the post body still renders
for anonymous viewers of ownerless posts."""
def setUp(self):
from apps.billboard.models import Post
# Ownerless Post — matches the Percival anonymous-share contract
# exercised by apps.dashboard.tests.integrated.test_views.SharePostTest.
# No Line needed; the empty post still exercises the template render.
self.post = Post.objects.create(title="Public-ish")
def test_anonymous_can_view_ownerless_post_without_500(self):
response = self.client.get(
reverse("billboard:view_post", args=[self.post.id])
)
self.assertEqual(response.status_code, 200)
def test_anonymous_gets_no_gear_menu(self):
response = self.client.get(
reverse("billboard:view_post", args=[self.post.id])
)
self.assertNotIn(b"id_post_menu", response.content)
Game Sign picker @ /billboard/my-sign/ + billboard applet — Sprint 4a of My Sea roadmap — TDD User scope (per design conv this session): split the room's sig-select responsibility off into a standalone billboard-context "My Significator" applet — branded "Game Sign" on the surface. Same 18-card pile as room sig-select (16 middle arcana + Major 0 & 1 filtered by Note unlocks); polarity collapses to a single FLIP choice (the FLIP btn in the picker carousel toggles User.significator_reversed). Selection persists globally on the User model + propagates to the billboard's Game Sign applet ; **naming convention locked**: "significator" stays at storage (User.significator FK + User.significator_reversed) + room sig-select context (DRY w. existing template/JS); "Sign" / "Game Sign" is the billboard-surface branding (file my_sign.html, URL /billboard/my-sign/, URL names my_sign + save_sign, applet name "Game Sign", page wordmark "Game Sign", btn label SAVE SIGN). Action URLs don't carry a trailing slash per project convention (/billboard/my-sign/save vs the page's /billboard/my-sign/) ; **schema**: User gains 2 fields — `significator: FK → epic.TarotCard (nullable, on_delete=SET_NULL)` + `significator_reversed: BooleanField(default=False)`. Migration lyric/0006_user_significator_user_significator_reversed.py auto-generated; reversible. Applet seed in applets/0009_seed_my_sig_applet.py adds the row (slug='my-sign', name='Game Sign', context='billboard', default_visible=True, grid_cols=4, grid_rows=6), idempotent update_or_create, reversible unseed() ; **picker page** (my_sign.html): solo lift of `_sig_select_overlay.html` — sig-stage-card scaffold + sig-stat-block + 18-card grid + SAVE SIGN form. Stripped: countdown / WebSocket / polarity / multi-user / reservations. Empty-state branch covers no-equipped-deck (link back to Game Kit; full Brief-redirect + Earthman-Backup fallback deferred to a follow-up sub-sprint). Minimal inline JS: click .sig-card → mark .sig-focused + set hidden card_id + enable SAVE SIGN; FLIP btn toggles .is-reversed + the hidden reversed input. Stage-card preview (name/qualifier population + keyword swap on FLIP) deferred — Sprint 4a follow-up will lift stage-card.js's populator into a non-room context ; **applet partial** (_applet-my-sign.html): renders user.significator's corner-rank + suit-icon + name_title if set; `.my-sign-applet-empty` "No sign chosen yet." otherwise. Header `<h2><a href="{% url 'billboard:my_sign' %}">Game Sign</a></h2>` links to the picker ; **helper refactor** (epic/models.py): extracted `_sig_unique_cards_for_deck(deck_variant)` from `_sig_unique_cards(room)`. New public `personal_sig_cards(user)` parallels `levity_sig_cards / gravity_sig_cards` but pulls from `user.equipped_deck` instead of `room.deck_variant`. Same Note-unlock filtering. No behavior change to existing room callers (3-line wrapper preserves the room signature) ; **TDD trail** — user called out mid-sprint that I'd skipped FTs; pivoted to FT-first. test_bill_my_sign.py (new, 3 FTs): T1 picker renders w. wordmark + target card present in grid; T2 click card → SAVE SIGN enables → POST persists → applet shows the card; T3 fresh user → applet renders empty-state. Initial reds — (a) setUp's `personal_sig_cards(user)` returned [] because StaticLiveServerTestCase → TransactionTestCase flushes migration-seeded DeckVariant + TarotCard between tests; fixed w. `serialized_rollback = True` on the test class (per [[feedback_transactiontestcase_flush]]); (b) h2 wordmark assertion against `MYSIGNIFICATOR` failed against the renamed "Game Sign" + the letter-splitter spreading chars across <span> children — switched to whitespace-stripped substring check `GAMESIGN`; (c) `.fan-corner-rank` text is CSS-hidden so Selenium returns "" — replaced corner-rank assertions w. data-card-id selectors (already-proven reliable from the parent .sig-card lookup) ; ITs (+12, in apps.billboard.tests.integrated.test_views): MySignViewTest (6 — login redirect, 200 + template, 16-card pile, save persists, invalid card_id → 403, GET save redirects); BillboardAppletMySignTest (3 — applet rendered, empty-state w/o sig, card+reversed class w. sig). PersonalSigCardsTest in apps.epic.tests.integrated.test_models (3 — happy path 16 cards, no-equipped-deck → [], schizo Note unlocks Major 1) ; pre-existing change picked up by the commit: my_sea.html branding "Game Sea" (user-modified mid-session; was "My Sea" in Sprint 3 — divergence captured in MEMORY.md follow-up) ; 1020 IT/UT green (+12) in 46s; 3 FTs green in 24s. Sprint 4a unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/) + Sprint 4c (FT helper for mocking the sig choice across other FTs) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:23:24 -04:00
class MySignViewTest(TestCase):
"""Game Sign picker view at /billboard/my-sign/ — Sprint 4a of
[[project-my-sea-roadmap]]. Pins the GET render + POST save contract."""
def setUp(self):
self.user = User.objects.create(email="sign@test.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_my_sign_requires_login(self):
self.client.logout()
response = self.client.get(reverse("billboard:my_sign"))
self.assertRedirects(
response, "/?next=/billboard/my-sign/", fetch_redirect_response=False,
)
def test_my_sign_renders_200(self):
response = self.client.get(reverse("billboard:my_sign"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/billboard/my_sign.html")
def test_my_sign_passes_18_card_pile_for_user_w_no_notes(self):
# The signal auto-equips Earthman; personal_sig_cards returns 16
# middle arcana courts (Majors 0/1 filtered out w.o Schizo/Nomad).
response = self.client.get(reverse("billboard:my_sign"))
self.assertEqual(len(response.context["cards"]), 16)
def test_save_sign_persists_card_and_reversed_flag(self):
from apps.epic.models import personal_sig_cards
target = personal_sig_cards(self.user)[0]
response = self.client.post(
reverse("billboard:save_sign"),
{"card_id": target.id, "reversed": "1"},
)
self.assertRedirects(response, reverse("billboard:my_sign"))
self.user.refresh_from_db()
self.assertEqual(self.user.significator_id, target.id)
self.assertTrue(self.user.significator_reversed)
def test_save_sign_rejects_invalid_card_id(self):
response = self.client.post(
reverse("billboard:save_sign"),
{"card_id": 999999, "reversed": "0"},
)
self.assertEqual(response.status_code, 403)
self.user.refresh_from_db()
self.assertIsNone(self.user.significator_id)
def test_save_sign_get_redirects_back_to_picker(self):
response = self.client.get(reverse("billboard:save_sign"))
self.assertRedirects(response, reverse("billboard:my_sign"))
My Sign DEL btn: clear-sign affordance on SCAN SIGN landing — Sprint 4b-adjacent of My Sea roadmap — TDD Pre-spec'd in [[sprint-my-sea-sign-gate-may19]] as the unblocker for tomorrow's visual verification of 4b's no-sig branch — admin user (@disco) had a saved sig from Sprint 4a testing & there was no in-UI affordance to undo it short of DB surgery. Lands ahead of the deferred 4b visual verify so dev users can toggle between sig/no-sig states on Claudezilla. - Endpoint: `path("my-sign/clear", views.clear_sign, name="clear_sign")` — POST sets `User.significator = None` + `significator_reversed = False`, redirects to picker; GET is a no-mutation redirect to picker (mirrors save_sign's GET handling). `login_required(login_url="/")`. No trailing slash per [[feedback_url_convention_actions_no_trailing_slash]] (action endpoint, not page). - Template (my_sign.html): `<form id="id_clear_sign_form" class="my-sign-clear-form">` w. `<button id="id_clear_sign_btn" class="btn btn-danger">DEL</button>`, rendered ONLY when `current_significator` is set; sits inside `.my-sign-landing` as a sibling of `.room-shell` so it's bound to the landing-phase UI alone (picker phase already has its own NVM unlock affordance on focused thumbnails). - SCSS: anchored bottom-right of `.my-sign-landing` via `position: absolute; bottom: .75rem; right: 1rem` — `.my-sign-landing` gains `position: relative` to scope the absolute. `.btn-danger` carries the destructive treatment; "DEL" mirrors post.html gear menu's DEL convention from [[sprint-post-polish-may13]]. - 3 FTs in new `MySignClearTest` class — covers: btn renders on landing when sig saved (T1, asserts text "DEL" + `.btn-danger` class); btn absent when no sig (T2); click POSTs, reloads, & wipes `User.significator` + `significator_reversed` in DB (T3). - 6 ITs in new `ClearSignViewTest` + `MySignClearAffordanceTemplateTest` — covers: login_required gate, POST wipes both fields w. redirect-back, GET redirects w.o mutation, POST-w/o-existing-sig is idempotent no-op, template renders btn only when sig set, template's form action targets `clear_sign` reverse. - 1029 IT/UT green in 47s (+6 from baseline); 20/20 FT green across test_bill_my_sign + test_game_my_sea in 165s. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:13:20 -04:00
class ClearSignViewTest(TestCase):
"""Clear-sign endpoint — POST `/billboard/my-sign/clear` wipes the
user's significator FK + reversed flag, then redirects to the picker.
Sprint 4b-adjacent (2026-05-19). See [[project-my-sea-roadmap]]."""
def setUp(self):
self.user = User.objects.create(email="clear@test.io")
self.client.force_login(self.user)
_seed_billboard_applets()
from apps.epic.models import personal_sig_cards
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.significator_reversed = True
self.user.save(update_fields=["significator", "significator_reversed"])
def test_clear_sign_requires_login(self):
self.client.logout()
response = self.client.post(reverse("billboard:clear_sign"))
self.assertRedirects(
response, "/?next=/billboard/my-sign/clear",
fetch_redirect_response=False,
)
def test_clear_sign_post_wipes_sig_and_reversed_flag(self):
response = self.client.post(reverse("billboard:clear_sign"))
self.assertRedirects(response, reverse("billboard:my_sign"))
self.user.refresh_from_db()
self.assertIsNone(self.user.significator_id)
self.assertFalse(self.user.significator_reversed)
def test_clear_sign_get_redirects_without_clearing(self):
response = self.client.get(reverse("billboard:clear_sign"))
self.assertRedirects(response, reverse("billboard:my_sign"))
self.user.refresh_from_db()
self.assertEqual(self.user.significator_id, self.target.id)
self.assertTrue(self.user.significator_reversed)
def test_clear_sign_post_with_no_existing_sig_is_noop(self):
self.user.significator = None
self.user.significator_reversed = False
self.user.save(update_fields=["significator", "significator_reversed"])
response = self.client.post(reverse("billboard:clear_sign"))
self.assertRedirects(response, reverse("billboard:my_sign"))
self.user.refresh_from_db()
self.assertIsNone(self.user.significator_id)
self.assertFalse(self.user.significator_reversed)
class MySignClearAffordanceTemplateTest(TestCase):
"""Pin the CLEAR SIGN btn template-render contract — visible only when
`user.significator` is set on the picker page."""
def setUp(self):
self.user = User.objects.create(email="ctmpl@test.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_clear_btn_absent_when_no_sig_saved(self):
response = self.client.get(reverse("billboard:my_sign"))
self.assertNotContains(response, 'id="id_clear_sign_btn"')
def test_clear_btn_present_and_targets_clear_url_when_sig_saved(self):
from apps.epic.models import personal_sig_cards
target = personal_sig_cards(self.user)[0]
self.user.significator = target
self.user.save(update_fields=["significator"])
response = self.client.get(reverse("billboard:my_sign"))
self.assertContains(response, 'id="id_clear_sign_btn"')
self.assertContains(response, reverse("billboard:clear_sign"))
Game Sign picker @ /billboard/my-sign/ + billboard applet — Sprint 4a of My Sea roadmap — TDD User scope (per design conv this session): split the room's sig-select responsibility off into a standalone billboard-context "My Significator" applet — branded "Game Sign" on the surface. Same 18-card pile as room sig-select (16 middle arcana + Major 0 & 1 filtered by Note unlocks); polarity collapses to a single FLIP choice (the FLIP btn in the picker carousel toggles User.significator_reversed). Selection persists globally on the User model + propagates to the billboard's Game Sign applet ; **naming convention locked**: "significator" stays at storage (User.significator FK + User.significator_reversed) + room sig-select context (DRY w. existing template/JS); "Sign" / "Game Sign" is the billboard-surface branding (file my_sign.html, URL /billboard/my-sign/, URL names my_sign + save_sign, applet name "Game Sign", page wordmark "Game Sign", btn label SAVE SIGN). Action URLs don't carry a trailing slash per project convention (/billboard/my-sign/save vs the page's /billboard/my-sign/) ; **schema**: User gains 2 fields — `significator: FK → epic.TarotCard (nullable, on_delete=SET_NULL)` + `significator_reversed: BooleanField(default=False)`. Migration lyric/0006_user_significator_user_significator_reversed.py auto-generated; reversible. Applet seed in applets/0009_seed_my_sig_applet.py adds the row (slug='my-sign', name='Game Sign', context='billboard', default_visible=True, grid_cols=4, grid_rows=6), idempotent update_or_create, reversible unseed() ; **picker page** (my_sign.html): solo lift of `_sig_select_overlay.html` — sig-stage-card scaffold + sig-stat-block + 18-card grid + SAVE SIGN form. Stripped: countdown / WebSocket / polarity / multi-user / reservations. Empty-state branch covers no-equipped-deck (link back to Game Kit; full Brief-redirect + Earthman-Backup fallback deferred to a follow-up sub-sprint). Minimal inline JS: click .sig-card → mark .sig-focused + set hidden card_id + enable SAVE SIGN; FLIP btn toggles .is-reversed + the hidden reversed input. Stage-card preview (name/qualifier population + keyword swap on FLIP) deferred — Sprint 4a follow-up will lift stage-card.js's populator into a non-room context ; **applet partial** (_applet-my-sign.html): renders user.significator's corner-rank + suit-icon + name_title if set; `.my-sign-applet-empty` "No sign chosen yet." otherwise. Header `<h2><a href="{% url 'billboard:my_sign' %}">Game Sign</a></h2>` links to the picker ; **helper refactor** (epic/models.py): extracted `_sig_unique_cards_for_deck(deck_variant)` from `_sig_unique_cards(room)`. New public `personal_sig_cards(user)` parallels `levity_sig_cards / gravity_sig_cards` but pulls from `user.equipped_deck` instead of `room.deck_variant`. Same Note-unlock filtering. No behavior change to existing room callers (3-line wrapper preserves the room signature) ; **TDD trail** — user called out mid-sprint that I'd skipped FTs; pivoted to FT-first. test_bill_my_sign.py (new, 3 FTs): T1 picker renders w. wordmark + target card present in grid; T2 click card → SAVE SIGN enables → POST persists → applet shows the card; T3 fresh user → applet renders empty-state. Initial reds — (a) setUp's `personal_sig_cards(user)` returned [] because StaticLiveServerTestCase → TransactionTestCase flushes migration-seeded DeckVariant + TarotCard between tests; fixed w. `serialized_rollback = True` on the test class (per [[feedback_transactiontestcase_flush]]); (b) h2 wordmark assertion against `MYSIGNIFICATOR` failed against the renamed "Game Sign" + the letter-splitter spreading chars across <span> children — switched to whitespace-stripped substring check `GAMESIGN`; (c) `.fan-corner-rank` text is CSS-hidden so Selenium returns "" — replaced corner-rank assertions w. data-card-id selectors (already-proven reliable from the parent .sig-card lookup) ; ITs (+12, in apps.billboard.tests.integrated.test_views): MySignViewTest (6 — login redirect, 200 + template, 16-card pile, save persists, invalid card_id → 403, GET save redirects); BillboardAppletMySignTest (3 — applet rendered, empty-state w/o sig, card+reversed class w. sig). PersonalSigCardsTest in apps.epic.tests.integrated.test_models (3 — happy path 16 cards, no-equipped-deck → [], schizo Note unlocks Major 1) ; pre-existing change picked up by the commit: my_sea.html branding "Game Sea" (user-modified mid-session; was "My Sea" in Sprint 3 — divergence captured in MEMORY.md follow-up) ; 1020 IT/UT green (+12) in 46s; 3 FTs green in 24s. Sprint 4a unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/) + Sprint 4c (FT helper for mocking the sig choice across other FTs) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:23:24 -04:00
class BillboardAppletMySignTest(TestCase):
"""My Sign applet rendering on /billboard/."""
def setUp(self):
self.user = User.objects.create(email="apllet@test.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_billboard_shows_my_sign_applet(self):
response = self.client.get("/billboard/")
self.assertContains(response, 'id="id_applet_my_sign"')
def test_my_sign_applet_renders_empty_state_when_no_sig(self):
response = self.client.get("/billboard/")
self.assertContains(response, "my-sign-applet-empty")
self.assertContains(response, "No sign chosen yet.")
self.assertNotContains(response, "my-sign-applet-card")
def test_my_sign_applet_renders_card_when_sig_set(self):
from apps.epic.models import personal_sig_cards
target = personal_sig_cards(self.user)[0]
self.user.significator = target
self.user.significator_reversed = True
self.user.save(update_fields=["significator", "significator_reversed"])
response = self.client.get("/billboard/")
self.assertContains(response, "my-sign-applet-card")
self.assertContains(response, f'data-card-id="{target.id}"')
fix: significator_reversed=polarity bug + Pattern B name-swap rendering + qualifier-aware applet faces + sticky PAID DRAW + cooldown anchor on User + stat-block polarity unification across Sig/Sea/Fan/applets Five-thread sprint atop 53cd7af; all 1238 IT/UT green (no FTs run per [[feedback-ft-run-discipline]]). **Thread 1 — User.significator_reversed is the POLARITY axis, not orientation.** The saved sig was rendering as a gravity reversal when the user saved a levity emanation. Root cause: `my_sign.html` JS post-save load called `_toggleOrientation()` whenever `revInput.value==='1'` (SPIN-ing a card whose flag only meant "polarity=levity"); `_applet-my-sign.html` applied `.stage-card--reversed` + `keywords_reversed` for the same flag. Fix: JS drops the `_toggleOrientation()` call (saved sigs are always upright in their polarity, never spun); the applet drops the rotation class, swaps to `my-sign-applet-card--{levity,gravity}` modifier, and always renders `keywords_upright` / "Emanation". `data-polarity` cascades correctly. Memory: [[feedback-significator-reversed-is-polarity]]. **Thread 2 — qualifier rendering on the My Sign + My Sea applets.** Both applets were rendering name only — no qualifier word. Added `TarotCard.applet_face(polarity, reversed)` (model method) + `User.sig_face` (delegator for the saved sig) returning `{title, qualifier, qualifier_first}` payload that mirrors `populateCard` in `stage-card.js`. `latest_draw_slots()` augments each slot dict w. `face`. Templates render `.fan-card-qualifier` + `.fan-card-name` in the order the payload dictates (non-Major: qualifier-above-title; Major+qualifier: title-with-trailing-comma above qualifier; polarity-split: single-line title). Typography matched to title (same bold, same size, same color via `color: inherit` w. polarity-pin at 0,3,0 specificity to beat `_card-deck.scss:376-383`'s 0,2,0 `.fan-card-face .fan-card-name` rule that out-cascades when loaded after gameboard). **Thread 3 — My Sea cooldown bugs.** Two: (a) PAID DRAW button reverted to FREE DRAW after one navigation cycle because `my_sea_paid_draw` deleted the row at commit time — without a row, `quota_spent=False` on next render. (b) Brief's "next free draw at" was anchored to the most recent paid draw, not the original free draw. Fix: new `User.last_free_draw_at` field (set in `my_sea_lock` when a fresh row lands AND user wasn't already in cooldown — i.e., this is a tokenless free draw); paid draws NEVER touch it. New `MySeaDraw.paid_through_at` field stamped at commit time + cleared in `my_sea_lock` when the first card of the paid session lands (one-shot credit per user-spec: "each redraw needs a new token"). `my_sea_paid_draw` no longer deletes the row — clears hand+deposit, sets `paid_through_at`, redirects to `?phase=picker`. View's landing button uses `show_paid_draw` (`deposit_reserved OR paid_through_at`) so PAID DRAW persists across navigation until the paid session's first card lands. Brief reads `user.next_free_draw_at` (= `last_free_draw_at + 24h`) w. row-fallback for legacy test fixtures. 11 new ITs (`MySeaCooldownAnchoredToFreeDrawTest`, `UserFreeDrawCooldownPropertyTest`, expanded `MySeaPhasePickerQueryParamTest`, expanded `my_sea_lock` tests). Existing `test_paid_draw_deletes_active_draw_row` rewritten as `test_paid_draw_preserves_row_and_sets_paid_through_at`. 1 new FT pinning the navigation-persistence regression. Memory: [[feedback-my-sea-cooldown-design]]. **Thread 4 — Pattern B / B' Major reversal name-swap.** Card 34's My Sea applet rendered the reversal as "Animal Powers, Patrilineage" (Patrilineage treated as a qualifier). User-locked semantics: for Majors w. BOTH polarity qualifiers AND a `reversal_qualifier`, the `reversal_qualifier` field carries the NAME SWAP for the reversal face; the polarity qualifier persists across both faces. Affected cards: 2-5 (Pope/Horseman), 10-15 (Elements), 22-33 (Zodiac → Houses), 34-35 (Lunars), 41 (Asteroid Belt). Pattern B': cards 16-18 (Realms — Disco Inferno → Shame etc.) reversal face drops the qualifier entirely; new `TarotCard.reversal_drops_qualifier` BooleanField marks these (set True on 16-18 via `epic/0010_set_reversal_drops_qualifier_realms.py` data migration). `applet_face()` + `stage-card.js::populateCard` both branch on `arcana==MAJOR AND reversal_qualifier AND polarity_qualifier` → Pattern B/B' rendering. Non-Major `reversal_qualifier` semantics unchanged (middle court: "Queen of Crowns" stays as title, "Vacant" renders as the reversal-face qualifier). New data attr `data-reversal-drops-qualifier` added to `my_sign.html`, `_sig_select_overlay.html`, `_tarot_fan.html` so stage-card.js can read it via dataset. `card_dict()` extended w. the same field. 3 new UTs (`TarotCardAppletFaceTest`: Pattern B name swap, Pattern B' qualifier drop, non-Major regression pin). Old `test_reversed_uses_reversal_qualifier_with_comma_for_major` deleted (it pinned the conflated old behavior). **Thread 5 — unified card + stat-block polarity convention across all 6 surfaces** (Sig Select, Sea Select stage modal, Game Kit fan, My Sign applet, My Sea applet, room.html). User-locked: card and adjacent stat block always carry OPPOSITE-polarity bgs (gravity card --priUser → stat block --secUser; levity card --secUser → stat block --priUser). `.is-reversed` (SPIN) is preview-only — never shifts bg. Per-card scoping (NOT page-wide) — drawn sea cards each carry their own polarity from the deck stack; `.sea-stage--{gravity,levity}` parent rules + `.tarot-fan-wrap[data-polarity=...]` parent rules cascade to their respective stat blocks. `game-kit.js` `_populateStage` + `_flipActive` mirror `_polarity` onto `.tarot-fan-wrap` so SCSS can pick it up without touching the stat block directly. Sea-stat-block was previously stuck at --priUser regardless of polarity; fan-stage-block ditto. Both inverted now. Memory: [[feedback-card-polarity-convention]]. **Bundled polish across the same surfaces** (each one a small visible item the user spotted during the sprint): - My Sign applet card: levity polarity flips bg to --secUser + border to --priUser + ink to --quiUser (matches page stage card at `_card-deck.scss:1002-1019`). Gravity stat block flips to --secUser bg w. --quiUser label ink + --priUser keyword ink (matches `_card-deck.scss:1042-1046`). - Qualifier + title share typography (font-size, weight, polarity-color, text-wrap). `.fan-card-face { gap: 0 }` + `line-height: 1.15` so qualifier sits directly above title at the title's own line-height. `.fan-card-arcana { margin-top }` reserves breathing room below. - `.fan-card-qualifier:empty { display: none }` collapses polarity-split / Major-no-qualifier cards cleanly. **Memory recorded**: 1. [[feedback-ft-run-discipline]] — re-pinned 2026-05-23 after I burned a multi-minute full-FT-suite run mid-task. Default loop is IT/UT only. FT runs must be ONE test method by full dotted path; never a whole file; never re-run an already-green FT. 2. [[feedback-significator-reversed-is-polarity]] — the flag is polarity (FLIP), not orientation (SPIN); SPIN never persisted; saved sigs always upright in their polarity. 3. [[feedback-card-polarity-convention]] — opposite-polarity stat-block bg, per-card scoping, SPIN never shifts bg, the full color table. 4. [[feedback-my-sea-cooldown-design]] — cooldown anchored to User.last_free_draw_at, paid draws never reset it, paid_through_at is a sticky one-shot credit, button state machine. **Files** (every uncommitted file folded in — session work + pre-existing modifications): Models / migrations: - `apps/epic/models.py` — `applet_face()` extended w. Pattern B/B' branches; new `reversal_drops_qualifier` BooleanField. - `apps/epic/migrations/0009_reversal_drops_qualifier.py` — schema. - `apps/epic/migrations/0010_set_reversal_drops_qualifier_realms.py` — data migration setting flag True on cards 16-18. - `apps/epic/utils.py` — `card_dict` carries `reversal_drops_qualifier`. - `apps/gameboard/models.py` — `paid_through_at` field; `latest_draw_slots()` attaches `face` payload per slot; `active_draw_for` docstring refreshed. - `apps/gameboard/migrations/0003_myseadraw_paid_through_at.py` — schema. - `apps/lyric/models.py` — `last_free_draw_at` field; `free_draw_cooldown_active` + `next_free_draw_at` props; `sig_face` delegator. - `apps/lyric/migrations/0013_user_last_free_draw_at.py` — schema. Views: - `apps/gameboard/views.py` — `my_sea` view button state machine (`show_paid_draw` / `show_gate_view` / `show_picker`); `my_sea_lock` sets `last_free_draw_at` on free-draw + clears `paid_through_at` on paid-session first card; `my_sea_paid_draw` preserves row + stamps `paid_through_at`. JS: - `apps/epic/static/apps/epic/stage-card.js` — `fromDataset` reads `reversal_drops_qualifier`; `populateCard` branches Pattern B / B' for the reversal face. - `apps/gameboard/static/apps/gameboard/game-kit.js` — mirrors `_polarity` onto `.tarot-fan-wrap` so SCSS can invert the fan-stage-block bg per active card. Templates: - `templates/apps/billboard/my_sign.html` — JS drops `_toggleOrientation()` on saved-sig load; sig-card grid carries `data-reversal-drops-qualifier`. - `templates/apps/billboard/_partials/_applet-my-sign.html` — drops `stage-card--reversed`, adds polarity modifier, renders qualifier via `sig_face` payload, always shows Emanation keywords + label. - `templates/apps/gameboard/_partials/_applet-my-sea.html` — renders qualifier via `slot.face` payload (Pattern B/B' aware). - `templates/apps/gameboard/_partials/_sig_select_overlay.html` + `_tarot_fan.html` — `data-reversal-drops-qualifier` added to sig-card grid + fan cards. - `templates/apps/gameboard/my_sea.html` — landing button form swaps to `show_paid_draw` / `show_gate_view` flags. SCSS: - `static_src/scss/_billboard.scss` — My Sign applet card polarity inversion (levity bg + ink), polarity stat-block inversion (gravity → --secUser bg), qualifier+title shared typography, polarity-aware ink via `color: inherit`. - `static_src/scss/_card-deck.scss` — sea-stat-block polarity rules (`.sea-stage--gravity/levity .sea-stat-block`), fan-stage-block polarity rules (`.tarot-fan-wrap[data-polarity] .fan-stage-block`), comments documenting fallback bgs. - `static_src/scss/_gameboard.scss` — `.my-sea-slot--filled.--gravity/--levity` pin `color: inherit` on `.fan-card-corner`, `.fan-card-qualifier`, `.fan-card-name`, `.fan-card-arcana` (0,3,0 beats global 0,2,0). Slot label keeps original wrap-sibling placement w. `z-index: 2` to render above the dotted bottom border on empty slots. Tests: - `apps/billboard/tests/integrated/test_views.py` — updated `test_my_sign_applet_renders_card_when_sig_set` to assert polarity modifier + qualifier text + Emanation-only; new `test_my_sign_applet_renders_gravity_qualifier_when_not_reversed`. - `apps/epic/tests/unit/test_models.py` — `TarotCardAppletFaceTest` (Pattern B name swap, Pattern B' qualifier drop, non-Major regression pin, polarity-split, reversal qualifier fallback). - `apps/gameboard/tests/integrated/test_views.py` — `MySeaCooldownAnchoredToFreeDrawTest` (5 tests pinning cooldown anchor on User, sticky PAID DRAW, paid-through credit consumption); `UserFreeDrawCooldownPropertyTest` (4 tests); expanded `MySeaPhasePickerQueryParamTest` w. paid-through-shows-PAID-DRAW-btn assertion; expanded `my_sea_lock` tests (free-draw-anchors-last_free_draw_at, paid-draw-leaves-anchor-alone, first-paid-card-consumes-credit); My Sea applet qualifier IT (Major comma format end-to-end). - `functional_tests/test_game_my_sea.py` — `test_paid_draw_commits_token_and_redirects_to_picker` updated to assert row preservation + paid_through_at stamping; new `test_paid_draw_btn_persists_after_navigation_without_card_draw` pinning the user-reported regression. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:06:35 -04:00
# significator_reversed = True ↔ polarity=levity (per convention).
# Saved sigs are POLARITY-only — the orientation (SPIN) axis is not
# persisted, so the applet card renders upright with the levity
# polarity class, NOT rotated via `stage-card--reversed`.
self.assertContains(response, "my-sign-applet-card--levity")
self.assertNotContains(response, "stage-card--reversed")
# Polarity qualifier renders alongside the title (middle court →
# "Elevated" for levity, "Graven" for gravity).
self.assertContains(response, "fan-card-qualifier")
if target.levity_qualifier:
self.assertContains(response, target.levity_qualifier)
# Always the emanation face — keywords_upright + "Emanation" label.
self.assertContains(response, "Emanation")
self.assertNotContains(response, ">Reversal<")
def test_my_sign_applet_renders_gravity_qualifier_when_not_reversed(self):
from apps.epic.models import personal_sig_cards
target = personal_sig_cards(self.user)[0]
self.user.significator = target
self.user.significator_reversed = False
self.user.save(update_fields=["significator", "significator_reversed"])
response = self.client.get("/billboard/")
self.assertContains(response, "my-sign-applet-card--gravity")
if target.gravity_qualifier:
self.assertContains(response, target.gravity_qualifier)